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 +13 -4
- package/SECURITY.md +2 -1
- package/package.json +1 -1
- package/scripts/fetch-anthropic.js +71 -11
- package/scripts/service.js +26 -3
- package/scripts/shield.js +145 -67
- package/src/anonymizer.js +5 -2
- package/src/fortress/url.js +77 -3
- package/src/logger.js +8 -0
- package/src/shield/decisions.js +31 -8
- package/src/shield/policy.js +80 -9
- package/src/shield/signature.js +37 -4
- package/src/shield/sources/fortress.js +66 -6
- package/src/shield/upload.js +8 -0
- package/src/sources/anthropic-managed.js +113 -26
- package/src/sources/contract.js +17 -0
- package/src/sources/openai-agents-js.js +76 -16
- package/src/tokens.js +25 -10
- package/src/watch-state.js +56 -0
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.
|
|
9
|
-
3. **Real-time enforcement
|
|
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 (
|
|
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
|
-
-
|
|
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.
|
|
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
|
|
325
|
-
|
|
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
|
-
|
|
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))
|
|
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'}, ${
|
|
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 &&
|
|
422
|
-
if (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
|
-
|
|
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
|
-
|
|
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.
|
package/scripts/service.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
//
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
}
|
|
267
|
-
|
|
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
|
-
|
|
290
|
-
|
|
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
|
-
|
|
295
|
-
|
|
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
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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
|
-
|
|
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;
|