agent-sanitizer 2.24.2 → 2.26.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 +28 -6
- package/THREAT-MODEL.md +55 -3
- package/bin/sanitize-cli.mjs +12 -9
- package/claude-hooks/lib/control-plane.mjs +18 -2
- package/claude-hooks/lib/hook-timing.mjs +94 -5
- package/claude-hooks/sanitize-output.mjs +73 -27
- package/claude-hooks/scan-invisible-chars.mjs +31 -86
- package/package.json +1 -1
- package/src/claude-context.mjs +125 -0
- package/src/html.mjs +48 -10
- package/src/index.mjs +6 -5
- package/src/instructions.mjs +36 -6
- package/src/invisible.mjs +85 -5
- package/src/layer1.mjs +15 -0
- package/src/output.mjs +159 -54
- package/src/prompt.mjs +11 -28
- package/src/severity.mjs +97 -0
- package/types/claude-context.d.mts +88 -0
- package/types/claude-hooks/lib/hook-timing.d.mts +64 -0
- package/types/claude-hooks/sanitize-output.d.mts +8 -5
- package/types/claude-hooks/scan-invisible-chars.d.mts +13 -31
- package/types/html.d.mts +7 -1
- package/types/index.d.mts +5 -3
- package/types/instructions.d.mts +14 -4
- package/types/invisible.d.mts +66 -0
- package/types/layer1.d.mts +12 -0
- package/types/output.d.mts +29 -14
- package/types/severity.d.mts +83 -0
- package/types/src/claude-context.d.mts +88 -0
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Milliseconds as the seconds string every notice below prints.
|
|
3
|
+
*
|
|
4
|
+
* Rounds tenths half-UP from an exact integer count of hundredths, rather than
|
|
5
|
+
* `(ms / 1000).toFixed(1)`: the shell port of this module
|
|
6
|
+
* (plugin/scripts/lib/hook-timing.sh) has to produce the byte-identical string
|
|
7
|
+
* with integer arithmetic, and `toFixed` rounds the underlying double — so 1150
|
|
8
|
+
* would print "1.1" here (1.15 is below its decimal value as a double) and "1.2"
|
|
9
|
+
* there. `ms / 100` lands exactly on a half only when `ms` ends in 50, and every
|
|
10
|
+
* such quotient is dyadic, so this rounding is exact for every input.
|
|
11
|
+
* @param {number} ms
|
|
12
|
+
* @returns {string}
|
|
13
|
+
*/
|
|
14
|
+
export function formatSeconds(ms: number): string;
|
|
1
15
|
/**
|
|
2
16
|
* Run `work`, charging its whole duration to provisioning so no timer running
|
|
3
17
|
* across it counts that time. Charged in a `finally`, so a provisioning step
|
|
@@ -37,6 +51,43 @@ export function startHookTimer(now?: () => number): () => number;
|
|
|
37
51
|
* @returns {string | null}
|
|
38
52
|
*/
|
|
39
53
|
export function slowHookNotice(hookName: string, elapsedMs: number, thresholdMs?: number): string | null;
|
|
54
|
+
/**
|
|
55
|
+
* The line for a ONE-TIME provisioning step that overran
|
|
56
|
+
* {@link SLOW_PROVISION_THRESHOLD_MS}, or null when it did not.
|
|
57
|
+
*
|
|
58
|
+
* Deliberately NOT {@link slowHookNotice} with a bigger threshold: that message
|
|
59
|
+
* says "every affected call pays it", which is false here and would send the
|
|
60
|
+
* reader hunting a per-call cost that does not exist. What is actionable about a
|
|
61
|
+
* slow install is the installer (uv resolves in a fraction of pip's time) and
|
|
62
|
+
* the fact that a repeat means the idempotence check is broken — so this asks
|
|
63
|
+
* for a report only on the repeat, which is the version of this that is a bug.
|
|
64
|
+
*
|
|
65
|
+
* The one caller is the shell provisioner, whose port of this module
|
|
66
|
+
* (plugin/scripts/lib/hook-timing.sh) must emit this exact string; that port and
|
|
67
|
+
* this definition are pinned to each other by a contract test rather than left
|
|
68
|
+
* as two independently-worded copies.
|
|
69
|
+
* @param {string} stepName
|
|
70
|
+
* @param {number} elapsedMs
|
|
71
|
+
* @param {number} [thresholdMs]
|
|
72
|
+
* @returns {string | null}
|
|
73
|
+
*/
|
|
74
|
+
export function slowProvisionNotice(stepName: string, elapsedMs: number, thresholdMs?: number): string | null;
|
|
75
|
+
/**
|
|
76
|
+
* Write the slow-hook notice to stderr and return it, or return null when the
|
|
77
|
+
* run was within budget (writing nothing, so the quiet path stays quiet).
|
|
78
|
+
*
|
|
79
|
+
* The one place the notice reaches stderr: every reporter below needs the
|
|
80
|
+
* transcript copy, and a hook whose run ENDED IN AN ERROR has nothing but this —
|
|
81
|
+
* its verdict is the fail-closed one its `onError` composed, and diluting that
|
|
82
|
+
* message with a performance aside would bury the fault. A judge that spent
|
|
83
|
+
* thirty seconds and then threw is exactly the case the timing exists to name,
|
|
84
|
+
* so the error path measures and reports; it just reports on the human channel.
|
|
85
|
+
* @param {string} hookName
|
|
86
|
+
* @param {number} elapsedMs
|
|
87
|
+
* @param {(chunk: string) => void} [writeErr] injectable stderr sink, for tests
|
|
88
|
+
* @returns {string | null}
|
|
89
|
+
*/
|
|
90
|
+
export function writeSlowHookNotice(hookName: string, elapsedMs: number, writeErr?: (chunk: string) => void): string | null;
|
|
40
91
|
/**
|
|
41
92
|
* `verdict` with the slow-hook notice folded into its `additional_context`, or
|
|
42
93
|
* the verdict untouched when the run was within budget. Also writes the notice
|
|
@@ -104,3 +155,16 @@ export function reportSlowHook(hookName: string, elapsedMs: number, hookEventNam
|
|
|
104
155
|
* means something is actually wrong, not that the machine is busy.
|
|
105
156
|
*/
|
|
106
157
|
export const SLOW_HOOK_THRESHOLD_MS: 1000;
|
|
158
|
+
/**
|
|
159
|
+
* Wall-clock a ONE-TIME provisioning step may spend before it is reported.
|
|
160
|
+
*
|
|
161
|
+
* Two orders of magnitude above {@link SLOW_HOOK_THRESHOLD_MS}, because it
|
|
162
|
+
* measures something categorically different: a dependency install that a
|
|
163
|
+
* session pays once, not a cost every tool call repeats. A cold `uv` install of
|
|
164
|
+
* the redactor engine is seconds and a cold `pip` one can be tens of them, so a
|
|
165
|
+
* budget anywhere near a second would report every first session — the alert
|
|
166
|
+
* fatigue this whole module exists to avoid. Past a minute, something is
|
|
167
|
+
* actually wrong (a serial pip resolve, a wedged mirror, or an idempotence bug
|
|
168
|
+
* re-provisioning every session), which is worth saying out loud.
|
|
169
|
+
*/
|
|
170
|
+
export const SLOW_PROVISION_THRESHOLD_MS: 60000;
|
|
@@ -56,13 +56,14 @@
|
|
|
56
56
|
* @param {{remainingMs: () => number}} [deadline] shared wall-clock budget across
|
|
57
57
|
* all leaves of one hook run; a direct caller gets a fresh full budget
|
|
58
58
|
* @param {SanitizeExtensions} [ext]
|
|
59
|
-
* @returns {Promise<{ cleaned: string, warnings: string[], modified: boolean, sgrNote: boolean, reveal?: string }>}
|
|
59
|
+
* @returns {Promise<{ cleaned: string, warnings: string[], notes: string[], modified: boolean, sgrNote: boolean, reveal?: string }>}
|
|
60
60
|
*/
|
|
61
61
|
export function sanitizeText(text: string, toolName: string, deadline?: {
|
|
62
62
|
remainingMs: () => number;
|
|
63
63
|
}, ext?: SanitizeExtensions): Promise<{
|
|
64
64
|
cleaned: string;
|
|
65
65
|
warnings: string[];
|
|
66
|
+
notes: string[];
|
|
66
67
|
modified: boolean;
|
|
67
68
|
sgrNote: boolean;
|
|
68
69
|
reveal?: string;
|
|
@@ -77,10 +78,10 @@ export function sanitizeText(text: string, toolName: string, deadline?: {
|
|
|
77
78
|
* too (a connector can hide a secret in a field name); non-string leaves
|
|
78
79
|
* (booleans, numbers, null) pass through untouched, and `warnings` accumulates
|
|
79
80
|
* across leaves.
|
|
80
|
-
* `sgrNote` is the OR across leaves: true when some leaf
|
|
81
|
+
* `sgrNote` is the OR across leaves: true when some leaf came back note-only.
|
|
81
82
|
* `reveals` accumulates each leaf's pre-Layer-2 text (when the HTML splice
|
|
82
|
-
* removed something) for the orchestrator to persist
|
|
83
|
-
* shape as `warnings`.
|
|
83
|
+
* removed something) for the orchestrator to persist, and `notes` the leaves'
|
|
84
|
+
* NOTE-severity findings — same mutated-accumulator shape as `warnings`.
|
|
84
85
|
* @param {any} value
|
|
85
86
|
* @param {string} toolName
|
|
86
87
|
* @param {string[]} warnings
|
|
@@ -88,11 +89,13 @@ export function sanitizeText(text: string, toolName: string, deadline?: {
|
|
|
88
89
|
* @param {{remainingMs: () => number}} [deadline] shared wall-clock budget across
|
|
89
90
|
* every leaf of this value (created once by the top-level caller)
|
|
90
91
|
* @param {SanitizeExtensions} [ext]
|
|
92
|
+
* @param {string[]} [notes] appended last so an existing caller's positional
|
|
93
|
+
* arguments keep their meaning
|
|
91
94
|
* @returns {Promise<{ value: any, modified: boolean, sgrNote: boolean }>}
|
|
92
95
|
*/
|
|
93
96
|
export function sanitizeValue(value: any, toolName: string, warnings: string[], reveals?: string[], deadline?: {
|
|
94
97
|
remainingMs: () => number;
|
|
95
|
-
}, ext?: SanitizeExtensions): Promise<{
|
|
98
|
+
}, ext?: SanitizeExtensions, notes?: string[]): Promise<{
|
|
96
99
|
value: any;
|
|
97
100
|
modified: boolean;
|
|
98
101
|
sgrNote: boolean;
|
|
@@ -71,24 +71,6 @@ export function cliMain(opts?: {
|
|
|
71
71
|
trace?: import("./lib/trace.mjs").TraceFn;
|
|
72
72
|
scan?: () => ReturnType<typeof scanProject>;
|
|
73
73
|
}): Promise<void>;
|
|
74
|
-
/**
|
|
75
|
-
* The `.claude/` subdirectories whose markdown Claude Code loads as model
|
|
76
|
-
* context. This is a WHITELIST, and that is the point: `.claude/` is also where
|
|
77
|
-
* tooling parks bulk data that is never loaded as context — `worktrees/`
|
|
78
|
-
* (entire checked-out copies of the repo), plus caches, transcripts and
|
|
79
|
-
* snapshots — and globbing `.claude/**` swept all of it in. On a repo with a few
|
|
80
|
-
* populated worktrees that is thousands of files READ at every session start:
|
|
81
|
-
* one report put it at 30 seconds of blocked startup, paid for scanning files
|
|
82
|
-
* that cannot reach the model.
|
|
83
|
-
*
|
|
84
|
-
* A whitelist, not a `worktrees` denylist, because the failure modes are not
|
|
85
|
-
* symmetric: an unlisted context directory costs a scan this hook was never
|
|
86
|
-
* asked for anyway (the PostToolUse sanitizer still cleans those bytes when a
|
|
87
|
-
* tool reads them), while an unlisted BULK directory silently costs every future
|
|
88
|
-
* session its startup. Add an entry here when Claude Code starts loading a new
|
|
89
|
-
* `.claude/` subdirectory as context.
|
|
90
|
-
*/
|
|
91
|
-
export const CLAUDE_CONTEXT_SUBDIRS: readonly string[];
|
|
92
74
|
/**
|
|
93
75
|
* @param {string} filePath
|
|
94
76
|
* @returns {Array<{ line: number, charCount: number, method: string, decoded: string }>}
|
|
@@ -99,6 +81,8 @@ export function scanFile(filePath: string): Array<{
|
|
|
99
81
|
method: string;
|
|
100
82
|
decoded: string;
|
|
101
83
|
}>;
|
|
84
|
+
import { CLAUDE_CONTEXT_SUBDIRS } from "../src/claude-context.mjs";
|
|
85
|
+
import { CLAUDE_INSTRUCTION_GLOBS } from "../src/claude-context.mjs";
|
|
102
86
|
/**
|
|
103
87
|
* @param {string} run
|
|
104
88
|
* @returns {{ method: string, decoded: string }}
|
|
@@ -109,19 +93,17 @@ export function decodeRun(run: string): {
|
|
|
109
93
|
};
|
|
110
94
|
/**
|
|
111
95
|
* Every file under `dir` that Claude Code loads as model context: the
|
|
112
|
-
*
|
|
113
|
-
* whitelisted `.claude/` markdown
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* here. Skips node_modules.
|
|
96
|
+
* per-directory instruction files (CLAUDE.md, CLAUDE.local.md, AGENTS.md) and
|
|
97
|
+
* the whitelisted `.claude/` markdown. Claude Code loads these on entry to their
|
|
98
|
+
* containing directory — a load path that bypasses the PostToolUse sanitizer —
|
|
99
|
+
* so a payload planted in e.g. `packages/foo/CLAUDE.md` reaches the model
|
|
100
|
+
* uncleaned unless it is scanned here.
|
|
118
101
|
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* patterns cover the root tree too.
|
|
102
|
+
* The scope itself — which globs, and which directories the walk must prune —
|
|
103
|
+
* is the library's {@link CLAUDE_INSTRUCTION_GLOBS} /
|
|
104
|
+
* {@link excludeFromContextScan}, so this hook and every other consumer read one
|
|
105
|
+
* list (see src/claude-context.mjs for why it is imported relatively rather than
|
|
106
|
+
* through the `agent-sanitizer` specifier the plugin bundle pins).
|
|
125
107
|
* @param {string} dir
|
|
126
108
|
* @returns {string[]}
|
|
127
109
|
*/
|
|
@@ -147,4 +129,4 @@ export function formatReport(allFindings: Array<{
|
|
|
147
129
|
decoded: string;
|
|
148
130
|
}>;
|
|
149
131
|
}>): string;
|
|
150
|
-
export { ALERT_FILE, ALERT_ACK_FILE };
|
|
132
|
+
export { CLAUDE_CONTEXT_SUBDIRS, CLAUDE_INSTRUCTION_GLOBS, ALERT_FILE, ALERT_ACK_FILE };
|
package/types/html.d.mts
CHANGED
|
@@ -93,11 +93,17 @@ export function urlHost(url: string): string;
|
|
|
93
93
|
* and HTML attributes (src/href/background/srcset/ping, form action/formaction,
|
|
94
94
|
* meta-refresh). Detection only — the text is never modified; the caller
|
|
95
95
|
* surfaces the threats as a warning.
|
|
96
|
+
*
|
|
97
|
+
* `autoFetched` marks a threat that needs no deliberate act to fire — a
|
|
98
|
+
* rendered image, a stylesheet, a form target, a meta refresh — as opposed to a
|
|
99
|
+
* link somebody has to follow. Both are reported; the caller uses it to decide
|
|
100
|
+
* how loudly (see the exfil tier in ./output.mjs).
|
|
96
101
|
* @param {string} text
|
|
97
|
-
* @returns {Array<{ isImage: boolean, reason: string, target: string }> | null}
|
|
102
|
+
* @returns {Array<{ isImage: boolean, autoFetched: boolean, reason: string, target: string }> | null}
|
|
98
103
|
*/
|
|
99
104
|
export function detectExfil(text: string): Array<{
|
|
100
105
|
isImage: boolean;
|
|
106
|
+
autoFetched: boolean;
|
|
101
107
|
reason: string;
|
|
102
108
|
target: string;
|
|
103
109
|
}> | null;
|
package/types/index.d.mts
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* its own removal.
|
|
11
11
|
*
|
|
12
12
|
* `found` names the categories neutralized; `warnings` carries the
|
|
13
|
-
* operator-facing notices
|
|
13
|
+
* operator-facing notices and `notes` the quiet tier (see `./severity.mjs`).
|
|
14
|
+
* `cleaned` is always a string, and a change only
|
|
14
15
|
* ever carries a warning (no silent suppression). `options` is optional and
|
|
15
16
|
* tolerates an explicit `null`/`undefined` (treated the same as omitted) —
|
|
16
17
|
* only a genuinely malformed `text` (not a string) throws, deliberately: a
|
|
@@ -20,7 +21,7 @@
|
|
|
20
21
|
*
|
|
21
22
|
* The layer bodies live in `./output.mjs`; this is a facade over them, not a
|
|
22
23
|
* second implementation (see the module doc). It narrows `sanitizeText`'s result
|
|
23
|
-
* to the
|
|
24
|
+
* to the four fields this entry promises — `modified`/`sgrNote`
|
|
24
25
|
* describe the tool-output pipeline's banner, and `reveal` is produced only by
|
|
25
26
|
* options this facade does not expose. `html` selects Layers 2 AND 3 together
|
|
26
27
|
* here, which is the surface this entry has always had; `sanitizeText` takes
|
|
@@ -28,7 +29,7 @@
|
|
|
28
29
|
* detection without Layer 2's splice.
|
|
29
30
|
* @param {string} text
|
|
30
31
|
* @param {{ html?: boolean } | null} [options]
|
|
31
|
-
* @returns {Promise<{ cleaned: string, found: string[], warnings: string[] }>}
|
|
32
|
+
* @returns {Promise<{ cleaned: string, found: string[], warnings: string[], notes: string[] }>}
|
|
32
33
|
*/
|
|
33
34
|
export function sanitize(text: string, options?: {
|
|
34
35
|
html?: boolean;
|
|
@@ -36,6 +37,7 @@ export function sanitize(text: string, options?: {
|
|
|
36
37
|
cleaned: string;
|
|
37
38
|
found: string[];
|
|
38
39
|
warnings: string[];
|
|
40
|
+
notes: string[];
|
|
39
41
|
}>;
|
|
40
42
|
export { applyLayer1, isBenignAnsi, isBenignAnsiKinds, stripAnsiFully, LONE_SURROGATE_RE } from "./layer1.mjs";
|
|
41
43
|
export { stripInvisible, stripInvisibleWithReport, isSgrOnly, STRIP, SGR_RE, CHECKS, CATEGORY, CATEGORY_LABELS, LINGUISTIC_SCRIPTS, VS, BLANK_NON_CF, LONG_RUN_RE, LONG_RUN_THRESHOLD, SCATTERED_THRESHOLD } from "./invisible.mjs";
|
package/types/instructions.d.mts
CHANGED
|
@@ -40,23 +40,32 @@ export function scanText(content: string): Array<{
|
|
|
40
40
|
* cannot be resolved (a dangling symlink or unreadable entry inside the
|
|
41
41
|
* tree), is SKIPPED, so one bad symlink never aborts scanning the rest of the
|
|
42
42
|
* project.
|
|
43
|
+
*
|
|
44
|
+
* `exclude` prunes the WALK, which is where a wide glob's cost actually is —
|
|
45
|
+
* a pattern that merely fails to match a bulk directory still pays to read it.
|
|
46
|
+
* It is composed with, never replaces, the unconditional `node_modules` prune:
|
|
47
|
+
* a caller narrowing the scan must not be able to widen it into a dependency
|
|
48
|
+
* tree. Pass {@link excludeFromContextScan} to take Claude Code's own scope.
|
|
43
49
|
* @param {string[]} globs
|
|
44
|
-
* @param {{ cwd?: string }} [options]
|
|
50
|
+
* @param {{ cwd?: string, exclude?: (entry: string) => boolean }} [options]
|
|
45
51
|
* @returns {string[]}
|
|
46
52
|
*/
|
|
47
|
-
export function findInstructionFiles(globs: string[], { cwd }?: {
|
|
53
|
+
export function findInstructionFiles(globs: string[], { cwd, exclude }?: {
|
|
48
54
|
cwd?: string;
|
|
55
|
+
exclude?: (entry: string) => boolean;
|
|
49
56
|
}): string[];
|
|
50
57
|
/**
|
|
51
58
|
* Scan every instruction file matched by `globs` and return only those with
|
|
52
59
|
* findings, each path reported relative to `cwd`. Unreadable/missing files are
|
|
53
60
|
* skipped. Pure scan — no mutation; pair with {@link cleanFile} to strip.
|
|
54
61
|
* @param {string[]} globs
|
|
55
|
-
* @param {{ cwd?: string }} [options]
|
|
62
|
+
* @param {{ cwd?: string, exclude?: (entry: string) => boolean }} [options]
|
|
63
|
+
* `exclude` is forwarded to {@link findInstructionFiles}
|
|
56
64
|
* @returns {Array<{ file: string, findings: ReturnType<typeof scanText> }>}
|
|
57
65
|
*/
|
|
58
|
-
export function scanInstructionFiles(globs: string[], { cwd }?: {
|
|
66
|
+
export function scanInstructionFiles(globs: string[], { cwd, exclude }?: {
|
|
59
67
|
cwd?: string;
|
|
68
|
+
exclude?: (entry: string) => boolean;
|
|
60
69
|
}): Array<{
|
|
61
70
|
file: string;
|
|
62
71
|
findings: ReturnType<typeof scanText>;
|
|
@@ -135,3 +144,4 @@ export function atomicReplaceFile(absPath: string, data: string, mode: number, t
|
|
|
135
144
|
* @returns {boolean}
|
|
136
145
|
*/
|
|
137
146
|
export function cleanFile(absPath: string, lstat?: (path: string) => import("node:fs").Stats): boolean;
|
|
147
|
+
export { CLAUDE_CONTEXT_SUBDIRS, CLAUDE_INSTRUCTION_GLOBS, excludeFromContextScan } from "./claude-context.mjs";
|
package/types/invisible.d.mts
CHANGED
|
@@ -46,6 +46,72 @@ export function countPayloadInvisible(text: string): number;
|
|
|
46
46
|
* @returns {string}
|
|
47
47
|
*/
|
|
48
48
|
export function payloadInvisibleView(text: string): string;
|
|
49
|
+
/**
|
|
50
|
+
* The first payload-invisible LONG RUN in `text`, or null when there is none.
|
|
51
|
+
*
|
|
52
|
+
* THE definition of "this text carries a hidden run", shared by every consumer
|
|
53
|
+
* that has an opinion about one: the strip's `[LONG RUN — possible injection
|
|
54
|
+
* payload]` marker, the prompt gate's block decision, and the tool-output
|
|
55
|
+
* severity tier. They used to spell it twice, and differently — the marker
|
|
56
|
+
* probed the PAYLOAD view while the prompt gate probed the raw text, so a
|
|
57
|
+
* legitimate ten-emoji flag sequence (carve-out-preserved, never stripped) was
|
|
58
|
+
* quietly enough to BLOCK a prompt while the strip that saw the same text
|
|
59
|
+
* declined to even flag it. Masking the preserved invisibles is the right half
|
|
60
|
+
* of that disagreement: a run the carve-out keeps is rendering work, not a
|
|
61
|
+
* channel, and the joiners it does NOT keep are counted as payload anyway (see
|
|
62
|
+
* {@link countEffectiveInvisible}).
|
|
63
|
+
*
|
|
64
|
+
* Because the view replaces only PRESERVED invisibles (and visible characters)
|
|
65
|
+
* with spaces, a match consists solely of payload code points and is therefore
|
|
66
|
+
* byte-identical to the corresponding span of `text` — so a caller may report
|
|
67
|
+
* the sample verbatim.
|
|
68
|
+
* @param {string} text
|
|
69
|
+
* @returns {string | null}
|
|
70
|
+
*/
|
|
71
|
+
export function payloadLongRunSample(text: string): string | null;
|
|
72
|
+
/**
|
|
73
|
+
* How many invisible code points in `text` the strip layer treats as PAYLOAD:
|
|
74
|
+
* the ones {@link countPayloadInvisible} counts, plus the joiners that sit in a
|
|
75
|
+
* genuine linguistic context but exceed the carve-out's preservation budget.
|
|
76
|
+
*
|
|
77
|
+
* The surplus term closes the preserved-joiner covert channel (O3):
|
|
78
|
+
* `countPayloadInvisible` excludes every ZWNJ/ZWJ doing real rendering work, so
|
|
79
|
+
* an attacker who alternates `letter joiner letter joiner …` — every joiner
|
|
80
|
+
* legitimately between two cursive letters — counts as ZERO there. The strip
|
|
81
|
+
* layer already refuses that (it preserves joiners only up to
|
|
82
|
+
* TOTAL_PRESERVED_JOINER_BUDGET / CONSECUTIVE_JOINER_CAP and strips the rest),
|
|
83
|
+
* so the surplus is read back OFF the strip — the SSOT — rather than by
|
|
84
|
+
* re-deriving the budget here, which is what would drift.
|
|
85
|
+
*
|
|
86
|
+
* A leading BOM is preserved by the strip but counted by
|
|
87
|
+
* {@link countPayloadInvisible}, so the difference can go slightly negative;
|
|
88
|
+
* hence the clamp.
|
|
89
|
+
* @param {string} text ANSI-stripped text (an escape sequence can hide invisibles)
|
|
90
|
+
* @returns {number}
|
|
91
|
+
*/
|
|
92
|
+
export function countEffectiveInvisible(text: string): number;
|
|
93
|
+
/**
|
|
94
|
+
* True when the invisible characters in `text` are INCIDENTAL: no hidden run,
|
|
95
|
+
* and too few of them in total to carry an instruction.
|
|
96
|
+
*
|
|
97
|
+
* This is a severity line, not a strip line — the bytes are removed either way
|
|
98
|
+
* (see ../src/severity.mjs). It exists because a single soft hyphen in a
|
|
99
|
+
* pasted paragraph, or one variation selector a font demanded, raised the exact
|
|
100
|
+
* `WARNING: Tool output sanitized` an encoded payload does, and a warning that
|
|
101
|
+
* fires on a stray character in ordinary prose is one operators learn to skip.
|
|
102
|
+
*
|
|
103
|
+
* The bar is {@link LONG_RUN_THRESHOLD} — the count this module already calls
|
|
104
|
+
* "payload length" — applied to the WHOLE text rather than to one run, so it is
|
|
105
|
+
* strictly stronger than the run probe: fewer than ten payload-invisible code
|
|
106
|
+
* points, however they are distributed, cannot spell a smuggled instruction (ten
|
|
107
|
+
* tag characters are ten ASCII letters). Deliberately NOT the far looser
|
|
108
|
+
* {@link SCATTERED_THRESHOLD} of 30, which is the prompt gate's BLOCK bar: 29
|
|
109
|
+
* tag characters is a short sentence, and staying quiet about a short sentence
|
|
110
|
+
* hidden in a tool result is not a trade worth making.
|
|
111
|
+
* @param {string} text ANSI-stripped text, invisible runs intact
|
|
112
|
+
* @returns {boolean}
|
|
113
|
+
*/
|
|
114
|
+
export function isIncidentalInvisible(text: string): boolean;
|
|
49
115
|
/**
|
|
50
116
|
* Strip payload-capable invisible chars and report which categories were
|
|
51
117
|
* removed. A single leading U+FEFF (BOM) is preserved as a legitimate marker;
|
package/types/layer1.d.mts
CHANGED
|
@@ -99,3 +99,15 @@ export function applyLayer1(text: string): {
|
|
|
99
99
|
ansiKinds: string[];
|
|
100
100
|
};
|
|
101
101
|
export const LONE_SURROGATE_RE: RegExp;
|
|
102
|
+
/**
|
|
103
|
+
* What a reader is told when the ONLY thing a strip removed was inert ANSI (see
|
|
104
|
+
* {@link isBenignAnsiKinds}).
|
|
105
|
+
*
|
|
106
|
+
* It lives here, beside the predicate that decides it, because every entry point
|
|
107
|
+
* that can reach that verdict must say the same thing: the tool-output pipeline,
|
|
108
|
+
* the prompt gate's pass-with-note, and any host wiring its own. The wording is
|
|
109
|
+
* deliberately not `describeStripped`'s — "Stripped: ANSI escapes" names a
|
|
110
|
+
* category that reads like an attack, when the honest report is "these were
|
|
111
|
+
* colour codes, and here is how to look at the raw bytes".
|
|
112
|
+
*/
|
|
113
|
+
export const INERT_ANSI_NOTE: string;
|
package/types/output.d.mts
CHANGED
|
@@ -50,18 +50,26 @@ export function deleteVerbatimSpans(text: string, spans: string[]): {
|
|
|
50
50
|
* post-mutation invariants and forget the rest, and every string in the returned
|
|
51
51
|
* object has traversed Layer 4.
|
|
52
52
|
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
53
|
+
* Findings come back SPLIT BY SEVERITY (see ./severity.mjs): `warnings` holds
|
|
54
|
+
* everything injection-shaped — the banner a caller must show — and `notes`
|
|
55
|
+
* holds what happened but is not alarming. `warnings` therefore keeps exactly
|
|
56
|
+
* the meaning it always had, and a caller that ignores `notes` is no louder
|
|
57
|
+
* than before, just quieter about incidental bytes.
|
|
58
|
+
*
|
|
59
|
+
* `found` is the machine-readable twin, and the severity split does NOT reach
|
|
60
|
+
* it — the {@link CATEGORY} codes for what Layers 1-3 neutralized or flagged,
|
|
61
|
+
* in the order the layers ran, whichever tier described them. Layers 4 and 5
|
|
62
|
+
* have no category codes (their findings are the injected seam's own
|
|
63
|
+
* vocabulary), so they contribute findings only.
|
|
57
64
|
* @param {string} text
|
|
58
65
|
* @param {SanitizeTextOptions} [options]
|
|
59
|
-
* @returns {Promise<{ cleaned: string, found: string[], warnings: string[], modified: boolean, sgrNote: boolean, reveal?: string }>}
|
|
66
|
+
* @returns {Promise<{ cleaned: string, found: string[], warnings: string[], notes: string[], modified: boolean, sgrNote: boolean, reveal?: string }>}
|
|
60
67
|
*/
|
|
61
68
|
export function sanitizeText(text: string, options?: SanitizeTextOptions): Promise<{
|
|
62
69
|
cleaned: string;
|
|
63
70
|
found: string[];
|
|
64
71
|
warnings: string[];
|
|
72
|
+
notes: string[];
|
|
65
73
|
modified: boolean;
|
|
66
74
|
sgrNote: boolean;
|
|
67
75
|
reveal?: string;
|
|
@@ -82,8 +90,10 @@ export function isWalkableContainer(value: any): boolean;
|
|
|
82
90
|
/**
|
|
83
91
|
* Sanitize every string leaf of a tool-output value, preserving its shape (a
|
|
84
92
|
* structured tool output whose shape changes would be ignored by a harness,
|
|
85
|
-
* leaking the raw value). Non-string leaves pass through; `warnings`
|
|
86
|
-
*
|
|
93
|
+
* leaking the raw value). Non-string leaves pass through; `warnings` and
|
|
94
|
+
* `notes` accumulate across leaves, split by severity (see ./severity.mjs).
|
|
95
|
+
* `sgrNote` is the OR across leaves — true when SOME leaf was note-only — so a
|
|
96
|
+
* caller can still pick the quiet banner when no leaf raised a warning.
|
|
87
97
|
*
|
|
88
98
|
* Fails CLOSED on two hostile shapes that would otherwise throw a `RangeError`
|
|
89
99
|
* as an unhandled async rejection (a DoS that leaves the output un-sanitized):
|
|
@@ -99,9 +109,12 @@ export function isWalkableContainer(value: any): boolean;
|
|
|
99
109
|
* @param {SanitizeTextOptions} options
|
|
100
110
|
* @param {string[]} warnings
|
|
101
111
|
* @param {string[]} [reveals]
|
|
112
|
+
* @param {string[]} [notes] the NOTE-severity counterpart of `warnings`;
|
|
113
|
+
* appended last so an existing positional caller keeps working (it simply
|
|
114
|
+
* discards the notes, which is exactly as loud as before the split)
|
|
102
115
|
* @returns {Promise<{ value: any, modified: boolean, sgrNote: boolean }>}
|
|
103
116
|
*/
|
|
104
|
-
export function sanitizeValue(value: any, options: SanitizeTextOptions, warnings: string[], reveals?: string[]): Promise<{
|
|
117
|
+
export function sanitizeValue(value: any, options: SanitizeTextOptions, warnings: string[], reveals?: string[], notes?: string[]): Promise<{
|
|
105
118
|
value: any;
|
|
106
119
|
modified: boolean;
|
|
107
120
|
sgrNote: boolean;
|
|
@@ -199,16 +212,18 @@ export type Layer5Result = {
|
|
|
199
212
|
};
|
|
200
213
|
/**
|
|
201
214
|
* The running state of one {@link sanitizeText} call. Layers read `text` and
|
|
202
|
-
* mutate it ONLY through {@link applyMutation}.
|
|
203
|
-
*
|
|
204
|
-
*
|
|
215
|
+
* mutate it ONLY through {@link applyMutation}. Findings carry their own
|
|
216
|
+
* severity (see ./severity.mjs) and are split into `warnings`/`notes` at the
|
|
217
|
+
* single exit, so no layer can push into the wrong list. `found` is the
|
|
218
|
+
* machine-readable twin, unaffected by the split: the {@link CATEGORY} codes
|
|
219
|
+
* for whatever Layers 1-3 neutralized or flagged.
|
|
205
220
|
*/
|
|
206
221
|
export type PipelineState = {
|
|
207
222
|
text: string;
|
|
208
223
|
found: string[];
|
|
209
|
-
|
|
224
|
+
findings: import("./severity.mjs").Finding[];
|
|
210
225
|
modified: boolean;
|
|
211
|
-
|
|
226
|
+
unreportedChange: boolean;
|
|
212
227
|
};
|
|
213
228
|
export type SanitizeTextOptions = {
|
|
214
229
|
html?: boolean;
|
|
@@ -218,4 +233,4 @@ export type SanitizeTextOptions = {
|
|
|
218
233
|
sgrCarveOut?: boolean;
|
|
219
234
|
};
|
|
220
235
|
import { needsMarkdownPipeline } from "./gates.mjs";
|
|
221
|
-
export { describeRemoved, describeWarned } from "./warnings.mjs";
|
|
236
|
+
export { describeExfil, describeRemoved, describeWarned } from "./warnings.mjs";
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {{ severity: string, message: string }} Finding
|
|
3
|
+
* A single reportable outcome and how loudly to report it.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* A WARNING-severity finding: injection-shaped, worth the banner.
|
|
7
|
+
* @param {string} message
|
|
8
|
+
* @returns {Finding}
|
|
9
|
+
*/
|
|
10
|
+
export function warning(message: string): Finding;
|
|
11
|
+
/**
|
|
12
|
+
* A NOTE-severity finding: reported, not alarming.
|
|
13
|
+
* @param {string} message
|
|
14
|
+
* @returns {Finding}
|
|
15
|
+
*/
|
|
16
|
+
export function note(message: string): Finding;
|
|
17
|
+
/**
|
|
18
|
+
* A finding at `severity` — the constructor for a caller that has already
|
|
19
|
+
* computed the tier as a boolean and would otherwise write the ternary itself.
|
|
20
|
+
* @param {boolean} isWarning
|
|
21
|
+
* @param {string} message
|
|
22
|
+
* @returns {Finding}
|
|
23
|
+
*/
|
|
24
|
+
export function finding(isWarning: boolean, message: string): Finding;
|
|
25
|
+
/**
|
|
26
|
+
* The messages of every WARNING-severity finding, in order.
|
|
27
|
+
* @param {readonly Finding[]} findings
|
|
28
|
+
* @returns {string[]}
|
|
29
|
+
*/
|
|
30
|
+
export function warningMessages(findings: readonly Finding[]): string[];
|
|
31
|
+
/**
|
|
32
|
+
* The messages of every NOTE-severity finding, in order.
|
|
33
|
+
* @param {readonly Finding[]} findings
|
|
34
|
+
* @returns {string[]}
|
|
35
|
+
*/
|
|
36
|
+
export function noteMessages(findings: readonly Finding[]): string[];
|
|
37
|
+
/**
|
|
38
|
+
* The one place a finding's LOUDNESS is decided, and the vocabulary every layer
|
|
39
|
+
* reports in.
|
|
40
|
+
*
|
|
41
|
+
* Every layer of this pipeline used to have exactly one volume. A cursor-spoofing
|
|
42
|
+
* ANSI payload and a single stray escape byte in a README produced the same
|
|
43
|
+
* `WARNING: Tool output sanitized` banner; so did one soft hyphen, and so did
|
|
44
|
+
* the `<script>` tag that every fetched web page carries. That is the failure
|
|
45
|
+
* mode a detector dies of: a banner that fires on every ordinary page teaches its
|
|
46
|
+
* reader to skip the banner, and then the one that mattered scrolls past too.
|
|
47
|
+
*
|
|
48
|
+
* So a finding carries a SEVERITY, and the two tiers mean specific things:
|
|
49
|
+
*
|
|
50
|
+
* WARNING — this text is injection-shaped. Something was hidden from a human
|
|
51
|
+
* reader, something was removed that a payload would have used, or a
|
|
52
|
+
* secret was redacted. Worth interrupting the reader for.
|
|
53
|
+
* NOTE — this happened, and here is how to look at it, but nothing about it
|
|
54
|
+
* is attack-shaped. Incidental bytes, or content that was PRESERVED
|
|
55
|
+
* and merely described.
|
|
56
|
+
*
|
|
57
|
+
* The tier never changes what the pipeline DOES: the same bytes are stripped,
|
|
58
|
+
* spliced and redacted either way, and a note is still reported. All that rides
|
|
59
|
+
* on it is which banner the operator sees, which is why a note is the right
|
|
60
|
+
* answer whenever the evidence is thin — an under-loud true finding is still
|
|
61
|
+
* delivered, while an over-loud false one costs the channel its credibility.
|
|
62
|
+
*
|
|
63
|
+
* Mechanism only, deliberately: WHICH findings qualify is each layer's own
|
|
64
|
+
* judgement, made where that layer's evidence lives (see isBenignAnsiKinds in
|
|
65
|
+
* ./layer1.mjs, isIncidentalInvisible in ./invisible.mjs, and the exfil/HTML
|
|
66
|
+
* tiers in ./output.mjs and ./index.mjs). This module owns the enum, the constructors and the
|
|
67
|
+
* queries so nobody spells `severity === "warning"` by hand.
|
|
68
|
+
*/
|
|
69
|
+
/**
|
|
70
|
+
* The closed severity vocabulary. Stable, machine-readable values: branch on
|
|
71
|
+
* these, not on the prose.
|
|
72
|
+
*/
|
|
73
|
+
export const SEVERITY: Readonly<{
|
|
74
|
+
NOTE: "note";
|
|
75
|
+
WARNING: "warning";
|
|
76
|
+
}>;
|
|
77
|
+
/**
|
|
78
|
+
* A single reportable outcome and how loudly to report it.
|
|
79
|
+
*/
|
|
80
|
+
export type Finding = {
|
|
81
|
+
severity: string;
|
|
82
|
+
message: string;
|
|
83
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one directory no instruction-file walk ever descends into. Its own
|
|
3
|
+
* function so the name is spelled once, and so the two predicates that need it
|
|
4
|
+
* (a plain glob walk, and {@link excludeFromContextScan}) cannot disagree.
|
|
5
|
+
* @param {string} entry a bare entry name or a path relative to the scan root
|
|
6
|
+
* @returns {boolean}
|
|
7
|
+
*/
|
|
8
|
+
export function excludeNodeModules(entry: string): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Entries a context scan must not descend into or return: `node_modules`, and
|
|
11
|
+
* every child of a `.claude` directory that is not whitelisted context.
|
|
12
|
+
*
|
|
13
|
+
* The globs alone would already refuse to MATCH those files, but a glob walker
|
|
14
|
+
* calls this on directories as it walks and prunes the ones it rejects — which
|
|
15
|
+
* is where the cost actually is. Without the prune, a `.claude/worktrees/`
|
|
16
|
+
* holding a few repo checkouts is walked in full on every session start (and,
|
|
17
|
+
* because a doubled-star segment does cross into a dot directory when the
|
|
18
|
+
* pattern names one, a `.claude` NESTED inside a worktree was matched and
|
|
19
|
+
* scanned as if it were this session's context).
|
|
20
|
+
*
|
|
21
|
+
* A walker calls this with both bare names and root-relative paths, so it must
|
|
22
|
+
* answer for either; a bare name carries no `.claude` context and is judged only
|
|
23
|
+
* against `node_modules`.
|
|
24
|
+
* @param {string} entry a bare entry name or a path relative to the scan root
|
|
25
|
+
* @returns {boolean}
|
|
26
|
+
*/
|
|
27
|
+
export function excludeFromContextScan(entry: string): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* WHICH files an agent loads as model context, as data: the glob set and the
|
|
30
|
+
* walk-pruning predicate that together define "everything Claude Code reads as
|
|
31
|
+
* instructions, and nothing else".
|
|
32
|
+
*
|
|
33
|
+
* This is the SINGLE SOURCE for that scope. It used to live inside
|
|
34
|
+
* `claude-hooks/scan-invisible-chars.mjs`, which meant the SessionStart hook
|
|
35
|
+
* knew the answer and nobody else did: `src/instructions.mjs` takes
|
|
36
|
+
* caller-supplied globs by design (no agent's convention is baked into the
|
|
37
|
+
* engine), so the CLI, the Python port and every downstream fork spelled their
|
|
38
|
+
* own approximation of this list — and an approximation that drifts either
|
|
39
|
+
* scans bulk data that can never reach the model (the 30-second session start
|
|
40
|
+
* this whitelist exists to fix) or MISSES a context directory entirely, which
|
|
41
|
+
* is a silent hole in the one scan standing between a poisoned instruction file
|
|
42
|
+
* and a session that loads it.
|
|
43
|
+
*
|
|
44
|
+
* It is a standalone, dependency-free DATA module (like ./cf-charset.mjs) for
|
|
45
|
+
* two reasons: `src/instructions.mjs` re-exports it as the library's public
|
|
46
|
+
* door, and the hook imports it RELATIVELY — deliberately not through the
|
|
47
|
+
* `agent-sanitizer` specifier the plugin bundle pins to a published engine.
|
|
48
|
+
* This scope is hook POLICY, not engine behavior: it must ship and move with the
|
|
49
|
+
* hook that walks it, or a plugin built against an older pin would prune the
|
|
50
|
+
* wrong directories while believing it had scanned everything.
|
|
51
|
+
*/
|
|
52
|
+
/**
|
|
53
|
+
* The `.claude/` subdirectories whose markdown Claude Code loads as model
|
|
54
|
+
* context. This is a WHITELIST, and that is the point: `.claude/` is also where
|
|
55
|
+
* tooling parks bulk data that is never loaded as context — `worktrees/`
|
|
56
|
+
* (entire checked-out copies of the repo), plus caches, transcripts and
|
|
57
|
+
* snapshots — and globbing `.claude/**` swept all of it in. On a repo with a few
|
|
58
|
+
* populated worktrees that is thousands of files READ at every session start:
|
|
59
|
+
* one report put it at 30 seconds of blocked startup, paid for scanning files
|
|
60
|
+
* that cannot reach the model.
|
|
61
|
+
*
|
|
62
|
+
* A whitelist, not a `worktrees` denylist, because the failure modes are not
|
|
63
|
+
* symmetric: an unlisted context directory costs a scan nobody asked for anyway
|
|
64
|
+
* (the PostToolUse sanitizer still cleans those bytes when a tool reads them),
|
|
65
|
+
* while an unlisted BULK directory silently costs every future session its
|
|
66
|
+
* startup. Add an entry here when Claude Code starts loading a new `.claude/`
|
|
67
|
+
* subdirectory as context.
|
|
68
|
+
*/
|
|
69
|
+
export const CLAUDE_CONTEXT_SUBDIRS: readonly string[];
|
|
70
|
+
/**
|
|
71
|
+
* Every glob whose matches Claude Code loads as model context: the
|
|
72
|
+
* per-directory instruction files (CLAUDE.md, CLAUDE.local.md, AGENTS.md) and
|
|
73
|
+
* the whitelisted `.claude/` markdown. Claude Code loads these on entry to their
|
|
74
|
+
* containing directory — a load path that bypasses the PostToolUse sanitizer —
|
|
75
|
+
* so a payload planted in e.g. `packages/foo/CLAUDE.md` reaches the model
|
|
76
|
+
* uncleaned unless something scans it here.
|
|
77
|
+
*
|
|
78
|
+
* `**` does not descend into dot directories, so NESTED `.claude/` trees need
|
|
79
|
+
* their own doubled-star-prefixed patterns: without them a directory-scoped
|
|
80
|
+
* skill at `packages/foo/.claude/skills/x/SKILL.md` — model context by the same
|
|
81
|
+
* load path — is never matched. That same rule is why the root `.claude` needs
|
|
82
|
+
* no separate entry: a leading doubled star matches zero segments, so the
|
|
83
|
+
* nested patterns cover the root tree too.
|
|
84
|
+
*
|
|
85
|
+
* Pair with {@link excludeFromContextScan}: the patterns alone already refuse to
|
|
86
|
+
* MATCH a bulk directory, but only pruning the WALK avoids paying to read it.
|
|
87
|
+
*/
|
|
88
|
+
export const CLAUDE_INSTRUCTION_GLOBS: readonly string[];
|