watchmyagents 0.5.0 → 0.6.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
@@ -205,15 +205,30 @@ Report vulnerabilities via [SECURITY.md](./SECURITY.md).
205
205
 
206
206
  ## Shield — real-time policy enforcement
207
207
 
208
- `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).
208
+ `wma-shield` is the real-time enforcement companion to Watch. It streams agent events live, evaluates them against a policy ruleset, 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).
209
209
 
210
+ ### Two policy sources (v0.6.0+)
211
+
212
+ **Local JSON** (standalone — no cloud dependency):
210
213
  ```bash
211
- # Agent-wide mode — attaches to ALL active sessions of the agent automatically.
212
- # Run under a process supervisor (systemd, pm2, docker) for production.
213
214
  wma-shield --agent-id agent_xxx --policy ./policies.json
214
215
  ```
215
216
 
216
- Shield auto-detects the best enforcement mode at startup:
217
+ **Fortress cloud** (policies managed in the dashboard, auto-refreshed every 5 min):
218
+ ```bash
219
+ export ANTHROPIC_API_KEY="sk-ant-..."
220
+ export WMA_API_KEY="wma_..."
221
+ export WMA_FORTRESS_BASE_URL="https://<project>.supabase.co/functions/v1"
222
+ export WMA_SIGNALS_SALT="..." # same salt as wma-upload-fortress (for cross-table IoC correlation)
223
+
224
+ wma-shield --agent-id agent_xxx --policies-source fortress
225
+ ```
226
+
227
+ In Fortress mode, Shield also POSTs each enforcement decision back to Fortress (`/functions/v1/ingest-decisions`), so the dashboard's live timeline + Loop Visualizer light up in real time.
228
+
229
+ ### Enforcement mode auto-detection
230
+
231
+ Shield auto-detects the best mode at startup:
217
232
  - **tool_confirmation** (precise, pre-execution blocking) when at least one tool has `permission_policy: always_ask`
218
233
  - **interrupt** (degraded, post-execution termination) otherwise
219
234
 
@@ -232,7 +247,8 @@ Decisions are logged to the same NDJSON stream as Watch (`action_type: shield_de
232
247
  - ✅ Anonymized telemetry to WMA Fortress cloud (`wma-upload-fortress` in v0.5.0)
233
248
  - ✅ Guardian AI (cloud) — automatic policy suggestions from observed behavior
234
249
  - ✅ Fortress (cloud) — dashboard + human-in-the-loop validation queue
235
- - 🚧 Shield policy puller from Fortress (replace local JSON with cloud-synced policies)
250
+ - Shield policy puller from Fortress (`wma-shield --policies-source fortress` in v0.6.0)
251
+ - ✅ Shield decisions push to Fortress (live timeline + Loop Visualizer)
236
252
  - 🚧 Encrypted upload to customer's own cloud (S3/GCS/Azure with `age` public-key encryption)
237
253
 
238
254
  ## License
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "watchmyagents",
3
- "version": "0.5.0",
4
- "description": "Security observability + real-time policy enforcement for AI agents. Local-first NDJSON capture, Shield CLI that blocks policy violations live, anonymizer producing signals-only payloads, and an upload command that ships anonymized telemetry to WatchMyAgents Fortress (the cloud control plane).",
3
+ "version": "0.6.0",
4
+ "description": "Security observability + real-time policy enforcement for AI agents. Local-first NDJSON capture, Shield CLI that blocks policy violations live (with policies pulled from Fortress cloud), anonymizer producing signals-only payloads, and bidirectional sync with WatchMyAgents Fortress closing the recursive Watch→Guardian→Shield security loop.",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "src/",
package/scripts/shield.js CHANGED
@@ -25,6 +25,7 @@
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';
28
29
  import { streamWithReconnect } from '../src/shield/stream.js';
29
30
  import { loadPolicies, evaluate } from '../src/shield/policy.js';
30
31
  import {
@@ -33,6 +34,8 @@ import {
33
34
  } from '../src/shield/enforce.js';
34
35
  import { DecisionLogger } from '../src/shield/decisions.js';
35
36
  import { listSessions } from '../src/sources/anthropic-managed.js';
37
+ import { FortressPolicySource, postDecision } from '../src/shield/sources/fortress.js';
38
+ import { resolveFortressBase } from '../src/fortress/url.js';
36
39
 
37
40
  function parseArgs(argv) {
38
41
  const out = {};
@@ -114,9 +117,45 @@ After either option, restart Shield — it auto-detects the new mode.
114
117
  // Per-session worker — runs one event loop, returns when session ends.
115
118
  // ────────────────────────────────────────────────────────────────────────
116
119
  async function runSessionWorker({ sessionId, ctx }) {
117
- const { apiKey, agentId, ruleset, mode, decisions, signal } = ctx;
120
+ const { apiKey, agentId, mode, decisions, signal, pushDecisionToFortress, signalsSalt } = ctx;
121
+ // NOTE: ctx.ruleset is a getter — read it FRESH per evaluation so policy
122
+ // refreshes from Fortress (every 5 min) take effect without restart.
118
123
  sinfo(sessionId, `attached (${mode} mode)`);
119
124
 
125
+ // Helper: hash an IoC value with the customer salt (same one used by
126
+ // anonymizer for signals → correlates decisions to signals in Fortress).
127
+ // Returns null if no salt is configured (decisions still upload, just
128
+ // without input_hash).
129
+ const hashIoc = (value) => {
130
+ if (!signalsSalt || value == null) return null;
131
+ const s = typeof value === 'string' ? value : JSON.stringify(value);
132
+ return 'sha256:' + createHash('sha256').update(signalsSalt).update(s).digest('hex').slice(0, 32);
133
+ };
134
+
135
+ // Helper: assemble + fire the decision push to Fortress (fire-and-forget).
136
+ const fireToFortress = (rawEvent, normalized, result, decidedInMs) => {
137
+ if (!pushDecisionToFortress) return;
138
+ // Extract the most relevant input value to hash (URL > command > query > path)
139
+ const inp = normalized?.input;
140
+ let inputForHash = null;
141
+ if (inp && typeof inp === 'object') {
142
+ inputForHash = inp.url || inp.command || inp.query || inp.path || inp.file_path || null;
143
+ }
144
+ pushDecisionToFortress({
145
+ anthropic_agent_id: agentId,
146
+ decision: result.decision,
147
+ rule_id: result.rule_id || undefined,
148
+ session_hash: hashIoc(sessionId) || undefined,
149
+ event_id_hash: hashIoc(rawEvent?.id) || undefined,
150
+ input_hash: hashIoc(inputForHash) || undefined,
151
+ action_type: normalized?.action_type || undefined,
152
+ tool_name: normalized?.tool_name || undefined,
153
+ message: result.message || result.rule_name || undefined,
154
+ decided_at: new Date().toISOString(),
155
+ decided_in_ms: decidedInMs,
156
+ }).catch(() => undefined);
157
+ };
158
+
120
159
  let processed = 0, enforced = 0, sessionInterrupted = false;
121
160
  // Cache is only needed for tool_confirmation mode (lookup by event_id when
122
161
  // requires_action fires). Interrupt mode evaluates synchronously and never
@@ -161,7 +200,7 @@ async function runSessionWorker({ sessionId, ctx }) {
161
200
  // No caching in interrupt mode — react synchronously, free memory.
162
201
  const normalized = normalizeForPolicy(rawEvent);
163
202
  const t0 = Date.now();
164
- const result = evaluate(normalized, ruleset);
203
+ const result = evaluate(normalized, ctx.ruleset);
165
204
  const decidedInMs = Date.now() - t0;
166
205
 
167
206
  sinfo(sessionId, `${rawEvent.type} tool=${normalized.tool_name} → ${result.decision}${result.rule_id ? ` (${result.rule_id})` : ''}`);
@@ -171,6 +210,7 @@ async function runSessionWorker({ sessionId, ctx }) {
171
210
  ruleId: result.rule_id, ruleName: result.rule_name,
172
211
  message: result.message, decidedInMs,
173
212
  });
213
+ fireToFortress(rawEvent, normalized, result, decidedInMs);
174
214
 
175
215
  if ((result.decision === 'deny' || result.decision === 'interrupt') && !sessionInterrupted) {
176
216
  try {
@@ -217,7 +257,7 @@ async function runSessionWorker({ sessionId, ctx }) {
217
257
 
218
258
  const normalized = normalizeForPolicy(sourceEvent);
219
259
  const t0 = Date.now();
220
- const result = evaluate(normalized, ruleset);
260
+ const result = evaluate(normalized, ctx.ruleset);
221
261
  const decidedInMs = Date.now() - t0;
222
262
 
223
263
  sinfo(sessionId, `requires_action ${sourceEvent.type} tool=${normalized.tool_name} → ${result.decision}${result.rule_id ? ` (${result.rule_id})` : ''}`);
@@ -227,6 +267,7 @@ async function runSessionWorker({ sessionId, ctx }) {
227
267
  ruleId: result.rule_id, ruleName: result.rule_name,
228
268
  message: result.message, decidedInMs,
229
269
  });
270
+ fireToFortress(sourceEvent, normalized, result, decidedInMs);
230
271
 
231
272
  try {
232
273
  if (result.decision === 'allow') {
@@ -373,17 +414,53 @@ async function main() {
373
414
 
374
415
  const singleSessionId = args['session-id']; // optional now
375
416
  const policyPath = args.policy;
417
+ const policiesSource = args['policies-source'] || (policyPath ? 'local' : null);
418
+ const wmaApiKey = args['wma-api-key'] || process.env.WMA_API_KEY;
419
+ const signalsSalt = args['salt'] || process.env.WMA_SIGNALS_SALT;
420
+ const fortressBase = resolveFortressBase({
421
+ explicitBase: args['fortress-base-url'],
422
+ explicitUrl: args['fortress-url'],
423
+ });
376
424
  const logDir = resolve(args['log-dir'] || './watchmyagents-logs');
377
425
 
378
426
  if (!apiKey) die('error: --api-key or ANTHROPIC_API_KEY required');
379
427
  if (!agentId) die('error: --agent-id required');
380
- if (!policyPath) die('error: --policy <path-to-policies.json> required');
381
428
 
382
- let ruleset;
383
- try {
384
- ruleset = await loadPolicies(resolve(policyPath));
385
- } catch (e) {
386
- die(`error loading policies: ${e.message}`);
429
+ // Policies source: --policies-source fortress | local (default infers from --policy)
430
+ let ruleset; // for 'local' mode: static; for 'fortress': initial snapshot
431
+ let fortressPolicies; // FortressPolicySource instance, used as ground truth at runtime
432
+
433
+ if (policiesSource === 'fortress') {
434
+ if (!wmaApiKey) die('error: --policies-source fortress requires --wma-api-key or WMA_API_KEY env');
435
+ if (!fortressBase) die('error: --policies-source fortress requires --fortress-base-url or WMA_FORTRESS_BASE_URL env');
436
+ if (!/^wma_[a-f0-9]{32}$/i.test(wmaApiKey)) warn(`WMA_API_KEY format looks unusual (expected wma_<32hex>).`);
437
+
438
+ fortressPolicies = new FortressPolicySource({
439
+ apiKey: wmaApiKey,
440
+ base: fortressBase,
441
+ anthropicAgentId: agentId,
442
+ refreshIntervalMs: 5 * 60_000,
443
+ onError: (e) => warn(`policy refresh failed (keeping cached): ${e.message}`),
444
+ onRefresh: ({ policies, fetched_at, initial }) => {
445
+ info(`policies ${initial ? 'loaded' : 'refreshed'} from Fortress — ${policies.length} active (fetched_at: ${fetched_at})`);
446
+ },
447
+ });
448
+ try {
449
+ await fortressPolicies.start();
450
+ } catch (e) {
451
+ die(`error fetching policies from Fortress: ${e.message}\n` +
452
+ ` Check WMA_FORTRESS_BASE_URL and WMA_API_KEY.`);
453
+ }
454
+ ruleset = fortressPolicies.current();
455
+ } else if (policiesSource === 'local') {
456
+ if (!policyPath) die('error: --policies-source local requires --policy <path-to-policies.json>');
457
+ try {
458
+ ruleset = await loadPolicies(resolve(policyPath));
459
+ } catch (e) {
460
+ die(`error loading policies: ${e.message}`);
461
+ }
462
+ } else {
463
+ die('error: --policy <path> OR --policies-source fortress required');
387
464
  }
388
465
 
389
466
  let mode = 'interrupt';
@@ -395,7 +472,10 @@ async function main() {
395
472
  warn(`could not fetch agent config (${e.message}). Defaulting to interrupt mode.`);
396
473
  }
397
474
 
398
- info(`armed ${ruleset.policies.length} policies loaded from ${policyPath}`);
475
+ const sourceLabel = policiesSource === 'fortress'
476
+ ? `Fortress (${fortressBase})`
477
+ : policyPath;
478
+ info(`armed — ${ruleset.policies.length} policies loaded from ${sourceLabel}`);
399
479
  info(`default action when no rule matches: ${ruleset.default.action}`);
400
480
  info(`agent: ${agentId}${agentMeta?.name ? ` "${agentMeta.name}"` : ''}`);
401
481
  info(`enforcement mode: ${mode}`);
@@ -414,11 +494,45 @@ async function main() {
414
494
  return loggers.get(sessionId);
415
495
  };
416
496
 
497
+ // Optional Fortress decision pusher — only active if we have a wma key + base.
498
+ // In 'fortress' mode this is always available. In 'local' mode it's a fire-
499
+ // and-forget extra channel if both are set.
500
+ const canPushToFortress = !!(wmaApiKey && fortressBase);
501
+ const pushDecisionToFortress = canPushToFortress
502
+ ? async (decisionData) => {
503
+ try {
504
+ await postDecision({ apiKey: wmaApiKey, base: fortressBase, decision: decisionData });
505
+ } catch (e) {
506
+ warn(`Fortress decision push failed: ${e.message}`);
507
+ }
508
+ }
509
+ : null;
510
+
417
511
  const ac = new AbortController();
418
- process.on('SIGINT', () => { info('SIGINT received, shutting down…'); ac.abort(); });
419
- process.on('SIGTERM', () => { info('SIGTERM received, shutting down…'); ac.abort(); });
512
+ process.on('SIGINT', () => {
513
+ info('SIGINT received, shutting down…');
514
+ if (fortressPolicies) fortressPolicies.stop();
515
+ ac.abort();
516
+ });
517
+ process.on('SIGTERM', () => {
518
+ info('SIGTERM received, shutting down…');
519
+ if (fortressPolicies) fortressPolicies.stop();
520
+ ac.abort();
521
+ });
420
522
 
421
- const ctx = { apiKey, agentId, ruleset, mode, decisions, signal: ac.signal };
523
+ // ctx exposes a getter for the live ruleset so workers see policy refreshes.
524
+ const ctx = {
525
+ apiKey,
526
+ agentId,
527
+ get ruleset() {
528
+ return fortressPolicies ? fortressPolicies.current() : ruleset;
529
+ },
530
+ mode,
531
+ decisions,
532
+ pushDecisionToFortress,
533
+ signalsSalt,
534
+ signal: ac.signal,
535
+ };
422
536
 
423
537
  if (singleSessionId) {
424
538
  info(`single-session mode — attached to ${singleSessionId}`);
@@ -29,6 +29,7 @@ import { join, resolve } from 'node:path';
29
29
  import { createReadStream } from 'node:fs';
30
30
  import { createInterface } from 'node:readline';
31
31
  import { SignalsAggregator } from '../src/anonymizer.js';
32
+ import { resolveFortressBase, fortressEndpoint } from '../src/fortress/url.js';
32
33
 
33
34
  function parseArgs(argv) {
34
35
  const out = {};
@@ -101,12 +102,22 @@ async function main() {
101
102
 
102
103
  const agentId = args['agent-id'];
103
104
  const logDir = resolve(args['log-dir'] || './watchmyagents-logs');
104
- const fortressUrl = args['fortress-url'] || process.env.WMA_FORTRESS_URL;
105
105
  const apiKey = args['api-key'] || process.env.WMA_API_KEY;
106
106
  const salt = args.salt || process.env.WMA_SIGNALS_SALT;
107
107
  const displayName = args['display-name'] || agentId;
108
108
  const dryRun = !!args['dry-run'];
109
109
 
110
+ // Resolve Fortress base URL. Accepts:
111
+ // --fortress-base-url <base> (preferred CLI)
112
+ // --fortress-url <full ingest-signals> (legacy CLI)
113
+ // WMA_FORTRESS_BASE_URL env (preferred env)
114
+ // WMA_FORTRESS_URL env (legacy env, points at ingest-signals)
115
+ const fortressBase = resolveFortressBase({
116
+ explicitBase: args['fortress-base-url'],
117
+ explicitUrl: args['fortress-url'],
118
+ });
119
+ const fortressUrl = fortressBase ? fortressEndpoint(fortressBase, 'ingest-signals') : null;
120
+
110
121
  // Validation
111
122
  if (!agentId) die('error: --agent-id required (Anthropic agent_id, e.g. agent_01XaN...)');
112
123
  // Strict alphanumeric to prevent path traversal in collectFiles below
@@ -0,0 +1,59 @@
1
+ // ─────────────────────────────────────────────────────────────────────────
2
+ // Fortress URL resolution — shared across upload-fortress, shield, etc.
3
+ // ─────────────────────────────────────────────────────────────────────────
4
+ // The user sets ONE of:
5
+ //
6
+ // WMA_FORTRESS_BASE_URL=https://<project>.supabase.co/functions/v1
7
+ // → preferred. Each tool appends its endpoint (/ingest-signals,
8
+ // /get-policies, /ingest-decisions).
9
+ //
10
+ // WMA_FORTRESS_URL=https://<project>.supabase.co/functions/v1/ingest-signals
11
+ // → legacy (v0.5.0 era). The base URL is derived by stripping the
12
+ // last path segment, so other endpoints can be constructed.
13
+ //
14
+ // Either way, callers receive a `base` they append `/<endpoint>` to.
15
+
16
+ /**
17
+ * Resolve the Fortress base URL from env / args.
18
+ * @param {object} opts - { explicitUrl, explicitBase, env }
19
+ * @returns {string|null} base URL like https://x.supabase.co/functions/v1
20
+ * (no trailing slash), or null if not configured.
21
+ */
22
+ export function resolveFortressBase({ explicitUrl, explicitBase, env = process.env } = {}) {
23
+ // 1. Explicit base URL from CLI
24
+ if (explicitBase) return stripTrailingSlash(explicitBase);
25
+
26
+ // 2. Env: WMA_FORTRESS_BASE_URL (preferred)
27
+ if (env.WMA_FORTRESS_BASE_URL) return stripTrailingSlash(env.WMA_FORTRESS_BASE_URL);
28
+
29
+ // 3. Legacy: WMA_FORTRESS_URL (full path to ingest-signals)
30
+ const legacy = explicitUrl || env.WMA_FORTRESS_URL;
31
+ if (legacy) {
32
+ // Strip last path segment to get the base
33
+ try {
34
+ const u = new URL(legacy);
35
+ const parts = u.pathname.split('/').filter(Boolean);
36
+ if (parts.length > 0) parts.pop();
37
+ u.pathname = '/' + parts.join('/');
38
+ return stripTrailingSlash(u.toString());
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ return null;
45
+ }
46
+
47
+ function stripTrailingSlash(s) {
48
+ return s.endsWith('/') ? s.slice(0, -1) : s;
49
+ }
50
+
51
+ /**
52
+ * Build a full endpoint URL given a base + endpoint name.
53
+ * @param {string} base - e.g. https://x.supabase.co/functions/v1
54
+ * @param {string} endpoint - e.g. "ingest-signals", "get-policies"
55
+ */
56
+ export function fortressEndpoint(base, endpoint) {
57
+ if (!base) throw new Error('Fortress base URL not configured');
58
+ return `${base}/${endpoint}`;
59
+ }
@@ -0,0 +1,203 @@
1
+ // ─────────────────────────────────────────────────────────────────────────
2
+ // Shield → Fortress integration (v0.6.0)
3
+ // ─────────────────────────────────────────────────────────────────────────
4
+ // Two pieces:
5
+ // 1. fetchPolicies() — Shield pulls active policies from Fortress
6
+ // 2. postDecision() — Shield pushes each enforcement decision to Fortress
7
+ //
8
+ // Both authenticate with the customer's `wma_xxx` API key (Bearer header),
9
+ // against the Edge Functions get-policies and ingest-decisions deployed in
10
+ // the Fortress repo.
11
+
12
+ import { request as httpsRequest } from 'node:https';
13
+ import { URL } from 'node:url';
14
+ import { fortressEndpoint } from '../../fortress/url.js';
15
+
16
+ const DEFAULT_TIMEOUT_MS = 15_000;
17
+
18
+ function httpsJson(method, url, headers, body, timeoutMs = DEFAULT_TIMEOUT_MS) {
19
+ return new Promise((resolveReq, rejectReq) => {
20
+ const u = new URL(url);
21
+ if (u.protocol !== 'https:') {
22
+ return rejectReq(new Error(`refusing non-https Fortress URL: ${url}`));
23
+ }
24
+ const data = body ? Buffer.from(JSON.stringify(body)) : null;
25
+ const opts = {
26
+ method,
27
+ hostname: u.hostname,
28
+ port: u.port || 443,
29
+ path: u.pathname + (u.search || ''),
30
+ headers: {
31
+ ...headers,
32
+ ...(data ? { 'content-type': 'application/json', 'content-length': data.length } : {}),
33
+ },
34
+ rejectUnauthorized: true,
35
+ };
36
+ const req = httpsRequest(opts, (res) => {
37
+ const chunks = [];
38
+ res.on('data', (c) => chunks.push(c));
39
+ res.on('end', () => {
40
+ const raw = Buffer.concat(chunks).toString('utf8');
41
+ let parsed = null;
42
+ try { parsed = raw ? JSON.parse(raw) : null; } catch { /* keep raw */ }
43
+ resolveReq({ status: res.statusCode || 0, body: parsed ?? raw });
44
+ });
45
+ });
46
+ req.on('error', rejectReq);
47
+ req.setTimeout(timeoutMs, () => {
48
+ req.destroy(new Error(`Fortress request timed out after ${timeoutMs}ms`));
49
+ });
50
+ if (data) req.write(data);
51
+ req.end();
52
+ });
53
+ }
54
+
55
+ /**
56
+ * GET /functions/v1/get-policies?agent_id=<anthropicAgentId>
57
+ * Returns the array of enabled policies for this customer + agent.
58
+ *
59
+ * @param {object} opts
60
+ * @param {string} opts.apiKey - wma_xxx
61
+ * @param {string} opts.base - Fortress base URL (https://x.supabase.co/functions/v1)
62
+ * @param {string} [opts.anthropicAgentId] - optional filter
63
+ * @returns {Promise<{ ok: true, policies: array, fetched_at: string }>}
64
+ */
65
+ export async function fetchPolicies({ apiKey, base, anthropicAgentId }) {
66
+ let url = fortressEndpoint(base, 'get-policies');
67
+ if (anthropicAgentId) {
68
+ const sep = url.includes('?') ? '&' : '?';
69
+ url += `${sep}agent_id=${encodeURIComponent(anthropicAgentId)}`;
70
+ }
71
+ const { status, body } = await httpsJson('GET', url, {
72
+ authorization: `Bearer ${apiKey}`,
73
+ accept: 'application/json',
74
+ });
75
+ if (status === 200 && body && body.ok) {
76
+ return { ok: true, policies: body.policies || [], fetched_at: body.fetched_at };
77
+ }
78
+ const err = body?.error || (typeof body === 'string' ? body.slice(0, 200) : 'unknown');
79
+ throw new Error(`get-policies failed (HTTP ${status}): ${err}`);
80
+ }
81
+
82
+ /**
83
+ * POST /functions/v1/ingest-decisions
84
+ * Push a single enforcement decision to Fortress.
85
+ *
86
+ * @param {object} opts
87
+ * @param {string} opts.apiKey - wma_xxx
88
+ * @param {string} opts.base - Fortress base URL
89
+ * @param {object} opts.decision - the body to POST. See ingest-decisions docs.
90
+ * {
91
+ * anthropic_agent_id, decision,
92
+ * rule_id?, session_hash?, event_id_hash?, input_hash?,
93
+ * action_type?, tool_name?, message?, decided_at?, decided_in_ms?
94
+ * }
95
+ * @returns {Promise<{ ok: true, decision_id: string, agent_id: string }>}
96
+ */
97
+ export async function postDecision({ apiKey, base, decision }) {
98
+ const url = fortressEndpoint(base, 'ingest-decisions');
99
+ const { status, body } = await httpsJson('POST', url, {
100
+ authorization: `Bearer ${apiKey}`,
101
+ }, decision);
102
+ if (status === 200 && body && body.ok) {
103
+ return { ok: true, decision_id: body.decision_id, agent_id: body.agent_id };
104
+ }
105
+ const err = body?.error || (typeof body === 'string' ? body.slice(0, 200) : 'unknown');
106
+ throw new Error(`ingest-decisions failed (HTTP ${status}): ${err}`);
107
+ }
108
+
109
+ // ────────────────────────────────────────────────────────────────────────
110
+ // FortressPolicySource — drop-in replacement for the local JSON loader.
111
+ // Periodically refreshes the policy ruleset from Fortress.
112
+ // ────────────────────────────────────────────────────────────────────────
113
+
114
+ import { matchesPolicy } from '../policy.js';
115
+
116
+ export class FortressPolicySource {
117
+ constructor({ apiKey, base, anthropicAgentId, refreshIntervalMs = 5 * 60_000, onError, onRefresh }) {
118
+ if (!apiKey) throw new Error('FortressPolicySource: apiKey required');
119
+ if (!base) throw new Error('FortressPolicySource: base URL required');
120
+ this.apiKey = apiKey;
121
+ this.base = base;
122
+ this.anthropicAgentId = anthropicAgentId;
123
+ this.refreshIntervalMs = refreshIntervalMs;
124
+ this.onError = onError || (() => {});
125
+ this.onRefresh = onRefresh || (() => {});
126
+ this.ruleset = { version: 1, policies: [], default: { action: 'allow' } };
127
+ this.lastFetchedAt = null;
128
+ this._timer = null;
129
+ this._aborted = false;
130
+ }
131
+
132
+ /** Initial fetch — fails loud if it can't reach Fortress at startup. */
133
+ async start() {
134
+ await this._refresh({ initial: true });
135
+ this._timer = setInterval(() => this._refresh().catch(this.onError), this.refreshIntervalMs);
136
+ if (this._timer.unref) this._timer.unref();
137
+ }
138
+
139
+ stop() {
140
+ this._aborted = true;
141
+ if (this._timer) { clearInterval(this._timer); this._timer = null; }
142
+ }
143
+
144
+ /** Returns the current ruleset (used by the policy evaluator). */
145
+ current() {
146
+ return this.ruleset;
147
+ }
148
+
149
+ async _refresh({ initial = false } = {}) {
150
+ if (this._aborted) return;
151
+ try {
152
+ const { policies, fetched_at } = await fetchPolicies({
153
+ apiKey: this.apiKey,
154
+ base: this.base,
155
+ anthropicAgentId: this.anthropicAgentId,
156
+ });
157
+ // Compile regex etc. — reuse the same shape policy.js expects.
158
+ const compiled = policies.map((p) => compilePolicyFromFortress(p));
159
+ this.ruleset = {
160
+ version: 1,
161
+ policies: compiled,
162
+ default: { action: 'allow' },
163
+ };
164
+ this.lastFetchedAt = fetched_at;
165
+ this.onRefresh({ policies: compiled, fetched_at, initial });
166
+ } catch (e) {
167
+ // On initial failure, propagate so the operator notices a config issue.
168
+ // On subsequent failures, log and keep the previous cached ruleset.
169
+ if (initial) throw e;
170
+ this.onError(e);
171
+ }
172
+ }
173
+ }
174
+
175
+ // Convert a Fortress DB policy row to the local Shield format (compile regex).
176
+ function compilePolicyFromFortress(p) {
177
+ const out = {
178
+ id: p.rule_id,
179
+ name: p.name,
180
+ rationale: p.rationale,
181
+ match: p.match || {},
182
+ action: p.action,
183
+ message: p.message,
184
+ priority: p.priority ?? 100,
185
+ };
186
+ // Compile regex strings to RegExp via the same _regex/_not_regex/_regex_any
187
+ // protocol the local policy.js engine uses (avoids parsing each event).
188
+ // We rely on the validation already done in compileMatchRegexes within
189
+ // policy.js, but since we're not going through loadPolicies here we replicate
190
+ // the safe-compile step inline.
191
+ for (const [field, condition] of Object.entries(out.match)) {
192
+ if (condition && typeof condition === 'object') {
193
+ if (condition.regex) condition._regex = new RegExp(condition.regex);
194
+ if (condition.not_regex) condition._not_regex = new RegExp(condition.not_regex);
195
+ if (condition.regex_any) condition._regex_any = condition.regex_any.map(r => new RegExp(r));
196
+ }
197
+ }
198
+ return out;
199
+ }
200
+
201
+ // Re-export matchesPolicy for convenience (callers can use the FortressPolicySource
202
+ // + the standard evaluate() from policy.js).
203
+ export { matchesPolicy };