agent-sanitizer 2.23.1 → 2.24.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 +6 -3
- package/THREAT-MODEL.md +37 -10
- package/claude-hooks/lib/control-plane.mjs +17 -2
- package/claude-hooks/lib/hook-io.mjs +39 -0
- package/claude-hooks/lib/hook-timing.mjs +170 -0
- package/claude-hooks/lib/redactor-client.mjs +11 -2
- package/claude-hooks/sanitize-output.mjs +11 -6
- package/claude-hooks/sanitize-user-prompt.mjs +1 -1
- package/claude-hooks/scan-invisible-chars.mjs +107 -28
- package/package.json +1 -1
- package/src/ansi.mjs +66 -5
- package/src/index.mjs +7 -1
- package/src/invisible.mjs +96 -13
- package/src/layer1.mjs +105 -20
- package/src/output.mjs +21 -10
- package/src/prompt.mjs +16 -26
- package/types/ansi.d.mts +54 -4
- package/types/claude-hooks/lib/control-plane.d.mts +6 -1
- package/types/claude-hooks/lib/hook-timing.d.mts +106 -0
- package/types/claude-hooks/lib/redactor-client.d.mts +2 -1
- package/types/claude-hooks/scan-invisible-chars.d.mts +31 -14
- package/types/index.d.mts +1 -1
- package/types/invisible.d.mts +2 -0
- package/types/layer1.d.mts +54 -2
package/README.md
CHANGED
|
@@ -55,7 +55,7 @@ the callback you inject for the agent-specific concern; `—` is a pure transfor
|
|
|
55
55
|
| 3 | `/html` | Detect exfil-shaped URLs (payloads in query/path, embedded creds, `data:`/`javascript:`, off-origin redirects). Reports only. | — |
|
|
56
56
|
| 4 | `/confusables` | Fold look-alike glyphs in tool-call input (paths, commands) to ASCII, closing a cross-script deny-rule bypass. Gated per token, so non-Latin prose passes through unfolded. | `scan` |
|
|
57
57
|
| 5 | `/instructions` | Scan/auto-clean `CLAUDE.md`, `AGENTS.md`, `SKILL.md`, etc., decoding Unicode-tag + zero-width-binary payloads. | `fs` (direct) |
|
|
58
|
-
| 6 | `/prompt` | Classify a prompt pass /
|
|
58
|
+
| 6 | `/prompt` | Classify a prompt pass / note / block on payload-capable invisible/ANSI content (inert escapes get the note). | — |
|
|
59
59
|
| 7 | `/output` | Run Layers 1–4 over structured tool output, preserving shape. The Layer-5 slot takes a delete-only filter. | `redact`, `filterInjection` |
|
|
60
60
|
| 8 | `/rehydrate` | Re-anchor a model Edit composed from the _sanitized_ view back onto real bytes; deny anything ambiguous or secret-exposing. | `io` |
|
|
61
61
|
| — | `/view-map` | Pure offset/text machinery mapping a file's on-disk bytes ↔ the sanitized view (Layer-1 deletions, Layer-4 redactions). No I/O — consumed by `/rehydrate`. | — |
|
|
@@ -104,8 +104,11 @@ for a later span (overlapping spans resolve first-match-wins).
|
|
|
104
104
|
Installing the plugin puts four hooks on every session, and this is what they
|
|
105
105
|
buy you:
|
|
106
106
|
|
|
107
|
-
1. Your `CLAUDE.md`, `AGENTS.md` and `.claude/`
|
|
108
|
-
start for hidden-Unicode payloads and auto-cleaned where
|
|
107
|
+
1. Your `CLAUDE.md`, `AGENTS.md` and the context markdown under `.claude/` are
|
|
108
|
+
scanned at session start for hidden-Unicode payloads and auto-cleaned where
|
|
109
|
+
possible. Only the subdirectories Claude Code loads as context are walked, so
|
|
110
|
+
bulk data parked under `.claude/` (`worktrees/`, caches, transcripts) does not
|
|
111
|
+
slow startup.
|
|
109
112
|
2. Prompts carrying payload-capable invisible or ANSI characters are blocked
|
|
110
113
|
before they reach the model; pasted terminal color passes with a note.
|
|
111
114
|
3. Look-alike glyphs in tool inputs are folded to ASCII, so a Cyrillic `а` can't
|
package/THREAT-MODEL.md
CHANGED
|
@@ -35,6 +35,22 @@ table](./README.md#entry-points) maps each to its import.
|
|
|
35
35
|
Sinhala) or inside an emoji ZWJ sequence. The carve-out fires only when **both**
|
|
36
36
|
neighbors clearly belong to the context, and it is disabled once the total
|
|
37
37
|
invisible count crosses a scatter floor—over-stripping beats under-stripping.
|
|
38
|
+
- **Blank fillers doing real work in their own script**: the Braille blank
|
|
39
|
+
(U+2800) beside a real cell, a Hangul filler beside a real jamo/syllable. A
|
|
40
|
+
_run_ of fillers has only fillers for neighbors, so it fails the anchor and is
|
|
41
|
+
stripped. Because U+2800 _is_ the word space of Unicode Braille and a Hangul
|
|
42
|
+
filler completes a defective syllable, these are far denser in genuine text
|
|
43
|
+
than joiners are, so they carry their own document-wide allowance—one
|
|
44
|
+
preserved blank per two visible anchor-script characters, above a floor—rather
|
|
45
|
+
than drawing on the joiner/selector preserve budget. The allowance is counted
|
|
46
|
+
per script (a blank never anchors cross-script, so Korean prose must not fund
|
|
47
|
+
a Braille channel). Past that ratio no blank of that script is preserved
|
|
48
|
+
(never half-spaced) and all of them count as payload, which is the density an
|
|
49
|
+
alternating `syllable filler …` channel needs. Contracted (grade-2) Braille
|
|
50
|
+
sits closest to the boundary: alphabet wordsigns are single cells, so a
|
|
51
|
+
passage of mostly one-cell words approaches 1:1 and is stripped like the
|
|
52
|
+
channel—an accepted residual false positive inherent to a density rule, not a
|
|
53
|
+
gap, and not worth widening the ratio to reach.
|
|
38
54
|
|
|
39
55
|
**Reassembly hardening.** The two passes feed each other in _both_ directions:
|
|
40
56
|
stripping an invisible char can reconstitute an ANSI escape its split had
|
|
@@ -50,9 +66,18 @@ visible text; a final unconditional sweep after the loop keeps the
|
|
|
50
66
|
no-raw-introducer guarantee independent of the iteration bound. The result
|
|
51
67
|
carries no raw ANSI introducer for _any_ input, and re-cleaning it reproduces
|
|
52
68
|
it exactly—the idempotence the Edit-repair rehydrator's soundness gate assumes.
|
|
53
|
-
One tokenizer answers every ANSI question (what to splice, and whether
|
|
54
|
-
|
|
55
|
-
|
|
69
|
+
One tokenizer answers every ANSI question (what to splice, and whether what was
|
|
70
|
+
removed was INERT—display-only SGR colour, or a lone 7-bit `ESC` that opened
|
|
71
|
+
nothing at all), so the stripper and the operator warning cannot disagree about
|
|
72
|
+
what a sequence is. That inert/injection-shaped split is what keeps the warning
|
|
73
|
+
worth reading: a stray `ESC` sitting in a file is reported as a terse note, while
|
|
74
|
+
a cursor move, an erase, an OSC string, or a raw C1 introducer (which no
|
|
75
|
+
legitimate UTF-8 text carries, and which includes the DCS/SOS/PM/APC string
|
|
76
|
+
introducers) keeps the WARNING. An `ESC` that _opened_ a CSI it never completed
|
|
77
|
+
stays loud too: a terminal's CSI parser is stateful and keeps consuming what
|
|
78
|
+
follows until a final byte arrives, so `ESC[12 world` shows the human `orld`
|
|
79
|
+
while the model reads every word—the same model-sees/human-sees divergence a
|
|
80
|
+
complete sequence buys. OSC strings (titles,
|
|
56
81
|
clickable-hyperlink URLs) are consumed as a
|
|
57
82
|
whole, for every terminator form—ST (`ESC\` or 8-bit C1 ST U+009C) and the
|
|
58
83
|
legacy BEL—and for the 8-bit C1 OSC introducer (U+009D); an _unterminated_ OSC
|
|
@@ -164,13 +189,15 @@ if a contaminated file cannot be rewritten.
|
|
|
164
189
|
`./prompt` classifies a submitted prompt as **pass / pass-with-note / block** on
|
|
165
190
|
payload-capable invisible Unicode and ANSI. A prompt-submission channel usually
|
|
166
191
|
cannot rewrite the prompt in place, so the only neutralization is to block.
|
|
167
|
-
One carve-out: a prompt whose only escape content is
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
C1-introduced cursor-move,
|
|
173
|
-
mistaken for benign color
|
|
192
|
+
One carve-out: a prompt whose only escape content is INERT passes with a note —
|
|
193
|
+
display-only SGR color, and/or a 7-bit `ESC` that completes no sequence (a log
|
|
194
|
+
line cut mid-escape). Pasting colored terminal output is the common case, and
|
|
195
|
+
neither form can move the cursor, erase, or carry an OSC payload. The test gates
|
|
196
|
+
on both the 7-bit ESC (`U+001B`) introducer and the whole 8-bit C1 control block
|
|
197
|
+
(U+0080–U+009F)—not just the CSI byte (`U+009B`)—so a C1-introduced cursor-move,
|
|
198
|
+
erase, or OSC/DCS/SOS/PM/APC string is never mistaken for benign color; and it
|
|
199
|
+
judges from what the Layer-1 strip actually removed, so a sequence that only
|
|
200
|
+
RECONSTITUTES during stripping is judged as the sequence it becomes.
|
|
174
201
|
|
|
175
202
|
## Tool-output pipeline & Layer 5
|
|
176
203
|
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
probeSetupAlive,
|
|
18
18
|
readStdinJson,
|
|
19
19
|
} from "./hook-io.mjs";
|
|
20
|
+
import { startHookTimer, withSlowHookNotice } from "./hook-timing.mjs";
|
|
20
21
|
|
|
21
22
|
// Loaded via a *caught* dynamic import — never a bare static `import … from`.
|
|
22
23
|
// A static npm import resolves before any try/catch, so a missing node_modules
|
|
@@ -128,7 +129,12 @@ export function nativeStdout(response) {
|
|
|
128
129
|
* unparsable stdin, missing package, a judge error — is reported on stderr and
|
|
129
130
|
* routed to `onError(err, input)` (`input` undefined when stdin never parsed),
|
|
130
131
|
* where the hook applies its declared fail posture.
|
|
131
|
-
*
|
|
132
|
+
*
|
|
133
|
+
* It is also where every judge hook is TIMED: the verdict picks up a
|
|
134
|
+
* performance note when the judge overran the hook budget (see
|
|
135
|
+
* lib/hook-timing.mjs), so no hook has to remember to measure itself.
|
|
136
|
+
* @param {string} hookName prefix for the stderr diagnostic, and the hook name
|
|
137
|
+
* a slow-run notice reports
|
|
132
138
|
* @param {(event: import("agent-control-plane-core").ToolCallEvent) =>
|
|
133
139
|
* import("agent-control-plane-core").Verdict |
|
|
134
140
|
* Promise<import("agent-control-plane-core").Verdict>} judge
|
|
@@ -152,9 +158,18 @@ export async function runJudgeCli(
|
|
|
152
158
|
let input;
|
|
153
159
|
try {
|
|
154
160
|
input = await readInput();
|
|
161
|
+
// Timed from HERE, not from process start: the wait for the harness to hand
|
|
162
|
+
// over stdin is not this hook's cost, and blaming it for one would send
|
|
163
|
+
// operators chasing a bug report that is not theirs to fix.
|
|
164
|
+
const elapsed = startHookTimer();
|
|
155
165
|
const { claudeAdapter: adapter } = controlPlane();
|
|
156
166
|
const event = adapter.parse(transformInput(input));
|
|
157
|
-
|
|
167
|
+
// Awaited into its own binding first: as an inline argument, `elapsed()`
|
|
168
|
+
// would be evaluated BEFORE the judge it is supposed to be timing.
|
|
169
|
+
const judged = await judge(event);
|
|
170
|
+
const out = nativeStdout(
|
|
171
|
+
adapter.render(withSlowHookNotice(hookName, elapsed(), judged), event),
|
|
172
|
+
);
|
|
158
173
|
if (out !== null) write(out);
|
|
159
174
|
} catch (err) {
|
|
160
175
|
process.stderr.write(`${hookName} hook error: ${errMessage(err)}\n`);
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { userInfo } from "node:os";
|
|
12
12
|
import { createHash } from "node:crypto";
|
|
13
13
|
import { pathToFileURL } from "node:url";
|
|
14
|
+
import { excludeProvisioning } from "./hook-timing.mjs";
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* EVERY process-wide slot these helpers keep — the four a host can observe or
|
|
@@ -742,6 +743,44 @@ export async function awaitLazyDependency({
|
|
|
742
743
|
settleMs = 1000,
|
|
743
744
|
ceilingMs = 900000,
|
|
744
745
|
intervalMs = 250,
|
|
746
|
+
}) {
|
|
747
|
+
// The whole wait is PROVISIONING, not this hook's own cost: it is time the
|
|
748
|
+
// container spends installing dependencies, and on a cold start it can run to
|
|
749
|
+
// minutes. Charging it to the hook would make the slow-hook notice fire on
|
|
750
|
+
// every cold session with a number that names the wrong culprit.
|
|
751
|
+
return excludeProvisioning(
|
|
752
|
+
() =>
|
|
753
|
+
pollForDependency({
|
|
754
|
+
tryImport,
|
|
755
|
+
markerPresent,
|
|
756
|
+
setupAlive,
|
|
757
|
+
now,
|
|
758
|
+
sleep,
|
|
759
|
+
graceMs,
|
|
760
|
+
settleMs,
|
|
761
|
+
ceilingMs,
|
|
762
|
+
intervalMs,
|
|
763
|
+
}),
|
|
764
|
+
now,
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* The poll loop {@link awaitLazyDependency} wraps. Split out so the
|
|
770
|
+
* provisioning charge brackets every exit — including the give-up arms.
|
|
771
|
+
* @param {Required<Parameters<typeof awaitLazyDependency>[0]>} opts
|
|
772
|
+
* @returns {Promise<Record<string, any> | null>}
|
|
773
|
+
*/
|
|
774
|
+
async function pollForDependency({
|
|
775
|
+
tryImport,
|
|
776
|
+
markerPresent,
|
|
777
|
+
setupAlive,
|
|
778
|
+
now,
|
|
779
|
+
sleep,
|
|
780
|
+
graceMs,
|
|
781
|
+
settleMs,
|
|
782
|
+
ceilingMs,
|
|
783
|
+
intervalMs,
|
|
745
784
|
}) {
|
|
746
785
|
const start = now();
|
|
747
786
|
let sawInstalling = false;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place a hook's own wall-clock cost is measured and reported.
|
|
3
|
+
*
|
|
4
|
+
* These hooks sit on the critical path of every tool call, every prompt and
|
|
5
|
+
* every session start: whatever they spend, the user waits. That cost is also
|
|
6
|
+
* the hardest kind of bug to notice from inside — a hook that got slow looks
|
|
7
|
+
* exactly like an agent that got slow, so it goes unreported for weeks (one
|
|
8
|
+
* SessionStart scan blocked startup for 30 SECONDS before anyone traced it back
|
|
9
|
+
* here). A hook past the budget therefore says so IN BAND, in the model's
|
|
10
|
+
* context, where it cannot be missed and can be relayed to the operator.
|
|
11
|
+
*
|
|
12
|
+
* One threshold, one message, one merge rule, shared by every hook — the
|
|
13
|
+
* measurement is worthless if each hook words it differently or picks its own
|
|
14
|
+
* bar for "slow".
|
|
15
|
+
*
|
|
16
|
+
* What it deliberately does NOT count is ONE-TIME PROVISIONING (see
|
|
17
|
+
* {@link excludeProvisioning}). A dependency-install wait or a cold redactor
|
|
18
|
+
* spawn is wall-clock the user really waits, but it is not a cost this hook
|
|
19
|
+
* pays per call and it is not a bug worth a report — charging it would make the
|
|
20
|
+
* FIRST call of every session cry wolf, which is precisely the alert fatigue
|
|
21
|
+
* this notice exists to avoid.
|
|
22
|
+
*
|
|
23
|
+
* Dependency-free on purpose: everything imports this, including hook-io, so a
|
|
24
|
+
* back-import would close a cycle. The one emitter it needs is passed in.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Wall-clock a single hook invocation may spend before it is reported as slow.
|
|
29
|
+
*
|
|
30
|
+
* A second is far above anything these hooks do when healthy (Layer 1 is a few
|
|
31
|
+
* regex passes; the redactor daemon answers in tens of milliseconds once warm)
|
|
32
|
+
* and far below the point where a human is merely impatient — so crossing it
|
|
33
|
+
* means something is actually wrong, not that the machine is busy.
|
|
34
|
+
*/
|
|
35
|
+
export const SLOW_HOOK_THRESHOLD_MS = 1000;
|
|
36
|
+
|
|
37
|
+
/** Where a reader is asked to send the timing. */
|
|
38
|
+
const ISSUE_URL =
|
|
39
|
+
"https://github.com/AlexanderMattTurner/agent-sanitizer/issues/new";
|
|
40
|
+
|
|
41
|
+
// Process-wide total of milliseconds spent in one-time provisioning. A running
|
|
42
|
+
// total rather than a flag because a single hook run can pay more than one (a
|
|
43
|
+
// dependency wait AND a cold daemon spawn), and they may not nest.
|
|
44
|
+
let provisioningMs = 0;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Run `work`, charging its whole duration to provisioning so no timer running
|
|
48
|
+
* across it counts that time. Charged in a `finally`, so a provisioning step
|
|
49
|
+
* that FAILS is still excluded — the wait happened either way, and a hook that
|
|
50
|
+
* then fails is reported through its fault posture, not as "slow".
|
|
51
|
+
*
|
|
52
|
+
* Wrap only genuinely one-time, per-session setup: waiting out a dependency
|
|
53
|
+
* install, waiting for a cold redactor daemon to bind. Never wrap the hook's
|
|
54
|
+
* actual work — that is exactly what this measurement is for.
|
|
55
|
+
* @template T
|
|
56
|
+
* @param {() => Promise<T>} work
|
|
57
|
+
* @param {() => number} [now] injectable clock, for tests
|
|
58
|
+
* @returns {Promise<T>}
|
|
59
|
+
*/
|
|
60
|
+
export async function excludeProvisioning(work, now = Date.now) {
|
|
61
|
+
const started = now();
|
|
62
|
+
try {
|
|
63
|
+
return await work();
|
|
64
|
+
} finally {
|
|
65
|
+
provisioningMs += Math.max(0, now() - started);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Start measuring; the returned function reports the milliseconds elapsed so
|
|
71
|
+
* far MINUS any provisioning charged in the meantime, and may be called more
|
|
72
|
+
* than once.
|
|
73
|
+
*
|
|
74
|
+
* Only provisioning charged since this timer started is subtracted, so an
|
|
75
|
+
* earlier run's cold start cannot pay down a later run's real cost. A
|
|
76
|
+
* provisioning window that straddles the timer's start would otherwise be able
|
|
77
|
+
* to subtract more than the timer has measured, so the result is floored at 0.
|
|
78
|
+
* @param {() => number} [now] injectable clock, for tests
|
|
79
|
+
* @returns {() => number}
|
|
80
|
+
*/
|
|
81
|
+
export function startHookTimer(now = Date.now) {
|
|
82
|
+
const started = now();
|
|
83
|
+
const provisionedBefore = provisioningMs;
|
|
84
|
+
return () =>
|
|
85
|
+
Math.max(0, now() - started - (provisioningMs - provisionedBefore));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The model-facing line for a hook that overran the budget, or null when it did
|
|
90
|
+
* not. Addressed to the model because the model is the only party that reliably
|
|
91
|
+
* reads this channel — stderr from a non-blocking hook is easy to miss — and it
|
|
92
|
+
* is asked to relay the number, since the operator is the one who can file it.
|
|
93
|
+
* @param {string} hookName
|
|
94
|
+
* @param {number} elapsedMs
|
|
95
|
+
* @param {number} [thresholdMs]
|
|
96
|
+
* @returns {string | null}
|
|
97
|
+
*/
|
|
98
|
+
export function slowHookNotice(
|
|
99
|
+
hookName,
|
|
100
|
+
elapsedMs,
|
|
101
|
+
thresholdMs = SLOW_HOOK_THRESHOLD_MS,
|
|
102
|
+
) {
|
|
103
|
+
if (elapsedMs <= thresholdMs) return null;
|
|
104
|
+
return (
|
|
105
|
+
`agent-sanitizer PERFORMANCE: the ${hookName} hook took ` +
|
|
106
|
+
`${(elapsedMs / 1000).toFixed(1)}s, over its ${(thresholdMs / 1000).toFixed(1)}s budget — ` +
|
|
107
|
+
"this delay is the sanitizer's, not the model's, and every affected call pays it. " +
|
|
108
|
+
`Tell the user, and suggest they report it at ${ISSUE_URL} with the hook name and timing.`
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* `verdict` with the slow-hook notice folded into its `additional_context`, or
|
|
114
|
+
* the verdict untouched when the run was within budget. Also writes the notice
|
|
115
|
+
* to stderr, so the timing survives in the transcript even for a hook whose
|
|
116
|
+
* verdict carries no context channel to the model.
|
|
117
|
+
*
|
|
118
|
+
* Appended, never substituted: the context slot is how a hook reports a REDACTED
|
|
119
|
+
* secret or a stripped payload, and a timing note must not displace that.
|
|
120
|
+
* @template {{ additional_context?: string }} V
|
|
121
|
+
* @param {string} hookName
|
|
122
|
+
* @param {number} elapsedMs
|
|
123
|
+
* @param {V} verdict
|
|
124
|
+
* @param {(chunk: string) => void} [writeErr] injectable stderr sink, for tests
|
|
125
|
+
* @returns {V}
|
|
126
|
+
*/
|
|
127
|
+
export function withSlowHookNotice(
|
|
128
|
+
hookName,
|
|
129
|
+
elapsedMs,
|
|
130
|
+
verdict,
|
|
131
|
+
writeErr = (chunk) => process.stderr.write(chunk),
|
|
132
|
+
) {
|
|
133
|
+
const notice = slowHookNotice(hookName, elapsedMs);
|
|
134
|
+
if (notice === null) return verdict;
|
|
135
|
+
writeErr(notice + "\n");
|
|
136
|
+
return {
|
|
137
|
+
...verdict,
|
|
138
|
+
additional_context: verdict.additional_context
|
|
139
|
+
? `${verdict.additional_context} ${notice}`
|
|
140
|
+
: notice,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Report a slow run for a hook that answers with a bare `hookSpecificOutput`
|
|
146
|
+
* envelope rather than a control-plane verdict — SessionStart, which has no
|
|
147
|
+
* verdict channel at all. A within-budget run emits nothing, so the quiet path
|
|
148
|
+
* stays quiet (and the hook's silent-success contract is unchanged).
|
|
149
|
+
* @param {string} hookName
|
|
150
|
+
* @param {number} elapsedMs
|
|
151
|
+
* @param {string} hookEventName
|
|
152
|
+
* @param {(event: string, fields: Record<string, unknown>) => void} emit the
|
|
153
|
+
* stdout envelope writer (hook-io's emitHookResponse); passed in rather than
|
|
154
|
+
* imported so this module stays dependency-free — see the module doc
|
|
155
|
+
* @param {(chunk: string) => void} [writeErr] injectable stderr sink, for tests
|
|
156
|
+
* @returns {boolean} whether a notice was emitted
|
|
157
|
+
*/
|
|
158
|
+
export function reportSlowHook(
|
|
159
|
+
hookName,
|
|
160
|
+
elapsedMs,
|
|
161
|
+
hookEventName,
|
|
162
|
+
emit,
|
|
163
|
+
writeErr = (chunk) => process.stderr.write(chunk),
|
|
164
|
+
) {
|
|
165
|
+
const notice = slowHookNotice(hookName, elapsedMs);
|
|
166
|
+
if (notice === null) return false;
|
|
167
|
+
writeErr(notice + "\n");
|
|
168
|
+
emit(hookEventName, { additionalContext: notice });
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* daemon could not vet input.
|
|
19
19
|
*/
|
|
20
20
|
import { spawn } from "node:child_process";
|
|
21
|
+
import { excludeProvisioning } from "./hook-timing.mjs";
|
|
21
22
|
import { existsSync, lstatSync } from "node:fs";
|
|
22
23
|
import { createConnection } from "node:net";
|
|
23
24
|
import { tmpdir, userInfo } from "node:os";
|
|
@@ -422,7 +423,7 @@ function canConnect(socketPath) {
|
|
|
422
423
|
* @param {{map?: boolean, webIngress?: boolean, socketPath?: string,
|
|
423
424
|
* deadline?: {remainingMs: () => number},
|
|
424
425
|
* connect?: typeof connectAndRequest, spawn?: typeof spawnDaemon,
|
|
425
|
-
* waitForSocket?: typeof waitForSocket}} [opts]
|
|
426
|
+
* waitForSocket?: typeof waitForSocket, now?: () => number}} [opts]
|
|
426
427
|
* @returns {Promise<RedactResponse|null>}
|
|
427
428
|
*/
|
|
428
429
|
export async function redactViaDaemon(text, opts = {}) {
|
|
@@ -434,6 +435,10 @@ export async function redactViaDaemon(text, opts = {}) {
|
|
|
434
435
|
connect = connectAndRequest,
|
|
435
436
|
spawn: spawnFn = spawnDaemon,
|
|
436
437
|
waitForSocket: waitFn = waitForSocket,
|
|
438
|
+
// The clock the provisioning charge is measured on; injectable alongside the
|
|
439
|
+
// waitForSocket seam it brackets, since a stubbed wait advances a test clock
|
|
440
|
+
// rather than real time.
|
|
441
|
+
now = Date.now,
|
|
437
442
|
} = opts;
|
|
438
443
|
// Remaining shared budget in ms, or undefined when no budget was threaded (the
|
|
439
444
|
// standalone default). Re-read per step so the respawn path cannot overshoot.
|
|
@@ -502,7 +507,11 @@ export async function redactViaDaemon(text, opts = {}) {
|
|
|
502
507
|
budgetMs === undefined
|
|
503
508
|
? undefined
|
|
504
509
|
: { deadlineMs: Math.min(WAIT_DEADLINE_MS, budgetMs) };
|
|
505
|
-
|
|
510
|
+
// The cold-start wait is PROVISIONING — a one-time detect-secrets import and
|
|
511
|
+
// plugin prime (~1-3s, see WAIT_DEADLINE_MS), paid by whichever tool call
|
|
512
|
+
// happens to be first and by no other. Charging it to this hook would make
|
|
513
|
+
// the slow-hook notice fire once per session on a healthy install.
|
|
514
|
+
if (!(await excludeProvisioning(() => waitFn(socketPath, waitOpts), now)))
|
|
506
515
|
throw failClosed(
|
|
507
516
|
new Error(`redactor daemon did not start within ${WAIT_DEADLINE_MS}ms`),
|
|
508
517
|
);
|
|
@@ -96,13 +96,18 @@ const SANITIZE_BUDGET_MS = positiveMsOr(
|
|
|
96
96
|
120000,
|
|
97
97
|
);
|
|
98
98
|
|
|
99
|
-
// Non-WARNING note for a strip whose only change was
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
99
|
+
// Non-WARNING note for a strip whose only change was INERT ANSI on a local tool:
|
|
100
|
+
// the display-only colour git/pytest/npm/etc. emit by default, and/or a stray
|
|
101
|
+
// escape byte that formed no sequence at all (a truncated write, a log fragment,
|
|
102
|
+
// a raw ESC sitting in a file the tool echoed back). Neither can move the cursor,
|
|
103
|
+
// erase the screen, or open an OSC string — those need a complete CSI/OSC token,
|
|
104
|
+
// which keeps the WARNING. The note keeps the "escapes were here, and here is how
|
|
105
|
+
// to see them" signal without the WARNING prefix, whose constant firing on inert
|
|
106
|
+
// bytes would desensitize the reader to the strips that matter (invisible-char
|
|
107
|
+
// payloads, redacted secrets).
|
|
104
108
|
const SGR_OUTPUT_NOTE =
|
|
105
|
-
"
|
|
109
|
+
"Inert ANSI stripped (display-only colour and/or a stray escape byte that " +
|
|
110
|
+
"formed no control sequence); pipe through cat -v to inspect raw escapes.";
|
|
106
111
|
|
|
107
112
|
// Web-ingress tools always get the Layer 2 HTML rewrite; local tools — Read,
|
|
108
113
|
// Bash, Grep, gh — never do. A local HTML/markdown pass either rewrites bytes the
|
|
@@ -65,7 +65,7 @@ export const USER_PROMPT_MESSAGES = Object.freeze({
|
|
|
65
65
|
blockContext:
|
|
66
66
|
"User prompt blocked: payload-capable invisible/ANSI characters detected.",
|
|
67
67
|
sgrNote:
|
|
68
|
-
"The prompt contains ANSI
|
|
68
|
+
"The prompt contains inert ANSI escapes (pasted terminal output): display-only SGR colour codes and/or a stray escape byte that forms no control sequence. They are formatting noise; read through them.",
|
|
69
69
|
hookFailed: (cause) =>
|
|
70
70
|
`sanitize-user-prompt hook failed (fail-closed): ${cause}`,
|
|
71
71
|
// What a reader should run when the package itself is what is missing. It
|
|
@@ -9,6 +9,7 @@ import { readFileSync, globSync, writeFileSync, unlinkSync } from "node:fs";
|
|
|
9
9
|
import { join, relative } from "node:path";
|
|
10
10
|
import {
|
|
11
11
|
awaitLazyDependency,
|
|
12
|
+
emitHookResponse,
|
|
12
13
|
safeErrMessage,
|
|
13
14
|
hookgateMarkerPath,
|
|
14
15
|
HookEvent,
|
|
@@ -29,6 +30,7 @@ import {
|
|
|
29
30
|
PROJECT_DIR,
|
|
30
31
|
} from "./lib/invisible-alert.mjs";
|
|
31
32
|
import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
|
|
33
|
+
import { reportSlowHook, startHookTimer } from "./lib/hook-timing.mjs";
|
|
32
34
|
|
|
33
35
|
// Layer-1 primitives, bound via lazyImport (see its doc for the fail-OPEN
|
|
34
36
|
// hazard of a bare static npm import — here the instruction files would load
|
|
@@ -191,27 +193,85 @@ function decodeRun(run) {
|
|
|
191
193
|
}
|
|
192
194
|
|
|
193
195
|
/**
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
+
* The `.claude/` subdirectories whose markdown Claude Code loads as model
|
|
197
|
+
* context. This is a WHITELIST, and that is the point: `.claude/` is also where
|
|
198
|
+
* tooling parks bulk data that is never loaded as context — `worktrees/`
|
|
199
|
+
* (entire checked-out copies of the repo), plus caches, transcripts and
|
|
200
|
+
* snapshots — and globbing `.claude/**` swept all of it in. On a repo with a few
|
|
201
|
+
* populated worktrees that is thousands of files READ at every session start:
|
|
202
|
+
* one report put it at 30 seconds of blocked startup, paid for scanning files
|
|
203
|
+
* that cannot reach the model.
|
|
204
|
+
*
|
|
205
|
+
* A whitelist, not a `worktrees` denylist, because the failure modes are not
|
|
206
|
+
* symmetric: an unlisted context directory costs a scan this hook was never
|
|
207
|
+
* asked for anyway (the PostToolUse sanitizer still cleans those bytes when a
|
|
208
|
+
* tool reads them), while an unlisted BULK directory silently costs every future
|
|
209
|
+
* session its startup. Add an entry here when Claude Code starts loading a new
|
|
210
|
+
* `.claude/` subdirectory as context.
|
|
211
|
+
*/
|
|
212
|
+
export const CLAUDE_CONTEXT_SUBDIRS = Object.freeze([
|
|
213
|
+
"agents",
|
|
214
|
+
"commands",
|
|
215
|
+
"output-styles",
|
|
216
|
+
"skills",
|
|
217
|
+
]);
|
|
218
|
+
|
|
219
|
+
// The glob patterns for one `.claude` tree at `prefix` (empty for the project
|
|
220
|
+
// root, a doubled-star segment for nested ones): its top-level markdown, plus the
|
|
221
|
+
// whitelisted context subdirectories. Built once, from the one list above.
|
|
222
|
+
/** @param {string} prefix @returns {string[]} */
|
|
223
|
+
function claudeDirPatterns(prefix) {
|
|
224
|
+
return [
|
|
225
|
+
`${prefix}.claude/*.md`,
|
|
226
|
+
...CLAUDE_CONTEXT_SUBDIRS.map((sub) => `${prefix}.claude/${sub}/**/*.md`),
|
|
227
|
+
];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Entries the walk must not descend into or return: `node_modules`, and every
|
|
232
|
+
* child of a `.claude` directory that is not whitelisted context.
|
|
233
|
+
*
|
|
234
|
+
* The patterns alone would already refuse to MATCH those files, but globSync
|
|
235
|
+
* calls this on directories as it walks and prunes the ones it rejects — which
|
|
236
|
+
* is where the cost actually is. Without the prune, a `.claude/worktrees/`
|
|
237
|
+
* holding a few repo checkouts is walked in full on every session start (and,
|
|
238
|
+
* because a doubled-star segment does cross into a dot directory when the
|
|
239
|
+
* pattern names one, a `.claude` NESTED inside a worktree was matched and
|
|
240
|
+
* scanned as if it were this session's context).
|
|
241
|
+
*
|
|
242
|
+
* globSync calls this with both bare names and repo-relative paths, so it must
|
|
243
|
+
* answer for either; a bare name carries no `.claude` context and is judged only
|
|
244
|
+
* against `node_modules`.
|
|
245
|
+
* @param {string} entry a bare entry name or a path relative to the scan root
|
|
246
|
+
* @returns {boolean}
|
|
196
247
|
*/
|
|
197
|
-
function
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
248
|
+
function excludeFromScan(entry) {
|
|
249
|
+
if (entry === "node_modules") return true;
|
|
250
|
+
const parts = entry.split(/[/\\]/);
|
|
251
|
+
const claudeIndex = parts.indexOf(".claude");
|
|
252
|
+
const tail = parts.slice(claudeIndex + 1);
|
|
253
|
+
if (claudeIndex === -1 || tail.length === 0) return false;
|
|
254
|
+
// `.claude/<file>.md` is context (a top-level note); anything else directly
|
|
255
|
+
// under `.claude` must be a whitelisted subdirectory to be walked at all.
|
|
256
|
+
if (tail.length === 1 && tail[0].endsWith(".md")) return false;
|
|
257
|
+
return !CLAUDE_CONTEXT_SUBDIRS.includes(tail[0]);
|
|
202
258
|
}
|
|
203
259
|
|
|
204
260
|
/**
|
|
205
|
-
* Every
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
261
|
+
* Every file under `dir` that Claude Code loads as model context: the
|
|
262
|
+
* subdirectory instruction files (CLAUDE.md, CLAUDE.local.md, AGENTS.md) and the
|
|
263
|
+
* whitelisted `.claude/` markdown (see {@link CLAUDE_CONTEXT_SUBDIRS}). Claude
|
|
264
|
+
* Code loads these on entry to their containing directory — a load path that
|
|
265
|
+
* bypasses the PostToolUse sanitizer — so a payload planted in e.g.
|
|
266
|
+
* `packages/foo/CLAUDE.md` reaches the model uncleaned unless it is scanned
|
|
267
|
+
* here. Skips node_modules.
|
|
210
268
|
*
|
|
211
269
|
* `**` does not descend into dot directories, so NESTED `.claude/` trees need
|
|
212
|
-
* their own
|
|
213
|
-
*
|
|
214
|
-
* —
|
|
270
|
+
* their own doubled-star-prefixed patterns: without them a directory-scoped skill at
|
|
271
|
+
* `packages/foo/.claude/skills/x/SKILL.md` — model context by the same load
|
|
272
|
+
* path — is never scanned. That same rule is why the root `.claude` needs no
|
|
273
|
+
* separate walk: a leading doubled star matches zero segments, so the nested
|
|
274
|
+
* patterns cover the root tree too.
|
|
215
275
|
* @param {string} dir
|
|
216
276
|
* @returns {string[]}
|
|
217
277
|
*/
|
|
@@ -221,12 +281,9 @@ function findInstructionFiles(dir) {
|
|
|
221
281
|
"**/CLAUDE.md",
|
|
222
282
|
"**/CLAUDE.local.md",
|
|
223
283
|
"**/AGENTS.md",
|
|
224
|
-
"
|
|
284
|
+
...claudeDirPatterns("**/"),
|
|
225
285
|
],
|
|
226
|
-
{
|
|
227
|
-
cwd: dir,
|
|
228
|
-
exclude: (name) => name === "node_modules",
|
|
229
|
-
},
|
|
286
|
+
{ cwd: dir, exclude: excludeFromScan },
|
|
230
287
|
).map((name) => join(dir, name));
|
|
231
288
|
}
|
|
232
289
|
|
|
@@ -268,7 +325,6 @@ function scanFile(filePath) {
|
|
|
268
325
|
|
|
269
326
|
export {
|
|
270
327
|
decodeRun,
|
|
271
|
-
findMdFiles,
|
|
272
328
|
findInstructionFiles,
|
|
273
329
|
scanFile,
|
|
274
330
|
ALERT_FILE,
|
|
@@ -350,12 +406,7 @@ export { formatReport };
|
|
|
350
406
|
* }}
|
|
351
407
|
*/
|
|
352
408
|
export function scanProject(dir = PROJECT_DIR) {
|
|
353
|
-
const targets = [
|
|
354
|
-
...new Set([
|
|
355
|
-
...findInstructionFiles(dir),
|
|
356
|
-
...findMdFiles(join(dir, ".claude")),
|
|
357
|
-
]),
|
|
358
|
-
];
|
|
409
|
+
const targets = [...new Set(findInstructionFiles(dir))];
|
|
359
410
|
const findings = [];
|
|
360
411
|
const skipped = [];
|
|
361
412
|
let scanned = 0;
|
|
@@ -420,7 +471,35 @@ export function formatSkipped(skipped) {
|
|
|
420
471
|
* untested fault path is how a posture goes missing in the first place.
|
|
421
472
|
* @returns {Promise<void>}
|
|
422
473
|
*/
|
|
423
|
-
export async function cliMain(
|
|
474
|
+
export async function cliMain(opts = {}) {
|
|
475
|
+
// The scan blocks session startup, and a slow one is invisible from the
|
|
476
|
+
// inside — it reads as "Claude is slow to start". Timing the whole body and
|
|
477
|
+
// reporting an overrun in band is what turned a 30-second scan from a rumor
|
|
478
|
+
// into a bug report (see lib/hook-timing.mjs).
|
|
479
|
+
const elapsed = startHookTimer();
|
|
480
|
+
try {
|
|
481
|
+
await runScanCli(opts);
|
|
482
|
+
} finally {
|
|
483
|
+
reportSlowHook(
|
|
484
|
+
HOOK_NAME,
|
|
485
|
+
elapsed(),
|
|
486
|
+
HookEvent.SESSION_START,
|
|
487
|
+
emitHookResponse,
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* The scan itself. Split from {@link cliMain} only so the timing wrapper above
|
|
494
|
+
* has a single call to bracket — every early return here is an exit the wrapper
|
|
495
|
+
* must still measure.
|
|
496
|
+
* @param {{
|
|
497
|
+
* trace?: import("./lib/trace.mjs").TraceFn,
|
|
498
|
+
* scan?: () => ReturnType<typeof scanProject>,
|
|
499
|
+
* }} opts see {@link cliMain}
|
|
500
|
+
* @returns {Promise<void>}
|
|
501
|
+
*/
|
|
502
|
+
async function runScanCli({ trace: sink = trace, scan: runScan }) {
|
|
424
503
|
// Bound best-effort: the announcements below run BEFORE the auto-clean and
|
|
425
504
|
// the alert write, with no catch above them, so a throwing host sink would
|
|
426
505
|
// abort the scan silently (see bestEffortTrace).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.24.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": {
|