agent-sanitizer 2.28.0 → 2.28.2
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/bin/sanitize-cli.mjs +22 -20
- package/claude-hooks/config/inference-key-vars.json +1 -2
- package/claude-hooks/lib/env-config.mjs +5 -1
- package/claude-hooks/lib/secret-annotate.mjs +53 -16
- package/claude-hooks/plugin-hooks.mjs +1 -0
- package/claude-hooks/scan-invisible-chars.mjs +99 -98
- package/package.json +3 -1
- package/python/agent_sanitizer/data/invisible-charset.json +481 -0
- package/python/agent_sanitizer/secrets/data/redaction-floor.json +4 -0
- package/src/ansi.mjs +17 -3
- package/src/instructions.mjs +9 -5
- package/types/ansi.d.mts +2 -1
- package/types/claude-hooks/lib/secret-annotate.d.mts +10 -1
- package/types/claude-hooks/scan-invisible-chars.d.mts +16 -9
- package/types/instructions.d.mts +9 -5
package/bin/sanitize-cli.mjs
CHANGED
|
@@ -77,17 +77,22 @@ function maxInputBytes() {
|
|
|
77
77
|
const errorMessage = (err) =>
|
|
78
78
|
/** @type {{ message?: string }} */ (err)?.message ?? String(err);
|
|
79
79
|
|
|
80
|
-
/**
|
|
81
|
-
* and the env var so a caller can act on it
|
|
80
|
+
/** The one oversize-rejection message, for every path that enforces the cap.
|
|
81
|
+
* Names the limit and the env var so a caller can act on it; `size` is included
|
|
82
|
+
* when the rejecting path knows it (the streaming paths discard the input
|
|
83
|
+
* unbuffered, so they only know the cap was crossed).
|
|
84
|
+
* @param {number} limit @param {number} [size] */
|
|
85
|
+
const oversizeMessage = (limit, size) =>
|
|
86
|
+
`request too large: ${size === undefined ? "input" : `${size} bytes`} ` +
|
|
87
|
+
`exceeds the ${limit}-byte limit ` +
|
|
88
|
+
"(raise AGENT_SANITIZER_MAX_INPUT_BYTES to accept it)";
|
|
89
|
+
|
|
90
|
+
/** Throw if `text` exceeds the configured byte cap.
|
|
82
91
|
* @param {string} text */
|
|
83
92
|
function enforceSizeLimit(text) {
|
|
84
93
|
const limit = maxInputBytes();
|
|
85
94
|
const size = Buffer.byteLength(text, "utf8");
|
|
86
|
-
if (size > limit)
|
|
87
|
-
throw new Error(
|
|
88
|
-
`request too large: ${size} bytes exceeds the ${limit}-byte limit ` +
|
|
89
|
-
"(raise AGENT_SANITIZER_MAX_INPUT_BYTES to accept it)",
|
|
90
|
-
);
|
|
95
|
+
if (size > limit) throw new Error(oversizeMessage(limit, size));
|
|
91
96
|
}
|
|
92
97
|
|
|
93
98
|
/** Read a required string field, throwing when it is absent or the wrong type.
|
|
@@ -105,7 +110,7 @@ function requireString(req, key) {
|
|
|
105
110
|
/** Operations the CLI exposes. Each takes the parsed request, returns the JSON
|
|
106
111
|
* payload object. Non-`sanitize` modules are imported lazily so a caller that
|
|
107
112
|
* only ever sanitizes never loads prompt/output/instructions code. */
|
|
108
|
-
const OPS = {
|
|
113
|
+
export const OPS = {
|
|
109
114
|
/** @param {Record<string, unknown>} req */
|
|
110
115
|
async sanitize(req) {
|
|
111
116
|
const text = requireString(req, "text");
|
|
@@ -203,11 +208,7 @@ async function readAll(stream) {
|
|
|
203
208
|
let bytes = 0;
|
|
204
209
|
for await (const chunk of stream) {
|
|
205
210
|
bytes += Buffer.byteLength(chunk, "utf8");
|
|
206
|
-
if (bytes > limit)
|
|
207
|
-
throw new Error(
|
|
208
|
-
`request too large: input exceeds the ${limit}-byte limit ` +
|
|
209
|
-
"(raise AGENT_SANITIZER_MAX_INPUT_BYTES to accept it)",
|
|
210
|
-
);
|
|
211
|
+
if (bytes > limit) throw new Error(oversizeMessage(limit));
|
|
211
212
|
text += chunk;
|
|
212
213
|
}
|
|
213
214
|
return text;
|
|
@@ -339,11 +340,7 @@ function createLineSplitter(limit) {
|
|
|
339
340
|
|
|
340
341
|
/** @param {number} limit */
|
|
341
342
|
const OVERSIZE_ERROR = (limit) =>
|
|
342
|
-
JSON.stringify({
|
|
343
|
-
error:
|
|
344
|
-
`request too large: input exceeds the ${limit}-byte limit ` +
|
|
345
|
-
"(raise AGENT_SANITIZER_MAX_INPUT_BYTES to accept it)",
|
|
346
|
-
});
|
|
343
|
+
JSON.stringify({ error: oversizeMessage(limit) });
|
|
347
344
|
|
|
348
345
|
async function runWorker() {
|
|
349
346
|
// Stream raw bytes (no encoding) so the splitter tracks UTF-8 byte length, not
|
|
@@ -417,6 +414,11 @@ function invokedAsScript() {
|
|
|
417
414
|
return false;
|
|
418
415
|
}
|
|
419
416
|
}
|
|
417
|
+
// Derived from OPS (the dispatch table is the SSOT) so adding or renaming an
|
|
418
|
+
// op can never leave the help text advertising a stale set.
|
|
419
|
+
const OPS_SENTENCE = Object.keys(OPS)
|
|
420
|
+
.map((op) => (op === "sanitize" ? "sanitize (default)" : op))
|
|
421
|
+
.join(", ");
|
|
420
422
|
export const USAGE = `sanitize-cli — sanitize untrusted text before an LLM sees it.
|
|
421
423
|
|
|
422
424
|
Reads JSON on stdin, writes one JSON response line on stdout.
|
|
@@ -426,8 +428,8 @@ Usage:
|
|
|
426
428
|
sanitize-cli --worker worker: read newline-delimited requests until EOF, one response per line
|
|
427
429
|
sanitize-cli --help show this help
|
|
428
430
|
|
|
429
|
-
Request: { "op"?: string, ...fields }. Ops:
|
|
430
|
-
|
|
431
|
+
Request: { "op"?: string, ...fields }. Ops: ${OPS_SENTENCE}.
|
|
432
|
+
A failure response is { "error": string }.
|
|
431
433
|
`;
|
|
432
434
|
|
|
433
435
|
/**
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "Inference-provider API-key env vars whose VALUES the redactor masks by exact match
|
|
3
|
-
"min_secret_len": 16,
|
|
2
|
+
"description": "Inference-provider API-key env vars whose VALUES the redactor masks by exact match; the hooks send these names' current values to the redactor daemon per request (see lib/redactor-client.mjs). The placeholder floor lives in python/agent_sanitizer/secrets/data/redaction-floor.json — the one physical file both ecosystems read — not here.",
|
|
4
3
|
"vars": [
|
|
5
4
|
"ANTHROPIC_API_KEY",
|
|
6
5
|
"ANTHROPIC_AUTH_TOKEN",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import { credentialNameMatcher } from "agent-sanitizer/credential-names-matcher";
|
|
23
23
|
|
|
24
24
|
import credentialNames from "../../python/agent_sanitizer/secrets/data/credential-names.json" with { type: "json" };
|
|
25
|
+
import redactionFloor from "../../python/agent_sanitizer/secrets/data/redaction-floor.json" with { type: "json" };
|
|
25
26
|
import inferenceKeys from "../config/inference-key-vars.json" with { type: "json" };
|
|
26
27
|
import scrubbed from "../config/scrubbed-env-vars.json" with { type: "json" };
|
|
27
28
|
|
|
@@ -102,7 +103,10 @@ function hostSource() {
|
|
|
102
103
|
*/
|
|
103
104
|
export function minEnvSecretLen() {
|
|
104
105
|
const hostLen = hostSource()?.minSecretLen;
|
|
105
|
-
|
|
106
|
+
// The package floor comes from the same physical file
|
|
107
|
+
// agent_sanitizer.secrets.config reads (DEFAULT_MIN_SECRET_LEN), so the JS
|
|
108
|
+
// pre-gate and the Python daemon cannot drift apart on it.
|
|
109
|
+
if (hostLen === undefined) return redactionFloor.min_secret_len;
|
|
106
110
|
if (!Number.isInteger(hostLen) || hostLen <= 0)
|
|
107
111
|
throw new Error(
|
|
108
112
|
`${HOST_SOURCE_LABEL}: minSecretLen must be a positive integer, got ${JSON.stringify(hostLen)}`,
|
|
@@ -5,33 +5,70 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { minEnvSecretLen, envBoundSecretVars } from "./env-config.mjs";
|
|
7
7
|
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
8
|
+
// The generated cross-language charset SSOT, imported as a module like
|
|
9
|
+
// env-config.mjs's JSON configs: esbuild inlines it into the plugin bundle
|
|
10
|
+
// (which ships with no data directory beside it) and Node resolves it from the
|
|
11
|
+
// package when the hooks run from source. A DATA import, deliberately not a
|
|
12
|
+
// static `agent-sanitizer/invisible` import — a top-level package import in a
|
|
13
|
+
// hook lib aborts the process before the consuming hook's fail-closed catch
|
|
14
|
+
// installs, which is a fail OPEN on the module that withholds secrets.
|
|
15
|
+
import charset from "../../python/agent_sanitizer/data/invisible-charset.json" with { type: "json" };
|
|
16
|
+
|
|
17
|
+
// Invisible characters an attacker can splice between a value's characters to
|
|
18
|
+
// break an exact-substring pre-gate while the daemon's redactor still matches
|
|
19
|
+
// across them. Built from the SAME pinned charset Layer 1 strips and the daemon
|
|
20
|
+
// tolerates (`invisible_run_pattern` in agent_sanitizer.secrets.invisible
|
|
21
|
+
// builds the same shape from the same set), not a hand-curated subset: a subset
|
|
22
|
+
// pre-gate silently under-matches whenever this gate runs on text Layer 1 has
|
|
23
|
+
// not already stripped (the module is exported and callable on its own), and a
|
|
24
|
+
// value spliced with a code point in the gap then never reaches the daemon at
|
|
25
|
+
// all. A run of zero-or-more is allowed at each interior gap, so the plain
|
|
26
|
+
// value still matches (a superset of `includes`). The required literals between
|
|
27
|
+
// every gap are what bound the match — a `*` gap is never adjacent to another
|
|
28
|
+
// gap, so there is no ambiguity to backtrack over and no ReDoS. (The class size
|
|
29
|
+
// is irrelevant to that: a character class matches in O(1) whether it holds one
|
|
30
|
+
// member or all 435.)
|
|
17
31
|
const ENV_INVIS_RUN =
|
|
18
|
-
"[
|
|
32
|
+
"[" +
|
|
33
|
+
[...new Set([...charset.cf_codepoints, ...charset.extra_codepoints])]
|
|
34
|
+
.sort((a, b) => a - b)
|
|
35
|
+
.map((cp) => `\\u{${cp.toString(16)}}`)
|
|
36
|
+
.join("") +
|
|
37
|
+
"]*";
|
|
19
38
|
|
|
20
39
|
/**
|
|
21
40
|
* Regex matching `value` tolerating invisible chars spliced between its
|
|
22
41
|
* characters (mirrors the engine's env-value regex). Code-point split so
|
|
23
|
-
* an astral character is escaped whole, not as two surrogate halves
|
|
42
|
+
* an astral character is escaped whole, not as two surrogate halves — and the
|
|
43
|
+
* `u` flag, which the astral `\u{…}` class members in {@link ENV_INVIS_RUN}
|
|
44
|
+
* require.
|
|
45
|
+
* Memoized per distinct value: {@link ENV_INVIS_RUN} renders ~435 code points
|
|
46
|
+
* as ~4 KB of source and is joined at EVERY interior gap, so a 20-char secret
|
|
47
|
+
* compiles a ~75 KB pattern — and `hasEnvBoundSecret` builds one per configured
|
|
48
|
+
* var on every PostToolUse output. Env values are stable for the process's
|
|
49
|
+
* lifetime, so the cache is bounded by the number of distinct values. Sharing an
|
|
50
|
+
* instance is safe because the regex carries no `g`/`y` flag, hence no
|
|
51
|
+
* `lastIndex` state to leak between calls.
|
|
24
52
|
* @param {string} value
|
|
25
53
|
* @returns {RegExp}
|
|
26
54
|
*/
|
|
27
55
|
export function envValueRegex(value) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
56
|
+
let re = ENV_VALUE_REGEX_CACHE.get(value);
|
|
57
|
+
if (re === undefined) {
|
|
58
|
+
re = new RegExp(
|
|
59
|
+
[...value]
|
|
60
|
+
.map((ch) => ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
61
|
+
.join(ENV_INVIS_RUN),
|
|
62
|
+
"u",
|
|
63
|
+
);
|
|
64
|
+
ENV_VALUE_REGEX_CACHE.set(value, re);
|
|
65
|
+
}
|
|
66
|
+
return re;
|
|
33
67
|
}
|
|
34
68
|
|
|
69
|
+
/** value → compiled matcher; see {@link envValueRegex}. */
|
|
70
|
+
const ENV_VALUE_REGEX_CACHE = new Map();
|
|
71
|
+
|
|
35
72
|
/**
|
|
36
73
|
* True when tool output contains the literal value of a configured env-bound
|
|
37
74
|
* secret. The shape-based secret hint can't match a prefix-less key or a host
|
|
@@ -65,6 +65,7 @@ const LAZY_LOADERS = {
|
|
|
65
65
|
import("agent-control-plane-core/claude"),
|
|
66
66
|
"agent-sanitizer": () => import("agent-sanitizer"),
|
|
67
67
|
"agent-sanitizer/confusables": () => import("agent-sanitizer/confusables"),
|
|
68
|
+
"agent-sanitizer/instructions": () => import("agent-sanitizer/instructions"),
|
|
68
69
|
"agent-sanitizer/invisible": () => import("agent-sanitizer/invisible"),
|
|
69
70
|
"agent-sanitizer/output": () => import("agent-sanitizer/output"),
|
|
70
71
|
"agent-sanitizer/prompt": () => import("agent-sanitizer/prompt"),
|
|
@@ -4,8 +4,17 @@
|
|
|
4
4
|
* invisible sequences (tag chars, zero-width encodings) that hijack the model's
|
|
5
5
|
* behavior — invisible in an editor but read by the LLM. These files load as
|
|
6
6
|
* project instructions at session start, bypassing the PostToolUse sanitizer.
|
|
7
|
+
*
|
|
8
|
+
* The scan/decode/clean LOGIC lives in `agent-sanitizer/instructions` — this
|
|
9
|
+
* hook is glue (target discovery, accounting, alert persistence, fault
|
|
10
|
+
* posture) over that SSOT. A hand-written twin used to live here and drifted
|
|
11
|
+
* three ways at once: its report re-emitted the decoded payload with no
|
|
12
|
+
* `untrusted data` framing or escaping (re-injecting the very instruction the
|
|
13
|
+
* scan exists to catch), its scattered counter re-grew the linguistic-joiner
|
|
14
|
+
* false positive the SSOT had already fixed, and its clean path was a bare
|
|
15
|
+
* `writeFileSync` with none of cleanFile's symlink/UTF-8/TOCTOU guards.
|
|
7
16
|
*/
|
|
8
|
-
import { readFileSync, globSync,
|
|
17
|
+
import { readFileSync, globSync, unlinkSync } from "node:fs";
|
|
9
18
|
import { join, relative } from "node:path";
|
|
10
19
|
import {
|
|
11
20
|
awaitLazyDependency,
|
|
@@ -44,35 +53,43 @@ import {
|
|
|
44
53
|
excludeFromContextScan,
|
|
45
54
|
} from "../src/claude-context.mjs";
|
|
46
55
|
|
|
47
|
-
// Layer-1 primitives, bound via lazyImport (see
|
|
48
|
-
// hazard of a bare static npm import — here the
|
|
49
|
-
// UNSCANNED). A failed load leaves the bindings
|
|
50
|
-
// (node deps not yet installed) cliMain's guard
|
|
51
|
-
// before giving up, and fails loud rather than
|
|
56
|
+
// Layer-1 primitives + the instruction-scanner SSOT, bound via lazyImport (see
|
|
57
|
+
// its doc for the fail-OPEN hazard of a bare static npm import — here the
|
|
58
|
+
// instruction files would load UNSCANNED). A failed load leaves the bindings
|
|
59
|
+
// undefined; on a cold container (node deps not yet installed) cliMain's guard
|
|
60
|
+
// below waits out session-setup before giving up, and fails loud rather than
|
|
61
|
+
// silently passing.
|
|
52
62
|
// `let`, not `const`: the cold-start poll re-binds these once the package loads.
|
|
53
63
|
let {
|
|
54
64
|
LONG_RUN_RE,
|
|
55
65
|
LONG_RUN_THRESHOLD,
|
|
56
66
|
SCATTERED_THRESHOLD: TOTAL_INVISIBLE_THRESHOLD,
|
|
57
|
-
STRIP,
|
|
58
|
-
stripInvisible,
|
|
59
67
|
} = /** @type {typeof import("agent-sanitizer/invisible")} */ (
|
|
60
68
|
await lazyImport("agent-sanitizer/invisible")
|
|
61
69
|
);
|
|
70
|
+
let {
|
|
71
|
+
decodeRun: instrDecodeRun,
|
|
72
|
+
scanText,
|
|
73
|
+
cleanFile,
|
|
74
|
+
} = /** @type {typeof import("agent-sanitizer/instructions")} */ (
|
|
75
|
+
await lazyImport("agent-sanitizer/instructions")
|
|
76
|
+
);
|
|
62
77
|
|
|
63
78
|
/**
|
|
64
|
-
* Re-attempt the sanitizer
|
|
65
|
-
* giving up. On a cold container the node deps this hook needs are still
|
|
66
|
-
* installed when SessionStart fires; without this wait `
|
|
67
|
-
* undefined, the scan is skipped, and the instruction files load UNSCANNED for
|
|
68
|
-
* whole session (fail open) — silently. Reuses the control-plane poll
|
|
69
|
-
* PID liveness) so the wait bound matches every other
|
|
79
|
+
* Re-attempt the sanitizer imports, waiting out an in-flight session-setup
|
|
80
|
+
* before giving up. On a cold container the node deps this hook needs are still
|
|
81
|
+
* being installed when SessionStart fires; without this wait `scanText` is
|
|
82
|
+
* undefined, the scan is skipped, and the instruction files load UNSCANNED for
|
|
83
|
+
* the whole session (fail open) — silently. Reuses the control-plane poll
|
|
84
|
+
* (marker + PID liveness) so the wait bound matches every other
|
|
85
|
+
* cold-start-aware gate.
|
|
70
86
|
* @returns {Promise<boolean>} whether the sanitizer is now bound
|
|
71
87
|
*/
|
|
72
88
|
async function ensureSanitizerLoaded() {
|
|
73
|
-
if (typeof
|
|
89
|
+
if (typeof scanText === "function" && typeof cleanFile === "function")
|
|
90
|
+
return true;
|
|
74
91
|
/* c8 ignore start -- cold-start reload: only runs when the top-level
|
|
75
|
-
agent-sanitizer
|
|
92
|
+
agent-sanitizer imports above failed (node deps not yet installed), which
|
|
76
93
|
can't be simulated in-process or in the spawned-subprocess CLI run the tests
|
|
77
94
|
observe (the test env always has the deps, so the guard above early-returns).
|
|
78
95
|
The reload reuses awaitLazyDependency / markerIsTrusted /
|
|
@@ -80,20 +97,28 @@ async function ensureSanitizerLoaded() {
|
|
|
80
97
|
const marker = hookgateMarkerPath();
|
|
81
98
|
const reloaded = await awaitLazyDependency({
|
|
82
99
|
tryImport: async () => {
|
|
83
|
-
const
|
|
84
|
-
|
|
100
|
+
const invisible = await lazyImport("agent-sanitizer/invisible");
|
|
101
|
+
const instructions = await lazyImport("agent-sanitizer/instructions");
|
|
102
|
+
return typeof instructions.scanText === "function" &&
|
|
103
|
+
typeof instructions.cleanFile === "function" &&
|
|
104
|
+
invisible.LONG_RUN_RE !== undefined
|
|
105
|
+
? { invisible, instructions }
|
|
106
|
+
: null;
|
|
85
107
|
},
|
|
86
108
|
markerPresent: () => markerIsTrusted(marker),
|
|
87
109
|
setupAlive: () => probeSetupAlive(marker),
|
|
88
110
|
});
|
|
89
111
|
if (!reloaded) return false;
|
|
112
|
+
const bound = /** @type {{
|
|
113
|
+
invisible: typeof import("agent-sanitizer/invisible"),
|
|
114
|
+
instructions: typeof import("agent-sanitizer/instructions"),
|
|
115
|
+
}} */ (reloaded);
|
|
90
116
|
({
|
|
91
117
|
LONG_RUN_RE,
|
|
92
118
|
LONG_RUN_THRESHOLD,
|
|
93
119
|
SCATTERED_THRESHOLD: TOTAL_INVISIBLE_THRESHOLD,
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
} = /** @type {typeof import("agent-sanitizer/invisible")} */ (reloaded));
|
|
120
|
+
} = bound.invisible);
|
|
121
|
+
({ decodeRun: instrDecodeRun, scanText, cleanFile } = bound.instructions);
|
|
97
122
|
return true;
|
|
98
123
|
/* c8 ignore stop */
|
|
99
124
|
}
|
|
@@ -164,46 +189,29 @@ function persistAlert(parts) {
|
|
|
164
189
|
// Decoder
|
|
165
190
|
|
|
166
191
|
/**
|
|
192
|
+
* The SSOT decoder, re-exported through a lazy-bound wrapper (the binding is
|
|
193
|
+
* `let` and may be re-bound by the cold-start reload, so the export must read
|
|
194
|
+
* it at call time). A hand-written twin used to live here; it decoded tag
|
|
195
|
+
* characters to RAW bytes — including actual C0 controls for U+E0001–U+E001F —
|
|
196
|
+
* with no `untrusted data, not instructions:` framing or escaping, so the
|
|
197
|
+
* hook's own report re-injected the hidden payload it had just caught.
|
|
167
198
|
* @param {string} run
|
|
168
199
|
* @returns {{ method: string, decoded: string }}
|
|
169
200
|
*/
|
|
170
201
|
function decodeRun(run) {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
// Tag characters U+E0001-U+E007F map directly to ASCII
|
|
174
|
-
const tagAscii = cps
|
|
175
|
-
.filter((cp) => cp >= 0xe0001 && cp <= 0xe007f)
|
|
176
|
-
// Stryker disable next-line ArithmeticOperator: cp - 0xe0000 → cp + 0xe0000 is equivalent — 0xe0000 is a multiple of 2^16 and String.fromCharCode truncates to 16 bits, so both yield the same character.
|
|
177
|
-
.map((cp) => String.fromCharCode(cp - 0xe0000))
|
|
178
|
-
.join("");
|
|
179
|
-
|
|
180
|
-
if (tagAscii.length > 0) {
|
|
181
|
-
return { method: "Unicode tag characters → ASCII", decoded: tagAscii };
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// Zero-width binary encoding: ZWSP=0, ZWNJ=1, ZWJ=group separator.
|
|
185
|
-
const ZW_BIT = new Map([
|
|
186
|
-
[0x200b, "0"],
|
|
187
|
-
[0x200c, "1"],
|
|
188
|
-
[0x200d, "|"],
|
|
189
|
-
]);
|
|
190
|
-
if (cps.every((cp) => ZW_BIT.has(cp))) {
|
|
191
|
-
const bits = cps.map((cp) => ZW_BIT.get(cp)).join("");
|
|
192
|
-
return {
|
|
193
|
-
method: "zero-width binary encoding",
|
|
194
|
-
decoded: `[${cps.length} zero-width chars: ${bits.slice(0, 80)}]`,
|
|
195
|
-
};
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// Mixed/unknown
|
|
199
|
-
return {
|
|
200
|
-
method: "invisible Unicode sequence",
|
|
201
|
-
decoded: cps
|
|
202
|
-
.map((cp) => `U+${cp.toString(16).toUpperCase().padStart(4, "0")}`)
|
|
203
|
-
.join(" "),
|
|
204
|
-
};
|
|
202
|
+
return instrDecodeRun(run);
|
|
205
203
|
}
|
|
206
204
|
|
|
205
|
+
// Target discovery stays hook-local glue, NOT a copy of the SSOT's
|
|
206
|
+
// containment-checked `findInstructionFiles`: the two have different contracts.
|
|
207
|
+
// The SSOT finder silently DROPS a target it cannot resolve (dangling symlink,
|
|
208
|
+
// out-of-tree symlink), which is right for a pure scan API — but this hook's
|
|
209
|
+
// accounting invariant (scanned + skipped === targets, see scanProject)
|
|
210
|
+
// requires unreadable targets to stay LISTED so they are reported as unvetted
|
|
211
|
+
// rather than vanishing into an "all clean" announcement. The write-side
|
|
212
|
+
// symlink hazard the SSOT finder guards against is covered here by cleanFile's
|
|
213
|
+
// own O_NOFOLLOW open.
|
|
214
|
+
|
|
207
215
|
/**
|
|
208
216
|
* Every file under `dir` that Claude Code loads as model context: the
|
|
209
217
|
* per-directory instruction files (CLAUDE.md, CLAUDE.local.md, AGENTS.md) and
|
|
@@ -230,37 +238,17 @@ function findInstructionFiles(dir) {
|
|
|
230
238
|
// Scanner
|
|
231
239
|
|
|
232
240
|
/**
|
|
241
|
+
* Read one file and run the SSOT scan over it. The scan logic itself (long-run
|
|
242
|
+
* decode + scattered threshold-evasion counting) is `scanText`'s — a local
|
|
243
|
+
* mirror used to re-count scatter from the raw STRIP match count, silently
|
|
244
|
+
* re-growing the linguistic-joiner/VS15 false positive `scanText`'s carve-out
|
|
245
|
+
* counter had already fixed.
|
|
233
246
|
* @param {string} filePath
|
|
234
|
-
* @returns {
|
|
247
|
+
* @returns {ReturnType<typeof import("agent-sanitizer/instructions").scanText>}
|
|
248
|
+
* `line` is 1-based, or `null` for the whole-file scattered-chars finding.
|
|
235
249
|
*/
|
|
236
250
|
function scanFile(filePath) {
|
|
237
|
-
|
|
238
|
-
const findings = [];
|
|
239
|
-
LONG_RUN_RE.lastIndex = 0;
|
|
240
|
-
let match;
|
|
241
|
-
let runChars = 0;
|
|
242
|
-
while ((match = LONG_RUN_RE.exec(content)) !== null) {
|
|
243
|
-
const lineNum = content.slice(0, match.index).split("\n").length;
|
|
244
|
-
const charCount = [...match[0]].length;
|
|
245
|
-
runChars += charCount;
|
|
246
|
-
findings.push({ line: lineNum, charCount, ...decodeRun(match[0]) });
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// Threshold-evasion: scattered invisible chars not in a long run can still be
|
|
250
|
-
// a payload. Always evaluated; chars already in a run are excluded so they
|
|
251
|
-
// aren't double-counted.
|
|
252
|
-
const allInvisible = content.match(STRIP);
|
|
253
|
-
const scattered = (allInvisible ? allInvisible.length : 0) - runChars;
|
|
254
|
-
if (scattered >= TOTAL_INVISIBLE_THRESHOLD) {
|
|
255
|
-
findings.push({
|
|
256
|
-
line: 0,
|
|
257
|
-
charCount: scattered,
|
|
258
|
-
method: "scattered invisible chars (possible threshold evasion)",
|
|
259
|
-
decoded: `[${scattered} invisible chars distributed across file]`,
|
|
260
|
-
});
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
return findings;
|
|
251
|
+
return scanText(readFileSync(filePath, "utf-8"));
|
|
264
252
|
}
|
|
265
253
|
|
|
266
254
|
export {
|
|
@@ -282,7 +270,7 @@ export {
|
|
|
282
270
|
/**
|
|
283
271
|
* @param {Array<{
|
|
284
272
|
* file: string,
|
|
285
|
-
* findings: Array<{ line: number, charCount: number, method: string, decoded: string }>,
|
|
273
|
+
* findings: Array<{ line: number | null, charCount: number, method: string, decoded: string }>,
|
|
286
274
|
* }>} allFindings
|
|
287
275
|
* @returns {string}
|
|
288
276
|
*/
|
|
@@ -304,8 +292,12 @@ function formatReport(allFindings) {
|
|
|
304
292
|
for (const { file, findings } of allFindings) {
|
|
305
293
|
lines.push(` ${file}:`);
|
|
306
294
|
for (const finding of findings) {
|
|
295
|
+
// `line` is null for the whole-file scattered-chars finding, which is
|
|
296
|
+
// not tied to any single line.
|
|
297
|
+
const where =
|
|
298
|
+
finding.line === null ? "Whole file" : `Line ${finding.line}`;
|
|
307
299
|
lines.push(
|
|
308
|
-
`
|
|
300
|
+
` ${where}: ${finding.charCount} invisible chars (${finding.method})`,
|
|
309
301
|
);
|
|
310
302
|
lines.push(` Decodes to: ${JSON.stringify(finding.decoded)}`);
|
|
311
303
|
}
|
|
@@ -548,20 +540,29 @@ function autoCleanFindings(allFindings, dir) {
|
|
|
548
540
|
for (const { file } of allFindings) {
|
|
549
541
|
const absPath = join(dir, file);
|
|
550
542
|
try {
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
543
|
+
// The SSOT clean: O_NOFOLLOW open, UTF-8 round-trip check, TOCTOU
|
|
544
|
+
// recheck, atomic rename + fsync, mode preservation. The bare
|
|
545
|
+
// readFileSync/writeFileSync pair that lived here had none of those
|
|
546
|
+
// guards — on the one hook that REWRITES instruction files.
|
|
547
|
+
//
|
|
548
|
+
// Counted ONLY on `true` (bytes changed), matching the old
|
|
549
|
+
// `stripped !== original` gate. `false` means cleanFile re-scanned and
|
|
550
|
+
// found nothing to strip in a file this run flagged — the file was
|
|
551
|
+
// changed under us, or the flagged run is one the stripper PRESERVES —
|
|
552
|
+
// so it is not a file we cleaned, and leaving `cleaned` short is what
|
|
553
|
+
// routes it to the alert below instead of an "all clean" report.
|
|
554
|
+
if (cleanFile(absPath)) cleaned++;
|
|
555
|
+
/* c8 ignore start -- only fires on a file cleanFile refuses (symlink,
|
|
556
|
+
non-UTF-8, concurrent write) or cannot rewrite, which the test run
|
|
557
|
+
does not create */
|
|
558
558
|
} catch (err) {
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
|
|
564
|
-
|
|
559
|
+
// The reason is REPORTED rather than swallowed: a refusal or an
|
|
560
|
+
// unwritable file legitimately falls through to the alert path below. A
|
|
561
|
+
// TypeError is the one throw that still propagates — an unbound lazy
|
|
562
|
+
// import, i.e. a bug in THIS hook, which must not be laundered into
|
|
563
|
+
// "this file resisted cleaning". (The scan phase has already exercised
|
|
564
|
+
// the same bindings, so this is a belt-and-braces rethrow.)
|
|
565
|
+
if (err instanceof TypeError) throw err;
|
|
565
566
|
process.stderr.write(
|
|
566
567
|
`scan-invisible-chars: could not clean ${file}: ${safeErrMessage(err)}\n`,
|
|
567
568
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.28.
|
|
3
|
+
"version": "2.28.2",
|
|
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": {
|
|
@@ -186,7 +186,9 @@
|
|
|
186
186
|
},
|
|
187
187
|
"files": [
|
|
188
188
|
"src/*.mjs",
|
|
189
|
+
"python/agent_sanitizer/data/invisible-charset.json",
|
|
189
190
|
"python/agent_sanitizer/secrets/data/credential-names.json",
|
|
191
|
+
"python/agent_sanitizer/secrets/data/redaction-floor.json",
|
|
190
192
|
"claude-hooks/*.mjs",
|
|
191
193
|
"claude-hooks/lib/*.mjs",
|
|
192
194
|
"claude-hooks/config/*.json",
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "SSOT for the payload-capable invisible code points, generated by scripts/gen-invisible-charset.mjs. `extra_codepoints` are the non-Cf extras (variation selectors, blank-rendering fillers, zero-width combining marks) from src/invisible.mjs (VS + BLANK_NON_CF). `cf_codepoints` is the general-category Cf set PINNED from Node's Unicode data at generation time (see `unicode_version`) — NOT resolved live per consumer, because Node and CPython ship different Unicode versions and a live-Cf split let a code point in the version delta escape one layer. The deletion set is the UNION of the two lists. `control_introducers` is the raw ANSI control-introducer set (ESC + the C1 block) from src/ansi.mjs, which Layer 1 sweeps and the Python textstrip port must sweep identically. Consumers in other languages read this file instead of forking the lists — a fork is a silent security regression.",
|
|
3
|
+
"unicode_version": "17.0",
|
|
4
|
+
"extra_codepoints": [
|
|
5
|
+
847,
|
|
6
|
+
4447,
|
|
7
|
+
4448,
|
|
8
|
+
6068,
|
|
9
|
+
6069,
|
|
10
|
+
6155,
|
|
11
|
+
6156,
|
|
12
|
+
6157,
|
|
13
|
+
6159,
|
|
14
|
+
10240,
|
|
15
|
+
12644,
|
|
16
|
+
65024,
|
|
17
|
+
65025,
|
|
18
|
+
65026,
|
|
19
|
+
65027,
|
|
20
|
+
65028,
|
|
21
|
+
65029,
|
|
22
|
+
65030,
|
|
23
|
+
65031,
|
|
24
|
+
65032,
|
|
25
|
+
65033,
|
|
26
|
+
65034,
|
|
27
|
+
65035,
|
|
28
|
+
65036,
|
|
29
|
+
65037,
|
|
30
|
+
65038,
|
|
31
|
+
65039,
|
|
32
|
+
65440,
|
|
33
|
+
917760,
|
|
34
|
+
917761,
|
|
35
|
+
917762,
|
|
36
|
+
917763,
|
|
37
|
+
917764,
|
|
38
|
+
917765,
|
|
39
|
+
917766,
|
|
40
|
+
917767,
|
|
41
|
+
917768,
|
|
42
|
+
917769,
|
|
43
|
+
917770,
|
|
44
|
+
917771,
|
|
45
|
+
917772,
|
|
46
|
+
917773,
|
|
47
|
+
917774,
|
|
48
|
+
917775,
|
|
49
|
+
917776,
|
|
50
|
+
917777,
|
|
51
|
+
917778,
|
|
52
|
+
917779,
|
|
53
|
+
917780,
|
|
54
|
+
917781,
|
|
55
|
+
917782,
|
|
56
|
+
917783,
|
|
57
|
+
917784,
|
|
58
|
+
917785,
|
|
59
|
+
917786,
|
|
60
|
+
917787,
|
|
61
|
+
917788,
|
|
62
|
+
917789,
|
|
63
|
+
917790,
|
|
64
|
+
917791,
|
|
65
|
+
917792,
|
|
66
|
+
917793,
|
|
67
|
+
917794,
|
|
68
|
+
917795,
|
|
69
|
+
917796,
|
|
70
|
+
917797,
|
|
71
|
+
917798,
|
|
72
|
+
917799,
|
|
73
|
+
917800,
|
|
74
|
+
917801,
|
|
75
|
+
917802,
|
|
76
|
+
917803,
|
|
77
|
+
917804,
|
|
78
|
+
917805,
|
|
79
|
+
917806,
|
|
80
|
+
917807,
|
|
81
|
+
917808,
|
|
82
|
+
917809,
|
|
83
|
+
917810,
|
|
84
|
+
917811,
|
|
85
|
+
917812,
|
|
86
|
+
917813,
|
|
87
|
+
917814,
|
|
88
|
+
917815,
|
|
89
|
+
917816,
|
|
90
|
+
917817,
|
|
91
|
+
917818,
|
|
92
|
+
917819,
|
|
93
|
+
917820,
|
|
94
|
+
917821,
|
|
95
|
+
917822,
|
|
96
|
+
917823,
|
|
97
|
+
917824,
|
|
98
|
+
917825,
|
|
99
|
+
917826,
|
|
100
|
+
917827,
|
|
101
|
+
917828,
|
|
102
|
+
917829,
|
|
103
|
+
917830,
|
|
104
|
+
917831,
|
|
105
|
+
917832,
|
|
106
|
+
917833,
|
|
107
|
+
917834,
|
|
108
|
+
917835,
|
|
109
|
+
917836,
|
|
110
|
+
917837,
|
|
111
|
+
917838,
|
|
112
|
+
917839,
|
|
113
|
+
917840,
|
|
114
|
+
917841,
|
|
115
|
+
917842,
|
|
116
|
+
917843,
|
|
117
|
+
917844,
|
|
118
|
+
917845,
|
|
119
|
+
917846,
|
|
120
|
+
917847,
|
|
121
|
+
917848,
|
|
122
|
+
917849,
|
|
123
|
+
917850,
|
|
124
|
+
917851,
|
|
125
|
+
917852,
|
|
126
|
+
917853,
|
|
127
|
+
917854,
|
|
128
|
+
917855,
|
|
129
|
+
917856,
|
|
130
|
+
917857,
|
|
131
|
+
917858,
|
|
132
|
+
917859,
|
|
133
|
+
917860,
|
|
134
|
+
917861,
|
|
135
|
+
917862,
|
|
136
|
+
917863,
|
|
137
|
+
917864,
|
|
138
|
+
917865,
|
|
139
|
+
917866,
|
|
140
|
+
917867,
|
|
141
|
+
917868,
|
|
142
|
+
917869,
|
|
143
|
+
917870,
|
|
144
|
+
917871,
|
|
145
|
+
917872,
|
|
146
|
+
917873,
|
|
147
|
+
917874,
|
|
148
|
+
917875,
|
|
149
|
+
917876,
|
|
150
|
+
917877,
|
|
151
|
+
917878,
|
|
152
|
+
917879,
|
|
153
|
+
917880,
|
|
154
|
+
917881,
|
|
155
|
+
917882,
|
|
156
|
+
917883,
|
|
157
|
+
917884,
|
|
158
|
+
917885,
|
|
159
|
+
917886,
|
|
160
|
+
917887,
|
|
161
|
+
917888,
|
|
162
|
+
917889,
|
|
163
|
+
917890,
|
|
164
|
+
917891,
|
|
165
|
+
917892,
|
|
166
|
+
917893,
|
|
167
|
+
917894,
|
|
168
|
+
917895,
|
|
169
|
+
917896,
|
|
170
|
+
917897,
|
|
171
|
+
917898,
|
|
172
|
+
917899,
|
|
173
|
+
917900,
|
|
174
|
+
917901,
|
|
175
|
+
917902,
|
|
176
|
+
917903,
|
|
177
|
+
917904,
|
|
178
|
+
917905,
|
|
179
|
+
917906,
|
|
180
|
+
917907,
|
|
181
|
+
917908,
|
|
182
|
+
917909,
|
|
183
|
+
917910,
|
|
184
|
+
917911,
|
|
185
|
+
917912,
|
|
186
|
+
917913,
|
|
187
|
+
917914,
|
|
188
|
+
917915,
|
|
189
|
+
917916,
|
|
190
|
+
917917,
|
|
191
|
+
917918,
|
|
192
|
+
917919,
|
|
193
|
+
917920,
|
|
194
|
+
917921,
|
|
195
|
+
917922,
|
|
196
|
+
917923,
|
|
197
|
+
917924,
|
|
198
|
+
917925,
|
|
199
|
+
917926,
|
|
200
|
+
917927,
|
|
201
|
+
917928,
|
|
202
|
+
917929,
|
|
203
|
+
917930,
|
|
204
|
+
917931,
|
|
205
|
+
917932,
|
|
206
|
+
917933,
|
|
207
|
+
917934,
|
|
208
|
+
917935,
|
|
209
|
+
917936,
|
|
210
|
+
917937,
|
|
211
|
+
917938,
|
|
212
|
+
917939,
|
|
213
|
+
917940,
|
|
214
|
+
917941,
|
|
215
|
+
917942,
|
|
216
|
+
917943,
|
|
217
|
+
917944,
|
|
218
|
+
917945,
|
|
219
|
+
917946,
|
|
220
|
+
917947,
|
|
221
|
+
917948,
|
|
222
|
+
917949,
|
|
223
|
+
917950,
|
|
224
|
+
917951,
|
|
225
|
+
917952,
|
|
226
|
+
917953,
|
|
227
|
+
917954,
|
|
228
|
+
917955,
|
|
229
|
+
917956,
|
|
230
|
+
917957,
|
|
231
|
+
917958,
|
|
232
|
+
917959,
|
|
233
|
+
917960,
|
|
234
|
+
917961,
|
|
235
|
+
917962,
|
|
236
|
+
917963,
|
|
237
|
+
917964,
|
|
238
|
+
917965,
|
|
239
|
+
917966,
|
|
240
|
+
917967,
|
|
241
|
+
917968,
|
|
242
|
+
917969,
|
|
243
|
+
917970,
|
|
244
|
+
917971,
|
|
245
|
+
917972,
|
|
246
|
+
917973,
|
|
247
|
+
917974,
|
|
248
|
+
917975,
|
|
249
|
+
917976,
|
|
250
|
+
917977,
|
|
251
|
+
917978,
|
|
252
|
+
917979,
|
|
253
|
+
917980,
|
|
254
|
+
917981,
|
|
255
|
+
917982,
|
|
256
|
+
917983,
|
|
257
|
+
917984,
|
|
258
|
+
917985,
|
|
259
|
+
917986,
|
|
260
|
+
917987,
|
|
261
|
+
917988,
|
|
262
|
+
917989,
|
|
263
|
+
917990,
|
|
264
|
+
917991,
|
|
265
|
+
917992,
|
|
266
|
+
917993,
|
|
267
|
+
917994,
|
|
268
|
+
917995,
|
|
269
|
+
917996,
|
|
270
|
+
917997,
|
|
271
|
+
917998,
|
|
272
|
+
917999
|
|
273
|
+
],
|
|
274
|
+
"cf_codepoints": [
|
|
275
|
+
173,
|
|
276
|
+
1536,
|
|
277
|
+
1537,
|
|
278
|
+
1538,
|
|
279
|
+
1539,
|
|
280
|
+
1540,
|
|
281
|
+
1541,
|
|
282
|
+
1564,
|
|
283
|
+
1757,
|
|
284
|
+
1807,
|
|
285
|
+
2192,
|
|
286
|
+
2193,
|
|
287
|
+
2274,
|
|
288
|
+
6158,
|
|
289
|
+
8203,
|
|
290
|
+
8204,
|
|
291
|
+
8205,
|
|
292
|
+
8206,
|
|
293
|
+
8207,
|
|
294
|
+
8234,
|
|
295
|
+
8235,
|
|
296
|
+
8236,
|
|
297
|
+
8237,
|
|
298
|
+
8238,
|
|
299
|
+
8288,
|
|
300
|
+
8289,
|
|
301
|
+
8290,
|
|
302
|
+
8291,
|
|
303
|
+
8292,
|
|
304
|
+
8294,
|
|
305
|
+
8295,
|
|
306
|
+
8296,
|
|
307
|
+
8297,
|
|
308
|
+
8298,
|
|
309
|
+
8299,
|
|
310
|
+
8300,
|
|
311
|
+
8301,
|
|
312
|
+
8302,
|
|
313
|
+
8303,
|
|
314
|
+
65279,
|
|
315
|
+
65529,
|
|
316
|
+
65530,
|
|
317
|
+
65531,
|
|
318
|
+
69821,
|
|
319
|
+
69837,
|
|
320
|
+
78896,
|
|
321
|
+
78897,
|
|
322
|
+
78898,
|
|
323
|
+
78899,
|
|
324
|
+
78900,
|
|
325
|
+
78901,
|
|
326
|
+
78902,
|
|
327
|
+
78903,
|
|
328
|
+
78904,
|
|
329
|
+
78905,
|
|
330
|
+
78906,
|
|
331
|
+
78907,
|
|
332
|
+
78908,
|
|
333
|
+
78909,
|
|
334
|
+
78910,
|
|
335
|
+
78911,
|
|
336
|
+
113824,
|
|
337
|
+
113825,
|
|
338
|
+
113826,
|
|
339
|
+
113827,
|
|
340
|
+
119155,
|
|
341
|
+
119156,
|
|
342
|
+
119157,
|
|
343
|
+
119158,
|
|
344
|
+
119159,
|
|
345
|
+
119160,
|
|
346
|
+
119161,
|
|
347
|
+
119162,
|
|
348
|
+
917505,
|
|
349
|
+
917536,
|
|
350
|
+
917537,
|
|
351
|
+
917538,
|
|
352
|
+
917539,
|
|
353
|
+
917540,
|
|
354
|
+
917541,
|
|
355
|
+
917542,
|
|
356
|
+
917543,
|
|
357
|
+
917544,
|
|
358
|
+
917545,
|
|
359
|
+
917546,
|
|
360
|
+
917547,
|
|
361
|
+
917548,
|
|
362
|
+
917549,
|
|
363
|
+
917550,
|
|
364
|
+
917551,
|
|
365
|
+
917552,
|
|
366
|
+
917553,
|
|
367
|
+
917554,
|
|
368
|
+
917555,
|
|
369
|
+
917556,
|
|
370
|
+
917557,
|
|
371
|
+
917558,
|
|
372
|
+
917559,
|
|
373
|
+
917560,
|
|
374
|
+
917561,
|
|
375
|
+
917562,
|
|
376
|
+
917563,
|
|
377
|
+
917564,
|
|
378
|
+
917565,
|
|
379
|
+
917566,
|
|
380
|
+
917567,
|
|
381
|
+
917568,
|
|
382
|
+
917569,
|
|
383
|
+
917570,
|
|
384
|
+
917571,
|
|
385
|
+
917572,
|
|
386
|
+
917573,
|
|
387
|
+
917574,
|
|
388
|
+
917575,
|
|
389
|
+
917576,
|
|
390
|
+
917577,
|
|
391
|
+
917578,
|
|
392
|
+
917579,
|
|
393
|
+
917580,
|
|
394
|
+
917581,
|
|
395
|
+
917582,
|
|
396
|
+
917583,
|
|
397
|
+
917584,
|
|
398
|
+
917585,
|
|
399
|
+
917586,
|
|
400
|
+
917587,
|
|
401
|
+
917588,
|
|
402
|
+
917589,
|
|
403
|
+
917590,
|
|
404
|
+
917591,
|
|
405
|
+
917592,
|
|
406
|
+
917593,
|
|
407
|
+
917594,
|
|
408
|
+
917595,
|
|
409
|
+
917596,
|
|
410
|
+
917597,
|
|
411
|
+
917598,
|
|
412
|
+
917599,
|
|
413
|
+
917600,
|
|
414
|
+
917601,
|
|
415
|
+
917602,
|
|
416
|
+
917603,
|
|
417
|
+
917604,
|
|
418
|
+
917605,
|
|
419
|
+
917606,
|
|
420
|
+
917607,
|
|
421
|
+
917608,
|
|
422
|
+
917609,
|
|
423
|
+
917610,
|
|
424
|
+
917611,
|
|
425
|
+
917612,
|
|
426
|
+
917613,
|
|
427
|
+
917614,
|
|
428
|
+
917615,
|
|
429
|
+
917616,
|
|
430
|
+
917617,
|
|
431
|
+
917618,
|
|
432
|
+
917619,
|
|
433
|
+
917620,
|
|
434
|
+
917621,
|
|
435
|
+
917622,
|
|
436
|
+
917623,
|
|
437
|
+
917624,
|
|
438
|
+
917625,
|
|
439
|
+
917626,
|
|
440
|
+
917627,
|
|
441
|
+
917628,
|
|
442
|
+
917629,
|
|
443
|
+
917630,
|
|
444
|
+
917631
|
|
445
|
+
],
|
|
446
|
+
"control_introducers": [
|
|
447
|
+
27,
|
|
448
|
+
128,
|
|
449
|
+
129,
|
|
450
|
+
130,
|
|
451
|
+
131,
|
|
452
|
+
132,
|
|
453
|
+
133,
|
|
454
|
+
134,
|
|
455
|
+
135,
|
|
456
|
+
136,
|
|
457
|
+
137,
|
|
458
|
+
138,
|
|
459
|
+
139,
|
|
460
|
+
140,
|
|
461
|
+
141,
|
|
462
|
+
142,
|
|
463
|
+
143,
|
|
464
|
+
144,
|
|
465
|
+
145,
|
|
466
|
+
146,
|
|
467
|
+
147,
|
|
468
|
+
148,
|
|
469
|
+
149,
|
|
470
|
+
150,
|
|
471
|
+
151,
|
|
472
|
+
152,
|
|
473
|
+
153,
|
|
474
|
+
154,
|
|
475
|
+
155,
|
|
476
|
+
156,
|
|
477
|
+
157,
|
|
478
|
+
158,
|
|
479
|
+
159
|
|
480
|
+
]
|
|
481
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Floor below which a configured env value is treated as a doc stub / placeholder rather than a real key, so a var set to a short test value ('fake', 'sk-test') never blanks unrelated output. ONE physical file backs both ecosystems, exactly like credential-names.json beside it: agent_sanitizer.secrets.config reads DEFAULT_MIN_SECRET_LEN from here, and claude-hooks/lib/env-config.mjs imports it for the Layer-4 pre-gate — there is no second copy to drift.",
|
|
3
|
+
"min_secret_len": 16
|
|
4
|
+
}
|
package/src/ansi.mjs
CHANGED
|
@@ -25,13 +25,27 @@
|
|
|
25
25
|
// DCS/SOS/PM/APC string the grammar does not consume still loses its introducer
|
|
26
26
|
// and terminator, so no terminal can hide-render its body as a control payload.
|
|
27
27
|
//
|
|
28
|
+
// The introducer set as DATA, so a non-JS consumer can share it: the generator
|
|
29
|
+
// (scripts/gen-invisible-charset.mjs) pins these code points into
|
|
30
|
+
// data/invisible-charset.json's `control_introducers`, which the Python port
|
|
31
|
+
// (python/agent_sanitizer/textstrip.py) sweeps. Before that, the Python side
|
|
32
|
+
// hand-wrote `\x1b` alone and the whole C1 block survived its strip — the exact
|
|
33
|
+
// fork this module's header says must not recur, one language over.
|
|
34
|
+
export const CONTROL_INTRODUCER_CODEPOINTS = Object.freeze([
|
|
35
|
+
0x1b,
|
|
36
|
+
...Array.from({ length: 0x9f - 0x80 + 1 }, (_, i) => 0x80 + i),
|
|
37
|
+
]);
|
|
38
|
+
|
|
28
39
|
// A SOURCE STRING, not a literal: three call sites need it with different flags
|
|
29
40
|
// (`g` for the Layer-1 sweep, unflagged for the prompt gate, `g` again to drive
|
|
30
41
|
// the scan below), and spelling the class out at each site is how the three
|
|
31
42
|
// copies came to spell the same byte two different ways — which defeats a
|
|
32
|
-
// grep-based drift check as well.
|
|
33
|
-
//
|
|
34
|
-
|
|
43
|
+
// grep-based drift check as well. Derived from the code-point list above so the
|
|
44
|
+
// regex and the exported data cannot disagree; `\uXXXX` escapes keep every raw
|
|
45
|
+
// control byte out of the source (no `no-control-regex` disable needed).
|
|
46
|
+
export const CONTROL_INTRODUCER_SOURCE = `[${CONTROL_INTRODUCER_CODEPOINTS.map(
|
|
47
|
+
(cp) => `\\u${cp.toString(16).padStart(4, "0")}`,
|
|
48
|
+
).join("")}]`;
|
|
35
49
|
|
|
36
50
|
// SGR (Select Graphic Rendition): colors, bold, reset. The grammar is closed:
|
|
37
51
|
// params are [0-9;:]* and the final byte is `m`, so a match can only restyle
|
package/src/instructions.mjs
CHANGED
|
@@ -450,11 +450,15 @@ export function atomicReplaceFile(
|
|
|
450
450
|
/**
|
|
451
451
|
* Strip payload-capable invisible characters from `absPath` in place. Returns
|
|
452
452
|
* `true` when the file's bytes actually changed (a payload {@link scanText}
|
|
453
|
-
* flags was removed)
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
453
|
+
* flags was removed) and `false` when {@link scanText} reports nothing to
|
|
454
|
+
* strip. `true` means and only means "bytes changed", so a caller that flagged
|
|
455
|
+
* this file and gets `false` back must NOT record it as cleaned — the file
|
|
456
|
+
* changed under it, or the flagged run is one {@link stripInvisible}
|
|
457
|
+
* preserves (a well-formed emoji-tag sequence), and either way the payload it
|
|
458
|
+
* flagged is still there. There is no third return value: the `null` arm this
|
|
459
|
+
* doc once described was dropped as dead (`stripInvisible` cannot leave the
|
|
460
|
+
* bytes identical for anything `scanText` flags), and callers must branch on
|
|
461
|
+
* the boolean rather than testing against `null`, which is vacuously true.
|
|
458
462
|
*
|
|
459
463
|
* Contract (scan/clean coherence): clean strips exactly what scan flags. A
|
|
460
464
|
* write happens ONLY when `scanText` reports a finding, so the "scan, then
|
package/types/ansi.d.mts
CHANGED
|
@@ -55,7 +55,8 @@ export function scanAnsi(text: string): AnsiToken[];
|
|
|
55
55
|
* Same precedent (and same reason) as `cf-charset.mjs`: a dependency-free leaf
|
|
56
56
|
* module both layers read from.
|
|
57
57
|
*/
|
|
58
|
-
export const
|
|
58
|
+
export const CONTROL_INTRODUCER_CODEPOINTS: readonly number[];
|
|
59
|
+
export const CONTROL_INTRODUCER_SOURCE: string;
|
|
59
60
|
/**
|
|
60
61
|
* Public alias kept for compatibility (re-exported by `invisible.mjs` and the
|
|
61
62
|
* package root). It is now DERIVED: {@link scanAnsi} classifies a token as SGR
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Regex matching `value` tolerating invisible chars spliced between its
|
|
3
3
|
* characters (mirrors the engine's env-value regex). Code-point split so
|
|
4
|
-
* an astral character is escaped whole, not as two surrogate halves
|
|
4
|
+
* an astral character is escaped whole, not as two surrogate halves — and the
|
|
5
|
+
* `u` flag, which the astral `\u{…}` class members in {@link ENV_INVIS_RUN}
|
|
6
|
+
* require.
|
|
7
|
+
* Memoized per distinct value: {@link ENV_INVIS_RUN} renders ~435 code points
|
|
8
|
+
* as ~4 KB of source and is joined at EVERY interior gap, so a 20-char secret
|
|
9
|
+
* compiles a ~75 KB pattern — and `hasEnvBoundSecret` builds one per configured
|
|
10
|
+
* var on every PostToolUse output. Env values are stable for the process's
|
|
11
|
+
* lifetime, so the cache is bounded by the number of distinct values. Sharing an
|
|
12
|
+
* instance is safe because the regex carries no `g`/`y` flag, hence no
|
|
13
|
+
* `lastIndex` state to leak between calls.
|
|
5
14
|
* @param {string} value
|
|
6
15
|
* @returns {RegExp}
|
|
7
16
|
*/
|
|
@@ -72,18 +72,25 @@ export function cliMain(opts?: {
|
|
|
72
72
|
scan?: () => ReturnType<typeof scanProject>;
|
|
73
73
|
}): Promise<void>;
|
|
74
74
|
/**
|
|
75
|
+
* Read one file and run the SSOT scan over it. The scan logic itself (long-run
|
|
76
|
+
* decode + scattered threshold-evasion counting) is `scanText`'s — a local
|
|
77
|
+
* mirror used to re-count scatter from the raw STRIP match count, silently
|
|
78
|
+
* re-growing the linguistic-joiner/VS15 false positive `scanText`'s carve-out
|
|
79
|
+
* counter had already fixed.
|
|
75
80
|
* @param {string} filePath
|
|
76
|
-
* @returns {
|
|
81
|
+
* @returns {ReturnType<typeof import("agent-sanitizer/instructions").scanText>}
|
|
82
|
+
* `line` is 1-based, or `null` for the whole-file scattered-chars finding.
|
|
77
83
|
*/
|
|
78
|
-
export function scanFile(filePath: string):
|
|
79
|
-
line: number;
|
|
80
|
-
charCount: number;
|
|
81
|
-
method: string;
|
|
82
|
-
decoded: string;
|
|
83
|
-
}>;
|
|
84
|
+
export function scanFile(filePath: string): ReturnType<typeof import("agent-sanitizer/instructions").scanText>;
|
|
84
85
|
import { CLAUDE_CONTEXT_SUBDIRS } from "../src/claude-context.mjs";
|
|
85
86
|
import { CLAUDE_INSTRUCTION_GLOBS } from "../src/claude-context.mjs";
|
|
86
87
|
/**
|
|
88
|
+
* The SSOT decoder, re-exported through a lazy-bound wrapper (the binding is
|
|
89
|
+
* `let` and may be re-bound by the cold-start reload, so the export must read
|
|
90
|
+
* it at call time). A hand-written twin used to live here; it decoded tag
|
|
91
|
+
* characters to RAW bytes — including actual C0 controls for U+E0001–U+E001F —
|
|
92
|
+
* with no `untrusted data, not instructions:` framing or escaping, so the
|
|
93
|
+
* hook's own report re-injected the hidden payload it had just caught.
|
|
87
94
|
* @param {string} run
|
|
88
95
|
* @returns {{ method: string, decoded: string }}
|
|
89
96
|
*/
|
|
@@ -116,14 +123,14 @@ export let TOTAL_INVISIBLE_THRESHOLD: 30;
|
|
|
116
123
|
/**
|
|
117
124
|
* @param {Array<{
|
|
118
125
|
* file: string,
|
|
119
|
-
* findings: Array<{ line: number, charCount: number, method: string, decoded: string }>,
|
|
126
|
+
* findings: Array<{ line: number | null, charCount: number, method: string, decoded: string }>,
|
|
120
127
|
* }>} allFindings
|
|
121
128
|
* @returns {string}
|
|
122
129
|
*/
|
|
123
130
|
export function formatReport(allFindings: Array<{
|
|
124
131
|
file: string;
|
|
125
132
|
findings: Array<{
|
|
126
|
-
line: number;
|
|
133
|
+
line: number | null;
|
|
127
134
|
charCount: number;
|
|
128
135
|
method: string;
|
|
129
136
|
decoded: string;
|
package/types/instructions.d.mts
CHANGED
|
@@ -100,11 +100,15 @@ export function atomicReplaceFile(absPath: string, data: string, mode: number, t
|
|
|
100
100
|
/**
|
|
101
101
|
* Strip payload-capable invisible characters from `absPath` in place. Returns
|
|
102
102
|
* `true` when the file's bytes actually changed (a payload {@link scanText}
|
|
103
|
-
* flags was removed)
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
103
|
+
* flags was removed) and `false` when {@link scanText} reports nothing to
|
|
104
|
+
* strip. `true` means and only means "bytes changed", so a caller that flagged
|
|
105
|
+
* this file and gets `false` back must NOT record it as cleaned — the file
|
|
106
|
+
* changed under it, or the flagged run is one {@link stripInvisible}
|
|
107
|
+
* preserves (a well-formed emoji-tag sequence), and either way the payload it
|
|
108
|
+
* flagged is still there. There is no third return value: the `null` arm this
|
|
109
|
+
* doc once described was dropped as dead (`stripInvisible` cannot leave the
|
|
110
|
+
* bytes identical for anything `scanText` flags), and callers must branch on
|
|
111
|
+
* the boolean rather than testing against `null`, which is vacuously true.
|
|
108
112
|
*
|
|
109
113
|
* Contract (scan/clean coherence): clean strips exactly what scan flags. A
|
|
110
114
|
* write happens ONLY when `scanText` reports a finding, so the "scan, then
|