watchmyagents 0.6.0 → 0.8.2
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 +50 -8
- package/package.json +8 -7
- package/scripts/anonymize.js +7 -17
- package/scripts/fetch-anthropic.js +242 -61
- package/scripts/service.js +349 -0
- package/scripts/shield.js +9 -0
- package/scripts/upload-fortress.js +1 -1
- 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 +33 -16
- 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
|
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 (with policies pulled from Fortress cloud), anonymizer producing signals-only payloads,
|
|
3
|
+
"version": "0.8.2",
|
|
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,22 +20,22 @@
|
|
|
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"
|
|
33
36
|
},
|
|
34
37
|
"dependencies": {},
|
|
35
|
-
"devDependencies": {
|
|
36
|
-
"@anthropic-ai/sdk": "^0.42.0"
|
|
37
|
-
},
|
|
38
|
+
"devDependencies": {},
|
|
38
39
|
"keywords": [
|
|
39
40
|
"ai",
|
|
40
41
|
"agents",
|
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); });
|
|
@@ -0,0 +1,349 @@
|
|
|
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
|
+
// Load secrets with a read-loop, NOT '. file' / source. Sourcing would
|
|
98
|
+
// shell-evaluate each value, so a secret like FOO=https://x/$(cmd) would
|
|
99
|
+
// execute cmd at launch. A read-loop assigns the value literally — the
|
|
100
|
+
// content is never re-scanned for command substitution.
|
|
101
|
+
const body = `#!/bin/sh
|
|
102
|
+
# Generated by wma-service. Loads secrets WITHOUT shell-evaluating their values.
|
|
103
|
+
while IFS='=' read -r __k __v; do
|
|
104
|
+
[ -n "$__k" ] && export "$__k=$__v"
|
|
105
|
+
done < ${sh(ENV_FILE)}
|
|
106
|
+
exec ${sh(NODE)} ${sh(scriptPath)} ${argLine}
|
|
107
|
+
`;
|
|
108
|
+
const p = launcherPath(label);
|
|
109
|
+
writeFileSync(p, body, { mode: 0o700 });
|
|
110
|
+
chmodSync(p, 0o700);
|
|
111
|
+
return p;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function writePlist(label, launcher) {
|
|
115
|
+
const outLog = join(CONFIG_DIR, `${label}.out.log`);
|
|
116
|
+
const errLog = join(CONFIG_DIR, `${label}.err.log`);
|
|
117
|
+
// Pre-create the log files 0600 so launchd appends to owner-only files.
|
|
118
|
+
// (No secrets are logged, but defense-in-depth on world-readable home files.)
|
|
119
|
+
for (const lp of [outLog, errLog]) {
|
|
120
|
+
if (!existsSync(lp)) writeFileSync(lp, '', { mode: 0o600 });
|
|
121
|
+
else chmodSync(lp, 0o600);
|
|
122
|
+
}
|
|
123
|
+
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
|
124
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
125
|
+
<plist version="1.0">
|
|
126
|
+
<dict>
|
|
127
|
+
<key>Label</key><string>${label}</string>
|
|
128
|
+
<key>ProgramArguments</key>
|
|
129
|
+
<array><string>${launcher}</string></array>
|
|
130
|
+
<key>RunAtLoad</key><true/>
|
|
131
|
+
<key>KeepAlive</key><true/>
|
|
132
|
+
<key>ProcessType</key><string>Background</string>
|
|
133
|
+
<key>StandardOutPath</key><string>${outLog}</string>
|
|
134
|
+
<key>StandardErrorPath</key><string>${errLog}</string>
|
|
135
|
+
</dict>
|
|
136
|
+
</plist>
|
|
137
|
+
`;
|
|
138
|
+
mkdirSync(launchAgentsDir(), { recursive: true });
|
|
139
|
+
const p = plistPath(label);
|
|
140
|
+
writeFileSync(p, body, { mode: 0o644 });
|
|
141
|
+
return p;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function launchctl(args, { ignoreError = false } = {}) {
|
|
145
|
+
try {
|
|
146
|
+
execFileSync('launchctl', args, { stdio: 'pipe' });
|
|
147
|
+
return true;
|
|
148
|
+
} catch (e) {
|
|
149
|
+
if (!ignoreError) warn(`launchctl ${args.join(' ')} failed: ${(e.stderr || e.message).toString().trim().slice(0, 200)}`);
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Synchronous sleep (installer CLI — blocking is fine). Used to let launchd's
|
|
155
|
+
// asynchronous bootout finish before we bootstrap again.
|
|
156
|
+
function syncSleep(ms) {
|
|
157
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
158
|
+
}
|
|
159
|
+
function macLoaded(label) {
|
|
160
|
+
try { execFileSync('launchctl', ['print', `gui/${UID}/${label}`], { stdio: 'pipe' }); return true; }
|
|
161
|
+
catch { return false; }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function macLoad(label, plist) {
|
|
165
|
+
const domain = `gui/${UID}`;
|
|
166
|
+
// bootout is async: on reinstall, bootstrapping again before the old instance
|
|
167
|
+
// is gone races and silently fails (symptom: reinstall = dead services).
|
|
168
|
+
// Wait for the prior instance to disappear, then retry bootstrap.
|
|
169
|
+
if (macLoaded(label)) {
|
|
170
|
+
launchctl(['bootout', `${domain}/${label}`], { ignoreError: true });
|
|
171
|
+
for (let i = 0; i < 20 && macLoaded(label); i++) syncSleep(150);
|
|
172
|
+
}
|
|
173
|
+
let ok = false;
|
|
174
|
+
for (let attempt = 0; attempt < 5 && !ok; attempt++) {
|
|
175
|
+
ok = launchctl(['bootstrap', domain, plist], { ignoreError: attempt < 4 });
|
|
176
|
+
if (!ok) syncSleep(250);
|
|
177
|
+
}
|
|
178
|
+
launchctl(['enable', `${domain}/${label}`], { ignoreError: true });
|
|
179
|
+
if (ok) info(`loaded ${label} (launchd) — running now + at every login`);
|
|
180
|
+
else {
|
|
181
|
+
warn(`could not auto-load ${label}. Load it manually:`);
|
|
182
|
+
process.stdout.write(` launchctl bootout gui/${UID}/${label} 2>/dev/null; launchctl bootstrap gui/${UID} ${plist}\n`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function macUnload(label) {
|
|
187
|
+
const domain = `gui/${UID}`;
|
|
188
|
+
launchctl(['bootout', `${domain}/${label}`], { ignoreError: true });
|
|
189
|
+
for (const p of [plistPath(label), launcherPath(label)]) if (existsSync(p)) rmSync(p);
|
|
190
|
+
info(`removed ${label} (launchd)`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function macInstallOne(label, scriptPath, args) {
|
|
194
|
+
const launcher = writeLauncher(label, scriptPath, args);
|
|
195
|
+
const plist = writePlist(label, launcher);
|
|
196
|
+
macLoad(label, plist);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── Linux (systemd user) ───────────────────────────────────────────────────
|
|
200
|
+
function systemdDir() { return join(HOME, '.config', 'systemd', 'user'); }
|
|
201
|
+
function unitName(label) { return `${label.replace(/\./g, '-')}.service`; }
|
|
202
|
+
function unitPath(label) { return join(systemdDir(), unitName(label)); }
|
|
203
|
+
|
|
204
|
+
function writeUnit(label, desc, scriptPath, args) {
|
|
205
|
+
// Quote every token for systemd. systemd splits ExecStart on whitespace and
|
|
206
|
+
// does NOT run a shell; double-quotes group tokens and honor \" and \\.
|
|
207
|
+
const sdQuote = (s) => `"${String(s).replace(/(["\\])/g, '\\$1')}"`;
|
|
208
|
+
const exec = [NODE, scriptPath, ...args].map(sdQuote).join(' ');
|
|
209
|
+
const body = `[Unit]
|
|
210
|
+
Description=${desc}
|
|
211
|
+
After=network-online.target
|
|
212
|
+
Wants=network-online.target
|
|
213
|
+
|
|
214
|
+
[Service]
|
|
215
|
+
Type=simple
|
|
216
|
+
EnvironmentFile=${ENV_FILE}
|
|
217
|
+
ExecStart=${exec}
|
|
218
|
+
Restart=always
|
|
219
|
+
RestartSec=10
|
|
220
|
+
|
|
221
|
+
[Install]
|
|
222
|
+
WantedBy=default.target
|
|
223
|
+
`;
|
|
224
|
+
mkdirSync(systemdDir(), { recursive: true });
|
|
225
|
+
const p = unitPath(label);
|
|
226
|
+
writeFileSync(p, body, { mode: 0o644 });
|
|
227
|
+
return p;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function systemctl(args, { ignoreError = false } = {}) {
|
|
231
|
+
try { execFileSync('systemctl', ['--user', ...args], { stdio: 'pipe' }); return true; }
|
|
232
|
+
catch (e) { if (!ignoreError) warn(`systemctl --user ${args.join(' ')} failed: ${(e.stderr || e.message).toString().trim().slice(0, 200)}`); return false; }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function linuxInstallOne(label, desc, scriptPath, args) {
|
|
236
|
+
writeUnit(label, desc, scriptPath, args);
|
|
237
|
+
const unit = unitName(label);
|
|
238
|
+
systemctl(['daemon-reload'], { ignoreError: true });
|
|
239
|
+
const ok = systemctl(['enable', '--now', unit]);
|
|
240
|
+
if (ok) info(`enabled ${unit} (systemd) — running now + at login. For boot-without-login: loginctl enable-linger ${process.env.USER || ''}`);
|
|
241
|
+
else { warn(`could not auto-enable ${unit}. Enable manually:`); process.stdout.write(` systemctl --user enable --now ${unit}\n`); }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function linuxUninstallOne(label) {
|
|
245
|
+
const unit = unitName(label);
|
|
246
|
+
systemctl(['disable', '--now', unit], { ignoreError: true });
|
|
247
|
+
if (existsSync(unitPath(label))) rmSync(unitPath(label));
|
|
248
|
+
systemctl(['daemon-reload'], { ignoreError: true });
|
|
249
|
+
info(`removed ${unit} (systemd)`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── Commands ────────────────────────────────────────────────────────────--
|
|
253
|
+
function cmdInstall(args) {
|
|
254
|
+
const agentId = args['agent-id'];
|
|
255
|
+
if (!agentId) die('--agent-id required (e.g. agent_01ABC...)');
|
|
256
|
+
if (!isValidAgentId(agentId)) die(`--agent-id invalid format (expected "agent_" + alphanumeric, got "${agentId}")`);
|
|
257
|
+
const interval = args.interval || '5m';
|
|
258
|
+
if (!/^\d+[smhd]$/.test(interval)) die(`--interval invalid format (expected like 30s, 5m, 1h, 2d; got "${interval}")`);
|
|
259
|
+
const logDir = args['log-dir'] || LOG_DIR_DEFAULT;
|
|
260
|
+
const withShield = !!args['with-shield'];
|
|
261
|
+
|
|
262
|
+
if (PLATFORM !== 'darwin' && PLATFORM !== 'linux') {
|
|
263
|
+
die(`unsupported platform "${PLATFORM}". Supported: macOS (launchd), Linux (systemd).\n` +
|
|
264
|
+
' Run the daemon manually or wrap it in your own process manager:\n' +
|
|
265
|
+
` wma-fetch --agent-id ${agentId} --watch --upload --interval ${interval}`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
mkdirSync(logDir, { recursive: true, mode: 0o700 });
|
|
269
|
+
writeEnvFile();
|
|
270
|
+
|
|
271
|
+
const watchArgs = ['--agent-id', agentId, '--watch', '--upload', '--interval', interval, '--log-dir', logDir];
|
|
272
|
+
const shieldArgs = ['--agent-id', agentId, '--policies-source', 'fortress', '--log-dir', logDir];
|
|
273
|
+
|
|
274
|
+
if (PLATFORM === 'darwin') {
|
|
275
|
+
macInstallOne(WATCH_LABEL, FETCH_SCRIPT, watchArgs);
|
|
276
|
+
if (withShield) macInstallOne(SHIELD_LABEL, SHIELD_SCRIPT, shieldArgs);
|
|
277
|
+
} else {
|
|
278
|
+
linuxInstallOne(WATCH_LABEL, 'WatchMyAgents Watch daemon', FETCH_SCRIPT, watchArgs);
|
|
279
|
+
if (withShield) linuxInstallOne(SHIELD_LABEL, 'WatchMyAgents Shield enforcement', SHIELD_SCRIPT, shieldArgs);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
info('done — the WGS loop now runs always-on, no terminal needed.');
|
|
283
|
+
info(`logs: ${CONFIG_DIR}/*.log | captured events: ${logDir}`);
|
|
284
|
+
info(`status: wma-service status uninstall: wma-service uninstall${withShield ? ' --with-shield' : ''}`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function cmdUninstall(args) {
|
|
288
|
+
const withShield = !!args['with-shield'];
|
|
289
|
+
if (PLATFORM === 'darwin') {
|
|
290
|
+
macUnload(WATCH_LABEL);
|
|
291
|
+
if (withShield) macUnload(SHIELD_LABEL);
|
|
292
|
+
} else if (PLATFORM === 'linux') {
|
|
293
|
+
linuxUninstallOne(WATCH_LABEL);
|
|
294
|
+
if (withShield) linuxUninstallOne(SHIELD_LABEL);
|
|
295
|
+
} else {
|
|
296
|
+
die(`unsupported platform "${PLATFORM}"`);
|
|
297
|
+
}
|
|
298
|
+
info('uninstalled. (Secrets in ' + ENV_FILE + ' left intact — delete manually if you want them gone.)');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function cmdStatus() {
|
|
302
|
+
if (PLATFORM === 'darwin') {
|
|
303
|
+
try {
|
|
304
|
+
const out = execFileSync('launchctl', ['list'], { encoding: 'utf8' });
|
|
305
|
+
const lines = out.split('\n').filter((l) => l.includes('watchmyagents'));
|
|
306
|
+
process.stdout.write(lines.length ? lines.join('\n') + '\n' : 'no WatchMyAgents services loaded\n');
|
|
307
|
+
} catch { warn('could not query launchctl'); }
|
|
308
|
+
} else if (PLATFORM === 'linux') {
|
|
309
|
+
for (const label of [WATCH_LABEL, SHIELD_LABEL]) {
|
|
310
|
+
try {
|
|
311
|
+
const out = execFileSync('systemctl', ['--user', 'is-active', unitName(label)], { encoding: 'utf8' }).trim();
|
|
312
|
+
process.stdout.write(`${unitName(label)}: ${out}\n`);
|
|
313
|
+
} catch (e) {
|
|
314
|
+
process.stdout.write(`${unitName(label)}: ${(e.stdout || 'inactive').toString().trim()}\n`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
} else {
|
|
318
|
+
die(`unsupported platform "${PLATFORM}"`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function usage() {
|
|
323
|
+
process.stdout.write(`wma-service — run WatchMyAgents as an always-on OS service
|
|
324
|
+
|
|
325
|
+
Usage:
|
|
326
|
+
wma-service install --agent-id agent_xxx [--interval 5m] [--log-dir DIR] [--with-shield]
|
|
327
|
+
wma-service status
|
|
328
|
+
wma-service uninstall [--with-shield]
|
|
329
|
+
|
|
330
|
+
Required env at install (snapshotted to ~/.watchmyagents/env, chmod 600):
|
|
331
|
+
ANTHROPIC_API_KEY, WMA_API_KEY, WMA_FORTRESS_BASE_URL, WMA_SIGNALS_SALT
|
|
332
|
+
|
|
333
|
+
macOS → launchd LaunchAgent · Linux → systemd user unit.
|
|
334
|
+
The service starts at login and restarts on crash. Raw logs stay local.
|
|
335
|
+
`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function main() {
|
|
339
|
+
const args = parseArgs(process.argv.slice(2));
|
|
340
|
+
const cmd = args._[0];
|
|
341
|
+
switch (cmd) {
|
|
342
|
+
case 'install': return cmdInstall(args);
|
|
343
|
+
case 'uninstall': return cmdUninstall(args);
|
|
344
|
+
case 'status': return cmdStatus();
|
|
345
|
+
default: usage(); process.exit(cmd ? 1 : 0);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
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, isValidSessionId } from '../src/validate.js';
|
|
39
40
|
|
|
40
41
|
function parseArgs(argv) {
|
|
41
42
|
const out = {};
|
|
@@ -425,6 +426,14 @@ 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
|
+
}
|
|
432
|
+
// --session-id ends up in the Anthropic SSE URL path (src/shield/stream.js).
|
|
433
|
+
// Validate the same way wma-fetch does so a crafted value can't tamper the URL.
|
|
434
|
+
if (singleSessionId && !isValidSessionId(singleSessionId)) {
|
|
435
|
+
die(`error: --session-id has invalid format (expected "sesn_" + alphanumeric, got "${singleSessionId}")`);
|
|
436
|
+
}
|
|
428
437
|
|
|
429
438
|
// Policies source: --policies-source fortress | local (default infers from --policy)
|
|
430
439
|
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.
|
|
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();
|
package/src/shield/enforce.js
CHANGED
|
@@ -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
|
|
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
|
|
79
|
+
const res = await fetchWithTimeout(url, {
|
|
62
80
|
method: 'POST',
|
|
63
81
|
headers: authHeaders(apiKey),
|
|
64
82
|
body: JSON.stringify({ events }),
|
package/src/shield/policy.js
CHANGED
|
@@ -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
|
|
158
|
-
|
|
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
|
|
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
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
|
|
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,
|
package/src/validate.js
ADDED
|
@@ -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
|
+
}
|