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.
@@ -24,6 +24,7 @@
24
24
  // Field paths use dotted notation (`input.url`, `output.content.text`).
25
25
 
26
26
  import { readFile } from 'node:fs/promises';
27
+ import { TOOL_USE_FAMILY } from '../sources/contract.js';
27
28
 
28
29
  export async function loadPolicies(path) {
29
30
  const raw = await readFile(path, 'utf8');
@@ -165,6 +166,17 @@ function safeRegexTest(re, value) {
165
166
  return re.test(s);
166
167
  }
167
168
 
169
+ // v1.4.3 F-40: coerce ONLY a clean decimal-number string to a number, for the
170
+ // ordered numeric comparators. Anything else (real number → passthrough;
171
+ // hex/exponent/whitespace/Infinity/non-string → returned as-is so the caller's
172
+ // Number.isFinite check fails closed). Never used for equality or length_*.
173
+ const CLEAN_NUMERIC_STRING = /^-?\d+(\.\d+)?$/;
174
+ function asOrderedNumber(value) {
175
+ if (typeof value === 'number') return value;
176
+ if (typeof value === 'string' && CLEAN_NUMERIC_STRING.test(value)) return Number(value);
177
+ return value;
178
+ }
179
+
168
180
  function matchValue(value, condition) {
169
181
  // Literal scalar match
170
182
  if (condition === null || typeof condition !== 'object') {
@@ -185,20 +197,34 @@ function matchValue(value, condition) {
185
197
  return condition._regex_any.some(r => safeRegexTest(r, value));
186
198
  }
187
199
  // v1.2.0 — DSL extensions for ABAC / parameter validation.
188
- // Numeric comparators are strict: a non-finite VALUE or a non-finite
189
- // CONDITION operand fails-closed. Same as the regex branch: we never
190
- // coerce or guess intent for a malformed policy.
200
+ // Numeric comparators reject a non-finite CONDITION operand (malformed
201
+ // policy fail-closed).
202
+ //
203
+ // v1.4.3 F-40 (P2 audit) — the VALUE is run through asOrderedNumber():
204
+ // a real number passes through; a CLEAN decimal-number STRING (e.g. a tool
205
+ // that serializes `bytes` as "1500000") is coerced to its number. Why: a
206
+ // deny threshold like { gt: 1000000 } previously did NOT match a stringified
207
+ // value, so the rule silently no-matched and the action fell through to
208
+ // default-allow — a fail-OPEN on exfil-size / rate thresholds. Coercion is
209
+ // deliberately NARROW: only /^-?\d+(\.\d+)?$/ (no hex, exponent, whitespace,
210
+ // Infinity/NaN). This applies ONLY to the ORDERED comparators below.
211
+ // EQUALITY (literal / in / not_in) stays strict — "3" still never === 3 —
212
+ // and length_* (further down) still measures the raw string/array length.
191
213
  if (Number.isFinite(condition.gt)) {
192
- return Number.isFinite(value) && value > condition.gt;
214
+ const v = asOrderedNumber(value);
215
+ return Number.isFinite(v) && v > condition.gt;
193
216
  }
194
217
  if (Number.isFinite(condition.gte)) {
195
- return Number.isFinite(value) && value >= condition.gte;
218
+ const v = asOrderedNumber(value);
219
+ return Number.isFinite(v) && v >= condition.gte;
196
220
  }
197
221
  if (Number.isFinite(condition.lt)) {
198
- return Number.isFinite(value) && value < condition.lt;
222
+ const v = asOrderedNumber(value);
223
+ return Number.isFinite(v) && v < condition.lt;
199
224
  }
200
225
  if (Number.isFinite(condition.lte)) {
201
- return Number.isFinite(value) && value <= condition.lte;
226
+ const v = asOrderedNumber(value);
227
+ return Number.isFinite(v) && v <= condition.lte;
202
228
  }
203
229
  // in_range: tuple [min, max] inclusive both sides. An inverted tuple
204
230
  // (min > max) fails-closed — most likely an operator typo, never a
@@ -206,7 +232,8 @@ function matchValue(value, condition) {
206
232
  if (Array.isArray(condition.in_range) && condition.in_range.length === 2) {
207
233
  const [min, max] = condition.in_range;
208
234
  if (!Number.isFinite(min) || !Number.isFinite(max) || min > max) return false;
209
- return Number.isFinite(value) && value >= min && value <= max;
235
+ const v = asOrderedNumber(value);
236
+ return Number.isFinite(v) && v >= min && v <= max;
210
237
  }
211
238
  // length_* operators apply to strings or arrays. Anything else (object,
212
239
  // number, null) fails-closed — length is meaningless there.
@@ -247,11 +274,55 @@ export function matchesPolicy(event, policy, ctx = {}) {
247
274
  const value = field.startsWith('ctx.')
248
275
  ? getNested(ctx, field.slice(4))
249
276
  : getNested(event, field);
250
- if (!matchValue(value, condition)) return false;
277
+ // v1.4.2 F-38 (P0 audit): the action_type field gets tool-family
278
+ // expansion so a rule keyed on the generic `tool_use` catches MCP +
279
+ // custom tool calls too. Every other field uses plain matchValue.
280
+ const matched = field === 'action_type'
281
+ ? matchActionType(value, condition)
282
+ : matchValue(value, condition);
283
+ if (!matched) return false;
251
284
  }
252
285
  return true;
253
286
  }
254
287
 
288
+ // v1.4.2 F-38 (P0 audit) — action_type matching with tool-family expansion.
289
+ //
290
+ // Adapters emit three distinct tool-invocation action_types: `tool_use`
291
+ // (provider built-ins), `mcp_tool_use` (MCP servers), `custom_tool_use`
292
+ // (customer-wired tools). A policy that targets the GENERIC `tool_use` must
293
+ // match all three — otherwise a deny/allowlist rule silently misses MCP and
294
+ // custom tool calls (e.g. the Deep Researcher "only web_search/web_fetch,
295
+ // deny everything else" containment rule had a hole exactly there). The OpenAI
296
+ // adapter emits `custom_tool_use` for EVERY tool, so without this a
297
+ // `tool_use`-keyed rule matched nothing on that runtime at all.
298
+ //
299
+ // Expansion is one-directional and conservative: only the generic `tool_use`
300
+ // token expands to the family. A policy that names a SPECIFIC member
301
+ // (`mcp_tool_use` / `custom_tool_use`) stays an exact match, so an operator
302
+ // who deliberately wants to target one surface still can. Non-set conditions
303
+ // on action_type (regex, numeric — unusual but legal) fall back to matchValue.
304
+ function expandActionTypeTargets(target) {
305
+ return target === 'tool_use' ? TOOL_USE_FAMILY : [target];
306
+ }
307
+
308
+ function matchActionType(value, condition) {
309
+ if (condition === null || typeof condition !== 'object') {
310
+ return expandActionTypeTargets(condition).includes(value);
311
+ }
312
+ if (Array.isArray(condition)) {
313
+ return condition.flatMap(expandActionTypeTargets).includes(value);
314
+ }
315
+ if (condition.in !== undefined) {
316
+ return condition.in.flatMap(expandActionTypeTargets).includes(value);
317
+ }
318
+ if (condition.not_in !== undefined) {
319
+ return !condition.not_in.flatMap(expandActionTypeTargets).includes(value);
320
+ }
321
+ // regex / numeric / unknown shapes on action_type are unusual but legal —
322
+ // defer to the generic matcher (which fails-closed on unknown shapes).
323
+ return matchValue(value, condition);
324
+ }
325
+
255
326
  // First-match-wins evaluation. Returns the policy decision and metadata.
256
327
  // v1.2.0 — accepts an optional `ctx` for runtime-computed attributes;
257
328
  // see matchesPolicy() above. Omitting ctx preserves v1.1.x behavior.
@@ -50,14 +50,47 @@ import { createPublicKey, verify as cryptoVerify } from 'node:crypto';
50
50
 
51
51
  const POLICY_SIGNED_FIELDS = ['rule_id', 'match', 'action', 'message', 'priority', 'mode'];
52
52
 
53
- // Recursive deterministic JSON: sorted keys at every depth, arrays kept in order.
53
+ // Recursive deterministic JSON: sorted keys at every depth, arrays kept in
54
+ // order. This MUST mirror JSON.stringify's value semantics exactly — the only
55
+ // intentional difference is key sorting for determinism. The reason is
56
+ // load-bearing: the bytes we hash here have to equal the bytes that land on
57
+ // disk after JSON.stringify(record), because the verifier re-reads the disk
58
+ // record (already JSON round-tripped) and re-canonicalizes it.
59
+ //
60
+ // v1.4.2 F-39 (P0 audit) — the previous version emitted the literal token
61
+ // `undefined` for object keys / array elements whose value was `undefined`
62
+ // (or a function / symbol). JSON.stringify DROPS such object keys and renders
63
+ // such array elements as `null`. So a record like { tool_input: { max_uses:
64
+ // undefined } } hashed to `{"tool_input":{"max_uses":undefined}}` at write
65
+ // time but, after JSON.stringify dropped `max_uses` on disk, re-canonicalized
66
+ // to `{"tool_input":{}}` at verify time — chain_hash mismatch on an UNTAMPERED
67
+ // record. That broke the tamper-evidence guarantee (false alarms masking real
68
+ // tampering) and corrupted the Ed25519 signing domain the same way. The fix
69
+ // makes canonicalize JSON-faithful; for JSON-clean data it is byte-identical
70
+ // to the old behavior, so existing valid chains keep verifying.
54
71
  export function canonicalize(value) {
55
- if (value === null || typeof value !== 'object') return JSON.stringify(value);
72
+ if (value === null || typeof value !== 'object') {
73
+ // JSON.stringify(undefined | function | symbol) returns `undefined`
74
+ // (the JS value, not a string). Default those to `null` so we never
75
+ // concatenate the literal token `undefined` into the hashed string.
76
+ const enc = JSON.stringify(value);
77
+ return enc === undefined ? 'null' : enc;
78
+ }
56
79
  if (Array.isArray(value)) {
57
- return '[' + value.map(canonicalize).join(',') + ']';
80
+ // JSON renders undefined / function / symbol array elements as null —
81
+ // the primitive branch above already returns 'null' for them.
82
+ return '[' + value.map((v) => canonicalize(v)).join(',') + ']';
58
83
  }
84
+ // JSON.stringify omits object keys whose value is undefined / a function /
85
+ // a symbol. Mirror that, or the hashed string carries a key that vanishes
86
+ // on disk (the F-39 break).
59
87
  const keys = Object.keys(value).sort();
60
- const pairs = keys.map((k) => JSON.stringify(k) + ':' + canonicalize(value[k]));
88
+ const pairs = [];
89
+ for (const k of keys) {
90
+ const v = value[k];
91
+ if (v === undefined || typeof v === 'function' || typeof v === 'symbol') continue;
92
+ pairs.push(JSON.stringify(k) + ':' + canonicalize(v));
93
+ }
61
94
  return '{' + pairs.join(',') + '}';
62
95
  }
63
96
 
@@ -167,6 +167,18 @@ function strictModeFromEnv() {
167
167
  return true;
168
168
  }
169
169
 
170
+ // v1.4.2 F-48 (P2 audit) — what to do when a SUCCESSFUL refresh yields zero
171
+ // usable policies even though Fortress SENT some (i.e. every policy was
172
+ // dropped by signature verification or compile validation). 'closed' (the
173
+ // default, the right stance for a security control) refuses to install an
174
+ // empty allow-everything ruleset; 'open' preserves the pre-F-48 behavior
175
+ // (install default-allow) as an explicit opt-out.
176
+ function failModeFromEnv() {
177
+ const v = process.env.WMA_SHIELD_FAIL_MODE;
178
+ if (v === 'open') return 'open';
179
+ return 'closed'; // unset / anything else → fail-closed
180
+ }
181
+
170
182
  // Parse the embedded root pubkey ONCE at module load. If the file still
171
183
  // carries the placeholder, we DO NOT throw — but every refresh logs a
172
184
  // loud reminder so an unattended deploy can't silently trust a key whose
@@ -183,7 +195,7 @@ const ROOT_PUBLIC_KEY = (() => {
183
195
  })();
184
196
 
185
197
  export class FortressPolicySource {
186
- constructor({ apiKey, base, anthropicAgentId, refreshIntervalMs = 5 * 60_000, onError, onRefresh, requireSignedPolicies }) {
198
+ constructor({ apiKey, base, anthropicAgentId, refreshIntervalMs = 5 * 60_000, onError, onRefresh, requireSignedPolicies, failMode }) {
187
199
  if (!apiKey) throw new Error('FortressPolicySource: apiKey required');
188
200
  if (!base) throw new Error('FortressPolicySource: base URL required');
189
201
  this.apiKey = apiKey;
@@ -196,12 +208,18 @@ export class FortressPolicySource {
196
208
  this.lastFetchedAt = null;
197
209
  this._timer = null;
198
210
  this._aborted = false;
211
+ // v1.4.2 F-48: tracks whether this.ruleset came from a non-degraded
212
+ // refresh (so a later all-dropped refresh can retain last-known-good
213
+ // instead of wiping it) vs. is still the constructor's default-allow.
214
+ this._installedFromRefresh = false;
199
215
  // v1.1.5: per-instance override of the env var. Tests use this to
200
216
  // exercise both modes without touching process.env. If neither the
201
217
  // constructor option nor the env var is set, strict mode wins.
202
218
  this.requireSignedPolicies = requireSignedPolicies != null
203
219
  ? !!requireSignedPolicies
204
220
  : strictModeFromEnv();
221
+ // v1.4.2 F-48: 'closed' (default) | 'open'. Per-instance override for tests.
222
+ this.failMode = failMode != null ? failMode : failModeFromEnv();
205
223
  }
206
224
 
207
225
  /** Initial fetch — fails loud if it can't reach Fortress at startup. */
@@ -232,14 +250,20 @@ export class FortressPolicySource {
232
250
  return this._refresh();
233
251
  }
234
252
 
253
+ // v1.4.2 F-48: injectable fetch seam (tests override this to drive _refresh
254
+ // hermetically). Production hits the real Fortress endpoint.
255
+ async _fetchPolicies() {
256
+ return fetchPolicies({
257
+ apiKey: this.apiKey,
258
+ base: this.base,
259
+ anthropicAgentId: this.anthropicAgentId,
260
+ });
261
+ }
262
+
235
263
  async _refresh({ initial = false } = {}) {
236
264
  if (this._aborted) return;
237
265
  try {
238
- const { policies, signing_keys, fetched_at } = await fetchPolicies({
239
- apiKey: this.apiKey,
240
- base: this.base,
241
- anthropicAgentId: this.anthropicAgentId,
242
- });
266
+ const { policies, signing_keys, fetched_at } = await this._fetchPolicies();
243
267
 
244
268
  // v1.1.5 Phase 1.5 — verify the chain-of-trust BEFORE any other
245
269
  // processing. We must verify on the raw JSON shape sent by Fortress,
@@ -272,11 +296,47 @@ export class FortressPolicySource {
272
296
  // priorities) keep their relative order via the stable sort
273
297
  // guarantee in V8 — predictable behavior.
274
298
  compiled.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
299
+
300
+ // v1.4.2 F-48 (P2 audit): FAIL-CLOSED on a "successful" refresh that
301
+ // dropped EVERYTHING. If Fortress SENT policies (policies.length > 0)
302
+ // but none survived verification/compile (compiled.length === 0), an
303
+ // on-path attacker or a broken signing pipeline has effectively
304
+ // disarmed Shield — installing the empty { default: allow } ruleset
305
+ // here would silently allow every action while the daemon looks
306
+ // healthy. We distinguish this from a LEGITIMATELY empty response
307
+ // (operator cleared all policies → policies.length === 0 → install
308
+ // allow as intended).
309
+ const allDropped = policies.length > 0 && compiled.length === 0;
310
+ if (allDropped && this.failMode === 'closed') {
311
+ this.lastFetchedAt = fetched_at;
312
+ if (this._installedFromRefresh) {
313
+ // We have a last-known-good ruleset → KEEP it; do not overwrite
314
+ // with allow-everything. Protection continues on the prior rules.
315
+ this.onError(new Error(
316
+ `Fortress refresh: all ${policies.length} policies failed verification/compile — ` +
317
+ 'FAIL-CLOSED, retaining last-known-good ruleset (set WMA_SHIELD_FAIL_MODE=open to override).',
318
+ ));
319
+ this.onRefresh({ policies: this.ruleset.policies, fetched_at, initial, failClosed: true });
320
+ } else {
321
+ // No prior good ruleset (e.g. initial load) → cannot fall back to
322
+ // allow-everything for a security control. Install deny-by-default
323
+ // so unmatched actions are denied rather than waved through.
324
+ this.ruleset = { version: 1, policies: [], default: { action: 'deny' } };
325
+ this.onError(new Error(
326
+ `Fortress refresh: all ${policies.length} policies failed verification/compile and there is no ` +
327
+ 'prior ruleset — FAIL-CLOSED to deny-by-default (set WMA_SHIELD_FAIL_MODE=open to override).',
328
+ ));
329
+ this.onRefresh({ policies: [], fetched_at, initial, failClosed: true });
330
+ }
331
+ return;
332
+ }
333
+
275
334
  this.ruleset = {
276
335
  version: 1,
277
336
  policies: compiled,
278
337
  default: { action: 'allow' },
279
338
  };
339
+ this._installedFromRefresh = true;
280
340
  this.lastFetchedAt = fetched_at;
281
341
  this.onRefresh({ policies: compiled, fetched_at, initial });
282
342
  } catch (e) {
@@ -57,6 +57,12 @@ function pickInputForHash(input) {
57
57
  export function buildFortressDecisionPayload({
58
58
  agentId, sessionId, rawEvent, normalized, result, decidedInMs,
59
59
  signalsSalt, decidedAtIso,
60
+ // v1.4.2 F-44 (P1 audit): the real enforcement outcome (true delivered /
61
+ // false failed / undefined n/a). Lets the Fortress dashboard distinguish a
62
+ // confirmed block from one whose API call failed — i.e. surface degraded
63
+ // enforcement instead of trusting the computed verdict. Boolean only; no
64
+ // raw content, so Containment is unaffected.
65
+ enforcementDelivered,
60
66
  }) {
61
67
  // F-19: vendor built-ins survive even without a salt (allowlist short-circuit);
62
68
  // custom tool names throw without a salt — we catch and drop the field.
@@ -85,5 +91,7 @@ export function buildFortressDecisionPayload({
85
91
  // v1.1.3 Phase 1.D: mode threading so Fortress can store and surface
86
92
  // shadow-vs-enforce in the Reports timeline.
87
93
  mode: result.mode || undefined,
94
+ // v1.4.2 F-44: real enforcement outcome (omitted when not applicable).
95
+ enforcement_delivered: enforcementDelivered === undefined ? undefined : enforcementDelivered,
88
96
  };
89
97
  }
@@ -22,6 +22,7 @@ import {
22
22
  getAgentConfig, detectAlwaysAsk,
23
23
  confirmAllow, confirmDeny, interruptSession,
24
24
  } from '../shield/enforce.js';
25
+ import { safeNonNegInt } from '../tokens.js';
25
26
 
26
27
  const API_HOST = 'api.anthropic.com';
27
28
  const BETA = 'managed-agents-2026-04-01';
@@ -120,25 +121,48 @@ export async function listAgents(apiKey, { limit = 100 } = {}) {
120
121
  return agents;
121
122
  }
122
123
 
123
- export async function listSessions(apiKey, { agentId, since, limit = 100 } = {}) {
124
+ // v1.4.2 F-45 (P1 audit): page through the FULL session list and filter by
125
+ // `since` per-row — do NOT early-break. The previous version stopped
126
+ // paginating at the first session older than `since`, assuming the API
127
+ // returns sessions newest-first by created_at. Nothing enforces that ordering
128
+ // (no `order=` param is sent), and the doc never promised it. If the API
129
+ // ordered by id / updated_at, a single out-of-window session aborted
130
+ // pagination while newer in-window sessions sat on later pages — they were
131
+ // NEVER enumerated, so that agent's activity went untraced (a security blind
132
+ // spot, the exact failure this tool exists to prevent). We now scan every
133
+ // page; a session older than `since` is skipped, not a stop signal. A null
134
+ // created_at is treated as in-window (we cannot prove it is old). `maxPages`
135
+ // is a pathological-loop backstop; hitting it warns loudly rather than
136
+ // silently truncating.
137
+ export async function listSessions(apiKey, { agentId, since, limit = 100, maxPages = 1000, _fetch } = {}) {
138
+ // _fetch is an injectable page-fetcher seam for tests (path -> response
139
+ // object with { data, has_more }). Production uses the real getWithRetry.
140
+ const fetchPage = _fetch || ((path) => getWithRetry(apiKey, path));
124
141
  const sessions = [];
125
142
  let after = null;
126
- while (true) {
143
+ let pages = 0;
144
+ while (pages < maxPages) {
145
+ pages++;
127
146
  const qs = new URLSearchParams({ limit: String(limit) });
128
147
  if (agentId) qs.set('agent_id', agentId);
129
148
  if (after) qs.set('after_id', after);
130
- const data = await getWithRetry(apiKey, `/v1/sessions?${qs}`);
149
+ const data = await fetchPage(`/v1/sessions?${qs}`);
131
150
  const page = data.data || [];
132
- let stop = false;
133
151
  for (const s of page) {
134
152
  const created = s.created_at ? new Date(s.created_at) : null;
135
- if (since && created && created < since) { stop = true; break; }
153
+ if (since && created && created < since) continue; // skip, do NOT stop
136
154
  sessions.push(s);
137
155
  }
138
- if (stop || !data.has_more || page.length === 0) break;
156
+ if (!data.has_more || page.length === 0) break;
139
157
  after = page[page.length - 1]?.id;
140
158
  if (!after) break;
141
159
  }
160
+ if (pages >= maxPages) {
161
+ process.stderr.write(
162
+ `[wma] listSessions: hit maxPages=${maxPages} for agent ${agentId || '(all)'} — ` +
163
+ 'session enumeration may be INCOMPLETE. Raise maxPages or narrow the agent set.\n',
164
+ );
165
+ }
142
166
  return sessions;
143
167
  }
144
168
 
@@ -211,6 +235,16 @@ export async function* transformRawEventsToWMAActions(rawEventsAsyncIterable, {
211
235
  // Pair-tracking maps: event_id of the "start" → its timestamp + metadata
212
236
  const pendingModelReq = new Map(); // span.model_request_start.id → ts
213
237
  const pendingToolUse = new Map(); // agent.tool_use.id → { ts, name, isMcp, input }
238
+ // v1.4.2 F-50 (P2 audit): pair custom tools like provider/MCP tools so the
239
+ // single yielded row carries the REAL status/error/duration. Pre-F-50
240
+ // agent.custom_tool_use was emitted immediately with a hardcoded status:'ok'
241
+ // and its outcome arrived on a SEPARATE custom_tool_result row with
242
+ // tool_name:null — so a failing custom tool never entered error_rate_by_tool
243
+ // (the aggregator skips null-named rows) and duration was always null. The
244
+ // customer-controlled tool surface — the highest-risk one — was the only
245
+ // family with no error/latency signal. Pairing fixes that with no aggregator
246
+ // special-case (custom_tool_use is already in TOOL_ACTIONS).
247
+ const pendingCustomTool = new Map(); // agent.custom_tool_use.id → { ts, name, input, startTimestamp, meta }
214
248
 
215
249
  // v1.1.3 Phase 1.C — Anthropic sub-agent detection.
216
250
  // Anthropic Task tool: when a parent agent delegates, a NEW thread is
@@ -279,10 +313,13 @@ export async function* transformRawEventsToWMAActions(rawEventsAsyncIterable, {
279
313
  const startTs = pendingModelReq.get(ev.model_request_start_id);
280
314
  pendingModelReq.delete(ev.model_request_start_id);
281
315
  const u = ev.model_usage || {};
282
- const i = u.input_tokens || 0;
283
- const o = u.output_tokens || 0;
284
- const cr = u.cache_read_input_tokens || 0;
285
- const cw = u.cache_creation_input_tokens || 0;
316
+ // v1.4.2 F-49 (P2 audit): sanitize to non-negative integers. A
317
+ // malformed/adversarial model_usage (NaN, negative, string) must not
318
+ // zero-out or poison the token totals that drive cost/abuse detection.
319
+ const i = safeNonNegInt(u.input_tokens);
320
+ const o = safeNonNegInt(u.output_tokens);
321
+ const cr = safeNonNegInt(u.cache_read_input_tokens);
322
+ const cw = safeNonNegInt(u.cache_creation_input_tokens);
286
323
  yield {
287
324
  ...base,
288
325
  ...subAgentMeta,
@@ -351,16 +388,36 @@ export async function* transformRawEventsToWMAActions(rawEventsAsyncIterable, {
351
388
  }
352
389
 
353
390
  if (type === 'user.custom_tool_result') {
391
+ // v1.4.2 F-50: pair with the stored start and emit ONE completed
392
+ // custom_tool_use row carrying the real status/error/duration + the
393
+ // tool_name from the start (so a failing custom tool now lands in
394
+ // error_rate_by_tool). Mirrors the agent.tool_result handler. An
395
+ // orphan result (no matching start — out-of-order / restart) still
396
+ // emits, with tool_name null and input from the result only, so we
397
+ // never drop an action.
398
+ const cStart = pendingCustomTool.get(ev.custom_tool_use_id);
399
+ pendingCustomTool.delete(ev.custom_tool_use_id);
400
+ const isError = ev.is_error === true;
401
+ const cMeta = cStart ? {
402
+ session_thread_id: cStart.session_thread_id ?? subAgentMeta.session_thread_id,
403
+ agent_name: cStart.agent_name ?? subAgentMeta.agent_name,
404
+ composition_pattern: (cStart.session_thread_id != null && subThreadIds.has(cStart.session_thread_id))
405
+ ? COMPOSITION_PATTERNS.HIERARCHY
406
+ : COMPOSITION_PATTERNS.SOLO,
407
+ parent_agent_id: null,
408
+ } : subAgentMeta;
354
409
  yield {
355
410
  ...base,
356
- ...subAgentMeta,
411
+ ...cMeta,
357
412
  id: ev.id,
358
- action_type: 'custom_tool_result',
359
- tool_name: null,
413
+ action_type: 'custom_tool_use',
414
+ tool_name: cStart?.name ?? null,
360
415
  model: model || null,
361
416
  timestamp: ts,
362
- status: ev.is_error ? 'error' : 'ok',
363
- input: { custom_tool_use_id: ev.custom_tool_use_id },
417
+ duration_ms: (cStart?.ts && tsMillis) ? tsMillis - cStart.ts : null,
418
+ status: isError ? 'error' : 'ok',
419
+ error: isError ? extractText(ev.content).slice(0, 500) : null,
420
+ input: cStart?.input ?? { custom_tool_use_id: ev.custom_tool_use_id },
364
421
  output: { content: ev.content ?? null },
365
422
  };
366
423
  continue;
@@ -399,7 +456,16 @@ export async function* transformRawEventsToWMAActions(rawEventsAsyncIterable, {
399
456
  if (type === 'agent.tool_use' || type === 'agent.mcp_tool_use') {
400
457
  pendingToolUse.set(ev.id, {
401
458
  ts: tsMillis,
402
- name: ev.name || 'unknown',
459
+ // v1.4.2 F-41 (P1 audit): a missing tool name becomes null, NOT the
460
+ // literal string 'unknown'. A 'unknown' fallback (a) let a tool_name
461
+ // DENYLIST silently miss a real tool whose name field was absent
462
+ // (the rule keys on e.g. "bash"; "unknown" !== "bash" → no match →
463
+ // default-allow), and (b) collided two distinct unnamed tools into
464
+ // one forensic bucket. null fails every specific tool_name clause
465
+ // closed and is honestly absent. tool_name is `string|null` per the
466
+ // WMAAction contract; normalizeToolName(null) returns null and the
467
+ // aggregator skips null-named entries from per-tool counts.
468
+ name: ev.name ?? null,
403
469
  isMcp: type === 'agent.mcp_tool_use',
404
470
  input: ev.input ?? null,
405
471
  mcpServer: ev.server_name ?? ev.mcp_server_name ?? null,
@@ -435,7 +501,7 @@ export async function* transformRawEventsToWMAActions(rawEventsAsyncIterable, {
435
501
  ...pairedMeta,
436
502
  id: ev.id,
437
503
  action_type: start?.isMcp ? 'mcp_tool_use' : 'tool_use',
438
- tool_name: start?.name || 'unknown',
504
+ tool_name: start?.name ?? null, // F-41: null, never literal 'unknown'
439
505
  timestamp: ts,
440
506
  duration_ms: (start?.ts && tsMillis) ? tsMillis - start.ts : null,
441
507
  status: isError ? 'error' : 'ok',
@@ -447,16 +513,15 @@ export async function* transformRawEventsToWMAActions(rawEventsAsyncIterable, {
447
513
  }
448
514
 
449
515
  if (type === 'agent.custom_tool_use') {
450
- yield {
451
- ...base,
452
- ...subAgentMeta,
453
- id: ev.id,
454
- action_type: 'custom_tool_use',
455
- tool_name: ev.name || 'unknown',
456
- timestamp: ts,
457
- status: 'ok',
516
+ // v1.4.2 F-50: store the start; the paired custom_tool_result (or the
517
+ // end-of-session flush) emits the single completed row with real status.
518
+ pendingCustomTool.set(ev.id, {
519
+ ts: tsMillis,
520
+ name: ev.name ?? null, // F-41: null, never literal 'unknown'
458
521
  input: ev.input ?? null,
459
- };
522
+ startTimestamp: ts,
523
+ session_thread_id, agent_name,
524
+ });
460
525
  continue;
461
526
  }
462
527
 
@@ -619,6 +684,28 @@ export async function* transformRawEventsToWMAActions(rawEventsAsyncIterable, {
619
684
  };
620
685
  }
621
686
  pendingToolUse.clear();
687
+
688
+ // v1.4.2 F-50: same flush for custom tools that started but never returned
689
+ // a result (Shield-blocked, denied, died mid-flight, session cut). Emitted
690
+ // as custom_tool_use / status=error / no_result_observed — the high-value
691
+ // "started but didn't complete" signal, now also covered for custom tools.
692
+ for (const [useId, pending] of pendingCustomTool) {
693
+ yield {
694
+ ...base,
695
+ session_thread_id: pending.session_thread_id,
696
+ agent_name: pending.agent_name,
697
+ id: useId,
698
+ action_type: 'custom_tool_use',
699
+ tool_name: pending.name,
700
+ model: model || null,
701
+ timestamp: pending.startTimestamp,
702
+ duration_ms: null,
703
+ status: 'error',
704
+ error: 'no_result_observed',
705
+ input: pending.input,
706
+ };
707
+ }
708
+ pendingCustomTool.clear();
622
709
  }
623
710
 
624
711
  // ────────────────────────────────────────────────────────────────────────
@@ -101,6 +101,23 @@ export const ACTION_TYPES = Object.freeze({
101
101
  STATE_TRANSITION: 'state_transition',
102
102
  });
103
103
 
104
+ // v1.4.2 F-38 (P0 audit) — the three tool-INVOCATION action_types form one
105
+ // security-equivalent family. WHICH surface a tool call came through
106
+ // (provider built-in vs MCP server vs customer-wired custom tool) is an
107
+ // adapter implementation detail, not a security distinction: all three are
108
+ // "the agent invoked a tool." A deny/allowlist policy keyed on the generic
109
+ // `tool_use` MUST catch all three, or it silently misses MCP + custom tool
110
+ // calls — exactly where an attacker pivots. The policy matcher
111
+ // (src/shield/policy.js) expands a generic `tool_use` target to this family;
112
+ // matching a SPECIFIC member (mcp_tool_use / custom_tool_use) stays exact so
113
+ // an operator can still target one surface when they mean to.
114
+ // SignalsAggregator (src/anonymizer.js) uses the same set for IoC capture.
115
+ export const TOOL_USE_FAMILY = Object.freeze([
116
+ ACTION_TYPES.TOOL_USE,
117
+ ACTION_TYPES.MCP_TOOL_USE,
118
+ ACTION_TYPES.CUSTOM_TOOL_USE,
119
+ ]);
120
+
104
121
  export const STATUS_VALUES = Object.freeze({
105
122
  OK: 'ok',
106
123
  ERROR: 'error',
@@ -549,10 +549,26 @@ export function wmaToolInputGuardrail(options = {}) {
549
549
  try {
550
550
  const rs = await ensureRuleset();
551
551
 
552
+ // v1.4.2 F-42 (P1 audit): read the tool name DEFENSIVELY across the
553
+ // shapes @openai/agents may use for the guardrail's toolCall object.
554
+ // The adapter already knows the id lives under a non-obvious key
555
+ // (makeEventId reads callId||id), so the name may likewise sit under
556
+ // .name, .toolName, or .function.name depending on SDK version/tool
557
+ // kind. If we only read .name and the SDK puts it elsewhere, tool_name
558
+ // is null and EVERY tool_name-keyed deny rule silently misses — a
559
+ // total enforcement bypass. Coalesce all known shapes; null only if
560
+ // none are present (then tool_name-keyed rules correctly no-match,
561
+ // and the operator can still deny on action_type alone).
562
+ const toolName = toolCall?.name
563
+ ?? toolCall?.toolName
564
+ ?? toolCall?.function?.name
565
+ ?? data?.tool?.name
566
+ ?? null;
567
+
552
568
  // 1. Normalize the about-to-fire tool_use event.
553
569
  const event = normalizeToolStart({
554
570
  agent,
555
- tool: { name: toolCall?.name },
571
+ tool: { name: toolName },
556
572
  toolCall,
557
573
  sessionId,
558
574
  teamId: getTeamId(),
package/src/tokens.js CHANGED
@@ -4,14 +4,26 @@
4
4
  // token counts (split + by-model) are tracked.
5
5
  export const DEFAULT_PRICING = {};
6
6
 
7
+ // v1.4.2 F-49 (P2 audit): coerce a token/usage value to a safe non-negative
8
+ // integer. The old `x || 0` is a FALSY guard, not a numeric one — it lets
9
+ // negatives through (`-5 || 0` === -5) and a NaN propagates through later
10
+ // arithmetic to poison totals (`NaN + n` → NaN → `|| null` → null), silently
11
+ // ZEROING a real LLM call's tokens. A malformed or adversarial usage payload
12
+ // could thus under-report the security-relevant consumption signal (a runaway
13
+ // / abused agent) or drive aggregate token/cost negative. Anything not a
14
+ // finite non-negative number becomes 0.
15
+ export function safeNonNegInt(x) {
16
+ return Number.isFinite(x) && x >= 0 ? Math.trunc(x) : 0;
17
+ }
18
+
7
19
  export function estimateCost(model, t, pricing) {
8
20
  if (!model) return null;
9
21
  const p = (pricing && pricing[model]) || DEFAULT_PRICING[model];
10
22
  if (!p) return null;
11
- const inT = t.input_tokens || 0;
12
- const outT = t.output_tokens || 0;
13
- const cr = t.cache_read_tokens || 0;
14
- const cw = t.cache_creation_tokens || 0;
23
+ const inT = safeNonNegInt(t.input_tokens);
24
+ const outT = safeNonNegInt(t.output_tokens);
25
+ const cr = safeNonNegInt(t.cache_read_tokens);
26
+ const cw = safeNonNegInt(t.cache_creation_tokens);
15
27
  const cost = ((inT * (p.input || 0)) + (outT * (p.output || 0)) +
16
28
  (cr * (p.cache_read || 0)) + (cw * (p.cache_write || 0))) / 1_000_000;
17
29
  return Math.round(cost * 1_000_000) / 1_000_000;
@@ -38,13 +50,16 @@ export class TokenTracker {
38
50
  if (entry.action_type === 'session_end') return;
39
51
 
40
52
  const t = {
41
- input: entry.input_tokens || 0,
42
- output: entry.output_tokens || 0,
43
- cache_read: entry.cache_read_tokens || 0,
44
- cache_creation: entry.cache_creation_tokens || 0,
53
+ input: safeNonNegInt(entry.input_tokens),
54
+ output: safeNonNegInt(entry.output_tokens),
55
+ cache_read: safeNonNegInt(entry.cache_read_tokens),
56
+ cache_creation: safeNonNegInt(entry.cache_creation_tokens),
45
57
  };
46
- const sum = entry.tokens_used || (t.input + t.output + t.cache_read + t.cache_creation);
47
- const cost = entry.cost_usd || 0;
58
+ // F-49: a provided tokens_used is sanitized too; 0/missing falls back to
59
+ // the (sanitized) component sum.
60
+ const provided = safeNonNegInt(entry.tokens_used);
61
+ const sum = provided || (t.input + t.output + t.cache_read + t.cache_creation);
62
+ const cost = Number.isFinite(entry.cost_usd) && entry.cost_usd >= 0 ? entry.cost_usd : 0;
48
63
 
49
64
  this.total.input += t.input;
50
65
  this.total.output += t.output;