watchmyagents 0.3.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.
@@ -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 };