agent-sanitizer 2.1.0 → 2.2.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
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.1
|
|
3
|
+
"version": "2.2.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": {
|
|
@@ -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",
|
package/python/README.md
ADDED
|
@@ -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
|
+
}
|