@celestea/tools 2.7.1

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 (140) hide show
  1. package/LICENSE +21 -0
  2. package/dist/args.d.ts +15 -0
  3. package/dist/args.js +55 -0
  4. package/dist/attachments/image-header.d.ts +28 -0
  5. package/dist/attachments/image-header.js +148 -0
  6. package/dist/attachments/store.d.ts +56 -0
  7. package/dist/attachments/store.js +148 -0
  8. package/dist/browser/cdp.d.ts +162 -0
  9. package/dist/browser/cdp.js +269 -0
  10. package/dist/browser/launch.d.ts +92 -0
  11. package/dist/browser/launch.js +232 -0
  12. package/dist/browser/memory-guard.d.ts +55 -0
  13. package/dist/browser/memory-guard.js +210 -0
  14. package/dist/browser/session.d.ts +146 -0
  15. package/dist/browser/session.js +371 -0
  16. package/dist/browser/snapshot.d.ts +69 -0
  17. package/dist/browser/snapshot.js +163 -0
  18. package/dist/browser/types.d.ts +51 -0
  19. package/dist/browser/types.js +8 -0
  20. package/dist/builtin.d.ts +54 -0
  21. package/dist/builtin.js +77 -0
  22. package/dist/desc.d.ts +20 -0
  23. package/dist/desc.js +22 -0
  24. package/dist/disclosure.d.ts +113 -0
  25. package/dist/disclosure.js +141 -0
  26. package/dist/env.d.ts +10 -0
  27. package/dist/env.js +33 -0
  28. package/dist/errors.d.ts +25 -0
  29. package/dist/errors.js +60 -0
  30. package/dist/exposure.d.ts +145 -0
  31. package/dist/exposure.js +244 -0
  32. package/dist/fn-tool.d.ts +14 -0
  33. package/dist/fn-tool.js +14 -0
  34. package/dist/fs/file-io.d.ts +78 -0
  35. package/dist/fs/file-io.js +239 -0
  36. package/dist/guard/path-guard.d.ts +144 -0
  37. package/dist/guard/path-guard.js +289 -0
  38. package/dist/guard/paths.d.ts +35 -0
  39. package/dist/guard/paths.js +100 -0
  40. package/dist/http/errors.d.ts +24 -0
  41. package/dist/http/errors.js +64 -0
  42. package/dist/http/headers.d.ts +19 -0
  43. package/dist/http/headers.js +62 -0
  44. package/dist/http/redirects.d.ts +31 -0
  45. package/dist/http/redirects.js +76 -0
  46. package/dist/http/ssrf.d.ts +105 -0
  47. package/dist/http/ssrf.js +272 -0
  48. package/dist/http/transport.d.ts +50 -0
  49. package/dist/http/transport.js +130 -0
  50. package/dist/index.d.ts +114 -0
  51. package/dist/index.js +129 -0
  52. package/dist/memory/log.d.ts +75 -0
  53. package/dist/memory/log.js +157 -0
  54. package/dist/memory/store.d.ts +47 -0
  55. package/dist/memory/store.js +61 -0
  56. package/dist/platform/exec.d.ts +79 -0
  57. package/dist/platform/exec.js +218 -0
  58. package/dist/platform/index.d.ts +12 -0
  59. package/dist/platform/index.js +12 -0
  60. package/dist/platform/paths.d.ts +51 -0
  61. package/dist/platform/paths.js +60 -0
  62. package/dist/platform/quote.d.ts +72 -0
  63. package/dist/platform/quote.js +102 -0
  64. package/dist/plugin.d.ts +96 -0
  65. package/dist/plugin.js +101 -0
  66. package/dist/process/buffers.d.ts +33 -0
  67. package/dist/process/buffers.js +86 -0
  68. package/dist/process/registry.d.ts +98 -0
  69. package/dist/process/registry.js +282 -0
  70. package/dist/registry.d.ts +52 -0
  71. package/dist/registry.js +161 -0
  72. package/dist/run-code/broker.d.ts +68 -0
  73. package/dist/run-code/broker.js +465 -0
  74. package/dist/run-code/limits.d.ts +69 -0
  75. package/dist/run-code/limits.js +88 -0
  76. package/dist/run-code/lines.d.ts +69 -0
  77. package/dist/run-code/lines.js +199 -0
  78. package/dist/run-code/sdk-ts.d.ts +34 -0
  79. package/dist/run-code/sdk-ts.js +276 -0
  80. package/dist/run-code/sdk.d.ts +39 -0
  81. package/dist/run-code/sdk.js +294 -0
  82. package/dist/sandbox/async.d.ts +10 -0
  83. package/dist/sandbox/async.js +26 -0
  84. package/dist/sandbox/bwrap-argv.d.ts +62 -0
  85. package/dist/sandbox/bwrap-argv.js +113 -0
  86. package/dist/sandbox/bwrap.d.ts +94 -0
  87. package/dist/sandbox/bwrap.js +159 -0
  88. package/dist/sandbox/child.d.ts +38 -0
  89. package/dist/sandbox/child.js +98 -0
  90. package/dist/sandbox/config.d.ts +89 -0
  91. package/dist/sandbox/config.js +149 -0
  92. package/dist/sandbox/fake-sandbox.d.ts +57 -0
  93. package/dist/sandbox/fake-sandbox.js +110 -0
  94. package/dist/sandbox/launch.d.ts +51 -0
  95. package/dist/sandbox/launch.js +134 -0
  96. package/dist/sandbox/limits.d.ts +63 -0
  97. package/dist/sandbox/limits.js +113 -0
  98. package/dist/sandbox/probe.d.ts +46 -0
  99. package/dist/sandbox/probe.js +102 -0
  100. package/dist/sandbox/provider.d.ts +83 -0
  101. package/dist/sandbox/provider.js +126 -0
  102. package/dist/sandbox/rlimit.d.ts +60 -0
  103. package/dist/sandbox/rlimit.js +76 -0
  104. package/dist/sandbox/seccomp.d.ts +48 -0
  105. package/dist/sandbox/seccomp.js +115 -0
  106. package/dist/sandbox/userspace.d.ts +65 -0
  107. package/dist/sandbox/userspace.js +107 -0
  108. package/dist/sandbox/workdir.d.ts +13 -0
  109. package/dist/sandbox/workdir.js +44 -0
  110. package/dist/schema.d.ts +20 -0
  111. package/dist/schema.js +135 -0
  112. package/dist/testing/platform-gates.d.ts +54 -0
  113. package/dist/testing/platform-gates.js +62 -0
  114. package/dist/tool-failure.d.ts +13 -0
  115. package/dist/tool-failure.js +19 -0
  116. package/dist/tools/ask-user.d.ts +32 -0
  117. package/dist/tools/ask-user.js +145 -0
  118. package/dist/tools/browser.d.ts +24 -0
  119. package/dist/tools/browser.js +132 -0
  120. package/dist/tools/http-request.d.ts +31 -0
  121. package/dist/tools/http-request.js +117 -0
  122. package/dist/tools/list-dir.d.ts +9 -0
  123. package/dist/tools/list-dir.js +45 -0
  124. package/dist/tools/load-skill.d.ts +37 -0
  125. package/dist/tools/load-skill.js +76 -0
  126. package/dist/tools/memory.d.ts +46 -0
  127. package/dist/tools/memory.js +131 -0
  128. package/dist/tools/process-control.d.ts +14 -0
  129. package/dist/tools/process-control.js +49 -0
  130. package/dist/tools/read-file.d.ts +11 -0
  131. package/dist/tools/read-file.js +81 -0
  132. package/dist/tools/read-image.d.ts +33 -0
  133. package/dist/tools/read-image.js +144 -0
  134. package/dist/tools/run-code.d.ts +48 -0
  135. package/dist/tools/run-code.js +115 -0
  136. package/dist/tools/run-shell.d.ts +22 -0
  137. package/dist/tools/run-shell.js +81 -0
  138. package/dist/tools/write-file.d.ts +8 -0
  139. package/dist/tools/write-file.js +31 -0
  140. package/package.json +28 -0
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Fake `Sandbox` — a scripted, in-memory implementation of the seam.
3
+ *
4
+ * Purpose: prove the seam is genuinely replaceable (ARCHITECTURE.md §7.4) and
5
+ * let the tool pipeline and the process registry be tested without spawning any
6
+ * real process: stdout/stderr are synthetic streams, exits are scripted, and
7
+ * `terminate()` can be made deliberately ignorable so the SIGKILL escalation
8
+ * path is observable. It is a test double, not a production provider.
9
+ */
10
+ import { PassThrough, Readable } from "node:stream";
11
+ import { SandboxError, USERSPACE_SANDBOX_META } from "@celestea/core";
12
+ import { buildSandboxConfig } from "./config.js";
13
+ /** Scripted child: synthetic pipes, scripted exit, observable signals. */
14
+ export class FakeChild {
15
+ pid;
16
+ stdin = new PassThrough();
17
+ stdout;
18
+ stderr;
19
+ written = [];
20
+ termAttempts = 0;
21
+ killAttempts = 0;
22
+ resolved = null;
23
+ resolveWait = null;
24
+ waited;
25
+ script;
26
+ constructor(pid, script) {
27
+ this.pid = pid;
28
+ this.script = script;
29
+ this.stdout = Readable.from(script.stdout ?? []);
30
+ this.stderr = Readable.from(script.stderr ?? []);
31
+ this.stdin.on("data", (chunk) => this.written.push(chunk.toString("utf8")));
32
+ this.waited = new Promise((resolve) => {
33
+ this.resolveWait = resolve;
34
+ });
35
+ const delayMs = script.exitAfterMs ?? 0;
36
+ if (Number.isFinite(delayMs)) {
37
+ const timer = setTimeout(() => this.settle({ code: script.exitCode ?? 0, signal: null }), delayMs);
38
+ timer.unref?.();
39
+ }
40
+ }
41
+ wait() {
42
+ return this.waited;
43
+ }
44
+ terminate() {
45
+ this.termAttempts += 1;
46
+ if (this.script.ignoresSigterm === true)
47
+ return;
48
+ this.settle({ code: null, signal: "SIGTERM" });
49
+ }
50
+ kill() {
51
+ this.killAttempts += 1;
52
+ this.settle({ code: null, signal: "SIGKILL" });
53
+ }
54
+ settle(exit) {
55
+ if (this.resolved !== null)
56
+ return;
57
+ this.resolved = exit;
58
+ this.resolveWait?.(exit);
59
+ }
60
+ }
61
+ export function createFakeSandbox(options = {}) {
62
+ const config = buildSandboxConfig(options.config ?? {});
63
+ const meta = options.meta ?? USERSPACE_SANDBOX_META;
64
+ const scripts = [...(options.scripts ?? [])];
65
+ const spawns = [];
66
+ const runs = [];
67
+ let nextPid = 5000;
68
+ const take = () => scripts.shift() ?? options.fallback ?? {};
69
+ const child = (script) => {
70
+ nextPid += 1;
71
+ return new FakeChild(nextPid, script);
72
+ };
73
+ return {
74
+ config,
75
+ spawns,
76
+ runs,
77
+ get lastChild() {
78
+ return spawns.at(-1)?.child ?? null;
79
+ },
80
+ async run(request) {
81
+ const script = take();
82
+ runs.push(request.command);
83
+ const timeoutMs = request.timeoutMs ?? config.timeoutMs;
84
+ const delayMs = script.exitAfterMs ?? 0;
85
+ if (!Number.isFinite(delayMs) || delayMs > timeoutMs) {
86
+ throw new SandboxError("timeout", `killed pid ? after ${timeoutMs}ms (stdout_captured_bytes=0 stderr_captured_bytes=0)`, {
87
+ timeout_ms: timeoutMs,
88
+ });
89
+ }
90
+ const stdout = clip((script.stdout ?? []).join(""), config.maxOutputBytes);
91
+ const stderr = clip((script.stderr ?? []).join(""), config.maxOutputBytes);
92
+ return {
93
+ stdout: stdout.text,
94
+ stderr: stderr.text,
95
+ exit_code: script.exitCode ?? 0,
96
+ stdout_truncated: stdout.truncated,
97
+ stderr_truncated: stderr.truncated,
98
+ sandbox: meta,
99
+ };
100
+ },
101
+ async spawn(request) {
102
+ const created = child(take());
103
+ spawns.push({ command: request.command, workdir: request.workdir, child: created });
104
+ return { child: created, sandbox: meta };
105
+ },
106
+ };
107
+ }
108
+ function clip(text, cap) {
109
+ return text.length <= cap ? { text, truncated: false } : { text: text.slice(0, cap), truncated: true };
110
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Provider-neutral process plumbing: spawn → capture → deadline → reap.
3
+ *
4
+ * Every sandbox provider (`userspace`, `bwrap`) differs only in *what* it
5
+ * execs; the hard parts are identical and live here so the two paths cannot
6
+ * drift:
7
+ * - the child leads its own **process group** (`detached`), so a timeout
8
+ * SIGKILLs the whole tree, not just the leader;
9
+ * - each stream is drained to EOF while buffering at most `maxOutputBytes`
10
+ * (a chatty child can never deadlock on a full pipe);
11
+ * - a timeout surfaces as a structured `SandboxError` carrying the captured
12
+ * byte counts and output previews.
13
+ */
14
+ import type { ChildProcess } from "node:child_process";
15
+ import type { Readable } from "node:stream";
16
+ import type { SandboxConfig, SandboxMeta, SandboxRunResult } from "@celestea/core";
17
+ /** Grace allowed for a SIGKILLed child to be reaped before we stop waiting. */
18
+ export declare const REAP_GRACE_MS = 5000;
19
+ export interface SpawnPlan {
20
+ program: string;
21
+ args: readonly string[];
22
+ workdir: string;
23
+ env: Record<string, string>;
24
+ /** Extra fds passed to the child after stdio (index 3, 4, …). */
25
+ extraFds?: readonly number[];
26
+ /** stdin is piped for background children (`process_control` writes lines). */
27
+ withStdin: boolean;
28
+ /** Human-readable command for spawn-failure messages. */
29
+ label: string;
30
+ }
31
+ export declare function validateSandboxConfig(config: SandboxConfig): void;
32
+ export declare function resolveTimeout(config: SandboxConfig, override: number | undefined): number;
33
+ /** Spawn the plan; rejects with a structured `spawn` error, never a bare throw. */
34
+ export declare function spawnPlan(plan: SpawnPlan): Promise<ChildProcess>;
35
+ /** Enforce the deadline, cap both streams, and report the effective meta. */
36
+ export declare function captureRun(config: SandboxConfig, child: ChildProcess, timeoutMs: number, meta: SandboxMeta): Promise<SandboxRunResult>;
37
+ export interface CappedText {
38
+ text: string;
39
+ bytes: number;
40
+ truncated: boolean;
41
+ }
42
+ /**
43
+ * Drain a stream to EOF while buffering at most `cap` bytes. Bytes past the cap
44
+ * are read and discarded (never buffered) so a chatty child can still finish
45
+ * and its exit code stays observable.
46
+ */
47
+ export declare function readCapped(stream: Readable | null, cap: number): Promise<CappedText>;
48
+ /** Single-line, truncated preview for error messages (legacy `preview`). */
49
+ export declare function preview(text: string, max: number): string;
50
+ /** Signal the whole process group of a detached child (best effort). */
51
+ export declare function killDetached(child: ChildProcess, signal: NodeJS.Signals): void;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Provider-neutral process plumbing: spawn → capture → deadline → reap.
3
+ *
4
+ * Every sandbox provider (`userspace`, `bwrap`) differs only in *what* it
5
+ * execs; the hard parts are identical and live here so the two paths cannot
6
+ * drift:
7
+ * - the child leads its own **process group** (`detached`), so a timeout
8
+ * SIGKILLs the whole tree, not just the leader;
9
+ * - each stream is drained to EOF while buffering at most `maxOutputBytes`
10
+ * (a chatty child can never deadlock on a full pipe);
11
+ * - a timeout surfaces as a structured `SandboxError` carrying the captured
12
+ * byte counts and output previews.
13
+ */
14
+ import { spawn } from "node:child_process";
15
+ import { SandboxError } from "@celestea/core";
16
+ import { TIMED_OUT, withTimeout } from "./async.js";
17
+ import { signalTree, wrapChild } from "./child.js";
18
+ /** Grace allowed for a SIGKILLed child to be reaped before we stop waiting. */
19
+ export const REAP_GRACE_MS = 5_000;
20
+ export function validateSandboxConfig(config) {
21
+ if (config.maxOutputBytes <= 0)
22
+ throw new SandboxError("config", "maxOutputBytes must be > 0");
23
+ if (config.timeoutMs <= 0)
24
+ throw new SandboxError("config", "timeoutMs must be > 0");
25
+ if (config.maxTimeoutMs <= 0)
26
+ throw new SandboxError("config", "maxTimeoutMs must be > 0");
27
+ }
28
+ export function resolveTimeout(config, override) {
29
+ if (override === undefined)
30
+ return config.timeoutMs;
31
+ if (override < 1)
32
+ throw new SandboxError("arg", `timeoutMs must be >= 1, got ${override}`);
33
+ if (override > config.maxTimeoutMs) {
34
+ throw new SandboxError("arg", `timeoutMs=${override} exceeds the sandbox maximum ${config.maxTimeoutMs}ms`);
35
+ }
36
+ return override;
37
+ }
38
+ /** Spawn the plan; rejects with a structured `spawn` error, never a bare throw. */
39
+ export function spawnPlan(plan) {
40
+ const stdio = [
41
+ plan.withStdin ? "pipe" : "ignore",
42
+ "pipe",
43
+ "pipe",
44
+ ...(plan.extraFds ?? []),
45
+ ];
46
+ return new Promise((resolve, reject) => {
47
+ const child = spawn(plan.program, [...plan.args], {
48
+ cwd: plan.workdir,
49
+ env: plan.env,
50
+ stdio,
51
+ detached: true,
52
+ });
53
+ child.once("spawn", () => resolve(child));
54
+ child.once("error", (error) => {
55
+ reject(new SandboxError("spawn", `failed to start '${preview(plan.label, 256)}': ${error.message}`));
56
+ });
57
+ });
58
+ }
59
+ /** Enforce the deadline, cap both streams, and report the effective meta. */
60
+ export async function captureRun(config, child, timeoutMs, meta) {
61
+ const sandboxed = wrapChild(child, { detached: true });
62
+ const outPromise = readCapped(child.stdout, config.maxOutputBytes);
63
+ const errPromise = readCapped(child.stderr, config.maxOutputBytes);
64
+ const exit = await withTimeout(sandboxed.wait(), timeoutMs);
65
+ if (exit === TIMED_OUT)
66
+ throw await timeoutFailure(sandboxed, outPromise, errPromise, timeoutMs);
67
+ const [stdout, stderr] = await Promise.all([outPromise, errPromise]);
68
+ return {
69
+ stdout: stdout.text,
70
+ stderr: stderr.text,
71
+ exit_code: exit.code,
72
+ signal: exit.signal,
73
+ stdout_truncated: stdout.truncated,
74
+ stderr_truncated: stderr.truncated,
75
+ sandbox: meta,
76
+ };
77
+ }
78
+ async function timeoutFailure(sandboxed, outPromise, errPromise, timeoutMs) {
79
+ sandboxed.kill();
80
+ await withTimeout(sandboxed.wait(), REAP_GRACE_MS);
81
+ const stdout = await outPromise;
82
+ const stderr = await errPromise;
83
+ const detail = {
84
+ pid: sandboxed.pid,
85
+ timeout_ms: timeoutMs,
86
+ stdout_captured: stdout.bytes,
87
+ stderr_captured: stderr.bytes,
88
+ };
89
+ return new SandboxError("timeout", `killed pid ${sandboxed.pid ?? "?"} after ${timeoutMs}ms (stdout_captured_bytes=${stdout.bytes} stderr_captured_bytes=${stderr.bytes} stdout_preview="${preview(stdout.text, 512)}" stderr_preview="${preview(stderr.text, 512)}")`, detail);
90
+ }
91
+ /**
92
+ * Drain a stream to EOF while buffering at most `cap` bytes. Bytes past the cap
93
+ * are read and discarded (never buffered) so a chatty child can still finish
94
+ * and its exit code stays observable.
95
+ */
96
+ export async function readCapped(stream, cap) {
97
+ if (stream === null)
98
+ return { text: "", bytes: 0, truncated: false };
99
+ const chunks = [];
100
+ let size = 0;
101
+ let truncated = false;
102
+ try {
103
+ for await (const chunk of stream) {
104
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
105
+ const room = cap - size;
106
+ // B3 / W812 P2-1: exactly `cap` bytes is COMPLETE, not truncated; only a
107
+ // byte beyond the remaining capacity (or an already-full buffer) is.
108
+ if (buffer.length <= room) {
109
+ chunks.push(buffer);
110
+ size += buffer.length;
111
+ continue;
112
+ }
113
+ if (room > 0)
114
+ chunks.push(buffer.subarray(0, room));
115
+ size = cap;
116
+ truncated = true;
117
+ }
118
+ }
119
+ catch {
120
+ // A killed child tears its pipes down mid-read; the bytes captured so far
121
+ // are still the honest answer (the legacy path drains to EOF the same way).
122
+ return { text: Buffer.concat(chunks).toString("utf8"), bytes: size, truncated };
123
+ }
124
+ return { text: Buffer.concat(chunks).toString("utf8"), bytes: size, truncated };
125
+ }
126
+ /** Single-line, truncated preview for error messages (legacy `preview`). */
127
+ export function preview(text, max) {
128
+ const folded = text.replace(/\r?\n/g, "\\n");
129
+ return folded.length > max ? `${folded.slice(0, max)}…` : folded;
130
+ }
131
+ /** Signal the whole process group of a detached child (best effort). */
132
+ export function killDetached(child, signal) {
133
+ signalTree(child, { detached: true }, signal);
134
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Resource limits for the OS-isolated provider — and the `RLIMIT_NPROC` trap.
3
+ *
4
+ * `RLIMIT_NPROC` is **not** a per-sandbox process count: the kernel counts every
5
+ * thread owned by the *real UID across the whole host*. W274 §3.2 measured this
6
+ * the hard way: with 347 threads owned by uid 1003 on this machine, `--nproc`
7
+ * below that number makes bwrap fail to even `clone()` its pid-1
8
+ * (`Resource temporarily unavailable`), while the legacy default of 512 left only
9
+ * ~165 threads of slack before production commands start failing.
10
+ *
11
+ * So the cap is **derived at probe time**: `threads(uid) + headroom`, with a
12
+ * floor that also covers hosts where counting is impossible (non-Linux, no
13
+ * `/proc`). It stays a fork-bomb brake, it just stops being a time bomb.
14
+ */
15
+ /** Env var: explicit `RLIMIT_NPROC` override (skips derivation). */
16
+ export declare const ENV_SANDBOX_NPROC = "CELESTEA_SANDBOX_NPROC";
17
+ /** Env var: slack added to the measured UID thread count (default 512). */
18
+ export declare const ENV_SANDBOX_NPROC_HEADROOM = "CELESTEA_SANDBOX_NPROC_HEADROOM";
19
+ /** Env var: `0` disables every rlimit (operator escape hatch). */
20
+ export declare const ENV_SANDBOX_RLIMITS = "CELESTEA_SANDBOX_RLIMITS";
21
+ /** Default slack above the measured thread count. */
22
+ export declare const NPROC_HEADROOM = 512;
23
+ /** Floor used when the thread count cannot be measured (and a sane minimum). */
24
+ export declare const NPROC_FLOOR = 1024;
25
+ /** The six limits the OS layer enforces (mirrors the legacy `V2Limits::default()`). */
26
+ export interface SandboxLimits {
27
+ cpuSec: number;
28
+ memMb: number;
29
+ nproc: number;
30
+ fsizeBytes: number;
31
+ nofile: number;
32
+ core: boolean;
33
+ }
34
+ /** Legacy defaults, except `nproc` which is always derived (see module docs). */
35
+ export declare const DEFAULT_LIMITS: Omit<SandboxLimits, "nproc">;
36
+ /**
37
+ * Count every thread owned by `uid` on this host by summing
38
+ * `/proc/<pid>/task/*` for the pids that uid owns. Returns `null` when the
39
+ * answer cannot be established (non-Linux, no `/proc`, nothing readable).
40
+ */
41
+ export declare function countUidThreads(uid?: number | null, procRoot?: string): number | null;
42
+ /** `threads + headroom`, never below `floor` (see the module docs). */
43
+ export declare function deriveNproc(threads: number | null, headroom?: number, floor?: number): number;
44
+ /** Limits for one provider instance: measured `nproc` plus env overrides. */
45
+ export declare function limitsFromEnv(env?: NodeJS.ProcessEnv, threads?: number | null): SandboxLimits;
46
+ /** rlimits are on unless the operator explicitly turns them off. */
47
+ export declare function rlimitsEnabled(env?: NodeJS.ProcessEnv): boolean;
48
+ /** W6: the resolved per-call CPU limit plus whether it was clamped. */
49
+ export interface CpuResolution {
50
+ cpuSec: number;
51
+ clamped: boolean;
52
+ /** The raw request (null = the caller passed nothing). */
53
+ requested: number | null;
54
+ }
55
+ /**
56
+ * Resolve the effective `RLIMIT_CPU` for ONE call.
57
+ * - absent `requested` keeps `base` (the default, `DEFAULT_LIMITS.cpuSec` = 20);
58
+ * - above `max` is CLAMPED to `max` (`clamped: true`, documented, not an error);
59
+ * - a non-integer/below-1 value keeps `base` (the arg schema rejects it first).
60
+ */
61
+ export declare function resolveCpuSec(base: number, requested: number | undefined, max: number): CpuResolution;
62
+ /** The base limits with the resolved CPU limit merged in (identity when equal). */
63
+ export declare function limitsForCpu(base: SandboxLimits, resolution: CpuResolution): SandboxLimits;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Resource limits for the OS-isolated provider — and the `RLIMIT_NPROC` trap.
3
+ *
4
+ * `RLIMIT_NPROC` is **not** a per-sandbox process count: the kernel counts every
5
+ * thread owned by the *real UID across the whole host*. W274 §3.2 measured this
6
+ * the hard way: with 347 threads owned by uid 1003 on this machine, `--nproc`
7
+ * below that number makes bwrap fail to even `clone()` its pid-1
8
+ * (`Resource temporarily unavailable`), while the legacy default of 512 left only
9
+ * ~165 threads of slack before production commands start failing.
10
+ *
11
+ * So the cap is **derived at probe time**: `threads(uid) + headroom`, with a
12
+ * floor that also covers hosts where counting is impossible (non-Linux, no
13
+ * `/proc`). It stays a fork-bomb brake, it just stops being a time bomb.
14
+ */
15
+ import { readdirSync, statSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { envFlag, envInt } from "../env.js";
18
+ /** Env var: explicit `RLIMIT_NPROC` override (skips derivation). */
19
+ export const ENV_SANDBOX_NPROC = "CELESTEA_SANDBOX_NPROC";
20
+ /** Env var: slack added to the measured UID thread count (default 512). */
21
+ export const ENV_SANDBOX_NPROC_HEADROOM = "CELESTEA_SANDBOX_NPROC_HEADROOM";
22
+ /** Env var: `0` disables every rlimit (operator escape hatch). */
23
+ export const ENV_SANDBOX_RLIMITS = "CELESTEA_SANDBOX_RLIMITS";
24
+ /** Default slack above the measured thread count. */
25
+ export const NPROC_HEADROOM = 512;
26
+ /** Floor used when the thread count cannot be measured (and a sane minimum). */
27
+ export const NPROC_FLOOR = 1024;
28
+ /** Legacy defaults, except `nproc` which is always derived (see module docs). */
29
+ export const DEFAULT_LIMITS = {
30
+ cpuSec: 20,
31
+ memMb: 2048,
32
+ fsizeBytes: 256 * 1024 * 1024,
33
+ nofile: 256,
34
+ core: true,
35
+ };
36
+ /**
37
+ * Count every thread owned by `uid` on this host by summing
38
+ * `/proc/<pid>/task/*` for the pids that uid owns. Returns `null` when the
39
+ * answer cannot be established (non-Linux, no `/proc`, nothing readable).
40
+ */
41
+ export function countUidThreads(uid = currentUid(), procRoot = "/proc") {
42
+ if (uid === null || process.platform !== "linux")
43
+ return null;
44
+ const entries = readDir(procRoot);
45
+ let total = 0;
46
+ for (const entry of entries) {
47
+ if (isPid(entry))
48
+ total += threadsOf(join(procRoot, entry), uid);
49
+ }
50
+ return total > 0 ? total : null;
51
+ }
52
+ function currentUid() {
53
+ return typeof process.getuid === "function" ? process.getuid() : null;
54
+ }
55
+ function readDir(target) {
56
+ try {
57
+ return readdirSync(target);
58
+ }
59
+ catch {
60
+ return [];
61
+ }
62
+ }
63
+ function isPid(entry) {
64
+ const value = Number(entry);
65
+ return Number.isInteger(value) && value > 0 && entry === String(value);
66
+ }
67
+ function threadsOf(pidDir, uid) {
68
+ try {
69
+ if (statSync(pidDir).uid !== uid)
70
+ return 0;
71
+ return readdirSync(join(pidDir, "task")).length;
72
+ }
73
+ catch {
74
+ return 0; // the process vanished mid-scan (or is not ours to inspect)
75
+ }
76
+ }
77
+ /** `threads + headroom`, never below `floor` (see the module docs). */
78
+ export function deriveNproc(threads, headroom = NPROC_HEADROOM, floor = NPROC_FLOOR) {
79
+ return Math.max(floor, (threads ?? 0) + headroom);
80
+ }
81
+ /** Limits for one provider instance: measured `nproc` plus env overrides. */
82
+ export function limitsFromEnv(env = process.env, threads = countUidThreads()) {
83
+ const explicit = envInt(env, ENV_SANDBOX_NPROC);
84
+ const headroom = envInt(env, ENV_SANDBOX_NPROC_HEADROOM);
85
+ return {
86
+ ...DEFAULT_LIMITS,
87
+ nproc: explicit !== undefined && explicit > 0 ? explicit : deriveNproc(threads, headroom ?? NPROC_HEADROOM),
88
+ };
89
+ }
90
+ /** rlimits are on unless the operator explicitly turns them off. */
91
+ export function rlimitsEnabled(env = process.env) {
92
+ return envFlag(env[ENV_SANDBOX_RLIMITS], true);
93
+ }
94
+ /**
95
+ * Resolve the effective `RLIMIT_CPU` for ONE call.
96
+ * - absent `requested` keeps `base` (the default, `DEFAULT_LIMITS.cpuSec` = 20);
97
+ * - above `max` is CLAMPED to `max` (`clamped: true`, documented, not an error);
98
+ * - a non-integer/below-1 value keeps `base` (the arg schema rejects it first).
99
+ */
100
+ export function resolveCpuSec(base, requested, max) {
101
+ if (requested === undefined)
102
+ return { cpuSec: base, clamped: false, requested: null };
103
+ const raw = Math.trunc(requested);
104
+ if (!Number.isFinite(raw) || raw < 1)
105
+ return { cpuSec: base, clamped: false, requested };
106
+ if (raw > max)
107
+ return { cpuSec: max, clamped: true, requested: raw };
108
+ return { cpuSec: raw, clamped: false, requested: raw };
109
+ }
110
+ /** The base limits with the resolved CPU limit merged in (identity when equal). */
111
+ export function limitsForCpu(base, resolution) {
112
+ return resolution.cpuSec === base.cpuSec ? base : { ...base, cpuSec: resolution.cpuSec };
113
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Host self-check (`probeHost`): what isolation can this machine actually give?
3
+ *
4
+ * The probe is deliberately *evidence-based*: bwrap is only "usable" after it
5
+ * has run the real mount sequence and read `/dev/zero` inside it (W274 §8.2).
6
+ * A binary that exists but cannot create a namespace (AppArmor tightening, a
7
+ * missing `bwrap-userns-restrict` exception, a deleted device node) must be
8
+ * reported as unusable — never assumed working, never assumed broken.
9
+ *
10
+ * The result is memoized per process (the probe costs two short `execFileSync`s)
11
+ * and is injectable, so policy tests never touch the host.
12
+ */
13
+ /** Env var: explicit `bwrap` path (skips the PATH search). */
14
+ export declare const ENV_SANDBOX_BWRAP = "CELESTEA_SANDBOX_BWRAP";
15
+ export interface HostProbe {
16
+ readonly platform: NodeJS.Platform;
17
+ readonly bwrapPath: string | null;
18
+ readonly bwrapVersion: string | null;
19
+ /** true only after the ordered device smoke returned `ok`. */
20
+ readonly bwrapUsable: boolean;
21
+ /** Why the probe rejected bwrap (startup-log / degraded-reason material). */
22
+ readonly bwrapRejectReason: string | null;
23
+ readonly prlimitPath: string | null;
24
+ readonly shellUlimitWorks: boolean;
25
+ /** Threads owned by this uid host-wide → drives the `RLIMIT_NPROC` cap. */
26
+ readonly uidThreads: number | null;
27
+ }
28
+ export interface ProbeOptions {
29
+ env?: NodeJS.ProcessEnv;
30
+ /** Ignore (and replace) the memoized probe. */
31
+ refresh?: boolean;
32
+ /** Inject a uid thread count (tests); default: measure the host. */
33
+ uidThreads?: number | null;
34
+ }
35
+ /** Drop the memoized probe (tests, or after a host-level change). */
36
+ export declare function resetProbeCache(): void;
37
+ export declare function probeHost(options?: ProbeOptions): HostProbe;
38
+ /**
39
+ * PATH lookup that honours an explicitly passed env (no global mutation).
40
+ *
41
+ * W885: split per PLATFORM delimiter and, on Windows, with `PATHEXT`
42
+ * suffixes — a Windows `PATH` is `;`-separated and its entries carry drive
43
+ * letters, so the old `split(":")` produced meaningless results there
44
+ * (W883 B13). `platform` is injectable so the win32 rule is testable on Linux.
45
+ */
46
+ export declare function whichSync(bin: string, env?: NodeJS.ProcessEnv, platform?: string): string | null;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Host self-check (`probeHost`): what isolation can this machine actually give?
3
+ *
4
+ * The probe is deliberately *evidence-based*: bwrap is only "usable" after it
5
+ * has run the real mount sequence and read `/dev/zero` inside it (W274 §8.2).
6
+ * A binary that exists but cannot create a namespace (AppArmor tightening, a
7
+ * missing `bwrap-userns-restrict` exception, a deleted device node) must be
8
+ * reported as unusable — never assumed working, never assumed broken.
9
+ *
10
+ * The result is memoized per process (the probe costs two short `execFileSync`s)
11
+ * and is injectable, so policy tests never touch the host.
12
+ */
13
+ import { execFileSync } from "node:child_process";
14
+ import { existsSync } from "node:fs";
15
+ import { whichInPath } from "../platform/exec.js";
16
+ import { buildBwrapArgv, DEFAULT_BWRAP_OPTIONS } from "./bwrap-argv.js";
17
+ import { countUidThreads } from "./limits.js";
18
+ /** Env var: explicit `bwrap` path (skips the PATH search). */
19
+ export const ENV_SANDBOX_BWRAP = "CELESTEA_SANDBOX_BWRAP";
20
+ /** One-shot device smoke: read a device node that only exists if order is right. */
21
+ const DEVICE_SMOKE = "exec 3</dev/zero 2>/dev/null && exec 4</dev/null && printf ok";
22
+ /** One-shot ulimit probe: the zero-dependency rlimit fallback. */
23
+ const ULIMIT_SMOKE = "ulimit -v 65536 2>/dev/null && ulimit -t 1 && printf ok";
24
+ let cached = null;
25
+ let cachedKey = null;
26
+ /** Drop the memoized probe (tests, or after a host-level change). */
27
+ export function resetProbeCache() {
28
+ cached = null;
29
+ cachedKey = null;
30
+ }
31
+ /** Cache key: the probe answer depends on these two env values and nothing else. */
32
+ function cacheKey(env) {
33
+ return `${env[ENV_SANDBOX_BWRAP] ?? ""}\u0000${env["PATH"] ?? ""}`;
34
+ }
35
+ export function probeHost(options = {}) {
36
+ const env = options.env ?? process.env;
37
+ const key = cacheKey(env);
38
+ if (cached !== null && cachedKey === key && options.refresh !== true)
39
+ return cached;
40
+ cachedKey = key;
41
+ const bwrap = probeBwrap(env);
42
+ cached = {
43
+ platform: process.platform,
44
+ ...bwrap,
45
+ prlimitPath: whichSync("prlimit", env),
46
+ shellUlimitWorks: runOk("/bin/sh", ["-c", ULIMIT_SMOKE]).includes("ok"),
47
+ uidThreads: options.uidThreads === undefined ? countUidThreads() : options.uidThreads,
48
+ };
49
+ return cached;
50
+ }
51
+ function probeBwrap(env) {
52
+ const path = resolveBwrapPath(env);
53
+ if (path === null)
54
+ return reject("bwrap not found on PATH (set CELESTEA_SANDBOX_BWRAP to pin one)");
55
+ const version = run(path, ["--version"]);
56
+ if (!version.ok)
57
+ return reject(`${path} --version failed: ${version.out}`, path);
58
+ if (process.platform !== "linux")
59
+ return reject(`${path} reported ${version.out}, but this is not Linux`, path);
60
+ const smoke = run(path, [...buildBwrapArgv(null, DEFAULT_BWRAP_OPTIONS), "--", "/bin/sh", "-c", DEVICE_SMOKE]);
61
+ if (!smoke.out.includes("ok")) {
62
+ return reject(`device smoke failed (argv order regression?): ${smoke.out || "(no output)"}`, path);
63
+ }
64
+ return { bwrapPath: path, bwrapVersion: version.out, bwrapUsable: true, bwrapRejectReason: null };
65
+ }
66
+ function reject(reason, path = null) {
67
+ return { bwrapPath: path, bwrapVersion: null, bwrapUsable: false, bwrapRejectReason: reason };
68
+ }
69
+ function resolveBwrapPath(env) {
70
+ const pinned = env[ENV_SANDBOX_BWRAP];
71
+ if (pinned !== undefined && pinned.trim() !== "") {
72
+ const candidate = pinned.trim();
73
+ return existsSync(candidate) ? candidate : null;
74
+ }
75
+ return whichSync("bwrap", env);
76
+ }
77
+ /**
78
+ * PATH lookup that honours an explicitly passed env (no global mutation).
79
+ *
80
+ * W885: split per PLATFORM delimiter and, on Windows, with `PATHEXT`
81
+ * suffixes — a Windows `PATH` is `;`-separated and its entries carry drive
82
+ * letters, so the old `split(":")` produced meaningless results there
83
+ * (W883 B13). `platform` is injectable so the win32 rule is testable on Linux.
84
+ */
85
+ export function whichSync(bin, env = process.env, platform = process.platform) {
86
+ return whichInPath(bin, platform, env);
87
+ }
88
+ /** Run a short probe command; never throws, folds stdout+stderr into `out`. */
89
+ function run(file, args) {
90
+ try {
91
+ const out = execFileSync(file, [...args], { stdio: ["ignore", "pipe", "pipe"], timeout: 10_000 }).toString();
92
+ return { ok: true, out };
93
+ }
94
+ catch (error) {
95
+ const detail = error;
96
+ const out = `${detail.stdout?.toString() ?? ""}${detail.stderr?.toString() ?? ""}${detail.message ?? ""}`;
97
+ return { ok: false, out };
98
+ }
99
+ }
100
+ function runOk(file, args) {
101
+ return run(file, args).out;
102
+ }