watchmyagents 1.4.0 → 1.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -4
- package/SECURITY.md +2 -1
- package/package.json +1 -1
- package/scripts/fetch-anthropic.js +71 -11
- package/scripts/service.js +26 -3
- package/scripts/shield.js +145 -67
- package/src/anonymizer.js +5 -2
- package/src/fortress/url.js +77 -3
- package/src/logger.js +8 -0
- package/src/shield/decisions.js +31 -8
- package/src/shield/policy.js +80 -9
- package/src/shield/signature.js +37 -4
- package/src/shield/sources/fortress.js +66 -6
- package/src/shield/upload.js +8 -0
- package/src/sources/anthropic-managed.js +113 -26
- package/src/sources/contract.js +17 -0
- package/src/sources/openai-agents-js.js +76 -16
- package/src/tokens.js +25 -10
- package/src/watch-state.js +56 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
153
|
+
if (since && created && created < since) continue; // skip, do NOT stop
|
|
136
154
|
sessions.push(s);
|
|
137
155
|
}
|
|
138
|
-
if (
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
const
|
|
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
|
-
...
|
|
411
|
+
...cMeta,
|
|
357
412
|
id: ev.id,
|
|
358
|
-
action_type: '
|
|
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
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
// ────────────────────────────────────────────────────────────────────────
|
package/src/sources/contract.js
CHANGED
|
@@ -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',
|
|
@@ -116,24 +116,48 @@ function readEnv(key, fallback) {
|
|
|
116
116
|
// Policies that match on tool_name still work; policies that match on
|
|
117
117
|
// argument values silently miss the truncated suffix — which is the
|
|
118
118
|
// correct fail-closed behavior for an oversize input.
|
|
119
|
+
// v1.4.1 F-36 (P3 Codex audit on v1.4.0): proper byte-level truncation
|
|
120
|
+
// for UTF-8 strings. Pre-v1.4.1 we used `str.slice(0, maxBytes)`, which
|
|
121
|
+
// slices by CHARACTERS — with multi-byte Unicode (emoji = 4 bytes per
|
|
122
|
+
// char) the cap could be exceeded by up to 3x. This helper cuts on the
|
|
123
|
+
// byte boundary via Buffer and strips the trailing U+FFFD replacement
|
|
124
|
+
// character if the cut landed mid-sequence. We accept the dropped
|
|
125
|
+
// trailing char as acceptable cost: the result is marked _wmaTruncated.
|
|
126
|
+
function truncateUtf8(str, maxBytes) {
|
|
127
|
+
if (typeof str !== 'string') return str;
|
|
128
|
+
const buf = Buffer.from(str, 'utf8');
|
|
129
|
+
if (buf.length <= maxBytes) return str;
|
|
130
|
+
return buf.subarray(0, maxBytes).toString('utf8').replace(/�+$/, '');
|
|
131
|
+
}
|
|
132
|
+
|
|
119
133
|
function safeParseToolArgs(rawArgs, maxBytes = DEFAULT_MAX_ARG_BYTES) {
|
|
120
134
|
if (rawArgs == null) return null;
|
|
121
135
|
if (typeof rawArgs === 'object') {
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
136
|
+
// v1.4.1 F-36 — also cap object inputs by their serialized byte
|
|
137
|
+
// size. Pre-v1.4.1 already-parsed objects bypassed the cap entirely,
|
|
138
|
+
// which let a misbehaving tool / customer pass a 5 MB object through
|
|
139
|
+
// safeParseToolArgs unimpeded. We don't deep-walk-and-truncate (that
|
|
140
|
+
// would silently change policy match semantics on nested fields);
|
|
141
|
+
// instead we replace the whole input with a truncation marker if
|
|
142
|
+
// the serialized form exceeds maxBytes.
|
|
143
|
+
try {
|
|
144
|
+
const serialized = JSON.stringify(rawArgs);
|
|
145
|
+
const byteLen = Buffer.byteLength(serialized, 'utf8');
|
|
146
|
+
if (byteLen > maxBytes) {
|
|
147
|
+
return { _wmaTruncated: true, _wmaOriginalBytes: byteLen };
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
// Unserializable input — return as-is. Policy match against
|
|
151
|
+
// anything in this object is going to fail anyway.
|
|
152
|
+
}
|
|
127
153
|
return rawArgs;
|
|
128
154
|
}
|
|
129
155
|
if (typeof rawArgs !== 'string') return null;
|
|
130
156
|
// Byte cap: Buffer.byteLength is the safe length on the wire.
|
|
131
157
|
const byteLen = Buffer.byteLength(rawArgs, 'utf8');
|
|
132
158
|
if (byteLen > maxBytes) {
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
// the result as truncated and never feed it back to a real tool).
|
|
136
|
-
const head = rawArgs.slice(0, maxBytes);
|
|
159
|
+
// v1.4.1 F-36 — byte-level truncation (was char-level before).
|
|
160
|
+
const head = truncateUtf8(rawArgs, maxBytes);
|
|
137
161
|
try {
|
|
138
162
|
const parsed = JSON.parse(head);
|
|
139
163
|
// If by luck the head is valid JSON, return it plus the marker.
|
|
@@ -374,8 +398,9 @@ function truncateResult(result, maxBytes = DEFAULT_MAX_RESULT_BYTES) {
|
|
|
374
398
|
if (typeof result === 'string') {
|
|
375
399
|
const byteLen = Buffer.byteLength(result, 'utf8');
|
|
376
400
|
if (byteLen > maxBytes) {
|
|
401
|
+
// v1.4.1 F-36 — byte-level truncation (was char-level before).
|
|
377
402
|
return {
|
|
378
|
-
text: result
|
|
403
|
+
text: truncateUtf8(result, maxBytes) + TRUNCATION_SENTINEL,
|
|
379
404
|
_wmaTruncated: true,
|
|
380
405
|
_wmaOriginalBytes: byteLen,
|
|
381
406
|
};
|
|
@@ -445,6 +470,23 @@ export function normalizeToolEnd({ agent, tool, result, toolCall, sessionId, tea
|
|
|
445
470
|
// policy ruleset on first invocation if `policiesPath` is set.
|
|
446
471
|
|
|
447
472
|
export function wmaToolInputGuardrail(options = {}) {
|
|
473
|
+
// v1.4.1 F-35 (P2 Codex audit on v1.4.0): fail-loud at construction
|
|
474
|
+
// time when nothing safety-relevant is configured. Same intent as the
|
|
475
|
+
// openaiAgents() factory's mode:'enforce' check, but applied to the
|
|
476
|
+
// lower-level entry point too — defense in depth. Customers using the
|
|
477
|
+
// direct import path no longer get a silent "allow all" surprise.
|
|
478
|
+
const hasPolicySource =
|
|
479
|
+
options.policiesPath != null ||
|
|
480
|
+
options.ruleset != null ||
|
|
481
|
+
options.allowAllWhenUnconfigured === true;
|
|
482
|
+
if (!hasPolicySource) {
|
|
483
|
+
throw new Error(
|
|
484
|
+
'wmaToolInputGuardrail: no policy configured. Pass policiesPath or ruleset. ' +
|
|
485
|
+
'For demos / smoke tests that intentionally allow all tool calls, pass ' +
|
|
486
|
+
'{ allowAllWhenUnconfigured: true } explicitly.',
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
448
490
|
const failOpen = options.failOpen === true ? true : DEFAULT_FAIL_OPEN;
|
|
449
491
|
const sessionId = options.sessionId || makeSessionId();
|
|
450
492
|
const logDir = options.logDir
|
|
@@ -480,14 +522,16 @@ export function wmaToolInputGuardrail(options = {}) {
|
|
|
480
522
|
ruleset = await loadPolicies(options.policiesPath);
|
|
481
523
|
return ruleset;
|
|
482
524
|
}
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
//
|
|
525
|
+
// v1.4.1 F-35: only reachable when the customer passed
|
|
526
|
+
// `allowAllWhenUnconfigured: true` — the synchronous check at
|
|
527
|
+
// construction time has already rejected every other un-configured
|
|
528
|
+
// shape. Log a loud warning once so demo deployments don't quietly
|
|
529
|
+
// ship without a real ruleset.
|
|
486
530
|
if (!ensureRuleset._warned) {
|
|
487
531
|
ensureRuleset._warned = true;
|
|
488
532
|
process.stderr.write(
|
|
489
|
-
'[wma/openai-agents]
|
|
490
|
-
'
|
|
533
|
+
'[wma/openai-agents] allowAllWhenUnconfigured=true — guardrail will allow ALL tool calls. ' +
|
|
534
|
+
'This is intended for demos only; ship with a real ruleset in production.\n',
|
|
491
535
|
);
|
|
492
536
|
}
|
|
493
537
|
ruleset = { policies: [], default: { action: 'allow' } };
|
|
@@ -505,10 +549,26 @@ export function wmaToolInputGuardrail(options = {}) {
|
|
|
505
549
|
try {
|
|
506
550
|
const rs = await ensureRuleset();
|
|
507
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
|
+
|
|
508
568
|
// 1. Normalize the about-to-fire tool_use event.
|
|
509
569
|
const event = normalizeToolStart({
|
|
510
570
|
agent,
|
|
511
|
-
tool: { name:
|
|
571
|
+
tool: { name: toolName },
|
|
512
572
|
toolCall,
|
|
513
573
|
sessionId,
|
|
514
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
|
|
12
|
-
const outT = t.output_tokens
|
|
13
|
-
const cr = t.cache_read_tokens
|
|
14
|
-
const cw = t.cache_creation_tokens
|
|
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
|
|
42
|
-
output: entry.output_tokens
|
|
43
|
-
cache_read: entry.cache_read_tokens
|
|
44
|
-
cache_creation: entry.cache_creation_tokens
|
|
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
|
-
|
|
47
|
-
|
|
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;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// v1.4.3 F-51 (P2 audit) — bounded dedup state for the long-running watch loop.
|
|
2
|
+
//
|
|
3
|
+
// The watch daemon dedupes events by their stable Anthropic event id so a
|
|
4
|
+
// re-fetch of an already-captured session doesn't double-write the NDJSON.
|
|
5
|
+
// Pre-F-51 every id ever seen lived in a single Set that was only ever added
|
|
6
|
+
// to — on a 24/7 daemon at high event volume that Set grows without bound
|
|
7
|
+
// (hundreds of MB within days), and the daemon eventually OOM-kills and stops
|
|
8
|
+
// collecting (a silent monitoring gap).
|
|
9
|
+
//
|
|
10
|
+
// SeenTracker bounds runtime growth WITHOUT weakening dedup:
|
|
11
|
+
// - Preloaded ids (read from on-disk NDJSON at startup) sit in a static set,
|
|
12
|
+
// loaded once. Bounded by what the operator keeps on disk.
|
|
13
|
+
// - Runtime-discovered ids are tracked PER SESSION. When a session
|
|
14
|
+
// terminates it is never re-fetched, so its id set is dropped. Live memory
|
|
15
|
+
// is therefore bounded by ACTIVE sessions, not lifetime event volume.
|
|
16
|
+
//
|
|
17
|
+
// Correctness: Anthropic event ids are globally unique and an event belongs to
|
|
18
|
+
// exactly one session, so checking the preloaded set + that session's set is
|
|
19
|
+
// equivalent to the old global-set membership test (no missed dedup, no
|
|
20
|
+
// missed events).
|
|
21
|
+
export class SeenTracker {
|
|
22
|
+
constructor(preloadedIds = []) {
|
|
23
|
+
this._preloaded = new Set(preloadedIds);
|
|
24
|
+
this._bySession = new Map(); // sessionId -> Set<eventId>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
has(sessionId, eventId) {
|
|
28
|
+
if (this._preloaded.has(eventId)) return true;
|
|
29
|
+
const s = this._bySession.get(sessionId);
|
|
30
|
+
return s ? s.has(eventId) : false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
add(sessionId, eventId) {
|
|
34
|
+
let s = this._bySession.get(sessionId);
|
|
35
|
+
if (!s) { s = new Set(); this._bySession.set(sessionId, s); }
|
|
36
|
+
s.add(eventId);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Drop a terminated session's runtime id set — it will never be re-fetched,
|
|
40
|
+
// so its ids no longer need to be remembered for dedup.
|
|
41
|
+
forgetSession(sessionId) {
|
|
42
|
+
this._bySession.delete(sessionId);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Total tracked ids (preloaded + all live per-session sets). For observability.
|
|
46
|
+
get size() {
|
|
47
|
+
let n = this._preloaded.size;
|
|
48
|
+
for (const s of this._bySession.values()) n += s.size;
|
|
49
|
+
return n;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Number of sessions currently holding a runtime id set.
|
|
53
|
+
get sessionCount() {
|
|
54
|
+
return this._bySession.size;
|
|
55
|
+
}
|
|
56
|
+
}
|