permissions hook helper
0
fork

Configure Feed

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

Grouped review: generalize audit entries into smart rule suggestions

+225 -77
+225 -77
chook.mjs
··· 451 451 console.log("No deny/passthrough entries in audit log."); 452 452 process.exit(0); 453 453 } 454 - // Deduplicate by tool+detail 455 - const seen = new Set(); 456 - const unique = []; 457 - for (const entry of entries) { 458 - const ti = entry.tool_input; 459 - const key = 460 - entry.tool_name + 461 - ":" + 462 - (ti.command || ti.file_path || ti.notebook_path || ti.subagent_type || ""); 463 - if (!seen.has(key)) { 464 - seen.add(key); 465 - unique.push(entry); 454 + 455 + // --- Grouping logic --- 456 + // Group entries into generalized suggestions 457 + 458 + function groupEntries(entries) { 459 + const groups = []; 460 + 461 + // Separate by tool type 462 + const bashEntries = entries.filter((e) => e.tool_name === "Bash"); 463 + const fileEntries = entries.filter((e) => 464 + ["Read", "Write", "Edit", "MultiEdit", "Glob", "NotebookEdit"].includes(e.tool_name), 465 + ); 466 + const taskEntries = entries.filter((e) => e.tool_name === "Task"); 467 + const otherEntries = entries.filter( 468 + (e) => 469 + !["Bash", "Read", "Write", "Edit", "MultiEdit", "Glob", "NotebookEdit", "Task"].includes( 470 + e.tool_name, 471 + ), 472 + ); 473 + 474 + // Group bash by first word (the command) 475 + const bashByCmd = new Map(); 476 + for (const e of bashEntries) { 477 + const cmd = e.tool_input.command || ""; 478 + const firstWord = cmd.split(/\s+/)[0]; 479 + if (!firstWord) continue; 480 + if (!bashByCmd.has(firstWord)) bashByCmd.set(firstWord, []); 481 + bashByCmd.get(firstWord).push(e); 466 482 } 467 - } 483 + for (const [cmd, cmdEntries] of bashByCmd) { 484 + const subcommands = [ 485 + ...new Set( 486 + cmdEntries.map((e) => { 487 + const parts = (e.tool_input.command || "").split(/\s+/); 488 + return parts[1] || ""; 489 + }), 490 + ), 491 + ].filter(Boolean); 492 + const summary = 493 + subcommands.length <= 4 494 + ? subcommands.join(", ") 495 + : subcommands.slice(0, 3).join(", ") + `, +${subcommands.length - 3} more`; 468 496 469 - // Interactive review 470 - let cursor = 0; 471 - let added = 0; 497 + groups.push({ 498 + label: `Bash: ${cmd} (${cmdEntries.length} hits: ${summary})`, 499 + rule: `[[allow]]\ntool = "Bash"\ncommand_regex = "^${tomlEscape(regexEscape(cmd))} "`, 500 + entries: cmdEntries, 501 + }); 502 + } 472 503 473 - function getDetail(entry) { 474 - const ti = entry.tool_input; 475 - switch (entry.tool_name) { 476 - case "Bash": 477 - return ti.command || ""; 478 - case "Read": 479 - case "Write": 480 - case "Edit": 481 - case "MultiEdit": 482 - case "Glob": 483 - case "NotebookEdit": 484 - return ti.file_path || ti.notebook_path || ""; 485 - case "Task": 486 - return ti.subagent_type || ""; 487 - default: 488 - return JSON.stringify(ti).slice(0, 120); 504 + // Group file ops by tool, then by whether they're under a common cwd 505 + // First collect all cwds and paths per tool 506 + const fileByTool = new Map(); 507 + for (const e of fileEntries) { 508 + const tool = e.tool_name; 509 + if (!fileByTool.has(tool)) fileByTool.set(tool, []); 510 + fileByTool.get(tool).push(e); 489 511 } 512 + 513 + for (const [tool, toolEntries] of fileByTool) { 514 + // Check if all paths fall under their respective cwds 515 + const cwdPaths = toolEntries.map((e) => ({ 516 + cwd: e.cwd, 517 + path: e.tool_input.file_path || e.tool_input.notebook_path || "", 518 + })); 519 + 520 + const underCwd = cwdPaths.filter((p) => { 521 + const cwdPrefix = p.cwd.endsWith("/") ? p.cwd : p.cwd + "/"; 522 + return p.path.startsWith(cwdPrefix) || p.path === p.cwd; 523 + }); 524 + 525 + const outsideCwd = cwdPaths.filter((p) => { 526 + const cwdPrefix = p.cwd.endsWith("/") ? p.cwd : p.cwd + "/"; 527 + return !p.path.startsWith(cwdPrefix) && p.path !== p.cwd; 528 + }); 529 + 530 + if (underCwd.length > 0) { 531 + // Find common prefix among the cwds 532 + const cwds = [...new Set(underCwd.map((p) => p.cwd))]; 533 + const cwdDisplay = 534 + cwds.length === 1 ? cwds[0] : `${cwds.length} project dirs`; 535 + 536 + groups.push({ 537 + label: `${tool}: ${underCwd.length} files under cwd (${cwdDisplay})`, 538 + rule: `[[allow]]\ntool = "${tool}"\nrestrict_to_cwd = true\nfile_path_exclude_regex = "\\\\.\\\\."` , 539 + entries: toolEntries.filter((e) => { 540 + const p = e.tool_input.file_path || e.tool_input.notebook_path || ""; 541 + const cwdPrefix = e.cwd.endsWith("/") ? e.cwd : e.cwd + "/"; 542 + return p.startsWith(cwdPrefix) || p === e.cwd; 543 + }), 544 + }); 545 + } 546 + 547 + if (outsideCwd.length > 0) { 548 + // Group by common path prefix 549 + const paths = outsideCwd.map((p) => p.path); 550 + const prefix = commonPrefix(paths); 551 + const prefixDir = prefix.includes("/") 552 + ? prefix.slice(0, prefix.lastIndexOf("/") + 1) 553 + : ""; 554 + 555 + if (prefixDir) { 556 + groups.push({ 557 + label: `${tool}: ${outsideCwd.length} files under ${prefixDir}`, 558 + rule: `[[allow]]\ntool = "${tool}"\nfile_path_regex = "^${tomlEscape(regexEscape(prefixDir))}"`, 559 + entries: toolEntries.filter((e) => { 560 + const p = e.tool_input.file_path || e.tool_input.notebook_path || ""; 561 + return !p.startsWith(e.cwd) && p.startsWith(prefixDir); 562 + }), 563 + }); 564 + } else { 565 + // No common prefix, list individually 566 + for (const e of toolEntries.filter((e) => { 567 + const p = e.tool_input.file_path || e.tool_input.notebook_path || ""; 568 + const cwdPrefix = e.cwd.endsWith("/") ? e.cwd : e.cwd + "/"; 569 + return !p.startsWith(cwdPrefix) && p !== e.cwd; 570 + })) { 571 + const p = e.tool_input.file_path || e.tool_input.notebook_path || ""; 572 + groups.push({ 573 + label: `${tool}: ${p}`, 574 + rule: buildRuleFromEntry(e, "allow"), 575 + entries: [e], 576 + }); 577 + } 578 + } 579 + } 580 + } 581 + 582 + // Group tasks by subagent_type 583 + const taskByType = new Map(); 584 + for (const e of taskEntries) { 585 + const t = e.tool_input.subagent_type || "unknown"; 586 + if (!taskByType.has(t)) taskByType.set(t, []); 587 + taskByType.get(t).push(e); 588 + } 589 + for (const [type, typeEntries] of taskByType) { 590 + groups.push({ 591 + label: `Task: ${type} (${typeEntries.length} hits)`, 592 + rule: `[[allow]]\ntool = "Task"\nsubagent_type = "${tomlEscape(type)}"`, 593 + entries: typeEntries, 594 + }); 595 + } 596 + 597 + // Others individually 598 + for (const e of otherEntries) { 599 + groups.push({ 600 + label: `${e.tool_name}: ${JSON.stringify(e.tool_input).slice(0, 60)}`, 601 + rule: buildRuleFromEntry(e, "allow"), 602 + entries: [e], 603 + }); 604 + } 605 + 606 + return groups; 490 607 } 491 608 609 + function commonPrefix(strings) { 610 + if (strings.length === 0) return ""; 611 + if (strings.length === 1) return strings[0]; 612 + let prefix = strings[0]; 613 + for (let i = 1; i < strings.length; i++) { 614 + while (!strings[i].startsWith(prefix)) { 615 + prefix = prefix.slice(0, -1); 616 + if (!prefix) return ""; 617 + } 618 + } 619 + return prefix; 620 + } 621 + 622 + const groups = groupEntries(entries); 623 + if (groups.length === 0) { 624 + console.log("No actionable entries found."); 625 + process.exit(0); 626 + } 627 + 628 + // Interactive review on groups 629 + let cursor = 0; 630 + let added = 0; 631 + 492 632 function render() { 493 633 const rows = process.stdout.rows || 24; 494 634 const cols = process.stdout.columns || 100; 495 635 496 - // Reserve: 1 title + 1 blank + list + 1 blank + rule lines + 1 blank + 1 keys 497 - const rule = buildRuleFromEntry(unique[cursor], "allow"); 498 - const ruleLines = rule.split("\n"); 499 - const footerHeight = ruleLines.length + 3; // blank + rule + blank + keys 500 - const headerHeight = 2; // title + blank 636 + const currentGroup = groups[cursor]; 637 + const ruleLines = currentGroup.rule.split("\n"); 638 + // Show a few example entries from the group 639 + const exampleCount = Math.min(3, currentGroup.entries.length); 640 + const footerHeight = ruleLines.length + exampleCount + 5; 641 + const headerHeight = 2; 501 642 const pageSize = Math.max(3, rows - headerHeight - footerHeight); 502 643 503 - // Clear screen and move to top 504 644 process.stdout.write("\x1b[2J\x1b[H"); 505 645 506 - // Header 507 - console.log(`chook review - ${unique.length} entries (${cursor + 1}/${unique.length})`); 646 + console.log( 647 + `chook review - ${groups.length} suggestions (${cursor + 1}/${groups.length})`, 648 + ); 508 649 console.log(""); 509 650 510 - // Scrolling list 511 651 const halfPage = Math.floor(pageSize / 2); 512 652 let start = Math.max(0, cursor - halfPage); 513 - let end = Math.min(unique.length, start + pageSize); 653 + let end = Math.min(groups.length, start + pageSize); 514 654 if (end - start < pageSize) start = Math.max(0, end - pageSize); 515 655 516 656 for (let i = start; i < end; i++) { 517 - const e = unique[i]; 657 + const g = groups[i]; 518 658 const marker = i === cursor ? ">" : " "; 519 - const decision = e.decision.toUpperCase().padEnd(11); 520 - const tool = e.tool_name.padEnd(12); 521 - let detail = getDetail(e); 522 - const maxDetail = cols - 32; 523 - if (detail.length > maxDetail) 524 - detail = detail.slice(0, maxDetail - 3) + "..."; 659 + let label = g.label; 660 + const maxLen = cols - 6; 661 + if (label.length > maxLen) label = label.slice(0, maxLen - 3) + "..."; 525 662 526 663 if (i === cursor) { 527 - process.stdout.write(`\x1b[7m ${marker} ${decision} ${tool} ${detail}\x1b[0m\n`); 664 + process.stdout.write(`\x1b[7m ${marker} ${label}\x1b[0m\n`); 528 665 } else { 529 - console.log(` ${marker} ${decision} ${tool} ${detail}`); 666 + console.log(` ${marker} ${label}`); 530 667 } 531 668 } 532 669 533 - // Footer: proposed rule + keys (always visible at bottom) 670 + // Footer: examples + proposed rule + keys 534 671 console.log(""); 672 + console.log(" \x1b[2mExamples:\x1b[0m"); 673 + for (let i = 0; i < exampleCount; i++) { 674 + const e = currentGroup.entries[i]; 675 + const ti = e.tool_input; 676 + let detail = 677 + ti.command || ti.file_path || ti.notebook_path || ti.subagent_type || ""; 678 + if (detail.length > cols - 8) detail = detail.slice(0, cols - 11) + "..."; 679 + console.log(` ${detail}`); 680 + } 681 + if (currentGroup.entries.length > exampleCount) { 682 + console.log( 683 + ` \x1b[2m...and ${currentGroup.entries.length - exampleCount} more\x1b[0m`, 684 + ); 685 + } 686 + console.log(""); 687 + console.log(" \x1b[2mProposed rule:\x1b[0m"); 535 688 for (const line of ruleLines) { 536 689 console.log(` ${line}`); 537 690 } 538 691 console.log(""); 539 - console.log(" \x1b[2m[j/k] navigate [a] allow [d] deny [s] skip [q] quit\x1b[0m"); 692 + console.log( 693 + " \x1b[2m[j/k] navigate [a] allow [d] deny [s] skip [q] quit\x1b[0m", 694 + ); 540 695 } 541 696 542 - // Raw mode for keypress handling 543 697 process.stdin.setRawMode(true); 544 698 process.stdin.resume(); 545 699 process.stdin.setEncoding("utf-8"); ··· 547 701 render(); 548 702 549 703 process.stdin.on("data", (key) => { 550 - // Ctrl-C or q 551 704 if (key === "\x03" || key === "q") { 552 705 process.stdout.write("\x1b[2J\x1b[H"); 553 - if (added > 0) { 554 - console.log(`Done. Added ${added} rule(s) to ${configPath}`); 555 - } else { 556 - console.log("No rules added."); 557 - } 706 + console.log( 707 + added > 0 708 + ? `Done. Added ${added} rule(s) to ${configPath}` 709 + : "No rules added.", 710 + ); 558 711 process.exit(0); 559 712 } 560 713 561 - // Arrow up or k 562 714 if (key === "\x1b[A" || key === "k") { 563 715 if (cursor > 0) cursor--; 564 716 render(); 565 717 return; 566 718 } 567 719 568 - // Arrow down or j 569 720 if (key === "\x1b[B" || key === "j") { 570 - if (cursor < unique.length - 1) cursor++; 721 + if (cursor < groups.length - 1) cursor++; 571 722 render(); 572 723 return; 573 724 } 574 725 575 - // Allow 576 726 if (key === "a") { 577 - const ruleBlock = buildRuleFromEntry(unique[cursor], "allow"); 578 - appendRuleToConfig(configPath, ruleBlock); 727 + appendRuleToConfig(configPath, groups[cursor].rule); 579 728 added++; 580 - unique.splice(cursor, 1); 581 - if (unique.length === 0) { 729 + groups.splice(cursor, 1); 730 + if (groups.length === 0) { 582 731 process.stdout.write("\x1b[2J\x1b[H"); 583 732 console.log(`Done. Added ${added} rule(s) to ${configPath}`); 584 733 process.exit(0); 585 734 } 586 - if (cursor >= unique.length) cursor = unique.length - 1; 735 + if (cursor >= groups.length) cursor = groups.length - 1; 587 736 render(); 588 737 return; 589 738 } 590 739 591 - // Deny 592 740 if (key === "d") { 593 - const ruleBlock = buildRuleFromEntry(unique[cursor], "deny"); 594 - appendRuleToConfig(configPath, ruleBlock); 741 + // For deny, swap [[allow]] to [[deny]] in the rule 742 + const denyRule = groups[cursor].rule.replace("[[allow]]", "[[deny]]"); 743 + appendRuleToConfig(configPath, denyRule); 595 744 added++; 596 - unique.splice(cursor, 1); 597 - if (unique.length === 0) { 745 + groups.splice(cursor, 1); 746 + if (groups.length === 0) { 598 747 process.stdout.write("\x1b[2J\x1b[H"); 599 748 console.log(`Done. Added ${added} rule(s) to ${configPath}`); 600 749 process.exit(0); 601 750 } 602 - if (cursor >= unique.length) cursor = unique.length - 1; 751 + if (cursor >= groups.length) cursor = groups.length - 1; 603 752 render(); 604 753 return; 605 754 } 606 755 607 - // Skip 608 756 if (key === "s") { 609 - if (cursor < unique.length - 1) cursor++; 757 + if (cursor < groups.length - 1) cursor++; 610 758 render(); 611 759 return; 612 760 }