@cruxy/cli 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/approval/prompt.js +17 -3
  2. package/dist/cli/commands/run.js +19 -1
  3. package/dist/cli/session-factory.d.ts +2 -1
  4. package/dist/cli/session-factory.js +10 -3
  5. package/dist/components/fuzzy.js +7 -1
  6. package/dist/config/schema.d.ts +123 -0
  7. package/dist/config/schema.js +40 -0
  8. package/dist/errors/constructors.d.ts +21 -0
  9. package/dist/errors/constructors.js +58 -0
  10. package/dist/errors/types.d.ts +5 -0
  11. package/dist/errors/types.js +11 -0
  12. package/dist/onboarding/steps.js +4 -1
  13. package/dist/render/capabilities.d.ts +10 -2
  14. package/dist/render/capabilities.js +26 -6
  15. package/dist/render/index.d.ts +9 -5
  16. package/dist/render/index.js +12 -5
  17. package/dist/render/plain-renderer.d.ts +3 -3
  18. package/dist/render/plain-renderer.js +10 -2
  19. package/dist/render/screen-reader-renderer.d.ts +45 -0
  20. package/dist/render/screen-reader-renderer.js +75 -0
  21. package/dist/render/types.d.ts +15 -1
  22. package/dist/sandbox/detect.d.ts +22 -0
  23. package/dist/sandbox/detect.js +67 -0
  24. package/dist/sandbox/docker-runtime.d.ts +32 -0
  25. package/dist/sandbox/docker-runtime.js +263 -0
  26. package/dist/sandbox/index.d.ts +7 -0
  27. package/dist/sandbox/index.js +5 -0
  28. package/dist/sandbox/policy.d.ts +17 -0
  29. package/dist/sandbox/policy.js +90 -0
  30. package/dist/sandbox/service.d.ts +57 -0
  31. package/dist/sandbox/service.js +64 -0
  32. package/dist/sandbox/types.d.ts +114 -0
  33. package/dist/sandbox/types.js +17 -0
  34. package/dist/subagent/orchestrator.d.ts +7 -0
  35. package/dist/subagent/orchestrator.js +1 -0
  36. package/dist/testing/run-tests-tool.d.ts +5 -1
  37. package/dist/testing/run-tests-tool.js +8 -1
  38. package/dist/testing/sandbox-runner.d.ts +16 -0
  39. package/dist/testing/sandbox-runner.js +47 -0
  40. package/dist/theme/resolve.d.ts +18 -7
  41. package/dist/theme/resolve.js +32 -10
  42. package/dist/theme/tokens.d.ts +16 -1
  43. package/dist/theme/tokens.js +27 -0
  44. package/dist/tools/shell/run-command.js +35 -1
  45. package/dist/tools/types.d.ts +10 -0
  46. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  import type { ActionPreview } from "../tools/types.js";
2
2
  import { type Theme } from "../theme/index.js";
3
- import type { RenderCapabilities, RenderStream, StreamRenderer, ToolLifecycleEvent } from "./types.js";
3
+ import type { ProgressState, RenderCapabilities, RenderPhase, RenderStream, StreamRenderer, ToolLifecycleEvent } from "./types.js";
4
4
  /**
5
5
  * The append-only renderer for pipes, CI, and cursor-less terminals. Emits no
6
6
  * cursor-control sequences ever, and no color unless the capabilities say so
@@ -30,8 +30,8 @@ export declare class PlainRenderer implements StreamRenderer {
30
30
  note(text: string): void;
31
31
  preview(preview: ActionPreview): void;
32
32
  status(): void;
33
- setPhase(): void;
34
- progress(): void;
33
+ setPhase(phase: RenderPhase | null): void;
34
+ progress(state: ProgressState | null): void;
35
35
  toolLifecycle(event: ToolLifecycleEvent): void;
36
36
  promptResolved(): void;
37
37
  endTurn(): void;
@@ -59,12 +59,20 @@ export class PlainRenderer {
59
59
  status() {
60
60
  // Append-only medium: transient state is dropped by design.
61
61
  }
62
- setPhase() {
62
+ // setPhase / progress declare their StreamRenderer params because the
63
+ // ScreenReaderRenderer subclass overrides them to announce (the override must
64
+ // be signature-compatible). In the plain medium they are no-ops: there is no
65
+ // live region to update, so the guard simply returns.
66
+ setPhase(phase) {
63
67
  // Phases are live-region state; there is no live region here (U.4).
68
+ if (phase !== null)
69
+ return;
64
70
  }
65
- progress() {
71
+ progress(state) {
66
72
  // The committed plan trail (C.31, via PromptIO) is the record in this
67
73
  // medium; a live [i/n] prefix would just duplicate it line by line.
74
+ if (state !== null)
75
+ return;
68
76
  }
69
77
  toolLifecycle(event) {
70
78
  if (event.event === "start") {
@@ -0,0 +1,45 @@
1
+ import { PlainRenderer } from "./plain-renderer.js";
2
+ import type { ProgressState, RenderCapabilities, RenderPhase, RenderStream, ToolLifecycleEvent } from "./types.js";
3
+ /**
4
+ * The screen-reader renderer (U.11): {@link PlainRenderer}'s linear,
5
+ * append-only, zero-cursor-control output — plus two things a screen reader
6
+ * needs that the plain path drops.
7
+ *
8
+ * 1. **Worded status.** Its theme resolves the {@link SCREEN_READER_GLYPHS}
9
+ * table (via `caps.screenReader`), so every inherited code path that writes
10
+ * `theme.glyph.success` prints `done` instead of `✓`, `failed` for `✗`, etc.
11
+ * Nothing here special-cases it — the glyph table does the work.
12
+ *
13
+ * 2. **Announced state.** A screen reader cannot see an in-place live region, so
14
+ * each state change is emitted as its own committed line (never a redraw):
15
+ * a phase becomes `working: read_file src/x.ts`, a tool run brackets as
16
+ * `working: …` → `done …`, and plan progress announces `step 2 of 5: title`.
17
+ * Announcements are deduped by phase *identity* so token-count updates within
18
+ * the same activity don't repeat a line.
19
+ *
20
+ * Everything else — text streaming, `endSegment`, `note`, `preview` — is
21
+ * inherited unchanged. This is the whole of screen-reader mode: no parallel
22
+ * path, no behavior change to the plain or TTY renderers.
23
+ */
24
+ export declare class ScreenReaderRenderer extends PlainRenderer {
25
+ /** Identity of the last phase announced, so we speak each activity once. */
26
+ private lastPhaseId;
27
+ /** Text of the last progress line announced, to avoid repeats. */
28
+ private lastProgress;
29
+ constructor(caps: RenderCapabilities, out: RenderStream, err: RenderStream);
30
+ /**
31
+ * Announce a phase transition as a committed line. `null`, `awaiting-approval`
32
+ * (the prompt block is its own announcement), and `calling-tool` (announced by
33
+ * {@link toolLifecycle}) are silent; repeats of the same activity are deduped.
34
+ */
35
+ setPhase(phase: RenderPhase | null): void;
36
+ /** Announce plan-step progress as `step i of n: title`, deduped. */
37
+ progress(state: ProgressState | null): void;
38
+ /**
39
+ * Bracket a tool call with two committed lines — `working: label` on start,
40
+ * `done/failed label` on end — so a screen-reader user hears both that a call
41
+ * began (crucial for long ones) and how it resolved. The end note + honest
42
+ * duration is the inherited plain behavior; only the start line is added.
43
+ */
44
+ toolLifecycle(event: ToolLifecycleEvent): void;
45
+ }
@@ -0,0 +1,75 @@
1
+ import { PlainRenderer } from "./plain-renderer.js";
2
+ import { describePhase, phaseIdentity } from "./state.js";
3
+ /**
4
+ * The screen-reader renderer (U.11): {@link PlainRenderer}'s linear,
5
+ * append-only, zero-cursor-control output — plus two things a screen reader
6
+ * needs that the plain path drops.
7
+ *
8
+ * 1. **Worded status.** Its theme resolves the {@link SCREEN_READER_GLYPHS}
9
+ * table (via `caps.screenReader`), so every inherited code path that writes
10
+ * `theme.glyph.success` prints `done` instead of `✓`, `failed` for `✗`, etc.
11
+ * Nothing here special-cases it — the glyph table does the work.
12
+ *
13
+ * 2. **Announced state.** A screen reader cannot see an in-place live region, so
14
+ * each state change is emitted as its own committed line (never a redraw):
15
+ * a phase becomes `working: read_file src/x.ts`, a tool run brackets as
16
+ * `working: …` → `done …`, and plan progress announces `step 2 of 5: title`.
17
+ * Announcements are deduped by phase *identity* so token-count updates within
18
+ * the same activity don't repeat a line.
19
+ *
20
+ * Everything else — text streaming, `endSegment`, `note`, `preview` — is
21
+ * inherited unchanged. This is the whole of screen-reader mode: no parallel
22
+ * path, no behavior change to the plain or TTY renderers.
23
+ */
24
+ export class ScreenReaderRenderer extends PlainRenderer {
25
+ /** Identity of the last phase announced, so we speak each activity once. */
26
+ lastPhaseId = "";
27
+ /** Text of the last progress line announced, to avoid repeats. */
28
+ lastProgress = "";
29
+ constructor(caps, out, err) {
30
+ super(caps, out, err);
31
+ }
32
+ /**
33
+ * Announce a phase transition as a committed line. `null`, `awaiting-approval`
34
+ * (the prompt block is its own announcement), and `calling-tool` (announced by
35
+ * {@link toolLifecycle}) are silent; repeats of the same activity are deduped.
36
+ */
37
+ setPhase(phase) {
38
+ const id = phaseIdentity(phase);
39
+ if (id === this.lastPhaseId)
40
+ return;
41
+ this.lastPhaseId = id;
42
+ if (phase === null ||
43
+ phase.kind === "awaiting-approval" ||
44
+ phase.kind === "calling-tool") {
45
+ return;
46
+ }
47
+ this.note(describePhase(phase, this.theme.glyph));
48
+ }
49
+ /** Announce plan-step progress as `step i of n: title`, deduped. */
50
+ progress(state) {
51
+ if (state === null) {
52
+ this.lastProgress = "";
53
+ return;
54
+ }
55
+ const line = `step ${state.step} of ${state.of}: ${state.title}`;
56
+ if (line === this.lastProgress)
57
+ return;
58
+ this.lastProgress = line;
59
+ this.note(line);
60
+ }
61
+ /**
62
+ * Bracket a tool call with two committed lines — `working: label` on start,
63
+ * `done/failed label` on end — so a screen-reader user hears both that a call
64
+ * began (crucial for long ones) and how it resolved. The end note + honest
65
+ * duration is the inherited plain behavior; only the start line is added.
66
+ */
67
+ toolLifecycle(event) {
68
+ if (event.event === "start") {
69
+ super.toolLifecycle(event); // records the start time (silent in plain)
70
+ this.note(`${this.theme.glyph.running}: ${event.label}`);
71
+ return;
72
+ }
73
+ super.toolLifecycle(event); // commits `done/failed label (duration)`
74
+ }
75
+ }
@@ -20,8 +20,22 @@ export interface RenderCapabilities {
20
20
  color: boolean;
21
21
  /** Cursor-control sequences are safe (`tty` and not `TERM=dumb`). */
22
22
  cursor: boolean;
23
- /** Animation is welcome (`cursor` and CRUXY_NO_SPINNER unset). */
23
+ /** Animation is welcome (`cursor` and motion is not reduced). */
24
24
  spinner: boolean;
25
+ /**
26
+ * Motion is reduced (U.11): no spinner animation/timer, no in-place
27
+ * re-animation — state survives as static text, only movement stops. True
28
+ * under `CRUXY_NO_SPINNER` (alias) / `NO_MOTION` / `CRUXY_REDUCED_MOTION`, and
29
+ * implied by `screenReader`. The one axis the spinner gate keys off.
30
+ */
31
+ reducedMotion: boolean;
32
+ /**
33
+ * Screen-reader mode (U.11): plain, linear, announce-friendly output — no
34
+ * live-region redraws (each state change is a committed line), no spinners,
35
+ * status glyphs rendered as words. Opt-in via `CRUXY_SCREEN_READER` /
36
+ * `ACCESSIBLE`; routes rendering to the linear path regardless of `cursor`.
37
+ */
38
+ screenReader: boolean;
25
39
  /** Unicode glyphs are safe (U.1) — false under `TERM=dumb` / `CRUXY_ASCII`;
26
40
  * independent of `color`. Drives the theme's glyph table, not its stylers. */
27
41
  unicode: boolean;
@@ -0,0 +1,22 @@
1
+ import type { SandboxCapability } from "./types.js";
2
+ /**
3
+ * Runtime detection (C.16): a container runtime is a *capability*, not an
4
+ * assumption. Presence means both that the binary exists AND its daemon
5
+ * answers — `docker` installed with a dead daemon is NOT available, and the
6
+ * caller must fail loud rather than pretend a box exists.
7
+ */
8
+ /** Injectable probe seam — spawns a short command and reports how it exited. */
9
+ export type RuntimeProbe = (bin: string, args: string[]) => Promise<{
10
+ code: number | null;
11
+ stdout: string;
12
+ stderr: string;
13
+ }>;
14
+ /**
15
+ * Detect the Docker runtime. `docker version --format {{.Server.Version}}`
16
+ * exits non-zero when the daemon is unreachable (even though the client is
17
+ * installed), so a zero exit with a server version is the honest "available"
18
+ * signal. Memoized for the process; pass a probe (tests) to bypass the cache.
19
+ */
20
+ export declare function detectDocker(probe?: RuntimeProbe): Promise<SandboxCapability>;
21
+ /** Clear the memoized capability (tests). */
22
+ export declare function resetDetectionCache(): void;
@@ -0,0 +1,67 @@
1
+ import { spawn } from "node:child_process";
2
+ /** Default probe: spawn the binary, capture output, treat a spawn error (e.g.
3
+ * ENOENT — binary missing) as a non-zero exit rather than a throw. */
4
+ const spawnProbe = (bin, args) => new Promise((resolve) => {
5
+ let child;
6
+ try {
7
+ child = spawn(bin, args);
8
+ }
9
+ catch (err) {
10
+ resolve({ code: null, stdout: "", stderr: err.message });
11
+ return;
12
+ }
13
+ let out = "";
14
+ let errText = "";
15
+ let settled = false;
16
+ const done = (code, stderr = errText) => {
17
+ if (settled)
18
+ return;
19
+ settled = true;
20
+ resolve({ code, stdout: out, stderr });
21
+ };
22
+ // The daemon can hang; a probe must never wedge startup.
23
+ const timer = setTimeout(() => {
24
+ child.kill("SIGKILL");
25
+ done(null, "timed out probing the runtime");
26
+ }, PROBE_TIMEOUT_MS);
27
+ timer.unref?.();
28
+ child.stdout?.on("data", (b) => (out += b.toString("utf8")));
29
+ child.stderr?.on("data", (b) => (errText += b.toString("utf8")));
30
+ child.on("error", (err) => done(null, err.message));
31
+ child.on("close", (code) => {
32
+ clearTimeout(timer);
33
+ done(code);
34
+ });
35
+ });
36
+ const PROBE_TIMEOUT_MS = 5000;
37
+ let cached;
38
+ /**
39
+ * Detect the Docker runtime. `docker version --format {{.Server.Version}}`
40
+ * exits non-zero when the daemon is unreachable (even though the client is
41
+ * installed), so a zero exit with a server version is the honest "available"
42
+ * signal. Memoized for the process; pass a probe (tests) to bypass the cache.
43
+ */
44
+ export function detectDocker(probe) {
45
+ if (probe)
46
+ return probeDocker(probe);
47
+ cached ??= probeDocker(spawnProbe);
48
+ return cached;
49
+ }
50
+ /** Clear the memoized capability (tests). */
51
+ export function resetDetectionCache() {
52
+ cached = undefined;
53
+ }
54
+ async function probeDocker(probe) {
55
+ const { code, stdout, stderr } = await probe("docker", [
56
+ "version",
57
+ "--format",
58
+ "{{.Server.Version}}",
59
+ ]);
60
+ if (code === 0 && stdout.trim().length > 0) {
61
+ return { available: true, runtime: "docker" };
62
+ }
63
+ const detail = code === null
64
+ ? "the docker binary is not installed or not on PATH"
65
+ : (stderr.trim().split("\n")[0] ?? "docker daemon is not reachable");
66
+ return { available: false, runtime: "docker", detail };
67
+ }
@@ -0,0 +1,32 @@
1
+ import type { ExecOptions, ExecResult, IsolationPolicy, SandboxRuntime } from "./types.js";
2
+ /**
3
+ * The shipped {@link SandboxRuntime}: shells out to the `docker` CLI (no SDK —
4
+ * matches the no-vendor-client ethos). {@link buildRunArgs} is a pure function
5
+ * so the entire isolation posture can be asserted from the argv without a live
6
+ * daemon; `exec` spawns docker, captures bias-capped output, enforces the
7
+ * wall-clock timeout by force-killing the container, and maps the result.
8
+ *
9
+ * The exit code from `docker run` is the command's own — EXCEPT `125`, which
10
+ * docker reserves for "the run itself failed" (bad flags, daemon error): that,
11
+ * and a spawn failure, are the only container-start failures, surfaced as a
12
+ * coded {@link sandboxExec} error. An ordinary non-zero command exit is a
13
+ * normal result (exit code is truth), never a thrown error and never a host run.
14
+ */
15
+ export declare class DockerRuntime implements SandboxRuntime {
16
+ private readonly bin;
17
+ readonly name = "docker";
18
+ constructor(bin?: string);
19
+ ensureImage(image: string, onPull?: () => void): Promise<void>;
20
+ /** Run a non-container docker subcommand to completion, capturing output. */
21
+ private simpleRun;
22
+ exec(command: string, policy: IsolationPolicy, opts: ExecOptions): Promise<ExecResult>;
23
+ private run;
24
+ /** Best-effort container teardown after a timeout kill. */
25
+ private forceRemove;
26
+ }
27
+ /**
28
+ * Build the `docker run` argv from a resolved policy. Pure and total — the
29
+ * single source of truth for the isolation boundary, asserted directly in
30
+ * tests. Order is stable for readability; docker is order-insensitive for flags.
31
+ */
32
+ export declare function buildRunArgs(policy: IsolationPolicy, container: string, command: string): string[];
@@ -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;