watchmyagents 1.4.1 → 1.4.3

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.3",
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,19 @@ 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
+ // 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 preloaded = [];
384
387
  for (const ag of agents) {
385
- for (const id of await preloadSeenIds(logDir, ag.agentId)) seenIds.add(id);
388
+ for (const id of await preloadSeenIds(logDir, ag.agentId)) preloaded.push(id);
386
389
  }
390
+ const seen = new SeenTracker(preloaded);
387
391
  const loggers = new Map(); // sessionId → Logger (session ids are globally unique)
388
- const ended = new Set(); // terminated sessions (skip)
392
+ const ended = new Set(); // terminated sessions (skip). Grows one id per
393
+ // terminated session over the daemon's life —
394
+ // bounded by session COUNT (ids only, the
395
+ // smallest term); the per-EVENT growth that
396
+ // actually drove OOM is fixed via SeenTracker.
389
397
  const sessionAgent = new Map();// sessionId → { agentId, model, displayName }
390
398
  const priors = new Map(); // agentId → previous classification (threads the
391
399
  // typology state machine across upload cycles)
@@ -399,7 +407,7 @@ async function runWatch({ apiKey, resolveAgents, fleet, logDir, intervalMs, wind
399
407
  process.on('SIGINT', shutdown);
400
408
  process.on('SIGTERM', shutdown);
401
409
 
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`);
410
+ 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
411
 
404
412
  while (!ac.signal.aborted) {
405
413
  if (fleet) { const next = await resolveAgents(); if (next.length) agents = next; }
@@ -427,6 +435,14 @@ async function runWatch({ apiKey, resolveAgents, fleet, logDir, intervalMs, wind
427
435
  if (ac.signal.aborted) break;
428
436
  if (ended.has(sid)) continue;
429
437
  const tag = fleet ? `[${ag.displayName}] ` : '';
438
+ // v1.4.3 F-51: top-level guard around the WHOLE per-session body. Pre-fix
439
+ // only the fetch + upload had try/catch; an unguarded throw elsewhere
440
+ // (e.g. ENOSPC/EACCES on the fail-loud session_end logger.write, or a
441
+ // malformed entry in TokenTracker) propagated out of the loop and
442
+ // KILLED the daemon — silent total collection-stop on a backgrounded
443
+ // process. Now any single session's failure drops that session for the
444
+ // cycle and the daemon survives.
445
+ try {
430
446
  let logger = loggers.get(sid);
431
447
  if (!logger) { logger = new Logger({ logDir, agentId: ag.agentId, sessionId: sid, silent: true }); loggers.set(sid, logger); }
432
448
 
@@ -434,8 +450,8 @@ async function runWatch({ apiKey, resolveAgents, fleet, logDir, intervalMs, wind
434
450
  let sawTerminated = false;
435
451
  try {
436
452
  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);
453
+ if (entry.id && seen.has(sid, entry.id)) continue;
454
+ if (entry.id) seen.add(sid, entry.id);
439
455
  const written = await logger.write(entry);
440
456
  fresh.push(written);
441
457
  if (entry.action_type === 'state_transition'
@@ -492,9 +508,19 @@ async function runWatch({ apiKey, resolveAgents, fleet, logDir, intervalMs, wind
492
508
  session_cost_usd: stats.cost_usd || null,
493
509
  });
494
510
  ended.add(sid);
495
- sessionAgent.delete(sid); // bound memory: terminated sessions aren't re-fetched
511
+ // v1.4.3 F-51: bound memory terminated sessions aren't re-fetched,
512
+ // so drop their per-session dedup set AND their Logger object (the
513
+ // latter was never deleted pre-fix → unbounded growth of Logger
514
+ // instances over a long daemon run).
515
+ sessionAgent.delete(sid);
516
+ seen.forgetSession(sid);
517
+ loggers.delete(sid);
496
518
  info(`${tag}session ${sid.slice(0, 16)}… terminated — closed`);
497
519
  }
520
+ } catch (e) {
521
+ // F-51 top-level guard: never let one session's failure kill the loop.
522
+ warn(`${tag}session ${sid.slice(0, 16)}…: cycle error (skipped): ${e.message}`);
523
+ }
498
524
  }
499
525
 
500
526
  if (cycleNew === 0) info('cycle: no new events');
@@ -567,7 +593,25 @@ async function main() {
567
593
  const intervalMs = parseDurationMs(args.interval, 60_000);
568
594
  // Discovery window for NEW sessions (default 7d, configurable). Sessions we
569
595
  // 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);
596
+ let windowMs = parseDurationMs(args['discovery-since'], 7 * 24 * 3600_000);
597
+ // v1.4.2 F-46 (P1 audit): the discovery window must comfortably exceed the
598
+ // poll interval. Discovery runs once per cycle; a session that is created
599
+ // AND ends/ages-out between two sweeps is never added to the tracked set,
600
+ // so its events are never fetched (untraced activity). Requiring
601
+ // windowMs >= 2*intervalMs guarantees every session appears in at least
602
+ // two consecutive discovery sweeps before it can leave the window. The 7d
603
+ // default is far above this; the guard only bites when an operator sets
604
+ // --discovery-since too tight relative to --interval. Clamp + warn rather
605
+ // than silently honoring a value that opens a per-cycle blind window.
606
+ const minWindowMs = intervalMs * 2;
607
+ if (windowMs < minWindowMs) {
608
+ process.stderr.write(
609
+ `[wma] --discovery-since (${Math.round(windowMs / 1000)}s) is below 2x --interval ` +
610
+ `(${Math.round(intervalMs / 1000)}s) — a short-lived session could be missed between ` +
611
+ `sweeps. Clamping discovery window up to ${Math.round(minWindowMs / 1000)}s.\n`,
612
+ );
613
+ windowMs = minWindowMs;
614
+ }
571
615
  // display_name on the Fortress payload: defaults to the human agent name
572
616
  // (UX-friendly — operators identify agents by name in the dashboard). The
573
617
  // 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
  }
@@ -52,6 +52,16 @@ export class DecisionLogger {
52
52
  message,
53
53
  decidedInMs,
54
54
  mode, // v1.1.3 Phase 1.D: 'enforce' | 'shadow' (default 'enforce' if absent)
55
+ // v1.4.2 F-44 (P1 audit): the ACTUAL outcome of the enforcement API call,
56
+ // captured by the caller AFTER it attempts confirmDeny / interruptSession.
57
+ // true → the block/interrupt landed (confirmed delivered)
58
+ // false → the call FAILED after retries; the violating action was
59
+ // NOT blocked on the wire (silent fail-open if we lied here)
60
+ // undefined → not applicable (allow / shadow / non-enforcing caller).
61
+ // Pre-F-44 the row claimed "enforced" purely from mode+decision, before
62
+ // (and regardless of) the API call — so the tamper-evident audit chain
63
+ // could assert a block that never happened.
64
+ enforcementDelivered,
55
65
  }) {
56
66
  // In shadow mode the decision is computed and logged but NOT enforced.
57
67
  // status must reflect what actually happened on the wire: shadow + deny
@@ -61,25 +71,38 @@ export class DecisionLogger {
61
71
  const effectiveMode = mode || 'enforce';
62
72
  const enforced = effectiveMode === 'enforce'
63
73
  && (decision === 'deny' || decision === 'interrupt');
74
+ // F-44: enforcement was ATTEMPTED but FAILED → the action was NOT blocked.
75
+ // Surface that honestly instead of recording a clean block.
76
+ const failedDelivery = enforced && enforcementDelivered === false;
77
+
78
+ const output = {
79
+ decision,
80
+ rule_id: ruleId,
81
+ rule_name: ruleName,
82
+ message,
83
+ mode: effectiveMode,
84
+ };
85
+ // Only add the field when enforcement was actually attempted, so allow /
86
+ // shadow / pre-F-44 callers keep their exact record shape.
87
+ if (enforcementDelivered !== undefined) {
88
+ output.enforcement_delivered = enforcementDelivered;
89
+ }
90
+
64
91
  return this._logger.write({
65
92
  action_type: 'shield_decision',
66
93
  provider: this._provider,
67
94
  tool_name: sourceEvent?.name || sourceEvent?.tool_name || null,
68
95
  status: enforced ? 'error' : 'ok',
69
- error: enforced ? message : null,
96
+ error: failedDelivery
97
+ ? `ENFORCEMENT FAILED (action NOT blocked): ${message || ruleName || 'policy violation'}`
98
+ : (enforced ? message : null),
70
99
  duration_ms: decidedInMs ?? null,
71
100
  input: {
72
101
  source_event_id: sourceEvent?.id || null,
73
102
  source_event_type: sourceEvent?.type || null,
74
103
  tool_input: sourceEvent?.input ?? null,
75
104
  },
76
- output: {
77
- decision,
78
- rule_id: ruleId,
79
- rule_name: ruleName,
80
- message,
81
- mode: effectiveMode,
82
- },
105
+ output,
83
106
  });
84
107
  }
85
108
  }