agent-sanitizer 2.58.10 → 2.58.11
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/THREAT-MODEL.md +12 -7
- package/bin/sanitize-cli.mjs +4 -17
- package/claude-hooks/lib/authored-content.mjs +3 -2
- package/claude-hooks/lib/env-config.mjs +12 -10
- package/claude-hooks/lib/hook-fault.mjs +38 -5
- package/claude-hooks/lib/hook-io.mjs +133 -54
- package/claude-hooks/lib/invisible-alert.mjs +9 -7
- package/claude-hooks/lib/knobs.mjs +286 -0
- package/claude-hooks/lib/redactor-client.mjs +15 -33
- package/claude-hooks/lib/reveal.mjs +5 -10
- package/claude-hooks/lib/trace.mjs +38 -9
- package/claude-hooks/plugin-hooks.mjs +2 -0
- package/claude-hooks/pretooluse-sanitize.mjs +4 -1
- package/claude-hooks/sanitize-output.mjs +37 -21
- package/claude-hooks/sanitize-user-prompt.mjs +2 -0
- package/claude-hooks/scan-invisible-chars.mjs +2 -0
- package/claude-hooks/scan-loaded-instructions.mjs +2 -0
- package/package.json +1 -1
- package/src/instructions.mjs +6 -1
- package/types/claude-hooks/lib/env-config.d.mts +8 -7
- package/types/claude-hooks/lib/hook-fault.d.mts +74 -4
- package/types/claude-hooks/lib/hook-io.d.mts +55 -14
- package/types/claude-hooks/lib/knobs.d.mts +138 -0
- package/types/claude-hooks/lib/redactor-client.d.mts +3 -15
- package/types/claude-hooks/lib/trace.d.mts +19 -4
- package/types/claude-hooks/sanitize-output.d.mts +5 -6
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE environment knobs the hooks and the CLI read, as one table.
|
|
3
|
+
*
|
|
4
|
+
* Every `AGENT_SANITIZER_*` / `_AGENT_SANITIZER_*` read goes through
|
|
5
|
+
* {@link readKnob}, which looks the name up here, parses the raw value with the
|
|
6
|
+
* entry's own grammar, and REPORTS a value the grammar does not recognize
|
|
7
|
+
* instead of silently treating it as unset. Seven hand-rolled parsers each
|
|
8
|
+
* decided that alone, and each failed the same way: `SECRETS_ENABLED=true`
|
|
9
|
+
* left the secret layer off, `FAIL_OPEN=FALSE` left the hooks open, and
|
|
10
|
+
* `MAX_INPUT_BYTES=0.5` became a zero-byte cap — all without a word.
|
|
11
|
+
*
|
|
12
|
+
* A bad value is reported, never thrown: these variables are set outside the
|
|
13
|
+
* session, so a throw (or a blocking verdict) would fail every tool call on a
|
|
14
|
+
* config value the session cannot edit. The entry's `fallback` is what the
|
|
15
|
+
* value then reads as, and `consequence` says so in the report.
|
|
16
|
+
*
|
|
17
|
+
* `test/claude-hooks-knobs.test.mjs` parses every hook module and the CLI and
|
|
18
|
+
* asserts the knobs they read are exactly this table, and that nothing reads
|
|
19
|
+
* one past {@link readKnob}.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Values of AGENT_SANITIZER_FAIL_OPEN that select the fail-closed posture.
|
|
24
|
+
* Matched exactly: a case-insensitive match would need `tr`, which the launcher
|
|
25
|
+
* cannot reach (it runs its no-node arm on shell builtins alone).
|
|
26
|
+
*
|
|
27
|
+
* THE single source of truth for the closed set. The shell shims cannot import
|
|
28
|
+
* it, so `plugin/scripts/lib/fail-open.sh` is GENERATED from it by
|
|
29
|
+
* `scripts/gen-fail-open-lib.mjs` and committed; the round trip is asserted in
|
|
30
|
+
* plugin/test/fail-open-parity.test.mjs. Everything else that spells the set
|
|
31
|
+
* out by hand is an implementation that must appear in the parity table in
|
|
32
|
+
* tests/test_safe_launch.py.
|
|
33
|
+
*/
|
|
34
|
+
export const FAIL_CLOSED_VALUES = Object.freeze(["0", "false"]);
|
|
35
|
+
|
|
36
|
+
/** What a parser returns for a value its grammar does not recognize. */
|
|
37
|
+
const INVALID = Symbol("invalid knob value");
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One knob: its grammar, the value an unrecognized input reads as, and the two
|
|
41
|
+
* halves of the report naming it.
|
|
42
|
+
* @template T
|
|
43
|
+
* @typedef {{
|
|
44
|
+
* parse: (raw: string | undefined) => T | typeof INVALID,
|
|
45
|
+
* fallback: T,
|
|
46
|
+
* expect: string,
|
|
47
|
+
* consequence: string,
|
|
48
|
+
* }} Knob
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `spec`, checked against {@link Knob} so a grammar and its fallback agree in
|
|
53
|
+
* type; {@link on} freezes it.
|
|
54
|
+
* @template T
|
|
55
|
+
* @param {Knob<T>} spec
|
|
56
|
+
* @returns {Knob<T>}
|
|
57
|
+
*/
|
|
58
|
+
const knob = (spec) => spec;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Who sets a knob, which is where it must be documented (asserted in
|
|
62
|
+
* test/claude-hooks-knobs.test.mjs):
|
|
63
|
+
* - `hooks`: a Claude Code plugin operator — plugin/README.md's knob tables;
|
|
64
|
+
* - `cli`: a caller of bin/sanitize-cli.mjs — README.md;
|
|
65
|
+
* - `internal`: `_`-prefixed plumbing hooks.json and tests set — no stable
|
|
66
|
+
* interface, so documented nowhere by name.
|
|
67
|
+
* @typedef {"hooks" | "cli" | "internal"} Surface
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `spec`, tagged with the surface that sets it.
|
|
72
|
+
* @template {Knob<any>} S
|
|
73
|
+
* @param {Surface} surface
|
|
74
|
+
* @param {S} spec
|
|
75
|
+
* @returns {Readonly<S & { surface: Surface }>}
|
|
76
|
+
*/
|
|
77
|
+
const on = (surface, spec) => Object.freeze({ ...spec, surface });
|
|
78
|
+
|
|
79
|
+
/** Unset and empty are the same request: the default. */
|
|
80
|
+
const unset = (/** @type {string | undefined} */ raw) =>
|
|
81
|
+
raw === undefined || raw === "";
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* An on/off switch spelled `1`. Only `1` turns it on, so no typo can switch a
|
|
85
|
+
* layer on or off by accident; `0` and unset are the recognized off spellings,
|
|
86
|
+
* and anything else (`true`, `yes`) is reported and reads as off.
|
|
87
|
+
* @param {string} consequence what an unrecognized value leaves in place
|
|
88
|
+
* @returns {Knob<boolean>}
|
|
89
|
+
*/
|
|
90
|
+
const flag = (consequence) =>
|
|
91
|
+
knob({
|
|
92
|
+
parse: (raw) => {
|
|
93
|
+
if (raw === "1") return true;
|
|
94
|
+
return unset(raw) || raw === "0" ? false : INVALID;
|
|
95
|
+
},
|
|
96
|
+
fallback: false,
|
|
97
|
+
expect: "1 to set it, 0 or unset to leave it off",
|
|
98
|
+
consequence,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* `raw` as a finite positive number of milliseconds, else `fallback`. A bare
|
|
103
|
+
* `Number(env) || fallback` silently accepts a NEGATIVE override (`-5 || 8000`
|
|
104
|
+
* is -5), and a non-positive deadline makes a fail-closed wait return at once.
|
|
105
|
+
* @param {string | undefined} raw
|
|
106
|
+
* @param {number} fallback
|
|
107
|
+
* @returns {number}
|
|
108
|
+
*/
|
|
109
|
+
export function positiveMsOr(raw, fallback) {
|
|
110
|
+
const ms = Number(raw);
|
|
111
|
+
return Number.isFinite(ms) && ms > 0 ? ms : fallback;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A duration override in milliseconds.
|
|
116
|
+
* @param {number} fallback
|
|
117
|
+
* @returns {Knob<number>}
|
|
118
|
+
*/
|
|
119
|
+
const milliseconds = (fallback) =>
|
|
120
|
+
knob({
|
|
121
|
+
parse: (raw) => {
|
|
122
|
+
if (unset(raw)) return fallback;
|
|
123
|
+
const ms = positiveMsOr(raw, Number.NaN);
|
|
124
|
+
return Number.isNaN(ms) ? INVALID : ms;
|
|
125
|
+
},
|
|
126
|
+
fallback,
|
|
127
|
+
expect: "a positive number of milliseconds",
|
|
128
|
+
consequence: `the ${fallback} ms default applies`,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Free text the consumer interprets itself — a path, or a list whose entries
|
|
133
|
+
* it validates against its own vocabulary. Empty reads as unset.
|
|
134
|
+
* @returns {Knob<string | undefined>}
|
|
135
|
+
*/
|
|
136
|
+
const text = () =>
|
|
137
|
+
knob({
|
|
138
|
+
parse: (raw) => (unset(raw) ? undefined : raw),
|
|
139
|
+
fallback: undefined,
|
|
140
|
+
expect: "any text",
|
|
141
|
+
consequence: "it is ignored",
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
/** Trace verbosity spellings, matched case-insensitively. */
|
|
145
|
+
const TRACE_LEVELS = Object.freeze({
|
|
146
|
+
0: ["", "0", "off", "false"],
|
|
147
|
+
1: ["1", "info", "true", "on"],
|
|
148
|
+
2: ["2", "debug"],
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
/** Largest single CLI request, in UTF-8 bytes, when the knob is unset. */
|
|
152
|
+
const DEFAULT_MAX_INPUT_BYTES = 10 * 1024 * 1024;
|
|
153
|
+
|
|
154
|
+
/** Every knob, by variable name. */
|
|
155
|
+
export const KNOBS = Object.freeze({
|
|
156
|
+
// Fail-open unless one of FAIL_CLOSED_VALUES; the documented open spellings
|
|
157
|
+
// are recognized too, so only a genuinely foreign value is reported.
|
|
158
|
+
AGENT_SANITIZER_FAIL_OPEN: on(
|
|
159
|
+
"hooks",
|
|
160
|
+
knob({
|
|
161
|
+
parse: (raw) => {
|
|
162
|
+
if (raw !== undefined && FAIL_CLOSED_VALUES.includes(raw)) return false;
|
|
163
|
+
return unset(raw) || raw === "1" || raw === "true" ? true : INVALID;
|
|
164
|
+
},
|
|
165
|
+
fallback: true,
|
|
166
|
+
expect: `${FAIL_CLOSED_VALUES.join(" or ")} to fail closed, 1/true/unset for the default`,
|
|
167
|
+
consequence: "the hooks stay fail-OPEN",
|
|
168
|
+
}),
|
|
169
|
+
),
|
|
170
|
+
AGENT_SANITIZER_DISABLED_HOOKS: on("hooks", text()),
|
|
171
|
+
AGENT_SANITIZER_SECRETS_ENABLED: on(
|
|
172
|
+
"hooks",
|
|
173
|
+
flag("the secret layer stays OFF"),
|
|
174
|
+
),
|
|
175
|
+
AGENT_SANITIZER_FLAG_DIGEST_VALUES: on(
|
|
176
|
+
"hooks",
|
|
177
|
+
flag("digest-shaped values stay exempt"),
|
|
178
|
+
),
|
|
179
|
+
AGENT_SANITIZER_INVISIBLE_DISABLED: on(
|
|
180
|
+
"hooks",
|
|
181
|
+
flag("invisible-character stripping stays ON"),
|
|
182
|
+
),
|
|
183
|
+
AGENT_SANITIZER_TERMINAL_DISABLED: on(
|
|
184
|
+
"hooks",
|
|
185
|
+
flag("terminal-control stripping stays ON"),
|
|
186
|
+
),
|
|
187
|
+
AGENT_SANITIZER_OUTPUT_DISABLED: on(
|
|
188
|
+
"hooks",
|
|
189
|
+
flag("authored-content sanitizing stays ON"),
|
|
190
|
+
),
|
|
191
|
+
AGENT_SANITIZER_MAX_INPUT_BYTES: on(
|
|
192
|
+
"cli",
|
|
193
|
+
knob({
|
|
194
|
+
parse: (raw) => {
|
|
195
|
+
if (unset(raw)) return DEFAULT_MAX_INPUT_BYTES;
|
|
196
|
+
const bytes = Number(raw);
|
|
197
|
+
return Number.isInteger(bytes) && bytes >= 1 ? bytes : INVALID;
|
|
198
|
+
},
|
|
199
|
+
fallback: DEFAULT_MAX_INPUT_BYTES,
|
|
200
|
+
expect: "a positive whole number of bytes",
|
|
201
|
+
consequence: `the ${DEFAULT_MAX_INPUT_BYTES}-byte default applies`,
|
|
202
|
+
}),
|
|
203
|
+
),
|
|
204
|
+
_AGENT_SANITIZER_EXTRA_SECRET_VARS: on("internal", text()),
|
|
205
|
+
_AGENT_SANITIZER_TRACE: on(
|
|
206
|
+
"internal",
|
|
207
|
+
knob({
|
|
208
|
+
parse: (raw) => {
|
|
209
|
+
const value = (raw ?? "").toLowerCase();
|
|
210
|
+
const level = Object.entries(TRACE_LEVELS).find(([, spellings]) =>
|
|
211
|
+
spellings.includes(value),
|
|
212
|
+
);
|
|
213
|
+
return level === undefined ? INVALID : Number(level[0]);
|
|
214
|
+
},
|
|
215
|
+
fallback: 0,
|
|
216
|
+
expect: "off/0, info/1/on/true, or debug/2",
|
|
217
|
+
consequence: "tracing stays off",
|
|
218
|
+
}),
|
|
219
|
+
),
|
|
220
|
+
_AGENT_SANITIZER_TRACE_FILE: on("internal", text()),
|
|
221
|
+
_AGENT_SANITIZER_REDACTOR_SOCKET: on("internal", text()),
|
|
222
|
+
_AGENT_SANITIZER_REDACTOR_DAEMON: on("internal", text()),
|
|
223
|
+
_AGENT_SANITIZER_REDACTOR_WAIT_MS: on("internal", milliseconds(8000)),
|
|
224
|
+
_AGENT_SANITIZER_REDACTOR_REQUEST_MS: on("internal", milliseconds(20000)),
|
|
225
|
+
_AGENT_SANITIZER_SANITIZE_BUDGET_MS: on("internal", milliseconds(120000)),
|
|
226
|
+
_AGENT_SANITIZER_REVEAL_DIR: on("internal", text()),
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The operator knobs the plugin's shell launcher (plugin/scripts/) reads before
|
|
231
|
+
* any JS runs. They share plugin/README.md's knob tables with the `hooks`
|
|
232
|
+
* surface, so the tables document exactly these plus that surface; JS never
|
|
233
|
+
* reads them (the partition test holds JS reads to KNOBS).
|
|
234
|
+
*/
|
|
235
|
+
export const LAUNCHER_KNOBS = Object.freeze([
|
|
236
|
+
"AGENT_SANITIZER_NODE",
|
|
237
|
+
"AGENT_SANITIZER_HOOK_BINARY",
|
|
238
|
+
"AGENT_SANITIZER_COMPILE_CACHE",
|
|
239
|
+
"AGENT_SANITIZER_REPEAT_DEGRADED_CONTEXT",
|
|
240
|
+
]);
|
|
241
|
+
|
|
242
|
+
/** @typedef {typeof KNOBS} Knobs */
|
|
243
|
+
|
|
244
|
+
/** `name=value` pairs already reported, so a per-leaf read reports once. */
|
|
245
|
+
const reported = new Set();
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Write `message` to stderr the first time it is seen in this process.
|
|
249
|
+
* @param {string} message
|
|
250
|
+
* @returns {void}
|
|
251
|
+
*/
|
|
252
|
+
function reportOnce(message) {
|
|
253
|
+
if (reported.has(message)) return;
|
|
254
|
+
reported.add(message);
|
|
255
|
+
process.stderr.write(`${message}\n`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The parsed value of knob `name` in `env`. An unrecognized value is reported
|
|
260
|
+
* through `report` and reads as the knob's fallback; a name missing from
|
|
261
|
+
* {@link KNOBS} throws, since it is a wiring bug in this package rather than
|
|
262
|
+
* operator input.
|
|
263
|
+
* @template {keyof Knobs} K
|
|
264
|
+
* @param {K} name
|
|
265
|
+
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
|
|
266
|
+
* @param {(message: string) => void} [report]
|
|
267
|
+
* @returns {Knobs[K]["fallback"]}
|
|
268
|
+
*/
|
|
269
|
+
export function readKnob(name, env = process.env, report = reportOnce) {
|
|
270
|
+
if (!Object.hasOwn(KNOBS, name))
|
|
271
|
+
throw new Error(
|
|
272
|
+
`unknown knob ${JSON.stringify(name)}: declare it in KNOBS ` +
|
|
273
|
+
"(claude-hooks/lib/knobs.mjs) before reading it",
|
|
274
|
+
);
|
|
275
|
+
/** @type {Knob<any>} */
|
|
276
|
+
const spec = KNOBS[name];
|
|
277
|
+
const raw = env[name];
|
|
278
|
+
const value = spec.parse(raw);
|
|
279
|
+
if (value !== INVALID) return value;
|
|
280
|
+
const shown = JSON.stringify(/** @type {string} */ (raw).slice(0, 80));
|
|
281
|
+
report(
|
|
282
|
+
`agent-sanitizer: ${name}=${shown} is not a recognized value ` +
|
|
283
|
+
`(${spec.expect}); ${spec.consequence}.`,
|
|
284
|
+
);
|
|
285
|
+
return spec.fallback;
|
|
286
|
+
}
|
|
@@ -24,35 +24,24 @@ import {
|
|
|
24
24
|
} from "./hook-timing.mjs";
|
|
25
25
|
import { existsSync, lstatSync } from "node:fs";
|
|
26
26
|
import { createConnection } from "node:net";
|
|
27
|
-
import { tmpdir
|
|
27
|
+
import { tmpdir } from "node:os";
|
|
28
28
|
import { dirname, join } from "node:path";
|
|
29
29
|
import { fileURLToPath } from "node:url";
|
|
30
30
|
import { envBoundSecretVars } from "./env-config.mjs";
|
|
31
|
+
import { currentUid, isPrivateDir, ownedByCurrentUser } from "./hook-io.mjs";
|
|
32
|
+
import { readKnob } from "./knobs.mjs";
|
|
31
33
|
|
|
32
34
|
// Refuse absurd frames rather than buffer unbounded (mirrors the daemon's cap).
|
|
33
35
|
export const FRAME_CAP = 16 * 1024 * 1024;
|
|
34
36
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
* silently accepts a NEGATIVE override (`-5 || 8000` is -5) — a non-positive
|
|
39
|
-
* deadline makes the fail-closed wait/request return immediately, defeating the
|
|
40
|
-
* deadline. Unset/blank/NaN/<=0 all take the sane positive fallback; a load-time
|
|
41
|
-
* throw is deliberately avoided so a misconfigured env can never crash these
|
|
42
|
-
* fail-closed hooks into a fail-OPEN non-load.
|
|
43
|
-
* @param {string|undefined} raw the env override value
|
|
44
|
-
* @param {number} fallback the sane positive default
|
|
45
|
-
* @returns {number}
|
|
46
|
-
*/
|
|
47
|
-
export function positiveMsOr(raw, fallback) {
|
|
48
|
-
const ms = Number(raw);
|
|
49
|
-
return Number.isFinite(ms) && ms > 0 ? ms : fallback;
|
|
50
|
-
}
|
|
37
|
+
// Published here, where hosts already import it; the parser behind every
|
|
38
|
+
// millisecond knob lives with the knob table.
|
|
39
|
+
export { positiveMsOr } from "./knobs.mjs";
|
|
51
40
|
|
|
52
41
|
// Per-session private socket. The daemon binds it 0600 under a 0700 dir AFTER
|
|
53
42
|
// priming, so its mere existence means "ready". Overridable for tests.
|
|
54
43
|
export const DEFAULT_SOCKET_PATH =
|
|
55
|
-
|
|
44
|
+
readKnob("_AGENT_SANITIZER_REDACTOR_SOCKET") ??
|
|
56
45
|
join(tmpdir(), "agent-sanitizer-redactor", "redactor.sock");
|
|
57
46
|
|
|
58
47
|
/**
|
|
@@ -71,7 +60,7 @@ export const DEFAULT_SOCKET_PATH =
|
|
|
71
60
|
* @returns {string[]}
|
|
72
61
|
*/
|
|
73
62
|
function daemonCommand() {
|
|
74
|
-
const configured =
|
|
63
|
+
const configured = readKnob("_AGENT_SANITIZER_REDACTOR_DAEMON");
|
|
75
64
|
if (configured) return [configured];
|
|
76
65
|
// Inside the built bundle import.meta.url is .../plugin/dist/hooks/<bundle>,
|
|
77
66
|
// so the committed zipapp sits one level up; from the raw sources no such
|
|
@@ -83,10 +72,7 @@ function daemonCommand() {
|
|
|
83
72
|
// How long to wait for a freshly-spawned daemon to start accepting. A cold start
|
|
84
73
|
// pays the detect-secrets import + plugin prime (~1-3s), so the default leaves
|
|
85
74
|
// margin; tests shorten it to exercise the give-up-and-fail-closed path quickly.
|
|
86
|
-
const WAIT_DEADLINE_MS =
|
|
87
|
-
process.env._AGENT_SANITIZER_REDACTOR_WAIT_MS,
|
|
88
|
-
8000,
|
|
89
|
-
);
|
|
75
|
+
const WAIT_DEADLINE_MS = readKnob("_AGENT_SANITIZER_REDACTOR_WAIT_MS");
|
|
90
76
|
|
|
91
77
|
// A daemon that ACCEPTS the connection but then stalls — a deadlock, a
|
|
92
78
|
// pathological detect-secrets input, or a half-written length prefix that never
|
|
@@ -100,7 +86,7 @@ const WAIT_DEADLINE_MS = positiveMsOr(
|
|
|
100
86
|
// timeout (two connects + the spawn wait must fit); tests shorten it via the env
|
|
101
87
|
// override (read per call) or the deadlineMs parameter to exercise the path fast.
|
|
102
88
|
function requestDeadlineMs() {
|
|
103
|
-
return
|
|
89
|
+
return readKnob("_AGENT_SANITIZER_REDACTOR_REQUEST_MS");
|
|
104
90
|
}
|
|
105
91
|
|
|
106
92
|
/**
|
|
@@ -196,18 +182,18 @@ function failClosed(cause) {
|
|
|
196
182
|
* `lstat`/`uid` are injectable seams so a test can drive a stat shape the test
|
|
197
183
|
* process cannot create (a dir owned by another uid); production binds the real ones.
|
|
198
184
|
* @param {string} socketPath
|
|
199
|
-
* @param {{lstat?: typeof lstatSync, uid?: number}} [deps]
|
|
185
|
+
* @param {{lstat?: typeof lstatSync, uid?: number | null}} [deps]
|
|
200
186
|
* @returns {"absent" | "untrusted" | "ok"}
|
|
201
187
|
*/
|
|
202
188
|
export function classifySocket(socketPath, deps = {}) {
|
|
203
|
-
const { lstat = lstatSync, uid =
|
|
189
|
+
const { lstat = lstatSync, uid = currentUid() } = deps;
|
|
204
190
|
let st;
|
|
205
191
|
try {
|
|
206
192
|
st = lstat(socketPath);
|
|
207
193
|
} catch {
|
|
208
194
|
return "absent";
|
|
209
195
|
}
|
|
210
|
-
if (!st.isSocket() || st
|
|
196
|
+
if (!st.isSocket() || !ownedByCurrentUser(st, uid)) return "untrusted";
|
|
211
197
|
let dir;
|
|
212
198
|
try {
|
|
213
199
|
dir = lstat(dirname(socketPath));
|
|
@@ -241,15 +227,11 @@ export function classifySocket(socketPath, deps = {}) {
|
|
|
241
227
|
* root to hand it to: there the hook spawns the daemon as the ordinary user, which
|
|
242
228
|
* binds under a 0700 dir it owns, and our own uid is not the adversary.
|
|
243
229
|
* @param {import("node:fs").Stats} dir
|
|
244
|
-
* @param {number} uid
|
|
230
|
+
* @param {number | null} uid
|
|
245
231
|
* @returns {boolean}
|
|
246
232
|
*/
|
|
247
233
|
function isTrustedSocketDir(dir, uid) {
|
|
248
|
-
return (
|
|
249
|
-
dir.isDirectory() &&
|
|
250
|
-
(dir.uid === uid || dir.uid === 0) &&
|
|
251
|
-
(dir.mode & 0o022) === 0
|
|
252
|
-
);
|
|
234
|
+
return isPrivateDir(dir, { uid, rootMayOwn: true });
|
|
253
235
|
}
|
|
254
236
|
|
|
255
237
|
/**
|
|
@@ -31,9 +31,10 @@ import {
|
|
|
31
31
|
closeSync,
|
|
32
32
|
constants,
|
|
33
33
|
} from "node:fs";
|
|
34
|
-
import { tmpdir
|
|
34
|
+
import { tmpdir } from "node:os";
|
|
35
35
|
import { join, resolve, sep } from "node:path";
|
|
36
|
-
import { PROJECT_HASH, writeFileNoFollow } from "./hook-io.mjs";
|
|
36
|
+
import { PROJECT_HASH, isPrivateDir, writeFileNoFollow } from "./hook-io.mjs";
|
|
37
|
+
import { readKnob } from "./knobs.mjs";
|
|
37
38
|
|
|
38
39
|
/**
|
|
39
40
|
* Where reveal sidecars are stored. Exported so the PreToolUse placeholder
|
|
@@ -48,7 +49,7 @@ export function revealDir() {
|
|
|
48
49
|
// instead. The 12-hex span KEY inside the directory is untouched — it is the
|
|
49
50
|
// published placeholder grammar, not a path detail.
|
|
50
51
|
return (
|
|
51
|
-
|
|
52
|
+
readKnob("_AGENT_SANITIZER_REVEAL_DIR") ??
|
|
52
53
|
join(tmpdir(), `agent-sanitizer-layer2-reveal-${PROJECT_HASH}`)
|
|
53
54
|
);
|
|
54
55
|
}
|
|
@@ -131,13 +132,7 @@ function revealDirIsSafe(dir) {
|
|
|
131
132
|
return false;
|
|
132
133
|
}
|
|
133
134
|
/* c8 ignore stop */
|
|
134
|
-
|
|
135
|
-
return (
|
|
136
|
-
st.isDirectory() &&
|
|
137
|
-
!st.isSymbolicLink() &&
|
|
138
|
-
st.uid === userInfo().uid &&
|
|
139
|
-
!groupOrOtherWritable
|
|
140
|
-
);
|
|
135
|
+
return isPrivateDir(st);
|
|
141
136
|
}
|
|
142
137
|
|
|
143
138
|
/**
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
import { appendFileSync } from "node:fs";
|
|
22
22
|
import { chargeHostExtensionSync } from "./hook-timing.mjs";
|
|
23
|
+
import { readKnob } from "./knobs.mjs";
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* The sink shape a hook emits through: the event name, its metadata fields, and
|
|
@@ -42,16 +43,44 @@ export const TraceEvent = Object.freeze({
|
|
|
42
43
|
const LEVELS = Object.freeze({ off: 0, info: 1, debug: 2 });
|
|
43
44
|
|
|
44
45
|
/**
|
|
45
|
-
* Numeric verbosity from _AGENT_SANITIZER_TRACE: 0 off, 1 info, 2 debug.
|
|
46
|
-
*
|
|
47
|
-
* @param {NodeJS.ProcessEnv} [env]
|
|
46
|
+
* Numeric verbosity from _AGENT_SANITIZER_TRACE: 0 off, 1 info, 2 debug. Empty
|
|
47
|
+
* or "off" → 0; an unrecognized value is reported and reads as 0.
|
|
48
|
+
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
|
|
48
49
|
* @returns {number}
|
|
49
50
|
*/
|
|
50
51
|
export function traceThreshold(env = process.env) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
return readKnob("_AGENT_SANITIZER_TRACE", env);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The keys every trace line's envelope owns; a field cannot set them. */
|
|
56
|
+
export const TRACE_RESERVED_KEYS = Object.freeze(["ts", "level", "event"]);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* THE trace line's record: the envelope ({@link TRACE_RESERVED_KEYS}) first,
|
|
60
|
+
* then `fields`. A field named like an envelope key cannot overwrite it — a
|
|
61
|
+
* reader buckets on `level` and `event`, so a producer field `level: "debug"`
|
|
62
|
+
* would misfile the line — and is not dropped silently either: its name lands in
|
|
63
|
+
* `shadowed`, so the producer bug is visible on the channel it broke.
|
|
64
|
+
* @param {string} event
|
|
65
|
+
* @param {Record<string, unknown>} fields
|
|
66
|
+
* @param {"info"|"debug"} level
|
|
67
|
+
* @param {number} ts
|
|
68
|
+
* @returns {Record<string, unknown>}
|
|
69
|
+
*/
|
|
70
|
+
export function traceRecord(event, fields, level, ts) {
|
|
71
|
+
const shadowed = TRACE_RESERVED_KEYS.filter((key) =>
|
|
72
|
+
Object.hasOwn(fields, key),
|
|
73
|
+
);
|
|
74
|
+
const rest = Object.fromEntries(
|
|
75
|
+
Object.entries(fields).filter(([key]) => !shadowed.includes(key)),
|
|
76
|
+
);
|
|
77
|
+
return {
|
|
78
|
+
ts,
|
|
79
|
+
level,
|
|
80
|
+
event,
|
|
81
|
+
...rest,
|
|
82
|
+
...(shadowed.length > 0 ? { shadowed } : {}),
|
|
83
|
+
};
|
|
55
84
|
}
|
|
56
85
|
|
|
57
86
|
/**
|
|
@@ -69,8 +98,8 @@ export function trace(event, fields = {}, level = "info") {
|
|
|
69
98
|
const lvl = level === "debug" ? "debug" : "info";
|
|
70
99
|
if (traceThreshold() < LEVELS[lvl]) return;
|
|
71
100
|
const line =
|
|
72
|
-
JSON.stringify(
|
|
73
|
-
const file =
|
|
101
|
+
JSON.stringify(traceRecord(event, fields, lvl, Date.now())) + "\n";
|
|
102
|
+
const file = readKnob("_AGENT_SANITIZER_TRACE_FILE");
|
|
74
103
|
try {
|
|
75
104
|
if (file) appendFileSync(file, line);
|
|
76
105
|
else process.stderr.write(line);
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
} from "./lib/hook-io.mjs";
|
|
27
27
|
import {
|
|
28
28
|
registerFaultPolicy,
|
|
29
|
+
KNOB_DECIDES,
|
|
29
30
|
hookFaultOutcome,
|
|
30
31
|
writeFaultOutcome,
|
|
31
32
|
} from "./lib/hook-fault.mjs";
|
|
@@ -54,6 +55,7 @@ const unknownMode = (/** @type {{ message: string }} */ ctx) => ({
|
|
|
54
55
|
registerFaultPolicy(HOOK_NAME, {
|
|
55
56
|
event: null,
|
|
56
57
|
guarded: "hook payload",
|
|
58
|
+
alwaysClosed: KNOB_DECIDES,
|
|
57
59
|
open: unknownMode,
|
|
58
60
|
closed: unknownMode,
|
|
59
61
|
});
|
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
} from "./lib/hook-io.mjs";
|
|
46
46
|
import {
|
|
47
47
|
registerFaultPolicy,
|
|
48
|
+
KNOB_DECIDES,
|
|
48
49
|
hookFaultOutcome,
|
|
49
50
|
defaultOpen,
|
|
50
51
|
} from "./lib/hook-fault.mjs";
|
|
@@ -65,6 +66,7 @@ import {
|
|
|
65
66
|
} from "./lib/authored-content.mjs";
|
|
66
67
|
import { redactViaDaemon } from "./lib/redactor-client.mjs";
|
|
67
68
|
import { secretsEnabled } from "./lib/env-config.mjs";
|
|
69
|
+
import { readKnob } from "./lib/knobs.mjs";
|
|
68
70
|
import { withSecretDropGuard } from "./lib/secret-drop-guard.mjs";
|
|
69
71
|
import {
|
|
70
72
|
placeholderNotice,
|
|
@@ -420,7 +422,7 @@ export function preToolUseLayers(rehydrate, env = process.env) {
|
|
|
420
422
|
run: (tool, toolInput) => rehydrateLayer2(tool, toolInput),
|
|
421
423
|
},
|
|
422
424
|
];
|
|
423
|
-
return
|
|
425
|
+
return readKnob("AGENT_SANITIZER_OUTPUT_DISABLED", env)
|
|
424
426
|
? layers.filter((layer) => layer.name !== "authored-content")
|
|
425
427
|
: layers;
|
|
426
428
|
}
|
|
@@ -848,6 +850,7 @@ export function hookFailureFields(parsedOk, err, opts = {}) {
|
|
|
848
850
|
registerFaultPolicy(HOOK_NAME, {
|
|
849
851
|
event: HookEvent.PRE_TOOL_USE,
|
|
850
852
|
guarded: "tool input",
|
|
853
|
+
alwaysClosed: KNOB_DECIDES,
|
|
851
854
|
open: (ctx) => {
|
|
852
855
|
// Non-carve-out faults take the SHARED open rendering, not a copy of it —
|
|
853
856
|
// hook-fault.mjs owns that body, and a restated one would silently drift.
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
* Layer 3 and the strict secret mode run on all MCP connector output (see
|
|
25
25
|
* isUntrustedIngress).
|
|
26
26
|
*/
|
|
27
|
-
import { redactViaDaemon
|
|
27
|
+
import { redactViaDaemon } from "./lib/redactor-client.mjs";
|
|
28
|
+
import { readKnob } from "./lib/knobs.mjs";
|
|
28
29
|
import {
|
|
29
30
|
chargeHostExtension,
|
|
30
31
|
chargeHostExtensionSync,
|
|
@@ -49,7 +50,11 @@ import { registerFaultPolicy, hookFaultOutcome } from "./lib/hook-fault.mjs";
|
|
|
49
50
|
import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
|
|
50
51
|
import { hookTrace, TraceEvent } from "./lib/trace.mjs";
|
|
51
52
|
import { hasEnvBoundSecret } from "./lib/secret-annotate.mjs";
|
|
52
|
-
import {
|
|
53
|
+
import {
|
|
54
|
+
digestFlaggingEnabled,
|
|
55
|
+
secretsEnabled,
|
|
56
|
+
SECRETS_ENABLED_ENV,
|
|
57
|
+
} from "./lib/env-config.mjs";
|
|
53
58
|
import {
|
|
54
59
|
persistReveal,
|
|
55
60
|
persistSpan,
|
|
@@ -118,10 +123,7 @@ export const REVEAL_WITHHELD_WARNING = withheldWarning("reveal sidecar");
|
|
|
118
123
|
// would be shown (fail OPEN). The default sits comfortably above one legitimately
|
|
119
124
|
// slow leaf (a cold redactor respawn + a full scan) yet far under the hook
|
|
120
125
|
// timeout; env-tunable so tests can drive the exhausted-budget path fast.
|
|
121
|
-
const SANITIZE_BUDGET_MS =
|
|
122
|
-
process.env._AGENT_SANITIZER_SANITIZE_BUDGET_MS,
|
|
123
|
-
120000,
|
|
124
|
-
);
|
|
126
|
+
const SANITIZE_BUDGET_MS = readKnob("_AGENT_SANITIZER_SANITIZE_BUDGET_MS");
|
|
125
127
|
|
|
126
128
|
// Web-ingress tools always get the Layer 2 HTML rewrite; local tools — Read,
|
|
127
129
|
// Bash, Grep, gh — never do. A local HTML/markdown pass either rewrites bytes the
|
|
@@ -276,11 +278,11 @@ export async function sanitizeText(
|
|
|
276
278
|
// Layer 4 — OPT-IN (secretsEnabled): with the knob unset the seam gets no
|
|
277
279
|
// redact callback at all, so plain output never spawns the daemon and no
|
|
278
280
|
// placeholder ever enters the model's view. When it runs, the seam rethrows
|
|
279
|
-
// a redactor throw wrapped, and
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
// degraded redactor would otherwise be invisible to
|
|
283
|
-
// the
|
|
281
|
+
// a redactor throw wrapped, and this hook's fault policy suppresses the
|
|
282
|
+
// output under either posture (alwaysClosed). Surface the failure to the
|
|
283
|
+
// operator's terminal here first: the suppression note rides in what only
|
|
284
|
+
// the model sees, so a degraded redactor would otherwise be invisible to
|
|
285
|
+
// the human.
|
|
284
286
|
redact: !secretsEnabled()
|
|
285
287
|
? undefined
|
|
286
288
|
: async (/** @type {string} */ content) => {
|
|
@@ -783,15 +785,14 @@ function failClosedParts(
|
|
|
783
785
|
* fail-OPEN by default — a warning context and NO `updatedToolOutput`, leaving
|
|
784
786
|
* the original tool output in the model's view — or the fail-closed
|
|
785
787
|
* suppression of {@link emitFailClosed} when the caller set
|
|
786
|
-
* AGENT_SANITIZER_FAIL_OPEN=0.
|
|
788
|
+
* AGENT_SANITIZER_FAIL_OPEN=0, or opted into Layer 4 (see the policy below).
|
|
787
789
|
*
|
|
788
790
|
* This is the hook where the two postures diverge the most, so state the open
|
|
789
791
|
* one plainly: several of these layers throw on inputs an attacker composes (a
|
|
790
|
-
* nesting depth that overflows the walk,
|
|
791
|
-
*
|
|
792
|
-
*
|
|
793
|
-
*
|
|
794
|
-
* keeping the session moving sets the knob to `0`.
|
|
792
|
+
* nesting depth that overflows the walk), and each of those throws is guarding
|
|
793
|
+
* content the open posture hands to the model verbatim. An operator who cares
|
|
794
|
+
* more about withholding that than about keeping the session moving sets the
|
|
795
|
+
* knob to `0`.
|
|
795
796
|
* @param {any} input parsed hook input, or undefined if parsing threw
|
|
796
797
|
* @param {unknown} err
|
|
797
798
|
* @param {(fields: Record<string, unknown>) => void} [emit]
|
|
@@ -830,14 +831,29 @@ function suppressionMessage(cause) {
|
|
|
830
831
|
return `[SANITIZATION FAILED — original output suppressed for safety. Hook error: ${cause}]`;
|
|
831
832
|
}
|
|
832
833
|
|
|
834
|
+
/** Why an open knob still suppressed: the secret layer's own guarantee. */
|
|
835
|
+
const SECRETS_PIN_NOTE =
|
|
836
|
+
` Secret redaction is on (${SECRETS_ENABLED_ENV}=1), so output it could ` +
|
|
837
|
+
"not vet is withheld under either AGENT_SANITIZER_FAIL_OPEN posture.";
|
|
838
|
+
|
|
833
839
|
// This hook's entry in the one posture table (lib/hook-fault.mjs). OPEN is the
|
|
834
840
|
// shared default (a warning context, the original output left in the model's
|
|
835
841
|
// view); CLOSED replaces every string leaf of the output with the placeholder.
|
|
842
|
+
// With Layer 4 opted in, a fault here means the output was never vetted for
|
|
843
|
+
// secrets — a dead daemon, a spent budget, a walk that overflowed before the
|
|
844
|
+
// redactor ran — so this pin is what keeps the open default from handing an
|
|
845
|
+
// unredacted credential to the model.
|
|
836
846
|
registerFaultPolicy(HOOK_NAME, {
|
|
837
847
|
event: HookEvent.POST_TOOL_USE,
|
|
838
848
|
guarded: "tool output",
|
|
849
|
+
alwaysClosed: secretsEnabled,
|
|
839
850
|
closed: (ctx) =>
|
|
840
|
-
failClosedParts(
|
|
851
|
+
failClosedParts(
|
|
852
|
+
ctx.input,
|
|
853
|
+
suppressionMessage(ctx.message) +
|
|
854
|
+
(ctx.pinnedClosed ? SECRETS_PIN_NOTE : ""),
|
|
855
|
+
ctx.remedy,
|
|
856
|
+
),
|
|
841
857
|
});
|
|
842
858
|
|
|
843
859
|
/**
|
|
@@ -1137,9 +1153,9 @@ export async function cliMain(ext = {}) {
|
|
|
1137
1153
|
// back the parsed `input` even when the control-plane load failed, so the
|
|
1138
1154
|
// suppression shape-matches the real tool_response). emitFailClosed itself
|
|
1139
1155
|
// falls back to a bare string if that shape-matching replacement or its
|
|
1140
|
-
// serialization throws, so even a pathological input fails closed.
|
|
1141
|
-
//
|
|
1142
|
-
// instead — see emitHookFailure.
|
|
1156
|
+
// serialization throws, so even a pathological input fails closed. Under
|
|
1157
|
+
// the open default with Layer 4 off the caller gets the warning-only
|
|
1158
|
+
// pass-through instead — see emitHookFailure.
|
|
1143
1159
|
onError: (err, input) =>
|
|
1144
1160
|
emitHookFailure(input, err, undefined, ext.remedy),
|
|
1145
1161
|
},
|