agent-sanitizer 2.58.11 → 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.
@@ -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 (both directions): a 4-byte big-endian unsigned length prefix
15
- * then that many bytes of UTF-8 JSON. Request {text, map, web_ingress,
16
- * env_secrets}; response is the same object the redactor's one-shot CLI would
17
- * print, or JSON null for the "nothing to redact" case, or {error} when the
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 {
@@ -28,11 +27,18 @@ 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";
31
38
  import { currentUid, isPrivateDir, ownedByCurrentUser } from "./hook-io.mjs";
32
39
  import { readKnob } from "./knobs.mjs";
33
40
 
34
- // Refuse absurd frames rather than buffer unbounded (mirrors the daemon's cap).
35
- export const FRAME_CAP = 16 * 1024 * 1024;
41
+ export { FRAME_CAP };
36
42
 
37
43
  // Published here, where hosts already import it; the parser behind every
38
44
  // millisecond knob lives with the knob table.
@@ -238,10 +244,12 @@ function isTrustedSocketDir(dir, uid) {
238
244
  * Open one connection, send `request`, resolve with the parsed response object
239
245
  * (or null). Rejects on connect failure, a malformed/oversize/short frame, or an
240
246
  * {error} response — every one of which the caller turns into a fail-closed. A
241
- * socket present but not owned by us fails closed WITHOUT respawning (the error
242
- * carries no errno, so isRespawnable is false), so we never dial into a squat.
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.
243
251
  * @param {string} socketPath
244
- * @param {{text: string, map: boolean, web_ingress: boolean}} request
252
+ * @param {Record<string, unknown>} request
245
253
  * @param {number} [deadlineMs] total exchange deadline; defaults to the env-tunable value
246
254
  * @returns {Promise<RedactResponse|null>}
247
255
  */
@@ -259,16 +267,15 @@ export function connectAndRequest(
259
267
  );
260
268
  return;
261
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);
262
277
  const sock = createConnection(socketPath);
263
- /** @type {Buffer[]} */
264
- const chunks = [];
265
- // Running total of buffered bytes, so we never re-concat the whole backlog on
266
- // every 'data' event (that would be O(n^2) in the response size). We concat at
267
- // most twice: once to read the 4-byte header if it straddles chunks, once to
268
- // slice the full frame — both O(n) overall.
269
- let received = 0;
270
- /** @type {number|null} */
271
- let expected = null;
278
+ const reader = new FrameReader();
272
279
  // Total deadline for the whole connect→request→response exchange, cleared by
273
280
  // finish() on the first terminal event. A stalled daemon (post-connect
274
281
  // silence) trips it and fails the call closed instead of hanging the hook.
@@ -286,41 +293,31 @@ export function connectAndRequest(
286
293
  () => finish(reject, new Error("redactor response timeout")),
287
294
  deadlineMs,
288
295
  );
289
- // The bytes buffered so far as one Buffer, copying only when more than one
290
- // chunk is held (a single chunk — the common case — is returned as-is).
291
- const joined = () =>
292
- chunks.length === 1 ? chunks[0] : Buffer.concat(chunks, received);
293
296
  sock.on("error", (err) => finish(reject, err));
294
- sock.on("connect", () => {
295
- const body = Buffer.from(JSON.stringify(request), "utf8");
296
- const header = Buffer.allocUnsafe(4);
297
- header.writeUInt32BE(body.length, 0);
298
- sock.write(Buffer.concat([header, body]));
299
- });
297
+ sock.on("connect", () => sock.write(frame));
300
298
  sock.on("data", (chunk) => {
301
- // No setEncoding, so 'data' is always a Buffer at runtime; the cast tells the
302
- // type checker that without a (never-taken, uncoverable) string branch.
303
- chunks.push(/** @type {Buffer} */ (chunk));
304
- received += chunk.length;
305
- if (expected === null) {
306
- if (received < 4) return;
307
- expected = joined().readUInt32BE(0);
308
- if (expected > FRAME_CAP) {
309
- finish(reject, new Error("oversize response frame"));
310
- return;
311
- }
312
- }
313
- if (received < 4 + expected) return;
314
- const buf = joined();
315
- let parsed;
299
+ let frameRead;
316
300
  try {
317
- parsed = JSON.parse(buf.subarray(4, 4 + expected).toString("utf8"));
301
+ // No setEncoding, so 'data' is always a Buffer at runtime.
302
+ frameRead = reader.push(/** @type {Buffer} */ (chunk));
318
303
  } catch (err) {
304
+ // Recovery: an over-cap or non-JSON frame fails THIS call closed.
319
305
  finish(reject, err);
320
306
  return;
321
307
  }
322
- if (parsed && typeof parsed === "object" && "error" in parsed) {
323
- finish(reject, new Error("daemon reported redaction failure"));
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
+ );
324
321
  return;
325
322
  }
326
323
  finish(resolve, parsed);
@@ -331,43 +328,81 @@ export function connectAndRequest(
331
328
  });
332
329
  }
333
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
+
334
343
  /**
335
344
  * Spawn the daemon detached so it outlives this hook process. The daemon's bind()
336
- * is the cross-process mutex, so a racing second spawn just exits — the spawn is
337
- * 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.
338
352
  * @param {string} socketPath
339
353
  * @param {string[]} [command] daemon command as [argv0, ...leadingArgs]
340
354
  * (injectable so tests can drive the missing-binary arm in-process;
341
355
  * production always uses daemonCommand())
356
+ * @returns {DaemonHandle}
342
357
  */
343
358
  export function spawnDaemon(socketPath, command = daemonCommand()) {
344
359
  const [bin, ...leading] = command;
345
360
  const child = spawn(bin, [...leading, socketPath], {
346
361
  detached: true,
347
- 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);
348
371
  });
349
372
  // A missing daemon binary surfaces as an async 'error' event; UNHANDLED it
350
373
  // kills this hook process, which the harness reads as "no objection" — the
351
- // tool output would pass through UNSANITIZED (fail open). Swallowed, the
352
- // daemon simply never binds and waitForSocket's deadline fails the call
353
- // CLOSED, the declared posture for an unreachable redactor.
354
- child.on("error", () => {});
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
+ });
355
387
  child.unref();
388
+ stderr.unref();
389
+ return { failure: () => failure, release: () => stderr.destroy() };
356
390
  }
357
391
 
358
392
  /**
359
- * Poll until the daemon is accepting connections or the deadline passes. Probes by
360
- * connecting (not just existsSync) so it waits for listen(), not merely bind().
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().
361
396
  * @param {string} socketPath
362
- * @param {{deadlineMs?: number, stepMs?: number}} [opts]
397
+ * @param {{deadlineMs?: number, stepMs?: number, abandon?: () => boolean}} [opts]
363
398
  * @returns {Promise<boolean>}
364
399
  */
365
400
  export async function waitForSocket(
366
401
  socketPath,
367
- { deadlineMs = WAIT_DEADLINE_MS, stepMs = 100 } = {},
402
+ { deadlineMs = WAIT_DEADLINE_MS, stepMs = 100, abandon = () => false } = {},
368
403
  ) {
369
404
  const deadline = Date.now() + deadlineMs;
370
- while (Date.now() < deadline) {
405
+ while (Date.now() < deadline && !abandon()) {
371
406
  if (existsSync(socketPath) && (await canConnect(socketPath))) return true;
372
407
  await sleep(stepMs);
373
408
  }
@@ -404,17 +439,26 @@ function canConnect(socketPath) {
404
439
  * budget fails CLOSED without dialing — never dial with a non-positive deadline,
405
440
  * which would race and could return the raw, unvetted secret (fail open). Omitted,
406
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.
407
446
  * @param {string} text
408
- * @param {{map?: boolean, webIngress?: boolean, socketPath?: string,
447
+ * @param {{map?: boolean, webIngress: boolean, socketPath?: string,
409
448
  * deadline?: {remainingMs: () => number},
410
- * connect?: typeof connectAndRequest, spawn?: typeof spawnDaemon,
411
- * waitForSocket?: typeof waitForSocket, now?: () => number}} [opts]
449
+ * connect?: typeof connectAndRequest,
450
+ * spawn?: (socketPath: string) => DaemonHandle | void,
451
+ * waitForSocket?: typeof waitForSocket, now?: () => number}} opts
412
452
  * @returns {Promise<RedactResponse|null>}
413
453
  */
414
- 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
+ );
415
459
  const {
416
460
  map = false,
417
- webIngress = false,
461
+ webIngress,
418
462
  socketPath = DEFAULT_SOCKET_PATH,
419
463
  deadline,
420
464
  connect = connectAndRequest,
@@ -441,36 +485,33 @@ export async function redactViaDaemon(text, opts = {}) {
441
485
  web_ingress: webIngress,
442
486
  env_secrets: collectEnvSecrets(),
443
487
  };
444
- // Response-contract guard — every out-of-contract shape fails CLOSED, because a
445
- // caller reading a field the daemon didn't set gets `undefined` and would proceed
446
- // on unvetted content (fail OPEN). null (nothing to redact) is in-contract in both
447
- // modes. Plain mode: `{text, found}` — text must be a string. Map mode: the
448
- // `{unmappable}` marker (the daemon could not build a reversible map, so the
449
- // caller suppresses) OR a `{text, pairs}` map — string text AND an array of pairs;
450
- // a bare `{}` or a missing/non-array `pairs` is refused, since rehydrateRedacted
451
- // reading `result.pairs` on it would silently emit the original secret-shaped
452
- // content. Validated AFTER the respawn/retry logic so a malformed response is not
453
- // mistaken for a dead socket worth respawning.
454
- // Each throw carries the BARE cause: every call sits inside a catch that
455
- // wraps what it caught, so wrapping here too would nest the sentence in itself.
456
- /** @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} */
457
499
  const validate = (result) => {
458
- if (result === null) return null;
459
500
  if (map) {
460
501
  if (
461
- result?.unmappable === undefined &&
462
- !(typeof result?.text === "string" && Array.isArray(result?.pairs))
502
+ !conforms(result, responses.map) &&
503
+ !conforms(result, responses.unmappable)
463
504
  )
464
505
  throw new Error(
465
- "redactor returned a malformed map response (no `unmappable` marker and no `{text, pairs}` map)",
506
+ "redactor returned a malformed map response (neither `{text, pairs, found}` nor `{unmappable}`)",
466
507
  );
467
- return result;
508
+ return /** @type {RedactResponse} */ (result);
468
509
  }
469
- if (typeof result?.text !== "string")
510
+ if (result !== null && !conforms(result, responses.plain))
470
511
  throw new Error(
471
- "redactor returned a malformed plain response (no string `text`)",
512
+ "redactor returned a malformed plain response (neither null nor `{text, found}`)",
472
513
  );
473
- return result;
514
+ return /** @type {RedactResponse|null} */ (result);
474
515
  };
475
516
  // Every dial is charged to the redactor share, so the slow-hook notice can
476
517
  // place the wait in this call rather than guess at it: the daemon is a
@@ -491,7 +532,7 @@ export async function redactViaDaemon(text, opts = {}) {
491
532
  // Socket absent or dead: (re)spawn the daemon, wait for it, retry exactly once
492
533
  // — but only if the shared budget still has room for a cold start + a scan.
493
534
  if (budgetSpent()) throw outOfBudget("before redactor respawn");
494
- spawnFn(socketPath);
535
+ const daemon = spawnFn(socketPath) ?? undefined;
495
536
  // Clamp the cold-start wait to what the budget still allows, so a respawn can
496
537
  // never blow it. waitForSocket returns false when the daemon never bound
497
538
  // within the deadline; surface that as the actual cause rather than the opaque
@@ -501,18 +542,30 @@ export async function redactViaDaemon(text, opts = {}) {
501
542
  budgetMs === undefined
502
543
  ? WAIT_DEADLINE_MS
503
544
  : Math.min(WAIT_DEADLINE_MS, budgetMs);
504
- const waitOpts =
505
- budgetMs === undefined ? undefined : { deadlineMs: waitMs };
545
+ const waitOpts = {
546
+ deadlineMs: waitMs,
547
+ abandon: () => daemon?.failure() !== undefined,
548
+ };
506
549
  // The cold-start wait is PROVISIONING — a one-time detect-secrets import and
507
550
  // plugin prime (~1-3s, see WAIT_DEADLINE_MS), paid by whichever tool call
508
551
  // happens to be first and by no other. Charging it to this hook would make
509
552
  // the slow-hook notice fire once per session on a healthy install.
510
- if (!(await excludeProvisioning(() => waitFn(socketPath, waitOpts), now)))
553
+ const ready = await excludeProvisioning(
554
+ () => waitFn(socketPath, waitOpts),
555
+ now,
556
+ );
557
+ daemon?.release();
558
+ const died = daemon?.failure();
559
+ if (!ready)
511
560
  throw failClosed(
512
- // The CLAMPED wait, since that is what actually elapsed: naming
513
- // WAIT_DEADLINE_MS tells an operator debugging a 200ms budget that the
514
- // redactor got seconds it was never given.
515
- new Error(`redactor daemon did not start within ${waitMs}ms`),
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
+ ),
516
569
  );
517
570
  if (budgetSpent()) throw outOfBudget("after redactor respawn");
518
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
+ }
@@ -161,9 +161,11 @@ const confusableScan = (text) =>
161
161
  const redactorIo = {
162
162
  readFile: (path) => readFileSync(path, "utf8"),
163
163
  redactMap: async (text) =>
164
- /** @type {any} */ (await redactViaDaemon(text, { map: true })),
164
+ /** @type {any} */ (
165
+ await redactViaDaemon(text, { map: true, webIngress: false })
166
+ ),
165
167
  redact: async (text) => {
166
- const out = await redactViaDaemon(text, {});
168
+ const out = await redactViaDaemon(text, { webIngress: false });
167
169
  return out ? /** @type {string} */ (out.text) : null;
168
170
  },
169
171
  };
@@ -171,11 +171,11 @@ function isUntrustedIngress(toolName) {
171
171
  * daemon. A transient daemon failure fails only THIS call — no session-wide
172
172
  * sentinel — and the client respawns a dead daemon on the next call.
173
173
  * @param {string} text
174
- * @param {boolean} [webIngress]
174
+ * @param {boolean} webIngress
175
175
  * @param {{remainingMs: () => number}} [deadline] shared wall-clock budget
176
176
  * @returns {Promise<{ text: string, found: string[] } | null>}
177
177
  */
178
- async function redactSecrets(text, webIngress = false, deadline) {
178
+ async function redactSecrets(text, webIngress, deadline) {
179
179
  if (!matchesSecretHint(text) && !hasEnvBoundSecret(text)) return null;
180
180
  // On web ingress the field name around a value is attacker-controlled, so the
181
181
  // redactor's benign-skip heuristics (metadata field / cursor / path) are a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.58.11",
3
+ "version": "2.59.0",
4
4
  "description": "Defend an agent against hidden-content injection: strip payload-capable invisible Unicode and ANSI, splice out human-invisible HTML, and flag data-exfil URLs in untrusted text before any model sees it.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -209,6 +209,7 @@
209
209
  "python/agent_sanitizer/data/invisible-charset.json",
210
210
  "python/agent_sanitizer/secrets/data/credential-names.json",
211
211
  "python/agent_sanitizer/secrets/data/redaction-floor.json",
212
+ "python/agent_sanitizer/secrets/data/redactor-protocol.json",
212
213
  "claude-hooks/*.mjs",
213
214
  "claude-hooks/lib/*.mjs",
214
215
  "claude-hooks/config/*.json",
package/python/README.md CHANGED
@@ -97,9 +97,12 @@ console scripts (installed with the extra) cover both modes:
97
97
  server that configures the plugin set **once** at startup (a warm-up scan
98
98
  primes the cache before it binds, so a bound socket means ready). Wire protocol
99
99
  both directions: a 4-byte big-endian length prefix then that many bytes of
100
- UTF-8 JSON. Request `{"text", "map", "web_ingress", "env_secrets"}`; response is
101
- the one-shot result, JSON `null` when nothing is redacted, or `{"error": …}` on
102
- a scan failure.
100
+ UTF-8 JSON, specified field by field in
101
+ `agent_sanitizer/secrets/data/redactor-protocol.json`. Every request field
102
+ (`text`, `map`, `web_ingress`, `env_secrets`) is required; the response is the
103
+ one-shot result, JSON `null` when nothing is redacted, or `{"error": …}` for a
104
+ malformed request or a scan failure. The daemon logs failures to `daemon.log`
105
+ beside the socket.
103
106
 
104
107
  Both console scripts import the engine on startup, so they fail loud if the
105
108
  `[secrets]` extra (hence `detect-secrets`) is not installed — fail closed, never
@@ -0,0 +1,17 @@
1
+ {
2
+ "description": "Wire protocol of the Layer-4 secret-redactor daemon. Both directions: a 4-byte big-endian unsigned length, then that many bytes of UTF-8 JSON, at most frame_cap bytes. The client (claude-hooks/lib/redactor-client.mjs) and the daemon (python/agent_sanitizer/secrets/daemon.py) both read this file. Every object has exactly the listed fields. Field types: string, boolean, index (a non-negative integer), string-list, string-map (an object whose values are strings), pair-list (a list of `pair` objects). A plain-mode reply may also be JSON null, meaning there was nothing to redact.",
3
+ "frame_cap": 16777216,
4
+ "request": {
5
+ "text": "string",
6
+ "map": "boolean",
7
+ "web_ingress": "boolean",
8
+ "env_secrets": "string-map"
9
+ },
10
+ "responses": {
11
+ "plain": { "text": "string", "found": "string-list" },
12
+ "map": { "text": "string", "pairs": "pair-list", "found": "string-list" },
13
+ "unmappable": { "unmappable": "string" },
14
+ "error": { "error": "string" }
15
+ },
16
+ "pair": { "placeholder": "string", "original": "string", "start": "index" }
17
+ }
@@ -38,38 +38,51 @@ export function classifySocket(socketPath: string, deps?: {
38
38
  * Open one connection, send `request`, resolve with the parsed response object
39
39
  * (or null). Rejects on connect failure, a malformed/oversize/short frame, or an
40
40
  * {error} response — every one of which the caller turns into a fail-closed. A
41
- * socket present but not owned by us fails closed WITHOUT respawning (the error
42
- * carries no errno, so isRespawnable is false), so we never dial into a squat.
41
+ * socket present but not owned by us, and a request the protocol does not
42
+ * accept, fail closed WITHOUT dialing (the error carries no errno, so
43
+ * isRespawnable is false): we never dial into a squat, and an oversize request
44
+ * is not mistaken for a dead daemon worth respawning.
43
45
  * @param {string} socketPath
44
- * @param {{text: string, map: boolean, web_ingress: boolean}} request
46
+ * @param {Record<string, unknown>} request
45
47
  * @param {number} [deadlineMs] total exchange deadline; defaults to the env-tunable value
46
48
  * @returns {Promise<RedactResponse|null>}
47
49
  */
48
- export function connectAndRequest(socketPath: string, request: {
49
- text: string;
50
- map: boolean;
51
- web_ingress: boolean;
52
- }, deadlineMs?: number): Promise<RedactResponse | null>;
50
+ export function connectAndRequest(socketPath: string, request: Record<string, unknown>, deadlineMs?: number): Promise<RedactResponse | null>;
51
+ /**
52
+ * A spawned daemon, as far as the spawning hook can see it.
53
+ * @typedef {object} DaemonHandle
54
+ * @property {() => string | undefined} failure why the daemon died before it was
55
+ * ready (its exit status and stderr tail), or undefined while it may still bind
56
+ * @property {() => void} release stop reading its stderr
57
+ */
53
58
  /**
54
59
  * Spawn the daemon detached so it outlives this hook process. The daemon's bind()
55
- * is the cross-process mutex, so a racing second spawn just exits — the spawn is
56
- * idempotent and needs no lock here.
60
+ * is the cross-process mutex, so a racing second spawn just exits 0 — the spawn
61
+ * is idempotent and needs no lock here.
62
+ *
63
+ * Its stderr is piped back so a daemon that dies on startup (an import error, a
64
+ * refused cache directory) fails the call at once with its own reason rather than
65
+ * after the whole spawn wait with none. The daemon points its stderr at a log
66
+ * file before it publishes the socket, so the pipe is only read until then.
57
67
  * @param {string} socketPath
58
68
  * @param {string[]} [command] daemon command as [argv0, ...leadingArgs]
59
69
  * (injectable so tests can drive the missing-binary arm in-process;
60
70
  * production always uses daemonCommand())
71
+ * @returns {DaemonHandle}
61
72
  */
62
- export function spawnDaemon(socketPath: string, command?: string[]): void;
73
+ export function spawnDaemon(socketPath: string, command?: string[]): DaemonHandle;
63
74
  /**
64
- * Poll until the daemon is accepting connections or the deadline passes. Probes by
65
- * connecting (not just existsSync) so it waits for listen(), not merely bind().
75
+ * Poll until the daemon is accepting connections, the deadline passes, or
76
+ * `abandon` reports the spawned daemon dead. Probes by connecting (not just
77
+ * existsSync) so it waits for listen(), not merely bind().
66
78
  * @param {string} socketPath
67
- * @param {{deadlineMs?: number, stepMs?: number}} [opts]
79
+ * @param {{deadlineMs?: number, stepMs?: number, abandon?: () => boolean}} [opts]
68
80
  * @returns {Promise<boolean>}
69
81
  */
70
- export function waitForSocket(socketPath: string, { deadlineMs, stepMs }?: {
82
+ export function waitForSocket(socketPath: string, { deadlineMs, stepMs, abandon }?: {
71
83
  deadlineMs?: number;
72
84
  stepMs?: number;
85
+ abandon?: () => boolean;
73
86
  }): Promise<boolean>;
74
87
  /**
75
88
  * Redact `text` via the daemon. Returns the response object (`{text, found}` for
@@ -83,26 +96,31 @@ export function waitForSocket(socketPath: string, { deadlineMs, stepMs }?: {
83
96
  * budget fails CLOSED without dialing — never dial with a non-positive deadline,
84
97
  * which would race and could return the raw, unvetted secret (fail open). Omitted,
85
98
  * the redactor keeps its own per-call request deadline (the standalone default).
99
+ *
100
+ * `webIngress` is required: it chooses between the name-trusting heuristics for
101
+ * local output and the stricter ones for attacker-controlled text, and a
102
+ * default would make that choice silently for any caller that forgot it.
86
103
  * @param {string} text
87
- * @param {{map?: boolean, webIngress?: boolean, socketPath?: string,
104
+ * @param {{map?: boolean, webIngress: boolean, socketPath?: string,
88
105
  * deadline?: {remainingMs: () => number},
89
- * connect?: typeof connectAndRequest, spawn?: typeof spawnDaemon,
90
- * waitForSocket?: typeof waitForSocket, now?: () => number}} [opts]
106
+ * connect?: typeof connectAndRequest,
107
+ * spawn?: (socketPath: string) => DaemonHandle | void,
108
+ * waitForSocket?: typeof waitForSocket, now?: () => number}} opts
91
109
  * @returns {Promise<RedactResponse|null>}
92
110
  */
93
- export function redactViaDaemon(text: string, opts?: {
111
+ export function redactViaDaemon(text: string, opts: {
94
112
  map?: boolean;
95
- webIngress?: boolean;
113
+ webIngress: boolean;
96
114
  socketPath?: string;
97
115
  deadline?: {
98
116
  remainingMs: () => number;
99
117
  };
100
118
  connect?: typeof connectAndRequest;
101
- spawn?: typeof spawnDaemon;
119
+ spawn?: (socketPath: string) => DaemonHandle | void;
102
120
  waitForSocket?: typeof waitForSocket;
103
121
  now?: () => number;
104
122
  }): Promise<RedactResponse | null>;
105
- export const FRAME_CAP: number;
123
+ export { FRAME_CAP };
106
124
  export { positiveMsOr } from "./knobs.mjs";
107
125
  export const DEFAULT_SOCKET_PATH: string;
108
126
  /**
@@ -120,4 +138,19 @@ export type RedactResponse = {
120
138
  }[] | undefined;
121
139
  unmappable?: string | undefined;
122
140
  };
141
+ /**
142
+ * A spawned daemon, as far as the spawning hook can see it.
143
+ */
144
+ export type DaemonHandle = {
145
+ /**
146
+ * why the daemon died before it was
147
+ * ready (its exit status and stderr tail), or undefined while it may still bind
148
+ */
149
+ failure: () => string | undefined;
150
+ /**
151
+ * stop reading its stderr
152
+ */
153
+ release: () => void;
154
+ };
123
155
  import { lstatSync } from "node:fs";
156
+ import { FRAME_CAP } from "./redactor-wire.mjs";
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @param {unknown} value
3
+ * @returns {value is Record<string, unknown>}
4
+ */
5
+ export function isPlainObject(value: unknown): value is Record<string, unknown>;
6
+ /**
7
+ * Whether `value` is an object with exactly `shape`'s fields, each of its
8
+ * declared protocol type.
9
+ * @param {unknown} value
10
+ * @param {Shape} shape
11
+ * @returns {boolean}
12
+ */
13
+ export function conforms(value: unknown, shape: Shape): boolean;
14
+ /**
15
+ * One frame carrying `value`. Throws (with no errno, so the client does not
16
+ * mistake it for a dead daemon) when the body exceeds the frame cap, which the
17
+ * peer would refuse unread.
18
+ * @param {unknown} value
19
+ * @returns {Buffer}
20
+ */
21
+ export function encodeFrame(value: unknown): Buffer;
22
+ export { PROTOCOL };
23
+ export const FRAME_CAP: number;
24
+ /**
25
+ * Reassembles one frame from the chunks a stream delivers. Holds a running
26
+ * byte total so the backlog is concatenated at most twice (header, then the
27
+ * whole frame) rather than on every chunk, which would be quadratic.
28
+ */
29
+ export class FrameReader {
30
+ /**
31
+ * Feed one chunk. Returns `{ value }` once the whole frame has arrived and
32
+ * undefined before then; throws on a header over the frame cap or a body
33
+ * that is not JSON.
34
+ * @param {Buffer} chunk
35
+ * @returns {{ value: unknown } | undefined}
36
+ */
37
+ push(chunk: Buffer): {
38
+ value: unknown;
39
+ } | undefined;
40
+ #private;
41
+ }
42
+ /**
43
+ * protocol field name -> field type
44
+ */
45
+ export type Shape = Record<string, string>;
46
+ import PROTOCOL from "../../python/agent_sanitizer/secrets/data/redactor-protocol.json" with { type: "json" };