agent-sanitizer 2.58.11 → 2.60.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/claude-hooks/lib/control-plane.mjs +6 -0
- package/claude-hooks/lib/hook-timing.mjs +67 -14
- package/claude-hooks/lib/redactor-client.mjs +146 -93
- package/claude-hooks/lib/redactor-wire.mjs +145 -0
- package/claude-hooks/pretooluse-sanitize.mjs +4 -2
- package/claude-hooks/sanitize-output.mjs +2 -2
- package/package.json +2 -1
- package/python/README.md +6 -3
- package/python/agent_sanitizer/secrets/data/redactor-protocol.json +17 -0
- package/types/claude-hooks/lib/control-plane.d.mts +5 -1
- package/types/claude-hooks/lib/hook-timing.d.mts +13 -1
- package/types/claude-hooks/lib/redactor-client.d.mts +55 -22
- package/types/claude-hooks/lib/redactor-wire.d.mts +46 -0
|
@@ -152,6 +152,9 @@ export function nativeStdout(response) {
|
|
|
152
152
|
* @param {(input: unknown) => unknown} [opts.transformInput] raw-payload normalization before adapter.parse
|
|
153
153
|
* @param {() => Promise<unknown>} [opts.readInput] injectable stdin reader
|
|
154
154
|
* @param {(chunk: string) => void} [opts.write] injectable stdout writer
|
|
155
|
+
* @param {import("./hook-timing.mjs").ReportTo} [opts.reportTo] the composer
|
|
156
|
+
* that owns this hook, which a slow-run notice names and sends the report to;
|
|
157
|
+
* omitted, the notice names this package
|
|
155
158
|
* @returns {Promise<void>}
|
|
156
159
|
*/
|
|
157
160
|
export async function runJudgeCli(
|
|
@@ -162,6 +165,7 @@ export async function runJudgeCli(
|
|
|
162
165
|
transformInput = (raw) => raw,
|
|
163
166
|
readInput = readStdinJson,
|
|
164
167
|
write = (chunk) => process.stdout.write(chunk),
|
|
168
|
+
reportTo,
|
|
165
169
|
},
|
|
166
170
|
) {
|
|
167
171
|
let input;
|
|
@@ -200,6 +204,7 @@ export async function runJudgeCli(
|
|
|
200
204
|
cpuMs: timer.cpuMs(),
|
|
201
205
|
redactorMs: timer.redactorMs(),
|
|
202
206
|
hostMs: timer.hostMs(),
|
|
207
|
+
reportTo,
|
|
203
208
|
}),
|
|
204
209
|
event,
|
|
205
210
|
),
|
|
@@ -218,6 +223,7 @@ export async function runJudgeCli(
|
|
|
218
223
|
cpuMs: timer.cpuMs(),
|
|
219
224
|
redactorMs: timer.redactorMs(),
|
|
220
225
|
hostMs: timer.hostMs(),
|
|
226
|
+
reportTo,
|
|
221
227
|
});
|
|
222
228
|
onError(err, input);
|
|
223
229
|
}
|
|
@@ -102,8 +102,10 @@ const ISSUE_URL =
|
|
|
102
102
|
* module at `claude-hooks/lib/` and
|
|
103
103
|
* carries the published version
|
|
104
104
|
*
|
|
105
|
-
* First hit wins, and
|
|
106
|
-
*
|
|
105
|
+
* First hit wins, and only a manifest naming {@link OWN_NAME} counts. The offsets
|
|
106
|
+
* alone do not exclude foreign manifests: a composer that bundles this module
|
|
107
|
+
* into its own `.claude/hooks/` puts `../../package.json` on the COMPOSER's
|
|
108
|
+
* manifest, whose version would otherwise be reported as this build's.
|
|
107
109
|
*/
|
|
108
110
|
const VERSION_MANIFESTS = [
|
|
109
111
|
"../../.claude-plugin/plugin.json",
|
|
@@ -111,6 +113,9 @@ const VERSION_MANIFESTS = [
|
|
|
111
113
|
"../../package.json",
|
|
112
114
|
];
|
|
113
115
|
|
|
116
|
+
/** The `name` both this package's package.json and its plugin.json carry. */
|
|
117
|
+
const OWN_NAME = "agent-sanitizer";
|
|
118
|
+
|
|
114
119
|
/** Strict X.Y.Z, the only shape this project's release tooling ever writes. */
|
|
115
120
|
const SEMVER = /^[0-9]+\.[0-9]+\.[0-9]+$/;
|
|
116
121
|
|
|
@@ -131,7 +136,8 @@ function readVersion() {
|
|
|
131
136
|
}
|
|
132
137
|
|
|
133
138
|
/**
|
|
134
|
-
* The strict semver `path` carries, or null when it carries none
|
|
139
|
+
* The strict semver `path` carries, or null when it carries none or names a
|
|
140
|
+
* package other than {@link OWN_NAME}.
|
|
135
141
|
*
|
|
136
142
|
* The read and the parse are caught because neither failure is this function's
|
|
137
143
|
* business: every candidate but one is absent in any given artifact, and a
|
|
@@ -147,7 +153,9 @@ function readManifest(path) {
|
|
|
147
153
|
} catch {
|
|
148
154
|
return null;
|
|
149
155
|
}
|
|
150
|
-
return SEMVER.test(manifest
|
|
156
|
+
return manifest?.name === OWN_NAME && SEMVER.test(manifest.version)
|
|
157
|
+
? manifest.version
|
|
158
|
+
: null;
|
|
151
159
|
}
|
|
152
160
|
|
|
153
161
|
/** @type {string | null | undefined} */
|
|
@@ -182,6 +190,45 @@ function versionClause(version) {
|
|
|
182
190
|
: "your agent-sanitizer version";
|
|
183
191
|
}
|
|
184
192
|
|
|
193
|
+
/**
|
|
194
|
+
* A composer that bundles this hook runtime into hooks of its own names itself
|
|
195
|
+
* here, so a slow notice from one of ITS hooks charges the cost to it and sends
|
|
196
|
+
* the report to its tracker rather than this package's.
|
|
197
|
+
* @typedef {{
|
|
198
|
+
* name: string,
|
|
199
|
+
* issueUrl: string,
|
|
200
|
+
* version?: string | null,
|
|
201
|
+
* }} ReportTo
|
|
202
|
+
*/
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Who a slow-hook notice names as the hook's owner, where it sends the report,
|
|
206
|
+
* and the version clause the report carries: this package unless `reportTo`
|
|
207
|
+
* names a composer. A composer that passes no version gets the look-it-up
|
|
208
|
+
* clause, never this package's version under its name.
|
|
209
|
+
* @param {ReportTo | undefined} reportTo
|
|
210
|
+
* @param {string | null | undefined} version see {@link versionClause}
|
|
211
|
+
*/
|
|
212
|
+
function noticeOwner(reportTo, version) {
|
|
213
|
+
if (reportTo === undefined)
|
|
214
|
+
return {
|
|
215
|
+
label: "agent-sanitizer",
|
|
216
|
+
owner: "the sanitizer",
|
|
217
|
+
issueUrl: ISSUE_URL,
|
|
218
|
+
versionClause: versionClause(version),
|
|
219
|
+
composer: undefined,
|
|
220
|
+
};
|
|
221
|
+
return {
|
|
222
|
+
label: reportTo.name,
|
|
223
|
+
owner: reportTo.name,
|
|
224
|
+
issueUrl: reportTo.issueUrl,
|
|
225
|
+
versionClause: reportTo.version
|
|
226
|
+
? `${reportTo.name} ${reportTo.version}`
|
|
227
|
+
: `your ${reportTo.name} version`,
|
|
228
|
+
composer: reportTo.name,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
185
232
|
/**
|
|
186
233
|
* Milliseconds as the seconds string every notice below prints.
|
|
187
234
|
*
|
|
@@ -229,6 +276,7 @@ export function formatBytes(bytes) {
|
|
|
229
276
|
* cpuMs?: number | null,
|
|
230
277
|
* redactorMs?: number | null,
|
|
231
278
|
* hostMs?: number | null,
|
|
279
|
+
* reportTo?: ReportTo,
|
|
232
280
|
* }} SlowHookContext
|
|
233
281
|
*/
|
|
234
282
|
|
|
@@ -555,9 +603,10 @@ export function startHookTimer(now = Date.now, cpuNow = processCpuMs) {
|
|
|
555
603
|
* @param {number} redactorMs
|
|
556
604
|
* @param {number | undefined} hostMs absent when the caller measures no host
|
|
557
605
|
* callbacks; a caller that does pass 0 when none ran
|
|
606
|
+
* @param {ReturnType<typeof noticeOwner>} owner
|
|
558
607
|
* @returns {string}
|
|
559
608
|
*/
|
|
560
|
-
function attributeWait(elapsedMs, cpuMs, redactorMs, hostMs) {
|
|
609
|
+
function attributeWait(elapsedMs, cpuMs, redactorMs, hostMs, owner) {
|
|
561
610
|
// `hostMs` absent means the caller cannot measure that window, not that it was
|
|
562
611
|
// empty — a caller that CAN measure passes 0 and gets the zero printed. So an
|
|
563
612
|
// absent one is left out of both the sentence and the remainder, and the
|
|
@@ -569,12 +618,14 @@ function attributeWait(elapsedMs, cpuMs, redactorMs, hostMs) {
|
|
|
569
618
|
redactorMs === largest
|
|
570
619
|
? "The largest share was spent inside the redactor round trip — the daemon's scan, the host it shares, or both; this hook was not computing it."
|
|
571
620
|
: hostMs !== undefined && hostMs === largest
|
|
572
|
-
?
|
|
621
|
+
? owner.composer === undefined
|
|
622
|
+
? "The largest share was spent inside a host extension this hook called — a callback the composer injected, and a cost that composer owns; neither the sanitizer nor the redactor was computing it."
|
|
623
|
+
: `The largest share was spent inside a host extension this hook called — a callback ${owner.composer} injected, and a cost ${owner.composer} owns; neither this hook nor the redactor was computing it.`
|
|
573
624
|
: cpuMs === largest
|
|
574
|
-
?
|
|
625
|
+
? `The largest share is this hook computing — a per-call cost ${owner.owner} owns, repeated by every affected call.`
|
|
575
626
|
: hostMs === undefined
|
|
576
|
-
?
|
|
577
|
-
:
|
|
627
|
+
? `The largest share is neither the redactor nor this hook computing: it was blocked on a loaded machine, on a host extension this caller does not measure, or on something else outside ${owner.owner} that it called.`
|
|
628
|
+
: `The largest share is none of those three: it was blocked on a loaded machine or on something outside ${owner.owner} that it called without measuring.`;
|
|
578
629
|
const hostClause =
|
|
579
630
|
hostMs === undefined
|
|
580
631
|
? ""
|
|
@@ -609,7 +660,8 @@ function attributeWait(elapsedMs, cpuMs, redactorMs, hostMs) {
|
|
|
609
660
|
* @param {number} [thresholdMs]
|
|
610
661
|
* @param {SlowHookContext} [context] known CPU time / payload size /
|
|
611
662
|
* triggering tool, so the notice is self-diagnosing rather than requiring the
|
|
612
|
-
* next reader to reconstruct what was slow by hand
|
|
663
|
+
* next reader to reconstruct what was slow by hand; its `reportTo` names the
|
|
664
|
+
* composer that owns the hook, and replaces the owner, tracker and version
|
|
613
665
|
* @param {string | null} [version] the build to name in the report line;
|
|
614
666
|
* omitted asks {@link sanitizerVersion}, and the shell port passes its own,
|
|
615
667
|
* read from the plugin manifest it ships beside
|
|
@@ -632,12 +684,13 @@ export function slowHookNotice(
|
|
|
632
684
|
// number, including when it is 0.
|
|
633
685
|
const hostMs =
|
|
634
686
|
typeof context?.hostMs === "number" ? context.hostMs : undefined;
|
|
687
|
+
const owner = noticeOwner(context?.reportTo, version);
|
|
635
688
|
const attribution = attributed
|
|
636
|
-
? attributeWait(elapsedMs, cpuMs, redactorMs, hostMs)
|
|
689
|
+
? attributeWait(elapsedMs, cpuMs, redactorMs, hostMs, owner)
|
|
637
690
|
: typeof cpuMs === "number"
|
|
638
691
|
? `, and used ${formatSeconds(cpuMs)}s of CPU. ` +
|
|
639
692
|
"Only the CPU share is work every affected call repeats; the rest was spent waiting, on a busy machine or on something this hook called."
|
|
640
|
-
:
|
|
693
|
+
: `. Wall-clock alone cannot separate ${owner.owner}'s own work from a busy machine.`;
|
|
641
694
|
const timings = attributed
|
|
642
695
|
? hostMs === undefined
|
|
643
696
|
? "all three timings"
|
|
@@ -646,9 +699,9 @@ export function slowHookNotice(
|
|
|
646
699
|
? "both timings"
|
|
647
700
|
: "timing";
|
|
648
701
|
return (
|
|
649
|
-
|
|
702
|
+
`${owner.label} PERFORMANCE: the ${hookName} hook took ` +
|
|
650
703
|
`${formatSeconds(elapsedMs)}s${formatContextSuffix(context)}, over its ${formatSeconds(thresholdMs)}s budget${attribution} ` +
|
|
651
|
-
`Tell the user, and suggest they report it at ${
|
|
704
|
+
`Tell the user, and suggest they report it at ${owner.issueUrl} with ${owner.versionClause}, the hook name and ${timings}.`
|
|
652
705
|
);
|
|
653
706
|
}
|
|
654
707
|
|
|
@@ -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 {
|
|
@@ -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
|
-
|
|
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
|
|
242
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
323
|
-
|
|
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
|
|
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).
|
|
352
|
-
//
|
|
353
|
-
|
|
354
|
-
|
|
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
|
|
360
|
-
*
|
|
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
|
|
447
|
+
* @param {{map?: boolean, webIngress: boolean, socketPath?: string,
|
|
409
448
|
* deadline?: {remainingMs: () => number},
|
|
410
|
-
* connect?: typeof connectAndRequest,
|
|
411
|
-
*
|
|
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
|
|
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
|
|
445
|
-
// caller reading a field the daemon didn't set
|
|
446
|
-
// on unvetted content (fail OPEN).
|
|
447
|
-
//
|
|
448
|
-
// `{
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
|
|
454
|
-
|
|
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
|
|
462
|
-
!(
|
|
502
|
+
!conforms(result, responses.map) &&
|
|
503
|
+
!conforms(result, responses.unmappable)
|
|
463
504
|
)
|
|
464
505
|
throw new Error(
|
|
465
|
-
"redactor returned a malformed map response (
|
|
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 (
|
|
510
|
+
if (result !== null && !conforms(result, responses.plain))
|
|
470
511
|
throw new Error(
|
|
471
|
-
"redactor returned a malformed plain response (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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} */ (
|
|
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}
|
|
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
|
|
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.
|
|
3
|
+
"version": "2.60.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
|
|
101
|
-
|
|
102
|
-
|
|
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
|
+
}
|
|
@@ -57,11 +57,15 @@ export function nativeStdout(response: {
|
|
|
57
57
|
* @param {(input: unknown) => unknown} [opts.transformInput] raw-payload normalization before adapter.parse
|
|
58
58
|
* @param {() => Promise<unknown>} [opts.readInput] injectable stdin reader
|
|
59
59
|
* @param {(chunk: string) => void} [opts.write] injectable stdout writer
|
|
60
|
+
* @param {import("./hook-timing.mjs").ReportTo} [opts.reportTo] the composer
|
|
61
|
+
* that owns this hook, which a slow-run notice names and sends the report to;
|
|
62
|
+
* omitted, the notice names this package
|
|
60
63
|
* @returns {Promise<void>}
|
|
61
64
|
*/
|
|
62
|
-
export function runJudgeCli(hookName: string, judge: (event: import("agent-control-plane-core").ToolCallEvent) => import("agent-control-plane-core").Verdict | Promise<import("agent-control-plane-core").Verdict>, { onError, transformInput, readInput, write, }: {
|
|
65
|
+
export function runJudgeCli(hookName: string, judge: (event: import("agent-control-plane-core").ToolCallEvent) => import("agent-control-plane-core").Verdict | Promise<import("agent-control-plane-core").Verdict>, { onError, transformInput, readInput, write, reportTo, }: {
|
|
63
66
|
onError: (err: unknown, input: unknown) => void;
|
|
64
67
|
transformInput?: ((input: unknown) => unknown) | undefined;
|
|
65
68
|
readInput?: (() => Promise<unknown>) | undefined;
|
|
66
69
|
write?: ((chunk: string) => void) | undefined;
|
|
70
|
+
reportTo?: import("./hook-timing.mjs").ReportTo | undefined;
|
|
67
71
|
}): Promise<void>;
|
|
@@ -184,7 +184,8 @@ export function startHookTimer(now?: () => number, cpuNow?: () => number): {
|
|
|
184
184
|
* @param {number} [thresholdMs]
|
|
185
185
|
* @param {SlowHookContext} [context] known CPU time / payload size /
|
|
186
186
|
* triggering tool, so the notice is self-diagnosing rather than requiring the
|
|
187
|
-
* next reader to reconstruct what was slow by hand
|
|
187
|
+
* next reader to reconstruct what was slow by hand; its `reportTo` names the
|
|
188
|
+
* composer that owns the hook, and replaces the owner, tracker and version
|
|
188
189
|
* @param {string | null} [version] the build to name in the report line;
|
|
189
190
|
* omitted asks {@link sanitizerVersion}, and the shell port passes its own,
|
|
190
191
|
* read from the plugin manifest it ships beside
|
|
@@ -303,6 +304,16 @@ export const SLOW_PROVISION_THRESHOLD_MS: 60000;
|
|
|
303
304
|
* alongside it must not be what buys that silence.
|
|
304
305
|
*/
|
|
305
306
|
export const CONCURRENT_PROVISION_CEILING_MS: number;
|
|
307
|
+
/**
|
|
308
|
+
* A composer that bundles this hook runtime into hooks of its own names itself
|
|
309
|
+
* here, so a slow notice from one of ITS hooks charges the cost to it and sends
|
|
310
|
+
* the report to its tracker rather than this package's.
|
|
311
|
+
*/
|
|
312
|
+
export type ReportTo = {
|
|
313
|
+
name: string;
|
|
314
|
+
issueUrl: string;
|
|
315
|
+
version?: string | null;
|
|
316
|
+
};
|
|
306
317
|
/**
|
|
307
318
|
* Debugging context a caller may already have in hand when a hook overruns its
|
|
308
319
|
* budget, so the notice names WHAT was slow instead of just HOW slow — the gap
|
|
@@ -320,6 +331,7 @@ export type SlowHookContext = {
|
|
|
320
331
|
cpuMs?: number | null;
|
|
321
332
|
redactorMs?: number | null;
|
|
322
333
|
hostMs?: number | null;
|
|
334
|
+
reportTo?: ReportTo;
|
|
323
335
|
};
|
|
324
336
|
/**
|
|
325
337
|
* This process's own user+system processor time so far, in milliseconds.
|
|
@@ -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
|
|
42
|
-
*
|
|
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 {
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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[]):
|
|
73
|
+
export function spawnDaemon(socketPath: string, command?: string[]): DaemonHandle;
|
|
63
74
|
/**
|
|
64
|
-
* Poll until the daemon is accepting connections
|
|
65
|
-
*
|
|
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
|
|
104
|
+
* @param {{map?: boolean, webIngress: boolean, socketPath?: string,
|
|
88
105
|
* deadline?: {remainingMs: () => number},
|
|
89
|
-
* connect?: typeof connectAndRequest,
|
|
90
|
-
*
|
|
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
|
|
113
|
+
webIngress: boolean;
|
|
96
114
|
socketPath?: string;
|
|
97
115
|
deadline?: {
|
|
98
116
|
remainingMs: () => number;
|
|
99
117
|
};
|
|
100
118
|
connect?: typeof connectAndRequest;
|
|
101
|
-
spawn?:
|
|
119
|
+
spawn?: (socketPath: string) => DaemonHandle | void;
|
|
102
120
|
waitForSocket?: typeof waitForSocket;
|
|
103
121
|
now?: () => number;
|
|
104
122
|
}): Promise<RedactResponse | null>;
|
|
105
|
-
export
|
|
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" };
|