@webpieces/ai-hook-rules 0.4.558 → 0.4.560
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.d.ts +1 -1
- package/src/adapters/claude-code-response.js +9 -1
- package/src/adapters/claude-code-response.js.map +1 -1
- package/src/adapters/hook-core.js +27 -10
- package/src/adapters/hook-core.js.map +1 -1
- package/src/bin/shim-audit-log.d.ts +73 -0
- package/src/bin/shim-audit-log.js +144 -0
- package/src/bin/shim-audit-log.js.map +1 -0
- package/src/bin/shim.d.ts +1 -0
- package/src/bin/shim.js +45 -18
- package/src/bin/shim.js.map +1 -1
- package/src/core/coordinator-worktree.d.ts +61 -0
- package/src/core/coordinator-worktree.js +94 -0
- package/src/core/coordinator-worktree.js.map +1 -0
- package/src/core/decision-log.d.ts +50 -9
- package/src/core/decision-log.js +118 -34
- package/src/core/decision-log.js.map +1 -1
- package/src/core/effective-tree.js.map +1 -1
- package/src/core/l0-matrix.js +2 -2
- package/src/core/l0-matrix.js.map +1 -1
- package/src/core/main-sync-log.d.ts +1 -1
- package/src/core/main-sync-log.js +9 -9
- package/src/core/main-sync-log.js.map +1 -1
- package/src/core/main-sync-refresh.d.ts +1 -1
- package/src/core/main-sync-refresh.js +2 -2
- package/src/core/main-sync-refresh.js.map +1 -1
- package/src/core/rejection-log.d.ts +7 -0
- package/src/core/rejection-log.js +21 -6
- package/src/core/rejection-log.js.map +1 -1
- package/src/core/runner.d.ts +2 -1
- package/src/core/runner.js +33 -7
- package/src/core/runner.js.map +1 -1
- package/templates/ai-hook.sh +69 -16
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ openclaw plugins enable @webpieces/ai-hook-rules
|
|
|
40
40
|
- `wp-ai-guards-hook` — matcher `Write|Edit|MultiEdit|Bash|Read`. Runs the git/PR/branch guards
|
|
41
41
|
(`hookGuards` section): bash git/PR guards on `Bash`, and file guards like
|
|
42
42
|
`feature-branch-guard` on `Write|Edit|MultiEdit`. `Read` carries no guard — it is a
|
|
43
|
-
log-and-allow fast path that records every file the AI opens in `.webpieces/
|
|
43
|
+
log-and-allow fast path that records every file the AI opens in `.webpieces/logs/guard-invocations.log`
|
|
44
44
|
(never blocked), so you can see whether the AI read a project's `design.json` before editing it.
|
|
45
45
|
|
|
46
46
|
For each 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.560",
|
|
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",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"directory": "packages/tooling/ai-hook-rules"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@webpieces/rules-config": "0.4.
|
|
35
|
+
"@webpieces/rules-config": "0.4.560"
|
|
36
36
|
},
|
|
37
37
|
"publishConfig": {
|
|
38
38
|
"access": "public"
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export declare function denyJson(reason: string, toolName: string): string;
|
|
2
|
-
export declare function emitDeny(reason: string, toolName: string): never;
|
|
2
|
+
export declare function emitDeny(reason: string, toolName: string, rule?: string): never;
|
|
3
3
|
export declare function emitAllow(): never;
|
|
@@ -30,6 +30,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
30
30
|
exports.denyJson = denyJson;
|
|
31
31
|
exports.emitDeny = emitDeny;
|
|
32
32
|
exports.emitAllow = emitAllow;
|
|
33
|
+
const decision_log_1 = require("../core/decision-log");
|
|
33
34
|
// ANSI escape (0x1b) built at runtime so no raw ESC byte sits in source. ANSI red is a *bonus* — the
|
|
34
35
|
// 🛑 prefix + reason stay meaningful if a future/CI renderer strips the color. One place = one escape.
|
|
35
36
|
const ESC = String.fromCharCode(0x1b);
|
|
@@ -53,13 +54,20 @@ function denyJson(reason, toolName) {
|
|
|
53
54
|
// selects whether the red `systemMessage` is added (Bash) or omitted (file tools) — see denyJson.
|
|
54
55
|
// emitDeny/emitAllow are the hook's designated terminal boundary — the exit code IS the Claude Code
|
|
55
56
|
// PreToolUse protocol (exit 0 + JSON = the contract), so the process.exit stays and is allowlisted.
|
|
56
|
-
|
|
57
|
+
//
|
|
58
|
+
// Being the ONE boundary every path exits through is also why the per-invocation audit line is
|
|
59
|
+
// flushed HERE: guard-invocations.log carries the outcome of its own call, and the outcome is not
|
|
60
|
+
// known until this point. `rule` names what blocked (or '-'), for the line's `rule=` field.
|
|
61
|
+
// 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.
|
|
62
|
+
function emitDeny(reason, toolName, rule = '-') {
|
|
63
|
+
decision_log_1.invocationLog.finish('BLOCK', rule);
|
|
57
64
|
process.stdout.write(denyJson(reason, toolName) + '\n');
|
|
58
65
|
// webpieces-disable no-process-exit-outside-main -- hook exit-code IS the Claude Code PreToolUse protocol (exit 0 + JSON = the contract); designated terminal boundary.
|
|
59
66
|
process.exit(0);
|
|
60
67
|
}
|
|
61
68
|
// Allow the tool call. No JSON needed — a silent exit 0 is "allow" in the PreToolUse protocol.
|
|
62
69
|
function emitAllow() {
|
|
70
|
+
decision_log_1.invocationLog.finish('ALLOW', '-');
|
|
63
71
|
// webpieces-disable no-process-exit-outside-main -- hook exit-code IS the Claude Code PreToolUse protocol (silent exit 0 = "allow"); designated terminal boundary.
|
|
64
72
|
process.exit(0);
|
|
65
73
|
}
|
|
@@ -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;;
|
|
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;;AAW7F,4BAYC;AAWD,4BAKC;AAGD,8BAIC;AA5CD,uDAAqD;AAErD,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,kGAAkG;AAClG,4FAA4F;AAC5F,2NAA2N;AAC3N,SAAgB,QAAQ,CAAC,MAAc,EAAE,QAAgB,EAAE,OAAe,GAAG;IACzE,4BAAa,CAAC,MAAM,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;IACpC,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';\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: guard-invocations.log 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// 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 = '-'): never {\n invocationLog.finish('BLOCK', rule);\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"]}
|
|
@@ -5,6 +5,7 @@ exports.runMain = runMain;
|
|
|
5
5
|
const tslib_1 = require("tslib");
|
|
6
6
|
const path = tslib_1.__importStar(require("path"));
|
|
7
7
|
const runner_1 = require("../core/runner");
|
|
8
|
+
const coordinator_worktree_1 = require("../core/coordinator-worktree");
|
|
8
9
|
const rejection_log_1 = require("../core/rejection-log");
|
|
9
10
|
const decision_log_1 = require("../core/decision-log");
|
|
10
11
|
const main_sync_refresh_1 = require("../core/main-sync-refresh");
|
|
@@ -71,12 +72,26 @@ function normalizeToolInput(toolKind, toolInput) {
|
|
|
71
72
|
}
|
|
72
73
|
return null;
|
|
73
74
|
}
|
|
75
|
+
// The caller behind this payload. Both fields are sent only inside a subagent, so BOTH absent (or
|
|
76
|
+
// empty) is the coordinator — the one distinction CoordinatorWorktreeGuard turns on.
|
|
77
|
+
// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design
|
|
78
|
+
function agentIdentityOf(payload) {
|
|
79
|
+
return new coordinator_worktree_1.AgentIdentity(payload.agent_id ?? '', payload.agent_type ?? '');
|
|
80
|
+
}
|
|
81
|
+
// The rule name for a block's audit line: the FIRST rule the report cites, or `fallback` when the
|
|
82
|
+
// report opens with no `[rule]` header (a hand-written guard message). Comma-joined when a report
|
|
83
|
+
// cites several, so `rule=` never silently drops one.
|
|
84
|
+
// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design
|
|
85
|
+
function blockingRule(report, fallback) {
|
|
86
|
+
const names = (0, rejection_log_1.extractRuleNames)(report);
|
|
87
|
+
return names.length > 0 ? names.join(',') : fallback;
|
|
88
|
+
}
|
|
74
89
|
function handleBash(payload, cwd, mode) {
|
|
75
90
|
const command = payload.tool_input.command;
|
|
76
91
|
if (!command || command.trim() === '') {
|
|
77
92
|
(0, claude_code_response_1.emitAllow)();
|
|
78
93
|
}
|
|
79
|
-
const result = (0, runner_1.runBash)(command, cwd, mode);
|
|
94
|
+
const result = (0, runner_1.runBash)(command, cwd, mode, agentIdentityOf(payload));
|
|
80
95
|
if (!result) {
|
|
81
96
|
(0, claude_code_response_1.emitAllow)();
|
|
82
97
|
}
|
|
@@ -87,7 +102,7 @@ function handleBash(payload, cwd, mode) {
|
|
|
87
102
|
(0, decision_log_1.logGuardDecision)(root, new decision_log_1.GuardDecision('bash-guard', 'Bash', command ?? '', (0, decision_log_1.branchForLog)(root), 'BLOCK', result.report));
|
|
88
103
|
// Bash deny → pass 'Bash' so denyJson adds the ANSI-red systemMessage (the only field a Bash deny
|
|
89
104
|
// shows the human; permissionDecisionReason is invisible on Bash). See claude-code-response.ts.
|
|
90
|
-
(0, claude_code_response_1.emitDeny)(result.report, 'Bash');
|
|
105
|
+
(0, claude_code_response_1.emitDeny)(result.report, 'Bash', blockingRule(result.report, 'bash-guard'));
|
|
91
106
|
}
|
|
92
107
|
/**
|
|
93
108
|
* The read-scoped guard pass. Returns normally to ALLOW; only calls emitDeny when the guard fires.
|
|
@@ -114,7 +129,7 @@ function handleRead(filePath, cwd, mode) {
|
|
|
114
129
|
if (!result)
|
|
115
130
|
return;
|
|
116
131
|
(0, rejection_log_1.logRejection)('Read', new types_1.NormalizedToolInput(filePath, []), result, cwd);
|
|
117
|
-
(0, claude_code_response_1.emitDeny)(result.report, 'Read');
|
|
132
|
+
(0, claude_code_response_1.emitDeny)(result.report, 'Read', blockingRule(result.report, 'read-guard'));
|
|
118
133
|
}
|
|
119
134
|
function handleFileTool(payload, cwd, mode) {
|
|
120
135
|
const toolKind = normalizeToolKind(payload.tool_name);
|
|
@@ -149,7 +164,7 @@ function handleFileTool(payload, cwd, mode) {
|
|
|
149
164
|
(0, rejection_log_1.logRejection)(toolKind, input, result, cwd);
|
|
150
165
|
// File-tool deny → pass the Write/Edit/MultiEdit kind so denyJson omits systemMessage (the reason
|
|
151
166
|
// already renders red natively for these tools). See claude-code-response.ts.
|
|
152
|
-
(0, claude_code_response_1.emitDeny)(result.report, toolKind);
|
|
167
|
+
(0, claude_code_response_1.emitDeny)(result.report, toolKind, blockingRule(result.report, 'file-guard'));
|
|
153
168
|
}
|
|
154
169
|
// webpieces-disable no-function-outside-class -- pure decision helper beside the adapter's other module-scope functions; exported for direct unit testing.
|
|
155
170
|
function shimStaleRecoveryDecision(toolName, command, filePath) {
|
|
@@ -182,7 +197,9 @@ function enforceCommittedShim(payload, cwd, mode) {
|
|
|
182
197
|
// Drop the L0 matrix doc where the AI can read it and point the deny at it — a Read is entry 1 of
|
|
183
198
|
// the same allowlist, so the pointer is always followable. Best-effort: no doc → no pointer.
|
|
184
199
|
const docPath = (0, l0_matrix_1.writeGuardMatrixDoc)(new rules_config_1.RepoRootFinder().resolveRepoRoot(cwd));
|
|
185
|
-
|
|
200
|
+
// L0 fault S in GUARD_MATRIX.md's codebook — named as the blocking rule so the invocation line
|
|
201
|
+
// says WHAT stopped the call, not merely that something did.
|
|
202
|
+
(0, claude_code_response_1.emitDeny)((0, shim_1.shimStaleDenyReason)((0, shim_1.installedShimRulesVersion)()) + (0, l0_matrix_1.guardMatrixPointer)(docPath), payload.tool_name, 'committed-shim-stale');
|
|
186
203
|
}
|
|
187
204
|
/**
|
|
188
205
|
* Shared entry point for all three Claude Code PreToolUse adapters. `mode` selects which tool kinds
|
|
@@ -218,7 +235,7 @@ async function runMain(mode) {
|
|
|
218
235
|
if (READ_ONLY_TOOLS.has(payload.tool_name)) {
|
|
219
236
|
const readPath = payload.tool_input.file_path ?? '';
|
|
220
237
|
if (mode !== 'rules') {
|
|
221
|
-
|
|
238
|
+
decision_log_1.invocationLog.begin(cwd, payload.tool_name, readPath);
|
|
222
239
|
// Reads vastly outnumber edits, so refreshing here is what actually keeps the shared
|
|
223
240
|
// main-sync cache warm for feature-branch-guard. Detached; never slows the read.
|
|
224
241
|
(0, main_sync_refresh_1.triggerMainSyncRefresh)(cwd);
|
|
@@ -232,7 +249,7 @@ async function runMain(mode) {
|
|
|
232
249
|
// reported by the self-guard above, not rewritten out from under the AI.)
|
|
233
250
|
if (mode !== 'rules') {
|
|
234
251
|
const target = payload.tool_name === 'Bash' ? (payload.tool_input.command ?? '') : (payload.tool_input.file_path ?? '');
|
|
235
|
-
|
|
252
|
+
decision_log_1.invocationLog.begin(cwd, payload.tool_name, target);
|
|
236
253
|
}
|
|
237
254
|
if (payload.tool_name === 'Bash') {
|
|
238
255
|
// No code-style rule is bash-scoped, so the rules hook ignores Bash.
|
|
@@ -252,13 +269,13 @@ async function runMain(mode) {
|
|
|
252
269
|
// InformAiError (bad config/stdin) both carry an AI-readable message; anything else is an
|
|
253
270
|
// unexpected bug. All three deny (fail closed) and surface their reason to the AI.
|
|
254
271
|
if (error instanceof types_1.RuleFailError) {
|
|
255
|
-
(0, claude_code_response_1.emitDeny)(error.aiMessage, toolName);
|
|
272
|
+
(0, claude_code_response_1.emitDeny)(error.aiMessage, toolName, 'rule-crash');
|
|
256
273
|
}
|
|
257
274
|
else if (error instanceof types_1.InformAiError) {
|
|
258
|
-
(0, claude_code_response_1.emitDeny)(error.message, toolName);
|
|
275
|
+
(0, claude_code_response_1.emitDeny)(error.message, toolName, 'bad-config-or-stdin');
|
|
259
276
|
}
|
|
260
277
|
else {
|
|
261
|
-
(0, claude_code_response_1.emitDeny)(`[ai-hooks] hook crashed unexpectedly — failing closed: ${error.message}`, toolName);
|
|
278
|
+
(0, claude_code_response_1.emitDeny)(`[ai-hooks] hook crashed unexpectedly — failing closed: ${error.message}`, toolName, 'hook-crash');
|
|
262
279
|
}
|
|
263
280
|
}
|
|
264
281
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hook-core.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/hook-core.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport { run, runBash, runRead } from '../core/runner';\nimport { logRejection } from '../core/rejection-log';\nimport { logGuardDecision, GuardDecision, branchForLog, logGuardInvocation } 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 { committedShimStale, isAllowed, shimStaleDenyReason, installedShimRulesVersion } from '../bin/shim';\nimport { writeGuardMatrixDoc, guardMatrixPointer } from '../core/l0-matrix';\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}\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\nfunction handleBash(payload: ClaudeCodePayload, cwd: string, mode: HookMode): void {\n const command = payload.tool_input.command;\n if (!command || command.trim() === '') { emitAllow(); }\n const result = runBash(command, cwd, mode);\n if (!result) { emitAllow(); }\n // 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));\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');\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');\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);\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// Committed-shim self-guard, moved here from the rendered shim (2026-07-24). 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 if (mode === 'rules' || !committedShimStale(cwd)) 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 docPath = writeGuardMatrixDoc(new RepoRootFinder().resolveRepoRoot(cwd));\n emitDeny(shimStaleDenyReason(installedShimRulesVersion()) + guardMatrixPointer(docPath), payload.tool_name);\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 // 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 logGuardInvocation(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 logGuardInvocation(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 // An escaped RuleFailError (a rule that threw past the runner's per-rule catch) or an\n // InformAiError (bad config/stdin) both carry an AI-readable message; anything else is an\n // unexpected bug. All three deny (fail closed) and surface their reason to the AI.\n if (error instanceof RuleFailError) {\n emitDeny(error.aiMessage, toolName);\n } else if (error instanceof InformAiError) {\n emitDeny(error.message, toolName);\n } else {\n emitDeny(`[ai-hooks] hook crashed unexpectedly — failing closed: ${error.message}`, toolName);\n }\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"hook-core.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/hook-core.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport { run, runBash, runRead } from '../core/runner';\nimport { 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 { committedShimStale, isAllowed, shimStaleDenyReason, installedShimRulesVersion } from '../bin/shim';\nimport { writeGuardMatrixDoc, guardMatrixPointer } from '../core/l0-matrix';\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 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 result = runBash(command, cwd, mode, agentIdentityOf(payload));\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));\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'));\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'));\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'));\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// Committed-shim self-guard, moved here from the rendered shim (2026-07-24). 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 if (mode === 'rules' || !committedShimStale(cwd)) 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 docPath = writeGuardMatrixDoc(new RepoRootFinder().resolveRepoRoot(cwd));\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.\n emitDeny(shimStaleDenyReason(installedShimRulesVersion()) + guardMatrixPointer(docPath), payload.tool_name, 'committed-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 // 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 // An escaped RuleFailError (a rule that threw past the runner's per-rule catch) or an\n // InformAiError (bad config/stdin) both carry an AI-readable message; anything else is an\n // unexpected bug. All three deny (fail closed) and surface their reason to the AI.\n if (error instanceof RuleFailError) {\n emitDeny(error.aiMessage, toolName, 'rule-crash');\n } else if (error instanceof InformAiError) {\n emitDeny(error.message, toolName, 'bad-config-or-stdin');\n } else {\n emitDeny(`[ai-hooks] hook crashed unexpectedly — failing closed: ${error.message}`, toolName, 'hook-crash');\n }\n }\n}\n"]}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** The audit log's filename, and the sibling a rotation renames it to. Mirrors decision-log.ts. */
|
|
2
|
+
export declare const SHIM_LOG_FILE = "ai-hook-shim.log";
|
|
3
|
+
export declare const SHIM_LOG_FILE_PREV = "ai-hook-shim.1.log";
|
|
4
|
+
/**
|
|
5
|
+
* Rotation threshold, in bytes — 512 KB, the SAME number and the same `.1.log` naming as
|
|
6
|
+
* decision-log.ts / rejection-log.ts / main-sync-log.ts. Deliberately identical rather than merely
|
|
7
|
+
* similar: two log families in one directory with two different retention rules is a trap for whoever
|
|
8
|
+
* later tries to reason about how much history they still have.
|
|
9
|
+
*/
|
|
10
|
+
export declare const SHIM_LOG_MAX_BYTES: number;
|
|
11
|
+
/**
|
|
12
|
+
* The verdict vocabulary one shim invocation can record, and how each maps to guards/L0-tooling.md.
|
|
13
|
+
*
|
|
14
|
+
* The three ALLOW-* and three DENY-* labels are the ones this log has always used and are kept
|
|
15
|
+
* verbatim, so anything already grepping them keeps working. `PASS-BIN-*` is new: it is the healthy
|
|
16
|
+
* case the log used to be silent about.
|
|
17
|
+
*
|
|
18
|
+
* PASS-BIN-ALLOW no sh-side fault; the bin ran and returned 0 → matrix row 1 (no fault → L1)
|
|
19
|
+
* PASS-BIN-BLOCK no sh-side fault; the bin ran and returned 2 → matrix row 1; a LATER layer blocked
|
|
20
|
+
* ALLOW-READ allowlist entry 1 (any Read) → PASS, terminal here (use case 10)
|
|
21
|
+
* ALLOW-CONFIG allowlist entry 2 (webpieces.config.json) → PASS, terminal here
|
|
22
|
+
* ALLOW-CURE allowlist entries 3-8 (a cure command) → ALLOW
|
|
23
|
+
* DENY fault X, not on the allowlist → BLOCK_AI_CURE
|
|
24
|
+
* DENY-STALE fault D, not on the allowlist → BLOCK_AI_CURE
|
|
25
|
+
* DENY-BROKEN fault K, not on the allowlist → BLOCK_AI_CURE
|
|
26
|
+
*/
|
|
27
|
+
export declare const SHIM_LOG_VERDICTS: readonly ["PASS-BIN-ALLOW", "PASS-BIN-BLOCK", "ALLOW-READ", "ALLOW-CONFIG", "ALLOW-CURE", "DENY", "DENY-STALE", "DENY-BROKEN"];
|
|
28
|
+
/**
|
|
29
|
+
* The sh-side L0 fault codes, exactly as guards/L0-tooling.md names them. `-` means "no sh-side fault" —
|
|
30
|
+
* the shim cannot classify S / C / Y, which the BINARY detects (and logs through its own streams), so
|
|
31
|
+
* a `-` here is a statement about this layer only, not a claim that nothing was wrong.
|
|
32
|
+
*/
|
|
33
|
+
export declare const SHIM_LOG_FAULTS: readonly ["D", "X", "K", "-"];
|
|
34
|
+
/**
|
|
35
|
+
* Shell fragment: derive WHERE this call's log belongs — the sh TWIN of `DotWebpieces.local()` +
|
|
36
|
+
* `worktreeName()` + `primaryRoot()` in @webpieces/rules-config.
|
|
37
|
+
*
|
|
38
|
+
* sh cannot import TypeScript, so this derivation is duplicated by necessity; the mitigation is
|
|
39
|
+
* `shim-audit-log.spec.ts`, which runs THIS function through a real /bin/sh in real git worktrees and
|
|
40
|
+
* asserts it returns exactly what `dotWebpieces.worktreeName()` returns. If the two ever disagree the
|
|
41
|
+
* lock goes red rather than the logs quietly splitting in half.
|
|
42
|
+
*
|
|
43
|
+
* It asks git the SAME question the TS side asks — `--git-dir` vs `--git-common-dir`, which differ if
|
|
44
|
+
* and only if this is a linked worktree — but in ONE `rev-parse` (it accepts both flags and prints a
|
|
45
|
+
* line each) rather than two, because this runs on the blocking path of every tool call.
|
|
46
|
+
*
|
|
47
|
+
* The tree is derived from the PAYLOAD's `cwd` (Claude Code documents it as the working directory the
|
|
48
|
+
* hook was invoked from), not from `$ROOT`. `$ROOT` is where the shim FILE lives and stays the anchor
|
|
49
|
+
* for what the drift guard MEASURES — this fragment changes only where the log is WRITTEN.
|
|
50
|
+
*
|
|
51
|
+
* Fails soft, exactly like the TS side: when git cannot answer, the log collapses to
|
|
52
|
+
* `<cwd>/.webpieces/logs`, which is the pre-change behaviour.
|
|
53
|
+
*/
|
|
54
|
+
export declare const RESOLVE_LOG_DIR_SH = "wp_resolve_log_dir() {\n _wp_rp=\"$(git -C \"$WP_CWD\" rev-parse --git-dir --git-common-dir 2>/dev/null)\"\n _wp_gd=\"$(printf '%s\\n' \"$_wp_rp\" | sed -n 1p)\"\n _wp_cd=\"$(printf '%s\\n' \"$_wp_rp\" | sed -n 2p)\"\n if [ -z \"$_wp_gd\" ] || [ -z \"$_wp_cd\" ]; then\n WP_TREE=primary; WP_LOG_DIR=\"$WP_CWD/.webpieces/logs\"; return 0\n fi\n # git prints a BARE .git from the primary clone and an absolute path from a linked worktree; the TS\n # twin runs path.resolve(cwd, printed), so do the same before comparing or taking a basename.\n case \"$_wp_gd\" in /*) : ;; *) _wp_gd=\"$WP_CWD/$_wp_gd\" ;; esac\n case \"$_wp_cd\" in /*) : ;; *) _wp_cd=\"$WP_CWD/$_wp_cd\" ;; esac\n # The primary clone's root is the parent of the SHARED git dir \u2014 declining any layout whose shared\n # dir is not named .git (a bare repo, --separate-git-dir), same test as primaryRoot().\n _wp_primary=\"$WP_CWD\"\n case \"$_wp_cd\" in\n */.git) [ -d \"${_wp_cd%/*}\" ] && _wp_primary=\"${_wp_cd%/*}\" ;;\n esac\n if [ \"$_wp_gd\" = \"$_wp_cd\" ]; then\n WP_TREE=primary\n WP_LOG_DIR=\"$_wp_primary/.webpieces/logs\"\n else\n # git's OWN name for the worktree (the basename of <primary>/.git/worktrees/<name>), not the\n # directory's basename \u2014 two worktrees under different parents may share a directory name.\n WP_TREE=\"${_wp_gd##*/}\"\n WP_LOG_DIR=\"$_wp_primary/.webpieces/worktrees/$WP_TREE/logs\"\n fi\n}";
|
|
55
|
+
/**
|
|
56
|
+
* Shell fragment: the audit-log writer itself — `wp_log <fault> <verdict>`, one tab-separated line.
|
|
57
|
+
*
|
|
58
|
+
* FORMAT (7 fields, tab-separated, append-only):
|
|
59
|
+
* <iso-ts> <bin-name> <tool> tree=<name|primary> fault=<D|X|K|-> <VERDICT> <command>
|
|
60
|
+
*
|
|
61
|
+
* `tree=` and `fault=` are the two fields that make the file reconcilable against guards/L0-tooling.md:
|
|
62
|
+
* the first says WHICH checkout produced the line (a shared log across seven worktrees is otherwise
|
|
63
|
+
* unreadable), the second says which of the six documented faults the sh half detected. The verdict
|
|
64
|
+
* keeps its historical spelling and stays adjacent to the command, so `grep 'DENY-STALE\\t'` still
|
|
65
|
+
* finds what it always found.
|
|
66
|
+
*
|
|
67
|
+
* NEVER breaks or blocks the hook: the whole body is wrapped so a failure of any kind — unwritable
|
|
68
|
+
* directory, read-only filesystem, missing `git` — is swallowed, and nothing is ever written to
|
|
69
|
+
* stdout (stdout is the PreToolUse decision channel; a stray byte there corrupts allow/deny).
|
|
70
|
+
*
|
|
71
|
+
* The log dir is resolved LAZILY on first use so a call that never logs never pays for the git probe.
|
|
72
|
+
*/
|
|
73
|
+
export declare const WP_LOG_SH: string;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.WP_LOG_SH = exports.RESOLVE_LOG_DIR_SH = exports.SHIM_LOG_FAULTS = exports.SHIM_LOG_VERDICTS = exports.SHIM_LOG_MAX_BYTES = exports.SHIM_LOG_FILE_PREV = exports.SHIM_LOG_FILE = void 0;
|
|
4
|
+
const rules_config_1 = require("@webpieces/rules-config");
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// THE L0 AUDIT LOG, in POSIX sh — the shim half of `.webpieces/**/logs/ai-hook-shim.log`.
|
|
7
|
+
//
|
|
8
|
+
// Split out of ./shim.ts (which renders the shim body) purely so both stay readable; shim.ts splices
|
|
9
|
+
// these fragments in verbatim and re-exports the constants. Like l0-allowlist.ts, this module must
|
|
10
|
+
// stay dependency-light: the shim it renders has to work on a tree too broken to load the rule engine.
|
|
11
|
+
//
|
|
12
|
+
// ─── What changed, and why it is not just a bigger log ─────────────────────────────────────────────
|
|
13
|
+
// This log used to be a FAULT log wearing an audit log's name. `wp_log` fired only on the fail-closed
|
|
14
|
+
// path (ALLOW-READ / ALLOW-CONFIG / ALLOW-CURE / DENY*), so a HEALTHY call — the overwhelming majority
|
|
15
|
+
// — exec'd the bin and recorded nothing at all. You could therefore never answer "what did L0 do to
|
|
16
|
+
// this tool call?", only "what did L0 do on the calls where L0 was already broken". Absence of a line
|
|
17
|
+
// meant either "healthy" or "the shim never ran", and those are the two answers you most need to tell
|
|
18
|
+
// apart. Every path now logs exactly one line, including the pass-through, so the file can be diffed
|
|
19
|
+
// against the documented matrix in guards/L0-tooling.md rather than merely spot-checked.
|
|
20
|
+
//
|
|
21
|
+
// Two more defects went with it:
|
|
22
|
+
// • it wrote to a hardcoded `$ROOT/.webpieces/logs`, so every worktree's lines landed in one flat
|
|
23
|
+
// file (or, worse, in whichever tree happened to hold the shim) instead of the per-tree namespace
|
|
24
|
+
// the L1 binary has used since the state-dir split;
|
|
25
|
+
// • it had NO rotation, on a file now written on EVERY tool call.
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
/** The audit log's filename, and the sibling a rotation renames it to. Mirrors decision-log.ts. */
|
|
28
|
+
exports.SHIM_LOG_FILE = 'ai-hook-shim.log';
|
|
29
|
+
exports.SHIM_LOG_FILE_PREV = 'ai-hook-shim.1.log';
|
|
30
|
+
/**
|
|
31
|
+
* Rotation threshold, in bytes — 512 KB, the SAME number and the same `.1.log` naming as
|
|
32
|
+
* decision-log.ts / rejection-log.ts / main-sync-log.ts. Deliberately identical rather than merely
|
|
33
|
+
* similar: two log families in one directory with two different retention rules is a trap for whoever
|
|
34
|
+
* later tries to reason about how much history they still have.
|
|
35
|
+
*/
|
|
36
|
+
exports.SHIM_LOG_MAX_BYTES = 512 * 1024;
|
|
37
|
+
/**
|
|
38
|
+
* The verdict vocabulary one shim invocation can record, and how each maps to guards/L0-tooling.md.
|
|
39
|
+
*
|
|
40
|
+
* The three ALLOW-* and three DENY-* labels are the ones this log has always used and are kept
|
|
41
|
+
* verbatim, so anything already grepping them keeps working. `PASS-BIN-*` is new: it is the healthy
|
|
42
|
+
* case the log used to be silent about.
|
|
43
|
+
*
|
|
44
|
+
* PASS-BIN-ALLOW no sh-side fault; the bin ran and returned 0 → matrix row 1 (no fault → L1)
|
|
45
|
+
* PASS-BIN-BLOCK no sh-side fault; the bin ran and returned 2 → matrix row 1; a LATER layer blocked
|
|
46
|
+
* ALLOW-READ allowlist entry 1 (any Read) → PASS, terminal here (use case 10)
|
|
47
|
+
* ALLOW-CONFIG allowlist entry 2 (webpieces.config.json) → PASS, terminal here
|
|
48
|
+
* ALLOW-CURE allowlist entries 3-8 (a cure command) → ALLOW
|
|
49
|
+
* DENY fault X, not on the allowlist → BLOCK_AI_CURE
|
|
50
|
+
* DENY-STALE fault D, not on the allowlist → BLOCK_AI_CURE
|
|
51
|
+
* DENY-BROKEN fault K, not on the allowlist → BLOCK_AI_CURE
|
|
52
|
+
*/
|
|
53
|
+
exports.SHIM_LOG_VERDICTS = [
|
|
54
|
+
'PASS-BIN-ALLOW', 'PASS-BIN-BLOCK', 'ALLOW-READ', 'ALLOW-CONFIG', 'ALLOW-CURE',
|
|
55
|
+
'DENY', 'DENY-STALE', 'DENY-BROKEN',
|
|
56
|
+
];
|
|
57
|
+
/**
|
|
58
|
+
* The sh-side L0 fault codes, exactly as guards/L0-tooling.md names them. `-` means "no sh-side fault" —
|
|
59
|
+
* the shim cannot classify S / C / Y, which the BINARY detects (and logs through its own streams), so
|
|
60
|
+
* a `-` here is a statement about this layer only, not a claim that nothing was wrong.
|
|
61
|
+
*/
|
|
62
|
+
exports.SHIM_LOG_FAULTS = ['D', 'X', 'K', '-'];
|
|
63
|
+
/**
|
|
64
|
+
* Shell fragment: derive WHERE this call's log belongs — the sh TWIN of `DotWebpieces.local()` +
|
|
65
|
+
* `worktreeName()` + `primaryRoot()` in @webpieces/rules-config.
|
|
66
|
+
*
|
|
67
|
+
* sh cannot import TypeScript, so this derivation is duplicated by necessity; the mitigation is
|
|
68
|
+
* `shim-audit-log.spec.ts`, which runs THIS function through a real /bin/sh in real git worktrees and
|
|
69
|
+
* asserts it returns exactly what `dotWebpieces.worktreeName()` returns. If the two ever disagree the
|
|
70
|
+
* lock goes red rather than the logs quietly splitting in half.
|
|
71
|
+
*
|
|
72
|
+
* It asks git the SAME question the TS side asks — `--git-dir` vs `--git-common-dir`, which differ if
|
|
73
|
+
* and only if this is a linked worktree — but in ONE `rev-parse` (it accepts both flags and prints a
|
|
74
|
+
* line each) rather than two, because this runs on the blocking path of every tool call.
|
|
75
|
+
*
|
|
76
|
+
* The tree is derived from the PAYLOAD's `cwd` (Claude Code documents it as the working directory the
|
|
77
|
+
* hook was invoked from), not from `$ROOT`. `$ROOT` is where the shim FILE lives and stays the anchor
|
|
78
|
+
* for what the drift guard MEASURES — this fragment changes only where the log is WRITTEN.
|
|
79
|
+
*
|
|
80
|
+
* Fails soft, exactly like the TS side: when git cannot answer, the log collapses to
|
|
81
|
+
* `<cwd>/.webpieces/logs`, which is the pre-change behaviour.
|
|
82
|
+
*/
|
|
83
|
+
exports.RESOLVE_LOG_DIR_SH = `wp_resolve_log_dir() {
|
|
84
|
+
_wp_rp="$(git -C "$WP_CWD" rev-parse --git-dir --git-common-dir 2>/dev/null)"
|
|
85
|
+
_wp_gd="$(printf '%s\\n' "$_wp_rp" | sed -n 1p)"
|
|
86
|
+
_wp_cd="$(printf '%s\\n' "$_wp_rp" | sed -n 2p)"
|
|
87
|
+
if [ -z "$_wp_gd" ] || [ -z "$_wp_cd" ]; then
|
|
88
|
+
WP_TREE=primary; WP_LOG_DIR="$WP_CWD/${rules_config_1.WEBPIECES_TMP_DIR}/${rules_config_1.LOGS_STATE_DIR}"; return 0
|
|
89
|
+
fi
|
|
90
|
+
# git prints a BARE .git from the primary clone and an absolute path from a linked worktree; the TS
|
|
91
|
+
# twin runs path.resolve(cwd, printed), so do the same before comparing or taking a basename.
|
|
92
|
+
case "$_wp_gd" in /*) : ;; *) _wp_gd="$WP_CWD/$_wp_gd" ;; esac
|
|
93
|
+
case "$_wp_cd" in /*) : ;; *) _wp_cd="$WP_CWD/$_wp_cd" ;; esac
|
|
94
|
+
# The primary clone's root is the parent of the SHARED git dir — declining any layout whose shared
|
|
95
|
+
# dir is not named .git (a bare repo, --separate-git-dir), same test as primaryRoot().
|
|
96
|
+
_wp_primary="$WP_CWD"
|
|
97
|
+
case "$_wp_cd" in
|
|
98
|
+
*/.git) [ -d "\${_wp_cd%/*}" ] && _wp_primary="\${_wp_cd%/*}" ;;
|
|
99
|
+
esac
|
|
100
|
+
if [ "$_wp_gd" = "$_wp_cd" ]; then
|
|
101
|
+
WP_TREE=primary
|
|
102
|
+
WP_LOG_DIR="$_wp_primary/${rules_config_1.WEBPIECES_TMP_DIR}/${rules_config_1.LOGS_STATE_DIR}"
|
|
103
|
+
else
|
|
104
|
+
# git's OWN name for the worktree (the basename of <primary>/.git/worktrees/<name>), not the
|
|
105
|
+
# directory's basename — two worktrees under different parents may share a directory name.
|
|
106
|
+
WP_TREE="\${_wp_gd##*/}"
|
|
107
|
+
WP_LOG_DIR="$_wp_primary/${rules_config_1.WEBPIECES_TMP_DIR}/${rules_config_1.WORKTREE_STATE_DIR}/$WP_TREE/${rules_config_1.LOGS_STATE_DIR}"
|
|
108
|
+
fi
|
|
109
|
+
}`;
|
|
110
|
+
/**
|
|
111
|
+
* Shell fragment: the audit-log writer itself — `wp_log <fault> <verdict>`, one tab-separated line.
|
|
112
|
+
*
|
|
113
|
+
* FORMAT (7 fields, tab-separated, append-only):
|
|
114
|
+
* <iso-ts> <bin-name> <tool> tree=<name|primary> fault=<D|X|K|-> <VERDICT> <command>
|
|
115
|
+
*
|
|
116
|
+
* `tree=` and `fault=` are the two fields that make the file reconcilable against guards/L0-tooling.md:
|
|
117
|
+
* the first says WHICH checkout produced the line (a shared log across seven worktrees is otherwise
|
|
118
|
+
* unreadable), the second says which of the six documented faults the sh half detected. The verdict
|
|
119
|
+
* keeps its historical spelling and stays adjacent to the command, so `grep 'DENY-STALE\\t'` still
|
|
120
|
+
* finds what it always found.
|
|
121
|
+
*
|
|
122
|
+
* NEVER breaks or blocks the hook: the whole body is wrapped so a failure of any kind — unwritable
|
|
123
|
+
* directory, read-only filesystem, missing `git` — is swallowed, and nothing is ever written to
|
|
124
|
+
* stdout (stdout is the PreToolUse decision channel; a stray byte there corrupts allow/deny).
|
|
125
|
+
*
|
|
126
|
+
* The log dir is resolved LAZILY on first use so a call that never logs never pays for the git probe.
|
|
127
|
+
*/
|
|
128
|
+
exports.WP_LOG_SH = `WP_TREE=""
|
|
129
|
+
WP_LOG_DIR=""
|
|
130
|
+
${exports.RESOLVE_LOG_DIR_SH}
|
|
131
|
+
wp_log() { # $1 = L0 fault code (D|X|K|-), $2 = verdict label
|
|
132
|
+
{
|
|
133
|
+
[ -n "$WP_LOG_DIR" ] || wp_resolve_log_dir
|
|
134
|
+
mkdir -p "$WP_LOG_DIR" 2>/dev/null || return 0
|
|
135
|
+
_wp_f="$WP_LOG_DIR/${exports.SHIM_LOG_FILE}"
|
|
136
|
+
# Rotate at the SAME 512 KB into the SAME .1.log sibling as every JS-side webpieces log. This runs
|
|
137
|
+
# on every tool call, so it is one wc and no more; a size we cannot read counts as 0 (no rotation).
|
|
138
|
+
_wp_sz="$(wc -c < "$_wp_f" 2>/dev/null | tr -d ' ')"
|
|
139
|
+
case "$_wp_sz" in ''|*[!0-9]*) _wp_sz=0 ;; esac
|
|
140
|
+
[ "$_wp_sz" -gt ${String(exports.SHIM_LOG_MAX_BYTES)} ] && mv -f "$_wp_f" "$WP_LOG_DIR/${exports.SHIM_LOG_FILE_PREV}" 2>/dev/null
|
|
141
|
+
printf '%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' "$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null)" "$BIN_NAME" "$TOOL" "tree=$WP_TREE" "fault=$1" "$2" "$CMD" >> "$_wp_f"
|
|
142
|
+
} 2>/dev/null || true
|
|
143
|
+
}`;
|
|
144
|
+
//# sourceMappingURL=shim-audit-log.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shim-audit-log.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/shim-audit-log.ts"],"names":[],"mappings":";;;AAAA,0DAAgG;AAEhG,8EAA8E;AAC9E,0FAA0F;AAC1F,EAAE;AACF,qGAAqG;AACrG,mGAAmG;AACnG,uGAAuG;AACvG,EAAE;AACF,sGAAsG;AACtG,sGAAsG;AACtG,uGAAuG;AACvG,oGAAoG;AACpG,sGAAsG;AACtG,sGAAsG;AACtG,qGAAqG;AACrG,yFAAyF;AACzF,EAAE;AACF,iCAAiC;AACjC,oGAAoG;AACpG,sGAAsG;AACtG,wDAAwD;AACxD,oEAAoE;AACpE,8EAA8E;AAE9E,mGAAmG;AACtF,QAAA,aAAa,GAAG,kBAAkB,CAAC;AACnC,QAAA,kBAAkB,GAAG,oBAAoB,CAAC;AAEvD;;;;;GAKG;AACU,QAAA,kBAAkB,GAAG,GAAG,GAAG,IAAI,CAAC;AAE7C;;;;;;;;;;;;;;;GAeG;AACU,QAAA,iBAAiB,GAAG;IAC7B,gBAAgB,EAAE,gBAAgB,EAAE,YAAY,EAAE,cAAc,EAAE,YAAY;IAC9E,MAAM,EAAE,YAAY,EAAE,aAAa;CAC7B,CAAC;AAEX;;;;GAIG;AACU,QAAA,eAAe,GAAG,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,CAAU,CAAC;AAE7D;;;;;;;;;;;;;;;;;;;GAmBG;AACU,QAAA,kBAAkB,GAAG;;;;;2CAKS,gCAAiB,IAAI,6BAAc;;;;;;;;;;;;;;+BAc/C,gCAAiB,IAAI,6BAAc;;;;;+BAKnC,gCAAiB,IAAI,iCAAkB,aAAa,6BAAc;;EAE/F,CAAC;AAEH;;;;;;;;;;;;;;;;;GAiBG;AACU,QAAA,SAAS,GAAG;;EAEvB,0BAAkB;;;;;yBAKK,qBAAa;;;;;sBAKhB,MAAM,CAAC,0BAAkB,CAAC,qCAAqC,0BAAkB;;;EAGrG,CAAC","sourcesContent":["import { LOGS_STATE_DIR, WORKTREE_STATE_DIR, WEBPIECES_TMP_DIR } from '@webpieces/rules-config';\n\n// ---------------------------------------------------------------------------\n// THE L0 AUDIT LOG, in POSIX sh — the shim half of `.webpieces/**/logs/ai-hook-shim.log`.\n//\n// Split out of ./shim.ts (which renders the shim body) purely so both stay readable; shim.ts splices\n// these fragments in verbatim and re-exports the constants. Like l0-allowlist.ts, this module must\n// stay dependency-light: the shim it renders has to work on a tree too broken to load the rule engine.\n//\n// ─── What changed, and why it is not just a bigger log ─────────────────────────────────────────────\n// This log used to be a FAULT log wearing an audit log's name. `wp_log` fired only on the fail-closed\n// path (ALLOW-READ / ALLOW-CONFIG / ALLOW-CURE / DENY*), so a HEALTHY call — the overwhelming majority\n// — exec'd the bin and recorded nothing at all. You could therefore never answer \"what did L0 do to\n// this tool call?\", only \"what did L0 do on the calls where L0 was already broken\". Absence of a line\n// meant either \"healthy\" or \"the shim never ran\", and those are the two answers you most need to tell\n// apart. Every path now logs exactly one line, including the pass-through, so the file can be diffed\n// against the documented matrix in guards/L0-tooling.md rather than merely spot-checked.\n//\n// Two more defects went with it:\n// • it wrote to a hardcoded `$ROOT/.webpieces/logs`, so every worktree's lines landed in one flat\n// file (or, worse, in whichever tree happened to hold the shim) instead of the per-tree namespace\n// the L1 binary has used since the state-dir split;\n// • it had NO rotation, on a file now written on EVERY tool call.\n// ---------------------------------------------------------------------------\n\n/** The audit log's filename, and the sibling a rotation renames it to. Mirrors decision-log.ts. */\nexport const SHIM_LOG_FILE = 'ai-hook-shim.log';\nexport const SHIM_LOG_FILE_PREV = 'ai-hook-shim.1.log';\n\n/**\n * Rotation threshold, in bytes — 512 KB, the SAME number and the same `.1.log` naming as\n * decision-log.ts / rejection-log.ts / main-sync-log.ts. Deliberately identical rather than merely\n * similar: two log families in one directory with two different retention rules is a trap for whoever\n * later tries to reason about how much history they still have.\n */\nexport const SHIM_LOG_MAX_BYTES = 512 * 1024;\n\n/**\n * The verdict vocabulary one shim invocation can record, and how each maps to guards/L0-tooling.md.\n *\n * The three ALLOW-* and three DENY-* labels are the ones this log has always used and are kept\n * verbatim, so anything already grepping them keeps working. `PASS-BIN-*` is new: it is the healthy\n * case the log used to be silent about.\n *\n * PASS-BIN-ALLOW no sh-side fault; the bin ran and returned 0 → matrix row 1 (no fault → L1)\n * PASS-BIN-BLOCK no sh-side fault; the bin ran and returned 2 → matrix row 1; a LATER layer blocked\n * ALLOW-READ allowlist entry 1 (any Read) → PASS, terminal here (use case 10)\n * ALLOW-CONFIG allowlist entry 2 (webpieces.config.json) → PASS, terminal here\n * ALLOW-CURE allowlist entries 3-8 (a cure command) → ALLOW\n * DENY fault X, not on the allowlist → BLOCK_AI_CURE\n * DENY-STALE fault D, not on the allowlist → BLOCK_AI_CURE\n * DENY-BROKEN fault K, not on the allowlist → BLOCK_AI_CURE\n */\nexport const SHIM_LOG_VERDICTS = [\n 'PASS-BIN-ALLOW', 'PASS-BIN-BLOCK', 'ALLOW-READ', 'ALLOW-CONFIG', 'ALLOW-CURE',\n 'DENY', 'DENY-STALE', 'DENY-BROKEN',\n] as const;\n\n/**\n * The sh-side L0 fault codes, exactly as guards/L0-tooling.md names them. `-` means \"no sh-side fault\" —\n * the shim cannot classify S / C / Y, which the BINARY detects (and logs through its own streams), so\n * a `-` here is a statement about this layer only, not a claim that nothing was wrong.\n */\nexport const SHIM_LOG_FAULTS = ['D', 'X', 'K', '-'] as const;\n\n/**\n * Shell fragment: derive WHERE this call's log belongs — the sh TWIN of `DotWebpieces.local()` +\n * `worktreeName()` + `primaryRoot()` in @webpieces/rules-config.\n *\n * sh cannot import TypeScript, so this derivation is duplicated by necessity; the mitigation is\n * `shim-audit-log.spec.ts`, which runs THIS function through a real /bin/sh in real git worktrees and\n * asserts it returns exactly what `dotWebpieces.worktreeName()` returns. If the two ever disagree the\n * lock goes red rather than the logs quietly splitting in half.\n *\n * It asks git the SAME question the TS side asks — `--git-dir` vs `--git-common-dir`, which differ if\n * and only if this is a linked worktree — but in ONE `rev-parse` (it accepts both flags and prints a\n * line each) rather than two, because this runs on the blocking path of every tool call.\n *\n * The tree is derived from the PAYLOAD's `cwd` (Claude Code documents it as the working directory the\n * hook was invoked from), not from `$ROOT`. `$ROOT` is where the shim FILE lives and stays the anchor\n * for what the drift guard MEASURES — this fragment changes only where the log is WRITTEN.\n *\n * Fails soft, exactly like the TS side: when git cannot answer, the log collapses to\n * `<cwd>/.webpieces/logs`, which is the pre-change behaviour.\n */\nexport const RESOLVE_LOG_DIR_SH = `wp_resolve_log_dir() {\n _wp_rp=\"$(git -C \"$WP_CWD\" rev-parse --git-dir --git-common-dir 2>/dev/null)\"\n _wp_gd=\"$(printf '%s\\\\n' \"$_wp_rp\" | sed -n 1p)\"\n _wp_cd=\"$(printf '%s\\\\n' \"$_wp_rp\" | sed -n 2p)\"\n if [ -z \"$_wp_gd\" ] || [ -z \"$_wp_cd\" ]; then\n WP_TREE=primary; WP_LOG_DIR=\"$WP_CWD/${WEBPIECES_TMP_DIR}/${LOGS_STATE_DIR}\"; return 0\n fi\n # git prints a BARE .git from the primary clone and an absolute path from a linked worktree; the TS\n # twin runs path.resolve(cwd, printed), so do the same before comparing or taking a basename.\n case \"$_wp_gd\" in /*) : ;; *) _wp_gd=\"$WP_CWD/$_wp_gd\" ;; esac\n case \"$_wp_cd\" in /*) : ;; *) _wp_cd=\"$WP_CWD/$_wp_cd\" ;; esac\n # The primary clone's root is the parent of the SHARED git dir — declining any layout whose shared\n # dir is not named .git (a bare repo, --separate-git-dir), same test as primaryRoot().\n _wp_primary=\"$WP_CWD\"\n case \"$_wp_cd\" in\n */.git) [ -d \"\\${_wp_cd%/*}\" ] && _wp_primary=\"\\${_wp_cd%/*}\" ;;\n esac\n if [ \"$_wp_gd\" = \"$_wp_cd\" ]; then\n WP_TREE=primary\n WP_LOG_DIR=\"$_wp_primary/${WEBPIECES_TMP_DIR}/${LOGS_STATE_DIR}\"\n else\n # git's OWN name for the worktree (the basename of <primary>/.git/worktrees/<name>), not the\n # directory's basename — two worktrees under different parents may share a directory name.\n WP_TREE=\"\\${_wp_gd##*/}\"\n WP_LOG_DIR=\"$_wp_primary/${WEBPIECES_TMP_DIR}/${WORKTREE_STATE_DIR}/$WP_TREE/${LOGS_STATE_DIR}\"\n fi\n}`;\n\n/**\n * Shell fragment: the audit-log writer itself — `wp_log <fault> <verdict>`, one tab-separated line.\n *\n * FORMAT (7 fields, tab-separated, append-only):\n * <iso-ts> <bin-name> <tool> tree=<name|primary> fault=<D|X|K|-> <VERDICT> <command>\n *\n * `tree=` and `fault=` are the two fields that make the file reconcilable against guards/L0-tooling.md:\n * the first says WHICH checkout produced the line (a shared log across seven worktrees is otherwise\n * unreadable), the second says which of the six documented faults the sh half detected. The verdict\n * keeps its historical spelling and stays adjacent to the command, so `grep 'DENY-STALE\\\\t'` still\n * finds what it always found.\n *\n * NEVER breaks or blocks the hook: the whole body is wrapped so a failure of any kind — unwritable\n * directory, read-only filesystem, missing `git` — is swallowed, and nothing is ever written to\n * stdout (stdout is the PreToolUse decision channel; a stray byte there corrupts allow/deny).\n *\n * The log dir is resolved LAZILY on first use so a call that never logs never pays for the git probe.\n */\nexport const WP_LOG_SH = `WP_TREE=\"\"\nWP_LOG_DIR=\"\"\n${RESOLVE_LOG_DIR_SH}\nwp_log() { # $1 = L0 fault code (D|X|K|-), $2 = verdict label\n {\n [ -n \"$WP_LOG_DIR\" ] || wp_resolve_log_dir\n mkdir -p \"$WP_LOG_DIR\" 2>/dev/null || return 0\n _wp_f=\"$WP_LOG_DIR/${SHIM_LOG_FILE}\"\n # Rotate at the SAME 512 KB into the SAME .1.log sibling as every JS-side webpieces log. This runs\n # on every tool call, so it is one wc and no more; a size we cannot read counts as 0 (no rotation).\n _wp_sz=\"$(wc -c < \"$_wp_f\" 2>/dev/null | tr -d ' ')\"\n case \"$_wp_sz\" in ''|*[!0-9]*) _wp_sz=0 ;; esac\n [ \"$_wp_sz\" -gt ${String(SHIM_LOG_MAX_BYTES)} ] && mv -f \"$_wp_f\" \"$WP_LOG_DIR/${SHIM_LOG_FILE_PREV}\" 2>/dev/null\n printf '%s\\\\t%s\\\\t%s\\\\t%s\\\\t%s\\\\t%s\\\\t%s\\\\n' \"$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null)\" \"$BIN_NAME\" \"$TOOL\" \"tree=$WP_TREE\" \"fault=$1\" \"$2\" \"$CMD\" >> \"$_wp_f\"\n } 2>/dev/null || true\n}`;\n"]}
|
package/src/bin/shim.d.ts
CHANGED