agent-sanitizer 2.26.2 → 2.26.3

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
@@ -217,14 +217,34 @@ import {
217
217
  } from "agent-sanitizer/claude-hooks/lib/hook-io";
218
218
  ```
219
219
 
220
- The exported set is deliberately small the four hooks
221
- (`sanitize-output`, `pretooluse-sanitize`, `sanitize-user-prompt`,
222
- `scan-invisible-chars`) plus `lib/hook-io` and `lib/control-plane`. Everything
223
- else under `claude-hooks/` stays internal and is refused by the exports map, so
224
- it never becomes a surface this package owes compatibility on. `lib/hook-io` is
225
- exported because it must be _shared_ rather than copied: it owns the
226
- lazy-module registry and the CLI-slot singleton, and two copies in one bundle
227
- double-fire the inlined CLIs.
220
+ The exported set is **curated, not a wildcard**: exactly the subpaths below and
221
+ nothing else. Anything unlisted is refused by the exports map with
222
+ `ERR_PACKAGE_PATH_NOT_EXPORTED`, so it never becomes a surface this package owes
223
+ compatibility on. `lib/hook-io` in particular is exported because it must be
224
+ _shared_ rather than copied: it owns the lazy-module registry and the CLI-slot
225
+ singleton, and two copies in one bundle double-fire the inlined CLIs.
226
+
227
+ <!-- exports-table: rows are asserted to equal package.json's ./claude-hooks* exports by test/claude-hooks-exports.test.mjs -->
228
+
229
+ | Subpath | What it is |
230
+ | ----------------------------------- | ------------------------------------------------------------------------------------------ |
231
+ | `claude-hooks` | The `--hook=` CLI dispatcher all four hooks are spawned through |
232
+ | `claude-hooks/pretooluse-sanitize` | PreToolUse orchestrator: invisible-char gate, confusable folding, stego strip, rehydration |
233
+ | `claude-hooks/sanitize-output` | PostToolUse pipeline: Layers 1–4 over tool output, plus the host-extension bag |
234
+ | `claude-hooks/sanitize-user-prompt` | UserPromptSubmit verdict on payload-capable invisible/ANSI content |
235
+ | `claude-hooks/scan-invisible-chars` | SessionStart scan of `CLAUDE.md` / `.claude/` markdown |
236
+ | `claude-hooks/lib/hook-io` | Shared hook I/O: the lazy-module registry, the CLI slot, deadlines, the hookgate marker |
237
+ | `claude-hooks/lib/control-plane` | Bridge to `agent-control-plane-core` and the shared judge-CLI transport |
238
+ | `claude-hooks/lib/authored-content` | Stego + terminal-control stripping of the fields the MODEL authors |
239
+ | `claude-hooks/lib/env-config` | The env-bound secret vocabulary the Layer-4 pre-gate and the redactor client share |
240
+ | `claude-hooks/lib/invisible-alert` | Cross-hook alert state for uncleanable invisible-char injection in instruction files |
241
+ | `claude-hooks/lib/redactor-client` | Client for the long-lived `agent-secret-redactor-daemon` (Layer 4's transport) |
242
+ | `claude-hooks/lib/reveal` | The Layer-2 sidecar that lets the model re-read what the HTML splice removed |
243
+ | `claude-hooks/lib/secret-annotate` | The cheap deterministic Layer-4 pre-gate checks around the daemon call |
244
+ | `claude-hooks/lib/trace` | The opt-in structured trace channel every layer announces itself on |
245
+
246
+ Only `plugin-hooks` itself is unexported under its own name — it is reachable as
247
+ the bare `claude-hooks` entry above.
228
248
 
229
249
  Importing one runs no CLI and reads no stdin. Same stability posture as the
230
250
  `_AGENT_SANITIZER_*` variables below: reachable and typed, but the supported
package/THREAT-MODEL.md CHANGED
@@ -135,6 +135,74 @@ the model chooses to follow it — and the sentence reporting it is precisely th
135
135
  instruction not to. A target whose kind cannot be resolved is treated as
136
136
  auto-fetched (fail closed).
137
137
 
138
+ ## Layer 4—secret redaction (injected engine)
139
+
140
+ The threat is the reverse of the other layers: not attacker text reaching the
141
+ model, but a credential in tool output (a `.env` cat, a failing curl, a CI log)
142
+ reaching a model that will paste it into the next tool call, a commit, or a
143
+ bug report.
144
+
145
+ **The package bundles no detector.** Layer 4 is an injected
146
+ `redact(text) => {text, found, note?} | null` callback on `./output`, so the npm
147
+ package ships no secret engine and a host may supply its own. The Claude Code
148
+ plugin injects the Python engine from `agent-sanitizer[secrets]`
149
+ (`python/agent_sanitizer/secrets/engine.py`) over a local daemon;
150
+ `plugin/scripts/provision-redactor.sh` installs it at SessionStart.
151
+
152
+ **It is the one fail-closed layer.** A redactor that throws — unreachable
153
+ daemon, engine error — is rethrown from `sanitizeValue` as `CRITICAL: secret
154
+ redaction failed …` (`src/output.mjs`), so the caller suppresses the output
155
+ rather than emit a value nothing vetted. That is distinct from an engine that
156
+ was never provisioned: there the hooks' posture applies, passing the output
157
+ through with a loud warning by default and suppressing it under
158
+ `AGENT_SANITIZER_FAIL_OPEN=0`. Either way the gap is announced, never silent.
159
+
160
+ **Lone surrogates are normalized to U+FFFD before the redactor sees the text.**
161
+ A secret split by an interposed lone surrogate renders as contiguous to the
162
+ model but arrives broken at the redactor, so without normalization a
163
+ reconstituted secret survives redaction. Both redact-input paths share one
164
+ normalizer so they cannot drift.
165
+
166
+ **Detection.** detect-secrets is the single oracle — its bundled plugins plus
167
+ gitleaks-sourced ones for formats it lacks — extended with a regex
168
+ for the unquoted `key=value` shapes `KeywordDetector` misses, PEM block
169
+ collapse, cross-line reassembly of a secret split across lines, and exact-match
170
+ redaction of caller-supplied env-var **values**. Each hit becomes
171
+ `[REDACTED: <label>]` and its label joins `found`.
172
+
173
+ **The engine discovers nothing about its environment.** Every
174
+ environment-specific input — which env-var values to redact, the invisible
175
+ charset, whether the text is web ingress — arrives through `RedactorConfig`; the
176
+ engine never reads `os.environ`. Passing values rather than names is
177
+ load-bearing for the daemon, which serves many sessions and must redact the
178
+ _requester's_ keys. The invisible charset is sourced from the same SSOT Layer 1
179
+ uses (and raises if that dependency is missing rather than silently using a
180
+ partial set): a key spliced with a code point one layer omits would otherwise
181
+ escape both.
182
+
183
+ **Precision over recall, deliberately.** Redacting a UUID, a content digest, a
184
+ timestamp, a version, a filesystem path, a public endpoint URL, a `$VAR`
185
+ reference, a documentation placeholder or a markdown code span would delete text
186
+ the model needed, so each is filtered out before redaction, and an env value
187
+ shorter than `min_secret_len` (16) is treated as a test stub rather than a key.
188
+ Two config switches move the trade-off where the context justifies it:
189
+ `web_ingress` disables the name-based benign skips for attacker-controlled text,
190
+ and `high_confidence` drops the fuzzy keyword/field-value detectors for source
191
+ scans, where secret-shaped names appear legitimately.
192
+
193
+ **Redaction stays reversible for editing, never for the model.**
194
+ `redact_map` returns placeholder ↔ original pairs with offsets, which
195
+ `./rehydrate` uses to re-anchor a model `Edit` composed from the redacted view
196
+ back onto the real bytes. If the input already contains the private-use
197
+ sentinels the map machinery reserves, it returns `{"unmappable": …}` rather than
198
+ risk mis-pairing a placeholder with the wrong secret.
199
+
200
+ **Ordering.** Layer 4 runs after Layers 1–2 have removed bytes, and is re-run on
201
+ the post-deletion text whenever Layer 5 deletes a span — a deletion can
202
+ reconstitute a secret the first pass never saw intact. On `Bash.command` it runs
203
+ before `sanitizeAuthoredContent`, which is the assumption the confusable-folding
204
+ soundness argument below relies on.
205
+
138
206
  ## Confusable folding (tool input)
139
207
 
140
208
  `./confusables` folds look-alike glyphs in tool-call **input** fields (paths,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.26.2",
3
+ "version": "2.26.3",
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": {