@webpieces/ai-hook-rules 0.4.608 → 0.4.610
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/README.md +1 -1
- package/package.json +2 -2
- package/src/adapters/claude-code-response.js +6 -2
- package/src/adapters/claude-code-response.js.map +1 -1
- package/src/adapters/hook-core.js +13 -10
- package/src/adapters/hook-core.js.map +1 -1
- package/src/bin/guarantee-root.js +23 -10
- package/src/bin/guarantee-root.js.map +1 -1
- package/src/bin/l0-allowlist.js +1 -1
- package/src/bin/l0-allowlist.js.map +1 -1
- package/src/bin/shim-audit-log.d.ts +0 -3
- package/src/bin/shim-audit-log.js +14 -13
- package/src/bin/shim-audit-log.js.map +1 -1
- package/src/bin/shim.js +2 -2
- package/src/bin/shim.js.map +1 -1
- package/src/core/decision-log.d.ts +75 -10
- package/src/core/decision-log.js +91 -28
- package/src/core/decision-log.js.map +1 -1
- package/src/core/l0-fault-codes.d.ts +1 -1
- package/src/core/l0-fault-codes.js +1 -1
- package/src/core/l0-fault-codes.js.map +1 -1
- package/src/core/l1-doc.js +6 -0
- package/src/core/l1-doc.js.map +1 -1
- package/src/core/l1-rows.d.ts +13 -0
- package/src/core/l1-rows.js +24 -11
- package/src/core/l1-rows.js.map +1 -1
- package/src/core/log-stream.d.ts +32 -18
- package/src/core/log-stream.js +33 -19
- package/src/core/log-stream.js.map +1 -1
- package/src/core/log-streams.d.ts +38 -0
- package/src/core/log-streams.js +45 -0
- package/src/core/log-streams.js.map +1 -0
- package/src/core/main-sync-log.d.ts +2 -2
- package/src/core/main-sync-log.js +22 -15
- package/src/core/main-sync-log.js.map +1 -1
- package/src/core/main-sync-refresh.d.ts +3 -3
- package/src/core/main-sync-refresh.js +4 -4
- package/src/core/main-sync-refresh.js.map +1 -1
- package/src/core/rejection-log.d.ts +1 -1
- package/src/core/rejection-log.js +13 -12
- package/src/core/rejection-log.js.map +1 -1
- package/src/core/rules/feature-branch-guard.d.ts +11 -0
- package/src/core/rules/feature-branch-guard.js +20 -5
- package/src/core/rules/feature-branch-guard.js.map +1 -1
- package/src/core/rules/merged-branch-bash-guard.d.ts +12 -1
- package/src/core/rules/merged-branch-bash-guard.js +21 -6
- package/src/core/rules/merged-branch-bash-guard.js.map +1 -1
- package/src/core/rules/read-stale-guard.d.ts +11 -0
- package/src/core/rules/read-stale-guard.js +25 -10
- package/src/core/rules/read-stale-guard.js.map +1 -1
- package/src/core/rules/stale-main-bash-guard.d.ts +11 -0
- package/src/core/rules/stale-main-bash-guard.js +22 -7
- package/src/core/rules/stale-main-bash-guard.js.map +1 -1
- package/src/core/runner.js +34 -15
- package/src/core/runner.js.map +1 -1
- package/src/core/sync-main.d.ts +1 -1
- package/src/core/sync-main.js +2 -2
- package/src/core/sync-main.js.map +1 -1
- package/templates/ai-hook.sh +11 -8
- package/templates/guarantee-root.sh +10 -7
package/README.md
CHANGED
|
@@ -48,7 +48,7 @@ openclaw plugins enable @webpieces/ai-hook-rules
|
|
|
48
48
|
(`hookGuards` section): bash git/PR guards on `Bash`, and file guards like
|
|
49
49
|
`feature-branch-guard` on `Write|Edit|MultiEdit`. `Read` carries no guard — it is a
|
|
50
50
|
log-and-allow fast path that records every file the AI opens in
|
|
51
|
-
`.webpieces/logs/<session>-<agent>-<hook
|
|
51
|
+
`.webpieces/logs/calls/<session>-<agent>-<hook>.log`
|
|
52
52
|
(never blocked), so you can see whether the AI read a project's `design.json` before editing it.
|
|
53
53
|
|
|
54
54
|
For each guard hook the setup command prompts for a target: project `.claude/settings.json`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webpieces/ai-hook-rules",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.610",
|
|
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.
|
|
28
|
+
"@webpieces/rules-config": "0.4.610"
|
|
29
29
|
},
|
|
30
30
|
"publishConfig": {
|
|
31
31
|
"access": "public"
|
|
@@ -57,7 +57,7 @@ function denyJson(reason, toolName) {
|
|
|
57
57
|
// PreToolUse protocol (exit 0 + JSON = the contract), so the process.exit stays and is allowlisted.
|
|
58
58
|
//
|
|
59
59
|
// Being the ONE boundary every path exits through is also why the per-invocation audit line is
|
|
60
|
-
// flushed HERE:
|
|
60
|
+
// flushed HERE: the `calls/` stream carries the outcome of its own call, and the outcome is not
|
|
61
61
|
// known until this point. `rule` names what blocked (or '-'), for the line's `rule=` field.
|
|
62
62
|
//
|
|
63
63
|
// `fault` is the L0 fault code when the block IS an L0 fault (S/C/Y — the three decided here in JS,
|
|
@@ -65,7 +65,11 @@ function denyJson(reason, toolName) {
|
|
|
65
65
|
// what makes `grep 'fault=S'` span the whole audit trail rather than only its sh half.
|
|
66
66
|
// 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.
|
|
67
67
|
function emitDeny(reason, toolName, rule = '-', fault = l0_fault_codes_1.L0_FAULT_NONE) {
|
|
68
|
-
|
|
68
|
+
// BLOCK_AI_CURE: every deny that reaches this boundary prints a cure the agent can act on — the
|
|
69
|
+
// L0 faults name a command on the allowlist, and the L1/L2 guards print theirs. A deny needing a
|
|
70
|
+
// HUMAN would have to say so at its own site; none does today, and inventing one here would be
|
|
71
|
+
// guessing at the boundary rather than at the decision.
|
|
72
|
+
decision_log_1.invocationLog.finish('BLOCK_AI_CURE', rule, fault);
|
|
69
73
|
process.stdout.write(denyJson(reason, toolName) + '\n');
|
|
70
74
|
// webpieces-disable no-process-exit-outside-main -- hook exit-code IS the Claude Code PreToolUse protocol (exit 0 + JSON = the contract); designated terminal boundary.
|
|
71
75
|
process.exit(0);
|
|
@@ -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,
|
|
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"]}
|
|
@@ -21,7 +21,7 @@ const log_stream_1 = require("../core/log-stream");
|
|
|
21
21
|
const l0_fault_codes_1 = require("../core/l0-fault-codes");
|
|
22
22
|
const HANDLED_FILE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit']);
|
|
23
23
|
// Read-only tools carry NO guard or code rule, but the guards hook owns the per-invocation audit log
|
|
24
|
-
// (
|
|
24
|
+
// (the `calls/` stream). When the guards matcher includes these (see setup.ts GUARDS_HOOK), a
|
|
25
25
|
// log-and-allow fast path records every file the AI opens — so a human can later inspect whether it
|
|
26
26
|
// read a project's design.json BEFORE editing the project. Never blocked. Scoped to Read for now;
|
|
27
27
|
// widen (Grep/Glob/NotebookRead) later if desired.
|
|
@@ -99,11 +99,14 @@ function handleBash(payload, cwd, mode) {
|
|
|
99
99
|
if (!result) {
|
|
100
100
|
(0, claude_code_response_1.emitAllow)();
|
|
101
101
|
}
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
102
|
+
// NO DECISION LINE HERE. This used to write a generic `bash-guard` line because a Bash deny once
|
|
103
|
+
// had no audit trail at all — but every layer now records its own: L1 into `L1-location/` with its
|
|
104
|
+
// row, L2's guards into `L2-decisions/` with their rule and cache, and emitDeny below stamps the
|
|
105
|
+
// call-level outcome onto `calls/`. So this was the THIRD line for one block, and the worst of the
|
|
106
|
+
// three: it re-resolved the root from `cwd` via RepoRootFinder, which is not necessarily the tree
|
|
107
|
+
// the guard actually judged, so a `cd`-relocated command scattered one block across two different
|
|
108
|
+
// `.webpieces` directories.
|
|
109
|
+
//
|
|
107
110
|
// Bash deny → pass 'Bash' so denyJson adds the ANSI-red systemMessage (the only field a Bash deny
|
|
108
111
|
// shows the human; permissionDecisionReason is invisible on Bash). See claude-code-response.ts.
|
|
109
112
|
(0, claude_code_response_1.emitDeny)(result.report, 'Bash', blockingRule(result.report, 'bash-guard'), result.fault);
|
|
@@ -153,7 +156,7 @@ function handleFileTool(payload, cwd, mode) {
|
|
|
153
156
|
// root, not the AI's cwd — resolve it so a config edit from a subdir doesn't create a
|
|
154
157
|
// stray `<subdir>/.webpieces` tree.
|
|
155
158
|
const root = new rules_config_1.RepoRootFinder().resolveRepoRoot(cwd);
|
|
156
|
-
(0, decision_log_1.logGuardDecision)(root, new decision_log_1.GuardDecision('feature-branch-guard', toolKind, input.filePath, (0, decision_log_1.branchForLog)(root), '
|
|
159
|
+
(0, decision_log_1.logGuardDecision)(root, new decision_log_1.GuardDecision('feature-branch-guard', toolKind, input.filePath, (0, decision_log_1.branchForLog)(root), 'ALLOW_EXEMPT', 'config-bypass (feature-branch-guard skipped)', '-', l0_fault_codes_1.L0_FAULT_NONE, decision_log_1.MATRIX_L2));
|
|
157
160
|
// The guard's own refresh trigger lives inside its check(), which we skip here — so warm
|
|
158
161
|
// the cache directly, otherwise a session that only edits webpieces.config.json never
|
|
159
162
|
// refreshes the sync status. Fire-and-forget; never blocks the edit.
|
|
@@ -221,10 +224,10 @@ function enforceCommittedShim(payload, cwd, mode) {
|
|
|
221
224
|
// `S` storm left NO trace at all: the one fault most likely to block twenty consecutive tool calls
|
|
222
225
|
// was the one fault the trail could not show. A decision line is the fix that costs no reordering.
|
|
223
226
|
const target = payload.tool_input.command ?? payload.tool_input.file_path ?? '';
|
|
224
|
-
(0, decision_log_1.logGuardDecision)(root, new decision_log_1.GuardDecision('committed-shim-stale', payload.tool_name, target, (0, decision_log_1.branchForLog)(root), '
|
|
227
|
+
(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));
|
|
225
228
|
// L0 fault S in GUARD_MATRIX.md's codebook — named as the blocking rule so the invocation line
|
|
226
229
|
// says WHAT stopped the call, not merely that something did, and stamped as `fault=S` so the same
|
|
227
|
-
// grep finds it here as in the sh half's
|
|
230
|
+
// grep finds it here as in the sh half's `L0-shim/` stream.
|
|
228
231
|
(0, claude_code_response_1.emitDeny)((0, shim_1.shimStaleDenyReason)((0, shim_1.installedShimRulesVersion)(), shimRoot ?? '', drifted) + (0, l0_matrix_1.guardMatrixPointer)(docPath), payload.tool_name, 'committed-shim-stale', l0_fault_codes_1.L0_FAULT_SHIM_STALE);
|
|
229
232
|
}
|
|
230
233
|
/**
|
|
@@ -272,7 +275,7 @@ async function runMain(mode) {
|
|
|
272
275
|
handleRead(readPath, cwd, mode);
|
|
273
276
|
(0, claude_code_response_1.emitAllow)();
|
|
274
277
|
}
|
|
275
|
-
// Per-invocation guard log (
|
|
278
|
+
// Per-invocation guard log (the `calls/` stream): tool + command/file + live branch +
|
|
276
279
|
// main-sync-status snapshot, on EVERY guards call, for later cleanup automation. Best-effort;
|
|
277
280
|
// never blocks the call. (The committed shim is no longer silently healed here — a mismatch is
|
|
278
281
|
// reported by the self-guard above, not rewritten out from under the AI.)
|
|
@@ -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 { AgentIdentity } from '../core/coordinator-worktree';\nimport { logRejection, extractRuleNames } from '../core/rejection-log';\nimport { logGuardDecision, GuardDecision, branchForLog, invocationLog } 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, shimStaleDenyReason, installedShimRulesVersion } from '../bin/shim';\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 } 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// (guard-invocations.log). 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 (the main agent\n // loop) — there is no positive coordinator field to read. See AgentIdentity.\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 caller behind this payload. Both fields are sent only inside a subagent, so BOTH absent (or\n// empty) is the coordinator — the one distinction CoordinatorWorktreeGuard turns on.\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction agentIdentityOf(payload: ClaudeCodePayload): AgentIdentity {\n return new AgentIdentity(payload.agent_id ?? '', payload.agent_type ?? '');\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 agent = agentIdentityOf(payload);\n const result = runBash(command, cwd, mode, agent);\n if (!result) { emitAllow(); }\n // Persist the block + WHY. File-tool denies go to hook-rejection.log via logRejection, but a Bash\n // deny had no audit trail — record it in guard-sync-decisions.log so \"blocked and why\" is complete\n // for Bash too. `.webpieces` lives at the repo root, resolved from cwd. Best-effort; never blocks.\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n logGuardDecision(root, new GuardDecision('bash-guard', 'Bash', command ?? '', branchForLog(root), 'BLOCK', result.report, '-', result.fault));\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', 'config-bypass (feature-branch-guard skipped)'),\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 — the shim, the L-1 hook, or the settings.json\n // registration. Nothing validated the registration before, so a settings file left on the old\n // two-absolute-hook form silently reverted a repo to per-PRIMARY governance and disabled L-1\n // entirely: the one component whose whole job is failing closed, switched off with no signal.\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', 'L0 fault S (committed shim != renderShim)', '-', L0_FAULT_SHIM_STALE),\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 ai-hook-shim.log.\n emitDeny(shimStaleDenyReason(installedShimRulesVersion(), shimRoot ?? '', drifted) + 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 (guard-invocations.log): 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":";;AAmPA,8DAKC;AAyDD,0BAgEC;;AAjXD,mDAA6B;AAE7B,2CAAuD;AACvD,uEAA6D;AAC7D,yDAAuE;AACvE,uDAA0H;AAC1H,iEAAmE;AACnE,qDAAsD;AACtD,0DAAyD;AACzD,yCAAqI;AACrI,+CAA2C;AAC3C,iEAA6D;AAC7D,sCAA2G;AAC3G,gEAA+D;AAC/D,iDAA4E;AAC5E,mDAA+D;AAC/D,2DAA4E;AAW5E,MAAM,kBAAkB,GAAG,IAAI,GAAG,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,CAAC,CAAC,CAAC;AAEnE,qGAAqG;AACrG,8FAA8F;AAC9F,oGAAoG;AACpG,kGAAkG;AAClG,mDAAmD;AACnD,MAAM,eAAe,GAAG,IAAI,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC;AA6B1C,SAAS,SAAS;IACd,OAAO,IAAI,OAAO,CAAC,CAAC,OAAgC,EAAE,EAAE;QACpD,IAAI,IAAI,GAAG,EAAE,CAAC;QACd,OAAO,CAAC,KAAK,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;QAClC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAa,EAAE,EAAE,GAAG,IAAI,IAAI,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;QAChE,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;QAC7C,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC;QAC7C,IAAI,OAAO,CAAC,KAAK,CAAC,KAAK;YAAE,OAAO,CAAC,EAAE,CAAC,CAAC;IACzC,CAAC,CAAC,CAAC;AACP,CAAC;AAED,SAAS,SAAS,CAAC,GAAW;IAC1B,IAAI,CAAC,GAAG,IAAI,GAAG,CAAC,IAAI,EAAE,KAAK,EAAE;QAAE,OAAO,IAAI,CAAC;IAC3C,8DAA8D;IAC9D,IAAI,CAAC;QACD,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAsB,CAAC;IAChD,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,MAAM,IAAI,qBAAa,CAAC,gDAAgD,KAAK,CAAC,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC;IAC/G,CAAC;AACL,CAAC;AAED,SAAS,iBAAiB,CAAC,QAAgB;IACvC,IAAI,kBAAkB,CAAC,GAAG,CAAC,QAAQ,CAAC;QAAE,OAAO,QAAoB,CAAC;IAClE,OAAO,IAAI,CAAC;AAChB,CAAC;AAED,SAAS,kBAAkB,CAAC,QAAkB,EAAE,SAA8B;IAC1E,MAAM,QAAQ,GAAG,SAAS,CAAC,SAAS,CAAC;IACrC,IAAI,CAAC,QAAQ;QAAE,OAAO,IAAI,CAAC;IAE3B,IAAI,QAAQ,KAAK,OAAO,EAAE,CAAC;QACvB,OAAO,IAAI,2BAAmB,CAAC,QAAQ,EAAE;YACrC,IAAI,sBAAc,CAAC,EAAE,EAAE,SAAS,CAAC,OAAO,IAAI,EAAE,CAAC;SAClD,CAAC,CAAC;IACP,CAAC;IACD,IAAI,QAAQ,KAAK,MAAM,EAAE,CAAC;QACtB,OAAO,IAAI,2BAAmB,CAAC,QAAQ,EAAE;YACrC,IAAI,sBAAc,CAAC,SAAS,CAAC,UAAU,IAAI,EAAE,EAAE,SAAS,CAAC,UAAU,IAAI,EAAE,CAAC;SAC7E,CAAC,CAAC;IACP,CAAC;IACD,IAAI,QAAQ,KAAK,WAAW,EAAE,CAAC;QAC3B,MAAM,GAAG,GAAG,KAAK,CAAC,OAAO,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC;QAClE,MAAM,KAAK,GAAG,GAAG,CAAC,GAAG,CAAC,CAAC,CAAsB,EAAE,EAAE,CAAC,IAAI,sBAAc,CAAC,CAAC,CAAC,UAAU,IAAI,EAAE,EAAE,CAAC,CAAC,UAAU,IAAI,EAAE,CAAC,CAAC,CAAC;QAC9G,OAAO,IAAI,2BAAmB,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC;IACpD,CAAC;IACD,OAAO,IAAI,CAAC;AAChB,CAAC;AAED,kGAAkG;AAClG,qFAAqF;AACrF,0JAA0J;AAC1J,SAAS,eAAe,CAAC,OAA0B;IAC/C,OAAO,IAAI,oCAAa,CAAC,OAAO,CAAC,QAAQ,IAAI,EAAE,EAAE,OAAO,CAAC,UAAU,IAAI,EAAE,CAAC,CAAC;AAC/E,CAAC;AAED,kGAAkG;AAClG,kGAAkG;AAClG,sDAAsD;AACtD,0JAA0J;AAC1J,SAAS,YAAY,CAAC,MAAc,EAAE,QAAgB;IAClD,MAAM,KAAK,GAAG,IAAA,gCAAgB,EAAC,MAAM,CAAC,CAAC;IACvC,OAAO,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC;AACzD,CAAC;AAED,SAAS,UAAU,CAAC,OAA0B,EAAE,GAAW,EAAE,IAAc;IACvE,MAAM,OAAO,GAAG,OAAO,CAAC,UAAU,CAAC,OAAO,CAAC;IAC3C,IAAI,CAAC,OAAO,IAAI,OAAO,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC;QAAC,IAAA,gCAAS,GAAE,CAAC;IAAC,CAAC;IACvD,MAAM,KAAK,GAAG,eAAe,CAAC,OAAO,CAAC,CAAC;IACvC,MAAM,MAAM,GAAG,IAAA,gBAAO,EAAC,OAAO,EAAE,GAAG,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC;IAClD,IAAI,CAAC,MAAM,EAAE,CAAC;QAAC,IAAA,gCAAS,GAAE,CAAC;IAAC,CAAC;IAC7B,iGAAiG;IACjG,mGAAmG;IACnG,iGAAiG;IACjG,mGAAmG;IACnG,kGAAkG;IAClG,kGAAkG;IAClG,4BAA4B;IAC5B,EAAE;IACF,kGAAkG;IAClG,gGAAgG;IAChG,IAAA,+BAAQ,EAAC,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,YAAY,CAAC,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC;AAC7F,CAAC;AAED;;;;;;;GAOG;AACH,0JAA0J;AAC1J,SAAS,UAAU,CAAC,QAAgB,EAAE,GAAW,EAAE,IAAc;IAC7D,IAAI,QAAQ,KAAK,EAAE;QAAE,OAAO;IAC5B,IAAI,MAAM,GAAyB,IAAI,CAAC;IACxC,8DAA8D;IAC9D,IAAI,CAAC;QACD,MAAM,GAAG,IAAA,gBAAO,EAAC,QAAQ,EAAE,GAAG,EAAE,IAAI,CAAC,CAAC;IAC1C,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,KAAK,KAAK,CAAC;QACX,OAAO,CAAC,kCAAkC;IAC9C,CAAC;IACD,IAAI,CAAC,MAAM;QAAE,OAAO;IACpB,IAAA,4BAAY,EAAC,MAAM,EAAE,IAAI,2BAAmB,CAAC,QAAQ,EAAE,EAAE,CAAC,EAAE,MAAM,EAAE,GAAG,CAAC,CAAC;IACzE,IAAA,+BAAQ,EAAC,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,YAAY,CAAC,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC;AAC7F,CAAC;AAED,SAAS,cAAc,CAAC,OAA0B,EAAE,GAAW,EAAE,IAAc;IAC3E,MAAM,QAAQ,GAAG,iBAAiB,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;IACtD,IAAI,CAAC,QAAQ,EAAE,CAAC;QAAC,IAAA,gCAAS,GAAE,CAAC;IAAC,CAAC;IAE/B,MAAM,KAAK,GAAG,kBAAkB,CAAC,QAAQ,EAAE,OAAO,CAAC,UAAU,CAAC,CAAC;IAC/D,IAAI,CAAC,KAAK,EAAE,CAAC;QAAC,IAAA,gCAAS,GAAE,CAAC;IAAC,CAAC;IAE5B,+FAA+F;IAC/F,gGAAgG;IAChG,gGAAgG;IAChG,IAAI,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,QAAQ,CAAC,KAAK,6BAAe,EAAE,CAAC;QACpD,IAAI,IAAI,KAAK,OAAO,EAAE,CAAC;YACnB,wFAAwF;YACxF,sFAAsF;YACtF,oCAAoC;YACpC,MAAM,IAAI,GAAG,IAAI,6BAAc,EAAE,CAAC,eAAe,CAAC,GAAG,CAAC,CAAC;YACvD,IAAA,+BAAgB,EACZ,IAAI,EACJ,IAAI,4BAAa,CAAC,sBAAsB,EAAE,QAAQ,EAAE,KAAK,CAAC,QAAQ,EAAE,IAAA,2BAAY,EAAC,IAAI,CAAC,EAAE,cAAc,EAAE,8CAA8C,EAAE,GAAG,EAAE,8BAAa,EAAE,wBAAS,CAAC,CACzL,CAAC;YACF,yFAAyF;YACzF,sFAAsF;YACtF,qEAAqE;YACrE,IAAA,0CAAsB,EAAC,IAAI,CAAC,CAAC;QACjC,CAAC;QACD,IAAA,gCAAS,GAAE,CAAC;IAChB,CAAC;IAED,MAAM,MAAM,GAAG,IAAA,YAAG,EAAC,QAAQ,EAAE,KAAK,EAAE,GAAG,EAAE,IAAI,CAAC,CAAC;IAC/C,IAAI,CAAC,MAAM,EAAE,CAAC;QAAC,IAAA,gCAAS,GAAE,CAAC;IAAC,CAAC;IAE7B,IAAA,4BAAY,EAAC,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,CAAC,CAAC;IAC3C,kGAAkG;IAClG,8EAA8E;IAC9E,IAAA,+BAAQ,EAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,EAAE,YAAY,CAAC,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC;AAC/F,CAAC;AAmCD,2JAA2J;AAC3J,SAAgB,yBAAyB,CAAC,QAAgB,EAAE,OAAe,EAAE,QAAgB;IACzF,MAAM,OAAO,GAAG,IAAA,gBAAS,EAAC,QAAQ,EAAE,OAAO,EAAE,QAAQ,CAAC,CAAC;IACvD,IAAI,OAAO,KAAK,MAAM;QAAE,OAAO,MAAM,CAAC;IACtC,IAAI,OAAO,KAAK,OAAO;QAAE,OAAO,YAAY,CAAC;IAC7C,OAAO,MAAM,CAAC;AAClB,CAAC;AAED,uGAAuG;AACvG,4CAA4C;AAC5C,qGAAqG;AACrG,uGAAuG;AACvG,mGAAmG;AACnG,sGAAsG;AACtG,mGAAmG;AACnG,6FAA6F;AAC7F,6GAA6G;AAC7G,oGAAoG;AACpG,kFAAkF;AAClF,0JAA0J;AAC1J,SAAS,oBAAoB,CAAC,OAA0B,EAAE,GAAW,EAAE,IAAc;IACjF,oGAAoG;IACpG,gGAAgG;IAChG,qGAAqG;IACrG,wGAAwG;IACxG,6BAA6B;IAC7B,MAAM,QAAQ,GAAG,IAAA,wBAAiB,GAAE,CAAC;IACrC,IAAI,IAAI,KAAK,OAAO;QAAE,OAAO;IAC7B,yFAAyF;IACzF,8FAA8F;IAC9F,6FAA6F;IAC7F,8FAA8F;IAC9F,MAAM,OAAO,GAAG,IAAA,uCAAmB,EAAC,QAAQ,CAAC,CAAC;IAC9C,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO;IACjC,MAAM,QAAQ,GAAG,yBAAyB,CAAC,OAAO,CAAC,SAAS,EAAE,OAAO,CAAC,UAAU,CAAC,OAAO,IAAI,EAAE,EAAE,OAAO,CAAC,UAAU,CAAC,SAAS,IAAI,EAAE,CAAC,CAAC;IACpI,IAAI,QAAQ,KAAK,MAAM;QAAE,OAAO;IAChC,IAAI,QAAQ,KAAK,YAAY;QAAE,IAAA,gCAAS,GAAE,CAAC;IAC3C,kGAAkG;IAClG,6FAA6F;IAC7F,MAAM,IAAI,GAAG,IAAI,6BAAc,EAAE,CAAC,eAAe,CAAC,GAAG,CAAC,CAAC;IACvD,MAAM,OAAO,GAAG,IAAA,+BAAmB,EAAC,IAAI,CAAC,CAAC;IAC1C,kGAAkG;IAClG,kGAAkG;IAClG,mGAAmG;IACnG,mGAAmG;IACnG,MAAM,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,OAAO,IAAI,OAAO,CAAC,UAAU,CAAC,SAAS,IAAI,EAAE,CAAC;IAChF,IAAA,+BAAgB,EACZ,IAAI,EACJ,IAAI,4BAAa,CAAC,sBAAsB,EAAE,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,IAAA,2BAAY,EAAC,IAAI,CAAC,EAAE,eAAe,EAAE,2CAA2C,EAAE,GAAG,EAAE,oCAAmB,EAAE,wBAAS,CAAC,CAC9L,CAAC;IACF,+FAA+F;IAC/F,kGAAkG;IAClG,4DAA4D;IAC5D,IAAA,+BAAQ,EAAC,IAAA,0BAAmB,EAAC,IAAA,gCAAyB,GAAE,EAAE,QAAQ,IAAI,EAAE,EAAE,OAAO,CAAC,GAAG,IAAA,8BAAkB,EAAC,OAAO,CAAC,EAAE,OAAO,CAAC,SAAS,EAAE,sBAAsB,EAAE,oCAAmB,CAAC,CAAC;AACtL,CAAC;AAED;;;;;;GAMG;AACI,KAAK,UAAU,OAAO,CAAC,IAAc;IACxC,kGAAkG;IAClG,gGAAgG;IAChG,+FAA+F;IAC/F,IAAI,QAAQ,GAAG,EAAE,CAAC;IAClB,8DAA8D;IAC9D,IAAI,CAAC;QACD,MAAM,GAAG,GAAG,MAAM,SAAS,EAAE,CAAC;QAC9B,MAAM,OAAO,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC;QAC/B,IAAI,CAAC,OAAO,EAAE,CAAC;YAAC,IAAA,gCAAS,GAAE,CAAC;QAAC,CAAC;QAC9B,QAAQ,GAAG,OAAO,CAAC,SAAS,CAAC;QAE7B,4FAA4F;QAC5F,sFAAsF;QACtF,sBAAS,CAAC,QAAQ,CAAC,IAAI,2BAAc,CAAC,OAAO,CAAC,UAAU,IAAI,EAAE,EAAE,OAAO,CAAC,QAAQ,IAAI,EAAE,EAAE,IAAI,CAAC,CAAC,CAAC;QAE/F,sFAAsF;QACtF,yFAAyF;QACzF,uFAAuF;QACvF,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,IAAI,OAAO,CAAC,GAAG,EAAE,CAAC;QAEzC,+FAA+F;QAC/F,wGAAwG;QACxG,oBAAoB,CAAC,OAAO,EAAE,GAAG,EAAE,IAAI,CAAC,CAAC;QAEzC,4FAA4F;QAC5F,+FAA+F;QAC/F,2EAA2E;QAC3E,yEAAyE;QACzE,IAAI,eAAe,CAAC,GAAG,CAAC,OAAO,CAAC,SAAS,CAAC,EAAE,CAAC;YACzC,MAAM,QAAQ,GAAG,OAAO,CAAC,UAAU,CAAC,SAAS,IAAI,EAAE,CAAC;YACpD,IAAI,IAAI,KAAK,OAAO,EAAE,CAAC;gBACnB,4BAAa,CAAC,KAAK,CAAC,GAAG,EAAE,OAAO,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;gBACtD,qFAAqF;gBACrF,iFAAiF;gBACjF,IAAA,0CAAsB,EAAC,GAAG,CAAC,CAAC;YAChC,CAAC;YACD,UAAU,CAAC,QAAQ,EAAE,GAAG,EAAE,IAAI,CAAC,CAAC;YAChC,IAAA,gCAAS,GAAE,CAAC;QAChB,CAAC;QAED,sFAAsF;QACtF,8FAA8F;QAC9F,+FAA+F;QAC/F,0EAA0E;QAC1E,IAAI,IAAI,KAAK,OAAO,EAAE,CAAC;YACnB,MAAM,MAAM,GAAG,OAAO,CAAC,SAAS,KAAK,MAAM,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,UAAU,CAAC,OAAO,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,UAAU,CAAC,SAAS,IAAI,EAAE,CAAC,CAAC;YACxH,4BAAa,CAAC,KAAK,CAAC,GAAG,EAAE,OAAO,CAAC,SAAS,EAAE,MAAM,CAAC,CAAC;QACxD,CAAC;QAED,IAAI,OAAO,CAAC,SAAS,KAAK,MAAM,EAAE,CAAC;YAC/B,qEAAqE;YACrE,IAAI,IAAI,KAAK,OAAO,EAAE,CAAC;gBAAC,IAAA,gCAAS,GAAE,CAAC;YAAC,CAAC;YACtC,UAAU,CAAC,OAAO,EAAE,GAAG,EAAE,IAAI,CAAC,CAAC;YAC/B,OAAO;QACX,CAAC;QAED,+EAA+E;QAC/E,8EAA8E;QAC9E,cAAc,CAAC,OAAO,EAAE,GAAG,EAAE,IAAI,CAAC,CAAC;IACvC,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,YAAY,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC;IAClC,CAAC;AACL,CAAC;AAED;;;;;GAKG;AACH,oLAAoL;AACpL,SAAS,YAAY,CAAC,KAAY,EAAE,QAAgB;IAChD,IAAI,KAAK,YAAY,qBAAa,EAAE,CAAC;QACjC,IAAA,+BAAQ,EAAC,KAAK,CAAC,SAAS,EAAE,QAAQ,EAAE,YAAY,CAAC,CAAC;IACtD,CAAC;IACD,IAAI,KAAK,YAAY,qBAAa,EAAE,CAAC;QACjC,IAAA,+BAAQ,EAAC,KAAK,CAAC,OAAO,EAAE,QAAQ,EAAE,qBAAqB,CAAC,CAAC;IAC7D,CAAC;IACD,IAAA,+BAAQ,EAAC,0DAA0D,KAAK,CAAC,OAAO,EAAE,EAAE,QAAQ,EAAE,YAAY,CAAC,CAAC;AAChH,CAAC","sourcesContent":["import * as path from 'path';\n\nimport { run, runBash, runRead } from '../core/runner';\nimport { AgentIdentity } from '../core/coordinator-worktree';\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, shimStaleDenyReason, installedShimRulesVersion } from '../bin/shim';\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 (the main agent\n // loop) — there is no positive coordinator field to read. See AgentIdentity.\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 caller behind this payload. Both fields are sent only inside a subagent, so BOTH absent (or\n// empty) is the coordinator — the one distinction CoordinatorWorktreeGuard turns on.\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction agentIdentityOf(payload: ClaudeCodePayload): AgentIdentity {\n return new AgentIdentity(payload.agent_id ?? '', payload.agent_type ?? '');\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 agent = agentIdentityOf(payload);\n const result = runBash(command, cwd, mode, agent);\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 — the shim, the L-1 hook, or the settings.json\n // registration. Nothing validated the registration before, so a settings file left on the old\n // two-absolute-hook form silently reverted a repo to per-PRIMARY governance and disabled L-1\n // entirely: the one component whose whole job is failing closed, switched off with no signal.\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 emitDeny(shimStaleDenyReason(installedShimRulesVersion(), shimRoot ?? '', drifted) + 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"]}
|
|
@@ -8,6 +8,8 @@ exports.committedGuaranteeRootStale = committedGuaranteeRootStale;
|
|
|
8
8
|
const tslib_1 = require("tslib");
|
|
9
9
|
const fs = tslib_1.__importStar(require("fs"));
|
|
10
10
|
const path = tslib_1.__importStar(require("path"));
|
|
11
|
+
const rules_config_1 = require("@webpieces/rules-config");
|
|
12
|
+
const log_streams_1 = require("../core/log-streams");
|
|
11
13
|
const to_error_1 = require("../core/to-error");
|
|
12
14
|
/**
|
|
13
15
|
* L-1 — the layer BELOW L0: guarantee the shell stays somewhere the RELATIVE guard hooks can launch.
|
|
@@ -120,9 +122,17 @@ const DENY_SUBDIR = 'The webpieces guard hooks are registered RELATIVE (.claude/
|
|
|
120
122
|
* own file under the same session/agent/hook key LogStream uses — one writer per directory, so an
|
|
121
123
|
* append can never interleave with another's (macOS PIPE_BUF is 512 bytes and real log lines exceed it).
|
|
122
124
|
*
|
|
123
|
-
* Unlike the shim's RESOLVE_LOG_DIR_SH this needs NO worktree resolution:
|
|
124
|
-
* anchored by definition, so
|
|
125
|
-
*
|
|
125
|
+
* Unlike the shim's RESOLVE_LOG_DIR_SH this needs NO worktree resolution: L-1 is $CLAUDE_PROJECT_DIR-
|
|
126
|
+
* anchored by definition, so that tree's `.webpieces` is always the right home. That is the one upside
|
|
127
|
+
* of being the hook that cannot follow the tree, and it is kept DELIBERATELY.
|
|
128
|
+
*
|
|
129
|
+
* The cost is stated rather than hidden: for a call inside a linked worktree, L-1's line lands under
|
|
130
|
+
* $CLAUDE_PROJECT_DIR while L0's lands under `worktrees/<name>/`, so the two halves of one tool call
|
|
131
|
+
* sit in different roots. The cure would be to splice RESOLVE_LOG_DIR_SH in here — and that buys a
|
|
132
|
+
* `git rev-parse` subprocess on EVERY Bash call, paid by the one layer whose whole guarantee is that
|
|
133
|
+
* it reads no config, spawns no binary and touches no network. Trading that guarantee for tidier log
|
|
134
|
+
* placement is the wrong way round: `L-1-cd/` is always at $CLAUDE_PROJECT_DIR, which is a rule a
|
|
135
|
+
* reader can simply know.
|
|
126
136
|
*
|
|
127
137
|
* Every write is swallowed and nothing ever reaches stdout — stdout is the PreToolUse decision channel
|
|
128
138
|
* and a stray byte there would corrupt allow/deny.
|
|
@@ -137,16 +147,19 @@ clean() { printf '%s' "$1" | tr -c 'A-Za-z0-9._-' '_' \\
|
|
|
137
147
|
wp_cd_log() { # $1 = verdict, $2 = destination (may be empty)
|
|
138
148
|
{
|
|
139
149
|
[ -n "$CLAUDE_PROJECT_DIR" ] || return 0
|
|
140
|
-
_d="$CLAUDE_PROJECT_DIR
|
|
150
|
+
_d="$CLAUDE_PROJECT_DIR/${rules_config_1.WEBPIECES_TMP_DIR}/${rules_config_1.LOGS_STATE_DIR}/${log_streams_1.LMINUS1_CD_STREAM}"
|
|
141
151
|
mkdir -p "$_d" 2>/dev/null || return 0
|
|
142
|
-
#
|
|
143
|
-
# ALWAYS
|
|
144
|
-
|
|
145
|
-
|
|
152
|
+
# The LAYER is the directory; this is the WRITER, keyed exactly like LogStream.writerFile():
|
|
153
|
+
# <session>-<agent|coordinator>-guarantee-root.log. ALWAYS keyed; a missing session_id renders as
|
|
154
|
+
# 'unknown'. No bare-name branch anywhere.
|
|
155
|
+
_p="$(clean "\${SID:-unknown}")-$(clean "\${AID:-coordinator}")-guarantee-root"
|
|
156
|
+
_f="$_d/\${_p}.log"
|
|
146
157
|
_sz="$(wc -c < "$_f" 2>/dev/null | tr -d ' ')"
|
|
147
158
|
case "$_sz" in ''|*[!0-9]*) _sz=0 ;; esac
|
|
148
|
-
[ "$_sz" -gt 524288 ] && mv -f "$_f" "$_d/\${_p}
|
|
149
|
-
|
|
159
|
+
[ "$_sz" -gt 524288 ] && mv -f "$_f" "$_d/\${_p}.1.log" 2>/dev/null
|
|
160
|
+
# fault=- is a constant here: L-1 detects no L0 fault. It is present so ONE grep spans every
|
|
161
|
+
# hook-written stream rather than needing a different field list per layer.
|
|
162
|
+
printf '%s\\t%s\\tfault=-\\tdest=%s\\tcwd=%s\\t%s\\n' \\
|
|
150
163
|
"$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null)" "$1" "$2" "$CWD" "$CMD" >> "$_f"
|
|
151
164
|
} 2>/dev/null || true
|
|
152
165
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"guarantee-root.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/guarantee-root.ts"],"names":[],"mappings":";;;AAiFA,8CAEC;AAOD,gDAMC;AAsGD,kDAkEC;AAYD,kEAYC;;AAhSD,+CAAyB;AACzB,mDAA6B;AAE7B,+CAA2C;AAE3C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuEG;AAEU,QAAA,qBAAqB,GAAG,qCAAqC,CAAC;AAE3E,6NAA6N;AAC7N,SAAgB,iBAAiB,CAAC,WAAmB;IACjD,OAAO,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,SAAS,EAAE,WAAW,EAAE,mBAAmB,CAAC,CAAC;AAC/E,CAAC;AAED;;;GAGG;AACH,wJAAwJ;AACxJ,SAAgB,kBAAkB,CAAC,WAAmB;IAClD,MAAM,MAAM,GAAG,iBAAiB,CAAC,WAAW,CAAC,CAAC;IAC9C,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACxD,EAAE,CAAC,aAAa,CAAC,MAAM,EAAE,mBAAmB,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IACjE,0FAA0F;IAC1F,EAAE,CAAC,SAAS,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AAChC,CAAC;AAED,sGAAsG;AACtG,6FAA6F;AAC7F,2DAA2D;AAC3D,MAAM,gBAAgB,GAClB,+FAA+F;MAC7F,+DAA+D;MAC/D,+DAA+D,CAAC;AAEtE,MAAM,cAAc,GAChB,8FAA8F;MAC5F,8FAA8F;MAC9F,oEAAoE,CAAC;AAE3E,qGAAqG;AACrG,kGAAkG;AAClG,MAAM,WAAW,GACb,gGAAgG;MAC9F,8FAA8F;MAC9F,iGAAiG;MACjG,0FAA0F;MAC1F,gGAAgG;MAChG,wBAAwB,CAAC;AAE/B;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,GAAG;;;;;;;;;;;;;;;;;;;;;;;CAuBnB,CAAC;AAGF,+FAA+F;AAC/F,sGAAsG;AACtG,qGAAqG;AACrG,MAAM,YAAY,GAAG;;;;;;CAMpB,CAAC;AAEF,+FAA+F;AAC/F,oGAAoG;AACpG,8BAA8B;AAC9B,MAAM,SAAS,GAAG;;;;;;;;;;;;;;;;;CAiBjB,CAAC;AAEF;;;;GAIG;AACH,kLAAkL;AAClL,SAAgB,mBAAmB;IAC/B,OAAO,GAAG,SAAS;;;;;;;;;;;;;;;;;;;EAmBrB,WAAW;;;;;;;;;;;;;;;;YAgBD,cAAc;;;;;sCAKY,gBAAgB;;;;;;;;;;;;;;;;;;;;;sBAqBhC,WAAW;;;EAG/B,YAAY,EAAE,CAAC;AACjB,CAAC;AAED;;;;;;;;GAQG;AACH,8IAA8I;AAC9I,SAAgB,2BAA2B,CAAC,IAAmB;IAC3D,IAAI,IAAI,KAAK,IAAI;QAAE,OAAO,KAAK,CAAC;IAChC,MAAM,IAAI,GAAG,iBAAiB,CAAC,IAAI,CAAC,CAAC;IACrC,8DAA8D;IAC9D,IAAI,CAAC;QACD,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC;YAAE,OAAO,KAAK,CAAC;QACvC,OAAO,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,KAAK,mBAAmB,EAAE,CAAC;IACnE,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,KAAK,KAAK,CAAC,CAAC,oFAAoF;QAChG,OAAO,KAAK,CAAC;IACjB,CAAC;AACL,CAAC","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport { toError } from '../core/to-error';\n\n/**\n * L-1 — the layer BELOW L0: guarantee the shell stays somewhere the RELATIVE guard hooks can launch.\n *\n * ─── Why a layer below L0 exists at all ────────────────────────────────────────────────────────────\n * From the Claude Code hooks reference: exit 2 is the blocking channel, exit 0 carries the JSON\n * decision, and ANY OTHER exit is a \"non-blocking error. Execution continues; the action proceeds\" —\n * including \"File missing or not executable: Error logged; tool proceeds.\"\n *\n * A HOOK THAT FAILS TO LAUNCH IS A SILENT ALLOW. Not a block, not an error the AI sees.\n *\n * Every layer L0-L4 assumes the hook process ran at all. This file is what makes that assumption true.\n *\n * ─── Why the guard hooks become RELATIVE ───────────────────────────────────────────────────────────\n * `.claude/settings.json` registers hooks as `sh \"$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh\"`,\n * and `$CLAUDE_PROJECT_DIR` NEVER moves — proven from four separate worktrees' own logs, every line\n * reading `root=<worktree> projectDir=<primary>`. So every tree is governed by the PRIMARY's shim and\n * the PRIMARY's binary, forever: a worktree can never be judged by the release its own branch pins, and\n * measuring one tree while running another's binary is the non-convergent \"two-tree straddle\" recorded\n * in shim.ts (an agent gave up after four cures).\n *\n * The fix is to re-root the WHOLE hook, not to measure differently. The hooks reference says \"the hook\n * runs in the cwd value from the JSON input\", so a RELATIVE command resolves against the tool call's\n * own tree — each tree runs its own shim, its own binary, its own pin. One version, one tree.\n *\n * That is only safe if the relative path is guaranteed to resolve. Hence this file.\n *\n * ─── Why this is a SEPARATE checked-in file from ai-hook.sh ────────────────────────────────────────\n * 1. It is the ONE file that must stay $CLAUDE_PROJECT_DIR-anchored, so it is the one file that cannot\n * be upgraded per-tree. Its surface is therefore kept minimal: a path check. No drift scraper, no\n * allowlist, no config load, no binary — nothing that needs to change often.\n * 2. Its stability profile is the OPPOSITE of ai-hook.sh's. The shim changes most releases; a `cd`\n * validator should converge and stop. Sharing one file forces the stable half to inherit the\n * volatile half's churn — which is today's problem, one level up.\n * 3. A NEW FILE IS INVISIBLE TO OLD RELEASES; A NEW FLAG BREAKS THEM. Measured 2026-08-06: registering\n * `ai-hook.sh --force-root` made the installed shim read `--force-root` as a BIN NAME, fail to find\n * it, and emit fault U — denying `ls` and the very `cd` that would have fixed it:\n * 16:32:20 --force-root Bash tree=primary fault=U DENY-UNDECLARED ls\n * A separate file is simply not registered until a release that ships it, so old releases are safe.\n *\n * ─── The invariant, maintained inductively ─────────────────────────────────────────────────────────\n * A session always starts at a tree root (primary for the coordinator, the worktree for a subagent).\n * The only thing that moves the shell is a `cd`, and (measured 2026-08-02, effective-tree.ts) a `cd`\n * that stays INSIDE the workspace PERSISTS to later calls, while a `cd` that LEAVES it is RESET by the\n * harness before the next call.\n *\n * If every `cd` that would leave a tree root is refused, \"the shell is at a tree root\" is an\n * INDUCTIVE INVARIANT — so this hook never inspects cwd as a state, only the command.\n *\n * That is also why it is registered for Bash ALONE: no other tool can move the shell.\n *\n * ─── The predicate, in three tests ─────────────────────────────────────────────────────────────────\n * 1. destination holds `.git` → ALLOW. Complete by construction: the primary clone has a .git DIR,\n * every linked worktree (nested or sibling) has a .git FILE, and a nested foreign clone under\n * repositories/** has its own .git DIR. The first two are where the relative hooks launch; the\n * third is a tree we deliberately do not govern.\n * 2. destination is OUTSIDE $CLAUDE_PROJECT_DIR → ALLOW. The harness resets it before the next call,\n * so at most ONE call runs at a path we do not govern — and there is nothing there to guard.\n * 3. otherwise (inside a governed tree, no .git — `tools/`, `dataform/`, `packages/…`) → DENY. This\n * is the only region that is both STICKY and UNGUARDED.\n *\n * It reads NO config. `excludePaths` governs which FILES are enforced, not whether the hook may run,\n * and it cannot be parsed here anyway (this is sh, pre-config, pre-binary). The one measured divergence\n * is `tools/**`: exempt by excludePaths, denied here — correctly, because the real hooks genuinely\n * cannot launch there and we genuinely want them to. Cost: 4 calls in a 2,236-call sample.\n *\n * ─── Nothing to recover from ───────────────────────────────────────────────────────────────────────\n * A denied `cd` NEVER EXECUTES — PreToolUse denies the whole tool call before the shell moves — so the\n * shell is still at a root. There is no bad state and no cure command that needs allowlisting, which is\n * what keeps this hook from ever being able to wedge a session.\n *\n * Measured cost of the deny: 47 of 2,236 real Bash calls (2.10%), or 9.7% of all `cd` commands.\n */\n\nexport const GUARANTEE_ROOT_MARKER = '.claude/webpieces/guarantee-root.sh';\n\n// webpieces-disable no-function-outside-class -- L-1 sibling of shim.ts's shimPath(); this module is deliberately dependency-free module-scope functions so it stays callable from a tree too broken to build a DI container\nexport function guaranteeRootPath(projectRoot: string): string {\n return path.join(projectRoot, '.claude', 'webpieces', 'guarantee-root.sh');\n}\n\n/**\n * Write (or overwrite) the committed L-1 hook. Idempotent — the installer and `wp-upgrade-shim` both\n * call it, and re-running either simply re-arms the file. Twin of setup.ts's writeShim().\n */\n// webpieces-disable no-function-outside-class -- L-1 sibling of guaranteeRootPath(); this module is deliberately dependency-free module-scope functions\nexport function writeGuaranteeRoot(projectRoot: string): void {\n const target = guaranteeRootPath(projectRoot);\n fs.mkdirSync(path.dirname(target), { recursive: true });\n fs.writeFileSync(target, renderGuaranteeRoot(), { mode: 0o755 });\n // writeFileSync's mode is only applied when creating the file; force it on overwrite too.\n fs.chmodSync(target, 0o755);\n}\n\n// Deny REASON constraint, inherited from the shim: the text is interpolated into a `REASON=\"…\"` shell\n// assignment and then printf'd into a JSON string, so it may contain NO double quotes and NO\n// backslashes. Single quotes only — do not \"improve\" them.\nconst DENY_NOT_LITERAL =\n 'This cd target is not a literal path, so the guards cannot tell where the shell will end up. '\n + 'Use a literal absolute path: cd /abs/path && <your command>. '\n + 'A $VAR, ~, $(...) or backtick is never expanded by the guard.';\n\nconst DENY_NO_TARGET =\n 'A bare cd (or cd -) moves the shell somewhere the guards cannot predict - a bare cd goes to '\n + 'your home directory, where the webpieces hooks do not exist and every later tool call would '\n + 'run UNGUARDED. Name the directory: cd /abs/path && <your command>.';\n\n// `%s` is the destination, then the project root. Kept to one short paragraph on purpose: L0 already\n// ran a message diet and these denies regress straight back to a wall of text if each one argues.\nconst DENY_SUBDIR =\n 'The webpieces guard hooks are registered RELATIVE (.claude/webpieces/ai-hook.sh) so that each '\n + 'git tree is governed by its own release. %s has no .claude/webpieces/ai-hook.sh, so a shell '\n + 'parked there launches NO hooks at all and every later tool call runs UNGUARDED - and a cd that '\n + 'stays inside the project PERSISTS to your next call. Run it from the tree root instead: '\n + 'cd %s && <your command>. Tools that take their own directory (git -C, pnpm -C, pnpm --filter, '\n + 'nx) need no cd at all.';\n\n/**\n * The cd audit trail. A THIRD parallel writer joins guards+rules on every Bash call, so it gets its\n * own file under the same session/agent/hook key LogStream uses — one writer per directory, so an\n * append can never interleave with another's (macOS PIPE_BUF is 512 bytes and real log lines exceed it).\n *\n * Unlike the shim's RESOLVE_LOG_DIR_SH this needs NO worktree resolution: H1 is $CLAUDE_PROJECT_DIR-\n * anchored by definition, so the primary clone's `.webpieces` is always the right home. That is the one\n * upside of being the hook that cannot follow the tree.\n *\n * Every write is swallowed and nothing ever reaches stdout — stdout is the PreToolUse decision channel\n * and a stray byte there would corrupt allow/deny.\n */\nconst CD_AUDIT_SH = `# --- cd audit (best-effort; never blocks, never touches stdout) --------------------------------\nSID=\"$(printf '%s' \"$PAYLOAD\" | sed -n 's/.*\"session_id\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nAID=\"$(printf '%s' \"$PAYLOAD\" | sed -n 's/.*\"agent_id\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\n# Untrusted payload values are used as path segments, so anything outside [A-Za-z0-9._-] collapses to _\n# and a leading dot is neutralised — ../../etc can never escape the logs directory.\nclean() { printf '%s' \"$1\" | tr -c 'A-Za-z0-9._-' '_' \\\\\n | sed -e 's/\\\\.\\\\{2,\\\\}/_/g' -e 's/^\\\\.\\\\{1,\\\\}/_/' | cut -c1-64; }\nwp_cd_log() { # $1 = verdict, $2 = destination (may be empty)\n {\n [ -n \"$CLAUDE_PROJECT_DIR\" ] || return 0\n _d=\"$CLAUDE_PROJECT_DIR/.webpieces/logs\"\n mkdir -p \"$_d\" 2>/dev/null || return 0\n # Flat name, same scheme as LogStream.fileName(): <session>-<agent|coordinator>-guarantee-root-<base>\n # ALWAYS prefixed; a missing session_id renders as 'unknown'. No bare-name branch anywhere.\n _p=\"$(clean \"\\${SID:-unknown}\")-$(clean \"\\${AID:-coordinator}\")-guarantee-root-\"\n _f=\"$_d/\\${_p}cd-audit.log\"\n _sz=\"$(wc -c < \"$_f\" 2>/dev/null | tr -d ' ')\"\n case \"$_sz\" in ''|*[!0-9]*) _sz=0 ;; esac\n [ \"$_sz\" -gt 524288 ] && mv -f \"$_f\" \"$_d/\\${_p}cd-audit.1.log\" 2>/dev/null\n printf '%s\\\\t%s\\\\tdest=%s\\\\tcwd=%s\\\\t%s\\\\n' \\\\\n \"$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null)\" \"$1\" \"$2\" \"$CWD\" \"$CMD\" >> \"$_f\"\n } 2>/dev/null || true\n}\n`;\n\n\n// The deny boundary. Extracted for the same reason CD_AUDIT_SH and HEADER_SH are — to keep the\n// renderer inside the 70-line method budget — and spliced back verbatim. Mirrors the shim's own emit:\n// for Bash, permissionDecisionReason is NOT user-visible, so the red systemMessage carries the text.\nconst DENY_EMIT_SH = `wp_cd_log DENY \"\\${ABS:-$DEST}\"\n\nBS='\\\\' # one literal backslash, so the \\\\u001b escape never sits in this source\nESC=\"\\${BS}u001b\" # the 6 chars: backslash u 0 0 1 b — Claude Code parses \\\\u001b -> ESC\nprintf '{\"systemMessage\":\"%s🛑 %s%s\",\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"%s\"}}\\\\n' \"\\${ESC}[31;1m\" \"$REASON\" \"\\${ESC}[0m\" \"$REASON\"\nexit 0 # the decision is carried by permissionDecision deny, not the exit code\n`;\n\n// The file's own banner. Extracted to a module const for the same reason renderShim() extracts\n// VERSION_DRIFT_GUARD_SH — to keep the renderer inside the 80-line method budget — and spliced back\n// in verbatim, byte for byte.\nconst HEADER_SH = `#!/bin/sh\n# webpieces L-1 hook — GUARANTEE ROOT. Generated by renderGuaranteeRoot(); do not hand-edit.\n#\n# Registered ABSOLUTE in .claude/settings.json, matcher \"Bash\":\n# sh \"$CLAUDE_PROJECT_DIR/.claude/webpieces/guarantee-root.sh\"\n#\n# It exists because the GUARD hooks beside it are registered RELATIVE, so that each git tree is\n# governed by its own @webpieces release. A relative hook that cannot resolve does not block — the\n# harness logs it and lets the tool call proceed UNGUARDED. This file makes that unreachable by\n# refusing any cd that would park the shell where the relative hooks cannot launch.\n#\n# Three tests, no config, no binary, no network:\n# 1. destination holds .git -> ALLOW (tree root, worktree, or foreign clone)\n# 2. destination is outside $CLAUDE_PROJECT_DIR -> ALLOW (the harness resets the cwd next call)\n# 3. otherwise -> DENY (sticky AND unguarded)\n#\n# A denied cd never runs, so the shell never leaves the root and there is nothing to recover from.\n`;\n\n/**\n * The POSIX-sh source. Byte-identical to `templates/guarantee-root.sh`, locked by a unit test, exactly\n * as renderShim()/templates/ai-hook.sh are — so the file a consumer commits and the file this release\n * expects can never silently diverge.\n */\n// webpieces-disable no-function-outside-class -- L-1 twin of shim.ts's renderShim(), byte-locked to templates/guarantee-root.sh; module-scope for the same dependency-free reason\nexport function renderGuaranteeRoot(): string {\n return `${HEADER_SH}\nPAYLOAD=\"$(cat)\"\nCWD=\"$(printf '%s' \"$PAYLOAD\" | sed -n 's/.*\"cwd\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nTOOL=\"$(printf '%s' \"$PAYLOAD\" | sed -n 's/.*\"tool_name\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\n\n# The command PREFIX, not the whole command — note there is no closing \" in this pattern.\n# WHY: a JSON payload escapes an embedded double quote as \\\\\", and \\`[^\"\\\\\\\\]*\\` stops dead at that\n# backslash, so the usual \"capture between quotes\" form yields the EMPTY STRING for any command\n# containing a quote at all (measured: \\`cd /a/b && echo \"hi\"\\` -> ''). An empty CMD here would mean\n# \"no cd found\" -> ALLOW, i.e. this guard would fail OPEN for every quoted command — the exact hazard\n# it exists to close. Capturing only up to the first quote/backslash is enough, because everything\n# L-1 needs (is the FIRST word a cd, and what is its target) lives in the prefix; a quote can only\n# appear later, in the part we do not need.\nCMD=\"$(printf '%s' \"$PAYLOAD\" | sed -n 's/.*\"command\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\).*/\\\\1/p')\"\n\n# Only Bash can move the shell. Anything else, and any payload we cannot read, is not ours.\n[ \"$TOOL\" = \"Bash\" ] || exit 0\n[ -n \"$CMD\" ] || exit 0\n\n${CD_AUDIT_SH}\n\n# Does the command OPEN with cd/pushd? Only a LEADING cd counts — the same rule effective-tree.ts\n# enforces, because a later cd cannot retroactively move a command that has already run.\nFIRST=\"$(printf '%s' \"$CMD\" | sed -n 's/^[[:space:]]*\\\\([^[:space:]]\\\\{1,\\\\}\\\\).*/\\\\1/p')\"\ncase \"$FIRST\" in\n cd|pushd) ;;\n *) exit 0 ;; # no leading cd: nothing to audit, nothing to judge\nesac\n\n# The target: a single-quoted path first (that is how a path with spaces is spelled), else a bare word.\nDEST=\"$(printf '%s' \"$CMD\" | sed -n \"s/^[[:space:]]*[a-z]\\\\{2,5\\\\}[[:space:]]\\\\{1,\\\\}'\\\\([^']*\\\\)'.*/\\\\1/p\")\"\n[ -n \"$DEST\" ] || DEST=\"$(printf '%s' \"$CMD\" | sed -n 's/^[[:space:]]*[a-z]\\\\{2,5\\\\}[[:space:]]\\\\{1,\\\\}\\\\([^[:space:];&|]\\\\{1,\\\\}\\\\).*/\\\\1/p')\"\n\nREASON=\"\"\nif [ -z \"$DEST\" ] || [ \"$DEST\" = \"-\" ]; then\n REASON='${DENY_NO_TARGET}'\nelse\n # A target the guard cannot expand is a target it cannot judge. sh has no regex here, so test the\n # four unexpandable shapes directly.\n case \"$DEST\" in\n *'$'*|*'\\`'*|'~'|'~/'*) REASON='${DENY_NOT_LITERAL}' ;;\n esac\nfi\n\nif [ -z \"$REASON\" ]; then\n # Resolve against the shell's real cwd. A destination that does not exist needs no verdict: the cd\n # itself will fail and the shell stays exactly where it is.\n ABS=\"$(CDPATH= cd -- \"\\${CWD:-.}\" 2>/dev/null && CDPATH= cd -- \"$DEST\" 2>/dev/null && pwd)\"\n if [ -z \"$ABS\" ]; then wp_cd_log ALLOW-NO-SUCH-DIR \"$DEST\"; exit 0; fi\n\n # TEST 1 — a git tree of any kind. A worktree's .git is a FILE, a clone's is a DIR; -e covers both.\n if [ -e \"$ABS/.git\" ]; then wp_cd_log ALLOW-GIT-TREE \"$ABS\"; exit 0; fi\n\n # TEST 2 — outside the governed project. The harness resets the cwd before the next call, so at most\n # one command runs there, on paths we do not govern anyway.\n case \"$ABS/\" in\n \"$CLAUDE_PROJECT_DIR\"/*) ;;\n *) wp_cd_log ALLOW-OUTSIDE \"$ABS\"; exit 0 ;;\n esac\n\n # TEST 3 — inside a governed tree with no shim beside it: sticky AND unguarded.\n REASON=\"$(printf '${DENY_SUBDIR}' \"$ABS\" \"$CLAUDE_PROJECT_DIR\")\"\nfi\n\n${DENY_EMIT_SH}`;\n}\n\n/**\n * True when a committed guarantee-root.sh EXISTS but no longer equals renderGuaranteeRoot(). Missing\n * file → false: a repo that has not adopted L-1 yet is not \"stale\", it is simply still on the two-hook\n * registration, and `wp-install-ai-hooks` is what moves it forward.\n *\n * `root` is the tree whose committed copy this BINARY governs — resolved from the running module's own\n * location by governingShimRoot(), never from cwd and never from $CLAUDE_PROJECT_DIR, for the same\n * reason the shim's own check is anchored that way: it makes the two-tree straddle unconstructible.\n */\n// webpieces-disable no-function-outside-class -- L-1 twin of shim.ts's committedShimStale(); module-scope for the same dependency-free reason\nexport function committedGuaranteeRootStale(root: string | null): boolean {\n if (root === null) return false;\n const file = guaranteeRootPath(root);\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (!fs.existsSync(file)) return false;\n return fs.readFileSync(file, 'utf8') !== renderGuaranteeRoot();\n } catch (err: unknown) {\n const error = toError(err);\n void error; // best-effort: an unreadable tree counts as \"not stale\" so this never wedges a call\n return false;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"guarantee-root.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/guarantee-root.ts"],"names":[],"mappings":";;;AAoFA,8CAEC;AAOD,gDAMC;AAiHD,kDAkEC;AAYD,kEAYC;;AA9SD,+CAAyB;AACzB,mDAA6B;AAE7B,0DAA4E;AAC5E,qDAAwD;AAExD,+CAA2C;AAE3C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuEG;AAEU,QAAA,qBAAqB,GAAG,qCAAqC,CAAC;AAE3E,6NAA6N;AAC7N,SAAgB,iBAAiB,CAAC,WAAmB;IACjD,OAAO,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,SAAS,EAAE,WAAW,EAAE,mBAAmB,CAAC,CAAC;AAC/E,CAAC;AAED;;;GAGG;AACH,wJAAwJ;AACxJ,SAAgB,kBAAkB,CAAC,WAAmB;IAClD,MAAM,MAAM,GAAG,iBAAiB,CAAC,WAAW,CAAC,CAAC;IAC9C,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACxD,EAAE,CAAC,aAAa,CAAC,MAAM,EAAE,mBAAmB,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IACjE,0FAA0F;IAC1F,EAAE,CAAC,SAAS,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AAChC,CAAC;AAED,sGAAsG;AACtG,6FAA6F;AAC7F,2DAA2D;AAC3D,MAAM,gBAAgB,GAClB,+FAA+F;MAC7F,+DAA+D;MAC/D,+DAA+D,CAAC;AAEtE,MAAM,cAAc,GAChB,8FAA8F;MAC5F,8FAA8F;MAC9F,oEAAoE,CAAC;AAE3E,qGAAqG;AACrG,kGAAkG;AAClG,MAAM,WAAW,GACb,gGAAgG;MAC9F,8FAA8F;MAC9F,iGAAiG;MACjG,0FAA0F;MAC1F,gGAAgG;MAChG,wBAAwB,CAAC;AAE/B;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,WAAW,GAAG;;;;;;;;;;8BAUU,gCAAiB,IAAI,6BAAc,IAAI,+BAAiB;;;;;;;;;;;;;;;;CAgBrF,CAAC;AAGF,+FAA+F;AAC/F,sGAAsG;AACtG,qGAAqG;AACrG,MAAM,YAAY,GAAG;;;;;;CAMpB,CAAC;AAEF,+FAA+F;AAC/F,oGAAoG;AACpG,8BAA8B;AAC9B,MAAM,SAAS,GAAG;;;;;;;;;;;;;;;;;CAiBjB,CAAC;AAEF;;;;GAIG;AACH,kLAAkL;AAClL,SAAgB,mBAAmB;IAC/B,OAAO,GAAG,SAAS;;;;;;;;;;;;;;;;;;;EAmBrB,WAAW;;;;;;;;;;;;;;;;YAgBD,cAAc;;;;;sCAKY,gBAAgB;;;;;;;;;;;;;;;;;;;;;sBAqBhC,WAAW;;;EAG/B,YAAY,EAAE,CAAC;AACjB,CAAC;AAED;;;;;;;;GAQG;AACH,8IAA8I;AAC9I,SAAgB,2BAA2B,CAAC,IAAmB;IAC3D,IAAI,IAAI,KAAK,IAAI;QAAE,OAAO,KAAK,CAAC;IAChC,MAAM,IAAI,GAAG,iBAAiB,CAAC,IAAI,CAAC,CAAC;IACrC,8DAA8D;IAC9D,IAAI,CAAC;QACD,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC;YAAE,OAAO,KAAK,CAAC;QACvC,OAAO,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,KAAK,mBAAmB,EAAE,CAAC;IACnE,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,KAAK,KAAK,CAAC,CAAC,oFAAoF;QAChG,OAAO,KAAK,CAAC;IACjB,CAAC;AACL,CAAC","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport { WEBPIECES_TMP_DIR, LOGS_STATE_DIR } from '@webpieces/rules-config';\nimport { LMINUS1_CD_STREAM } from '../core/log-streams';\n\nimport { toError } from '../core/to-error';\n\n/**\n * L-1 — the layer BELOW L0: guarantee the shell stays somewhere the RELATIVE guard hooks can launch.\n *\n * ─── Why a layer below L0 exists at all ────────────────────────────────────────────────────────────\n * From the Claude Code hooks reference: exit 2 is the blocking channel, exit 0 carries the JSON\n * decision, and ANY OTHER exit is a \"non-blocking error. Execution continues; the action proceeds\" —\n * including \"File missing or not executable: Error logged; tool proceeds.\"\n *\n * A HOOK THAT FAILS TO LAUNCH IS A SILENT ALLOW. Not a block, not an error the AI sees.\n *\n * Every layer L0-L4 assumes the hook process ran at all. This file is what makes that assumption true.\n *\n * ─── Why the guard hooks become RELATIVE ───────────────────────────────────────────────────────────\n * `.claude/settings.json` registers hooks as `sh \"$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh\"`,\n * and `$CLAUDE_PROJECT_DIR` NEVER moves — proven from four separate worktrees' own logs, every line\n * reading `root=<worktree> projectDir=<primary>`. So every tree is governed by the PRIMARY's shim and\n * the PRIMARY's binary, forever: a worktree can never be judged by the release its own branch pins, and\n * measuring one tree while running another's binary is the non-convergent \"two-tree straddle\" recorded\n * in shim.ts (an agent gave up after four cures).\n *\n * The fix is to re-root the WHOLE hook, not to measure differently. The hooks reference says \"the hook\n * runs in the cwd value from the JSON input\", so a RELATIVE command resolves against the tool call's\n * own tree — each tree runs its own shim, its own binary, its own pin. One version, one tree.\n *\n * That is only safe if the relative path is guaranteed to resolve. Hence this file.\n *\n * ─── Why this is a SEPARATE checked-in file from ai-hook.sh ────────────────────────────────────────\n * 1. It is the ONE file that must stay $CLAUDE_PROJECT_DIR-anchored, so it is the one file that cannot\n * be upgraded per-tree. Its surface is therefore kept minimal: a path check. No drift scraper, no\n * allowlist, no config load, no binary — nothing that needs to change often.\n * 2. Its stability profile is the OPPOSITE of ai-hook.sh's. The shim changes most releases; a `cd`\n * validator should converge and stop. Sharing one file forces the stable half to inherit the\n * volatile half's churn — which is today's problem, one level up.\n * 3. A NEW FILE IS INVISIBLE TO OLD RELEASES; A NEW FLAG BREAKS THEM. Measured 2026-08-06: registering\n * `ai-hook.sh --force-root` made the installed shim read `--force-root` as a BIN NAME, fail to find\n * it, and emit fault U — denying `ls` and the very `cd` that would have fixed it:\n * 16:32:20 --force-root Bash tree=primary fault=U DENY-UNDECLARED ls\n * A separate file is simply not registered until a release that ships it, so old releases are safe.\n *\n * ─── The invariant, maintained inductively ─────────────────────────────────────────────────────────\n * A session always starts at a tree root (primary for the coordinator, the worktree for a subagent).\n * The only thing that moves the shell is a `cd`, and (measured 2026-08-02, effective-tree.ts) a `cd`\n * that stays INSIDE the workspace PERSISTS to later calls, while a `cd` that LEAVES it is RESET by the\n * harness before the next call.\n *\n * If every `cd` that would leave a tree root is refused, \"the shell is at a tree root\" is an\n * INDUCTIVE INVARIANT — so this hook never inspects cwd as a state, only the command.\n *\n * That is also why it is registered for Bash ALONE: no other tool can move the shell.\n *\n * ─── The predicate, in three tests ─────────────────────────────────────────────────────────────────\n * 1. destination holds `.git` → ALLOW. Complete by construction: the primary clone has a .git DIR,\n * every linked worktree (nested or sibling) has a .git FILE, and a nested foreign clone under\n * repositories/** has its own .git DIR. The first two are where the relative hooks launch; the\n * third is a tree we deliberately do not govern.\n * 2. destination is OUTSIDE $CLAUDE_PROJECT_DIR → ALLOW. The harness resets it before the next call,\n * so at most ONE call runs at a path we do not govern — and there is nothing there to guard.\n * 3. otherwise (inside a governed tree, no .git — `tools/`, `dataform/`, `packages/…`) → DENY. This\n * is the only region that is both STICKY and UNGUARDED.\n *\n * It reads NO config. `excludePaths` governs which FILES are enforced, not whether the hook may run,\n * and it cannot be parsed here anyway (this is sh, pre-config, pre-binary). The one measured divergence\n * is `tools/**`: exempt by excludePaths, denied here — correctly, because the real hooks genuinely\n * cannot launch there and we genuinely want them to. Cost: 4 calls in a 2,236-call sample.\n *\n * ─── Nothing to recover from ───────────────────────────────────────────────────────────────────────\n * A denied `cd` NEVER EXECUTES — PreToolUse denies the whole tool call before the shell moves — so the\n * shell is still at a root. There is no bad state and no cure command that needs allowlisting, which is\n * what keeps this hook from ever being able to wedge a session.\n *\n * Measured cost of the deny: 47 of 2,236 real Bash calls (2.10%), or 9.7% of all `cd` commands.\n */\n\nexport const GUARANTEE_ROOT_MARKER = '.claude/webpieces/guarantee-root.sh';\n\n// webpieces-disable no-function-outside-class -- L-1 sibling of shim.ts's shimPath(); this module is deliberately dependency-free module-scope functions so it stays callable from a tree too broken to build a DI container\nexport function guaranteeRootPath(projectRoot: string): string {\n return path.join(projectRoot, '.claude', 'webpieces', 'guarantee-root.sh');\n}\n\n/**\n * Write (or overwrite) the committed L-1 hook. Idempotent — the installer and `wp-upgrade-shim` both\n * call it, and re-running either simply re-arms the file. Twin of setup.ts's writeShim().\n */\n// webpieces-disable no-function-outside-class -- L-1 sibling of guaranteeRootPath(); this module is deliberately dependency-free module-scope functions\nexport function writeGuaranteeRoot(projectRoot: string): void {\n const target = guaranteeRootPath(projectRoot);\n fs.mkdirSync(path.dirname(target), { recursive: true });\n fs.writeFileSync(target, renderGuaranteeRoot(), { mode: 0o755 });\n // writeFileSync's mode is only applied when creating the file; force it on overwrite too.\n fs.chmodSync(target, 0o755);\n}\n\n// Deny REASON constraint, inherited from the shim: the text is interpolated into a `REASON=\"…\"` shell\n// assignment and then printf'd into a JSON string, so it may contain NO double quotes and NO\n// backslashes. Single quotes only — do not \"improve\" them.\nconst DENY_NOT_LITERAL =\n 'This cd target is not a literal path, so the guards cannot tell where the shell will end up. '\n + 'Use a literal absolute path: cd /abs/path && <your command>. '\n + 'A $VAR, ~, $(...) or backtick is never expanded by the guard.';\n\nconst DENY_NO_TARGET =\n 'A bare cd (or cd -) moves the shell somewhere the guards cannot predict - a bare cd goes to '\n + 'your home directory, where the webpieces hooks do not exist and every later tool call would '\n + 'run UNGUARDED. Name the directory: cd /abs/path && <your command>.';\n\n// `%s` is the destination, then the project root. Kept to one short paragraph on purpose: L0 already\n// ran a message diet and these denies regress straight back to a wall of text if each one argues.\nconst DENY_SUBDIR =\n 'The webpieces guard hooks are registered RELATIVE (.claude/webpieces/ai-hook.sh) so that each '\n + 'git tree is governed by its own release. %s has no .claude/webpieces/ai-hook.sh, so a shell '\n + 'parked there launches NO hooks at all and every later tool call runs UNGUARDED - and a cd that '\n + 'stays inside the project PERSISTS to your next call. Run it from the tree root instead: '\n + 'cd %s && <your command>. Tools that take their own directory (git -C, pnpm -C, pnpm --filter, '\n + 'nx) need no cd at all.';\n\n/**\n * The cd audit trail. A THIRD parallel writer joins guards+rules on every Bash call, so it gets its\n * own file under the same session/agent/hook key LogStream uses — one writer per directory, so an\n * append can never interleave with another's (macOS PIPE_BUF is 512 bytes and real log lines exceed it).\n *\n * Unlike the shim's RESOLVE_LOG_DIR_SH this needs NO worktree resolution: L-1 is $CLAUDE_PROJECT_DIR-\n * anchored by definition, so that tree's `.webpieces` is always the right home. That is the one upside\n * of being the hook that cannot follow the tree, and it is kept DELIBERATELY.\n *\n * The cost is stated rather than hidden: for a call inside a linked worktree, L-1's line lands under\n * $CLAUDE_PROJECT_DIR while L0's lands under `worktrees/<name>/`, so the two halves of one tool call\n * sit in different roots. The cure would be to splice RESOLVE_LOG_DIR_SH in here — and that buys a\n * `git rev-parse` subprocess on EVERY Bash call, paid by the one layer whose whole guarantee is that\n * it reads no config, spawns no binary and touches no network. Trading that guarantee for tidier log\n * placement is the wrong way round: `L-1-cd/` is always at $CLAUDE_PROJECT_DIR, which is a rule a\n * reader can simply know.\n *\n * Every write is swallowed and nothing ever reaches stdout — stdout is the PreToolUse decision channel\n * and a stray byte there would corrupt allow/deny.\n */\nconst CD_AUDIT_SH = `# --- cd audit (best-effort; never blocks, never touches stdout) --------------------------------\nSID=\"$(printf '%s' \"$PAYLOAD\" | sed -n 's/.*\"session_id\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nAID=\"$(printf '%s' \"$PAYLOAD\" | sed -n 's/.*\"agent_id\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\n# Untrusted payload values are used as path segments, so anything outside [A-Za-z0-9._-] collapses to _\n# and a leading dot is neutralised — ../../etc can never escape the logs directory.\nclean() { printf '%s' \"$1\" | tr -c 'A-Za-z0-9._-' '_' \\\\\n | sed -e 's/\\\\.\\\\{2,\\\\}/_/g' -e 's/^\\\\.\\\\{1,\\\\}/_/' | cut -c1-64; }\nwp_cd_log() { # $1 = verdict, $2 = destination (may be empty)\n {\n [ -n \"$CLAUDE_PROJECT_DIR\" ] || return 0\n _d=\"$CLAUDE_PROJECT_DIR/${WEBPIECES_TMP_DIR}/${LOGS_STATE_DIR}/${LMINUS1_CD_STREAM}\"\n mkdir -p \"$_d\" 2>/dev/null || return 0\n # The LAYER is the directory; this is the WRITER, keyed exactly like LogStream.writerFile():\n # <session>-<agent|coordinator>-guarantee-root.log. ALWAYS keyed; a missing session_id renders as\n # 'unknown'. No bare-name branch anywhere.\n _p=\"$(clean \"\\${SID:-unknown}\")-$(clean \"\\${AID:-coordinator}\")-guarantee-root\"\n _f=\"$_d/\\${_p}.log\"\n _sz=\"$(wc -c < \"$_f\" 2>/dev/null | tr -d ' ')\"\n case \"$_sz\" in ''|*[!0-9]*) _sz=0 ;; esac\n [ \"$_sz\" -gt 524288 ] && mv -f \"$_f\" \"$_d/\\${_p}.1.log\" 2>/dev/null\n # fault=- is a constant here: L-1 detects no L0 fault. It is present so ONE grep spans every\n # hook-written stream rather than needing a different field list per layer.\n printf '%s\\\\t%s\\\\tfault=-\\\\tdest=%s\\\\tcwd=%s\\\\t%s\\\\n' \\\\\n \"$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null)\" \"$1\" \"$2\" \"$CWD\" \"$CMD\" >> \"$_f\"\n } 2>/dev/null || true\n}\n`;\n\n\n// The deny boundary. Extracted for the same reason CD_AUDIT_SH and HEADER_SH are — to keep the\n// renderer inside the 70-line method budget — and spliced back verbatim. Mirrors the shim's own emit:\n// for Bash, permissionDecisionReason is NOT user-visible, so the red systemMessage carries the text.\nconst DENY_EMIT_SH = `wp_cd_log DENY \"\\${ABS:-$DEST}\"\n\nBS='\\\\' # one literal backslash, so the \\\\u001b escape never sits in this source\nESC=\"\\${BS}u001b\" # the 6 chars: backslash u 0 0 1 b — Claude Code parses \\\\u001b -> ESC\nprintf '{\"systemMessage\":\"%s🛑 %s%s\",\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"%s\"}}\\\\n' \"\\${ESC}[31;1m\" \"$REASON\" \"\\${ESC}[0m\" \"$REASON\"\nexit 0 # the decision is carried by permissionDecision deny, not the exit code\n`;\n\n// The file's own banner. Extracted to a module const for the same reason renderShim() extracts\n// VERSION_DRIFT_GUARD_SH — to keep the renderer inside the 80-line method budget — and spliced back\n// in verbatim, byte for byte.\nconst HEADER_SH = `#!/bin/sh\n# webpieces L-1 hook — GUARANTEE ROOT. Generated by renderGuaranteeRoot(); do not hand-edit.\n#\n# Registered ABSOLUTE in .claude/settings.json, matcher \"Bash\":\n# sh \"$CLAUDE_PROJECT_DIR/.claude/webpieces/guarantee-root.sh\"\n#\n# It exists because the GUARD hooks beside it are registered RELATIVE, so that each git tree is\n# governed by its own @webpieces release. A relative hook that cannot resolve does not block — the\n# harness logs it and lets the tool call proceed UNGUARDED. This file makes that unreachable by\n# refusing any cd that would park the shell where the relative hooks cannot launch.\n#\n# Three tests, no config, no binary, no network:\n# 1. destination holds .git -> ALLOW (tree root, worktree, or foreign clone)\n# 2. destination is outside $CLAUDE_PROJECT_DIR -> ALLOW (the harness resets the cwd next call)\n# 3. otherwise -> DENY (sticky AND unguarded)\n#\n# A denied cd never runs, so the shell never leaves the root and there is nothing to recover from.\n`;\n\n/**\n * The POSIX-sh source. Byte-identical to `templates/guarantee-root.sh`, locked by a unit test, exactly\n * as renderShim()/templates/ai-hook.sh are — so the file a consumer commits and the file this release\n * expects can never silently diverge.\n */\n// webpieces-disable no-function-outside-class -- L-1 twin of shim.ts's renderShim(), byte-locked to templates/guarantee-root.sh; module-scope for the same dependency-free reason\nexport function renderGuaranteeRoot(): string {\n return `${HEADER_SH}\nPAYLOAD=\"$(cat)\"\nCWD=\"$(printf '%s' \"$PAYLOAD\" | sed -n 's/.*\"cwd\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nTOOL=\"$(printf '%s' \"$PAYLOAD\" | sed -n 's/.*\"tool_name\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\n\n# The command PREFIX, not the whole command — note there is no closing \" in this pattern.\n# WHY: a JSON payload escapes an embedded double quote as \\\\\", and \\`[^\"\\\\\\\\]*\\` stops dead at that\n# backslash, so the usual \"capture between quotes\" form yields the EMPTY STRING for any command\n# containing a quote at all (measured: \\`cd /a/b && echo \"hi\"\\` -> ''). An empty CMD here would mean\n# \"no cd found\" -> ALLOW, i.e. this guard would fail OPEN for every quoted command — the exact hazard\n# it exists to close. Capturing only up to the first quote/backslash is enough, because everything\n# L-1 needs (is the FIRST word a cd, and what is its target) lives in the prefix; a quote can only\n# appear later, in the part we do not need.\nCMD=\"$(printf '%s' \"$PAYLOAD\" | sed -n 's/.*\"command\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\).*/\\\\1/p')\"\n\n# Only Bash can move the shell. Anything else, and any payload we cannot read, is not ours.\n[ \"$TOOL\" = \"Bash\" ] || exit 0\n[ -n \"$CMD\" ] || exit 0\n\n${CD_AUDIT_SH}\n\n# Does the command OPEN with cd/pushd? Only a LEADING cd counts — the same rule effective-tree.ts\n# enforces, because a later cd cannot retroactively move a command that has already run.\nFIRST=\"$(printf '%s' \"$CMD\" | sed -n 's/^[[:space:]]*\\\\([^[:space:]]\\\\{1,\\\\}\\\\).*/\\\\1/p')\"\ncase \"$FIRST\" in\n cd|pushd) ;;\n *) exit 0 ;; # no leading cd: nothing to audit, nothing to judge\nesac\n\n# The target: a single-quoted path first (that is how a path with spaces is spelled), else a bare word.\nDEST=\"$(printf '%s' \"$CMD\" | sed -n \"s/^[[:space:]]*[a-z]\\\\{2,5\\\\}[[:space:]]\\\\{1,\\\\}'\\\\([^']*\\\\)'.*/\\\\1/p\")\"\n[ -n \"$DEST\" ] || DEST=\"$(printf '%s' \"$CMD\" | sed -n 's/^[[:space:]]*[a-z]\\\\{2,5\\\\}[[:space:]]\\\\{1,\\\\}\\\\([^[:space:];&|]\\\\{1,\\\\}\\\\).*/\\\\1/p')\"\n\nREASON=\"\"\nif [ -z \"$DEST\" ] || [ \"$DEST\" = \"-\" ]; then\n REASON='${DENY_NO_TARGET}'\nelse\n # A target the guard cannot expand is a target it cannot judge. sh has no regex here, so test the\n # four unexpandable shapes directly.\n case \"$DEST\" in\n *'$'*|*'\\`'*|'~'|'~/'*) REASON='${DENY_NOT_LITERAL}' ;;\n esac\nfi\n\nif [ -z \"$REASON\" ]; then\n # Resolve against the shell's real cwd. A destination that does not exist needs no verdict: the cd\n # itself will fail and the shell stays exactly where it is.\n ABS=\"$(CDPATH= cd -- \"\\${CWD:-.}\" 2>/dev/null && CDPATH= cd -- \"$DEST\" 2>/dev/null && pwd)\"\n if [ -z \"$ABS\" ]; then wp_cd_log ALLOW-NO-SUCH-DIR \"$DEST\"; exit 0; fi\n\n # TEST 1 — a git tree of any kind. A worktree's .git is a FILE, a clone's is a DIR; -e covers both.\n if [ -e \"$ABS/.git\" ]; then wp_cd_log ALLOW-GIT-TREE \"$ABS\"; exit 0; fi\n\n # TEST 2 — outside the governed project. The harness resets the cwd before the next call, so at most\n # one command runs there, on paths we do not govern anyway.\n case \"$ABS/\" in\n \"$CLAUDE_PROJECT_DIR\"/*) ;;\n *) wp_cd_log ALLOW-OUTSIDE \"$ABS\"; exit 0 ;;\n esac\n\n # TEST 3 — inside a governed tree with no shim beside it: sticky AND unguarded.\n REASON=\"$(printf '${DENY_SUBDIR}' \"$ABS\" \"$CLAUDE_PROJECT_DIR\")\"\nfi\n\n${DENY_EMIT_SH}`;\n}\n\n/**\n * True when a committed guarantee-root.sh EXISTS but no longer equals renderGuaranteeRoot(). Missing\n * file → false: a repo that has not adopted L-1 yet is not \"stale\", it is simply still on the two-hook\n * registration, and `wp-install-ai-hooks` is what moves it forward.\n *\n * `root` is the tree whose committed copy this BINARY governs — resolved from the running module's own\n * location by governingShimRoot(), never from cwd and never from $CLAUDE_PROJECT_DIR, for the same\n * reason the shim's own check is anchored that way: it makes the two-tree straddle unconstructible.\n */\n// webpieces-disable no-function-outside-class -- L-1 twin of shim.ts's committedShimStale(); module-scope for the same dependency-free reason\nexport function committedGuaranteeRootStale(root: string | null): boolean {\n if (root === null) return false;\n const file = guaranteeRootPath(root);\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (!fs.existsSync(file)) return false;\n return fs.readFileSync(file, 'utf8') !== renderGuaranteeRoot();\n } catch (err: unknown) {\n const error = toError(err);\n void error; // best-effort: an unreadable tree counts as \"not stale\" so this never wedges a call\n return false;\n }\n}\n"]}
|
package/src/bin/l0-allowlist.js
CHANGED
|
@@ -16,7 +16,7 @@ const rules_config_1 = require("@webpieces/rules-config");
|
|
|
16
16
|
// The OUTPUT-CAPTURE TAIL every escape hatch below tolerates — the 2026-07-21 deadlock report, part 2.
|
|
17
17
|
// Every allowlist was anchored to a BARE command, but the way an AI assistant actually spells a
|
|
18
18
|
// diagnostic command is `<cmd> 2>&1 | tail -20` (it trims the output it has to read back). The audit
|
|
19
|
-
// log proves it: `.webpieces/logs
|
|
19
|
+
// log proves it: `.webpieces/logs/L0-shim/<writer>.log` has `pnpm install 2>&1 | tail -15` logged as
|
|
20
20
|
// DENY-STALE seconds away from a bare `pnpm install` logged as ALLOW-INSTALL — the same cure, denied
|
|
21
21
|
// for its redirection. A cure that is denied when spelled the natural way reads to the assistant as
|
|
22
22
|
// "the guard blocks its own fix", which is exactly the conclusion it drew before handing the fix back
|