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 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.** 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
@@ -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. A live
173
- second-LLM injection filter is the caller’s to wire behind that contract.
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 — 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