permissions hook helper
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Initial commit: PreToolUse permissions hook for Claude Code

dietrich ayala 1db84f53

+418
+332
chook.mjs
··· 1 + #!/usr/bin/env node 2 + 3 + import { readFileSync } from "fs"; 4 + import { appendFileSync, openSync, closeSync, writeFileSync } from "fs"; 5 + import { resolve } from "path"; 6 + 7 + // --- Config loading --- 8 + 9 + function loadConfig(configPath) { 10 + const text = readFileSync(configPath, "utf-8"); 11 + return parseTOML(text); 12 + } 13 + 14 + // Minimal TOML parser sufficient for our config format 15 + function parseTOML(text) { 16 + const config = { allow: [], deny: [], audit: {} }; 17 + let currentSection = null; 18 + let currentObj = null; 19 + 20 + for (const rawLine of text.split("\n")) { 21 + const line = rawLine.replace(/#.*$/, "").trim(); 22 + if (!line) continue; 23 + 24 + // Array of tables: [[allow]] or [[deny]] 25 + const arrayMatch = line.match(/^\[\[(\w+)\]\]$/); 26 + if (arrayMatch) { 27 + const key = arrayMatch[1]; 28 + if (!config[key]) config[key] = []; 29 + currentObj = {}; 30 + config[key].push(currentObj); 31 + currentSection = "array"; 32 + continue; 33 + } 34 + 35 + // Table: [audit] 36 + const tableMatch = line.match(/^\[(\w+)\]$/); 37 + if (tableMatch) { 38 + const key = tableMatch[1]; 39 + if (!config[key]) config[key] = {}; 40 + currentObj = config[key]; 41 + currentSection = "table"; 42 + continue; 43 + } 44 + 45 + // Key = value 46 + const kvMatch = line.match(/^(\w+)\s*=\s*(.+)$/); 47 + if (kvMatch && currentObj) { 48 + const [, key, rawVal] = kvMatch; 49 + currentObj[key] = parseValue(rawVal.trim()); 50 + } 51 + } 52 + 53 + return config; 54 + } 55 + 56 + function parseValue(val) { 57 + // String (double-quoted) 58 + if (val.startsWith('"') && val.endsWith('"')) { 59 + // Handle TOML escape sequences 60 + return val.slice(1, -1).replace(/\\(.)/g, (_, c) => { 61 + if (c === "n") return "\n"; 62 + if (c === "t") return "\t"; 63 + if (c === "\\") return "\\"; 64 + if (c === '"') return '"'; 65 + return c; 66 + }); 67 + } 68 + // Single-quoted (literal, no escapes) 69 + if (val.startsWith("'") && val.endsWith("'")) { 70 + return val.slice(1, -1); 71 + } 72 + // Boolean 73 + if (val === "true") return true; 74 + if (val === "false") return false; 75 + // Number 76 + if (/^-?\d+(\.\d+)?$/.test(val)) return Number(val); 77 + return val; 78 + } 79 + 80 + // --- Rule compilation --- 81 + 82 + function compileRules(ruleConfigs) { 83 + return ruleConfigs.map((rc) => ({ 84 + tool: rc.tool, 85 + reason: rc.reason || null, 86 + restrict_to_cwd: rc.restrict_to_cwd === true, 87 + file_path_regex: rc.file_path_regex ? new RegExp(rc.file_path_regex) : null, 88 + file_path_exclude_regex: rc.file_path_exclude_regex 89 + ? new RegExp(rc.file_path_exclude_regex) 90 + : null, 91 + command_regex: rc.command_regex ? new RegExp(rc.command_regex) : null, 92 + command_exclude_regex: rc.command_exclude_regex 93 + ? new RegExp(rc.command_exclude_regex) 94 + : null, 95 + subagent_type: rc.subagent_type || null, 96 + prompt_regex: rc.prompt_regex ? new RegExp(rc.prompt_regex) : null, 97 + prompt_exclude_regex: rc.prompt_exclude_regex 98 + ? new RegExp(rc.prompt_exclude_regex) 99 + : null, 100 + })); 101 + } 102 + 103 + // --- Matching --- 104 + 105 + function checkFieldWithExclude(value, mainRegex, excludeRegex) { 106 + if (!mainRegex) return false; 107 + if (!mainRegex.test(value)) return false; 108 + if (excludeRegex && excludeRegex.test(value)) return false; 109 + return true; 110 + } 111 + 112 + function checkFilePath(filePath, rule, cwd) { 113 + if (!filePath) return false; 114 + // resolve to absolute to neutralize ../ tricks 115 + const resolved = resolve(filePath); 116 + if (rule.restrict_to_cwd) { 117 + const cwdPrefix = cwd.endsWith("/") ? cwd : cwd + "/"; 118 + if (!resolved.startsWith(cwdPrefix) && resolved !== cwd) return false; 119 + } 120 + if (rule.file_path_regex) { 121 + if (!rule.file_path_regex.test(resolved)) return false; 122 + } 123 + if (rule.file_path_exclude_regex && rule.file_path_exclude_regex.test(resolved)) { 124 + return false; 125 + } 126 + // restrict_to_cwd alone (no regex) is a valid match 127 + if (!rule.restrict_to_cwd && !rule.file_path_regex) return false; 128 + return true; 129 + } 130 + 131 + function checkRule(rule, toolName, toolInput, cwd) { 132 + if (rule.tool !== toolName) return null; 133 + 134 + let autoReason = null; 135 + 136 + switch (toolName) { 137 + case "Read": 138 + case "Write": 139 + case "Edit": 140 + case "MultiEdit": 141 + case "Glob": { 142 + const filePath = toolInput.file_path || toolInput.notebook_path; 143 + if (checkFilePath(filePath, rule, cwd)) { 144 + autoReason = `Matched rule for ${toolName} with file_path: ${filePath}`; 145 + } 146 + break; 147 + } 148 + case "Bash": { 149 + const command = toolInput.command; 150 + if ( 151 + command && 152 + checkFieldWithExclude( 153 + command, 154 + rule.command_regex, 155 + rule.command_exclude_regex, 156 + ) 157 + ) { 158 + autoReason = `Matched rule for Bash with command: ${command}`; 159 + } 160 + break; 161 + } 162 + case "Task": { 163 + if (rule.subagent_type && toolInput.subagent_type) { 164 + if (rule.subagent_type === toolInput.subagent_type) { 165 + autoReason = `Matched rule for Task with subagent_type: ${toolInput.subagent_type}`; 166 + } 167 + } 168 + if (!autoReason && toolInput.prompt) { 169 + if ( 170 + checkFieldWithExclude( 171 + toolInput.prompt, 172 + rule.prompt_regex, 173 + rule.prompt_exclude_regex, 174 + ) 175 + ) { 176 + autoReason = "Matched rule for Task with prompt pattern"; 177 + } 178 + } 179 + break; 180 + } 181 + case "NotebookEdit": { 182 + const nbPath = toolInput.notebook_path; 183 + if (checkFilePath(nbPath, rule, cwd)) { 184 + autoReason = `Matched rule for NotebookEdit with path: ${nbPath}`; 185 + } 186 + break; 187 + } 188 + } 189 + 190 + if (!autoReason) return null; 191 + return rule.reason || autoReason; 192 + } 193 + 194 + function checkRules(rules, toolName, toolInput, cwd) { 195 + for (const rule of rules) { 196 + const reason = checkRule(rule, toolName, toolInput, cwd); 197 + if (reason) return reason; 198 + } 199 + return null; 200 + } 201 + 202 + // --- Auditing --- 203 + 204 + function truncateStrings(value, maxLen = 256) { 205 + if (typeof value === "string") { 206 + return value.length <= maxLen ? value : value.slice(0, maxLen) + "…"; 207 + } 208 + if (Array.isArray(value)) { 209 + return value.map((v) => truncateStrings(v, maxLen)); 210 + } 211 + if (value && typeof value === "object") { 212 + const out = {}; 213 + for (const [k, v] of Object.entries(value)) { 214 + out[k] = truncateStrings(v, maxLen); 215 + } 216 + return out; 217 + } 218 + return value; 219 + } 220 + 221 + function audit(auditConfig, input, decision, reason) { 222 + if (!auditConfig?.audit_file) return; 223 + const level = auditConfig.audit_level || "matched"; 224 + if (level === "off") return; 225 + if (level === "matched" && decision === "passthrough") return; 226 + 227 + const entry = { 228 + timestamp: new Date().toISOString(), 229 + session_id: input.session_id, 230 + tool_name: input.tool_name, 231 + tool_input: truncateStrings(input.tool_input), 232 + decision, 233 + ...(reason ? { reason } : {}), 234 + cwd: input.cwd, 235 + }; 236 + 237 + try { 238 + appendFileSync(auditConfig.audit_file, JSON.stringify(entry) + "\n"); 239 + } catch (e) { 240 + process.stderr.write(`chook: audit write failed: ${e.message}\n`); 241 + } 242 + } 243 + 244 + // --- Hook output --- 245 + 246 + function makeOutput(decision, reason) { 247 + return { 248 + hookSpecificOutput: { 249 + hookEventName: "PreToolUse", 250 + permissionDecision: decision, 251 + permissionDecisionReason: reason || "", 252 + }, 253 + suppressOutput: true, 254 + }; 255 + } 256 + 257 + // --- CLI --- 258 + 259 + const args = process.argv.slice(2); 260 + const command = args[0]; 261 + 262 + if (command === "validate") { 263 + const configIdx = args.indexOf("--config"); 264 + const configPath = configIdx >= 0 ? args[configIdx + 1] : null; 265 + if (!configPath) { 266 + console.error("Usage: chook validate --config <path>"); 267 + process.exit(1); 268 + } 269 + try { 270 + const config = loadConfig(resolve(configPath)); 271 + const denyRules = compileRules(config.deny || []); 272 + const allowRules = compileRules(config.allow || []); 273 + console.log("Configuration is valid!"); 274 + console.log(` Deny rules: ${denyRules.length}`); 275 + console.log(` Allow rules: ${allowRules.length}`); 276 + console.log(` Audit file: ${config.audit?.audit_file || "(none)"}`); 277 + console.log(` Audit level: ${config.audit?.audit_level || "matched"}`); 278 + } catch (e) { 279 + console.error(`Invalid config: ${e.message}`); 280 + process.exit(1); 281 + } 282 + } else if (command === "run") { 283 + const configIdx = args.indexOf("--config"); 284 + const configPath = configIdx >= 0 ? args[configIdx + 1] : null; 285 + if (!configPath) { 286 + console.error("Usage: chook run --config <path>"); 287 + process.exit(1); 288 + } 289 + 290 + try { 291 + const config = loadConfig(resolve(configPath)); 292 + const denyRules = compileRules(config.deny || []); 293 + const allowRules = compileRules(config.allow || []); 294 + 295 + // Read JSON from stdin 296 + const stdin = readFileSync(0, "utf-8"); 297 + const input = JSON.parse(stdin); 298 + 299 + const toolName = input.tool_name; 300 + const toolInput = input.tool_input; 301 + const cwd = input.cwd; 302 + 303 + // Check deny first 304 + const denyReason = checkRules(denyRules, toolName, toolInput, cwd); 305 + if (denyReason) { 306 + audit(config.audit, input, "deny", denyReason); 307 + process.stdout.write(JSON.stringify(makeOutput("deny", denyReason))); 308 + process.exit(0); 309 + } 310 + 311 + // Check allow 312 + const allowReason = checkRules(allowRules, toolName, toolInput, cwd); 313 + if (allowReason) { 314 + audit(config.audit, input, "allow", allowReason); 315 + process.stdout.write(JSON.stringify(makeOutput("allow", allowReason))); 316 + process.exit(0); 317 + } 318 + 319 + // Passthrough - no output 320 + audit(config.audit, input, "passthrough", null); 321 + } catch (e) { 322 + process.stderr.write(`chook error: ${e.message}\n`); 323 + process.exit(1); 324 + } 325 + } else { 326 + console.error("Usage: chook <run|validate> --config <path>"); 327 + console.error(""); 328 + console.error("Commands:"); 329 + console.error(" run Run as PreToolUse hook (reads JSON from stdin)"); 330 + console.error(" validate Validate a TOML configuration file"); 331 + process.exit(1); 332 + }
+86
example.toml
··· 1 + [audit] 2 + audit_file = "/tmp/chook-audit.json" 3 + # off | matched | all 4 + audit_level = "matched" 5 + 6 + # === DENY RULES (checked first) === 7 + 8 + [[deny]] 9 + tool = "Bash" 10 + command_regex = "^rm .*-rf" 11 + reason = "Recursive force-delete is blocked. Use rm on specific files, or ask the user to run this manually." 12 + 13 + [[deny]] 14 + tool = "Bash" 15 + command_regex = "^sudo " 16 + reason = "sudo is not allowed. Find an alternative that doesn't require elevated privileges." 17 + 18 + [[deny]] 19 + tool = "Read" 20 + file_path_regex = "\\.(env|secret|pem|key)$" 21 + reason = "Reading secrets/credentials is blocked. Use environment variables or ask the user to provide the needed values." 22 + 23 + [[deny]] 24 + tool = "Write" 25 + file_path_regex = "\\.(env|secret|pem|key)$" 26 + reason = "Writing secrets/credentials is blocked. Ask the user to create or update this file manually." 27 + 28 + # Block shell metacharacters in commands — run each command separately or use a package.json script 29 + [[deny]] 30 + tool = "Bash" 31 + command_regex = "&|;|\\||`|\\$\\(" 32 + reason = "Chained/piped commands are not auto-approved. Run each command independently as separate Bash calls, or define a script in package.json and run that instead." 33 + 34 + # === ALLOW RULES (checked after deny) === 35 + 36 + # Allow file ops within the cwd tree where Claude was started 37 + [[allow]] 38 + tool = "Read" 39 + restrict_to_cwd = true 40 + file_path_exclude_regex = "\\.\\." 41 + 42 + [[allow]] 43 + tool = "Write" 44 + restrict_to_cwd = true 45 + file_path_exclude_regex = "\\.\\." 46 + 47 + [[allow]] 48 + tool = "Edit" 49 + restrict_to_cwd = true 50 + file_path_exclude_regex = "\\.\\." 51 + 52 + [[allow]] 53 + tool = "Glob" 54 + restrict_to_cwd = true 55 + 56 + # Always allow /tmp access 57 + [[allow]] 58 + tool = "Read" 59 + file_path_regex = "^/tmp/" 60 + 61 + [[allow]] 62 + tool = "Write" 63 + file_path_regex = "^/tmp/" 64 + 65 + [[allow]] 66 + tool = "Edit" 67 + file_path_regex = "^/tmp/" 68 + 69 + [[allow]] 70 + tool = "Glob" 71 + file_path_regex = "^/tmp/" 72 + 73 + # Allow safe bash commands (no shell injection) 74 + [[allow]] 75 + tool = "Bash" 76 + command_regex = "^(git |npm |node |npx |bun |cargo |make |ls |cat |head |tail |wc |find |grep |rg |which |echo |pwd |mkdir |cp |mv |touch |chmod |brew |gh )" 77 + command_exclude_regex = "&|;|\\||`|\\$\\(" 78 + 79 + # Allow subagents 80 + [[allow]] 81 + tool = "Task" 82 + subagent_type = "Explore" 83 + 84 + [[allow]] 85 + tool = "Task" 86 + subagent_type = "Plan"