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/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;
|
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
// v1.4.4 F-53: fold an agent's on-disk history into the static preloaded
|
|
28
|
+
// set. Called lazily the first time an agent is seen (including agents that
|
|
29
|
+
// --all-agents discovers AFTER startup) so their already-captured NDJSON ids
|
|
30
|
+
// are deduped against — otherwise a late-appearing agent with existing logs
|
|
31
|
+
// would re-append and re-upload events it already has.
|
|
32
|
+
addPreloaded(id) {
|
|
33
|
+
if (id) this._preloaded.add(id);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
has(sessionId, eventId) {
|
|
37
|
+
if (this._preloaded.has(eventId)) return true;
|
|
38
|
+
const s = this._bySession.get(sessionId);
|
|
39
|
+
return s ? s.has(eventId) : false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
add(sessionId, eventId) {
|
|
43
|
+
let s = this._bySession.get(sessionId);
|
|
44
|
+
if (!s) { s = new Set(); this._bySession.set(sessionId, s); }
|
|
45
|
+
s.add(eventId);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Drop a terminated session's runtime id set — it will never be re-fetched,
|
|
49
|
+
// so its ids no longer need to be remembered for dedup.
|
|
50
|
+
forgetSession(sessionId) {
|
|
51
|
+
this._bySession.delete(sessionId);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Total tracked ids (preloaded + all live per-session sets). For observability.
|
|
55
|
+
get size() {
|
|
56
|
+
let n = this._preloaded.size;
|
|
57
|
+
for (const s of this._bySession.values()) n += s.size;
|
|
58
|
+
return n;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Number of sessions currently holding a runtime id set.
|
|
62
|
+
get sessionCount() {
|
|
63
|
+
return this._bySession.size;
|
|
64
|
+
}
|
|
65
|
+
}
|