watchmyagents 1.4.3 → 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 +1 -1
- package/scripts/fetch-anthropic.js +29 -10
- package/scripts/shield.js +15 -3
- package/src/shield/stream.js +72 -3
- package/src/sources/anthropic-managed.js +30 -2
- package/src/watch-state.js +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "watchmyagents",
|
|
3
|
-
"version": "1.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": [
|
|
@@ -383,17 +383,25 @@ async function runWatch({ apiKey, resolveAgents, fleet, logDir, intervalMs, wind
|
|
|
383
383
|
let agents = await resolveAgents();
|
|
384
384
|
// v1.4.3 F-51: bounded dedup. Preloaded on-disk ids are static; runtime ids
|
|
385
385
|
// are tracked per-session and dropped on terminate (see src/watch-state.js).
|
|
386
|
-
const
|
|
387
|
-
|
|
388
|
-
|
|
386
|
+
const seen = new SeenTracker();
|
|
387
|
+
// v1.4.4 F-53: preload each agent's on-disk history the FIRST time we see it
|
|
388
|
+
// — including agents that --all-agents discovers after startup. Pre-fix the
|
|
389
|
+
// preload ran once for the initial fleet only, so a late-appearing agent with
|
|
390
|
+
// existing logs re-appended + re-uploaded already-captured events.
|
|
391
|
+
const preloadedAgents = new Set();
|
|
392
|
+
async function ensurePreloaded(agentId) {
|
|
393
|
+
if (preloadedAgents.has(agentId)) return;
|
|
394
|
+
preloadedAgents.add(agentId);
|
|
395
|
+
for (const id of await preloadSeenIds(logDir, agentId)) seen.addPreloaded(id);
|
|
389
396
|
}
|
|
390
|
-
const
|
|
397
|
+
for (const ag of agents) await ensurePreloaded(ag.agentId);
|
|
391
398
|
const loggers = new Map(); // sessionId → Logger (session ids are globally unique)
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
399
|
+
// terminated sessions (skip). v1.4.4 F-54: a Map<sid → terminatedAtMs> so we
|
|
400
|
+
// can TTL-prune. A session terminated more than windowMs ago has created_at
|
|
401
|
+
// older still, so listSessions (which filters created_at < since, since =
|
|
402
|
+
// now - windowMs) can no longer return it — its id is then safe to forget,
|
|
403
|
+
// bounding this map's growth on a long daemon with many short sessions.
|
|
404
|
+
const ended = new Map();
|
|
397
405
|
const sessionAgent = new Map();// sessionId → { agentId, model, displayName }
|
|
398
406
|
const priors = new Map(); // agentId → previous classification (threads the
|
|
399
407
|
// typology state machine across upload cycles)
|
|
@@ -413,9 +421,20 @@ async function runWatch({ apiKey, resolveAgents, fleet, logDir, intervalMs, wind
|
|
|
413
421
|
if (fleet) { const next = await resolveAgents(); if (next.length) agents = next; }
|
|
414
422
|
const since = new Date(Date.now() - windowMs);
|
|
415
423
|
|
|
424
|
+
// F-54: forget sessions terminated longer ago than the discovery window —
|
|
425
|
+
// listSessions can no longer surface them (created_at < since), so their
|
|
426
|
+
// skip-marker is no longer needed. Bounds `ended` on a long-running daemon.
|
|
427
|
+
const ttlCutoff = Date.now() - windowMs;
|
|
428
|
+
for (const [sid, terminatedAt] of ended) {
|
|
429
|
+
if (terminatedAt < ttlCutoff) ended.delete(sid);
|
|
430
|
+
}
|
|
431
|
+
|
|
416
432
|
// (1) Discover sessions in the window; register the owning agent for each.
|
|
417
433
|
for (const ag of agents) {
|
|
418
434
|
if (ac.signal.aborted) break;
|
|
435
|
+
// F-53: a late-discovered agent (fleet mode) gets its disk history
|
|
436
|
+
// preloaded before we fetch any of its sessions, so dedup holds.
|
|
437
|
+
await ensurePreloaded(ag.agentId);
|
|
419
438
|
const tag = fleet ? `[${ag.displayName}] ` : '';
|
|
420
439
|
let sessions = [];
|
|
421
440
|
try { sessions = await listSessions(apiKey, { agentId: ag.agentId, since }); }
|
|
@@ -507,7 +526,7 @@ async function runWatch({ apiKey, resolveAgents, fleet, logDir, intervalMs, wind
|
|
|
507
526
|
session_tokens: { input: stats.input, output: stats.output, cache_read: stats.cache_read, cache_creation: stats.cache_creation, total: stats.sum },
|
|
508
527
|
session_cost_usd: stats.cost_usd || null,
|
|
509
528
|
});
|
|
510
|
-
ended.
|
|
529
|
+
ended.set(sid, Date.now());
|
|
511
530
|
// v1.4.3 F-51: bound memory — terminated sessions aren't re-fetched,
|
|
512
531
|
// so drop their per-session dedup set AND their Logger object (the
|
|
513
532
|
// latter was never deleted pre-fix → unbounded growth of Logger
|
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
|
-
|
|
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
|
|
package/src/shield/stream.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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);
|
|
@@ -265,6 +269,18 @@ export async function* transformRawEventsToWMAActions(rawEventsAsyncIterable, {
|
|
|
265
269
|
// distinct API-level IDs will populate parent_agent_id natively.
|
|
266
270
|
const subThreadIds = new Set();
|
|
267
271
|
|
|
272
|
+
// v1.4.4 F-52 (P2 audit): only emit the end-of-stream "no_result_observed"
|
|
273
|
+
// flush when the session ACTUALLY terminated in this stream. In watch mode
|
|
274
|
+
// fetchRawEvents re-fetches the FULL session every cycle, so a tool whose
|
|
275
|
+
// start lands in cycle N but whose result lands in cycle N+1 would, with an
|
|
276
|
+
// unconditional flush, get a phantom no_result_observed row (id = start id)
|
|
277
|
+
// in cycle N AND the real paired row (id = result id) in cycle N+1 — a
|
|
278
|
+
// double-count + persistent phantom error skewing error_rate_by_tool. While
|
|
279
|
+
// the session is still running an unpaired start is in-flight, not
|
|
280
|
+
// "no result observed"; it stays pending and pairs (or flushes) once the
|
|
281
|
+
// session's terminal state is observed.
|
|
282
|
+
let sessionTerminated = false;
|
|
283
|
+
|
|
268
284
|
// `provider` is the canonical field per src/sources/contract.js (no
|
|
269
285
|
// other consumer ever read the previous `framework` field, so it was
|
|
270
286
|
// dropped in PR-B with zero downstream impact).
|
|
@@ -632,6 +648,8 @@ export async function* transformRawEventsToWMAActions(rawEventsAsyncIterable, {
|
|
|
632
648
|
const prefix = isThread ? 'session.thread_status_' : 'session.status_';
|
|
633
649
|
const state = type.slice(prefix.length); // 'running' | 'idle' | 'rescheduled' | 'terminated'
|
|
634
650
|
const fatal = state === 'terminated';
|
|
651
|
+
// F-52: a SESSION-scope terminate (not a sub-thread one) gates the flush.
|
|
652
|
+
if (fatal && !isThread) sessionTerminated = true;
|
|
635
653
|
yield {
|
|
636
654
|
...base,
|
|
637
655
|
...subAgentMeta,
|
|
@@ -666,6 +684,16 @@ export async function* transformRawEventsToWMAActions(rawEventsAsyncIterable, {
|
|
|
666
684
|
// explicitly with status='error' keeps the local NDJSON, anonymizer
|
|
667
685
|
// signals (counts, IoC hashes, tool_counts), and Fortress decisions
|
|
668
686
|
// honest about what actually happened.
|
|
687
|
+
//
|
|
688
|
+
// v1.4.4 F-52: GATED on sessionTerminated. If the session is still running
|
|
689
|
+
// (watch mode re-fetches the full session every cycle), an unpaired start is
|
|
690
|
+
// in-flight, not lost — flushing it now would create a phantom row that the
|
|
691
|
+
// next cycle's real paired row double-counts. We only flush once the
|
|
692
|
+
// session's terminal state proves the calls genuinely never resolved. (Both
|
|
693
|
+
// F-8 and F-50 flush tests feed a session.status_terminated event, so this
|
|
694
|
+
// gate is satisfied for them.)
|
|
695
|
+
if (!sessionTerminated) return;
|
|
696
|
+
|
|
669
697
|
for (const [toolUseId, pending] of pendingToolUse) {
|
|
670
698
|
yield {
|
|
671
699
|
...base,
|
package/src/watch-state.js
CHANGED
|
@@ -24,6 +24,15 @@ export class SeenTracker {
|
|
|
24
24
|
this._bySession = new Map(); // sessionId -> Set<eventId>
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// v1.4.4 F-53: fold an agent's on-disk history into the static preloaded
|
|
28
|
+
// set. Called lazily the first time an agent is seen (including agents that
|
|
29
|
+
// --all-agents discovers AFTER startup) so their already-captured NDJSON ids
|
|
30
|
+
// are deduped against — otherwise a late-appearing agent with existing logs
|
|
31
|
+
// would re-append and re-upload events it already has.
|
|
32
|
+
addPreloaded(id) {
|
|
33
|
+
if (id) this._preloaded.add(id);
|
|
34
|
+
}
|
|
35
|
+
|
|
27
36
|
has(sessionId, eventId) {
|
|
28
37
|
if (this._preloaded.has(eventId)) return true;
|
|
29
38
|
const s = this._bySession.get(sessionId);
|