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 +86 -61
- package/claude-hooks/config/credential-var-names.json +23 -0
- package/claude-hooks/config/inference-key-vars.json +17 -0
- package/claude-hooks/config/scrubbed-env-vars.json +15 -0
- package/claude-hooks/lib/authored-content.mjs +166 -0
- package/claude-hooks/lib/control-plane.mjs +138 -0
- package/claude-hooks/lib/env-config.mjs +140 -0
- package/claude-hooks/lib/hook-io.mjs +366 -0
- package/claude-hooks/lib/invisible-alert.mjs +115 -0
- package/claude-hooks/lib/redactor-client.mjs +514 -0
- package/claude-hooks/lib/reveal.mjs +135 -0
- package/claude-hooks/lib/secret-annotate.mjs +56 -0
- package/claude-hooks/lib/trace.mjs +60 -0
- package/claude-hooks/plugin-hooks.mjs +133 -0
- package/claude-hooks/pretooluse-sanitize.mjs +341 -0
- package/claude-hooks/sanitize-output.mjs +656 -0
- package/claude-hooks/sanitize-user-prompt.mjs +164 -0
- package/claude-hooks/scan-invisible-chars.mjs +313 -0
- package/package.json +8 -5
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.
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
|
166
|
-
is published as data so a consumer with its own matcher derives it
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
|
203
|
-
through the bundled CLI, so
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
`
|
|
209
|
-
|
|
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":"ab"}' | 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
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
`
|
|
226
|
-
|
|
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
|
+
}
|