anyagent-bridge 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.env.example +81 -0
  2. package/LICENSE +21 -0
  3. package/README.md +289 -0
  4. package/bin/anyagent-bridge.js +127 -0
  5. package/client/index.html +525 -0
  6. package/config.example.json +69 -0
  7. package/docs/INSTALL.md +138 -0
  8. package/docs/ROADMAP.md +168 -0
  9. package/docs/SECURITY.md +85 -0
  10. package/docs/WALKTHROUGH.md +82 -0
  11. package/docs/screenshots/.gitkeep +3 -0
  12. package/docs/screenshots/01-startup-banner.png +0 -0
  13. package/docs/screenshots/02-terminal-view.png +0 -0
  14. package/docs/screenshots/03-agent-running.png +0 -0
  15. package/docs/screenshots/04-mobile.png +0 -0
  16. package/package.json +57 -0
  17. package/server/auth/index.js +20 -0
  18. package/server/auth/manager.js +448 -0
  19. package/server/auth/oauth.js +154 -0
  20. package/server/auth/providers/github.js +59 -0
  21. package/server/auth/providers/google.js +44 -0
  22. package/server/auth/sessions.js +160 -0
  23. package/server/auth/store.js +135 -0
  24. package/server/auth/totp.js +140 -0
  25. package/server/index.js +1779 -0
  26. package/server/safety/audit.js +139 -0
  27. package/server/safety/clientip.js +73 -0
  28. package/server/safety/index.js +17 -0
  29. package/server/safety/manager.js +507 -0
  30. package/server/safety/redact.js +153 -0
  31. package/server/safety/sandbox.js +130 -0
  32. package/server/tunnel/adapters/cloudflare-quick.js +40 -0
  33. package/server/tunnel/adapters/cloudflared-named.js +49 -0
  34. package/server/tunnel/adapters/devtunnel.js +54 -0
  35. package/server/tunnel/adapters/tailscale.js +42 -0
  36. package/server/tunnel/base-adapter.js +185 -0
  37. package/server/tunnel/detect.js +65 -0
  38. package/server/tunnel/index.js +15 -0
  39. package/server/tunnel/manager.js +321 -0
  40. package/server/tunnel/registry.js +31 -0
  41. package/test/stage4-boot.js +98 -0
  42. package/test/stage4-smoke.js +267 -0
@@ -0,0 +1,507 @@
1
+ /**
2
+ * AnyAgent Bridge — safety subsystem manager (Stage 4)
3
+ *
4
+ * One manager wiring four opt-in safety layers on top of Stage 3:
5
+ * • Docker sandbox — run a session's shell (and therefore its agent) inside a
6
+ * container instead of on the host.
7
+ * • Kill-switch — per-session hard kill + a global panic (kill all, sweep
8
+ * stray containers, optionally stop the tunnel and lock the
9
+ * bridge against new agent launches).
10
+ * • Audit log — JSONL of REST mutations + semantic agent commands.
11
+ * • Secret redaction — scrub the audit log always; opt-in live PTY-stream redaction.
12
+ *
13
+ * THE CARDINAL RULE: when `safety.enabled` is false (the default), this manager is
14
+ * inert — getStatus() is null, no routes/middleware are mounted, spawnSpecFor()
15
+ * returns null (the session keeps its original host-shell spawn), newLiveStream()
16
+ * is null, handleWsMessage() returns false, canLaunchAgent() is true. The server is
17
+ * byte-identical to Stage 3.
18
+ *
19
+ * Invariant #3 sharpened: NEVER throw. The server's uncaughtException handler does
20
+ * NOT exit on most errors, so a throw from a safety hook would be silently swallowed
21
+ * and leave the bridge half-broken — worse than a crash. Every method is defensive.
22
+ *
23
+ * Zero new npm dependencies — Node core (fs/crypto/path/child_process) + the
24
+ * existing tunnel detect helper.
25
+ */
26
+
27
+ 'use strict';
28
+
29
+ const fs = require('fs');
30
+ const path = require('path');
31
+ const crypto = require('crypto');
32
+ const { spawn } = require('child_process');
33
+
34
+ const sandbox = require('./sandbox');
35
+ const { createRedactor } = require('./redact');
36
+ const { createAuditLog } = require('./audit');
37
+
38
+ function asBool(v, dflt) { return v === undefined ? dflt : !!v; }
39
+
40
+ class SafetyManager {
41
+ constructor(config, deps) {
42
+ const d = deps || {};
43
+ this.logger = d.logger || console;
44
+ this.dataDir = d.dataDir || '.data';
45
+ this.isOperator = typeof d.isOperator === 'function' ? d.isOperator : (() => false);
46
+ this.getClientIP = typeof d.getClientIP === 'function' ? d.getClientIP : (() => 'unknown');
47
+ this.baseShell = d.baseShell || (process.platform === 'win32' ? 'cmd.exe' : '/bin/bash');
48
+ this.blockedDirs = Array.isArray(d.blockedDirs) ? d.blockedDirs : [];
49
+
50
+ this.cfg = this._normalize(config);
51
+ this.enabled = !!this.cfg.enabled;
52
+
53
+ // Redactor knows the bridge's own secrets so they can never leak to the log /
54
+ // stream. The session secret is read best-effort from the auth subsystem's file
55
+ // (we do not modify the auth subsystem to expose it).
56
+ const secrets = [];
57
+ if (d.secrets && d.secrets.authToken) secrets.push(d.secrets.authToken);
58
+ const sessionSecret = (d.secrets && d.secrets.sessionSecret) || this._readSessionSecret();
59
+ if (sessionSecret) secrets.push(sessionSecret);
60
+ this.redactor = createRedactor({ extraSecrets: secrets, maxHoldBytes: this.cfg.redaction.maxHoldBytes });
61
+
62
+ this.installId = this._loadOrCreateInstallId();
63
+ this._containerPrefix = `aab-${this.installId}-sess-`;
64
+ this.docker = null; // { available, path } once detected
65
+ this._sandboxDegraded = false;
66
+ this.locked = false;
67
+ this.audit = null;
68
+ }
69
+
70
+ _normalize(c) {
71
+ const cfg = c && typeof c === 'object' ? c : {};
72
+ const sb = cfg.sandbox || {};
73
+ const ks = cfg.killSwitch || {};
74
+ const au = cfg.audit || {};
75
+ const rd = cfg.redaction || {};
76
+ return {
77
+ enabled: !!cfg.enabled,
78
+ sandbox: {
79
+ enabled: !!sb.enabled,
80
+ image: sb.image || null,
81
+ network: sb.network || 'bridge',
82
+ mountMode: sb.mountMode === 'ro' ? 'ro' : 'rw',
83
+ workdir: sb.workdir || '/workspace',
84
+ shell: sb.shell || null,
85
+ memory: sb.memory === null ? null : (sb.memory || '2g'),
86
+ cpus: sb.cpus === null ? null : (sb.cpus || '2'),
87
+ pidsLimit: sb.pidsLimit === null ? null : (sb.pidsLimit || 512),
88
+ noNewPrivileges: asBool(sb.noNewPrivileges, true),
89
+ readOnlyRootfs: !!sb.readOnlyRootfs,
90
+ dropAllCaps: !!sb.dropAllCaps,
91
+ runAsHostUser: !!sb.runAsHostUser,
92
+ envPassthrough: Array.isArray(sb.envPassthrough) ? sb.envPassthrough : ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY'],
93
+ onDockerMissing: sb.onDockerMissing === 'refuse' ? 'refuse' : 'host',
94
+ onMissingProject: sb.onMissingProject === 'refuse' ? 'refuse' : 'host',
95
+ extraArgs: Array.isArray(sb.extraArgs) ? sb.extraArgs : []
96
+ },
97
+ killSwitch: {
98
+ enabled: asBool(ks.enabled, true),
99
+ lockOnPanic: asBool(ks.lockOnPanic, true),
100
+ stopTunnelOnPanic: asBool(ks.stopTunnelOnPanic, true),
101
+ persistLock: asBool(ks.persistLock, true)
102
+ },
103
+ audit: {
104
+ enabled: !!au.enabled,
105
+ dir: au.dir || null,
106
+ includeReads: !!au.includeReads,
107
+ maxFileBytes: au.maxFileBytes || 10 * 1024 * 1024,
108
+ retentionDays: au.retentionDays || 30
109
+ },
110
+ redaction: {
111
+ liveStream: !!rd.liveStream,
112
+ auditAlways: asBool(rd.auditAlways, true),
113
+ maxHoldBytes: rd.maxHoldBytes || 8192
114
+ }
115
+ };
116
+ }
117
+
118
+ _readSessionSecret() {
119
+ try {
120
+ const f = path.join(this.dataDir, 'auth-secret.json');
121
+ if (fs.existsSync(f)) {
122
+ const j = JSON.parse(fs.readFileSync(f, 'utf8'));
123
+ if (j && typeof j.secret === 'string') return j.secret;
124
+ }
125
+ } catch (e) { /* best-effort */ }
126
+ return null;
127
+ }
128
+
129
+ _loadOrCreateInstallId() {
130
+ const f = path.join(this.dataDir, 'safety.json');
131
+ try {
132
+ if (fs.existsSync(f)) {
133
+ const j = JSON.parse(fs.readFileSync(f, 'utf8'));
134
+ if (j && typeof j.installId === 'string' && j.installId) return j.installId;
135
+ }
136
+ } catch (e) { /* fall through */ }
137
+ const id = crypto.randomBytes(4).toString('hex');
138
+ try { fs.mkdirSync(this.dataDir, { recursive: true }); fs.writeFileSync(f, JSON.stringify({ installId: id, createdAt: Date.now() }, null, 2), { mode: 0o600 }); } catch (e) { /* ignore */ }
139
+ return id;
140
+ }
141
+
142
+ /** Boot-time setup: docker detect, lock-file load, audit init. Never throws. */
143
+ init() {
144
+ if (!this.enabled) return this;
145
+ try {
146
+ if (this.cfg.sandbox.enabled) {
147
+ const p = sandbox.detectDocker();
148
+ this.docker = { available: !!p, path: p };
149
+ if (!p) {
150
+ this.logger.warn(`[Safety] sandbox.enabled but 'docker' is not on PATH — new sessions will ${this.cfg.sandbox.onDockerMissing === 'refuse' ? 'be REFUSED' : 'fall back to a host shell'}.`);
151
+ }
152
+ }
153
+ if (this.cfg.killSwitch.persistLock) this._loadLock();
154
+ if (this.cfg.audit.enabled) {
155
+ const dir = this.cfg.audit.dir ? path.resolve(this.cfg.audit.dir) : path.join(this.dataDir, 'audit');
156
+ this._auditDir = dir;
157
+ this.audit = createAuditLog({
158
+ dir, maxFileBytes: this.cfg.audit.maxFileBytes, retentionDays: this.cfg.audit.retentionDays,
159
+ scrub: (s) => this.redactor.scrub(s), logger: this.logger
160
+ });
161
+ }
162
+ } catch (e) {
163
+ this.logger.warn(`[Safety] init error (continuing): ${e.message}`);
164
+ }
165
+ return this;
166
+ }
167
+
168
+ // ── Status ───────────────────────────────────────────────────────────────────
169
+ getStatus() {
170
+ if (!this.enabled) return null; // byte-identical: /api/system/status gets no `safety` key
171
+ return {
172
+ sandbox: this.cfg.sandbox.enabled
173
+ ? { enabled: true, dockerAvailable: !!(this.docker && this.docker.path), image: this.cfg.sandbox.image || null, network: this.cfg.sandbox.network, degraded: this._sandboxDegraded, onDockerMissing: this.cfg.sandbox.onDockerMissing }
174
+ : { enabled: false },
175
+ killSwitch: { enabled: this.cfg.killSwitch.enabled, locked: !!this.locked },
176
+ audit: this.audit ? { enabled: true, entries: this.audit.count(), dir: this._auditDir } : { enabled: false },
177
+ redaction: { liveStream: !!this.cfg.redaction.liveStream, auditAlways: !!this.cfg.redaction.auditAlways },
178
+ auditScope: ['rest-mutations', 'agent.start', 'agent.send', 'kill', 'panic']
179
+ };
180
+ }
181
+
182
+ // ── Sandbox spawn spec ─────────────────────────────────────────────────────────
183
+ /**
184
+ * Returns the spawn spec for a sandboxed session, or null to let the caller use
185
+ * its original host-shell spawn (the byte-identical off-path). May also return a
186
+ * { kind:'refuse', message } marker when sandbox is required but unavailable.
187
+ * session: the TerminalSession (we set session.containerName / _sandboxSpawnAt)
188
+ * cwd: the resolved working dir to mount
189
+ * baseEnv: the env the host shell would have used (process.env minus agent guards)
190
+ */
191
+ spawnSpecFor(session, cwd, baseEnv) {
192
+ if (!this.enabled || !this.cfg.sandbox.enabled) return null;
193
+ if (this._sandboxDegraded) return null; // already fell back this run
194
+
195
+ const sb = this.cfg.sandbox;
196
+
197
+ if (!this.docker || !this.docker.path) {
198
+ if (sb.onDockerMissing === 'refuse') {
199
+ return { kind: 'refuse', message: '\r\n[sandbox required but docker was not found on PATH — session not started]\r\n' };
200
+ }
201
+ return null; // fall back to host shell
202
+ }
203
+ if (!sandbox.isSandboxableDir(cwd, this.blockedDirs)) {
204
+ if (sb.onMissingProject === 'refuse') {
205
+ return { kind: 'refuse', message: '\r\n[sandbox refuses to mount your home directory — open a project folder instead]\r\n' };
206
+ }
207
+ this.logger.warn(`[Safety] session ${session.sessionId} has no bounded project dir — running on host (not sandboxed).`);
208
+ return null;
209
+ }
210
+ if (!sb.image) {
211
+ this.logger.warn('[Safety] sandbox.enabled but no image is set — running on host. Set safety.sandbox.image to an image that contains your agent CLI.');
212
+ return null;
213
+ }
214
+
215
+ try {
216
+ const cname = `${this._containerPrefix}${session.sessionId}-${crypto.randomBytes(3).toString('hex')}`;
217
+ const passthrough = sandbox.resolvePassthrough(sb.envPassthrough, process.env);
218
+ const env = sandbox.buildClientEnv(passthrough, process.env);
219
+ const args = sandbox.buildDockerArgs({ containerName: cname, hostProjectDir: cwd, image: sb.image, passthroughNames: passthrough, cfg: sb });
220
+ session.containerName = cname;
221
+ session._sandboxSpawnAt = Date.now();
222
+ return { kind: 'sandbox', file: this.docker.path, args, env, cwd, containerName: cname, sandboxed: true };
223
+ } catch (e) {
224
+ this.logger.warn(`[Safety] failed to build sandbox spec (${e.message}) — running on host.`);
225
+ return null;
226
+ }
227
+ }
228
+
229
+ /** A sandboxed PTY exited; if it died fast, count it toward auto-degrade. */
230
+ noteSandboxExit(session) {
231
+ if (!this.enabled || !session || !session._sandboxSpawnAt) return;
232
+ const fast = Date.now() - session._sandboxSpawnAt < 4000;
233
+ session._sandboxSpawnAt = 0;
234
+ if (!fast) return;
235
+ const now = Date.now();
236
+ if (!this._sbWindow || now - this._sbWindow > 30000) { this._sbWindow = now; this._sbFails = 0; }
237
+ this._sbFails = (this._sbFails || 0) + 1;
238
+ if (this._sbFails >= 2 && !this._sandboxDegraded) {
239
+ this._sandboxDegraded = true;
240
+ this.logger.warn('[Safety] sandbox repeatedly failed to start — falling back to a host shell for new spawns. Check the docker daemon and the configured image.');
241
+ }
242
+ }
243
+
244
+ // ── Redaction ──────────────────────────────────────────────────────────────────
245
+ /** A stateful live-stream redactor for one PTY, or null when live redaction is off. */
246
+ newLiveStream() {
247
+ if (!this.enabled || !this.cfg.redaction.liveStream) return null;
248
+ try { return this.redactor.createStream(); } catch (e) { return null; }
249
+ }
250
+
251
+ scrub(s) { try { return this.redactor.scrub(s); } catch (e) { return s; } }
252
+
253
+ // ── Kill-switch ────────────────────────────────────────────────────────────────
254
+ canLaunchAgent() { return !this.locked; }
255
+
256
+ reapContainer(name) {
257
+ if (!name || !this.docker || !this.docker.path) return;
258
+ this._runDocker(['rm', '-f', name], () => {}); // best-effort; ignores "no such container"
259
+ }
260
+
261
+ killSession(session, sessions, saveSessions) {
262
+ if (!session) return;
263
+ try { if (session.ptyProcess) session.ptyProcess.kill('SIGKILL'); } catch (e) { /* ignore */ }
264
+ try { session.destroy(); } catch (e) { /* destroy reaps the container + closes clients */ }
265
+ try { if (sessions) sessions.delete(session.sessionId); } catch (e) { /* ignore */ }
266
+ try { if (saveSessions) saveSessions(); } catch (e) { /* ignore */ }
267
+ }
268
+
269
+ async panic(ctx) {
270
+ const c = ctx || {};
271
+ const opts = c.opts || {};
272
+ const sessions = c.sessions;
273
+ const killed = [];
274
+ if (sessions && typeof sessions.forEach === 'function') {
275
+ for (const s of Array.from(sessions.values())) {
276
+ killed.push(s.sessionId);
277
+ this.killSession(s, sessions, null);
278
+ }
279
+ try { if (c.saveSessions) c.saveSessions(); } catch (e) { /* ignore */ }
280
+ }
281
+ this._sweepContainers();
282
+
283
+ let tunnelStopped = false;
284
+ const wantStopTunnel = opts.stopTunnel !== undefined ? !!opts.stopTunnel : this.cfg.killSwitch.stopTunnelOnPanic;
285
+ if (wantStopTunnel && c.tunnel && typeof c.tunnel.stop === 'function') {
286
+ try { await c.tunnel.stop(); tunnelStopped = true; } catch (e) { /* ignore */ }
287
+ }
288
+ const wantLock = opts.lock !== undefined ? !!opts.lock : this.cfg.killSwitch.lockOnPanic;
289
+ if (wantLock) this._setLock(true);
290
+
291
+ this._auditEvent({ action: 'panic', actor: c.actor || null, note: `killed ${killed.length} session(s); tunnelStopped=${tunnelStopped}; locked=${this.locked}` });
292
+ return { killedSessions: killed, tunnelStopped, locked: !!this.locked };
293
+ }
294
+
295
+ _setLock(v) {
296
+ this.locked = !!v;
297
+ if (!this.cfg.killSwitch.persistLock) return;
298
+ const f = path.join(this.dataDir, 'safety-lock.json');
299
+ try {
300
+ if (this.locked) fs.writeFileSync(f, JSON.stringify({ locked: true, at: Date.now() }, null, 2), { mode: 0o600 });
301
+ else if (fs.existsSync(f)) fs.unlinkSync(f);
302
+ } catch (e) { /* ignore */ }
303
+ }
304
+
305
+ _loadLock() {
306
+ const f = path.join(this.dataDir, 'safety-lock.json');
307
+ try { if (fs.existsSync(f)) { const j = JSON.parse(fs.readFileSync(f, 'utf8')); this.locked = !!(j && j.locked); } } catch (e) { /* ignore */ }
308
+ }
309
+
310
+ _runDocker(args, onDone) {
311
+ if (!this.docker || !this.docker.path) { if (onDone) onDone(new Error('docker not found'), ''); return; }
312
+ let out = '';
313
+ try {
314
+ const cp = spawn(this.docker.path, args, { stdio: ['ignore', 'pipe', 'ignore'] });
315
+ cp.stdout.on('data', (d) => { out += d.toString(); });
316
+ cp.on('error', (e) => { if (onDone) onDone(e, out); });
317
+ cp.on('close', () => { if (onDone) onDone(null, out); });
318
+ } catch (e) { if (onDone) onDone(e, out); }
319
+ }
320
+
321
+ /** Best-effort sweep of THIS install's stray containers (e.g. crash orphans). */
322
+ _sweepContainers() {
323
+ if (!this.docker || !this.docker.path) return;
324
+ this._runDocker(['ps', '-a', '-q', '--filter', `name=${this._containerPrefix}`], (err, out) => {
325
+ if (err || !out) return;
326
+ const ids = out.split('\n').map(s => s.trim()).filter(Boolean);
327
+ for (const id of ids) this._runDocker(['rm', '-f', id], () => {});
328
+ });
329
+ }
330
+
331
+ // ── Audit ──────────────────────────────────────────────────────────────────────
332
+ installAuditMiddleware(app) {
333
+ if (!this.enabled || !this.audit) return; // byte-identical: nothing added to the pipeline
334
+ const self = this;
335
+ // Mounted before the route definitions so its res.on('finish') fires for every
336
+ // /api route (incl. auth, which registers earlier) AFTER per-route requireAuth has
337
+ // populated req.principal. A cheap pre-filter avoids attaching a finish listener to
338
+ // static-asset / non-API / read requests.
339
+ app.use((req, res, next) => {
340
+ const m = req.method;
341
+ const mutating = m === 'POST' || m === 'PUT' || m === 'PATCH' || m === 'DELETE';
342
+ if ((mutating || self.cfg.audit.includeReads) && typeof req.path === 'string' && req.path.indexOf('/api/') === 0) {
343
+ res.on('finish', () => { try { self.auditHttp(req, res); } catch (e) { /* never throw from a hook */ } });
344
+ }
345
+ next();
346
+ });
347
+ }
348
+
349
+ auditHttp(req, res) {
350
+ if (!this.audit) return;
351
+ const m = req.method;
352
+ const mutating = m === 'POST' || m === 'PUT' || m === 'PATCH' || m === 'DELETE';
353
+ if (!mutating && !this.cfg.audit.includeReads) return;
354
+ const p = req.path || '';
355
+ if (p.indexOf('/api/') !== 0) return; // only the API surface
356
+ const body = req.body || {};
357
+ const query = req.query || {};
358
+ const params = req.params || {};
359
+ const target = body.path || body.oldPath || body.sourcePath || query.path || params.sessionId || params.name;
360
+ this._auditEvent({
361
+ action: this._classify(m, p),
362
+ method: m,
363
+ path: p,
364
+ target: target != null ? String(target) : undefined,
365
+ actor: this._actorFromPrincipal(req.principal),
366
+ clientIP: this._safeClientIP(req),
367
+ status: res.statusCode
368
+ });
369
+ }
370
+
371
+ auditWs(action, info) {
372
+ if (!this.audit) return;
373
+ this._auditEvent({
374
+ action,
375
+ actor: this._actorFromPrincipal(info && info.principal),
376
+ target: info && info.target != null ? String(info.target) : undefined,
377
+ termSessionId: info && info.termSessionId,
378
+ clientIP: info && info.clientIP
379
+ });
380
+ }
381
+
382
+ _auditEvent(e) { try { if (this.audit) this.audit.record(e); } catch (err) { /* never throw */ } }
383
+
384
+ _safeClientIP(req) { try { return this.getClientIP(req); } catch (e) { return 'unknown'; } }
385
+
386
+ _classify(method, p) {
387
+ if (p.indexOf('/api/auth/') === 0) {
388
+ if (p.indexOf('/login') !== -1) return 'auth.login';
389
+ if (p.indexOf('/logout') !== -1) return 'auth.logout';
390
+ if (p.indexOf('/oauth/') !== -1) return 'auth.oauth';
391
+ if (p.indexOf('/totp/') !== -1) return 'auth.totp';
392
+ if (p.indexOf('/sessions') !== -1) return 'auth.session';
393
+ return `auth ${method}`;
394
+ }
395
+ if (p.indexOf('/api/safety/') === 0) return `safety.${p.split('/')[3] || 'action'}`;
396
+ if (p.indexOf('/api/tunnel/') === 0) return `tunnel.${p.split('/')[3] || 'action'}`;
397
+ if (p.indexOf('/api/sessions') === 0) return method === 'DELETE' ? 'session.delete' : 'session.update';
398
+ if (p.indexOf('/api/projects') === 0) return method === 'DELETE' ? 'project.delete' : 'project.update';
399
+ if (p.indexOf('/api/folder') === 0 || p.indexOf('/create-folder') !== -1 || p.indexOf('/mkdir') !== -1) return 'folder.create';
400
+ if (p.indexOf('/upload') !== -1) return 'file.upload';
401
+ if (p.indexOf('/rename') !== -1) return 'file.rename';
402
+ if (p.indexOf('/move') !== -1) return 'file.move';
403
+ if (p.indexOf('/file') !== -1 || p.indexOf('/explorer/') !== -1) {
404
+ if (method === 'DELETE') return 'file.delete';
405
+ if (method === 'POST') return 'file.create';
406
+ return 'file.write';
407
+ }
408
+ return `${method} ${p}`;
409
+ }
410
+
411
+ _actorFromPrincipal(p) {
412
+ if (!p) return null;
413
+ if (p.type === 'token') return { type: 'token', provider: 'token', sub: 'operator' };
414
+ const s = p.session || {};
415
+ return { type: 'session', provider: s.provider || 'unknown', sub: s.sub || s.login || s.email || s.id || 'session' };
416
+ }
417
+
418
+ // ── WebSocket control + audit ──────────────────────────────────────────────────
419
+ /** Consume operator panic/kill WS messages. Returns false (off or not consumed). */
420
+ handleWsMessage(msg, ctx) {
421
+ if (!this.enabled || !msg) return false;
422
+ if (msg.type !== 'panic' && msg.type !== 'kill') return false;
423
+ const c = ctx || {};
424
+ if (!this.isOperator(c.principal)) {
425
+ try { c.ws.send(JSON.stringify({ type: 'error', message: 'Operator only' })); } catch (e) { /* ignore */ }
426
+ return true;
427
+ }
428
+ if (msg.type === 'kill') {
429
+ const id = parseInt(msg.sessionId, 10);
430
+ const s = c.sessions ? c.sessions.get(id) : null;
431
+ if (s) this.killSession(s, c.sessions, c.saveSessions);
432
+ this._auditEvent({ action: 'kill', actor: this._actorFromPrincipal(c.principal), target: String(msg.sessionId), clientIP: c.clientIP });
433
+ try { c.ws.send(JSON.stringify({ type: 'killed', sessionId: msg.sessionId })); } catch (e) { /* ignore */ }
434
+ } else {
435
+ Promise.resolve(this.panic({
436
+ sessions: c.sessions, tunnel: c.tunnel, saveSessions: c.saveSessions,
437
+ opts: { stopTunnel: msg.stopTunnel, lock: msg.lock }, actor: this._actorFromPrincipal(c.principal)
438
+ })).then((r) => { try { c.ws.send(JSON.stringify({ type: 'panicked', result: r })); } catch (e) {} }).catch(() => {});
439
+ }
440
+ return true;
441
+ }
442
+
443
+ // ── Routes ───────────────────────────────────────────────────────────────────
444
+ registerRoutes(app, deps) {
445
+ if (!this.enabled) return; // byte-identical: no /api/safety/* routes exist
446
+ const d = deps || {};
447
+ const requireAuth = d.requireAuth || ((req, res, next) => next());
448
+ const self = this;
449
+ const operatorOnly = (req, res, next) => {
450
+ if (self.isOperator(req.principal)) return next();
451
+ return res.status(403).json({ error: 'Operator only' });
452
+ };
453
+
454
+ app.get('/api/safety/status', requireAuth, (req, res) => {
455
+ res.json(self.getStatus() || { enabled: false });
456
+ });
457
+
458
+ app.post('/api/safety/kill/:sessionId', requireAuth, operatorOnly, (req, res) => {
459
+ const id = parseInt(req.params.sessionId, 10);
460
+ const s = d.sessions ? d.sessions.get(id) : null;
461
+ if (!s) return res.status(404).json({ error: 'Session not found' });
462
+ self.killSession(s, d.sessions, d.saveSessions);
463
+ res.json({ success: true, killed: id });
464
+ });
465
+
466
+ app.post('/api/safety/panic', requireAuth, operatorOnly, async (req, res) => {
467
+ const body = req.body || {};
468
+ const result = await self.panic({
469
+ sessions: d.sessions, tunnel: d.tunnel, saveSessions: d.saveSessions,
470
+ opts: { stopTunnel: body.stopTunnel, lock: body.lock }, actor: self._actorFromPrincipal(req.principal)
471
+ });
472
+ res.json({ success: true, ...result });
473
+ });
474
+
475
+ app.post('/api/safety/unlock', requireAuth, operatorOnly, (req, res) => {
476
+ self._setLock(false);
477
+ self._auditEvent({ action: 'unlock', actor: self._actorFromPrincipal(req.principal) });
478
+ res.json({ success: true, locked: self.locked });
479
+ });
480
+ }
481
+
482
+ // ── Boot banner + shutdown ─────────────────────────────────────────────────────
483
+ bootSummaryLines() {
484
+ if (!this.enabled) return []; // byte-identical banner when off
485
+ const lines = [];
486
+ if (this.cfg.sandbox.enabled) {
487
+ const dock = this.docker && this.docker.path ? 'docker found' : `docker MISSING (onMissing=${this.cfg.sandbox.onDockerMissing})`;
488
+ lines.push(` Sandbox: on — image=${this.cfg.sandbox.image || '(unset!)'} network=${this.cfg.sandbox.network} [${dock}]`);
489
+ if (this.cfg.sandbox.network === 'none') lines.push(' NOTE: network=none — agents that call an API (Claude/Codex) will fail; set network=bridge.');
490
+ if (!this.cfg.sandbox.image) lines.push(' NOTE: no image set — sessions run on the host until safety.sandbox.image is configured.');
491
+ }
492
+ if (this.audit) lines.push(` Audit: on — ${this._auditDir}`);
493
+ if (this.cfg.redaction.liveStream) lines.push(' Redaction: live PTY-stream ON (best-effort; audit log is always redacted)');
494
+ if (this.locked) {
495
+ lines.push(' SAFETY: bridge is LOCKED (panic) — new agent launches are refused.');
496
+ lines.push(' Unlock: POST /api/safety/unlock (operator credential required)');
497
+ }
498
+ return lines;
499
+ }
500
+
501
+ flushSync() { try { if (this.audit) this.audit.flushSync(); } catch (e) { /* ignore */ } }
502
+
503
+ /** Best-effort container sweep on shutdown (non-blocking). */
504
+ sweepOnShutdown() { try { if (this.enabled && this.cfg.sandbox.enabled) this._sweepContainers(); } catch (e) { /* ignore */ } }
505
+ }
506
+
507
+ module.exports = SafetyManager;
@@ -0,0 +1,153 @@
1
+ /**
2
+ * AnyAgent Bridge — secret redaction (Stage 4)
3
+ *
4
+ * Two consumers, one engine:
5
+ * - scrub(str) full-string, used by the audit log (ALWAYS, when audit is on).
6
+ * - createStream() a stateful per-PTY redactor for the LIVE output stream
7
+ * (opt-in, default off — mutating a live xterm byte stream
8
+ * risks corruption, so the stream redactor is best-effort and
9
+ * the audit-side scrub is the authoritative one).
10
+ *
11
+ * Zero dependencies (Node core only). Never throws — a redaction bug must not take
12
+ * down the terminal or the audit path.
13
+ *
14
+ * The bridge's OWN secrets (the access token, the session secret) are the highest
15
+ * value to redact and are matched as exact substrings (split/join), not regex —
16
+ * faster and immune to regex-metachar surprises in a 64-hex token.
17
+ */
18
+
19
+ 'use strict';
20
+
21
+ // label → compiled GLOBAL regex. Order matters: the multi-line private-key block
22
+ // runs first so its body is not nibbled by the narrower token patterns.
23
+ function buildPatterns() {
24
+ return [
25
+ { label: 'private-key', re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/g },
26
+ { label: 'aws-key', re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
27
+ { label: 'openai-key', re: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/g },
28
+ { label: 'github-token', re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/g },
29
+ { label: 'slack-token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
30
+ { label: 'google-key', re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
31
+ { label: 'jwt', re: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
32
+ // key=value / key: value for obviously-secret names — mask only the value. The
33
+ // value class excludes [ and ] so an already-inserted [REDACTED:label] placeholder
34
+ // (from an earlier pattern) is never re-wrapped / re-labelled.
35
+ { label: 'secret-assignment', re: /\b(api[_-]?key|secret|token|password|passwd|pwd)\b(\s*[:=]\s*)(['"]?)([^\s'"[\]]{6,})\3/gi,
36
+ replace: (m, k, sep) => `${k}${sep}[REDACTED:secret]` },
37
+ ];
38
+ }
39
+
40
+ // Characters that can appear inside the tokens we redact. The stream redactor holds
41
+ // back a trailing run of these (a possible secret straddling the chunk boundary) so
42
+ // a token split across two chunks is re-examined whole on the next push.
43
+ const TOKEN_CHAR = /[A-Za-z0-9_+/=.\-]/;
44
+
45
+ function escapeLiteral(s) {
46
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
47
+ }
48
+
49
+ function createRedactor(opts) {
50
+ const o = opts || {};
51
+ const patterns = buildPatterns();
52
+ // Exact-match secrets (the bridge's own token + session secret). De-duped,
53
+ // sorted longest-first so a secret that is a prefix of another is masked whole.
54
+ const literals = Array.from(new Set((o.extraSecrets || []).filter(s => typeof s === 'string' && s.length >= 8)))
55
+ .sort((a, b) => b.length - a.length);
56
+ const maxHold = Math.max(256, o.maxHoldBytes || 8192);
57
+
58
+ /** Full-string scrub. Used by the audit log and any non-streaming caller. */
59
+ function scrub(str) {
60
+ if (typeof str !== 'string' || str.length === 0) return str;
61
+ let out = str;
62
+ for (const lit of literals) {
63
+ if (lit && out.indexOf(lit) !== -1) out = out.split(lit).join('[REDACTED:bridge-secret]');
64
+ }
65
+ for (const p of patterns) {
66
+ out = out.replace(p.re, p.replace || (() => `[REDACTED:${p.label}]`));
67
+ }
68
+ return out;
69
+ }
70
+
71
+ // Where (index into `buf`) it is unsafe to flush past — the start of the earliest
72
+ // still-unresolved thing near the tail: a trailing token run (a possible partial
73
+ // secret), a partial ANSI escape, or a partial private-key block. Output that ends
74
+ // on a boundary (newline/space/escape-terminator) holds back nothing → no lag.
75
+ function holdFrom(buf) {
76
+ const len = buf.length;
77
+ if (len === 0) return len;
78
+ let hold = len; // default: nothing held back
79
+
80
+ // (a) trailing in-progress token run (a partial secret straddling the boundary)
81
+ let i = len;
82
+ while (i > 0 && TOKEN_CHAR.test(buf[i - 1])) i--;
83
+ if (i < len) hold = Math.min(hold, i);
84
+
85
+ // (b) trailing in-progress ANSI escape (ESC not yet terminated)
86
+ const lastEsc = buf.lastIndexOf('\x1b');
87
+ if (lastEsc !== -1) {
88
+ const tail = buf.slice(lastEsc);
89
+ // CSI ends with a byte in @-~ ; OSC ends with BEL or ST (ESC \). If we do not
90
+ // see a terminator yet, the escape is incomplete — hold from ESC.
91
+ const terminated = /^\x1b\[[0-9;?]*[ -/]*[@-~]/.test(tail) ||
92
+ /^\x1b\][\s\S]*?(?:\x07|\x1b\\)/.test(tail) ||
93
+ /^\x1b[@-Z\\-_]/.test(tail);
94
+ if (!terminated) hold = Math.min(hold, lastEsc);
95
+ }
96
+
97
+ // (c) unresolved private-key opener (multi-line; not a single token run)
98
+ const lastBegin = buf.lastIndexOf('-----BEGIN');
99
+ if (lastBegin !== -1 && buf.indexOf('-----END', lastBegin) === -1) {
100
+ hold = Math.min(hold, lastBegin);
101
+ }
102
+
103
+ // never hold more than maxHold (bounded memory; a rare never-terminating secret
104
+ // is accepted as a miss rather than growing carry without bound)
105
+ if (len - hold > maxHold) hold = len - maxHold;
106
+ return Math.max(0, Math.min(hold, len));
107
+ }
108
+
109
+ /**
110
+ * Stateful stream redactor. push(chunk) returns the scrubbed, safe-to-emit
111
+ * portion and retains a small unscrubbed carry; flush() drains the carry.
112
+ * Operates on strings (node-pty's onData delivers decoded strings).
113
+ */
114
+ function createStream() {
115
+ let carry = '';
116
+ let overflowedOnce = false;
117
+ return {
118
+ push(chunk) {
119
+ if (typeof chunk !== 'string') chunk = String(chunk == null ? '' : chunk);
120
+ const buf = carry + chunk;
121
+ const hold = holdFrom(buf);
122
+ const emit = buf.slice(0, hold);
123
+ carry = buf.slice(hold);
124
+ let out = scrub(emit);
125
+ // Overflow guard: if the maxHold clamp forced the emit boundary INSIDE an
126
+ // uninterrupted token run, `out` ends mid-token and scrub() cannot match a
127
+ // split secret — mask the trailing partial token rather than leak a raw
128
+ // fragment. A clean (non-clamped) cut always ends on a non-token char, so this
129
+ // fires only in the bounded-memory overflow case (a single >maxHold token-char
130
+ // run with no whitespace/newline/ANSI — atypical terminal output). We mask the
131
+ // WHOLE trailing run, not a bounded tail: a straddling secret's emitted portion
132
+ // can be long (a JWT is 1-2 KB), so a bounded tail could still leak it. The
133
+ // cost is that a benign >maxHold run (e.g. a base64 wall) is over-masked — an
134
+ // acceptable availability trade in this opt-in, best-effort live path, since the
135
+ // audit log (authoritative, full-string scrub) is unaffected either way.
136
+ if (out.length && TOKEN_CHAR.test(out[out.length - 1])) {
137
+ overflowedOnce = true;
138
+ out = out.replace(/[A-Za-z0-9_+/=.\-]+$/, '[REDACTED:overflow]');
139
+ }
140
+ return out;
141
+ },
142
+ flush() {
143
+ const rest = carry;
144
+ carry = '';
145
+ return scrub(rest);
146
+ }
147
+ };
148
+ }
149
+
150
+ return { scrub, createStream };
151
+ }
152
+
153
+ module.exports = { createRedactor, escapeLiteral };