agent-sanitizer 2.31.1 → 2.31.3
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 +9 -5
- 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/src/html.mjs +141 -2
- 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
package/bin/sanitize-cli.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* Protocol — a request is a JSON object with an `op` (default `"sanitize"` so a
|
|
15
15
|
* bare `{ text, html }` keeps working). Per op:
|
|
16
16
|
*
|
|
17
|
-
* sanitize { text, html? } -> { cleaned, found, warnings, notes }
|
|
17
|
+
* sanitize { text, html? } -> { cleaned, found, warnings, notes, splices? }
|
|
18
18
|
* sanitizeText { text, html?, exfilScan? } -> { cleaned, warnings, notes, modified, sgrNote }
|
|
19
19
|
* classifyPrompt { text } -> { action, reason? }
|
|
20
20
|
* scanInstructionFiles { globs, cwd? } -> { findings: [{ file, findings }] }
|
|
@@ -114,10 +114,14 @@ export const OPS = {
|
|
|
114
114
|
/** @param {Record<string, unknown>} req */
|
|
115
115
|
async sanitize(req) {
|
|
116
116
|
const text = requireString(req, "text");
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
117
|
+
// Forwarded whole rather than re-listed field by field. A hand-picked
|
|
118
|
+
// projection is a second copy of the return shape that nothing keeps in
|
|
119
|
+
// sync: `splices` was part of `sanitize()`'s result and silently never
|
|
120
|
+
// reached the wire, so every non-JS caller was blind to Layer 2's spliced
|
|
121
|
+
// ranges. `test/cli-response-contract.test.mjs` pins this set against the
|
|
122
|
+
// Python client's field list, so growing the result stays a two-file edit
|
|
123
|
+
// that CI notices instead of a silent drop.
|
|
124
|
+
return await sanitize(text, { html: Boolean(req.html) });
|
|
121
125
|
},
|
|
122
126
|
|
|
123
127
|
/** @param {Record<string, unknown>} req */
|
|
@@ -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.3",
|
|
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 .",
|
package/src/html.mjs
CHANGED
|
@@ -882,6 +882,131 @@ function hasImageLayer(nodeOf) {
|
|
|
882
882
|
return paintsImageLayer(nodeOf("background"));
|
|
883
883
|
}
|
|
884
884
|
|
|
885
|
+
// The declarations that add to an axis's BORDER box beyond its content-box
|
|
886
|
+
// length, per axis. Shorthands are listed alongside the longhands they can set,
|
|
887
|
+
// because a shorthand this checker cannot resolve must fail OPEN rather than be
|
|
888
|
+
// ignored — ignoring it is what let `height:0; padding-bottom:56.25%` read as
|
|
889
|
+
// invisible.
|
|
890
|
+
// Logical spellings are listed beside their physical twins: `padding-block-end`
|
|
891
|
+
// IS the aspect-ratio idiom in a logical stylesheet, so omitting it would leave
|
|
892
|
+
// the exact false positive this checker exists to close.
|
|
893
|
+
const BLOCK_AXIS_EXTENT_PROPS = [
|
|
894
|
+
"padding",
|
|
895
|
+
"padding-top",
|
|
896
|
+
"padding-bottom",
|
|
897
|
+
"padding-block",
|
|
898
|
+
"padding-block-start",
|
|
899
|
+
"padding-block-end",
|
|
900
|
+
"border",
|
|
901
|
+
"border-width",
|
|
902
|
+
"border-top",
|
|
903
|
+
"border-bottom",
|
|
904
|
+
"border-top-width",
|
|
905
|
+
"border-bottom-width",
|
|
906
|
+
"border-block",
|
|
907
|
+
"border-block-width",
|
|
908
|
+
"border-block-start",
|
|
909
|
+
"border-block-end",
|
|
910
|
+
"border-block-start-width",
|
|
911
|
+
"border-block-end-width",
|
|
912
|
+
];
|
|
913
|
+
const INLINE_AXIS_EXTENT_PROPS = [
|
|
914
|
+
"padding",
|
|
915
|
+
"padding-left",
|
|
916
|
+
"padding-right",
|
|
917
|
+
"padding-inline",
|
|
918
|
+
"padding-inline-start",
|
|
919
|
+
"padding-inline-end",
|
|
920
|
+
"border",
|
|
921
|
+
"border-width",
|
|
922
|
+
"border-left",
|
|
923
|
+
"border-right",
|
|
924
|
+
"border-left-width",
|
|
925
|
+
"border-right-width",
|
|
926
|
+
"border-inline",
|
|
927
|
+
"border-inline-width",
|
|
928
|
+
"border-inline-start",
|
|
929
|
+
"border-inline-end",
|
|
930
|
+
"border-inline-start-width",
|
|
931
|
+
"border-inline-end-width",
|
|
932
|
+
];
|
|
933
|
+
|
|
934
|
+
// The shorthands that can set a width alongside a style and a color. An omitted
|
|
935
|
+
// width computes to `medium`, so these need an explicit numeric width before
|
|
936
|
+
// the declaration can be called zero-extent.
|
|
937
|
+
const BORDER_SHORTHANDS = new Set([
|
|
938
|
+
"border",
|
|
939
|
+
"border-top",
|
|
940
|
+
"border-bottom",
|
|
941
|
+
"border-left",
|
|
942
|
+
"border-right",
|
|
943
|
+
"border-block",
|
|
944
|
+
"border-inline",
|
|
945
|
+
"border-block-start",
|
|
946
|
+
"border-block-end",
|
|
947
|
+
"border-inline-start",
|
|
948
|
+
"border-inline-end",
|
|
949
|
+
]);
|
|
950
|
+
|
|
951
|
+
// `border-width`'s keyword values. They are LENGTHS, so a border shorthand that
|
|
952
|
+
// names one (or names none at all, defaulting to `medium`) has real extent.
|
|
953
|
+
const BORDER_WIDTH_KEYWORDS = new Set(["thin", "medium", "thick"]);
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* True when a declared axis-additive property provably contributes NO extent.
|
|
957
|
+
*
|
|
958
|
+
* Deliberately conservative: every numeric token must be near zero, no
|
|
959
|
+
* border-width keyword may appear, and a `border*` shorthand must carry an
|
|
960
|
+
* explicit numeric width (an omitted width computes to `medium`, i.e. 3px). A
|
|
961
|
+
* `calc()`, a `var()`, or any unit this cannot resolve leaves a non-numeric
|
|
962
|
+
* token behind and returns false — the fail-open the module's own policy
|
|
963
|
+
* requires, since an unresolvable value may well paint a visible box.
|
|
964
|
+
* @param {string} prop @param {any} node @returns {boolean}
|
|
965
|
+
*/
|
|
966
|
+
function contributesNoExtent(prop, node) {
|
|
967
|
+
const tokens = valueTokens(node);
|
|
968
|
+
if (tokens.length === 0) return false;
|
|
969
|
+
const isBorderShorthand = BORDER_SHORTHANDS.has(prop);
|
|
970
|
+
let sawNumeric = false;
|
|
971
|
+
for (const token of tokens) {
|
|
972
|
+
if (
|
|
973
|
+
token.type === "Number" ||
|
|
974
|
+
token.type === "Dimension" ||
|
|
975
|
+
token.type === "Percentage"
|
|
976
|
+
) {
|
|
977
|
+
if (Math.abs(parseFloat(token.value)) >= NEAR_ZERO_EPSILON) return false;
|
|
978
|
+
sawNumeric = true;
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
981
|
+
// A hex color is a `Hash` node, never a length — accepting it keeps
|
|
982
|
+
// `border:0 solid #ccc` resolvable without weakening the fail-open below.
|
|
983
|
+
if (token.type === "Hash") continue;
|
|
984
|
+
// A style/color identifier (`solid`, `red`) adds no length, but a
|
|
985
|
+
// width keyword does — and anything else (a function node, `var()`) is
|
|
986
|
+
// unresolvable and must fail open.
|
|
987
|
+
if (token.type !== "Identifier") return false;
|
|
988
|
+
if (BORDER_WIDTH_KEYWORDS.has(String(token.name).toLowerCase()))
|
|
989
|
+
return false;
|
|
990
|
+
}
|
|
991
|
+
return isBorderShorthand ? sawNumeric : true;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* True when every declaration that could add to `axisProps`' axis is either
|
|
996
|
+
* absent or provably zero, so a near-zero content-box length really does mean
|
|
997
|
+
* the rendered border box is empty.
|
|
998
|
+
* @param {(key: string) => any} nodeOf @param {string[]} axisProps
|
|
999
|
+
* @returns {boolean}
|
|
1000
|
+
*/
|
|
1001
|
+
function axisExtentProvablyZero(nodeOf, axisProps) {
|
|
1002
|
+
for (const prop of axisProps) {
|
|
1003
|
+
const node = nodeOf(prop);
|
|
1004
|
+
if (!node) continue; // undeclared: contributes its initial value, 0
|
|
1005
|
+
if (!contributesNoExtent(prop, node)) return false;
|
|
1006
|
+
}
|
|
1007
|
+
return true;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
885
1010
|
/**
|
|
886
1011
|
* @param {(key: string) => any} nodeOf value node for a property, or null
|
|
887
1012
|
* @param {(key: string) => string} textOf decoded/lowercased text for a property
|
|
@@ -889,10 +1014,24 @@ function hasImageLayer(nodeOf) {
|
|
|
889
1014
|
*/
|
|
890
1015
|
function isOverflowHidden(nodeOf, textOf) {
|
|
891
1016
|
if (textOf("overflow") !== "hidden") return false;
|
|
892
|
-
for (const dim of [
|
|
1017
|
+
for (const [dim, axisProps] of /** @type {[string, string[]][]} */ ([
|
|
1018
|
+
["height", BLOCK_AXIS_EXTENT_PROPS],
|
|
1019
|
+
["width", INLINE_AXIS_EXTENT_PROPS],
|
|
1020
|
+
["max-height", BLOCK_AXIS_EXTENT_PROPS],
|
|
1021
|
+
["max-width", INLINE_AXIS_EXTENT_PROPS],
|
|
1022
|
+
]))
|
|
893
1023
|
// Near-zero (epsilon band), not exact 0, so `height:0.0001px` still counts —
|
|
894
1024
|
// matching the standalone size checks a browser renders as invisible.
|
|
895
|
-
|
|
1025
|
+
// The content box being empty is necessary but NOT sufficient: the universal
|
|
1026
|
+
// aspect-ratio wrapper (`height:0; padding-bottom:56.25%; overflow:hidden` —
|
|
1027
|
+
// Bootstrap's `.ratio`, and every hand-pasted padding-bottom hack) renders
|
|
1028
|
+
// at 56.25% of its container with everything inside it on screen. Reading
|
|
1029
|
+
// the content-box length alone spliced that visible content out.
|
|
1030
|
+
if (
|
|
1031
|
+
isNearZeroLength(nodeOf(dim)) &&
|
|
1032
|
+
axisExtentProvablyZero(nodeOf, axisProps)
|
|
1033
|
+
)
|
|
1034
|
+
return true;
|
|
896
1035
|
return false;
|
|
897
1036
|
}
|
|
898
1037
|
|
|
@@ -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
|