agent-sanitizer 2.44.0 → 2.45.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 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, as before.
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
- // where a marker can outlive its session.
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 dirs = [alertDir(sessionId)];
280
- if (dirs[0] !== alertDir()) dirs.push(alertDir());
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
- return markerIsTrusted(alertAckFile(sessionId));
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.0",
3
+ "version": "2.45.0",
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
  */