@webpieces/ai-hook-rules 0.4.625 → 0.4.627
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/hook-registration.d.ts +4 -3
- package/src/bin/hook-registration.js +4 -3
- package/src/bin/hook-registration.js.map +1 -1
- package/src/bin/shim-deny-reason.js +49 -8
- package/src/bin/shim-deny-reason.js.map +1 -1
- package/src/bin/shim.js +1 -1
- package/src/bin/shim.js.map +1 -1
- package/src/core/effective-tree.d.ts +32 -3
- package/src/core/effective-tree.js +34 -15
- package/src/core/effective-tree.js.map +1 -1
- package/src/core/l1-doc.js +6 -5
- package/src/core/l1-doc.js.map +1 -1
- package/src/core/l1-rows.js +2 -2
- package/src/core/l1-rows.js.map +1 -1
- package/src/core/version-sync.d.ts +19 -0
- package/src/core/version-sync.js +38 -12
- package/src/core/version-sync.js.map +1 -1
- package/templates/ai-hook.sh +1 -1
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 { CONFIG_FILENAME, claudeEnv } from '@webpieces/rules-config';\n\nimport {\n L0_FAULT_BIN_BROKEN, L0_FAULT_BIN_MISSING, L0_FAULT_DRIFT, L0_FAULT_UNDECLARED,\n} from '../core/l0-fault-codes';\nimport { toError } from '../core/to-error';\nimport {\n L0_ALLOW_ERE_SH, 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 ADD_HOOK_PKG_CMD, HOOK_PKG,\n} from './l0-allowlist';\nimport { WP_LOG_SH } from './shim-audit-log';\nimport { BASH_CWD_ENV_KEY, BASH_CWD_ENV_VALUE } from './managed-env';\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// Same treatment for the audit-log fragment: one name to import the whole rendered shim by.\nexport * from './shim-audit-log';\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/L0-shim/<writer>.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// ---------------------------------------------------------------------------\n// LENGTH IS PART OF THE FIX (2026-08-03). This block used to run ~153 words and was repeated verbatim\n// in D, X and K — in X it was ~85% of the whole message, which buries the one sentence that matters.\n// It now states the rule and the three tolerated additions, and nothing else: the tolerated set is\n// exactly what CD_PREFIX_ERE + CAPTURE_TAIL_ERE accept (the single-quoted path branch of CD_PREFIX_ERE\n// is why \"single-quote a path containing spaces\" is named), so this text cannot promise more or less\n// than the allowlist grants.\nexport const NO_CHAINING_RULE =\n 'Run it EXACTLY as written - the allowlist matches the whole command, so appending anything ' +\n '(even && git status) makes it a different command and it is rejected; that is not the guard ' +\n 'blocking its own cure. Only these may be added: a leading cd <dir> && (single-quote a path ' +\n 'containing spaces), a trailing 2>&1, and | tail -N.';\n\n// Shell fragment: resolve the guard BIN by WALKING UP from ROOT, and remember WHERE it came from.\n//\n// THE BUG THIS CLOSES (it would have landed the day the hooks went relative). `ai-hook.sh` used to set\n// `BIN=\"$ROOT/node_modules/.bin/$BIN_NAME\"` — a LITERAL path with no upward walk, while Node's own\n// resolver walks up. That was correct only while the hooks were registered ABSOLUTE, because then ROOT\n// was always the primary clone and the bin was always there. The moment H2/H3 became relative, ROOT\n// became the tree the call is in — and a nested worktree at `<primary>/.claude/worktrees/<name>` has NO\n// node_modules of its own. Every subagent would have hard-blocked on fault X at its first tool call,\n// fleet-wide, on the day of the flip. Walking up finds the primary's install, exactly as a `require()`\n// from the same directory would; a SIBLING worktree finds nothing and correctly still faults X.\n//\n// BIN_ROOT is not a curiosity: walking up ALONE re-creates the version straddle documented above\n// committedShimStale(), where the shim of one tree is paired with the binary of another and the cure\n// can never converge. So the walk is paired with a check — DECLARED comes from `$ROOT/package.json`\n// (the tree being judged) and INSTALLED comes from `$BIN_ROOT/node_modules` (the binary actually\n// running). Equal → no fault, keep reusing the inherited bin, which is the common case and stays free.\n// Different → fault D, cured by an install in THIS tree, which materialises its own node_modules.\nconst RESOLVE_BIN_SH = `BIN_ROOT=\"\\$ROOT\"\nBIN=\"\\$ROOT/node_modules/.bin/\\$BIN_NAME\"\nWP_WALK=\"\\$ROOT\"\nwhile [ ! -x \"\\$WP_WALK/node_modules/.bin/\\$BIN_NAME\" ]; do\n WP_UP=\"\\$(dirname -- \"\\$WP_WALK\")\"\n [ \"\\$WP_UP\" != \"\\$WP_WALK\" ] || break\n WP_WALK=\"\\$WP_UP\"\ndone\nif [ -x \"\\$WP_WALK/node_modules/.bin/\\$BIN_NAME\" ]; then\n BIN_ROOT=\"\\$WP_WALK\"\n BIN=\"\\$WP_WALK/node_modules/.bin/\\$BIN_NAME\"\nfi`;\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.\n#\n# THE SAME PASS ANSWERS FAULT U (2026-08-05). Scraping root package.json is also the only way to learn\n# whether @webpieces/ai-hook-rules is DECLARED at all, and that is the difference between \"not installed\n# yet\" (X, cured by pnpm install) and \"nothing asks for it\" (U, where pnpm install is a guaranteed\n# no-op). WP_PIN carries the first EXACT @webpieces pin found, so U's deny can prescribe the version the\n# rest of the repo is already on rather than an unpinned add. Both are set BEFORE the range/catalog\n# \\`continue\\`s, so a repo pinning the package by range still counts as having declared it.\nDRIFT_PKG=\"\"\nDRIFT_DECLARED=\"\"\nDRIFT_INSTALLED=\"\"\nWP_HOOK_PKG_DECLARED=\"\"\nWP_PIN=\"\"\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 # Fault U's input: the package is DECLARED (in any spec shape, in any dependency block of the root\n # manifest). Recorded before every \\`continue\\` below, so a range or catalog spec still counts.\n [ \"$WP_NAME\" = \"ai-hook-rules\" ] && WP_HOOK_PKG_DECLARED=1\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 # The release the rest of this repo is on — what fault U's cure should pin to.\n [ -n \"$WP_PIN\" ] || WP_PIN=\"$WP_DECL\"\n WP_MANIFEST=\"$BIN_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# THE CURE FOR A BORROWED node_modules RUNS IN THIS TREE, NOT WHEREVER THE BIN CAME FROM. A bare\n# 'pnpm install' typed while the shell sits in the primary clone installs into the primary, changes\n# nothing in the worktree being judged, and re-fires the identical fault — the four-cure straddle\n# recorded above committedShimStale(). When the bin was inherited, prescribe the cd and say why.\nWP_INSTALL_CMD=\"pnpm install\"\nWP_BORROW_NOTE=\"\"\nif [ \"$BIN_ROOT\" != \"$ROOT\" ]; then\n WP_INSTALL_CMD=\"cd $ROOT && pnpm install\"\n WP_BORROW_NOTE=\" NOTE: this tree ($ROOT) has NO node_modules of its own, so the guard binary was inherited from $BIN_ROOT by walking up. For a linked WORKTREE that is the DESIGNED state, not a gap - the guard hooks are registered absolute, so the main tree governs every tree and a worktree needs no install of its own. What matters is that the two trees agree on the VERSION, and because the pin is tracked in git the reliable way to get that is the same git hash in both, then ONE 'pnpm install' in the main tree. Installing HERE is legitimate too (adding a dependency does it), but then this tree's own @webpieces must match the main tree's. If you genuinely need a DIFFERENT version, use a separate clone rather than a worktree.\"\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 # THE HEALTHY CALL, which this log used to be silent about (see WP_LOG_SH's header). No sh-side\n # fault, the bin ran, and its own exit code says how it ended — 0 allow, 2 block. Logged AFTER the\n # decision bytes are already on stdout, so the audit trail never sits between the guard and Claude\n # Code. Without this line, \"no entry\" meant either healthy or never-ran, and those are the two\n # answers a reader most needs to tell apart.\n WP_VERDICT=PASS-BIN-ALLOW\n [ \"\\$RC\" = 2 ] && WP_VERDICT=PASS-BIN-BLOCK\n wp_log - \"\\$WP_VERDICT\"\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: pull the four fields the shim itself reasons about out of the tool payload.\n//\n// MOVED AHEAD OF THE BIN (it used to sit inside TRIAGE_SH, i.e. only on the fail-closed path) because\n// the audit log now covers the HEALTHY call too, and a log line needs the tool and the command whether\n// or not anything went wrong.\n//\n// `cwd` is Claude Code's documented \"current working directory when the hook is invoked\". It is used\n// for ONE thing: deciding which tree's log directory this line belongs in (see RESOLVE_LOG_DIR_SH).\n// It deliberately does NOT change what the drift guard MEASURES — that stays anchored to $ROOT, the\n// tree the shim FILE lives in. Where a call is logged and what a call is judged against are separate\n// questions and are kept separate here.\n// TWO command variables, and the split is a SECURITY boundary — do not collapse them.\n//\n// $CMD is the DECISION input (the L0 allowlist greps it). Its pattern requires the CLOSING quote, so a\n// JSON payload that escapes an embedded quote as \\\\\" yields the EMPTY STRING for the whole command.\n// That looks like a bug and is in fact the safe direction: an empty command matches no allowlist entry,\n// so a quoted command falls through to the deny. FAIL CLOSED. Keep it that way.\n//\n// $CMD_LOG is the AUDIT input and must never reach a decision. It drops the closing quote from the\n// pattern so it captures the command PREFIX instead of nothing.\n//\n// WHY THEY CANNOT BE ONE VARIABLE: every L0 allowlist ERE is anchored `^…[[:space:]]*$`, and trailing\n// whitespace is tolerated — so `pnpm install \"; rm -rf /\"` would prefix-capture to `pnpm install `,\n// which MATCHES, and the injection after the quote would ride through allowlisted. Measured 2026-08-06:\n// 3,908 of 4,917 shim audit lines (79.5%) recorded an empty command, i.e. four out of five audit\n// entries were blind. Fixing the LOG is worth doing; fixing the DECISION the same way is a hole.\nconst PARSE_PAYLOAD_SH = `CMD=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"command\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nCMD_LOG=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"command\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\).*/\\\\1/p')\"\n[ -n \"\\$CMD_LOG\" ] || CMD_LOG=\"\\$CMD\"\nTOOL=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"tool_name\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nWP_SID=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"session_id\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nWP_AID=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"agent_id\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nFILE=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"file_path\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nWP_CWD=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"cwd\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\n[ -n \"\\$WP_CWD\" ] || WP_CWD=\"\\$ROOT\" # no cwd in the payload (older client, or a hand-run) → the shim's own tree`;\n\n// Shell fragment: the guards are DOWN (missing | stale | crashed). Classify the fault, then let THE L0\n// 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 = `# WHICH of the guards/L0-tooling.md faults fired, in the doc's own letters. Only the four sh-side\n# codes can be decided here; S/C/Y live in the binary, which never got to run on this path.\nWP_FAULT=${L0_FAULT_BIN_MISSING} # X — bin missing (fresh clone, new worktree)\n[ -z \"\\$WP_HOOK_PKG_DECLARED\" ] && WP_FAULT=${L0_FAULT_UNDECLARED} # U — X, but nothing declares the package: install is a no-op\n[ -n \"\\$DRIFT_PKG\" ] && WP_FAULT=${L0_FAULT_DRIFT} # D — version drift; D and K are mutually exclusive\n[ -n \"\\$BROKEN_BIN\" ] && WP_FAULT=${L0_FAULT_BIN_BROKEN} # K — bin present but CRASHED (corrupt node_modules)\nDENY_LABEL=\"DENY\"\n[ -z \"\\$WP_HOOK_PKG_DECLARED\" ] && DENY_LABEL=\"DENY-UNDECLARED\" # nothing in package.json asks for the package\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 \"\\$WP_FAULT\" 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 \"\\$WP_FAULT\" 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_SH}'; then\n wp_log \"\\$WP_FAULT\" 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 \"\\$WP_FAULT\" \"\\$DENY_LABEL\" # every fail-closed block, with the fault that caused it`;\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 \"\\$BIN_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.\" # only when N > 0\n fi\n REASON=\"❌ webpieces guards are DOWN and every other 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. Run EXACTLY: '${RECOVERY_CMD}'. A bare 'pnpm install' will NOT fix this: pnpm sees the correct version on disk and skips the broken package.\\${STAGING_NOTE} ${NO_CHAINING_RULE}\"\nelif [ -n \"\\$DRIFT_PKG\" ]; then\n # DECIDE THE DIRECTION, do not make the reader do it (2026-08-03). The detection is a plain !=, so it\n # fires BOTH ways, and the message used to carry OPTION 1/2/3 covering every direction at once — 3343\n # chars of which only about a third was the decision. A reader on the wrong branch of that menu was\n # one misread away from a downgrade. So compare the two versions HERE and emit only the relevant half.\n #\n # WITH AWK, not \\`sort -V\\`: -V is a GNU extension (absent/different on BSD sort), while the shim\n # already runs an awk pass to resolve catalog: specs, so awk adds no dependency. The program compares\n # the numeric cores component-by-component; a pre-release/build suffix (-rc.1, +sha) is stripped from\n # the core, and when the cores are EQUAL the side carrying a PRE-RELEASE suffix is the older one\n # (semver precedence). Build metadata (+sha) carries NO precedence, so two versions differing only\n # there come back undecidable rather than ordered. Anything it cannot parse prints NOTHING, and an\n # empty answer falls through to the ambiguous wording below rather than guessing a direction.\n #\n # WHAT WAS DELETED, so it does not creep back: the \"how to get main itself current\" paragraph and the\n # \"do NOT reach for git merge --ff-only / reset --hard / checkout -B main\" paragraph both belong to\n # redirect-how-to-merge-main, which fires on its own with its own message; and the sentence that named\n # wp-start-update / wp-start-upsert-pr ONLY to forbid them while the block is up — naming a command\n # purely to forbid it is pure cost, and the install that clears this fault comes first regardless.\n DRIFT_DIR=\"\\$(awk -v i=\"\\$DRIFT_INSTALLED\" -v d=\"\\$DRIFT_DECLARED\" 'BEGIN {\n iv = i; sub(/\\\\+.*/, \"\", iv); ic = iv; sub(/-.*/, \"\", ic); ip = substr(iv, length(ic) + 1)\n dv = d; sub(/\\\\+.*/, \"\", dv); dc = dv; sub(/-.*/, \"\", dc); dp = substr(dv, length(dc) + 1)\n if (ic !~ /^[0-9]+(\\\\.[0-9]+)*\\$/ || dc !~ /^[0-9]+(\\\\.[0-9]+)*\\$/) exit\n n = split(ic, ia, \".\"); m = split(dc, da, \".\"); k = (n > m) ? n : m\n for (x = 1; x <= k; x++) {\n av = (x <= n) ? ia[x] + 0 : 0; bv = (x <= m) ? da[x] + 0 : 0\n if (av < bv) { print \"older\"; exit }\n if (av > bv) { print \"newer\"; exit }\n }\n if (ip == dp) exit\n if (ip != \"\" && dp == \"\") print \"older\"\n if (ip == \"\" && dp != \"\") print \"newer\"\n }' 2>/dev/null)\"\n if [ \"\\$DRIFT_DIR\" = older ]; then\n REASON=\"❌ webpieces version drift: package.json pins \\$DRIFT_PKG@\\$DRIFT_DECLARED but node_modules has \\$DRIFT_INSTALLED - node_modules is OLDER, so the pin is what you want. Every other call is blocked until they agree. Run EXACTLY: '\\$WP_INSTALL_CMD'.\\${WP_BORROW_NOTE} ${NO_CHAINING_RULE}\"\n else\n # NEWER, or undecidable — the same three choices apply either way, so the only thing the ambiguous\n # case changes is the claim about which side is stale.\n DRIFT_NOTE=\"node_modules is NEWER, so the PIN is the stale side and a bare 'pnpm install' DOWNGRADES you to \\$DRIFT_DECLARED\"\n [ \"\\$DRIFT_DIR\" = newer ] || DRIFT_NOTE=\"these two versions could not be ordered automatically - compare them yourself: if node_modules is the NEWER side then the PIN is the stale side and a bare 'pnpm install' DOWNGRADES you to \\$DRIFT_DECLARED\"\n REASON=\"❌ webpieces version drift: package.json pins \\$DRIFT_PKG@\\$DRIFT_DECLARED but node_modules has \\$DRIFT_INSTALLED - \\$DRIFT_NOTE. That may be exactly what you want. Every other call is blocked until they agree. Pick one: - move forward to what origin pins: run 'git pull origin main', then 'pnpm install'. - stay on this code deliberately: run 'pnpm install' (the downgrade is the point). - on a feature branch: run 'pnpm install' (aligns to YOUR branch pin - usually right).\\${WP_BORROW_NOTE} ${NO_CHAINING_RULE}\"\n fi\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 it HERE, in this worktree, not in the primary clone.\"\n fi\n if [ -z \"\\$WP_HOOK_PKG_DECLARED\" ]; then\n # FAULT U — the one shape where the X message is not merely unhelpful but actively WRONG. It asserted\n # \"declared in package.json\" without ever checking, and prescribed the one command that provably\n # cannot help: with nothing asking for the package, \\`pnpm install\\` reports \"Lockfile is up to date\"\n # and converges to the identical broken tree, forever. So say what is actually true, say out loud\n # that the install is a no-op (an agent that has already run it needs to be told to STOP), and\n # prescribe the add — which is allowlist entry ADD_HOOK_PKG, so it is reachable while this block is up.\n WP_ADD_CMD=\"${ADD_HOOK_PKG_CMD}\"\n [ -n \"\\$WP_PIN\" ] && WP_ADD_CMD=\"\\${WP_ADD_CMD}@\\$WP_PIN\"\n REASON=\"❌ ${HOOK_PKG} is NOT declared in package.json anywhere, and is not installed (\\${BIN_NAME} not found) - yet .claude/settings.json still runs its hooks, so every tool call is blocked. Do NOT run 'pnpm install': nothing asks for this package, so it is a NO-OP and repeating it converges to this same state. It normally arrives with @webpieces/nx-webpieces-rules, the umbrella that bundles the whole toolchain - so the durable fix is to upgrade that. To unblock yourself right now, declare it directly. Run EXACTLY: '\\$WP_ADD_CMD'. ${NO_CHAINING_RULE} (If you removed ${HOOK_PKG} on purpose, delete its hooks from .claude/settings.json instead.)\"\n else\n REASON=\"❌ ${HOOK_PKG} is declared in package.json but is not installed (\\${BIN_NAME} not found). Run EXACTLY: 'pnpm install'.\\${WORKTREE_NOTE} ${NO_CHAINING_RULE} (If you removed ${HOOK_PKG} on purpose, delete its hooks from .claude/settings.json.)\"\n fi\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, ABSOLUTE so the MAIN tree governs every tree):\n# sh \"$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh\" <bin-name>\nBIN_NAME=\"$1\"\nshift\n# Resolve the tree 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)\"\n# The BIN is resolved by walking UP from ROOT (as Node does), and BIN_ROOT records which tree supplied\n# it — the version-drift guard below compares THIS tree's pin against THAT tree's installed version.\n${RESOLVE_BIN_SH}\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)\"\n${PARSE_PAYLOAD_SH}\n# Best-effort AUDIT TRAIL of what L0 did with this call — every call, not just the broken ones. One\n# tab-separated line per invocation into this TREE's own\n# logs/L0-shim/<session>-<agent|coordinator>-<binName>.log (gitignored), so the\n# observed behaviour can be diffed against the matrix in guards/L0-tooling.md. NEVER breaks or blocks the\n# hook: every write is swallowed, and nothing ever goes to stdout (stdout is the PreToolUse decision\n# channel — a stray byte there would corrupt allow/deny).\n${WP_LOG_SH}\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// The root whose committed shim this BINARY governs — resolved from the RUNNING MODULE's own location,\n// never from process.cwd() and never from $CLAUDE_PROJECT_DIR. Same premise as installedShimRulesVersion()\n// below: the binary IS this package, so it can point at its OWN install.\n//\n// WHY IT MUST BE THE MODULE AND NOT THE CWD (the two-tree straddle, fixed 2026-08-03).\n// committedShimStale used to resolve its root by walking up from the invocation cwd, then compare that\n// tree's shim FILE against renderShim() — which is compiled into whichever binary is actually running.\n//\n// Which tree supplies the binary? settings.json runs $CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh,\n// and that shim derives ROOT (hence BIN) from its own $0 — so the SESSION ROOT's tree supplies BOTH the\n// shim and the binary, and that pair is self-consistent by construction. A session rooted in a linked\n// worktree runs the worktree's shim and the worktree's binary; that is fine and is NOT the bug.\n//\n// The straddle appears when an agent's SESSION ROOT and its CWD are different trees — CLAUDE_PROJECT_DIR\n// is fixed at session start, so an agent that `cd`s into another checkout keeps running the session-root\n// tree's binary while findShimRoot(cwd) walks up into the OTHER tree. Each tree carries its own\n// node_modules at its own @webpieces version (seen in the wild: 0.4.545, 0.4.560 and 0.4.526 side by\n// side, every tree internally consistent). The comparison then straddles the two and can NEVER converge:\n// curing in the cwd tree renders with THAT tree's renderShim(), which the running binary's renderShim()\n// still rejects, so the cure re-fires the deny forever (observed: an agent gave up after four cures).\n//\n// Anchoring on __dirname makes the straddle UNCONSTRUCTIBLE rather than merely discouraged. It does not\n// pick a tree and privileges none: whichever tree the running binary came from is the tree whose shim it\n// compares, so the two halves of the comparison provably come from the same install either way.\n//\n// OUTERMOST node_modules wins, not innermost: under pnpm's linked layout __dirname realpaths to\n// <root>/node_modules/.pnpm/@webpieces+ai-hook-rules@X/node_modules/@webpieces/ai-hook-rules/src/bin —\n// the outermost segment lands on <root>, an innermost/first-ancestor rule lands inside the store.\n// With no node_modules segment at all we are running from a SOURCE checkout (vitest via tsconfig paths),\n// so walk up to the nearest ancestor that owns a shim. null = no committed shim to guard.\n// webpieces-disable no-function-outside-class -- pure fs+path helper in the dependency-free shim module, beside findShimRoot/healShim.\nexport function governingShimRoot(moduleDir: string = __dirname): string | null {\n const segments = moduleDir.split(path.sep);\n const outermost = segments.indexOf('node_modules');\n if (outermost > 0) {\n const root = segments.slice(0, outermost).join(path.sep);\n return fs.existsSync(shimPath(root)) ? root : null;\n }\n // A moduleDir that STARTS with node_modules is relative, so the root before it would be '' — i.e.\n // cwd-relative, the exact input this function exists to refuse. Nothing to govern.\n if (outermost === 0) return null;\n let dir = moduleDir;\n for (;;) {\n if (fs.existsSync(shimPath(dir))) return dir;\n const parent = path.dirname(dir);\n if (parent === dir) return null;\n dir = parent;\n }\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//\n// The root defaults to governingShimRoot() — the decision's input is the MODULE's tree, never the cwd\n// (see governingShimRoot for the straddle this closes). The parameter exists ONLY so unit tests can\n// stage a temp root; nothing in production should pass one.\n// webpieces-disable no-function-outside-class -- pure fs+path helper in the shim module, beside healShim/renderShim.\nexport function committedShimStale(root: string | null = governingShimRoot()): boolean {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\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 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 { CONFIG_FILENAME, claudeEnv } from '@webpieces/rules-config';\n\nimport {\n L0_FAULT_BIN_BROKEN, L0_FAULT_BIN_MISSING, L0_FAULT_DRIFT, L0_FAULT_UNDECLARED,\n} from '../core/l0-fault-codes';\nimport { toError } from '../core/to-error';\nimport {\n L0_ALLOW_ERE_SH, 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 ADD_HOOK_PKG_CMD, HOOK_PKG,\n} from './l0-allowlist';\nimport { WP_LOG_SH } from './shim-audit-log';\nimport { BASH_CWD_ENV_KEY, BASH_CWD_ENV_VALUE } from './managed-env';\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// Same treatment for the audit-log fragment: one name to import the whole rendered shim by.\nexport * from './shim-audit-log';\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/L0-shim/<writer>.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// ---------------------------------------------------------------------------\n// LENGTH IS PART OF THE FIX (2026-08-03). This block used to run ~153 words and was repeated verbatim\n// in D, X and K — in X it was ~85% of the whole message, which buries the one sentence that matters.\n// It now states the rule and the three tolerated additions, and nothing else: the tolerated set is\n// exactly what CD_PREFIX_ERE + CAPTURE_TAIL_ERE accept (the single-quoted path branch of CD_PREFIX_ERE\n// is why \"single-quote a path containing spaces\" is named), so this text cannot promise more or less\n// than the allowlist grants.\nexport const NO_CHAINING_RULE =\n 'Run it EXACTLY as written - the allowlist matches the whole command, so appending anything ' +\n '(even && git status) makes it a different command and it is rejected; that is not the guard ' +\n 'blocking its own cure. Only these may be added: a leading cd <dir> && (single-quote a path ' +\n 'containing spaces), a trailing 2>&1, and | tail -N.';\n\n// Shell fragment: resolve the guard BIN by WALKING UP from ROOT, and remember WHERE it came from.\n//\n// THE BUG THIS CLOSES (it would have landed the day the hooks went relative). `ai-hook.sh` used to set\n// `BIN=\"$ROOT/node_modules/.bin/$BIN_NAME\"` — a LITERAL path with no upward walk, while Node's own\n// resolver walks up. That was correct only while the hooks were registered ABSOLUTE, because then ROOT\n// was always the primary clone and the bin was always there. The moment H2/H3 became relative, ROOT\n// became the tree the call is in — and a nested worktree at `<primary>/.claude/worktrees/<name>` has NO\n// node_modules of its own. Every subagent would have hard-blocked on fault X at its first tool call,\n// fleet-wide, on the day of the flip. Walking up finds the primary's install, exactly as a `require()`\n// from the same directory would; a SIBLING worktree finds nothing and correctly still faults X.\n//\n// BIN_ROOT is not a curiosity: walking up ALONE re-creates the version straddle documented above\n// committedShimStale(), where the shim of one tree is paired with the binary of another and the cure\n// can never converge. So the walk is paired with a check — DECLARED comes from `$ROOT/package.json`\n// (the tree being judged) and INSTALLED comes from `$BIN_ROOT/node_modules` (the binary actually\n// running). Equal → no fault, keep reusing the inherited bin, which is the common case and stays free.\n// Different → fault D, cured by an install in THIS tree, which materialises its own node_modules.\nconst RESOLVE_BIN_SH = `BIN_ROOT=\"\\$ROOT\"\nBIN=\"\\$ROOT/node_modules/.bin/\\$BIN_NAME\"\nWP_WALK=\"\\$ROOT\"\nwhile [ ! -x \"\\$WP_WALK/node_modules/.bin/\\$BIN_NAME\" ]; do\n WP_UP=\"\\$(dirname -- \"\\$WP_WALK\")\"\n [ \"\\$WP_UP\" != \"\\$WP_WALK\" ] || break\n WP_WALK=\"\\$WP_UP\"\ndone\nif [ -x \"\\$WP_WALK/node_modules/.bin/\\$BIN_NAME\" ]; then\n BIN_ROOT=\"\\$WP_WALK\"\n BIN=\"\\$WP_WALK/node_modules/.bin/\\$BIN_NAME\"\nfi`;\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.\n#\n# THE SAME PASS ANSWERS FAULT U (2026-08-05). Scraping root package.json is also the only way to learn\n# whether @webpieces/ai-hook-rules is DECLARED at all, and that is the difference between \"not installed\n# yet\" (X, cured by pnpm install) and \"nothing asks for it\" (U, where pnpm install is a guaranteed\n# no-op). WP_PIN carries the first EXACT @webpieces pin found, so U's deny can prescribe the version the\n# rest of the repo is already on rather than an unpinned add. Both are set BEFORE the range/catalog\n# \\`continue\\`s, so a repo pinning the package by range still counts as having declared it.\nDRIFT_PKG=\"\"\nDRIFT_DECLARED=\"\"\nDRIFT_INSTALLED=\"\"\nWP_HOOK_PKG_DECLARED=\"\"\nWP_PIN=\"\"\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 # Fault U's input: the package is DECLARED (in any spec shape, in any dependency block of the root\n # manifest). Recorded before every \\`continue\\` below, so a range or catalog spec still counts.\n [ \"$WP_NAME\" = \"ai-hook-rules\" ] && WP_HOOK_PKG_DECLARED=1\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 # The release the rest of this repo is on — what fault U's cure should pin to.\n [ -n \"$WP_PIN\" ] || WP_PIN=\"$WP_DECL\"\n WP_MANIFEST=\"$BIN_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# THE CURE FOR A BORROWED node_modules RUNS IN THIS TREE, NOT WHEREVER THE BIN CAME FROM. A bare\n# 'pnpm install' typed while the shell sits in the primary clone installs into the primary, changes\n# nothing in the worktree being judged, and re-fires the identical fault — the four-cure straddle\n# recorded above committedShimStale(). When the bin was inherited, prescribe the cd and say why.\nWP_INSTALL_CMD=\"pnpm install\"\nWP_BORROW_NOTE=\"\"\nif [ \"$BIN_ROOT\" != \"$ROOT\" ]; then\n WP_INSTALL_CMD=\"cd $ROOT && pnpm install\"\n WP_BORROW_NOTE=\" NOTE: this tree ($ROOT) has NO node_modules of its own, so the guard binary was inherited from $BIN_ROOT by walking up. TWO cures are real and they fix DIFFERENT things: A makes THIS tree work now, B stops the two trees disagreeing. A - run the command above HERE. That is legitimate and it does work; a worktree NEEDS its own node_modules anyway (nx, vitest and the eslint plugin all execute in this tree and load from it). B - get $BIN_ROOT onto the same @webpieces version: put both trees on the same git hash (the pin is tracked) and run ONE 'pnpm install' there. If you are a SUBAGENT you cannot reach that tree, so ESCALATE - ask the coordinator to run 'pnpm install' in the main tree so both trees are on the same @webpieces version. The rule is NOT no-install-here: it is that this tree's @webpieces must EQUAL $BIN_ROOT's, because doing only A leaves two trees on two releases (the trinary-version-skew guard then BLOCKS rather than letting it pass unnoticed). Adding an ordinary third-party dependency here changes none of that - only a differing @webpieces version does. If this tree genuinely needs a DIFFERENT version, use a separate clone rather than a worktree.\"\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 # THE HEALTHY CALL, which this log used to be silent about (see WP_LOG_SH's header). No sh-side\n # fault, the bin ran, and its own exit code says how it ended — 0 allow, 2 block. Logged AFTER the\n # decision bytes are already on stdout, so the audit trail never sits between the guard and Claude\n # Code. Without this line, \"no entry\" meant either healthy or never-ran, and those are the two\n # answers a reader most needs to tell apart.\n WP_VERDICT=PASS-BIN-ALLOW\n [ \"\\$RC\" = 2 ] && WP_VERDICT=PASS-BIN-BLOCK\n wp_log - \"\\$WP_VERDICT\"\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: pull the four fields the shim itself reasons about out of the tool payload.\n//\n// MOVED AHEAD OF THE BIN (it used to sit inside TRIAGE_SH, i.e. only on the fail-closed path) because\n// the audit log now covers the HEALTHY call too, and a log line needs the tool and the command whether\n// or not anything went wrong.\n//\n// `cwd` is Claude Code's documented \"current working directory when the hook is invoked\". It is used\n// for ONE thing: deciding which tree's log directory this line belongs in (see RESOLVE_LOG_DIR_SH).\n// It deliberately does NOT change what the drift guard MEASURES — that stays anchored to $ROOT, the\n// tree the shim FILE lives in. Where a call is logged and what a call is judged against are separate\n// questions and are kept separate here.\n// TWO command variables, and the split is a SECURITY boundary — do not collapse them.\n//\n// $CMD is the DECISION input (the L0 allowlist greps it). Its pattern requires the CLOSING quote, so a\n// JSON payload that escapes an embedded quote as \\\\\" yields the EMPTY STRING for the whole command.\n// That looks like a bug and is in fact the safe direction: an empty command matches no allowlist entry,\n// so a quoted command falls through to the deny. FAIL CLOSED. Keep it that way.\n//\n// $CMD_LOG is the AUDIT input and must never reach a decision. It drops the closing quote from the\n// pattern so it captures the command PREFIX instead of nothing.\n//\n// WHY THEY CANNOT BE ONE VARIABLE: every L0 allowlist ERE is anchored `^…[[:space:]]*$`, and trailing\n// whitespace is tolerated — so `pnpm install \"; rm -rf /\"` would prefix-capture to `pnpm install `,\n// which MATCHES, and the injection after the quote would ride through allowlisted. Measured 2026-08-06:\n// 3,908 of 4,917 shim audit lines (79.5%) recorded an empty command, i.e. four out of five audit\n// entries were blind. Fixing the LOG is worth doing; fixing the DECISION the same way is a hole.\nconst PARSE_PAYLOAD_SH = `CMD=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"command\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nCMD_LOG=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"command\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\).*/\\\\1/p')\"\n[ -n \"\\$CMD_LOG\" ] || CMD_LOG=\"\\$CMD\"\nTOOL=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"tool_name\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nWP_SID=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"session_id\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nWP_AID=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"agent_id\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nFILE=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"file_path\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nWP_CWD=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"cwd\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\n[ -n \"\\$WP_CWD\" ] || WP_CWD=\"\\$ROOT\" # no cwd in the payload (older client, or a hand-run) → the shim's own tree`;\n\n// Shell fragment: the guards are DOWN (missing | stale | crashed). Classify the fault, then let THE L0\n// 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 = `# WHICH of the guards/L0-tooling.md faults fired, in the doc's own letters. Only the four sh-side\n# codes can be decided here; S/C/Y live in the binary, which never got to run on this path.\nWP_FAULT=${L0_FAULT_BIN_MISSING} # X — bin missing (fresh clone, new worktree)\n[ -z \"\\$WP_HOOK_PKG_DECLARED\" ] && WP_FAULT=${L0_FAULT_UNDECLARED} # U — X, but nothing declares the package: install is a no-op\n[ -n \"\\$DRIFT_PKG\" ] && WP_FAULT=${L0_FAULT_DRIFT} # D — version drift; D and K are mutually exclusive\n[ -n \"\\$BROKEN_BIN\" ] && WP_FAULT=${L0_FAULT_BIN_BROKEN} # K — bin present but CRASHED (corrupt node_modules)\nDENY_LABEL=\"DENY\"\n[ -z \"\\$WP_HOOK_PKG_DECLARED\" ] && DENY_LABEL=\"DENY-UNDECLARED\" # nothing in package.json asks for the package\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 \"\\$WP_FAULT\" 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 \"\\$WP_FAULT\" 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_SH}'; then\n wp_log \"\\$WP_FAULT\" 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 \"\\$WP_FAULT\" \"\\$DENY_LABEL\" # every fail-closed block, with the fault that caused it`;\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 \"\\$BIN_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.\" # only when N > 0\n fi\n REASON=\"❌ webpieces guards are DOWN and every other 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. Run EXACTLY: '${RECOVERY_CMD}'. A bare 'pnpm install' will NOT fix this: pnpm sees the correct version on disk and skips the broken package.\\${STAGING_NOTE} ${NO_CHAINING_RULE}\"\nelif [ -n \"\\$DRIFT_PKG\" ]; then\n # DECIDE THE DIRECTION, do not make the reader do it (2026-08-03). The detection is a plain !=, so it\n # fires BOTH ways, and the message used to carry OPTION 1/2/3 covering every direction at once — 3343\n # chars of which only about a third was the decision. A reader on the wrong branch of that menu was\n # one misread away from a downgrade. So compare the two versions HERE and emit only the relevant half.\n #\n # WITH AWK, not \\`sort -V\\`: -V is a GNU extension (absent/different on BSD sort), while the shim\n # already runs an awk pass to resolve catalog: specs, so awk adds no dependency. The program compares\n # the numeric cores component-by-component; a pre-release/build suffix (-rc.1, +sha) is stripped from\n # the core, and when the cores are EQUAL the side carrying a PRE-RELEASE suffix is the older one\n # (semver precedence). Build metadata (+sha) carries NO precedence, so two versions differing only\n # there come back undecidable rather than ordered. Anything it cannot parse prints NOTHING, and an\n # empty answer falls through to the ambiguous wording below rather than guessing a direction.\n #\n # WHAT WAS DELETED, so it does not creep back: the \"how to get main itself current\" paragraph and the\n # \"do NOT reach for git merge --ff-only / reset --hard / checkout -B main\" paragraph both belong to\n # redirect-how-to-merge-main, which fires on its own with its own message; and the sentence that named\n # wp-start-update / wp-start-upsert-pr ONLY to forbid them while the block is up — naming a command\n # purely to forbid it is pure cost, and the install that clears this fault comes first regardless.\n DRIFT_DIR=\"\\$(awk -v i=\"\\$DRIFT_INSTALLED\" -v d=\"\\$DRIFT_DECLARED\" 'BEGIN {\n iv = i; sub(/\\\\+.*/, \"\", iv); ic = iv; sub(/-.*/, \"\", ic); ip = substr(iv, length(ic) + 1)\n dv = d; sub(/\\\\+.*/, \"\", dv); dc = dv; sub(/-.*/, \"\", dc); dp = substr(dv, length(dc) + 1)\n if (ic !~ /^[0-9]+(\\\\.[0-9]+)*\\$/ || dc !~ /^[0-9]+(\\\\.[0-9]+)*\\$/) exit\n n = split(ic, ia, \".\"); m = split(dc, da, \".\"); k = (n > m) ? n : m\n for (x = 1; x <= k; x++) {\n av = (x <= n) ? ia[x] + 0 : 0; bv = (x <= m) ? da[x] + 0 : 0\n if (av < bv) { print \"older\"; exit }\n if (av > bv) { print \"newer\"; exit }\n }\n if (ip == dp) exit\n if (ip != \"\" && dp == \"\") print \"older\"\n if (ip == \"\" && dp != \"\") print \"newer\"\n }' 2>/dev/null)\"\n if [ \"\\$DRIFT_DIR\" = older ]; then\n REASON=\"❌ webpieces version drift: package.json pins \\$DRIFT_PKG@\\$DRIFT_DECLARED but node_modules has \\$DRIFT_INSTALLED - node_modules is OLDER, so the pin is what you want. Every other call is blocked until they agree. Run EXACTLY: '\\$WP_INSTALL_CMD'.\\${WP_BORROW_NOTE} ${NO_CHAINING_RULE}\"\n else\n # NEWER, or undecidable — the same three choices apply either way, so the only thing the ambiguous\n # case changes is the claim about which side is stale.\n DRIFT_NOTE=\"node_modules is NEWER, so the PIN is the stale side and a bare 'pnpm install' DOWNGRADES you to \\$DRIFT_DECLARED\"\n [ \"\\$DRIFT_DIR\" = newer ] || DRIFT_NOTE=\"these two versions could not be ordered automatically - compare them yourself: if node_modules is the NEWER side then the PIN is the stale side and a bare 'pnpm install' DOWNGRADES you to \\$DRIFT_DECLARED\"\n REASON=\"❌ webpieces version drift: package.json pins \\$DRIFT_PKG@\\$DRIFT_DECLARED but node_modules has \\$DRIFT_INSTALLED - \\$DRIFT_NOTE. That may be exactly what you want. Every other call is blocked until they agree. Pick one: - move forward to what origin pins: run 'git pull origin main', then 'pnpm install'. - stay on this code deliberately: run 'pnpm install' (the downgrade is the point). - on a feature branch: run 'pnpm install' (aligns to YOUR branch pin - usually right).\\${WP_BORROW_NOTE} ${NO_CHAINING_RULE}\"\n fi\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 it HERE, in this worktree, not in the primary clone.\"\n fi\n if [ -z \"\\$WP_HOOK_PKG_DECLARED\" ]; then\n # FAULT U — the one shape where the X message is not merely unhelpful but actively WRONG. It asserted\n # \"declared in package.json\" without ever checking, and prescribed the one command that provably\n # cannot help: with nothing asking for the package, \\`pnpm install\\` reports \"Lockfile is up to date\"\n # and converges to the identical broken tree, forever. So say what is actually true, say out loud\n # that the install is a no-op (an agent that has already run it needs to be told to STOP), and\n # prescribe the add — which is allowlist entry ADD_HOOK_PKG, so it is reachable while this block is up.\n WP_ADD_CMD=\"${ADD_HOOK_PKG_CMD}\"\n [ -n \"\\$WP_PIN\" ] && WP_ADD_CMD=\"\\${WP_ADD_CMD}@\\$WP_PIN\"\n REASON=\"❌ ${HOOK_PKG} is NOT declared in package.json anywhere, and is not installed (\\${BIN_NAME} not found) - yet .claude/settings.json still runs its hooks, so every tool call is blocked. Do NOT run 'pnpm install': nothing asks for this package, so it is a NO-OP and repeating it converges to this same state. It normally arrives with @webpieces/nx-webpieces-rules, the umbrella that bundles the whole toolchain - so the durable fix is to upgrade that. To unblock yourself right now, declare it directly. Run EXACTLY: '\\$WP_ADD_CMD'. ${NO_CHAINING_RULE} (If you removed ${HOOK_PKG} on purpose, delete its hooks from .claude/settings.json instead.)\"\n else\n REASON=\"❌ ${HOOK_PKG} is declared in package.json but is not installed (\\${BIN_NAME} not found). Run EXACTLY: 'pnpm install'.\\${WORKTREE_NOTE} ${NO_CHAINING_RULE} (If you removed ${HOOK_PKG} on purpose, delete its hooks from .claude/settings.json.)\"\n fi\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, ABSOLUTE so the MAIN tree governs every tree):\n# sh \"$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh\" <bin-name>\nBIN_NAME=\"$1\"\nshift\n# Resolve the tree 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)\"\n# The BIN is resolved by walking UP from ROOT (as Node does), and BIN_ROOT records which tree supplied\n# it — the version-drift guard below compares THIS tree's pin against THAT tree's installed version.\n${RESOLVE_BIN_SH}\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)\"\n${PARSE_PAYLOAD_SH}\n# Best-effort AUDIT TRAIL of what L0 did with this call — every call, not just the broken ones. One\n# tab-separated line per invocation into this TREE's own\n# logs/L0-shim/<session>-<agent|coordinator>-<binName>.log (gitignored), so the\n# observed behaviour can be diffed against the matrix in guards/L0-tooling.md. NEVER breaks or blocks the\n# hook: every write is swallowed, and nothing ever goes to stdout (stdout is the PreToolUse decision\n# channel — a stray byte there would corrupt allow/deny).\n${WP_LOG_SH}\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// The root whose committed shim this BINARY governs — resolved from the RUNNING MODULE's own location,\n// never from process.cwd() and never from $CLAUDE_PROJECT_DIR. Same premise as installedShimRulesVersion()\n// below: the binary IS this package, so it can point at its OWN install.\n//\n// WHY IT MUST BE THE MODULE AND NOT THE CWD (the two-tree straddle, fixed 2026-08-03).\n// committedShimStale used to resolve its root by walking up from the invocation cwd, then compare that\n// tree's shim FILE against renderShim() — which is compiled into whichever binary is actually running.\n//\n// Which tree supplies the binary? settings.json runs $CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh,\n// and that shim derives ROOT (hence BIN) from its own $0 — so the SESSION ROOT's tree supplies BOTH the\n// shim and the binary, and that pair is self-consistent by construction. A session rooted in a linked\n// worktree runs the worktree's shim and the worktree's binary; that is fine and is NOT the bug.\n//\n// The straddle appears when an agent's SESSION ROOT and its CWD are different trees — CLAUDE_PROJECT_DIR\n// is fixed at session start, so an agent that `cd`s into another checkout keeps running the session-root\n// tree's binary while findShimRoot(cwd) walks up into the OTHER tree. Each tree carries its own\n// node_modules at its own @webpieces version (seen in the wild: 0.4.545, 0.4.560 and 0.4.526 side by\n// side, every tree internally consistent). The comparison then straddles the two and can NEVER converge:\n// curing in the cwd tree renders with THAT tree's renderShim(), which the running binary's renderShim()\n// still rejects, so the cure re-fires the deny forever (observed: an agent gave up after four cures).\n//\n// Anchoring on __dirname makes the straddle UNCONSTRUCTIBLE rather than merely discouraged. It does not\n// pick a tree and privileges none: whichever tree the running binary came from is the tree whose shim it\n// compares, so the two halves of the comparison provably come from the same install either way.\n//\n// OUTERMOST node_modules wins, not innermost: under pnpm's linked layout __dirname realpaths to\n// <root>/node_modules/.pnpm/@webpieces+ai-hook-rules@X/node_modules/@webpieces/ai-hook-rules/src/bin —\n// the outermost segment lands on <root>, an innermost/first-ancestor rule lands inside the store.\n// With no node_modules segment at all we are running from a SOURCE checkout (vitest via tsconfig paths),\n// so walk up to the nearest ancestor that owns a shim. null = no committed shim to guard.\n// webpieces-disable no-function-outside-class -- pure fs+path helper in the dependency-free shim module, beside findShimRoot/healShim.\nexport function governingShimRoot(moduleDir: string = __dirname): string | null {\n const segments = moduleDir.split(path.sep);\n const outermost = segments.indexOf('node_modules');\n if (outermost > 0) {\n const root = segments.slice(0, outermost).join(path.sep);\n return fs.existsSync(shimPath(root)) ? root : null;\n }\n // A moduleDir that STARTS with node_modules is relative, so the root before it would be '' — i.e.\n // cwd-relative, the exact input this function exists to refuse. Nothing to govern.\n if (outermost === 0) return null;\n let dir = moduleDir;\n for (;;) {\n if (fs.existsSync(shimPath(dir))) return dir;\n const parent = path.dirname(dir);\n if (parent === dir) return null;\n dir = parent;\n }\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//\n// The root defaults to governingShimRoot() — the decision's input is the MODULE's tree, never the cwd\n// (see governingShimRoot for the straddle this closes). The parameter exists ONLY so unit tests can\n// stage a temp root; nothing in production should pass one.\n// webpieces-disable no-function-outside-class -- pure fs+path helper in the shim module, beside healShim/renderShim.\nexport function committedShimStale(root: string | null = governingShimRoot()): boolean {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\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 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"]}
|
|
@@ -45,8 +45,27 @@ import { CommandScanner } from './command-scan';
|
|
|
45
45
|
* 3. WHICH CHECKOUT? `--show-toplevel` from `effectiveCwd`. A LINKED WORKTREE of the governed repo is
|
|
46
46
|
* MANAGED — it is the same project, just another checkout — so guards run, keyed on THAT tree's
|
|
47
47
|
* branch and its own state.
|
|
48
|
-
* 4. PRIMARY OR LINKED? `gitDir !== commonDir`, git's own canonical test
|
|
49
|
-
*
|
|
48
|
+
* 4. PRIMARY OR LINKED? `gitDir !== commonDir`, git's own canonical test — AND NOTHING ELSE. Where
|
|
49
|
+
* `webpieces.config.json` happened to be found does not enter into it (see below).
|
|
50
|
+
* 5. WHICH TREE GOVERNS THE INSTALL? `<git-common-dir>/..` — the PRIMARY clone, carried as `mainRoot`.
|
|
51
|
+
* It is the tree whose `node_modules` supplies the binary judging the call, from any checkout.
|
|
52
|
+
*
|
|
53
|
+
* GOVERNANCE IS NOT IDENTITY EITHER — the second half of the same lesson, and the second bug. classify()
|
|
54
|
+
* used to answer `primary` for ANY tree that also owned the `webpieces.config.json` it was judged
|
|
55
|
+
* against (`sameDir(treeRoot, governedRoot)`). `governedRoot` is walked UP from the payload cwd, and a
|
|
56
|
+
* linked worktree has its own TRACKED config, so for an agent whose cwd IS the worktree — the common
|
|
57
|
+
* case, and the only one the harness creates for a worktree-isolated subagent — its own tree read as
|
|
58
|
+
* `primary`. Row 8 (VersionSyncGuard) matches on `w`, so it could not fire for exactly the agents it
|
|
59
|
+
* exists to protect: measured 2026-08-10, a worktree bumped its pin to 0.4.624 and ran its own
|
|
60
|
+
* `pnpm install` while the main clone stayed on 0.4.616, and nothing said a word. In the SAME tool call
|
|
61
|
+
* the `.webpieces/` log resolver — which asks git — correctly stamped `tree=agent-abfdc0aaf1f981f3f`.
|
|
62
|
+
* Two resolvers in one process disagreeing at the same instant is the shape this module exists to make
|
|
63
|
+
* impossible, so K is now git's answer and ONLY git's answer.
|
|
64
|
+
*
|
|
65
|
+
* That leaves `governedRoot` meaning what its name says (whose config and excludePaths apply) and adds
|
|
66
|
+
* `mainRoot` for the question VersionSyncGuard actually asks (whose `node_modules` is judging this
|
|
67
|
+
* call). Those were never the same value, and conflating them made the guard compare a worktree
|
|
68
|
+
* against ITSELF, which is trivially in sync.
|
|
50
69
|
*
|
|
51
70
|
* PLACEMENT IS NOT IDENTITY, and assuming it was is the bug this shape exists to prevent. classify()
|
|
52
71
|
* used to short-circuit on "is `effectiveCwd` inside the governed root?" and never ask git anything
|
|
@@ -76,10 +95,20 @@ export declare class EffectiveTree {
|
|
|
76
95
|
readonly root: string;
|
|
77
96
|
/** The root that owns webpieces.config.json — where config and excludePaths come from. */
|
|
78
97
|
readonly governedRoot: string;
|
|
98
|
+
/**
|
|
99
|
+
* The PRIMARY clone's root — git's `<git-common-dir>/..`, the same answer from every checkout of the
|
|
100
|
+
* repo, and equal to `root` in the primary clone itself.
|
|
101
|
+
*
|
|
102
|
+
* NOT `governedRoot`. A linked worktree owns its own TRACKED `webpieces.config.json`, so for an agent
|
|
103
|
+
* resident in one the governed root is the WORKTREE — while the `node_modules` (and therefore the
|
|
104
|
+
* binary judging the call, the nx executors and the eslint plugin) still resolves by walking up to
|
|
105
|
+
* the primary clone. This field is that walk-up, asked of git instead of inferred.
|
|
106
|
+
*/
|
|
107
|
+
readonly mainRoot: string;
|
|
79
108
|
readonly kind: TreeKind;
|
|
80
109
|
/** The command acts on a tree other than the shell's own — messages must steer with `cd <root> &&`. */
|
|
81
110
|
readonly redirected: boolean;
|
|
82
|
-
constructor(shellCwd: string, effectiveCwd: string, root: string, governedRoot: string, kind: TreeKind);
|
|
111
|
+
constructor(shellCwd: string, effectiveCwd: string, root: string, governedRoot: string, mainRoot: string, kind: TreeKind);
|
|
83
112
|
}
|
|
84
113
|
export declare class EffectiveTreeResolver {
|
|
85
114
|
private readonly scanner;
|
|
@@ -17,14 +17,26 @@ class EffectiveTree {
|
|
|
17
17
|
root;
|
|
18
18
|
/** The root that owns webpieces.config.json — where config and excludePaths come from. */
|
|
19
19
|
governedRoot;
|
|
20
|
+
/**
|
|
21
|
+
* The PRIMARY clone's root — git's `<git-common-dir>/..`, the same answer from every checkout of the
|
|
22
|
+
* repo, and equal to `root` in the primary clone itself.
|
|
23
|
+
*
|
|
24
|
+
* NOT `governedRoot`. A linked worktree owns its own TRACKED `webpieces.config.json`, so for an agent
|
|
25
|
+
* resident in one the governed root is the WORKTREE — while the `node_modules` (and therefore the
|
|
26
|
+
* binary judging the call, the nx executors and the eslint plugin) still resolves by walking up to
|
|
27
|
+
* the primary clone. This field is that walk-up, asked of git instead of inferred.
|
|
28
|
+
*/
|
|
29
|
+
mainRoot;
|
|
20
30
|
kind;
|
|
21
31
|
/** The command acts on a tree other than the shell's own — messages must steer with `cd <root> &&`. */
|
|
22
32
|
redirected;
|
|
23
|
-
|
|
33
|
+
// eslint-disable-next-line @typescript-eslint/max-params -- five resolved paths plus the kind decided from them
|
|
34
|
+
constructor(shellCwd, effectiveCwd, root, governedRoot, mainRoot, kind) {
|
|
24
35
|
this.shellCwd = shellCwd;
|
|
25
36
|
this.effectiveCwd = effectiveCwd;
|
|
26
37
|
this.root = root;
|
|
27
38
|
this.governedRoot = governedRoot;
|
|
39
|
+
this.mainRoot = mainRoot;
|
|
28
40
|
this.kind = kind;
|
|
29
41
|
this.redirected = path.resolve(root) !== path.resolve(shellCwd);
|
|
30
42
|
}
|
|
@@ -40,7 +52,7 @@ class EffectiveTreeResolver {
|
|
|
40
52
|
resolve(command, shellCwd, governedRoot) {
|
|
41
53
|
const effectiveCwd = this.effectiveCwd(command, shellCwd);
|
|
42
54
|
const kindAndRoot = this.classify(effectiveCwd, governedRoot);
|
|
43
|
-
return new EffectiveTree(shellCwd, effectiveCwd, kindAndRoot.root, governedRoot, kindAndRoot.kind);
|
|
55
|
+
return new EffectiveTree(shellCwd, effectiveCwd, kindAndRoot.root, governedRoot, kindAndRoot.mainRoot, kindAndRoot.kind);
|
|
44
56
|
}
|
|
45
57
|
/**
|
|
46
58
|
* The cwd a command actually runs from, resolving a LEADING run of `cd`/`pushd` in the command.
|
|
@@ -158,29 +170,33 @@ class EffectiveTreeResolver {
|
|
|
158
170
|
// ahead of the git calls, keeps them apart. The root is the GOVERNED root because that is the
|
|
159
171
|
// only tree left to steer anyone to.
|
|
160
172
|
if (!fs.existsSync(effectiveCwd))
|
|
161
|
-
return new TreeClassification('missing', governedRoot);
|
|
173
|
+
return new TreeClassification('missing', governedRoot, governedRoot);
|
|
162
174
|
const dirs = rules_config_1.dotWebpieces.gitDirs(effectiveCwd);
|
|
163
175
|
// Not a git repo at all. Inside the governed tree that can only be a directory git declined to
|
|
164
176
|
// answer for, so it stays `primary` exactly as before; outside it is the `cd /tmp && …` case.
|
|
165
177
|
if (dirs === null) {
|
|
166
178
|
return this.isInside(effectiveCwd, governedRoot)
|
|
167
|
-
? new TreeClassification('primary', governedRoot)
|
|
168
|
-
: new TreeClassification('outside', governedRoot);
|
|
179
|
+
? new TreeClassification('primary', governedRoot, governedRoot)
|
|
180
|
+
: new TreeClassification('outside', governedRoot, governedRoot);
|
|
169
181
|
}
|
|
170
182
|
const treeRoot = rules_config_1.dotWebpieces.treeRoot(effectiveCwd) ?? governedRoot;
|
|
183
|
+
// `<git-common-dir>/..`, off the SAME memoized rev-parse pair `gitDirs` just answered with — so
|
|
184
|
+
// this costs no extra process, which matters on the hook's blocking path.
|
|
185
|
+
const mainRoot = rules_config_1.dotWebpieces.primaryRoot(effectiveCwd);
|
|
171
186
|
const ours = rules_config_1.dotWebpieces.gitDirs(governedRoot);
|
|
172
187
|
// ONE test for "is this our repo": the shared git dir. It is identical for every checkout of one
|
|
173
188
|
// repo and different for a nested clone, wherever either happens to sit on disk.
|
|
174
189
|
if (ours === null || !sameDir(dirs.commonDir, ours.commonDir)) {
|
|
175
|
-
return new TreeClassification('foreign', treeRoot);
|
|
176
|
-
}
|
|
177
|
-
// Home is `primary` whether the session was started in the clone or in a worktree — the split
|
|
178
|
-
// VersionSyncGuard exists to catch is acting on a tree OTHER than the one that governs
|
|
179
|
-
// you, and there is no split when they are the same directory.
|
|
180
|
-
if (!dirs.isLinkedWorktree || sameDir(treeRoot, governedRoot)) {
|
|
181
|
-
return new TreeClassification('primary', treeRoot);
|
|
190
|
+
return new TreeClassification('foreign', treeRoot, mainRoot);
|
|
182
191
|
}
|
|
183
|
-
|
|
192
|
+
// GIT DECIDES, and nothing else. `isLinkedWorktree` is `--git-dir !== --git-common-dir`, an
|
|
193
|
+
// answer about the CHECKOUT — so a worktree is `worktree` whether the command was typed from the
|
|
194
|
+
// primary clone (`cd <wt> && …`) or by an agent living in it. The old extra clause
|
|
195
|
+
// (`|| sameDir(treeRoot, governedRoot)`) made a resident agent's OWN tree read `primary`,
|
|
196
|
+
// silently retiring row 8 for every worktree-isolated subagent — see this module's header.
|
|
197
|
+
if (!dirs.isLinkedWorktree)
|
|
198
|
+
return new TreeClassification('primary', treeRoot, mainRoot);
|
|
199
|
+
return new TreeClassification('worktree', treeRoot, mainRoot);
|
|
184
200
|
}
|
|
185
201
|
/** Is `dir` the directory `root` itself, or somewhere beneath it? Pure path math, no filesystem. */
|
|
186
202
|
isInside(dir, root) {
|
|
@@ -209,13 +225,16 @@ const VARIABLE_TARGET = /[$`]|^~/;
|
|
|
209
225
|
// The separator joining a leading `cd` to what follows it, stripped when withoutLeadingCds() drops the
|
|
210
226
|
// `cd`. `&&`, `||`, `;` and a bare newline are the shapes CommandScanner splits a leading run on.
|
|
211
227
|
const LEADING_SEPARATOR = /^\s*(?:&&|\|\||;|\n)\s*/;
|
|
212
|
-
/** Data-only carrier for the
|
|
228
|
+
/** Data-only carrier for the three values classify() decides together. */
|
|
213
229
|
class TreeClassification {
|
|
214
230
|
kind;
|
|
215
231
|
root;
|
|
216
|
-
|
|
232
|
+
/** The primary clone behind `root` — see EffectiveTree.mainRoot for why it is not `governedRoot`. */
|
|
233
|
+
mainRoot;
|
|
234
|
+
constructor(kind, root, mainRoot) {
|
|
217
235
|
this.kind = kind;
|
|
218
236
|
this.root = root;
|
|
237
|
+
this.mainRoot = mainRoot;
|
|
219
238
|
}
|
|
220
239
|
}
|
|
221
240
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"effective-tree.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/effective-tree.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport { atRoot, dotWebpieces } from '@webpieces/rules-config';\n\nimport { CommandScanner } from './command-scan';\nimport { ShellSegmentScan } from './rules/shell-segment-scan';\n\n/**\n * WHICH TREE does a Bash command actually act on? The ONE resolver every bash guard and the\n * force-to-root check share.\n *\n * WHY it has to exist at all: the shell cwd a PreToolUse hook is handed does not tell you which tree\n * the command acts on. `cd` behaves TWO different ways, and both break a cwd-based guard:\n *\n * - IN THE MAIN SESSION ONLY, a `cd` that stays INSIDE the session's working directory PERSISTS to\n * later calls. Claude Code documents this as a main-session property and states that \"subagent\n * sessions never carry over working directory changes\" — measured true here, and the reason this\n * rule must not be stated unconditionally to a reader who may be a subagent. So the cwd\n * can be a subdirectory of the governed root, left there by an unrelated command several turns\n * earlier — a relative path then resolves somewhere other than the root while still being in the\n * governed tree.\n * - A `cd` that LEAVES it is reset by the harness, which says so (`Shell cwd was reset to <root>`).\n * So an agent working in a linked worktree is back in the primary clone by the next call and must\n * write self-contained `cd <worktree> && …` commands — and the cwd the hook sees is the primary\n * clone, not the worktree the command targets.\n *\n * (Measured on 2026-08-02: `cd backlog && pwd` → `…/backlog`, then a bare `pwd` in a FRESH call →\n * still `…/backlog`. But `cd ../<linked-worktree> && pwd` → the worktree, then a bare `pwd` → back at\n * the primary clone. An earlier version of this comment asserted `cd` never persists; that was the\n * worktree case generalized. The conclusion below is unchanged — only the reason was wrong.)\n *\n * Either way a guard that reasons from the raw cwd judges the wrong tree. Three field sightings in\n * one session:\n * an `ls` of a path outside every repo blocked as \"this branch is merged\"; a version-drift cure\n * (`pnpm install`) that could not be typed from the directory that needed it; and a command aimed at\n * `/private/tmp` blocked because the PRIMARY clone's main was behind — with a remedy (`git pull` in\n * the primary clone) the agent had been explicitly forbidden to run.\n *\n * Two separate copies of the cwd logic used to exist (the runner's foreign-repo/excludePaths check\n * and force-to-root). They are both this class now: two resolvers WILL disagree about which tree you\n * are in, and a guard that disagrees with the guard beside it is worse than either being wrong.\n *\n * Resolution, in order:\n * 1. `effectiveCwd` — the leading run of `cd`/`pushd` in the command itself, resolved left to right.\n * 2. SAME REPO? `git rev-parse --git-common-dir` is identical for every checkout of one repo, so\n * comparing it against the governed root's answer is the whole test. Different → FOREIGN (a\n * nested clone under `repositories/**`), out of scope, hands-off. Not a git repo at all\n * (`cd /tmp && …`) → OUTSIDE: the guards still run — an absolute path back into the repo must\n * still be judged — but nothing the command names relative to `/tmp` is workspace content, which\n * is what ContentReadScan uses `effectiveCwd` for.\n * 3. WHICH CHECKOUT? `--show-toplevel` from `effectiveCwd`. A LINKED WORKTREE of the governed repo is\n * MANAGED — it is the same project, just another checkout — so guards run, keyed on THAT tree's\n * branch and its own state.\n * 4. PRIMARY OR LINKED? `gitDir !== commonDir`, git's own canonical test. The governed root itself is\n * always `primary`: it is home, whether the session was started in the clone or in a worktree.\n *\n * PLACEMENT IS NOT IDENTITY, and assuming it was is the bug this shape exists to prevent. classify()\n * used to short-circuit on \"is `effectiveCwd` inside the governed root?\" and never ask git anything\n * else — so an agent worktree, which Claude Code checks out INSIDE the repo at\n * `<repo>/.claude/worktrees/agent-XXXX`, took that path, disagreed with `--show-toplevel`, and read as\n * FOREIGN. `foreign` is ALLOW_EXEMPT in runner.ts: every bash guard silently off, and\n * the worktree guard (which requires `kind === 'worktree'`) dead code, for exactly the worktrees\n * the harness creates. A common-dir comparison answers the same for both placements, so there is no\n * inside/outside case left to get wrong.\n *\n * WHY THE GIT DIRS AND NOT ONE OF THE OTHER RESOLVERS — state-dir.ts's own header makes this argument\n * in full (\"Why `--git-dir` / `--git-common-dir`, and not one of the existing services\"); the short\n * version is that `WorktreeService` is a repo-wide ENUMERATION that fails SOFT to `[]` (i.e. fails open\n * into `foreign`, this bug) and `webpieces.config.json` walk-up is GOVERNANCE, not identity — it\n * deliberately climbs past a nested clone's `.git` to the outer config (repo-root.spec.ts pins that),\n * so identity built on it would hand the guards someone else's repo. This class asks DotWebpieces,\n * whose `rev-parse` pair is memoized per directory per process — it is on the hook's blocking path.\n */\n// L1's K dimension. 'primary' and 'worktree' are the same PROJECT, so every rule-scoped guard treats\n// them alike — guards/L1-location.md writes them as one value, `pw`. Exactly ONE guard separates them,\n// and on a dimension read OFF the tree itself: VersionSyncGuard blocks work in a linked worktree whose\n// @webpieces pin disagrees with the MAIN tree's, because the main tree's binary is what judges it.\n//\n// 'outside' is produced below (git has no answer for the directory) and consumed NOWHERE, so a command in no git repo is\n// judged against governedRoot — a repo it is not in. guards/L1-location.md's \"Not done\" section explains why\n// exempting it must ship together with target-based jurisdiction, never alone.\n//\n// 'missing' is the directory that is NOT THERE — the worktree reaped out from under a live shell. It is\n// separate from 'outside' because the two used to be one `null` from git, and conflating them produced\n// the worst message this layer has emitted: \"you are in a subdirectory\", with a remedy that `cd`s back\n// into the deleted path. See MissingDirectoryGuard.\nexport type TreeKind = 'primary' | 'worktree' | 'foreign' | 'outside' | 'missing';\n\n/** Data-only (per CLAUDE.md, classes for data). */\nexport class EffectiveTree {\n /** The pre-`cd` cwd the hook was handed. For an agent in a worktree this is the primary clone. */\n readonly shellCwd: string;\n /** The directory the command really runs in, after its own leading `cd`/`pushd` run. */\n readonly effectiveCwd: string;\n /** The tree root to JUDGE: the owning worktree root, the foreign repo root, or the governed root. */\n readonly root: string;\n /** The root that owns webpieces.config.json — where config and excludePaths come from. */\n readonly governedRoot: string;\n readonly kind: TreeKind;\n /** The command acts on a tree other than the shell's own — messages must steer with `cd <root> &&`. */\n readonly redirected: boolean;\n\n constructor(shellCwd: string, effectiveCwd: string, root: string, governedRoot: string, kind: TreeKind) {\n this.shellCwd = shellCwd;\n this.effectiveCwd = effectiveCwd;\n this.root = root;\n this.governedRoot = governedRoot;\n this.kind = kind;\n this.redirected = path.resolve(root) !== path.resolve(shellCwd);\n }\n}\n\nexport class EffectiveTreeResolver {\n private readonly shell: ShellSegmentScan;\n\n constructor(private readonly scanner: CommandScanner = new CommandScanner()) {\n this.shell = new ShellSegmentScan(scanner);\n }\n\n resolve(command: string, shellCwd: string, governedRoot: string): EffectiveTree {\n const effectiveCwd = this.effectiveCwd(command, shellCwd);\n const kindAndRoot = this.classify(effectiveCwd, governedRoot);\n return new EffectiveTree(shellCwd, effectiveCwd, kindAndRoot.root, governedRoot, kindAndRoot.kind);\n }\n\n /**\n * The cwd a command actually runs from, resolving a LEADING run of `cd`/`pushd` in the command.\n *\n * ONLY a leading run counts. Once a non-cd command appears it has ALREADY run in the current dir,\n * so a later `cd` must not retroactively pull it out of scope — otherwise a trailing\n * `… && cd <exempt-tree>` would exempt the WHOLE line, smuggling a root-level `git push` past the\n * guards. `cd a && cd b && git …` resolves left to right, matching the shell.\n *\n * Segmentation is ShellSegmentScan's (over CommandScanner), so quoting is handled exactly as the\n * guards handle it: `echo \"cd sub && git push\"` is ONE opaque segment whose first word is `echo`,\n * so the quoted `cd` is never picked up and cannot be weaponised into a scope escape.\n */\n effectiveCwd(command: string, shellCwd: string): string {\n let effective = shellCwd;\n for (const segment of this.scanner.commandSegments(command)) {\n const words = this.shell.effectiveWords(segment);\n if (words[0] !== 'cd' && words[0] !== 'pushd') break;\n if (words[1] !== undefined) effective = path.resolve(effective, words[1]);\n }\n return effective;\n }\n\n /**\n * Is this command's location UNAMBIGUOUS — or should it be rejected outright? Returns the reason a\n * `cd` in it cannot be resolved, or null when there is nothing wrong.\n *\n * `cd <literal path> && <work>` is the ONE shape that moves where a command is judged, because it\n * is the one shape effectiveCwd() can resolve. Everything else fell back to the shell cwd, which\n * is SAFE (fails closed, nothing smuggled) but silent, and silent is what cost the time:\n *\n * `cd \"$DIR\" && git push` — `path.resolve(cwd, '$DIR')` is a directory that does not\n * exist, not the tree that was meant.\n * `D=/x; cd \"$D\"; git push` — a bare `VAR=value` segment tokenizes to NO words, so the\n * leading run ends before the `cd` is reached.\n * `git fetch && cd /x && git push` — bash really does run the push in /x; the guard judged it\n * from the shell cwd and blocked it.\n * `git push && cd /x` — the push already ran at the root, whatever the trailing\n * `cd` says.\n *\n * Naming these in the block MESSAGE (the previous two PRs) helped, but left the agent to notice a\n * paragraph on an otherwise-normal block. Rejecting is the simpler contract and the one the human\n * asked for: ONE legal shape, everything else refused with the fix spelled out. No verdict changes\n * — every command this rejects was already being judged from the shell cwd — so this trades a\n * silent misdirect for a loud one-line rule, and deletes the near-miss taxonomy entirely.\n *\n * Expanding `$VAR` here is still the fix NOT taken. The resolver decides ONE location for a whole\n * line, so a `cd` that counts retroactively is exactly the `… && cd <exempt-tree>` scope escape.\n *\n * A command containing a HEREDOC (`<<`) is exempt: CommandScanner does not model heredoc bodies,\n * so a commit message or doc that merely CONTAINS `cd /x && …` tokenizes as if it were code, and\n * rejecting that would block ordinary writing. Skipping the rejection cannot open a hole — the\n * location still falls back to the shell cwd exactly as before.\n */\n misplacedCd(command: string): string | null {\n if (HEREDOC.test(command)) return null;\n\n const segments = this.scanner.commandSegments(command).map(\n (segment: string): readonly string[] => this.shell.effectiveWords(segment));\n\n // The leading run effectiveCwd() actually consumed — a `cd` at or after this index did not count.\n let consumed = 0;\n while (consumed < segments.length && isCd(segments[consumed])) consumed++;\n\n for (let i = 0; i < segments.length; i++) {\n if (!isCd(segments[i])) continue;\n if (i >= consumed) {\n return segments.slice(0, i).some(isRealCommand)\n ? 'it comes after another command — a `cd` only counts at the FRONT of the line'\n : 'a `VAR=…` assignment precedes it, which ends the scan';\n }\n const target = segments[i][1];\n if (target !== undefined && VARIABLE_TARGET.test(target)) {\n return 'its target is not a literal path (a `$VAR`, `~` or `$(…)` the guard cannot expand)';\n }\n }\n return null;\n }\n\n /**\n * The remedy for a command judged in the wrong directory — `cd '<root>' && <the work>`, with the\n * command's OWN leading `cd` run REPLACED rather than prefixed.\n *\n * A block's remedy must not leave the block's condition true. `atRoot()` alone prefixes, and\n * `effectiveCwd()` resolves the leading run of `cd`s LEFT TO RIGHT — so prefixing `cd '<root>' &&`\n * onto `cd <elsewhere> && git status` still lands in `<elsewhere>`, the identical block fires on\n * the remedy, and the retry prints it with the prefix doubled, then tripled. Structurally\n * non-convergent, and observed in the field against an agent worktree.\n *\n * Only the LEADING run is dropped, because only the leading run moved where the command was judged.\n * A mid-line `cd` is part of the work and is carried through untouched (it is separately rejected by\n * `misplacedCd`).\n */\n remedyAtRoot(root: string, command: string): string {\n return atRoot(root, this.withoutLeadingCds(command));\n }\n\n private withoutLeadingCds(command: string): string {\n let rest = command;\n for (const segment of this.scanner.commandSegments(command)) {\n const words = this.shell.effectiveWords(segment);\n if (words[0] !== 'cd' && words[0] !== 'pushd') break;\n const at = rest.indexOf(segment);\n if (at < 0) break;\n rest = rest.slice(at + segment.length).replace(LEADING_SEPARATOR, '');\n }\n // A line that is NOTHING but `cd`s has no work to steer; hand it back whole rather than emit an\n // empty remedy.\n return rest.trim() === '' ? command : rest.trim();\n }\n\n private classify(effectiveCwd: string, governedRoot: string): TreeClassification {\n // GONE, not merely un-gitted. git answers `null` for both \"not a repo\" and \"no such directory\",\n // and collapsing the two is what made a reaped worktree read as an ordinary subdirectory of the\n // governed root — with a remedy that `cd`s straight back into the deleted path. One statSync,\n // ahead of the git calls, keeps them apart. The root is the GOVERNED root because that is the\n // only tree left to steer anyone to.\n if (!fs.existsSync(effectiveCwd)) return new TreeClassification('missing', governedRoot);\n\n const dirs = dotWebpieces.gitDirs(effectiveCwd);\n // Not a git repo at all. Inside the governed tree that can only be a directory git declined to\n // answer for, so it stays `primary` exactly as before; outside it is the `cd /tmp && …` case.\n if (dirs === null) {\n return this.isInside(effectiveCwd, governedRoot)\n ? new TreeClassification('primary', governedRoot)\n : new TreeClassification('outside', governedRoot);\n }\n\n const treeRoot = dotWebpieces.treeRoot(effectiveCwd) ?? governedRoot;\n const ours = dotWebpieces.gitDirs(governedRoot);\n // ONE test for \"is this our repo\": the shared git dir. It is identical for every checkout of one\n // repo and different for a nested clone, wherever either happens to sit on disk.\n if (ours === null || !sameDir(dirs.commonDir, ours.commonDir)) {\n return new TreeClassification('foreign', treeRoot);\n }\n\n // Home is `primary` whether the session was started in the clone or in a worktree — the split\n // VersionSyncGuard exists to catch is acting on a tree OTHER than the one that governs\n // you, and there is no split when they are the same directory.\n if (!dirs.isLinkedWorktree || sameDir(treeRoot, governedRoot)) {\n return new TreeClassification('primary', treeRoot);\n }\n return new TreeClassification('worktree', treeRoot);\n }\n\n /** Is `dir` the directory `root` itself, or somewhere beneath it? Pure path math, no filesystem. */\n private isInside(dir: string, root: string): boolean {\n const relative = path.relative(path.resolve(root), path.resolve(dir));\n return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));\n }\n}\n\n// The two segment shapes unresolvedCd() sorts by. A segment with NO words at all is a bare `VAR=value`\n// assignment (CommandScanner strips assignments as command prefixes), which is neither.\n// webpieces-disable no-function-outside-class -- pure predicates over one segment's words, siblings of the module-scope helpers below\nfunction isCd(words: readonly string[]): boolean {\n return words[0] === 'cd' || words[0] === 'pushd';\n}\n\n// webpieces-disable no-function-outside-class -- sibling of isCd()\nfunction isRealCommand(words: readonly string[]): boolean {\n return words.length > 0 && !isCd(words);\n}\n\n// `<<EOF` / `<<'EOF'` / `<<-EOF`. NOT `<` or `<<<` alone — a herestring has no multi-line body, so it\n// cannot carry prose that tokenizes as commands.\nconst HEREDOC = /<<-?\\s*['\"]?[A-Za-z_]/;\n\n// A `cd` target that is not a literal path: `$DIR`, `${DIR}`, `~`, or a `$(…)`/backtick substitution.\n// `~` is here because path.resolve() does not expand it either — the shell does, and the hook never\n// sees a shell.\nconst VARIABLE_TARGET = /[$`]|^~/;\n\n// The separator joining a leading `cd` to what follows it, stripped when withoutLeadingCds() drops the\n// `cd`. `&&`, `||`, `;` and a bare newline are the shapes CommandScanner splits a leading run on.\nconst LEADING_SEPARATOR = /^\\s*(?:&&|\\|\\||;|\\n)\\s*/;\n\n/** Data-only carrier for the two values classify() decides together. */\nclass TreeClassification {\n readonly kind: TreeKind;\n readonly root: string;\n\n constructor(kind: TreeKind, root: string) {\n this.kind = kind;\n this.root = root;\n }\n}\n\n/**\n * The steering prefix every remedy needs, re-exported from @webpieces/rules-config so the guards, the\n * message builders and pr-gate's worktree notices all emit the IDENTICAL string — including the single\n * quotes that keep it runnable when the repo path contains a space. See atRoot's own header for why the\n * quotes are single and never double.\n */\nexport { atRoot } from '@webpieces/rules-config';\n\n// Two absolute paths naming the same directory. There is no filesystem access here — both sides are\n// already git's own answers or a resolved root.\n// webpieces-disable no-function-outside-class -- sibling of atRoot(); this module is the resolver plus its pure helpers\nfunction sameDir(a: string, b: string): boolean {\n return path.resolve(a) === path.resolve(b);\n}\n"]}
|
|
1
|
+
{"version":3,"file":"effective-tree.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/effective-tree.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport { atRoot, dotWebpieces } from '@webpieces/rules-config';\n\nimport { CommandScanner } from './command-scan';\nimport { ShellSegmentScan } from './rules/shell-segment-scan';\n\n/**\n * WHICH TREE does a Bash command actually act on? The ONE resolver every bash guard and the\n * force-to-root check share.\n *\n * WHY it has to exist at all: the shell cwd a PreToolUse hook is handed does not tell you which tree\n * the command acts on. `cd` behaves TWO different ways, and both break a cwd-based guard:\n *\n * - IN THE MAIN SESSION ONLY, a `cd` that stays INSIDE the session's working directory PERSISTS to\n * later calls. Claude Code documents this as a main-session property and states that \"subagent\n * sessions never carry over working directory changes\" — measured true here, and the reason this\n * rule must not be stated unconditionally to a reader who may be a subagent. So the cwd\n * can be a subdirectory of the governed root, left there by an unrelated command several turns\n * earlier — a relative path then resolves somewhere other than the root while still being in the\n * governed tree.\n * - A `cd` that LEAVES it is reset by the harness, which says so (`Shell cwd was reset to <root>`).\n * So an agent working in a linked worktree is back in the primary clone by the next call and must\n * write self-contained `cd <worktree> && …` commands — and the cwd the hook sees is the primary\n * clone, not the worktree the command targets.\n *\n * (Measured on 2026-08-02: `cd backlog && pwd` → `…/backlog`, then a bare `pwd` in a FRESH call →\n * still `…/backlog`. But `cd ../<linked-worktree> && pwd` → the worktree, then a bare `pwd` → back at\n * the primary clone. An earlier version of this comment asserted `cd` never persists; that was the\n * worktree case generalized. The conclusion below is unchanged — only the reason was wrong.)\n *\n * Either way a guard that reasons from the raw cwd judges the wrong tree. Three field sightings in\n * one session:\n * an `ls` of a path outside every repo blocked as \"this branch is merged\"; a version-drift cure\n * (`pnpm install`) that could not be typed from the directory that needed it; and a command aimed at\n * `/private/tmp` blocked because the PRIMARY clone's main was behind — with a remedy (`git pull` in\n * the primary clone) the agent had been explicitly forbidden to run.\n *\n * Two separate copies of the cwd logic used to exist (the runner's foreign-repo/excludePaths check\n * and force-to-root). They are both this class now: two resolvers WILL disagree about which tree you\n * are in, and a guard that disagrees with the guard beside it is worse than either being wrong.\n *\n * Resolution, in order:\n * 1. `effectiveCwd` — the leading run of `cd`/`pushd` in the command itself, resolved left to right.\n * 2. SAME REPO? `git rev-parse --git-common-dir` is identical for every checkout of one repo, so\n * comparing it against the governed root's answer is the whole test. Different → FOREIGN (a\n * nested clone under `repositories/**`), out of scope, hands-off. Not a git repo at all\n * (`cd /tmp && …`) → OUTSIDE: the guards still run — an absolute path back into the repo must\n * still be judged — but nothing the command names relative to `/tmp` is workspace content, which\n * is what ContentReadScan uses `effectiveCwd` for.\n * 3. WHICH CHECKOUT? `--show-toplevel` from `effectiveCwd`. A LINKED WORKTREE of the governed repo is\n * MANAGED — it is the same project, just another checkout — so guards run, keyed on THAT tree's\n * branch and its own state.\n * 4. PRIMARY OR LINKED? `gitDir !== commonDir`, git's own canonical test — AND NOTHING ELSE. Where\n * `webpieces.config.json` happened to be found does not enter into it (see below).\n * 5. WHICH TREE GOVERNS THE INSTALL? `<git-common-dir>/..` — the PRIMARY clone, carried as `mainRoot`.\n * It is the tree whose `node_modules` supplies the binary judging the call, from any checkout.\n *\n * GOVERNANCE IS NOT IDENTITY EITHER — the second half of the same lesson, and the second bug. classify()\n * used to answer `primary` for ANY tree that also owned the `webpieces.config.json` it was judged\n * against (`sameDir(treeRoot, governedRoot)`). `governedRoot` is walked UP from the payload cwd, and a\n * linked worktree has its own TRACKED config, so for an agent whose cwd IS the worktree — the common\n * case, and the only one the harness creates for a worktree-isolated subagent — its own tree read as\n * `primary`. Row 8 (VersionSyncGuard) matches on `w`, so it could not fire for exactly the agents it\n * exists to protect: measured 2026-08-10, a worktree bumped its pin to 0.4.624 and ran its own\n * `pnpm install` while the main clone stayed on 0.4.616, and nothing said a word. In the SAME tool call\n * the `.webpieces/` log resolver — which asks git — correctly stamped `tree=agent-abfdc0aaf1f981f3f`.\n * Two resolvers in one process disagreeing at the same instant is the shape this module exists to make\n * impossible, so K is now git's answer and ONLY git's answer.\n *\n * That leaves `governedRoot` meaning what its name says (whose config and excludePaths apply) and adds\n * `mainRoot` for the question VersionSyncGuard actually asks (whose `node_modules` is judging this\n * call). Those were never the same value, and conflating them made the guard compare a worktree\n * against ITSELF, which is trivially in sync.\n *\n * PLACEMENT IS NOT IDENTITY, and assuming it was is the bug this shape exists to prevent. classify()\n * used to short-circuit on \"is `effectiveCwd` inside the governed root?\" and never ask git anything\n * else — so an agent worktree, which Claude Code checks out INSIDE the repo at\n * `<repo>/.claude/worktrees/agent-XXXX`, took that path, disagreed with `--show-toplevel`, and read as\n * FOREIGN. `foreign` is ALLOW_EXEMPT in runner.ts: every bash guard silently off, and\n * the worktree guard (which requires `kind === 'worktree'`) dead code, for exactly the worktrees\n * the harness creates. A common-dir comparison answers the same for both placements, so there is no\n * inside/outside case left to get wrong.\n *\n * WHY THE GIT DIRS AND NOT ONE OF THE OTHER RESOLVERS — state-dir.ts's own header makes this argument\n * in full (\"Why `--git-dir` / `--git-common-dir`, and not one of the existing services\"); the short\n * version is that `WorktreeService` is a repo-wide ENUMERATION that fails SOFT to `[]` (i.e. fails open\n * into `foreign`, this bug) and `webpieces.config.json` walk-up is GOVERNANCE, not identity — it\n * deliberately climbs past a nested clone's `.git` to the outer config (repo-root.spec.ts pins that),\n * so identity built on it would hand the guards someone else's repo. This class asks DotWebpieces,\n * whose `rev-parse` pair is memoized per directory per process — it is on the hook's blocking path.\n */\n// L1's K dimension. 'primary' and 'worktree' are the same PROJECT, so every rule-scoped guard treats\n// them alike — guards/L1-location.md writes them as one value, `pw`. Exactly ONE guard separates them,\n// and on a dimension read OFF the tree itself: VersionSyncGuard blocks work in a linked worktree whose\n// @webpieces pin disagrees with the MAIN tree's, because the main tree's binary is what judges it.\n// 'worktree' is git's answer (`--git-dir` ≠ `--git-common-dir`) for the tree the command ACTS ON — it\n// does not depend on where the agent was launched, nor on which tree owns the config.\n//\n// 'outside' is produced below (git has no answer for the directory) and consumed NOWHERE, so a command in no git repo is\n// judged against governedRoot — a repo it is not in. guards/L1-location.md's \"Not done\" section explains why\n// exempting it must ship together with target-based jurisdiction, never alone.\n//\n// 'missing' is the directory that is NOT THERE — the worktree reaped out from under a live shell. It is\n// separate from 'outside' because the two used to be one `null` from git, and conflating them produced\n// the worst message this layer has emitted: \"you are in a subdirectory\", with a remedy that `cd`s back\n// into the deleted path. See MissingDirectoryGuard.\nexport type TreeKind = 'primary' | 'worktree' | 'foreign' | 'outside' | 'missing';\n\n/** Data-only (per CLAUDE.md, classes for data). */\nexport class EffectiveTree {\n /** The pre-`cd` cwd the hook was handed. For an agent in a worktree this is the primary clone. */\n readonly shellCwd: string;\n /** The directory the command really runs in, after its own leading `cd`/`pushd` run. */\n readonly effectiveCwd: string;\n /** The tree root to JUDGE: the owning worktree root, the foreign repo root, or the governed root. */\n readonly root: string;\n /** The root that owns webpieces.config.json — where config and excludePaths come from. */\n readonly governedRoot: string;\n /**\n * The PRIMARY clone's root — git's `<git-common-dir>/..`, the same answer from every checkout of the\n * repo, and equal to `root` in the primary clone itself.\n *\n * NOT `governedRoot`. A linked worktree owns its own TRACKED `webpieces.config.json`, so for an agent\n * resident in one the governed root is the WORKTREE — while the `node_modules` (and therefore the\n * binary judging the call, the nx executors and the eslint plugin) still resolves by walking up to\n * the primary clone. This field is that walk-up, asked of git instead of inferred.\n */\n readonly mainRoot: string;\n readonly kind: TreeKind;\n /** The command acts on a tree other than the shell's own — messages must steer with `cd <root> &&`. */\n readonly redirected: boolean;\n\n // eslint-disable-next-line @typescript-eslint/max-params -- five resolved paths plus the kind decided from them\n constructor(\n shellCwd: string,\n effectiveCwd: string,\n root: string,\n governedRoot: string,\n mainRoot: string,\n kind: TreeKind,\n ) {\n this.shellCwd = shellCwd;\n this.effectiveCwd = effectiveCwd;\n this.root = root;\n this.governedRoot = governedRoot;\n this.mainRoot = mainRoot;\n this.kind = kind;\n this.redirected = path.resolve(root) !== path.resolve(shellCwd);\n }\n}\n\nexport class EffectiveTreeResolver {\n private readonly shell: ShellSegmentScan;\n\n constructor(private readonly scanner: CommandScanner = new CommandScanner()) {\n this.shell = new ShellSegmentScan(scanner);\n }\n\n resolve(command: string, shellCwd: string, governedRoot: string): EffectiveTree {\n const effectiveCwd = this.effectiveCwd(command, shellCwd);\n const kindAndRoot = this.classify(effectiveCwd, governedRoot);\n return new EffectiveTree(\n shellCwd, effectiveCwd, kindAndRoot.root, governedRoot, kindAndRoot.mainRoot, kindAndRoot.kind);\n }\n\n /**\n * The cwd a command actually runs from, resolving a LEADING run of `cd`/`pushd` in the command.\n *\n * ONLY a leading run counts. Once a non-cd command appears it has ALREADY run in the current dir,\n * so a later `cd` must not retroactively pull it out of scope — otherwise a trailing\n * `… && cd <exempt-tree>` would exempt the WHOLE line, smuggling a root-level `git push` past the\n * guards. `cd a && cd b && git …` resolves left to right, matching the shell.\n *\n * Segmentation is ShellSegmentScan's (over CommandScanner), so quoting is handled exactly as the\n * guards handle it: `echo \"cd sub && git push\"` is ONE opaque segment whose first word is `echo`,\n * so the quoted `cd` is never picked up and cannot be weaponised into a scope escape.\n */\n effectiveCwd(command: string, shellCwd: string): string {\n let effective = shellCwd;\n for (const segment of this.scanner.commandSegments(command)) {\n const words = this.shell.effectiveWords(segment);\n if (words[0] !== 'cd' && words[0] !== 'pushd') break;\n if (words[1] !== undefined) effective = path.resolve(effective, words[1]);\n }\n return effective;\n }\n\n /**\n * Is this command's location UNAMBIGUOUS — or should it be rejected outright? Returns the reason a\n * `cd` in it cannot be resolved, or null when there is nothing wrong.\n *\n * `cd <literal path> && <work>` is the ONE shape that moves where a command is judged, because it\n * is the one shape effectiveCwd() can resolve. Everything else fell back to the shell cwd, which\n * is SAFE (fails closed, nothing smuggled) but silent, and silent is what cost the time:\n *\n * `cd \"$DIR\" && git push` — `path.resolve(cwd, '$DIR')` is a directory that does not\n * exist, not the tree that was meant.\n * `D=/x; cd \"$D\"; git push` — a bare `VAR=value` segment tokenizes to NO words, so the\n * leading run ends before the `cd` is reached.\n * `git fetch && cd /x && git push` — bash really does run the push in /x; the guard judged it\n * from the shell cwd and blocked it.\n * `git push && cd /x` — the push already ran at the root, whatever the trailing\n * `cd` says.\n *\n * Naming these in the block MESSAGE (the previous two PRs) helped, but left the agent to notice a\n * paragraph on an otherwise-normal block. Rejecting is the simpler contract and the one the human\n * asked for: ONE legal shape, everything else refused with the fix spelled out. No verdict changes\n * — every command this rejects was already being judged from the shell cwd — so this trades a\n * silent misdirect for a loud one-line rule, and deletes the near-miss taxonomy entirely.\n *\n * Expanding `$VAR` here is still the fix NOT taken. The resolver decides ONE location for a whole\n * line, so a `cd` that counts retroactively is exactly the `… && cd <exempt-tree>` scope escape.\n *\n * A command containing a HEREDOC (`<<`) is exempt: CommandScanner does not model heredoc bodies,\n * so a commit message or doc that merely CONTAINS `cd /x && …` tokenizes as if it were code, and\n * rejecting that would block ordinary writing. Skipping the rejection cannot open a hole — the\n * location still falls back to the shell cwd exactly as before.\n */\n misplacedCd(command: string): string | null {\n if (HEREDOC.test(command)) return null;\n\n const segments = this.scanner.commandSegments(command).map(\n (segment: string): readonly string[] => this.shell.effectiveWords(segment));\n\n // The leading run effectiveCwd() actually consumed — a `cd` at or after this index did not count.\n let consumed = 0;\n while (consumed < segments.length && isCd(segments[consumed])) consumed++;\n\n for (let i = 0; i < segments.length; i++) {\n if (!isCd(segments[i])) continue;\n if (i >= consumed) {\n return segments.slice(0, i).some(isRealCommand)\n ? 'it comes after another command — a `cd` only counts at the FRONT of the line'\n : 'a `VAR=…` assignment precedes it, which ends the scan';\n }\n const target = segments[i][1];\n if (target !== undefined && VARIABLE_TARGET.test(target)) {\n return 'its target is not a literal path (a `$VAR`, `~` or `$(…)` the guard cannot expand)';\n }\n }\n return null;\n }\n\n /**\n * The remedy for a command judged in the wrong directory — `cd '<root>' && <the work>`, with the\n * command's OWN leading `cd` run REPLACED rather than prefixed.\n *\n * A block's remedy must not leave the block's condition true. `atRoot()` alone prefixes, and\n * `effectiveCwd()` resolves the leading run of `cd`s LEFT TO RIGHT — so prefixing `cd '<root>' &&`\n * onto `cd <elsewhere> && git status` still lands in `<elsewhere>`, the identical block fires on\n * the remedy, and the retry prints it with the prefix doubled, then tripled. Structurally\n * non-convergent, and observed in the field against an agent worktree.\n *\n * Only the LEADING run is dropped, because only the leading run moved where the command was judged.\n * A mid-line `cd` is part of the work and is carried through untouched (it is separately rejected by\n * `misplacedCd`).\n */\n remedyAtRoot(root: string, command: string): string {\n return atRoot(root, this.withoutLeadingCds(command));\n }\n\n private withoutLeadingCds(command: string): string {\n let rest = command;\n for (const segment of this.scanner.commandSegments(command)) {\n const words = this.shell.effectiveWords(segment);\n if (words[0] !== 'cd' && words[0] !== 'pushd') break;\n const at = rest.indexOf(segment);\n if (at < 0) break;\n rest = rest.slice(at + segment.length).replace(LEADING_SEPARATOR, '');\n }\n // A line that is NOTHING but `cd`s has no work to steer; hand it back whole rather than emit an\n // empty remedy.\n return rest.trim() === '' ? command : rest.trim();\n }\n\n private classify(effectiveCwd: string, governedRoot: string): TreeClassification {\n // GONE, not merely un-gitted. git answers `null` for both \"not a repo\" and \"no such directory\",\n // and collapsing the two is what made a reaped worktree read as an ordinary subdirectory of the\n // governed root — with a remedy that `cd`s straight back into the deleted path. One statSync,\n // ahead of the git calls, keeps them apart. The root is the GOVERNED root because that is the\n // only tree left to steer anyone to.\n if (!fs.existsSync(effectiveCwd)) return new TreeClassification('missing', governedRoot, governedRoot);\n\n const dirs = dotWebpieces.gitDirs(effectiveCwd);\n // Not a git repo at all. Inside the governed tree that can only be a directory git declined to\n // answer for, so it stays `primary` exactly as before; outside it is the `cd /tmp && …` case.\n if (dirs === null) {\n return this.isInside(effectiveCwd, governedRoot)\n ? new TreeClassification('primary', governedRoot, governedRoot)\n : new TreeClassification('outside', governedRoot, governedRoot);\n }\n\n const treeRoot = dotWebpieces.treeRoot(effectiveCwd) ?? governedRoot;\n // `<git-common-dir>/..`, off the SAME memoized rev-parse pair `gitDirs` just answered with — so\n // this costs no extra process, which matters on the hook's blocking path.\n const mainRoot = dotWebpieces.primaryRoot(effectiveCwd);\n const ours = dotWebpieces.gitDirs(governedRoot);\n // ONE test for \"is this our repo\": the shared git dir. It is identical for every checkout of one\n // repo and different for a nested clone, wherever either happens to sit on disk.\n if (ours === null || !sameDir(dirs.commonDir, ours.commonDir)) {\n return new TreeClassification('foreign', treeRoot, mainRoot);\n }\n\n // GIT DECIDES, and nothing else. `isLinkedWorktree` is `--git-dir !== --git-common-dir`, an\n // answer about the CHECKOUT — so a worktree is `worktree` whether the command was typed from the\n // primary clone (`cd <wt> && …`) or by an agent living in it. The old extra clause\n // (`|| sameDir(treeRoot, governedRoot)`) made a resident agent's OWN tree read `primary`,\n // silently retiring row 8 for every worktree-isolated subagent — see this module's header.\n if (!dirs.isLinkedWorktree) return new TreeClassification('primary', treeRoot, mainRoot);\n return new TreeClassification('worktree', treeRoot, mainRoot);\n }\n\n /** Is `dir` the directory `root` itself, or somewhere beneath it? Pure path math, no filesystem. */\n private isInside(dir: string, root: string): boolean {\n const relative = path.relative(path.resolve(root), path.resolve(dir));\n return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));\n }\n}\n\n// The two segment shapes unresolvedCd() sorts by. A segment with NO words at all is a bare `VAR=value`\n// assignment (CommandScanner strips assignments as command prefixes), which is neither.\n// webpieces-disable no-function-outside-class -- pure predicates over one segment's words, siblings of the module-scope helpers below\nfunction isCd(words: readonly string[]): boolean {\n return words[0] === 'cd' || words[0] === 'pushd';\n}\n\n// webpieces-disable no-function-outside-class -- sibling of isCd()\nfunction isRealCommand(words: readonly string[]): boolean {\n return words.length > 0 && !isCd(words);\n}\n\n// `<<EOF` / `<<'EOF'` / `<<-EOF`. NOT `<` or `<<<` alone — a herestring has no multi-line body, so it\n// cannot carry prose that tokenizes as commands.\nconst HEREDOC = /<<-?\\s*['\"]?[A-Za-z_]/;\n\n// A `cd` target that is not a literal path: `$DIR`, `${DIR}`, `~`, or a `$(…)`/backtick substitution.\n// `~` is here because path.resolve() does not expand it either — the shell does, and the hook never\n// sees a shell.\nconst VARIABLE_TARGET = /[$`]|^~/;\n\n// The separator joining a leading `cd` to what follows it, stripped when withoutLeadingCds() drops the\n// `cd`. `&&`, `||`, `;` and a bare newline are the shapes CommandScanner splits a leading run on.\nconst LEADING_SEPARATOR = /^\\s*(?:&&|\\|\\||;|\\n)\\s*/;\n\n/** Data-only carrier for the three values classify() decides together. */\nclass TreeClassification {\n readonly kind: TreeKind;\n readonly root: string;\n /** The primary clone behind `root` — see EffectiveTree.mainRoot for why it is not `governedRoot`. */\n readonly mainRoot: string;\n\n constructor(kind: TreeKind, root: string, mainRoot: string) {\n this.kind = kind;\n this.root = root;\n this.mainRoot = mainRoot;\n }\n}\n\n/**\n * The steering prefix every remedy needs, re-exported from @webpieces/rules-config so the guards, the\n * message builders and pr-gate's worktree notices all emit the IDENTICAL string — including the single\n * quotes that keep it runnable when the repo path contains a space. See atRoot's own header for why the\n * quotes are single and never double.\n */\nexport { atRoot } from '@webpieces/rules-config';\n\n// Two absolute paths naming the same directory. There is no filesystem access here — both sides are\n// already git's own answers or a resolved root.\n// webpieces-disable no-function-outside-class -- sibling of atRoot(); this module is the resolver plus its pure helpers\nfunction sameDir(a: string, b: string): boolean {\n return path.resolve(a) === path.resolve(b);\n}\n"]}
|
package/src/core/l1-doc.js
CHANGED
|
@@ -73,11 +73,12 @@ function renderHead() {
|
|
|
73
73
|
'2. **Does the directory still EXIST?** — row 7. A worktree reaped out from under a live shell leaves',
|
|
74
74
|
' a cwd that names nothing, and that state needs its own name and its own message, because the',
|
|
75
75
|
' remedy for "you are in a subdirectory" is a `cd` back into the very directory that is gone.',
|
|
76
|
-
'3. **Is this tree governed by a release it did not ask for?** — row 8.
|
|
77
|
-
'
|
|
78
|
-
'
|
|
79
|
-
'
|
|
80
|
-
'
|
|
76
|
+
'3. **Is this tree governed by a release it did not ask for?** — row 8. A worktree MAY have its own',
|
|
77
|
+
' `node_modules` (nx, vitest and the eslint plugin all execute there and load from it), and when it',
|
|
78
|
+
' has none the shim\'s upward walk runs the main tree\'s binary. Either way the rule is the same and',
|
|
79
|
+
' holds whichever registration form — absolute or relative — is live in the consumer: the two trees',
|
|
80
|
+
' must PIN the same `@webpieces`, or the worktree is linted, validated and built by a release its',
|
|
81
|
+
' own manifest does not ask for. Asked of the PATH acted on, never of who is asking —',
|
|
81
82
|
' agent identity was measured untrustworthy for tree detection (a worktree-isolated agent whose tree',
|
|
82
83
|
' is auto-reaped at a turn boundary silently resumes on the primary clone).',
|
|
83
84
|
'4. **Is the agent stranded away from the root?** — force-to-root, git/gh only. Agents forget where',
|