watchmyagents 1.4.0 → 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
  ---
@@ -287,7 +293,7 @@ export WMA_SIGNALS_SALT="..." # stable per-custo
287
293
 
288
294
  wma-service install (--agent-id agent_01ABC... | --all-agents) [--interval 1m] [--with-shield]
289
295
  wma-service status
290
- wma-service uninstall [--with-shield]
296
+ wma-service uninstall [--with-shield] [--purge]
291
297
  ```
292
298
 
293
299
  - macOS → **launchd** LaunchAgent · Linux → **systemd** user unit.
@@ -295,6 +301,9 @@ wma-service uninstall [--with-shield]
295
301
  runtime — **never** written into the plist/unit.
296
302
  - `--with-shield` also runs `wma-shield --policies-source fortress` always-on for
297
303
  live enforcement.
304
+ - `uninstall` by default **leaves `~/.watchmyagents/env` on disk** so a re-install
305
+ keeps your snapshotted keys. Pass `--purge` to also delete the env file and the
306
+ whole `~/.watchmyagents` directory (including local logs).
298
307
  - Raw logs stay local (`~/.watchmyagents/logs`); only anonymized signals upload.
299
308
 
300
309
  After this, the full Watch→Guardian→Shield loop runs hands-off.
package/SECURITY.md CHANGED
@@ -33,7 +33,8 @@ Hardening notes:
33
33
  - The launcher loads secrets with `while IFS='=' read -r k v` instead of `. file` / `source file`. Sourcing would shell-evaluate every value, so a value containing `$(cmd)` would execute at every restart. The literal read assigns the bytes verbatim.
34
34
  - Values are validated before write: a newline anywhere in a credential aborts the install (would corrupt the env file or inject extra lines).
35
35
  - To wipe the credential without uninstalling the service: `chmod 600 ~/.watchmyagents/env && : > ~/.watchmyagents/env` (the daemon will exit on the next missing-env check).
36
- - Full removal: `wma-service uninstall` deletes the unit, the launcher, the env file, and the `~/.watchmyagents` directory.
36
+ - Service removal: `wma-service uninstall` removes the unit and the launcher but **leaves `~/.watchmyagents/env` on disk** so a re-install keeps your snapshotted keys. This is the default since v1.4.1 (F-37) and is the safer behavior — uninstalling to retry an install does not destroy your API keys.
37
+ - Full removal incl. secrets: `wma-service uninstall --purge` removes the unit, the launcher, the env file, and the whole `~/.watchmyagents` directory (including local NDJSON logs under `~/.watchmyagents/logs`). Use this when you really want to scrub the machine.
37
38
 
38
39
  ### Local log files
39
40
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "watchmyagents",
3
- "version": "1.4.0",
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": [
@@ -25,7 +25,7 @@ import { createInterface } from 'node:readline';
25
25
  import { join, resolve } from 'node:path';
26
26
  import { request as httpsRequest } from 'node:https';
27
27
  import { URL } from 'node:url';
28
- import { Logger } from '../src/logger.js';
28
+ import { Logger, tightenMode } from '../src/logger.js';
29
29
  import { TokenTracker } from '../src/tokens.js';
30
30
  import { SignalsAggregator } from '../src/anonymizer.js';
31
31
  import { resolveFortressBase, fortressEndpoint } from '../src/fortress/url.js';
@@ -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 = {};
@@ -321,10 +322,26 @@ async function fetchOneShot({ apiKey, agentId, model, logDir, since, sessionId,
321
322
  process.stdout.write(`\n[wma-fetch] session ${sid}\n`);
322
323
  if (dumpRaw) {
323
324
  assertSafePathSegment(sid, 'session-id'); // defense-in-depth: sid → file path
324
- const rawPath = join(logDir, agentId, `raw-${sid}.jsonl`);
325
- await mkdir(join(logDir, agentId), { recursive: true, mode: 0o700 });
325
+ const rawDir = join(logDir, agentId);
326
+ const rawPath = join(rawDir, `raw-${sid}.jsonl`);
327
+ await mkdir(rawDir, { recursive: true, mode: 0o700 });
328
+ // v1.4.1 F-34 (P2 Codex audit): mkdir/appendFile `mode` is creation-
329
+ // only. If a previous `wma-fetch` run, a different user, or a hand-
330
+ // rolled mkdir left the directory or file in place with loose perms
331
+ // (typically 0755/0644 via umask), the original code path kept them.
332
+ // The raw JSONL carries unredacted API events, so loose perms make
333
+ // them readable to any local user. Tighten after the directory and
334
+ // after the first append so an existing inode is brought in line
335
+ // with the doc promise (0700/0600). Best-effort: chmod failures
336
+ // here MUST NOT break wma-fetch.
337
+ await tightenMode(rawDir, 0o700);
338
+ let firstAppend = true;
326
339
  for await (const ev of fetchRawEvents(apiKey, sid)) {
327
340
  await appendFile(rawPath, JSON.stringify(ev) + '\n', { encoding: 'utf8', mode: 0o600 });
341
+ if (firstAppend) {
342
+ await tightenMode(rawPath, 0o600);
343
+ firstAppend = false;
344
+ }
328
345
  }
329
346
  process.stdout.write(` raw events → ${rawPath}\n`);
330
347
  }
@@ -364,12 +381,19 @@ async function fetchOneShot({ apiKey, agentId, model, logDir, since, sessionId,
364
381
  // Fortress display_name (opt-in); default sends the agent id only (Containment).
365
382
  async function runWatch({ apiKey, resolveAgents, fleet, logDir, intervalMs, windowMs, uploadCtx, sendNames }) {
366
383
  let agents = await resolveAgents();
367
- 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 = [];
368
387
  for (const ag of agents) {
369
- 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);
370
389
  }
390
+ const seen = new SeenTracker(preloaded);
371
391
  const loggers = new Map(); // sessionId → Logger (session ids are globally unique)
372
- 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.
373
397
  const sessionAgent = new Map();// sessionId → { agentId, model, displayName }
374
398
  const priors = new Map(); // agentId → previous classification (threads the
375
399
  // typology state machine across upload cycles)
@@ -383,7 +407,7 @@ async function runWatch({ apiKey, resolveAgents, fleet, logDir, intervalMs, wind
383
407
  process.on('SIGINT', shutdown);
384
408
  process.on('SIGTERM', shutdown);
385
409
 
386
- 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`);
387
411
 
388
412
  while (!ac.signal.aborted) {
389
413
  if (fleet) { const next = await resolveAgents(); if (next.length) agents = next; }
@@ -411,6 +435,14 @@ async function runWatch({ apiKey, resolveAgents, fleet, logDir, intervalMs, wind
411
435
  if (ac.signal.aborted) break;
412
436
  if (ended.has(sid)) continue;
413
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 {
414
446
  let logger = loggers.get(sid);
415
447
  if (!logger) { logger = new Logger({ logDir, agentId: ag.agentId, sessionId: sid, silent: true }); loggers.set(sid, logger); }
416
448
 
@@ -418,8 +450,8 @@ async function runWatch({ apiKey, resolveAgents, fleet, logDir, intervalMs, wind
418
450
  let sawTerminated = false;
419
451
  try {
420
452
  for await (const entry of fetchSessionEntries({ apiKey, agentId: ag.agentId, sessionId: sid, model: ag.model })) {
421
- if (entry.id && seenIds.has(entry.id)) continue;
422
- 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);
423
455
  const written = await logger.write(entry);
424
456
  fresh.push(written);
425
457
  if (entry.action_type === 'state_transition'
@@ -476,9 +508,19 @@ async function runWatch({ apiKey, resolveAgents, fleet, logDir, intervalMs, wind
476
508
  session_cost_usd: stats.cost_usd || null,
477
509
  });
478
510
  ended.add(sid);
479
- 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);
480
518
  info(`${tag}session ${sid.slice(0, 16)}… terminated — closed`);
481
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
+ }
482
524
  }
483
525
 
484
526
  if (cycleNew === 0) info('cycle: no new events');
@@ -551,7 +593,25 @@ async function main() {
551
593
  const intervalMs = parseDurationMs(args.interval, 60_000);
552
594
  // Discovery window for NEW sessions (default 7d, configurable). Sessions we
553
595
  // already track are re-fetched regardless of age, so long-lived ones don't drop.
554
- 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
+ }
555
615
  // display_name on the Fortress payload: defaults to the human agent name
556
616
  // (UX-friendly — operators identify agents by name in the dashboard). The
557
617
  // name is sanitized via cleanLabel() so log/payload injection is impossible.
@@ -11,7 +11,14 @@
11
11
  // One integrated install:
12
12
  // wma-service install --agent-id agent_xxx [--interval 5m] [--with-shield]
13
13
  // wma-service status
14
- // wma-service uninstall [--with-shield]
14
+ // wma-service uninstall [--with-shield] [--purge]
15
+ //
16
+ // v1.4.1 F-37 (P3 Codex audit on v1.4.0): `uninstall` defaults to LEAVING
17
+ // ~/.watchmyagents/env on disk (containing the snapshotted API keys). This
18
+ // matches the historical behavior and protects users who uninstall just to
19
+ // reinstall with a different agent-id from losing their snapshot. To wipe
20
+ // the env file + config dir, pass `--purge` explicitly. SECURITY.md says
21
+ // the same thing; do not let the doc + the code drift again.
15
22
  //
16
23
  // Secrets NEVER go in the plist/unit. They're snapshotted (from the current
17
24
  // environment) into a protected env file (~/.watchmyagents/env, chmod 600) that
@@ -289,6 +296,7 @@ function cmdInstall(args) {
289
296
 
290
297
  function cmdUninstall(args) {
291
298
  const withShield = !!args['with-shield'];
299
+ const purge = !!args.purge;
292
300
  if (PLATFORM === 'darwin') {
293
301
  macUnload(WATCH_LABEL);
294
302
  if (withShield) macUnload(SHIELD_LABEL);
@@ -298,7 +306,18 @@ function cmdUninstall(args) {
298
306
  } else {
299
307
  die(`unsupported platform "${PLATFORM}"`);
300
308
  }
301
- info('uninstalled. (Secrets in ' + ENV_FILE + ' left intact — delete manually if you want them gone.)');
309
+ if (purge) {
310
+ // F-37: explicit opt-in only. Removes the snapshotted env file (chmod
311
+ // 600, contains API keys) and the whole config dir. Logs under
312
+ // ~/.watchmyagents/logs are wiped too — the user asked for purge, so
313
+ // we do not second-guess them on local NDJSON. `force: true` ensures
314
+ // we don't throw if the path is already gone.
315
+ try { rmSync(ENV_FILE, { force: true }); } catch { /* best-effort */ }
316
+ try { rmSync(CONFIG_DIR, { recursive: true, force: true }); } catch { /* best-effort */ }
317
+ info(`uninstalled + purged ${CONFIG_DIR} (env file and local logs removed).`);
318
+ } else {
319
+ info(`uninstalled. Secrets in ${ENV_FILE} left intact — re-run with --purge to wipe them.`);
320
+ }
302
321
  }
303
322
 
304
323
  function cmdStatus() {
@@ -328,11 +347,15 @@ function usage() {
328
347
  Usage:
329
348
  wma-service install --agent-id agent_xxx [--interval 5m] [--log-dir DIR] [--with-shield]
330
349
  wma-service status
331
- wma-service uninstall [--with-shield]
350
+ wma-service uninstall [--with-shield] [--purge]
332
351
 
333
352
  Required env at install (snapshotted to ~/.watchmyagents/env, chmod 600):
334
353
  ANTHROPIC_API_KEY, WMA_API_KEY, WMA_FORTRESS_BASE_URL, WMA_SIGNALS_SALT
335
354
 
355
+ Uninstall by default leaves ~/.watchmyagents/env on disk so a re-install
356
+ keeps your snapshotted keys. Pass --purge to also delete the env file and
357
+ the whole ~/.watchmyagents directory (including local logs).
358
+
336
359
  macOS → launchd LaunchAgent · Linux → systemd user unit.
337
360
  The service starts at login and restarts on crash. Raw logs stay local.
338
361
  `);
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;