agent-sanitizer 2.2.2 → 2.4.0

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.
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Layer-4 pre-gate helpers for env-bound secrets. The redaction transport (the
3
+ * daemon call) stays with the sanitize-output hook; this module owns only the
4
+ * cheap, deterministic checks around it.
5
+ */
6
+ import { minEnvSecretLen, envBoundSecretVars } from "./env-config.mjs";
7
+
8
+ // Zero-width / format (Cf) characters an attacker can splice between a value's
9
+ // characters to break an exact-substring pre-gate while the daemon's redactor
10
+ // still matches across them. This is a curated subset of the common bidi /
11
+ // zero-width controls, NOT an exact copy of the daemon's full dynamic Cf set — it
12
+ // is a defense-in-depth backstop, because Layer 1 (applyLayer1) strips every Cf
13
+ // splice from the text BEFORE this pre-gate runs, so hasEnvBoundSecret already
14
+ // sees the plain value. A run of zero-or-more is allowed at each interior gap, so
15
+ // the plain value still matches (a superset of `includes`). Required literals
16
+ // between every gap keep the pattern linear — no ReDoS.
17
+ const ENV_INVIS_RUN =
18
+ "[\\u200b\\u200c\\u200d\\u2060\\ufeff\\u00ad\\u180e\\u200e\\u200f\\u202a-\\u202e\\u2066-\\u2069]*";
19
+
20
+ /**
21
+ * Regex matching `value` tolerating invisible chars spliced between its
22
+ * characters (mirrors the engine's env-value regex). Code-point split so
23
+ * an astral character is escaped whole, not as two surrogate halves.
24
+ * @param {string} value
25
+ * @returns {RegExp}
26
+ */
27
+ export function envValueRegex(value) {
28
+ return new RegExp(
29
+ [...value]
30
+ .map((ch) => ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
31
+ .join(ENV_INVIS_RUN),
32
+ );
33
+ }
34
+
35
+ /**
36
+ * True when tool output contains the literal value of a configured env-bound
37
+ * secret. The shape-based secret hint can't match a prefix-less key or a host
38
+ * credential, so the pre-gate must also fire on the value itself — otherwise
39
+ * the engine's env-bound redaction never runs. Invisible-tolerant so a
40
+ * value with spliced Cf chars (which the daemon still redacts) trips it too.
41
+ * @param {string} text
42
+ * @param {NodeJS.ProcessEnv} [env]
43
+ * @returns {boolean}
44
+ */
45
+ export function hasEnvBoundSecret(text, env = process.env) {
46
+ const minLen = minEnvSecretLen();
47
+ return envBoundSecretVars().some((name) => {
48
+ const value = env[name];
49
+ // Code-point length to match envValueRegex's code-point split — an astral
50
+ // char counts once, not as two UTF-16 units, so minLen means the same thing
51
+ // on both sides of the gate.
52
+ return (
53
+ value && [...value].length >= minLen && envValueRegex(value).test(text)
54
+ );
55
+ });
56
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Opt-in structured trace channel for the hooks. When _AGENT_SANITIZER_TRACE
3
+ * names a level (info|debug; off/empty disables), each call appends one JSON line
4
+ * {"ts":<epoch_ms>,"level":"info","event":"<name>",...<fields>}
5
+ * to the sink — the file named by _AGENT_SANITIZER_TRACE_FILE, else stderr. The
6
+ * point is that every defense layer announces it ENGAGED, so a missing
7
+ * announcement is loud. It is best-effort: a sink it can't write never throws, so
8
+ * dropping a trace() onto a hook path costs nothing and risks nothing.
9
+ *
10
+ * METADATA ONLY — never pass a tool_input body or secret material as a field; the
11
+ * channel is not redaction-aware.
12
+ */
13
+
14
+ import { appendFileSync } from "node:fs";
15
+
16
+ /** Trace-channel event names. */
17
+ export const TraceEvent = Object.freeze({
18
+ HOOK_RAN: "hook_ran",
19
+ SCAN_INVISIBLE_CHARS_RAN: "scan_invisible_chars_ran",
20
+ });
21
+
22
+ const LEVELS = Object.freeze({ off: 0, info: 1, debug: 2 });
23
+
24
+ /**
25
+ * Numeric verbosity from _AGENT_SANITIZER_TRACE: 0 off, 1 info, 2 debug.
26
+ * Unknown, empty, or "off" → 0.
27
+ * @param {NodeJS.ProcessEnv} [env]
28
+ * @returns {number}
29
+ */
30
+ export function traceThreshold(env = process.env) {
31
+ const value = (env._AGENT_SANITIZER_TRACE ?? "").toLowerCase();
32
+ if (value === "debug" || value === "2") return LEVELS.debug;
33
+ if (["info", "1", "true", "on"].includes(value)) return LEVELS.info;
34
+ return LEVELS.off;
35
+ }
36
+
37
+ /**
38
+ * Emit one JSON trace line for `event` at `level` (default "info") carrying the
39
+ * metadata `fields`. No-op when the channel is below `level`; best-effort on write.
40
+ * @param {string} event
41
+ * @param {Record<string, unknown>} [fields]
42
+ * @param {"info"|"debug"} [level]
43
+ * @returns {void}
44
+ */
45
+ export function trace(event, fields = {}, level = "info") {
46
+ // info|debug are the only real levels; anything else (a producer typo) clamps
47
+ // to info for BOTH the gate and the recorded field, so a line never carries a
48
+ // level outside {info,debug} for a reader to bucket on.
49
+ const lvl = level === "debug" ? "debug" : "info";
50
+ if (traceThreshold() < LEVELS[lvl]) return;
51
+ const line =
52
+ JSON.stringify({ ts: Date.now(), level: lvl, event, ...fields }) + "\n";
53
+ const file = process.env._AGENT_SANITIZER_TRACE_FILE;
54
+ try {
55
+ if (file) appendFileSync(file, line);
56
+ else process.stderr.write(line);
57
+ } catch {
58
+ // best-effort: a trace we can't write must never break a hook.
59
+ }
60
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Entry point: ALL FOUR sanitization hooks behind one dispatch flag. The four
3
+ * hooks share almost the entire package graph, so one bundle per hook would ship
4
+ * four near-identical copies; a single entry with a `--hook=<name>` flag
5
+ * selecting the hook ships the graph once (hooks.json passes the flag).
6
+ *
7
+ * The plugin's shipped artifact is the esbuild BUNDLE with every package inlined
8
+ * at build time — there is no node_modules for a runtime import to resolve — so
9
+ * this binder statically imports the packages the hooks lazy-load and
10
+ * pre-registers them BEFORE any hook module is imported. Each hook module is
11
+ * loaded through a literal dynamic import so the registration (and the CLI-slot
12
+ * claim) precede its top-level lazyImport calls, then its own exported CLI runs.
13
+ *
14
+ * Layer 2/3 (the remark/rehype HTML parser graph) is inlined and runs locally.
15
+ */
16
+ import {
17
+ claimCliEntry,
18
+ isMain,
19
+ registerLazyModules,
20
+ readFlag,
21
+ readStdinJson,
22
+ } from "./lib/hook-io.mjs";
23
+
24
+ // The packages the hooks lazy-load, each behind a thunk whose import specifier
25
+ // is a LITERAL — esbuild only inlines `import("…")` it can read statically, so
26
+ // an `import(variable)` here would survive into the bundle as a runtime dial
27
+ // against a node_modules the plugin does not ship, and every registration would
28
+ // fail at once.
29
+ const LAZY_LOADERS = {
30
+ "agent-control-plane-core": () => import("agent-control-plane-core"),
31
+ "agent-control-plane-core/claude": () =>
32
+ import("agent-control-plane-core/claude"),
33
+ "agent-sanitizer": () => import("agent-sanitizer"),
34
+ "agent-sanitizer/confusables": () => import("agent-sanitizer/confusables"),
35
+ "agent-sanitizer/invisible": () => import("agent-sanitizer/invisible"),
36
+ "agent-sanitizer/output": () => import("agent-sanitizer/output"),
37
+ "agent-sanitizer/rehydrate": () => import("agent-sanitizer/rehydrate"),
38
+ "namespace-guard": () => import("namespace-guard"),
39
+ };
40
+
41
+ /**
42
+ * Pre-register every package the hooks lazy-load, skipping any that will not
43
+ * load.
44
+ *
45
+ * A top-level static import of these would abort the process before `main`
46
+ * runs, and an aborted hook writes NOTHING to stdout — which Claude Code reads
47
+ * as a non-blocking hook error and shows the raw tool output. That is a
48
+ * fail-OPEN on the very hook that withholds secrets. Registering what resolves
49
+ * and leaving the rest absent hands the gap to each hook's own lazyImport
50
+ * guard, whose posture is to block.
51
+ * @returns {Promise<void>}
52
+ */
53
+ async function registerAvailableModules() {
54
+ /** @type {Record<string, Record<string, any>>} */
55
+ const loaded = {};
56
+ await Promise.all(
57
+ Object.entries(LAZY_LOADERS).map(async ([specifier, load]) => {
58
+ try {
59
+ loaded[specifier] = await load();
60
+ } catch {
61
+ // Left unregistered on purpose — see the fail-open note above.
62
+ }
63
+ }),
64
+ );
65
+ registerLazyModules(loaded);
66
+ }
67
+
68
+ /**
69
+ * Dispatch to the hook named by `--hook=<name>` in argv. Exported and guarded by
70
+ * isMain below so importing this module (the published entry point) is a no-op:
71
+ * only a direct `node plugin-hooks.mjs --hook=…` run consumes stdin and exits.
72
+ * @returns {Promise<void>}
73
+ */
74
+ export async function main() {
75
+ // This binder owns the process's CLI entry: inside the bundle every inlined
76
+ // module shares this file's import.meta.url, so without the claim the inlined
77
+ // hooks' own isMain-guarded CLIs would also fire and consume stdin.
78
+ claimCliEntry();
79
+ await registerAvailableModules();
80
+
81
+ const mode = readFlag(process.argv, "hook");
82
+ switch (mode) {
83
+ case "pretooluse-sanitize": {
84
+ const { cliMain } =
85
+ /** @type {typeof import("./pretooluse-sanitize.mjs")} */ (
86
+ await import("./pretooluse-sanitize.mjs")
87
+ );
88
+ await cliMain();
89
+ break;
90
+ }
91
+ case "sanitize-output": {
92
+ const { cliMain } =
93
+ /** @type {typeof import("./sanitize-output.mjs")} */ (
94
+ await import("./sanitize-output.mjs")
95
+ );
96
+ await cliMain();
97
+ break;
98
+ }
99
+ case "sanitize-user-prompt": {
100
+ const { main: promptMain } =
101
+ /** @type {typeof import("./sanitize-user-prompt.mjs")} */ (
102
+ await import("./sanitize-user-prompt.mjs")
103
+ );
104
+ await promptMain(readStdinJson, (chunk) => process.stdout.write(chunk));
105
+ break;
106
+ }
107
+ case "scan-invisible-chars": {
108
+ const { cliMain } =
109
+ /** @type {typeof import("./scan-invisible-chars.mjs")} */ (
110
+ await import("./scan-invisible-chars.mjs")
111
+ );
112
+ await cliMain();
113
+ break;
114
+ }
115
+ default:
116
+ // An unknown mode means broken hooks.json wiring — fail CLOSED, never fall
117
+ // through to some default hook and vet the wrong payload class. Exit 2 is
118
+ // the one non-zero code Claude Code treats as blocking (a plain exit 1 is
119
+ // a non-blocking hook error that lets the guarded action through
120
+ // unsanitized); it blocks PreToolUse and UserPromptSubmit, surfaces
121
+ // stderr for PostToolUse, and is harmless for SessionStart.
122
+ process.stderr.write(
123
+ `plugin-hooks: unknown hook mode ${JSON.stringify(mode)}\n`,
124
+ );
125
+ process.exit(2);
126
+ }
127
+ }
128
+
129
+ // isMain is read BEFORE main() claims the CLI slot (the claim makes every later
130
+ // isMain answer false, including this one).
131
+ if (isMain(import.meta.url)) {
132
+ await main();
133
+ }
@@ -0,0 +1,341 @@
1
+ /**
2
+ * PreToolUse content-protection orchestrator. Runs four layers in ONE process:
3
+ *
4
+ * 1. Invisible-char injection gate (lib/invisible-alert.mjs)
5
+ * 2. Confusable/homoglyph normalization of paths & commands
6
+ * (agent-sanitizer/confusables, namespace-guard scanner injected)
7
+ * 3. Stego / terminal-control stripping of model-authored fields
8
+ * (lib/authored-content.mjs)
9
+ * 4. Rehydration of secret-redaction placeholders in Edit/Write inputs
10
+ * (agent-sanitizer/rehydrate, redactor-daemon io injected)
11
+ *
12
+ * WHY ONE PROCESS: Claude Code runs PreToolUse hooks in parallel and does NOT
13
+ * chain their `updatedInput` — each hook sees the original input and the last to
14
+ * finish wins. Registered as three separate hooks, layers 2 and 3 both rewrite
15
+ * the shared Bash `command` field from the original text, so a command carrying
16
+ * BOTH a confusable AND a stego payload had one fix non-deterministically
17
+ * clobbered by the other. Composing them here makes the rewrite deterministic
18
+ * (normalize, then strip the normalized text) and pays a single Node start
19
+ * instead of three on the hottest path.
20
+ *
21
+ * Layers 2 and 4 are the provider-agnostic transforms in the agent-sanitizer
22
+ * package; this file binds its peers (namespace-guard, the redactor daemon, the
23
+ * filesystem) into them.
24
+ */
25
+ import { createRequire } from "node:module";
26
+ import { readFileSync } from "node:fs";
27
+ import {
28
+ isMain,
29
+ lazyImport,
30
+ registeredLazyModule,
31
+ emitHookResponse,
32
+ safeErrMessage,
33
+ HookEvent,
34
+ PermissionDecision,
35
+ } from "./lib/hook-io.mjs";
36
+ import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
37
+ import {
38
+ invisibleCharAlert,
39
+ gateAskReason,
40
+ gateReminderContext,
41
+ alertAcknowledged,
42
+ acknowledgeAlert,
43
+ } from "./lib/invisible-alert.mjs";
44
+ import {
45
+ sanitizeAuthoredContent,
46
+ authoredContext,
47
+ } from "./lib/authored-content.mjs";
48
+ import { redactViaDaemon } from "./lib/redactor-client.mjs";
49
+ import { trace, TraceEvent } from "./lib/trace.mjs";
50
+
51
+ const HOOK_NAME = "pretooluse-sanitize";
52
+
53
+ // Layers 2 & 4 come from the agent-sanitizer package, bound via lazyImport (see
54
+ // its doc for the fail-OPEN hazard of a bare static npm import); a failed load
55
+ // leaves these bindings undefined, so the layer calls below throw into the CLI's
56
+ // fail-closed catch (ask) instead.
57
+ const { normalizeConfusables, normalizeContext } =
58
+ /** @type {typeof import("agent-sanitizer/confusables")} */ (
59
+ await lazyImport("agent-sanitizer/confusables")
60
+ );
61
+ const { rehydrateRedacted } =
62
+ /** @type {typeof import("agent-sanitizer/rehydrate")} */ (
63
+ await lazyImport("agent-sanitizer/rehydrate")
64
+ );
65
+
66
+ // Injection seams binding the peer dependencies into the provider-agnostic
67
+ // package functions. namespace-guard (the confusable vision map) and the
68
+ // redactor daemon are external to the package; it imports neither.
69
+ // namespace-guard is lazy-required so its map loads only on the first field that
70
+ // actually carries a non-ASCII glyph — normalizeConfusables applies its ASCII
71
+ // fast-path before ever calling scan.
72
+ const require = createRequire(import.meta.url);
73
+ // Registry first, so a build-time bundle (which has no node_modules for the
74
+ // require to resolve from) reaches its statically-inlined, pre-registered copy;
75
+ // running from source keeps the lazy require. The lookup must stay synchronous —
76
+ // normalizeConfusables calls the scan inline.
77
+ /** @param {string} text */
78
+ const confusableScan = (text) =>
79
+ (registeredLazyModule("namespace-guard") ?? require("namespace-guard")).scan(
80
+ text,
81
+ );
82
+
83
+ /**
84
+ * File + redactor-daemon I/O the package's rehydrateRedacted runs against:
85
+ * `redactMap` yields the redacted view plus ordered (placeholder, original,
86
+ * start) pairs, `redact` the plain redacted text or null. Both go through the
87
+ * long-lived redactor daemon so detect-secrets stays the only engine.
88
+ * @type {import("agent-sanitizer/rehydrate").RehydrateIo}
89
+ */
90
+ const redactorIo = {
91
+ readFile: (path) => readFileSync(path, "utf8"),
92
+ redactMap: async (text) =>
93
+ /** @type {any} */ (await redactViaDaemon(text, { map: true })),
94
+ redact: async (text) => {
95
+ const out = await redactViaDaemon(text, {});
96
+ return out ? /** @type {string} */ (out.text) : null;
97
+ },
98
+ };
99
+
100
+ /**
101
+ * Default Layer-4 rehydrator: the package's rehydrateRedacted bound to the
102
+ * redactor-daemon io. Hoisted (not an inline default-param arrow) so tests can
103
+ * still inject a fake as the second argument to buildPreToolUseResponse.
104
+ * @param {string} tool
105
+ * @param {any} toolInput
106
+ */
107
+ const defaultRehydrate = (tool, toolInput) =>
108
+ rehydrateRedacted(tool, toolInput, redactorIo);
109
+
110
+ /**
111
+ * Trace the response on the way out — "noop" (clean pass-through), "deny",
112
+ * "ask", or "modified" (input rewritten and/or context attached) — and return
113
+ * it unchanged. The trace lives on this in-process, mutation-tested path, not in
114
+ * the CLI block, so engagement is announced (hook_ran — metadata only: hook
115
+ * name, tool, outcome) for every exit.
116
+ * @param {string} toolName
117
+ * @param {Record<string, unknown> | null} fields
118
+ * @returns {Record<string, unknown> | null}
119
+ */
120
+ function emitTraced(toolName, fields) {
121
+ let outcome = "modified";
122
+ if (fields === null) outcome = "noop";
123
+ else if (fields.permissionDecision === PermissionDecision.DENY)
124
+ outcome = "deny";
125
+ else if (fields.permissionDecision === PermissionDecision.ASK)
126
+ outcome = "ask";
127
+ trace(TraceEvent.HOOK_RAN, { hook: HOOK_NAME, tool: toolName, outcome });
128
+ return fields;
129
+ }
130
+
131
+ /**
132
+ * Compose the four protections. Returns the `hookSpecificOutput` fields to
133
+ * emit, or null for a clean no-op. Throws only if a layer's engine throws; the
134
+ * caller fails closed (ask) on any throw. Every exit routes through emitTraced.
135
+ * @param {any} input parsed PreToolUse event
136
+ * @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
137
+ * injectable for tests; the default binds the real redactor-daemon io (the
138
+ * layer reads the target file and maps secrets through the daemon)
139
+ * @returns {Promise<Record<string, unknown> | null>}
140
+ */
141
+ export async function buildPreToolUseResponse(
142
+ input,
143
+ rehydrate = defaultRehydrate,
144
+ ) {
145
+ const asks = [];
146
+ const contexts = [];
147
+
148
+ // Layer 1: gate. Persists across the session until the injected files are
149
+ // cleaned. It asks ONCE (a hard checkpoint, recorded once emitted) then
150
+ // degrades to a passive reminder, so it doesn't prompt on every tool call.
151
+ const findings = invisibleCharAlert();
152
+ let pendingGateAck = false;
153
+ if (findings) {
154
+ if (alertAcknowledged()) {
155
+ contexts.push(gateReminderContext());
156
+ } else {
157
+ asks.push(gateAskReason(findings));
158
+ pendingGateAck = true;
159
+ }
160
+ }
161
+
162
+ const { tool_name: tool, tool_input: toolInput } = input;
163
+
164
+ // Layers 2 then 3, chained: normalize confusables first, then strip authored
165
+ // stego/terminal-control from the normalized text.
166
+ let current = toolInput;
167
+ let changed = false;
168
+
169
+ const norm = normalizeConfusables(tool, current, { scan: confusableScan });
170
+ if (norm) {
171
+ current = norm.updatedInput;
172
+ changed = true;
173
+ contexts.push(normalizeContext(norm.normalized));
174
+ }
175
+
176
+ if (process.env.AGENT_SANITIZER_OUTPUT_DISABLED !== "1") {
177
+ const authored = sanitizeAuthoredContent(tool, current);
178
+ if (authored) {
179
+ current = authored.updatedInput;
180
+ changed = true;
181
+ contexts.push(authoredContext(authored.changed));
182
+ }
183
+ }
184
+
185
+ // Layer 4: re-anchor Edit/Write inputs composed from a sanitized file view
186
+ // ([REDACTED…] placeholders, stripped invisible characters) back onto the
187
+ // on-disk bytes. Runs last so it sees the final authored text and its
188
+ // rehydrated secrets are not re-stripped by layer 3. An unresolvable or
189
+ // secret-exposing call is denied outright — that verdict outranks any ask
190
+ // above, so it returns immediately.
191
+ const rehydrated = await rehydrate(tool, current);
192
+ if (rehydrated && "deny" in rehydrated)
193
+ return emitTraced(input.tool_name, {
194
+ permissionDecision: PermissionDecision.DENY,
195
+ permissionDecisionReason: rehydrated.deny,
196
+ });
197
+ if (rehydrated) {
198
+ current = rehydrated.updatedInput;
199
+ changed = true;
200
+ contexts.push(rehydrated.context);
201
+ }
202
+
203
+ return emitTraced(
204
+ input.tool_name,
205
+ assembleResponse({ changed, current, asks, contexts, pendingGateAck }),
206
+ );
207
+ }
208
+
209
+ /**
210
+ * Assemble the hookSpecificOutput fields from the per-layer results, or null
211
+ * for a clean no-op (nothing asked, changed, or annotated). Records the gate
212
+ * acknowledgement only when an ask actually lands in the response.
213
+ * @param {{ changed: boolean, current: any, asks: string[], contexts: string[], pendingGateAck: boolean }} parts
214
+ * @returns {Record<string, unknown> | null}
215
+ */
216
+ function assembleResponse({
217
+ changed,
218
+ current,
219
+ asks,
220
+ contexts,
221
+ pendingGateAck,
222
+ }) {
223
+ if (asks.length === 0 && !changed && contexts.length === 0) return null;
224
+
225
+ /** @type {Record<string, unknown>} */
226
+ const fields = {};
227
+ // Include the rewritten input even alongside an ask: applying it can only
228
+ // surface a *cleaner* call to the user than the original (and is ignored if
229
+ // Claude Code doesn't apply updatedInput under an ask).
230
+ if (changed) fields.updatedInput = current;
231
+ if (asks.length > 0) {
232
+ fields.permissionDecision = PermissionDecision.ASK;
233
+ // Stryker disable next-line StringLiteral: the gate is the only source that
234
+ // pushes onto `asks`, so the array never holds more than one reason and the
235
+ // separator is unobservable — join("") is equivalent. The paragraph break is
236
+ // kept for the day a second ask source is added.
237
+ fields.permissionDecisionReason = asks.join("\n\n");
238
+ }
239
+ if (contexts.length > 0) fields.additionalContext = contexts.join(" ");
240
+ // Record the gate ack only now that the ask is actually in the response — a
241
+ // rehydrate deny above returns first, so a preempted ask is not marked seen.
242
+ if (pendingGateAck) acknowledgeAlert();
243
+ return fields;
244
+ }
245
+
246
+ /**
247
+ * Agent-agnostic judge over the four protections: consumes a control-plane
248
+ * ToolCallEvent and returns a Verdict, so a non-Claude host can run the same
249
+ * sanitization pipeline through its own adapter. The wired Claude CLI below
250
+ * routes through this judge and renders the Verdict with the Claude adapter; on
251
+ * any throw (a control-plane package-load failure included) it falls back to
252
+ * failClosedFields — a native response that needs no package — so the
253
+ * fail-closed posture holds even when the adapter never loaded.
254
+ * @param {import("agent-control-plane-core").ToolCallEvent} event
255
+ * @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
256
+ * @returns {Promise<import("agent-control-plane-core").Verdict>}
257
+ */
258
+ export async function judgePreToolUseSanitize(event, rehydrate) {
259
+ const { Decision, EventKind } = controlPlane();
260
+ // A payload the adapter cannot classify (a missing/unexpected hook_event_name)
261
+ // would drive the pipeline with an empty event and no-op to ALLOW — a silent
262
+ // fail-OPEN of the gate. This hook is wired only to PreToolUse, so an
263
+ // unclassifiable payload is harness-contract drift or an out-of-band caller,
264
+ // never a real call: deny-when-blind. Rewarding an unclassifiable payload with
265
+ // a pass is the one incentive a gate must never create.
266
+ if (event.event === EventKind.UNKNOWN)
267
+ return {
268
+ decision: Decision.DENY,
269
+ reason:
270
+ "PreToolUse sanitization blocked (fail-closed): unrecognized hook payload.",
271
+ };
272
+ const fields = await buildPreToolUseResponse(
273
+ { tool_name: event.tool, tool_input: event.input },
274
+ rehydrate,
275
+ );
276
+ if (fields === null) return { decision: Decision.ALLOW };
277
+ /** @type {Record<string, unknown>} */
278
+ const verdict = {
279
+ decision: fields.permissionDecision ?? Decision.ALLOW,
280
+ };
281
+ if (fields.permissionDecisionReason !== undefined)
282
+ verdict.reason = fields.permissionDecisionReason;
283
+ if (fields.updatedInput !== undefined)
284
+ verdict.mutated_input = fields.updatedInput;
285
+ if (fields.additionalContext !== undefined)
286
+ verdict.additional_context = fields.additionalContext;
287
+ return /** @type {import("agent-control-plane-core").Verdict} */ (verdict);
288
+ }
289
+
290
+ /**
291
+ * The fail-closed hookSpecificOutput fields for a hook-level failure, chosen by
292
+ * WHICH failure it was. Corrupt/unparsable INPUT (`parsedOk` false — a JSON parse
293
+ * error or the oversize-body cap) is a state an adversary can induce with no
294
+ * upside to failing, so it hard-DENIES: no human to talk past, no approval
295
+ * fatigue, no latency. A LAYER/engine throw after a clean parse (`parsedOk` true
296
+ * — redactor daemon down, package not loaded) is the sanitizer being UNAVAILABLE,
297
+ * so it ASKS to keep a human in the loop rather than hard-block on infrastructure.
298
+ * @param {boolean} parsedOk whether the input parsed before the failure
299
+ * @param {unknown} err
300
+ * @returns {Record<string, unknown>}
301
+ */
302
+ export function failClosedFields(parsedOk, err) {
303
+ return {
304
+ permissionDecision: parsedOk
305
+ ? PermissionDecision.ASK
306
+ : PermissionDecision.DENY,
307
+ permissionDecisionReason: parsedOk
308
+ ? `PreToolUse sanitization failed (fail-closed): ${safeErrMessage(err)}`
309
+ : `PreToolUse input unparsable (fail-closed): ${safeErrMessage(err)}`,
310
+ };
311
+ }
312
+
313
+ // Stryker disable all: CLI wiring — it runs only in the spawned hook
314
+ // subprocess, never in-process, so every mutant from here down is NoCoverage.
315
+ // The exported judgePreToolUseSanitize and failClosedFields above carry the
316
+ // real, mutation-tested logic.
317
+ /**
318
+ * The hook's CLI: parse → judge → render, with this hook's fail-closed posture.
319
+ * Exported so a bundle entry (which must claim the CLI slot before this module
320
+ * loads) can run the exact same wiring instead of duplicating the onError
321
+ * posture.
322
+ * @returns {Promise<void>}
323
+ */
324
+ export async function cliMain() {
325
+ await runJudgeCli("pretooluse-sanitize", judgePreToolUseSanitize, {
326
+ // Fail closed WITHOUT the package: unparsable INPUT (`input` undefined)
327
+ // hard-denies (adversary-inducible, no benefit to failing); any throw
328
+ // after a clean parse — a layer engine down or the control-plane package
329
+ // unavailable — asks to keep a human in the loop. emitHookResponse renders
330
+ // natively, so this posture holds even when the adapter never loaded.
331
+ onError: (err, input) =>
332
+ emitHookResponse(
333
+ HookEvent.PRE_TOOL_USE,
334
+ failClosedFields(input !== undefined, err),
335
+ ),
336
+ });
337
+ }
338
+
339
+ if (isMain(import.meta.url)) {
340
+ await cliMain();
341
+ }