agent-sanitizer 2.19.3 → 2.19.5

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/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.** Stripping an invisible char can reconstitute an ANSI
40
- escape its split had hidden, and removing one ANSI sequence can reconstitute
41
- another. Layer 1 strips ANSI to a fixed point and then sweeps any residual raw
42
- control introducer—7-bit ESC (U+001B) or 8-bit C1 CSI (U+009B)outright, so the
43
- result carries no raw ANSI introducer for _any_ input and the operation is
44
- idempotent. OSC strings (titles, clickable-hyperlink URLs) are consumed as a
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 nota 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
@@ -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 — fail CLOSED, never fall
118
- // through to some default hook and vet the wrong payload class. Exit 2 is
119
- // the one non-zero code Claude Code treats as blocking (a plain exit 1 is
120
- // a non-blocking hook error that lets the guarded action through
121
- // unsanitized); it blocks PreToolUse and UserPromptSubmit, surfaces
122
- // stderr for PostToolUse, and is harmless for SessionStart.
123
- process.stderr.write(
124
- `plugin-hooks: unknown hook mode ${JSON.stringify(mode)}\n`,
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
 
@@ -16,7 +16,9 @@
16
16
  * BOTH a confusable AND a stego payload had one fix non-deterministically
17
17
  * clobbered by the other. Composing them here makes the rewrite deterministic
18
18
  * (normalize, then strip the normalized text) and pays a single Node start
19
- * instead of three on the hottest path.
19
+ * instead of three on the hottest path. Layers 2-4 run through the declared
20
+ * pipeline in lib/layer-pipeline.mjs, which is what keeps the confusable fold's
21
+ * skip decisions sound once the erasing strip follows it.
20
22
  *
21
23
  * Layers 2 and 4 are the provider-agnostic transforms in the agent-sanitizer
22
24
  * package; this file binds its peers (namespace-guard, the redactor daemon, the
@@ -34,11 +36,11 @@ import {
34
36
  registeredLazyModule,
35
37
  emitHookResponse,
36
38
  safeErrMessage,
37
- failOpenEnabled,
38
- failOpenContext,
39
39
  HookEvent,
40
40
  PermissionDecision,
41
41
  } from "./lib/hook-io.mjs";
42
+ import { registerFaultPolicy, hookFaultOutcome } from "./lib/hook-fault.mjs";
43
+ import { runLayerPipeline } from "./lib/layer-pipeline.mjs";
42
44
  import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
43
45
  import {
44
46
  invisibleCharAlert,
@@ -168,6 +170,83 @@ function emitTraced(emitTrace, toolName, fields) {
168
170
  return fields;
169
171
  }
170
172
 
173
+ /**
174
+ * The declared layer chain, layers 2-4. Every entry states the two properties
175
+ * the driver reasons about — whether it ERASES code points another layer reads,
176
+ * and whether its own decision is SKIP-BASED and therefore invalidated by such
177
+ * an erasure — so the confusable fold's ordering precondition is enforced by the
178
+ * table instead of restated as a comment.
179
+ *
180
+ * Layer 3 is dropped from the table (not merely skipped at run time) when
181
+ * AGENT_SANITIZER_OUTPUT_DISABLED=1, so the driver sees the chain that will
182
+ * actually run: with no erasing layer left after the fold there is no fixed
183
+ * point to reach and no extra pass to pay for.
184
+ * @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} rehydrate
185
+ * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
186
+ * @returns {import("./lib/layer-pipeline.mjs").Layer[]}
187
+ */
188
+ export function preToolUseLayers(rehydrate, env = process.env) {
189
+ /** @type {import("./lib/layer-pipeline.mjs").Layer[]} */
190
+ const layers = [
191
+ {
192
+ name: "confusables",
193
+ // Folding SUBSTITUTES a glyph for its ASCII canon, which drops the
194
+ // original code point; and it SKIPS any token still holding an unmapped
195
+ // glyph, which is the decision an erasure can invalidate.
196
+ erases: true,
197
+ skipBased: true,
198
+ run: (tool, toolInput) => {
199
+ const norm = normalizeConfusables(tool, toolInput, {
200
+ scan: confusableScan,
201
+ });
202
+ return norm === null
203
+ ? null
204
+ : {
205
+ updatedInput: norm.updatedInput,
206
+ context: normalizeContext(norm.normalized),
207
+ };
208
+ },
209
+ },
210
+ {
211
+ name: "authored-content",
212
+ // Erases payload-capable invisible characters, and skips below the
213
+ // payload-capable floor — the erasure that broke the fold's precondition.
214
+ erases: true,
215
+ skipBased: true,
216
+ run: (tool, toolInput) => {
217
+ const authored = sanitizeAuthoredContent(tool, toolInput);
218
+ return authored === null
219
+ ? null
220
+ : {
221
+ updatedInput: authored.updatedInput,
222
+ context: authoredContext(authored.changed),
223
+ };
224
+ },
225
+ },
226
+ {
227
+ name: "rehydrate",
228
+ erases: true,
229
+ skipBased: false,
230
+ // Terminal by contract, not by luck: it re-anchors Edit/Write inputs onto
231
+ // the on-disk bytes, and the secrets it restores must NOT be re-stripped
232
+ // by layer 3 or re-folded by layer 2 on a later pass.
233
+ terminal: true,
234
+ run: async (tool, toolInput) => {
235
+ const rehydrated = await rehydrate(tool, toolInput);
236
+ if (!rehydrated) return null;
237
+ if ("deny" in rehydrated) return { deny: rehydrated.deny };
238
+ return {
239
+ updatedInput: rehydrated.updatedInput,
240
+ context: rehydrated.context,
241
+ };
242
+ },
243
+ },
244
+ ];
245
+ return env.AGENT_SANITIZER_OUTPUT_DISABLED === "1"
246
+ ? layers.filter((layer) => layer.name !== "authored-content")
247
+ : layers;
248
+ }
249
+
171
250
  /**
172
251
  * Compose the four protections. Returns the `hookSpecificOutput` fields to
173
252
  * emit, or null for a clean no-op. Throws only if a layer's engine throws; the
@@ -207,44 +286,21 @@ export async function buildPreToolUseResponse(
207
286
 
208
287
  const { tool_name: tool, tool_input: toolInput } = input;
209
288
 
210
- // Layers 2 then 3, chained: normalize confusables first, then strip authored
211
- // stego/terminal-control from the normalized text.
212
- let current = toolInput;
213
- let changed = false;
214
-
215
- const norm = normalizeConfusables(tool, current, { scan: confusableScan });
216
- if (norm) {
217
- current = norm.updatedInput;
218
- changed = true;
219
- contexts.push(normalizeContext(norm.normalized));
220
- }
221
-
222
- if (process.env.AGENT_SANITIZER_OUTPUT_DISABLED !== "1") {
223
- const authored = sanitizeAuthoredContent(tool, current);
224
- if (authored) {
225
- current = authored.updatedInput;
226
- changed = true;
227
- contexts.push(authoredContext(authored.changed));
228
- }
229
- }
230
-
231
- // Layer 4: re-anchor Edit/Write inputs composed from a sanitized file view
232
- // ([REDACTED…] placeholders, stripped invisible characters) back onto the
233
- // on-disk bytes. Runs last so it sees the final authored text and its
234
- // rehydrated secrets are not re-stripped by layer 3. An unresolvable or
235
- // secret-exposing call is denied outright — that verdict outranks any ask
236
- // above, so it returns immediately.
237
- const rehydrated = await rehydrate(tool, current);
238
- if (rehydrated && "deny" in rehydrated)
289
+ // Layers 2-4, run by the declared pipeline: the driver not this call order —
290
+ // is what keeps the confusable fold's soundness precondition true once an
291
+ // erasing layer follows it (see lib/layer-pipeline.mjs).
292
+ const {
293
+ updatedInput: current,
294
+ changed,
295
+ contexts: layerContexts,
296
+ deny,
297
+ } = await runLayerPipeline(tool, toolInput, preToolUseLayers(rehydrate));
298
+ if (deny !== undefined)
239
299
  return emitTraced(emitTrace, input.tool_name, {
240
300
  permissionDecision: PermissionDecision.DENY,
241
- permissionDecisionReason: rehydrated.deny,
301
+ permissionDecisionReason: deny,
242
302
  });
243
- if (rehydrated) {
244
- current = rehydrated.updatedInput;
245
- changed = true;
246
- contexts.push(rehydrated.context);
247
- }
303
+ contexts.push(...layerContexts);
248
304
 
249
305
  return emitTraced(
250
306
  emitTrace,
@@ -447,11 +503,30 @@ export function failClosedFields(parsedOk, err, opts = {}) {
447
503
  * @returns {Record<string, unknown>}
448
504
  */
449
505
  export function hookFailureFields(parsedOk, err, opts = {}) {
450
- if (failOpenEnabled(opts.env))
451
- return { additionalContext: failOpenContext(HOOK_NAME, "tool input", err) };
452
- return failClosedFields(parsedOk, err, opts);
506
+ return /** @type {Record<string, unknown>} */ (
507
+ hookFaultOutcome(HOOK_NAME, err, {
508
+ parsedOk,
509
+ env: opts.env,
510
+ messages: opts.messages,
511
+ hint: opts.hint,
512
+ }).fields
513
+ );
453
514
  }
454
515
 
516
+ // This hook's entry in the one posture table (lib/hook-fault.mjs). The OPEN arm
517
+ // is the shared default — a warning context and no verdict — so only the CLOSED
518
+ // verdict, which is this hook's own ask/deny split, is stated here.
519
+ registerFaultPolicy(HOOK_NAME, {
520
+ event: HookEvent.PRE_TOOL_USE,
521
+ guarded: "tool input",
522
+ closed: (ctx) => ({
523
+ fields: failClosedFields(ctx.parsedOk, ctx.err, {
524
+ messages: ctx.messages,
525
+ hint: ctx.hint,
526
+ }),
527
+ }),
528
+ });
529
+
455
530
  // Stryker disable all: CLI wiring — it runs only in the spawned hook
456
531
  // subprocess, never in-process, so every mutant from here down is NoCoverage.
457
532
  // The exported judgePreToolUseSanitize and failClosedFields above carry the