agent-sanitizer 2.67.2 → 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 +52 -39
- package/claude-hooks/lib/env-config.mjs +2 -2
- package/claude-hooks/lib/hook-fault.mjs +13 -35
- package/claude-hooks/lib/hook-io.mjs +84 -9
- package/claude-hooks/lib/hook-timing.mjs +26 -6
- package/claude-hooks/lib/invisible-alert.mjs +6 -3
- package/claude-hooks/lib/redactor-client.mjs +23 -23
- package/claude-hooks/lib/secret-annotate.mjs +36 -66
- package/claude-hooks/lib/secret-drop-guard.mjs +136 -29
- package/claude-hooks/plugin-hooks.mjs +30 -27
- package/claude-hooks/pretooluse-sanitize.mjs +288 -64
- package/claude-hooks/sanitize-output.mjs +27 -40
- package/claude-hooks/sanitize-user-prompt.mjs +28 -17
- package/claude-hooks/scan-invisible-chars.mjs +86 -35
- package/claude-hooks/scan-loaded-instructions.mjs +20 -16
- package/package.json +1 -1
- package/src/html.mjs +17 -17
- package/src/output.mjs +20 -19
- package/src/rehydrate.mjs +36 -9
- package/types/claude-hooks/lib/hook-fault.d.mts +8 -73
- package/types/claude-hooks/lib/hook-io.d.mts +32 -6
- package/types/claude-hooks/lib/hook-timing.d.mts +9 -4
- package/types/claude-hooks/lib/invisible-alert.d.mts +6 -3
- package/types/claude-hooks/lib/redactor-client.d.mts +3 -3
- package/types/claude-hooks/lib/secret-annotate.d.mts +11 -17
- 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-output.d.mts +1 -1
- package/types/claude-hooks/sanitize-user-prompt.d.mts +3 -2
- package/types/claude-hooks/scan-invisible-chars.d.mts +3 -3
- package/types/output.d.mts +4 -5
- package/types/rehydrate.d.mts +8 -1
|
@@ -17,70 +17,25 @@ import charset from "../../python/agent_sanitizer/data/invisible-charset.json" w
|
|
|
17
17
|
// Invisible characters an attacker can splice between a value's characters to
|
|
18
18
|
// break an exact-substring pre-gate while the daemon's redactor still matches
|
|
19
19
|
// across them. Built from the SAME pinned charset Layer 1 strips and the daemon
|
|
20
|
-
// tolerates (`
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
// all. A run of zero-or-more is allowed at each interior gap, so the plain
|
|
26
|
-
// value still matches (a superset of `includes`). The required literals between
|
|
27
|
-
// every gap are what bound the match — a `*` gap is never adjacent to another
|
|
28
|
-
// gap, so there is no ambiguity to backtrack over and no ReDoS. (The class size
|
|
29
|
-
// is irrelevant to that: a character class matches in O(1) whether it holds one
|
|
30
|
-
// member or all 435.) That argument holds only while no literal is itself a
|
|
31
|
-
// member of the class, so {@link envValueRegex} drops the value's own invisible
|
|
32
|
-
// code points before it splices: `a<ZWSP>a` would otherwise build
|
|
33
|
-
// `a[…]*<ZWSP>[…]*a`, whose two stars share the literal and backtrack
|
|
34
|
-
// quadratically over a long run of ZWSP.
|
|
35
|
-
const ENV_INVIS_CODEPOINTS = [
|
|
36
|
-
...new Set([...charset.cf_codepoints, ...charset.extra_codepoints]),
|
|
37
|
-
].sort((a, b) => a - b);
|
|
38
|
-
const ENV_INVIS_SET = new Set(ENV_INVIS_CODEPOINTS);
|
|
39
|
-
const ENV_INVIS_RUN =
|
|
20
|
+
// tolerates (`_env_value_spans` in agent_sanitizer.secrets.engine), not
|
|
21
|
+
// a hand-curated subset: a subset pre-gate silently under-matches whenever this
|
|
22
|
+
// gate runs on text Layer 1 has not already stripped, and a value spliced with a
|
|
23
|
+
// code point in the gap then never reaches the daemon at all.
|
|
24
|
+
const ENV_INVIS_RE = new RegExp(
|
|
40
25
|
"[" +
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
26
|
+
[...new Set([...charset.cf_codepoints, ...charset.extra_codepoints])]
|
|
27
|
+
.map((cp) => `\\u{${cp.toString(16)}}`)
|
|
28
|
+
.join("") +
|
|
29
|
+
"]",
|
|
30
|
+
"gu",
|
|
31
|
+
);
|
|
46
32
|
|
|
47
33
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* `u` flag, which the astral `\u{…}` class members in {@link ENV_INVIS_RUN}
|
|
52
|
-
* require.
|
|
53
|
-
* Memoized per distinct value: {@link ENV_INVIS_RUN} renders ~435 code points
|
|
54
|
-
* as ~4 KB of source and is joined at EVERY interior gap, so a 20-char secret
|
|
55
|
-
* compiles a ~75 KB pattern — and `hasEnvBoundSecret` builds one per configured
|
|
56
|
-
* var on every PostToolUse output. Env values are stable for the process's
|
|
57
|
-
* lifetime, so the cache is bounded by the number of distinct values. Sharing an
|
|
58
|
-
* instance is safe because the regex carries no `g`/`y` flag, hence no
|
|
59
|
-
* `lastIndex` state to leak between calls.
|
|
60
|
-
* @param {string} value
|
|
61
|
-
* @returns {RegExp}
|
|
34
|
+
* `s` with every charset invisible removed.
|
|
35
|
+
* @param {string} s
|
|
36
|
+
* @returns {string}
|
|
62
37
|
*/
|
|
63
|
-
|
|
64
|
-
let re = ENV_VALUE_REGEX_CACHE.get(value);
|
|
65
|
-
if (re === undefined) {
|
|
66
|
-
const visible = [...value].filter(
|
|
67
|
-
(ch) => !ENV_INVIS_SET.has(/** @type {number} */ (ch.codePointAt(0))),
|
|
68
|
-
);
|
|
69
|
-
// A value made only of invisibles has no literal to anchor a gap on: match
|
|
70
|
-
// it exactly rather than build a pattern that is nothing but gaps.
|
|
71
|
-
re = new RegExp(
|
|
72
|
-
visible.length > 0
|
|
73
|
-
? visible.map(escapeLiteral).join(ENV_INVIS_RUN)
|
|
74
|
-
: [...value].map(escapeLiteral).join(""),
|
|
75
|
-
"u",
|
|
76
|
-
);
|
|
77
|
-
ENV_VALUE_REGEX_CACHE.set(value, re);
|
|
78
|
-
}
|
|
79
|
-
return re;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** value → compiled matcher; see {@link envValueRegex}. */
|
|
83
|
-
const ENV_VALUE_REGEX_CACHE = new Map();
|
|
38
|
+
const stripInvisibles = (s) => s.replace(ENV_INVIS_RE, "");
|
|
84
39
|
|
|
85
40
|
/**
|
|
86
41
|
* True when tool output contains the literal value of a configured env-bound
|
|
@@ -88,19 +43,34 @@ const ENV_VALUE_REGEX_CACHE = new Map();
|
|
|
88
43
|
* credential, so the pre-gate must also fire on the value itself — otherwise
|
|
89
44
|
* the engine's env-bound redaction never runs. Invisible-tolerant so a
|
|
90
45
|
* value with spliced Cf chars (which the daemon still redacts) trips it too.
|
|
46
|
+
*
|
|
47
|
+
* Both sides are stripped and compared as substrings, which is exactly
|
|
48
|
+
* equivalent: the value's visible characters appear contiguously in the
|
|
49
|
+
* stripped text iff they appear in the original separated only by invisibles.
|
|
50
|
+
* It is linear and independent of the value's length — a regex with an
|
|
51
|
+
* invisible class at every gap grows ~4 KB per character, and a long secret (a
|
|
52
|
+
* PEM key) overflows V8's regex size limit and throws on every output.
|
|
53
|
+
*
|
|
54
|
+
* Callers pass post-Layer-1 text: terminal escapes are already gone, so a value
|
|
55
|
+
* split by one is contiguous here. Raw text spliced with an escape is not
|
|
56
|
+
* matched.
|
|
91
57
|
* @param {string} text
|
|
92
58
|
* @param {NodeJS.ProcessEnv} [env]
|
|
93
59
|
* @returns {boolean}
|
|
94
60
|
*/
|
|
95
61
|
export function hasEnvBoundSecret(text, env = process.env) {
|
|
96
62
|
const minLen = minEnvSecretLen();
|
|
63
|
+
/** @type {string | undefined} */
|
|
64
|
+
let strippedText;
|
|
97
65
|
return envBoundSecretVars(env).some((name) => {
|
|
98
66
|
const value = env[name];
|
|
99
|
-
// Code-point length
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
);
|
|
67
|
+
// Code-point length, so an astral char counts once, not as two UTF-16 units.
|
|
68
|
+
if (!value || [...value].length < minLen) return false;
|
|
69
|
+
const needle = stripInvisibles(value);
|
|
70
|
+
// A value made only of invisibles has no visible character to anchor on:
|
|
71
|
+
// match it exactly rather than as the empty string, which matches anything.
|
|
72
|
+
if (needle === "") return text.includes(value);
|
|
73
|
+
strippedText ??= stripInvisibles(text);
|
|
74
|
+
return strippedText.includes(needle);
|
|
105
75
|
});
|
|
106
76
|
}
|
|
@@ -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
|
+
}
|
|
@@ -26,7 +26,6 @@ import {
|
|
|
26
26
|
} from "./lib/hook-io.mjs";
|
|
27
27
|
import {
|
|
28
28
|
registerFaultPolicy,
|
|
29
|
-
KNOB_DECIDES,
|
|
30
29
|
hookFaultOutcome,
|
|
31
30
|
writeFaultOutcome,
|
|
32
31
|
} from "./lib/hook-fault.mjs";
|
|
@@ -35,29 +34,31 @@ const HOOK_NAME = "plugin-hooks";
|
|
|
35
34
|
|
|
36
35
|
// The dispatcher's entry in the one posture table (lib/hook-fault.mjs). It
|
|
37
36
|
// answers no single event — it is the binder in front of all of them — so it
|
|
38
|
-
// 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.
|
|
39
40
|
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
// Exit 2 is the one non-zero code Claude Code treats as BLOCKING: it blocks
|
|
49
|
-
// PreToolUse and UserPromptSubmit, surfaces stderr for PostToolUse, and is
|
|
50
|
-
// harmless for SessionStart.
|
|
51
|
-
const unknownMode = (/** @type {{ message: string }} */ ctx) => ({
|
|
52
|
-
stderr: `${HOOK_NAME}: ${ctx.message}\n`,
|
|
53
|
-
exitCode: 2,
|
|
54
|
-
});
|
|
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.`;
|
|
55
49
|
registerFaultPolicy(HOOK_NAME, {
|
|
56
50
|
event: null,
|
|
57
51
|
guarded: "hook payload",
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
+
}),
|
|
61
62
|
});
|
|
62
63
|
|
|
63
64
|
// The packages the hooks lazy-load, each behind a thunk whose import specifier
|
|
@@ -90,7 +91,7 @@ const LAZY_LOADERS = {
|
|
|
90
91
|
* as a non-blocking hook error and shows the raw tool output. That is a
|
|
91
92
|
* fail-OPEN on the very hook that withholds secrets. Registering what resolves
|
|
92
93
|
* and leaving the rest absent hands the gap to each hook's own lazyImport
|
|
93
|
-
* guard,
|
|
94
|
+
* guard, which answers it loudly under the operator's fault posture.
|
|
94
95
|
* @returns {Promise<void>}
|
|
95
96
|
*/
|
|
96
97
|
async function registerAvailableModules() {
|
|
@@ -201,14 +202,16 @@ export async function main() {
|
|
|
201
202
|
// some default hook and vet the wrong payload class. WHICH way it fails is
|
|
202
203
|
// the operator's call, taken through the one posture table like every other
|
|
203
204
|
// hook fault.
|
|
204
|
-
process.
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
new Error(`unknown hook mode ${JSON.stringify(mode)}`),
|
|
209
|
-
),
|
|
205
|
+
process.exitCode = writeFaultOutcome(
|
|
206
|
+
hookFaultOutcome(
|
|
207
|
+
HOOK_NAME,
|
|
208
|
+
new Error(`unknown hook mode ${JSON.stringify(mode)}`),
|
|
210
209
|
),
|
|
211
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;
|
|
212
215
|
}
|
|
213
216
|
|
|
214
217
|
// An empty envelope is a verdict, not a crash: stdout stays non-empty, so the
|