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 +71 -13
- package/package.json +7 -4
- package/scripts/anonymize.js +7 -17
- package/scripts/fetch-anthropic.js +242 -61
- package/scripts/service.js +325 -0
- package/scripts/shield.js +131 -13
- package/scripts/upload-fortress.js +13 -2
- package/src/fortress/url.js +59 -0
- package/src/logger.js +4 -0
- package/src/shield/enforce.js +20 -2
- package/src/shield/policy.js +2 -2
- package/src/shield/sources/fortress.js +220 -0
- package/src/sources/anthropic-managed.js +21 -0
- package/src/validate.js +33 -0
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
|
|
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
|
|
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
|
|
170
|
+
## Automating — continuous monitoring
|
|
167
171
|
|
|
168
|
-
|
|
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
|
|
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
|
|
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`
|
|
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
|
-
|
|
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
|
-
-
|
|
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.
|
|
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,
|
|
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"
|
package/scripts/anonymize.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
96
|
+
agg.add(e);
|
|
107
97
|
}
|
|
108
98
|
}
|
|
109
99
|
|
|
110
|
-
const signals =
|
|
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
|
-
//
|
|
2
|
+
// wma-fetch — pull session events from Anthropic Managed Agents and write them
|
|
3
|
+
// as WatchMyAgents NDJSON, ready for `wma-inspect`.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
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
|
-
//
|
|
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
|
|
34
|
-
if (!s || s === true) return
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
action_type: 'session_end',
|
|
119
|
-
|
|
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
|
-
|
|
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); });
|