agent-sanitizer 2.43.12 → 2.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/claude-hooks/lib/hook-io.mjs +69 -9
- package/claude-hooks/lib/invisible-alert.mjs +207 -92
- package/claude-hooks/lib/redactor-client.mjs +15 -12
- package/claude-hooks/lib/reveal.mjs +49 -2
- package/claude-hooks/lib/secret-drop-guard.mjs +44 -3
- package/claude-hooks/plugin-hooks.mjs +5 -2
- package/claude-hooks/pretooluse-sanitize.mjs +25 -11
- package/claude-hooks/scan-invisible-chars.mjs +59 -32
- package/claude-hooks/scan-loaded-instructions.mjs +10 -3
- package/package.json +1 -1
- package/types/claude-hooks/lib/hook-io.d.mts +33 -7
- package/types/claude-hooks/lib/invisible-alert.d.mts +101 -47
- package/types/claude-hooks/lib/reveal.d.mts +16 -0
- package/types/claude-hooks/lib/secret-drop-guard.d.mts +11 -0
- package/types/claude-hooks/scan-invisible-chars.d.mts +18 -3
package/README.md
CHANGED
|
@@ -368,6 +368,14 @@ 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, as before.
|
|
378
|
+
|
|
371
379
|
**A host's own remedy can replace the packaged one in every failure reason**
|
|
372
380
|
(the fail-closed verdicts and the fail-open warning alike). Deep call sites (`lib/control-plane`'s missing-package throw) take no
|
|
373
381
|
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
|
+
// where a marker can outlive its session.
|
|
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,27 +125,65 @@ 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
|
+
* Whether `path` is a real directory this uid owns — the directory counterpart
|
|
133
|
+
* of markerIsTrusted. `lstat`, so a symlink planted at the predictable alert-dir
|
|
134
|
+
* path is judged on ITSELF: followed, it would let a co-tenant aim the gate's
|
|
135
|
+
* reader at a directory of unrelated files this uid owns and splice their bytes
|
|
136
|
+
* into a permission prompt.
|
|
137
|
+
* @param {string} path
|
|
138
|
+
* @returns {boolean}
|
|
139
|
+
*/
|
|
140
|
+
function dirIsTrusted(path) {
|
|
141
|
+
let st;
|
|
142
|
+
try {
|
|
143
|
+
st = lstatSync(path);
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
return st.isDirectory() && st.uid === userInfo().uid;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Delete this project's artifacts from OTHER sessions once they are older than
|
|
152
|
+
* the TTL. The current session's own prefix is skipped, so the sweep can never
|
|
153
|
+
* answer its own question wrong; every other session's files are past history
|
|
154
|
+
* that nothing reads.
|
|
155
|
+
*
|
|
156
|
+
* This replaces the destructive SessionStart clear: with the store session-keyed
|
|
157
|
+
* there is nothing to reset, only old state to age out.
|
|
158
|
+
* @param {string} [sessionId] the session whose artifacts must be kept
|
|
110
159
|
* @returns {void}
|
|
111
160
|
*/
|
|
112
|
-
function
|
|
161
|
+
export function sweepStaleSessions(sessionId) {
|
|
113
162
|
const dir = tmpdir();
|
|
114
|
-
const prefix = `${basename(
|
|
163
|
+
const prefix = `${basename(ALERT_BASE)}.s-`;
|
|
164
|
+
const keep = basename(sessionPrefix(sessionId));
|
|
115
165
|
const cutoff = Date.now() - MARKER_TTL_MS;
|
|
116
166
|
for (const name of readdirSync(dir)) {
|
|
117
167
|
if (!name.startsWith(prefix)) continue;
|
|
168
|
+
// Whole-segment match on the keep prefix, not a bare startsWith: session key
|
|
169
|
+
// "abc" must not claim (and so preserve) session key "abc2"'s artifacts.
|
|
170
|
+
if (name === keep || name.startsWith(`${keep}.`)) continue;
|
|
118
171
|
const path = join(dir, name);
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
172
|
+
try {
|
|
173
|
+
// lstat, not stat: a squatted symlink at a predictable $TMPDIR path must
|
|
174
|
+
// be judged on ITSELF, not on whatever it points at.
|
|
175
|
+
if (lstatSync(path).mtimeMs >= cutoff) continue;
|
|
176
|
+
rmSync(path, { recursive: true, force: true });
|
|
177
|
+
} catch (err) {
|
|
178
|
+
// ENOENT: a parallel session swept this entry between readdir and lstat.
|
|
179
|
+
// EPERM/EACCES: a co-tenant's entry sharing the prefix, which is not ours
|
|
180
|
+
// to remove. Both are benign races on a shared $TMPDIR, and a throw here
|
|
181
|
+
// would abort recordInstructionsLoaded and render the "instruction file
|
|
182
|
+
// was NOT scanned" fault on a session that WAS scanned. Anything else is
|
|
183
|
+
// a bug in this sweep and propagates.
|
|
184
|
+
const code = /** @type {NodeJS.ErrnoException} */ (err).code;
|
|
185
|
+
if (code !== "ENOENT" && code !== "EPERM" && code !== "EACCES") throw err;
|
|
186
|
+
}
|
|
123
187
|
}
|
|
124
188
|
}
|
|
125
189
|
|
|
@@ -128,8 +192,8 @@ function sweepStaleMarkers(keep) {
|
|
|
128
192
|
* write (see writeSentinelFile) at a predictable $TMPDIR path.
|
|
129
193
|
*
|
|
130
194
|
* 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.
|
|
195
|
+
* returns without a write — and the stale-session sweep rides the FIRST fire of
|
|
196
|
+
* a session, where one readdir is paid once rather than per loaded file.
|
|
133
197
|
* @param {string} [sessionId]
|
|
134
198
|
* @returns {void}
|
|
135
199
|
*/
|
|
@@ -137,14 +201,18 @@ export function recordInstructionsLoaded(sessionId) {
|
|
|
137
201
|
const marker = instructionsLoadedFile(sessionId);
|
|
138
202
|
if (markerIsTrusted(marker)) return;
|
|
139
203
|
writeSentinelFile(marker);
|
|
140
|
-
|
|
204
|
+
sweepStaleSessions(sessionId);
|
|
141
205
|
}
|
|
142
206
|
|
|
143
207
|
/**
|
|
144
208
|
* The one-time context line for a session where no InstructionsLoaded scan ran,
|
|
145
209
|
* or null when the scan has been seen or the notice was already surfaced this
|
|
146
|
-
* session.
|
|
147
|
-
*
|
|
210
|
+
* session.
|
|
211
|
+
*
|
|
212
|
+
* PURE: it does not record that the notice was handed out. The caller records
|
|
213
|
+
* separately, once the notice has actually landed in a response — a deny
|
|
214
|
+
* assembled after this call discards the notice, and a marker written here would
|
|
215
|
+
* have burned the session's one chance to report the loss.
|
|
148
216
|
*
|
|
149
217
|
* The loss it names is real and otherwise invisible: SessionStart scans the
|
|
150
218
|
* instruction files that load at launch, and everything a subdirectory loads
|
|
@@ -162,9 +230,7 @@ export function recordInstructionsLoaded(sessionId) {
|
|
|
162
230
|
*/
|
|
163
231
|
export function instructionsLoadedGapNotice(sessionId) {
|
|
164
232
|
if (instructionsLoadedSeen(sessionId)) return null;
|
|
165
|
-
|
|
166
|
-
if (markerIsTrusted(noticeFile)) return null;
|
|
167
|
-
writeSentinelFile(noticeFile);
|
|
233
|
+
if (markerIsTrusted(instructionsLoadedNoticeFile(sessionId))) return null;
|
|
168
234
|
return (
|
|
169
235
|
"agent-sanitizer: no InstructionsLoaded scan has run this session, so " +
|
|
170
236
|
"instruction files loaded from SUBDIRECTORIES (a nested CLAUDE.md, a " +
|
|
@@ -178,65 +244,114 @@ export function instructionsLoadedGapNotice(sessionId) {
|
|
|
178
244
|
);
|
|
179
245
|
}
|
|
180
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Record that the gap notice above was surfaced, so it rides on ONE tool call
|
|
249
|
+
* rather than every one — the per-call repeat is what trains a reader to skip it.
|
|
250
|
+
* Called only once the notice is in a response that is actually being returned.
|
|
251
|
+
* @param {string} [sessionId]
|
|
252
|
+
* @returns {void}
|
|
253
|
+
*/
|
|
254
|
+
export function recordInstructionsLoadedNotice(sessionId) {
|
|
255
|
+
writeSentinelFile(instructionsLoadedNoticeFile(sessionId));
|
|
256
|
+
}
|
|
257
|
+
|
|
181
258
|
/**
|
|
182
259
|
* 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
|
|
260
|
+
* files and couldn't be auto-cleaned, else null.
|
|
261
|
+
*
|
|
262
|
+
* The store is a DIRECTORY of one file per finding, all at predictable,
|
|
263
|
+
* world-visible $TMPDIR paths, so both the directory and every entry in it are
|
|
264
|
+
* attacker-plantable: trust the directory only when it is a real directory this
|
|
265
|
+
* uid owns (a symlink would let a co-tenant aim this reader at unrelated files),
|
|
266
|
+
* each entry only when markerIsTrusted confirms a regular file this uid owns,
|
|
267
|
+
* then scrub the bytes through Layer-1 before any caller splices them into a
|
|
268
|
+
* reason — the report would otherwise carry ANSI/invisible spoofing into the
|
|
269
|
+
* model's context.
|
|
270
|
+
* This session's store AND the shared `no-session` fallback, because a hook that
|
|
271
|
+
* faults BEFORE it can parse its payload has no session identity to key by: its
|
|
272
|
+
* finding lands in the fallback, and a strictly session-keyed read would leave
|
|
273
|
+
* the one report of an unscanned instruction file unreachable. The ack stays
|
|
274
|
+
* strictly session-keyed, so the gate still asks exactly once per session.
|
|
275
|
+
* @param {string} [sessionId]
|
|
189
276
|
* @returns {string | null}
|
|
190
277
|
*/
|
|
191
|
-
export function invisibleCharAlert() {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
278
|
+
export function invisibleCharAlert(sessionId) {
|
|
279
|
+
const dirs = [alertDir(sessionId)];
|
|
280
|
+
if (dirs[0] !== alertDir()) dirs.push(alertDir());
|
|
281
|
+
const parts = [];
|
|
282
|
+
for (const dir of dirs) {
|
|
283
|
+
if (!dirIsTrusted(dir)) continue;
|
|
284
|
+
// Sorted so a multi-finding report reads the same on every call; the names
|
|
285
|
+
// are random, so the order carries no meaning beyond being stable.
|
|
286
|
+
for (const name of readdirSync(dir).sort()) {
|
|
287
|
+
const path = join(dir, name);
|
|
288
|
+
if (!markerIsTrusted(path)) continue;
|
|
289
|
+
const text = readFileSync(path, "utf-8").trim();
|
|
290
|
+
if (text !== "") parts.push(text);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (parts.length === 0) return null;
|
|
294
|
+
return scrubUntrustedText(parts.join("\n"), applyLayer1);
|
|
195
295
|
}
|
|
196
296
|
|
|
197
297
|
/**
|
|
198
|
-
* Add `text` to the alert the PreToolUse gate surfaces, keeping
|
|
199
|
-
* already there.
|
|
298
|
+
* Add `text` to the alert the PreToolUse gate surfaces this session, keeping
|
|
299
|
+
* whatever is already there.
|
|
200
300
|
*
|
|
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.
|
|
301
|
+
* One O_EXCL-created, randomly-named file per finding. The store used to be a
|
|
302
|
+
* single file appended through a read-modify-write, so two hooks recording a
|
|
303
|
+
* finding at once silently dropped one of them; a fresh file per finding has no
|
|
304
|
+
* shared cell to lose. Symlink-refusing (writeFileNoFollow) because the store
|
|
305
|
+
* sits at a predictable, world-visible $TMPDIR path.
|
|
208
306
|
* @param {string} text
|
|
209
|
-
* @
|
|
307
|
+
* @param {string} [sessionId]
|
|
308
|
+
* @returns {boolean} whether the finding was recorded
|
|
210
309
|
*/
|
|
211
|
-
export function appendAlert(text) {
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
310
|
+
export function appendAlert(text, sessionId) {
|
|
311
|
+
const dir = alertDir(sessionId);
|
|
312
|
+
const path = join(dir, randomBytes(8).toString("hex"));
|
|
313
|
+
// Caught, not propagated: one caller is the fault handler that reports a hook
|
|
314
|
+
// crash, and a throw there would replace the report with a second crash. The
|
|
315
|
+
// loss is announced on stderr instead — never swallowed.
|
|
316
|
+
let usable;
|
|
317
|
+
try {
|
|
318
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
319
|
+
usable = dirIsTrusted(dir);
|
|
320
|
+
} catch {
|
|
321
|
+
usable = false;
|
|
322
|
+
}
|
|
323
|
+
if (usable && writeFileNoFollow(path, text + "\n")) return true;
|
|
324
|
+
process.stderr.write(
|
|
325
|
+
"agent-sanitizer: could not record an instruction-file finding under " +
|
|
326
|
+
`${dir}; the PreToolUse gate will NOT surface it this session.\n`,
|
|
327
|
+
);
|
|
328
|
+
return false;
|
|
216
329
|
}
|
|
217
330
|
|
|
218
331
|
/**
|
|
219
332
|
* 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.
|
|
333
|
+
* ownership (not mere existence): a co-tenant could pre-create the ack at its
|
|
334
|
+
* predictable $TMPDIR path to permanently suppress the one-time blocking ask down
|
|
335
|
+
* to the passive reminder, so trust the marker only when it is a regular file
|
|
336
|
+
* this uid wrote (markerIsTrusted), mirroring how acknowledgeAlert writes it.
|
|
337
|
+
* @param {string} [sessionId]
|
|
224
338
|
* @returns {boolean}
|
|
225
339
|
*/
|
|
226
|
-
export function alertAcknowledged() {
|
|
227
|
-
return markerIsTrusted(
|
|
340
|
+
export function alertAcknowledged(sessionId) {
|
|
341
|
+
return markerIsTrusted(alertAckFile(sessionId));
|
|
228
342
|
}
|
|
229
343
|
|
|
230
344
|
/**
|
|
231
345
|
* 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
|
-
*
|
|
346
|
+
* passive reminder instead of an ask on every call. Session-keyed, so the next
|
|
347
|
+
* session re-asks once without anything having to clear this.
|
|
348
|
+
* @param {string} [sessionId]
|
|
234
349
|
* @returns {void}
|
|
235
350
|
*/
|
|
236
|
-
export function acknowledgeAlert() {
|
|
237
|
-
// Symlink-safe presence write:
|
|
238
|
-
//
|
|
239
|
-
writeSentinelFile(
|
|
351
|
+
export function acknowledgeAlert(sessionId) {
|
|
352
|
+
// Symlink-safe presence write: the ack sits at a predictable $TMPDIR path a
|
|
353
|
+
// co-tenant could pre-plant a symlink at (see writeSentinelFile).
|
|
354
|
+
writeSentinelFile(alertAckFile(sessionId));
|
|
240
355
|
}
|
|
241
356
|
|
|
242
357
|
// What the operator can actually DO — one bullet per kind of report the alert
|
|
@@ -466,6 +466,8 @@ export async function redactViaDaemon(text, opts = {}) {
|
|
|
466
466
|
// reading `result.pairs` on it would silently emit the original secret-shaped
|
|
467
467
|
// content. Validated AFTER the respawn/retry logic so a malformed response is not
|
|
468
468
|
// mistaken for a dead socket worth respawning.
|
|
469
|
+
// Each throw carries the BARE cause: every call sits inside a catch that
|
|
470
|
+
// wraps what it caught, so wrapping here too would nest the sentence in itself.
|
|
469
471
|
/** @param {RedactResponse|null} result @returns {RedactResponse|null} */
|
|
470
472
|
const validate = (result) => {
|
|
471
473
|
if (result === null) return null;
|
|
@@ -474,18 +476,14 @@ export async function redactViaDaemon(text, opts = {}) {
|
|
|
474
476
|
result?.unmappable === undefined &&
|
|
475
477
|
!(typeof result?.text === "string" && Array.isArray(result?.pairs))
|
|
476
478
|
)
|
|
477
|
-
throw
|
|
478
|
-
|
|
479
|
-
"redactor returned a malformed map response (no `unmappable` marker and no `{text, pairs}` map)",
|
|
480
|
-
),
|
|
479
|
+
throw new Error(
|
|
480
|
+
"redactor returned a malformed map response (no `unmappable` marker and no `{text, pairs}` map)",
|
|
481
481
|
);
|
|
482
482
|
return result;
|
|
483
483
|
}
|
|
484
484
|
if (typeof result?.text !== "string")
|
|
485
|
-
throw
|
|
486
|
-
|
|
487
|
-
"redactor returned a malformed plain response (no string `text`)",
|
|
488
|
-
),
|
|
485
|
+
throw new Error(
|
|
486
|
+
"redactor returned a malformed plain response (no string `text`)",
|
|
489
487
|
);
|
|
490
488
|
return result;
|
|
491
489
|
};
|
|
@@ -503,17 +501,22 @@ export async function redactViaDaemon(text, opts = {}) {
|
|
|
503
501
|
// within the deadline; surface that as the actual cause rather than the opaque
|
|
504
502
|
// ENOENT/connect error the retry would otherwise throw.
|
|
505
503
|
const budgetMs = remainingMs();
|
|
506
|
-
const
|
|
504
|
+
const waitMs =
|
|
507
505
|
budgetMs === undefined
|
|
508
|
-
?
|
|
509
|
-
:
|
|
506
|
+
? WAIT_DEADLINE_MS
|
|
507
|
+
: Math.min(WAIT_DEADLINE_MS, budgetMs);
|
|
508
|
+
const waitOpts =
|
|
509
|
+
budgetMs === undefined ? undefined : { deadlineMs: waitMs };
|
|
510
510
|
// The cold-start wait is PROVISIONING — a one-time detect-secrets import and
|
|
511
511
|
// plugin prime (~1-3s, see WAIT_DEADLINE_MS), paid by whichever tool call
|
|
512
512
|
// happens to be first and by no other. Charging it to this hook would make
|
|
513
513
|
// the slow-hook notice fire once per session on a healthy install.
|
|
514
514
|
if (!(await excludeProvisioning(() => waitFn(socketPath, waitOpts), now)))
|
|
515
515
|
throw failClosed(
|
|
516
|
-
|
|
516
|
+
// The CLAMPED wait, since that is what actually elapsed: naming
|
|
517
|
+
// WAIT_DEADLINE_MS tells an operator debugging a 200ms budget that the
|
|
518
|
+
// redactor got seconds it was never given.
|
|
519
|
+
new Error(`redactor daemon did not start within ${waitMs}ms`),
|
|
517
520
|
);
|
|
518
521
|
if (budgetSpent()) throw outOfBudget("after redactor respawn");
|
|
519
522
|
try {
|