agent-sanitizer 2.40.0 → 2.40.2

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
@@ -241,6 +241,8 @@ To wire them yourself instead, one entry dispatches every mode on `--hook=`:
241
241
  | `SessionStart` | `scan-invisible-chars` |
242
242
  | `InstructionsLoaded` | `scan-loaded-instructions` |
243
243
 
244
+ **Wire all five.** The instruction-file scan is split across two of them: `SessionStart` covers the files that load at launch, and `InstructionsLoaded` covers every one a subdirectory loads later. A host that wires the first without the second leaves a nested `CLAUDE.md` scanned by nothing, and the one-time PreToolUse coverage notice is the only thing that says so.
245
+
244
246
  `require.resolve("agent-sanitizer/claude-hooks")` gives the path without
245
247
  hardcoding a layout. Importing the module rather than spawning it is a no-op.
246
248
 
package/THREAT-MODEL.md CHANGED
@@ -303,9 +303,12 @@ neutralization is to strip the payload from disk (so no reload re-reads it) and
303
303
  tell the model to treat what it just read as untrusted data. Auto-cleaning is
304
304
  confined to `CLAUDE_PROJECT_DIR` in both — an ancestor file, or one under
305
305
  `~/.claude`, is shared with every other project on the machine, so it is reported
306
- through the cross-hook alert and never rewritten. A Claude Code build that emits
307
- no `InstructionsLoaded` event loses the lazy half entirely; the PreToolUse gate
308
- says so once per session rather than leaving the gap silent.
306
+ through the cross-hook alert and never rewritten. Three things lose the lazy half
307
+ entirely: a host that never wired the `InstructionsLoaded` event to
308
+ `scan-loaded-instructions`, a Claude Code build that does not emit that event,
309
+ and `scan-loaded-instructions` switched off in `AGENT_SANITIZER_DISABLED_HOOKS`.
310
+ Nothing on disk tells them apart, so the PreToolUse gate names all three, once
311
+ per session, rather than leaving the gap silent.
309
312
 
310
313
  That table is a claim about someone else's product, so the event that names a
311
314
  loaded file is also what falsifies it. `contextScopeContradiction` checks every
@@ -673,3 +676,44 @@ is only as durable as the files carrying it.
673
676
  The knob adds no layer and changes no layer's semantics. Ambiguous input still
674
677
  fails open at the detection level (precision over recall), as it always has —
675
678
  that is a separate, and unrelated, sense of the phrase.
679
+
680
+ ## Benchmark (`test/injection-corpus.test.mjs`)
681
+
682
+ The published "invisible prompt injection" corpora fall in two families, and
683
+ only one benchmarks a deterministic transform. Model-susceptibility suites
684
+ (AgentDojo, InjecAgent, Agent Security Bench, LLMail-Inject, Reverse CAPTCHA)
685
+ measure whether a _model_ obeys an injection; scoring against them needs an LLM
686
+ in the loop, and most inject in plaintext — a channel this library does not
687
+ touch. Payload corpora do apply, because ground truth ("this text carries
688
+ operator-hidden content") is checkable with no model.
689
+
690
+ `test/injection-corpus.test.mjs` is that benchmark, built the way the rest of
691
+ this repo pins behavior: a self-authored, append-only corpus. It transcribes
692
+ each documented encoding — the encoders in the file are the cited spec, not a
693
+ vendored dependency — over benign carrier and payload text, and scores the pair
694
+ a sanitizer is actually responsible for: the concealment is **neutralized** and
695
+ the finding is **reported at the right tier**. One vector per named technique:
696
+
697
+ - Unicode tags (garak `goodside.Tag`; Cisco Unicode-tag advisory) → Layer 1.
698
+ - Zero-width binary (Reverse CAPTCHA) → Layer 1.
699
+ - Variation-selector smuggling (sneaky-bits; garak `badchars`) → Layer 1.
700
+ - ANSI hidden text (garak `ansiescape`) → Layer 1.
701
+ - Bidi override (Trojan Source; garak `badchars`) → Layer 1.
702
+ - Markdown-image exfil URL (garak `web_injection.MarkdownImageExfil`) → Layer 3.
703
+ - Homoglyph command (garak `smuggling.HomoglyphObfuscation`) → Layer 4.
704
+
705
+ Two things the corpus fixes that a naive harness gets wrong. It drives Layer 4
706
+ through the wired `namespace-guard` seam, not `sanitize()` — which never reaches
707
+ confusable folding, so a one-entry-point harness reports a false 0% on every
708
+ homoglyph vector. And it pins the _observed_ verdict, not a wished one: a short
709
+ bidi override is stripped and reported (`cf-format`) but is not payload-shaped,
710
+ so `classifyPrompt` passes it — recorded as a measured partial-coverage point
711
+ rather than asserted away.
712
+
713
+ The benign twin is load-bearing. Every public corpus is positives-only, and a
714
+ recall-only score rewards the over-triggering this repo forbids ("flag
715
+ everything" scores 100%). The corpus pairs the vectors with legitimate inputs —
716
+ preserved ZWNJ/ZWJ joiners, emoji sequences, non-Latin prose, long-but-benign
717
+ opaque-token URLs (presigned S3, OAuth callbacks) — asserting **zero** findings,
718
+ so the number that ships is precision alongside recall. `INJECTION_REPORT=1
719
+ node --test test/injection-corpus.test.mjs` prints the roll-up.
@@ -150,10 +150,12 @@ export function recordInstructionsLoaded(sessionId) {
150
150
  * instruction files that load at launch, and everything a subdirectory loads
151
151
  * later is scanned by the event. No scan, and nothing says so.
152
152
  *
153
- * The notice names the OBSERVABLE — no scan ran — and both of its causes, because
154
- * the marker cannot tell a host that never emits the event from an operator who
155
- * switched the hook off in AGENT_SANITIZER_DISABLED_HOOKS, and asserting the
156
- * first would send an operator who chose the second to the wrong fix.
153
+ * The notice names the OBSERVABLE — no scan ran — and all three causes, because
154
+ * the marker cannot tell them apart: a host that never wired the event to
155
+ * scan-loaded-instructions, a Claude Code that does not emit it, and the hook
156
+ * switched off in AGENT_SANITIZER_DISABLED_HOOKS; asserting one sends a reader
157
+ * who is in another to the wrong fix. The wiring cause leads because it is the
158
+ * only one the reader can repair in this session, and nothing else reports it.
157
159
  * @param {string} [sessionId] the harness's session identity, so the answer
158
160
  * belongs to THIS session (see instructionsLoadedFile)
159
161
  * @returns {string | null}
@@ -168,9 +170,11 @@ export function instructionsLoadedGapNotice(sessionId) {
168
170
  "instruction files loaded from SUBDIRECTORIES (a nested CLAUDE.md, a " +
169
171
  "directory-scoped rule) are reaching the model unscanned for hidden " +
170
172
  "Unicode — the session-start scan covers only the files loaded at launch. " +
171
- "Tell the user, and name both causes: a Claude Code that does not emit " +
172
- "the event (upgrading restores the coverage), or scan-loaded-instructions " +
173
- "switched off in AGENT_SANITIZER_DISABLED_HOOKS."
173
+ "Tell the user, and name all three causes: this host never wired the " +
174
+ "InstructionsLoaded event to scan-loaded-instructions (wiring it restores " +
175
+ "the coverage), a Claude Code that does not emit the event (upgrading " +
176
+ "restores it), or scan-loaded-instructions switched off in " +
177
+ "AGENT_SANITIZER_DISABLED_HOOKS."
174
178
  );
175
179
  }
176
180
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.40.0",
3
+ "version": "2.40.2",
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": {
@@ -52,10 +52,12 @@ export function recordInstructionsLoaded(sessionId?: string): void;
52
52
  * instruction files that load at launch, and everything a subdirectory loads
53
53
  * later is scanned by the event. No scan, and nothing says so.
54
54
  *
55
- * The notice names the OBSERVABLE — no scan ran — and both of its causes, because
56
- * the marker cannot tell a host that never emits the event from an operator who
57
- * switched the hook off in AGENT_SANITIZER_DISABLED_HOOKS, and asserting the
58
- * first would send an operator who chose the second to the wrong fix.
55
+ * The notice names the OBSERVABLE — no scan ran — and all three causes, because
56
+ * the marker cannot tell them apart: a host that never wired the event to
57
+ * scan-loaded-instructions, a Claude Code that does not emit it, and the hook
58
+ * switched off in AGENT_SANITIZER_DISABLED_HOOKS; asserting one sends a reader
59
+ * who is in another to the wrong fix. The wiring cause leads because it is the
60
+ * only one the reader can repair in this session, and nothing else reports it.
59
61
  * @param {string} [sessionId] the harness's session identity, so the answer
60
62
  * belongs to THIS session (see instructionsLoadedFile)
61
63
  * @returns {string | null}