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
|
@@ -31,6 +31,18 @@ import {
|
|
|
31
31
|
} from "./lib/invisible-alert.mjs";
|
|
32
32
|
import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
|
|
33
33
|
import { reportSlowHook, startHookTimer } from "./lib/hook-timing.mjs";
|
|
34
|
+
// Relative, not the `agent-sanitizer` specifier every other engine import uses:
|
|
35
|
+
// this is the scan's SCOPE, which is hook policy and must move with the hook.
|
|
36
|
+
// Routing it through the specifier would resolve it, in the shipped plugin
|
|
37
|
+
// bundle, against a PINNED older engine that does not export it — leaving the
|
|
38
|
+
// walk with undefined globs while believing it had scanned everything. The
|
|
39
|
+
// module is dependency-free data (see src/claude-context.mjs), so importing it
|
|
40
|
+
// statically carries none of the fail-open hazard lazyImport exists to cover.
|
|
41
|
+
import {
|
|
42
|
+
CLAUDE_CONTEXT_SUBDIRS,
|
|
43
|
+
CLAUDE_INSTRUCTION_GLOBS,
|
|
44
|
+
excludeFromContextScan,
|
|
45
|
+
} from "../src/claude-context.mjs";
|
|
34
46
|
|
|
35
47
|
// Layer-1 primitives, bound via lazyImport (see its doc for the fail-OPEN
|
|
36
48
|
// hazard of a bare static npm import — here the instruction files would load
|
|
@@ -192,99 +204,27 @@ function decodeRun(run) {
|
|
|
192
204
|
};
|
|
193
205
|
}
|
|
194
206
|
|
|
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}
|
|
247
|
-
*/
|
|
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]);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
207
|
/**
|
|
261
208
|
* Every file under `dir` that Claude Code loads as model context: the
|
|
262
|
-
*
|
|
263
|
-
* whitelisted `.claude/` markdown
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
* here. Skips node_modules.
|
|
209
|
+
* per-directory instruction files (CLAUDE.md, CLAUDE.local.md, AGENTS.md) and
|
|
210
|
+
* the whitelisted `.claude/` markdown. Claude Code loads these on entry to their
|
|
211
|
+
* containing directory — a load path that bypasses the PostToolUse sanitizer —
|
|
212
|
+
* so a payload planted in e.g. `packages/foo/CLAUDE.md` reaches the model
|
|
213
|
+
* uncleaned unless it is scanned here.
|
|
268
214
|
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
* patterns cover the root tree too.
|
|
215
|
+
* The scope itself — which globs, and which directories the walk must prune —
|
|
216
|
+
* is the library's {@link CLAUDE_INSTRUCTION_GLOBS} /
|
|
217
|
+
* {@link excludeFromContextScan}, so this hook and every other consumer read one
|
|
218
|
+
* list (see src/claude-context.mjs for why it is imported relatively rather than
|
|
219
|
+
* through the `agent-sanitizer` specifier the plugin bundle pins).
|
|
275
220
|
* @param {string} dir
|
|
276
221
|
* @returns {string[]}
|
|
277
222
|
*/
|
|
278
223
|
function findInstructionFiles(dir) {
|
|
279
|
-
return globSync(
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
"**/AGENTS.md",
|
|
284
|
-
...claudeDirPatterns("**/"),
|
|
285
|
-
],
|
|
286
|
-
{ cwd: dir, exclude: excludeFromScan },
|
|
287
|
-
).map((name) => join(dir, name));
|
|
224
|
+
return globSync([...CLAUDE_INSTRUCTION_GLOBS], {
|
|
225
|
+
cwd: dir,
|
|
226
|
+
exclude: excludeFromContextScan,
|
|
227
|
+
}).map((name) => join(dir, name));
|
|
288
228
|
}
|
|
289
229
|
|
|
290
230
|
// Scanner
|
|
@@ -324,6 +264,11 @@ function scanFile(filePath) {
|
|
|
324
264
|
}
|
|
325
265
|
|
|
326
266
|
export {
|
|
267
|
+
// Re-exported, never redefined: the scope this hook walks is the library's
|
|
268
|
+
// (src/claude-context.mjs), and a consumer that reads it off this hook must
|
|
269
|
+
// get that same list rather than a second copy that can drift.
|
|
270
|
+
CLAUDE_CONTEXT_SUBDIRS,
|
|
271
|
+
CLAUDE_INSTRUCTION_GLOBS,
|
|
327
272
|
decodeRun,
|
|
328
273
|
findInstructionFiles,
|
|
329
274
|
scanFile,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.26.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": {
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHICH files an agent loads as model context, as data: the glob set and the
|
|
3
|
+
* walk-pruning predicate that together define "everything Claude Code reads as
|
|
4
|
+
* instructions, and nothing else".
|
|
5
|
+
*
|
|
6
|
+
* This is the SINGLE SOURCE for that scope. It used to live inside
|
|
7
|
+
* `claude-hooks/scan-invisible-chars.mjs`, which meant the SessionStart hook
|
|
8
|
+
* knew the answer and nobody else did: `src/instructions.mjs` takes
|
|
9
|
+
* caller-supplied globs by design (no agent's convention is baked into the
|
|
10
|
+
* engine), so the CLI, the Python port and every downstream fork spelled their
|
|
11
|
+
* own approximation of this list — and an approximation that drifts either
|
|
12
|
+
* scans bulk data that can never reach the model (the 30-second session start
|
|
13
|
+
* this whitelist exists to fix) or MISSES a context directory entirely, which
|
|
14
|
+
* is a silent hole in the one scan standing between a poisoned instruction file
|
|
15
|
+
* and a session that loads it.
|
|
16
|
+
*
|
|
17
|
+
* It is a standalone, dependency-free DATA module (like ./cf-charset.mjs) for
|
|
18
|
+
* two reasons: `src/instructions.mjs` re-exports it as the library's public
|
|
19
|
+
* door, and the hook imports it RELATIVELY — deliberately not through the
|
|
20
|
+
* `agent-sanitizer` specifier the plugin bundle pins to a published engine.
|
|
21
|
+
* This scope is hook POLICY, not engine behavior: it must ship and move with the
|
|
22
|
+
* hook that walks it, or a plugin built against an older pin would prune the
|
|
23
|
+
* wrong directories while believing it had scanned everything.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The `.claude/` subdirectories whose markdown Claude Code loads as model
|
|
28
|
+
* context. This is a WHITELIST, and that is the point: `.claude/` is also where
|
|
29
|
+
* tooling parks bulk data that is never loaded as context — `worktrees/`
|
|
30
|
+
* (entire checked-out copies of the repo), plus caches, transcripts and
|
|
31
|
+
* snapshots — and globbing `.claude/**` swept all of it in. On a repo with a few
|
|
32
|
+
* populated worktrees that is thousands of files READ at every session start:
|
|
33
|
+
* one report put it at 30 seconds of blocked startup, paid for scanning files
|
|
34
|
+
* that cannot reach the model.
|
|
35
|
+
*
|
|
36
|
+
* A whitelist, not a `worktrees` denylist, because the failure modes are not
|
|
37
|
+
* symmetric: an unlisted context directory costs a scan nobody asked for anyway
|
|
38
|
+
* (the PostToolUse sanitizer still cleans those bytes when a tool reads them),
|
|
39
|
+
* while an unlisted BULK directory silently costs every future session its
|
|
40
|
+
* startup. Add an entry here when Claude Code starts loading a new `.claude/`
|
|
41
|
+
* subdirectory as context.
|
|
42
|
+
*/
|
|
43
|
+
export const CLAUDE_CONTEXT_SUBDIRS = Object.freeze([
|
|
44
|
+
"agents",
|
|
45
|
+
"commands",
|
|
46
|
+
"output-styles",
|
|
47
|
+
"skills",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
// The glob patterns for one `.claude` tree at `prefix` (empty for the project
|
|
51
|
+
// root, a doubled-star segment for nested ones): its top-level markdown, plus the
|
|
52
|
+
// whitelisted context subdirectories. Built once, from the one list above.
|
|
53
|
+
/** @param {string} prefix @returns {string[]} */
|
|
54
|
+
function claudeDirPatterns(prefix) {
|
|
55
|
+
return [
|
|
56
|
+
`${prefix}.claude/*.md`,
|
|
57
|
+
...CLAUDE_CONTEXT_SUBDIRS.map((sub) => `${prefix}.claude/${sub}/**/*.md`),
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Every glob whose matches Claude Code loads as model context: the
|
|
63
|
+
* per-directory instruction files (CLAUDE.md, CLAUDE.local.md, AGENTS.md) and
|
|
64
|
+
* the whitelisted `.claude/` markdown. Claude Code loads these on entry to their
|
|
65
|
+
* containing directory — a load path that bypasses the PostToolUse sanitizer —
|
|
66
|
+
* so a payload planted in e.g. `packages/foo/CLAUDE.md` reaches the model
|
|
67
|
+
* uncleaned unless something scans it here.
|
|
68
|
+
*
|
|
69
|
+
* `**` does not descend into dot directories, so NESTED `.claude/` trees need
|
|
70
|
+
* their own doubled-star-prefixed patterns: without them a directory-scoped
|
|
71
|
+
* skill at `packages/foo/.claude/skills/x/SKILL.md` — model context by the same
|
|
72
|
+
* load path — is never matched. That same rule is why the root `.claude` needs
|
|
73
|
+
* no separate entry: a leading doubled star matches zero segments, so the
|
|
74
|
+
* nested patterns cover the root tree too.
|
|
75
|
+
*
|
|
76
|
+
* Pair with {@link excludeFromContextScan}: the patterns alone already refuse to
|
|
77
|
+
* MATCH a bulk directory, but only pruning the WALK avoids paying to read it.
|
|
78
|
+
*/
|
|
79
|
+
export const CLAUDE_INSTRUCTION_GLOBS = Object.freeze([
|
|
80
|
+
"**/CLAUDE.md",
|
|
81
|
+
"**/CLAUDE.local.md",
|
|
82
|
+
"**/AGENTS.md",
|
|
83
|
+
...claudeDirPatterns("**/"),
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The one directory no instruction-file walk ever descends into. Its own
|
|
88
|
+
* function so the name is spelled once, and so the two predicates that need it
|
|
89
|
+
* (a plain glob walk, and {@link excludeFromContextScan}) cannot disagree.
|
|
90
|
+
* @param {string} entry a bare entry name or a path relative to the scan root
|
|
91
|
+
* @returns {boolean}
|
|
92
|
+
*/
|
|
93
|
+
export function excludeNodeModules(entry) {
|
|
94
|
+
return entry === "node_modules";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Entries a context scan must not descend into or return: `node_modules`, and
|
|
99
|
+
* every child of a `.claude` directory that is not whitelisted context.
|
|
100
|
+
*
|
|
101
|
+
* The globs alone would already refuse to MATCH those files, but a glob walker
|
|
102
|
+
* calls this on directories as it walks and prunes the ones it rejects — which
|
|
103
|
+
* is where the cost actually is. Without the prune, a `.claude/worktrees/`
|
|
104
|
+
* holding a few repo checkouts is walked in full on every session start (and,
|
|
105
|
+
* because a doubled-star segment does cross into a dot directory when the
|
|
106
|
+
* pattern names one, a `.claude` NESTED inside a worktree was matched and
|
|
107
|
+
* scanned as if it were this session's context).
|
|
108
|
+
*
|
|
109
|
+
* A walker calls this with both bare names and root-relative paths, so it must
|
|
110
|
+
* answer for either; a bare name carries no `.claude` context and is judged only
|
|
111
|
+
* against `node_modules`.
|
|
112
|
+
* @param {string} entry a bare entry name or a path relative to the scan root
|
|
113
|
+
* @returns {boolean}
|
|
114
|
+
*/
|
|
115
|
+
export function excludeFromContextScan(entry) {
|
|
116
|
+
if (excludeNodeModules(entry)) return true;
|
|
117
|
+
const parts = entry.split(/[/\\]/);
|
|
118
|
+
const claudeIndex = parts.indexOf(".claude");
|
|
119
|
+
const tail = parts.slice(claudeIndex + 1);
|
|
120
|
+
if (claudeIndex === -1 || tail.length === 0) return false;
|
|
121
|
+
// `.claude/<file>.md` is context (a top-level note); anything else directly
|
|
122
|
+
// under `.claude` must be a whitelisted subdirectory to be walked at all.
|
|
123
|
+
if (tail.length === 1 && tail[0].endsWith(".md")) return false;
|
|
124
|
+
return !CLAUDE_CONTEXT_SUBDIRS.includes(tail[0]);
|
|
125
|
+
}
|
package/src/html.mjs
CHANGED
|
@@ -2313,26 +2313,45 @@ function multiUrlAttr(value) {
|
|
|
2313
2313
|
* `context` selects the per-URL check the caller applies: resource URLs get the
|
|
2314
2314
|
* exfil-shape test; form-submission and meta-refresh targets additionally flag
|
|
2315
2315
|
* any absolute off-origin destination.
|
|
2316
|
+
*
|
|
2317
|
+
* `autoFetched` says whether reaching this URL takes a deliberate act. Exactly
|
|
2318
|
+
* one attribute here does: `href` on an `<a>`, which somebody has to follow.
|
|
2319
|
+
* Every other one is fetched by the renderer on sight (`src`, `srcset`,
|
|
2320
|
+
* `background`, and `href` on a `<link>`), fires on a click aimed at something
|
|
2321
|
+
* else (`ping`), or navigates on its own (a form action, a meta refresh). The
|
|
2322
|
+
* distinction is the caller's severity line, not a detection line — the URL is
|
|
2323
|
+
* reported either way (see the exfil tier in ../src/output.mjs).
|
|
2316
2324
|
* @param {string} text
|
|
2317
|
-
* @returns {Array<{ url: string, isImage: boolean, context: "resource" | "form" | "refresh" }>}
|
|
2325
|
+
* @returns {Array<{ url: string, isImage: boolean, autoFetched: boolean, context: "resource" | "form" | "refresh" }>}
|
|
2318
2326
|
*/
|
|
2319
2327
|
function extractHtmlUrls(text) {
|
|
2320
2328
|
const tree = parseFragment(text);
|
|
2321
|
-
/** @type {Array<{ url: string, isImage: boolean, context: "resource" | "form" | "refresh" }>} */
|
|
2329
|
+
/** @type {Array<{ url: string, isImage: boolean, autoFetched: boolean, context: "resource" | "form" | "refresh" }>} */
|
|
2322
2330
|
const urls = [];
|
|
2323
2331
|
visit(tree, "element", (/** @type {any} */ node) => {
|
|
2324
2332
|
// hast element nodes always carry a `properties` object (parse5 sets it).
|
|
2325
2333
|
const props = node.properties;
|
|
2326
2334
|
const isImage = node.tagName === "img";
|
|
2335
|
+
const isAnchor = node.tagName === "a";
|
|
2327
2336
|
for (const key of ["src", "href", "background"])
|
|
2328
2337
|
if (typeof props[key] === "string")
|
|
2329
|
-
urls.push({
|
|
2338
|
+
urls.push({
|
|
2339
|
+
url: props[key],
|
|
2340
|
+
isImage,
|
|
2341
|
+
autoFetched: !(key === "href" && isAnchor),
|
|
2342
|
+
context: "resource",
|
|
2343
|
+
});
|
|
2330
2344
|
for (const key of ["srcSet", "ping"])
|
|
2331
2345
|
for (const url of multiUrlAttr(props[key]))
|
|
2332
|
-
urls.push({ url, isImage, context: "resource" });
|
|
2346
|
+
urls.push({ url, isImage, autoFetched: true, context: "resource" });
|
|
2333
2347
|
for (const key of ["action", "formAction"])
|
|
2334
2348
|
if (typeof props[key] === "string")
|
|
2335
|
-
urls.push({
|
|
2349
|
+
urls.push({
|
|
2350
|
+
url: props[key],
|
|
2351
|
+
isImage: false,
|
|
2352
|
+
autoFetched: true,
|
|
2353
|
+
context: "form",
|
|
2354
|
+
});
|
|
2336
2355
|
// rehype delivers `http-equiv` as an array (comma-separated); join it back
|
|
2337
2356
|
// so a `refresh` directive is matched regardless of how it was tokenized.
|
|
2338
2357
|
const httpEquiv = Array.isArray(props.httpEquiv)
|
|
@@ -2344,7 +2363,13 @@ function extractHtmlUrls(text) {
|
|
|
2344
2363
|
typeof props.content === "string"
|
|
2345
2364
|
) {
|
|
2346
2365
|
const url = metaRefreshUrl(props.content);
|
|
2347
|
-
if (url)
|
|
2366
|
+
if (url)
|
|
2367
|
+
urls.push({
|
|
2368
|
+
url,
|
|
2369
|
+
isImage: false,
|
|
2370
|
+
autoFetched: true,
|
|
2371
|
+
context: "refresh",
|
|
2372
|
+
});
|
|
2348
2373
|
}
|
|
2349
2374
|
});
|
|
2350
2375
|
return urls;
|
|
@@ -2362,13 +2387,18 @@ const OFF_ORIGIN_REASON = {
|
|
|
2362
2387
|
* and HTML attributes (src/href/background/srcset/ping, form action/formaction,
|
|
2363
2388
|
* meta-refresh). Detection only — the text is never modified; the caller
|
|
2364
2389
|
* surfaces the threats as a warning.
|
|
2390
|
+
*
|
|
2391
|
+
* `autoFetched` marks a threat that needs no deliberate act to fire — a
|
|
2392
|
+
* rendered image, a stylesheet, a form target, a meta refresh — as opposed to a
|
|
2393
|
+
* link somebody has to follow. Both are reported; the caller uses it to decide
|
|
2394
|
+
* how loudly (see the exfil tier in ./output.mjs).
|
|
2365
2395
|
* @param {string} text
|
|
2366
|
-
* @returns {Array<{ isImage: boolean, reason: string, target: string }> | null}
|
|
2396
|
+
* @returns {Array<{ isImage: boolean, autoFetched: boolean, reason: string, target: string }> | null}
|
|
2367
2397
|
*/
|
|
2368
2398
|
export function detectExfil(text) {
|
|
2369
2399
|
if (!MD_LINK_HINT.test(text) && !HTML_TAG_PRESENT.test(text)) return null;
|
|
2370
2400
|
|
|
2371
|
-
/** @type {Array<{ isImage: boolean, reason: string, target: string }>} */
|
|
2401
|
+
/** @type {Array<{ isImage: boolean, autoFetched: boolean, reason: string, target: string }>} */
|
|
2372
2402
|
const threats = [];
|
|
2373
2403
|
|
|
2374
2404
|
try {
|
|
@@ -2386,20 +2416,25 @@ export function detectExfil(text) {
|
|
|
2386
2416
|
if (!reason) return;
|
|
2387
2417
|
threats.push({
|
|
2388
2418
|
isImage: node.type === "image",
|
|
2419
|
+
// A markdown image is fetched the moment the document renders; a link
|
|
2420
|
+
// (or a definition, which only names one) is not.
|
|
2421
|
+
autoFetched: node.type === "image",
|
|
2389
2422
|
reason,
|
|
2390
2423
|
target: urlHost(node.url),
|
|
2391
2424
|
});
|
|
2392
2425
|
});
|
|
2393
2426
|
|
|
2394
2427
|
// HTML attributes (not AST nodes in remark).
|
|
2395
|
-
for (const { url, isImage, context } of extractHtmlUrls(
|
|
2428
|
+
for (const { url, isImage, autoFetched, context } of extractHtmlUrls(
|
|
2429
|
+
text,
|
|
2430
|
+
)) {
|
|
2396
2431
|
const reason =
|
|
2397
2432
|
checkExfilUrl(url) ||
|
|
2398
2433
|
(context !== "resource" && isOffOrigin(url)
|
|
2399
2434
|
? OFF_ORIGIN_REASON[context]
|
|
2400
2435
|
: null);
|
|
2401
2436
|
if (!reason) continue;
|
|
2402
|
-
threats.push({ isImage, reason, target: urlHost(url) });
|
|
2437
|
+
threats.push({ isImage, autoFetched, reason, target: urlHost(url) });
|
|
2403
2438
|
}
|
|
2404
2439
|
} catch {
|
|
2405
2440
|
// The parse/visit blew up (stack overflow on pathological nesting). Fail
|
|
@@ -2409,6 +2444,9 @@ export function detectExfil(text) {
|
|
|
2409
2444
|
return [
|
|
2410
2445
|
{
|
|
2411
2446
|
isImage: false,
|
|
2447
|
+
// Fail closed on the severity tier too: an input the scanner could not
|
|
2448
|
+
// read is not evidence that what it hides is harmless.
|
|
2449
|
+
autoFetched: true,
|
|
2412
2450
|
reason: "input too deeply nested to scan for exfil URLs",
|
|
2413
2451
|
target: "(unparseable HTML)",
|
|
2414
2452
|
},
|
package/src/index.mjs
CHANGED
|
@@ -73,7 +73,8 @@ export {
|
|
|
73
73
|
* its own removal.
|
|
74
74
|
*
|
|
75
75
|
* `found` names the categories neutralized; `warnings` carries the
|
|
76
|
-
* operator-facing notices
|
|
76
|
+
* operator-facing notices and `notes` the quiet tier (see `./severity.mjs`).
|
|
77
|
+
* `cleaned` is always a string, and a change only
|
|
77
78
|
* ever carries a warning (no silent suppression). `options` is optional and
|
|
78
79
|
* tolerates an explicit `null`/`undefined` (treated the same as omitted) —
|
|
79
80
|
* only a genuinely malformed `text` (not a string) throws, deliberately: a
|
|
@@ -83,7 +84,7 @@ export {
|
|
|
83
84
|
*
|
|
84
85
|
* The layer bodies live in `./output.mjs`; this is a facade over them, not a
|
|
85
86
|
* second implementation (see the module doc). It narrows `sanitizeText`'s result
|
|
86
|
-
* to the
|
|
87
|
+
* to the four fields this entry promises — `modified`/`sgrNote`
|
|
87
88
|
* describe the tool-output pipeline's banner, and `reveal` is produced only by
|
|
88
89
|
* options this facade does not expose. `html` selects Layers 2 AND 3 together
|
|
89
90
|
* here, which is the surface this entry has always had; `sanitizeText` takes
|
|
@@ -91,15 +92,15 @@ export {
|
|
|
91
92
|
* detection without Layer 2's splice.
|
|
92
93
|
* @param {string} text
|
|
93
94
|
* @param {{ html?: boolean } | null} [options]
|
|
94
|
-
* @returns {Promise<{ cleaned: string, found: string[], warnings: string[] }>}
|
|
95
|
+
* @returns {Promise<{ cleaned: string, found: string[], warnings: string[], notes: string[] }>}
|
|
95
96
|
*/
|
|
96
97
|
export async function sanitize(text, options) {
|
|
97
98
|
if (typeof text !== "string")
|
|
98
99
|
throw new TypeError("sanitize(text, options): text must be a string");
|
|
99
100
|
const { html = false } = options ?? {};
|
|
100
|
-
const { cleaned, found, warnings } = await sanitizeText(text, {
|
|
101
|
+
const { cleaned, found, warnings, notes } = await sanitizeText(text, {
|
|
101
102
|
html,
|
|
102
103
|
exfilScan: html,
|
|
103
104
|
});
|
|
104
|
-
return { cleaned, found, warnings };
|
|
105
|
+
return { cleaned, found, warnings, notes };
|
|
105
106
|
}
|
package/src/instructions.mjs
CHANGED
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
* The target file set is CALLER-SUPPLIED: pass the globs your agent's
|
|
13
13
|
* instruction files live under (e.g. `["CLAUDE.md", "AGENTS.md",
|
|
14
14
|
* ".claude/**\/*.md", "**\/SKILL.md"]`), so no agent's convention is baked in.
|
|
15
|
+
* Claude Code's own convention is re-exported below
|
|
16
|
+
* ({@link CLAUDE_INSTRUCTION_GLOBS} / {@link excludeFromContextScan}) so a
|
|
17
|
+
* caller that wants it takes the SessionStart hook's exact scope rather than
|
|
18
|
+
* approximating it — see ./claude-context.mjs.
|
|
15
19
|
*/
|
|
16
20
|
import {
|
|
17
21
|
readFileSync,
|
|
@@ -36,6 +40,18 @@ import {
|
|
|
36
40
|
countPayloadInvisible,
|
|
37
41
|
stripInvisible,
|
|
38
42
|
} from "./invisible.mjs";
|
|
43
|
+
import { excludeNodeModules } from "./claude-context.mjs";
|
|
44
|
+
|
|
45
|
+
// The library's public door onto Claude Code's context scope. The definitions
|
|
46
|
+
// live in a dependency-free data module because the SessionStart hook imports
|
|
47
|
+
// them relatively (see ./claude-context.mjs); re-exporting them here is what
|
|
48
|
+
// makes `agent-sanitizer/instructions` the single place a CLI, a port or a fork
|
|
49
|
+
// reads that scope from instead of re-spelling it.
|
|
50
|
+
export {
|
|
51
|
+
CLAUDE_CONTEXT_SUBDIRS,
|
|
52
|
+
CLAUDE_INSTRUCTION_GLOBS,
|
|
53
|
+
excludeFromContextScan,
|
|
54
|
+
} from "./claude-context.mjs";
|
|
39
55
|
|
|
40
56
|
// Prefix on any decoded tag-character payload. The decoded text is
|
|
41
57
|
// attacker-controlled and flows into the scan report, which itself reaches model
|
|
@@ -296,18 +312,28 @@ function keepContained(absPath, realRoot, literalRoot, pattern) {
|
|
|
296
312
|
* cannot be resolved (a dangling symlink or unreadable entry inside the
|
|
297
313
|
* tree), is SKIPPED, so one bad symlink never aborts scanning the rest of the
|
|
298
314
|
* project.
|
|
315
|
+
*
|
|
316
|
+
* `exclude` prunes the WALK, which is where a wide glob's cost actually is —
|
|
317
|
+
* a pattern that merely fails to match a bulk directory still pays to read it.
|
|
318
|
+
* It is composed with, never replaces, the unconditional `node_modules` prune:
|
|
319
|
+
* a caller narrowing the scan must not be able to widen it into a dependency
|
|
320
|
+
* tree. Pass {@link excludeFromContextScan} to take Claude Code's own scope.
|
|
299
321
|
* @param {string[]} globs
|
|
300
|
-
* @param {{ cwd?: string }} [options]
|
|
322
|
+
* @param {{ cwd?: string, exclude?: (entry: string) => boolean }} [options]
|
|
301
323
|
* @returns {string[]}
|
|
302
324
|
*/
|
|
303
|
-
export function findInstructionFiles(
|
|
325
|
+
export function findInstructionFiles(
|
|
326
|
+
globs,
|
|
327
|
+
{ cwd = process.cwd(), exclude } = {},
|
|
328
|
+
) {
|
|
304
329
|
const literalRoot = resolve(cwd);
|
|
305
330
|
const realRoot = realpathSync(literalRoot);
|
|
306
331
|
const seen = new Set();
|
|
307
332
|
for (const pattern of globs)
|
|
308
333
|
for (const name of globSync(pattern, {
|
|
309
334
|
cwd,
|
|
310
|
-
exclude: (entry) =>
|
|
335
|
+
exclude: (entry) =>
|
|
336
|
+
excludeNodeModules(entry) || (exclude?.(entry) ?? false),
|
|
311
337
|
})) {
|
|
312
338
|
// globSync returns absolute paths verbatim for an absolute pattern and
|
|
313
339
|
// cwd-relative names otherwise; joining an already-absolute name would
|
|
@@ -324,12 +350,16 @@ export function findInstructionFiles(globs, { cwd = process.cwd() } = {}) {
|
|
|
324
350
|
* findings, each path reported relative to `cwd`. Unreadable/missing files are
|
|
325
351
|
* skipped. Pure scan — no mutation; pair with {@link cleanFile} to strip.
|
|
326
352
|
* @param {string[]} globs
|
|
327
|
-
* @param {{ cwd?: string }} [options]
|
|
353
|
+
* @param {{ cwd?: string, exclude?: (entry: string) => boolean }} [options]
|
|
354
|
+
* `exclude` is forwarded to {@link findInstructionFiles}
|
|
328
355
|
* @returns {Array<{ file: string, findings: ReturnType<typeof scanText> }>}
|
|
329
356
|
*/
|
|
330
|
-
export function scanInstructionFiles(
|
|
357
|
+
export function scanInstructionFiles(
|
|
358
|
+
globs,
|
|
359
|
+
{ cwd = process.cwd(), exclude } = {},
|
|
360
|
+
) {
|
|
331
361
|
const out = [];
|
|
332
|
-
for (const file of findInstructionFiles(globs, { cwd })) {
|
|
362
|
+
for (const file of findInstructionFiles(globs, { cwd, exclude })) {
|
|
333
363
|
let content;
|
|
334
364
|
try {
|
|
335
365
|
content = readFileSync(file, "utf-8");
|
package/src/invisible.mjs
CHANGED
|
@@ -175,11 +175,7 @@ export const LONG_RUN_RE = new RegExp(
|
|
|
175
175
|
*/
|
|
176
176
|
export function describeStripped(invisFound, deAnsi) {
|
|
177
177
|
let msg = `Stripped: ${invisFound.map((code) => CATEGORY_LABELS[code]).join(", ")}`;
|
|
178
|
-
|
|
179
|
-
// Probe only the PAYLOAD invisibles: a legitimate emoji/flag/variation
|
|
180
|
-
// sequence is carve-out-preserved and masked out here, so it never trips the
|
|
181
|
-
// injection marker (alert fatigue) while a genuine hidden run still surfaces.
|
|
182
|
-
if (LONG_RUN_RE.test(payloadInvisibleView(deAnsi)))
|
|
178
|
+
if (payloadLongRunSample(deAnsi) !== null)
|
|
183
179
|
msg += " [LONG RUN — possible injection payload]";
|
|
184
180
|
return (
|
|
185
181
|
msg +
|
|
@@ -1048,6 +1044,90 @@ export function payloadInvisibleView(text) {
|
|
|
1048
1044
|
return out;
|
|
1049
1045
|
}
|
|
1050
1046
|
|
|
1047
|
+
/**
|
|
1048
|
+
* The first payload-invisible LONG RUN in `text`, or null when there is none.
|
|
1049
|
+
*
|
|
1050
|
+
* THE definition of "this text carries a hidden run", shared by every consumer
|
|
1051
|
+
* that has an opinion about one: the strip's `[LONG RUN — possible injection
|
|
1052
|
+
* payload]` marker, the prompt gate's block decision, and the tool-output
|
|
1053
|
+
* severity tier. They used to spell it twice, and differently — the marker
|
|
1054
|
+
* probed the PAYLOAD view while the prompt gate probed the raw text, so a
|
|
1055
|
+
* legitimate ten-emoji flag sequence (carve-out-preserved, never stripped) was
|
|
1056
|
+
* quietly enough to BLOCK a prompt while the strip that saw the same text
|
|
1057
|
+
* declined to even flag it. Masking the preserved invisibles is the right half
|
|
1058
|
+
* of that disagreement: a run the carve-out keeps is rendering work, not a
|
|
1059
|
+
* channel, and the joiners it does NOT keep are counted as payload anyway (see
|
|
1060
|
+
* {@link countEffectiveInvisible}).
|
|
1061
|
+
*
|
|
1062
|
+
* Because the view replaces only PRESERVED invisibles (and visible characters)
|
|
1063
|
+
* with spaces, a match consists solely of payload code points and is therefore
|
|
1064
|
+
* byte-identical to the corresponding span of `text` — so a caller may report
|
|
1065
|
+
* the sample verbatim.
|
|
1066
|
+
* @param {string} text
|
|
1067
|
+
* @returns {string | null}
|
|
1068
|
+
*/
|
|
1069
|
+
export function payloadLongRunSample(text) {
|
|
1070
|
+
LONG_RUN_RE.lastIndex = 0;
|
|
1071
|
+
return payloadInvisibleView(text).match(LONG_RUN_RE)?.[0] ?? null;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* How many invisible code points in `text` the strip layer treats as PAYLOAD:
|
|
1076
|
+
* the ones {@link countPayloadInvisible} counts, plus the joiners that sit in a
|
|
1077
|
+
* genuine linguistic context but exceed the carve-out's preservation budget.
|
|
1078
|
+
*
|
|
1079
|
+
* The surplus term closes the preserved-joiner covert channel (O3):
|
|
1080
|
+
* `countPayloadInvisible` excludes every ZWNJ/ZWJ doing real rendering work, so
|
|
1081
|
+
* an attacker who alternates `letter joiner letter joiner …` — every joiner
|
|
1082
|
+
* legitimately between two cursive letters — counts as ZERO there. The strip
|
|
1083
|
+
* layer already refuses that (it preserves joiners only up to
|
|
1084
|
+
* TOTAL_PRESERVED_JOINER_BUDGET / CONSECUTIVE_JOINER_CAP and strips the rest),
|
|
1085
|
+
* so the surplus is read back OFF the strip — the SSOT — rather than by
|
|
1086
|
+
* re-deriving the budget here, which is what would drift.
|
|
1087
|
+
*
|
|
1088
|
+
* A leading BOM is preserved by the strip but counted by
|
|
1089
|
+
* {@link countPayloadInvisible}, so the difference can go slightly negative;
|
|
1090
|
+
* hence the clamp.
|
|
1091
|
+
* @param {string} text ANSI-stripped text (an escape sequence can hide invisibles)
|
|
1092
|
+
* @returns {number}
|
|
1093
|
+
*/
|
|
1094
|
+
export function countEffectiveInvisible(text) {
|
|
1095
|
+
const payload = countPayloadInvisible(text);
|
|
1096
|
+
const surplusPreservedJoiners = Math.max(
|
|
1097
|
+
0,
|
|
1098
|
+
[...text].length - [...stripInvisible(text)].length - payload,
|
|
1099
|
+
);
|
|
1100
|
+
return payload + surplusPreservedJoiners;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* True when the invisible characters in `text` are INCIDENTAL: no hidden run,
|
|
1105
|
+
* and too few of them in total to carry an instruction.
|
|
1106
|
+
*
|
|
1107
|
+
* This is a severity line, not a strip line — the bytes are removed either way
|
|
1108
|
+
* (see ../src/severity.mjs). It exists because a single soft hyphen in a
|
|
1109
|
+
* pasted paragraph, or one variation selector a font demanded, raised the exact
|
|
1110
|
+
* `WARNING: Tool output sanitized` an encoded payload does, and a warning that
|
|
1111
|
+
* fires on a stray character in ordinary prose is one operators learn to skip.
|
|
1112
|
+
*
|
|
1113
|
+
* The bar is {@link LONG_RUN_THRESHOLD} — the count this module already calls
|
|
1114
|
+
* "payload length" — applied to the WHOLE text rather than to one run, so it is
|
|
1115
|
+
* strictly stronger than the run probe: fewer than ten payload-invisible code
|
|
1116
|
+
* points, however they are distributed, cannot spell a smuggled instruction (ten
|
|
1117
|
+
* tag characters are ten ASCII letters). Deliberately NOT the far looser
|
|
1118
|
+
* {@link SCATTERED_THRESHOLD} of 30, which is the prompt gate's BLOCK bar: 29
|
|
1119
|
+
* tag characters is a short sentence, and staying quiet about a short sentence
|
|
1120
|
+
* hidden in a tool result is not a trade worth making.
|
|
1121
|
+
* @param {string} text ANSI-stripped text, invisible runs intact
|
|
1122
|
+
* @returns {boolean}
|
|
1123
|
+
*/
|
|
1124
|
+
export function isIncidentalInvisible(text) {
|
|
1125
|
+
return (
|
|
1126
|
+
payloadLongRunSample(text) === null &&
|
|
1127
|
+
countEffectiveInvisible(text) < LONG_RUN_THRESHOLD
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1051
1131
|
/**
|
|
1052
1132
|
* Strip payload-capable invisible chars and report which categories were
|
|
1053
1133
|
* removed. A single leading U+FEFF (BOM) is preserved as a legitimate marker;
|
package/src/layer1.mjs
CHANGED
|
@@ -109,6 +109,21 @@ export function stripAnsiFully(input, kinds) {
|
|
|
109
109
|
return out;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/**
|
|
113
|
+
* What a reader is told when the ONLY thing a strip removed was inert ANSI (see
|
|
114
|
+
* {@link isBenignAnsiKinds}).
|
|
115
|
+
*
|
|
116
|
+
* It lives here, beside the predicate that decides it, because every entry point
|
|
117
|
+
* that can reach that verdict must say the same thing: the tool-output pipeline,
|
|
118
|
+
* the prompt gate's pass-with-note, and any host wiring its own. The wording is
|
|
119
|
+
* deliberately not `describeStripped`'s — "Stripped: ANSI escapes" names a
|
|
120
|
+
* category that reads like an attack, when the honest report is "these were
|
|
121
|
+
* colour codes, and here is how to look at the raw bytes".
|
|
122
|
+
*/
|
|
123
|
+
export const INERT_ANSI_NOTE =
|
|
124
|
+
"Inert ANSI stripped (display-only colour and/or a stray escape byte that " +
|
|
125
|
+
"formed no control sequence); pipe through cat -v to inspect raw escapes.";
|
|
126
|
+
|
|
112
127
|
/**
|
|
113
128
|
* True when the ANSI a Layer-1 strip removed was INERT: every removed sequence
|
|
114
129
|
* was either a display-only SGR colour token or a LONE 7-bit `ESC` that opened
|