@agenthippo-ai/agenthippo 0.2.3-dataplane → 1.110.9

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 (130) hide show
  1. package/out/analytics/dashboard/provider.js +13 -2
  2. package/out/analytics/dashboard/webview/panel.js +3 -2
  3. package/out/analytics/identity.js +0 -0
  4. package/out/analytics/service.js +23 -25
  5. package/out/analytics/sessionEvents.js +98 -23
  6. package/out/auth/authBaseUrl.js +59 -0
  7. package/out/auth/bootstrapApply.js +9 -10
  8. package/out/auth/brokerClient.js +48 -14
  9. package/out/auth/provider.js +8 -21
  10. package/out/auth/sessionStore.js +3 -40
  11. package/out/auth/signInFlow.js +2 -1
  12. package/out/auth-broker/config.js +50 -5
  13. package/out/auth-broker/server.js +122 -26
  14. package/out/auth-broker/store.js +25 -24
  15. package/out/authority/warrant.js +206 -0
  16. package/out/branding.js +27 -1
  17. package/out/cli/commands/conformance.js +249 -0
  18. package/out/cli/commands/data-plane.js +67 -0
  19. package/out/cli/commands/deploy.js +1 -0
  20. package/out/cli/commands/gateway.js +4 -0
  21. package/out/cli/commands/pack.js +182 -3
  22. package/out/cli/commands/sandbox.js +332 -0
  23. package/out/cli/commands/serve.js +875 -52
  24. package/out/cli/commands/store.js +12 -4
  25. package/out/cli/data-plane/gateway.js +28 -5
  26. package/out/cli/data-plane/index.js +17 -2
  27. package/out/cli/data-plane/registry.js +9 -0
  28. package/out/cli/data-plane/remote.js +106 -0
  29. package/out/cli/data-plane/sidecar.js +161 -0
  30. package/out/cli/deploy/layers/identity.js +85 -4
  31. package/out/cli/deploy-init.js +89 -24
  32. package/out/cli/deps.js +106 -2
  33. package/out/cli/idp-init.js +35 -0
  34. package/out/cli/index.js +155 -16
  35. package/out/cli/pack-install-ref.js +15 -0
  36. package/out/cli/pack-loader.js +8 -1
  37. package/out/cli/pack-runtime.js +61 -0
  38. package/out/cli/runtime.js +35 -1
  39. package/out/cli/serve-hardening.js +80 -3
  40. package/out/cli/utils.js +12 -1
  41. package/out/engines/base.js +32 -3
  42. package/out/engines/claude.js +33 -15
  43. package/out/engines/codex.js +110 -26
  44. package/out/engines/custom.js +8 -0
  45. package/out/evidence/sessionShip.js +610 -0
  46. package/out/extension.js +11 -2
  47. package/out/gateway/bridge.js +40 -2
  48. package/out/gateway/client.js +34 -1
  49. package/out/gateway/installSource.js +68 -0
  50. package/out/gateway/lifecycle.js +185 -51
  51. package/out/gateway/outboundPaths.js +4 -1
  52. package/out/gateway/secretsPreflight.js +90 -0
  53. package/out/gateway/service.js +7 -2
  54. package/out/headless/index.js +17 -1
  55. package/out/live-e2e/mcp-demo/server.mjs +32 -0
  56. package/out/mcp.js +30 -0
  57. package/out/pack/caps.js +271 -0
  58. package/out/pack/governor.js +309 -0
  59. package/out/pack/lease-schema.js +270 -0
  60. package/out/pack/lease.js +640 -0
  61. package/out/pack/reconcile.js +217 -0
  62. package/out/pack/signature.js +839 -0
  63. package/out/remote/handleRemoteChat.js +44 -3
  64. package/out/remote/remoteTrust.js +277 -0
  65. package/out/remote/serveClient.js +52 -5
  66. package/out/runner/host.js +131 -0
  67. package/out/runner/protocol.js +108 -0
  68. package/out/runner/remoteEngine.js +130 -0
  69. package/out/runtime/interfaces/edit-tracker.js +2 -0
  70. package/out/runtime/vscode/vscode-edit-tracker.js +2 -0
  71. package/out/sandbox/containerDriver.js +570 -0
  72. package/out/sandbox/processDriver.js +315 -0
  73. package/out/sandbox/sandboxRunner.js +191 -0
  74. package/out/sandbox/support.js +301 -0
  75. package/out/sandbox/types.js +55 -0
  76. package/out/scripts/agentide-litellm.sh +67 -5
  77. package/out/scripts/agentide_litellm_hooks_v1.py +182 -21
  78. package/out/scripts/analytics/agentide-litellm-otel.config.yaml +94 -132
  79. package/out/scripts/analytics/setup-analytics.ps1 +37 -13
  80. package/out/scripts/analytics/setup-analytics.sh +93 -13
  81. package/out/scripts/analytics/start-analytics.sh +86 -14
  82. package/out/scripts/gateway/setup-gateway.sh +72 -32
  83. package/out/shared/models.js +11 -110
  84. package/out/spotlight/install.js +7 -1
  85. package/out/store/index.js +41 -10
  86. package/out/sync.js +3 -1
  87. package/out/test/analytics.test.js +162 -23
  88. package/out/test/auth-baseurl.test.js +132 -0
  89. package/out/test/auth-bootstrapApply.test.js +27 -6
  90. package/out/test/auth-broker-hardening.test.js +227 -0
  91. package/out/test/auth-broker.test.js +59 -9
  92. package/out/test/branding.test.js +13 -0
  93. package/out/test/caps-governor.test.js +455 -0
  94. package/out/test/codex-new-file-survival.test.js +119 -0
  95. package/out/test/configuration-scopes.test.js +131 -0
  96. package/out/test/data-plane.test.js +67 -0
  97. package/out/test/deploy-init.test.js +40 -0
  98. package/out/test/gateway-client.test.js +28 -0
  99. package/out/test/gateway-install-source.test.js +111 -0
  100. package/out/test/gateway-lifecycle.test.js +1 -1
  101. package/out/test/gateway-outbound.test.js +7 -0
  102. package/out/test/gateway-secrets-preflight.test.js +127 -0
  103. package/out/test/gateway-session-routing.test.js +18 -0
  104. package/out/test/governor-hardening.test.js +217 -0
  105. package/out/test/headless-sync.test.js +22 -0
  106. package/out/test/lease-signature.test.js +357 -0
  107. package/out/test/lease-trust-root.test.js +273 -0
  108. package/out/test/lease-validation.test.js +169 -0
  109. package/out/test/lease.test.js +114 -0
  110. package/out/test/mcp.test.js +27 -1
  111. package/out/test/pack-admission.test.js +216 -0
  112. package/out/test/pack-digest-parity.test.js +140 -0
  113. package/out/test/pack-reconcile.test.js +183 -0
  114. package/out/test/pack-signature-artifact.test.js +103 -0
  115. package/out/test/pack-signature.test.js +176 -0
  116. package/out/test/pack-trust-root.test.js +202 -0
  117. package/out/test/remote-trust.test.js +242 -0
  118. package/out/test/runner-protocol.test.js +249 -0
  119. package/out/test/sandbox-driver.test.js +553 -0
  120. package/out/test/sandbox-hardening.test.js +395 -0
  121. package/out/test/sandbox-runner.test.js +343 -0
  122. package/out/test/serve-hardening.test.js +45 -6
  123. package/out/test/serveClient.test.js +6 -5
  124. package/out/test/sessionShip.test.js +560 -0
  125. package/out/test/spotlightPublish.test.js +28 -0
  126. package/out/test/store-install-safe.test.js +69 -0
  127. package/out/test/tls-bypass.test.js +90 -0
  128. package/out/test/warrant.test.js +138 -0
  129. package/out/utils.js +46 -9
  130. package/package.json +1 -1
@@ -0,0 +1,570 @@
1
+ "use strict";
2
+ /*---------------------------------------------------------------------------------------------
3
+ * Copyright (c) AgentHippo.ai. All rights reserved.
4
+ *--------------------------------------------------------------------------------------------*/
5
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
6
+ if (k2 === undefined) k2 = k;
7
+ var desc = Object.getOwnPropertyDescriptor(m, k);
8
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
9
+ desc = { enumerable: true, get: function() { return m[k]; } };
10
+ }
11
+ Object.defineProperty(o, k2, desc);
12
+ }) : (function(o, m, k, k2) {
13
+ if (k2 === undefined) k2 = k;
14
+ o[k2] = m[k];
15
+ }));
16
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
17
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
18
+ }) : function(o, v) {
19
+ o["default"] = v;
20
+ });
21
+ var __importStar = (this && this.__importStar) || (function () {
22
+ var ownKeys = function(o) {
23
+ ownKeys = Object.getOwnPropertyNames || function (o) {
24
+ var ar = [];
25
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
26
+ return ar;
27
+ };
28
+ return ownKeys(o);
29
+ };
30
+ return function (mod) {
31
+ if (mod && mod.__esModule) return mod;
32
+ var result = {};
33
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
34
+ __setModuleDefault(result, mod);
35
+ return result;
36
+ };
37
+ })();
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.ContainerSandboxDriver = exports.SANDBOX_PACK_DIR = exports.SANDBOX_WORKSPACE_DIR = exports.SANDBOX_STATE_DIR = exports.SANDBOX_SOCKET_DIR_IN_SANDBOX = void 0;
40
+ exports.strictEgressFromEnv = strictEgressFromEnv;
41
+ exports.prepareSocketDir = prepareSocketDir;
42
+ exports.assertOwnSocket = assertOwnSocket;
43
+ /**
44
+ * The `container` driver (tier 2) — one disposable container per session.
45
+ *
46
+ * What delivers the boundary, concretely:
47
+ * - `--network none` plus one explicit user-defined network per declared route. A sandbox with no
48
+ * routes has no network at all; no destination is reachable because a prompt asked nicely.
49
+ * The strength of the boundary for a sandbox that DOES declare routes is the topology of those
50
+ * networks: `--internal` ones make everything else unroutable, a NAT'd one does not. The driver
51
+ * does not create them and cannot make them internal, so it verifies and reports instead of
52
+ * asserting — see `assertRouteNetworks`. An inference route to an external proxy is the case
53
+ * that cannot be internal, and it is the common one; the disclosure belongs in the deployment.
54
+ * - a per-sandbox unix socket bind-mounted ONLY into that container. Reaching the socket ⇔ being
55
+ * the sandbox, so the channel is the sandbox's identity — no certificates needed at this tier.
56
+ * - the image is pinned BY DIGEST; a mutable tag would make the signing and rollback story false.
57
+ * - the container holds no secret: `spec.env` is non-secret only, and the per-sandbox model token
58
+ * rides the run frame instead of the environment.
59
+ *
60
+ * The docker CLI is injected so this class is testable without a daemon; a live smoke test is
61
+ * the acceptance gate for the driver itself.
62
+ *
63
+ * **Platform note (verified 2026-07-25):** the socket channel requires a Linux host. On macOS
64
+ * Docker Desktop the sandbox binds its socket fine and connects to it from *inside* the container,
65
+ * but the host cannot connect through the VM's file-sharing layer (`ECONNREFUSED`) — unix
66
+ * sockets do not traverse virtiofs/gRPC-FUSE. Production targets (VM, Cloud Run, K8s) are all
67
+ * Linux, so this affects local development on macOS only; use the `process` driver there.
68
+ */
69
+ const crypto = __importStar(require("node:crypto"));
70
+ const fs = __importStar(require("node:fs"));
71
+ const net = __importStar(require("node:net"));
72
+ const os = __importStar(require("node:os"));
73
+ const path = __importStar(require("node:path"));
74
+ const types_1 = require("./types");
75
+ /**
76
+ * Read the strict-egress opt-in from the environment.
77
+ *
78
+ * Env rather than a constructor argument so a deployment can turn it on without the composition
79
+ * root having to thread a flag; the driver still accepts an explicit override for tests.
80
+ */
81
+ function strictEgressFromEnv(env = process.env) {
82
+ return env.AGENTHIPPO_SANDBOX_STRICT_EGRESS === '1';
83
+ }
84
+ /**
85
+ * Where per-sandbox unix sockets live on the HOST.
86
+ *
87
+ * `/run/agenthippo/sandboxes` is the right default — `/run` is tmpfs, so a killed serve leaves
88
+ * no stale socket files across a reboot. But `/run` is root-owned, and serve running as an
89
+ * ordinary user cannot create a directory there: every tier-2 turn then dies with
90
+ * `EACCES: mkdir '/run/agenthippo/sandboxes/…'` (observed on Amazon Linux, 2026-07-27). An
91
+ * unprivileged runtime must not be un-deployable for want of a directory, so fall back.
92
+ *
93
+ * Order: explicit env → `/run/agenthippo/sandboxes` if usable → `$XDG_RUNTIME_DIR` → tmpdir.
94
+ * Every candidate is kept SHORT on purpose: a unix socket path is capped near 104 bytes on
95
+ * macOS / 108 on Linux, and the sandbox id plus `/sandbox.sock` is appended to whatever we
96
+ * return — a deep fallback would fail at bind() with a far less obvious error than this one.
97
+ */
98
+ function resolveDefaultSocketDir() {
99
+ const candidates = [
100
+ process.env.AGENTHIPPO_SANDBOX_SOCKET_DIR,
101
+ '/run/agenthippo/sandboxes',
102
+ process.env.XDG_RUNTIME_DIR ? path.join(process.env.XDG_RUNTIME_DIR, 'agenthippo-sandboxes') : undefined,
103
+ path.join(os.tmpdir(), 'agenthippo-sandboxes'),
104
+ ].filter((c) => Boolean(c));
105
+ for (const dir of candidates) {
106
+ try {
107
+ fs.mkdirSync(dir, { recursive: true });
108
+ fs.accessSync(dir, fs.constants.W_OK);
109
+ // Leave room for `/<sandbox-id>/sandbox.sock` (~40 chars) inside the platform limit.
110
+ if (dir.length > 60) {
111
+ continue;
112
+ }
113
+ return dir;
114
+ }
115
+ catch {
116
+ // Not creatable or not writable by this user — try the next candidate.
117
+ }
118
+ }
119
+ // Nothing worked: return the canonical path so the failure names the intended location
120
+ // rather than a temp dir the operator never configured.
121
+ return '/run/agenthippo/sandboxes';
122
+ }
123
+ const DEFAULT_SOCKET_DIR = resolveDefaultSocketDir();
124
+ /** How long a sandbox may take to boot and bind. Pack sync + engine dep checks dominate this. */
125
+ const SOCKET_WAIT_MS = 90_000;
126
+ const SOCKET_POLL_MS = 150;
127
+ /** Where each sandbox's socket directory appears INSIDE the sandbox. The sandbox binds `sandbox.sock` here. */
128
+ exports.SANDBOX_SOCKET_DIR_IN_SANDBOX = '/run/sandbox';
129
+ /**
130
+ * Canonical in-sandbox paths. These are the `sandbox` command's own defaults (`--state-dir /state`,
131
+ * so `--workspace` resolves to `/state/workspace`) and the pack directory generated deploy
132
+ * images use. serve mounts the host workspace and pack ONTO them, so the two sides cannot
133
+ * drift: without the workspace mount a sandbox's work is container-private and dies with the sandbox.
134
+ */
135
+ exports.SANDBOX_STATE_DIR = '/state';
136
+ exports.SANDBOX_WORKSPACE_DIR = '/state/workspace';
137
+ exports.SANDBOX_PACK_DIR = '/app/pack';
138
+ class ContainerSandboxDriver {
139
+ opts;
140
+ kind = 'container';
141
+ tier = 2;
142
+ sandboxes = new Map();
143
+ seq = 0;
144
+ /**
145
+ * Distinguishes THIS serve process's sandboxes from any other's. A bare counter restarts at 1 on
146
+ * every serve, so a restarted instance reused the previous one's container names AND socket
147
+ * paths — see the stale-socket note in `create`. Cheap, and it makes two serves on one host
148
+ * (blue/green, a stray process) safe by construction rather than by luck.
149
+ */
150
+ instance = crypto.randomBytes(2).toString('hex');
151
+ constructor(opts) {
152
+ this.opts = opts;
153
+ }
154
+ async create(spec) {
155
+ assertPinnedByDigest(spec.image);
156
+ const id = `sandbox-${this.instance}-${++this.seq}-${sanitize(spec.sessionKey)}`;
157
+ // One DIRECTORY per sandbox, mounted at a fixed path inside. Docker cannot bind-mount a
158
+ // socket file that does not exist yet — it would create a directory in its place and the
159
+ // sandbox's bind() then fails (observed live). The sandbox creates the socket inside its own
160
+ // mounted directory, which keeps the "only this sandbox's channel" property intact.
161
+ const socketDir = this.opts.socketDir ?? DEFAULT_SOCKET_DIR;
162
+ const sandboxSocketDir = path.join(socketDir, id);
163
+ const socketPath = path.join(sandboxSocketDir, 'sandbox.sock');
164
+ // Purge first: this directory must contain THIS sandbox's socket and nothing else.
165
+ // A serve that is killed leaves its sandboxes' `sandbox.sock` files behind (a bound socket file
166
+ // outlives its process), and `mkdirSync(recursive)` happily adopts such a directory. The
167
+ // waiter below then sees a socket that exists but has no listener, hands back a handle
168
+ // immediately, and serve dials a corpse — ECONNREFUSED, reported as the useless "sandbox
169
+ // channel closed", with the retry landing on the NEXT stale path and failing identically.
170
+ // Diagnosed live: two containers per turn, both healthy, every turn failing until serve
171
+ // was restarted. The unique instance id above makes reuse unlikely; this makes it
172
+ // impossible, including across a serve that died mid-sandbox.
173
+ fs.rmSync(sandboxSocketDir, { recursive: true, force: true });
174
+ prepareSocketDir(sandboxSocketDir, this.opts.socketOwner);
175
+ // Default-deny: the sandbox never joins the default bridge. A sandbox with no declared routes
176
+ // gets `none`; otherwise it is created directly on its FIRST route's network and the
177
+ // rest are attached after. Docker refuses `network connect` on a container created in
178
+ // `none` mode ("cannot be connected to multiple networks with one of the networks in
179
+ // private (none) mode"), so create-then-attach only works this way — verified live.
180
+ const routeNetworks = spec.routes.map((r) => ({ kind: r.kind, network: routeNetwork(r.target) }));
181
+ const egressContained = await this.assertRouteNetworks(routeNetworks);
182
+ // The tier this sandbox ACTUALLY gets. Tier 2's distinguishing property over tier 1 is that
183
+ // the sandbox cannot reach a destination it was not routed to; a NAT'd route network hands
184
+ // it the internet, so the honest report is 1. This value — not `this.tier` — is what the
185
+ // handle carries and what is stamped into the sandbox's own environment, so no consumer
186
+ // downstream can claim a containment level that was never built.
187
+ const effectiveTier = egressContained ? this.tier : 1;
188
+ const primary = routeNetworks[0];
189
+ const args = [
190
+ 'run', '--detach', '--name', id,
191
+ '--network', primary ? primary.network : 'none',
192
+ // Least privilege, cheap and unconditional.
193
+ '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges',
194
+ '--read-only',
195
+ // A read-only root REQUIRES a writable /tmp. Codex's Linux sandbox shells out to
196
+ // bubblewrap, which creates a mount registry under /tmp and panics without it
197
+ // ("failed to create synthetic bubblewrap mount registry … Read-only file system"),
198
+ // so every shell command and file write inside the sandbox failed. `exec` is required —
199
+ // bubblewrap execs from there. Observed live on tier 2.
200
+ '--tmpfs', '/tmp:rw,exec,nosuid,size=512m',
201
+ // The sandbox's state root must be writable on a read-only rootfs: it holds HOME
202
+ // (/state/home — engine SDKs write dot-dirs there) and per-engine caches, and the
203
+ // sandbox exits at boot with `EROFS: mkdir '/state/home'` without it (observed live on
204
+ // tier 2). mode=1777 (sticky, like /tmp) on the tmpfs form: a fresh tmpfs is
205
+ // root-owned 0755 and the runtime image runs NON-ROOT, so the sandbox got
206
+ // `EACCES: mkdir '/state/home'` instead — the same boot failure one step later.
207
+ //
208
+ // A per-SESSION host directory replaces the tmpfs when one is given, and that is what
209
+ // makes session continuity possible at all: `provider-sessions/` and the engines'
210
+ // own rollout files live under this root, so a tmpfs here means every new sandbox starts
211
+ // a brand-new engine thread and the conversation silently restarts (the §8.4 resume
212
+ // gate — it failed 100% of the time before this). Sandboxes stay disposable; the SESSION's
213
+ // state is what survives them.
214
+ ...(spec.stateDir
215
+ ? ['--volume', `${spec.stateDir}:${exports.SANDBOX_STATE_DIR}`]
216
+ : ['--tmpfs', `${exports.SANDBOX_STATE_DIR}:rw,exec,nosuid,size=512m,mode=1777`]),
217
+ // The channel: this sandbox's own socket directory and no other's.
218
+ '--volume', `${sandboxSocketDir}:${exports.SANDBOX_SOCKET_DIR_IN_SANDBOX}`,
219
+ '--label', `ai.agenthippo.sandbox=${id}`,
220
+ '--label', `ai.agenthippo.session=${spec.sessionKey}`,
221
+ '--label', `ai.agenthippo.tier=${spec.tier}`,
222
+ ];
223
+ for (const entry of spec.fs.readonly) {
224
+ args.push('--volume', mountSpec(entry, true));
225
+ }
226
+ for (const entry of spec.fs.writable) {
227
+ args.push('--volume', mountSpec(entry, false));
228
+ }
229
+ if (spec.limits.memMiB) {
230
+ args.push('--memory', `${spec.limits.memMiB}m`);
231
+ }
232
+ if (spec.limits.cpu) {
233
+ args.push('--cpus', spec.limits.cpu);
234
+ }
235
+ for (const [k, v] of Object.entries(spec.env ?? {})) {
236
+ args.push('--env', `${k}=${v}`);
237
+ }
238
+ // Last, so the driver's own tier wins over anything the caller put in `spec.env`. This is
239
+ // what lets an engine inside the container know a real boundary surrounds it — and what
240
+ // keeps the tier-0 driver from ever making the same claim.
241
+ args.push('--env', `${types_1.SANDBOX_TIER_ENV}=${effectiveTier}`);
242
+ args.push(spec.image);
243
+ const run = await this.opts.exec(args);
244
+ if (run.code !== 0) {
245
+ // A failed `docker run` can still leave the container OBJECT behind (e.g. created,
246
+ // then failed to start because a declared route network is missing). Without this
247
+ // sweep the name stays taken and every retry for the same session fails — observed
248
+ // live. Fail closed AND leave nothing behind.
249
+ await this.opts.exec(['rm', '--force', id]);
250
+ throw new Error(`failed to create sandbox ${id}: ${run.stdout.trim() || `exit ${run.code}`}`);
251
+ }
252
+ const containerId = run.stdout.trim();
253
+ // Exactly the declared routes, and nothing else: each route is one user-defined network
254
+ // the destination also sits on. The primary was attached at create; attach the rest.
255
+ const networks = new Map();
256
+ if (primary) {
257
+ networks.set(primary.kind, primary.network);
258
+ }
259
+ for (const route of routeNetworks.slice(1)) {
260
+ const attach = await this.opts.exec(['network', 'connect', route.network, id]);
261
+ if (attach.code !== 0) {
262
+ // Fail closed: a sandbox that came up without one of its declared routes is not the
263
+ // sandbox that was asked for — tear it down rather than run it half-wired.
264
+ await this.opts.exec(['rm', '--force', id]);
265
+ throw new Error(`failed to attach ${route.kind} route for sandbox ${id}: ${attach.stdout.trim()}`);
266
+ }
267
+ networks.set(route.kind, route.network);
268
+ }
269
+ // `docker run --detach` returns as soon as the container STARTS, but the sandbox only binds
270
+ // its socket after ensureDeps + pack sync — seconds later. Handing back a handle now made
271
+ // serve dial a socket that did not exist yet, so the first turn failed with the useless
272
+ // "sandbox channel closed" and the retry raced a second sandbox into existence (observed live on
273
+ // tier 2: two containers per turn, both healthy, turn failed). Wait for the socket, and if
274
+ // the sandbox dies while we wait, surface ITS error instead of a connection failure.
275
+ await this.waitForSocket(id, socketPath);
276
+ const sandbox = { containerId, socketPath, networks };
277
+ if (spec.limits.ttlSeconds > 0) {
278
+ sandbox.ttl = setTimeout(() => { void this.destroyById(id); }, spec.limits.ttlSeconds * 1000);
279
+ sandbox.ttl.unref?.();
280
+ }
281
+ this.sandboxes.set(id, sandbox);
282
+ const connect = this.opts.connectFn ?? unixSocketChannel;
283
+ return {
284
+ id,
285
+ tier: effectiveTier,
286
+ endpoint: socketPath,
287
+ connect: () => connect(socketPath),
288
+ };
289
+ }
290
+ /**
291
+ * Check each declared route network BEFORE `docker run`, and say what is actually wrong.
292
+ *
293
+ * Two different failures used to look identical (`docker run` exits non-zero, container object
294
+ * left behind, sandbox creation fails on the first turn):
295
+ * - the network does not exist, because nothing in the deployment ever created it. Nothing in
296
+ * this codebase creates route networks; that is the operator's job, so the error must say so.
297
+ * - the network exists but is NOT internal, i.e. it is NAT'd to the internet. The sandbox then
298
+ * reaches arbitrary hosts, and the tier's "unroutable by construction" property is simply
299
+ * not delivered. That is a disclosure, not a refusal: an inference route to an external
300
+ * proxy CANNOT be internal, so refusing here would forbid the normal deployment. Say it out
301
+ * loud instead of letting the claim stand unverified.
302
+ *
303
+ * Two things changed after this was audited. First, `AGENTHIPPO_SANDBOX_STRICT_EGRESS=1` (or
304
+ * `opts.strictEgress`) turns the disclosure into a REFUSAL, for deployments that need the
305
+ * containment claim to be true rather than documented. Second — and unconditionally — a
306
+ * non-internal route network is now reflected in the tier the driver REPORTS
307
+ * (`effectiveTier`), so nothing downstream can advertise a containment level that is not
308
+ * being enforced. A warning nobody reads is not a boundary; a tier that reads 1 instead of 2
309
+ * travels into the warrant and the evidence.
310
+ *
311
+ * Returns true when every declared route network is `--internal`.
312
+ */
313
+ async assertRouteNetworks(routes) {
314
+ const checked = new Set();
315
+ let allInternal = true;
316
+ for (const route of routes) {
317
+ if (checked.has(route.network)) {
318
+ continue;
319
+ }
320
+ checked.add(route.network);
321
+ const res = await this.opts.exec(['network', 'inspect', '--format', '{{.Internal}}', route.network]);
322
+ if (res.code !== 0) {
323
+ throw new Error(`route network "${route.network}" (${route.kind}) does not exist — a tier-2 sandbox is ` +
324
+ `created on its declared route networks and nothing creates them for you. Create it ` +
325
+ `(\`docker network create --internal ${route.network}\`) and put the ${route.kind} ` +
326
+ `service on it.`);
327
+ }
328
+ if (res.stdout.trim() !== 'true') {
329
+ allInternal = false;
330
+ const strict = this.opts.strictEgress ?? strictEgressFromEnv();
331
+ if (strict) {
332
+ throw new Error(`route network "${route.network}" (${route.kind}) is not --internal, and strict egress ` +
333
+ `containment is enabled (AGENTHIPPO_SANDBOX_STRICT_EGRESS=1). A sandbox on this network ` +
334
+ `can reach any host it routes to, so the tier-2 containment claim would be false. ` +
335
+ `Recreate it as internal (\`docker network rm ${route.network} && docker network create ` +
336
+ `--internal ${route.network}\`), or unset AGENTHIPPO_SANDBOX_STRICT_EGRESS to accept a ` +
337
+ `sandbox whose egress is bounded by the network topology instead.`);
338
+ }
339
+ this.opts.logger?.warn(`route network "${route.network}" (${route.kind}) is not --internal: this sandbox can ` +
340
+ `reach any host that network routes to, so tier-2 egress is bounded by that network's ` +
341
+ `topology, not by the sandbox. This sandbox is therefore reported as tier 1, not tier 2. ` +
342
+ `Make the network internal (or set AGENTHIPPO_SANDBOX_STRICT_EGRESS=1 to refuse outright).`);
343
+ }
344
+ }
345
+ return allInternal;
346
+ }
347
+ /**
348
+ * Block until the sandbox has bound its socket, or fail with the reason it never did.
349
+ *
350
+ * A sandbox that dies during boot (EROFS on a read-only /state, an unwritable workspace mount,
351
+ * a missing pack) must report THAT, not a downstream connect error — every one of those was
352
+ * diagnosed live only by hand-reading `docker logs`, which no operator should have to do.
353
+ *
354
+ * Readiness is NOT "the path exists". `fs.existsSync` is true for a regular file, a symlink, or
355
+ * a socket some other local account bound at the predictable path first; each of those is a
356
+ * different sandbox answering for this one. `assertOwnSocket` decides.
357
+ */
358
+ async waitForSocket(id, socketPath) {
359
+ const budgetMs = this.opts.socketWaitMs ?? SOCKET_WAIT_MS;
360
+ // 0 disables the wait outright — for tests that stub the channel and never bind a socket.
361
+ if (budgetMs <= 0) {
362
+ return;
363
+ }
364
+ const deadline = Date.now() + budgetMs;
365
+ while (Date.now() < deadline) {
366
+ if (fs.existsSync(socketPath)) {
367
+ try {
368
+ assertOwnSocket(socketPath, this.opts.socketOwner);
369
+ }
370
+ catch (err) {
371
+ await this.opts.exec(['rm', '--force', id]);
372
+ throw err;
373
+ }
374
+ return;
375
+ }
376
+ // Cheap liveness check: a dead container will never bind, so stop waiting on it.
377
+ const state = await this.opts.exec(['inspect', '--format', '{{.State.Running}}', id]);
378
+ if (state.stdout.trim() === 'false') {
379
+ const logs = await this.opts.exec(['logs', '--tail', '20', id]);
380
+ await this.opts.exec(['rm', '--force', id]);
381
+ throw new Error(`sandbox ${id} exited during boot: ${combinedOutput(logs)}`);
382
+ }
383
+ await new Promise((resolve) => setTimeout(resolve, SOCKET_POLL_MS));
384
+ }
385
+ const logs = await this.opts.exec(['logs', '--tail', '20', id]);
386
+ await this.opts.exec(['rm', '--force', id]);
387
+ throw new Error(`sandbox ${id} did not bind ${socketPath} within ${budgetMs / 1000}s: ${combinedOutput(logs)}`);
388
+ }
389
+ /**
390
+ * Detaching the route's network is a real, immediate boundary change — the sandbox's next
391
+ * connection to that destination fails to route. This is how a cost cap is enforced
392
+ * non-cooperatively: the agent cannot reach around a route it no longer has.
393
+ */
394
+ async revoke(handle, what) {
395
+ const sandbox = this.sandboxes.get(handle.id);
396
+ if (!sandbox) {
397
+ return;
398
+ }
399
+ if (what === 'all') {
400
+ return this.destroyById(handle.id);
401
+ }
402
+ const network = sandbox.networks.get(what);
403
+ if (!network) {
404
+ throw new types_1.RevocationUnsupportedError('container', what);
405
+ }
406
+ const res = await this.opts.exec(['network', 'disconnect', '--force', network, handle.id]);
407
+ if (res.code !== 0) {
408
+ throw new Error(`failed to revoke ${what} for sandbox ${handle.id}: ${res.stdout.trim()}`);
409
+ }
410
+ sandbox.networks.delete(what);
411
+ }
412
+ async destroy(handle) {
413
+ await this.destroyById(handle.id);
414
+ }
415
+ async destroyById(id) {
416
+ const sandbox = this.sandboxes.get(id);
417
+ if (!sandbox) {
418
+ return;
419
+ }
420
+ this.sandboxes.delete(id);
421
+ if (sandbox.ttl) {
422
+ clearTimeout(sandbox.ttl);
423
+ }
424
+ await this.opts.exec(['rm', '--force', id]);
425
+ // The socket directory is this sandbox's; it dies with the sandbox.
426
+ try {
427
+ fs.rmSync(path.dirname(sandbox.socketPath), { recursive: true, force: true });
428
+ }
429
+ catch { /* best effort */ }
430
+ }
431
+ }
432
+ exports.ContainerSandboxDriver = ContainerSandboxDriver;
433
+ /**
434
+ * Create this sandbox's socket directory with the tightest mode that still lets the sandbox bind.
435
+ *
436
+ * What was wrong with the previous `0o733`: write+execute for OTHER, no sticky bit, at a
437
+ * PREDICTABLE path. Any local unprivileged account could therefore create `sandbox.sock` in that
438
+ * directory before the sandbox did, win the bind, and receive the `run` frame — which carries the
439
+ * ES256 inference warrant (`modelToken`) and `turnEnv` with `AGENTHIPPO_TURN_NONCE`, the acting
440
+ * user's data-plane capability. That is: spend the user's inference budget, call their connectors,
441
+ * and return arbitrary text as the agent's answer.
442
+ *
443
+ * Two modes, in order of preference:
444
+ *
445
+ * - **0o700 owned by the sandbox uid** (when the image's uid is known). Nothing but the sandbox
446
+ * and root can traverse, list, or create here, so the race has no participants. This is the
447
+ * right answer and the reason `resolveSandboxImageUser` exists.
448
+ * - **0o1733** otherwise. Still write+execute for other — the container's non-root uid has to be
449
+ * able to create the socket and we do not know which uid that is — but the STICKY bit (0o1000)
450
+ * means only the file's owner (or root) may delete or rename an entry in the directory. An
451
+ * attacker can no longer replace a socket the sandbox already bound; combined with the
452
+ * post-bind ownership check in `assertOwnSocket`, a pre-created hostile socket is detected
453
+ * instead of dialled. `0o700` unconditionally is not an option: the image runs non-root, so
454
+ * bind() fails and the sandbox exits (observed live on Linux).
455
+ */
456
+ function prepareSocketDir(dir, owner) {
457
+ fs.mkdirSync(dir, { recursive: true });
458
+ if (owner && owner.uid !== 0) {
459
+ try {
460
+ fs.chownSync(dir, owner.uid, owner.gid);
461
+ fs.chmodSync(dir, 0o700);
462
+ return;
463
+ }
464
+ catch {
465
+ // Not permitted to chown (serve is not root) — fall through to the sticky mode.
466
+ }
467
+ }
468
+ fs.chmodSync(dir, 0o1733);
469
+ }
470
+ /**
471
+ * Verify the thing we are about to dial is the sandbox's own socket.
472
+ *
473
+ * `lstat` (never `stat`): a symlink planted at the expected path would otherwise be followed to
474
+ * whatever the attacker chose. Three conditions, all required:
475
+ * - it is a socket, not a symlink/file/fifo;
476
+ * - it is owned by us or by the sandbox's uid — not by some third local account;
477
+ * - it lives in the directory we just created for this sandbox.
478
+ */
479
+ function assertOwnSocket(socketPath, owner, self = typeof process.getuid === 'function' ? process.getuid() : undefined) {
480
+ const st = fs.lstatSync(socketPath);
481
+ if (st.isSymbolicLink()) {
482
+ throw new Error(`sandbox socket ${socketPath} is a symlink — refusing to connect`);
483
+ }
484
+ if (!st.isSocket()) {
485
+ throw new Error(`sandbox socket ${socketPath} is not a unix socket — refusing to connect`);
486
+ }
487
+ const acceptable = new Set();
488
+ if (self !== undefined) {
489
+ acceptable.add(self);
490
+ }
491
+ if (owner) {
492
+ acceptable.add(owner.uid);
493
+ }
494
+ // Root-owned is always acceptable: a rootful container runtime binds as root.
495
+ acceptable.add(0);
496
+ if (!acceptable.has(st.uid)) {
497
+ throw new Error(`sandbox socket ${socketPath} is owned by uid ${st.uid}, which is neither this process ` +
498
+ `nor the sandbox — another local user may have raced the bind. Refusing to connect.`);
499
+ }
500
+ }
501
+ /**
502
+ * An fs entry is either a plain path (bound at the same path inside the sandbox) or an explicit
503
+ * `hostPath:sandboxPath` mapping. The mapping form is required in practice: a pack lives at an
504
+ * arbitrary host path but must appear at a fixed path inside the sandbox.
505
+ */
506
+ function mountSpec(entry, readOnly) {
507
+ const idx = entry.indexOf(':');
508
+ const [src, dst] = idx === -1 ? [entry, entry] : [entry.slice(0, idx), entry.slice(idx + 1)];
509
+ return `${src}:${dst}${readOnly ? ':ro' : ''}`;
510
+ }
511
+ /**
512
+ * The image must be pinned by digest. A mutable tag makes pack signing and version rollback
513
+ * unverifiable — the deployment could silently run different bytes than the ones that were
514
+ * signed (`credential-provenance` conformance check).
515
+ */
516
+ function assertPinnedByDigest(image) {
517
+ // Two immutable forms are accepted: a registry digest ref (`repo@sha256:…`, the normal
518
+ // production case) and a bare image ID (`sha256:…`), which is what a locally built or
519
+ // air-gapped image is addressed by. Both name exact bytes; a tag does not.
520
+ const registryDigest = /@sha256:[a-f0-9]{64}$/.test(image);
521
+ const bareImageId = /^sha256:[a-f0-9]{64}$/.test(image);
522
+ if (!registryDigest && !bareImageId) {
523
+ throw new Error(`sandbox image must be pinned by digest (got "${image}") — a mutable tag makes signing and rollback unverifiable`);
524
+ }
525
+ }
526
+ /** A sandbox reports boot failures on stderr; show both streams or the diagnostic is blank. */
527
+ function combinedOutput(r) {
528
+ return `${r.stdout ?? ''}${r.stderr ?? ''}`.trim() || '(no output)';
529
+ }
530
+ /**
531
+ * Loopback means "this container" inside a container, never "the host that configured me".
532
+ *
533
+ * A route to 127.0.0.1 is therefore not a route at all: it names a service that cannot exist at
534
+ * that address from inside the sandbox. Left alone it produced `--network agenthippo-127.0.0.1`,
535
+ * a network nothing creates, and the whole tier failed at `docker run` with a message about a
536
+ * missing network rather than about the actual misconfiguration — the in-process data plane and
537
+ * a local LiteLLM are both loopback by default, so this is the DEFAULT configuration, not a
538
+ * corner. Refuse at the seam that knows why, and say what to do instead.
539
+ */
540
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0', '[::1]']);
541
+ /** Route target `host:port` → the user-defined network named for that host. */
542
+ function routeNetwork(target) {
543
+ const host = target.replace(/^\w+:\/\//, '').split(/[:/]/)[0];
544
+ if (LOOPBACK_HOSTS.has(host.toLowerCase())) {
545
+ throw new Error(`route target "${target}" is loopback, which inside a sandbox means the sandbox itself — ` +
546
+ `a tier-2 sandbox cannot reach a service on the host this way. Give the service a name ` +
547
+ `the sandbox can resolve (a compose service name, or host.docker.internal) on a network ` +
548
+ `named agenthippo-<host>, or run this deployment with --sandbox process.`);
549
+ }
550
+ return `agenthippo-${host}`;
551
+ }
552
+ function unixSocketChannel(socketPath) {
553
+ const socket = net.createConnection(socketPath);
554
+ const closed = new Promise((resolve, reject) => {
555
+ socket.once('close', () => resolve());
556
+ // REJECT, so the reason survives. Swallowing this turned every socket-level failure
557
+ // (ECONNREFUSED on a stale socket, EACCES from a uid mismatch, ENOENT) into the same
558
+ // blank "sandbox channel closed" — the diagnostic that cost a live debugging session.
559
+ // The message keeps that prefix because the retry-once path matches on it.
560
+ socket.once('error', (err) => reject(new Error(`sandbox channel closed: ${err.message}`)));
561
+ });
562
+ return {
563
+ input: socket,
564
+ output: { write: (chunk) => socket.write(chunk) },
565
+ closed,
566
+ };
567
+ }
568
+ function sanitize(s) {
569
+ return s.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 32) || 'session';
570
+ }