agent-sanitizer 2.58.10 → 2.59.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/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 +161 -126
- package/claude-hooks/lib/redactor-wire.mjs +145 -0
- 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 +8 -3
- package/claude-hooks/sanitize-output.mjs +39 -23
- 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 +2 -1
- package/python/README.md +6 -3
- package/python/agent_sanitizer/secrets/data/redactor-protocol.json +17 -0
- 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 +58 -37
- package/types/claude-hooks/lib/redactor-wire.d.mts +46 -0
- package/types/claude-hooks/lib/trace.d.mts +19 -4
- package/types/claude-hooks/sanitize-output.d.mts +5 -6
|
@@ -11,11 +11,10 @@
|
|
|
11
11
|
* retries from scratch. If the socket is absent or dead we (re)spawn the daemon
|
|
12
12
|
* once and retry — a crashed daemon self-heals on the next redaction.
|
|
13
13
|
*
|
|
14
|
-
* Wire protocol
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* daemon could not vet input.
|
|
14
|
+
* Wire protocol: python/agent_sanitizer/secrets/data/redactor-protocol.json,
|
|
15
|
+
* which the daemon reads too — the framing, the frame cap, and the exact shape
|
|
16
|
+
* of every request and response. A response that matches no shape the request
|
|
17
|
+
* allows fails closed.
|
|
19
18
|
*/
|
|
20
19
|
import { spawn } from "node:child_process";
|
|
21
20
|
import {
|
|
@@ -24,35 +23,31 @@ import {
|
|
|
24
23
|
} from "./hook-timing.mjs";
|
|
25
24
|
import { existsSync, lstatSync } from "node:fs";
|
|
26
25
|
import { createConnection } from "node:net";
|
|
27
|
-
import { tmpdir
|
|
26
|
+
import { tmpdir } from "node:os";
|
|
28
27
|
import { dirname, join } from "node:path";
|
|
29
28
|
import { fileURLToPath } from "node:url";
|
|
30
29
|
import { envBoundSecretVars } from "./env-config.mjs";
|
|
30
|
+
import {
|
|
31
|
+
FRAME_CAP,
|
|
32
|
+
FrameReader,
|
|
33
|
+
PROTOCOL,
|
|
34
|
+
conforms,
|
|
35
|
+
encodeFrame,
|
|
36
|
+
isPlainObject,
|
|
37
|
+
} from "./redactor-wire.mjs";
|
|
38
|
+
import { currentUid, isPrivateDir, ownedByCurrentUser } from "./hook-io.mjs";
|
|
39
|
+
import { readKnob } from "./knobs.mjs";
|
|
31
40
|
|
|
32
|
-
|
|
33
|
-
export const FRAME_CAP = 16 * 1024 * 1024;
|
|
41
|
+
export { FRAME_CAP };
|
|
34
42
|
|
|
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
|
-
}
|
|
43
|
+
// Published here, where hosts already import it; the parser behind every
|
|
44
|
+
// millisecond knob lives with the knob table.
|
|
45
|
+
export { positiveMsOr } from "./knobs.mjs";
|
|
51
46
|
|
|
52
47
|
// Per-session private socket. The daemon binds it 0600 under a 0700 dir AFTER
|
|
53
48
|
// priming, so its mere existence means "ready". Overridable for tests.
|
|
54
49
|
export const DEFAULT_SOCKET_PATH =
|
|
55
|
-
|
|
50
|
+
readKnob("_AGENT_SANITIZER_REDACTOR_SOCKET") ??
|
|
56
51
|
join(tmpdir(), "agent-sanitizer-redactor", "redactor.sock");
|
|
57
52
|
|
|
58
53
|
/**
|
|
@@ -71,7 +66,7 @@ export const DEFAULT_SOCKET_PATH =
|
|
|
71
66
|
* @returns {string[]}
|
|
72
67
|
*/
|
|
73
68
|
function daemonCommand() {
|
|
74
|
-
const configured =
|
|
69
|
+
const configured = readKnob("_AGENT_SANITIZER_REDACTOR_DAEMON");
|
|
75
70
|
if (configured) return [configured];
|
|
76
71
|
// Inside the built bundle import.meta.url is .../plugin/dist/hooks/<bundle>,
|
|
77
72
|
// so the committed zipapp sits one level up; from the raw sources no such
|
|
@@ -83,10 +78,7 @@ function daemonCommand() {
|
|
|
83
78
|
// How long to wait for a freshly-spawned daemon to start accepting. A cold start
|
|
84
79
|
// pays the detect-secrets import + plugin prime (~1-3s), so the default leaves
|
|
85
80
|
// 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
|
-
);
|
|
81
|
+
const WAIT_DEADLINE_MS = readKnob("_AGENT_SANITIZER_REDACTOR_WAIT_MS");
|
|
90
82
|
|
|
91
83
|
// A daemon that ACCEPTS the connection but then stalls — a deadlock, a
|
|
92
84
|
// pathological detect-secrets input, or a half-written length prefix that never
|
|
@@ -100,7 +92,7 @@ const WAIT_DEADLINE_MS = positiveMsOr(
|
|
|
100
92
|
// timeout (two connects + the spawn wait must fit); tests shorten it via the env
|
|
101
93
|
// override (read per call) or the deadlineMs parameter to exercise the path fast.
|
|
102
94
|
function requestDeadlineMs() {
|
|
103
|
-
return
|
|
95
|
+
return readKnob("_AGENT_SANITIZER_REDACTOR_REQUEST_MS");
|
|
104
96
|
}
|
|
105
97
|
|
|
106
98
|
/**
|
|
@@ -196,18 +188,18 @@ function failClosed(cause) {
|
|
|
196
188
|
* `lstat`/`uid` are injectable seams so a test can drive a stat shape the test
|
|
197
189
|
* process cannot create (a dir owned by another uid); production binds the real ones.
|
|
198
190
|
* @param {string} socketPath
|
|
199
|
-
* @param {{lstat?: typeof lstatSync, uid?: number}} [deps]
|
|
191
|
+
* @param {{lstat?: typeof lstatSync, uid?: number | null}} [deps]
|
|
200
192
|
* @returns {"absent" | "untrusted" | "ok"}
|
|
201
193
|
*/
|
|
202
194
|
export function classifySocket(socketPath, deps = {}) {
|
|
203
|
-
const { lstat = lstatSync, uid =
|
|
195
|
+
const { lstat = lstatSync, uid = currentUid() } = deps;
|
|
204
196
|
let st;
|
|
205
197
|
try {
|
|
206
198
|
st = lstat(socketPath);
|
|
207
199
|
} catch {
|
|
208
200
|
return "absent";
|
|
209
201
|
}
|
|
210
|
-
if (!st.isSocket() || st
|
|
202
|
+
if (!st.isSocket() || !ownedByCurrentUser(st, uid)) return "untrusted";
|
|
211
203
|
let dir;
|
|
212
204
|
try {
|
|
213
205
|
dir = lstat(dirname(socketPath));
|
|
@@ -241,25 +233,23 @@ export function classifySocket(socketPath, deps = {}) {
|
|
|
241
233
|
* root to hand it to: there the hook spawns the daemon as the ordinary user, which
|
|
242
234
|
* binds under a 0700 dir it owns, and our own uid is not the adversary.
|
|
243
235
|
* @param {import("node:fs").Stats} dir
|
|
244
|
-
* @param {number} uid
|
|
236
|
+
* @param {number | null} uid
|
|
245
237
|
* @returns {boolean}
|
|
246
238
|
*/
|
|
247
239
|
function isTrustedSocketDir(dir, uid) {
|
|
248
|
-
return (
|
|
249
|
-
dir.isDirectory() &&
|
|
250
|
-
(dir.uid === uid || dir.uid === 0) &&
|
|
251
|
-
(dir.mode & 0o022) === 0
|
|
252
|
-
);
|
|
240
|
+
return isPrivateDir(dir, { uid, rootMayOwn: true });
|
|
253
241
|
}
|
|
254
242
|
|
|
255
243
|
/**
|
|
256
244
|
* Open one connection, send `request`, resolve with the parsed response object
|
|
257
245
|
* (or null). Rejects on connect failure, a malformed/oversize/short frame, or an
|
|
258
246
|
* {error} response — every one of which the caller turns into a fail-closed. A
|
|
259
|
-
* socket present but not owned by us
|
|
260
|
-
*
|
|
247
|
+
* socket present but not owned by us, and a request the protocol does not
|
|
248
|
+
* accept, fail closed WITHOUT dialing (the error carries no errno, so
|
|
249
|
+
* isRespawnable is false): we never dial into a squat, and an oversize request
|
|
250
|
+
* is not mistaken for a dead daemon worth respawning.
|
|
261
251
|
* @param {string} socketPath
|
|
262
|
-
* @param {
|
|
252
|
+
* @param {Record<string, unknown>} request
|
|
263
253
|
* @param {number} [deadlineMs] total exchange deadline; defaults to the env-tunable value
|
|
264
254
|
* @returns {Promise<RedactResponse|null>}
|
|
265
255
|
*/
|
|
@@ -277,16 +267,15 @@ export function connectAndRequest(
|
|
|
277
267
|
);
|
|
278
268
|
return;
|
|
279
269
|
}
|
|
270
|
+
if (!conforms(request, PROTOCOL.request)) {
|
|
271
|
+
reject(new Error("redactor request does not match the wire protocol"));
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
// Encoded before dialing: an over-cap request throws here, rejecting with
|
|
275
|
+
// no errno, so it is not mistaken for a dead daemon worth respawning.
|
|
276
|
+
const frame = encodeFrame(request);
|
|
280
277
|
const sock = createConnection(socketPath);
|
|
281
|
-
|
|
282
|
-
const chunks = [];
|
|
283
|
-
// Running total of buffered bytes, so we never re-concat the whole backlog on
|
|
284
|
-
// every 'data' event (that would be O(n^2) in the response size). We concat at
|
|
285
|
-
// most twice: once to read the 4-byte header if it straddles chunks, once to
|
|
286
|
-
// slice the full frame — both O(n) overall.
|
|
287
|
-
let received = 0;
|
|
288
|
-
/** @type {number|null} */
|
|
289
|
-
let expected = null;
|
|
278
|
+
const reader = new FrameReader();
|
|
290
279
|
// Total deadline for the whole connect→request→response exchange, cleared by
|
|
291
280
|
// finish() on the first terminal event. A stalled daemon (post-connect
|
|
292
281
|
// silence) trips it and fails the call closed instead of hanging the hook.
|
|
@@ -304,41 +293,31 @@ export function connectAndRequest(
|
|
|
304
293
|
() => finish(reject, new Error("redactor response timeout")),
|
|
305
294
|
deadlineMs,
|
|
306
295
|
);
|
|
307
|
-
// The bytes buffered so far as one Buffer, copying only when more than one
|
|
308
|
-
// chunk is held (a single chunk — the common case — is returned as-is).
|
|
309
|
-
const joined = () =>
|
|
310
|
-
chunks.length === 1 ? chunks[0] : Buffer.concat(chunks, received);
|
|
311
296
|
sock.on("error", (err) => finish(reject, err));
|
|
312
|
-
sock.on("connect", () =>
|
|
313
|
-
const body = Buffer.from(JSON.stringify(request), "utf8");
|
|
314
|
-
const header = Buffer.allocUnsafe(4);
|
|
315
|
-
header.writeUInt32BE(body.length, 0);
|
|
316
|
-
sock.write(Buffer.concat([header, body]));
|
|
317
|
-
});
|
|
297
|
+
sock.on("connect", () => sock.write(frame));
|
|
318
298
|
sock.on("data", (chunk) => {
|
|
319
|
-
|
|
320
|
-
// type checker that without a (never-taken, uncoverable) string branch.
|
|
321
|
-
chunks.push(/** @type {Buffer} */ (chunk));
|
|
322
|
-
received += chunk.length;
|
|
323
|
-
if (expected === null) {
|
|
324
|
-
if (received < 4) return;
|
|
325
|
-
expected = joined().readUInt32BE(0);
|
|
326
|
-
if (expected > FRAME_CAP) {
|
|
327
|
-
finish(reject, new Error("oversize response frame"));
|
|
328
|
-
return;
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
if (received < 4 + expected) return;
|
|
332
|
-
const buf = joined();
|
|
333
|
-
let parsed;
|
|
299
|
+
let frameRead;
|
|
334
300
|
try {
|
|
335
|
-
|
|
301
|
+
// No setEncoding, so 'data' is always a Buffer at runtime.
|
|
302
|
+
frameRead = reader.push(/** @type {Buffer} */ (chunk));
|
|
336
303
|
} catch (err) {
|
|
304
|
+
// Recovery: an over-cap or non-JSON frame fails THIS call closed.
|
|
337
305
|
finish(reject, err);
|
|
338
306
|
return;
|
|
339
307
|
}
|
|
340
|
-
if (
|
|
341
|
-
|
|
308
|
+
if (frameRead === undefined) return;
|
|
309
|
+
const parsed = frameRead.value;
|
|
310
|
+
if (isPlainObject(parsed) && "error" in parsed) {
|
|
311
|
+
// The daemon's error strings are fixed literals, never request data.
|
|
312
|
+
const reason = conforms(parsed, PROTOCOL.responses.error)
|
|
313
|
+
? `: ${parsed.error}`
|
|
314
|
+
: "";
|
|
315
|
+
finish(
|
|
316
|
+
reject,
|
|
317
|
+
new Error(
|
|
318
|
+
`daemon reported redaction failure${reason}; its log is ${join(dirname(socketPath), "daemon.log")}`,
|
|
319
|
+
),
|
|
320
|
+
);
|
|
342
321
|
return;
|
|
343
322
|
}
|
|
344
323
|
finish(resolve, parsed);
|
|
@@ -349,43 +328,81 @@ export function connectAndRequest(
|
|
|
349
328
|
});
|
|
350
329
|
}
|
|
351
330
|
|
|
331
|
+
// How much of a failed daemon's stderr is kept for the error message: the tail,
|
|
332
|
+
// which is where a Python traceback names its exception.
|
|
333
|
+
const STDERR_TAIL_CHARS = 2000;
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* A spawned daemon, as far as the spawning hook can see it.
|
|
337
|
+
* @typedef {object} DaemonHandle
|
|
338
|
+
* @property {() => string | undefined} failure why the daemon died before it was
|
|
339
|
+
* ready (its exit status and stderr tail), or undefined while it may still bind
|
|
340
|
+
* @property {() => void} release stop reading its stderr
|
|
341
|
+
*/
|
|
342
|
+
|
|
352
343
|
/**
|
|
353
344
|
* Spawn the daemon detached so it outlives this hook process. The daemon's bind()
|
|
354
|
-
* is the cross-process mutex, so a racing second spawn just exits — the spawn
|
|
355
|
-
* idempotent and needs no lock here.
|
|
345
|
+
* is the cross-process mutex, so a racing second spawn just exits 0 — the spawn
|
|
346
|
+
* is idempotent and needs no lock here.
|
|
347
|
+
*
|
|
348
|
+
* Its stderr is piped back so a daemon that dies on startup (an import error, a
|
|
349
|
+
* refused cache directory) fails the call at once with its own reason rather than
|
|
350
|
+
* after the whole spawn wait with none. The daemon points its stderr at a log
|
|
351
|
+
* file before it publishes the socket, so the pipe is only read until then.
|
|
356
352
|
* @param {string} socketPath
|
|
357
353
|
* @param {string[]} [command] daemon command as [argv0, ...leadingArgs]
|
|
358
354
|
* (injectable so tests can drive the missing-binary arm in-process;
|
|
359
355
|
* production always uses daemonCommand())
|
|
356
|
+
* @returns {DaemonHandle}
|
|
360
357
|
*/
|
|
361
358
|
export function spawnDaemon(socketPath, command = daemonCommand()) {
|
|
362
359
|
const [bin, ...leading] = command;
|
|
363
360
|
const child = spawn(bin, [...leading, socketPath], {
|
|
364
361
|
detached: true,
|
|
365
|
-
stdio: "ignore",
|
|
362
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
363
|
+
});
|
|
364
|
+
const stderr = /** @type {import("node:net").Socket} */ (child.stderr);
|
|
365
|
+
let output = "";
|
|
366
|
+
/** @type {string | undefined} */
|
|
367
|
+
let failure;
|
|
368
|
+
stderr.setEncoding("utf8");
|
|
369
|
+
stderr.on("data", (/** @type {string} */ chunk) => {
|
|
370
|
+
output = (output + chunk).slice(-STDERR_TAIL_CHARS);
|
|
366
371
|
});
|
|
367
372
|
// A missing daemon binary surfaces as an async 'error' event; UNHANDLED it
|
|
368
373
|
// kills this hook process, which the harness reads as "no objection" — the
|
|
369
|
-
// tool output would pass through UNSANITIZED (fail open).
|
|
370
|
-
//
|
|
371
|
-
|
|
372
|
-
|
|
374
|
+
// tool output would pass through UNSANITIZED (fail open). Recorded instead,
|
|
375
|
+
// it ends the spawn wait and fails the call CLOSED.
|
|
376
|
+
child.on("error", (err) => {
|
|
377
|
+
failure = err.message;
|
|
378
|
+
});
|
|
379
|
+
// 'close', not 'exit': it fires after stderr has drained, so the tail is whole.
|
|
380
|
+
// Exit 0 is the daemon that lost the bind race to a live one.
|
|
381
|
+
child.on("close", (code, signal) => {
|
|
382
|
+
if (code === 0) return;
|
|
383
|
+
const status = signal ? `killed by ${signal}` : `exit ${code}`;
|
|
384
|
+
const tail = output.trim();
|
|
385
|
+
failure ??= tail ? `${status}: ${tail}` : status;
|
|
386
|
+
});
|
|
373
387
|
child.unref();
|
|
388
|
+
stderr.unref();
|
|
389
|
+
return { failure: () => failure, release: () => stderr.destroy() };
|
|
374
390
|
}
|
|
375
391
|
|
|
376
392
|
/**
|
|
377
|
-
* Poll until the daemon is accepting connections
|
|
378
|
-
*
|
|
393
|
+
* Poll until the daemon is accepting connections, the deadline passes, or
|
|
394
|
+
* `abandon` reports the spawned daemon dead. Probes by connecting (not just
|
|
395
|
+
* existsSync) so it waits for listen(), not merely bind().
|
|
379
396
|
* @param {string} socketPath
|
|
380
|
-
* @param {{deadlineMs?: number, stepMs?: number}} [opts]
|
|
397
|
+
* @param {{deadlineMs?: number, stepMs?: number, abandon?: () => boolean}} [opts]
|
|
381
398
|
* @returns {Promise<boolean>}
|
|
382
399
|
*/
|
|
383
400
|
export async function waitForSocket(
|
|
384
401
|
socketPath,
|
|
385
|
-
{ deadlineMs = WAIT_DEADLINE_MS, stepMs = 100 } = {},
|
|
402
|
+
{ deadlineMs = WAIT_DEADLINE_MS, stepMs = 100, abandon = () => false } = {},
|
|
386
403
|
) {
|
|
387
404
|
const deadline = Date.now() + deadlineMs;
|
|
388
|
-
while (Date.now() < deadline) {
|
|
405
|
+
while (Date.now() < deadline && !abandon()) {
|
|
389
406
|
if (existsSync(socketPath) && (await canConnect(socketPath))) return true;
|
|
390
407
|
await sleep(stepMs);
|
|
391
408
|
}
|
|
@@ -422,17 +439,26 @@ function canConnect(socketPath) {
|
|
|
422
439
|
* budget fails CLOSED without dialing — never dial with a non-positive deadline,
|
|
423
440
|
* which would race and could return the raw, unvetted secret (fail open). Omitted,
|
|
424
441
|
* the redactor keeps its own per-call request deadline (the standalone default).
|
|
442
|
+
*
|
|
443
|
+
* `webIngress` is required: it chooses between the name-trusting heuristics for
|
|
444
|
+
* local output and the stricter ones for attacker-controlled text, and a
|
|
445
|
+
* default would make that choice silently for any caller that forgot it.
|
|
425
446
|
* @param {string} text
|
|
426
|
-
* @param {{map?: boolean, webIngress
|
|
447
|
+
* @param {{map?: boolean, webIngress: boolean, socketPath?: string,
|
|
427
448
|
* deadline?: {remainingMs: () => number},
|
|
428
|
-
* connect?: typeof connectAndRequest,
|
|
429
|
-
*
|
|
449
|
+
* connect?: typeof connectAndRequest,
|
|
450
|
+
* spawn?: (socketPath: string) => DaemonHandle | void,
|
|
451
|
+
* waitForSocket?: typeof waitForSocket, now?: () => number}} opts
|
|
430
452
|
* @returns {Promise<RedactResponse|null>}
|
|
431
453
|
*/
|
|
432
|
-
export async function redactViaDaemon(text, opts
|
|
454
|
+
export async function redactViaDaemon(text, opts) {
|
|
455
|
+
if (typeof opts?.webIngress !== "boolean")
|
|
456
|
+
throw new TypeError(
|
|
457
|
+
"redactViaDaemon: opts.webIngress must be true (attacker-controlled text) or false (local output)",
|
|
458
|
+
);
|
|
433
459
|
const {
|
|
434
460
|
map = false,
|
|
435
|
-
webIngress
|
|
461
|
+
webIngress,
|
|
436
462
|
socketPath = DEFAULT_SOCKET_PATH,
|
|
437
463
|
deadline,
|
|
438
464
|
connect = connectAndRequest,
|
|
@@ -459,36 +485,33 @@ export async function redactViaDaemon(text, opts = {}) {
|
|
|
459
485
|
web_ingress: webIngress,
|
|
460
486
|
env_secrets: collectEnvSecrets(),
|
|
461
487
|
};
|
|
462
|
-
// Response-contract guard — every
|
|
463
|
-
// caller reading a field the daemon didn't set
|
|
464
|
-
// on unvetted content (fail OPEN).
|
|
465
|
-
//
|
|
466
|
-
// `{
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
// wraps what it caught, so wrapping here too would nest the sentence in itself.
|
|
474
|
-
/** @param {RedactResponse|null} result @returns {RedactResponse|null} */
|
|
488
|
+
// Response-contract guard — every shape the protocol does not allow for this
|
|
489
|
+
// mode fails CLOSED, because a caller reading a field the daemon didn't set
|
|
490
|
+
// gets `undefined` and would proceed on unvetted content (fail OPEN). Plain
|
|
491
|
+
// mode: `{text, found}`, or null for nothing to redact. Map mode: a
|
|
492
|
+
// `{text, pairs, found}` map or the `{unmappable}` marker — never null, since
|
|
493
|
+
// the daemon always answers a map request with one of the two. Validated AFTER
|
|
494
|
+
// the respawn/retry logic so a malformed response is not mistaken for a dead
|
|
495
|
+
// socket worth respawning. Each throw carries the BARE cause: every call sits
|
|
496
|
+
// inside a catch that wraps what it caught.
|
|
497
|
+
const { responses } = PROTOCOL;
|
|
498
|
+
/** @param {unknown} result @returns {RedactResponse|null} */
|
|
475
499
|
const validate = (result) => {
|
|
476
|
-
if (result === null) return null;
|
|
477
500
|
if (map) {
|
|
478
501
|
if (
|
|
479
|
-
result
|
|
480
|
-
!(
|
|
502
|
+
!conforms(result, responses.map) &&
|
|
503
|
+
!conforms(result, responses.unmappable)
|
|
481
504
|
)
|
|
482
505
|
throw new Error(
|
|
483
|
-
"redactor returned a malformed map response (
|
|
506
|
+
"redactor returned a malformed map response (neither `{text, pairs, found}` nor `{unmappable}`)",
|
|
484
507
|
);
|
|
485
|
-
return result;
|
|
508
|
+
return /** @type {RedactResponse} */ (result);
|
|
486
509
|
}
|
|
487
|
-
if (
|
|
510
|
+
if (result !== null && !conforms(result, responses.plain))
|
|
488
511
|
throw new Error(
|
|
489
|
-
"redactor returned a malformed plain response (
|
|
512
|
+
"redactor returned a malformed plain response (neither null nor `{text, found}`)",
|
|
490
513
|
);
|
|
491
|
-
return result;
|
|
514
|
+
return /** @type {RedactResponse|null} */ (result);
|
|
492
515
|
};
|
|
493
516
|
// Every dial is charged to the redactor share, so the slow-hook notice can
|
|
494
517
|
// place the wait in this call rather than guess at it: the daemon is a
|
|
@@ -509,7 +532,7 @@ export async function redactViaDaemon(text, opts = {}) {
|
|
|
509
532
|
// Socket absent or dead: (re)spawn the daemon, wait for it, retry exactly once
|
|
510
533
|
// — but only if the shared budget still has room for a cold start + a scan.
|
|
511
534
|
if (budgetSpent()) throw outOfBudget("before redactor respawn");
|
|
512
|
-
spawnFn(socketPath);
|
|
535
|
+
const daemon = spawnFn(socketPath) ?? undefined;
|
|
513
536
|
// Clamp the cold-start wait to what the budget still allows, so a respawn can
|
|
514
537
|
// never blow it. waitForSocket returns false when the daemon never bound
|
|
515
538
|
// within the deadline; surface that as the actual cause rather than the opaque
|
|
@@ -519,18 +542,30 @@ export async function redactViaDaemon(text, opts = {}) {
|
|
|
519
542
|
budgetMs === undefined
|
|
520
543
|
? WAIT_DEADLINE_MS
|
|
521
544
|
: Math.min(WAIT_DEADLINE_MS, budgetMs);
|
|
522
|
-
const waitOpts =
|
|
523
|
-
|
|
545
|
+
const waitOpts = {
|
|
546
|
+
deadlineMs: waitMs,
|
|
547
|
+
abandon: () => daemon?.failure() !== undefined,
|
|
548
|
+
};
|
|
524
549
|
// The cold-start wait is PROVISIONING — a one-time detect-secrets import and
|
|
525
550
|
// plugin prime (~1-3s, see WAIT_DEADLINE_MS), paid by whichever tool call
|
|
526
551
|
// happens to be first and by no other. Charging it to this hook would make
|
|
527
552
|
// the slow-hook notice fire once per session on a healthy install.
|
|
528
|
-
|
|
553
|
+
const ready = await excludeProvisioning(
|
|
554
|
+
() => waitFn(socketPath, waitOpts),
|
|
555
|
+
now,
|
|
556
|
+
);
|
|
557
|
+
daemon?.release();
|
|
558
|
+
const died = daemon?.failure();
|
|
559
|
+
if (!ready)
|
|
529
560
|
throw failClosed(
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
561
|
+
new Error(
|
|
562
|
+
died === undefined
|
|
563
|
+
? // The CLAMPED wait, since that is what actually elapsed: naming
|
|
564
|
+
// WAIT_DEADLINE_MS tells an operator debugging a 200ms budget
|
|
565
|
+
// that the redactor got seconds it was never given.
|
|
566
|
+
`redactor daemon did not start within ${waitMs}ms`
|
|
567
|
+
: `redactor daemon failed to start (${died})`,
|
|
568
|
+
),
|
|
534
569
|
);
|
|
535
570
|
if (budgetSpent()) throw outOfBudget("after redactor respawn");
|
|
536
571
|
try {
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The redactor daemon's wire protocol, on the Node side: the one codec every
|
|
3
|
+
* party that speaks it here (the client, and the stand-in daemons the tests
|
|
4
|
+
* serve) derives from.
|
|
5
|
+
*
|
|
6
|
+
* The protocol itself is python/agent_sanitizer/secrets/data/redactor-protocol.json,
|
|
7
|
+
* which the Python daemon reads too: the frame cap and the exact shape of every
|
|
8
|
+
* request and response. A frame is a 4-byte big-endian body length, then that
|
|
9
|
+
* many bytes of UTF-8 JSON, in both directions.
|
|
10
|
+
*/
|
|
11
|
+
import PROTOCOL from "../../python/agent_sanitizer/secrets/data/redactor-protocol.json" with { type: "json" };
|
|
12
|
+
|
|
13
|
+
export { PROTOCOL };
|
|
14
|
+
|
|
15
|
+
// Refuse absurd frames rather than buffer unbounded, in either direction.
|
|
16
|
+
export const FRAME_CAP = PROTOCOL.frame_cap;
|
|
17
|
+
|
|
18
|
+
const HEADER_BYTES = 4;
|
|
19
|
+
|
|
20
|
+
/** @typedef {Record<string, string>} Shape protocol field name -> field type */
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Whether `value` is of protocol field type `kind` (see the protocol file's
|
|
24
|
+
* description). An unknown type throws: it is a defect in the protocol file,
|
|
25
|
+
* never a verdict on the value.
|
|
26
|
+
* @param {unknown} value
|
|
27
|
+
* @param {string} kind
|
|
28
|
+
* @returns {boolean}
|
|
29
|
+
*/
|
|
30
|
+
function hasType(value, kind) {
|
|
31
|
+
switch (kind) {
|
|
32
|
+
case "string":
|
|
33
|
+
return typeof value === "string";
|
|
34
|
+
case "boolean":
|
|
35
|
+
return typeof value === "boolean";
|
|
36
|
+
case "index":
|
|
37
|
+
return Number.isSafeInteger(value) && /** @type {number} */ (value) >= 0;
|
|
38
|
+
case "string-list":
|
|
39
|
+
return Array.isArray(value) && value.every((v) => typeof v === "string");
|
|
40
|
+
case "string-map":
|
|
41
|
+
return (
|
|
42
|
+
isPlainObject(value) &&
|
|
43
|
+
Object.values(value).every((v) => typeof v === "string")
|
|
44
|
+
);
|
|
45
|
+
case "pair-list":
|
|
46
|
+
return (
|
|
47
|
+
Array.isArray(value) && value.every((v) => conforms(v, PROTOCOL.pair))
|
|
48
|
+
);
|
|
49
|
+
default:
|
|
50
|
+
throw new Error(`unknown protocol field type ${JSON.stringify(kind)}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {unknown} value
|
|
56
|
+
* @returns {value is Record<string, unknown>}
|
|
57
|
+
*/
|
|
58
|
+
export function isPlainObject(value) {
|
|
59
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Whether `value` is an object with exactly `shape`'s fields, each of its
|
|
64
|
+
* declared protocol type.
|
|
65
|
+
* @param {unknown} value
|
|
66
|
+
* @param {Shape} shape
|
|
67
|
+
* @returns {boolean}
|
|
68
|
+
*/
|
|
69
|
+
export function conforms(value, shape) {
|
|
70
|
+
if (!isPlainObject(value)) return false;
|
|
71
|
+
const names = Object.keys(shape);
|
|
72
|
+
return (
|
|
73
|
+
Object.keys(value).length === names.length &&
|
|
74
|
+
names.every(
|
|
75
|
+
(name) => Object.hasOwn(value, name) && hasType(value[name], shape[name]),
|
|
76
|
+
)
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One frame carrying `value`. Throws (with no errno, so the client does not
|
|
82
|
+
* mistake it for a dead daemon) when the body exceeds the frame cap, which the
|
|
83
|
+
* peer would refuse unread.
|
|
84
|
+
* @param {unknown} value
|
|
85
|
+
* @returns {Buffer}
|
|
86
|
+
*/
|
|
87
|
+
export function encodeFrame(value) {
|
|
88
|
+
const body = Buffer.from(JSON.stringify(value), "utf8");
|
|
89
|
+
if (body.length > FRAME_CAP) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`redactor frame of ${body.length} bytes exceeds the ${FRAME_CAP}-byte frame cap`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
const header = Buffer.allocUnsafe(HEADER_BYTES);
|
|
95
|
+
header.writeUInt32BE(body.length, 0);
|
|
96
|
+
return Buffer.concat([header, body]);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Reassembles one frame from the chunks a stream delivers. Holds a running
|
|
101
|
+
* byte total so the backlog is concatenated at most twice (header, then the
|
|
102
|
+
* whole frame) rather than on every chunk, which would be quadratic.
|
|
103
|
+
*/
|
|
104
|
+
export class FrameReader {
|
|
105
|
+
/** @type {Buffer[]} */
|
|
106
|
+
#chunks = [];
|
|
107
|
+
#received = 0;
|
|
108
|
+
/** @type {number | null} */
|
|
109
|
+
#expected = null;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Feed one chunk. Returns `{ value }` once the whole frame has arrived and
|
|
113
|
+
* undefined before then; throws on a header over the frame cap or a body
|
|
114
|
+
* that is not JSON.
|
|
115
|
+
* @param {Buffer} chunk
|
|
116
|
+
* @returns {{ value: unknown } | undefined}
|
|
117
|
+
*/
|
|
118
|
+
push(chunk) {
|
|
119
|
+
this.#chunks.push(chunk);
|
|
120
|
+
this.#received += chunk.length;
|
|
121
|
+
if (this.#expected === null) {
|
|
122
|
+
if (this.#received < HEADER_BYTES) return undefined;
|
|
123
|
+
this.#expected = this.#joined().readUInt32BE(0);
|
|
124
|
+
if (this.#expected > FRAME_CAP) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`redactor frame header claims ${this.#expected} bytes, over the ${FRAME_CAP}-byte frame cap`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const end = HEADER_BYTES + this.#expected;
|
|
131
|
+
if (this.#received < end) return undefined;
|
|
132
|
+
return {
|
|
133
|
+
value: JSON.parse(
|
|
134
|
+
this.#joined().subarray(HEADER_BYTES, end).toString("utf8"),
|
|
135
|
+
),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The bytes buffered so far, copying only when more than one chunk is held. */
|
|
140
|
+
#joined() {
|
|
141
|
+
return this.#chunks.length === 1
|
|
142
|
+
? this.#chunks[0]
|
|
143
|
+
: Buffer.concat(this.#chunks, this.#received);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -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
|
/**
|