agent-sanitizer 2.31.2 → 2.31.4
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/claude-hooks/lib/authored-content.mjs +82 -9
- package/claude-hooks/lib/hook-io.mjs +14 -6
- package/claude-hooks/lib/placeholder-grammar.mjs +1 -1
- package/claude-hooks/pretooluse-sanitize.mjs +1 -1
- package/package.json +2 -1
- package/types/claude-hooks/lib/authored-content.d.mts +30 -0
- package/types/claude-hooks/lib/hook-io.d.mts +13 -0
- package/types/claude-hooks/lib/placeholder-grammar.d.mts +1 -0
- package/types/claude-hooks/pretooluse-sanitize.d.mts +1 -0
|
@@ -21,6 +21,15 @@
|
|
|
21
21
|
* *literals* (`\033`, `\x1b`, `\e`) — a *raw* ESC byte in authored content
|
|
22
22
|
* is anomalous.
|
|
23
23
|
*
|
|
24
|
+
* SCOPE IS DECLARED, NOT INFERRED. Which tools this layer touches is a
|
|
25
|
+
* partition — {@link AUTHORED_FIELDS} (covered, with the field list) and
|
|
26
|
+
* {@link EXEMPT_TOOLS}/{@link EXEMPT_TOOL_PATTERNS} (looked at, with the reason
|
|
27
|
+
* nothing is sanitized) — resolved through the single
|
|
28
|
+
* {@link authoredScopeDecision} helper. Notably `mcp__*` server tools are
|
|
29
|
+
* exempt, so a PR body written via `gh pr create` IS stripped while the same
|
|
30
|
+
* body sent through a GitHub MCP tool is NOT; that asymmetry is a stated
|
|
31
|
+
* position with a rationale, not an oversight.
|
|
32
|
+
*
|
|
24
33
|
* Distinct from sanitize-output.mjs, which scrubs tool *responses* flowing
|
|
25
34
|
* toward the model (data the model reads). This scrubs what the model emits
|
|
26
35
|
* (data the model writes out). In pretooluse-sanitize.mjs it runs *after*
|
|
@@ -55,14 +64,76 @@ const { STRIP, LONG_RUN_RE, SCATTERED_THRESHOLD, stripInvisible } =
|
|
|
55
64
|
// A "key[].sub" entry addresses `sub` on every element of the array at `key`
|
|
56
65
|
// (MultiEdit batches its writes as edits[].new_string), so the nested authored
|
|
57
66
|
// content is sanitized too — not just the top-level fields.
|
|
67
|
+
//
|
|
68
|
+
// Null-prototype: `tool` comes from the payload, and on a plain object literal
|
|
69
|
+
// `FIELDS["constructor"]` answers a truthy inherited value that the field loop
|
|
70
|
+
// below would then try to iterate.
|
|
58
71
|
/** @type {Record<string, string[]>} */
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
72
|
+
export const AUTHORED_FIELDS = Object.freeze(
|
|
73
|
+
Object.assign(Object.create(null), {
|
|
74
|
+
Write: ["content"],
|
|
75
|
+
Edit: ["new_string"],
|
|
76
|
+
MultiEdit: ["edits[].new_string"],
|
|
77
|
+
NotebookEdit: ["new_source"],
|
|
78
|
+
Bash: ["command"],
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
// The other half of the partition: tools this layer has LOOKED AT and decided
|
|
83
|
+
// carry no model-authored free text, each with the reason. Together with
|
|
84
|
+
// AUTHORED_FIELDS this is a declared scope rather than a fallthrough — an
|
|
85
|
+
// omission becomes a reviewable line instead of the absence of one, and
|
|
86
|
+
// test/claude-hooks-authored-scope.test.mjs fails when a tool the package
|
|
87
|
+
// elsewhere claims to know lands in neither side.
|
|
88
|
+
/** @type {Record<string, string>} */
|
|
89
|
+
export const EXEMPT_TOOLS = Object.freeze(
|
|
90
|
+
Object.assign(Object.create(null), {
|
|
91
|
+
Read: "inputs are a path plus offsets — nothing the model authored is persisted or displayed",
|
|
92
|
+
Grep: "inputs are a search pattern and a path; rewriting a pattern would change what the search matches",
|
|
93
|
+
Glob: "inputs are a glob pattern and a path; rewriting a pattern would change what it matches",
|
|
94
|
+
LS: "input is a path — the confusable layer's domain, not authored free text",
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// Prefix-shaped exemptions, for tool families no fixed list can enumerate.
|
|
99
|
+
/** @type {ReadonlyArray<{ pattern: RegExp, reason: string }>} */
|
|
100
|
+
export const EXEMPT_TOOL_PATTERNS = Object.freeze([
|
|
101
|
+
Object.freeze({
|
|
102
|
+
pattern: /^mcp__/u,
|
|
103
|
+
reason:
|
|
104
|
+
"MCP tool inputs follow a server-declared schema this package cannot see, " +
|
|
105
|
+
"so there is no field it can name as authored free text. A blanket walk over " +
|
|
106
|
+
"every string in the input would buy recall at a real precision cost — it " +
|
|
107
|
+
"would rewrite opaque IDs, base64 blobs and protocol fields the server parses " +
|
|
108
|
+
"— so the gap is DECLARED rather than closed. A deployment that wants a " +
|
|
109
|
+
"specific server's body field covered adds it to AUTHORED_FIELDS by its full " +
|
|
110
|
+
'tool name (e.g. mcp__github__create_issue: ["body"]).',
|
|
111
|
+
}),
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The single place an unlisted tool's fate is decided: covered by a field list,
|
|
116
|
+
* exempt with a stated reason, or undeclared — nobody has classified it.
|
|
117
|
+
*
|
|
118
|
+
* `undeclared` is NOT a runtime alarm. Every arm returns the same
|
|
119
|
+
* pass-through behaviour, because a stderr line on each of the many tools no
|
|
120
|
+
* one has had a reason to classify (Task, TodoWrite, WebFetch, …) is alert
|
|
121
|
+
* fatigue, and the doctrine here is precision over recall. The signal is the
|
|
122
|
+
* partition test, which reads this function.
|
|
123
|
+
* @param {string} tool
|
|
124
|
+
* @returns {{ kind: "covered", fields: string[] } | { kind: "exempt", reason: string } | { kind: "undeclared" }}
|
|
125
|
+
*/
|
|
126
|
+
export function authoredScopeDecision(tool) {
|
|
127
|
+
const fields = AUTHORED_FIELDS[tool];
|
|
128
|
+
if (fields) return { kind: "covered", fields };
|
|
129
|
+
const exempt = EXEMPT_TOOLS[tool];
|
|
130
|
+
if (exempt) return { kind: "exempt", reason: exempt };
|
|
131
|
+
const matched = EXEMPT_TOOL_PATTERNS.find((entry) =>
|
|
132
|
+
entry.pattern.test(tool),
|
|
133
|
+
);
|
|
134
|
+
if (matched) return { kind: "exempt", reason: matched.reason };
|
|
135
|
+
return { kind: "undeclared" };
|
|
136
|
+
}
|
|
66
137
|
|
|
67
138
|
// Payload-capable: a long contiguous run, or enough scattered invisibles to
|
|
68
139
|
// carry a message. Mirrors sanitize-user-prompt so the model→world and
|
|
@@ -123,8 +194,10 @@ export function authoredContext(changed) {
|
|
|
123
194
|
* @returns {{ updatedInput: any, changed: string[] } | null}
|
|
124
195
|
*/
|
|
125
196
|
export function sanitizeAuthoredContent(tool, toolInput) {
|
|
126
|
-
const
|
|
127
|
-
if (
|
|
197
|
+
const scope = authoredScopeDecision(tool);
|
|
198
|
+
if (scope.kind !== "covered" || toolInput === null || toolInput === undefined)
|
|
199
|
+
return null;
|
|
200
|
+
const keys = scope.fields;
|
|
128
201
|
|
|
129
202
|
const changed = [];
|
|
130
203
|
// Null-prototype copy: toolInput is untrusted parsed JSON where a `__proto__`
|
|
@@ -210,12 +210,20 @@ export const PermissionDecision = Object.freeze({
|
|
|
210
210
|
export const FAIL_OPEN_ENV = "AGENT_SANITIZER_FAIL_OPEN";
|
|
211
211
|
|
|
212
212
|
/**
|
|
213
|
-
* Values that turn the default posture back to fail-closed. Matched exactly
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
213
|
+
* Values that turn the default posture back to fail-closed. Matched exactly:
|
|
214
|
+
* a case-insensitive match would need `tr`, which the launcher cannot reach
|
|
215
|
+
* (it runs its no-node arm on shell builtins alone).
|
|
216
|
+
*
|
|
217
|
+
* THE SINGLE SOURCE OF TRUTH for the closed set. The shell shims cannot import
|
|
218
|
+
* it, so `plugin/scripts/lib/fail-open.sh` is GENERATED from it by
|
|
219
|
+
* `scripts/gen-fail-open-lib.mjs` and committed; the round trip is asserted in
|
|
220
|
+
* plugin/test/fail-open-parity.test.mjs. Everything else that spells the set
|
|
221
|
+
* out by hand is an implementation that must appear in the parity table in
|
|
222
|
+
* tests/test_safe_launch.py.
|
|
217
223
|
*/
|
|
218
|
-
const FAIL_CLOSED_VALUES =
|
|
224
|
+
export const FAIL_CLOSED_VALUES = Object.freeze(["0", "false"]);
|
|
225
|
+
|
|
226
|
+
const FAIL_CLOSED_SET = new Set(FAIL_CLOSED_VALUES);
|
|
219
227
|
|
|
220
228
|
/**
|
|
221
229
|
* Whether hook failures pass the guarded action through. True unless the caller
|
|
@@ -231,7 +239,7 @@ const FAIL_CLOSED_VALUES = new Set(["0", "false"]);
|
|
|
231
239
|
* @returns {boolean}
|
|
232
240
|
*/
|
|
233
241
|
export function failOpenEnabled(env = process.env) {
|
|
234
|
-
return !
|
|
242
|
+
return !FAIL_CLOSED_SET.has(env[FAIL_OPEN_ENV] ?? "");
|
|
235
243
|
}
|
|
236
244
|
|
|
237
245
|
/**
|
|
@@ -98,7 +98,7 @@ export function layer2KeysIn(value, depth = 0) {
|
|
|
98
98
|
// Tools whose inputs the rehydration layer itself resolves (or, for
|
|
99
99
|
// MultiEdit/NotebookEdit, refuses with guidance). Both advisories stay silent
|
|
100
100
|
// on these: their placeholder handling is a verdict, not a note.
|
|
101
|
-
const REHYDRATED_TOOLS = new Set([
|
|
101
|
+
export const REHYDRATED_TOOLS = new Set([
|
|
102
102
|
"Edit",
|
|
103
103
|
"Write",
|
|
104
104
|
"MultiEdit",
|
|
@@ -686,7 +686,7 @@ export const REDACTION_HINT = "[REDACTED";
|
|
|
686
686
|
// mention "[REDACTED" benignly far too often — grepping for it, discussing
|
|
687
687
|
// it — for an ask to hold precision there. That is an accepted gap, named in
|
|
688
688
|
// THREAT-MODEL.md's carve-out paragraph, not a completeness claim.
|
|
689
|
-
const WRITE_SHAPED_TOOLS = new Set([
|
|
689
|
+
export const WRITE_SHAPED_TOOLS = new Set([
|
|
690
690
|
"Write",
|
|
691
691
|
"Edit",
|
|
692
692
|
"MultiEdit",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.31.
|
|
3
|
+
"version": "2.31.4",
|
|
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": {
|
|
@@ -216,6 +216,7 @@
|
|
|
216
216
|
"typecheck": "tsc --noEmit && tsc -p tsconfig.hooks.json --noEmit",
|
|
217
217
|
"build:types": "tsc -p tsconfig.build.json && tsc -p tsconfig.build-hooks.json",
|
|
218
218
|
"gen:joining-type": "node scripts/gen-joining-type.mjs",
|
|
219
|
+
"gen:fail-open-lib": "node scripts/gen-fail-open-lib.mjs",
|
|
219
220
|
"lint": "eslint .",
|
|
220
221
|
"test:mutation": "node scripts/mutate.mjs",
|
|
221
222
|
"format": "prettier --write .",
|
|
@@ -1,3 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single place an unlisted tool's fate is decided: covered by a field list,
|
|
3
|
+
* exempt with a stated reason, or undeclared — nobody has classified it.
|
|
4
|
+
*
|
|
5
|
+
* `undeclared` is NOT a runtime alarm. Every arm returns the same
|
|
6
|
+
* pass-through behaviour, because a stderr line on each of the many tools no
|
|
7
|
+
* one has had a reason to classify (Task, TodoWrite, WebFetch, …) is alert
|
|
8
|
+
* fatigue, and the doctrine here is precision over recall. The signal is the
|
|
9
|
+
* partition test, which reads this function.
|
|
10
|
+
* @param {string} tool
|
|
11
|
+
* @returns {{ kind: "covered", fields: string[] } | { kind: "exempt", reason: string } | { kind: "undeclared" }}
|
|
12
|
+
*/
|
|
13
|
+
export function authoredScopeDecision(tool: string): {
|
|
14
|
+
kind: "covered";
|
|
15
|
+
fields: string[];
|
|
16
|
+
} | {
|
|
17
|
+
kind: "exempt";
|
|
18
|
+
reason: string;
|
|
19
|
+
} | {
|
|
20
|
+
kind: "undeclared";
|
|
21
|
+
};
|
|
1
22
|
/** @param {string[]} changed */
|
|
2
23
|
export function authoredContext(changed: string[]): string;
|
|
3
24
|
/**
|
|
@@ -13,3 +34,12 @@ export function sanitizeAuthoredContent(tool: string, toolInput: any): {
|
|
|
13
34
|
updatedInput: any;
|
|
14
35
|
changed: string[];
|
|
15
36
|
} | null;
|
|
37
|
+
/** @type {Record<string, string[]>} */
|
|
38
|
+
export const AUTHORED_FIELDS: Record<string, string[]>;
|
|
39
|
+
/** @type {Record<string, string>} */
|
|
40
|
+
export const EXEMPT_TOOLS: Record<string, string>;
|
|
41
|
+
/** @type {ReadonlyArray<{ pattern: RegExp, reason: string }>} */
|
|
42
|
+
export const EXEMPT_TOOL_PATTERNS: ReadonlyArray<{
|
|
43
|
+
pattern: RegExp;
|
|
44
|
+
reason: string;
|
|
45
|
+
}>;
|
|
@@ -430,6 +430,19 @@ export const PermissionDecision: Readonly<{
|
|
|
430
430
|
* by construction, not by remembering to set an env var.
|
|
431
431
|
*/
|
|
432
432
|
export const FAIL_OPEN_ENV: "AGENT_SANITIZER_FAIL_OPEN";
|
|
433
|
+
/**
|
|
434
|
+
* Values that turn the default posture back to fail-closed. Matched exactly:
|
|
435
|
+
* a case-insensitive match would need `tr`, which the launcher cannot reach
|
|
436
|
+
* (it runs its no-node arm on shell builtins alone).
|
|
437
|
+
*
|
|
438
|
+
* THE SINGLE SOURCE OF TRUTH for the closed set. The shell shims cannot import
|
|
439
|
+
* it, so `plugin/scripts/lib/fail-open.sh` is GENERATED from it by
|
|
440
|
+
* `scripts/gen-fail-open-lib.mjs` and committed; the round trip is asserted in
|
|
441
|
+
* plugin/test/fail-open-parity.test.mjs. Everything else that spells the set
|
|
442
|
+
* out by hand is an implementation that must appear in the parity table in
|
|
443
|
+
* tests/test_safe_launch.py.
|
|
444
|
+
*/
|
|
445
|
+
export const FAIL_CLOSED_VALUES: readonly string[];
|
|
433
446
|
/**
|
|
434
447
|
* Hard cap on hook stdin. A well-formed Claude Code hook payload is at most a
|
|
435
448
|
* few MB (tool input plus the harness-truncated tool output); 64 MiB leaves
|
|
@@ -117,6 +117,7 @@ export const LAYER2_PLACEHOLDER_RE: RegExp;
|
|
|
117
117
|
* points at the sidecar instead of a span file.
|
|
118
118
|
*/
|
|
119
119
|
export const UNPARSEABLE_MARKER: "[HTML unparseable \u2014 withheld]";
|
|
120
|
+
export const REHYDRATED_TOOLS: Set<string>;
|
|
120
121
|
/**
|
|
121
122
|
* One found token: the exact placeholder text and the dotted field path of the
|
|
122
123
|
* FIRST input field carrying it (empty for a bare string input).
|
|
@@ -210,6 +210,7 @@ export const PRE_TOOL_USE_MESSAGES: Readonly<{
|
|
|
210
210
|
remedy: string;
|
|
211
211
|
}>;
|
|
212
212
|
export const REDACTION_HINT: "[REDACTED";
|
|
213
|
+
export const WRITE_SHAPED_TOOLS: Set<string>;
|
|
213
214
|
/**
|
|
214
215
|
* A host-supplied deny gate: given the PreToolUse input, the reason this call
|
|
215
216
|
* must be blocked, or null to let the pipeline continue. Hosts use these for
|