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 +21 -5
- package/package.json +2 -2
- package/scripts/shield.js +127 -13
- package/scripts/upload-fortress.js +12 -1
- package/src/fortress/url.js +59 -0
- package/src/shield/sources/fortress.js +203 -0
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`
|
|
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
|
-
|
|
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
|
-
-
|
|
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.
|
|
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
|
|
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,
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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', () => {
|
|
419
|
-
|
|
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
|
-
|
|
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 };
|