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 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` (shipped in v0.2.0) is the real-time enforcement companion to Watch. It streams agent events live, evaluates them against a local JSON policy file, 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).
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
- Shield auto-detects the best enforcement mode at startup:
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 is built to give visibility into your AI agent's behavior. It is **observational**, not preventive.
40
+ WMA combines **two complementary layers**:
41
+ - **Watch** (`wma-fetch`, `wma-inspect`) — observational. Captures every agent action into local NDJSON for after-the-fact audit.
42
+ - **Shield** (`wma-shield`, shipped in v0.2.0) — preventive. Streams agent events in real time and enforces policies via `user.tool_confirmation` (block before execution when the agent has `permission_policy: always_ask`) or `user.interrupt` (terminate after a violating tool ran, when always_ask is not configured).
41
43
 
42
44
  ### What WMA defends against
43
45
 
44
- - **Blind spots in agent behavior.** Tool calls, prompts, state transitions, errors are all captured for after-the-fact analysis.
46
+ - **Blind spots in agent behavior.** Watch captures tool calls, prompts, state transitions, and errors for after-the-fact analysis.
45
47
  - **Token-only observability tools.** WMA captures every action including zero-token ones (`tool_use`, `state_transition`, etc.) that are the most security-relevant.
48
+ - **Inline policy violations** (Shield). When the agent has `permission_policy: always_ask` configured, Shield blocks tool calls before execution. When not, Shield interrupts the session on first violation (the offending tool already ran, but the agent loop stops).
46
49
  - **Vendor lock-in.** NDJSON is portable; you own the data.
47
50
 
48
51
  ### What WMA does NOT defend against
49
52
 
50
- - **Real-time attack prevention.** WMA observes after events occur. For inline policy gating, see the upcoming Shield product.
51
53
  - **A compromised host.** If an attacker has read access to your user account, they can read the log files. Consider encryption at rest (filesystem-level, or future opt-in via `age`) for sensitive environments.
52
54
  - **Tampering with local logs.** Files are append-only by convention, not enforced. A future release will add a per-line hash chain for tamper-evident audit.
55
+ - **Shield being killed.** Shield is an external process. If killed, the agent runs without enforcement until Shield restarts. Run under a process supervisor (systemd, pm2, docker `restart: always`) in production.
56
+ - **Pre-installation activity.** Shield only enforces from the moment it attaches forward. Past events are not retroactively replayed or re-evaluated.
57
+ - **A malicious policy file.** Shield's policy engine refuses obviously unsafe regex patterns (e.g. catastrophic backtracking) and truncates inputs before regex tests to mitigate ReDoS. But a user-controlled policy file remains a code-adjacent input — treat it as you would treat sourcecode.
58
+ - **A compromised Anthropic API.** WMA trusts the events delivered by Anthropic. This is out of scope.
53
59
  - **A compromised Anthropic API.** WMA trusts the events delivered by Anthropic. This is out of scope.
54
60
 
55
61
  ## Supply chain
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "watchmyagents",
3
- "version": "0.3.0",
4
- "description": "Security observability + real-time policy enforcement for AI agents — local-first NDJSON capture of every agent action (tool calls, prompts, state transitions, errors) plus the Shield CLI that blocks policy violations live on Anthropic Managed Agents.",
3
+ "version": "0.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;
@@ -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
@@ -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, ruleset, mode, decisions, signal } = ctx;
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
- const toolUseCache = new Map();
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
- toolUseCache.set(rawEvent.id, rawEvent);
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 sourceEvent = toolUseCache.get(eventId);
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
- let ruleset;
349
- try {
350
- ruleset = await loadPolicies(resolve(policyPath));
351
- } catch (e) {
352
- die(`error loading policies: ${e.message}`);
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
- info(`armed ${ruleset.policies.length} policies loaded from ${policyPath}`);
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', () => { info('SIGINT received, shutting down…'); ac.abort(); });
385
- process.on('SIGTERM', () => { info('SIGTERM received, shutting down…'); ac.abort(); });
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
- const ctx = { apiKey, agentId, ruleset, mode, decisions, signal: ac.signal };
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}`);