@webpieces/ai-hook-rules 0.4.713 → 0.4.715

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.
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.L0_CODEX_ALLOW_JS = exports.L0_CODEX_ALLOW_ERE_SH = exports.L0_CODEX_ALLOW_ERE = exports.CODEX_READ_STILL_ALLOWED = exports.CODEX_READ_CMD = exports.CODEX_READ_BODY_JS = exports.CODEX_READ_BODY_ERE = void 0;
4
+ const shell_read_parity_1 = require("../core/shell-read-parity");
5
+ // ---------------------------------------------------------------------------
6
+ // CODEX READ PARITY AT L0 — the deadlock PR #731 measured and deliberately left open.
7
+ //
8
+ // Allowlist entry 1 is "any Read", and under Claude Code that single entry is what makes every L0 fault
9
+ // SURVIVABLE rather than fatal: the agent is denied all work, reads the config / the logs / the matrix
10
+ // doc, and diagnoses its way out. CODEX HAS NO `Read` TOOL. Measured on codex-cli 0.151.0, a file read
11
+ // arrives as `tool_name: "Bash"` running `sed -n '1,240p' package.json`. Nothing on the list above
12
+ // matches that, so under D/X/U/K/S a Codex session is denied EVERYTHING — including the reads the deny
13
+ // message is telling it to perform. That is a hard deadlock, and it is the exact failure class this
14
+ // whole module exists to remove.
15
+ //
16
+ // So this entry is entry 1's twin for the other harness, and it is GATED ON `aiType` (see
17
+ // L0AllowEntry.aiType). A Claude payload can never reach it — Claude already has `Read`, and widening
18
+ // L0's Bash surface for a harness that does not need it is a change to the one behaviour that must not
19
+ // change. `codex-l0-read.spec.ts` proves the unreachability rather than asserting it in a comment.
20
+ //
21
+ // ─── AS WIDE AS core/shell-read-parity.ts AND NO WIDER ─────────────────────────────────────────────
22
+ // That module is the repo's ONE definition of "this Codex Bash call is a read", and this pattern is
23
+ // built from its exported vocabulary (READ_COMMANDS, SED_RANGE_BODY) rather than a second list. It
24
+ // cannot literally call it — L0's sh half has no JS, and the JS half runs where nothing above it can be
25
+ // trusted — so what is shared is the vocabulary and what is asserted is agreement over a corpus.
26
+ //
27
+ // Where it is deliberately NARROWER than that module: the shell-read predicate also resolves every
28
+ // operand against the filesystem and rejects anything outside the tree. A regex cannot ask the
29
+ // filesystem, so this pattern simply refuses everything the predicate refuses SYNTACTICALLY and accepts
30
+ // a superset only in the "does this path exist here" dimension. That dimension is not a privilege
31
+ // boundary at L0: entry 1 grants Claude an unrestricted Read of any path already.
32
+ //
33
+ // NO `cd` PREFIX AND NO CAPTURE TAIL, unlike every entry above, and that is the point rather than an
34
+ // oversight. `shell-read-parity` treats `|`, `&&`, `;` and every redirect as PROOF the command is not a
35
+ // read; splicing CAPTURE_TAIL_ERE on would make this entry accept `cat x | tail -20`, which that module
36
+ // says is not a read — i.e. it would make this entry WIDER than the definition it is supposed to share.
37
+ // The cost is that `sed -n '1,240p' x 2>/dev/null` is denied; the deny text names the bare spelling, and
38
+ // bare is how Codex actually spells a read (measured).
39
+ //
40
+ // WHAT CANNOT RIDE ALONG: every character class below excludes `;` `&` `|` `` ` `` `<` `>` `$` and the
41
+ // double quote, so no chaining, no redirect, no substitution and no expansion is expressible — the same
42
+ // set `NOT_ONE_COMMAND` rejects, enforced by construction instead of by a scan. A path containing
43
+ // spaces is reachable through the single-quoted branch, where sh performs no expansion at all.
44
+ // Keep in sync with CODEX_READ_BODY_JS below (locked by a unit test).
45
+ // `-50` (head/tail's line count) as well as `-n` / `--number`: `shell-read-parity` skips every token
46
+ // starting with `-`, so a flag shape it accepts and this pattern rejects is a spelling the deny would
47
+ // leave untypable — the deadlock this entry exists to remove, one level down.
48
+ const CODEX_READ_FLAG_ERE = '(-[0-9]+|-{1,2}[A-Za-z][A-Za-z0-9=._-]*)';
49
+ // A path token: no leading `-` (that is a flag, and a bare `-` is stdin, not a file), or any path at all
50
+ // inside single quotes — where the ONLY character that can end the region is the one the class excludes.
51
+ const CODEX_READ_PATH_ERE = "([A-Za-z0-9._/@~+,:][A-Za-z0-9._/@~+,:-]*|'[^']+')";
52
+ const CODEX_READ_ARG_ERE = '(' + CODEX_READ_FLAG_ERE + '|' + CODEX_READ_PATH_ERE + ')';
53
+ // `<pager> [flags…] <path> [more args…]` — at least one path operand is REQUIRED, so `cat` on its own
54
+ // (which reads stdin, not a file) is not a read here either.
55
+ const CODEX_PAGER_ERE = '(' + [...shell_read_parity_1.READ_COMMANDS].join('|') + ')([[:space:]]+' + CODEX_READ_ARG_ERE + ')*'
56
+ + '[[:space:]]+' + CODEX_READ_PATH_ERE + '([[:space:]]+' + CODEX_READ_ARG_ERE + ')*';
57
+ // `sed -n '<range>p' <path>` — BOTH halves required, exactly as sedOperands() requires them: without
58
+ // `-n` sed echoes and edits, and any script that is not a bare range print is a transformation.
59
+ const CODEX_SED_ERE = 'sed[[:space:]]+-n[[:space:]]+(' + shell_read_parity_1.SED_RANGE_BODY + "|'" + shell_read_parity_1.SED_RANGE_BODY + "')"
60
+ + '([[:space:]]+' + CODEX_READ_ARG_ERE + ')*[[:space:]]+' + CODEX_READ_PATH_ERE
61
+ + '([[:space:]]+' + CODEX_READ_ARG_ERE + ')*';
62
+ exports.CODEX_READ_BODY_ERE = '(' + CODEX_PAGER_ERE + '|' + CODEX_SED_ERE + ')';
63
+ // JS-regex-source twin of CODEX_READ_BODY_ERE (POSIX `[[:space:]]` → `\s`). A unit test asserts they agree.
64
+ const CODEX_READ_FLAG_JS = '(-[0-9]+|-{1,2}[A-Za-z][A-Za-z0-9=._-]*)';
65
+ const CODEX_READ_PATH_JS = "([A-Za-z0-9._\\/@~+,:][A-Za-z0-9._\\/@~+,:-]*|'[^']+')";
66
+ const CODEX_READ_ARG_JS = '(' + CODEX_READ_FLAG_JS + '|' + CODEX_READ_PATH_JS + ')';
67
+ const CODEX_PAGER_JS = '(' + [...shell_read_parity_1.READ_COMMANDS].join('|') + ')(\\s+' + CODEX_READ_ARG_JS + ')*'
68
+ + '\\s+' + CODEX_READ_PATH_JS + '(\\s+' + CODEX_READ_ARG_JS + ')*';
69
+ const CODEX_SED_JS = 'sed\\s+-n\\s+(' + shell_read_parity_1.SED_RANGE_BODY + "|'" + shell_read_parity_1.SED_RANGE_BODY + "')"
70
+ + '(\\s+' + CODEX_READ_ARG_JS + ')*\\s+' + CODEX_READ_PATH_JS + '(\\s+' + CODEX_READ_ARG_JS + ')*';
71
+ exports.CODEX_READ_BODY_JS = '(' + CODEX_PAGER_JS + '|' + CODEX_SED_JS + ')';
72
+ /** The measured Codex spelling of a file read, and this entry's canonical sample. */
73
+ exports.CODEX_READ_CMD = "sed -n '1,240p' package.json";
74
+ /**
75
+ * The line every L0 deny's "still allowed" block prints for the harness with no `Read` tool.
76
+ *
77
+ * It exists because the block already said "any Read" and a Codex session HAS no Read — so the one
78
+ * sentence telling a blocked agent how to inspect its way out named a tool it could not call. That is
79
+ * the deadlock shape this module is a catalogue of, one level up in the message instead of the pattern.
80
+ *
81
+ * CONSTRAINT (see NO_CHAINING_RULE in ./shim): this string is interpolated into a `REASON="…"` shell
82
+ * assignment and then printf'd into a JSON string, so it may contain no double quote and no backslash.
83
+ */
84
+ exports.CODEX_READ_STILL_ALLOWED = `on CODEX (no Read tool): a bare read command - ${[...shell_read_parity_1.READ_COMMANDS].join('/')} <file>, `
85
+ + `or ${exports.CODEX_READ_CMD} - with nothing piped, redirected or chained onto it`;
86
+ // ---------------------------------------------------------------------------
87
+ // THE CODEX-ONLY UNION — a SECOND, separately-anchored list, consulted only after both halves of L0
88
+ // have answered "which harness sent this call?" (AI_TYPE_SH in sh, detectAiType() in JS).
89
+ //
90
+ // A separate union rather than a flag inside L0_ALLOW_ERE, for two reasons that are both structural:
91
+ // 1. UNREACHABILITY IS THE POINT. A Claude payload never evaluates this pattern at all — the sh half
92
+ // guards it with `[ "$AI" = codex ]` and the JS half with an `aiType === 'codex'` test — so
93
+ // "Claude Code behaviour does not change" is a property of the control flow, not of the regex.
94
+ // 2. IT IS ANCHORED DIFFERENTLY. Every ungated entry tolerates a `cd <dir> &&` prefix and a
95
+ // `2>&1 | tail -N` capture tail. A read-shaped command may tolerate NEITHER without becoming
96
+ // wider than core/shell-read-parity.ts, which treats both as proof the command is not a read.
97
+ // Folding this body into L0_ALLOW_ERE would silently splice both onto it.
98
+ //
99
+ // Built from the SAME constants the L0_ALLOWLIST entry carries — it cannot filter that array here,
100
+ // because the array imports these bodies and the import would be a cycle. `codex-l0-read.spec.ts`
101
+ // locks the two together instead: the gated entry's `ere`/`js` must BE this union's source.
102
+ // ---------------------------------------------------------------------------
103
+ /** The Codex-gated Bash allowlist as a POSIX ERE — anchored at BOTH ends, no prefix, no tail. */
104
+ exports.L0_CODEX_ALLOW_ERE = '^(' + exports.CODEX_READ_BODY_ERE + ')[[:space:]]*$';
105
+ /** L0_CODEX_ALLOW_ERE as it must be SPELLED inside a single-quoted sh string — the `'\''` dance. */
106
+ exports.L0_CODEX_ALLOW_ERE_SH = exports.L0_CODEX_ALLOW_ERE.split("'").join(`'\\''`);
107
+ /** JS twin of L0_CODEX_ALLOW_ERE. A unit test asserts the two agree over a corpus. */
108
+ exports.L0_CODEX_ALLOW_JS = new RegExp('^(' + exports.CODEX_READ_BODY_JS + ')\\s*$');
109
+ //# sourceMappingURL=l0-codex-read.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"l0-codex-read.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/l0-codex-read.ts"],"names":[],"mappings":"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","sourcesContent":["import { READ_COMMANDS, SED_RANGE_BODY } from '../core/shell-read-parity';\n\n// ---------------------------------------------------------------------------\n// CODEX READ PARITY AT L0 — the deadlock PR #731 measured and deliberately left open.\n//\n// Allowlist entry 1 is \"any Read\", and under Claude Code that single entry is what makes every L0 fault\n// SURVIVABLE rather than fatal: the agent is denied all work, reads the config / the logs / the matrix\n// doc, and diagnoses its way out. CODEX HAS NO `Read` TOOL. Measured on codex-cli 0.151.0, a file read\n// arrives as `tool_name: \"Bash\"` running `sed -n '1,240p' package.json`. Nothing on the list above\n// matches that, so under D/X/U/K/S a Codex session is denied EVERYTHING — including the reads the deny\n// message is telling it to perform. That is a hard deadlock, and it is the exact failure class this\n// whole module exists to remove.\n//\n// So this entry is entry 1's twin for the other harness, and it is GATED ON `aiType` (see\n// L0AllowEntry.aiType). A Claude payload can never reach it — Claude already has `Read`, and widening\n// L0's Bash surface for a harness that does not need it is a change to the one behaviour that must not\n// change. `codex-l0-read.spec.ts` proves the unreachability rather than asserting it in a comment.\n//\n// ─── AS WIDE AS core/shell-read-parity.ts AND NO WIDER ─────────────────────────────────────────────\n// That module is the repo's ONE definition of \"this Codex Bash call is a read\", and this pattern is\n// built from its exported vocabulary (READ_COMMANDS, SED_RANGE_BODY) rather than a second list. It\n// cannot literally call it — L0's sh half has no JS, and the JS half runs where nothing above it can be\n// trusted — so what is shared is the vocabulary and what is asserted is agreement over a corpus.\n//\n// Where it is deliberately NARROWER than that module: the shell-read predicate also resolves every\n// operand against the filesystem and rejects anything outside the tree. A regex cannot ask the\n// filesystem, so this pattern simply refuses everything the predicate refuses SYNTACTICALLY and accepts\n// a superset only in the \"does this path exist here\" dimension. That dimension is not a privilege\n// boundary at L0: entry 1 grants Claude an unrestricted Read of any path already.\n//\n// NO `cd` PREFIX AND NO CAPTURE TAIL, unlike every entry above, and that is the point rather than an\n// oversight. `shell-read-parity` treats `|`, `&&`, `;` and every redirect as PROOF the command is not a\n// read; splicing CAPTURE_TAIL_ERE on would make this entry accept `cat x | tail -20`, which that module\n// says is not a read — i.e. it would make this entry WIDER than the definition it is supposed to share.\n// The cost is that `sed -n '1,240p' x 2>/dev/null` is denied; the deny text names the bare spelling, and\n// bare is how Codex actually spells a read (measured).\n//\n// WHAT CANNOT RIDE ALONG: every character class below excludes `;` `&` `|` `` ` `` `<` `>` `$` and the\n// double quote, so no chaining, no redirect, no substitution and no expansion is expressible — the same\n// set `NOT_ONE_COMMAND` rejects, enforced by construction instead of by a scan. A path containing\n// spaces is reachable through the single-quoted branch, where sh performs no expansion at all.\n// Keep in sync with CODEX_READ_BODY_JS below (locked by a unit test).\n// `-50` (head/tail's line count) as well as `-n` / `--number`: `shell-read-parity` skips every token\n// starting with `-`, so a flag shape it accepts and this pattern rejects is a spelling the deny would\n// leave untypable — the deadlock this entry exists to remove, one level down.\nconst CODEX_READ_FLAG_ERE = '(-[0-9]+|-{1,2}[A-Za-z][A-Za-z0-9=._-]*)';\n// A path token: no leading `-` (that is a flag, and a bare `-` is stdin, not a file), or any path at all\n// inside single quotes — where the ONLY character that can end the region is the one the class excludes.\nconst CODEX_READ_PATH_ERE = \"([A-Za-z0-9._/@~+,:][A-Za-z0-9._/@~+,:-]*|'[^']+')\";\nconst CODEX_READ_ARG_ERE = '(' + CODEX_READ_FLAG_ERE + '|' + CODEX_READ_PATH_ERE + ')';\n// `<pager> [flags…] <path> [more args…]` — at least one path operand is REQUIRED, so `cat` on its own\n// (which reads stdin, not a file) is not a read here either.\nconst CODEX_PAGER_ERE =\n '(' + [...READ_COMMANDS].join('|') + ')([[:space:]]+' + CODEX_READ_ARG_ERE + ')*'\n + '[[:space:]]+' + CODEX_READ_PATH_ERE + '([[:space:]]+' + CODEX_READ_ARG_ERE + ')*';\n// `sed -n '<range>p' <path>` — BOTH halves required, exactly as sedOperands() requires them: without\n// `-n` sed echoes and edits, and any script that is not a bare range print is a transformation.\nconst CODEX_SED_ERE =\n 'sed[[:space:]]+-n[[:space:]]+(' + SED_RANGE_BODY + \"|'\" + SED_RANGE_BODY + \"')\"\n + '([[:space:]]+' + CODEX_READ_ARG_ERE + ')*[[:space:]]+' + CODEX_READ_PATH_ERE\n + '([[:space:]]+' + CODEX_READ_ARG_ERE + ')*';\nexport const CODEX_READ_BODY_ERE = '(' + CODEX_PAGER_ERE + '|' + CODEX_SED_ERE + ')';\n\n// JS-regex-source twin of CODEX_READ_BODY_ERE (POSIX `[[:space:]]` → `\\s`). A unit test asserts they agree.\nconst CODEX_READ_FLAG_JS = '(-[0-9]+|-{1,2}[A-Za-z][A-Za-z0-9=._-]*)';\nconst CODEX_READ_PATH_JS = \"([A-Za-z0-9._\\\\/@~+,:][A-Za-z0-9._\\\\/@~+,:-]*|'[^']+')\";\nconst CODEX_READ_ARG_JS = '(' + CODEX_READ_FLAG_JS + '|' + CODEX_READ_PATH_JS + ')';\nconst CODEX_PAGER_JS =\n '(' + [...READ_COMMANDS].join('|') + ')(\\\\s+' + CODEX_READ_ARG_JS + ')*'\n + '\\\\s+' + CODEX_READ_PATH_JS + '(\\\\s+' + CODEX_READ_ARG_JS + ')*';\nconst CODEX_SED_JS =\n 'sed\\\\s+-n\\\\s+(' + SED_RANGE_BODY + \"|'\" + SED_RANGE_BODY + \"')\"\n + '(\\\\s+' + CODEX_READ_ARG_JS + ')*\\\\s+' + CODEX_READ_PATH_JS + '(\\\\s+' + CODEX_READ_ARG_JS + ')*';\nexport const CODEX_READ_BODY_JS = '(' + CODEX_PAGER_JS + '|' + CODEX_SED_JS + ')';\n\n/** The measured Codex spelling of a file read, and this entry's canonical sample. */\nexport const CODEX_READ_CMD = \"sed -n '1,240p' package.json\";\n\n/**\n * The line every L0 deny's \"still allowed\" block prints for the harness with no `Read` tool.\n *\n * It exists because the block already said \"any Read\" and a Codex session HAS no Read — so the one\n * sentence telling a blocked agent how to inspect its way out named a tool it could not call. That is\n * the deadlock shape this module is a catalogue of, one level up in the message instead of the pattern.\n *\n * CONSTRAINT (see NO_CHAINING_RULE in ./shim): this string is interpolated into a `REASON=\"…\"` shell\n * assignment and then printf'd into a JSON string, so it may contain no double quote and no backslash.\n */\nexport const CODEX_READ_STILL_ALLOWED =\n `on CODEX (no Read tool): a bare read command - ${[...READ_COMMANDS].join('/')} <file>, `\n + `or ${CODEX_READ_CMD} - with nothing piped, redirected or chained onto it`;\n// ---------------------------------------------------------------------------\n// THE CODEX-ONLY UNION — a SECOND, separately-anchored list, consulted only after both halves of L0\n// have answered \"which harness sent this call?\" (AI_TYPE_SH in sh, detectAiType() in JS).\n//\n// A separate union rather than a flag inside L0_ALLOW_ERE, for two reasons that are both structural:\n// 1. UNREACHABILITY IS THE POINT. A Claude payload never evaluates this pattern at all — the sh half\n// guards it with `[ \"$AI\" = codex ]` and the JS half with an `aiType === 'codex'` test — so\n// \"Claude Code behaviour does not change\" is a property of the control flow, not of the regex.\n// 2. IT IS ANCHORED DIFFERENTLY. Every ungated entry tolerates a `cd <dir> &&` prefix and a\n// `2>&1 | tail -N` capture tail. A read-shaped command may tolerate NEITHER without becoming\n// wider than core/shell-read-parity.ts, which treats both as proof the command is not a read.\n// Folding this body into L0_ALLOW_ERE would silently splice both onto it.\n//\n// Built from the SAME constants the L0_ALLOWLIST entry carries — it cannot filter that array here,\n// because the array imports these bodies and the import would be a cycle. `codex-l0-read.spec.ts`\n// locks the two together instead: the gated entry's `ere`/`js` must BE this union's source.\n// ---------------------------------------------------------------------------\n/** The Codex-gated Bash allowlist as a POSIX ERE — anchored at BOTH ends, no prefix, no tail. */\nexport const L0_CODEX_ALLOW_ERE =\n '^(' + CODEX_READ_BODY_ERE + ')[[:space:]]*$';\n\n/** L0_CODEX_ALLOW_ERE as it must be SPELLED inside a single-quoted sh string — the `'\\''` dance. */\nexport const L0_CODEX_ALLOW_ERE_SH = L0_CODEX_ALLOW_ERE.split(\"'\").join(`'\\\\''`);\n\n/** JS twin of L0_CODEX_ALLOW_ERE. A unit test asserts the two agree over a corpus. */\nexport const L0_CODEX_ALLOW_JS =\n new RegExp('^(' + CODEX_READ_BODY_JS + ')\\\\s*$');\n"]}
@@ -1,3 +1,4 @@
1
+ import { AiType } from '../core/agent-event';
1
2
  /**
2
3
  * THE DECISION — `isAllowed()`, the ONE question sh and JS both ask, and the two tool-shaped facts it
3
4
  * needs that no regex can express.
@@ -22,8 +23,14 @@ export declare const READ_TOOLS: ReadonlySet<string>;
22
23
  * - null → not on the list.
23
24
  *
24
25
  * `CONFIG_FILENAME` stays a basename match on purpose — one per tree; narrowing it is its own question.
26
+ *
27
+ * `aiType` is REQUIRED, and there is no default. The harness is a fact of the call, not a preference,
28
+ * and every caller already has it: the shim scrapes it in POSIX sh (AI_TYPE_SH) and the binary reads it
29
+ * off the raw envelope (detectAiType). A default here would be a second spelling of "which harness?" —
30
+ * the one that silently answers `claude-code` for a Codex call and re-creates the deadlock the gated
31
+ * entry exists to remove.
25
32
  */
26
- export declare function isAllowed(toolName: string, command: string, filePath: string): 'pass' | 'allow' | null;
33
+ export declare function isAllowed(toolName: string, command: string, filePath: string, aiType: AiType): 'pass' | 'allow' | null;
27
34
  /**
28
35
  * Is `filePath` the `package.json` / `pnpm-workspace.yaml` at the ROOT OF A GOVERNED TREE — the only two
29
36
  * files the version cure ever edits?
@@ -8,6 +8,7 @@ const fs = tslib_1.__importStar(require("fs"));
8
8
  const path = tslib_1.__importStar(require("path"));
9
9
  const rules_config_1 = require("@webpieces/rules-config");
10
10
  const l0_allowlist_1 = require("./l0-allowlist");
11
+ const l0_codex_read_1 = require("./l0-codex-read");
11
12
  const l0_ignored_tools_1 = require("./l0-ignored-tools");
12
13
  /**
13
14
  * THE DECISION — `isAllowed()`, the ONE question sh and JS both ask, and the two tool-shaped facts it
@@ -41,9 +42,15 @@ exports.READ_TOOLS = new Set(['Read']);
41
42
  * - null → not on the list.
42
43
  *
43
44
  * `CONFIG_FILENAME` stays a basename match on purpose — one per tree; narrowing it is its own question.
45
+ *
46
+ * `aiType` is REQUIRED, and there is no default. The harness is a fact of the call, not a preference,
47
+ * and every caller already has it: the shim scrapes it in POSIX sh (AI_TYPE_SH) and the binary reads it
48
+ * off the raw envelope (detectAiType). A default here would be a second spelling of "which harness?" —
49
+ * the one that silently answers `claude-code` for a Codex call and re-creates the deadlock the gated
50
+ * entry exists to remove.
44
51
  */
45
52
  // 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)
46
- function isAllowed(toolName, command, filePath) {
53
+ function isAllowed(toolName, command, filePath, aiType) {
47
54
  if (exports.READ_TOOLS.has(toolName))
48
55
  return 'pass';
49
56
  // Nothing to judge — see L0_IGNORED_TOOLS. `pass`, never `allow`: L0 declines to be terminal, so on
@@ -56,6 +63,12 @@ function isAllowed(toolName, command, filePath) {
56
63
  return 'pass';
57
64
  if (l0_allowlist_1.L0_ALLOW_JS.test(command.trim()))
58
65
  return 'allow';
66
+ // The HARNESS-GATED tail of the list, and the only place `aiType` is consulted. Codex has no `Read`
67
+ // tool, so a read arrives here as a Bash command; without this it is denied under every L0 fault and
68
+ // a Codex session cannot read the deny that is telling it what to run. `pass`, exactly like the Read
69
+ // entry it twins — see the L0_CODEX_ALLOW_ERE header for why this union is anchored on its own.
70
+ if (aiType === 'codex' && l0_codex_read_1.L0_CODEX_ALLOW_JS.test(command.trim()))
71
+ return 'pass';
59
72
  return null;
60
73
  }
61
74
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"l0-decide.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/l0-decide.ts"],"names":[],"mappings":";;;AA2CA,8BASC;AAmBD,wCASC;;AAhFD,+CAAyB;AACzB,mDAA6B;AAE7B,0DAA0D;AAE1D,iDAAiE;AACjE,yDAAsD;AAEtD;;;;;;;;;;;;GAYG;AACH,4FAA4F;AAC5F,EAAE;AACF,2FAA2F;AAC3F,uGAAuG;AACvG,oGAAoG;AACpG,wGAAwG;AACxG,uGAAuG;AACvG,yFAAyF;AAC5E,QAAA,UAAU,GAAwB,IAAI,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC;AAEjE;;;;;;;;;;GAUG;AACH,iMAAiM;AACjM,SAAgB,SAAS,CAAC,QAAgB,EAAE,OAAe,EAAE,QAAgB;IACzE,IAAI,kBAAU,CAAC,GAAG,CAAC,QAAQ,CAAC;QAAE,OAAO,MAAM,CAAC;IAC5C,oGAAoG;IACpG,qEAAqE;IACrE,IAAI,mCAAgB,CAAC,GAAG,CAAC,QAAQ,CAAC;QAAE,OAAO,MAAM,CAAC;IAClD,IAAI,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,KAAK,8BAAe;QAAE,OAAO,MAAM,CAAC;IAC/D,IAAI,cAAc,CAAC,QAAQ,CAAC;QAAE,OAAO,MAAM,CAAC;IAC5C,IAAI,0BAAW,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC;QAAE,OAAO,OAAO,CAAC;IACrD,OAAO,IAAI,CAAC;AAChB,CAAC;AAED;;;;;;;;;;;;;;;GAeG;AACH,4HAA4H;AAC5H,SAAgB,cAAc,CAAC,QAAgB;IAC3C,IAAI,CAAC,iCAAkB,CAAC,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;QAAE,OAAO,KAAK,CAAC;IACnE,8DAA8D;IAC9D,IAAI,CAAC;QACD,OAAO,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE,8BAAe,CAAC,CAAC,CAAC;IAC7E,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,+FAA+F;QAC/F,OAAO,KAAK,CAAC;IACjB,CAAC;AACL,CAAC","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport { CONFIG_FILENAME } from '@webpieces/rules-config';\n\nimport { L0_ALLOW_JS, MANIFEST_FILENAMES } from './l0-allowlist';\nimport { L0_IGNORED_TOOLS } from './l0-ignored-tools';\n\n/**\n * THE DECISION — `isAllowed()`, the ONE question sh and JS both ask, and the two tool-shaped facts it\n * needs that no regex can express.\n *\n * Split out of ./l0-allowlist.ts (which was over the file-size limit) along the seam that was already\n * there: that module is the VOCABULARY — the named cure patterns and the one union built from them —\n * and this is what CONSULTS it. `shim.ts` re-exports both, so there is still ONE name to import L0 by\n * and every existing `from './shim'` import keeps working.\n *\n * The direction of the dependency is the reason for the split: this module imports the allowlist, the\n * allowlist imports nothing from here, and neither imports the rule engine — L0 must decide on a tree\n * too broken to load it.\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 * `CONFIG_FILENAME` stays a basename match on purpose — one per tree; narrowing it is its own question.\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 // Nothing to judge — see L0_IGNORED_TOOLS. `pass`, never `allow`: L0 declines to be terminal, so on\n // a healthy tree the call still falls through to whatever runs next.\n if (L0_IGNORED_TOOLS.has(toolName)) return 'pass';\n if (path.basename(filePath) === CONFIG_FILENAME) return 'pass';\n if (isRootManifest(filePath)) return 'pass';\n if (L0_ALLOW_JS.test(command.trim())) return 'allow';\n return null;\n}\n\n/**\n * Is `filePath` the `package.json` / `pnpm-workspace.yaml` at the ROOT OF A GOVERNED TREE — the only two\n * files the version cure ever edits?\n *\n * AS WIDE AS THE CURE AND NO WIDER. A basename match would put EVERY project, app and library\n * `package.json` on the L0 allowlist, and at L0 that is worse than it sounds: the sh half treats a hit\n * as TERMINAL (`exit 0`, the guard bin never runs), so each of those would be editable under fault\n * D/X/U/K with nothing downstream judging it. BUT IT MUST ADMIT EVERY TREE, not one — a worktree\n * agent's cure edits ITS OWN root manifest, and the shim's `$ROOT` names whichever tree supplied the\n * shim (governingShimRoot's straddle), so neither a basename nor a fixed root is the right test.\n *\n * The test is: its own directory must ALSO hold a `webpieces.config.json`. That file is TRACKED, so the\n * main clone has one and every linked worktree has its own — the same definition `runner.ts` uses\n * (`dirname(configPath)`), without knowing which tree you stand in. A project manifest deep under\n * `packages/` has no config beside it and is excluded. The sh twin is one `[ -f ... ]` test.\n */\n// webpieces-disable no-function-outside-class -- pure fs+path predicate beside isAllowed in the dependency-free shim module\nexport function isRootManifest(filePath: string): boolean {\n if (!MANIFEST_FILENAMES.has(path.basename(filePath))) return false;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return fs.existsSync(path.join(path.dirname(filePath), CONFIG_FILENAME));\n } catch (err: unknown) {\n //const error = toError(err); best-effort on a blocking path: unreadable is NOT a root manifest\n return false;\n }\n}\n"]}
1
+ {"version":3,"file":"l0-decide.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/l0-decide.ts"],"names":[],"mappings":";;;AAmDA,8BAcC;AAmBD,wCASC;;AA7FD,+CAAyB;AACzB,mDAA6B;AAE7B,0DAA0D;AAG1D,iDAAiE;AACjE,mDAAoD;AACpD,yDAAsD;AAEtD;;;;;;;;;;;;GAYG;AACH,4FAA4F;AAC5F,EAAE;AACF,2FAA2F;AAC3F,uGAAuG;AACvG,oGAAoG;AACpG,wGAAwG;AACxG,uGAAuG;AACvG,yFAAyF;AAC5E,QAAA,UAAU,GAAwB,IAAI,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC;AAEjE;;;;;;;;;;;;;;;;GAgBG;AACH,iMAAiM;AACjM,SAAgB,SAAS,CAAC,QAAgB,EAAE,OAAe,EAAE,QAAgB,EAAE,MAAc;IACzF,IAAI,kBAAU,CAAC,GAAG,CAAC,QAAQ,CAAC;QAAE,OAAO,MAAM,CAAC;IAC5C,oGAAoG;IACpG,qEAAqE;IACrE,IAAI,mCAAgB,CAAC,GAAG,CAAC,QAAQ,CAAC;QAAE,OAAO,MAAM,CAAC;IAClD,IAAI,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,KAAK,8BAAe;QAAE,OAAO,MAAM,CAAC;IAC/D,IAAI,cAAc,CAAC,QAAQ,CAAC;QAAE,OAAO,MAAM,CAAC;IAC5C,IAAI,0BAAW,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC;QAAE,OAAO,OAAO,CAAC;IACrD,oGAAoG;IACpG,qGAAqG;IACrG,qGAAqG;IACrG,gGAAgG;IAChG,IAAI,MAAM,KAAK,OAAO,IAAI,iCAAiB,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC;QAAE,OAAO,MAAM,CAAC;IAChF,OAAO,IAAI,CAAC;AAChB,CAAC;AAED;;;;;;;;;;;;;;;GAeG;AACH,4HAA4H;AAC5H,SAAgB,cAAc,CAAC,QAAgB;IAC3C,IAAI,CAAC,iCAAkB,CAAC,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;QAAE,OAAO,KAAK,CAAC;IACnE,8DAA8D;IAC9D,IAAI,CAAC;QACD,OAAO,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE,8BAAe,CAAC,CAAC,CAAC;IAC7E,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,+FAA+F;QAC/F,OAAO,KAAK,CAAC;IACjB,CAAC;AACL,CAAC","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport { CONFIG_FILENAME } from '@webpieces/rules-config';\n\nimport { AiType } from '../core/agent-event';\nimport { L0_ALLOW_JS, MANIFEST_FILENAMES } from './l0-allowlist';\nimport { L0_CODEX_ALLOW_JS } from './l0-codex-read';\nimport { L0_IGNORED_TOOLS } from './l0-ignored-tools';\n\n/**\n * THE DECISION — `isAllowed()`, the ONE question sh and JS both ask, and the two tool-shaped facts it\n * needs that no regex can express.\n *\n * Split out of ./l0-allowlist.ts (which was over the file-size limit) along the seam that was already\n * there: that module is the VOCABULARY — the named cure patterns and the one union built from them —\n * and this is what CONSULTS it. `shim.ts` re-exports both, so there is still ONE name to import L0 by\n * and every existing `from './shim'` import keeps working.\n *\n * The direction of the dependency is the reason for the split: this module imports the allowlist, the\n * allowlist imports nothing from here, and neither imports the rule engine — L0 must decide on a tree\n * too broken to load it.\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 * `CONFIG_FILENAME` stays a basename match on purpose — one per tree; narrowing it is its own question.\n *\n * `aiType` is REQUIRED, and there is no default. The harness is a fact of the call, not a preference,\n * and every caller already has it: the shim scrapes it in POSIX sh (AI_TYPE_SH) and the binary reads it\n * off the raw envelope (detectAiType). A default here would be a second spelling of \"which harness?\" —\n * the one that silently answers `claude-code` for a Codex call and re-creates the deadlock the gated\n * entry exists to remove.\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, aiType: AiType): 'pass' | 'allow' | null {\n if (READ_TOOLS.has(toolName)) return 'pass';\n // Nothing to judge — see L0_IGNORED_TOOLS. `pass`, never `allow`: L0 declines to be terminal, so on\n // a healthy tree the call still falls through to whatever runs next.\n if (L0_IGNORED_TOOLS.has(toolName)) return 'pass';\n if (path.basename(filePath) === CONFIG_FILENAME) return 'pass';\n if (isRootManifest(filePath)) return 'pass';\n if (L0_ALLOW_JS.test(command.trim())) return 'allow';\n // The HARNESS-GATED tail of the list, and the only place `aiType` is consulted. Codex has no `Read`\n // tool, so a read arrives here as a Bash command; without this it is denied under every L0 fault and\n // a Codex session cannot read the deny that is telling it what to run. `pass`, exactly like the Read\n // entry it twins — see the L0_CODEX_ALLOW_ERE header for why this union is anchored on its own.\n if (aiType === 'codex' && L0_CODEX_ALLOW_JS.test(command.trim())) return 'pass';\n return null;\n}\n\n/**\n * Is `filePath` the `package.json` / `pnpm-workspace.yaml` at the ROOT OF A GOVERNED TREE — the only two\n * files the version cure ever edits?\n *\n * AS WIDE AS THE CURE AND NO WIDER. A basename match would put EVERY project, app and library\n * `package.json` on the L0 allowlist, and at L0 that is worse than it sounds: the sh half treats a hit\n * as TERMINAL (`exit 0`, the guard bin never runs), so each of those would be editable under fault\n * D/X/U/K with nothing downstream judging it. BUT IT MUST ADMIT EVERY TREE, not one — a worktree\n * agent's cure edits ITS OWN root manifest, and the shim's `$ROOT` names whichever tree supplied the\n * shim (governingShimRoot's straddle), so neither a basename nor a fixed root is the right test.\n *\n * The test is: its own directory must ALSO hold a `webpieces.config.json`. That file is TRACKED, so the\n * main clone has one and every linked worktree has its own — the same definition `runner.ts` uses\n * (`dirname(configPath)`), without knowing which tree you stand in. A project manifest deep under\n * `packages/` has no config beside it and is excluded. The sh twin is one `[ -f ... ]` test.\n */\n// webpieces-disable no-function-outside-class -- pure fs+path predicate beside isAllowed in the dependency-free shim module\nexport function isRootManifest(filePath: string): boolean {\n if (!MANIFEST_FILENAMES.has(path.basename(filePath))) return false;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return fs.existsSync(path.join(path.dirname(filePath), CONFIG_FILENAME));\n } catch (err: unknown) {\n //const error = toError(err); best-effort on a blocking path: unreadable is NOT a root manifest\n return false;\n }\n}\n"]}
@@ -68,6 +68,8 @@ exports.SHIM_LOG_VERDICTS = [
68
68
  new ShimLogVerdict('PASS-BIN-BLOCK', 'no sh-side fault; the bin ran and exited 2 — matrix row 1, a LATER layer blocked'),
69
69
  new ShimLogVerdict('ALLOW-READ', 'allowlist entry 1 (any Read) — PASS, but terminal here (the bin never ran)'),
70
70
  new ShimLogVerdict('ALLOW-IGNORED', 'a Codex tool with nothing to judge (L0_IGNORED_TOOLS) — PASS, terminal here'),
71
+ new ShimLogVerdict('ALLOW-CODEX-READ', 'the aiType-gated entry: a read-shaped Bash command on CODEX, which has no Read tool — PASS, terminal here. '
72
+ + 'It cannot appear on a claude-code line; if one ever does, the sh harness test (AI_TYPE_SH) misread the payload'),
71
73
  new ShimLogVerdict('ALLOW-CONFIG', 'allowlist entry 2 (a Write/Edit of webpieces.config.json) — PASS, terminal here'),
72
74
  new ShimLogVerdict('ALLOW-MANIFEST', 'allowlist entry 3 (a Write/Edit of pnpm-workspace.yaml or package.json) — PASS, terminal here'),
73
75
  new ShimLogVerdict('ALLOW-CURE', 'a Bash entry of the allowlist matched — ALLOW'),
@@ -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":"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","sourcesContent":["import { LOGS_STATE_DIR, WORKTREE_STATE_DIR, WEBPIECES_TMP_DIR } from '@webpieces/rules-config';\nimport { L0_SHIM_STREAM } from '../core/log-streams';\nimport { AI_TYPES } from '../core/agent-event';\n\nimport {\n L0_FAULT_NONE, L0_LAYER, L0_SH_FAULT_CODES, L0_ROW_ALLOWLISTED, L0_ROW_BLOCKED, L0_ROW_HANDED_DOWN,\n} from '../core/l0-fault-codes';\n\n// ---------------------------------------------------------------------------\n// THE L0 AUDIT LOG, in POSIX sh — the shim half of\n// `.webpieces/**/logs/L0-shim/<session>-<agent|coordinator>-<binName>.log`. The writer key is\n// the sh twin of ai-hook-rules' LogStream.writerFile(): wp-ai-guards-hook and wp-ai-rules-hook are run\n// IN PARALLEL by Claude Code on every file edit, so an unsplit name means two writers, one file, and\n// torn appends above PIPE_BUF. A payload with no session_id renders 'unknown', never a bare name —\n// there is no un-prefixed spelling on either side.\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/**\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 * One verdict label the shim can record, WITH what it means. Data-only → a class, per CLAUDE.md.\n *\n * The meaning travels with the label because guards/L0-tooling.md renders this table rather than\n * restating it: a bare `string[]` left the meanings in prose, and the prose is what went stale (the\n * hand-written doc documented `DENY-UNDECLARED` for releases while this array did not list it at all).\n */\nexport class ShimLogVerdict {\n constructor(\n readonly label: string,\n readonly means: string,\n ) {}\n}\n\n/**\n * The verdict vocabulary one shim invocation can record, and how each maps to guards/L0-tooling.md.\n *\n * The ALLOW-* and DENY-* labels are the ones this log has always used and are kept verbatim, so\n * anything already grepping them keeps working. `PASS-BIN-*` is the healthy case the log used to be\n * silent about, and `DENY-UNDECLARED` is fault U's — emitted by the shim since U existed, but missing\n * from this array until the generated doc started reading it.\n */\nexport const SHIM_LOG_VERDICTS: readonly ShimLogVerdict[] = [\n new ShimLogVerdict('PASS-BIN-ALLOW', 'no sh-side fault; the bin ran and exited 0 — matrix row 1, handed down to L1'),\n new ShimLogVerdict('PASS-BIN-BLOCK', 'no sh-side fault; the bin ran and exited 2 — matrix row 1, a LATER layer blocked'),\n new ShimLogVerdict('ALLOW-READ', 'allowlist entry 1 (any Read) — PASS, but terminal here (the bin never ran)'),\n new ShimLogVerdict('ALLOW-IGNORED', 'a Codex tool with nothing to judge (L0_IGNORED_TOOLS) — PASS, terminal here'),\n new ShimLogVerdict('ALLOW-CONFIG', 'allowlist entry 2 (a Write/Edit of webpieces.config.json) — PASS, terminal here'),\n new ShimLogVerdict('ALLOW-MANIFEST', 'allowlist entry 3 (a Write/Edit of pnpm-workspace.yaml or package.json) — PASS, terminal here'),\n new ShimLogVerdict('ALLOW-CURE', 'a Bash entry of the allowlist matched — ALLOW'),\n new ShimLogVerdict('DENY', 'fault X, not on the allowlist — BLOCK_AI_CURE'),\n new ShimLogVerdict('DENY-UNDECLARED', 'fault U, not on the allowlist — BLOCK_AI_CURE'),\n new ShimLogVerdict('DENY-STALE', 'fault D, not on the allowlist — BLOCK_AI_CURE'),\n new ShimLogVerdict('DENY-BROKEN', 'fault K, not on the allowlist — BLOCK_AI_CURE'),\n];\n\n/**\n * The sh-side L0 fault codes, IMPORTED from the one codebook (../core/l0-fault-codes) rather than\n * retyped here — the letters in this file and the letters in `L0_FAULTS` have to be the same letters or\n * the log cannot be reconciled against the matrix. `-` means \"no sh-side fault\": the shim cannot\n * classify S / C / Y, which the BINARY detects and stamps onto its OWN streams with the same `fault=`\n * field, so a `-` here is a statement about this layer only, never a claim that nothing was wrong.\n */\nexport const SHIM_LOG_FAULTS = [...L0_SH_FAULT_CODES, L0_FAULT_NONE] as const;\n\n/**\n * One FIELD of the audit line: how it reads on disk, the sh expression that produces it, and what it\n * answers. Data-only → a class, per CLAUDE.md.\n */\nexport class ShimLogField {\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n readonly label: string,\n /** The sh word spliced into the printf below — the ONE place this field's value is spelled. */\n readonly shValue: string,\n readonly means: string,\n /**\n * True for a field that is printed only SOMETIMES (`bin=`, and only when it differs from\n * `shim=`). Such a field carries its OWN trailing tab in its sh value and therefore renders\n * with NO separator of its own — `%s%s` glues it to the next field, so an empty value leaves\n * the line one field shorter rather than leaving a stray tab behind.\n */\n readonly optional: boolean = false,\n ) {}\n}\n\n/**\n * THE LINE, as data. The printf below is BUILT from this array and guards/L0-tooling.md RENDERS it, so\n * a field cannot be added, dropped or reordered without both the shim and the doc changing with it.\n *\n * That is not decoration: `shim=`/`bin=` were inserted mid-line (deliberately breaking positional\n * readers rather than appending where a stale parser keeps working), then `layer=`/`row=` joined them,\n * and the hand-written doc went on describing a 7-field line with no `U` in its fault set the whole time.\n */\nexport const SHIM_LOG_FIELDS: readonly ShimLogField[] = [\n new ShimLogField('<iso-ts>', `\"$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null)\"`,\n 'when the shim judged the call, local time with offset'),\n new ShimLogField('<bin-name>', '\"$BIN_NAME\"',\n 'WHICH hook ran - wp-ai-guards-hook or wp-ai-rules-hook; Claude Code runs them in parallel'),\n new ShimLogField('<tool>', '\"$TOOL\"', 'the PreToolUse tool name (Bash, Read, Write, Edit, apply_patch, …)'),\n // WHICH HARNESS. Inserted MID-LINE rather than appended, which is this format's house style and is\n // deliberate: a positional reader that has not been updated fails loudly here instead of silently\n // reading the wrong column forever (see this array's own header). The values are the `AiType`\n // union's, produced by AI_TYPE_SH — one vocabulary across all five streams. A row written before\n // this field existed simply has no `ai=`, which reads as `unknown`; that is a real value, not a\n // compatibility shim.\n new ShimLogField(`ai=<${AI_TYPES.join('|')}>`, '\"ai=$AI\"',\n 'WHICH coding agent made the call, from the one turn_id discriminator (adapters/detect-ai.ts)'),\n new ShimLogField('tree=<name|primary>', '\"tree=$WP_TREE\"',\n 'git\\'s own name for the worktree the CALL was made in, derived from the payload\\'s cwd'),\n new ShimLogField(`layer=${L0_LAYER}`, `\"layer=${L0_LAYER}\"`,\n 'the layer that judged it — constant here, and the first half of the join key a deny cites'),\n new ShimLogField(`row=<${L0_ROW_HANDED_DOWN}|${L0_ROW_ALLOWLISTED}|${L0_ROW_BLOCKED}>`, '\"row=$_wp_row\"',\n 'WHICH row of the three-row matrix this call took, read off the verdict (hand-down / allowlisted / blocked)'),\n new ShimLogField('shim=<root>', '\"shim=$ROOT\"',\n 'WHICH COPY of ai-hook.sh ran, resolved from $0 — against tree= it is the straddle detector'),\n new ShimLogField('bin=<root>', '\"$_wp_bin\"',\n 'WHICH TREE supplied the binary — printed ONLY when it differs from shim=, so its presence IS the borrow',\n true),\n new ShimLogField(`fault=<${SHIM_LOG_FAULTS.join('|')}>`, '\"fault=$1\"',\n 'the sh-side L0 fault, or `-`; S/C/Y are the binary\\'s and are stamped on ITS streams'),\n new ShimLogField('<VERDICT>', '\"$2\"', 'one of the verdict labels below — kept adjacent to the command'),\n new ShimLogField('<command>', '\"$CMD_LOG\"',\n 'the command PREFIX (the audit spelling; the DECISION reads $CMD, which fails closed on a quote)'),\n];\n\n/**\n * The writer's `printf`, assembled from SHIM_LOG_FIELDS — one `%s` per field, in the same order, and a\n * tab after every field EXCEPT an optional one (which carries its own). Retyping either half is what\n * let the format and its documentation disagree, so neither half is retyped anywhere.\n */\nexport const SHIM_LOG_PRINTF =\n `printf '${SHIM_LOG_FIELDS.map(\n (f: ShimLogField, i: number): string => '%s' + (i === SHIM_LOG_FIELDS.length - 1 ? '' : (f.optional ? '' : '\\\\t')),\n ).join('')}\\\\n' `\n + `${SHIM_LOG_FIELDS.map((f: ShimLogField): string => f.shValue).join(' ')} >> \"$_wp_f\"`;\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}\"\n WP_PRIMARY_LOG_DIR=\"$WP_LOG_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 # The PRIMARY clone's log dir, resolved on both branches. A deny that has to tell a human WHERE the\n # audit trail is (the inverse-drift escalation in shim.ts) must be able to name both the tree it is\n # standing in and the primary — a subagent has no reach into the second one, so the deny has to quote\n # that path rather than send anyone to go and look.\n WP_PRIMARY_LOG_DIR=\"$_wp_primary/${WEBPIECES_TMP_DIR}/${LOGS_STATE_DIR}\"\n if [ \"$_wp_gd\" = \"$_wp_cd\" ]; then\n WP_TREE=primary\n WP_LOG_DIR=\"$WP_PRIMARY_LOG_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: SHIM_LOG_FIELDS, tab-separated, append-only — that array IS the format, and SHIM_LOG_PRINTF\n * is built from it, so neither this docblock nor guards/L0-tooling.md can describe a line the shim does\n * not write.\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 sh-side faults the shim 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=\"\"\nWP_PRIMARY_LOG_DIR=\"\"\nWP_TAB=\"$(printf '\\\\t')\" # one real tab, so the OPTIONAL bin= field can carry its own separator\n${RESOLVE_LOG_DIR_SH}\nwp_clean() { # one path segment from an UNTRUSTED payload id — twin of LogStream's segment()\n printf '%s' \"$1\" | tr -c 'A-Za-z0-9._-' '_' | sed -e 's/\\\\.\\\\{2,\\\\}/_/g' -e 's/^\\\\.\\\\{1,\\\\}/_/' | cut -c1-64\n}\nwp_log() { # $1 = L0 fault code (D|X|K|-), $2 = verdict label\n {\n [ -n \"$WP_LOG_DIR\" ] || wp_resolve_log_dir\n # The LAYER is the directory and the WRITER is the file — same layout the TS writers use, spelled\n # from the same constant so the two halves cannot drift apart.\n _wp_sd=\"$WP_LOG_DIR/${L0_SHIM_STREAM}\"\n mkdir -p \"$_wp_sd\" 2>/dev/null || return 0\n # Same writer key as LogStream.writerFile(): <session>-<agent|coordinator>-<hook>.log. $BIN_NAME\n # IS the hook discriminator here (wp-ai-guards-hook vs wp-ai-rules-hook), and Claude Code runs those\n # two IN PARALLEL on every file edit — without this prefix they append to ONE file and tear above\n # PIPE_BUF. An empty session id renders 'unknown' — this has no bare-name branch, matching\n # LogStream.writerFile(), which has none either.\n # ALWAYS prefixed - a missing session_id renders as 'unknown', never as the shared bare name.\n # Gating this on a non-empty id would drop both parallel hooks back onto one file, which is the\n # torn-append case this exists to remove. Twin of LogStream.writerFile(), which has no bare branch.\n _wp_pfx=\"$(wp_clean \"\\${WP_SID:-unknown}\")-$(wp_clean \"\\${WP_AID:-coordinator}\")-$BIN_NAME\"\n _wp_f=\"$_wp_sd/\\${_wp_pfx}.log\"\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_sd/\\${_wp_pfx}.1.log\" 2>/dev/null\n # shim= and bin= are the two facts this log could not previously answer, and they are the ones that\n # decide whether a tree was governed by its OWN release or a borrowed one:\n # shim= WHICH COPY OF ai-hook.sh RAN — $ROOT, resolved from $0. The file is TRACKED, so every\n # worktree carries the version at ITS commit; settings.json registers it ABSOLUTE, so the copy\n # that runs is the SESSION ROOT's. Logged rather than assumed, on EVERY line: compared against\n # tree= it is the STRADDLE detector (tree=agent-X shim=<repo> = standing in one tree, judged by\n # another), and that pair varies constantly.\n # bin= WHICH TREE SUPPLIED THE BINARY — $BIN_ROOT, the upward walk's answer.\n #\n # bin= IS PRINTED ONLY WHEN IT DIFFERS FROM shim=, so its mere PRESENCE is the diagnostic (\"the binary\n # came from a different tree than the shim\") instead of ~50 bytes repeated on every line. Measured\n # across 549 logged lines: it differed on 39, every one a worktree agent's first few calls before it\n # ran pnpm install — after that they matched for the rest of that agent's life. And since the hooks\n # went ABSOLUTE, shim= is always the MAIN tree, so the two can now only differ when the main tree\n # itself has no node_modules (a fresh clone before install). ~7% of lines then, near 0% going forward.\n # A unit test asserts the field appears if and only if the roots differ, so it cannot quietly become\n # unconditional noise again.\n _wp_bin=\"\"\n [ \"$BIN_ROOT\" != \"$ROOT\" ] && _wp_bin=\"bin=$BIN_ROOT$WP_TAB\"\n # layer= and row= are the JOIN KEYS, and they are here so the join is REAL rather than promised.\n # Every L0 deny now opens '[<guard>] (layer=L0 fault=<code> row=<n>)' and cites \"the same coordinates\n # the audit line carries\" — which was true of the JS half (MATRIX_L0_BLOCK, via decision-log) and\n # FALSE of this one, which carried 'fault=' alone. Fixing the message instead of the line would have\n # left 'grep 'layer=L0 row=3'' finding one half of L0 and silently missing the other four faults.\n #\n # 'row=' is NOT a constant: it is the row of the three-row matrix this call actually took, read off\n # the verdict — hand-down, allowlisted, or blocked — exactly as L1 logs 'row=' from L1_ROWS. That is\n # what distinguishes it from the ~50 constant bytes 'bin=' used to spend above.\n _wp_row=${L0_ROW_HANDED_DOWN}\n case \"$2\" in ALLOW*) _wp_row=${L0_ROW_ALLOWLISTED} ;; DENY*) _wp_row=${L0_ROW_BLOCKED} ;; esac\n ${SHIM_LOG_PRINTF}\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":"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","sourcesContent":["import { LOGS_STATE_DIR, WORKTREE_STATE_DIR, WEBPIECES_TMP_DIR } from '@webpieces/rules-config';\nimport { L0_SHIM_STREAM } from '../core/log-streams';\nimport { AI_TYPES } from '../core/agent-event';\n\nimport {\n L0_FAULT_NONE, L0_LAYER, L0_SH_FAULT_CODES, L0_ROW_ALLOWLISTED, L0_ROW_BLOCKED, L0_ROW_HANDED_DOWN,\n} from '../core/l0-fault-codes';\n\n// ---------------------------------------------------------------------------\n// THE L0 AUDIT LOG, in POSIX sh — the shim half of\n// `.webpieces/**/logs/L0-shim/<session>-<agent|coordinator>-<binName>.log`. The writer key is\n// the sh twin of ai-hook-rules' LogStream.writerFile(): wp-ai-guards-hook and wp-ai-rules-hook are run\n// IN PARALLEL by Claude Code on every file edit, so an unsplit name means two writers, one file, and\n// torn appends above PIPE_BUF. A payload with no session_id renders 'unknown', never a bare name —\n// there is no un-prefixed spelling on either side.\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/**\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 * One verdict label the shim can record, WITH what it means. Data-only → a class, per CLAUDE.md.\n *\n * The meaning travels with the label because guards/L0-tooling.md renders this table rather than\n * restating it: a bare `string[]` left the meanings in prose, and the prose is what went stale (the\n * hand-written doc documented `DENY-UNDECLARED` for releases while this array did not list it at all).\n */\nexport class ShimLogVerdict {\n constructor(\n readonly label: string,\n readonly means: string,\n ) {}\n}\n\n/**\n * The verdict vocabulary one shim invocation can record, and how each maps to guards/L0-tooling.md.\n *\n * The ALLOW-* and DENY-* labels are the ones this log has always used and are kept verbatim, so\n * anything already grepping them keeps working. `PASS-BIN-*` is the healthy case the log used to be\n * silent about, and `DENY-UNDECLARED` is fault U's — emitted by the shim since U existed, but missing\n * from this array until the generated doc started reading it.\n */\nexport const SHIM_LOG_VERDICTS: readonly ShimLogVerdict[] = [\n new ShimLogVerdict('PASS-BIN-ALLOW', 'no sh-side fault; the bin ran and exited 0 — matrix row 1, handed down to L1'),\n new ShimLogVerdict('PASS-BIN-BLOCK', 'no sh-side fault; the bin ran and exited 2 — matrix row 1, a LATER layer blocked'),\n new ShimLogVerdict('ALLOW-READ', 'allowlist entry 1 (any Read) — PASS, but terminal here (the bin never ran)'),\n new ShimLogVerdict('ALLOW-IGNORED', 'a Codex tool with nothing to judge (L0_IGNORED_TOOLS) — PASS, terminal here'),\n new ShimLogVerdict('ALLOW-CODEX-READ',\n 'the aiType-gated entry: a read-shaped Bash command on CODEX, which has no Read tool — PASS, terminal here. '\n + 'It cannot appear on a claude-code line; if one ever does, the sh harness test (AI_TYPE_SH) misread the payload'),\n new ShimLogVerdict('ALLOW-CONFIG', 'allowlist entry 2 (a Write/Edit of webpieces.config.json) — PASS, terminal here'),\n new ShimLogVerdict('ALLOW-MANIFEST', 'allowlist entry 3 (a Write/Edit of pnpm-workspace.yaml or package.json) — PASS, terminal here'),\n new ShimLogVerdict('ALLOW-CURE', 'a Bash entry of the allowlist matched — ALLOW'),\n new ShimLogVerdict('DENY', 'fault X, not on the allowlist — BLOCK_AI_CURE'),\n new ShimLogVerdict('DENY-UNDECLARED', 'fault U, not on the allowlist — BLOCK_AI_CURE'),\n new ShimLogVerdict('DENY-STALE', 'fault D, not on the allowlist — BLOCK_AI_CURE'),\n new ShimLogVerdict('DENY-BROKEN', 'fault K, not on the allowlist — BLOCK_AI_CURE'),\n];\n\n/**\n * The sh-side L0 fault codes, IMPORTED from the one codebook (../core/l0-fault-codes) rather than\n * retyped here — the letters in this file and the letters in `L0_FAULTS` have to be the same letters or\n * the log cannot be reconciled against the matrix. `-` means \"no sh-side fault\": the shim cannot\n * classify S / C / Y, which the BINARY detects and stamps onto its OWN streams with the same `fault=`\n * field, so a `-` here is a statement about this layer only, never a claim that nothing was wrong.\n */\nexport const SHIM_LOG_FAULTS = [...L0_SH_FAULT_CODES, L0_FAULT_NONE] as const;\n\n/**\n * One FIELD of the audit line: how it reads on disk, the sh expression that produces it, and what it\n * answers. Data-only → a class, per CLAUDE.md.\n */\nexport class ShimLogField {\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n readonly label: string,\n /** The sh word spliced into the printf below — the ONE place this field's value is spelled. */\n readonly shValue: string,\n readonly means: string,\n /**\n * True for a field that is printed only SOMETIMES (`bin=`, and only when it differs from\n * `shim=`). Such a field carries its OWN trailing tab in its sh value and therefore renders\n * with NO separator of its own — `%s%s` glues it to the next field, so an empty value leaves\n * the line one field shorter rather than leaving a stray tab behind.\n */\n readonly optional: boolean = false,\n ) {}\n}\n\n/**\n * THE LINE, as data. The printf below is BUILT from this array and guards/L0-tooling.md RENDERS it, so\n * a field cannot be added, dropped or reordered without both the shim and the doc changing with it.\n *\n * That is not decoration: `shim=`/`bin=` were inserted mid-line (deliberately breaking positional\n * readers rather than appending where a stale parser keeps working), then `layer=`/`row=` joined them,\n * and the hand-written doc went on describing a 7-field line with no `U` in its fault set the whole time.\n */\nexport const SHIM_LOG_FIELDS: readonly ShimLogField[] = [\n new ShimLogField('<iso-ts>', `\"$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null)\"`,\n 'when the shim judged the call, local time with offset'),\n new ShimLogField('<bin-name>', '\"$BIN_NAME\"',\n 'WHICH hook ran - wp-ai-guards-hook or wp-ai-rules-hook; Claude Code runs them in parallel'),\n new ShimLogField('<tool>', '\"$TOOL\"', 'the PreToolUse tool name (Bash, Read, Write, Edit, apply_patch, …)'),\n // WHICH HARNESS. Inserted MID-LINE rather than appended, which is this format's house style and is\n // deliberate: a positional reader that has not been updated fails loudly here instead of silently\n // reading the wrong column forever (see this array's own header). The values are the `AiType`\n // union's, produced by AI_TYPE_SH — one vocabulary across all five streams. A row written before\n // this field existed simply has no `ai=`, which reads as `unknown`; that is a real value, not a\n // compatibility shim.\n new ShimLogField(`ai=<${AI_TYPES.join('|')}>`, '\"ai=$AI\"',\n 'WHICH coding agent made the call, from the one turn_id discriminator (adapters/detect-ai.ts)'),\n new ShimLogField('tree=<name|primary>', '\"tree=$WP_TREE\"',\n 'git\\'s own name for the worktree the CALL was made in, derived from the payload\\'s cwd'),\n new ShimLogField(`layer=${L0_LAYER}`, `\"layer=${L0_LAYER}\"`,\n 'the layer that judged it — constant here, and the first half of the join key a deny cites'),\n new ShimLogField(`row=<${L0_ROW_HANDED_DOWN}|${L0_ROW_ALLOWLISTED}|${L0_ROW_BLOCKED}>`, '\"row=$_wp_row\"',\n 'WHICH row of the three-row matrix this call took, read off the verdict (hand-down / allowlisted / blocked)'),\n new ShimLogField('shim=<root>', '\"shim=$ROOT\"',\n 'WHICH COPY of ai-hook.sh ran, resolved from $0 — against tree= it is the straddle detector'),\n new ShimLogField('bin=<root>', '\"$_wp_bin\"',\n 'WHICH TREE supplied the binary — printed ONLY when it differs from shim=, so its presence IS the borrow',\n true),\n new ShimLogField(`fault=<${SHIM_LOG_FAULTS.join('|')}>`, '\"fault=$1\"',\n 'the sh-side L0 fault, or `-`; S/C/Y are the binary\\'s and are stamped on ITS streams'),\n new ShimLogField('<VERDICT>', '\"$2\"', 'one of the verdict labels below — kept adjacent to the command'),\n new ShimLogField('<command>', '\"$CMD_LOG\"',\n 'the command PREFIX (the audit spelling; the DECISION reads $CMD, which fails closed on a quote)'),\n];\n\n/**\n * The writer's `printf`, assembled from SHIM_LOG_FIELDS — one `%s` per field, in the same order, and a\n * tab after every field EXCEPT an optional one (which carries its own). Retyping either half is what\n * let the format and its documentation disagree, so neither half is retyped anywhere.\n */\nexport const SHIM_LOG_PRINTF =\n `printf '${SHIM_LOG_FIELDS.map(\n (f: ShimLogField, i: number): string => '%s' + (i === SHIM_LOG_FIELDS.length - 1 ? '' : (f.optional ? '' : '\\\\t')),\n ).join('')}\\\\n' `\n + `${SHIM_LOG_FIELDS.map((f: ShimLogField): string => f.shValue).join(' ')} >> \"$_wp_f\"`;\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}\"\n WP_PRIMARY_LOG_DIR=\"$WP_LOG_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 # The PRIMARY clone's log dir, resolved on both branches. A deny that has to tell a human WHERE the\n # audit trail is (the inverse-drift escalation in shim.ts) must be able to name both the tree it is\n # standing in and the primary — a subagent has no reach into the second one, so the deny has to quote\n # that path rather than send anyone to go and look.\n WP_PRIMARY_LOG_DIR=\"$_wp_primary/${WEBPIECES_TMP_DIR}/${LOGS_STATE_DIR}\"\n if [ \"$_wp_gd\" = \"$_wp_cd\" ]; then\n WP_TREE=primary\n WP_LOG_DIR=\"$WP_PRIMARY_LOG_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: SHIM_LOG_FIELDS, tab-separated, append-only — that array IS the format, and SHIM_LOG_PRINTF\n * is built from it, so neither this docblock nor guards/L0-tooling.md can describe a line the shim does\n * not write.\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 sh-side faults the shim 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=\"\"\nWP_PRIMARY_LOG_DIR=\"\"\nWP_TAB=\"$(printf '\\\\t')\" # one real tab, so the OPTIONAL bin= field can carry its own separator\n${RESOLVE_LOG_DIR_SH}\nwp_clean() { # one path segment from an UNTRUSTED payload id — twin of LogStream's segment()\n printf '%s' \"$1\" | tr -c 'A-Za-z0-9._-' '_' | sed -e 's/\\\\.\\\\{2,\\\\}/_/g' -e 's/^\\\\.\\\\{1,\\\\}/_/' | cut -c1-64\n}\nwp_log() { # $1 = L0 fault code (D|X|K|-), $2 = verdict label\n {\n [ -n \"$WP_LOG_DIR\" ] || wp_resolve_log_dir\n # The LAYER is the directory and the WRITER is the file — same layout the TS writers use, spelled\n # from the same constant so the two halves cannot drift apart.\n _wp_sd=\"$WP_LOG_DIR/${L0_SHIM_STREAM}\"\n mkdir -p \"$_wp_sd\" 2>/dev/null || return 0\n # Same writer key as LogStream.writerFile(): <session>-<agent|coordinator>-<hook>.log. $BIN_NAME\n # IS the hook discriminator here (wp-ai-guards-hook vs wp-ai-rules-hook), and Claude Code runs those\n # two IN PARALLEL on every file edit — without this prefix they append to ONE file and tear above\n # PIPE_BUF. An empty session id renders 'unknown' — this has no bare-name branch, matching\n # LogStream.writerFile(), which has none either.\n # ALWAYS prefixed - a missing session_id renders as 'unknown', never as the shared bare name.\n # Gating this on a non-empty id would drop both parallel hooks back onto one file, which is the\n # torn-append case this exists to remove. Twin of LogStream.writerFile(), which has no bare branch.\n _wp_pfx=\"$(wp_clean \"\\${WP_SID:-unknown}\")-$(wp_clean \"\\${WP_AID:-coordinator}\")-$BIN_NAME\"\n _wp_f=\"$_wp_sd/\\${_wp_pfx}.log\"\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_sd/\\${_wp_pfx}.1.log\" 2>/dev/null\n # shim= and bin= are the two facts this log could not previously answer, and they are the ones that\n # decide whether a tree was governed by its OWN release or a borrowed one:\n # shim= WHICH COPY OF ai-hook.sh RAN — $ROOT, resolved from $0. The file is TRACKED, so every\n # worktree carries the version at ITS commit; settings.json registers it ABSOLUTE, so the copy\n # that runs is the SESSION ROOT's. Logged rather than assumed, on EVERY line: compared against\n # tree= it is the STRADDLE detector (tree=agent-X shim=<repo> = standing in one tree, judged by\n # another), and that pair varies constantly.\n # bin= WHICH TREE SUPPLIED THE BINARY — $BIN_ROOT, the upward walk's answer.\n #\n # bin= IS PRINTED ONLY WHEN IT DIFFERS FROM shim=, so its mere PRESENCE is the diagnostic (\"the binary\n # came from a different tree than the shim\") instead of ~50 bytes repeated on every line. Measured\n # across 549 logged lines: it differed on 39, every one a worktree agent's first few calls before it\n # ran pnpm install — after that they matched for the rest of that agent's life. And since the hooks\n # went ABSOLUTE, shim= is always the MAIN tree, so the two can now only differ when the main tree\n # itself has no node_modules (a fresh clone before install). ~7% of lines then, near 0% going forward.\n # A unit test asserts the field appears if and only if the roots differ, so it cannot quietly become\n # unconditional noise again.\n _wp_bin=\"\"\n [ \"$BIN_ROOT\" != \"$ROOT\" ] && _wp_bin=\"bin=$BIN_ROOT$WP_TAB\"\n # layer= and row= are the JOIN KEYS, and they are here so the join is REAL rather than promised.\n # Every L0 deny now opens '[<guard>] (layer=L0 fault=<code> row=<n>)' and cites \"the same coordinates\n # the audit line carries\" — which was true of the JS half (MATRIX_L0_BLOCK, via decision-log) and\n # FALSE of this one, which carried 'fault=' alone. Fixing the message instead of the line would have\n # left 'grep 'layer=L0 row=3'' finding one half of L0 and silently missing the other four faults.\n #\n # 'row=' is NOT a constant: it is the row of the three-row matrix this call actually took, read off\n # the verdict — hand-down, allowlisted, or blocked — exactly as L1 logs 'row=' from L1_ROWS. That is\n # what distinguishes it from the ~50 constant bytes 'bin=' used to spend above.\n _wp_row=${L0_ROW_HANDED_DOWN}\n case \"$2\" in ALLOW*) _wp_row=${L0_ROW_ALLOWLISTED} ;; DENY*) _wp_row=${L0_ROW_BLOCKED} ;; esac\n ${SHIM_LOG_PRINTF}\n } 2>/dev/null || true\n}`;\n"]}
@@ -4,6 +4,7 @@ exports.shimStaleDenyReason = shimStaleDenyReason;
4
4
  const rules_config_1 = require("@webpieces/rules-config");
5
5
  const l0_fault_codes_1 = require("../core/l0-fault-codes");
6
6
  const l0_allowlist_1 = require("./l0-allowlist");
7
+ const l0_codex_read_1 = require("./l0-codex-read");
7
8
  const managed_env_1 = require("./managed-env");
8
9
  const shim_1 = require("./shim");
9
10
  /**
@@ -169,7 +170,7 @@ class ShimStaleDeny {
169
170
  stillAllowed() {
170
171
  return [
171
172
  'Still allowed while this block is up:',
172
- ' - any Read',
173
+ ` - any Read, and ${l0_codex_read_1.CODEX_READ_STILL_ALLOWED}`,
173
174
  ' - any Write/Edit whose target is webpieces.config.json',
174
175
  ' - every command on the L0 allowlist, including both Fix Options below',
175
176
  ' THIS IS NOT A DEADLOCK: both options are explicitly ALLOWED through, so run one YOURSELF now - do not hand it back to the human. Every OTHER tool call is blocked until every managed surface matches again.',
@@ -1 +1 @@
1
- {"version":3,"file":"shim-deny-reason.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/shim-deny-reason.ts"],"names":[],"mappings":"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","sourcesContent":["import { claudeEnv } from '@webpieces/rules-config';\n\nimport { L0_FAULT_SHIM_STALE, l0GuardHeader, l0MatrixCitation } from '../core/l0-fault-codes';\nimport { INSTALL_HOOKS_CMD, RESTORE_SHIM_CMD, UPGRADE_SHIM_CMD } from './l0-allowlist';\nimport { BASH_CWD_ENV_KEY, BASH_CWD_ENV_VALUE } from './managed-env';\nimport { NO_CHAINING_RULE, SHIM_MARKER } from './shim';\n\n/**\n * THE FAIL-CLOSED DENY TEXT for a drifted managed hook surface (L0 fault S) — its own module because\n * shim.ts is at its line cap and this is one cohesive unit: the words a blocked agent reads, and\n * nothing else. It imports FROM shim.ts and is never imported BY it, so the graph stays acyclic.\n *\n * IT IS RENDERED IN THE HOUSE FORMAT, the same skeleton core/report.ts (formatReport) gives every L1\n * and L2 deny: a header naming what was blocked, a `[guard-name]` block listing the offenders with a\n * one-line `→ why`, what is still allowed, then numbered `Fix Option N:` lines each with its command\n * on its own line. L0 used to be the ONLY layer in webpieces that answered in one unbroken paragraph —\n * ~3,300 characters of it here — so the two commands that matter were buried in prose and there was no\n * guard name to grep for. Nothing about the DECISION changed; only the shape of the words.\n *\n * SHORTER IS NOT THE GOAL, SCANNABLE IS. The budget below still exists (a paragraph regrows when every\n * new finding argues its case here), but a section that earns a line gets a line.\n *\n * CONSTRAINT: the returned string must contain no `\"` and no `\\` — it is JSON-serialized by denyJson()\n * (a stray quote/backslash would corrupt the PreToolUse decision payload, not just the text). That is an\n * INVARIANT, not a hope, so every interpolated path is STRIPPED of both rather than trusted — a\n * Windows-style path or an odd directory name must not be able to corrupt the decision. Locked by unit\n * tests. An unusual root is also dropped from the `cd` cure rather than quoted (CD_PREFIX would reject it).\n *\n * NEWLINES ARE SAFE HERE AND NEEDED NO NEW MECHANISM. denyJson() runs JSON.stringify, which escapes a\n * real newline to the two-character `\\n` on the wire, and Claude Code's parser turns it back. This is\n * already proven in production — every L1 deny is formatReport()'s multi-line string down this exact\n * path. The sh half of L0 (faults D/X/U/K in renderShim) cannot do this: it printf's REASON into a JSON\n * string literal, so it spells its newlines `${NL}` the same way it spells the ANSI escape `${ESC}`.\n * A real newline is neither a quote nor a backslash, so the JSON-safety assertions above are untouched.\n */\nclass ShimStaleDeny {\n private readonly installedVersion: string;\n /** The governing root, STRIPPED of `\"` and `\\` for display. '' when there is none to name. */\n private readonly safeRoot: string;\n /** CLAUDE_PROJECT_DIR as this process sees it, stripped the same way. */\n private readonly projectDir: string;\n private readonly drifted: readonly string[];\n private readonly inSubagent: boolean;\n /**\n * Whether the RAW root can carry a leading `cd <root> &&`. Tested against the raw root, never the\n * stripped one: stripping is a display-safety measure, and cd-anchoring to a path we just mangled\n * would prescribe a cd into a directory that does not exist. A root CD_PREFIX cannot express is\n * simply not offered as a `cd` (raw ok ⇒ safeRoot === root).\n */\n private readonly cdOk: boolean;\n\n constructor(installedVersion: string, root: string, drifted: readonly string[], inSubagent: boolean) {\n this.installedVersion = installedVersion;\n this.safeRoot = root.replace(/[\"\\\\]/g, '');\n this.projectDir = claudeEnv.projectDirForLog().replace(/[\"\\\\]/g, '');\n this.drifted = drifted;\n this.inSubagent = inSubagent;\n this.cdOk = root !== '' && /^[A-Za-z0-9._/@~+-]+$/.test(root);\n }\n\n render(): string {\n return [\n ...this.header(),\n ...this.measured(),\n ...this.caller(),\n ...this.stillAllowed(),\n ...this.fixOptions(),\n ...this.footer(),\n ].join('\\n');\n }\n\n /**\n * `[managed-hook-surface]` and the surfaces that drifted, one per line.\n *\n * `drifted` names WHICH of the managed things moved — .claude/webpieces/ai-hook.sh, each harness's\n * .claude/settings.json hook registration, and its managed env entry (see hook-registration.ts). It\n * is REQUIRED, not optional: this used to be a shim-only message, and an optional list would let a\n * caller silently keep emitting the one-file text after the surface grew — the \"two spellings of one\n * thing\" shape the compatibility policy rejects. It was FOUR; guarantee-root.sh (L-1) is gone,\n * because the guard hooks are registered ABSOLUTE now and there is no second .sh to keep byte-locked.\n *\n * THE CAUSE IS A LIST. It used to assert flatly \"(it was reverted or hand-edited)\", which is\n * frequently FALSE — the common case is a shim whose logic simply predates this binary — and that\n * false certainty sent a real agent hunting for a tamper that never happened.\n */\n private header(): string[] {\n const verNote = this.installedVersion ? ` (installed version ${this.installedVersion})` : '';\n const n = this.drifted.length;\n const label = n === 1 ? '1 surface drifted' : `${String(n)} surfaces drifted`;\n return [\n '❌ webpieces ai-hooks blocked this call: a webpieces-managed hook surface no longer matches the installed guard binary.',\n '',\n l0GuardHeader(L0_FAULT_SHIM_STALE, label),\n ...this.drifted.map((surface: string): string => ` ${surface}`),\n ` → webpieces manages those THREE things as ONE set, GENERATED by the INSTALLED @webpieces/ai-hook-rules${verNote}; what is on disk is reverted, hand-edited, or predating this binary. The env entry is ${BASH_CWD_ENV_KEY}=${BASH_CWD_ENV_VALUE}, which pins the Bash cwd to the project root, identically for every subagent because settings env is inherited.`,\n ` → ${l0MatrixCitation(L0_FAULT_SHIM_STALE)}`,\n '',\n ];\n }\n\n /**\n * WHERE IT WAS MEASURED, in the deny itself and not only in the logs. #574 put `root=` and\n * `projectDir=` on every L1 invocation line (see decision-log / ClaudeEnv.projectDirForLog, whose\n * `<unset>` token keeps \"variable absent\" distinguishable from \"set to empty\"). The log is forensics\n * AFTER the fact; the deny is what a blocked agent reads IN the moment, and the absence of exactly\n * these two fields is what sent a real agent chasing the wrong mechanism for four cures. Same field\n * names on purpose, so the deny text and the log lines grep together.\n *\n * Agreement is the routine case; DISAGREEMENT is the signature of the session-root-vs-cwd split this\n * guard was rewritten to make unconstructible, so it gets said out loud rather than left to inference.\n */\n private measured(): string[] {\n if (this.safeRoot === '') return [];\n return [\n 'Where this was measured:',\n ` root=${this.safeRoot} - the tree whose shim was compared, and the one to repair`,\n ` projectDir=${this.projectDir} - CLAUDE_PROJECT_DIR as this process sees it; <unset> = absent, not set-but-empty`,\n ` → ${this.verdict()}`,\n '',\n ];\n }\n\n private verdict(): string {\n return this.callerIsInTheTree()\n ? 'These two AGREE, so this is the ordinary case - the tree you are in is the tree being judged.'\n : 'These two DISAGREE - the tree being judged is NOT the one CLAUDE_PROJECT_DIR names, so cure the root= tree and do not assume your cwd is it.';\n }\n\n /** True when the tree needing repair is the caller's own tree — the input the caller branch gates on. */\n private callerIsInTheTree(): boolean {\n return this.safeRoot === this.projectDir;\n }\n\n /**\n * THE CALLER-GATED BRANCH. It changes the WORDS ONLY — never the block/allow decision, never which\n * command is printed, and never which tree anything acts on (that is decided from the path, which is\n * why the deleted AgentIdentity class is NOT coming back for anything but message shape; agent\n * identity was measured untrustworthy as a location signal when a worktree agent resumed on the\n * primary clone after its tree was reaped).\n *\n * Two inputs, both already on hand:\n * 1. `inSubagent` — from the payload's `agent_id`, which Claude Code populates ONLY off the main\n * loop (main falls back to the session id, so the field is absent there). `agent_type` is NOT\n * usable: it is always populated and discriminates nothing. REQUIRED for the same reason\n * `drifted` is — an optional flag would let a caller keep emitting the main-loop text.\n * 2. root= vs projectDir= — different means the caller is not standing in the tree to repair.\n *\n * A main agent, and a subagent whose cwd IS the tree, get the cure and nothing else: they can run it,\n * see the result and commit it. A WORKTREE-ISOLATED subagent gets one extra step, and only because it\n * is true — MEASURED 2026-08-11: such an agent CAN run `cd <main> && pnpm exec wp-upgrade-shim` and it\n * works (the harness refuses cross-tree GIT operations, not this), but it can neither verify nor\n * commit the result, because `git -C <main>` is refused. So the escalation is of the COMMIT, not of\n * the repair, and the text must never tell it a local cure cannot work — the older wording asserted\n * exactly that and was false in the window where it fired (measured 2026-08-10: a worktree subagent\n * cured in place and the block lifted, with the deny's own root= naming that worktree).\n *\n * WHAT IS DELIBERATELY GONE: the \"ask the coordinator to run pnpm install so both trees are on the\n * same @webpieces version\" clause. Both hooks are registered ABSOLUTE, so every tree is already\n * judged by MAIN's shim and MAIN's binary — there is no version alignment left to ask for, and\n * asking sends an agent after a non-problem.\n */\n private caller(): string[] {\n // No governing root to name means no `Where this was measured` section either, so there is no\n // root= for this text to point at and nothing to escalate ABOUT. Silence beats a dangling field.\n if (this.safeRoot === '' || !this.inSubagent || this.callerIsInTheTree()) return [];\n return [\n 'You are a SUBAGENT and root= is not the tree you are standing in, so this takes TWO steps:',\n ' 1. Run Fix Option 1 below exactly as printed. It is already anchored to the tree that must change, and a worktree-isolated subagent CAN run it against another tree - that was measured and it works, so never conclude a local cure cannot work.',\n ` 2. Then ESCALATE THE COMMIT, which is the part you cannot do: git -C ${this.safeRoot} is refused here, so you can neither verify nor commit what the cure regenerated. Tell the coordinator to run git status in ${this.safeRoot} and commit the regenerated shim.`,\n '',\n ];\n }\n\n private stillAllowed(): string[] {\n return [\n 'Still allowed while this block is up:',\n ' - any Read',\n ' - any Write/Edit whose target is webpieces.config.json',\n ' - every command on the L0 allowlist, including both Fix Options below',\n ' THIS IS NOT A DEADLOCK: both options are explicitly ALLOWED through, so run one YOURSELF now - do not hand it back to the human. Every OTHER tool call is blocked until every managed surface matches again.',\n '',\n ];\n }\n\n /**\n * The two cures, house-numbered. ORDER IS LOAD-BEARING: wp-upgrade-shim LEADS because it is the only\n * cure that repairs every managed surface, it touches no config and imports only fs/path, so it\n * runs on a tree too broken to load the rule engine. The `cp` stays last as the pre-0.4.408 fallback.\n *\n * Both are anchored with a leading `cd <root> &&` when the root allows it — CD_PREFIX_*_ANCHORED\n * tolerates exactly that one prefix (locked by unit test), and it is what keeps the cure curable when\n * the AI's cwd is a DIFFERENT tree than the one being judged. OPTION 2 is a relative-path `cp`, so it\n * is even MORE cwd-sensitive than OPTION 1 — it is anchored too. Never a SECOND `cd … &&`: the\n * allowlist matches the whole command and three segments are denied.\n */\n private fixOptions(): string[] {\n const anchor = (cmd: string): string => (this.cdOk ? `cd ${this.safeRoot} && ${cmd}` : cmd);\n return [\n ' Fix Option 1: (preferred) the only cure that repairs every managed surface, and it runs on a broken tree',\n ` run EXACTLY: '${anchor(UPGRADE_SHIM_CMD)}'`,\n ` Fix Option 2: PARTIAL - repairs ${SHIM_MARKER} only. Pick it ONLY when the installed @webpieces/ai-hook-rules is older than 0.4.408, where Fix Option 1 does not exist yet; then upgrade and run Fix Option 1.`,\n ` run EXACTLY: '${anchor(RESTORE_SHIM_CMD)}'`,\n ` NOT an option: do NOT use the bare '${INSTALL_HOOKS_CMD}' here - it also migrates your config and PROMPTS for a hook target twice, which hangs a non-interactive session.`,\n '',\n ];\n }\n\n private footer(): string[] {\n return [\n NO_CHAINING_RULE,\n `If you meant to remove @webpieces/ai-hook-rules, delete its hooks from .claude/settings.json rather than reverting ${SHIM_MARKER}.`,\n ];\n }\n}\n\n// The ONE entry point its two call sites (hook-core's fault-S deny, and the L0 fault table in\n// l0-matrix) import. The rendering lives on ShimStaleDeny above, per CLAUDE.md; this is the seam.\n// webpieces-disable no-function-outside-class -- one-line constructor+render seam for ShimStaleDeny, in the dependency-free shim module (it must stay callable from a tree too broken to build a DI container).\nexport function shimStaleDenyReason(installedVersion: string, root: string, drifted: readonly string[], inSubagent: boolean): string {\n return new ShimStaleDeny(installedVersion, root, drifted, inSubagent).render();\n}\n"]}
1
+ {"version":3,"file":"shim-deny-reason.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/shim-deny-reason.ts"],"names":[],"mappings":"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","sourcesContent":["import { claudeEnv } from '@webpieces/rules-config';\n\nimport { L0_FAULT_SHIM_STALE, l0GuardHeader, l0MatrixCitation } from '../core/l0-fault-codes';\nimport { INSTALL_HOOKS_CMD, RESTORE_SHIM_CMD, UPGRADE_SHIM_CMD } from './l0-allowlist';\nimport { CODEX_READ_STILL_ALLOWED } from './l0-codex-read';\nimport { BASH_CWD_ENV_KEY, BASH_CWD_ENV_VALUE } from './managed-env';\nimport { NO_CHAINING_RULE, SHIM_MARKER } from './shim';\n\n/**\n * THE FAIL-CLOSED DENY TEXT for a drifted managed hook surface (L0 fault S) — its own module because\n * shim.ts is at its line cap and this is one cohesive unit: the words a blocked agent reads, and\n * nothing else. It imports FROM shim.ts and is never imported BY it, so the graph stays acyclic.\n *\n * IT IS RENDERED IN THE HOUSE FORMAT, the same skeleton core/report.ts (formatReport) gives every L1\n * and L2 deny: a header naming what was blocked, a `[guard-name]` block listing the offenders with a\n * one-line `→ why`, what is still allowed, then numbered `Fix Option N:` lines each with its command\n * on its own line. L0 used to be the ONLY layer in webpieces that answered in one unbroken paragraph —\n * ~3,300 characters of it here — so the two commands that matter were buried in prose and there was no\n * guard name to grep for. Nothing about the DECISION changed; only the shape of the words.\n *\n * SHORTER IS NOT THE GOAL, SCANNABLE IS. The budget below still exists (a paragraph regrows when every\n * new finding argues its case here), but a section that earns a line gets a line.\n *\n * CONSTRAINT: the returned string must contain no `\"` and no `\\` — it is JSON-serialized by denyJson()\n * (a stray quote/backslash would corrupt the PreToolUse decision payload, not just the text). That is an\n * INVARIANT, not a hope, so every interpolated path is STRIPPED of both rather than trusted — a\n * Windows-style path or an odd directory name must not be able to corrupt the decision. Locked by unit\n * tests. An unusual root is also dropped from the `cd` cure rather than quoted (CD_PREFIX would reject it).\n *\n * NEWLINES ARE SAFE HERE AND NEEDED NO NEW MECHANISM. denyJson() runs JSON.stringify, which escapes a\n * real newline to the two-character `\\n` on the wire, and Claude Code's parser turns it back. This is\n * already proven in production — every L1 deny is formatReport()'s multi-line string down this exact\n * path. The sh half of L0 (faults D/X/U/K in renderShim) cannot do this: it printf's REASON into a JSON\n * string literal, so it spells its newlines `${NL}` the same way it spells the ANSI escape `${ESC}`.\n * A real newline is neither a quote nor a backslash, so the JSON-safety assertions above are untouched.\n */\nclass ShimStaleDeny {\n private readonly installedVersion: string;\n /** The governing root, STRIPPED of `\"` and `\\` for display. '' when there is none to name. */\n private readonly safeRoot: string;\n /** CLAUDE_PROJECT_DIR as this process sees it, stripped the same way. */\n private readonly projectDir: string;\n private readonly drifted: readonly string[];\n private readonly inSubagent: boolean;\n /**\n * Whether the RAW root can carry a leading `cd <root> &&`. Tested against the raw root, never the\n * stripped one: stripping is a display-safety measure, and cd-anchoring to a path we just mangled\n * would prescribe a cd into a directory that does not exist. A root CD_PREFIX cannot express is\n * simply not offered as a `cd` (raw ok ⇒ safeRoot === root).\n */\n private readonly cdOk: boolean;\n\n constructor(installedVersion: string, root: string, drifted: readonly string[], inSubagent: boolean) {\n this.installedVersion = installedVersion;\n this.safeRoot = root.replace(/[\"\\\\]/g, '');\n this.projectDir = claudeEnv.projectDirForLog().replace(/[\"\\\\]/g, '');\n this.drifted = drifted;\n this.inSubagent = inSubagent;\n this.cdOk = root !== '' && /^[A-Za-z0-9._/@~+-]+$/.test(root);\n }\n\n render(): string {\n return [\n ...this.header(),\n ...this.measured(),\n ...this.caller(),\n ...this.stillAllowed(),\n ...this.fixOptions(),\n ...this.footer(),\n ].join('\\n');\n }\n\n /**\n * `[managed-hook-surface]` and the surfaces that drifted, one per line.\n *\n * `drifted` names WHICH of the managed things moved — .claude/webpieces/ai-hook.sh, each harness's\n * .claude/settings.json hook registration, and its managed env entry (see hook-registration.ts). It\n * is REQUIRED, not optional: this used to be a shim-only message, and an optional list would let a\n * caller silently keep emitting the one-file text after the surface grew — the \"two spellings of one\n * thing\" shape the compatibility policy rejects. It was FOUR; guarantee-root.sh (L-1) is gone,\n * because the guard hooks are registered ABSOLUTE now and there is no second .sh to keep byte-locked.\n *\n * THE CAUSE IS A LIST. It used to assert flatly \"(it was reverted or hand-edited)\", which is\n * frequently FALSE — the common case is a shim whose logic simply predates this binary — and that\n * false certainty sent a real agent hunting for a tamper that never happened.\n */\n private header(): string[] {\n const verNote = this.installedVersion ? ` (installed version ${this.installedVersion})` : '';\n const n = this.drifted.length;\n const label = n === 1 ? '1 surface drifted' : `${String(n)} surfaces drifted`;\n return [\n '❌ webpieces ai-hooks blocked this call: a webpieces-managed hook surface no longer matches the installed guard binary.',\n '',\n l0GuardHeader(L0_FAULT_SHIM_STALE, label),\n ...this.drifted.map((surface: string): string => ` ${surface}`),\n ` → webpieces manages those THREE things as ONE set, GENERATED by the INSTALLED @webpieces/ai-hook-rules${verNote}; what is on disk is reverted, hand-edited, or predating this binary. The env entry is ${BASH_CWD_ENV_KEY}=${BASH_CWD_ENV_VALUE}, which pins the Bash cwd to the project root, identically for every subagent because settings env is inherited.`,\n ` → ${l0MatrixCitation(L0_FAULT_SHIM_STALE)}`,\n '',\n ];\n }\n\n /**\n * WHERE IT WAS MEASURED, in the deny itself and not only in the logs. #574 put `root=` and\n * `projectDir=` on every L1 invocation line (see decision-log / ClaudeEnv.projectDirForLog, whose\n * `<unset>` token keeps \"variable absent\" distinguishable from \"set to empty\"). The log is forensics\n * AFTER the fact; the deny is what a blocked agent reads IN the moment, and the absence of exactly\n * these two fields is what sent a real agent chasing the wrong mechanism for four cures. Same field\n * names on purpose, so the deny text and the log lines grep together.\n *\n * Agreement is the routine case; DISAGREEMENT is the signature of the session-root-vs-cwd split this\n * guard was rewritten to make unconstructible, so it gets said out loud rather than left to inference.\n */\n private measured(): string[] {\n if (this.safeRoot === '') return [];\n return [\n 'Where this was measured:',\n ` root=${this.safeRoot} - the tree whose shim was compared, and the one to repair`,\n ` projectDir=${this.projectDir} - CLAUDE_PROJECT_DIR as this process sees it; <unset> = absent, not set-but-empty`,\n ` → ${this.verdict()}`,\n '',\n ];\n }\n\n private verdict(): string {\n return this.callerIsInTheTree()\n ? 'These two AGREE, so this is the ordinary case - the tree you are in is the tree being judged.'\n : 'These two DISAGREE - the tree being judged is NOT the one CLAUDE_PROJECT_DIR names, so cure the root= tree and do not assume your cwd is it.';\n }\n\n /** True when the tree needing repair is the caller's own tree — the input the caller branch gates on. */\n private callerIsInTheTree(): boolean {\n return this.safeRoot === this.projectDir;\n }\n\n /**\n * THE CALLER-GATED BRANCH. It changes the WORDS ONLY — never the block/allow decision, never which\n * command is printed, and never which tree anything acts on (that is decided from the path, which is\n * why the deleted AgentIdentity class is NOT coming back for anything but message shape; agent\n * identity was measured untrustworthy as a location signal when a worktree agent resumed on the\n * primary clone after its tree was reaped).\n *\n * Two inputs, both already on hand:\n * 1. `inSubagent` — from the payload's `agent_id`, which Claude Code populates ONLY off the main\n * loop (main falls back to the session id, so the field is absent there). `agent_type` is NOT\n * usable: it is always populated and discriminates nothing. REQUIRED for the same reason\n * `drifted` is — an optional flag would let a caller keep emitting the main-loop text.\n * 2. root= vs projectDir= — different means the caller is not standing in the tree to repair.\n *\n * A main agent, and a subagent whose cwd IS the tree, get the cure and nothing else: they can run it,\n * see the result and commit it. A WORKTREE-ISOLATED subagent gets one extra step, and only because it\n * is true — MEASURED 2026-08-11: such an agent CAN run `cd <main> && pnpm exec wp-upgrade-shim` and it\n * works (the harness refuses cross-tree GIT operations, not this), but it can neither verify nor\n * commit the result, because `git -C <main>` is refused. So the escalation is of the COMMIT, not of\n * the repair, and the text must never tell it a local cure cannot work — the older wording asserted\n * exactly that and was false in the window where it fired (measured 2026-08-10: a worktree subagent\n * cured in place and the block lifted, with the deny's own root= naming that worktree).\n *\n * WHAT IS DELIBERATELY GONE: the \"ask the coordinator to run pnpm install so both trees are on the\n * same @webpieces version\" clause. Both hooks are registered ABSOLUTE, so every tree is already\n * judged by MAIN's shim and MAIN's binary — there is no version alignment left to ask for, and\n * asking sends an agent after a non-problem.\n */\n private caller(): string[] {\n // No governing root to name means no `Where this was measured` section either, so there is no\n // root= for this text to point at and nothing to escalate ABOUT. Silence beats a dangling field.\n if (this.safeRoot === '' || !this.inSubagent || this.callerIsInTheTree()) return [];\n return [\n 'You are a SUBAGENT and root= is not the tree you are standing in, so this takes TWO steps:',\n ' 1. Run Fix Option 1 below exactly as printed. It is already anchored to the tree that must change, and a worktree-isolated subagent CAN run it against another tree - that was measured and it works, so never conclude a local cure cannot work.',\n ` 2. Then ESCALATE THE COMMIT, which is the part you cannot do: git -C ${this.safeRoot} is refused here, so you can neither verify nor commit what the cure regenerated. Tell the coordinator to run git status in ${this.safeRoot} and commit the regenerated shim.`,\n '',\n ];\n }\n\n private stillAllowed(): string[] {\n return [\n 'Still allowed while this block is up:',\n ` - any Read, and ${CODEX_READ_STILL_ALLOWED}`,\n ' - any Write/Edit whose target is webpieces.config.json',\n ' - every command on the L0 allowlist, including both Fix Options below',\n ' THIS IS NOT A DEADLOCK: both options are explicitly ALLOWED through, so run one YOURSELF now - do not hand it back to the human. Every OTHER tool call is blocked until every managed surface matches again.',\n '',\n ];\n }\n\n /**\n * The two cures, house-numbered. ORDER IS LOAD-BEARING: wp-upgrade-shim LEADS because it is the only\n * cure that repairs every managed surface, it touches no config and imports only fs/path, so it\n * runs on a tree too broken to load the rule engine. The `cp` stays last as the pre-0.4.408 fallback.\n *\n * Both are anchored with a leading `cd <root> &&` when the root allows it — CD_PREFIX_*_ANCHORED\n * tolerates exactly that one prefix (locked by unit test), and it is what keeps the cure curable when\n * the AI's cwd is a DIFFERENT tree than the one being judged. OPTION 2 is a relative-path `cp`, so it\n * is even MORE cwd-sensitive than OPTION 1 — it is anchored too. Never a SECOND `cd … &&`: the\n * allowlist matches the whole command and three segments are denied.\n */\n private fixOptions(): string[] {\n const anchor = (cmd: string): string => (this.cdOk ? `cd ${this.safeRoot} && ${cmd}` : cmd);\n return [\n ' Fix Option 1: (preferred) the only cure that repairs every managed surface, and it runs on a broken tree',\n ` run EXACTLY: '${anchor(UPGRADE_SHIM_CMD)}'`,\n ` Fix Option 2: PARTIAL - repairs ${SHIM_MARKER} only. Pick it ONLY when the installed @webpieces/ai-hook-rules is older than 0.4.408, where Fix Option 1 does not exist yet; then upgrade and run Fix Option 1.`,\n ` run EXACTLY: '${anchor(RESTORE_SHIM_CMD)}'`,\n ` NOT an option: do NOT use the bare '${INSTALL_HOOKS_CMD}' here - it also migrates your config and PROMPTS for a hook target twice, which hangs a non-interactive session.`,\n '',\n ];\n }\n\n private footer(): string[] {\n return [\n NO_CHAINING_RULE,\n `If you meant to remove @webpieces/ai-hook-rules, delete its hooks from .claude/settings.json rather than reverting ${SHIM_MARKER}.`,\n ];\n }\n}\n\n// The ONE entry point its two call sites (hook-core's fault-S deny, and the L0 fault table in\n// l0-matrix) import. The rendering lives on ShimStaleDeny above, per CLAUDE.md; this is the seam.\n// webpieces-disable no-function-outside-class -- one-line constructor+render seam for ShimStaleDeny, in the dependency-free shim module (it must stay callable from a tree too broken to build a DI container).\nexport function shimStaleDenyReason(installedVersion: string, root: string, drifted: readonly string[], inSubagent: boolean): string {\n return new ShimStaleDeny(installedVersion, root, drifted, inSubagent).render();\n}\n"]}
package/src/bin/shim.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from './l0-allowlist';
2
2
  export * from './l0-decide';
3
3
  export * from './l0-ignored-tools';
4
+ export * from './l0-codex-read';
4
5
  export * from './shim-audit-log';
5
6
  export declare const SHIM_MARKER = ".claude/webpieces/ai-hook.sh";
6
7
  export declare function shimPath(projectRoot: string): string;
package/src/bin/shim.js CHANGED
@@ -16,6 +16,7 @@ const rules_config_1 = require("@webpieces/rules-config");
16
16
  const l0_fault_codes_1 = require("../core/l0-fault-codes");
17
17
  const to_error_1 = require("../core/to-error");
18
18
  const l0_allowlist_1 = require("./l0-allowlist");
19
+ const l0_codex_read_1 = require("./l0-codex-read");
19
20
  const l0_ignored_tools_1 = require("./l0-ignored-tools");
20
21
  const detect_ai_1 = require("../adapters/detect-ai");
21
22
  const shim_audit_log_1 = require("./shim-audit-log");
@@ -28,6 +29,9 @@ tslib_1.__exportStar(require("./l0-allowlist"), exports);
28
29
  // READ_TOOLS) and the tool-shaped set it consults. ONE name to import L0 by, still.
29
30
  tslib_1.__exportStar(require("./l0-decide"), exports);
30
31
  tslib_1.__exportStar(require("./l0-ignored-tools"), exports);
32
+ // …and the harness-GATED half, split out for the same size reason: it owns the read-shaped vocabulary
33
+ // and the Codex-only union built from it. ONE name to import L0 by, still.
34
+ tslib_1.__exportStar(require("./l0-codex-read"), exports);
31
35
  // Same treatment for the audit-log fragment: one name to import the whole rendered shim by.
32
36
  tslib_1.__exportStar(require("./shim-audit-log"), exports);
33
37
  // ---------------------------------------------------------------------------
@@ -139,7 +143,7 @@ fi`;
139
143
  const ESCAPES_SH = `BS='\\' # one literal backslash, so no \\u001b / \\n escape sits in this source
140
144
  ESC="\${BS}u001b" # the 6 chars: backslash u 0 0 1 b — Claude Code parses \\u001b → ESC
141
145
  NL="\${BS}n" # the 2 chars: backslash n — parsed as a real newline inside the JSON string
142
- WP_STILL_ALLOWED="Still allowed while this block is up:\${NL} - any Read\${NL} - any Write/Edit whose target is ${rules_config_1.CONFIG_FILENAME}, ${l0_allowlist_1.WORKSPACE_MANIFEST} or ${l0_allowlist_1.PACKAGE_MANIFEST}\${NL} - every command on the L0 allowlist, including the Fix Options below\${NL} THIS IS NOT A DEADLOCK - run one YOURSELF now; do not hand it back to the human."`;
146
+ WP_STILL_ALLOWED="Still allowed while this block is up:\${NL} - any Read, and ${l0_codex_read_1.CODEX_READ_STILL_ALLOWED}\${NL} - any Write/Edit whose target is ${rules_config_1.CONFIG_FILENAME}, ${l0_allowlist_1.WORKSPACE_MANIFEST} or ${l0_allowlist_1.PACKAGE_MANIFEST}\${NL} - every command on the L0 allowlist, including the Fix Options below\${NL} THIS IS NOT A DEADLOCK - run one YOURSELF now; do not hand it back to the human."`;
143
147
  // Shell fragment: run the installed guard bin and INSPECT its outcome, instead of exec'ing it.
144
148
  //
145
149
  // THE BUG THIS FIXES (guards silently fail-OPEN): the shim used to `exec "$BIN"`. exec REPLACES this
@@ -279,6 +283,17 @@ if printf '%s' "\$CMD" | grep -Eq '${l0_allowlist_1.L0_ALLOW_ERE_SH}'; then
279
283
  wp_log "\$WP_FAULT" ALLOW-CURE # record the self-heal we let through (re-enables the guards)
280
284
  exit 0 # allow the cure so the assistant can break the deadlock
281
285
  fi
286
+ # THE HARNESS-GATED TAIL OF THE SAME LIST, and the ONLY place \$AI changes a decision. Under Claude Code
287
+ # the guard is false and the next line is the deny — byte for byte the path a Claude payload took before
288
+ # this branch existed. Under Codex it is the twin of the ALLOW-READ arm above: Codex has NO Read tool, so
289
+ # a read arrives as this Bash command, and without this every L0 fault denies a Codex session the very
290
+ # reads the deny is telling it to perform. The pattern is anchored on its own (no cd prefix, no capture
291
+ # tail) — see L0_CODEX_ALLOW_ERE. TERMINAL, exactly like the Read arm, and for the same reason: on this
292
+ # path the bin never runs, so there is nothing to fall through to.
293
+ if [ "\$AI" = codex ] && printf '%s' "\$CMD" | grep -Eq '${l0_codex_read_1.L0_CODEX_ALLOW_ERE_SH}'; then
294
+ wp_log "\$WP_FAULT" ALLOW-CODEX-READ
295
+ exit 0
296
+ fi
282
297
  wp_log "\$WP_FAULT" "\$DENY_LABEL" # every fail-closed block, with the fault that caused it`;
283
298
  // Shell fragment: emit the deny. FAIL CLOSED via Claude Code's PreToolUse JSON protocol
284
299
  // (permissionDecision "deny" on stdout, then exit 0) rather than a bare "exit 2". BOTH block the call,