@cruxy/cli 0.11.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.
Files changed (71) hide show
  1. package/dist/approval/prompt.js +17 -15
  2. package/dist/cli/commands/checkpoint.js +6 -4
  3. package/dist/cli/commands/config.js +10 -7
  4. package/dist/cli/commands/index.js +16 -15
  5. package/dist/cli/commands/init.js +5 -3
  6. package/dist/cli/commands/login.js +5 -3
  7. package/dist/cli/commands/pr.js +8 -7
  8. package/dist/cli/commands/rollback.js +7 -6
  9. package/dist/cli/commands/run.js +26 -7
  10. package/dist/cli/commands/skills.js +12 -10
  11. package/dist/cli/program.js +7 -6
  12. package/dist/cli/repl.js +11 -9
  13. package/dist/cli/session-factory.d.ts +2 -1
  14. package/dist/cli/session-factory.js +10 -3
  15. package/dist/components/frame.js +3 -1
  16. package/dist/components/fuzzy.d.ts +4 -4
  17. package/dist/components/fuzzy.js +14 -13
  18. package/dist/components/select.js +8 -7
  19. package/dist/config/schema.d.ts +123 -0
  20. package/dist/config/schema.js +40 -0
  21. package/dist/errors/constructors.d.ts +21 -0
  22. package/dist/errors/constructors.js +58 -0
  23. package/dist/errors/format.js +8 -8
  24. package/dist/errors/types.d.ts +5 -0
  25. package/dist/errors/types.js +11 -0
  26. package/dist/onboarding/flow.js +6 -6
  27. package/dist/onboarding/steps.js +11 -11
  28. package/dist/plan/approve.js +6 -6
  29. package/dist/plan/render.js +26 -18
  30. package/dist/render/capabilities.js +4 -0
  31. package/dist/render/diff.d.ts +6 -7
  32. package/dist/render/diff.js +33 -22
  33. package/dist/render/highlight.d.ts +3 -3
  34. package/dist/render/highlight.js +15 -15
  35. package/dist/render/index.d.ts +1 -1
  36. package/dist/render/plain-renderer.d.ts +2 -1
  37. package/dist/render/plain-renderer.js +7 -6
  38. package/dist/render/state.d.ts +7 -2
  39. package/dist/render/state.js +16 -10
  40. package/dist/render/tty-renderer.d.ts +2 -1
  41. package/dist/render/tty-renderer.js +20 -17
  42. package/dist/render/types.d.ts +7 -0
  43. package/dist/sandbox/detect.d.ts +22 -0
  44. package/dist/sandbox/detect.js +67 -0
  45. package/dist/sandbox/docker-runtime.d.ts +32 -0
  46. package/dist/sandbox/docker-runtime.js +263 -0
  47. package/dist/sandbox/index.d.ts +7 -0
  48. package/dist/sandbox/index.js +5 -0
  49. package/dist/sandbox/policy.d.ts +17 -0
  50. package/dist/sandbox/policy.js +90 -0
  51. package/dist/sandbox/service.d.ts +57 -0
  52. package/dist/sandbox/service.js +64 -0
  53. package/dist/sandbox/types.d.ts +114 -0
  54. package/dist/sandbox/types.js +17 -0
  55. package/dist/subagent/orchestrator.d.ts +7 -0
  56. package/dist/subagent/orchestrator.js +22 -6
  57. package/dist/testing/run-tests-tool.d.ts +5 -1
  58. package/dist/testing/run-tests-tool.js +8 -1
  59. package/dist/testing/sandbox-runner.d.ts +16 -0
  60. package/dist/testing/sandbox-runner.js +47 -0
  61. package/dist/theme/index.d.ts +2 -0
  62. package/dist/theme/index.js +2 -0
  63. package/dist/theme/resolve.d.ts +32 -0
  64. package/dist/theme/resolve.js +73 -0
  65. package/dist/theme/tokens.d.ts +104 -0
  66. package/dist/theme/tokens.js +52 -0
  67. package/dist/tools/shell/run-command.js +35 -1
  68. package/dist/tools/types.d.ts +10 -0
  69. package/dist/utils/logger.d.ts +2 -0
  70. package/dist/utils/logger.js +7 -4
  71. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
1
  import type { ActionPreview } from "../tools/types.js";
2
+ import { type Theme } from "../theme/index.js";
2
3
  import type { ProgressState, RenderCapabilities, RenderPhase, RenderStream, StreamRenderer, ToolLifecycleEvent } from "./types.js";
3
4
  /**
4
5
  * The interactive renderer: committed content is append-only; the one transient
@@ -28,7 +29,7 @@ import type { ProgressState, RenderCapabilities, RenderPhase, RenderStream, Stre
28
29
  export declare class TtyRenderer implements StreamRenderer {
29
30
  readonly caps: RenderCapabilities;
30
31
  private readonly out;
31
- private readonly colors;
32
+ readonly theme: Theme;
32
33
  private print;
33
34
  private highlighter;
34
35
  private wroteInSegment;
@@ -1,13 +1,10 @@
1
- import pc from "picocolors";
1
+ import { resolveTheme } from "../theme/index.js";
2
2
  import { createStreamPrinter } from "../cli/stream-print.js";
3
3
  import { renderActionPreview } from "./diff.js";
4
4
  import { createStreamHighlighter, } from "./highlight.js";
5
5
  import { composeStatusLine, ELAPSED_AFTER_MS, formatElapsed, phaseIdentity, } from "./state.js";
6
6
  /** Erase the current line and return the cursor to column 0. */
7
7
  const CLEAR_LINE = "\r\x1b[2K";
8
- /** Spinner frames (braille); a static glyph when animation is disabled. */
9
- const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
10
- const STATIC_FRAME = "◐";
11
8
  const SPINNER_INTERVAL_MS = 100;
12
9
  /**
13
10
  * The interactive renderer: committed content is append-only; the one transient
@@ -37,7 +34,7 @@ const SPINNER_INTERVAL_MS = 100;
37
34
  export class TtyRenderer {
38
35
  caps;
39
36
  out;
40
- colors;
37
+ theme;
41
38
  print;
42
39
  highlighter;
43
40
  wroteInSegment = false;
@@ -59,8 +56,8 @@ export class TtyRenderer {
59
56
  constructor(caps, out) {
60
57
  this.caps = caps;
61
58
  this.out = out;
62
- this.colors = pc.createColors(caps.color);
63
- this.highlighter = createStreamHighlighter(this.colors);
59
+ this.theme = resolveTheme(caps);
60
+ this.highlighter = createStreamHighlighter(this.theme);
64
61
  this.print = this.newPrinter();
65
62
  }
66
63
  newPrinter() {
@@ -115,7 +112,7 @@ export class TtyRenderer {
115
112
  const elapsed = this.phase !== null && this.caps.spinner
116
113
  ? Date.now() - this.phaseStartedAt
117
114
  : undefined;
118
- return composeStatusLine(this.progressState, this.phase, elapsed);
115
+ return composeStatusLine(this.progressState, this.phase, elapsed, this.theme.glyph);
119
116
  }
120
117
  /** Redraw the live line from current state, or hide it when there is none. */
121
118
  refresh() {
@@ -138,16 +135,19 @@ export class TtyRenderer {
138
135
  }
139
136
  drawLine(text) {
140
137
  this.lineVisible = true;
138
+ const frames = this.theme.glyph.spinnerFrames;
141
139
  const glyph = this.caps.spinner
142
- ? FRAMES[this.frame % FRAMES.length]
143
- : STATIC_FRAME;
140
+ ? frames[this.frame % frames.length]
141
+ : this.theme.glyph.spinnerStatic;
144
142
  // Reserve glyph + space; truncate so the live line can never soft-wrap.
145
143
  const room = Math.max(1, this.caps.width - 2);
146
- const line = text.length > room ? text.slice(0, Math.max(0, room - 1)) + "…" : text;
147
- this.out.write(`${CLEAR_LINE}${this.colors.cyan(glyph)} ${this.colors.dim(line)}`);
144
+ const line = text.length > room
145
+ ? text.slice(0, Math.max(0, room - 1)) + this.theme.glyph.ellipsis
146
+ : text;
147
+ this.out.write(`${CLEAR_LINE}${this.theme.accent(glyph)} ${this.theme.muted(line)}`);
148
148
  }
149
149
  beginTurn() {
150
- this.highlighter = createStreamHighlighter(this.colors);
150
+ this.highlighter = createStreamHighlighter(this.theme);
151
151
  this.print = this.newPrinter();
152
152
  this.wroteInSegment = false;
153
153
  }
@@ -169,13 +169,15 @@ export class TtyRenderer {
169
169
  if (this.closed)
170
170
  return;
171
171
  const room = Math.max(1, this.caps.width);
172
- const line = text.length > room ? text.slice(0, room - 1) + "…" : text;
173
- this.commit(this.colors.dim(line) + "\n");
172
+ const line = text.length > room
173
+ ? text.slice(0, room - 1) + this.theme.glyph.ellipsis
174
+ : text;
175
+ this.commit(this.theme.muted(line) + "\n");
174
176
  }
175
177
  preview(preview) {
176
178
  if (this.closed)
177
179
  return;
178
- const block = renderActionPreview(preview, this.colors);
180
+ const block = renderActionPreview(preview, this.theme);
179
181
  if (block)
180
182
  this.commit(block + "\n");
181
183
  }
@@ -237,7 +239,8 @@ export class TtyRenderer {
237
239
  // honest even with CRUXY_NO_SPINNER; shown only once it means something.
238
240
  const elapsed = started === null ? 0 : Date.now() - started.at;
239
241
  const suffix = elapsed >= ELAPSED_AFTER_MS ? ` (${formatElapsed(elapsed)})` : "";
240
- this.note(`${event.ok ? "✓" : "✗"} ${event.label}${suffix}`);
242
+ const mark = event.ok ? this.theme.glyph.success : this.theme.glyph.failure;
243
+ this.note(`${mark} ${event.label}${suffix}`);
241
244
  }
242
245
  promptResolved() {
243
246
  if (this.closed)
@@ -1,4 +1,5 @@
1
1
  import type { ActionPreview } from "../tools/types.js";
2
+ import type { Theme } from "../theme/index.js";
2
3
  /**
3
4
  * The streaming render seam (U.2): the agent loop talks to a
4
5
  * {@link StreamRenderer}, never to raw stdout. Two implementations exist —
@@ -21,6 +22,9 @@ export interface RenderCapabilities {
21
22
  cursor: boolean;
22
23
  /** Animation is welcome (`cursor` and CRUXY_NO_SPINNER unset). */
23
24
  spinner: boolean;
25
+ /** Unicode glyphs are safe (U.1) — false under `TERM=dumb` / `CRUXY_ASCII`;
26
+ * independent of `color`. Drives the theme's glyph table, not its stylers. */
27
+ unicode: boolean;
24
28
  /** Terminal columns; 80 when unknown (non-TTY). */
25
29
  width: number;
26
30
  }
@@ -97,6 +101,9 @@ export type ToolLifecycleEvent = {
97
101
  */
98
102
  export interface StreamRenderer {
99
103
  readonly caps: RenderCapabilities;
104
+ /** The one resolved design system (U.1) — glyphs/roles for chrome a
105
+ * surface emits through this renderer (e.g. the subagent trail notes). */
106
+ readonly theme: Theme;
100
107
  /** Start a user turn: reset leading-newline trim and code-fence state. */
101
108
  beginTurn(): void;
102
109
  /**
@@ -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;
@@ -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
+ }