@ggui-ai/sandbox 0.1.0-rc.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.
package/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for describing the origin of the Work and
141
+ reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Support. While redistributing the Work or
166
+ Derivative Works thereof, You may choose to offer, and charge a
167
+ fee for, acceptance of support, warranty, indemnity, or other
168
+ liability obligations and/or rights consistent with this License.
169
+ However, in accepting such obligations, You may act only on Your
170
+ own behalf and on Your sole responsibility, not on behalf of any
171
+ other Contributor, and only if You agree to indemnify, defend,
172
+ and hold each Contributor harmless for any liability incurred by,
173
+ or claims asserted against, such Contributor by reason of your
174
+ accepting any such warranty or support.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2026 Loqu, Inc. (Guuey)
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
200
+ implied. See the License for the specific language governing
201
+ permissions and limitations under the License.
package/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # @ggui-ai/sandbox
2
+
3
+ Bounded process-isolation runner for untrusted Node subprocesses.
4
+
5
+ ## Usage
6
+
7
+ ```ts
8
+ import { runSandboxed } from "@ggui-ai/sandbox";
9
+
10
+ const result = await runSandboxed({
11
+ command: process.execPath, // node
12
+ args: ["./build-probe.js"],
13
+ timeoutMs: 15_000,
14
+ env: { NODE_ENV: "production" }, // parent process.env NEVER merged
15
+ maxStdoutBytes: 4 * 1024 * 1024, // 4 MiB cap
16
+ nodeHeapMb: 256,
17
+ });
18
+
19
+ if (result.outcome === "exit" && result.exitCode === 0) {
20
+ // result.stdout + result.stderr captured, child gone, tmpdir cleaned.
21
+ }
22
+ ```
23
+
24
+ ## What it actually enforces (MVP)
25
+
26
+ One subprocess, one bounded run, one pinned outcome. Portably, from pure Node user-space:
27
+
28
+ - **Process boundary.** `spawn` with `shell: false`, `detached: false`, `windowsHide: true`, `stdio: ['pipe','pipe','pipe']`. Child cannot attach to the parent's controlling terminal, cannot fork into a new process group, cannot inherit open fds.
29
+ - **Working-directory isolation.** Caller supplies an absolute `cwd`, or the sandbox mints an owned `mkdtempSync` dir and removes it at the end of the run. Relative `cwd` values are rejected at validation — no silent resolve against the parent's CWD.
30
+ - **Environment allowlist.** The parent's `process.env` is **never** merged in. Only the caller's `env` keys plus a minimal bootstrap (`PATH`, `HOME`, `TMPDIR` when present on the parent) reach the child. Callers that want to forward extra vars copy them in explicitly.
31
+ - **Wall-clock timeout.** `timeoutMs` is required. On overrun the sandbox sends `SIGTERM`, waits `gracePeriodMs`, then escalates to `SIGKILL`. Outcome is `'timeout'`.
32
+ - **Output byte caps.** `stdout` and `stderr` are captured up to `maxStdoutBytes` / `maxStderrBytes` (defaults: 8 MiB / 1 MiB). Exceeding the cap terminates the child with outcome `'overflow-stdout'` / `'overflow-stderr'` and the captured output is truncated to exactly the cap.
33
+ - **No stdin leakage.** Absent `stdin` → closed immediately. Present → written then closed. Parent stdin is never forwarded.
34
+ - **V8 heap cap (Node children only).** When `nodeHeapMb` is set AND the command basename is `node` / equals `process.execPath`, the sandbox prepends `--max-old-space-size=<mb>` to `NODE_OPTIONS`. Caps V8's old-generation heap only.
35
+
36
+ Every decision path funnels through a single `finish()` closure — no fd, timer, or tmpdir leaks regardless of which termination path fires first.
37
+
38
+ ## What it does NOT enforce
39
+
40
+ All of the following need OS-level primitives (network namespaces, seccomp, cgroups, chroot/Landlock) that aren't portable from Node user-space. Consumers who need these run the sandbox under a stronger layer (Docker `network:none`, gVisor, firecracker, systemd with the right ambient config):
41
+
42
+ - **Network egress blocking.** Not enforced. The child has the same network access as the parent.
43
+ - **Filesystem read boundaries.** Not enforced. The child runs as the parent's UID/GID and can read anything the parent can. Relative paths resolve under `cwd`; absolute paths and `..` traversal remain reachable.
44
+ - **CPU share / scheduling cap.** Not enforced. Node has no portable rlimit surface for CPU time. Total RSS is not capped either — only V8's old-gen heap (and only when the child is Node).
45
+ - **Syscall filtering.** Not enforced. No seccomp, no LSM hooks. The child can make any syscall the parent could.
46
+ - **Fork-bomb / grandchild containment.** Not enforced. The sandbox kills only its direct child; descendants that reparent (the classic daemon / double-fork trick) survive.
47
+
48
+ If the threat model requires any of these guarantees, **do not rely on this package alone**. The sandbox is the portable MVP; stacking it under Docker / gVisor / firecracker is the production posture, not a "later" improvement.
49
+
50
+ ## API
51
+
52
+ ```ts
53
+ runSandboxed(opts: SandboxOptions): Promise<SandboxResult>
54
+ ```
55
+
56
+ ### `SandboxOptions`
57
+
58
+ | Field | Required | Default | Notes |
59
+ | ---------------- | -------- | ------------ | --------------------------------------------------------- |
60
+ | `command` | ✅ | — | Absolute path to the executable. No shell interpretation. |
61
+ | `args` | ✅ | — | Forwarded verbatim. Pass `[]` for no args. |
62
+ | `cwd` | ❌ | owned tmpdir | Must be absolute when supplied. |
63
+ | `env` | ❌ | `{}` | Allowlist. Parent's `process.env` is NEVER merged. |
64
+ | `timeoutMs` | ✅ | — | Positive finite integer. No "infinity". |
65
+ | `shutdownSignal` | ❌ | `'SIGTERM'` | Soft-kill signal; SIGKILL escalation is unconditional. |
66
+ | `gracePeriodMs` | ❌ | `2000` | Must be `< timeoutMs`. |
67
+ | `stdin` | ❌ | closed | `string \| Uint8Array`. Absent = immediate EOF. |
68
+ | `maxStdoutBytes` | ❌ | 8 MiB | Positive integer. Overflow → `'overflow-stdout'`. |
69
+ | `maxStderrBytes` | ❌ | 1 MiB | Positive integer. Overflow → `'overflow-stderr'`. |
70
+ | `nodeHeapMb` | ❌ | — | Node children only. Sets `--max-old-space-size`. |
71
+ | `signal` | ❌ | — | External `AbortSignal` → outcome `'canceled'`. |
72
+ | `spawner` | ❌ | real `spawn` | Test seam; production leaves unset. |
73
+
74
+ ### `SandboxResult`
75
+
76
+ ```ts
77
+ interface SandboxResult {
78
+ outcome: "exit" | "timeout" | "canceled" | "overflow-stdout" | "overflow-stderr" | "spawn-error";
79
+ exitCode: number | null; // present only on 'exit'
80
+ signal: NodeJS.Signals | null; // present only on 'exit'
81
+ stdout: string; // UTF-8, truncated to maxStdoutBytes
82
+ stderr: string; // UTF-8, truncated to maxStderrBytes
83
+ durationMs: number;
84
+ stdoutTruncated: boolean;
85
+ stderrTruncated: boolean;
86
+ cwd: string; // absolute
87
+ cwdOwnedBySandbox: boolean; // true → already cleaned up
88
+ nodeHeapMbApplied: boolean;
89
+ errorMessage: string; // non-empty only on 'spawn-error'
90
+ }
91
+ ```
92
+
93
+ ## Typical use
94
+
95
+ The UI-gen render-probe path uses this package so LLM-generated TSX never executes in the parent Node process — each render spawns a subprocess through `runSandboxed` with a short timeout, a bounded stdout cap, a V8 heap cap, a sandbox-owned tmpdir cwd, and an env allowlist forwarding only `NODE_ENV`.
96
+
97
+ ## License
98
+
99
+ Apache 2.0
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `@ggui-ai/sandbox` — bounded process-isolation runner for
3
+ * OSS UI-gen workloads.
4
+ *
5
+ * Public surface:
6
+ *
7
+ * - {@link runSandboxed} — one-shot runner. Spawns a subprocess,
8
+ * enforces timeout + output caps + env allowlist + cwd
9
+ * isolation, returns a {@link SandboxResult} with captured
10
+ * output + terminal outcome.
11
+ *
12
+ * - {@link SandboxOptions} / {@link SandboxResult} /
13
+ * {@link SandboxOutcome} — pinned types.
14
+ *
15
+ * For the honest security boundary + what this MVP does NOT enforce,
16
+ * see the JSDoc header on `./types.ts` and the package README.
17
+ */
18
+ export { runSandboxed } from './run.js';
19
+ export type { SandboxOptions, SandboxOutcome, SandboxResult, } from './types.js';
20
+ export type { Spawner, SpawnerOptions } from './spawner.js';
21
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AACxC,YAAY,EACV,cAAc,EACd,cAAc,EACd,aAAa,GACd,MAAM,YAAY,CAAC;AACpB,YAAY,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `@ggui-ai/sandbox` — bounded process-isolation runner for
3
+ * OSS UI-gen workloads.
4
+ *
5
+ * Public surface:
6
+ *
7
+ * - {@link runSandboxed} — one-shot runner. Spawns a subprocess,
8
+ * enforces timeout + output caps + env allowlist + cwd
9
+ * isolation, returns a {@link SandboxResult} with captured
10
+ * output + terminal outcome.
11
+ *
12
+ * - {@link SandboxOptions} / {@link SandboxResult} /
13
+ * {@link SandboxOutcome} — pinned types.
14
+ *
15
+ * For the honest security boundary + what this MVP does NOT enforce,
16
+ * see the JSDoc header on `./types.ts` and the package README.
17
+ */
18
+ export { runSandboxed } from './run.js';
package/dist/run.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ import type { SandboxOptions, SandboxResult } from './types.js';
2
+ /**
3
+ * Run a command in a bounded subprocess. See {@link SandboxOptions}
4
+ * and the `./types.ts` header for the full semantics + honest-boundary
5
+ * lock.
6
+ */
7
+ export declare function runSandboxed(opts: SandboxOptions): Promise<SandboxResult>;
8
+ //# sourceMappingURL=run.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../src/run.ts"],"names":[],"mappings":"AAgCA,OAAO,KAAK,EACV,cAAc,EAEd,aAAa,EACd,MAAM,YAAY,CAAC;AAepB;;;;GAIG;AACH,wBAAsB,YAAY,CAChC,IAAI,EAAE,cAAc,GACnB,OAAO,CAAC,aAAa,CAAC,CA2PxB"}
package/dist/run.js ADDED
@@ -0,0 +1,428 @@
1
+ /**
2
+ * `runSandboxed` — bounded subprocess runner.
3
+ *
4
+ * Single code path per outcome. The state machine is intentionally
5
+ * small:
6
+ *
7
+ * starting → (spawn error) → 'spawn-error'
8
+ * → (child exits) → 'exit'
9
+ * → (timeout fires) → kill → 'timeout'
10
+ * → (signal aborts) → kill → 'canceled'
11
+ * → (stdout overflow) → kill → 'overflow-stdout'
12
+ * → (stderr overflow) → kill → 'overflow-stderr'
13
+ *
14
+ * Once an outcome is decided, the runner:
15
+ *
16
+ * 1. stops accepting further outcome transitions (`outcomeDecided`),
17
+ * 2. if the child is still alive, sends `shutdownSignal`,
18
+ * 3. after `gracePeriodMs` escalates to `SIGKILL`,
19
+ * 4. waits for the 'exit' event to flush remaining stdio,
20
+ * 5. decodes captured buffers as UTF-8, truncated to their caps,
21
+ * 6. cleans up the owned tmpdir if one was created,
22
+ * 7. resolves the promise with a full `SandboxResult`.
23
+ *
24
+ * The runner resolves exactly once. All cleanup paths funnel through
25
+ * a single `finish()` closure so we can't leak a file descriptor,
26
+ * timer, or tmpdir no matter which path fired first.
27
+ */
28
+ import { spawn } from 'node:child_process';
29
+ import { mkdtempSync, rmSync } from 'node:fs';
30
+ import { tmpdir } from 'node:os';
31
+ import { basename, isAbsolute, join } from 'node:path';
32
+ /**
33
+ * Default wall-clock grace between `shutdownSignal` and `SIGKILL`.
34
+ * 2 seconds covers typical Node child shutdown; a hostile / stuck
35
+ * child hits the SIGKILL escalation deterministically.
36
+ */
37
+ const DEFAULT_GRACE_PERIOD_MS = 2_000;
38
+ /** Default stdout budget — 8 MiB. */
39
+ const DEFAULT_MAX_STDOUT_BYTES = 8 * 1024 * 1024;
40
+ /** Default stderr budget — 1 MiB. */
41
+ const DEFAULT_MAX_STDERR_BYTES = 1 * 1024 * 1024;
42
+ /**
43
+ * Run a command in a bounded subprocess. See {@link SandboxOptions}
44
+ * and the `./types.ts` header for the full semantics + honest-boundary
45
+ * lock.
46
+ */
47
+ export async function runSandboxed(opts) {
48
+ // ── 1. Validate inputs ───────────────────────────────────────────
49
+ validateOptions(opts);
50
+ const gracePeriodMs = opts.gracePeriodMs ?? DEFAULT_GRACE_PERIOD_MS;
51
+ const maxStdoutBytes = opts.maxStdoutBytes ?? DEFAULT_MAX_STDOUT_BYTES;
52
+ const maxStderrBytes = opts.maxStderrBytes ?? DEFAULT_MAX_STDERR_BYTES;
53
+ const shutdownSignal = opts.shutdownSignal ?? 'SIGTERM';
54
+ if (gracePeriodMs >= opts.timeoutMs) {
55
+ throw new RangeError(`runSandboxed: gracePeriodMs (${gracePeriodMs}) must be < timeoutMs (${opts.timeoutMs}) so SIGTERM has time to take effect before the sandbox considers the child stuck.`);
56
+ }
57
+ const start = Date.now();
58
+ // ── 2. Resolve cwd (owned tmpdir or caller-supplied absolute) ────
59
+ let cwd;
60
+ let cwdOwnedBySandbox = false;
61
+ if (opts.cwd !== undefined) {
62
+ cwd = opts.cwd;
63
+ }
64
+ else {
65
+ cwd = mkdtempSync(join(tmpdir(), 'ggui-sandbox-'));
66
+ cwdOwnedBySandbox = true;
67
+ }
68
+ // ── 3. Resolve env + detect Node child for heap cap ──────────────
69
+ const { env, nodeHeapMbApplied } = resolveEnv(opts);
70
+ // ── 4. Pre-spawn guard: caller already aborted ───────────────────
71
+ if (opts.signal?.aborted) {
72
+ cleanupCwd(cwd, cwdOwnedBySandbox);
73
+ return {
74
+ outcome: 'canceled',
75
+ exitCode: null,
76
+ signal: null,
77
+ stdout: '',
78
+ stderr: '',
79
+ durationMs: 0,
80
+ stdoutTruncated: false,
81
+ stderrTruncated: false,
82
+ cwd,
83
+ cwdOwnedBySandbox,
84
+ nodeHeapMbApplied,
85
+ errorMessage: '',
86
+ };
87
+ }
88
+ // ── 5. Spawn the child ──────────────────────────────────────────
89
+ const spawner = opts.spawner ??
90
+ ((cmd, args, spawnOpts) => spawn(cmd, args, {
91
+ cwd: spawnOpts.cwd,
92
+ env: spawnOpts.env,
93
+ stdio: spawnOpts.stdio,
94
+ shell: spawnOpts.shell,
95
+ detached: spawnOpts.detached,
96
+ windowsHide: spawnOpts.windowsHide,
97
+ }));
98
+ let child;
99
+ try {
100
+ child = spawner(opts.command, opts.args, {
101
+ cwd,
102
+ env,
103
+ stdio: ['pipe', 'pipe', 'pipe'],
104
+ shell: false,
105
+ detached: false,
106
+ windowsHide: true,
107
+ });
108
+ }
109
+ catch (err) {
110
+ cleanupCwd(cwd, cwdOwnedBySandbox);
111
+ return {
112
+ outcome: 'spawn-error',
113
+ exitCode: null,
114
+ signal: null,
115
+ stdout: '',
116
+ stderr: '',
117
+ durationMs: Date.now() - start,
118
+ stdoutTruncated: false,
119
+ stderrTruncated: false,
120
+ cwd,
121
+ cwdOwnedBySandbox,
122
+ nodeHeapMbApplied,
123
+ errorMessage: err instanceof Error ? err.message : String(err),
124
+ };
125
+ }
126
+ // ── 6. Supervise the child ──────────────────────────────────────
127
+ return new Promise((resolve) => {
128
+ const stdoutChunks = [];
129
+ const stderrChunks = [];
130
+ let stdoutBytes = 0;
131
+ let stderrBytes = 0;
132
+ let stdoutTruncated = false;
133
+ let stderrTruncated = false;
134
+ let outcome = 'exit'; // Overwritten on every
135
+ let outcomeDecided = false; // decision path
136
+ let errorMessage = '';
137
+ let killTimer = null;
138
+ let onAbort = null;
139
+ // Wall-clock timer — runs from spawn. Firing decides 'timeout'.
140
+ const timeoutTimer = setTimeout(() => decide('timeout', ''), opts.timeoutMs);
141
+ timeoutTimer.unref?.();
142
+ // External abort signal — fires 'canceled' when parent wants to
143
+ // cancel mid-run.
144
+ if (opts.signal) {
145
+ onAbort = () => decide('canceled', '');
146
+ opts.signal.addEventListener('abort', onAbort, { once: true });
147
+ }
148
+ // stdin — close immediately when absent, else write-then-close.
149
+ // Errors here are non-fatal — the child may close its stdin
150
+ // before we finish writing, which is fine.
151
+ if (opts.stdin !== undefined && child.stdin) {
152
+ try {
153
+ child.stdin.end(opts.stdin);
154
+ }
155
+ catch {
156
+ /* child stdin already closed — not our problem */
157
+ }
158
+ }
159
+ else {
160
+ child.stdin?.end();
161
+ }
162
+ const onStdout = (chunk) => {
163
+ if (outcomeDecided)
164
+ return;
165
+ const remaining = maxStdoutBytes - stdoutBytes;
166
+ if (chunk.length <= remaining) {
167
+ stdoutChunks.push(chunk);
168
+ stdoutBytes += chunk.length;
169
+ return;
170
+ }
171
+ // Overflow — accept only the remaining bytes, then decide.
172
+ if (remaining > 0) {
173
+ stdoutChunks.push(chunk.subarray(0, remaining));
174
+ stdoutBytes += remaining;
175
+ }
176
+ stdoutTruncated = true;
177
+ decide('overflow-stdout', '');
178
+ };
179
+ const onStderr = (chunk) => {
180
+ if (outcomeDecided)
181
+ return;
182
+ const remaining = maxStderrBytes - stderrBytes;
183
+ if (chunk.length <= remaining) {
184
+ stderrChunks.push(chunk);
185
+ stderrBytes += chunk.length;
186
+ return;
187
+ }
188
+ if (remaining > 0) {
189
+ stderrChunks.push(chunk.subarray(0, remaining));
190
+ stderrBytes += remaining;
191
+ }
192
+ stderrTruncated = true;
193
+ decide('overflow-stderr', '');
194
+ };
195
+ child.stdout?.on('data', onStdout);
196
+ child.stderr?.on('data', onStderr);
197
+ child.on('error', (err) => {
198
+ // Post-spawn error. When the child never gets a pid (ENOENT on
199
+ // Linux surfaces async via 'error' with no 'exit' follow-up),
200
+ // 'exit' will not fire — we must resolve from here. Per Node's
201
+ // docs, 'exit' MAY fire after 'error' but isn't guaranteed.
202
+ const message = err instanceof Error ? err.message : String(err);
203
+ if (child.pid === undefined && !outcomeDecided) {
204
+ outcomeDecided = true;
205
+ clearTimeout(timeoutTimer);
206
+ if (onAbort && opts.signal) {
207
+ opts.signal.removeEventListener('abort', onAbort);
208
+ onAbort = null;
209
+ }
210
+ cleanupCwd(cwd, cwdOwnedBySandbox);
211
+ resolve({
212
+ outcome: 'spawn-error',
213
+ exitCode: null,
214
+ signal: null,
215
+ stdout: decodeUpTo(stdoutChunks, maxStdoutBytes),
216
+ stderr: decodeUpTo(stderrChunks, maxStderrBytes),
217
+ durationMs: Date.now() - start,
218
+ stdoutTruncated,
219
+ stderrTruncated,
220
+ cwd,
221
+ cwdOwnedBySandbox,
222
+ nodeHeapMbApplied,
223
+ errorMessage: message,
224
+ });
225
+ return;
226
+ }
227
+ decide('spawn-error', message);
228
+ });
229
+ const decide = (next, message) => {
230
+ if (outcomeDecided)
231
+ return;
232
+ outcomeDecided = true;
233
+ outcome = next;
234
+ errorMessage = message;
235
+ // If the child is still alive, request graceful shutdown. The
236
+ // real terminal state ('stopped') is observed on the 'exit'
237
+ // event below.
238
+ killChild(child, shutdownSignal, gracePeriodMs, (timer) => {
239
+ killTimer = timer;
240
+ });
241
+ };
242
+ child.on('exit', (code, signal) => {
243
+ // Flush any buffered output (the 'data' listener handles it
244
+ // live, but the kernel can deliver final chunks after exit on
245
+ // some platforms). Node's ChildProcess already emits all
246
+ // 'data' before 'exit', so we just decode.
247
+ if (killTimer) {
248
+ clearTimeout(killTimer);
249
+ killTimer = null;
250
+ }
251
+ clearTimeout(timeoutTimer);
252
+ if (onAbort && opts.signal) {
253
+ opts.signal.removeEventListener('abort', onAbort);
254
+ onAbort = null;
255
+ }
256
+ // If no one has decided yet, the child exited on its own.
257
+ if (!outcomeDecided) {
258
+ outcomeDecided = true;
259
+ outcome = 'exit';
260
+ }
261
+ const stdout = decodeUpTo(stdoutChunks, maxStdoutBytes);
262
+ const stderr = decodeUpTo(stderrChunks, maxStderrBytes);
263
+ cleanupCwd(cwd, cwdOwnedBySandbox);
264
+ resolve({
265
+ outcome,
266
+ exitCode: outcome === 'exit' ? code : null,
267
+ signal: outcome === 'exit' ? signal : null,
268
+ stdout,
269
+ stderr,
270
+ durationMs: Date.now() - start,
271
+ stdoutTruncated,
272
+ stderrTruncated,
273
+ cwd,
274
+ cwdOwnedBySandbox,
275
+ nodeHeapMbApplied,
276
+ errorMessage,
277
+ });
278
+ });
279
+ });
280
+ }
281
+ /**
282
+ * Input validation — throws synchronously on invalid configuration
283
+ * so callers find bugs at the call site, not as a confusing 'exit'
284
+ * outcome with an empty stderr.
285
+ */
286
+ function validateOptions(opts) {
287
+ if (!opts.command || typeof opts.command !== 'string') {
288
+ throw new TypeError('runSandboxed: `command` must be a non-empty string');
289
+ }
290
+ if (!Array.isArray(opts.args)) {
291
+ throw new TypeError('runSandboxed: `args` must be an array');
292
+ }
293
+ if (typeof opts.timeoutMs !== 'number' ||
294
+ !Number.isFinite(opts.timeoutMs) ||
295
+ opts.timeoutMs <= 0 ||
296
+ !Number.isInteger(opts.timeoutMs)) {
297
+ throw new RangeError('runSandboxed: `timeoutMs` must be a positive finite integer');
298
+ }
299
+ if (opts.cwd !== undefined && !isAbsolute(opts.cwd)) {
300
+ throw new TypeError(`runSandboxed: \`cwd\` must be an absolute path, got ${JSON.stringify(opts.cwd)}`);
301
+ }
302
+ if (opts.maxStdoutBytes !== undefined &&
303
+ (opts.maxStdoutBytes <= 0 || !Number.isInteger(opts.maxStdoutBytes))) {
304
+ throw new RangeError('runSandboxed: `maxStdoutBytes` must be a positive integer');
305
+ }
306
+ if (opts.maxStderrBytes !== undefined &&
307
+ (opts.maxStderrBytes <= 0 || !Number.isInteger(opts.maxStderrBytes))) {
308
+ throw new RangeError('runSandboxed: `maxStderrBytes` must be a positive integer');
309
+ }
310
+ if (opts.gracePeriodMs !== undefined && opts.gracePeriodMs < 0) {
311
+ throw new RangeError('runSandboxed: `gracePeriodMs` must be >= 0');
312
+ }
313
+ if (opts.nodeHeapMb !== undefined &&
314
+ (opts.nodeHeapMb <= 0 || !Number.isInteger(opts.nodeHeapMb))) {
315
+ throw new RangeError('runSandboxed: `nodeHeapMb` must be a positive integer');
316
+ }
317
+ }
318
+ /**
319
+ * Resolve the child's environment.
320
+ *
321
+ * The parent's `process.env` is NEVER merged in. Only three things
322
+ * reach the child:
323
+ *
324
+ * 1. A minimal bootstrap (`PATH`, `HOME`, and `TMPDIR` when
325
+ * present on the parent). `PATH` is required or the kernel
326
+ * cannot locate the command; `HOME` is required for most Node
327
+ * shutdown paths (e.g. npm config); `TMPDIR` lets child code
328
+ * that writes scratch files respect the parent's temp location.
329
+ * Callers can override any of these by declaring the key
330
+ * explicitly in `opts.env`.
331
+ *
332
+ * 2. Every key from `opts.env` (verbatim — parent values NEVER
333
+ * leak).
334
+ *
335
+ * 3. `NODE_OPTIONS=--max-old-space-size=<nodeHeapMb>` when the
336
+ * child is a Node process AND `opts.nodeHeapMb` is set.
337
+ * Merges with any `NODE_OPTIONS` the caller supplied.
338
+ */
339
+ function resolveEnv(opts) {
340
+ const env = {};
341
+ // Bootstrap — only the three keys that matter for the child to
342
+ // locate its binary, have a home dir, and know where to scratch.
343
+ const bootstrapKeys = ['PATH', 'HOME', 'TMPDIR'];
344
+ for (const key of bootstrapKeys) {
345
+ const parentValue = process.env[key];
346
+ if (parentValue !== undefined)
347
+ env[key] = parentValue;
348
+ }
349
+ // Explicit allowlist — callers OVERRIDE bootstrap values if they
350
+ // declare the key themselves. Shallow merge; no special handling.
351
+ if (opts.env) {
352
+ for (const [key, value] of Object.entries(opts.env)) {
353
+ env[key] = value;
354
+ }
355
+ }
356
+ // Node heap cap — only applied for Node children.
357
+ let nodeHeapMbApplied = false;
358
+ if (opts.nodeHeapMb !== undefined && isNodeCommand(opts.command)) {
359
+ const flag = `--max-old-space-size=${opts.nodeHeapMb}`;
360
+ const existing = env.NODE_OPTIONS;
361
+ env.NODE_OPTIONS = existing ? `${existing} ${flag}` : flag;
362
+ nodeHeapMbApplied = true;
363
+ }
364
+ return { env, nodeHeapMbApplied };
365
+ }
366
+ function isNodeCommand(command) {
367
+ if (command === process.execPath)
368
+ return true;
369
+ const base = basename(command);
370
+ return base === 'node' || base === 'node.exe';
371
+ }
372
+ /**
373
+ * Kill the child. Sends `shutdownSignal` first; after `gracePeriodMs`
374
+ * escalates to SIGKILL. Both calls tolerate "child already exited" —
375
+ * `kill()` returns false but doesn't throw on a reaped child.
376
+ *
377
+ * `onTimer` lets the caller hold a reference to the escalation timer
378
+ * so it can cancel on early exit without a second kill attempt.
379
+ */
380
+ function killChild(child, signal, gracePeriodMs, onTimer) {
381
+ if (child.exitCode !== null || child.signalCode !== null)
382
+ return;
383
+ try {
384
+ child.kill(signal);
385
+ }
386
+ catch {
387
+ // Already gone.
388
+ return;
389
+ }
390
+ const timer = setTimeout(() => {
391
+ if (child.exitCode !== null || child.signalCode !== null)
392
+ return;
393
+ try {
394
+ child.kill('SIGKILL');
395
+ }
396
+ catch {
397
+ /* already gone */
398
+ }
399
+ }, gracePeriodMs);
400
+ timer.unref?.();
401
+ onTimer(timer);
402
+ }
403
+ /**
404
+ * Concatenate captured buffer chunks and decode as UTF-8, truncated
405
+ * to `max` bytes. UTF-8 decode tolerates a truncation mid-codepoint
406
+ * by replacing the partial bytes with the replacement character.
407
+ */
408
+ function decodeUpTo(chunks, max) {
409
+ const joined = Buffer.concat(chunks);
410
+ const sliced = joined.length > max ? joined.subarray(0, max) : joined;
411
+ return sliced.toString('utf-8');
412
+ }
413
+ /**
414
+ * Remove the tmpdir the sandbox created. Best-effort — a stray lock
415
+ * file or open handle in the child should not propagate as a caller-
416
+ * visible error. Real failures surface via logs, not exceptions.
417
+ */
418
+ function cleanupCwd(cwd, owned) {
419
+ if (!owned)
420
+ return;
421
+ try {
422
+ rmSync(cwd, { recursive: true, force: true });
423
+ }
424
+ catch {
425
+ // Best-effort. Tests that need a clean tmpdir use mkdtempSync
426
+ // under os.tmpdir() so the OS eventually reclaims it.
427
+ }
428
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Spawner seam. Broken out from the runner so tests can substitute
3
+ * a fake child process without touching the filesystem or launching
4
+ * a real OS process.
5
+ *
6
+ * Kept minimal — only the shape the sandbox actually uses. Real
7
+ * production wiring imports `spawn` from `node:child_process`.
8
+ */
9
+ import type { ChildProcess } from 'node:child_process';
10
+ export type Spawner = (command: string, args: readonly string[], options: SpawnerOptions) => ChildProcess;
11
+ export interface SpawnerOptions {
12
+ readonly cwd: string;
13
+ readonly env: NodeJS.ProcessEnv;
14
+ readonly stdio: ['pipe', 'pipe', 'pipe'];
15
+ readonly shell: false;
16
+ readonly detached: false;
17
+ readonly windowsHide: true;
18
+ }
19
+ //# sourceMappingURL=spawner.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"spawner.d.ts","sourceRoot":"","sources":["../src/spawner.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvD,MAAM,MAAM,OAAO,GAAG,CACpB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,EAAE,cAAc,KACpB,YAAY,CAAC;AAElB,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC;IAChC,QAAQ,CAAC,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;IACzC,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC;IACtB,QAAQ,CAAC,QAAQ,EAAE,KAAK,CAAC;IACzB,QAAQ,CAAC,WAAW,EAAE,IAAI,CAAC;CAC5B"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,253 @@
1
+ /**
2
+ * `@ggui-ai/sandbox` — public API types.
3
+ *
4
+ * One high-level entry point (`runSandboxed`) with a small, pinned
5
+ * surface. The discriminated `SandboxOutcome` lets callers branch on
6
+ * terminal state without re-deriving it from exit codes / signals.
7
+ *
8
+ * ## Honest security boundary (MVP)
9
+ *
10
+ * What this package DOES enforce, portably, from pure Node:
11
+ *
12
+ * - **Process boundary.** Work runs in a fresh `child_process.spawn`
13
+ * subprocess with `shell: false` + `detached: false` + `stdio:
14
+ * ['pipe', 'pipe', 'pipe']`. The child cannot attach to the
15
+ * parent's controlling terminal, cannot inherit open fds, and
16
+ * cannot fork into a new process group. A crash in the child
17
+ * cannot corrupt parent state.
18
+ *
19
+ * - **Working-directory isolation.** Callers either bring their own
20
+ * absolute `cwd` or let the sandbox mint an owned tmpdir that it
21
+ * cleans up on finish. Relative paths inside the child resolve
22
+ * against the declared `cwd` only. (Not an FS boundary — the
23
+ * child still has the parent user's read permissions on absolute
24
+ * paths; see "Does NOT enforce" below.)
25
+ *
26
+ * - **Environment allowlist.** The child receives the `env` object
27
+ * verbatim. The parent's `process.env` is NEVER merged.
28
+ * Consumers that want to expose specific variables write them
29
+ * explicitly. A minimal bootstrap (`PATH`, `HOME`, `TMPDIR` if
30
+ * present on the parent) is injected only so the child can
31
+ * resolve the command and write scratch data — and is documented
32
+ * in {@link SandboxOptions.env}.
33
+ *
34
+ * - **Wall-clock timeout.** `timeoutMs` is required — no
35
+ * "infinity." On overrun the sandbox sends `SIGTERM`, waits
36
+ * `gracePeriodMs`, then escalates to `SIGKILL`. Outcome is
37
+ * `'timeout'`.
38
+ *
39
+ * - **Output byte caps.** stdout + stderr are accumulated up to
40
+ * `maxStdoutBytes` / `maxStderrBytes`. When either is exceeded
41
+ * the sandbox terminates the child and returns outcome
42
+ * `'overflow-stdout'` / `'overflow-stderr'`. Captured output is
43
+ * truncated to exactly the cap.
44
+ *
45
+ * - **V8 heap cap (Node children only).** When `nodeHeapMb` is
46
+ * set AND the command is `process.execPath` (or has the basename
47
+ * `node`), the sandbox prepends `--max-old-space-size=<mb>` to
48
+ * `args` via the `NODE_OPTIONS` env var. This caps V8's old-
49
+ * generation heap. It does NOT cap total RSS (native buffers,
50
+ * ArrayBuffers, child-of-child memory).
51
+ *
52
+ * - **No stdin leakage.** When `stdin` is absent the child's stdin
53
+ * is closed immediately. When present, the sandbox writes exactly
54
+ * the supplied bytes then closes. The parent never forwards its
55
+ * own stdin.
56
+ *
57
+ * What this package does NOT enforce (portably from Node):
58
+ *
59
+ * - **Network egress blocking.** Impossible portably from Node
60
+ * user-space. Real enforcement needs OS primitives: Linux
61
+ * network namespaces + iptables, macOS pf, Windows WFP, or a
62
+ * sidecar (Docker network:none, gVisor, firecracker). Consumers
63
+ * who need no-egress MUST layer those themselves; the sandbox
64
+ * does not pretend.
65
+ *
66
+ * - **Filesystem read boundaries.** The child shares the parent
67
+ * process's UID/GID + filesystem visibility. Relative paths
68
+ * resolve under `cwd`, but absolute paths and `..` traversals
69
+ * remain reachable. A true FS sandbox needs chroot / pivot_root /
70
+ * user namespaces / Landlock (Linux) / sandbox-exec (macOS).
71
+ *
72
+ * - **CPU share / scheduling cap.** Node has no portable rlimit
73
+ * surface for CPU time. Consumers who need CPU caps run the
74
+ * sandbox under a cgroup / `ulimit` / container.
75
+ *
76
+ * - **Syscall filtering.** No seccomp, no LSM hooks. The child can
77
+ * make any syscall the parent could.
78
+ *
79
+ * - **Fork-bomb containment.** No `RLIMIT_NPROC`. A malicious
80
+ * child could spawn descendants that the sandbox does NOT track
81
+ * or kill. (The sandbox kills only its direct child; grandchildren
82
+ * survive if they reparent.)
83
+ *
84
+ * Every "does not" above is a deliberate omission to keep the MVP
85
+ * portable + honest. Consumers that require any of those guarantees
86
+ * run the sandbox under a stronger layer (Docker, gVisor, firecracker,
87
+ * etc.) — the sandbox does not lie about what it gives them.
88
+ */
89
+ import type { Spawner } from './spawner.js';
90
+ /**
91
+ * Options for a single {@link runSandboxed} invocation. Immutable —
92
+ * callers construct a fresh object per run.
93
+ */
94
+ export interface SandboxOptions {
95
+ /**
96
+ * Absolute path to the executable. Required. No shell interpretation
97
+ * (`shell: false` is the only supported mode); to run a Node script
98
+ * pass `process.execPath` or a resolved `node` path and put the
99
+ * script path in `args`.
100
+ */
101
+ readonly command: string;
102
+ /**
103
+ * Args forwarded verbatim to the child. Pass `[]` for no args.
104
+ */
105
+ readonly args: readonly string[];
106
+ /**
107
+ * Working directory. Absolute paths only. Absent = sandbox creates
108
+ * an owned tmpdir under `os.tmpdir()` and removes it at the end
109
+ * of the run (result.cwdOwnedBySandbox === true). Relative paths
110
+ * are rejected — the sandbox does not silently resolve against the
111
+ * parent's CWD.
112
+ */
113
+ readonly cwd?: string;
114
+ /**
115
+ * Environment variables exposed to the child. **The parent's
116
+ * `process.env` is NEVER merged in.** Only the keys in this map
117
+ * plus a minimal bootstrap (`PATH`, `HOME`, and `TMPDIR` when
118
+ * present on the parent — required so the kernel + Node can find
119
+ * the command and write scratch data) reach the child. Callers
120
+ * that want to forward extra variables do so explicitly by reading
121
+ * `process.env` and copying the keys they want.
122
+ *
123
+ * Absent = empty allowlist (child gets only the bootstrap).
124
+ */
125
+ readonly env?: Readonly<Record<string, string>>;
126
+ /**
127
+ * Wall-clock timeout in milliseconds. Required — the sandbox does
128
+ * not support "run forever." Must be a positive finite integer.
129
+ *
130
+ * On overrun:
131
+ * 1. sandbox sends {@link shutdownSignal} to the child,
132
+ * 2. waits up to {@link gracePeriodMs},
133
+ * 3. escalates to `SIGKILL` if the child is still alive.
134
+ *
135
+ * Outcome is `'timeout'`.
136
+ */
137
+ readonly timeoutMs: number;
138
+ /**
139
+ * Signal to send when terminating. Defaults to `'SIGTERM'`.
140
+ * The escalation to `'SIGKILL'` after {@link gracePeriodMs} is
141
+ * unconditional and not configurable.
142
+ */
143
+ readonly shutdownSignal?: NodeJS.Signals;
144
+ /**
145
+ * Grace period in ms between the soft-kill signal and the hard
146
+ * `SIGKILL`. Defaults to `2000`. Must be >= 0 and < {@link
147
+ * timeoutMs} to leave headroom for a child that ignores SIGTERM.
148
+ */
149
+ readonly gracePeriodMs?: number;
150
+ /**
151
+ * Optional data written to the child's stdin. When omitted, stdin
152
+ * is closed immediately and the child reads EOF. Strings are
153
+ * encoded as UTF-8; `Uint8Array` is written verbatim.
154
+ */
155
+ readonly stdin?: string | Uint8Array;
156
+ /**
157
+ * Max bytes to capture from stdout before terminating the child.
158
+ * Defaults to `8 * 1024 * 1024` (8 MiB). Must be > 0.
159
+ * Exceeding the cap terminates the child with outcome
160
+ * `'overflow-stdout'`; captured stdout is truncated to the cap.
161
+ */
162
+ readonly maxStdoutBytes?: number;
163
+ /**
164
+ * Max bytes to capture from stderr before terminating the child.
165
+ * Defaults to `1 * 1024 * 1024` (1 MiB). Must be > 0.
166
+ * Exceeding the cap terminates the child with outcome
167
+ * `'overflow-stderr'`; captured stderr is truncated to the cap.
168
+ */
169
+ readonly maxStderrBytes?: number;
170
+ /**
171
+ * V8 old-generation heap cap in MiB. Applied only when the
172
+ * resolved `command` basename is `node` (or equals
173
+ * `process.execPath`). For non-Node children the field is ignored
174
+ * and {@link SandboxResult.nodeHeapMbApplied} is `false`.
175
+ *
176
+ * Applied via `NODE_OPTIONS=--max-old-space-size=<mb>`. Caps V8's
177
+ * old-gen only — not total RSS.
178
+ */
179
+ readonly nodeHeapMb?: number;
180
+ /**
181
+ * External cancellation. When the signal fires, the sandbox
182
+ * terminates the child (SIGTERM → grace → SIGKILL) and returns
183
+ * outcome `'canceled'`.
184
+ */
185
+ readonly signal?: AbortSignal;
186
+ /**
187
+ * Test seam — substitute a spawner to exercise the runner without
188
+ * launching real processes. Production code leaves this unset.
189
+ */
190
+ readonly spawner?: Spawner;
191
+ }
192
+ /**
193
+ * Terminal state of a sandbox run. Exhaustive; every possible
194
+ * shutdown path maps to exactly one of these.
195
+ */
196
+ export type SandboxOutcome =
197
+ /** Child exited on its own within the budget. `exitCode`/`signal`
198
+ * record why. */
199
+ 'exit'
200
+ /** `timeoutMs` elapsed; sandbox killed the child. */
201
+ | 'timeout'
202
+ /** External `signal` aborted the run; sandbox killed the child. */
203
+ | 'canceled'
204
+ /** stdout exceeded `maxStdoutBytes`; sandbox killed the child. */
205
+ | 'overflow-stdout'
206
+ /** stderr exceeded `maxStderrBytes`; sandbox killed the child. */
207
+ | 'overflow-stderr'
208
+ /** Spawn failed or an internal error occurred before the child
209
+ * could be supervised. `errorMessage` carries detail. */
210
+ | 'spawn-error';
211
+ /**
212
+ * Result of a {@link runSandboxed} invocation. All fields are
213
+ * non-optional — consumers get a consistent shape whatever the
214
+ * outcome. Fields that are only meaningful for some outcomes carry
215
+ * documented sentinels (e.g. `exitCode: null` for signal-induced
216
+ * termination).
217
+ */
218
+ export interface SandboxResult {
219
+ /** Terminal state. */
220
+ readonly outcome: SandboxOutcome;
221
+ /** Exit code when the child exited on its own; `null` when the
222
+ * sandbox killed it or spawn failed. */
223
+ readonly exitCode: number | null;
224
+ /** Signal that terminated the child; `null` when the child exited
225
+ * with an explicit code or spawn failed. */
226
+ readonly signal: NodeJS.Signals | null;
227
+ /** Captured stdout, decoded as UTF-8. Truncated to
228
+ * `maxStdoutBytes`. */
229
+ readonly stdout: string;
230
+ /** Captured stderr, decoded as UTF-8. Truncated to
231
+ * `maxStderrBytes`. */
232
+ readonly stderr: string;
233
+ /** Wall-clock duration in milliseconds. */
234
+ readonly durationMs: number;
235
+ /** True iff stdout output was truncated (actual stream was larger
236
+ * than `maxStdoutBytes`). */
237
+ readonly stdoutTruncated: boolean;
238
+ /** True iff stderr output was truncated. */
239
+ readonly stderrTruncated: boolean;
240
+ /** Absolute `cwd` the child ran in. When the caller brought their
241
+ * own it is echoed back; when the sandbox minted one this points
242
+ * at the owned tmpdir (already removed by the time the result
243
+ * resolves, per `cwdOwnedBySandbox`). */
244
+ readonly cwd: string;
245
+ /** True iff the sandbox created (and cleaned up) `cwd`. */
246
+ readonly cwdOwnedBySandbox: boolean;
247
+ /** True iff the sandbox applied the V8 heap cap (Node child only). */
248
+ readonly nodeHeapMbApplied: boolean;
249
+ /** Human-readable reason when `outcome === 'spawn-error'`.
250
+ * Empty string for every other outcome. */
251
+ readonly errorMessage: string;
252
+ }
253
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuFG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAE5C;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC7B;;;;;OAKG;IACH,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IAEzB;;OAEG;IACH,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IAEjC;;;;;;OAMG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IAEtB;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAEhD;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAE3B;;;;OAIG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC,OAAO,CAAC;IAEzC;;;;OAIG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAEhC;;;;OAIG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,UAAU,CAAC;IAErC;;;;;OAKG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IAEjC;;;;;OAKG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IAEjC;;;;;;;;OAQG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAE7B;;;;OAIG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAC;IAE9B;;;OAGG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED;;;GAGG;AACH,MAAM,MAAM,cAAc;AACxB;iBACiB;AACf,MAAM;AACR,qDAAqD;GACnD,SAAS;AACX,mEAAmE;GACjE,UAAU;AACZ,kEAAkE;GAChE,iBAAiB;AACnB,kEAAkE;GAChE,iBAAiB;AACnB;yDACyD;GACvD,aAAa,CAAC;AAElB;;;;;;GAMG;AACH,MAAM,WAAW,aAAa;IAC5B,sBAAsB;IACtB,QAAQ,CAAC,OAAO,EAAE,cAAc,CAAC;IAEjC;4CACwC;IACxC,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IAEjC;gDAC4C;IAC5C,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,OAAO,GAAG,IAAI,CAAC;IAEvC;2BACuB;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IAExB;2BACuB;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IAExB,2CAA2C;IAC3C,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAE5B;iCAC6B;IAC7B,QAAQ,CAAC,eAAe,EAAE,OAAO,CAAC;IAElC,4CAA4C;IAC5C,QAAQ,CAAC,eAAe,EAAE,OAAO,CAAC;IAElC;;;6CAGyC;IACzC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IAErB,2DAA2D;IAC3D,QAAQ,CAAC,iBAAiB,EAAE,OAAO,CAAC;IAEpC,sEAAsE;IACtE,QAAQ,CAAC,iBAAiB,EAAE,OAAO,CAAC;IAEpC;+CAC2C;IAC3C,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;CAC/B"}
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@ggui-ai/sandbox",
3
+ "version": "0.1.0-rc.1",
4
+ "description": "Bounded process-isolation runner for untrusted Node subprocesses. Spawns a child with explicit cwd, env allowlist, wall-clock timeout, output caps, and (for Node children) a V8 heap cap. Honest scope — does NOT attempt network egress blocking, filesystem read boundaries, CPU limits, or syscall filtering (those need OS primitives: namespaces / seccomp / cgroups).",
5
+ "keywords": [
6
+ "ggui",
7
+ "sandbox",
8
+ "process-isolation",
9
+ "subprocess",
10
+ "spawn"
11
+ ],
12
+ "license": "Apache-2.0",
13
+ "homepage": "https://github.com/ggui-ai/ggui/tree/main/packages/sandbox",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/ggui-ai/ggui.git",
17
+ "directory": "packages/sandbox"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "type": "module",
23
+ "main": "dist/index.js",
24
+ "types": "dist/index.d.ts",
25
+ "files": [
26
+ "dist",
27
+ "README.md"
28
+ ],
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "import": "./dist/index.js"
33
+ }
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^24.0.0",
37
+ "typescript": "^5.0.0",
38
+ "vitest": "^3.0.0"
39
+ },
40
+ "bugs": {
41
+ "url": "https://github.com/ggui-ai/ggui/issues"
42
+ },
43
+ "engines": {
44
+ "node": ">=20.0.0"
45
+ },
46
+ "author": "ggui contributors <hello@ggui.ai>",
47
+ "scripts": {
48
+ "build": "tsc -p tsconfig.build.json",
49
+ "dev": "tsc --watch",
50
+ "typecheck": "tsc --noEmit",
51
+ "test": "vitest run",
52
+ "test:watch": "vitest"
53
+ }
54
+ }