agent-sanitizer 2.19.3 → 2.19.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/THREAT-MODEL.md +18 -6
- package/claude-hooks/lib/hook-fault.mjs +224 -0
- package/claude-hooks/lib/layer-pipeline.mjs +147 -0
- package/claude-hooks/plugin-hooks.mjs +45 -9
- package/claude-hooks/pretooluse-sanitize.mjs +116 -41
- package/claude-hooks/sanitize-output.mjs +66 -19
- package/claude-hooks/sanitize-user-prompt.mjs +31 -23
- package/claude-hooks/scan-invisible-chars.mjs +235 -47
- package/package.json +1 -1
- package/src/ansi.mjs +207 -0
- package/src/confusables.mjs +6 -2
- package/src/html.mjs +130 -48
- package/src/invisible.mjs +202 -159
- package/src/layer1.mjs +101 -116
- package/src/prompt.mjs +9 -6
- package/types/ansi.d.mts +66 -0
- package/types/claude-hooks/lib/hook-fault.d.mts +104 -0
- package/types/claude-hooks/lib/layer-pipeline.d.mts +113 -0
- package/types/claude-hooks/pretooluse-sanitize.d.mts +16 -0
- package/types/claude-hooks/scan-invisible-chars.d.mts +74 -14
- package/types/confusables.d.mts +6 -2
- package/types/invisible.d.mts +11 -2
- package/types/layer1.d.mts +19 -10
|
@@ -26,15 +26,13 @@ import {
|
|
|
26
26
|
lazyImport,
|
|
27
27
|
emitHookResponse,
|
|
28
28
|
errMessage,
|
|
29
|
-
safeErrMessage,
|
|
30
|
-
failOpenEnabled,
|
|
31
|
-
failOpenContext,
|
|
32
29
|
makeDeadline,
|
|
33
30
|
lazyImportErrorFor,
|
|
34
31
|
missingPackageMessage,
|
|
35
32
|
DEFAULT_MISSING_PACKAGE_REMEDY,
|
|
36
33
|
HookEvent,
|
|
37
34
|
} from "./lib/hook-io.mjs";
|
|
35
|
+
import { registerFaultPolicy, hookFaultOutcome } from "./lib/hook-fault.mjs";
|
|
38
36
|
import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
|
|
39
37
|
import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
|
|
40
38
|
import { hasEnvBoundSecret } from "./lib/secret-annotate.mjs";
|
|
@@ -552,19 +550,48 @@ export function emitFailClosed(
|
|
|
552
550
|
message,
|
|
553
551
|
emit = (fields) => emitHookResponse(HookEvent.POST_TOOL_USE, fields),
|
|
554
552
|
remedy = DEFAULT_MISSING_PACKAGE_REMEDY,
|
|
553
|
+
) {
|
|
554
|
+
const { fields, fallbackFields } = failClosedParts(input, message, remedy);
|
|
555
|
+
try {
|
|
556
|
+
emit(fields);
|
|
557
|
+
} catch {
|
|
558
|
+
emit(fallbackFields);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* The fail-closed response fields plus the shallow fallback to emit if
|
|
564
|
+
* serializing them throws. Split out from {@link emitFailClosed} so the posture
|
|
565
|
+
* table can state this hook's CLOSED verdict as a VALUE — the table is what
|
|
566
|
+
* `test/claude-hooks-posture.test.mjs` compares each hook's emission against, so
|
|
567
|
+
* a verdict reachable only by running the emitter could not be pinned there.
|
|
568
|
+
* @param {any} input parsed hook input, or undefined if parsing threw
|
|
569
|
+
* @param {string} message
|
|
570
|
+
* @param {string} [remedy] what a reader should run; hosts pass their own
|
|
571
|
+
* @returns {{ fields: Record<string, unknown>, fallbackFields: Record<string, unknown> }}
|
|
572
|
+
*/
|
|
573
|
+
function failClosedParts(
|
|
574
|
+
input,
|
|
575
|
+
message,
|
|
576
|
+
remedy = DEFAULT_MISSING_PACKAGE_REMEDY,
|
|
555
577
|
) {
|
|
556
578
|
// Threaded rather than defaulted here: this is the ONLY production caller of
|
|
557
579
|
// failClosedContext, so a remedy it does not pass is a remedy no host can ever
|
|
558
580
|
// reach — the parameter would be live only from tests.
|
|
559
581
|
const additionalContext = failClosedContext(sanitizerDepsLoaded, remedy);
|
|
582
|
+
const fallbackFields = { updatedToolOutput: message, additionalContext };
|
|
583
|
+
let updatedToolOutput;
|
|
560
584
|
try {
|
|
561
|
-
|
|
562
|
-
updatedToolOutput: failClosedReplacement(input, message),
|
|
563
|
-
additionalContext,
|
|
564
|
-
});
|
|
585
|
+
updatedToolOutput = failClosedReplacement(input, message);
|
|
565
586
|
} catch {
|
|
566
|
-
|
|
587
|
+
// The shape-matching walk overflowed on a pathologically deep (but valid)
|
|
588
|
+
// tool_response. The bare string is shallow, always serializable, and still
|
|
589
|
+
// a valid string tool_response — so the hook stays CLOSED rather than
|
|
590
|
+
// throwing out of its own failure path, which would emit nothing and let
|
|
591
|
+
// the harness show the raw, unvetted output.
|
|
592
|
+
return { fields: fallbackFields, fallbackFields };
|
|
567
593
|
}
|
|
594
|
+
return { fields: { updatedToolOutput, additionalContext }, fallbackFields };
|
|
568
595
|
}
|
|
569
596
|
|
|
570
597
|
/**
|
|
@@ -595,20 +622,40 @@ export function emitHookFailure(
|
|
|
595
622
|
remedy = DEFAULT_MISSING_PACKAGE_REMEDY,
|
|
596
623
|
env = process.env,
|
|
597
624
|
) {
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
625
|
+
const outcome = hookFaultOutcome(HOOK_NAME, err, { input, remedy, env });
|
|
626
|
+
const fields = /** @type {Record<string, unknown>} */ (outcome.fields);
|
|
627
|
+
try {
|
|
628
|
+
emit(fields);
|
|
629
|
+
} catch (emitErr) {
|
|
630
|
+
// The open posture's fields are a lone string context — always
|
|
631
|
+
// serializable — so it declares no fallback, and a throw there is a real
|
|
632
|
+
// bug the caller must see rather than a suppression to retry.
|
|
633
|
+
if (outcome.fallbackFields === null) throw emitErr;
|
|
634
|
+
emit(outcome.fallbackFields);
|
|
603
635
|
}
|
|
604
|
-
emitFailClosed(
|
|
605
|
-
input,
|
|
606
|
-
`[SANITIZATION FAILED — original output suppressed for safety. Hook error: ${safeErrMessage(err)}]`,
|
|
607
|
-
emit,
|
|
608
|
-
remedy,
|
|
609
|
-
);
|
|
610
636
|
}
|
|
611
637
|
|
|
638
|
+
/**
|
|
639
|
+
* The suppression placeholder that replaces the tool output under the closed
|
|
640
|
+
* posture. Named so the posture table and {@link emitFailClosed} cannot drift on
|
|
641
|
+
* the wording the model sees.
|
|
642
|
+
* @param {string} cause the scrubbed hook error
|
|
643
|
+
* @returns {string}
|
|
644
|
+
*/
|
|
645
|
+
function suppressionMessage(cause) {
|
|
646
|
+
return `[SANITIZATION FAILED — original output suppressed for safety. Hook error: ${cause}]`;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// This hook's entry in the one posture table (lib/hook-fault.mjs). OPEN is the
|
|
650
|
+
// shared default (a warning context, the original output left in the model's
|
|
651
|
+
// view); CLOSED replaces every string leaf of the output with the placeholder.
|
|
652
|
+
registerFaultPolicy(HOOK_NAME, {
|
|
653
|
+
event: HookEvent.POST_TOOL_USE,
|
|
654
|
+
guarded: "tool output",
|
|
655
|
+
closed: (ctx) =>
|
|
656
|
+
failClosedParts(ctx.input, suppressionMessage(ctx.message), ctx.remedy),
|
|
657
|
+
});
|
|
658
|
+
|
|
612
659
|
/**
|
|
613
660
|
* Run the sanitization pipeline over a tool output and return the contract-
|
|
614
661
|
* shaped verdict fields — `mutated_output` (the shape-matching sanitized value)
|
|
@@ -20,15 +20,17 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import {
|
|
22
22
|
readStdinJson,
|
|
23
|
-
safeErrMessage,
|
|
24
|
-
failOpenEnabled,
|
|
25
|
-
failOpenContext,
|
|
26
23
|
HookEvent,
|
|
27
24
|
isMain,
|
|
28
25
|
lazyImport,
|
|
29
26
|
missingPackageError,
|
|
30
27
|
DEFAULT_MISSING_PACKAGE_REMEDY,
|
|
31
28
|
} from "./lib/hook-io.mjs";
|
|
29
|
+
import {
|
|
30
|
+
registerFaultPolicy,
|
|
31
|
+
hookFaultOutcome,
|
|
32
|
+
writeFaultOutcome,
|
|
33
|
+
} from "./lib/hook-fault.mjs";
|
|
32
34
|
import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
|
|
33
35
|
import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
|
|
34
36
|
// classifyPrompt (the user-prompt verdict) and stripAnsiFully (its ANSI stripper)
|
|
@@ -73,6 +75,27 @@ export const USER_PROMPT_MESSAGES = Object.freeze({
|
|
|
73
75
|
remedy: DEFAULT_MISSING_PACKAGE_REMEDY,
|
|
74
76
|
});
|
|
75
77
|
|
|
78
|
+
const HOOK_NAME = "sanitize-user-prompt";
|
|
79
|
+
|
|
80
|
+
// This hook's entry in the one posture table (lib/hook-fault.mjs). OPEN is the
|
|
81
|
+
// shared default (a warning context alongside the prompt); CLOSED is a
|
|
82
|
+
// top-level `decision: "block"`, NOT a hookSpecificOutput verdict —
|
|
83
|
+
// UserPromptSubmit has no permissionDecision channel, so this envelope shape is
|
|
84
|
+
// the gate's own and the table records it rather than a reader inferring it.
|
|
85
|
+
registerFaultPolicy(HOOK_NAME, {
|
|
86
|
+
event: HookEvent.USER_PROMPT_SUBMIT,
|
|
87
|
+
guarded: "prompt",
|
|
88
|
+
closed: (ctx) => ({
|
|
89
|
+
envelope: {
|
|
90
|
+
decision: "block",
|
|
91
|
+
reason: {
|
|
92
|
+
...USER_PROMPT_MESSAGES,
|
|
93
|
+
...ctx.messages,
|
|
94
|
+
}.hookFailed(ctx.message),
|
|
95
|
+
},
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
|
|
76
99
|
/* c8 ignore start — module-load boundary: the imports resolve in every real
|
|
77
100
|
* run, and their failure (the package absent) can't be simulated in-process, so
|
|
78
101
|
* neither arm is observable to the in-process tests. The judge's typeof guard
|
|
@@ -202,13 +225,13 @@ export async function main(read, write, opts = {}) {
|
|
|
202
225
|
// exactly what may have failed to load. Either way this is the HOOK failing;
|
|
203
226
|
// a prompt the working stripper flagged is still blocked in both postures.
|
|
204
227
|
await runJudgeCli(
|
|
205
|
-
|
|
228
|
+
HOOK_NAME,
|
|
206
229
|
(event) => {
|
|
207
230
|
const verdict = judgeSanitizeUserPrompt(event, strip, messages);
|
|
208
231
|
// Announce engagement on the trace channel like the other stdin hooks —
|
|
209
232
|
// a prompt gate that silently stopped running is otherwise invisible.
|
|
210
233
|
emitTrace(TraceEvent.HOOK_RAN, {
|
|
211
|
-
hook:
|
|
234
|
+
hook: HOOK_NAME,
|
|
212
235
|
outcome:
|
|
213
236
|
verdict.decision === controlPlane().Decision.DENY
|
|
214
237
|
? "deny"
|
|
@@ -222,24 +245,9 @@ export async function main(read, write, opts = {}) {
|
|
|
222
245
|
readInput: read,
|
|
223
246
|
write,
|
|
224
247
|
onError: (err) =>
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
? {
|
|
229
|
-
hookSpecificOutput: {
|
|
230
|
-
hookEventName: HookEvent.USER_PROMPT_SUBMIT,
|
|
231
|
-
additionalContext: failOpenContext(
|
|
232
|
-
"sanitize-user-prompt",
|
|
233
|
-
"prompt",
|
|
234
|
-
err,
|
|
235
|
-
),
|
|
236
|
-
},
|
|
237
|
-
}
|
|
238
|
-
: {
|
|
239
|
-
decision: "block",
|
|
240
|
-
reason: messages.hookFailed(safeErrMessage(err)),
|
|
241
|
-
},
|
|
242
|
-
),
|
|
248
|
+
writeFaultOutcome(
|
|
249
|
+
hookFaultOutcome(HOOK_NAME, err, { messages, env }),
|
|
250
|
+
write,
|
|
243
251
|
),
|
|
244
252
|
},
|
|
245
253
|
);
|
|
@@ -9,13 +9,20 @@ import { readFileSync, globSync, writeFileSync, unlinkSync } from "node:fs";
|
|
|
9
9
|
import { join, relative } from "node:path";
|
|
10
10
|
import {
|
|
11
11
|
awaitLazyDependency,
|
|
12
|
+
safeErrMessage,
|
|
12
13
|
hookgateMarkerPath,
|
|
14
|
+
HookEvent,
|
|
13
15
|
isMain,
|
|
14
16
|
lazyImport,
|
|
15
17
|
markerIsTrusted,
|
|
16
18
|
probeSetupAlive,
|
|
17
19
|
writeFileNoFollow,
|
|
18
20
|
} from "./lib/hook-io.mjs";
|
|
21
|
+
import {
|
|
22
|
+
registerFaultPolicy,
|
|
23
|
+
hookFaultOutcome,
|
|
24
|
+
writeFaultOutcome,
|
|
25
|
+
} from "./lib/hook-fault.mjs";
|
|
19
26
|
import {
|
|
20
27
|
ALERT_FILE,
|
|
21
28
|
ALERT_ACK_FILE,
|
|
@@ -77,6 +84,69 @@ async function ensureSanitizerLoaded() {
|
|
|
77
84
|
/* c8 ignore stop */
|
|
78
85
|
}
|
|
79
86
|
|
|
87
|
+
const HOOK_NAME = "scan-invisible-chars";
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The stderr line both posture arms share: what broke, and what it cost.
|
|
91
|
+
* @param {{ message: string }} ctx
|
|
92
|
+
* @returns {string}
|
|
93
|
+
*/
|
|
94
|
+
function faultLine(ctx) {
|
|
95
|
+
return (
|
|
96
|
+
`${HOOK_NAME}: ${ctx.message}. Instruction files were NOT fully scanned ` +
|
|
97
|
+
"for hidden Unicode, so any payload in them reaches the model unvetted."
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// This hook's entry in the one posture table (lib/hook-fault.mjs). It has no
|
|
102
|
+
// stdout verdict channel — SessionStart cannot deny — so BOTH arms are stated
|
|
103
|
+
// explicitly rather than taking the shared additionalContext default: the only
|
|
104
|
+
// enforcement a SessionStart hook can reach is the cross-hook alert, which makes
|
|
105
|
+
// the PreToolUse gate ask once on the next tool call.
|
|
106
|
+
registerFaultPolicy(HOOK_NAME, {
|
|
107
|
+
event: HookEvent.SESSION_START,
|
|
108
|
+
guarded: "instruction files",
|
|
109
|
+
open: (ctx) => ({
|
|
110
|
+
stderr: `${faultLine(ctx)} Passing through unguarded; set AGENT_SANITIZER_FAIL_OPEN=0 to arm the tool-call gate instead.\n`,
|
|
111
|
+
exitCode: 1,
|
|
112
|
+
}),
|
|
113
|
+
closed: (ctx) => ({
|
|
114
|
+
stderr: `${faultLine(ctx)} Arming the tool-call gate (AGENT_SANITIZER_FAIL_OPEN=0).\n`,
|
|
115
|
+
exitCode: 1,
|
|
116
|
+
armAlert: true,
|
|
117
|
+
}),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Render this hook's fault under the declared posture, and return the text (if
|
|
122
|
+
* any) that must ride in the cross-hook alert so a later gate carries the
|
|
123
|
+
* posture this hook cannot express itself.
|
|
124
|
+
* @param {unknown} err
|
|
125
|
+
* @returns {string[]}
|
|
126
|
+
*/
|
|
127
|
+
function reportFault(err) {
|
|
128
|
+
const outcome = hookFaultOutcome(HOOK_NAME, err);
|
|
129
|
+
process.exitCode = writeFaultOutcome(outcome);
|
|
130
|
+
return outcome.armAlert ? [/** @type {string} */ (outcome.stderr)] : [];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Persist the accumulated alert text for the PreToolUse gate, or leave the alert
|
|
135
|
+
* absent when there is nothing to surface.
|
|
136
|
+
*
|
|
137
|
+
* ALERT_FILE sits at a predictable, world-visible $TMPDIR path, so a plain
|
|
138
|
+
* writeFileSync would follow a co-tenant-planted symlink and overwrite an
|
|
139
|
+
* arbitrary file this uid owns. Create it symlink-refusingly (see
|
|
140
|
+
* writeFileNoFollow); the gate treats an absent alert as "nothing to surface",
|
|
141
|
+
* so a lost race degrades safely rather than to a hijacked write.
|
|
142
|
+
* @param {string[]} parts
|
|
143
|
+
* @returns {void}
|
|
144
|
+
*/
|
|
145
|
+
function persistAlert(parts) {
|
|
146
|
+
if (parts.length === 0) return;
|
|
147
|
+
writeFileNoFollow(ALERT_FILE, parts.join("\n") + "\n");
|
|
148
|
+
}
|
|
149
|
+
|
|
80
150
|
// Decoder
|
|
81
151
|
|
|
82
152
|
/**
|
|
@@ -249,50 +319,118 @@ export { formatReport };
|
|
|
249
319
|
|
|
250
320
|
// Main (skip when imported for testing)
|
|
251
321
|
|
|
252
|
-
// Stryker disable all: CLI-entry body. It runs only as a spawned subprocess,
|
|
253
|
-
// which in-process tests can't observe, so every mutant here is unkillable by
|
|
254
|
-
// construction (same boundary as the c8-ignored regions below). The exported
|
|
255
|
-
// scanFile/decodeRun above carry the real, mutation-tested logic.
|
|
256
322
|
/**
|
|
257
|
-
* Scan every instruction file under the project for
|
|
258
|
-
*
|
|
323
|
+
* Scan every instruction file under the project, ACCOUNTING for every target
|
|
324
|
+
* the finder returned: `scanned + skipped.length === targets.length`, always.
|
|
325
|
+
*
|
|
326
|
+
* The accounting is the point. This scan is the only thing standing between a
|
|
327
|
+
* poisoned `CLAUDE.md` and a session that loads it as instructions, and its
|
|
328
|
+
* caller announces "clean" on the trace channel — the channel that exists so a
|
|
329
|
+
* MISSING announcement is loud. A per-file failure swallowed into an empty
|
|
330
|
+
* findings list turns "we could not read this file" into "this file is fine",
|
|
331
|
+
* which is the one lie this hook must never tell. So a file that cannot be read
|
|
332
|
+
* is REPORTED as unscanned, not dropped.
|
|
333
|
+
*
|
|
334
|
+
* ANY errno is a skip; only a non-filesystem throw propagates. The split is
|
|
335
|
+
* between "this file could not be read" (report it and keep scanning) and "this
|
|
336
|
+
* code is broken" (a TypeError from an unloaded binding — nothing here can be
|
|
337
|
+
* trusted, so it goes to the caller's declared failure posture). Catching only
|
|
338
|
+
* ENOENT would invert the enforcement: one EACCES target would discard the
|
|
339
|
+
* result for EVERY other instruction file, leaving them unscanned and
|
|
340
|
+
* un-auto-cleaned, and under the shipped OPEN posture the hook fault arms
|
|
341
|
+
* nothing — so the SUSPICIOUS failure would get weaker enforcement than the
|
|
342
|
+
* benign glob race, which reaches `partial` and arms the gate. Same errno-vs-bug
|
|
343
|
+
* split {@link autoCleanFindings} uses.
|
|
344
|
+
* @param {string} [dir] project root to scan (injectable for tests)
|
|
345
|
+
* @returns {{
|
|
346
|
+
* targets: string[],
|
|
347
|
+
* scanned: number,
|
|
348
|
+
* findings: Array<{file: string, findings: ReturnType<typeof scanFile>}>,
|
|
349
|
+
* skipped: Array<{file: string, reason: string}>,
|
|
350
|
+
* }}
|
|
259
351
|
*/
|
|
260
|
-
function scanProject() {
|
|
352
|
+
export function scanProject(dir = PROJECT_DIR) {
|
|
261
353
|
const targets = [
|
|
262
354
|
...new Set([
|
|
263
|
-
...findInstructionFiles(
|
|
264
|
-
...findMdFiles(join(
|
|
355
|
+
...findInstructionFiles(dir),
|
|
356
|
+
...findMdFiles(join(dir, ".claude")),
|
|
265
357
|
]),
|
|
266
358
|
];
|
|
267
|
-
const
|
|
359
|
+
const findings = [];
|
|
360
|
+
const skipped = [];
|
|
361
|
+
let scanned = 0;
|
|
268
362
|
for (const file of targets) {
|
|
363
|
+
let fileFindings;
|
|
269
364
|
try {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
//
|
|
365
|
+
fileFindings = scanFile(file);
|
|
366
|
+
} catch (err) {
|
|
367
|
+
if (/** @type {NodeJS.ErrnoException} */ (err).code === undefined)
|
|
368
|
+
throw err;
|
|
369
|
+
// safeErrMessage, not errMessage: this reason is rendered into stderr and
|
|
370
|
+
// into ALERT_FILE, and an errno message embeds the absolute path globbed
|
|
371
|
+
// out of a possibly-hostile repo — a filename carrying ANSI or invisible
|
|
372
|
+
// bytes would otherwise reach the operator's terminal raw.
|
|
373
|
+
skipped.push({ file: relative(dir, file), reason: safeErrMessage(err) });
|
|
374
|
+
continue;
|
|
276
375
|
}
|
|
376
|
+
scanned++;
|
|
377
|
+
if (fileFindings.length > 0)
|
|
378
|
+
findings.push({ file: relative(dir, file), findings: fileFindings });
|
|
277
379
|
}
|
|
278
|
-
return
|
|
380
|
+
return { targets, scanned, findings, skipped };
|
|
279
381
|
}
|
|
280
382
|
|
|
383
|
+
/**
|
|
384
|
+
* The report for targets the scan could not read. Rendered into the alert the
|
|
385
|
+
* PreToolUse gate surfaces, so an incomplete scan reaches the operator as a
|
|
386
|
+
* checkpoint rather than as silence.
|
|
387
|
+
* @param {Array<{file: string, reason: string}>} skipped
|
|
388
|
+
* @returns {string}
|
|
389
|
+
*/
|
|
390
|
+
export function formatSkipped(skipped) {
|
|
391
|
+
return [
|
|
392
|
+
"",
|
|
393
|
+
"━━━ INSTRUCTION FILES NOT SCANNED ━━━",
|
|
394
|
+
"",
|
|
395
|
+
"These files load as project instructions but could NOT be read, so they",
|
|
396
|
+
"were never checked for hidden Unicode. Treat their content as unvetted.",
|
|
397
|
+
"",
|
|
398
|
+
...skipped.map(({ file, reason }) => ` ${file}: ${reason}`),
|
|
399
|
+
"",
|
|
400
|
+
].join("\n");
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Stryker disable all: CLI-entry body. It runs only as a spawned subprocess,
|
|
404
|
+
// which in-process tests can't observe, so every mutant here is unkillable by
|
|
405
|
+
// construction (same boundary as the c8-ignored regions below). The exported
|
|
406
|
+
// scanFile / decodeRun / scanProject above carry the real, tested logic.
|
|
281
407
|
/**
|
|
282
408
|
* The hook's CLI: scan the instruction files, auto-clean what it can, persist
|
|
283
409
|
* the alert for the PreToolUse gate otherwise. Exported so a bundle entry
|
|
284
410
|
* (which must claim the CLI slot before this module loads) can run the exact
|
|
285
411
|
* same scan instead of duplicating it.
|
|
286
|
-
* @param {{
|
|
287
|
-
*
|
|
288
|
-
*
|
|
412
|
+
* @param {{
|
|
413
|
+
* trace?: import("./lib/trace.mjs").TraceFn,
|
|
414
|
+
* scan?: () => ReturnType<typeof scanProject>,
|
|
415
|
+
* }} [opts] `trace` is where this scan announces engagement; a host with its
|
|
416
|
+
* own trace channel passes its sink so the announcement lands where its
|
|
417
|
+
* detector reads (see lib/trace.mjs). `scan` is the scanner, injectable so the
|
|
418
|
+
* FAULT path below — a scanner that throws something other than an errno, i.e.
|
|
419
|
+
* a bug — is drivable end to end; no filesystem state can force it, and an
|
|
420
|
+
* untested fault path is how a posture goes missing in the first place.
|
|
289
421
|
* @returns {Promise<void>}
|
|
290
422
|
*/
|
|
291
|
-
export async function cliMain({ trace: sink = trace } = {}) {
|
|
423
|
+
export async function cliMain({ trace: sink = trace, scan: runScan } = {}) {
|
|
292
424
|
// Bound best-effort: the announcements below run BEFORE the auto-clean and
|
|
293
425
|
// the alert write, with no catch above them, so a throwing host sink would
|
|
294
426
|
// abort the scan silently (see bestEffortTrace).
|
|
295
427
|
const emitTrace = bestEffortTrace(sink);
|
|
428
|
+
// Everything the PreToolUse gate must surface this session, written once at
|
|
429
|
+
// the end: an incomplete scan and an uncleanable file are independent reasons
|
|
430
|
+
// to arm the gate, and two separate writes would have the second clobber the
|
|
431
|
+
// first.
|
|
432
|
+
/** @type {string[]} */
|
|
433
|
+
const alertParts = [];
|
|
296
434
|
/* c8 ignore start -- fail-closed module-load guard: only reachable when the
|
|
297
435
|
agent-sanitizer import above failed, which can't be simulated in the
|
|
298
436
|
spawned-subprocess CLI run the tests observe. */
|
|
@@ -301,11 +439,21 @@ export async function cliMain({ trace: sink = trace } = {}) {
|
|
|
301
439
|
// the trace channel — a scan that never ran is otherwise invisible, and the
|
|
302
440
|
// downstream PreToolUse sanitize gate then passes cleanly all session.
|
|
303
441
|
emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, { outcome: "skipped" });
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
442
|
+
// Through the shared posture table, NOT a bare exit(1): this hook took an
|
|
443
|
+
// advisory posture unconditionally, so an operator who pinned
|
|
444
|
+
// AGENT_SANITIZER_FAIL_OPEN=0 got only a warning on the one hook guarding
|
|
445
|
+
// session-start ingress, and the PreToolUse gate then passed cleanly for
|
|
446
|
+
// the rest of the session. The closed arm arms the cross-hook alert, which
|
|
447
|
+
// is the only channel a SessionStart hook has to make a later gate ask.
|
|
448
|
+
alertParts.push(
|
|
449
|
+
...reportFault(
|
|
450
|
+
new Error(
|
|
451
|
+
"agent-sanitizer failed to load (node deps not installed and " +
|
|
452
|
+
"session-setup did not finish in time); run `pnpm install`",
|
|
453
|
+
),
|
|
454
|
+
),
|
|
308
455
|
);
|
|
456
|
+
persistAlert(alertParts);
|
|
309
457
|
process.exit(1);
|
|
310
458
|
}
|
|
311
459
|
/* c8 ignore stop */
|
|
@@ -320,22 +468,61 @@ export async function cliMain({ trace: sink = trace } = {}) {
|
|
|
320
468
|
}
|
|
321
469
|
}
|
|
322
470
|
|
|
323
|
-
|
|
471
|
+
// Only a non-errno throw reaches here — a bug in the scanner, not a file it
|
|
472
|
+
// could not read (those are accounted for in `skipped`). It is a fault of THIS
|
|
473
|
+
// hook, so it renders through the same posture table as every other hook's
|
|
474
|
+
// fault instead of aborting with no announcement at all.
|
|
475
|
+
let scan;
|
|
476
|
+
try {
|
|
477
|
+
scan = (runScan ?? scanProject)();
|
|
478
|
+
} catch (err) {
|
|
479
|
+
emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, { outcome: "skipped" });
|
|
480
|
+
alertParts.push(...reportFault(err));
|
|
481
|
+
persistAlert(alertParts);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
const { findings: allFindings, skipped, scanned } = scan;
|
|
324
485
|
|
|
325
|
-
|
|
486
|
+
// "clean" is a claim about EVERY target, so it may only be made when every
|
|
487
|
+
// target was read. A scan that could not read one says "partial" and arms the
|
|
488
|
+
// gate: an unread instruction file is UNVETTED context, not absent findings.
|
|
489
|
+
if (skipped.length > 0) {
|
|
490
|
+
emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, {
|
|
491
|
+
outcome: "partial",
|
|
492
|
+
scanned,
|
|
493
|
+
skipped: skipped.length,
|
|
494
|
+
files: allFindings.length,
|
|
495
|
+
});
|
|
496
|
+
const notice = formatSkipped(skipped);
|
|
497
|
+
process.stderr.write(notice + "\n");
|
|
498
|
+
alertParts.push(notice);
|
|
499
|
+
} else if (allFindings.length === 0) {
|
|
326
500
|
emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, { outcome: "clean" });
|
|
327
501
|
return;
|
|
502
|
+
} else {
|
|
503
|
+
emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, {
|
|
504
|
+
outcome: "found",
|
|
505
|
+
files: allFindings.length,
|
|
506
|
+
});
|
|
328
507
|
}
|
|
329
|
-
emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, {
|
|
330
|
-
outcome: "found",
|
|
331
|
-
files: allFindings.length,
|
|
332
|
-
});
|
|
333
508
|
|
|
334
|
-
|
|
335
|
-
|
|
509
|
+
if (allFindings.length > 0)
|
|
510
|
+
alertParts.push(...autoCleanFindings(allFindings, PROJECT_DIR));
|
|
511
|
+
persistAlert(alertParts);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Auto-clean the contaminated files so the session proceeds without blocking
|
|
516
|
+
* every tool call, and return the alert text for whatever could not be cleaned
|
|
517
|
+
* (empty when everything was). The gate hook is the fallback for the rest.
|
|
518
|
+
* @param {Array<{file: string, findings: ReturnType<typeof scanFile>}>} allFindings
|
|
519
|
+
* @param {string} dir the root the finding paths are relative to
|
|
520
|
+
* @returns {string[]}
|
|
521
|
+
*/
|
|
522
|
+
function autoCleanFindings(allFindings, dir) {
|
|
336
523
|
let cleaned = 0;
|
|
337
524
|
for (const { file } of allFindings) {
|
|
338
|
-
const absPath = join(
|
|
525
|
+
const absPath = join(dir, file);
|
|
339
526
|
try {
|
|
340
527
|
const original = readFileSync(absPath, "utf-8");
|
|
341
528
|
const stripped = stripInvisible(original);
|
|
@@ -344,15 +531,21 @@ export async function cliMain({ trace: sink = trace } = {}) {
|
|
|
344
531
|
cleaned++;
|
|
345
532
|
}
|
|
346
533
|
/* c8 ignore start -- only fires on a file this uid cannot rewrite, which the test run cannot create */
|
|
347
|
-
} catch {
|
|
348
|
-
//
|
|
349
|
-
//
|
|
534
|
+
} catch (err) {
|
|
535
|
+
// Narrowed to filesystem errnos, and the reason is REPORTED rather than
|
|
536
|
+
// swallowed: an unwritable file legitimately falls through to the alert
|
|
537
|
+
// path below, but a throw from stripInvisible is a bug in the sanitizer
|
|
538
|
+
// and must not be laundered into "this file resisted cleaning".
|
|
539
|
+
if (/** @type {NodeJS.ErrnoException} */ (err).code === undefined)
|
|
540
|
+
throw err;
|
|
541
|
+
process.stderr.write(
|
|
542
|
+
`scan-invisible-chars: could not clean ${file}: ${safeErrMessage(err)}\n`,
|
|
543
|
+
);
|
|
350
544
|
}
|
|
351
545
|
/* c8 ignore stop */
|
|
352
546
|
}
|
|
353
547
|
|
|
354
548
|
const report = formatReport(allFindings);
|
|
355
|
-
|
|
356
549
|
if (cleaned === allFindings.length) {
|
|
357
550
|
process.stderr.write(
|
|
358
551
|
report +
|
|
@@ -363,16 +556,11 @@ export async function cliMain({ trace: sink = trace } = {}) {
|
|
|
363
556
|
"suspicion, and restart the session if in doubt. Future sessions load " +
|
|
364
557
|
"the cleaned files.\n",
|
|
365
558
|
);
|
|
366
|
-
|
|
367
|
-
} else {
|
|
368
|
-
process.stderr.write(report + "\n");
|
|
369
|
-
// ALERT_FILE sits at a predictable, world-visible $TMPDIR path, so a plain
|
|
370
|
-
// writeFileSync would follow a co-tenant-planted symlink and overwrite an
|
|
371
|
-
// arbitrary file this uid owns. Create it symlink-refusingly (see
|
|
372
|
-
// writeFileNoFollow); the PreToolUse gate treats an absent alert as "nothing
|
|
373
|
-
// to surface", so a lost race degrades safely rather than to a hijacked write.
|
|
374
|
-
writeFileNoFollow(ALERT_FILE, report + "\n");
|
|
559
|
+
return [];
|
|
375
560
|
}
|
|
561
|
+
/* c8 ignore start -- only reachable when the write catch above fires */
|
|
562
|
+
process.stderr.write(report + "\n");
|
|
563
|
+
return [report];
|
|
376
564
|
/* c8 ignore stop */
|
|
377
565
|
}
|
|
378
566
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.19.
|
|
3
|
+
"version": "2.19.4",
|
|
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": {
|