watchmyagents 0.5.0 → 0.8.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.
@@ -0,0 +1,325 @@
1
+ #!/usr/bin/env node
2
+ // wma-service — install WatchMyAgents as an always-on OS background service.
3
+ //
4
+ // Turns the manual `wma-fetch --watch` (and optionally `wma-shield`) commands
5
+ // into OS-native services that start at login, restart on crash, and run with
6
+ // NO terminal — so the WGS loop is truly automatic on the customer's machine.
7
+ //
8
+ // macOS → launchd LaunchAgent (~/Library/LaunchAgents)
9
+ // Linux → systemd user unit (~/.config/systemd/user)
10
+ //
11
+ // One integrated install:
12
+ // wma-service install --agent-id agent_xxx [--interval 5m] [--with-shield]
13
+ // wma-service status
14
+ // wma-service uninstall [--with-shield]
15
+ //
16
+ // Secrets NEVER go in the plist/unit. They're snapshotted (from the current
17
+ // environment) into a protected env file (~/.watchmyagents/env, chmod 600) that
18
+ // the service loads at runtime. Required env at install time:
19
+ // ANTHROPIC_API_KEY, WMA_API_KEY, WMA_FORTRESS_BASE_URL, WMA_SIGNALS_SALT
20
+ // Raw logs stay local (Modèle C); only anonymized signals are uploaded.
21
+
22
+ import os from 'node:os';
23
+ import { mkdirSync, writeFileSync, rmSync, existsSync, chmodSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+ import { execFileSync } from 'node:child_process';
27
+ import { isValidAgentId } from '../src/validate.js';
28
+
29
+ const HOME = os.homedir();
30
+ const PLATFORM = process.platform; // 'darwin' | 'linux' | …
31
+ const UID = typeof process.getuid === 'function' ? process.getuid() : null;
32
+ const NODE = process.execPath; // absolute node binary
33
+ const FETCH_SCRIPT = fileURLToPath(new URL('./fetch-anthropic.js', import.meta.url));
34
+ const SHIELD_SCRIPT = fileURLToPath(new URL('./shield.js', import.meta.url));
35
+
36
+ const CONFIG_DIR = join(HOME, '.watchmyagents');
37
+ const ENV_FILE = join(CONFIG_DIR, 'env');
38
+ const LOG_DIR_DEFAULT = join(CONFIG_DIR, 'logs');
39
+
40
+ const REQUIRED_ENV = ['ANTHROPIC_API_KEY', 'WMA_API_KEY', 'WMA_FORTRESS_BASE_URL', 'WMA_SIGNALS_SALT'];
41
+
42
+ const WATCH_LABEL = 'com.watchmyagents.watch';
43
+ const SHIELD_LABEL = 'com.watchmyagents.shield';
44
+
45
+ function parseArgs(argv) {
46
+ const out = { _: [] };
47
+ for (let i = 0; i < argv.length; i++) {
48
+ const a = argv[i];
49
+ if (a.startsWith('--')) {
50
+ const k = a.slice(2);
51
+ const n = argv[i + 1];
52
+ if (n == null || n.startsWith('--')) out[k] = true;
53
+ else { out[k] = n; i++; }
54
+ } else out._.push(a);
55
+ }
56
+ return out;
57
+ }
58
+
59
+ function die(msg, code = 1) { process.stderr.write(`error: ${msg}\n`); process.exit(code); }
60
+ function info(msg) { process.stdout.write(`[wma-service] ${msg}\n`); }
61
+ function warn(msg) { process.stderr.write(`[wma-service] ⚠️ ${msg}\n`); }
62
+
63
+ function sh(value) { return `"${String(value).replace(/(["$`\\])/g, '\\$1')}"`; }
64
+
65
+ // ── Config (secrets) ──────────────────────────────────────────────────────
66
+ function writeEnvFile() {
67
+ const missing = REQUIRED_ENV.filter((k) => !process.env[k]);
68
+ if (missing.length) {
69
+ die(`missing required env var(s): ${missing.join(', ')}\n` +
70
+ ' Export them in this shell, then re-run install. e.g.:\n' +
71
+ ' export $(grep -v "^#" .env | xargs)\n' +
72
+ ' export WMA_API_KEY=... WMA_FORTRESS_BASE_URL=... WMA_SIGNALS_SALT=...');
73
+ }
74
+ // The env file is sourced by the launcher (set -a; . file) and read by
75
+ // systemd's EnvironmentFile. A newline in a value would inject extra lines /
76
+ // corrupt the file, so reject it. (Our value types — hex salt, wma_/sk-ant
77
+ // keys, https URL — never legitimately contain newlines.)
78
+ for (const k of REQUIRED_ENV) {
79
+ if (/[\r\n]/.test(process.env[k])) die(`${k} contains a newline — refusing to write it to the env file`);
80
+ }
81
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
82
+ // Plain KEY=value lines — readable by both `set -a; . file` (launchd launcher)
83
+ // and systemd's EnvironmentFile=. No quoting needed (no spaces in our values).
84
+ const body = REQUIRED_ENV.map((k) => `${k}=${process.env[k]}`).join('\n') + '\n';
85
+ writeFileSync(ENV_FILE, body, { mode: 0o600 });
86
+ chmodSync(ENV_FILE, 0o600);
87
+ info(`secrets written to ${ENV_FILE} (chmod 600)`);
88
+ }
89
+
90
+ // ── macOS (launchd) ─────────────────────────────────────────────────────--
91
+ function launchAgentsDir() { return join(HOME, 'Library', 'LaunchAgents'); }
92
+ function plistPath(label) { return join(launchAgentsDir(), `${label}.plist`); }
93
+ function launcherPath(label) { return join(CONFIG_DIR, `${label}.launcher.sh`); }
94
+
95
+ function writeLauncher(label, scriptPath, args) {
96
+ const argLine = args.map(sh).join(' ');
97
+ const body = `#!/bin/sh
98
+ # Generated by wma-service. Loads secrets then runs the WMA daemon.
99
+ set -a
100
+ . ${sh(ENV_FILE)}
101
+ set +a
102
+ exec ${sh(NODE)} ${sh(scriptPath)} ${argLine}
103
+ `;
104
+ const p = launcherPath(label);
105
+ writeFileSync(p, body, { mode: 0o700 });
106
+ chmodSync(p, 0o700);
107
+ return p;
108
+ }
109
+
110
+ function writePlist(label, launcher) {
111
+ const outLog = join(CONFIG_DIR, `${label}.out.log`);
112
+ const errLog = join(CONFIG_DIR, `${label}.err.log`);
113
+ // Pre-create the log files 0600 so launchd appends to owner-only files.
114
+ // (No secrets are logged, but defense-in-depth on world-readable home files.)
115
+ for (const lp of [outLog, errLog]) {
116
+ if (!existsSync(lp)) writeFileSync(lp, '', { mode: 0o600 });
117
+ else chmodSync(lp, 0o600);
118
+ }
119
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
120
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
121
+ <plist version="1.0">
122
+ <dict>
123
+ <key>Label</key><string>${label}</string>
124
+ <key>ProgramArguments</key>
125
+ <array><string>${launcher}</string></array>
126
+ <key>RunAtLoad</key><true/>
127
+ <key>KeepAlive</key><true/>
128
+ <key>ProcessType</key><string>Background</string>
129
+ <key>StandardOutPath</key><string>${outLog}</string>
130
+ <key>StandardErrorPath</key><string>${errLog}</string>
131
+ </dict>
132
+ </plist>
133
+ `;
134
+ mkdirSync(launchAgentsDir(), { recursive: true });
135
+ const p = plistPath(label);
136
+ writeFileSync(p, body, { mode: 0o644 });
137
+ return p;
138
+ }
139
+
140
+ function launchctl(args, { ignoreError = false } = {}) {
141
+ try {
142
+ execFileSync('launchctl', args, { stdio: 'pipe' });
143
+ return true;
144
+ } catch (e) {
145
+ if (!ignoreError) warn(`launchctl ${args.join(' ')} failed: ${(e.stderr || e.message).toString().trim().slice(0, 200)}`);
146
+ return false;
147
+ }
148
+ }
149
+
150
+ function macLoad(label, plist) {
151
+ const domain = `gui/${UID}`;
152
+ launchctl(['bootout', `${domain}/${label}`], { ignoreError: true }); // unload prior
153
+ const ok = launchctl(['bootstrap', domain, plist]);
154
+ launchctl(['enable', `${domain}/${label}`], { ignoreError: true });
155
+ if (ok) info(`loaded ${label} (launchd) — running now + at every login`);
156
+ else {
157
+ warn(`could not auto-load ${label}. Load it manually:`);
158
+ process.stdout.write(` launchctl bootstrap gui/${UID} ${plist}\n`);
159
+ }
160
+ }
161
+
162
+ function macUnload(label) {
163
+ const domain = `gui/${UID}`;
164
+ launchctl(['bootout', `${domain}/${label}`], { ignoreError: true });
165
+ for (const p of [plistPath(label), launcherPath(label)]) if (existsSync(p)) rmSync(p);
166
+ info(`removed ${label} (launchd)`);
167
+ }
168
+
169
+ function macInstallOne(label, scriptPath, args) {
170
+ const launcher = writeLauncher(label, scriptPath, args);
171
+ const plist = writePlist(label, launcher);
172
+ macLoad(label, plist);
173
+ }
174
+
175
+ // ── Linux (systemd user) ───────────────────────────────────────────────────
176
+ function systemdDir() { return join(HOME, '.config', 'systemd', 'user'); }
177
+ function unitName(label) { return `${label.replace(/\./g, '-')}.service`; }
178
+ function unitPath(label) { return join(systemdDir(), unitName(label)); }
179
+
180
+ function writeUnit(label, desc, scriptPath, args) {
181
+ // Quote every token for systemd. systemd splits ExecStart on whitespace and
182
+ // does NOT run a shell; double-quotes group tokens and honor \" and \\.
183
+ const sdQuote = (s) => `"${String(s).replace(/(["\\])/g, '\\$1')}"`;
184
+ const exec = [NODE, scriptPath, ...args].map(sdQuote).join(' ');
185
+ const body = `[Unit]
186
+ Description=${desc}
187
+ After=network-online.target
188
+ Wants=network-online.target
189
+
190
+ [Service]
191
+ Type=simple
192
+ EnvironmentFile=${ENV_FILE}
193
+ ExecStart=${exec}
194
+ Restart=always
195
+ RestartSec=10
196
+
197
+ [Install]
198
+ WantedBy=default.target
199
+ `;
200
+ mkdirSync(systemdDir(), { recursive: true });
201
+ const p = unitPath(label);
202
+ writeFileSync(p, body, { mode: 0o644 });
203
+ return p;
204
+ }
205
+
206
+ function systemctl(args, { ignoreError = false } = {}) {
207
+ try { execFileSync('systemctl', ['--user', ...args], { stdio: 'pipe' }); return true; }
208
+ catch (e) { if (!ignoreError) warn(`systemctl --user ${args.join(' ')} failed: ${(e.stderr || e.message).toString().trim().slice(0, 200)}`); return false; }
209
+ }
210
+
211
+ function linuxInstallOne(label, desc, scriptPath, args) {
212
+ writeUnit(label, desc, scriptPath, args);
213
+ const unit = unitName(label);
214
+ systemctl(['daemon-reload'], { ignoreError: true });
215
+ const ok = systemctl(['enable', '--now', unit]);
216
+ if (ok) info(`enabled ${unit} (systemd) — running now + at login. For boot-without-login: loginctl enable-linger ${process.env.USER || ''}`);
217
+ else { warn(`could not auto-enable ${unit}. Enable manually:`); process.stdout.write(` systemctl --user enable --now ${unit}\n`); }
218
+ }
219
+
220
+ function linuxUninstallOne(label) {
221
+ const unit = unitName(label);
222
+ systemctl(['disable', '--now', unit], { ignoreError: true });
223
+ if (existsSync(unitPath(label))) rmSync(unitPath(label));
224
+ systemctl(['daemon-reload'], { ignoreError: true });
225
+ info(`removed ${unit} (systemd)`);
226
+ }
227
+
228
+ // ── Commands ────────────────────────────────────────────────────────────--
229
+ function cmdInstall(args) {
230
+ const agentId = args['agent-id'];
231
+ if (!agentId) die('--agent-id required (e.g. agent_01ABC...)');
232
+ if (!isValidAgentId(agentId)) die(`--agent-id invalid format (expected "agent_" + alphanumeric, got "${agentId}")`);
233
+ const interval = args.interval || '5m';
234
+ if (!/^\d+[smhd]$/.test(interval)) die(`--interval invalid format (expected like 30s, 5m, 1h, 2d; got "${interval}")`);
235
+ const logDir = args['log-dir'] || LOG_DIR_DEFAULT;
236
+ const withShield = !!args['with-shield'];
237
+
238
+ if (PLATFORM !== 'darwin' && PLATFORM !== 'linux') {
239
+ die(`unsupported platform "${PLATFORM}". Supported: macOS (launchd), Linux (systemd).\n` +
240
+ ' Run the daemon manually or wrap it in your own process manager:\n' +
241
+ ` wma-fetch --agent-id ${agentId} --watch --upload --interval ${interval}`);
242
+ }
243
+
244
+ mkdirSync(logDir, { recursive: true, mode: 0o700 });
245
+ writeEnvFile();
246
+
247
+ const watchArgs = ['--agent-id', agentId, '--watch', '--upload', '--interval', interval, '--log-dir', logDir];
248
+ const shieldArgs = ['--agent-id', agentId, '--policies-source', 'fortress', '--log-dir', logDir];
249
+
250
+ if (PLATFORM === 'darwin') {
251
+ macInstallOne(WATCH_LABEL, FETCH_SCRIPT, watchArgs);
252
+ if (withShield) macInstallOne(SHIELD_LABEL, SHIELD_SCRIPT, shieldArgs);
253
+ } else {
254
+ linuxInstallOne(WATCH_LABEL, 'WatchMyAgents Watch daemon', FETCH_SCRIPT, watchArgs);
255
+ if (withShield) linuxInstallOne(SHIELD_LABEL, 'WatchMyAgents Shield enforcement', SHIELD_SCRIPT, shieldArgs);
256
+ }
257
+
258
+ info('done — the WGS loop now runs always-on, no terminal needed.');
259
+ info(`logs: ${CONFIG_DIR}/*.log | captured events: ${logDir}`);
260
+ info(`status: wma-service status uninstall: wma-service uninstall${withShield ? ' --with-shield' : ''}`);
261
+ }
262
+
263
+ function cmdUninstall(args) {
264
+ const withShield = !!args['with-shield'];
265
+ if (PLATFORM === 'darwin') {
266
+ macUnload(WATCH_LABEL);
267
+ if (withShield) macUnload(SHIELD_LABEL);
268
+ } else if (PLATFORM === 'linux') {
269
+ linuxUninstallOne(WATCH_LABEL);
270
+ if (withShield) linuxUninstallOne(SHIELD_LABEL);
271
+ } else {
272
+ die(`unsupported platform "${PLATFORM}"`);
273
+ }
274
+ info('uninstalled. (Secrets in ' + ENV_FILE + ' left intact — delete manually if you want them gone.)');
275
+ }
276
+
277
+ function cmdStatus() {
278
+ if (PLATFORM === 'darwin') {
279
+ try {
280
+ const out = execFileSync('launchctl', ['list'], { encoding: 'utf8' });
281
+ const lines = out.split('\n').filter((l) => l.includes('watchmyagents'));
282
+ process.stdout.write(lines.length ? lines.join('\n') + '\n' : 'no WatchMyAgents services loaded\n');
283
+ } catch { warn('could not query launchctl'); }
284
+ } else if (PLATFORM === 'linux') {
285
+ for (const label of [WATCH_LABEL, SHIELD_LABEL]) {
286
+ try {
287
+ const out = execFileSync('systemctl', ['--user', 'is-active', unitName(label)], { encoding: 'utf8' }).trim();
288
+ process.stdout.write(`${unitName(label)}: ${out}\n`);
289
+ } catch (e) {
290
+ process.stdout.write(`${unitName(label)}: ${(e.stdout || 'inactive').toString().trim()}\n`);
291
+ }
292
+ }
293
+ } else {
294
+ die(`unsupported platform "${PLATFORM}"`);
295
+ }
296
+ }
297
+
298
+ function usage() {
299
+ process.stdout.write(`wma-service — run WatchMyAgents as an always-on OS service
300
+
301
+ Usage:
302
+ wma-service install --agent-id agent_xxx [--interval 5m] [--log-dir DIR] [--with-shield]
303
+ wma-service status
304
+ wma-service uninstall [--with-shield]
305
+
306
+ Required env at install (snapshotted to ~/.watchmyagents/env, chmod 600):
307
+ ANTHROPIC_API_KEY, WMA_API_KEY, WMA_FORTRESS_BASE_URL, WMA_SIGNALS_SALT
308
+
309
+ macOS → launchd LaunchAgent · Linux → systemd user unit.
310
+ The service starts at login and restarts on crash. Raw logs stay local.
311
+ `);
312
+ }
313
+
314
+ function main() {
315
+ const args = parseArgs(process.argv.slice(2));
316
+ const cmd = args._[0];
317
+ switch (cmd) {
318
+ case 'install': return cmdInstall(args);
319
+ case 'uninstall': return cmdUninstall(args);
320
+ case 'status': return cmdStatus();
321
+ default: usage(); process.exit(cmd ? 1 : 0);
322
+ }
323
+ }
324
+
325
+ main();
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,9 @@ 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';
39
+ import { isValidAgentId } from '../src/validate.js';
36
40
 
37
41
  function parseArgs(argv) {
38
42
  const out = {};
@@ -114,9 +118,45 @@ After either option, restart Shield — it auto-detects the new mode.
114
118
  // Per-session worker — runs one event loop, returns when session ends.
115
119
  // ────────────────────────────────────────────────────────────────────────
116
120
  async function runSessionWorker({ sessionId, ctx }) {
117
- const { apiKey, agentId, ruleset, mode, decisions, signal } = ctx;
121
+ const { apiKey, agentId, mode, decisions, signal, pushDecisionToFortress, signalsSalt } = ctx;
122
+ // NOTE: ctx.ruleset is a getter — read it FRESH per evaluation so policy
123
+ // refreshes from Fortress (every 5 min) take effect without restart.
118
124
  sinfo(sessionId, `attached (${mode} mode)`);
119
125
 
126
+ // Helper: hash an IoC value with the customer salt (same one used by
127
+ // anonymizer for signals → correlates decisions to signals in Fortress).
128
+ // Returns null if no salt is configured (decisions still upload, just
129
+ // without input_hash).
130
+ const hashIoc = (value) => {
131
+ if (!signalsSalt || value == null) return null;
132
+ const s = typeof value === 'string' ? value : JSON.stringify(value);
133
+ return 'sha256:' + createHash('sha256').update(signalsSalt).update(s).digest('hex').slice(0, 32);
134
+ };
135
+
136
+ // Helper: assemble + fire the decision push to Fortress (fire-and-forget).
137
+ const fireToFortress = (rawEvent, normalized, result, decidedInMs) => {
138
+ if (!pushDecisionToFortress) return;
139
+ // Extract the most relevant input value to hash (URL > command > query > path)
140
+ const inp = normalized?.input;
141
+ let inputForHash = null;
142
+ if (inp && typeof inp === 'object') {
143
+ inputForHash = inp.url || inp.command || inp.query || inp.path || inp.file_path || null;
144
+ }
145
+ pushDecisionToFortress({
146
+ anthropic_agent_id: agentId,
147
+ decision: result.decision,
148
+ rule_id: result.rule_id || undefined,
149
+ session_hash: hashIoc(sessionId) || undefined,
150
+ event_id_hash: hashIoc(rawEvent?.id) || undefined,
151
+ input_hash: hashIoc(inputForHash) || undefined,
152
+ action_type: normalized?.action_type || undefined,
153
+ tool_name: normalized?.tool_name || undefined,
154
+ message: result.message || result.rule_name || undefined,
155
+ decided_at: new Date().toISOString(),
156
+ decided_in_ms: decidedInMs,
157
+ }).catch(() => undefined);
158
+ };
159
+
120
160
  let processed = 0, enforced = 0, sessionInterrupted = false;
121
161
  // Cache is only needed for tool_confirmation mode (lookup by event_id when
122
162
  // requires_action fires). Interrupt mode evaluates synchronously and never
@@ -161,7 +201,7 @@ async function runSessionWorker({ sessionId, ctx }) {
161
201
  // No caching in interrupt mode — react synchronously, free memory.
162
202
  const normalized = normalizeForPolicy(rawEvent);
163
203
  const t0 = Date.now();
164
- const result = evaluate(normalized, ruleset);
204
+ const result = evaluate(normalized, ctx.ruleset);
165
205
  const decidedInMs = Date.now() - t0;
166
206
 
167
207
  sinfo(sessionId, `${rawEvent.type} tool=${normalized.tool_name} → ${result.decision}${result.rule_id ? ` (${result.rule_id})` : ''}`);
@@ -171,6 +211,7 @@ async function runSessionWorker({ sessionId, ctx }) {
171
211
  ruleId: result.rule_id, ruleName: result.rule_name,
172
212
  message: result.message, decidedInMs,
173
213
  });
214
+ fireToFortress(rawEvent, normalized, result, decidedInMs);
174
215
 
175
216
  if ((result.decision === 'deny' || result.decision === 'interrupt') && !sessionInterrupted) {
176
217
  try {
@@ -217,7 +258,7 @@ async function runSessionWorker({ sessionId, ctx }) {
217
258
 
218
259
  const normalized = normalizeForPolicy(sourceEvent);
219
260
  const t0 = Date.now();
220
- const result = evaluate(normalized, ruleset);
261
+ const result = evaluate(normalized, ctx.ruleset);
221
262
  const decidedInMs = Date.now() - t0;
222
263
 
223
264
  sinfo(sessionId, `requires_action ${sourceEvent.type} tool=${normalized.tool_name} → ${result.decision}${result.rule_id ? ` (${result.rule_id})` : ''}`);
@@ -227,6 +268,7 @@ async function runSessionWorker({ sessionId, ctx }) {
227
268
  ruleId: result.rule_id, ruleName: result.rule_name,
228
269
  message: result.message, decidedInMs,
229
270
  });
271
+ fireToFortress(sourceEvent, normalized, result, decidedInMs);
230
272
 
231
273
  try {
232
274
  if (result.decision === 'allow') {
@@ -373,17 +415,56 @@ async function main() {
373
415
 
374
416
  const singleSessionId = args['session-id']; // optional now
375
417
  const policyPath = args.policy;
418
+ const policiesSource = args['policies-source'] || (policyPath ? 'local' : null);
419
+ const wmaApiKey = args['wma-api-key'] || process.env.WMA_API_KEY;
420
+ const signalsSalt = args['salt'] || process.env.WMA_SIGNALS_SALT;
421
+ const fortressBase = resolveFortressBase({
422
+ explicitBase: args['fortress-base-url'],
423
+ explicitUrl: args['fortress-url'],
424
+ });
376
425
  const logDir = resolve(args['log-dir'] || './watchmyagents-logs');
377
426
 
378
427
  if (!apiKey) die('error: --api-key or ANTHROPIC_API_KEY required');
379
428
  if (!agentId) die('error: --agent-id required');
380
- if (!policyPath) die('error: --policy <path-to-policies.json> required');
429
+ if (!isValidAgentId(agentId)) {
430
+ die(`error: --agent-id has invalid format (expected "agent_" + alphanumeric, got "${agentId}")`);
431
+ }
381
432
 
382
- let ruleset;
383
- try {
384
- ruleset = await loadPolicies(resolve(policyPath));
385
- } catch (e) {
386
- die(`error loading policies: ${e.message}`);
433
+ // Policies source: --policies-source fortress | local (default infers from --policy)
434
+ let ruleset; // for 'local' mode: static; for 'fortress': initial snapshot
435
+ let fortressPolicies; // FortressPolicySource instance, used as ground truth at runtime
436
+
437
+ if (policiesSource === 'fortress') {
438
+ if (!wmaApiKey) die('error: --policies-source fortress requires --wma-api-key or WMA_API_KEY env');
439
+ if (!fortressBase) die('error: --policies-source fortress requires --fortress-base-url or WMA_FORTRESS_BASE_URL env');
440
+ if (!/^wma_[a-f0-9]{32}$/i.test(wmaApiKey)) warn(`WMA_API_KEY format looks unusual (expected wma_<32hex>).`);
441
+
442
+ fortressPolicies = new FortressPolicySource({
443
+ apiKey: wmaApiKey,
444
+ base: fortressBase,
445
+ anthropicAgentId: agentId,
446
+ refreshIntervalMs: 5 * 60_000,
447
+ onError: (e) => warn(`policy refresh failed (keeping cached): ${e.message}`),
448
+ onRefresh: ({ policies, fetched_at, initial }) => {
449
+ info(`policies ${initial ? 'loaded' : 'refreshed'} from Fortress — ${policies.length} active (fetched_at: ${fetched_at})`);
450
+ },
451
+ });
452
+ try {
453
+ await fortressPolicies.start();
454
+ } catch (e) {
455
+ die(`error fetching policies from Fortress: ${e.message}\n` +
456
+ ` Check WMA_FORTRESS_BASE_URL and WMA_API_KEY.`);
457
+ }
458
+ ruleset = fortressPolicies.current();
459
+ } else if (policiesSource === 'local') {
460
+ if (!policyPath) die('error: --policies-source local requires --policy <path-to-policies.json>');
461
+ try {
462
+ ruleset = await loadPolicies(resolve(policyPath));
463
+ } catch (e) {
464
+ die(`error loading policies: ${e.message}`);
465
+ }
466
+ } else {
467
+ die('error: --policy <path> OR --policies-source fortress required');
387
468
  }
388
469
 
389
470
  let mode = 'interrupt';
@@ -395,7 +476,10 @@ async function main() {
395
476
  warn(`could not fetch agent config (${e.message}). Defaulting to interrupt mode.`);
396
477
  }
397
478
 
398
- info(`armed ${ruleset.policies.length} policies loaded from ${policyPath}`);
479
+ const sourceLabel = policiesSource === 'fortress'
480
+ ? `Fortress (${fortressBase})`
481
+ : policyPath;
482
+ info(`armed — ${ruleset.policies.length} policies loaded from ${sourceLabel}`);
399
483
  info(`default action when no rule matches: ${ruleset.default.action}`);
400
484
  info(`agent: ${agentId}${agentMeta?.name ? ` "${agentMeta.name}"` : ''}`);
401
485
  info(`enforcement mode: ${mode}`);
@@ -414,11 +498,45 @@ async function main() {
414
498
  return loggers.get(sessionId);
415
499
  };
416
500
 
501
+ // Optional Fortress decision pusher — only active if we have a wma key + base.
502
+ // In 'fortress' mode this is always available. In 'local' mode it's a fire-
503
+ // and-forget extra channel if both are set.
504
+ const canPushToFortress = !!(wmaApiKey && fortressBase);
505
+ const pushDecisionToFortress = canPushToFortress
506
+ ? async (decisionData) => {
507
+ try {
508
+ await postDecision({ apiKey: wmaApiKey, base: fortressBase, decision: decisionData });
509
+ } catch (e) {
510
+ warn(`Fortress decision push failed: ${e.message}`);
511
+ }
512
+ }
513
+ : null;
514
+
417
515
  const ac = new AbortController();
418
- process.on('SIGINT', () => { info('SIGINT received, shutting down…'); ac.abort(); });
419
- process.on('SIGTERM', () => { info('SIGTERM received, shutting down…'); ac.abort(); });
516
+ process.on('SIGINT', () => {
517
+ info('SIGINT received, shutting down…');
518
+ if (fortressPolicies) fortressPolicies.stop();
519
+ ac.abort();
520
+ });
521
+ process.on('SIGTERM', () => {
522
+ info('SIGTERM received, shutting down…');
523
+ if (fortressPolicies) fortressPolicies.stop();
524
+ ac.abort();
525
+ });
420
526
 
421
- const ctx = { apiKey, agentId, ruleset, mode, decisions, signal: ac.signal };
527
+ // ctx exposes a getter for the live ruleset so workers see policy refreshes.
528
+ const ctx = {
529
+ apiKey,
530
+ agentId,
531
+ get ruleset() {
532
+ return fortressPolicies ? fortressPolicies.current() : ruleset;
533
+ },
534
+ mode,
535
+ decisions,
536
+ pushDecisionToFortress,
537
+ signalsSalt,
538
+ signal: ac.signal,
539
+ };
422
540
 
423
541
  if (singleSessionId) {
424
542
  info(`single-session mode — attached to ${singleSessionId}`);
@@ -29,6 +29,7 @@ import { join, resolve } from 'node:path';
29
29
  import { createReadStream } from 'node:fs';
30
30
  import { createInterface } from 'node:readline';
31
31
  import { SignalsAggregator } from '../src/anonymizer.js';
32
+ import { resolveFortressBase, fortressEndpoint } from '../src/fortress/url.js';
32
33
 
33
34
  function parseArgs(argv) {
34
35
  const out = {};
@@ -101,14 +102,24 @@ async function main() {
101
102
 
102
103
  const agentId = args['agent-id'];
103
104
  const logDir = resolve(args['log-dir'] || './watchmyagents-logs');
104
- const fortressUrl = args['fortress-url'] || process.env.WMA_FORTRESS_URL;
105
105
  const apiKey = args['api-key'] || process.env.WMA_API_KEY;
106
106
  const salt = args.salt || process.env.WMA_SIGNALS_SALT;
107
107
  const displayName = args['display-name'] || agentId;
108
108
  const dryRun = !!args['dry-run'];
109
109
 
110
+ // Resolve Fortress base URL. Accepts:
111
+ // --fortress-base-url <base> (preferred CLI)
112
+ // --fortress-url <full ingest-signals> (legacy CLI)
113
+ // WMA_FORTRESS_BASE_URL env (preferred env)
114
+ // WMA_FORTRESS_URL env (legacy env, points at ingest-signals)
115
+ const fortressBase = resolveFortressBase({
116
+ explicitBase: args['fortress-base-url'],
117
+ explicitUrl: args['fortress-url'],
118
+ });
119
+ const fortressUrl = fortressBase ? fortressEndpoint(fortressBase, 'ingest-signals') : null;
120
+
110
121
  // Validation
111
- if (!agentId) die('error: --agent-id required (Anthropic agent_id, e.g. agent_01XaN...)');
122
+ if (!agentId) die('error: --agent-id required (Anthropic agent_id, e.g. agent_01ABC...)');
112
123
  // Strict alphanumeric to prevent path traversal in collectFiles below
113
124
  // (--agent-id ends up as a filesystem path segment).
114
125
  if (!/^agent_[a-zA-Z0-9]+$/.test(agentId)) {
@@ -0,0 +1,59 @@
1
+ // ─────────────────────────────────────────────────────────────────────────
2
+ // Fortress URL resolution — shared across upload-fortress, shield, etc.
3
+ // ─────────────────────────────────────────────────────────────────────────
4
+ // The user sets ONE of:
5
+ //
6
+ // WMA_FORTRESS_BASE_URL=https://<project>.supabase.co/functions/v1
7
+ // → preferred. Each tool appends its endpoint (/ingest-signals,
8
+ // /get-policies, /ingest-decisions).
9
+ //
10
+ // WMA_FORTRESS_URL=https://<project>.supabase.co/functions/v1/ingest-signals
11
+ // → legacy (v0.5.0 era). The base URL is derived by stripping the
12
+ // last path segment, so other endpoints can be constructed.
13
+ //
14
+ // Either way, callers receive a `base` they append `/<endpoint>` to.
15
+
16
+ /**
17
+ * Resolve the Fortress base URL from env / args.
18
+ * @param {object} opts - { explicitUrl, explicitBase, env }
19
+ * @returns {string|null} base URL like https://x.supabase.co/functions/v1
20
+ * (no trailing slash), or null if not configured.
21
+ */
22
+ export function resolveFortressBase({ explicitUrl, explicitBase, env = process.env } = {}) {
23
+ // 1. Explicit base URL from CLI
24
+ if (explicitBase) return stripTrailingSlash(explicitBase);
25
+
26
+ // 2. Env: WMA_FORTRESS_BASE_URL (preferred)
27
+ if (env.WMA_FORTRESS_BASE_URL) return stripTrailingSlash(env.WMA_FORTRESS_BASE_URL);
28
+
29
+ // 3. Legacy: WMA_FORTRESS_URL (full path to ingest-signals)
30
+ const legacy = explicitUrl || env.WMA_FORTRESS_URL;
31
+ if (legacy) {
32
+ // Strip last path segment to get the base
33
+ try {
34
+ const u = new URL(legacy);
35
+ const parts = u.pathname.split('/').filter(Boolean);
36
+ if (parts.length > 0) parts.pop();
37
+ u.pathname = '/' + parts.join('/');
38
+ return stripTrailingSlash(u.toString());
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ return null;
45
+ }
46
+
47
+ function stripTrailingSlash(s) {
48
+ return s.endsWith('/') ? s.slice(0, -1) : s;
49
+ }
50
+
51
+ /**
52
+ * Build a full endpoint URL given a base + endpoint name.
53
+ * @param {string} base - e.g. https://x.supabase.co/functions/v1
54
+ * @param {string} endpoint - e.g. "ingest-signals", "get-policies"
55
+ */
56
+ export function fortressEndpoint(base, endpoint) {
57
+ if (!base) throw new Error('Fortress base URL not configured');
58
+ return `${base}/${endpoint}`;
59
+ }
package/src/logger.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { mkdir, appendFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
+ import { assertSafePathSegment } from './validate.js';
4
5
 
5
6
  const EXPORT_FIELDS = [
6
7
  'id', 'agent_id', 'framework', 'timestamp', 'action_type',
@@ -18,6 +19,9 @@ export class Logger {
18
19
  // full / EACCES / EINVAL must propagate so callers know.
19
20
  // Opt into bestEffort=true only for non-critical paths.
20
21
  constructor({ logDir, agentId, sessionId, silent, bestEffort } = {}) {
22
+ // agentId becomes a filesystem path segment (logDir/<agentId>/…). Reject
23
+ // anything that could traverse out of logDir before we ever build a path.
24
+ assertSafePathSegment(agentId, 'agentId');
21
25
  this.logDir = logDir;
22
26
  this.agentId = agentId;
23
27
  this.sessionId = sessionId || randomUUID();