watchmyagents 1.0.2 → 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 +13 -6
- package/package.json +1 -1
- package/scripts/fetch-anthropic.js +96 -1
- package/scripts/shield.js +35 -4
- package/src/anonymizer.js +10 -2
- package/src/shield/policy-stream.js +209 -0
- package/src/shield/sources/fortress.js +11 -0
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
|
|
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
|
|
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
|
|
|
@@ -155,7 +156,7 @@ wma-upload-fortress --agent-id agent_01ABC... [--display-name "My agent"]
|
|
|
155
156
|
wma-upload-fortress --agent-id agent_xxx --dry-run
|
|
156
157
|
```
|
|
157
158
|
|
|
158
|
-
**What is sent:** the anonymized signals payload (counts, latencies, salted IoC hashes, sequences — same as `wma-signals` output), the agent's **`classification`** when the daemon has it (`{agent_type, confidence, stage}` — anonymized metadata, never raw content), **plus the routing identifiers**: `provider` (e.g., `"anthropic-managed"` — added in v1.0 for the multi-framework SDK), `native_agent_id` (the canonical provider-agnostic field), `anthropic_agent_id` (kept for backwards compat with existing Fortress instances; will be dropped once Fortress migrates), `parent_agent_id` (`null` for root agents — populated for sub-agents detected via OpenAI Agents handoffs, CrewAI manager mode, Hermes Agent `spawn_subagent`, LangGraph sub-graphs), `composition_pattern` (`"solo" | "hierarchy" | "graph" | "peer"` — defaults to `"solo"` for Anthropic until thread-message detection lands), `enforcement_mode` (`"sync_confirm" | "sync_interrupt" | "detect_only"` — the strongest enforcement capability the Source provides; Fortress greys out Shield UI for `detect_only` agents to prevent UI/runtime mismatch), and a `display_name`. The agent id is required so Fortress can associate signals with the right agent; `display_name` defaults to the **human-readable agent name** (sanitized to strip control chars) for UX in the dashboard — pass `--no-send-agent-names` to keep it pseudonymized (sends the agent id instead) if your agent names themselves carry sensitive client/project info.
|
|
159
|
+
**What is sent:** the anonymized signals payload (counts, latencies, salted IoC hashes, sequences — same as `wma-signals` output), the agent's **`classification`** when the daemon has it (`{agent_type, confidence, stage}` — anonymized metadata, never raw content), **plus the routing identifiers**: `provider` (e.g., `"anthropic-managed"` — added in v1.0 for the multi-framework SDK), `native_agent_id` (the canonical provider-agnostic field), `anthropic_agent_id` (kept for backwards compat with existing Fortress instances; will be dropped once Fortress migrates), `parent_agent_id` (`null` for root agents — populated for sub-agents detected via OpenAI Agents handoffs, CrewAI manager mode, Hermes Agent `spawn_subagent`, LangGraph sub-graphs), `composition_pattern` (`"solo" | "hierarchy" | "graph" | "peer"` — defaults to `"solo"` for Anthropic until thread-message detection lands), `enforcement_mode` (`"sync_confirm" | "sync_interrupt" | "detect_only"` — the strongest enforcement capability the Source provides; Fortress greys out Shield UI for `detect_only` agents to prevent UI/runtime mismatch), **`session_ids[]`** (opaque vendor session tokens — e.g. Anthropic `sess_01XaNB…` — added in v1.0.2 so an operator looking at a Shield decision in Fortress can `grep` the local NDJSON immediately for full raw context ; non-secret but sensitive, see [docs/CONTAINMENT.md](docs/CONTAINMENT.md#routing--forensic-metadata--what-can-cross-to-fortress) for Fortress-side guardrails), and a `display_name`. The agent id is required so Fortress can associate signals with the right agent; `display_name` defaults to the **human-readable agent name** (sanitized to strip control chars) for UX in the dashboard — pass `--no-send-agent-names` to keep it pseudonymized (sends the agent id instead) if your agent names themselves carry sensitive client/project info.
|
|
159
160
|
**What is NOT sent:** raw prompts, raw URLs/commands/queries, raw agent responses, raw error messages. All payload content stays on your machine.
|
|
160
161
|
|
|
161
162
|
The endpoint auto-registers the agent on the first upload if it doesn't exist in Fortress yet — no manual onboarding needed for new agents.
|
|
@@ -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
|
|
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
|
|
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
|
|
@@ -247,7 +248,7 @@ WatchMyAgents is built so that **your prompts and outputs never have to leave yo
|
|
|
247
248
|
|---|---|
|
|
248
249
|
| **Your machine** (`./watchmyagents-logs/`) | Full NDJSON with all prompts, tool inputs, agent outputs. `chmod 600` on every file. |
|
|
249
250
|
| **Anthropic API** | Where the agent runs. WMA pulls events via the public REST API only. |
|
|
250
|
-
| **WMA Fortress** (opt-in, only with `--upload` / `wma-upload-fortress` / `wma-shield --policies-source fortress`) | The **anonymized signals** payload (counts, timings, salted hashes, sequences) + routing identifiers: `provider` (e.g. `"anthropic-managed"`), `native_agent_id`, `anthropic_agent_id` (legacy alias),
|
|
251
|
+
| **WMA Fortress** (opt-in, only with `--upload` / `wma-upload-fortress` / `wma-shield --policies-source fortress`) | The **anonymized signals** payload (counts, timings, salted hashes, sequences) + routing identifiers: `provider` (e.g. `"anthropic-managed"`), `native_agent_id`, `anthropic_agent_id` (legacy alias), `display_name` (defaults to the **human agent name** for dashboard UX — pass `--no-send-agent-names` to opt out and send only the agent id), and **`session_ids[]`** (opaque vendor session tokens, v1.0.2+, used by operators to grep their LOCAL NDJSON for full context after a Shield decision; non-secret but sensitive — Fortress applies RBAC, UI masking with reveal+audit, and retention limits, see [docs/CONTAINMENT.md](docs/CONTAINMENT.md)). Shield enforcement **decisions** (hashed session/event/input fingerprints — never raw values). **Never** raw prompts, URLs, commands, or outputs. |
|
|
251
252
|
|
|
252
253
|
This is the "local-first" guarantee: **raw payloads never leave your machine.** Cloud upload is opt-in and carries only anonymized metadata + the agent id/name needed to route it.
|
|
253
254
|
|
|
@@ -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
|
+
"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
|
-
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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); }
|
package/src/anonymizer.js
CHANGED
|
@@ -18,10 +18,18 @@
|
|
|
18
18
|
// - output.content (agent text)
|
|
19
19
|
// - raw URLs / commands / queries
|
|
20
20
|
// - error messages
|
|
21
|
-
// - readable session_id (hashed)
|
|
22
|
-
// - readable agent_id (hashed)
|
|
23
21
|
// - PII of any kind
|
|
24
22
|
//
|
|
23
|
+
// Forensic routing metadata that DOES cross to Fortress (opaque tokens,
|
|
24
|
+
// no semantic content, same sensitivity class as agent_id):
|
|
25
|
+
// - session_ids[] — opaque vendor session ids (e.g. Anthropic
|
|
26
|
+
// `sess_01XaNB…`). Sent so the operator looking
|
|
27
|
+
// at a Shield decision in Fortress can grep the
|
|
28
|
+
// LOCAL NDJSON for full raw context.
|
|
29
|
+
// → see docs/CONTAINMENT.md "Routing & forensic
|
|
30
|
+
// metadata" + the Fortress-side guardrails
|
|
31
|
+
// (RBAC, UI masking, audit log, retention).
|
|
32
|
+
//
|
|
25
33
|
// This is the single bottleneck between Watch (local) and Fortress (cloud).
|
|
26
34
|
// Every byte that crosses to the cloud passes through this module.
|
|
27
35
|
|
|
@@ -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 {
|