agent-sanitizer 2.43.12 → 2.44.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/README.md +8 -0
- package/claude-hooks/lib/hook-io.mjs +69 -9
- package/claude-hooks/lib/invisible-alert.mjs +207 -92
- package/claude-hooks/lib/redactor-client.mjs +15 -12
- package/claude-hooks/lib/reveal.mjs +49 -2
- package/claude-hooks/lib/secret-drop-guard.mjs +44 -3
- package/claude-hooks/plugin-hooks.mjs +5 -2
- package/claude-hooks/pretooluse-sanitize.mjs +25 -11
- package/claude-hooks/scan-invisible-chars.mjs +59 -32
- package/claude-hooks/scan-loaded-instructions.mjs +10 -3
- package/package.json +1 -1
- package/types/claude-hooks/lib/hook-io.d.mts +33 -7
- package/types/claude-hooks/lib/invisible-alert.d.mts +101 -47
- package/types/claude-hooks/lib/reveal.d.mts +16 -0
- package/types/claude-hooks/lib/secret-drop-guard.d.mts +11 -0
- package/types/claude-hooks/scan-invisible-chars.d.mts +18 -3
|
@@ -21,8 +21,11 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { createHash } from "node:crypto";
|
|
23
23
|
import {
|
|
24
|
+
existsSync,
|
|
24
25
|
mkdirSync,
|
|
25
26
|
lstatSync,
|
|
27
|
+
readdirSync,
|
|
28
|
+
unlinkSync,
|
|
26
29
|
openSync,
|
|
27
30
|
readFileSync,
|
|
28
31
|
closeSync,
|
|
@@ -30,7 +33,7 @@ import {
|
|
|
30
33
|
} from "node:fs";
|
|
31
34
|
import { tmpdir, userInfo } from "node:os";
|
|
32
35
|
import { join, resolve, sep } from "node:path";
|
|
33
|
-
import { writeFileNoFollow } from "./hook-io.mjs";
|
|
36
|
+
import { PROJECT_HASH, writeFileNoFollow } from "./hook-io.mjs";
|
|
34
37
|
|
|
35
38
|
/**
|
|
36
39
|
* Where reveal sidecars are stored. Exported so the PreToolUse placeholder
|
|
@@ -39,12 +42,56 @@ import { writeFileNoFollow } from "./hook-io.mjs";
|
|
|
39
42
|
* @returns {string}
|
|
40
43
|
*/
|
|
41
44
|
export function revealDir() {
|
|
45
|
+
// Project-keyed like every other $TMPDIR store: one unkeyed directory is shared
|
|
46
|
+
// by every project on the machine, so one project's sweep ages out another's
|
|
47
|
+
// spans and a rehydration that should have restored a placeholder fails closed
|
|
48
|
+
// instead. The 12-hex span KEY inside the directory is untouched — it is the
|
|
49
|
+
// published placeholder grammar, not a path detail.
|
|
42
50
|
return (
|
|
43
51
|
process.env._AGENT_SANITIZER_REVEAL_DIR ||
|
|
44
|
-
join(tmpdir(),
|
|
52
|
+
join(tmpdir(), `agent-sanitizer-layer2-reveal-${PROJECT_HASH}`)
|
|
45
53
|
);
|
|
46
54
|
}
|
|
47
55
|
|
|
56
|
+
/**
|
|
57
|
+
* How long a reveal sidecar or span file is kept before a later session sweeps it.
|
|
58
|
+
* It must outlast the longest session that could still rehydrate a placeholder the
|
|
59
|
+
* model is holding, so it is generous rather than tight — these files are small,
|
|
60
|
+
* and the cost of sweeping one too early is a rehydration that fails closed.
|
|
61
|
+
*/
|
|
62
|
+
export const REVEAL_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Delete this project's reveal sidecars and spans older than {@link REVEAL_TTL_MS}.
|
|
66
|
+
*
|
|
67
|
+
* Nothing else removes them: every entry is content-addressed, so a store that is
|
|
68
|
+
* never swept grows for the life of the machine. Called from SessionStart, the one
|
|
69
|
+
* touchpoint that runs once per session rather than once per tool call.
|
|
70
|
+
* @returns {void}
|
|
71
|
+
*/
|
|
72
|
+
export function sweepStaleReveals() {
|
|
73
|
+
const dir = revealDir();
|
|
74
|
+
// existsSync first: revealDirIsSafe CREATES the directory, and a sweep run on
|
|
75
|
+
// every session start must not leave an empty store behind for a project that
|
|
76
|
+
// never spliced anything.
|
|
77
|
+
if (!existsSync(dir) || !revealDirIsSafe(dir)) return;
|
|
78
|
+
const cutoff = Date.now() - REVEAL_TTL_MS;
|
|
79
|
+
for (const name of readdirSync(dir)) {
|
|
80
|
+
const path = join(dir, name);
|
|
81
|
+
try {
|
|
82
|
+
// lstat, not stat: a squatted symlink at a precomputable path is judged on
|
|
83
|
+
// ITSELF, and unlink removes the link rather than its target.
|
|
84
|
+
if (lstatSync(path).mtimeMs < cutoff) unlinkSync(path);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
// ENOENT: a parallel session swept this entry between readdir and lstat.
|
|
87
|
+
// EPERM/EACCES: a co-tenant's entry, which is not ours to remove. Anything
|
|
88
|
+
// else is a bug in this sweep and propagates.
|
|
89
|
+
const code = /** @type {NodeJS.ErrnoException} */ (err).code;
|
|
90
|
+
if (code !== "ENOENT" && code !== "EPERM" && code !== "EACCES") throw err;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
48
95
|
/**
|
|
49
96
|
* Content-addressed path the pre-splice text of `content` is stored at.
|
|
50
97
|
* @param {string} content
|
|
@@ -28,12 +28,16 @@
|
|
|
28
28
|
* pass costs only what today's behavior already allows.
|
|
29
29
|
*/
|
|
30
30
|
import { createHash } from "node:crypto";
|
|
31
|
-
import { lstatSync, unlinkSync } from "node:fs";
|
|
31
|
+
import { lstatSync, readdirSync, unlinkSync } from "node:fs";
|
|
32
32
|
import { join, dirname } from "node:path";
|
|
33
33
|
import { tmpdir } from "node:os";
|
|
34
34
|
import { spawnSync } from "node:child_process";
|
|
35
|
-
import {
|
|
36
|
-
|
|
35
|
+
import {
|
|
36
|
+
lazyImport,
|
|
37
|
+
markerIsTrusted,
|
|
38
|
+
PROJECT_HASH,
|
|
39
|
+
writeSentinelFile,
|
|
40
|
+
} from "./hook-io.mjs";
|
|
37
41
|
|
|
38
42
|
// Layer-1 view primitives, bound via lazyImport (see its doc for the fail-OPEN
|
|
39
43
|
// hazard of a bare static npm import): a load failure leaves these undefined,
|
|
@@ -75,6 +79,37 @@ export function confirmMarkerPath(fingerprint) {
|
|
|
75
79
|
return join(tmpdir(), `.claude-secret-drop-${PROJECT_HASH}-${fingerprint}`);
|
|
76
80
|
}
|
|
77
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Delete this project's confirm sentinels older than {@link CONFIRM_TTL_MS}.
|
|
84
|
+
*
|
|
85
|
+
* consumeConfirm removes the sentinel it honors, but an abandoned confirmation —
|
|
86
|
+
* denied, never retried — is removed by nothing, so the store grows for the life
|
|
87
|
+
* of the machine. Called from SessionStart, the one touchpoint that runs once per
|
|
88
|
+
* session rather than once per tool call. A sentinel past the TTL is already inert
|
|
89
|
+
* (consumeConfirm refuses it), so this reclaims space without changing a verdict.
|
|
90
|
+
* @returns {void}
|
|
91
|
+
*/
|
|
92
|
+
export function sweepStaleConfirms() {
|
|
93
|
+
const dir = tmpdir();
|
|
94
|
+
const prefix = `.claude-secret-drop-${PROJECT_HASH}-`;
|
|
95
|
+
const cutoff = Date.now() - CONFIRM_TTL_MS;
|
|
96
|
+
for (const name of readdirSync(dir)) {
|
|
97
|
+
if (!name.startsWith(prefix)) continue;
|
|
98
|
+
const path = join(dir, name);
|
|
99
|
+
try {
|
|
100
|
+
// lstat, not stat: a squatted symlink at a predictable path is judged on
|
|
101
|
+
// ITSELF, and unlink removes the link rather than its target.
|
|
102
|
+
if (lstatSync(path).mtimeMs < cutoff) unlinkSync(path);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
// ENOENT: a parallel session swept this entry between readdir and lstat.
|
|
105
|
+
// EPERM/EACCES: a co-tenant's entry, which is not ours to remove. Anything
|
|
106
|
+
// else is a bug in this sweep and propagates.
|
|
107
|
+
const code = /** @type {NodeJS.ErrnoException} */ (err).code;
|
|
108
|
+
if (code !== "ENOENT" && code !== "EPERM" && code !== "EACCES") throw err;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
78
113
|
/**
|
|
79
114
|
* Whether git tracks `filePath`. Exit 0 is tracked; exit 1 (untracked) and 128
|
|
80
115
|
* (not a repository) both mean "no git recovery exists", which is what the
|
|
@@ -165,6 +200,12 @@ export async function secretDropGuard(toolInput, io, opts = {}) {
|
|
|
165
200
|
if (dropped.length === 0) return null;
|
|
166
201
|
|
|
167
202
|
const fingerprint = dropFingerprint(filePath, content, dropped);
|
|
203
|
+
// THE INVARIANT THIS ENFORCES: a confirmation approves exactly one drop —
|
|
204
|
+
// the identical (path, bytes, drop-set) — and only for CONFIRM_TTL_MS. The
|
|
205
|
+
// window must outlive a model retry (the confirmation IS the retry, so a
|
|
206
|
+
// shorter one would deny forever) and must not outlive the session's working
|
|
207
|
+
// context, or a marker left by an abandoned attempt becomes a standing
|
|
208
|
+
// auto-approval for a Write the user never saw.
|
|
168
209
|
if (confirmSeen(fingerprint)) return null;
|
|
169
210
|
recordConfirm(fingerprint);
|
|
170
211
|
return { deny: dropDeny(dropped.length, filePath) };
|
|
@@ -154,11 +154,14 @@ const HOOKS = {
|
|
|
154
154
|
"scan-invisible-chars": {
|
|
155
155
|
event: HookEvent.SESSION_START,
|
|
156
156
|
run: async () => {
|
|
157
|
-
const { cliMain } =
|
|
157
|
+
const { cliMain, sessionIdFromStdin } =
|
|
158
158
|
/** @type {typeof import("./scan-invisible-chars.mjs")} */ (
|
|
159
159
|
await import("./scan-invisible-chars.mjs")
|
|
160
160
|
);
|
|
161
|
-
|
|
161
|
+
// The SessionStart payload carries the session identity the alert store is
|
|
162
|
+
// keyed by; without it every session shares one store and inherits the
|
|
163
|
+
// previous one's gate acknowledgement.
|
|
164
|
+
await cliMain({ sessionId: await sessionIdFromStdin() });
|
|
162
165
|
},
|
|
163
166
|
},
|
|
164
167
|
"scan-loaded-instructions": {
|
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
gateReminderContext,
|
|
56
56
|
alertAcknowledged,
|
|
57
57
|
acknowledgeAlert,
|
|
58
|
+
recordInstructionsLoadedNotice,
|
|
58
59
|
instructionsLoadedGapNotice,
|
|
59
60
|
} from "./lib/invisible-alert.mjs";
|
|
60
61
|
import {
|
|
@@ -448,10 +449,10 @@ export async function buildPreToolUseResponse(
|
|
|
448
449
|
// Layer 1: gate. Persists across the session until the injected files are
|
|
449
450
|
// cleaned. It asks ONCE (a hard checkpoint, recorded once emitted) then
|
|
450
451
|
// degrades to a passive reminder, so it doesn't prompt on every tool call.
|
|
451
|
-
const findings = invisibleCharAlert();
|
|
452
|
+
const findings = invisibleCharAlert(input.session_id);
|
|
452
453
|
let pendingGateAck = false;
|
|
453
454
|
if (findings) {
|
|
454
|
-
if (alertAcknowledged()) {
|
|
455
|
+
if (alertAcknowledged(input.session_id)) {
|
|
455
456
|
contexts.push(gateReminderContext());
|
|
456
457
|
} else {
|
|
457
458
|
asks.push(gateAskReason(findings));
|
|
@@ -461,11 +462,11 @@ export async function buildPreToolUseResponse(
|
|
|
461
462
|
|
|
462
463
|
const { tool_name: tool, tool_input: toolInput } = input;
|
|
463
464
|
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
//
|
|
468
|
-
// that
|
|
465
|
+
// Reported here because this is the first hook that runs after the launch-time
|
|
466
|
+
// loads would have happened. assembleResponse — not this call — records that
|
|
467
|
+
// the notice was surfaced: a rehydrate deny below returns before the response
|
|
468
|
+
// is assembled, and recording here would burn the session's one report on a
|
|
469
|
+
// call that never carried it.
|
|
469
470
|
const gapNotice = instructionsLoadedGapNotice(input.session_id);
|
|
470
471
|
if (gapNotice !== null) contexts.push(gapNotice);
|
|
471
472
|
|
|
@@ -504,15 +505,25 @@ export async function buildPreToolUseResponse(
|
|
|
504
505
|
return emitTraced(
|
|
505
506
|
emitTrace,
|
|
506
507
|
input.tool_name,
|
|
507
|
-
assembleResponse({
|
|
508
|
+
assembleResponse({
|
|
509
|
+
changed,
|
|
510
|
+
current,
|
|
511
|
+
asks,
|
|
512
|
+
contexts,
|
|
513
|
+
pendingGateAck,
|
|
514
|
+
pendingGapNotice: gapNotice !== null,
|
|
515
|
+
sessionId: input.session_id,
|
|
516
|
+
}),
|
|
508
517
|
);
|
|
509
518
|
}
|
|
510
519
|
|
|
511
520
|
/**
|
|
512
521
|
* Assemble the hookSpecificOutput fields from the per-layer results, or null
|
|
513
522
|
* for a clean no-op (nothing asked, changed, or annotated). Records the gate
|
|
514
|
-
* acknowledgement only when
|
|
515
|
-
*
|
|
523
|
+
* acknowledgement and the coverage-gap notice only when they actually land in
|
|
524
|
+
* the response.
|
|
525
|
+
* @param {{ changed: boolean, current: any, asks: string[], contexts: string[],
|
|
526
|
+
* pendingGateAck: boolean, pendingGapNotice: boolean, sessionId?: string }} parts
|
|
516
527
|
* @returns {Record<string, unknown> | null}
|
|
517
528
|
*/
|
|
518
529
|
function assembleResponse({
|
|
@@ -521,6 +532,8 @@ function assembleResponse({
|
|
|
521
532
|
asks,
|
|
522
533
|
contexts,
|
|
523
534
|
pendingGateAck,
|
|
535
|
+
pendingGapNotice,
|
|
536
|
+
sessionId,
|
|
524
537
|
}) {
|
|
525
538
|
if (asks.length === 0 && !changed && contexts.length === 0) return null;
|
|
526
539
|
|
|
@@ -541,7 +554,8 @@ function assembleResponse({
|
|
|
541
554
|
if (contexts.length > 0) fields.additionalContext = contexts.join(" ");
|
|
542
555
|
// Record the gate ack only now that the ask is actually in the response — a
|
|
543
556
|
// rehydrate deny above returns first, so a preempted ask is not marked seen.
|
|
544
|
-
if (pendingGateAck) acknowledgeAlert();
|
|
557
|
+
if (pendingGateAck) acknowledgeAlert(sessionId);
|
|
558
|
+
if (pendingGapNotice) recordInstructionsLoadedNotice(sessionId);
|
|
545
559
|
return fields;
|
|
546
560
|
}
|
|
547
561
|
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* false positive the SSOT had already fixed, and its clean path was a bare
|
|
22
22
|
* `writeFileSync` with none of cleanFile's symlink/UTF-8/TOCTOU guards.
|
|
23
23
|
*/
|
|
24
|
-
import { existsSync, readFileSync, globSync
|
|
24
|
+
import { existsSync, readFileSync, globSync } from "node:fs";
|
|
25
25
|
import { join, relative, resolve } from "node:path";
|
|
26
26
|
import {
|
|
27
27
|
awaitLazyDependency,
|
|
@@ -33,7 +33,8 @@ import {
|
|
|
33
33
|
lazyImport,
|
|
34
34
|
markerIsTrusted,
|
|
35
35
|
probeSetupAlive,
|
|
36
|
-
|
|
36
|
+
PROJECT_DIR,
|
|
37
|
+
readStdinJson,
|
|
37
38
|
} from "./lib/hook-io.mjs";
|
|
38
39
|
import {
|
|
39
40
|
registerFaultPolicy,
|
|
@@ -41,11 +42,14 @@ import {
|
|
|
41
42
|
writeFaultOutcome,
|
|
42
43
|
} from "./lib/hook-fault.mjs";
|
|
43
44
|
import {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
alertAckFile,
|
|
46
|
+
alertDir,
|
|
47
|
+
appendAlert,
|
|
48
|
+
sweepStaleSessions,
|
|
47
49
|
} from "./lib/invisible-alert.mjs";
|
|
48
50
|
import { formatReport } from "./lib/invisible-report.mjs";
|
|
51
|
+
import { sweepStaleReveals } from "./lib/reveal.mjs";
|
|
52
|
+
import { sweepStaleConfirms } from "./lib/secret-drop-guard.mjs";
|
|
49
53
|
import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
|
|
50
54
|
import { reportSlowHook, startHookTimer } from "./lib/hook-timing.mjs";
|
|
51
55
|
// Relative, not the `agent-sanitizer` specifier every other engine import uses:
|
|
@@ -181,19 +185,15 @@ function reportFault(err) {
|
|
|
181
185
|
|
|
182
186
|
/**
|
|
183
187
|
* Persist the accumulated alert text for the PreToolUse gate, or leave the alert
|
|
184
|
-
* absent when there is nothing to surface.
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
* writeFileSync would follow a co-tenant-planted symlink and overwrite an
|
|
188
|
-
* arbitrary file this uid owns. Create it symlink-refusingly (see
|
|
189
|
-
* writeFileNoFollow); the gate treats an absent alert as "nothing to surface",
|
|
190
|
-
* so a lost race degrades safely rather than to a hijacked write.
|
|
188
|
+
* absent when there is nothing to surface. One store entry per part, into THIS
|
|
189
|
+
* session's alert directory (see appendAlert), so a concurrent
|
|
190
|
+
* InstructionsLoaded finding cannot clobber the launch scan's report.
|
|
191
191
|
* @param {string[]} parts
|
|
192
|
+
* @param {string} [sessionId]
|
|
192
193
|
* @returns {void}
|
|
193
194
|
*/
|
|
194
|
-
function persistAlert(parts) {
|
|
195
|
-
|
|
196
|
-
writeFileNoFollow(ALERT_FILE, parts.join("\n") + "\n");
|
|
195
|
+
function persistAlert(parts, sessionId) {
|
|
196
|
+
for (const part of parts) appendAlert(part, sessionId);
|
|
197
197
|
}
|
|
198
198
|
|
|
199
199
|
// Decoder
|
|
@@ -282,8 +282,8 @@ export {
|
|
|
282
282
|
decodeRun,
|
|
283
283
|
findInstructionFiles,
|
|
284
284
|
scanFile,
|
|
285
|
-
|
|
286
|
-
|
|
285
|
+
alertAckFile,
|
|
286
|
+
alertDir,
|
|
287
287
|
LONG_RUN_RE,
|
|
288
288
|
LONG_RUN_THRESHOLD,
|
|
289
289
|
TOTAL_INVISIBLE_THRESHOLD,
|
|
@@ -353,7 +353,7 @@ export function scanProject(dir = PROJECT_DIR) {
|
|
|
353
353
|
continue;
|
|
354
354
|
}
|
|
355
355
|
// safeErrMessage, not errMessage: this reason is rendered into stderr and
|
|
356
|
-
// into
|
|
356
|
+
// into the alert store, and an errno message embeds the absolute path globbed
|
|
357
357
|
// out of a possibly-hostile repo — a filename carrying ANSI or invisible
|
|
358
358
|
// bytes would otherwise reach the operator's terminal raw.
|
|
359
359
|
skipped.push({ file: report(file), reason: safeErrMessage(err) });
|
|
@@ -399,12 +399,15 @@ export function formatSkipped(skipped) {
|
|
|
399
399
|
* @param {{
|
|
400
400
|
* trace?: import("./lib/trace.mjs").TraceFn,
|
|
401
401
|
* scan?: () => ReturnType<typeof scanProject>,
|
|
402
|
+
* sessionId?: string,
|
|
402
403
|
* }} [opts] `trace` is where this scan announces engagement; a host with its
|
|
403
404
|
* own trace channel passes its sink so the announcement lands where its
|
|
404
405
|
* detector reads (see lib/trace.mjs). `scan` is the scanner, injectable so the
|
|
405
406
|
* FAULT path below — a scanner that throws something other than an errno, i.e.
|
|
406
407
|
* a bug — is drivable end to end; no filesystem state can force it, and an
|
|
407
408
|
* untested fault path is how a posture goes missing in the first place.
|
|
409
|
+
* `sessionId` keys the alert store this scan writes; the CLI entry reads it off
|
|
410
|
+
* the SessionStart payload, and an in-process caller passes it directly.
|
|
408
411
|
* @returns {Promise<void>}
|
|
409
412
|
*/
|
|
410
413
|
export async function cliMain(opts = {}) {
|
|
@@ -434,10 +437,11 @@ export async function cliMain(opts = {}) {
|
|
|
434
437
|
* @param {{
|
|
435
438
|
* trace?: import("./lib/trace.mjs").TraceFn,
|
|
436
439
|
* scan?: () => ReturnType<typeof scanProject>,
|
|
440
|
+
* sessionId?: string,
|
|
437
441
|
* }} opts see {@link cliMain}
|
|
438
442
|
* @returns {Promise<void>}
|
|
439
443
|
*/
|
|
440
|
-
async function runScanCli({ trace: sink = trace, scan: runScan }) {
|
|
444
|
+
async function runScanCli({ trace: sink = trace, scan: runScan, sessionId }) {
|
|
441
445
|
// Bound best-effort: the announcements below run BEFORE the auto-clean and
|
|
442
446
|
// the alert write, with no catch above them, so a throwing host sink would
|
|
443
447
|
// abort the scan silently (see bestEffortTrace).
|
|
@@ -470,20 +474,22 @@ async function runScanCli({ trace: sink = trace, scan: runScan }) {
|
|
|
470
474
|
),
|
|
471
475
|
),
|
|
472
476
|
);
|
|
473
|
-
persistAlert(alertParts);
|
|
477
|
+
persistAlert(alertParts, sessionId);
|
|
474
478
|
process.exit(1);
|
|
475
479
|
}
|
|
476
480
|
/* c8 ignore stop */
|
|
477
481
|
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
482
|
+
// No clear: the store is session-keyed, so this session starts empty by
|
|
483
|
+
// construction and cannot inherit a previous session's ack. Age out what past
|
|
484
|
+
// sessions left instead — SessionStart is the once-per-session touchpoint the
|
|
485
|
+
// sweep belongs on.
|
|
486
|
+
sweepStaleSessions(sessionId);
|
|
487
|
+
// The other two $TMPDIR stores these hooks own. Both are content- or
|
|
488
|
+
// fingerprint-addressed with no natural owner to delete them, so without a
|
|
489
|
+
// sweep they grow for the life of the machine; SessionStart is the only
|
|
490
|
+
// touchpoint that runs once per session rather than once per tool call.
|
|
491
|
+
sweepStaleReveals();
|
|
492
|
+
sweepStaleConfirms();
|
|
487
493
|
|
|
488
494
|
// Only a non-errno throw reaches here — a bug in the scanner, not a file it
|
|
489
495
|
// could not read (those are accounted for in `skipped`). It is a fault of THIS
|
|
@@ -495,7 +501,7 @@ async function runScanCli({ trace: sink = trace, scan: runScan }) {
|
|
|
495
501
|
} catch (err) {
|
|
496
502
|
emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, { outcome: "skipped" });
|
|
497
503
|
alertParts.push(...reportFault(err));
|
|
498
|
-
persistAlert(alertParts);
|
|
504
|
+
persistAlert(alertParts, sessionId);
|
|
499
505
|
return;
|
|
500
506
|
}
|
|
501
507
|
const { findings: allFindings, skipped, absent, scanned } = scan;
|
|
@@ -529,7 +535,7 @@ async function runScanCli({ trace: sink = trace, scan: runScan }) {
|
|
|
529
535
|
|
|
530
536
|
if (allFindings.length > 0)
|
|
531
537
|
alertParts.push(...autoCleanFindings(allFindings, PROJECT_DIR));
|
|
532
|
-
persistAlert(alertParts);
|
|
538
|
+
persistAlert(alertParts, sessionId);
|
|
533
539
|
}
|
|
534
540
|
|
|
535
541
|
/**
|
|
@@ -602,6 +608,27 @@ function autoCleanFindings(allFindings, dir) {
|
|
|
602
608
|
return [report];
|
|
603
609
|
}
|
|
604
610
|
|
|
611
|
+
/**
|
|
612
|
+
* The session identity from the SessionStart payload on stdin, or undefined when
|
|
613
|
+
* the host sent none.
|
|
614
|
+
*
|
|
615
|
+
* Swallowing: the payload is read for ONE optional field, and a host that pipes
|
|
616
|
+
* nothing (or malformed JSON) must still get the scan — a session-start scan
|
|
617
|
+
* refused over an unparseable envelope is a strictly worse outcome than one
|
|
618
|
+
* keyed to the shared `no-session` fallback.
|
|
619
|
+
* @returns {Promise<string | undefined>}
|
|
620
|
+
*/
|
|
621
|
+
export async function sessionIdFromStdin() {
|
|
622
|
+
// A TTY is a human running this scan by hand, not a harness sending an event:
|
|
623
|
+
// reading it would block forever waiting for a payload nobody is going to send.
|
|
624
|
+
if (process.stdin.isTTY) return undefined;
|
|
625
|
+
try {
|
|
626
|
+
return (await readStdinJson())?.session_id;
|
|
627
|
+
} catch {
|
|
628
|
+
return undefined;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
605
632
|
if (isMain(import.meta.url)) {
|
|
606
|
-
await cliMain();
|
|
633
|
+
await cliMain({ sessionId: await sessionIdFromStdin() });
|
|
607
634
|
}
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
HookEvent,
|
|
25
25
|
isMain,
|
|
26
26
|
lazyImport,
|
|
27
|
+
PROJECT_DIR,
|
|
27
28
|
readStdinJson,
|
|
28
29
|
safeErrMessage,
|
|
29
30
|
} from "./lib/hook-io.mjs";
|
|
@@ -34,7 +35,6 @@ import {
|
|
|
34
35
|
} from "./lib/hook-fault.mjs";
|
|
35
36
|
import {
|
|
36
37
|
appendAlert,
|
|
37
|
-
PROJECT_DIR,
|
|
38
38
|
recordInstructionsLoaded,
|
|
39
39
|
} from "./lib/invisible-alert.mjs";
|
|
40
40
|
import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
|
|
@@ -259,8 +259,11 @@ export { HOOK_NAME };
|
|
|
259
259
|
export async function cliMain({ trace: sink = trace } = {}) {
|
|
260
260
|
const timer = startHookTimer();
|
|
261
261
|
const emitTrace = bestEffortTrace(sink);
|
|
262
|
+
/** @type {string | undefined} */
|
|
263
|
+
let sessionId;
|
|
262
264
|
try {
|
|
263
265
|
const payload = await readStdinJson();
|
|
266
|
+
sessionId = payload?.session_id;
|
|
264
267
|
// Recorded before the scan, not after: the marker answers "is this event
|
|
265
268
|
// being scanned", which is true the moment the hook is running, and a
|
|
266
269
|
// faulting scan must not read as an unscanned event — that notice names a
|
|
@@ -289,7 +292,7 @@ export async function cliMain({ trace: sink = trace } = {}) {
|
|
|
289
292
|
// A payload still on disk is the case the PreToolUse gate exists for: it
|
|
290
293
|
// asks once, on the next tool call, rather than leaving the only report on a
|
|
291
294
|
// channel that scrolls.
|
|
292
|
-
if (!result.cleaned) appendAlert(message);
|
|
295
|
+
if (!result.cleaned) appendAlert(message, sessionId);
|
|
293
296
|
// systemMessage reaches the user, additionalContext the model. Both, because
|
|
294
297
|
// this hook cannot block and the file is already loaded: the user is the one
|
|
295
298
|
// who can act on it, and the model is the one currently reading it.
|
|
@@ -306,7 +309,11 @@ export async function cliMain({ trace: sink = trace } = {}) {
|
|
|
306
309
|
emitTrace(TraceEvent.SCAN_LOADED_INSTRUCTIONS_RAN, { outcome: "skipped" });
|
|
307
310
|
const outcome = hookFaultOutcome(HOOK_NAME, err);
|
|
308
311
|
process.exitCode = writeFaultOutcome(outcome);
|
|
309
|
-
|
|
312
|
+
// `sessionId`, captured before the throw: a payload read that itself failed
|
|
313
|
+
// leaves it undefined, and the fault then lands in the shared fallback store
|
|
314
|
+
// rather than in no store at all.
|
|
315
|
+
if (outcome.armAlert)
|
|
316
|
+
appendAlert(/** @type {string} */ (outcome.stderr), sessionId);
|
|
310
317
|
} finally {
|
|
311
318
|
reportSlowHook(
|
|
312
319
|
HOOK_NAME,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.44.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": {
|
|
@@ -326,13 +326,21 @@ export function configureHookgateMarker(path: string | null): void;
|
|
|
326
326
|
*/
|
|
327
327
|
export function hookgateMarkerPath(projectDir?: string | undefined, runtimeDir?: string | undefined): string | null;
|
|
328
328
|
/**
|
|
329
|
-
* Is the setup process that wrote `markerPath` still alive?
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
329
|
+
* Is the setup process that wrote `markerPath` still alive?
|
|
330
|
+
*
|
|
331
|
+
* A marker declaring {@link SETUP_LOCK_DECLARATION} is judged by the LOCK, and that
|
|
332
|
+
* answer has no aliasing: the kernel drops an flock the instant its holder dies, so
|
|
333
|
+
* held means alive and free means dead, with no third state and nothing to reuse. A
|
|
334
|
+
* marker that declares nothing carries only a pid, and `process.kill(pid, 0)` is all
|
|
335
|
+
* there is — it throws ESRCH once the process is gone (a killed setup → stale
|
|
336
|
+
* marker, so stop waiting) and EPERM when it exists but is not ours (still alive).
|
|
337
|
+
* That reading is the one this replaces where it can: a recycled pid reads as a live
|
|
338
|
+
* setup for as long as the caller's ceiling allows.
|
|
339
|
+
*
|
|
340
|
+
* An unreadable / not-yet-written marker is treated as alive — favouring a brief
|
|
341
|
+
* wait over a premature give-up during setup's write race. A null markerPath (no
|
|
342
|
+
* project dir → no setup to wait on) reads as alive so the caller's own
|
|
343
|
+
* grace/ceiling bound governs.
|
|
336
344
|
* @param {string | null} markerPath
|
|
337
345
|
* @returns {boolean}
|
|
338
346
|
*/
|
|
@@ -503,6 +511,24 @@ export class EmptyStdinError extends Error {
|
|
|
503
511
|
* command the reader should actually run.
|
|
504
512
|
*/
|
|
505
513
|
export const DEFAULT_MISSING_PACKAGE_REMEDY: "reinstall the hook dependencies (pnpm install) and retry.";
|
|
514
|
+
/**
|
|
515
|
+
* The project the hooks are guarding. Every per-project $TMPDIR store is keyed to
|
|
516
|
+
* it, so it lives here — beside the other shared identity these hooks agree on —
|
|
517
|
+
* rather than in whichever store happened to need it first.
|
|
518
|
+
*/
|
|
519
|
+
export const PROJECT_DIR: string;
|
|
520
|
+
/** Short project digest keying this project's $TMPDIR store names. */
|
|
521
|
+
export const PROJECT_HASH: string;
|
|
522
|
+
/**
|
|
523
|
+
* The line a cold-start marker carries on its own to declare that its writer holds
|
|
524
|
+
* an exclusive `flock` on the marker file for the whole install.
|
|
525
|
+
*
|
|
526
|
+
* This is the marker's PROTOCOL, and the reason it is declared in the data rather
|
|
527
|
+
* than assumed: a reader cannot tell "the writer released the lock because it died"
|
|
528
|
+
* from "the writer never took one" by looking at a free lock, so only a marker that
|
|
529
|
+
* says it locks may be judged by the lock.
|
|
530
|
+
*/
|
|
531
|
+
export const SETUP_LOCK_DECLARATION: "flock";
|
|
506
532
|
/**
|
|
507
533
|
* EVERY process-wide slot these helpers keep — the four a host can observe or
|
|
508
534
|
* steer, so a second instance that adopts this object is steered in all four at
|