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.
- package/README.md +71 -13
- package/package.json +7 -4
- package/scripts/anonymize.js +7 -17
- package/scripts/fetch-anthropic.js +242 -61
- package/scripts/service.js +325 -0
- package/scripts/shield.js +131 -13
- package/scripts/upload-fortress.js +13 -2
- package/src/fortress/url.js +59 -0
- package/src/logger.js +4 -0
- package/src/shield/enforce.js +20 -2
- package/src/shield/policy.js +2 -2
- package/src/shield/sources/fortress.js +220 -0
- package/src/sources/anthropic-managed.js +21 -0
- package/src/validate.js +33 -0
|
@@ -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,
|
|
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 (!
|
|
429
|
+
if (!isValidAgentId(agentId)) {
|
|
430
|
+
die(`error: --agent-id has invalid format (expected "agent_" + alphanumeric, got "${agentId}")`);
|
|
431
|
+
}
|
|
381
432
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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', () => {
|
|
419
|
-
|
|
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
|
-
|
|
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.
|
|
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();
|