watchmyagents 0.1.0 → 0.2.0

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 CHANGED
@@ -170,12 +170,35 @@ For added safety, generate a **workspace-scoped** API key with read-only permiss
170
170
 
171
171
  Report vulnerabilities via [SECURITY.md](./SECURITY.md).
172
172
 
173
+ ## Shield — real-time policy enforcement
174
+
175
+ `wma-shield` (shipped in v0.2.0) is the real-time enforcement companion to Watch. It streams agent events live, evaluates them against a local JSON policy file, and blocks tool calls that violate the policy via `user.tool_confirmation` (when the agent has `permission_policy: always_ask` configured) or `user.interrupt` (zero-setup fallback).
176
+
177
+ ```bash
178
+ # Agent-wide mode — attaches to ALL active sessions of the agent automatically.
179
+ # Run under a process supervisor (systemd, pm2, docker) for production.
180
+ wma-shield --agent-id agent_xxx --policy ./policies.json
181
+ ```
182
+
183
+ Shield auto-detects the best enforcement mode at startup:
184
+ - **tool_confirmation** (precise, pre-execution blocking) when at least one tool has `permission_policy: always_ask`
185
+ - **interrupt** (degraded, post-execution termination) otherwise
186
+
187
+ For the precise mode setup instructions:
188
+ ```bash
189
+ wma-shield --setup-guide --agent-id agent_xxx
190
+ ```
191
+
192
+ Decisions are logged to the same NDJSON stream as Watch (`action_type: shield_decision`), so `wma-inspect` surfaces them in its audit summaries.
193
+
173
194
  ## Status
174
195
 
175
- - ✅ Anthropic Managed Agents (post-hoc fetch + audit)
196
+ - ✅ Watch SDK — Anthropic Managed Agents post-hoc fetch + local audit
197
+ - ✅ Shield SDK — real-time enforcement (interrupt mode + tool_confirmation mode)
176
198
  - 🚧 Encrypted upload to customer's own cloud (S3/GCS/Azure with `age` public-key encryption)
177
199
  - 🚧 Anonymized telemetry to WMA cloud (opt-in, freemium model)
178
- - 🚧 Shield productreal-time policy gating via `user.tool_confirmation` + `user.interrupt`
200
+ - 🚧 Guardian AI (cloud) automatic policy suggestions from observed behavior
201
+ - 🚧 Fortress (cloud) — dashboard + human-in-the-loop validation queue
179
202
  - 🚧 Adapters for in-process agents (Claude SDK, OpenAI, LangChain, generic) — code present in `src/adapters/` but unverified against the new Modèle C architecture; documentation will follow once re-validated
180
203
 
181
204
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "watchmyagents",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Security observability for AI agents — local-first NDJSON capture of every agent action (tool calls, prompts, state transitions, errors). Built for security audits, not just token counting.",
5
5
  "type": "module",
6
6
  "main": "./src/index.cjs",
@@ -19,17 +19,20 @@
19
19
  "src/",
20
20
  "scripts/inspect.js",
21
21
  "scripts/fetch-anthropic.js",
22
+ "scripts/shield.js",
22
23
  "README.md",
23
24
  "SECURITY.md",
24
25
  "LICENSE"
25
26
  ],
26
27
  "bin": {
27
28
  "wma-inspect": "scripts/inspect.js",
28
- "wma-fetch": "scripts/fetch-anthropic.js"
29
+ "wma-fetch": "scripts/fetch-anthropic.js",
30
+ "wma-shield": "scripts/shield.js"
29
31
  },
30
32
  "scripts": {
31
33
  "inspect": "node scripts/inspect.js",
32
34
  "fetch": "node scripts/fetch-anthropic.js",
35
+ "shield": "node scripts/shield.js",
33
36
  "example": "node examples/claude-agent/index.js"
34
37
  },
35
38
  "engines": {
@@ -0,0 +1,397 @@
1
+ #!/usr/bin/env node
2
+ // wma-shield — real-time policy enforcement for Anthropic Managed Agents.
3
+ //
4
+ // Two modes:
5
+ //
6
+ // AGENT-WIDE (production) — wma-shield --agent-id agent_xxx
7
+ // Attaches to ALL active sessions of the agent. Discovers new sessions
8
+ // via periodic listSessions polling. Runs forever until SIGINT.
9
+ //
10
+ // SINGLE-SESSION (testing) — wma-shield --agent-id agent_xxx --session-id sesn_xxx
11
+ // Attaches to one specific session and exits when that session ends.
12
+ //
13
+ // Within each session, Shield uses one of two enforcement modes auto-detected
14
+ // at startup from the agent config:
15
+ //
16
+ // tool_confirmation — when at least one tool has permission_policy:always_ask.
17
+ // Blocks tool calls BEFORE execution.
18
+ // interrupt — when no tool has always_ask. Reactive: terminates the
19
+ // session AFTER a violating tool ran. Zero setup required.
20
+ //
21
+ // Setup helper:
22
+ // wma-shield --setup-guide --agent-id agent_xxx
23
+ // → prints instructions to upgrade to tool_confirmation mode.
24
+ //
25
+ // ANTHROPIC_API_KEY env var is used if --api-key is omitted.
26
+
27
+ import { resolve } from 'node:path';
28
+ import { streamWithReconnect } from '../src/shield/stream.js';
29
+ import { loadPolicies, evaluate } from '../src/shield/policy.js';
30
+ import {
31
+ confirmAllow, confirmDeny, interruptSession,
32
+ getAgentConfig, detectAlwaysAsk,
33
+ } from '../src/shield/enforce.js';
34
+ import { DecisionLogger } from '../src/shield/decisions.js';
35
+ import { listSessions } from '../src/sources/anthropic-managed.js';
36
+
37
+ function parseArgs(argv) {
38
+ const out = {};
39
+ for (let i = 0; i < argv.length; i++) {
40
+ const a = argv[i];
41
+ if (a.startsWith('--')) {
42
+ const k = a.slice(2);
43
+ const n = argv[i + 1];
44
+ if (n == null || n.startsWith('--')) out[k] = true;
45
+ else { out[k] = n; i++; }
46
+ }
47
+ }
48
+ return out;
49
+ }
50
+
51
+ function die(msg, code = 1) { process.stderr.write(`${msg}\n`); process.exit(code); }
52
+ function info(msg) { process.stdout.write(`[shield] ${msg}\n`); }
53
+ function warn(msg) { process.stderr.write(`[shield] ⚠️ ${msg}\n`); }
54
+ function sinfo(sid, msg) { process.stdout.write(`[shield/${sid.slice(0, 12)}] ${msg}\n`); }
55
+ function swarn(sid, msg) { process.stderr.write(`[shield/${sid.slice(0, 12)}] ⚠️ ${msg}\n`); }
56
+
57
+ const CACHEABLE_TOOL_TYPES = new Set([
58
+ 'agent.tool_use', 'agent.mcp_tool_use', 'agent.custom_tool_use',
59
+ ]);
60
+
61
+ // Session statuses that mean "still active, worth watching"
62
+ const ACTIVE_STATUSES = new Set(['running', 'idle', 'rescheduled']);
63
+
64
+ function normalizeForPolicy(rawEvent) {
65
+ return {
66
+ action_type: rawEvent.type === 'agent.tool_use' ? 'tool_use'
67
+ : rawEvent.type === 'agent.mcp_tool_use' ? 'mcp_tool_use'
68
+ : rawEvent.type === 'agent.custom_tool_use' ? 'custom_tool_use'
69
+ : 'unknown',
70
+ tool_name: rawEvent.name || 'unknown',
71
+ input: rawEvent.input ?? null,
72
+ _raw_type: rawEvent.type,
73
+ _raw_id: rawEvent.id,
74
+ };
75
+ }
76
+
77
+ function printSetupGuide(agentId) {
78
+ process.stdout.write(`
79
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
80
+ Shield setup guide — upgrade your agent to precise mode
81
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
82
+
83
+ Without permission_policy: always_ask configured on your agent's
84
+ tools, Shield runs in DEGRADED mode (interrupts the session AFTER
85
+ a violating tool already executed).
86
+
87
+ For pre-execution blocking, your agent's "tools" array needs:
88
+
89
+ {
90
+ "type": "agent_toolset_20260401",
91
+ "default_config": {
92
+ "permission_policy": { "type": "always_ask" }
93
+ }
94
+ }
95
+
96
+ Anthropic's API does NOT support PATCH on /v1/agents, so options:
97
+
98
+ Option A — Edit in the Anthropic Console (recommended):
99
+ 1. Visit https://console.anthropic.com/agents/${agentId}
100
+ 2. Edit the agent
101
+ 3. Set default_config.permission_policy to { "type": "always_ask" }
102
+ 4. Save. NEW sessions use the updated permission policy.
103
+
104
+ Option B — Recreate the agent via API (returns a new agent_id):
105
+ Use POST /v1/agents with your current config + the snippet above.
106
+
107
+ After either option, restart Shield — it auto-detects the new mode.
108
+
109
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
110
+ `);
111
+ }
112
+
113
+ // ────────────────────────────────────────────────────────────────────────
114
+ // Per-session worker — runs one event loop, returns when session ends.
115
+ // ────────────────────────────────────────────────────────────────────────
116
+ async function runSessionWorker({ sessionId, ctx }) {
117
+ const { apiKey, agentId, ruleset, mode, decisions, signal } = ctx;
118
+ sinfo(sessionId, `attached (${mode} mode)`);
119
+
120
+ let processed = 0, enforced = 0, sessionInterrupted = false;
121
+ const toolUseCache = new Map();
122
+
123
+ try {
124
+ for await (const rawEvent of streamWithReconnect({
125
+ apiKey, sessionId, signal, maxAttempts: 3,
126
+ onReconnect: ({ attempt, backoffMs, error }) => {
127
+ sinfo(sessionId, `reconnect attempt ${attempt}/3 in ${backoffMs}ms (${error.message})`);
128
+ },
129
+ })) {
130
+ processed++;
131
+
132
+ // ── INTERRUPT MODE ──────────────────────────────────────────────
133
+ if (mode === 'interrupt' && CACHEABLE_TOOL_TYPES.has(rawEvent.type)) {
134
+ toolUseCache.set(rawEvent.id, rawEvent);
135
+ const normalized = normalizeForPolicy(rawEvent);
136
+ const t0 = Date.now();
137
+ const result = evaluate(normalized, ruleset);
138
+ const decidedInMs = Date.now() - t0;
139
+
140
+ sinfo(sessionId, `${rawEvent.type} tool=${normalized.tool_name} → ${result.decision}${result.rule_id ? ` (${result.rule_id})` : ''}`);
141
+
142
+ await decisions(sessionId).record({
143
+ sourceEvent: rawEvent, decision: result.decision,
144
+ ruleId: result.rule_id, ruleName: result.rule_name,
145
+ message: result.message, decidedInMs,
146
+ });
147
+
148
+ if ((result.decision === 'deny' || result.decision === 'interrupt') && !sessionInterrupted) {
149
+ try {
150
+ await interruptSession({
151
+ apiKey, sessionId,
152
+ followUpMessage: `Shield interrupted: ${result.message || result.rule_name || 'policy violation'}`,
153
+ });
154
+ sessionInterrupted = true;
155
+ enforced++;
156
+ swarn(sessionId, 'session interrupted — agent loop stopped');
157
+ } catch (e) {
158
+ process.stderr.write(`[shield/${sessionId.slice(0, 12)}] interrupt error: ${e.message}\n`);
159
+ }
160
+ }
161
+ continue;
162
+ }
163
+
164
+ // ── TOOL_CONFIRMATION MODE ──────────────────────────────────────
165
+ if (mode === 'tool_confirmation' && CACHEABLE_TOOL_TYPES.has(rawEvent.type)) {
166
+ toolUseCache.set(rawEvent.id, rawEvent);
167
+ continue;
168
+ }
169
+
170
+ if (mode === 'tool_confirmation'
171
+ && rawEvent.type === 'session.status_idle'
172
+ && rawEvent.stop_reason?.type === 'requires_action'
173
+ && Array.isArray(rawEvent.stop_reason.event_ids)) {
174
+
175
+ for (const eventId of rawEvent.stop_reason.event_ids) {
176
+ const sourceEvent = toolUseCache.get(eventId);
177
+ if (!sourceEvent) {
178
+ swarn(sessionId, `requires_action for unknown event_id ${eventId} — denying defensively`);
179
+ try {
180
+ await confirmDeny({
181
+ apiKey, sessionId, toolUseId: eventId,
182
+ denyMessage: 'Shield never saw the original tool_use. Denying defensively.',
183
+ });
184
+ } catch (e) {
185
+ process.stderr.write(`[shield/${sessionId.slice(0, 12)}] enforcement error: ${e.message}\n`);
186
+ }
187
+ continue;
188
+ }
189
+
190
+ const normalized = normalizeForPolicy(sourceEvent);
191
+ const t0 = Date.now();
192
+ const result = evaluate(normalized, ruleset);
193
+ const decidedInMs = Date.now() - t0;
194
+
195
+ sinfo(sessionId, `requires_action ${sourceEvent.type} tool=${normalized.tool_name} → ${result.decision}${result.rule_id ? ` (${result.rule_id})` : ''}`);
196
+
197
+ await decisions(sessionId).record({
198
+ sourceEvent, decision: result.decision,
199
+ ruleId: result.rule_id, ruleName: result.rule_name,
200
+ message: result.message, decidedInMs,
201
+ });
202
+
203
+ try {
204
+ if (result.decision === 'allow') {
205
+ await confirmAllow({ apiKey, sessionId, toolUseId: eventId });
206
+ enforced++;
207
+ } else if (result.decision === 'deny') {
208
+ await confirmDeny({
209
+ apiKey, sessionId, toolUseId: eventId,
210
+ denyMessage: result.message || `Blocked by ${result.rule_name}`,
211
+ });
212
+ enforced++;
213
+ } else if (result.decision === 'interrupt') {
214
+ await interruptSession({
215
+ apiKey, sessionId,
216
+ followUpMessage: `Shield interrupted: ${result.message || result.rule_name}`,
217
+ });
218
+ sessionInterrupted = true;
219
+ enforced++;
220
+ break;
221
+ }
222
+ } catch (e) {
223
+ process.stderr.write(`[shield/${sessionId.slice(0, 12)}] enforcement error on event ${eventId}: ${e.message}\n`);
224
+ }
225
+
226
+ toolUseCache.delete(eventId);
227
+ }
228
+ continue;
229
+ }
230
+
231
+ // Session ended → exit worker cleanly.
232
+ if (rawEvent.type === 'session.status_terminated') {
233
+ sinfo(sessionId, `session terminated: ${rawEvent.stop_reason?.type || 'unknown'}`);
234
+ break;
235
+ }
236
+ }
237
+ } catch (e) {
238
+ if (!signal.aborted) {
239
+ process.stderr.write(`[shield/${sessionId.slice(0, 12)}] worker error: ${e.message}\n`);
240
+ }
241
+ }
242
+
243
+ sinfo(sessionId, `worker exit — observed ${processed}, enforced ${enforced}`);
244
+ return { processed, enforced };
245
+ }
246
+
247
+ // ────────────────────────────────────────────────────────────────────────
248
+ // Agent-wide discovery — polls listSessions and spawns workers for new ones.
249
+ // ────────────────────────────────────────────────────────────────────────
250
+ async function runAgentWide(ctx) {
251
+ const { apiKey, agentId, signal } = ctx;
252
+ const workers = new Map(); // sessionId → AbortController (active workers)
253
+ const cooldown = new Map(); // sessionId → unix-ms timestamp when re-attach is allowed
254
+
255
+ const POLL_INTERVAL_MS = 10_000;
256
+ // When a worker exits without seeing any events, the session's SSE stream
257
+ // closed cleanly with no traffic — Anthropic does this for idle sessions.
258
+ // Re-attaching every 10s spams the logs and the API for no benefit; cool down
259
+ // for 60s before trying again. Any real activity invalidates the cooldown.
260
+ const QUIET_COOLDOWN_MS = 60_000;
261
+
262
+ async function discoverAndAttach() {
263
+ let sessions;
264
+ try {
265
+ // Look at sessions from the last 24h (anything older that's still idle
266
+ // is probably stale; the user can extend the window if needed).
267
+ const since = new Date(Date.now() - 24 * 3600_000);
268
+ sessions = await listSessions(apiKey, { agentId, since });
269
+ } catch (e) {
270
+ warn(`listSessions failed: ${e.message}`);
271
+ return;
272
+ }
273
+
274
+ const now = Date.now();
275
+ for (const s of sessions) {
276
+ if (!s.id || workers.has(s.id)) continue;
277
+ const status = s.status?.type || s.status; // tolerate either shape
278
+ if (!ACTIVE_STATUSES.has(status)) continue;
279
+
280
+ // Honor the cooldown for sessions that recently exited quietly.
281
+ const retryAt = cooldown.get(s.id) || 0;
282
+ if (now < retryAt) continue;
283
+
284
+ // New active session — spawn a worker.
285
+ const sessionAc = new AbortController();
286
+ workers.set(s.id, sessionAc);
287
+ const combined = AbortSignal.any([signal, sessionAc.signal]);
288
+
289
+ runSessionWorker({
290
+ sessionId: s.id,
291
+ ctx: { ...ctx, signal: combined },
292
+ }).then((stats) => {
293
+ // Quiet exit → cooldown so we don't busy-loop reconnecting.
294
+ // Productive exit (at least one event observed) → clear any cooldown.
295
+ if (stats && stats.processed === 0) {
296
+ cooldown.set(s.id, Date.now() + QUIET_COOLDOWN_MS);
297
+ } else {
298
+ cooldown.delete(s.id);
299
+ }
300
+ }).finally(() => {
301
+ workers.delete(s.id);
302
+ });
303
+ }
304
+ }
305
+
306
+ info(`agent-wide mode — polling for sessions every ${POLL_INTERVAL_MS / 1000}s`);
307
+ await discoverAndAttach();
308
+
309
+ const ticker = setInterval(discoverAndAttach, POLL_INTERVAL_MS);
310
+
311
+ // Block until SIGINT/SIGTERM.
312
+ await new Promise(resolveOuter => {
313
+ signal.addEventListener('abort', () => {
314
+ clearInterval(ticker);
315
+ for (const ac of workers.values()) ac.abort();
316
+ resolveOuter();
317
+ });
318
+ });
319
+
320
+ info(`shutdown — drained ${workers.size} remaining workers`);
321
+ }
322
+
323
+ // ────────────────────────────────────────────────────────────────────────
324
+ // Main
325
+ // ────────────────────────────────────────────────────────────────────────
326
+ async function main() {
327
+ const args = parseArgs(process.argv.slice(2));
328
+ const apiKey = args['api-key'] || process.env.ANTHROPIC_API_KEY;
329
+ const agentId = args['agent-id'];
330
+
331
+ if (args['setup-guide']) {
332
+ if (!agentId) die('error: --setup-guide requires --agent-id <id>');
333
+ printSetupGuide(agentId);
334
+ process.exit(0);
335
+ }
336
+
337
+ const singleSessionId = args['session-id']; // optional now
338
+ const policyPath = args.policy;
339
+ const logDir = resolve(args['log-dir'] || './watchmyagents-logs');
340
+
341
+ if (!apiKey) die('error: --api-key or ANTHROPIC_API_KEY required');
342
+ if (!agentId) die('error: --agent-id required');
343
+ if (!policyPath) die('error: --policy <path-to-policies.json> required');
344
+
345
+ let ruleset;
346
+ try {
347
+ ruleset = await loadPolicies(resolve(policyPath));
348
+ } catch (e) {
349
+ die(`error loading policies: ${e.message}`);
350
+ }
351
+
352
+ let mode = 'interrupt';
353
+ let agentMeta = null;
354
+ try {
355
+ agentMeta = await getAgentConfig(apiKey, agentId);
356
+ if (detectAlwaysAsk(agentMeta)) mode = 'tool_confirmation';
357
+ } catch (e) {
358
+ warn(`could not fetch agent config (${e.message}). Defaulting to interrupt mode.`);
359
+ }
360
+
361
+ info(`armed — ${ruleset.policies.length} policies loaded from ${policyPath}`);
362
+ info(`default action when no rule matches: ${ruleset.default.action}`);
363
+ info(`agent: ${agentId}${agentMeta?.name ? ` "${agentMeta.name}"` : ''}`);
364
+ info(`enforcement mode: ${mode}`);
365
+ if (mode === 'interrupt') {
366
+ warn('DEGRADED mode — Shield will interrupt AFTER a violating tool runs.');
367
+ warn(`For pre-execution blocking, run: wma-shield --setup-guide --agent-id ${agentId}`);
368
+ }
369
+
370
+ // Per-session DecisionLogger factory (each session gets its own to keep
371
+ // sequence numbers monotonic per session).
372
+ const loggers = new Map();
373
+ const decisions = (sessionId) => {
374
+ if (!loggers.has(sessionId)) {
375
+ loggers.set(sessionId, new DecisionLogger({ logDir, agentId, sessionId }));
376
+ }
377
+ return loggers.get(sessionId);
378
+ };
379
+
380
+ const ac = new AbortController();
381
+ process.on('SIGINT', () => { info('SIGINT received, shutting down…'); ac.abort(); });
382
+ process.on('SIGTERM', () => { info('SIGTERM received, shutting down…'); ac.abort(); });
383
+
384
+ const ctx = { apiKey, agentId, ruleset, mode, decisions, signal: ac.signal };
385
+
386
+ if (singleSessionId) {
387
+ info(`single-session mode — attached to ${singleSessionId}`);
388
+ await runSessionWorker({ sessionId: singleSessionId, ctx });
389
+ } else {
390
+ await runAgentWide(ctx);
391
+ }
392
+ }
393
+
394
+ main().catch(e => {
395
+ process.stderr.write(`error: ${e.stack || e.message}\n`);
396
+ process.exit(1);
397
+ });
@@ -0,0 +1,46 @@
1
+ // Shield decisions logger.
2
+ //
3
+ // Writes one NDJSON line per Shield decision into the same daily-rotated
4
+ // file as Watch, with action_type: "shield_decision". This closes the
5
+ // recursive loop trivially — the next wma-fetch / wma-inspect run will
6
+ // surface Shield's actions alongside the agent's actions.
7
+
8
+ import { Logger } from '../logger.js';
9
+
10
+ export class DecisionLogger {
11
+ constructor({ logDir, agentId, sessionId }) {
12
+ this._logger = new Logger({ logDir, agentId, sessionId, silent: true });
13
+ }
14
+
15
+ // Record a decision Shield made about an upstream event. Shield's own
16
+ // action_type is 'shield_decision' — this lets aggregations (wma-inspect)
17
+ // distinguish them from the agent's own actions.
18
+ async record({
19
+ sourceEvent, // the original Anthropic event we decided on (for context)
20
+ decision, // 'allow' | 'deny' | 'interrupt'
21
+ ruleId,
22
+ ruleName,
23
+ message,
24
+ decidedInMs,
25
+ }) {
26
+ return this._logger.write({
27
+ action_type: 'shield_decision',
28
+ framework: 'anthropic-managed',
29
+ tool_name: sourceEvent?.name || sourceEvent?.tool_name || null,
30
+ status: decision === 'deny' || decision === 'interrupt' ? 'error' : 'ok',
31
+ error: decision === 'deny' || decision === 'interrupt' ? message : null,
32
+ duration_ms: decidedInMs ?? null,
33
+ input: {
34
+ source_event_id: sourceEvent?.id || null,
35
+ source_event_type: sourceEvent?.type || null,
36
+ tool_input: sourceEvent?.input ?? null,
37
+ },
38
+ output: {
39
+ decision,
40
+ rule_id: ruleId,
41
+ rule_name: ruleName,
42
+ message,
43
+ },
44
+ });
45
+ }
46
+ }
@@ -0,0 +1,104 @@
1
+ // Shield enforcement — sends user.tool_confirmation back to Anthropic
2
+ // to allow or deny a pending tool call.
3
+ //
4
+ // Per Anthropic docs (managed-agents-2026-04-01 beta), when a tool requires
5
+ // confirmation (via a permission policy on the agent), the session emits
6
+ // agent.tool_use and then pauses on session.status_idle with
7
+ // stop_reason: requires_action. The user.tool_confirmation event resolves it.
8
+
9
+ const API_BASE = 'https://api.anthropic.com';
10
+ const BETA = 'managed-agents-2026-04-01';
11
+ const VERSION = '2023-06-01';
12
+
13
+ function authHeaders(apiKey) {
14
+ return {
15
+ 'x-api-key': apiKey,
16
+ 'anthropic-version': VERSION,
17
+ 'anthropic-beta': BETA,
18
+ 'content-type': 'application/json',
19
+ };
20
+ }
21
+
22
+ // GET /v1/agents/{id} — used at Shield startup to determine which enforcement
23
+ // mode (tool_confirmation vs interrupt) is available.
24
+ export async function getAgentConfig(apiKey, agentId) {
25
+ const url = `${API_BASE}/v1/agents/${agentId}`;
26
+ const res = await fetch(url, { headers: authHeaders(apiKey) });
27
+ if (!res.ok) {
28
+ const body = await res.text().catch(() => '');
29
+ throw new Error(`getAgent failed: HTTP ${res.status}: ${body.slice(0, 300)}`);
30
+ }
31
+ return res.json();
32
+ }
33
+
34
+ // Inspect agent config to determine if any tool/toolset has
35
+ // permission_policy.type === "always_ask". When at least one tool does,
36
+ // Shield can use the precise tool_confirmation flow. Otherwise it falls
37
+ // back to user.interrupt (post-hoc termination).
38
+ export function detectAlwaysAsk(agent) {
39
+ const tools = agent?.tools || [];
40
+ const mcp = (agent?.mcp_servers || []).length > 0;
41
+ for (const t of tools) {
42
+ if (t?.default_config?.permission_policy?.type === 'always_ask') return true;
43
+ if (Array.isArray(t?.configs)) {
44
+ for (const c of t.configs) {
45
+ if (c?.permission_policy?.type === 'always_ask') return true;
46
+ }
47
+ }
48
+ // MCP toolsets default to always_ask per Anthropic docs (if any MCP server
49
+ // is attached but no explicit always_allow override is set).
50
+ if (t?.type === 'mcp_toolset' && !t?.default_config?.permission_policy) {
51
+ return true;
52
+ }
53
+ }
54
+ // If the agent has MCP servers but no explicit mcp_toolset config, MCP
55
+ // defaults are always_ask — so we still get requires_action for MCP calls.
56
+ return mcp;
57
+ }
58
+
59
+ async function sendEvents(apiKey, sessionId, events) {
60
+ const url = `${API_BASE}/v1/sessions/${sessionId}/events?beta=true`;
61
+ const res = await fetch(url, {
62
+ method: 'POST',
63
+ headers: authHeaders(apiKey),
64
+ body: JSON.stringify({ events }),
65
+ });
66
+ if (!res.ok) {
67
+ const body = await res.text().catch(() => '');
68
+ throw new Error(`enforce failed: HTTP ${res.status}: ${body.slice(0, 300)}`);
69
+ }
70
+ return res;
71
+ }
72
+
73
+ // Approve a pending tool_use by its event id.
74
+ export function confirmAllow({ apiKey, sessionId, toolUseId }) {
75
+ return sendEvents(apiKey, sessionId, [{
76
+ type: 'user.tool_confirmation',
77
+ tool_use_id: toolUseId,
78
+ result: 'allow',
79
+ }]);
80
+ }
81
+
82
+ // Deny a pending tool_use with an explanatory message that surfaces to the
83
+ // agent (the agent sees the deny_message in its tool_result).
84
+ export function confirmDeny({ apiKey, sessionId, toolUseId, denyMessage }) {
85
+ return sendEvents(apiKey, sessionId, [{
86
+ type: 'user.tool_confirmation',
87
+ tool_use_id: toolUseId,
88
+ result: 'deny',
89
+ deny_message: denyMessage || 'Blocked by Shield policy',
90
+ }]);
91
+ }
92
+
93
+ // Interrupt the entire session (stops the agent loop). Used for serious
94
+ // policy violations where letting the agent continue is unsafe.
95
+ export function interruptSession({ apiKey, sessionId, followUpMessage }) {
96
+ const events = [{ type: 'user.interrupt' }];
97
+ if (followUpMessage) {
98
+ events.push({
99
+ type: 'user.message',
100
+ content: [{ type: 'text', text: followUpMessage }],
101
+ });
102
+ }
103
+ return sendEvents(apiKey, sessionId, events);
104
+ }
@@ -0,0 +1,112 @@
1
+ // Shield policy engine — JSON parser + match evaluator.
2
+ // Zero dependencies. JSON intentionally over YAML to keep the SDK dep-free.
3
+ //
4
+ // Match spec format (matches the future Fortress JSONB schema):
5
+ //
6
+ // {
7
+ // "match": {
8
+ // "action_type": "tool_use",
9
+ // "tool_name": { "not_in": ["web_search", "web_fetch"] },
10
+ // "input.url": { "not_regex": "^https://(github|wikipedia)\\.com/" }
11
+ // },
12
+ // "action": "deny",
13
+ // "message": "..."
14
+ // }
15
+ //
16
+ // Supported conditions on a field value:
17
+ // - literal value → strict equality
18
+ // - { in: [...] } → value must be in the list
19
+ // - { not_in: [...] } → value must NOT be in the list
20
+ // - { regex: "..." } → string match against the regex
21
+ // - { not_regex: "..." } → string must NOT match the regex
22
+ // - { regex_any: [...] } → string matches at least one of the regexes
23
+ //
24
+ // Field paths use dotted notation (`input.url`, `output.content.text`).
25
+
26
+ import { readFile } from 'node:fs/promises';
27
+
28
+ export async function loadPolicies(path) {
29
+ const raw = await readFile(path, 'utf8');
30
+ const data = JSON.parse(raw);
31
+ if (!data.policies || !Array.isArray(data.policies)) {
32
+ throw new Error(`policy file ${path} has no "policies" array`);
33
+ }
34
+ // Pre-compile regex for performance + early failure on bad patterns.
35
+ for (const p of data.policies) {
36
+ compileMatchRegexes(p.match || {});
37
+ if (!['allow', 'deny', 'interrupt'].includes(p.action)) {
38
+ throw new Error(`policy ${p.id || p.name}: unsupported action "${p.action}"`);
39
+ }
40
+ }
41
+ data.default = data.default || { action: 'allow' };
42
+ return data;
43
+ }
44
+
45
+ function compileMatchRegexes(match) {
46
+ for (const condition of Object.values(match)) {
47
+ if (condition && typeof condition === 'object') {
48
+ if (condition.regex) condition._regex = new RegExp(condition.regex);
49
+ if (condition.not_regex) condition._not_regex = new RegExp(condition.not_regex);
50
+ if (condition.regex_any) condition._regex_any = condition.regex_any.map(r => new RegExp(r));
51
+ }
52
+ }
53
+ }
54
+
55
+ function getNested(obj, path) {
56
+ return path.split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj);
57
+ }
58
+
59
+ function matchValue(value, condition) {
60
+ // Literal scalar match
61
+ if (condition === null || typeof condition !== 'object') {
62
+ return value === condition;
63
+ }
64
+ if (Array.isArray(condition)) {
65
+ return condition.includes(value);
66
+ }
67
+ if (condition.in !== undefined) return condition.in.includes(value);
68
+ if (condition.not_in !== undefined) return !condition.not_in.includes(value);
69
+ if (condition._regex !== undefined) {
70
+ return typeof value === 'string' && condition._regex.test(value);
71
+ }
72
+ if (condition._not_regex !== undefined) {
73
+ return typeof value === 'string' && !condition._not_regex.test(value);
74
+ }
75
+ if (condition._regex_any !== undefined) {
76
+ return typeof value === 'string' && condition._regex_any.some(r => r.test(value));
77
+ }
78
+ // Unknown condition shape — defensive: fail-closed (no match) so unknown
79
+ // conditions never silently allow events.
80
+ return false;
81
+ }
82
+
83
+ // Evaluate a single policy against an event. Returns true iff every match
84
+ // clause is satisfied. A match clause with an undefined target field still
85
+ // counts as "no match" rather than "any match".
86
+ export function matchesPolicy(event, policy) {
87
+ for (const [field, condition] of Object.entries(policy.match || {})) {
88
+ const value = getNested(event, field);
89
+ if (!matchValue(value, condition)) return false;
90
+ }
91
+ return true;
92
+ }
93
+
94
+ // First-match-wins evaluation. Returns the policy decision and metadata.
95
+ export function evaluate(event, ruleset) {
96
+ for (const policy of ruleset.policies) {
97
+ if (matchesPolicy(event, policy)) {
98
+ return {
99
+ decision: policy.action,
100
+ rule_id: policy.id || null,
101
+ rule_name: policy.name || null,
102
+ message: policy.message || null,
103
+ };
104
+ }
105
+ }
106
+ return {
107
+ decision: ruleset.default?.action || 'allow',
108
+ rule_id: null,
109
+ rule_name: '(default)',
110
+ message: null,
111
+ };
112
+ }
@@ -0,0 +1,101 @@
1
+ // Anthropic Managed Agents SSE stream client.
2
+ //
3
+ // Opens GET /v1/sessions/{id}/events/stream and yields one parsed event per
4
+ // SSE `data:` line. Handles reconnection on stream drop (exponential backoff,
5
+ // max attempts configurable).
6
+ //
7
+ // Uses built-in fetch + ReadableStream (Node 18+). Zero deps.
8
+
9
+ const API_BASE = 'https://api.anthropic.com';
10
+ const BETA = 'managed-agents-2026-04-01';
11
+ const VERSION = '2023-06-01';
12
+
13
+ function authHeaders(apiKey) {
14
+ return {
15
+ 'x-api-key': apiKey,
16
+ 'anthropic-version': VERSION,
17
+ 'anthropic-beta': BETA,
18
+ 'accept': 'text/event-stream',
19
+ };
20
+ }
21
+
22
+ // Async generator that yields parsed event objects from the SSE stream.
23
+ // Caller decides what to do with each (typically: evaluate policy + enforce).
24
+ //
25
+ // On stream end or network error, the generator throws. The caller should
26
+ // wrap it in retry logic if appropriate.
27
+ export async function* openEventStream({ apiKey, sessionId, signal }) {
28
+ const url = `${API_BASE}/v1/sessions/${sessionId}/events/stream?beta=true`;
29
+ const res = await fetch(url, { headers: authHeaders(apiKey), signal });
30
+ if (!res.ok) {
31
+ const body = await res.text().catch(() => '');
32
+ throw new Error(`stream open failed: HTTP ${res.status}: ${body.slice(0, 300)}`);
33
+ }
34
+ if (!res.body) throw new Error('stream open failed: no response body');
35
+
36
+ const reader = res.body.getReader();
37
+ const decoder = new TextDecoder('utf-8');
38
+ let buffer = '';
39
+
40
+ try {
41
+ while (true) {
42
+ const { done, value } = await reader.read();
43
+ if (done) break;
44
+ buffer += decoder.decode(value, { stream: true });
45
+
46
+ // SSE frames are separated by a blank line ("\n\n"). Each frame may
47
+ // contain multiple lines; we only care about `data:` lines for now.
48
+ let nlIdx;
49
+ while ((nlIdx = buffer.indexOf('\n\n')) !== -1) {
50
+ const frame = buffer.slice(0, nlIdx);
51
+ buffer = buffer.slice(nlIdx + 2);
52
+ const data = parseFrame(frame);
53
+ if (data) yield data;
54
+ }
55
+ }
56
+ // Stream ended cleanly. Flush any final frame missing trailing \n\n.
57
+ if (buffer.trim()) {
58
+ const data = parseFrame(buffer);
59
+ if (data) yield data;
60
+ }
61
+ } finally {
62
+ try { reader.releaseLock(); } catch {}
63
+ }
64
+ }
65
+
66
+ function parseFrame(frame) {
67
+ // Concatenate all `data:` lines per the SSE spec (multi-line payload).
68
+ const parts = [];
69
+ for (const line of frame.split('\n')) {
70
+ if (line.startsWith('data:')) parts.push(line.slice(5).trim());
71
+ }
72
+ if (parts.length === 0) return null;
73
+ const payload = parts.join('\n');
74
+ try { return JSON.parse(payload); }
75
+ catch { return null; }
76
+ }
77
+
78
+ // High-level wrapper: stream forever, reconnecting on transient errors.
79
+ // Yields events; on fatal/permanent errors throws after maxAttempts.
80
+ export async function* streamWithReconnect({ apiKey, sessionId, signal, maxAttempts = 5, onReconnect }) {
81
+ let attempt = 0;
82
+ while (true) {
83
+ try {
84
+ for await (const ev of openEventStream({ apiKey, sessionId, signal })) {
85
+ attempt = 0; // any event resets the backoff
86
+ yield ev;
87
+ }
88
+ // Stream ended without throwing — session likely closed cleanly. Exit.
89
+ return;
90
+ } catch (e) {
91
+ if (signal?.aborted) return;
92
+ attempt++;
93
+ if (attempt > maxAttempts) {
94
+ throw new Error(`stream failed after ${maxAttempts} attempts: ${e.message}`);
95
+ }
96
+ const backoffMs = Math.min(30_000, 1000 * 2 ** (attempt - 1));
97
+ if (onReconnect) onReconnect({ attempt, backoffMs, error: e });
98
+ await new Promise(r => setTimeout(r, backoffMs));
99
+ }
100
+ }
101
+ }