watchmyagents 0.8.0 → 0.9.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 +19 -3
- package/package.json +8 -6
- package/scripts/agents.js +218 -0
- package/scripts/fetch-anthropic.js +82 -55
- package/scripts/service.js +38 -12
- package/scripts/shield.js +97 -95
- package/src/sources/anthropic-managed.js +18 -0
- package/src/typology-weights.json +88 -0
- package/src/typology.js +398 -0
package/README.md
CHANGED
|
@@ -105,14 +105,15 @@ Each entry carries: `id`, `agent_id`, `framework`, `timestamp`, `action_type`, `
|
|
|
105
105
|
### `wma-fetch` — pull events from Anthropic Managed Agents
|
|
106
106
|
|
|
107
107
|
```bash
|
|
108
|
-
wma-fetch --agent-id <agent_id> [--session-id <sess_id>] [--since 1h]
|
|
108
|
+
wma-fetch (--agent-id <agent_id> | --all-agents) [--session-id <sess_id>] [--since 1h]
|
|
109
109
|
[--log-dir ./watchmyagents-logs] [--dump-raw]
|
|
110
110
|
[--watch [--interval 5m] [--upload]]
|
|
111
111
|
```
|
|
112
112
|
|
|
113
113
|
| Flag | Effect |
|
|
114
114
|
|---|---|
|
|
115
|
-
| `--agent-id agent_xxx` |
|
|
115
|
+
| `--agent-id agent_xxx` | Anthropic agent identifier (required unless `--all-agents`) |
|
|
116
|
+
| `--all-agents` | **Fleet mode** (requires `--watch`) — discover ALL agents under the key and watch them in a single process |
|
|
116
117
|
| `--since 1h` / `24h` / `7d` | Fetch window (default: all) |
|
|
117
118
|
| `--session-id sesn_xxx` | Limit to a single session |
|
|
118
119
|
| `--log-dir ./logs` | Where to write NDJSON (default `./watchmyagents-logs`) |
|
|
@@ -167,6 +168,21 @@ wma-inspect [path]
|
|
|
167
168
|
|
|
168
169
|
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.
|
|
169
170
|
|
|
171
|
+
### `wma-agents` — discover + classify your agents (typology)
|
|
172
|
+
|
|
173
|
+
Lists every Managed Agent under your key and classifies each one's **typology**
|
|
174
|
+
(one of 10 Guardian Core archetypes) from its OBSERVED behaviour in your local
|
|
175
|
+
logs — which drives the cold-start Shield template. Modèle C: reads local logs
|
|
176
|
+
only (tool-category fractions, never raw content) and transmits nothing.
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
wma-agents list [--log-dir ~/.watchmyagents/logs] [--json]
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
With fewer than ~50 observed events an agent stays `generic` (cold start) and
|
|
183
|
+
refines as activity accumulates. Re-classification to a *less strict* type is
|
|
184
|
+
gated (raised confidence + longer window) to resist mimicry-evasion.
|
|
185
|
+
|
|
170
186
|
## Automating — continuous monitoring
|
|
171
187
|
|
|
172
188
|
### `wma-service` — install as an always-on service (recommended)
|
|
@@ -180,7 +196,7 @@ export WMA_API_KEY="wma_..."
|
|
|
180
196
|
export WMA_FORTRESS_BASE_URL="https://<project>.supabase.co/functions/v1"
|
|
181
197
|
export WMA_SIGNALS_SALT="..." # stable per-customer salt
|
|
182
198
|
|
|
183
|
-
wma-service install --agent-id agent_01ABC... --interval 5m [--with-shield]
|
|
199
|
+
wma-service install (--agent-id agent_01ABC... | --all-agents) [--interval 5m] [--with-shield]
|
|
184
200
|
wma-service status
|
|
185
201
|
wma-service uninstall [--with-shield]
|
|
186
202
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "watchmyagents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Security observability + real-time policy enforcement for AI agents. Local-first NDJSON capture with a continuous Watch daemon that auto-uploads anonymized signals, Shield CLI that blocks policy violations live (with policies pulled from Fortress cloud), anonymizer producing signals-only payloads, bidirectional sync with WatchMyAgents Fortress, and one-command install as an always-on launchd/systemd service — closing the recursive Watch→Guardian→Shield security loop.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"scripts/anonymize.js",
|
|
12
12
|
"scripts/upload-fortress.js",
|
|
13
13
|
"scripts/service.js",
|
|
14
|
+
"scripts/agents.js",
|
|
14
15
|
"README.md",
|
|
15
16
|
"SECURITY.md",
|
|
16
17
|
"LICENSE"
|
|
@@ -21,23 +22,24 @@
|
|
|
21
22
|
"wma-shield": "scripts/shield.js",
|
|
22
23
|
"wma-anonymize": "scripts/anonymize.js",
|
|
23
24
|
"wma-upload-fortress": "scripts/upload-fortress.js",
|
|
24
|
-
"wma-service": "scripts/service.js"
|
|
25
|
+
"wma-service": "scripts/service.js",
|
|
26
|
+
"wma-agents": "scripts/agents.js"
|
|
25
27
|
},
|
|
26
28
|
"scripts": {
|
|
29
|
+
"test": "node --test",
|
|
27
30
|
"inspect": "node scripts/inspect.js",
|
|
28
31
|
"fetch": "node scripts/fetch-anthropic.js",
|
|
29
32
|
"shield": "node scripts/shield.js",
|
|
30
33
|
"anonymize": "node scripts/anonymize.js",
|
|
31
34
|
"upload-fortress": "node scripts/upload-fortress.js",
|
|
32
|
-
"service": "node scripts/service.js"
|
|
35
|
+
"service": "node scripts/service.js",
|
|
36
|
+
"agents": "node scripts/agents.js"
|
|
33
37
|
},
|
|
34
38
|
"engines": {
|
|
35
39
|
"node": ">=18.0.0"
|
|
36
40
|
},
|
|
37
41
|
"dependencies": {},
|
|
38
|
-
"devDependencies": {
|
|
39
|
-
"@anthropic-ai/sdk": "^0.42.0"
|
|
40
|
-
},
|
|
42
|
+
"devDependencies": {},
|
|
41
43
|
"keywords": [
|
|
42
44
|
"ai",
|
|
43
45
|
"agents",
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// wma-agents — discover all Managed Agents under your key and classify each
|
|
3
|
+
// agent's typology from its OBSERVED behaviour (for Shield template selection).
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// wma-agents [list] [--log-dir ~/.watchmyagents/logs] [--json]
|
|
7
|
+
//
|
|
8
|
+
// Reads the local Watch logs (NEVER leaves the machine — Modèle C) and derives
|
|
9
|
+
// the anonymized behavioural FEATURE VECTOR per the typology spec:
|
|
10
|
+
// per-tool-category FRACTIONS (f_*), boolean local flags (flag_*), aux ratios
|
|
11
|
+
// (aux_*), and n_events. It then calls classifyAgentType() and prints the
|
|
12
|
+
// schema-conformant result. With <50 events an agent is `generic` (cold start)
|
|
13
|
+
// and refines as activity accumulates.
|
|
14
|
+
//
|
|
15
|
+
// Modèle C invariant: only counts/ratios/flags are computed here — never raw
|
|
16
|
+
// prompt/output content, never the agent display name. Nothing is transmitted.
|
|
17
|
+
//
|
|
18
|
+
// ANTHROPIC_API_KEY from env (or --api-key, discouraged).
|
|
19
|
+
|
|
20
|
+
import os from 'node:os';
|
|
21
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
22
|
+
import { createReadStream } from 'node:fs';
|
|
23
|
+
import { createInterface } from 'node:readline';
|
|
24
|
+
import { join, resolve } from 'node:path';
|
|
25
|
+
import { listAgents } from '../src/sources/anthropic-managed.js';
|
|
26
|
+
import { classifyAgentType } from '../src/typology.js';
|
|
27
|
+
import { isValidAgentId, assertSafePathSegment } from '../src/validate.js';
|
|
28
|
+
|
|
29
|
+
function parseArgs(argv) {
|
|
30
|
+
const out = { _: [] };
|
|
31
|
+
for (let i = 0; i < argv.length; i++) {
|
|
32
|
+
const a = argv[i];
|
|
33
|
+
if (a.startsWith('--')) {
|
|
34
|
+
const k = a.slice(2); const n = argv[i + 1];
|
|
35
|
+
if (n == null || n.startsWith('--')) out[k] = true; else { out[k] = n; i++; }
|
|
36
|
+
} else out._.push(a);
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
function die(msg, code = 1) { process.stderr.write(`error: ${msg}\n`); process.exit(code); }
|
|
41
|
+
function info(msg) { process.stdout.write(`[wma-agents] ${msg}\n`); }
|
|
42
|
+
|
|
43
|
+
// Action types that represent a TOOL invocation (the denominator for f_* tool
|
|
44
|
+
// fractions). Confirmed produced by src/sources/anthropic-managed.js.
|
|
45
|
+
const TOOL_ACTIONS = new Set(['tool_use', 'mcp_tool_use', 'custom_tool_use']);
|
|
46
|
+
|
|
47
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
48
|
+
// Tool-name → category mapping (Modèle C: name-based, no content). Managed
|
|
49
|
+
// Agents expose tools as an opaque bundle, so tool_name is free-text. We match
|
|
50
|
+
// the confirmed built-ins (web_search, web_fetch, bash) plus best-effort
|
|
51
|
+
// regexes for common tool names. A tool that matches nothing contributes to the
|
|
52
|
+
// denominator but to no category (honest: unknown ≠ inferred).
|
|
53
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
54
|
+
const CATEGORY_RULES = [
|
|
55
|
+
// category, matcher (lower-cased tool_name)
|
|
56
|
+
['search', (n) => /(^|_)web_search$|(^|_)search($|_)|google|brave/.test(n)],
|
|
57
|
+
['browser', (n) => /web_fetch|browser|playwright|puppeteer|navigate|screenshot/.test(n)],
|
|
58
|
+
['http', (n) => /(^|_)http|fetch_url|curl|request|webhook|api_call/.test(n)],
|
|
59
|
+
['code', (n) => /bash|shell|terminal|code_exec|exec_|python|node_run|run_code|interpreter/.test(n)],
|
|
60
|
+
['database', (n) => /sql|query_db|database|postgres|mysql|mongo|redis|bigquery|snowflake/.test(n)],
|
|
61
|
+
['email', (n) => /email|gmail|smtp|sendmail|mailgun|outlook/.test(n)],
|
|
62
|
+
['payment', (n) => /payment|charge|transfer|invoice|stripe|paypal|payout|refund|checkout/.test(n)],
|
|
63
|
+
['secret', (n) => /secret|vault|credential|kms|keychain|token_get/.test(n)],
|
|
64
|
+
['memory', (n) => /memory|retriev|vector|(^|_)rag($|_)|knowledge|embed|pinecone|chroma/.test(n)],
|
|
65
|
+
['file', (n) => /editor|str_replace|read_file|write_file|create_file|file_io|(^|_)file($|_)|fs_/.test(n)],
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
// Best-effort deploy detection (spec discriminator devops_infra vs coding).
|
|
69
|
+
const DEPLOY_RE = /deploy|terraform|kubectl|helm|(^|_)release($|_)|ansible|pulumi|cloudformation/;
|
|
70
|
+
|
|
71
|
+
function categoryOf(toolName) {
|
|
72
|
+
const n = String(toolName || '').toLowerCase();
|
|
73
|
+
for (const [cat, m] of CATEGORY_RULES) if (m(n)) return cat;
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Aggregate raw counts from an agent's local NDJSON logs (Modèle C: counts only).
|
|
78
|
+
async function aggregate(logDir, agentId) {
|
|
79
|
+
const actionCounts = {}; // action_type → count
|
|
80
|
+
const categoryCounts = {}; // tool category → count
|
|
81
|
+
let toolEvents = 0; // denominator for f_* fractions
|
|
82
|
+
let deployUses = 0;
|
|
83
|
+
const dir = join(logDir, agentId);
|
|
84
|
+
const s = await stat(dir).catch(() => null);
|
|
85
|
+
if (!s || !s.isDirectory()) return { actionCounts, categoryCounts, toolEvents, deployUses, hasLogs: false };
|
|
86
|
+
let names;
|
|
87
|
+
try { names = await readdir(dir); } catch { return { actionCounts, categoryCounts, toolEvents, deployUses, hasLogs: false }; }
|
|
88
|
+
const files = names.filter((n) => n.endsWith('.ndjson') && !n.startsWith('raw-'));
|
|
89
|
+
if (files.length === 0) return { actionCounts, categoryCounts, toolEvents, deployUses, hasLogs: false };
|
|
90
|
+
|
|
91
|
+
for (const f of files) {
|
|
92
|
+
await new Promise((res) => {
|
|
93
|
+
const rl = createInterface({ input: createReadStream(join(dir, f), { encoding: 'utf8' }), crlfDelay: Infinity });
|
|
94
|
+
rl.on('line', (line) => {
|
|
95
|
+
if (!line.trim()) return;
|
|
96
|
+
let e; try { e = JSON.parse(line); } catch { return; }
|
|
97
|
+
if (e.action_type) actionCounts[e.action_type] = (actionCounts[e.action_type] || 0) + 1;
|
|
98
|
+
if (TOOL_ACTIONS.has(e.action_type)) {
|
|
99
|
+
toolEvents += 1;
|
|
100
|
+
const cat = categoryOf(e.tool_name);
|
|
101
|
+
if (cat) categoryCounts[cat] = (categoryCounts[cat] || 0) + 1;
|
|
102
|
+
if (DEPLOY_RE.test(String(e.tool_name || '').toLowerCase())) deployUses += 1;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
rl.on('close', res); rl.on('error', res);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return { actionCounts, categoryCounts, toolEvents, deployUses, hasLogs: true };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Features that the WMA NDJSON logs CANNOT reliably expose today (opaque tool
|
|
112
|
+
// names / no behavioural signal / content off-limits under Modèle C). They
|
|
113
|
+
// default to 0/false; the caller prints a one-line note.
|
|
114
|
+
const NON_DERIVABLE = [
|
|
115
|
+
'f_database', 'f_email', 'f_payment', 'f_secret', 'f_memory',
|
|
116
|
+
'flag_internal_sys', 'flag_on_behalf', 'aux_untrusted', 'aux_sensitive',
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
// Build the canonical anonymized FEATURE VECTOR from the aggregated counts.
|
|
120
|
+
// Fractions = category_count / toolEvents. n_events = total observed events.
|
|
121
|
+
function buildFeatures(agg) {
|
|
122
|
+
const { actionCounts, categoryCounts, toolEvents, deployUses } = agg;
|
|
123
|
+
const nEvents = Object.values(actionCounts).reduce((a, b) => a + b, 0);
|
|
124
|
+
const frac = (c) => (toolEvents > 0 ? (categoryCounts[c] || 0) / toolEvents : 0);
|
|
125
|
+
const eventFrac = (...types) => (nEvents > 0
|
|
126
|
+
? types.reduce((a, t) => a + (actionCounts[t] || 0), 0) / nEvents
|
|
127
|
+
: 0);
|
|
128
|
+
|
|
129
|
+
// f_handoff / f_user_msg are derived from event TYPE (not tool category):
|
|
130
|
+
// confirmed action_types thread_message_* and user_message.
|
|
131
|
+
const handoff = eventFrac('thread_message_sent', 'thread_message_received', 'thread_created');
|
|
132
|
+
const userMsg = eventFrac('user_message');
|
|
133
|
+
|
|
134
|
+
// aux_autonomy ≈ 1 − (human-in-the-loop event share). Confirmed action_types
|
|
135
|
+
// user_message / user_interrupt / tool_confirmation mark human involvement; an
|
|
136
|
+
// agent that proceeds without them is more autonomous. Heuristic — documented.
|
|
137
|
+
const hitlShare = eventFrac('user_message', 'user_interrupt', 'tool_confirmation');
|
|
138
|
+
const auxAutonomy = nEvents > 0 ? Math.max(0, 1 - hitlShare) : 0;
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
// tool-category fractions (over tool uses)
|
|
142
|
+
f_code: frac('code'),
|
|
143
|
+
f_browser: frac('browser'),
|
|
144
|
+
f_database: frac('database'), // non-derivable in practice → ~0
|
|
145
|
+
f_http: frac('http'),
|
|
146
|
+
f_email: frac('email'), // non-derivable in practice → ~0
|
|
147
|
+
f_payment: frac('payment'), // non-derivable in practice → ~0
|
|
148
|
+
f_secret: frac('secret'), // non-derivable in practice → ~0
|
|
149
|
+
f_search: frac('search'),
|
|
150
|
+
f_memory: frac('memory'), // non-derivable in practice → ~0
|
|
151
|
+
f_file: frac('file'),
|
|
152
|
+
// event-type fractions (over all events)
|
|
153
|
+
f_handoff: handoff,
|
|
154
|
+
f_user_msg: userMsg,
|
|
155
|
+
// discriminator flags (best-effort; only flag_deploy has any behavioural
|
|
156
|
+
// signal — and only if the agent literally names a deploy tool).
|
|
157
|
+
flag_deploy: deployUses > 0 ? 1 : 0,
|
|
158
|
+
flag_internal_sys: 0, // no behavioural signal in logs
|
|
159
|
+
flag_on_behalf: 0, // no behavioural signal in logs
|
|
160
|
+
// aux ratios
|
|
161
|
+
aux_autonomy: auxAutonomy, // heuristic (HITL-frequency)
|
|
162
|
+
aux_untrusted: 0, // no honest source in logs
|
|
163
|
+
aux_sensitive: 0, // no honest source in logs
|
|
164
|
+
// window size
|
|
165
|
+
n_events: nEvents,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function main() {
|
|
170
|
+
const args = parseArgs(process.argv.slice(2));
|
|
171
|
+
if (args._[0] && args._[0] !== 'list') die(`unknown command "${args._[0]}" (only "list" supported)`);
|
|
172
|
+
const apiKey = args['api-key'] || process.env.ANTHROPIC_API_KEY;
|
|
173
|
+
if (!apiKey) die('--api-key or ANTHROPIC_API_KEY required');
|
|
174
|
+
if (args['api-key']) process.stderr.write('[wma-agents] WARNING: --api-key is visible in shell history; prefer ANTHROPIC_API_KEY env\n');
|
|
175
|
+
const logDir = resolve(args['log-dir'] || join(os.homedir(), '.watchmyagents', 'logs'));
|
|
176
|
+
const asJson = !!args.json;
|
|
177
|
+
|
|
178
|
+
let agents;
|
|
179
|
+
try { agents = await listAgents(apiKey); }
|
|
180
|
+
catch (e) { die(`failed to list agents: ${e.message}`); }
|
|
181
|
+
|
|
182
|
+
const results = [];
|
|
183
|
+
for (const a of agents) {
|
|
184
|
+
if (!a.id || !isValidAgentId(a.id)) continue;
|
|
185
|
+
assertSafePathSegment(a.id, 'agent id');
|
|
186
|
+
const agg = await aggregate(logDir, a.id);
|
|
187
|
+
const features = buildFeatures(agg);
|
|
188
|
+
features.agent_id = a.id;
|
|
189
|
+
// No prior state threaded here (single-shot CLI snapshot); the continuous
|
|
190
|
+
// Watch daemon is responsible for threading window state across runs.
|
|
191
|
+
const cls = classifyAgentType(features);
|
|
192
|
+
results.push({
|
|
193
|
+
id: a.id,
|
|
194
|
+
name: a.name || '(unnamed)', // shown for the human only — NOT a classification signal
|
|
195
|
+
hasLogs: agg.hasLogs,
|
|
196
|
+
...cls,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (asJson) { process.stdout.write(JSON.stringify(results, null, 2) + '\n'); return; }
|
|
201
|
+
|
|
202
|
+
info(`discovered ${results.length} agent(s) - classified from local logs in ${logDir}`);
|
|
203
|
+
info(`Modele C: features below default to 0 (logs don't expose them): ${NON_DERIVABLE.join(', ')}`);
|
|
204
|
+
process.stdout.write('\n');
|
|
205
|
+
for (const r of results) {
|
|
206
|
+
const mods = (r.modifiers && r.modifiers.length) ? ` [+${r.modifiers.join(',')}]` : '';
|
|
207
|
+
const overlay = r.evidence?.payment_overlay ? ' (+transactional overlay)' : '';
|
|
208
|
+
process.stdout.write(` ${r.name}\n`);
|
|
209
|
+
process.stdout.write(` ${r.id}\n`);
|
|
210
|
+
process.stdout.write(` -> ${r.classified_type} (conf ${Math.round(r.confidence * 100)}%, ${r.stage})${mods}${overlay}\n`);
|
|
211
|
+
process.stdout.write(` evidence: ${r.evidence.window_events} events, top2=${r.evidence.top2_type}, margin=${r.evidence.margin}\n`);
|
|
212
|
+
if (!r.hasLogs) process.stdout.write(' (no local logs yet - cold start)\n');
|
|
213
|
+
process.stdout.write('\n');
|
|
214
|
+
}
|
|
215
|
+
info('type drives the cold-start Shield template (Guardian Core §8). The global-baseline floor applies regardless of classification.');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
main().catch((e) => { process.stderr.write(`error: ${e.stack || e.message}\n`); process.exit(1); });
|
|
@@ -31,7 +31,7 @@ import { SignalsAggregator } from '../src/anonymizer.js';
|
|
|
31
31
|
import { resolveFortressBase, fortressEndpoint } from '../src/fortress/url.js';
|
|
32
32
|
import { isValidAgentId, isValidSessionId, assertSafePathSegment } from '../src/validate.js';
|
|
33
33
|
import {
|
|
34
|
-
getAgent, listSessions, fetchSessionEntries, fetchRawEvents,
|
|
34
|
+
getAgent, listAgents, listSessions, fetchSessionEntries, fetchRawEvents,
|
|
35
35
|
} from '../src/sources/anthropic-managed.js';
|
|
36
36
|
|
|
37
37
|
function parseArgs(argv) {
|
|
@@ -70,6 +70,9 @@ function parseSince(s) {
|
|
|
70
70
|
function die(msg, code = 1) { process.stderr.write(`${msg}\n`); process.exit(code); }
|
|
71
71
|
function info(msg) { process.stdout.write(`[wma-fetch] ${msg}\n`); }
|
|
72
72
|
function warn(msg) { process.stderr.write(`[wma-fetch] ⚠️ ${msg}\n`); }
|
|
73
|
+
// Strip control chars + truncate a customer-set agent name before it goes into
|
|
74
|
+
// a log line or the Fortress display_name (defense-in-depth vs log/payload injection).
|
|
75
|
+
function cleanLabel(s) { return [...String(s ?? '')].filter((c) => c.charCodeAt(0) >= 32 && c.charCodeAt(0) !== 127).join('').slice(0, 60).trim(); }
|
|
73
76
|
|
|
74
77
|
function resolveModel(agent) {
|
|
75
78
|
const raw = agent.model || agent.config?.model || null;
|
|
@@ -198,10 +201,14 @@ async function fetchOneShot({ apiKey, agentId, model, logDir, since, sessionId,
|
|
|
198
201
|
process.stdout.write(`[wma-fetch] inspect with: npx wma-inspect ${logDir}\n`);
|
|
199
202
|
}
|
|
200
203
|
|
|
201
|
-
// ── CONTINUOUS / DAEMON
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const
|
|
204
|
+
// ── CONTINUOUS / DAEMON (single agent or whole fleet) ───────────────────────
|
|
205
|
+
// `agents` = [{ agentId, model, displayName }]. One process watches them all.
|
|
206
|
+
async function runWatch({ apiKey, agents, logDir, intervalMs, uploadCtx }) {
|
|
207
|
+
const seenIds = new Set(); // stable Anthropic event ids already captured
|
|
208
|
+
for (const ag of agents) {
|
|
209
|
+
for (const id of await preloadSeenIds(logDir, ag.agentId)) seenIds.add(id);
|
|
210
|
+
}
|
|
211
|
+
const loggers = new Map(); // sessionId → Logger (session ids are globally unique)
|
|
205
212
|
const ended = new Set(); // sessions we've already closed with session_end
|
|
206
213
|
|
|
207
214
|
const ac = new AbortController();
|
|
@@ -209,56 +216,62 @@ async function runWatch({ apiKey, agentId, model, displayName, logDir, intervalM
|
|
|
209
216
|
process.on('SIGINT', shutdown);
|
|
210
217
|
process.on('SIGTERM', shutdown);
|
|
211
218
|
|
|
212
|
-
|
|
219
|
+
const fleet = agents.length > 1;
|
|
220
|
+
info(`watch mode — ${agents.length} agent(s), interval ${Math.round(intervalMs / 1000)}s, upload ${uploadCtx ? 'ON' : 'OFF'}, ${seenIds.size} known events preloaded`);
|
|
213
221
|
|
|
214
222
|
while (!ac.signal.aborted) {
|
|
215
223
|
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
224
|
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
225
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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; }
|
|
226
|
+
for (const ag of agents) {
|
|
227
|
+
if (ac.signal.aborted) break;
|
|
228
|
+
const tag = fleet ? `[${ag.displayName}] ` : '';
|
|
229
|
+
let sessions = [];
|
|
230
|
+
try { sessions = await listSessions(apiKey, { agentId: ag.agentId, since }); }
|
|
231
|
+
catch (e) { warn(`${tag}listSessions failed: ${e.message}`); continue; }
|
|
239
232
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
233
|
+
for (const s of sessions) {
|
|
234
|
+
if (!s.id || ended.has(s.id)) continue;
|
|
235
|
+
let logger = loggers.get(s.id);
|
|
236
|
+
if (!logger) { logger = new Logger({ logDir, agentId: ag.agentId, sessionId: s.id, silent: true }); loggers.set(s.id, logger); }
|
|
243
237
|
|
|
244
|
-
|
|
238
|
+
const fresh = [];
|
|
239
|
+
let sawTerminated = false;
|
|
245
240
|
try {
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
241
|
+
for await (const entry of fetchSessionEntries({ apiKey, agentId: ag.agentId, sessionId: s.id, model: ag.model })) {
|
|
242
|
+
if (entry.id && seenIds.has(entry.id)) continue;
|
|
243
|
+
if (entry.id) seenIds.add(entry.id);
|
|
244
|
+
const written = await logger.write(entry);
|
|
245
|
+
fresh.push(written);
|
|
246
|
+
if (entry.action_type === 'state_transition'
|
|
247
|
+
&& entry.output?.scope === 'session'
|
|
248
|
+
&& entry.output?.state === 'terminated') sawTerminated = true;
|
|
249
|
+
}
|
|
250
|
+
} catch (e) { warn(`${tag}session ${s.id.slice(0, 16)}…: fetch failed: ${e.message}`); continue; }
|
|
251
|
+
|
|
252
|
+
if (fresh.length === 0) continue;
|
|
253
|
+
cycleNew += fresh.length;
|
|
254
|
+
info(`${tag}session ${s.id.slice(0, 16)}…: +${fresh.length} new event(s)`);
|
|
250
255
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
256
|
+
if (uploadCtx) {
|
|
257
|
+
try {
|
|
258
|
+
const resp = await uploadSignals(uploadCtx, ag.agentId, ag.displayName, fresh);
|
|
259
|
+
if (resp?.signal_id) info(` ↑ signals uploaded (signal_id ${resp.signal_id})`);
|
|
260
|
+
} catch (e) { warn(` signals upload failed: ${e.message}`); }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (sawTerminated) {
|
|
264
|
+
const tracker = new TokenTracker();
|
|
265
|
+
for (const e of fresh) tracker.record(e);
|
|
266
|
+
const stats = tracker.stats().total;
|
|
267
|
+
await logger.write({
|
|
268
|
+
action_type: 'session_end', framework: 'anthropic-managed', status: 'ok', model: ag.model,
|
|
269
|
+
session_tokens: { input: stats.input, output: stats.output, cache_read: stats.cache_read, cache_creation: stats.cache_creation, total: stats.sum },
|
|
270
|
+
session_cost_usd: stats.cost_usd || null,
|
|
271
|
+
});
|
|
272
|
+
ended.add(s.id);
|
|
273
|
+
info(`${tag}session ${s.id.slice(0, 16)}… terminated — closed`);
|
|
274
|
+
}
|
|
262
275
|
}
|
|
263
276
|
}
|
|
264
277
|
|
|
@@ -275,10 +288,12 @@ async function main() {
|
|
|
275
288
|
const logDir = resolve(args['log-dir'] || './watchmyagents-logs');
|
|
276
289
|
const watch = !!args.watch;
|
|
277
290
|
const upload = !!args.upload;
|
|
291
|
+
const allAgents = !!args['all-agents'];
|
|
278
292
|
|
|
279
293
|
if (!apiKey) die('error: --api-key or ANTHROPIC_API_KEY required');
|
|
280
|
-
if (!agentId) die('error: --agent-id required (
|
|
281
|
-
if (!
|
|
294
|
+
if (!allAgents && !agentId) die('error: --agent-id required (or --all-agents for fleet mode)');
|
|
295
|
+
if (allAgents && !watch) die('error: --all-agents requires --watch (fleet daemon). For a one-shot, target a single --agent-id.');
|
|
296
|
+
if (agentId && !isValidAgentId(agentId)) {
|
|
282
297
|
die(`error: --agent-id has invalid format (expected "agent_" + alphanumeric, got "${agentId}")`);
|
|
283
298
|
}
|
|
284
299
|
const sessionIdArg = args['session-id'];
|
|
@@ -303,18 +318,30 @@ async function main() {
|
|
|
303
318
|
uploadCtx = { apiKey: wmaKey, salt, url: fortressEndpoint(base, 'ingest-signals') };
|
|
304
319
|
}
|
|
305
320
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
321
|
+
// Resolve the agent list: the whole fleet (--all-agents) or a single agent.
|
|
322
|
+
let agents;
|
|
323
|
+
if (allAgents) {
|
|
324
|
+
info('discovering agents (fleet mode)…');
|
|
325
|
+
const all = await listAgents(apiKey).catch((e) => die(`failed to list agents: ${e.message}`));
|
|
326
|
+
agents = all
|
|
327
|
+
.filter((a) => a.id && isValidAgentId(a.id))
|
|
328
|
+
.map((a) => ({ agentId: a.id, model: resolveModel(a), displayName: cleanLabel(a.name || a.id) }));
|
|
329
|
+
if (agents.length === 0) die('error: no agents found under this API key');
|
|
330
|
+
info(`fleet: ${agents.length} agent(s) — ${agents.map((a) => a.displayName).join(', ')}`);
|
|
331
|
+
} else {
|
|
332
|
+
info(`resolving agent ${agentId}…`);
|
|
333
|
+
const agent = await getAgent(apiKey, agentId).catch((e) => die(`failed to GET agent: ${e.message}`));
|
|
334
|
+
agents = [{ agentId, model: resolveModel(agent), displayName: cleanLabel(agent.name || agentId) }];
|
|
335
|
+
info(`model: ${agents[0].model || '(unknown)'}`);
|
|
336
|
+
}
|
|
311
337
|
|
|
312
338
|
if (watch) {
|
|
313
339
|
const intervalMs = parseDurationMs(args.interval, 5 * 60_000);
|
|
314
|
-
await runWatch({ apiKey,
|
|
340
|
+
await runWatch({ apiKey, agents, logDir, intervalMs, uploadCtx });
|
|
315
341
|
} else {
|
|
316
342
|
const since = args.since ? parseSince(args.since) : null;
|
|
317
|
-
|
|
343
|
+
const a = agents[0];
|
|
344
|
+
await fetchOneShot({ apiKey, agentId: a.agentId, model: a.model, logDir, since, sessionId: args['session-id'], dumpRaw: !!args['dump-raw'] });
|
|
318
345
|
}
|
|
319
346
|
}
|
|
320
347
|
|
package/scripts/service.js
CHANGED
|
@@ -94,11 +94,15 @@ function launcherPath(label) { return join(CONFIG_DIR, `${label}.launcher.sh`);
|
|
|
94
94
|
|
|
95
95
|
function writeLauncher(label, scriptPath, args) {
|
|
96
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.
|
|
97
101
|
const body = `#!/bin/sh
|
|
98
|
-
# Generated by wma-service. Loads secrets
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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)}
|
|
102
106
|
exec ${sh(NODE)} ${sh(scriptPath)} ${argLine}
|
|
103
107
|
`;
|
|
104
108
|
const p = launcherPath(label);
|
|
@@ -147,15 +151,35 @@ function launchctl(args, { ignoreError = false } = {}) {
|
|
|
147
151
|
}
|
|
148
152
|
}
|
|
149
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
|
+
|
|
150
164
|
function macLoad(label, plist) {
|
|
151
165
|
const domain = `gui/${UID}`;
|
|
152
|
-
|
|
153
|
-
|
|
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
|
+
}
|
|
154
178
|
launchctl(['enable', `${domain}/${label}`], { ignoreError: true });
|
|
155
179
|
if (ok) info(`loaded ${label} (launchd) — running now + at every login`);
|
|
156
180
|
else {
|
|
157
181
|
warn(`could not auto-load ${label}. Load it manually:`);
|
|
158
|
-
process.stdout.write(` launchctl bootstrap gui/${UID} ${plist}\n`);
|
|
182
|
+
process.stdout.write(` launchctl bootout gui/${UID}/${label} 2>/dev/null; launchctl bootstrap gui/${UID} ${plist}\n`);
|
|
159
183
|
}
|
|
160
184
|
}
|
|
161
185
|
|
|
@@ -227,9 +251,10 @@ function linuxUninstallOne(label) {
|
|
|
227
251
|
|
|
228
252
|
// ── Commands ────────────────────────────────────────────────────────────--
|
|
229
253
|
function cmdInstall(args) {
|
|
254
|
+
const allAgents = !!args['all-agents'];
|
|
230
255
|
const agentId = args['agent-id'];
|
|
231
|
-
if (!agentId) die('--agent-id required (
|
|
232
|
-
if (!isValidAgentId(agentId)) die(`--agent-id invalid format (expected "agent_" + alphanumeric, got "${agentId}")`);
|
|
256
|
+
if (!allAgents && !agentId) die('--agent-id required (or --all-agents to cover the whole fleet)');
|
|
257
|
+
if (agentId && !isValidAgentId(agentId)) die(`--agent-id invalid format (expected "agent_" + alphanumeric, got "${agentId}")`);
|
|
233
258
|
const interval = args.interval || '5m';
|
|
234
259
|
if (!/^\d+[smhd]$/.test(interval)) die(`--interval invalid format (expected like 30s, 5m, 1h, 2d; got "${interval}")`);
|
|
235
260
|
const logDir = args['log-dir'] || LOG_DIR_DEFAULT;
|
|
@@ -238,14 +263,15 @@ function cmdInstall(args) {
|
|
|
238
263
|
if (PLATFORM !== 'darwin' && PLATFORM !== 'linux') {
|
|
239
264
|
die(`unsupported platform "${PLATFORM}". Supported: macOS (launchd), Linux (systemd).\n` +
|
|
240
265
|
' Run the daemon manually or wrap it in your own process manager:\n' +
|
|
241
|
-
` wma-fetch --agent-id ${agentId} --watch --upload --interval ${interval}`);
|
|
266
|
+
` wma-fetch ${allAgents ? '--all-agents' : `--agent-id ${agentId}`} --watch --upload --interval ${interval}`);
|
|
242
267
|
}
|
|
243
268
|
|
|
244
269
|
mkdirSync(logDir, { recursive: true, mode: 0o700 });
|
|
245
270
|
writeEnvFile();
|
|
246
271
|
|
|
247
|
-
const
|
|
248
|
-
const
|
|
272
|
+
const target = allAgents ? ['--all-agents'] : ['--agent-id', agentId];
|
|
273
|
+
const watchArgs = [...target, '--watch', '--upload', '--interval', interval, '--log-dir', logDir];
|
|
274
|
+
const shieldArgs = [...target, '--policies-source', 'fortress', '--log-dir', logDir];
|
|
249
275
|
|
|
250
276
|
if (PLATFORM === 'darwin') {
|
|
251
277
|
macInstallOne(WATCH_LABEL, FETCH_SCRIPT, watchArgs);
|