agent-sanitizer 2.8.0 → 2.9.1
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/claude-hooks/lib/control-plane.mjs +39 -14
- package/claude-hooks/lib/hook-io.mjs +262 -3
- package/claude-hooks/plugin-hooks.mjs +1 -0
- package/claude-hooks/pretooluse-sanitize.mjs +136 -26
- package/claude-hooks/sanitize-output.mjs +29 -14
- package/claude-hooks/sanitize-user-prompt.mjs +95 -34
- package/claude-hooks/scan-invisible-chars.mjs +56 -7
- package/package.json +29 -1
- package/types/claude-hooks/lib/hook-io.d.mts +119 -0
- package/types/claude-hooks/pretooluse-sanitize.d.mts +71 -3
- package/types/claude-hooks/sanitize-output.d.mts +22 -4
- package/types/claude-hooks/sanitize-user-prompt.d.mts +27 -2
- package/types/claude-hooks/scan-invisible-chars.d.mts +3 -3
|
@@ -28,6 +28,9 @@ import {
|
|
|
28
28
|
errMessage,
|
|
29
29
|
safeErrMessage,
|
|
30
30
|
makeDeadline,
|
|
31
|
+
lazyImportErrorFor,
|
|
32
|
+
missingPackageMessage,
|
|
33
|
+
DEFAULT_MISSING_PACKAGE_REMEDY,
|
|
31
34
|
HookEvent,
|
|
32
35
|
} from "./lib/hook-io.mjs";
|
|
33
36
|
import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
|
|
@@ -192,6 +195,11 @@ async function redactSecrets(text, webIngress = false, deadline) {
|
|
|
192
195
|
* @property {(record: { tool: string | null, modified: boolean, output: unknown, context?: string }) => Promise<void> | void} [audit]
|
|
193
196
|
* Awaited once per judged event that carried a tool response, with the output
|
|
194
197
|
* the model will actually see.
|
|
198
|
+
* @property {string} [remedy]
|
|
199
|
+
* What a reader should run when the sanitizer's own bindings are what is
|
|
200
|
+
* missing. This hook's host channel is `ext`, where the other two gates use a
|
|
201
|
+
* frozen message table; either way it is one channel per gate, so a host
|
|
202
|
+
* cannot supply its wording somewhere the fail-closed context never reads.
|
|
195
203
|
*/
|
|
196
204
|
|
|
197
205
|
/**
|
|
@@ -478,13 +486,6 @@ const FAIL_CLOSED_CONTEXT =
|
|
|
478
486
|
"(replaced with a placeholder) to fail closed -- the unsanitized output was " +
|
|
479
487
|
"not shown. Investigate the hook error before relying on this tool.";
|
|
480
488
|
|
|
481
|
-
// The one cause that is a broken INSTALL rather than a broken hook: the
|
|
482
|
-
// sanitizer's bindings are absent, so every subsequent tool call fails closed
|
|
483
|
-
// with no visible cause. Name the remedy in the emission itself.
|
|
484
|
-
const MISSING_DEPS_HINT =
|
|
485
|
-
" The cause is a missing dependency (agent-sanitizer did not load), not a" +
|
|
486
|
-
" hook defect: reinstall the plugin, then retry the tool call.";
|
|
487
|
-
|
|
488
489
|
/**
|
|
489
490
|
* Whether the sanitizer's bindings actually loaded. lazyImport swallows a
|
|
490
491
|
* missing package and yields `{}`, so the absence shows up as an undefined
|
|
@@ -501,15 +502,22 @@ export function sanitizerDepsLoaded() {
|
|
|
501
502
|
}
|
|
502
503
|
|
|
503
504
|
/**
|
|
504
|
-
* The model-facing note for a fail-closed emission
|
|
505
|
-
*
|
|
505
|
+
* The model-facing note for a fail-closed emission. When the sanitizer's own
|
|
506
|
+
* bindings are what is absent — a broken INSTALL rather than a broken hook, and
|
|
507
|
+
* otherwise invisible because every later tool call then fails closed with no
|
|
508
|
+
* stated cause — the recorded loader error and its remedy ride along. The text
|
|
509
|
+
* comes from missingPackageMessage so this hook, the PreToolUse gate and the
|
|
510
|
+
* prompt gate cannot drift apart on what a missing dependency reads like.
|
|
506
511
|
* @param {() => boolean} [depsLoaded] injectable seam for testing
|
|
512
|
+
* @param {string} [remedy] what a reader should run; hosts pass their own
|
|
507
513
|
* @returns {string}
|
|
508
514
|
*/
|
|
509
|
-
export function failClosedContext(
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
515
|
+
export function failClosedContext(
|
|
516
|
+
depsLoaded = sanitizerDepsLoaded,
|
|
517
|
+
remedy = DEFAULT_MISSING_PACKAGE_REMEDY,
|
|
518
|
+
) {
|
|
519
|
+
if (depsLoaded()) return FAIL_CLOSED_CONTEXT;
|
|
520
|
+
return `${FAIL_CLOSED_CONTEXT} ${missingPackageMessage("agent-sanitizer", lazyImportErrorFor("agent-sanitizer"), remedy)}`;
|
|
513
521
|
}
|
|
514
522
|
|
|
515
523
|
/**
|
|
@@ -525,14 +533,19 @@ export function failClosedContext(depsLoaded = sanitizerDepsLoaded) {
|
|
|
525
533
|
* @param {any} input parsed hook input, or undefined if parsing threw
|
|
526
534
|
* @param {string} message
|
|
527
535
|
* @param {(fields: Record<string, unknown>) => void} [emit]
|
|
536
|
+
* @param {string} [remedy] what a reader should run; hosts pass their own
|
|
528
537
|
* @returns {void}
|
|
529
538
|
*/
|
|
530
539
|
export function emitFailClosed(
|
|
531
540
|
input,
|
|
532
541
|
message,
|
|
533
542
|
emit = (fields) => emitHookResponse(HookEvent.POST_TOOL_USE, fields),
|
|
543
|
+
remedy = DEFAULT_MISSING_PACKAGE_REMEDY,
|
|
534
544
|
) {
|
|
535
|
-
|
|
545
|
+
// Threaded rather than defaulted here: this is the ONLY production caller of
|
|
546
|
+
// failClosedContext, so a remedy it does not pass is a remedy no host can ever
|
|
547
|
+
// reach — the parameter would be live only from tests.
|
|
548
|
+
const additionalContext = failClosedContext(sanitizerDepsLoaded, remedy);
|
|
536
549
|
try {
|
|
537
550
|
emit({
|
|
538
551
|
updatedToolOutput: failClosedReplacement(input, message),
|
|
@@ -771,6 +784,8 @@ export async function cliMain(ext = {}) {
|
|
|
771
784
|
"[SANITIZATION FAILED — original output suppressed for safety. Hook error: " +
|
|
772
785
|
safeErrMessage(err) +
|
|
773
786
|
"]",
|
|
787
|
+
undefined,
|
|
788
|
+
ext.remedy,
|
|
774
789
|
),
|
|
775
790
|
},
|
|
776
791
|
);
|
|
@@ -18,7 +18,14 @@
|
|
|
18
18
|
* (cursor movement, erase, OSC title-set, DCS/APC/PM) still blocks, as do the
|
|
19
19
|
* invisible-char thresholds, which are the actual web-paste payload defense.
|
|
20
20
|
*/
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
readStdinJson,
|
|
23
|
+
safeErrMessage,
|
|
24
|
+
isMain,
|
|
25
|
+
lazyImport,
|
|
26
|
+
missingPackageError,
|
|
27
|
+
DEFAULT_MISSING_PACKAGE_REMEDY,
|
|
28
|
+
} from "./lib/hook-io.mjs";
|
|
22
29
|
import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
|
|
23
30
|
import { trace, TraceEvent } from "./lib/trace.mjs";
|
|
24
31
|
// classifyPrompt (the user-prompt verdict) and stripAnsiFully (its ANSI stripper)
|
|
@@ -26,34 +33,61 @@ import { trace, TraceEvent } from "./lib/trace.mjs";
|
|
|
26
33
|
// import, never a bare top-level `import … from "…"`: a static npm import
|
|
27
34
|
// resolves before any try/catch, so a missing node_modules would crash this hook
|
|
28
35
|
// at load and let the prompt through UNSANITIZED (fail-open). A failed load
|
|
29
|
-
// leaves
|
|
30
|
-
// fail-closed block
|
|
36
|
+
// leaves that binding undefined, which the judge's typeof guards turn into a
|
|
37
|
+
// fail-closed block — one guard each, since the two loads succeed or fail
|
|
38
|
+
// independently.
|
|
31
39
|
/** @type {typeof import("agent-sanitizer/prompt").classifyPrompt} */
|
|
32
40
|
export let classifyPrompt;
|
|
33
41
|
/** @type {typeof import("agent-sanitizer").stripAnsiFully} */
|
|
34
42
|
let stripAnsiFully;
|
|
35
43
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
44
|
+
/**
|
|
45
|
+
* The reasons this gate emits, as a table a host overrides. A host that knows
|
|
46
|
+
* which of ITS files wires the adapter, and what a reader should do about a
|
|
47
|
+
* failure, can say so — the package cannot, since it has no idea where it is
|
|
48
|
+
* installed. Every field is a plain string or a string-returning function, so
|
|
49
|
+
* an override is auditable next to the default it replaces.
|
|
50
|
+
* @type {Readonly<{
|
|
51
|
+
* unknownEvent: string,
|
|
52
|
+
* blockContext: string,
|
|
53
|
+
* sgrNote: string,
|
|
54
|
+
* hookFailed: (cause: string) => string,
|
|
55
|
+
* remedy: string,
|
|
56
|
+
* }>}
|
|
57
|
+
*/
|
|
58
|
+
export const USER_PROMPT_MESSAGES = Object.freeze({
|
|
59
|
+
unknownEvent: "User prompt blocked (fail-closed): unrecognized hook payload.",
|
|
60
|
+
blockContext:
|
|
61
|
+
"User prompt blocked: payload-capable invisible/ANSI characters detected.",
|
|
62
|
+
sgrNote:
|
|
63
|
+
"The prompt contains ANSI SGR color codes (pasted terminal output). They are display-only formatting noise; read through them.",
|
|
64
|
+
hookFailed: (cause) =>
|
|
65
|
+
`sanitize-user-prompt hook failed (fail-closed): ${cause}`,
|
|
66
|
+
// What a reader should run when the package itself is what is missing. It
|
|
67
|
+
// rides in this table rather than a separate argument because it is host text
|
|
68
|
+
// exactly like the reasons above, and one channel means a host cannot supply
|
|
69
|
+
// its wording in one place and forget it in the other.
|
|
70
|
+
remedy: DEFAULT_MISSING_PACKAGE_REMEDY,
|
|
71
|
+
});
|
|
40
72
|
|
|
41
73
|
/* c8 ignore start — module-load boundary: the imports resolve in every real
|
|
42
74
|
* run, and their failure (the package absent) can't be simulated in-process, so
|
|
43
|
-
* neither arm is observable to the in-process tests.
|
|
75
|
+
* neither arm is observable to the in-process tests. The judge's typeof guard
|
|
44
76
|
* converts an undefined stripper into a fail-closed block — that guard IS tested. */
|
|
45
77
|
// Stryker disable all
|
|
46
|
-
try
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
78
|
+
// lazyImport rather than a bare `await import` in a try/catch: it RECORDS the
|
|
79
|
+
// loader error, which is the only thing that can tell a missing install apart
|
|
80
|
+
// from a present package missing an export. Discarding it leaves
|
|
81
|
+
// missingPackageError with nothing to report but a guess.
|
|
82
|
+
// The cast asserts the loaded SHAPE, not that it loaded: lazyImport yields {} on
|
|
83
|
+
// failure, so either binding can still be undefined here — which is exactly what
|
|
84
|
+
// the judge's typeof guard turns into a fail-closed block.
|
|
85
|
+
({ classifyPrompt } = /** @type {typeof import("agent-sanitizer/prompt")} */ (
|
|
86
|
+
await lazyImport("agent-sanitizer/prompt")
|
|
87
|
+
));
|
|
88
|
+
({ stripAnsiFully } = /** @type {typeof import("agent-sanitizer")} */ (
|
|
89
|
+
await lazyImport("agent-sanitizer")
|
|
90
|
+
));
|
|
57
91
|
// Stryker restore all
|
|
58
92
|
/* c8 ignore stop */
|
|
59
93
|
|
|
@@ -67,9 +101,20 @@ try {
|
|
|
67
101
|
* @param {import("agent-control-plane-core").ToolCallEvent} event
|
|
68
102
|
* @param {((s: string) => string) | null} [strip] the ANSI stripper (defaults
|
|
69
103
|
* to the package's stripAnsiFully; injectable so the fail-closed path is testable)
|
|
104
|
+
* @param {Partial<typeof USER_PROMPT_MESSAGES>} [overrides] reason overrides,
|
|
105
|
+
* merged over the defaults so a partial table can never leave a field unset
|
|
70
106
|
* @returns {import("agent-control-plane-core").Verdict}
|
|
71
107
|
*/
|
|
72
|
-
export function judgeSanitizeUserPrompt(
|
|
108
|
+
export function judgeSanitizeUserPrompt(
|
|
109
|
+
event,
|
|
110
|
+
strip = stripAnsiFully,
|
|
111
|
+
overrides = USER_PROMPT_MESSAGES,
|
|
112
|
+
) {
|
|
113
|
+
// MERGED over the defaults, never substituted for them — a host that overrides
|
|
114
|
+
// one field would otherwise leave the rest undefined. main() threads the same
|
|
115
|
+
// object into its onError, where a missing field throws out of the catch and
|
|
116
|
+
// the gate emits nothing, which the harness reads as a pass: fail OPEN.
|
|
117
|
+
const messages = { ...USER_PROMPT_MESSAGES, ...overrides };
|
|
73
118
|
const { Decision, EventKind } = controlPlane();
|
|
74
119
|
// A payload the adapter cannot classify carries no readable prompt, so an
|
|
75
120
|
// abstain would fail OPEN on harness contract drift; this gate's posture is
|
|
@@ -77,18 +122,24 @@ export function judgeSanitizeUserPrompt(event, strip = stripAnsiFully) {
|
|
|
77
122
|
// channel — a non-PRE_TOOL event has no permissionDecision body — which Claude
|
|
78
123
|
// honors on UserPromptSubmit.)
|
|
79
124
|
if (event.event === EventKind.UNKNOWN)
|
|
80
|
-
return {
|
|
81
|
-
decision: Decision.DENY,
|
|
82
|
-
reason: "User prompt blocked (fail-closed): unrecognized hook payload.",
|
|
83
|
-
};
|
|
125
|
+
return { decision: Decision.DENY, reason: messages.unknownEvent };
|
|
84
126
|
if (event.event !== EventKind.PROMPT_SUBMIT)
|
|
85
127
|
return { decision: Decision.ALLOW };
|
|
86
|
-
// The module-load guard
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
128
|
+
// The module-load guard, one arm per binding. The two lazyImports are
|
|
129
|
+
// INDEPENDENT — each yields {} on its own failure — so a present stripper does
|
|
130
|
+
// not prove the classifier loaded, and guarding on it alone would let a
|
|
131
|
+
// classifier-only failure reach `classifyPrompt(...)` as a bare TypeError
|
|
132
|
+
// naming no package, no cause and no remedy: the exact diagnostic this hook
|
|
133
|
+
// now exists to produce. lazyImportErrorFor matches subpaths, so naming
|
|
134
|
+
// `agent-sanitizer/prompt` still recovers the recorded cause.
|
|
90
135
|
if (typeof strip !== "function")
|
|
91
|
-
throw
|
|
136
|
+
throw missingPackageError("agent-sanitizer", undefined, messages.remedy);
|
|
137
|
+
if (typeof classifyPrompt !== "function")
|
|
138
|
+
throw missingPackageError(
|
|
139
|
+
"agent-sanitizer/prompt",
|
|
140
|
+
undefined,
|
|
141
|
+
messages.remedy,
|
|
142
|
+
);
|
|
92
143
|
// The contract guarantees a string here: every adapter normalizes the
|
|
93
144
|
// prompt-submit input (Claude's parse coerces a missing/non-string prompt to
|
|
94
145
|
// "" via asString), so a defensive typeof re-check is a dead branch.
|
|
@@ -97,13 +148,13 @@ export function judgeSanitizeUserPrompt(event, strip = stripAnsiFully) {
|
|
|
97
148
|
const verdict = classifyPrompt(prompt, strip);
|
|
98
149
|
if (verdict.action === "pass") return { decision: Decision.ALLOW };
|
|
99
150
|
if (verdict.action === "note")
|
|
100
|
-
return { decision: Decision.ALLOW, additional_context:
|
|
151
|
+
return { decision: Decision.ALLOW, additional_context: messages.sgrNote };
|
|
101
152
|
// block: carry the reason AND a context note — UserPromptSubmit can't rewrite
|
|
102
153
|
// the prompt, so the context is the only forward signal about why it dropped.
|
|
103
154
|
return {
|
|
104
155
|
decision: Decision.DENY,
|
|
105
156
|
reason: verdict.reason,
|
|
106
|
-
additional_context:
|
|
157
|
+
additional_context: messages.blockContext,
|
|
107
158
|
};
|
|
108
159
|
}
|
|
109
160
|
|
|
@@ -112,9 +163,19 @@ export function judgeSanitizeUserPrompt(event, strip = stripAnsiFully) {
|
|
|
112
163
|
* @param {(chunk: string) => void} write
|
|
113
164
|
* @param {((s: string) => string) | null} [strip] the ANSI stripper (defaults
|
|
114
165
|
* to the package's stripAnsiFully; injectable so the fail-closed path is testable)
|
|
166
|
+
* @param {Partial<typeof USER_PROMPT_MESSAGES>} [overrides] reason overrides,
|
|
167
|
+
* merged over the defaults so a partial table can never leave a field unset
|
|
115
168
|
* @returns {Promise<void>}
|
|
116
169
|
*/
|
|
117
|
-
export async function main(
|
|
170
|
+
export async function main(
|
|
171
|
+
read,
|
|
172
|
+
write,
|
|
173
|
+
strip = stripAnsiFully,
|
|
174
|
+
overrides = USER_PROMPT_MESSAGES,
|
|
175
|
+
) {
|
|
176
|
+
// Merged, not substituted — see judgeSanitizeUserPrompt. onError below is the
|
|
177
|
+
// call site where a missing field would throw out of the catch and fail OPEN.
|
|
178
|
+
const messages = { ...USER_PROMPT_MESSAGES, ...overrides };
|
|
118
179
|
// Delegate the parse → judge → render → write contract to the shared
|
|
119
180
|
// runJudgeCli so this hook doesn't re-implement the control-plane boundary:
|
|
120
181
|
// runJudgeCli reads stdin BEFORE loading the control-plane package, so a
|
|
@@ -125,7 +186,7 @@ export async function main(read, write, strip = stripAnsiFully) {
|
|
|
125
186
|
await runJudgeCli(
|
|
126
187
|
"sanitize-user-prompt",
|
|
127
188
|
(event) => {
|
|
128
|
-
const verdict = judgeSanitizeUserPrompt(event, strip);
|
|
189
|
+
const verdict = judgeSanitizeUserPrompt(event, strip, messages);
|
|
129
190
|
// Announce engagement on the trace channel like the other stdin hooks —
|
|
130
191
|
// a prompt gate that silently stopped running is otherwise invisible.
|
|
131
192
|
trace(TraceEvent.HOOK_RAN, {
|
|
@@ -146,7 +207,7 @@ export async function main(read, write, strip = stripAnsiFully) {
|
|
|
146
207
|
write(
|
|
147
208
|
JSON.stringify({
|
|
148
209
|
decision: "block",
|
|
149
|
-
reason:
|
|
210
|
+
reason: messages.hookFailed(safeErrMessage(err)),
|
|
150
211
|
}),
|
|
151
212
|
),
|
|
152
213
|
},
|
|
@@ -7,7 +7,15 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { readFileSync, globSync, writeFileSync, unlinkSync } from "node:fs";
|
|
9
9
|
import { join, relative } from "node:path";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
awaitLazyDependency,
|
|
12
|
+
hookgateMarkerPath,
|
|
13
|
+
isMain,
|
|
14
|
+
lazyImport,
|
|
15
|
+
markerIsTrusted,
|
|
16
|
+
probeSetupAlive,
|
|
17
|
+
writeFileNoFollow,
|
|
18
|
+
} from "./lib/hook-io.mjs";
|
|
11
19
|
import {
|
|
12
20
|
ALERT_FILE,
|
|
13
21
|
ALERT_ACK_FILE,
|
|
@@ -17,9 +25,11 @@ import { trace, TraceEvent } from "./lib/trace.mjs";
|
|
|
17
25
|
|
|
18
26
|
// Layer-1 primitives, bound via lazyImport (see its doc for the fail-OPEN
|
|
19
27
|
// hazard of a bare static npm import — here the instruction files would load
|
|
20
|
-
// UNSCANNED). A failed load leaves the bindings undefined
|
|
21
|
-
//
|
|
22
|
-
|
|
28
|
+
// UNSCANNED). A failed load leaves the bindings undefined; on a cold container
|
|
29
|
+
// (node deps not yet installed) cliMain's guard below waits out session-setup
|
|
30
|
+
// before giving up, and fails loud rather than silently passing.
|
|
31
|
+
// `let`, not `const`: the cold-start poll re-binds these once the package loads.
|
|
32
|
+
let {
|
|
23
33
|
LONG_RUN_RE,
|
|
24
34
|
LONG_RUN_THRESHOLD,
|
|
25
35
|
SCATTERED_THRESHOLD: TOTAL_INVISIBLE_THRESHOLD,
|
|
@@ -29,6 +39,44 @@ const {
|
|
|
29
39
|
await lazyImport("agent-sanitizer/invisible")
|
|
30
40
|
);
|
|
31
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Re-attempt the sanitizer import, waiting out an in-flight session-setup before
|
|
44
|
+
* giving up. On a cold container the node deps this hook needs are still being
|
|
45
|
+
* installed when SessionStart fires; without this wait `stripInvisible` is
|
|
46
|
+
* undefined, the scan is skipped, and the instruction files load UNSCANNED for the
|
|
47
|
+
* whole session (fail open) — silently. Reuses the control-plane poll (marker +
|
|
48
|
+
* PID liveness) so the wait bound matches every other cold-start-aware gate.
|
|
49
|
+
* @returns {Promise<boolean>} whether the sanitizer is now bound
|
|
50
|
+
*/
|
|
51
|
+
async function ensureSanitizerLoaded() {
|
|
52
|
+
if (typeof stripInvisible === "function") return true;
|
|
53
|
+
/* c8 ignore start -- cold-start reload: only runs when the top-level
|
|
54
|
+
agent-sanitizer import above failed (node deps not yet installed), which
|
|
55
|
+
can't be simulated in-process or in the spawned-subprocess CLI run the tests
|
|
56
|
+
observe (the test env always has the deps, so the guard above early-returns).
|
|
57
|
+
The reload reuses awaitLazyDependency / markerIsTrusted /
|
|
58
|
+
probeSetupAlive, each unit-tested directly. */
|
|
59
|
+
const marker = hookgateMarkerPath();
|
|
60
|
+
const reloaded = await awaitLazyDependency({
|
|
61
|
+
tryImport: async () => {
|
|
62
|
+
const mod = await lazyImport("agent-sanitizer/invisible");
|
|
63
|
+
return typeof mod.stripInvisible === "function" ? mod : null;
|
|
64
|
+
},
|
|
65
|
+
markerPresent: () => markerIsTrusted(marker),
|
|
66
|
+
setupAlive: () => probeSetupAlive(marker),
|
|
67
|
+
});
|
|
68
|
+
if (!reloaded) return false;
|
|
69
|
+
({
|
|
70
|
+
LONG_RUN_RE,
|
|
71
|
+
LONG_RUN_THRESHOLD,
|
|
72
|
+
SCATTERED_THRESHOLD: TOTAL_INVISIBLE_THRESHOLD,
|
|
73
|
+
STRIP,
|
|
74
|
+
stripInvisible,
|
|
75
|
+
} = /** @type {typeof import("agent-sanitizer/invisible")} */ (reloaded));
|
|
76
|
+
return true;
|
|
77
|
+
/* c8 ignore stop */
|
|
78
|
+
}
|
|
79
|
+
|
|
32
80
|
// Decoder
|
|
33
81
|
|
|
34
82
|
/**
|
|
@@ -229,14 +277,15 @@ export async function cliMain() {
|
|
|
229
277
|
/* c8 ignore start -- fail-closed module-load guard: only reachable when the
|
|
230
278
|
agent-sanitizer import above failed, which can't be simulated in the
|
|
231
279
|
spawned-subprocess CLI run the tests observe. */
|
|
232
|
-
if (
|
|
280
|
+
if (!(await ensureSanitizerLoaded())) {
|
|
233
281
|
// Emit the engagement event with a "skipped" outcome so the loss is LOUD on
|
|
234
282
|
// the trace channel — a scan that never ran is otherwise invisible, and the
|
|
235
283
|
// downstream PreToolUse sanitize gate then passes cleanly all session.
|
|
236
284
|
trace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, { outcome: "skipped" });
|
|
237
285
|
process.stderr.write(
|
|
238
|
-
"scan-invisible-chars: agent-sanitizer failed to load
|
|
239
|
-
"
|
|
286
|
+
"scan-invisible-chars: agent-sanitizer failed to load (node deps not " +
|
|
287
|
+
"installed and session-setup did not finish in time); instruction " +
|
|
288
|
+
"files were NOT scanned for hidden Unicode. Run `pnpm install`.\n",
|
|
240
289
|
);
|
|
241
290
|
process.exit(1);
|
|
242
291
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.9.1",
|
|
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": {
|
|
@@ -153,6 +153,34 @@
|
|
|
153
153
|
"./claude-hooks/lib/control-plane": {
|
|
154
154
|
"types": "./types/claude-hooks/lib/control-plane.d.mts",
|
|
155
155
|
"default": "./claude-hooks/lib/control-plane.mjs"
|
|
156
|
+
},
|
|
157
|
+
"./claude-hooks/lib/invisible-alert": {
|
|
158
|
+
"types": "./types/claude-hooks/lib/invisible-alert.d.mts",
|
|
159
|
+
"default": "./claude-hooks/lib/invisible-alert.mjs"
|
|
160
|
+
},
|
|
161
|
+
"./claude-hooks/lib/authored-content": {
|
|
162
|
+
"types": "./types/claude-hooks/lib/authored-content.d.mts",
|
|
163
|
+
"default": "./claude-hooks/lib/authored-content.mjs"
|
|
164
|
+
},
|
|
165
|
+
"./claude-hooks/lib/env-config": {
|
|
166
|
+
"types": "./types/claude-hooks/lib/env-config.d.mts",
|
|
167
|
+
"default": "./claude-hooks/lib/env-config.mjs"
|
|
168
|
+
},
|
|
169
|
+
"./claude-hooks/lib/secret-annotate": {
|
|
170
|
+
"types": "./types/claude-hooks/lib/secret-annotate.d.mts",
|
|
171
|
+
"default": "./claude-hooks/lib/secret-annotate.mjs"
|
|
172
|
+
},
|
|
173
|
+
"./claude-hooks/lib/reveal": {
|
|
174
|
+
"types": "./types/claude-hooks/lib/reveal.d.mts",
|
|
175
|
+
"default": "./claude-hooks/lib/reveal.mjs"
|
|
176
|
+
},
|
|
177
|
+
"./claude-hooks/lib/redactor-client": {
|
|
178
|
+
"types": "./types/claude-hooks/lib/redactor-client.d.mts",
|
|
179
|
+
"default": "./claude-hooks/lib/redactor-client.mjs"
|
|
180
|
+
},
|
|
181
|
+
"./claude-hooks/lib/trace": {
|
|
182
|
+
"types": "./types/claude-hooks/lib/trace.d.mts",
|
|
183
|
+
"default": "./claude-hooks/lib/trace.mjs"
|
|
156
184
|
}
|
|
157
185
|
},
|
|
158
186
|
"files": [
|
|
@@ -62,6 +62,47 @@ export function registeredLazyModule(specifier: string): Record<string, any> | u
|
|
|
62
62
|
* @returns {Promise<Record<string, any>>}
|
|
63
63
|
*/
|
|
64
64
|
export function lazyImport(specifier: string): Promise<Record<string, any>>;
|
|
65
|
+
/**
|
|
66
|
+
* The most recently recorded load error for `pkg` under any of its specifiers —
|
|
67
|
+
* the bare package or a subpath export (`pkg/output`, `pkg/invisible`) — or
|
|
68
|
+
* undefined when none is recorded. Hooks import a package through several
|
|
69
|
+
* subpaths; any one of them names why the package is absent, and the newest
|
|
70
|
+
* record reflects the current failure when they differ.
|
|
71
|
+
* @param {string} pkg
|
|
72
|
+
* @returns {unknown}
|
|
73
|
+
*/
|
|
74
|
+
export function lazyImportErrorFor(pkg: string): unknown;
|
|
75
|
+
/**
|
|
76
|
+
* Package names (never relative-path specifiers) with a recorded load error,
|
|
77
|
+
* newest first — so a fail-closed reason can name whichever dependency actually
|
|
78
|
+
* failed instead of consulting a hardcoded package list.
|
|
79
|
+
* @returns {string[]}
|
|
80
|
+
*/
|
|
81
|
+
export function failedLazyPackages(): string[];
|
|
82
|
+
/**
|
|
83
|
+
* The fail-closed reason for a package a hook could not load: the recorded
|
|
84
|
+
* loader error plus the remedy. The cause is scrubbed (it is spliced into
|
|
85
|
+
* reasons shown to user and model) and its cap is COMPUTED so that
|
|
86
|
+
* prefix + cause + remedy always fits the downstream 300-char safeErrMessage
|
|
87
|
+
* re-scrub — the remedy can never be truncated off, whatever the package name.
|
|
88
|
+
* A remedy that alone exceeds that budget (roughly 260 characters) leaves the
|
|
89
|
+
* cause nothing to spend and still overruns; keep host remedies to a sentence.
|
|
90
|
+
* @param {string} pkg
|
|
91
|
+
* @param {unknown} [err]
|
|
92
|
+
* @param {string} [remedy]
|
|
93
|
+
* @returns {string}
|
|
94
|
+
*/
|
|
95
|
+
export function missingPackageMessage(pkg: string, err?: unknown, remedy?: string): string;
|
|
96
|
+
/**
|
|
97
|
+
* {@link missingPackageMessage} as a throwable, tagged `code: "DEP_UNAVAILABLE"`
|
|
98
|
+
* so downstream reason-builders can recognize it structurally and not append a
|
|
99
|
+
* second copy of the same cause.
|
|
100
|
+
* @param {string} pkg
|
|
101
|
+
* @param {unknown} [err]
|
|
102
|
+
* @param {string} [remedy]
|
|
103
|
+
* @returns {Error}
|
|
104
|
+
*/
|
|
105
|
+
export function missingPackageError(pkg: string, err?: unknown, remedy?: string): Error;
|
|
65
106
|
/**
|
|
66
107
|
* A monotonic wall-clock budget shared across one hook run's downstream blocking
|
|
67
108
|
* calls. `remainingMs()` returns the milliseconds left until the budget is spent
|
|
@@ -126,6 +167,77 @@ export function safeErrMessage(err: unknown, cap?: number): string;
|
|
|
126
167
|
* @returns {void}
|
|
127
168
|
*/
|
|
128
169
|
export function emitHookResponse(hookEventName: string, fields: Record<string, unknown>): void;
|
|
170
|
+
/**
|
|
171
|
+
* Path of the cold-start in-flight marker a host's setup script writes
|
|
172
|
+
* SYNCHRONOUSLY before it starts installing deps (its own PID as the contents)
|
|
173
|
+
* and removes once the hook dependencies are provisioned. A hook that fires
|
|
174
|
+
* before setup finishes finds the marker and WAITS for its dependency rather
|
|
175
|
+
* than failing closed on it — so the first turn is merely delayed, never
|
|
176
|
+
* blocked, for as long as setup is still alive (the PID lets the hook tell a
|
|
177
|
+
* live install from a stale marker left by a killed setup). Derived purely from
|
|
178
|
+
* the raw CLAUDE_PROJECT_DIR the harness sets for both processes (no
|
|
179
|
+
* canonicalization — the two must produce byte-identical paths), so no env has
|
|
180
|
+
* to propagate from setup to the hook. Null when CLAUDE_PROJECT_DIR is unset (no
|
|
181
|
+
* setup ran → nothing to wait on).
|
|
182
|
+
* @param {string | undefined} [projectDir]
|
|
183
|
+
* @param {string | undefined} [runtimeDir]
|
|
184
|
+
* @returns {string | null}
|
|
185
|
+
*/
|
|
186
|
+
export function hookgateMarkerPath(projectDir?: string | undefined, runtimeDir?: string | undefined): string | null;
|
|
187
|
+
/**
|
|
188
|
+
* Is the setup process that wrote `markerPath` still alive? `process.kill(pid, 0)`
|
|
189
|
+
* probes liveness without signalling: it throws ESRCH once the process is gone (a
|
|
190
|
+
* killed setup → stale marker, so stop waiting) and EPERM when it exists but isn't
|
|
191
|
+
* ours (still alive). An unreadable / not-yet-written marker is treated as alive —
|
|
192
|
+
* favouring a brief wait over a premature give-up during setup's write race. A null
|
|
193
|
+
* markerPath (no project dir → no setup to wait on) reads as alive so the caller's
|
|
194
|
+
* own grace/ceiling bound governs.
|
|
195
|
+
* @param {string | null} markerPath
|
|
196
|
+
* @returns {boolean}
|
|
197
|
+
*/
|
|
198
|
+
export function probeSetupAlive(markerPath: string | null): boolean;
|
|
199
|
+
/**
|
|
200
|
+
* Resolve a lazily-loaded dependency, blocking through the cold-start window while
|
|
201
|
+
* setup is still installing it. Returns the loaded value, or null once it gives up
|
|
202
|
+
* (the caller leaves its bindings undefined so the hook fails closed). It waits for
|
|
203
|
+
* as long as setup is genuinely alive, so a slow install is never cut off; the only
|
|
204
|
+
* bound on that wait is a backstop ceiling that stays under the hook's harness
|
|
205
|
+
* timeout — a hook killed for running over is a fail-OPEN, the opposite of what a
|
|
206
|
+
* gate wants. The give-up cases are the honest ones (setup finished/died without the
|
|
207
|
+
* dep, or no setup at all), so a genuinely-absent dep fails closed fast, never after
|
|
208
|
+
* a long block:
|
|
209
|
+
* - import succeeds → return immediately (warm session: no wait).
|
|
210
|
+
* - marker present AND setup alive → setup is working; wait it out (ceilingMs is a
|
|
211
|
+
* backstop only, for a hung-but-alive setup).
|
|
212
|
+
* - was installing, now not (marker cleared, or a stale marker from a killed setup)
|
|
213
|
+
* → settleMs grace for a just-orphaned install to
|
|
214
|
+
* land, then give up: the dep is absent.
|
|
215
|
+
* - no live setup ever seen → wait only graceMs (tolerating setup not having
|
|
216
|
+
* written the marker yet), then give up.
|
|
217
|
+
* @param {{
|
|
218
|
+
* tryImport: () => Promise<object | null>,
|
|
219
|
+
* markerPresent: () => boolean,
|
|
220
|
+
* setupAlive: () => boolean,
|
|
221
|
+
* now?: () => number,
|
|
222
|
+
* sleep?: (ms: number) => Promise<void>,
|
|
223
|
+
* graceMs?: number,
|
|
224
|
+
* settleMs?: number,
|
|
225
|
+
* ceilingMs?: number,
|
|
226
|
+
* intervalMs?: number,
|
|
227
|
+
* }} deps
|
|
228
|
+
* @returns {Promise<object | null>}
|
|
229
|
+
*/
|
|
230
|
+
export function awaitLazyDependency({ tryImport, markerPresent, setupAlive, now, sleep, graceMs, settleMs, ceilingMs, intervalMs, }: {
|
|
231
|
+
tryImport: () => Promise<object | null>;
|
|
232
|
+
markerPresent: () => boolean;
|
|
233
|
+
setupAlive: () => boolean;
|
|
234
|
+
now?: () => number;
|
|
235
|
+
sleep?: (ms: number) => Promise<void>;
|
|
236
|
+
graceMs?: number;
|
|
237
|
+
settleMs?: number;
|
|
238
|
+
ceilingMs?: number;
|
|
239
|
+
intervalMs?: number;
|
|
240
|
+
}): Promise<object | null>;
|
|
129
241
|
/**
|
|
130
242
|
* Is the file at `path` one WE wrote — a regular file owned by this uid — rather
|
|
131
243
|
* than a squat? These markers live at predictable, world-visible $TMPDIR paths, so
|
|
@@ -194,3 +306,10 @@ export const PermissionDecision: Readonly<{
|
|
|
194
306
|
* and take its own fail-closed output down with it.
|
|
195
307
|
*/
|
|
196
308
|
export const MAX_STDIN_BYTES: number;
|
|
309
|
+
/**
|
|
310
|
+
* The remedy {@link missingPackageMessage} states when the host does not supply
|
|
311
|
+
* one of its own. A host whose install has a specific entry point (a setup
|
|
312
|
+
* script, a devcontainer rebuild) passes that instead, so the reason names the
|
|
313
|
+
* command the reader should actually run.
|
|
314
|
+
*/
|
|
315
|
+
export const DEFAULT_MISSING_PACKAGE_REMEDY: "reinstall the hook dependencies (pnpm install) and retry.";
|