agent-sanitizer 2.68.0 → 2.69.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -6
- package/THREAT-MODEL.md +31 -18
- package/claude-hooks/lib/hook-fault.mjs +4 -2
- package/claude-hooks/lib/hook-io.mjs +84 -9
- package/claude-hooks/lib/invisible-alert.mjs +6 -3
- package/claude-hooks/lib/secret-drop-guard.mjs +136 -29
- package/claude-hooks/plugin-hooks.mjs +29 -24
- package/claude-hooks/pretooluse-sanitize.mjs +288 -62
- package/claude-hooks/sanitize-user-prompt.mjs +28 -15
- package/claude-hooks/scan-invisible-chars.mjs +86 -33
- package/claude-hooks/scan-loaded-instructions.mjs +20 -14
- package/package.json +1 -1
- package/src/html.mjs +17 -17
- package/src/output.mjs +5 -4
- package/src/rehydrate.mjs +36 -9
- package/types/claude-hooks/lib/hook-fault.d.mts +4 -2
- package/types/claude-hooks/lib/hook-io.d.mts +32 -6
- package/types/claude-hooks/lib/invisible-alert.d.mts +6 -3
- package/types/claude-hooks/lib/secret-drop-guard.d.mts +77 -9
- package/types/claude-hooks/pretooluse-sanitize.d.mts +28 -10
- package/types/claude-hooks/sanitize-user-prompt.d.mts +3 -2
- package/types/claude-hooks/scan-invisible-chars.d.mts +3 -3
- package/types/rehydrate.d.mts +8 -1
package/README.md
CHANGED
|
@@ -138,9 +138,10 @@ composed against the redacted view are re-anchored onto the real bytes, and
|
|
|
138
138
|
placeholders in new content resolve back to the real secrets — disk → tool
|
|
139
139
|
input only, never into the model's view. Anything ambiguous is denied rather
|
|
140
140
|
than guessed, the redactor's own map is verified against the file before any
|
|
141
|
-
splice, and a write that would persist placeholder text over a real secret
|
|
142
|
-
|
|
143
|
-
|
|
141
|
+
splice, and a write that would persist placeholder text over a real secret is
|
|
142
|
+
refused once, even when the hook's own machinery fails mid-session, and proceeds
|
|
143
|
+
with a warning when re-issued. The whole layer is opt-in — its denies and asks
|
|
144
|
+
are friction, so it
|
|
144
145
|
engages only when asked for: set `AGENT_SANITIZER_SECRETS_ENABLED=1` in the
|
|
145
146
|
environment Claude Code runs the hooks with; unset, no redactor runs and no
|
|
146
147
|
placeholders exist.
|
|
@@ -193,9 +194,10 @@ withholds a string-shaped response; the shell arm in `plugin/scripts/safe-launch
|
|
|
193
194
|
therefore also emits a top-level `decision`/`reason` pair, honored whatever
|
|
194
195
|
shape the response had, telling the model that any output still visible above
|
|
195
196
|
it is unsanitized. One carve-out to the
|
|
196
|
-
open default, with secrets enabled: a write-shaped call carrying `[REDACTED…]`
|
|
197
|
-
|
|
198
|
-
through would overwrite the real secret with the placeholder
|
|
197
|
+
open default, with secrets enabled: a write-shaped call carrying `[REDACTED…]`
|
|
198
|
+
placeholder text is refused once when the hook itself is broken, since letting
|
|
199
|
+
it through would overwrite the real secret with the placeholder; the exact
|
|
200
|
+
re-issue proceeds with a warning. Either way, a plugin that
|
|
199
201
|
never loaded at all is invisible — Claude Code reads a crashed hook as "no
|
|
200
202
|
objection" — so confirm with `/plugin` rather than reading a quiet session as a
|
|
201
203
|
working one. Neither posture touches what a sanitizer that RAN decided (see
|
package/THREAT-MODEL.md
CHANGED
|
@@ -960,20 +960,28 @@ parsed, and a layer that ran and threw.
|
|
|
960
960
|
One carve-out: when the PreToolUse hook itself fails (redactor daemon down,
|
|
961
961
|
package failed to load, a layer threw) and the call is a **write-shaped tool**
|
|
962
962
|
(Write/Edit/MultiEdit/NotebookEdit) whose input carries the `[REDACTED`
|
|
963
|
-
placeholder prefix, the hook **
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
963
|
+
placeholder prefix, the hook **refuses it once** instead of passing it through.
|
|
964
|
+
With the sanitizer down, rehydration cannot run, so the placeholder text would be
|
|
965
|
+
persisted literally over the real secret on disk: a destructive clobber, not a
|
|
966
|
+
missed scan. The refusal is releasable, never a loop: the exact same call
|
|
967
|
+
re-issued is let through with a warning that the placeholder was written
|
|
968
|
+
literally, and when the hook cannot record that confirmation (an unwritable
|
|
969
|
+
`$TMPDIR`) it lets the call through at once with the warning. The same rule
|
|
970
|
+
covers a write whose placeholder cannot be restored — a Layer-2 span missing
|
|
971
|
+
from the store, an unreadable target, a redactor map that fails validation.
|
|
972
|
+
Releasing an unhinted Edit on a file whose redactor map fails validation reopens
|
|
973
|
+
the hidden-span probing oracle the rehydrator closes, at the price of one refusal
|
|
974
|
+
per distinct probe, for as long as the redactor stays broken. An unhinted Write
|
|
975
|
+
there skips the secret-drop guard, which reads the same broken map, so its
|
|
976
|
+
refusal and warning say instead that any redacted secret it omits is removed. The check is
|
|
977
|
+
package-free (a literal-string test on the already-parsed payload), so
|
|
970
978
|
it holds even when the failure IS the missing package. Two accepted gaps: a
|
|
971
979
|
launcher-level failure (no `node`, corrupt bundle, a provisioned hook binary
|
|
972
980
|
that is missing, unexecutable or dies without a verdict) never reaches the
|
|
973
981
|
check — the launcher cannot inspect the payload and always warns — and `Bash`
|
|
974
982
|
is excluded even though shell redirection can also persist placeholder text,
|
|
975
|
-
because command strings mention `[REDACTED` benignly far too often for the
|
|
976
|
-
to hold precision. All other faults keep the open default, and
|
|
983
|
+
because command strings mention `[REDACTED` benignly far too often for the
|
|
984
|
+
refusal to hold precision. All other faults keep the open default, and
|
|
977
985
|
`AGENT_SANITIZER_FAIL_OPEN=0` behavior is unchanged.
|
|
978
986
|
|
|
979
987
|
**The open default is not enforceable against content.** Several of those
|
|
@@ -1005,13 +1013,13 @@ in `claude-hooks/sanitize-output.mjs` — so a host that wires those directly (a
|
|
|
1005
1013
|
`test/downstream-parity.test.mjs` shows) keeps strict failure semantics by
|
|
1006
1014
|
construction, with no env var to remember and none an agent could set for it.
|
|
1007
1015
|
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1016
|
+
The posture does NOT reach **detection verdicts**: it speaks only to the hook
|
|
1017
|
+
FAILING, and a working sanitizer that found an injection blocks under both
|
|
1018
|
+
settings. It does reach an unknown `--hook=` mode — static wiring corruption,
|
|
1019
|
+
from a `hooks.json` and bundle out of step: under the open default that hook
|
|
1020
|
+
exits non-blocking with a stderr line naming the mode and the fix, so every
|
|
1021
|
+
event it fires on reports the gap loudly instead of blocking the session, and
|
|
1022
|
+
under `=0` it exits 2.
|
|
1015
1023
|
|
|
1016
1024
|
The knob is operator configuration read from the process environment, so
|
|
1017
1025
|
anything that can set it for a session — `.claude/settings.json`'s `env` block,
|
|
@@ -1046,8 +1054,13 @@ extracts by walking the TypeScript AST of every shipped `.mjs` (not by matching
|
|
|
1046
1054
|
regex source with a regex), and fails on any pattern with super-linear
|
|
1047
1055
|
backtracking. A `RegExp(…)` built at runtime must be statically resolvable or
|
|
1048
1056
|
carry a written exemption, so a new dynamic pattern is a red build rather than a
|
|
1049
|
-
hole.
|
|
1050
|
-
|
|
1057
|
+
hole. No construction may read call-time data either — a parameter, tool
|
|
1058
|
+
output, `process.env` — because a pattern compiled from it is as large as that
|
|
1059
|
+
data, and past V8's size limit it throws on every call; the same walk covers a
|
|
1060
|
+
string handed to `.match`/`.matchAll`/`.search`.
|
|
1061
|
+
`tests/secrets/test_redos_static_guard.py` is the twin over the Python engine,
|
|
1062
|
+
and `tests/secrets/test_regex_size_static_guard.py` holds the Python package to
|
|
1063
|
+
the call-time rule. In `pnpm lint`, `eslint-plugin-redos` (recheck) and
|
|
1051
1064
|
`eslint-plugin-regexp`'s `no-super-linear-backtracking` and
|
|
1052
1065
|
`no-super-linear-move` read every shipped regex literal — the last of them
|
|
1053
1066
|
reports the shape that carries no backtracking at all, an **unanchored**
|
|
@@ -97,8 +97,10 @@ export const FAULT_POLICY_HOOKS = Object.freeze([
|
|
|
97
97
|
/**
|
|
98
98
|
* A hook's declared posture. There is deliberately no way for a policy to pin
|
|
99
99
|
* the CLOSED arm: AGENT_SANITIZER_FAIL_OPEN alone decides, so no hook fault can
|
|
100
|
-
* withhold a tool output
|
|
101
|
-
*
|
|
100
|
+
* withhold a tool output, block a prompt, or hold a tool call for good unless the
|
|
101
|
+
* operator set the knob to `0` (an open arm may refuse a call once, releasing the
|
|
102
|
+
* exact re-issue). A pin turns any bug in the guarded layer into every tool call
|
|
103
|
+
* failing.
|
|
102
104
|
* @typedef {{
|
|
103
105
|
* event: string | null,
|
|
104
106
|
* guarded: string,
|
|
@@ -680,6 +680,77 @@ export function emitHookResponse(hookEventName, fields) {
|
|
|
680
680
|
);
|
|
681
681
|
}
|
|
682
682
|
|
|
683
|
+
/**
|
|
684
|
+
* Fold several hook envelopes into the ONE that may reach stdout: `systemMessage`
|
|
685
|
+
* and `additionalContext` are joined (a later part is appended, never allowed to
|
|
686
|
+
* displace an earlier one); any other field takes the last value written.
|
|
687
|
+
* @param {Array<Record<string, any>>} envelopes non-empty
|
|
688
|
+
* @returns {Record<string, any>}
|
|
689
|
+
*/
|
|
690
|
+
function mergeEnvelopes(envelopes) {
|
|
691
|
+
/** @type {Record<string, any>} */
|
|
692
|
+
const merged = {};
|
|
693
|
+
/** @type {Record<string, any>} */
|
|
694
|
+
const specific = {};
|
|
695
|
+
/** @type {string[]} */
|
|
696
|
+
const systemMessages = [];
|
|
697
|
+
/** @type {string[]} */
|
|
698
|
+
const contexts = [];
|
|
699
|
+
for (const { hookSpecificOutput, systemMessage, ...rest } of envelopes) {
|
|
700
|
+
Object.assign(merged, rest);
|
|
701
|
+
if (systemMessage) systemMessages.push(systemMessage);
|
|
702
|
+
if (!hookSpecificOutput) continue;
|
|
703
|
+
const { additionalContext, ...fields } = hookSpecificOutput;
|
|
704
|
+
Object.assign(specific, fields);
|
|
705
|
+
if (additionalContext) contexts.push(additionalContext);
|
|
706
|
+
}
|
|
707
|
+
if (systemMessages.length > 0)
|
|
708
|
+
merged.systemMessage = systemMessages.join("\n");
|
|
709
|
+
if (contexts.length > 0) specific.additionalContext = contexts.join("\n");
|
|
710
|
+
if (Object.keys(specific).length > 0) merged.hookSpecificOutput = specific;
|
|
711
|
+
return merged;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* @typedef {{
|
|
716
|
+
* add: (envelope: Record<string, any>) => void,
|
|
717
|
+
* emit: (hookEventName: string, fields: Record<string, unknown>) => void,
|
|
718
|
+
* flush: () => void,
|
|
719
|
+
* }} EnvelopeBuffer
|
|
720
|
+
*/
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Collect everything a hook run wants to say on stdout and write it as exactly
|
|
724
|
+
* ONE JSON object on `flush`. Claude Code parses a hook's stdout as a single
|
|
725
|
+
* document, so two envelopes written by two reporters (a fault notice, then the
|
|
726
|
+
* slow-hook notice from the timing wrapper's `finally`) concatenate into invalid
|
|
727
|
+
* output and the host DROPS the whole response — the model never sees either
|
|
728
|
+
* warning. Every reporter in a run therefore adds here instead of writing, and
|
|
729
|
+
* the entry point flushes once, last.
|
|
730
|
+
* @param {(chunk: string) => void} [write] injectable stdout sink, for tests
|
|
731
|
+
* @returns {EnvelopeBuffer}
|
|
732
|
+
*/
|
|
733
|
+
export function createEnvelopeBuffer(
|
|
734
|
+
write = (chunk) => process.stdout.write(chunk),
|
|
735
|
+
) {
|
|
736
|
+
/** @type {Array<Record<string, any>>} */
|
|
737
|
+
const pending = [];
|
|
738
|
+
/** @param {Record<string, any>} envelope */
|
|
739
|
+
const add = (envelope) => {
|
|
740
|
+
pending.push(envelope);
|
|
741
|
+
};
|
|
742
|
+
return {
|
|
743
|
+
add,
|
|
744
|
+
emit: (hookEventName, fields) =>
|
|
745
|
+
add({ hookSpecificOutput: { hookEventName, ...fields } }),
|
|
746
|
+
flush() {
|
|
747
|
+
if (pending.length === 0) return;
|
|
748
|
+
write(JSON.stringify(mergeEnvelopes(pending)));
|
|
749
|
+
pending.length = 0;
|
|
750
|
+
},
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
|
|
683
754
|
/**
|
|
684
755
|
* The project the hooks are guarding. Every per-project $TMPDIR store is keyed to
|
|
685
756
|
* it, so it lives here — beside the other shared identity these hooks agree on —
|
|
@@ -799,7 +870,7 @@ export function hookgateMarkerPath(
|
|
|
799
870
|
// Prefer the per-user, mode-0700 runtime dir when the harness gives an
|
|
800
871
|
// absolute one; else the world-writable /tmp, where markerIsTrusted() — not
|
|
801
872
|
// the path — defends against a squatted marker.
|
|
802
|
-
const base = runtimeDir && runtimeDir
|
|
873
|
+
const base = runtimeDir && isAbsolute(runtimeDir) ? runtimeDir : "/tmp";
|
|
803
874
|
// The flattened dir is for a human reading `ls /tmp`; the digest of the RAW
|
|
804
875
|
// dir is the identity. Flattening alone is lossy — /work/a-b, /work/a_b and
|
|
805
876
|
// "/work/a b" all collapse to one name — and two such projects on one machine
|
|
@@ -811,7 +882,7 @@ export function hookgateMarkerPath(
|
|
|
811
882
|
.digest("hex")
|
|
812
883
|
.slice(0, 8);
|
|
813
884
|
const flattened = projectDir.replace(/[^A-Za-z0-9]/g, "_");
|
|
814
|
-
return `${
|
|
885
|
+
return join(base, `${HOOKGATE_MARKER_STEM}${flattened}-${digest}`);
|
|
815
886
|
}
|
|
816
887
|
|
|
817
888
|
/**
|
|
@@ -1199,26 +1270,30 @@ export function touchLiveState(path) {
|
|
|
1199
1270
|
* file the hook's user owns. Unlink any existing entry first (removing a squatted
|
|
1200
1271
|
* symlink), then create exclusively (O_EXCL) so a symlink re-planted in the race
|
|
1201
1272
|
* window fails the open rather than being dereferenced. Content is irrelevant —
|
|
1202
|
-
* callers test only for existence — so the file is left empty.
|
|
1203
|
-
* missing/read-only $TMPDIR or a lost race
|
|
1204
|
-
*
|
|
1205
|
-
*
|
|
1273
|
+
* callers test only for existence — so the file is left empty. Never throws: a
|
|
1274
|
+
* missing/read-only $TMPDIR or a lost race leaves the sentinel absent, and the
|
|
1275
|
+
* return value says so. A caller whose refusal is released only by a later read
|
|
1276
|
+
* of this sentinel must branch on it, because a refusal that records nothing can
|
|
1277
|
+
* never be released.
|
|
1206
1278
|
* @param {string} path
|
|
1207
|
-
* @returns {
|
|
1279
|
+
* @returns {boolean} whether a sentinel this uid owns now exists at `path`
|
|
1280
|
+
* (the same test {@link markerIsTrusted} applies when a reader looks for it)
|
|
1208
1281
|
*/
|
|
1209
1282
|
export function writeSentinelFile(path) {
|
|
1210
1283
|
try {
|
|
1211
1284
|
unlinkSync(path);
|
|
1212
1285
|
} catch {
|
|
1213
1286
|
// No existing entry (the common case), or an unremovable one — either way the
|
|
1214
|
-
// exclusive create below is the real guard, and its own failure is
|
|
1287
|
+
// exclusive create below is the real guard, and its own failure is returned.
|
|
1215
1288
|
}
|
|
1216
1289
|
try {
|
|
1217
1290
|
closeSync(openSync(path, "wx"));
|
|
1218
1291
|
} catch {
|
|
1219
1292
|
// A symlink re-planted in the unlink→open window, an unwritable dir, or a
|
|
1220
|
-
// leftover entry
|
|
1293
|
+
// leftover entry.
|
|
1294
|
+
return false;
|
|
1221
1295
|
}
|
|
1296
|
+
return markerIsTrusted(path);
|
|
1222
1297
|
}
|
|
1223
1298
|
|
|
1224
1299
|
/**
|
|
@@ -706,14 +706,17 @@ export function alertAcknowledged(sessionId) {
|
|
|
706
706
|
/**
|
|
707
707
|
* Record that the gate has surfaced its blocking ask, so later tool calls get a
|
|
708
708
|
* passive reminder instead of an ask on every call. Session-keyed, so the next
|
|
709
|
-
* session re-asks once without anything having to clear this.
|
|
709
|
+
* session re-asks once without anything having to clear this. Returns false
|
|
710
|
+
* when the ack could not be recorded, so the caller knows the next call will
|
|
711
|
+
* ask again: under the fail-open posture it surfaces the finding as a warning
|
|
712
|
+
* rather than asking on every tool call for the rest of the session.
|
|
710
713
|
* @param {string} [sessionId]
|
|
711
|
-
* @returns {
|
|
714
|
+
* @returns {boolean}
|
|
712
715
|
*/
|
|
713
716
|
export function acknowledgeAlert(sessionId) {
|
|
714
717
|
// Symlink-safe presence write: the ack sits at a predictable STATE_ROOT path
|
|
715
718
|
// a co-tenant could pre-plant a symlink at (see writeSentinelFile).
|
|
716
|
-
writeSentinelFile(alertAckFile(sessionId));
|
|
719
|
+
return writeSentinelFile(alertAckFile(sessionId));
|
|
717
720
|
}
|
|
718
721
|
|
|
719
722
|
// What the operator can actually DO — one bullet per kind of report the alert
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Clobber-by-omission guard for whole-file Writes
|
|
2
|
+
* Clobber-by-omission guard for whole-file Writes, and the confirm-by-exact-
|
|
3
|
+
* re-issue sentinel it shares with every other releasable PreToolUse refusal.
|
|
3
4
|
*
|
|
4
5
|
* Rehydration (agent-sanitizer/rehydrate) only engages when a Write's content
|
|
5
6
|
* carries a placeholder; a Write that REGENERATES a secret-bearing file and
|
|
@@ -65,12 +66,62 @@ export const CONFIRM_TTL_MS = 10 * 60_000;
|
|
|
65
66
|
* @returns {string}
|
|
66
67
|
*/
|
|
67
68
|
export function dropFingerprint(filePath, content, dropped = []) {
|
|
68
|
-
const digest = createHash("sha256").update(
|
|
69
|
+
const digest = createHash("sha256").update("drop\0").update(filePath);
|
|
70
|
+
digest.update("\0");
|
|
69
71
|
digest.update(content);
|
|
70
72
|
for (const secret of dropped) digest.update("\0").update(secret);
|
|
71
73
|
return digest.digest("hex").slice(0, 32);
|
|
72
74
|
}
|
|
73
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Fingerprint of one refusal of one exact tool call — same refusing site, same
|
|
78
|
+
* tool, same input — released by re-issuing that call. Keyed on the site too, so
|
|
79
|
+
* a call refused by two sites in turn needs a confirmation for each and one
|
|
80
|
+
* re-issue never releases a refusal the model was not shown. The input is
|
|
81
|
+
* hashed in canonical key order, so a re-issue whose keys arrive reordered is
|
|
82
|
+
* still the same call. Distinct domain tags keep it disjoint from
|
|
83
|
+
* {@link dropFingerprint}.
|
|
84
|
+
* @param {string} site
|
|
85
|
+
* @param {unknown} tool
|
|
86
|
+
* @param {unknown} toolInput
|
|
87
|
+
* @returns {string}
|
|
88
|
+
*/
|
|
89
|
+
export function reissueFingerprint(site, tool, toolInput) {
|
|
90
|
+
return createHash("sha256")
|
|
91
|
+
.update("reissue\0")
|
|
92
|
+
.update(canonicalJson([site, tool ?? null, toolInput ?? null]))
|
|
93
|
+
.digest("hex")
|
|
94
|
+
.slice(0, 32);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* JSON with every object's keys sorted, so two encodings of one value agree.
|
|
99
|
+
* @param {unknown} value
|
|
100
|
+
* @returns {string}
|
|
101
|
+
*/
|
|
102
|
+
function canonicalJson(value) {
|
|
103
|
+
return JSON.stringify(value, (_key, v) =>
|
|
104
|
+
v !== null && typeof v === "object" && !Array.isArray(v)
|
|
105
|
+
? Object.fromEntries(
|
|
106
|
+
Object.keys(v)
|
|
107
|
+
.sort()
|
|
108
|
+
.map((k) => [k, v[k]]),
|
|
109
|
+
)
|
|
110
|
+
: v,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Record a confirm sentinel for `fingerprint`, so the identical re-issue is
|
|
116
|
+
* approved by {@link consumeConfirm}. False when it could not be recorded: the
|
|
117
|
+
* caller then must not refuse, because nothing could ever release the refusal.
|
|
118
|
+
* @param {string} fingerprint
|
|
119
|
+
* @returns {boolean}
|
|
120
|
+
*/
|
|
121
|
+
export function recordConfirm(fingerprint) {
|
|
122
|
+
return writeSentinelFile(confirmMarkerPath(fingerprint));
|
|
123
|
+
}
|
|
124
|
+
|
|
74
125
|
/**
|
|
75
126
|
* The confirm sentinel's path for one fingerprint. Predictable and
|
|
76
127
|
* world-visible like the alert markers, hence the markerIsTrusted read.
|
|
@@ -81,6 +132,9 @@ export function confirmMarkerPath(fingerprint) {
|
|
|
81
132
|
return join(tmpdir(), `.claude-secret-drop-${PROJECT_HASH}-${fingerprint}`);
|
|
82
133
|
}
|
|
83
134
|
|
|
135
|
+
/** readdir codes meaning the confirm store's directory cannot be used at all. */
|
|
136
|
+
const UNUSABLE_DIR_CODES = new Set(["ENOENT", "ENOTDIR", "EACCES", "EPERM"]);
|
|
137
|
+
|
|
84
138
|
/**
|
|
85
139
|
* Delete this project's confirm sentinels older than {@link CONFIRM_TTL_MS}.
|
|
86
140
|
*
|
|
@@ -95,7 +149,18 @@ export function sweepStaleConfirms() {
|
|
|
95
149
|
const dir = tmpdir();
|
|
96
150
|
const prefix = `.claude-secret-drop-${PROJECT_HASH}-`;
|
|
97
151
|
const cutoff = Date.now() - CONFIRM_TTL_MS;
|
|
98
|
-
|
|
152
|
+
let names;
|
|
153
|
+
try {
|
|
154
|
+
names = readdirSync(dir);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
// An unusable $TMPDIR holds no sentinel to sweep, and recordConfirm already
|
|
157
|
+
// answers for it on every call; letting it throw here would abort the
|
|
158
|
+
// SessionStart scan this sweep merely rides along with.
|
|
159
|
+
const code = /** @type {NodeJS.ErrnoException} */ (err).code;
|
|
160
|
+
if (UNUSABLE_DIR_CODES.has(String(code))) return;
|
|
161
|
+
throw err;
|
|
162
|
+
}
|
|
163
|
+
for (const name of names) {
|
|
99
164
|
if (!name.startsWith(prefix)) continue;
|
|
100
165
|
const path = join(dir, name);
|
|
101
166
|
try {
|
|
@@ -150,14 +215,34 @@ export function gitTracked(filePath, spawn = spawnSync) {
|
|
|
150
215
|
/**
|
|
151
216
|
* @param {number} count
|
|
152
217
|
* @param {string} filePath
|
|
218
|
+
* @param {boolean} [confirmable] false when no confirmation could be recorded,
|
|
219
|
+
* so the reason must not offer a re-issue that would only be refused again
|
|
153
220
|
* @returns {string}
|
|
154
221
|
*/
|
|
155
|
-
function dropDeny(count, filePath) {
|
|
156
|
-
|
|
222
|
+
function dropDeny(count, filePath, confirmable = true) {
|
|
223
|
+
const head =
|
|
157
224
|
`this Write removes ${count} redacted secret value(s) from ${filePath}, and the ` +
|
|
158
|
-
`file is not tracked by git, so the secrets may be unrecoverable.
|
|
159
|
-
|
|
160
|
-
|
|
225
|
+
`file is not tracked by git, so the secrets may be unrecoverable. `;
|
|
226
|
+
const placeholders = `[REDACTED…] placeholder (or its secret's line) in the new content`;
|
|
227
|
+
return confirmable
|
|
228
|
+
? `${head}If removing them is intended, re-issue this exact Write to confirm; ` +
|
|
229
|
+
`otherwise keep each ${placeholders}`
|
|
230
|
+
: `${head}Keep each ${placeholders}, or ask the user to remove them`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The `fault` a drop deny carries when its confirmation could not be recorded:
|
|
235
|
+
* the deny could then never be released by a re-issue, so it is a sanitizer
|
|
236
|
+
* fault rather than a verdict, and the hook's fault posture decides it.
|
|
237
|
+
* @param {number} count
|
|
238
|
+
* @param {string} filePath
|
|
239
|
+
* @returns {string}
|
|
240
|
+
*/
|
|
241
|
+
function dropUnconfirmable(count, filePath) {
|
|
242
|
+
return (
|
|
243
|
+
`the secret-drop confirmation could not be recorded under ${tmpdir()}, so ` +
|
|
244
|
+
`this Write removes ${count} redacted secret value(s) from untracked ` +
|
|
245
|
+
`${filePath} without a confirmed re-issue`
|
|
161
246
|
);
|
|
162
247
|
}
|
|
163
248
|
|
|
@@ -167,22 +252,22 @@ function dropDeny(count, filePath) {
|
|
|
167
252
|
* rehydration substituted any placeholders — so a preserved secret shows up as
|
|
168
253
|
* its real value in `content`. `io` is the rehydrate-shaped I/O bag (readFile /
|
|
169
254
|
* redact / redactMap). Injectable seams: `isTracked` (the git probe),
|
|
170
|
-
* `confirmSeen`/`recordConfirm` (the sentinel state).
|
|
255
|
+
* `confirmSeen`/`recordConfirm` (the sentinel state). A deny whose confirmation
|
|
256
|
+
* could not be recorded carries `fault` (see {@link dropUnconfirmable}).
|
|
171
257
|
* @param {{file_path?: unknown, content?: unknown}} toolInput
|
|
172
258
|
* @param {import("agent-sanitizer/rehydrate").RehydrateIo} io
|
|
173
259
|
* @param {{
|
|
174
260
|
* isTracked?: (filePath: string) => boolean,
|
|
175
261
|
* confirmSeen?: (fingerprint: string) => boolean,
|
|
176
|
-
* recordConfirm?: (fingerprint: string) =>
|
|
262
|
+
* recordConfirm?: (fingerprint: string) => boolean,
|
|
177
263
|
* }} [opts]
|
|
178
|
-
* @returns {Promise<{deny: string} | null>}
|
|
264
|
+
* @returns {Promise<{deny: string, fault?: string} | null>}
|
|
179
265
|
*/
|
|
180
266
|
export async function secretDropGuard(toolInput, io, opts = {}) {
|
|
181
267
|
const {
|
|
182
268
|
isTracked = gitTracked,
|
|
183
269
|
confirmSeen = consumeConfirm,
|
|
184
|
-
recordConfirm =
|
|
185
|
-
writeSentinelFile(confirmMarkerPath(fingerprint)),
|
|
270
|
+
recordConfirm: record = recordConfirm,
|
|
186
271
|
} = opts;
|
|
187
272
|
const { file_path: filePath, content } = toolInput ?? {};
|
|
188
273
|
if (typeof filePath !== "string" || typeof content !== "string") return null;
|
|
@@ -225,8 +310,11 @@ export async function secretDropGuard(toolInput, io, opts = {}) {
|
|
|
225
310
|
// context, or a marker left by an abandoned attempt becomes a standing
|
|
226
311
|
// auto-approval for a Write the user never saw.
|
|
227
312
|
if (confirmSeen(fingerprint)) return null;
|
|
228
|
-
|
|
229
|
-
return {
|
|
313
|
+
if (record(fingerprint)) return { deny: dropDeny(dropped.length, filePath) };
|
|
314
|
+
return {
|
|
315
|
+
deny: dropDeny(dropped.length, filePath, false),
|
|
316
|
+
fault: dropUnconfirmable(dropped.length, filePath),
|
|
317
|
+
};
|
|
230
318
|
}
|
|
231
319
|
|
|
232
320
|
/**
|
|
@@ -235,8 +323,12 @@ export async function secretDropGuard(toolInput, io, opts = {}) {
|
|
|
235
323
|
* substitution — so a rehydrated secret counts as preserved and only a
|
|
236
324
|
* genuinely dropped one can trip it. A rehydrate deny short-circuits (the
|
|
237
325
|
* guard never runs), non-Write tools skip the guard entirely, and a guard
|
|
238
|
-
* deny wins over a pass/rewrite result.
|
|
239
|
-
*
|
|
326
|
+
* deny wins over a pass/rewrite result. A guard FAULT deny carries the
|
|
327
|
+
* rewrite as `release`: the open posture lets that call proceed, and it must
|
|
328
|
+
* proceed rehydrated, or every secret the rewrite restored is overwritten by
|
|
329
|
+
* its placeholder.
|
|
330
|
+
* @typedef {{updatedInput: any, context: string}} RehydrateRewrite
|
|
331
|
+
* @typedef {RehydrateRewrite | {deny: string, fault?: string, release?: RehydrateRewrite} | null} RehydrateResult
|
|
240
332
|
* @param {(tool: string, toolInput: any) => Promise<RehydrateResult>} rehydrate
|
|
241
333
|
* @param {import("agent-sanitizer/rehydrate").RehydrateIo} io
|
|
242
334
|
* @param {typeof secretDropGuard} [guard]
|
|
@@ -250,33 +342,29 @@ export function withSecretDropGuard(rehydrate, io, guard = secretDropGuard) {
|
|
|
250
342
|
const finalInput =
|
|
251
343
|
rehydrated === null ? toolInput : rehydrated.updatedInput;
|
|
252
344
|
const drop = await guard(finalInput, io);
|
|
253
|
-
|
|
345
|
+
if (drop === null) return rehydrated;
|
|
346
|
+
if (drop.fault === undefined || rehydrated === null) return drop;
|
|
347
|
+
return { ...drop, release: rehydrated };
|
|
254
348
|
};
|
|
255
349
|
}
|
|
256
350
|
|
|
257
351
|
/**
|
|
258
352
|
* True when a trusted, fresh confirm sentinel exists for `fingerprint`,
|
|
259
|
-
* consuming it so one confirmation approves exactly one
|
|
353
|
+
* consuming it so one confirmation approves exactly one call. A sentinel
|
|
260
354
|
* older than {@link CONFIRM_TTL_MS} is stale — an abandoned deny from a
|
|
261
355
|
* previous session, not a live confirmation — so it is removed and NOT
|
|
262
356
|
* honored, preventing a leftover marker from acting as a standing
|
|
263
357
|
* auto-approval. Removal is best-effort: a fresh marker we could not unlink
|
|
264
358
|
* still counted as consumed for THIS call, and a leftover empty sentinel only
|
|
265
|
-
* ever re-approves the identical
|
|
266
|
-
*
|
|
359
|
+
* ever re-approves the identical fingerprinted call the model already
|
|
360
|
+
* confirmed — and only within the TTL.
|
|
267
361
|
* @param {string} fingerprint
|
|
268
362
|
* @returns {boolean}
|
|
269
363
|
*/
|
|
270
|
-
function consumeConfirm(fingerprint) {
|
|
364
|
+
export function consumeConfirm(fingerprint) {
|
|
271
365
|
const marker = confirmMarkerPath(fingerprint);
|
|
272
366
|
if (!markerIsTrusted(marker)) return false;
|
|
273
|
-
|
|
274
|
-
try {
|
|
275
|
-
fresh = Date.now() - lstatSync(marker).mtimeMs <= CONFIRM_TTL_MS;
|
|
276
|
-
} catch {
|
|
277
|
-
// Raced away between the trust check and the stat: no sentinel, no confirm.
|
|
278
|
-
return false;
|
|
279
|
-
}
|
|
367
|
+
const fresh = confirmFresh(fingerprint);
|
|
280
368
|
try {
|
|
281
369
|
unlinkSync(marker);
|
|
282
370
|
} catch {
|
|
@@ -284,3 +372,22 @@ function consumeConfirm(fingerprint) {
|
|
|
284
372
|
}
|
|
285
373
|
return fresh;
|
|
286
374
|
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* True when a trusted confirm sentinel younger than {@link CONFIRM_TTL_MS}
|
|
378
|
+
* exists for `fingerprint`, WITHOUT consuming it — for a caller that learns
|
|
379
|
+
* only later whether the call it confirms actually proceeds, and consumes it
|
|
380
|
+
* then.
|
|
381
|
+
* @param {string} fingerprint
|
|
382
|
+
* @returns {boolean}
|
|
383
|
+
*/
|
|
384
|
+
export function confirmFresh(fingerprint) {
|
|
385
|
+
const marker = confirmMarkerPath(fingerprint);
|
|
386
|
+
if (!markerIsTrusted(marker)) return false;
|
|
387
|
+
try {
|
|
388
|
+
return Date.now() - lstatSync(marker).mtimeMs <= CONFIRM_TTL_MS;
|
|
389
|
+
} catch {
|
|
390
|
+
// Raced away between the trust check and the stat: no sentinel, no confirm.
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
@@ -34,28 +34,31 @@ const HOOK_NAME = "plugin-hooks";
|
|
|
34
34
|
|
|
35
35
|
// The dispatcher's entry in the one posture table (lib/hook-fault.mjs). It
|
|
36
36
|
// answers no single event — it is the binder in front of all of them — so it
|
|
37
|
-
// carries no stdout envelope and both arms are process-level.
|
|
37
|
+
// carries no stdout envelope and both arms are process-level. Its one fault is
|
|
38
|
+
// an unknown `--hook` mode: broken hooks.json wiring, so no hook runs for that
|
|
39
|
+
// event until the install is fixed.
|
|
38
40
|
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
// Exit 2 is the one non-zero code Claude Code treats as BLOCKING: it blocks
|
|
48
|
-
// PreToolUse and UserPromptSubmit, surfaces stderr for PostToolUse, and is
|
|
49
|
-
// harmless for SessionStart.
|
|
50
|
-
const unknownMode = (/** @type {{ message: string }} */ ctx) => ({
|
|
51
|
-
stderr: `${HOOK_NAME}: ${ctx.message}\n`,
|
|
52
|
-
exitCode: 2,
|
|
53
|
-
});
|
|
41
|
+
// OPEN exits 1, which Claude Code treats as a non-blocking hook error; through
|
|
42
|
+
// plugin/scripts/safe-launch.sh it becomes that event's fail-open warning.
|
|
43
|
+
// CLOSED exits 2, the one non-zero code Claude Code treats as BLOCKING: it
|
|
44
|
+
// blocks PreToolUse and UserPromptSubmit, surfaces stderr for PostToolUse, and
|
|
45
|
+
// is harmless for SessionStart.
|
|
46
|
+
const unknownModeLine = (/** @type {{ message: string }} */ ctx) =>
|
|
47
|
+
`${HOOK_NAME}: ${ctx.message} — hooks.json names a hook this bundle does ` +
|
|
48
|
+
`not dispatch (known: ${HOOK_MODES.join(", ")}); reinstall or update the plugin.`;
|
|
54
49
|
registerFaultPolicy(HOOK_NAME, {
|
|
55
50
|
event: null,
|
|
56
51
|
guarded: "hook payload",
|
|
57
|
-
open:
|
|
58
|
-
|
|
52
|
+
open: (ctx) => ({
|
|
53
|
+
stderr:
|
|
54
|
+
`${unknownModeLine(ctx)} This event is UNGUARDED; set ` +
|
|
55
|
+
"AGENT_SANITIZER_FAIL_OPEN=0 to block it instead.\n",
|
|
56
|
+
exitCode: 1,
|
|
57
|
+
}),
|
|
58
|
+
closed: (ctx) => ({
|
|
59
|
+
stderr: `${unknownModeLine(ctx)} Blocking (AGENT_SANITIZER_FAIL_OPEN=0).\n`,
|
|
60
|
+
exitCode: 2,
|
|
61
|
+
}),
|
|
59
62
|
});
|
|
60
63
|
|
|
61
64
|
// The packages the hooks lazy-load, each behind a thunk whose import specifier
|
|
@@ -199,14 +202,16 @@ export async function main() {
|
|
|
199
202
|
// some default hook and vet the wrong payload class. WHICH way it fails is
|
|
200
203
|
// the operator's call, taken through the one posture table like every other
|
|
201
204
|
// hook fault.
|
|
202
|
-
process.
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
new Error(`unknown hook mode ${JSON.stringify(mode)}`),
|
|
207
|
-
),
|
|
205
|
+
process.exitCode = writeFaultOutcome(
|
|
206
|
+
hookFaultOutcome(
|
|
207
|
+
HOOK_NAME,
|
|
208
|
+
new Error(`unknown hook mode ${JSON.stringify(mode)}`),
|
|
208
209
|
),
|
|
209
210
|
);
|
|
211
|
+
// Drained like the disabled arm below: under the open posture the event
|
|
212
|
+
// proceeds, and a harness left writing into a closed pipe sees EPIPE.
|
|
213
|
+
process.stdin.resume();
|
|
214
|
+
return;
|
|
210
215
|
}
|
|
211
216
|
|
|
212
217
|
// An empty envelope is a verdict, not a crash: stdout stays non-empty, so the
|