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 +39 -5
- package/SECURITY.md +9 -3
- package/package.json +13 -18
- package/scripts/anonymize.js +121 -0
- package/scripts/fetch-anthropic.js +10 -0
- package/scripts/inspect.js +14 -4
- package/scripts/shield.js +41 -4
- package/scripts/upload-fortress.js +211 -0
- package/src/anonymizer.js +198 -40
- package/src/logger.js +11 -2
- package/src/shield/policy.js +50 -7
- package/src/adapters/claude.js +0 -46
- package/src/adapters/generic.js +0 -21
- package/src/adapters/langchain.js +0 -42
- package/src/adapters/openai.js +0 -47
- package/src/collector.js +0 -113
- package/src/exporter.js +0 -71
- package/src/index.cjs +0 -36
- package/src/index.js +0 -26
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
|
|
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.**
|
|
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.
|
|
4
|
-
"description": "Security observability for AI 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
|
-
"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
|
-
"
|
|
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": "
|
|
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;
|
package/scripts/inspect.js
CHANGED
|
@@ -8,10 +8,22 @@
|
|
|
8
8
|
// - a directory (recursively scans for .ndjson)
|
|
9
9
|
// - omitted → defaults to ./watchmyagents-logs
|
|
10
10
|
|
|
11
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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); });
|