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
|
@@ -25,7 +25,7 @@ import { readFileSync } from "node:fs";
|
|
|
25
25
|
import { relative, resolve } from "node:path";
|
|
26
26
|
import {
|
|
27
27
|
awaitLazyDependency,
|
|
28
|
-
|
|
28
|
+
createEnvelopeBuffer,
|
|
29
29
|
safeErrMessage,
|
|
30
30
|
hookgateMarkerPath,
|
|
31
31
|
HookEvent,
|
|
@@ -157,36 +157,76 @@ function faultLine(ctx) {
|
|
|
157
157
|
);
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
160
|
+
/**
|
|
161
|
+
* The open-posture parts for instruction files the scan could not READ, or null
|
|
162
|
+
* when the fault is anything else. Exit 0 with an envelope, not the fault arm's
|
|
163
|
+
* bare exit 1: the hook ran and is reporting what it could not check, so the
|
|
164
|
+
* user (systemMessage) and the model (additionalContext) are both told, and the
|
|
165
|
+
* launcher forwards it rather than reporting a broken bundle.
|
|
166
|
+
* @param {import("./lib/hook-fault.mjs").FaultContext} ctx
|
|
167
|
+
* @returns {import("./lib/hook-fault.mjs").FaultParts | null}
|
|
168
|
+
*/
|
|
169
|
+
function openGapParts(ctx) {
|
|
170
|
+
if (ctx.gapNotice === undefined) return null;
|
|
171
|
+
const warning =
|
|
172
|
+
`${ctx.gapNotice}\n${HOOK_NAME}: failing open — nothing is blocked. Set ` +
|
|
173
|
+
"AGENT_SANITIZER_FAIL_OPEN=0 to have the next tool call ask instead.";
|
|
174
|
+
return {
|
|
175
|
+
stderr: `${warning}\n`,
|
|
176
|
+
envelope: {
|
|
177
|
+
systemMessage: warning,
|
|
178
|
+
hookSpecificOutput: {
|
|
179
|
+
hookEventName: HookEvent.SESSION_START,
|
|
180
|
+
additionalContext: warning,
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// This hook's entry in the one posture table (lib/hook-fault.mjs). SessionStart
|
|
187
|
+
// cannot deny, so the only enforcement either arm can reach is the cross-hook
|
|
188
|
+
// alert, which makes the PreToolUse gate ask once on the next tool call — and
|
|
189
|
+
// only the CLOSED arm arms it. An unreadable instruction file (`gapNotice`) is
|
|
190
|
+
// rendered here too: the scan worked, but whether a gap may stop the session is
|
|
191
|
+
// the same operator choice a fault is.
|
|
165
192
|
registerFaultPolicy(HOOK_NAME, {
|
|
166
193
|
event: HookEvent.SESSION_START,
|
|
167
194
|
guarded: "instruction files",
|
|
168
|
-
open: (ctx) =>
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
195
|
+
open: (ctx) =>
|
|
196
|
+
openGapParts(ctx) ?? {
|
|
197
|
+
stderr: `${faultLine(ctx)} Passing through unguarded; set AGENT_SANITIZER_FAIL_OPEN=0 to arm the tool-call gate instead.\n`,
|
|
198
|
+
exitCode: 1,
|
|
199
|
+
},
|
|
200
|
+
closed: (ctx) =>
|
|
201
|
+
ctx.gapNotice === undefined
|
|
202
|
+
? {
|
|
203
|
+
stderr: `${faultLine(ctx)} Arming the tool-call gate (AGENT_SANITIZER_FAIL_OPEN=0).\n`,
|
|
204
|
+
exitCode: 1,
|
|
205
|
+
armAlert: true,
|
|
206
|
+
}
|
|
207
|
+
: { stderr: `${ctx.gapNotice}\n`, armAlert: true },
|
|
177
208
|
});
|
|
178
209
|
|
|
179
210
|
/**
|
|
180
|
-
* Render this hook's fault
|
|
181
|
-
*
|
|
182
|
-
* posture this hook cannot
|
|
211
|
+
* Render this hook's fault (or, with `gapNotice`, its unreadable-file report)
|
|
212
|
+
* under the declared posture, and return the text (if any) that must ride in
|
|
213
|
+
* the cross-hook alert so a later gate carries the posture this hook cannot
|
|
214
|
+
* express itself.
|
|
183
215
|
* @param {unknown} err
|
|
216
|
+
* @param {string | undefined} gapNotice the {@link formatSkipped} report
|
|
217
|
+
* @param {import("./lib/hook-io.mjs").EnvelopeBuffer} out this run's one stdout
|
|
218
|
+
* buffer
|
|
184
219
|
* @returns {string[]}
|
|
185
220
|
*/
|
|
186
|
-
function reportFault(err) {
|
|
187
|
-
const outcome = hookFaultOutcome(HOOK_NAME, err);
|
|
188
|
-
|
|
189
|
-
|
|
221
|
+
function reportFault(err, gapNotice, out) {
|
|
222
|
+
const outcome = hookFaultOutcome(HOOK_NAME, err, { gapNotice });
|
|
223
|
+
// Buffered, not written: the timing wrapper may still owe stdout a slow-hook
|
|
224
|
+
// notice, and two envelopes on one stdout are invalid hook output.
|
|
225
|
+
process.exitCode = writeFaultOutcome(outcome, (chunk) =>
|
|
226
|
+
out.add(JSON.parse(chunk)),
|
|
227
|
+
);
|
|
228
|
+
if (!outcome.armAlert) return [];
|
|
229
|
+
return [gapNotice ?? /** @type {string} */ (outcome.stderr)];
|
|
190
230
|
}
|
|
191
231
|
|
|
192
232
|
/**
|
|
@@ -331,9 +371,9 @@ export function scanProject(dir = PROJECT_DIR) {
|
|
|
331
371
|
}
|
|
332
372
|
|
|
333
373
|
/**
|
|
334
|
-
* The report for targets the scan could not read
|
|
335
|
-
*
|
|
336
|
-
*
|
|
374
|
+
* The report for targets the scan could not read: a SessionStart warning under
|
|
375
|
+
* the open posture, and the alert the PreToolUse gate surfaces under the closed
|
|
376
|
+
* one — either way an incomplete scan reaches the operator rather than silence.
|
|
337
377
|
* @param {Array<{file: string, reason: string}>} skipped
|
|
338
378
|
* @returns {string}
|
|
339
379
|
*/
|
|
@@ -383,12 +423,15 @@ export async function cliMain(opts = {}) {
|
|
|
383
423
|
// reporting an overrun in band is what turned a 30-second scan from a rumor
|
|
384
424
|
// into a bug report (see lib/hook-timing.mjs).
|
|
385
425
|
const timer = startHookTimer();
|
|
426
|
+
// One stdout document per run: the scan's own envelope (a fault or the
|
|
427
|
+
// unreadable-file notice) and the slow-hook notice below fold into it.
|
|
428
|
+
const out = createEnvelopeBuffer();
|
|
386
429
|
try {
|
|
387
430
|
// Charged to provisioning when the session's setup is installing THROUGHOUT
|
|
388
431
|
// this run: that install saturates the machine, and a scan that merely
|
|
389
432
|
// waited it out has no per-call cost to report (see hook-timing.mjs).
|
|
390
433
|
await excludeConcurrentProvisioning(
|
|
391
|
-
() => runScanCli(opts),
|
|
434
|
+
() => runScanCli(opts, out),
|
|
392
435
|
() => setupRunning(hookgateMarkerPath()),
|
|
393
436
|
);
|
|
394
437
|
} finally {
|
|
@@ -396,7 +439,7 @@ export async function cliMain(opts = {}) {
|
|
|
396
439
|
HOOK_NAME,
|
|
397
440
|
timer.wallMs(),
|
|
398
441
|
HookEvent.SESSION_START,
|
|
399
|
-
|
|
442
|
+
out.emit,
|
|
400
443
|
undefined,
|
|
401
444
|
// All four windows, including the two this scan normally leaves empty: a
|
|
402
445
|
// measured 0 rules a window OUT, where an omitted one leaves the notice
|
|
@@ -407,6 +450,7 @@ export async function cliMain(opts = {}) {
|
|
|
407
450
|
hostMs: timer.hostMs(),
|
|
408
451
|
},
|
|
409
452
|
);
|
|
453
|
+
out.flush();
|
|
410
454
|
}
|
|
411
455
|
}
|
|
412
456
|
|
|
@@ -419,9 +463,10 @@ export async function cliMain(opts = {}) {
|
|
|
419
463
|
* scan?: () => ReturnType<typeof scanProject>,
|
|
420
464
|
* sessionId?: string,
|
|
421
465
|
* }} opts see {@link cliMain}
|
|
466
|
+
* @param {import("./lib/hook-io.mjs").EnvelopeBuffer} out see {@link cliMain}
|
|
422
467
|
* @returns {Promise<void>}
|
|
423
468
|
*/
|
|
424
|
-
async function runScanCli({ trace: sink, scan: runScan, sessionId }) {
|
|
469
|
+
async function runScanCli({ trace: sink, scan: runScan, sessionId }, out) {
|
|
425
470
|
// Bound best-effort: the announcements below run BEFORE the auto-clean and
|
|
426
471
|
// the alert write, with no catch above them, so a throwing host sink would
|
|
427
472
|
// abort the scan silently (see hookTrace).
|
|
@@ -452,9 +497,13 @@ async function runScanCli({ trace: sink, scan: runScan, sessionId }) {
|
|
|
452
497
|
"agent-sanitizer failed to load (node deps not installed and " +
|
|
453
498
|
"session-setup did not finish in time); run `pnpm install`",
|
|
454
499
|
),
|
|
500
|
+
undefined,
|
|
501
|
+
out,
|
|
455
502
|
),
|
|
456
503
|
);
|
|
457
504
|
persistAlert(alertParts, sessionId);
|
|
505
|
+
// process.exit skips cliMain's `finally`, which would otherwise flush.
|
|
506
|
+
out.flush();
|
|
458
507
|
process.exit(1);
|
|
459
508
|
}
|
|
460
509
|
/* c8 ignore stop */
|
|
@@ -480,14 +529,14 @@ async function runScanCli({ trace: sink, scan: runScan, sessionId }) {
|
|
|
480
529
|
scan = (runScan ?? scanProject)();
|
|
481
530
|
} catch (err) {
|
|
482
531
|
emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, { outcome: "skipped" });
|
|
483
|
-
alertParts.push(...reportFault(err));
|
|
532
|
+
alertParts.push(...reportFault(err, undefined, out));
|
|
484
533
|
persistAlert(alertParts, sessionId);
|
|
485
534
|
return;
|
|
486
535
|
}
|
|
487
536
|
const { findings: allFindings, skipped, absent, scanned } = scan;
|
|
488
537
|
|
|
489
538
|
// The three buckets of classifyReadFailure, rendered: only `skipped` may
|
|
490
|
-
// withhold "clean" and
|
|
539
|
+
// withhold "clean", and it arms the gate only under the closed posture.
|
|
491
540
|
if (skipped.length > 0) {
|
|
492
541
|
emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, {
|
|
493
542
|
outcome: "partial",
|
|
@@ -496,9 +545,13 @@ async function runScanCli({ trace: sink, scan: runScan, sessionId }) {
|
|
|
496
545
|
absent: absent.length,
|
|
497
546
|
files: allFindings.length,
|
|
498
547
|
});
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
548
|
+
alertParts.push(
|
|
549
|
+
...reportFault(
|
|
550
|
+
new Error(`${skipped.length} instruction file(s) could not be read`),
|
|
551
|
+
formatSkipped(skipped),
|
|
552
|
+
out,
|
|
553
|
+
),
|
|
554
|
+
);
|
|
502
555
|
} else if (allFindings.length === 0) {
|
|
503
556
|
emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, {
|
|
504
557
|
outcome: "clean",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { readFileSync } from "node:fs";
|
|
21
21
|
import {
|
|
22
|
-
|
|
22
|
+
createEnvelopeBuffer,
|
|
23
23
|
EmptyStdinError,
|
|
24
24
|
hookgateMarkerPath,
|
|
25
25
|
HookEvent,
|
|
@@ -268,12 +268,15 @@ export { HOOK_NAME };
|
|
|
268
268
|
export async function cliMain({ trace: sink } = {}) {
|
|
269
269
|
const timer = startHookTimer();
|
|
270
270
|
const emitTrace = hookTrace(sink);
|
|
271
|
+
// One stdout document per run: the finding/fault envelope and the slow-hook
|
|
272
|
+
// notice below fold into it, never written as two.
|
|
273
|
+
const out = createEnvelopeBuffer();
|
|
271
274
|
try {
|
|
272
275
|
// Charged to provisioning when the session's setup is installing THROUGHOUT
|
|
273
276
|
// this run: that install saturates the machine, and a scan that merely
|
|
274
277
|
// waited it out has no per-call cost to report (see hook-timing.mjs).
|
|
275
278
|
await excludeConcurrentProvisioning(
|
|
276
|
-
() => runLoadedScanCli(emitTrace),
|
|
279
|
+
() => runLoadedScanCli(emitTrace, out),
|
|
277
280
|
() => setupRunning(hookgateMarkerPath()),
|
|
278
281
|
);
|
|
279
282
|
} finally {
|
|
@@ -281,7 +284,7 @@ export async function cliMain({ trace: sink } = {}) {
|
|
|
281
284
|
HOOK_NAME,
|
|
282
285
|
timer.wallMs(),
|
|
283
286
|
HookEvent.INSTRUCTIONS_LOADED,
|
|
284
|
-
|
|
287
|
+
out.emit,
|
|
285
288
|
undefined,
|
|
286
289
|
// All four windows, including the two this scan normally leaves empty: a
|
|
287
290
|
// measured 0 rules a window OUT, where an omitted one leaves the notice
|
|
@@ -292,6 +295,7 @@ export async function cliMain({ trace: sink } = {}) {
|
|
|
292
295
|
hostMs: timer.hostMs(),
|
|
293
296
|
},
|
|
294
297
|
);
|
|
298
|
+
out.flush();
|
|
295
299
|
}
|
|
296
300
|
}
|
|
297
301
|
|
|
@@ -300,9 +304,11 @@ export async function cliMain({ trace: sink } = {}) {
|
|
|
300
304
|
* single call to bracket — every early return here is an exit that wrapper must
|
|
301
305
|
* still measure.
|
|
302
306
|
* @param {import("./lib/trace.mjs").TraceFn} emitTrace
|
|
307
|
+
* @param {import("./lib/hook-io.mjs").EnvelopeBuffer} out this run's one stdout
|
|
308
|
+
* buffer
|
|
303
309
|
* @returns {Promise<void>}
|
|
304
310
|
*/
|
|
305
|
-
async function runLoadedScanCli(emitTrace) {
|
|
311
|
+
async function runLoadedScanCli(emitTrace, out) {
|
|
306
312
|
/** @type {string | undefined} */
|
|
307
313
|
let sessionId;
|
|
308
314
|
try {
|
|
@@ -340,19 +346,19 @@ async function runLoadedScanCli(emitTrace) {
|
|
|
340
346
|
// systemMessage reaches the user, additionalContext the model. Both, because
|
|
341
347
|
// this hook cannot block and the file is already loaded: the user is the one
|
|
342
348
|
// who can act on it, and the model is the one currently reading it.
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
}),
|
|
351
|
-
);
|
|
349
|
+
out.add({
|
|
350
|
+
systemMessage: message,
|
|
351
|
+
hookSpecificOutput: {
|
|
352
|
+
hookEventName: HookEvent.INSTRUCTIONS_LOADED,
|
|
353
|
+
additionalContext: message,
|
|
354
|
+
},
|
|
355
|
+
});
|
|
352
356
|
} catch (err) {
|
|
353
357
|
emitTrace(TraceEvent.SCAN_LOADED_INSTRUCTIONS_RAN, { outcome: "skipped" });
|
|
354
358
|
const outcome = hookFaultOutcome(HOOK_NAME, err);
|
|
355
|
-
process.exitCode = writeFaultOutcome(outcome)
|
|
359
|
+
process.exitCode = writeFaultOutcome(outcome, (chunk) =>
|
|
360
|
+
out.add(JSON.parse(chunk)),
|
|
361
|
+
);
|
|
356
362
|
// `sessionId`, captured before the throw: a payload read that itself failed
|
|
357
363
|
// leaves it undefined, and the fault then lands in the shared fallback store
|
|
358
364
|
// rather than in no store at all.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.69.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/html.mjs
CHANGED
|
@@ -1592,16 +1592,17 @@ function isSelfClosedForeign(tagName, value) {
|
|
|
1592
1592
|
// under fragment parsing (scripting disabled) parse5 parses its content as
|
|
1593
1593
|
// normal markup, so scanning it is correct. Once `plaintext` opens it runs to
|
|
1594
1594
|
// EOF and never closes.
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
"
|
|
1598
|
-
"
|
|
1599
|
-
"
|
|
1600
|
-
"
|
|
1601
|
-
"
|
|
1602
|
-
"
|
|
1603
|
-
"
|
|
1604
|
-
"
|
|
1595
|
+
// Each end-tag matcher is spelled per tag, so none compiles a document's text.
|
|
1596
|
+
const RAW_TEXT_END_TAGS = new Map([
|
|
1597
|
+
["script", /<\/script(?![a-z0-9-])/i],
|
|
1598
|
+
["style", /<\/style(?![a-z0-9-])/i],
|
|
1599
|
+
["textarea", /<\/textarea(?![a-z0-9-])/i],
|
|
1600
|
+
["title", /<\/title(?![a-z0-9-])/i],
|
|
1601
|
+
["xmp", /<\/xmp(?![a-z0-9-])/i],
|
|
1602
|
+
["iframe", /<\/iframe(?![a-z0-9-])/i],
|
|
1603
|
+
["noembed", /<\/noembed(?![a-z0-9-])/i],
|
|
1604
|
+
["noframes", /<\/noframes(?![a-z0-9-])/i],
|
|
1605
|
+
["plaintext", /<\/plaintext(?![a-z0-9-])/i],
|
|
1605
1606
|
]);
|
|
1606
1607
|
|
|
1607
1608
|
// Keywords that take the parent's value of an inherited property: `revert`
|
|
@@ -2608,9 +2609,9 @@ function scanInlineChildren(node, text, ranges, warned) {
|
|
|
2608
2609
|
// "Inside an unterminated tag / bogus comment" — parse5 absorbs following
|
|
2609
2610
|
// markup into it until the next `>`.
|
|
2610
2611
|
let absorbing = false;
|
|
2611
|
-
// Non-null while inside a raw-text element (its
|
|
2612
|
-
// is opaque until
|
|
2613
|
-
let
|
|
2612
|
+
// Non-null while inside a raw-text element (the matcher for its end tag);
|
|
2613
|
+
// content is opaque until that end tag.
|
|
2614
|
+
let rawTextEnd = /** @type {RegExp | null} */ (null);
|
|
2614
2615
|
// End offset of the last html node processed; the raw slice from here to the
|
|
2615
2616
|
// next html node is what parse5 tokenizes between them.
|
|
2616
2617
|
let prevEnd = node.position.start.offset;
|
|
@@ -2622,10 +2623,9 @@ function scanInlineChildren(node, text, ranges, warned) {
|
|
|
2622
2623
|
// into the absorb state before deciding what to do with this html node.
|
|
2623
2624
|
absorbing = foldAbsorb(absorbing, text.slice(prevEnd, base));
|
|
2624
2625
|
|
|
2625
|
-
if (
|
|
2626
|
+
if (rawTextEnd) {
|
|
2626
2627
|
// Raw-text content is opaque; only the matching end tag ends the region.
|
|
2627
|
-
if (
|
|
2628
|
-
rawText = null;
|
|
2628
|
+
if (rawTextEnd.test(value)) rawTextEnd = null;
|
|
2629
2629
|
} else if (state.depth > 0) {
|
|
2630
2630
|
updateHiddenState(state, value, end, ranges);
|
|
2631
2631
|
} else if (!absorbing) {
|
|
@@ -2655,7 +2655,7 @@ function scanInlineChildren(node, text, ranges, warned) {
|
|
|
2655
2655
|
if (el) {
|
|
2656
2656
|
// A raw-text open tag starts an opaque region (a self-closing `/>`
|
|
2657
2657
|
// does not apply to these in HTML — they always open).
|
|
2658
|
-
|
|
2658
|
+
rawTextEnd = RAW_TEXT_END_TAGS.get(el.tagName) ?? null;
|
|
2659
2659
|
// `el` is one tag parsed alone, so a `<template>` inside an inline
|
|
2660
2660
|
// `<svg>`/`<math>` reads as HTML here and is reported, where the
|
|
2661
2661
|
// source branch, which sees the namespace, does not: this errs toward
|
package/src/output.mjs
CHANGED
|
@@ -365,8 +365,9 @@ function processLayer1(text, sgrCarveOut) {
|
|
|
365
365
|
* Refuse to START a parse with no budget left. Each of Layers 2/3 parses the
|
|
366
366
|
* whole document in ONE synchronous call, so nothing interrupts it, and a host
|
|
367
367
|
* that kills the overrun hook shows the RAW text. Called before EACH parse, and
|
|
368
|
-
* only after a pre-gate passed, so a declined call costs no time.
|
|
369
|
-
*
|
|
368
|
+
* only after a pre-gate passed, so a declined call costs no time. Throws, as
|
|
369
|
+
* Layer 4 does: whether the unchecked text then passes or is withheld is the
|
|
370
|
+
* caller's fault posture, never this layer's.
|
|
370
371
|
* @param {Deadline | undefined} deadline
|
|
371
372
|
* @returns {void}
|
|
372
373
|
*/
|
|
@@ -374,8 +375,8 @@ function refuseIfSpent(deadline) {
|
|
|
374
375
|
if (deadline && deadline.remainingMs() <= 0)
|
|
375
376
|
throw new Error(
|
|
376
377
|
"CRITICAL: the sanitization time budget ran out before the hidden-HTML " +
|
|
377
|
-
"and exfil-URL layers finished, so this text was not fully checked
|
|
378
|
-
"
|
|
378
|
+
"and exfil-URL layers finished, so this text was not fully checked " +
|
|
379
|
+
"and must be treated as UNSANITIZED.",
|
|
379
380
|
);
|
|
380
381
|
}
|
|
381
382
|
|
package/src/rehydrate.mjs
CHANGED
|
@@ -823,6 +823,12 @@ function isCandidate(tool, ti) {
|
|
|
823
823
|
* there is nothing to do. Throws only on internal error (the caller fails
|
|
824
824
|
* closed).
|
|
825
825
|
*
|
|
826
|
+
* A deny carrying `fault` is not a verdict on the input but this module being
|
|
827
|
+
* unable to do its job (an unreadable target, a redactor map that fails
|
|
828
|
+
* validation): `fault` states what proceeding un-rehydrated would write, and the
|
|
829
|
+
* caller's failure posture decides whether the deny stands. A deny without it
|
|
830
|
+
* is a verdict and stands in every posture.
|
|
831
|
+
*
|
|
826
832
|
* `io` is the injected I/O (file read + redactor map/plain). `hint` is the
|
|
827
833
|
* redaction-placeholder prefix (defaults to {@link DEFAULT_HINT}); override it
|
|
828
834
|
* only if the injected redactor emits a different placeholder shape.
|
|
@@ -830,7 +836,7 @@ function isCandidate(tool, ti) {
|
|
|
830
836
|
* @param {any} toolInput
|
|
831
837
|
* @param {RehydrateIo} io
|
|
832
838
|
* @param {{ hint?: string }} [options]
|
|
833
|
-
* @returns {Promise<{updatedInput: any, context: string} | {deny: string} | null>}
|
|
839
|
+
* @returns {Promise<{updatedInput: any, context: string} | {deny: string, fault?: string} | null>}
|
|
834
840
|
*/
|
|
835
841
|
export async function rehydrateRedacted(
|
|
836
842
|
tool,
|
|
@@ -911,12 +917,17 @@ export async function rehydrateRedacted(
|
|
|
911
917
|
if (tool !== "Write") throw err;
|
|
912
918
|
return null;
|
|
913
919
|
}
|
|
920
|
+
const cause = nodeErr?.code ?? nodeErr?.message;
|
|
914
921
|
return {
|
|
915
922
|
deny:
|
|
916
923
|
`could not read ${toolInput.file_path} to rehydrate its secrets ` +
|
|
917
|
-
`(${
|
|
924
|
+
`(${cause}); the file likely still exists, so writing ` +
|
|
918
925
|
`the placeholder text as-is risks overwriting a real secret. Retry the read, or ` +
|
|
919
926
|
`ask the user to make this change directly`,
|
|
927
|
+
fault:
|
|
928
|
+
`${toolInput.file_path} could not be read (${cause}) to rehydrate its ` +
|
|
929
|
+
`secrets, so this call is not rehydrated and its ${hint}…] placeholder ` +
|
|
930
|
+
`text is written to the file literally`,
|
|
920
931
|
};
|
|
921
932
|
}
|
|
922
933
|
const { layer1Cleaned, cleaned } = layer1View(content);
|
|
@@ -949,8 +960,14 @@ export async function rehydrateRedacted(
|
|
|
949
960
|
// reaches its own resolver-backed pass-through.
|
|
950
961
|
if (tool === "MultiEdit") return multiEditDeny(toolInput.file_path);
|
|
951
962
|
if (!hinted) return null;
|
|
963
|
+
// A verdict, not a fault: the file itself defeats mapping on every call, so
|
|
964
|
+
// a released re-issue would overwrite the real secret with its placeholder.
|
|
965
|
+
// The escape is to stop writing the placeholder, which the reason names.
|
|
952
966
|
return {
|
|
953
|
-
deny:
|
|
967
|
+
deny:
|
|
968
|
+
`cannot resolve redaction placeholders in ${toolInput.file_path}: ` +
|
|
969
|
+
`${mapped.unmappable}. Leave every ${hint}…] placeholder out of this ` +
|
|
970
|
+
`call (edit around it), or ask the user to make this change`,
|
|
954
971
|
};
|
|
955
972
|
}
|
|
956
973
|
// The redactor emits code-point offsets; the offset machinery below works in
|
|
@@ -970,9 +987,8 @@ export async function rehydrateRedacted(
|
|
|
970
987
|
// before acting: construction re-checks range/ordering (the throws in
|
|
971
988
|
// assertPairsOrdered and pairsToUtf16), viewMapDefect proves the view
|
|
972
989
|
// reconstructs `cleaned` exactly. A defective map DENIES for every tool and
|
|
973
|
-
// hint state
|
|
974
|
-
//
|
|
975
|
-
// OPEN, i.e. the unsanitized placeholder write this module exists to prevent.
|
|
990
|
+
// hint state with a `fault`, never a throw: the deny lets the caller refuse
|
|
991
|
+
// the call once and name what proceeding writes, which a throw cannot.
|
|
976
992
|
let view = null;
|
|
977
993
|
let mapDefect = null;
|
|
978
994
|
try {
|
|
@@ -993,14 +1009,25 @@ export async function rehydrateRedacted(
|
|
|
993
1009
|
// Reaching this line at all means the fast pass-through did not fire — the
|
|
994
1010
|
// file provably holds redacted content or diverges from its view — so an
|
|
995
1011
|
// unhinted pass-through here would hand the real Edit bytes inside spans
|
|
996
|
-
// nothing can vet (the R1 hidden-span oracle). Deny
|
|
997
|
-
//
|
|
1012
|
+
// nothing can vet (the R1 hidden-span oracle). Deny, as a fault: the
|
|
1013
|
+
// engine, not the input, is what is wrong.
|
|
998
1014
|
if (tool === "MultiEdit") return multiEditDeny(toolInput.file_path);
|
|
1015
|
+
// A Write replaces the whole file, and the secret-drop guard reads this
|
|
1016
|
+
// same broken map, so the loss it would name has to be named here instead.
|
|
1017
|
+
const dropped =
|
|
1018
|
+
tool === "Write"
|
|
1019
|
+
? `; it replaces the whole file, so any redacted secret it does not ` +
|
|
1020
|
+
`reproduce is removed, and which secrets those are cannot be checked`
|
|
1021
|
+
: "";
|
|
999
1022
|
return {
|
|
1000
1023
|
deny:
|
|
1001
1024
|
`cannot safely edit ${toolInput.file_path}: ${mapDefect}; the file holds ` +
|
|
1002
1025
|
`redacted content whose location cannot be trusted, so no edit can be ` +
|
|
1003
|
-
`verified. Retry later, or ask the user to make this change`,
|
|
1026
|
+
`verified${dropped}. Retry later, or ask the user to make this change`,
|
|
1027
|
+
fault:
|
|
1028
|
+
`${mapDefect} for ${toolInput.file_path}, so this call is not ` +
|
|
1029
|
+
`rehydrated: any ${hint}…] placeholder text in it is written to the ` +
|
|
1030
|
+
`file literally, and it is applied without the redacted-span checks${dropped}`,
|
|
1004
1031
|
};
|
|
1005
1032
|
}
|
|
1006
1033
|
// View identical to disk: any placeholders in an Edit's old_string are
|
|
@@ -110,8 +110,10 @@ export type FaultContext = Record<string, any> & {
|
|
|
110
110
|
/**
|
|
111
111
|
* A hook's declared posture. There is deliberately no way for a policy to pin
|
|
112
112
|
* the CLOSED arm: AGENT_SANITIZER_FAIL_OPEN alone decides, so no hook fault can
|
|
113
|
-
* withhold a tool output
|
|
114
|
-
*
|
|
113
|
+
* withhold a tool output, block a prompt, or hold a tool call for good unless the
|
|
114
|
+
* operator set the knob to `0` (an open arm may refuse a call once, releasing the
|
|
115
|
+
* exact re-issue). A pin turns any bug in the guarded layer into every tool call
|
|
116
|
+
* failing.
|
|
115
117
|
*/
|
|
116
118
|
export type FaultPolicy = {
|
|
117
119
|
event: string | null;
|
|
@@ -286,6 +286,25 @@ export function safeErrMessage(err: unknown, cap?: number): string;
|
|
|
286
286
|
* @returns {void}
|
|
287
287
|
*/
|
|
288
288
|
export function emitHookResponse(hookEventName: string, fields: Record<string, unknown>): void;
|
|
289
|
+
/**
|
|
290
|
+
* @typedef {{
|
|
291
|
+
* add: (envelope: Record<string, any>) => void,
|
|
292
|
+
* emit: (hookEventName: string, fields: Record<string, unknown>) => void,
|
|
293
|
+
* flush: () => void,
|
|
294
|
+
* }} EnvelopeBuffer
|
|
295
|
+
*/
|
|
296
|
+
/**
|
|
297
|
+
* Collect everything a hook run wants to say on stdout and write it as exactly
|
|
298
|
+
* ONE JSON object on `flush`. Claude Code parses a hook's stdout as a single
|
|
299
|
+
* document, so two envelopes written by two reporters (a fault notice, then the
|
|
300
|
+
* slow-hook notice from the timing wrapper's `finally`) concatenate into invalid
|
|
301
|
+
* output and the host DROPS the whole response — the model never sees either
|
|
302
|
+
* warning. Every reporter in a run therefore adds here instead of writing, and
|
|
303
|
+
* the entry point flushes once, last.
|
|
304
|
+
* @param {(chunk: string) => void} [write] injectable stdout sink, for tests
|
|
305
|
+
* @returns {EnvelopeBuffer}
|
|
306
|
+
*/
|
|
307
|
+
export function createEnvelopeBuffer(write?: (chunk: string) => void): EnvelopeBuffer;
|
|
289
308
|
/**
|
|
290
309
|
* The directory the session-lifetime stores live under: the alert markers and
|
|
291
310
|
* findings (invisible-alert.mjs) and the reveal sidecars (reveal.mjs). They must
|
|
@@ -522,14 +541,16 @@ export function touchLiveState(path: string): void;
|
|
|
522
541
|
* file the hook's user owns. Unlink any existing entry first (removing a squatted
|
|
523
542
|
* symlink), then create exclusively (O_EXCL) so a symlink re-planted in the race
|
|
524
543
|
* window fails the open rather than being dereferenced. Content is irrelevant —
|
|
525
|
-
* callers test only for existence — so the file is left empty.
|
|
526
|
-
* missing/read-only $TMPDIR or a lost race
|
|
527
|
-
*
|
|
528
|
-
*
|
|
544
|
+
* callers test only for existence — so the file is left empty. Never throws: a
|
|
545
|
+
* missing/read-only $TMPDIR or a lost race leaves the sentinel absent, and the
|
|
546
|
+
* return value says so. A caller whose refusal is released only by a later read
|
|
547
|
+
* of this sentinel must branch on it, because a refusal that records nothing can
|
|
548
|
+
* never be released.
|
|
529
549
|
* @param {string} path
|
|
530
|
-
* @returns {
|
|
550
|
+
* @returns {boolean} whether a sentinel this uid owns now exists at `path`
|
|
551
|
+
* (the same test {@link markerIsTrusted} applies when a reader looks for it)
|
|
531
552
|
*/
|
|
532
|
-
export function writeSentinelFile(path: string):
|
|
553
|
+
export function writeSentinelFile(path: string): boolean;
|
|
533
554
|
/**
|
|
534
555
|
* Write `content` to `path` without following a symlink a co-tenant may have
|
|
535
556
|
* pre-planted there — the content-bearing counterpart to writeSentinelFile. These
|
|
@@ -635,6 +656,11 @@ export const STATE_ROOT: string;
|
|
|
635
656
|
* says it locks may be judged by the lock.
|
|
636
657
|
*/
|
|
637
658
|
export const SETUP_LOCK_DECLARATION: "flock";
|
|
659
|
+
export type EnvelopeBuffer = {
|
|
660
|
+
add: (envelope: Record<string, any>) => void;
|
|
661
|
+
emit: (hookEventName: string, fields: Record<string, unknown>) => void;
|
|
662
|
+
flush: () => void;
|
|
663
|
+
};
|
|
638
664
|
/**
|
|
639
665
|
* EVERY process-wide slot these helpers keep — the four a host can observe or
|
|
640
666
|
* steer, so a second instance that adopts this object is steered in all four at
|
|
@@ -193,11 +193,14 @@ export function alertAcknowledged(sessionId?: string): boolean;
|
|
|
193
193
|
/**
|
|
194
194
|
* Record that the gate has surfaced its blocking ask, so later tool calls get a
|
|
195
195
|
* passive reminder instead of an ask on every call. Session-keyed, so the next
|
|
196
|
-
* session re-asks once without anything having to clear this.
|
|
196
|
+
* session re-asks once without anything having to clear this. Returns false
|
|
197
|
+
* when the ack could not be recorded, so the caller knows the next call will
|
|
198
|
+
* ask again: under the fail-open posture it surfaces the finding as a warning
|
|
199
|
+
* rather than asking on every tool call for the rest of the session.
|
|
197
200
|
* @param {string} [sessionId]
|
|
198
|
-
* @returns {
|
|
201
|
+
* @returns {boolean}
|
|
199
202
|
*/
|
|
200
|
-
export function acknowledgeAlert(sessionId?: string):
|
|
203
|
+
export function acknowledgeAlert(sessionId?: string): boolean;
|
|
201
204
|
/**
|
|
202
205
|
* The blocking ask. The heading states only that the scan did not finish clean:
|
|
203
206
|
* the alert carries injection findings, unreadable targets, or a scanner fault,
|