watchmyagents 0.6.0 → 0.8.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
@@ -25,7 +25,7 @@ You'll need:
25
25
  ```bash
26
26
  export ANTHROPIC_API_KEY="sk-ant-..."
27
27
 
28
- wma-fetch --agent-id agent_01XaN... --since 1h
28
+ wma-fetch --agent-id agent_01ABC... --since 1h
29
29
  wma-inspect
30
30
  ```
31
31
 
@@ -107,6 +107,7 @@ Each entry carries: `id`, `agent_id`, `framework`, `timestamp`, `action_type`, `
107
107
  ```bash
108
108
  wma-fetch --agent-id <agent_id> [--session-id <sess_id>] [--since 1h]
109
109
  [--log-dir ./watchmyagents-logs] [--dump-raw]
110
+ [--watch [--interval 5m] [--upload]]
110
111
  ```
111
112
 
112
113
  | Flag | Effect |
@@ -116,6 +117,9 @@ wma-fetch --agent-id <agent_id> [--session-id <sess_id>] [--since 1h]
116
117
  | `--session-id sesn_xxx` | Limit to a single session |
117
118
  | `--log-dir ./logs` | Where to write NDJSON (default `./watchmyagents-logs`) |
118
119
  | `--dump-raw` | Also save raw API events alongside (forensic / debugging) |
120
+ | `--watch` | **Continuous daemon** — loop forever, incrementally capturing NEW events (deduped by stable event id) until `Ctrl+C` |
121
+ | `--interval 5m` | Poll interval in watch mode (default `5m`; accepts `30s`/`1h`/…) |
122
+ | `--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. |
119
123
  | `--api-key sk-ant-…` | Override the `ANTHROPIC_API_KEY` env var. **Discouraged** — visible in shell history & process list. Prefer the env var. |
120
124
 
121
125
  Logs land in `./watchmyagents-logs/<agent_id>/<date>.ndjson` (file mode `0600`, dir `0700`).
@@ -141,7 +145,7 @@ export WMA_API_KEY="wma_..." # from Fortress dashboard → Se
141
145
  export WMA_FORTRESS_URL="https://<your-project>.supabase.co/functions/v1/ingest-signals"
142
146
  export WMA_SIGNALS_SALT="..." # same salt as wma-anonymize
143
147
 
144
- wma-upload-fortress --agent-id agent_01XaN... [--display-name "My agent"]
148
+ wma-upload-fortress --agent-id agent_01ABC... [--display-name "My agent"]
145
149
  # → POSTs the anonymized payload. Server returns signal_id + agent_id.
146
150
 
147
151
  # Inspect what WOULD be posted, without uploading:
@@ -163,20 +167,58 @@ wma-inspect [path]
163
167
 
164
168
  Outputs sections aligned with security audit needs: tokens summary, by-tool / by-action-type breakdowns, top tool destinations (URLs / queries), action-sequence transitions, tool error rates, p50/p95/max latency per tool, rate metrics.
165
169
 
166
- ## Automating (cron)
170
+ ## Automating — continuous monitoring
167
171
 
168
- For continuous monitoring, run `wma-fetch` on a cron:
172
+ ### `wma-service` — install as an always-on service (recommended)
173
+
174
+ The turnkey way: install Watch (and optionally Shield) as an OS-native service
175
+ that starts at login, restarts on crash, and runs with **no terminal**.
176
+
177
+ ```bash
178
+ export ANTHROPIC_API_KEY="sk-ant-..."
179
+ export WMA_API_KEY="wma_..."
180
+ export WMA_FORTRESS_BASE_URL="https://<project>.supabase.co/functions/v1"
181
+ export WMA_SIGNALS_SALT="..." # stable per-customer salt
182
+
183
+ wma-service install --agent-id agent_01ABC... --interval 5m [--with-shield]
184
+ wma-service status
185
+ wma-service uninstall [--with-shield]
186
+ ```
187
+
188
+ - macOS → **launchd** LaunchAgent · Linux → **systemd** user unit.
189
+ - Secrets are snapshotted to `~/.watchmyagents/env` (**chmod 600**) and loaded at
190
+ runtime — **never** written into the plist/unit.
191
+ - `--with-shield` also runs `wma-shield --policies-source fortress` always-on for
192
+ live enforcement.
193
+ - Raw logs stay local (`~/.watchmyagents/logs`); only anonymized signals upload.
194
+
195
+ After this, the full Watch→Guardian→Shield loop runs hands-off.
196
+
197
+ ### `wma-fetch --watch` — the daemon directly
198
+
199
+ If you'd rather run the loop in a terminal you control (the service wraps this):
200
+
201
+ ```bash
202
+ wma-fetch --agent-id agent_01ABC... --watch --upload --interval 5m
203
+ ```
204
+
205
+ It loops until `Ctrl+C`, dedupes by the stable Anthropic event id (no duplicate
206
+ log lines across cycles), and is restart-safe (it preloads already-captured
207
+ event ids on startup). The raw NDJSON never leaves your machine; only the
208
+ anonymized signals are uploaded.
209
+
210
+ ### cron alternative (one-shot)
211
+
212
+ If you'd rather not run a daemon, schedule one-shot fetches:
169
213
 
170
214
  ```cron
171
215
  # Every 15 minutes
172
- */15 * * * * cd /path/to/project && wma-fetch --agent-id agent_01XaN... --since 20m
216
+ */15 * * * * cd /path/to/project && wma-fetch --agent-id agent_01ABC... --since 20m
173
217
  ```
174
218
 
175
- Or for daily reports:
176
-
177
219
  ```cron
178
220
  # Once per night, fetch the full last 24h
179
- 5 0 * * * cd /path/to/project && wma-fetch --agent-id agent_01XaN... --since 25h
221
+ 5 0 * * * cd /path/to/project && wma-fetch --agent-id agent_01ABC... --since 25h
180
222
  ```
181
223
 
182
224
  ## Data sovereignty model
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "watchmyagents",
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.",
3
+ "version": "0.8.0",
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": [
7
7
  "src/",
@@ -10,6 +10,7 @@
10
10
  "scripts/shield.js",
11
11
  "scripts/anonymize.js",
12
12
  "scripts/upload-fortress.js",
13
+ "scripts/service.js",
13
14
  "README.md",
14
15
  "SECURITY.md",
15
16
  "LICENSE"
@@ -19,14 +20,16 @@
19
20
  "wma-fetch": "scripts/fetch-anthropic.js",
20
21
  "wma-shield": "scripts/shield.js",
21
22
  "wma-anonymize": "scripts/anonymize.js",
22
- "wma-upload-fortress": "scripts/upload-fortress.js"
23
+ "wma-upload-fortress": "scripts/upload-fortress.js",
24
+ "wma-service": "scripts/service.js"
23
25
  },
24
26
  "scripts": {
25
27
  "inspect": "node scripts/inspect.js",
26
28
  "fetch": "node scripts/fetch-anthropic.js",
27
29
  "shield": "node scripts/shield.js",
28
30
  "anonymize": "node scripts/anonymize.js",
29
- "upload-fortress": "node scripts/upload-fortress.js"
31
+ "upload-fortress": "node scripts/upload-fortress.js",
32
+ "service": "node scripts/service.js"
30
33
  },
31
34
  "engines": {
32
35
  "node": ">=18.0.0"
@@ -16,7 +16,9 @@
16
16
 
17
17
  import { readdir, stat, writeFile } from 'node:fs/promises';
18
18
  import { resolve, join } from 'node:path';
19
- import { SignalsAggregator, anonymizeFile } from '../src/anonymizer.js';
19
+ import { SignalsAggregator } from '../src/anonymizer.js';
20
+ import { createReadStream } from 'node:fs';
21
+ import { createInterface } from 'node:readline';
20
22
 
21
23
  function parseArgs(argv) {
22
24
  const out = {};
@@ -84,30 +86,18 @@ and save it in .env.local.`);
84
86
  die(`error: no .ndjson files found at ${target}`);
85
87
  }
86
88
 
87
- // Aggregate across all files into one big payload (typical: one fetch run)
89
+ // Aggregate across all files into one signals payload, single pass.
88
90
  const agg = new SignalsAggregator({ salt });
89
91
  for (const f of files) {
90
- const partial = await anonymizeFile(f, { salt });
91
- // Merge counts (a bit clunky — for the MVP we just re-iterate via agg)
92
- // Simpler: aggregate over the files using the same agg instance.
93
- // Re-implement here cleanly:
94
- void partial;
95
- }
96
- // Re-do cleanly with a single aggregator across files:
97
- const oneAgg = new SignalsAggregator({ salt });
98
- for (const f of files) {
99
- const { createReadStream } = await import('node:fs');
100
- const { createInterface } = await import('node:readline');
101
- const stream = createReadStream(f, { encoding: 'utf8' });
102
- const rl = createInterface({ input: stream, crlfDelay: Infinity });
92
+ const rl = createInterface({ input: createReadStream(f, { encoding: 'utf8' }), crlfDelay: Infinity });
103
93
  for await (const line of rl) {
104
94
  if (!line.trim()) continue;
105
95
  let e; try { e = JSON.parse(line); } catch { continue; }
106
- oneAgg.add(e);
96
+ agg.add(e);
107
97
  }
108
98
  }
109
99
 
110
- const signals = oneAgg.finalize();
100
+ const signals = agg.finalize();
111
101
 
112
102
  const json = JSON.stringify(signals, null, 2);
113
103
  if (args.out) {
@@ -1,17 +1,35 @@
1
1
  #!/usr/bin/env node
2
- // wma-fetch — pull session events from Anthropic Managed Agents and
3
- // write them as WatchMyAgents NDJSON, ready for `wma-inspect`.
2
+ // wma-fetch — pull session events from Anthropic Managed Agents and write them
3
+ // as WatchMyAgents NDJSON, ready for `wma-inspect`.
4
4
  //
5
- // Usage:
6
- // wma-fetch --agent-id agent_xxx [--session-id sess_xxx] [--since 1h]
7
- // [--log-dir ./watchmyagents-logs] [--dump-raw]
5
+ // Two modes:
8
6
  //
9
- // API key is read from --api-key or env ANTHROPIC_API_KEY.
7
+ // ONE-SHOT (default):
8
+ // wma-fetch --agent-id agent_xxx [--session-id sess_xxx] [--since 1h]
9
+ // [--log-dir ./watchmyagents-logs] [--dump-raw]
10
+ //
11
+ // CONTINUOUS / DAEMON:
12
+ // wma-fetch --agent-id agent_xxx --watch [--interval 5m] [--upload]
13
+ // Loops until SIGINT. Each cycle incrementally fetches NEW events (deduped
14
+ // by the stable Anthropic event id), appends them to the NDJSON, and — with
15
+ // --upload — anonymizes the new window and ships signals to Fortress. This
16
+ // automates the Watch leg of the WGS loop so Guardian gets fresh data with
17
+ // no manual step. The raw NDJSON always stays local (Modèle C).
18
+ //
19
+ // API key from --api-key or env ANTHROPIC_API_KEY.
20
+ // --upload also needs: WMA_API_KEY, WMA_FORTRESS_BASE_URL, WMA_SIGNALS_SALT.
10
21
 
11
- import { mkdir, appendFile } from 'node:fs/promises';
22
+ import { mkdir, appendFile, readdir } from 'node:fs/promises';
23
+ import { createReadStream } from 'node:fs';
24
+ import { createInterface } from 'node:readline';
12
25
  import { join, resolve } from 'node:path';
26
+ import { request as httpsRequest } from 'node:https';
27
+ import { URL } from 'node:url';
13
28
  import { Logger } from '../src/logger.js';
14
29
  import { TokenTracker } from '../src/tokens.js';
30
+ import { SignalsAggregator } from '../src/anonymizer.js';
31
+ import { resolveFortressBase, fortressEndpoint } from '../src/fortress/url.js';
32
+ import { isValidAgentId, isValidSessionId, assertSafePathSegment } from '../src/validate.js';
15
33
  import {
16
34
  getAgent, listSessions, fetchSessionEntries, fetchRawEvents,
17
35
  } from '../src/sources/anthropic-managed.js';
@@ -30,71 +48,126 @@ function parseArgs(argv) {
30
48
  return out;
31
49
  }
32
50
 
33
- function parseSince(s) {
34
- if (!s || s === true) return null;
51
+ function parseDurationMs(s, fallback) {
52
+ if (!s || s === true) return fallback;
35
53
  const m = String(s).match(/^(\d+)\s*([smhd])$/);
36
54
  if (m) {
37
55
  const n = parseInt(m[1], 10);
38
- const mult = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2]];
39
- return new Date(Date.now() - n * mult);
56
+ return n * { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2]];
40
57
  }
58
+ throw new Error(`invalid duration: ${s} (use e.g. 30s, 5m, 1h, 2d)`);
59
+ }
60
+
61
+ function parseSince(s) {
62
+ if (!s || s === true) return null;
63
+ const m = String(s).match(/^(\d+)\s*([smhd])$/);
64
+ if (m) return new Date(Date.now() - parseDurationMs(s));
41
65
  const d = new Date(s);
42
66
  if (isNaN(d)) throw new Error(`invalid --since value: ${s}`);
43
67
  return d;
44
68
  }
45
69
 
46
70
  function die(msg, code = 1) { process.stderr.write(`${msg}\n`); process.exit(code); }
71
+ function info(msg) { process.stdout.write(`[wma-fetch] ${msg}\n`); }
72
+ function warn(msg) { process.stderr.write(`[wma-fetch] ⚠️ ${msg}\n`); }
47
73
 
48
- async function main() {
49
- const args = parseArgs(process.argv.slice(2));
50
- const apiKey = args['api-key'] || process.env.ANTHROPIC_API_KEY;
51
- const agentId = args['agent-id'];
52
- const sessionId = args['session-id'];
53
- const since = args.since ? parseSince(args.since) : null;
54
- const logDir = resolve(args['log-dir'] || './watchmyagents-logs');
55
- const dumpRaw = !!args['dump-raw'];
74
+ function resolveModel(agent) {
75
+ const raw = agent.model || agent.config?.model || null;
76
+ return (raw && typeof raw === 'object') ? (raw.id || null) : raw;
77
+ }
56
78
 
57
- if (!apiKey) die('error: --api-key or ANTHROPIC_API_KEY required');
58
- if (!agentId) die('error: --agent-id required (e.g. agent_01XaNB4M88ZvcW8FoQ5GC14A)');
79
+ // HTTPS POST helper for the --upload signals push (mirrors wma-upload-fortress).
80
+ function postJson(url, headers, body) {
81
+ return new Promise((resolveReq, rejectReq) => {
82
+ const u = new URL(url);
83
+ if (u.protocol !== 'https:') return rejectReq(new Error(`refusing non-https URL: ${url}`));
84
+ const data = Buffer.from(body);
85
+ const req = httpsRequest({
86
+ method: 'POST', hostname: u.hostname, port: u.port || 443,
87
+ path: u.pathname + (u.search || ''),
88
+ headers: { ...headers, 'content-type': 'application/json', 'content-length': data.length },
89
+ rejectUnauthorized: true,
90
+ }, (res) => {
91
+ const chunks = [];
92
+ res.on('data', (c) => chunks.push(c));
93
+ res.on('end', () => {
94
+ const raw = Buffer.concat(chunks).toString('utf8');
95
+ let parsed = null; try { parsed = JSON.parse(raw); } catch { /* keep raw */ }
96
+ resolveReq({ status: res.statusCode || 0, body: parsed ?? raw });
97
+ });
98
+ });
99
+ req.on('error', rejectReq);
100
+ req.write(data); req.end();
101
+ });
102
+ }
59
103
 
60
- // Security: --api-key on the command line ends up in shell history and is
61
- // visible to other processes via /proc/<pid>/cmdline. Strongly prefer the
62
- // ANTHROPIC_API_KEY environment variable.
63
- if (args['api-key']) {
64
- process.stderr.write(
65
- '[wma-fetch] warning: --api-key on the command line is visible in shell history and\n' +
66
- ' in the process list. Prefer: export ANTHROPIC_API_KEY=...\n'
67
- );
104
+ // Anonymize a batch of just-written entries and ship them as one signals row.
105
+ async function uploadSignals(uploadCtx, agentId, displayName, entries) {
106
+ const agg = new SignalsAggregator({ salt: uploadCtx.salt });
107
+ for (const e of entries) agg.add(e);
108
+ const sig = agg.finalize();
109
+ if (!sig.window_start || !sig.window_end) return null; // nothing datable to ship
110
+ const body = JSON.stringify({
111
+ anthropic_agent_id: agentId,
112
+ display_name: displayName,
113
+ window_start: sig.window_start,
114
+ window_end: sig.window_end,
115
+ payload: sig.payload,
116
+ });
117
+ const { status, body: resp } = await postJson(
118
+ uploadCtx.url, { authorization: `Bearer ${uploadCtx.apiKey}` }, body,
119
+ );
120
+ if (status < 200 || status >= 300) {
121
+ throw new Error(`ingest-signals HTTP ${status}: ${typeof resp === 'string' ? resp.slice(0, 200) : JSON.stringify(resp)}`);
122
+ }
123
+ return resp;
124
+ }
125
+
126
+ // Preload already-written entry ids so a restarted daemon doesn't re-append
127
+ // events captured in a previous run (dedup by the stable Anthropic event id).
128
+ async function preloadSeenIds(logDir, agentId) {
129
+ const seen = new Set();
130
+ const dir = join(logDir, agentId);
131
+ let names;
132
+ try { names = await readdir(dir); } catch { return seen; }
133
+ for (const name of names) {
134
+ if (!name.endsWith('.ndjson') || name.startsWith('raw-')) continue;
135
+ await new Promise((res) => {
136
+ const rl = createInterface({ input: createReadStream(join(dir, name), { encoding: 'utf8' }), crlfDelay: Infinity });
137
+ rl.on('line', (line) => {
138
+ if (!line.trim()) return;
139
+ try { const e = JSON.parse(line); if (e.id) seen.add(e.id); } catch { /* skip */ }
140
+ });
141
+ rl.on('close', res);
142
+ rl.on('error', res);
143
+ });
68
144
  }
145
+ return seen;
146
+ }
69
147
 
70
- process.stdout.write(`[wma-fetch] resolving agent ${agentId}…\n`);
71
- const agent = await getAgent(apiKey, agentId).catch(e => die(`failed to GET agent: ${e.message}`));
72
- const rawModel = agent.model || agent.config?.model || null;
73
- // API may return model as { id, speed } object or as a plain string.
74
- const model = (rawModel && typeof rawModel === 'object') ? (rawModel.id || null) : rawModel;
75
- process.stdout.write(`[wma-fetch] model: ${model || '(unknown)'}\n`);
148
+ const sleep = (ms, signal) => new Promise((res) => {
149
+ const t = setTimeout(res, ms);
150
+ if (signal) signal.addEventListener('abort', () => { clearTimeout(t); res(); }, { once: true });
151
+ });
76
152
 
153
+ // ── ONE-SHOT ──────────────────────────────────────────────────────────────
154
+ async function fetchOneShot({ apiKey, agentId, model, logDir, since, sessionId, dumpRaw }) {
77
155
  let sessions;
78
156
  if (sessionId) {
79
157
  sessions = [{ id: sessionId, created_at: new Date().toISOString() }];
80
158
  } else {
81
- process.stdout.write(`[wma-fetch] listing sessions${since ? ` since ${since.toISOString()}` : ''}…\n`);
82
- sessions = await listSessions(apiKey, { agentId, since })
83
- .catch(e => die(`failed to list sessions: ${e.message}`));
84
- }
85
-
86
- if (sessions.length === 0) {
87
- process.stdout.write('[wma-fetch] no sessions to fetch\n');
88
- return;
159
+ info(`listing sessions${since ? ` since ${since.toISOString()}` : ''}…`);
160
+ sessions = await listSessions(apiKey, { agentId, since }).catch((e) => die(`failed to list sessions: ${e.message}`));
89
161
  }
90
- process.stdout.write(`[wma-fetch] ${sessions.length} session(s) to fetch\n`);
162
+ if (sessions.length === 0) { info('no sessions to fetch'); return; }
163
+ info(`${sessions.length} session(s) to fetch`);
91
164
 
92
165
  let totalEntries = 0;
93
166
  for (const s of sessions) {
94
167
  const sid = s.id;
95
168
  process.stdout.write(`\n[wma-fetch] session ${sid}\n`);
96
-
97
169
  if (dumpRaw) {
170
+ assertSafePathSegment(sid, 'session-id'); // defense-in-depth: sid → file path
98
171
  const rawPath = join(logDir, agentId, `raw-${sid}.jsonl`);
99
172
  await mkdir(join(logDir, agentId), { recursive: true, mode: 0o700 });
100
173
  for await (const ev of fetchRawEvents(apiKey, sid)) {
@@ -102,39 +175,147 @@ async function main() {
102
175
  }
103
176
  process.stdout.write(` raw events → ${rawPath}\n`);
104
177
  }
105
-
106
178
  const logger = new Logger({ logDir, agentId, sessionId: sid, silent: true });
107
179
  const tracker = new TokenTracker();
108
-
109
180
  let count = 0;
110
181
  for await (const entry of fetchSessionEntries({ apiKey, agentId, sessionId: sid, model })) {
111
182
  const written = await logger.write(entry);
112
183
  tracker.record(written);
113
184
  count++;
114
185
  }
115
-
116
186
  const stats = tracker.stats().total;
117
- const sessionEnd = await logger.write({
118
- action_type: 'session_end',
119
- framework: 'anthropic-managed',
120
- status: 'ok',
121
- model,
122
- session_tokens: {
123
- input: stats.input, output: stats.output,
124
- cache_read: stats.cache_read, cache_creation: stats.cache_creation,
125
- total: stats.sum,
126
- },
187
+ await logger.write({
188
+ action_type: 'session_end', framework: 'anthropic-managed', status: 'ok', model,
189
+ session_tokens: { input: stats.input, output: stats.output, cache_read: stats.cache_read, cache_creation: stats.cache_creation, total: stats.sum },
127
190
  session_cost_usd: stats.cost_usd || null,
128
191
  });
129
-
130
192
  process.stdout.write(` entries : ${count} (+1 session_end)\n`);
131
193
  process.stdout.write(` tokens : in=${stats.input} out=${stats.output} cache_r=${stats.cache_read} cache_w=${stats.cache_creation}\n`);
132
194
  process.stdout.write(` written to : ${logger._pathForToday()}\n`);
133
195
  totalEntries += count + 1;
134
196
  }
135
-
136
197
  process.stdout.write(`\n[wma-fetch] done — ${totalEntries} total entries across ${sessions.length} session(s)\n`);
137
198
  process.stdout.write(`[wma-fetch] inspect with: npx wma-inspect ${logDir}\n`);
138
199
  }
139
200
 
140
- main().catch(e => { process.stderr.write(`error: ${e.stack || e.message}\n`); process.exit(1); });
201
+ // ── CONTINUOUS / DAEMON ─────────────────────────────────────────────────────
202
+ async function runWatch({ apiKey, agentId, model, displayName, logDir, intervalMs, uploadCtx }) {
203
+ const seenIds = await preloadSeenIds(logDir, agentId);
204
+ const loggers = new Map(); // sessionId → Logger (persists sequence across cycles)
205
+ const ended = new Set(); // sessions we've already closed with session_end
206
+
207
+ const ac = new AbortController();
208
+ const shutdown = () => { info('shutting down…'); ac.abort(); };
209
+ process.on('SIGINT', shutdown);
210
+ process.on('SIGTERM', shutdown);
211
+
212
+ info(`watch mode — interval ${Math.round(intervalMs / 1000)}s, upload ${uploadCtx ? 'ON' : 'OFF'}, ${seenIds.size} known events preloaded`);
213
+
214
+ while (!ac.signal.aborted) {
215
+ const since = new Date(Date.now() - 24 * 3600 * 1000);
216
+ let sessions = [];
217
+ try { sessions = await listSessions(apiKey, { agentId, since }); }
218
+ catch (e) { warn(`listSessions failed: ${e.message}`); }
219
+
220
+ let cycleNew = 0;
221
+ for (const s of sessions) {
222
+ if (!s.id || ended.has(s.id)) continue;
223
+ let logger = loggers.get(s.id);
224
+ if (!logger) { logger = new Logger({ logDir, agentId, sessionId: s.id, silent: true }); loggers.set(s.id, logger); }
225
+
226
+ const fresh = [];
227
+ let sawTerminated = false;
228
+ try {
229
+ for await (const entry of fetchSessionEntries({ apiKey, agentId, sessionId: s.id, model })) {
230
+ if (entry.id && seenIds.has(entry.id)) continue;
231
+ if (entry.id) seenIds.add(entry.id);
232
+ const written = await logger.write(entry);
233
+ fresh.push(written);
234
+ if (entry.action_type === 'state_transition'
235
+ && entry.output?.scope === 'session'
236
+ && entry.output?.state === 'terminated') sawTerminated = true;
237
+ }
238
+ } catch (e) { warn(`session ${s.id}: fetch failed: ${e.message}`); continue; }
239
+
240
+ if (fresh.length === 0) continue;
241
+ cycleNew += fresh.length;
242
+ info(`session ${s.id.slice(0, 16)}…: +${fresh.length} new event(s)`);
243
+
244
+ if (uploadCtx) {
245
+ try {
246
+ const resp = await uploadSignals(uploadCtx, agentId, displayName, fresh);
247
+ if (resp?.signal_id) info(` ↑ signals uploaded (signal_id ${resp.signal_id})`);
248
+ } catch (e) { warn(` signals upload failed: ${e.message}`); }
249
+ }
250
+
251
+ if (sawTerminated) {
252
+ const tracker = new TokenTracker();
253
+ for (const e of fresh) tracker.record(e);
254
+ const stats = tracker.stats().total;
255
+ await logger.write({
256
+ action_type: 'session_end', framework: 'anthropic-managed', status: 'ok', model,
257
+ session_tokens: { input: stats.input, output: stats.output, cache_read: stats.cache_read, cache_creation: stats.cache_creation, total: stats.sum },
258
+ session_cost_usd: stats.cost_usd || null,
259
+ });
260
+ ended.add(s.id);
261
+ info(`session ${s.id.slice(0, 16)}… terminated — closed`);
262
+ }
263
+ }
264
+
265
+ if (cycleNew === 0) info('cycle: no new events');
266
+ await sleep(intervalMs, ac.signal);
267
+ }
268
+ info('stopped.');
269
+ }
270
+
271
+ async function main() {
272
+ const args = parseArgs(process.argv.slice(2));
273
+ const apiKey = args['api-key'] || process.env.ANTHROPIC_API_KEY;
274
+ const agentId = args['agent-id'];
275
+ const logDir = resolve(args['log-dir'] || './watchmyagents-logs');
276
+ const watch = !!args.watch;
277
+ const upload = !!args.upload;
278
+
279
+ if (!apiKey) die('error: --api-key or ANTHROPIC_API_KEY required');
280
+ if (!agentId) die('error: --agent-id required (e.g. agent_01ABC...)');
281
+ if (!isValidAgentId(agentId)) {
282
+ die(`error: --agent-id has invalid format (expected "agent_" + alphanumeric, got "${agentId}")`);
283
+ }
284
+ const sessionIdArg = args['session-id'];
285
+ if (sessionIdArg && !isValidSessionId(sessionIdArg)) {
286
+ die(`error: --session-id has invalid format (expected "sesn_" + alphanumeric, got "${sessionIdArg}")`);
287
+ }
288
+ if (args['api-key']) {
289
+ warn('--api-key on the command line is visible in shell history and the process list. Prefer: export ANTHROPIC_API_KEY=...');
290
+ }
291
+ if (upload && !watch) die('error: --upload requires --watch (continuous mode). For one-shot upload use wma-upload-fortress.');
292
+
293
+ // Resolve upload config up-front (so a misconfig fails before the loop starts).
294
+ let uploadCtx = null;
295
+ if (upload) {
296
+ const wmaKey = process.env.WMA_API_KEY;
297
+ const salt = process.env.WMA_SIGNALS_SALT;
298
+ const base = resolveFortressBase({});
299
+ if (!wmaKey) die('error: --upload needs WMA_API_KEY env (from Fortress dashboard → Settings → API Keys)');
300
+ if (!base) die('error: --upload needs WMA_FORTRESS_BASE_URL env (https://<project>.supabase.co/functions/v1)');
301
+ if (!salt) die('error: --upload needs WMA_SIGNALS_SALT env (stable per-customer hex secret)');
302
+ if (salt.length < 16) die('error: WMA_SIGNALS_SALT too short (need ≥16 hex chars)');
303
+ uploadCtx = { apiKey: wmaKey, salt, url: fortressEndpoint(base, 'ingest-signals') };
304
+ }
305
+
306
+ info(`resolving agent ${agentId}…`);
307
+ const agent = await getAgent(apiKey, agentId).catch((e) => die(`failed to GET agent: ${e.message}`));
308
+ const model = resolveModel(agent);
309
+ const displayName = agent.name || agentId;
310
+ info(`model: ${model || '(unknown)'}`);
311
+
312
+ if (watch) {
313
+ const intervalMs = parseDurationMs(args.interval, 5 * 60_000);
314
+ await runWatch({ apiKey, agentId, model, displayName, logDir, intervalMs, uploadCtx });
315
+ } else {
316
+ const since = args.since ? parseSince(args.since) : null;
317
+ await fetchOneShot({ apiKey, agentId, model, logDir, since, sessionId: args['session-id'], dumpRaw: !!args['dump-raw'] });
318
+ }
319
+ }
320
+
321
+ main().catch((e) => { process.stderr.write(`error: ${e.stack || e.message}\n`); process.exit(1); });
@@ -0,0 +1,325 @@
1
+ #!/usr/bin/env node
2
+ // wma-service — install WatchMyAgents as an always-on OS background service.
3
+ //
4
+ // Turns the manual `wma-fetch --watch` (and optionally `wma-shield`) commands
5
+ // into OS-native services that start at login, restart on crash, and run with
6
+ // NO terminal — so the WGS loop is truly automatic on the customer's machine.
7
+ //
8
+ // macOS → launchd LaunchAgent (~/Library/LaunchAgents)
9
+ // Linux → systemd user unit (~/.config/systemd/user)
10
+ //
11
+ // One integrated install:
12
+ // wma-service install --agent-id agent_xxx [--interval 5m] [--with-shield]
13
+ // wma-service status
14
+ // wma-service uninstall [--with-shield]
15
+ //
16
+ // Secrets NEVER go in the plist/unit. They're snapshotted (from the current
17
+ // environment) into a protected env file (~/.watchmyagents/env, chmod 600) that
18
+ // the service loads at runtime. Required env at install time:
19
+ // ANTHROPIC_API_KEY, WMA_API_KEY, WMA_FORTRESS_BASE_URL, WMA_SIGNALS_SALT
20
+ // Raw logs stay local (Modèle C); only anonymized signals are uploaded.
21
+
22
+ import os from 'node:os';
23
+ import { mkdirSync, writeFileSync, rmSync, existsSync, chmodSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+ import { execFileSync } from 'node:child_process';
27
+ import { isValidAgentId } from '../src/validate.js';
28
+
29
+ const HOME = os.homedir();
30
+ const PLATFORM = process.platform; // 'darwin' | 'linux' | …
31
+ const UID = typeof process.getuid === 'function' ? process.getuid() : null;
32
+ const NODE = process.execPath; // absolute node binary
33
+ const FETCH_SCRIPT = fileURLToPath(new URL('./fetch-anthropic.js', import.meta.url));
34
+ const SHIELD_SCRIPT = fileURLToPath(new URL('./shield.js', import.meta.url));
35
+
36
+ const CONFIG_DIR = join(HOME, '.watchmyagents');
37
+ const ENV_FILE = join(CONFIG_DIR, 'env');
38
+ const LOG_DIR_DEFAULT = join(CONFIG_DIR, 'logs');
39
+
40
+ const REQUIRED_ENV = ['ANTHROPIC_API_KEY', 'WMA_API_KEY', 'WMA_FORTRESS_BASE_URL', 'WMA_SIGNALS_SALT'];
41
+
42
+ const WATCH_LABEL = 'com.watchmyagents.watch';
43
+ const SHIELD_LABEL = 'com.watchmyagents.shield';
44
+
45
+ function parseArgs(argv) {
46
+ const out = { _: [] };
47
+ for (let i = 0; i < argv.length; i++) {
48
+ const a = argv[i];
49
+ if (a.startsWith('--')) {
50
+ const k = a.slice(2);
51
+ const n = argv[i + 1];
52
+ if (n == null || n.startsWith('--')) out[k] = true;
53
+ else { out[k] = n; i++; }
54
+ } else out._.push(a);
55
+ }
56
+ return out;
57
+ }
58
+
59
+ function die(msg, code = 1) { process.stderr.write(`error: ${msg}\n`); process.exit(code); }
60
+ function info(msg) { process.stdout.write(`[wma-service] ${msg}\n`); }
61
+ function warn(msg) { process.stderr.write(`[wma-service] ⚠️ ${msg}\n`); }
62
+
63
+ function sh(value) { return `"${String(value).replace(/(["$`\\])/g, '\\$1')}"`; }
64
+
65
+ // ── Config (secrets) ──────────────────────────────────────────────────────
66
+ function writeEnvFile() {
67
+ const missing = REQUIRED_ENV.filter((k) => !process.env[k]);
68
+ if (missing.length) {
69
+ die(`missing required env var(s): ${missing.join(', ')}\n` +
70
+ ' Export them in this shell, then re-run install. e.g.:\n' +
71
+ ' export $(grep -v "^#" .env | xargs)\n' +
72
+ ' export WMA_API_KEY=... WMA_FORTRESS_BASE_URL=... WMA_SIGNALS_SALT=...');
73
+ }
74
+ // The env file is sourced by the launcher (set -a; . file) and read by
75
+ // systemd's EnvironmentFile. A newline in a value would inject extra lines /
76
+ // corrupt the file, so reject it. (Our value types — hex salt, wma_/sk-ant
77
+ // keys, https URL — never legitimately contain newlines.)
78
+ for (const k of REQUIRED_ENV) {
79
+ if (/[\r\n]/.test(process.env[k])) die(`${k} contains a newline — refusing to write it to the env file`);
80
+ }
81
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
82
+ // Plain KEY=value lines — readable by both `set -a; . file` (launchd launcher)
83
+ // and systemd's EnvironmentFile=. No quoting needed (no spaces in our values).
84
+ const body = REQUIRED_ENV.map((k) => `${k}=${process.env[k]}`).join('\n') + '\n';
85
+ writeFileSync(ENV_FILE, body, { mode: 0o600 });
86
+ chmodSync(ENV_FILE, 0o600);
87
+ info(`secrets written to ${ENV_FILE} (chmod 600)`);
88
+ }
89
+
90
+ // ── macOS (launchd) ─────────────────────────────────────────────────────--
91
+ function launchAgentsDir() { return join(HOME, 'Library', 'LaunchAgents'); }
92
+ function plistPath(label) { return join(launchAgentsDir(), `${label}.plist`); }
93
+ function launcherPath(label) { return join(CONFIG_DIR, `${label}.launcher.sh`); }
94
+
95
+ function writeLauncher(label, scriptPath, args) {
96
+ const argLine = args.map(sh).join(' ');
97
+ const body = `#!/bin/sh
98
+ # Generated by wma-service. Loads secrets then runs the WMA daemon.
99
+ set -a
100
+ . ${sh(ENV_FILE)}
101
+ set +a
102
+ exec ${sh(NODE)} ${sh(scriptPath)} ${argLine}
103
+ `;
104
+ const p = launcherPath(label);
105
+ writeFileSync(p, body, { mode: 0o700 });
106
+ chmodSync(p, 0o700);
107
+ return p;
108
+ }
109
+
110
+ function writePlist(label, launcher) {
111
+ const outLog = join(CONFIG_DIR, `${label}.out.log`);
112
+ const errLog = join(CONFIG_DIR, `${label}.err.log`);
113
+ // Pre-create the log files 0600 so launchd appends to owner-only files.
114
+ // (No secrets are logged, but defense-in-depth on world-readable home files.)
115
+ for (const lp of [outLog, errLog]) {
116
+ if (!existsSync(lp)) writeFileSync(lp, '', { mode: 0o600 });
117
+ else chmodSync(lp, 0o600);
118
+ }
119
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
120
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
121
+ <plist version="1.0">
122
+ <dict>
123
+ <key>Label</key><string>${label}</string>
124
+ <key>ProgramArguments</key>
125
+ <array><string>${launcher}</string></array>
126
+ <key>RunAtLoad</key><true/>
127
+ <key>KeepAlive</key><true/>
128
+ <key>ProcessType</key><string>Background</string>
129
+ <key>StandardOutPath</key><string>${outLog}</string>
130
+ <key>StandardErrorPath</key><string>${errLog}</string>
131
+ </dict>
132
+ </plist>
133
+ `;
134
+ mkdirSync(launchAgentsDir(), { recursive: true });
135
+ const p = plistPath(label);
136
+ writeFileSync(p, body, { mode: 0o644 });
137
+ return p;
138
+ }
139
+
140
+ function launchctl(args, { ignoreError = false } = {}) {
141
+ try {
142
+ execFileSync('launchctl', args, { stdio: 'pipe' });
143
+ return true;
144
+ } catch (e) {
145
+ if (!ignoreError) warn(`launchctl ${args.join(' ')} failed: ${(e.stderr || e.message).toString().trim().slice(0, 200)}`);
146
+ return false;
147
+ }
148
+ }
149
+
150
+ function macLoad(label, plist) {
151
+ const domain = `gui/${UID}`;
152
+ launchctl(['bootout', `${domain}/${label}`], { ignoreError: true }); // unload prior
153
+ const ok = launchctl(['bootstrap', domain, plist]);
154
+ launchctl(['enable', `${domain}/${label}`], { ignoreError: true });
155
+ if (ok) info(`loaded ${label} (launchd) — running now + at every login`);
156
+ else {
157
+ warn(`could not auto-load ${label}. Load it manually:`);
158
+ process.stdout.write(` launchctl bootstrap gui/${UID} ${plist}\n`);
159
+ }
160
+ }
161
+
162
+ function macUnload(label) {
163
+ const domain = `gui/${UID}`;
164
+ launchctl(['bootout', `${domain}/${label}`], { ignoreError: true });
165
+ for (const p of [plistPath(label), launcherPath(label)]) if (existsSync(p)) rmSync(p);
166
+ info(`removed ${label} (launchd)`);
167
+ }
168
+
169
+ function macInstallOne(label, scriptPath, args) {
170
+ const launcher = writeLauncher(label, scriptPath, args);
171
+ const plist = writePlist(label, launcher);
172
+ macLoad(label, plist);
173
+ }
174
+
175
+ // ── Linux (systemd user) ───────────────────────────────────────────────────
176
+ function systemdDir() { return join(HOME, '.config', 'systemd', 'user'); }
177
+ function unitName(label) { return `${label.replace(/\./g, '-')}.service`; }
178
+ function unitPath(label) { return join(systemdDir(), unitName(label)); }
179
+
180
+ function writeUnit(label, desc, scriptPath, args) {
181
+ // Quote every token for systemd. systemd splits ExecStart on whitespace and
182
+ // does NOT run a shell; double-quotes group tokens and honor \" and \\.
183
+ const sdQuote = (s) => `"${String(s).replace(/(["\\])/g, '\\$1')}"`;
184
+ const exec = [NODE, scriptPath, ...args].map(sdQuote).join(' ');
185
+ const body = `[Unit]
186
+ Description=${desc}
187
+ After=network-online.target
188
+ Wants=network-online.target
189
+
190
+ [Service]
191
+ Type=simple
192
+ EnvironmentFile=${ENV_FILE}
193
+ ExecStart=${exec}
194
+ Restart=always
195
+ RestartSec=10
196
+
197
+ [Install]
198
+ WantedBy=default.target
199
+ `;
200
+ mkdirSync(systemdDir(), { recursive: true });
201
+ const p = unitPath(label);
202
+ writeFileSync(p, body, { mode: 0o644 });
203
+ return p;
204
+ }
205
+
206
+ function systemctl(args, { ignoreError = false } = {}) {
207
+ try { execFileSync('systemctl', ['--user', ...args], { stdio: 'pipe' }); return true; }
208
+ catch (e) { if (!ignoreError) warn(`systemctl --user ${args.join(' ')} failed: ${(e.stderr || e.message).toString().trim().slice(0, 200)}`); return false; }
209
+ }
210
+
211
+ function linuxInstallOne(label, desc, scriptPath, args) {
212
+ writeUnit(label, desc, scriptPath, args);
213
+ const unit = unitName(label);
214
+ systemctl(['daemon-reload'], { ignoreError: true });
215
+ const ok = systemctl(['enable', '--now', unit]);
216
+ if (ok) info(`enabled ${unit} (systemd) — running now + at login. For boot-without-login: loginctl enable-linger ${process.env.USER || ''}`);
217
+ else { warn(`could not auto-enable ${unit}. Enable manually:`); process.stdout.write(` systemctl --user enable --now ${unit}\n`); }
218
+ }
219
+
220
+ function linuxUninstallOne(label) {
221
+ const unit = unitName(label);
222
+ systemctl(['disable', '--now', unit], { ignoreError: true });
223
+ if (existsSync(unitPath(label))) rmSync(unitPath(label));
224
+ systemctl(['daemon-reload'], { ignoreError: true });
225
+ info(`removed ${unit} (systemd)`);
226
+ }
227
+
228
+ // ── Commands ────────────────────────────────────────────────────────────--
229
+ function cmdInstall(args) {
230
+ const agentId = args['agent-id'];
231
+ if (!agentId) die('--agent-id required (e.g. agent_01ABC...)');
232
+ if (!isValidAgentId(agentId)) die(`--agent-id invalid format (expected "agent_" + alphanumeric, got "${agentId}")`);
233
+ const interval = args.interval || '5m';
234
+ if (!/^\d+[smhd]$/.test(interval)) die(`--interval invalid format (expected like 30s, 5m, 1h, 2d; got "${interval}")`);
235
+ const logDir = args['log-dir'] || LOG_DIR_DEFAULT;
236
+ const withShield = !!args['with-shield'];
237
+
238
+ if (PLATFORM !== 'darwin' && PLATFORM !== 'linux') {
239
+ die(`unsupported platform "${PLATFORM}". Supported: macOS (launchd), Linux (systemd).\n` +
240
+ ' Run the daemon manually or wrap it in your own process manager:\n' +
241
+ ` wma-fetch --agent-id ${agentId} --watch --upload --interval ${interval}`);
242
+ }
243
+
244
+ mkdirSync(logDir, { recursive: true, mode: 0o700 });
245
+ writeEnvFile();
246
+
247
+ const watchArgs = ['--agent-id', agentId, '--watch', '--upload', '--interval', interval, '--log-dir', logDir];
248
+ const shieldArgs = ['--agent-id', agentId, '--policies-source', 'fortress', '--log-dir', logDir];
249
+
250
+ if (PLATFORM === 'darwin') {
251
+ macInstallOne(WATCH_LABEL, FETCH_SCRIPT, watchArgs);
252
+ if (withShield) macInstallOne(SHIELD_LABEL, SHIELD_SCRIPT, shieldArgs);
253
+ } else {
254
+ linuxInstallOne(WATCH_LABEL, 'WatchMyAgents Watch daemon', FETCH_SCRIPT, watchArgs);
255
+ if (withShield) linuxInstallOne(SHIELD_LABEL, 'WatchMyAgents Shield enforcement', SHIELD_SCRIPT, shieldArgs);
256
+ }
257
+
258
+ info('done — the WGS loop now runs always-on, no terminal needed.');
259
+ info(`logs: ${CONFIG_DIR}/*.log | captured events: ${logDir}`);
260
+ info(`status: wma-service status uninstall: wma-service uninstall${withShield ? ' --with-shield' : ''}`);
261
+ }
262
+
263
+ function cmdUninstall(args) {
264
+ const withShield = !!args['with-shield'];
265
+ if (PLATFORM === 'darwin') {
266
+ macUnload(WATCH_LABEL);
267
+ if (withShield) macUnload(SHIELD_LABEL);
268
+ } else if (PLATFORM === 'linux') {
269
+ linuxUninstallOne(WATCH_LABEL);
270
+ if (withShield) linuxUninstallOne(SHIELD_LABEL);
271
+ } else {
272
+ die(`unsupported platform "${PLATFORM}"`);
273
+ }
274
+ info('uninstalled. (Secrets in ' + ENV_FILE + ' left intact — delete manually if you want them gone.)');
275
+ }
276
+
277
+ function cmdStatus() {
278
+ if (PLATFORM === 'darwin') {
279
+ try {
280
+ const out = execFileSync('launchctl', ['list'], { encoding: 'utf8' });
281
+ const lines = out.split('\n').filter((l) => l.includes('watchmyagents'));
282
+ process.stdout.write(lines.length ? lines.join('\n') + '\n' : 'no WatchMyAgents services loaded\n');
283
+ } catch { warn('could not query launchctl'); }
284
+ } else if (PLATFORM === 'linux') {
285
+ for (const label of [WATCH_LABEL, SHIELD_LABEL]) {
286
+ try {
287
+ const out = execFileSync('systemctl', ['--user', 'is-active', unitName(label)], { encoding: 'utf8' }).trim();
288
+ process.stdout.write(`${unitName(label)}: ${out}\n`);
289
+ } catch (e) {
290
+ process.stdout.write(`${unitName(label)}: ${(e.stdout || 'inactive').toString().trim()}\n`);
291
+ }
292
+ }
293
+ } else {
294
+ die(`unsupported platform "${PLATFORM}"`);
295
+ }
296
+ }
297
+
298
+ function usage() {
299
+ process.stdout.write(`wma-service — run WatchMyAgents as an always-on OS service
300
+
301
+ Usage:
302
+ wma-service install --agent-id agent_xxx [--interval 5m] [--log-dir DIR] [--with-shield]
303
+ wma-service status
304
+ wma-service uninstall [--with-shield]
305
+
306
+ Required env at install (snapshotted to ~/.watchmyagents/env, chmod 600):
307
+ ANTHROPIC_API_KEY, WMA_API_KEY, WMA_FORTRESS_BASE_URL, WMA_SIGNALS_SALT
308
+
309
+ macOS → launchd LaunchAgent · Linux → systemd user unit.
310
+ The service starts at login and restarts on crash. Raw logs stay local.
311
+ `);
312
+ }
313
+
314
+ function main() {
315
+ const args = parseArgs(process.argv.slice(2));
316
+ const cmd = args._[0];
317
+ switch (cmd) {
318
+ case 'install': return cmdInstall(args);
319
+ case 'uninstall': return cmdUninstall(args);
320
+ case 'status': return cmdStatus();
321
+ default: usage(); process.exit(cmd ? 1 : 0);
322
+ }
323
+ }
324
+
325
+ main();
package/scripts/shield.js CHANGED
@@ -36,6 +36,7 @@ import { DecisionLogger } from '../src/shield/decisions.js';
36
36
  import { listSessions } from '../src/sources/anthropic-managed.js';
37
37
  import { FortressPolicySource, postDecision } from '../src/shield/sources/fortress.js';
38
38
  import { resolveFortressBase } from '../src/fortress/url.js';
39
+ import { isValidAgentId } from '../src/validate.js';
39
40
 
40
41
  function parseArgs(argv) {
41
42
  const out = {};
@@ -425,6 +426,9 @@ async function main() {
425
426
 
426
427
  if (!apiKey) die('error: --api-key or ANTHROPIC_API_KEY required');
427
428
  if (!agentId) die('error: --agent-id required');
429
+ if (!isValidAgentId(agentId)) {
430
+ die(`error: --agent-id has invalid format (expected "agent_" + alphanumeric, got "${agentId}")`);
431
+ }
428
432
 
429
433
  // Policies source: --policies-source fortress | local (default infers from --policy)
430
434
  let ruleset; // for 'local' mode: static; for 'fortress': initial snapshot
@@ -119,7 +119,7 @@ async function main() {
119
119
  const fortressUrl = fortressBase ? fortressEndpoint(fortressBase, 'ingest-signals') : null;
120
120
 
121
121
  // Validation
122
- if (!agentId) die('error: --agent-id required (Anthropic agent_id, e.g. agent_01XaN...)');
122
+ if (!agentId) die('error: --agent-id required (Anthropic agent_id, e.g. agent_01ABC...)');
123
123
  // Strict alphanumeric to prevent path traversal in collectFiles below
124
124
  // (--agent-id ends up as a filesystem path segment).
125
125
  if (!/^agent_[a-zA-Z0-9]+$/.test(agentId)) {
package/src/logger.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { mkdir, appendFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
+ import { assertSafePathSegment } from './validate.js';
4
5
 
5
6
  const EXPORT_FIELDS = [
6
7
  'id', 'agent_id', 'framework', 'timestamp', 'action_type',
@@ -18,6 +19,9 @@ export class Logger {
18
19
  // full / EACCES / EINVAL must propagate so callers know.
19
20
  // Opt into bestEffort=true only for non-critical paths.
20
21
  constructor({ logDir, agentId, sessionId, silent, bestEffort } = {}) {
22
+ // agentId becomes a filesystem path segment (logDir/<agentId>/…). Reject
23
+ // anything that could traverse out of logDir before we ever build a path.
24
+ assertSafePathSegment(agentId, 'agentId');
21
25
  this.logDir = logDir;
22
26
  this.agentId = agentId;
23
27
  this.sessionId = sessionId || randomUUID();
@@ -9,6 +9,9 @@
9
9
  const API_BASE = 'https://api.anthropic.com';
10
10
  const BETA = 'managed-agents-2026-04-01';
11
11
  const VERSION = '2023-06-01';
12
+ // Enforcement must be snappy: a hung confirm/interrupt would leave the agent
13
+ // paused (tool_confirmation) or running (interrupt) indefinitely. Fail fast.
14
+ const ENFORCE_TIMEOUT_MS = 15_000;
12
15
 
13
16
  function authHeaders(apiKey) {
14
17
  return {
@@ -19,11 +22,26 @@ function authHeaders(apiKey) {
19
22
  };
20
23
  }
21
24
 
25
+ // fetch() has no built-in timeout — without one a stalled connection hangs the
26
+ // enforcement path forever. Abort after ENFORCE_TIMEOUT_MS with a clear error.
27
+ async function fetchWithTimeout(url, opts = {}, timeoutMs = ENFORCE_TIMEOUT_MS) {
28
+ const ac = new AbortController();
29
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
30
+ try {
31
+ return await fetch(url, { ...opts, signal: ac.signal });
32
+ } catch (e) {
33
+ if (ac.signal.aborted) throw new Error(`request to ${url} timed out after ${timeoutMs}ms`);
34
+ throw e;
35
+ } finally {
36
+ clearTimeout(timer);
37
+ }
38
+ }
39
+
22
40
  // GET /v1/agents/{id} — used at Shield startup to determine which enforcement
23
41
  // mode (tool_confirmation vs interrupt) is available.
24
42
  export async function getAgentConfig(apiKey, agentId) {
25
43
  const url = `${API_BASE}/v1/agents/${agentId}`;
26
- const res = await fetch(url, { headers: authHeaders(apiKey) });
44
+ const res = await fetchWithTimeout(url, { headers: authHeaders(apiKey) });
27
45
  if (!res.ok) {
28
46
  const body = await res.text().catch(() => '');
29
47
  throw new Error(`getAgent failed: HTTP ${res.status}: ${body.slice(0, 300)}`);
@@ -58,7 +76,7 @@ export function detectAlwaysAsk(agent) {
58
76
 
59
77
  async function sendEvents(apiKey, sessionId, events) {
60
78
  const url = `${API_BASE}/v1/sessions/${sessionId}/events?beta=true`;
61
- const res = await fetch(url, {
79
+ const res = await fetchWithTimeout(url, {
62
80
  method: 'POST',
63
81
  headers: authHeaders(apiKey),
64
82
  body: JSON.stringify({ events }),
@@ -58,7 +58,7 @@ const SUSPICIOUS_REGEX_PATTERNS = [
58
58
  /(\.\*){3,}/, // multiple .* in a row
59
59
  ];
60
60
 
61
- function validateRegexString(src, where) {
61
+ export function validateRegexString(src, where) {
62
62
  if (typeof src !== 'string') {
63
63
  throw new Error(`policy ${where}: regex must be a string`);
64
64
  }
@@ -73,7 +73,7 @@ function validateRegexString(src, where) {
73
73
  return new RegExp(src);
74
74
  }
75
75
 
76
- function compileMatchRegexes(match) {
76
+ export function compileMatchRegexes(match) {
77
77
  for (const [field, condition] of Object.entries(match)) {
78
78
  if (condition && typeof condition === 'object') {
79
79
  if (condition.regex) condition._regex = validateRegexString(condition.regex, `${field}.regex`);
@@ -111,7 +111,9 @@ export async function postDecision({ apiKey, base, decision }) {
111
111
  // Periodically refreshes the policy ruleset from Fortress.
112
112
  // ────────────────────────────────────────────────────────────────────────
113
113
 
114
- import { matchesPolicy } from '../policy.js';
114
+ import { matchesPolicy, compileMatchRegexes } from '../policy.js';
115
+
116
+ const VALID_ACTIONS = new Set(['allow', 'deny', 'interrupt']);
115
117
 
116
118
  export class FortressPolicySource {
117
119
  constructor({ apiKey, base, anthropicAgentId, refreshIntervalMs = 5 * 60_000, onError, onRefresh }) {
@@ -154,8 +156,18 @@ export class FortressPolicySource {
154
156
  base: this.base,
155
157
  anthropicAgentId: this.anthropicAgentId,
156
158
  });
157
- // Compile regex etc. reuse the same shape policy.js expects.
158
- const compiled = policies.map((p) => compilePolicyFromFortress(p));
159
+ // Compile + validate each policy. A single malformed/dangerous policy
160
+ // (bad action, ReDoS-prone regex) must NOT take down the whole ruleset:
161
+ // skip it, report it, keep the rest. This matters because policies come
162
+ // from the cloud (Guardian-generated) — they're not fully trusted input.
163
+ const compiled = [];
164
+ for (const p of policies) {
165
+ try {
166
+ compiled.push(compilePolicyFromFortress(p));
167
+ } catch (e) {
168
+ this.onError(new Error(`skipping invalid Fortress policy "${p?.rule_id || p?.name || '?'}": ${e.message}`));
169
+ }
170
+ }
159
171
  this.ruleset = {
160
172
  version: 1,
161
173
  policies: compiled,
@@ -172,8 +184,20 @@ export class FortressPolicySource {
172
184
  }
173
185
  }
174
186
 
175
- // Convert a Fortress DB policy row to the local Shield format (compile regex).
187
+ // Convert a Fortress DB policy row to the local Shield format.
188
+ // Throws on anything invalid so _refresh can skip it (policies from the cloud
189
+ // are NOT fully trusted — apply the same hardening as the local JSON loader).
176
190
  function compilePolicyFromFortress(p) {
191
+ if (!p || typeof p !== 'object') throw new Error('policy is not an object');
192
+ if (!VALID_ACTIONS.has(p.action)) {
193
+ throw new Error(`unsupported action "${p.action}" (expected allow|deny|interrupt)`);
194
+ }
195
+ if (p.match != null && typeof p.match !== 'object') {
196
+ throw new Error('match must be an object');
197
+ }
198
+ if (p.priority != null && (typeof p.priority !== 'number' || !Number.isFinite(p.priority))) {
199
+ throw new Error(`priority must be a finite number (got ${p.priority})`);
200
+ }
177
201
  const out = {
178
202
  id: p.rule_id,
179
203
  name: p.name,
@@ -183,18 +207,11 @@ function compilePolicyFromFortress(p) {
183
207
  message: p.message,
184
208
  priority: p.priority ?? 100,
185
209
  };
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
- }
210
+ // Reuse the SAME ReDoS-safe compiler as the local JSON loader (rejects
211
+ // catastrophic-backtracking patterns + over-long regexes). Previously this
212
+ // path used a bare new RegExp(), bypassing those guards — a dangerous remote
213
+ // regex could pin Shield's CPU.
214
+ compileMatchRegexes(out.match);
198
215
  return out;
199
216
  }
200
217
 
@@ -21,6 +21,9 @@ import { URLSearchParams } from 'node:url';
21
21
  const API_HOST = 'api.anthropic.com';
22
22
  const BETA = 'managed-agents-2026-04-01';
23
23
  const VERSION = '2023-06-01';
24
+ // Hard cap on any single GET so a hung connection can't pin Watch/Shield
25
+ // forever. getWithRetry will retry on timeout (the error propagates here).
26
+ const REQUEST_TIMEOUT_MS = 30_000;
24
27
 
25
28
  function httpGet(apiKey, path) {
26
29
  return new Promise((resolve, reject) => {
@@ -50,6 +53,9 @@ function httpGet(apiKey, path) {
50
53
  });
51
54
  });
52
55
  req.on('error', reject);
56
+ req.setTimeout(REQUEST_TIMEOUT_MS, () => {
57
+ req.destroy(new Error(`Anthropic request timed out after ${REQUEST_TIMEOUT_MS}ms (${path})`));
58
+ });
53
59
  req.end();
54
60
  });
55
61
  }
@@ -165,6 +171,7 @@ export async function* fetchSessionEntries({ apiKey, agentId, sessionId, model }
165
171
  const cw = u.cache_creation_input_tokens || 0;
166
172
  yield {
167
173
  ...base,
174
+ id: ev.id,
168
175
  action_type: 'llm_call',
169
176
  tool_name: null,
170
177
  model: model || null,
@@ -183,6 +190,7 @@ export async function* fetchSessionEntries({ apiKey, agentId, sessionId, model }
183
190
  if (type === 'user.message') {
184
191
  yield {
185
192
  ...base,
193
+ id: ev.id,
186
194
  action_type: 'user_message',
187
195
  tool_name: null,
188
196
  model: model || null,
@@ -196,6 +204,7 @@ export async function* fetchSessionEntries({ apiKey, agentId, sessionId, model }
196
204
  if (type === 'user.interrupt') {
197
205
  yield {
198
206
  ...base,
207
+ id: ev.id,
199
208
  action_type: 'user_interrupt',
200
209
  tool_name: null,
201
210
  model: model || null,
@@ -210,6 +219,7 @@ export async function* fetchSessionEntries({ apiKey, agentId, sessionId, model }
210
219
  const denied = ev.result === 'deny';
211
220
  yield {
212
221
  ...base,
222
+ id: ev.id,
213
223
  action_type: 'tool_confirmation',
214
224
  tool_name: null,
215
225
  model: model || null,
@@ -225,6 +235,7 @@ export async function* fetchSessionEntries({ apiKey, agentId, sessionId, model }
225
235
  if (type === 'user.custom_tool_result') {
226
236
  yield {
227
237
  ...base,
238
+ id: ev.id,
228
239
  action_type: 'custom_tool_result',
229
240
  tool_name: null,
230
241
  model: model || null,
@@ -239,6 +250,7 @@ export async function* fetchSessionEntries({ apiKey, agentId, sessionId, model }
239
250
  if (type === 'agent.message') {
240
251
  yield {
241
252
  ...base,
253
+ id: ev.id,
242
254
  action_type: 'message',
243
255
  tool_name: null,
244
256
  model: model || null,
@@ -252,6 +264,7 @@ export async function* fetchSessionEntries({ apiKey, agentId, sessionId, model }
252
264
  if (type === 'agent.thinking') {
253
265
  yield {
254
266
  ...base,
267
+ id: ev.id,
255
268
  action_type: 'thinking',
256
269
  tool_name: null,
257
270
  model: model || null,
@@ -278,6 +291,7 @@ export async function* fetchSessionEntries({ apiKey, agentId, sessionId, model }
278
291
  const isError = ev.is_error === true;
279
292
  yield {
280
293
  ...base,
294
+ id: ev.id,
281
295
  action_type: start?.isMcp ? 'mcp_tool_use' : 'tool_use',
282
296
  tool_name: start?.name || 'unknown',
283
297
  timestamp: ts,
@@ -293,6 +307,7 @@ export async function* fetchSessionEntries({ apiKey, agentId, sessionId, model }
293
307
  if (type === 'agent.custom_tool_use') {
294
308
  yield {
295
309
  ...base,
310
+ id: ev.id,
296
311
  action_type: 'custom_tool_use',
297
312
  tool_name: ev.name || 'unknown',
298
313
  timestamp: ts,
@@ -306,6 +321,7 @@ export async function* fetchSessionEntries({ apiKey, agentId, sessionId, model }
306
321
  if (type === 'agent.thread_context_compacted') {
307
322
  yield {
308
323
  ...base,
324
+ id: ev.id,
309
325
  action_type: 'context_compacted',
310
326
  tool_name: null,
311
327
  model: model || null,
@@ -324,6 +340,7 @@ export async function* fetchSessionEntries({ apiKey, agentId, sessionId, model }
324
340
  const direction = type.endsWith('_sent') ? 'sent' : 'received';
325
341
  yield {
326
342
  ...base,
343
+ id: ev.id,
327
344
  action_type: `thread_message_${direction}`,
328
345
  tool_name: null,
329
346
  model: model || null,
@@ -344,6 +361,7 @@ export async function* fetchSessionEntries({ apiKey, agentId, sessionId, model }
344
361
  const { id: _id, type: _type, processed_at: _pa, created_at: _ca, ...changes } = ev;
345
362
  yield {
346
363
  ...base,
364
+ id: ev.id,
347
365
  action_type: 'config_change',
348
366
  tool_name: null,
349
367
  model: model || null,
@@ -357,6 +375,7 @@ export async function* fetchSessionEntries({ apiKey, agentId, sessionId, model }
357
375
  if (type === 'session.thread_created') {
358
376
  yield {
359
377
  ...base,
378
+ id: ev.id,
360
379
  action_type: 'thread_created',
361
380
  tool_name: null,
362
381
  model: model || null,
@@ -373,6 +392,7 @@ export async function* fetchSessionEntries({ apiKey, agentId, sessionId, model }
373
392
  if (type === 'session.error') {
374
393
  yield {
375
394
  ...base,
395
+ id: ev.id,
376
396
  action_type: 'session_error',
377
397
  tool_name: null,
378
398
  timestamp: ts,
@@ -393,6 +413,7 @@ export async function* fetchSessionEntries({ apiKey, agentId, sessionId, model }
393
413
  const fatal = state === 'terminated';
394
414
  yield {
395
415
  ...base,
416
+ id: ev.id,
396
417
  action_type: 'state_transition',
397
418
  tool_name: null,
398
419
  model: model || null,
@@ -0,0 +1,33 @@
1
+ // Shared identifier + path-segment validation.
2
+ //
3
+ // agentId and sessionId end up as filesystem path segments (logDir/<agentId>/…
4
+ // and raw-<sessionId>.jsonl). Without validation a crafted value like
5
+ // "../../etc" would traverse out of the log directory. Every entry point that
6
+ // turns an id into a path MUST validate it first.
7
+
8
+ const AGENT_ID_RE = /^agent_[a-zA-Z0-9]+$/;
9
+ const SESSION_ID_RE = /^sesn_[a-zA-Z0-9]+$/;
10
+
11
+ export function isValidAgentId(id) {
12
+ return typeof id === 'string' && AGENT_ID_RE.test(id);
13
+ }
14
+
15
+ export function isValidSessionId(id) {
16
+ return typeof id === 'string' && SESSION_ID_RE.test(id);
17
+ }
18
+
19
+ // Defense-in-depth: reject any value that could escape its parent directory
20
+ // before it is passed to path.join(). Throws on anything suspicious.
21
+ export function assertSafePathSegment(seg, label = 'path segment') {
22
+ if (typeof seg !== 'string' || seg.length === 0) {
23
+ throw new Error(`${label} must be a non-empty string`);
24
+ }
25
+ if (
26
+ seg === '.' || seg === '..' ||
27
+ seg.includes('/') || seg.includes('\\') ||
28
+ seg.includes('..') || seg.includes('\0')
29
+ ) {
30
+ throw new Error(`${label} "${seg.slice(0, 40)}" contains illegal path characters`);
31
+ }
32
+ return seg;
33
+ }