@cruxy/cli 0.12.0 → 0.13.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.
@@ -0,0 +1,263 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { sandboxExec, sandboxImage } from "../errors/index.js";
4
+ /**
5
+ * The shipped {@link SandboxRuntime}: shells out to the `docker` CLI (no SDK —
6
+ * matches the no-vendor-client ethos). {@link buildRunArgs} is a pure function
7
+ * so the entire isolation posture can be asserted from the argv without a live
8
+ * daemon; `exec` spawns docker, captures bias-capped output, enforces the
9
+ * wall-clock timeout by force-killing the container, and maps the result.
10
+ *
11
+ * The exit code from `docker run` is the command's own — EXCEPT `125`, which
12
+ * docker reserves for "the run itself failed" (bad flags, daemon error): that,
13
+ * and a spawn failure, are the only container-start failures, surfaced as a
14
+ * coded {@link sandboxExec} error. An ordinary non-zero command exit is a
15
+ * normal result (exit code is truth), never a thrown error and never a host run.
16
+ */
17
+ export class DockerRuntime {
18
+ bin;
19
+ name = "docker";
20
+ constructor(bin = "docker") {
21
+ this.bin = bin;
22
+ }
23
+ async ensureImage(image, onPull) {
24
+ // Present locally already? `docker image inspect` exits 0 when it is.
25
+ const inspect = await this.simpleRun(["image", "inspect", image]);
26
+ if (inspect.code === 0)
27
+ return;
28
+ // Not present — pull it (surface once), and fail loud if the pull fails.
29
+ onPull?.();
30
+ const pull = await this.simpleRun(["pull", image]);
31
+ if (pull.code !== 0) {
32
+ throw sandboxImage(image, pull.stderr.trim() || pull.stdout.trim());
33
+ }
34
+ }
35
+ /** Run a non-container docker subcommand to completion, capturing output. */
36
+ simpleRun(args) {
37
+ return new Promise((resolve) => {
38
+ let child;
39
+ try {
40
+ child = spawn(this.bin, args);
41
+ }
42
+ catch (err) {
43
+ resolve({ code: null, stdout: "", stderr: err.message });
44
+ return;
45
+ }
46
+ let out = "";
47
+ let errText = "";
48
+ child.stdout?.on("data", (b) => (out += b.toString("utf8")));
49
+ child.stderr?.on("data", (b) => (errText += b.toString("utf8")));
50
+ child.on("error", (err) => resolve({ code: null, stdout: out, stderr: err.message }));
51
+ child.on("close", (code) => resolve({ code, stdout: out, stderr: errText }));
52
+ });
53
+ }
54
+ exec(command, policy, opts) {
55
+ const container = `cruxy-sbx-${randomUUID()}`;
56
+ const argv = buildRunArgs(policy, container, command);
57
+ return this.run(argv, container, opts);
58
+ }
59
+ run(argv, container, opts) {
60
+ const startedAt = Date.now();
61
+ return new Promise((resolve, reject) => {
62
+ const capture = new OutputCapture(opts.maxOutputBytes, opts.capture);
63
+ let child;
64
+ try {
65
+ // `detached` groups the docker client so a timeout kills the whole tree.
66
+ child = spawn(this.bin, argv, { detached: true });
67
+ }
68
+ catch (err) {
69
+ reject(sandboxExec(err));
70
+ return;
71
+ }
72
+ child.stdout?.on("data", (b) => capture.push(b));
73
+ child.stderr?.on("data", (b) => capture.push(b));
74
+ let settled = false;
75
+ const timer = setTimeout(() => {
76
+ if (settled)
77
+ return;
78
+ settled = true;
79
+ killTree(child.pid);
80
+ // Killing the client may orphan the container — force-remove it too.
81
+ this.forceRemove(container);
82
+ const { output, truncated } = capture.result();
83
+ resolve({
84
+ exitCode: null,
85
+ output,
86
+ outputTruncated: truncated,
87
+ durationMs: Date.now() - startedAt,
88
+ timedOut: true,
89
+ });
90
+ }, opts.timeoutMs);
91
+ child.on("error", (err) => {
92
+ if (settled)
93
+ return;
94
+ settled = true;
95
+ clearTimeout(timer);
96
+ reject(sandboxExec(err));
97
+ });
98
+ child.on("close", (code) => {
99
+ if (settled)
100
+ return;
101
+ settled = true;
102
+ clearTimeout(timer);
103
+ // 125 = `docker run` itself failed (not the inner command) → the
104
+ // container never really started. Fail loud, never fabricate a result.
105
+ if (code === 125) {
106
+ const { output } = capture.result();
107
+ reject(sandboxExec(output.trim() || "docker run exited 125"));
108
+ return;
109
+ }
110
+ const { output, truncated } = capture.result();
111
+ resolve({
112
+ exitCode: code,
113
+ output,
114
+ outputTruncated: truncated,
115
+ durationMs: Date.now() - startedAt,
116
+ timedOut: false,
117
+ });
118
+ });
119
+ });
120
+ }
121
+ /** Best-effort container teardown after a timeout kill. */
122
+ forceRemove(container) {
123
+ try {
124
+ const rm = spawn(this.bin, ["rm", "-f", container], {
125
+ stdio: "ignore",
126
+ detached: true,
127
+ });
128
+ rm.on("error", () => { });
129
+ rm.unref();
130
+ }
131
+ catch {
132
+ // Nothing more we can do; the container may already be gone.
133
+ }
134
+ }
135
+ }
136
+ /**
137
+ * Build the `docker run` argv from a resolved policy. Pure and total — the
138
+ * single source of truth for the isolation boundary, asserted directly in
139
+ * tests. Order is stable for readability; docker is order-insensitive for flags.
140
+ */
141
+ export function buildRunArgs(policy, container, command) {
142
+ return [
143
+ "run",
144
+ "--rm", // auto-remove the container when it exits
145
+ "--name",
146
+ container, // so a timeout can force-remove it
147
+ ...networkArgs(policy.network),
148
+ "--user",
149
+ policy.user, // non-root
150
+ "--read-only", // root filesystem is read-only …
151
+ "--tmpfs",
152
+ `${policy.tmpfs}:rw,nosuid,nodev,size=64m`, // … except an in-memory tmp
153
+ "-v",
154
+ mountSpec(policy.workdir), // ONLY the workdir, read-write
155
+ ...policy.mounts.flatMap((m) => ["-v", mountSpec(m)]),
156
+ "-w",
157
+ policy.workdir.target,
158
+ "--memory",
159
+ policy.memory,
160
+ "--memory-swap",
161
+ policy.memory, // == memory disables swap (no swap-escape of the cap)
162
+ "--pids-limit",
163
+ String(policy.pids),
164
+ "--cpus",
165
+ String(policy.cpus),
166
+ "--security-opt",
167
+ "no-new-privileges", // no setuid privilege escalation
168
+ "--cap-drop",
169
+ "ALL", // drop every Linux capability
170
+ policy.image,
171
+ "sh",
172
+ "-c",
173
+ command,
174
+ ];
175
+ }
176
+ /**
177
+ * Egress flags. `none` denies all network (the default). Widening is a
178
+ * deliberate act: `full` uses the default bridge; `host-loopback` adds a
179
+ * host-gateway alias (best-effort — strict loopback-only firewalling is left
180
+ * for a later build). Anything but `none` can only come from explicit config.
181
+ */
182
+ function networkArgs(network) {
183
+ switch (network) {
184
+ case "none":
185
+ return ["--network", "none"];
186
+ case "host-loopback":
187
+ return [
188
+ "--network",
189
+ "bridge",
190
+ "--add-host",
191
+ "host.docker.internal:host-gateway",
192
+ ];
193
+ case "full":
194
+ return ["--network", "bridge"];
195
+ }
196
+ }
197
+ function mountSpec(m) {
198
+ return `${m.source}:${m.target}:${m.readonly ? "ro" : "rw"}`;
199
+ }
200
+ /**
201
+ * Bias-capped output capture: `head` keeps the START and stops once the cap is
202
+ * hit (matches `run_command`); `tail` keeps the END, where failures live
203
+ * (matches `run_tests`). Bounded memory either way.
204
+ */
205
+ class OutputCapture {
206
+ cap;
207
+ bias;
208
+ chunks = [];
209
+ bytes = 0;
210
+ truncated = false;
211
+ constructor(cap, bias) {
212
+ this.cap = cap;
213
+ this.bias = bias;
214
+ }
215
+ push(buf) {
216
+ if (this.bias === "head") {
217
+ if (this.truncated)
218
+ return;
219
+ const room = this.cap - this.bytes;
220
+ if (buf.length <= room) {
221
+ this.chunks.push(buf);
222
+ this.bytes += buf.length;
223
+ }
224
+ else {
225
+ if (room > 0) {
226
+ this.chunks.push(buf.subarray(0, room));
227
+ this.bytes += room;
228
+ }
229
+ this.truncated = true;
230
+ }
231
+ return;
232
+ }
233
+ // tail: append, dropping whole head chunks while the remainder still meets
234
+ // the cap; a final exact trim happens in result().
235
+ this.chunks.push(buf);
236
+ this.bytes += buf.length;
237
+ while (this.chunks.length > 1 &&
238
+ this.bytes - this.chunks[0].length >= this.cap) {
239
+ this.bytes -= this.chunks[0].length;
240
+ this.chunks.shift();
241
+ this.truncated = true;
242
+ }
243
+ }
244
+ result() {
245
+ let all = Buffer.concat(this.chunks);
246
+ if (this.bias === "tail" && all.length > this.cap) {
247
+ all = all.subarray(all.length - this.cap);
248
+ this.truncated = true;
249
+ }
250
+ return { output: all.toString("utf8"), truncated: this.truncated };
251
+ }
252
+ }
253
+ /** Kill the docker client's process group (POSIX; matches run_command). */
254
+ function killTree(pid) {
255
+ if (pid === undefined)
256
+ return;
257
+ try {
258
+ process.kill(-pid, "SIGKILL");
259
+ }
260
+ catch {
261
+ // Already exited, or no group — nothing to kill.
262
+ }
263
+ }
@@ -0,0 +1,7 @@
1
+ export * from "./types.js";
2
+ export { detectDocker, resetDetectionCache } from "./detect.js";
3
+ export type { RuntimeProbe } from "./detect.js";
4
+ export { DockerRuntime, buildRunArgs } from "./docker-runtime.js";
5
+ export { buildPolicy } from "./policy.js";
6
+ export { SandboxService } from "./service.js";
7
+ export type { SandboxReporter, SandboxServiceDeps } from "./service.js";
@@ -0,0 +1,5 @@
1
+ export * from "./types.js";
2
+ export { detectDocker, resetDetectionCache } from "./detect.js";
3
+ export { DockerRuntime, buildRunArgs } from "./docker-runtime.js";
4
+ export { buildPolicy } from "./policy.js";
5
+ export { SandboxService } from "./service.js";
@@ -0,0 +1,17 @@
1
+ import type { SandboxConfig } from "../config/index.js";
2
+ import type { IsolationPolicy } from "./types.js";
3
+ /**
4
+ * Turn a validated {@link SandboxConfig} + the run's cwd into a fully-resolved
5
+ * {@link IsolationPolicy}. This is where the security posture is decided, and
6
+ * every default here is deny/minimal:
7
+ *
8
+ * - the ONLY read-write mount is the project workdir (at its identical absolute
9
+ * path, so paths stay coherent with the host and the C.32 checkpoint);
10
+ * - extra mounts come solely from `sandbox.mounts` (explicit by construction),
11
+ * and a mount of the docker socket, the cruxy home, or the user's home root
12
+ * is rejected — those are the escape hatches we refuse to open;
13
+ * - the container runs as the host's non-root uid:gid so mounted edits are
14
+ * writable and never left root-owned;
15
+ * - network defaults to `none`; any widening can only come from explicit config.
16
+ */
17
+ export declare function buildPolicy(cfg: SandboxConfig, cwd: string): IsolationPolicy;
@@ -0,0 +1,90 @@
1
+ import { homedir } from "node:os";
2
+ import { isAbsolute, resolve as resolvePath } from "node:path";
3
+ import { configInvalid } from "../errors/index.js";
4
+ import { globalDir } from "../config/paths.js";
5
+ /**
6
+ * Turn a validated {@link SandboxConfig} + the run's cwd into a fully-resolved
7
+ * {@link IsolationPolicy}. This is where the security posture is decided, and
8
+ * every default here is deny/minimal:
9
+ *
10
+ * - the ONLY read-write mount is the project workdir (at its identical absolute
11
+ * path, so paths stay coherent with the host and the C.32 checkpoint);
12
+ * - extra mounts come solely from `sandbox.mounts` (explicit by construction),
13
+ * and a mount of the docker socket, the cruxy home, or the user's home root
14
+ * is rejected — those are the escape hatches we refuse to open;
15
+ * - the container runs as the host's non-root uid:gid so mounted edits are
16
+ * writable and never left root-owned;
17
+ * - network defaults to `none`; any widening can only come from explicit config.
18
+ */
19
+ export function buildPolicy(cfg, cwd) {
20
+ const workdir = {
21
+ source: cwd,
22
+ target: cwd,
23
+ readonly: false,
24
+ };
25
+ return {
26
+ image: cfg.image,
27
+ network: cfg.network,
28
+ user: resolveUser(),
29
+ memory: cfg.memory,
30
+ pids: cfg.pids,
31
+ cpus: cfg.cpus,
32
+ workdir,
33
+ mounts: cfg.mounts.map((spec) => parseMount(spec, cwd)),
34
+ tmpfs: "/tmp",
35
+ };
36
+ }
37
+ /** Host `uid:gid` — non-root (the human isn't uid 0) and keeps mounted files
38
+ * writable without leaving them root-owned. Falls back to a conventional
39
+ * non-root id where `getuid` is unavailable (non-POSIX). */
40
+ function resolveUser() {
41
+ const getuid = process.getuid?.bind(process);
42
+ const getgid = process.getgid?.bind(process);
43
+ if (getuid && getgid)
44
+ return `${getuid()}:${getgid()}`;
45
+ return "1000:1000";
46
+ }
47
+ /** Sources we refuse to bind-mount into the box — the whole point is that the
48
+ * container cannot reach the docker socket, the cruxy credential store, or the
49
+ * user's home. Matched by resolved absolute path. */
50
+ function forbiddenMountSource(source) {
51
+ const resolved = resolvePath(source);
52
+ const home = homedir();
53
+ if (resolved === "/var/run/docker.sock" || resolved.endsWith("docker.sock")) {
54
+ return "the docker socket (would grant full host control)";
55
+ }
56
+ if (resolved === globalDir() || resolved.startsWith(globalDir() + "/")) {
57
+ return "the cruxy home (holds credentials)";
58
+ }
59
+ if (resolved === home)
60
+ return "the home directory root";
61
+ if (resolved === "/")
62
+ return "the filesystem root";
63
+ return undefined;
64
+ }
65
+ /** Parse one `src:dst[:ro|:rw]` mount spec into a validated {@link BindMount}.
66
+ * Relative sources resolve against the run's cwd; a forbidden source throws a
67
+ * config error (never silently dropped). */
68
+ function parseMount(spec, cwd) {
69
+ // Split on ":" but keep it simple — sources/targets are absolute POSIX-ish
70
+ // paths; a Windows drive letter is out of scope for this build.
71
+ const parts = spec.split(":");
72
+ if (parts.length < 2 || parts.length > 3) {
73
+ throw configInvalid(`sandbox.mounts entry "${spec}" must be "src:dst" or "src:dst:ro|rw"`);
74
+ }
75
+ const [rawSource, target, mode] = parts;
76
+ if (!target || !isAbsolute(target)) {
77
+ throw configInvalid(`sandbox.mounts entry "${spec}" needs an absolute container path (dst)`);
78
+ }
79
+ if (mode !== undefined && mode !== "ro" && mode !== "rw") {
80
+ throw configInvalid(`sandbox.mounts entry "${spec}" mode must be "ro" or "rw"`);
81
+ }
82
+ const source = isAbsolute(rawSource)
83
+ ? rawSource
84
+ : resolvePath(cwd, rawSource);
85
+ const forbidden = forbiddenMountSource(source);
86
+ if (forbidden) {
87
+ throw configInvalid(`sandbox.mounts refuses to mount ${forbidden}: "${spec}"`);
88
+ }
89
+ return { source, target, readonly: mode === "ro" };
90
+ }
@@ -0,0 +1,57 @@
1
+ import type { CruxyConfig } from "../config/index.js";
2
+ import type { ExecOptions, ExecResult, IsolationPolicy, SandboxCapability, SandboxRuntime } from "./types.js";
3
+ /**
4
+ * The minimal surface the sandbox needs to report a first-run image pull
5
+ * through the U.4 state layer. `StreamRenderer` satisfies it structurally, so
6
+ * callers pass the renderer directly and the sandbox stays render-decoupled.
7
+ */
8
+ export interface SandboxReporter {
9
+ status(text: string | null): void;
10
+ }
11
+ export interface SandboxServiceDeps {
12
+ config: CruxyConfig;
13
+ /** The run's working directory — mounted read-write as the workdir. */
14
+ cwd: string;
15
+ /** Execution runtime seam (defaults to Docker). */
16
+ runtime?: SandboxRuntime;
17
+ /** Capability probe seam (defaults to real docker detection). */
18
+ detect?: () => Promise<SandboxCapability>;
19
+ /** U.4 sink for the "pulling sandbox image…" line (optional). */
20
+ reporter?: SandboxReporter;
21
+ }
22
+ /**
23
+ * The sandbox execution service (C.16) — what `ToolContext.sandbox` points at.
24
+ * It is only ever constructed when the sandbox is enabled, and constructing it
25
+ * is where the fail-loud guarantee lives: {@link SandboxService.create} probes
26
+ * the runtime and throws {@link sandboxUnavailable} if it isn't available, so a
27
+ * user who asked for the box either gets the box or a loud, coded error — never
28
+ * a silent drop back to host execution. `exec` then contains an approved
29
+ * command inside the isolation policy, ensuring the image on first use.
30
+ */
31
+ export declare class SandboxService {
32
+ private readonly runtime;
33
+ private readonly policy;
34
+ private readonly reporter?;
35
+ private imageReady?;
36
+ private constructor();
37
+ /**
38
+ * Resolve the runtime and build the policy. THROWS {@link sandboxUnavailable}
39
+ * when the runtime is missing/unreachable — the caller (session wiring) lets
40
+ * it propagate so the run stops before any command executes. There is no code
41
+ * path from here to host execution.
42
+ */
43
+ static create(deps: SandboxServiceDeps): Promise<SandboxService>;
44
+ /** The runtime backing this service (e.g. "docker") — for logging. */
45
+ get runtimeName(): string;
46
+ /** The resolved isolation policy — exposed for logging/inspection. */
47
+ get isolationPolicy(): IsolationPolicy;
48
+ /**
49
+ * Execute an already-approved command inside the box. Ensures the image once
50
+ * (surfacing the pull via U.4), then delegates to the runtime. A container
51
+ * that fails to start throws a coded error; an ordinary non-zero command exit
52
+ * comes back as a normal {@link ExecResult} — exit code is truth.
53
+ */
54
+ exec(command: string, opts: ExecOptions): Promise<ExecResult>;
55
+ /** Ensure the image is present, at most once per service (memoized). */
56
+ private ensureImage;
57
+ }
@@ -0,0 +1,64 @@
1
+ import { sandboxUnavailable } from "../errors/index.js";
2
+ import { detectDocker } from "./detect.js";
3
+ import { DockerRuntime } from "./docker-runtime.js";
4
+ import { buildPolicy } from "./policy.js";
5
+ /**
6
+ * The sandbox execution service (C.16) — what `ToolContext.sandbox` points at.
7
+ * It is only ever constructed when the sandbox is enabled, and constructing it
8
+ * is where the fail-loud guarantee lives: {@link SandboxService.create} probes
9
+ * the runtime and throws {@link sandboxUnavailable} if it isn't available, so a
10
+ * user who asked for the box either gets the box or a loud, coded error — never
11
+ * a silent drop back to host execution. `exec` then contains an approved
12
+ * command inside the isolation policy, ensuring the image on first use.
13
+ */
14
+ export class SandboxService {
15
+ runtime;
16
+ policy;
17
+ reporter;
18
+ imageReady;
19
+ constructor(runtime, policy, reporter) {
20
+ this.runtime = runtime;
21
+ this.policy = policy;
22
+ this.reporter = reporter;
23
+ }
24
+ /**
25
+ * Resolve the runtime and build the policy. THROWS {@link sandboxUnavailable}
26
+ * when the runtime is missing/unreachable — the caller (session wiring) lets
27
+ * it propagate so the run stops before any command executes. There is no code
28
+ * path from here to host execution.
29
+ */
30
+ static async create(deps) {
31
+ const runtime = deps.runtime ?? new DockerRuntime();
32
+ const detect = deps.detect ?? (() => detectDocker());
33
+ const capability = await detect();
34
+ if (!capability.available) {
35
+ throw sandboxUnavailable(capability.runtime, capability.detail);
36
+ }
37
+ const policy = buildPolicy(deps.config.sandbox, deps.cwd);
38
+ return new SandboxService(runtime, policy, deps.reporter);
39
+ }
40
+ /** The runtime backing this service (e.g. "docker") — for logging. */
41
+ get runtimeName() {
42
+ return this.runtime.name;
43
+ }
44
+ /** The resolved isolation policy — exposed for logging/inspection. */
45
+ get isolationPolicy() {
46
+ return this.policy;
47
+ }
48
+ /**
49
+ * Execute an already-approved command inside the box. Ensures the image once
50
+ * (surfacing the pull via U.4), then delegates to the runtime. A container
51
+ * that fails to start throws a coded error; an ordinary non-zero command exit
52
+ * comes back as a normal {@link ExecResult} — exit code is truth.
53
+ */
54
+ async exec(command, opts) {
55
+ await this.ensureImage();
56
+ return this.runtime.exec(command, this.policy, opts);
57
+ }
58
+ /** Ensure the image is present, at most once per service (memoized). */
59
+ ensureImage() {
60
+ return (this.imageReady ??= this.runtime
61
+ .ensureImage(this.policy.image, () => this.reporter?.status(`pulling sandbox image ${this.policy.image}…`))
62
+ .then(() => this.reporter?.status(null)));
63
+ }
64
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Sandbox execution (C.16) — the isolation boundary beneath the U.3 gate.
3
+ *
4
+ * When the sandbox is enabled, the highest-risk tools (`run_command`,
5
+ * `run_tests`) execute inside a container instead of directly on the host: the
6
+ * project workdir is mounted so the agent edits real files, but the *process*
7
+ * cannot reach the network, cannot touch the host beyond that mount, and cannot
8
+ * exhaust the machine. Approval still gates every command (see U.3) — the
9
+ * sandbox contains what an approved command is able to do; it does not replace
10
+ * the decision to run it.
11
+ *
12
+ * Execution is abstracted behind {@link SandboxRuntime} (Docker ships; podman /
13
+ * none slot in without touching call sites), and the neutral {@link ExecResult}
14
+ * matches what host execution conceptually returns so `run_command`/`run_tests`
15
+ * stay substrate-agnostic — exit code is the source of truth on either path.
16
+ */
17
+ /** Egress policy for the container. `none` is the default (deny all). */
18
+ export type NetworkPolicy = "none" | "host-loopback" | "full";
19
+ /** One resolved bind mount: an absolute host path exposed in the container. */
20
+ export interface BindMount {
21
+ /** Absolute host path. */
22
+ readonly source: string;
23
+ /** Absolute container path (the workdir mount uses `source === target`). */
24
+ readonly target: string;
25
+ /** Read-only when true; the workdir mount is read-write. */
26
+ readonly readonly: boolean;
27
+ }
28
+ /**
29
+ * A fully-resolved isolation policy — every knob the runtime needs, already
30
+ * merged from config and validated. `docker-runtime` turns this (plus the
31
+ * command) into an argv; nothing here is optional or defaulted downstream.
32
+ */
33
+ export interface IsolationPolicy {
34
+ /** Pinned base image the container runs (never chosen dynamically). */
35
+ readonly image: string;
36
+ /** Egress policy — `none` unless the user deliberately widened it. */
37
+ readonly network: NetworkPolicy;
38
+ /** Non-root `uid:gid` the container runs as (host uid, so mounts stay writable). */
39
+ readonly user: string;
40
+ /** Memory cap (`--memory`, also mirrored to `--memory-swap` to disable swap). */
41
+ readonly memory: string;
42
+ /** Process/thread cap (`--pids-limit`). */
43
+ readonly pids: number;
44
+ /** CPU cap (`--cpus`, fractional allowed). */
45
+ readonly cpus: number;
46
+ /** The project workdir, mounted read-write at the identical absolute path. */
47
+ readonly workdir: BindMount;
48
+ /** Extra explicit mounts beyond the workdir (from `sandbox.mounts`). */
49
+ readonly mounts: readonly BindMount[];
50
+ /** Writable in-memory tmp mount point; the rest of the root fs is read-only. */
51
+ readonly tmpfs: string;
52
+ }
53
+ /** Which end of the output to keep when the byte cap is exceeded. */
54
+ export type CaptureBias = "head" | "tail";
55
+ /** Per-exec bounds handed to a runtime. */
56
+ export interface ExecOptions {
57
+ /** Host project directory → bind-mounted as the workdir (same absolute path). */
58
+ readonly cwd: string;
59
+ /** Wall-clock timeout in ms; overrun kills the container and returns a failure. */
60
+ readonly timeoutMs: number;
61
+ /** Cap on combined stdout+stderr bytes captured. */
62
+ readonly maxOutputBytes: number;
63
+ /**
64
+ * Head-bias keeps the start of the output (`run_command`); tail-bias keeps
65
+ * the end, where test runners print their failure summaries (`run_tests`).
66
+ */
67
+ readonly capture: CaptureBias;
68
+ }
69
+ /**
70
+ * The neutral outcome of one sandboxed execution — the SAME shape host
71
+ * execution conceptually produces, so both `run_command` and `run_tests` map it
72
+ * to their own result type without caring which substrate ran the command.
73
+ */
74
+ export interface ExecResult {
75
+ /** Process exit code; `null` on a signal kill / timeout. Exit code is truth. */
76
+ readonly exitCode: number | null;
77
+ /** Combined stdout+stderr, already bias-capped to `maxOutputBytes`. */
78
+ readonly output: string;
79
+ /** True when output was dropped to honor the cap. */
80
+ readonly outputTruncated: boolean;
81
+ /** Measured wall-clock duration of the execution. */
82
+ readonly durationMs: number;
83
+ /** True when the wall-clock timeout fired and the container was killed. */
84
+ readonly timedOut: boolean;
85
+ }
86
+ /**
87
+ * The swappable execution seam. Docker ships as {@link DockerRuntime}; a future
88
+ * podman/other runtime implements this without touching the tools or service.
89
+ * `exec` never falls back to the host and never throws for an ordinary non-zero
90
+ * command exit — it throws only when the container itself cannot run (a coded
91
+ * {@link CruxyError}), so the fail-loud guarantee is structural.
92
+ */
93
+ export interface SandboxRuntime {
94
+ /** Identifier surfaced in errors/logs (e.g. "docker"). */
95
+ readonly name: string;
96
+ /**
97
+ * Ensure `image` is present locally, pulling it if needed. `onPull` fires
98
+ * once, only if a pull actually starts (so callers can surface it via U.4).
99
+ * Throws a coded {@link CruxyError} on pull/build failure — never silently
100
+ * substitutes another image.
101
+ */
102
+ ensureImage(image: string, onPull?: () => void): Promise<void>;
103
+ /** Run `command` inside the box under `policy`, returning a neutral result. */
104
+ exec(command: string, policy: IsolationPolicy, opts: ExecOptions): Promise<ExecResult>;
105
+ }
106
+ /** Result of probing for a container runtime; presence is a capability. */
107
+ export interface SandboxCapability {
108
+ /** True when the runtime binary exists AND its daemon is reachable. */
109
+ readonly available: boolean;
110
+ /** The runtime that was probed. */
111
+ readonly runtime: string;
112
+ /** Why it's unavailable (for the fail-loud error), when not available. */
113
+ readonly detail?: string;
114
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Sandbox execution (C.16) — the isolation boundary beneath the U.3 gate.
3
+ *
4
+ * When the sandbox is enabled, the highest-risk tools (`run_command`,
5
+ * `run_tests`) execute inside a container instead of directly on the host: the
6
+ * project workdir is mounted so the agent edits real files, but the *process*
7
+ * cannot reach the network, cannot touch the host beyond that mount, and cannot
8
+ * exhaust the machine. Approval still gates every command (see U.3) — the
9
+ * sandbox contains what an approved command is able to do; it does not replace
10
+ * the decision to run it.
11
+ *
12
+ * Execution is abstracted behind {@link SandboxRuntime} (Docker ships; podman /
13
+ * none slot in without touching call sites), and the neutral {@link ExecResult}
14
+ * matches what host execution conceptually returns so `run_command`/`run_tests`
15
+ * stay substrate-agnostic — exit code is the source of truth on either path.
16
+ */
17
+ export {};
@@ -3,6 +3,7 @@ import type { ApprovalDecision } from "../approval/types.js";
3
3
  import type { CruxyConfig } from "../config/index.js";
4
4
  import type { StreamRenderer } from "../render/index.js";
5
5
  import type { ApproveAction, ToolContext, ToolRegistry } from "../tools/index.js";
6
+ import type { SandboxService } from "../sandbox/index.js";
6
7
  import type { SubagentResult, SubagentSpec } from "./types.js";
7
8
  /**
8
9
  * Everything a spawn needs from the surrounding session, injected by the
@@ -24,6 +25,12 @@ export interface SubagentOrchestratorDeps {
24
25
  } | null;
25
26
  projectInstructions?: string | null;
26
27
  renderer?: StreamRenderer;
28
+ /**
29
+ * The run's sandbox (C.16), when enabled. Threaded into the child ctx so a
30
+ * subagent's shell/test commands run in the SAME box as the parent's —
31
+ * sandboxing is never silently dropped for a child.
32
+ */
33
+ sandbox?: SandboxService;
27
34
  /**
28
35
  * Build a fresh, fully-wrapped approval gate for one child run: a NEW
29
36
  * `ApprovalService` (so the child gets its own empty session allowlist —
@@ -65,6 +65,7 @@ export class SubagentOrchestrator {
65
65
  recordArtifacts(action, artifacts, deps.cwd);
66
66
  return decision;
67
67
  },
68
+ sandbox: deps.sandbox,
68
69
  };
69
70
  const label = taskLabel(spec.task);
70
71
  if (deps.renderer) {