agent-sanitizer 2.19.2 → 2.19.4
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/README.md +5 -0
- package/THREAT-MODEL.md +24 -8
- package/claude-hooks/lib/hook-fault.mjs +224 -0
- package/claude-hooks/lib/layer-pipeline.mjs +147 -0
- package/claude-hooks/plugin-hooks.mjs +45 -9
- package/claude-hooks/pretooluse-sanitize.mjs +116 -41
- package/claude-hooks/sanitize-output.mjs +66 -19
- package/claude-hooks/sanitize-user-prompt.mjs +31 -23
- package/claude-hooks/scan-invisible-chars.mjs +235 -47
- package/package.json +1 -1
- package/src/ansi.mjs +207 -0
- package/src/confusables.mjs +6 -2
- package/src/html.mjs +130 -48
- package/src/invisible.mjs +202 -159
- package/src/layer1.mjs +101 -116
- package/src/output.mjs +28 -11
- package/src/prompt.mjs +9 -6
- package/src/rehydrate.mjs +13 -20
- package/src/view-map.mjs +59 -22
- package/types/ansi.d.mts +66 -0
- package/types/claude-hooks/lib/hook-fault.d.mts +104 -0
- package/types/claude-hooks/lib/layer-pipeline.d.mts +113 -0
- package/types/claude-hooks/pretooluse-sanitize.d.mts +16 -0
- package/types/claude-hooks/scan-invisible-chars.d.mts +74 -14
- package/types/confusables.d.mts +6 -2
- package/types/invisible.d.mts +11 -2
- package/types/layer1.d.mts +19 -10
- package/types/output.d.mts +15 -2
- package/types/view-map.d.mts +47 -3
package/README.md
CHANGED
|
@@ -94,6 +94,11 @@ owns each message, and any value outside the enum makes `sanitizeText` **throw**
|
|
|
94
94
|
| `filter-flagged` | The filter flagged the output as a possible injection without deleting (content intact) |
|
|
95
95
|
| `filter-error` | The filter reported a non-fatal internal error while scanning (a fatal filter throws) |
|
|
96
96
|
|
|
97
|
+
Every span is matched against the **original** text and the deletions applied in
|
|
98
|
+
a single ordered pass, so the bytes a filter can remove are exactly the bytes its
|
|
99
|
+
spans matched in the input — an earlier deletion can never manufacture a match
|
|
100
|
+
for a later span (overlapping spans resolve first-match-wins).
|
|
101
|
+
|
|
97
102
|
## What installing entails
|
|
98
103
|
|
|
99
104
|
Installing the plugin puts four hooks on every session, and this is what they
|
package/THREAT-MODEL.md
CHANGED
|
@@ -36,12 +36,24 @@ table](./README.md#entry-points) maps each to its import.
|
|
|
36
36
|
neighbors clearly belong to the context, and it is disabled once the total
|
|
37
37
|
invisible count crosses a scatter floor—over-stripping beats under-stripping.
|
|
38
38
|
|
|
39
|
-
**Reassembly hardening.**
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
39
|
+
**Reassembly hardening.** The two passes feed each other in _both_ directions:
|
|
40
|
+
stripping an invisible char can reconstitute an ANSI escape its split had
|
|
41
|
+
hidden, removing one ANSI sequence can reconstitute another, and removing an
|
|
42
|
+
ANSI sequence can make two invisibles adjacent that were not—a joiner run the
|
|
43
|
+
invisible pass treats as a payload channel rather than as linguistic. So Layer 1
|
|
44
|
+
does not run a fixed sequence of passes; it iterates the whole
|
|
45
|
+
{ANSI, invisible} composition to a fixed point (bounded, since each round
|
|
46
|
+
deletes at least one character), and only once that is stable does it sweep any
|
|
47
|
+
residual raw control introducer—7-bit ESC (U+001B) or any 8-bit C1 control
|
|
48
|
+
(U+0080–U+009F)—outright. Sweeping earlier would strand a hidden control as
|
|
49
|
+
visible text; a final unconditional sweep after the loop keeps the
|
|
50
|
+
no-raw-introducer guarantee independent of the iteration bound. The result
|
|
51
|
+
carries no raw ANSI introducer for _any_ input, and re-cleaning it reproduces
|
|
52
|
+
it exactly—the idempotence the Edit-repair rehydrator's soundness gate assumes.
|
|
53
|
+
One tokenizer answers every ANSI question (what to splice, and whether the only
|
|
54
|
+
escape content is display-only SGR colour), so the stripper and the operator
|
|
55
|
+
warning cannot disagree about what a sequence is. OSC strings (titles,
|
|
56
|
+
clickable-hyperlink URLs) are consumed as a
|
|
45
57
|
whole, for every terminator form—ST (`ESC\` or 8-bit C1 ST U+009C) and the
|
|
46
58
|
legacy BEL—and for the 8-bit C1 OSC introducer (U+009D); an _unterminated_ OSC
|
|
47
59
|
introducer is dropped through end-of-string (fail-closed), so no OSC body
|
|
@@ -169,8 +181,12 @@ fail-closed path: a redactor that throws makes the pipeline rethrow, so the
|
|
|
169
181
|
caller suppresses the output rather than emit an unvetted value. Layer 5 is a
|
|
170
182
|
deliberately thin, safe slot: the injected filter returns **verbatim spans to
|
|
171
183
|
delete** (never replacement text), so even a compromised filter can only remove
|
|
172
|
-
legitimate content—it can never inject bytes into the model’s view.
|
|
173
|
-
|
|
184
|
+
legitimate content—it can never inject bytes into the model’s view. That removal
|
|
185
|
+
is bounded to the spans the filter actually named: every span is matched against
|
|
186
|
+
the **original** text and the deletions applied in a single ordered pass, so an
|
|
187
|
+
earlier deletion cannot join two kept regions into a match for a later span and
|
|
188
|
+
erase text neither span occurred in. A live second-LLM injection filter is the
|
|
189
|
+
caller’s to wire behind that contract.
|
|
174
190
|
|
|
175
191
|
The same "never inject" property governs the filter’s `warning`: it is a
|
|
176
192
|
**closed enum code** (`FILTER_WARNING`: `spans-removed` / `filter-flagged` /
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE failure posture, in one place.
|
|
3
|
+
*
|
|
4
|
+
* Every hook in this package has to answer the same question when its own
|
|
5
|
+
* machinery breaks: does the guarded action proceed with a warning (fail OPEN,
|
|
6
|
+
* the shipped default) or is it withheld (fail CLOSED, what
|
|
7
|
+
* AGENT_SANITIZER_FAIL_OPEN=0 buys)? Before this module each hook answered it by
|
|
8
|
+
* hand — four hand-rolled renderings in three different envelope shapes, one
|
|
9
|
+
* hook (scan-invisible-chars) that never consulted the knob at all, and a
|
|
10
|
+
* dispatcher default arm that hard-exited past it. A posture that is re-derived
|
|
11
|
+
* per hook is a posture nobody can audit: the one hook that forgot is invisible
|
|
12
|
+
* until an operator who pinned the strict posture silently does not get it.
|
|
13
|
+
*
|
|
14
|
+
* So the posture becomes DATA. Each hook registers one policy — what it guards,
|
|
15
|
+
* which event it answers, and how its CLOSED verdict renders — and this module
|
|
16
|
+
* owns everything shared: the single {@link failOpenEnabled} read, the default
|
|
17
|
+
* OPEN rendering, and the shape of the outcome a hook then writes. A hook with
|
|
18
|
+
* no registered policy is a hard error at fault time rather than a silent
|
|
19
|
+
* default, and `test/claude-hooks-posture.test.mjs` enumerates the modules on
|
|
20
|
+
* disk so a hook added without a policy fails there first.
|
|
21
|
+
*
|
|
22
|
+
* WHY REGISTRATION AND NOT A LITERAL TABLE HERE: a closed verdict is
|
|
23
|
+
* hook-specific content (the PostToolUse suppression has to shape-match the tool
|
|
24
|
+
* response it replaces; the PreToolUse ask/deny split reads the host's message
|
|
25
|
+
* table), and every hook already imports this module's peer `hook-io.mjs`. A
|
|
26
|
+
* literal table would have to import the hooks back, which is a cycle. The list
|
|
27
|
+
* of hooks that MUST register is still declared here, as data, so the set is
|
|
28
|
+
* closed in both directions.
|
|
29
|
+
*/
|
|
30
|
+
import {
|
|
31
|
+
failOpenEnabled,
|
|
32
|
+
failOpenContext,
|
|
33
|
+
safeErrMessage,
|
|
34
|
+
} from "./hook-io.mjs";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The hook modules that must register a fault policy — every CLI entry point in
|
|
38
|
+
* `claude-hooks/*.mjs`. Declared here rather than discovered so registering an
|
|
39
|
+
* unknown name is an error instead of a typo nobody notices.
|
|
40
|
+
* @type {readonly string[]}
|
|
41
|
+
*/
|
|
42
|
+
export const FAULT_POLICY_HOOKS = Object.freeze([
|
|
43
|
+
"plugin-hooks",
|
|
44
|
+
"pretooluse-sanitize",
|
|
45
|
+
"sanitize-output",
|
|
46
|
+
"sanitize-user-prompt",
|
|
47
|
+
"scan-invisible-chars",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* What a hook's fault renders to. `fields`/`envelope` are the stdout response
|
|
52
|
+
* (`fields` is the `hookSpecificOutput` body, wrapped with the policy's event;
|
|
53
|
+
* `envelope` is a hook that answers with a top-level shape instead, like
|
|
54
|
+
* UserPromptSubmit's `{decision:"block"}`); `stderr` and `exitCode` are the
|
|
55
|
+
* process-level halves a hook with no stdout channel uses; `armAlert` asks the
|
|
56
|
+
* caller to persist its cross-hook alert so a LATER gate carries the closed
|
|
57
|
+
* posture the faulting hook could not express itself.
|
|
58
|
+
* @typedef {{
|
|
59
|
+
* posture: "open" | "closed",
|
|
60
|
+
* fields: Record<string, unknown> | null,
|
|
61
|
+
* fallbackFields: Record<string, unknown> | null,
|
|
62
|
+
* envelope: object | null,
|
|
63
|
+
* stderr: string | null,
|
|
64
|
+
* exitCode: number,
|
|
65
|
+
* armAlert: boolean,
|
|
66
|
+
* }} FaultOutcome
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* What a policy's `open`/`closed` builder returns: any subset of a
|
|
71
|
+
* {@link FaultOutcome}'s renderable slots. Everything omitted takes its default
|
|
72
|
+
* (no output, exit 0, no alert).
|
|
73
|
+
* @typedef {{
|
|
74
|
+
* fields?: Record<string, unknown>,
|
|
75
|
+
* fallbackFields?: Record<string, unknown>,
|
|
76
|
+
* envelope?: object,
|
|
77
|
+
* stderr?: string,
|
|
78
|
+
* exitCode?: number,
|
|
79
|
+
* armAlert?: boolean,
|
|
80
|
+
* }} FaultParts
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The context a builder is handed: the caller's own inputs (whatever it passed
|
|
85
|
+
* to {@link hookFaultOutcome}) plus the three values every hook derived by hand
|
|
86
|
+
* before — the hook name, the scrubbed error message, and the model-facing
|
|
87
|
+
* open-posture warning.
|
|
88
|
+
* @typedef {Record<string, any> & {
|
|
89
|
+
* hook: string,
|
|
90
|
+
* err: unknown,
|
|
91
|
+
* message: string,
|
|
92
|
+
* openContext: string,
|
|
93
|
+
* }} FaultContext
|
|
94
|
+
*/
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A hook's declared posture.
|
|
98
|
+
* @typedef {{
|
|
99
|
+
* event: string | null,
|
|
100
|
+
* guarded: string,
|
|
101
|
+
* open?: (ctx: FaultContext) => FaultParts,
|
|
102
|
+
* closed: (ctx: FaultContext) => FaultParts,
|
|
103
|
+
* }} FaultPolicy
|
|
104
|
+
*/
|
|
105
|
+
|
|
106
|
+
/** @type {Map<string, FaultPolicy>} */
|
|
107
|
+
const policies = new Map();
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Declare a hook's failure posture. Called at module scope by each hook, so
|
|
111
|
+
* importing the hook is what makes its posture reachable.
|
|
112
|
+
* @param {string} hook a member of {@link FAULT_POLICY_HOOKS}
|
|
113
|
+
* @param {FaultPolicy} policy
|
|
114
|
+
* @returns {void}
|
|
115
|
+
*/
|
|
116
|
+
export function registerFaultPolicy(hook, policy) {
|
|
117
|
+
if (!FAULT_POLICY_HOOKS.includes(hook))
|
|
118
|
+
throw new Error(
|
|
119
|
+
`unknown hook ${JSON.stringify(hook)}: add it to FAULT_POLICY_HOOKS in ` +
|
|
120
|
+
"claude-hooks/lib/hook-fault.mjs before registering a posture for it",
|
|
121
|
+
);
|
|
122
|
+
policies.set(hook, policy);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The registered policy for `hook`. Throws rather than defaulting: a hook whose
|
|
127
|
+
* posture nobody declared has no defensible default — guessing OPEN would let
|
|
128
|
+
* the guarded action through on a hook an operator believed was strict, and
|
|
129
|
+
* guessing CLOSED would block a session on a wiring bug.
|
|
130
|
+
* @param {string} hook
|
|
131
|
+
* @returns {FaultPolicy}
|
|
132
|
+
*/
|
|
133
|
+
export function faultPolicy(hook) {
|
|
134
|
+
const policy = policies.get(hook);
|
|
135
|
+
if (policy === undefined)
|
|
136
|
+
throw new Error(
|
|
137
|
+
`no fault policy registered for hook ${JSON.stringify(hook)}; call ` +
|
|
138
|
+
"registerFaultPolicy at the hook module's scope",
|
|
139
|
+
);
|
|
140
|
+
return policy;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The default OPEN rendering: no verdict, and a non-empty `additionalContext`
|
|
145
|
+
* recording that the guarded content passed through unsanitized. Non-empty
|
|
146
|
+
* matters — an empty stdout is recorded by Claude Code as a CLEAN run rather
|
|
147
|
+
* than a degraded one, so the posture would give up visibility as well as
|
|
148
|
+
* enforcement.
|
|
149
|
+
* @param {FaultContext} ctx
|
|
150
|
+
* @returns {FaultParts}
|
|
151
|
+
*/
|
|
152
|
+
function defaultOpen(ctx) {
|
|
153
|
+
return { fields: { additionalContext: ctx.openContext } };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Resolve `hook`'s response to its own failure under the caller's posture. This
|
|
158
|
+
* is the ONLY place {@link failOpenEnabled} is consulted on a hook fault, so the
|
|
159
|
+
* knob cannot be honored in three hooks and skipped in the fourth.
|
|
160
|
+
* @param {string} hook
|
|
161
|
+
* @param {unknown} err
|
|
162
|
+
* @param {Record<string, any> & {
|
|
163
|
+
* env?: NodeJS.ProcessEnv | Record<string, string | undefined>,
|
|
164
|
+
* }} [ctx] hook-specific inputs threaded to the builders (a message table, the
|
|
165
|
+
* parsed input, a remedy). `env` selects the posture; it is threaded to the
|
|
166
|
+
* builders along with the rest, though none reads it — the posture is resolved
|
|
167
|
+
* here precisely so a builder never has to.
|
|
168
|
+
* @returns {FaultOutcome}
|
|
169
|
+
*/
|
|
170
|
+
export function hookFaultOutcome(hook, err, ctx = {}) {
|
|
171
|
+
const policy = faultPolicy(hook);
|
|
172
|
+
const open = failOpenEnabled(ctx.env);
|
|
173
|
+
/** @type {FaultContext} */
|
|
174
|
+
const full = {
|
|
175
|
+
...ctx,
|
|
176
|
+
hook,
|
|
177
|
+
err,
|
|
178
|
+
message: safeErrMessage(err),
|
|
179
|
+
openContext: failOpenContext(hook, policy.guarded, err),
|
|
180
|
+
};
|
|
181
|
+
const parts = (open ? (policy.open ?? defaultOpen) : policy.closed)(full);
|
|
182
|
+
const fields = parts.fields ?? null;
|
|
183
|
+
// A policy that supplies `fields` but no event has nothing to wrap them in;
|
|
184
|
+
// that is a policy bug, not a runtime condition, so say so rather than
|
|
185
|
+
// silently emitting nothing (which reads as a clean run).
|
|
186
|
+
if (fields !== null && parts.envelope === undefined && policy.event === null)
|
|
187
|
+
throw new Error(
|
|
188
|
+
`fault policy for ${JSON.stringify(hook)} returns hookSpecificOutput ` +
|
|
189
|
+
"fields but declares no event to wrap them in",
|
|
190
|
+
);
|
|
191
|
+
return Object.freeze({
|
|
192
|
+
posture: open ? "open" : "closed",
|
|
193
|
+
fields,
|
|
194
|
+
fallbackFields: parts.fallbackFields ?? null,
|
|
195
|
+
envelope:
|
|
196
|
+
parts.envelope ??
|
|
197
|
+
(fields === null
|
|
198
|
+
? null
|
|
199
|
+
: { hookSpecificOutput: { hookEventName: policy.event, ...fields } }),
|
|
200
|
+
stderr: parts.stderr ?? null,
|
|
201
|
+
exitCode: parts.exitCode ?? 0,
|
|
202
|
+
armAlert: parts.armAlert ?? false,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Render an outcome's stdout/stderr halves and return its exit code. The caller
|
|
208
|
+
* decides what to do with the code (a hook that must keep running ignores it),
|
|
209
|
+
* and performs `armAlert` itself — persisting the alert needs the hook's own
|
|
210
|
+
* report text, which this module has no view of.
|
|
211
|
+
* @param {FaultOutcome} outcome
|
|
212
|
+
* @param {(chunk: string) => void} [write]
|
|
213
|
+
* @param {(chunk: string) => void} [writeErr]
|
|
214
|
+
* @returns {number}
|
|
215
|
+
*/
|
|
216
|
+
export function writeFaultOutcome(
|
|
217
|
+
outcome,
|
|
218
|
+
write = (chunk) => process.stdout.write(chunk),
|
|
219
|
+
writeErr = (chunk) => process.stderr.write(chunk),
|
|
220
|
+
) {
|
|
221
|
+
if (outcome.envelope !== null) write(JSON.stringify(outcome.envelope));
|
|
222
|
+
if (outcome.stderr !== null) writeErr(outcome.stderr);
|
|
223
|
+
return outcome.exitCode;
|
|
224
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The PreToolUse layer chain as a DECLARED pipeline instead of a run of
|
|
3
|
+
* sequential statements.
|
|
4
|
+
*
|
|
5
|
+
* The confusable fold (Layer 2) carries a soundness precondition: it deliberately
|
|
6
|
+
* SKIPS a token that still holds an unmapped non-ASCII glyph, because such a
|
|
7
|
+
* token can never come out byte-equal to an ASCII deny-rule target, so folding it
|
|
8
|
+
* would only mangle real foreign-language text. That argument holds only while no
|
|
9
|
+
* later layer ERASES code points from the same field — if it does, the glyph the
|
|
10
|
+
* fold relied on can disappear after the decision was taken.
|
|
11
|
+
*
|
|
12
|
+
* Layer 3 (authored-content stripping) erases exactly that: payload-capable
|
|
13
|
+
* invisible characters. Running it after the fold made the precondition false,
|
|
14
|
+
* and the gap was reachable: `cat /etc/p<CYRILLIC A><12 x ZWSP>sswd` put the
|
|
15
|
+
* zero-width run inside the token, so the fold skipped it, then the strip removed
|
|
16
|
+
* the padding and emitted `cat /etc/p<CYRILLIC A>sswd` — the homoglyph intact and
|
|
17
|
+
* the evidence for skipping it gone. Zero-width padding suppressed the fold and
|
|
18
|
+
* the next layer erased the reason.
|
|
19
|
+
*
|
|
20
|
+
* The eliminator is structural: layers declare whether they ERASE code points and
|
|
21
|
+
* whether their decision is SKIP-BASED (suppressible by code points another layer
|
|
22
|
+
* erases), and this driver enforces the precondition rather than a comment
|
|
23
|
+
* asking a future reader to preserve it. When the table puts an erasing layer
|
|
24
|
+
* after a skip-based one, the driver re-runs the body to a FIXED POINT, so the
|
|
25
|
+
* emitted value is one every skip-based layer has seen in its final form. A table
|
|
26
|
+
* that needs no fixed point runs exactly once, so the cost is paid only by the
|
|
27
|
+
* ordering that creates the hazard.
|
|
28
|
+
*
|
|
29
|
+
* Layers may be `terminal`: run once, after the fixed point, never re-run. That
|
|
30
|
+
* is Layer 4 (rehydration), whose whole contract is that it sees the FINAL
|
|
31
|
+
* authored text and its restored secrets are not re-stripped by Layer 3. Terminal
|
|
32
|
+
* layers must be a suffix of the table, which the driver checks.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A layer's result: the rewritten input plus the model-facing note, a `deny`
|
|
37
|
+
* verdict that ends the pipeline, or null when the layer changed nothing.
|
|
38
|
+
* @typedef {{ updatedInput: any, context: string } | { deny: string } | null} LayerResult
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One declared layer.
|
|
43
|
+
* - `erases` — may REMOVE code points from a field another layer reads. This is
|
|
44
|
+
* the property that can invalidate a skip-based decision taken earlier.
|
|
45
|
+
* - `skipBased` — its decision can be suppressed by code points present at the
|
|
46
|
+
* time it ran. Such a layer must be re-run after any erasure.
|
|
47
|
+
* - `terminal` — runs once after the fixed point and is never re-run.
|
|
48
|
+
* @typedef {{
|
|
49
|
+
* name: string,
|
|
50
|
+
* erases: boolean,
|
|
51
|
+
* skipBased: boolean,
|
|
52
|
+
* terminal?: boolean,
|
|
53
|
+
* run: (tool: string, input: any) => LayerResult | Promise<LayerResult>,
|
|
54
|
+
* }} Layer
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Bound on fixed-point passes. Each pass either changes the input or ends the
|
|
59
|
+
* loop, and the layers are contractive in practice (folding and stripping both
|
|
60
|
+
* shrink the space of remaining findings), so a run that is still changing after
|
|
61
|
+
* this many passes is a layer that oscillates — a bug. Throwing hands it to the
|
|
62
|
+
* hook's fail-closed catch, which is the loud outcome; looping forever would be
|
|
63
|
+
* silently killed by the harness and read as a non-blocking pass (fail OPEN).
|
|
64
|
+
*/
|
|
65
|
+
export const MAX_PIPELINE_PASSES = 8;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Whether `layers` places an erasing layer after a skip-based one — the ordering
|
|
69
|
+
* whose soundness needs a fixed point. Exported so the property is assertable
|
|
70
|
+
* about a table directly, not only through a run.
|
|
71
|
+
* @param {Layer[]} layers
|
|
72
|
+
* @returns {boolean}
|
|
73
|
+
*/
|
|
74
|
+
export function needsFixedPoint(layers) {
|
|
75
|
+
let sawSkipBased = false;
|
|
76
|
+
for (const layer of layers) {
|
|
77
|
+
if (layer.erases && sawSkipBased) return true;
|
|
78
|
+
if (layer.skipBased) sawSkipBased = true;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Run a declared layer chain over one tool input.
|
|
85
|
+
*
|
|
86
|
+
* Returns the final input, whether anything changed, and the model-facing notes
|
|
87
|
+
* in the order they were produced (deduplicated: a fixed-point re-run that
|
|
88
|
+
* repeats a layer's note would otherwise say the same thing twice). A layer that
|
|
89
|
+
* denies ends the run immediately, with `deny` set.
|
|
90
|
+
* @param {string} tool
|
|
91
|
+
* @param {any} toolInput
|
|
92
|
+
* @param {Layer[]} layers
|
|
93
|
+
* @returns {Promise<{ updatedInput: any, changed: boolean, contexts: string[], deny?: string }>}
|
|
94
|
+
*/
|
|
95
|
+
export async function runLayerPipeline(tool, toolInput, layers) {
|
|
96
|
+
const firstTerminal = layers.findIndex((layer) => layer.terminal === true);
|
|
97
|
+
const body = firstTerminal === -1 ? layers : layers.slice(0, firstTerminal);
|
|
98
|
+
const terminal = firstTerminal === -1 ? [] : layers.slice(firstTerminal);
|
|
99
|
+
// A non-terminal layer after a terminal one would run BEFORE it on the next
|
|
100
|
+
// pass and after it on this one — an ordering nobody declared. Reject the
|
|
101
|
+
// table rather than pick an interpretation.
|
|
102
|
+
if (terminal.some((layer) => layer.terminal !== true))
|
|
103
|
+
throw new Error("terminal layers must come last in the pipeline table");
|
|
104
|
+
|
|
105
|
+
let current = toolInput;
|
|
106
|
+
let changed = false;
|
|
107
|
+
/** @type {string[]} */
|
|
108
|
+
const contexts = [];
|
|
109
|
+
/** @param {{ updatedInput: any, context: string }} result */
|
|
110
|
+
const accept = (result) => {
|
|
111
|
+
current = result.updatedInput;
|
|
112
|
+
changed = true;
|
|
113
|
+
if (!contexts.includes(result.context)) contexts.push(result.context);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Only the hazardous ordering pays for convergence: a table with no erasing
|
|
117
|
+
// layer after a skip-based one runs its single pass and is done, changes or
|
|
118
|
+
// not — there is no decision left to invalidate.
|
|
119
|
+
const requireFixedPoint = needsFixedPoint(body);
|
|
120
|
+
const passes = requireFixedPoint ? MAX_PIPELINE_PASSES : 1;
|
|
121
|
+
let settled = false;
|
|
122
|
+
for (let pass = 0; pass < passes && !settled; pass++) {
|
|
123
|
+
settled = true;
|
|
124
|
+
for (const layer of body) {
|
|
125
|
+
const result = await layer.run(tool, current);
|
|
126
|
+
if (result === null) continue;
|
|
127
|
+
if ("deny" in result)
|
|
128
|
+
return { updatedInput: current, changed, contexts, deny: result.deny };
|
|
129
|
+
accept(result);
|
|
130
|
+
settled = false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (requireFixedPoint && !settled)
|
|
134
|
+
throw new Error(
|
|
135
|
+
`layer pipeline did not reach a fixed point in ${passes} passes; a layer ` +
|
|
136
|
+
"is undoing another layer's rewrite",
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
for (const layer of terminal) {
|
|
140
|
+
const result = await layer.run(tool, current);
|
|
141
|
+
if (result === null) continue;
|
|
142
|
+
if ("deny" in result)
|
|
143
|
+
return { updatedInput: current, changed, contexts, deny: result.deny };
|
|
144
|
+
accept(result);
|
|
145
|
+
}
|
|
146
|
+
return { updatedInput: current, changed, contexts };
|
|
147
|
+
}
|
|
@@ -20,6 +20,39 @@ import {
|
|
|
20
20
|
readFlag,
|
|
21
21
|
readStdinJson,
|
|
22
22
|
} from "./lib/hook-io.mjs";
|
|
23
|
+
import {
|
|
24
|
+
registerFaultPolicy,
|
|
25
|
+
hookFaultOutcome,
|
|
26
|
+
writeFaultOutcome,
|
|
27
|
+
} from "./lib/hook-fault.mjs";
|
|
28
|
+
|
|
29
|
+
const HOOK_NAME = "plugin-hooks";
|
|
30
|
+
|
|
31
|
+
// The dispatcher's entry in the one posture table (lib/hook-fault.mjs). It
|
|
32
|
+
// answers no single event — it is the binder in front of all four — so it
|
|
33
|
+
// carries no stdout envelope and both arms are process-level.
|
|
34
|
+
//
|
|
35
|
+
// BOTH ARMS BLOCK, and that is the declaration, not an oversight. The
|
|
36
|
+
// AGENT_SANITIZER_FAIL_OPEN knob covers a hook that RAN and broke; an unknown
|
|
37
|
+
// mode is static wiring corruption, which means no hook runs at all, silently,
|
|
38
|
+
// for the life of the install — there is no run to degrade. Stating it here (and
|
|
39
|
+
// pinning it in plugin/test/plugin-bundle.test.mjs) is the point of the table:
|
|
40
|
+
// the arm that ignores the knob does so on the record, next to the four that
|
|
41
|
+
// honor it, instead of by hard-exiting past the question.
|
|
42
|
+
//
|
|
43
|
+
// Exit 2 is the one non-zero code Claude Code treats as BLOCKING: it blocks
|
|
44
|
+
// PreToolUse and UserPromptSubmit, surfaces stderr for PostToolUse, and is
|
|
45
|
+
// harmless for SessionStart.
|
|
46
|
+
const unknownMode = (/** @type {{ message: string }} */ ctx) => ({
|
|
47
|
+
stderr: `${HOOK_NAME}: ${ctx.message}\n`,
|
|
48
|
+
exitCode: 2,
|
|
49
|
+
});
|
|
50
|
+
registerFaultPolicy(HOOK_NAME, {
|
|
51
|
+
event: null,
|
|
52
|
+
guarded: "hook payload",
|
|
53
|
+
open: unknownMode,
|
|
54
|
+
closed: unknownMode,
|
|
55
|
+
});
|
|
23
56
|
|
|
24
57
|
// The packages the hooks lazy-load, each behind a thunk whose import specifier
|
|
25
58
|
// is a LITERAL — esbuild only inlines `import("…")` it can read statically, so
|
|
@@ -114,16 +147,19 @@ export async function main() {
|
|
|
114
147
|
break;
|
|
115
148
|
}
|
|
116
149
|
default:
|
|
117
|
-
// An unknown mode means broken hooks.json wiring —
|
|
118
|
-
//
|
|
119
|
-
// the
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
150
|
+
// An unknown mode means broken hooks.json wiring — never fall through to
|
|
151
|
+
// some default hook and vet the wrong payload class. WHICH way it fails is
|
|
152
|
+
// the operator's call, taken through the one posture table like every
|
|
153
|
+
// other hook fault (this arm used to hard-exit 2 unconditionally, so the
|
|
154
|
+
// knob an operator set was silently overruled here alone).
|
|
155
|
+
process.exit(
|
|
156
|
+
writeFaultOutcome(
|
|
157
|
+
hookFaultOutcome(
|
|
158
|
+
HOOK_NAME,
|
|
159
|
+
new Error(`unknown hook mode ${JSON.stringify(mode)}`),
|
|
160
|
+
),
|
|
161
|
+
),
|
|
125
162
|
);
|
|
126
|
-
process.exit(2);
|
|
127
163
|
}
|
|
128
164
|
}
|
|
129
165
|
|