@webpieces/ai-hook-rules 0.4.718 → 0.4.719
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/bin/codex-guard-presence.d.ts +36 -10
- package/src/bin/codex-guard-presence.js +64 -24
- package/src/bin/codex-guard-presence.js.map +1 -1
- package/src/bin/l0-allowlist.d.ts +1 -70
- package/src/bin/l0-allowlist.js +33 -102
- package/src/bin/l0-allowlist.js.map +1 -1
- package/src/bin/l0-codex-read.js +2 -2
- package/src/bin/l0-codex-read.js.map +1 -1
- package/src/bin/l0-entry.d.ts +103 -0
- package/src/bin/l0-entry.js +98 -0
- package/src/bin/l0-entry.js.map +1 -0
- package/src/bin/shim-audit-log.js +1 -1
- package/src/bin/shim-audit-log.js.map +1 -1
- package/src/bin/shim.d.ts +1 -0
- package/src/bin/shim.js +1 -0
- package/src/bin/shim.js.map +1 -1
- package/src/index.d.ts +1 -0
- package/src/index.js +8 -1
- package/src/index.js.map +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"l0-entry.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/l0-entry.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;GAUG;;;AAIH;;;;;;;;;GASG;AACU,QAAA,aAAa,GAAG,eAAe,CAAC;AAY7C,uGAAuG;AACvG,MAAa,MAAM;IAEF;IACA;IACA;IAHb,YACa,QAAgB,EAChB,OAAe,EACf,QAAgB;QAFhB,aAAQ,GAAR,QAAQ,CAAQ;QAChB,YAAO,GAAP,OAAO,CAAQ;QACf,aAAQ,GAAR,QAAQ,CAAQ;IAC1B,CAAC;CACP;AAND,wBAMC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAa,YAAY;IAGR;IACA;IAOA;IACA;IACA;IAYA;IACA;IACA;IA1Bb,yDAAyD;IACzD,YACa,KAAa,EACb,IAAsB;IAC/B;;;;;OAKG;IACM,IAAa,EACb,GAAkB,EAClB,EAAiB;IAC1B;;;;;;;;;;OAUG;IACM,OAAkB,EAClB,MAAc,EACd,eAAkC,EAAE;QAxBpC,UAAK,GAAL,KAAK,CAAQ;QACb,SAAI,GAAJ,IAAI,CAAkB;QAOtB,SAAI,GAAJ,IAAI,CAAS;QACb,QAAG,GAAH,GAAG,CAAe;QAClB,OAAE,GAAF,EAAE,CAAe;QAYjB,YAAO,GAAP,OAAO,CAAW;QAClB,WAAM,GAAN,MAAM,CAAQ;QACd,iBAAY,GAAZ,YAAY,CAAwB;IAC9C,CAAC;IAEJ,kFAAkF;IAClF,UAAU;QACN,OAAO,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,IAAI,CAAC,YAAY,CAAC,CAAC;IAC/C,CAAC;CACJ;AAlCD,oCAkCC","sourcesContent":["/**\n * THE L0 ENTRY TYPES — the shape of one allowlist entry, split out of ./l0-allowlist.\n *\n * Same reason `./l0-codex-read` and `./l0-ignored-tools` are their own modules: the LIST is long and\n * every entry carries a paragraph of reasoning, so the types that describe an entry get crowded out\n * by the entries themselves. Nothing here knows what is on the list; `./l0-allowlist` owns that and\n * imports these, so the dependency runs one way and there is no cycle.\n *\n * NOT re-exported from here onward — `./shim` is the ONE name that gathers L0, and it stars this\n * module alongside the others.\n */\n\nimport { AiType } from '../core/agent-event';\n\n/**\n * The token an L0 entry uses to say it serves EVERY harness — SAID OUT LOUD.\n *\n * This used to be `null`, and `null` is shim shape #5 from CLAUDE.md: a widening expressed as an\n * ABSENCE. It made the widest setting the shortest thing to type and, worse, impossible to grep —\n * there is no search that lists \"the entries every harness gets\", because they were identified by\n * a field that was not there. `grep -n EVERY_HARNESS` now lists every one of them.\n *\n * It is a REAL VALUE in exactly the sense `AI_TYPE_UNKNOWN` is: countable, greppable, and typed.\n */\nexport const EVERY_HARNESS = 'every-harness';\n\n/**\n * Who one L0 entry is FOR: exactly one harness, or — named, never implied — all of them.\n *\n * Deliberately NOT `readonly AiType[]`. A list re-opens the hole this closed from the other side:\n * `[]` would be a second way to write \"nobody\", and `['claude-code', 'codex']` a second way to write\n * EVERY_HARNESS — two spellings of one decision, which is shim shape #1. Dean's rule is the whole\n * type: we only support codex or claudecode or FAIL.\n */\nexport type L0Harness = AiType | typeof EVERY_HARNESS;\n\n/** One tool call as L0 judges it: the tool name, the Bash command (or ''), the file target (or ''). */\nexport class L0Call {\n constructor(\n readonly toolName: string,\n readonly command: string,\n readonly filePath: string,\n ) {}\n}\n\n/**\n * One entry of THE L0 allowlist. Data-only → a class, per CLAUDE.md.\n *\n * `ere`/`js` are the twin regex BODIES for a Bash entry, or null for a tool-shaped entry (Read, the\n * webpieces.config.json target) that no regex can express. `sample` is a call this entry must accept —\n * it is what the matrix-coverage and cure-reachability tests drive isAllowed() with.\n *\n * `extraSamples` pins ADDITIONAL spellings the same entry must accept. A spelling that some deny\n * message prescribes belongs here, or nothing stops a later tightening of the pattern from making that\n * message's cure untypable again — which is the deadlock shape this whole module exists to prevent.\n *\n * `cure` is the ONE thing that is not uniform across the list, and it is not about L0 at all — see\n * L0_CURE_ALLOW_JS below. Every entry is judged identically while an L0 fault is up; `cure` decides\n * only whether the entry ALSO bypasses the downstream (L1) guards on a HEALTHY tree.\n */\nexport class L0AllowEntry {\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n readonly label: string,\n readonly kind: 'pass' | 'allow',\n /**\n * True when this entry REPAIRS the tooling (install, sync, shim restore). A cure has to run\n * before webpieces.config.json can even be loaded, so it bypasses everything, always. A\n * non-cure (read-only orientation) is allowed while a fault is up and is otherwise an ordinary\n * command the downstream guards still judge.\n */\n readonly cure: boolean,\n readonly ere: string | null,\n readonly js: string | null,\n /**\n * The ONE harness this entry exists for, or `EVERY_HARNESS` for the entries every harness gets.\n * There is no default and no absence: an entry must say which it is, or it does not compile.\n *\n * A gated entry is unreachable from any other harness — it is spliced into its own union, which\n * both halves of L0 consult only after answering \"which harness sent this call?\". It exists\n * because the two harnesses do not have the same TOOLS: `Read` is entry 1 for Claude Code and\n * Codex has no such tool, so read parity at L0 can only be expressed per harness. Anything added\n * here later must satisfy the property stated on AI_TYPE_SH: the sh half's answer is an\n * approximation, so a gated entry may never grant more than the OTHER harness already has.\n */\n readonly harness: L0Harness,\n readonly sample: L0Call,\n readonly extraSamples: readonly L0Call[] = [],\n ) {}\n\n /** Every call this entry pins: the canonical sample plus every extra spelling. */\n allSamples(): readonly L0Call[] {\n return [this.sample, ...this.extraSamples];\n }\n}\n\n\n"]}
|
|
@@ -68,7 +68,7 @@ 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
|
|
71
|
+
new ShimLogVerdict('ALLOW-CODEX-READ', 'the harness-gated entry: a read-shaped Bash command on CODEX, which has no Read tool — PASS, terminal here. '
|
|
72
72
|
+ 'It cannot appear on a claude-code line; if one ever does, the sh harness test (AI_TYPE_SH) misread the payload'),
|
|
73
73
|
new ShimLogVerdict('ALLOW-CONFIG', 'allowlist entry 2 (a Write/Edit of webpieces.config.json) — PASS, terminal here'),
|
|
74
74
|
new ShimLogVerdict('ALLOW-MANIFEST', 'allowlist entry 3 (a Write/Edit of pnpm-workspace.yaml or package.json) — PASS, terminal here'),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"shim-audit-log.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/shim-audit-log.ts"],"names":[],"mappings":";;;AAAA,0DAAgG;AAChG,qDAAqD;AACrD,qDAA+C;AAE/C,2DAEgC;AAEhC,8EAA8E;AAC9E,mDAAmD;AACnD,8FAA8F;AAC9F,uGAAuG;AACvG,qGAAqG;AACrG,mGAAmG;AACnG,mDAAmD;AACnD,EAAE;AACF,qGAAqG;AACrG,mGAAmG;AACnG,uGAAuG;AACvG,EAAE;AACF,sGAAsG;AACtG,sGAAsG;AACtG,uGAAuG;AACvG,oGAAoG;AACpG,sGAAsG;AACtG,sGAAsG;AACtG,qGAAqG;AACrG,yFAAyF;AACzF,EAAE;AACF,iCAAiC;AACjC,oGAAoG;AACpG,sGAAsG;AACtG,wDAAwD;AACxD,oEAAoE;AACpE,8EAA8E;AAE9E;;;;;GAKG;AACU,QAAA,kBAAkB,GAAG,GAAG,GAAG,IAAI,CAAC;AAE7C;;;;;;GAMG;AACH,MAAa,cAAc;IAEV;IACA;IAFb,YACa,KAAa,EACb,KAAa;QADb,UAAK,GAAL,KAAK,CAAQ;QACb,UAAK,GAAL,KAAK,CAAQ;IACvB,CAAC;CACP;AALD,wCAKC;AAED;;;;;;;GAOG;AACU,QAAA,iBAAiB,GAA8B;IACxD,IAAI,cAAc,CAAC,gBAAgB,EAAE,8EAA8E,CAAC;IACpH,IAAI,cAAc,CAAC,gBAAgB,EAAE,kFAAkF,CAAC;IACxH,IAAI,cAAc,CAAC,YAAY,EAAE,4EAA4E,CAAC;IAC9G,IAAI,cAAc,CAAC,eAAe,EAAE,6EAA6E,CAAC;IAClH,IAAI,cAAc,CAAC,kBAAkB,EACjC,6GAA6G;UAC3G,gHAAgH,CAAC;IACvH,IAAI,cAAc,CAAC,cAAc,EAAE,iFAAiF,CAAC;IACrH,IAAI,cAAc,CAAC,gBAAgB,EAAE,+FAA+F,CAAC;IACrI,IAAI,cAAc,CAAC,YAAY,EAAE,+CAA+C,CAAC;IACjF,IAAI,cAAc,CAAC,MAAM,EAAE,+CAA+C,CAAC;IAC3E,IAAI,cAAc,CAAC,iBAAiB,EAAE,+CAA+C,CAAC;IACtF,IAAI,cAAc,CAAC,YAAY,EAAE,+CAA+C,CAAC;IACjF,IAAI,cAAc,CAAC,aAAa,EAAE,+CAA+C,CAAC;CACrF,CAAC;AAEF;;;;;;GAMG;AACU,QAAA,eAAe,GAAG,CAAC,GAAG,kCAAiB,EAAE,8BAAa,CAAU,CAAC;AAE9E;;;GAGG;AACH,MAAa,YAAY;IAGR;IAEA;IACA;IAOA;IAZb,yDAAyD;IACzD,YACa,KAAa;IACtB,+FAA+F;IACtF,OAAe,EACf,KAAa;IACtB;;;;;OAKG;IACM,WAAoB,KAAK;QAVzB,UAAK,GAAL,KAAK,CAAQ;QAEb,YAAO,GAAP,OAAO,CAAQ;QACf,UAAK,GAAL,KAAK,CAAQ;QAOb,aAAQ,GAAR,QAAQ,CAAiB;IACnC,CAAC;CACP;AAfD,oCAeC;AAED;;;;;;;GAOG;AACU,QAAA,eAAe,GAA4B;IACpD,IAAI,YAAY,CAAC,UAAU,EAAE,8CAA8C,EACvE,uDAAuD,CAAC;IAC5D,IAAI,YAAY,CAAC,YAAY,EAAE,aAAa,EACxC,2FAA2F,CAAC;IAChG,IAAI,YAAY,CAAC,QAAQ,EAAE,SAAS,EAAE,oEAAoE,CAAC;IAC3G,mGAAmG;IACnG,kGAAkG;IAClG,8FAA8F;IAC9F,iGAAiG;IACjG,gGAAgG;IAChG,sBAAsB;IACtB,IAAI,YAAY,CAAC,OAAO,sBAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,UAAU,EACrD,8FAA8F,CAAC;IACnG,IAAI,YAAY,CAAC,qBAAqB,EAAE,iBAAiB,EACrD,wFAAwF,CAAC;IAC7F,IAAI,YAAY,CAAC,SAAS,yBAAQ,EAAE,EAAE,UAAU,yBAAQ,GAAG,EACvD,2FAA2F,CAAC;IAChG,IAAI,YAAY,CAAC,QAAQ,mCAAkB,IAAI,mCAAkB,IAAI,+BAAc,GAAG,EAAE,gBAAgB,EACpG,4GAA4G,CAAC;IACjH,IAAI,YAAY,CAAC,aAAa,EAAE,cAAc,EAC1C,4FAA4F,CAAC;IACjG,IAAI,YAAY,CAAC,YAAY,EAAE,YAAY,EACvC,yGAAyG,EACzG,IAAI,CAAC;IACT,IAAI,YAAY,CAAC,UAAU,uBAAe,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,YAAY,EACjE,sFAAsF,CAAC;IAC3F,IAAI,YAAY,CAAC,WAAW,EAAE,MAAM,EAAE,gEAAgE,CAAC;IACvG,IAAI,YAAY,CAAC,WAAW,EAAE,YAAY,EACtC,iGAAiG,CAAC;CACzG,CAAC;AAEF;;;;GAIG;AACU,QAAA,eAAe,GACxB,WAAW,uBAAe,CAAC,GAAG,CAC1B,CAAC,CAAe,EAAE,CAAS,EAAU,EAAE,CAAC,IAAI,GAAG,CAAC,CAAC,KAAK,uBAAe,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CACrH,CAAC,IAAI,CAAC,EAAE,CAAC,OAAO;MACf,GAAG,uBAAe,CAAC,GAAG,CAAC,CAAC,CAAe,EAAU,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,cAAc,CAAC;AAE7F;;;;;;;;;;;;;;;;;;;GAmBG;AACU,QAAA,kBAAkB,GAAG;;;;;2CAKS,gCAAiB,IAAI,6BAAc;;;;;;;;;;;;;;;;;qCAiBzC,gCAAiB,IAAI,6BAAc;;;;;;;;+BAQzC,gCAAiB,IAAI,iCAAkB,aAAa,6BAAc;;EAE/F,CAAC;AAEH;;;;;;;;;;;;;;;;;;GAkBG;AACU,QAAA,SAAS,GAAG;;;;EAIvB,0BAAkB;;;;;;;;;0BASM,4BAAc;;;;;;;;;;;;;;;;sBAgBlB,MAAM,CAAC,0BAAkB,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cA6BlC,mCAAkB;mCACG,mCAAkB,sBAAsB,+BAAc;MACnF,uBAAe;;EAEnB,CAAC","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"]}
|
|
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 harness-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"]}
|
package/src/bin/shim.d.ts
CHANGED
package/src/bin/shim.js
CHANGED
|
@@ -24,6 +24,7 @@ const shim_drift_fix_1 = require("./shim-drift-fix");
|
|
|
24
24
|
const shim_version_drift_1 = require("./shim-version-drift");
|
|
25
25
|
// The allowlist moved to ./l0-allowlist (this module was over the file-size limit); re-exported here so
|
|
26
26
|
// every existing `from './shim'` import keeps working and there is still ONE name to import L0 by.
|
|
27
|
+
tslib_1.__exportStar(require("./l0-entry"), exports);
|
|
27
28
|
tslib_1.__exportStar(require("./l0-allowlist"), exports);
|
|
28
29
|
// …and its two siblings, split out for the same size reason: the decision (isAllowed / isRootManifest /
|
|
29
30
|
// READ_TOOLS) and the tool-shaped set it consults. ONE name to import L0 by, still.
|
package/src/bin/shim.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"shim.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/shim.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport { CONFIG_FILENAME, claudeEnv } from '@webpieces/rules-config';\n\nimport {\n L0_FAULT_BIN_BROKEN, L0_FAULT_BIN_MISSING, L0_FAULT_DRIFT, L0_FAULT_UNDECLARED,\n l0GuardHeader, l0MatrixCitation,\n} from '../core/l0-fault-codes';\nimport { toError } from '../core/to-error';\nimport {\n L0_ALLOW_ERE_SH, RECOVERY_CMD, INSTALL_HOOKS_CMD, UPGRADE_SHIM_CMD, RESTORE_SHIM_CMD,\n INSTALL_HOOKS_ALLOW_JS, UPGRADE_SHIM_ALLOW_JS, RESTORE_SHIM_ALLOW_JS,\n ADD_HOOK_PKG_CMD, HOOK_PKG, WORKSPACE_MANIFEST, PACKAGE_MANIFEST,\n} from './l0-allowlist';\nimport { CODEX_READ_STILL_ALLOWED, L0_CODEX_ALLOW_ERE_SH } from './l0-codex-read';\nimport { L0_IGNORED_TOOLS_SH } from './l0-ignored-tools';\nimport { AI_TYPE_SH } from '../adapters/detect-ai';\nimport { WP_LOG_SH } from './shim-audit-log';\nimport { DRIFT_INVERSE_FIX_SH } from './shim-drift-fix';\nimport { VERSION_DRIFT_GUARD_SH } from './shim-version-drift';\nimport { BASH_CWD_ENV_KEY, BASH_CWD_ENV_VALUE } from './managed-env';\n\n// The allowlist moved to ./l0-allowlist (this module was over the file-size limit); re-exported here so\n// every existing `from './shim'` import keeps working and there is still ONE name to import L0 by.\nexport * from './l0-allowlist';\n// …and its two siblings, split out for the same size reason: the decision (isAllowed / isRootManifest /\n// READ_TOOLS) and the tool-shaped set it consults. ONE name to import L0 by, still.\nexport * from './l0-decide';\nexport * from './l0-ignored-tools';\n// …and the harness-GATED half, split out for the same size reason: it owns the read-shaped vocabulary\n// and the Codex-only union built from it. ONE name to import L0 by, still.\nexport * from './l0-codex-read';\n// Same treatment for the audit-log fragment: one name to import the whole rendered shim by.\nexport * from './shim-audit-log';\n\n// ---------------------------------------------------------------------------\n// The single checked-in shim (.claude/webpieces/ai-hook.sh). Both project hooks point at it, passing\n// their bin name as the first arg. settings.json points here (not at the bare bin) so a missing bin\n// (fresh clone, package removed) yields a friendly message instead of the raw `sh: No such file or\n// directory` on every Write/Edit/Bash tool call. `.claude` is committed, so the shim survives even\n// when node_modules does not.\n//\n// This module is the SINGLE SOURCE OF TRUTH for the shim body + the installer allowlist. The\n// installer (setup.ts) renders it on install; the running guards binary re-renders and self-heals it\n// (healShim) so the committed .sh can never go stale — no human ever hand-edits it.\n// ---------------------------------------------------------------------------\nexport const SHIM_MARKER = '.claude/webpieces/ai-hook.sh';\n\n// NO VERSION STAMP (removed 2026-07-24). The shim used to carry a per-release `# webpieces shim\n// version: <v> (<sha>)` on line 2, rewritten by scripts/set-version.sh at publish. It was a pure\n// human-eyeball diagnostic — nothing reads it (the deny's version note comes from the installed\n// package.json) — but it made the committed shim go byte-different on EVERY release even when the\n// logic was identical, so the committed-shim self-guard tripped on every upgrade over a comment (the\n// DENY-SHIM-STALE churn). It also carried its own hazard: stamp one of the two lockstep artifacts and\n// not the other and every consumer fail-closes forever on a phantom edit. Deleting it makes the shim\n// byte-STABLE across releases, so the self-guard (now in the binary) fires only on a genuine logic\n// change or a real tamper — which is what lets `pnpm install` be the fix for almost everything.\n\nexport function shimPath(projectRoot: string): string {\n return path.join(projectRoot, '.claude', 'webpieces', 'ai-hook.sh');\n}\n\n\n// ---------------------------------------------------------------------------\n// HOW EVERY DENY MUST SPELL ITS CURE (added 2026-07-23, from a live audit-log post-mortem).\n//\n// The guards were right, the message was right, and the assistant STILL handed the block back to the\n// human — because of one appended clause. From .webpieces/logs/L0-shim/<writer>.log in a consumer repo:\n//\n// DENY-SHIM-STALE cp node_modules/@webpieces/ai-hook-rules/templates/ai-hook.sh .claude/webpieces/ai-hook.sh && git status --short\n//\n// That is the prescribed cure, verbatim, plus `&& git status --short`. Every allowlist here is anchored\n// to `$`, so the trailing `&&` made it a different command and it was denied — and the assistant read\n// its own denial as proof that \"the guard blocks the very command that fixes it\" and stopped.\n//\n// Widening the allowlist to accept `&& <anything>` is NOT the fix: these are fail-CLOSED escape hatches\n// whose entire security property is that no shell operator can ride along (`cp … && rm -rf /`). The fix\n// is to stop the assistant appending in the first place — so every deny that prescribes a command now\n// (a) numbers its cures as OPTIONs, (b) quotes each one so the exact bytes are unambiguous, and\n// (c) carries this rule, which says in plain words that adding `&&` gets it rejected again.\n//\n// CONSTRAINT on every string that reaches a deny REASON: no double quotes and no backslashes. The\n// reason is interpolated into a `REASON=\"…\"` shell assignment and then printf'd into a JSON string, so\n// a `\"` would break BOTH. Hence single quotes around the commands here — do not \"improve\" them.\n// ---------------------------------------------------------------------------\n// LENGTH IS PART OF THE FIX (2026-08-03). This block used to run ~153 words and was repeated verbatim\n// in D, X and K — in X it was ~85% of the whole message, which buries the one sentence that matters.\n// It now states the rule and the three tolerated additions, and nothing else: the tolerated set is\n// exactly what CD_PREFIX_ERE + CAPTURE_TAIL_ERE accept (the single-quoted path branch of CD_PREFIX_ERE\n// is why \"single-quote a path containing spaces\" is named), so this text cannot promise more or less\n// than the allowlist grants.\nexport const NO_CHAINING_RULE =\n 'Run it EXACTLY as written - the allowlist matches the whole command, so appending anything ' +\n '(even && git status) makes it a different command and it is rejected; that is not the guard ' +\n 'blocking its own cure. Only these may be added: a leading cd <dir> && (single-quote a path ' +\n 'containing spaces), a trailing 2>&1, and | tail -N.';\n\n// Shell fragment: resolve the guard BIN by WALKING UP from ROOT, and remember WHERE it came from.\n//\n// THE BUG THIS CLOSES (it would have landed the day the hooks went relative). `ai-hook.sh` used to set\n// `BIN=\"$ROOT/node_modules/.bin/$BIN_NAME\"` — a LITERAL path with no upward walk, while Node's own\n// resolver walks up. That was correct only while the hooks were registered ABSOLUTE, because then ROOT\n// was always the primary clone and the bin was always there. The moment H2/H3 became relative, ROOT\n// became the tree the call is in — and a nested worktree at `<primary>/.claude/worktrees/<name>` has NO\n// node_modules of its own. Every subagent would have hard-blocked on fault X at its first tool call,\n// fleet-wide, on the day of the flip. Walking up finds the primary's install, exactly as a `require()`\n// from the same directory would; a SIBLING worktree finds nothing and correctly still faults X.\n//\n// BIN_ROOT is not a curiosity: walking up ALONE re-creates the version straddle documented above\n// committedShimStale(), where the shim of one tree is paired with the binary of another and the cure\n// can never converge. So BIN_ROOT is RECORDED, and the drift scan below uses it to decide WHOSE\n// question this is: when BIN_ROOT == ROOT the pin and the install belong to one tree and fault D\n// answers; when they differ the disagreement is CROSS-TREE and L0 stays silent so L1 row 8\n// (VersionSyncGuard), which reads all four versions, answers instead. See VERSION_DRIFT_GUARD_SH.\nconst RESOLVE_BIN_SH = `BIN_ROOT=\"\\$ROOT\"\nBIN=\"\\$ROOT/node_modules/.bin/\\$BIN_NAME\"\nWP_WALK=\"\\$ROOT\"\nwhile [ ! -x \"\\$WP_WALK/node_modules/.bin/\\$BIN_NAME\" ]; do\n WP_UP=\"\\$(dirname -- \"\\$WP_WALK\")\"\n [ \"\\$WP_UP\" != \"\\$WP_WALK\" ] || break\n WP_WALK=\"\\$WP_UP\"\ndone\nif [ -x \"\\$WP_WALK/node_modules/.bin/\\$BIN_NAME\" ]; then\n BIN_ROOT=\"\\$WP_WALK\"\n BIN=\"\\$WP_WALK/node_modules/.bin/\\$BIN_NAME\"\nfi`;\n\n// Normal template literal (not String.raw): it carries #235's shell escapes verbatim (\\${BIN_NAME},\n// \\$REASON, \\\\n for the deny JSON) AND my sed backslashes (doubled: \\\\(, \\\\), \\\\1, [^\"\\\\\\\\]). The\n// grep pattern is interpolated from INSTALLER_ALLOW_ERE (its value has no backslashes).\n// Shell fragment: the two JSON escapes the deny text is built from, plus the one shared \"what is still\n// allowed\" block. Hoisted to the TOP of the shim (it used to sit inside DENY_EMIT_SH's Bash branch,\n// i.e. AFTER every REASON was already assembled) because the deny text now needs the newline escape\n// while it is being BUILT, not only while it is being printed.\n//\n// THE MECHANISM IS THE ONE THE COLOUR ALREADY USED. `REASON` is interpolated into a `REASON=\"…\"` shell\n// assignment and then printf'd into a JSON string literal, so it may contain no RAW double-quote and no\n// RAW backslash — and a RAW newline would be invalid JSON. That constraint is not \"no newlines\": it is\n// \"no raw backslash\", and `${BS}` produces the backslash at RUNTIME, so `${ESC}` (six chars: \\ u 0 0 1 b)\n// and `${NL}` (two chars: \\ n) both travel as legal JSON escapes that Claude Code's parser turns back\n// into a real ESC and a real newline. Verified end to end through /bin/sh in setup.spec.ts: the payload\n// still parses as JSON, the systemMessage still carries 31;1m, and the reason renders as many lines.\n//\n// WHY THE STRUCTURE MATTERS: every L1/L2 deny is rendered by formatReport() into a scannable shape —\n// header, `[guard-name] (N violations)`, indented offenders each with a one-line `→ why`, then numbered\n// `Fix Option N:` lines. L0 was the ONLY layer answering in one unbroken paragraph. It now uses the same\n// skeleton, which is what WP_STILL_ALLOWED exists for: one definition of that section for all four\n// sh-side faults, so they cannot drift into four different answers to the same question.\nconst ESCAPES_SH = `BS='\\\\' # one literal backslash, so no \\\\u001b / \\\\n escape sits in this source\nESC=\"\\${BS}u001b\" # the 6 chars: backslash u 0 0 1 b — Claude Code parses \\\\u001b → ESC\nNL=\"\\${BS}n\" # the 2 chars: backslash n — parsed as a real newline inside the JSON string\nWP_STILL_ALLOWED=\"Still allowed while this block is up:\\${NL} - any Read, and ${CODEX_READ_STILL_ALLOWED}\\${NL} - any Write/Edit whose target is ${CONFIG_FILENAME}, ${WORKSPACE_MANIFEST} or ${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.\"`;\n\n// Shell fragment: run the installed guard bin and INSPECT its outcome, instead of exec'ing it.\n//\n// THE BUG THIS FIXES (guards silently fail-OPEN): the shim used to `exec \"$BIN\"`. exec REPLACES this\n// shim process, so once the bin was executable the shim was GONE and could no longer make a decision.\n// That is fine when the bin runs — but the bin can be INSTALLED YET BROKEN: a corrupt/partially-written\n// node_modules makes node die at require() time with MODULE_NOT_FOUND, exiting 1. And in the PreToolUse\n// protocol ONLY exit 2 blocks: any other non-zero is a NON-BLOCKING error, so Claude Code prints\n// \"Failed with non-blocking status code\" and RUNS THE TOOL CALL ANYWAY — the guard is silently skipped.\n// Result: every Write/Edit/Bash went UNGUARDED, for as long as node_modules stayed corrupt. The shim\n// handled \"bin missing\" and \"bin stale\", but never \"bin present and CRASHES\" — the third failure mode.\n//\n// So: do not exec. Run the bin with the payload on stdin and branch on its exit code.\n// rc 0 | 2 → a REAL decision (allow / block). Relay stdout, stderr and the code byte-faithfully.\n// anything else → the guard CRASHED. Fall through to the fail-CLOSED path (BROKEN_BIN=1).\n// stdout/stderr go through temp FILES, not $(command substitution), so the bin's bytes reach Claude\n// Code exactly as written — command substitution strips trailing newlines and would corrupt the\n// decision JSON. Reading the payload up-front ($PAYLOAD) is what replaces exec's stdin passthrough.\nconst RUN_BIN_SH = `if [ -x \"\\$BIN\" ] && [ -z \"\\$DRIFT_PKG\" ]; then\n OUT_FILE=\"\\${TMPDIR:-/tmp}/wp-ai-hook-out.\\$\\$\"\n ERR_FILE=\"\\${TMPDIR:-/tmp}/wp-ai-hook-err.\\$\\$\"\n printf '%s' \"\\$PAYLOAD\" | \"\\$BIN\" \"\\$@\" >\"\\$OUT_FILE\" 2>\"\\$ERR_FILE\"\n RC=\\$?\n if [ \"\\$RC\" = 0 ] || [ \"\\$RC\" = 2 ]; then\n cat \"\\$OUT_FILE\" # the guard's real decision — verbatim\n cat \"\\$ERR_FILE\" >&2\n rm -f \"\\$OUT_FILE\" \"\\$ERR_FILE\" 2>/dev/null\n # THE HEALTHY CALL, which this log used to be silent about (see WP_LOG_SH's header). No sh-side\n # fault, the bin ran, and its own exit code says how it ended — 0 allow, 2 block. Logged AFTER the\n # decision bytes are already on stdout, so the audit trail never sits between the guard and Claude\n # Code. Without this line, \"no entry\" meant either healthy or never-ran, and those are the two\n # answers a reader most needs to tell apart.\n WP_VERDICT=PASS-BIN-ALLOW\n [ \"\\$RC\" = 2 ] && WP_VERDICT=PASS-BIN-BLOCK\n wp_log - \"\\$WP_VERDICT\"\n exit \"\\$RC\"\n fi\n # Crashed. Keep the most useful stderr line for the human. Strip \" and backslash so the text stays a\n # valid JSON string, and cap the length so a giant node stack cannot blow up the deny payload.\n CRASH_MSG=\"\\$(grep -m1 'Cannot find module' \"\\$ERR_FILE\" 2>/dev/null | tr -d '\"\\\\\\\\' | cut -c1-120)\"\n [ -n \"\\$CRASH_MSG\" ] || CRASH_MSG=\"\\$(head -n1 \"\\$ERR_FILE\" 2>/dev/null | tr -d '\"\\\\\\\\' | cut -c1-120)\"\n [ -n \"\\$CRASH_MSG\" ] || CRASH_MSG=\"exit code \\$RC, no stderr\"\n rm -f \"\\$OUT_FILE\" \"\\$ERR_FILE\" 2>/dev/null\n BROKEN_BIN=1\nfi`;\n\n// Shell fragment: pull the four fields the shim itself reasons about out of the tool payload.\n//\n// MOVED AHEAD OF THE BIN (it used to sit inside TRIAGE_SH, i.e. only on the fail-closed path) because\n// the audit log now covers the HEALTHY call too, and a log line needs the tool and the command whether\n// or not anything went wrong.\n//\n// `cwd` is Claude Code's documented \"current working directory when the hook is invoked\". It is used\n// for ONE thing: deciding which tree's log directory this line belongs in (see RESOLVE_LOG_DIR_SH).\n// It deliberately does NOT change what the drift guard MEASURES — that stays anchored to $ROOT, the\n// tree the shim FILE lives in. Where a call is logged and what a call is judged against are separate\n// questions and are kept separate here.\n// TWO command variables, and the split is a SECURITY boundary — do not collapse them.\n//\n// $CMD is the DECISION input (the L0 allowlist greps it). Its pattern requires the CLOSING quote, so a\n// JSON payload that escapes an embedded quote as \\\\\" yields the EMPTY STRING for the whole command.\n// That looks like a bug and is in fact the safe direction: an empty command matches no allowlist entry,\n// so a quoted command falls through to the deny. FAIL CLOSED. Keep it that way.\n//\n// $CMD_LOG is the AUDIT input and must never reach a decision. It drops the closing quote from the\n// pattern so it captures the command PREFIX instead of nothing.\n//\n// WHY THEY CANNOT BE ONE VARIABLE: every L0 allowlist ERE is anchored `^…[[:space:]]*$`, and trailing\n// whitespace is tolerated — so `pnpm install \"; rm -rf /\"` would prefix-capture to `pnpm install `,\n// which MATCHES, and the injection after the quote would ride through allowlisted. Measured 2026-08-06:\n// 3,908 of 4,917 shim audit lines (79.5%) recorded an empty command, i.e. four out of five audit\n// entries were blind. Fixing the LOG is worth doing; fixing the DECISION the same way is a hole.\nconst PARSE_PAYLOAD_SH = `CMD=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"command\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nCMD_LOG=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"command\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\).*/\\\\1/p')\"\n[ -n \"\\$CMD_LOG\" ] || CMD_LOG=\"\\$CMD\"\nTOOL=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"tool_name\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nWP_SID=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"session_id\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nWP_AID=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"agent_id\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nFILE=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"file_path\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nWP_CWD=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"cwd\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\n[ -n \"\\$WP_CWD\" ] || WP_CWD=\"\\$ROOT\" # no cwd in the payload (older client, or a hand-run) → the shim's own tree\n# WHICH HARNESS sent this call — the ONE discriminator, imported from ../adapters/detect-ai so the sh\n# half of L0 and the JS half answer the identical question from one definition (the same twin pattern\n# L0_ALLOW_ERE_SH / L0_ALLOW_JS uses). One \\`case\\`, no JSON parser: consistent with how every field\n# above is \\`sed\\`-scraped, and the values are the AiType union's own strings so the twin-agreement spec\n# compares them byte for byte instead of translating between two vocabularies.\n${AI_TYPE_SH}`;\n\n// Shell fragment: the guards are DOWN (missing | stale | crashed). Classify the fault, then let THE L0\n// ALLOWLIST through — everything else falls to the deny below.\n//\n// This asks the identical question isAllowed() asks in JS, in the same order: Read, then the\n// webpieces.config.json target, then the one command union (L0_ALLOW_ERE). The sh and JS halves exist\n// because D/X/K are decided BEFORE the bin runs (a stale/missing/broken validator cannot validate\n// itself) while S/C/Y are decided inside it — one model, two enforcement points.\n//\n// NOTE the documented asymmetry: here the bin is never executed, so an allowed Read is TERMINAL and\n// read-stale-guard does not run. In JS the same entry falls through and it does. See isAllowed().\nconst TRIAGE_SH = `# WHICH of the guards/L0-tooling.md faults fired, in the doc's own letters. Only the four sh-side\n# codes can be decided here; S/C/Y live in the binary, which never got to run on this path.\nWP_FAULT=${L0_FAULT_BIN_MISSING} # X — bin missing (fresh clone, new worktree)\n[ -z \"\\$WP_HOOK_PKG_DECLARED\" ] && WP_FAULT=${L0_FAULT_UNDECLARED} # U — X, but nothing declares the package: install is a no-op\n[ -n \"\\$DRIFT_PKG\" ] && WP_FAULT=${L0_FAULT_DRIFT} # D — version drift; D and K are mutually exclusive\n[ -n \"\\$BROKEN_BIN\" ] && WP_FAULT=${L0_FAULT_BIN_BROKEN} # K — bin present but CRASHED (corrupt node_modules)\nDENY_LABEL=\"DENY\"\n[ -z \"\\$WP_HOOK_PKG_DECLARED\" ] && DENY_LABEL=\"DENY-UNDECLARED\" # nothing in package.json asks for the package\n[ -n \"\\$DRIFT_PKG\" ] && DENY_LABEL=\"DENY-STALE\" # version drift, not a missing bin\n[ -n \"\\$BROKEN_BIN\" ] && DENY_LABEL=\"DENY-BROKEN\" # bin present but CRASHED (corrupt node_modules)\n# THE L0 ALLOWLIST, entry order identical to isAllowed(). No fault is consulted: a cure that cannot\n# help a given fault also cannot hurt it, and gating each entry on a fault is what produced the four\n# defects recorded above L0_ALLOW_ERE.\nif [ \"\\$TOOL\" = \"Read\" ]; then\n wp_log \"\\$WP_FAULT\" ALLOW-READ # you must be able to read to work out how to fix this\n exit 0\nfi\ncase \"\\$TOOL\" in\n ${L0_IGNORED_TOOLS_SH})\n # Nothing to judge — the Codex tools that are neither a shell command nor a file edit. sh twin of\n # isAllowed()'s L0_IGNORED_TOOLS branch; see there for why the list is EXPLICIT and why apply_patch\n # (Codex's only WRITE) is not on it.\n wp_log \"\\$WP_FAULT\" ALLOW-IGNORED\n exit 0 ;;\nesac\ncase \"\\$FILE\" in\n */${CONFIG_FILENAME}|${CONFIG_FILENAME})\n wp_log \"\\$WP_FAULT\" ALLOW-CONFIG # the always-allowed recovery target — every guard is configured from it\n exit 0 ;;\n */${WORKSPACE_MANIFEST}|${WORKSPACE_MANIFEST}|*/${PACKAGE_MANIFEST}|${PACKAGE_MANIFEST})\n # A manifest AT THE ROOT OF A GOVERNED TREE, which is the only place the version pin lives. The test\n # is the sibling ${CONFIG_FILENAME} — TRACKED, so the main clone has one and every worktree has its\n # own — and NOT \\$ROOT, which names whichever tree supplied this shim and would deny the other's.\n # Basename alone would be far worse here than in the JS half: this arm is TERMINAL (exit 0, the bin\n # never runs), so every packages/**/package.json would be editable with nothing judging it.\n if [ -f \"\\$(dirname -- \"\\$FILE\")/${CONFIG_FILENAME}\" ]; then\n wp_log \"\\$WP_FAULT\" ALLOW-MANIFEST # raising the pin must be typable from inside the block\n exit 0\n fi ;;\nesac\nif printf '%s' \"\\$CMD\" | grep -Eq '${L0_ALLOW_ERE_SH}'; then\n wp_log \"\\$WP_FAULT\" ALLOW-CURE # record the self-heal we let through (re-enables the guards)\n exit 0 # allow the cure so the assistant can break the deadlock\nfi\n# THE HARNESS-GATED TAIL OF THE SAME LIST, and the ONLY place \\$AI changes a decision. Under Claude Code\n# the guard is false and the next line is the deny — byte for byte the path a Claude payload took before\n# this branch existed. Under Codex it is the twin of the ALLOW-READ arm above: Codex has NO Read tool, so\n# a read arrives as this Bash command, and without this every L0 fault denies a Codex session the very\n# reads the deny is telling it to perform. The pattern is anchored on its own (no cd prefix, no capture\n# tail) — see L0_CODEX_ALLOW_ERE. TERMINAL, exactly like the Read arm, and for the same reason: on this\n# path the bin never runs, so there is nothing to fall through to.\nif [ \"\\$AI\" = codex ] && printf '%s' \"\\$CMD\" | grep -Eq '${L0_CODEX_ALLOW_ERE_SH}'; then\n wp_log \"\\$WP_FAULT\" ALLOW-CODEX-READ\n exit 0\nfi\nwp_log \"\\$WP_FAULT\" \"\\$DENY_LABEL\" # every fail-closed block, with the fault that caused it`;\n\n// Shell fragment: emit the deny. FAIL CLOSED via Claude Code's PreToolUse JSON protocol\n// (permissionDecision \"deny\" on stdout, then exit 0) rather than a bare \"exit 2\". BOTH block the call,\n// but the reason must be made VISIBLE, and HOW depends on the tool (verified by live tests; the docs\n// are wrong here):\n// - Bash deny: permissionDecisionReason is NOT shown to the human — ONLY a top-level systemMessage\n// is, and it honors ANSI. So for Bash we emit systemMessage wrapped in ANSI red so the\n// recovery command is visible (without it, on Bash, it is invisible).\n// - Write/Edit/MultiEdit deny: permissionDecisionReason renders as a RED \"Error:\" block natively —\n// no systemMessage needed (a second line would be redundant).\n// - NEVER exit 2 (stdout JSON ignored; stderr not reliably shown on a blocked Bash call).\n// The ESC and the newline escape are both built in ESCAPES_SH at the TOP of the shim (see its header):\n// ${ESC} is the literal 6-char JSON escape \\\\u001b and ${NL} the 2-char \\\\n, so no raw ESC byte, no raw\n// newline and no \\\\uXXXX sits in this source, and Claude Code's JSON parser turns both back. The reason\n// is a single JSON string with no RAW double-quotes/backslashes, so it stays valid JSON after the subs.\n//\n// ONLY THE HEADLINE IS RED, and that is deliberate — the same call redSystemMessage() makes on the JS\n// side. The reason is MULTI-LINE now, and a whole page in bold red is harder to read than the paragraph\n// it replaced: the indentation carrying the structure stops registering when every line shouts. So\n// \\$WP_HEAD (each branch's own first line, kept in its own variable for exactly this) is wrapped in\n// [31;1m … [0m, and \\${REASON#\"\\$WP_HEAD\"} — POSIX prefix removal with a QUOTED pattern, so the\n// headline is matched literally and not as a glob — supplies the plain body after it.\nconst DENY_EMIT_SH = `if [ \"\\$TOOL\" = \"Bash\" ]; then\n printf '{\"systemMessage\":\"%s🛑 %s%s%s\",\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"%s\"}}\\\\n' \"\\${ESC}[31;1m\" \"\\$WP_HEAD\" \"\\${ESC}[0m\" \"\\${REASON#\"\\$WP_HEAD\"}\" \"\\$REASON\"\nelse\n printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"%s\"}}\\\\n' \"\\$REASON\"\nfi\nexit 0 # decision is carried by permissionDecision \"deny\", not the exit code`;\n\n// Shell fragment: pick the fail-closed deny REASON — a crashed-bin message (corrupt node_modules) vs a\n// version-drift message (bin present but stale) vs the missing-bin message. Extracted alongside\n// VERSION_DRIFT_GUARD_SH / RUN_BIN_SH to keep renderShim() within the method-line budget.\nconst DENY_REASON_SH = `if [ -n \"\\$BROKEN_BIN\" ]; then\n # Report (do NOT auto-clean) the orphaned pnpm staging dirs — a package pnpm was mid-way through\n # writing is left behind as <name>_<pid>_<hash>. Their presence is the fingerprint of an install that\n # was killed, which is what corrupts node_modules in the first place. Best-effort; never fatal.\n STAGING_N=\"\\$(ls \"\\$BIN_ROOT/node_modules\" 2>/dev/null | grep -Ec '_[0-9a-f]+_[0-9a-f]+\\$' || true)\"\n STAGING_NOTE=\"\"\n if [ \"\\${STAGING_N:-0}\" -gt 0 ] 2>/dev/null; then\n STAGING_NOTE=\"\\${NL} → also found \\$STAGING_N orphaned pnpm staging dirs (name_pid_hash) under node_modules - the fingerprint of an install that was killed mid-write.\" # only when N > 0\n fi\n # The HEADLINE, kept in its own variable so DENY_EMIT_SH can paint ONLY it red (see there).\n WP_HEAD=\"❌ webpieces ai-hooks blocked this call: the webpieces guards are DOWN.\"\n REASON=\"\\$WP_HEAD\\${NL}\\${NL}${l0GuardHeader(L0_FAULT_BIN_BROKEN, '1 violation')}\\${NL} \\${BIN_NAME} (\\$CRASH_MSG)\\${NL} → it is installed but CRASHED, so your node_modules is corrupt or partially written; the guards cannot run and they must not be silently skipped. Every OTHER tool call is BLOCKED until they can.\\${STAGING_NOTE}\\${NL} → ${l0MatrixCitation(L0_FAULT_BIN_BROKEN)}\\${NL}\\${NL}\\${WP_STILL_ALLOWED}\\${NL}\\${NL} Fix Option 1: (preferred) the only cure. A bare 'pnpm install' will NOT fix this, because pnpm sees the correct version on disk and skips the broken package\\${NL} run EXACTLY: '${RECOVERY_CMD}'\\${NL}\\${NL}${NO_CHAINING_RULE}\"\nelif [ -n \"\\$DRIFT_PKG\" ]; then\n # DECIDE THE DIRECTION, do not make the reader do it (2026-08-03). The detection is a plain !=, so it\n # fires BOTH ways, and the message used to carry OPTION 1/2/3 covering every direction at once — 3343\n # chars of which only about a third was the decision. A reader on the wrong branch of that menu was\n # one misread away from a downgrade. So compare the two versions HERE and emit only the relevant half.\n #\n # WITH AWK, not \\`sort -V\\`: -V is a GNU extension (absent/different on BSD sort), while the shim\n # already runs an awk pass to resolve catalog: specs, so awk adds no dependency. The program compares\n # the numeric cores component-by-component; a pre-release/build suffix (-rc.1, +sha) is stripped from\n # the core, and when the cores are EQUAL the side carrying a PRE-RELEASE suffix is the older one\n # (semver precedence). Build metadata (+sha) carries NO precedence, so two versions differing only\n # there come back undecidable rather than ordered. Anything it cannot parse prints NOTHING, and an\n # empty answer falls through to the ambiguous wording below rather than guessing a direction.\n #\n # WHAT WAS DELETED, so it does not creep back: the \"how to get main itself current\" paragraph and the\n # \"do NOT reach for git merge --ff-only / reset --hard / checkout -B main\" paragraph both belong to\n # redirect-how-to-merge-main, which fires on its own with its own message; and the sentence that named\n # wp-start-update / wp-start-upsert-pr ONLY to forbid them while the block is up — naming a command\n # purely to forbid it is pure cost, and the install that clears this fault comes first regardless.\n DRIFT_DIR=\"\\$(awk -v i=\"\\$DRIFT_INSTALLED\" -v d=\"\\$DRIFT_DECLARED\" 'BEGIN {\n iv = i; sub(/\\\\+.*/, \"\", iv); ic = iv; sub(/-.*/, \"\", ic); ip = substr(iv, length(ic) + 1)\n dv = d; sub(/\\\\+.*/, \"\", dv); dc = dv; sub(/-.*/, \"\", dc); dp = substr(dv, length(dc) + 1)\n if (ic !~ /^[0-9]+(\\\\.[0-9]+)*\\$/ || dc !~ /^[0-9]+(\\\\.[0-9]+)*\\$/) exit\n n = split(ic, ia, \".\"); m = split(dc, da, \".\"); k = (n > m) ? n : m\n for (x = 1; x <= k; x++) {\n av = (x <= n) ? ia[x] + 0 : 0; bv = (x <= m) ? da[x] + 0 : 0\n if (av < bv) { print \"older\"; exit }\n if (av > bv) { print \"newer\"; exit }\n }\n if (ip == dp) exit\n if (ip != \"\" && dp == \"\") print \"older\"\n if (ip == \"\" && dp != \"\") print \"newer\"\n }' 2>/dev/null)\"\n if [ \"\\$DRIFT_DIR\" = older ]; then\n # The HEADLINE, kept in its own variable so DENY_EMIT_SH can paint ONLY it red (see there).\n WP_HEAD=\"❌ webpieces ai-hooks blocked this call: webpieces version drift.\"\n REASON=\"\\$WP_HEAD\\${NL}\\${NL}${l0GuardHeader(L0_FAULT_DRIFT, '1 violation')}\\${NL} package.json pins \\$DRIFT_PKG@\\$DRIFT_DECLARED but node_modules has \\$DRIFT_INSTALLED\\${NL} → node_modules is OLDER, so the pin is what you want. Every OTHER tool call is BLOCKED until the two agree.\\${NL} → ${l0MatrixCitation(L0_FAULT_DRIFT)}\\${NL}\\${NL}\\${WP_STILL_ALLOWED}\\${NL}\\${NL} Fix Option 1: (preferred) the only cure - it makes node_modules match the pin\\${NL} run EXACTLY: 'pnpm install'\\${NL}\\${NL}${NO_CHAINING_RULE}\"\n else\n # NEWER, or undecidable — the same choices apply either way, so the only thing the ambiguous\n # case changes is the claim about which side is stale.\n DRIFT_NOTE=\"node_modules is NEWER, so the PIN is the stale side and a bare 'pnpm install' DOWNGRADES you to \\$DRIFT_DECLARED\"\n [ \"\\$DRIFT_DIR\" = newer ] || DRIFT_NOTE=\"these two versions could not be ordered automatically - compare them yourself: if node_modules is the NEWER side then the PIN is the stale side and a bare 'pnpm install' DOWNGRADES you to \\$DRIFT_DECLARED\"\n ${DRIFT_INVERSE_FIX_SH}\n # The HEADLINE, kept in its own variable so DENY_EMIT_SH can paint ONLY it red (see there).\n WP_HEAD=\"❌ webpieces ai-hooks blocked this call: webpieces version drift.\"\n REASON=\"\\$WP_HEAD\\${NL}\\${NL}${l0GuardHeader(L0_FAULT_DRIFT, '1 violation')}\\${NL} package.json pins \\$DRIFT_PKG@\\$DRIFT_DECLARED but node_modules has \\$DRIFT_INSTALLED\\${NL} → \\$DRIFT_NOTE. That may be exactly what you want. Every OTHER tool call is BLOCKED until the two agree.\\${NL} → ${l0MatrixCitation(L0_FAULT_DRIFT)}\\${NL}\\${NL}\\${WP_STILL_ALLOWED}\\${NL}\\${NL}\\${WP_FIX}\\${NL}\\${NL}${NO_CHAINING_RULE}\"\n fi\nelse\n # A LINKED WORKTREE is the overwhelmingly common way to land here with a perfectly healthy repo:\n # git gives the new worktree a .git FILE (the primary clone has a .git directory) and copies no\n # node_modules, so the very first tool call in a brand-new worktree fail-closes on a missing bin.\n # Naming that explicitly turns a baffling \"not installed\" into a one-command fix, and the HERE is\n # load-bearing: installing in the primary clone does nothing for this tree.\n WORKTREE_NOTE=\"\"\n if [ -f \"\\$ROOT/.git\" ]; then\n WORKTREE_NOTE=\"\\${NL} → \\$ROOT is a LINKED WORKTREE - git does not copy node_modules into a new worktree, so this is expected on a fresh one. Run the Fix Option HERE, in this worktree, not in the primary clone.\"\n fi\n if [ -z \"\\$WP_HOOK_PKG_DECLARED\" ]; then\n # FAULT U — the one shape where the X message is not merely unhelpful but actively WRONG. It asserted\n # \"declared in package.json\" without ever checking, and prescribed the one command that provably\n # cannot help: with nothing asking for the package, \\`pnpm install\\` reports \"Lockfile is up to date\"\n # and converges to the identical broken tree, forever. So say what is actually true, say out loud\n # that the install is a no-op (an agent that has already run it needs to be told to STOP), and\n # prescribe the add — which is allowlist entry ADD_HOOK_PKG, so it is reachable while this block is up.\n WP_ADD_CMD=\"${ADD_HOOK_PKG_CMD}\"\n [ -n \"\\$WP_PIN\" ] && WP_ADD_CMD=\"\\${WP_ADD_CMD}@\\$WP_PIN\"\n # The HEADLINE, kept in its own variable so DENY_EMIT_SH can paint ONLY it red (see there).\n WP_HEAD=\"❌ webpieces ai-hooks blocked this call: the guard package is not declared anywhere.\"\n REASON=\"\\$WP_HEAD\\${NL}\\${NL}${l0GuardHeader(L0_FAULT_UNDECLARED, '1 violation')}\\${NL} ${HOOK_PKG} is NOT declared in package.json anywhere, and is not installed (\\${BIN_NAME} not found)\\${NL} → .claude/settings.json still runs its hooks, so every OTHER tool call is BLOCKED. Do NOT run 'pnpm install': nothing asks for this package, so it is a NO-OP and repeating it converges to this same state.\\${NL} → ${l0MatrixCitation(L0_FAULT_UNDECLARED)}\\${NL}\\${NL}\\${WP_STILL_ALLOWED}\\${NL}\\${NL} Fix Option 1: (preferred) declare it directly, to unblock yourself right now\\${NL} run EXACTLY: '\\$WP_ADD_CMD'\\${NL} Fix Option 2: the durable fix - ${HOOK_PKG} normally arrives with @webpieces/nx-webpieces-rules, the umbrella that bundles the whole toolchain, so upgrade that once you are unblocked.\\${NL} NOT an option: if you removed ${HOOK_PKG} on purpose, delete its hooks from .claude/settings.json instead.\\${NL}\\${NL}${NO_CHAINING_RULE}\"\n else\n # The HEADLINE, kept in its own variable so DENY_EMIT_SH can paint ONLY it red (see there).\n WP_HEAD=\"❌ webpieces ai-hooks blocked this call: the webpieces guard bin is not installed.\"\n REASON=\"\\$WP_HEAD\\${NL}\\${NL}${l0GuardHeader(L0_FAULT_BIN_MISSING, '1 violation')}\\${NL} ${HOOK_PKG} is declared in package.json but is not installed (\\${BIN_NAME} not found)\\${NL} → the guards cannot run, so every OTHER tool call is BLOCKED until they can.\\${WORKTREE_NOTE}\\${NL} → ${l0MatrixCitation(L0_FAULT_BIN_MISSING)}\\${NL}\\${NL}\\${WP_STILL_ALLOWED}\\${NL}\\${NL} Fix Option 1: (preferred) the only cure - it materializes what package.json already asks for\\${NL} run EXACTLY: 'pnpm install'\\${NL} NOT an option: if you removed ${HOOK_PKG} on purpose, delete its hooks from .claude/settings.json instead.\\${NL}\\${NL}${NO_CHAINING_RULE}\"\n fi\nfi`;\n\nexport function renderShim(): string {\n return `#!/bin/sh\n# Managed by @webpieces/ai-hook-rules (wp-install-ai-hooks) — do not edit. This file is GENERATED from\n# renderShim() and is intentionally VERSION-AGNOSTIC and byte-STABLE across releases: it carries no\n# version stamp, so it only changes when its own logic changes. The installed guards binary is what\n# checks that this committed copy still matches renderShim() (the committed-shim self-guard); if you\n# revert or hand-edit this file the binary fails closed and names the cure. Checked in on purpose so\n# the hook has a stable entry point even when node_modules is absent. Safe to delete along with the\n# matching .claude/settings.json entries if you remove @webpieces/ai-hook-rules.\n#\n# Usage (wired into .claude/settings.json, ABSOLUTE so the MAIN tree governs every tree):\n# sh \"$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh\" <bin-name>\nBIN_NAME=\"$1\"\nshift\n# Resolve the tree relative to THIS script (…/<root>/.claude/webpieces/ai-hook.sh → <root>), not the\n# caller's cwd — the hook can be invoked from any directory (a subdir, or a nested clone).\nROOT=\"$(CDPATH= cd -- \"$(dirname -- \"$0\")/../..\" && pwd)\"\n# The JSON escapes every deny message below is assembled from (ANSI red, and the newlines that give the\n# deny the same scannable shape formatReport() gives every L1/L2 deny). See ESCAPES_SH's header.\n${ESCAPES_SH}\n# The BIN is resolved by walking UP from ROOT (as Node does), and BIN_ROOT records which tree supplied\n# it — the version-drift guard below compares THIS tree's pin against THAT tree's installed version.\n${RESOLVE_BIN_SH}\n${VERSION_DRIFT_GUARD_SH}\n# Read the tool payload ONCE, up front. The shim no longer exec's the bin (see RUN_BIN_SH), so it must\n# forward stdin to the bin itself — and it needs the payload again on the fail-closed path below.\nPAYLOAD=\"$(cat)\"\n${PARSE_PAYLOAD_SH}\n# Best-effort AUDIT TRAIL of what L0 did with this call — every call, not just the broken ones. One\n# tab-separated line per invocation into this TREE's own\n# logs/L0-shim/<session>-<agent|coordinator>-<binName>.log (gitignored), so the\n# observed behaviour can be diffed against the matrix in guards/L0-tooling.md. NEVER breaks or blocks the\n# hook: every write is swallowed, and nothing ever goes to stdout (stdout is the PreToolUse decision\n# channel — a stray byte there would corrupt allow/deny).\n${WP_LOG_SH}\nBROKEN_BIN=\"\"\nCRASH_MSG=\"\"\n${RUN_BIN_SH}\n# Bin missing (fresh clone before install) OR a version drift (stale node_modules) OR the bin is\n# installed but CRASHED (corrupt node_modules). The webpieces guards CANNOT safely run.\n# Before failing closed, peek at the tool payload and let ONLY package-manager install/recovery commands\n# through: the assistant's own Bash tool routes through this hook too, so blocking everything would\n# deadlock the very commands (pnpm install / rm -rf node_modules && pnpm install) that re-enable the\n# guards. A silent exit 0 = \"allow\" in the PreToolUse protocol; the guards resume once the tree is sane.\n${TRIAGE_SH}\n${DENY_REASON_SH}\n${DENY_EMIT_SH}\n`;\n}\n\n// Find the repo root that owns the committed shim to heal: walk up from `cwd` (the invocation's\n// actual dir) to the nearest ancestor holding a shim, falling back to $CLAUDE_PROJECT_DIR (which\n// Claude Code exports to hooks) only if the walk finds nothing. cwd-first keeps this correct for a\n// nested clone and testable (a temp root is honoured over the ambient project env). Returns null when\n// no committed shim exists (e.g. a global / absolute install, which has none to heal).\n//\n// Exported for install-entry.ts: on a CORRUPT node_modules, healShim is the only installer step that\n// can still run, so the installer must be able to tell the human whether a committed shim was actually\n// there to re-arm. Pure existsSync walk — never throws, so it needs no try/catch of its own.\n// webpieces-disable no-function-outside-class -- pure fs+path helper in the dependency-free shim module; it must not depend on DI (install-entry.ts relies on this loading on a corrupt tree).\nexport function findShimRoot(cwd: string): string | null {\n let dir = cwd;\n for (;;) {\n if (fs.existsSync(shimPath(dir))) return dir;\n const parent = path.dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n const env = process.env['CLAUDE_PROJECT_DIR'];\n if (env && fs.existsSync(shimPath(env))) return env;\n return null;\n}\n\n// Best-effort: keep the committed shim identical to renderShim() so the fail-closed escape hatch and\n// allowlist never drift. Only rewrites an EXISTING shim (never creates one) so global installs are\n// untouched. NEVER throws — a self-heal must never block or crash a tool call.\n//\n// The overwrite itself is correct and deliberate — shim and binary are two halves of one L0 and MUST\n// come from the same release (see shimStaleRecoveryDecision's header in ../adapters/hook-core).\n//\n// It needs no backup and no notice: the shim is a TRACKED file, so whatever it replaced is already in\n// git — `git diff` shows the rewrite, and a tamper is a working-tree modification git surfaces on its\n// own. In a consistent repo this is a no-op (committed shim already equals renderShim()); it earns its\n// keep on the upgrade path, where bumping the pin and installing leaves the committed shim behind and\n// this quietly brings it forward to be committed.\nexport function healShim(cwd: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const root = findShimRoot(cwd);\n if (!root) return;\n const target = shimPath(root);\n const desired = renderShim();\n if (fs.readFileSync(target, 'utf8') === desired) return;\n fs.writeFileSync(target, desired, { mode: 0o755 });\n fs.chmodSync(target, 0o755);\n } catch (err: unknown) {\n //const error = toError(err);\n // Ignore: healing is a convenience, not part of the guard decision.\n }\n}\n\n// ---------------------------------------------------------------------------\n// COMMITTED-SHIM SELF-GUARD — now enforced by the guards BINARY, not the shim (moved 2026-07-24).\n//\n// It used to live in the rendered shim (`cmp -s \"$0\" \"$WP_TEMPLATE\"` → fail closed). That was a\n// double-edged fix trap: the shim-matching logic lived IN the committed shim, so a bug in it could\n// only be fixed by regenerating the committed shim — which required passing the buggy shim's own gate\n// (via wp-upgrade-shim). The fix was locked behind the gate it needed to open.\n//\n// The drift guard MUST stay pre-binary (a stale validator can't be trusted to guard itself), but this\n// check's rationale — \"don't run possibly-stale shim logic\" — evaporates once the check is in the\n// binary: at that point the deciding code is the CURRENT binary from node_modules, not the reverted\n// shim. So the shim now only checks drift + bin-presence and always hands off; the binary (hook-core)\n// calls committedShimStale() and, on a mismatch, fails closed with shimStaleDenyReason() — the SAME\n// OPTION 1/2/3 message — while isShimCureCommand() lets the three cures through so the AI self-heals.\n// We deny + tell the AI; we do NOT silently rewrite the file under it. With the version stamp gone the\n// shim is byte-stable across releases, so this fires only on a genuine logic change or a real tamper.\n// ---------------------------------------------------------------------------\n\n// The root whose committed shim this BINARY governs — resolved from the RUNNING MODULE's own location,\n// never from process.cwd() and never from $CLAUDE_PROJECT_DIR. Same premise as installedShimRulesVersion()\n// below: the binary IS this package, so it can point at its OWN install.\n//\n// WHY IT MUST BE THE MODULE AND NOT THE CWD (the two-tree straddle, fixed 2026-08-03).\n// committedShimStale used to resolve its root by walking up from the invocation cwd, then compare that\n// tree's shim FILE against renderShim() — which is compiled into whichever binary is actually running.\n//\n// Which tree supplies the binary? settings.json runs $CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh,\n// and that shim derives ROOT (hence BIN) from its own $0 — so the SESSION ROOT's tree supplies BOTH the\n// shim and the binary, and that pair is self-consistent by construction. A session rooted in a linked\n// worktree runs the worktree's shim and the worktree's binary; that is fine and is NOT the bug.\n//\n// The straddle appears when an agent's SESSION ROOT and its CWD are different trees — CLAUDE_PROJECT_DIR\n// is fixed at session start, so an agent that `cd`s into another checkout keeps running the session-root\n// tree's binary while findShimRoot(cwd) walks up into the OTHER tree. Each tree carries its own\n// node_modules at its own @webpieces version (seen in the wild: 0.4.545, 0.4.560 and 0.4.526 side by\n// side, every tree internally consistent). The comparison then straddles the two and can NEVER converge:\n// curing in the cwd tree renders with THAT tree's renderShim(), which the running binary's renderShim()\n// still rejects, so the cure re-fires the deny forever (observed: an agent gave up after four cures).\n//\n// Anchoring on __dirname makes the straddle UNCONSTRUCTIBLE rather than merely discouraged. It does not\n// pick a tree and privileges none: whichever tree the running binary came from is the tree whose shim it\n// compares, so the two halves of the comparison provably come from the same install either way.\n//\n// OUTERMOST node_modules wins, not innermost: under pnpm's linked layout __dirname realpaths to\n// <root>/node_modules/.pnpm/@webpieces+ai-hook-rules@X/node_modules/@webpieces/ai-hook-rules/src/bin —\n// the outermost segment lands on <root>, an innermost/first-ancestor rule lands inside the store.\n// With no node_modules segment at all we are running from a SOURCE checkout (vitest via tsconfig paths),\n// so walk up to the nearest ancestor that owns a shim. null = no committed shim to guard.\n// webpieces-disable no-function-outside-class -- pure fs+path helper in the dependency-free shim module, beside findShimRoot/healShim.\nexport function governingShimRoot(moduleDir: string = __dirname): string | null {\n const segments = moduleDir.split(path.sep);\n const outermost = segments.indexOf('node_modules');\n if (outermost > 0) {\n const root = segments.slice(0, outermost).join(path.sep);\n return fs.existsSync(shimPath(root)) ? root : null;\n }\n // A moduleDir that STARTS with node_modules is relative, so the root before it would be '' — i.e.\n // cwd-relative, the exact input this function exists to refuse. Nothing to govern.\n if (outermost === 0) return null;\n let dir = moduleDir;\n for (;;) {\n if (fs.existsSync(shimPath(dir))) return dir;\n const parent = path.dirname(dir);\n if (parent === dir) return null;\n dir = parent;\n }\n}\n\n// True when a committed shim EXISTS but no longer equals renderShim() (reverted, hand-edited, or a shim\n// whose LOGIC predates the installed binary). Missing shim → false: a fresh clone / global install has\n// nothing to guard, matching the old shim's `[ -f \"$WP_TEMPLATE\" ]` skip. Same comparison healShim\n// makes; never throws (an unreadable tree is treated as \"not stale\" so it can't wedge a tool call).\n//\n// The root defaults to governingShimRoot() — the decision's input is the MODULE's tree, never the cwd\n// (see governingShimRoot for the straddle this closes). The parameter exists ONLY so unit tests can\n// stage a temp root; nothing in production should pass one.\n// webpieces-disable no-function-outside-class -- pure fs+path helper in the shim module, beside healShim/renderShim.\nexport function committedShimStale(root: string | null = governingShimRoot()): boolean {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (root === null) return false;\n return fs.readFileSync(shimPath(root), 'utf8') !== renderShim();\n } catch (err: unknown) {\n const error = toError(err);\n void error; // best-effort: an unreadable tree counts as \"not stale\" so this never wedges a tool call\n return false;\n }\n}\n\n// True when `command` re-arms the committed shim — the two prescribed cures plus the installer, which\n// also heals the shim as its first step. These are the only commands allowed through while a stale\n// committed shim blocks everything else, so the AI can re-arm it. Each JS twin already tolerates\n// a trailing `2>&1 | tail -N` and rejects any `&&`-chained tail (see CAPTURE_TAIL_JS_SRC).\n// webpieces-disable no-function-outside-class -- pure predicate over the exported allowlist twins; belongs beside them in the shim module.\nexport function isShimCureCommand(command: string): boolean {\n const cmd = command.trim();\n return INSTALL_HOOKS_ALLOW_JS.test(cmd) || UPGRADE_SHIM_ALLOW_JS.test(cmd) || RESTORE_SHIM_ALLOW_JS.test(cmd);\n}\n\n// The shape of the fields we read out of this package's package.json.\ninterface ShimPackageManifest {\n readonly version?: string;\n}\n\n// The installed @webpieces/ai-hook-rules version, for shimStaleDenyReason's note. The binary IS this\n// package, so it reads its OWN package.json (two dirs up from src/bin). Best-effort: '' on any failure,\n// which shimStaleDenyReason renders as no note rather than a broken one.\n// webpieces-disable no-function-outside-class -- pure fs helper beside the shim module's other version plumbing.\nexport function installedShimRulesVersion(): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8')) as ShimPackageManifest;\n return pkg.version ?? '';\n } catch (err: unknown) {\n const error = toError(err);\n void error; // best-effort: no readable version → shimStaleDenyReason prints no note\n return '';\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"shim.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/shim.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport { CONFIG_FILENAME, claudeEnv } from '@webpieces/rules-config';\n\nimport {\n L0_FAULT_BIN_BROKEN, L0_FAULT_BIN_MISSING, L0_FAULT_DRIFT, L0_FAULT_UNDECLARED,\n l0GuardHeader, l0MatrixCitation,\n} from '../core/l0-fault-codes';\nimport { toError } from '../core/to-error';\nimport {\n L0_ALLOW_ERE_SH, RECOVERY_CMD, INSTALL_HOOKS_CMD, UPGRADE_SHIM_CMD, RESTORE_SHIM_CMD,\n INSTALL_HOOKS_ALLOW_JS, UPGRADE_SHIM_ALLOW_JS, RESTORE_SHIM_ALLOW_JS,\n ADD_HOOK_PKG_CMD, HOOK_PKG, WORKSPACE_MANIFEST, PACKAGE_MANIFEST,\n} from './l0-allowlist';\nimport { CODEX_READ_STILL_ALLOWED, L0_CODEX_ALLOW_ERE_SH } from './l0-codex-read';\nimport { L0_IGNORED_TOOLS_SH } from './l0-ignored-tools';\nimport { AI_TYPE_SH } from '../adapters/detect-ai';\nimport { WP_LOG_SH } from './shim-audit-log';\nimport { DRIFT_INVERSE_FIX_SH } from './shim-drift-fix';\nimport { VERSION_DRIFT_GUARD_SH } from './shim-version-drift';\nimport { BASH_CWD_ENV_KEY, BASH_CWD_ENV_VALUE } from './managed-env';\n\n// The allowlist moved to ./l0-allowlist (this module was over the file-size limit); re-exported here so\n// every existing `from './shim'` import keeps working and there is still ONE name to import L0 by.\nexport * from './l0-entry';\nexport * from './l0-allowlist';\n// …and its two siblings, split out for the same size reason: the decision (isAllowed / isRootManifest /\n// READ_TOOLS) and the tool-shaped set it consults. ONE name to import L0 by, still.\nexport * from './l0-decide';\nexport * from './l0-ignored-tools';\n// …and the harness-GATED half, split out for the same size reason: it owns the read-shaped vocabulary\n// and the Codex-only union built from it. ONE name to import L0 by, still.\nexport * from './l0-codex-read';\n// Same treatment for the audit-log fragment: one name to import the whole rendered shim by.\nexport * from './shim-audit-log';\n\n// ---------------------------------------------------------------------------\n// The single checked-in shim (.claude/webpieces/ai-hook.sh). Both project hooks point at it, passing\n// their bin name as the first arg. settings.json points here (not at the bare bin) so a missing bin\n// (fresh clone, package removed) yields a friendly message instead of the raw `sh: No such file or\n// directory` on every Write/Edit/Bash tool call. `.claude` is committed, so the shim survives even\n// when node_modules does not.\n//\n// This module is the SINGLE SOURCE OF TRUTH for the shim body + the installer allowlist. The\n// installer (setup.ts) renders it on install; the running guards binary re-renders and self-heals it\n// (healShim) so the committed .sh can never go stale — no human ever hand-edits it.\n// ---------------------------------------------------------------------------\nexport const SHIM_MARKER = '.claude/webpieces/ai-hook.sh';\n\n// NO VERSION STAMP (removed 2026-07-24). The shim used to carry a per-release `# webpieces shim\n// version: <v> (<sha>)` on line 2, rewritten by scripts/set-version.sh at publish. It was a pure\n// human-eyeball diagnostic — nothing reads it (the deny's version note comes from the installed\n// package.json) — but it made the committed shim go byte-different on EVERY release even when the\n// logic was identical, so the committed-shim self-guard tripped on every upgrade over a comment (the\n// DENY-SHIM-STALE churn). It also carried its own hazard: stamp one of the two lockstep artifacts and\n// not the other and every consumer fail-closes forever on a phantom edit. Deleting it makes the shim\n// byte-STABLE across releases, so the self-guard (now in the binary) fires only on a genuine logic\n// change or a real tamper — which is what lets `pnpm install` be the fix for almost everything.\n\nexport function shimPath(projectRoot: string): string {\n return path.join(projectRoot, '.claude', 'webpieces', 'ai-hook.sh');\n}\n\n\n// ---------------------------------------------------------------------------\n// HOW EVERY DENY MUST SPELL ITS CURE (added 2026-07-23, from a live audit-log post-mortem).\n//\n// The guards were right, the message was right, and the assistant STILL handed the block back to the\n// human — because of one appended clause. From .webpieces/logs/L0-shim/<writer>.log in a consumer repo:\n//\n// DENY-SHIM-STALE cp node_modules/@webpieces/ai-hook-rules/templates/ai-hook.sh .claude/webpieces/ai-hook.sh && git status --short\n//\n// That is the prescribed cure, verbatim, plus `&& git status --short`. Every allowlist here is anchored\n// to `$`, so the trailing `&&` made it a different command and it was denied — and the assistant read\n// its own denial as proof that \"the guard blocks the very command that fixes it\" and stopped.\n//\n// Widening the allowlist to accept `&& <anything>` is NOT the fix: these are fail-CLOSED escape hatches\n// whose entire security property is that no shell operator can ride along (`cp … && rm -rf /`). The fix\n// is to stop the assistant appending in the first place — so every deny that prescribes a command now\n// (a) numbers its cures as OPTIONs, (b) quotes each one so the exact bytes are unambiguous, and\n// (c) carries this rule, which says in plain words that adding `&&` gets it rejected again.\n//\n// CONSTRAINT on every string that reaches a deny REASON: no double quotes and no backslashes. The\n// reason is interpolated into a `REASON=\"…\"` shell assignment and then printf'd into a JSON string, so\n// a `\"` would break BOTH. Hence single quotes around the commands here — do not \"improve\" them.\n// ---------------------------------------------------------------------------\n// LENGTH IS PART OF THE FIX (2026-08-03). This block used to run ~153 words and was repeated verbatim\n// in D, X and K — in X it was ~85% of the whole message, which buries the one sentence that matters.\n// It now states the rule and the three tolerated additions, and nothing else: the tolerated set is\n// exactly what CD_PREFIX_ERE + CAPTURE_TAIL_ERE accept (the single-quoted path branch of CD_PREFIX_ERE\n// is why \"single-quote a path containing spaces\" is named), so this text cannot promise more or less\n// than the allowlist grants.\nexport const NO_CHAINING_RULE =\n 'Run it EXACTLY as written - the allowlist matches the whole command, so appending anything ' +\n '(even && git status) makes it a different command and it is rejected; that is not the guard ' +\n 'blocking its own cure. Only these may be added: a leading cd <dir> && (single-quote a path ' +\n 'containing spaces), a trailing 2>&1, and | tail -N.';\n\n// Shell fragment: resolve the guard BIN by WALKING UP from ROOT, and remember WHERE it came from.\n//\n// THE BUG THIS CLOSES (it would have landed the day the hooks went relative). `ai-hook.sh` used to set\n// `BIN=\"$ROOT/node_modules/.bin/$BIN_NAME\"` — a LITERAL path with no upward walk, while Node's own\n// resolver walks up. That was correct only while the hooks were registered ABSOLUTE, because then ROOT\n// was always the primary clone and the bin was always there. The moment H2/H3 became relative, ROOT\n// became the tree the call is in — and a nested worktree at `<primary>/.claude/worktrees/<name>` has NO\n// node_modules of its own. Every subagent would have hard-blocked on fault X at its first tool call,\n// fleet-wide, on the day of the flip. Walking up finds the primary's install, exactly as a `require()`\n// from the same directory would; a SIBLING worktree finds nothing and correctly still faults X.\n//\n// BIN_ROOT is not a curiosity: walking up ALONE re-creates the version straddle documented above\n// committedShimStale(), where the shim of one tree is paired with the binary of another and the cure\n// can never converge. So BIN_ROOT is RECORDED, and the drift scan below uses it to decide WHOSE\n// question this is: when BIN_ROOT == ROOT the pin and the install belong to one tree and fault D\n// answers; when they differ the disagreement is CROSS-TREE and L0 stays silent so L1 row 8\n// (VersionSyncGuard), which reads all four versions, answers instead. See VERSION_DRIFT_GUARD_SH.\nconst RESOLVE_BIN_SH = `BIN_ROOT=\"\\$ROOT\"\nBIN=\"\\$ROOT/node_modules/.bin/\\$BIN_NAME\"\nWP_WALK=\"\\$ROOT\"\nwhile [ ! -x \"\\$WP_WALK/node_modules/.bin/\\$BIN_NAME\" ]; do\n WP_UP=\"\\$(dirname -- \"\\$WP_WALK\")\"\n [ \"\\$WP_UP\" != \"\\$WP_WALK\" ] || break\n WP_WALK=\"\\$WP_UP\"\ndone\nif [ -x \"\\$WP_WALK/node_modules/.bin/\\$BIN_NAME\" ]; then\n BIN_ROOT=\"\\$WP_WALK\"\n BIN=\"\\$WP_WALK/node_modules/.bin/\\$BIN_NAME\"\nfi`;\n\n// Normal template literal (not String.raw): it carries #235's shell escapes verbatim (\\${BIN_NAME},\n// \\$REASON, \\\\n for the deny JSON) AND my sed backslashes (doubled: \\\\(, \\\\), \\\\1, [^\"\\\\\\\\]). The\n// grep pattern is interpolated from INSTALLER_ALLOW_ERE (its value has no backslashes).\n// Shell fragment: the two JSON escapes the deny text is built from, plus the one shared \"what is still\n// allowed\" block. Hoisted to the TOP of the shim (it used to sit inside DENY_EMIT_SH's Bash branch,\n// i.e. AFTER every REASON was already assembled) because the deny text now needs the newline escape\n// while it is being BUILT, not only while it is being printed.\n//\n// THE MECHANISM IS THE ONE THE COLOUR ALREADY USED. `REASON` is interpolated into a `REASON=\"…\"` shell\n// assignment and then printf'd into a JSON string literal, so it may contain no RAW double-quote and no\n// RAW backslash — and a RAW newline would be invalid JSON. That constraint is not \"no newlines\": it is\n// \"no raw backslash\", and `${BS}` produces the backslash at RUNTIME, so `${ESC}` (six chars: \\ u 0 0 1 b)\n// and `${NL}` (two chars: \\ n) both travel as legal JSON escapes that Claude Code's parser turns back\n// into a real ESC and a real newline. Verified end to end through /bin/sh in setup.spec.ts: the payload\n// still parses as JSON, the systemMessage still carries 31;1m, and the reason renders as many lines.\n//\n// WHY THE STRUCTURE MATTERS: every L1/L2 deny is rendered by formatReport() into a scannable shape —\n// header, `[guard-name] (N violations)`, indented offenders each with a one-line `→ why`, then numbered\n// `Fix Option N:` lines. L0 was the ONLY layer answering in one unbroken paragraph. It now uses the same\n// skeleton, which is what WP_STILL_ALLOWED exists for: one definition of that section for all four\n// sh-side faults, so they cannot drift into four different answers to the same question.\nconst ESCAPES_SH = `BS='\\\\' # one literal backslash, so no \\\\u001b / \\\\n escape sits in this source\nESC=\"\\${BS}u001b\" # the 6 chars: backslash u 0 0 1 b — Claude Code parses \\\\u001b → ESC\nNL=\"\\${BS}n\" # the 2 chars: backslash n — parsed as a real newline inside the JSON string\nWP_STILL_ALLOWED=\"Still allowed while this block is up:\\${NL} - any Read, and ${CODEX_READ_STILL_ALLOWED}\\${NL} - any Write/Edit whose target is ${CONFIG_FILENAME}, ${WORKSPACE_MANIFEST} or ${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.\"`;\n\n// Shell fragment: run the installed guard bin and INSPECT its outcome, instead of exec'ing it.\n//\n// THE BUG THIS FIXES (guards silently fail-OPEN): the shim used to `exec \"$BIN\"`. exec REPLACES this\n// shim process, so once the bin was executable the shim was GONE and could no longer make a decision.\n// That is fine when the bin runs — but the bin can be INSTALLED YET BROKEN: a corrupt/partially-written\n// node_modules makes node die at require() time with MODULE_NOT_FOUND, exiting 1. And in the PreToolUse\n// protocol ONLY exit 2 blocks: any other non-zero is a NON-BLOCKING error, so Claude Code prints\n// \"Failed with non-blocking status code\" and RUNS THE TOOL CALL ANYWAY — the guard is silently skipped.\n// Result: every Write/Edit/Bash went UNGUARDED, for as long as node_modules stayed corrupt. The shim\n// handled \"bin missing\" and \"bin stale\", but never \"bin present and CRASHES\" — the third failure mode.\n//\n// So: do not exec. Run the bin with the payload on stdin and branch on its exit code.\n// rc 0 | 2 → a REAL decision (allow / block). Relay stdout, stderr and the code byte-faithfully.\n// anything else → the guard CRASHED. Fall through to the fail-CLOSED path (BROKEN_BIN=1).\n// stdout/stderr go through temp FILES, not $(command substitution), so the bin's bytes reach Claude\n// Code exactly as written — command substitution strips trailing newlines and would corrupt the\n// decision JSON. Reading the payload up-front ($PAYLOAD) is what replaces exec's stdin passthrough.\nconst RUN_BIN_SH = `if [ -x \"\\$BIN\" ] && [ -z \"\\$DRIFT_PKG\" ]; then\n OUT_FILE=\"\\${TMPDIR:-/tmp}/wp-ai-hook-out.\\$\\$\"\n ERR_FILE=\"\\${TMPDIR:-/tmp}/wp-ai-hook-err.\\$\\$\"\n printf '%s' \"\\$PAYLOAD\" | \"\\$BIN\" \"\\$@\" >\"\\$OUT_FILE\" 2>\"\\$ERR_FILE\"\n RC=\\$?\n if [ \"\\$RC\" = 0 ] || [ \"\\$RC\" = 2 ]; then\n cat \"\\$OUT_FILE\" # the guard's real decision — verbatim\n cat \"\\$ERR_FILE\" >&2\n rm -f \"\\$OUT_FILE\" \"\\$ERR_FILE\" 2>/dev/null\n # THE HEALTHY CALL, which this log used to be silent about (see WP_LOG_SH's header). No sh-side\n # fault, the bin ran, and its own exit code says how it ended — 0 allow, 2 block. Logged AFTER the\n # decision bytes are already on stdout, so the audit trail never sits between the guard and Claude\n # Code. Without this line, \"no entry\" meant either healthy or never-ran, and those are the two\n # answers a reader most needs to tell apart.\n WP_VERDICT=PASS-BIN-ALLOW\n [ \"\\$RC\" = 2 ] && WP_VERDICT=PASS-BIN-BLOCK\n wp_log - \"\\$WP_VERDICT\"\n exit \"\\$RC\"\n fi\n # Crashed. Keep the most useful stderr line for the human. Strip \" and backslash so the text stays a\n # valid JSON string, and cap the length so a giant node stack cannot blow up the deny payload.\n CRASH_MSG=\"\\$(grep -m1 'Cannot find module' \"\\$ERR_FILE\" 2>/dev/null | tr -d '\"\\\\\\\\' | cut -c1-120)\"\n [ -n \"\\$CRASH_MSG\" ] || CRASH_MSG=\"\\$(head -n1 \"\\$ERR_FILE\" 2>/dev/null | tr -d '\"\\\\\\\\' | cut -c1-120)\"\n [ -n \"\\$CRASH_MSG\" ] || CRASH_MSG=\"exit code \\$RC, no stderr\"\n rm -f \"\\$OUT_FILE\" \"\\$ERR_FILE\" 2>/dev/null\n BROKEN_BIN=1\nfi`;\n\n// Shell fragment: pull the four fields the shim itself reasons about out of the tool payload.\n//\n// MOVED AHEAD OF THE BIN (it used to sit inside TRIAGE_SH, i.e. only on the fail-closed path) because\n// the audit log now covers the HEALTHY call too, and a log line needs the tool and the command whether\n// or not anything went wrong.\n//\n// `cwd` is Claude Code's documented \"current working directory when the hook is invoked\". It is used\n// for ONE thing: deciding which tree's log directory this line belongs in (see RESOLVE_LOG_DIR_SH).\n// It deliberately does NOT change what the drift guard MEASURES — that stays anchored to $ROOT, the\n// tree the shim FILE lives in. Where a call is logged and what a call is judged against are separate\n// questions and are kept separate here.\n// TWO command variables, and the split is a SECURITY boundary — do not collapse them.\n//\n// $CMD is the DECISION input (the L0 allowlist greps it). Its pattern requires the CLOSING quote, so a\n// JSON payload that escapes an embedded quote as \\\\\" yields the EMPTY STRING for the whole command.\n// That looks like a bug and is in fact the safe direction: an empty command matches no allowlist entry,\n// so a quoted command falls through to the deny. FAIL CLOSED. Keep it that way.\n//\n// $CMD_LOG is the AUDIT input and must never reach a decision. It drops the closing quote from the\n// pattern so it captures the command PREFIX instead of nothing.\n//\n// WHY THEY CANNOT BE ONE VARIABLE: every L0 allowlist ERE is anchored `^…[[:space:]]*$`, and trailing\n// whitespace is tolerated — so `pnpm install \"; rm -rf /\"` would prefix-capture to `pnpm install `,\n// which MATCHES, and the injection after the quote would ride through allowlisted. Measured 2026-08-06:\n// 3,908 of 4,917 shim audit lines (79.5%) recorded an empty command, i.e. four out of five audit\n// entries were blind. Fixing the LOG is worth doing; fixing the DECISION the same way is a hole.\nconst PARSE_PAYLOAD_SH = `CMD=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"command\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nCMD_LOG=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"command\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\).*/\\\\1/p')\"\n[ -n \"\\$CMD_LOG\" ] || CMD_LOG=\"\\$CMD\"\nTOOL=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"tool_name\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nWP_SID=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"session_id\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nWP_AID=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"agent_id\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nFILE=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"file_path\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nWP_CWD=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"cwd\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\n[ -n \"\\$WP_CWD\" ] || WP_CWD=\"\\$ROOT\" # no cwd in the payload (older client, or a hand-run) → the shim's own tree\n# WHICH HARNESS sent this call — the ONE discriminator, imported from ../adapters/detect-ai so the sh\n# half of L0 and the JS half answer the identical question from one definition (the same twin pattern\n# L0_ALLOW_ERE_SH / L0_ALLOW_JS uses). One \\`case\\`, no JSON parser: consistent with how every field\n# above is \\`sed\\`-scraped, and the values are the AiType union's own strings so the twin-agreement spec\n# compares them byte for byte instead of translating between two vocabularies.\n${AI_TYPE_SH}`;\n\n// Shell fragment: the guards are DOWN (missing | stale | crashed). Classify the fault, then let THE L0\n// ALLOWLIST through — everything else falls to the deny below.\n//\n// This asks the identical question isAllowed() asks in JS, in the same order: Read, then the\n// webpieces.config.json target, then the one command union (L0_ALLOW_ERE). The sh and JS halves exist\n// because D/X/K are decided BEFORE the bin runs (a stale/missing/broken validator cannot validate\n// itself) while S/C/Y are decided inside it — one model, two enforcement points.\n//\n// NOTE the documented asymmetry: here the bin is never executed, so an allowed Read is TERMINAL and\n// read-stale-guard does not run. In JS the same entry falls through and it does. See isAllowed().\nconst TRIAGE_SH = `# WHICH of the guards/L0-tooling.md faults fired, in the doc's own letters. Only the four sh-side\n# codes can be decided here; S/C/Y live in the binary, which never got to run on this path.\nWP_FAULT=${L0_FAULT_BIN_MISSING} # X — bin missing (fresh clone, new worktree)\n[ -z \"\\$WP_HOOK_PKG_DECLARED\" ] && WP_FAULT=${L0_FAULT_UNDECLARED} # U — X, but nothing declares the package: install is a no-op\n[ -n \"\\$DRIFT_PKG\" ] && WP_FAULT=${L0_FAULT_DRIFT} # D — version drift; D and K are mutually exclusive\n[ -n \"\\$BROKEN_BIN\" ] && WP_FAULT=${L0_FAULT_BIN_BROKEN} # K — bin present but CRASHED (corrupt node_modules)\nDENY_LABEL=\"DENY\"\n[ -z \"\\$WP_HOOK_PKG_DECLARED\" ] && DENY_LABEL=\"DENY-UNDECLARED\" # nothing in package.json asks for the package\n[ -n \"\\$DRIFT_PKG\" ] && DENY_LABEL=\"DENY-STALE\" # version drift, not a missing bin\n[ -n \"\\$BROKEN_BIN\" ] && DENY_LABEL=\"DENY-BROKEN\" # bin present but CRASHED (corrupt node_modules)\n# THE L0 ALLOWLIST, entry order identical to isAllowed(). No fault is consulted: a cure that cannot\n# help a given fault also cannot hurt it, and gating each entry on a fault is what produced the four\n# defects recorded above L0_ALLOW_ERE.\nif [ \"\\$TOOL\" = \"Read\" ]; then\n wp_log \"\\$WP_FAULT\" ALLOW-READ # you must be able to read to work out how to fix this\n exit 0\nfi\ncase \"\\$TOOL\" in\n ${L0_IGNORED_TOOLS_SH})\n # Nothing to judge — the Codex tools that are neither a shell command nor a file edit. sh twin of\n # isAllowed()'s L0_IGNORED_TOOLS branch; see there for why the list is EXPLICIT and why apply_patch\n # (Codex's only WRITE) is not on it.\n wp_log \"\\$WP_FAULT\" ALLOW-IGNORED\n exit 0 ;;\nesac\ncase \"\\$FILE\" in\n */${CONFIG_FILENAME}|${CONFIG_FILENAME})\n wp_log \"\\$WP_FAULT\" ALLOW-CONFIG # the always-allowed recovery target — every guard is configured from it\n exit 0 ;;\n */${WORKSPACE_MANIFEST}|${WORKSPACE_MANIFEST}|*/${PACKAGE_MANIFEST}|${PACKAGE_MANIFEST})\n # A manifest AT THE ROOT OF A GOVERNED TREE, which is the only place the version pin lives. The test\n # is the sibling ${CONFIG_FILENAME} — TRACKED, so the main clone has one and every worktree has its\n # own — and NOT \\$ROOT, which names whichever tree supplied this shim and would deny the other's.\n # Basename alone would be far worse here than in the JS half: this arm is TERMINAL (exit 0, the bin\n # never runs), so every packages/**/package.json would be editable with nothing judging it.\n if [ -f \"\\$(dirname -- \"\\$FILE\")/${CONFIG_FILENAME}\" ]; then\n wp_log \"\\$WP_FAULT\" ALLOW-MANIFEST # raising the pin must be typable from inside the block\n exit 0\n fi ;;\nesac\nif printf '%s' \"\\$CMD\" | grep -Eq '${L0_ALLOW_ERE_SH}'; then\n wp_log \"\\$WP_FAULT\" ALLOW-CURE # record the self-heal we let through (re-enables the guards)\n exit 0 # allow the cure so the assistant can break the deadlock\nfi\n# THE HARNESS-GATED TAIL OF THE SAME LIST, and the ONLY place \\$AI changes a decision. Under Claude Code\n# the guard is false and the next line is the deny — byte for byte the path a Claude payload took before\n# this branch existed. Under Codex it is the twin of the ALLOW-READ arm above: Codex has NO Read tool, so\n# a read arrives as this Bash command, and without this every L0 fault denies a Codex session the very\n# reads the deny is telling it to perform. The pattern is anchored on its own (no cd prefix, no capture\n# tail) — see L0_CODEX_ALLOW_ERE. TERMINAL, exactly like the Read arm, and for the same reason: on this\n# path the bin never runs, so there is nothing to fall through to.\nif [ \"\\$AI\" = codex ] && printf '%s' \"\\$CMD\" | grep -Eq '${L0_CODEX_ALLOW_ERE_SH}'; then\n wp_log \"\\$WP_FAULT\" ALLOW-CODEX-READ\n exit 0\nfi\nwp_log \"\\$WP_FAULT\" \"\\$DENY_LABEL\" # every fail-closed block, with the fault that caused it`;\n\n// Shell fragment: emit the deny. FAIL CLOSED via Claude Code's PreToolUse JSON protocol\n// (permissionDecision \"deny\" on stdout, then exit 0) rather than a bare \"exit 2\". BOTH block the call,\n// but the reason must be made VISIBLE, and HOW depends on the tool (verified by live tests; the docs\n// are wrong here):\n// - Bash deny: permissionDecisionReason is NOT shown to the human — ONLY a top-level systemMessage\n// is, and it honors ANSI. So for Bash we emit systemMessage wrapped in ANSI red so the\n// recovery command is visible (without it, on Bash, it is invisible).\n// - Write/Edit/MultiEdit deny: permissionDecisionReason renders as a RED \"Error:\" block natively —\n// no systemMessage needed (a second line would be redundant).\n// - NEVER exit 2 (stdout JSON ignored; stderr not reliably shown on a blocked Bash call).\n// The ESC and the newline escape are both built in ESCAPES_SH at the TOP of the shim (see its header):\n// ${ESC} is the literal 6-char JSON escape \\\\u001b and ${NL} the 2-char \\\\n, so no raw ESC byte, no raw\n// newline and no \\\\uXXXX sits in this source, and Claude Code's JSON parser turns both back. The reason\n// is a single JSON string with no RAW double-quotes/backslashes, so it stays valid JSON after the subs.\n//\n// ONLY THE HEADLINE IS RED, and that is deliberate — the same call redSystemMessage() makes on the JS\n// side. The reason is MULTI-LINE now, and a whole page in bold red is harder to read than the paragraph\n// it replaced: the indentation carrying the structure stops registering when every line shouts. So\n// \\$WP_HEAD (each branch's own first line, kept in its own variable for exactly this) is wrapped in\n// [31;1m … [0m, and \\${REASON#\"\\$WP_HEAD\"} — POSIX prefix removal with a QUOTED pattern, so the\n// headline is matched literally and not as a glob — supplies the plain body after it.\nconst DENY_EMIT_SH = `if [ \"\\$TOOL\" = \"Bash\" ]; then\n printf '{\"systemMessage\":\"%s🛑 %s%s%s\",\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"%s\"}}\\\\n' \"\\${ESC}[31;1m\" \"\\$WP_HEAD\" \"\\${ESC}[0m\" \"\\${REASON#\"\\$WP_HEAD\"}\" \"\\$REASON\"\nelse\n printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"%s\"}}\\\\n' \"\\$REASON\"\nfi\nexit 0 # decision is carried by permissionDecision \"deny\", not the exit code`;\n\n// Shell fragment: pick the fail-closed deny REASON — a crashed-bin message (corrupt node_modules) vs a\n// version-drift message (bin present but stale) vs the missing-bin message. Extracted alongside\n// VERSION_DRIFT_GUARD_SH / RUN_BIN_SH to keep renderShim() within the method-line budget.\nconst DENY_REASON_SH = `if [ -n \"\\$BROKEN_BIN\" ]; then\n # Report (do NOT auto-clean) the orphaned pnpm staging dirs — a package pnpm was mid-way through\n # writing is left behind as <name>_<pid>_<hash>. Their presence is the fingerprint of an install that\n # was killed, which is what corrupts node_modules in the first place. Best-effort; never fatal.\n STAGING_N=\"\\$(ls \"\\$BIN_ROOT/node_modules\" 2>/dev/null | grep -Ec '_[0-9a-f]+_[0-9a-f]+\\$' || true)\"\n STAGING_NOTE=\"\"\n if [ \"\\${STAGING_N:-0}\" -gt 0 ] 2>/dev/null; then\n STAGING_NOTE=\"\\${NL} → also found \\$STAGING_N orphaned pnpm staging dirs (name_pid_hash) under node_modules - the fingerprint of an install that was killed mid-write.\" # only when N > 0\n fi\n # The HEADLINE, kept in its own variable so DENY_EMIT_SH can paint ONLY it red (see there).\n WP_HEAD=\"❌ webpieces ai-hooks blocked this call: the webpieces guards are DOWN.\"\n REASON=\"\\$WP_HEAD\\${NL}\\${NL}${l0GuardHeader(L0_FAULT_BIN_BROKEN, '1 violation')}\\${NL} \\${BIN_NAME} (\\$CRASH_MSG)\\${NL} → it is installed but CRASHED, so your node_modules is corrupt or partially written; the guards cannot run and they must not be silently skipped. Every OTHER tool call is BLOCKED until they can.\\${STAGING_NOTE}\\${NL} → ${l0MatrixCitation(L0_FAULT_BIN_BROKEN)}\\${NL}\\${NL}\\${WP_STILL_ALLOWED}\\${NL}\\${NL} Fix Option 1: (preferred) the only cure. A bare 'pnpm install' will NOT fix this, because pnpm sees the correct version on disk and skips the broken package\\${NL} run EXACTLY: '${RECOVERY_CMD}'\\${NL}\\${NL}${NO_CHAINING_RULE}\"\nelif [ -n \"\\$DRIFT_PKG\" ]; then\n # DECIDE THE DIRECTION, do not make the reader do it (2026-08-03). The detection is a plain !=, so it\n # fires BOTH ways, and the message used to carry OPTION 1/2/3 covering every direction at once — 3343\n # chars of which only about a third was the decision. A reader on the wrong branch of that menu was\n # one misread away from a downgrade. So compare the two versions HERE and emit only the relevant half.\n #\n # WITH AWK, not \\`sort -V\\`: -V is a GNU extension (absent/different on BSD sort), while the shim\n # already runs an awk pass to resolve catalog: specs, so awk adds no dependency. The program compares\n # the numeric cores component-by-component; a pre-release/build suffix (-rc.1, +sha) is stripped from\n # the core, and when the cores are EQUAL the side carrying a PRE-RELEASE suffix is the older one\n # (semver precedence). Build metadata (+sha) carries NO precedence, so two versions differing only\n # there come back undecidable rather than ordered. Anything it cannot parse prints NOTHING, and an\n # empty answer falls through to the ambiguous wording below rather than guessing a direction.\n #\n # WHAT WAS DELETED, so it does not creep back: the \"how to get main itself current\" paragraph and the\n # \"do NOT reach for git merge --ff-only / reset --hard / checkout -B main\" paragraph both belong to\n # redirect-how-to-merge-main, which fires on its own with its own message; and the sentence that named\n # wp-start-update / wp-start-upsert-pr ONLY to forbid them while the block is up — naming a command\n # purely to forbid it is pure cost, and the install that clears this fault comes first regardless.\n DRIFT_DIR=\"\\$(awk -v i=\"\\$DRIFT_INSTALLED\" -v d=\"\\$DRIFT_DECLARED\" 'BEGIN {\n iv = i; sub(/\\\\+.*/, \"\", iv); ic = iv; sub(/-.*/, \"\", ic); ip = substr(iv, length(ic) + 1)\n dv = d; sub(/\\\\+.*/, \"\", dv); dc = dv; sub(/-.*/, \"\", dc); dp = substr(dv, length(dc) + 1)\n if (ic !~ /^[0-9]+(\\\\.[0-9]+)*\\$/ || dc !~ /^[0-9]+(\\\\.[0-9]+)*\\$/) exit\n n = split(ic, ia, \".\"); m = split(dc, da, \".\"); k = (n > m) ? n : m\n for (x = 1; x <= k; x++) {\n av = (x <= n) ? ia[x] + 0 : 0; bv = (x <= m) ? da[x] + 0 : 0\n if (av < bv) { print \"older\"; exit }\n if (av > bv) { print \"newer\"; exit }\n }\n if (ip == dp) exit\n if (ip != \"\" && dp == \"\") print \"older\"\n if (ip == \"\" && dp != \"\") print \"newer\"\n }' 2>/dev/null)\"\n if [ \"\\$DRIFT_DIR\" = older ]; then\n # The HEADLINE, kept in its own variable so DENY_EMIT_SH can paint ONLY it red (see there).\n WP_HEAD=\"❌ webpieces ai-hooks blocked this call: webpieces version drift.\"\n REASON=\"\\$WP_HEAD\\${NL}\\${NL}${l0GuardHeader(L0_FAULT_DRIFT, '1 violation')}\\${NL} package.json pins \\$DRIFT_PKG@\\$DRIFT_DECLARED but node_modules has \\$DRIFT_INSTALLED\\${NL} → node_modules is OLDER, so the pin is what you want. Every OTHER tool call is BLOCKED until the two agree.\\${NL} → ${l0MatrixCitation(L0_FAULT_DRIFT)}\\${NL}\\${NL}\\${WP_STILL_ALLOWED}\\${NL}\\${NL} Fix Option 1: (preferred) the only cure - it makes node_modules match the pin\\${NL} run EXACTLY: 'pnpm install'\\${NL}\\${NL}${NO_CHAINING_RULE}\"\n else\n # NEWER, or undecidable — the same choices apply either way, so the only thing the ambiguous\n # case changes is the claim about which side is stale.\n DRIFT_NOTE=\"node_modules is NEWER, so the PIN is the stale side and a bare 'pnpm install' DOWNGRADES you to \\$DRIFT_DECLARED\"\n [ \"\\$DRIFT_DIR\" = newer ] || DRIFT_NOTE=\"these two versions could not be ordered automatically - compare them yourself: if node_modules is the NEWER side then the PIN is the stale side and a bare 'pnpm install' DOWNGRADES you to \\$DRIFT_DECLARED\"\n ${DRIFT_INVERSE_FIX_SH}\n # The HEADLINE, kept in its own variable so DENY_EMIT_SH can paint ONLY it red (see there).\n WP_HEAD=\"❌ webpieces ai-hooks blocked this call: webpieces version drift.\"\n REASON=\"\\$WP_HEAD\\${NL}\\${NL}${l0GuardHeader(L0_FAULT_DRIFT, '1 violation')}\\${NL} package.json pins \\$DRIFT_PKG@\\$DRIFT_DECLARED but node_modules has \\$DRIFT_INSTALLED\\${NL} → \\$DRIFT_NOTE. That may be exactly what you want. Every OTHER tool call is BLOCKED until the two agree.\\${NL} → ${l0MatrixCitation(L0_FAULT_DRIFT)}\\${NL}\\${NL}\\${WP_STILL_ALLOWED}\\${NL}\\${NL}\\${WP_FIX}\\${NL}\\${NL}${NO_CHAINING_RULE}\"\n fi\nelse\n # A LINKED WORKTREE is the overwhelmingly common way to land here with a perfectly healthy repo:\n # git gives the new worktree a .git FILE (the primary clone has a .git directory) and copies no\n # node_modules, so the very first tool call in a brand-new worktree fail-closes on a missing bin.\n # Naming that explicitly turns a baffling \"not installed\" into a one-command fix, and the HERE is\n # load-bearing: installing in the primary clone does nothing for this tree.\n WORKTREE_NOTE=\"\"\n if [ -f \"\\$ROOT/.git\" ]; then\n WORKTREE_NOTE=\"\\${NL} → \\$ROOT is a LINKED WORKTREE - git does not copy node_modules into a new worktree, so this is expected on a fresh one. Run the Fix Option HERE, in this worktree, not in the primary clone.\"\n fi\n if [ -z \"\\$WP_HOOK_PKG_DECLARED\" ]; then\n # FAULT U — the one shape where the X message is not merely unhelpful but actively WRONG. It asserted\n # \"declared in package.json\" without ever checking, and prescribed the one command that provably\n # cannot help: with nothing asking for the package, \\`pnpm install\\` reports \"Lockfile is up to date\"\n # and converges to the identical broken tree, forever. So say what is actually true, say out loud\n # that the install is a no-op (an agent that has already run it needs to be told to STOP), and\n # prescribe the add — which is allowlist entry ADD_HOOK_PKG, so it is reachable while this block is up.\n WP_ADD_CMD=\"${ADD_HOOK_PKG_CMD}\"\n [ -n \"\\$WP_PIN\" ] && WP_ADD_CMD=\"\\${WP_ADD_CMD}@\\$WP_PIN\"\n # The HEADLINE, kept in its own variable so DENY_EMIT_SH can paint ONLY it red (see there).\n WP_HEAD=\"❌ webpieces ai-hooks blocked this call: the guard package is not declared anywhere.\"\n REASON=\"\\$WP_HEAD\\${NL}\\${NL}${l0GuardHeader(L0_FAULT_UNDECLARED, '1 violation')}\\${NL} ${HOOK_PKG} is NOT declared in package.json anywhere, and is not installed (\\${BIN_NAME} not found)\\${NL} → .claude/settings.json still runs its hooks, so every OTHER tool call is BLOCKED. Do NOT run 'pnpm install': nothing asks for this package, so it is a NO-OP and repeating it converges to this same state.\\${NL} → ${l0MatrixCitation(L0_FAULT_UNDECLARED)}\\${NL}\\${NL}\\${WP_STILL_ALLOWED}\\${NL}\\${NL} Fix Option 1: (preferred) declare it directly, to unblock yourself right now\\${NL} run EXACTLY: '\\$WP_ADD_CMD'\\${NL} Fix Option 2: the durable fix - ${HOOK_PKG} normally arrives with @webpieces/nx-webpieces-rules, the umbrella that bundles the whole toolchain, so upgrade that once you are unblocked.\\${NL} NOT an option: if you removed ${HOOK_PKG} on purpose, delete its hooks from .claude/settings.json instead.\\${NL}\\${NL}${NO_CHAINING_RULE}\"\n else\n # The HEADLINE, kept in its own variable so DENY_EMIT_SH can paint ONLY it red (see there).\n WP_HEAD=\"❌ webpieces ai-hooks blocked this call: the webpieces guard bin is not installed.\"\n REASON=\"\\$WP_HEAD\\${NL}\\${NL}${l0GuardHeader(L0_FAULT_BIN_MISSING, '1 violation')}\\${NL} ${HOOK_PKG} is declared in package.json but is not installed (\\${BIN_NAME} not found)\\${NL} → the guards cannot run, so every OTHER tool call is BLOCKED until they can.\\${WORKTREE_NOTE}\\${NL} → ${l0MatrixCitation(L0_FAULT_BIN_MISSING)}\\${NL}\\${NL}\\${WP_STILL_ALLOWED}\\${NL}\\${NL} Fix Option 1: (preferred) the only cure - it materializes what package.json already asks for\\${NL} run EXACTLY: 'pnpm install'\\${NL} NOT an option: if you removed ${HOOK_PKG} on purpose, delete its hooks from .claude/settings.json instead.\\${NL}\\${NL}${NO_CHAINING_RULE}\"\n fi\nfi`;\n\nexport function renderShim(): string {\n return `#!/bin/sh\n# Managed by @webpieces/ai-hook-rules (wp-install-ai-hooks) — do not edit. This file is GENERATED from\n# renderShim() and is intentionally VERSION-AGNOSTIC and byte-STABLE across releases: it carries no\n# version stamp, so it only changes when its own logic changes. The installed guards binary is what\n# checks that this committed copy still matches renderShim() (the committed-shim self-guard); if you\n# revert or hand-edit this file the binary fails closed and names the cure. Checked in on purpose so\n# the hook has a stable entry point even when node_modules is absent. Safe to delete along with the\n# matching .claude/settings.json entries if you remove @webpieces/ai-hook-rules.\n#\n# Usage (wired into .claude/settings.json, ABSOLUTE so the MAIN tree governs every tree):\n# sh \"$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh\" <bin-name>\nBIN_NAME=\"$1\"\nshift\n# Resolve the tree relative to THIS script (…/<root>/.claude/webpieces/ai-hook.sh → <root>), not the\n# caller's cwd — the hook can be invoked from any directory (a subdir, or a nested clone).\nROOT=\"$(CDPATH= cd -- \"$(dirname -- \"$0\")/../..\" && pwd)\"\n# The JSON escapes every deny message below is assembled from (ANSI red, and the newlines that give the\n# deny the same scannable shape formatReport() gives every L1/L2 deny). See ESCAPES_SH's header.\n${ESCAPES_SH}\n# The BIN is resolved by walking UP from ROOT (as Node does), and BIN_ROOT records which tree supplied\n# it — the version-drift guard below compares THIS tree's pin against THAT tree's installed version.\n${RESOLVE_BIN_SH}\n${VERSION_DRIFT_GUARD_SH}\n# Read the tool payload ONCE, up front. The shim no longer exec's the bin (see RUN_BIN_SH), so it must\n# forward stdin to the bin itself — and it needs the payload again on the fail-closed path below.\nPAYLOAD=\"$(cat)\"\n${PARSE_PAYLOAD_SH}\n# Best-effort AUDIT TRAIL of what L0 did with this call — every call, not just the broken ones. One\n# tab-separated line per invocation into this TREE's own\n# logs/L0-shim/<session>-<agent|coordinator>-<binName>.log (gitignored), so the\n# observed behaviour can be diffed against the matrix in guards/L0-tooling.md. NEVER breaks or blocks the\n# hook: every write is swallowed, and nothing ever goes to stdout (stdout is the PreToolUse decision\n# channel — a stray byte there would corrupt allow/deny).\n${WP_LOG_SH}\nBROKEN_BIN=\"\"\nCRASH_MSG=\"\"\n${RUN_BIN_SH}\n# Bin missing (fresh clone before install) OR a version drift (stale node_modules) OR the bin is\n# installed but CRASHED (corrupt node_modules). The webpieces guards CANNOT safely run.\n# Before failing closed, peek at the tool payload and let ONLY package-manager install/recovery commands\n# through: the assistant's own Bash tool routes through this hook too, so blocking everything would\n# deadlock the very commands (pnpm install / rm -rf node_modules && pnpm install) that re-enable the\n# guards. A silent exit 0 = \"allow\" in the PreToolUse protocol; the guards resume once the tree is sane.\n${TRIAGE_SH}\n${DENY_REASON_SH}\n${DENY_EMIT_SH}\n`;\n}\n\n// Find the repo root that owns the committed shim to heal: walk up from `cwd` (the invocation's\n// actual dir) to the nearest ancestor holding a shim, falling back to $CLAUDE_PROJECT_DIR (which\n// Claude Code exports to hooks) only if the walk finds nothing. cwd-first keeps this correct for a\n// nested clone and testable (a temp root is honoured over the ambient project env). Returns null when\n// no committed shim exists (e.g. a global / absolute install, which has none to heal).\n//\n// Exported for install-entry.ts: on a CORRUPT node_modules, healShim is the only installer step that\n// can still run, so the installer must be able to tell the human whether a committed shim was actually\n// there to re-arm. Pure existsSync walk — never throws, so it needs no try/catch of its own.\n// webpieces-disable no-function-outside-class -- pure fs+path helper in the dependency-free shim module; it must not depend on DI (install-entry.ts relies on this loading on a corrupt tree).\nexport function findShimRoot(cwd: string): string | null {\n let dir = cwd;\n for (;;) {\n if (fs.existsSync(shimPath(dir))) return dir;\n const parent = path.dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n const env = process.env['CLAUDE_PROJECT_DIR'];\n if (env && fs.existsSync(shimPath(env))) return env;\n return null;\n}\n\n// Best-effort: keep the committed shim identical to renderShim() so the fail-closed escape hatch and\n// allowlist never drift. Only rewrites an EXISTING shim (never creates one) so global installs are\n// untouched. NEVER throws — a self-heal must never block or crash a tool call.\n//\n// The overwrite itself is correct and deliberate — shim and binary are two halves of one L0 and MUST\n// come from the same release (see shimStaleRecoveryDecision's header in ../adapters/hook-core).\n//\n// It needs no backup and no notice: the shim is a TRACKED file, so whatever it replaced is already in\n// git — `git diff` shows the rewrite, and a tamper is a working-tree modification git surfaces on its\n// own. In a consistent repo this is a no-op (committed shim already equals renderShim()); it earns its\n// keep on the upgrade path, where bumping the pin and installing leaves the committed shim behind and\n// this quietly brings it forward to be committed.\nexport function healShim(cwd: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const root = findShimRoot(cwd);\n if (!root) return;\n const target = shimPath(root);\n const desired = renderShim();\n if (fs.readFileSync(target, 'utf8') === desired) return;\n fs.writeFileSync(target, desired, { mode: 0o755 });\n fs.chmodSync(target, 0o755);\n } catch (err: unknown) {\n //const error = toError(err);\n // Ignore: healing is a convenience, not part of the guard decision.\n }\n}\n\n// ---------------------------------------------------------------------------\n// COMMITTED-SHIM SELF-GUARD — now enforced by the guards BINARY, not the shim (moved 2026-07-24).\n//\n// It used to live in the rendered shim (`cmp -s \"$0\" \"$WP_TEMPLATE\"` → fail closed). That was a\n// double-edged fix trap: the shim-matching logic lived IN the committed shim, so a bug in it could\n// only be fixed by regenerating the committed shim — which required passing the buggy shim's own gate\n// (via wp-upgrade-shim). The fix was locked behind the gate it needed to open.\n//\n// The drift guard MUST stay pre-binary (a stale validator can't be trusted to guard itself), but this\n// check's rationale — \"don't run possibly-stale shim logic\" — evaporates once the check is in the\n// binary: at that point the deciding code is the CURRENT binary from node_modules, not the reverted\n// shim. So the shim now only checks drift + bin-presence and always hands off; the binary (hook-core)\n// calls committedShimStale() and, on a mismatch, fails closed with shimStaleDenyReason() — the SAME\n// OPTION 1/2/3 message — while isShimCureCommand() lets the three cures through so the AI self-heals.\n// We deny + tell the AI; we do NOT silently rewrite the file under it. With the version stamp gone the\n// shim is byte-stable across releases, so this fires only on a genuine logic change or a real tamper.\n// ---------------------------------------------------------------------------\n\n// The root whose committed shim this BINARY governs — resolved from the RUNNING MODULE's own location,\n// never from process.cwd() and never from $CLAUDE_PROJECT_DIR. Same premise as installedShimRulesVersion()\n// below: the binary IS this package, so it can point at its OWN install.\n//\n// WHY IT MUST BE THE MODULE AND NOT THE CWD (the two-tree straddle, fixed 2026-08-03).\n// committedShimStale used to resolve its root by walking up from the invocation cwd, then compare that\n// tree's shim FILE against renderShim() — which is compiled into whichever binary is actually running.\n//\n// Which tree supplies the binary? settings.json runs $CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh,\n// and that shim derives ROOT (hence BIN) from its own $0 — so the SESSION ROOT's tree supplies BOTH the\n// shim and the binary, and that pair is self-consistent by construction. A session rooted in a linked\n// worktree runs the worktree's shim and the worktree's binary; that is fine and is NOT the bug.\n//\n// The straddle appears when an agent's SESSION ROOT and its CWD are different trees — CLAUDE_PROJECT_DIR\n// is fixed at session start, so an agent that `cd`s into another checkout keeps running the session-root\n// tree's binary while findShimRoot(cwd) walks up into the OTHER tree. Each tree carries its own\n// node_modules at its own @webpieces version (seen in the wild: 0.4.545, 0.4.560 and 0.4.526 side by\n// side, every tree internally consistent). The comparison then straddles the two and can NEVER converge:\n// curing in the cwd tree renders with THAT tree's renderShim(), which the running binary's renderShim()\n// still rejects, so the cure re-fires the deny forever (observed: an agent gave up after four cures).\n//\n// Anchoring on __dirname makes the straddle UNCONSTRUCTIBLE rather than merely discouraged. It does not\n// pick a tree and privileges none: whichever tree the running binary came from is the tree whose shim it\n// compares, so the two halves of the comparison provably come from the same install either way.\n//\n// OUTERMOST node_modules wins, not innermost: under pnpm's linked layout __dirname realpaths to\n// <root>/node_modules/.pnpm/@webpieces+ai-hook-rules@X/node_modules/@webpieces/ai-hook-rules/src/bin —\n// the outermost segment lands on <root>, an innermost/first-ancestor rule lands inside the store.\n// With no node_modules segment at all we are running from a SOURCE checkout (vitest via tsconfig paths),\n// so walk up to the nearest ancestor that owns a shim. null = no committed shim to guard.\n// webpieces-disable no-function-outside-class -- pure fs+path helper in the dependency-free shim module, beside findShimRoot/healShim.\nexport function governingShimRoot(moduleDir: string = __dirname): string | null {\n const segments = moduleDir.split(path.sep);\n const outermost = segments.indexOf('node_modules');\n if (outermost > 0) {\n const root = segments.slice(0, outermost).join(path.sep);\n return fs.existsSync(shimPath(root)) ? root : null;\n }\n // A moduleDir that STARTS with node_modules is relative, so the root before it would be '' — i.e.\n // cwd-relative, the exact input this function exists to refuse. Nothing to govern.\n if (outermost === 0) return null;\n let dir = moduleDir;\n for (;;) {\n if (fs.existsSync(shimPath(dir))) return dir;\n const parent = path.dirname(dir);\n if (parent === dir) return null;\n dir = parent;\n }\n}\n\n// True when a committed shim EXISTS but no longer equals renderShim() (reverted, hand-edited, or a shim\n// whose LOGIC predates the installed binary). Missing shim → false: a fresh clone / global install has\n// nothing to guard, matching the old shim's `[ -f \"$WP_TEMPLATE\" ]` skip. Same comparison healShim\n// makes; never throws (an unreadable tree is treated as \"not stale\" so it can't wedge a tool call).\n//\n// The root defaults to governingShimRoot() — the decision's input is the MODULE's tree, never the cwd\n// (see governingShimRoot for the straddle this closes). The parameter exists ONLY so unit tests can\n// stage a temp root; nothing in production should pass one.\n// webpieces-disable no-function-outside-class -- pure fs+path helper in the shim module, beside healShim/renderShim.\nexport function committedShimStale(root: string | null = governingShimRoot()): boolean {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (root === null) return false;\n return fs.readFileSync(shimPath(root), 'utf8') !== renderShim();\n } catch (err: unknown) {\n const error = toError(err);\n void error; // best-effort: an unreadable tree counts as \"not stale\" so this never wedges a tool call\n return false;\n }\n}\n\n// True when `command` re-arms the committed shim — the two prescribed cures plus the installer, which\n// also heals the shim as its first step. These are the only commands allowed through while a stale\n// committed shim blocks everything else, so the AI can re-arm it. Each JS twin already tolerates\n// a trailing `2>&1 | tail -N` and rejects any `&&`-chained tail (see CAPTURE_TAIL_JS_SRC).\n// webpieces-disable no-function-outside-class -- pure predicate over the exported allowlist twins; belongs beside them in the shim module.\nexport function isShimCureCommand(command: string): boolean {\n const cmd = command.trim();\n return INSTALL_HOOKS_ALLOW_JS.test(cmd) || UPGRADE_SHIM_ALLOW_JS.test(cmd) || RESTORE_SHIM_ALLOW_JS.test(cmd);\n}\n\n// The shape of the fields we read out of this package's package.json.\ninterface ShimPackageManifest {\n readonly version?: string;\n}\n\n// The installed @webpieces/ai-hook-rules version, for shimStaleDenyReason's note. The binary IS this\n// package, so it reads its OWN package.json (two dirs up from src/bin). Best-effort: '' on any failure,\n// which shimStaleDenyReason renders as no note rather than a broken one.\n// webpieces-disable no-function-outside-class -- pure fs helper beside the shim module's other version plumbing.\nexport function installedShimRulesVersion(): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8')) as ShimPackageManifest;\n return pkg.version ?? '';\n } catch (err: unknown) {\n const error = toError(err);\n void error; // best-effort: no readable version → shimStaleDenyReason prints no note\n return '';\n }\n}\n"]}
|
package/src/index.d.ts
CHANGED
|
@@ -26,3 +26,4 @@ export { run } from './core/runner';
|
|
|
26
26
|
export { stripTsNoise } from './core/strip-ts-noise';
|
|
27
27
|
export { parseDirectives, DirectiveIndex, createIsLineDisabled } from './core/disable-directives';
|
|
28
28
|
export { formatReport, ReportSubject, WRITE_SUBJECT, READ_SUBJECT, BASH_SUBJECT } from './core/report';
|
|
29
|
+
export { CodexGuardPresence, CodexSessionDetector, GuardPresenceVerdict } from './bin/codex-guard-presence';
|
package/src/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.WRITE_SUBJECT = exports.ReportSubject = exports.formatReport = exports.createIsLineDisabled = exports.DirectiveIndex = exports.parseDirectives = exports.stripTsNoise = exports.run = exports.FeatureBranchGuardRule = exports.NoJsFilesRule = exports.RedirectHowToMergeMainRule = exports.PrMergeGuardRule = exports.MergeInProgressGuardRule = exports.PrCreationOrPushGuardRule = exports.BranchCreationGuardRule = exports.NoProcessExitOutsideMainRule = exports.NoSymbolDiTokensRule = exports.ThrowCauseRequiredRule = exports.CatchErrorPatternRule = exports.NoUnmanagedExceptionsRule = exports.RequireReturnTypeRule = exports.NoDestructureRule = exports.ValidateTsInSrcRule = exports.MaxFileLinesRule = exports.NoImplicitAnyRule = exports.NoAnyUnknownRule = exports.CustomRuleAdapter = exports.EmptyRuleConfig = exports.BashRuleBase = exports.FileRuleBase = exports.EditRuleBase = exports.DisableEscape = exports.FixHint = exports.detectAiType = exports.AI_TYPE_TOKEN_SH = exports.AI_TYPE_SH = exports.FileOperation = exports.AgentHookEvent = exports.AI_TYPE_UNKNOWN = exports.AI_TYPES = exports.ResolvedRuleConfig = exports.ResolvedConfig = exports.BlockedResult = exports.RuleGroup = exports.BashContext = exports.FileContext = exports.EditContext = exports.NormalizedToolInput = exports.NormalizedEdit = exports.Violation = void 0;
|
|
4
|
-
exports.BASH_SUBJECT = exports.READ_SUBJECT = void 0;
|
|
4
|
+
exports.GuardPresenceVerdict = exports.CodexSessionDetector = exports.CodexGuardPresence = exports.BASH_SUBJECT = exports.READ_SUBJECT = void 0;
|
|
5
5
|
// Pluggable write-time validation framework for AI coding agents
|
|
6
6
|
var types_1 = require("./core/types");
|
|
7
7
|
Object.defineProperty(exports, "Violation", { enumerable: true, get: function () { return types_1.Violation; } });
|
|
@@ -91,4 +91,11 @@ Object.defineProperty(exports, "ReportSubject", { enumerable: true, get: functio
|
|
|
91
91
|
Object.defineProperty(exports, "WRITE_SUBJECT", { enumerable: true, get: function () { return report_1.WRITE_SUBJECT; } });
|
|
92
92
|
Object.defineProperty(exports, "READ_SUBJECT", { enumerable: true, get: function () { return report_1.READ_SUBJECT; } });
|
|
93
93
|
Object.defineProperty(exports, "BASH_SUBJECT", { enumerable: true, get: function () { return report_1.BASH_SUBJECT; } });
|
|
94
|
+
// Guard-presence attestation. Exported because the GATE that consumes it lives in @webpieces/pr-gate
|
|
95
|
+
// (BuildAffected.runBuildGate) — the check and its refusal belong here, beside the L0 shim stream that
|
|
96
|
+
// is their evidence, and only the throw belongs over there.
|
|
97
|
+
var codex_guard_presence_1 = require("./bin/codex-guard-presence");
|
|
98
|
+
Object.defineProperty(exports, "CodexGuardPresence", { enumerable: true, get: function () { return codex_guard_presence_1.CodexGuardPresence; } });
|
|
99
|
+
Object.defineProperty(exports, "CodexSessionDetector", { enumerable: true, get: function () { return codex_guard_presence_1.CodexSessionDetector; } });
|
|
100
|
+
Object.defineProperty(exports, "GuardPresenceVerdict", { enumerable: true, get: function () { return codex_guard_presence_1.GuardPresenceVerdict; } });
|
|
94
101
|
//# sourceMappingURL=index.js.map
|
package/src/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../../packages/tooling/ai-hook-rules/src/index.ts"],"names":[],"mappings":";;;;AAAA,iEAAiE;AACjE,sCAOsB;AALlB,kGAAA,SAAS,OAAA;AAAE,uGAAA,cAAc,OAAA;AAAE,4GAAA,mBAAmB,OAAA;AAC9C,oGAAA,WAAW,OAAA;AAAE,oGAAA,WAAW,OAAA;AAAE,oGAAA,WAAW,OAAA;AAErC,kGAAA,SAAS,OAAA;AAAE,sGAAA,aAAa,OAAA;AACxB,uGAAA,cAAc,OAAA;AAAE,2GAAA,kBAAkB,OAAA;AAGtC,oGAAoG;AACpG,oGAAoG;AACpG,yDAAyD;AACzD,kDAAsH;AAArG,uGAAA,QAAQ,OAAA;AAAE,8GAAA,eAAe,OAAA;AAAkB,6GAAA,cAAc,OAAA;AAAE,4GAAA,aAAa,OAAA;AACzF,kDAAkF;AAAzE,uGAAA,UAAU,OAAA;AAAE,6GAAA,gBAAgB,OAAA;AAAE,yGAAA,YAAY,OAAA;AAEnD,iGAAiG;AACjG,yFAAyF;AACzF,gEAAgE;AAChE,4CAAyD;AAAhD,mGAAA,OAAO,OAAA;AAAE,yGAAA,aAAa,OAAA;AAE/B,gGAAgG;AAChG,8CAA6F;AAApF,yGAAA,YAAY,OAAA;AAAE,yGAAA,YAAY,OAAA;AAAE,yGAAA,YAAY,OAAA;AAAE,4GAAA,eAAe,OAAA;AAClE,kEAA+D;AAAtD,wHAAA,iBAAiB,OAAA;AAE1B,+FAA+F;AAC/F,8DAA+D;AAAtD,kHAAA,gBAAgB,OAAA;AACzB,gEAAiE;AAAxD,oHAAA,iBAAiB,OAAA;AAC1B,8DAA+D;AAAtD,kHAAA,gBAAgB,OAAA;AACzB,sEAAsE;AAA7D,yHAAA,mBAAmB,OAAA;AAC5B,8DAAgE;AAAvD,mHAAA,iBAAiB,OAAA;AAC1B,wEAAyE;AAAhE,4HAAA,qBAAqB,OAAA;AAC9B,gFAAiF;AAAxE,oIAAA,yBAAyB,OAAA;AAClC,wEAAyE;AAAhE,4HAAA,qBAAqB,OAAA;AAC9B,0EAA2E;AAAlE,8HAAA,sBAAsB,OAAA;AAC/B,wEAAwE;AAA/D,2HAAA,oBAAoB,OAAA;AAC7B,0FAAyF;AAAhF,4IAAA,4BAA4B,OAAA;AACrC,4EAA6E;AAApE,gIAAA,uBAAuB,OAAA;AAChC,oFAAmF;AAA1E,sIAAA,yBAAyB,OAAA;AAClC,gFAAgF;AAAvE,mIAAA,wBAAwB,OAAA;AACjC,8DAA+D;AAAtD,kHAAA,gBAAgB,OAAA;AACzB,sFAAqF;AAA5E,wIAAA,0BAA0B,OAAA;AACnC,wDAAyD;AAAhD,4GAAA,aAAa,OAAA;AACtB,0EAA2E;AAAlE,8HAAA,sBAAsB,OAAA;AAE/B,wCAAoC;AAA3B,6FAAA,GAAG,OAAA;AACZ,wDAAqD;AAA5C,8GAAA,YAAY,OAAA;AACrB,gEAAkG;AAAzF,qHAAA,eAAe,OAAA;AAAE,oHAAA,cAAc,OAAA;AAAE,0HAAA,oBAAoB,OAAA;AAC9D,wCAAuG;AAA9F,sGAAA,YAAY,OAAA;AAAE,uGAAA,aAAa,OAAA;AAAE,uGAAA,aAAa,OAAA;AAAE,sGAAA,YAAY,OAAA;AAAE,sGAAA,YAAY,OAAA","sourcesContent":["// Pluggable write-time validation framework for AI coding agents\nexport {\n ToolKind, RuleScope, RuleOptions, IsLineDisabled,\n Violation, NormalizedEdit, NormalizedToolInput,\n EditContext, FileContext, BashContext,\n Rule, PlainRule,\n RuleGroup, BlockedResult,\n ResolvedConfig, ResolvedRuleConfig,\n} from './core/types';\n\n// The ONE normalized hook event every harness's payload is morphed into, and the discriminator that\n// decides which harness produced it. `AgentHookEvent` sits beside the types above because it is the\n// shape the adapters produce and the hook body consumes.\nexport { AiType, AI_TYPES, AI_TYPE_UNKNOWN, AgentEventKind, AgentHookEvent, FileOperation } from './core/agent-event';\nexport { AI_TYPE_SH, AI_TYPE_TOKEN_SH, detectAiType } from './adapters/detect-ai';\n\n// Structured fix guidance shown in blocked reports (violation + mainMessage + options + escape).\n// `Option` is NOT re-exported here — it has ONE home, `@webpieces/rules-config`, because\n// `RuleFailError` carries the same class. Import it from there.\nexport { FixHint, DisableEscape } from './core/fix-hint';\n\n// Scope-specific rule bases (each extends the shared AbstractRule from @webpieces/rules-config)\nexport { EditRuleBase, FileRuleBase, BashRuleBase, EmptyRuleConfig } from './core/rule-base';\nexport { CustomRuleAdapter } from './core/custom-rule-adapter';\n\n// Built-in rule classes — each constructed with its typed *Config from @webpieces/rules-config\nexport { NoAnyUnknownRule } from './core/rules/no-any-unknown';\nexport { NoImplicitAnyRule } from './core/rules/no-implicit-any';\nexport { MaxFileLinesRule } from './core/rules/max-file-lines';\nexport { ValidateTsInSrcRule } from './core/rules/validate-ts-in-src';\nexport { NoDestructureRule } from './core/rules/no-destructure';\nexport { RequireReturnTypeRule } from './core/rules/require-return-type';\nexport { NoUnmanagedExceptionsRule } from './core/rules/no-unmanaged-exceptions';\nexport { CatchErrorPatternRule } from './core/rules/catch-error-pattern';\nexport { ThrowCauseRequiredRule } from './core/rules/throw-cause-required';\nexport { NoSymbolDiTokensRule } from './core/rules/no-symbol-di-tokens';\nexport { NoProcessExitOutsideMainRule } from './core/rules/no-process-exit-outside-main';\nexport { BranchCreationGuardRule } from './core/rules/branch-creation-guard';\nexport { PrCreationOrPushGuardRule } from './core/rules/pr-creation-or-push-guard';\nexport { MergeInProgressGuardRule } from './core/rules/merge-in-progress-guard';\nexport { PrMergeGuardRule } from './core/rules/pr-merge-guard';\nexport { RedirectHowToMergeMainRule } from './core/rules/redirect-how-to-merge-main';\nexport { NoJsFilesRule } from './core/rules/no-js-files';\nexport { FeatureBranchGuardRule } from './core/rules/feature-branch-guard';\n\nexport { run } from './core/runner';\nexport { stripTsNoise } from './core/strip-ts-noise';\nexport { parseDirectives, DirectiveIndex, createIsLineDisabled } from './core/disable-directives';\nexport { formatReport, ReportSubject, WRITE_SUBJECT, READ_SUBJECT, BASH_SUBJECT } from './core/report';\n"]}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../../packages/tooling/ai-hook-rules/src/index.ts"],"names":[],"mappings":";;;;AAAA,iEAAiE;AACjE,sCAOsB;AALlB,kGAAA,SAAS,OAAA;AAAE,uGAAA,cAAc,OAAA;AAAE,4GAAA,mBAAmB,OAAA;AAC9C,oGAAA,WAAW,OAAA;AAAE,oGAAA,WAAW,OAAA;AAAE,oGAAA,WAAW,OAAA;AAErC,kGAAA,SAAS,OAAA;AAAE,sGAAA,aAAa,OAAA;AACxB,uGAAA,cAAc,OAAA;AAAE,2GAAA,kBAAkB,OAAA;AAGtC,oGAAoG;AACpG,oGAAoG;AACpG,yDAAyD;AACzD,kDAAsH;AAArG,uGAAA,QAAQ,OAAA;AAAE,8GAAA,eAAe,OAAA;AAAkB,6GAAA,cAAc,OAAA;AAAE,4GAAA,aAAa,OAAA;AACzF,kDAAkF;AAAzE,uGAAA,UAAU,OAAA;AAAE,6GAAA,gBAAgB,OAAA;AAAE,yGAAA,YAAY,OAAA;AAEnD,iGAAiG;AACjG,yFAAyF;AACzF,gEAAgE;AAChE,4CAAyD;AAAhD,mGAAA,OAAO,OAAA;AAAE,yGAAA,aAAa,OAAA;AAE/B,gGAAgG;AAChG,8CAA6F;AAApF,yGAAA,YAAY,OAAA;AAAE,yGAAA,YAAY,OAAA;AAAE,yGAAA,YAAY,OAAA;AAAE,4GAAA,eAAe,OAAA;AAClE,kEAA+D;AAAtD,wHAAA,iBAAiB,OAAA;AAE1B,+FAA+F;AAC/F,8DAA+D;AAAtD,kHAAA,gBAAgB,OAAA;AACzB,gEAAiE;AAAxD,oHAAA,iBAAiB,OAAA;AAC1B,8DAA+D;AAAtD,kHAAA,gBAAgB,OAAA;AACzB,sEAAsE;AAA7D,yHAAA,mBAAmB,OAAA;AAC5B,8DAAgE;AAAvD,mHAAA,iBAAiB,OAAA;AAC1B,wEAAyE;AAAhE,4HAAA,qBAAqB,OAAA;AAC9B,gFAAiF;AAAxE,oIAAA,yBAAyB,OAAA;AAClC,wEAAyE;AAAhE,4HAAA,qBAAqB,OAAA;AAC9B,0EAA2E;AAAlE,8HAAA,sBAAsB,OAAA;AAC/B,wEAAwE;AAA/D,2HAAA,oBAAoB,OAAA;AAC7B,0FAAyF;AAAhF,4IAAA,4BAA4B,OAAA;AACrC,4EAA6E;AAApE,gIAAA,uBAAuB,OAAA;AAChC,oFAAmF;AAA1E,sIAAA,yBAAyB,OAAA;AAClC,gFAAgF;AAAvE,mIAAA,wBAAwB,OAAA;AACjC,8DAA+D;AAAtD,kHAAA,gBAAgB,OAAA;AACzB,sFAAqF;AAA5E,wIAAA,0BAA0B,OAAA;AACnC,wDAAyD;AAAhD,4GAAA,aAAa,OAAA;AACtB,0EAA2E;AAAlE,8HAAA,sBAAsB,OAAA;AAE/B,wCAAoC;AAA3B,6FAAA,GAAG,OAAA;AACZ,wDAAqD;AAA5C,8GAAA,YAAY,OAAA;AACrB,gEAAkG;AAAzF,qHAAA,eAAe,OAAA;AAAE,oHAAA,cAAc,OAAA;AAAE,0HAAA,oBAAoB,OAAA;AAC9D,wCAAuG;AAA9F,sGAAA,YAAY,OAAA;AAAE,uGAAA,aAAa,OAAA;AAAE,uGAAA,aAAa,OAAA;AAAE,sGAAA,YAAY,OAAA;AAAE,sGAAA,YAAY,OAAA;AAE/E,qGAAqG;AACrG,uGAAuG;AACvG,4DAA4D;AAC5D,mEAA4G;AAAnG,0HAAA,kBAAkB,OAAA;AAAE,4HAAA,oBAAoB,OAAA;AAAE,4HAAA,oBAAoB,OAAA","sourcesContent":["// Pluggable write-time validation framework for AI coding agents\nexport {\n ToolKind, RuleScope, RuleOptions, IsLineDisabled,\n Violation, NormalizedEdit, NormalizedToolInput,\n EditContext, FileContext, BashContext,\n Rule, PlainRule,\n RuleGroup, BlockedResult,\n ResolvedConfig, ResolvedRuleConfig,\n} from './core/types';\n\n// The ONE normalized hook event every harness's payload is morphed into, and the discriminator that\n// decides which harness produced it. `AgentHookEvent` sits beside the types above because it is the\n// shape the adapters produce and the hook body consumes.\nexport { AiType, AI_TYPES, AI_TYPE_UNKNOWN, AgentEventKind, AgentHookEvent, FileOperation } from './core/agent-event';\nexport { AI_TYPE_SH, AI_TYPE_TOKEN_SH, detectAiType } from './adapters/detect-ai';\n\n// Structured fix guidance shown in blocked reports (violation + mainMessage + options + escape).\n// `Option` is NOT re-exported here — it has ONE home, `@webpieces/rules-config`, because\n// `RuleFailError` carries the same class. Import it from there.\nexport { FixHint, DisableEscape } from './core/fix-hint';\n\n// Scope-specific rule bases (each extends the shared AbstractRule from @webpieces/rules-config)\nexport { EditRuleBase, FileRuleBase, BashRuleBase, EmptyRuleConfig } from './core/rule-base';\nexport { CustomRuleAdapter } from './core/custom-rule-adapter';\n\n// Built-in rule classes — each constructed with its typed *Config from @webpieces/rules-config\nexport { NoAnyUnknownRule } from './core/rules/no-any-unknown';\nexport { NoImplicitAnyRule } from './core/rules/no-implicit-any';\nexport { MaxFileLinesRule } from './core/rules/max-file-lines';\nexport { ValidateTsInSrcRule } from './core/rules/validate-ts-in-src';\nexport { NoDestructureRule } from './core/rules/no-destructure';\nexport { RequireReturnTypeRule } from './core/rules/require-return-type';\nexport { NoUnmanagedExceptionsRule } from './core/rules/no-unmanaged-exceptions';\nexport { CatchErrorPatternRule } from './core/rules/catch-error-pattern';\nexport { ThrowCauseRequiredRule } from './core/rules/throw-cause-required';\nexport { NoSymbolDiTokensRule } from './core/rules/no-symbol-di-tokens';\nexport { NoProcessExitOutsideMainRule } from './core/rules/no-process-exit-outside-main';\nexport { BranchCreationGuardRule } from './core/rules/branch-creation-guard';\nexport { PrCreationOrPushGuardRule } from './core/rules/pr-creation-or-push-guard';\nexport { MergeInProgressGuardRule } from './core/rules/merge-in-progress-guard';\nexport { PrMergeGuardRule } from './core/rules/pr-merge-guard';\nexport { RedirectHowToMergeMainRule } from './core/rules/redirect-how-to-merge-main';\nexport { NoJsFilesRule } from './core/rules/no-js-files';\nexport { FeatureBranchGuardRule } from './core/rules/feature-branch-guard';\n\nexport { run } from './core/runner';\nexport { stripTsNoise } from './core/strip-ts-noise';\nexport { parseDirectives, DirectiveIndex, createIsLineDisabled } from './core/disable-directives';\nexport { formatReport, ReportSubject, WRITE_SUBJECT, READ_SUBJECT, BASH_SUBJECT } from './core/report';\n\n// Guard-presence attestation. Exported because the GATE that consumes it lives in @webpieces/pr-gate\n// (BuildAffected.runBuildGate) — the check and its refusal belong here, beside the L0 shim stream that\n// is their evidence, and only the throw belongs over there.\nexport { CodexGuardPresence, CodexSessionDetector, GuardPresenceVerdict } from './bin/codex-guard-presence';\n"]}
|