watchmyagents 0.9.0 → 0.9.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 +5 -3
- package/package.json +1 -1
- package/scripts/agents.js +4 -128
- package/scripts/fetch-anthropic.js +126 -65
- package/scripts/shield.js +50 -14
- package/src/typology-features.js +136 -0
package/README.md
CHANGED
|
@@ -121,6 +121,8 @@ wma-fetch (--agent-id <agent_id> | --all-agents) [--session-id <sess_id>] [--sin
|
|
|
121
121
|
| `--watch` | **Continuous daemon** — loop forever, incrementally capturing NEW events (deduped by stable event id) until `Ctrl+C` |
|
|
122
122
|
| `--interval 5m` | Poll interval in watch mode (default `5m`; accepts `30s`/`1h`/…) |
|
|
123
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). |
|
|
124
126
|
| `--api-key sk-ant-…` | Override the `ANTHROPIC_API_KEY` env var. **Discouraged** — visible in shell history & process list. Prefer the env var. |
|
|
125
127
|
|
|
126
128
|
Logs land in `./watchmyagents-logs/<agent_id>/<date>.ndjson` (file mode `0600`, dir `0700`).
|
|
@@ -153,7 +155,7 @@ wma-upload-fortress --agent-id agent_01ABC... [--display-name "My agent"]
|
|
|
153
155
|
wma-upload-fortress --agent-id agent_xxx --dry-run
|
|
154
156
|
```
|
|
155
157
|
|
|
156
|
-
**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`).
|
|
157
159
|
**What is NOT sent:** raw prompts, raw URLs/commands/queries, raw agent responses, raw error messages. All payload content stays on your machine.
|
|
158
160
|
|
|
159
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.
|
|
@@ -245,9 +247,9 @@ WatchMyAgents is built so that **your prompts and outputs never have to leave yo
|
|
|
245
247
|
|---|---|
|
|
246
248
|
| **Your machine** (`./watchmyagents-logs/`) | Full NDJSON with all prompts, tool inputs, agent outputs. `chmod 600` on every file. |
|
|
247
249
|
| **Anthropic API** | Where the agent runs. WMA pulls events via the public REST API only. |
|
|
248
|
-
| **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. |
|
|
249
251
|
|
|
250
|
-
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.
|
|
251
253
|
|
|
252
254
|
## Security
|
|
253
255
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "watchmyagents",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
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": [
|
package/scripts/agents.js
CHANGED
|
@@ -18,12 +18,10 @@
|
|
|
18
18
|
// ANTHROPIC_API_KEY from env (or --api-key, discouraged).
|
|
19
19
|
|
|
20
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
21
|
import { join, resolve } from 'node:path';
|
|
25
22
|
import { listAgents } from '../src/sources/anthropic-managed.js';
|
|
26
23
|
import { classifyAgentType } from '../src/typology.js';
|
|
24
|
+
import { aggregate, buildFeatures, NON_DERIVABLE } from '../src/typology-features.js';
|
|
27
25
|
import { isValidAgentId, assertSafePathSegment } from '../src/validate.js';
|
|
28
26
|
|
|
29
27
|
function parseArgs(argv) {
|
|
@@ -40,131 +38,9 @@ function parseArgs(argv) {
|
|
|
40
38
|
function die(msg, code = 1) { process.stderr.write(`error: ${msg}\n`); process.exit(code); }
|
|
41
39
|
function info(msg) { process.stdout.write(`[wma-agents] ${msg}\n`); }
|
|
42
40
|
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
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
|
-
}
|
|
41
|
+
// Feature aggregation lives in src/typology-features.js (shared with the Watch
|
|
42
|
+
// daemon so both CLI snapshot and continuous upload use the same Modèle C
|
|
43
|
+
// extraction). The rest of this file is just CLI presentation.
|
|
168
44
|
|
|
169
45
|
async function main() {
|
|
170
46
|
const args = parseArgs(process.argv.slice(2));
|
|
@@ -30,6 +30,8 @@ import { TokenTracker } from '../src/tokens.js';
|
|
|
30
30
|
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
|
+
import { classifyAgentType } from '../src/typology.js';
|
|
34
|
+
import { aggregate, buildFeatures } from '../src/typology-features.js';
|
|
33
35
|
import {
|
|
34
36
|
getAgent, listAgents, listSessions, fetchSessionEntries, fetchRawEvents,
|
|
35
37
|
} from '../src/sources/anthropic-managed.js';
|
|
@@ -105,7 +107,10 @@ function postJson(url, headers, body) {
|
|
|
105
107
|
}
|
|
106
108
|
|
|
107
109
|
// Anonymize a batch of just-written entries and ship them as one signals row.
|
|
108
|
-
|
|
110
|
+
// `classification` (optional) carries the agent's typology — Fortress upserts
|
|
111
|
+
// agent_type/confidence/stage on the agent row so the typology badge + the
|
|
112
|
+
// apply-template flow fill themselves with no manual click.
|
|
113
|
+
async function uploadSignals(uploadCtx, agentId, displayName, entries, classification) {
|
|
109
114
|
const agg = new SignalsAggregator({ salt: uploadCtx.salt });
|
|
110
115
|
for (const e of entries) agg.add(e);
|
|
111
116
|
const sig = agg.finalize();
|
|
@@ -116,6 +121,7 @@ async function uploadSignals(uploadCtx, agentId, displayName, entries) {
|
|
|
116
121
|
window_start: sig.window_start,
|
|
117
122
|
window_end: sig.window_end,
|
|
118
123
|
payload: sig.payload,
|
|
124
|
+
...(classification ? { classification } : {}),
|
|
119
125
|
});
|
|
120
126
|
const { status, body: resp } = await postJson(
|
|
121
127
|
uploadCtx.url, { authorization: `Bearer ${uploadCtx.apiKey}` }, body,
|
|
@@ -202,76 +208,112 @@ async function fetchOneShot({ apiKey, agentId, model, logDir, since, sessionId,
|
|
|
202
208
|
}
|
|
203
209
|
|
|
204
210
|
// ── CONTINUOUS / DAEMON (single agent or whole fleet) ───────────────────────
|
|
205
|
-
//
|
|
206
|
-
|
|
211
|
+
// resolveAgents() returns the current fleet [{agentId, model, displayName}] each
|
|
212
|
+
// cycle — in fleet mode it RE-DISCOVERS so agents created after startup get picked
|
|
213
|
+
// up. `windowMs` bounds discovery of NEW sessions, but sessions we're ALREADY
|
|
214
|
+
// tracking are re-fetched regardless of age, so a long-running (>window) session
|
|
215
|
+
// never drops out of capture. `sendNames`: include the human agent name in the
|
|
216
|
+
// Fortress display_name (opt-in); default sends the agent id only (Modèle C).
|
|
217
|
+
async function runWatch({ apiKey, resolveAgents, fleet, logDir, intervalMs, windowMs, uploadCtx, sendNames }) {
|
|
218
|
+
let agents = await resolveAgents();
|
|
207
219
|
const seenIds = new Set(); // stable Anthropic event ids already captured
|
|
208
220
|
for (const ag of agents) {
|
|
209
221
|
for (const id of await preloadSeenIds(logDir, ag.agentId)) seenIds.add(id);
|
|
210
222
|
}
|
|
211
223
|
const loggers = new Map(); // sessionId → Logger (session ids are globally unique)
|
|
212
|
-
const ended = new Set(); // sessions
|
|
224
|
+
const ended = new Set(); // terminated sessions (skip)
|
|
225
|
+
const sessionAgent = new Map();// sessionId → { agentId, model, displayName }
|
|
226
|
+
const priors = new Map(); // agentId → previous classification (threads the
|
|
227
|
+
// typology state machine across upload cycles)
|
|
213
228
|
|
|
214
229
|
const ac = new AbortController();
|
|
215
230
|
const shutdown = () => { info('shutting down…'); ac.abort(); };
|
|
216
231
|
process.on('SIGINT', shutdown);
|
|
217
232
|
process.on('SIGTERM', shutdown);
|
|
218
233
|
|
|
219
|
-
|
|
220
|
-
info(`watch mode — ${agents.length} agent(s), interval ${Math.round(intervalMs / 1000)}s, upload ${uploadCtx ? 'ON' : 'OFF'}, ${seenIds.size} known events preloaded`);
|
|
234
|
+
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`);
|
|
221
235
|
|
|
222
236
|
while (!ac.signal.aborted) {
|
|
223
|
-
const
|
|
224
|
-
|
|
237
|
+
if (fleet) { const next = await resolveAgents(); if (next.length) agents = next; }
|
|
238
|
+
const since = new Date(Date.now() - windowMs);
|
|
225
239
|
|
|
240
|
+
// (1) Discover sessions in the window; register the owning agent for each.
|
|
226
241
|
for (const ag of agents) {
|
|
227
242
|
if (ac.signal.aborted) break;
|
|
228
243
|
const tag = fleet ? `[${ag.displayName}] ` : '';
|
|
229
244
|
let sessions = [];
|
|
230
245
|
try { sessions = await listSessions(apiKey, { agentId: ag.agentId, since }); }
|
|
231
246
|
catch (e) { warn(`${tag}listSessions failed: ${e.message}`); continue; }
|
|
232
|
-
|
|
233
247
|
for (const s of sessions) {
|
|
234
|
-
if (
|
|
235
|
-
|
|
236
|
-
|
|
248
|
+
if (s.id && !ended.has(s.id) && !sessionAgent.has(s.id)) {
|
|
249
|
+
sessionAgent.set(s.id, { agentId: ag.agentId, model: ag.model, displayName: ag.displayName });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
237
253
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
&& entry.output?.state === 'terminated') sawTerminated = true;
|
|
249
|
-
}
|
|
250
|
-
} catch (e) { warn(`${tag}session ${s.id.slice(0, 16)}…: fetch failed: ${e.message}`); continue; }
|
|
254
|
+
// (2) Capture every tracked, not-yet-ended session — REGARDLESS of age. This
|
|
255
|
+
// is what stops a long-running session created before the window from silently
|
|
256
|
+
// dropping out of monitoring (and, paired with Shield, out of enforcement).
|
|
257
|
+
let cycleNew = 0;
|
|
258
|
+
for (const [sid, ag] of sessionAgent) {
|
|
259
|
+
if (ac.signal.aborted) break;
|
|
260
|
+
if (ended.has(sid)) continue;
|
|
261
|
+
const tag = fleet ? `[${ag.displayName}] ` : '';
|
|
262
|
+
let logger = loggers.get(sid);
|
|
263
|
+
if (!logger) { logger = new Logger({ logDir, agentId: ag.agentId, sessionId: sid, silent: true }); loggers.set(sid, logger); }
|
|
251
264
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
265
|
+
const fresh = [];
|
|
266
|
+
let sawTerminated = false;
|
|
267
|
+
try {
|
|
268
|
+
for await (const entry of fetchSessionEntries({ apiKey, agentId: ag.agentId, sessionId: sid, model: ag.model })) {
|
|
269
|
+
if (entry.id && seenIds.has(entry.id)) continue;
|
|
270
|
+
if (entry.id) seenIds.add(entry.id);
|
|
271
|
+
const written = await logger.write(entry);
|
|
272
|
+
fresh.push(written);
|
|
273
|
+
if (entry.action_type === 'state_transition'
|
|
274
|
+
&& entry.output?.scope === 'session'
|
|
275
|
+
&& entry.output?.state === 'terminated') sawTerminated = true;
|
|
276
|
+
}
|
|
277
|
+
} catch (e) { warn(`${tag}session ${sid.slice(0, 16)}…: fetch failed: ${e.message}`); continue; }
|
|
255
278
|
|
|
256
|
-
|
|
279
|
+
if (fresh.length === 0) continue;
|
|
280
|
+
cycleNew += fresh.length;
|
|
281
|
+
info(`${tag}session ${sid.slice(0, 16)}…: +${fresh.length} new event(s)`);
|
|
282
|
+
|
|
283
|
+
if (uploadCtx) {
|
|
284
|
+
try {
|
|
285
|
+
// Compute the agent's typology from its CUMULATIVE local logs and
|
|
286
|
+
// thread the prior across cycles so the state machine refines toward
|
|
287
|
+
// stable (Modèle C: features = counts/categories only, no raw content).
|
|
288
|
+
let classification;
|
|
257
289
|
try {
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
290
|
+
const features = buildFeatures(await aggregate(logDir, ag.agentId));
|
|
291
|
+
features.agent_id = ag.agentId;
|
|
292
|
+
const cls = classifyAgentType(features, priors.get(ag.agentId) || null);
|
|
293
|
+
priors.set(ag.agentId, cls);
|
|
294
|
+
classification = { agent_type: cls.classified_type, confidence: cls.confidence, stage: cls.stage };
|
|
295
|
+
} catch (e) { warn(` classification skipped: ${e.message}`); }
|
|
262
296
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
297
|
+
const resp = await uploadSignals(uploadCtx, ag.agentId, sendNames ? ag.displayName : ag.agentId, fresh, classification);
|
|
298
|
+
if (resp?.signal_id) {
|
|
299
|
+
const cTag = classification ? ` · type ${classification.agent_type} (${Math.round(classification.confidence * 100)}%, ${classification.stage})` : '';
|
|
300
|
+
info(` ↑ signals uploaded (signal_id ${resp.signal_id})${cTag}`);
|
|
301
|
+
}
|
|
302
|
+
} catch (e) { warn(` signals upload failed: ${e.message}`); }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (sawTerminated) {
|
|
306
|
+
const tracker = new TokenTracker();
|
|
307
|
+
for (const e of fresh) tracker.record(e);
|
|
308
|
+
const stats = tracker.stats().total;
|
|
309
|
+
await logger.write({
|
|
310
|
+
action_type: 'session_end', framework: 'anthropic-managed', status: 'ok', model: ag.model,
|
|
311
|
+
session_tokens: { input: stats.input, output: stats.output, cache_read: stats.cache_read, cache_creation: stats.cache_creation, total: stats.sum },
|
|
312
|
+
session_cost_usd: stats.cost_usd || null,
|
|
313
|
+
});
|
|
314
|
+
ended.add(sid);
|
|
315
|
+
sessionAgent.delete(sid); // bound memory: terminated sessions aren't re-fetched
|
|
316
|
+
info(`${tag}session ${sid.slice(0, 16)}… terminated — closed`);
|
|
275
317
|
}
|
|
276
318
|
}
|
|
277
319
|
|
|
@@ -318,30 +360,49 @@ async function main() {
|
|
|
318
360
|
uploadCtx = { apiKey: wmaKey, salt, url: fortressEndpoint(base, 'ingest-signals') };
|
|
319
361
|
}
|
|
320
362
|
|
|
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
|
-
}
|
|
337
|
-
|
|
338
363
|
if (watch) {
|
|
339
364
|
const intervalMs = parseDurationMs(args.interval, 5 * 60_000);
|
|
340
|
-
|
|
365
|
+
// Discovery window for NEW sessions (default 7d, configurable). Sessions we
|
|
366
|
+
// already track are re-fetched regardless of age, so long-lived ones don't drop.
|
|
367
|
+
const windowMs = parseDurationMs(args['discovery-since'], 7 * 24 * 3600_000);
|
|
368
|
+
const sendNames = !!args['send-agent-names'];
|
|
369
|
+
|
|
370
|
+
let resolveAgents;
|
|
371
|
+
if (allAgents) {
|
|
372
|
+
// Re-discover the fleet each cycle: agents created after startup get picked
|
|
373
|
+
// up, gone ones drop off. Keep the last good list if a discovery call fails.
|
|
374
|
+
let lastFleet = [];
|
|
375
|
+
resolveAgents = async () => {
|
|
376
|
+
const all = await listAgents(apiKey).catch((e) => { warn(`fleet re-discovery failed (keeping last): ${e.message}`); return null; });
|
|
377
|
+
if (!all) return lastFleet;
|
|
378
|
+
const next = all
|
|
379
|
+
.filter((a) => a.id && isValidAgentId(a.id))
|
|
380
|
+
.map((a) => ({ agentId: a.id, model: resolveModel(a), displayName: cleanLabel(a.name || a.id) }));
|
|
381
|
+
const prev = new Set(lastFleet.map((a) => a.agentId));
|
|
382
|
+
const cur = new Set(next.map((a) => a.agentId));
|
|
383
|
+
for (const a of next) if (!prev.has(a.agentId)) info(`fleet: + ${a.displayName}`);
|
|
384
|
+
for (const a of lastFleet) if (!cur.has(a.agentId)) info(`fleet: − ${a.displayName} (gone)`);
|
|
385
|
+
lastFleet = next;
|
|
386
|
+
return next;
|
|
387
|
+
};
|
|
388
|
+
info('discovering agents (fleet mode)…');
|
|
389
|
+
const first = await resolveAgents();
|
|
390
|
+
if (first.length === 0) die('error: no agents found under this API key');
|
|
391
|
+
info(`fleet: ${first.length} agent(s) — ${first.map((a) => a.displayName).join(', ')}`);
|
|
392
|
+
} else {
|
|
393
|
+
info(`resolving agent ${agentId}…`);
|
|
394
|
+
const agent = await getAgent(apiKey, agentId).catch((e) => die(`failed to GET agent: ${e.message}`));
|
|
395
|
+
const single = [{ agentId, model: resolveModel(agent), displayName: cleanLabel(agent.name || agentId) }];
|
|
396
|
+
info(`model: ${single[0].model || '(unknown)'}`);
|
|
397
|
+
resolveAgents = async () => single;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
await runWatch({ apiKey, resolveAgents, fleet: allAgents, logDir, intervalMs, windowMs, uploadCtx, sendNames });
|
|
341
401
|
} else {
|
|
402
|
+
info(`resolving agent ${agentId}…`);
|
|
403
|
+
const agent = await getAgent(apiKey, agentId).catch((e) => die(`failed to GET agent: ${e.message}`));
|
|
342
404
|
const since = args.since ? parseSince(args.since) : null;
|
|
343
|
-
|
|
344
|
-
await fetchOneShot({ apiKey, agentId: a.agentId, model: a.model, logDir, since, sessionId: args['session-id'], dumpRaw: !!args['dump-raw'] });
|
|
405
|
+
await fetchOneShot({ apiKey, agentId, model: resolveModel(agent), logDir, since, sessionId: args['session-id'], dumpRaw: !!args['dump-raw'] });
|
|
345
406
|
}
|
|
346
407
|
}
|
|
347
408
|
|
package/scripts/shield.js
CHANGED
|
@@ -57,6 +57,14 @@ function info(msg) { process.stdout.write(`[shield] ${msg}\n`); }
|
|
|
57
57
|
function warn(msg) { process.stderr.write(`[shield] ⚠️ ${msg}\n`); }
|
|
58
58
|
function sinfo(sid, msg) { process.stdout.write(`[shield/${sid.slice(0, 12)}] ${msg}\n`); }
|
|
59
59
|
function swarn(sid, msg) { process.stderr.write(`[shield/${sid.slice(0, 12)}] ⚠️ ${msg}\n`); }
|
|
60
|
+
const sleep = (ms, signal) => new Promise((res) => {
|
|
61
|
+
const t = setTimeout(res, ms);
|
|
62
|
+
if (signal) signal.addEventListener('abort', () => { clearTimeout(t); res(); }, { once: true });
|
|
63
|
+
});
|
|
64
|
+
function parseWindowMs(v, fallback) {
|
|
65
|
+
const m = v && String(v).match(/^(\d+)\s*([smhd])$/);
|
|
66
|
+
return m ? parseInt(m[1], 10) * { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2]] : fallback;
|
|
67
|
+
}
|
|
60
68
|
|
|
61
69
|
const CACHEABLE_TOOL_TYPES = new Set([
|
|
62
70
|
'agent.tool_use', 'agent.mcp_tool_use', 'agent.custom_tool_use',
|
|
@@ -319,6 +327,10 @@ async function runSessionWorker({ sessionId, ctx }) {
|
|
|
319
327
|
// ────────────────────────────────────────────────────────────────────────
|
|
320
328
|
async function runAgentWide(ctx) {
|
|
321
329
|
const { apiKey, agentId, signal } = ctx;
|
|
330
|
+
// Discovery window for sessions we haven't attached yet (default 7d). Already-
|
|
331
|
+
// attached workers stream until the session terminates regardless of age, so a
|
|
332
|
+
// long-running session never loses enforcement once attached.
|
|
333
|
+
const discoveryWindowMs = ctx.discoveryWindowMs || 7 * 24 * 3600_000;
|
|
322
334
|
const workers = new Map(); // sessionId → AbortController (active workers)
|
|
323
335
|
const cooldown = new Map(); // sessionId → unix-ms timestamp when re-attach is allowed
|
|
324
336
|
|
|
@@ -332,9 +344,7 @@ async function runAgentWide(ctx) {
|
|
|
332
344
|
async function discoverAndAttach() {
|
|
333
345
|
let sessions;
|
|
334
346
|
try {
|
|
335
|
-
|
|
336
|
-
// is probably stale; the user can extend the window if needed).
|
|
337
|
-
const since = new Date(Date.now() - 24 * 3600_000);
|
|
347
|
+
const since = new Date(Date.now() - discoveryWindowMs);
|
|
338
348
|
sessions = await listSessions(apiKey, { agentId, since });
|
|
339
349
|
} catch (e) {
|
|
340
350
|
warn(`listSessions failed: ${e.message}`);
|
|
@@ -424,6 +434,7 @@ async function main() {
|
|
|
424
434
|
});
|
|
425
435
|
const logDir = resolve(args['log-dir'] || './watchmyagents-logs');
|
|
426
436
|
const allAgents = !!args['all-agents'];
|
|
437
|
+
const discoveryWindowMs = parseWindowMs(args['discovery-since'], 7 * 24 * 3600_000);
|
|
427
438
|
|
|
428
439
|
if (!apiKey) die('error: --api-key or ANTHROPIC_API_KEY required');
|
|
429
440
|
if (!allAgents && !agentId) die('error: --agent-id required (or --all-agents for fleet mode)');
|
|
@@ -530,22 +541,47 @@ async function main() {
|
|
|
530
541
|
return {
|
|
531
542
|
apiKey, agentId: aid,
|
|
532
543
|
get ruleset() { return fortressPolicies ? fortressPolicies.current() : ruleset; },
|
|
533
|
-
mode, decisions, pushDecisionToFortress, signalsSalt, signal: ac.signal,
|
|
544
|
+
mode, decisions, pushDecisionToFortress, signalsSalt, signal: ac.signal, discoveryWindowMs,
|
|
534
545
|
};
|
|
535
546
|
}
|
|
536
547
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
548
|
+
if (!fleet) {
|
|
549
|
+
// Single agent: arm + run (blocks until SIGINT/SIGTERM). die() on failure
|
|
550
|
+
// already fires inside setupAgent for the non-fleet path.
|
|
551
|
+
const ctx = await setupAgent(agentIds[0]);
|
|
552
|
+
await (singleSessionId ? runSessionWorker({ sessionId: singleSessionId, ctx }) : runAgentWide(ctx));
|
|
553
|
+
return;
|
|
542
554
|
}
|
|
543
|
-
if (fleet) info(`armed ${ctxs.length}/${agentIds.length} agent(s); watching.`);
|
|
544
555
|
|
|
545
|
-
//
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
)
|
|
556
|
+
// Fleet: arm all discovered agents, then RECONCILE periodically so an agent
|
|
557
|
+
// created after startup gets armed + protected without a restart. A per-agent
|
|
558
|
+
// arm failure is skipped and retried on the next reconcile.
|
|
559
|
+
const armed = new Set();
|
|
560
|
+
const running = [];
|
|
561
|
+
const armNew = async (ids) => {
|
|
562
|
+
for (const aid of ids) {
|
|
563
|
+
if (armed.has(aid)) continue;
|
|
564
|
+
const ctx = await setupAgent(aid);
|
|
565
|
+
if (!ctx) continue; // skipped (policy fetch failed) → retry next reconcile
|
|
566
|
+
armed.add(aid);
|
|
567
|
+
running.push(runAgentWide(ctx)); // fire; blocks on the shared signal until shutdown
|
|
568
|
+
info(`fleet: armed ${aid.slice(0, 16)}…`);
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
await armNew(agentIds);
|
|
572
|
+
if (armed.size === 0) {
|
|
573
|
+
die(`error: no agents could be armed (${agentIds.length} discovered; all policy fetches failed). Check WMA_API_KEY / WMA_FORTRESS_BASE_URL.`);
|
|
574
|
+
}
|
|
575
|
+
info(`fleet: ${armed.size}/${agentIds.length} agent(s) armed; reconciling every 60s for new agents.`);
|
|
576
|
+
while (!ac.signal.aborted) {
|
|
577
|
+
await sleep(60_000, ac.signal);
|
|
578
|
+
if (ac.signal.aborted) break;
|
|
579
|
+
let all;
|
|
580
|
+
try { all = await listAgents(apiKey); }
|
|
581
|
+
catch (e) { warn(`fleet reconcile failed (keeping current): ${e.message}`); continue; }
|
|
582
|
+
await armNew(all.map((a) => a.id).filter((id) => id && isValidAgentId(id)));
|
|
583
|
+
}
|
|
584
|
+
await Promise.all(running);
|
|
549
585
|
}
|
|
550
586
|
|
|
551
587
|
main().catch(e => {
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Shared local-log feature extraction for the typology classifier (Modèle C).
|
|
2
|
+
// Both wma-agents (CLI snapshot) and the Watch daemon (continuous upload) use
|
|
3
|
+
// this to derive the anonymized behavioural FEATURE VECTOR from local NDJSON
|
|
4
|
+
// logs, then feed it to classifyAgentType() in ./typology.js.
|
|
5
|
+
//
|
|
6
|
+
// Modèle C invariant: only `action_type` and `tool_name` are read from each log
|
|
7
|
+
// line — the raw payload fields (input/output/content/error/thinking) are NEVER
|
|
8
|
+
// touched here, so no raw content can ever enter a feature.
|
|
9
|
+
|
|
10
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
11
|
+
import { createReadStream } from 'node:fs';
|
|
12
|
+
import { createInterface } from 'node:readline';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
// Action types that represent a TOOL invocation (denominator for f_* fractions).
|
|
16
|
+
const TOOL_ACTIONS = new Set(['tool_use', 'mcp_tool_use', 'custom_tool_use']);
|
|
17
|
+
|
|
18
|
+
// Tool-name → category mapping. Anthropic Managed Agents expose tools as an
|
|
19
|
+
// opaque bundle, so tool_name is free-text. We match the confirmed built-ins
|
|
20
|
+
// (web_search, web_fetch, bash) plus best-effort regexes for common tool names.
|
|
21
|
+
// A tool that matches nothing contributes to the denominator but to no category
|
|
22
|
+
// (honest: unknown ≠ inferred).
|
|
23
|
+
const CATEGORY_RULES = [
|
|
24
|
+
['search', (n) => /(^|_)web_search$|(^|_)search($|_)|google|brave/.test(n)],
|
|
25
|
+
['browser', (n) => /web_fetch|browser|playwright|puppeteer|navigate|screenshot/.test(n)],
|
|
26
|
+
['http', (n) => /(^|_)http|fetch_url|curl|request|webhook|api_call/.test(n)],
|
|
27
|
+
['code', (n) => /bash|shell|terminal|code_exec|exec_|python|node_run|run_code|interpreter/.test(n)],
|
|
28
|
+
['database', (n) => /sql|query_db|database|postgres|mysql|mongo|redis|bigquery|snowflake/.test(n)],
|
|
29
|
+
['email', (n) => /email|gmail|smtp|sendmail|mailgun|outlook/.test(n)],
|
|
30
|
+
['payment', (n) => /payment|charge|transfer|invoice|stripe|paypal|payout|refund|checkout/.test(n)],
|
|
31
|
+
['secret', (n) => /secret|vault|credential|kms|keychain|token_get/.test(n)],
|
|
32
|
+
['memory', (n) => /memory|retriev|vector|(^|_)rag($|_)|knowledge|embed|pinecone|chroma/.test(n)],
|
|
33
|
+
['file', (n) => /editor|str_replace|read_file|write_file|create_file|file_io|(^|_)file($|_)|fs_/.test(n)],
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// Best-effort deploy detection (spec discriminator devops_infra vs coding).
|
|
37
|
+
const DEPLOY_RE = /deploy|terraform|kubectl|helm|(^|_)release($|_)|ansible|pulumi|cloudformation/;
|
|
38
|
+
|
|
39
|
+
// Features that the WMA NDJSON logs CANNOT reliably expose today (opaque tool
|
|
40
|
+
// names, no behavioural signal, or raw content off-limits under Modèle C).
|
|
41
|
+
// They default to 0/false; callers can surface this to the user.
|
|
42
|
+
export const NON_DERIVABLE = [
|
|
43
|
+
'f_database', 'f_email', 'f_payment', 'f_secret', 'f_memory',
|
|
44
|
+
'flag_internal_sys', 'flag_on_behalf', 'aux_untrusted', 'aux_sensitive',
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
function categoryOf(toolName) {
|
|
48
|
+
const n = String(toolName || '').toLowerCase();
|
|
49
|
+
for (const [cat, m] of CATEGORY_RULES) if (m(n)) return cat;
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Aggregate raw counts from an agent's local NDJSON logs. Returns `hasLogs:false`
|
|
54
|
+
// when there's nothing to read (cold start).
|
|
55
|
+
export async function aggregate(logDir, agentId) {
|
|
56
|
+
const actionCounts = {};
|
|
57
|
+
const categoryCounts = {};
|
|
58
|
+
let toolEvents = 0;
|
|
59
|
+
let deployUses = 0;
|
|
60
|
+
const dir = join(logDir, agentId);
|
|
61
|
+
const s = await stat(dir).catch(() => null);
|
|
62
|
+
if (!s || !s.isDirectory()) return { actionCounts, categoryCounts, toolEvents, deployUses, hasLogs: false };
|
|
63
|
+
let names;
|
|
64
|
+
try { names = await readdir(dir); } catch { return { actionCounts, categoryCounts, toolEvents, deployUses, hasLogs: false }; }
|
|
65
|
+
const files = names.filter((n) => n.endsWith('.ndjson') && !n.startsWith('raw-'));
|
|
66
|
+
if (files.length === 0) return { actionCounts, categoryCounts, toolEvents, deployUses, hasLogs: false };
|
|
67
|
+
|
|
68
|
+
for (const f of files) {
|
|
69
|
+
await new Promise((res) => {
|
|
70
|
+
const rl = createInterface({ input: createReadStream(join(dir, f), { encoding: 'utf8' }), crlfDelay: Infinity });
|
|
71
|
+
rl.on('line', (line) => {
|
|
72
|
+
if (!line.trim()) return;
|
|
73
|
+
let e; try { e = JSON.parse(line); } catch { return; }
|
|
74
|
+
if (e.action_type) actionCounts[e.action_type] = (actionCounts[e.action_type] || 0) + 1;
|
|
75
|
+
if (TOOL_ACTIONS.has(e.action_type)) {
|
|
76
|
+
toolEvents += 1;
|
|
77
|
+
const cat = categoryOf(e.tool_name);
|
|
78
|
+
if (cat) categoryCounts[cat] = (categoryCounts[cat] || 0) + 1;
|
|
79
|
+
if (DEPLOY_RE.test(String(e.tool_name || '').toLowerCase())) deployUses += 1;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
rl.on('close', res); rl.on('error', res);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return { actionCounts, categoryCounts, toolEvents, deployUses, hasLogs: true };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Build the canonical anonymized FEATURE VECTOR from the aggregated counts.
|
|
89
|
+
// fractions f_* = category_count / toolEvents ; event fractions = type_count /
|
|
90
|
+
// nEvents ; flags 0/1 ; aux ratios in [0,1] ; n_events = total observed events.
|
|
91
|
+
export function buildFeatures(agg) {
|
|
92
|
+
const { actionCounts, categoryCounts, toolEvents, deployUses } = agg;
|
|
93
|
+
const nEvents = Object.values(actionCounts).reduce((a, b) => a + b, 0);
|
|
94
|
+
const frac = (c) => (toolEvents > 0 ? (categoryCounts[c] || 0) / toolEvents : 0);
|
|
95
|
+
const eventFrac = (...types) => (nEvents > 0
|
|
96
|
+
? types.reduce((a, t) => a + (actionCounts[t] || 0), 0) / nEvents
|
|
97
|
+
: 0);
|
|
98
|
+
|
|
99
|
+
// f_handoff / f_user_msg are derived from event TYPE (not tool category):
|
|
100
|
+
// confirmed action_types thread_message_* and user_message.
|
|
101
|
+
const handoff = eventFrac('thread_message_sent', 'thread_message_received', 'thread_created');
|
|
102
|
+
const userMsg = eventFrac('user_message');
|
|
103
|
+
|
|
104
|
+
// aux_autonomy ≈ 1 − (human-in-the-loop event share). user_message /
|
|
105
|
+
// user_interrupt / tool_confirmation mark human involvement.
|
|
106
|
+
const hitlShare = eventFrac('user_message', 'user_interrupt', 'tool_confirmation');
|
|
107
|
+
const auxAutonomy = nEvents > 0 ? Math.max(0, 1 - hitlShare) : 0;
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
// tool-category fractions (over tool uses)
|
|
111
|
+
f_code: frac('code'),
|
|
112
|
+
f_browser: frac('browser'),
|
|
113
|
+
f_database: frac('database'), // non-derivable in practice → ~0
|
|
114
|
+
f_http: frac('http'),
|
|
115
|
+
f_email: frac('email'), // non-derivable in practice → ~0
|
|
116
|
+
f_payment: frac('payment'), // non-derivable in practice → ~0
|
|
117
|
+
f_secret: frac('secret'), // non-derivable in practice → ~0
|
|
118
|
+
f_search: frac('search'),
|
|
119
|
+
f_memory: frac('memory'), // non-derivable in practice → ~0
|
|
120
|
+
f_file: frac('file'),
|
|
121
|
+
// event-type fractions (over all events)
|
|
122
|
+
f_handoff: handoff,
|
|
123
|
+
f_user_msg: userMsg,
|
|
124
|
+
// discriminator flags (best-effort; only flag_deploy has any behavioural
|
|
125
|
+
// signal — and only if the agent literally names a deploy tool).
|
|
126
|
+
flag_deploy: deployUses > 0 ? 1 : 0,
|
|
127
|
+
flag_internal_sys: 0, // no behavioural signal in logs
|
|
128
|
+
flag_on_behalf: 0, // no behavioural signal in logs
|
|
129
|
+
// aux ratios
|
|
130
|
+
aux_autonomy: auxAutonomy, // heuristic (HITL-frequency)
|
|
131
|
+
aux_untrusted: 0, // no honest source in logs
|
|
132
|
+
aux_sensitive: 0, // no honest source in logs
|
|
133
|
+
// window size
|
|
134
|
+
n_events: nEvents,
|
|
135
|
+
};
|
|
136
|
+
}
|