watchmyagents 1.0.3 → 1.1.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
@@ -107,7 +107,7 @@ Each entry carries: `id`, `agent_id`, `framework`, `timestamp`, `action_type`, `
107
107
  ```bash
108
108
  wma-fetch (--agent-id <agent_id> | --all-agents) [--session-id <sess_id>] [--since 1h]
109
109
  [--log-dir ./watchmyagents-logs] [--dump-raw]
110
- [--watch [--interval 5m] [--upload]]
110
+ [--watch [--interval 1m] [--upload]]
111
111
  ```
112
112
 
113
113
  | Flag | Effect |
@@ -119,11 +119,12 @@ wma-fetch (--agent-id <agent_id> | --all-agents) [--session-id <sess_id>] [--sin
119
119
  | `--log-dir ./logs` | Where to write NDJSON (default `./watchmyagents-logs`) |
120
120
  | `--dump-raw` | Also save raw API events alongside (forensic / debugging) |
121
121
  | `--watch` | **Continuous daemon** — loop forever, incrementally capturing NEW events (deduped by stable event id) until `Ctrl+C` |
122
- | `--interval 5m` | Poll interval in watch mode (default `5m`; accepts `30s`/`1h`/…) |
122
+ | `--interval 1m` | Poll interval in watch mode (default `1m` since v1.1.0; was `5m` in v1.0.x; accepts `30s`/`1h`/…). At each tick Watch re-discovers the fleet AND polls for new events on tracked sessions. |
123
123
  | `--upload` | In watch mode, anonymize each new window and ship signals to Fortress (needs `WMA_API_KEY` + `WMA_FORTRESS_BASE_URL` + `WMA_SIGNALS_SALT`). Raw stays local. |
124
124
  | `--discovery-since 7d` | Window for discovering NEW sessions (default `7d`). Sessions already being tracked are re-fetched regardless of age, so long-running ones never drop out. |
125
125
  | `--no-send-agent-names` | Opt-out: send only the agent id as the Fortress `display_name`. **By default, the human agent name** (sanitized) is sent so dashboards/decisions stay legible. Pass this flag if your agent names themselves carry client/project info you'd rather keep pseudonymized. |
126
126
  | `--api-key sk-ant-…` | Override the `ANTHROPIC_API_KEY` env var. **Discouraged** — visible in shell history & process list. Prefer the env var. |
127
+ | `--discover-now` | **One-shot fast-register mode** (v1.1.0+). Lists every agent your Anthropic key can see and pushes a discovery signal to Fortress so they appear in the dashboard immediately — no waiting for the next Watch cycle, no need to trigger activity first. Requires the same env (`WMA_API_KEY`, `WMA_FORTRESS_BASE_URL`, `WMA_SIGNALS_SALT`) as `--upload`. Exits when done. Typical use: after creating a new agent in the Anthropic console, run `wma-fetch --discover-now` and it shows up in Fortress in ~2 seconds. |
127
128
 
128
129
  Logs land in `./watchmyagents-logs/<agent_id>/<date>.ndjson` (file mode `0600`, dir `0700`).
129
130
 
@@ -198,7 +199,7 @@ export WMA_API_KEY="wma_..."
198
199
  export WMA_FORTRESS_BASE_URL="https://<project>.supabase.co/functions/v1"
199
200
  export WMA_SIGNALS_SALT="..." # stable per-customer salt
200
201
 
201
- wma-service install (--agent-id agent_01ABC... | --all-agents) [--interval 5m] [--with-shield]
202
+ wma-service install (--agent-id agent_01ABC... | --all-agents) [--interval 1m] [--with-shield]
202
203
  wma-service status
203
204
  wma-service uninstall [--with-shield]
204
205
  ```
@@ -217,7 +218,7 @@ After this, the full Watch→Guardian→Shield loop runs hands-off.
217
218
  If you'd rather run the loop in a terminal you control (the service wraps this):
218
219
 
219
220
  ```bash
220
- wma-fetch --agent-id agent_01ABC... --watch --upload --interval 5m
221
+ wma-fetch --agent-id agent_01ABC... --watch --upload --interval 1m
221
222
  ```
222
223
 
223
224
  It loops until `Ctrl+C`, dedupes by the stable Anthropic event id (no duplicate
@@ -286,6 +287,12 @@ wma-shield --agent-id agent_xxx --policies-source fortress
286
287
 
287
288
  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.
288
289
 
290
+ ### Realtime policy propagation (v1.1.0+)
291
+
292
+ When you accept a Guardian suggestion or deploy a manual rule in the Fortress dashboard, Shield is notified within ~100ms via a persistent Server-Sent Events (SSE) connection to `/functions/v1/policies-stream` and refreshes its ruleset immediately. Shield falls back gracefully to its 60s polling cadence if the SSE endpoint isn't deployed yet on your Fortress instance (HTTP 404), so the SDK ships safely either way.
293
+
294
+ Why SSE (not WebSocket): zero runtime dependencies preserved (HTTPS = Node built-in), firewall-friendly (many enterprise proxies block raw WS but pass `text/event-stream` cleanly), and the protocol is one-way push-only — exactly what we need.
295
+
289
296
  ### Enforcement mode auto-detection
290
297
 
291
298
  Shield auto-detects the best mode at startup:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "watchmyagents",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "Security observability + real-time policy enforcement for AI agents. Local-first NDJSON capture with a continuous Watch daemon that auto-uploads anonymized signals, Shield CLI that blocks policy violations live (with policies pulled from Fortress cloud), anonymizer producing signals-only payloads, bidirectional sync with WatchMyAgents Fortress, and one-command install as an always-on launchd/systemd service — closing the recursive Watch→Guardian→Shield security loop.",
5
5
  "type": "module",
6
6
  "files": [
@@ -159,6 +159,80 @@ async function uploadSignals(uploadCtx, agentId, displayName, entries, classific
159
159
  return resp;
160
160
  }
161
161
 
162
+ // v1.1.0 L2 — minimal one-shot registration signal sent to Fortress so
163
+ // a freshly-created Anthropic agent appears in the dashboard immediately,
164
+ // without waiting for the next Watch cycle AND without waiting for actual
165
+ // activity. The signal carries an empty SignalsAggregator payload + a
166
+ // degenerate window (window_start == window_end == now) so Fortress's
167
+ // ingest-signals upserts the agent row but contributes zero metrics.
168
+ // Used by --discover-now CLI mode.
169
+ async function uploadDiscoverySignal(uploadCtx, agentId, displayName, enforcementMode) {
170
+ const now = new Date().toISOString();
171
+ const body = JSON.stringify({
172
+ provider: AnthropicManagedSource.providerName,
173
+ native_agent_id: agentId,
174
+ anthropic_agent_id: agentId,
175
+ parent_agent_id: null,
176
+ composition_pattern: 'solo',
177
+ enforcement_mode: enforcementMode || AnthropicManagedSource.enforcementMode,
178
+ display_name: displayName,
179
+ window_start: now,
180
+ window_end: now,
181
+ payload: {
182
+ counts: {},
183
+ tool_counts: {},
184
+ latencies_p50_ms: {},
185
+ latencies_p95_ms: {},
186
+ error_rate_by_tool: {},
187
+ ioc_hashes: [],
188
+ sequences_top10: [],
189
+ stop_reasons: {},
190
+ tokens_total: 0,
191
+ session_ids: [],
192
+ },
193
+ });
194
+ const { status, body: resp } = await postJson(
195
+ uploadCtx.url, { authorization: `Bearer ${uploadCtx.apiKey}` }, body,
196
+ );
197
+ if (status < 200 || status >= 300) {
198
+ throw new Error(`ingest-signals HTTP ${status}: ${typeof resp === 'string' ? resp.slice(0, 200) : JSON.stringify(resp)}`);
199
+ }
200
+ return resp;
201
+ }
202
+
203
+ // One-shot "discover and register" mode: list every agent the customer's
204
+ // Anthropic key can see, derive each effective enforcement mode, and push
205
+ // a discovery signal to Fortress so the agent appears in the dashboard
206
+ // immediately. Exits when done — no watch loop, no event polling.
207
+ async function runDiscoverNow({ apiKey, uploadCtx, sendNames }) {
208
+ info('discover-now: listing agents from Anthropic…');
209
+ let agents;
210
+ try { agents = await listAgents(apiKey); }
211
+ catch (e) { die(`failed to list agents: ${e.message}`); }
212
+ info(`discover-now: ${agents.length} agent(s) found`);
213
+
214
+ let registered = 0;
215
+ let skipped = 0;
216
+ let failed = 0;
217
+ for (const a of agents) {
218
+ if (!a.id || !isValidAgentId(a.id)) { skipped++; continue; }
219
+ const displayName = sendNames ? cleanLabel(a.name) || a.id : a.id;
220
+ // Resolve effective enforcement mode best-effort; fall back to provider max.
221
+ let mode;
222
+ try { mode = await effectiveEnforcementMode(apiKey, a.id); }
223
+ catch (e) { warn(` enforcement_mode resolution failed for ${a.id}: ${e.message} (using provider max)`); }
224
+ try {
225
+ const resp = await uploadDiscoverySignal(uploadCtx, a.id, displayName, mode);
226
+ registered++;
227
+ info(` ✓ ${a.id} (${displayName})${resp?.registered_new_agent ? ' 🆕' : ''}`);
228
+ } catch (e) {
229
+ failed++;
230
+ warn(` ✗ ${a.id}: ${e.message}`);
231
+ }
232
+ }
233
+ info(`discover-now: done — ${registered} registered, ${skipped} skipped, ${failed} failed`);
234
+ }
235
+
162
236
  // Preload already-written entry ids so a restarted daemon doesn't re-append
163
237
  // events captured in a previous run (dedup by the stable Anthropic event id).
164
238
  async function preloadSeenIds(logDir, agentId) {
@@ -374,8 +448,23 @@ async function main() {
374
448
  const watch = !!args.watch;
375
449
  const upload = !!args.upload;
376
450
  const allAgents = !!args['all-agents'];
451
+ const discoverNow = !!args['discover-now'];
377
452
 
378
453
  if (!apiKey) die('error: --api-key or ANTHROPIC_API_KEY required');
454
+ // --discover-now is its own mode: list+register every agent immediately, exit.
455
+ // It requires the same Fortress credentials as --upload (it IS a one-shot upload).
456
+ if (discoverNow) {
457
+ const wmaKey = process.env.WMA_API_KEY;
458
+ const salt = process.env.WMA_SIGNALS_SALT;
459
+ const base = resolveFortressBase({});
460
+ if (!wmaKey) die('error: --discover-now needs WMA_API_KEY env (from Fortress dashboard → Settings → API Keys)');
461
+ if (!base) die('error: --discover-now needs WMA_FORTRESS_BASE_URL env');
462
+ if (!salt) die('error: --discover-now needs WMA_SIGNALS_SALT env');
463
+ if (salt.length < 16) die('error: WMA_SIGNALS_SALT too short (need ≥16 hex chars)');
464
+ const uploadCtx = { apiKey: wmaKey, salt, url: fortressEndpoint(base, 'ingest-signals') };
465
+ const sendNames = args['no-send-agent-names'] !== true;
466
+ return runDiscoverNow({ apiKey, uploadCtx, sendNames });
467
+ }
379
468
  if (!allAgents && !agentId) die('error: --agent-id required (or --all-agents for fleet mode)');
380
469
  if (allAgents && !watch) die('error: --all-agents requires --watch (fleet daemon). For a one-shot, target a single --agent-id.');
381
470
  if (agentId && !isValidAgentId(agentId)) {
@@ -404,7 +493,13 @@ async function main() {
404
493
  }
405
494
 
406
495
  if (watch) {
407
- const intervalMs = parseDurationMs(args.interval, 5 * 60_000);
496
+ // v1.1.0 Phase 1 L1: default Watch cycle = 60s (was 300s/5min). At this
497
+ // cadence both event polling AND fleet re-discovery happen every minute,
498
+ // bringing the agent-to-Fortress visibility from 5min worst-case down to
499
+ // ~60s. ~1440 list/get calls/day against Anthropic — well inside free
500
+ // tier limits, no behavioral risk. Operators who want the legacy 5min
501
+ // cadence can still pass --interval 5m explicitly.
502
+ const intervalMs = parseDurationMs(args.interval, 60_000);
408
503
  // Discovery window for NEW sessions (default 7d, configurable). Sessions we
409
504
  // already track are re-fetched regardless of age, so long-lived ones don't drop.
410
505
  const windowMs = parseDurationMs(args['discovery-since'], 7 * 24 * 3600_000);
package/scripts/shield.js CHANGED
@@ -35,7 +35,8 @@ import {
35
35
  import { DecisionLogger } from '../src/shield/decisions.js';
36
36
  import { listSessions, listAgents } from '../src/sources/anthropic-managed.js';
37
37
  import { FortressPolicySource, postDecision } from '../src/shield/sources/fortress.js';
38
- import { resolveFortressBase } from '../src/fortress/url.js';
38
+ import { resolveFortressBase, fortressEndpoint } from '../src/fortress/url.js';
39
+ import { PolicyStream } from '../src/shield/policy-stream.js';
39
40
  import { isValidAgentId, isValidSessionId } from '../src/validate.js';
40
41
 
41
42
  function parseArgs(argv) {
@@ -482,9 +483,11 @@ async function main() {
482
483
  // Shared infra: one shutdown signal, one fortress-source registry, one pusher.
483
484
  const ac = new AbortController();
484
485
  const fortressSources = [];
486
+ const fortressStreams = []; // v1.1.0 Phase 2 PolicyStream instances
485
487
  const shutdown = (sig) => {
486
488
  info(`${sig} received, shutting down…`);
487
489
  for (const fp of fortressSources) fp.stop();
490
+ for (const ps of fortressStreams) ps.close();
488
491
  ac.abort();
489
492
  };
490
493
  process.on('SIGINT', () => shutdown('SIGINT'));
@@ -508,8 +511,13 @@ async function main() {
508
511
  let fortressPolicies = null;
509
512
  let ruleset = sharedLocalRuleset;
510
513
  if (policiesSource === 'fortress') {
514
+ // v1.1.0 Phase 1 L3.5: policy refresh from Fortress every 60s
515
+ // (was 5min). Combined with Phase 2 realtime subscription work,
516
+ // this brings new-policy-deployed-to-Shield latency from 5min
517
+ // worst-case down to ~60s, with the Phase 2 push model taking
518
+ // it to sub-second later.
511
519
  fortressPolicies = new FortressPolicySource({
512
- apiKey: wmaApiKey, base: fortressBase, anthropicAgentId: aid, refreshIntervalMs: 5 * 60_000,
520
+ apiKey: wmaApiKey, base: fortressBase, anthropicAgentId: aid, refreshIntervalMs: 60_000,
513
521
  onError: (e) => warn(`${tag}policy refresh failed (keeping cached): ${e.message}`),
514
522
  onRefresh: ({ policies, fetched_at, initial }) => info(`${tag}policies ${initial ? 'loaded' : 'refreshed'} from Fortress — ${policies.length} active (fetched_at: ${fetched_at})`),
515
523
  });
@@ -519,6 +527,27 @@ async function main() {
519
527
  die(`error fetching policies from Fortress: ${e.message}\n Check WMA_FORTRESS_BASE_URL and WMA_API_KEY.`);
520
528
  }
521
529
  fortressSources.push(fortressPolicies);
530
+ // v1.1.0 Phase 2: persistent SSE connection to Fortress for instant
531
+ // policy updates (~100ms latency vs 60s poll). Falls back silently
532
+ // when the /policies-stream endpoint isn't deployed yet (HTTP 404),
533
+ // so the SDK ships safely even if the companion Lovable prompt
534
+ // hasn't landed on a given Fortress instance.
535
+ const streamUrl = fortressEndpoint(fortressBase, 'policies-stream');
536
+ const policyStream = new PolicyStream({
537
+ url: streamUrl,
538
+ apiKey: wmaApiKey,
539
+ anthropicAgentId: aid,
540
+ onError: (e) => warn(`${tag}policy-stream: ${e.message}`),
541
+ onInfo: (msg) => info(`${tag}${msg}`),
542
+ });
543
+ policyStream.on('policy_changed', () => {
544
+ // Fortress pushed a policy change for this agent — trigger an
545
+ // immediate refresh through the standard path so all the existing
546
+ // compile/validation logic applies.
547
+ fortressPolicies.refresh().catch((e) => warn(`${tag}stream-triggered refresh failed: ${e.message}`));
548
+ });
549
+ policyStream.start();
550
+ fortressStreams.push(policyStream);
522
551
  ruleset = fortressPolicies.current();
523
552
  }
524
553
 
@@ -572,9 +601,11 @@ async function main() {
572
601
  if (armed.size === 0) {
573
602
  die(`error: no agents could be armed (${agentIds.length} discovered; all policy fetches failed). Check WMA_API_KEY / WMA_FORTRESS_BASE_URL.`);
574
603
  }
575
- info(`fleet: ${armed.size}/${agentIds.length} agent(s) armed; reconciling every 60s for new agents.`);
604
+ // v1.1.0 Phase 1 L3: supervisor reconcile every 30s (was 60s) so a
605
+ // freshly-created Anthropic agent gets armed sub-30s instead of sub-minute.
606
+ info(`fleet: ${armed.size}/${agentIds.length} agent(s) armed; reconciling every 30s for new agents.`);
576
607
  while (!ac.signal.aborted) {
577
- await sleep(60_000, ac.signal);
608
+ await sleep(30_000, ac.signal);
578
609
  if (ac.signal.aborted) break;
579
610
  let all;
580
611
  try { all = await listAgents(apiKey); }
@@ -0,0 +1,209 @@
1
+ // ────────────────────────────────────────────────────────────────────────
2
+ // PolicyStream — Server-Sent Events consumer for instant policy propagation
3
+ // ────────────────────────────────────────────────────────────────────────
4
+ //
5
+ // v1.1.0 Phase 2: instead of polling Fortress every 60s for new policies
6
+ // (the FortressPolicySource refreshIntervalMs path), Shield maintains a
7
+ // persistent SSE connection to /functions/v1/policies-stream and refreshes
8
+ // its ruleset within ~100ms of a policy change in Fortress.
9
+ //
10
+ // Why SSE (not WebSocket):
11
+ // - Zero runtime dependencies preserved: HTTPS + SSE = node:https built-in,
12
+ // no @supabase/realtime-js, no custom Phoenix Channels client.
13
+ // - Node 18+ compat preserved: no native WebSocket needed.
14
+ // - Firewall-friendly: SSE rides on standard HTTPS — many enterprise
15
+ // proxies block raw WebSocket but pass through text/event-stream cleanly.
16
+ // - Realtime is uni-directional (Fortress → Shield) anyway. SSE is the
17
+ // right tool for one-way push notifications.
18
+ //
19
+ // Graceful fallback:
20
+ // - On HTTP 404 from the SSE endpoint (Fortress side not yet upgraded
21
+ // with the Lovable prompt), this stream goes into "fallback mode" and
22
+ // stops trying to reconnect aggressively. The FortressPolicySource's
23
+ // existing poll cadence (60s in v1.1.0) covers the gap.
24
+ // - On HTTP 401, this is a config error — logged once, stream stays
25
+ // down.
26
+ // - On network errors / disconnects, reconnect with exponential backoff
27
+ // (1s → 60s cap).
28
+ //
29
+ // Per-agent: each PolicyStream targets a single anthropic_agent_id so the
30
+ // Fortress side can scope the channel to "this customer + this agent".
31
+
32
+ import { request as httpsRequest } from 'node:https';
33
+ import { URL } from 'node:url';
34
+ import { EventEmitter } from 'node:events';
35
+
36
+ const RECONNECT_MIN_MS = 1_000;
37
+ const RECONNECT_MAX_MS = 60_000;
38
+ const FALLBACK_RETRY_INTERVAL_MS = 5 * 60_000;
39
+ const PERMANENT_FAILURE_LOG_INTERVAL_MS = 5 * 60_000;
40
+
41
+ export class PolicyStream extends EventEmitter {
42
+ constructor({ url, apiKey, anthropicAgentId, onError, onInfo }) {
43
+ super();
44
+ if (!url) throw new Error('PolicyStream requires url');
45
+ if (!apiKey) throw new Error('PolicyStream requires apiKey');
46
+ if (!anthropicAgentId) throw new Error('PolicyStream requires anthropicAgentId');
47
+ this.url = url;
48
+ this.apiKey = apiKey;
49
+ this.agentId = anthropicAgentId;
50
+ this.onError = onError || (() => {});
51
+ this.onInfo = onInfo || (() => {});
52
+ this._req = null;
53
+ this._closed = false;
54
+ this._started = false;
55
+ this._backoffMs = RECONNECT_MIN_MS;
56
+ this._inFallback = false;
57
+ this._lastFallbackLogAt = 0;
58
+ this._lastConfigErrorLogAt = 0;
59
+ }
60
+
61
+ start() {
62
+ if (this._closed) return;
63
+ this._started = true;
64
+ this._connect();
65
+ }
66
+
67
+ close() {
68
+ this._closed = true;
69
+ if (this._req) {
70
+ try { this._req.destroy(); } catch { /* already destroyed */ }
71
+ this._req = null;
72
+ }
73
+ }
74
+
75
+ // Whether the stream is currently the source of truth (i.e., started,
76
+ // not closed, AND not in fallback mode). Useful for Shield to know
77
+ // whether to trust SSE or rely on its own polling cadence.
78
+ isLive() {
79
+ return this._started && !this._inFallback && !this._closed;
80
+ }
81
+
82
+ _connect() {
83
+ if (this._closed) return;
84
+ const u = new URL(this.url);
85
+ // Query-param scoping so Fortress can filter to this agent's channel.
86
+ u.searchParams.set('agent_id', this.agentId);
87
+ if (u.protocol !== 'https:') {
88
+ this.onError(new Error(`policy-stream: refusing non-https URL: ${this.url}`));
89
+ return;
90
+ }
91
+
92
+ const req = httpsRequest({
93
+ hostname: u.hostname,
94
+ port: u.port || 443,
95
+ path: u.pathname + (u.search || ''),
96
+ method: 'GET',
97
+ headers: {
98
+ 'authorization': `Bearer ${this.apiKey}`,
99
+ 'accept': 'text/event-stream',
100
+ 'cache-control': 'no-cache',
101
+ 'connection': 'keep-alive',
102
+ },
103
+ rejectUnauthorized: true,
104
+ }, (res) => {
105
+ this._req = req;
106
+
107
+ // 404 — Fortress side hasn't deployed the endpoint yet. Silent
108
+ // fallback: log once per 5 min, retry every 5 min, don't spam.
109
+ if (res.statusCode === 404) {
110
+ this._inFallback = true;
111
+ const now = Date.now();
112
+ if (now - this._lastFallbackLogAt > PERMANENT_FAILURE_LOG_INTERVAL_MS) {
113
+ this.onInfo(`policy-stream: SSE endpoint not deployed (HTTP 404). Falling back to polling.`);
114
+ this._lastFallbackLogAt = now;
115
+ }
116
+ res.resume(); // drain to free the socket
117
+ this._scheduleReconnect(FALLBACK_RETRY_INTERVAL_MS);
118
+ return;
119
+ }
120
+
121
+ // 401 — auth error. Config bug; log once per 5 min.
122
+ if (res.statusCode === 401 || res.statusCode === 403) {
123
+ const now = Date.now();
124
+ if (now - this._lastConfigErrorLogAt > PERMANENT_FAILURE_LOG_INTERVAL_MS) {
125
+ this.onError(new Error(`policy-stream: auth error (HTTP ${res.statusCode}) — check WMA_API_KEY`));
126
+ this._lastConfigErrorLogAt = now;
127
+ }
128
+ this._inFallback = true;
129
+ res.resume();
130
+ this._scheduleReconnect(FALLBACK_RETRY_INTERVAL_MS);
131
+ return;
132
+ }
133
+
134
+ if (res.statusCode !== 200) {
135
+ this.onError(new Error(`policy-stream: unexpected HTTP ${res.statusCode}`));
136
+ res.resume();
137
+ this._scheduleReconnect();
138
+ return;
139
+ }
140
+
141
+ // We're live. Reset backoff + fallback flag.
142
+ this._backoffMs = RECONNECT_MIN_MS;
143
+ this._inFallback = false;
144
+ this.onInfo(`policy-stream: connected for ${this.agentId.slice(0, 16)}…`);
145
+ res.setEncoding('utf8');
146
+
147
+ let buffer = '';
148
+ res.on('data', (chunk) => {
149
+ buffer += chunk;
150
+ // SSE events are separated by a blank line ("\n\n").
151
+ let eolIdx;
152
+ while ((eolIdx = buffer.indexOf('\n\n')) !== -1) {
153
+ const rawEvent = buffer.slice(0, eolIdx);
154
+ buffer = buffer.slice(eolIdx + 2);
155
+ this._parseAndEmit(rawEvent);
156
+ }
157
+ });
158
+ res.on('end', () => {
159
+ if (!this._closed) {
160
+ this.onInfo('policy-stream: connection closed, reconnecting…');
161
+ this._scheduleReconnect();
162
+ }
163
+ });
164
+ res.on('error', (e) => {
165
+ this.onError(new Error(`policy-stream: response error: ${e.message}`));
166
+ if (!this._closed) this._scheduleReconnect();
167
+ });
168
+ });
169
+
170
+ req.on('error', (e) => {
171
+ this.onError(new Error(`policy-stream: request error: ${e.message}`));
172
+ if (!this._closed) this._scheduleReconnect();
173
+ });
174
+ // Stream MUST remain open — no body, no end() until close.
175
+ req.end();
176
+ }
177
+
178
+ _parseAndEmit(rawEvent) {
179
+ // SSE spec: each event is a set of "field: value" lines.
180
+ // We care about the `data:` field (multiple data: lines concatenate).
181
+ const dataLines = [];
182
+ for (const line of rawEvent.split('\n')) {
183
+ // Skip comments (lines starting with ":")
184
+ if (line.startsWith(':')) continue;
185
+ if (line.startsWith('data:')) {
186
+ // Drop leading "data:" and optional space
187
+ const v = line.slice(5).replace(/^ /, '');
188
+ dataLines.push(v);
189
+ }
190
+ }
191
+ if (dataLines.length === 0) return;
192
+ const data = dataLines.join('\n');
193
+ let parsed;
194
+ try { parsed = JSON.parse(data); }
195
+ catch (e) {
196
+ this.onError(new Error(`policy-stream: invalid JSON in event: ${e.message}`));
197
+ return;
198
+ }
199
+ // Emit 'policy_changed' — consumers should refresh their ruleset.
200
+ this.emit('policy_changed', parsed);
201
+ }
202
+
203
+ _scheduleReconnect(forceDelay) {
204
+ if (this._closed) return;
205
+ const delay = forceDelay != null ? forceDelay : this._backoffMs;
206
+ this._backoffMs = Math.min(this._backoffMs * 2, RECONNECT_MAX_MS);
207
+ setTimeout(() => this._connect(), delay);
208
+ }
209
+ }
@@ -148,6 +148,17 @@ export class FortressPolicySource {
148
148
  return this.ruleset;
149
149
  }
150
150
 
151
+ /**
152
+ * Public refresh hook for out-of-band triggers — e.g. the v1.1.0 SSE
153
+ * PolicyStream fires this when Fortress pushes a policy_changed event,
154
+ * collapsing the up-to-60s polling latency to ~100ms.
155
+ * Safe to call concurrently with the internal interval timer: each
156
+ * call only performs a single network round-trip.
157
+ */
158
+ async refresh() {
159
+ return this._refresh();
160
+ }
161
+
151
162
  async _refresh({ initial = false } = {}) {
152
163
  if (this._aborted) return;
153
164
  try {