watchmyagents 0.9.0 → 0.9.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
@@ -121,6 +121,8 @@ wma-fetch (--agent-id <agent_id> | --all-agents) [--session-id <sess_id>] [--sin
121
121
  | `--watch` | **Continuous daemon** — loop forever, incrementally capturing NEW events (deduped by stable event id) until `Ctrl+C` |
122
122
  | `--interval 5m` | Poll interval in watch mode (default `5m`; accepts `30s`/`1h`/…) |
123
123
  | `--upload` | In watch mode, anonymize each new window and ship signals to Fortress (needs `WMA_API_KEY` + `WMA_FORTRESS_BASE_URL` + `WMA_SIGNALS_SALT`). Raw stays local. |
124
+ | `--discovery-since 7d` | Window for discovering NEW sessions (default `7d`). Sessions already being tracked are re-fetched regardless of age, so long-running ones never drop out. |
125
+ | `--send-agent-names` | Opt-in: send the human agent name as the Fortress `display_name`. Default sends the agent id only (the name may contain client/project info). |
124
126
  | `--api-key sk-ant-…` | Override the `ANTHROPIC_API_KEY` env var. **Discouraged** — visible in shell history & process list. Prefer the env var. |
125
127
 
126
128
  Logs land in `./watchmyagents-logs/<agent_id>/<date>.ndjson` (file mode `0600`, dir `0700`).
@@ -153,7 +155,7 @@ wma-upload-fortress --agent-id agent_01ABC... [--display-name "My agent"]
153
155
  wma-upload-fortress --agent-id agent_xxx --dry-run
154
156
  ```
155
157
 
156
- **What is sent:** counts, latencies, salted IoC hashes, sequences — same as `wma-anonymize` output.
158
+ **What is sent:** the anonymized signals payload (counts, latencies, salted IoC hashes, sequences — same as `wma-anonymize` output) **plus two routing identifiers**: your `anthropic_agent_id` and a `display_name`. The agent id is required so Fortress can associate signals with the right agent; `display_name` defaults to the agent id and only carries the human-readable agent name if you opt in (`wma-fetch --watch --upload --send-agent-names`).
157
159
  **What is NOT sent:** raw prompts, raw URLs/commands/queries, raw agent responses, raw error messages. All payload content stays on your machine.
158
160
 
159
161
  The endpoint auto-registers the agent on the first upload if it doesn't exist in Fortress yet — no manual onboarding needed for new agents.
@@ -245,9 +247,9 @@ WatchMyAgents is built so that **your prompts and outputs never have to leave yo
245
247
  |---|---|
246
248
  | **Your machine** (`./watchmyagents-logs/`) | Full NDJSON with all prompts, tool inputs, agent outputs. `chmod 600` on every file. |
247
249
  | **Anthropic API** | Where the agent runs. WMA pulls events via the public REST API only. |
248
- | **WMA infrastructure** | **Nothing today.** Future opt-in telemetry will ship only anonymized metadata (counts, timings, hashes) — never raw payloads. |
250
+ | **WMA Fortress** (opt-in, only with `--upload` / `wma-upload-fortress` / `wma-shield --policies-source fortress`) | The **anonymized signals** payload (counts, timings, salted hashes, sequences) + two routing identifiers: your `anthropic_agent_id` and a `display_name` (defaults to the agent id; the human agent name only with `--send-agent-names`). Shield enforcement **decisions** (hashed session/event/input fingerprints — never raw values). **Never** raw prompts, URLs, commands, or outputs. |
249
251
 
250
- This is the "local-first" guarantee. It is the product, not a marketing claim.
252
+ This is the "local-first" guarantee: **raw payloads never leave your machine.** Cloud upload is opt-in and carries only anonymized metadata + the agent id/name needed to route it.
251
253
 
252
254
  ## Security
253
255
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "watchmyagents",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
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": [
@@ -202,76 +202,95 @@ async function fetchOneShot({ apiKey, agentId, model, logDir, since, sessionId,
202
202
  }
203
203
 
204
204
  // ── CONTINUOUS / DAEMON (single agent or whole fleet) ───────────────────────
205
- // `agents` = [{ agentId, model, displayName }]. One process watches them all.
206
- async function runWatch({ apiKey, agents, logDir, intervalMs, uploadCtx }) {
205
+ // resolveAgents() returns the current fleet [{agentId, model, displayName}] each
206
+ // cycle in fleet mode it RE-DISCOVERS so agents created after startup get picked
207
+ // up. `windowMs` bounds discovery of NEW sessions, but sessions we're ALREADY
208
+ // tracking are re-fetched regardless of age, so a long-running (>window) session
209
+ // never drops out of capture. `sendNames`: include the human agent name in the
210
+ // Fortress display_name (opt-in); default sends the agent id only (Modèle C).
211
+ async function runWatch({ apiKey, resolveAgents, fleet, logDir, intervalMs, windowMs, uploadCtx, sendNames }) {
212
+ let agents = await resolveAgents();
207
213
  const seenIds = new Set(); // stable Anthropic event ids already captured
208
214
  for (const ag of agents) {
209
215
  for (const id of await preloadSeenIds(logDir, ag.agentId)) seenIds.add(id);
210
216
  }
211
217
  const loggers = new Map(); // sessionId → Logger (session ids are globally unique)
212
- const ended = new Set(); // sessions we've already closed with session_end
218
+ const ended = new Set(); // terminated sessions (skip)
219
+ const sessionAgent = new Map();// sessionId → { agentId, model, displayName }
213
220
 
214
221
  const ac = new AbortController();
215
222
  const shutdown = () => { info('shutting down…'); ac.abort(); };
216
223
  process.on('SIGINT', shutdown);
217
224
  process.on('SIGTERM', shutdown);
218
225
 
219
- const fleet = agents.length > 1;
220
- info(`watch mode — ${agents.length} agent(s), interval ${Math.round(intervalMs / 1000)}s, upload ${uploadCtx ? 'ON' : 'OFF'}, ${seenIds.size} known events preloaded`);
226
+ info(`watch mode ${agents.length} agent(s)${fleet ? ' (fleet, re-discovered each cycle)' : ''}, interval ${Math.round(intervalMs / 1000)}s, discovery window ${Math.round(windowMs / 3600000)}h, upload ${uploadCtx ? 'ON' : 'OFF'}, ${seenIds.size} known events preloaded`);
221
227
 
222
228
  while (!ac.signal.aborted) {
223
- const since = new Date(Date.now() - 24 * 3600 * 1000);
224
- let cycleNew = 0;
229
+ if (fleet) { const next = await resolveAgents(); if (next.length) agents = next; }
230
+ const since = new Date(Date.now() - windowMs);
225
231
 
232
+ // (1) Discover sessions in the window; register the owning agent for each.
226
233
  for (const ag of agents) {
227
234
  if (ac.signal.aborted) break;
228
235
  const tag = fleet ? `[${ag.displayName}] ` : '';
229
236
  let sessions = [];
230
237
  try { sessions = await listSessions(apiKey, { agentId: ag.agentId, since }); }
231
238
  catch (e) { warn(`${tag}listSessions failed: ${e.message}`); continue; }
232
-
233
239
  for (const s of sessions) {
234
- if (!s.id || ended.has(s.id)) continue;
235
- let logger = loggers.get(s.id);
236
- if (!logger) { logger = new Logger({ logDir, agentId: ag.agentId, sessionId: s.id, silent: true }); loggers.set(s.id, logger); }
237
-
238
- const fresh = [];
239
- let sawTerminated = false;
240
- try {
241
- for await (const entry of fetchSessionEntries({ apiKey, agentId: ag.agentId, sessionId: s.id, model: ag.model })) {
242
- if (entry.id && seenIds.has(entry.id)) continue;
243
- if (entry.id) seenIds.add(entry.id);
244
- const written = await logger.write(entry);
245
- fresh.push(written);
246
- if (entry.action_type === 'state_transition'
247
- && entry.output?.scope === 'session'
248
- && entry.output?.state === 'terminated') sawTerminated = true;
249
- }
250
- } catch (e) { warn(`${tag}session ${s.id.slice(0, 16)}…: fetch failed: ${e.message}`); continue; }
240
+ if (s.id && !ended.has(s.id) && !sessionAgent.has(s.id)) {
241
+ sessionAgent.set(s.id, { agentId: ag.agentId, model: ag.model, displayName: ag.displayName });
242
+ }
243
+ }
244
+ }
251
245
 
252
- if (fresh.length === 0) continue;
253
- cycleNew += fresh.length;
254
- info(`${tag}session ${s.id.slice(0, 16)}…: +${fresh.length} new event(s)`);
246
+ // (2) Capture every tracked, not-yet-ended session — REGARDLESS of age. This
247
+ // is what stops a long-running session created before the window from silently
248
+ // dropping out of monitoring (and, paired with Shield, out of enforcement).
249
+ let cycleNew = 0;
250
+ for (const [sid, ag] of sessionAgent) {
251
+ if (ac.signal.aborted) break;
252
+ if (ended.has(sid)) continue;
253
+ const tag = fleet ? `[${ag.displayName}] ` : '';
254
+ let logger = loggers.get(sid);
255
+ if (!logger) { logger = new Logger({ logDir, agentId: ag.agentId, sessionId: sid, silent: true }); loggers.set(sid, logger); }
255
256
 
256
- if (uploadCtx) {
257
- try {
258
- const resp = await uploadSignals(uploadCtx, ag.agentId, ag.displayName, fresh);
259
- if (resp?.signal_id) info(` ↑ signals uploaded (signal_id ${resp.signal_id})`);
260
- } catch (e) { warn(` signals upload failed: ${e.message}`); }
257
+ const fresh = [];
258
+ let sawTerminated = false;
259
+ try {
260
+ for await (const entry of fetchSessionEntries({ apiKey, agentId: ag.agentId, sessionId: sid, model: ag.model })) {
261
+ if (entry.id && seenIds.has(entry.id)) continue;
262
+ if (entry.id) seenIds.add(entry.id);
263
+ const written = await logger.write(entry);
264
+ fresh.push(written);
265
+ if (entry.action_type === 'state_transition'
266
+ && entry.output?.scope === 'session'
267
+ && entry.output?.state === 'terminated') sawTerminated = true;
261
268
  }
269
+ } catch (e) { warn(`${tag}session ${sid.slice(0, 16)}…: fetch failed: ${e.message}`); continue; }
262
270
 
263
- if (sawTerminated) {
264
- const tracker = new TokenTracker();
265
- for (const e of fresh) tracker.record(e);
266
- const stats = tracker.stats().total;
267
- await logger.write({
268
- action_type: 'session_end', framework: 'anthropic-managed', status: 'ok', model: ag.model,
269
- session_tokens: { input: stats.input, output: stats.output, cache_read: stats.cache_read, cache_creation: stats.cache_creation, total: stats.sum },
270
- session_cost_usd: stats.cost_usd || null,
271
- });
272
- ended.add(s.id);
273
- info(`${tag}session ${s.id.slice(0, 16)}… terminated — closed`);
274
- }
271
+ if (fresh.length === 0) continue;
272
+ cycleNew += fresh.length;
273
+ info(`${tag}session ${sid.slice(0, 16)}…: +${fresh.length} new event(s)`);
274
+
275
+ if (uploadCtx) {
276
+ try {
277
+ const resp = await uploadSignals(uploadCtx, ag.agentId, sendNames ? ag.displayName : ag.agentId, fresh);
278
+ if (resp?.signal_id) info(` ↑ signals uploaded (signal_id ${resp.signal_id})`);
279
+ } catch (e) { warn(` signals upload failed: ${e.message}`); }
280
+ }
281
+
282
+ if (sawTerminated) {
283
+ const tracker = new TokenTracker();
284
+ for (const e of fresh) tracker.record(e);
285
+ const stats = tracker.stats().total;
286
+ await logger.write({
287
+ action_type: 'session_end', framework: 'anthropic-managed', status: 'ok', model: ag.model,
288
+ session_tokens: { input: stats.input, output: stats.output, cache_read: stats.cache_read, cache_creation: stats.cache_creation, total: stats.sum },
289
+ session_cost_usd: stats.cost_usd || null,
290
+ });
291
+ ended.add(sid);
292
+ sessionAgent.delete(sid); // bound memory: terminated sessions aren't re-fetched
293
+ info(`${tag}session ${sid.slice(0, 16)}… terminated — closed`);
275
294
  }
276
295
  }
277
296
 
@@ -318,30 +337,49 @@ async function main() {
318
337
  uploadCtx = { apiKey: wmaKey, salt, url: fortressEndpoint(base, 'ingest-signals') };
319
338
  }
320
339
 
321
- // Resolve the agent list: the whole fleet (--all-agents) or a single agent.
322
- let agents;
323
- if (allAgents) {
324
- info('discovering agents (fleet mode)…');
325
- const all = await listAgents(apiKey).catch((e) => die(`failed to list agents: ${e.message}`));
326
- agents = all
327
- .filter((a) => a.id && isValidAgentId(a.id))
328
- .map((a) => ({ agentId: a.id, model: resolveModel(a), displayName: cleanLabel(a.name || a.id) }));
329
- if (agents.length === 0) die('error: no agents found under this API key');
330
- info(`fleet: ${agents.length} agent(s) — ${agents.map((a) => a.displayName).join(', ')}`);
331
- } else {
332
- info(`resolving agent ${agentId}…`);
333
- const agent = await getAgent(apiKey, agentId).catch((e) => die(`failed to GET agent: ${e.message}`));
334
- agents = [{ agentId, model: resolveModel(agent), displayName: cleanLabel(agent.name || agentId) }];
335
- info(`model: ${agents[0].model || '(unknown)'}`);
336
- }
337
-
338
340
  if (watch) {
339
341
  const intervalMs = parseDurationMs(args.interval, 5 * 60_000);
340
- await runWatch({ apiKey, agents, logDir, intervalMs, uploadCtx });
342
+ // Discovery window for NEW sessions (default 7d, configurable). Sessions we
343
+ // already track are re-fetched regardless of age, so long-lived ones don't drop.
344
+ const windowMs = parseDurationMs(args['discovery-since'], 7 * 24 * 3600_000);
345
+ const sendNames = !!args['send-agent-names'];
346
+
347
+ let resolveAgents;
348
+ if (allAgents) {
349
+ // Re-discover the fleet each cycle: agents created after startup get picked
350
+ // up, gone ones drop off. Keep the last good list if a discovery call fails.
351
+ let lastFleet = [];
352
+ resolveAgents = async () => {
353
+ const all = await listAgents(apiKey).catch((e) => { warn(`fleet re-discovery failed (keeping last): ${e.message}`); return null; });
354
+ if (!all) return lastFleet;
355
+ const next = all
356
+ .filter((a) => a.id && isValidAgentId(a.id))
357
+ .map((a) => ({ agentId: a.id, model: resolveModel(a), displayName: cleanLabel(a.name || a.id) }));
358
+ const prev = new Set(lastFleet.map((a) => a.agentId));
359
+ const cur = new Set(next.map((a) => a.agentId));
360
+ for (const a of next) if (!prev.has(a.agentId)) info(`fleet: + ${a.displayName}`);
361
+ for (const a of lastFleet) if (!cur.has(a.agentId)) info(`fleet: − ${a.displayName} (gone)`);
362
+ lastFleet = next;
363
+ return next;
364
+ };
365
+ info('discovering agents (fleet mode)…');
366
+ const first = await resolveAgents();
367
+ if (first.length === 0) die('error: no agents found under this API key');
368
+ info(`fleet: ${first.length} agent(s) — ${first.map((a) => a.displayName).join(', ')}`);
369
+ } else {
370
+ info(`resolving agent ${agentId}…`);
371
+ const agent = await getAgent(apiKey, agentId).catch((e) => die(`failed to GET agent: ${e.message}`));
372
+ const single = [{ agentId, model: resolveModel(agent), displayName: cleanLabel(agent.name || agentId) }];
373
+ info(`model: ${single[0].model || '(unknown)'}`);
374
+ resolveAgents = async () => single;
375
+ }
376
+
377
+ await runWatch({ apiKey, resolveAgents, fleet: allAgents, logDir, intervalMs, windowMs, uploadCtx, sendNames });
341
378
  } else {
379
+ info(`resolving agent ${agentId}…`);
380
+ const agent = await getAgent(apiKey, agentId).catch((e) => die(`failed to GET agent: ${e.message}`));
342
381
  const since = args.since ? parseSince(args.since) : null;
343
- const a = agents[0];
344
- await fetchOneShot({ apiKey, agentId: a.agentId, model: a.model, logDir, since, sessionId: args['session-id'], dumpRaw: !!args['dump-raw'] });
382
+ await fetchOneShot({ apiKey, agentId, model: resolveModel(agent), logDir, since, sessionId: args['session-id'], dumpRaw: !!args['dump-raw'] });
345
383
  }
346
384
  }
347
385
 
package/scripts/shield.js CHANGED
@@ -57,6 +57,14 @@ function info(msg) { process.stdout.write(`[shield] ${msg}\n`); }
57
57
  function warn(msg) { process.stderr.write(`[shield] ⚠️ ${msg}\n`); }
58
58
  function sinfo(sid, msg) { process.stdout.write(`[shield/${sid.slice(0, 12)}] ${msg}\n`); }
59
59
  function swarn(sid, msg) { process.stderr.write(`[shield/${sid.slice(0, 12)}] ⚠️ ${msg}\n`); }
60
+ const sleep = (ms, signal) => new Promise((res) => {
61
+ const t = setTimeout(res, ms);
62
+ if (signal) signal.addEventListener('abort', () => { clearTimeout(t); res(); }, { once: true });
63
+ });
64
+ function parseWindowMs(v, fallback) {
65
+ const m = v && String(v).match(/^(\d+)\s*([smhd])$/);
66
+ return m ? parseInt(m[1], 10) * { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2]] : fallback;
67
+ }
60
68
 
61
69
  const CACHEABLE_TOOL_TYPES = new Set([
62
70
  'agent.tool_use', 'agent.mcp_tool_use', 'agent.custom_tool_use',
@@ -319,6 +327,10 @@ async function runSessionWorker({ sessionId, ctx }) {
319
327
  // ────────────────────────────────────────────────────────────────────────
320
328
  async function runAgentWide(ctx) {
321
329
  const { apiKey, agentId, signal } = ctx;
330
+ // Discovery window for sessions we haven't attached yet (default 7d). Already-
331
+ // attached workers stream until the session terminates regardless of age, so a
332
+ // long-running session never loses enforcement once attached.
333
+ const discoveryWindowMs = ctx.discoveryWindowMs || 7 * 24 * 3600_000;
322
334
  const workers = new Map(); // sessionId → AbortController (active workers)
323
335
  const cooldown = new Map(); // sessionId → unix-ms timestamp when re-attach is allowed
324
336
 
@@ -332,9 +344,7 @@ async function runAgentWide(ctx) {
332
344
  async function discoverAndAttach() {
333
345
  let sessions;
334
346
  try {
335
- // Look at sessions from the last 24h (anything older that's still idle
336
- // is probably stale; the user can extend the window if needed).
337
- const since = new Date(Date.now() - 24 * 3600_000);
347
+ const since = new Date(Date.now() - discoveryWindowMs);
338
348
  sessions = await listSessions(apiKey, { agentId, since });
339
349
  } catch (e) {
340
350
  warn(`listSessions failed: ${e.message}`);
@@ -424,6 +434,7 @@ async function main() {
424
434
  });
425
435
  const logDir = resolve(args['log-dir'] || './watchmyagents-logs');
426
436
  const allAgents = !!args['all-agents'];
437
+ const discoveryWindowMs = parseWindowMs(args['discovery-since'], 7 * 24 * 3600_000);
427
438
 
428
439
  if (!apiKey) die('error: --api-key or ANTHROPIC_API_KEY required');
429
440
  if (!allAgents && !agentId) die('error: --agent-id required (or --all-agents for fleet mode)');
@@ -530,22 +541,47 @@ async function main() {
530
541
  return {
531
542
  apiKey, agentId: aid,
532
543
  get ruleset() { return fortressPolicies ? fortressPolicies.current() : ruleset; },
533
- mode, decisions, pushDecisionToFortress, signalsSalt, signal: ac.signal,
544
+ mode, decisions, pushDecisionToFortress, signalsSalt, signal: ac.signal, discoveryWindowMs,
534
545
  };
535
546
  }
536
547
 
537
- // Phase 1: arm every agent. Fail LOUD if none armed (otherwise the process would
538
- // exit silently and under launchd/systemd restart-loop without a clear cause).
539
- const ctxs = (await Promise.all(agentIds.map(setupAgent))).filter(Boolean);
540
- if (ctxs.length === 0) {
541
- die(`error: no agents could be armed (${agentIds.length} discovered; all policy fetches failed). Check WMA_API_KEY / WMA_FORTRESS_BASE_URL.`);
548
+ if (!fleet) {
549
+ // Single agent: arm + run (blocks until SIGINT/SIGTERM). die() on failure
550
+ // already fires inside setupAgent for the non-fleet path.
551
+ const ctx = await setupAgent(agentIds[0]);
552
+ await (singleSessionId ? runSessionWorker({ sessionId: singleSessionId, ctx }) : runAgentWide(ctx));
553
+ return;
542
554
  }
543
- if (fleet) info(`armed ${ctxs.length}/${agentIds.length} agent(s); watching.`);
544
555
 
545
- // Phase 2: run each agent's loop (blocks until SIGINT/SIGTERM).
546
- await Promise.all(ctxs.map((ctx) => (
547
- singleSessionId ? runSessionWorker({ sessionId: singleSessionId, ctx }) : runAgentWide(ctx)
548
- )));
556
+ // Fleet: arm all discovered agents, then RECONCILE periodically so an agent
557
+ // created after startup gets armed + protected without a restart. A per-agent
558
+ // arm failure is skipped and retried on the next reconcile.
559
+ const armed = new Set();
560
+ const running = [];
561
+ const armNew = async (ids) => {
562
+ for (const aid of ids) {
563
+ if (armed.has(aid)) continue;
564
+ const ctx = await setupAgent(aid);
565
+ if (!ctx) continue; // skipped (policy fetch failed) → retry next reconcile
566
+ armed.add(aid);
567
+ running.push(runAgentWide(ctx)); // fire; blocks on the shared signal until shutdown
568
+ info(`fleet: armed ${aid.slice(0, 16)}…`);
569
+ }
570
+ };
571
+ await armNew(agentIds);
572
+ if (armed.size === 0) {
573
+ die(`error: no agents could be armed (${agentIds.length} discovered; all policy fetches failed). Check WMA_API_KEY / WMA_FORTRESS_BASE_URL.`);
574
+ }
575
+ info(`fleet: ${armed.size}/${agentIds.length} agent(s) armed; reconciling every 60s for new agents.`);
576
+ while (!ac.signal.aborted) {
577
+ await sleep(60_000, ac.signal);
578
+ if (ac.signal.aborted) break;
579
+ let all;
580
+ try { all = await listAgents(apiKey); }
581
+ catch (e) { warn(`fleet reconcile failed (keeping current): ${e.message}`); continue; }
582
+ await armNew(all.map((a) => a.id).filter((id) => id && isValidAgentId(id)));
583
+ }
584
+ await Promise.all(running);
549
585
  }
550
586
 
551
587
  main().catch(e => {