watchmyagents 0.3.0 → 0.6.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 +59 -9
- package/SECURITY.md +9 -3
- package/package.json +10 -4
- package/scripts/anonymize.js +121 -0
- package/scripts/fetch-anthropic.js +10 -0
- package/scripts/inspect.js +14 -4
- package/scripts/shield.js +164 -16
- package/scripts/upload-fortress.js +222 -0
- package/src/anonymizer.js +206 -0
- package/src/fortress/url.js +59 -0
- package/src/logger.js +11 -2
- package/src/shield/policy.js +50 -7
- package/src/shield/sources/fortress.js +203 -0
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
|
|
@@ -172,15 +205,30 @@ Report vulnerabilities via [SECURITY.md](./SECURITY.md).
|
|
|
172
205
|
|
|
173
206
|
## Shield — real-time policy enforcement
|
|
174
207
|
|
|
175
|
-
`wma-shield`
|
|
208
|
+
`wma-shield` is the real-time enforcement companion to Watch. It streams agent events live, evaluates them against a policy ruleset, and blocks tool calls that violate the policy via `user.tool_confirmation` (when the agent has `permission_policy: always_ask` configured) or `user.interrupt` (zero-setup fallback).
|
|
176
209
|
|
|
210
|
+
### Two policy sources (v0.6.0+)
|
|
211
|
+
|
|
212
|
+
**Local JSON** (standalone — no cloud dependency):
|
|
177
213
|
```bash
|
|
178
|
-
# Agent-wide mode — attaches to ALL active sessions of the agent automatically.
|
|
179
|
-
# Run under a process supervisor (systemd, pm2, docker) for production.
|
|
180
214
|
wma-shield --agent-id agent_xxx --policy ./policies.json
|
|
181
215
|
```
|
|
182
216
|
|
|
183
|
-
|
|
217
|
+
**Fortress cloud** (policies managed in the dashboard, auto-refreshed every 5 min):
|
|
218
|
+
```bash
|
|
219
|
+
export ANTHROPIC_API_KEY="sk-ant-..."
|
|
220
|
+
export WMA_API_KEY="wma_..."
|
|
221
|
+
export WMA_FORTRESS_BASE_URL="https://<project>.supabase.co/functions/v1"
|
|
222
|
+
export WMA_SIGNALS_SALT="..." # same salt as wma-upload-fortress (for cross-table IoC correlation)
|
|
223
|
+
|
|
224
|
+
wma-shield --agent-id agent_xxx --policies-source fortress
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
In Fortress mode, Shield also POSTs each enforcement decision back to Fortress (`/functions/v1/ingest-decisions`), so the dashboard's live timeline + Loop Visualizer light up in real time.
|
|
228
|
+
|
|
229
|
+
### Enforcement mode auto-detection
|
|
230
|
+
|
|
231
|
+
Shield auto-detects the best mode at startup:
|
|
184
232
|
- **tool_confirmation** (precise, pre-execution blocking) when at least one tool has `permission_policy: always_ask`
|
|
185
233
|
- **interrupt** (degraded, post-execution termination) otherwise
|
|
186
234
|
|
|
@@ -195,11 +243,13 @@ Decisions are logged to the same NDJSON stream as Watch (`action_type: shield_de
|
|
|
195
243
|
|
|
196
244
|
- ✅ Watch SDK — Anthropic Managed Agents post-hoc fetch + local audit
|
|
197
245
|
- ✅ Shield SDK — real-time enforcement (interrupt mode + tool_confirmation mode)
|
|
246
|
+
- ✅ Anonymizer — produce signals payloads (Modèle C: no raw content leaves)
|
|
247
|
+
- ✅ Anonymized telemetry to WMA Fortress cloud (`wma-upload-fortress` in v0.5.0)
|
|
248
|
+
- ✅ Guardian AI (cloud) — automatic policy suggestions from observed behavior
|
|
249
|
+
- ✅ Fortress (cloud) — dashboard + human-in-the-loop validation queue
|
|
250
|
+
- ✅ Shield policy puller from Fortress (`wma-shield --policies-source fortress` in v0.6.0)
|
|
251
|
+
- ✅ Shield decisions push to Fortress (live timeline + Loop Visualizer)
|
|
198
252
|
- 🚧 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
253
|
|
|
204
254
|
## License
|
|
205
255
|
|
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,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "watchmyagents",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Security observability + real-time policy enforcement for AI agents
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Security observability + real-time policy enforcement for AI agents. Local-first NDJSON capture, Shield CLI that blocks policy violations live (with policies pulled from Fortress cloud), anonymizer producing signals-only payloads, and bidirectional sync with WatchMyAgents Fortress — closing the recursive Watch→Guardian→Shield security loop.",
|
|
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;
|
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
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
// ANTHROPIC_API_KEY env var is used if --api-key is omitted.
|
|
26
26
|
|
|
27
27
|
import { resolve } from 'node:path';
|
|
28
|
+
import { createHash } from 'node:crypto';
|
|
28
29
|
import { streamWithReconnect } from '../src/shield/stream.js';
|
|
29
30
|
import { loadPolicies, evaluate } from '../src/shield/policy.js';
|
|
30
31
|
import {
|
|
@@ -33,6 +34,8 @@ import {
|
|
|
33
34
|
} from '../src/shield/enforce.js';
|
|
34
35
|
import { DecisionLogger } from '../src/shield/decisions.js';
|
|
35
36
|
import { listSessions } from '../src/sources/anthropic-managed.js';
|
|
37
|
+
import { FortressPolicySource, postDecision } from '../src/shield/sources/fortress.js';
|
|
38
|
+
import { resolveFortressBase } from '../src/fortress/url.js';
|
|
36
39
|
|
|
37
40
|
function parseArgs(argv) {
|
|
38
41
|
const out = {};
|
|
@@ -114,14 +117,74 @@ After either option, restart Shield — it auto-detects the new mode.
|
|
|
114
117
|
// Per-session worker — runs one event loop, returns when session ends.
|
|
115
118
|
// ────────────────────────────────────────────────────────────────────────
|
|
116
119
|
async function runSessionWorker({ sessionId, ctx }) {
|
|
117
|
-
const { apiKey, agentId,
|
|
120
|
+
const { apiKey, agentId, mode, decisions, signal, pushDecisionToFortress, signalsSalt } = ctx;
|
|
121
|
+
// NOTE: ctx.ruleset is a getter — read it FRESH per evaluation so policy
|
|
122
|
+
// refreshes from Fortress (every 5 min) take effect without restart.
|
|
118
123
|
sinfo(sessionId, `attached (${mode} mode)`);
|
|
119
124
|
|
|
125
|
+
// Helper: hash an IoC value with the customer salt (same one used by
|
|
126
|
+
// anonymizer for signals → correlates decisions to signals in Fortress).
|
|
127
|
+
// Returns null if no salt is configured (decisions still upload, just
|
|
128
|
+
// without input_hash).
|
|
129
|
+
const hashIoc = (value) => {
|
|
130
|
+
if (!signalsSalt || value == null) return null;
|
|
131
|
+
const s = typeof value === 'string' ? value : JSON.stringify(value);
|
|
132
|
+
return 'sha256:' + createHash('sha256').update(signalsSalt).update(s).digest('hex').slice(0, 32);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// Helper: assemble + fire the decision push to Fortress (fire-and-forget).
|
|
136
|
+
const fireToFortress = (rawEvent, normalized, result, decidedInMs) => {
|
|
137
|
+
if (!pushDecisionToFortress) return;
|
|
138
|
+
// Extract the most relevant input value to hash (URL > command > query > path)
|
|
139
|
+
const inp = normalized?.input;
|
|
140
|
+
let inputForHash = null;
|
|
141
|
+
if (inp && typeof inp === 'object') {
|
|
142
|
+
inputForHash = inp.url || inp.command || inp.query || inp.path || inp.file_path || null;
|
|
143
|
+
}
|
|
144
|
+
pushDecisionToFortress({
|
|
145
|
+
anthropic_agent_id: agentId,
|
|
146
|
+
decision: result.decision,
|
|
147
|
+
rule_id: result.rule_id || undefined,
|
|
148
|
+
session_hash: hashIoc(sessionId) || undefined,
|
|
149
|
+
event_id_hash: hashIoc(rawEvent?.id) || undefined,
|
|
150
|
+
input_hash: hashIoc(inputForHash) || undefined,
|
|
151
|
+
action_type: normalized?.action_type || undefined,
|
|
152
|
+
tool_name: normalized?.tool_name || undefined,
|
|
153
|
+
message: result.message || result.rule_name || undefined,
|
|
154
|
+
decided_at: new Date().toISOString(),
|
|
155
|
+
decided_in_ms: decidedInMs,
|
|
156
|
+
}).catch(() => undefined);
|
|
157
|
+
};
|
|
158
|
+
|
|
120
159
|
let processed = 0, enforced = 0, sessionInterrupted = false;
|
|
121
160
|
// Cache is only needed for tool_confirmation mode (lookup by event_id when
|
|
122
161
|
// requires_action fires). Interrupt mode evaluates synchronously and never
|
|
123
162
|
// reads the cache, so caching there would just leak memory on long sessions.
|
|
124
|
-
|
|
163
|
+
//
|
|
164
|
+
// Bounded cache: any tool_use whose policy is "always_allow" never appears
|
|
165
|
+
// in requires_action, so without these limits the Map would grow forever
|
|
166
|
+
// on long-running sessions. Two limits enforced:
|
|
167
|
+
// - Maximum 1000 entries (LRU eviction)
|
|
168
|
+
// - TTL 5 minutes (any entry not consumed by requires_action gets dropped)
|
|
169
|
+
const TOOLUSE_CACHE_MAX = 1000;
|
|
170
|
+
const TOOLUSE_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
171
|
+
const toolUseCache = new Map(); // event_id → { event, cachedAt }
|
|
172
|
+
|
|
173
|
+
function cacheToolUse(event) {
|
|
174
|
+
const now = Date.now();
|
|
175
|
+
// TTL sweep: only walk if cache is non-trivial in size (cheap noop otherwise)
|
|
176
|
+
if (toolUseCache.size > 16) {
|
|
177
|
+
for (const [k, v] of toolUseCache) {
|
|
178
|
+
if (now - v.cachedAt > TOOLUSE_CACHE_TTL_MS) toolUseCache.delete(k);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// LRU cap: drop oldest insertion if over the size limit
|
|
182
|
+
while (toolUseCache.size >= TOOLUSE_CACHE_MAX) {
|
|
183
|
+
const oldest = toolUseCache.keys().next().value;
|
|
184
|
+
toolUseCache.delete(oldest);
|
|
185
|
+
}
|
|
186
|
+
toolUseCache.set(event.id, { event, cachedAt: now });
|
|
187
|
+
}
|
|
125
188
|
|
|
126
189
|
try {
|
|
127
190
|
for await (const rawEvent of streamWithReconnect({
|
|
@@ -137,7 +200,7 @@ async function runSessionWorker({ sessionId, ctx }) {
|
|
|
137
200
|
// No caching in interrupt mode — react synchronously, free memory.
|
|
138
201
|
const normalized = normalizeForPolicy(rawEvent);
|
|
139
202
|
const t0 = Date.now();
|
|
140
|
-
const result = evaluate(normalized, ruleset);
|
|
203
|
+
const result = evaluate(normalized, ctx.ruleset);
|
|
141
204
|
const decidedInMs = Date.now() - t0;
|
|
142
205
|
|
|
143
206
|
sinfo(sessionId, `${rawEvent.type} tool=${normalized.tool_name} → ${result.decision}${result.rule_id ? ` (${result.rule_id})` : ''}`);
|
|
@@ -147,6 +210,7 @@ async function runSessionWorker({ sessionId, ctx }) {
|
|
|
147
210
|
ruleId: result.rule_id, ruleName: result.rule_name,
|
|
148
211
|
message: result.message, decidedInMs,
|
|
149
212
|
});
|
|
213
|
+
fireToFortress(rawEvent, normalized, result, decidedInMs);
|
|
150
214
|
|
|
151
215
|
if ((result.decision === 'deny' || result.decision === 'interrupt') && !sessionInterrupted) {
|
|
152
216
|
try {
|
|
@@ -166,7 +230,7 @@ async function runSessionWorker({ sessionId, ctx }) {
|
|
|
166
230
|
|
|
167
231
|
// ── TOOL_CONFIRMATION MODE ──────────────────────────────────────
|
|
168
232
|
if (mode === 'tool_confirmation' && CACHEABLE_TOOL_TYPES.has(rawEvent.type)) {
|
|
169
|
-
|
|
233
|
+
cacheToolUse(rawEvent);
|
|
170
234
|
continue;
|
|
171
235
|
}
|
|
172
236
|
|
|
@@ -176,7 +240,8 @@ async function runSessionWorker({ sessionId, ctx }) {
|
|
|
176
240
|
&& Array.isArray(rawEvent.stop_reason.event_ids)) {
|
|
177
241
|
|
|
178
242
|
for (const eventId of rawEvent.stop_reason.event_ids) {
|
|
179
|
-
const
|
|
243
|
+
const cached = toolUseCache.get(eventId);
|
|
244
|
+
const sourceEvent = cached?.event;
|
|
180
245
|
if (!sourceEvent) {
|
|
181
246
|
swarn(sessionId, `requires_action for unknown event_id ${eventId} — denying defensively`);
|
|
182
247
|
try {
|
|
@@ -192,7 +257,7 @@ async function runSessionWorker({ sessionId, ctx }) {
|
|
|
192
257
|
|
|
193
258
|
const normalized = normalizeForPolicy(sourceEvent);
|
|
194
259
|
const t0 = Date.now();
|
|
195
|
-
const result = evaluate(normalized, ruleset);
|
|
260
|
+
const result = evaluate(normalized, ctx.ruleset);
|
|
196
261
|
const decidedInMs = Date.now() - t0;
|
|
197
262
|
|
|
198
263
|
sinfo(sessionId, `requires_action ${sourceEvent.type} tool=${normalized.tool_name} → ${result.decision}${result.rule_id ? ` (${result.rule_id})` : ''}`);
|
|
@@ -202,6 +267,7 @@ async function runSessionWorker({ sessionId, ctx }) {
|
|
|
202
267
|
ruleId: result.rule_id, ruleName: result.rule_name,
|
|
203
268
|
message: result.message, decidedInMs,
|
|
204
269
|
});
|
|
270
|
+
fireToFortress(sourceEvent, normalized, result, decidedInMs);
|
|
205
271
|
|
|
206
272
|
try {
|
|
207
273
|
if (result.decision === 'allow') {
|
|
@@ -337,19 +403,64 @@ async function main() {
|
|
|
337
403
|
process.exit(0);
|
|
338
404
|
}
|
|
339
405
|
|
|
406
|
+
// Security: --api-key on the command line ends up in shell history and in
|
|
407
|
+
// the process list. Strongly prefer the ANTHROPIC_API_KEY env var.
|
|
408
|
+
if (args['api-key']) {
|
|
409
|
+
process.stderr.write(
|
|
410
|
+
'[shield] warning: --api-key on the command line is visible in shell history and\n' +
|
|
411
|
+
' in the process list. Prefer: export ANTHROPIC_API_KEY=...\n'
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
340
415
|
const singleSessionId = args['session-id']; // optional now
|
|
341
416
|
const policyPath = args.policy;
|
|
417
|
+
const policiesSource = args['policies-source'] || (policyPath ? 'local' : null);
|
|
418
|
+
const wmaApiKey = args['wma-api-key'] || process.env.WMA_API_KEY;
|
|
419
|
+
const signalsSalt = args['salt'] || process.env.WMA_SIGNALS_SALT;
|
|
420
|
+
const fortressBase = resolveFortressBase({
|
|
421
|
+
explicitBase: args['fortress-base-url'],
|
|
422
|
+
explicitUrl: args['fortress-url'],
|
|
423
|
+
});
|
|
342
424
|
const logDir = resolve(args['log-dir'] || './watchmyagents-logs');
|
|
343
425
|
|
|
344
426
|
if (!apiKey) die('error: --api-key or ANTHROPIC_API_KEY required');
|
|
345
427
|
if (!agentId) die('error: --agent-id required');
|
|
346
|
-
if (!policyPath) die('error: --policy <path-to-policies.json> required');
|
|
347
428
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
429
|
+
// Policies source: --policies-source fortress | local (default infers from --policy)
|
|
430
|
+
let ruleset; // for 'local' mode: static; for 'fortress': initial snapshot
|
|
431
|
+
let fortressPolicies; // FortressPolicySource instance, used as ground truth at runtime
|
|
432
|
+
|
|
433
|
+
if (policiesSource === 'fortress') {
|
|
434
|
+
if (!wmaApiKey) die('error: --policies-source fortress requires --wma-api-key or WMA_API_KEY env');
|
|
435
|
+
if (!fortressBase) die('error: --policies-source fortress requires --fortress-base-url or WMA_FORTRESS_BASE_URL env');
|
|
436
|
+
if (!/^wma_[a-f0-9]{32}$/i.test(wmaApiKey)) warn(`WMA_API_KEY format looks unusual (expected wma_<32hex>).`);
|
|
437
|
+
|
|
438
|
+
fortressPolicies = new FortressPolicySource({
|
|
439
|
+
apiKey: wmaApiKey,
|
|
440
|
+
base: fortressBase,
|
|
441
|
+
anthropicAgentId: agentId,
|
|
442
|
+
refreshIntervalMs: 5 * 60_000,
|
|
443
|
+
onError: (e) => warn(`policy refresh failed (keeping cached): ${e.message}`),
|
|
444
|
+
onRefresh: ({ policies, fetched_at, initial }) => {
|
|
445
|
+
info(`policies ${initial ? 'loaded' : 'refreshed'} from Fortress — ${policies.length} active (fetched_at: ${fetched_at})`);
|
|
446
|
+
},
|
|
447
|
+
});
|
|
448
|
+
try {
|
|
449
|
+
await fortressPolicies.start();
|
|
450
|
+
} catch (e) {
|
|
451
|
+
die(`error fetching policies from Fortress: ${e.message}\n` +
|
|
452
|
+
` Check WMA_FORTRESS_BASE_URL and WMA_API_KEY.`);
|
|
453
|
+
}
|
|
454
|
+
ruleset = fortressPolicies.current();
|
|
455
|
+
} else if (policiesSource === 'local') {
|
|
456
|
+
if (!policyPath) die('error: --policies-source local requires --policy <path-to-policies.json>');
|
|
457
|
+
try {
|
|
458
|
+
ruleset = await loadPolicies(resolve(policyPath));
|
|
459
|
+
} catch (e) {
|
|
460
|
+
die(`error loading policies: ${e.message}`);
|
|
461
|
+
}
|
|
462
|
+
} else {
|
|
463
|
+
die('error: --policy <path> OR --policies-source fortress required');
|
|
353
464
|
}
|
|
354
465
|
|
|
355
466
|
let mode = 'interrupt';
|
|
@@ -361,7 +472,10 @@ async function main() {
|
|
|
361
472
|
warn(`could not fetch agent config (${e.message}). Defaulting to interrupt mode.`);
|
|
362
473
|
}
|
|
363
474
|
|
|
364
|
-
|
|
475
|
+
const sourceLabel = policiesSource === 'fortress'
|
|
476
|
+
? `Fortress (${fortressBase})`
|
|
477
|
+
: policyPath;
|
|
478
|
+
info(`armed — ${ruleset.policies.length} policies loaded from ${sourceLabel}`);
|
|
365
479
|
info(`default action when no rule matches: ${ruleset.default.action}`);
|
|
366
480
|
info(`agent: ${agentId}${agentMeta?.name ? ` "${agentMeta.name}"` : ''}`);
|
|
367
481
|
info(`enforcement mode: ${mode}`);
|
|
@@ -380,11 +494,45 @@ async function main() {
|
|
|
380
494
|
return loggers.get(sessionId);
|
|
381
495
|
};
|
|
382
496
|
|
|
497
|
+
// Optional Fortress decision pusher — only active if we have a wma key + base.
|
|
498
|
+
// In 'fortress' mode this is always available. In 'local' mode it's a fire-
|
|
499
|
+
// and-forget extra channel if both are set.
|
|
500
|
+
const canPushToFortress = !!(wmaApiKey && fortressBase);
|
|
501
|
+
const pushDecisionToFortress = canPushToFortress
|
|
502
|
+
? async (decisionData) => {
|
|
503
|
+
try {
|
|
504
|
+
await postDecision({ apiKey: wmaApiKey, base: fortressBase, decision: decisionData });
|
|
505
|
+
} catch (e) {
|
|
506
|
+
warn(`Fortress decision push failed: ${e.message}`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
: null;
|
|
510
|
+
|
|
383
511
|
const ac = new AbortController();
|
|
384
|
-
process.on('SIGINT', () => {
|
|
385
|
-
|
|
512
|
+
process.on('SIGINT', () => {
|
|
513
|
+
info('SIGINT received, shutting down…');
|
|
514
|
+
if (fortressPolicies) fortressPolicies.stop();
|
|
515
|
+
ac.abort();
|
|
516
|
+
});
|
|
517
|
+
process.on('SIGTERM', () => {
|
|
518
|
+
info('SIGTERM received, shutting down…');
|
|
519
|
+
if (fortressPolicies) fortressPolicies.stop();
|
|
520
|
+
ac.abort();
|
|
521
|
+
});
|
|
386
522
|
|
|
387
|
-
|
|
523
|
+
// ctx exposes a getter for the live ruleset so workers see policy refreshes.
|
|
524
|
+
const ctx = {
|
|
525
|
+
apiKey,
|
|
526
|
+
agentId,
|
|
527
|
+
get ruleset() {
|
|
528
|
+
return fortressPolicies ? fortressPolicies.current() : ruleset;
|
|
529
|
+
},
|
|
530
|
+
mode,
|
|
531
|
+
decisions,
|
|
532
|
+
pushDecisionToFortress,
|
|
533
|
+
signalsSalt,
|
|
534
|
+
signal: ac.signal,
|
|
535
|
+
};
|
|
388
536
|
|
|
389
537
|
if (singleSessionId) {
|
|
390
538
|
info(`single-session mode — attached to ${singleSessionId}`);
|