agent-sanitizer 2.21.1 → 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 +50 -0
- package/claude-hooks/lib/invisible-alert.mjs +2 -1
- package/claude-hooks/lib/placeholder-grammar.mjs +85 -0
- package/claude-hooks/lib/secret-drop-guard.mjs +227 -0
- package/claude-hooks/pretooluse-sanitize.mjs +19 -6
- package/claude-hooks/sanitize-output.mjs +29 -0
- package/package.json +1 -1
- package/src/output.mjs +14 -1
- package/src/rehydrate.mjs +75 -9
- package/types/claude-hooks/lib/invisible-alert.d.mts +2 -0
- package/types/claude-hooks/lib/placeholder-grammar.d.mts +47 -0
- package/types/claude-hooks/lib/secret-drop-guard.d.mts +88 -0
- package/types/claude-hooks/sanitize-output.d.mts +1 -0
- package/types/output.d.mts +10 -0
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
|
-
|
|
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
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
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 = (
|
|
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.
|
|
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": {
|
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
|
-
|
|
542
|
-
|
|
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
|
|
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).
|
|
627
|
-
//
|
|
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.
|
|
630
|
-
//
|
|
631
|
-
//
|
|
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
|
-
|
|
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
|
package/types/output.d.mts
CHANGED
|
@@ -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
|