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,139 @@
1
+ /**
2
+ * AnyAgent Bridge — audit log (Stage 4)
3
+ *
4
+ * Append-only JSONL of security-relevant events: file mutations, auth, tunnel and
5
+ * safety actions (REST), plus the semantic agent commands over WebSocket
6
+ * (agent.start / agent.send). Raw terminal keystrokes are deliberately NOT logged —
7
+ * they are character-at-a-time with line editing and TUI control codes and cannot
8
+ * be reconstructed into discrete commands without a shell parser; logging them
9
+ * would be misleading and high-volume. The audited surface is therefore:
10
+ * REST mutations + agent.start + agent.send + kill/panic.
11
+ *
12
+ * Properties:
13
+ * - One line = one `JSON.stringify(entry)` (newlines inside fields are escaped by
14
+ * JSON, so attacker-controlled paths/text cannot forge a log line).
15
+ * - Every string field is run through the redactor's scrub() before write, so a
16
+ * secret typed as a path or sent to an agent never persists in the log (this is
17
+ * ALWAYS on when audit is on, independent of live-stream redaction).
18
+ * - Writes are SYNCHRONOUS (fs.appendFileSync) and ordered. Audit events fire after
19
+ * the HTTP response has finished (res.on('finish')) or after a WS command, so the
20
+ * sub-millisecond append never adds latency to the client path, and every event is
21
+ * durable the instant it is recorded (no async tail to lose on crash/exit). The
22
+ * audited surface is low-rate (mutations + agent commands, not keystrokes), so a
23
+ * sync append is cheap. A write failure is swallowed (one warn) — a full disk must
24
+ * not take down the bridge.
25
+ * - Rotation: per-UTC-day file, plus size rollover to `…​.N.jsonl`. Retention prune
26
+ * on boot by a strict filename regex (never deletes by prefix).
27
+ *
28
+ * Zero dependencies (Node core only).
29
+ */
30
+
31
+ 'use strict';
32
+
33
+ const fs = require('fs');
34
+ const path = require('path');
35
+
36
+ const FILE_RE = /^audit-\d{4}-\d{2}-\d{2}(?:\.\d+)?\.jsonl$/;
37
+
38
+ function utcDay(d) {
39
+ const t = d || new Date();
40
+ return `${t.getUTCFullYear()}-${String(t.getUTCMonth() + 1).padStart(2, '0')}-${String(t.getUTCDate()).padStart(2, '0')}`;
41
+ }
42
+
43
+ class AuditLog {
44
+ constructor(opts) {
45
+ const o = opts || {};
46
+ this.dir = o.dir;
47
+ this.maxFileBytes = o.maxFileBytes || 10 * 1024 * 1024;
48
+ this.retentionDays = o.retentionDays || 30;
49
+ this.scrub = typeof o.scrub === 'function' ? o.scrub : (s => s);
50
+ this.logger = o.logger || console;
51
+ this.seq = 0;
52
+ this._rollIndex = 0;
53
+ this._day = null;
54
+ this._warned = false;
55
+ this._ready = false;
56
+ }
57
+
58
+ init() {
59
+ try {
60
+ fs.mkdirSync(this.dir, { recursive: true });
61
+ this._pruneOld();
62
+ this._ready = true;
63
+ } catch (e) {
64
+ this.logger.warn(`[Audit] init failed (${e.message}) — audit disabled for this run`);
65
+ this._ready = false;
66
+ }
67
+ return this;
68
+ }
69
+
70
+ _pruneOld() {
71
+ let names;
72
+ try { names = fs.readdirSync(this.dir); } catch (e) { return; }
73
+ const cutoff = Date.now() - this.retentionDays * 24 * 60 * 60 * 1000;
74
+ for (const name of names) {
75
+ if (!FILE_RE.test(name)) continue; // never touch non-audit files
76
+ const full = path.join(this.dir, name);
77
+ try {
78
+ if (fs.statSync(full).mtimeMs < cutoff) fs.unlinkSync(full);
79
+ } catch (e) { /* ignore */ }
80
+ }
81
+ }
82
+
83
+ _resolveTargetFile() {
84
+ const day = utcDay();
85
+ if (day !== this._day) {
86
+ this._day = day;
87
+ this._rollIndex = 0;
88
+ }
89
+ let file = path.join(this.dir, `audit-${day}.jsonl`);
90
+ // pick up an existing day file's size, and roll forward if over the cap
91
+ for (;;) {
92
+ let size = 0;
93
+ try { size = fs.existsSync(file) ? fs.statSync(file).size : 0; } catch (e) { size = 0; }
94
+ if (size < this.maxFileBytes) return file;
95
+ this._rollIndex += 1;
96
+ file = path.join(this.dir, `audit-${day}.${this._rollIndex}.jsonl`);
97
+ }
98
+ }
99
+
100
+ /** Record an event synchronously. Never throws, never blocks the client path. */
101
+ record(entry) {
102
+ if (!this._ready) return;
103
+ try {
104
+ const line = JSON.stringify(this._sanitize(entry)) + '\n';
105
+ const file = this._resolveTargetFile();
106
+ fs.appendFileSync(file, line);
107
+ } catch (e) {
108
+ if (!this._warned) { this._warned = true; this.logger.warn(`[Audit] record failed: ${e.message}`); }
109
+ }
110
+ }
111
+
112
+ _sanitize(entry) {
113
+ const e = entry || {};
114
+ const scrub = this.scrub;
115
+ const s = (v) => (typeof v === 'string' ? scrub(v) : v);
116
+ return {
117
+ ts: new Date().toISOString(),
118
+ seq: ++this.seq,
119
+ action: s(e.action) || 'unknown',
120
+ method: e.method || undefined,
121
+ path: s(e.path),
122
+ target: s(e.target),
123
+ actor: e.actor || null,
124
+ termSessionId: e.termSessionId != null ? e.termSessionId : undefined,
125
+ clientIP: e.clientIP || undefined,
126
+ status: e.status != null ? e.status : undefined,
127
+ note: s(e.note)
128
+ };
129
+ }
130
+
131
+ /** No-op: writes are already synchronous. Kept for the shutdown call site. */
132
+ flushSync() { /* writes are synchronous; nothing buffered to drain */ }
133
+
134
+ count() { return this.seq; }
135
+ }
136
+
137
+ function createAuditLog(opts) { return new AuditLog(opts).init(); }
138
+
139
+ module.exports = { createAuditLog, AuditLog, FILE_RE };
@@ -0,0 +1,73 @@
1
+ /**
2
+ * AnyAgent Bridge — client IP resolution (Stage 4, closes a Stage 3 residual)
3
+ *
4
+ * Stage 3 trusted `X-Forwarded-For` unconditionally and took the LEFTMOST entry —
5
+ * both attacker-controllable, so a remote client could spoof its IP to dodge the
6
+ * per-IP login rate limit and to forge the audit clientIP. This resolver makes the
7
+ * trust explicit and opt-in.
8
+ *
9
+ * trustProxy = false (or unset) → ignore XFF entirely; use the direct socket peer.
10
+ * trustProxy = true → trust ONE proxy hop: take the RIGHTMOST XFF
11
+ * entry (the address the nearest proxy saw),
12
+ * NOT the spoofable leftmost.
13
+ * trustProxy = N (number) → trust N proxy hops: take the Nth-from-right
14
+ * XFF entry.
15
+ *
16
+ * Pure and never throws. Used by BOTH the login rate limiter and the audit log so
17
+ * the two always agree. IMPORTANT (byte-identical rule): the caller only routes
18
+ * through here when trustProxy was EXPLICITLY configured; with no config the
19
+ * original Stage-3 expression is kept verbatim.
20
+ */
21
+
22
+ 'use strict';
23
+
24
+ // `::ffff:1.2.3.4` (IPv4-mapped IPv6) and `::1` are common from Node sockets;
25
+ // normalize the mapped form so allowlists/compares see a plain IPv4 address.
26
+ function normalizeIP(ip) {
27
+ if (typeof ip !== 'string' || !ip) return 'unknown';
28
+ let s = ip.trim();
29
+ const m = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(s);
30
+ if (m) s = m[1];
31
+ return s || 'unknown';
32
+ }
33
+
34
+ function xffParts(req) {
35
+ const raw = req && req.headers && req.headers['x-forwarded-for'];
36
+ if (!raw || typeof raw !== 'string') return [];
37
+ return raw.split(',').map(p => p.trim()).filter(Boolean);
38
+ }
39
+
40
+ function socketIP(req) {
41
+ const sock = req && req.socket;
42
+ return normalizeIP((sock && sock.remoteAddress) || 'unknown');
43
+ }
44
+
45
+ /**
46
+ * Resolve the effective client IP for `req` under the given trustProxy policy.
47
+ * Handles both Express requests and the raw WS-upgrade request (both expose
48
+ * `.headers` and `.socket`).
49
+ */
50
+ function resolveClientIP(req, trustProxy) {
51
+ try {
52
+ if (trustProxy === undefined || trustProxy === null || trustProxy === false) {
53
+ return socketIP(req);
54
+ }
55
+ const parts = xffParts(req);
56
+ if (parts.length === 0) return socketIP(req);
57
+
58
+ let hops;
59
+ if (trustProxy === true) hops = 1;
60
+ else {
61
+ const n = parseInt(trustProxy, 10);
62
+ hops = Number.isFinite(n) && n > 0 ? n : 1;
63
+ }
64
+ // Take the entry `hops` from the right (the client as seen past N trusted hops).
65
+ const idx = parts.length - hops;
66
+ const pick = idx >= 0 ? parts[idx] : parts[0];
67
+ return normalizeIP(pick);
68
+ } catch (e) {
69
+ return socketIP(req);
70
+ }
71
+ }
72
+
73
+ module.exports = { resolveClientIP, normalizeIP };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * AnyAgent Bridge — safety subsystem entry (Stage 4)
3
+ *
4
+ * The only module server/index.js imports for Stage 4. Creates a SafetyManager that
5
+ * wires the Docker sandbox, kill-switch, audit log, and secret redaction on top of
6
+ * Stage 3. When `safety.enabled` is false (the default) the manager is inert and the
7
+ * server is byte-identical to Stage 3.
8
+ */
9
+
10
+ const SafetyManager = require('./manager');
11
+ const { resolveClientIP } = require('./clientip');
12
+
13
+ function createSafetyManager(safetyConfig, deps) {
14
+ return new SafetyManager(safetyConfig, deps).init();
15
+ }
16
+
17
+ module.exports = { createSafetyManager, resolveClientIP };