watchmyagents 0.8.2 → 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/scripts/shield.js CHANGED
@@ -33,7 +33,7 @@ import {
33
33
  getAgentConfig, detectAlwaysAsk,
34
34
  } from '../src/shield/enforce.js';
35
35
  import { DecisionLogger } from '../src/shield/decisions.js';
36
- import { listSessions } from '../src/sources/anthropic-managed.js';
36
+ import { listSessions, listAgents } from '../src/sources/anthropic-managed.js';
37
37
  import { FortressPolicySource, postDecision } from '../src/shield/sources/fortress.js';
38
38
  import { resolveFortressBase } from '../src/fortress/url.js';
39
39
  import { isValidAgentId, isValidSessionId } from '../src/validate.js';
@@ -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}`);
@@ -423,10 +433,16 @@ async function main() {
423
433
  explicitUrl: args['fortress-url'],
424
434
  });
425
435
  const logDir = resolve(args['log-dir'] || './watchmyagents-logs');
436
+ const allAgents = !!args['all-agents'];
437
+ const discoveryWindowMs = parseWindowMs(args['discovery-since'], 7 * 24 * 3600_000);
426
438
 
427
439
  if (!apiKey) die('error: --api-key or ANTHROPIC_API_KEY required');
428
- if (!agentId) die('error: --agent-id required');
429
- if (!isValidAgentId(agentId)) {
440
+ if (!allAgents && !agentId) die('error: --agent-id required (or --all-agents for fleet mode)');
441
+ if (allAgents && singleSessionId) die('error: --all-agents is incompatible with --session-id');
442
+ if (allAgents && policiesSource !== 'fortress') {
443
+ die('error: --all-agents requires --policies-source fortress (per-agent policies).');
444
+ }
445
+ if (agentId && !isValidAgentId(agentId)) {
430
446
  die(`error: --agent-id has invalid format (expected "agent_" + alphanumeric, got "${agentId}")`);
431
447
  }
432
448
  // --session-id ends up in the Anthropic SSE URL path (src/shield/stream.js).
@@ -435,120 +451,137 @@ async function main() {
435
451
  die(`error: --session-id has invalid format (expected "sesn_" + alphanumeric, got "${singleSessionId}")`);
436
452
  }
437
453
 
438
- // Policies source: --policies-source fortress | local (default infers from --policy)
439
- let ruleset; // for 'local' mode: static; for 'fortress': initial snapshot
440
- let fortressPolicies; // FortressPolicySource instance, used as ground truth at runtime
441
-
454
+ // Validate the policy source config once (shared across the fleet). For local
455
+ // mode the ruleset is loaded once and shared by every agent.
456
+ let sharedLocalRuleset = null;
442
457
  if (policiesSource === 'fortress') {
443
458
  if (!wmaApiKey) die('error: --policies-source fortress requires --wma-api-key or WMA_API_KEY env');
444
459
  if (!fortressBase) die('error: --policies-source fortress requires --fortress-base-url or WMA_FORTRESS_BASE_URL env');
445
460
  if (!/^wma_[a-f0-9]{32}$/i.test(wmaApiKey)) warn(`WMA_API_KEY format looks unusual (expected wma_<32hex>).`);
446
-
447
- fortressPolicies = new FortressPolicySource({
448
- apiKey: wmaApiKey,
449
- base: fortressBase,
450
- anthropicAgentId: agentId,
451
- refreshIntervalMs: 5 * 60_000,
452
- onError: (e) => warn(`policy refresh failed (keeping cached): ${e.message}`),
453
- onRefresh: ({ policies, fetched_at, initial }) => {
454
- info(`policies ${initial ? 'loaded' : 'refreshed'} from Fortress — ${policies.length} active (fetched_at: ${fetched_at})`);
455
- },
456
- });
457
- try {
458
- await fortressPolicies.start();
459
- } catch (e) {
460
- die(`error fetching policies from Fortress: ${e.message}\n` +
461
- ` Check WMA_FORTRESS_BASE_URL and WMA_API_KEY.`);
462
- }
463
- ruleset = fortressPolicies.current();
464
461
  } else if (policiesSource === 'local') {
465
462
  if (!policyPath) die('error: --policies-source local requires --policy <path-to-policies.json>');
466
- try {
467
- ruleset = await loadPolicies(resolve(policyPath));
468
- } catch (e) {
469
- die(`error loading policies: ${e.message}`);
470
- }
463
+ try { sharedLocalRuleset = await loadPolicies(resolve(policyPath)); }
464
+ catch (e) { die(`error loading policies: ${e.message}`); }
471
465
  } else {
472
466
  die('error: --policy <path> OR --policies-source fortress required');
473
467
  }
474
468
 
475
- let mode = 'interrupt';
476
- let agentMeta = null;
477
- try {
478
- agentMeta = await getAgentConfig(apiKey, agentId);
479
- if (detectAlwaysAsk(agentMeta)) mode = 'tool_confirmation';
480
- } catch (e) {
481
- warn(`could not fetch agent config (${e.message}). Defaulting to interrupt mode.`);
482
- }
483
-
484
- const sourceLabel = policiesSource === 'fortress'
485
- ? `Fortress (${fortressBase})`
486
- : policyPath;
487
- info(`armed — ${ruleset.policies.length} policies loaded from ${sourceLabel}`);
488
- info(`default action when no rule matches: ${ruleset.default.action}`);
489
- info(`agent: ${agentId}${agentMeta?.name ? ` "${agentMeta.name}"` : ''}`);
490
- info(`enforcement mode: ${mode}`);
491
- if (mode === 'interrupt') {
492
- warn('DEGRADED mode — Shield will interrupt AFTER a violating tool runs.');
493
- warn(`For pre-execution blocking, run: wma-shield --setup-guide --agent-id ${agentId}`);
469
+ // Resolve the agent list: whole fleet (--all-agents) or a single agent.
470
+ let agentIds;
471
+ if (allAgents) {
472
+ info('discovering agents (fleet mode)…');
473
+ const all = await listAgents(apiKey).catch((e) => die(`failed to list agents: ${e.message}`));
474
+ agentIds = all.map((a) => a.id).filter((id) => id && isValidAgentId(id));
475
+ if (agentIds.length === 0) die('error: no agents found under this API key');
476
+ info(`fleet: ${agentIds.length} agent(s)`);
477
+ } else {
478
+ agentIds = [agentId];
494
479
  }
480
+ const fleet = agentIds.length > 1;
495
481
 
496
- // Per-session DecisionLogger factory (each session gets its own to keep
497
- // sequence numbers monotonic per session).
498
- const loggers = new Map();
499
- const decisions = (sessionId) => {
500
- if (!loggers.has(sessionId)) {
501
- loggers.set(sessionId, new DecisionLogger({ logDir, agentId, sessionId }));
502
- }
503
- return loggers.get(sessionId);
482
+ // Shared infra: one shutdown signal, one fortress-source registry, one pusher.
483
+ const ac = new AbortController();
484
+ const fortressSources = [];
485
+ const shutdown = (sig) => {
486
+ info(`${sig} received, shutting down…`);
487
+ for (const fp of fortressSources) fp.stop();
488
+ ac.abort();
504
489
  };
490
+ process.on('SIGINT', () => shutdown('SIGINT'));
491
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
505
492
 
506
- // Optional Fortress decision pusher only active if we have a wma key + base.
507
- // In 'fortress' mode this is always available. In 'local' mode it's a fire-
508
- // and-forget extra channel if both are set.
493
+ // Optional Fortress decision pusher (each ctx carries its own agent id, so a
494
+ // single shared pusher tags decisions with the right agent).
509
495
  const canPushToFortress = !!(wmaApiKey && fortressBase);
510
496
  const pushDecisionToFortress = canPushToFortress
511
497
  ? async (decisionData) => {
512
- try {
513
- await postDecision({ apiKey: wmaApiKey, base: fortressBase, decision: decisionData });
514
- } catch (e) {
515
- warn(`Fortress decision push failed: ${e.message}`);
516
- }
498
+ try { await postDecision({ apiKey: wmaApiKey, base: fortressBase, decision: decisionData }); }
499
+ catch (e) { warn(`Fortress decision push failed: ${e.message}`); }
517
500
  }
518
501
  : null;
519
502
 
520
- const ac = new AbortController();
521
- process.on('SIGINT', () => {
522
- info('SIGINT received, shutting down…');
523
- if (fortressPolicies) fortressPolicies.stop();
524
- ac.abort();
525
- });
526
- process.on('SIGTERM', () => {
527
- info('SIGTERM received, shutting down…');
528
- if (fortressPolicies) fortressPolicies.stop();
529
- ac.abort();
530
- });
503
+ // Per-agent SETUP (separate from the long-running phase so we can COUNT how
504
+ // many actually armed). In fleet mode a per-agent startup failure is skipped
505
+ // (warn) instead of killing the fleet. Returns the agent's ctx, or null if skipped.
506
+ async function setupAgent(aid) {
507
+ const tag = fleet ? `[${aid.slice(0, 16)}…] ` : '';
508
+ let fortressPolicies = null;
509
+ let ruleset = sharedLocalRuleset;
510
+ if (policiesSource === 'fortress') {
511
+ fortressPolicies = new FortressPolicySource({
512
+ apiKey: wmaApiKey, base: fortressBase, anthropicAgentId: aid, refreshIntervalMs: 5 * 60_000,
513
+ onError: (e) => warn(`${tag}policy refresh failed (keeping cached): ${e.message}`),
514
+ onRefresh: ({ policies, fetched_at, initial }) => info(`${tag}policies ${initial ? 'loaded' : 'refreshed'} from Fortress — ${policies.length} active (fetched_at: ${fetched_at})`),
515
+ });
516
+ try { await fortressPolicies.start(); }
517
+ catch (e) {
518
+ if (fleet) { warn(`${tag}skipped — policy fetch failed: ${e.message}`); return null; }
519
+ die(`error fetching policies from Fortress: ${e.message}\n Check WMA_FORTRESS_BASE_URL and WMA_API_KEY.`);
520
+ }
521
+ fortressSources.push(fortressPolicies);
522
+ ruleset = fortressPolicies.current();
523
+ }
531
524
 
532
- // ctx exposes a getter for the live ruleset so workers see policy refreshes.
533
- const ctx = {
534
- apiKey,
535
- agentId,
536
- get ruleset() {
537
- return fortressPolicies ? fortressPolicies.current() : ruleset;
538
- },
539
- mode,
540
- decisions,
541
- pushDecisionToFortress,
542
- signalsSalt,
543
- signal: ac.signal,
544
- };
525
+ let mode = 'interrupt';
526
+ let agentMeta = null;
527
+ try { agentMeta = await getAgentConfig(apiKey, aid); if (detectAlwaysAsk(agentMeta)) mode = 'tool_confirmation'; }
528
+ catch (e) { warn(`${tag}could not fetch agent config (${e.message}). Defaulting to interrupt mode.`); }
545
529
 
546
- if (singleSessionId) {
547
- info(`single-session mode attached to ${singleSessionId}`);
548
- await runSessionWorker({ sessionId: singleSessionId, ctx });
549
- } else {
550
- await runAgentWide(ctx);
530
+ info(`${tag}armed — ${ruleset.policies.length} policies · default ${ruleset.default.action} · mode ${mode}${agentMeta?.name ? ` · "${agentMeta.name}"` : ''}`);
531
+ if (mode === 'interrupt' && !fleet) {
532
+ warn('DEGRADED mode Shield will interrupt AFTER a violating tool runs.');
533
+ warn(`For pre-execution blocking, run: wma-shield --setup-guide --agent-id ${aid}`);
534
+ }
535
+
536
+ const loggers = new Map();
537
+ const decisions = (sessionId) => {
538
+ if (!loggers.has(sessionId)) loggers.set(sessionId, new DecisionLogger({ logDir, agentId: aid, sessionId }));
539
+ return loggers.get(sessionId);
540
+ };
541
+ return {
542
+ apiKey, agentId: aid,
543
+ get ruleset() { return fortressPolicies ? fortressPolicies.current() : ruleset; },
544
+ mode, decisions, pushDecisionToFortress, signalsSalt, signal: ac.signal, discoveryWindowMs,
545
+ };
546
+ }
547
+
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;
554
+ }
555
+
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)));
551
583
  }
584
+ await Promise.all(running);
552
585
  }
553
586
 
554
587
  main().catch(e => {
@@ -77,6 +77,24 @@ export async function getAgent(apiKey, agentId) {
77
77
  return getWithRetry(apiKey, `/v1/agents/${agentId}`);
78
78
  }
79
79
 
80
+ // List every Managed Agent under the API key (paginated). Used for fleet mode
81
+ // (watch/shield/service --all-agents) and agent discovery.
82
+ export async function listAgents(apiKey, { limit = 100 } = {}) {
83
+ const agents = [];
84
+ let after = null;
85
+ while (true) {
86
+ const qs = new URLSearchParams({ limit: String(limit) });
87
+ if (after) qs.set('after_id', after);
88
+ const data = await getWithRetry(apiKey, `/v1/agents?${qs}`);
89
+ const page = data.data || [];
90
+ for (const a of page) agents.push(a);
91
+ if (!data.has_more || page.length === 0) break;
92
+ after = page[page.length - 1]?.id;
93
+ if (!after) break;
94
+ }
95
+ return agents;
96
+ }
97
+
80
98
  export async function listSessions(apiKey, { agentId, since, limit = 100 } = {}) {
81
99
  const sessions = [];
82
100
  let after = null;
@@ -0,0 +1,88 @@
1
+ {
2
+ "$comment": "WatchMyAgents — typology classifier weights + thresholds (Guardian Core, agent-typology-classification.spec.md §3/§4/§5). INVARIANT: weights and thresholds live HERE, never hardcoded in typology.js ('poids de signature en config, pas en dur'). Calibrate on labelled real traffic. Modèle C: all inputs are anonymized behavioural fractions/flags only.",
3
+ "version": "0.1.0",
4
+ "updated_at": "2026-05-29T00:00:00Z",
5
+
6
+ "thresholds": {
7
+ "$comment": "§4 'Seuils par défaut (à calibrer)' + §5 downgrade asymmetry.",
8
+ "n_events_min": 50,
9
+ "confidence_min": 0.70,
10
+ "margin_min": 0.15,
11
+ "stable_windows": 3,
12
+ "downgrade_confidence_min": 0.85,
13
+ "downgrade_windows": 5,
14
+ "untrusted_modifier_min": 0.1,
15
+ "sensitive_modifier_min": 0.0,
16
+ "payment_overlay_min": 0.0,
17
+ "autonomy_modifier_min": 0.5,
18
+ "$comment_tie": "§8 conservative tie-break: when |score(top1)-score(top2)| <= tie_epsilon (a near/exact tie between two REAL types with real signal), select the STRICTER of the two rather than falling to the more-permissive generic — 'dans le doute, on reste sur le plus protecteur'. Set to 0 for exact-tie only.",
19
+ "tie_epsilon": 0.0
20
+ },
21
+
22
+ "confidence_sigmoid": {
23
+ "$comment": "§4 confidence = sigmoid(a·top1.score + b·margin + c·log(n_events)). All three coefficients live in config; a naive impl that only used top1.score would be wrong.",
24
+ "a": 4.0,
25
+ "b": 6.0,
26
+ "c": 0.6,
27
+ "bias": -3.5
28
+ },
29
+
30
+ "strictness_rank": {
31
+ "$comment": "§5 restriction ranking — derived from each template's baseline_policies enforcement severity (isolate>block>require_approval>throttle>monitor>warn). Higher rank = STRICTER. Drives re-classification asymmetry: to a stricter rank = normal threshold; to a looser rank = downgrade gate (conf>=0.85 AND 5 windows). NOT alphabetical.",
32
+ "devops_infra": 10,
33
+ "transactional_financial": 9,
34
+ "workflow_backoffice": 8,
35
+ "coding": 7,
36
+ "orchestrator": 6,
37
+ "browser_web": 5,
38
+ "personal_assistant": 4,
39
+ "data_rag": 3,
40
+ "generic": 2,
41
+ "customer_facing": 1
42
+ },
43
+
44
+ "features": {
45
+ "$comment": "Canonical anonymized feature keys (Modèle C). Fractions f_* in [0,1]; flag_* in {0,1}; aux_* in [0,1]. Order is informational only — scoring is key-addressed.",
46
+ "fractions": ["f_code", "f_browser", "f_database", "f_http", "f_email", "f_payment", "f_secret", "f_search", "f_memory", "f_handoff", "f_user_msg", "f_file"],
47
+ "flags": ["flag_deploy", "flag_internal_sys", "flag_on_behalf"],
48
+ "aux": ["aux_autonomy", "aux_untrusted", "aux_sensitive"]
49
+ },
50
+
51
+ "weights": {
52
+ "$comment": "w[type][feature] — signature weights (§3). Positive = signal for the type; negative = signal against. flag_* are the REQUIRED discriminators for the 3 inseparable pairs (coding/devops, data_rag/workflow, personal_assistant/workflow). 'generic' has no positive weights (pure fallback).",
53
+
54
+ "coding": {
55
+ "f_code": 1.0, "f_file": 0.5, "f_search": 0.3, "f_secret": 0.1,
56
+ "flag_deploy": -0.9
57
+ },
58
+ "devops_infra": {
59
+ "f_code": 0.7, "f_secret": 0.6, "f_file": 0.2,
60
+ "flag_deploy": 1.2
61
+ },
62
+ "data_rag": {
63
+ "f_database": 0.8, "f_search": 0.35, "f_memory": 0.7, "aux_untrusted": 0.2,
64
+ "flag_internal_sys": -0.7
65
+ },
66
+ "customer_facing": {
67
+ "f_user_msg": 1.0, "f_handoff": 0.3, "f_email": 0.2
68
+ },
69
+ "browser_web": {
70
+ "f_browser": 1.0, "f_http": 0.6, "f_search": 0.7
71
+ },
72
+ "orchestrator": {
73
+ "f_handoff": 1.2, "f_code": -0.2, "f_browser": -0.2, "f_database": -0.2
74
+ },
75
+ "workflow_backoffice": {
76
+ "f_database": 0.6, "f_http": 0.5, "f_file": 0.2,
77
+ "flag_internal_sys": 0.9, "flag_on_behalf": -0.6
78
+ },
79
+ "personal_assistant": {
80
+ "f_email": 0.8, "f_file": 0.4, "f_user_msg": 0.3,
81
+ "flag_on_behalf": 1.0
82
+ },
83
+ "transactional_financial": {
84
+ "f_payment": 1.5
85
+ },
86
+ "generic": {}
87
+ }
88
+ }