agent-sanitizer 2.44.0 → 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
CHANGED
|
@@ -374,7 +374,10 @@ second line reading `flock` (`SETUP_LOCK_DECLARATION`), and the hooks then judge
|
|
|
374
374
|
liveness by the lock rather than the pid: the kernel releases an `flock` the
|
|
375
375
|
instant its holder dies, so a killed setup is detected immediately instead of
|
|
376
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
|
|
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.
|
|
378
381
|
|
|
379
382
|
**A host's own remedy can replace the packaged one in every failure reason**
|
|
380
383
|
(the fail-closed verdicts and the fail-open warning alike). Deep call sites (`lib/control-plane`'s missing-package throw) take no
|
|
@@ -66,7 +66,7 @@ export function sessionPrefix(sessionId) {
|
|
|
66
66
|
// The id becomes a path component, so anything outside this class — a `/` in
|
|
67
67
|
// a hostile session id above all — is folded away rather than escaping
|
|
68
68
|
// $TMPDIR. A host that exports no session id falls back to one shared name,
|
|
69
|
-
//
|
|
69
|
+
// whose findings and ack are bounded by FALLBACK_TTL_MS instead.
|
|
70
70
|
const key =
|
|
71
71
|
(sessionId ?? "").replace(/[^A-Za-z0-9._-]/gu, "_") || "no-session";
|
|
72
72
|
return `${ALERT_BASE}.s-${key}`;
|
|
@@ -128,6 +128,62 @@ export function instructionsLoadedSeen(sessionId) {
|
|
|
128
128
|
/** How long a past session's artifacts are kept before a later session sweeps. */
|
|
129
129
|
const MARKER_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
130
130
|
|
|
131
|
+
/**
|
|
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
|
+
|
|
131
187
|
/**
|
|
132
188
|
* Whether `path` is a real directory this uid owns — the directory counterpart
|
|
133
189
|
* of markerIsTrusted. `lstat`, so a symlink planted at the predictable alert-dir
|
|
@@ -185,6 +241,45 @@ export function sweepStaleSessions(sessionId) {
|
|
|
185
241
|
if (code !== "ENOENT" && code !== "EPERM" && code !== "EACCES") throw err;
|
|
186
242
|
}
|
|
187
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);
|
|
188
283
|
}
|
|
189
284
|
|
|
190
285
|
/**
|
|
@@ -271,13 +366,18 @@ export function recordInstructionsLoadedNotice(sessionId) {
|
|
|
271
366
|
* faults BEFORE it can parse its payload has no session identity to key by: its
|
|
272
367
|
* finding lands in the fallback, and a strictly session-keyed read would leave
|
|
273
368
|
* 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.
|
|
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.
|
|
275
372
|
* @param {string} [sessionId]
|
|
276
373
|
* @returns {string | null}
|
|
277
374
|
*/
|
|
278
375
|
export function invisibleCharAlert(sessionId) {
|
|
279
|
-
const
|
|
280
|
-
|
|
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()];
|
|
281
381
|
const parts = [];
|
|
282
382
|
for (const dir of dirs) {
|
|
283
383
|
if (!dirIsTrusted(dir)) continue;
|
|
@@ -286,6 +386,7 @@ export function invisibleCharAlert(sessionId) {
|
|
|
286
386
|
for (const name of readdirSync(dir).sort()) {
|
|
287
387
|
const path = join(dir, name);
|
|
288
388
|
if (!markerIsTrusted(path)) continue;
|
|
389
|
+
if (dir !== own && !withinFallbackTtl(path)) continue;
|
|
289
390
|
const text = readFileSync(path, "utf-8").trim();
|
|
290
391
|
if (text !== "") parts.push(text);
|
|
291
392
|
}
|
|
@@ -334,11 +435,19 @@ export function appendAlert(text, sessionId) {
|
|
|
334
435
|
* predictable $TMPDIR path to permanently suppress the one-time blocking ask down
|
|
335
436
|
* to the passive reminder, so trust the marker only when it is a regular file
|
|
336
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.
|
|
337
443
|
* @param {string} [sessionId]
|
|
338
444
|
* @returns {boolean}
|
|
339
445
|
*/
|
|
340
446
|
export function alertAcknowledged(sessionId) {
|
|
341
|
-
|
|
447
|
+
const path = alertAckFile(sessionId);
|
|
448
|
+
if (!markerIsTrusted(path)) return false;
|
|
449
|
+
if (sessionPrefix(sessionId) !== sessionPrefix()) return true;
|
|
450
|
+
return withinFallbackTtl(path);
|
|
342
451
|
}
|
|
343
452
|
|
|
344
453
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.44.
|
|
3
|
+
"version": "2.44.1",
|
|
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": {
|
|
@@ -123,7 +123,9 @@ export function recordInstructionsLoadedNotice(sessionId?: string): void;
|
|
|
123
123
|
* faults BEFORE it can parse its payload has no session identity to key by: its
|
|
124
124
|
* finding lands in the fallback, and a strictly session-keyed read would leave
|
|
125
125
|
* the one report of an unscanned instruction file unreachable. The ack stays
|
|
126
|
-
* strictly session-keyed, so the gate still asks exactly once per session.
|
|
126
|
+
* strictly session-keyed, so the gate still asks exactly once per session. A
|
|
127
|
+
* fallback finding is read only while it is inside FALLBACK_TTL_MS, which is
|
|
128
|
+
* what keeps it from re-arming the gate for a later session.
|
|
127
129
|
* @param {string} [sessionId]
|
|
128
130
|
* @returns {string | null}
|
|
129
131
|
*/
|
|
@@ -148,6 +150,11 @@ export function appendAlert(text: string, sessionId?: string): boolean;
|
|
|
148
150
|
* predictable $TMPDIR path to permanently suppress the one-time blocking ask down
|
|
149
151
|
* to the passive reminder, so trust the marker only when it is a regular file
|
|
150
152
|
* this uid wrote (markerIsTrusted), mirroring how acknowledgeAlert writes it.
|
|
153
|
+
*
|
|
154
|
+
* On a host that exports no session id every session shares the fallback prefix,
|
|
155
|
+
* so the ack has no session to end with and would suppress the one-time blocking
|
|
156
|
+
* ask down to the passive reminder for the life of the machine. There it expires
|
|
157
|
+
* with {@link FALLBACK_TTL_MS} like the findings it answers for.
|
|
151
158
|
* @param {string} [sessionId]
|
|
152
159
|
* @returns {boolean}
|
|
153
160
|
*/
|