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.
Files changed (2) hide show
  1. package/README.md +66 -148
  2. 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. 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
  | --------------------- | --------------------------------------------------------------------------------------- |
@@ -88,120 +82,57 @@ like `found`:
88
82
 
89
83
  ## Using it with Claude Code
90
84
 
91
- The entry points above are a library you supply the wiring. For Claude Code
92
- the wiring is already written, as four hooks that put Layers 1–4 on the tool
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 ships a self-contained bundle and a committed Python zipapp of the
104
- secret-redaction engine, so it needs no `node_modules` and no install step
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
- Install the package and point your `settings.json` at the published hook entry.
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
- "hooks": {
115
- "UserPromptSubmit": [
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
- Resolve the path however your project prefers — `require.resolve("agent-sanitizer/claude-hooks")` gives it without hardcoding a layout. Importing the module instead of spawning it is a no-op, so a build step that pulls it in will not consume stdin.
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
- **Layer 4 needs the Python engine.** Secret redaction runs out-of-process
164
- against `agent-secret-redactor-daemon`, from the `secrets` extra on PyPI:
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
- Without it, `sanitize-output` **fails closed** on secret-shaped output — the
171
- tool result is suppressed and replaced with a placeholder rather than shown
172
- unvetted. That is the intended posture, not a degradation to ignore: Layers 1–3
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.** The `/output`
176
- seam accepts a `filterInjection` callback, but these hooks never supply one, so
177
- nothing here calls out to a model or leaves the machine.
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
- ### Internal environment variables
180
-
181
- These tune the hooks' internals. They are **not a stable interface** — they
182
- carry a leading underscore and may change between minor versions. The stable
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 "sanitize untrusted LLM input" space mostly splits into two camps: ML
199
- classifiers that score a prompt's _intent_ (Lakera Guard, Meta's Prompt
200
- Guard, Rebuff, NeMo Guardrails' input rails), and PII-focused redactors
201
- (Microsoft Presidio). Neither targets the byte-level hiding channel this
202
- library covers, and that gap is exactly where invisible-Unicode and
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
- In practice these are complementary, not competing: run a semantic guard for
217
- intent, Presidio for PII you must not leak, and this library for the hidden
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
- ### Examples
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 secret —
273
- is published as data so a consumer with its own matcher derives it from one list
274
- instead of forking one. Each noun carries the `uses` it is valid for: `env-name`
275
- for a matcher that inspects a variable NAME only, `field-value` for one that
276
- redacts whatever follows `noun = ` (a broad noun there mangles ordinary text, so
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
- const vocabulary = createRequire(import.meta.url)(
282
- "agent-sanitizer/credential-names",
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**—non-JS callers drive the same verdicts
310
- through the bundled CLI, so there’s
311
- no second implementation to drift. An `op` field selects the entry point
312
- (default `sanitize`); the self-contained ones—`sanitizeText`, `classifyPrompt`,
313
- `scanInstructionFiles`, `cleanFile`—are all bridged. Entry points with an
314
- injected JS callback have no wire form and stay JS-only. The bridged
315
- `sanitizeText` runs Layers 1–3 only (invisible/ANSI strip, HTML hidden-content
316
- splice, exfil detection): it never redacts secrets (Layer 4) or runs injection
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":"a​b"}' | 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 (`sanitize`,
328
- `sanitize_text`, `classify_prompt`, `scan_instruction_files`, `clean_file`). The
329
- wheel ships a self-contained, single-file build of the CLI (`src/` and its npm
330
- dependencies bundled into one `.mjs` at release time), so a `pip install` plus
331
- Node.js (>=22) on `PATH` works with no separate JavaScript checkout to clone.
332
- `AGENT_SANITIZER_CLI` is only an override escape hatch (e.g. to point at a
333
- custom/dev build)—a normal install never needs to set it. The first
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.0",
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": {