@webpieces/ai-hook-rules 0.4.550 → 0.4.552
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/bin/l0-allowlist.d.ts +9 -1
- package/src/bin/l0-allowlist.js +28 -7
- package/src/bin/l0-allowlist.js.map +1 -1
- package/src/bin/setup.js +13 -12
- package/src/bin/setup.js.map +1 -1
- package/src/bin/shim.js +17 -4
- package/src/bin/shim.js.map +1 -1
- package/src/core/l0-matrix.d.ts +23 -1
- package/src/core/l0-matrix.js +108 -14
- package/src/core/l0-matrix.js.map +1 -1
- package/templates/ai-hook.sh +14 -2
package/src/bin/shim.js
CHANGED
|
@@ -265,7 +265,19 @@ elif [ -n "\$DRIFT_PKG" ]; then
|
|
|
265
265
|
# check is a plain !=, so it fires BOTH ways, and the old text always claimed node_modules was the
|
|
266
266
|
# older side. When it is actually the NEWER side (a checkout behind origin), that text sent people
|
|
267
267
|
# to 'pnpm install', which DOWNGRADES them further from correct.
|
|
268
|
-
|
|
268
|
+
#
|
|
269
|
+
# But "which side is stale" is NOT the same question as "what clears the block". 'pnpm install' clears
|
|
270
|
+
# fault D in BOTH directions by definition — it makes installed == pin. The old text never said so, so
|
|
271
|
+
# a reader on the OPTION 2 branch could not tell whether it was even permitted to install. The only
|
|
272
|
+
# real question is whether the PIN is the version you WANT, and that has three legitimate answers,
|
|
273
|
+
# including deliberately staying on the older code (a checkout + feature branch + install to
|
|
274
|
+
# downgrade). Saying that out loud stops it being improvised as a reset --hard.
|
|
275
|
+
#
|
|
276
|
+
# The FEATURE-BRANCH case is why featureBranchSyncAdvice() is a separate method: wp-start-update is
|
|
277
|
+
# NOT on the L0 allowlist (a 3-point merge is not a tooling-integrity cure), so it can only be offered
|
|
278
|
+
# AFTER the install that clears this block and re-arms the guards. Prescribing it while the block is
|
|
279
|
+
# up would be the same deny-names-a-denied-command deadlock this module exists to prevent.
|
|
280
|
+
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. 'pnpm install' ALWAYS clears this block, in BOTH directions - it makes node_modules match the pin by definition, and it is allowed through while this guard is up. The only question is whether the PIN is the version you WANT, so compare the two versions above. OPTION 1 (node_modules is OLDER than the pin - you just pulled or switched to a branch pinning a newer webpieces) - run EXACTLY this command and you are done: 'pnpm install'. OPTION 2 (node_modules is NEWER than the pin - your checkout is behind origin, so the PIN is the stale side, and a bare 'pnpm install' would DOWNGRADE you) - if you are ON MAIN, get the checkout current FIRST and then install: run 'git pull origin main', and after it succeeds run 'pnpm install'. OPTION 3 (node_modules is NEWER than the pin and you deliberately want to stay on the OLD code) - check out the exact commit you want, create a feature branch from it, then run 'pnpm install' to bring node_modules DOWN to the version that commit pinned. That is a legitimate choice, not a mistake - the downgrade is the point. ON A FEATURE BRANCH: a bare 'pnpm install' aligns node_modules to YOUR BRANCH pin, which is usually what you want, and it clears this block. If you actually want main's newer @webpieces, still run 'pnpm install' FIRST to clear the drift and re-arm the guards, and only THEN sync from main normally. ${new rules_config_1.SyncFlowGuidance().featureBranchSyncAdvice()} Do NOT try to run those two while this block is up: they are not on the allowlist, and they do not need to be - the install comes first. ${new rules_config_1.SyncFlowGuidance().updateMainAdvice()} git pull and git fetch are allowed while this guard is up and are the cure here. git merge is NOT allowed - not by this guard and not by redirect-how-to-merge-main once the guards are back - because main is merged ONLY through the 3-point fork merge: 'pnpm wp-start-update', or 'pnpm wp-start-upsert-pr' when a PR is already open. ${exports.NO_CHAINING_RULE}"
|
|
269
281
|
else
|
|
270
282
|
# A LINKED WORKTREE is the overwhelmingly common way to land here with a perfectly healthy repo:
|
|
271
283
|
# git gives the new worktree a .git FILE (the primary clone has a .git directory) and copies no
|
|
@@ -404,8 +416,9 @@ function committedShimStale(cwd) {
|
|
|
404
416
|
return false;
|
|
405
417
|
}
|
|
406
418
|
}
|
|
407
|
-
// True when `command`
|
|
408
|
-
//
|
|
419
|
+
// True when `command` re-arms the committed shim — the two prescribed cures plus the installer, which
|
|
420
|
+
// also heals the shim as its first step. These are the only commands allowed through while a stale
|
|
421
|
+
// committed shim blocks everything else, so the AI can re-arm it. Each JS twin already tolerates
|
|
409
422
|
// a trailing `2>&1 | tail -N` and rejects any `&&`-chained tail (see CAPTURE_TAIL_JS_SRC).
|
|
410
423
|
// webpieces-disable no-function-outside-class -- pure predicate over the exported allowlist twins; belongs beside them in the shim module.
|
|
411
424
|
function isShimCureCommand(command) {
|
|
@@ -420,7 +433,7 @@ function isShimCureCommand(command) {
|
|
|
420
433
|
// 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.
|
|
421
434
|
function shimStaleDenyReason(installedVersion) {
|
|
422
435
|
const verNote = installedVersion ? ` (installed version ${installedVersion})` : '';
|
|
423
|
-
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:
|
|
436
|
+
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: both 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 - it is the SURGICAL tool: it regenerates the shim and touches NOTHING else, no config and no settings.json, and it imports only fs/path so it runs on a broken tree; needs installed @webpieces/ai-hook-rules 0.4.408 or newer) - run EXACTLY this command: '${l0_allowlist_1.UPGRADE_SHIM_CMD}'. OPTION 2 (pick this when the installed @webpieces/ai-hook-rules is OLDER than 0.4.408, so wp-upgrade-shim does not exist yet - it works on every version, but Claude Code's own permission prompt may ask you to confirm the file overwrite, and that prompt is NOT this guard) - run EXACTLY this command: '${l0_allowlist_1.RESTORE_SHIM_CMD}'. Do NOT use the bare '${l0_allowlist_1.INSTALL_HOOKS_CMD}' here: this fault is shim-only, and the installer also migrates your config and wires BOTH hooks, PROMPTING for a target twice, which hangs a non-interactive session. ${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.`;
|
|
424
437
|
}
|
|
425
438
|
// The installed @webpieces/ai-hook-rules version, for shimStaleDenyReason's note. The binary IS this
|
|
426
439
|
// package, so it reads its OWN package.json (two dirs up from src/bin). Best-effort: '' on any failure,
|
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, CONFIG_FILENAME } from '@webpieces/rules-config';\n\nimport { toError } from '../core/to-error';\nimport {\n L0_ALLOW_ERE, RECOVERY_CMD, INSTALL_HOOKS_CMD, UPGRADE_SHIM_CMD, RESTORE_SHIM_CMD,\n INSTALL_HOOKS_ALLOW_JS, UPGRADE_SHIM_ALLOW_JS, RESTORE_SHIM_ALLOW_JS,\n} from './l0-allowlist';\n\n// The allowlist moved to ./l0-allowlist (this module was over the file-size limit); re-exported here so\n// every existing `from './shim'` import keeps working and there is still ONE name to import L0 by.\nexport * from './l0-allowlist';\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\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 wrap it in a subshell. ' +\n '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 tolerated are a LEADING cd <dir> && (needed to run the cure in a linked worktree, since ' +\n 'the harness resets a cwd that left the workspace and puts you back in the primary clone - that one IS ' +\n 'accepted), a trailing 2>&1, and a pipe into tail/head ' +\n '(e.g. cd /path/to/worktree && pnpm install 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 THE L0 ALLOWLIST through — everything else falls to the deny below.\n//\n// This asks the identical question isAllowed() asks in JS, in the same order: Read, then the\n// webpieces.config.json target, then the one command union (L0_ALLOW_ERE). The sh and JS halves exist\n// because D/X/K are decided BEFORE the bin runs (a stale/missing/broken validator cannot validate\n// itself) while S/C/Y are decided inside it — one model, two enforcement points.\n//\n// NOTE the documented asymmetry: here the bin is never executed, so an allowed Read is TERMINAL and\n// read-stale-guard does not run. In JS the same entry falls through and it does. See isAllowed().\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')\"\nFILE=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"file_path\"[[: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 = label (ALLOW-CURE|ALLOW-READ|ALLOW-CONFIG|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)\n# THE L0 ALLOWLIST, entry order identical to isAllowed(). No fault is consulted: a cure that cannot\n# help a given fault also cannot hurt it, and gating each entry on a fault is what produced the four\n# defects recorded above L0_ALLOW_ERE.\nif [ \"\\$TOOL\" = \"Read\" ]; then\n wp_log ALLOW-READ # you must be able to read to work out how to fix this\n exit 0\nfi\ncase \"\\$FILE\" in\n */${CONFIG_FILENAME}|${CONFIG_FILENAME})\n wp_log ALLOW-CONFIG # the always-allowed recovery target — every guard is configured from it\n exit 0 ;;\nesac\nif printf '%s' \"\\$CMD\" | grep -Eq '${L0_ALLOW_ERE}'; then\n wp_log ALLOW-CURE # record the self-heal we let through (re-enables the guards)\n exit 0 # allow the cure 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 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. The SYNC allowlist no longer accepts merge either, so\n # the text and the allowlist now agree instead of the text warning against what the list permits.\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. git merge is NOT allowed - not by this guard and not by redirect-how-to-merge-main once the guards are back - because main is merged ONLY through the 3-point fork merge: 'pnpm wp-start-update', or 'pnpm wp-start-upsert-pr' when a PR is already open. ${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.\n//\n// The overwrite itself is correct and deliberate — shim and binary are two halves of one L0 and MUST\n// come from the same release (see shimStaleRecoveryDecision's header in ../adapters/hook-core).\n//\n// It needs no backup and no notice: the shim is a TRACKED file, so whatever it replaced is already in\n// git — `git diff` shows the rewrite, and a tamper is a working-tree modification git surfaces on its\n// own. In a consistent repo this is a no-op (committed shim already equals renderShim()); it earns its\n// keep on the upgrade path, where bumping the pin and installing leaves the committed shim behind and\n// this quietly brings it forward to be committed.\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"]}
|
|
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, CONFIG_FILENAME } from '@webpieces/rules-config';\n\nimport { toError } from '../core/to-error';\nimport {\n L0_ALLOW_ERE, RECOVERY_CMD, INSTALL_HOOKS_CMD, UPGRADE_SHIM_CMD, RESTORE_SHIM_CMD,\n INSTALL_HOOKS_ALLOW_JS, UPGRADE_SHIM_ALLOW_JS, RESTORE_SHIM_ALLOW_JS,\n} from './l0-allowlist';\n\n// The allowlist moved to ./l0-allowlist (this module was over the file-size limit); re-exported here so\n// every existing `from './shim'` import keeps working and there is still ONE name to import L0 by.\nexport * from './l0-allowlist';\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\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 wrap it in a subshell. ' +\n '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 tolerated are a LEADING cd <dir> && (needed to run the cure in a linked worktree, since ' +\n 'the harness resets a cwd that left the workspace and puts you back in the primary clone - that one IS ' +\n 'accepted), a trailing 2>&1, and a pipe into tail/head ' +\n '(e.g. cd /path/to/worktree && pnpm install 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 THE L0 ALLOWLIST through — everything else falls to the deny below.\n//\n// This asks the identical question isAllowed() asks in JS, in the same order: Read, then the\n// webpieces.config.json target, then the one command union (L0_ALLOW_ERE). The sh and JS halves exist\n// because D/X/K are decided BEFORE the bin runs (a stale/missing/broken validator cannot validate\n// itself) while S/C/Y are decided inside it — one model, two enforcement points.\n//\n// NOTE the documented asymmetry: here the bin is never executed, so an allowed Read is TERMINAL and\n// read-stale-guard does not run. In JS the same entry falls through and it does. See isAllowed().\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')\"\nFILE=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"file_path\"[[: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 = label (ALLOW-CURE|ALLOW-READ|ALLOW-CONFIG|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)\n# THE L0 ALLOWLIST, entry order identical to isAllowed(). No fault is consulted: a cure that cannot\n# help a given fault also cannot hurt it, and gating each entry on a fault is what produced the four\n# defects recorded above L0_ALLOW_ERE.\nif [ \"\\$TOOL\" = \"Read\" ]; then\n wp_log ALLOW-READ # you must be able to read to work out how to fix this\n exit 0\nfi\ncase \"\\$FILE\" in\n */${CONFIG_FILENAME}|${CONFIG_FILENAME})\n wp_log ALLOW-CONFIG # the always-allowed recovery target — every guard is configured from it\n exit 0 ;;\nesac\nif printf '%s' \"\\$CMD\" | grep -Eq '${L0_ALLOW_ERE}'; then\n wp_log ALLOW-CURE # record the self-heal we let through (re-enables the guards)\n exit 0 # allow the cure 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 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. The SYNC allowlist no longer accepts merge either, so\n # the text and the allowlist now agree instead of the text warning against what the list permits.\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 #\n # But \"which side is stale\" is NOT the same question as \"what clears the block\". 'pnpm install' clears\n # fault D in BOTH directions by definition — it makes installed == pin. The old text never said so, so\n # a reader on the OPTION 2 branch could not tell whether it was even permitted to install. The only\n # real question is whether the PIN is the version you WANT, and that has three legitimate answers,\n # including deliberately staying on the older code (a checkout + feature branch + install to\n # downgrade). Saying that out loud stops it being improvised as a reset --hard.\n #\n # The FEATURE-BRANCH case is why featureBranchSyncAdvice() is a separate method: wp-start-update is\n # NOT on the L0 allowlist (a 3-point merge is not a tooling-integrity cure), so it can only be offered\n # AFTER the install that clears this block and re-arms the guards. Prescribing it while the block is\n # up would be the same deny-names-a-denied-command deadlock this module exists to prevent.\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. 'pnpm install' ALWAYS clears this block, in BOTH directions - it makes node_modules match the pin by definition, and it is allowed through while this guard is up. The only question is whether the PIN is the version you WANT, so compare the two versions above. OPTION 1 (node_modules is OLDER than the pin - you just pulled or switched to a branch pinning a newer webpieces) - run EXACTLY this command and you are done: 'pnpm install'. OPTION 2 (node_modules is NEWER than the pin - your checkout is behind origin, so the PIN is the stale side, and a bare 'pnpm install' would DOWNGRADE you) - if you are ON MAIN, get the checkout current FIRST and then install: run 'git pull origin main', and after it succeeds run 'pnpm install'. OPTION 3 (node_modules is NEWER than the pin and you deliberately want to stay on the OLD code) - check out the exact commit you want, create a feature branch from it, then run 'pnpm install' to bring node_modules DOWN to the version that commit pinned. That is a legitimate choice, not a mistake - the downgrade is the point. ON A FEATURE BRANCH: a bare 'pnpm install' aligns node_modules to YOUR BRANCH pin, which is usually what you want, and it clears this block. If you actually want main's newer @webpieces, still run 'pnpm install' FIRST to clear the drift and re-arm the guards, and only THEN sync from main normally. ${new SyncFlowGuidance().featureBranchSyncAdvice()} Do NOT try to run those two while this block is up: they are not on the allowlist, and they do not need to be - the install comes first. ${new SyncFlowGuidance().updateMainAdvice()} git pull and git fetch are allowed while this guard is up and are the cure here. git merge is NOT allowed - not by this guard and not by redirect-how-to-merge-main once the guards are back - because main is merged ONLY through the 3-point fork merge: 'pnpm wp-start-update', or 'pnpm wp-start-upsert-pr' when a PR is already open. ${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.\n//\n// The overwrite itself is correct and deliberate — shim and binary are two halves of one L0 and MUST\n// come from the same release (see shimStaleRecoveryDecision's header in ../adapters/hook-core).\n//\n// It needs no backup and no notice: the shim is a TRACKED file, so whatever it replaced is already in\n// git — `git diff` shows the rewrite, and a tamper is a working-tree modification git surfaces on its\n// own. In a consistent repo this is a no-op (committed shim already equals renderShim()); it earns its\n// keep on the upgrade path, where bumping the pin and installing leaves the committed shim behind and\n// this quietly brings it forward to be committed.\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` re-arms the committed shim — the two prescribed cures plus the installer, which\n// also heals the shim as its first step. These are the only commands allowed through while a stale\n// 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: both 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 - it is the SURGICAL tool: it regenerates the shim and touches NOTHING else, no config and no settings.json, and it imports only fs/path so it runs on a broken tree; needs installed @webpieces/ai-hook-rules 0.4.408 or newer) - run EXACTLY this command: '${UPGRADE_SHIM_CMD}'. OPTION 2 (pick this when the installed @webpieces/ai-hook-rules is OLDER than 0.4.408, so wp-upgrade-shim does not exist yet - it works on every version, but Claude Code's own permission prompt may ask you to confirm the file overwrite, and that prompt is NOT this guard) - run EXACTLY this command: '${RESTORE_SHIM_CMD}'. Do NOT use the bare '${INSTALL_HOOKS_CMD}' here: this fault is shim-only, and the installer also migrates your config and wires BOTH hooks, PROMPTING for a target twice, which hangs a non-interactive session. ${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/src/core/l0-matrix.d.ts
CHANGED
|
@@ -21,7 +21,29 @@ export declare const GUARD_MATRIX_DOC = "webpieces.guard-matrix.md";
|
|
|
21
21
|
export declare class L0Cure {
|
|
22
22
|
readonly mention: string;
|
|
23
23
|
readonly call: L0Call;
|
|
24
|
-
|
|
24
|
+
/**
|
|
25
|
+
* The one to reach for first when a fault has several. Exactly one cure per fault carries it,
|
|
26
|
+
* so the rendered Fix section never asks the reader to choose between equals.
|
|
27
|
+
*/
|
|
28
|
+
readonly preferred: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* WHEN to pick this cure over its siblings — the sentence that makes a list of commands
|
|
31
|
+
* actionable instead of a menu ("when the PIN is the stale side", not "an alternative").
|
|
32
|
+
*/
|
|
33
|
+
readonly discriminator: string;
|
|
34
|
+
constructor(mention: string, call: L0Call,
|
|
35
|
+
/**
|
|
36
|
+
* The one to reach for first when a fault has several. Exactly one cure per fault carries it,
|
|
37
|
+
* so the rendered Fix section never asks the reader to choose between equals.
|
|
38
|
+
*/
|
|
39
|
+
preferred: boolean,
|
|
40
|
+
/**
|
|
41
|
+
* WHEN to pick this cure over its siblings — the sentence that makes a list of commands
|
|
42
|
+
* actionable instead of a menu ("when the PIN is the stale side", not "an alternative").
|
|
43
|
+
*/
|
|
44
|
+
discriminator: string);
|
|
45
|
+
/** A Bash cure renders as a literal command; a tool-shaped one renders as the edit it stands for. */
|
|
46
|
+
isCommand(): boolean;
|
|
25
47
|
}
|
|
26
48
|
/** One L0 fault. Data-only → a class, per CLAUDE.md. */
|
|
27
49
|
export declare class L0Fault {
|
package/src/core/l0-matrix.js
CHANGED
|
@@ -42,9 +42,28 @@ exports.GUARD_MATRIX_DOC = 'webpieces.guard-matrix.md';
|
|
|
42
42
|
class L0Cure {
|
|
43
43
|
mention;
|
|
44
44
|
call;
|
|
45
|
-
|
|
45
|
+
preferred;
|
|
46
|
+
discriminator;
|
|
47
|
+
// eslint-disable-next-line @typescript-eslint/max-params
|
|
48
|
+
constructor(mention, call,
|
|
49
|
+
/**
|
|
50
|
+
* The one to reach for first when a fault has several. Exactly one cure per fault carries it,
|
|
51
|
+
* so the rendered Fix section never asks the reader to choose between equals.
|
|
52
|
+
*/
|
|
53
|
+
preferred,
|
|
54
|
+
/**
|
|
55
|
+
* WHEN to pick this cure over its siblings — the sentence that makes a list of commands
|
|
56
|
+
* actionable instead of a menu ("when the PIN is the stale side", not "an alternative").
|
|
57
|
+
*/
|
|
58
|
+
discriminator) {
|
|
46
59
|
this.mention = mention;
|
|
47
60
|
this.call = call;
|
|
61
|
+
this.preferred = preferred;
|
|
62
|
+
this.discriminator = discriminator;
|
|
63
|
+
}
|
|
64
|
+
/** A Bash cure renders as a literal command; a tool-shaped one renders as the edit it stands for. */
|
|
65
|
+
isCommand() {
|
|
66
|
+
return this.call.toolName === 'Bash';
|
|
48
67
|
}
|
|
49
68
|
}
|
|
50
69
|
exports.L0Cure = L0Cure;
|
|
@@ -80,21 +99,35 @@ exports.L0Fault = L0Fault;
|
|
|
80
99
|
// (b) not on the L0 allowlist in that spelling, i.e. the AI was handed a cure it could neither run nor
|
|
81
100
|
// get past the guard. Now it names the installer that actually seeds the config AND is entry 8 of the
|
|
82
101
|
// allowlist, and it says out loud that writing the config yourself is allowed through.
|
|
102
|
+
//
|
|
103
|
+
// ORDERING (2026-08-02): writing the file yourself now LEADS. The bare installer used to be OPTION 1,
|
|
104
|
+
// but it seeds the config and then PROMPTS twice for a hook target, which hangs a non-interactive
|
|
105
|
+
// agent. Writing the file is the one cure that always works, and it is the same cure every other config
|
|
106
|
+
// problem has (see the config-validation invariant in GUARD_MATRIX.md): the validator reports every
|
|
107
|
+
// error at once, so the write/validate loop converges in a couple of passes.
|
|
83
108
|
exports.CONFIG_MISSING_REPORT = `${rules_config_1.CONFIG_FILENAME} not found — the webpieces guards cannot run without it, so every other tool call is blocked.\n` +
|
|
84
109
|
'THIS IS NOT A DEADLOCK: both options below are explicitly allowed through while this guard is up.\n' +
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
110
|
+
`OPTION 1 (preferred — it needs no other tool and it never prompts) - create ${rules_config_1.CONFIG_FILENAME}\n` +
|
|
111
|
+
'yourself: any Read, and any Write/Edit whose target is that file, is always allowed through, so\n' +
|
|
112
|
+
'you can inspect the repo and write it. The validator reports EVERY missing/invalid entry at once\n' +
|
|
113
|
+
'(each with the snippet to paste), so a minimal first draft converges in about two passes.\n' +
|
|
114
|
+
'OPTION 2 (pick this ONLY at an interactive terminal where you can answer its two prompts) - run\n' +
|
|
115
|
+
'EXACTLY this command to seed the config: `pnpm exec wp-install-ai-hooks`. It goes on to wire the\n' +
|
|
116
|
+
'Claude Code hooks and asks for a target twice, which hangs a non-interactive session.\n' +
|
|
88
117
|
'Do not append anything to the option you pick — the allowlist is anchored to the whole command.';
|
|
89
118
|
// The first line of the fault-Y deny (built out in runner.checkConfigSync, which appends the per-rule
|
|
90
119
|
// detail). Kept here so the fault table quotes the same text the runner emits.
|
|
91
120
|
exports.CONFIG_OUT_OF_SYNC_HEADER = `${rules_config_1.CONFIG_FILENAME} is out of sync — new built-in rules are present that have no entry in ${rules_config_1.CONFIG_FILENAME}.`;
|
|
92
|
-
|
|
121
|
+
// Writing/repairing the file yourself. PREFERRED for both config faults, per the config-validation
|
|
122
|
+
// invariant in GUARD_MATRIX.md: every config problem cures to "make the file right", the validator
|
|
123
|
+
// reports all errors at once so the loop converges in a couple of passes, and allowlist entry 2 permits
|
|
124
|
+
// this edit unconditionally. (That section is the authority — do not restate its reasoning here.)
|
|
125
|
+
const CONFIG_WRITE_CURE = new L0Cure(rules_config_1.CONFIG_FILENAME, new shim_1.L0Call('Edit', '', `/repo/${rules_config_1.CONFIG_FILENAME}`), true, 'this fault fires at all — it is the only cure that needs no other tool, and it is never denied');
|
|
93
126
|
// Cure calls are spelled exactly as the deny messages spell them, so the mention assertion is a real
|
|
94
127
|
// string search rather than a paraphrase.
|
|
95
128
|
// webpieces-disable no-function-outside-class -- pure constructor helper for the L0_FAULTS literal below, in this data module
|
|
96
|
-
function bashCure(command) {
|
|
97
|
-
return new L0Cure(command, new shim_1.L0Call('Bash', command, ''));
|
|
129
|
+
function bashCure(command, preferred, discriminator) {
|
|
130
|
+
return new L0Cure(command, new shim_1.L0Call('Bash', command, ''), preferred, discriminator);
|
|
98
131
|
}
|
|
99
132
|
/**
|
|
100
133
|
* THE six L0 faults, in first-match-wins order. D/X/K are decided in POSIX sh BEFORE the bin runs (a
|
|
@@ -102,14 +135,57 @@ function bashCure(command) {
|
|
|
102
135
|
* the bin, in JS. One model, two enforcement points.
|
|
103
136
|
*/
|
|
104
137
|
exports.L0_FAULTS = [
|
|
105
|
-
new L0Fault('D', 'version drift — root package.json pin != installed version', 'sh, before the bin runs', 'sh', [
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
bashCure('
|
|
110
|
-
|
|
138
|
+
new L0Fault('D', 'version drift — root package.json pin != installed version', 'sh, before the bin runs', 'sh', [
|
|
139
|
+
// `pnpm install` clears D in BOTH directions — it makes installed == pin by definition — so
|
|
140
|
+
// it is always the preferred cure. The direction only decides whether the PIN is the version
|
|
141
|
+
// you WANT, which is what the second cure is for.
|
|
142
|
+
bashCure('pnpm install', true, 'node_modules is OLDER than the pin, OR you are on a feature branch and want YOUR '
|
|
143
|
+
+ 'branch pin (usually the case) — it always clears the drift'),
|
|
144
|
+
bashCure('git pull', false, 'node_modules is NEWER than the pin AND you are on main — the PIN is the stale side, so '
|
|
145
|
+
+ 'pull first and install second; a bare install would downgrade you'),
|
|
146
|
+
], (0, shim_1.renderShim)()),
|
|
147
|
+
new L0Fault('X', 'guard bin missing (fresh clone / new worktree / package removed)', 'sh, before the bin runs', 'sh', [bashCure('pnpm install', true, 'this fault fires at all — nothing is installed in THIS tree, and a new git worktree '
|
|
148
|
+
+ 'copies no node_modules')], (0, shim_1.renderShim)()),
|
|
149
|
+
new L0Fault('K', 'guard bin present but CRASHED (exit code not 0 or 2 — corrupt node_modules)', 'sh, before the bin runs', 'sh', [bashCure(shim_1.RECOVERY_CMD, true, 'this fault fires at all — a BARE pnpm install SKIPS the corrupt package, because pnpm sees '
|
|
150
|
+
+ 'the right version on disk and considers it installed; only the delete forces a rewrite')], (0, shim_1.renderShim)()),
|
|
151
|
+
new L0Fault('S', 'committed .claude/webpieces/ai-hook.sh != renderShim()', 'the guard bin', 'JS', [
|
|
152
|
+
// wp-upgrade-shim is the SURGICAL tool and therefore leads: upgrade-shim.ts writes
|
|
153
|
+
// renderShim() to .claude/webpieces/ai-hook.sh and touches nothing else — no config, no
|
|
154
|
+
// settings.json — and it imports only fs/path, so it runs on a tree too broken to load the
|
|
155
|
+
// rule engine. The INSTALLER is deliberately NOT a cure here: it also migrates the config
|
|
156
|
+
// and wires BOTH hooks, prompting for a target twice, which hangs a non-interactive agent.
|
|
157
|
+
bashCure(shim_1.UPGRADE_SHIM_CMD, true, 'this fault fires at all — it regenerates the shim and NOTHING else (no config, no '
|
|
158
|
+
+ 'settings.json); needs installed @webpieces/ai-hook-rules 0.4.408 or newer'),
|
|
159
|
+
// 2026-07-21: the version gap below caused a real "command not found" deadlock.
|
|
160
|
+
bashCure(shim_1.RESTORE_SHIM_CMD, false, 'the installed @webpieces/ai-hook-rules is OLDER than 0.4.408, so wp-upgrade-shim does '
|
|
161
|
+
+ 'not exist yet — this works on every release, though Claude Code may ask you to '
|
|
162
|
+
+ 'confirm the overwrite, and that prompt is NOT this guard'),
|
|
163
|
+
], (0, shim_1.shimStaleDenyReason)('')),
|
|
164
|
+
new L0Fault('C', `${rules_config_1.CONFIG_FILENAME} missing`, 'the guard bin', 'JS', [
|
|
165
|
+
CONFIG_WRITE_CURE,
|
|
166
|
+
// Kept, but demoted: it seeds the file and then PROMPTS twice for a hook target.
|
|
167
|
+
bashCure(shim_1.INSTALL_HOOKS_CMD, false, 'you are at an INTERACTIVE terminal and can answer its two hook-target prompts'),
|
|
168
|
+
], exports.CONFIG_MISSING_REPORT),
|
|
111
169
|
new L0Fault('Y', `a loaded rule has no ${rules_config_1.CONFIG_FILENAME} key`, 'the guard bin', 'JS', [CONFIG_WRITE_CURE], exports.CONFIG_OUT_OF_SYNC_HEADER),
|
|
112
170
|
];
|
|
171
|
+
/**
|
|
172
|
+
* One fault's FIX section, rendered from its `cures` array — literal commands only, never prose.
|
|
173
|
+
*
|
|
174
|
+
* This is the half that used to live in hand-written docs and drift. The three fields of L0Cure map
|
|
175
|
+
* onto the three things a blocked reader needs and nothing else: WHAT to type (the call), WHETHER it is
|
|
176
|
+
* the default (preferred), and WHEN to pick a sibling instead (discriminator). A cure with no
|
|
177
|
+
* discriminator would render as a menu of equals, which is how an agent picks the wrong one.
|
|
178
|
+
*/
|
|
179
|
+
// webpieces-disable no-function-outside-class -- pure string builder for renderGuardMatrixDoc below, beside the arrays it reads
|
|
180
|
+
function renderFixSection(fault) {
|
|
181
|
+
const options = fault.cures.map((cure, i) => {
|
|
182
|
+
// A Bash cure is the command verbatim; a tool-shaped one is the file it edits (allowlist entry 2).
|
|
183
|
+
const literal = cure.isCommand() ? `\`${cure.call.command}\`` : `edit \`${cure.mention}\` yourself`;
|
|
184
|
+
const label = cure.preferred ? `Option ${i + 1} (preferred)` : `Option ${i + 1}`;
|
|
185
|
+
return `- **${label}**: ${literal} ← pick this when ${cure.discriminator}`;
|
|
186
|
+
});
|
|
187
|
+
return [`### \`${fault.code}\` — ${fault.name}`, '', ...options, ''];
|
|
188
|
+
}
|
|
113
189
|
/**
|
|
114
190
|
* Render webpieces.guard-matrix.md from L0_FAULTS + L0_ALLOWLIST.
|
|
115
191
|
*
|
|
@@ -139,6 +215,24 @@ function renderGuardMatrixDoc() {
|
|
|
139
215
|
'First match wins. `D`/`X`/`K` are decided in POSIX `sh` inside the committed shim, BEFORE the',
|
|
140
216
|
'guard bin runs — a stale, missing or broken validator cannot be trusted to validate itself.',
|
|
141
217
|
'',
|
|
218
|
+
'## The fix, per fault',
|
|
219
|
+
'',
|
|
220
|
+
'Every command below is rendered from that fault\'s `cures` array and is asserted, by unit test,',
|
|
221
|
+
'to be accepted by `isAllowed()` — so nothing here can be a command the guard then rejects. Type',
|
|
222
|
+
'the option you pick EXACTLY as written and run nothing else on that line.',
|
|
223
|
+
'',
|
|
224
|
+
...exports.L0_FAULTS.flatMap(renderFixSection),
|
|
225
|
+
...renderMatrixAndAllowlist(),
|
|
226
|
+
].join('\n');
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* The second half of the doc: the three-row matrix and the ONE allowlist. Split out of
|
|
230
|
+
* renderGuardMatrixDoc solely to keep it inside the method-line budget — the join order is what makes
|
|
231
|
+
* the two halves one file, so keep them adjacent and keep the byte-lock test as the arbiter.
|
|
232
|
+
*/
|
|
233
|
+
// webpieces-disable no-function-outside-class -- second half of renderGuardMatrixDoc's string, beside it in this module
|
|
234
|
+
function renderMatrixAndAllowlist() {
|
|
235
|
+
return [
|
|
142
236
|
'## The matrix',
|
|
143
237
|
'',
|
|
144
238
|
'L0 has NO genuine second dimension. Every branch reduces to one question:',
|
|
@@ -184,7 +278,7 @@ function renderGuardMatrixDoc() {
|
|
|
184
278
|
'Add an entry to `L0_ALLOWLIST` in `packages/tooling/ai-hook-rules/src/bin/shim.ts`. That array is',
|
|
185
279
|
'the single source for the JS allowlist, the `grep -E` inside the rendered shim, and this file.',
|
|
186
280
|
'',
|
|
187
|
-
]
|
|
281
|
+
];
|
|
188
282
|
}
|
|
189
283
|
/**
|
|
190
284
|
* Drop the matrix doc where the AI can read it, and return its absolute path ('' if it could not be
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"l0-matrix.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/l0-matrix.ts"],"names":[],"mappings":"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","sourcesContent":["import { CONFIG_FILENAME, writeTemplate } from '@webpieces/rules-config';\n\nimport { L0AllowEntry, L0Call, L0_ALLOWLIST, renderShim, shimStaleDenyReason } from '../bin/shim';\nimport { toError } from './to-error';\n\n// ---------------------------------------------------------------------------\n// L0 — the TOOLING-INTEGRITY layer, as data.\n//\n// L0 is the outermost guard: it blocks work while node_modules, the committed shim, or\n// webpieces.config.json are in a state that makes every OTHER guard untrustworthy. It has SIX faults\n// and — drawn as a decision matrix — NO genuine second dimension:\n//\n// fault present AND call not on the allowlist -> BLOCK(messageFor(fault))\n//\n// so the only thing that varies per fault is the MESSAGE. This module holds the fault table and\n// renders it, together with L0_ALLOWLIST (../bin/shim), into webpieces.guard-matrix.md — the doc the\n// deny messages point the AI at. Doc and code come from the SAME arrays, so they cannot drift.\n// ---------------------------------------------------------------------------\n\n/**\n * The doc L0's deny messages point at. Lives in @webpieces/rules-config/templates alongside the others,\n * and is written to <root>/.webpieces/instruct-ai/ lazily, only on an L0 BLOCK.\n *\n * That generated doc is the AUTHORITY for the fault table and the allowlist (same arrays, cannot\n * drift). GUARD_MATRIX.md at this repo's root is the hand-written companion: it adds L0's evaluation\n * ORDER, the use cases and the known gaps, and it documents L1, none of which are rendered from code.\n * Change L0_FAULTS or L0_ALLOWLIST and the generated doc follows automatically — GUARD_MATRIX.md does\n * not, so update it in the same PR.\n */\nexport const GUARD_MATRIX_DOC = 'webpieces.guard-matrix.md';\n\n/**\n * One CURE for a fault: the exact call, plus the `mention` that must appear in that fault's deny text.\n *\n * Both halves are asserted (l0-matrix.spec.ts): the call must be accepted by isAllowed(), and the deny\n * message must actually name it. That pairing is the anti-deadlock invariant — a message that\n * prescribes a command the allowlist rejects is exactly the shape of the three deadlocks CLAUDE.md\n * records, and it is how the dead `wp-setup-ai-hooks` bin in the config-missing text was caught.\n */\nexport class L0Cure {\n constructor(\n readonly mention: string,\n readonly call: L0Call,\n ) {}\n}\n\n/** One L0 fault. Data-only → a class, per CLAUDE.md. */\nexport class L0Fault {\n constructor(\n readonly code: string,\n readonly name: string,\n readonly detectedBy: string,\n readonly enforcedIn: string,\n readonly cures: readonly L0Cure[],\n /**\n * The artifact carrying this fault's deny text. For S/C/Y that is the deny string itself; for\n * D/X/K the text is built in POSIX sh inside the rendered shim, so it is the rendered shim —\n * the same bytes the consumer runs, which is what the mention assertion needs to search.\n */\n readonly denyText: string,\n ) {}\n}\n\n// The deny for fault C, and the ONLY message L0 has for a repo with no webpieces.config.json at all.\n// Moved here from runner.ts so the fault table and the runner cannot state different cures.\n//\n// It used to name `./node_modules/.bin/wp-setup-ai-hooks` — a bin that HAS NOT EXISTED since it was\n// renamed to wp-install-ai-hooks. So the one command this deny prescribed was (a) not installable and\n// (b) not on the L0 allowlist in that spelling, i.e. the AI was handed a cure it could neither run nor\n// get past the guard. Now it names the installer that actually seeds the config AND is entry 8 of the\n// allowlist, and it says out loud that writing the config yourself is allowed through.\nexport const CONFIG_MISSING_REPORT =\n `${CONFIG_FILENAME} not found — the webpieces guards cannot run without it, so every other tool call is blocked.\\n` +\n 'THIS IS NOT A DEADLOCK: both options below are explicitly allowed through while this guard is up.\\n' +\n 'OPTION 1 - run EXACTLY this command to seed the config (it also re-arms the hooks): `pnpm exec wp-install-ai-hooks`.\\n' +\n `OPTION 2 - create ${CONFIG_FILENAME} yourself: any Read, and any Write/Edit whose target is\\n` +\n `${CONFIG_FILENAME}, is always allowed through, so you can inspect the repo and write it.\\n` +\n 'Do not append anything to the option you pick — the allowlist is anchored to the whole command.';\n\n// The first line of the fault-Y deny (built out in runner.checkConfigSync, which appends the per-rule\n// detail). Kept here so the fault table quotes the same text the runner emits.\nexport const CONFIG_OUT_OF_SYNC_HEADER =\n `${CONFIG_FILENAME} is out of sync — new built-in rules are present that have no entry in ${CONFIG_FILENAME}.`;\n\nconst CONFIG_WRITE_CURE = new L0Cure(CONFIG_FILENAME, new L0Call('Edit', '', `/repo/${CONFIG_FILENAME}`));\n\n// Cure calls are spelled exactly as the deny messages spell them, so the mention assertion is a real\n// string search rather than a paraphrase.\n// webpieces-disable no-function-outside-class -- pure constructor helper for the L0_FAULTS literal below, in this data module\nfunction bashCure(command: string): L0Cure {\n return new L0Cure(command, new L0Call('Bash', command, ''));\n}\n\n/**\n * THE six L0 faults, in first-match-wins order. D/X/K are decided in POSIX sh BEFORE the bin runs (a\n * stale, missing or broken validator cannot be trusted to validate itself); S/C/Y are decided inside\n * the bin, in JS. One model, two enforcement points.\n */\nexport const L0_FAULTS: readonly L0Fault[] = [\n new L0Fault('D', 'version drift — root package.json pin != installed version',\n 'sh, before the bin runs', 'sh',\n [bashCure('pnpm install'), bashCure('git pull')], renderShim()),\n new L0Fault('X', 'guard bin missing (fresh clone / new worktree / package removed)',\n 'sh, before the bin runs', 'sh',\n [bashCure('pnpm install')], renderShim()),\n new L0Fault('K', 'guard bin present but CRASHED (exit code not 0 or 2 — corrupt node_modules)',\n 'sh, before the bin runs', 'sh',\n [bashCure('rm -rf node_modules && pnpm install')], renderShim()),\n new L0Fault('S', 'committed .claude/webpieces/ai-hook.sh != renderShim()',\n 'the guard bin', 'JS',\n [bashCure('pnpm exec wp-install-ai-hooks'), bashCure('pnpm exec wp-upgrade-shim'),\n bashCure('cp node_modules/@webpieces/ai-hook-rules/templates/ai-hook.sh .claude/webpieces/ai-hook.sh')],\n shimStaleDenyReason('')),\n new L0Fault('C', `${CONFIG_FILENAME} missing`,\n 'the guard bin', 'JS',\n [bashCure('pnpm exec wp-install-ai-hooks'), CONFIG_WRITE_CURE], CONFIG_MISSING_REPORT),\n new L0Fault('Y', `a loaded rule has no ${CONFIG_FILENAME} key`,\n 'the guard bin', 'JS',\n [CONFIG_WRITE_CURE], CONFIG_OUT_OF_SYNC_HEADER),\n];\n\n/**\n * Render webpieces.guard-matrix.md from L0_FAULTS + L0_ALLOWLIST.\n *\n * A unit test locks the committed template byte-identical to this output, the same way\n * templates/ai-hook.sh is locked to renderShim(). That is what makes the doc assertable instead of\n * aspirational: the table in the doc IS the array the guard consults.\n */\n// webpieces-disable no-function-outside-class -- pure string builder over the two exported arrays, beside them in this module\nexport function renderGuardMatrixDoc(): string {\n return [\n '# webpieces guard matrix — L0 (tooling integrity)',\n '',\n 'GENERATED from `L0_FAULTS` + `L0_ALLOWLIST` in `@webpieces/ai-hook-rules`. Do not hand-edit —',\n 'a unit test locks this file byte-identical to `renderGuardMatrixDoc()`, so the table below is',\n 'the array the guard actually consults, not a description of it.',\n '',\n 'L0 is the OUTERMOST guard layer. It blocks work while `node_modules`, the committed shim, or',\n '`webpieces.config.json` are in a state that makes every other guard untrustworthy. If you are',\n 'reading this, one of the six faults below fired and named this file.',\n '',\n '## The faults',\n '',\n '| code | fault | detected by | enforced in |',\n '|---|---|---|---|',\n ...L0_FAULTS.map((f: L0Fault): string => `| \\`${f.code}\\` | ${f.name} | ${f.detectedBy} | ${f.enforcedIn} |`),\n '',\n 'First match wins. `D`/`X`/`K` are decided in POSIX `sh` inside the committed shim, BEFORE the',\n 'guard bin runs — a stale, missing or broken validator cannot be trusted to validate itself.',\n '',\n '## The matrix',\n '',\n 'L0 has NO genuine second dimension. Every branch reduces to one question:',\n '',\n '| # | fault | on the allowlist? | outcome |',\n '|---|---|---|---|',\n '| 1 | none | — | hand down to the next guard layer |',\n '| 2 | any | yes | PASS or ALLOW (see the entry) |',\n '| 3 | any | no | BLOCK — **only the message varies by fault** |',\n '',\n 'The tool is not a dimension either: \"any Read\" is an allowlist ENTRY, not a tool check.',\n '',\n '## The allowlist',\n '',\n 'ONE list, consulted identically by all six faults. A cure that cannot help a given fault also',\n 'cannot hurt it, and gating each entry on a fault is what produced four real defects (a stale',\n 'shim that denied `pnpm install` and `git pull`; faults that denied every Read; a config fault',\n 'that denied `rm -rf node_modules && pnpm install` while allowing a bare `pnpm install`).',\n '',\n '| # | allowed | outcome |',\n '|---|---|---|',\n ...L0_ALLOWLIST.map((e: L0AllowEntry, i: number): string => `| ${i + 1} | ${e.label} | ${e.kind.toUpperCase()} |`),\n '',\n '- **PASS** — L0 has no objection; the call falls THROUGH so the downstream guards still judge it.',\n '- **ALLOW** — terminal; bypasses everything, because a cure must stay reachable even when a',\n ' downstream guard would block it.',\n '',\n 'Every Bash entry is anchored to the WHOLE command. A leading `cd <dir> &&`, a trailing `2>&1`',\n 'and a pipe into `tail`/`head` are tolerated; nothing else is. Appending `&& git status` makes it',\n 'a DIFFERENT command and it is rejected again — that is not the guard refusing its own cure.',\n '',\n '`git merge` is deliberately NOT on this list. Main is merged ONLY through the 3-point fork merge',\n '(`pnpm wp-start-update`, or `pnpm wp-start-upsert-pr` when a PR is already open).',\n '',\n '## Known asymmetry',\n '',\n 'Under `S`/`C`/`Y` the guard bin IS running, so a PASS really does fall through to the downstream',\n 'guards. Under `D`/`X`/`K` the bin is never executed, so there is nothing to fall through to and a',\n 'PASS degenerates into a terminal allow — reads are unguarded during those three faults.',\n '',\n '## Widening L0',\n '',\n 'Add an entry to `L0_ALLOWLIST` in `packages/tooling/ai-hook-rules/src/bin/shim.ts`. That array is',\n 'the single source for the JS allowlist, the `grep -E` inside the rendered shim, and this file.',\n '',\n ].join('\\n');\n}\n\n/**\n * Drop the matrix doc where the AI can read it, and return its absolute path ('' if it could not be\n * written). Called from the L0 BLOCK path so the deny can say `READ <path>`.\n *\n * Best-effort by design: this runs while the tree is already known-broken, and a missing template (an\n * @webpieces/rules-config older than this package) must degrade the deny message, never replace it\n * with a crash.\n */\n// webpieces-disable no-function-outside-class -- sibling of renderGuardMatrixDoc in this module\nexport function writeGuardMatrixDoc(workspaceRoot: string): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return writeTemplate(workspaceRoot, GUARD_MATRIX_DOC);\n } catch (err: unknown) {\n const error = toError(err);\n void error; // best-effort: no doc → the deny simply omits the pointer\n return '';\n }\n}\n\n/** The `READ <path>` pointer appended to an L0 deny, or '' when the doc could not be written. */\n// webpieces-disable no-function-outside-class -- sibling of writeGuardMatrixDoc in this module\nexport function guardMatrixPointer(docPath: string): string {\n if (docPath === '') return '';\n return ` The full L0 guard matrix - all six faults and everything that is allowed through - is at ${docPath}; READ it if you are unsure why this call was blocked.`;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"l0-matrix.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/l0-matrix.ts"],"names":[],"mappings":"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","sourcesContent":["import { CONFIG_FILENAME, writeTemplate } from '@webpieces/rules-config';\n\nimport {\n INSTALL_HOOKS_CMD, L0AllowEntry, L0Call, L0_ALLOWLIST, RECOVERY_CMD,\n RESTORE_SHIM_CMD, UPGRADE_SHIM_CMD, renderShim, shimStaleDenyReason,\n} from '../bin/shim';\nimport { toError } from './to-error';\n\n// ---------------------------------------------------------------------------\n// L0 — the TOOLING-INTEGRITY layer, as data.\n//\n// L0 is the outermost guard: it blocks work while node_modules, the committed shim, or\n// webpieces.config.json are in a state that makes every OTHER guard untrustworthy. It has SIX faults\n// and — drawn as a decision matrix — NO genuine second dimension:\n//\n// fault present AND call not on the allowlist -> BLOCK(messageFor(fault))\n//\n// so the only thing that varies per fault is the MESSAGE. This module holds the fault table and\n// renders it, together with L0_ALLOWLIST (../bin/shim), into webpieces.guard-matrix.md — the doc the\n// deny messages point the AI at. Doc and code come from the SAME arrays, so they cannot drift.\n// ---------------------------------------------------------------------------\n\n/**\n * The doc L0's deny messages point at. Lives in @webpieces/rules-config/templates alongside the others,\n * and is written to <root>/.webpieces/instruct-ai/ lazily, only on an L0 BLOCK.\n *\n * That generated doc is the AUTHORITY for the fault table and the allowlist (same arrays, cannot\n * drift). GUARD_MATRIX.md at this repo's root is the hand-written companion: it adds L0's evaluation\n * ORDER, the use cases and the known gaps, and it documents L1, none of which are rendered from code.\n * Change L0_FAULTS or L0_ALLOWLIST and the generated doc follows automatically — GUARD_MATRIX.md does\n * not, so update it in the same PR.\n */\nexport const GUARD_MATRIX_DOC = 'webpieces.guard-matrix.md';\n\n/**\n * One CURE for a fault: the exact call, plus the `mention` that must appear in that fault's deny text.\n *\n * Both halves are asserted (l0-matrix.spec.ts): the call must be accepted by isAllowed(), and the deny\n * message must actually name it. That pairing is the anti-deadlock invariant — a message that\n * prescribes a command the allowlist rejects is exactly the shape of the three deadlocks CLAUDE.md\n * records, and it is how the dead `wp-setup-ai-hooks` bin in the config-missing text was caught.\n */\nexport class L0Cure {\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n readonly mention: string,\n readonly call: L0Call,\n /**\n * The one to reach for first when a fault has several. Exactly one cure per fault carries it,\n * so the rendered Fix section never asks the reader to choose between equals.\n */\n readonly preferred: boolean,\n /**\n * WHEN to pick this cure over its siblings — the sentence that makes a list of commands\n * actionable instead of a menu (\"when the PIN is the stale side\", not \"an alternative\").\n */\n readonly discriminator: string,\n ) {}\n\n /** A Bash cure renders as a literal command; a tool-shaped one renders as the edit it stands for. */\n isCommand(): boolean {\n return this.call.toolName === 'Bash';\n }\n}\n\n/** One L0 fault. Data-only → a class, per CLAUDE.md. */\nexport class L0Fault {\n constructor(\n readonly code: string,\n readonly name: string,\n readonly detectedBy: string,\n readonly enforcedIn: string,\n readonly cures: readonly L0Cure[],\n /**\n * The artifact carrying this fault's deny text. For S/C/Y that is the deny string itself; for\n * D/X/K the text is built in POSIX sh inside the rendered shim, so it is the rendered shim —\n * the same bytes the consumer runs, which is what the mention assertion needs to search.\n */\n readonly denyText: string,\n ) {}\n}\n\n// The deny for fault C, and the ONLY message L0 has for a repo with no webpieces.config.json at all.\n// Moved here from runner.ts so the fault table and the runner cannot state different cures.\n//\n// It used to name `./node_modules/.bin/wp-setup-ai-hooks` — a bin that HAS NOT EXISTED since it was\n// renamed to wp-install-ai-hooks. So the one command this deny prescribed was (a) not installable and\n// (b) not on the L0 allowlist in that spelling, i.e. the AI was handed a cure it could neither run nor\n// get past the guard. Now it names the installer that actually seeds the config AND is entry 8 of the\n// allowlist, and it says out loud that writing the config yourself is allowed through.\n//\n// ORDERING (2026-08-02): writing the file yourself now LEADS. The bare installer used to be OPTION 1,\n// but it seeds the config and then PROMPTS twice for a hook target, which hangs a non-interactive\n// agent. Writing the file is the one cure that always works, and it is the same cure every other config\n// problem has (see the config-validation invariant in GUARD_MATRIX.md): the validator reports every\n// error at once, so the write/validate loop converges in a couple of passes.\nexport const CONFIG_MISSING_REPORT =\n `${CONFIG_FILENAME} not found — the webpieces guards cannot run without it, so every other tool call is blocked.\\n` +\n 'THIS IS NOT A DEADLOCK: both options below are explicitly allowed through while this guard is up.\\n' +\n `OPTION 1 (preferred — it needs no other tool and it never prompts) - create ${CONFIG_FILENAME}\\n` +\n 'yourself: any Read, and any Write/Edit whose target is that file, is always allowed through, so\\n' +\n 'you can inspect the repo and write it. The validator reports EVERY missing/invalid entry at once\\n' +\n '(each with the snippet to paste), so a minimal first draft converges in about two passes.\\n' +\n 'OPTION 2 (pick this ONLY at an interactive terminal where you can answer its two prompts) - run\\n' +\n 'EXACTLY this command to seed the config: `pnpm exec wp-install-ai-hooks`. It goes on to wire the\\n' +\n 'Claude Code hooks and asks for a target twice, which hangs a non-interactive session.\\n' +\n 'Do not append anything to the option you pick — the allowlist is anchored to the whole command.';\n\n// The first line of the fault-Y deny (built out in runner.checkConfigSync, which appends the per-rule\n// detail). Kept here so the fault table quotes the same text the runner emits.\nexport const CONFIG_OUT_OF_SYNC_HEADER =\n `${CONFIG_FILENAME} is out of sync — new built-in rules are present that have no entry in ${CONFIG_FILENAME}.`;\n\n// Writing/repairing the file yourself. PREFERRED for both config faults, per the config-validation\n// invariant in GUARD_MATRIX.md: every config problem cures to \"make the file right\", the validator\n// reports all errors at once so the loop converges in a couple of passes, and allowlist entry 2 permits\n// this edit unconditionally. (That section is the authority — do not restate its reasoning here.)\nconst CONFIG_WRITE_CURE = new L0Cure(\n CONFIG_FILENAME, new L0Call('Edit', '', `/repo/${CONFIG_FILENAME}`), true,\n 'this fault fires at all — it is the only cure that needs no other tool, and it is never denied',\n);\n\n// Cure calls are spelled exactly as the deny messages spell them, so the mention assertion is a real\n// string search rather than a paraphrase.\n// webpieces-disable no-function-outside-class -- pure constructor helper for the L0_FAULTS literal below, in this data module\nfunction bashCure(command: string, preferred: boolean, discriminator: string): L0Cure {\n return new L0Cure(command, new L0Call('Bash', command, ''), preferred, discriminator);\n}\n\n/**\n * THE six L0 faults, in first-match-wins order. D/X/K are decided in POSIX sh BEFORE the bin runs (a\n * stale, missing or broken validator cannot be trusted to validate itself); S/C/Y are decided inside\n * the bin, in JS. One model, two enforcement points.\n */\nexport const L0_FAULTS: readonly L0Fault[] = [\n new L0Fault('D', 'version drift — root package.json pin != installed version',\n 'sh, before the bin runs', 'sh',\n [\n // `pnpm install` clears D in BOTH directions — it makes installed == pin by definition — so\n // it is always the preferred cure. The direction only decides whether the PIN is the version\n // you WANT, which is what the second cure is for.\n bashCure('pnpm install', true,\n 'node_modules is OLDER than the pin, OR you are on a feature branch and want YOUR '\n + 'branch pin (usually the case) — it always clears the drift'),\n bashCure('git pull', false,\n 'node_modules is NEWER than the pin AND you are on main — the PIN is the stale side, so '\n + 'pull first and install second; a bare install would downgrade you'),\n ], renderShim()),\n new L0Fault('X', 'guard bin missing (fresh clone / new worktree / package removed)',\n 'sh, before the bin runs', 'sh',\n [bashCure('pnpm install', true,\n 'this fault fires at all — nothing is installed in THIS tree, and a new git worktree '\n + 'copies no node_modules')],\n renderShim()),\n new L0Fault('K', 'guard bin present but CRASHED (exit code not 0 or 2 — corrupt node_modules)',\n 'sh, before the bin runs', 'sh',\n [bashCure(RECOVERY_CMD, true,\n 'this fault fires at all — a BARE pnpm install SKIPS the corrupt package, because pnpm sees '\n + 'the right version on disk and considers it installed; only the delete forces a rewrite')],\n renderShim()),\n new L0Fault('S', 'committed .claude/webpieces/ai-hook.sh != renderShim()',\n 'the guard bin', 'JS',\n [\n // wp-upgrade-shim is the SURGICAL tool and therefore leads: upgrade-shim.ts writes\n // renderShim() to .claude/webpieces/ai-hook.sh and touches nothing else — no config, no\n // settings.json — and it imports only fs/path, so it runs on a tree too broken to load the\n // rule engine. The INSTALLER is deliberately NOT a cure here: it also migrates the config\n // and wires BOTH hooks, prompting for a target twice, which hangs a non-interactive agent.\n bashCure(UPGRADE_SHIM_CMD, true,\n 'this fault fires at all — it regenerates the shim and NOTHING else (no config, no '\n + 'settings.json); needs installed @webpieces/ai-hook-rules 0.4.408 or newer'),\n // 2026-07-21: the version gap below caused a real \"command not found\" deadlock.\n bashCure(RESTORE_SHIM_CMD, false,\n 'the installed @webpieces/ai-hook-rules is OLDER than 0.4.408, so wp-upgrade-shim does '\n + 'not exist yet — this works on every release, though Claude Code may ask you to '\n + 'confirm the overwrite, and that prompt is NOT this guard'),\n ],\n shimStaleDenyReason('')),\n new L0Fault('C', `${CONFIG_FILENAME} missing`,\n 'the guard bin', 'JS',\n [\n CONFIG_WRITE_CURE,\n // Kept, but demoted: it seeds the file and then PROMPTS twice for a hook target.\n bashCure(INSTALL_HOOKS_CMD, false,\n 'you are at an INTERACTIVE terminal and can answer its two hook-target prompts'),\n ], CONFIG_MISSING_REPORT),\n new L0Fault('Y', `a loaded rule has no ${CONFIG_FILENAME} key`,\n 'the guard bin', 'JS',\n [CONFIG_WRITE_CURE], CONFIG_OUT_OF_SYNC_HEADER),\n];\n\n/**\n * One fault's FIX section, rendered from its `cures` array — literal commands only, never prose.\n *\n * This is the half that used to live in hand-written docs and drift. The three fields of L0Cure map\n * onto the three things a blocked reader needs and nothing else: WHAT to type (the call), WHETHER it is\n * the default (preferred), and WHEN to pick a sibling instead (discriminator). A cure with no\n * discriminator would render as a menu of equals, which is how an agent picks the wrong one.\n */\n// webpieces-disable no-function-outside-class -- pure string builder for renderGuardMatrixDoc below, beside the arrays it reads\nfunction renderFixSection(fault: L0Fault): string[] {\n const options = fault.cures.map((cure: L0Cure, i: number): string => {\n // A Bash cure is the command verbatim; a tool-shaped one is the file it edits (allowlist entry 2).\n const literal = cure.isCommand() ? `\\`${cure.call.command}\\`` : `edit \\`${cure.mention}\\` yourself`;\n const label = cure.preferred ? `Option ${i + 1} (preferred)` : `Option ${i + 1}`;\n return `- **${label}**: ${literal} ← pick this when ${cure.discriminator}`;\n });\n return [`### \\`${fault.code}\\` — ${fault.name}`, '', ...options, ''];\n}\n\n/**\n * Render webpieces.guard-matrix.md from L0_FAULTS + L0_ALLOWLIST.\n *\n * A unit test locks the committed template byte-identical to this output, the same way\n * templates/ai-hook.sh is locked to renderShim(). That is what makes the doc assertable instead of\n * aspirational: the table in the doc IS the array the guard consults.\n */\n// webpieces-disable no-function-outside-class -- pure string builder over the two exported arrays, beside them in this module\nexport function renderGuardMatrixDoc(): string {\n return [\n '# webpieces guard matrix — L0 (tooling integrity)',\n '',\n 'GENERATED from `L0_FAULTS` + `L0_ALLOWLIST` in `@webpieces/ai-hook-rules`. Do not hand-edit —',\n 'a unit test locks this file byte-identical to `renderGuardMatrixDoc()`, so the table below is',\n 'the array the guard actually consults, not a description of it.',\n '',\n 'L0 is the OUTERMOST guard layer. It blocks work while `node_modules`, the committed shim, or',\n '`webpieces.config.json` are in a state that makes every other guard untrustworthy. If you are',\n 'reading this, one of the six faults below fired and named this file.',\n '',\n '## The faults',\n '',\n '| code | fault | detected by | enforced in |',\n '|---|---|---|---|',\n ...L0_FAULTS.map((f: L0Fault): string => `| \\`${f.code}\\` | ${f.name} | ${f.detectedBy} | ${f.enforcedIn} |`),\n '',\n 'First match wins. `D`/`X`/`K` are decided in POSIX `sh` inside the committed shim, BEFORE the',\n 'guard bin runs — a stale, missing or broken validator cannot be trusted to validate itself.',\n '',\n '## The fix, per fault',\n '',\n 'Every command below is rendered from that fault\\'s `cures` array and is asserted, by unit test,',\n 'to be accepted by `isAllowed()` — so nothing here can be a command the guard then rejects. Type',\n 'the option you pick EXACTLY as written and run nothing else on that line.',\n '',\n ...L0_FAULTS.flatMap(renderFixSection),\n ...renderMatrixAndAllowlist(),\n ].join('\\n');\n}\n\n/**\n * The second half of the doc: the three-row matrix and the ONE allowlist. Split out of\n * renderGuardMatrixDoc solely to keep it inside the method-line budget — the join order is what makes\n * the two halves one file, so keep them adjacent and keep the byte-lock test as the arbiter.\n */\n// webpieces-disable no-function-outside-class -- second half of renderGuardMatrixDoc's string, beside it in this module\nfunction renderMatrixAndAllowlist(): string[] {\n return [\n '## The matrix',\n '',\n 'L0 has NO genuine second dimension. Every branch reduces to one question:',\n '',\n '| # | fault | on the allowlist? | outcome |',\n '|---|---|---|---|',\n '| 1 | none | — | hand down to the next guard layer |',\n '| 2 | any | yes | PASS or ALLOW (see the entry) |',\n '| 3 | any | no | BLOCK — **only the message varies by fault** |',\n '',\n 'The tool is not a dimension either: \"any Read\" is an allowlist ENTRY, not a tool check.',\n '',\n '## The allowlist',\n '',\n 'ONE list, consulted identically by all six faults. A cure that cannot help a given fault also',\n 'cannot hurt it, and gating each entry on a fault is what produced four real defects (a stale',\n 'shim that denied `pnpm install` and `git pull`; faults that denied every Read; a config fault',\n 'that denied `rm -rf node_modules && pnpm install` while allowing a bare `pnpm install`).',\n '',\n '| # | allowed | outcome |',\n '|---|---|---|',\n ...L0_ALLOWLIST.map((e: L0AllowEntry, i: number): string => `| ${i + 1} | ${e.label} | ${e.kind.toUpperCase()} |`),\n '',\n '- **PASS** — L0 has no objection; the call falls THROUGH so the downstream guards still judge it.',\n '- **ALLOW** — terminal; bypasses everything, because a cure must stay reachable even when a',\n ' downstream guard would block it.',\n '',\n 'Every Bash entry is anchored to the WHOLE command. A leading `cd <dir> &&`, a trailing `2>&1`',\n 'and a pipe into `tail`/`head` are tolerated; nothing else is. Appending `&& git status` makes it',\n 'a DIFFERENT command and it is rejected again — that is not the guard refusing its own cure.',\n '',\n '`git merge` is deliberately NOT on this list. Main is merged ONLY through the 3-point fork merge',\n '(`pnpm wp-start-update`, or `pnpm wp-start-upsert-pr` when a PR is already open).',\n '',\n '## Known asymmetry',\n '',\n 'Under `S`/`C`/`Y` the guard bin IS running, so a PASS really does fall through to the downstream',\n 'guards. Under `D`/`X`/`K` the bin is never executed, so there is nothing to fall through to and a',\n 'PASS degenerates into a terminal allow — reads are unguarded during those three faults.',\n '',\n '## Widening L0',\n '',\n 'Add an entry to `L0_ALLOWLIST` in `packages/tooling/ai-hook-rules/src/bin/shim.ts`. That array is',\n 'the single source for the JS allowlist, the `grep -E` inside the rendered shim, and this file.',\n '',\n ];\n}\n\n/**\n * Drop the matrix doc where the AI can read it, and return its absolute path ('' if it could not be\n * written). Called from the L0 BLOCK path so the deny can say `READ <path>`.\n *\n * Best-effort by design: this runs while the tree is already known-broken, and a missing template (an\n * @webpieces/rules-config older than this package) must degrade the deny message, never replace it\n * with a crash.\n */\n// webpieces-disable no-function-outside-class -- sibling of renderGuardMatrixDoc in this module\nexport function writeGuardMatrixDoc(workspaceRoot: string): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return writeTemplate(workspaceRoot, GUARD_MATRIX_DOC);\n } catch (err: unknown) {\n const error = toError(err);\n void error; // best-effort: no doc → the deny simply omits the pointer\n return '';\n }\n}\n\n/** The `READ <path>` pointer appended to an L0 deny, or '' when the doc could not be written. */\n// webpieces-disable no-function-outside-class -- sibling of writeGuardMatrixDoc in this module\nexport function guardMatrixPointer(docPath: string): string {\n if (docPath === '') return '';\n return ` The full L0 guard matrix - all six faults and everything that is allowed through - is at ${docPath}; READ it if you are unsure why this call was blocked.`;\n}\n"]}
|