permissions hook helper
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}