watchmyagents 0.2.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,25 +1,15 @@
1
1
  {
2
2
  "name": "watchmyagents",
3
- "version": "0.2.0",
4
- "description": "Security observability for AI agents — local-first NDJSON capture of every agent action (tool calls, prompts, state transitions, errors). Built for security audits, not just token counting.",
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
- "main": "./src/index.cjs",
7
- "module": "./src/index.js",
8
- "exports": {
9
- ".": {
10
- "import": "./src/index.js",
11
- "require": "./src/index.cjs"
12
- },
13
- "./adapters/claude": "./src/adapters/claude.js",
14
- "./adapters/openai": "./src/adapters/openai.js",
15
- "./adapters/langchain": "./src/adapters/langchain.js",
16
- "./adapters/generic": "./src/adapters/generic.js"
17
- },
18
6
  "files": [
19
7
  "src/",
20
8
  "scripts/inspect.js",
21
9
  "scripts/fetch-anthropic.js",
22
10
  "scripts/shield.js",
11
+ "scripts/anonymize.js",
12
+ "scripts/upload-fortress.js",
23
13
  "README.md",
24
14
  "SECURITY.md",
25
15
  "LICENSE"
@@ -27,20 +17,23 @@
27
17
  "bin": {
28
18
  "wma-inspect": "scripts/inspect.js",
29
19
  "wma-fetch": "scripts/fetch-anthropic.js",
30
- "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"
31
23
  },
32
24
  "scripts": {
33
25
  "inspect": "node scripts/inspect.js",
34
26
  "fetch": "node scripts/fetch-anthropic.js",
35
27
  "shield": "node scripts/shield.js",
36
- "example": "node examples/claude-agent/index.js"
28
+ "anonymize": "node scripts/anonymize.js",
29
+ "upload-fortress": "node scripts/upload-fortress.js"
37
30
  },
38
31
  "engines": {
39
32
  "node": ">=18.0.0"
40
33
  },
41
34
  "dependencies": {},
42
35
  "devDependencies": {
43
- "@anthropic-ai/sdk": "latest"
36
+ "@anthropic-ai/sdk": "^0.42.0"
44
37
  },
45
38
  "keywords": [
46
39
  "ai",
@@ -54,7 +47,9 @@
54
47
  "claude",
55
48
  "managed-agents",
56
49
  "audit",
57
- "ndjson"
50
+ "ndjson",
51
+ "policy-enforcement",
52
+ "shield"
58
53
  ],
59
54
  "author": "MinedorFBM <minedor@watchmyagents.com>",
60
55
  "license": "MIT",
@@ -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
@@ -118,7 +118,34 @@ async function runSessionWorker({ sessionId, ctx }) {
118
118
  sinfo(sessionId, `attached (${mode} mode)`);
119
119
 
120
120
  let processed = 0, enforced = 0, sessionInterrupted = false;
121
- const toolUseCache = new Map();
121
+ // Cache is only needed for tool_confirmation mode (lookup by event_id when
122
+ // requires_action fires). Interrupt mode evaluates synchronously and never
123
+ // reads the cache, so caching there would just leak memory on long sessions.
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
+ }
122
149
 
123
150
  try {
124
151
  for await (const rawEvent of streamWithReconnect({
@@ -131,7 +158,7 @@ async function runSessionWorker({ sessionId, ctx }) {
131
158
 
132
159
  // ── INTERRUPT MODE ──────────────────────────────────────────────
133
160
  if (mode === 'interrupt' && CACHEABLE_TOOL_TYPES.has(rawEvent.type)) {
134
- toolUseCache.set(rawEvent.id, rawEvent);
161
+ // No caching in interrupt mode — react synchronously, free memory.
135
162
  const normalized = normalizeForPolicy(rawEvent);
136
163
  const t0 = Date.now();
137
164
  const result = evaluate(normalized, ruleset);
@@ -163,7 +190,7 @@ async function runSessionWorker({ sessionId, ctx }) {
163
190
 
164
191
  // ── TOOL_CONFIRMATION MODE ──────────────────────────────────────
165
192
  if (mode === 'tool_confirmation' && CACHEABLE_TOOL_TYPES.has(rawEvent.type)) {
166
- toolUseCache.set(rawEvent.id, rawEvent);
193
+ cacheToolUse(rawEvent);
167
194
  continue;
168
195
  }
169
196
 
@@ -173,7 +200,8 @@ async function runSessionWorker({ sessionId, ctx }) {
173
200
  && Array.isArray(rawEvent.stop_reason.event_ids)) {
174
201
 
175
202
  for (const eventId of rawEvent.stop_reason.event_ids) {
176
- const sourceEvent = toolUseCache.get(eventId);
203
+ const cached = toolUseCache.get(eventId);
204
+ const sourceEvent = cached?.event;
177
205
  if (!sourceEvent) {
178
206
  swarn(sessionId, `requires_action for unknown event_id ${eventId} — denying defensively`);
179
207
  try {
@@ -334,6 +362,15 @@ async function main() {
334
362
  process.exit(0);
335
363
  }
336
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
+
337
374
  const singleSessionId = args['session-id']; // optional now
338
375
  const policyPath = args.policy;
339
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); });