agent-sanitizer 2.21.0 → 2.22.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/THREAT-MODEL.md CHANGED
@@ -232,6 +232,56 @@ are load-bearing and **fail closed**:
232
232
  File access and the redactor are injected via `io`; the package performs no I/O
233
233
  of its own and bundles no secret engine.
234
234
 
235
+ `MultiEdit` is a rehydration candidate but never re-anchored: its edits apply
236
+ sequentially, each against the result of the previous, which the span machinery
237
+ (one `old_string` against one static view) cannot model. A MultiEdit against a
238
+ file whose view equals disk passes through untouched (the common case); one
239
+ against a divergent file is **denied** with use-single-Edit guidance — an
240
+ unguarded pass-through there would be both a silent clobber and the same
241
+ extraction oracle the Edit path's hidden-span rule closes.
242
+
243
+ ## Placeholder-clobber guards (hooks layer)
244
+
245
+ Rehydration re-anchors only Edit/Write, so every other write path — a shell
246
+ heredoc, `sed -i`/`tee`, an MCP file tool — persists a copied `[REDACTED…]`
247
+ placeholder literally, destroying the secret it stands for; and a Write that
248
+ simply **drops** a secret line never carries a placeholder at all. The Claude
249
+ hooks close these around the package's core, favoring precision (a false
250
+ positive costs a sentence of context, never a mangled input):
251
+
252
+ - **Grammar, not prefix.** Detection matches the exact placeholder language
253
+ (`claude-hooks/lib/placeholder-grammar.mjs`, mirrored from the Python
254
+ producer `placeholders.py` and pinned to it by a contract test), never the
255
+ bare `[REDACTED` prefix — `grep "\[REDACTED"` and `[REDACTED…]` prose are
256
+ not placeholders. It lives in the hooks layer, not the engine, so the
257
+ plugin's pinned-engine bundle ships it immediately.
258
+ - **Doctrine at redaction time.** The Layer-4 warning that introduces
259
+ placeholders into the model's view now states that they rehydrate only via
260
+ Edit/Write — removing the information asymmetry that made the shell
261
+ route-around an honest mistake.
262
+ - **Advisory on non-rehydrated tools (context-only, never a verdict).** A
263
+ Bash/MCP/unknown-tool input carrying a placeholder gets one PreToolUse
264
+ context line explaining the hazard. It cannot tell a write from a read, so
265
+ it never blocks.
266
+ - **On-disk tripwire (warning-only).** A `Read` whose RAW bytes — before this
267
+ session's redaction — already contain placeholder text warns that an earlier
268
+ write may have clobbered a secret. Detection rides the read, the one choke
269
+ point every write path (including other agents) eventually crosses; reveal
270
+ sidecars are excluded, since their bytes are redacted before persisting.
271
+ - **Clobber-by-omission confirm (`lib/secret-drop-guard.mjs`).** A Write to an
272
+ existing, **git-untracked** file (no git recovery path — `.env` and its kin,
273
+ or a file outside any repository) whose redacted secrets vanish from the
274
+ final, post-rehydration content is denied once with the reason; re-issuing
275
+ the identical Write confirms and passes. The confirmation is the model's
276
+ deliberate retry — never a human permission prompt — held as a
277
+ consumed-on-use, TTL-bounded sentinel (keyed to path + content + the dropped
278
+ values) via the same squat-resistant `$TMPDIR` helpers as the invisible-char
279
+ gate. Tracked files, secret-free files, failed git probes and unmappable
280
+ views all skip the guard (fail open). "Tracked" approximates "recoverable":
281
+ an uncommitted secret line on a tracked file is an accepted gap — the
282
+ committed content survives, and probing index-vs-worktree state would trade
283
+ precision for recall.
284
+
235
285
  ## Failure posture (`AGENT_SANITIZER_FAIL_OPEN`)
236
286
 
237
287
  Installed as Claude Code hooks, these fail **open**: a hook that could not
@@ -31,7 +31,8 @@ const { applyLayer1 } = /** @type {typeof import("agent-sanitizer")} */ (
31
31
  /** The project the hooks are guarding; the alert paths are keyed to it. */
32
32
  export const PROJECT_DIR = process.env.CLAUDE_PROJECT_DIR || process.cwd();
33
33
 
34
- const PROJECT_HASH = createHash("sha256")
34
+ /** Short project digest keying this project's $TMPDIR marker names. */
35
+ export const PROJECT_HASH = createHash("sha256")
35
36
  .update(PROJECT_DIR)
36
37
  .digest("hex")
37
38
  .slice(0, 8);
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The redaction-placeholder grammar, mirrored from the single producer in
3
+ * python/agent_sanitizer/secrets/placeholders.py (PLACEHOLDER_LABEL_CHARS /
4
+ * PLACEHOLDER_RE there), plus the advisory for placeholder text in tool inputs
5
+ * rehydration cannot re-anchor.
6
+ *
7
+ * This lives in the HOOKS layer, not the engine (`src/`), deliberately: the
8
+ * plugin bundle inlines the hook sources from this repo but resolves the
9
+ * engine from the pinned registry release, so an engine export the pin lacks
10
+ * is undefined in the shipped bundle. The grammar's consumers (the PreToolUse
11
+ * advisory, the PostToolUse on-disk tripwire, the drop guard's deny prose) are
12
+ * all hooks, so defining it here keeps the shipped bundle and the source tree
13
+ * on one implementation. test/placeholder-guards.test.mjs pins these constants
14
+ * against the Python source, so an edit to either side that forgets the other
15
+ * fails CI rather than letting the two parsers drift.
16
+ */
17
+
18
+ export const PLACEHOLDER_LABEL_CHARS = "A-Za-z0-9 ()._-";
19
+ const PLACEHOLDER_LABEL_MAX_LEN = 64;
20
+
21
+ /**
22
+ * Matches exactly the placeholder text the canonical redactor can emit:
23
+ * `[REDACTED]` or `[REDACTED: <label>]`. Detection sites use this — never a
24
+ * bare `[REDACTED` prefix — so hint-prefixed prose (`[REDACTED…]`,
25
+ * `grep "\[REDACTED"`) is not mistaken for a placeholder.
26
+ */
27
+ export const PLACEHOLDER_RE = new RegExp(
28
+ `\\[REDACTED(?:: [${PLACEHOLDER_LABEL_CHARS}]{1,${PLACEHOLDER_LABEL_MAX_LEN}})?\\]`,
29
+ );
30
+
31
+ // Tools whose inputs the rehydration layer itself resolves (or, for
32
+ // NotebookEdit, refuses with guidance). placeholderNotice stays silent on
33
+ // these: their placeholder handling is a verdict, not a note.
34
+ const REHYDRATED_TOOLS = new Set([
35
+ "Edit",
36
+ "Write",
37
+ "MultiEdit",
38
+ "NotebookEdit",
39
+ ]);
40
+
41
+ /**
42
+ * Depth-capped walk: does any string in `value` carry placeholder-shaped text?
43
+ * The cap fails OPEN (deeper content is unseen) — every caller feeds a
44
+ * context-only advisory, so a miss costs one line, never a mangled input.
45
+ * @param {unknown} value
46
+ * @param {number} [depth]
47
+ * @returns {boolean}
48
+ */
49
+ export function containsPlaceholder(value, depth = 0) {
50
+ if (depth > 32) return false;
51
+ if (typeof value === "string") return PLACEHOLDER_RE.test(value);
52
+ if (Array.isArray(value))
53
+ return value.some((item) => containsPlaceholder(item, depth + 1));
54
+ if (value !== null && typeof value === "object")
55
+ return Object.values(value).some((item) =>
56
+ containsPlaceholder(item, depth + 1),
57
+ );
58
+ return false;
59
+ }
60
+
61
+ /**
62
+ * Advisory context for a tool call OUTSIDE the rehydrated set (Bash, MCP
63
+ * tools, anything unknown) whose input carries placeholder-shaped text, or
64
+ * null. Rehydration only re-anchors Edit/Write; every other write path — a
65
+ * shell heredoc, `sed -i`, an MCP file tool — persists the literal placeholder
66
+ * and destroys the secret it stands for. This cannot tell a write from a read
67
+ * (`grep` for a placeholder is legitimate), so it is deliberately a NOTE, not
68
+ * a verdict: a false positive costs one sentence of context, never a blocked
69
+ * call or a mangled input.
70
+ * @param {string} tool
71
+ * @param {unknown} toolInput
72
+ * @returns {string | null}
73
+ */
74
+ export function placeholderNotice(tool, toolInput) {
75
+ if (REHYDRATED_TOOLS.has(tool)) return null;
76
+ if (!containsPlaceholder(toolInput)) return null;
77
+ return (
78
+ "This tool call carries [REDACTED…] placeholder text, which stands for a " +
79
+ "secret hidden from your view. Placeholders are rehydrated to the real " +
80
+ "secret only for Edit/Write on the file that owns them; any other write " +
81
+ "path (shell redirection, sed/tee, MCP file tools) persists the literal " +
82
+ "placeholder and destroys the secret. Use Edit or Write for changes to " +
83
+ "that file, or ask the user."
84
+ );
85
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Clobber-by-omission guard for whole-file Writes.
3
+ *
4
+ * Rehydration (agent-sanitizer/rehydrate) only engages when a Write's content
5
+ * carries a placeholder; a Write that REGENERATES a secret-bearing file and
6
+ * simply drops the secret line (reworded to `API_KEY=<your-key-here>`, or
7
+ * omitted) never enters that layer, and the real secret is silently destroyed.
8
+ * "Tracked by git" is used as an approximation of "recoverable": a tracked
9
+ * file's committed content survives the Write, so the guard stays out of the
10
+ * way — even though an uncommitted secret line on a tracked file is still
11
+ * lost (accepted: precision over recall). For an UNTRACKED file — `.env` and
12
+ * its kin are gitignored precisely because they hold secrets, and files
13
+ * outside any repository count too — there is no git recovery, so the first
14
+ * such Write is denied with an instructive reason and the model confirms by
15
+ * re-issuing the exact same call: a deliberate retry, never a human
16
+ * permission prompt.
17
+ *
18
+ * The confirm state is a presence sentinel keyed to a fingerprint of
19
+ * (file_path, content, dropped secret values) under $TMPDIR, written and read
20
+ * with the same squat-resistant helpers the invisible-char gate uses
21
+ * (markerIsTrusted / writeSentinelFile), consumed on use, and expired after
22
+ * {@link CONFIRM_TTL_MS}, so one confirmation approves exactly one prompt
23
+ * Write — never a stale or standing auto-approval.
24
+ *
25
+ * Fail-open posture (per the package's precision-over-recall doctrine): a
26
+ * failed git probe, an unmappable redaction view, or a secret-free file all
27
+ * skip the guard — a false denial here blocks legitimate work, while a false
28
+ * pass costs only what today's behavior already allows.
29
+ */
30
+ import { createHash } from "node:crypto";
31
+ import { lstatSync, unlinkSync } from "node:fs";
32
+ import { join, dirname } from "node:path";
33
+ import { tmpdir } from "node:os";
34
+ import { spawnSync } from "node:child_process";
35
+ import { lazyImport, markerIsTrusted, writeSentinelFile } from "./hook-io.mjs";
36
+ import { PROJECT_HASH } from "./invisible-alert.mjs";
37
+
38
+ // Layer-1 view primitives, bound via lazyImport (see its doc for the fail-OPEN
39
+ // hazard of a bare static npm import): a load failure leaves these undefined,
40
+ // so the guard's use throws into the hook's posture-managed catch.
41
+ const { applyLayer1, LONE_SURROGATE_RE } =
42
+ /** @type {typeof import("agent-sanitizer")} */ (
43
+ await lazyImport("agent-sanitizer")
44
+ );
45
+
46
+ /** A confirm sentinel older than this is stale, not a confirmation. */
47
+ export const CONFIRM_TTL_MS = 10 * 60_000;
48
+
49
+ /**
50
+ * Fingerprint of one exact drop: same path, same new bytes, same set of
51
+ * secret values being removed. The model confirms by re-issuing the identical
52
+ * call, so the sentinel must match nothing broader — including the dropped
53
+ * values themselves: if the file's secrets change between the deny and the
54
+ * retry, the retry is dropping something the model was never warned about,
55
+ * and must be re-denied.
56
+ * @param {string} filePath
57
+ * @param {string} content
58
+ * @param {string[]} [dropped] the secret values the Write would remove
59
+ * @returns {string}
60
+ */
61
+ export function dropFingerprint(filePath, content, dropped = []) {
62
+ const digest = createHash("sha256").update(filePath).update("\0");
63
+ digest.update(content);
64
+ for (const secret of dropped) digest.update("\0").update(secret);
65
+ return digest.digest("hex").slice(0, 32);
66
+ }
67
+
68
+ /**
69
+ * The confirm sentinel's path for one fingerprint. Predictable and
70
+ * world-visible like the alert markers, hence the markerIsTrusted read.
71
+ * @param {string} fingerprint
72
+ * @returns {string}
73
+ */
74
+ export function confirmMarkerPath(fingerprint) {
75
+ return join(tmpdir(), `.claude-secret-drop-${PROJECT_HASH}-${fingerprint}`);
76
+ }
77
+
78
+ /**
79
+ * Whether git tracks `filePath`. Exit 0 is tracked; exit 1 (untracked) and 128
80
+ * (not a repository) both mean "no git recovery exists", which is what the
81
+ * guard actually cares about. A spawn-level failure (no git binary) means we
82
+ * cannot tell — report tracked so the guard skips (fail open) rather than
83
+ * denying on missing tooling.
84
+ * @param {string} filePath
85
+ * @param {typeof spawnSync} [spawn]
86
+ * @returns {boolean}
87
+ */
88
+ export function gitTracked(filePath, spawn = spawnSync) {
89
+ const res = spawn("git", ["ls-files", "--error-unmatch", "--", filePath], {
90
+ cwd: dirname(filePath),
91
+ stdio: "ignore",
92
+ });
93
+ if (res.error) return true;
94
+ return res.status === 0;
95
+ }
96
+
97
+ /**
98
+ * @param {number} count
99
+ * @param {string} filePath
100
+ * @returns {string}
101
+ */
102
+ function dropDeny(count, filePath) {
103
+ return (
104
+ `this Write removes ${count} redacted secret value(s) from ${filePath}, and the ` +
105
+ `file is not tracked by git, so the secrets may be unrecoverable. If removing ` +
106
+ `them is intended, re-issue this exact Write to confirm; otherwise keep each ` +
107
+ `[REDACTED…] placeholder (or its secret's line) in the new content`
108
+ );
109
+ }
110
+
111
+ /**
112
+ * Deny a first-time Write that would drop a redacted secret from an untracked
113
+ * file, or null to let it pass. `toolInput` is the FINAL Write input — after
114
+ * rehydration substituted any placeholders — so a preserved secret shows up as
115
+ * its real value in `content`. `io` is the rehydrate-shaped I/O bag (readFile /
116
+ * redact / redactMap). Injectable seams: `isTracked` (the git probe),
117
+ * `confirmSeen`/`recordConfirm` (the sentinel state).
118
+ * @param {{file_path?: unknown, content?: unknown}} toolInput
119
+ * @param {import("agent-sanitizer/rehydrate").RehydrateIo} io
120
+ * @param {{
121
+ * isTracked?: (filePath: string) => boolean,
122
+ * confirmSeen?: (fingerprint: string) => boolean,
123
+ * recordConfirm?: (fingerprint: string) => void,
124
+ * }} [opts]
125
+ * @returns {Promise<{deny: string} | null>}
126
+ */
127
+ export async function secretDropGuard(toolInput, io, opts = {}) {
128
+ const {
129
+ isTracked = gitTracked,
130
+ confirmSeen = consumeConfirm,
131
+ recordConfirm = (fingerprint) =>
132
+ writeSentinelFile(confirmMarkerPath(fingerprint)),
133
+ } = opts;
134
+ const { file_path: filePath, content } = toolInput ?? {};
135
+ if (typeof filePath !== "string" || typeof content !== "string") return null;
136
+
137
+ let disk;
138
+ try {
139
+ disk = io.readFile(filePath);
140
+ } catch (err) {
141
+ // ENOENT: the Write CREATES the file — nothing on disk to drop, and the
142
+ // hinted-creation case is already rehydration's deny. Any other read
143
+ // failure propagates: the Write itself will hit the same error, and this
144
+ // guard must not convert an unexpected failure into a silent pass.
145
+ if (/** @type {NodeJS.ErrnoException} */ (err)?.code === "ENOENT")
146
+ return null;
147
+ throw err;
148
+ }
149
+ if (isTracked(filePath)) return null;
150
+ const cleaned = applyLayer1(disk).cleaned.replace(
151
+ LONE_SURROGATE_RE,
152
+ "\uFFFD",
153
+ );
154
+ // Cheap secrets-present probe: null exactly when the file holds no secrets —
155
+ // the overwhelmingly common case pays one daemon round-trip and no map.
156
+ if ((await io.redact(cleaned)) === null) return null;
157
+ const view = await io.redactMap(cleaned);
158
+ // Unmappable: cannot resolve WHICH values the file holds, so the drop set is
159
+ // unknowable — skip rather than deny on ambiguity (rehydration already
160
+ // denies the hinted calls this state actually endangers).
161
+ if ("unmappable" in view) return null;
162
+ const dropped = [...new Set(view.pairs.map((pair) => pair.original))].filter(
163
+ (secret) => !content.includes(secret),
164
+ );
165
+ if (dropped.length === 0) return null;
166
+
167
+ const fingerprint = dropFingerprint(filePath, content, dropped);
168
+ if (confirmSeen(fingerprint)) return null;
169
+ recordConfirm(fingerprint);
170
+ return { deny: dropDeny(dropped.length, filePath) };
171
+ }
172
+
173
+ /**
174
+ * Compose a Layer-4 rehydrator with this guard: for a Write the rehydrator
175
+ * did not deny, run the guard on the FINAL content — after any placeholder
176
+ * substitution — so a rehydrated secret counts as preserved and only a
177
+ * genuinely dropped one can trip it. A rehydrate deny short-circuits (the
178
+ * guard never runs), non-Write tools skip the guard entirely, and a guard
179
+ * deny wins over a pass/rewrite result.
180
+ * @typedef {{updatedInput: any, context: string} | {deny: string} | null} RehydrateResult
181
+ * @param {(tool: string, toolInput: any) => Promise<RehydrateResult>} rehydrate
182
+ * @param {import("agent-sanitizer/rehydrate").RehydrateIo} io
183
+ * @param {typeof secretDropGuard} [guard]
184
+ * @returns {(tool: string, toolInput: any) => Promise<RehydrateResult>}
185
+ */
186
+ export function withSecretDropGuard(rehydrate, io, guard = secretDropGuard) {
187
+ return async (tool, toolInput) => {
188
+ const rehydrated = await rehydrate(tool, toolInput);
189
+ if (tool !== "Write" || (rehydrated !== null && "deny" in rehydrated))
190
+ return rehydrated;
191
+ const finalInput =
192
+ rehydrated === null ? toolInput : rehydrated.updatedInput;
193
+ const drop = await guard(finalInput, io);
194
+ return drop ?? rehydrated;
195
+ };
196
+ }
197
+
198
+ /**
199
+ * True when a trusted, fresh confirm sentinel exists for `fingerprint`,
200
+ * consuming it so one confirmation approves exactly one Write. A sentinel
201
+ * older than {@link CONFIRM_TTL_MS} is stale — an abandoned deny from a
202
+ * previous session, not a live confirmation — so it is removed and NOT
203
+ * honored, preventing a leftover marker from acting as a standing
204
+ * auto-approval. Removal is best-effort: a fresh marker we could not unlink
205
+ * still counted as consumed for THIS call, and a leftover empty sentinel only
206
+ * ever re-approves the identical (path, bytes, drop-set) Write the model
207
+ * already confirmed — and only within the TTL.
208
+ * @param {string} fingerprint
209
+ * @returns {boolean}
210
+ */
211
+ function consumeConfirm(fingerprint) {
212
+ const marker = confirmMarkerPath(fingerprint);
213
+ if (!markerIsTrusted(marker)) return false;
214
+ let fresh;
215
+ try {
216
+ fresh = Date.now() - lstatSync(marker).mtimeMs <= CONFIRM_TTL_MS;
217
+ } catch {
218
+ // Raced away between the trust check and the stat: no sentinel, no confirm.
219
+ return false;
220
+ }
221
+ try {
222
+ unlinkSync(marker);
223
+ } catch {
224
+ // Best-effort consume — see doc.
225
+ }
226
+ return fresh;
227
+ }
@@ -54,6 +54,8 @@ import {
54
54
  authoredContext,
55
55
  } from "./lib/authored-content.mjs";
56
56
  import { redactViaDaemon } from "./lib/redactor-client.mjs";
57
+ import { withSecretDropGuard } from "./lib/secret-drop-guard.mjs";
58
+ import { placeholderNotice } from "./lib/placeholder-grammar.mjs";
57
59
  import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
58
60
 
59
61
  const HOOK_NAME = "pretooluse-sanitize";
@@ -140,13 +142,15 @@ const redactorIo = {
140
142
 
141
143
  /**
142
144
  * Default Layer-4 rehydrator: the package's rehydrateRedacted bound to the
143
- * redactor-daemon io. Hoisted (not an inline default-param arrow) so tests can
144
- * still inject a fake as the second argument to buildPreToolUseResponse.
145
- * @param {string} tool
146
- * @param {any} toolInput
145
+ * redactor-daemon io, composed (via withSecretDropGuard, where the ordering
146
+ * logic lives and is unit-tested) with the clobber-by-omission guard. Hoisted
147
+ * (not an inline default-param arrow) so tests can still inject a fake as the
148
+ * second argument to buildPreToolUseResponse.
147
149
  */
148
- const defaultRehydrate = (tool, toolInput) =>
149
- rehydrateRedacted(tool, toolInput, redactorIo);
150
+ const defaultRehydrate = withSecretDropGuard(
151
+ (tool, toolInput) => rehydrateRedacted(tool, toolInput, redactorIo),
152
+ redactorIo,
153
+ );
150
154
 
151
155
  /**
152
156
  * Trace the response on the way out — "noop" (clean pass-through), "deny",
@@ -302,6 +306,15 @@ export async function buildPreToolUseResponse(
302
306
  });
303
307
  contexts.push(...layerContexts);
304
308
 
309
+ // Placeholder advisory for tools OUTSIDE the rehydrated set (Bash, MCP,
310
+ // anything unknown): rehydration cannot re-anchor these, so a placeholder in
311
+ // their input would be persisted literally by any write they perform. It
312
+ // cannot tell a write from a read, so it is context-only — never a verdict
313
+ // (see placeholderNotice). Evaluated on the pipeline's FINAL input, matching
314
+ // what the tool will actually receive.
315
+ const notice = placeholderNotice(tool, current);
316
+ if (notice !== null) contexts.push(notice);
317
+
305
318
  return emitTraced(
306
319
  emitTrace,
307
320
  input.tool_name,
@@ -41,6 +41,7 @@ import {
41
41
  isRevealRead,
42
42
  REVEAL_READ_ENVELOPE,
43
43
  } from "./lib/reveal.mjs";
44
+ import { containsPlaceholder } from "./lib/placeholder-grammar.mjs";
44
45
 
45
46
  // Layer-1 primitives and the cheap pre-gates, bound via lazyImport (see its
46
47
  // doc for the fail-OPEN hazard of a bare static npm import). A load failure
@@ -454,6 +455,22 @@ async function sanitizeObject(
454
455
  return { value: out, modified, sgrNote };
455
456
  }
456
457
 
458
+ // On-disk placeholder tripwire: warning for a Read whose RAW bytes — before
459
+ // this hook's own redaction ran — already carry placeholder-shaped text. That
460
+ // is the after-the-fact signature of a clobbered secret: some earlier write
461
+ // (a heredoc, sed, an MCP file tool, another agent) copied a placeholder out
462
+ // of a sanitized view and persisted it literally. It can equally be a
463
+ // legitimate fixture or document ABOUT redaction, so this is a warning the
464
+ // model relays, never a verdict — detection rides the read, which is the one
465
+ // choke point every write path eventually passes through.
466
+ // Exported so tests assert the surfaced warning by reference instead of
467
+ // re-typing the prose.
468
+ export const ON_DISK_PLACEHOLDER_WARNING =
469
+ "this file's raw on-disk bytes already contain literal [REDACTED…] " +
470
+ "placeholder text (not inserted by this sanitizer). If an earlier write " +
471
+ "copied a placeholder from a sanitized view, the secret it stood for has " +
472
+ "been destroyed; verify with the user before trusting or propagating this file";
473
+
457
474
  /**
458
475
  * Compose the model-facing additionalContext line for a sanitized/flagged tool
459
476
  * output. The seam (composeContextSeam) owns the prefix + warning join; this
@@ -742,6 +759,18 @@ export async function evaluateToolOutput(input, ext = {}) {
742
759
  const hint = persistReveal(stored);
743
760
  if (hint) warnings.push(hint);
744
761
  }
762
+ // On-disk placeholder tripwire (see ON_DISK_PLACEHOLDER_WARNING). Tested on
763
+ // the RAW tool_response — post-sanitization text carries placeholders this
764
+ // hook itself just inserted. Reads only: file bytes are where a clobbered
765
+ // secret surfaces, while grep/Bash output quoting placeholders is routine.
766
+ // Reveal sidecars are excluded — their bytes are redacted BEFORE persisting,
767
+ // so placeholder text there is this sanitizer's own.
768
+ if (
769
+ input.tool_name === "Read" &&
770
+ !revealRead &&
771
+ containsPlaceholder(toolOutput)
772
+ )
773
+ warnings.push(ON_DISK_PLACEHOLDER_WARNING);
745
774
  // sgrNote implies modified (the carve-out lives inside the Layer-1 strip), so
746
775
  // it never independently survives this guard — `modified` covers it.
747
776
  if (!modified && warnings.length === 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.21.0",
3
+ "version": "2.22.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": {
@@ -53,6 +53,7 @@
53
53
  "@stryker-mutator/core": "^9.6.1",
54
54
  "@stryker-mutator/tap-runner": "^9.6.1",
55
55
  "@types/node": "25.9.1",
56
+ "acorn": "^8.18.0",
56
57
  "c8": "11.0.0",
57
58
  "esbuild": "0.28.1",
58
59
  "eslint": "10.4.0",
@@ -198,8 +199,8 @@
198
199
  ],
199
200
  "dependencies": {
200
201
  "agent-control-plane-core": "0.2.13",
201
- "namespace-guard": "0.20.0",
202
202
  "css-tree": "^3.2.1",
203
+ "namespace-guard": "0.20.0",
203
204
  "rehype-parse": "9.0.1",
204
205
  "remark-gfm": "4.0.1",
205
206
  "remark-parse": "11.0.0",
package/src/output.mjs CHANGED
@@ -221,10 +221,23 @@ async function runRedact(state, redact) {
221
221
  if (!secrets) return;
222
222
  applyMutation(state, secrets.text);
223
223
  state.warnings.push(
224
- `API keys/secrets redacted: ${secrets.found.join(", ")}${secrets.note ?? ""}`,
224
+ `API keys/secrets redacted: ${secrets.found.join(", ")}${secrets.note ?? ""}${REDACTION_DOCTRINE}`,
225
225
  );
226
226
  }
227
227
 
228
+ /**
229
+ * The doctrine clause riding every redaction warning — the one moment
230
+ * placeholders enter the model's view. Without it the model has no way to know
231
+ * that a placeholder written back through any path but Edit/Write (a heredoc,
232
+ * sed/tee, an MCP file tool) is persisted literally, destroying the secret —
233
+ * making that route-around an honest mistake rather than a warned one.
234
+ * Exported so tests assert the composed warning by reference instead of
235
+ * re-typing the prose.
236
+ */
237
+ export const REDACTION_DOCTRINE =
238
+ " (placeholders rehydrate only via Edit/Write on the owning file; other " +
239
+ "write paths persist the placeholder text and lose the secret)";
240
+
228
241
  // Layer 2/3 pre-gate and warning prose are shared with the root entry
229
242
  // (./index.mjs), which runs the same layers; re-exported here because both were
230
243
  // part of this module's public surface before they moved.
package/src/rehydrate.mjs CHANGED
@@ -45,6 +45,13 @@
45
45
  * I/O is INJECTED through `io`: the caller supplies file reads and the secret
46
46
  * redactor (its map/plain contract). The package never bundles a redactor —
47
47
  * detect-secrets, a daemon, or any other engine is the caller's to wire.
48
+ *
49
+ * MultiEdit is a candidate but never re-anchored: its edits apply
50
+ * sequentially, each against the result of the previous, which this module's
51
+ * one-old_string-against-one-static-view machinery cannot model. A MultiEdit
52
+ * on a file whose view equals disk passes through; on a divergent file it is
53
+ * denied with use-single-Edit guidance (see the dispatch in
54
+ * {@link rehydrateRedacted}).
48
55
  */
49
56
  import { applyLayer1, LONE_SURROGATE_RE } from "./layer1.mjs";
50
57
  import {
@@ -479,6 +486,25 @@ async function rehydrateWrite(ti, view, io, hint) {
479
486
  };
480
487
  }
481
488
 
489
+ /**
490
+ * The single MultiEdit refusal: covers both a sanitized view that diverges
491
+ * from disk (redacted secrets, stripped invisible characters, a lone
492
+ * surrogate) and edits that carry placeholder text over a pristine file —
493
+ * either way the sequential edits cannot be re-anchored, so route the model
494
+ * to the per-call verified path.
495
+ * @param {string} filePath
496
+ */
497
+ function multiEditDeny(filePath) {
498
+ return {
499
+ deny:
500
+ `the sanitized view of ${filePath} differs from its on-disk bytes ` +
501
+ `(redacted secrets or stripped invisible characters), or the edits carry ` +
502
+ `[REDACTED…] placeholder text; MultiEdit's sequential edits cannot be ` +
503
+ `re-anchored onto the real bytes. Use single Edit calls — each is ` +
504
+ `rehydrated individually — or ask the user to make this change`,
505
+ };
506
+ }
507
+
482
508
  /**
483
509
  * True when this tool call could need re-anchoring against the target file's
484
510
  * sanitized view: any well-formed Edit (the view may differ from disk even
@@ -496,6 +522,24 @@ function isCandidate(tool, ti, hint) {
496
522
  );
497
523
  if (tool === "Write")
498
524
  return typeof ti.content === "string" && ti.content.includes(hint);
525
+ // MultiEdit applies its edits SEQUENTIALLY, each against the result of the
526
+ // previous, so the span machinery below (which maps one old_string against
527
+ // one static view) cannot re-anchor it. It is still a candidate: on a
528
+ // divergent file an unguarded pass-through is both a silent clobber (a
529
+ // placeholder in new_string persisted verbatim) and the same char-extraction
530
+ // oracle R1 closes for Edit (an old_string matching bytes inside a redacted
531
+ // span). The dispatch at the bottom pass-throughs the clean-file case and
532
+ // denies the divergent one with use-single-Edit guidance.
533
+ if (tool === "MultiEdit")
534
+ return (
535
+ Array.isArray(ti.edits) &&
536
+ ti.edits.length > 0 &&
537
+ ti.edits.every(
538
+ (/** @type {any} */ edit) =>
539
+ typeof edit?.old_string === "string" &&
540
+ typeof edit?.new_string === "string",
541
+ )
542
+ );
499
543
  return false;
500
544
  }
501
545
 
@@ -538,8 +582,13 @@ export async function rehydrateRedacted(
538
582
  if (!isCandidate(tool, toolInput, hint)) return null;
539
583
  const hinted =
540
584
  tool === "Write" ||
541
- toolInput.old_string.includes(hint) ||
542
- toolInput.new_string.includes(hint);
585
+ (tool === "MultiEdit"
586
+ ? toolInput.edits.some(
587
+ (/** @type {{old_string: string, new_string: string}} */ edit) =>
588
+ edit.old_string.includes(hint) || edit.new_string.includes(hint),
589
+ )
590
+ : toolInput.old_string.includes(hint) ||
591
+ toolInput.new_string.includes(hint));
543
592
 
544
593
  let content;
545
594
  try {
@@ -557,8 +606,13 @@ export async function rehydrateRedacted(
557
606
  // there is the same cross-file/stale-placeholder mistake a same-file Write
558
607
  // is denied for; refuse with the same guidance rather than write the
559
608
  // placeholder text as a real value.
609
+ // A hinted MultiEdit on a missing path is the same mistake: its first edit
610
+ // (empty old_string) CREATES the file, so a placeholder in any edit would
611
+ // be persisted verbatim as the new file's content — deny like the Write.
612
+ // A hint-free MultiEdit fails on its own (nothing to create placeholder
613
+ // text from), so it passes through like an Edit.
560
614
  if (nodeErr?.code === "ENOENT") {
561
- if (tool !== "Write") return null;
615
+ if (tool === "Edit" || (tool === "MultiEdit" && !hinted)) return null;
562
616
  return {
563
617
  deny:
564
618
  `${toolInput.file_path} does not exist, so the ${hint}…] placeholder in the ` +
@@ -607,6 +661,11 @@ export async function rehydrateRedacted(
607
661
  const deletions = alignDeletions(content, layer1Cleaned);
608
662
  const mapped = await io.redactMap(cleaned);
609
663
  if ("unmappable" in mapped) {
664
+ // MultiEdit has no resolver to vet it against an unresolvable map — a
665
+ // hint-free pass-through here would splice bytes inside spans nothing can
666
+ // account for, so it gets the MultiEdit deny where a hint-free Edit still
667
+ // reaches its own resolver-backed pass-through.
668
+ if (tool === "MultiEdit") return multiEditDeny(toolInput.file_path);
610
669
  if (!hinted) return null;
611
670
  return {
612
671
  deny: `cannot resolve redaction placeholders in ${toolInput.file_path}: ${mapped.unmappable}`,
@@ -623,20 +682,27 @@ export async function rehydrateRedacted(
623
682
  // literal text, so there is nothing to re-anchor. `cleaned === content` also
624
683
  // rules out a lone-surrogate-only divergence (view.pairs/deletions alone
625
684
  // would miss that, since the normalization is neither a redaction pair nor a
626
- // Layer-1 deletion). A Write is the exception: its content still carries the
627
- // hint prefix (isCandidate guaranteed it), and with no own placeholder to
685
+ // Layer-1 deletion). HINTED Write and MultiEdit are the exceptions: their
686
+ // content still carries the hint prefix, and with no own placeholder to
628
687
  // resolve that hint is a FOREIGN [REDACTED…] placeholder that would be
629
- // persisted verbatim over pristine bytes. Fall through to rehydrateWrite so
630
- // it denies with the cross-file guidance the same verdict a Write onto a
631
- // secret-bearing or absent target already gets.
688
+ // persisted verbatim over pristine bytes. A Write falls through to
689
+ // rehydrateWrite's cross-file deny; a MultiEdit to the MultiEdit deny below
690
+ // without this a hinted MultiEdit on a pristine file silently persists
691
+ // the foreign placeholder the byte-identical Write is denied for.
632
692
  if (
633
693
  view.pairs.length === 0 &&
634
694
  deletions.length === 0 &&
635
695
  cleaned === content &&
636
- !(tool === "Write" && toolInput.content.includes(hint))
696
+ (tool === "Edit" || !hinted)
637
697
  )
638
698
  return null;
639
699
 
700
+ // MultiEdit reaches here when the view diverges from disk (redacted
701
+ // secrets, stripped runs, a lone surrogate) or when its edits carry
702
+ // placeholder text: its sequential edits cannot be re-anchored one-by-one
703
+ // against a static view, so fail closed with the escape hatch that lands in
704
+ // the fully-verified path.
705
+ if (tool === "MultiEdit") return multiEditDeny(toolInput.file_path);
640
706
  return tool === "Edit"
641
707
  ? rehydrateEdit(
642
708
  toolInput,
@@ -39,6 +39,8 @@ export function gateAskReason(findings: string): string;
39
39
  export function gateReminderContext(): string;
40
40
  /** The project the hooks are guarding; the alert paths are keyed to it. */
41
41
  export const PROJECT_DIR: string;
42
+ /** Short project digest keying this project's $TMPDIR marker names. */
43
+ export const PROJECT_HASH: string;
42
44
  /** Findings the SessionStart scanner could not clean, for the PreToolUse gate. */
43
45
  export const ALERT_FILE: string;
44
46
  export const ALERT_ACK_FILE: string;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Depth-capped walk: does any string in `value` carry placeholder-shaped text?
3
+ * The cap fails OPEN (deeper content is unseen) — every caller feeds a
4
+ * context-only advisory, so a miss costs one line, never a mangled input.
5
+ * @param {unknown} value
6
+ * @param {number} [depth]
7
+ * @returns {boolean}
8
+ */
9
+ export function containsPlaceholder(value: unknown, depth?: number): boolean;
10
+ /**
11
+ * Advisory context for a tool call OUTSIDE the rehydrated set (Bash, MCP
12
+ * tools, anything unknown) whose input carries placeholder-shaped text, or
13
+ * null. Rehydration only re-anchors Edit/Write; every other write path — a
14
+ * shell heredoc, `sed -i`, an MCP file tool — persists the literal placeholder
15
+ * and destroys the secret it stands for. This cannot tell a write from a read
16
+ * (`grep` for a placeholder is legitimate), so it is deliberately a NOTE, not
17
+ * a verdict: a false positive costs one sentence of context, never a blocked
18
+ * call or a mangled input.
19
+ * @param {string} tool
20
+ * @param {unknown} toolInput
21
+ * @returns {string | null}
22
+ */
23
+ export function placeholderNotice(tool: string, toolInput: unknown): string | null;
24
+ /**
25
+ * The redaction-placeholder grammar, mirrored from the single producer in
26
+ * python/agent_sanitizer/secrets/placeholders.py (PLACEHOLDER_LABEL_CHARS /
27
+ * PLACEHOLDER_RE there), plus the advisory for placeholder text in tool inputs
28
+ * rehydration cannot re-anchor.
29
+ *
30
+ * This lives in the HOOKS layer, not the engine (`src/`), deliberately: the
31
+ * plugin bundle inlines the hook sources from this repo but resolves the
32
+ * engine from the pinned registry release, so an engine export the pin lacks
33
+ * is undefined in the shipped bundle. The grammar's consumers (the PreToolUse
34
+ * advisory, the PostToolUse on-disk tripwire, the drop guard's deny prose) are
35
+ * all hooks, so defining it here keeps the shipped bundle and the source tree
36
+ * on one implementation. test/placeholder-guards.test.mjs pins these constants
37
+ * against the Python source, so an edit to either side that forgets the other
38
+ * fails CI rather than letting the two parsers drift.
39
+ */
40
+ export const PLACEHOLDER_LABEL_CHARS: "A-Za-z0-9 ()._-";
41
+ /**
42
+ * Matches exactly the placeholder text the canonical redactor can emit:
43
+ * `[REDACTED]` or `[REDACTED: <label>]`. Detection sites use this — never a
44
+ * bare `[REDACTED` prefix — so hint-prefixed prose (`[REDACTED…]`,
45
+ * `grep "\[REDACTED"`) is not mistaken for a placeholder.
46
+ */
47
+ export const PLACEHOLDER_RE: RegExp;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Fingerprint of one exact drop: same path, same new bytes, same set of
3
+ * secret values being removed. The model confirms by re-issuing the identical
4
+ * call, so the sentinel must match nothing broader — including the dropped
5
+ * values themselves: if the file's secrets change between the deny and the
6
+ * retry, the retry is dropping something the model was never warned about,
7
+ * and must be re-denied.
8
+ * @param {string} filePath
9
+ * @param {string} content
10
+ * @param {string[]} [dropped] the secret values the Write would remove
11
+ * @returns {string}
12
+ */
13
+ export function dropFingerprint(filePath: string, content: string, dropped?: string[]): string;
14
+ /**
15
+ * The confirm sentinel's path for one fingerprint. Predictable and
16
+ * world-visible like the alert markers, hence the markerIsTrusted read.
17
+ * @param {string} fingerprint
18
+ * @returns {string}
19
+ */
20
+ export function confirmMarkerPath(fingerprint: string): string;
21
+ /**
22
+ * Whether git tracks `filePath`. Exit 0 is tracked; exit 1 (untracked) and 128
23
+ * (not a repository) both mean "no git recovery exists", which is what the
24
+ * guard actually cares about. A spawn-level failure (no git binary) means we
25
+ * cannot tell — report tracked so the guard skips (fail open) rather than
26
+ * denying on missing tooling.
27
+ * @param {string} filePath
28
+ * @param {typeof spawnSync} [spawn]
29
+ * @returns {boolean}
30
+ */
31
+ export function gitTracked(filePath: string, spawn?: typeof spawnSync): boolean;
32
+ /**
33
+ * Deny a first-time Write that would drop a redacted secret from an untracked
34
+ * file, or null to let it pass. `toolInput` is the FINAL Write input — after
35
+ * rehydration substituted any placeholders — so a preserved secret shows up as
36
+ * its real value in `content`. `io` is the rehydrate-shaped I/O bag (readFile /
37
+ * redact / redactMap). Injectable seams: `isTracked` (the git probe),
38
+ * `confirmSeen`/`recordConfirm` (the sentinel state).
39
+ * @param {{file_path?: unknown, content?: unknown}} toolInput
40
+ * @param {import("agent-sanitizer/rehydrate").RehydrateIo} io
41
+ * @param {{
42
+ * isTracked?: (filePath: string) => boolean,
43
+ * confirmSeen?: (fingerprint: string) => boolean,
44
+ * recordConfirm?: (fingerprint: string) => void,
45
+ * }} [opts]
46
+ * @returns {Promise<{deny: string} | null>}
47
+ */
48
+ export function secretDropGuard(toolInput: {
49
+ file_path?: unknown;
50
+ content?: unknown;
51
+ }, io: import("agent-sanitizer/rehydrate").RehydrateIo, opts?: {
52
+ isTracked?: (filePath: string) => boolean;
53
+ confirmSeen?: (fingerprint: string) => boolean;
54
+ recordConfirm?: (fingerprint: string) => void;
55
+ }): Promise<{
56
+ deny: string;
57
+ } | null>;
58
+ /**
59
+ * Compose a Layer-4 rehydrator with this guard: for a Write the rehydrator
60
+ * did not deny, run the guard on the FINAL content — after any placeholder
61
+ * substitution — so a rehydrated secret counts as preserved and only a
62
+ * genuinely dropped one can trip it. A rehydrate deny short-circuits (the
63
+ * guard never runs), non-Write tools skip the guard entirely, and a guard
64
+ * deny wins over a pass/rewrite result.
65
+ * @typedef {{updatedInput: any, context: string} | {deny: string} | null} RehydrateResult
66
+ * @param {(tool: string, toolInput: any) => Promise<RehydrateResult>} rehydrate
67
+ * @param {import("agent-sanitizer/rehydrate").RehydrateIo} io
68
+ * @param {typeof secretDropGuard} [guard]
69
+ * @returns {(tool: string, toolInput: any) => Promise<RehydrateResult>}
70
+ */
71
+ export function withSecretDropGuard(rehydrate: (tool: string, toolInput: any) => Promise<RehydrateResult>, io: import("agent-sanitizer/rehydrate").RehydrateIo, guard?: typeof secretDropGuard): (tool: string, toolInput: any) => Promise<RehydrateResult>;
72
+ /** A confirm sentinel older than this is stale, not a confirmation. */
73
+ export const CONFIRM_TTL_MS: number;
74
+ /**
75
+ * Compose a Layer-4 rehydrator with this guard: for a Write the rehydrator
76
+ * did not deny, run the guard on the FINAL content — after any placeholder
77
+ * substitution — so a rehydrated secret counts as preserved and only a
78
+ * genuinely dropped one can trip it. A rehydrate deny short-circuits (the
79
+ * guard never runs), non-Write tools skip the guard entirely, and a guard
80
+ * deny wins over a pass/rewrite result.
81
+ */
82
+ export type RehydrateResult = {
83
+ updatedInput: any;
84
+ context: string;
85
+ } | {
86
+ deny: string;
87
+ } | null;
88
+ import { spawnSync } from "node:child_process";
@@ -247,6 +247,7 @@ export const SECRET_HINT_EXT: RegExp;
247
247
  export const describeRemoved: typeof import("agent-sanitizer/output").describeRemoved;
248
248
  export const describeWarned: typeof import("agent-sanitizer/output").describeWarned;
249
249
  export const suppressToolOutput: typeof import("agent-sanitizer/output").suppressToolOutput;
250
+ export const ON_DISK_PLACEHOLDER_WARNING: string;
250
251
  /**
251
252
  * Host-supplied extensions to this hook, threaded from {@link cliMain} down to
252
253
  * each string leaf. Every field is optional and the bag defaults to `{}`, so a
@@ -152,6 +152,16 @@ export const FILTER_WARNING: Readonly<{
152
152
  FILTER_FLAGGED: "filter-flagged";
153
153
  FILTER_ERROR: "filter-error";
154
154
  }>;
155
+ /**
156
+ * The doctrine clause riding every redaction warning — the one moment
157
+ * placeholders enter the model's view. Without it the model has no way to know
158
+ * that a placeholder written back through any path but Edit/Write (a heredoc,
159
+ * sed/tee, an MCP file tool) is persisted literally, destroying the secret —
160
+ * making that route-around an honest mistake rather than a warned one.
161
+ * Exported so tests assert the composed warning by reference instead of
162
+ * re-typing the prose.
163
+ */
164
+ export const REDACTION_DOCTRINE: string;
155
165
  export { needsMarkdownPipeline };
156
166
  /**
157
167
  * Maximum container nesting `sanitizeValue` / `suppressToolOutput` will descend