watchmyagents 1.4.1 → 1.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -3
- package/package.json +1 -1
- package/scripts/fetch-anthropic.js +73 -10
- package/scripts/shield.js +145 -67
- package/src/anonymizer.js +5 -2
- package/src/fortress/url.js +77 -3
- 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 +137 -26
- package/src/sources/contract.js +17 -0
- package/src/sources/openai-agents-js.js +17 -1
- package/src/tokens.js +25 -10
- package/src/watch-state.js +65 -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
|
---
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "watchmyagents",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.4",
|
|
4
4
|
"description": "Security observability + real-time policy enforcement for AI agents. Local-first NDJSON capture with a continuous Watch daemon that auto-uploads anonymized signals, Shield CLI that blocks policy violations live (with policies pulled from Fortress cloud), anonymizer producing signals-only payloads, bidirectional sync with WatchMyAgents Fortress, and one-command install as an always-on launchd/systemd service — closing the recursive Watch→Guardian→Shield security loop.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
AnthropicManagedSource, effectiveEnforcementMode,
|
|
39
39
|
} from '../src/sources/anthropic-managed.js';
|
|
40
40
|
import { maybePrintVersionAndExit } from '../src/version.js';
|
|
41
|
+
import { SeenTracker } from '../src/watch-state.js';
|
|
41
42
|
|
|
42
43
|
function parseArgs(argv) {
|
|
43
44
|
const out = {};
|
|
@@ -380,12 +381,27 @@ async function fetchOneShot({ apiKey, agentId, model, logDir, since, sessionId,
|
|
|
380
381
|
// Fortress display_name (opt-in); default sends the agent id only (Containment).
|
|
381
382
|
async function runWatch({ apiKey, resolveAgents, fleet, logDir, intervalMs, windowMs, uploadCtx, sendNames }) {
|
|
382
383
|
let agents = await resolveAgents();
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
384
|
+
// v1.4.3 F-51: bounded dedup. Preloaded on-disk ids are static; runtime ids
|
|
385
|
+
// are tracked per-session and dropped on terminate (see src/watch-state.js).
|
|
386
|
+
const seen = new SeenTracker();
|
|
387
|
+
// v1.4.4 F-53: preload each agent's on-disk history the FIRST time we see it
|
|
388
|
+
// — including agents that --all-agents discovers after startup. Pre-fix the
|
|
389
|
+
// preload ran once for the initial fleet only, so a late-appearing agent with
|
|
390
|
+
// existing logs re-appended + re-uploaded already-captured events.
|
|
391
|
+
const preloadedAgents = new Set();
|
|
392
|
+
async function ensurePreloaded(agentId) {
|
|
393
|
+
if (preloadedAgents.has(agentId)) return;
|
|
394
|
+
preloadedAgents.add(agentId);
|
|
395
|
+
for (const id of await preloadSeenIds(logDir, agentId)) seen.addPreloaded(id);
|
|
386
396
|
}
|
|
397
|
+
for (const ag of agents) await ensurePreloaded(ag.agentId);
|
|
387
398
|
const loggers = new Map(); // sessionId → Logger (session ids are globally unique)
|
|
388
|
-
|
|
399
|
+
// terminated sessions (skip). v1.4.4 F-54: a Map<sid → terminatedAtMs> so we
|
|
400
|
+
// can TTL-prune. A session terminated more than windowMs ago has created_at
|
|
401
|
+
// older still, so listSessions (which filters created_at < since, since =
|
|
402
|
+
// now - windowMs) can no longer return it — its id is then safe to forget,
|
|
403
|
+
// bounding this map's growth on a long daemon with many short sessions.
|
|
404
|
+
const ended = new Map();
|
|
389
405
|
const sessionAgent = new Map();// sessionId → { agentId, model, displayName }
|
|
390
406
|
const priors = new Map(); // agentId → previous classification (threads the
|
|
391
407
|
// typology state machine across upload cycles)
|
|
@@ -399,15 +415,26 @@ async function runWatch({ apiKey, resolveAgents, fleet, logDir, intervalMs, wind
|
|
|
399
415
|
process.on('SIGINT', shutdown);
|
|
400
416
|
process.on('SIGTERM', shutdown);
|
|
401
417
|
|
|
402
|
-
info(`watch mode — ${agents.length} agent(s)${fleet ? ' (fleet, re-discovered each cycle)' : ''}, interval ${Math.round(intervalMs / 1000)}s, discovery window ${Math.round(windowMs / 3600000)}h, upload ${uploadCtx ? 'ON' : 'OFF'}, ${
|
|
418
|
+
info(`watch mode — ${agents.length} agent(s)${fleet ? ' (fleet, re-discovered each cycle)' : ''}, interval ${Math.round(intervalMs / 1000)}s, discovery window ${Math.round(windowMs / 3600000)}h, upload ${uploadCtx ? 'ON' : 'OFF'}, ${seen.size} known events preloaded`);
|
|
403
419
|
|
|
404
420
|
while (!ac.signal.aborted) {
|
|
405
421
|
if (fleet) { const next = await resolveAgents(); if (next.length) agents = next; }
|
|
406
422
|
const since = new Date(Date.now() - windowMs);
|
|
407
423
|
|
|
424
|
+
// F-54: forget sessions terminated longer ago than the discovery window —
|
|
425
|
+
// listSessions can no longer surface them (created_at < since), so their
|
|
426
|
+
// skip-marker is no longer needed. Bounds `ended` on a long-running daemon.
|
|
427
|
+
const ttlCutoff = Date.now() - windowMs;
|
|
428
|
+
for (const [sid, terminatedAt] of ended) {
|
|
429
|
+
if (terminatedAt < ttlCutoff) ended.delete(sid);
|
|
430
|
+
}
|
|
431
|
+
|
|
408
432
|
// (1) Discover sessions in the window; register the owning agent for each.
|
|
409
433
|
for (const ag of agents) {
|
|
410
434
|
if (ac.signal.aborted) break;
|
|
435
|
+
// F-53: a late-discovered agent (fleet mode) gets its disk history
|
|
436
|
+
// preloaded before we fetch any of its sessions, so dedup holds.
|
|
437
|
+
await ensurePreloaded(ag.agentId);
|
|
411
438
|
const tag = fleet ? `[${ag.displayName}] ` : '';
|
|
412
439
|
let sessions = [];
|
|
413
440
|
try { sessions = await listSessions(apiKey, { agentId: ag.agentId, since }); }
|
|
@@ -427,6 +454,14 @@ async function runWatch({ apiKey, resolveAgents, fleet, logDir, intervalMs, wind
|
|
|
427
454
|
if (ac.signal.aborted) break;
|
|
428
455
|
if (ended.has(sid)) continue;
|
|
429
456
|
const tag = fleet ? `[${ag.displayName}] ` : '';
|
|
457
|
+
// v1.4.3 F-51: top-level guard around the WHOLE per-session body. Pre-fix
|
|
458
|
+
// only the fetch + upload had try/catch; an unguarded throw elsewhere
|
|
459
|
+
// (e.g. ENOSPC/EACCES on the fail-loud session_end logger.write, or a
|
|
460
|
+
// malformed entry in TokenTracker) propagated out of the loop and
|
|
461
|
+
// KILLED the daemon — silent total collection-stop on a backgrounded
|
|
462
|
+
// process. Now any single session's failure drops that session for the
|
|
463
|
+
// cycle and the daemon survives.
|
|
464
|
+
try {
|
|
430
465
|
let logger = loggers.get(sid);
|
|
431
466
|
if (!logger) { logger = new Logger({ logDir, agentId: ag.agentId, sessionId: sid, silent: true }); loggers.set(sid, logger); }
|
|
432
467
|
|
|
@@ -434,8 +469,8 @@ async function runWatch({ apiKey, resolveAgents, fleet, logDir, intervalMs, wind
|
|
|
434
469
|
let sawTerminated = false;
|
|
435
470
|
try {
|
|
436
471
|
for await (const entry of fetchSessionEntries({ apiKey, agentId: ag.agentId, sessionId: sid, model: ag.model })) {
|
|
437
|
-
if (entry.id &&
|
|
438
|
-
if (entry.id)
|
|
472
|
+
if (entry.id && seen.has(sid, entry.id)) continue;
|
|
473
|
+
if (entry.id) seen.add(sid, entry.id);
|
|
439
474
|
const written = await logger.write(entry);
|
|
440
475
|
fresh.push(written);
|
|
441
476
|
if (entry.action_type === 'state_transition'
|
|
@@ -491,10 +526,20 @@ async function runWatch({ apiKey, resolveAgents, fleet, logDir, intervalMs, wind
|
|
|
491
526
|
session_tokens: { input: stats.input, output: stats.output, cache_read: stats.cache_read, cache_creation: stats.cache_creation, total: stats.sum },
|
|
492
527
|
session_cost_usd: stats.cost_usd || null,
|
|
493
528
|
});
|
|
494
|
-
ended.
|
|
495
|
-
|
|
529
|
+
ended.set(sid, Date.now());
|
|
530
|
+
// v1.4.3 F-51: bound memory — terminated sessions aren't re-fetched,
|
|
531
|
+
// so drop their per-session dedup set AND their Logger object (the
|
|
532
|
+
// latter was never deleted pre-fix → unbounded growth of Logger
|
|
533
|
+
// instances over a long daemon run).
|
|
534
|
+
sessionAgent.delete(sid);
|
|
535
|
+
seen.forgetSession(sid);
|
|
536
|
+
loggers.delete(sid);
|
|
496
537
|
info(`${tag}session ${sid.slice(0, 16)}… terminated — closed`);
|
|
497
538
|
}
|
|
539
|
+
} catch (e) {
|
|
540
|
+
// F-51 top-level guard: never let one session's failure kill the loop.
|
|
541
|
+
warn(`${tag}session ${sid.slice(0, 16)}…: cycle error (skipped): ${e.message}`);
|
|
542
|
+
}
|
|
498
543
|
}
|
|
499
544
|
|
|
500
545
|
if (cycleNew === 0) info('cycle: no new events');
|
|
@@ -567,7 +612,25 @@ async function main() {
|
|
|
567
612
|
const intervalMs = parseDurationMs(args.interval, 60_000);
|
|
568
613
|
// Discovery window for NEW sessions (default 7d, configurable). Sessions we
|
|
569
614
|
// already track are re-fetched regardless of age, so long-lived ones don't drop.
|
|
570
|
-
|
|
615
|
+
let windowMs = parseDurationMs(args['discovery-since'], 7 * 24 * 3600_000);
|
|
616
|
+
// v1.4.2 F-46 (P1 audit): the discovery window must comfortably exceed the
|
|
617
|
+
// poll interval. Discovery runs once per cycle; a session that is created
|
|
618
|
+
// AND ends/ages-out between two sweeps is never added to the tracked set,
|
|
619
|
+
// so its events are never fetched (untraced activity). Requiring
|
|
620
|
+
// windowMs >= 2*intervalMs guarantees every session appears in at least
|
|
621
|
+
// two consecutive discovery sweeps before it can leave the window. The 7d
|
|
622
|
+
// default is far above this; the guard only bites when an operator sets
|
|
623
|
+
// --discovery-since too tight relative to --interval. Clamp + warn rather
|
|
624
|
+
// than silently honoring a value that opens a per-cycle blind window.
|
|
625
|
+
const minWindowMs = intervalMs * 2;
|
|
626
|
+
if (windowMs < minWindowMs) {
|
|
627
|
+
process.stderr.write(
|
|
628
|
+
`[wma] --discovery-since (${Math.round(windowMs / 1000)}s) is below 2x --interval ` +
|
|
629
|
+
`(${Math.round(intervalMs / 1000)}s) — a short-lived session could be missed between ` +
|
|
630
|
+
`sweeps. Clamping discovery window up to ${Math.round(minWindowMs / 1000)}s.\n`,
|
|
631
|
+
);
|
|
632
|
+
windowMs = minWindowMs;
|
|
633
|
+
}
|
|
571
634
|
// display_name on the Fortress payload: defaults to the human agent name
|
|
572
635
|
// (UX-friendly — operators identify agents by name in the dashboard). The
|
|
573
636
|
// name is sanitized via cleanLabel() so log/payload injection is impossible.
|
package/scripts/shield.js
CHANGED
|
@@ -146,16 +146,42 @@ async function runSessionWorker({ sessionId, ctx }) {
|
|
|
146
146
|
// isolation. The helper drops any field it cannot safely normalize
|
|
147
147
|
// (custom tool without salt, missing salt for hashes) rather than
|
|
148
148
|
// passing the raw value through.
|
|
149
|
-
const fireToFortress = (rawEvent, normalized, result, decidedInMs) => {
|
|
149
|
+
const fireToFortress = (rawEvent, normalized, result, decidedInMs, enforcementDelivered) => {
|
|
150
150
|
if (!pushDecisionToFortress) return;
|
|
151
151
|
const payload = buildFortressDecisionPayload({
|
|
152
152
|
agentId, sessionId, rawEvent, normalized, result, decidedInMs,
|
|
153
|
-
signalsSalt,
|
|
153
|
+
signalsSalt, enforcementDelivered,
|
|
154
154
|
});
|
|
155
155
|
pushDecisionToFortress(payload).catch(() => undefined);
|
|
156
156
|
};
|
|
157
157
|
|
|
158
|
-
|
|
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;
|
package/src/fortress/url.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|