agent-sanitizer 2.2.2 → 2.4.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 +107 -0
- package/claude-hooks/config/credential-var-names.json +23 -0
- package/claude-hooks/config/inference-key-vars.json +17 -0
- package/claude-hooks/config/scrubbed-env-vars.json +15 -0
- package/claude-hooks/lib/authored-content.mjs +166 -0
- package/claude-hooks/lib/control-plane.mjs +138 -0
- package/claude-hooks/lib/env-config.mjs +140 -0
- package/claude-hooks/lib/hook-io.mjs +366 -0
- package/claude-hooks/lib/invisible-alert.mjs +115 -0
- package/claude-hooks/lib/redactor-client.mjs +514 -0
- package/claude-hooks/lib/reveal.mjs +135 -0
- package/claude-hooks/lib/secret-annotate.mjs +56 -0
- package/claude-hooks/lib/trace.mjs +60 -0
- package/claude-hooks/plugin-hooks.mjs +133 -0
- package/claude-hooks/pretooluse-sanitize.mjs +341 -0
- package/claude-hooks/sanitize-output.mjs +656 -0
- package/claude-hooks/sanitize-user-prompt.mjs +164 -0
- package/claude-hooks/scan-invisible-chars.mjs +313 -0
- package/package.json +11 -4
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UserPromptSubmit: gate user prompts on payload-capable invisible Unicode
|
|
3
|
+
* and ANSI escapes. A prompt pasted from a tampered web page can carry tag
|
|
4
|
+
* characters or zero-width sequences that the LLM reads but the user cannot
|
|
5
|
+
* see. The PostToolUse sanitizer never runs on user input, so this is the
|
|
6
|
+
* only line of defense.
|
|
7
|
+
*
|
|
8
|
+
* UserPromptSubmit cannot rewrite the prompt — `additionalContext` is added
|
|
9
|
+
* alongside the original, not in place of it — so the only way to neutralize
|
|
10
|
+
* a payload is to block. Thresholds match scan-invisible-chars (SessionStart)
|
|
11
|
+
* for UX consistency.
|
|
12
|
+
*
|
|
13
|
+
* One carve-out: a prompt whose only escape content is SGR color/style codes
|
|
14
|
+
* (ESC [ params m) passes with a note instead of blocking. Pasting colored
|
|
15
|
+
* terminal output (test runs, build logs) is the single most common debugging
|
|
16
|
+
* action, and SGR is display-only by the ECMA-48 grammar — it cannot move the
|
|
17
|
+
* cursor, erase the screen, or carry an OSC payload. Anything beyond SGR
|
|
18
|
+
* (cursor movement, erase, OSC title-set, DCS/APC/PM) still blocks, as do the
|
|
19
|
+
* invisible-char thresholds, which are the actual web-paste payload defense.
|
|
20
|
+
*/
|
|
21
|
+
import { readStdinJson, safeErrMessage, isMain } from "./lib/hook-io.mjs";
|
|
22
|
+
import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
|
|
23
|
+
import { trace, TraceEvent } from "./lib/trace.mjs";
|
|
24
|
+
// classifyPrompt (the user-prompt verdict) and stripAnsiFully (its ANSI stripper)
|
|
25
|
+
// come from the agent-sanitizer package. They are bound by a *caught* dynamic
|
|
26
|
+
// import, never a bare top-level `import … from "…"`: a static npm import
|
|
27
|
+
// resolves before any try/catch, so a missing node_modules would crash this hook
|
|
28
|
+
// at load and let the prompt through UNSANITIZED (fail-open). A failed load
|
|
29
|
+
// leaves the bindings undefined, which main()'s typeof guard turns into a
|
|
30
|
+
// fail-closed block.
|
|
31
|
+
/** @type {typeof import("agent-sanitizer/prompt").classifyPrompt} */
|
|
32
|
+
export let classifyPrompt;
|
|
33
|
+
/** @type {typeof import("agent-sanitizer").stripAnsiFully} */
|
|
34
|
+
let stripAnsiFully;
|
|
35
|
+
|
|
36
|
+
const BLOCK_CONTEXT =
|
|
37
|
+
"User prompt blocked: payload-capable invisible/ANSI characters detected.";
|
|
38
|
+
const SGR_NOTE =
|
|
39
|
+
"The prompt contains ANSI SGR color codes (pasted terminal output). They are display-only formatting noise; read through them.";
|
|
40
|
+
|
|
41
|
+
/* c8 ignore start — module-load boundary: the imports resolve in every real
|
|
42
|
+
* run, and their failure (the package absent) can't be simulated in-process, so
|
|
43
|
+
* neither arm is observable to the in-process tests. main()'s typeof guard
|
|
44
|
+
* converts an undefined stripper into a fail-closed block — that guard IS tested. */
|
|
45
|
+
// Stryker disable all
|
|
46
|
+
try {
|
|
47
|
+
// The /prompt subpath is imported first: if it fails, the catch fires before
|
|
48
|
+
// stripAnsiFully is assigned, so a half-load can never leave the stripper set
|
|
49
|
+
// while the classifier is missing (main guards on the stripper alone).
|
|
50
|
+
({ classifyPrompt } = await import("agent-sanitizer/prompt"));
|
|
51
|
+
({ stripAnsiFully } = await import("agent-sanitizer"));
|
|
52
|
+
} catch {
|
|
53
|
+
// Leave classifyPrompt/stripAnsiFully undefined so main()'s typeof guard fails
|
|
54
|
+
// closed — the prompt is blocked, never passed through with the package
|
|
55
|
+
// half-loaded.
|
|
56
|
+
}
|
|
57
|
+
// Stryker restore all
|
|
58
|
+
/* c8 ignore stop */
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Judge a normalized prompt-submit event. Agent-agnostic: consumes the
|
|
62
|
+
* control-plane ToolCallEvent and returns a Verdict, so the same prompt gate
|
|
63
|
+
* renders through any agent adapter, not just Claude's. Throws (into the
|
|
64
|
+
* calling hook's catch) when the sanitizer package never loaded — this hook is
|
|
65
|
+
* the only defense on user input, so a prompt it cannot classify must block,
|
|
66
|
+
* never pass through.
|
|
67
|
+
* @param {import("agent-control-plane-core").ToolCallEvent} event
|
|
68
|
+
* @param {((s: string) => string) | null} [strip] the ANSI stripper (defaults
|
|
69
|
+
* to the package's stripAnsiFully; injectable so the fail-closed path is testable)
|
|
70
|
+
* @returns {import("agent-control-plane-core").Verdict}
|
|
71
|
+
*/
|
|
72
|
+
export function judgeSanitizeUserPrompt(event, strip = stripAnsiFully) {
|
|
73
|
+
const { Decision, EventKind } = controlPlane();
|
|
74
|
+
// A payload the adapter cannot classify carries no readable prompt, so an
|
|
75
|
+
// abstain would fail OPEN on harness contract drift; this gate's posture is
|
|
76
|
+
// deny-when-blind. (Renders through the adapter's top-level decision:"block"
|
|
77
|
+
// channel — a non-PRE_TOOL event has no permissionDecision body — which Claude
|
|
78
|
+
// honors on UserPromptSubmit.)
|
|
79
|
+
if (event.event === EventKind.UNKNOWN)
|
|
80
|
+
return {
|
|
81
|
+
decision: Decision.DENY,
|
|
82
|
+
reason: "User prompt blocked (fail-closed): unrecognized hook payload.",
|
|
83
|
+
};
|
|
84
|
+
if (event.event !== EventKind.PROMPT_SUBMIT)
|
|
85
|
+
return { decision: Decision.ALLOW };
|
|
86
|
+
// The module-load guard: a missing stripper means agent-sanitizer never
|
|
87
|
+
// loaded. Guarding on the stripper alone is sufficient — it loads AFTER
|
|
88
|
+
// classifyPrompt in the same try, so a present stripper proves the classifier
|
|
89
|
+
// loaded too.
|
|
90
|
+
if (typeof strip !== "function")
|
|
91
|
+
throw new Error("agent-sanitizer is unavailable");
|
|
92
|
+
// The contract guarantees a string here: every adapter normalizes the
|
|
93
|
+
// prompt-submit input (Claude's parse coerces a missing/non-string prompt to
|
|
94
|
+
// "" via asString), so a defensive typeof re-check is a dead branch.
|
|
95
|
+
const prompt = /** @type {string} */ (event.input.prompt);
|
|
96
|
+
if (!prompt) return { decision: Decision.ALLOW };
|
|
97
|
+
const verdict = classifyPrompt(prompt, strip);
|
|
98
|
+
if (verdict.action === "pass") return { decision: Decision.ALLOW };
|
|
99
|
+
if (verdict.action === "note")
|
|
100
|
+
return { decision: Decision.ALLOW, additional_context: SGR_NOTE };
|
|
101
|
+
// block: carry the reason AND a context note — UserPromptSubmit can't rewrite
|
|
102
|
+
// the prompt, so the context is the only forward signal about why it dropped.
|
|
103
|
+
return {
|
|
104
|
+
decision: Decision.DENY,
|
|
105
|
+
reason: verdict.reason,
|
|
106
|
+
additional_context: BLOCK_CONTEXT,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @param {() => Promise<any> | any} read
|
|
112
|
+
* @param {(chunk: string) => void} write
|
|
113
|
+
* @param {((s: string) => string) | null} [strip] the ANSI stripper (defaults
|
|
114
|
+
* to the package's stripAnsiFully; injectable so the fail-closed path is testable)
|
|
115
|
+
* @returns {Promise<void>}
|
|
116
|
+
*/
|
|
117
|
+
export async function main(read, write, strip = stripAnsiFully) {
|
|
118
|
+
// Delegate the parse → judge → render → write contract to the shared
|
|
119
|
+
// runJudgeCli so this hook doesn't re-implement the control-plane boundary:
|
|
120
|
+
// runJudgeCli reads stdin BEFORE loading the control-plane package, so a
|
|
121
|
+
// load failure fails to this hook's posture (the onError block) instead of
|
|
122
|
+
// leaving stdin unread. The fail-closed onError writes the UserPromptSubmit
|
|
123
|
+
// `decision:"block"` envelope by hand because the adapter that would render it
|
|
124
|
+
// is exactly what may have failed to load.
|
|
125
|
+
await runJudgeCli(
|
|
126
|
+
"sanitize-user-prompt",
|
|
127
|
+
(event) => {
|
|
128
|
+
const verdict = judgeSanitizeUserPrompt(event, strip);
|
|
129
|
+
// Announce engagement on the trace channel like the other stdin hooks —
|
|
130
|
+
// a prompt gate that silently stopped running is otherwise invisible.
|
|
131
|
+
trace(TraceEvent.HOOK_RAN, {
|
|
132
|
+
hook: "sanitize-user-prompt",
|
|
133
|
+
outcome:
|
|
134
|
+
verdict.decision === controlPlane().Decision.DENY
|
|
135
|
+
? "deny"
|
|
136
|
+
: verdict.additional_context
|
|
137
|
+
? "note"
|
|
138
|
+
: "allow",
|
|
139
|
+
});
|
|
140
|
+
return verdict;
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
readInput: read,
|
|
144
|
+
write,
|
|
145
|
+
onError: (err) =>
|
|
146
|
+
write(
|
|
147
|
+
JSON.stringify({
|
|
148
|
+
decision: "block",
|
|
149
|
+
reason: `sanitize-user-prompt hook failed (fail-closed): ${safeErrMessage(err)}`,
|
|
150
|
+
}),
|
|
151
|
+
),
|
|
152
|
+
},
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* c8 ignore start — CLI entry runs only in the spawned subprocess; main/render/
|
|
157
|
+
* classifyPrompt are mutation-tested via the in-process tests that call them. */
|
|
158
|
+
// Stryker disable all: same subprocess-only boundary as the c8 ignore — the
|
|
159
|
+
// direct-run guard can't be observed in-process.
|
|
160
|
+
if (isMain(import.meta.url)) {
|
|
161
|
+
void main(readStdinJson, (chunk) => process.stdout.write(chunk));
|
|
162
|
+
}
|
|
163
|
+
/* c8 ignore stop */
|
|
164
|
+
// Stryker restore all
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionStart: scan CLAUDE.md and .claude/ markdown for runs of invisible
|
|
3
|
+
* Unicode that may encode hidden instructions. Pasted markdown can embed
|
|
4
|
+
* invisible sequences (tag chars, zero-width encodings) that hijack the model's
|
|
5
|
+
* behavior — invisible in an editor but read by the LLM. These files load as
|
|
6
|
+
* project instructions at session start, bypassing the PostToolUse sanitizer.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync, globSync, writeFileSync, unlinkSync } from "node:fs";
|
|
9
|
+
import { join, relative } from "node:path";
|
|
10
|
+
import { isMain, lazyImport, writeFileNoFollow } from "./lib/hook-io.mjs";
|
|
11
|
+
import {
|
|
12
|
+
ALERT_FILE,
|
|
13
|
+
ALERT_ACK_FILE,
|
|
14
|
+
PROJECT_DIR,
|
|
15
|
+
} from "./lib/invisible-alert.mjs";
|
|
16
|
+
import { trace, TraceEvent } from "./lib/trace.mjs";
|
|
17
|
+
|
|
18
|
+
// Layer-1 primitives, bound via lazyImport (see its doc for the fail-OPEN
|
|
19
|
+
// hazard of a bare static npm import — here the instruction files would load
|
|
20
|
+
// UNSCANNED). A failed load leaves the bindings undefined, and cliMain's guard
|
|
21
|
+
// below fails loud rather than silently passing.
|
|
22
|
+
const {
|
|
23
|
+
LONG_RUN_RE,
|
|
24
|
+
LONG_RUN_THRESHOLD,
|
|
25
|
+
SCATTERED_THRESHOLD: TOTAL_INVISIBLE_THRESHOLD,
|
|
26
|
+
STRIP,
|
|
27
|
+
stripInvisible,
|
|
28
|
+
} = /** @type {typeof import("agent-sanitizer/invisible")} */ (
|
|
29
|
+
await lazyImport("agent-sanitizer/invisible")
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
// Decoder
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} run
|
|
36
|
+
* @returns {{ method: string, decoded: string }}
|
|
37
|
+
*/
|
|
38
|
+
function decodeRun(run) {
|
|
39
|
+
const cps = [...run].map((ch) => /** @type {number} */ (ch.codePointAt(0)));
|
|
40
|
+
|
|
41
|
+
// Tag characters U+E0001-U+E007F map directly to ASCII
|
|
42
|
+
const tagAscii = cps
|
|
43
|
+
.filter((cp) => cp >= 0xe0001 && cp <= 0xe007f)
|
|
44
|
+
// 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.
|
|
45
|
+
.map((cp) => String.fromCharCode(cp - 0xe0000))
|
|
46
|
+
.join("");
|
|
47
|
+
|
|
48
|
+
if (tagAscii.length > 0) {
|
|
49
|
+
return { method: "Unicode tag characters → ASCII", decoded: tagAscii };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Zero-width binary encoding: ZWSP=0, ZWNJ=1, ZWJ=group separator.
|
|
53
|
+
const ZW_BIT = new Map([
|
|
54
|
+
[0x200b, "0"],
|
|
55
|
+
[0x200c, "1"],
|
|
56
|
+
[0x200d, "|"],
|
|
57
|
+
]);
|
|
58
|
+
if (cps.every((cp) => ZW_BIT.has(cp))) {
|
|
59
|
+
const bits = cps.map((cp) => ZW_BIT.get(cp)).join("");
|
|
60
|
+
return {
|
|
61
|
+
method: "zero-width binary encoding",
|
|
62
|
+
decoded: `[${cps.length} zero-width chars: ${bits.slice(0, 80)}]`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Mixed/unknown
|
|
67
|
+
return {
|
|
68
|
+
method: "invisible Unicode sequence",
|
|
69
|
+
decoded: cps
|
|
70
|
+
.map((cp) => `U+${cp.toString(16).toUpperCase().padStart(4, "0")}`)
|
|
71
|
+
.join(" "),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {string} dir
|
|
77
|
+
* @returns {string[]}
|
|
78
|
+
*/
|
|
79
|
+
function findMdFiles(dir) {
|
|
80
|
+
return globSync("**/*.md", {
|
|
81
|
+
cwd: dir,
|
|
82
|
+
exclude: (name) => name === "node_modules",
|
|
83
|
+
}).map((name) => join(dir, name));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Every subdirectory instruction file (CLAUDE.md, CLAUDE.local.md, AGENTS.md)
|
|
88
|
+
* under `dir`. Claude Code loads these as project instructions on entry to their
|
|
89
|
+
* containing directory — a load path that bypasses the PostToolUse sanitizer — so
|
|
90
|
+
* a payload planted in e.g. `packages/foo/CLAUDE.md` reaches the model uncleaned
|
|
91
|
+
* unless it is scanned here. Skips node_modules; `**` skips dot directories by
|
|
92
|
+
* default (`.git`, and `.claude`, which the caller scans separately).
|
|
93
|
+
* @param {string} dir
|
|
94
|
+
* @returns {string[]}
|
|
95
|
+
*/
|
|
96
|
+
function findInstructionFiles(dir) {
|
|
97
|
+
return globSync(["**/CLAUDE.md", "**/CLAUDE.local.md", "**/AGENTS.md"], {
|
|
98
|
+
cwd: dir,
|
|
99
|
+
exclude: (name) => name === "node_modules",
|
|
100
|
+
}).map((name) => join(dir, name));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Scanner
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @param {string} filePath
|
|
107
|
+
* @returns {Array<{ line: number, charCount: number, method: string, decoded: string }>}
|
|
108
|
+
*/
|
|
109
|
+
function scanFile(filePath) {
|
|
110
|
+
const content = readFileSync(filePath, "utf-8");
|
|
111
|
+
const findings = [];
|
|
112
|
+
LONG_RUN_RE.lastIndex = 0;
|
|
113
|
+
let match;
|
|
114
|
+
let runChars = 0;
|
|
115
|
+
while ((match = LONG_RUN_RE.exec(content)) !== null) {
|
|
116
|
+
const lineNum = content.slice(0, match.index).split("\n").length;
|
|
117
|
+
const charCount = [...match[0]].length;
|
|
118
|
+
runChars += charCount;
|
|
119
|
+
findings.push({ line: lineNum, charCount, ...decodeRun(match[0]) });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Threshold-evasion: scattered invisible chars not in a long run can still be
|
|
123
|
+
// a payload. Always evaluated; chars already in a run are excluded so they
|
|
124
|
+
// aren't double-counted.
|
|
125
|
+
const allInvisible = content.match(STRIP);
|
|
126
|
+
const scattered = (allInvisible ? allInvisible.length : 0) - runChars;
|
|
127
|
+
if (scattered >= TOTAL_INVISIBLE_THRESHOLD) {
|
|
128
|
+
findings.push({
|
|
129
|
+
line: 0,
|
|
130
|
+
charCount: scattered,
|
|
131
|
+
method: "scattered invisible chars (possible threshold evasion)",
|
|
132
|
+
decoded: `[${scattered} invisible chars distributed across file]`,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return findings;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export {
|
|
140
|
+
decodeRun,
|
|
141
|
+
findMdFiles,
|
|
142
|
+
findInstructionFiles,
|
|
143
|
+
scanFile,
|
|
144
|
+
ALERT_FILE,
|
|
145
|
+
ALERT_ACK_FILE,
|
|
146
|
+
LONG_RUN_RE,
|
|
147
|
+
LONG_RUN_THRESHOLD,
|
|
148
|
+
TOTAL_INVISIBLE_THRESHOLD,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param {Array<{
|
|
153
|
+
* file: string,
|
|
154
|
+
* findings: Array<{ line: number, charCount: number, method: string, decoded: string }>,
|
|
155
|
+
* }>} allFindings
|
|
156
|
+
* @returns {string}
|
|
157
|
+
*/
|
|
158
|
+
function formatReport(allFindings) {
|
|
159
|
+
const BAR = "━".repeat(52);
|
|
160
|
+
const lines = [
|
|
161
|
+
"",
|
|
162
|
+
`━━━ INVISIBLE CHARACTER INJECTION DETECTED ${BAR.slice(0, 11)}`,
|
|
163
|
+
"",
|
|
164
|
+
"Invisible Unicode in instruction files can hijack the model’s behavior",
|
|
165
|
+
"(skill invocation, tool use, instruction override). This commonly",
|
|
166
|
+
"happens when copy-pasting content from the internet.",
|
|
167
|
+
"",
|
|
168
|
+
"These files are loaded directly as context, bypassing PostToolUse",
|
|
169
|
+
"sanitization, so the invisible characters reach the model raw.",
|
|
170
|
+
"",
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
for (const { file, findings } of allFindings) {
|
|
174
|
+
lines.push(` ${file}:`);
|
|
175
|
+
for (const finding of findings) {
|
|
176
|
+
lines.push(
|
|
177
|
+
` Line ${finding.line}: ${finding.charCount} invisible chars (${finding.method})`,
|
|
178
|
+
);
|
|
179
|
+
lines.push(` Decodes to: ${JSON.stringify(finding.decoded)}`);
|
|
180
|
+
}
|
|
181
|
+
lines.push("");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
lines.push(BAR);
|
|
185
|
+
return lines.join("\n");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export { formatReport };
|
|
189
|
+
|
|
190
|
+
// Main (skip when imported for testing)
|
|
191
|
+
|
|
192
|
+
// Stryker disable all: CLI-entry body. It runs only as a spawned subprocess,
|
|
193
|
+
// which in-process tests can't observe, so every mutant here is unkillable by
|
|
194
|
+
// construction (same boundary as the c8-ignored regions below). The exported
|
|
195
|
+
// scanFile/decodeRun above carry the real, mutation-tested logic.
|
|
196
|
+
/**
|
|
197
|
+
* Scan every instruction file under the project for invisible-char findings.
|
|
198
|
+
* @returns {Array<{file: string, findings: ReturnType<typeof scanFile>}>}
|
|
199
|
+
*/
|
|
200
|
+
function scanProject() {
|
|
201
|
+
const targets = [
|
|
202
|
+
...new Set([
|
|
203
|
+
...findInstructionFiles(PROJECT_DIR),
|
|
204
|
+
...findMdFiles(join(PROJECT_DIR, ".claude")),
|
|
205
|
+
]),
|
|
206
|
+
];
|
|
207
|
+
const allFindings = [];
|
|
208
|
+
for (const file of targets) {
|
|
209
|
+
try {
|
|
210
|
+
const findings = scanFile(file);
|
|
211
|
+
if (findings.length > 0) {
|
|
212
|
+
allFindings.push({ file: relative(PROJECT_DIR, file), findings });
|
|
213
|
+
}
|
|
214
|
+
} catch {
|
|
215
|
+
// File doesn't exist or unreadable
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return allFindings;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The hook's CLI: scan the instruction files, auto-clean what it can, persist
|
|
223
|
+
* the alert for the PreToolUse gate otherwise. Exported so a bundle entry
|
|
224
|
+
* (which must claim the CLI slot before this module loads) can run the exact
|
|
225
|
+
* same scan instead of duplicating it.
|
|
226
|
+
* @returns {Promise<void>}
|
|
227
|
+
*/
|
|
228
|
+
export async function cliMain() {
|
|
229
|
+
/* c8 ignore start -- fail-closed module-load guard: only reachable when the
|
|
230
|
+
agent-sanitizer import above failed, which can't be simulated in the
|
|
231
|
+
spawned-subprocess CLI run the tests observe. */
|
|
232
|
+
if (typeof stripInvisible !== "function") {
|
|
233
|
+
// Emit the engagement event with a "skipped" outcome so the loss is LOUD on
|
|
234
|
+
// the trace channel — a scan that never ran is otherwise invisible, and the
|
|
235
|
+
// downstream PreToolUse sanitize gate then passes cleanly all session.
|
|
236
|
+
trace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, { outcome: "skipped" });
|
|
237
|
+
process.stderr.write(
|
|
238
|
+
"scan-invisible-chars: agent-sanitizer failed to load; instruction " +
|
|
239
|
+
"files were NOT scanned for hidden Unicode.\n",
|
|
240
|
+
);
|
|
241
|
+
process.exit(1);
|
|
242
|
+
}
|
|
243
|
+
/* c8 ignore stop */
|
|
244
|
+
|
|
245
|
+
// Clean up stale alert + its ack marker from a previous session so this
|
|
246
|
+
// session re-surfaces the gate once if injection is still present.
|
|
247
|
+
for (const stale of [ALERT_FILE, ALERT_ACK_FILE]) {
|
|
248
|
+
try {
|
|
249
|
+
unlinkSync(stale);
|
|
250
|
+
} catch {
|
|
251
|
+
// Doesn't exist or not writable
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const allFindings = scanProject();
|
|
256
|
+
|
|
257
|
+
if (allFindings.length === 0) {
|
|
258
|
+
trace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, { outcome: "clean" });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
trace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, {
|
|
262
|
+
outcome: "found",
|
|
263
|
+
files: allFindings.length,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// Auto-clean contaminated files so the session proceeds without blocking
|
|
267
|
+
// every tool call; the gate hook is the fallback when cleaning fails.
|
|
268
|
+
let cleaned = 0;
|
|
269
|
+
for (const { file } of allFindings) {
|
|
270
|
+
const absPath = join(PROJECT_DIR, file);
|
|
271
|
+
try {
|
|
272
|
+
const original = readFileSync(absPath, "utf-8");
|
|
273
|
+
const stripped = stripInvisible(original);
|
|
274
|
+
if (stripped !== original) {
|
|
275
|
+
writeFileSync(absPath, stripped);
|
|
276
|
+
cleaned++;
|
|
277
|
+
}
|
|
278
|
+
/* c8 ignore start -- only fires on a file this uid cannot rewrite, which the test run cannot create */
|
|
279
|
+
} catch {
|
|
280
|
+
// Unreadable or unwritable: the file stays contaminated and falls into the
|
|
281
|
+
// alert path below, which hands it to the PreToolUse gate.
|
|
282
|
+
}
|
|
283
|
+
/* c8 ignore stop */
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const report = formatReport(allFindings);
|
|
287
|
+
|
|
288
|
+
if (cleaned === allFindings.length) {
|
|
289
|
+
process.stderr.write(
|
|
290
|
+
report +
|
|
291
|
+
`\nAll ${cleaned} file(s) cleaned on disk automatically. ` +
|
|
292
|
+
"NOTE: these files load as project instructions at session start, so " +
|
|
293
|
+
"THIS session may have already ingested the pre-clean bytes before the " +
|
|
294
|
+
"hook ran — treat any injected-looking instruction from them with " +
|
|
295
|
+
"suspicion, and restart the session if in doubt. Future sessions load " +
|
|
296
|
+
"the cleaned files.\n",
|
|
297
|
+
);
|
|
298
|
+
/* c8 ignore start -- only reachable when the write catch above fires */
|
|
299
|
+
} else {
|
|
300
|
+
process.stderr.write(report + "\n");
|
|
301
|
+
// ALERT_FILE sits at a predictable, world-visible $TMPDIR path, so a plain
|
|
302
|
+
// writeFileSync would follow a co-tenant-planted symlink and overwrite an
|
|
303
|
+
// arbitrary file this uid owns. Create it symlink-refusingly (see
|
|
304
|
+
// writeFileNoFollow); the PreToolUse gate treats an absent alert as "nothing
|
|
305
|
+
// to surface", so a lost race degrades safely rather than to a hijacked write.
|
|
306
|
+
writeFileNoFollow(ALERT_FILE, report + "\n");
|
|
307
|
+
}
|
|
308
|
+
/* c8 ignore stop */
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (isMain(import.meta.url)) {
|
|
312
|
+
await cliMain();
|
|
313
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.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": {
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
]
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
+
"sanitizer-engine": "npm:agent-sanitizer@2.1.0",
|
|
49
50
|
"@commitlint/cli": "^21.0.1",
|
|
50
51
|
"@commitlint/config-conventional": "^21.0.1",
|
|
51
52
|
"@eslint/js": "10.0.1",
|
|
@@ -120,11 +121,15 @@
|
|
|
120
121
|
"types": "./types/rehydrate.d.mts",
|
|
121
122
|
"default": "./src/rehydrate.mjs"
|
|
122
123
|
},
|
|
123
|
-
"./credential-names": "./python/agent_sanitizer/secrets/data/credential-names.json"
|
|
124
|
+
"./credential-names": "./python/agent_sanitizer/secrets/data/credential-names.json",
|
|
125
|
+
"./claude-hooks": "./claude-hooks/plugin-hooks.mjs"
|
|
124
126
|
},
|
|
125
127
|
"files": [
|
|
126
128
|
"src/*.mjs",
|
|
127
129
|
"python/agent_sanitizer/secrets/data/credential-names.json",
|
|
130
|
+
"claude-hooks/*.mjs",
|
|
131
|
+
"claude-hooks/lib/*.mjs",
|
|
132
|
+
"claude-hooks/config/*.json",
|
|
128
133
|
"bin/sanitize-cli.mjs",
|
|
129
134
|
"types",
|
|
130
135
|
"LICENSE",
|
|
@@ -133,6 +138,8 @@
|
|
|
133
138
|
"SECURITY.md"
|
|
134
139
|
],
|
|
135
140
|
"dependencies": {
|
|
141
|
+
"agent-control-plane-core": "0.2.13",
|
|
142
|
+
"namespace-guard": "0.20.0",
|
|
136
143
|
"css-tree": "^3.2.1",
|
|
137
144
|
"rehype-parse": "9.0.1",
|
|
138
145
|
"remark-gfm": "4.0.1",
|
|
@@ -144,8 +151,8 @@
|
|
|
144
151
|
"scripts": {
|
|
145
152
|
"test": "c8 node --test",
|
|
146
153
|
"coverage": "c8 node --test",
|
|
147
|
-
"check": "tsc --noEmit",
|
|
148
|
-
"typecheck": "tsc --noEmit",
|
|
154
|
+
"check": "tsc --noEmit && tsc -p tsconfig.hooks.json --noEmit",
|
|
155
|
+
"typecheck": "tsc --noEmit && tsc -p tsconfig.hooks.json --noEmit",
|
|
149
156
|
"build:types": "tsc -p tsconfig.build.json",
|
|
150
157
|
"gen:joining-type": "node scripts/gen-joining-type.mjs",
|
|
151
158
|
"lint": "eslint .",
|