agent-sanitizer 2.4.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 +66 -148
- package/package.json +1 -1
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
|
| --------------------- | --------------------------------------------------------------------------------------- |
|
|
@@ -88,120 +82,57 @@ like `found`:
|
|
|
88
82
|
|
|
89
83
|
## Using it with Claude Code
|
|
90
84
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
stream: tool input, tool output, user prompts, and a session-start scan of the
|
|
94
|
-
instruction files.
|
|
95
|
-
|
|
96
|
-
### The plugin (recommended)
|
|
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.
|
|
97
87
|
|
|
98
88
|
```
|
|
99
89
|
/plugin marketplace add AlexanderMattTurner/agent-sanitizer
|
|
100
90
|
/plugin install agent-sanitizer@agent-sanitizer
|
|
101
91
|
```
|
|
102
92
|
|
|
103
|
-
The plugin
|
|
104
|
-
|
|
105
|
-
beyond a `python3` on PATH.
|
|
106
|
-
|
|
107
|
-
### Without the plugin
|
|
93
|
+
The plugin needs no `node_modules` and no build step — just `python3` on PATH
|
|
94
|
+
for Layer 4.
|
|
108
95
|
|
|
109
|
-
|
|
110
|
-
One entry point dispatches all four modes on `--hook=`:
|
|
96
|
+
To wire them yourself instead, one entry dispatches all four modes on `--hook=`:
|
|
111
97
|
|
|
112
98
|
```jsonc
|
|
99
|
+
// settings.json — one entry per event; PreToolUse/PostToolUse also take "matcher": "*"
|
|
113
100
|
{
|
|
114
|
-
"
|
|
115
|
-
|
|
116
|
-
{
|
|
117
|
-
"hooks": [
|
|
118
|
-
{
|
|
119
|
-
"type": "command",
|
|
120
|
-
"command": "node ./node_modules/agent-sanitizer/claude-hooks/plugin-hooks.mjs --hook=sanitize-user-prompt",
|
|
121
|
-
},
|
|
122
|
-
],
|
|
123
|
-
},
|
|
124
|
-
],
|
|
125
|
-
"PreToolUse": [
|
|
126
|
-
{
|
|
127
|
-
"matcher": "*",
|
|
128
|
-
"hooks": [
|
|
129
|
-
{
|
|
130
|
-
"type": "command",
|
|
131
|
-
"command": "node ./node_modules/agent-sanitizer/claude-hooks/plugin-hooks.mjs --hook=pretooluse-sanitize",
|
|
132
|
-
},
|
|
133
|
-
],
|
|
134
|
-
},
|
|
135
|
-
],
|
|
136
|
-
"PostToolUse": [
|
|
137
|
-
{
|
|
138
|
-
"matcher": "*",
|
|
139
|
-
"hooks": [
|
|
140
|
-
{
|
|
141
|
-
"type": "command",
|
|
142
|
-
"command": "node ./node_modules/agent-sanitizer/claude-hooks/plugin-hooks.mjs --hook=sanitize-output",
|
|
143
|
-
},
|
|
144
|
-
],
|
|
145
|
-
},
|
|
146
|
-
],
|
|
147
|
-
"SessionStart": [
|
|
148
|
-
{
|
|
149
|
-
"hooks": [
|
|
150
|
-
{
|
|
151
|
-
"type": "command",
|
|
152
|
-
"command": "node ./node_modules/agent-sanitizer/claude-hooks/plugin-hooks.mjs --hook=scan-invisible-chars",
|
|
153
|
-
},
|
|
154
|
-
],
|
|
155
|
-
},
|
|
156
|
-
],
|
|
157
|
-
},
|
|
101
|
+
"type": "command",
|
|
102
|
+
"command": "node ./node_modules/agent-sanitizer/claude-hooks/plugin-hooks.mjs --hook=sanitize-output",
|
|
158
103
|
}
|
|
159
104
|
```
|
|
160
105
|
|
|
161
|
-
|
|
106
|
+
| Event | `--hook=` |
|
|
107
|
+
| ------------------ | ---------------------- |
|
|
108
|
+
| `UserPromptSubmit` | `sanitize-user-prompt` |
|
|
109
|
+
| `PreToolUse` | `pretooluse-sanitize` |
|
|
110
|
+
| `PostToolUse` | `sanitize-output` |
|
|
111
|
+
| `SessionStart` | `scan-invisible-chars` |
|
|
162
112
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
```bash
|
|
167
|
-
pip install 'agent-sanitizer[secrets]' # version-match the npm package
|
|
168
|
-
```
|
|
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.
|
|
169
115
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
still run, but a missing daemon means every secret-shaped output is withheld.
|
|
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.
|
|
174
119
|
|
|
175
|
-
**Layer 5 (second-model injection filtering) is not included.**
|
|
176
|
-
|
|
177
|
-
|
|
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.
|
|
178
123
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
surface is the `--hook=` CLI and the settings wiring above.
|
|
184
|
-
|
|
185
|
-
| Variable | Effect |
|
|
186
|
-
| -------------------------------------- | ------------------------------------------------------------------ |
|
|
187
|
-
| `_AGENT_SANITIZER_REDACTOR_DAEMON` | Path to the redactor daemon binary |
|
|
188
|
-
| `_AGENT_SANITIZER_REDACTOR_SOCKET` | Unix socket the daemon binds; per-session and private by default |
|
|
189
|
-
| `_AGENT_SANITIZER_REDACTOR_WAIT_MS` | How long to wait for a freshly spawned daemon to accept |
|
|
190
|
-
| `_AGENT_SANITIZER_REDACTOR_REQUEST_MS` | Deadline for one request; bounds a daemon that accepts then stalls |
|
|
191
|
-
| `_AGENT_SANITIZER_SANITIZE_BUDGET_MS` | Total wall-clock budget for one hook run's daemon calls |
|
|
192
|
-
| `_AGENT_SANITIZER_TRACE` | `info` or `debug` to emit one JSON line per layer engagement |
|
|
193
|
-
| `_AGENT_SANITIZER_TRACE_FILE` | Trace sink; stderr when unset |
|
|
194
|
-
| `_AGENT_SANITIZER_REVEAL_DIR` | Where Layer 2 stores pre-splice text for the model to read back |
|
|
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.
|
|
195
128
|
|
|
196
129
|
## How this compares
|
|
197
130
|
|
|
198
|
-
The
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
hidden-HTML payloads live—content a semantic classifier never "sees" as
|
|
204
|
-
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.
|
|
205
136
|
|
|
206
137
|
| | `agent-sanitizer` | Semantic guard/classifier (Lakera, Prompt Guard, Rebuff, NeMo rails) | PII redactor (Presidio) |
|
|
207
138
|
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
|
|
@@ -213,13 +144,10 @@ suspicious because it renders as blank space or doesn't render at all.
|
|
|
213
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 |
|
|
214
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) |
|
|
215
146
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
channel both of those are blind to. If you already run a classifier and are
|
|
219
|
-
still getting bitten by zero-width payloads or `display:none` instructions
|
|
220
|
-
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.
|
|
221
149
|
|
|
222
|
-
|
|
150
|
+
## Examples
|
|
223
151
|
|
|
224
152
|
```js
|
|
225
153
|
import { stripInvisibleWithReport } from "agent-sanitizer/invisible";
|
|
@@ -269,25 +197,21 @@ await rehydrateRedacted("Edit", toolInput, {
|
|
|
269
197
|
}); // { updatedInput, context } | { deny } | null — a deny never exposes a secret
|
|
270
198
|
```
|
|
271
199
|
|
|
272
|
-
The credential-noun vocabulary — the words that make an identifier name a
|
|
273
|
-
is published as data so a consumer with its own matcher derives it
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
`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).
|
|
278
205
|
|
|
279
206
|
```js
|
|
280
207
|
import { createRequire } from "node:module";
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
);
|
|
284
|
-
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"] }, …]
|
|
285
210
|
```
|
|
286
211
|
|
|
287
212
|
```python
|
|
288
213
|
from agent_sanitizer.secrets import credential_name_segments
|
|
289
|
-
|
|
290
|
-
credential_name_segments() # ("API_KEY", "APIKEY", "ACCESS_KEY", …) — rendered for a NAME matcher
|
|
214
|
+
credential_name_segments() # ("API_KEY", "APIKEY", "ACCESS_KEY", …)
|
|
291
215
|
```
|
|
292
216
|
|
|
293
217
|
## Limits
|
|
@@ -306,17 +230,14 @@ layer does and does not defend against.
|
|
|
306
230
|
|
|
307
231
|
## Non-JS pipelines (Python, etc.)
|
|
308
232
|
|
|
309
|
-
The JS is the **single source of truth
|
|
310
|
-
through the bundled CLI, so
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
`
|
|
316
|
-
|
|
317
|
-
filtering (Layer 5), and the wire protocol's `sgrNote` (Python's
|
|
318
|
-
`TextResult.sgr_note`) is always `false`, since the bridge never wires
|
|
319
|
-
`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`.
|
|
320
241
|
|
|
321
242
|
```sh
|
|
322
243
|
echo '{"text":"ab"}' | npx sanitize-cli # default op: sanitize
|
|
@@ -324,16 +245,13 @@ echo '{"op":"classifyPrompt","text":"…"}' | npx sanitize-cli
|
|
|
324
245
|
sanitize-cli --worker # newline-delimited, one response/line
|
|
325
246
|
```
|
|
326
247
|
|
|
327
|
-
The [`python/`](./python) client wraps every bridged op
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
`
|
|
333
|
-
|
|
334
|
-
`html=True` call starts a shared worker, so the ~200 ms HTML module-load is
|
|
335
|
-
paid **once per process**; Layer-1 calls stay one-shot. `persist=True/False`
|
|
336
|
-
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.
|
|
337
255
|
|
|
338
256
|
```python
|
|
339
257
|
from agent_sanitizer import sanitize, Sanitizer
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.1",
|
|
4
4
|
"description": "Defend an agent against hidden-content injection: strip payload-capable invisible Unicode and ANSI, splice out human-invisible HTML, and flag data-exfil URLs in untrusted text before any model sees it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|