@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,83 @@
1
+ /**
2
+ * Provider policy — **which** sandbox a deployment gets, decided out loud.
3
+ *
4
+ * `bwrap` (probe-gated) → else `userspace`. The fallback is *explicit*:
5
+ * `CELESTEA_SANDBOX_FALLBACK=userspace` (default) degrades, while `=fail`
6
+ * refuses to run at all. That is the direct answer to the W268 lesson — the
7
+ * engine spent months on the weak userspace path because "bwrap unusable" was
8
+ * inferred from a broken probe and the degradation was never reported.
9
+ *
10
+ * Two consequences of "fail-closed" are deliberate:
11
+ * - an unrecognized `CELESTEA_SANDBOX_FALLBACK` value is an error, not a silent
12
+ * return to the default (a typo must not decide the security posture);
13
+ * - the chosen provider always travels in `SandboxMeta` (`degraded`/`reason`
14
+ * are available from `selectSandboxDetailed` for startup logs).
15
+ *
16
+ * Same env vocabulary as the engine contract (`contracts/tools.json`):
17
+ * `CELESTEA_SANDBOX_NET=1`, `CELESTEA_SANDBOX_SHARE_TMP=1`,
18
+ * `CELESTEA_SANDBOX_SECCOMP=1`, plus `CELESTEA_SANDBOX_MASK=<abs dirs>`.
19
+ */
20
+ import type { Sandbox, SandboxConfig } from "@celestea/core";
21
+ import { type BwrapOptions } from "./bwrap-argv.js";
22
+ import { type HostProbe } from "./probe.js";
23
+ /** Env var: `userspace` (default, degrade) | `fail` (refuse). */
24
+ export declare const ENV_SANDBOX_FALLBACK = "CELESTEA_SANDBOX_FALLBACK";
25
+ /** Env var: `1` keeps the host network namespace (contract knob). */
26
+ export declare const ENV_SANDBOX_NET = "CELESTEA_SANDBOX_NET";
27
+ /** Env var: `1` binds the host `/tmp` instead of a private tmpfs (contract knob). */
28
+ export declare const ENV_SANDBOX_SHARE_TMP = "CELESTEA_SANDBOX_SHARE_TMP";
29
+ /** Env var: `1` installs the TS cBPF whitelist (contract knob). */
30
+ export declare const ENV_SANDBOX_SECCOMP = "CELESTEA_SANDBOX_SECCOMP";
31
+ /** Env var: comma-separated absolute host dirs masked with an empty tmpfs. */
32
+ export declare const ENV_SANDBOX_MASK = "CELESTEA_SANDBOX_MASK";
33
+ export type SandboxFallbackMode = "userspace" | "fail";
34
+ /**
35
+ * Session-grant view of the sandbox provider (W516). Structural: the tools
36
+ * package never imports the host's grants module.
37
+ */
38
+ export interface SandboxGrantView {
39
+ /** `network`: keep the host network namespace even without the env knob. */
40
+ network?: boolean;
41
+ /** `unsandboxed`: accept the userspace provider although the mode is `fail`. */
42
+ unsandboxed?: boolean;
43
+ /** W9: the permission baseline's write capability (false = read-only). */
44
+ workspaceWritable?: boolean;
45
+ /** W9: extra absolute write roots to bind rw in the sandbox. */
46
+ writeRoots?: readonly string[];
47
+ }
48
+ export interface SelectOptions {
49
+ env?: NodeJS.ProcessEnv;
50
+ config?: SandboxConfig;
51
+ /** Inject a probe (tests); default: the memoized host probe. */
52
+ probe?: HostProbe;
53
+ /** Per-session grants (W516): widen only — see [bwrapOptionsFromEnv]. */
54
+ grants?: SandboxGrantView;
55
+ }
56
+ export interface SandboxSelection {
57
+ sandbox: Sandbox;
58
+ /** Provider that will actually execute commands. */
59
+ provider: string;
60
+ /** true when bwrap was unavailable and the userspace path was chosen. */
61
+ degraded: boolean;
62
+ /** Why bwrap was rejected (for the startup log); null when not degraded. */
63
+ reason: string | null;
64
+ mode: SandboxFallbackMode;
65
+ /**
66
+ * true when the userspace provider was chosen *because* the `unsandboxed`
67
+ * session grant overrode `CELESTEA_SANDBOX_FALLBACK=fail`. The host must
68
+ * record a `degraded_by_grant` audit line when it sees this (`§4.4`).
69
+ */
70
+ degradedByGrant: boolean;
71
+ }
72
+ /** The selected sandbox, ready to inject (`builtinTools`, plugin service). */
73
+ export declare function selectSandbox(options?: SelectOptions): Sandbox;
74
+ /** Selection plus the honest story of how it was made. */
75
+ export declare function selectSandboxDetailed(options?: SelectOptions): SandboxSelection;
76
+ /** Fallback policy; an unknown value is rejected (fail-closed, see module docs). */
77
+ export declare function fallbackMode(env?: NodeJS.ProcessEnv): SandboxFallbackMode;
78
+ /**
79
+ * bwrap knobs from the contract env vocabulary. W516: a session's `network`
80
+ * grant ORs into `shareNet` (and touches nothing else — `shareTmp`, `seccomp`,
81
+ * `maskDirs` and the rlimits stay exactly as the operator set them, §5.6).
82
+ */
83
+ export declare function bwrapOptionsFromEnv(env?: NodeJS.ProcessEnv, grants?: SandboxGrantView): BwrapOptions;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Provider policy — **which** sandbox a deployment gets, decided out loud.
3
+ *
4
+ * `bwrap` (probe-gated) → else `userspace`. The fallback is *explicit*:
5
+ * `CELESTEA_SANDBOX_FALLBACK=userspace` (default) degrades, while `=fail`
6
+ * refuses to run at all. That is the direct answer to the W268 lesson — the
7
+ * engine spent months on the weak userspace path because "bwrap unusable" was
8
+ * inferred from a broken probe and the degradation was never reported.
9
+ *
10
+ * Two consequences of "fail-closed" are deliberate:
11
+ * - an unrecognized `CELESTEA_SANDBOX_FALLBACK` value is an error, not a silent
12
+ * return to the default (a typo must not decide the security posture);
13
+ * - the chosen provider always travels in `SandboxMeta` (`degraded`/`reason`
14
+ * are available from `selectSandboxDetailed` for startup logs).
15
+ *
16
+ * Same env vocabulary as the engine contract (`contracts/tools.json`):
17
+ * `CELESTEA_SANDBOX_NET=1`, `CELESTEA_SANDBOX_SHARE_TMP=1`,
18
+ * `CELESTEA_SANDBOX_SECCOMP=1`, plus `CELESTEA_SANDBOX_MASK=<abs dirs>`.
19
+ */
20
+ import { isAbsolute } from "node:path";
21
+ import { SandboxError } from "@celestea/core";
22
+ import { BWRAP_PROVIDER, DEFAULT_BWRAP_OPTIONS } from "./bwrap-argv.js";
23
+ import { BwrapSandbox } from "./bwrap.js";
24
+ import { sandboxConfigFromEnv } from "./config.js";
25
+ import { envFlag, envString } from "../env.js";
26
+ import { limitsFromEnv, rlimitsEnabled } from "./limits.js";
27
+ import { probeHost } from "./probe.js";
28
+ import { UserspaceSandbox } from "./userspace.js";
29
+ /** Env var: `userspace` (default, degrade) | `fail` (refuse). */
30
+ export const ENV_SANDBOX_FALLBACK = "CELESTEA_SANDBOX_FALLBACK";
31
+ /** Env var: `1` keeps the host network namespace (contract knob). */
32
+ export const ENV_SANDBOX_NET = "CELESTEA_SANDBOX_NET";
33
+ /** Env var: `1` binds the host `/tmp` instead of a private tmpfs (contract knob). */
34
+ export const ENV_SANDBOX_SHARE_TMP = "CELESTEA_SANDBOX_SHARE_TMP";
35
+ /** Env var: `1` installs the TS cBPF whitelist (contract knob). */
36
+ export const ENV_SANDBOX_SECCOMP = "CELESTEA_SANDBOX_SECCOMP";
37
+ /** Env var: comma-separated absolute host dirs masked with an empty tmpfs. */
38
+ export const ENV_SANDBOX_MASK = "CELESTEA_SANDBOX_MASK";
39
+ /** The selected sandbox, ready to inject (`builtinTools`, plugin service). */
40
+ export function selectSandbox(options = {}) {
41
+ return selectSandboxDetailed(options).sandbox;
42
+ }
43
+ /** Selection plus the honest story of how it was made. */
44
+ export function selectSandboxDetailed(options = {}) {
45
+ const env = options.env ?? process.env;
46
+ const mode = fallbackMode(env);
47
+ const grants = options.grants ?? {};
48
+ const probe = options.probe ?? probeHost({ env });
49
+ const config = options.config ?? sandboxConfigFromEnv(env);
50
+ if (probe.bwrapUsable && probe.bwrapPath !== null) {
51
+ const sandbox = new BwrapSandbox(config, {
52
+ probe,
53
+ limits: limitsFromEnv(env, probe.uidThreads),
54
+ // W516 §4.3.5: `unsandboxed` is IGNORED when bwrap works — isolation is
55
+ // already in effect and grants only ever add an escape when a policy
56
+ // refuses, never "less isolation than the host already provides".
57
+ run: bwrapOptionsFromEnv(env, grants),
58
+ rlimits: rlimitsEnabled(env),
59
+ });
60
+ return { sandbox, provider: BWRAP_PROVIDER, degraded: false, reason: null, mode, degradedByGrant: false };
61
+ }
62
+ const reason = probe.bwrapRejectReason ?? "bwrap reported unusable by the host probe";
63
+ if (mode === "fail") {
64
+ // §4.3.5: the ONE case `unsandboxed` is for — the policy would refuse, and
65
+ // the user explicitly asked (by clicking) for this session to run anyway.
66
+ if (grants.unsandboxed === true) {
67
+ return {
68
+ sandbox: new UserspaceSandbox(config, { probe, limits: limitsFromEnv(env, probe.uidThreads), rlimits: rlimitsEnabled(env) }),
69
+ provider: "userspace",
70
+ degraded: true,
71
+ reason: `${reason} — degraded by the 'unsandboxed' session grant`,
72
+ mode,
73
+ degradedByGrant: true,
74
+ };
75
+ }
76
+ throw new SandboxError("config", `sandbox_unavailable: ${reason} and ${ENV_SANDBOX_FALLBACK}=fail refuses to degrade to the userspace sandbox`, { provider: BWRAP_PROVIDER, reason, mode });
77
+ }
78
+ return {
79
+ sandbox: new UserspaceSandbox(config, { probe, limits: limitsFromEnv(env, probe.uidThreads), rlimits: rlimitsEnabled(env) }),
80
+ provider: "userspace",
81
+ degraded: true,
82
+ reason,
83
+ mode,
84
+ degradedByGrant: false,
85
+ };
86
+ }
87
+ /** Fallback policy; an unknown value is rejected (fail-closed, see module docs). */
88
+ export function fallbackMode(env = process.env) {
89
+ const raw = envString(env, ENV_SANDBOX_FALLBACK)?.toLowerCase();
90
+ if (raw === undefined || raw === "userspace")
91
+ return "userspace";
92
+ if (raw === "fail")
93
+ return "fail";
94
+ throw new SandboxError("config", `invalid ${ENV_SANDBOX_FALLBACK}='${raw}' (expected 'userspace' or 'fail')`, {
95
+ value: raw,
96
+ });
97
+ }
98
+ /**
99
+ * bwrap knobs from the contract env vocabulary. W516: a session's `network`
100
+ * grant ORs into `shareNet` (and touches nothing else — `shareTmp`, `seccomp`,
101
+ * `maskDirs` and the rlimits stay exactly as the operator set them, §5.6).
102
+ */
103
+ export function bwrapOptionsFromEnv(env = process.env, grants = {}) {
104
+ return {
105
+ shareNet: envFlag(env[ENV_SANDBOX_NET], DEFAULT_BWRAP_OPTIONS.shareNet) || grants.network === true,
106
+ shareTmp: envFlag(env[ENV_SANDBOX_SHARE_TMP], DEFAULT_BWRAP_OPTIONS.shareTmp),
107
+ seccomp: envFlag(env[ENV_SANDBOX_SECCOMP], DEFAULT_BWRAP_OPTIONS.seccomp),
108
+ maskDirs: parseMaskDirs(envString(env, ENV_SANDBOX_MASK)),
109
+ workspaceWritable: grants.workspaceWritable !== false,
110
+ writeRoots: [...(grants.writeRoots ?? [])],
111
+ };
112
+ }
113
+ function parseMaskDirs(raw) {
114
+ if (raw === undefined)
115
+ return [];
116
+ const dirs = raw
117
+ .split(",")
118
+ .map((entry) => entry.trim())
119
+ .filter((entry) => entry !== "");
120
+ for (const dir of dirs) {
121
+ if (!isAbsolute(dir) || dir === "/") {
122
+ throw new SandboxError("config", `invalid ${ENV_SANDBOX_MASK} entry '${dir}' (absolute, non-root paths only)`);
123
+ }
124
+ }
125
+ return dirs;
126
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The rlimit layer: `prlimit` when present, `/bin/sh` builtins when not.
3
+ *
4
+ * Limits are applied **outside** the namespace layer (on the bwrap process
5
+ * itself) and inherited by everything it forks, so a sandboxed tree cannot raise
6
+ * them back. Both mechanisms are equivalent (W274 §3.1/§3.3); the binary is
7
+ * preferred only because `prlimit --core=0` expresses "no core files" without
8
+ * shell quoting.
9
+ *
10
+ * Note `RLIMIT_NPROC` semantics: it counts threads of the whole real UID on the
11
+ * host (`limits.ts` derives the value accordingly) — a too-small value does not
12
+ * merely throttle the sandbox, it makes `clone()` fail for the sandbox launcher.
13
+ *
14
+ * F4: `RLIMIT_AS` is the ONE limit a modern Chromium cannot live under (it
15
+ * reserves an enormous virtual address space). [RlimitApplyOptions] therefore
16
+ * carries a per-call `noAddressSpaceLimit` that omits ONLY `--as=` /
17
+ * `ulimit -v`; every other limit stays. [rlimitDiagnostics] makes that exemption
18
+ * observable on the provider's `describe()` path without touching SandboxMeta.
19
+ */
20
+ import type { SandboxLimits } from "./limits.js";
21
+ import type { HostProbe } from "./probe.js";
22
+ /** How the limits were enforced — reported, never guessed. */
23
+ export type RlimitVia = "prlimit" | "shell-ulimit" | "none";
24
+ export interface RlimitPlan {
25
+ program: string;
26
+ args: string[];
27
+ via: RlimitVia;
28
+ }
29
+ /** Which rlimit mechanism the probe leaves available. */
30
+ export declare function rlimitVia(probe: HostProbe): RlimitVia;
31
+ /** Per-call knobs for [applyLimits]. */
32
+ export interface RlimitApplyOptions {
33
+ /** false disables EVERY rlimit (the operator escape hatch). Default true. */
34
+ enabled?: boolean;
35
+ /** true omits RLIMIT_AS only; every other limit stays. Default false. */
36
+ noAddressSpaceLimit?: boolean;
37
+ }
38
+ /** Diagnostic knobs for a provider's describe() path. */
39
+ export interface RlimitDescribeOptions {
40
+ /** The answer for a call that WOULD pass noAddressSpaceLimit. Default false. */
41
+ noAddressSpaceLimit?: boolean;
42
+ }
43
+ /** Whether (and how) limits are in force — diagnostics, never SandboxMeta. */
44
+ export interface RlimitDiagnostics {
45
+ via: RlimitVia;
46
+ rlimits_enabled: boolean;
47
+ address_space_limited: boolean;
48
+ }
49
+ /** Pure diagnostic projection (F4: makes an address-space exemption observable). */
50
+ export declare function rlimitDiagnostics(probe: HostProbe, enabled: boolean, noAddressSpaceLimit: boolean): RlimitDiagnostics;
51
+ /**
52
+ * Wrap `program args` with the strongest available rlimit mechanism.
53
+ *
54
+ * The 5th parameter accepts the legacy boolean ("enable every limit") OR
55
+ * [RlimitApplyOptions]. `false` / `{ enabled: false }` returns the command
56
+ * untouched; `{ noAddressSpaceLimit: true }` omits ONLY `RLIMIT_AS`.
57
+ */
58
+ export declare function applyLimits(program: string, args: readonly string[], limits: SandboxLimits, probe: HostProbe, options?: boolean | RlimitApplyOptions): RlimitPlan;
59
+ /** dash/bash `ulimit` form of the same six limits (RLIMIT_AS optional). */
60
+ export declare function ulimitScript(limits: SandboxLimits, noAddressSpaceLimit?: boolean): string;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The rlimit layer: `prlimit` when present, `/bin/sh` builtins when not.
3
+ *
4
+ * Limits are applied **outside** the namespace layer (on the bwrap process
5
+ * itself) and inherited by everything it forks, so a sandboxed tree cannot raise
6
+ * them back. Both mechanisms are equivalent (W274 §3.1/§3.3); the binary is
7
+ * preferred only because `prlimit --core=0` expresses "no core files" without
8
+ * shell quoting.
9
+ *
10
+ * Note `RLIMIT_NPROC` semantics: it counts threads of the whole real UID on the
11
+ * host (`limits.ts` derives the value accordingly) — a too-small value does not
12
+ * merely throttle the sandbox, it makes `clone()` fail for the sandbox launcher.
13
+ *
14
+ * F4: `RLIMIT_AS` is the ONE limit a modern Chromium cannot live under (it
15
+ * reserves an enormous virtual address space). [RlimitApplyOptions] therefore
16
+ * carries a per-call `noAddressSpaceLimit` that omits ONLY `--as=` /
17
+ * `ulimit -v`; every other limit stays. [rlimitDiagnostics] makes that exemption
18
+ * observable on the provider's `describe()` path without touching SandboxMeta.
19
+ */
20
+ import { SandboxError } from "@celestea/core";
21
+ /** Which rlimit mechanism the probe leaves available. */
22
+ export function rlimitVia(probe) {
23
+ if (probe.prlimitPath !== null)
24
+ return "prlimit";
25
+ return probe.shellUlimitWorks ? "shell-ulimit" : "none";
26
+ }
27
+ /** Pure diagnostic projection (F4: makes an address-space exemption observable). */
28
+ export function rlimitDiagnostics(probe, enabled, noAddressSpaceLimit) {
29
+ return {
30
+ via: enabled ? rlimitVia(probe) : "none",
31
+ rlimits_enabled: enabled,
32
+ address_space_limited: enabled && !noAddressSpaceLimit,
33
+ };
34
+ }
35
+ /**
36
+ * Wrap `program args` with the strongest available rlimit mechanism.
37
+ *
38
+ * The 5th parameter accepts the legacy boolean ("enable every limit") OR
39
+ * [RlimitApplyOptions]. `false` / `{ enabled: false }` returns the command
40
+ * untouched; `{ noAddressSpaceLimit: true }` omits ONLY `RLIMIT_AS`.
41
+ */
42
+ export function applyLimits(program, args, limits, probe, options = true) {
43
+ const { enabled, noAddressSpaceLimit } = normalizeApplyOptions(options);
44
+ if (!enabled)
45
+ return { program, args: [...args], via: "none" };
46
+ if (probe.prlimitPath !== null) {
47
+ return { program: probe.prlimitPath, args: [...prlimitArgs(limits, noAddressSpaceLimit), "--", program, ...args], via: "prlimit" };
48
+ }
49
+ if (probe.shellUlimitWorks) {
50
+ const script = ulimitScript(limits, noAddressSpaceLimit) + '; exec "$0" "$@"';
51
+ return { program: "/bin/sh", args: ["-c", script, program, ...args], via: "shell-ulimit" };
52
+ }
53
+ throw new SandboxError("config", "rlimits requested but neither the prlimit binary nor a shell with usable ulimit builtins is available", { prlimit: false, shellUlimit: false, nproc: limits.nproc });
54
+ }
55
+ function normalizeApplyOptions(options) {
56
+ if (typeof options === "boolean")
57
+ return { enabled: options, noAddressSpaceLimit: false };
58
+ return { enabled: options.enabled ?? true, noAddressSpaceLimit: options.noAddressSpaceLimit === true };
59
+ }
60
+ function prlimitArgs(limits, noAddressSpaceLimit = false) {
61
+ const args = ["--cpu=" + limits.cpuSec];
62
+ if (!noAddressSpaceLimit)
63
+ args.push("--as=" + limits.memMb * 1024 * 1024);
64
+ args.push("--nproc=" + limits.nproc, "--fsize=" + limits.fsizeBytes, "--nofile=" + limits.nofile, "--core=" + (limits.core ? 0 : "unlimited"));
65
+ return args;
66
+ }
67
+ /** dash/bash `ulimit` form of the same six limits (RLIMIT_AS optional). */
68
+ // B3 / W812 P2-3: POSIX/dash ulimit -f counts 512-byte blocks while prlimit's
69
+ // --fsize is bytes; dividing by 1024 halved the effective limit.
70
+ export function ulimitScript(limits, noAddressSpaceLimit = false) {
71
+ const lines = ["ulimit -t " + limits.cpuSec];
72
+ if (!noAddressSpaceLimit)
73
+ lines.push("ulimit -v " + limits.memMb * 1024);
74
+ lines.push("ulimit -u " + limits.nproc, "ulimit -f " + Math.ceil(limits.fsizeBytes / 512), "ulimit -n " + limits.nofile, "ulimit -c " + (limits.core ? 0 : "unlimited"));
75
+ return lines.join("; ");
76
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Pure-TS cBPF seccomp whitelist (W274 §3.6), handed to `bwrap --seccomp FD`.
3
+ *
4
+ * Classic BPF needs no native addon and no helper process: 8 bytes per
5
+ * instruction (`u16 code | u8 jt | u8 jf | u32 k`, little-endian) written to a
6
+ * regular file whose fd is passed as fd 3. The instruction stream is a
7
+ * field-for-field port of the engine's `seccomp_v2` builder, so the two sandboxes
8
+ * deny the same syscalls.
9
+ *
10
+ * Semantics: x86_64 only; unknown arch → `EPERM`; x32-flagged numbers → `EPERM`;
11
+ * everything outside the whitelist → `EPERM`, except `clone3` → `ENOSYS` (so
12
+ * glibc falls back to `clone` instead of failing outright).
13
+ *
14
+ * Node needs four read-only metadata queries on top of the engine table
15
+ * (`51 getsockname` + `55 getsockopt` let libuv's `uv_guess_handle()` classify
16
+ * socket-backed stdio; `143 sched_getparam` + `145 sched_getscheduler` back
17
+ * `pthread_getschedparam`, which V8's absl mutex calls at startup). Without them
18
+ * Node does not fail loudly: `uv_guess_handle()` returns an unknown handle, the
19
+ * `process.stdout`/`stderr` objects get no libuv handle, and every write is
20
+ * dropped silently (W775).
21
+ *
22
+ * `53 socketpair` is the one addition that is not a metadata query: CPython's
23
+ * asyncio event loop builds its self-pipe with it. It stays a purely local IPC
24
+ * primitive — `socket(41)`, `connect(42)`, `bind(49)` and friends are still
25
+ * denied and the net namespace is unshared, so it grants no reach outside the
26
+ * sandbox (same family as the already-allowed `pipe2`/`eventfd2`).
27
+ */
28
+ export interface BpfInstruction {
29
+ code: number;
30
+ jt: number;
31
+ jf: number;
32
+ k: number;
33
+ }
34
+ /** The full filter: 4 header/jump slots + 2 per allowed syscall + 3 tail slots. */
35
+ export declare function buildSeccompFilter(): BpfInstruction[];
36
+ /** Serialize to the blob `bwrap --seccomp FD` expects. */
37
+ export declare function toBlobBytes(program?: BpfInstruction[]): Buffer;
38
+ export declare function instructionCount(program?: BpfInstruction[]): number;
39
+ export interface SeccompBlobHandle {
40
+ /** Open read-only fd, passed to the child as fd 3 (`--seccomp 3`). */
41
+ readonly fd: number;
42
+ dispose(): void;
43
+ }
44
+ /**
45
+ * Materialize the filter in a private temp file and open it. The caller must
46
+ * call `dispose()` after spawn (the child holds its own dup of the fd).
47
+ */
48
+ export declare function openSeccompBlob(dir?: string): SeccompBlobHandle;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Pure-TS cBPF seccomp whitelist (W274 §3.6), handed to `bwrap --seccomp FD`.
3
+ *
4
+ * Classic BPF needs no native addon and no helper process: 8 bytes per
5
+ * instruction (`u16 code | u8 jt | u8 jf | u32 k`, little-endian) written to a
6
+ * regular file whose fd is passed as fd 3. The instruction stream is a
7
+ * field-for-field port of the engine's `seccomp_v2` builder, so the two sandboxes
8
+ * deny the same syscalls.
9
+ *
10
+ * Semantics: x86_64 only; unknown arch → `EPERM`; x32-flagged numbers → `EPERM`;
11
+ * everything outside the whitelist → `EPERM`, except `clone3` → `ENOSYS` (so
12
+ * glibc falls back to `clone` instead of failing outright).
13
+ *
14
+ * Node needs four read-only metadata queries on top of the engine table
15
+ * (`51 getsockname` + `55 getsockopt` let libuv's `uv_guess_handle()` classify
16
+ * socket-backed stdio; `143 sched_getparam` + `145 sched_getscheduler` back
17
+ * `pthread_getschedparam`, which V8's absl mutex calls at startup). Without them
18
+ * Node does not fail loudly: `uv_guess_handle()` returns an unknown handle, the
19
+ * `process.stdout`/`stderr` objects get no libuv handle, and every write is
20
+ * dropped silently (W775).
21
+ *
22
+ * `53 socketpair` is the one addition that is not a metadata query: CPython's
23
+ * asyncio event loop builds its self-pipe with it. It stays a purely local IPC
24
+ * primitive — `socket(41)`, `connect(42)`, `bind(49)` and friends are still
25
+ * denied and the net namespace is unshared, so it grants no reach outside the
26
+ * sandbox (same family as the already-allowed `pipe2`/`eventfd2`).
27
+ */
28
+ import { closeSync, openSync, unlinkSync, writeFileSync } from "node:fs";
29
+ import { tmpdir } from "node:os";
30
+ import { join } from "node:path";
31
+ const BPF_LD = 0x00;
32
+ const BPF_W = 0x00;
33
+ const BPF_ABS = 0x20;
34
+ const BPF_JMP = 0x05;
35
+ const BPF_JEQ = 0x10;
36
+ const BPF_JGE = 0x30;
37
+ const BPF_RET = 0x06;
38
+ const BPF_K = 0x00;
39
+ const AUDIT_ARCH_X86_64 = 0xc000_003e;
40
+ const X32_BIT = 0x4000_0000;
41
+ const RET_ALLOW = 0x7fff_0000;
42
+ const RET_EPERM = 0x0005_0001;
43
+ const RET_ENOSYS = 0x0005_0026;
44
+ const SYSCALL_CLONE3 = 435;
45
+ /** x86_64 syscall numbers the sandbox allows (engine `ALLOW` table). */
46
+ const ALLOWED_SYSCALLS = [
47
+ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28,
48
+ 32, 33, 35, 38, 39, 40, 42, 51, 53, 55, 56, 57, 58, 59, 60, 61, 62, 63, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83,
49
+ 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 102, 104, 107, 108, 109, 110, 111, 112,
50
+ 118, 120, 121, 131, 137, 138, 143, 145, 157, 158, 160, 186, 202, 203, 204, 217, 218, 219, 228, 229, 230, 231, 232, 233,
51
+ 234, 235, 247, 253, 254, 255, 257, 258, 260, 261, 262, 263, 264, 265, 266, 267, 268, 269, 270, 271, 273, 274,
52
+ 276, 280, 281, 282, 283, 284, 285, 286, 287, 288, 289, 290, 291, 292, 293, 294, 295, 296, 302, 306, 315, 318,
53
+ 322, 324, 326, 327, 328, 332, 334, 437, 439, 452,
54
+ ];
55
+ const stmt = (code, k) => ({ code, jt: 0, jf: 0, k });
56
+ const jump = (code, jt, jf, k) => ({ code, jt, jf, k });
57
+ /** The full filter: 4 header/jump slots + 2 per allowed syscall + 3 tail slots. */
58
+ export function buildSeccompFilter() {
59
+ const program = [
60
+ stmt(BPF_LD | BPF_W | BPF_ABS, 4),
61
+ jump(BPF_JMP | BPF_JEQ | BPF_K, 0, 2, AUDIT_ARCH_X86_64),
62
+ stmt(BPF_LD | BPF_W | BPF_ABS, 0),
63
+ jump(BPF_JMP | BPF_JGE | BPF_K, 0, 1, X32_BIT),
64
+ stmt(BPF_RET | BPF_K, RET_EPERM),
65
+ ];
66
+ for (const nr of ALLOWED_SYSCALLS) {
67
+ program.push(jump(BPF_JMP | BPF_JEQ | BPF_K, 0, 1, nr), stmt(BPF_RET | BPF_K, RET_ALLOW));
68
+ }
69
+ program.push(jump(BPF_JMP | BPF_JEQ | BPF_K, 0, 1, SYSCALL_CLONE3), stmt(BPF_RET | BPF_K, RET_ENOSYS));
70
+ program.push(stmt(BPF_RET | BPF_K, RET_EPERM));
71
+ return program;
72
+ }
73
+ /** Serialize to the blob `bwrap --seccomp FD` expects. */
74
+ export function toBlobBytes(program = buildSeccompFilter()) {
75
+ const out = Buffer.alloc(program.length * 8);
76
+ program.forEach((ins, i) => {
77
+ const at = i * 8;
78
+ out.writeUInt16LE(ins.code, at);
79
+ out.writeUInt8(ins.jt, at + 2);
80
+ out.writeUInt8(ins.jf, at + 3);
81
+ out.writeUInt32LE(ins.k >>> 0, at + 4);
82
+ });
83
+ return out;
84
+ }
85
+ export function instructionCount(program = buildSeccompFilter()) {
86
+ return program.length;
87
+ }
88
+ let counter = 0;
89
+ /**
90
+ * Materialize the filter in a private temp file and open it. The caller must
91
+ * call `dispose()` after spawn (the child holds its own dup of the fd).
92
+ */
93
+ export function openSeccompBlob(dir = tmpdir()) {
94
+ counter += 1;
95
+ const path = join(dir, `celestea-seccomp-${process.pid}-${Date.now()}-${counter}.bpf`);
96
+ writeFileSync(path, toBlobBytes(), { mode: 0o600 });
97
+ const fd = openSync(path, "r");
98
+ return {
99
+ fd,
100
+ dispose: () => {
101
+ try {
102
+ closeSync(fd);
103
+ }
104
+ catch {
105
+ /* already closed */
106
+ }
107
+ try {
108
+ unlinkSync(path);
109
+ }
110
+ catch {
111
+ /* already gone */
112
+ }
113
+ },
114
+ };
115
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * `UserspaceSandbox` — the P2b implementation of the `Sandbox` seam, kept as the
3
+ * explicit **fallback** (and as the "no OS isolation at all" reference the
4
+ * tests compare against).
5
+ *
6
+ * It is honest about what it is: no namespaces, no seccomp, no private /tmp, and
7
+ * — W274 §6.2/§6.3 — a `setsid()`ing grandchild or a SIGKILLed Node parent leaks
8
+ * processes that the bwrap path reaps. What it does enforce, and what the tool
9
+ * contract depends on:
10
+ * - a fixed workdir that must resolve inside the configured root;
11
+ * - an allowlisted child environment (never the host environment, never HOME);
12
+ * - a kill deadline that SIGKILLs the whole process group;
13
+ * - per-stream output caps, with the truncation flag reported;
14
+ * - structured failures (`run_shell-sandbox: code=timeout|workdir|arg|config|spawn`).
15
+ *
16
+ * Which provider a deployment actually gets is decided in `provider.ts`; the
17
+ * OS-isolated one is `bwrap.ts`.
18
+ */
19
+ import type { Sandbox, SandboxConfig, SandboxMeta, SandboxRunRequest, SandboxRunResult, SandboxShellLookup, SandboxSpawnRequest, SandboxSpawned } from "@celestea/core";
20
+ import { type SandboxConfigOverrides } from "./config.js";
21
+ import { type SandboxLimits } from "./limits.js";
22
+ import { type HostProbe } from "./probe.js";
23
+ import { type RlimitDescribeOptions, type RlimitVia } from "./rlimit.js";
24
+ /** W6: the same rlimit layer the bwrap path uses (best-effort here). */
25
+ export interface UserspaceSandboxOptions {
26
+ probe?: HostProbe;
27
+ limits?: SandboxLimits;
28
+ /** false disables every rlimit (operator escape hatch). */
29
+ rlimits?: boolean;
30
+ /** W885: the platform/shell view commands run under (defaults to the host). */
31
+ shell?: SandboxShellLookup;
32
+ }
33
+ /** The effective mode every result reports (never inferred by the caller). */
34
+ export declare const USERSPACE_META: SandboxMeta;
35
+ /**
36
+ * F4: the DIAGNOSTIC view (logs / health), never the model-visible contract.
37
+ * `address_space_limited` is how an AS exemption stays observable without
38
+ * adding a field to `SandboxMeta`.
39
+ */
40
+ export interface UserspaceMeta extends SandboxMeta {
41
+ cpu_sec: number;
42
+ rlimit_via: RlimitVia;
43
+ address_space_limited: boolean;
44
+ }
45
+ export declare class UserspaceSandbox implements Sandbox {
46
+ readonly config: SandboxConfig;
47
+ readonly probe: HostProbe;
48
+ readonly limits: SandboxLimits;
49
+ /** W885: injected platform view; `undefined` = the host's own defaults. */
50
+ readonly shell: SandboxShellLookup | undefined;
51
+ private readonly rlimits;
52
+ constructor(config?: SandboxConfig, options?: UserspaceSandboxOptions);
53
+ static fromEnv(env?: NodeJS.ProcessEnv): UserspaceSandbox;
54
+ run(request: SandboxRunRequest): Promise<SandboxRunResult>;
55
+ spawn(request: SandboxSpawnRequest): Promise<SandboxSpawned>;
56
+ /** W6: the base limits with the per-call `cpu_sec` merged in (clamped). */
57
+ private limitsFor;
58
+ /** F4: diagnostic view of what would be enforced (never SandboxMeta). */
59
+ describe(options?: RlimitDescribeOptions): UserspaceMeta;
60
+ private launch;
61
+ }
62
+ /** Factory used by the policy layer as the explicit fallback. */
63
+ export declare function userspaceSandbox(config?: SandboxConfig): UserspaceSandbox;
64
+ /** Factory with explicit knobs (tests / embeddings). */
65
+ export declare function userspaceSandboxWith(overrides: SandboxConfigOverrides): UserspaceSandbox;