agent-sanitizer 2.66.3 → 2.66.5
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 +1 -1
- package/THREAT-MODEL.md +20 -7
- package/claude-hooks/lib/hook-io.mjs +99 -5
- package/claude-hooks/lib/invisible-alert.mjs +176 -103
- package/claude-hooks/lib/redactor-client.mjs +8 -22
- package/claude-hooks/lib/reveal.mjs +53 -24
- package/claude-hooks/pretooluse-sanitize.mjs +1 -40
- package/claude-hooks/scan-invisible-chars.mjs +4 -4
- package/package.json +1 -1
- package/types/claude-hooks/lib/hook-io.d.mts +52 -5
- package/types/claude-hooks/lib/invisible-alert.d.mts +44 -35
- package/types/claude-hooks/lib/redactor-client.d.mts +1 -1
- package/types/claude-hooks/lib/reveal.d.mts +2 -1
package/README.md
CHANGED
|
@@ -250,7 +250,7 @@ To wire them yourself instead, one entry dispatches every mode on `--hook=`:
|
|
|
250
250
|
| `SessionStart` | `scan-invisible-chars` |
|
|
251
251
|
| `InstructionsLoaded` | `scan-loaded-instructions` |
|
|
252
252
|
|
|
253
|
-
**Wire all five.** The instruction-file scan is split across two of them: `SessionStart` covers the files that load at launch, and `InstructionsLoaded` covers every one a subdirectory loads later. A host that wires the first without the second leaves a nested `CLAUDE.md` scanned by nothing
|
|
253
|
+
**Wire all five.** The instruction-file scan is split across two of them: `SessionStart` covers the files that load at launch, and `InstructionsLoaded` covers every one a subdirectory loads later. A host that wires the first without the second leaves a nested `CLAUDE.md` scanned by nothing. The one-time PreToolUse coverage notice says so, but only when a file announced at launch went unscanned: a project whose only `CLAUDE.md` files sit in subdirectories gets no notice.
|
|
254
254
|
|
|
255
255
|
`InstructionsLoaded` needs Claude Code **2.1.69 or newer** — 2.1.68 does not emit the event, so the hook never fires however you wire it. Check with `claude --version`.
|
|
256
256
|
|
package/THREAT-MODEL.md
CHANGED
|
@@ -547,8 +547,10 @@ entirely: a host that never wired the `InstructionsLoaded` event to
|
|
|
547
547
|
`scan-loaded-instructions`, a Claude Code older than 2.1.69, which is the first
|
|
548
548
|
build that emits that event, and `scan-loaded-instructions` switched off in
|
|
549
549
|
`AGENT_SANITIZER_DISABLED_HOOKS`.
|
|
550
|
-
|
|
551
|
-
|
|
550
|
+
The PreToolUse gate reports the gap once per session rather than leaving it
|
|
551
|
+
silent. It checks the disable list and asks the session's own `claude --version`,
|
|
552
|
+
so the notice names only the cause it could not rule out: usually the wiring,
|
|
553
|
+
which only `/hooks` shows.
|
|
552
554
|
|
|
553
555
|
A fourth state is not one of them and is not reported as one: the host emits no
|
|
554
556
|
event when it has nothing to announce. The gate therefore asks not "does anything
|
|
@@ -561,10 +563,11 @@ or an empty `~/.claude/CLAUDE.md` fires no event however the hook is wired, so
|
|
|
561
563
|
the missing scan there is evidence of nothing. The trade this accepts is a false
|
|
562
564
|
NEGATIVE: a repo whose announced instruction files all sit in subdirectories —
|
|
563
565
|
a monorepo of per-package `CLAUDE.md` under a root carrying only `AGENTS.md`, on
|
|
564
|
-
a machine with no user-global memory —
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
566
|
+
a machine with no user-global memory — gets no notice at all. A tool call that
|
|
567
|
+
touches such a directory is no evidence either: Claude Code loads the nested
|
|
568
|
+
file only after that call, so its PreToolUse runs before any scan could have.
|
|
569
|
+
Weighed against a false POSITIVE on every session launched outside a project,
|
|
570
|
+
which trains the operator to skip the notice everywhere.
|
|
568
571
|
|
|
569
572
|
That table is a claim about someone else's product, so the event that names a
|
|
570
573
|
loaded file is also what falsifies it. `contextScopeContradiction` checks every
|
|
@@ -797,7 +800,7 @@ positive costs a sentence of context, never a mangled input):
|
|
|
797
800
|
the identical Write confirms and passes. The confirmation is the model's
|
|
798
801
|
deliberate retry — never a human permission prompt — held as a
|
|
799
802
|
consumed-on-use, TTL-bounded sentinel (keyed to path + content + the dropped
|
|
800
|
-
values) via the same squat-resistant
|
|
803
|
+
values) in `$TMPDIR` via the same squat-resistant helpers as the invisible-char
|
|
801
804
|
gate. Tracked files, secret-free files, failed git probes and unmappable
|
|
802
805
|
views all skip the guard (fail open). "Tracked" approximates "recoverable":
|
|
803
806
|
an uncommitted secret line on a tracked file is an accepted gap — the
|
|
@@ -927,6 +930,16 @@ it, so what anchors its trust is worth stating exactly.
|
|
|
927
930
|
under `TMPDIR` is read only when this user owns it with owner-only write, so a
|
|
928
931
|
squatted shared-temp entry cannot pre-suppress the warning that a call ran
|
|
929
932
|
unsanitized.
|
|
933
|
+
- **Session state lives under that directory too, not in `$TMPDIR`.** The
|
|
934
|
+
InstructionsLoaded marker, the invisible-char gate's findings and ack, and the
|
|
935
|
+
Layer-2 reveal spans sit in `${CLAUDE_PLUGIN_DATA}/session-state`, created mode
|
|
936
|
+
700 and used only when both it and the data directory are real directories
|
|
937
|
+
this user owns with no group/other write (`privateDirAt`) and the leaf is
|
|
938
|
+
writable. An OS temp cleaner (macOS dirhelper purges `$TMPDIR` entries unread
|
|
939
|
+
for 3 days) would otherwise delete a live session's marker — a false "no
|
|
940
|
+
InstructionsLoaded scan has run" notice — or its findings, lifting the gate.
|
|
941
|
+
A host with no plugin data directory, or one that fails those checks, falls
|
|
942
|
+
back to `$TMPDIR` and keeps that exposure.
|
|
930
943
|
- **Opting out.** `AGENT_SANITIZER_HOOK_BINARY=0` never downloads and never runs
|
|
931
944
|
a binary. That leaves the node path running the shipped bundle and the
|
|
932
945
|
compile cache above; `AGENT_SANITIZER_COMPILE_CACHE=0` turns the cache off as
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
/** Shared I/O helpers for the Claude Code hook scripts. */
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
+
accessSync,
|
|
5
|
+
constants as fsConstants,
|
|
4
6
|
openSync,
|
|
5
7
|
closeSync,
|
|
6
8
|
lstatSync,
|
|
9
|
+
lutimesSync,
|
|
10
|
+
mkdirSync,
|
|
7
11
|
readFileSync,
|
|
8
12
|
unlinkSync,
|
|
9
13
|
writeFileSync,
|
|
10
14
|
} from "node:fs";
|
|
11
15
|
import { spawnSync } from "node:child_process";
|
|
12
16
|
import { createHash } from "node:crypto";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { isAbsolute, join } from "node:path";
|
|
13
19
|
import { pathToFileURL } from "node:url";
|
|
14
20
|
import { excludeProvisioning } from "./hook-timing.mjs";
|
|
15
21
|
import { readKnob } from "./knobs.mjs";
|
|
@@ -695,6 +701,55 @@ export const PROJECT_HASH = createHash("sha256")
|
|
|
695
701
|
.digest("hex")
|
|
696
702
|
.slice(0, 8);
|
|
697
703
|
|
|
704
|
+
/**
|
|
705
|
+
* The directory the session-lifetime stores live under: the alert markers and
|
|
706
|
+
* findings (invisible-alert.mjs) and the reveal sidecars (reveal.mjs). They must
|
|
707
|
+
* outlive the longest session, which $TMPDIR does not promise — macOS's daily
|
|
708
|
+
* dirhelper job deletes anything in the per-user `/var/folders/.../T/` not
|
|
709
|
+
* accessed for 3 days, and a marker written at launch and only ever `lstat`ed
|
|
710
|
+
* then vanishes from a session still running, its absence read as "never ran".
|
|
711
|
+
*
|
|
712
|
+
* `$CLAUDE_PLUGIN_DATA/session-state` when both levels are private to this uid
|
|
713
|
+
* ({@link privateDirAt}): write on the plugin data dir alone is enough to swap
|
|
714
|
+
* `session-state` out. Each level is created 0700 when absent — so the
|
|
715
|
+
* launch-time InstructionsLoaded event and the first tool call resolve the same
|
|
716
|
+
* root whichever runs first — and checked BEFORE the next is created inside it,
|
|
717
|
+
* so a symlinked or shared plugin data dir never gets a directory planted in
|
|
718
|
+
* its target. A root this process cannot write — a read-only mount, an owner
|
|
719
|
+
* bit removed — is no root: every marker write would fail and read as "the
|
|
720
|
+
* event never fired". Otherwise $TMPDIR, where the stores' squat checks apply.
|
|
721
|
+
* @param {Record<string, string | undefined>} env
|
|
722
|
+
* @returns {string}
|
|
723
|
+
*/
|
|
724
|
+
export function resolveStateRoot(env) {
|
|
725
|
+
const data = env.CLAUDE_PLUGIN_DATA;
|
|
726
|
+
// A relative path resolves against each hook process's cwd, so two processes
|
|
727
|
+
// would disagree on the root, and it could land inside a scanned tree.
|
|
728
|
+
if (!data || !isAbsolute(data)) return tmpdir();
|
|
729
|
+
const root = join(data, "session-state");
|
|
730
|
+
for (const dir of [data, root]) {
|
|
731
|
+
try {
|
|
732
|
+
mkdirSync(dir, { mode: 0o700 });
|
|
733
|
+
} catch (err) {
|
|
734
|
+
// Anything but "already there" — a read-only or unreachable plugin data
|
|
735
|
+
// dir — is an ordinary host: $TMPDIR still holds the state, at its
|
|
736
|
+
// shorter lifetime.
|
|
737
|
+
if (/** @type {NodeJS.ErrnoException} */ (err).code !== "EEXIST")
|
|
738
|
+
return tmpdir();
|
|
739
|
+
}
|
|
740
|
+
if (!privateDirAt(dir)) return tmpdir();
|
|
741
|
+
}
|
|
742
|
+
try {
|
|
743
|
+
accessSync(root, fsConstants.W_OK | fsConstants.X_OK);
|
|
744
|
+
} catch {
|
|
745
|
+
return tmpdir();
|
|
746
|
+
}
|
|
747
|
+
return root;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** {@link resolveStateRoot} for this process, resolved once at load. */
|
|
751
|
+
export const STATE_ROOT = resolveStateRoot(process.env);
|
|
752
|
+
|
|
698
753
|
/** The marker filename stem; the project directory is appended to it. */
|
|
699
754
|
const HOOKGATE_MARKER_STEM = "agent-sanitizer-hookgate-inflight-";
|
|
700
755
|
|
|
@@ -1067,7 +1122,7 @@ export function currentUid() {
|
|
|
1067
1122
|
|
|
1068
1123
|
/**
|
|
1069
1124
|
* Whether `st` (an lstat result) is owned by this process's uid. THE ownership
|
|
1070
|
-
* predicate for every
|
|
1125
|
+
* predicate for every state store these hooks trust — a second spelling is how
|
|
1071
1126
|
* four call sites came to share the passwd-lookup crash {@link currentUid}
|
|
1072
1127
|
* documents. False when the platform has no uid model: ownership that cannot be
|
|
1073
1128
|
* proven is not granted. `uid` is a seam for a test that must model another
|
|
@@ -1084,10 +1139,11 @@ export function ownedByCurrentUser(st, uid = currentUid()) {
|
|
|
1084
1139
|
* Whether `st` (an lstat result) is a directory no untrusted uid can change: a
|
|
1085
1140
|
* real directory (lstat, so a planted symlink is not one), owned by this uid —
|
|
1086
1141
|
* or by root where `rootMayOwn` — with no group/other write bit. THE trust test
|
|
1087
|
-
* for every private directory these hooks keep
|
|
1088
|
-
* property because creating, unlinking or replacing an entry needs
|
|
1089
|
-
* parent: a co-tenant holding it can delete the finding a gate
|
|
1090
|
-
* reveal file, or rebind the redactor socket to a listener that
|
|
1142
|
+
* for every private directory these hooks keep, under STATE_ROOT or $TMPDIR.
|
|
1143
|
+
* Write is the property because creating, unlinking or replacing an entry needs
|
|
1144
|
+
* write on its parent: a co-tenant holding it can delete the finding a gate
|
|
1145
|
+
* needs, swap a reveal file, or rebind the redactor socket to a listener that
|
|
1146
|
+
* redacts nothing.
|
|
1091
1147
|
* Root ownership is strictly stronger than ours, so a deployment may hand a dir
|
|
1092
1148
|
* to root (`rootMayOwn`); with no uid model nothing is trusted, root included.
|
|
1093
1149
|
* @param {{ isDirectory(): boolean, uid: number, mode: number }} st
|
|
@@ -1104,6 +1160,44 @@ export function isPrivateDir(
|
|
|
1104
1160
|
return st.isDirectory() && owner && (st.mode & 0o022) === 0;
|
|
1105
1161
|
}
|
|
1106
1162
|
|
|
1163
|
+
/**
|
|
1164
|
+
* Whether `path` is a private directory of this uid ({@link isPrivateDir}) —
|
|
1165
|
+
* the directory counterpart of markerIsTrusted. `lstat`, so a symlink planted at
|
|
1166
|
+
* a predictable path is judged on ITSELF: followed, it would let a co-tenant aim
|
|
1167
|
+
* a reader at unrelated files this uid owns (the alert gate splices those bytes
|
|
1168
|
+
* into a permission prompt). An absent path is simply not one.
|
|
1169
|
+
* @param {string} path
|
|
1170
|
+
* @param {{ uid?: number | null }} [opts] as in {@link isPrivateDir}
|
|
1171
|
+
* @returns {boolean}
|
|
1172
|
+
*/
|
|
1173
|
+
export function privateDirAt(path, opts) {
|
|
1174
|
+
let st;
|
|
1175
|
+
try {
|
|
1176
|
+
st = lstatSync(path);
|
|
1177
|
+
} catch {
|
|
1178
|
+
return false;
|
|
1179
|
+
}
|
|
1180
|
+
return isPrivateDir(st, opts);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/**
|
|
1184
|
+
* Stamp a state file the caller has just trusted as used now, so a live
|
|
1185
|
+
* session's state never looks idle to an age-based sweep — another session's
|
|
1186
|
+
* {@link STATE_ROOT} sweep (mtime) or an OS temp cleaner (atime). `lutimes`, so
|
|
1187
|
+
* a symlink swapped in after the caller's check is stamped itself, never its
|
|
1188
|
+
* target. A path a parallel sweep removed after that check is simply gone.
|
|
1189
|
+
* @param {string} path
|
|
1190
|
+
* @returns {void}
|
|
1191
|
+
*/
|
|
1192
|
+
export function touchLiveState(path) {
|
|
1193
|
+
const now = new Date();
|
|
1194
|
+
try {
|
|
1195
|
+
lutimesSync(path, now, now);
|
|
1196
|
+
} catch (err) {
|
|
1197
|
+
if (/** @type {NodeJS.ErrnoException} */ (err).code !== "ENOENT") throw err;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1107
1201
|
/**
|
|
1108
1202
|
* Create a presence sentinel at `path` without following a symlink a co-tenant
|
|
1109
1203
|
* may have pre-planted there. These sentinels live at predictable, world-visible
|
|
@@ -19,18 +19,24 @@ import {
|
|
|
19
19
|
rmSync,
|
|
20
20
|
statSync,
|
|
21
21
|
} from "node:fs";
|
|
22
|
+
import { spawnSync } from "node:child_process";
|
|
22
23
|
import { randomBytes } from "node:crypto";
|
|
23
24
|
import { basename, join } from "node:path";
|
|
24
|
-
import { homedir
|
|
25
|
+
import { homedir } from "node:os";
|
|
25
26
|
import {
|
|
27
|
+
DISABLED_HOOKS_ENV,
|
|
28
|
+
disabledHooks,
|
|
26
29
|
hookgateMarkerPath,
|
|
27
30
|
lazyImport,
|
|
28
31
|
markerIsTrusted,
|
|
29
|
-
|
|
32
|
+
ownedByCurrentUser,
|
|
33
|
+
privateDirAt,
|
|
30
34
|
probeSetupAlive,
|
|
31
35
|
PROJECT_DIR,
|
|
32
36
|
PROJECT_HASH,
|
|
33
37
|
scrubUntrustedText,
|
|
38
|
+
STATE_ROOT,
|
|
39
|
+
touchLiveState,
|
|
34
40
|
writeFileNoFollow,
|
|
35
41
|
writeSentinelFile,
|
|
36
42
|
} from "./hook-io.mjs";
|
|
@@ -59,15 +65,28 @@ const { applyLayer1WellFormed } =
|
|
|
59
65
|
await lazyImport("agent-sanitizer")
|
|
60
66
|
);
|
|
61
67
|
|
|
68
|
+
/** Every alert artifact name's start, less the project hash that follows it. */
|
|
69
|
+
const ALERT_STEM = ".claude-invisible-char-alert-";
|
|
70
|
+
|
|
62
71
|
/**
|
|
63
72
|
* The path prefix every alert artifact of this PROJECT shares. Never a file
|
|
64
73
|
* itself — only {@link sessionPrefix} and the sweep read it — so that one
|
|
65
|
-
* `startsWith` covers every artifact the sweep must age out.
|
|
74
|
+
* `startsWith` covers every artifact the sweep must age out. Under STATE_ROOT,
|
|
75
|
+
* not $TMPDIR itself: an OS temp cleaner deleting a live session's marker would
|
|
76
|
+
* read as the InstructionsLoaded scan never having run, and deleting its
|
|
77
|
+
* findings would lift the gate mid-session.
|
|
78
|
+
*/
|
|
79
|
+
export const ALERT_BASE = join(STATE_ROOT, `${ALERT_STEM}${PROJECT_HASH}`);
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Whether `name` is a session artifact of ANY project: {@link ALERT_BASE}'s
|
|
83
|
+
* name, whatever its project hash, then `.s-`.
|
|
84
|
+
* @param {string} name
|
|
85
|
+
* @returns {boolean}
|
|
66
86
|
*/
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
);
|
|
87
|
+
const isAnyProjectSessionEntry = (name) =>
|
|
88
|
+
name.startsWith(ALERT_STEM) &&
|
|
89
|
+
/^[0-9a-f]{8}\.s-/u.test(name.slice(ALERT_STEM.length));
|
|
71
90
|
|
|
72
91
|
/**
|
|
73
92
|
* The path prefix every alert artifact of ONE session under this project shares.
|
|
@@ -86,7 +105,7 @@ export const ALERT_BASE = join(
|
|
|
86
105
|
export function sessionPrefix(sessionId) {
|
|
87
106
|
// The id becomes a path component, so anything outside this class — a `/` in
|
|
88
107
|
// a hostile session id above all — is folded away rather than escaping
|
|
89
|
-
//
|
|
108
|
+
// STATE_ROOT. A host that exports no session id falls back to one shared name,
|
|
90
109
|
// whose findings and ack are bounded by FALLBACK_TTL_MS instead.
|
|
91
110
|
const key =
|
|
92
111
|
(sessionId ?? "").replace(/[^A-Za-z0-9._-]/gu, "_") || "no-session";
|
|
@@ -138,12 +157,20 @@ export function instructionsLoadedNoticeFile(sessionId) {
|
|
|
138
157
|
* lazily-loaded instruction files are being scanned at all. Ownership-validated
|
|
139
158
|
* like every other marker here: a co-tenant could otherwise plant the
|
|
140
159
|
* predictable path and suppress the notice below, which is the whole signal that
|
|
141
|
-
* nested files are going unscanned.
|
|
160
|
+
* nested files are going unscanned. A marker that is seen is stamped as used:
|
|
161
|
+
* it is written once at launch, so otherwise a session outliving
|
|
162
|
+
* MARKER_TTL_MS would have it swept by another session's SessionStart and read
|
|
163
|
+
* its absence as the event never having fired.
|
|
142
164
|
* @param {string} [sessionId]
|
|
143
165
|
* @returns {boolean}
|
|
144
166
|
*/
|
|
145
167
|
export function instructionsLoadedSeen(sessionId) {
|
|
146
|
-
|
|
168
|
+
const marker = instructionsLoadedFile(sessionId);
|
|
169
|
+
if (!markerIsTrusted(marker)) return false;
|
|
170
|
+
// Not the shared no-session marker: every session-less host would refresh
|
|
171
|
+
// it, and MARKER_TTL_MS is what keeps it from silencing every later session.
|
|
172
|
+
if (sessionPrefix(sessionId) !== sessionPrefix()) touchLiveState(marker);
|
|
173
|
+
return true;
|
|
147
174
|
}
|
|
148
175
|
|
|
149
176
|
/** How long a past session's artifacts are kept before a later session sweeps. */
|
|
@@ -182,8 +209,8 @@ const FALLBACK_TTL_MS = 30 * 60 * 1000;
|
|
|
182
209
|
* this `lstat` reads as expired, which is what every caller wants of a file that
|
|
183
210
|
* is no longer there: the sweep's unlink becomes a no-op, the gate's reader skips
|
|
184
211
|
* it, and an ack that vanished stops suppressing the ask. Propagating instead
|
|
185
|
-
* would abort recordInstructionsLoaded on a benign
|
|
186
|
-
* false "instruction file was NOT scanned" fault.
|
|
212
|
+
* would abort recordInstructionsLoaded on a benign STATE_ROOT race and render
|
|
213
|
+
* the false "instruction file was NOT scanned" fault.
|
|
187
214
|
*
|
|
188
215
|
* `lstat`, so a planted symlink is judged on itself.
|
|
189
216
|
* @param {string} path
|
|
@@ -206,30 +233,12 @@ function withinTtl(path, ttlMs) {
|
|
|
206
233
|
const withinFallbackTtl = (path) => withinTtl(path, FALLBACK_TTL_MS);
|
|
207
234
|
|
|
208
235
|
/**
|
|
209
|
-
*
|
|
210
|
-
* the directory counterpart of markerIsTrusted. `lstat`, so a symlink planted
|
|
211
|
-
* at the predictable alert-dir path is judged on ITSELF: followed, it would let
|
|
212
|
-
* a co-tenant aim the gate's reader at a directory of unrelated files this uid
|
|
213
|
-
* owns and splice their bytes into a permission prompt. A group/other-writable
|
|
214
|
-
* one is refused too: a co-tenant could unlink the finding the gate needs.
|
|
215
|
-
* @param {string} path
|
|
216
|
-
* @returns {boolean}
|
|
217
|
-
*/
|
|
218
|
-
function dirIsTrusted(path) {
|
|
219
|
-
let st;
|
|
220
|
-
try {
|
|
221
|
-
st = lstatSync(path);
|
|
222
|
-
} catch {
|
|
223
|
-
return false;
|
|
224
|
-
}
|
|
225
|
-
return isPrivateDir(st);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* Delete this project's artifacts from OTHER sessions once they are older than
|
|
236
|
+
* Delete OTHER sessions' artifacts, of every project, once they are older than
|
|
230
237
|
* the TTL. The current session's own prefix is skipped, so the sweep can never
|
|
231
238
|
* answer its own question wrong; every other session's files are past history
|
|
232
|
-
* that nothing reads.
|
|
239
|
+
* that nothing reads — a live one keeps its marker and findings fresh on read.
|
|
240
|
+
* Every project, because STATE_ROOT may be persistent: a project never reopened
|
|
241
|
+
* would otherwise keep its artifacts for good.
|
|
233
242
|
*
|
|
234
243
|
* This replaces the destructive SessionStart clear: with the store session-keyed
|
|
235
244
|
* there is nothing to reset, only old state to age out.
|
|
@@ -237,28 +246,28 @@ function dirIsTrusted(path) {
|
|
|
237
246
|
* @returns {void}
|
|
238
247
|
*/
|
|
239
248
|
export function sweepStaleSessions(sessionId) {
|
|
240
|
-
const dir =
|
|
241
|
-
const prefix = `${basename(ALERT_BASE)}.s-`;
|
|
249
|
+
const dir = STATE_ROOT;
|
|
242
250
|
const keep = basename(sessionPrefix(sessionId));
|
|
243
251
|
const cutoff = Date.now() - MARKER_TTL_MS;
|
|
244
252
|
for (const name of readdirSync(dir)) {
|
|
245
|
-
if (!name
|
|
253
|
+
if (!isAnyProjectSessionEntry(name)) continue;
|
|
246
254
|
// Whole-segment match on the keep prefix, not a bare startsWith: session key
|
|
247
255
|
// "abc" must not claim (and so preserve) session key "abc2"'s artifacts.
|
|
248
256
|
if (name === keep || name.startsWith(`${keep}.`)) continue;
|
|
249
257
|
const path = join(dir, name);
|
|
250
258
|
try {
|
|
251
|
-
// lstat, not stat: a squatted symlink at a predictable
|
|
252
|
-
//
|
|
253
|
-
|
|
259
|
+
// lstat, not stat: a squatted symlink at a predictable path is judged on
|
|
260
|
+
// ITSELF. Ownership first: in a shared /tmp another uid's entry is not
|
|
261
|
+
// ours to remove, and an rmSync on its file fails ENOTDIR, not EPERM.
|
|
262
|
+
const st = lstatSync(path);
|
|
263
|
+
if (!ownedByCurrentUser(st) || st.mtimeMs >= cutoff) continue;
|
|
254
264
|
rmSync(path, { recursive: true, force: true });
|
|
255
265
|
} catch (err) {
|
|
256
266
|
// ENOENT: a parallel session swept this entry between readdir and lstat.
|
|
257
|
-
// EPERM/EACCES: a co-tenant's
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
// a bug in this sweep and propagates.
|
|
267
|
+
// EPERM/EACCES: an entry of ours a co-tenant's directory shields. A throw
|
|
268
|
+
// here would abort recordInstructionsLoaded and render the "instruction
|
|
269
|
+
// file was NOT scanned" fault on a session that WAS scanned. Anything else
|
|
270
|
+
// is a bug in this sweep and propagates.
|
|
262
271
|
const code = /** @type {NodeJS.ErrnoException} */ (err).code;
|
|
263
272
|
if (code !== "ENOENT" && code !== "EPERM" && code !== "EACCES") throw err;
|
|
264
273
|
}
|
|
@@ -268,7 +277,7 @@ export function sweepStaleSessions(sessionId) {
|
|
|
268
277
|
|
|
269
278
|
/**
|
|
270
279
|
* Remove the shared-prefix artifacts the reader has stopped honouring, so they
|
|
271
|
-
* stop occupying
|
|
280
|
+
* stop occupying STATE_ROOT too. The loop above cannot: it skips the current
|
|
272
281
|
* session's prefix, which on a host with no session id IS this one.
|
|
273
282
|
*
|
|
274
283
|
* Mirrors what {@link invisibleCharAlert} and {@link alertAcknowledged} read.
|
|
@@ -281,14 +290,14 @@ export function sweepStaleSessions(sessionId) {
|
|
|
281
290
|
* scanned, or re-glob a launch this session already found empty. They still go,
|
|
282
291
|
* at MARKER_TTL_MS — the same age the loop above ages a real session's prefix
|
|
283
292
|
* out at, and far past any session's life — because the loop skips this prefix
|
|
284
|
-
* and would otherwise leave a session-less host's markers in
|
|
285
|
-
* with the gap notice suppressed on every later session.
|
|
293
|
+
* and would otherwise leave a session-less host's markers in STATE_ROOT
|
|
294
|
+
* forever, with the gap notice suppressed on every later session.
|
|
286
295
|
* @param {string} [sessionId]
|
|
287
296
|
* @returns {void}
|
|
288
297
|
*/
|
|
289
298
|
function sweepStaleFallback(sessionId) {
|
|
290
299
|
const dir = alertDir();
|
|
291
|
-
const inherited = alertDir(sessionId) !== dir &&
|
|
300
|
+
const inherited = alertDir(sessionId) !== dir && privateDirAt(dir);
|
|
292
301
|
const entries = inherited
|
|
293
302
|
? readdirSync(dir).map((name) => join(dir, name))
|
|
294
303
|
: [];
|
|
@@ -311,7 +320,7 @@ function sweepStaleFallback(sessionId) {
|
|
|
311
320
|
|
|
312
321
|
/**
|
|
313
322
|
* Record that the InstructionsLoaded scanner engaged. Symlink-safe presence
|
|
314
|
-
* write (see writeSentinelFile) at a predictable
|
|
323
|
+
* write (see writeSentinelFile) at a predictable STATE_ROOT path.
|
|
315
324
|
*
|
|
316
325
|
* The event fires once per instruction file loaded, so the already-recorded case
|
|
317
326
|
* returns without a write — and the stale-session sweep rides the FIRST fire of
|
|
@@ -334,12 +343,13 @@ export function recordInstructionsLoaded(sessionId) {
|
|
|
334
343
|
*/
|
|
335
344
|
const EVENT_MIN_CLI_VERSION = "2.1.69";
|
|
336
345
|
|
|
346
|
+
/** The `--hook=` name of the InstructionsLoaded scanner. */
|
|
347
|
+
const LOADED_SCAN_HOOK = "scan-loaded-instructions";
|
|
348
|
+
|
|
337
349
|
/**
|
|
338
350
|
* Companion marker: this session already found the LAUNCH set empty — that set
|
|
339
351
|
* (which files load at session start) cannot change mid-session, so a second
|
|
340
|
-
* glob of `dir` can never change
|
|
341
|
-
* a directory touched later; {@link instructionsLoadedGapNotice}'s `touchedDir`
|
|
342
|
-
* covers that half fresh on every call instead.
|
|
352
|
+
* glob of `dir` can never change the answer.
|
|
343
353
|
* @param {string} [sessionId]
|
|
344
354
|
* @returns {string}
|
|
345
355
|
*/
|
|
@@ -449,36 +459,106 @@ function userGlobalLaunchHasContent(env = process.env) {
|
|
|
449
459
|
);
|
|
450
460
|
}
|
|
451
461
|
|
|
462
|
+
/**
|
|
463
|
+
* The running Claude Code's version as `[major, minor, patch]`, or null when it
|
|
464
|
+
* cannot be read. Asks the binary rather than `CLAUDE_CODE_VERSION`: a remote
|
|
465
|
+
* host hands hooks a stale value there (2.1.42 under a 2.1.304 CLI).
|
|
466
|
+
* `CLAUDE_CODE_EXECPATH` names the binary running this session. Without it the
|
|
467
|
+
* answer is null: PATH's `claude` may be another install, and reporting its
|
|
468
|
+
* version as this session's would name a false cause. On an npm install that
|
|
469
|
+
* path is `node`, whose `v…` output also reads as null.
|
|
470
|
+
* @param {Record<string, string | undefined>} [env]
|
|
471
|
+
* @returns {number[] | null}
|
|
472
|
+
*/
|
|
473
|
+
function runningCliVersion(env = process.env) {
|
|
474
|
+
if (!env.CLAUDE_CODE_EXECPATH) return null;
|
|
475
|
+
const result = spawnSync(env.CLAUDE_CODE_EXECPATH, ["--version"], {
|
|
476
|
+
encoding: "utf8",
|
|
477
|
+
timeout: 2000,
|
|
478
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
479
|
+
});
|
|
480
|
+
const match = /^(\d+)\.(\d+)\.(\d+)/u.exec(result.stdout ?? "");
|
|
481
|
+
return match ? match.slice(1).map(Number) : null;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Whether `version` is older than EVENT_MIN_CLI_VERSION.
|
|
486
|
+
* @param {number[]} version
|
|
487
|
+
* @returns {boolean}
|
|
488
|
+
*/
|
|
489
|
+
function predatesEvent(version) {
|
|
490
|
+
const floor = EVENT_MIN_CLI_VERSION.split(".").map(Number);
|
|
491
|
+
for (let i = 0; i < floor.length; i++)
|
|
492
|
+
if (version[i] !== floor[i]) return version[i] < floor[i];
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* The causes of a missing InstructionsLoaded scan that this hook could NOT rule
|
|
498
|
+
* out, each with the command that settles it. The disable list and the CLI
|
|
499
|
+
* version are checked here; only the wiring is left for `/hooks`. A cause that
|
|
500
|
+
* is confirmed is returned alone, since it explains the gap by itself.
|
|
501
|
+
* @param {Record<string, string | undefined>} env
|
|
502
|
+
* @param {(env: Record<string, string | undefined>) => number[] | null} cliVersion
|
|
503
|
+
* @returns {string}
|
|
504
|
+
*/
|
|
505
|
+
function unexplainedGapCauses(env, cliVersion) {
|
|
506
|
+
// A real list of hook names never reaches this one-name `known`, so the
|
|
507
|
+
// unknown-name report would misfire on every other entry: it is silenced.
|
|
508
|
+
if (disabledHooks([LOADED_SCAN_HOOK], env, () => {}).size > 0)
|
|
509
|
+
return `${LOADED_SCAN_HOOK} is switched off in ${DISABLED_HOOKS_ENV}; removing it from that list restores the scan.`;
|
|
510
|
+
const unwired =
|
|
511
|
+
`this host never wired the InstructionsLoaded event to ${LOADED_SCAN_HOOK} ` +
|
|
512
|
+
"(the `/hooks` command lists what this session registered; wiring it " +
|
|
513
|
+
"restores the scan), or the hook crashed before it ran";
|
|
514
|
+
const version = cliVersion(env);
|
|
515
|
+
if (version === null)
|
|
516
|
+
return (
|
|
517
|
+
`${LOADED_SCAN_HOOK} is not disabled. Either ${unwired}, or this Claude ` +
|
|
518
|
+
`Code is older than ${EVENT_MIN_CLI_VERSION}, the first build that emits ` +
|
|
519
|
+
"the event (its version could not be read: run `claude --version`)."
|
|
520
|
+
);
|
|
521
|
+
if (predatesEvent(version))
|
|
522
|
+
return (
|
|
523
|
+
`this Claude Code is ${version.join(".")}, older than ` +
|
|
524
|
+
`${EVENT_MIN_CLI_VERSION}, the first build that emits the event; ` +
|
|
525
|
+
"upgrading restores the scan."
|
|
526
|
+
);
|
|
527
|
+
return (
|
|
528
|
+
`${LOADED_SCAN_HOOK} is not disabled and Claude Code ${version.join(".")} ` +
|
|
529
|
+
`emits the event, so either ${unwired}.`
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
452
533
|
/**
|
|
453
534
|
* The one-time context line for a session where no InstructionsLoaded scan ran,
|
|
454
535
|
* or null when the scan has been seen, the notice already ran this session, the
|
|
455
|
-
* host's cold-start marker says setup is still running, or
|
|
456
|
-
*
|
|
536
|
+
* host's cold-start marker says setup is still running, or nothing announced at
|
|
537
|
+
* launch could have fired the event.
|
|
457
538
|
*
|
|
458
539
|
* PURE for the notice: nothing is recorded until the caller confirms it landed
|
|
459
|
-
* in a returned response. Launch-emptiness is cached once found
|
|
460
|
-
*
|
|
461
|
-
* SUBDIRECTORY files, so an empty launch must not silence a later call whose
|
|
462
|
-
* tool touched a directory that DOES carry real, unscanned content.
|
|
540
|
+
* in a returned response. Launch-emptiness is cached once found, since the
|
|
541
|
+
* launch set is fixed for the session.
|
|
463
542
|
*
|
|
464
|
-
*
|
|
465
|
-
* the
|
|
466
|
-
*
|
|
467
|
-
*
|
|
468
|
-
* the marker cannot tell them apart: an unwired event, a Claude Code older than
|
|
469
|
-
* EVENT_MIN_CLI_VERSION, or the hook disabled via AGENT_SANITIZER_DISABLED_HOOKS.
|
|
543
|
+
* Launch is the only evidence read. Claude Code fires the session_start events
|
|
544
|
+
* before the first PreToolUse, but a subdirectory's CLAUDE.md loads only AFTER
|
|
545
|
+
* the tool call that reaches it, so a PreToolUse can never already see that
|
|
546
|
+
* file's scan, however the hook is wired.
|
|
470
547
|
* @param {string} [sessionId] the harness's session identity (see
|
|
471
548
|
* instructionsLoadedFile)
|
|
472
549
|
* @param {string} [dir] the project root to check (injectable; defaults to
|
|
473
550
|
* the real project)
|
|
474
|
-
* @param {
|
|
475
|
-
*
|
|
551
|
+
* @param {{
|
|
552
|
+
* env?: Record<string, string | undefined>,
|
|
553
|
+
* cliVersion?: (env: Record<string, string | undefined>) => number[] | null,
|
|
554
|
+
* }} [options] injectable for tests; any other value, such as a directory
|
|
555
|
+
* string from an older caller, reads as no options
|
|
476
556
|
* @returns {string | null}
|
|
477
557
|
*/
|
|
478
558
|
export function instructionsLoadedGapNotice(
|
|
479
559
|
sessionId,
|
|
480
560
|
dir = PROJECT_DIR,
|
|
481
|
-
|
|
561
|
+
{ env = process.env, cliVersion = runningCliVersion } = {},
|
|
482
562
|
) {
|
|
483
563
|
if (instructionsLoadedSeen(sessionId)) return null;
|
|
484
564
|
if (markerIsTrusted(instructionsLoadedNoticeFile(sessionId))) return null;
|
|
@@ -491,31 +571,20 @@ export function instructionsLoadedGapNotice(
|
|
|
491
571
|
// notice for every later session on the host.
|
|
492
572
|
const marker = hookgateMarkerPath();
|
|
493
573
|
if (markerIsTrusted(marker) && probeSetupAlive(marker)) return null;
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
!
|
|
497
|
-
(
|
|
498
|
-
|
|
499
|
-
if (!launchCached && !launchHasBytes)
|
|
574
|
+
if (markerIsTrusted(launchEmptyFile(sessionId))) return null;
|
|
575
|
+
if (
|
|
576
|
+
!anyFileHasBytes(announcedLaunchFiles(dir)) &&
|
|
577
|
+
!userGlobalLaunchHasContent(env)
|
|
578
|
+
) {
|
|
500
579
|
writeSentinelFile(launchEmptyFile(sessionId));
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
anyFileHasBytes(announcedLaunchFiles(touchedDir));
|
|
504
|
-
if (!launchHasBytes && !touchedHasBytes) return null;
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
505
582
|
return (
|
|
506
583
|
"agent-sanitizer: no InstructionsLoaded scan has run this session, so " +
|
|
507
584
|
"instruction files loaded from SUBDIRECTORIES (a nested CLAUDE.md, a " +
|
|
508
585
|
"directory-scoped rule) are reaching the model unscanned for hidden " +
|
|
509
586
|
"Unicode — the session-start scan covers only the files loaded at launch. " +
|
|
510
|
-
|
|
511
|
-
"each: this host never wired the InstructionsLoaded event to " +
|
|
512
|
-
"scan-loaded-instructions (the `/hooks` command lists what this session " +
|
|
513
|
-
"actually registered, whichever config dir or plugin root the host uses; " +
|
|
514
|
-
"wiring it restores the coverage), a Claude Code " +
|
|
515
|
-
`older than ${EVENT_MIN_CLI_VERSION}, the first build that emits the event ` +
|
|
516
|
-
"(`claude --version`; upgrading restores it), or scan-loaded-instructions " +
|
|
517
|
-
"switched off in AGENT_SANITIZER_DISABLED_HOOKS (`echo " +
|
|
518
|
-
"$AGENT_SANITIZER_DISABLED_HOOKS`)."
|
|
587
|
+
`Tell the user: ${unexplainedGapCauses(env, cliVersion)}`
|
|
519
588
|
);
|
|
520
589
|
}
|
|
521
590
|
|
|
@@ -534,14 +603,14 @@ export function recordInstructionsLoadedNotice(sessionId) {
|
|
|
534
603
|
* The alert findings if invisible-char injection was detected in instruction
|
|
535
604
|
* files and couldn't be auto-cleaned, else null.
|
|
536
605
|
*
|
|
537
|
-
* The store is a DIRECTORY of one file per finding, all at predictable
|
|
538
|
-
* world-visible $TMPDIR
|
|
539
|
-
*
|
|
540
|
-
* uid owns (a symlink would let a
|
|
541
|
-
*
|
|
542
|
-
*
|
|
543
|
-
*
|
|
544
|
-
* model's context.
|
|
606
|
+
* The store is a DIRECTORY of one file per finding, all at predictable
|
|
607
|
+
* STATE_ROOT paths (world-visible in the $TMPDIR fallback), so both the
|
|
608
|
+
* directory and every entry in it are attacker-plantable: trust the directory
|
|
609
|
+
* only when it is a real directory this uid owns (a symlink would let a
|
|
610
|
+
* co-tenant aim this reader at unrelated files), each entry only when
|
|
611
|
+
* markerIsTrusted confirms a regular file this uid owns, then scrub the bytes
|
|
612
|
+
* through Layer-1 before any caller splices them into a reason — the report
|
|
613
|
+
* would otherwise carry ANSI/invisible spoofing into the model's context.
|
|
545
614
|
* This session's store AND the shared `no-session` fallback, because a hook that
|
|
546
615
|
* faults BEFORE it can parse its payload has no session identity to key by: its
|
|
547
616
|
* finding lands in the fallback, and a strictly session-keyed read would leave
|
|
@@ -560,7 +629,10 @@ export function invisibleCharAlert(sessionId) {
|
|
|
560
629
|
const dirs = own === alertDir() ? [own] : [own, alertDir()];
|
|
561
630
|
const parts = [];
|
|
562
631
|
for (const dir of dirs) {
|
|
563
|
-
if (!
|
|
632
|
+
if (!privateDirAt(dir)) continue;
|
|
633
|
+
// A live session's findings must outlast any sweep that ages the store by
|
|
634
|
+
// its directory's timestamp: losing them lifts the gate mid-session.
|
|
635
|
+
if (dir === own) touchLiveState(dir);
|
|
564
636
|
// Sorted so a multi-finding report reads the same on every call; the names
|
|
565
637
|
// are random, so the order carries no meaning beyond being stable.
|
|
566
638
|
for (const name of readdirSync(dir).sort()) {
|
|
@@ -583,7 +655,7 @@ export function invisibleCharAlert(sessionId) {
|
|
|
583
655
|
* through a read-modify-write would let two hooks recording a finding at once
|
|
584
656
|
* silently drop one of them; a fresh file per finding has no shared cell to
|
|
585
657
|
* lose. Symlink-refusing (writeFileNoFollow) because the store sits at a
|
|
586
|
-
* predictable, world-visible $TMPDIR
|
|
658
|
+
* predictable STATE_ROOT path, world-visible in the $TMPDIR fallback.
|
|
587
659
|
* @param {string} text
|
|
588
660
|
* @param {string} [sessionId]
|
|
589
661
|
* @returns {boolean} whether the finding was recorded
|
|
@@ -597,7 +669,7 @@ export function appendAlert(text, sessionId) {
|
|
|
597
669
|
let usable;
|
|
598
670
|
try {
|
|
599
671
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
600
|
-
usable =
|
|
672
|
+
usable = privateDirAt(dir);
|
|
601
673
|
} catch {
|
|
602
674
|
usable = false;
|
|
603
675
|
}
|
|
@@ -612,9 +684,10 @@ export function appendAlert(text, sessionId) {
|
|
|
612
684
|
/**
|
|
613
685
|
* True once the gate has surfaced its blocking ask this session. Validates
|
|
614
686
|
* ownership (not mere existence): a co-tenant could pre-create the ack at its
|
|
615
|
-
* predictable
|
|
616
|
-
* to the passive reminder, so trust the marker only when it is a regular
|
|
617
|
-
* this uid wrote (markerIsTrusted), mirroring how acknowledgeAlert writes
|
|
687
|
+
* predictable STATE_ROOT path to permanently suppress the one-time blocking ask
|
|
688
|
+
* down to the passive reminder, so trust the marker only when it is a regular
|
|
689
|
+
* file this uid wrote (markerIsTrusted), mirroring how acknowledgeAlert writes
|
|
690
|
+
* it.
|
|
618
691
|
*
|
|
619
692
|
* On a host that exports no session id every session shares the fallback prefix,
|
|
620
693
|
* so the ack has no session to end with and would suppress the one-time blocking
|
|
@@ -638,8 +711,8 @@ export function alertAcknowledged(sessionId) {
|
|
|
638
711
|
* @returns {void}
|
|
639
712
|
*/
|
|
640
713
|
export function acknowledgeAlert(sessionId) {
|
|
641
|
-
// Symlink-safe presence write: the ack sits at a predictable
|
|
642
|
-
// co-tenant could pre-plant a symlink at (see writeSentinelFile).
|
|
714
|
+
// Symlink-safe presence write: the ack sits at a predictable STATE_ROOT path
|
|
715
|
+
// a co-tenant could pre-plant a symlink at (see writeSentinelFile).
|
|
643
716
|
writeSentinelFile(alertAckFile(sessionId));
|
|
644
717
|
}
|
|
645
718
|
|
|
@@ -35,7 +35,12 @@ import {
|
|
|
35
35
|
encodeFrame,
|
|
36
36
|
isPlainObject,
|
|
37
37
|
} from "./redactor-wire.mjs";
|
|
38
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
currentUid,
|
|
40
|
+
isPrivateDir,
|
|
41
|
+
ownedByCurrentUser,
|
|
42
|
+
privateDirAt,
|
|
43
|
+
} from "./hook-io.mjs";
|
|
39
44
|
import { readKnob } from "./knobs.mjs";
|
|
40
45
|
|
|
41
46
|
export { FRAME_CAP };
|
|
@@ -54,25 +59,6 @@ export { positiveMsOr } from "./knobs.mjs";
|
|
|
54
59
|
const SOCKET_PATH_MAX = 103;
|
|
55
60
|
const TEMP_BIND_NAME_LENGTH = 17;
|
|
56
61
|
|
|
57
|
-
/**
|
|
58
|
-
* Whether `root` is a real directory this uid owns that no group/other can
|
|
59
|
-
* write. lstat, so a symlinked root is refused rather than followed. An absent
|
|
60
|
-
* root is just not a candidate: this runs at module load, where a throw would
|
|
61
|
-
* stop the hook loading at all.
|
|
62
|
-
* @param {string} root
|
|
63
|
-
* @param {number | null} uid
|
|
64
|
-
* @returns {boolean}
|
|
65
|
-
*/
|
|
66
|
-
function isPrivateRoot(root, uid) {
|
|
67
|
-
let st;
|
|
68
|
-
try {
|
|
69
|
-
st = lstatSync(root);
|
|
70
|
-
} catch {
|
|
71
|
-
return false;
|
|
72
|
-
}
|
|
73
|
-
return isPrivateDir(st, { uid });
|
|
74
|
-
}
|
|
75
|
-
|
|
76
62
|
/**
|
|
77
63
|
* The redactor socket path: one per user, shared by every session of that user
|
|
78
64
|
* (the daemon redacts each REQUESTER's env secrets). The daemon binds it 0600
|
|
@@ -81,7 +67,7 @@ function isPrivateRoot(root, uid) {
|
|
|
81
67
|
* 1. `_AGENT_SANITIZER_REDACTOR_SOCKET` — explicit override.
|
|
82
68
|
* 2. `$XDG_RUNTIME_DIR`, then `$CLAUDE_PLUGIN_DATA` — per-user directories no
|
|
83
69
|
* other uid can write, so a co-tenant cannot pre-create the socket dir under
|
|
84
|
-
* them. Taken only if
|
|
70
|
+
* them. Taken only if privateDirAt passes and the path fits sun_path.
|
|
85
71
|
* 3. `<tmpdir>/agent-sanitizer-redactor-<uid>` — on a shared /tmp a co-tenant
|
|
86
72
|
* can still pre-create it; the daemon then refuses to serve and the client
|
|
87
73
|
* refuses to connect (classifySocket), costing availability, never a secret.
|
|
@@ -94,7 +80,7 @@ export function resolveSocketPath(env, uid) {
|
|
|
94
80
|
const override = readKnob("_AGENT_SANITIZER_REDACTOR_SOCKET", env);
|
|
95
81
|
if (override) return override;
|
|
96
82
|
for (const root of [env.XDG_RUNTIME_DIR, env.CLAUDE_PLUGIN_DATA]) {
|
|
97
|
-
if (!root || !
|
|
83
|
+
if (!root || !privateDirAt(root, { uid })) continue;
|
|
98
84
|
const dir = join(root, "agent-sanitizer-redactor");
|
|
99
85
|
if (Buffer.byteLength(dir) + 1 + TEMP_BIND_NAME_LENGTH <= SOCKET_PATH_MAX)
|
|
100
86
|
return join(dir, "redactor.sock");
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* instructions" envelope (REVEAL_READ_ENVELOPE) re-attached when that file is read.
|
|
10
10
|
* Read is not untrusted ingress, so a Read of the sidecar already bypasses
|
|
11
11
|
* Layer 2 (no re-splice); the carve-out's job is to mark the bytes untrusted.
|
|
12
|
-
* The store is content-addressed (identical output dedupes) and lives under
|
|
13
|
-
*
|
|
12
|
+
* The store is content-addressed (identical output dedupes) and lives under
|
|
13
|
+
* STATE_ROOT; _AGENT_SANITIZER_REVEAL_DIR overrides the location.
|
|
14
14
|
*
|
|
15
15
|
* The same dir also holds the per-splice SPAN files (`span-<key>.txt`): each
|
|
16
16
|
* Layer-2 splice's (redacted) original, keyed by its placeholder's
|
|
@@ -25,17 +25,26 @@ import {
|
|
|
25
25
|
mkdirSync,
|
|
26
26
|
lstatSync,
|
|
27
27
|
readdirSync,
|
|
28
|
+
rmSync,
|
|
28
29
|
unlinkSync,
|
|
29
30
|
openSync,
|
|
30
31
|
readFileSync,
|
|
31
32
|
closeSync,
|
|
32
33
|
constants,
|
|
33
34
|
} from "node:fs";
|
|
34
|
-
import { tmpdir } from "node:os";
|
|
35
35
|
import { join, resolve, sep } from "node:path";
|
|
36
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
PROJECT_HASH,
|
|
38
|
+
STATE_ROOT,
|
|
39
|
+
privateDirAt,
|
|
40
|
+
writeFileNoFollow,
|
|
41
|
+
} from "./hook-io.mjs";
|
|
37
42
|
import { readKnob } from "./knobs.mjs";
|
|
38
43
|
|
|
44
|
+
/** The default reveal store's name, less the project hash that completes it. */
|
|
45
|
+
const REVEAL_STORE_STEM = "agent-sanitizer-layer2-reveal-";
|
|
46
|
+
const ANY_PROJECT_HASH = /^[0-9a-f]{8}$/u;
|
|
47
|
+
|
|
39
48
|
/**
|
|
40
49
|
* Where reveal sidecars are stored. Exported so the PreToolUse placeholder
|
|
41
50
|
* advisory can name the directory a spliced original was saved under without
|
|
@@ -43,14 +52,13 @@ import { readKnob } from "./knobs.mjs";
|
|
|
43
52
|
* @returns {string}
|
|
44
53
|
*/
|
|
45
54
|
export function revealDir() {
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
// published placeholder grammar, not a path detail.
|
|
55
|
+
// Under STATE_ROOT, so an OS temp cleaner cannot empty it under a live session.
|
|
56
|
+
// Project-keyed, so one project's sweep cannot age out another's spans — either
|
|
57
|
+
// loss fails a rehydration closed. The 12-hex span KEY inside is untouched: it
|
|
58
|
+
// is the published placeholder grammar, not a path detail.
|
|
51
59
|
return (
|
|
52
60
|
readKnob("_AGENT_SANITIZER_REVEAL_DIR") ??
|
|
53
|
-
join(
|
|
61
|
+
join(STATE_ROOT, `${REVEAL_STORE_STEM}${PROJECT_HASH}`)
|
|
54
62
|
);
|
|
55
63
|
}
|
|
56
64
|
|
|
@@ -63,7 +71,8 @@ export function revealDir() {
|
|
|
63
71
|
export const REVEAL_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
64
72
|
|
|
65
73
|
/**
|
|
66
|
-
* Delete this project's reveal sidecars and spans older than {@link REVEAL_TTL_MS}
|
|
74
|
+
* Delete this project's reveal sidecars and spans older than {@link REVEAL_TTL_MS},
|
|
75
|
+
* and every other project's default store that holds nothing younger.
|
|
67
76
|
*
|
|
68
77
|
* Nothing else removes them: every entry is content-addressed, so a store that is
|
|
69
78
|
* never swept grows for the life of the machine. Called from SessionStart, the one
|
|
@@ -71,6 +80,7 @@ export const REVEAL_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
71
80
|
* @returns {void}
|
|
72
81
|
*/
|
|
73
82
|
export function sweepStaleReveals() {
|
|
83
|
+
sweepAbandonedRevealStores();
|
|
74
84
|
const dir = revealDir();
|
|
75
85
|
// existsSync first: revealDirIsSafe CREATES the directory, and a sweep run on
|
|
76
86
|
// every session start must not leave an empty store behind for a project that
|
|
@@ -93,6 +103,37 @@ export function sweepStaleReveals() {
|
|
|
93
103
|
}
|
|
94
104
|
}
|
|
95
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Remove OTHER projects' default reveal stores under STATE_ROOT once they are
|
|
108
|
+
* older than {@link REVEAL_TTL_MS}. Each project sweeps only its own store's
|
|
109
|
+
* files, and STATE_ROOT is persistent when it is the plugin data dir, so a
|
|
110
|
+
* project never reopened would keep its spans — tool output — for good. A
|
|
111
|
+
* store's mtime moves with every entry written or swept, and entries are
|
|
112
|
+
* write-once, so a store past the cutoff holds only files past it too.
|
|
113
|
+
* @returns {void}
|
|
114
|
+
*/
|
|
115
|
+
function sweepAbandonedRevealStores() {
|
|
116
|
+
const cutoff = Date.now() - REVEAL_TTL_MS;
|
|
117
|
+
for (const name of readdirSync(STATE_ROOT)) {
|
|
118
|
+
const path = join(STATE_ROOT, name);
|
|
119
|
+
const isStore =
|
|
120
|
+
name.startsWith(REVEAL_STORE_STEM) &&
|
|
121
|
+
ANY_PROJECT_HASH.test(name.slice(REVEAL_STORE_STEM.length));
|
|
122
|
+
if (!isStore || path === revealDir()) continue;
|
|
123
|
+
// privateDirAt: a co-tenant's store, or a symlink planted at the name, is
|
|
124
|
+
// not this uid's to remove.
|
|
125
|
+
if (!privateDirAt(path)) continue;
|
|
126
|
+
try {
|
|
127
|
+
if (lstatSync(path).mtimeMs < cutoff)
|
|
128
|
+
rmSync(path, { recursive: true, force: true });
|
|
129
|
+
} catch (err) {
|
|
130
|
+
// ENOENT: a parallel session swept this store between readdir and lstat.
|
|
131
|
+
if (/** @type {NodeJS.ErrnoException} */ (err).code !== "ENOENT")
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
96
137
|
/**
|
|
97
138
|
* Content-addressed path the pre-splice text of `content` is stored at.
|
|
98
139
|
* @param {string} content
|
|
@@ -120,19 +161,7 @@ function revealDirIsSafe(dir) {
|
|
|
120
161
|
} catch {
|
|
121
162
|
return false;
|
|
122
163
|
}
|
|
123
|
-
|
|
124
|
-
try {
|
|
125
|
-
st = lstatSync(dir);
|
|
126
|
-
/* c8 ignore start -- TOCTOU-race defense: after the mkdirSync above succeeds, dir
|
|
127
|
-
exists with accessible parents, so lstatSync can only throw if a co-tenant removes
|
|
128
|
-
or replaces it in the window between the two syscalls — unreachable in a
|
|
129
|
-
single-threaded test, yet load-bearing to keep persistReveal fail-closed on that
|
|
130
|
-
race rather than crashing. */
|
|
131
|
-
} catch {
|
|
132
|
-
return false;
|
|
133
|
-
}
|
|
134
|
-
/* c8 ignore stop */
|
|
135
|
-
return isPrivateDir(st);
|
|
164
|
+
return privateDirAt(dir);
|
|
136
165
|
}
|
|
137
166
|
|
|
138
167
|
/**
|
|
@@ -29,7 +29,6 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import { createRequire } from "node:module";
|
|
31
31
|
import { readFileSync } from "node:fs";
|
|
32
|
-
import { dirname } from "node:path";
|
|
33
32
|
import {
|
|
34
33
|
isMain,
|
|
35
34
|
lazyImport,
|
|
@@ -438,40 +437,6 @@ export function preToolUseLayers(
|
|
|
438
437
|
);
|
|
439
438
|
}
|
|
440
439
|
|
|
441
|
-
// The tools whose path field Claude Code sends as an absolute path by
|
|
442
|
-
// contract, so reading it needs no cwd to resolve against. Bounded to these:
|
|
443
|
-
// Glob/Grep's `path` can be relative or omitted (defaults to a cwd this
|
|
444
|
-
// payload does not carry), and Bash has no reliable target at all — the same
|
|
445
|
-
// carve-out WRITE_SHAPED_TOOLS below takes for Bash writes.
|
|
446
|
-
const PATH_FIELD_BY_TOOL = /** @type {Record<string, string>} */ (
|
|
447
|
-
Object.freeze({
|
|
448
|
-
Read: "file_path",
|
|
449
|
-
Edit: "file_path",
|
|
450
|
-
Write: "file_path",
|
|
451
|
-
MultiEdit: "file_path",
|
|
452
|
-
NotebookEdit: "notebook_path",
|
|
453
|
-
})
|
|
454
|
-
);
|
|
455
|
-
|
|
456
|
-
/**
|
|
457
|
-
* The directory THIS tool call targets, for the InstructionsLoaded gap-notice
|
|
458
|
-
* check — or undefined when the tool carries no reliable absolute path. An
|
|
459
|
-
* imprecise guess here only ever WIDENS coverage (see instructionsLoadedGapNotice's
|
|
460
|
-
* `touchedDir`): missing a real target loses nothing this check did not
|
|
461
|
-
* already lack, and there is no wrong-directory case that suppresses a real
|
|
462
|
-
* finding.
|
|
463
|
-
* @param {string} tool
|
|
464
|
-
* @param {any} toolInput
|
|
465
|
-
* @returns {string | undefined}
|
|
466
|
-
*/
|
|
467
|
-
function toolTargetDir(tool, toolInput) {
|
|
468
|
-
const field = PATH_FIELD_BY_TOOL[tool];
|
|
469
|
-
const path = field && toolInput?.[field];
|
|
470
|
-
return typeof path === "string" && path.startsWith("/")
|
|
471
|
-
? dirname(path)
|
|
472
|
-
: undefined;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
440
|
/**
|
|
476
441
|
* Compose the five stages. Returns the `hookSpecificOutput` fields to emit, or
|
|
477
442
|
* null for a clean no-op. Throws only if a stage's engine throws; the caller
|
|
@@ -523,11 +488,7 @@ export async function buildPreToolUseResponse(input, rehydrate, sink, layers) {
|
|
|
523
488
|
// the notice was surfaced: a rehydrate deny below returns before the response
|
|
524
489
|
// is assembled, and recording here would burn the session's one report on a
|
|
525
490
|
// call that never carried it.
|
|
526
|
-
const gapNotice = instructionsLoadedGapNotice(
|
|
527
|
-
input.session_id,
|
|
528
|
-
undefined,
|
|
529
|
-
toolTargetDir(tool, toolInput),
|
|
530
|
-
);
|
|
491
|
+
const gapNotice = instructionsLoadedGapNotice(input.session_id);
|
|
531
492
|
if (gapNotice !== null) contexts.push(gapNotice);
|
|
532
493
|
|
|
533
494
|
// The rewriting stages, run by the declared pipeline: the driver — not this
|
|
@@ -495,10 +495,10 @@ async function runScanCli({ trace: sink, scan: runScan, sessionId }) {
|
|
|
495
495
|
// sessions left instead — SessionStart is the once-per-session touchpoint the
|
|
496
496
|
// sweep belongs on.
|
|
497
497
|
sweepStaleSessions(sessionId);
|
|
498
|
-
// The other two
|
|
499
|
-
// fingerprint-addressed with no natural owner
|
|
500
|
-
// sweep they grow for the life of the machine;
|
|
501
|
-
// touchpoint that runs once per session
|
|
498
|
+
// The other two stores these hooks own (reveal under STATE_ROOT, confirm in
|
|
499
|
+
// $TMPDIR). Both are content- or fingerprint-addressed with no natural owner
|
|
500
|
+
// to delete them, so without a sweep they grow for the life of the machine;
|
|
501
|
+
// SessionStart is the only touchpoint that runs once per session.
|
|
502
502
|
sweepStaleReveals();
|
|
503
503
|
sweepStaleConfirms();
|
|
504
504
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.66.
|
|
3
|
+
"version": "2.66.5",
|
|
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": {
|
|
@@ -294,6 +294,27 @@ export function safeErrMessage(err: unknown, cap?: number): string;
|
|
|
294
294
|
* @returns {void}
|
|
295
295
|
*/
|
|
296
296
|
export function emitHookResponse(hookEventName: string, fields: Record<string, unknown>): void;
|
|
297
|
+
/**
|
|
298
|
+
* The directory the session-lifetime stores live under: the alert markers and
|
|
299
|
+
* findings (invisible-alert.mjs) and the reveal sidecars (reveal.mjs). They must
|
|
300
|
+
* outlive the longest session, which $TMPDIR does not promise — macOS's daily
|
|
301
|
+
* dirhelper job deletes anything in the per-user `/var/folders/.../T/` not
|
|
302
|
+
* accessed for 3 days, and a marker written at launch and only ever `lstat`ed
|
|
303
|
+
* then vanishes from a session still running, its absence read as "never ran".
|
|
304
|
+
*
|
|
305
|
+
* `$CLAUDE_PLUGIN_DATA/session-state` when both levels are private to this uid
|
|
306
|
+
* ({@link privateDirAt}): write on the plugin data dir alone is enough to swap
|
|
307
|
+
* `session-state` out. Each level is created 0700 when absent — so the
|
|
308
|
+
* launch-time InstructionsLoaded event and the first tool call resolve the same
|
|
309
|
+
* root whichever runs first — and checked BEFORE the next is created inside it,
|
|
310
|
+
* so a symlinked or shared plugin data dir never gets a directory planted in
|
|
311
|
+
* its target. A root this process cannot write — a read-only mount, an owner
|
|
312
|
+
* bit removed — is no root: every marker write would fail and read as "the
|
|
313
|
+
* event never fired". Otherwise $TMPDIR, where the stores' squat checks apply.
|
|
314
|
+
* @param {Record<string, string | undefined>} env
|
|
315
|
+
* @returns {string}
|
|
316
|
+
*/
|
|
317
|
+
export function resolveStateRoot(env: Record<string, string | undefined>): string;
|
|
297
318
|
/**
|
|
298
319
|
* Adopt a host's own cold-start marker path in place of the derived one, so a
|
|
299
320
|
* host whose setup script already writes a marker under its own convention can
|
|
@@ -441,7 +462,7 @@ export function markerIsTrusted(path: string | null): boolean;
|
|
|
441
462
|
export function currentUid(): number | null;
|
|
442
463
|
/**
|
|
443
464
|
* Whether `st` (an lstat result) is owned by this process's uid. THE ownership
|
|
444
|
-
* predicate for every
|
|
465
|
+
* predicate for every state store these hooks trust — a second spelling is how
|
|
445
466
|
* four call sites came to share the passwd-lookup crash {@link currentUid}
|
|
446
467
|
* documents. False when the platform has no uid model: ownership that cannot be
|
|
447
468
|
* proven is not granted. `uid` is a seam for a test that must model another
|
|
@@ -457,10 +478,11 @@ export function ownedByCurrentUser(st: {
|
|
|
457
478
|
* Whether `st` (an lstat result) is a directory no untrusted uid can change: a
|
|
458
479
|
* real directory (lstat, so a planted symlink is not one), owned by this uid —
|
|
459
480
|
* or by root where `rootMayOwn` — with no group/other write bit. THE trust test
|
|
460
|
-
* for every private directory these hooks keep
|
|
461
|
-
* property because creating, unlinking or replacing an entry needs
|
|
462
|
-
* parent: a co-tenant holding it can delete the finding a gate
|
|
463
|
-
* reveal file, or rebind the redactor socket to a listener that
|
|
481
|
+
* for every private directory these hooks keep, under STATE_ROOT or $TMPDIR.
|
|
482
|
+
* Write is the property because creating, unlinking or replacing an entry needs
|
|
483
|
+
* write on its parent: a co-tenant holding it can delete the finding a gate
|
|
484
|
+
* needs, swap a reveal file, or rebind the redactor socket to a listener that
|
|
485
|
+
* redacts nothing.
|
|
464
486
|
* Root ownership is strictly stronger than ours, so a deployment may hand a dir
|
|
465
487
|
* to root (`rootMayOwn`); with no uid model nothing is trusted, root included.
|
|
466
488
|
* @param {{ isDirectory(): boolean, uid: number, mode: number }} st
|
|
@@ -476,6 +498,29 @@ export function isPrivateDir(st: {
|
|
|
476
498
|
uid?: number | null;
|
|
477
499
|
rootMayOwn?: boolean;
|
|
478
500
|
}): boolean;
|
|
501
|
+
/**
|
|
502
|
+
* Whether `path` is a private directory of this uid ({@link isPrivateDir}) —
|
|
503
|
+
* the directory counterpart of markerIsTrusted. `lstat`, so a symlink planted at
|
|
504
|
+
* a predictable path is judged on ITSELF: followed, it would let a co-tenant aim
|
|
505
|
+
* a reader at unrelated files this uid owns (the alert gate splices those bytes
|
|
506
|
+
* into a permission prompt). An absent path is simply not one.
|
|
507
|
+
* @param {string} path
|
|
508
|
+
* @param {{ uid?: number | null }} [opts] as in {@link isPrivateDir}
|
|
509
|
+
* @returns {boolean}
|
|
510
|
+
*/
|
|
511
|
+
export function privateDirAt(path: string, opts?: {
|
|
512
|
+
uid?: number | null;
|
|
513
|
+
}): boolean;
|
|
514
|
+
/**
|
|
515
|
+
* Stamp a state file the caller has just trusted as used now, so a live
|
|
516
|
+
* session's state never looks idle to an age-based sweep — another session's
|
|
517
|
+
* {@link STATE_ROOT} sweep (mtime) or an OS temp cleaner (atime). `lutimes`, so
|
|
518
|
+
* a symlink swapped in after the caller's check is stamped itself, never its
|
|
519
|
+
* target. A path a parallel sweep removed after that check is simply gone.
|
|
520
|
+
* @param {string} path
|
|
521
|
+
* @returns {void}
|
|
522
|
+
*/
|
|
523
|
+
export function touchLiveState(path: string): void;
|
|
479
524
|
/**
|
|
480
525
|
* Create a presence sentinel at `path` without following a symlink a co-tenant
|
|
481
526
|
* may have pre-planted there. These sentinels live at predictable, world-visible
|
|
@@ -585,6 +630,8 @@ export const DEFAULT_MISSING_PACKAGE_REMEDY: "reinstall the hook dependencies (p
|
|
|
585
630
|
export const PROJECT_DIR: string;
|
|
586
631
|
/** Short project digest keying this project's $TMPDIR store names. */
|
|
587
632
|
export const PROJECT_HASH: string;
|
|
633
|
+
/** {@link resolveStateRoot} for this process, resolved once at load. */
|
|
634
|
+
export const STATE_ROOT: string;
|
|
588
635
|
/**
|
|
589
636
|
* The line a cold-start marker carries on its own to declare that its writer holds
|
|
590
637
|
* an exclusive `flock` on the marker file for the whole install.
|
|
@@ -46,16 +46,21 @@ export function instructionsLoadedNoticeFile(sessionId?: string): string;
|
|
|
46
46
|
* lazily-loaded instruction files are being scanned at all. Ownership-validated
|
|
47
47
|
* like every other marker here: a co-tenant could otherwise plant the
|
|
48
48
|
* predictable path and suppress the notice below, which is the whole signal that
|
|
49
|
-
* nested files are going unscanned.
|
|
49
|
+
* nested files are going unscanned. A marker that is seen is stamped as used:
|
|
50
|
+
* it is written once at launch, so otherwise a session outliving
|
|
51
|
+
* MARKER_TTL_MS would have it swept by another session's SessionStart and read
|
|
52
|
+
* its absence as the event never having fired.
|
|
50
53
|
* @param {string} [sessionId]
|
|
51
54
|
* @returns {boolean}
|
|
52
55
|
*/
|
|
53
56
|
export function instructionsLoadedSeen(sessionId?: string): boolean;
|
|
54
57
|
/**
|
|
55
|
-
* Delete
|
|
58
|
+
* Delete OTHER sessions' artifacts, of every project, once they are older than
|
|
56
59
|
* the TTL. The current session's own prefix is skipped, so the sweep can never
|
|
57
60
|
* answer its own question wrong; every other session's files are past history
|
|
58
|
-
* that nothing reads.
|
|
61
|
+
* that nothing reads — a live one keeps its marker and findings fresh on read.
|
|
62
|
+
* Every project, because STATE_ROOT may be persistent: a project never reopened
|
|
63
|
+
* would otherwise keep its artifacts for good.
|
|
59
64
|
*
|
|
60
65
|
* This replaces the destructive SessionStart clear: with the store session-keyed
|
|
61
66
|
* there is nothing to reset, only old state to age out.
|
|
@@ -65,7 +70,7 @@ export function instructionsLoadedSeen(sessionId?: string): boolean;
|
|
|
65
70
|
export function sweepStaleSessions(sessionId?: string): void;
|
|
66
71
|
/**
|
|
67
72
|
* Record that the InstructionsLoaded scanner engaged. Symlink-safe presence
|
|
68
|
-
* write (see writeSentinelFile) at a predictable
|
|
73
|
+
* write (see writeSentinelFile) at a predictable STATE_ROOT path.
|
|
69
74
|
*
|
|
70
75
|
* The event fires once per instruction file loaded, so the already-recorded case
|
|
71
76
|
* returns without a write — and the stale-session sweep rides the FIRST fire of
|
|
@@ -77,9 +82,7 @@ export function recordInstructionsLoaded(sessionId?: string): void;
|
|
|
77
82
|
/**
|
|
78
83
|
* Companion marker: this session already found the LAUNCH set empty — that set
|
|
79
84
|
* (which files load at session start) cannot change mid-session, so a second
|
|
80
|
-
* glob of `dir` can never change
|
|
81
|
-
* a directory touched later; {@link instructionsLoadedGapNotice}'s `touchedDir`
|
|
82
|
-
* covers that half fresh on every call instead.
|
|
85
|
+
* glob of `dir` can never change the answer.
|
|
83
86
|
* @param {string} [sessionId]
|
|
84
87
|
* @returns {string}
|
|
85
88
|
*/
|
|
@@ -100,30 +103,32 @@ export function launchInstructionFiles(dir: string): string[];
|
|
|
100
103
|
/**
|
|
101
104
|
* The one-time context line for a session where no InstructionsLoaded scan ran,
|
|
102
105
|
* or null when the scan has been seen, the notice already ran this session, the
|
|
103
|
-
* host's cold-start marker says setup is still running, or
|
|
104
|
-
*
|
|
106
|
+
* host's cold-start marker says setup is still running, or nothing announced at
|
|
107
|
+
* launch could have fired the event.
|
|
105
108
|
*
|
|
106
109
|
* PURE for the notice: nothing is recorded until the caller confirms it landed
|
|
107
|
-
* in a returned response. Launch-emptiness is cached once found
|
|
108
|
-
*
|
|
109
|
-
* SUBDIRECTORY files, so an empty launch must not silence a later call whose
|
|
110
|
-
* tool touched a directory that DOES carry real, unscanned content.
|
|
110
|
+
* in a returned response. Launch-emptiness is cached once found, since the
|
|
111
|
+
* launch set is fixed for the session.
|
|
111
112
|
*
|
|
112
|
-
*
|
|
113
|
-
* the
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
* the marker cannot tell them apart: an unwired event, a Claude Code older than
|
|
117
|
-
* EVENT_MIN_CLI_VERSION, or the hook disabled via AGENT_SANITIZER_DISABLED_HOOKS.
|
|
113
|
+
* Launch is the only evidence read. Claude Code fires the session_start events
|
|
114
|
+
* before the first PreToolUse, but a subdirectory's CLAUDE.md loads only AFTER
|
|
115
|
+
* the tool call that reaches it, so a PreToolUse can never already see that
|
|
116
|
+
* file's scan, however the hook is wired.
|
|
118
117
|
* @param {string} [sessionId] the harness's session identity (see
|
|
119
118
|
* instructionsLoadedFile)
|
|
120
119
|
* @param {string} [dir] the project root to check (injectable; defaults to
|
|
121
120
|
* the real project)
|
|
122
|
-
* @param {
|
|
123
|
-
*
|
|
121
|
+
* @param {{
|
|
122
|
+
* env?: Record<string, string | undefined>,
|
|
123
|
+
* cliVersion?: (env: Record<string, string | undefined>) => number[] | null,
|
|
124
|
+
* }} [options] injectable for tests; any other value, such as a directory
|
|
125
|
+
* string from an older caller, reads as no options
|
|
124
126
|
* @returns {string | null}
|
|
125
127
|
*/
|
|
126
|
-
export function instructionsLoadedGapNotice(sessionId?: string, dir?: string,
|
|
128
|
+
export function instructionsLoadedGapNotice(sessionId?: string, dir?: string, { env, cliVersion }?: {
|
|
129
|
+
env?: Record<string, string | undefined>;
|
|
130
|
+
cliVersion?: (env: Record<string, string | undefined>) => number[] | null;
|
|
131
|
+
}): string | null;
|
|
127
132
|
/**
|
|
128
133
|
* Record that the gap notice above was surfaced, so it rides on ONE tool call
|
|
129
134
|
* rather than every one — the per-call repeat is what trains a reader to skip it.
|
|
@@ -136,14 +141,14 @@ export function recordInstructionsLoadedNotice(sessionId?: string): void;
|
|
|
136
141
|
* The alert findings if invisible-char injection was detected in instruction
|
|
137
142
|
* files and couldn't be auto-cleaned, else null.
|
|
138
143
|
*
|
|
139
|
-
* The store is a DIRECTORY of one file per finding, all at predictable
|
|
140
|
-
* world-visible $TMPDIR
|
|
141
|
-
*
|
|
142
|
-
* uid owns (a symlink would let a
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
* model's context.
|
|
144
|
+
* The store is a DIRECTORY of one file per finding, all at predictable
|
|
145
|
+
* STATE_ROOT paths (world-visible in the $TMPDIR fallback), so both the
|
|
146
|
+
* directory and every entry in it are attacker-plantable: trust the directory
|
|
147
|
+
* only when it is a real directory this uid owns (a symlink would let a
|
|
148
|
+
* co-tenant aim this reader at unrelated files), each entry only when
|
|
149
|
+
* markerIsTrusted confirms a regular file this uid owns, then scrub the bytes
|
|
150
|
+
* through Layer-1 before any caller splices them into a reason — the report
|
|
151
|
+
* would otherwise carry ANSI/invisible spoofing into the model's context.
|
|
147
152
|
* This session's store AND the shared `no-session` fallback, because a hook that
|
|
148
153
|
* faults BEFORE it can parse its payload has no session identity to key by: its
|
|
149
154
|
* finding lands in the fallback, and a strictly session-keyed read would leave
|
|
@@ -163,7 +168,7 @@ export function invisibleCharAlert(sessionId?: string): string | null;
|
|
|
163
168
|
* through a read-modify-write would let two hooks recording a finding at once
|
|
164
169
|
* silently drop one of them; a fresh file per finding has no shared cell to
|
|
165
170
|
* lose. Symlink-refusing (writeFileNoFollow) because the store sits at a
|
|
166
|
-
* predictable, world-visible $TMPDIR
|
|
171
|
+
* predictable STATE_ROOT path, world-visible in the $TMPDIR fallback.
|
|
167
172
|
* @param {string} text
|
|
168
173
|
* @param {string} [sessionId]
|
|
169
174
|
* @returns {boolean} whether the finding was recorded
|
|
@@ -172,9 +177,10 @@ export function appendAlert(text: string, sessionId?: string): boolean;
|
|
|
172
177
|
/**
|
|
173
178
|
* True once the gate has surfaced its blocking ask this session. Validates
|
|
174
179
|
* ownership (not mere existence): a co-tenant could pre-create the ack at its
|
|
175
|
-
* predictable
|
|
176
|
-
* to the passive reminder, so trust the marker only when it is a regular
|
|
177
|
-
* this uid wrote (markerIsTrusted), mirroring how acknowledgeAlert writes
|
|
180
|
+
* predictable STATE_ROOT path to permanently suppress the one-time blocking ask
|
|
181
|
+
* down to the passive reminder, so trust the marker only when it is a regular
|
|
182
|
+
* file this uid wrote (markerIsTrusted), mirroring how acknowledgeAlert writes
|
|
183
|
+
* it.
|
|
178
184
|
*
|
|
179
185
|
* On a host that exports no session id every session shares the fallback prefix,
|
|
180
186
|
* so the ack has no session to end with and would suppress the one-time blocking
|
|
@@ -211,6 +217,9 @@ export function gateReminderContext(): string;
|
|
|
211
217
|
/**
|
|
212
218
|
* The path prefix every alert artifact of this PROJECT shares. Never a file
|
|
213
219
|
* itself — only {@link sessionPrefix} and the sweep read it — so that one
|
|
214
|
-
* `startsWith` covers every artifact the sweep must age out.
|
|
220
|
+
* `startsWith` covers every artifact the sweep must age out. Under STATE_ROOT,
|
|
221
|
+
* not $TMPDIR itself: an OS temp cleaner deleting a live session's marker would
|
|
222
|
+
* read as the InstructionsLoaded scan never having run, and deleting its
|
|
223
|
+
* findings would lift the gate mid-session.
|
|
215
224
|
*/
|
|
216
225
|
export const ALERT_BASE: string;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* 1. `_AGENT_SANITIZER_REDACTOR_SOCKET` — explicit override.
|
|
7
7
|
* 2. `$XDG_RUNTIME_DIR`, then `$CLAUDE_PLUGIN_DATA` — per-user directories no
|
|
8
8
|
* other uid can write, so a co-tenant cannot pre-create the socket dir under
|
|
9
|
-
* them. Taken only if
|
|
9
|
+
* them. Taken only if privateDirAt passes and the path fits sun_path.
|
|
10
10
|
* 3. `<tmpdir>/agent-sanitizer-redactor-<uid>` — on a shared /tmp a co-tenant
|
|
11
11
|
* can still pre-create it; the daemon then refuses to serve and the client
|
|
12
12
|
* refuses to connect (classifySocket), costing availability, never a secret.
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export function revealDir(): string;
|
|
8
8
|
/**
|
|
9
|
-
* Delete this project's reveal sidecars and spans older than {@link REVEAL_TTL_MS}
|
|
9
|
+
* Delete this project's reveal sidecars and spans older than {@link REVEAL_TTL_MS},
|
|
10
|
+
* and every other project's default store that holds nothing younger.
|
|
10
11
|
*
|
|
11
12
|
* Nothing else removes them: every entry is content-addressed, so a store that is
|
|
12
13
|
* never swept grows for the life of the machine. Called from SessionStart, the one
|