@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.
- package/out/analytics/dashboard/provider.js +13 -2
- package/out/analytics/dashboard/webview/panel.js +3 -2
- package/out/analytics/identity.js +0 -0
- package/out/analytics/service.js +23 -25
- package/out/analytics/sessionEvents.js +98 -23
- package/out/auth/authBaseUrl.js +59 -0
- package/out/auth/bootstrapApply.js +9 -10
- package/out/auth/brokerClient.js +48 -14
- package/out/auth/provider.js +8 -21
- package/out/auth/sessionStore.js +3 -40
- package/out/auth/signInFlow.js +2 -1
- package/out/auth-broker/config.js +50 -5
- package/out/auth-broker/server.js +122 -26
- package/out/auth-broker/store.js +25 -24
- package/out/authority/warrant.js +206 -0
- package/out/branding.js +27 -1
- package/out/cli/commands/conformance.js +249 -0
- package/out/cli/commands/data-plane.js +67 -0
- package/out/cli/commands/deploy.js +1 -0
- package/out/cli/commands/gateway.js +4 -0
- package/out/cli/commands/pack.js +182 -3
- package/out/cli/commands/sandbox.js +332 -0
- package/out/cli/commands/serve.js +875 -52
- package/out/cli/commands/store.js +12 -4
- package/out/cli/data-plane/gateway.js +28 -5
- package/out/cli/data-plane/index.js +17 -2
- package/out/cli/data-plane/registry.js +9 -0
- package/out/cli/data-plane/remote.js +106 -0
- package/out/cli/data-plane/sidecar.js +161 -0
- package/out/cli/deploy/layers/identity.js +85 -4
- package/out/cli/deploy-init.js +89 -24
- package/out/cli/deps.js +106 -2
- package/out/cli/idp-init.js +35 -0
- package/out/cli/index.js +155 -16
- package/out/cli/pack-install-ref.js +15 -0
- package/out/cli/pack-loader.js +8 -1
- package/out/cli/pack-runtime.js +61 -0
- package/out/cli/runtime.js +35 -1
- package/out/cli/serve-hardening.js +80 -3
- package/out/cli/utils.js +12 -1
- package/out/engines/base.js +32 -3
- package/out/engines/claude.js +33 -15
- package/out/engines/codex.js +110 -26
- package/out/engines/custom.js +8 -0
- package/out/evidence/sessionShip.js +610 -0
- package/out/extension.js +11 -2
- package/out/gateway/bridge.js +40 -2
- package/out/gateway/client.js +34 -1
- package/out/gateway/installSource.js +68 -0
- package/out/gateway/lifecycle.js +185 -51
- package/out/gateway/outboundPaths.js +4 -1
- package/out/gateway/secretsPreflight.js +90 -0
- package/out/gateway/service.js +7 -2
- package/out/headless/index.js +17 -1
- package/out/live-e2e/mcp-demo/server.mjs +32 -0
- package/out/mcp.js +30 -0
- package/out/pack/caps.js +271 -0
- package/out/pack/governor.js +309 -0
- package/out/pack/lease-schema.js +270 -0
- package/out/pack/lease.js +640 -0
- package/out/pack/reconcile.js +217 -0
- package/out/pack/signature.js +839 -0
- package/out/remote/handleRemoteChat.js +44 -3
- package/out/remote/remoteTrust.js +277 -0
- package/out/remote/serveClient.js +52 -5
- package/out/runner/host.js +131 -0
- package/out/runner/protocol.js +108 -0
- package/out/runner/remoteEngine.js +130 -0
- package/out/runtime/interfaces/edit-tracker.js +2 -0
- package/out/runtime/vscode/vscode-edit-tracker.js +2 -0
- package/out/sandbox/containerDriver.js +570 -0
- package/out/sandbox/processDriver.js +315 -0
- package/out/sandbox/sandboxRunner.js +191 -0
- package/out/sandbox/support.js +301 -0
- package/out/sandbox/types.js +55 -0
- package/out/scripts/agentide-litellm.sh +67 -5
- package/out/scripts/agentide_litellm_hooks_v1.py +182 -21
- package/out/scripts/analytics/agentide-litellm-otel.config.yaml +94 -132
- package/out/scripts/analytics/setup-analytics.ps1 +37 -13
- package/out/scripts/analytics/setup-analytics.sh +93 -13
- package/out/scripts/analytics/start-analytics.sh +86 -14
- package/out/scripts/gateway/setup-gateway.sh +72 -32
- package/out/shared/models.js +11 -110
- package/out/spotlight/install.js +7 -1
- package/out/store/index.js +41 -10
- package/out/sync.js +3 -1
- package/out/test/analytics.test.js +162 -23
- package/out/test/auth-baseurl.test.js +132 -0
- package/out/test/auth-bootstrapApply.test.js +27 -6
- package/out/test/auth-broker-hardening.test.js +227 -0
- package/out/test/auth-broker.test.js +59 -9
- package/out/test/branding.test.js +13 -0
- package/out/test/caps-governor.test.js +455 -0
- package/out/test/codex-new-file-survival.test.js +119 -0
- package/out/test/configuration-scopes.test.js +131 -0
- package/out/test/data-plane.test.js +67 -0
- package/out/test/deploy-init.test.js +40 -0
- package/out/test/gateway-client.test.js +28 -0
- package/out/test/gateway-install-source.test.js +111 -0
- package/out/test/gateway-lifecycle.test.js +1 -1
- package/out/test/gateway-outbound.test.js +7 -0
- package/out/test/gateway-secrets-preflight.test.js +127 -0
- package/out/test/gateway-session-routing.test.js +18 -0
- package/out/test/governor-hardening.test.js +217 -0
- package/out/test/headless-sync.test.js +22 -0
- package/out/test/lease-signature.test.js +357 -0
- package/out/test/lease-trust-root.test.js +273 -0
- package/out/test/lease-validation.test.js +169 -0
- package/out/test/lease.test.js +114 -0
- package/out/test/mcp.test.js +27 -1
- package/out/test/pack-admission.test.js +216 -0
- package/out/test/pack-digest-parity.test.js +140 -0
- package/out/test/pack-reconcile.test.js +183 -0
- package/out/test/pack-signature-artifact.test.js +103 -0
- package/out/test/pack-signature.test.js +176 -0
- package/out/test/pack-trust-root.test.js +202 -0
- package/out/test/remote-trust.test.js +242 -0
- package/out/test/runner-protocol.test.js +249 -0
- package/out/test/sandbox-driver.test.js +553 -0
- package/out/test/sandbox-hardening.test.js +395 -0
- package/out/test/sandbox-runner.test.js +343 -0
- package/out/test/serve-hardening.test.js +45 -6
- package/out/test/serveClient.test.js +6 -5
- package/out/test/sessionShip.test.js +560 -0
- package/out/test/spotlightPublish.test.js +28 -0
- package/out/test/store-install-safe.test.js +69 -0
- package/out/test/tls-bypass.test.js +90 -0
- package/out/test/warrant.test.js +138 -0
- package/out/utils.js +46 -9
- 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
|
+
}
|