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.
- package/.env.example +81 -0
- package/LICENSE +21 -0
- package/README.md +289 -0
- package/bin/anyagent-bridge.js +127 -0
- package/client/index.html +525 -0
- package/config.example.json +69 -0
- package/docs/INSTALL.md +138 -0
- package/docs/ROADMAP.md +168 -0
- package/docs/SECURITY.md +85 -0
- package/docs/WALKTHROUGH.md +82 -0
- package/docs/screenshots/.gitkeep +3 -0
- package/docs/screenshots/01-startup-banner.png +0 -0
- package/docs/screenshots/02-terminal-view.png +0 -0
- package/docs/screenshots/03-agent-running.png +0 -0
- package/docs/screenshots/04-mobile.png +0 -0
- package/package.json +57 -0
- package/server/auth/index.js +20 -0
- package/server/auth/manager.js +448 -0
- package/server/auth/oauth.js +154 -0
- package/server/auth/providers/github.js +59 -0
- package/server/auth/providers/google.js +44 -0
- package/server/auth/sessions.js +160 -0
- package/server/auth/store.js +135 -0
- package/server/auth/totp.js +140 -0
- package/server/index.js +1779 -0
- package/server/safety/audit.js +139 -0
- package/server/safety/clientip.js +73 -0
- package/server/safety/index.js +17 -0
- package/server/safety/manager.js +507 -0
- package/server/safety/redact.js +153 -0
- package/server/safety/sandbox.js +130 -0
- package/server/tunnel/adapters/cloudflare-quick.js +40 -0
- package/server/tunnel/adapters/cloudflared-named.js +49 -0
- package/server/tunnel/adapters/devtunnel.js +54 -0
- package/server/tunnel/adapters/tailscale.js +42 -0
- package/server/tunnel/base-adapter.js +185 -0
- package/server/tunnel/detect.js +65 -0
- package/server/tunnel/index.js +15 -0
- package/server/tunnel/manager.js +321 -0
- package/server/tunnel/registry.js +31 -0
- package/test/stage4-boot.js +98 -0
- 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 };
|