agent-sanitizer 2.3.0 → 2.4.1

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
@@ -30,11 +30,9 @@ placeholders where hidden HTML was spliced out.
30
30
 
31
31
  ## Entry points
32
32
 
33
- Split into subpaths so the heavy HTML dependency stays opt-in. The **seam**
34
- column marks entry points that inject the agent-specific concern (a callback
35
- you supply) rather than baking it in; `—` means the function is a pure
36
- transform with no such hook, and `fs (direct)` means it does its own file I/O
37
- (Node's filesystem, not an agent harness) instead of taking a callback.
33
+ Split into subpaths so the heavy HTML dependency stays opt-in. **Seam** names
34
+ the callback you inject for the agent-specific concern; `—` is a pure transform,
35
+ `fs (direct)` does its own file I/O instead of taking one.
38
36
 
39
37
  | # | Import | Purpose | Seam |
40
38
  | --- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- |
@@ -69,16 +67,12 @@ without notice.
69
67
 
70
68
  ### `FILTER_WARNING` codes (Layer 5)
71
69
 
72
- The Layer-5 `filterInjection` seam is deliberately thin: the injected filter may
73
- only ask for **verbatim span deletions** (`removeSpans`) and may only warn with a
74
- **closed enum code** (`warning`), never free text. Because the filter runs on
75
- attacker-influenced content and its `warning` would otherwise be concatenated
76
- straight into the model-facing context **without** re-passing Layer 1, a
77
- compromised or prompt-injected filter emitting arbitrary text would defeat the
78
- "a compromised filter can only remove bytes, never inject" contract. So the
79
- **library owns the message** for each code, and a filter returning any value
80
- outside this enum makes `sanitizeText` **throw** (fail loud). Branch on the code,
81
- like `found`:
70
+ The Layer-5 `filterInjection` seam is deliberately thin: the filter may only
71
+ request **verbatim span deletions** (`removeSpans`) and warn with a **closed
72
+ enum code**, never free text. Its warning reaches the model-facing context
73
+ without re-passing Layer 1, so a prompt-injected filter emitting arbitrary text
74
+ would defeat the "can only remove bytes, never inject" contract. The library
75
+ owns each message, and any value outside the enum makes `sanitizeText` **throw**:
82
76
 
83
77
  | `FILTER_WARNING` code | Meaning |
84
78
  | --------------------- | --------------------------------------------------------------------------------------- |
@@ -86,15 +80,59 @@ like `found`:
86
80
  | `filter-flagged` | The filter flagged the output as a possible injection without deleting (content intact) |
87
81
  | `filter-error` | The filter reported a non-fatal internal error while scanning (a fatal filter throws) |
88
82
 
83
+ ## Using it with Claude Code
84
+
85
+ Four hooks put Layers 1–4 on the tool stream: tool input, tool output, user
86
+ prompts, and a session-start scan of the instruction files.
87
+
88
+ ```
89
+ /plugin marketplace add AlexanderMattTurner/agent-sanitizer
90
+ /plugin install agent-sanitizer@agent-sanitizer
91
+ ```
92
+
93
+ The plugin needs no `node_modules` and no build step — just `python3` on PATH
94
+ for Layer 4.
95
+
96
+ To wire them yourself instead, one entry dispatches all four modes on `--hook=`:
97
+
98
+ ```jsonc
99
+ // settings.json — one entry per event; PreToolUse/PostToolUse also take "matcher": "*"
100
+ {
101
+ "type": "command",
102
+ "command": "node ./node_modules/agent-sanitizer/claude-hooks/plugin-hooks.mjs --hook=sanitize-output",
103
+ }
104
+ ```
105
+
106
+ | Event | `--hook=` |
107
+ | ------------------ | ---------------------- |
108
+ | `UserPromptSubmit` | `sanitize-user-prompt` |
109
+ | `PreToolUse` | `pretooluse-sanitize` |
110
+ | `PostToolUse` | `sanitize-output` |
111
+ | `SessionStart` | `scan-invisible-chars` |
112
+
113
+ `require.resolve("agent-sanitizer/claude-hooks")` gives the path without
114
+ hardcoding a layout. Importing the module rather than spawning it is a no-op.
115
+
116
+ **Layer 4 needs the Python engine** — `pip install 'agent-sanitizer[secrets]'`,
117
+ version-matched to the npm package. Without it `sanitize-output` fails closed:
118
+ secret-shaped output is suppressed, not shown unvetted. Layers 1–3 still run.
119
+
120
+ **Layer 5 (second-model injection filtering) is not included.** These hooks
121
+ never supply the `/output` seam's `filterInjection` callback, so nothing here
122
+ calls a model or leaves the machine.
123
+
124
+ Hook internals are tuned by `_AGENT_SANITIZER_*` variables (redactor daemon
125
+ path/socket/timeouts, sanitize budget, trace channel, Layer-2 reveal dir). The
126
+ leading underscore marks them unstable — the supported surface is the `--hook=`
127
+ CLI above.
128
+
89
129
  ## How this compares
90
130
 
91
- The "sanitize untrusted LLM input" space mostly splits into two camps: ML
92
- classifiers that score a prompt's _intent_ (Lakera Guard, Meta's Prompt
93
- Guard, Rebuff, NeMo Guardrails' input rails), and PII-focused redactors
94
- (Microsoft Presidio). Neither targets the byte-level hiding channel this
95
- library covers, and that gap is exactly where invisible-Unicode and
96
- hidden-HTML payloads live—content a semantic classifier never "sees" as
97
- suspicious because it renders as blank space or doesn't render at all.
131
+ The space splits into ML classifiers that score a prompt's _intent_ (Lakera
132
+ Guard, Meta's Prompt Guard, Rebuff, NeMo Guardrails) and PII redactors
133
+ (Presidio). Neither targets the byte-level hiding channel content a semantic
134
+ classifier never "sees" as suspicious because it renders as blank space or
135
+ doesn't render at all.
98
136
 
99
137
  | | `agent-sanitizer` | Semantic guard/classifier (Lakera, Prompt Guard, Rebuff, NeMo rails) | PII redactor (Presidio) |
100
138
  | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
@@ -106,13 +144,10 @@ suspicious because it renders as blank space or doesn't render at all.
106
144
  | **Reversibility** | `/rehydrate` re-anchors a model's edit from the sanitized view back onto real bytes, denying anything ambiguous | N/A—classifiers only pass/block, they don't rewrite-and-reverse | N/A |
107
145
  | **Non-JS support** | Same verdicts via a bundled CLI/worker—Python client included, no reimplementation | Usually a hosted API (language-agnostic) or Python-only SDK | Python-first (spaCy-based) |
108
146
 
109
- In practice these are complementary, not competing: run a semantic guard for
110
- intent, Presidio for PII you must not leak, and this library for the hidden
111
- channel both of those are blind to. If you already run a classifier and are
112
- still getting bitten by zero-width payloads or `display:none` instructions
113
- riding along in RAG context, that's the gap this library closes.
147
+ These are complementary: a semantic guard for intent, Presidio for PII, and this
148
+ for the hidden channel both are blind to.
114
149
 
115
- ### Examples
150
+ ## Examples
116
151
 
117
152
  ```js
118
153
  import { stripInvisibleWithReport } from "agent-sanitizer/invisible";
@@ -162,25 +197,21 @@ await rehydrateRedacted("Edit", toolInput, {
162
197
  }); // { updatedInput, context } | { deny } | null — a deny never exposes a secret
163
198
  ```
164
199
 
165
- The credential-noun vocabulary — the words that make an identifier name a secret —
166
- is published as data so a consumer with its own matcher derives it from one list
167
- instead of forking one. Each noun carries the `uses` it is valid for: `env-name`
168
- for a matcher that inspects a variable NAME only, `field-value` for one that
169
- redacts whatever follows `noun = ` (a broad noun there mangles ordinary text, so
170
- `key` and `pat` are name-only).
200
+ The credential-noun vocabulary — the words that make an identifier name a
201
+ secret — is published as data so a consumer with its own matcher derives it
202
+ rather than forking it. Each noun's `uses` marks where it is valid: `env-name`
203
+ inspects a variable NAME only, `field-value` also redacts what follows
204
+ `noun = ` (too broad for `key` and `pat`, which stay name-only).
171
205
 
172
206
  ```js
173
207
  import { createRequire } from "node:module";
174
- const vocabulary = createRequire(import.meta.url)(
175
- "agent-sanitizer/credential-names",
176
- );
177
- vocabulary.nouns; // [{ parts: ["api", "key"], uses: ["env-name", "field-value"] }, …]
208
+ createRequire(import.meta.url)("agent-sanitizer/credential-names").nouns;
209
+ // [{ parts: ["api", "key"], uses: ["env-name", "field-value"] }, …]
178
210
  ```
179
211
 
180
212
  ```python
181
213
  from agent_sanitizer.secrets import credential_name_segments
182
-
183
- credential_name_segments() # ("API_KEY", "APIKEY", "ACCESS_KEY", …) — rendered for a NAME matcher
214
+ credential_name_segments() # ("API_KEY", "APIKEY", "ACCESS_KEY", …)
184
215
  ```
185
216
 
186
217
  ## Limits
@@ -199,17 +230,14 @@ layer does and does not defend against.
199
230
 
200
231
  ## Non-JS pipelines (Python, etc.)
201
232
 
202
- The JS is the **single source of truth**—non-JS callers drive the same verdicts
203
- through the bundled CLI, so there’s
204
- no second implementation to drift. An `op` field selects the entry point
205
- (default `sanitize`); the self-contained ones—`sanitizeText`, `classifyPrompt`,
206
- `scanInstructionFiles`, `cleanFile`—are all bridged. Entry points with an
207
- injected JS callback have no wire form and stay JS-only. The bridged
208
- `sanitizeText` runs Layers 1–3 only (invisible/ANSI strip, HTML hidden-content
209
- splice, exfil detection): it never redacts secrets (Layer 4) or runs injection
210
- filtering (Layer 5), and the wire protocol's `sgrNote` (Python's
211
- `TextResult.sgr_note`) is always `false`, since the bridge never wires
212
- `sgrCarveOut`.
233
+ The JS is the **single source of truth** — non-JS callers drive the same
234
+ verdicts through the bundled CLI, so no second implementation can drift. An `op`
235
+ field selects the entry point (default `sanitize`); the self-contained ones
236
+ `sanitizeText`, `classifyPrompt`, `scanInstructionFiles`, `cleanFile` — are
237
+ bridged, while entry points taking a JS callback have no wire form. Bridged
238
+ `sanitizeText` runs Layers 1–3 only: no secret redaction (Layer 4), no injection
239
+ filtering (Layer 5), and `sgrNote` is always `false` since the bridge never
240
+ wires `sgrCarveOut`.
213
241
 
214
242
  ```sh
215
243
  echo '{"text":"a​b"}' | npx sanitize-cli # default op: sanitize
@@ -217,16 +245,13 @@ echo '{"op":"classifyPrompt","text":"…"}' | npx sanitize-cli
217
245
  sanitize-cli --worker # newline-delimited, one response/line
218
246
  ```
219
247
 
220
- The [`python/`](./python) client wraps every bridged op (`sanitize`,
221
- `sanitize_text`, `classify_prompt`, `scan_instruction_files`, `clean_file`). The
222
- wheel ships a self-contained, single-file build of the CLI (`src/` and its npm
223
- dependencies bundled into one `.mjs` at release time), so a `pip install` plus
224
- Node.js (>=22) on `PATH` works with no separate JavaScript checkout to clone.
225
- `AGENT_SANITIZER_CLI` is only an override escape hatch (e.g. to point at a
226
- custom/dev build)—a normal install never needs to set it. The first
227
- `html=True` call starts a shared worker, so the ~200 ms HTML module-load is
228
- paid **once per process**; Layer-1 calls stay one-shot. `persist=True/False`
229
- forces the mode and `shutdown_worker()` (also an `atexit` hook) stops it.
248
+ The [`python/`](./python) client wraps every bridged op. The wheel ships a
249
+ single-file build of the CLI, so `pip install` plus Node.js (>=22) on `PATH`
250
+ needs no JavaScript checkout; `AGENT_SANITIZER_CLI` is an override escape hatch
251
+ a normal install never sets. The first `html=True` call starts a shared worker,
252
+ paying the ~200 ms HTML module-load **once per process**; Layer-1 calls stay
253
+ one-shot. `persist=True/False` forces the mode; `shutdown_worker()` (also an
254
+ `atexit` hook) stops it.
230
255
 
231
256
  ```python
232
257
  from agent_sanitizer import sanitize, Sanitizer
@@ -0,0 +1,23 @@
1
+ {
2
+ "comment": "The credential-shaped ENV-VAR NAME vocabulary the hook-side pre-gate builds its regexes from (looksLikeCredentialVar in lib/env-config.mjs). `segments`: a var whose trailing underscore-delimited segment is one of these is treated as credential-bearing (matched as `(?:^|_)(?:<segment>)$`, case-insensitive). `excludeSuffixes` / `excludeNames`: names that end like a credential but hold a non-secret (an identifier, a public key, the ssh-agent socket path) and must NOT be redacted out of tool output. Every token is restricted to A-Z and _ so it carries no regex metacharacter; the consumer enforces that and fails closed on a violation, an empty list, or a missing field.",
3
+ "segments": [
4
+ "TOKEN",
5
+ "SECRET",
6
+ "SECRETS",
7
+ "PASSWORD",
8
+ "PASSWD",
9
+ "PASSPHRASE",
10
+ "APIKEY",
11
+ "API_KEY",
12
+ "ACCESS_KEY",
13
+ "SECRET_KEY",
14
+ "PRIVATE_KEY",
15
+ "AUTH_TOKEN",
16
+ "PAT",
17
+ "CREDENTIAL",
18
+ "CREDENTIALS",
19
+ "KEY"
20
+ ],
21
+ "excludeSuffixes": ["_KEY_ID", "_PUBLIC_KEY"],
22
+ "excludeNames": ["SSH_AUTH_SOCK"]
23
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "description": "Inference-provider API-key env vars whose VALUES the redactor masks by exact match, plus the placeholder floor below which a configured value is treated as a doc stub rather than a real key. Mirrors agent_sanitizer.secrets.config.DEFAULT_MIN_SECRET_LEN; the hooks send these names' current values to the redactor daemon per request (see lib/redactor-client.mjs).",
3
+ "min_secret_len": 16,
4
+ "vars": [
5
+ "ANTHROPIC_API_KEY",
6
+ "ANTHROPIC_AUTH_TOKEN",
7
+ "OPENAI_API_KEY",
8
+ "OPENROUTER_API_KEY",
9
+ "GEMINI_API_KEY",
10
+ "GOOGLE_API_KEY",
11
+ "MISTRAL_API_KEY",
12
+ "GROQ_API_KEY",
13
+ "DEEPSEEK_API_KEY",
14
+ "XAI_API_KEY",
15
+ "VENICE_INFERENCE_KEY"
16
+ ]
17
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "description": "The GUARANTEED FLOOR of credential-bearing environment variables whose values must never reach the model through tool output. On top of this list the redactor self-populates with any credential-shaped var present in the environment (looksLikeCredentialVar in lib/env-config.mjs), so a newly-forwarded token is redacted without editing this file — this list only pins the names that must always be covered regardless of shape. The Layer-4 secret redactor treats each as an env-bound secret (env_secrets).",
3
+ "vars": [
4
+ "CLAUDE_CODE_OAUTH_TOKEN",
5
+ "GH_TOKEN",
6
+ "GITHUB_TOKEN",
7
+ "AWS_ACCESS_KEY_ID",
8
+ "AWS_SECRET_ACCESS_KEY",
9
+ "AWS_SESSION_TOKEN",
10
+ "NPM_TOKEN",
11
+ "PYPI_TOKEN",
12
+ "DOCKER_PASSWORD",
13
+ "DOCKER_AUTH_CONFIG"
14
+ ]
15
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Sanitize content the *model authors* into tool calls — file writes, edits,
3
+ * notebook cells, and command bodies (commit messages, PR/issue bodies passed on
4
+ * the command line). Two complementary protections:
5
+ *
6
+ * 1. Covert channel (steganography). Format chars (Cf — including the U+E00xx
7
+ * TAG block used for ASCII smuggling and zero-width joiners) and variation
8
+ * selectors can encode a hidden message that another AI reading the
9
+ * committed file / PR / commit later decodes, while staying invisible to a
10
+ * human reviewer. Stripped when payload-capable (see isPayloadCapable):
11
+ * gated on volume because incidental joiners / emoji selectors are benign
12
+ * and authored content is *persisted*, so over-stripping is costly.
13
+ *
14
+ * 2. Terminal-display rewriting. ANSI/terminal control sequences (CSI/OSC)
15
+ * authored into a command — echoed and executed live — or into file
16
+ * content (a latent bomb when the file is later `cat`'d) can clear the
17
+ * screen, reposition the cursor, or overwrite what the user sees, hiding
18
+ * the real command behind spoofed output. Stripped *unconditionally*: a
19
+ * single sequence already does harm, so there is no volume threshold. The
20
+ * false-positive rate is low because real source represents escapes as
21
+ * *literals* (`\033`, `\x1b`, `\e`) — a *raw* ESC byte in authored content
22
+ * is anomalous.
23
+ *
24
+ * Distinct from sanitize-output.mjs, which scrubs tool *responses* flowing
25
+ * toward the model (data the model reads). This scrubs what the model emits
26
+ * (data the model writes out). In pretooluse-sanitize.mjs it runs *after*
27
+ * confusable normalization, so on the shared `command` field it sees the
28
+ * already-normalized text and the two protections compose deterministically.
29
+ *
30
+ * Opt-outs are granular so dropping one protection doesn't drop the other:
31
+ * AGENT_SANITIZER_INVISIBLE_DISABLED=1 keeps invisible chars (legitimate i18n
32
+ * text relying on ZWNJ/ZWJ joiners) while terminal-control stripping stays on;
33
+ * AGENT_SANITIZER_TERMINAL_DISABLED=1 keeps raw escape sequences (fixtures that
34
+ * must contain them) while stego stripping stays on; and
35
+ * AGENT_SANITIZER_OUTPUT_DISABLED=1 disables both.
36
+ */
37
+ import { lazyImport } from "./hook-io.mjs";
38
+
39
+ // Bound via lazyImport (see its doc for the fail-OPEN hazard of a bare static
40
+ // npm import — here the load crash would fire inside pretooluse-sanitize.mjs's
41
+ // static import of this module, before its fail-closed catch runs). A failed
42
+ // load leaves these bindings undefined, so sanitizeField's calls throw into
43
+ // the fail-closed catch (ask) instead.
44
+ const { stripAnsiFully } = /** @type {typeof import("agent-sanitizer")} */ (
45
+ await lazyImport("agent-sanitizer")
46
+ );
47
+ const { STRIP, LONG_RUN_RE, SCATTERED_THRESHOLD, stripInvisible } =
48
+ /** @type {typeof import("agent-sanitizer/invisible")} */ (
49
+ await lazyImport("agent-sanitizer/invisible")
50
+ );
51
+
52
+ // Content fields the model authors, per tool. Paths and confusables are the
53
+ // confusable layer's domain; here we target the free-text fields that carry
54
+ // model-authored prose / code / data out into persisted or displayed artifacts.
55
+ // A "key[].sub" entry addresses `sub` on every element of the array at `key`
56
+ // (MultiEdit batches its writes as edits[].new_string), so the nested authored
57
+ // content is sanitized too — not just the top-level fields.
58
+ /** @type {Record<string, string[]>} */
59
+ const FIELDS = {
60
+ Write: ["content"],
61
+ Edit: ["new_string"],
62
+ MultiEdit: ["edits[].new_string"],
63
+ NotebookEdit: ["new_source"],
64
+ Bash: ["command"],
65
+ };
66
+
67
+ // Payload-capable: a long contiguous run, or enough scattered invisibles to
68
+ // carry a message. Mirrors sanitize-user-prompt so the model→world and
69
+ // user→model surfaces share one definition of "stego payload".
70
+ /** @param {string} text */
71
+ function isPayloadCapable(text) {
72
+ LONG_RUN_RE.lastIndex = 0;
73
+ if (LONG_RUN_RE.test(text)) return true;
74
+ return (text.match(STRIP)?.length ?? 0) >= SCATTERED_THRESHOLD;
75
+ }
76
+
77
+ // Returns the cleaned value plus the human-readable actions applied, or null if
78
+ // the field is already clean. Each protection has its own opt-out (see the
79
+ // header) so a deployment can keep one while dropping the other.
80
+ /** @param {string} value */
81
+ function sanitizeField(value) {
82
+ const actions = [];
83
+ let cleaned = value;
84
+
85
+ // Strip terminal-control sequences first, so the invisible scan below runs on
86
+ // the same de-ANSI'd view sanitize-output uses (both go through the package's
87
+ // stripAnsiFully, which strips to a fixed point — so a sequence reconstituted
88
+ // when an inner one is removed is itself stripped on the next pass). Compare
89
+ // before/after rather than pre-testing for ESC: a lone control byte that forms
90
+ // no real sequence does not rewrite the display and is left alone, so we only
91
+ // report a genuine strip.
92
+ if (process.env.AGENT_SANITIZER_TERMINAL_DISABLED !== "1") {
93
+ const deAnsi = stripAnsiFully(cleaned);
94
+ if (deAnsi !== cleaned) {
95
+ cleaned = deAnsi;
96
+ actions.push("terminal-control sequences");
97
+ }
98
+ }
99
+
100
+ if (
101
+ process.env.AGENT_SANITIZER_INVISIBLE_DISABLED !== "1" &&
102
+ isPayloadCapable(cleaned)
103
+ ) {
104
+ cleaned = stripInvisible(cleaned);
105
+ actions.push("invisible characters");
106
+ }
107
+
108
+ return actions.length > 0 ? { cleaned, actions } : null;
109
+ }
110
+
111
+ /** @param {string[]} changed */
112
+ export function authoredContext(changed) {
113
+ return `Sanitized model-authored content in: ${changed.join("; ")}. This removes a covert channel to other AIs and prevents authored content from rewriting the user's terminal. Opt out granularly with AGENT_SANITIZER_INVISIBLE_DISABLED=1 (i18n joiners) or AGENT_SANITIZER_TERMINAL_DISABLED=1 (raw-escape fixtures), or fully with AGENT_SANITIZER_OUTPUT_DISABLED=1.`;
114
+ }
115
+
116
+ /**
117
+ * Strip authored stego / terminal-control sequences from the model-authored
118
+ * fields of a tool call. Returns the updated input plus a per-field description
119
+ * of what was stripped, or null when nothing changed. Throws on internal error
120
+ * (caller fails closed).
121
+ * @param {string} tool
122
+ * @param {any} toolInput
123
+ * @returns {{ updatedInput: any, changed: string[] } | null}
124
+ */
125
+ export function sanitizeAuthoredContent(tool, toolInput) {
126
+ const keys = FIELDS[tool];
127
+ if (!keys || toolInput === null || toolInput === undefined) return null;
128
+
129
+ const changed = [];
130
+ // Null-prototype copy: toolInput is untrusted parsed JSON where a `__proto__`
131
+ // key is own-enumerable, and the computed writes below would otherwise route
132
+ // it through the prototype chain. Object.assign onto Object.create(null) copies
133
+ // every own field (including a literal `__proto__`) as a plain own property.
134
+ const updatedInput = Object.assign(Object.create(null), toolInput);
135
+ for (const k of keys) {
136
+ // Named groups satisfy prefer-named-capture-group; reading the numeric
137
+ // indices keeps the values typed as string (match.groups is optional).
138
+ const nested = k.match(/^(?<arr>\w+)\[\]\.(?<sub>\w+)$/);
139
+ if (nested) {
140
+ const arrKey = nested[1];
141
+ const subKey = nested[2];
142
+ const arr = toolInput[arrKey];
143
+ if (!Array.isArray(arr)) continue;
144
+ let nestedChanged = false;
145
+ const newArr = arr.map((el) => {
146
+ const val = el?.[subKey];
147
+ if (typeof val !== "string") return el;
148
+ const result = sanitizeField(val);
149
+ if (!result) return el;
150
+ nestedChanged = true;
151
+ changed.push(`${arrKey}[].${subKey} (${result.actions.join(", ")})`);
152
+ return { ...el, [subKey]: result.cleaned };
153
+ });
154
+ if (nestedChanged) updatedInput[arrKey] = newArr;
155
+ continue;
156
+ }
157
+ if (typeof toolInput[k] !== "string") continue;
158
+ const result = sanitizeField(toolInput[k]);
159
+ if (!result) continue;
160
+ updatedInput[k] = result.cleaned;
161
+ changed.push(`${k} (${result.actions.join(", ")})`);
162
+ }
163
+
164
+ if (changed.length === 0) return null;
165
+ return { updatedInput, changed };
166
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Bridge to the agent-agnostic control plane (agent-control-plane-core).
3
+ * Guardrail judges consume the normalized ToolCallEvent and return a Verdict;
4
+ * a per-agent adapter parses the native payload and renders the native
5
+ * response, so the same judge runs unchanged under any agent the package has
6
+ * an adapter for. This module owns the package load, the one Claude-specific
7
+ * transport rule (nativeStdout), and the shared judge-CLI transport
8
+ * (runJudgeCli).
9
+ */
10
+ import { errMessage, lazyImport, readStdinJson } from "./hook-io.mjs";
11
+
12
+ // Loaded via a *caught* dynamic import — never a bare static `import … from`.
13
+ // A static npm import resolves before any try/catch, so a missing node_modules
14
+ // would crash every importing hook at load; the harness treats that as a
15
+ // non-blocking error and the tool call sails through UNGUARDED — fail OPEN. A
16
+ // failed load leaves the bindings undefined, so controlPlane() throws into the
17
+ // calling hook's catch and each hook takes its declared failure posture
18
+ // (deny/ask for gates, suppression for the output sanitizer) instead.
19
+ /** @type {typeof import("agent-control-plane-core/claude").claudeAdapter | undefined} */
20
+ let claudeAdapter;
21
+ /** @type {typeof import("agent-control-plane-core").Decision | undefined} */
22
+ let Decision;
23
+ /** @type {typeof import("agent-control-plane-core").EventKind | undefined} */
24
+ let EventKind;
25
+
26
+ /* c8 ignore start -- module-load boundary: the real import resolves in every
27
+ in-process test and spawned CLI run, and a missing node_modules can't be
28
+ simulated in-process, so this glue's failure arm is unobservable here. The
29
+ observable behaviour is controlPlane()'s throw, unit-tested directly. */
30
+ // Stryker disable all
31
+ {
32
+ const { claudeAdapter: adapter } =
33
+ /** @type {Partial<typeof import("agent-control-plane-core/claude")>} */ (
34
+ await lazyImport("agent-control-plane-core/claude")
35
+ );
36
+ const { Decision: decision, EventKind: eventKind } =
37
+ /** @type {Partial<typeof import("agent-control-plane-core")>} */ (
38
+ await lazyImport("agent-control-plane-core")
39
+ );
40
+ claudeAdapter = adapter;
41
+ Decision = decision;
42
+ EventKind = eventKind;
43
+ }
44
+ // Stryker restore all
45
+ /* c8 ignore stop */
46
+
47
+ /**
48
+ * The loaded control-plane bindings, narrowed to non-undefined — or a throw
49
+ * the calling hook's catch converts into its own failure posture. Overrides
50
+ * exist so tests can drive the unavailable arm in-process.
51
+ * @param {{ claudeAdapter?: unknown, Decision?: unknown, EventKind?: unknown }} [overrides]
52
+ * @returns {{
53
+ * claudeAdapter: typeof import("agent-control-plane-core/claude").claudeAdapter,
54
+ * Decision: typeof import("agent-control-plane-core").Decision,
55
+ * EventKind: typeof import("agent-control-plane-core").EventKind,
56
+ * }}
57
+ */
58
+ export function controlPlane(overrides = {}) {
59
+ const bindings = { claudeAdapter, Decision, EventKind, ...overrides };
60
+ if (!bindings.claudeAdapter || !bindings.Decision || !bindings.EventKind)
61
+ throw new Error("agent-control-plane-core is unavailable");
62
+ return /** @type {ReturnType<typeof controlPlane>} */ (bindings);
63
+ }
64
+
65
+ /**
66
+ * Serialize a rendered NativeResponse for Claude Code's stdout, or null when
67
+ * the body carries nothing a silent exit 0 doesn't already say. The adapter's
68
+ * exit_code is deliberately NOT honored by the hooks: Claude Code parses hook
69
+ * stdout as JSON only on exit 0 — under the adapter's exit-2 enforced-deny
70
+ * channel it discards stdout and reads the (empty) stderr instead, so the
71
+ * deny would land without its reason. For this host the stdout JSON's
72
+ * permissionDecision IS the enforcement channel, and hooks always exit 0.
73
+ * @param {{ stdout?: unknown }} response a NativeResponse from adapter.render
74
+ * @returns {string | null}
75
+ */
76
+ export function nativeStdout(response) {
77
+ const stdout = /** @type {Record<string, unknown> | undefined} */ (
78
+ response.stdout
79
+ );
80
+ if (!stdout) return null;
81
+ // Directives live either inside hookSpecificOutput (permissionDecision,
82
+ // updatedInput, additionalContext) or at the top level (the non-gating
83
+ // decision:"block"/reason the adapter uses for post-tool and unclassified
84
+ // events). A body that is only the echoed hookEventName says nothing.
85
+ const body = /** @type {Record<string, unknown> | undefined} */ (
86
+ stdout.hookSpecificOutput
87
+ );
88
+ const meaningful =
89
+ Object.keys(stdout).some((key) => key !== "hookSpecificOutput") ||
90
+ (body !== undefined &&
91
+ Object.keys(body).some((key) => key !== "hookEventName"));
92
+ return meaningful ? JSON.stringify(stdout) : null;
93
+ }
94
+
95
+ /**
96
+ * Run a judge hook's CLI transport: read the native payload from stdin, parse
97
+ * it through the claude adapter, render the judge's verdict, and write the
98
+ * native response. This encodes the two transport invariants every gate hook
99
+ * shares: stdin is read BEFORE the control-plane bindings are touched, so a
100
+ * package-load failure still lands in `onError` with the parsed input in hand;
101
+ * and the process always exits 0 with the verdict in the stdout JSON (see
102
+ * nativeStdout — exit-code enforcement is deliberately not used). Any throw —
103
+ * unparsable stdin, missing package, a judge error — is reported on stderr and
104
+ * routed to `onError(err, input)` (`input` undefined when stdin never parsed),
105
+ * where the hook applies its declared fail posture.
106
+ * @param {string} hookName prefix for the stderr diagnostic
107
+ * @param {(event: import("agent-control-plane-core").ToolCallEvent) =>
108
+ * import("agent-control-plane-core").Verdict |
109
+ * Promise<import("agent-control-plane-core").Verdict>} judge
110
+ * @param {object} opts
111
+ * @param {(err: unknown, input: unknown) => void} opts.onError fail-posture emitter
112
+ * @param {(input: unknown) => unknown} [opts.transformInput] raw-payload normalization before adapter.parse
113
+ * @param {() => Promise<unknown>} [opts.readInput] injectable stdin reader
114
+ * @param {(chunk: string) => void} [opts.write] injectable stdout writer
115
+ * @returns {Promise<void>}
116
+ */
117
+ export async function runJudgeCli(
118
+ hookName,
119
+ judge,
120
+ {
121
+ onError,
122
+ transformInput = (raw) => raw,
123
+ readInput = readStdinJson,
124
+ write = (chunk) => process.stdout.write(chunk),
125
+ },
126
+ ) {
127
+ let input;
128
+ try {
129
+ input = await readInput();
130
+ const { claudeAdapter: adapter } = controlPlane();
131
+ const event = adapter.parse(transformInput(input));
132
+ const out = nativeStdout(adapter.render(await judge(event), event));
133
+ if (out !== null) write(out);
134
+ } catch (err) {
135
+ process.stderr.write(`${hookName} hook error: ${errMessage(err)}\n`);
136
+ onError(err, input);
137
+ }
138
+ }