agent-sanitizer 2.26.2 → 2.27.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 +55 -12
- package/THREAT-MODEL.md +116 -1
- package/claude-hooks/lib/env-config.mjs +21 -0
- package/claude-hooks/lib/hook-fault.mjs +6 -2
- package/claude-hooks/pretooluse-sanitize.mjs +111 -6
- package/claude-hooks/sanitize-output.mjs +40 -27
- package/package.json +1 -1
- package/src/rehydrate.mjs +106 -43
- package/src/view-map.mjs +35 -0
- package/types/claude-hooks/lib/env-config.d.mts +10 -0
- package/types/claude-hooks/lib/hook-fault.d.mts +14 -0
- package/types/claude-hooks/pretooluse-sanitize.d.mts +21 -1
- package/types/rehydrate.d.mts +7 -5
- package/types/view-map.d.mts +18 -0
package/README.md
CHANGED
|
@@ -58,7 +58,7 @@ the callback you inject for the agent-specific concern; `—` is a pure transfor
|
|
|
58
58
|
| 5 | `/instructions` | Scan/auto-clean `CLAUDE.md`, `AGENTS.md`, `SKILL.md`, etc., decoding Unicode-tag + zero-width-binary payloads. | `fs` (direct) |
|
|
59
59
|
| 6 | `/prompt` | Classify a prompt pass / note / block on payload-capable invisible/ANSI content (inert escapes get the note). | — |
|
|
60
60
|
| 7 | `/output` | Run Layers 1–4 over structured tool output, preserving shape. The Layer-5 slot takes a delete-only filter. | `redact`, `filterInjection` |
|
|
61
|
-
| 8 | `/rehydrate` | Re-anchor a model Edit composed from the _sanitized_ view back onto real bytes; deny anything ambiguous or secret-exposing.
|
|
61
|
+
| 8 | `/rehydrate` | Re-anchor a model Edit/Write composed from the _sanitized_ view back onto real bytes; gate MultiEdit on a verified view==disk; deny anything ambiguous or secret-exposing. | `io` |
|
|
62
62
|
| — | `/view-map` | Pure offset/text machinery mapping a file's on-disk bytes ↔ the sanitized view (Layer-1 deletions, Layer-4 redactions). No I/O — consumed by `/rehydrate`. | — |
|
|
63
63
|
|
|
64
64
|
See [`THREAT-MODEL.md`](./THREAT-MODEL.md) for per-vector detail.
|
|
@@ -120,6 +120,25 @@ a single ordered pass, so the bytes a filter can remove are exactly the bytes it
|
|
|
120
120
|
spans matched in the input — an earlier deletion can never manufacture a match
|
|
121
121
|
for a later span (overlapping spans resolve first-match-wins).
|
|
122
122
|
|
|
123
|
+
## Secret redaction
|
|
124
|
+
|
|
125
|
+
Secrets in tool output are redacted **locally**, before the model ever sees
|
|
126
|
+
them. The engine is an injected seam — the plugin wires
|
|
127
|
+
[`detect-secrets`](https://github.com/Yelp/detect-secrets), running entirely
|
|
128
|
+
on-machine; the library bundles no engine. The model reads stable `[REDACTED…]`
|
|
129
|
+
placeholders instead of the values. The write path closes the loop: Edits
|
|
130
|
+
composed against the redacted view are re-anchored onto the real bytes, and
|
|
131
|
+
placeholders in new content resolve back to the real secrets — disk → tool
|
|
132
|
+
input only, never into the model's view. Anything ambiguous is denied rather
|
|
133
|
+
than guessed, the redactor's own map is verified against the file before any
|
|
134
|
+
splice, and a write that would persist placeholder text over a real secret
|
|
135
|
+
asks instead of passing through even when the hook's own machinery fails
|
|
136
|
+
mid-session. The whole layer is opt-in — its denies and asks are friction, so it
|
|
137
|
+
engages only when asked for: set `AGENT_SANITIZER_SECRETS_ENABLED=1` in the
|
|
138
|
+
environment Claude Code runs the hooks with; unset, no redactor runs and no
|
|
139
|
+
placeholders exist.
|
|
140
|
+
Per-vector detail in [`THREAT-MODEL.md`](./THREAT-MODEL.md).
|
|
141
|
+
|
|
123
142
|
## What installing entails
|
|
124
143
|
|
|
125
144
|
Installing the plugin puts four hooks on every session, and this is what they
|
|
@@ -136,8 +155,9 @@ buy you:
|
|
|
136
155
|
walk a command past a deny rule.
|
|
137
156
|
4. Tool output has invisible characters and terminal escapes stripped, hidden
|
|
138
157
|
HTML spliced out with a placeholder, and exfil-shaped URLs flagged.
|
|
139
|
-
5.
|
|
140
|
-
|
|
158
|
+
5. With `AGENT_SANITIZER_SECRETS_ENABLED=1` set, secrets in tool output are
|
|
159
|
+
redacted locally by `detect-secrets` — the engine ships with the plugin and
|
|
160
|
+
provisions itself on first run, no further setup from you.
|
|
141
161
|
6. Edits the model composes against the redacted view are re-anchored onto the
|
|
142
162
|
real bytes on disk, and anything ambiguous is denied rather than guessed.
|
|
143
163
|
7. The costs are a few seconds on the first secret-shaped output, ~200 ms on the
|
|
@@ -150,7 +170,10 @@ your session on its own breakage — but it says so, in a warning the model and
|
|
|
150
170
|
the transcript both carry. Set `AGENT_SANITIZER_FAIL_OPEN=0` and the same
|
|
151
171
|
failures block instead: suppressed tool output
|
|
152
172
|
(`[output sanitizer unavailable — original output suppressed]`), blocked
|
|
153
|
-
prompts, permission asks whose reason names the cause.
|
|
173
|
+
prompts, permission asks whose reason names the cause. One carve-out to the
|
|
174
|
+
open default, with secrets enabled: a write-shaped call carrying `[REDACTED…]` placeholder text asks
|
|
175
|
+
instead of passing through when the hook itself is broken, since letting it
|
|
176
|
+
through would overwrite the real secret with the placeholder. Either way, a plugin that
|
|
154
177
|
never loaded at all is invisible — Claude Code reads a crashed hook as "no
|
|
155
178
|
objection" — so confirm with `/plugin` rather than reading a quiet session as a
|
|
156
179
|
working one. Neither posture touches what a sanitizer that RAN decided (see
|
|
@@ -217,14 +240,34 @@ import {
|
|
|
217
240
|
} from "agent-sanitizer/claude-hooks/lib/hook-io";
|
|
218
241
|
```
|
|
219
242
|
|
|
220
|
-
The exported set is
|
|
221
|
-
|
|
222
|
-
`
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
243
|
+
The exported set is **curated, not a wildcard**: exactly the subpaths below and
|
|
244
|
+
nothing else. Anything unlisted is refused by the exports map with
|
|
245
|
+
`ERR_PACKAGE_PATH_NOT_EXPORTED`, so it never becomes a surface this package owes
|
|
246
|
+
compatibility on. `lib/hook-io` in particular is exported because it must be
|
|
247
|
+
_shared_ rather than copied: it owns the lazy-module registry and the CLI-slot
|
|
248
|
+
singleton, and two copies in one bundle double-fire the inlined CLIs.
|
|
249
|
+
|
|
250
|
+
<!-- exports-table: rows are asserted to equal package.json's ./claude-hooks* exports by test/claude-hooks-exports.test.mjs -->
|
|
251
|
+
|
|
252
|
+
| Subpath | What it is |
|
|
253
|
+
| ----------------------------------- | ------------------------------------------------------------------------------------------ |
|
|
254
|
+
| `claude-hooks` | The `--hook=` CLI dispatcher all four hooks are spawned through |
|
|
255
|
+
| `claude-hooks/pretooluse-sanitize` | PreToolUse orchestrator: invisible-char gate, confusable folding, stego strip, rehydration |
|
|
256
|
+
| `claude-hooks/sanitize-output` | PostToolUse pipeline: Layers 1–4 over tool output, plus the host-extension bag |
|
|
257
|
+
| `claude-hooks/sanitize-user-prompt` | UserPromptSubmit verdict on payload-capable invisible/ANSI content |
|
|
258
|
+
| `claude-hooks/scan-invisible-chars` | SessionStart scan of `CLAUDE.md` / `.claude/` markdown |
|
|
259
|
+
| `claude-hooks/lib/hook-io` | Shared hook I/O: the lazy-module registry, the CLI slot, deadlines, the hookgate marker |
|
|
260
|
+
| `claude-hooks/lib/control-plane` | Bridge to `agent-control-plane-core` and the shared judge-CLI transport |
|
|
261
|
+
| `claude-hooks/lib/authored-content` | Stego + terminal-control stripping of the fields the MODEL authors |
|
|
262
|
+
| `claude-hooks/lib/env-config` | The env-bound secret vocabulary the Layer-4 pre-gate and the redactor client share |
|
|
263
|
+
| `claude-hooks/lib/invisible-alert` | Cross-hook alert state for uncleanable invisible-char injection in instruction files |
|
|
264
|
+
| `claude-hooks/lib/redactor-client` | Client for the long-lived `agent-secret-redactor-daemon` (Layer 4's transport) |
|
|
265
|
+
| `claude-hooks/lib/reveal` | The Layer-2 sidecar that lets the model re-read what the HTML splice removed |
|
|
266
|
+
| `claude-hooks/lib/secret-annotate` | The cheap deterministic Layer-4 pre-gate checks around the daemon call |
|
|
267
|
+
| `claude-hooks/lib/trace` | The opt-in structured trace channel every layer announces itself on |
|
|
268
|
+
|
|
269
|
+
Only `plugin-hooks` itself is unexported under its own name — it is reachable as
|
|
270
|
+
the bare `claude-hooks` entry above.
|
|
228
271
|
|
|
229
272
|
Importing one runs no CLI and reads no stdin. Same stability posture as the
|
|
230
273
|
`_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,
|
|
@@ -216,7 +284,14 @@ RECONSTITUTES during stripping is judged as the sequence it becomes.
|
|
|
216
284
|
(a harness that gets a shape-mismatched value silently shows the raw output).
|
|
217
285
|
Layer 4 (secret redaction) is an **injected** redactor and is the one
|
|
218
286
|
fail-closed path: a redactor that throws makes the pipeline rethrow, so the
|
|
219
|
-
caller suppresses the output rather than emit an unvetted value.
|
|
287
|
+
caller suppresses the output rather than emit an unvetted value. In the Claude
|
|
288
|
+
Code hooks the entire secret layer is **opt-in**: `secretsEnabled()`
|
|
289
|
+
(`claude-hooks/lib/env-config.mjs`) reads `AGENT_SANITIZER_SECRETS_ENABLED=1`,
|
|
290
|
+
and every secret-layer guarantee below — Layer-4 redaction, rehydration, the
|
|
291
|
+
placeholder guards, the placeholder-write carve-out, SessionStart engine
|
|
292
|
+
provisioning — is conditional on that knob being set; unset, no redactor is
|
|
293
|
+
spawned and no placeholders enter the model's view, because the layer's denies
|
|
294
|
+
and asks are friction an operator must ask for. Layer 5 is a
|
|
220
295
|
deliberately thin, safe slot: the injected filter returns **verbatim spans to
|
|
221
296
|
delete** (never replacement text), so even a compromised filter can only remove
|
|
222
297
|
legitimate content—it can never inject bytes into the model’s view. That removal
|
|
@@ -266,6 +341,28 @@ are load-bearing and **fail closed**:
|
|
|
266
341
|
edit would re-run redaction on every `Edit` call and risk false denials on a
|
|
267
342
|
legitimate relabel in a large file. The secret flows disk → tool input only;
|
|
268
343
|
the model’s next view is sanitized again.
|
|
344
|
+
- **Never trust the redactor’s map unverified.** The redactor’s map-mode output
|
|
345
|
+
is validated before any splice: every pair’s placeholder must occupy the view
|
|
346
|
+
text at its stated offset, and splicing the originals back must reconstruct
|
|
347
|
+
the file’s cleaned bytes exactly — out-of-range and overlapping pairs are
|
|
348
|
+
caught too. A map that fails either proof is **denied outright** — for every
|
|
349
|
+
tool and hint state, stricter than the honest-unmappable arm — and never
|
|
350
|
+
thrown into the host’s fail-open posture: unlike an engine reporting it
|
|
351
|
+
cannot map, a validated-wrong map is affirmative evidence the engine is wrong
|
|
352
|
+
about where this file’s secrets sit, so neither a splice nor a raw
|
|
353
|
+
pass-through (the R1 hidden-span oracle) can be vetted against it.
|
|
354
|
+
|
|
355
|
+
MultiEdit is gated, not rehydrated. MultiEdit applies its edits sequentially,
|
|
356
|
+
each against the file state the previous edit produced, so the span-exact
|
|
357
|
+
view↔disk mapping done for a single Edit has no sound equivalent. It passes
|
|
358
|
+
through only when the file’s sanitized view provably equals disk AND no edit
|
|
359
|
+
carries a `[REDACTED…]` placeholder; every other case — a view that diverges via
|
|
360
|
+
redacted secrets or stripped invisible characters, an unmappable or defective
|
|
361
|
+
redactor map, or placeholder text aimed at a pristine file (the
|
|
362
|
+
foreign-placeholder rule) — is **denied**, with guidance to re-issue the changes
|
|
363
|
+
as single Edit calls, which _are_ rehydrated. The previous full pass-through was
|
|
364
|
+
both a silent clobber (the placeholder persisted over the secret) and a
|
|
365
|
+
character-extraction oracle.
|
|
269
366
|
|
|
270
367
|
File access and the redactor are injected via `io`; the package performs no I/O
|
|
271
368
|
of its own and bundles no secret engine.
|
|
@@ -371,6 +468,24 @@ every way a hook can fail: the launcher not starting (no `node`, missing or
|
|
|
371
468
|
corrupt bundle), the package never loading, a payload that never parsed, and a
|
|
372
469
|
layer that ran and threw.
|
|
373
470
|
|
|
471
|
+
One carve-out: when the PreToolUse hook itself fails (redactor daemon down,
|
|
472
|
+
package failed to load, a layer threw) and the call is a **write-shaped tool**
|
|
473
|
+
(Write/Edit/MultiEdit/NotebookEdit) whose input carries the `[REDACTED`
|
|
474
|
+
placeholder prefix, the hook **asks** — fail-closed, human in the loop — instead
|
|
475
|
+
of passing through. With the sanitizer down, rehydration cannot run, so the
|
|
476
|
+
placeholder text would be persisted literally over the real secret on disk: a
|
|
477
|
+
destructive clobber, not a missed scan. Holding these calls is safe because a
|
|
478
|
+
placeholder-bearing write is never the benign availability case the open default
|
|
479
|
+
protects — the model can retry once the sanitizer recovers, or ask the user. The
|
|
480
|
+
check is package-free (a literal-string test on the already-parsed payload), so
|
|
481
|
+
it holds even when the failure IS the missing package. Two accepted gaps: a
|
|
482
|
+
launcher-level failure (no `node`, corrupt bundle) never reaches the check — the
|
|
483
|
+
launcher cannot inspect the payload and always warns — and `Bash` is excluded
|
|
484
|
+
even though shell redirection can also persist placeholder text, because command
|
|
485
|
+
strings mention `[REDACTED` benignly far too often for the ask to hold
|
|
486
|
+
precision. All other faults keep the open default, and
|
|
487
|
+
`AGENT_SANITIZER_FAIL_OPEN=0` behavior is unchanged.
|
|
488
|
+
|
|
374
489
|
**The open default is not enforceable against content.** Several of those
|
|
375
490
|
failures are composable by whoever authored the payload — in the output hook
|
|
376
491
|
alone, the key-collision guard (two field names that collapse to one after
|
|
@@ -213,6 +213,27 @@ export function extraSecretVars(env = process.env) {
|
|
|
213
213
|
return tokens;
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
+
// The one switch for the whole secret layer (Layer 4). Secret redaction is
|
|
217
|
+
// OPT-IN: it spawns a Python daemon, rewrites the model's view of tool output,
|
|
218
|
+
// and gates the write path (rehydration denies, the placeholder-write
|
|
219
|
+
// carve-out) — machinery whose false positives cost real work, so it engages
|
|
220
|
+
// only when an operator asked for it. Every secret-layer call site consults
|
|
221
|
+
// THIS predicate; a second reading of the variable is the drift channel that
|
|
222
|
+
// would let one hook redact while another passes placeholders through.
|
|
223
|
+
export const SECRETS_ENABLED_ENV = "AGENT_SANITIZER_SECRETS_ENABLED";
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* True when the operator opted into the secret-redaction layer. `=== "1"`
|
|
227
|
+
* matches the other public knobs (`AGENT_SANITIZER_*_DISABLED`): any other
|
|
228
|
+
* value — unset, "true", "yes" — keeps the layer off, so a typo can only fail
|
|
229
|
+
* toward the default (no secret machinery), never silently enable it.
|
|
230
|
+
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
|
|
231
|
+
* @returns {boolean}
|
|
232
|
+
*/
|
|
233
|
+
export function secretsEnabled(env = process.env) {
|
|
234
|
+
return env[SECRETS_ENABLED_ENV] === "1";
|
|
235
|
+
}
|
|
236
|
+
|
|
216
237
|
/**
|
|
217
238
|
* The env-bound redaction set: the UNION of the inference keys, the curated host
|
|
218
239
|
* credentials, any credential-shaped var present in the environment, the
|
|
@@ -145,11 +145,15 @@ export function faultPolicy(hook) {
|
|
|
145
145
|
* recording that the guarded content passed through unsanitized. Non-empty
|
|
146
146
|
* matters — an empty stdout is recorded by Claude Code as a CLEAN run rather
|
|
147
147
|
* than a degraded one, so the posture would give up visibility as well as
|
|
148
|
-
* enforcement.
|
|
148
|
+
* enforcement. Exported so a policy that declares its own `open` arm for a
|
|
149
|
+
* carve-out can still route its non-carve-out path through THIS rendering
|
|
150
|
+
* instead of restating it — a hand-copied body would silently drift the day
|
|
151
|
+
* this one changes, which is the per-hook re-derivation this module exists to
|
|
152
|
+
* end.
|
|
149
153
|
* @param {FaultContext} ctx
|
|
150
154
|
* @returns {FaultParts}
|
|
151
155
|
*/
|
|
152
|
-
function defaultOpen(ctx) {
|
|
156
|
+
export function defaultOpen(ctx) {
|
|
153
157
|
return { fields: { additionalContext: ctx.openContext } };
|
|
154
158
|
}
|
|
155
159
|
|
|
@@ -39,7 +39,11 @@ import {
|
|
|
39
39
|
HookEvent,
|
|
40
40
|
PermissionDecision,
|
|
41
41
|
} from "./lib/hook-io.mjs";
|
|
42
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
registerFaultPolicy,
|
|
44
|
+
hookFaultOutcome,
|
|
45
|
+
defaultOpen,
|
|
46
|
+
} from "./lib/hook-fault.mjs";
|
|
43
47
|
import { runLayerPipeline } from "./lib/layer-pipeline.mjs";
|
|
44
48
|
import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
|
|
45
49
|
import {
|
|
@@ -54,6 +58,7 @@ import {
|
|
|
54
58
|
authoredContext,
|
|
55
59
|
} from "./lib/authored-content.mjs";
|
|
56
60
|
import { redactViaDaemon } from "./lib/redactor-client.mjs";
|
|
61
|
+
import { secretsEnabled } from "./lib/env-config.mjs";
|
|
57
62
|
import { withSecretDropGuard } from "./lib/secret-drop-guard.mjs";
|
|
58
63
|
import { placeholderNotice } from "./lib/placeholder-grammar.mjs";
|
|
59
64
|
import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
|
|
@@ -147,11 +152,24 @@ const redactorIo = {
|
|
|
147
152
|
* (not an inline default-param arrow) so tests can still inject a fake as the
|
|
148
153
|
* second argument to buildPreToolUseResponse.
|
|
149
154
|
*/
|
|
150
|
-
const
|
|
155
|
+
const guardedRehydrate = withSecretDropGuard(
|
|
151
156
|
(tool, toolInput) => rehydrateRedacted(tool, toolInput, redactorIo),
|
|
152
157
|
redactorIo,
|
|
153
158
|
);
|
|
154
159
|
|
|
160
|
+
/**
|
|
161
|
+
* The wired default gates the whole rehydration layer on the secret opt-in:
|
|
162
|
+
* with secrets off the output hook never inserts placeholders, so there is
|
|
163
|
+
* nothing to re-anchor — and skipping here (rather than inside the layer)
|
|
164
|
+
* means an Edit on a plain file never touches the file system twice or spawns
|
|
165
|
+
* the daemon. Consulted per call, not at module load, so a knob set after the
|
|
166
|
+
* bundle loads still governs the next tool call.
|
|
167
|
+
* @param {string} tool
|
|
168
|
+
* @param {any} toolInput
|
|
169
|
+
*/
|
|
170
|
+
const defaultRehydrate = async (tool, toolInput) =>
|
|
171
|
+
secretsEnabled() ? guardedRehydrate(tool, toolInput) : null;
|
|
172
|
+
|
|
155
173
|
/**
|
|
156
174
|
* Trace the response on the way out — "noop" (clean pass-through), "deny",
|
|
157
175
|
* "ask", or "modified" (input rewritten and/or context attached) — and return
|
|
@@ -312,7 +330,9 @@ export async function buildPreToolUseResponse(
|
|
|
312
330
|
// cannot tell a write from a read, so it is context-only — never a verdict
|
|
313
331
|
// (see placeholderNotice). Evaluated on the pipeline's FINAL input, matching
|
|
314
332
|
// what the tool will actually receive.
|
|
315
|
-
|
|
333
|
+
// Gated on the secret opt-in like the layer itself: with secrets off,
|
|
334
|
+
// placeholder-shaped text is ordinary prose and the advisory is noise.
|
|
335
|
+
const notice = secretsEnabled() ? placeholderNotice(tool, current) : null;
|
|
316
336
|
if (notice !== null) contexts.push(notice);
|
|
317
337
|
|
|
318
338
|
return emitTraced(
|
|
@@ -496,12 +516,60 @@ export function failClosedFields(parsedOk, err, opts = {}) {
|
|
|
496
516
|
};
|
|
497
517
|
}
|
|
498
518
|
|
|
519
|
+
// The redaction-placeholder prefix, restated as a literal rather than imported:
|
|
520
|
+
// this check belongs to the FAILURE posture and must work exactly when the
|
|
521
|
+
// agent-sanitizer package (which exports it as DEFAULT_HINT) failed to load.
|
|
522
|
+
// Exported so test/claude-hooks-fail-open.test.mjs can pin the two spellings
|
|
523
|
+
// together with exact equality — a prefix-only behavioral check would keep
|
|
524
|
+
// passing if this literal drifted shorter and the carve-out over-triggered.
|
|
525
|
+
export const REDACTION_HINT = "[REDACTED";
|
|
526
|
+
// The file-editing tools whose input fields ARE the bytes persisted to disk.
|
|
527
|
+
// Deliberately NOT exhaustive over every clobber path: Bash can also write a
|
|
528
|
+
// placeholder to disk (`>`, `tee`, `sed -i`, a heredoc), but command strings
|
|
529
|
+
// mention "[REDACTED" benignly far too often — grepping for it, discussing
|
|
530
|
+
// it — for an ask to hold precision there. That is an accepted gap, named in
|
|
531
|
+
// THREAT-MODEL.md's carve-out paragraph, not a completeness claim.
|
|
532
|
+
const WRITE_SHAPED_TOOLS = new Set([
|
|
533
|
+
"Write",
|
|
534
|
+
"Edit",
|
|
535
|
+
"MultiEdit",
|
|
536
|
+
"NotebookEdit",
|
|
537
|
+
]);
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* True when a faulting PreToolUse call is the one case the OPEN posture must
|
|
541
|
+
* still not pass: a write-shaped tool whose input carries the
|
|
542
|
+
* redaction-placeholder prefix. Such a placeholder stands for a secret the
|
|
543
|
+
* sanitizer redacted out of the model's view; with the sanitizer down,
|
|
544
|
+
* rehydration cannot translate it back, so letting the call through would
|
|
545
|
+
* persist the literal placeholder text over the real secret on disk — a
|
|
546
|
+
* destructive clobber, not a missed scan. Package-free by construction (a Set
|
|
547
|
+
* lookup and a substring test on the already-parsed payload), so it holds when
|
|
548
|
+
* the failure IS the missing package.
|
|
549
|
+
* @param {unknown} input raw parsed PreToolUse payload (undefined if unparsed)
|
|
550
|
+
* @returns {boolean}
|
|
551
|
+
*/
|
|
552
|
+
export function hintedWriteFault(input) {
|
|
553
|
+
const payload =
|
|
554
|
+
/** @type {{tool_name?: unknown, tool_input?: unknown} | undefined} */ (
|
|
555
|
+
input
|
|
556
|
+
);
|
|
557
|
+
if (!WRITE_SHAPED_TOOLS.has(/** @type {string} */ (payload?.tool_name)))
|
|
558
|
+
return false;
|
|
559
|
+
// A parsed-JSON payload can hold no circular reference, so stringify cannot
|
|
560
|
+
// throw; `?? null` keeps a missing tool_input from stringifying to undefined.
|
|
561
|
+
return JSON.stringify(payload?.tool_input ?? null).includes(REDACTION_HINT);
|
|
562
|
+
}
|
|
563
|
+
|
|
499
564
|
/**
|
|
500
565
|
* The hookSpecificOutput fields for a hook-level failure under the CALLER's
|
|
501
566
|
* chosen posture: fail-OPEN by default — a warning context and no
|
|
502
567
|
* permissionDecision, so the tool call proceeds unsanitized — or the
|
|
503
568
|
* fail-CLOSED verdict of {@link failClosedFields} when the caller set
|
|
504
|
-
* AGENT_SANITIZER_FAIL_OPEN=0.
|
|
569
|
+
* AGENT_SANITIZER_FAIL_OPEN=0. The open default has ONE carve-out, declared in
|
|
570
|
+
* this hook's fault policy below: a write-shaped call whose input carries a
|
|
571
|
+
* `[REDACTED…]` placeholder asks instead of passing (see
|
|
572
|
+
* {@link hintedWriteFault}) — pass `input` so the policy can see it.
|
|
505
573
|
*
|
|
506
574
|
* The posture covers this hook's own failures, whatever their cause. What it
|
|
507
575
|
* does NOT cover is the verdict of a sanitizer that ran: a payload
|
|
@@ -512,6 +580,7 @@ export function failClosedFields(parsedOk, err, opts = {}) {
|
|
|
512
580
|
* messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>,
|
|
513
581
|
* hint?: string,
|
|
514
582
|
* env?: NodeJS.ProcessEnv | Record<string, string | undefined>,
|
|
583
|
+
* input?: unknown,
|
|
515
584
|
* }} [opts]
|
|
516
585
|
* @returns {Record<string, unknown>}
|
|
517
586
|
*/
|
|
@@ -522,16 +591,51 @@ export function hookFailureFields(parsedOk, err, opts = {}) {
|
|
|
522
591
|
env: opts.env,
|
|
523
592
|
messages: opts.messages,
|
|
524
593
|
hint: opts.hint,
|
|
594
|
+
input: opts.input,
|
|
525
595
|
}).fields
|
|
526
596
|
);
|
|
527
597
|
}
|
|
528
598
|
|
|
529
599
|
// This hook's entry in the one posture table (lib/hook-fault.mjs). The OPEN arm
|
|
530
|
-
//
|
|
531
|
-
//
|
|
600
|
+
// keeps the shared default — a warning context and no verdict — with ONE
|
|
601
|
+
// carve-out: a write-shaped input carrying a [REDACTED… placeholder asks
|
|
602
|
+
// instead. The open posture trades enforcement for availability on the
|
|
603
|
+
// sanitizer's own breakage, and that trade is sound for a missed SCAN; a
|
|
604
|
+
// placeholder-bearing write is not a scan but a guaranteed clobber — the
|
|
605
|
+
// placeholder would be persisted literally over the secret it stands for (see
|
|
606
|
+
// hintedWriteFault). The ask keeps a human in the loop exactly as the closed
|
|
607
|
+
// posture's clean-parse arm does.
|
|
532
608
|
registerFaultPolicy(HOOK_NAME, {
|
|
533
609
|
event: HookEvent.PRE_TOOL_USE,
|
|
534
610
|
guarded: "tool input",
|
|
611
|
+
open: (ctx) => {
|
|
612
|
+
// Non-carve-out faults take the SHARED open rendering, not a copy of it —
|
|
613
|
+
// hook-fault.mjs owns that body, and a restated one would silently drift.
|
|
614
|
+
// The carve-out itself rides the secret opt-in: with secrets off no
|
|
615
|
+
// sanitized view ever handed the model a placeholder, so hint-shaped text
|
|
616
|
+
// in a write is literal prose and holding it would be a false positive.
|
|
617
|
+
if (!secretsEnabled(ctx.env) || !hintedWriteFault(ctx.input))
|
|
618
|
+
return defaultOpen(ctx);
|
|
619
|
+
// parsedOk is hardcoded true (the ASK arm): hintedWriteFault(undefined)
|
|
620
|
+
// is false, so an unparsed input can never reach this line — reaching it
|
|
621
|
+
// proves the payload parsed.
|
|
622
|
+
const closed = failClosedFields(true, ctx.err, {
|
|
623
|
+
messages: ctx.messages,
|
|
624
|
+
hint: ctx.hint,
|
|
625
|
+
});
|
|
626
|
+
return {
|
|
627
|
+
fields: {
|
|
628
|
+
...closed,
|
|
629
|
+
permissionDecisionReason:
|
|
630
|
+
`${closed.permissionDecisionReason} This input would write ` +
|
|
631
|
+
`${REDACTION_HINT}…] placeholder text, which stands for a redacted ` +
|
|
632
|
+
`secret the unavailable sanitizer cannot translate back; proceeding ` +
|
|
633
|
+
`would overwrite the real secret with the placeholder, so the call ` +
|
|
634
|
+
`is held even under the fail-open posture. Retry once the sanitizer ` +
|
|
635
|
+
`recovers, or ask the user to make this change.`,
|
|
636
|
+
},
|
|
637
|
+
};
|
|
638
|
+
},
|
|
535
639
|
closed: (ctx) => ({
|
|
536
640
|
fields: failClosedFields(ctx.parsedOk, ctx.err, {
|
|
537
641
|
messages: ctx.messages,
|
|
@@ -580,6 +684,7 @@ export async function cliMain(opts = {}) {
|
|
|
580
684
|
hookFailureFields(input !== undefined, err, {
|
|
581
685
|
messages,
|
|
582
686
|
hint: depLoadHint(err, messages.remedy),
|
|
687
|
+
input,
|
|
583
688
|
}),
|
|
584
689
|
),
|
|
585
690
|
},
|
|
@@ -36,6 +36,7 @@ import { registerFaultPolicy, hookFaultOutcome } from "./lib/hook-fault.mjs";
|
|
|
36
36
|
import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
|
|
37
37
|
import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
|
|
38
38
|
import { hasEnvBoundSecret } from "./lib/secret-annotate.mjs";
|
|
39
|
+
import { secretsEnabled } from "./lib/env-config.mjs";
|
|
39
40
|
import {
|
|
40
41
|
persistReveal,
|
|
41
42
|
isRevealRead,
|
|
@@ -252,32 +253,36 @@ export async function sanitizeText(
|
|
|
252
253
|
exfilScan: webIngress,
|
|
253
254
|
sgrCarveOut: !webIngress,
|
|
254
255
|
deadline,
|
|
255
|
-
// Layer 4 —
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
256
|
+
// Layer 4 — OPT-IN (secretsEnabled): with the knob unset the seam gets no
|
|
257
|
+
// redact callback at all, so plain output never spawns the daemon and no
|
|
258
|
+
// placeholder ever enters the model's view. When it runs, the seam rethrows
|
|
259
|
+
// a redactor throw wrapped, and the CLI applies the caller's posture to it.
|
|
260
|
+
// Surface the failure to the operator's terminal here first: whatever the
|
|
261
|
+
// CLI decides rides in additionalContext, which only the model sees, so a
|
|
262
|
+
// degraded redactor would otherwise be invisible to the human — and under
|
|
263
|
+
// the fail-open default this line is the ONLY signal the human gets.
|
|
264
|
+
redact: !secretsEnabled()
|
|
265
|
+
? undefined
|
|
266
|
+
: async (/** @type {string} */ content) => {
|
|
267
|
+
let secrets;
|
|
268
|
+
try {
|
|
269
|
+
secrets = await redactSecrets(content, webIngress, deadline);
|
|
270
|
+
} catch (l4err) {
|
|
271
|
+
process.stderr.write(
|
|
272
|
+
`sanitize-output: CRITICAL: secret redaction failed (${errMessage(l4err)}). ` +
|
|
273
|
+
"This output was never vetted for secrets. Fix the redactor installation.\n",
|
|
274
|
+
);
|
|
275
|
+
throw l4err;
|
|
276
|
+
}
|
|
277
|
+
if (!secrets) return null;
|
|
278
|
+
// The note is derived from the PRE-redaction text: the caller's reason for
|
|
279
|
+
// annotating (which variable, which provenance) is exactly what redaction
|
|
280
|
+
// is about to remove.
|
|
281
|
+
const note = ext.redactNote?.(content);
|
|
282
|
+
return note
|
|
283
|
+
? { text: secrets.text, found: secrets.found, note }
|
|
284
|
+
: { text: secrets.text, found: secrets.found };
|
|
285
|
+
},
|
|
281
286
|
};
|
|
282
287
|
const seamResult =
|
|
283
288
|
/** @type {{ cleaned: string, warnings: string[], notes?: string[], modified: boolean, sgrNote: boolean, reveal?: string }} */ (
|
|
@@ -776,7 +781,11 @@ export async function evaluateToolOutput(input, ext = {}) {
|
|
|
776
781
|
for (const original of reveals) {
|
|
777
782
|
let stored;
|
|
778
783
|
try {
|
|
779
|
-
|
|
784
|
+
// Same opt-in as the main pass: with secrets off nothing was redacted
|
|
785
|
+
// out of the primary output either, so the sidecar persists verbatim.
|
|
786
|
+
const secrets = secretsEnabled()
|
|
787
|
+
? await redactSecrets(original, true, deadline)
|
|
788
|
+
: null;
|
|
780
789
|
stored = secrets ? secrets.text : original;
|
|
781
790
|
} catch {
|
|
782
791
|
// The pre-splice text carries the spliced comment bodies, so a secret
|
|
@@ -795,7 +804,11 @@ export async function evaluateToolOutput(input, ext = {}) {
|
|
|
795
804
|
// secret surfaces, while grep/Bash output quoting placeholders is routine.
|
|
796
805
|
// Reveal sidecars are excluded — their bytes are redacted BEFORE persisting,
|
|
797
806
|
// so placeholder text there is this sanitizer's own.
|
|
807
|
+
// Gated on the secret opt-in: with the layer off this sanitizer never
|
|
808
|
+
// inserts placeholders, so placeholder-shaped bytes are ordinary text and
|
|
809
|
+
// the warning would be pure noise on every doc ABOUT redaction.
|
|
798
810
|
if (
|
|
811
|
+
secretsEnabled() &&
|
|
799
812
|
input.tool_name === "Read" &&
|
|
800
813
|
!revealRead &&
|
|
801
814
|
containsPlaceholder(toolOutput)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.27.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": {
|
package/src/rehydrate.mjs
CHANGED
|
@@ -65,6 +65,7 @@ import {
|
|
|
65
65
|
makeFileView,
|
|
66
66
|
toUtf16View,
|
|
67
67
|
pairDiskSpans,
|
|
68
|
+
viewMapDefect,
|
|
68
69
|
} from "./view-map.mjs";
|
|
69
70
|
|
|
70
71
|
// Cheap gate: every redaction placeholder the canonical redactor emits starts
|
|
@@ -488,29 +489,32 @@ async function rehydrateWrite(ti, view, io, hint) {
|
|
|
488
489
|
}
|
|
489
490
|
|
|
490
491
|
/**
|
|
491
|
-
* The single MultiEdit refusal: covers
|
|
492
|
+
* The single MultiEdit refusal: covers a sanitized view that diverges
|
|
492
493
|
* from disk (redacted secrets, stripped invisible characters, a lone
|
|
493
|
-
* surrogate)
|
|
494
|
-
*
|
|
495
|
-
*
|
|
494
|
+
* surrogate), a redactor map that is unmappable or failed validation, and
|
|
495
|
+
* edits that carry foreign placeholder text over a pristine file — in every
|
|
496
|
+
* case the sequential edits cannot be re-anchored, so route the model to the
|
|
497
|
+
* per-call verified path.
|
|
496
498
|
* @param {string} filePath
|
|
497
499
|
*/
|
|
498
500
|
function multiEditDeny(filePath) {
|
|
499
501
|
return {
|
|
500
502
|
deny:
|
|
501
503
|
`the sanitized view of ${filePath} differs from its on-disk bytes ` +
|
|
502
|
-
`(redacted secrets
|
|
503
|
-
`[REDACTED…] placeholder text;
|
|
504
|
-
`re-anchored onto the real
|
|
505
|
-
`
|
|
504
|
+
`(redacted secrets, stripped invisible characters, or normalized lone ` +
|
|
505
|
+
`surrogates), or the edits carry [REDACTED…] placeholder text; ` +
|
|
506
|
+
`MultiEdit's sequential edits cannot be re-anchored onto the real ` +
|
|
507
|
+
`bytes. Use single Edit calls — each is rehydrated individually — or ` +
|
|
508
|
+
`ask the user to make this change`,
|
|
506
509
|
};
|
|
507
510
|
}
|
|
508
511
|
|
|
509
512
|
/**
|
|
510
513
|
* True when this tool call could need re-anchoring against the target file's
|
|
511
514
|
* sanitized view: any well-formed Edit (the view may differ from disk even
|
|
512
|
-
* without placeholders, via stripped invisible characters),
|
|
513
|
-
* content carries a
|
|
515
|
+
* without placeholders, via stripped invisible characters), any well-formed
|
|
516
|
+
* MultiEdit (gated on the same grounds), or a Write whose content carries a
|
|
517
|
+
* placeholder.
|
|
514
518
|
* @param {string} tool
|
|
515
519
|
* @param {any} ti
|
|
516
520
|
* @param {string} hint
|
|
@@ -521,8 +525,6 @@ function isCandidate(tool, ti, hint) {
|
|
|
521
525
|
return (
|
|
522
526
|
typeof ti.old_string === "string" && typeof ti.new_string === "string"
|
|
523
527
|
);
|
|
524
|
-
if (tool === "Write")
|
|
525
|
-
return typeof ti.content === "string" && ti.content.includes(hint);
|
|
526
528
|
// MultiEdit applies its edits SEQUENTIALLY, each against the result of the
|
|
527
529
|
// previous, so the span machinery below (which maps one old_string against
|
|
528
530
|
// one static view) cannot re-anchor it. It is still a candidate: on a
|
|
@@ -541,16 +543,20 @@ function isCandidate(tool, ti, hint) {
|
|
|
541
543
|
typeof edit?.new_string === "string",
|
|
542
544
|
)
|
|
543
545
|
);
|
|
546
|
+
if (tool === "Write")
|
|
547
|
+
return typeof ti.content === "string" && ti.content.includes(hint);
|
|
544
548
|
return false;
|
|
545
549
|
}
|
|
546
550
|
|
|
547
551
|
/**
|
|
548
552
|
* Re-anchor an Edit/Write input composed from a sanitized file view back onto
|
|
549
|
-
* the on-disk bytes (secrets rehydrated, stripped invisible runs re-attached)
|
|
550
|
-
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
553
|
+
* the on-disk bytes (secrets rehydrated, stripped invisible runs re-attached),
|
|
554
|
+
* and gate MultiEdit (pass-through only on a verified view==disk with no
|
|
555
|
+
* placeholder in any edit; denied otherwise — see the module doc). Returns the
|
|
556
|
+
* rewritten input plus a model-facing context line, a deny with an instructive
|
|
557
|
+
* reason when the input is unresolvable or would expose a secret, or null when
|
|
558
|
+
* there is nothing to do. Throws only on internal error (the caller fails
|
|
559
|
+
* closed).
|
|
554
560
|
*
|
|
555
561
|
* `io` is the injected I/O (file read + redactor map/plain). `hint` is the
|
|
556
562
|
* redaction-placeholder prefix (defaults to {@link DEFAULT_HINT}); override it
|
|
@@ -599,21 +605,25 @@ export async function rehydrateRedacted(
|
|
|
599
605
|
// promises Node-shaped read failures (a real `readFile`'s throw), so
|
|
600
606
|
// narrow once here rather than re-deriving the cast at every use below.
|
|
601
607
|
const nodeErr = /** @type {NodeJS.ErrnoException} */ (err);
|
|
602
|
-
// ENOENT (missing target):
|
|
603
|
-
// re-anchor), so pass through
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
//
|
|
612
|
-
//
|
|
613
|
-
//
|
|
614
|
-
// text from), so it passes through like an Edit.
|
|
608
|
+
// ENOENT (missing target): a call that cannot CREATE the file fails on
|
|
609
|
+
// its own (nothing to re-anchor), so pass through — a hint-free call, an
|
|
610
|
+
// Edit whose old_string is non-empty, or a MultiEdit whose FIRST edit's
|
|
611
|
+
// old_string is non-empty (only an empty first old_string is the create
|
|
612
|
+
// form; anything else errors not-found in the real tool). But any call
|
|
613
|
+
// that WOULD create the file with hinted content — a Write (always
|
|
614
|
+
// hinted; isCandidate requires the prefix), a hinted Edit-create, or a
|
|
615
|
+
// hinted MultiEdit-create — persists its placeholder verbatim, standing
|
|
616
|
+
// for a secret that does NOT exist on this new path. R4: that is the same
|
|
617
|
+
// cross-file/stale-placeholder mistake a same-file Write is denied for;
|
|
618
|
+
// refuse with the same guidance rather than write the placeholder text as
|
|
619
|
+
// a real value.
|
|
615
620
|
if (nodeErr?.code === "ENOENT") {
|
|
616
|
-
|
|
621
|
+
const creates =
|
|
622
|
+
tool === "Write" ||
|
|
623
|
+
(tool === "Edit"
|
|
624
|
+
? toolInput.old_string === ""
|
|
625
|
+
: toolInput.edits[0].old_string === "");
|
|
626
|
+
if (!hinted || !creates) return null;
|
|
617
627
|
return {
|
|
618
628
|
deny:
|
|
619
629
|
`${toolInput.file_path} does not exist, so the ${hint}…] placeholder in the ` +
|
|
@@ -680,9 +690,48 @@ export async function rehydrateRedacted(
|
|
|
680
690
|
// already-converted object and get converted twice, so the same input would
|
|
681
691
|
// yield a different verdict on the second call. The space brand is what makes
|
|
682
692
|
// that second conversion throw rather than silently shift; see toUtf16View.
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
693
|
+
//
|
|
694
|
+
// The map is TRUSTED by every splice below, and it came from the injected
|
|
695
|
+
// redactor — the one component with a real defect rate. A wrong map
|
|
696
|
+
// (mis-ordered or out-of-range pairs, a placeholder not at its stated
|
|
697
|
+
// offset, originals that do not splice back to the file's bytes) would
|
|
698
|
+
// anchor an edit onto the WRONG disk bytes and corrupt the file. Verify it
|
|
699
|
+
// before acting: construction re-checks range/ordering (the throws in
|
|
700
|
+
// assertPairsOrdered and pairsToUtf16), viewMapDefect proves the view
|
|
701
|
+
// reconstructs `cleaned` exactly. A defective map DENIES for every tool and
|
|
702
|
+
// hint state, and is deliberately NOT allowed to throw out of this module: a
|
|
703
|
+
// throw lands in the host's failure posture, whose shipped default is fail
|
|
704
|
+
// OPEN, i.e. the unsanitized placeholder write this module exists to prevent.
|
|
705
|
+
let view = null;
|
|
706
|
+
let mapDefect = null;
|
|
707
|
+
try {
|
|
708
|
+
view = toUtf16View(makeFileView(mapped.text, mapped.pairs, "codePoint"));
|
|
709
|
+
} catch (err) {
|
|
710
|
+
mapDefect = `the redactor's map violates its contract (${/** @type {Error} */ (err).message})`;
|
|
711
|
+
}
|
|
712
|
+
if (view !== null) {
|
|
713
|
+
const defect = viewMapDefect(cleaned, view);
|
|
714
|
+
if (defect !== null)
|
|
715
|
+
mapDefect = `the redactor's map is inconsistent with the file's bytes (${defect})`;
|
|
716
|
+
}
|
|
717
|
+
if (view === null || mapDefect !== null) {
|
|
718
|
+
// STRICTER than the unmappable arm above, on purpose: unmappable is an
|
|
719
|
+
// engine honestly reporting it cannot map, so an unhinted Edit keeps its
|
|
720
|
+
// resolver-free pass-through; a map that FAILED VALIDATION is affirmative
|
|
721
|
+
// evidence the engine is wrong about where this file's secrets sit.
|
|
722
|
+
// Reaching this line at all means the fast pass-through did not fire — the
|
|
723
|
+
// file provably holds redacted content or diverges from its view — so an
|
|
724
|
+
// unhinted pass-through here would hand the real Edit bytes inside spans
|
|
725
|
+
// nothing can vet (the R1 hidden-span oracle). Deny everything until the
|
|
726
|
+
// redactor is fixed.
|
|
727
|
+
if (tool === "MultiEdit") return multiEditDeny(toolInput.file_path);
|
|
728
|
+
return {
|
|
729
|
+
deny:
|
|
730
|
+
`cannot safely edit ${toolInput.file_path}: ${mapDefect}; the file holds ` +
|
|
731
|
+
`redacted content whose location cannot be trusted, so no edit can be ` +
|
|
732
|
+
`verified. Retry later, or ask the user to make this change`,
|
|
733
|
+
};
|
|
734
|
+
}
|
|
686
735
|
// View identical to disk: any placeholders in an Edit's old_string are
|
|
687
736
|
// literal text, so there is nothing to re-anchor. `cleaned === content` also
|
|
688
737
|
// rules out a lone-surrogate-only divergence (view.pairs/deletions alone
|
|
@@ -694,19 +743,33 @@ export async function rehydrateRedacted(
|
|
|
694
743
|
// rehydrateWrite's cross-file deny; a MultiEdit to the MultiEdit deny below
|
|
695
744
|
// — without this a hinted MultiEdit on a pristine file silently persists
|
|
696
745
|
// the foreign placeholder the byte-identical Write is denied for.
|
|
697
|
-
|
|
698
|
-
view.pairs.length === 0 &&
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
746
|
+
const viewEqualsDisk =
|
|
747
|
+
view.pairs.length === 0 && deletions.length === 0 && cleaned === content;
|
|
748
|
+
// Precision refinement for a hinted MultiEdit on that PRISTINE file: a
|
|
749
|
+
// hint token that already exists verbatim in the file is LITERAL prose
|
|
750
|
+
// (this repo's own docs carry "[REDACTED…" text), not a placeholder — the
|
|
751
|
+
// same whitelist foreignPlaceholders applies for a Write. Only new_string
|
|
752
|
+
// needs vetting: it is the sole field that persists bytes, while an
|
|
753
|
+
// old_string that is not literal file text simply fails the real tool's
|
|
754
|
+
// exact-match and persists nothing. The file holds no secrets here (the
|
|
755
|
+
// verified-empty map above proves it), so a fully-literal MultiEdit cannot
|
|
756
|
+
// clobber one; pass it through instead of false-denying documentation
|
|
757
|
+
// edits.
|
|
758
|
+
const multiEditLiteralHint =
|
|
759
|
+
tool === "MultiEdit" &&
|
|
760
|
+
viewEqualsDisk &&
|
|
761
|
+
toolInput.edits.every(
|
|
762
|
+
(/** @type {{new_string: string}} */ edit) =>
|
|
763
|
+
foreignPlaceholders(edit.new_string, hint, view.text, []).length === 0,
|
|
764
|
+
);
|
|
765
|
+
if (viewEqualsDisk && (tool === "Edit" || !hinted || multiEditLiteralHint))
|
|
703
766
|
return null;
|
|
704
767
|
|
|
705
768
|
// MultiEdit reaches here when the view diverges from disk (redacted
|
|
706
769
|
// secrets, stripped runs, a lone surrogate) or when its edits carry
|
|
707
|
-
// placeholder text: its sequential edits cannot be re-anchored
|
|
708
|
-
// against a static view, so fail closed with the escape hatch
|
|
709
|
-
// the fully-verified path.
|
|
770
|
+
// foreign placeholder text: its sequential edits cannot be re-anchored
|
|
771
|
+
// one-by-one against a static view, so fail closed with the escape hatch
|
|
772
|
+
// that lands in the fully-verified path.
|
|
710
773
|
if (tool === "MultiEdit") return multiEditDeny(toolInput.file_path);
|
|
711
774
|
return tool === "Edit"
|
|
712
775
|
? rehydrateEdit(
|
package/src/view-map.mjs
CHANGED
|
@@ -491,6 +491,41 @@ export function pairDiskSpans(view, deletions) {
|
|
|
491
491
|
});
|
|
492
492
|
}
|
|
493
493
|
|
|
494
|
+
/**
|
|
495
|
+
* Defect in a redactor map relative to the Layer-1-cleaned text it claims to
|
|
496
|
+
* describe, or null when the map is sound. Two proofs, both required before
|
|
497
|
+
* any splice may trust the map: every pair's placeholder must actually occupy
|
|
498
|
+
* `view.text` at its stated offset, and splicing each pair's original back
|
|
499
|
+
* over its placeholder must reproduce `cleaned` byte-for-byte. The redactor is
|
|
500
|
+
* an INJECTED engine with a real defect rate, and the offsets it emits anchor
|
|
501
|
+
* edits onto disk bytes — a map failing either proof would splice at the wrong
|
|
502
|
+
* position and corrupt the file, so the caller must treat it as unmappable
|
|
503
|
+
* rather than act on it. Defect messages name placeholders and offsets only,
|
|
504
|
+
* never an original (secret) byte.
|
|
505
|
+
* Indexes `view.text` by `pair.start` directly, so it requires the UTF-16
|
|
506
|
+
* carrier — the same one every splice consumes.
|
|
507
|
+
* @param {string} cleaned Layer-1-cleaned file text the map was derived from
|
|
508
|
+
* @param {FileView<"utf16">} view
|
|
509
|
+
* @returns {string | null}
|
|
510
|
+
*/
|
|
511
|
+
export function viewMapDefect(cleaned, view) {
|
|
512
|
+
assertFileView(view, "utf16", "viewMapDefect");
|
|
513
|
+
for (const pair of view.pairs)
|
|
514
|
+
if (!view.text.startsWith(pair.placeholder, pair.start))
|
|
515
|
+
return (
|
|
516
|
+
`the map places placeholder ${JSON.stringify(pair.placeholder)} at view ` +
|
|
517
|
+
`offset ${pair.start}, but the view text there differs`
|
|
518
|
+
);
|
|
519
|
+
const rebuilt = spliceOrdered(
|
|
520
|
+
view.text,
|
|
521
|
+
view.pairs.map((pair) => ({ text: pair.placeholder, index: pair.start })),
|
|
522
|
+
(_match, i) => view.pairs[i].original,
|
|
523
|
+
).text;
|
|
524
|
+
return rebuilt === cleaned
|
|
525
|
+
? null
|
|
526
|
+
: "substituting the mapped secrets back over their placeholders does not reconstruct the file";
|
|
527
|
+
}
|
|
528
|
+
|
|
494
529
|
/**
|
|
495
530
|
* Substitute the placeholders in a model-authored new_string with the secrets
|
|
496
531
|
* they stand for. Resolution, strictest first: if the new placeholder
|
|
@@ -58,6 +58,15 @@ export function dynamicSecretVars(env?: Record<string, string | undefined>): str
|
|
|
58
58
|
* @returns {string[]}
|
|
59
59
|
*/
|
|
60
60
|
export function extraSecretVars(env?: Record<string, string | undefined>): string[];
|
|
61
|
+
/**
|
|
62
|
+
* True when the operator opted into the secret-redaction layer. `=== "1"`
|
|
63
|
+
* matches the other public knobs (`AGENT_SANITIZER_*_DISABLED`): any other
|
|
64
|
+
* value — unset, "true", "yes" — keeps the layer off, so a typo can only fail
|
|
65
|
+
* toward the default (no secret machinery), never silently enable it.
|
|
66
|
+
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
|
|
67
|
+
* @returns {boolean}
|
|
68
|
+
*/
|
|
69
|
+
export function secretsEnabled(env?: NodeJS.ProcessEnv | Record<string, string | undefined>): boolean;
|
|
61
70
|
/**
|
|
62
71
|
* The env-bound redaction set: the UNION of the inference keys, the curated host
|
|
63
72
|
* credentials, any credential-shaped var present in the environment, the
|
|
@@ -69,3 +78,4 @@ export function extraSecretVars(env?: Record<string, string | undefined>): strin
|
|
|
69
78
|
* @returns {string[]}
|
|
70
79
|
*/
|
|
71
80
|
export function envBoundSecretVars(env?: Record<string, string | undefined>): string[];
|
|
81
|
+
export const SECRETS_ENABLED_ENV: "AGENT_SANITIZER_SECRETS_ENABLED";
|
|
@@ -15,6 +15,20 @@ export function registerFaultPolicy(hook: string, policy: FaultPolicy): void;
|
|
|
15
15
|
* @returns {FaultPolicy}
|
|
16
16
|
*/
|
|
17
17
|
export function faultPolicy(hook: string): FaultPolicy;
|
|
18
|
+
/**
|
|
19
|
+
* The default OPEN rendering: no verdict, and a non-empty `additionalContext`
|
|
20
|
+
* recording that the guarded content passed through unsanitized. Non-empty
|
|
21
|
+
* matters — an empty stdout is recorded by Claude Code as a CLEAN run rather
|
|
22
|
+
* than a degraded one, so the posture would give up visibility as well as
|
|
23
|
+
* enforcement. Exported so a policy that declares its own `open` arm for a
|
|
24
|
+
* carve-out can still route its non-carve-out path through THIS rendering
|
|
25
|
+
* instead of restating it — a hand-copied body would silently drift the day
|
|
26
|
+
* this one changes, which is the per-hook re-derivation this module exists to
|
|
27
|
+
* end.
|
|
28
|
+
* @param {FaultContext} ctx
|
|
29
|
+
* @returns {FaultParts}
|
|
30
|
+
*/
|
|
31
|
+
export function defaultOpen(ctx: FaultContext): FaultParts;
|
|
18
32
|
/**
|
|
19
33
|
* Resolve `hook`'s response to its own failure under the caller's posture. This
|
|
20
34
|
* is the ONLY place {@link failOpenEnabled} is consulted on a hook fault, so the
|
|
@@ -88,12 +88,29 @@ export function failClosedFields(parsedOk: boolean, err: unknown, opts?: {
|
|
|
88
88
|
messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>;
|
|
89
89
|
hint?: string;
|
|
90
90
|
}): Record<string, unknown>;
|
|
91
|
+
/**
|
|
92
|
+
* True when a faulting PreToolUse call is the one case the OPEN posture must
|
|
93
|
+
* still not pass: a write-shaped tool whose input carries the
|
|
94
|
+
* redaction-placeholder prefix. Such a placeholder stands for a secret the
|
|
95
|
+
* sanitizer redacted out of the model's view; with the sanitizer down,
|
|
96
|
+
* rehydration cannot translate it back, so letting the call through would
|
|
97
|
+
* persist the literal placeholder text over the real secret on disk — a
|
|
98
|
+
* destructive clobber, not a missed scan. Package-free by construction (a Set
|
|
99
|
+
* lookup and a substring test on the already-parsed payload), so it holds when
|
|
100
|
+
* the failure IS the missing package.
|
|
101
|
+
* @param {unknown} input raw parsed PreToolUse payload (undefined if unparsed)
|
|
102
|
+
* @returns {boolean}
|
|
103
|
+
*/
|
|
104
|
+
export function hintedWriteFault(input: unknown): boolean;
|
|
91
105
|
/**
|
|
92
106
|
* The hookSpecificOutput fields for a hook-level failure under the CALLER's
|
|
93
107
|
* chosen posture: fail-OPEN by default — a warning context and no
|
|
94
108
|
* permissionDecision, so the tool call proceeds unsanitized — or the
|
|
95
109
|
* fail-CLOSED verdict of {@link failClosedFields} when the caller set
|
|
96
|
-
* AGENT_SANITIZER_FAIL_OPEN=0.
|
|
110
|
+
* AGENT_SANITIZER_FAIL_OPEN=0. The open default has ONE carve-out, declared in
|
|
111
|
+
* this hook's fault policy below: a write-shaped call whose input carries a
|
|
112
|
+
* `[REDACTED…]` placeholder asks instead of passing (see
|
|
113
|
+
* {@link hintedWriteFault}) — pass `input` so the policy can see it.
|
|
97
114
|
*
|
|
98
115
|
* The posture covers this hook's own failures, whatever their cause. What it
|
|
99
116
|
* does NOT cover is the verdict of a sanitizer that ran: a payload
|
|
@@ -104,6 +121,7 @@ export function failClosedFields(parsedOk: boolean, err: unknown, opts?: {
|
|
|
104
121
|
* messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>,
|
|
105
122
|
* hint?: string,
|
|
106
123
|
* env?: NodeJS.ProcessEnv | Record<string, string | undefined>,
|
|
124
|
+
* input?: unknown,
|
|
107
125
|
* }} [opts]
|
|
108
126
|
* @returns {Record<string, unknown>}
|
|
109
127
|
*/
|
|
@@ -111,6 +129,7 @@ export function hookFailureFields(parsedOk: boolean, err: unknown, opts?: {
|
|
|
111
129
|
messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>;
|
|
112
130
|
hint?: string;
|
|
113
131
|
env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
|
|
132
|
+
input?: unknown;
|
|
114
133
|
}): Record<string, unknown>;
|
|
115
134
|
/**
|
|
116
135
|
* The hook's CLI: parse → judge → render, under the caller's failure posture.
|
|
@@ -155,6 +174,7 @@ export const PRE_TOOL_USE_MESSAGES: Readonly<{
|
|
|
155
174
|
unparsable: (cause: string) => string;
|
|
156
175
|
remedy: string;
|
|
157
176
|
}>;
|
|
177
|
+
export const REDACTION_HINT: "[REDACTED";
|
|
158
178
|
/**
|
|
159
179
|
* A host-supplied deny gate: given the PreToolUse input, the reason this call
|
|
160
180
|
* must be blocked, or null to let the pipeline continue. Hosts use these for
|
package/types/rehydrate.d.mts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Re-anchor an Edit/Write input composed from a sanitized file view back onto
|
|
3
|
-
* the on-disk bytes (secrets rehydrated, stripped invisible runs re-attached)
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
3
|
+
* the on-disk bytes (secrets rehydrated, stripped invisible runs re-attached),
|
|
4
|
+
* and gate MultiEdit (pass-through only on a verified view==disk with no
|
|
5
|
+
* placeholder in any edit; denied otherwise — see the module doc). Returns the
|
|
6
|
+
* rewritten input plus a model-facing context line, a deny with an instructive
|
|
7
|
+
* reason when the input is unresolvable or would expose a secret, or null when
|
|
8
|
+
* there is nothing to do. Throws only on internal error (the caller fails
|
|
9
|
+
* closed).
|
|
8
10
|
*
|
|
9
11
|
* `io` is the injected I/O (file read + redactor map/plain). `hint` is the
|
|
10
12
|
* redaction-placeholder prefix (defaults to {@link DEFAULT_HINT}); override it
|
package/types/view-map.d.mts
CHANGED
|
@@ -216,6 +216,24 @@ export function pairDiskSpans(view: FileView<"utf16">, deletions: {
|
|
|
216
216
|
start: number;
|
|
217
217
|
end: number;
|
|
218
218
|
}[];
|
|
219
|
+
/**
|
|
220
|
+
* Defect in a redactor map relative to the Layer-1-cleaned text it claims to
|
|
221
|
+
* describe, or null when the map is sound. Two proofs, both required before
|
|
222
|
+
* any splice may trust the map: every pair's placeholder must actually occupy
|
|
223
|
+
* `view.text` at its stated offset, and splicing each pair's original back
|
|
224
|
+
* over its placeholder must reproduce `cleaned` byte-for-byte. The redactor is
|
|
225
|
+
* an INJECTED engine with a real defect rate, and the offsets it emits anchor
|
|
226
|
+
* edits onto disk bytes — a map failing either proof would splice at the wrong
|
|
227
|
+
* position and corrupt the file, so the caller must treat it as unmappable
|
|
228
|
+
* rather than act on it. Defect messages name placeholders and offsets only,
|
|
229
|
+
* never an original (secret) byte.
|
|
230
|
+
* Indexes `view.text` by `pair.start` directly, so it requires the UTF-16
|
|
231
|
+
* carrier — the same one every splice consumes.
|
|
232
|
+
* @param {string} cleaned Layer-1-cleaned file text the map was derived from
|
|
233
|
+
* @param {FileView<"utf16">} view
|
|
234
|
+
* @returns {string | null}
|
|
235
|
+
*/
|
|
236
|
+
export function viewMapDefect(cleaned: string, view: FileView<"utf16">): string | null;
|
|
219
237
|
/**
|
|
220
238
|
* Substitute the placeholders in a model-authored new_string with the secrets
|
|
221
239
|
* they stand for. Resolution, strictest first: if the new placeholder
|