@webpieces/ai-hook-rules 0.4.629 → 0.4.631
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/adapters/claude-code-response.js +16 -1
- package/src/adapters/claude-code-response.js.map +1 -1
- package/src/adapters/hook-core.js +1 -1
- package/src/adapters/hook-core.js.map +1 -1
- package/src/bin/shim-audit-log.d.ts +3 -2
- package/src/bin/shim-audit-log.js +31 -10
- package/src/bin/shim-audit-log.js.map +1 -1
- package/src/bin/shim-deny-reason.d.ts +0 -5
- package/src/bin/shim-deny-reason.js +199 -108
- package/src/bin/shim-deny-reason.js.map +1 -1
- package/src/bin/shim.js +55 -14
- package/src/bin/shim.js.map +1 -1
- package/src/core/decision-log.d.ts +12 -4
- package/src/core/decision-log.js +13 -5
- package/src/core/decision-log.js.map +1 -1
- package/src/core/l0-fault-codes.d.ts +46 -0
- package/src/core/l0-fault-codes.js +55 -1
- package/src/core/l0-fault-codes.js.map +1 -1
- package/src/core/l0-matrix.d.ts +23 -4
- package/src/core/l0-matrix.js +66 -21
- package/src/core/l0-matrix.js.map +1 -1
- package/src/core/rules/branch-switch-scan.d.ts +38 -0
- package/src/core/rules/branch-switch-scan.js +127 -0
- package/src/core/rules/branch-switch-scan.js.map +1 -0
- package/src/core/rules/redirect-how-to-merge-main.d.ts +1 -0
- package/src/core/rules/redirect-how-to-merge-main.js +15 -5
- package/src/core/rules/redirect-how-to-merge-main.js.map +1 -1
- package/src/core/rules/stale-main-bash-guard.d.ts +1 -6
- package/src/core/rules/stale-main-bash-guard.js +7 -21
- package/src/core/rules/stale-main-bash-guard.js.map +1 -1
- package/src/core/runner.js +1 -1
- package/src/core/runner.js.map +1 -1
- package/templates/ai-hook.sh +53 -19
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.shimStaleDenyReason = shimStaleDenyReason;
|
|
4
4
|
const rules_config_1 = require("@webpieces/rules-config");
|
|
5
|
+
const l0_fault_codes_1 = require("../core/l0-fault-codes");
|
|
5
6
|
const l0_allowlist_1 = require("./l0-allowlist");
|
|
6
7
|
const managed_env_1 = require("./managed-env");
|
|
7
8
|
const shim_1 = require("./shim");
|
|
@@ -9,115 +10,205 @@ const shim_1 = require("./shim");
|
|
|
9
10
|
* THE FAIL-CLOSED DENY TEXT for a drifted managed hook surface (L0 fault S) — its own module because
|
|
10
11
|
* shim.ts is at its line cap and this is one cohesive unit: the words a blocked agent reads, and
|
|
11
12
|
* nothing else. It imports FROM shim.ts and is never imported BY it, so the graph stays acyclic.
|
|
13
|
+
*
|
|
14
|
+
* IT IS RENDERED IN THE HOUSE FORMAT, the same skeleton core/report.ts (formatReport) gives every L1
|
|
15
|
+
* and L2 deny: a header naming what was blocked, a `[guard-name]` block listing the offenders with a
|
|
16
|
+
* one-line `→ why`, what is still allowed, then numbered `Fix Option N:` lines each with its command
|
|
17
|
+
* on its own line. L0 used to be the ONLY layer in webpieces that answered in one unbroken paragraph —
|
|
18
|
+
* ~3,300 characters of it here — so the two commands that matter were buried in prose and there was no
|
|
19
|
+
* guard name to grep for. Nothing about the DECISION changed; only the shape of the words.
|
|
20
|
+
*
|
|
21
|
+
* SHORTER IS NOT THE GOAL, SCANNABLE IS. The budget below still exists (a paragraph regrows when every
|
|
22
|
+
* new finding argues its case here), but a section that earns a line gets a line.
|
|
23
|
+
*
|
|
24
|
+
* CONSTRAINT: the returned string must contain no `"` and no `\` — it is JSON-serialized by denyJson()
|
|
25
|
+
* (a stray quote/backslash would corrupt the PreToolUse decision payload, not just the text). That is an
|
|
26
|
+
* INVARIANT, not a hope, so every interpolated path is STRIPPED of both rather than trusted — a
|
|
27
|
+
* Windows-style path or an odd directory name must not be able to corrupt the decision. Locked by unit
|
|
28
|
+
* tests. An unusual root is also dropped from the `cd` cure rather than quoted (CD_PREFIX would reject it).
|
|
29
|
+
*
|
|
30
|
+
* NEWLINES ARE SAFE HERE AND NEEDED NO NEW MECHANISM. denyJson() runs JSON.stringify, which escapes a
|
|
31
|
+
* real newline to the two-character `\n` on the wire, and Claude Code's parser turns it back. This is
|
|
32
|
+
* already proven in production — every L1 deny is formatReport()'s multi-line string down this exact
|
|
33
|
+
* path. The sh half of L0 (faults D/X/U/K in renderShim) cannot do this: it printf's REASON into a JSON
|
|
34
|
+
* string literal, so it spells its newlines `${NL}` the same way it spells the ANSI escape `${ESC}`.
|
|
35
|
+
* A real newline is neither a quote nor a backslash, so the JSON-safety assertions above are untouched.
|
|
12
36
|
*/
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
37
|
+
class ShimStaleDeny {
|
|
38
|
+
installedVersion;
|
|
39
|
+
/** The governing root, STRIPPED of `"` and `\` for display. '' when there is none to name. */
|
|
40
|
+
safeRoot;
|
|
41
|
+
/** CLAUDE_PROJECT_DIR as this process sees it, stripped the same way. */
|
|
42
|
+
projectDir;
|
|
43
|
+
drifted;
|
|
44
|
+
inSubagent;
|
|
45
|
+
/**
|
|
46
|
+
* Whether the RAW root can carry a leading `cd <root> &&`. Tested against the raw root, never the
|
|
47
|
+
* stripped one: stripping is a display-safety measure, and cd-anchoring to a path we just mangled
|
|
48
|
+
* would prescribe a cd into a directory that does not exist. A root CD_PREFIX cannot express is
|
|
49
|
+
* simply not offered as a `cd` (raw ok ⇒ safeRoot === root).
|
|
50
|
+
*/
|
|
51
|
+
cdOk;
|
|
52
|
+
constructor(installedVersion, root, drifted, inSubagent) {
|
|
53
|
+
this.installedVersion = installedVersion;
|
|
54
|
+
this.safeRoot = root.replace(/["\\]/g, '');
|
|
55
|
+
this.projectDir = rules_config_1.claudeEnv.projectDirForLog().replace(/["\\]/g, '');
|
|
56
|
+
this.drifted = drifted;
|
|
57
|
+
this.inSubagent = inSubagent;
|
|
58
|
+
this.cdOk = root !== '' && /^[A-Za-z0-9._/@~+-]+$/.test(root);
|
|
59
|
+
}
|
|
60
|
+
render() {
|
|
61
|
+
return [
|
|
62
|
+
...this.header(),
|
|
63
|
+
...this.measured(),
|
|
64
|
+
...this.caller(),
|
|
65
|
+
...this.stillAllowed(),
|
|
66
|
+
...this.fixOptions(),
|
|
67
|
+
...this.footer(),
|
|
68
|
+
].join('\n');
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* `[managed-hook-surface]` and the surfaces that drifted, one per line.
|
|
72
|
+
*
|
|
73
|
+
* `drifted` names WHICH of the three managed things moved — .claude/webpieces/ai-hook.sh, the
|
|
74
|
+
* .claude/settings.json hook registration, and its managed env entry (see hook-registration.ts). It
|
|
75
|
+
* is REQUIRED, not optional: this used to be a shim-only message, and an optional list would let a
|
|
76
|
+
* caller silently keep emitting the one-file text after the surface grew — the "two spellings of one
|
|
77
|
+
* thing" shape the compatibility policy rejects. It was FOUR; guarantee-root.sh (L-1) is gone,
|
|
78
|
+
* because the guard hooks are registered ABSOLUTE now and there is no second .sh to keep byte-locked.
|
|
79
|
+
*
|
|
80
|
+
* THE CAUSE IS A LIST. It used to assert flatly "(it was reverted or hand-edited)", which is
|
|
81
|
+
* frequently FALSE — the common case is a shim whose logic simply predates this binary — and that
|
|
82
|
+
* false certainty sent a real agent hunting for a tamper that never happened.
|
|
83
|
+
*/
|
|
84
|
+
header() {
|
|
85
|
+
const verNote = this.installedVersion ? ` (installed version ${this.installedVersion})` : '';
|
|
86
|
+
const n = this.drifted.length;
|
|
87
|
+
const label = n === 1 ? '1 surface drifted' : `${String(n)} surfaces drifted`;
|
|
88
|
+
return [
|
|
89
|
+
'❌ webpieces ai-hooks blocked this call: a webpieces-managed hook surface no longer matches the installed guard binary.',
|
|
90
|
+
'',
|
|
91
|
+
(0, l0_fault_codes_1.l0GuardHeader)(l0_fault_codes_1.L0_FAULT_SHIM_STALE, label),
|
|
92
|
+
...this.drifted.map((surface) => ` ${surface}`),
|
|
93
|
+
` → webpieces manages those THREE things as ONE set, GENERATED by the INSTALLED @webpieces/ai-hook-rules${verNote}; what is on disk is reverted, hand-edited, or predating this binary. The env entry is ${managed_env_1.BASH_CWD_ENV_KEY}=${managed_env_1.BASH_CWD_ENV_VALUE}, which pins the Bash cwd to the project root, identically for every subagent because settings env is inherited.`,
|
|
94
|
+
` → ${(0, l0_fault_codes_1.l0MatrixCitation)(l0_fault_codes_1.L0_FAULT_SHIM_STALE)}`,
|
|
95
|
+
'',
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* WHERE IT WAS MEASURED, in the deny itself and not only in the logs. #574 put `root=` and
|
|
100
|
+
* `projectDir=` on every L1 invocation line (see decision-log / ClaudeEnv.projectDirForLog, whose
|
|
101
|
+
* `<unset>` token keeps "variable absent" distinguishable from "set to empty"). The log is forensics
|
|
102
|
+
* AFTER the fact; the deny is what a blocked agent reads IN the moment, and the absence of exactly
|
|
103
|
+
* these two fields is what sent a real agent chasing the wrong mechanism for four cures. Same field
|
|
104
|
+
* names on purpose, so the deny text and the log lines grep together.
|
|
105
|
+
*
|
|
106
|
+
* Agreement is the routine case; DISAGREEMENT is the signature of the session-root-vs-cwd split this
|
|
107
|
+
* guard was rewritten to make unconstructible, so it gets said out loud rather than left to inference.
|
|
108
|
+
*/
|
|
109
|
+
measured() {
|
|
110
|
+
if (this.safeRoot === '')
|
|
111
|
+
return [];
|
|
112
|
+
return [
|
|
113
|
+
'Where this was measured:',
|
|
114
|
+
` root=${this.safeRoot} - the tree whose shim was compared, and the one to repair`,
|
|
115
|
+
` projectDir=${this.projectDir} - CLAUDE_PROJECT_DIR as this process sees it; <unset> = absent, not set-but-empty`,
|
|
116
|
+
` → ${this.verdict()}`,
|
|
117
|
+
'',
|
|
118
|
+
];
|
|
119
|
+
}
|
|
120
|
+
verdict() {
|
|
121
|
+
return this.callerIsInTheTree()
|
|
122
|
+
? 'These two AGREE, so this is the ordinary case - the tree you are in is the tree being judged.'
|
|
123
|
+
: 'These two DISAGREE - the tree being judged is NOT the one CLAUDE_PROJECT_DIR names, so cure the root= tree and do not assume your cwd is it.';
|
|
124
|
+
}
|
|
125
|
+
/** True when the tree needing repair is the caller's own tree — the input the caller branch gates on. */
|
|
126
|
+
callerIsInTheTree() {
|
|
127
|
+
return this.safeRoot === this.projectDir;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* THE CALLER-GATED BRANCH. It changes the WORDS ONLY — never the block/allow decision, never which
|
|
131
|
+
* command is printed, and never which tree anything acts on (that is decided from the path, which is
|
|
132
|
+
* why the deleted AgentIdentity class is NOT coming back for anything but message shape; agent
|
|
133
|
+
* identity was measured untrustworthy as a location signal when a worktree agent resumed on the
|
|
134
|
+
* primary clone after its tree was reaped).
|
|
135
|
+
*
|
|
136
|
+
* Two inputs, both already on hand:
|
|
137
|
+
* 1. `inSubagent` — from the payload's `agent_id`, which Claude Code populates ONLY off the main
|
|
138
|
+
* loop (main falls back to the session id, so the field is absent there). `agent_type` is NOT
|
|
139
|
+
* usable: it is always populated and discriminates nothing. REQUIRED for the same reason
|
|
140
|
+
* `drifted` is — an optional flag would let a caller keep emitting the main-loop text.
|
|
141
|
+
* 2. root= vs projectDir= — different means the caller is not standing in the tree to repair.
|
|
142
|
+
*
|
|
143
|
+
* A main agent, and a subagent whose cwd IS the tree, get the cure and nothing else: they can run it,
|
|
144
|
+
* see the result and commit it. A WORKTREE-ISOLATED subagent gets one extra step, and only because it
|
|
145
|
+
* is true — MEASURED 2026-08-11: such an agent CAN run `cd <main> && pnpm exec wp-upgrade-shim` and it
|
|
146
|
+
* works (the harness refuses cross-tree GIT operations, not this), but it can neither verify nor
|
|
147
|
+
* commit the result, because `git -C <main>` is refused. So the escalation is of the COMMIT, not of
|
|
148
|
+
* the repair, and the text must never tell it a local cure cannot work — the older wording asserted
|
|
149
|
+
* exactly that and was false in the window where it fired (measured 2026-08-10: a worktree subagent
|
|
150
|
+
* cured in place and the block lifted, with the deny's own root= naming that worktree).
|
|
151
|
+
*
|
|
152
|
+
* WHAT IS DELIBERATELY GONE: the "ask the coordinator to run pnpm install so both trees are on the
|
|
153
|
+
* same @webpieces version" clause. Both hooks are registered ABSOLUTE, so every tree is already
|
|
154
|
+
* judged by MAIN's shim and MAIN's binary — there is no version alignment left to ask for, and
|
|
155
|
+
* asking sends an agent after a non-problem.
|
|
156
|
+
*/
|
|
157
|
+
caller() {
|
|
158
|
+
// No governing root to name means no `Where this was measured` section either, so there is no
|
|
159
|
+
// root= for this text to point at and nothing to escalate ABOUT. Silence beats a dangling field.
|
|
160
|
+
if (this.safeRoot === '' || !this.inSubagent || this.callerIsInTheTree())
|
|
161
|
+
return [];
|
|
162
|
+
return [
|
|
163
|
+
'You are a SUBAGENT and root= is not the tree you are standing in, so this takes TWO steps:',
|
|
164
|
+
' 1. Run Fix Option 1 below exactly as printed. It is already anchored to the tree that must change, and a worktree-isolated subagent CAN run it against another tree - that was measured and it works, so never conclude a local cure cannot work.',
|
|
165
|
+
` 2. Then ESCALATE THE COMMIT, which is the part you cannot do: git -C ${this.safeRoot} is refused here, so you can neither verify nor commit what the cure regenerated. Tell the coordinator to run git status in ${this.safeRoot} and commit the regenerated shim.`,
|
|
166
|
+
'',
|
|
167
|
+
];
|
|
168
|
+
}
|
|
169
|
+
stillAllowed() {
|
|
170
|
+
return [
|
|
171
|
+
'Still allowed while this block is up:',
|
|
172
|
+
' - any Read',
|
|
173
|
+
' - any Write/Edit whose target is webpieces.config.json',
|
|
174
|
+
' - every command on the L0 allowlist, including both Fix Options below',
|
|
175
|
+
' THIS IS NOT A DEADLOCK: both options are explicitly ALLOWED through, so run one YOURSELF now - do not hand it back to the human. Every OTHER tool call is blocked until all three match again.',
|
|
176
|
+
'',
|
|
177
|
+
];
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* The two cures, house-numbered. ORDER IS LOAD-BEARING: wp-upgrade-shim LEADS because it is the only
|
|
181
|
+
* cure that repairs all three managed surfaces, it touches no config and imports only fs/path, so it
|
|
182
|
+
* runs on a tree too broken to load the rule engine. The `cp` stays last as the pre-0.4.408 fallback.
|
|
183
|
+
*
|
|
184
|
+
* Both are anchored with a leading `cd <root> &&` when the root allows it — CD_PREFIX_*_ANCHORED
|
|
185
|
+
* tolerates exactly that one prefix (locked by unit test), and it is what keeps the cure curable when
|
|
186
|
+
* the AI's cwd is a DIFFERENT tree than the one being judged. OPTION 2 is a relative-path `cp`, so it
|
|
187
|
+
* is even MORE cwd-sensitive than OPTION 1 — it is anchored too. Never a SECOND `cd … &&`: the
|
|
188
|
+
* allowlist matches the whole command and three segments are denied.
|
|
189
|
+
*/
|
|
190
|
+
fixOptions() {
|
|
191
|
+
const anchor = (cmd) => (this.cdOk ? `cd ${this.safeRoot} && ${cmd}` : cmd);
|
|
192
|
+
return [
|
|
193
|
+
' Fix Option 1: (preferred) the only cure that repairs all three managed things, and it runs on a broken tree',
|
|
194
|
+
` run EXACTLY: '${anchor(l0_allowlist_1.UPGRADE_SHIM_CMD)}'`,
|
|
195
|
+
` Fix Option 2: PARTIAL - repairs ${shim_1.SHIM_MARKER} only. Pick it ONLY when the installed @webpieces/ai-hook-rules is older than 0.4.408, where Fix Option 1 does not exist yet; then upgrade and run Fix Option 1.`,
|
|
196
|
+
` run EXACTLY: '${anchor(l0_allowlist_1.RESTORE_SHIM_CMD)}'`,
|
|
197
|
+
` NOT an option: do NOT use the bare '${l0_allowlist_1.INSTALL_HOOKS_CMD}' here - it also migrates your config and PROMPTS for a hook target twice, which hangs a non-interactive session.`,
|
|
198
|
+
'',
|
|
199
|
+
];
|
|
200
|
+
}
|
|
201
|
+
footer() {
|
|
202
|
+
return [
|
|
203
|
+
shim_1.NO_CHAINING_RULE,
|
|
204
|
+
`If you meant to remove @webpieces/ai-hook-rules, delete its hooks from .claude/settings.json rather than reverting ${shim_1.SHIM_MARKER}.`,
|
|
205
|
+
];
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// The ONE entry point its two call sites (hook-core's fault-S deny, and the L0 fault table in
|
|
209
|
+
// l0-matrix) import. The rendering lives on ShimStaleDeny above, per CLAUDE.md; this is the seam.
|
|
210
|
+
// webpieces-disable no-function-outside-class -- one-line constructor+render seam for ShimStaleDeny, in the dependency-free shim module (it must stay callable from a tree too broken to build a DI container).
|
|
95
211
|
function shimStaleDenyReason(installedVersion, root, drifted, inSubagent) {
|
|
96
|
-
|
|
97
|
-
const what = drifted.join(', ');
|
|
98
|
-
const safeRoot = root.replace(/["\\]/g, '');
|
|
99
|
-
const projectDir = rules_config_1.claudeEnv.projectDirForLog().replace(/["\\]/g, '');
|
|
100
|
-
// Tested against the RAW root, never the stripped one: stripping is a display-safety measure, and
|
|
101
|
-
// cd-anchoring to a path we just mangled would prescribe a cd into a directory that does not exist.
|
|
102
|
-
// A root CD_PREFIX cannot express is simply not offered as a `cd` (raw ok ⇒ safeRoot === root).
|
|
103
|
-
const cdOk = root !== '' && /^[A-Za-z0-9._/@~+-]+$/.test(root);
|
|
104
|
-
// Agreement is the routine case; DISAGREEMENT is the signature of the session-root-vs-cwd split this
|
|
105
|
-
// guard was rewritten to make unconstructible, so it gets said out loud rather than left to inference.
|
|
106
|
-
const verdict = safeRoot === projectDir
|
|
107
|
-
? 'These two AGREE, so this is the ordinary case - the tree you are in is the tree being judged.'
|
|
108
|
-
: 'These two DISAGREE - the tree being judged is NOT the one CLAUDE_PROJECT_DIR names, so cure the root= tree and do not assume your cwd is it.';
|
|
109
|
-
const rootNote = safeRoot === '' ? '' : ` WHERE THIS WAS MEASURED: root=${safeRoot} (the tree whose shim was compared - the one to repair), projectDir=${projectDir} (CLAUDE_PROJECT_DIR as this process sees it; <unset> = absent, not set-but-empty). ${verdict}`;
|
|
110
|
-
const upgrade = cdOk ? `cd ${safeRoot} && ${l0_allowlist_1.UPGRADE_SHIM_CMD}` : l0_allowlist_1.UPGRADE_SHIM_CMD;
|
|
111
|
-
// OPTION 2 is a relative-path `cp`, so it is even MORE cwd-sensitive than OPTION 1 — anchor it too.
|
|
112
|
-
const restore = cdOk ? `cd ${safeRoot} && ${l0_allowlist_1.RESTORE_SHIM_CMD}` : l0_allowlist_1.RESTORE_SHIM_CMD;
|
|
113
|
-
// The subagent sentence goes BEFORE the options, so it is read before a cure is chosen rather than
|
|
114
|
-
// after one has already been run in the wrong tree. WHICH tree is already answered by `verdict`
|
|
115
|
-
// above, from root= vs projectDir=; this adds only what is specific to a subagent — that A and B are
|
|
116
|
-
// BOTH real and fix different things, and that B is an ESCALATION because a subagent cannot reach
|
|
117
|
-
// the main clone.
|
|
118
|
-
const subagentNote = inSubagent
|
|
119
|
-
? ' YOU ARE RUNNING IN A SUBAGENT, so TWO cures are real and they fix DIFFERENT things: A makes THIS tree work now, B stops the two trees disagreeing. A - run the OPTION below exactly as printed; it is already anchored to the tree that must change, and running it from here DOES lift this block (measured), so never conclude a local cure cannot work. B - you cannot reach the main clone, so ESCALATE: ask the coordinator to run pnpm install in the main tree so both trees are on the same @webpieces version. A worktree NEEDS its own node_modules (nx, vitest and the eslint plugin all load from it); the rule is not no-install-here, it is that this tree @webpieces must EQUAL the main tree.'
|
|
120
|
-
: '';
|
|
121
|
-
return `❌ webpieces-managed hook surface was changed: ${what} no longer matches what the INSTALLED @webpieces/ai-hook-rules${verNote} expects (reverted, hand-edited, or predating this binary).${rootNote} webpieces manages THREE things and they only work as a set: ${shim_1.SHIM_MARKER} (the guard shim), the .claude/settings.json entries that register it, and the .claude/settings.json env entry ${managed_env_1.BASH_CWD_ENV_KEY}=${managed_env_1.BASH_CWD_ENV_VALUE} (which pins the Bash cwd to the project root, identically for every subagent because settings env is inherited). Every OTHER tool call is blocked until all three match again.${subagentNote} THIS IS NOT A DEADLOCK: both options below are explicitly ALLOWED through while this guard is up, so run one YOURSELF now - do not hand it back to the human. OPTION 1 (preferred - the only one that repairs all three, and it runs on a broken tree) - run EXACTLY this command: '${upgrade}'. OPTION 2 (PARTIAL - repairs ${shim_1.SHIM_MARKER} only; pick it ONLY when the installed @webpieces/ai-hook-rules is older than 0.4.408, where OPTION 1 does not exist yet, then upgrade and run OPTION 1) - run EXACTLY this command: '${restore}'. Do NOT use the bare '${l0_allowlist_1.INSTALL_HOOKS_CMD}' here: it also migrates your config and PROMPTS for a hook target twice, which hangs a non-interactive session. ${shim_1.NO_CHAINING_RULE} If you meant to remove @webpieces/ai-hook-rules, delete its hooks from .claude/settings.json rather than reverting these files.`;
|
|
212
|
+
return new ShimStaleDeny(installedVersion, root, drifted, inSubagent).render();
|
|
122
213
|
}
|
|
123
214
|
//# sourceMappingURL=shim-deny-reason.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"shim-deny-reason.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/shim-deny-reason.ts"],"names":[],"mappings":"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","sourcesContent":["import { claudeEnv } from '@webpieces/rules-config';\n\nimport { INSTALL_HOOKS_CMD, RESTORE_SHIM_CMD, UPGRADE_SHIM_CMD } from './l0-allowlist';\nimport { BASH_CWD_ENV_KEY, BASH_CWD_ENV_VALUE } from './managed-env';\nimport { NO_CHAINING_RULE, SHIM_MARKER } from './shim';\n\n/**\n * THE FAIL-CLOSED DENY TEXT for a drifted managed hook surface (L0 fault S) — its own module because\n * shim.ts is at its line cap and this is one cohesive unit: the words a blocked agent reads, and\n * nothing else. It imports FROM shim.ts and is never imported BY it, so the graph stays acyclic.\n */\n// The fail-closed deny text for a drifted MANAGED HOOK SURFACE, built from the single-source cure\n// constants + NO_CHAINING_RULE.\n//\n// `drifted` names WHICH of the three managed things moved — .claude/webpieces/ai-hook.sh, the\n// .claude/settings.json hook registration, and its managed env entry\n// CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR (see hook-registration.ts). It is REQUIRED, not optional:\n// this used to be a shim-only message, and an optional list would let a caller silently keep emitting\n// the one-file text after the surface grew — which is the \"two spellings of one thing\" shape the\n// compatibility policy rejects.\n//\n// It was FOUR. guarantee-root.sh (L-1) is gone: the guard hooks are registered ABSOLUTE now, so the\n// launch guarantee L-1 provided is structural and there is no second .sh file to keep byte-locked.\n//\n// `installedVersion` names WHICH webpieces the cure re-arms to (the binary is that\n// version); pass '' to omit the note rather than print an empty one. `root` is the tree the deciding\n// binary GOVERNS (governingShimRoot) — naming it, and anchoring the cure to it with a leading\n// `cd <root> &&` (which CD_PREFIX_*_ANCHORED already tolerates, locked by a unit test), is what keeps\n// the cure curable when the AI's cwd is a DIFFERENT tree than the one being judged. Pass '' to omit.\n//\n// The cause list is deliberately a LIST: it used to assert flatly \"(it was reverted or hand-edited)\",\n// which is frequently FALSE — the common case is a shim whose logic simply predates this binary — and\n// that false certainty sent a real agent hunting for a tamper that never happened.\n//\n// WHERE IT WAS MEASURED, in the deny itself and not only in the logs. #574 put `root=` and\n// `projectDir=` on every L1 invocation line (see decision-log / ClaudeEnv.projectDirForLog, whose\n// `<unset>` token keeps \"variable absent\" distinguishable from \"set to empty\"). The log is forensics\n// AFTER the fact; the deny is what a blocked agent reads IN the moment, and the absence of exactly\n// these two fields is what sent a real agent chasing the wrong mechanism for four cures. Same field\n// names on purpose, so the deny text and the log lines grep together.\n//\n// THE MESSAGE DIET IS PART OF THE CONTRACT, and this deny is where it regresses. main landed a\n// deliberate L0 message diet (384cdae) and blocks grow straight back into a wall of text when each new\n// finding argues its case here; at eleven sections this one was ~4,000 chars, and `denyBudget` in\n// shim-deny-reason.spec.ts now fails the build if it climbs back. Keep what CHANGES what the reader\n// types (the two exact commands, that it is not a deadlock, the no-chaining rule, root=/projectDir=);\n// cut what merely argues. What was cut and why:\n// - the \"up to and including 0.4.588 OPTION 1 was inert, so EMPTY OUTPUT means it did not run\"\n// paragraph. It was UNREACHABLE by construction: this text and `wp-upgrade-shim` ship in the SAME\n// package at the SAME version, so a binary new enough to print this sentence necessarily has the\n// process entry point 0.4.589 added. The incident is still recorded where it can bite —\n// bin-process-entry.spec.ts, which asserts the entry point exists.\n// - the \"registered ABSOLUTE / a worktree borrows by walking up\" aside. That is the DRIFT fault's\n// subject (WP_BORROW_NOTE in shim.ts) and it is restated in the guard-matrix doc this deny points at.\n// - \"they are GENERATED and committed ... must NOT be reverted by hand\", which said twice over what\n// the closing sentence already says once.\n//\n// CONSTRAINT: the returned string must contain no `\"` and no `\\` — it is JSON-serialized by denyJson()\n// (a stray quote/backslash would corrupt the PreToolUse decision payload, not just the text). That is an\n// INVARIANT, not a hope, so every interpolated path is STRIPPED of both rather than trusted — a\n// Windows-style path or an odd directory name must not be able to corrupt the decision. Locked by unit\n// tests. An unusual root is also dropped from the `cd` cure rather than quoted (CD_PREFIX would reject it).\n//\n// `inSubagent` comes from the PreToolUse payload's `agent_id`, which Claude Code delivers on stdin and\n// populates ONLY off the main loop (main falls back to the session id, so the field is absent there).\n// `agent_type` is NOT usable for this — it is always populated and discriminates nothing. It is a\n// REQUIRED parameter for the same reason `drifted` is — an optional flag would let a caller keep\n// emitting the main-loop text from a subagent, which is the case that most needs the extra line.\n//\n// THE SUBAGENT SENTENCE REASONS FROM root= vs projectDir=, IT DOES NOT ASSERT A FIXED CONCLUSION. It\n// used to say flatly \"the hooks resolve through CLAUDE_PROJECT_DIR, which names the MAIN tree, so a cure\n// run only here CANNOT lift this block\". That is a POST-FLIP fact stated during the PRE-FLIP window, and\n// it is FALSE exactly when it fires: measured 2026-08-10, a worktree subagent hit fault S, ran OPTION 1\n// in ITS OWN worktree, and the block lifted — because the registration still in force was the RELATIVE\n// three-hook form, so the worktree's own ai-hook.sh ran against the worktree's own node_modules. The\n// deny's own `root=` field named the worktree while the sentence insisted otherwise.\n// WHICH tree to cure is already answered, for BOTH windows, by the root=/projectDir= verdict above:\n// committedShimStale compares shimPath(root) and `root` is the tree the RUNNING binary came from, so\n// repairing the root= tree clears the fault whichever registration form is live, and the cure is\n// cd-anchored there. Telling an agent its cure cannot work, while it demonstrably can, costs more than\n// saying nothing.\n//\n// SO THE SUBAGENT SENTENCE CARRIES THE PART THAT IS ACTUALLY SUBAGENT-SPECIFIC: there are TWO real\n// cures and they fix different things. A — run the printed cure here; it works, and it makes THIS tree\n// work NOW. B — a subagent cannot reach the main clone, so aligning the two trees is an ESCALATION\n// (\"ask the coordinator to run pnpm install in the main tree\"), and that is what stops the trees\n// disagreeing. Doing only A leaves the repo with two trees on two @webpieces releases — the live state\n// on 2026-08-10, main clone on 0.4.616 while origin/main and three worktrees were on 0.4.624. And the\n// rule it states is deliberately NOT \"do not install in a worktree\": a worktree NEEDS its own\n// node_modules (nx, vitest and the eslint plugin all execute there and load from it). The rule is that\n// its @webpieces must EQUAL the main tree's — the older WP_BORROW_NOTE wording got this backwards in\n// both directions at different times.\n// webpieces-disable no-function-outside-class -- pure string builder over exported constants; the single source of the self-guard deny text now that the sh copy is gone.\nexport function shimStaleDenyReason(installedVersion: string, root: string, drifted: readonly string[], inSubagent: boolean): string {\n const verNote = installedVersion ? ` (installed version ${installedVersion})` : '';\n const what = drifted.join(', ');\n const safeRoot = root.replace(/[\"\\\\]/g, '');\n const projectDir = claudeEnv.projectDirForLog().replace(/[\"\\\\]/g, '');\n // Tested against the RAW root, never the stripped one: stripping is a display-safety measure, and\n // cd-anchoring to a path we just mangled would prescribe a cd into a directory that does not exist.\n // A root CD_PREFIX cannot express is simply not offered as a `cd` (raw ok ⇒ safeRoot === root).\n const cdOk = root !== '' && /^[A-Za-z0-9._/@~+-]+$/.test(root);\n // Agreement is the routine case; DISAGREEMENT is the signature of the session-root-vs-cwd split this\n // guard was rewritten to make unconstructible, so it gets said out loud rather than left to inference.\n const verdict = safeRoot === projectDir\n ? 'These two AGREE, so this is the ordinary case - the tree you are in is the tree being judged.'\n : 'These two DISAGREE - the tree being judged is NOT the one CLAUDE_PROJECT_DIR names, so cure the root= tree and do not assume your cwd is it.';\n const rootNote = safeRoot === '' ? '' : ` WHERE THIS WAS MEASURED: root=${safeRoot} (the tree whose shim was compared - the one to repair), projectDir=${projectDir} (CLAUDE_PROJECT_DIR as this process sees it; <unset> = absent, not set-but-empty). ${verdict}`;\n const upgrade = cdOk ? `cd ${safeRoot} && ${UPGRADE_SHIM_CMD}` : UPGRADE_SHIM_CMD;\n // OPTION 2 is a relative-path `cp`, so it is even MORE cwd-sensitive than OPTION 1 — anchor it too.\n const restore = cdOk ? `cd ${safeRoot} && ${RESTORE_SHIM_CMD}` : RESTORE_SHIM_CMD;\n // The subagent sentence goes BEFORE the options, so it is read before a cure is chosen rather than\n // after one has already been run in the wrong tree. WHICH tree is already answered by `verdict`\n // above, from root= vs projectDir=; this adds only what is specific to a subagent — that A and B are\n // BOTH real and fix different things, and that B is an ESCALATION because a subagent cannot reach\n // the main clone.\n const subagentNote = inSubagent\n ? ' YOU ARE RUNNING IN A SUBAGENT, so TWO cures are real and they fix DIFFERENT things: A makes THIS tree work now, B stops the two trees disagreeing. A - run the OPTION below exactly as printed; it is already anchored to the tree that must change, and running it from here DOES lift this block (measured), so never conclude a local cure cannot work. B - you cannot reach the main clone, so ESCALATE: ask the coordinator to run pnpm install in the main tree so both trees are on the same @webpieces version. A worktree NEEDS its own node_modules (nx, vitest and the eslint plugin all load from it); the rule is not no-install-here, it is that this tree @webpieces must EQUAL the main tree.'\n : '';\n return `❌ webpieces-managed hook surface was changed: ${what} no longer matches what the INSTALLED @webpieces/ai-hook-rules${verNote} expects (reverted, hand-edited, or predating this binary).${rootNote} webpieces manages THREE things and they only work as a set: ${SHIM_MARKER} (the guard shim), the .claude/settings.json entries that register it, and the .claude/settings.json env entry ${BASH_CWD_ENV_KEY}=${BASH_CWD_ENV_VALUE} (which pins the Bash cwd to the project root, identically for every subagent because settings env is inherited). Every OTHER tool call is blocked until all three match again.${subagentNote} THIS IS NOT A DEADLOCK: both options below are explicitly ALLOWED through while this guard is up, so run one YOURSELF now - do not hand it back to the human. OPTION 1 (preferred - the only one that repairs all three, and it runs on a broken tree) - run EXACTLY this command: '${upgrade}'. OPTION 2 (PARTIAL - repairs ${SHIM_MARKER} only; pick it ONLY when the installed @webpieces/ai-hook-rules is older than 0.4.408, where OPTION 1 does not exist yet, then upgrade and run OPTION 1) - run EXACTLY this command: '${restore}'. Do NOT use the bare '${INSTALL_HOOKS_CMD}' here: it also migrates your config and PROMPTS for a hook target twice, which hangs a non-interactive session. ${NO_CHAINING_RULE} If you meant to remove @webpieces/ai-hook-rules, delete its hooks from .claude/settings.json rather than reverting these files.`;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"shim-deny-reason.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/shim-deny-reason.ts"],"names":[],"mappings":"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","sourcesContent":["import { claudeEnv } from '@webpieces/rules-config';\n\nimport { L0_FAULT_SHIM_STALE, l0GuardHeader, l0MatrixCitation } from '../core/l0-fault-codes';\nimport { INSTALL_HOOKS_CMD, RESTORE_SHIM_CMD, UPGRADE_SHIM_CMD } from './l0-allowlist';\nimport { BASH_CWD_ENV_KEY, BASH_CWD_ENV_VALUE } from './managed-env';\nimport { NO_CHAINING_RULE, SHIM_MARKER } from './shim';\n\n/**\n * THE FAIL-CLOSED DENY TEXT for a drifted managed hook surface (L0 fault S) — its own module because\n * shim.ts is at its line cap and this is one cohesive unit: the words a blocked agent reads, and\n * nothing else. It imports FROM shim.ts and is never imported BY it, so the graph stays acyclic.\n *\n * IT IS RENDERED IN THE HOUSE FORMAT, the same skeleton core/report.ts (formatReport) gives every L1\n * and L2 deny: a header naming what was blocked, a `[guard-name]` block listing the offenders with a\n * one-line `→ why`, what is still allowed, then numbered `Fix Option N:` lines each with its command\n * on its own line. L0 used to be the ONLY layer in webpieces that answered in one unbroken paragraph —\n * ~3,300 characters of it here — so the two commands that matter were buried in prose and there was no\n * guard name to grep for. Nothing about the DECISION changed; only the shape of the words.\n *\n * SHORTER IS NOT THE GOAL, SCANNABLE IS. The budget below still exists (a paragraph regrows when every\n * new finding argues its case here), but a section that earns a line gets a line.\n *\n * CONSTRAINT: the returned string must contain no `\"` and no `\\` — it is JSON-serialized by denyJson()\n * (a stray quote/backslash would corrupt the PreToolUse decision payload, not just the text). That is an\n * INVARIANT, not a hope, so every interpolated path is STRIPPED of both rather than trusted — a\n * Windows-style path or an odd directory name must not be able to corrupt the decision. Locked by unit\n * tests. An unusual root is also dropped from the `cd` cure rather than quoted (CD_PREFIX would reject it).\n *\n * NEWLINES ARE SAFE HERE AND NEEDED NO NEW MECHANISM. denyJson() runs JSON.stringify, which escapes a\n * real newline to the two-character `\\n` on the wire, and Claude Code's parser turns it back. This is\n * already proven in production — every L1 deny is formatReport()'s multi-line string down this exact\n * path. The sh half of L0 (faults D/X/U/K in renderShim) cannot do this: it printf's REASON into a JSON\n * string literal, so it spells its newlines `${NL}` the same way it spells the ANSI escape `${ESC}`.\n * A real newline is neither a quote nor a backslash, so the JSON-safety assertions above are untouched.\n */\nclass ShimStaleDeny {\n private readonly installedVersion: string;\n /** The governing root, STRIPPED of `\"` and `\\` for display. '' when there is none to name. */\n private readonly safeRoot: string;\n /** CLAUDE_PROJECT_DIR as this process sees it, stripped the same way. */\n private readonly projectDir: string;\n private readonly drifted: readonly string[];\n private readonly inSubagent: boolean;\n /**\n * Whether the RAW root can carry a leading `cd <root> &&`. Tested against the raw root, never the\n * stripped one: stripping is a display-safety measure, and cd-anchoring to a path we just mangled\n * would prescribe a cd into a directory that does not exist. A root CD_PREFIX cannot express is\n * simply not offered as a `cd` (raw ok ⇒ safeRoot === root).\n */\n private readonly cdOk: boolean;\n\n constructor(installedVersion: string, root: string, drifted: readonly string[], inSubagent: boolean) {\n this.installedVersion = installedVersion;\n this.safeRoot = root.replace(/[\"\\\\]/g, '');\n this.projectDir = claudeEnv.projectDirForLog().replace(/[\"\\\\]/g, '');\n this.drifted = drifted;\n this.inSubagent = inSubagent;\n this.cdOk = root !== '' && /^[A-Za-z0-9._/@~+-]+$/.test(root);\n }\n\n render(): string {\n return [\n ...this.header(),\n ...this.measured(),\n ...this.caller(),\n ...this.stillAllowed(),\n ...this.fixOptions(),\n ...this.footer(),\n ].join('\\n');\n }\n\n /**\n * `[managed-hook-surface]` and the surfaces that drifted, one per line.\n *\n * `drifted` names WHICH of the three managed things moved — .claude/webpieces/ai-hook.sh, the\n * .claude/settings.json hook registration, and its managed env entry (see hook-registration.ts). It\n * is REQUIRED, not optional: this used to be a shim-only message, and an optional list would let a\n * caller silently keep emitting the one-file text after the surface grew — the \"two spellings of one\n * thing\" shape the compatibility policy rejects. It was FOUR; guarantee-root.sh (L-1) is gone,\n * because the guard hooks are registered ABSOLUTE now and there is no second .sh to keep byte-locked.\n *\n * THE CAUSE IS A LIST. It used to assert flatly \"(it was reverted or hand-edited)\", which is\n * frequently FALSE — the common case is a shim whose logic simply predates this binary — and that\n * false certainty sent a real agent hunting for a tamper that never happened.\n */\n private header(): string[] {\n const verNote = this.installedVersion ? ` (installed version ${this.installedVersion})` : '';\n const n = this.drifted.length;\n const label = n === 1 ? '1 surface drifted' : `${String(n)} surfaces drifted`;\n return [\n '❌ webpieces ai-hooks blocked this call: a webpieces-managed hook surface no longer matches the installed guard binary.',\n '',\n l0GuardHeader(L0_FAULT_SHIM_STALE, label),\n ...this.drifted.map((surface: string): string => ` ${surface}`),\n ` → webpieces manages those THREE things as ONE set, GENERATED by the INSTALLED @webpieces/ai-hook-rules${verNote}; what is on disk is reverted, hand-edited, or predating this binary. The env entry is ${BASH_CWD_ENV_KEY}=${BASH_CWD_ENV_VALUE}, which pins the Bash cwd to the project root, identically for every subagent because settings env is inherited.`,\n ` → ${l0MatrixCitation(L0_FAULT_SHIM_STALE)}`,\n '',\n ];\n }\n\n /**\n * WHERE IT WAS MEASURED, in the deny itself and not only in the logs. #574 put `root=` and\n * `projectDir=` on every L1 invocation line (see decision-log / ClaudeEnv.projectDirForLog, whose\n * `<unset>` token keeps \"variable absent\" distinguishable from \"set to empty\"). The log is forensics\n * AFTER the fact; the deny is what a blocked agent reads IN the moment, and the absence of exactly\n * these two fields is what sent a real agent chasing the wrong mechanism for four cures. Same field\n * names on purpose, so the deny text and the log lines grep together.\n *\n * Agreement is the routine case; DISAGREEMENT is the signature of the session-root-vs-cwd split this\n * guard was rewritten to make unconstructible, so it gets said out loud rather than left to inference.\n */\n private measured(): string[] {\n if (this.safeRoot === '') return [];\n return [\n 'Where this was measured:',\n ` root=${this.safeRoot} - the tree whose shim was compared, and the one to repair`,\n ` projectDir=${this.projectDir} - CLAUDE_PROJECT_DIR as this process sees it; <unset> = absent, not set-but-empty`,\n ` → ${this.verdict()}`,\n '',\n ];\n }\n\n private verdict(): string {\n return this.callerIsInTheTree()\n ? 'These two AGREE, so this is the ordinary case - the tree you are in is the tree being judged.'\n : 'These two DISAGREE - the tree being judged is NOT the one CLAUDE_PROJECT_DIR names, so cure the root= tree and do not assume your cwd is it.';\n }\n\n /** True when the tree needing repair is the caller's own tree — the input the caller branch gates on. */\n private callerIsInTheTree(): boolean {\n return this.safeRoot === this.projectDir;\n }\n\n /**\n * THE CALLER-GATED BRANCH. It changes the WORDS ONLY — never the block/allow decision, never which\n * command is printed, and never which tree anything acts on (that is decided from the path, which is\n * why the deleted AgentIdentity class is NOT coming back for anything but message shape; agent\n * identity was measured untrustworthy as a location signal when a worktree agent resumed on the\n * primary clone after its tree was reaped).\n *\n * Two inputs, both already on hand:\n * 1. `inSubagent` — from the payload's `agent_id`, which Claude Code populates ONLY off the main\n * loop (main falls back to the session id, so the field is absent there). `agent_type` is NOT\n * usable: it is always populated and discriminates nothing. REQUIRED for the same reason\n * `drifted` is — an optional flag would let a caller keep emitting the main-loop text.\n * 2. root= vs projectDir= — different means the caller is not standing in the tree to repair.\n *\n * A main agent, and a subagent whose cwd IS the tree, get the cure and nothing else: they can run it,\n * see the result and commit it. A WORKTREE-ISOLATED subagent gets one extra step, and only because it\n * is true — MEASURED 2026-08-11: such an agent CAN run `cd <main> && pnpm exec wp-upgrade-shim` and it\n * works (the harness refuses cross-tree GIT operations, not this), but it can neither verify nor\n * commit the result, because `git -C <main>` is refused. So the escalation is of the COMMIT, not of\n * the repair, and the text must never tell it a local cure cannot work — the older wording asserted\n * exactly that and was false in the window where it fired (measured 2026-08-10: a worktree subagent\n * cured in place and the block lifted, with the deny's own root= naming that worktree).\n *\n * WHAT IS DELIBERATELY GONE: the \"ask the coordinator to run pnpm install so both trees are on the\n * same @webpieces version\" clause. Both hooks are registered ABSOLUTE, so every tree is already\n * judged by MAIN's shim and MAIN's binary — there is no version alignment left to ask for, and\n * asking sends an agent after a non-problem.\n */\n private caller(): string[] {\n // No governing root to name means no `Where this was measured` section either, so there is no\n // root= for this text to point at and nothing to escalate ABOUT. Silence beats a dangling field.\n if (this.safeRoot === '' || !this.inSubagent || this.callerIsInTheTree()) return [];\n return [\n 'You are a SUBAGENT and root= is not the tree you are standing in, so this takes TWO steps:',\n ' 1. Run Fix Option 1 below exactly as printed. It is already anchored to the tree that must change, and a worktree-isolated subagent CAN run it against another tree - that was measured and it works, so never conclude a local cure cannot work.',\n ` 2. Then ESCALATE THE COMMIT, which is the part you cannot do: git -C ${this.safeRoot} is refused here, so you can neither verify nor commit what the cure regenerated. Tell the coordinator to run git status in ${this.safeRoot} and commit the regenerated shim.`,\n '',\n ];\n }\n\n private stillAllowed(): string[] {\n return [\n 'Still allowed while this block is up:',\n ' - any Read',\n ' - any Write/Edit whose target is webpieces.config.json',\n ' - every command on the L0 allowlist, including both Fix Options below',\n ' THIS IS NOT A DEADLOCK: both options are explicitly ALLOWED through, so run one YOURSELF now - do not hand it back to the human. Every OTHER tool call is blocked until all three match again.',\n '',\n ];\n }\n\n /**\n * The two cures, house-numbered. ORDER IS LOAD-BEARING: wp-upgrade-shim LEADS because it is the only\n * cure that repairs all three managed surfaces, it touches no config and imports only fs/path, so it\n * runs on a tree too broken to load the rule engine. The `cp` stays last as the pre-0.4.408 fallback.\n *\n * Both are anchored with a leading `cd <root> &&` when the root allows it — CD_PREFIX_*_ANCHORED\n * tolerates exactly that one prefix (locked by unit test), and it is what keeps the cure curable when\n * the AI's cwd is a DIFFERENT tree than the one being judged. OPTION 2 is a relative-path `cp`, so it\n * is even MORE cwd-sensitive than OPTION 1 — it is anchored too. Never a SECOND `cd … &&`: the\n * allowlist matches the whole command and three segments are denied.\n */\n private fixOptions(): string[] {\n const anchor = (cmd: string): string => (this.cdOk ? `cd ${this.safeRoot} && ${cmd}` : cmd);\n return [\n ' Fix Option 1: (preferred) the only cure that repairs all three managed things, and it runs on a broken tree',\n ` run EXACTLY: '${anchor(UPGRADE_SHIM_CMD)}'`,\n ` Fix Option 2: PARTIAL - repairs ${SHIM_MARKER} only. Pick it ONLY when the installed @webpieces/ai-hook-rules is older than 0.4.408, where Fix Option 1 does not exist yet; then upgrade and run Fix Option 1.`,\n ` run EXACTLY: '${anchor(RESTORE_SHIM_CMD)}'`,\n ` NOT an option: do NOT use the bare '${INSTALL_HOOKS_CMD}' here - it also migrates your config and PROMPTS for a hook target twice, which hangs a non-interactive session.`,\n '',\n ];\n }\n\n private footer(): string[] {\n return [\n NO_CHAINING_RULE,\n `If you meant to remove @webpieces/ai-hook-rules, delete its hooks from .claude/settings.json rather than reverting ${SHIM_MARKER}.`,\n ];\n }\n}\n\n// The ONE entry point its two call sites (hook-core's fault-S deny, and the L0 fault table in\n// l0-matrix) import. The rendering lives on ShimStaleDeny above, per CLAUDE.md; this is the seam.\n// webpieces-disable no-function-outside-class -- one-line constructor+render seam for ShimStaleDeny, in the dependency-free shim module (it must stay callable from a tree too broken to build a DI container).\nexport function shimStaleDenyReason(installedVersion: string, root: string, drifted: readonly string[], inSubagent: boolean): string {\n return new ShimStaleDeny(installedVersion, root, drifted, inSubagent).render();\n}\n"]}
|
package/src/bin/shim.js
CHANGED
|
@@ -110,6 +110,28 @@ fi`;
|
|
|
110
110
|
// Normal template literal (not String.raw): it carries #235's shell escapes verbatim (\${BIN_NAME},
|
|
111
111
|
// \$REASON, \\n for the deny JSON) AND my sed backslashes (doubled: \\(, \\), \\1, [^"\\\\]). The
|
|
112
112
|
// grep pattern is interpolated from INSTALLER_ALLOW_ERE (its value has no backslashes).
|
|
113
|
+
// Shell fragment: the two JSON escapes the deny text is built from, plus the one shared "what is still
|
|
114
|
+
// allowed" block. Hoisted to the TOP of the shim (it used to sit inside DENY_EMIT_SH's Bash branch,
|
|
115
|
+
// i.e. AFTER every REASON was already assembled) because the deny text now needs the newline escape
|
|
116
|
+
// while it is being BUILT, not only while it is being printed.
|
|
117
|
+
//
|
|
118
|
+
// THE MECHANISM IS THE ONE THE COLOUR ALREADY USED. `REASON` is interpolated into a `REASON="…"` shell
|
|
119
|
+
// assignment and then printf'd into a JSON string literal, so it may contain no RAW double-quote and no
|
|
120
|
+
// RAW backslash — and a RAW newline would be invalid JSON. That constraint is not "no newlines": it is
|
|
121
|
+
// "no raw backslash", and `${BS}` produces the backslash at RUNTIME, so `${ESC}` (six chars: \ u 0 0 1 b)
|
|
122
|
+
// and `${NL}` (two chars: \ n) both travel as legal JSON escapes that Claude Code's parser turns back
|
|
123
|
+
// into a real ESC and a real newline. Verified end to end through /bin/sh in setup.spec.ts: the payload
|
|
124
|
+
// still parses as JSON, the systemMessage still carries 31;1m, and the reason renders as many lines.
|
|
125
|
+
//
|
|
126
|
+
// WHY THE STRUCTURE MATTERS: every L1/L2 deny is rendered by formatReport() into a scannable shape —
|
|
127
|
+
// header, `[guard-name] (N violations)`, indented offenders each with a one-line `→ why`, then numbered
|
|
128
|
+
// `Fix Option N:` lines. L0 was the ONLY layer answering in one unbroken paragraph. It now uses the same
|
|
129
|
+
// skeleton, which is what WP_STILL_ALLOWED exists for: one definition of that section for all four
|
|
130
|
+
// sh-side faults, so they cannot drift into four different answers to the same question.
|
|
131
|
+
const ESCAPES_SH = `BS='\\' # one literal backslash, so no \\u001b / \\n escape sits in this source
|
|
132
|
+
ESC="\${BS}u001b" # the 6 chars: backslash u 0 0 1 b — Claude Code parses \\u001b → ESC
|
|
133
|
+
NL="\${BS}n" # the 2 chars: backslash n — parsed as a real newline inside the JSON string
|
|
134
|
+
WP_STILL_ALLOWED="Still allowed while this block is up:\${NL} - any Read\${NL} - any Write/Edit whose target is ${rules_config_1.CONFIG_FILENAME}\${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."`;
|
|
113
135
|
// Shell fragment: the version-drift guard (see its own block comment). Extracted to a module const so
|
|
114
136
|
// renderShim() stays within the method-line budget; it is spliced back in verbatim, byte-for-byte.
|
|
115
137
|
const VERSION_DRIFT_GUARD_SH = `# --- webpieces version-drift guard (pure sh — runs even when the installed guard bin is stale) -----
|
|
@@ -197,7 +219,7 @@ WP_INSTALL_CMD="pnpm install"
|
|
|
197
219
|
WP_BORROW_NOTE=""
|
|
198
220
|
if [ "$BIN_ROOT" != "$ROOT" ]; then
|
|
199
221
|
WP_INSTALL_CMD="cd $ROOT && pnpm install"
|
|
200
|
-
WP_BORROW_NOTE="
|
|
222
|
+
WP_BORROW_NOTE="\${NL} NOTE: this tree ($ROOT) has NO node_modules of its own, so the guard binary was inherited from $BIN_ROOT by walking up. TWO cures are real and they fix DIFFERENT things: A makes THIS tree work now, B stops the two trees disagreeing. A - run the command above HERE. That is legitimate and it does work; a worktree NEEDS its own node_modules anyway (nx, vitest and the eslint plugin all execute in this tree and load from it). B - get $BIN_ROOT onto the same @webpieces version: put both trees on the same git hash (the pin is tracked) and run ONE 'pnpm install' there. If you are a SUBAGENT you cannot reach that tree, so ESCALATE - ask the coordinator to run 'pnpm install' in the main tree so both trees are on the same @webpieces version. The rule is NOT no-install-here: it is that this tree's @webpieces must EQUAL $BIN_ROOT's, because doing only A leaves two trees on two releases (the trinary-version-skew guard then BLOCKS rather than letting it pass unnoticed). Adding an ordinary third-party dependency here changes none of that - only a differing @webpieces version does. If this tree genuinely needs a DIFFERENT version, use a separate clone rather than a worktree."
|
|
201
223
|
fi`;
|
|
202
224
|
// Shell fragment: run the installed guard bin and INSPECT its outcome, instead of exec'ing it.
|
|
203
225
|
//
|
|
@@ -325,13 +347,19 @@ wp_log "\$WP_FAULT" "\$DENY_LABEL" # every fail-closed block, with the fault th
|
|
|
325
347
|
// - Write/Edit/MultiEdit deny: permissionDecisionReason renders as a RED "Error:" block natively —
|
|
326
348
|
// no systemMessage needed (a second line would be redundant).
|
|
327
349
|
// - NEVER exit 2 (stdout JSON ignored; stderr not reliably shown on a blocked Bash call).
|
|
328
|
-
// The ESC
|
|
329
|
-
//
|
|
330
|
-
//
|
|
350
|
+
// The ESC and the newline escape are both built in ESCAPES_SH at the TOP of the shim (see its header):
|
|
351
|
+
// ${ESC} is the literal 6-char JSON escape \\u001b and ${NL} the 2-char \\n, so no raw ESC byte, no raw
|
|
352
|
+
// newline and no \\uXXXX sits in this source, and Claude Code's JSON parser turns both back. The reason
|
|
353
|
+
// is a single JSON string with no RAW double-quotes/backslashes, so it stays valid JSON after the subs.
|
|
354
|
+
//
|
|
355
|
+
// ONLY THE HEADLINE IS RED, and that is deliberate — the same call redSystemMessage() makes on the JS
|
|
356
|
+
// side. The reason is MULTI-LINE now, and a whole page in bold red is harder to read than the paragraph
|
|
357
|
+
// it replaced: the indentation carrying the structure stops registering when every line shouts. So
|
|
358
|
+
// \$WP_HEAD (each branch's own first line, kept in its own variable for exactly this) is wrapped in
|
|
359
|
+
// [31;1m … [0m, and \${REASON#"\$WP_HEAD"} — POSIX prefix removal with a QUOTED pattern, so the
|
|
360
|
+
// headline is matched literally and not as a glob — supplies the plain body after it.
|
|
331
361
|
const DENY_EMIT_SH = `if [ "\$TOOL" = "Bash" ]; then
|
|
332
|
-
|
|
333
|
-
ESC="\${BS}u001b" # the 6 chars: backslash u 0 0 1 b — Claude Code parses \\u001b → ESC
|
|
334
|
-
printf '{"systemMessage":"%s🛑 %s%s","hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\\n' "\${ESC}[31;1m" "\$REASON" "\${ESC}[0m" "\$REASON"
|
|
362
|
+
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"
|
|
335
363
|
else
|
|
336
364
|
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\\n' "\$REASON"
|
|
337
365
|
fi
|
|
@@ -346,9 +374,11 @@ const DENY_REASON_SH = `if [ -n "\$BROKEN_BIN" ]; then
|
|
|
346
374
|
STAGING_N="\$(ls "\$BIN_ROOT/node_modules" 2>/dev/null | grep -Ec '_[0-9a-f]+_[0-9a-f]+\$' || true)"
|
|
347
375
|
STAGING_NOTE=""
|
|
348
376
|
if [ "\${STAGING_N:-0}" -gt 0 ] 2>/dev/null; then
|
|
349
|
-
STAGING_NOTE="
|
|
377
|
+
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
|
|
350
378
|
fi
|
|
351
|
-
|
|
379
|
+
# The HEADLINE, kept in its own variable so DENY_EMIT_SH can paint ONLY it red (see there).
|
|
380
|
+
WP_HEAD="❌ webpieces ai-hooks blocked this call: the webpieces guards are DOWN."
|
|
381
|
+
REASON="\$WP_HEAD\${NL}\${NL}${(0, l0_fault_codes_1.l0GuardHeader)(l0_fault_codes_1.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} → ${(0, l0_fault_codes_1.l0MatrixCitation)(l0_fault_codes_1.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: '${l0_allowlist_1.RECOVERY_CMD}'\${NL}\${NL}${exports.NO_CHAINING_RULE}"
|
|
352
382
|
elif [ -n "\$DRIFT_PKG" ]; then
|
|
353
383
|
# DECIDE THE DIRECTION, do not make the reader do it (2026-08-03). The detection is a plain !=, so it
|
|
354
384
|
# fires BOTH ways, and the message used to carry OPTION 1/2/3 covering every direction at once — 3343
|
|
@@ -383,13 +413,17 @@ elif [ -n "\$DRIFT_PKG" ]; then
|
|
|
383
413
|
if (ip == "" && dp != "") print "newer"
|
|
384
414
|
}' 2>/dev/null)"
|
|
385
415
|
if [ "\$DRIFT_DIR" = older ]; then
|
|
386
|
-
|
|
416
|
+
# The HEADLINE, kept in its own variable so DENY_EMIT_SH can paint ONLY it red (see there).
|
|
417
|
+
WP_HEAD="❌ webpieces ai-hooks blocked this call: webpieces version drift."
|
|
418
|
+
REASON="\$WP_HEAD\${NL}\${NL}${(0, l0_fault_codes_1.l0GuardHeader)(l0_fault_codes_1.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} → ${(0, l0_fault_codes_1.l0MatrixCitation)(l0_fault_codes_1.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: '\$WP_INSTALL_CMD'\${WP_BORROW_NOTE}\${NL}\${NL}${exports.NO_CHAINING_RULE}"
|
|
387
419
|
else
|
|
388
420
|
# NEWER, or undecidable — the same three choices apply either way, so the only thing the ambiguous
|
|
389
421
|
# case changes is the claim about which side is stale.
|
|
390
422
|
DRIFT_NOTE="node_modules is NEWER, so the PIN is the stale side and a bare 'pnpm install' DOWNGRADES you to \$DRIFT_DECLARED"
|
|
391
423
|
[ "\$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"
|
|
392
|
-
|
|
424
|
+
# The HEADLINE, kept in its own variable so DENY_EMIT_SH can paint ONLY it red (see there).
|
|
425
|
+
WP_HEAD="❌ webpieces ai-hooks blocked this call: webpieces version drift."
|
|
426
|
+
REASON="\$WP_HEAD\${NL}\${NL}${(0, l0_fault_codes_1.l0GuardHeader)(l0_fault_codes_1.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} → ${(0, l0_fault_codes_1.l0MatrixCitation)(l0_fault_codes_1.L0_FAULT_DRIFT)}\${NL}\${NL}\${WP_STILL_ALLOWED}\${NL}\${NL} Fix Option 1: (preferred) you are on main and want what origin pins - move forward: run 'git pull origin main', then 'pnpm install'\${NL} Fix Option 2: you mean to stay on this code (the downgrade is the point), or you are on a feature branch and want YOUR branch pin - usually right\${NL} run EXACTLY: 'pnpm install'\${WP_BORROW_NOTE}\${NL}\${NL}${exports.NO_CHAINING_RULE}"
|
|
393
427
|
fi
|
|
394
428
|
else
|
|
395
429
|
# A LINKED WORKTREE is the overwhelmingly common way to land here with a perfectly healthy repo:
|
|
@@ -399,7 +433,7 @@ else
|
|
|
399
433
|
# load-bearing: installing in the primary clone does nothing for this tree.
|
|
400
434
|
WORKTREE_NOTE=""
|
|
401
435
|
if [ -f "\$ROOT/.git" ]; then
|
|
402
|
-
WORKTREE_NOTE="
|
|
436
|
+
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."
|
|
403
437
|
fi
|
|
404
438
|
if [ -z "\$WP_HOOK_PKG_DECLARED" ]; then
|
|
405
439
|
# FAULT U — the one shape where the X message is not merely unhelpful but actively WRONG. It asserted
|
|
@@ -410,9 +444,13 @@ else
|
|
|
410
444
|
# prescribe the add — which is allowlist entry ADD_HOOK_PKG, so it is reachable while this block is up.
|
|
411
445
|
WP_ADD_CMD="${l0_allowlist_1.ADD_HOOK_PKG_CMD}"
|
|
412
446
|
[ -n "\$WP_PIN" ] && WP_ADD_CMD="\${WP_ADD_CMD}@\$WP_PIN"
|
|
413
|
-
|
|
447
|
+
# The HEADLINE, kept in its own variable so DENY_EMIT_SH can paint ONLY it red (see there).
|
|
448
|
+
WP_HEAD="❌ webpieces ai-hooks blocked this call: the guard package is not declared anywhere."
|
|
449
|
+
REASON="\$WP_HEAD\${NL}\${NL}${(0, l0_fault_codes_1.l0GuardHeader)(l0_fault_codes_1.L0_FAULT_UNDECLARED, '1 violation')}\${NL} ${l0_allowlist_1.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} → ${(0, l0_fault_codes_1.l0MatrixCitation)(l0_fault_codes_1.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 - ${l0_allowlist_1.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 ${l0_allowlist_1.HOOK_PKG} on purpose, delete its hooks from .claude/settings.json instead.\${NL}\${NL}${exports.NO_CHAINING_RULE}"
|
|
414
450
|
else
|
|
415
|
-
|
|
451
|
+
# The HEADLINE, kept in its own variable so DENY_EMIT_SH can paint ONLY it red (see there).
|
|
452
|
+
WP_HEAD="❌ webpieces ai-hooks blocked this call: the webpieces guard bin is not installed."
|
|
453
|
+
REASON="\$WP_HEAD\${NL}\${NL}${(0, l0_fault_codes_1.l0GuardHeader)(l0_fault_codes_1.L0_FAULT_BIN_MISSING, '1 violation')}\${NL} ${l0_allowlist_1.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} → ${(0, l0_fault_codes_1.l0MatrixCitation)(l0_fault_codes_1.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 ${l0_allowlist_1.HOOK_PKG} on purpose, delete its hooks from .claude/settings.json instead.\${NL}\${NL}${exports.NO_CHAINING_RULE}"
|
|
416
454
|
fi
|
|
417
455
|
fi`;
|
|
418
456
|
function renderShim() {
|
|
@@ -432,6 +470,9 @@ shift
|
|
|
432
470
|
# Resolve the tree relative to THIS script (…/<root>/.claude/webpieces/ai-hook.sh → <root>), not the
|
|
433
471
|
# caller's cwd — the hook can be invoked from any directory (a subdir, or a nested clone).
|
|
434
472
|
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
|
473
|
+
# The JSON escapes every deny message below is assembled from (ANSI red, and the newlines that give the
|
|
474
|
+
# deny the same scannable shape formatReport() gives every L1/L2 deny). See ESCAPES_SH's header.
|
|
475
|
+
${ESCAPES_SH}
|
|
435
476
|
# The BIN is resolved by walking UP from ROOT (as Node does), and BIN_ROOT records which tree supplied
|
|
436
477
|
# it — the version-drift guard below compares THIS tree's pin against THAT tree's installed version.
|
|
437
478
|
${RESOLVE_BIN_SH}
|