permissions hook helper
0
fork

Configure Feed

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

at main 847 lines 27 kB view raw
1#!/usr/bin/env node 2 3import { readFileSync, existsSync, unlinkSync } from "fs"; 4import { appendFileSync, openSync, closeSync, writeFileSync } from "fs"; 5import { spawn } from "child_process"; 6import { resolve } from "path"; 7import { homedir } from "os"; 8 9const DEFAULT_CONFIG = resolve(homedir(), ".config/chook.toml"); 10 11// --- Config loading --- 12 13function loadConfig(configPath) { 14 const text = readFileSync(configPath, "utf-8"); 15 return parseTOML(text); 16} 17 18// Minimal TOML parser sufficient for our config format 19function parseTOML(text) { 20 const config = { allow: [], deny: [], audit: {} }; 21 let currentSection = null; 22 let currentObj = null; 23 24 for (const rawLine of text.split("\n")) { 25 const line = rawLine.replace(/#.*$/, "").trim(); 26 if (!line) continue; 27 28 // Array of tables: [[allow]] or [[deny]] 29 const arrayMatch = line.match(/^\[\[(\w+)\]\]$/); 30 if (arrayMatch) { 31 const key = arrayMatch[1]; 32 if (!config[key]) config[key] = []; 33 currentObj = {}; 34 config[key].push(currentObj); 35 currentSection = "array"; 36 continue; 37 } 38 39 // Table: [audit] 40 const tableMatch = line.match(/^\[(\w+)\]$/); 41 if (tableMatch) { 42 const key = tableMatch[1]; 43 if (!config[key]) config[key] = {}; 44 currentObj = config[key]; 45 currentSection = "table"; 46 continue; 47 } 48 49 // Key = value 50 const kvMatch = line.match(/^(\w+)\s*=\s*(.+)$/); 51 if (kvMatch && currentObj) { 52 const [, key, rawVal] = kvMatch; 53 currentObj[key] = parseValue(rawVal.trim()); 54 } 55 } 56 57 return config; 58} 59 60function parseValue(val) { 61 // String (double-quoted) 62 if (val.startsWith('"') && val.endsWith('"')) { 63 // Handle TOML escape sequences 64 return val.slice(1, -1).replace(/\\(.)/g, (_, c) => { 65 if (c === "n") return "\n"; 66 if (c === "t") return "\t"; 67 if (c === "\\") return "\\"; 68 if (c === '"') return '"'; 69 return c; 70 }); 71 } 72 // Single-quoted (literal, no escapes) 73 if (val.startsWith("'") && val.endsWith("'")) { 74 return val.slice(1, -1); 75 } 76 // Boolean 77 if (val === "true") return true; 78 if (val === "false") return false; 79 // Number 80 if (/^-?\d+(\.\d+)?$/.test(val)) return Number(val); 81 return val; 82} 83 84// --- Rule compilation --- 85 86function compileRules(ruleConfigs) { 87 return ruleConfigs.map((rc) => ({ 88 tool: rc.tool, 89 reason: rc.reason || null, 90 restrict_to_cwd: rc.restrict_to_cwd === true, 91 file_path_regex: rc.file_path_regex ? new RegExp(rc.file_path_regex) : null, 92 file_path_exclude_regex: rc.file_path_exclude_regex 93 ? new RegExp(rc.file_path_exclude_regex) 94 : null, 95 command_regex: rc.command_regex ? new RegExp(rc.command_regex) : null, 96 command_exclude_regex: rc.command_exclude_regex 97 ? new RegExp(rc.command_exclude_regex) 98 : null, 99 subagent_type: rc.subagent_type || null, 100 prompt_regex: rc.prompt_regex ? new RegExp(rc.prompt_regex) : null, 101 prompt_exclude_regex: rc.prompt_exclude_regex 102 ? new RegExp(rc.prompt_exclude_regex) 103 : null, 104 })); 105} 106 107// --- Matching --- 108 109function checkFieldWithExclude(value, mainRegex, excludeRegex) { 110 if (!mainRegex) return false; 111 if (!mainRegex.test(value)) return false; 112 if (excludeRegex && excludeRegex.test(value)) return false; 113 return true; 114} 115 116function checkFilePath(filePath, rule, cwd) { 117 if (!filePath) return false; 118 // resolve to absolute to neutralize ../ tricks 119 const resolved = resolve(filePath); 120 if (rule.restrict_to_cwd) { 121 const cwdPrefix = cwd.endsWith("/") ? cwd : cwd + "/"; 122 if (!resolved.startsWith(cwdPrefix) && resolved !== cwd) return false; 123 } 124 if (rule.file_path_regex) { 125 if (!rule.file_path_regex.test(resolved)) return false; 126 } 127 if (rule.file_path_exclude_regex && rule.file_path_exclude_regex.test(resolved)) { 128 return false; 129 } 130 // restrict_to_cwd alone (no regex) is a valid match 131 if (!rule.restrict_to_cwd && !rule.file_path_regex) return false; 132 return true; 133} 134 135function checkRule(rule, toolName, toolInput, cwd) { 136 if (rule.tool !== toolName) return null; 137 138 let autoReason = null; 139 140 switch (toolName) { 141 case "Read": 142 case "Write": 143 case "Edit": 144 case "MultiEdit": 145 case "Glob": { 146 const filePath = toolInput.file_path || toolInput.notebook_path; 147 if (checkFilePath(filePath, rule, cwd)) { 148 autoReason = `Matched rule for ${toolName} with file_path: ${filePath}`; 149 } 150 break; 151 } 152 case "Bash": { 153 const command = toolInput.command; 154 if ( 155 command && 156 checkFieldWithExclude( 157 command, 158 rule.command_regex, 159 rule.command_exclude_regex, 160 ) 161 ) { 162 autoReason = `Matched rule for Bash with command: ${command}`; 163 } 164 break; 165 } 166 case "Task": { 167 if (rule.subagent_type && toolInput.subagent_type) { 168 if (rule.subagent_type === toolInput.subagent_type) { 169 autoReason = `Matched rule for Task with subagent_type: ${toolInput.subagent_type}`; 170 } 171 } 172 if (!autoReason && toolInput.prompt) { 173 if ( 174 checkFieldWithExclude( 175 toolInput.prompt, 176 rule.prompt_regex, 177 rule.prompt_exclude_regex, 178 ) 179 ) { 180 autoReason = "Matched rule for Task with prompt pattern"; 181 } 182 } 183 break; 184 } 185 case "NotebookEdit": { 186 const nbPath = toolInput.notebook_path; 187 if (checkFilePath(nbPath, rule, cwd)) { 188 autoReason = `Matched rule for NotebookEdit with path: ${nbPath}`; 189 } 190 break; 191 } 192 } 193 194 if (!autoReason) return null; 195 return rule.reason || autoReason; 196} 197 198function checkRules(rules, toolName, toolInput, cwd) { 199 for (const rule of rules) { 200 const reason = checkRule(rule, toolName, toolInput, cwd); 201 if (reason) return reason; 202 } 203 return null; 204} 205 206// --- Auditing --- 207 208function truncateStrings(value, maxLen = 256) { 209 if (typeof value === "string") { 210 return value.length <= maxLen ? value : value.slice(0, maxLen) + "…"; 211 } 212 if (Array.isArray(value)) { 213 return value.map((v) => truncateStrings(v, maxLen)); 214 } 215 if (value && typeof value === "object") { 216 const out = {}; 217 for (const [k, v] of Object.entries(value)) { 218 out[k] = truncateStrings(v, maxLen); 219 } 220 return out; 221 } 222 return value; 223} 224 225function audit(auditConfig, input, decision, reason) { 226 if (!auditConfig?.audit_file) return; 227 const level = auditConfig.audit_level || "matched"; 228 if (level === "off") return; 229 if (level === "matched" && decision === "passthrough") return; 230 231 const entry = { 232 timestamp: new Date().toISOString(), 233 session_id: input.session_id, 234 tool_name: input.tool_name, 235 tool_input: truncateStrings(input.tool_input), 236 decision, 237 ...(reason ? { reason } : {}), 238 cwd: input.cwd, 239 }; 240 241 try { 242 appendFileSync(auditConfig.audit_file, JSON.stringify(entry) + "\n"); 243 } catch (e) { 244 process.stderr.write(`chook: audit write failed: ${e.message}\n`); 245 } 246} 247 248// --- Pending notification --- 249 250const NOTIFY_DELAY_SECS = 15; 251 252function pendingFile(sessionId) { 253 return `/tmp/chook-pending-${sessionId}`; 254} 255 256function scheduleNotification(sessionId, toolName, detail) { 257 const short = detail.length > 80 ? detail.slice(0, 77) + "..." : detail; 258 const msg = short.replace(/'/g, "'\\''"); 259 const pf = pendingFile(sessionId); 260 // The script checks if the pending file still exists before notifying. 261 // PostToolUse deletes it to cancel. 262 const script = `sleep ${NOTIFY_DELAY_SECS}; if [ -f '${pf}' ]; then osascript -e 'display notification "${msg}" with title "chook: ${toolName} needs approval"'; rm -f '${pf}'; fi`; 263 const child = spawn("sh", ["-c", script], { 264 detached: true, 265 stdio: "ignore", 266 }); 267 child.unref(); 268 try { 269 writeFileSync(pf, String(child.pid)); 270 } catch {} 271} 272 273function cancelNotification(sessionId) { 274 // Just delete the file — the sleeping script checks for it before notifying 275 try { 276 unlinkSync(pendingFile(sessionId)); 277 } catch {} 278} 279 280// --- Hook output --- 281 282function makeOutput(decision, reason) { 283 return { 284 hookSpecificOutput: { 285 hookEventName: "PreToolUse", 286 permissionDecision: decision, 287 permissionDecisionReason: reason || "", 288 }, 289 suppressOutput: true, 290 }; 291} 292 293// --- TOML generation --- 294 295// Escape a string for TOML double-quoted value 296function tomlEscape(s) { 297 return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); 298} 299 300// Escape a string to be a safe regex literal 301function regexEscape(s) { 302 return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); 303} 304 305// Build a TOML rule block from an audit entry 306function buildRuleFromEntry(entry, ruleType) { 307 const lines = [`[[${ruleType}]]`, `tool = "${entry.tool_name}"`]; 308 309 const toolInput = entry.tool_input; 310 switch (entry.tool_name) { 311 case "Read": 312 case "Write": 313 case "Edit": 314 case "MultiEdit": 315 case "Glob": 316 case "NotebookEdit": { 317 const filePath = toolInput.file_path || toolInput.notebook_path; 318 if (filePath) { 319 lines.push(`file_path_regex = "${tomlEscape(regexEscape(filePath))}"`); 320 } 321 break; 322 } 323 case "Bash": { 324 const cmd = toolInput.command; 325 if (cmd) { 326 // Use the first word(s) as a prefix pattern rather than the full command 327 const prefix = cmd.split(/\s+/).slice(0, 2).join(" "); 328 lines.push(`command_regex = "^${tomlEscape(regexEscape(prefix))}"`); 329 } 330 break; 331 } 332 case "Task": { 333 if (toolInput.subagent_type) { 334 lines.push(`subagent_type = "${tomlEscape(toolInput.subagent_type)}"`); 335 } 336 break; 337 } 338 } 339 340 return lines.join("\n"); 341} 342 343// Append a rule block to the config file 344function appendRuleToConfig(configPath, ruleBlock) { 345 appendFileSync(configPath, "\n" + ruleBlock + "\n"); 346} 347 348// --- Audit reading --- 349 350function readAuditEntries(auditPath, filter) { 351 let text; 352 try { 353 text = readFileSync(auditPath, "utf-8").trim(); 354 } catch { 355 return []; 356 } 357 if (!text) return []; 358 let entries = text.split("\n").map((line) => JSON.parse(line)); 359 if (filter) { 360 entries = entries.filter((e) => filter.includes(e.decision)); 361 } 362 return entries; 363} 364 365function formatEntry(entry, idx) { 366 const time = entry.timestamp.replace("T", " ").replace(/\.\d+Z$/, "Z"); 367 const tool = entry.tool_name; 368 const decision = entry.decision.toUpperCase(); 369 let detail = ""; 370 switch (tool) { 371 case "Bash": 372 detail = entry.tool_input.command || ""; 373 break; 374 case "Read": 375 case "Write": 376 case "Edit": 377 case "MultiEdit": 378 case "Glob": 379 case "NotebookEdit": 380 detail = entry.tool_input.file_path || entry.tool_input.notebook_path || ""; 381 break; 382 case "Task": 383 detail = entry.tool_input.subagent_type || ""; 384 break; 385 default: 386 detail = JSON.stringify(entry.tool_input).slice(0, 80); 387 } 388 // Truncate detail for display 389 if (detail.length > 80) detail = detail.slice(0, 77) + "..."; 390 return ` ${String(idx).padStart(3)} ${decision.padEnd(11)} ${tool.padEnd(12)} ${detail} [${time}]`; 391} 392 393// --- CLI --- 394 395const args = process.argv.slice(2); 396const command = args[0]; 397 398function getConfigPath() { 399 const idx = args.indexOf("--config"); 400 if (idx >= 0) return resolve(args[idx + 1]); 401 if (existsSync(DEFAULT_CONFIG)) return DEFAULT_CONFIG; 402 console.error(`No --config specified and ${DEFAULT_CONFIG} not found.`); 403 process.exit(1); 404} 405 406if (command === "validate") { 407 const configPath = getConfigPath(); 408 try { 409 const config = loadConfig(configPath); 410 const denyRules = compileRules(config.deny || []); 411 const allowRules = compileRules(config.allow || []); 412 console.log("Configuration is valid!"); 413 console.log(` Deny rules: ${denyRules.length}`); 414 console.log(` Allow rules: ${allowRules.length}`); 415 console.log(` Audit file: ${config.audit?.audit_file || "(none)"}`); 416 console.log(` Audit level: ${config.audit?.audit_level || "matched"}`); 417 } catch (e) { 418 console.error(`Invalid config: ${e.message}`); 419 process.exit(1); 420 } 421} else if (command === "run") { 422 const configPath = getConfigPath(); 423 try { 424 const config = loadConfig(configPath); 425 const denyRules = compileRules(config.deny || []); 426 const allowRules = compileRules(config.allow || []); 427 428 const stdin = readFileSync(0, "utf-8"); 429 const input = JSON.parse(stdin); 430 431 const toolName = input.tool_name; 432 const toolInput = input.tool_input; 433 const cwd = input.cwd; 434 435 const denyReason = checkRules(denyRules, toolName, toolInput, cwd); 436 if (denyReason) { 437 audit(config.audit, input, "deny", denyReason); 438 process.stdout.write(JSON.stringify(makeOutput("deny", denyReason))); 439 process.exit(0); 440 } 441 442 const allowReason = checkRules(allowRules, toolName, toolInput, cwd); 443 if (allowReason) { 444 audit(config.audit, input, "allow", allowReason); 445 process.stdout.write(JSON.stringify(makeOutput("allow", allowReason))); 446 process.exit(0); 447 } 448 449 audit(config.audit, input, "passthrough", null); 450 // Only notify for tools that Claude actually prompts for 451 // Read-only tools (Grep, Glob, Read, LS) are never prompted 452 const promptableTools = new Set(["Bash", "Write", "Edit", "MultiEdit", "NotebookEdit", "Task", "WebFetch", "WebSearch"]); 453 if (promptableTools.has(toolName)) { 454 const detail = toolInput.command || toolInput.file_path || toolInput.notebook_path || toolInput.url || toolInput.query || toolName; 455 scheduleNotification(input.session_id, toolName, detail); 456 } 457 } catch (e) { 458 process.stderr.write(`chook error: ${e.message}\n`); 459 process.exit(1); 460 } 461} else if (command === "post") { 462 // PostToolUse handler — cancel any pending notification 463 try { 464 const stdin = readFileSync(0, "utf-8"); 465 const input = JSON.parse(stdin); 466 cancelNotification(input.session_id); 467 } catch {} 468} else if (command === "allow-last" || command === "deny-last") { 469 const configPath = getConfigPath(); 470 const ruleType = command === "allow-last" ? "allow" : "deny"; 471 const config = loadConfig(configPath); 472 const auditPath = config.audit?.audit_file; 473 if (!auditPath) { 474 console.error("No audit_file configured — can't read last entry."); 475 process.exit(1); 476 } 477 const entries = readAuditEntries(auditPath); 478 if (entries.length === 0) { 479 console.error("Audit log is empty."); 480 process.exit(1); 481 } 482 const last = entries[entries.length - 1]; 483 const ruleBlock = buildRuleFromEntry(last, ruleType); 484 console.log(`Adding ${ruleType} rule from last audit entry:\n`); 485 console.log(ruleBlock); 486 console.log(`\n-> Appended to ${configPath}`); 487 appendRuleToConfig(configPath, ruleBlock); 488} else if (command === "review") { 489 const configPath = getConfigPath(); 490 const config = loadConfig(configPath); 491 const auditPath = config.audit?.audit_file; 492 if (!auditPath) { 493 console.error("No audit_file configured."); 494 process.exit(1); 495 } 496 const rawEntries = readAuditEntries(auditPath, ["deny", "passthrough"]); 497 // Filter out entries that current rules would now handle 498 const allowRules = compileRules(config.allow || []); 499 const denyRules = compileRules(config.deny || []); 500 const entries = rawEntries.filter((e) => { 501 const cwd = e.cwd || "/"; 502 // If a deny rule now matches, it's handled 503 if (checkRules(denyRules, e.tool_name, e.tool_input, cwd)) return false; 504 // If an allow rule now matches, it's handled 505 if (checkRules(allowRules, e.tool_name, e.tool_input, cwd)) return false; 506 return true; 507 }); 508 if (entries.length === 0) { 509 console.log("No unhandled deny/passthrough entries in audit log."); 510 process.exit(0); 511 } 512 513 // --- Grouping logic --- 514 // Group entries into generalized suggestions 515 516 function groupEntries(entries) { 517 const groups = []; 518 519 // Separate by tool type 520 const bashEntries = entries.filter((e) => e.tool_name === "Bash"); 521 const fileEntries = entries.filter((e) => 522 ["Read", "Write", "Edit", "MultiEdit", "Glob", "NotebookEdit"].includes(e.tool_name), 523 ); 524 const taskEntries = entries.filter((e) => e.tool_name === "Task"); 525 const otherEntries = entries.filter( 526 (e) => 527 !["Bash", "Read", "Write", "Edit", "MultiEdit", "Glob", "NotebookEdit", "Task"].includes( 528 e.tool_name, 529 ), 530 ); 531 532 // Dangerous flag patterns per command — these get an exclude regex 533 // so the broad allow doesn't cover dangerous variants 534 const dangerousFlags = { 535 rm: "-r|-rf|--recursive", 536 chmod: "-R|--recursive", 537 chown: "-R|--recursive", 538 }; 539 540 // Group bash by first word (the command) 541 // Filter out compound commands (already blocked by metachar deny) 542 const metaCharRe = /[&;|`]|\$\(/; 543 const bashByCmd = new Map(); 544 for (const e of bashEntries) { 545 const cmd = e.tool_input.command || ""; 546 // Skip compound commands — they're blocked separately 547 if (metaCharRe.test(cmd)) continue; 548 const firstWord = cmd.split(/\s+/)[0]; 549 if (!firstWord) continue; 550 if (!bashByCmd.has(firstWord)) bashByCmd.set(firstWord, []); 551 bashByCmd.get(firstWord).push(e); 552 } 553 for (const [cmd, cmdEntries] of bashByCmd) { 554 const subcommands = [ 555 ...new Set( 556 cmdEntries.map((e) => { 557 const parts = (e.tool_input.command || "").split(/\s+/); 558 return parts[1] || ""; 559 }), 560 ), 561 ].filter(Boolean); 562 const summary = 563 subcommands.length <= 4 564 ? subcommands.join(", ") 565 : subcommands.slice(0, 3).join(", ") + `, +${subcommands.length - 3} more`; 566 567 let rule = `[[allow]]\ntool = "Bash"\ncommand_regex = "^${tomlEscape(regexEscape(cmd))} "`; 568 if (dangerousFlags[cmd]) { 569 rule += `\ncommand_exclude_regex = "${tomlEscape(dangerousFlags[cmd])}"`; 570 } 571 572 groups.push({ 573 label: `Bash: ${cmd} (${cmdEntries.length} hits: ${summary})`, 574 rule, 575 entries: cmdEntries, 576 }); 577 } 578 579 // Group file ops by tool, then by whether they're under a common cwd 580 // First collect all cwds and paths per tool 581 const fileByTool = new Map(); 582 for (const e of fileEntries) { 583 const tool = e.tool_name; 584 if (!fileByTool.has(tool)) fileByTool.set(tool, []); 585 fileByTool.get(tool).push(e); 586 } 587 588 for (const [tool, toolEntries] of fileByTool) { 589 // Check if all paths fall under their respective cwds 590 const cwdPaths = toolEntries.map((e) => ({ 591 cwd: e.cwd, 592 path: e.tool_input.file_path || e.tool_input.notebook_path || "", 593 })); 594 595 const underCwd = cwdPaths.filter((p) => { 596 const cwdPrefix = p.cwd.endsWith("/") ? p.cwd : p.cwd + "/"; 597 return p.path.startsWith(cwdPrefix) || p.path === p.cwd; 598 }); 599 600 const outsideCwd = cwdPaths.filter((p) => { 601 const cwdPrefix = p.cwd.endsWith("/") ? p.cwd : p.cwd + "/"; 602 return !p.path.startsWith(cwdPrefix) && p.path !== p.cwd; 603 }); 604 605 if (underCwd.length > 0) { 606 // Find common prefix among the cwds 607 const cwds = [...new Set(underCwd.map((p) => p.cwd))]; 608 const cwdDisplay = 609 cwds.length === 1 ? cwds[0] : `${cwds.length} project dirs`; 610 611 groups.push({ 612 label: `${tool}: ${underCwd.length} files under cwd (${cwdDisplay})`, 613 rule: `[[allow]]\ntool = "${tool}"\nrestrict_to_cwd = true\nfile_path_exclude_regex = "\\\\.\\\\."` , 614 entries: toolEntries.filter((e) => { 615 const p = e.tool_input.file_path || e.tool_input.notebook_path || ""; 616 const cwdPrefix = e.cwd.endsWith("/") ? e.cwd : e.cwd + "/"; 617 return p.startsWith(cwdPrefix) || p === e.cwd; 618 }), 619 }); 620 } 621 622 if (outsideCwd.length > 0) { 623 // Group by common path prefix 624 const paths = outsideCwd.map((p) => p.path); 625 const prefix = commonPrefix(paths); 626 const prefixDir = prefix.includes("/") 627 ? prefix.slice(0, prefix.lastIndexOf("/") + 1) 628 : ""; 629 630 if (prefixDir) { 631 groups.push({ 632 label: `${tool}: ${outsideCwd.length} files under ${prefixDir}`, 633 rule: `[[allow]]\ntool = "${tool}"\nfile_path_regex = "^${tomlEscape(regexEscape(prefixDir))}"`, 634 entries: toolEntries.filter((e) => { 635 const p = e.tool_input.file_path || e.tool_input.notebook_path || ""; 636 return !p.startsWith(e.cwd) && p.startsWith(prefixDir); 637 }), 638 }); 639 } else { 640 // No common prefix, list individually 641 for (const e of toolEntries.filter((e) => { 642 const p = e.tool_input.file_path || e.tool_input.notebook_path || ""; 643 const cwdPrefix = e.cwd.endsWith("/") ? e.cwd : e.cwd + "/"; 644 return !p.startsWith(cwdPrefix) && p !== e.cwd; 645 })) { 646 const p = e.tool_input.file_path || e.tool_input.notebook_path || ""; 647 groups.push({ 648 label: `${tool}: ${p}`, 649 rule: buildRuleFromEntry(e, "allow"), 650 entries: [e], 651 }); 652 } 653 } 654 } 655 } 656 657 // Group tasks by subagent_type 658 const taskByType = new Map(); 659 for (const e of taskEntries) { 660 const t = e.tool_input.subagent_type || "unknown"; 661 if (!taskByType.has(t)) taskByType.set(t, []); 662 taskByType.get(t).push(e); 663 } 664 for (const [type, typeEntries] of taskByType) { 665 groups.push({ 666 label: `Task: ${type} (${typeEntries.length} hits)`, 667 rule: `[[allow]]\ntool = "Task"\nsubagent_type = "${tomlEscape(type)}"`, 668 entries: typeEntries, 669 }); 670 } 671 672 // Others individually 673 for (const e of otherEntries) { 674 groups.push({ 675 label: `${e.tool_name}: ${JSON.stringify(e.tool_input).slice(0, 60)}`, 676 rule: buildRuleFromEntry(e, "allow"), 677 entries: [e], 678 }); 679 } 680 681 return groups; 682 } 683 684 function commonPrefix(strings) { 685 if (strings.length === 0) return ""; 686 if (strings.length === 1) return strings[0]; 687 let prefix = strings[0]; 688 for (let i = 1; i < strings.length; i++) { 689 while (!strings[i].startsWith(prefix)) { 690 prefix = prefix.slice(0, -1); 691 if (!prefix) return ""; 692 } 693 } 694 return prefix; 695 } 696 697 const groups = groupEntries(entries); 698 if (groups.length === 0) { 699 console.log("No actionable entries found."); 700 process.exit(0); 701 } 702 703 // Interactive review on groups 704 let cursor = 0; 705 let added = 0; 706 707 function render() { 708 const rows = process.stdout.rows || 24; 709 const cols = process.stdout.columns || 100; 710 711 const currentGroup = groups[cursor]; 712 const ruleLines = currentGroup.rule.split("\n"); 713 // Show a few example entries from the group 714 const exampleCount = Math.min(3, currentGroup.entries.length); 715 const footerHeight = ruleLines.length + exampleCount + 5; 716 const headerHeight = 2; 717 const pageSize = Math.max(3, rows - headerHeight - footerHeight); 718 719 process.stdout.write("\x1b[2J\x1b[H"); 720 721 console.log( 722 `chook review - ${groups.length} suggestions (${cursor + 1}/${groups.length})`, 723 ); 724 console.log(""); 725 726 const halfPage = Math.floor(pageSize / 2); 727 let start = Math.max(0, cursor - halfPage); 728 let end = Math.min(groups.length, start + pageSize); 729 if (end - start < pageSize) start = Math.max(0, end - pageSize); 730 731 for (let i = start; i < end; i++) { 732 const g = groups[i]; 733 const marker = i === cursor ? ">" : " "; 734 let label = g.label; 735 const maxLen = cols - 6; 736 if (label.length > maxLen) label = label.slice(0, maxLen - 3) + "..."; 737 738 if (i === cursor) { 739 process.stdout.write(`\x1b[7m ${marker} ${label}\x1b[0m\n`); 740 } else { 741 console.log(` ${marker} ${label}`); 742 } 743 } 744 745 // Footer: examples + proposed rule + keys 746 console.log(""); 747 console.log(" \x1b[2mSeen in audit log:\x1b[0m"); 748 for (let i = 0; i < exampleCount; i++) { 749 const e = currentGroup.entries[i]; 750 const ti = e.tool_input; 751 let detail = 752 ti.command || ti.file_path || ti.notebook_path || ti.subagent_type || ""; 753 if (detail.length > cols - 8) detail = detail.slice(0, cols - 11) + "..."; 754 console.log(` \x1b[2m${detail}\x1b[0m`); 755 } 756 if (currentGroup.entries.length > exampleCount) { 757 console.log( 758 ` \x1b[2m...and ${currentGroup.entries.length - exampleCount} more\x1b[0m`, 759 ); 760 } 761 console.log(""); 762 console.log(" Rule to add (simple, non-compound commands only):"); 763 for (const line of ruleLines) { 764 console.log(` \x1b[1m${line}\x1b[0m`); 765 } 766 console.log(""); 767 console.log( 768 " \x1b[2m[j/k] navigate [a] allow [d] deny [s] skip [q] quit\x1b[0m", 769 ); 770 } 771 772 process.stdin.setRawMode(true); 773 process.stdin.resume(); 774 process.stdin.setEncoding("utf-8"); 775 776 render(); 777 778 process.stdin.on("data", (key) => { 779 if (key === "\x03" || key === "q") { 780 process.stdout.write("\x1b[2J\x1b[H"); 781 console.log( 782 added > 0 783 ? `Done. Added ${added} rule(s) to ${configPath}` 784 : "No rules added.", 785 ); 786 process.exit(0); 787 } 788 789 if (key === "\x1b[A" || key === "k") { 790 if (cursor > 0) cursor--; 791 render(); 792 return; 793 } 794 795 if (key === "\x1b[B" || key === "j") { 796 if (cursor < groups.length - 1) cursor++; 797 render(); 798 return; 799 } 800 801 if (key === "a") { 802 appendRuleToConfig(configPath, groups[cursor].rule); 803 added++; 804 groups.splice(cursor, 1); 805 if (groups.length === 0) { 806 process.stdout.write("\x1b[2J\x1b[H"); 807 console.log(`Done. Added ${added} rule(s) to ${configPath}`); 808 process.exit(0); 809 } 810 if (cursor >= groups.length) cursor = groups.length - 1; 811 render(); 812 return; 813 } 814 815 if (key === "d") { 816 // For deny, swap [[allow]] to [[deny]] in the rule 817 const denyRule = groups[cursor].rule.replace("[[allow]]", "[[deny]]"); 818 appendRuleToConfig(configPath, denyRule); 819 added++; 820 groups.splice(cursor, 1); 821 if (groups.length === 0) { 822 process.stdout.write("\x1b[2J\x1b[H"); 823 console.log(`Done. Added ${added} rule(s) to ${configPath}`); 824 process.exit(0); 825 } 826 if (cursor >= groups.length) cursor = groups.length - 1; 827 render(); 828 return; 829 } 830 831 if (key === "s") { 832 if (cursor < groups.length - 1) cursor++; 833 render(); 834 return; 835 } 836 }); 837} else { 838 console.error("Usage: chook <command> --config <path>"); 839 console.error(""); 840 console.error("Commands:"); 841 console.error(" run Run as PreToolUse hook (reads JSON from stdin)"); 842 console.error(" validate Validate a TOML configuration file"); 843 console.error(" allow-last Add an allow rule from the last audit entry"); 844 console.error(" deny-last Add a deny rule from the last audit entry"); 845 console.error(" review Show denied/passthrough entries from the audit log"); 846 process.exit(1); 847}