watchmyagents 0.3.0 → 0.5.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 CHANGED
@@ -116,10 +116,43 @@ wma-fetch --agent-id <agent_id> [--session-id <sess_id>] [--since 1h]
116
116
  | `--session-id sesn_xxx` | Limit to a single session |
117
117
  | `--log-dir ./logs` | Where to write NDJSON (default `./watchmyagents-logs`) |
118
118
  | `--dump-raw` | Also save raw API events alongside (forensic / debugging) |
119
- | `--api-key sk-ant-…` | Override the `ANTHROPIC_API_KEY` env var |
119
+ | `--api-key sk-ant-…` | Override the `ANTHROPIC_API_KEY` env var. **Discouraged** — visible in shell history & process list. Prefer the env var. |
120
120
 
121
121
  Logs land in `./watchmyagents-logs/<agent_id>/<date>.ndjson` (file mode `0600`, dir `0700`).
122
122
 
123
+ ### `wma-anonymize` — preview what would leave your machine
124
+
125
+ Produces the anonymized signals payload (counts, latencies, salted IoC hashes, sequence histograms — no raw URLs/commands/prompts) that future WMA cloud features would ship. Useful to verify Modèle C compliance and to test the format.
126
+
127
+ ```bash
128
+ export WMA_SIGNALS_SALT="$(node -e 'console.log(require("crypto").randomBytes(16).toString("hex"))')"
129
+ wma-anonymize ./watchmyagents-logs
130
+ # → JSON on stdout. Add --out signals.json to write to file.
131
+ ```
132
+
133
+ The salt is a per-customer secret — store it in `.env.local` and reuse it across runs (random salt each run breaks IoC correlation).
134
+
135
+ ### `wma-upload-fortress` — ship anonymized signals to your WMA Fortress
136
+
137
+ Anonymizes your local NDJSON and POSTs the resulting payload to the WMA Fortress cloud control plane, where Guardian AI analyzes patterns and proposes security policies for your agents.
138
+
139
+ ```bash
140
+ export WMA_API_KEY="wma_..." # from Fortress dashboard → Settings → API Keys
141
+ export WMA_FORTRESS_URL="https://<your-project>.supabase.co/functions/v1/ingest-signals"
142
+ export WMA_SIGNALS_SALT="..." # same salt as wma-anonymize
143
+
144
+ wma-upload-fortress --agent-id agent_01XaN... [--display-name "My agent"]
145
+ # → POSTs the anonymized payload. Server returns signal_id + agent_id.
146
+
147
+ # Inspect what WOULD be posted, without uploading:
148
+ wma-upload-fortress --agent-id agent_xxx --dry-run
149
+ ```
150
+
151
+ **What is sent:** counts, latencies, salted IoC hashes, sequences — same as `wma-anonymize` output.
152
+ **What is NOT sent:** raw prompts, raw URLs/commands/queries, raw agent responses, raw error messages. All payload content stays on your machine.
153
+
154
+ 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.
155
+
123
156
  ### `wma-inspect` — audit the logs
124
157
 
125
158
  ```bash
@@ -195,11 +228,12 @@ Decisions are logged to the same NDJSON stream as Watch (`action_type: shield_de
195
228
 
196
229
  - ✅ Watch SDK — Anthropic Managed Agents post-hoc fetch + local audit
197
230
  - ✅ Shield SDK — real-time enforcement (interrupt mode + tool_confirmation mode)
231
+ - ✅ Anonymizer — produce signals payloads (Modèle C: no raw content leaves)
232
+ - ✅ Anonymized telemetry to WMA Fortress cloud (`wma-upload-fortress` in v0.5.0)
233
+ - ✅ Guardian AI (cloud) — automatic policy suggestions from observed behavior
234
+ - ✅ Fortress (cloud) — dashboard + human-in-the-loop validation queue
235
+ - 🚧 Shield policy puller from Fortress (replace local JSON with cloud-synced policies)
198
236
  - 🚧 Encrypted upload to customer's own cloud (S3/GCS/Azure with `age` public-key encryption)
199
- - 🚧 Anonymized telemetry to WMA cloud (opt-in, freemium model)
200
- - 🚧 Guardian AI (cloud) — automatic policy suggestions from observed behavior
201
- - 🚧 Fortress (cloud) — dashboard + human-in-the-loop validation queue
202
- - 🚧 Adapters for in-process agents (Claude SDK, OpenAI, LangChain, generic) — code present in `src/adapters/` but unverified against the new Modèle C architecture; documentation will follow once re-validated
203
237
 
204
238
  ## License
205
239
 
package/SECURITY.md CHANGED
@@ -37,19 +37,25 @@ WMA needs your Anthropic API key to call the Managed Agents REST API on your beh
37
37
 
38
38
  ## Threat model
39
39
 
40
- WMA is built to give visibility into your AI agent's behavior. It is **observational**, not preventive.
40
+ WMA combines **two complementary layers**:
41
+ - **Watch** (`wma-fetch`, `wma-inspect`) — observational. Captures every agent action into local NDJSON for after-the-fact audit.
42
+ - **Shield** (`wma-shield`, shipped in v0.2.0) — preventive. Streams agent events in real time and enforces policies via `user.tool_confirmation` (block before execution when the agent has `permission_policy: always_ask`) or `user.interrupt` (terminate after a violating tool ran, when always_ask is not configured).
41
43
 
42
44
  ### What WMA defends against
43
45
 
44
- - **Blind spots in agent behavior.** Tool calls, prompts, state transitions, errors are all captured for after-the-fact analysis.
46
+ - **Blind spots in agent behavior.** Watch captures tool calls, prompts, state transitions, and errors for after-the-fact analysis.
45
47
  - **Token-only observability tools.** WMA captures every action including zero-token ones (`tool_use`, `state_transition`, etc.) that are the most security-relevant.
48
+ - **Inline policy violations** (Shield). When the agent has `permission_policy: always_ask` configured, Shield blocks tool calls before execution. When not, Shield interrupts the session on first violation (the offending tool already ran, but the agent loop stops).
46
49
  - **Vendor lock-in.** NDJSON is portable; you own the data.
47
50
 
48
51
  ### What WMA does NOT defend against
49
52
 
50
- - **Real-time attack prevention.** WMA observes after events occur. For inline policy gating, see the upcoming Shield product.
51
53
  - **A compromised host.** If an attacker has read access to your user account, they can read the log files. Consider encryption at rest (filesystem-level, or future opt-in via `age`) for sensitive environments.
52
54
  - **Tampering with local logs.** Files are append-only by convention, not enforced. A future release will add a per-line hash chain for tamper-evident audit.
55
+ - **Shield being killed.** Shield is an external process. If killed, the agent runs without enforcement until Shield restarts. Run under a process supervisor (systemd, pm2, docker `restart: always`) in production.
56
+ - **Pre-installation activity.** Shield only enforces from the moment it attaches forward. Past events are not retroactively replayed or re-evaluated.
57
+ - **A malicious policy file.** Shield's policy engine refuses obviously unsafe regex patterns (e.g. catastrophic backtracking) and truncates inputs before regex tests to mitigate ReDoS. But a user-controlled policy file remains a code-adjacent input — treat it as you would treat sourcecode.
58
+ - **A compromised Anthropic API.** WMA trusts the events delivered by Anthropic. This is out of scope.
53
59
  - **A compromised Anthropic API.** WMA trusts the events delivered by Anthropic. This is out of scope.
54
60
 
55
61
  ## Supply chain
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "watchmyagents",
3
- "version": "0.3.0",
4
- "description": "Security observability + real-time policy enforcement for AI agents — local-first NDJSON capture of every agent action (tool calls, prompts, state transitions, errors) plus the Shield CLI that blocks policy violations live on Anthropic Managed Agents.",
3
+ "version": "0.5.0",
4
+ "description": "Security observability + real-time policy enforcement for AI agents. Local-first NDJSON capture, Shield CLI that blocks policy violations live, anonymizer producing signals-only payloads, and an upload command that ships anonymized telemetry to WatchMyAgents Fortress (the cloud control plane).",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "src/",
8
8
  "scripts/inspect.js",
9
9
  "scripts/fetch-anthropic.js",
10
10
  "scripts/shield.js",
11
+ "scripts/anonymize.js",
12
+ "scripts/upload-fortress.js",
11
13
  "README.md",
12
14
  "SECURITY.md",
13
15
  "LICENSE"
@@ -15,12 +17,16 @@
15
17
  "bin": {
16
18
  "wma-inspect": "scripts/inspect.js",
17
19
  "wma-fetch": "scripts/fetch-anthropic.js",
18
- "wma-shield": "scripts/shield.js"
20
+ "wma-shield": "scripts/shield.js",
21
+ "wma-anonymize": "scripts/anonymize.js",
22
+ "wma-upload-fortress": "scripts/upload-fortress.js"
19
23
  },
20
24
  "scripts": {
21
25
  "inspect": "node scripts/inspect.js",
22
26
  "fetch": "node scripts/fetch-anthropic.js",
23
- "shield": "node scripts/shield.js"
27
+ "shield": "node scripts/shield.js",
28
+ "anonymize": "node scripts/anonymize.js",
29
+ "upload-fortress": "node scripts/upload-fortress.js"
24
30
  },
25
31
  "engines": {
26
32
  "node": ">=18.0.0"
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+ // wma-anonymize — produce the anonymized signals payload that Watch would
3
+ // send to Fortress, for inspection / verification.
4
+ //
5
+ // Usage:
6
+ // wma-anonymize <path-to-ndjson-or-dir> [--salt <hex>] [--out <file>]
7
+ //
8
+ // The `--salt` argument MUST be a stable per-customer secret. Using a
9
+ // random salt each run means hashes won't correlate across runs (useless
10
+ // for IoC tracking). Recommended: store the salt in `.env.local` as
11
+ // `WMA_SIGNALS_SALT=...`.
12
+ //
13
+ // If --salt is omitted and WMA_SIGNALS_SALT is set, that's used. Otherwise
14
+ // the script refuses to run (intentional — we don't want users to ship
15
+ // random-salt hashes by accident).
16
+
17
+ import { readdir, stat, writeFile } from 'node:fs/promises';
18
+ import { resolve, join } from 'node:path';
19
+ import { SignalsAggregator, anonymizeFile } from '../src/anonymizer.js';
20
+
21
+ function parseArgs(argv) {
22
+ const out = {};
23
+ for (let i = 0; i < argv.length; i++) {
24
+ const a = argv[i];
25
+ if (a.startsWith('--')) {
26
+ const k = a.slice(2);
27
+ const n = argv[i + 1];
28
+ if (n == null || n.startsWith('--')) out[k] = true;
29
+ else { out[k] = n; i++; }
30
+ } else if (!out._target) {
31
+ out._target = a;
32
+ }
33
+ }
34
+ return out;
35
+ }
36
+
37
+ function die(msg, code = 1) {
38
+ process.stderr.write(`${msg}\n`);
39
+ process.exit(code);
40
+ }
41
+
42
+ async function collectFiles(p) {
43
+ const s = await stat(p).catch(() => null);
44
+ if (!s) return [];
45
+ if (s.isFile()) return p.endsWith('.ndjson') ? [p] : [];
46
+ const out = [];
47
+ for (const name of await readdir(p)) {
48
+ out.push(...(await collectFiles(join(p, name))));
49
+ }
50
+ return out;
51
+ }
52
+
53
+ async function main() {
54
+ const args = parseArgs(process.argv.slice(2));
55
+
56
+ if (!args._target) {
57
+ die(`usage: wma-anonymize <path> [--salt <hex>] [--out <file>]
58
+
59
+ Reads Watch NDJSON logs and produces the anonymized signals payload
60
+ that would be sent to Fortress. Use this to inspect exactly what
61
+ leaves your machine BEFORE any upload feature is enabled.
62
+
63
+ Required: --salt <hex> or WMA_SIGNALS_SALT env var (per-customer secret).
64
+ If you don't have one, generate: node -e "console.log(require('crypto').randomBytes(16).toString('hex'))"
65
+ and save it in .env.local.`);
66
+ }
67
+
68
+ const salt = args.salt || process.env.WMA_SIGNALS_SALT;
69
+ if (!salt) {
70
+ die('error: --salt <hex> or WMA_SIGNALS_SALT env var required (per-customer secret for hashing).\n' +
71
+ ' generate one with: node -e "console.log(require(\'crypto\').randomBytes(16).toString(\'hex\'))"');
72
+ }
73
+ if (args.salt) {
74
+ process.stderr.write('[wma-anonymize] warning: --salt on the command line is visible in shell history.\n' +
75
+ ' Prefer: export WMA_SIGNALS_SALT=...\n');
76
+ }
77
+ if (salt.length < 16) {
78
+ die('error: salt too short (need ≥16 hex chars / ≥8 bytes of entropy)');
79
+ }
80
+
81
+ const target = resolve(args._target);
82
+ const files = await collectFiles(target);
83
+ if (files.length === 0) {
84
+ die(`error: no .ndjson files found at ${target}`);
85
+ }
86
+
87
+ // Aggregate across all files into one big payload (typical: one fetch run)
88
+ const agg = new SignalsAggregator({ salt });
89
+ for (const f of files) {
90
+ const partial = await anonymizeFile(f, { salt });
91
+ // Merge counts (a bit clunky — for the MVP we just re-iterate via agg)
92
+ // Simpler: aggregate over the files using the same agg instance.
93
+ // Re-implement here cleanly:
94
+ void partial;
95
+ }
96
+ // Re-do cleanly with a single aggregator across files:
97
+ const oneAgg = new SignalsAggregator({ salt });
98
+ for (const f of files) {
99
+ const { createReadStream } = await import('node:fs');
100
+ const { createInterface } = await import('node:readline');
101
+ const stream = createReadStream(f, { encoding: 'utf8' });
102
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
103
+ for await (const line of rl) {
104
+ if (!line.trim()) continue;
105
+ let e; try { e = JSON.parse(line); } catch { continue; }
106
+ oneAgg.add(e);
107
+ }
108
+ }
109
+
110
+ const signals = oneAgg.finalize();
111
+
112
+ const json = JSON.stringify(signals, null, 2);
113
+ if (args.out) {
114
+ await writeFile(resolve(args.out), json + '\n', { encoding: 'utf8', mode: 0o600 });
115
+ process.stderr.write(`[wma-anonymize] wrote ${args.out} (${signals._meta.entries_processed} entries processed)\n`);
116
+ } else {
117
+ process.stdout.write(json + '\n');
118
+ }
119
+ }
120
+
121
+ main().catch(e => { process.stderr.write(`error: ${e.stack || e.message}\n`); process.exit(1); });
@@ -57,6 +57,16 @@ async function main() {
57
57
  if (!apiKey) die('error: --api-key or ANTHROPIC_API_KEY required');
58
58
  if (!agentId) die('error: --agent-id required (e.g. agent_01XaNB4M88ZvcW8FoQ5GC14A)');
59
59
 
60
+ // Security: --api-key on the command line ends up in shell history and is
61
+ // visible to other processes via /proc/<pid>/cmdline. Strongly prefer the
62
+ // ANTHROPIC_API_KEY environment variable.
63
+ if (args['api-key']) {
64
+ process.stderr.write(
65
+ '[wma-fetch] warning: --api-key on the command line is visible in shell history and\n' +
66
+ ' in the process list. Prefer: export ANTHROPIC_API_KEY=...\n'
67
+ );
68
+ }
69
+
60
70
  process.stdout.write(`[wma-fetch] resolving agent ${agentId}…\n`);
61
71
  const agent = await getAgent(apiKey, agentId).catch(e => die(`failed to GET agent: ${e.message}`));
62
72
  const rawModel = agent.model || agent.config?.model || null;
@@ -8,10 +8,22 @@
8
8
  // - a directory (recursively scans for .ndjson)
9
9
  // - omitted → defaults to ./watchmyagents-logs
10
10
 
11
- import { readFile, readdir, stat } from 'node:fs/promises';
11
+ import { readdir, stat } from 'node:fs/promises';
12
+ import { createReadStream } from 'node:fs';
13
+ import { createInterface } from 'node:readline';
12
14
  import { join, resolve } from 'node:path';
13
15
  import { TokenTracker } from '../src/tokens.js';
14
16
 
17
+ // Streaming line-by-line reader — bounds memory usage on large NDJSON files
18
+ // (a long-running agent can produce hundreds of MB per day).
19
+ async function* readNdjsonLines(path) {
20
+ const stream = createReadStream(path, { encoding: 'utf8' });
21
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
22
+ for await (const line of rl) {
23
+ if (line.trim()) yield line;
24
+ }
25
+ }
26
+
15
27
  const target = resolve(process.argv[2] || './watchmyagents-logs');
16
28
 
17
29
  async function collectFiles(p) {
@@ -69,9 +81,7 @@ async function main() {
69
81
  let firstTs = null, lastTs = null;
70
82
 
71
83
  for (const f of files) {
72
- const raw = await readFile(f, 'utf8');
73
- for (const line of raw.split('\n')) {
74
- if (!line.trim()) continue;
84
+ for await (const line of readNdjsonLines(f)) {
75
85
  let e; try { e = JSON.parse(line); } catch { continue; }
76
86
  entries.push(e);
77
87
  tracker.record(e);
package/scripts/shield.js CHANGED
@@ -121,7 +121,31 @@ async function runSessionWorker({ sessionId, ctx }) {
121
121
  // Cache is only needed for tool_confirmation mode (lookup by event_id when
122
122
  // requires_action fires). Interrupt mode evaluates synchronously and never
123
123
  // reads the cache, so caching there would just leak memory on long sessions.
124
- const toolUseCache = new Map();
124
+ //
125
+ // Bounded cache: any tool_use whose policy is "always_allow" never appears
126
+ // in requires_action, so without these limits the Map would grow forever
127
+ // on long-running sessions. Two limits enforced:
128
+ // - Maximum 1000 entries (LRU eviction)
129
+ // - TTL 5 minutes (any entry not consumed by requires_action gets dropped)
130
+ const TOOLUSE_CACHE_MAX = 1000;
131
+ const TOOLUSE_CACHE_TTL_MS = 5 * 60 * 1000;
132
+ const toolUseCache = new Map(); // event_id → { event, cachedAt }
133
+
134
+ function cacheToolUse(event) {
135
+ const now = Date.now();
136
+ // TTL sweep: only walk if cache is non-trivial in size (cheap noop otherwise)
137
+ if (toolUseCache.size > 16) {
138
+ for (const [k, v] of toolUseCache) {
139
+ if (now - v.cachedAt > TOOLUSE_CACHE_TTL_MS) toolUseCache.delete(k);
140
+ }
141
+ }
142
+ // LRU cap: drop oldest insertion if over the size limit
143
+ while (toolUseCache.size >= TOOLUSE_CACHE_MAX) {
144
+ const oldest = toolUseCache.keys().next().value;
145
+ toolUseCache.delete(oldest);
146
+ }
147
+ toolUseCache.set(event.id, { event, cachedAt: now });
148
+ }
125
149
 
126
150
  try {
127
151
  for await (const rawEvent of streamWithReconnect({
@@ -166,7 +190,7 @@ async function runSessionWorker({ sessionId, ctx }) {
166
190
 
167
191
  // ── TOOL_CONFIRMATION MODE ──────────────────────────────────────
168
192
  if (mode === 'tool_confirmation' && CACHEABLE_TOOL_TYPES.has(rawEvent.type)) {
169
- toolUseCache.set(rawEvent.id, rawEvent);
193
+ cacheToolUse(rawEvent);
170
194
  continue;
171
195
  }
172
196
 
@@ -176,7 +200,8 @@ async function runSessionWorker({ sessionId, ctx }) {
176
200
  && Array.isArray(rawEvent.stop_reason.event_ids)) {
177
201
 
178
202
  for (const eventId of rawEvent.stop_reason.event_ids) {
179
- const sourceEvent = toolUseCache.get(eventId);
203
+ const cached = toolUseCache.get(eventId);
204
+ const sourceEvent = cached?.event;
180
205
  if (!sourceEvent) {
181
206
  swarn(sessionId, `requires_action for unknown event_id ${eventId} — denying defensively`);
182
207
  try {
@@ -337,6 +362,15 @@ async function main() {
337
362
  process.exit(0);
338
363
  }
339
364
 
365
+ // Security: --api-key on the command line ends up in shell history and in
366
+ // the process list. Strongly prefer the ANTHROPIC_API_KEY env var.
367
+ if (args['api-key']) {
368
+ process.stderr.write(
369
+ '[shield] warning: --api-key on the command line is visible in shell history and\n' +
370
+ ' in the process list. Prefer: export ANTHROPIC_API_KEY=...\n'
371
+ );
372
+ }
373
+
340
374
  const singleSessionId = args['session-id']; // optional now
341
375
  const policyPath = args.policy;
342
376
  const logDir = resolve(args['log-dir'] || './watchmyagents-logs');
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env node
2
+ // wma-upload-fortress — anonymize local Watch NDJSON and POST signals to
3
+ // the Fortress ingest-signals Edge Function.
4
+ //
5
+ // Composable with the rest of the SDK:
6
+ // wma-fetch → ./watchmyagents-logs/<agent_id>/<date>.ndjson (local capture)
7
+ // wma-anonymize → signals payload (Modèle C: no raw content)
8
+ // wma-upload-fortress → POST signals to https://<project>.supabase.co/functions/v1/ingest-signals
9
+ //
10
+ // Usage:
11
+ // wma-upload-fortress --agent-id agent_xxx \
12
+ // [--log-dir ./watchmyagents-logs] \
13
+ // [--fortress-url https://<project>.supabase.co/functions/v1/ingest-signals] \
14
+ // [--api-key wma_...] \
15
+ // [--salt <hex>] \
16
+ // [--display-name "My agent"] \
17
+ // [--dry-run]
18
+ //
19
+ // Env vars (preferred over CLI flags):
20
+ // WMA_API_KEY — the wma_xxx key from the Fortress dashboard
21
+ // WMA_FORTRESS_URL — full URL to the ingest-signals endpoint
22
+ // WMA_SIGNALS_SALT — per-customer hex salt for IoC hashing
23
+ // (must be stable across runs)
24
+
25
+ import { request as httpsRequest } from 'node:https';
26
+ import { URL } from 'node:url';
27
+ import { readdir, stat } from 'node:fs/promises';
28
+ import { join, resolve } from 'node:path';
29
+ import { createReadStream } from 'node:fs';
30
+ import { createInterface } from 'node:readline';
31
+ import { SignalsAggregator } from '../src/anonymizer.js';
32
+
33
+ function parseArgs(argv) {
34
+ const out = {};
35
+ for (let i = 0; i < argv.length; i++) {
36
+ const a = argv[i];
37
+ if (a.startsWith('--')) {
38
+ const k = a.slice(2);
39
+ const n = argv[i + 1];
40
+ if (n == null || n.startsWith('--')) out[k] = true;
41
+ else { out[k] = n; i++; }
42
+ }
43
+ }
44
+ return out;
45
+ }
46
+
47
+ function die(msg, code = 1) { process.stderr.write(`${msg}\n`); process.exit(code); }
48
+ function info(msg) { process.stdout.write(`[wma-upload-fortress] ${msg}\n`); }
49
+ function warn(msg) { process.stderr.write(`[wma-upload-fortress] ⚠️ ${msg}\n`); }
50
+
51
+ async function collectFiles(p) {
52
+ const s = await stat(p).catch(() => null);
53
+ if (!s) return [];
54
+ if (s.isFile()) return p.endsWith('.ndjson') && !p.includes('raw-') ? [p] : [];
55
+ const out = [];
56
+ for (const name of await readdir(p)) {
57
+ out.push(...(await collectFiles(join(p, name))));
58
+ }
59
+ return out;
60
+ }
61
+
62
+ function postJson(url, headers, body) {
63
+ return new Promise((resolveReq, rejectReq) => {
64
+ const u = new URL(url);
65
+ if (u.protocol !== 'https:') {
66
+ return rejectReq(new Error(`refusing non-https fortress URL: ${url}`));
67
+ }
68
+ const data = Buffer.from(body);
69
+ const req = httpsRequest(
70
+ {
71
+ method: 'POST',
72
+ hostname: u.hostname,
73
+ port: u.port || 443,
74
+ path: u.pathname + u.search,
75
+ headers: {
76
+ ...headers,
77
+ 'content-type': 'application/json',
78
+ 'content-length': data.length,
79
+ },
80
+ rejectUnauthorized: true,
81
+ },
82
+ (res) => {
83
+ const chunks = [];
84
+ res.on('data', (c) => chunks.push(c));
85
+ res.on('end', () => {
86
+ const raw = Buffer.concat(chunks).toString('utf8');
87
+ let parsed = null;
88
+ try { parsed = JSON.parse(raw); } catch { /* keep raw */ }
89
+ resolveReq({ status: res.statusCode || 0, body: parsed ?? raw });
90
+ });
91
+ }
92
+ );
93
+ req.on('error', rejectReq);
94
+ req.write(data);
95
+ req.end();
96
+ });
97
+ }
98
+
99
+ async function main() {
100
+ const args = parseArgs(process.argv.slice(2));
101
+
102
+ const agentId = args['agent-id'];
103
+ const logDir = resolve(args['log-dir'] || './watchmyagents-logs');
104
+ const fortressUrl = args['fortress-url'] || process.env.WMA_FORTRESS_URL;
105
+ const apiKey = args['api-key'] || process.env.WMA_API_KEY;
106
+ const salt = args.salt || process.env.WMA_SIGNALS_SALT;
107
+ const displayName = args['display-name'] || agentId;
108
+ const dryRun = !!args['dry-run'];
109
+
110
+ // Validation
111
+ if (!agentId) die('error: --agent-id required (Anthropic agent_id, e.g. agent_01XaN...)');
112
+ // Strict alphanumeric to prevent path traversal in collectFiles below
113
+ // (--agent-id ends up as a filesystem path segment).
114
+ if (!/^agent_[a-zA-Z0-9]+$/.test(agentId)) {
115
+ die(`error: --agent-id has invalid format (expected "agent_" + alphanumeric, got "${agentId}")`);
116
+ }
117
+ if (!dryRun && !fortressUrl) {
118
+ die('error: --fortress-url or WMA_FORTRESS_URL required (full URL to /functions/v1/ingest-signals).\n' +
119
+ ' Use --dry-run to print the payload without uploading.');
120
+ }
121
+ if (!dryRun && !apiKey) {
122
+ die('error: --api-key or WMA_API_KEY required.\n' +
123
+ ' Get one from your Fortress dashboard → Settings → API Keys.');
124
+ }
125
+ if (!dryRun && apiKey && !/^wma_[a-f0-9]{32}$/i.test(apiKey)) {
126
+ warn(`API key format looks unusual (expected "wma_<32hex>", got "${apiKey.slice(0, 8)}…").`);
127
+ }
128
+ if (!salt) {
129
+ die('error: --salt or WMA_SIGNALS_SALT required (per-customer hex secret for hashing IoCs).\n' +
130
+ ' Generate once with: node -e "console.log(require(\'crypto\').randomBytes(16).toString(\'hex\'))"\n' +
131
+ ' Store stably in .env.local.');
132
+ }
133
+ if (salt.length < 16) die('error: salt too short (need ≥16 hex chars)');
134
+
135
+ // Warn about CLI-passed secrets
136
+ if (args['api-key']) {
137
+ warn('--api-key on the command line is visible in shell history and process list.\n' +
138
+ ' Prefer: export WMA_API_KEY=...');
139
+ }
140
+ if (args.salt) {
141
+ warn('--salt on the command line is visible in shell history.\n' +
142
+ ' Prefer: export WMA_SIGNALS_SALT=...');
143
+ }
144
+
145
+ // Discover the agent's NDJSON files
146
+ const agentDir = join(logDir, agentId);
147
+ const files = await collectFiles(agentDir);
148
+ if (files.length === 0) {
149
+ die(`error: no .ndjson files found under ${agentDir}. Run wma-fetch first?`);
150
+ }
151
+ info(`scanning ${files.length} ndjson file(s) under ${agentDir}`);
152
+
153
+ // Aggregate into a single signals payload
154
+ const agg = new SignalsAggregator({ salt });
155
+ for (const f of files) {
156
+ const stream = createReadStream(f, { encoding: 'utf8' });
157
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
158
+ for await (const line of rl) {
159
+ if (!line.trim()) continue;
160
+ let e; try { e = JSON.parse(line); } catch { continue; }
161
+ agg.add(e);
162
+ }
163
+ }
164
+ const signals = agg.finalize();
165
+ if (!signals.window_start || !signals.window_end) {
166
+ die('error: no entries had timestamps — nothing to upload');
167
+ }
168
+
169
+ const body = {
170
+ anthropic_agent_id: agentId,
171
+ display_name: displayName,
172
+ window_start: signals.window_start,
173
+ window_end: signals.window_end,
174
+ payload: signals.payload,
175
+ };
176
+ const bodyJson = JSON.stringify(body);
177
+
178
+ info(`payload built: ${signals._meta.entries_processed} entries → ${bodyJson.length} bytes`);
179
+ info(`window: ${signals.window_start} → ${signals.window_end}`);
180
+ info(`ioc_hashes: ${signals.payload.ioc_hashes.length}, tool_counts: ${Object.keys(signals.payload.tool_counts).length}`);
181
+
182
+ if (dryRun) {
183
+ info('--dry-run: payload that WOULD be POSTed:');
184
+ process.stdout.write(JSON.stringify(body, null, 2) + '\n');
185
+ return;
186
+ }
187
+
188
+ // POST it
189
+ info(`POST ${fortressUrl}`);
190
+ const { status, body: respBody } = await postJson(
191
+ fortressUrl,
192
+ { authorization: `Bearer ${apiKey}` },
193
+ bodyJson
194
+ );
195
+
196
+ if (status >= 200 && status < 300) {
197
+ info(`✅ HTTP ${status}`);
198
+ if (typeof respBody === 'object' && respBody.signal_id) {
199
+ info(`signal_id: ${respBody.signal_id}`);
200
+ info(`agent_id: ${respBody.agent_id}`);
201
+ if (respBody.registered_new_agent) info('🆕 agent was auto-registered on this upload');
202
+ } else {
203
+ info(`response: ${typeof respBody === 'string' ? respBody.slice(0, 300) : JSON.stringify(respBody).slice(0, 300)}`);
204
+ }
205
+ } else {
206
+ const msg = typeof respBody === 'object' ? JSON.stringify(respBody) : String(respBody).slice(0, 500);
207
+ die(`error: upload failed (HTTP ${status}): ${msg}`);
208
+ }
209
+ }
210
+
211
+ main().catch((e) => { process.stderr.write(`error: ${e.stack || e.message}\n`); process.exit(1); });
@@ -0,0 +1,206 @@
1
+ // ─────────────────────────────────────────────────────────────────────────
2
+ // Anonymizer — strip raw payloads, produce signals safe for Fortress
3
+ // ─────────────────────────────────────────────────────────────────────────
4
+ // Reads a Watch NDJSON file (the full local log) and produces an
5
+ // anonymized signals payload — the shape Fortress's `signals` table
6
+ // expects. The output contains ONLY:
7
+ //
8
+ // - counts (action_type, tool_name)
9
+ // - latencies (p50, p95, max) per tool
10
+ // - error rates per tool
11
+ // - salted SHA-256 hashes of IoCs (URLs, commands, queries)
12
+ // - top action_type sequences (Markov pairs)
13
+ // - stop_reason type counts (NOT the message text)
14
+ // - tokens_total
15
+ //
16
+ // What it NEVER outputs:
17
+ // - input.content (prompts)
18
+ // - output.content (agent text)
19
+ // - raw URLs / commands / queries
20
+ // - error messages
21
+ // - readable session_id (hashed)
22
+ // - readable agent_id (hashed)
23
+ // - PII of any kind
24
+ //
25
+ // This is the single bottleneck between Watch (local) and Fortress (cloud).
26
+ // Every byte that crosses to the cloud passes through this module.
27
+
28
+ import { createHash, randomBytes } from 'node:crypto';
29
+ import { createReadStream } from 'node:fs';
30
+ import { createInterface } from 'node:readline';
31
+
32
+ // ── Configuration ────────────────────────────────────────────────────────
33
+
34
+ // Fields that may contain raw data — we extract a hash, never the raw value.
35
+ const HASHABLE_INPUT_FIELDS = ['url', 'query', 'command', 'path', 'file_path'];
36
+
37
+ // Tool types whose inputs we want to hash for IoC tracking
38
+ const TOOL_ACTIONS = new Set(['tool_use', 'mcp_tool_use', 'custom_tool_use']);
39
+
40
+ // ── Hash helpers ─────────────────────────────────────────────────────────
41
+
42
+ /**
43
+ * Salted SHA-256 hash. The salt is per-customer (passed in) so the same URL
44
+ * at customer A produces a different hash than at customer B by default —
45
+ * but if a global salt is used, identical IoCs across customers produce
46
+ * identical hashes (the antivirus model for L4 cross-customer intel).
47
+ */
48
+ export function hashWithSalt(value, salt) {
49
+ if (value == null) return null;
50
+ const s = typeof value === 'string' ? value : JSON.stringify(value);
51
+ return 'sha256:' + createHash('sha256').update(salt).update(s).digest('hex').slice(0, 32);
52
+ }
53
+
54
+ // Generate a customer salt (if none provided)
55
+ export function generateSalt() {
56
+ return randomBytes(16).toString('hex');
57
+ }
58
+
59
+ // ── Single-entry extractor: what hashable IoCs are in this entry? ────────
60
+
61
+ function extractIocs(entry, salt) {
62
+ const out = [];
63
+ if (!entry.input || typeof entry.input !== 'object') return out;
64
+ for (const field of HASHABLE_INPUT_FIELDS) {
65
+ const v = entry.input[field];
66
+ if (typeof v === 'string' && v.length > 0) {
67
+ out.push(hashWithSalt(v, salt));
68
+ }
69
+ }
70
+ return out;
71
+ }
72
+
73
+ // ── Aggregator: walks the NDJSON stream and builds the signals payload ──
74
+
75
+ export class SignalsAggregator {
76
+ constructor({ salt } = {}) {
77
+ if (!salt) throw new Error('SignalsAggregator requires a salt');
78
+ this.salt = salt;
79
+ this.counts = Object.create(null); // action_type → count
80
+ this.toolCounts = Object.create(null); // tool_name → count
81
+ this.toolErrors = Object.create(null); // tool_name → error count
82
+ this.toolLatencies = Object.create(null); // tool_name → number[]
83
+ this.iocHashes = new Set(); // unique IoC hashes
84
+ this.sequences = Object.create(null); // "A → B" → count
85
+ this.stopReasons = Object.create(null); // stop_reason.type → count
86
+ this.tokensTotal = 0;
87
+ this.windowStart = null;
88
+ this.windowEnd = null;
89
+ this.entryCount = 0;
90
+ this._prevActionType = null;
91
+ this._prevSessionId = null;
92
+ }
93
+
94
+ add(entry) {
95
+ if (!entry) return;
96
+ this.entryCount++;
97
+
98
+ // Track window bounds
99
+ const ts = entry.timestamp || '';
100
+ if (ts) {
101
+ if (!this.windowStart || ts < this.windowStart) this.windowStart = ts;
102
+ if (!this.windowEnd || ts > this.windowEnd) this.windowEnd = ts;
103
+ }
104
+
105
+ // Counts
106
+ const at = entry.action_type || 'unknown';
107
+ this.counts[at] = (this.counts[at] || 0) + 1;
108
+
109
+ // Sequence tracking (only within the same session)
110
+ if (this._prevActionType && entry.session_id === this._prevSessionId
111
+ && at !== 'session_end' && this._prevActionType !== 'session_end') {
112
+ const seqKey = `${this._prevActionType} → ${at}`;
113
+ this.sequences[seqKey] = (this.sequences[seqKey] || 0) + 1;
114
+ }
115
+ this._prevActionType = at;
116
+ this._prevSessionId = entry.session_id || null;
117
+
118
+ // Tools
119
+ if (entry.tool_name && TOOL_ACTIONS.has(at)) {
120
+ this.toolCounts[entry.tool_name] = (this.toolCounts[entry.tool_name] || 0) + 1;
121
+ if (entry.status === 'error') {
122
+ this.toolErrors[entry.tool_name] = (this.toolErrors[entry.tool_name] || 0) + 1;
123
+ }
124
+ if (typeof entry.duration_ms === 'number') {
125
+ if (!this.toolLatencies[entry.tool_name]) this.toolLatencies[entry.tool_name] = [];
126
+ this.toolLatencies[entry.tool_name].push(entry.duration_ms);
127
+ }
128
+ // Extract & hash IoCs from this tool's input
129
+ for (const h of extractIocs(entry, this.salt)) this.iocHashes.add(h);
130
+ }
131
+
132
+ // Tokens
133
+ if (typeof entry.tokens_used === 'number') this.tokensTotal += entry.tokens_used;
134
+
135
+ // Stop reasons (state_transition entries carry these)
136
+ const stopType = entry.output?.stop_reason?.type;
137
+ if (typeof stopType === 'string') {
138
+ this.stopReasons[stopType] = (this.stopReasons[stopType] || 0) + 1;
139
+ }
140
+ }
141
+
142
+ // Compute p50/p95/max for an array of durations
143
+ _percentiles(arr) {
144
+ if (arr.length === 0) return null;
145
+ const sorted = [...arr].sort((a, b) => a - b);
146
+ const at = (p) => sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))];
147
+ return { p50: at(50), p95: at(95), max: sorted[sorted.length - 1], n: sorted.length };
148
+ }
149
+
150
+ finalize() {
151
+ // Latencies aggregated
152
+ const latencies_p50_ms = {};
153
+ const latencies_p95_ms = {};
154
+ const error_rate_by_tool = {};
155
+ for (const [tool, durations] of Object.entries(this.toolLatencies)) {
156
+ const p = this._percentiles(durations);
157
+ if (p) {
158
+ latencies_p50_ms[tool] = p.p50;
159
+ latencies_p95_ms[tool] = p.p95;
160
+ }
161
+ }
162
+ for (const tool of Object.keys(this.toolCounts)) {
163
+ const errs = this.toolErrors[tool] || 0;
164
+ error_rate_by_tool[tool] = +(errs / this.toolCounts[tool]).toFixed(4);
165
+ }
166
+ // Top-10 sequences
167
+ const sequencesTop = Object.entries(this.sequences)
168
+ .sort((a, b) => b[1] - a[1])
169
+ .slice(0, 10)
170
+ .map(([pattern, count]) => ({ pattern, count }));
171
+
172
+ return {
173
+ window_start: this.windowStart,
174
+ window_end: this.windowEnd,
175
+ payload: {
176
+ counts: this.counts,
177
+ tool_counts: this.toolCounts,
178
+ latencies_p50_ms,
179
+ latencies_p95_ms,
180
+ error_rate_by_tool,
181
+ ioc_hashes: [...this.iocHashes],
182
+ sequences_top10: sequencesTop,
183
+ stop_reasons: this.stopReasons,
184
+ tokens_total: this.tokensTotal,
185
+ },
186
+ _meta: {
187
+ entries_processed: this.entryCount,
188
+ },
189
+ };
190
+ }
191
+ }
192
+
193
+ // ── Streaming convenience: anonymize a whole NDJSON file/dir ────────────
194
+
195
+ export async function anonymizeFile(filePath, { salt } = {}) {
196
+ if (!salt) throw new Error('anonymizeFile requires a salt');
197
+ const agg = new SignalsAggregator({ salt });
198
+ const stream = createReadStream(filePath, { encoding: 'utf8' });
199
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
200
+ for await (const line of rl) {
201
+ if (!line.trim()) continue;
202
+ let e; try { e = JSON.parse(line); } catch { continue; }
203
+ agg.add(e);
204
+ }
205
+ return agg.finalize();
206
+ }
package/src/logger.js CHANGED
@@ -12,11 +12,17 @@ const EXPORT_FIELDS = [
12
12
  ];
13
13
 
14
14
  export class Logger {
15
- constructor({ logDir, agentId, sessionId, silent }) {
15
+ // `silent` : don't print log errors to stderr (default: true quiet operation)
16
+ // `bestEffort` : SWALLOW write failures (default: false — fail loud).
17
+ // Audit-grade default: refuse to silently lose events. Disk
18
+ // full / EACCES / EINVAL must propagate so callers know.
19
+ // Opt into bestEffort=true only for non-critical paths.
20
+ constructor({ logDir, agentId, sessionId, silent, bestEffort } = {}) {
16
21
  this.logDir = logDir;
17
22
  this.agentId = agentId;
18
23
  this.sessionId = sessionId || randomUUID();
19
24
  this.silent = silent !== false;
25
+ this.bestEffort = bestEffort === true;
20
26
  this.sequence = 0;
21
27
  this.currentDay = null;
22
28
  this.currentPath = null;
@@ -63,7 +69,10 @@ export class Logger {
63
69
  await appendFile(path, JSON.stringify(full) + '\n', { encoding: 'utf8', mode: 0o600 });
64
70
  this.count++;
65
71
  } catch (err) {
66
- if (!this.silent) process.stderr.write(`[wma] log error: ${err.message}\n`);
72
+ if (!this.silent) process.stderr.write(`[wma] log write error: ${err.message}\n`);
73
+ // Audit-grade default: fail loud so callers know events are being lost.
74
+ // Disk full, EACCES, EINVAL etc. should NOT be silently swallowed.
75
+ if (!this.bestEffort) throw err;
67
76
  }
68
77
  return full;
69
78
  }
@@ -42,12 +42,46 @@ export async function loadPolicies(path) {
42
42
  return data;
43
43
  }
44
44
 
45
+ // ReDoS protection: regexes are loaded from a user-provided JSON policy file,
46
+ // so a malicious or buggy pattern (e.g. `(a+)+$`) could pin the CPU on a long
47
+ // input. We mitigate two ways:
48
+ // 1) Cap the maximum input length passed to any regex test to MAX_REGEX_INPUT
49
+ // bytes. Above that we truncate before testing. Real agent values
50
+ // (URLs, commands, queries) are well under this in practice.
51
+ // 2) Reject obviously dangerous patterns at compile time (heuristic).
52
+ //
53
+ // A future v0.5 may add a proper safe-regex-2 dependency for thorough analysis.
54
+ const MAX_REGEX_INPUT = 8192;
55
+
56
+ const SUSPICIOUS_REGEX_PATTERNS = [
57
+ /(\([^)]*[+*][^)]*\))[+*]/, // (x+)+ or (x*)* — classic catastrophic backtracking
58
+ /(\.\*){3,}/, // multiple .* in a row
59
+ ];
60
+
61
+ function validateRegexString(src, where) {
62
+ if (typeof src !== 'string') {
63
+ throw new Error(`policy ${where}: regex must be a string`);
64
+ }
65
+ if (src.length > 2000) {
66
+ throw new Error(`policy ${where}: regex too long (>2000 chars)`);
67
+ }
68
+ for (const sus of SUSPICIOUS_REGEX_PATTERNS) {
69
+ if (sus.test(src)) {
70
+ throw new Error(`policy ${where}: regex looks vulnerable to catastrophic backtracking ("${src.slice(0, 60)}…"). Refusing to load.`);
71
+ }
72
+ }
73
+ return new RegExp(src);
74
+ }
75
+
45
76
  function compileMatchRegexes(match) {
46
- for (const condition of Object.values(match)) {
77
+ for (const [field, condition] of Object.entries(match)) {
47
78
  if (condition && typeof condition === 'object') {
48
- if (condition.regex) condition._regex = new RegExp(condition.regex);
49
- if (condition.not_regex) condition._not_regex = new RegExp(condition.not_regex);
50
- if (condition.regex_any) condition._regex_any = condition.regex_any.map(r => new RegExp(r));
79
+ if (condition.regex) condition._regex = validateRegexString(condition.regex, `${field}.regex`);
80
+ if (condition.not_regex) condition._not_regex = validateRegexString(condition.not_regex, `${field}.not_regex`);
81
+ if (condition.regex_any) {
82
+ condition._regex_any = condition.regex_any.map((r, i) =>
83
+ validateRegexString(r, `${field}.regex_any[${i}]`));
84
+ }
51
85
  }
52
86
  }
53
87
  }
@@ -56,6 +90,15 @@ function getNested(obj, path) {
56
90
  return path.split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj);
57
91
  }
58
92
 
93
+ // Truncate input before passing to regex test — guards against ReDoS on
94
+ // pathologically long values (e.g. an agent that pastes a 5MB string into
95
+ // a tool argument).
96
+ function safeRegexTest(re, value) {
97
+ if (typeof value !== 'string') return false;
98
+ const s = value.length > MAX_REGEX_INPUT ? value.slice(0, MAX_REGEX_INPUT) : value;
99
+ return re.test(s);
100
+ }
101
+
59
102
  function matchValue(value, condition) {
60
103
  // Literal scalar match
61
104
  if (condition === null || typeof condition !== 'object') {
@@ -67,13 +110,13 @@ function matchValue(value, condition) {
67
110
  if (condition.in !== undefined) return condition.in.includes(value);
68
111
  if (condition.not_in !== undefined) return !condition.not_in.includes(value);
69
112
  if (condition._regex !== undefined) {
70
- return typeof value === 'string' && condition._regex.test(value);
113
+ return safeRegexTest(condition._regex, value);
71
114
  }
72
115
  if (condition._not_regex !== undefined) {
73
- return typeof value === 'string' && !condition._not_regex.test(value);
116
+ return typeof value === 'string' && !safeRegexTest(condition._not_regex, value);
74
117
  }
75
118
  if (condition._regex_any !== undefined) {
76
- return typeof value === 'string' && condition._regex_any.some(r => r.test(value));
119
+ return condition._regex_any.some(r => safeRegexTest(r, value));
77
120
  }
78
121
  // Unknown condition shape — defensive: fail-closed (no match) so unknown
79
122
  // conditions never silently allow events.