watchmyagents 0.5.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
@@ -205,15 +247,30 @@ Report vulnerabilities via [SECURITY.md](./SECURITY.md).
205
247
 
206
248
  ## Shield — real-time policy enforcement
207
249
 
208
- `wma-shield` (shipped in v0.2.0) is the real-time enforcement companion to Watch. It streams agent events live, evaluates them against a local JSON policy file, and blocks tool calls that violate the policy via `user.tool_confirmation` (when the agent has `permission_policy: always_ask` configured) or `user.interrupt` (zero-setup fallback).
250
+ `wma-shield` is the real-time enforcement companion to Watch. It streams agent events live, evaluates them against a policy ruleset, and blocks tool calls that violate the policy via `user.tool_confirmation` (when the agent has `permission_policy: always_ask` configured) or `user.interrupt` (zero-setup fallback).
251
+
252
+ ### Two policy sources (v0.6.0+)
209
253
 
254
+ **Local JSON** (standalone — no cloud dependency):
210
255
  ```bash
211
- # Agent-wide mode — attaches to ALL active sessions of the agent automatically.
212
- # Run under a process supervisor (systemd, pm2, docker) for production.
213
256
  wma-shield --agent-id agent_xxx --policy ./policies.json
214
257
  ```
215
258
 
216
- Shield auto-detects the best enforcement mode at startup:
259
+ **Fortress cloud** (policies managed in the dashboard, auto-refreshed every 5 min):
260
+ ```bash
261
+ export ANTHROPIC_API_KEY="sk-ant-..."
262
+ export WMA_API_KEY="wma_..."
263
+ export WMA_FORTRESS_BASE_URL="https://<project>.supabase.co/functions/v1"
264
+ export WMA_SIGNALS_SALT="..." # same salt as wma-upload-fortress (for cross-table IoC correlation)
265
+
266
+ wma-shield --agent-id agent_xxx --policies-source fortress
267
+ ```
268
+
269
+ 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.
270
+
271
+ ### Enforcement mode auto-detection
272
+
273
+ Shield auto-detects the best mode at startup:
217
274
  - **tool_confirmation** (precise, pre-execution blocking) when at least one tool has `permission_policy: always_ask`
218
275
  - **interrupt** (degraded, post-execution termination) otherwise
219
276
 
@@ -232,7 +289,8 @@ Decisions are logged to the same NDJSON stream as Watch (`action_type: shield_de
232
289
  - ✅ Anonymized telemetry to WMA Fortress cloud (`wma-upload-fortress` in v0.5.0)
233
290
  - ✅ Guardian AI (cloud) — automatic policy suggestions from observed behavior
234
291
  - ✅ Fortress (cloud) — dashboard + human-in-the-loop validation queue
235
- - 🚧 Shield policy puller from Fortress (replace local JSON with cloud-synced policies)
292
+ - Shield policy puller from Fortress (`wma-shield --policies-source fortress` in v0.6.0)
293
+ - ✅ Shield decisions push to Fortress (live timeline + Loop Visualizer)
236
294
  - 🚧 Encrypted upload to customer's own cloud (S3/GCS/Azure with `age` public-key encryption)
237
295
 
238
296
  ## License
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "watchmyagents",
3
- "version": "0.5.0",
4
- "description": "Security observability + real-time policy enforcement for AI agents. Local-first NDJSON capture, Shield CLI that blocks policy violations live, anonymizer producing signals-only payloads, and an upload command that ships anonymized telemetry to WatchMyAgents Fortress (the cloud control plane).",
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); });