agent-sanitizer 2.0.3 → 2.2.0

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
@@ -162,6 +162,27 @@ await rehydrateRedacted("Edit", toolInput, {
162
162
  }); // { updatedInput, context } | { deny } | null — a deny never exposes a secret
163
163
  ```
164
164
 
165
+ The credential-noun vocabulary — the words that make an identifier name a secret —
166
+ is published as data so a consumer with its own matcher derives it from one list
167
+ instead of forking one. Each noun carries the `uses` it is valid for: `env-name`
168
+ for a matcher that inspects a variable NAME only, `field-value` for one that
169
+ redacts whatever follows `noun = ` (a broad noun there mangles ordinary text, so
170
+ `key` and `pat` are name-only).
171
+
172
+ ```js
173
+ import { createRequire } from "node:module";
174
+ const vocabulary = createRequire(import.meta.url)(
175
+ "agent-sanitizer/credential-names",
176
+ );
177
+ vocabulary.nouns; // [{ parts: ["api", "key"], uses: ["env-name", "field-value"] }, …]
178
+ ```
179
+
180
+ ```python
181
+ from agent_sanitizer.secrets import credential_name_segments
182
+
183
+ credential_name_segments() # ("API_KEY", "APIKEY", "ACCESS_KEY", …) — rendered for a NAME matcher
184
+ ```
185
+
165
186
  ## Limits
166
187
 
167
188
  The CLI (and the worker that backs the Python client) rejects any single request
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.0.3",
3
+ "version": "2.2.0",
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": {
@@ -119,10 +119,12 @@
119
119
  "./rehydrate": {
120
120
  "types": "./types/rehydrate.d.mts",
121
121
  "default": "./src/rehydrate.mjs"
122
- }
122
+ },
123
+ "./credential-names": "./python/agent_sanitizer/secrets/data/credential-names.json"
123
124
  },
124
125
  "files": [
125
126
  "src/*.mjs",
127
+ "python/agent_sanitizer/secrets/data/credential-names.json",
126
128
  "bin/sanitize-cli.mjs",
127
129
  "types",
128
130
  "LICENSE",
@@ -0,0 +1,115 @@
1
+ # agent-sanitizer (Python client)
2
+
3
+ A thin Python bridge to the [`agent-sanitizer`](https://github.com/AlexanderMattTurner/agent-sanitizer)
4
+ Node.js CLI. The sanitization logic has a single source of truth — the
5
+ JavaScript in `src/` — so this package shells out to the CLI rather than
6
+ re-implementing it, giving a Python pipeline byte-identical verdicts with no
7
+ second implementation to keep in sync.
8
+
9
+ ## Requirements
10
+
11
+ - **Node.js (>= 22) on `PATH`.** The sanitizer is JavaScript; something has to
12
+ run it. There is deliberately no pure-Python fallback.
13
+
14
+ That's it — `pip install agent-sanitizer` and, with Node available, you're
15
+ ready to go. The wheel ships a self-contained, single-file build of the CLI
16
+ (the `src/` logic and its npm dependencies bundled into one `.mjs` at release
17
+ time), so there is no separate JavaScript checkout to clone and no environment
18
+ variable to set. The bundle is a versioned build artifact from `src/`, not a
19
+ hand-maintained port, so it can't drift from the JS.
20
+
21
+ ### Optional: point at your own JS checkout
22
+
23
+ Set `AGENT_SANITIZER_CLI` to a checkout's `bin/sanitize-cli.mjs` to override the
24
+ bundled CLI (e.g. to run against unreleased `src/` changes). When the module is
25
+ imported directly from a repo checkout, the source CLI is found automatically.
26
+
27
+ ## Usage
28
+
29
+ ```python
30
+ from agent_sanitizer import sanitize
31
+
32
+ result = sanitize("untrusted text", html=True)
33
+ print(result.cleaned, result.found, result.warnings)
34
+ ```
35
+
36
+ See the package docstring for the full set of entry points (`sanitize_text`,
37
+ `classify_prompt`, `scan_instruction_files`, `clean_file`, and the long-lived
38
+ `Sanitizer` worker).
39
+
40
+ ## Secret redaction (`[secrets]` extra)
41
+
42
+ The base install is dependency-free. The optional `secrets` extra adds a
43
+ pure-Python secret-redaction engine under `agent_sanitizer.secrets` —
44
+ detect-secrets plus custom detectors, benign-value skipping, cross-line
45
+ reassembly, PEM collapse, and exact-match redaction of caller-supplied env-var
46
+ values. Unlike the sanitizer above it needs **no Node.js**; its only dependency
47
+ is `detect-secrets`, pulled in by the extra:
48
+
49
+ ```bash
50
+ pip install "agent-sanitizer[secrets]"
51
+ ```
52
+
53
+ Every detect-secrets import lives inside this subpackage, so a plain
54
+ `import agent_sanitizer` never touches it. The engine shares the parent
55
+ package's invisible-character SSOT (`agent_sanitizer.invisible`) rather
56
+ than forking it — a fork would be a silent security regression.
57
+
58
+ ### In-process
59
+
60
+ ```python
61
+ from agent_sanitizer.secrets import RedactorConfig, redact, redact_map
62
+
63
+ redacted, found = redact("aws_key = AKIAIOSFODNN7EXAMPLE")
64
+ # -> ("aws_key = [REDACTED: AWS Access Key]", ["AWS Access Key"])
65
+
66
+ # Pass provider/host secret values in — config is supplied, never discovered.
67
+ cfg = RedactorConfig(host_cred_vars={"GH_TOKEN": "ghp_realtokenvalue123"})
68
+ redact("tok=ghp_realtokenvalue123", cfg) # -> ("tok=[REDACTED: GH_TOKEN]", ["GH_TOKEN"])
69
+ ```
70
+
71
+ ### The rehydration map contract
72
+
73
+ `redact_map(text, config)` returns a lossless, two-way view:
74
+
75
+ ```python
76
+ {"text": "<redacted>",
77
+ "pairs": [{"placeholder": "[REDACTED: …]", "original": "<secret>", "start": <int>}],
78
+ "found": ["<type>", ...]}
79
+ ```
80
+
81
+ Substituting each `pair["original"]` at its `start` reconstructs the input
82
+ byte-for-byte. `start` is a **Unicode code-point** offset into `text` (the field
83
+ is `start`, not `start_offset`). U+E000 / U+E001 are reserved placeholder
84
+ sentinels: input already containing either is refused with
85
+ `{"unmappable": "input contains reserved sentinel characters"}` (fail closed)
86
+ rather than producing an ambiguous map.
87
+
88
+ ### Daemon vs. one-shot
89
+
90
+ detect-secrets caches a process-global `secret_type → plugin` mapping, so a fresh
91
+ one-shot call must re-register the plugin set every time — slow under load. Two
92
+ console scripts (installed with the extra) cover both modes:
93
+
94
+ - **`agent-secret-redactor`** — one-shot: reads text on stdin, writes the
95
+ redaction result as JSON on stdout. Re-registers plugins per call.
96
+ - **`agent-secret-redactor-daemon <socket-path>`** — long-lived Unix-socket
97
+ server that configures the plugin set **once** at startup (a warm-up scan
98
+ primes the cache before it binds, so a bound socket means ready). Wire protocol
99
+ both directions: a 4-byte big-endian length prefix then that many bytes of
100
+ UTF-8 JSON. Request `{"text", "map", "web_ingress", "env_secrets"}`; response is
101
+ the one-shot result, JSON `null` when nothing is redacted, or `{"error": …}` on
102
+ a scan failure.
103
+
104
+ Both console scripts import the engine on startup, so they fail loud if the
105
+ `[secrets]` extra (hence `detect-secrets`) is not installed — fail closed, never
106
+ a silent no-op.
107
+
108
+ ## Versioning
109
+
110
+ This package is versioned in lockstep with the npm
111
+ [`agent-sanitizer`](https://www.npmjs.com/package/agent-sanitizer):
112
+ each release publishes both at the same version from the same commit, and the
113
+ wheel bundles `src/` at exactly that version. So `pip install
114
+ agent-sanitizer==X.Y.Z` and `npm i agent-sanitizer@X.Y.Z` are the
115
+ same underlying logic.
@@ -0,0 +1,29 @@
1
+ {
2
+ "$comment": "The credential-noun vocabulary: the words that make an identifier name a secret. Published so every consumer derives its own matcher from ONE list — a newly recognized noun reaches them all through a version bump instead of N hand edits. Read it from Python via agent_sanitizer.secrets (credential_name_segments / credential_field_name_patterns / non_secret_name_segments) or from JavaScript via the npm subpath export `agent-sanitizer/credential-names`. `parts` are the lowercase words of the noun; a consumer renders them for its own matcher (underscore-joined `API_KEY` and bare-joined `APIKEY` for an env-var NAME, `api[_-]?key` for a `field = value` regex). `uses` says which matcher may use the noun, because the two are not interchangeable: `env-name` matches a variable NAME and never inspects a value, so a broad noun there costs nothing, while `field-value` redacts whatever follows `noun = ` and a broad noun there mangles ordinary text — `key = <20 chars>` is a false-positive flood, so `key`, `pat`, `credential`, `credentials`, `secrets` and `passphrase` are env-name only. `nonSecretSuffixes` are the trailing words that make a credential-shaped name hold a NON-secret (a key's identifier, the public half of a keypair), which a consumer must not redact. Every part is restricted to a-z0-9 so it carries no regex metacharacter; the accessors enforce that and fail closed on a violation, an empty list, or an unknown `uses` value. It sits inside the Python package because a wheel can only ship data under its package directory, while npm's `files`/`exports` can name any path — so ONE physical file backs both ecosystems and there is no copy to drift.",
3
+ "nouns": [
4
+ { "parts": ["api", "key"], "uses": ["env-name", "field-value"] },
5
+ { "parts": ["access", "key"], "uses": ["env-name", "field-value"] },
6
+ { "parts": ["access", "token"], "uses": ["env-name", "field-value"] },
7
+ { "parts": ["secret", "key"], "uses": ["env-name", "field-value"] },
8
+ { "parts": ["client", "secret"], "uses": ["env-name", "field-value"] },
9
+ { "parts": ["private", "key"], "uses": ["env-name", "field-value"] },
10
+ { "parts": ["auth", "token"], "uses": ["env-name", "field-value"] },
11
+ { "parts": ["auth", "key"], "uses": ["env-name", "field-value"] },
12
+ { "parts": ["authorization"], "uses": ["env-name", "field-value"] },
13
+ { "parts": ["password"], "uses": ["env-name", "field-value"] },
14
+ { "parts": ["passwd"], "uses": ["env-name", "field-value"] },
15
+ { "parts": ["passphrase"], "uses": ["env-name"] },
16
+ { "parts": ["bearer"], "uses": ["env-name", "field-value"] },
17
+ { "parts": ["secret"], "uses": ["env-name", "field-value"] },
18
+ { "parts": ["secrets"], "uses": ["env-name"] },
19
+ { "parts": ["credential"], "uses": ["env-name"] },
20
+ { "parts": ["credentials"], "uses": ["env-name"] },
21
+ { "parts": ["token"], "uses": ["env-name", "field-value"] },
22
+ { "parts": ["pat"], "uses": ["env-name"] },
23
+ { "parts": ["key"], "uses": ["env-name"] }
24
+ ],
25
+ "nonSecretSuffixes": [
26
+ ["key", "id"],
27
+ ["public", "key"]
28
+ ]
29
+ }