watchmyagents 0.8.2 → 0.9.1
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 +24 -6
- package/package.json +7 -3
- package/scripts/agents.js +218 -0
- package/scripts/fetch-anthropic.js +97 -32
- package/scripts/service.js +7 -5
- package/scripts/shield.js +130 -97
- 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`) |
|
|
@@ -120,6 +121,8 @@ wma-fetch --agent-id <agent_id> [--session-id <sess_id>] [--since 1h]
|
|
|
120
121
|
| `--watch` | **Continuous daemon** — loop forever, incrementally capturing NEW events (deduped by stable event id) until `Ctrl+C` |
|
|
121
122
|
| `--interval 5m` | Poll interval in watch mode (default `5m`; accepts `30s`/`1h`/…) |
|
|
122
123
|
| `--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. |
|
|
124
|
+
| `--discovery-since 7d` | Window for discovering NEW sessions (default `7d`). Sessions already being tracked are re-fetched regardless of age, so long-running ones never drop out. |
|
|
125
|
+
| `--send-agent-names` | Opt-in: send the human agent name as the Fortress `display_name`. Default sends the agent id only (the name may contain client/project info). |
|
|
123
126
|
| `--api-key sk-ant-…` | Override the `ANTHROPIC_API_KEY` env var. **Discouraged** — visible in shell history & process list. Prefer the env var. |
|
|
124
127
|
|
|
125
128
|
Logs land in `./watchmyagents-logs/<agent_id>/<date>.ndjson` (file mode `0600`, dir `0700`).
|
|
@@ -152,7 +155,7 @@ wma-upload-fortress --agent-id agent_01ABC... [--display-name "My agent"]
|
|
|
152
155
|
wma-upload-fortress --agent-id agent_xxx --dry-run
|
|
153
156
|
```
|
|
154
157
|
|
|
155
|
-
**What is sent:** counts, latencies, salted IoC hashes, sequences — same as `wma-anonymize` output.
|
|
158
|
+
**What is sent:** the anonymized signals payload (counts, latencies, salted IoC hashes, sequences — same as `wma-anonymize` output) **plus two routing identifiers**: your `anthropic_agent_id` and a `display_name`. The agent id is required so Fortress can associate signals with the right agent; `display_name` defaults to the agent id and only carries the human-readable agent name if you opt in (`wma-fetch --watch --upload --send-agent-names`).
|
|
156
159
|
**What is NOT sent:** raw prompts, raw URLs/commands/queries, raw agent responses, raw error messages. All payload content stays on your machine.
|
|
157
160
|
|
|
158
161
|
The endpoint auto-registers the agent on the first upload if it doesn't exist in Fortress yet — no manual onboarding needed for new agents.
|
|
@@ -167,6 +170,21 @@ wma-inspect [path]
|
|
|
167
170
|
|
|
168
171
|
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
172
|
|
|
173
|
+
### `wma-agents` — discover + classify your agents (typology)
|
|
174
|
+
|
|
175
|
+
Lists every Managed Agent under your key and classifies each one's **typology**
|
|
176
|
+
(one of 10 Guardian Core archetypes) from its OBSERVED behaviour in your local
|
|
177
|
+
logs — which drives the cold-start Shield template. Modèle C: reads local logs
|
|
178
|
+
only (tool-category fractions, never raw content) and transmits nothing.
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
wma-agents list [--log-dir ~/.watchmyagents/logs] [--json]
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
With fewer than ~50 observed events an agent stays `generic` (cold start) and
|
|
185
|
+
refines as activity accumulates. Re-classification to a *less strict* type is
|
|
186
|
+
gated (raised confidence + longer window) to resist mimicry-evasion.
|
|
187
|
+
|
|
170
188
|
## Automating — continuous monitoring
|
|
171
189
|
|
|
172
190
|
### `wma-service` — install as an always-on service (recommended)
|
|
@@ -180,7 +198,7 @@ export WMA_API_KEY="wma_..."
|
|
|
180
198
|
export WMA_FORTRESS_BASE_URL="https://<project>.supabase.co/functions/v1"
|
|
181
199
|
export WMA_SIGNALS_SALT="..." # stable per-customer salt
|
|
182
200
|
|
|
183
|
-
wma-service install --agent-id agent_01ABC... --interval 5m [--with-shield]
|
|
201
|
+
wma-service install (--agent-id agent_01ABC... | --all-agents) [--interval 5m] [--with-shield]
|
|
184
202
|
wma-service status
|
|
185
203
|
wma-service uninstall [--with-shield]
|
|
186
204
|
```
|
|
@@ -229,9 +247,9 @@ WatchMyAgents is built so that **your prompts and outputs never have to leave yo
|
|
|
229
247
|
|---|---|
|
|
230
248
|
| **Your machine** (`./watchmyagents-logs/`) | Full NDJSON with all prompts, tool inputs, agent outputs. `chmod 600` on every file. |
|
|
231
249
|
| **Anthropic API** | Where the agent runs. WMA pulls events via the public REST API only. |
|
|
232
|
-
| **WMA
|
|
250
|
+
| **WMA Fortress** (opt-in, only with `--upload` / `wma-upload-fortress` / `wma-shield --policies-source fortress`) | The **anonymized signals** payload (counts, timings, salted hashes, sequences) + two routing identifiers: your `anthropic_agent_id` and a `display_name` (defaults to the agent id; the human agent name only with `--send-agent-names`). Shield enforcement **decisions** (hashed session/event/input fingerprints — never raw values). **Never** raw prompts, URLs, commands, or outputs. |
|
|
233
251
|
|
|
234
|
-
This is the "local-first" guarantee
|
|
252
|
+
This is the "local-first" guarantee: **raw payloads never leave your machine.** Cloud upload is opt-in and carries only anonymized metadata + the agent id/name needed to route it.
|
|
235
253
|
|
|
236
254
|
## Security
|
|
237
255
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "watchmyagents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
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,15 +22,18 @@
|
|
|
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"
|
|
@@ -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,35 +201,63 @@ 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
|
-
|
|
205
|
-
|
|
204
|
+
// ── CONTINUOUS / DAEMON (single agent or whole fleet) ───────────────────────
|
|
205
|
+
// resolveAgents() returns the current fleet [{agentId, model, displayName}] each
|
|
206
|
+
// cycle — in fleet mode it RE-DISCOVERS so agents created after startup get picked
|
|
207
|
+
// up. `windowMs` bounds discovery of NEW sessions, but sessions we're ALREADY
|
|
208
|
+
// tracking are re-fetched regardless of age, so a long-running (>window) session
|
|
209
|
+
// never drops out of capture. `sendNames`: include the human agent name in the
|
|
210
|
+
// Fortress display_name (opt-in); default sends the agent id only (Modèle C).
|
|
211
|
+
async function runWatch({ apiKey, resolveAgents, fleet, logDir, intervalMs, windowMs, uploadCtx, sendNames }) {
|
|
212
|
+
let agents = await resolveAgents();
|
|
213
|
+
const seenIds = new Set(); // stable Anthropic event ids already captured
|
|
214
|
+
for (const ag of agents) {
|
|
215
|
+
for (const id of await preloadSeenIds(logDir, ag.agentId)) seenIds.add(id);
|
|
216
|
+
}
|
|
217
|
+
const loggers = new Map(); // sessionId → Logger (session ids are globally unique)
|
|
218
|
+
const ended = new Set(); // terminated sessions (skip)
|
|
219
|
+
const sessionAgent = new Map();// sessionId → { agentId, model, displayName }
|
|
206
220
|
|
|
207
221
|
const ac = new AbortController();
|
|
208
222
|
const shutdown = () => { info('shutting down…'); ac.abort(); };
|
|
209
223
|
process.on('SIGINT', shutdown);
|
|
210
224
|
process.on('SIGTERM', shutdown);
|
|
211
225
|
|
|
212
|
-
info(`watch mode — interval ${Math.round(intervalMs / 1000)}s, upload ${uploadCtx ? 'ON' : 'OFF'}, ${seenIds.size} known events preloaded`);
|
|
226
|
+
info(`watch mode — ${agents.length} agent(s)${fleet ? ' (fleet, re-discovered each cycle)' : ''}, interval ${Math.round(intervalMs / 1000)}s, discovery window ${Math.round(windowMs / 3600000)}h, upload ${uploadCtx ? 'ON' : 'OFF'}, ${seenIds.size} known events preloaded`);
|
|
213
227
|
|
|
214
228
|
while (!ac.signal.aborted) {
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
229
|
+
if (fleet) { const next = await resolveAgents(); if (next.length) agents = next; }
|
|
230
|
+
const since = new Date(Date.now() - windowMs);
|
|
231
|
+
|
|
232
|
+
// (1) Discover sessions in the window; register the owning agent for each.
|
|
233
|
+
for (const ag of agents) {
|
|
234
|
+
if (ac.signal.aborted) break;
|
|
235
|
+
const tag = fleet ? `[${ag.displayName}] ` : '';
|
|
236
|
+
let sessions = [];
|
|
237
|
+
try { sessions = await listSessions(apiKey, { agentId: ag.agentId, since }); }
|
|
238
|
+
catch (e) { warn(`${tag}listSessions failed: ${e.message}`); continue; }
|
|
239
|
+
for (const s of sessions) {
|
|
240
|
+
if (s.id && !ended.has(s.id) && !sessionAgent.has(s.id)) {
|
|
241
|
+
sessionAgent.set(s.id, { agentId: ag.agentId, model: ag.model, displayName: ag.displayName });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
219
245
|
|
|
246
|
+
// (2) Capture every tracked, not-yet-ended session — REGARDLESS of age. This
|
|
247
|
+
// is what stops a long-running session created before the window from silently
|
|
248
|
+
// dropping out of monitoring (and, paired with Shield, out of enforcement).
|
|
220
249
|
let cycleNew = 0;
|
|
221
|
-
for (const
|
|
222
|
-
if (
|
|
223
|
-
|
|
224
|
-
|
|
250
|
+
for (const [sid, ag] of sessionAgent) {
|
|
251
|
+
if (ac.signal.aborted) break;
|
|
252
|
+
if (ended.has(sid)) continue;
|
|
253
|
+
const tag = fleet ? `[${ag.displayName}] ` : '';
|
|
254
|
+
let logger = loggers.get(sid);
|
|
255
|
+
if (!logger) { logger = new Logger({ logDir, agentId: ag.agentId, sessionId: sid, silent: true }); loggers.set(sid, logger); }
|
|
225
256
|
|
|
226
257
|
const fresh = [];
|
|
227
258
|
let sawTerminated = false;
|
|
228
259
|
try {
|
|
229
|
-
for await (const entry of fetchSessionEntries({ apiKey, agentId, sessionId:
|
|
260
|
+
for await (const entry of fetchSessionEntries({ apiKey, agentId: ag.agentId, sessionId: sid, model: ag.model })) {
|
|
230
261
|
if (entry.id && seenIds.has(entry.id)) continue;
|
|
231
262
|
if (entry.id) seenIds.add(entry.id);
|
|
232
263
|
const written = await logger.write(entry);
|
|
@@ -235,15 +266,15 @@ async function runWatch({ apiKey, agentId, model, displayName, logDir, intervalM
|
|
|
235
266
|
&& entry.output?.scope === 'session'
|
|
236
267
|
&& entry.output?.state === 'terminated') sawTerminated = true;
|
|
237
268
|
}
|
|
238
|
-
} catch (e) { warn(
|
|
269
|
+
} catch (e) { warn(`${tag}session ${sid.slice(0, 16)}…: fetch failed: ${e.message}`); continue; }
|
|
239
270
|
|
|
240
271
|
if (fresh.length === 0) continue;
|
|
241
272
|
cycleNew += fresh.length;
|
|
242
|
-
info(
|
|
273
|
+
info(`${tag}session ${sid.slice(0, 16)}…: +${fresh.length} new event(s)`);
|
|
243
274
|
|
|
244
275
|
if (uploadCtx) {
|
|
245
276
|
try {
|
|
246
|
-
const resp = await uploadSignals(uploadCtx, agentId, displayName, fresh);
|
|
277
|
+
const resp = await uploadSignals(uploadCtx, ag.agentId, sendNames ? ag.displayName : ag.agentId, fresh);
|
|
247
278
|
if (resp?.signal_id) info(` ↑ signals uploaded (signal_id ${resp.signal_id})`);
|
|
248
279
|
} catch (e) { warn(` signals upload failed: ${e.message}`); }
|
|
249
280
|
}
|
|
@@ -253,12 +284,13 @@ async function runWatch({ apiKey, agentId, model, displayName, logDir, intervalM
|
|
|
253
284
|
for (const e of fresh) tracker.record(e);
|
|
254
285
|
const stats = tracker.stats().total;
|
|
255
286
|
await logger.write({
|
|
256
|
-
action_type: 'session_end', framework: 'anthropic-managed', status: 'ok', model,
|
|
287
|
+
action_type: 'session_end', framework: 'anthropic-managed', status: 'ok', model: ag.model,
|
|
257
288
|
session_tokens: { input: stats.input, output: stats.output, cache_read: stats.cache_read, cache_creation: stats.cache_creation, total: stats.sum },
|
|
258
289
|
session_cost_usd: stats.cost_usd || null,
|
|
259
290
|
});
|
|
260
|
-
ended.add(
|
|
261
|
-
|
|
291
|
+
ended.add(sid);
|
|
292
|
+
sessionAgent.delete(sid); // bound memory: terminated sessions aren't re-fetched
|
|
293
|
+
info(`${tag}session ${sid.slice(0, 16)}… terminated — closed`);
|
|
262
294
|
}
|
|
263
295
|
}
|
|
264
296
|
|
|
@@ -275,10 +307,12 @@ async function main() {
|
|
|
275
307
|
const logDir = resolve(args['log-dir'] || './watchmyagents-logs');
|
|
276
308
|
const watch = !!args.watch;
|
|
277
309
|
const upload = !!args.upload;
|
|
310
|
+
const allAgents = !!args['all-agents'];
|
|
278
311
|
|
|
279
312
|
if (!apiKey) die('error: --api-key or ANTHROPIC_API_KEY required');
|
|
280
|
-
if (!agentId) die('error: --agent-id required (
|
|
281
|
-
if (!
|
|
313
|
+
if (!allAgents && !agentId) die('error: --agent-id required (or --all-agents for fleet mode)');
|
|
314
|
+
if (allAgents && !watch) die('error: --all-agents requires --watch (fleet daemon). For a one-shot, target a single --agent-id.');
|
|
315
|
+
if (agentId && !isValidAgentId(agentId)) {
|
|
282
316
|
die(`error: --agent-id has invalid format (expected "agent_" + alphanumeric, got "${agentId}")`);
|
|
283
317
|
}
|
|
284
318
|
const sessionIdArg = args['session-id'];
|
|
@@ -303,18 +337,49 @@ async function main() {
|
|
|
303
337
|
uploadCtx = { apiKey: wmaKey, salt, url: fortressEndpoint(base, 'ingest-signals') };
|
|
304
338
|
}
|
|
305
339
|
|
|
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
340
|
if (watch) {
|
|
313
341
|
const intervalMs = parseDurationMs(args.interval, 5 * 60_000);
|
|
314
|
-
|
|
342
|
+
// Discovery window for NEW sessions (default 7d, configurable). Sessions we
|
|
343
|
+
// already track are re-fetched regardless of age, so long-lived ones don't drop.
|
|
344
|
+
const windowMs = parseDurationMs(args['discovery-since'], 7 * 24 * 3600_000);
|
|
345
|
+
const sendNames = !!args['send-agent-names'];
|
|
346
|
+
|
|
347
|
+
let resolveAgents;
|
|
348
|
+
if (allAgents) {
|
|
349
|
+
// Re-discover the fleet each cycle: agents created after startup get picked
|
|
350
|
+
// up, gone ones drop off. Keep the last good list if a discovery call fails.
|
|
351
|
+
let lastFleet = [];
|
|
352
|
+
resolveAgents = async () => {
|
|
353
|
+
const all = await listAgents(apiKey).catch((e) => { warn(`fleet re-discovery failed (keeping last): ${e.message}`); return null; });
|
|
354
|
+
if (!all) return lastFleet;
|
|
355
|
+
const next = all
|
|
356
|
+
.filter((a) => a.id && isValidAgentId(a.id))
|
|
357
|
+
.map((a) => ({ agentId: a.id, model: resolveModel(a), displayName: cleanLabel(a.name || a.id) }));
|
|
358
|
+
const prev = new Set(lastFleet.map((a) => a.agentId));
|
|
359
|
+
const cur = new Set(next.map((a) => a.agentId));
|
|
360
|
+
for (const a of next) if (!prev.has(a.agentId)) info(`fleet: + ${a.displayName}`);
|
|
361
|
+
for (const a of lastFleet) if (!cur.has(a.agentId)) info(`fleet: − ${a.displayName} (gone)`);
|
|
362
|
+
lastFleet = next;
|
|
363
|
+
return next;
|
|
364
|
+
};
|
|
365
|
+
info('discovering agents (fleet mode)…');
|
|
366
|
+
const first = await resolveAgents();
|
|
367
|
+
if (first.length === 0) die('error: no agents found under this API key');
|
|
368
|
+
info(`fleet: ${first.length} agent(s) — ${first.map((a) => a.displayName).join(', ')}`);
|
|
369
|
+
} else {
|
|
370
|
+
info(`resolving agent ${agentId}…`);
|
|
371
|
+
const agent = await getAgent(apiKey, agentId).catch((e) => die(`failed to GET agent: ${e.message}`));
|
|
372
|
+
const single = [{ agentId, model: resolveModel(agent), displayName: cleanLabel(agent.name || agentId) }];
|
|
373
|
+
info(`model: ${single[0].model || '(unknown)'}`);
|
|
374
|
+
resolveAgents = async () => single;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
await runWatch({ apiKey, resolveAgents, fleet: allAgents, logDir, intervalMs, windowMs, uploadCtx, sendNames });
|
|
315
378
|
} else {
|
|
379
|
+
info(`resolving agent ${agentId}…`);
|
|
380
|
+
const agent = await getAgent(apiKey, agentId).catch((e) => die(`failed to GET agent: ${e.message}`));
|
|
316
381
|
const since = args.since ? parseSince(args.since) : null;
|
|
317
|
-
await fetchOneShot({ apiKey, agentId, model, logDir, since, sessionId: args['session-id'], dumpRaw: !!args['dump-raw'] });
|
|
382
|
+
await fetchOneShot({ apiKey, agentId, model: resolveModel(agent), logDir, since, sessionId: args['session-id'], dumpRaw: !!args['dump-raw'] });
|
|
318
383
|
}
|
|
319
384
|
}
|
|
320
385
|
|
package/scripts/service.js
CHANGED
|
@@ -251,9 +251,10 @@ function linuxUninstallOne(label) {
|
|
|
251
251
|
|
|
252
252
|
// ── Commands ────────────────────────────────────────────────────────────--
|
|
253
253
|
function cmdInstall(args) {
|
|
254
|
+
const allAgents = !!args['all-agents'];
|
|
254
255
|
const agentId = args['agent-id'];
|
|
255
|
-
if (!agentId) die('--agent-id required (
|
|
256
|
-
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}")`);
|
|
257
258
|
const interval = args.interval || '5m';
|
|
258
259
|
if (!/^\d+[smhd]$/.test(interval)) die(`--interval invalid format (expected like 30s, 5m, 1h, 2d; got "${interval}")`);
|
|
259
260
|
const logDir = args['log-dir'] || LOG_DIR_DEFAULT;
|
|
@@ -262,14 +263,15 @@ function cmdInstall(args) {
|
|
|
262
263
|
if (PLATFORM !== 'darwin' && PLATFORM !== 'linux') {
|
|
263
264
|
die(`unsupported platform "${PLATFORM}". Supported: macOS (launchd), Linux (systemd).\n` +
|
|
264
265
|
' Run the daemon manually or wrap it in your own process manager:\n' +
|
|
265
|
-
` wma-fetch --agent-id ${agentId} --watch --upload --interval ${interval}`);
|
|
266
|
+
` wma-fetch ${allAgents ? '--all-agents' : `--agent-id ${agentId}`} --watch --upload --interval ${interval}`);
|
|
266
267
|
}
|
|
267
268
|
|
|
268
269
|
mkdirSync(logDir, { recursive: true, mode: 0o700 });
|
|
269
270
|
writeEnvFile();
|
|
270
271
|
|
|
271
|
-
const
|
|
272
|
-
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];
|
|
273
275
|
|
|
274
276
|
if (PLATFORM === 'darwin') {
|
|
275
277
|
macInstallOne(WATCH_LABEL, FETCH_SCRIPT, watchArgs);
|