@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,294 @@
1
+ /**
2
+ * The engine-injected Python SDK preamble + runner (W255).
3
+ *
4
+ * Byte-for-byte port of `RUN_CODE_SDK` / `RUN_CODE_RUNNER` in
5
+ * `crates/tools/src/run_code.rs` (the raw strings are re-encoded here as
6
+ * escape-safe template literals; `run-code/sdk.test.ts` re-extracts the
7
+ * blocks and diffs them, so drift cannot hide).
8
+ *
9
+ * The program the broker actually runs is `SDK + user code + RUNNER`:
10
+ * - the SDK exposes exactly one object `tools` (four synchronous bridges) and
11
+ * the catchable `ToolCallError`; every bridge call is a one-line JSON
12
+ * request on stdout, answered by the parent on stdin;
13
+ * - the runner calls `main()` (awaiting it when it is a coroutine) and emits
14
+ * `{"__final__": <json>}` — or `{"__error__": "<Type>: <msg>"}` after
15
+ * printing the traceback, exiting 1.
16
+ */
17
+ /** The runner appended after the user program. */
18
+ export const RUN_CODE_RUNNER = `
19
+ # ======================= harness entry point (injected) =======================
20
+ def _plain(v):
21
+ """Unwrap dual-interface results before JSON serialization (dict/list deep)."""
22
+ if isinstance(v, _Value):
23
+ v = v._v
24
+ if isinstance(v, dict):
25
+ return {k: _plain(x) for k, x in v.items()}
26
+ if isinstance(v, (list, tuple)):
27
+ return [_plain(x) for x in v]
28
+ return v
29
+
30
+
31
+ import asyncio as _asyncio
32
+
33
+
34
+ def _celestea_run_main():
35
+ main = globals().get("main")
36
+ if main is None:
37
+ raise RuntimeError(
38
+ "run_code: no 'main' defined - write the program as an async "
39
+ "function body, or as a complete script defining async def main()"
40
+ )
41
+ result = main()
42
+ if _asyncio.iscoroutine(result):
43
+ result = _asyncio.run(result)
44
+ return result
45
+
46
+
47
+ try:
48
+ _final_value = _celestea_run_main()
49
+ print(_json.dumps({"__final__": _plain(_final_value)}, ensure_ascii=False), flush=True)
50
+ except BaseException as _exc: # report ANY failure as __error__
51
+ _tb = _traceback.format_exc()
52
+ for _line in _tb.rstrip("\\n").split("\\n"):
53
+ print(_line, flush=True)
54
+ print(
55
+ _json.dumps({"__error__": f"{type(_exc).__name__}: {_exc}"}, ensure_ascii=False),
56
+ flush=True,
57
+ )
58
+ _sys.exit(1)
59
+ `;
60
+ // W774: the TypeScript SDK/runner live in their own module (see `sdk-ts.ts`) and
61
+ // are re-exported here so a caller can reach both languages from one place.
62
+ export { RUN_CODE_RUNNER_TS, RUN_CODE_SDK_TS } from "./sdk-ts.js";
63
+ import { RUN_CODE_RUNNER_TS, RUN_CODE_SDK_TS } from "./sdk-ts.js";
64
+ /** The SDK preamble (standard library only; zero pip). */
65
+ export const RUN_CODE_SDK = `# =============================================================================
66
+ # celestea run_code SDK (W255 P0) - engine-injected preamble. Standard library
67
+ # only (zero pip). Runs under \`python3 -uB <file>\` inside the Celestea
68
+ # execution sandbox; the parent engine (the "broker") reads our protocol lines
69
+ # from stdout and writes replies to stdin.
70
+ #
71
+ # Protocol (one JSON object per line, no other framing):
72
+ # child -> parent {"id": <int>, "tool": "<name>", "args": {...}}
73
+ # parent -> child {"id": <int>, "ok": true, "value": <json>, "truncated": <bool>}
74
+ # {"id": <int>, "ok": false, "error": "<message>"}
75
+ # child -> parent {"__final__": <json>} (normal end: main()'s return)
76
+ # {"__error__": "<message>"} (uncaught exception)
77
+ #
78
+ # Contract:
79
+ # - Write the program as an \`async def main():\` FUNCTION BODY, or as a
80
+ # complete script that defines main. main()'s return value (lossless JSON)
81
+ # is the final result of the run.
82
+ # - \`tools.<name>(**args)\` (also \`tools.<name>({"path": ...})\`) is a
83
+ # SYNCHRONOUS bridge: every sub-call is dispatched by the parent through
84
+ # its normal tool pipeline (guards, schema checks, limits). A failure
85
+ # raises ToolCallError - catch it and continue.
86
+ # - Only print what the model needs (stdout logs are budgeted at 64KiB);
87
+ # intermediate tool results never enter the conversation automatically.
88
+ # - Sub-calls run SERIALLY in P0: Python-side concurrency (the Promise.all
89
+ # equivalent for independent read-only calls) is a documented follow-up.
90
+ # - Exposed tools: read_file / write_file / list_dir / run_shell. Any other
91
+ # name (including run_code itself) is rejected by the parent.
92
+ # - Do not read sys.stdin and do not add an \`if __name__ == "__main__"\`
93
+ # block: stdin is the protocol reply channel and this harness is the
94
+ # entry point.
95
+ # =============================================================================
96
+ import sys as _sys
97
+ import json as _json
98
+ import traceback as _traceback
99
+
100
+
101
+ class ToolCallError(Exception):
102
+ """A bridged tool call failed (guard denial, unknown tool, dispatch
103
+ error, limit exceeded). Catchable: the program may recover and continue."""
104
+
105
+ def __init__(self, tool_name, message):
106
+ super().__init__(f"tool '{tool_name}' failed: {message}")
107
+ self.tool_name = tool_name
108
+
109
+
110
+ _sub_call_id = 0
111
+
112
+
113
+ def _bridge_call(tool, args):
114
+ global _sub_call_id
115
+ _sub_call_id += 1
116
+ request_id = _sub_call_id
117
+ try:
118
+ payload = _json.dumps(
119
+ {"id": request_id, "tool": tool, "args": args}, ensure_ascii=False
120
+ )
121
+ except (TypeError, ValueError) as exc:
122
+ raise ToolCallError(tool, f"arguments are not JSON-serializable: {exc}")
123
+ print(payload, flush=True)
124
+ line = _sys.stdin.readline()
125
+ if not line:
126
+ raise ToolCallError(tool, "the parent broker closed the reply channel (run aborted)")
127
+ try:
128
+ reply = _json.loads(line)
129
+ except ValueError as exc:
130
+ raise ToolCallError(tool, f"malformed reply from the parent broker: {exc}")
131
+ if reply.get("id") != request_id:
132
+ raise ToolCallError(
133
+ tool, f"reply id mismatch (expected {request_id}, got {reply.get('id')})"
134
+ )
135
+ if not reply.get("ok", False):
136
+ raise ToolCallError(tool, reply.get("error", "unknown error"))
137
+ return _Value(_attr(reply.get("value")))
138
+
139
+
140
+ class _AttrDict(dict):
141
+ """dict whose keys are also attributes: s.stdout == s['stdout'].
142
+ Models write both styles; both must work, including AFTER \`await\`."""
143
+
144
+ def __getattr__(self, name):
145
+ try:
146
+ return self[name]
147
+ except KeyError:
148
+ raise AttributeError(name)
149
+
150
+
151
+ def _attr(v):
152
+ """Recursively convert decoded JSON dicts/lists so every dict in a tool
153
+ result is an _AttrDict (attribute access everywhere, at any depth)."""
154
+ if isinstance(v, dict):
155
+ return _AttrDict({k: _attr(x) for k, x in v.items()})
156
+ if isinstance(v, list):
157
+ return [_attr(x) for x in v]
158
+ return v
159
+
160
+
161
+ class _Value:
162
+ """Dual-interface tool result: usable directly (indexing/iteration/str)
163
+ AND awaitable. Models write both styles — \`await tools.list_dir(...)\`
164
+ and \`tools.list_dir(...)\` must behave identically."""
165
+
166
+ def __init__(self, v):
167
+ self._v = v
168
+
169
+ def __await__(self):
170
+ # Correct awaitable protocol: yield NOTHING and RETURN the value.
171
+ # (Yielding the value itself makes asyncio treat it as an awaitable
172
+ # and dict/list results blow up with "Task got bad yield".)
173
+ if False:
174
+ yield
175
+ return self._v
176
+
177
+ def __iter__(self):
178
+ return iter(self._v)
179
+
180
+ def __getitem__(self, k):
181
+ return self._v[k]
182
+
183
+ def __len__(self):
184
+ return len(self._v)
185
+
186
+ def __bool__(self):
187
+ return bool(self._v)
188
+
189
+ def __str__(self):
190
+ return str(self._v)
191
+
192
+ def __repr__(self):
193
+ return repr(self._v)
194
+
195
+ def __eq__(self, other):
196
+ if isinstance(other, _Value):
197
+ other = other._v
198
+ return self._v == other
199
+
200
+ def get(self, *a, **k):
201
+ return self._v.get(*a, **k) if hasattr(self._v, "get") else None
202
+
203
+ def __getattr__(self, name):
204
+ # Method passthrough (splitlines, keys, ...) so the wrapper behaves
205
+ # exactly like the wrapped value in normal code paths.
206
+ return getattr(self._v, name)
207
+
208
+
209
+ def _merge_args(positional, kwargs, tool):
210
+ if positional:
211
+ if len(positional) == 1 and isinstance(positional[0], dict):
212
+ merged = dict(positional[0])
213
+ merged.update(kwargs)
214
+ return merged
215
+ raise ToolCallError(tool, "expected a single dict argument and/or keyword arguments")
216
+ return dict(kwargs)
217
+
218
+
219
+ class _Tools:
220
+ """The SDK tool surface: only these four tools are bridged to the parent
221
+ (read_file / write_file / list_dir / run_shell)."""
222
+
223
+ def read_file(self, *args, **kwargs):
224
+ return _bridge_call("read_file", _merge_args(args, kwargs, "read_file"))
225
+
226
+ def write_file(self, *args, **kwargs):
227
+ return _bridge_call("write_file", _merge_args(args, kwargs, "write_file"))
228
+
229
+ def list_dir(self, *args, **kwargs):
230
+ return _bridge_call("list_dir", _merge_args(args, kwargs, "list_dir"))
231
+
232
+ def run_shell(self, *args, **kwargs):
233
+ # Returns the full result dict: {"exit_code", "stdout", "stderr", ...}.
234
+ # Fields are readable both ways: s['stdout'] AND s.stdout, before or
235
+ # after \`await\` (results are _AttrDict at any depth).
236
+ return _bridge_call("run_shell", _merge_args(args, kwargs, "run_shell"))
237
+
238
+
239
+ tools = _Tools()
240
+ `;
241
+ /** What `run_code` runs when the call omits `language`. */
242
+ export const DEFAULT_RUN_CODE_LANGUAGE = "typescript";
243
+ /**
244
+ * Assemble the program file for one language: SDK preamble + user code + runner.
245
+ *
246
+ * When the first non-blank line of the user code is indented it is treated as a
247
+ * **function body** and wrapped (`async def main():` / `async function main()`);
248
+ * otherwise it must be a complete script defining `main` itself (legacy
249
+ * `assemble_program`). The Python path is byte-for-byte what it always was; the
250
+ * TypeScript path lays the file out the same way and deliberately does NOT
251
+ * re-indent a wrapped body — JavaScript does not need it, and re-indenting would
252
+ * rewrite the contents of a template literal.
253
+ */
254
+ export function assembleProgram(userCode, language) {
255
+ const body = firstNonblankLineIndented(userCode);
256
+ if (language === "python") {
257
+ const parts = [RUN_CODE_SDK, "\n\n# ========================== user program ==========================\n"];
258
+ parts.push(body ? wrapBody(userCode) : terminate(userCode));
259
+ parts.push(RUN_CODE_RUNNER);
260
+ return parts.join("");
261
+ }
262
+ const parts = [RUN_CODE_SDK_TS, "\n\n// ========================== user program ==========================\n"];
263
+ parts.push(body ? wrapBodyTs(userCode) : terminate(userCode));
264
+ parts.push(RUN_CODE_RUNNER_TS);
265
+ return parts.join("");
266
+ }
267
+ /** `async function main() {` + the user body verbatim + `}`. */
268
+ function wrapBodyTs(userCode) {
269
+ return `async function main() {\n${terminate(userCode)}}\n`;
270
+ }
271
+ /** True when the first non-blank line starts with whitespace (body form). */
272
+ export function firstNonblankLineIndented(code) {
273
+ const line = splitProgramLines(code).find((candidate) => candidate.trim() !== "");
274
+ return line !== undefined && (line.startsWith(" ") || line.startsWith("\t"));
275
+ }
276
+ /** `async def main():` + the user body, indented one level (blank lines kept). */
277
+ function wrapBody(userCode) {
278
+ const out = ["async def main():\n"];
279
+ for (const line of splitProgramLines(userCode)) {
280
+ out.push(line.trim() === "" ? "\n" : ` ${line}\n`);
281
+ }
282
+ out.push("\n");
283
+ return out.join("");
284
+ }
285
+ function terminate(userCode) {
286
+ return userCode.endsWith("\n") ? userCode : `${userCode}\n`;
287
+ }
288
+ /** Split on `\n`, drop a trailing `\r`, no final empty line. */
289
+ function splitProgramLines(code) {
290
+ const lines = code.split("\n");
291
+ if (lines.length > 0 && lines[lines.length - 1] === "")
292
+ lines.pop();
293
+ return lines.map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line));
294
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Small async helpers shared by the sandbox and the process registry: a
3
+ * timeout race (without leaking timers) and a bounded poll.
4
+ */
5
+ /** Sentinel returned by [withTimeout] when the deadline won the race. */
6
+ export declare const TIMED_OUT: unique symbol;
7
+ export type TimeoutResult<T> = T | typeof TIMED_OUT;
8
+ /** Race a promise against a deadline; the timer is always cleared. */
9
+ export declare function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<TimeoutResult<T>>;
10
+ export declare function delay(ms: number): Promise<void>;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Small async helpers shared by the sandbox and the process registry: a
3
+ * timeout race (without leaking timers) and a bounded poll.
4
+ */
5
+ /** Sentinel returned by [withTimeout] when the deadline won the race. */
6
+ export const TIMED_OUT = Symbol("timed-out");
7
+ /** Race a promise against a deadline; the timer is always cleared. */
8
+ export async function withTimeout(promise, timeoutMs) {
9
+ let timer;
10
+ const deadline = new Promise((resolve) => {
11
+ timer = setTimeout(() => resolve(TIMED_OUT), timeoutMs);
12
+ });
13
+ try {
14
+ return await Promise.race([promise, deadline]);
15
+ }
16
+ finally {
17
+ if (timer !== undefined)
18
+ clearTimeout(timer);
19
+ }
20
+ }
21
+ export function delay(ms) {
22
+ return new Promise((resolve) => {
23
+ const timer = setTimeout(resolve, ms);
24
+ timer.unref?.();
25
+ });
26
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The bubblewrap argv builder — **the whole point of P2c is in this order**.
3
+ *
4
+ * W274 §2 diagnosed the engine's "bubblewrap is unusable" verdict as a pure
5
+ * argument-order bug: bwrap stacks mounts in argv order, so
6
+ * `--dev /dev --ro-bind / /` builds a private devtmpfs and then buries it under
7
+ * a read-only bind of the *host* root — `/dev/zero` becomes `EACCES` and the
8
+ * startup probe wrongly reports "no bwrap" (production then silently ran on the
9
+ * userspace path for months).
10
+ *
11
+ * --unshare-all → --ro-bind / / → --dev /dev → --proc /proc
12
+ *
13
+ * is the only shape that passes the device smoke test, so it is asserted by a
14
+ * regression test (`bwrap.test.ts`) rather than trusted to reviewers.
15
+ */
16
+ import type { SandboxMeta } from "@celestea/core";
17
+ import { type ShellResolveInput } from "../platform/exec.js";
18
+ /** Provider name reported in every `SandboxMeta` from this layer. */
19
+ export declare const BWRAP_PROVIDER = "bwrap";
20
+ /** Child fd of the seccomp blob: the first fd after stdin/stdout/stderr. */
21
+ export declare const SECCOMP_FD = 3;
22
+ export interface BwrapOptions {
23
+ /** true → `--share-net` (host network kept). Default: isolated. */
24
+ readonly shareNet: boolean;
25
+ /** true → host `/tmp` bind-mounted. Default: private tmpfs. */
26
+ readonly shareTmp: boolean;
27
+ /** true → install the TS-built cBPF whitelist via `--seccomp`. */
28
+ readonly seccomp: boolean;
29
+ /** Absolute host directories masked with an empty tmpfs (opt-in). */
30
+ readonly maskDirs: readonly string[];
31
+ /** W9: bind the workspace rw (default true; false = a read-only permission). */
32
+ readonly workspaceWritable?: boolean;
33
+ /** W9: extra absolute dirs to bind rw (permission tool roots / write_roots). */
34
+ readonly writeRoots?: readonly string[];
35
+ /**
36
+ * W880: the `run_code` program directory. It now lives under CELESTEA_HOME
37
+ * (outside the workspace), so it must be bound into the namespace — AFTER the
38
+ * private `/tmp` tmpfs, or a CELESTEA_HOME under `/tmp` would stay hidden.
39
+ */
40
+ readonly programDir?: string;
41
+ }
42
+ /** Contract default: network isolated, `/tmp` private, no seccomp, no masks. */
43
+ export declare const DEFAULT_BWRAP_OPTIONS: BwrapOptions;
44
+ /**
45
+ * Mount/namespace flags, in the one order that works. `workdir === null` is the
46
+ * probe shape (no bind, no chdir) so the smoke test exercises exactly the mount
47
+ * sequence production uses.
48
+ */
49
+ export declare function buildBwrapArgv(workdir: string | null, options: BwrapOptions): string[];
50
+ /**
51
+ * The full `bwrap … -- <shell> <args…> <command>` argv (workdir bind included).
52
+ *
53
+ * W885: the shell is RESOLVED, not the hardcoded `/bin/sh` — bwrap only exists
54
+ * on Linux today, so the POSIX answer is unchanged (this slice changes no Linux
55
+ * byte), but the gitbash/pwsh/cmd ladder is what a future Windows provider would
56
+ * consult. A host with no usable shell fails closed through [resolveShell].
57
+ */
58
+ export declare function buildBwrapCommand(workdir: string, options: BwrapOptions, command: string, input?: ShellResolveInput): string[];
59
+ /** The isolation actually in force — reported, never inferred by the caller. */
60
+ export declare function bwrapMeta(options: BwrapOptions): SandboxMeta;
61
+ /** Human-readable label for spawn failures (never contains the command body). */
62
+ export declare function bwrapLabel(options: BwrapOptions): string;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * The bubblewrap argv builder — **the whole point of P2c is in this order**.
3
+ *
4
+ * W274 §2 diagnosed the engine's "bubblewrap is unusable" verdict as a pure
5
+ * argument-order bug: bwrap stacks mounts in argv order, so
6
+ * `--dev /dev --ro-bind / /` builds a private devtmpfs and then buries it under
7
+ * a read-only bind of the *host* root — `/dev/zero` becomes `EACCES` and the
8
+ * startup probe wrongly reports "no bwrap" (production then silently ran on the
9
+ * userspace path for months).
10
+ *
11
+ * --unshare-all → --ro-bind / / → --dev /dev → --proc /proc
12
+ *
13
+ * is the only shape that passes the device smoke test, so it is asserted by a
14
+ * regression test (`bwrap.test.ts`) rather than trusted to reviewers.
15
+ */
16
+ import { resolveShell } from "../platform/exec.js";
17
+ /** Provider name reported in every `SandboxMeta` from this layer. */
18
+ export const BWRAP_PROVIDER = "bwrap";
19
+ /** Child fd of the seccomp blob: the first fd after stdin/stdout/stderr. */
20
+ export const SECCOMP_FD = 3;
21
+ /** Contract default: network isolated, `/tmp` private, no seccomp, no masks. */
22
+ export const DEFAULT_BWRAP_OPTIONS = {
23
+ shareNet: false,
24
+ shareTmp: false,
25
+ seccomp: false,
26
+ maskDirs: [],
27
+ workspaceWritable: true,
28
+ writeRoots: [],
29
+ };
30
+ // W885: no module-level SHELL constant — `resolveShell` owns the choice (the
31
+ // POSIX answer stays the literal `/bin/sh`, so the argv bytes do not move).
32
+ /**
33
+ * Mount/namespace flags, in the one order that works. `workdir === null` is the
34
+ * probe shape (no bind, no chdir) so the smoke test exercises exactly the mount
35
+ * sequence production uses.
36
+ */
37
+ export function buildBwrapArgv(workdir, options) {
38
+ const argv = ["--unshare-all", "--die-with-parent"];
39
+ // W9: writable mounts. The workspace is bound rw only while the permission
40
+ // allows it (read-only omits it, leaving the `--ro-bind / /` view); the
41
+ // permission's extra roots (deployment tool roots / write_roots) are bound too.
42
+ const rwRoots = [];
43
+ if (workdir !== null && options.workspaceWritable !== false)
44
+ rwRoots.push(workdir);
45
+ for (const root of options.writeRoots ?? [])
46
+ if (!rwRoots.includes(root))
47
+ rwRoots.push(root);
48
+ /**
49
+ * W864: a write root of `/` (a full-access permission's `allPaths`) makes the
50
+ * WHOLE host root writable, and it replaces `--ro-bind / /` instead of being
51
+ * stacked after `--dev`/`--proc`: bwrap applies mounts in argv order, so a
52
+ * trailing `--bind / /` would bury the private devtmpfs/procfs under the
53
+ * host's — the W274 regression in reverse. Emitting it first keeps
54
+ * devtmpfs/procfs on top and still yields exactly one `--bind / /`.
55
+ */
56
+ const wholeRootWritable = rwRoots.includes("/");
57
+ // ORDER IS SECURITY SEMANTICS — do not reorder (W274 §2):
58
+ // the host root goes on first, the private devtmpfs/procfs on top of it.
59
+ argv.push(wholeRootWritable ? "--bind" : "--ro-bind", "/", "/");
60
+ argv.push("--dev", "/dev");
61
+ argv.push("--proc", "/proc");
62
+ if (options.shareNet)
63
+ argv.push("--share-net");
64
+ if (options.shareTmp)
65
+ argv.push("--bind", "/tmp", "/tmp");
66
+ else
67
+ argv.push("--tmpfs", "/tmp");
68
+ for (const dir of options.maskDirs)
69
+ argv.push("--tmpfs", dir);
70
+ for (const root of rwRoots)
71
+ if (root !== "/")
72
+ argv.push("--bind", root, root);
73
+ // W880: re-expose the run_code program dir on top of the tmpfs/mask layers.
74
+ const programDir = options.programDir;
75
+ if (programDir !== undefined && programDir !== "" && !rwRoots.includes(programDir)) {
76
+ argv.push("--bind", programDir, programDir);
77
+ }
78
+ if (workdir !== null)
79
+ argv.push("--chdir", workdir);
80
+ if (options.seccomp)
81
+ argv.push("--seccomp", String(SECCOMP_FD));
82
+ return argv;
83
+ }
84
+ /**
85
+ * The full `bwrap … -- <shell> <args…> <command>` argv (workdir bind included).
86
+ *
87
+ * W885: the shell is RESOLVED, not the hardcoded `/bin/sh` — bwrap only exists
88
+ * on Linux today, so the POSIX answer is unchanged (this slice changes no Linux
89
+ * byte), but the gitbash/pwsh/cmd ladder is what a future Windows provider would
90
+ * consult. A host with no usable shell fails closed through [resolveShell].
91
+ */
92
+ export function buildBwrapCommand(workdir, options, command, input = {}) {
93
+ const shell = resolveShell(command, input);
94
+ return [...buildBwrapArgv(workdir, options), "--", shell.path, ...shell.argv];
95
+ }
96
+ /** The isolation actually in force — reported, never inferred by the caller. */
97
+ export function bwrapMeta(options) {
98
+ return {
99
+ provider: BWRAP_PROVIDER,
100
+ net_isolated: !options.shareNet,
101
+ tmp_private: !options.shareTmp,
102
+ seccomp: options.seccomp,
103
+ };
104
+ }
105
+ /** Human-readable label for spawn failures (never contains the command body). */
106
+ export function bwrapLabel(options) {
107
+ const bits = [options.shareNet ? "share-net" : "net-isolated", options.shareTmp ? "host-tmp" : "private-tmp"];
108
+ if (options.seccomp)
109
+ bits.push("seccomp");
110
+ if (options.maskDirs.length > 0)
111
+ bits.push(`masked=${options.maskDirs.length}`);
112
+ return `bwrap[${bits.join(",")}]`;
113
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * `BwrapSandbox` — the OS-isolated provider (P2c), behind the same `Sandbox`
3
+ * seam as the userspace one, so `run_shell` changes no line.
4
+ *
5
+ * Layer order (outermost → innermost), mirroring W274 §8.1:
6
+ *
7
+ * node spawn(detached) → own process group (group kill)
8
+ * prlimit | /bin/sh 'ulimit …; exec' → RLIMIT_CPU/AS/NPROC/FSIZE/…
9
+ * bwrap --unshare-all --die-with-parent → user/mount/pid/net/ipc/uts ns,
10
+ * --ro-bind / / --dev /dev --proc /proc read-only root, private /dev
11
+ * --tmpfs /tmp | --share-net | --seccomp FD
12
+ * /bin/sh -c <command>
13
+ *
14
+ * Two properties are non-negotiable and tested:
15
+ * - **argv order** (`bwrap-argv.ts`) — the W274 device regression;
16
+ * - **`--die-with-parent`** — orphan reaping when the Node parent is SIGKILLed
17
+ * (W274 §6.3: 3 orphans → 0), which the userspace path cannot do at all.
18
+ *
19
+ * If bwrap turns out unusable at call time the run is **refused** with a
20
+ * structured `SandboxError`; degrading to userspace is the *policy* layer's
21
+ * explicit decision (`provider.ts`), never this provider's silent fallback.
22
+ */
23
+ import type { Sandbox, SandboxConfig, SandboxRunRequest, SandboxRunResult, SandboxShellLookup, SandboxSpawnRequest, SandboxSpawned } from "@celestea/core";
24
+ import { type BwrapOptions } from "./bwrap-argv.js";
25
+ import { type SandboxLimits } from "./limits.js";
26
+ import { type HostProbe } from "./probe.js";
27
+ import { type RlimitDescribeOptions, type RlimitVia } from "./rlimit.js";
28
+ /** `SandboxMeta` plus the observability the 4-field contract cannot carry. */
29
+ export interface BwrapMeta {
30
+ provider: string;
31
+ net_isolated: boolean;
32
+ tmp_private: boolean;
33
+ seccomp: boolean;
34
+ /** true: the child saw a read-only host root. */
35
+ readonly_root: boolean;
36
+ /**
37
+ * F4: true when RLIMIT_AS is in force. false = this view describes a call
38
+ * that would be exempt (or every rlimit is off). Diagnostics only — the
39
+ * model-visible SandboxMeta deliberately does NOT carry it.
40
+ */
41
+ address_space_limited: boolean;
42
+ /** W6: effective `RLIMIT_CPU` for this run (seconds). */
43
+ cpu_sec: number;
44
+ /** Which mechanism enforced the rlimits. */
45
+ rlimit_via: RlimitVia;
46
+ /** Effective `RLIMIT_NPROC` (derived from the UID thread count). */
47
+ nproc: number;
48
+ /** Threads owned by this uid host-wide when the probe ran (null = unknown). */
49
+ uid_threads: number | null;
50
+ /** `bwrap --version` output, for post-mortems. */
51
+ bwrap_version: string | null;
52
+ }
53
+ export interface BwrapSandboxOptions {
54
+ probe?: HostProbe;
55
+ limits?: SandboxLimits;
56
+ run?: Partial<BwrapOptions>;
57
+ /** false disables every rlimit (operator escape hatch). */
58
+ rlimits?: boolean;
59
+ /** Directory for the generated seccomp blob (tests pin it). */
60
+ seccompDir?: string;
61
+ /** W885: the platform/shell view commands run under (defaults to the host). */
62
+ shell?: SandboxShellLookup;
63
+ }
64
+ export declare class BwrapSandbox implements Sandbox {
65
+ readonly config: SandboxConfig;
66
+ readonly probe: HostProbe;
67
+ readonly limits: SandboxLimits;
68
+ readonly options: BwrapOptions;
69
+ /** W885: injected platform view; `undefined` = the host's own defaults. */
70
+ readonly shell: SandboxShellLookup | undefined;
71
+ private readonly rlimits;
72
+ private readonly seccompDir;
73
+ constructor(config: SandboxConfig, options?: BwrapSandboxOptions);
74
+ static fromEnv(env?: NodeJS.ProcessEnv): BwrapSandbox;
75
+ run(request: SandboxRunRequest): Promise<SandboxRunResult>;
76
+ spawn(request: SandboxSpawnRequest): Promise<SandboxSpawned>;
77
+ /** W6: the base limits with this call's `cpu_sec` merged in (clamped). */
78
+ private limitsFor;
79
+ /** Isolation actually in force, without running anything (logs / health). */
80
+ describe(options?: RlimitDescribeOptions): BwrapMeta;
81
+ private launch;
82
+ private assertUsable;
83
+ }
84
+ /**
85
+ * Which rlimit mechanism the probe leaves available.
86
+ *
87
+ * F4: the implementation moved to `rlimit.ts` (where the limit plan lives);
88
+ * this re-export keeps the public path `@celestea/tools` -> `bwrap.js` stable.
89
+ */
90
+ export { rlimitVia } from "./rlimit.js";
91
+ /** Factory with explicit knobs (tests / embeddings). */
92
+ export declare function bwrapSandboxWith(config: SandboxConfig, options?: BwrapSandboxOptions): BwrapSandbox;
93
+ /** Env-tuned default (`selectSandbox` builds this only when bwrap is usable). */
94
+ export declare function bwrapSandbox(config?: SandboxConfig): BwrapSandbox;