watchmyagents 1.1.2 → 1.1.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/package.json +1 -1
- package/scripts/shield.js +50 -32
- package/src/anonymizer.js +47 -0
- package/src/shield/decisions.js +12 -2
- package/src/shield/policy-stream.js +10 -1
- package/src/shield/policy.js +61 -9
- package/src/shield/sources/fortress.js +12 -0
- package/src/shield/sse.js +48 -0
- package/src/shield/stream.js +11 -2
- package/src/shield/upload.js +89 -0
- package/src/sources/anthropic-managed.js +90 -5
- package/src/sources/contract.js +83 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "watchmyagents",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.4",
|
|
4
4
|
"description": "Security observability + real-time policy enforcement for AI agents. Local-first NDJSON capture with a continuous Watch daemon that auto-uploads anonymized signals, Shield CLI that blocks policy violations live (with policies pulled from Fortress cloud), anonymizer producing signals-only payloads, bidirectional sync with WatchMyAgents Fortress, and one-command install as an always-on launchd/systemd service — closing the recursive Watch→Guardian→Shield security loop.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
package/scripts/shield.js
CHANGED
|
@@ -25,7 +25,6 @@
|
|
|
25
25
|
// ANTHROPIC_API_KEY env var is used if --api-key is omitted.
|
|
26
26
|
|
|
27
27
|
import { resolve } from 'node:path';
|
|
28
|
-
import { createHash } from 'node:crypto';
|
|
29
28
|
import { streamWithReconnect } from '../src/shield/stream.js';
|
|
30
29
|
import { loadPolicies, evaluate } from '../src/shield/policy.js';
|
|
31
30
|
import {
|
|
@@ -39,6 +38,12 @@ import { FortressPolicySource, postDecision } from '../src/shield/sources/fortre
|
|
|
39
38
|
import { resolveFortressBase, fortressEndpoint } from '../src/fortress/url.js';
|
|
40
39
|
import { PolicyStream } from '../src/shield/policy-stream.js';
|
|
41
40
|
import { isValidAgentId, isValidSessionId } from '../src/validate.js';
|
|
41
|
+
// v1.1.4 F-19 (P1 Codex audit): all egress to Fortress now flows through
|
|
42
|
+
// buildFortressDecisionPayload, which normalizes tool_name via the
|
|
43
|
+
// anonymizer's allowlist + salted-hash scheme and drops anything it can't
|
|
44
|
+
// safely normalize. Keeps Shield's payload aligned with the README
|
|
45
|
+
// promise that decisions ship fingerprints, not raw values.
|
|
46
|
+
import { buildFortressDecisionPayload } from '../src/shield/upload.js';
|
|
42
47
|
|
|
43
48
|
function parseArgs(argv) {
|
|
44
49
|
const out = {};
|
|
@@ -133,38 +138,20 @@ async function runSessionWorker({ sessionId, ctx }) {
|
|
|
133
138
|
// refreshes from Fortress (every 5 min) take effect without restart.
|
|
134
139
|
sinfo(sessionId, `attached (${mode} mode)`);
|
|
135
140
|
|
|
136
|
-
// Helper: hash an IoC value with the customer salt (same one used by
|
|
137
|
-
// anonymizer for signals → correlates decisions to signals in Fortress).
|
|
138
|
-
// Returns null if no salt is configured (decisions still upload, just
|
|
139
|
-
// without input_hash).
|
|
140
|
-
const hashIoc = (value) => {
|
|
141
|
-
if (!signalsSalt || value == null) return null;
|
|
142
|
-
const s = typeof value === 'string' ? value : JSON.stringify(value);
|
|
143
|
-
return 'sha256:' + createHash('sha256').update(signalsSalt).update(s).digest('hex').slice(0, 32);
|
|
144
|
-
};
|
|
145
|
-
|
|
146
141
|
// Helper: assemble + fire the decision push to Fortress (fire-and-forget).
|
|
142
|
+
// v1.1.4 F-19 (P1 Codex audit): delegates payload construction to the
|
|
143
|
+
// pure helper in src/shield/upload.js so the egress-side containment
|
|
144
|
+
// logic (tool_name allowlist + salted hashing) is unit-tested in
|
|
145
|
+
// isolation. The helper drops any field it cannot safely normalize
|
|
146
|
+
// (custom tool without salt, missing salt for hashes) rather than
|
|
147
|
+
// passing the raw value through.
|
|
147
148
|
const fireToFortress = (rawEvent, normalized, result, decidedInMs) => {
|
|
148
149
|
if (!pushDecisionToFortress) return;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
pushDecisionToFortress({
|
|
156
|
-
anthropic_agent_id: agentId,
|
|
157
|
-
decision: result.decision,
|
|
158
|
-
rule_id: result.rule_id || undefined,
|
|
159
|
-
session_hash: hashIoc(sessionId) || undefined,
|
|
160
|
-
event_id_hash: hashIoc(rawEvent?.id) || undefined,
|
|
161
|
-
input_hash: hashIoc(inputForHash) || undefined,
|
|
162
|
-
action_type: normalized?.action_type || undefined,
|
|
163
|
-
tool_name: normalized?.tool_name || undefined,
|
|
164
|
-
message: result.message || result.rule_name || undefined,
|
|
165
|
-
decided_at: new Date().toISOString(),
|
|
166
|
-
decided_in_ms: decidedInMs,
|
|
167
|
-
}).catch(() => undefined);
|
|
150
|
+
const payload = buildFortressDecisionPayload({
|
|
151
|
+
agentId, sessionId, rawEvent, normalized, result, decidedInMs,
|
|
152
|
+
signalsSalt,
|
|
153
|
+
});
|
|
154
|
+
pushDecisionToFortress(payload).catch(() => undefined);
|
|
168
155
|
};
|
|
169
156
|
|
|
170
157
|
let processed = 0, enforced = 0, sessionInterrupted = false;
|
|
@@ -214,15 +201,27 @@ async function runSessionWorker({ sessionId, ctx }) {
|
|
|
214
201
|
const result = evaluate(normalized, ctx.ruleset);
|
|
215
202
|
const decidedInMs = Date.now() - t0;
|
|
216
203
|
|
|
217
|
-
|
|
204
|
+
// v1.1.3 Phase 1.D — mode badge in the log line for operator visibility.
|
|
205
|
+
const modeTag = result.mode === 'shadow' ? ' [SHADOW]' : '';
|
|
206
|
+
sinfo(sessionId, `${rawEvent.type} tool=${normalized.tool_name} → ${result.decision}${modeTag}${result.rule_id ? ` (${result.rule_id})` : ''}`);
|
|
218
207
|
|
|
219
208
|
await decisions(sessionId).record({
|
|
220
209
|
sourceEvent: rawEvent, decision: result.decision,
|
|
221
210
|
ruleId: result.rule_id, ruleName: result.rule_name,
|
|
222
211
|
message: result.message, decidedInMs,
|
|
212
|
+
mode: result.mode,
|
|
223
213
|
});
|
|
224
214
|
fireToFortress(rawEvent, normalized, result, decidedInMs);
|
|
225
215
|
|
|
216
|
+
// v1.1.3 Phase 1.D — in shadow mode, the decision is COMPUTED + LOGGED
|
|
217
|
+
// but NOT enforced. The rule's "would_deny" / "would_interrupt"
|
|
218
|
+
// outcome flows to Fortress for Platt-scaling calibration + diff-in-diff
|
|
219
|
+
// efficacy measurement (Guardian Core hardening axes 1 + 4), but the
|
|
220
|
+
// agent's session continues uninterrupted. Promote to enforce only
|
|
221
|
+
// after calibration confidence + lifecycle gates (Guardian Core spec
|
|
222
|
+
// observe → shadow → enforce → retired).
|
|
223
|
+
if (result.mode === 'shadow') continue;
|
|
224
|
+
|
|
226
225
|
if ((result.decision === 'deny' || result.decision === 'interrupt') && !sessionInterrupted) {
|
|
227
226
|
try {
|
|
228
227
|
await interruptSession({
|
|
@@ -271,15 +270,34 @@ async function runSessionWorker({ sessionId, ctx }) {
|
|
|
271
270
|
const result = evaluate(normalized, ctx.ruleset);
|
|
272
271
|
const decidedInMs = Date.now() - t0;
|
|
273
272
|
|
|
274
|
-
|
|
273
|
+
// v1.1.3 Phase 1.D — mode badge in the log line for operator visibility.
|
|
274
|
+
const modeTag = result.mode === 'shadow' ? ' [SHADOW]' : '';
|
|
275
|
+
sinfo(sessionId, `requires_action ${sourceEvent.type} tool=${normalized.tool_name} → ${result.decision}${modeTag}${result.rule_id ? ` (${result.rule_id})` : ''}`);
|
|
275
276
|
|
|
276
277
|
await decisions(sessionId).record({
|
|
277
278
|
sourceEvent, decision: result.decision,
|
|
278
279
|
ruleId: result.rule_id, ruleName: result.rule_name,
|
|
279
280
|
message: result.message, decidedInMs,
|
|
281
|
+
mode: result.mode,
|
|
280
282
|
});
|
|
281
283
|
fireToFortress(sourceEvent, normalized, result, decidedInMs);
|
|
282
284
|
|
|
285
|
+
// v1.1.3 Phase 1.D — shadow mode in tool_confirmation: we MUST
|
|
286
|
+
// still send confirmAllow even when the rule said deny/interrupt,
|
|
287
|
+
// otherwise the agent hangs waiting for our response. The
|
|
288
|
+
// decision is logged with mode=shadow so calibration can compare
|
|
289
|
+
// what the rule said vs what was enforced (which is "nothing"
|
|
290
|
+
// here). For mode=enforce, the original branching below stands.
|
|
291
|
+
if (result.mode === 'shadow') {
|
|
292
|
+
try {
|
|
293
|
+
await confirmAllow({ apiKey, sessionId, toolUseId: eventId });
|
|
294
|
+
// No enforced++ — shadow doesn't enforce by definition.
|
|
295
|
+
} catch (e) {
|
|
296
|
+
process.stderr.write(`[shield/${sessionId.slice(0, 12)}] shadow confirmAllow error: ${e.message}\n`);
|
|
297
|
+
}
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
|
|
283
301
|
try {
|
|
284
302
|
if (result.decision === 'allow') {
|
|
285
303
|
await confirmAllow({ apiKey, sessionId, toolUseId: eventId });
|
package/src/anonymizer.js
CHANGED
|
@@ -109,6 +109,53 @@ export function normalizeToolName(toolName, salt) {
|
|
|
109
109
|
return 'tool_hash:' + createHash('sha256').update(salt).update(s).digest('hex').slice(0, 32);
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
// ── Tool input field normalization (Phase 1.A L-2, v1.1.3) ──────────────
|
|
113
|
+
//
|
|
114
|
+
// HASHABLE_INPUT_FIELDS is the canonical set of input field names the
|
|
115
|
+
// anonymizer knows how to hash. These names came from the Anthropic tool
|
|
116
|
+
// shape (`web_fetch.url`, `web_search.query`, `bash.command`, file tools).
|
|
117
|
+
// Other frameworks use different native names — OpenAI function tools
|
|
118
|
+
// have arbitrary user-defined argument names, LangGraph tools use the
|
|
119
|
+
// `tools[].args` shape, CrewAI uses `task.context`, etc.
|
|
120
|
+
//
|
|
121
|
+
// To avoid silent IoC loss when a new adapter lands, adapters can call
|
|
122
|
+
// `normalizeToolInput()` BEFORE yielding their WMAAction to map their
|
|
123
|
+
// native tool argument names to the canonical set. The function takes
|
|
124
|
+
// the raw input object and an alias map `{nativeName: canonicalName}`
|
|
125
|
+
// and returns a new object where canonical names are populated from
|
|
126
|
+
// the corresponding native fields (without losing the originals — both
|
|
127
|
+
// are kept so the local NDJSON preserves full fidelity).
|
|
128
|
+
//
|
|
129
|
+
// Example for a future OpenAI function tool that exposes `endpoint_url`:
|
|
130
|
+
// yield {
|
|
131
|
+
// ...,
|
|
132
|
+
// action_type: 'custom_tool_use',
|
|
133
|
+
// tool_name: 'fetch_remote',
|
|
134
|
+
// input: normalizeToolInput(rawInput, { endpoint_url: 'url' }),
|
|
135
|
+
// };
|
|
136
|
+
// Now `entry.input.url` is populated → the anonymizer's IoC hashing
|
|
137
|
+
// fires → Fortress receives a hashed signal as expected.
|
|
138
|
+
//
|
|
139
|
+
// Adapters that already use canonical names (Anthropic) can skip the
|
|
140
|
+
// helper — it's a no-op when no aliases match.
|
|
141
|
+
export function normalizeToolInput(rawInput, aliases = {}) {
|
|
142
|
+
if (rawInput == null || typeof rawInput !== 'object') return rawInput;
|
|
143
|
+
const out = { ...rawInput };
|
|
144
|
+
for (const [nativeName, canonicalName] of Object.entries(aliases)) {
|
|
145
|
+
if (!HASHABLE_INPUT_FIELDS.includes(canonicalName)) {
|
|
146
|
+
throw new Error(`normalizeToolInput: "${canonicalName}" is not a canonical hashable field (must be one of: ${HASHABLE_INPUT_FIELDS.join(', ')})`);
|
|
147
|
+
}
|
|
148
|
+
if (out[canonicalName] == null && rawInput[nativeName] != null) {
|
|
149
|
+
out[canonicalName] = rawInput[nativeName];
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Re-export the canonical hashable-fields list so adapter authors can
|
|
156
|
+
// programmatically reference the contract.
|
|
157
|
+
export { HASHABLE_INPUT_FIELDS };
|
|
158
|
+
|
|
112
159
|
// ── Single-entry extractor: what hashable IoCs are in this entry? ────────
|
|
113
160
|
|
|
114
161
|
function extractIocs(entry, salt) {
|
package/src/shield/decisions.js
CHANGED
|
@@ -22,13 +22,22 @@ export class DecisionLogger {
|
|
|
22
22
|
ruleName,
|
|
23
23
|
message,
|
|
24
24
|
decidedInMs,
|
|
25
|
+
mode, // v1.1.3 Phase 1.D: 'enforce' | 'shadow' (default 'enforce' if absent)
|
|
25
26
|
}) {
|
|
27
|
+
// In shadow mode the decision is computed and logged but NOT enforced.
|
|
28
|
+
// status must reflect what actually happened on the wire: shadow + deny
|
|
29
|
+
// didn't actually block, so status='ok' keeps Watch's aggregations honest.
|
|
30
|
+
// output.mode is what calibration reads to compare would-have-blocked vs
|
|
31
|
+
// did-block.
|
|
32
|
+
const effectiveMode = mode || 'enforce';
|
|
33
|
+
const enforced = effectiveMode === 'enforce'
|
|
34
|
+
&& (decision === 'deny' || decision === 'interrupt');
|
|
26
35
|
return this._logger.write({
|
|
27
36
|
action_type: 'shield_decision',
|
|
28
37
|
provider: 'anthropic-managed',
|
|
29
38
|
tool_name: sourceEvent?.name || sourceEvent?.tool_name || null,
|
|
30
|
-
status:
|
|
31
|
-
error:
|
|
39
|
+
status: enforced ? 'error' : 'ok',
|
|
40
|
+
error: enforced ? message : null,
|
|
32
41
|
duration_ms: decidedInMs ?? null,
|
|
33
42
|
input: {
|
|
34
43
|
source_event_id: sourceEvent?.id || null,
|
|
@@ -40,6 +49,7 @@ export class DecisionLogger {
|
|
|
40
49
|
rule_id: ruleId,
|
|
41
50
|
rule_name: ruleName,
|
|
42
51
|
message,
|
|
52
|
+
mode: effectiveMode,
|
|
43
53
|
},
|
|
44
54
|
});
|
|
45
55
|
}
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
import { request as httpsRequest } from 'node:https';
|
|
33
33
|
import { URL } from 'node:url';
|
|
34
34
|
import { EventEmitter } from 'node:events';
|
|
35
|
+
import { normalizeSseBuffer } from './sse.js';
|
|
35
36
|
|
|
36
37
|
const RECONNECT_MIN_MS = 1_000;
|
|
37
38
|
const RECONNECT_MAX_MS = 60_000;
|
|
@@ -154,6 +155,13 @@ export class PolicyStream extends EventEmitter {
|
|
|
154
155
|
let buffer = '';
|
|
155
156
|
res.on('data', (chunk) => {
|
|
156
157
|
buffer += chunk;
|
|
158
|
+
// v1.1.4 F-18 (P1 Codex audit): normalize CR / CRLF line
|
|
159
|
+
// terminators to LF before scanning for the event separator.
|
|
160
|
+
// Without this, a Fortress deployment behind a reverse-proxy
|
|
161
|
+
// that emits CRLF would never trigger a policy refresh push —
|
|
162
|
+
// updates would silently fall back to the 60s polling loop,
|
|
163
|
+
// breaking the "sub-second propagation" promise.
|
|
164
|
+
buffer = normalizeSseBuffer(buffer);
|
|
157
165
|
// v1.1.1 F-9: cap on a single SSE event buffer. A buggy/compromised
|
|
158
166
|
// endpoint that never emits "\n\n" would otherwise OOM Shield.
|
|
159
167
|
// Abort + reconnect on overflow; the buffer is dropped so we
|
|
@@ -165,7 +173,8 @@ export class PolicyStream extends EventEmitter {
|
|
|
165
173
|
if (!this._closed) this._scheduleReconnect();
|
|
166
174
|
return;
|
|
167
175
|
}
|
|
168
|
-
// SSE events are separated by a blank line
|
|
176
|
+
// SSE events are separated by a blank line. Post-normalize the
|
|
177
|
+
// canonical separator is "\n\n".
|
|
169
178
|
let eolIdx;
|
|
170
179
|
while ((eolIdx = buffer.indexOf('\n\n')) !== -1) {
|
|
171
180
|
const rawEvent = buffer.slice(0, eolIdx);
|
package/src/shield/policy.js
CHANGED
|
@@ -33,11 +33,23 @@ export async function loadPolicies(path) {
|
|
|
33
33
|
}
|
|
34
34
|
// Pre-compile regex for performance + early failure on bad patterns.
|
|
35
35
|
const VALID_ACTIONS = ['allow', 'deny', 'interrupt'];
|
|
36
|
+
// v1.1.3 Phase 1.D — policy mode: 'enforce' (default) actually enforces
|
|
37
|
+
// the decision via the Anthropic API; 'shadow' computes the decision
|
|
38
|
+
// and logs it but skips enforcement. Shadow is the calibration bench
|
|
39
|
+
// for Guardian Core scoring (Platt scaling, diff-in-diff efficacy)
|
|
40
|
+
// and a safe staging step for new policies before promoting to enforce.
|
|
41
|
+
const VALID_MODES = ['enforce', 'shadow'];
|
|
36
42
|
for (const p of data.policies) {
|
|
37
43
|
compileMatchRegexes(p.match || {});
|
|
38
44
|
if (!VALID_ACTIONS.includes(p.action)) {
|
|
39
45
|
throw new Error(`policy ${p.id || p.name}: unsupported action "${p.action}"`);
|
|
40
46
|
}
|
|
47
|
+
// Default to 'enforce' if mode is omitted → preserves v1.0.x / v1.1.x
|
|
48
|
+
// behavior for policies authored before shadow mode existed.
|
|
49
|
+
if (p.mode == null) p.mode = 'enforce';
|
|
50
|
+
if (!VALID_MODES.includes(p.mode)) {
|
|
51
|
+
throw new Error(`policy ${p.id || p.name}: unsupported mode "${p.mode}" (must be one of: ${VALID_MODES.join(', ')})`);
|
|
52
|
+
}
|
|
41
53
|
}
|
|
42
54
|
// v1.1.2 F-14 (P2 Codex audit): validate the ruleset's default.action
|
|
43
55
|
// against the SAME canonical set as per-policy actions. Before this fix
|
|
@@ -56,28 +68,61 @@ export async function loadPolicies(path) {
|
|
|
56
68
|
return data;
|
|
57
69
|
}
|
|
58
70
|
|
|
59
|
-
// ReDoS protection: regexes are loaded from a user-provided JSON policy file
|
|
60
|
-
//
|
|
61
|
-
//
|
|
71
|
+
// ReDoS protection: regexes are loaded from a user-provided JSON policy file
|
|
72
|
+
// (LOCAL adapter) AND from Fortress / Guardian-generated rules (FORTRESS
|
|
73
|
+
// adapter), so a malicious or buggy pattern (e.g. `(a+)+$`) could pin
|
|
74
|
+
// Shield's CPU on a long input — taking the whole enforcement loop down.
|
|
75
|
+
// We mitigate three ways:
|
|
62
76
|
// 1) Cap the maximum input length passed to any regex test to MAX_REGEX_INPUT
|
|
63
77
|
// bytes. Above that we truncate before testing. Real agent values
|
|
64
|
-
// (URLs, commands, queries) are well under this in practice.
|
|
65
|
-
// 2) Reject obviously dangerous patterns at compile time (heuristic
|
|
78
|
+
// (URLs, commands, queries, file paths) are well under this in practice.
|
|
79
|
+
// 2) Reject obviously dangerous patterns at compile time (heuristic — see
|
|
80
|
+
// SUSPICIOUS_REGEX_PATTERNS below). The list errs toward false positives
|
|
81
|
+
// because Shield runs in the hot path: a rejected rule is loud (the
|
|
82
|
+
// rule is dropped at load time with a clear error) while a runaway
|
|
83
|
+
// regex would silently degrade Shield to "no enforcement" until the
|
|
84
|
+
// operator notices a CPU spike.
|
|
85
|
+
// 3) Hard upper bound on the regex source length so a deeply nested
|
|
86
|
+
// pattern can't game the heuristic by spreading the gadget across
|
|
87
|
+
// thousands of chars.
|
|
88
|
+
//
|
|
89
|
+
// Future work (v1.2+): proper RE2 or safe-regex-2 dependency for thorough
|
|
90
|
+
// analysis, or moving evaluation into a worker with a hard CPU timeout.
|
|
91
|
+
// We can't ship that today without breaking the zero-runtime-deps promise.
|
|
66
92
|
//
|
|
67
|
-
//
|
|
68
|
-
|
|
93
|
+
// v1.1.4 F-20 (P2 Codex audit): cap reduced from 8192 → 2048 (4×). Worst
|
|
94
|
+
// realistic IoC values (URL with long query string, base64 in a path)
|
|
95
|
+
// remain comfortably under 2048; the previous 8192 was a defence-in-depth
|
|
96
|
+
// holdover that no real workload exercises.
|
|
97
|
+
const MAX_REGEX_INPUT = 2048;
|
|
69
98
|
|
|
99
|
+
// v1.1.4 F-20: heuristic list extended to catch ambiguous alternation
|
|
100
|
+
// (`(a|a)*`, `(a|ab)+`, `(.|.)*`) — these don't trip the existing
|
|
101
|
+
// nested-quantifier heuristic but exhibit the same exponential behavior
|
|
102
|
+
// because every char has two paths to match. The new pattern rejects any
|
|
103
|
+
// alternation group immediately followed by `+` or `*`; this is intentionally
|
|
104
|
+
// over-broad — a customer who needs `(http|https):` should use either a
|
|
105
|
+
// character class (`[a-z]+:`) or move the optional letter (`https?:`).
|
|
70
106
|
const SUSPICIOUS_REGEX_PATTERNS = [
|
|
71
107
|
/(\([^)]*[+*][^)]*\))[+*]/, // (x+)+ or (x*)* — classic catastrophic backtracking
|
|
72
108
|
/(\.\*){3,}/, // multiple .* in a row
|
|
109
|
+
// F-20: alternation inside a group, then `+` or `*` — `(a|a)*`, `(a|ab)+`,
|
|
110
|
+
// `(.|.)*`. The `[^)]*\|[^)]*` body requires at least one `|` inside the
|
|
111
|
+
// group; a single-branch group like `(foo)+` is NOT matched.
|
|
112
|
+
/\([^)]*\|[^)]*\)[+*]/,
|
|
73
113
|
];
|
|
74
114
|
|
|
75
115
|
export function validateRegexString(src, where) {
|
|
76
116
|
if (typeof src !== 'string') {
|
|
77
117
|
throw new Error(`policy ${where}: regex must be a string`);
|
|
78
118
|
}
|
|
79
|
-
|
|
80
|
-
|
|
119
|
+
// v1.1.4 F-20: cap regex source at 1024 chars (was 2000). Real Shield
|
|
120
|
+
// policies are short (URL-prefix match, host-list deny, command-prefix
|
|
121
|
+
// check). A 1KB regex source is already an outlier; anything longer is
|
|
122
|
+
// either pathological or trying to bypass the heuristics by smuggling
|
|
123
|
+
// a gadget across many bytes.
|
|
124
|
+
if (src.length > 1024) {
|
|
125
|
+
throw new Error(`policy ${where}: regex too long (>1024 chars)`);
|
|
81
126
|
}
|
|
82
127
|
for (const sus of SUSPICIOUS_REGEX_PATTERNS) {
|
|
83
128
|
if (sus.test(src)) {
|
|
@@ -157,6 +202,10 @@ export function evaluate(event, ruleset) {
|
|
|
157
202
|
rule_id: policy.id || null,
|
|
158
203
|
rule_name: policy.name || null,
|
|
159
204
|
message: policy.message || null,
|
|
205
|
+
// v1.1.3 Phase 1.D — `mode` propagated so the Shield runtime can
|
|
206
|
+
// decide whether to actually call the Anthropic enforcement API
|
|
207
|
+
// (mode=enforce) or just log the would-be decision (mode=shadow).
|
|
208
|
+
mode: policy.mode || 'enforce',
|
|
160
209
|
};
|
|
161
210
|
}
|
|
162
211
|
}
|
|
@@ -165,5 +214,8 @@ export function evaluate(event, ruleset) {
|
|
|
165
214
|
rule_id: null,
|
|
166
215
|
rule_name: '(default)',
|
|
167
216
|
message: null,
|
|
217
|
+
// The ruleset-level default has no shadow concept — defaults always
|
|
218
|
+
// enforce (or, when the default is 'allow', do nothing of consequence).
|
|
219
|
+
mode: 'enforce',
|
|
168
220
|
};
|
|
169
221
|
}
|
|
@@ -233,6 +233,9 @@ export class FortressPolicySource {
|
|
|
233
233
|
// Convert a Fortress DB policy row to the local Shield format.
|
|
234
234
|
// Throws on anything invalid so _refresh can skip it (policies from the cloud
|
|
235
235
|
// are NOT fully trusted — apply the same hardening as the local JSON loader).
|
|
236
|
+
// v1.1.3 Phase 1.D — same modes as the local JSON loader.
|
|
237
|
+
const VALID_MODES = new Set(['enforce', 'shadow']);
|
|
238
|
+
|
|
236
239
|
function compilePolicyFromFortress(p) {
|
|
237
240
|
if (!p || typeof p !== 'object') throw new Error('policy is not an object');
|
|
238
241
|
if (!VALID_ACTIONS.has(p.action)) {
|
|
@@ -244,6 +247,14 @@ function compilePolicyFromFortress(p) {
|
|
|
244
247
|
if (p.priority != null && (typeof p.priority !== 'number' || !Number.isFinite(p.priority))) {
|
|
245
248
|
throw new Error(`priority must be a finite number (got ${p.priority})`);
|
|
246
249
|
}
|
|
250
|
+
// v1.1.3 Phase 1.D — accept `mode` from Fortress rows (added by Lovable
|
|
251
|
+
// when the companion prompt deploys the schema column). Default to
|
|
252
|
+
// 'enforce' for backwards compat: existing Fortress instances without
|
|
253
|
+
// the `mode` column yield policies that enforce, as they always have.
|
|
254
|
+
const mode = p.mode ?? 'enforce';
|
|
255
|
+
if (!VALID_MODES.has(mode)) {
|
|
256
|
+
throw new Error(`unsupported mode "${mode}" (expected enforce|shadow)`);
|
|
257
|
+
}
|
|
247
258
|
const out = {
|
|
248
259
|
id: p.rule_id,
|
|
249
260
|
name: p.name,
|
|
@@ -252,6 +263,7 @@ function compilePolicyFromFortress(p) {
|
|
|
252
263
|
action: p.action,
|
|
253
264
|
message: p.message,
|
|
254
265
|
priority: p.priority ?? 100,
|
|
266
|
+
mode,
|
|
255
267
|
};
|
|
256
268
|
// Reuse the SAME ReDoS-safe compiler as the local JSON loader (rejects
|
|
257
269
|
// catastrophic-backtracking patterns + over-long regexes). Previously this
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// SSE line terminator normalization — v1.1.4 F-18 (P1 Codex audit).
|
|
2
|
+
//
|
|
3
|
+
// The HTML Living Standard's event-stream parsing rules (whatwg SSE)
|
|
4
|
+
// accept three line terminator forms: LF (\n), CR (\r), and CRLF (\r\n).
|
|
5
|
+
// An event ends at a blank line: TWO consecutive line terminators of any
|
|
6
|
+
// of those forms, possibly mixed (e.g. \r\n\r\n, \r\r, \n\n, \r\n\n).
|
|
7
|
+
//
|
|
8
|
+
// Before this fix, Shield's two SSE consumers (stream.js for live agent
|
|
9
|
+
// events, policy-stream.js for Fortress policy push) only looked for
|
|
10
|
+
// the LF-LF separator. An upstream proxy or endpoint that emitted CRLF
|
|
11
|
+
// (most production-grade reverse-proxies do, by default!) would yield
|
|
12
|
+
// a buffer that never matched \n\n — Shield would silently never see
|
|
13
|
+
// the events:
|
|
14
|
+
// - agent-stream side: no deny/interrupt would fire live, breaking
|
|
15
|
+
// the sub-second enforcement promise
|
|
16
|
+
// - policy-stream side: updates would fall back to the 60s polling
|
|
17
|
+
// loop, making rule rollouts visibly slow
|
|
18
|
+
//
|
|
19
|
+
// Fix: normalize the buffer to LF-only before scanning. The normalize
|
|
20
|
+
// step is chunk-safe: a CR at the very end of the current buffer is
|
|
21
|
+
// preserved verbatim (it might be the first half of an incoming CRLF
|
|
22
|
+
// on the next chunk). Once a CR is no longer trailing, it's converted.
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Normalize SSE line terminators in a streaming buffer to LF.
|
|
26
|
+
*
|
|
27
|
+
* Use as: `buffer = normalizeSseBuffer(buffer + newChunk);`
|
|
28
|
+
*
|
|
29
|
+
* Guarantees, after the call:
|
|
30
|
+
* - every `\r\n` pair has been replaced by `\n`
|
|
31
|
+
* - every bare `\r` NOT at the very end has been replaced by `\n`
|
|
32
|
+
* - a trailing `\r` is preserved verbatim so the next iteration can
|
|
33
|
+
* check whether it was actually the first half of a CRLF
|
|
34
|
+
*
|
|
35
|
+
* @param {string} buffer the streaming buffer (already concatenated)
|
|
36
|
+
* @returns {string} buffer with line terminators normalized to LF
|
|
37
|
+
*/
|
|
38
|
+
export function normalizeSseBuffer(buffer) {
|
|
39
|
+
if (typeof buffer !== 'string' || buffer.length === 0) return buffer;
|
|
40
|
+
// Defer the very last character if it's a CR — we don't know yet
|
|
41
|
+
// whether it's a bare CR terminator or the first half of CRLF.
|
|
42
|
+
const tail = buffer.endsWith('\r') ? '\r' : '';
|
|
43
|
+
const scannable = tail ? buffer.slice(0, -1) : buffer;
|
|
44
|
+
// Two-pass replace: CRLF first (so we don't double-convert the LF
|
|
45
|
+
// half), then any remaining bare CR.
|
|
46
|
+
const normalized = scannable.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
47
|
+
return tail ? normalized + tail : normalized;
|
|
48
|
+
}
|
package/src/shield/stream.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Uses built-in fetch + ReadableStream (Node 18+). Zero deps.
|
|
8
8
|
|
|
9
|
+
import { normalizeSseBuffer } from './sse.js';
|
|
10
|
+
|
|
9
11
|
const API_BASE = 'https://api.anthropic.com';
|
|
10
12
|
const BETA = 'managed-agents-2026-04-01';
|
|
11
13
|
const VERSION = '2023-06-01';
|
|
@@ -51,6 +53,12 @@ export async function* openEventStream({ apiKey, sessionId, signal }) {
|
|
|
51
53
|
const { done, value } = await reader.read();
|
|
52
54
|
if (done) break;
|
|
53
55
|
buffer += decoder.decode(value, { stream: true });
|
|
56
|
+
// v1.1.4 F-18 (P1 Codex audit): normalize all SSE line terminators
|
|
57
|
+
// (CR, CRLF) to LF so the indexOf('\n\n') scan below catches every
|
|
58
|
+
// event boundary the spec allows. Without this, an upstream that
|
|
59
|
+
// emits CRLF (common in reverse-proxy paths) yielded a buffer that
|
|
60
|
+
// never matched and Shield silently lost the live enforcement loop.
|
|
61
|
+
buffer = normalizeSseBuffer(buffer);
|
|
54
62
|
|
|
55
63
|
// v1.1.2 F-16: guard against an upstream that never emits "\n\n" —
|
|
56
64
|
// throw to abort the stream cleanly, the caller's reconnect logic
|
|
@@ -60,8 +68,9 @@ export async function* openEventStream({ apiKey, sessionId, signal }) {
|
|
|
60
68
|
throw new Error(`SSE frame exceeded ${MAX_SSE_FRAME_BYTES} bytes — aborting stream (caller should reconnect)`);
|
|
61
69
|
}
|
|
62
70
|
|
|
63
|
-
// SSE frames are separated by a blank line
|
|
64
|
-
//
|
|
71
|
+
// SSE frames are separated by a blank line. Post-normalize, the
|
|
72
|
+
// canonical separator is "\n\n"; each frame may contain multiple
|
|
73
|
+
// lines; we only care about `data:` lines for now.
|
|
65
74
|
let nlIdx;
|
|
66
75
|
while ((nlIdx = buffer.indexOf('\n\n')) !== -1) {
|
|
67
76
|
const frame = buffer.slice(0, nlIdx);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Shield → Fortress decision payload builder.
|
|
2
|
+
// v1.1.4 F-19 (P1 Codex audit): pure helper extracted from scripts/shield.js
|
|
3
|
+
// so the egress-side containment can be unit-tested in isolation. The
|
|
4
|
+
// security invariant under test:
|
|
5
|
+
//
|
|
6
|
+
// nothing on this payload may carry raw customer identifiers — tool
|
|
7
|
+
// names, session ids, event ids, input values must either be in the
|
|
8
|
+
// documented allowlist (vendor built-ins) or salted-hashed.
|
|
9
|
+
//
|
|
10
|
+
// Anything that can't be safely normalized is DROPPED (set to undefined)
|
|
11
|
+
// rather than passed through. Decision still ships so Fortress can count
|
|
12
|
+
// it — only the leak-y field is omitted.
|
|
13
|
+
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { normalizeToolName } from '../anonymizer.js';
|
|
16
|
+
|
|
17
|
+
// Salted SHA-256 hash with the same 32-char truncation as the anonymizer
|
|
18
|
+
// and the rest of Shield's decision flow. Returns null for nullish input
|
|
19
|
+
// or when no salt is configured (fail-safe omission).
|
|
20
|
+
function hashWithSaltOpt(value, salt) {
|
|
21
|
+
if (value == null || !salt) return null;
|
|
22
|
+
const s = typeof value === 'string' ? value : JSON.stringify(value);
|
|
23
|
+
return 'sha256:' + createHash('sha256').update(salt).update(s).digest('hex').slice(0, 32);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Extract the most relevant input value to fingerprint (URL > command >
|
|
27
|
+
// query > path > file_path). The actual hashing is done downstream by
|
|
28
|
+
// hashIoc — this function only picks the IoC field.
|
|
29
|
+
function pickInputForHash(input) {
|
|
30
|
+
if (!input || typeof input !== 'object') return null;
|
|
31
|
+
return input.url || input.command || input.query || input.path || input.file_path || null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build the body POSTed to Fortress's ingest-decisions endpoint.
|
|
36
|
+
*
|
|
37
|
+
* Containment guarantees:
|
|
38
|
+
* - tool_name: vendor allowlist returned in clear; custom/MCP names
|
|
39
|
+
* return as "tool_hash:<32hex>" when a salt is configured; dropped
|
|
40
|
+
* entirely otherwise.
|
|
41
|
+
* - session_hash / event_id_hash / input_hash: salted SHA-256, omitted
|
|
42
|
+
* when no salt is configured.
|
|
43
|
+
* - Raw payload values (rawEvent.input, normalized.input) never
|
|
44
|
+
* appear on the wire — only their hashes do.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} opts
|
|
47
|
+
* @param {string} opts.agentId - Anthropic agent id (already an opaque token)
|
|
48
|
+
* @param {string} opts.sessionId - Anthropic session id (hashed before egress)
|
|
49
|
+
* @param {object} opts.rawEvent - raw upstream event (id field is hashed)
|
|
50
|
+
* @param {object} opts.normalized - normalized event from normalizeForPolicy()
|
|
51
|
+
* @param {object} opts.result - policy evaluator output
|
|
52
|
+
* @param {number} opts.decidedInMs
|
|
53
|
+
* @param {string|null|undefined} opts.signalsSalt
|
|
54
|
+
* @param {string} [opts.decidedAtIso] - ISO 8601 timestamp; defaults to now()
|
|
55
|
+
* @returns {object} payload ready to POST to ingest-decisions
|
|
56
|
+
*/
|
|
57
|
+
export function buildFortressDecisionPayload({
|
|
58
|
+
agentId, sessionId, rawEvent, normalized, result, decidedInMs,
|
|
59
|
+
signalsSalt, decidedAtIso,
|
|
60
|
+
}) {
|
|
61
|
+
// F-19: vendor built-ins survive even without a salt (allowlist short-circuit);
|
|
62
|
+
// custom tool names throw without a salt — we catch and drop the field.
|
|
63
|
+
let safeToolName;
|
|
64
|
+
const rawToolName = normalized?.tool_name;
|
|
65
|
+
if (rawToolName) {
|
|
66
|
+
try {
|
|
67
|
+
safeToolName = normalizeToolName(rawToolName, signalsSalt);
|
|
68
|
+
} catch {
|
|
69
|
+
safeToolName = undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
anthropic_agent_id: agentId,
|
|
75
|
+
decision: result.decision,
|
|
76
|
+
rule_id: result.rule_id || undefined,
|
|
77
|
+
session_hash: hashWithSaltOpt(sessionId, signalsSalt) || undefined,
|
|
78
|
+
event_id_hash: hashWithSaltOpt(rawEvent?.id, signalsSalt) || undefined,
|
|
79
|
+
input_hash: hashWithSaltOpt(pickInputForHash(normalized?.input), signalsSalt) || undefined,
|
|
80
|
+
action_type: normalized?.action_type || undefined,
|
|
81
|
+
tool_name: safeToolName,
|
|
82
|
+
message: result.message || result.rule_name || undefined,
|
|
83
|
+
decided_at: decidedAtIso || new Date().toISOString(),
|
|
84
|
+
decided_in_ms: decidedInMs,
|
|
85
|
+
// v1.1.3 Phase 1.D: mode threading so Fortress can store and surface
|
|
86
|
+
// shadow-vs-enforce in the Reports timeline.
|
|
87
|
+
mode: result.mode || undefined,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { request } from 'node:https';
|
|
19
19
|
import { URLSearchParams } from 'node:url';
|
|
20
|
-
import { Source, PROVIDERS, ENFORCEMENT_MODES, ACTION_TYPES } from './contract.js';
|
|
20
|
+
import { Source, PROVIDERS, ENFORCEMENT_MODES, ACTION_TYPES, COMPOSITION_PATTERNS } from './contract.js';
|
|
21
21
|
import {
|
|
22
22
|
getAgentConfig, detectAlwaysAsk,
|
|
23
23
|
confirmAllow, confirmDeny, interruptSession,
|
|
@@ -183,11 +183,54 @@ const RELEVANT_TYPES = [
|
|
|
183
183
|
|
|
184
184
|
const tsMs = ev => Date.parse(ev.processed_at || ev.created_at || '') || null;
|
|
185
185
|
|
|
186
|
+
// v1.1.3 Phase 1.B — `fetchSessionEntries` is now a 3-line wrapper around
|
|
187
|
+
// the pure async transformer below. The HTTP layer (fetchRawEvents) is
|
|
188
|
+
// the only thing that needs the network — the per-event normalization
|
|
189
|
+
// logic is a pure async generator that any test can drive with synthetic
|
|
190
|
+
// event arrays. Same external contract; new internal seam for testing.
|
|
186
191
|
export async function* fetchSessionEntries({ apiKey, agentId, sessionId, model }) {
|
|
192
|
+
yield* transformRawEventsToWMAActions(
|
|
193
|
+
fetchRawEvents(apiKey, sessionId),
|
|
194
|
+
{ agentId, sessionId, model },
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Pure async transformer: raw Anthropic events → WMAAction objects.
|
|
200
|
+
*
|
|
201
|
+
* Takes any async iterable of raw Anthropic events (live HTTP stream OR
|
|
202
|
+
* synthetic test fixture array) plus the routing metadata, yields one
|
|
203
|
+
* WMAAction per relevant event. Maintains all the cross-event state
|
|
204
|
+
* (pendingModelReq / pendingToolUse pairs, subThreadIds tracking, F-6a
|
|
205
|
+
* discriminators, F-8 end-of-session flush of unresolved tool calls).
|
|
206
|
+
*
|
|
207
|
+
* Why a separate function: keeps `fetchSessionEntries` HTTP-only and
|
|
208
|
+
* makes integration tests trivial — no network mocking required.
|
|
209
|
+
*/
|
|
210
|
+
export async function* transformRawEventsToWMAActions(rawEventsAsyncIterable, { agentId, sessionId, model }) {
|
|
187
211
|
// Pair-tracking maps: event_id of the "start" → its timestamp + metadata
|
|
188
212
|
const pendingModelReq = new Map(); // span.model_request_start.id → ts
|
|
189
213
|
const pendingToolUse = new Map(); // agent.tool_use.id → { ts, name, isMcp, input }
|
|
190
214
|
|
|
215
|
+
// v1.1.3 Phase 1.C — Anthropic sub-agent detection.
|
|
216
|
+
// Anthropic Task tool: when a parent agent delegates, a NEW thread is
|
|
217
|
+
// spawned within the parent's session (`session.thread_created` event).
|
|
218
|
+
// Subsequent events with that session_thread_id belong to the sub-agent.
|
|
219
|
+
// We track the set of "spawned" thread ids and, for each event, set
|
|
220
|
+
// composition_pattern='hierarchy' if it happened inside a sub-thread.
|
|
221
|
+
//
|
|
222
|
+
// Important Anthropic-specific design note: the sub-agent does NOT have
|
|
223
|
+
// a separate agent_id at the Anthropic API level — it runs inside the
|
|
224
|
+
// parent's session and its actions are attributed to the parent's
|
|
225
|
+
// agent_id. So `parent_agent_id` STAYS NULL for all events of this
|
|
226
|
+
// session (the sub-agent shares identity with the parent). Operators
|
|
227
|
+
// who want per-sub-agent visibility use the local NDJSON's
|
|
228
|
+
// `session_thread_id` + `agent_name` discriminators (captured since
|
|
229
|
+
// v1.0.2 F-6a) to differentiate. Other adapters (OpenAI handoffs,
|
|
230
|
+
// CrewAI manager, Hermes spawn_subagent) where sub-agents DO have
|
|
231
|
+
// distinct API-level IDs will populate parent_agent_id natively.
|
|
232
|
+
const subThreadIds = new Set();
|
|
233
|
+
|
|
191
234
|
// `provider` is the canonical field per src/sources/contract.js (no
|
|
192
235
|
// other consumer ever read the previous `framework` field, so it was
|
|
193
236
|
// dropped in PR-B with zero downstream impact).
|
|
@@ -201,7 +244,7 @@ export async function* fetchSessionEntries({ apiKey, agentId, sessionId, model }
|
|
|
201
244
|
// exact filterable set is undocumented & evolves. We pull everything and
|
|
202
245
|
// filter here, ensuring future event types are surfaced rather than dropped.
|
|
203
246
|
const RELEVANT = new Set(RELEVANT_TYPES);
|
|
204
|
-
for await (const ev of
|
|
247
|
+
for await (const ev of rawEventsAsyncIterable) {
|
|
205
248
|
if (!RELEVANT.has(ev.type)) continue;
|
|
206
249
|
const type = ev.type;
|
|
207
250
|
const ts = ev.processed_at || ev.created_at || new Date().toISOString();
|
|
@@ -211,7 +254,21 @@ export async function* fetchSessionEntries({ apiKey, agentId, sessionId, model }
|
|
|
211
254
|
// Preserved LOCALLY (NDJSON) only — never sent raw to Fortress.
|
|
212
255
|
const session_thread_id = ev.session_thread_id ?? null;
|
|
213
256
|
const agent_name = ev.agent_name ?? null;
|
|
214
|
-
|
|
257
|
+
// v1.1.3 Phase 1.C: derive composition_pattern at event level.
|
|
258
|
+
// Sub-thread events (session_thread_id registered in subThreadIds via
|
|
259
|
+
// a prior session.thread_created) → hierarchy. Root-thread events
|
|
260
|
+
// OR events without thread_id → solo. The aggregator in uploadSignals
|
|
261
|
+
// upgrades the AGENT-level composition_pattern to "hierarchy" when
|
|
262
|
+
// any event in the window is non-solo, so even pre-spawn root events
|
|
263
|
+
// contribute correctly to the agent's overall classification.
|
|
264
|
+
const inSubThread = session_thread_id != null && subThreadIds.has(session_thread_id);
|
|
265
|
+
const composition_pattern = inSubThread ? COMPOSITION_PATTERNS.HIERARCHY : COMPOSITION_PATTERNS.SOLO;
|
|
266
|
+
// parent_agent_id: see the design note at the top of fetchSessionEntries.
|
|
267
|
+
// Anthropic Task tool sub-agents share the parent's agent_id at the API
|
|
268
|
+
// level, so the field is null for ALL Anthropic events. Explicitly
|
|
269
|
+
// null (not undefined) so consumers see a documented value per WMAAction
|
|
270
|
+
// schema and downstream tests can assert it cleanly.
|
|
271
|
+
const subAgentMeta = { session_thread_id, agent_name, composition_pattern, parent_agent_id: null };
|
|
215
272
|
const tsMillis = tsMs(ev);
|
|
216
273
|
|
|
217
274
|
if (type === 'span.model_request_start') {
|
|
@@ -358,9 +415,24 @@ export async function* fetchSessionEntries({ apiKey, agentId, sessionId, model }
|
|
|
358
415
|
const start = pendingToolUse.get(ev.tool_use_id);
|
|
359
416
|
pendingToolUse.delete(ev.tool_use_id);
|
|
360
417
|
const isError = ev.is_error === true;
|
|
418
|
+
// v1.1.3 Phase 1.B fix: the yielded action represents the TOOL CALL
|
|
419
|
+
// as a whole (input from start, output from result). Its discriminators
|
|
420
|
+
// (session_thread_id, agent_name, composition_pattern) should reflect
|
|
421
|
+
// the START's context — where the tool was INVOKED — not the result
|
|
422
|
+
// event's, which may or may not carry these fields depending on
|
|
423
|
+
// provider event-shape quirks. Falls back to the current event's
|
|
424
|
+
// subAgentMeta if the start somehow didn't capture them (defensive).
|
|
425
|
+
const pairedMeta = start ? {
|
|
426
|
+
session_thread_id: start.session_thread_id ?? subAgentMeta.session_thread_id,
|
|
427
|
+
agent_name: start.agent_name ?? subAgentMeta.agent_name,
|
|
428
|
+
composition_pattern: (start.session_thread_id != null && subThreadIds.has(start.session_thread_id))
|
|
429
|
+
? COMPOSITION_PATTERNS.HIERARCHY
|
|
430
|
+
: COMPOSITION_PATTERNS.SOLO,
|
|
431
|
+
parent_agent_id: null, // see subAgentMeta — Anthropic shares parent's agent_id
|
|
432
|
+
} : subAgentMeta;
|
|
361
433
|
yield {
|
|
362
434
|
...base,
|
|
363
|
-
...
|
|
435
|
+
...pairedMeta,
|
|
364
436
|
id: ev.id,
|
|
365
437
|
action_type: start?.isMcp ? 'mcp_tool_use' : 'tool_use',
|
|
366
438
|
tool_name: start?.name || 'unknown',
|
|
@@ -447,6 +519,14 @@ export async function* fetchSessionEntries({ apiKey, agentId, sessionId, model }
|
|
|
447
519
|
}
|
|
448
520
|
|
|
449
521
|
if (type === 'session.thread_created') {
|
|
522
|
+
// v1.1.3 Phase 1.C: register the newly-spawned thread as a sub-thread
|
|
523
|
+
// BEFORE yielding so subsequent events with this session_thread_id are
|
|
524
|
+
// correctly marked composition_pattern='hierarchy'. The thread_created
|
|
525
|
+
// event itself is yielded with the pattern computed before this line —
|
|
526
|
+
// it's the PARENT's act of spawning, so 'solo' is correct here (the
|
|
527
|
+
// parent did this act in its own context); only the events that
|
|
528
|
+
// FOLLOW inside the new thread get 'hierarchy'.
|
|
529
|
+
if (ev.session_thread_id) subThreadIds.add(ev.session_thread_id);
|
|
450
530
|
yield {
|
|
451
531
|
...base,
|
|
452
532
|
...subAgentMeta,
|
|
@@ -587,7 +667,12 @@ function extractText(content) {
|
|
|
587
667
|
|
|
588
668
|
export class AnthropicManagedSource extends Source {
|
|
589
669
|
static providerName = PROVIDERS.ANTHROPIC_MANAGED;
|
|
590
|
-
|
|
670
|
+
// v1.1.3: renamed from `enforcementMode` — the static field is the MAX
|
|
671
|
+
// capability the provider exposes; the EFFECTIVE per-agent mode is
|
|
672
|
+
// resolved at runtime via effectiveEnforcementMode() since v1.0.1 F-2.
|
|
673
|
+
// The `enforcementMode` getter inherited from Source.enforcementMode
|
|
674
|
+
// returns this value, so callers reading either name still work.
|
|
675
|
+
static enforcementCapability = ENFORCEMENT_MODES.SYNC_CONFIRM;
|
|
591
676
|
|
|
592
677
|
constructor({ apiKey } = {}) {
|
|
593
678
|
super({ apiKey });
|
package/src/sources/contract.js
CHANGED
|
@@ -29,26 +29,76 @@
|
|
|
29
29
|
// Every WMAAction.action_type MUST be one of these. New adapters that
|
|
30
30
|
// emit a novel kind of action should propose adding a new constant here
|
|
31
31
|
// (and document it) rather than inventing one inline.
|
|
32
|
+
//
|
|
33
|
+
// Audit note (Phase 1.A, v1.1.3): the vocabulary is grouped into two
|
|
34
|
+
// tiers to make the contract honest about which types are framework-
|
|
35
|
+
// agnostic vs which originated from one specific framework (Anthropic
|
|
36
|
+
// Managed Agents was the seed adapter). New adapters can:
|
|
37
|
+
// 1. Use UNIVERSAL types directly — they're always meaningful.
|
|
38
|
+
// 2. Use FRAMEWORK-SPECIFIC types ONLY if the new framework has a
|
|
39
|
+
// genuinely equivalent concept. Otherwise emit a UNIVERSAL type
|
|
40
|
+
// (e.g., OpenAI handoffs → HANDOFF, NOT THREAD_MESSAGE_SENT).
|
|
41
|
+
// 3. Propose a new constant via a PR when their framework exposes a
|
|
42
|
+
// genuinely new category of event.
|
|
32
43
|
export const ACTION_TYPES = Object.freeze({
|
|
44
|
+
// ── UNIVERSAL (every adapter should be able to emit these) ─────────────
|
|
45
|
+
/** Model inference call (with token usage + duration). */
|
|
33
46
|
LLM_CALL: 'llm_call',
|
|
47
|
+
/** Provider-built-in tool invocation (web_search, web_fetch, bash, code_exec, …). */
|
|
34
48
|
TOOL_USE: 'tool_use',
|
|
49
|
+
/** MCP server tool invocation. */
|
|
35
50
|
MCP_TOOL_USE: 'mcp_tool_use',
|
|
51
|
+
/** Customer-defined tool invocation (the agent calls a tool the user wired). */
|
|
36
52
|
CUSTOM_TOOL_USE: 'custom_tool_use',
|
|
53
|
+
/** Customer returned the result of a custom tool. */
|
|
37
54
|
CUSTOM_TOOL_RESULT: 'custom_tool_result',
|
|
55
|
+
/** Human (or orchestrator) approved/denied a pending tool call. */
|
|
38
56
|
TOOL_CONFIRMATION: 'tool_confirmation',
|
|
57
|
+
/** User sent input to the agent (prompt, follow-up question, …). */
|
|
39
58
|
USER_MESSAGE: 'user_message',
|
|
59
|
+
/** User cancelled execution mid-flight. */
|
|
40
60
|
USER_INTERRUPT: 'user_interrupt',
|
|
61
|
+
/** Agent emitted an output message (final reply or intermediate). */
|
|
41
62
|
MESSAGE: 'message',
|
|
63
|
+
/** Agent emitted internal reasoning (extended thinking / scratchpad). */
|
|
42
64
|
THINKING: 'thinking',
|
|
65
|
+
/** Session-level error from the provider runtime. */
|
|
66
|
+
SESSION_ERROR: 'session_error',
|
|
67
|
+
/** Agent A passes control to agent B (OpenAI Agents handoffs, AgentCore
|
|
68
|
+
* multi-agent, CrewAI manager delegation, Hermes spawn_subagent, LangGraph
|
|
69
|
+
* subgraph spawn). For Anthropic Task tool, this is typically emitted as
|
|
70
|
+
* THREAD_MESSAGE_SENT — both are valid for that framework. */
|
|
71
|
+
HANDOFF: 'handoff',
|
|
72
|
+
/** Graph-flavored frameworks (LangGraph, AutoGen state machine,
|
|
73
|
+
* conditional workflow engines) emit this when execution moves from one
|
|
74
|
+
* node/state to another. Carries `from_node`/`to_node` in `output`. */
|
|
75
|
+
GRAPH_NODE_TRANSITION: 'graph_node_transition',
|
|
76
|
+
/** Shield-internal — emitted when WMA itself blocks/allows/interrupts
|
|
77
|
+
* an action. Always carries the decision in `output`. */
|
|
78
|
+
SHIELD_DECISION: 'shield_decision',
|
|
79
|
+
|
|
80
|
+
// ── FRAMEWORK-SPECIFIC (Anthropic Managed Agents-origin, may map cleanly to
|
|
81
|
+
// other frameworks but were named after the Anthropic event vocabulary) ──
|
|
82
|
+
/** Anthropic-specific: emitted when the context window saturates and the
|
|
83
|
+
* thread is compacted (some history lost). OpenAI/CrewAI/LangGraph
|
|
84
|
+
* generally roll the window silently without an explicit event. */
|
|
43
85
|
CONTEXT_COMPACTED: 'context_compacted',
|
|
86
|
+
/** Anthropic-specific: a sub-thread was spawned within a session (Task
|
|
87
|
+
* tool delegation). Other frameworks model this as HANDOFF + a new
|
|
88
|
+
* agent identity. */
|
|
44
89
|
THREAD_CREATED: 'thread_created',
|
|
90
|
+
/** Anthropic-specific: inter-agent message in a thread (parent → sub-agent). */
|
|
45
91
|
THREAD_MESSAGE_SENT: 'thread_message_sent',
|
|
92
|
+
/** Anthropic-specific: reply from a sub-agent in a thread (sub → parent). */
|
|
46
93
|
THREAD_MESSAGE_RECEIVED: 'thread_message_received',
|
|
94
|
+
/** Anthropic-specific: session configuration changed mid-flight
|
|
95
|
+
* (`session.updated` event with a diff). Other frameworks generally
|
|
96
|
+
* don't expose live config edits as events. */
|
|
47
97
|
CONFIG_CHANGE: 'config_change',
|
|
98
|
+
/** Anthropic-specific: session/thread lifecycle state change (running/
|
|
99
|
+
* idle/rescheduled/terminated). Other frameworks have different state
|
|
100
|
+
* machines; map cautiously. */
|
|
48
101
|
STATE_TRANSITION: 'state_transition',
|
|
49
|
-
SESSION_ERROR: 'session_error',
|
|
50
|
-
// Shield-only — emitted when WMA itself blocks an action:
|
|
51
|
-
SHIELD_DECISION: 'shield_decision',
|
|
52
102
|
});
|
|
53
103
|
|
|
54
104
|
export const STATUS_VALUES = Object.freeze({
|
|
@@ -185,19 +235,35 @@ export function validateWMAAction(obj) {
|
|
|
185
235
|
* provider-agnostic.
|
|
186
236
|
*
|
|
187
237
|
* Static contract:
|
|
188
|
-
* providerName
|
|
189
|
-
*
|
|
238
|
+
* providerName — value from PROVIDERS
|
|
239
|
+
* enforcementCapability — value from ENFORCEMENT_MODES; the MAX the
|
|
240
|
+
* provider can do. The EFFECTIVE per-agent
|
|
241
|
+
* mode may be weaker (resolved at runtime
|
|
242
|
+
* via the provider's effectiveEnforcementMode
|
|
243
|
+
* helper; see AnthropicManagedSource for the
|
|
244
|
+
* reference impl).
|
|
245
|
+
*
|
|
246
|
+
* ⚠️ Renamed from `enforcementMode` in v1.1.3 because the previous
|
|
247
|
+
* name was misleading (a static field is the capability ceiling,
|
|
248
|
+
* not the runtime mode). `enforcementMode` is kept as a backward-
|
|
249
|
+
* compat alias for v1.x consumers; it returns the same value.
|
|
190
250
|
*
|
|
191
251
|
* Instance contract:
|
|
192
|
-
* listAgents()
|
|
193
|
-
* streamEvents(id)
|
|
194
|
-
* enforce(action, d)
|
|
252
|
+
* listAgents() — return all agents accessible with the client creds
|
|
253
|
+
* streamEvents(id) — async generator yielding WMAAction objects
|
|
254
|
+
* enforce(action, d) — only required if enforcementCapability != detect_only
|
|
195
255
|
*
|
|
196
256
|
* See `docs/SOURCE-ADAPTER-CONTRACT.md` for the full author guide.
|
|
197
257
|
*/
|
|
198
258
|
export class Source {
|
|
199
259
|
static providerName = null;
|
|
200
|
-
static
|
|
260
|
+
static enforcementCapability = null;
|
|
261
|
+
// v1.1.3 backwards-compat alias for the renamed static field. Subclasses
|
|
262
|
+
// that set `enforcementMode` directly still work; new subclasses should
|
|
263
|
+
// set `enforcementCapability` instead. Resolved as a getter at read
|
|
264
|
+
// time so the rename can land without breaking consumers like
|
|
265
|
+
// assertImplementsSource() that read it from the class.
|
|
266
|
+
static get enforcementMode() { return this.enforcementCapability; }
|
|
201
267
|
|
|
202
268
|
constructor(config = {}) {
|
|
203
269
|
if (new.target === Source) {
|
|
@@ -237,7 +303,7 @@ export class Source {
|
|
|
237
303
|
* @returns {Promise<{enforced: boolean, native_response?: object}>}
|
|
238
304
|
*/
|
|
239
305
|
async enforce(action, decision) { // eslint-disable-line no-unused-vars
|
|
240
|
-
if (this.constructor.
|
|
306
|
+
if (this.constructor.enforcementCapability === ENFORCEMENT_MODES.DETECT_ONLY) {
|
|
241
307
|
throw new Error(`${this.constructor.name} is detect_only — enforce() must not be called`);
|
|
242
308
|
}
|
|
243
309
|
throw new Error(`${this.constructor.name}.enforce() not implemented`);
|
|
@@ -256,8 +322,11 @@ export function assertImplementsSource(SourceClass) {
|
|
|
256
322
|
if (!Object.values(PROVIDERS).includes(SourceClass.providerName)) {
|
|
257
323
|
throw new Error(`${SourceClass.name}.providerName="${SourceClass.providerName}" not in PROVIDERS`);
|
|
258
324
|
}
|
|
259
|
-
|
|
260
|
-
|
|
325
|
+
// v1.1.3: read enforcementCapability (the new canonical name) but fall
|
|
326
|
+
// back to enforcementMode for legacy subclasses that haven't migrated.
|
|
327
|
+
const capability = SourceClass.enforcementCapability ?? SourceClass.enforcementMode;
|
|
328
|
+
if (!Object.values(ENFORCEMENT_MODES).includes(capability)) {
|
|
329
|
+
throw new Error(`${SourceClass.name}.enforcementCapability="${capability}" not in ENFORCEMENT_MODES`);
|
|
261
330
|
}
|
|
262
331
|
// The base class throws "not implemented" — a real subclass must override.
|
|
263
332
|
for (const m of ['listAgents', 'streamEvents']) {
|
|
@@ -265,8 +334,8 @@ export function assertImplementsSource(SourceClass) {
|
|
|
265
334
|
throw new Error(`${SourceClass.name}.${m}() must be overridden`);
|
|
266
335
|
}
|
|
267
336
|
}
|
|
268
|
-
if (
|
|
337
|
+
if (capability !== ENFORCEMENT_MODES.DETECT_ONLY
|
|
269
338
|
&& SourceClass.prototype.enforce === Source.prototype.enforce) {
|
|
270
|
-
throw new Error(`${SourceClass.name}.enforce() must be overridden (
|
|
339
|
+
throw new Error(`${SourceClass.name}.enforce() must be overridden (enforcementCapability=${capability})`);
|
|
271
340
|
}
|
|
272
341
|
}
|