watchmyagents 1.4.4 → 1.4.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "watchmyagents",
3
- "version": "1.4.4",
3
+ "version": "1.4.5",
4
4
  "description": "Security observability + real-time policy enforcement for AI agents. Local-first NDJSON capture with a continuous Watch daemon that auto-uploads anonymized signals, Shield CLI that blocks policy violations live (with policies pulled from Fortress cloud), anonymizer producing signals-only payloads, bidirectional sync with WatchMyAgents Fortress, and one-command install as an always-on launchd/systemd service — closing the recursive Watch→Guardian→Shield security loop.",
5
5
  "type": "module",
6
6
  "files": [
package/scripts/shield.js CHANGED
@@ -34,7 +34,7 @@ import {
34
34
  } from '../src/shield/enforce.js';
35
35
  import { maybePrintVersionAndExit } from '../src/version.js';
36
36
  import { DecisionLogger } from '../src/shield/decisions.js';
37
- import { listSessions, listAgents } from '../src/sources/anthropic-managed.js';
37
+ import { listSessions, listAgents, fetchRawEvents } from '../src/sources/anthropic-managed.js';
38
38
  import { FortressPolicySource, postDecision } from '../src/shield/sources/fortress.js';
39
39
  import { resolveFortressBase, fortressEndpoint } from '../src/fortress/url.js';
40
40
  import { PolicyStream } from '../src/shield/policy-stream.js';
@@ -223,9 +223,21 @@ async function runSessionWorker({ sessionId, ctx }) {
223
223
  try {
224
224
  for await (const rawEvent of streamWithReconnect({
225
225
  apiKey, sessionId, signal, maxAttempts: 3,
226
- onReconnect: ({ attempt, backoffMs, error }) => {
227
- sinfo(sessionId, `reconnect attempt ${attempt}/3 in ${backoffMs}ms (${error.message})`);
226
+ onReconnect: ({ attempt, backoffMs, error, backfilled, afterId, backfillError }) => {
227
+ if (backfillError) {
228
+ swarn(sessionId, `reconnect backfill failed (continuing on live stream): ${backfillError.message}`);
229
+ } else if (backfilled) {
230
+ // F-55: surface that the gap was closed — these events would
231
+ // otherwise have been silently missed by enforcement.
232
+ sinfo(sessionId, `reconnect backfilled ${backfilled} missed event(s) after ${String(afterId).slice(0, 16)}…`);
233
+ } else {
234
+ sinfo(sessionId, `reconnect attempt ${attempt}/3 in ${backoffMs}ms (${error?.message ?? 'drop'})`);
235
+ }
228
236
  },
237
+ // F-55: close the enforcement blind window on reconnect — page the
238
+ // persisted events endpoint for everything the live stream missed
239
+ // during the drop, exclusive of the last id we already processed.
240
+ backfill: (afterId, sig) => fetchRawEvents(apiKey, sessionId, { afterId }),
229
241
  })) {
230
242
  processed++;
231
243
 
@@ -177,15 +177,68 @@ function parseFrame(frame) {
177
177
  catch { return null; }
178
178
  }
179
179
 
180
+ // v1.4.5 F-55 (P1 audit): bounded set of recently-yielded event ids. The
181
+ // reconnect path can re-deliver events (backfill tail overlapping the resumed
182
+ // live head, or an upstream redelivering after a drop). De-duplicating by id
183
+ // makes the whole stream idempotent so Shield never double-enforces /
184
+ // double-records the same event. Bounded so a long-lived stream can't grow it
185
+ // without limit; ordered eviction drops the oldest ids first.
186
+ export function makeSeenIdSet(cap = 4096) {
187
+ const set = new Set();
188
+ return {
189
+ seen(id) {
190
+ if (set.has(id)) return true;
191
+ set.add(id);
192
+ if (set.size > cap) {
193
+ // Evict oldest insertion-order ids down to ~90% of cap.
194
+ const target = Math.floor(cap * 0.9);
195
+ for (const old of set) {
196
+ set.delete(old);
197
+ if (set.size <= target) break;
198
+ }
199
+ }
200
+ return false;
201
+ },
202
+ };
203
+ }
204
+
180
205
  // High-level wrapper: stream forever, reconnecting on transient errors.
181
206
  // Yields events; on fatal/permanent errors throws after maxAttempts.
182
- export async function* streamWithReconnect({ apiKey, sessionId, signal, maxAttempts = 5, onReconnect }) {
207
+ //
208
+ // v1.4.5 F-55 (P1 audit): SSE reconnect previously re-opened the stream at the
209
+ // LIVE position with no resume cursor, so every event emitted during the drop
210
+ // window was lost — including a `requires_action` (which pauses the agent
211
+ // waiting on Shield) or the `agent.tool_use` that must be cached before it.
212
+ // That was a silent enforcement blind window. Now, before resuming the live
213
+ // stream after a drop, we BACKFILL the gap: page the persisted events endpoint
214
+ // for everything after the last id we yielded, feed those through, then resume
215
+ // live. Yielded ids are de-duplicated so the backfill/live overlap can't cause
216
+ // double-processing.
217
+ //
218
+ // `backfill(afterId, signal)` — optional async-iterable factory the caller
219
+ // injects (Shield passes one backed by fetchRawEvents with `afterId`). Kept as
220
+ // an injected dependency so this module stays import-free of the Anthropic
221
+ // source and remains unit-testable with a stub.
222
+ export async function* streamWithReconnect({ apiKey, sessionId, signal, maxAttempts = 5, onReconnect, backfill, _openStream = openEventStream }) {
183
223
  let attempt = 0;
224
+ let lastEventId = null;
225
+ const dedup = makeSeenIdSet();
226
+
227
+ // Yield an event unless we've already emitted it; track the last id so a
228
+ // reconnect knows where to backfill from.
229
+ function shouldYield(ev) {
230
+ if (ev && ev.id != null) {
231
+ if (dedup.seen(ev.id)) return false;
232
+ lastEventId = ev.id;
233
+ }
234
+ return true;
235
+ }
236
+
184
237
  while (true) {
185
238
  try {
186
- for await (const ev of openEventStream({ apiKey, sessionId, signal })) {
239
+ for await (const ev of _openStream({ apiKey, sessionId, signal })) {
187
240
  attempt = 0; // any event resets the backoff
188
- yield ev;
241
+ if (shouldYield(ev)) yield ev;
189
242
  }
190
243
  // Stream ended without throwing — session likely closed cleanly. Exit.
191
244
  return;
@@ -198,6 +251,22 @@ export async function* streamWithReconnect({ apiKey, sessionId, signal, maxAttem
198
251
  const backoffMs = Math.min(30_000, 1000 * 2 ** (attempt - 1));
199
252
  if (onReconnect) onReconnect({ attempt, backoffMs, error: e });
200
253
  await new Promise(r => setTimeout(r, backoffMs));
254
+
255
+ // F-55: backfill the gap the live stream missed before reconnecting.
256
+ // Best-effort: a backfill failure must not abort enforcement — we log
257
+ // it and fall through to the live re-open, which will itself retry.
258
+ if (backfill && lastEventId && !signal?.aborted) {
259
+ try {
260
+ let backfilled = 0;
261
+ for await (const ev of backfill(lastEventId, signal)) {
262
+ if (signal?.aborted) return;
263
+ if (shouldYield(ev)) { backfilled++; yield ev; }
264
+ }
265
+ if (backfilled && onReconnect) onReconnect({ attempt, backfilled, afterId: lastEventId });
266
+ } catch (be) {
267
+ if (onReconnect) onReconnect({ attempt, backfillError: be });
268
+ }
269
+ }
201
270
  }
202
271
  }
203
272
  }
@@ -168,8 +168,12 @@ export async function listSessions(apiKey, { agentId, since, limit = 100, maxPag
168
168
 
169
169
  // Yields raw events in chronological order. Accepts an optional types filter
170
170
  // to reduce payload (server-side `types[]=...&types[]=...`).
171
- export async function* fetchRawEvents(apiKey, sessionId, { types } = {}) {
172
- let after = null;
171
+ export async function* fetchRawEvents(apiKey, sessionId, { types, afterId } = {}) {
172
+ // v1.4.5 F-55: `afterId` lets a caller page only events AFTER a known id
173
+ // (exclusive) — used by Shield's reconnect backfill to fetch exactly the
174
+ // gap that the live SSE stream missed during a drop, rather than re-walking
175
+ // the whole session.
176
+ let after = afterId || null;
173
177
  while (true) {
174
178
  const qs = new URLSearchParams({ limit: '1000' });
175
179
  if (after) qs.set('after_id', after);