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
|
@@ -50,11 +50,7 @@ import { registerFaultPolicy, hookFaultOutcome } from "./lib/hook-fault.mjs";
|
|
|
50
50
|
import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
|
|
51
51
|
import { hookTrace, TraceEvent } from "./lib/trace.mjs";
|
|
52
52
|
import { hasEnvBoundSecret } from "./lib/secret-annotate.mjs";
|
|
53
|
-
import {
|
|
54
|
-
digestFlaggingEnabled,
|
|
55
|
-
secretsEnabled,
|
|
56
|
-
SECRETS_ENABLED_ENV,
|
|
57
|
-
} from "./lib/env-config.mjs";
|
|
53
|
+
import { digestFlaggingEnabled, secretsEnabled } from "./lib/env-config.mjs";
|
|
58
54
|
import {
|
|
59
55
|
persistReveal,
|
|
60
56
|
persistSpan,
|
|
@@ -120,16 +116,17 @@ const HOOK_NAME = "sanitize-output";
|
|
|
120
116
|
// reaches the model-facing context. Exported so tests assert it by reference.
|
|
121
117
|
export const REVEAL_WITHHELD_WARNING = withheldWarning("reveal sidecar");
|
|
122
118
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Total wall-clock budget for one hook invocation's blocking Layer-4 redactor
|
|
121
|
+
* calls, SHARED across every string leaf of the tool output. Each call gets the
|
|
122
|
+
* budget remaining at that moment; once it is spent, a further secret-shaped
|
|
123
|
+
* leaf throws, answered under the operator's fault posture. The per-call
|
|
124
|
+
* timeouts bound each call but not their SUM, and a structured output with many
|
|
125
|
+
* secret-shaped leaves could otherwise run past the PostToolUse hook kill,
|
|
126
|
+
* which shows the RAW output with no warning. The default sits above one slow
|
|
127
|
+
* leaf (a cold redactor respawn plus a full scan) and far under the hook
|
|
128
|
+
* timeout; env-tunable so tests can drive the exhausted-budget path fast.
|
|
129
|
+
*/
|
|
133
130
|
const SANITIZE_BUDGET_MS = readKnob("_AGENT_SANITIZER_SANITIZE_BUDGET_MS");
|
|
134
131
|
|
|
135
132
|
// Web-ingress tools always get the Layer 2 HTML rewrite; local tools — Read,
|
|
@@ -172,11 +169,13 @@ function isUntrustedIngress(toolName) {
|
|
|
172
169
|
|
|
173
170
|
/**
|
|
174
171
|
* Redact secrets via the long-lived redactor daemon (lib/redactor-client.mjs).
|
|
175
|
-
* Returns `{text, found}` or null when nothing was redacted; throws
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
172
|
+
* Returns `{text, found}` or null when nothing was redacted; throws when
|
|
173
|
+
* secret-shaped text cannot be vetted, which the CLI answers under the
|
|
174
|
+
* operator's fault posture. The cheap pre-gate runs first so plain output never
|
|
175
|
+
* touches the daemon. `text` is post-Layer-1, so it carries no terminal escape
|
|
176
|
+
* and the pre-gate needs no escape-stripped view. A transient daemon failure
|
|
177
|
+
* fails only THIS call — no session-wide sentinel — and the client respawns a
|
|
178
|
+
* dead daemon on the next call.
|
|
180
179
|
* @param {string} text
|
|
181
180
|
* @param {boolean} webIngress
|
|
182
181
|
* @param {{remainingMs: () => number}} [deadline] shared wall-clock budget
|
|
@@ -288,9 +287,9 @@ export async function sanitizeText(
|
|
|
288
287
|
// Layer 4 — OPT-IN (`secretsOn`): with the knob unset the seam gets no
|
|
289
288
|
// redact callback at all, so plain output never spawns the daemon and no
|
|
290
289
|
// placeholder ever enters the model's view. When it runs, the seam rethrows
|
|
291
|
-
// a redactor throw wrapped, and this hook's fault policy
|
|
292
|
-
//
|
|
293
|
-
// operator's terminal here first: the
|
|
290
|
+
// a redactor throw wrapped, and this hook's fault policy answers it under
|
|
291
|
+
// the operator's AGENT_SANITIZER_FAIL_OPEN posture. Surface the failure to
|
|
292
|
+
// the operator's terminal here first: the fault note rides in what only
|
|
294
293
|
// the model sees, so a degraded redactor would otherwise be invisible to
|
|
295
294
|
// the human.
|
|
296
295
|
redact: !secretsOn
|
|
@@ -579,7 +578,7 @@ function failClosedParts(
|
|
|
579
578
|
* fail-OPEN by default — a warning context and NO `updatedToolOutput`, leaving
|
|
580
579
|
* the original tool output in the model's view — or the fail-closed
|
|
581
580
|
* suppression of {@link emitFailClosed} when the caller set
|
|
582
|
-
* AGENT_SANITIZER_FAIL_OPEN=0
|
|
581
|
+
* AGENT_SANITIZER_FAIL_OPEN=0.
|
|
583
582
|
*
|
|
584
583
|
* This is the hook where the two postures diverge the most, so state the open
|
|
585
584
|
* one plainly: several of these layers throw on inputs an attacker composes (a
|
|
@@ -625,29 +624,17 @@ function suppressionMessage(cause) {
|
|
|
625
624
|
return `[SANITIZATION FAILED — original output suppressed for safety. Hook error: ${cause}]`;
|
|
626
625
|
}
|
|
627
626
|
|
|
628
|
-
/** Why an open knob still suppressed: the secret layer's own guarantee. */
|
|
629
|
-
const SECRETS_PIN_NOTE =
|
|
630
|
-
` Secret redaction is on (${SECRETS_ENABLED_ENV}=1), so output it could ` +
|
|
631
|
-
"not vet is withheld under either AGENT_SANITIZER_FAIL_OPEN posture.";
|
|
632
|
-
|
|
633
627
|
// This hook's entry in the one posture table (lib/hook-fault.mjs). OPEN is the
|
|
634
628
|
// shared default (a warning context, the original output left in the model's
|
|
635
629
|
// view); CLOSED replaces every string leaf of the output with the placeholder.
|
|
636
|
-
//
|
|
637
|
-
//
|
|
638
|
-
//
|
|
639
|
-
// unredacted credential to the model.
|
|
630
|
+
// Layer 4 follows the same knob: a redactor that cannot run (a dead daemon, a
|
|
631
|
+
// spent budget) leaves the output unvetted and says so, rather than blocking
|
|
632
|
+
// every tool call on an availability failure.
|
|
640
633
|
registerFaultPolicy(HOOK_NAME, {
|
|
641
634
|
event: HookEvent.POST_TOOL_USE,
|
|
642
635
|
guarded: "tool output",
|
|
643
|
-
alwaysClosed: secretsEnabled,
|
|
644
636
|
closed: (ctx) =>
|
|
645
|
-
failClosedParts(
|
|
646
|
-
ctx.input,
|
|
647
|
-
suppressionMessage(ctx.message) +
|
|
648
|
-
(ctx.pinnedClosed ? SECRETS_PIN_NOTE : ""),
|
|
649
|
-
ctx.remedy,
|
|
650
|
-
),
|
|
637
|
+
failClosedParts(ctx.input, suppressionMessage(ctx.message), ctx.remedy),
|
|
651
638
|
});
|
|
652
639
|
|
|
653
640
|
/**
|
|
@@ -28,7 +28,6 @@ import {
|
|
|
28
28
|
} from "./lib/hook-io.mjs";
|
|
29
29
|
import {
|
|
30
30
|
registerFaultPolicy,
|
|
31
|
-
KNOB_DECIDES,
|
|
32
31
|
hookFaultOutcome,
|
|
33
32
|
writeFaultOutcome,
|
|
34
33
|
} from "./lib/hook-fault.mjs";
|
|
@@ -78,6 +77,9 @@ export const USER_PROMPT_MESSAGES = Object.freeze({
|
|
|
78
77
|
|
|
79
78
|
const HOOK_NAME = "sanitize-user-prompt";
|
|
80
79
|
|
|
80
|
+
/** The `code` of the error a payload the adapter cannot classify raises. */
|
|
81
|
+
const UNKNOWN_EVENT_CODE = "UNKNOWN_EVENT";
|
|
82
|
+
|
|
81
83
|
// This hook's entry in the one posture table (lib/hook-fault.mjs). OPEN is the
|
|
82
84
|
// shared default (a warning context alongside the prompt); CLOSED is a
|
|
83
85
|
// top-level `decision: "block"`, NOT a hookSpecificOutput verdict —
|
|
@@ -86,16 +88,18 @@ const HOOK_NAME = "sanitize-user-prompt";
|
|
|
86
88
|
registerFaultPolicy(HOOK_NAME, {
|
|
87
89
|
event: HookEvent.USER_PROMPT_SUBMIT,
|
|
88
90
|
guarded: "prompt",
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
91
|
+
closed: (ctx) => {
|
|
92
|
+
const err = /** @type {{ code?: unknown, reason?: string } | null} */ (
|
|
93
|
+
ctx.err
|
|
94
|
+
);
|
|
95
|
+
// An unclassifiable payload blocks with the host's own `unknownEvent`
|
|
96
|
+
// reason, carried on the error, rather than wrapped as a hook failure.
|
|
97
|
+
const reason =
|
|
98
|
+
err?.code === UNKNOWN_EVENT_CODE
|
|
99
|
+
? /** @type {string} */ (err.reason)
|
|
100
|
+
: { ...USER_PROMPT_MESSAGES, ...ctx.messages }.hookFailed(ctx.message);
|
|
101
|
+
return { envelope: { decision: "block", reason } };
|
|
102
|
+
},
|
|
99
103
|
});
|
|
100
104
|
|
|
101
105
|
/* c8 ignore start — module-load boundary: the imports resolve in every real
|
|
@@ -123,8 +127,9 @@ registerFaultPolicy(HOOK_NAME, {
|
|
|
123
127
|
* Judge a normalized prompt-submit event. Agent-agnostic: consumes the
|
|
124
128
|
* control-plane ToolCallEvent and returns a Verdict, so the same prompt gate
|
|
125
129
|
* renders through any agent adapter, not just Claude's. Throws (into the
|
|
126
|
-
* calling hook's catch) when the sanitizer package never loaded
|
|
127
|
-
* cannot classify takes the hook's
|
|
130
|
+
* calling hook's catch) when the sanitizer package never loaded or the payload
|
|
131
|
+
* is no recognizable event, so a prompt it cannot classify takes the hook's
|
|
132
|
+
* fault posture rather than a silent ALLOW.
|
|
128
133
|
* @param {import("agent-control-plane-core").ToolCallEvent} event
|
|
129
134
|
* @param {((s: string) => string) | null} [strip] the ANSI stripper (defaults
|
|
130
135
|
* to the package's stripAnsiFully; injectable so the fault path is testable)
|
|
@@ -144,11 +149,17 @@ export function judgeSanitizeUserPrompt(
|
|
|
144
149
|
const messages = { ...USER_PROMPT_MESSAGES, ...overrides };
|
|
145
150
|
const { Decision, EventKind } = controlPlane();
|
|
146
151
|
// A payload the adapter cannot classify carries no readable prompt, so an
|
|
147
|
-
// abstain would
|
|
148
|
-
//
|
|
149
|
-
//
|
|
152
|
+
// abstain would report a CLEAN run on harness contract drift. It is a fault:
|
|
153
|
+
// the throw lands in main()'s onError, and the registered posture decides —
|
|
154
|
+
// a warning by default, a block carrying the error's `reason` (this host's
|
|
155
|
+
// `messages.unknownEvent`) under AGENT_SANITIZER_FAIL_OPEN=0.
|
|
150
156
|
if (event.event === EventKind.UNKNOWN)
|
|
151
|
-
|
|
157
|
+
throw Object.assign(
|
|
158
|
+
new Error(
|
|
159
|
+
"sanitize-user-prompt: unrecognized hook payload (not UserPromptSubmit)",
|
|
160
|
+
),
|
|
161
|
+
{ code: UNKNOWN_EVENT_CODE, reason: messages.unknownEvent },
|
|
162
|
+
);
|
|
152
163
|
if (event.event !== EventKind.PROMPT_SUBMIT)
|
|
153
164
|
return { decision: Decision.ALLOW };
|
|
154
165
|
// The module-load guard, one arm per binding. The two lazyImports are
|
|
@@ -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,
|
|
@@ -39,7 +39,6 @@ import {
|
|
|
39
39
|
} from "./lib/hook-io.mjs";
|
|
40
40
|
import {
|
|
41
41
|
registerFaultPolicy,
|
|
42
|
-
KNOB_DECIDES,
|
|
43
42
|
hookFaultOutcome,
|
|
44
43
|
writeFaultOutcome,
|
|
45
44
|
} from "./lib/hook-fault.mjs";
|
|
@@ -158,37 +157,76 @@ function faultLine(ctx) {
|
|
|
158
157
|
);
|
|
159
158
|
}
|
|
160
159
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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.
|
|
166
192
|
registerFaultPolicy(HOOK_NAME, {
|
|
167
193
|
event: HookEvent.SESSION_START,
|
|
168
194
|
guarded: "instruction files",
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
closed: (ctx) =>
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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 },
|
|
179
208
|
});
|
|
180
209
|
|
|
181
210
|
/**
|
|
182
|
-
* Render this hook's fault
|
|
183
|
-
*
|
|
184
|
-
* 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.
|
|
185
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
|
|
186
219
|
* @returns {string[]}
|
|
187
220
|
*/
|
|
188
|
-
function reportFault(err) {
|
|
189
|
-
const outcome = hookFaultOutcome(HOOK_NAME, err);
|
|
190
|
-
|
|
191
|
-
|
|
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)];
|
|
192
230
|
}
|
|
193
231
|
|
|
194
232
|
/**
|
|
@@ -333,9 +371,9 @@ export function scanProject(dir = PROJECT_DIR) {
|
|
|
333
371
|
}
|
|
334
372
|
|
|
335
373
|
/**
|
|
336
|
-
* The report for targets the scan could not read
|
|
337
|
-
*
|
|
338
|
-
*
|
|
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.
|
|
339
377
|
* @param {Array<{file: string, reason: string}>} skipped
|
|
340
378
|
* @returns {string}
|
|
341
379
|
*/
|
|
@@ -385,12 +423,15 @@ export async function cliMain(opts = {}) {
|
|
|
385
423
|
// reporting an overrun in band is what turned a 30-second scan from a rumor
|
|
386
424
|
// into a bug report (see lib/hook-timing.mjs).
|
|
387
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();
|
|
388
429
|
try {
|
|
389
430
|
// Charged to provisioning when the session's setup is installing THROUGHOUT
|
|
390
431
|
// this run: that install saturates the machine, and a scan that merely
|
|
391
432
|
// waited it out has no per-call cost to report (see hook-timing.mjs).
|
|
392
433
|
await excludeConcurrentProvisioning(
|
|
393
|
-
() => runScanCli(opts),
|
|
434
|
+
() => runScanCli(opts, out),
|
|
394
435
|
() => setupRunning(hookgateMarkerPath()),
|
|
395
436
|
);
|
|
396
437
|
} finally {
|
|
@@ -398,7 +439,7 @@ export async function cliMain(opts = {}) {
|
|
|
398
439
|
HOOK_NAME,
|
|
399
440
|
timer.wallMs(),
|
|
400
441
|
HookEvent.SESSION_START,
|
|
401
|
-
|
|
442
|
+
out.emit,
|
|
402
443
|
undefined,
|
|
403
444
|
// All four windows, including the two this scan normally leaves empty: a
|
|
404
445
|
// measured 0 rules a window OUT, where an omitted one leaves the notice
|
|
@@ -409,6 +450,7 @@ export async function cliMain(opts = {}) {
|
|
|
409
450
|
hostMs: timer.hostMs(),
|
|
410
451
|
},
|
|
411
452
|
);
|
|
453
|
+
out.flush();
|
|
412
454
|
}
|
|
413
455
|
}
|
|
414
456
|
|
|
@@ -421,9 +463,10 @@ export async function cliMain(opts = {}) {
|
|
|
421
463
|
* scan?: () => ReturnType<typeof scanProject>,
|
|
422
464
|
* sessionId?: string,
|
|
423
465
|
* }} opts see {@link cliMain}
|
|
466
|
+
* @param {import("./lib/hook-io.mjs").EnvelopeBuffer} out see {@link cliMain}
|
|
424
467
|
* @returns {Promise<void>}
|
|
425
468
|
*/
|
|
426
|
-
async function runScanCli({ trace: sink, scan: runScan, sessionId }) {
|
|
469
|
+
async function runScanCli({ trace: sink, scan: runScan, sessionId }, out) {
|
|
427
470
|
// Bound best-effort: the announcements below run BEFORE the auto-clean and
|
|
428
471
|
// the alert write, with no catch above them, so a throwing host sink would
|
|
429
472
|
// abort the scan silently (see hookTrace).
|
|
@@ -454,9 +497,13 @@ async function runScanCli({ trace: sink, scan: runScan, sessionId }) {
|
|
|
454
497
|
"agent-sanitizer failed to load (node deps not installed and " +
|
|
455
498
|
"session-setup did not finish in time); run `pnpm install`",
|
|
456
499
|
),
|
|
500
|
+
undefined,
|
|
501
|
+
out,
|
|
457
502
|
),
|
|
458
503
|
);
|
|
459
504
|
persistAlert(alertParts, sessionId);
|
|
505
|
+
// process.exit skips cliMain's `finally`, which would otherwise flush.
|
|
506
|
+
out.flush();
|
|
460
507
|
process.exit(1);
|
|
461
508
|
}
|
|
462
509
|
/* c8 ignore stop */
|
|
@@ -482,14 +529,14 @@ async function runScanCli({ trace: sink, scan: runScan, sessionId }) {
|
|
|
482
529
|
scan = (runScan ?? scanProject)();
|
|
483
530
|
} catch (err) {
|
|
484
531
|
emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, { outcome: "skipped" });
|
|
485
|
-
alertParts.push(...reportFault(err));
|
|
532
|
+
alertParts.push(...reportFault(err, undefined, out));
|
|
486
533
|
persistAlert(alertParts, sessionId);
|
|
487
534
|
return;
|
|
488
535
|
}
|
|
489
536
|
const { findings: allFindings, skipped, absent, scanned } = scan;
|
|
490
537
|
|
|
491
538
|
// The three buckets of classifyReadFailure, rendered: only `skipped` may
|
|
492
|
-
// withhold "clean" and
|
|
539
|
+
// withhold "clean", and it arms the gate only under the closed posture.
|
|
493
540
|
if (skipped.length > 0) {
|
|
494
541
|
emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, {
|
|
495
542
|
outcome: "partial",
|
|
@@ -498,9 +545,13 @@ async function runScanCli({ trace: sink, scan: runScan, sessionId }) {
|
|
|
498
545
|
absent: absent.length,
|
|
499
546
|
files: allFindings.length,
|
|
500
547
|
});
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
548
|
+
alertParts.push(
|
|
549
|
+
...reportFault(
|
|
550
|
+
new Error(`${skipped.length} instruction file(s) could not be read`),
|
|
551
|
+
formatSkipped(skipped),
|
|
552
|
+
out,
|
|
553
|
+
),
|
|
554
|
+
);
|
|
504
555
|
} else if (allFindings.length === 0) {
|
|
505
556
|
emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, {
|
|
506
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,
|
|
@@ -32,7 +32,6 @@ import {
|
|
|
32
32
|
} from "./lib/hook-io.mjs";
|
|
33
33
|
import {
|
|
34
34
|
registerFaultPolicy,
|
|
35
|
-
KNOB_DECIDES,
|
|
36
35
|
hookFaultOutcome,
|
|
37
36
|
writeFaultOutcome,
|
|
38
37
|
} from "./lib/hook-fault.mjs";
|
|
@@ -123,7 +122,6 @@ function emptyPayloadParts(ctx) {
|
|
|
123
122
|
registerFaultPolicy(HOOK_NAME, {
|
|
124
123
|
event: HookEvent.INSTRUCTIONS_LOADED,
|
|
125
124
|
guarded: "a loaded instruction file",
|
|
126
|
-
alwaysClosed: KNOB_DECIDES,
|
|
127
125
|
open: (ctx) =>
|
|
128
126
|
emptyPayloadParts(ctx) ?? {
|
|
129
127
|
stderr: `${faultLine(ctx)} Passing through unguarded; set AGENT_SANITIZER_FAIL_OPEN=0 to arm the tool-call gate instead.\n`,
|
|
@@ -270,12 +268,15 @@ export { HOOK_NAME };
|
|
|
270
268
|
export async function cliMain({ trace: sink } = {}) {
|
|
271
269
|
const timer = startHookTimer();
|
|
272
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();
|
|
273
274
|
try {
|
|
274
275
|
// Charged to provisioning when the session's setup is installing THROUGHOUT
|
|
275
276
|
// this run: that install saturates the machine, and a scan that merely
|
|
276
277
|
// waited it out has no per-call cost to report (see hook-timing.mjs).
|
|
277
278
|
await excludeConcurrentProvisioning(
|
|
278
|
-
() => runLoadedScanCli(emitTrace),
|
|
279
|
+
() => runLoadedScanCli(emitTrace, out),
|
|
279
280
|
() => setupRunning(hookgateMarkerPath()),
|
|
280
281
|
);
|
|
281
282
|
} finally {
|
|
@@ -283,7 +284,7 @@ export async function cliMain({ trace: sink } = {}) {
|
|
|
283
284
|
HOOK_NAME,
|
|
284
285
|
timer.wallMs(),
|
|
285
286
|
HookEvent.INSTRUCTIONS_LOADED,
|
|
286
|
-
|
|
287
|
+
out.emit,
|
|
287
288
|
undefined,
|
|
288
289
|
// All four windows, including the two this scan normally leaves empty: a
|
|
289
290
|
// measured 0 rules a window OUT, where an omitted one leaves the notice
|
|
@@ -294,6 +295,7 @@ export async function cliMain({ trace: sink } = {}) {
|
|
|
294
295
|
hostMs: timer.hostMs(),
|
|
295
296
|
},
|
|
296
297
|
);
|
|
298
|
+
out.flush();
|
|
297
299
|
}
|
|
298
300
|
}
|
|
299
301
|
|
|
@@ -302,9 +304,11 @@ export async function cliMain({ trace: sink } = {}) {
|
|
|
302
304
|
* single call to bracket — every early return here is an exit that wrapper must
|
|
303
305
|
* still measure.
|
|
304
306
|
* @param {import("./lib/trace.mjs").TraceFn} emitTrace
|
|
307
|
+
* @param {import("./lib/hook-io.mjs").EnvelopeBuffer} out this run's one stdout
|
|
308
|
+
* buffer
|
|
305
309
|
* @returns {Promise<void>}
|
|
306
310
|
*/
|
|
307
|
-
async function runLoadedScanCli(emitTrace) {
|
|
311
|
+
async function runLoadedScanCli(emitTrace, out) {
|
|
308
312
|
/** @type {string | undefined} */
|
|
309
313
|
let sessionId;
|
|
310
314
|
try {
|
|
@@ -342,19 +346,19 @@ async function runLoadedScanCli(emitTrace) {
|
|
|
342
346
|
// systemMessage reaches the user, additionalContext the model. Both, because
|
|
343
347
|
// this hook cannot block and the file is already loaded: the user is the one
|
|
344
348
|
// who can act on it, and the model is the one currently reading it.
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
}),
|
|
353
|
-
);
|
|
349
|
+
out.add({
|
|
350
|
+
systemMessage: message,
|
|
351
|
+
hookSpecificOutput: {
|
|
352
|
+
hookEventName: HookEvent.INSTRUCTIONS_LOADED,
|
|
353
|
+
additionalContext: message,
|
|
354
|
+
},
|
|
355
|
+
});
|
|
354
356
|
} catch (err) {
|
|
355
357
|
emitTrace(TraceEvent.SCAN_LOADED_INSTRUCTIONS_RAN, { outcome: "skipped" });
|
|
356
358
|
const outcome = hookFaultOutcome(HOOK_NAME, err);
|
|
357
|
-
process.exitCode = writeFaultOutcome(outcome)
|
|
359
|
+
process.exitCode = writeFaultOutcome(outcome, (chunk) =>
|
|
360
|
+
out.add(JSON.parse(chunk)),
|
|
361
|
+
);
|
|
358
362
|
// `sessionId`, captured before the throw: a payload read that itself failed
|
|
359
363
|
// leaves it undefined, and the fault then lands in the shared fallback store
|
|
360
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
|