agent-sanitizer 2.66.2 → 2.66.4
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/THREAT-MODEL.md +11 -1
- package/claude-hooks/lib/hook-io.mjs +99 -5
- package/claude-hooks/lib/invisible-alert.mjs +76 -66
- package/claude-hooks/lib/redactor-client.mjs +8 -22
- package/claude-hooks/lib/reveal.mjs +53 -24
- 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 +26 -17
- package/types/claude-hooks/lib/redactor-client.d.mts +1 -1
- package/types/claude-hooks/lib/reveal.d.mts +2 -1
package/THREAT-MODEL.md
CHANGED
|
@@ -797,7 +797,7 @@ positive costs a sentence of context, never a mangled input):
|
|
|
797
797
|
the identical Write confirms and passes. The confirmation is the model's
|
|
798
798
|
deliberate retry — never a human permission prompt — held as a
|
|
799
799
|
consumed-on-use, TTL-bounded sentinel (keyed to path + content + the dropped
|
|
800
|
-
values) via the same squat-resistant
|
|
800
|
+
values) in `$TMPDIR` via the same squat-resistant helpers as the invisible-char
|
|
801
801
|
gate. Tracked files, secret-free files, failed git probes and unmappable
|
|
802
802
|
views all skip the guard (fail open). "Tracked" approximates "recoverable":
|
|
803
803
|
an uncommitted secret line on a tracked file is an accepted gap — the
|
|
@@ -927,6 +927,16 @@ it, so what anchors its trust is worth stating exactly.
|
|
|
927
927
|
under `TMPDIR` is read only when this user owns it with owner-only write, so a
|
|
928
928
|
squatted shared-temp entry cannot pre-suppress the warning that a call ran
|
|
929
929
|
unsanitized.
|
|
930
|
+
- **Session state lives under that directory too, not in `$TMPDIR`.** The
|
|
931
|
+
InstructionsLoaded marker, the invisible-char gate's findings and ack, and the
|
|
932
|
+
Layer-2 reveal spans sit in `${CLAUDE_PLUGIN_DATA}/session-state`, created mode
|
|
933
|
+
700 and used only when both it and the data directory are real directories
|
|
934
|
+
this user owns with no group/other write (`privateDirAt`) and the leaf is
|
|
935
|
+
writable. An OS temp cleaner (macOS dirhelper purges `$TMPDIR` entries unread
|
|
936
|
+
for 3 days) would otherwise delete a live session's marker — a false "no
|
|
937
|
+
InstructionsLoaded scan has run" notice — or its findings, lifting the gate.
|
|
938
|
+
A host with no plugin data directory, or one that fails those checks, falls
|
|
939
|
+
back to `$TMPDIR` and keeps that exposure.
|
|
930
940
|
- **Opting out.** `AGENT_SANITIZER_HOOK_BINARY=0` never downloads and never runs
|
|
931
941
|
a binary. That leaves the node path running the shipped bundle and the
|
|
932
942
|
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
|
|
@@ -21,16 +21,19 @@ import {
|
|
|
21
21
|
} from "node:fs";
|
|
22
22
|
import { randomBytes } from "node:crypto";
|
|
23
23
|
import { basename, join } from "node:path";
|
|
24
|
-
import { homedir
|
|
24
|
+
import { homedir } from "node:os";
|
|
25
25
|
import {
|
|
26
26
|
hookgateMarkerPath,
|
|
27
27
|
lazyImport,
|
|
28
28
|
markerIsTrusted,
|
|
29
|
-
|
|
29
|
+
ownedByCurrentUser,
|
|
30
|
+
privateDirAt,
|
|
30
31
|
probeSetupAlive,
|
|
31
32
|
PROJECT_DIR,
|
|
32
33
|
PROJECT_HASH,
|
|
33
34
|
scrubUntrustedText,
|
|
35
|
+
STATE_ROOT,
|
|
36
|
+
touchLiveState,
|
|
34
37
|
writeFileNoFollow,
|
|
35
38
|
writeSentinelFile,
|
|
36
39
|
} from "./hook-io.mjs";
|
|
@@ -59,15 +62,28 @@ const { applyLayer1WellFormed } =
|
|
|
59
62
|
await lazyImport("agent-sanitizer")
|
|
60
63
|
);
|
|
61
64
|
|
|
65
|
+
/** Every alert artifact name's start, less the project hash that follows it. */
|
|
66
|
+
const ALERT_STEM = ".claude-invisible-char-alert-";
|
|
67
|
+
|
|
62
68
|
/**
|
|
63
69
|
* The path prefix every alert artifact of this PROJECT shares. Never a file
|
|
64
70
|
* itself — only {@link sessionPrefix} and the sweep read it — so that one
|
|
65
|
-
* `startsWith` covers every artifact the sweep must age out.
|
|
71
|
+
* `startsWith` covers every artifact the sweep must age out. Under STATE_ROOT,
|
|
72
|
+
* not $TMPDIR itself: an OS temp cleaner deleting a live session's marker would
|
|
73
|
+
* read as the InstructionsLoaded scan never having run, and deleting its
|
|
74
|
+
* findings would lift the gate mid-session.
|
|
75
|
+
*/
|
|
76
|
+
export const ALERT_BASE = join(STATE_ROOT, `${ALERT_STEM}${PROJECT_HASH}`);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Whether `name` is a session artifact of ANY project: {@link ALERT_BASE}'s
|
|
80
|
+
* name, whatever its project hash, then `.s-`.
|
|
81
|
+
* @param {string} name
|
|
82
|
+
* @returns {boolean}
|
|
66
83
|
*/
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
);
|
|
84
|
+
const isAnyProjectSessionEntry = (name) =>
|
|
85
|
+
name.startsWith(ALERT_STEM) &&
|
|
86
|
+
/^[0-9a-f]{8}\.s-/u.test(name.slice(ALERT_STEM.length));
|
|
71
87
|
|
|
72
88
|
/**
|
|
73
89
|
* The path prefix every alert artifact of ONE session under this project shares.
|
|
@@ -86,7 +102,7 @@ export const ALERT_BASE = join(
|
|
|
86
102
|
export function sessionPrefix(sessionId) {
|
|
87
103
|
// The id becomes a path component, so anything outside this class — a `/` in
|
|
88
104
|
// a hostile session id above all — is folded away rather than escaping
|
|
89
|
-
//
|
|
105
|
+
// STATE_ROOT. A host that exports no session id falls back to one shared name,
|
|
90
106
|
// whose findings and ack are bounded by FALLBACK_TTL_MS instead.
|
|
91
107
|
const key =
|
|
92
108
|
(sessionId ?? "").replace(/[^A-Za-z0-9._-]/gu, "_") || "no-session";
|
|
@@ -138,12 +154,20 @@ export function instructionsLoadedNoticeFile(sessionId) {
|
|
|
138
154
|
* lazily-loaded instruction files are being scanned at all. Ownership-validated
|
|
139
155
|
* like every other marker here: a co-tenant could otherwise plant the
|
|
140
156
|
* predictable path and suppress the notice below, which is the whole signal that
|
|
141
|
-
* nested files are going unscanned.
|
|
157
|
+
* nested files are going unscanned. A marker that is seen is stamped as used:
|
|
158
|
+
* it is written once at launch, so otherwise a session outliving
|
|
159
|
+
* MARKER_TTL_MS would have it swept by another session's SessionStart and read
|
|
160
|
+
* its absence as the event never having fired.
|
|
142
161
|
* @param {string} [sessionId]
|
|
143
162
|
* @returns {boolean}
|
|
144
163
|
*/
|
|
145
164
|
export function instructionsLoadedSeen(sessionId) {
|
|
146
|
-
|
|
165
|
+
const marker = instructionsLoadedFile(sessionId);
|
|
166
|
+
if (!markerIsTrusted(marker)) return false;
|
|
167
|
+
// Not the shared no-session marker: every session-less host would refresh
|
|
168
|
+
// it, and MARKER_TTL_MS is what keeps it from silencing every later session.
|
|
169
|
+
if (sessionPrefix(sessionId) !== sessionPrefix()) touchLiveState(marker);
|
|
170
|
+
return true;
|
|
147
171
|
}
|
|
148
172
|
|
|
149
173
|
/** How long a past session's artifacts are kept before a later session sweeps. */
|
|
@@ -182,8 +206,8 @@ const FALLBACK_TTL_MS = 30 * 60 * 1000;
|
|
|
182
206
|
* this `lstat` reads as expired, which is what every caller wants of a file that
|
|
183
207
|
* is no longer there: the sweep's unlink becomes a no-op, the gate's reader skips
|
|
184
208
|
* 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.
|
|
209
|
+
* would abort recordInstructionsLoaded on a benign STATE_ROOT race and render
|
|
210
|
+
* the false "instruction file was NOT scanned" fault.
|
|
187
211
|
*
|
|
188
212
|
* `lstat`, so a planted symlink is judged on itself.
|
|
189
213
|
* @param {string} path
|
|
@@ -206,30 +230,12 @@ function withinTtl(path, ttlMs) {
|
|
|
206
230
|
const withinFallbackTtl = (path) => withinTtl(path, FALLBACK_TTL_MS);
|
|
207
231
|
|
|
208
232
|
/**
|
|
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
|
|
233
|
+
* Delete OTHER sessions' artifacts, of every project, once they are older than
|
|
230
234
|
* the TTL. The current session's own prefix is skipped, so the sweep can never
|
|
231
235
|
* answer its own question wrong; every other session's files are past history
|
|
232
|
-
* that nothing reads.
|
|
236
|
+
* that nothing reads — a live one keeps its marker and findings fresh on read.
|
|
237
|
+
* Every project, because STATE_ROOT may be persistent: a project never reopened
|
|
238
|
+
* would otherwise keep its artifacts for good.
|
|
233
239
|
*
|
|
234
240
|
* This replaces the destructive SessionStart clear: with the store session-keyed
|
|
235
241
|
* there is nothing to reset, only old state to age out.
|
|
@@ -237,28 +243,28 @@ function dirIsTrusted(path) {
|
|
|
237
243
|
* @returns {void}
|
|
238
244
|
*/
|
|
239
245
|
export function sweepStaleSessions(sessionId) {
|
|
240
|
-
const dir =
|
|
241
|
-
const prefix = `${basename(ALERT_BASE)}.s-`;
|
|
246
|
+
const dir = STATE_ROOT;
|
|
242
247
|
const keep = basename(sessionPrefix(sessionId));
|
|
243
248
|
const cutoff = Date.now() - MARKER_TTL_MS;
|
|
244
249
|
for (const name of readdirSync(dir)) {
|
|
245
|
-
if (!name
|
|
250
|
+
if (!isAnyProjectSessionEntry(name)) continue;
|
|
246
251
|
// Whole-segment match on the keep prefix, not a bare startsWith: session key
|
|
247
252
|
// "abc" must not claim (and so preserve) session key "abc2"'s artifacts.
|
|
248
253
|
if (name === keep || name.startsWith(`${keep}.`)) continue;
|
|
249
254
|
const path = join(dir, name);
|
|
250
255
|
try {
|
|
251
|
-
// lstat, not stat: a squatted symlink at a predictable
|
|
252
|
-
//
|
|
253
|
-
|
|
256
|
+
// lstat, not stat: a squatted symlink at a predictable path is judged on
|
|
257
|
+
// ITSELF. Ownership first: in a shared /tmp another uid's entry is not
|
|
258
|
+
// ours to remove, and an rmSync on its file fails ENOTDIR, not EPERM.
|
|
259
|
+
const st = lstatSync(path);
|
|
260
|
+
if (!ownedByCurrentUser(st) || st.mtimeMs >= cutoff) continue;
|
|
254
261
|
rmSync(path, { recursive: true, force: true });
|
|
255
262
|
} catch (err) {
|
|
256
263
|
// 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.
|
|
264
|
+
// EPERM/EACCES: an entry of ours a co-tenant's directory shields. A throw
|
|
265
|
+
// here would abort recordInstructionsLoaded and render the "instruction
|
|
266
|
+
// file was NOT scanned" fault on a session that WAS scanned. Anything else
|
|
267
|
+
// is a bug in this sweep and propagates.
|
|
262
268
|
const code = /** @type {NodeJS.ErrnoException} */ (err).code;
|
|
263
269
|
if (code !== "ENOENT" && code !== "EPERM" && code !== "EACCES") throw err;
|
|
264
270
|
}
|
|
@@ -268,7 +274,7 @@ export function sweepStaleSessions(sessionId) {
|
|
|
268
274
|
|
|
269
275
|
/**
|
|
270
276
|
* Remove the shared-prefix artifacts the reader has stopped honouring, so they
|
|
271
|
-
* stop occupying
|
|
277
|
+
* stop occupying STATE_ROOT too. The loop above cannot: it skips the current
|
|
272
278
|
* session's prefix, which on a host with no session id IS this one.
|
|
273
279
|
*
|
|
274
280
|
* Mirrors what {@link invisibleCharAlert} and {@link alertAcknowledged} read.
|
|
@@ -281,14 +287,14 @@ export function sweepStaleSessions(sessionId) {
|
|
|
281
287
|
* scanned, or re-glob a launch this session already found empty. They still go,
|
|
282
288
|
* at MARKER_TTL_MS — the same age the loop above ages a real session's prefix
|
|
283
289
|
* 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.
|
|
290
|
+
* and would otherwise leave a session-less host's markers in STATE_ROOT
|
|
291
|
+
* forever, with the gap notice suppressed on every later session.
|
|
286
292
|
* @param {string} [sessionId]
|
|
287
293
|
* @returns {void}
|
|
288
294
|
*/
|
|
289
295
|
function sweepStaleFallback(sessionId) {
|
|
290
296
|
const dir = alertDir();
|
|
291
|
-
const inherited = alertDir(sessionId) !== dir &&
|
|
297
|
+
const inherited = alertDir(sessionId) !== dir && privateDirAt(dir);
|
|
292
298
|
const entries = inherited
|
|
293
299
|
? readdirSync(dir).map((name) => join(dir, name))
|
|
294
300
|
: [];
|
|
@@ -311,7 +317,7 @@ function sweepStaleFallback(sessionId) {
|
|
|
311
317
|
|
|
312
318
|
/**
|
|
313
319
|
* Record that the InstructionsLoaded scanner engaged. Symlink-safe presence
|
|
314
|
-
* write (see writeSentinelFile) at a predictable
|
|
320
|
+
* write (see writeSentinelFile) at a predictable STATE_ROOT path.
|
|
315
321
|
*
|
|
316
322
|
* The event fires once per instruction file loaded, so the already-recorded case
|
|
317
323
|
* returns without a write — and the stale-session sweep rides the FIRST fire of
|
|
@@ -534,14 +540,14 @@ export function recordInstructionsLoadedNotice(sessionId) {
|
|
|
534
540
|
* The alert findings if invisible-char injection was detected in instruction
|
|
535
541
|
* files and couldn't be auto-cleaned, else null.
|
|
536
542
|
*
|
|
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.
|
|
543
|
+
* The store is a DIRECTORY of one file per finding, all at predictable
|
|
544
|
+
* STATE_ROOT paths (world-visible in the $TMPDIR fallback), so both the
|
|
545
|
+
* directory and every entry in it are attacker-plantable: trust the directory
|
|
546
|
+
* only when it is a real directory this uid owns (a symlink would let a
|
|
547
|
+
* co-tenant aim this reader at unrelated files), each entry only when
|
|
548
|
+
* markerIsTrusted confirms a regular file this uid owns, then scrub the bytes
|
|
549
|
+
* through Layer-1 before any caller splices them into a reason — the report
|
|
550
|
+
* would otherwise carry ANSI/invisible spoofing into the model's context.
|
|
545
551
|
* This session's store AND the shared `no-session` fallback, because a hook that
|
|
546
552
|
* faults BEFORE it can parse its payload has no session identity to key by: its
|
|
547
553
|
* finding lands in the fallback, and a strictly session-keyed read would leave
|
|
@@ -560,7 +566,10 @@ export function invisibleCharAlert(sessionId) {
|
|
|
560
566
|
const dirs = own === alertDir() ? [own] : [own, alertDir()];
|
|
561
567
|
const parts = [];
|
|
562
568
|
for (const dir of dirs) {
|
|
563
|
-
if (!
|
|
569
|
+
if (!privateDirAt(dir)) continue;
|
|
570
|
+
// A live session's findings must outlast any sweep that ages the store by
|
|
571
|
+
// its directory's timestamp: losing them lifts the gate mid-session.
|
|
572
|
+
if (dir === own) touchLiveState(dir);
|
|
564
573
|
// Sorted so a multi-finding report reads the same on every call; the names
|
|
565
574
|
// are random, so the order carries no meaning beyond being stable.
|
|
566
575
|
for (const name of readdirSync(dir).sort()) {
|
|
@@ -583,7 +592,7 @@ export function invisibleCharAlert(sessionId) {
|
|
|
583
592
|
* through a read-modify-write would let two hooks recording a finding at once
|
|
584
593
|
* silently drop one of them; a fresh file per finding has no shared cell to
|
|
585
594
|
* lose. Symlink-refusing (writeFileNoFollow) because the store sits at a
|
|
586
|
-
* predictable, world-visible $TMPDIR
|
|
595
|
+
* predictable STATE_ROOT path, world-visible in the $TMPDIR fallback.
|
|
587
596
|
* @param {string} text
|
|
588
597
|
* @param {string} [sessionId]
|
|
589
598
|
* @returns {boolean} whether the finding was recorded
|
|
@@ -597,7 +606,7 @@ export function appendAlert(text, sessionId) {
|
|
|
597
606
|
let usable;
|
|
598
607
|
try {
|
|
599
608
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
600
|
-
usable =
|
|
609
|
+
usable = privateDirAt(dir);
|
|
601
610
|
} catch {
|
|
602
611
|
usable = false;
|
|
603
612
|
}
|
|
@@ -612,9 +621,10 @@ export function appendAlert(text, sessionId) {
|
|
|
612
621
|
/**
|
|
613
622
|
* True once the gate has surfaced its blocking ask this session. Validates
|
|
614
623
|
* 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
|
|
624
|
+
* predictable STATE_ROOT path to permanently suppress the one-time blocking ask
|
|
625
|
+
* down to the passive reminder, so trust the marker only when it is a regular
|
|
626
|
+
* file this uid wrote (markerIsTrusted), mirroring how acknowledgeAlert writes
|
|
627
|
+
* it.
|
|
618
628
|
*
|
|
619
629
|
* On a host that exports no session id every session shares the fallback prefix,
|
|
620
630
|
* so the ack has no session to end with and would suppress the one-time blocking
|
|
@@ -638,8 +648,8 @@ export function alertAcknowledged(sessionId) {
|
|
|
638
648
|
* @returns {void}
|
|
639
649
|
*/
|
|
640
650
|
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).
|
|
651
|
+
// Symlink-safe presence write: the ack sits at a predictable STATE_ROOT path
|
|
652
|
+
// a co-tenant could pre-plant a symlink at (see writeSentinelFile).
|
|
643
653
|
writeSentinelFile(alertAckFile(sessionId));
|
|
644
654
|
}
|
|
645
655
|
|
|
@@ -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
|
/**
|
|
@@ -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.4",
|
|
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
|
|
@@ -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
|