watchmyagents 1.4.1 → 1.4.4

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
@@ -5,8 +5,12 @@
5
5
  Designed around four guarantees:
6
6
 
7
7
  1. **Local-first.** Raw payloads (prompts, outputs, tool arguments) stay 100% on your machine. Nothing leaves unless you explicitly opt in.
8
- 2. **Trace everything, not just what costs tokens.** A `web_fetch` to a suspicious URL carries zero tokens but is exactly what a security audit needs to see. Even tool calls that were blocked, denied, or interrupted before producing a result are logged with `status: error` so the audit trail is complete.
9
- 3. **Real-time enforcement, not post-hoc auditing.** A policy accepted in Fortress UI is active in Shield within ~1 second via SSE + Postgres realtime. A policy violation is blocked in ~3ms via Anthropic's `user.tool_confirmation` / `user.interrupt` events. Measured in production, not promised in roadmap.
8
+ 2. **Trace everything, not just what costs tokens.** A `web_fetch` to a suspicious URL carries zero tokens but is exactly what a security audit needs to see. Tool calls that Shield denied and sessions it interrupted are logged with their `status` so the audit trail is complete.
9
+ 3. **Real-time enforcement.** A policy accepted in Fortress UI is active in Shield within ~1 second via SSE + Postgres realtime. Shield renders an allow/deny **decision in ~3ms** (local evaluation, measured in production). How that decision is *applied* depends on the agent's configuration, and the distinction matters:
10
+ - **`tool_confirmation` mode** — when the agent has a tool with `permission_policy: always_ask`, Anthropic pauses the agent and Shield **blocks the tool *before* it executes** (`user.tool_confirmation`). True prevention.
11
+ - **`interrupt` mode** (zero-config default) — Shield observes the tool-use event *after* Anthropic's cloud has already run the tool, then **terminates the session** (`user.interrupt`). This is **detect-and-terminate, not prevention**: the offending call has already executed; the interrupt stops *subsequent* steps. A single-shot action (one exfil `web_fetch`, one `rm -rf`) is not prevented in this mode.
12
+
13
+ See [Shield — real-time policy enforcement](#shield--real-time-policy-enforcement) for how to configure pre-execution blocking.
10
14
  4. **Zero dependencies.** Only Node.js 18+ built-ins. No telemetry, no phone-home, no hidden network calls. Preserved through every release including the SSE realtime work (custom RFC-compliant SSE parser, no `@supabase/realtime-js` or `ws` dep).
11
15
 
12
16
  ### Measured end-to-end loop latency (v1.1.0+)
@@ -17,9 +21,11 @@ Watch capture ────────► Fortress signal upload : ≤
17
21
  Fortress signal ────────► Guardian analysis : ≤ 30s (event-triggered, debounced)
18
22
  Guardian proposal ────────► Operator accepts in UI : (human)
19
23
  Policy accepted ────────► Shield receives via SSE : ≤ 1s (sub-second push, validated)
20
- Shield evaluates ────────► Decision (allow/deny) : ≤ 3ms (measured on Anthropic Managed)
24
+ Shield evaluates ────────► Decision (allow/deny) : ≤ 3ms (decision latency, not time-to-block)
21
25
  ```
22
26
 
27
+ > The ≤ 3ms is Shield's local *decision* latency. Time-to-**block** depends on the enforcement mode: `tool_confirmation` blocks before execution; `interrupt` (default) terminates the session *after* the violating tool ran. See guarantee #3 above and the Shield section.
28
+
23
29
  Full audit-clean: 3 successful Codex audit passes (v1.0.1, v1.0.2, v1.0.3) closed 7 findings with zero regression. Containment invariant (raw payloads never leave the customer machine) is formalized in `docs/CONTAINMENT.md` and locked by 8 regression tests.
24
30
 
25
31
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "watchmyagents",
3
- "version": "1.4.1",
3
+ "version": "1.4.4",
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": [
@@ -38,6 +38,7 @@ import {
38
38
  AnthropicManagedSource, effectiveEnforcementMode,
39
39
  } from '../src/sources/anthropic-managed.js';
40
40
  import { maybePrintVersionAndExit } from '../src/version.js';
41
+ import { SeenTracker } from '../src/watch-state.js';
41
42
 
42
43
  function parseArgs(argv) {
43
44
  const out = {};
@@ -380,12 +381,27 @@ async function fetchOneShot({ apiKey, agentId, model, logDir, since, sessionId,
380
381
  // Fortress display_name (opt-in); default sends the agent id only (Containment).
381
382
  async function runWatch({ apiKey, resolveAgents, fleet, logDir, intervalMs, windowMs, uploadCtx, sendNames }) {
382
383
  let agents = await resolveAgents();
383
- const seenIds = new Set(); // stable Anthropic event ids already captured
384
- for (const ag of agents) {
385
- for (const id of await preloadSeenIds(logDir, ag.agentId)) seenIds.add(id);
384
+ // v1.4.3 F-51: bounded dedup. Preloaded on-disk ids are static; runtime ids
385
+ // are tracked per-session and dropped on terminate (see src/watch-state.js).
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);
386
396
  }
397
+ for (const ag of agents) await ensurePreloaded(ag.agentId);
387
398
  const loggers = new Map(); // sessionId → Logger (session ids are globally unique)
388
- const ended = new Set(); // terminated sessions (skip)
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();
389
405
  const sessionAgent = new Map();// sessionId → { agentId, model, displayName }
390
406
  const priors = new Map(); // agentId → previous classification (threads the
391
407
  // typology state machine across upload cycles)
@@ -399,15 +415,26 @@ async function runWatch({ apiKey, resolveAgents, fleet, logDir, intervalMs, wind
399
415
  process.on('SIGINT', shutdown);
400
416
  process.on('SIGTERM', shutdown);
401
417
 
402
- 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`);
418
+ 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'}, ${seen.size} known events preloaded`);
403
419
 
404
420
  while (!ac.signal.aborted) {
405
421
  if (fleet) { const next = await resolveAgents(); if (next.length) agents = next; }
406
422
  const since = new Date(Date.now() - windowMs);
407
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
+
408
432
  // (1) Discover sessions in the window; register the owning agent for each.
409
433
  for (const ag of agents) {
410
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);
411
438
  const tag = fleet ? `[${ag.displayName}] ` : '';
412
439
  let sessions = [];
413
440
  try { sessions = await listSessions(apiKey, { agentId: ag.agentId, since }); }
@@ -427,6 +454,14 @@ async function runWatch({ apiKey, resolveAgents, fleet, logDir, intervalMs, wind
427
454
  if (ac.signal.aborted) break;
428
455
  if (ended.has(sid)) continue;
429
456
  const tag = fleet ? `[${ag.displayName}] ` : '';
457
+ // v1.4.3 F-51: top-level guard around the WHOLE per-session body. Pre-fix
458
+ // only the fetch + upload had try/catch; an unguarded throw elsewhere
459
+ // (e.g. ENOSPC/EACCES on the fail-loud session_end logger.write, or a
460
+ // malformed entry in TokenTracker) propagated out of the loop and
461
+ // KILLED the daemon — silent total collection-stop on a backgrounded
462
+ // process. Now any single session's failure drops that session for the
463
+ // cycle and the daemon survives.
464
+ try {
430
465
  let logger = loggers.get(sid);
431
466
  if (!logger) { logger = new Logger({ logDir, agentId: ag.agentId, sessionId: sid, silent: true }); loggers.set(sid, logger); }
432
467
 
@@ -434,8 +469,8 @@ async function runWatch({ apiKey, resolveAgents, fleet, logDir, intervalMs, wind
434
469
  let sawTerminated = false;
435
470
  try {
436
471
  for await (const entry of fetchSessionEntries({ apiKey, agentId: ag.agentId, sessionId: sid, model: ag.model })) {
437
- if (entry.id && seenIds.has(entry.id)) continue;
438
- if (entry.id) seenIds.add(entry.id);
472
+ if (entry.id && seen.has(sid, entry.id)) continue;
473
+ if (entry.id) seen.add(sid, entry.id);
439
474
  const written = await logger.write(entry);
440
475
  fresh.push(written);
441
476
  if (entry.action_type === 'state_transition'
@@ -491,10 +526,20 @@ async function runWatch({ apiKey, resolveAgents, fleet, logDir, intervalMs, wind
491
526
  session_tokens: { input: stats.input, output: stats.output, cache_read: stats.cache_read, cache_creation: stats.cache_creation, total: stats.sum },
492
527
  session_cost_usd: stats.cost_usd || null,
493
528
  });
494
- ended.add(sid);
495
- sessionAgent.delete(sid); // bound memory: terminated sessions aren't re-fetched
529
+ ended.set(sid, Date.now());
530
+ // v1.4.3 F-51: bound memory terminated sessions aren't re-fetched,
531
+ // so drop their per-session dedup set AND their Logger object (the
532
+ // latter was never deleted pre-fix → unbounded growth of Logger
533
+ // instances over a long daemon run).
534
+ sessionAgent.delete(sid);
535
+ seen.forgetSession(sid);
536
+ loggers.delete(sid);
496
537
  info(`${tag}session ${sid.slice(0, 16)}… terminated — closed`);
497
538
  }
539
+ } catch (e) {
540
+ // F-51 top-level guard: never let one session's failure kill the loop.
541
+ warn(`${tag}session ${sid.slice(0, 16)}…: cycle error (skipped): ${e.message}`);
542
+ }
498
543
  }
499
544
 
500
545
  if (cycleNew === 0) info('cycle: no new events');
@@ -567,7 +612,25 @@ async function main() {
567
612
  const intervalMs = parseDurationMs(args.interval, 60_000);
568
613
  // Discovery window for NEW sessions (default 7d, configurable). Sessions we
569
614
  // already track are re-fetched regardless of age, so long-lived ones don't drop.
570
- const windowMs = parseDurationMs(args['discovery-since'], 7 * 24 * 3600_000);
615
+ let windowMs = parseDurationMs(args['discovery-since'], 7 * 24 * 3600_000);
616
+ // v1.4.2 F-46 (P1 audit): the discovery window must comfortably exceed the
617
+ // poll interval. Discovery runs once per cycle; a session that is created
618
+ // AND ends/ages-out between two sweeps is never added to the tracked set,
619
+ // so its events are never fetched (untraced activity). Requiring
620
+ // windowMs >= 2*intervalMs guarantees every session appears in at least
621
+ // two consecutive discovery sweeps before it can leave the window. The 7d
622
+ // default is far above this; the guard only bites when an operator sets
623
+ // --discovery-since too tight relative to --interval. Clamp + warn rather
624
+ // than silently honoring a value that opens a per-cycle blind window.
625
+ const minWindowMs = intervalMs * 2;
626
+ if (windowMs < minWindowMs) {
627
+ process.stderr.write(
628
+ `[wma] --discovery-since (${Math.round(windowMs / 1000)}s) is below 2x --interval ` +
629
+ `(${Math.round(intervalMs / 1000)}s) — a short-lived session could be missed between ` +
630
+ `sweeps. Clamping discovery window up to ${Math.round(minWindowMs / 1000)}s.\n`,
631
+ );
632
+ windowMs = minWindowMs;
633
+ }
571
634
  // display_name on the Fortress payload: defaults to the human agent name
572
635
  // (UX-friendly — operators identify agents by name in the dashboard). The
573
636
  // name is sanitized via cleanLabel() so log/payload injection is impossible.
package/scripts/shield.js CHANGED
@@ -146,16 +146,42 @@ async function runSessionWorker({ sessionId, ctx }) {
146
146
  // isolation. The helper drops any field it cannot safely normalize
147
147
  // (custom tool without salt, missing salt for hashes) rather than
148
148
  // passing the raw value through.
149
- const fireToFortress = (rawEvent, normalized, result, decidedInMs) => {
149
+ const fireToFortress = (rawEvent, normalized, result, decidedInMs, enforcementDelivered) => {
150
150
  if (!pushDecisionToFortress) return;
151
151
  const payload = buildFortressDecisionPayload({
152
152
  agentId, sessionId, rawEvent, normalized, result, decidedInMs,
153
- signalsSalt,
153
+ signalsSalt, enforcementDelivered,
154
154
  });
155
155
  pushDecisionToFortress(payload).catch(() => undefined);
156
156
  };
157
157
 
158
- let processed = 0, enforced = 0, sessionInterrupted = false;
158
+ // v1.4.2 F-44 (P1 audit): enforcement API calls (confirmDeny / interrupt)
159
+ // could fail (15s timeout, 5xx, network drop) and the old code just logged
160
+ // and continued — the violating session kept running with no retry, while
161
+ // the decision row still claimed "enforced". Retry the call (these are
162
+ // idempotent: a duplicate deny/interrupt on the same tool_use/session is
163
+ // safe), and return whether it was ultimately delivered so the caller can
164
+ // record the REAL outcome and surface a loud failure. Backoff is short and
165
+ // bounded — the tool already ran in interrupt mode, so landing the session
166
+ // termination quickly matters more than long waits.
167
+ const enforceWithRetry = async (thunk, { label, attempts = 3 } = {}) => {
168
+ let lastErr;
169
+ for (let i = 0; i < attempts; i++) {
170
+ try {
171
+ await thunk();
172
+ return true;
173
+ } catch (e) {
174
+ lastErr = e;
175
+ if (i < attempts - 1) await sleep(250 * (i + 1));
176
+ }
177
+ }
178
+ process.stderr.write(
179
+ `[shield/${sessionId.slice(0, 12)}] ${label} FAILED after ${attempts} attempts: ${lastErr?.message}\n`,
180
+ );
181
+ return false;
182
+ };
183
+
184
+ let processed = 0, enforced = 0, enforcementFailed = 0, sessionInterrupted = false;
159
185
 
160
186
  // v1.2.0 — per-session context tracker. Feeds runtime attributes
161
187
  // (hour_of_day_utc, agent_age_minutes, recent_error_rate, …) into the
@@ -237,36 +263,43 @@ async function runSessionWorker({ sessionId, ctx }) {
237
263
  const modeTag = result.mode === 'shadow' ? ' [SHADOW]' : '';
238
264
  sinfo(sessionId, `${rawEvent.type} tool=${normalized.tool_name} → ${result.decision}${modeTag}${result.rule_id ? ` (${result.rule_id})` : ''}`);
239
265
 
240
- await decisions(sessionId).record({
241
- sourceEvent: rawEvent, decision: result.decision,
242
- ruleId: result.rule_id, ruleName: result.rule_name,
243
- message: result.message, decidedInMs,
244
- mode: result.mode,
245
- });
246
- fireToFortress(rawEvent, normalized, result, decidedInMs);
247
-
248
- // v1.1.3 Phase 1.D in shadow mode, the decision is COMPUTED + LOGGED
249
- // but NOT enforced. The rule's "would_deny" / "would_interrupt"
250
- // outcome flows to Fortress for Platt-scaling calibration + diff-in-diff
251
- // efficacy measurement (Guardian Core hardening axes 1 + 4), but the
252
- // agent's session continues uninterrupted. Promote to enforce only
253
- // after calibration confidence + lifecycle gates (Guardian Core spec
254
- // observe → shadow → enforce → retired).
255
- if (result.mode === 'shadow') continue;
256
-
257
- if ((result.decision === 'deny' || result.decision === 'interrupt') && !sessionInterrupted) {
258
- try {
259
- await interruptSession({
266
+ // v1.4.2 F-44 (P1 audit): ENFORCE FIRST, then record the REAL outcome.
267
+ // Pre-F-44 the decision was recorded as "enforced" before (and
268
+ // regardless of) the interrupt call, and a failed interrupt was
269
+ // swallowed with the session left running. Now: shadow + non-blocking
270
+ // decisions don't enforce (enforcementDelivered stays undefined);
271
+ // deny/interrupt attempts the interrupt with bounded retry and the
272
+ // recorded row + Fortress payload carry whether it actually landed.
273
+ const willEnforce = result.mode !== 'shadow'
274
+ && (result.decision === 'deny' || result.decision === 'interrupt')
275
+ && !sessionInterrupted;
276
+
277
+ let enforcementDelivered; // undefined unless we attempt enforcement
278
+ if (willEnforce) {
279
+ enforcementDelivered = await enforceWithRetry(
280
+ () => interruptSession({
260
281
  apiKey, sessionId,
261
282
  followUpMessage: `Shield interrupted: ${result.message || result.rule_name || 'policy violation'}`,
262
- });
283
+ }),
284
+ { label: 'interrupt' },
285
+ );
286
+ if (enforcementDelivered) {
263
287
  sessionInterrupted = true;
264
288
  enforced++;
265
289
  swarn(sessionId, 'session interrupted — agent loop stopped');
266
- } catch (e) {
267
- process.stderr.write(`[shield/${sessionId.slice(0, 12)}] interrupt error: ${e.message}\n`);
290
+ } else {
291
+ enforcementFailed++;
292
+ swarn(sessionId, `ENFORCEMENT FAILED — session NOT interrupted; violating action was NOT blocked (${result.rule_id || 'policy'})`);
268
293
  }
269
294
  }
295
+
296
+ await decisions(sessionId).record({
297
+ sourceEvent: rawEvent, decision: result.decision,
298
+ ruleId: result.rule_id, ruleName: result.rule_name,
299
+ message: result.message, decidedInMs,
300
+ mode: result.mode, enforcementDelivered,
301
+ });
302
+ fireToFortress(rawEvent, normalized, result, decidedInMs, enforcementDelivered);
270
303
  continue;
271
304
  }
272
305
 
@@ -286,13 +319,18 @@ async function runSessionWorker({ sessionId, ctx }) {
286
319
  const sourceEvent = cached?.event;
287
320
  if (!sourceEvent) {
288
321
  swarn(sessionId, `requires_action for unknown event_id ${eventId} — denying defensively`);
289
- try {
290
- await confirmDeny({
322
+ // F-44: retry + loud. If the defensive deny doesn't land, the
323
+ // agent may proceed or hang — either way the operator must know.
324
+ const delivered = await enforceWithRetry(
325
+ () => confirmDeny({
291
326
  apiKey, sessionId, toolUseId: eventId,
292
327
  denyMessage: 'Shield never saw the original tool_use. Denying defensively.',
293
- });
294
- } catch (e) {
295
- process.stderr.write(`[shield/${sessionId.slice(0, 12)}] enforcement error: ${e.message}\n`);
328
+ }),
329
+ { label: 'confirmDeny(unknown)' },
330
+ );
331
+ if (!delivered) {
332
+ enforcementFailed++;
333
+ swarn(sessionId, `ENFORCEMENT FAILED — defensive deny NOT delivered for event ${eventId}`);
296
334
  }
297
335
  continue;
298
336
  }
@@ -312,54 +350,75 @@ async function runSessionWorker({ sessionId, ctx }) {
312
350
  const modeTag = result.mode === 'shadow' ? ' [SHADOW]' : '';
313
351
  sinfo(sessionId, `requires_action ${sourceEvent.type} tool=${normalized.tool_name} → ${result.decision}${modeTag}${result.rule_id ? ` (${result.rule_id})` : ''}`);
314
352
 
315
- await decisions(sessionId).record({
316
- sourceEvent, decision: result.decision,
317
- ruleId: result.rule_id, ruleName: result.rule_name,
318
- message: result.message, decidedInMs,
319
- mode: result.mode,
320
- });
321
- fireToFortress(sourceEvent, normalized, result, decidedInMs);
322
-
323
353
  // v1.1.3 Phase 1.D — shadow mode in tool_confirmation: we MUST
324
354
  // still send confirmAllow even when the rule said deny/interrupt,
325
355
  // otherwise the agent hangs waiting for our response. The
326
356
  // decision is logged with mode=shadow so calibration can compare
327
357
  // what the rule said vs what was enforced (which is "nothing"
328
- // here). For mode=enforce, the original branching below stands.
358
+ // here). For mode=enforce, the branching below stands.
359
+ //
360
+ // v1.4.2 F-44 — ENFORCE FIRST, then record the REAL outcome (see
361
+ // the interrupt-mode block above for the rationale). Retry + loud
362
+ // on failure so a swallowed confirm/deny/interrupt can no longer
363
+ // leave the audit row claiming a block that never happened.
329
364
  if (result.mode === 'shadow') {
330
- try {
331
- await confirmAllow({ apiKey, sessionId, toolUseId: eventId });
332
- // No enforced++ — shadow doesn't enforce by definition.
333
- } catch (e) {
334
- process.stderr.write(`[shield/${sessionId.slice(0, 12)}] shadow confirmAllow error: ${e.message}\n`);
335
- }
365
+ await enforceWithRetry(
366
+ () => confirmAllow({ apiKey, sessionId, toolUseId: eventId }),
367
+ { label: 'shadow confirmAllow' },
368
+ );
369
+ // No enforced++ — shadow doesn't enforce by definition.
370
+ await decisions(sessionId).record({
371
+ sourceEvent, decision: result.decision,
372
+ ruleId: result.rule_id, ruleName: result.rule_name,
373
+ message: result.message, decidedInMs, mode: result.mode,
374
+ });
375
+ fireToFortress(sourceEvent, normalized, result, decidedInMs);
336
376
  continue;
337
377
  }
338
378
 
339
- try {
340
- if (result.decision === 'allow') {
341
- await confirmAllow({ apiKey, sessionId, toolUseId: eventId });
342
- enforced++;
343
- } else if (result.decision === 'deny') {
344
- await confirmDeny({
379
+ let enforcementDelivered; // undefined for allow (no block attempted)
380
+ let shouldBreak = false;
381
+ if (result.decision === 'allow') {
382
+ // An allow is not a block; confirmAllow just lets the tool
383
+ // proceed. Retry to avoid hangs, but this is not fail-open.
384
+ const ok = await enforceWithRetry(
385
+ () => confirmAllow({ apiKey, sessionId, toolUseId: eventId }),
386
+ { label: 'confirmAllow' },
387
+ );
388
+ if (ok) enforced++;
389
+ else { enforcementFailed++; swarn(sessionId, `confirmAllow NOT delivered for event ${eventId} — agent may hang`); }
390
+ } else if (result.decision === 'deny') {
391
+ enforcementDelivered = await enforceWithRetry(
392
+ () => confirmDeny({
345
393
  apiKey, sessionId, toolUseId: eventId,
346
394
  denyMessage: result.message || `Blocked by ${result.rule_name}`,
347
- });
348
- enforced++;
349
- } else if (result.decision === 'interrupt') {
350
- await interruptSession({
395
+ }),
396
+ { label: 'confirmDeny' },
397
+ );
398
+ if (enforcementDelivered) enforced++;
399
+ else { enforcementFailed++; swarn(sessionId, `ENFORCEMENT FAILED — deny NOT delivered; tool NOT blocked (${result.rule_id || 'policy'})`); }
400
+ } else if (result.decision === 'interrupt') {
401
+ enforcementDelivered = await enforceWithRetry(
402
+ () => interruptSession({
351
403
  apiKey, sessionId,
352
404
  followUpMessage: `Shield interrupted: ${result.message || result.rule_name}`,
353
- });
354
- sessionInterrupted = true;
355
- enforced++;
356
- break;
357
- }
358
- } catch (e) {
359
- process.stderr.write(`[shield/${sessionId.slice(0, 12)}] enforcement error on event ${eventId}: ${e.message}\n`);
405
+ }),
406
+ { label: 'interrupt' },
407
+ );
408
+ if (enforcementDelivered) { sessionInterrupted = true; enforced++; shouldBreak = true; }
409
+ else { enforcementFailed++; swarn(sessionId, `ENFORCEMENT FAILED — interrupt NOT delivered; session NOT stopped (${result.rule_id || 'policy'})`); }
360
410
  }
361
411
 
412
+ await decisions(sessionId).record({
413
+ sourceEvent, decision: result.decision,
414
+ ruleId: result.rule_id, ruleName: result.rule_name,
415
+ message: result.message, decidedInMs,
416
+ mode: result.mode, enforcementDelivered,
417
+ });
418
+ fireToFortress(sourceEvent, normalized, result, decidedInMs, enforcementDelivered);
419
+
362
420
  toolUseCache.delete(eventId);
421
+ if (shouldBreak) break;
363
422
  }
364
423
  continue;
365
424
  }
@@ -631,9 +690,21 @@ async function main() {
631
690
  catch (e) { warn(`${tag}could not fetch agent config (${e.message}). Defaulting to interrupt mode.`); }
632
691
 
633
692
  info(`${tag}armed — ${ruleset.policies.length} policies · default ${ruleset.default.action} · mode ${mode}${agentMeta?.name ? ` · "${agentMeta.name}"` : ''}`);
634
- if (mode === 'interrupt' && !fleet) {
635
- warn('DEGRADED mode Shield will interrupt AFTER a violating tool runs.');
636
- warn(`For pre-execution blocking, run: wma-shield --setup-guide --agent-id ${aid}`);
693
+ // v1.4.2 F-43 (P0 audit): surface the DEGRADED (interrupt) mode in BOTH
694
+ // single and fleet runs. Pre-fix this warning was gated on `!fleet`, so a
695
+ // fleet operator the LEAST likely to inspect each agent's mode and the
696
+ // MOST likely to assume uniform pre-execution blocking — got no signal
697
+ // that some/all of the fleet only terminates the session AFTER a
698
+ // violating tool runs. In fleet mode we warn per newly-armed degraded
699
+ // agent (once per agent lifetime, not per event) AND the caller emits a
700
+ // fleet-level summary count after the initial arm.
701
+ if (mode === 'interrupt') {
702
+ if (fleet) {
703
+ warn(`${tag}DEGRADED mode (interrupt) — no pre-execution blocking; session is terminated AFTER a violating tool runs.`);
704
+ } else {
705
+ warn('DEGRADED mode — Shield will interrupt AFTER a violating tool runs.');
706
+ warn(`For pre-execution blocking, run: wma-shield --setup-guide --agent-id ${aid}`);
707
+ }
637
708
  }
638
709
 
639
710
  const loggers = new Map();
@@ -660,6 +731,7 @@ async function main() {
660
731
  // created after startup gets armed + protected without a restart. A per-agent
661
732
  // arm failure is skipped and retried on the next reconcile.
662
733
  const armed = new Set();
734
+ const degraded = new Set(); // F-43: agents armed in interrupt (no pre-block) mode
663
735
  const running = [];
664
736
  const armNew = async (ids) => {
665
737
  for (const aid of ids) {
@@ -667,14 +739,20 @@ async function main() {
667
739
  const ctx = await setupAgent(aid);
668
740
  if (!ctx) continue; // skipped (policy fetch failed) → retry next reconcile
669
741
  armed.add(aid);
742
+ if (ctx.mode === 'interrupt') degraded.add(aid);
670
743
  running.push(runAgentWide(ctx)); // fire; blocks on the shared signal until shutdown
671
- info(`fleet: armed ${aid.slice(0, 16)}…`);
744
+ info(`fleet: armed ${aid.slice(0, 16)}… (mode ${ctx.mode})`);
672
745
  }
673
746
  };
674
747
  await armNew(agentIds);
675
748
  if (armed.size === 0) {
676
749
  die(`error: no agents could be armed (${agentIds.length} discovered; all policy fetches failed). Check WMA_API_KEY / WMA_FORTRESS_BASE_URL.`);
677
750
  }
751
+ // F-43: fleet-level honesty summary — how much of the fleet has only
752
+ // post-hoc termination vs true pre-execution blocking.
753
+ if (degraded.size > 0) {
754
+ warn(`fleet: ${degraded.size}/${armed.size} agent(s) in DEGRADED interrupt mode — those have NO pre-execution blocking (the tool runs, then the session is terminated). Configure permission_policy: always_ask on those agents for true pre-block.`);
755
+ }
678
756
  // v1.1.0 Phase 1 L3: supervisor reconcile every 30s (was 60s) so a
679
757
  // freshly-created Anthropic agent gets armed sub-30s instead of sub-minute.
680
758
  info(`fleet: ${armed.size}/${agentIds.length} agent(s) armed; reconciling every 30s for new agents.`);
package/src/anonymizer.js CHANGED
@@ -36,6 +36,7 @@
36
36
  import { createHash, randomBytes } from 'node:crypto';
37
37
  import { createReadStream } from 'node:fs';
38
38
  import { createInterface } from 'node:readline';
39
+ import { safeNonNegInt } from './tokens.js';
39
40
 
40
41
  // ── Configuration ────────────────────────────────────────────────────────
41
42
 
@@ -297,8 +298,10 @@ export class SignalsAggregator {
297
298
  for (const h of extractIocs(entry, this.salt)) this.iocHashes.add(h);
298
299
  }
299
300
 
300
- // Tokens
301
- if (typeof entry.tokens_used === 'number') this.tokensTotal += entry.tokens_used;
301
+ // Tokens — v1.4.2 F-49: sanitize so a NaN/Infinity/negative tokens_used
302
+ // can't poison the uploaded tokens_total (a single absurd value would
303
+ // otherwise distort the whole window's cost-anomaly signal).
304
+ this.tokensTotal += safeNonNegInt(entry.tokens_used);
302
305
 
303
306
  // Stop reasons (state_transition entries carry these)
304
307
  const stopType = entry.output?.stop_reason?.type;
@@ -21,29 +21,103 @@
21
21
  */
22
22
  export function resolveFortressBase({ explicitUrl, explicitBase, env = process.env } = {}) {
23
23
  // 1. Explicit base URL from CLI
24
- if (explicitBase) return stripTrailingSlash(explicitBase);
24
+ if (explicitBase) return assertSafeFortressBase(stripTrailingSlash(explicitBase));
25
25
 
26
26
  // 2. Env: WMA_FORTRESS_BASE_URL (preferred)
27
- if (env.WMA_FORTRESS_BASE_URL) return stripTrailingSlash(env.WMA_FORTRESS_BASE_URL);
27
+ if (env.WMA_FORTRESS_BASE_URL) return assertSafeFortressBase(stripTrailingSlash(env.WMA_FORTRESS_BASE_URL));
28
28
 
29
29
  // 3. Legacy: WMA_FORTRESS_URL (full path to ingest-signals)
30
30
  const legacy = explicitUrl || env.WMA_FORTRESS_URL;
31
31
  if (legacy) {
32
32
  // Strip last path segment to get the base
33
+ let derived;
33
34
  try {
34
35
  const u = new URL(legacy);
35
36
  const parts = u.pathname.split('/').filter(Boolean);
36
37
  if (parts.length > 0) parts.pop();
37
38
  u.pathname = '/' + parts.join('/');
38
- return stripTrailingSlash(u.toString());
39
+ derived = stripTrailingSlash(u.toString());
39
40
  } catch {
40
41
  return null;
41
42
  }
43
+ return assertSafeFortressBase(derived);
42
44
  }
43
45
 
44
46
  return null;
45
47
  }
46
48
 
49
+ // v1.4.2 F-47 (P1->P2 audit) — SSRF guard on the operator-supplied Fortress
50
+ // base URL. WMA POSTs the customer's Bearer API key + signals/decisions to
51
+ // this URL and PULLS live-enforcement policies from it, so an attacker who can
52
+ // influence the SDK's environment (templated deploy, shared .env, compromised
53
+ // orchestration layer) could previously point it at http://, an internal
54
+ // host, or a cloud metadata endpoint (169.254.169.254) and exfiltrate the key
55
+ // — the only prior check was `protocol === 'https:'` at the request layer.
56
+ //
57
+ // We require https, reject embedded credentials, and reject IP-LITERAL hosts
58
+ // in private / loopback / link-local / ULA ranges (the direct metadata-
59
+ // endpoint vector). We deliberately do NOT pin to *.supabase.co because
60
+ // operators self-host Fortress; any PUBLIC host is allowed. Residual:
61
+ // DNS-rebinding (a public hostname resolving to a private IP) is not caught
62
+ // here — that needs per-request resolved-IP pinning, out of scope for a sync
63
+ // URL validator. Throws (fail-loud) on a bad URL rather than returning null,
64
+ // so a misconfigured/hostile endpoint stops the run instead of silently
65
+ // disabling upload/enforcement.
66
+ export function assertSafeFortressBase(base) {
67
+ let u;
68
+ try { u = new URL(base); }
69
+ catch { throw new Error(`Fortress base URL is not a valid URL: ${base}`); }
70
+
71
+ if (u.protocol !== 'https:') {
72
+ throw new Error(`Fortress base URL must use https:// (got ${u.protocol}//). Refusing to send credentials over ${u.protocol}//.`);
73
+ }
74
+ if (u.username || u.password) {
75
+ throw new Error('Fortress base URL must not embed credentials (user:pass@host).');
76
+ }
77
+ if (isBlockedHost(u.hostname)) {
78
+ throw new Error(
79
+ `Fortress base URL host "${u.hostname}" is a private/loopback/link-local address — refusing (SSRF guard). ` +
80
+ 'Point WMA_FORTRESS_BASE_URL at your public Fortress endpoint.',
81
+ );
82
+ }
83
+ return base;
84
+ }
85
+
86
+ // True if the host is an IP literal in a private/loopback/link-local/ULA range,
87
+ // or localhost. Public hostnames and public IPs pass.
88
+ function isBlockedHost(hostname) {
89
+ // Node keeps surrounding brackets on IPv6 hostnames (e.g. "[::1]"); strip them.
90
+ const h = hostname.toLowerCase().replace(/^\[/, '').replace(/\]$/, '');
91
+ if (h === 'localhost' || h.endsWith('.localhost')) return true;
92
+
93
+ // IPv6 literal. Normalize IPv4-mapped form.
94
+ if (h.includes(':')) {
95
+ if (h === '::1' || h === '::') return true; // loopback / unspecified
96
+ if (h.startsWith('fe8') || h.startsWith('fe9') || h.startsWith('fea') || h.startsWith('feb')) return true; // fe80::/10 link-local
97
+ if (h.startsWith('fc') || h.startsWith('fd')) return true; // fc00::/7 unique-local
98
+ const mapped = h.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/); // IPv4-mapped IPv6
99
+ if (mapped) return isBlockedIpv4(mapped[1]);
100
+ return false;
101
+ }
102
+
103
+ // IPv4 literal?
104
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(h)) return isBlockedIpv4(h);
105
+ return false;
106
+ }
107
+
108
+ function isBlockedIpv4(ip) {
109
+ const o = ip.split('.').map(Number);
110
+ if (o.length !== 4 || o.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true; // malformed → block
111
+ const [a, b] = o;
112
+ if (a === 127) return true; // 127.0.0.0/8 loopback
113
+ if (a === 10) return true; // 10.0.0.0/8 private
114
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private
115
+ if (a === 192 && b === 168) return true; // 192.168.0.0/16 private
116
+ if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local (cloud metadata)
117
+ if (a === 0) return true; // 0.0.0.0/8
118
+ return false;
119
+ }
120
+
47
121
  function stripTrailingSlash(s) {
48
122
  return s.endsWith('/') ? s.slice(0, -1) : s;
49
123
  }