@webpieces/ai-hook-rules 0.4.704 → 0.4.705

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webpieces/ai-hook-rules",
3
- "version": "0.4.704",
3
+ "version": "0.4.705",
4
4
  "description": "Pluggable write-time validation framework for AI coding agents (@webpieces/ai-hook-rules). Claude Code PreToolUse + openclaw before_tool_call adapters share one rule engine.",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -25,7 +25,9 @@
25
25
  "directory": "packages/tooling/ai-hook-rules"
26
26
  },
27
27
  "dependencies": {
28
- "@webpieces/rules-config": "0.4.704"
28
+ "@webpieces/rules-config": "0.4.705",
29
+ "inversify": "7.10.4",
30
+ "reflect-metadata": "0.2.2"
29
31
  },
30
32
  "publishConfig": {
31
33
  "access": "public"
@@ -1,4 +1,6 @@
1
1
  import { AgentHookEvent } from '../core/agent-event';
2
+ import { HookOutcome } from './hook-outcome';
2
3
  export declare function denyJson(event: AgentHookEvent | null, reason: string): string;
4
+ export declare function denyOutcome(event: AgentHookEvent | null, reason: string, rule?: string, fault?: string): HookOutcome;
3
5
  export declare function emitDeny(event: AgentHookEvent | null, reason: string, rule?: string, fault?: string): never;
4
6
  export declare function emitAllow(): never;
@@ -28,10 +28,12 @@
28
28
  // Refs: Claude Code GitHub issues #31592, #40380, #17356 (asymmetry "closed / not planned").
29
29
  Object.defineProperty(exports, "__esModule", { value: true });
30
30
  exports.denyJson = denyJson;
31
+ exports.denyOutcome = denyOutcome;
31
32
  exports.emitDeny = emitDeny;
32
33
  exports.emitAllow = emitAllow;
33
34
  const decision_log_1 = require("../core/decision-log");
34
35
  const l0_fault_codes_1 = require("../core/l0-fault-codes");
36
+ const hook_outcome_1 = require("./hook-outcome");
35
37
  // ANSI escape (0x1b) built at runtime so no raw ESC byte sits in source. ANSI red is a *bonus* — the
36
38
  // 🛑 prefix + reason stay meaningful if a future/CI renderer strips the color. One place = one escape.
37
39
  const ESC = String.fromCharCode(0x1b);
@@ -80,34 +82,51 @@ function denyJson(event, reason) {
80
82
  // Write/Edit/MultiEdit (and anything else): reason renders red natively; no systemMessage.
81
83
  return JSON.stringify({ hookSpecificOutput });
82
84
  }
83
- // Block the tool call and surface `reason` to both the user (terminal UI) and the model. The event's
85
+ // THE DENY, AS A VALUE. `reason` is surfaced to both the user (terminal UI) and the model; the event's
84
86
  // kind selects whether the red `systemMessage` is added (Bash) or omitted (file tools) — see denyJson.
85
- // emitDeny/emitAllow are the hook's designated terminal boundary — the exit code IS the Claude Code
86
- // PreToolUse protocol (exit 0 + JSON = the contract), so the process.exit stays and is allowlisted.
87
87
  //
88
- // Being the ONE boundary every path exits through is also why the per-invocation audit line is
89
- // flushed HERE: the `calls/` stream carries the outcome of its own call, and the outcome is not
88
+ // This is the ONE boundary every blocking path passes through, which is why the per-invocation audit
89
+ // line is flushed HERE: the `calls/` stream carries the outcome of its own call, and the outcome is not
90
90
  // known until this point. `rule` names what blocked (or '-'), for the line's `rule=` field.
91
91
  //
92
92
  // `fault` is the L0 fault code when the block IS an L0 fault (S/C/Y — the three decided here in JS,
93
93
  // where the sh shim's own `fault=` stamp can never reach), else '-'. Stamping it at this ONE boundary is
94
94
  // what makes `grep 'fault=S'` span the whole audit trail rather than only its sh half.
95
- // webpieces-disable no-function-outside-class -- the Claude Code PreToolUse protocol boundary; module-scope beside denyJson/emitAllow by design, and it must stay callable from a tree too broken to build a DI container.
96
- function emitDeny(event, reason, rule = '-', fault = l0_fault_codes_1.L0_FAULT_NONE) {
95
+ //
96
+ // It RETURNS the outcome instead of writing it. The write and the exit belong to HookApp, which owns
97
+ // the injected stdout/exit ports — that separation is what lets a golden test read the exact bytes a
98
+ // composed run produces. `denyForCrash` (hook-core) needs the value rather than the throw, because it
99
+ // is already INSIDE the catch that would swallow one.
100
+ // webpieces-disable no-function-outside-class -- the Claude Code PreToolUse protocol boundary; module-scope beside denyJson/allowOutcome by design, and it must stay callable from a tree too broken to build a DI container.
101
+ function denyOutcome(event, reason, rule = '-', fault = l0_fault_codes_1.L0_FAULT_NONE) {
97
102
  // BLOCK_AI_CURE: every deny that reaches this boundary prints a cure the agent can act on — the
98
103
  // L0 faults name a command on the allowlist, and the L1/L2 guards print theirs. A deny needing a
99
104
  // HUMAN would have to say so at its own site; none does today, and inventing one here would be
100
105
  // guessing at the boundary rather than at the decision.
101
106
  decision_log_1.invocationLog.finish('BLOCK_AI_CURE', rule, fault);
102
- process.stdout.write(denyJson(event, reason) + '\n');
103
- // webpieces-disable no-process-exit-outside-main -- hook exit-code IS the Claude Code PreToolUse protocol (exit 0 + JSON = the contract); designated terminal boundary.
104
- process.exit(0);
107
+ return new hook_outcome_1.HookOutcome(denyJson(event, reason) + '\n', 0);
108
+ }
109
+ // The ALLOW, as a value. No JSON — a silent exit 0 is "allow" in the PreToolUse protocol.
110
+ //
111
+ // NOT exported, deliberately, where denyOutcome is: `denyForCrash` genuinely needs the deny in value
112
+ // form because it is already inside the catch a throw would land in, and nothing needs the allow that
113
+ // way. An exported one would be a second, externally-pickable spelling of "allow" sitting three lines
114
+ // from `emitAllow` — precisely the shape an agent picks by accident.
115
+ // webpieces-disable no-function-outside-class -- the PreToolUse protocol boundary, module-scope beside denyJson/denyOutcome by design, and it must stay callable from a tree too broken to build a DI container
116
+ function allowOutcome() {
117
+ decision_log_1.invocationLog.finish('ALLOW', '-');
118
+ return new hook_outcome_1.HookOutcome('', 0);
105
119
  }
106
- // Allow the tool call. No JSON needed a silent exit 0 is "allow" in the PreToolUse protocol.
120
+ // Block the tool call and END the invocation from wherever in the pipeline we are see HookTerminated
121
+ // for why the terminal control flow is a throw now rather than a `process.exit` at the call site. Still
122
+ // typed `never`: nothing after a call to this runs.
123
+ // webpieces-disable no-function-outside-class -- the Claude Code PreToolUse protocol boundary; module-scope beside denyJson/emitAllow by design, and it must stay callable from a tree too broken to build a DI container.
124
+ function emitDeny(event, reason, rule = '-', fault = l0_fault_codes_1.L0_FAULT_NONE) {
125
+ throw new hook_outcome_1.HookTerminated(denyOutcome(event, reason, rule, fault));
126
+ }
127
+ // Allow the tool call and END the invocation. See emitDeny.
107
128
  // webpieces-disable no-function-outside-class -- the PreToolUse protocol boundary, module-scope beside denyJson/emitDeny by design, and it must stay callable from a tree too broken to build a DI container
108
129
  function emitAllow() {
109
- decision_log_1.invocationLog.finish('ALLOW', '-');
110
- // webpieces-disable no-process-exit-outside-main -- hook exit-code IS the Claude Code PreToolUse protocol (silent exit 0 = "allow"); designated terminal boundary.
111
- process.exit(0);
130
+ throw new hook_outcome_1.HookTerminated(allowOutcome());
112
131
  }
113
132
  //# sourceMappingURL=agent-response.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"agent-response.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/agent-response.ts"],"names":[],"mappings":"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","sourcesContent":["// The single place that knows the PreToolUse decision protocol, so every deny is emitted identically\n// — and identically to the checked-in shim\n// (.claude/webpieces/ai-hook.sh, rendered by renderShim() in ../bin/shim.ts), which emits the same JSON.\n//\n// A block is signalled by `permissionDecision: \"deny\"` JSON on STDOUT with exit 0 — NOT exit 2.\n// Claude Code only parses the JSON on exit 0; exit 2 would ignore stdout and the reason would not\n// surface in the terminal UI. \"deny\" still blocks the tool, so this remains fail-closed: it is not the\n// silent-allow a bare exit 0 with no JSON would be.\n//\n// WHY the tool-conditional `systemMessage` (verified by live tests against Claude Code v2.1.x — the\n// docs are wrong here; do NOT re-derive from them):\n//\n// | deny field | Bash tool | Write/Edit/MultiEdit tool |\n// |-----------------------------------|-----------------------------------|-------------------------------|\n// | permissionDecisionReason (plain) | model sees it; USER SEES NOTHING | model + RED \"Error:\" block ok |\n// | systemMessage | ONLY user-visible field; grey | grey extra line (redundant) |\n// | systemMessage wrapped in ANSI red | RED + visible to the user (fix) | redundant 2nd red line |\n//\n// So: on a **Bash** deny we ALSO emit a top-level `systemMessage` wrapped in ANSI red (ESC[31;1m …\n// ESC[0m) — it is the only field a Bash deny shows the human, and it honors ANSI. On\n// Write/Edit/MultiEdit we add NO `systemMessage` (the reason already renders red natively — a second\n// line is just noise). `permissionDecisionReason` is always plain text (never ANSI): it's what the\n// model reads and what Write/Edit renders red. JSON.stringify serializes the ESC char as the valid\n// \\u escape, so the payload stays valid JSON — we build the ESC via String.fromCharCode(0x1b) so no\n// raw ESC (0x1b) byte ever lives in this source file. Do NOT use exit 2 (stdout JSON ignored;\n// stderr invisible to the user on Bash).\n// Refs: Claude Code GitHub issues #31592, #40380, #17356 (asymmetry \"closed / not planned\").\n\nimport { invocationLog } from '../core/decision-log';\nimport { L0_FAULT_NONE } from '../core/l0-fault-codes';\nimport { AgentHookEvent } from '../core/agent-event';\n\n// ANSI escape (0x1b) built at runtime so no raw ESC byte sits in source. ANSI red is a *bonus* — the\n// 🛑 prefix + reason stay meaningful if a future/CI renderer strips the color. One place = one escape.\nconst ESC = String.fromCharCode(0x1b);\n\n/**\n * ONLY THE HEADLINE IS RED. The body is left plain, and that is a legibility decision, not an oversight.\n *\n * Every deny that reaches here is MULTI-LINE — formatReport()'s `[rule] (N violations)` / `→ why` /\n * `Fix Option N:` skeleton for L1 and L2, and now the same skeleton for L0. A whole page rendered in\n * bold red is harder to read than the paragraph it replaced: the indentation that carries the structure\n * stops registering when every line shouts. Red the first line so the block is unmissable in a scroll of\n * terminal output, then let the structure do the rest of the work.\n *\n * The reset (`[0m`) still closes the sequence on the same line it opened, so nothing leaks into the\n * body or into whatever the terminal prints next.\n */\n// webpieces-disable no-function-outside-class -- private helper of the PreToolUse protocol boundary below; this module must stay callable from a tree too broken to build a DI container\nfunction redSystemMessage(reason: string): string {\n const nl = reason.indexOf('\\n');\n if (nl < 0) return `${ESC}[31;1m🛑 ${reason}${ESC}[0m`;\n return `${ESC}[31;1m🛑 ${reason.slice(0, nl)}${ESC}[0m${reason.slice(nl)}`;\n}\n\n// Takes the EVENT rather than a tool-name string, because the one thing this decision needs is the\n// event's routing kind, and the harnesses spell their tool names differently (`Bash` vs `apply_patch`)\n// while agreeing on the kind. The emitted bytes are identical for both harnesses — Codex accepts the\n// same `permissionDecision: \"deny\"` + `permissionDecisionReason` + `systemMessage` fields, and rejects\n// nothing we emit. It does hard-reject an EMPTY `permissionDecisionReason` where Claude tolerates one,\n// which is why emitDeny below refuses to send one.\n// webpieces-disable no-function-outside-class -- the PreToolUse wire shape itself, module-scope beside emitDeny/emitAllow by design, and it must stay callable from a tree too broken to build a DI container\nexport function denyJson(event: AgentHookEvent | null, reason: string): string {\n // NEVER an empty reason, and the check lives HERE because this function owns the wire shape. Codex\n // hard-rejects a deny whose permissionDecisionReason is empty (Claude tolerates it and shows the\n // human nothing), so an empty one is not a cosmetic defect — it is a block that silently fails to\n // block. Every call site passes prose; this is the backstop that keeps a future one from turning a\n // deny into a protocol error.\n const safe = reason.trim() === '' ? '[ai-hooks] blocked, but the guard produced no reason — failing closed.' : reason;\n const hookSpecificOutput = {\n hookEventName: 'PreToolUse',\n permissionDecision: 'deny',\n permissionDecisionReason: safe,\n };\n // Bash only: permissionDecisionReason is NOT user-visible, so add the red systemMessage.\n if (event !== null && event.kind === 'Bash') {\n return JSON.stringify({ systemMessage: redSystemMessage(safe), hookSpecificOutput });\n }\n // Write/Edit/MultiEdit (and anything else): reason renders red natively; no systemMessage.\n return JSON.stringify({ hookSpecificOutput });\n}\n\n// Block the tool call and surface `reason` to both the user (terminal UI) and the model. The event's\n// kind selects whether the red `systemMessage` is added (Bash) or omitted (file tools) — see denyJson.\n// emitDeny/emitAllow are the hook's designated terminal boundary — the exit code IS the Claude Code\n// PreToolUse protocol (exit 0 + JSON = the contract), so the process.exit stays and is allowlisted.\n//\n// Being the ONE boundary every path exits through is also why the per-invocation audit line is\n// flushed HERE: the `calls/` stream carries the outcome of its own call, and the outcome is not\n// known until this point. `rule` names what blocked (or '-'), for the line's `rule=` field.\n//\n// `fault` is the L0 fault code when the block IS an L0 fault (S/C/Y — the three decided here in JS,\n// where the sh shim's own `fault=` stamp can never reach), else '-'. Stamping it at this ONE boundary is\n// what makes `grep 'fault=S'` span the whole audit trail rather than only its sh half.\n// webpieces-disable no-function-outside-class -- the Claude Code PreToolUse protocol boundary; module-scope beside denyJson/emitAllow by design, and it must stay callable from a tree too broken to build a DI container.\nexport function emitDeny(event: AgentHookEvent | null, reason: string, rule: string = '-', fault: string = L0_FAULT_NONE): never {\n // BLOCK_AI_CURE: every deny that reaches this boundary prints a cure the agent can act on — the\n // L0 faults name a command on the allowlist, and the L1/L2 guards print theirs. A deny needing a\n // HUMAN would have to say so at its own site; none does today, and inventing one here would be\n // guessing at the boundary rather than at the decision.\n invocationLog.finish('BLOCK_AI_CURE', rule, fault);\n process.stdout.write(denyJson(event, reason) + '\\n');\n // webpieces-disable no-process-exit-outside-main -- hook exit-code IS the Claude Code PreToolUse protocol (exit 0 + JSON = the contract); designated terminal boundary.\n process.exit(0);\n}\n\n// Allow the tool call. No JSON needed — a silent exit 0 is \"allow\" in the PreToolUse protocol.\n// webpieces-disable no-function-outside-class -- the PreToolUse protocol boundary, module-scope beside denyJson/emitDeny by design, and it must stay callable from a tree too broken to build a DI container\nexport function emitAllow(): never {\n invocationLog.finish('ALLOW', '-');\n // webpieces-disable no-process-exit-outside-main -- hook exit-code IS the Claude Code PreToolUse protocol (silent exit 0 = \"allow\"); designated terminal boundary.\n process.exit(0);\n}\n"]}
1
+ {"version":3,"file":"agent-response.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/agent-response.ts"],"names":[],"mappings":"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","sourcesContent":["// The single place that knows the PreToolUse decision protocol, so every deny is emitted identically\n// — and identically to the checked-in shim\n// (.claude/webpieces/ai-hook.sh, rendered by renderShim() in ../bin/shim.ts), which emits the same JSON.\n//\n// A block is signalled by `permissionDecision: \"deny\"` JSON on STDOUT with exit 0 — NOT exit 2.\n// Claude Code only parses the JSON on exit 0; exit 2 would ignore stdout and the reason would not\n// surface in the terminal UI. \"deny\" still blocks the tool, so this remains fail-closed: it is not the\n// silent-allow a bare exit 0 with no JSON would be.\n//\n// WHY the tool-conditional `systemMessage` (verified by live tests against Claude Code v2.1.x — the\n// docs are wrong here; do NOT re-derive from them):\n//\n// | deny field | Bash tool | Write/Edit/MultiEdit tool |\n// |-----------------------------------|-----------------------------------|-------------------------------|\n// | permissionDecisionReason (plain) | model sees it; USER SEES NOTHING | model + RED \"Error:\" block ok |\n// | systemMessage | ONLY user-visible field; grey | grey extra line (redundant) |\n// | systemMessage wrapped in ANSI red | RED + visible to the user (fix) | redundant 2nd red line |\n//\n// So: on a **Bash** deny we ALSO emit a top-level `systemMessage` wrapped in ANSI red (ESC[31;1m …\n// ESC[0m) — it is the only field a Bash deny shows the human, and it honors ANSI. On\n// Write/Edit/MultiEdit we add NO `systemMessage` (the reason already renders red natively — a second\n// line is just noise). `permissionDecisionReason` is always plain text (never ANSI): it's what the\n// model reads and what Write/Edit renders red. JSON.stringify serializes the ESC char as the valid\n// \\u escape, so the payload stays valid JSON — we build the ESC via String.fromCharCode(0x1b) so no\n// raw ESC (0x1b) byte ever lives in this source file. Do NOT use exit 2 (stdout JSON ignored;\n// stderr invisible to the user on Bash).\n// Refs: Claude Code GitHub issues #31592, #40380, #17356 (asymmetry \"closed / not planned\").\n\nimport { invocationLog } from '../core/decision-log';\nimport { L0_FAULT_NONE } from '../core/l0-fault-codes';\nimport { AgentHookEvent } from '../core/agent-event';\nimport { HookOutcome, HookTerminated } from './hook-outcome';\n\n// ANSI escape (0x1b) built at runtime so no raw ESC byte sits in source. ANSI red is a *bonus* — the\n// 🛑 prefix + reason stay meaningful if a future/CI renderer strips the color. One place = one escape.\nconst ESC = String.fromCharCode(0x1b);\n\n/**\n * ONLY THE HEADLINE IS RED. The body is left plain, and that is a legibility decision, not an oversight.\n *\n * Every deny that reaches here is MULTI-LINE — formatReport()'s `[rule] (N violations)` / `→ why` /\n * `Fix Option N:` skeleton for L1 and L2, and now the same skeleton for L0. A whole page rendered in\n * bold red is harder to read than the paragraph it replaced: the indentation that carries the structure\n * stops registering when every line shouts. Red the first line so the block is unmissable in a scroll of\n * terminal output, then let the structure do the rest of the work.\n *\n * The reset (`[0m`) still closes the sequence on the same line it opened, so nothing leaks into the\n * body or into whatever the terminal prints next.\n */\n// webpieces-disable no-function-outside-class -- private helper of the PreToolUse protocol boundary below; this module must stay callable from a tree too broken to build a DI container\nfunction redSystemMessage(reason: string): string {\n const nl = reason.indexOf('\\n');\n if (nl < 0) return `${ESC}[31;1m🛑 ${reason}${ESC}[0m`;\n return `${ESC}[31;1m🛑 ${reason.slice(0, nl)}${ESC}[0m${reason.slice(nl)}`;\n}\n\n// Takes the EVENT rather than a tool-name string, because the one thing this decision needs is the\n// event's routing kind, and the harnesses spell their tool names differently (`Bash` vs `apply_patch`)\n// while agreeing on the kind. The emitted bytes are identical for both harnesses — Codex accepts the\n// same `permissionDecision: \"deny\"` + `permissionDecisionReason` + `systemMessage` fields, and rejects\n// nothing we emit. It does hard-reject an EMPTY `permissionDecisionReason` where Claude tolerates one,\n// which is why emitDeny below refuses to send one.\n// webpieces-disable no-function-outside-class -- the PreToolUse wire shape itself, module-scope beside emitDeny/emitAllow by design, and it must stay callable from a tree too broken to build a DI container\nexport function denyJson(event: AgentHookEvent | null, reason: string): string {\n // NEVER an empty reason, and the check lives HERE because this function owns the wire shape. Codex\n // hard-rejects a deny whose permissionDecisionReason is empty (Claude tolerates it and shows the\n // human nothing), so an empty one is not a cosmetic defect — it is a block that silently fails to\n // block. Every call site passes prose; this is the backstop that keeps a future one from turning a\n // deny into a protocol error.\n const safe = reason.trim() === '' ? '[ai-hooks] blocked, but the guard produced no reason — failing closed.' : reason;\n const hookSpecificOutput = {\n hookEventName: 'PreToolUse',\n permissionDecision: 'deny',\n permissionDecisionReason: safe,\n };\n // Bash only: permissionDecisionReason is NOT user-visible, so add the red systemMessage.\n if (event !== null && event.kind === 'Bash') {\n return JSON.stringify({ systemMessage: redSystemMessage(safe), hookSpecificOutput });\n }\n // Write/Edit/MultiEdit (and anything else): reason renders red natively; no systemMessage.\n return JSON.stringify({ hookSpecificOutput });\n}\n\n// THE DENY, AS A VALUE. `reason` is surfaced to both the user (terminal UI) and the model; the event's\n// kind selects whether the red `systemMessage` is added (Bash) or omitted (file tools) — see denyJson.\n//\n// This is the ONE boundary every blocking path passes through, which is why the per-invocation audit\n// line is flushed HERE: the `calls/` stream carries the outcome of its own call, and the outcome is not\n// known until this point. `rule` names what blocked (or '-'), for the line's `rule=` field.\n//\n// `fault` is the L0 fault code when the block IS an L0 fault (S/C/Y — the three decided here in JS,\n// where the sh shim's own `fault=` stamp can never reach), else '-'. Stamping it at this ONE boundary is\n// what makes `grep 'fault=S'` span the whole audit trail rather than only its sh half.\n//\n// It RETURNS the outcome instead of writing it. The write and the exit belong to HookApp, which owns\n// the injected stdout/exit ports — that separation is what lets a golden test read the exact bytes a\n// composed run produces. `denyForCrash` (hook-core) needs the value rather than the throw, because it\n// is already INSIDE the catch that would swallow one.\n// webpieces-disable no-function-outside-class -- the Claude Code PreToolUse protocol boundary; module-scope beside denyJson/allowOutcome by design, and it must stay callable from a tree too broken to build a DI container.\nexport function denyOutcome(event: AgentHookEvent | null, reason: string, rule: string = '-', fault: string = L0_FAULT_NONE): HookOutcome {\n // BLOCK_AI_CURE: every deny that reaches this boundary prints a cure the agent can act on — the\n // L0 faults name a command on the allowlist, and the L1/L2 guards print theirs. A deny needing a\n // HUMAN would have to say so at its own site; none does today, and inventing one here would be\n // guessing at the boundary rather than at the decision.\n invocationLog.finish('BLOCK_AI_CURE', rule, fault);\n return new HookOutcome(denyJson(event, reason) + '\\n', 0);\n}\n\n// The ALLOW, as a value. No JSON — a silent exit 0 is \"allow\" in the PreToolUse protocol.\n//\n// NOT exported, deliberately, where denyOutcome is: `denyForCrash` genuinely needs the deny in value\n// form because it is already inside the catch a throw would land in, and nothing needs the allow that\n// way. An exported one would be a second, externally-pickable spelling of \"allow\" sitting three lines\n// from `emitAllow` — precisely the shape an agent picks by accident.\n// webpieces-disable no-function-outside-class -- the PreToolUse protocol boundary, module-scope beside denyJson/denyOutcome by design, and it must stay callable from a tree too broken to build a DI container\nfunction allowOutcome(): HookOutcome {\n invocationLog.finish('ALLOW', '-');\n return new HookOutcome('', 0);\n}\n\n// Block the tool call and END the invocation from wherever in the pipeline we are — see HookTerminated\n// for why the terminal control flow is a throw now rather than a `process.exit` at the call site. Still\n// typed `never`: nothing after a call to this runs.\n// webpieces-disable no-function-outside-class -- the Claude Code PreToolUse protocol boundary; module-scope beside denyJson/emitAllow by design, and it must stay callable from a tree too broken to build a DI container.\nexport function emitDeny(event: AgentHookEvent | null, reason: string, rule: string = '-', fault: string = L0_FAULT_NONE): never {\n throw new HookTerminated(denyOutcome(event, reason, rule, fault));\n}\n\n// Allow the tool call and END the invocation. See emitDeny.\n// webpieces-disable no-function-outside-class -- the PreToolUse protocol boundary, module-scope beside denyJson/emitDeny by design, and it must stay callable from a tree too broken to build a DI container\nexport function emitAllow(): never {\n throw new HookTerminated(allowOutcome());\n}\n"]}
@@ -1,2 +1,3 @@
1
1
  #!/usr/bin/env node
2
+ import 'reflect-metadata';
2
3
  export declare function main(): Promise<void>;
@@ -2,13 +2,28 @@
2
2
  "use strict";
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.main = main;
5
- // Claude Code PreToolUse adapter for the GIT/PR/BRANCH GUARDS hook (matcher Bash).
6
- // File-edit payloads pass through untouched — code-style validation is the separate rules hook.
7
- const hook_core_1 = require("./hook-core");
8
- function main() {
9
- return (0, hook_core_1.runMain)('guards');
5
+ // Claude Code / Codex PreToolUse adapter for the GIT/PR/BRANCH GUARDS hook (matcher Bash|Write|Edit|
6
+ // MultiEdit|Read). Code-style validation is the separate rules hook.
7
+ //
8
+ // COMPOSITION ROOT, and deliberately nothing else: build the container, get the app, run it. Every
9
+ // decision this binary makes lives behind HookApp; the only thing that distinguishes it from
10
+ // rules-hook.ts is the one HookArgs it constructs.
11
+ require("reflect-metadata");
12
+ const inversify_1 = require("inversify");
13
+ const hook_app_1 = require("./hook-app");
14
+ const hook_outcome_1 = require("./hook-outcome");
15
+ // webpieces-disable no-function-outside-class -- this IS the bin's process entry point, named in package.json `exports` as ./claude-code-guards / ./claude-code-rules; a class here would be a namespace around one call and could not be the module's callable entry.
16
+ async function main() {
17
+ const container = new inversify_1.Container({ autobind: true });
18
+ const app = container.get(hook_app_1.HookApp);
19
+ await app.run(new hook_outcome_1.HookArgs('guards'));
10
20
  }
21
+ // `.catch` and not a bare `void main()`: a container that cannot be built throws BEFORE any HookApp
22
+ // exists, and an unhandled rejection exits non-zero — which PreToolUse reads as a non-blocking error
23
+ // and lets the tool call through. See HookBootFailure. `main` is async so a synchronous throw inside it
24
+ // arrives here as a rejection too.
11
25
  if (require.main === module) {
12
- void main();
26
+ // webpieces-disable no-any-unknown -- a rejection value is `unknown` by construction; HookBootFailure narrows it through toError(), which is the one place that job belongs
27
+ void main().catch((err) => { new hook_app_1.HookBootFailure().report(err); });
13
28
  }
14
29
  //# sourceMappingURL=guards-hook.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"guards-hook.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/guards-hook.ts"],"names":[],"mappings":";;;AAKA,oBAEC;AAND,mFAAmF;AACnF,gGAAgG;AAChG,2CAAsC;AAEtC,SAAgB,IAAI;IAChB,OAAO,IAAA,mBAAO,EAAC,QAAQ,CAAC,CAAC;AAC7B,CAAC;AAED,IAAI,OAAO,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;IAC1B,KAAK,IAAI,EAAE,CAAC;AAChB,CAAC","sourcesContent":["#!/usr/bin/env node\n// Claude Code PreToolUse adapter for the GIT/PR/BRANCH GUARDS hook (matcher Bash).\n// File-edit payloads pass through untouched code-style validation is the separate rules hook.\nimport { runMain } from './hook-core';\n\nexport function main(): Promise<void> {\n return runMain('guards');\n}\n\nif (require.main === module) {\n void main();\n}\n"]}
1
+ {"version":3,"file":"guards-hook.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/guards-hook.ts"],"names":[],"mappings":";;;AAcA,oBAIC;AAjBD,qGAAqG;AACrG,qEAAqE;AACrE,EAAE;AACF,mGAAmG;AACnG,6FAA6F;AAC7F,mDAAmD;AACnD,4BAA0B;AAC1B,yCAAsC;AAEtC,yCAAsD;AACtD,iDAA0C;AAE1C,uQAAuQ;AAChQ,KAAK,UAAU,IAAI;IACtB,MAAM,SAAS,GAAG,IAAI,qBAAS,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC;IACpD,MAAM,GAAG,GAAG,SAAS,CAAC,GAAG,CAAC,kBAAO,CAAC,CAAC;IACnC,MAAM,GAAG,CAAC,GAAG,CAAC,IAAI,uBAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC;AAC1C,CAAC;AAED,oGAAoG;AACpG,qGAAqG;AACrG,wGAAwG;AACxG,mCAAmC;AACnC,IAAI,OAAO,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;IAC1B,4KAA4K;IAC5K,KAAK,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,GAAY,EAAQ,EAAE,GAAG,IAAI,0BAAe,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AACtF,CAAC","sourcesContent":["#!/usr/bin/env node\n// Claude Code / Codex PreToolUse adapter for the GIT/PR/BRANCH GUARDS hook (matcher Bash|Write|Edit|\n// MultiEdit|Read). Code-style validation is the separate rules hook.\n//\n// COMPOSITION ROOT, and deliberately nothing else: build the container, get the app, run it. Every\n// decision this binary makes lives behind HookApp; the only thing that distinguishes it from\n// rules-hook.ts is the one HookArgs it constructs.\nimport 'reflect-metadata';\nimport { Container } from 'inversify';\n\nimport { HookApp, HookBootFailure } from './hook-app';\nimport { HookArgs } from './hook-outcome';\n\n// webpieces-disable no-function-outside-class -- this IS the bin's process entry point, named in package.json `exports` as ./claude-code-guards / ./claude-code-rules; a class here would be a namespace around one call and could not be the module's callable entry.\nexport async function main(): Promise<void> {\n const container = new Container({ autobind: true });\n const app = container.get(HookApp);\n await app.run(new HookArgs('guards'));\n}\n\n// `.catch` and not a bare `void main()`: a container that cannot be built throws BEFORE any HookApp\n// exists, and an unhandled rejection exits non-zero — which PreToolUse reads as a non-blocking error\n// and lets the tool call through. See HookBootFailure. `main` is async so a synchronous throw inside it\n// arrives here as a rejection too.\nif (require.main === module) {\n // webpieces-disable no-any-unknown -- a rejection value is `unknown` by construction; HookBootFailure narrows it through toError(), which is the one place that job belongs\n void main().catch((err: unknown): void => { new HookBootFailure().report(err); });\n}\n"]}
@@ -0,0 +1,68 @@
1
+ import { HookMode } from '../core/types';
2
+ /**
3
+ * THE WIRE BYTES the golden tests drive, and the throwaway repo they are judged against.
4
+ *
5
+ * Everything here exists so a reader can see EXACTLY what a harness sends: the payloads are built from
6
+ * the measured envelope key sets (see the fixture docs on GoldenFixture below), not from anything the
7
+ * hook itself produces, and the repo is a real `git init` with a frozen webpieces.config.json rather
8
+ * than whatever tree the suite happens to be running in. A golden computed against the live repo would
9
+ * change verdict with the branch you are standing on.
10
+ *
11
+ * This is NOT a spec file, deliberately: `tsconfig.lib.json` excludes `*.spec.ts`, so a payload builder
12
+ * living in one would never be type-checked by the build.
13
+ */
14
+ /** How one PreToolUse call is presented to the hook. Data class per CLAUDE.md rule 1. */
15
+ export declare class GoldenFixture {
16
+ /** Stable key into `__goldens__/hook-app-goldens.json`. */
17
+ readonly name: string;
18
+ /** Which hook binary's mode — `guards` or `rules`. */
19
+ readonly mode: HookMode;
20
+ /**
21
+ * The raw stdin bytes. A STRING, never an object, because malformed stdin is one of the fixtures
22
+ * and a shape that cannot express "not JSON" would quietly drop the case that matters most.
23
+ */
24
+ readonly stdin: string;
25
+ /** True ⇒ the fixture repo also gets a `rulesDir` whose one module throws when required. */
26
+ readonly crashingRulesDir: boolean;
27
+ constructor(name: string, mode: HookMode, stdin: string, crashingRulesDir?: boolean);
28
+ }
29
+ /** A built fixture repo: where it lives, and the stdin bytes with `<REPO>` resolved into it. */
30
+ export declare class PreparedFixture {
31
+ readonly repo: string;
32
+ readonly root: string;
33
+ readonly stdin: string;
34
+ constructor(repo: string, root: string, stdin: string);
35
+ }
36
+ /**
37
+ * The placeholder that stands for the fixture repo's absolute path, in BOTH directions: payloads are
38
+ * written with it and it is substituted in before the run; golden bytes are compared with the real
39
+ * path substituted back out. Guard reports legitimately name absolute paths (the git-workflow doc
40
+ * pointer, the blocked file), and a golden that hard-coded one machine's `/var/folders/...` would be
41
+ * a golden nobody else could run.
42
+ */
43
+ export declare const REPO_TOKEN = "<REPO>";
44
+ /**
45
+ * ONE fixture per row of the coverage the composed pipeline had none of before: for BOTH harnesses a
46
+ * Bash deny (the ANSI-red systemMessage), a file-tool deny (NO systemMessage), an allow, a read-only
47
+ * tool, malformed stdin, and a fail-closed crash.
48
+ */
49
+ export declare const GOLDEN_FIXTURES: readonly GoldenFixture[];
50
+ /**
51
+ * Builds ONE throwaway git repo per fixture and returns it with the payload's `<REPO>` resolved.
52
+ *
53
+ * A FRESH repo per fixture, not one shared: the hook writes `.webpieces/` state (the decision log, the
54
+ * main-sync cache) as it runs, so a shared tree would let fixture N's leftovers decide fixture N+1's
55
+ * verdict — an order-dependent suite, which is the one kind of golden test worse than none.
56
+ *
57
+ * `realpathSync` matters on macOS: `os.tmpdir()` hands back `/var/folders/...` which resolves to
58
+ * `/private/var/...`, and the L1 location guard compares the payload's cwd against the resolved repo
59
+ * root. Without it every fixture denies with "run git from the repo root" instead of the verdict under
60
+ * test.
61
+ */
62
+ export declare class GoldenRepoBuilder {
63
+ private readonly configJson;
64
+ constructor();
65
+ build(fixture: GoldenFixture): PreparedFixture;
66
+ private repoConfig;
67
+ private git;
68
+ }
@@ -0,0 +1,175 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.GoldenRepoBuilder = exports.GOLDEN_FIXTURES = exports.REPO_TOKEN = exports.PreparedFixture = exports.GoldenFixture = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const child_process_1 = require("child_process");
6
+ const fs = tslib_1.__importStar(require("fs"));
7
+ const os = tslib_1.__importStar(require("os"));
8
+ const path = tslib_1.__importStar(require("path"));
9
+ /**
10
+ * THE WIRE BYTES the golden tests drive, and the throwaway repo they are judged against.
11
+ *
12
+ * Everything here exists so a reader can see EXACTLY what a harness sends: the payloads are built from
13
+ * the measured envelope key sets (see the fixture docs on GoldenFixture below), not from anything the
14
+ * hook itself produces, and the repo is a real `git init` with a frozen webpieces.config.json rather
15
+ * than whatever tree the suite happens to be running in. A golden computed against the live repo would
16
+ * change verdict with the branch you are standing on.
17
+ *
18
+ * This is NOT a spec file, deliberately: `tsconfig.lib.json` excludes `*.spec.ts`, so a payload builder
19
+ * living in one would never be type-checked by the build.
20
+ */
21
+ /** How one PreToolUse call is presented to the hook. Data class per CLAUDE.md rule 1. */
22
+ class GoldenFixture {
23
+ /** Stable key into `__goldens__/hook-app-goldens.json`. */
24
+ name;
25
+ /** Which hook binary's mode — `guards` or `rules`. */
26
+ mode;
27
+ /**
28
+ * The raw stdin bytes. A STRING, never an object, because malformed stdin is one of the fixtures
29
+ * and a shape that cannot express "not JSON" would quietly drop the case that matters most.
30
+ */
31
+ stdin;
32
+ /** True ⇒ the fixture repo also gets a `rulesDir` whose one module throws when required. */
33
+ crashingRulesDir;
34
+ constructor(name, mode, stdin, crashingRulesDir = false) {
35
+ this.name = name;
36
+ this.mode = mode;
37
+ this.stdin = stdin;
38
+ this.crashingRulesDir = crashingRulesDir;
39
+ }
40
+ }
41
+ exports.GoldenFixture = GoldenFixture;
42
+ /** A built fixture repo: where it lives, and the stdin bytes with `<REPO>` resolved into it. */
43
+ class PreparedFixture {
44
+ repo;
45
+ root;
46
+ stdin;
47
+ constructor(repo, root, stdin) {
48
+ this.repo = repo;
49
+ this.root = root;
50
+ this.stdin = stdin;
51
+ }
52
+ }
53
+ exports.PreparedFixture = PreparedFixture;
54
+ /**
55
+ * The placeholder that stands for the fixture repo's absolute path, in BOTH directions: payloads are
56
+ * written with it and it is substituted in before the run; golden bytes are compared with the real
57
+ * path substituted back out. Guard reports legitimately name absolute paths (the git-workflow doc
58
+ * pointer, the blocked file), and a golden that hard-coded one machine's `/var/folders/...` would be
59
+ * a golden nobody else could run.
60
+ */
61
+ exports.REPO_TOKEN = '<REPO>';
62
+ const CLAUDE_ENVELOPE = { hook_event_name: 'PreToolUse', session_id: 'sess-golden', transcript_path: '/dev/null', cwd: exports.REPO_TOKEN };
63
+ /**
64
+ * The Codex additions, MEASURED from codex-cli 0.151.0: it uses Claude's key names and merely ADDS
65
+ * `model`, `turn_id`, `tool_use_id` and `permission_mode`. `turn_id` is the one discriminator (see
66
+ * detect-ai.ts). `agent_id` empty ⇒ the coordinator, populated ⇒ a subagent — identical semantics in
67
+ * both harnesses.
68
+ */
69
+ const CODEX_ENVELOPE = { ...CLAUDE_ENVELOPE, model: 'gpt-5-codex', turn_id: 'turn-golden', tool_use_id: 'call_1', permission_mode: 'default', agent_type: 'default', agent_id: '' };
70
+ // Codex's edit tool. MEASURED: the tool is named `apply_patch`, it carries `tool_input.command` (not
71
+ // file_path), hunk headers are a bare `@@`, and ONE patch may carry many files with mixed operations.
72
+ const PATCH_ADD_JS = '*** Begin Patch\n*** Add File: src/foo.js\n+var x = 1;\n*** End Patch\n';
73
+ const PATCH_ADD_TS = '*** Begin Patch\n*** Add File: scripts/added.ts\n+export const b = 2;\n*** End Patch\n';
74
+ /**
75
+ * Serializes ONE PreToolUse envelope to the bytes a harness would put on stdin. A class rather than a
76
+ * module-scope function because that is the repo rule, and the one instance below is built at module
77
+ * load so the fixture table can stay a plain literal list.
78
+ */
79
+ class WirePayload {
80
+ // webpieces-disable no-any-unknown -- these objects ARE the wire envelope; JSON.stringify of a plain object is the payload under test, and naming a type for it would assert a shape the fixtures exist to state literally
81
+ write(envelope, toolName, toolInput) {
82
+ return JSON.stringify({ ...envelope, tool_name: toolName, tool_input: toolInput });
83
+ }
84
+ }
85
+ const WIRE = new WirePayload();
86
+ /**
87
+ * ONE fixture per row of the coverage the composed pipeline had none of before: for BOTH harnesses a
88
+ * Bash deny (the ANSI-red systemMessage), a file-tool deny (NO systemMessage), an allow, a read-only
89
+ * tool, malformed stdin, and a fail-closed crash.
90
+ */
91
+ exports.GOLDEN_FIXTURES = [
92
+ // ── Claude Code ────────────────────────────────────────────────────────────────────────────────
93
+ // A Bash deny. `git merge` is blocked on every branch, so this verdict does not depend on repo
94
+ // state — and a Bash deny is the ONE case that carries the red `systemMessage`.
95
+ new GoldenFixture('claude/bash-deny', 'guards', WIRE.write(CLAUDE_ENVELOPE, 'Bash', { command: 'git merge main' })),
96
+ new GoldenFixture('claude/bash-allow', 'guards', WIRE.write(CLAUDE_ENVELOPE, 'Bash', { command: 'echo hi' })),
97
+ // The read-only tool: log-and-allow, and the only guard that can deny it is a stale `main`.
98
+ new GoldenFixture('claude/read-allow', 'guards', WIRE.write(CLAUDE_ENVELOPE, 'Read', { file_path: `${exports.REPO_TOKEN}/f.txt` })),
99
+ // Write / Edit / MultiEdit denies — all three must emit NO systemMessage.
100
+ new GoldenFixture('claude/write-deny', 'rules', WIRE.write(CLAUDE_ENVELOPE, 'Write', { file_path: `${exports.REPO_TOKEN}/src/foo.js`, content: 'var x = 1;\n' })),
101
+ new GoldenFixture('claude/edit-deny', 'rules', WIRE.write(CLAUDE_ENVELOPE, 'Edit', { file_path: `${exports.REPO_TOKEN}/scripts/ok.ts`, old_string: 'const a = 1;', new_string: 'const { a } = b;' })),
102
+ new GoldenFixture('claude/multiedit-deny', 'rules', WIRE.write(CLAUDE_ENVELOPE, 'MultiEdit', { file_path: `${exports.REPO_TOKEN}/scripts/ok.ts`, edits: [{ old_string: 'const a = 1;', new_string: 'const { a } = b;' }] })),
103
+ new GoldenFixture('claude/write-allow', 'rules', WIRE.write(CLAUDE_ENVELOPE, 'Write', { file_path: `${exports.REPO_TOKEN}/scripts/ok.ts`, content: 'export const a = 1;\n' })),
104
+ new GoldenFixture('claude/malformed', 'guards', 'not json at all'),
105
+ new GoldenFixture('claude/crash', 'rules', WIRE.write(CLAUDE_ENVELOPE, 'Write', { file_path: `${exports.REPO_TOKEN}/scripts/ok.ts`, content: 'export const a = 1;\n' }), true),
106
+ // ── Codex ──────────────────────────────────────────────────────────────────────────────────────
107
+ new GoldenFixture('codex/bash-deny', 'guards', WIRE.write(CODEX_ENVELOPE, 'Bash', { command: 'git merge main' })),
108
+ new GoldenFixture('codex/bash-allow', 'guards', WIRE.write(CODEX_ENVELOPE, 'Bash', { command: 'echo hi' })),
109
+ // Codex has no Read tool: a read arrives as `Bash` running a pager, which read parity turns into a
110
+ // read-scoped verdict ON TOP of the bash guards.
111
+ new GoldenFixture('codex/read-allow', 'guards', WIRE.write(CODEX_ENVELOPE, 'Bash', { command: `sed -n '1,240p' ${exports.REPO_TOKEN}/f.txt` })),
112
+ new GoldenFixture('codex/apply-patch-deny', 'rules', WIRE.write(CODEX_ENVELOPE, 'apply_patch', { command: PATCH_ADD_JS })),
113
+ new GoldenFixture('codex/apply-patch-allow', 'rules', WIRE.write(CODEX_ENVELOPE, 'apply_patch', { command: PATCH_ADD_TS })),
114
+ new GoldenFixture('codex/malformed', 'guards', '{"turn_id": broken'),
115
+ new GoldenFixture('codex/crash', 'rules', WIRE.write(CODEX_ENVELOPE, 'apply_patch', { command: PATCH_ADD_TS }), true),
116
+ ];
117
+ // A rules module that blows up the moment it is required — the shortest honest way to reach the hook's
118
+ // fail-closed boundary from OUTSIDE the hook, i.e. without stubbing anything the pipeline owns.
119
+ const CRASHING_RULE_MODULE = "throw new Error('boom from a custom rule module');\n";
120
+ /**
121
+ * Builds ONE throwaway git repo per fixture and returns it with the payload's `<REPO>` resolved.
122
+ *
123
+ * A FRESH repo per fixture, not one shared: the hook writes `.webpieces/` state (the decision log, the
124
+ * main-sync cache) as it runs, so a shared tree would let fixture N's leftovers decide fixture N+1's
125
+ * verdict — an order-dependent suite, which is the one kind of golden test worse than none.
126
+ *
127
+ * `realpathSync` matters on macOS: `os.tmpdir()` hands back `/var/folders/...` which resolves to
128
+ * `/private/var/...`, and the L1 location guard compares the payload's cwd against the resolved repo
129
+ * root. Without it every fixture denies with "run git from the repo root" instead of the verdict under
130
+ * test.
131
+ */
132
+ class GoldenRepoBuilder {
133
+ configJson;
134
+ constructor() {
135
+ this.configJson = fs.readFileSync(path.join(__dirname, '__goldens__', 'fixture-webpieces.config.json'), 'utf8');
136
+ }
137
+ build(fixture) {
138
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'wp-hook-golden-')));
139
+ const repo = path.join(root, 'repo');
140
+ fs.mkdirSync(repo);
141
+ this.git(repo, 'init -q -b main');
142
+ // The developer's own commit hooks would otherwise fire inside this throwaway repo.
143
+ this.git(repo, 'config core.hooksPath /dev/null');
144
+ this.git(repo, 'config user.email t@t.co');
145
+ this.git(repo, 'config user.name tester');
146
+ fs.writeFileSync(path.join(repo, 'webpieces.config.json'), this.repoConfig(fixture));
147
+ fs.writeFileSync(path.join(repo, 'f.txt'), 'hello\n');
148
+ fs.mkdirSync(path.join(repo, 'scripts'));
149
+ fs.writeFileSync(path.join(repo, 'scripts', 'ok.ts'), 'const a = 1;\n');
150
+ if (fixture.crashingRulesDir) {
151
+ fs.mkdirSync(path.join(repo, 'wprules'));
152
+ fs.writeFileSync(path.join(repo, 'wprules', 'crash.js'), CRASHING_RULE_MODULE);
153
+ }
154
+ this.git(repo, 'add -A');
155
+ this.git(repo, 'commit -qm init');
156
+ // A local origin/main, so `origin/main..<branch>` resolves exactly as it does in a real clone.
157
+ this.git(repo, 'update-ref refs/remotes/origin/main HEAD');
158
+ // A feature branch, because that is where an agent actually works.
159
+ this.git(repo, 'checkout -q -b dean/fixture');
160
+ return new PreparedFixture(repo, root, fixture.stdin.split(exports.REPO_TOKEN).join(repo));
161
+ }
162
+ repoConfig(fixture) {
163
+ if (!fixture.crashingRulesDir)
164
+ return this.configJson;
165
+ // webpieces-disable no-any-unknown -- the frozen fixture config is data on disk; re-parsing it into a named type here would be a second declaration of a file whose whole point is being literal
166
+ const parsed = JSON.parse(this.configJson);
167
+ parsed['rulesDir'] = ['wprules'];
168
+ return JSON.stringify(parsed, null, 4);
169
+ }
170
+ git(repo, args) {
171
+ (0, child_process_1.execSync)(`git ${args}`, { cwd: repo, encoding: 'utf8' });
172
+ }
173
+ }
174
+ exports.GoldenRepoBuilder = GoldenRepoBuilder;
175
+ //# sourceMappingURL=hook-app-fixtures.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hook-app-fixtures.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/hook-app-fixtures.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\nimport * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\n\nimport { HookMode } from '../core/types';\n\n/**\n * THE WIRE BYTES the golden tests drive, and the throwaway repo they are judged against.\n *\n * Everything here exists so a reader can see EXACTLY what a harness sends: the payloads are built from\n * the measured envelope key sets (see the fixture docs on GoldenFixture below), not from anything the\n * hook itself produces, and the repo is a real `git init` with a frozen webpieces.config.json rather\n * than whatever tree the suite happens to be running in. A golden computed against the live repo would\n * change verdict with the branch you are standing on.\n *\n * This is NOT a spec file, deliberately: `tsconfig.lib.json` excludes `*.spec.ts`, so a payload builder\n * living in one would never be type-checked by the build.\n */\n\n/** How one PreToolUse call is presented to the hook. Data class per CLAUDE.md rule 1. */\nexport class GoldenFixture {\n /** Stable key into `__goldens__/hook-app-goldens.json`. */\n readonly name: string;\n /** Which hook binary's mode — `guards` or `rules`. */\n readonly mode: HookMode;\n /**\n * The raw stdin bytes. A STRING, never an object, because malformed stdin is one of the fixtures\n * and a shape that cannot express \"not JSON\" would quietly drop the case that matters most.\n */\n readonly stdin: string;\n /** True ⇒ the fixture repo also gets a `rulesDir` whose one module throws when required. */\n readonly crashingRulesDir: boolean;\n\n constructor(name: string, mode: HookMode, stdin: string, crashingRulesDir: boolean = false) {\n this.name = name;\n this.mode = mode;\n this.stdin = stdin;\n this.crashingRulesDir = crashingRulesDir;\n }\n}\n\n/** A built fixture repo: where it lives, and the stdin bytes with `<REPO>` resolved into it. */\nexport class PreparedFixture {\n readonly repo: string;\n readonly root: string;\n readonly stdin: string;\n\n constructor(repo: string, root: string, stdin: string) {\n this.repo = repo;\n this.root = root;\n this.stdin = stdin;\n }\n}\n\n/**\n * The placeholder that stands for the fixture repo's absolute path, in BOTH directions: payloads are\n * written with it and it is substituted in before the run; golden bytes are compared with the real\n * path substituted back out. Guard reports legitimately name absolute paths (the git-workflow doc\n * pointer, the blocked file), and a golden that hard-coded one machine's `/var/folders/...` would be\n * a golden nobody else could run.\n */\nexport const REPO_TOKEN = '<REPO>';\n\nconst CLAUDE_ENVELOPE = { hook_event_name: 'PreToolUse', session_id: 'sess-golden', transcript_path: '/dev/null', cwd: REPO_TOKEN };\n\n/**\n * The Codex additions, MEASURED from codex-cli 0.151.0: it uses Claude's key names and merely ADDS\n * `model`, `turn_id`, `tool_use_id` and `permission_mode`. `turn_id` is the one discriminator (see\n * detect-ai.ts). `agent_id` empty ⇒ the coordinator, populated ⇒ a subagent — identical semantics in\n * both harnesses.\n */\nconst CODEX_ENVELOPE = { ...CLAUDE_ENVELOPE, model: 'gpt-5-codex', turn_id: 'turn-golden', tool_use_id: 'call_1', permission_mode: 'default', agent_type: 'default', agent_id: '' };\n\n// Codex's edit tool. MEASURED: the tool is named `apply_patch`, it carries `tool_input.command` (not\n// file_path), hunk headers are a bare `@@`, and ONE patch may carry many files with mixed operations.\nconst PATCH_ADD_JS = '*** Begin Patch\\n*** Add File: src/foo.js\\n+var x = 1;\\n*** End Patch\\n';\nconst PATCH_ADD_TS = '*** Begin Patch\\n*** Add File: scripts/added.ts\\n+export const b = 2;\\n*** End Patch\\n';\n\n/**\n * Serializes ONE PreToolUse envelope to the bytes a harness would put on stdin. A class rather than a\n * module-scope function because that is the repo rule, and the one instance below is built at module\n * load so the fixture table can stay a plain literal list.\n */\nclass WirePayload {\n // webpieces-disable no-any-unknown -- these objects ARE the wire envelope; JSON.stringify of a plain object is the payload under test, and naming a type for it would assert a shape the fixtures exist to state literally\n write(envelope: Record<string, unknown>, toolName: string, toolInput: Record<string, unknown>): string {\n return JSON.stringify({ ...envelope, tool_name: toolName, tool_input: toolInput });\n }\n}\n\nconst WIRE = new WirePayload();\n\n/**\n * ONE fixture per row of the coverage the composed pipeline had none of before: for BOTH harnesses a\n * Bash deny (the ANSI-red systemMessage), a file-tool deny (NO systemMessage), an allow, a read-only\n * tool, malformed stdin, and a fail-closed crash.\n */\nexport const GOLDEN_FIXTURES: readonly GoldenFixture[] = [\n // ── Claude Code ────────────────────────────────────────────────────────────────────────────────\n // A Bash deny. `git merge` is blocked on every branch, so this verdict does not depend on repo\n // state — and a Bash deny is the ONE case that carries the red `systemMessage`.\n new GoldenFixture('claude/bash-deny', 'guards', WIRE.write(CLAUDE_ENVELOPE, 'Bash', { command: 'git merge main' })),\n new GoldenFixture('claude/bash-allow', 'guards', WIRE.write(CLAUDE_ENVELOPE, 'Bash', { command: 'echo hi' })),\n // The read-only tool: log-and-allow, and the only guard that can deny it is a stale `main`.\n new GoldenFixture('claude/read-allow', 'guards', WIRE.write(CLAUDE_ENVELOPE, 'Read', { file_path: `${REPO_TOKEN}/f.txt` })),\n // Write / Edit / MultiEdit denies — all three must emit NO systemMessage.\n new GoldenFixture('claude/write-deny', 'rules', WIRE.write(CLAUDE_ENVELOPE, 'Write', { file_path: `${REPO_TOKEN}/src/foo.js`, content: 'var x = 1;\\n' })),\n new GoldenFixture('claude/edit-deny', 'rules', WIRE.write(CLAUDE_ENVELOPE, 'Edit', { file_path: `${REPO_TOKEN}/scripts/ok.ts`, old_string: 'const a = 1;', new_string: 'const { a } = b;' })),\n new GoldenFixture('claude/multiedit-deny', 'rules', WIRE.write(CLAUDE_ENVELOPE, 'MultiEdit', { file_path: `${REPO_TOKEN}/scripts/ok.ts`, edits: [{ old_string: 'const a = 1;', new_string: 'const { a } = b;' }] })),\n new GoldenFixture('claude/write-allow', 'rules', WIRE.write(CLAUDE_ENVELOPE, 'Write', { file_path: `${REPO_TOKEN}/scripts/ok.ts`, content: 'export const a = 1;\\n' })),\n new GoldenFixture('claude/malformed', 'guards', 'not json at all'),\n new GoldenFixture('claude/crash', 'rules', WIRE.write(CLAUDE_ENVELOPE, 'Write', { file_path: `${REPO_TOKEN}/scripts/ok.ts`, content: 'export const a = 1;\\n' }), true),\n\n // ── Codex ──────────────────────────────────────────────────────────────────────────────────────\n new GoldenFixture('codex/bash-deny', 'guards', WIRE.write(CODEX_ENVELOPE, 'Bash', { command: 'git merge main' })),\n new GoldenFixture('codex/bash-allow', 'guards', WIRE.write(CODEX_ENVELOPE, 'Bash', { command: 'echo hi' })),\n // Codex has no Read tool: a read arrives as `Bash` running a pager, which read parity turns into a\n // read-scoped verdict ON TOP of the bash guards.\n new GoldenFixture('codex/read-allow', 'guards', WIRE.write(CODEX_ENVELOPE, 'Bash', { command: `sed -n '1,240p' ${REPO_TOKEN}/f.txt` })),\n new GoldenFixture('codex/apply-patch-deny', 'rules', WIRE.write(CODEX_ENVELOPE, 'apply_patch', { command: PATCH_ADD_JS })),\n new GoldenFixture('codex/apply-patch-allow', 'rules', WIRE.write(CODEX_ENVELOPE, 'apply_patch', { command: PATCH_ADD_TS })),\n new GoldenFixture('codex/malformed', 'guards', '{\"turn_id\": broken'),\n new GoldenFixture('codex/crash', 'rules', WIRE.write(CODEX_ENVELOPE, 'apply_patch', { command: PATCH_ADD_TS }), true),\n];\n\n// A rules module that blows up the moment it is required — the shortest honest way to reach the hook's\n// fail-closed boundary from OUTSIDE the hook, i.e. without stubbing anything the pipeline owns.\nconst CRASHING_RULE_MODULE = \"throw new Error('boom from a custom rule module');\\n\";\n\n/**\n * Builds ONE throwaway git repo per fixture and returns it with the payload's `<REPO>` resolved.\n *\n * A FRESH repo per fixture, not one shared: the hook writes `.webpieces/` state (the decision log, the\n * main-sync cache) as it runs, so a shared tree would let fixture N's leftovers decide fixture N+1's\n * verdict — an order-dependent suite, which is the one kind of golden test worse than none.\n *\n * `realpathSync` matters on macOS: `os.tmpdir()` hands back `/var/folders/...` which resolves to\n * `/private/var/...`, and the L1 location guard compares the payload's cwd against the resolved repo\n * root. Without it every fixture denies with \"run git from the repo root\" instead of the verdict under\n * test.\n */\nexport class GoldenRepoBuilder {\n private readonly configJson: string;\n\n constructor() {\n this.configJson = fs.readFileSync(path.join(__dirname, '__goldens__', 'fixture-webpieces.config.json'), 'utf8');\n }\n\n build(fixture: GoldenFixture): PreparedFixture {\n const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'wp-hook-golden-')));\n const repo = path.join(root, 'repo');\n fs.mkdirSync(repo);\n this.git(repo, 'init -q -b main');\n // The developer's own commit hooks would otherwise fire inside this throwaway repo.\n this.git(repo, 'config core.hooksPath /dev/null');\n this.git(repo, 'config user.email t@t.co');\n this.git(repo, 'config user.name tester');\n fs.writeFileSync(path.join(repo, 'webpieces.config.json'), this.repoConfig(fixture));\n fs.writeFileSync(path.join(repo, 'f.txt'), 'hello\\n');\n fs.mkdirSync(path.join(repo, 'scripts'));\n fs.writeFileSync(path.join(repo, 'scripts', 'ok.ts'), 'const a = 1;\\n');\n if (fixture.crashingRulesDir) {\n fs.mkdirSync(path.join(repo, 'wprules'));\n fs.writeFileSync(path.join(repo, 'wprules', 'crash.js'), CRASHING_RULE_MODULE);\n }\n this.git(repo, 'add -A');\n this.git(repo, 'commit -qm init');\n // A local origin/main, so `origin/main..<branch>` resolves exactly as it does in a real clone.\n this.git(repo, 'update-ref refs/remotes/origin/main HEAD');\n // A feature branch, because that is where an agent actually works.\n this.git(repo, 'checkout -q -b dean/fixture');\n return new PreparedFixture(repo, root, fixture.stdin.split(REPO_TOKEN).join(repo));\n }\n\n private repoConfig(fixture: GoldenFixture): string {\n if (!fixture.crashingRulesDir) return this.configJson;\n // webpieces-disable no-any-unknown -- the frozen fixture config is data on disk; re-parsing it into a named type here would be a second declaration of a file whose whole point is being literal\n const parsed = JSON.parse(this.configJson) as Record<string, unknown>;\n parsed['rulesDir'] = ['wprules'];\n return JSON.stringify(parsed, null, 4);\n }\n\n private git(repo: string, args: string): void {\n execSync(`git ${args}`, { cwd: repo, encoding: 'utf8' });\n }\n}\n"]}
@@ -0,0 +1,61 @@
1
+ import { HookArgs } from './hook-outcome';
2
+ import { HookStdinSource, HookStdoutSink, HookProcessExit } from './hook-ports';
3
+ /**
4
+ * THE COMPOSITION ROOT'S APP — one PreToolUse invocation, end to end.
5
+ *
6
+ * Production is three lines in `guards-hook.ts` / `rules-hook.ts`:
7
+ *
8
+ * const container = new Container({ autobind: true });
9
+ * const app = container.get(HookApp);
10
+ * await app.run(new HookArgs('guards'));
11
+ *
12
+ * and a test is the SAME three lines with the ports rebound to doubles — canned stdin, a captured
13
+ * stdout, a recorded exit code. That is the whole difference, and it is the point: the test boundary
14
+ * is cut JUST ABOVE the injection point, so the seam a test drives is the seam production drives.
15
+ *
16
+ * What this replaces: `runMain(mode)`, which read stdin itself and reached `process.stdout.write` /
17
+ * `process.exit` from a dozen frames down. `runMain` is DELETED, not kept alongside — two spellings of
18
+ * one entry point is the shim shape this repo rejects outright (see CLAUDE.md, "NO webpieces surface
19
+ * is released backwards-compatible"). Nothing outside this file names it any more.
20
+ *
21
+ * The order of observable effects is unchanged from `runMain`: the invocation's audit line is flushed
22
+ * at the emit boundary inside the pipeline, the decision bytes are written next, and the process exits
23
+ * last through the injected exit port.
24
+ */
25
+ export declare class HookApp {
26
+ private readonly stdin;
27
+ private readonly stdout;
28
+ private readonly processExit;
29
+ constructor(stdin: HookStdinSource, stdout: HookStdoutSink, processExit: HookProcessExit);
30
+ run(args: HookArgs): Promise<void>;
31
+ /**
32
+ * THE FAIL-CLOSED BOUNDARY FOR THE READ ITSELF, and the reason this is a separate method.
33
+ *
34
+ * `runMain` had the stdin read INSIDE the try whose catch produced a deny, so a failure there was
35
+ * still a structured block. Moving the read behind a port would have quietly narrowed that: a
36
+ * rejected read (or anything else thrown before the pipeline starts) would escape `run`, land as an
37
+ * unhandled rejection, and exit non-zero — which PreToolUse reads as a NON-BLOCKING error and lets
38
+ * the tool call THROUGH. That is the exact inversion of "a broken hook never silently lets an edit
39
+ * through", and no golden could catch it, because the goldens substitute this very port.
40
+ *
41
+ * So the try is restored one level out, around the read AND the pipeline, and it emits the same
42
+ * bytes `denyForCrash` emits for a null event.
43
+ */
44
+ private decide;
45
+ }
46
+ /**
47
+ * THE LAST-RESORT FAIL-CLOSED BOUNDARY: the composition root itself could not run.
48
+ *
49
+ * `new Container(...)` and `container.get(HookApp)` happen BEFORE any HookApp exists to catch for
50
+ * them, so an unresolvable binding (a missing decorator, a stripped `design:paramtypes`) would exit
51
+ * non-zero with a stack on stderr — and a non-zero exit is a non-blocking error, so every guarded tool
52
+ * call would sail through unjudged for as long as the defect lasted. Low probability; total
53
+ * consequence. One shared class rather than a copy in each bin, so the two can never drift.
54
+ *
55
+ * It writes through `process` directly, and that is correct rather than a leak: by construction there
56
+ * is no container here to have handed it a port, and this is the same designated terminal boundary the
57
+ * ports themselves wrap.
58
+ */
59
+ export declare class HookBootFailure {
60
+ report(err: unknown): void;
61
+ }