agent-sanitizer 2.43.12 → 2.44.1
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 +11 -0
- package/claude-hooks/lib/hook-io.mjs +69 -9
- package/claude-hooks/lib/invisible-alert.mjs +316 -92
- package/claude-hooks/lib/redactor-client.mjs +15 -12
- package/claude-hooks/lib/reveal.mjs +49 -2
- package/claude-hooks/lib/secret-drop-guard.mjs +44 -3
- package/claude-hooks/plugin-hooks.mjs +5 -2
- package/claude-hooks/pretooluse-sanitize.mjs +25 -11
- package/claude-hooks/scan-invisible-chars.mjs +59 -32
- package/claude-hooks/scan-loaded-instructions.mjs +10 -3
- package/package.json +1 -1
- package/types/claude-hooks/lib/hook-io.d.mts +33 -7
- package/types/claude-hooks/lib/invisible-alert.d.mts +108 -47
- package/types/claude-hooks/lib/reveal.d.mts +16 -0
- package/types/claude-hooks/lib/secret-drop-guard.d.mts +11 -0
- package/types/claude-hooks/scan-invisible-chars.d.mts +18 -3
package/README.md
CHANGED
|
@@ -368,6 +368,17 @@ hook module, and every consumer waits on that path instead. `lib/control-plane`
|
|
|
368
368
|
resolves the marker at module scope, so a call that lands after that import
|
|
369
369
|
warns on stderr — it cannot steer the wait that already started.
|
|
370
370
|
|
|
371
|
+
The marker's first line is the setup process's pid. A writer that also holds an
|
|
372
|
+
exclusive `flock` on the marker file for the whole install says so by adding a
|
|
373
|
+
second line reading `flock` (`SETUP_LOCK_DECLARATION`), and the hooks then judge
|
|
374
|
+
liveness by the lock rather than the pid: the kernel releases an `flock` the
|
|
375
|
+
instant its holder dies, so a killed setup is detected immediately instead of
|
|
376
|
+
being read as alive for as long as a recycled pid keeps answering. A marker that
|
|
377
|
+
declares nothing is judged by its pid. A writer must hold the lock BEFORE the
|
|
378
|
+
declaring marker becomes visible: a marker that says `flock` while its writer
|
|
379
|
+
has not yet locked reads as a free lock, so every waiter abandons an install
|
|
380
|
+
that is still running.
|
|
381
|
+
|
|
371
382
|
**A host's own remedy can replace the packaged one in every failure reason**
|
|
372
383
|
(the fail-closed verdicts and the fail-open warning alike). Deep call sites (`lib/control-plane`'s missing-package throw) take no
|
|
373
384
|
remedy argument, so by default they can only say `pnpm install`. A host whose
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
unlinkSync,
|
|
9
9
|
writeFileSync,
|
|
10
10
|
} from "node:fs";
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
11
12
|
import { userInfo } from "node:os";
|
|
12
13
|
import { createHash } from "node:crypto";
|
|
13
14
|
import { pathToFileURL } from "node:url";
|
|
@@ -692,6 +693,19 @@ export function emitHookResponse(hookEventName, fields) {
|
|
|
692
693
|
);
|
|
693
694
|
}
|
|
694
695
|
|
|
696
|
+
/**
|
|
697
|
+
* The project the hooks are guarding. Every per-project $TMPDIR store is keyed to
|
|
698
|
+
* it, so it lives here — beside the other shared identity these hooks agree on —
|
|
699
|
+
* rather than in whichever store happened to need it first.
|
|
700
|
+
*/
|
|
701
|
+
export const PROJECT_DIR = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
702
|
+
|
|
703
|
+
/** Short project digest keying this project's $TMPDIR store names. */
|
|
704
|
+
export const PROJECT_HASH = createHash("sha256")
|
|
705
|
+
.update(PROJECT_DIR)
|
|
706
|
+
.digest("hex")
|
|
707
|
+
.slice(0, 8);
|
|
708
|
+
|
|
695
709
|
/** The marker filename stem; the project directory is appended to it. */
|
|
696
710
|
const HOOKGATE_MARKER_STEM = "agent-sanitizer-hookgate-inflight-";
|
|
697
711
|
|
|
@@ -765,24 +779,70 @@ export function hookgateMarkerPath(
|
|
|
765
779
|
}
|
|
766
780
|
|
|
767
781
|
/**
|
|
768
|
-
*
|
|
769
|
-
*
|
|
770
|
-
*
|
|
771
|
-
*
|
|
772
|
-
*
|
|
773
|
-
*
|
|
774
|
-
*
|
|
782
|
+
* The line a cold-start marker carries on its own to declare that its writer holds
|
|
783
|
+
* an exclusive `flock` on the marker file for the whole install.
|
|
784
|
+
*
|
|
785
|
+
* This is the marker's PROTOCOL, and the reason it is declared in the data rather
|
|
786
|
+
* than assumed: a reader cannot tell "the writer released the lock because it died"
|
|
787
|
+
* from "the writer never took one" by looking at a free lock, so only a marker that
|
|
788
|
+
* says it locks may be judged by the lock.
|
|
789
|
+
*/
|
|
790
|
+
export const SETUP_LOCK_DECLARATION = "flock";
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Whether an exclusive lock is currently held on `markerPath`, or null when the
|
|
794
|
+
* question cannot be answered here (no `flock(1)` — it is util-linux, absent from a
|
|
795
|
+
* stock macOS — or an exit status neither "acquired" nor "busy").
|
|
796
|
+
* @param {string} markerPath
|
|
797
|
+
* @returns {boolean | null}
|
|
798
|
+
*/
|
|
799
|
+
function markerLockHeld(markerPath) {
|
|
800
|
+
const probe = spawnSync(
|
|
801
|
+
"flock",
|
|
802
|
+
["--nonblock", "--exclusive", markerPath, "true"],
|
|
803
|
+
{ stdio: "ignore" },
|
|
804
|
+
);
|
|
805
|
+
if (probe.error) return null;
|
|
806
|
+
if (probe.status === 0) return false; // acquired: nobody holds it
|
|
807
|
+
if (probe.status === 1) return true; // busy: the writer still holds it
|
|
808
|
+
return null;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Is the setup process that wrote `markerPath` still alive?
|
|
813
|
+
*
|
|
814
|
+
* A marker declaring {@link SETUP_LOCK_DECLARATION} is judged by the LOCK, and that
|
|
815
|
+
* answer has no aliasing: the kernel drops an flock the instant its holder dies, so
|
|
816
|
+
* held means alive and free means dead, with no third state and nothing to reuse. A
|
|
817
|
+
* marker that declares nothing carries only a pid, and `process.kill(pid, 0)` is all
|
|
818
|
+
* there is — it throws ESRCH once the process is gone (a killed setup → stale
|
|
819
|
+
* marker, so stop waiting) and EPERM when it exists but is not ours (still alive).
|
|
820
|
+
* That reading is the one this replaces where it can: a recycled pid reads as a live
|
|
821
|
+
* setup for as long as the caller's ceiling allows.
|
|
822
|
+
*
|
|
823
|
+
* An unreadable / not-yet-written marker is treated as alive — favouring a brief
|
|
824
|
+
* wait over a premature give-up during setup's write race. A null markerPath (no
|
|
825
|
+
* project dir → no setup to wait on) reads as alive so the caller's own
|
|
826
|
+
* grace/ceiling bound governs.
|
|
775
827
|
* @param {string | null} markerPath
|
|
776
828
|
* @returns {boolean}
|
|
777
829
|
*/
|
|
778
830
|
export function probeSetupAlive(markerPath) {
|
|
779
831
|
if (markerPath === null) return true;
|
|
780
|
-
let
|
|
832
|
+
let raw;
|
|
781
833
|
try {
|
|
782
|
-
|
|
834
|
+
raw = readFileSync(markerPath, "utf8");
|
|
783
835
|
} catch {
|
|
784
836
|
return true;
|
|
785
837
|
}
|
|
838
|
+
const lines = raw.split("\n").map((line) => line.trim());
|
|
839
|
+
if (lines.includes(SETUP_LOCK_DECLARATION)) {
|
|
840
|
+
const held = markerLockHeld(markerPath);
|
|
841
|
+
// null only: a host without flock(1) still gets the pid reading below rather
|
|
842
|
+
// than an answer this probe cannot support.
|
|
843
|
+
if (held !== null) return held;
|
|
844
|
+
}
|
|
845
|
+
const pid = parseInt(lines[0], 10);
|
|
786
846
|
if (!Number.isInteger(pid) || pid <= 0) return true;
|
|
787
847
|
try {
|
|
788
848
|
process.kill(pid, 0);
|
|
@@ -10,19 +10,26 @@
|
|
|
10
10
|
* Both hooks reach the state through this module so the paths and the trust rule
|
|
11
11
|
* have one definition.
|
|
12
12
|
*/
|
|
13
|
-
import {
|
|
14
|
-
|
|
13
|
+
import {
|
|
14
|
+
lstatSync,
|
|
15
|
+
mkdirSync,
|
|
16
|
+
readdirSync,
|
|
17
|
+
readFileSync,
|
|
18
|
+
rmSync,
|
|
19
|
+
} from "node:fs";
|
|
20
|
+
import { randomBytes } from "node:crypto";
|
|
15
21
|
import { basename, join } from "node:path";
|
|
16
|
-
import { tmpdir } from "node:os";
|
|
22
|
+
import { tmpdir, userInfo } from "node:os";
|
|
17
23
|
import {
|
|
18
24
|
lazyImport,
|
|
19
25
|
markerIsTrusted,
|
|
26
|
+
PROJECT_HASH,
|
|
20
27
|
scrubUntrustedText,
|
|
21
28
|
writeFileNoFollow,
|
|
22
29
|
writeSentinelFile,
|
|
23
30
|
} from "./hook-io.mjs";
|
|
24
31
|
|
|
25
|
-
// Layer-1 scrubber for the untrusted
|
|
32
|
+
// Layer-1 scrubber for the untrusted alert-store contents the gate splices into a
|
|
26
33
|
// permissionDecisionReason. Bound via lazyImport (see its doc for the fail-OPEN
|
|
27
34
|
// hazard of a bare static npm import): a load failure leaves applyLayer1 undefined,
|
|
28
35
|
// so scrubUntrustedText throws into the caller's fail-closed catch (→ ask) rather
|
|
@@ -31,50 +38,69 @@ const { applyLayer1 } = /** @type {typeof import("agent-sanitizer")} */ (
|
|
|
31
38
|
await lazyImport("agent-sanitizer")
|
|
32
39
|
);
|
|
33
40
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
.digest("hex")
|
|
41
|
-
.slice(0, 8);
|
|
42
|
-
|
|
43
|
-
/** Findings the SessionStart scanner could not clean, for the PreToolUse gate. */
|
|
44
|
-
export const ALERT_FILE = join(
|
|
41
|
+
/**
|
|
42
|
+
* The path prefix every alert artifact of this PROJECT shares. Never a file
|
|
43
|
+
* itself — only {@link sessionPrefix} and the sweep read it — so that one
|
|
44
|
+
* `startsWith` covers every artifact the sweep must age out.
|
|
45
|
+
*/
|
|
46
|
+
export const ALERT_BASE = join(
|
|
45
47
|
tmpdir(),
|
|
46
48
|
`.claude-invisible-char-alert-${PROJECT_HASH}`,
|
|
47
49
|
);
|
|
48
50
|
|
|
49
|
-
// Companion marker the PreToolUse gate writes once it has surfaced the alert
|
|
50
|
-
// this session, so the gate asks ONCE then degrades to a passive reminder
|
|
51
|
-
// instead of prompting on every tool call. Cleared at SessionStart alongside
|
|
52
|
-
// ALERT_FILE so each fresh session re-asks once.
|
|
53
|
-
export const ALERT_ACK_FILE = `${ALERT_FILE}.acked`;
|
|
54
|
-
|
|
55
51
|
/**
|
|
56
|
-
*
|
|
57
|
-
* can tell whether that event is being scanned at all this session.
|
|
52
|
+
* The path prefix every alert artifact of ONE session under this project shares.
|
|
58
53
|
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* session's
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* @param {string} [sessionId]
|
|
54
|
+
* Session-keying is what makes the gate's one-time ask correct by construction.
|
|
55
|
+
* The store and its ack used to be keyed by PROJECT alone and reset by a
|
|
56
|
+
* destructive clear at SessionStart, which left two ways for a session to
|
|
57
|
+
* inherit the previous one's answer: an early-exiting scanner arm (a dep-load
|
|
58
|
+
* failure) returns before the clear, and nothing pins SessionStart against the
|
|
59
|
+
* InstructionsLoaded events fired for the files loaded at launch. A session that
|
|
60
|
+
* cannot see another session's files needs neither the clear nor the ordering —
|
|
61
|
+
* past sessions' artifacts simply age out through {@link sweepStaleSessions}.
|
|
62
|
+
* @param {string} [sessionId] the harness's session identity
|
|
69
63
|
* @returns {string}
|
|
70
64
|
*/
|
|
71
|
-
export function
|
|
65
|
+
export function sessionPrefix(sessionId) {
|
|
72
66
|
// The id becomes a path component, so anything outside this class — a `/` in
|
|
73
67
|
// a hostile session id above all — is folded away rather than escaping
|
|
74
|
-
// $TMPDIR.
|
|
68
|
+
// $TMPDIR. A host that exports no session id falls back to one shared name,
|
|
69
|
+
// whose findings and ack are bounded by FALLBACK_TTL_MS instead.
|
|
75
70
|
const key =
|
|
76
71
|
(sessionId ?? "").replace(/[^A-Za-z0-9._-]/gu, "_") || "no-session";
|
|
77
|
-
return `${
|
|
72
|
+
return `${ALERT_BASE}.s-${key}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The directory holding this session's alert findings, one file per finding.
|
|
77
|
+
* @param {string} [sessionId]
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
export function alertDir(sessionId) {
|
|
81
|
+
return `${sessionPrefix(sessionId)}.alerts`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Companion marker the PreToolUse gate writes once it has surfaced the alert
|
|
86
|
+
* this session, so the gate asks ONCE then degrades to a passive reminder
|
|
87
|
+
* instead of prompting on every tool call. Session-keyed like the findings it
|
|
88
|
+
* answers for, so a fresh session cannot read an older session's answer.
|
|
89
|
+
* @param {string} [sessionId]
|
|
90
|
+
* @returns {string}
|
|
91
|
+
*/
|
|
92
|
+
export function alertAckFile(sessionId) {
|
|
93
|
+
return `${sessionPrefix(sessionId)}.acked`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Marker the InstructionsLoaded scanner writes on every fire, so another hook
|
|
98
|
+
* can tell whether that event is being scanned at all this session.
|
|
99
|
+
* @param {string} [sessionId]
|
|
100
|
+
* @returns {string}
|
|
101
|
+
*/
|
|
102
|
+
export function instructionsLoadedFile(sessionId) {
|
|
103
|
+
return `${sessionPrefix(sessionId)}.instructions-loaded`;
|
|
78
104
|
}
|
|
79
105
|
|
|
80
106
|
/**
|
|
@@ -99,28 +125,161 @@ export function instructionsLoadedSeen(sessionId) {
|
|
|
99
125
|
return markerIsTrusted(instructionsLoadedFile(sessionId));
|
|
100
126
|
}
|
|
101
127
|
|
|
102
|
-
/** How long a past session's
|
|
128
|
+
/** How long a past session's artifacts are kept before a later session sweeps. */
|
|
103
129
|
const MARKER_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
104
130
|
|
|
105
131
|
/**
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
132
|
+
* How long a finding or an ack recorded WITHOUT a session identity stays live.
|
|
133
|
+
*
|
|
134
|
+
* Session-keying is what stops one session inheriting another's answer, and the
|
|
135
|
+
* shared `no-session` prefix is the one place it is unavailable: a hook that
|
|
136
|
+
* faults before it can parse its payload has no id to key by, and nothing can
|
|
137
|
+
* clear its artifact when that session ends. Wall-clock is the only lifetime
|
|
138
|
+
* left. It is applied at READ time — not by a SessionStart clear, which would
|
|
139
|
+
* erase a fault recorded moments earlier by an InstructionsLoaded event that had
|
|
140
|
+
* only this prefix to reach — and again by the sweep, so the bytes go too.
|
|
141
|
+
*
|
|
142
|
+
* Wide enough to cover a launch-time fault reaching the session's first tool
|
|
143
|
+
* call, narrow enough that the next session does not re-arm the gate for a fault
|
|
144
|
+
* it cannot act on. Past it an INHERITED fallback finding is neither surfaced
|
|
145
|
+
* nor remembered, which is what makes REMEDY's "start a new session and the gate
|
|
146
|
+
* clears" true for these findings too.
|
|
147
|
+
*
|
|
148
|
+
* A host that exports no session id keeps its findings, because there the
|
|
149
|
+
* fallback is the session's OWN store: expiring it would stop telling a
|
|
150
|
+
* still-running session that its instruction files are unvetted, a silent loss
|
|
151
|
+
* of the signal this module exists to carry. The ack expires there regardless,
|
|
152
|
+
* so a suppression that outlives its session fails toward asking again — which
|
|
153
|
+
* is recoverable, where a silence is not.
|
|
154
|
+
*/
|
|
155
|
+
const FALLBACK_TTL_MS = 30 * 60 * 1000;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Whether `path` was written inside the last `ttlMs`.
|
|
159
|
+
*
|
|
160
|
+
* A path a parallel session removed between the caller's markerIsTrusted and
|
|
161
|
+
* this `lstat` reads as expired, which is what every caller wants of a file that
|
|
162
|
+
* is no longer there: the sweep's unlink becomes a no-op, the gate's reader skips
|
|
163
|
+
* it, and an ack that vanished stops suppressing the ask. Propagating instead
|
|
164
|
+
* would abort recordInstructionsLoaded on a benign $TMPDIR race and render the
|
|
165
|
+
* false "instruction file was NOT scanned" fault.
|
|
166
|
+
*
|
|
167
|
+
* `lstat`, so a planted symlink is judged on itself.
|
|
168
|
+
* @param {string} path
|
|
169
|
+
* @param {number} ttlMs
|
|
170
|
+
* @returns {boolean}
|
|
171
|
+
*/
|
|
172
|
+
function withinTtl(path, ttlMs) {
|
|
173
|
+
try {
|
|
174
|
+
return lstatSync(path).mtimeMs >= Date.now() - ttlMs;
|
|
175
|
+
} catch {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Whether `path` was written inside {@link FALLBACK_TTL_MS}.
|
|
182
|
+
* @param {string} path
|
|
183
|
+
* @returns {boolean}
|
|
184
|
+
*/
|
|
185
|
+
const withinFallbackTtl = (path) => withinTtl(path, FALLBACK_TTL_MS);
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Whether `path` is a real directory this uid owns — the directory counterpart
|
|
189
|
+
* of markerIsTrusted. `lstat`, so a symlink planted at the predictable alert-dir
|
|
190
|
+
* path is judged on ITSELF: followed, it would let a co-tenant aim the gate's
|
|
191
|
+
* reader at a directory of unrelated files this uid owns and splice their bytes
|
|
192
|
+
* into a permission prompt.
|
|
193
|
+
* @param {string} path
|
|
194
|
+
* @returns {boolean}
|
|
195
|
+
*/
|
|
196
|
+
function dirIsTrusted(path) {
|
|
197
|
+
let st;
|
|
198
|
+
try {
|
|
199
|
+
st = lstatSync(path);
|
|
200
|
+
} catch {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
return st.isDirectory() && st.uid === userInfo().uid;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Delete this project's artifacts from OTHER sessions once they are older than
|
|
208
|
+
* the TTL. The current session's own prefix is skipped, so the sweep can never
|
|
209
|
+
* answer its own question wrong; every other session's files are past history
|
|
210
|
+
* that nothing reads.
|
|
211
|
+
*
|
|
212
|
+
* This replaces the destructive SessionStart clear: with the store session-keyed
|
|
213
|
+
* there is nothing to reset, only old state to age out.
|
|
214
|
+
* @param {string} [sessionId] the session whose artifacts must be kept
|
|
110
215
|
* @returns {void}
|
|
111
216
|
*/
|
|
112
|
-
function
|
|
217
|
+
export function sweepStaleSessions(sessionId) {
|
|
113
218
|
const dir = tmpdir();
|
|
114
|
-
const prefix = `${basename(
|
|
219
|
+
const prefix = `${basename(ALERT_BASE)}.s-`;
|
|
220
|
+
const keep = basename(sessionPrefix(sessionId));
|
|
115
221
|
const cutoff = Date.now() - MARKER_TTL_MS;
|
|
116
222
|
for (const name of readdirSync(dir)) {
|
|
117
223
|
if (!name.startsWith(prefix)) continue;
|
|
224
|
+
// Whole-segment match on the keep prefix, not a bare startsWith: session key
|
|
225
|
+
// "abc" must not claim (and so preserve) session key "abc2"'s artifacts.
|
|
226
|
+
if (name === keep || name.startsWith(`${keep}.`)) continue;
|
|
118
227
|
const path = join(dir, name);
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
228
|
+
try {
|
|
229
|
+
// lstat, not stat: a squatted symlink at a predictable $TMPDIR path must
|
|
230
|
+
// be judged on ITSELF, not on whatever it points at.
|
|
231
|
+
if (lstatSync(path).mtimeMs >= cutoff) continue;
|
|
232
|
+
rmSync(path, { recursive: true, force: true });
|
|
233
|
+
} catch (err) {
|
|
234
|
+
// ENOENT: a parallel session swept this entry between readdir and lstat.
|
|
235
|
+
// EPERM/EACCES: a co-tenant's entry sharing the prefix, which is not ours
|
|
236
|
+
// to remove. Both are benign races on a shared $TMPDIR, and a throw here
|
|
237
|
+
// would abort recordInstructionsLoaded and render the "instruction file
|
|
238
|
+
// was NOT scanned" fault on a session that WAS scanned. Anything else is
|
|
239
|
+
// a bug in this sweep and propagates.
|
|
240
|
+
const code = /** @type {NodeJS.ErrnoException} */ (err).code;
|
|
241
|
+
if (code !== "ENOENT" && code !== "EPERM" && code !== "EACCES") throw err;
|
|
242
|
+
}
|
|
123
243
|
}
|
|
244
|
+
sweepStaleFallback(sessionId);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Remove the shared-prefix artifacts the reader has stopped honouring, so they
|
|
249
|
+
* stop occupying $TMPDIR too. The loop above cannot: it skips the current
|
|
250
|
+
* session's prefix, which on a host with no session id IS this one.
|
|
251
|
+
*
|
|
252
|
+
* Mirrors what {@link invisibleCharAlert} and {@link alertAcknowledged} read.
|
|
253
|
+
* The ack always expires; the findings only when this session has its own store
|
|
254
|
+
* and so merely INHERITED these.
|
|
255
|
+
*
|
|
256
|
+
* The two InstructionsLoaded markers under the same prefix answer "did a scan
|
|
257
|
+
* run at all", so FALLBACK_TTL_MS must never reach them: expiring one mid-session
|
|
258
|
+
* would render the gap notice on a session that WAS scanned. They still go, at
|
|
259
|
+
* MARKER_TTL_MS — the same age the loop above ages a real session's prefix out
|
|
260
|
+
* at, and far past any session's life — because the loop skips this prefix and
|
|
261
|
+
* would otherwise leave a session-less host's markers in $TMPDIR forever, with
|
|
262
|
+
* the gap notice suppressed on every later session.
|
|
263
|
+
* @param {string} [sessionId]
|
|
264
|
+
* @returns {void}
|
|
265
|
+
*/
|
|
266
|
+
function sweepStaleFallback(sessionId) {
|
|
267
|
+
const dir = alertDir();
|
|
268
|
+
const inherited = alertDir(sessionId) !== dir && dirIsTrusted(dir);
|
|
269
|
+
const entries = inherited
|
|
270
|
+
? readdirSync(dir).map((name) => join(dir, name))
|
|
271
|
+
: [];
|
|
272
|
+
// markerIsTrusted absorbs an absent or foreign entry, and having confirmed this
|
|
273
|
+
// uid owns the file there is nothing left that can refuse the unlink; `force`
|
|
274
|
+
// covers only a parallel session removing it first.
|
|
275
|
+
/** @param {string} path */
|
|
276
|
+
const drop = (path) => {
|
|
277
|
+
if (markerIsTrusted(path)) rmSync(path, { force: true });
|
|
278
|
+
};
|
|
279
|
+
for (const path of [alertAckFile(), ...entries])
|
|
280
|
+
if (!withinFallbackTtl(path)) drop(path);
|
|
281
|
+
for (const path of [instructionsLoadedFile(), instructionsLoadedNoticeFile()])
|
|
282
|
+
if (!withinTtl(path, MARKER_TTL_MS)) drop(path);
|
|
124
283
|
}
|
|
125
284
|
|
|
126
285
|
/**
|
|
@@ -128,8 +287,8 @@ function sweepStaleMarkers(keep) {
|
|
|
128
287
|
* write (see writeSentinelFile) at a predictable $TMPDIR path.
|
|
129
288
|
*
|
|
130
289
|
* The event fires once per instruction file loaded, so the already-recorded case
|
|
131
|
-
* returns without a write — and the stale-
|
|
132
|
-
* session, where one readdir is paid once rather than per loaded file.
|
|
290
|
+
* returns without a write — and the stale-session sweep rides the FIRST fire of
|
|
291
|
+
* a session, where one readdir is paid once rather than per loaded file.
|
|
133
292
|
* @param {string} [sessionId]
|
|
134
293
|
* @returns {void}
|
|
135
294
|
*/
|
|
@@ -137,14 +296,18 @@ export function recordInstructionsLoaded(sessionId) {
|
|
|
137
296
|
const marker = instructionsLoadedFile(sessionId);
|
|
138
297
|
if (markerIsTrusted(marker)) return;
|
|
139
298
|
writeSentinelFile(marker);
|
|
140
|
-
|
|
299
|
+
sweepStaleSessions(sessionId);
|
|
141
300
|
}
|
|
142
301
|
|
|
143
302
|
/**
|
|
144
303
|
* The one-time context line for a session where no InstructionsLoaded scan ran,
|
|
145
304
|
* or null when the scan has been seen or the notice was already surfaced this
|
|
146
|
-
* session.
|
|
147
|
-
*
|
|
305
|
+
* session.
|
|
306
|
+
*
|
|
307
|
+
* PURE: it does not record that the notice was handed out. The caller records
|
|
308
|
+
* separately, once the notice has actually landed in a response — a deny
|
|
309
|
+
* assembled after this call discards the notice, and a marker written here would
|
|
310
|
+
* have burned the session's one chance to report the loss.
|
|
148
311
|
*
|
|
149
312
|
* The loss it names is real and otherwise invisible: SessionStart scans the
|
|
150
313
|
* instruction files that load at launch, and everything a subdirectory loads
|
|
@@ -162,9 +325,7 @@ export function recordInstructionsLoaded(sessionId) {
|
|
|
162
325
|
*/
|
|
163
326
|
export function instructionsLoadedGapNotice(sessionId) {
|
|
164
327
|
if (instructionsLoadedSeen(sessionId)) return null;
|
|
165
|
-
|
|
166
|
-
if (markerIsTrusted(noticeFile)) return null;
|
|
167
|
-
writeSentinelFile(noticeFile);
|
|
328
|
+
if (markerIsTrusted(instructionsLoadedNoticeFile(sessionId))) return null;
|
|
168
329
|
return (
|
|
169
330
|
"agent-sanitizer: no InstructionsLoaded scan has run this session, so " +
|
|
170
331
|
"instruction files loaded from SUBDIRECTORIES (a nested CLAUDE.md, a " +
|
|
@@ -178,65 +339,128 @@ export function instructionsLoadedGapNotice(sessionId) {
|
|
|
178
339
|
);
|
|
179
340
|
}
|
|
180
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Record that the gap notice above was surfaced, so it rides on ONE tool call
|
|
344
|
+
* rather than every one — the per-call repeat is what trains a reader to skip it.
|
|
345
|
+
* Called only once the notice is in a response that is actually being returned.
|
|
346
|
+
* @param {string} [sessionId]
|
|
347
|
+
* @returns {void}
|
|
348
|
+
*/
|
|
349
|
+
export function recordInstructionsLoadedNotice(sessionId) {
|
|
350
|
+
writeSentinelFile(instructionsLoadedNoticeFile(sessionId));
|
|
351
|
+
}
|
|
352
|
+
|
|
181
353
|
/**
|
|
182
354
|
* The alert findings if invisible-char injection was detected in instruction
|
|
183
|
-
* files and couldn't be auto-cleaned, else null.
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
* would
|
|
355
|
+
* files and couldn't be auto-cleaned, else null.
|
|
356
|
+
*
|
|
357
|
+
* The store is a DIRECTORY of one file per finding, all at predictable,
|
|
358
|
+
* world-visible $TMPDIR paths, so both the directory and every entry in it are
|
|
359
|
+
* attacker-plantable: trust the directory only when it is a real directory this
|
|
360
|
+
* uid owns (a symlink would let a co-tenant aim this reader at unrelated files),
|
|
361
|
+
* each entry only when markerIsTrusted confirms a regular file this uid owns,
|
|
362
|
+
* then scrub the bytes through Layer-1 before any caller splices them into a
|
|
363
|
+
* reason — the report would otherwise carry ANSI/invisible spoofing into the
|
|
364
|
+
* model's context.
|
|
365
|
+
* This session's store AND the shared `no-session` fallback, because a hook that
|
|
366
|
+
* faults BEFORE it can parse its payload has no session identity to key by: its
|
|
367
|
+
* finding lands in the fallback, and a strictly session-keyed read would leave
|
|
368
|
+
* the one report of an unscanned instruction file unreachable. The ack stays
|
|
369
|
+
* strictly session-keyed, so the gate still asks exactly once per session. A
|
|
370
|
+
* fallback finding is read only while it is inside FALLBACK_TTL_MS, which is
|
|
371
|
+
* what keeps it from re-arming the gate for a later session.
|
|
372
|
+
* @param {string} [sessionId]
|
|
189
373
|
* @returns {string | null}
|
|
190
374
|
*/
|
|
191
|
-
export function invisibleCharAlert() {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
375
|
+
export function invisibleCharAlert(sessionId) {
|
|
376
|
+
const own = alertDir(sessionId);
|
|
377
|
+
// The second entry, when there is one, is a store belonging to no session,
|
|
378
|
+
// so only age says whether it is still this session's business. Where the
|
|
379
|
+
// fallback IS `own` there is nothing inherited and nothing to expire.
|
|
380
|
+
const dirs = own === alertDir() ? [own] : [own, alertDir()];
|
|
381
|
+
const parts = [];
|
|
382
|
+
for (const dir of dirs) {
|
|
383
|
+
if (!dirIsTrusted(dir)) continue;
|
|
384
|
+
// Sorted so a multi-finding report reads the same on every call; the names
|
|
385
|
+
// are random, so the order carries no meaning beyond being stable.
|
|
386
|
+
for (const name of readdirSync(dir).sort()) {
|
|
387
|
+
const path = join(dir, name);
|
|
388
|
+
if (!markerIsTrusted(path)) continue;
|
|
389
|
+
if (dir !== own && !withinFallbackTtl(path)) continue;
|
|
390
|
+
const text = readFileSync(path, "utf-8").trim();
|
|
391
|
+
if (text !== "") parts.push(text);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (parts.length === 0) return null;
|
|
395
|
+
return scrubUntrustedText(parts.join("\n"), applyLayer1);
|
|
195
396
|
}
|
|
196
397
|
|
|
197
398
|
/**
|
|
198
|
-
* Add `text` to the alert the PreToolUse gate surfaces, keeping
|
|
199
|
-
* already there.
|
|
399
|
+
* Add `text` to the alert the PreToolUse gate surfaces this session, keeping
|
|
400
|
+
* whatever is already there.
|
|
200
401
|
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
* world-visible $TMPDIR path; a foreign or squatted file reads as empty and is
|
|
207
|
-
* replaced rather than appended to.
|
|
402
|
+
* One O_EXCL-created, randomly-named file per finding. The store used to be a
|
|
403
|
+
* single file appended through a read-modify-write, so two hooks recording a
|
|
404
|
+
* finding at once silently dropped one of them; a fresh file per finding has no
|
|
405
|
+
* shared cell to lose. Symlink-refusing (writeFileNoFollow) because the store
|
|
406
|
+
* sits at a predictable, world-visible $TMPDIR path.
|
|
208
407
|
* @param {string} text
|
|
209
|
-
* @
|
|
408
|
+
* @param {string} [sessionId]
|
|
409
|
+
* @returns {boolean} whether the finding was recorded
|
|
210
410
|
*/
|
|
211
|
-
export function appendAlert(text) {
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
411
|
+
export function appendAlert(text, sessionId) {
|
|
412
|
+
const dir = alertDir(sessionId);
|
|
413
|
+
const path = join(dir, randomBytes(8).toString("hex"));
|
|
414
|
+
// Caught, not propagated: one caller is the fault handler that reports a hook
|
|
415
|
+
// crash, and a throw there would replace the report with a second crash. The
|
|
416
|
+
// loss is announced on stderr instead — never swallowed.
|
|
417
|
+
let usable;
|
|
418
|
+
try {
|
|
419
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
420
|
+
usable = dirIsTrusted(dir);
|
|
421
|
+
} catch {
|
|
422
|
+
usable = false;
|
|
423
|
+
}
|
|
424
|
+
if (usable && writeFileNoFollow(path, text + "\n")) return true;
|
|
425
|
+
process.stderr.write(
|
|
426
|
+
"agent-sanitizer: could not record an instruction-file finding under " +
|
|
427
|
+
`${dir}; the PreToolUse gate will NOT surface it this session.\n`,
|
|
428
|
+
);
|
|
429
|
+
return false;
|
|
216
430
|
}
|
|
217
431
|
|
|
218
432
|
/**
|
|
219
433
|
* True once the gate has surfaced its blocking ask this session. Validates
|
|
220
|
-
* ownership (not mere existence): a co-tenant could pre-create
|
|
221
|
-
* predictable $TMPDIR path to permanently suppress the one-time blocking ask down
|
|
222
|
-
* the passive reminder, so trust the marker only when it is a regular file
|
|
223
|
-
* wrote (markerIsTrusted), mirroring how acknowledgeAlert writes it.
|
|
434
|
+
* ownership (not mere existence): a co-tenant could pre-create the ack at its
|
|
435
|
+
* predictable $TMPDIR path to permanently suppress the one-time blocking ask down
|
|
436
|
+
* to the passive reminder, so trust the marker only when it is a regular file
|
|
437
|
+
* this uid wrote (markerIsTrusted), mirroring how acknowledgeAlert writes it.
|
|
438
|
+
*
|
|
439
|
+
* On a host that exports no session id every session shares the fallback prefix,
|
|
440
|
+
* so the ack has no session to end with and would suppress the one-time blocking
|
|
441
|
+
* ask down to the passive reminder for the life of the machine. There it expires
|
|
442
|
+
* with {@link FALLBACK_TTL_MS} like the findings it answers for.
|
|
443
|
+
* @param {string} [sessionId]
|
|
224
444
|
* @returns {boolean}
|
|
225
445
|
*/
|
|
226
|
-
export function alertAcknowledged() {
|
|
227
|
-
|
|
446
|
+
export function alertAcknowledged(sessionId) {
|
|
447
|
+
const path = alertAckFile(sessionId);
|
|
448
|
+
if (!markerIsTrusted(path)) return false;
|
|
449
|
+
if (sessionPrefix(sessionId) !== sessionPrefix()) return true;
|
|
450
|
+
return withinFallbackTtl(path);
|
|
228
451
|
}
|
|
229
452
|
|
|
230
453
|
/**
|
|
231
454
|
* Record that the gate has surfaced its blocking ask, so later tool calls get a
|
|
232
|
-
* passive reminder instead of an ask on every call.
|
|
233
|
-
*
|
|
455
|
+
* passive reminder instead of an ask on every call. Session-keyed, so the next
|
|
456
|
+
* session re-asks once without anything having to clear this.
|
|
457
|
+
* @param {string} [sessionId]
|
|
234
458
|
* @returns {void}
|
|
235
459
|
*/
|
|
236
|
-
export function acknowledgeAlert() {
|
|
237
|
-
// Symlink-safe presence write:
|
|
238
|
-
//
|
|
239
|
-
writeSentinelFile(
|
|
460
|
+
export function acknowledgeAlert(sessionId) {
|
|
461
|
+
// Symlink-safe presence write: the ack sits at a predictable $TMPDIR path a
|
|
462
|
+
// co-tenant could pre-plant a symlink at (see writeSentinelFile).
|
|
463
|
+
writeSentinelFile(alertAckFile(sessionId));
|
|
240
464
|
}
|
|
241
465
|
|
|
242
466
|
// What the operator can actually DO — one bullet per kind of report the alert
|