watchmyagents 1.4.1 → 1.4.4
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 +73 -10
- 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 +137 -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 +65 -0
package/src/shield/decisions.js
CHANGED
|
@@ -52,6 +52,16 @@ export class DecisionLogger {
|
|
|
52
52
|
message,
|
|
53
53
|
decidedInMs,
|
|
54
54
|
mode, // v1.1.3 Phase 1.D: 'enforce' | 'shadow' (default 'enforce' if absent)
|
|
55
|
+
// v1.4.2 F-44 (P1 audit): the ACTUAL outcome of the enforcement API call,
|
|
56
|
+
// captured by the caller AFTER it attempts confirmDeny / interruptSession.
|
|
57
|
+
// true → the block/interrupt landed (confirmed delivered)
|
|
58
|
+
// false → the call FAILED after retries; the violating action was
|
|
59
|
+
// NOT blocked on the wire (silent fail-open if we lied here)
|
|
60
|
+
// undefined → not applicable (allow / shadow / non-enforcing caller).
|
|
61
|
+
// Pre-F-44 the row claimed "enforced" purely from mode+decision, before
|
|
62
|
+
// (and regardless of) the API call — so the tamper-evident audit chain
|
|
63
|
+
// could assert a block that never happened.
|
|
64
|
+
enforcementDelivered,
|
|
55
65
|
}) {
|
|
56
66
|
// In shadow mode the decision is computed and logged but NOT enforced.
|
|
57
67
|
// status must reflect what actually happened on the wire: shadow + deny
|
|
@@ -61,25 +71,38 @@ export class DecisionLogger {
|
|
|
61
71
|
const effectiveMode = mode || 'enforce';
|
|
62
72
|
const enforced = effectiveMode === 'enforce'
|
|
63
73
|
&& (decision === 'deny' || decision === 'interrupt');
|
|
74
|
+
// F-44: enforcement was ATTEMPTED but FAILED → the action was NOT blocked.
|
|
75
|
+
// Surface that honestly instead of recording a clean block.
|
|
76
|
+
const failedDelivery = enforced && enforcementDelivered === false;
|
|
77
|
+
|
|
78
|
+
const output = {
|
|
79
|
+
decision,
|
|
80
|
+
rule_id: ruleId,
|
|
81
|
+
rule_name: ruleName,
|
|
82
|
+
message,
|
|
83
|
+
mode: effectiveMode,
|
|
84
|
+
};
|
|
85
|
+
// Only add the field when enforcement was actually attempted, so allow /
|
|
86
|
+
// shadow / pre-F-44 callers keep their exact record shape.
|
|
87
|
+
if (enforcementDelivered !== undefined) {
|
|
88
|
+
output.enforcement_delivered = enforcementDelivered;
|
|
89
|
+
}
|
|
90
|
+
|
|
64
91
|
return this._logger.write({
|
|
65
92
|
action_type: 'shield_decision',
|
|
66
93
|
provider: this._provider,
|
|
67
94
|
tool_name: sourceEvent?.name || sourceEvent?.tool_name || null,
|
|
68
95
|
status: enforced ? 'error' : 'ok',
|
|
69
|
-
error:
|
|
96
|
+
error: failedDelivery
|
|
97
|
+
? `ENFORCEMENT FAILED (action NOT blocked): ${message || ruleName || 'policy violation'}`
|
|
98
|
+
: (enforced ? message : null),
|
|
70
99
|
duration_ms: decidedInMs ?? null,
|
|
71
100
|
input: {
|
|
72
101
|
source_event_id: sourceEvent?.id || null,
|
|
73
102
|
source_event_type: sourceEvent?.type || null,
|
|
74
103
|
tool_input: sourceEvent?.input ?? null,
|
|
75
104
|
},
|
|
76
|
-
output
|
|
77
|
-
decision,
|
|
78
|
-
rule_id: ruleId,
|
|
79
|
-
rule_name: ruleName,
|
|
80
|
-
message,
|
|
81
|
-
mode: effectiveMode,
|
|
82
|
-
},
|
|
105
|
+
output,
|
|
83
106
|
});
|
|
84
107
|
}
|
|
85
108
|
}
|
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
|
|
@@ -231,6 +265,18 @@ export async function* transformRawEventsToWMAActions(rawEventsAsyncIterable, {
|
|
|
231
265
|
// distinct API-level IDs will populate parent_agent_id natively.
|
|
232
266
|
const subThreadIds = new Set();
|
|
233
267
|
|
|
268
|
+
// v1.4.4 F-52 (P2 audit): only emit the end-of-stream "no_result_observed"
|
|
269
|
+
// flush when the session ACTUALLY terminated in this stream. In watch mode
|
|
270
|
+
// fetchRawEvents re-fetches the FULL session every cycle, so a tool whose
|
|
271
|
+
// start lands in cycle N but whose result lands in cycle N+1 would, with an
|
|
272
|
+
// unconditional flush, get a phantom no_result_observed row (id = start id)
|
|
273
|
+
// in cycle N AND the real paired row (id = result id) in cycle N+1 — a
|
|
274
|
+
// double-count + persistent phantom error skewing error_rate_by_tool. While
|
|
275
|
+
// the session is still running an unpaired start is in-flight, not
|
|
276
|
+
// "no result observed"; it stays pending and pairs (or flushes) once the
|
|
277
|
+
// session's terminal state is observed.
|
|
278
|
+
let sessionTerminated = false;
|
|
279
|
+
|
|
234
280
|
// `provider` is the canonical field per src/sources/contract.js (no
|
|
235
281
|
// other consumer ever read the previous `framework` field, so it was
|
|
236
282
|
// dropped in PR-B with zero downstream impact).
|
|
@@ -279,10 +325,13 @@ export async function* transformRawEventsToWMAActions(rawEventsAsyncIterable, {
|
|
|
279
325
|
const startTs = pendingModelReq.get(ev.model_request_start_id);
|
|
280
326
|
pendingModelReq.delete(ev.model_request_start_id);
|
|
281
327
|
const u = ev.model_usage || {};
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
const
|
|
328
|
+
// v1.4.2 F-49 (P2 audit): sanitize to non-negative integers. A
|
|
329
|
+
// malformed/adversarial model_usage (NaN, negative, string) must not
|
|
330
|
+
// zero-out or poison the token totals that drive cost/abuse detection.
|
|
331
|
+
const i = safeNonNegInt(u.input_tokens);
|
|
332
|
+
const o = safeNonNegInt(u.output_tokens);
|
|
333
|
+
const cr = safeNonNegInt(u.cache_read_input_tokens);
|
|
334
|
+
const cw = safeNonNegInt(u.cache_creation_input_tokens);
|
|
286
335
|
yield {
|
|
287
336
|
...base,
|
|
288
337
|
...subAgentMeta,
|
|
@@ -351,16 +400,36 @@ export async function* transformRawEventsToWMAActions(rawEventsAsyncIterable, {
|
|
|
351
400
|
}
|
|
352
401
|
|
|
353
402
|
if (type === 'user.custom_tool_result') {
|
|
403
|
+
// v1.4.2 F-50: pair with the stored start and emit ONE completed
|
|
404
|
+
// custom_tool_use row carrying the real status/error/duration + the
|
|
405
|
+
// tool_name from the start (so a failing custom tool now lands in
|
|
406
|
+
// error_rate_by_tool). Mirrors the agent.tool_result handler. An
|
|
407
|
+
// orphan result (no matching start — out-of-order / restart) still
|
|
408
|
+
// emits, with tool_name null and input from the result only, so we
|
|
409
|
+
// never drop an action.
|
|
410
|
+
const cStart = pendingCustomTool.get(ev.custom_tool_use_id);
|
|
411
|
+
pendingCustomTool.delete(ev.custom_tool_use_id);
|
|
412
|
+
const isError = ev.is_error === true;
|
|
413
|
+
const cMeta = cStart ? {
|
|
414
|
+
session_thread_id: cStart.session_thread_id ?? subAgentMeta.session_thread_id,
|
|
415
|
+
agent_name: cStart.agent_name ?? subAgentMeta.agent_name,
|
|
416
|
+
composition_pattern: (cStart.session_thread_id != null && subThreadIds.has(cStart.session_thread_id))
|
|
417
|
+
? COMPOSITION_PATTERNS.HIERARCHY
|
|
418
|
+
: COMPOSITION_PATTERNS.SOLO,
|
|
419
|
+
parent_agent_id: null,
|
|
420
|
+
} : subAgentMeta;
|
|
354
421
|
yield {
|
|
355
422
|
...base,
|
|
356
|
-
...
|
|
423
|
+
...cMeta,
|
|
357
424
|
id: ev.id,
|
|
358
|
-
action_type: '
|
|
359
|
-
tool_name: null,
|
|
425
|
+
action_type: 'custom_tool_use',
|
|
426
|
+
tool_name: cStart?.name ?? null,
|
|
360
427
|
model: model || null,
|
|
361
428
|
timestamp: ts,
|
|
362
|
-
|
|
363
|
-
|
|
429
|
+
duration_ms: (cStart?.ts && tsMillis) ? tsMillis - cStart.ts : null,
|
|
430
|
+
status: isError ? 'error' : 'ok',
|
|
431
|
+
error: isError ? extractText(ev.content).slice(0, 500) : null,
|
|
432
|
+
input: cStart?.input ?? { custom_tool_use_id: ev.custom_tool_use_id },
|
|
364
433
|
output: { content: ev.content ?? null },
|
|
365
434
|
};
|
|
366
435
|
continue;
|
|
@@ -399,7 +468,16 @@ export async function* transformRawEventsToWMAActions(rawEventsAsyncIterable, {
|
|
|
399
468
|
if (type === 'agent.tool_use' || type === 'agent.mcp_tool_use') {
|
|
400
469
|
pendingToolUse.set(ev.id, {
|
|
401
470
|
ts: tsMillis,
|
|
402
|
-
|
|
471
|
+
// v1.4.2 F-41 (P1 audit): a missing tool name becomes null, NOT the
|
|
472
|
+
// literal string 'unknown'. A 'unknown' fallback (a) let a tool_name
|
|
473
|
+
// DENYLIST silently miss a real tool whose name field was absent
|
|
474
|
+
// (the rule keys on e.g. "bash"; "unknown" !== "bash" → no match →
|
|
475
|
+
// default-allow), and (b) collided two distinct unnamed tools into
|
|
476
|
+
// one forensic bucket. null fails every specific tool_name clause
|
|
477
|
+
// closed and is honestly absent. tool_name is `string|null` per the
|
|
478
|
+
// WMAAction contract; normalizeToolName(null) returns null and the
|
|
479
|
+
// aggregator skips null-named entries from per-tool counts.
|
|
480
|
+
name: ev.name ?? null,
|
|
403
481
|
isMcp: type === 'agent.mcp_tool_use',
|
|
404
482
|
input: ev.input ?? null,
|
|
405
483
|
mcpServer: ev.server_name ?? ev.mcp_server_name ?? null,
|
|
@@ -435,7 +513,7 @@ export async function* transformRawEventsToWMAActions(rawEventsAsyncIterable, {
|
|
|
435
513
|
...pairedMeta,
|
|
436
514
|
id: ev.id,
|
|
437
515
|
action_type: start?.isMcp ? 'mcp_tool_use' : 'tool_use',
|
|
438
|
-
tool_name: start?.name
|
|
516
|
+
tool_name: start?.name ?? null, // F-41: null, never literal 'unknown'
|
|
439
517
|
timestamp: ts,
|
|
440
518
|
duration_ms: (start?.ts && tsMillis) ? tsMillis - start.ts : null,
|
|
441
519
|
status: isError ? 'error' : 'ok',
|
|
@@ -447,16 +525,15 @@ export async function* transformRawEventsToWMAActions(rawEventsAsyncIterable, {
|
|
|
447
525
|
}
|
|
448
526
|
|
|
449
527
|
if (type === 'agent.custom_tool_use') {
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
tool_name: ev.name || 'unknown',
|
|
456
|
-
timestamp: ts,
|
|
457
|
-
status: 'ok',
|
|
528
|
+
// v1.4.2 F-50: store the start; the paired custom_tool_result (or the
|
|
529
|
+
// end-of-session flush) emits the single completed row with real status.
|
|
530
|
+
pendingCustomTool.set(ev.id, {
|
|
531
|
+
ts: tsMillis,
|
|
532
|
+
name: ev.name ?? null, // F-41: null, never literal 'unknown'
|
|
458
533
|
input: ev.input ?? null,
|
|
459
|
-
|
|
534
|
+
startTimestamp: ts,
|
|
535
|
+
session_thread_id, agent_name,
|
|
536
|
+
});
|
|
460
537
|
continue;
|
|
461
538
|
}
|
|
462
539
|
|
|
@@ -567,6 +644,8 @@ export async function* transformRawEventsToWMAActions(rawEventsAsyncIterable, {
|
|
|
567
644
|
const prefix = isThread ? 'session.thread_status_' : 'session.status_';
|
|
568
645
|
const state = type.slice(prefix.length); // 'running' | 'idle' | 'rescheduled' | 'terminated'
|
|
569
646
|
const fatal = state === 'terminated';
|
|
647
|
+
// F-52: a SESSION-scope terminate (not a sub-thread one) gates the flush.
|
|
648
|
+
if (fatal && !isThread) sessionTerminated = true;
|
|
570
649
|
yield {
|
|
571
650
|
...base,
|
|
572
651
|
...subAgentMeta,
|
|
@@ -601,6 +680,16 @@ export async function* transformRawEventsToWMAActions(rawEventsAsyncIterable, {
|
|
|
601
680
|
// explicitly with status='error' keeps the local NDJSON, anonymizer
|
|
602
681
|
// signals (counts, IoC hashes, tool_counts), and Fortress decisions
|
|
603
682
|
// honest about what actually happened.
|
|
683
|
+
//
|
|
684
|
+
// v1.4.4 F-52: GATED on sessionTerminated. If the session is still running
|
|
685
|
+
// (watch mode re-fetches the full session every cycle), an unpaired start is
|
|
686
|
+
// in-flight, not lost — flushing it now would create a phantom row that the
|
|
687
|
+
// next cycle's real paired row double-counts. We only flush once the
|
|
688
|
+
// session's terminal state proves the calls genuinely never resolved. (Both
|
|
689
|
+
// F-8 and F-50 flush tests feed a session.status_terminated event, so this
|
|
690
|
+
// gate is satisfied for them.)
|
|
691
|
+
if (!sessionTerminated) return;
|
|
692
|
+
|
|
604
693
|
for (const [toolUseId, pending] of pendingToolUse) {
|
|
605
694
|
yield {
|
|
606
695
|
...base,
|
|
@@ -619,6 +708,28 @@ export async function* transformRawEventsToWMAActions(rawEventsAsyncIterable, {
|
|
|
619
708
|
};
|
|
620
709
|
}
|
|
621
710
|
pendingToolUse.clear();
|
|
711
|
+
|
|
712
|
+
// v1.4.2 F-50: same flush for custom tools that started but never returned
|
|
713
|
+
// a result (Shield-blocked, denied, died mid-flight, session cut). Emitted
|
|
714
|
+
// as custom_tool_use / status=error / no_result_observed — the high-value
|
|
715
|
+
// "started but didn't complete" signal, now also covered for custom tools.
|
|
716
|
+
for (const [useId, pending] of pendingCustomTool) {
|
|
717
|
+
yield {
|
|
718
|
+
...base,
|
|
719
|
+
session_thread_id: pending.session_thread_id,
|
|
720
|
+
agent_name: pending.agent_name,
|
|
721
|
+
id: useId,
|
|
722
|
+
action_type: 'custom_tool_use',
|
|
723
|
+
tool_name: pending.name,
|
|
724
|
+
model: model || null,
|
|
725
|
+
timestamp: pending.startTimestamp,
|
|
726
|
+
duration_ms: null,
|
|
727
|
+
status: 'error',
|
|
728
|
+
error: 'no_result_observed',
|
|
729
|
+
input: pending.input,
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
pendingCustomTool.clear();
|
|
622
733
|
}
|
|
623
734
|
|
|
624
735
|
// ────────────────────────────────────────────────────────────────────────
|