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