permissions hook helper
0
fork

Configure Feed

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

Add allow-last, deny-last, and review commands for fast rule feedback loop

+176 -21
+176 -21
chook.mjs
··· 254 254 }; 255 255 } 256 256 257 + // --- TOML generation --- 258 + 259 + // Escape a string for TOML double-quoted value 260 + function tomlEscape(s) { 261 + return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); 262 + } 263 + 264 + // Escape a string to be a safe regex literal 265 + function regexEscape(s) { 266 + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); 267 + } 268 + 269 + // Build a TOML rule block from an audit entry 270 + function buildRuleFromEntry(entry, ruleType) { 271 + const lines = [`[[${ruleType}]]`, `tool = "${entry.tool_name}"`]; 272 + 273 + const toolInput = entry.tool_input; 274 + switch (entry.tool_name) { 275 + case "Read": 276 + case "Write": 277 + case "Edit": 278 + case "MultiEdit": 279 + case "Glob": 280 + case "NotebookEdit": { 281 + const filePath = toolInput.file_path || toolInput.notebook_path; 282 + if (filePath) { 283 + lines.push(`file_path_regex = "${tomlEscape(regexEscape(filePath))}"`); 284 + } 285 + break; 286 + } 287 + case "Bash": { 288 + const cmd = toolInput.command; 289 + if (cmd) { 290 + // Use the first word(s) as a prefix pattern rather than the full command 291 + const prefix = cmd.split(/\s+/).slice(0, 2).join(" "); 292 + lines.push(`command_regex = "^${tomlEscape(regexEscape(prefix))}"`); 293 + } 294 + break; 295 + } 296 + case "Task": { 297 + if (toolInput.subagent_type) { 298 + lines.push(`subagent_type = "${tomlEscape(toolInput.subagent_type)}"`); 299 + } 300 + break; 301 + } 302 + } 303 + 304 + return lines.join("\n"); 305 + } 306 + 307 + // Append a rule block to the config file 308 + function appendRuleToConfig(configPath, ruleBlock) { 309 + appendFileSync(configPath, "\n" + ruleBlock + "\n"); 310 + } 311 + 312 + // --- Audit reading --- 313 + 314 + function readAuditEntries(auditPath, filter) { 315 + let text; 316 + try { 317 + text = readFileSync(auditPath, "utf-8").trim(); 318 + } catch { 319 + return []; 320 + } 321 + if (!text) return []; 322 + let entries = text.split("\n").map((line) => JSON.parse(line)); 323 + if (filter) { 324 + entries = entries.filter((e) => filter.includes(e.decision)); 325 + } 326 + return entries; 327 + } 328 + 329 + function formatEntry(entry, idx) { 330 + const time = entry.timestamp.replace("T", " ").replace(/\.\d+Z$/, "Z"); 331 + const tool = entry.tool_name; 332 + const decision = entry.decision.toUpperCase(); 333 + let detail = ""; 334 + switch (tool) { 335 + case "Bash": 336 + detail = entry.tool_input.command || ""; 337 + break; 338 + case "Read": 339 + case "Write": 340 + case "Edit": 341 + case "MultiEdit": 342 + case "Glob": 343 + case "NotebookEdit": 344 + detail = entry.tool_input.file_path || entry.tool_input.notebook_path || ""; 345 + break; 346 + case "Task": 347 + detail = entry.tool_input.subagent_type || ""; 348 + break; 349 + default: 350 + detail = JSON.stringify(entry.tool_input).slice(0, 80); 351 + } 352 + // Truncate detail for display 353 + if (detail.length > 80) detail = detail.slice(0, 77) + "..."; 354 + return ` ${String(idx).padStart(3)} ${decision.padEnd(11)} ${tool.padEnd(12)} ${detail} [${time}]`; 355 + } 356 + 257 357 // --- CLI --- 258 358 259 359 const args = process.argv.slice(2); 260 360 const command = args[0]; 261 361 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>"); 362 + function getConfigPath() { 363 + const idx = args.indexOf("--config"); 364 + return idx >= 0 ? resolve(args[idx + 1]) : null; 365 + } 366 + 367 + function getRequiredConfigPath() { 368 + const p = getConfigPath(); 369 + if (!p) { 370 + console.error("Missing --config <path>"); 267 371 process.exit(1); 268 372 } 373 + return p; 374 + } 375 + 376 + if (command === "validate") { 377 + const configPath = getRequiredConfigPath(); 269 378 try { 270 - const config = loadConfig(resolve(configPath)); 379 + const config = loadConfig(configPath); 271 380 const denyRules = compileRules(config.deny || []); 272 381 const allowRules = compileRules(config.allow || []); 273 382 console.log("Configuration is valid!"); ··· 280 389 process.exit(1); 281 390 } 282 391 } 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 - 392 + const configPath = getRequiredConfigPath(); 290 393 try { 291 - const config = loadConfig(resolve(configPath)); 394 + const config = loadConfig(configPath); 292 395 const denyRules = compileRules(config.deny || []); 293 396 const allowRules = compileRules(config.allow || []); 294 397 295 - // Read JSON from stdin 296 398 const stdin = readFileSync(0, "utf-8"); 297 399 const input = JSON.parse(stdin); 298 400 ··· 300 402 const toolInput = input.tool_input; 301 403 const cwd = input.cwd; 302 404 303 - // Check deny first 304 405 const denyReason = checkRules(denyRules, toolName, toolInput, cwd); 305 406 if (denyReason) { 306 407 audit(config.audit, input, "deny", denyReason); ··· 308 409 process.exit(0); 309 410 } 310 411 311 - // Check allow 312 412 const allowReason = checkRules(allowRules, toolName, toolInput, cwd); 313 413 if (allowReason) { 314 414 audit(config.audit, input, "allow", allowReason); ··· 316 416 process.exit(0); 317 417 } 318 418 319 - // Passthrough - no output 320 419 audit(config.audit, input, "passthrough", null); 321 420 } catch (e) { 322 421 process.stderr.write(`chook error: ${e.message}\n`); 323 422 process.exit(1); 324 423 } 424 + } else if (command === "allow-last" || command === "deny-last") { 425 + const configPath = getRequiredConfigPath(); 426 + const ruleType = command === "allow-last" ? "allow" : "deny"; 427 + const config = loadConfig(configPath); 428 + const auditPath = config.audit?.audit_file; 429 + if (!auditPath) { 430 + console.error("No audit_file configured — can't read last entry."); 431 + process.exit(1); 432 + } 433 + const entries = readAuditEntries(auditPath); 434 + if (entries.length === 0) { 435 + console.error("Audit log is empty."); 436 + process.exit(1); 437 + } 438 + const last = entries[entries.length - 1]; 439 + const ruleBlock = buildRuleFromEntry(last, ruleType); 440 + console.log(`Adding ${ruleType} rule from last audit entry:\n`); 441 + console.log(ruleBlock); 442 + console.log(`\n-> Appended to ${configPath}`); 443 + appendRuleToConfig(configPath, ruleBlock); 444 + } else if (command === "review") { 445 + const configPath = getRequiredConfigPath(); 446 + const config = loadConfig(configPath); 447 + const auditPath = config.audit?.audit_file; 448 + if (!auditPath) { 449 + console.error("No audit_file configured."); 450 + process.exit(1); 451 + } 452 + // Show deny and passthrough entries (things that weren't auto-allowed) 453 + const entries = readAuditEntries(auditPath, ["deny", "passthrough"]); 454 + if (entries.length === 0) { 455 + console.log("No deny/passthrough entries in audit log."); 456 + process.exit(0); 457 + } 458 + // Deduplicate by tool+detail to show unique patterns 459 + const seen = new Set(); 460 + const unique = []; 461 + for (const entry of entries) { 462 + const ti = entry.tool_input; 463 + const key = entry.tool_name + ":" + (ti.command || ti.file_path || ti.notebook_path || ti.subagent_type || ""); 464 + if (!seen.has(key)) { 465 + seen.add(key); 466 + unique.push(entry); 467 + } 468 + } 469 + console.log(`Denied/passthrough entries (${unique.length} unique):\n`); 470 + console.log(" IDX DECISION TOOL DETAIL"); 471 + console.log(" --- -------- ---- ------"); 472 + unique.forEach((e, i) => console.log(formatEntry(e, i))); 473 + console.log(`\nTo add a rule, run:`); 474 + console.log(` chook allow-last --config ${configPath} # allow the most recent entry`); 475 + console.log(` chook deny-last --config ${configPath} # deny the most recent entry`); 476 + console.log(`\nOr edit ${configPath} directly for more control.`); 325 477 } else { 326 - console.error("Usage: chook <run|validate> --config <path>"); 478 + console.error("Usage: chook <command> --config <path>"); 327 479 console.error(""); 328 480 console.error("Commands:"); 329 - console.error(" run Run as PreToolUse hook (reads JSON from stdin)"); 330 - console.error(" validate Validate a TOML configuration file"); 481 + console.error(" run Run as PreToolUse hook (reads JSON from stdin)"); 482 + console.error(" validate Validate a TOML configuration file"); 483 + console.error(" allow-last Add an allow rule from the last audit entry"); 484 + console.error(" deny-last Add a deny rule from the last audit entry"); 485 + console.error(" review Show denied/passthrough entries from the audit log"); 331 486 process.exit(1); 332 487 }