watchmyagents 1.4.1 → 1.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -3
- package/package.json +1 -1
- package/scripts/fetch-anthropic.js +52 -8
- package/scripts/shield.js +145 -67
- package/src/anonymizer.js +5 -2
- package/src/fortress/url.js +77 -3
- package/src/shield/decisions.js +31 -8
- package/src/shield/policy.js +80 -9
- package/src/shield/signature.js +37 -4
- package/src/shield/sources/fortress.js +66 -6
- package/src/shield/upload.js +8 -0
- package/src/sources/anthropic-managed.js +113 -26
- package/src/sources/contract.js +17 -0
- package/src/sources/openai-agents-js.js +17 -1
- package/src/tokens.js +25 -10
- package/src/watch-state.js +56 -0
package/src/shield/policy.js
CHANGED
|
@@ -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
|
|
189
|
-
//
|
|
190
|
-
//
|
|
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
|
-
|
|
214
|
+
const v = asOrderedNumber(value);
|
|
215
|
+
return Number.isFinite(v) && v > condition.gt;
|
|
193
216
|
}
|
|
194
217
|
if (Number.isFinite(condition.gte)) {
|
|
195
|
-
|
|
218
|
+
const v = asOrderedNumber(value);
|
|
219
|
+
return Number.isFinite(v) && v >= condition.gte;
|
|
196
220
|
}
|
|
197
221
|
if (Number.isFinite(condition.lt)) {
|
|
198
|
-
|
|
222
|
+
const v = asOrderedNumber(value);
|
|
223
|
+
return Number.isFinite(v) && v < condition.lt;
|
|
199
224
|
}
|
|
200
225
|
if (Number.isFinite(condition.lte)) {
|
|
201
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/src/shield/signature.js
CHANGED
|
@@ -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
|
|
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')
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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) {
|
package/src/shield/upload.js
CHANGED
|
@@ -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
|
-
|
|
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',
|
|
@@ -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:
|
|
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
|
|
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;
|