agent-sanitizer 2.58.1 → 2.58.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude-hooks/lib/hook-io.mjs +45 -0
- package/claude-hooks/lib/hook-timing.mjs +70 -1
- package/claude-hooks/scan-invisible-chars.mjs +13 -2
- package/claude-hooks/scan-loaded-instructions.mjs +42 -17
- package/package.json +1 -1
- package/types/claude-hooks/lib/hook-io.d.mts +21 -0
- package/types/claude-hooks/lib/hook-timing.d.mts +41 -0
|
@@ -805,6 +805,51 @@ function markerLockHeld(markerPath) {
|
|
|
805
805
|
return null;
|
|
806
806
|
}
|
|
807
807
|
|
|
808
|
+
/**
|
|
809
|
+
* Is a setup process DEMONSTRABLY running right now?
|
|
810
|
+
*
|
|
811
|
+
* The strict twin of {@link probeSetupAlive}, and the two differ only in which
|
|
812
|
+
* way they fall when the evidence runs out. That one decides whether to keep
|
|
813
|
+
* WAITING for a dependency, so every ambiguity — an unreadable marker, an
|
|
814
|
+
* unparseable pid, no project dir — reads as alive: waiting a moment longer is
|
|
815
|
+
* cheap and giving up early fails a hook closed. This one decides whether to
|
|
816
|
+
* stop charging a caller for time it spent, so the same ambiguity must read as
|
|
817
|
+
* NOT running: an absence of evidence that the machine was busy is not evidence
|
|
818
|
+
* that it was, and discounting a wait on that basis would hide the very
|
|
819
|
+
* slowdowns the caller is measuring for.
|
|
820
|
+
*
|
|
821
|
+
* Positive evidence is one of two things: the setup lock is held (the kernel
|
|
822
|
+
* drops an flock the instant its holder dies, so held means running), or the
|
|
823
|
+
* marker's pid names a live process. A marker this uid does not own is not
|
|
824
|
+
* evidence about our setup at all.
|
|
825
|
+
* @param {string | null} markerPath
|
|
826
|
+
* @returns {boolean}
|
|
827
|
+
*/
|
|
828
|
+
export function setupRunning(markerPath) {
|
|
829
|
+
if (!markerIsTrusted(markerPath)) return false;
|
|
830
|
+
let raw;
|
|
831
|
+
try {
|
|
832
|
+
raw = readFileSync(/** @type {string} */ (markerPath), "utf8");
|
|
833
|
+
} catch {
|
|
834
|
+
return false;
|
|
835
|
+
}
|
|
836
|
+
const lines = raw.split("\n").map((line) => line.trim());
|
|
837
|
+
if (lines.includes(SETUP_LOCK_DECLARATION)) {
|
|
838
|
+
const held = markerLockHeld(/** @type {string} */ (markerPath));
|
|
839
|
+
if (held !== null) return held;
|
|
840
|
+
}
|
|
841
|
+
const pid = parseInt(lines[0], 10);
|
|
842
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
843
|
+
try {
|
|
844
|
+
process.kill(pid, 0);
|
|
845
|
+
return true;
|
|
846
|
+
} catch (err) {
|
|
847
|
+
// EPERM: the pid exists but belongs to another uid. It is running, which is
|
|
848
|
+
// what this answers; whose it is is markerIsTrusted's question, asked above.
|
|
849
|
+
return /** @type {NodeJS.ErrnoException} */ (err).code === "EPERM";
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
808
853
|
/**
|
|
809
854
|
* Is the setup process that wrote `markerPath` still alive?
|
|
810
855
|
*
|
|
@@ -27,7 +27,12 @@
|
|
|
27
27
|
*
|
|
28
28
|
* ONE-TIME PROVISIONING is excluded (see {@link excludeProvisioning}): charging
|
|
29
29
|
* an install to the hook that merely waited it out would make the FIRST call of
|
|
30
|
-
* every session cry wolf, which is the alert fatigue this notice fights.
|
|
30
|
+
* every session cry wolf, which is the alert fatigue this notice fights. A
|
|
31
|
+
* provisioning step running in ANOTHER process is excluded the same way and for
|
|
32
|
+
* the same reason ({@link excludeConcurrentProvisioning}) — a launch hook's
|
|
33
|
+
* install saturates the machine every other hook is sharing, and a wait spent
|
|
34
|
+
* inside a window the caller can show was provisioning is no more this hook's
|
|
35
|
+
* cost than its own install is.
|
|
31
36
|
*
|
|
32
37
|
* Dependency-free on purpose: everything imports this, including hook-io, so a
|
|
33
38
|
* back-import would close a cycle. The one emitter it needs is passed in. The
|
|
@@ -63,6 +68,19 @@ export const SLOW_HOOK_THRESHOLD_MS = 1000;
|
|
|
63
68
|
*/
|
|
64
69
|
export const SLOW_PROVISION_THRESHOLD_MS = 60000;
|
|
65
70
|
|
|
71
|
+
/**
|
|
72
|
+
* The longest window {@link excludeConcurrentProvisioning} will discount.
|
|
73
|
+
*
|
|
74
|
+
* Ten times the hook budget, because the discount's whole premise is that the
|
|
75
|
+
* hook's work is small and the machine is busy: an instruction scan is tens of
|
|
76
|
+
* milliseconds of work, so a wait this far past its budget is not a busy box any
|
|
77
|
+
* more, whatever else is installing. Past the ceiling the run is measured in
|
|
78
|
+
* full and reports — the founding case of this module is a SessionStart scan
|
|
79
|
+
* that blocked startup for 30 SECONDS, and a cold-start install running
|
|
80
|
+
* alongside it must not be what buys that silence.
|
|
81
|
+
*/
|
|
82
|
+
export const CONCURRENT_PROVISION_CEILING_MS = 10 * SLOW_HOOK_THRESHOLD_MS;
|
|
83
|
+
|
|
66
84
|
/** Where a reader is asked to send the timing. */
|
|
67
85
|
const ISSUE_URL =
|
|
68
86
|
"https://github.com/AlexanderMattTurner/agent-sanitizer/issues/new";
|
|
@@ -408,6 +426,57 @@ export async function excludeProvisioning(
|
|
|
408
426
|
}
|
|
409
427
|
}
|
|
410
428
|
|
|
429
|
+
/**
|
|
430
|
+
* Run `work`, charging its duration to provisioning when a SESSION-LEVEL
|
|
431
|
+
* provisioning step was in flight for the whole of it — a neighbouring hook's
|
|
432
|
+
* dependency install, which saturates the machine this hook is only sharing.
|
|
433
|
+
* {@link excludeProvisioning} discounts the wait this process performs itself;
|
|
434
|
+
* this discounts the one it merely runs alongside.
|
|
435
|
+
*
|
|
436
|
+
* `setupAlive` is asked twice, before and after, and only a step alive at BOTH
|
|
437
|
+
* ends is charged. A step that started or finished mid-run leaves a window
|
|
438
|
+
* nothing here can apportion, and splitting it by guess would discount the
|
|
439
|
+
* hook's own work — so that run is measured in full and reports honestly.
|
|
440
|
+
*
|
|
441
|
+
* Wall-clock only, where {@link excludeProvisioning} charges CPU too: the
|
|
442
|
+
* install runs in ANOTHER process, so none of it lands in this one's CPU figure,
|
|
443
|
+
* and charging CPU here would discount the hook's own computing.
|
|
444
|
+
*
|
|
445
|
+
* Bounded by {@link CONCURRENT_PROVISION_CEILING_MS}, which is what stops the
|
|
446
|
+
* discount from hiding a wedged run: a window past the ceiling is charged to
|
|
447
|
+
* nobody but the hook, however busy the machine was, because at that magnitude
|
|
448
|
+
* the hook is the thing that is broken.
|
|
449
|
+
* @template T
|
|
450
|
+
* @param {() => Promise<T>} work
|
|
451
|
+
* @param {() => boolean} setupAlive whether a session-level provisioning step
|
|
452
|
+
* is running right now; the caller owns the evidence (a cold-start marker and
|
|
453
|
+
* its PID), since this module reads no files of its own
|
|
454
|
+
* @param {() => number} [now] injectable clock, for tests
|
|
455
|
+
* @returns {Promise<T>}
|
|
456
|
+
*/
|
|
457
|
+
export async function excludeConcurrentProvisioning(
|
|
458
|
+
work,
|
|
459
|
+
setupAlive,
|
|
460
|
+
now = Date.now,
|
|
461
|
+
) {
|
|
462
|
+
const startedAlive = setupAlive();
|
|
463
|
+
const started = now();
|
|
464
|
+
try {
|
|
465
|
+
return await work();
|
|
466
|
+
} finally {
|
|
467
|
+
// In a `finally`, like every other charge here: a scan that THREW still
|
|
468
|
+
// waited out whatever the machine was doing, and the fault it reports is a
|
|
469
|
+
// separate matter from how long the wait was.
|
|
470
|
+
const elapsed = Math.max(0, now() - started);
|
|
471
|
+
if (
|
|
472
|
+
startedAlive &&
|
|
473
|
+
elapsed <= CONCURRENT_PROVISION_CEILING_MS &&
|
|
474
|
+
setupAlive()
|
|
475
|
+
)
|
|
476
|
+
provisioningMs += elapsed;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
411
480
|
/**
|
|
412
481
|
* Start measuring; each reader on the returned object reports what has elapsed
|
|
413
482
|
* so far MINUS any provisioning charged in the meantime, and may be called more
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
lazyImport,
|
|
34
34
|
markerIsTrusted,
|
|
35
35
|
probeSetupAlive,
|
|
36
|
+
setupRunning,
|
|
36
37
|
PROJECT_DIR,
|
|
37
38
|
readStdinJson,
|
|
38
39
|
} from "./lib/hook-io.mjs";
|
|
@@ -52,7 +53,11 @@ import { formatReport } from "./lib/invisible-report.mjs";
|
|
|
52
53
|
import { sweepStaleReveals } from "./lib/reveal.mjs";
|
|
53
54
|
import { sweepStaleConfirms } from "./lib/secret-drop-guard.mjs";
|
|
54
55
|
import { hookTrace, TraceEvent } from "./lib/trace.mjs";
|
|
55
|
-
import {
|
|
56
|
+
import {
|
|
57
|
+
excludeConcurrentProvisioning,
|
|
58
|
+
reportSlowHook,
|
|
59
|
+
startHookTimer,
|
|
60
|
+
} from "./lib/hook-timing.mjs";
|
|
56
61
|
// Relative, not the `agent-sanitizer` specifier every other engine import uses:
|
|
57
62
|
// this is the scan's SCOPE, which is hook policy, and package.json's exports map
|
|
58
63
|
// deliberately does not publish it — routing it through the specifier would fail
|
|
@@ -404,7 +409,13 @@ export async function cliMain(opts = {}) {
|
|
|
404
409
|
// into a bug report (see lib/hook-timing.mjs).
|
|
405
410
|
const timer = startHookTimer();
|
|
406
411
|
try {
|
|
407
|
-
|
|
412
|
+
// Charged to provisioning when the session's setup is installing THROUGHOUT
|
|
413
|
+
// this run: that install saturates the machine, and a scan that merely
|
|
414
|
+
// waited it out has no per-call cost to report (see hook-timing.mjs).
|
|
415
|
+
await excludeConcurrentProvisioning(
|
|
416
|
+
() => runScanCli(opts),
|
|
417
|
+
() => setupRunning(hookgateMarkerPath()),
|
|
418
|
+
);
|
|
408
419
|
} finally {
|
|
409
420
|
reportSlowHook(
|
|
410
421
|
HOOK_NAME,
|
|
@@ -21,12 +21,14 @@ import { readFileSync } from "node:fs";
|
|
|
21
21
|
import {
|
|
22
22
|
emitHookResponse,
|
|
23
23
|
EmptyStdinError,
|
|
24
|
+
hookgateMarkerPath,
|
|
24
25
|
HookEvent,
|
|
25
26
|
isMain,
|
|
26
27
|
lazyImport,
|
|
27
28
|
PROJECT_DIR,
|
|
28
29
|
readStdinJson,
|
|
29
30
|
safeErrMessage,
|
|
31
|
+
setupRunning,
|
|
30
32
|
} from "./lib/hook-io.mjs";
|
|
31
33
|
import {
|
|
32
34
|
registerFaultPolicy,
|
|
@@ -38,7 +40,11 @@ import {
|
|
|
38
40
|
recordInstructionsLoaded,
|
|
39
41
|
} from "./lib/invisible-alert.mjs";
|
|
40
42
|
import { hookTrace, TraceEvent } from "./lib/trace.mjs";
|
|
41
|
-
import {
|
|
43
|
+
import {
|
|
44
|
+
excludeConcurrentProvisioning,
|
|
45
|
+
reportSlowHook,
|
|
46
|
+
startHookTimer,
|
|
47
|
+
} from "./lib/hook-timing.mjs";
|
|
42
48
|
import { formatReport } from "./lib/invisible-report.mjs";
|
|
43
49
|
import {
|
|
44
50
|
contextScopeContradiction,
|
|
@@ -259,6 +265,41 @@ export { HOOK_NAME };
|
|
|
259
265
|
export async function cliMain({ trace: sink } = {}) {
|
|
260
266
|
const timer = startHookTimer();
|
|
261
267
|
const emitTrace = hookTrace(sink);
|
|
268
|
+
try {
|
|
269
|
+
// Charged to provisioning when the session's setup is installing THROUGHOUT
|
|
270
|
+
// this run: that install saturates the machine, and a scan that merely
|
|
271
|
+
// waited it out has no per-call cost to report (see hook-timing.mjs).
|
|
272
|
+
await excludeConcurrentProvisioning(
|
|
273
|
+
() => runLoadedScanCli(emitTrace),
|
|
274
|
+
() => setupRunning(hookgateMarkerPath()),
|
|
275
|
+
);
|
|
276
|
+
} finally {
|
|
277
|
+
reportSlowHook(
|
|
278
|
+
HOOK_NAME,
|
|
279
|
+
timer.wallMs(),
|
|
280
|
+
HookEvent.INSTRUCTIONS_LOADED,
|
|
281
|
+
emitHookResponse,
|
|
282
|
+
undefined,
|
|
283
|
+
// All four windows, including the two this scan normally leaves empty: a
|
|
284
|
+
// measured 0 rules a window OUT, where an omitted one leaves the notice
|
|
285
|
+
// naming candidates it cannot separate.
|
|
286
|
+
{
|
|
287
|
+
cpuMs: timer.cpuMs(),
|
|
288
|
+
redactorMs: timer.redactorMs(),
|
|
289
|
+
hostMs: timer.hostMs(),
|
|
290
|
+
},
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The scan itself. Split from {@link cliMain} so the timing wrapper above has a
|
|
297
|
+
* single call to bracket — every early return here is an exit that wrapper must
|
|
298
|
+
* still measure.
|
|
299
|
+
* @param {import("./lib/trace.mjs").TraceFn} emitTrace
|
|
300
|
+
* @returns {Promise<void>}
|
|
301
|
+
*/
|
|
302
|
+
async function runLoadedScanCli(emitTrace) {
|
|
262
303
|
/** @type {string | undefined} */
|
|
263
304
|
let sessionId;
|
|
264
305
|
try {
|
|
@@ -314,22 +355,6 @@ export async function cliMain({ trace: sink } = {}) {
|
|
|
314
355
|
// rather than in no store at all.
|
|
315
356
|
if (outcome.armAlert)
|
|
316
357
|
appendAlert(/** @type {string} */ (outcome.stderr), sessionId);
|
|
317
|
-
} finally {
|
|
318
|
-
reportSlowHook(
|
|
319
|
-
HOOK_NAME,
|
|
320
|
-
timer.wallMs(),
|
|
321
|
-
HookEvent.INSTRUCTIONS_LOADED,
|
|
322
|
-
emitHookResponse,
|
|
323
|
-
undefined,
|
|
324
|
-
// All four windows, including the two this scan normally leaves empty: a
|
|
325
|
-
// measured 0 rules a window OUT, where an omitted one leaves the notice
|
|
326
|
-
// naming candidates it cannot separate.
|
|
327
|
-
{
|
|
328
|
-
cpuMs: timer.cpuMs(),
|
|
329
|
-
redactorMs: timer.redactorMs(),
|
|
330
|
-
hostMs: timer.hostMs(),
|
|
331
|
-
},
|
|
332
|
-
);
|
|
333
358
|
}
|
|
334
359
|
}
|
|
335
360
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.58.
|
|
3
|
+
"version": "2.58.3",
|
|
4
4
|
"description": "Defend an agent against hidden-content injection: strip payload-capable invisible Unicode and ANSI, splice out human-invisible HTML, and flag data-exfil URLs in untrusted text before any model sees it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -329,6 +329,27 @@ export function configureHookgateMarker(path: string | null): void;
|
|
|
329
329
|
* @returns {string | null}
|
|
330
330
|
*/
|
|
331
331
|
export function hookgateMarkerPath(projectDir?: string | undefined, runtimeDir?: string | undefined): string | null;
|
|
332
|
+
/**
|
|
333
|
+
* Is a setup process DEMONSTRABLY running right now?
|
|
334
|
+
*
|
|
335
|
+
* The strict twin of {@link probeSetupAlive}, and the two differ only in which
|
|
336
|
+
* way they fall when the evidence runs out. That one decides whether to keep
|
|
337
|
+
* WAITING for a dependency, so every ambiguity — an unreadable marker, an
|
|
338
|
+
* unparseable pid, no project dir — reads as alive: waiting a moment longer is
|
|
339
|
+
* cheap and giving up early fails a hook closed. This one decides whether to
|
|
340
|
+
* stop charging a caller for time it spent, so the same ambiguity must read as
|
|
341
|
+
* NOT running: an absence of evidence that the machine was busy is not evidence
|
|
342
|
+
* that it was, and discounting a wait on that basis would hide the very
|
|
343
|
+
* slowdowns the caller is measuring for.
|
|
344
|
+
*
|
|
345
|
+
* Positive evidence is one of two things: the setup lock is held (the kernel
|
|
346
|
+
* drops an flock the instant its holder dies, so held means running), or the
|
|
347
|
+
* marker's pid names a live process. A marker this uid does not own is not
|
|
348
|
+
* evidence about our setup at all.
|
|
349
|
+
* @param {string | null} markerPath
|
|
350
|
+
* @returns {boolean}
|
|
351
|
+
*/
|
|
352
|
+
export function setupRunning(markerPath: string | null): boolean;
|
|
332
353
|
/**
|
|
333
354
|
* Is the setup process that wrote `markerPath` still alive?
|
|
334
355
|
*
|
|
@@ -104,6 +104,35 @@ export function chargeHostExtensionSync<T>(work: () => T, now?: () => number, cp
|
|
|
104
104
|
* @returns {Promise<T>}
|
|
105
105
|
*/
|
|
106
106
|
export function excludeProvisioning<T>(work: () => Promise<T>, now?: () => number, cpuNow?: () => number): Promise<T>;
|
|
107
|
+
/**
|
|
108
|
+
* Run `work`, charging its duration to provisioning when a SESSION-LEVEL
|
|
109
|
+
* provisioning step was in flight for the whole of it — a neighbouring hook's
|
|
110
|
+
* dependency install, which saturates the machine this hook is only sharing.
|
|
111
|
+
* {@link excludeProvisioning} discounts the wait this process performs itself;
|
|
112
|
+
* this discounts the one it merely runs alongside.
|
|
113
|
+
*
|
|
114
|
+
* `setupAlive` is asked twice, before and after, and only a step alive at BOTH
|
|
115
|
+
* ends is charged. A step that started or finished mid-run leaves a window
|
|
116
|
+
* nothing here can apportion, and splitting it by guess would discount the
|
|
117
|
+
* hook's own work — so that run is measured in full and reports honestly.
|
|
118
|
+
*
|
|
119
|
+
* Wall-clock only, where {@link excludeProvisioning} charges CPU too: the
|
|
120
|
+
* install runs in ANOTHER process, so none of it lands in this one's CPU figure,
|
|
121
|
+
* and charging CPU here would discount the hook's own computing.
|
|
122
|
+
*
|
|
123
|
+
* Bounded by {@link CONCURRENT_PROVISION_CEILING_MS}, which is what stops the
|
|
124
|
+
* discount from hiding a wedged run: a window past the ceiling is charged to
|
|
125
|
+
* nobody but the hook, however busy the machine was, because at that magnitude
|
|
126
|
+
* the hook is the thing that is broken.
|
|
127
|
+
* @template T
|
|
128
|
+
* @param {() => Promise<T>} work
|
|
129
|
+
* @param {() => boolean} setupAlive whether a session-level provisioning step
|
|
130
|
+
* is running right now; the caller owns the evidence (a cold-start marker and
|
|
131
|
+
* its PID), since this module reads no files of its own
|
|
132
|
+
* @param {() => number} [now] injectable clock, for tests
|
|
133
|
+
* @returns {Promise<T>}
|
|
134
|
+
*/
|
|
135
|
+
export function excludeConcurrentProvisioning<T>(work: () => Promise<T>, setupAlive: () => boolean, now?: () => number): Promise<T>;
|
|
107
136
|
/**
|
|
108
137
|
* Start measuring; each reader on the returned object reports what has elapsed
|
|
109
138
|
* so far MINUS any provisioning charged in the meantime, and may be called more
|
|
@@ -262,6 +291,18 @@ export const SLOW_HOOK_THRESHOLD_MS: 1000;
|
|
|
262
291
|
* re-provisioning every session), which is worth saying out loud.
|
|
263
292
|
*/
|
|
264
293
|
export const SLOW_PROVISION_THRESHOLD_MS: 60000;
|
|
294
|
+
/**
|
|
295
|
+
* The longest window {@link excludeConcurrentProvisioning} will discount.
|
|
296
|
+
*
|
|
297
|
+
* Ten times the hook budget, because the discount's whole premise is that the
|
|
298
|
+
* hook's work is small and the machine is busy: an instruction scan is tens of
|
|
299
|
+
* milliseconds of work, so a wait this far past its budget is not a busy box any
|
|
300
|
+
* more, whatever else is installing. Past the ceiling the run is measured in
|
|
301
|
+
* full and reports — the founding case of this module is a SessionStart scan
|
|
302
|
+
* that blocked startup for 30 SECONDS, and a cold-start install running
|
|
303
|
+
* alongside it must not be what buys that silence.
|
|
304
|
+
*/
|
|
305
|
+
export const CONCURRENT_PROVISION_CEILING_MS: number;
|
|
265
306
|
/**
|
|
266
307
|
* Debugging context a caller may already have in hand when a hook overruns its
|
|
267
308
|
* budget, so the notice names WHAT was slow instead of just HOW slow — the gap
|