watchmyagents 1.1.1 → 1.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "watchmyagents",
3
- "version": "1.1.1",
3
+ "version": "1.1.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": [
@@ -85,6 +85,12 @@ function resolveModel(agent) {
85
85
  }
86
86
 
87
87
  // HTTPS POST helper for the --upload signals push (mirrors wma-upload-fortress).
88
+ // v1.1.2 F-17: response body cap for the Fortress ingest-signals POST.
89
+ // The expected reply is a small JSON confirmation ({signal_id, agent_id,
90
+ // registered_new_agent}) — well under 1 MB. Any larger and the endpoint
91
+ // is misconfigured or compromised; abort.
92
+ const MAX_FORTRESS_RESPONSE_BYTES = 1 * 1024 * 1024;
93
+
88
94
  function postJson(url, headers, body) {
89
95
  return new Promise((resolveReq, rejectReq) => {
90
96
  const u = new URL(url);
@@ -97,8 +103,22 @@ function postJson(url, headers, body) {
97
103
  rejectUnauthorized: true,
98
104
  }, (res) => {
99
105
  const chunks = [];
100
- res.on('data', (c) => chunks.push(c));
106
+ let receivedBytes = 0;
107
+ let aborted = false;
108
+ res.on('data', (c) => {
109
+ if (aborted) return;
110
+ receivedBytes += c.length;
111
+ if (receivedBytes > MAX_FORTRESS_RESPONSE_BYTES) {
112
+ aborted = true;
113
+ chunks.length = 0;
114
+ try { req.destroy(); } catch { /* already destroyed */ }
115
+ rejectReq(new Error(`Fortress response exceeded ${MAX_FORTRESS_RESPONSE_BYTES} bytes — aborting`));
116
+ return;
117
+ }
118
+ chunks.push(c);
119
+ });
101
120
  res.on('end', () => {
121
+ if (aborted) return;
102
122
  const raw = Buffer.concat(chunks).toString('utf8');
103
123
  let parsed = null; try { parsed = JSON.parse(raw); } catch { /* keep raw */ }
104
124
  resolveReq({ status: res.statusCode || 0, body: parsed ?? raw });
package/scripts/shield.js CHANGED
@@ -214,15 +214,27 @@ async function runSessionWorker({ sessionId, ctx }) {
214
214
  const result = evaluate(normalized, ctx.ruleset);
215
215
  const decidedInMs = Date.now() - t0;
216
216
 
217
- sinfo(sessionId, `${rawEvent.type} tool=${normalized.tool_name} ${result.decision}${result.rule_id ? ` (${result.rule_id})` : ''}`);
217
+ // v1.1.3 Phase 1.D mode badge in the log line for operator visibility.
218
+ const modeTag = result.mode === 'shadow' ? ' [SHADOW]' : '';
219
+ sinfo(sessionId, `${rawEvent.type} tool=${normalized.tool_name} → ${result.decision}${modeTag}${result.rule_id ? ` (${result.rule_id})` : ''}`);
218
220
 
219
221
  await decisions(sessionId).record({
220
222
  sourceEvent: rawEvent, decision: result.decision,
221
223
  ruleId: result.rule_id, ruleName: result.rule_name,
222
224
  message: result.message, decidedInMs,
225
+ mode: result.mode,
223
226
  });
224
227
  fireToFortress(rawEvent, normalized, result, decidedInMs);
225
228
 
229
+ // v1.1.3 Phase 1.D — in shadow mode, the decision is COMPUTED + LOGGED
230
+ // but NOT enforced. The rule's "would_deny" / "would_interrupt"
231
+ // outcome flows to Fortress for Platt-scaling calibration + diff-in-diff
232
+ // efficacy measurement (Guardian Core hardening axes 1 + 4), but the
233
+ // agent's session continues uninterrupted. Promote to enforce only
234
+ // after calibration confidence + lifecycle gates (Guardian Core spec
235
+ // observe → shadow → enforce → retired).
236
+ if (result.mode === 'shadow') continue;
237
+
226
238
  if ((result.decision === 'deny' || result.decision === 'interrupt') && !sessionInterrupted) {
227
239
  try {
228
240
  await interruptSession({
@@ -271,15 +283,34 @@ async function runSessionWorker({ sessionId, ctx }) {
271
283
  const result = evaluate(normalized, ctx.ruleset);
272
284
  const decidedInMs = Date.now() - t0;
273
285
 
274
- sinfo(sessionId, `requires_action ${sourceEvent.type} tool=${normalized.tool_name} ${result.decision}${result.rule_id ? ` (${result.rule_id})` : ''}`);
286
+ // v1.1.3 Phase 1.D mode badge in the log line for operator visibility.
287
+ const modeTag = result.mode === 'shadow' ? ' [SHADOW]' : '';
288
+ sinfo(sessionId, `requires_action ${sourceEvent.type} tool=${normalized.tool_name} → ${result.decision}${modeTag}${result.rule_id ? ` (${result.rule_id})` : ''}`);
275
289
 
276
290
  await decisions(sessionId).record({
277
291
  sourceEvent, decision: result.decision,
278
292
  ruleId: result.rule_id, ruleName: result.rule_name,
279
293
  message: result.message, decidedInMs,
294
+ mode: result.mode,
280
295
  });
281
296
  fireToFortress(sourceEvent, normalized, result, decidedInMs);
282
297
 
298
+ // v1.1.3 Phase 1.D — shadow mode in tool_confirmation: we MUST
299
+ // still send confirmAllow even when the rule said deny/interrupt,
300
+ // otherwise the agent hangs waiting for our response. The
301
+ // decision is logged with mode=shadow so calibration can compare
302
+ // what the rule said vs what was enforced (which is "nothing"
303
+ // here). For mode=enforce, the original branching below stands.
304
+ if (result.mode === 'shadow') {
305
+ try {
306
+ await confirmAllow({ apiKey, sessionId, toolUseId: eventId });
307
+ // No enforced++ — shadow doesn't enforce by definition.
308
+ } catch (e) {
309
+ process.stderr.write(`[shield/${sessionId.slice(0, 12)}] shadow confirmAllow error: ${e.message}\n`);
310
+ }
311
+ continue;
312
+ }
313
+
283
314
  try {
284
315
  if (result.decision === 'allow') {
285
316
  await confirmAllow({ apiKey, sessionId, toolUseId: eventId });
@@ -63,6 +63,11 @@ async function collectFiles(p) {
63
63
  return out;
64
64
  }
65
65
 
66
+ // v1.1.2 F-17: Fortress ingest-signals response is a small confirmation
67
+ // JSON. Cap at 1 MB and abort if the endpoint streams more — defensive
68
+ // against a compromised or misconfigured response.
69
+ const MAX_FORTRESS_RESPONSE_BYTES = 1 * 1024 * 1024;
70
+
66
71
  function postJson(url, headers, body) {
67
72
  return new Promise((resolveReq, rejectReq) => {
68
73
  const u = new URL(url);
@@ -85,8 +90,22 @@ function postJson(url, headers, body) {
85
90
  },
86
91
  (res) => {
87
92
  const chunks = [];
88
- res.on('data', (c) => chunks.push(c));
93
+ let receivedBytes = 0;
94
+ let aborted = false;
95
+ res.on('data', (c) => {
96
+ if (aborted) return;
97
+ receivedBytes += c.length;
98
+ if (receivedBytes > MAX_FORTRESS_RESPONSE_BYTES) {
99
+ aborted = true;
100
+ chunks.length = 0;
101
+ try { req.destroy(); } catch { /* already destroyed */ }
102
+ rejectReq(new Error(`Fortress response exceeded ${MAX_FORTRESS_RESPONSE_BYTES} bytes — aborting`));
103
+ return;
104
+ }
105
+ chunks.push(c);
106
+ });
89
107
  res.on('end', () => {
108
+ if (aborted) return;
90
109
  const raw = Buffer.concat(chunks).toString('utf8');
91
110
  let parsed = null;
92
111
  try { parsed = JSON.parse(raw); } catch { /* keep raw */ }
package/src/anonymizer.js CHANGED
@@ -109,6 +109,53 @@ export function normalizeToolName(toolName, salt) {
109
109
  return 'tool_hash:' + createHash('sha256').update(salt).update(s).digest('hex').slice(0, 32);
110
110
  }
111
111
 
112
+ // ── Tool input field normalization (Phase 1.A L-2, v1.1.3) ──────────────
113
+ //
114
+ // HASHABLE_INPUT_FIELDS is the canonical set of input field names the
115
+ // anonymizer knows how to hash. These names came from the Anthropic tool
116
+ // shape (`web_fetch.url`, `web_search.query`, `bash.command`, file tools).
117
+ // Other frameworks use different native names — OpenAI function tools
118
+ // have arbitrary user-defined argument names, LangGraph tools use the
119
+ // `tools[].args` shape, CrewAI uses `task.context`, etc.
120
+ //
121
+ // To avoid silent IoC loss when a new adapter lands, adapters can call
122
+ // `normalizeToolInput()` BEFORE yielding their WMAAction to map their
123
+ // native tool argument names to the canonical set. The function takes
124
+ // the raw input object and an alias map `{nativeName: canonicalName}`
125
+ // and returns a new object where canonical names are populated from
126
+ // the corresponding native fields (without losing the originals — both
127
+ // are kept so the local NDJSON preserves full fidelity).
128
+ //
129
+ // Example for a future OpenAI function tool that exposes `endpoint_url`:
130
+ // yield {
131
+ // ...,
132
+ // action_type: 'custom_tool_use',
133
+ // tool_name: 'fetch_remote',
134
+ // input: normalizeToolInput(rawInput, { endpoint_url: 'url' }),
135
+ // };
136
+ // Now `entry.input.url` is populated → the anonymizer's IoC hashing
137
+ // fires → Fortress receives a hashed signal as expected.
138
+ //
139
+ // Adapters that already use canonical names (Anthropic) can skip the
140
+ // helper — it's a no-op when no aliases match.
141
+ export function normalizeToolInput(rawInput, aliases = {}) {
142
+ if (rawInput == null || typeof rawInput !== 'object') return rawInput;
143
+ const out = { ...rawInput };
144
+ for (const [nativeName, canonicalName] of Object.entries(aliases)) {
145
+ if (!HASHABLE_INPUT_FIELDS.includes(canonicalName)) {
146
+ throw new Error(`normalizeToolInput: "${canonicalName}" is not a canonical hashable field (must be one of: ${HASHABLE_INPUT_FIELDS.join(', ')})`);
147
+ }
148
+ if (out[canonicalName] == null && rawInput[nativeName] != null) {
149
+ out[canonicalName] = rawInput[nativeName];
150
+ }
151
+ }
152
+ return out;
153
+ }
154
+
155
+ // Re-export the canonical hashable-fields list so adapter authors can
156
+ // programmatically reference the contract.
157
+ export { HASHABLE_INPUT_FIELDS };
158
+
112
159
  // ── Single-entry extractor: what hashable IoCs are in this entry? ────────
113
160
 
114
161
  function extractIocs(entry, salt) {
@@ -22,13 +22,22 @@ export class DecisionLogger {
22
22
  ruleName,
23
23
  message,
24
24
  decidedInMs,
25
+ mode, // v1.1.3 Phase 1.D: 'enforce' | 'shadow' (default 'enforce' if absent)
25
26
  }) {
27
+ // In shadow mode the decision is computed and logged but NOT enforced.
28
+ // status must reflect what actually happened on the wire: shadow + deny
29
+ // didn't actually block, so status='ok' keeps Watch's aggregations honest.
30
+ // output.mode is what calibration reads to compare would-have-blocked vs
31
+ // did-block.
32
+ const effectiveMode = mode || 'enforce';
33
+ const enforced = effectiveMode === 'enforce'
34
+ && (decision === 'deny' || decision === 'interrupt');
26
35
  return this._logger.write({
27
36
  action_type: 'shield_decision',
28
37
  provider: 'anthropic-managed',
29
38
  tool_name: sourceEvent?.name || sourceEvent?.tool_name || null,
30
- status: decision === 'deny' || decision === 'interrupt' ? 'error' : 'ok',
31
- error: decision === 'deny' || decision === 'interrupt' ? message : null,
39
+ status: enforced ? 'error' : 'ok',
40
+ error: enforced ? message : null,
32
41
  duration_ms: decidedInMs ?? null,
33
42
  input: {
34
43
  source_event_id: sourceEvent?.id || null,
@@ -40,6 +49,7 @@ export class DecisionLogger {
40
49
  rule_id: ruleId,
41
50
  rule_name: ruleName,
42
51
  message,
52
+ mode: effectiveMode,
43
53
  },
44
54
  });
45
55
  }
@@ -32,13 +32,39 @@ export async function loadPolicies(path) {
32
32
  throw new Error(`policy file ${path} has no "policies" array`);
33
33
  }
34
34
  // Pre-compile regex for performance + early failure on bad patterns.
35
+ const VALID_ACTIONS = ['allow', 'deny', 'interrupt'];
36
+ // v1.1.3 Phase 1.D — policy mode: 'enforce' (default) actually enforces
37
+ // the decision via the Anthropic API; 'shadow' computes the decision
38
+ // and logs it but skips enforcement. Shadow is the calibration bench
39
+ // for Guardian Core scoring (Platt scaling, diff-in-diff efficacy)
40
+ // and a safe staging step for new policies before promoting to enforce.
41
+ const VALID_MODES = ['enforce', 'shadow'];
35
42
  for (const p of data.policies) {
36
43
  compileMatchRegexes(p.match || {});
37
- if (!['allow', 'deny', 'interrupt'].includes(p.action)) {
44
+ if (!VALID_ACTIONS.includes(p.action)) {
38
45
  throw new Error(`policy ${p.id || p.name}: unsupported action "${p.action}"`);
39
46
  }
47
+ // Default to 'enforce' if mode is omitted → preserves v1.0.x / v1.1.x
48
+ // behavior for policies authored before shadow mode existed.
49
+ if (p.mode == null) p.mode = 'enforce';
50
+ if (!VALID_MODES.includes(p.mode)) {
51
+ throw new Error(`policy ${p.id || p.name}: unsupported mode "${p.mode}" (must be one of: ${VALID_MODES.join(', ')})`);
52
+ }
40
53
  }
54
+ // v1.1.2 F-14 (P2 Codex audit): validate the ruleset's default.action
55
+ // against the SAME canonical set as per-policy actions. Before this fix
56
+ // a typo like `default: { action: "drop" }` was accepted silently — at
57
+ // evaluation time evaluate() returned `decision: "drop"`, which the
58
+ // interrupt-mode runtime treated as a no-op (only deny/interrupt trigger
59
+ // termination) and the tool_confirmation-mode runtime left dangling
60
+ // (no allow/deny event sent). Either way the agent ran without
61
+ // enforcement, exactly opposite of the operator's intent.
41
62
  data.default = data.default || { action: 'allow' };
63
+ if (!VALID_ACTIONS.includes(data.default.action)) {
64
+ throw new Error(
65
+ `policy file ${path} default.action "${data.default.action}" is invalid — must be one of: ${VALID_ACTIONS.join(', ')}`,
66
+ );
67
+ }
42
68
  return data;
43
69
  }
44
70
 
@@ -143,6 +169,10 @@ export function evaluate(event, ruleset) {
143
169
  rule_id: policy.id || null,
144
170
  rule_name: policy.name || null,
145
171
  message: policy.message || null,
172
+ // v1.1.3 Phase 1.D — `mode` propagated so the Shield runtime can
173
+ // decide whether to actually call the Anthropic enforcement API
174
+ // (mode=enforce) or just log the would-be decision (mode=shadow).
175
+ mode: policy.mode || 'enforce',
146
176
  };
147
177
  }
148
178
  }
@@ -151,5 +181,8 @@ export function evaluate(event, ruleset) {
151
181
  rule_id: null,
152
182
  rule_name: '(default)',
153
183
  message: null,
184
+ // The ruleset-level default has no shadow concept — defaults always
185
+ // enforce (or, when the default is 'allow', do nothing of consequence).
186
+ mode: 'enforce',
154
187
  };
155
188
  }
@@ -14,6 +14,15 @@ import { URL } from 'node:url';
14
14
  import { fortressEndpoint } from '../../fortress/url.js';
15
15
 
16
16
  const DEFAULT_TIMEOUT_MS = 15_000;
17
+ // v1.1.2 F-17 (P3 Codex audit): cap on the total bytes we'll accumulate
18
+ // for a Fortress JSON response before aborting the request. A misconfigured
19
+ // or compromised endpoint streaming an unbounded body would otherwise
20
+ // exhaust Shield's memory, despite the HTTPS-only + timeout guards.
21
+ // 8 MB is far above the realistic ceiling for a customer's policy ruleset
22
+ // (hundreds of policies × ~1 KB each → ~hundreds of KB). On overflow we
23
+ // destroy the request, which propagates to onError + cached-ruleset
24
+ // fallback.
25
+ const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
17
26
 
18
27
  function httpsJson(method, url, headers, body, timeoutMs = DEFAULT_TIMEOUT_MS) {
19
28
  return new Promise((resolveReq, rejectReq) => {
@@ -35,8 +44,23 @@ function httpsJson(method, url, headers, body, timeoutMs = DEFAULT_TIMEOUT_MS) {
35
44
  };
36
45
  const req = httpsRequest(opts, (res) => {
37
46
  const chunks = [];
38
- res.on('data', (c) => chunks.push(c));
47
+ let receivedBytes = 0;
48
+ let aborted = false;
49
+ res.on('data', (c) => {
50
+ if (aborted) return;
51
+ receivedBytes += c.length;
52
+ if (receivedBytes > MAX_RESPONSE_BYTES) {
53
+ aborted = true;
54
+ // Free anything we already buffered, then tear down the request.
55
+ chunks.length = 0;
56
+ try { req.destroy(); } catch { /* already destroyed */ }
57
+ rejectReq(new Error(`Fortress response exceeded ${MAX_RESPONSE_BYTES} bytes — aborting (received ${receivedBytes} so far)`));
58
+ return;
59
+ }
60
+ chunks.push(c);
61
+ });
39
62
  res.on('end', () => {
63
+ if (aborted) return;
40
64
  const raw = Buffer.concat(chunks).toString('utf8');
41
65
  let parsed = null;
42
66
  try { parsed = raw ? JSON.parse(raw) : null; } catch { /* keep raw */ }
@@ -179,6 +203,17 @@ export class FortressPolicySource {
179
203
  this.onError(new Error(`skipping invalid Fortress policy "${p?.rule_id || p?.name || '?'}": ${e.message}`));
180
204
  }
181
205
  }
206
+ // v1.1.2 F-15 (P2 Codex audit): the policy evaluator is "first match
207
+ // wins" (src/shield/policy.js evaluate()), so policy order matters.
208
+ // Fortress validates `priority` server-side, but the API does not
209
+ // contractually guarantee that the returned array is sorted by
210
+ // priority. If a wide "allow" rule sat before a higher-priority
211
+ // "deny" rule in the response, the deny would never fire. Sort
212
+ // client-side by descending priority (higher priority first) before
213
+ // assigning to ruleset. Policies without `priority` (or with equal
214
+ // priorities) keep their relative order via the stable sort
215
+ // guarantee in V8 — predictable behavior.
216
+ compiled.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
182
217
  this.ruleset = {
183
218
  version: 1,
184
219
  policies: compiled,
@@ -198,6 +233,9 @@ export class FortressPolicySource {
198
233
  // Convert a Fortress DB policy row to the local Shield format.
199
234
  // Throws on anything invalid so _refresh can skip it (policies from the cloud
200
235
  // are NOT fully trusted — apply the same hardening as the local JSON loader).
236
+ // v1.1.3 Phase 1.D — same modes as the local JSON loader.
237
+ const VALID_MODES = new Set(['enforce', 'shadow']);
238
+
201
239
  function compilePolicyFromFortress(p) {
202
240
  if (!p || typeof p !== 'object') throw new Error('policy is not an object');
203
241
  if (!VALID_ACTIONS.has(p.action)) {
@@ -209,6 +247,14 @@ function compilePolicyFromFortress(p) {
209
247
  if (p.priority != null && (typeof p.priority !== 'number' || !Number.isFinite(p.priority))) {
210
248
  throw new Error(`priority must be a finite number (got ${p.priority})`);
211
249
  }
250
+ // v1.1.3 Phase 1.D — accept `mode` from Fortress rows (added by Lovable
251
+ // when the companion prompt deploys the schema column). Default to
252
+ // 'enforce' for backwards compat: existing Fortress instances without
253
+ // the `mode` column yield policies that enforce, as they always have.
254
+ const mode = p.mode ?? 'enforce';
255
+ if (!VALID_MODES.has(mode)) {
256
+ throw new Error(`unsupported mode "${mode}" (expected enforce|shadow)`);
257
+ }
212
258
  const out = {
213
259
  id: p.rule_id,
214
260
  name: p.name,
@@ -217,6 +263,7 @@ function compilePolicyFromFortress(p) {
217
263
  action: p.action,
218
264
  message: p.message,
219
265
  priority: p.priority ?? 100,
266
+ mode,
220
267
  };
221
268
  // Reuse the SAME ReDoS-safe compiler as the local JSON loader (rejects
222
269
  // catastrophic-backtracking patterns + over-long regexes). Previously this
@@ -9,6 +9,15 @@
9
9
  const API_BASE = 'https://api.anthropic.com';
10
10
  const BETA = 'managed-agents-2026-04-01';
11
11
  const VERSION = '2023-06-01';
12
+ // v1.1.2 F-16 (P2 Codex audit): hard cap on a single SSE frame buffer.
13
+ // A buggy upstream proxy that strips event separators OR a compromised
14
+ // Anthropic-style endpoint streaming bytes forever without "\n\n" would
15
+ // otherwise OOM Shield's host. 1 MB is far above any real Anthropic
16
+ // event payload (the heaviest events are agent.thinking + agent.message
17
+ // which carry at most a few hundred KB of text). On overflow we throw,
18
+ // which propagates through the generator and triggers the caller's
19
+ // reconnect logic — same outcome as a network error.
20
+ const MAX_SSE_FRAME_BYTES = 1 * 1024 * 1024;
12
21
 
13
22
  function authHeaders(apiKey) {
14
23
  return {
@@ -43,6 +52,14 @@ export async function* openEventStream({ apiKey, sessionId, signal }) {
43
52
  if (done) break;
44
53
  buffer += decoder.decode(value, { stream: true });
45
54
 
55
+ // v1.1.2 F-16: guard against an upstream that never emits "\n\n" —
56
+ // throw to abort the stream cleanly, the caller's reconnect logic
57
+ // will pick up. Drop the buffer to free memory before throwing.
58
+ if (buffer.length > MAX_SSE_FRAME_BYTES) {
59
+ buffer = '';
60
+ throw new Error(`SSE frame exceeded ${MAX_SSE_FRAME_BYTES} bytes — aborting stream (caller should reconnect)`);
61
+ }
62
+
46
63
  // SSE frames are separated by a blank line ("\n\n"). Each frame may
47
64
  // contain multiple lines; we only care about `data:` lines for now.
48
65
  let nlIdx;
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { request } from 'node:https';
19
19
  import { URLSearchParams } from 'node:url';
20
- import { Source, PROVIDERS, ENFORCEMENT_MODES, ACTION_TYPES } from './contract.js';
20
+ import { Source, PROVIDERS, ENFORCEMENT_MODES, ACTION_TYPES, COMPOSITION_PATTERNS } from './contract.js';
21
21
  import {
22
22
  getAgentConfig, detectAlwaysAsk,
23
23
  confirmAllow, confirmDeny, interruptSession,
@@ -29,6 +29,12 @@ const VERSION = '2023-06-01';
29
29
  // Hard cap on any single GET so a hung connection can't pin Watch/Shield
30
30
  // forever. getWithRetry will retry on timeout (the error propagates here).
31
31
  const REQUEST_TIMEOUT_MS = 30_000;
32
+ // v1.1.2 F-17 (P3 Codex audit): cap on a single Anthropic response body.
33
+ // Event history pages (/v1/sessions/{id}/events) can carry up to ~1000
34
+ // events × thousands of bytes each, so 16 MB is the headroom we leave
35
+ // before we conclude something is wrong. Above this we abort the
36
+ // request and getWithRetry will retry on the next attempt.
37
+ const MAX_ANTHROPIC_RESPONSE_BYTES = 16 * 1024 * 1024;
32
38
 
33
39
  function httpGet(apiKey, path) {
34
40
  return new Promise((resolve, reject) => {
@@ -43,8 +49,22 @@ function httpGet(apiKey, path) {
43
49
  },
44
50
  }, res => {
45
51
  const chunks = [];
46
- res.on('data', c => chunks.push(c));
52
+ let receivedBytes = 0;
53
+ let aborted = false;
54
+ res.on('data', c => {
55
+ if (aborted) return;
56
+ receivedBytes += c.length;
57
+ if (receivedBytes > MAX_ANTHROPIC_RESPONSE_BYTES) {
58
+ aborted = true;
59
+ chunks.length = 0;
60
+ try { req.destroy(); } catch { /* already destroyed */ }
61
+ reject(new Error(`Anthropic response exceeded ${MAX_ANTHROPIC_RESPONSE_BYTES} bytes — aborting (${path})`));
62
+ return;
63
+ }
64
+ chunks.push(c);
65
+ });
47
66
  res.on('end', () => {
67
+ if (aborted) return;
48
68
  const body = Buffer.concat(chunks).toString('utf8');
49
69
  if (res.statusCode >= 200 && res.statusCode < 300) {
50
70
  try { resolve(JSON.parse(body)); } catch (e) { reject(e); }
@@ -163,11 +183,54 @@ const RELEVANT_TYPES = [
163
183
 
164
184
  const tsMs = ev => Date.parse(ev.processed_at || ev.created_at || '') || null;
165
185
 
186
+ // v1.1.3 Phase 1.B — `fetchSessionEntries` is now a 3-line wrapper around
187
+ // the pure async transformer below. The HTTP layer (fetchRawEvents) is
188
+ // the only thing that needs the network — the per-event normalization
189
+ // logic is a pure async generator that any test can drive with synthetic
190
+ // event arrays. Same external contract; new internal seam for testing.
166
191
  export async function* fetchSessionEntries({ apiKey, agentId, sessionId, model }) {
192
+ yield* transformRawEventsToWMAActions(
193
+ fetchRawEvents(apiKey, sessionId),
194
+ { agentId, sessionId, model },
195
+ );
196
+ }
197
+
198
+ /**
199
+ * Pure async transformer: raw Anthropic events → WMAAction objects.
200
+ *
201
+ * Takes any async iterable of raw Anthropic events (live HTTP stream OR
202
+ * synthetic test fixture array) plus the routing metadata, yields one
203
+ * WMAAction per relevant event. Maintains all the cross-event state
204
+ * (pendingModelReq / pendingToolUse pairs, subThreadIds tracking, F-6a
205
+ * discriminators, F-8 end-of-session flush of unresolved tool calls).
206
+ *
207
+ * Why a separate function: keeps `fetchSessionEntries` HTTP-only and
208
+ * makes integration tests trivial — no network mocking required.
209
+ */
210
+ export async function* transformRawEventsToWMAActions(rawEventsAsyncIterable, { agentId, sessionId, model }) {
167
211
  // Pair-tracking maps: event_id of the "start" → its timestamp + metadata
168
212
  const pendingModelReq = new Map(); // span.model_request_start.id → ts
169
213
  const pendingToolUse = new Map(); // agent.tool_use.id → { ts, name, isMcp, input }
170
214
 
215
+ // v1.1.3 Phase 1.C — Anthropic sub-agent detection.
216
+ // Anthropic Task tool: when a parent agent delegates, a NEW thread is
217
+ // spawned within the parent's session (`session.thread_created` event).
218
+ // Subsequent events with that session_thread_id belong to the sub-agent.
219
+ // We track the set of "spawned" thread ids and, for each event, set
220
+ // composition_pattern='hierarchy' if it happened inside a sub-thread.
221
+ //
222
+ // Important Anthropic-specific design note: the sub-agent does NOT have
223
+ // a separate agent_id at the Anthropic API level — it runs inside the
224
+ // parent's session and its actions are attributed to the parent's
225
+ // agent_id. So `parent_agent_id` STAYS NULL for all events of this
226
+ // session (the sub-agent shares identity with the parent). Operators
227
+ // who want per-sub-agent visibility use the local NDJSON's
228
+ // `session_thread_id` + `agent_name` discriminators (captured since
229
+ // v1.0.2 F-6a) to differentiate. Other adapters (OpenAI handoffs,
230
+ // CrewAI manager, Hermes spawn_subagent) where sub-agents DO have
231
+ // distinct API-level IDs will populate parent_agent_id natively.
232
+ const subThreadIds = new Set();
233
+
171
234
  // `provider` is the canonical field per src/sources/contract.js (no
172
235
  // other consumer ever read the previous `framework` field, so it was
173
236
  // dropped in PR-B with zero downstream impact).
@@ -181,7 +244,7 @@ export async function* fetchSessionEntries({ apiKey, agentId, sessionId, model }
181
244
  // exact filterable set is undocumented & evolves. We pull everything and
182
245
  // filter here, ensuring future event types are surfaced rather than dropped.
183
246
  const RELEVANT = new Set(RELEVANT_TYPES);
184
- for await (const ev of fetchRawEvents(apiKey, sessionId)) {
247
+ for await (const ev of rawEventsAsyncIterable) {
185
248
  if (!RELEVANT.has(ev.type)) continue;
186
249
  const type = ev.type;
187
250
  const ts = ev.processed_at || ev.created_at || new Date().toISOString();
@@ -191,7 +254,21 @@ export async function* fetchSessionEntries({ apiKey, agentId, sessionId, model }
191
254
  // Preserved LOCALLY (NDJSON) only — never sent raw to Fortress.
192
255
  const session_thread_id = ev.session_thread_id ?? null;
193
256
  const agent_name = ev.agent_name ?? null;
194
- const subAgentMeta = { session_thread_id, agent_name };
257
+ // v1.1.3 Phase 1.C: derive composition_pattern at event level.
258
+ // Sub-thread events (session_thread_id registered in subThreadIds via
259
+ // a prior session.thread_created) → hierarchy. Root-thread events
260
+ // OR events without thread_id → solo. The aggregator in uploadSignals
261
+ // upgrades the AGENT-level composition_pattern to "hierarchy" when
262
+ // any event in the window is non-solo, so even pre-spawn root events
263
+ // contribute correctly to the agent's overall classification.
264
+ const inSubThread = session_thread_id != null && subThreadIds.has(session_thread_id);
265
+ const composition_pattern = inSubThread ? COMPOSITION_PATTERNS.HIERARCHY : COMPOSITION_PATTERNS.SOLO;
266
+ // parent_agent_id: see the design note at the top of fetchSessionEntries.
267
+ // Anthropic Task tool sub-agents share the parent's agent_id at the API
268
+ // level, so the field is null for ALL Anthropic events. Explicitly
269
+ // null (not undefined) so consumers see a documented value per WMAAction
270
+ // schema and downstream tests can assert it cleanly.
271
+ const subAgentMeta = { session_thread_id, agent_name, composition_pattern, parent_agent_id: null };
195
272
  const tsMillis = tsMs(ev);
196
273
 
197
274
  if (type === 'span.model_request_start') {
@@ -338,9 +415,24 @@ export async function* fetchSessionEntries({ apiKey, agentId, sessionId, model }
338
415
  const start = pendingToolUse.get(ev.tool_use_id);
339
416
  pendingToolUse.delete(ev.tool_use_id);
340
417
  const isError = ev.is_error === true;
418
+ // v1.1.3 Phase 1.B fix: the yielded action represents the TOOL CALL
419
+ // as a whole (input from start, output from result). Its discriminators
420
+ // (session_thread_id, agent_name, composition_pattern) should reflect
421
+ // the START's context — where the tool was INVOKED — not the result
422
+ // event's, which may or may not carry these fields depending on
423
+ // provider event-shape quirks. Falls back to the current event's
424
+ // subAgentMeta if the start somehow didn't capture them (defensive).
425
+ const pairedMeta = start ? {
426
+ session_thread_id: start.session_thread_id ?? subAgentMeta.session_thread_id,
427
+ agent_name: start.agent_name ?? subAgentMeta.agent_name,
428
+ composition_pattern: (start.session_thread_id != null && subThreadIds.has(start.session_thread_id))
429
+ ? COMPOSITION_PATTERNS.HIERARCHY
430
+ : COMPOSITION_PATTERNS.SOLO,
431
+ parent_agent_id: null, // see subAgentMeta — Anthropic shares parent's agent_id
432
+ } : subAgentMeta;
341
433
  yield {
342
434
  ...base,
343
- ...subAgentMeta,
435
+ ...pairedMeta,
344
436
  id: ev.id,
345
437
  action_type: start?.isMcp ? 'mcp_tool_use' : 'tool_use',
346
438
  tool_name: start?.name || 'unknown',
@@ -427,6 +519,14 @@ export async function* fetchSessionEntries({ apiKey, agentId, sessionId, model }
427
519
  }
428
520
 
429
521
  if (type === 'session.thread_created') {
522
+ // v1.1.3 Phase 1.C: register the newly-spawned thread as a sub-thread
523
+ // BEFORE yielding so subsequent events with this session_thread_id are
524
+ // correctly marked composition_pattern='hierarchy'. The thread_created
525
+ // event itself is yielded with the pattern computed before this line —
526
+ // it's the PARENT's act of spawning, so 'solo' is correct here (the
527
+ // parent did this act in its own context); only the events that
528
+ // FOLLOW inside the new thread get 'hierarchy'.
529
+ if (ev.session_thread_id) subThreadIds.add(ev.session_thread_id);
430
530
  yield {
431
531
  ...base,
432
532
  ...subAgentMeta,
@@ -567,7 +667,12 @@ function extractText(content) {
567
667
 
568
668
  export class AnthropicManagedSource extends Source {
569
669
  static providerName = PROVIDERS.ANTHROPIC_MANAGED;
570
- static enforcementMode = ENFORCEMENT_MODES.SYNC_CONFIRM;
670
+ // v1.1.3: renamed from `enforcementMode` the static field is the MAX
671
+ // capability the provider exposes; the EFFECTIVE per-agent mode is
672
+ // resolved at runtime via effectiveEnforcementMode() since v1.0.1 F-2.
673
+ // The `enforcementMode` getter inherited from Source.enforcementMode
674
+ // returns this value, so callers reading either name still work.
675
+ static enforcementCapability = ENFORCEMENT_MODES.SYNC_CONFIRM;
571
676
 
572
677
  constructor({ apiKey } = {}) {
573
678
  super({ apiKey });
@@ -29,26 +29,76 @@
29
29
  // Every WMAAction.action_type MUST be one of these. New adapters that
30
30
  // emit a novel kind of action should propose adding a new constant here
31
31
  // (and document it) rather than inventing one inline.
32
+ //
33
+ // Audit note (Phase 1.A, v1.1.3): the vocabulary is grouped into two
34
+ // tiers to make the contract honest about which types are framework-
35
+ // agnostic vs which originated from one specific framework (Anthropic
36
+ // Managed Agents was the seed adapter). New adapters can:
37
+ // 1. Use UNIVERSAL types directly — they're always meaningful.
38
+ // 2. Use FRAMEWORK-SPECIFIC types ONLY if the new framework has a
39
+ // genuinely equivalent concept. Otherwise emit a UNIVERSAL type
40
+ // (e.g., OpenAI handoffs → HANDOFF, NOT THREAD_MESSAGE_SENT).
41
+ // 3. Propose a new constant via a PR when their framework exposes a
42
+ // genuinely new category of event.
32
43
  export const ACTION_TYPES = Object.freeze({
44
+ // ── UNIVERSAL (every adapter should be able to emit these) ─────────────
45
+ /** Model inference call (with token usage + duration). */
33
46
  LLM_CALL: 'llm_call',
47
+ /** Provider-built-in tool invocation (web_search, web_fetch, bash, code_exec, …). */
34
48
  TOOL_USE: 'tool_use',
49
+ /** MCP server tool invocation. */
35
50
  MCP_TOOL_USE: 'mcp_tool_use',
51
+ /** Customer-defined tool invocation (the agent calls a tool the user wired). */
36
52
  CUSTOM_TOOL_USE: 'custom_tool_use',
53
+ /** Customer returned the result of a custom tool. */
37
54
  CUSTOM_TOOL_RESULT: 'custom_tool_result',
55
+ /** Human (or orchestrator) approved/denied a pending tool call. */
38
56
  TOOL_CONFIRMATION: 'tool_confirmation',
57
+ /** User sent input to the agent (prompt, follow-up question, …). */
39
58
  USER_MESSAGE: 'user_message',
59
+ /** User cancelled execution mid-flight. */
40
60
  USER_INTERRUPT: 'user_interrupt',
61
+ /** Agent emitted an output message (final reply or intermediate). */
41
62
  MESSAGE: 'message',
63
+ /** Agent emitted internal reasoning (extended thinking / scratchpad). */
42
64
  THINKING: 'thinking',
65
+ /** Session-level error from the provider runtime. */
66
+ SESSION_ERROR: 'session_error',
67
+ /** Agent A passes control to agent B (OpenAI Agents handoffs, AgentCore
68
+ * multi-agent, CrewAI manager delegation, Hermes spawn_subagent, LangGraph
69
+ * subgraph spawn). For Anthropic Task tool, this is typically emitted as
70
+ * THREAD_MESSAGE_SENT — both are valid for that framework. */
71
+ HANDOFF: 'handoff',
72
+ /** Graph-flavored frameworks (LangGraph, AutoGen state machine,
73
+ * conditional workflow engines) emit this when execution moves from one
74
+ * node/state to another. Carries `from_node`/`to_node` in `output`. */
75
+ GRAPH_NODE_TRANSITION: 'graph_node_transition',
76
+ /** Shield-internal — emitted when WMA itself blocks/allows/interrupts
77
+ * an action. Always carries the decision in `output`. */
78
+ SHIELD_DECISION: 'shield_decision',
79
+
80
+ // ── FRAMEWORK-SPECIFIC (Anthropic Managed Agents-origin, may map cleanly to
81
+ // other frameworks but were named after the Anthropic event vocabulary) ──
82
+ /** Anthropic-specific: emitted when the context window saturates and the
83
+ * thread is compacted (some history lost). OpenAI/CrewAI/LangGraph
84
+ * generally roll the window silently without an explicit event. */
43
85
  CONTEXT_COMPACTED: 'context_compacted',
86
+ /** Anthropic-specific: a sub-thread was spawned within a session (Task
87
+ * tool delegation). Other frameworks model this as HANDOFF + a new
88
+ * agent identity. */
44
89
  THREAD_CREATED: 'thread_created',
90
+ /** Anthropic-specific: inter-agent message in a thread (parent → sub-agent). */
45
91
  THREAD_MESSAGE_SENT: 'thread_message_sent',
92
+ /** Anthropic-specific: reply from a sub-agent in a thread (sub → parent). */
46
93
  THREAD_MESSAGE_RECEIVED: 'thread_message_received',
94
+ /** Anthropic-specific: session configuration changed mid-flight
95
+ * (`session.updated` event with a diff). Other frameworks generally
96
+ * don't expose live config edits as events. */
47
97
  CONFIG_CHANGE: 'config_change',
98
+ /** Anthropic-specific: session/thread lifecycle state change (running/
99
+ * idle/rescheduled/terminated). Other frameworks have different state
100
+ * machines; map cautiously. */
48
101
  STATE_TRANSITION: 'state_transition',
49
- SESSION_ERROR: 'session_error',
50
- // Shield-only — emitted when WMA itself blocks an action:
51
- SHIELD_DECISION: 'shield_decision',
52
102
  });
53
103
 
54
104
  export const STATUS_VALUES = Object.freeze({
@@ -185,19 +235,35 @@ export function validateWMAAction(obj) {
185
235
  * provider-agnostic.
186
236
  *
187
237
  * Static contract:
188
- * providerName — value from PROVIDERS
189
- * enforcementMode — value from ENFORCEMENT_MODES
238
+ * providerName — value from PROVIDERS
239
+ * enforcementCapability — value from ENFORCEMENT_MODES; the MAX the
240
+ * provider can do. The EFFECTIVE per-agent
241
+ * mode may be weaker (resolved at runtime
242
+ * via the provider's effectiveEnforcementMode
243
+ * helper; see AnthropicManagedSource for the
244
+ * reference impl).
245
+ *
246
+ * ⚠️ Renamed from `enforcementMode` in v1.1.3 because the previous
247
+ * name was misleading (a static field is the capability ceiling,
248
+ * not the runtime mode). `enforcementMode` is kept as a backward-
249
+ * compat alias for v1.x consumers; it returns the same value.
190
250
  *
191
251
  * Instance contract:
192
- * listAgents() — return all agents accessible with the client creds
193
- * streamEvents(id) — async generator yielding WMAAction objects
194
- * enforce(action, d) — only required if enforcementMode != detect_only
252
+ * listAgents() — return all agents accessible with the client creds
253
+ * streamEvents(id) — async generator yielding WMAAction objects
254
+ * enforce(action, d) — only required if enforcementCapability != detect_only
195
255
  *
196
256
  * See `docs/SOURCE-ADAPTER-CONTRACT.md` for the full author guide.
197
257
  */
198
258
  export class Source {
199
259
  static providerName = null;
200
- static enforcementMode = null;
260
+ static enforcementCapability = null;
261
+ // v1.1.3 backwards-compat alias for the renamed static field. Subclasses
262
+ // that set `enforcementMode` directly still work; new subclasses should
263
+ // set `enforcementCapability` instead. Resolved as a getter at read
264
+ // time so the rename can land without breaking consumers like
265
+ // assertImplementsSource() that read it from the class.
266
+ static get enforcementMode() { return this.enforcementCapability; }
201
267
 
202
268
  constructor(config = {}) {
203
269
  if (new.target === Source) {
@@ -237,7 +303,7 @@ export class Source {
237
303
  * @returns {Promise<{enforced: boolean, native_response?: object}>}
238
304
  */
239
305
  async enforce(action, decision) { // eslint-disable-line no-unused-vars
240
- if (this.constructor.enforcementMode === ENFORCEMENT_MODES.DETECT_ONLY) {
306
+ if (this.constructor.enforcementCapability === ENFORCEMENT_MODES.DETECT_ONLY) {
241
307
  throw new Error(`${this.constructor.name} is detect_only — enforce() must not be called`);
242
308
  }
243
309
  throw new Error(`${this.constructor.name}.enforce() not implemented`);
@@ -256,8 +322,11 @@ export function assertImplementsSource(SourceClass) {
256
322
  if (!Object.values(PROVIDERS).includes(SourceClass.providerName)) {
257
323
  throw new Error(`${SourceClass.name}.providerName="${SourceClass.providerName}" not in PROVIDERS`);
258
324
  }
259
- if (!Object.values(ENFORCEMENT_MODES).includes(SourceClass.enforcementMode)) {
260
- throw new Error(`${SourceClass.name}.enforcementMode="${SourceClass.enforcementMode}" not in ENFORCEMENT_MODES`);
325
+ // v1.1.3: read enforcementCapability (the new canonical name) but fall
326
+ // back to enforcementMode for legacy subclasses that haven't migrated.
327
+ const capability = SourceClass.enforcementCapability ?? SourceClass.enforcementMode;
328
+ if (!Object.values(ENFORCEMENT_MODES).includes(capability)) {
329
+ throw new Error(`${SourceClass.name}.enforcementCapability="${capability}" not in ENFORCEMENT_MODES`);
261
330
  }
262
331
  // The base class throws "not implemented" — a real subclass must override.
263
332
  for (const m of ['listAgents', 'streamEvents']) {
@@ -265,8 +334,8 @@ export function assertImplementsSource(SourceClass) {
265
334
  throw new Error(`${SourceClass.name}.${m}() must be overridden`);
266
335
  }
267
336
  }
268
- if (SourceClass.enforcementMode !== ENFORCEMENT_MODES.DETECT_ONLY
337
+ if (capability !== ENFORCEMENT_MODES.DETECT_ONLY
269
338
  && SourceClass.prototype.enforce === Source.prototype.enforce) {
270
- throw new Error(`${SourceClass.name}.enforce() must be overridden (enforcementMode=${SourceClass.enforcementMode})`);
339
+ throw new Error(`${SourceClass.name}.enforce() must be overridden (enforcementCapability=${capability})`);
271
340
  }
272
341
  }