sandboxedjs 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +27 -0
- package/README.md +353 -0
- package/bin/sandboxedjs.mjs +286 -0
- package/dist/index.cjs +19388 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1958 -0
- package/dist/index.d.ts +1958 -0
- package/dist/index.js +19318 -0
- package/dist/index.js.map +1 -0
- package/package.json +67 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 sandboxedjs contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
This package depends on @scelar/nodepod, which is licensed MIT with the
|
|
26
|
+
Commons Clause. Nodepod is not redistributed here; it is installed as a normal
|
|
27
|
+
npm dependency.
|
package/README.md
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
# sandboxedjs
|
|
2
|
+
|
|
3
|
+
A Linux-like container that runs entirely inside a Node.js process. No Docker, no VM, no native
|
|
4
|
+
modules — a virtual filesystem, a POSIX shell, 154 Unix programs, and both Node.js and Python
|
|
5
|
+
runtimes, all in-process.
|
|
6
|
+
|
|
7
|
+
Built on [Nodepod](https://github.com/R1ck404/Nodepod), which supplies the Node.js execution
|
|
8
|
+
engine and the in-memory volume.
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { createContainer } from "sandboxedjs";
|
|
12
|
+
|
|
13
|
+
const box = await createContainer({
|
|
14
|
+
files: { "/app/hello.js": "console.log('hi from', process.platform)" },
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
await box.exec("ls -la /app");
|
|
18
|
+
await box.exec("node /app/hello.js"); // → hi from linux
|
|
19
|
+
await box.exec("python3 -c 'print(2**64)'"); // → 18446744073709551616
|
|
20
|
+
|
|
21
|
+
box.dispose();
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Why
|
|
25
|
+
|
|
26
|
+
Sometimes you need to run untrusted or generated code, give an AI agent a shell, build a
|
|
27
|
+
browser-based IDE backend, or teach Unix — and spinning up a real container is too heavy, too
|
|
28
|
+
slow, or unavailable (serverless, CI, the browser). `sandboxedjs` boots in about 100 ms, costs
|
|
29
|
+
nothing but memory, and never touches your real filesystem.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm install sandboxedjs
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Node 18.17+. Everything is pure JavaScript and WebAssembly — no compilation step.
|
|
38
|
+
|
|
39
|
+
## What's inside
|
|
40
|
+
|
|
41
|
+
| | |
|
|
42
|
+
|---|---|
|
|
43
|
+
| **Filesystem** | Full FHS tree (`/etc`, `/usr`, `/var`, `/home`, …), permissions, ownership, symlinks, hard links, `umask`, sticky bits |
|
|
44
|
+
| **Shell** | POSIX `sh` (54 builtins) — pipelines, redirection, here-docs, globbing, brace/parameter/arithmetic/command expansion, functions, `if`/`for`/`while`/`case`/`select`, job control, traps, arrays, `[[ ]]`, `(( ))` |
|
|
45
|
+
| **Coreutils** | 154 programs — `ls cat cp mv rm mkdir grep sed awk find head tail sort uniq wc cut tr tee xargs chmod chown ln du df ps tar gzip base64 sha256sum diff curl wget` and the rest — plus 54 shell builtins |
|
|
46
|
+
| **Node.js** | Real Node semantics via Nodepod — `require`, npm packages, `http`, `fs`, streams, `worker_threads` |
|
|
47
|
+
| **Python** | MicroPython on WebAssembly, mounted on the *same* filesystem |
|
|
48
|
+
| **`/proc`** | Live and synthesised — `ps`, `top`, `free` and `uptime` all read the same source |
|
|
49
|
+
| **Networking** | Virtual interfaces, `/etc/hosts` resolution, in-container HTTP servers, an optional bridge to a real host port |
|
|
50
|
+
| **Users** | Real `/etc/passwd` and `/etc/group`; `useradd`, `su`, `sudo`, and permission checks that deny for real — for the shell and Python, [but not Node](#the-in-container-user-model-does-not-constrain-nodejs) |
|
|
51
|
+
|
|
52
|
+
Everything shares one filesystem. A file written by `echo` is readable by `require('fs')` in a
|
|
53
|
+
Node script and by `open()` in Python, in either direction.
|
|
54
|
+
|
|
55
|
+
## Guide
|
|
56
|
+
|
|
57
|
+
### Booting
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
const box = await createContainer({
|
|
61
|
+
files: { "/app/index.js": "…" }, // seed the filesystem
|
|
62
|
+
cwd: "/app", // default working directory
|
|
63
|
+
hostname: "sandbox",
|
|
64
|
+
user: "root", // or a name → uid 1000 with sudo
|
|
65
|
+
env: { NODE_ENV: "production" },
|
|
66
|
+
memory: 2 * 1024 ** 3, // what `free` and /proc/meminfo report
|
|
67
|
+
cpus: 4, // what `nproc` reports
|
|
68
|
+
network: { allowOutbound: false }, // outbound is off by default
|
|
69
|
+
timeoutMs: 30_000, // default limit for exec()
|
|
70
|
+
});
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`files` keys may be absolute or relative to `cwd`; parent directories are created for you, and
|
|
74
|
+
values may be strings or `Uint8Array`s. That is the quickest way to drop a whole project in.
|
|
75
|
+
|
|
76
|
+
### Running commands
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
const { stdout, stderr, exitCode, output } = await box.exec("grep -c . /etc/passwd");
|
|
80
|
+
|
|
81
|
+
await box.exec("cat", { stdin: "piped in" });
|
|
82
|
+
await box.exec("npm test", { cwd: "/app", onStdout: (t) => process.stdout.write(t) });
|
|
83
|
+
await box.exec("whoami", { user: "alice" });
|
|
84
|
+
await box.exec("sleep 60", { timeoutMs: 1000 }); // → { timedOut: true }
|
|
85
|
+
|
|
86
|
+
await box.run(["echo", "no shell parsing here"]);
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`exec` is stateless, like `docker exec`. For a shell that remembers things, use a session:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
const session = box.session();
|
|
93
|
+
await session.run("cd /app");
|
|
94
|
+
await session.run("export TOKEN=abc");
|
|
95
|
+
await session.run("echo $TOKEN in $(pwd)"); // → abc in /app
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Long-running processes
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
const proc = box.spawn("node server.js", { cwd: "/app" });
|
|
102
|
+
|
|
103
|
+
for (;;) {
|
|
104
|
+
const line = await proc.stdout.readLine();
|
|
105
|
+
if (line === null) break;
|
|
106
|
+
console.log("[server]", line);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
proc.kill();
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Servers
|
|
113
|
+
|
|
114
|
+
An HTTP server started inside the container is reachable three ways:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
box.spawn("node /app/server.js");
|
|
118
|
+
await box.waitForPort(3000);
|
|
119
|
+
|
|
120
|
+
// 1. programmatically
|
|
121
|
+
const res = await box.request(3000, { path: "/api", method: "POST", body: "{}" });
|
|
122
|
+
console.log(res.status, res.json());
|
|
123
|
+
|
|
124
|
+
// 2. from inside, with the usual tools
|
|
125
|
+
await box.exec("curl -s localhost:3000/api");
|
|
126
|
+
|
|
127
|
+
// 3. from your machine or a browser
|
|
128
|
+
const bridge = await box.expose(3000);
|
|
129
|
+
console.log(bridge.url); // http://127.0.0.1:54321
|
|
130
|
+
await bridge.close();
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### npm
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
const box = await createContainer({
|
|
137
|
+
cwd: "/app",
|
|
138
|
+
network: { allowOutbound: true }, // required to reach the registry
|
|
139
|
+
files: {
|
|
140
|
+
"/app/package.json": JSON.stringify({
|
|
141
|
+
name: "api",
|
|
142
|
+
scripts: { start: "node server.js" },
|
|
143
|
+
dependencies: { express: "^4.19.2" },
|
|
144
|
+
}),
|
|
145
|
+
"/app/server.js": `
|
|
146
|
+
const express = require('express');
|
|
147
|
+
const app = express();
|
|
148
|
+
app.get('/', (req, res) => res.json({ ok: true }));
|
|
149
|
+
app.listen(3000);
|
|
150
|
+
`,
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await box.exec("npm install", { cwd: "/app", timeoutMs: 300_000 });
|
|
155
|
+
box.spawn("npm start", { cwd: "/app" });
|
|
156
|
+
await box.waitForPort(3000);
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
`npx`, `yarn` and `pnpm` map onto the same installer. `apt`/`apt-get` reports the built-in
|
|
160
|
+
package set rather than pretending to download Debian archives.
|
|
161
|
+
|
|
162
|
+
### Python
|
|
163
|
+
|
|
164
|
+
Python is MicroPython compiled to WebAssembly, mounted on the container's filesystem:
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
await box.exec("python3 -c \"print(open('/etc/hostname').read())\"");
|
|
168
|
+
await box.exec("python3 /app/script.py arg1 arg2");
|
|
169
|
+
await box.exec("echo '1 2 3' | python3 -c \"import sys; print(sum(map(int, sys.stdin.read().split())))\"");
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The bundled standard library includes `json`, `re`, `os`, `sys`, `math`, `random`, `hashlib`,
|
|
173
|
+
`binascii`, `struct`, `time`, `collections`, `itertools`, `functools`, `asyncio` and more. It is
|
|
174
|
+
MicroPython, not CPython — there is no `numpy`, and `pip` can install pure-Python wheels only
|
|
175
|
+
(and only with outbound networking enabled).
|
|
176
|
+
|
|
177
|
+
### Filesystem from the host
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
await box.fs.writeFile("/app/config.json", JSON.stringify(config));
|
|
181
|
+
const log = await box.fs.readFile("/var/log/app.log", "utf8");
|
|
182
|
+
const entries = await box.fs.readdir("/app");
|
|
183
|
+
const { files, bytes } = await box.fs.usage("/app");
|
|
184
|
+
|
|
185
|
+
await box.copyIn("./my-project", "/app"); // host → container
|
|
186
|
+
await box.copyOut("/app/dist", "./dist"); // container → host
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Snapshots
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
const snapshot = box.snapshot(); // serialisable
|
|
193
|
+
await box.restore(snapshot);
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Interactive terminals
|
|
197
|
+
|
|
198
|
+
`Terminal` is transport-agnostic: feed it keystrokes, it hands you back what to display. That
|
|
199
|
+
works for a real TTY and for xterm.js in a browser alike.
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
import { Terminal } from "sandboxedjs";
|
|
203
|
+
|
|
204
|
+
const terminal = new Terminal(box.session(), {
|
|
205
|
+
write: (data) => xterm.write(data),
|
|
206
|
+
columns: 80,
|
|
207
|
+
rows: 24,
|
|
208
|
+
});
|
|
209
|
+
xterm.onData((data) => terminal.input(data));
|
|
210
|
+
terminal.start();
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
You get line editing, history, tab completion over commands and paths, multi-line continuation,
|
|
214
|
+
and the usual control keys.
|
|
215
|
+
|
|
216
|
+
## Command line
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
npx sandboxedjs # interactive shell
|
|
220
|
+
npx sandboxedjs -c 'ls -la /etc' # one command
|
|
221
|
+
npx sandboxedjs script.sh # run a script
|
|
222
|
+
npx sandboxedjs -v ./app:/app -w /app # mount a host directory
|
|
223
|
+
npx sandboxedjs --network -p 3000 # allow outbound, publish a port
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Run `sandboxedjs --help` for the full list.
|
|
227
|
+
|
|
228
|
+
## Security
|
|
229
|
+
|
|
230
|
+
The container has no access to your filesystem, environment, or network unless you grant it:
|
|
231
|
+
|
|
232
|
+
- The filesystem is entirely in memory. Code inside cannot read or write a host path — there is
|
|
233
|
+
no `/Users`, no `/home/you`, no way to reach one.
|
|
234
|
+
- Outbound network access is **off by default**; `curl https://…` fails until you pass
|
|
235
|
+
`network: { allowOutbound: true }`, optionally narrowed with `allowedHosts`.
|
|
236
|
+
- Host files enter only through `files`, `mount()` or `copyIn()`, and leave only through
|
|
237
|
+
`copyOut()` or `fs.readFile()`.
|
|
238
|
+
- `timeoutMs` bounds runaway commands, and `exec` settles even when a process ignores its kill
|
|
239
|
+
signal.
|
|
240
|
+
|
|
241
|
+
### The in-container user model does not constrain Node.js
|
|
242
|
+
|
|
243
|
+
This one matters, so it gets its own heading. The Unix permission layer is enforced for the
|
|
244
|
+
shell, the coreutils and Python:
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
await box.exec("cat /root/secret", { user: "agent" }); // Permission denied
|
|
248
|
+
await box.exec("python3 -c \"open('/etc/passwd','a')\"", { user: "agent" }); // OSError
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
It is **not** enforced for Node.js. A `node` script gets direct access to the underlying volume,
|
|
252
|
+
so it can read and write any path in the container regardless of `user`:
|
|
253
|
+
|
|
254
|
+
```ts
|
|
255
|
+
await box.exec("node -e \"require('fs').readFileSync('/root/secret')\"", { user: "agent" }); // succeeds
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Node code runs inside Nodepod's worker, which owns the volume and has no notion of container
|
|
259
|
+
uids. Treat `user` as a way to model *ordinary* multi-user behaviour, not as a privilege
|
|
260
|
+
boundary for JavaScript you do not trust. If untrusted JavaScript must not see something, keep
|
|
261
|
+
it out of the container rather than relying on file modes.
|
|
262
|
+
|
|
263
|
+
### And it is not a VM
|
|
264
|
+
|
|
265
|
+
Everything runs in your Node process, so a true sandbox escape is a JavaScript-engine escape.
|
|
266
|
+
This is isolation from mistakes and from ordinary untrusted programs — not a substitute for a
|
|
267
|
+
VM or a real container when facing a determined attacker.
|
|
268
|
+
|
|
269
|
+
## Known limits
|
|
270
|
+
|
|
271
|
+
Honest list of what does not work:
|
|
272
|
+
|
|
273
|
+
- **Vite's dev server** starts but fails resolving its own package metadata, because Nodepod's
|
|
274
|
+
ESM layer does not give bundled chunks a correct `import.meta.url`. Express, Koa, Fastify-style
|
|
275
|
+
apps and plain `http` servers work. See `examples/react-app` for a React setup that runs.
|
|
276
|
+
- **Python is MicroPython**, so C extensions (`numpy`, `pandas`, `cryptography`) are unavailable.
|
|
277
|
+
- **No real sockets.** HTTP servers work through the request proxy; raw TCP/UDP does not.
|
|
278
|
+
- **No real processes.** Processes are cooperative async tasks: `kill -9` cannot interrupt a
|
|
279
|
+
tight synchronous loop, and `SIGSTOP` only marks state.
|
|
280
|
+
- **`chroot` does not isolate**; it runs the command with its cwd inside the target.
|
|
281
|
+
- **`awk`'s `system()`** does not block on the child.
|
|
282
|
+
|
|
283
|
+
## API reference
|
|
284
|
+
|
|
285
|
+
### `createContainer(options): Promise<Container>`
|
|
286
|
+
|
|
287
|
+
| Option | Type | Default | |
|
|
288
|
+
|---|---|---|---|
|
|
289
|
+
| `files` | `Record<string, string \| Uint8Array>` | — | Seed the filesystem |
|
|
290
|
+
| `cwd` | `string` | `"/"` | Default working directory, and base for relative `files` keys |
|
|
291
|
+
| `hostname` | `string` | `"sandbox"` | |
|
|
292
|
+
| `user` | `string \| null` | `"root"` | Login user; a non-root name gets uid 1000 and sudo |
|
|
293
|
+
| `env` | `Record<string, string>` | — | Extra environment variables |
|
|
294
|
+
| `memory` | `number` | 2 GiB | Reported by `free`, `top`, `/proc/meminfo` |
|
|
295
|
+
| `cpus` | `number` | `4` | Reported by `nproc`, `/proc/cpuinfo` |
|
|
296
|
+
| `network` | `NetworkOptions` | outbound off | `{ allowOutbound, allowedHosts, ipv4, gateway }` |
|
|
297
|
+
| `timezone` | `string` | `"UTC"` | |
|
|
298
|
+
| `timeoutMs` | `number` | none | Default limit for `exec` |
|
|
299
|
+
| `onStdout` / `onStderr` | `(chunk: string) => void` | — | Container-wide output taps |
|
|
300
|
+
|
|
301
|
+
### `Container`
|
|
302
|
+
|
|
303
|
+
| Member | |
|
|
304
|
+
|---|---|
|
|
305
|
+
| `exec(command, opts?)` | Run a shell command line; returns `{ stdout, stderr, output, exitCode, timedOut, durationMs }` |
|
|
306
|
+
| `run(argv, opts?)` | Run a program without shell parsing |
|
|
307
|
+
| `spawn(command, opts?)` | Start a process; returns `{ pid, stdin, stdout, stderr, wait(), kill() }` |
|
|
308
|
+
| `session(opts?)` | A stateful shell session |
|
|
309
|
+
| `fs` | `readFile`, `writeFile`, `readdir`, `mkdir`, `rm`, `stat`, `walk`, `usage`, … |
|
|
310
|
+
| `mount(files, opts?)` | Add files after boot |
|
|
311
|
+
| `copyIn` / `copyOut` | Move trees between host and container |
|
|
312
|
+
| `request(port, init?)` | HTTP to an in-container server |
|
|
313
|
+
| `waitForPort(port, opts?)` | Resolve once something is listening |
|
|
314
|
+
| `expose(port, opts?)` | Bridge to a real host port |
|
|
315
|
+
| `snapshot()` / `restore(s)` | Filesystem persistence |
|
|
316
|
+
| `kernel`, `pod`, `net` | Escape hatches to the internals |
|
|
317
|
+
| `dispose()` | Tear everything down |
|
|
318
|
+
|
|
319
|
+
Lower-level pieces — `Kernel`, `Vfs`, `Shell`, `Terminal`, `NetworkStack`, `defineCommand` — are
|
|
320
|
+
exported too, so you can add your own commands or embed the shell on its own.
|
|
321
|
+
|
|
322
|
+
### Adding a command
|
|
323
|
+
|
|
324
|
+
```ts
|
|
325
|
+
import { createContainer, defineCommand } from "sandboxedjs";
|
|
326
|
+
|
|
327
|
+
const box = await createContainer();
|
|
328
|
+
|
|
329
|
+
box.kernel.installCommand(
|
|
330
|
+
defineCommand({
|
|
331
|
+
name: "greet",
|
|
332
|
+
summary: "say hello",
|
|
333
|
+
run(ctx) {
|
|
334
|
+
ctx.line(`hello ${ctx.args[0] ?? "world"}`);
|
|
335
|
+
return 0;
|
|
336
|
+
},
|
|
337
|
+
}),
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
await box.exec("greet there | tr a-z A-Z"); // → HELLO THERE
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
It becomes a real file in `/usr/bin`, so `which greet`, `man greet` and shebang dispatch all work.
|
|
344
|
+
|
|
345
|
+
## Examples
|
|
346
|
+
|
|
347
|
+
See [`examples/`](./examples): a REPL, an Express API, a React app, a Python data pipeline, an
|
|
348
|
+
agent sandbox, and a browser terminal.
|
|
349
|
+
|
|
350
|
+
## License
|
|
351
|
+
|
|
352
|
+
MIT. Nodepod itself is MIT with the Commons Clause; this package depends on it rather than
|
|
353
|
+
redistributing it.
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `sandboxedjs` — boot a Linux-like container and drop into a shell, or run a
|
|
4
|
+
* single command inside one.
|
|
5
|
+
*
|
|
6
|
+
* sandboxedjs interactive shell
|
|
7
|
+
* sandboxedjs -c 'ls -la /' run a command and exit
|
|
8
|
+
* sandboxedjs script.sh run a script
|
|
9
|
+
* sandboxedjs -v ./app:/app -w /app mount a host directory and start there
|
|
10
|
+
* sandboxedjs --network -p 3000 allow outbound, publish port 3000
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createContainer, Terminal } from "../dist/index.js";
|
|
14
|
+
import { readFile } from "node:fs/promises";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
|
|
18
|
+
const USAGE = `sandboxedjs — a Linux-like container inside Node.js
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
sandboxedjs [options] start an interactive shell
|
|
22
|
+
sandboxedjs [options] -c <command> run a command and exit
|
|
23
|
+
sandboxedjs [options] <script.sh> run a shell script and exit
|
|
24
|
+
|
|
25
|
+
Options:
|
|
26
|
+
-c, --command <cmd> run <cmd> and exit
|
|
27
|
+
-v, --volume <src:dst> copy a host directory into the container (repeatable)
|
|
28
|
+
-o, --out <src:dst> copy a container path back out on exit (repeatable)
|
|
29
|
+
-w, --workdir <dir> working directory inside the container (default /)
|
|
30
|
+
-u, --user <name> run as this user (default root)
|
|
31
|
+
-e, --env <K=V> set an environment variable (repeatable)
|
|
32
|
+
-p, --publish <port> publish a container port on the host (repeatable)
|
|
33
|
+
-H, --hostname <name> container hostname (default sandbox)
|
|
34
|
+
--network allow outbound network access (off by default)
|
|
35
|
+
--allow <host> allow one outbound host (implies --network, repeatable)
|
|
36
|
+
--memory <bytes> RAM reported inside the container
|
|
37
|
+
--cpus <n> CPU count reported inside the container
|
|
38
|
+
--timeout <ms> wall-clock limit for -c / script mode
|
|
39
|
+
-h, --help show this message
|
|
40
|
+
--version print the version
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
function parseCli(argv) {
|
|
44
|
+
const opts = {
|
|
45
|
+
volumes: [],
|
|
46
|
+
outputs: [],
|
|
47
|
+
env: {},
|
|
48
|
+
publish: [],
|
|
49
|
+
allow: [],
|
|
50
|
+
network: false,
|
|
51
|
+
workdir: "/",
|
|
52
|
+
user: "root",
|
|
53
|
+
hostname: "sandbox",
|
|
54
|
+
};
|
|
55
|
+
const rest = [];
|
|
56
|
+
|
|
57
|
+
for (let i = 0; i < argv.length; i++) {
|
|
58
|
+
const arg = argv[i];
|
|
59
|
+
const value = () => argv[++i];
|
|
60
|
+
switch (arg) {
|
|
61
|
+
case "-h":
|
|
62
|
+
case "--help":
|
|
63
|
+
opts.help = true;
|
|
64
|
+
break;
|
|
65
|
+
case "--version":
|
|
66
|
+
opts.version = true;
|
|
67
|
+
break;
|
|
68
|
+
case "-c":
|
|
69
|
+
case "--command":
|
|
70
|
+
opts.command = value();
|
|
71
|
+
break;
|
|
72
|
+
case "-v":
|
|
73
|
+
case "--volume":
|
|
74
|
+
opts.volumes.push(value());
|
|
75
|
+
break;
|
|
76
|
+
case "-o":
|
|
77
|
+
case "--out":
|
|
78
|
+
opts.outputs.push(value());
|
|
79
|
+
break;
|
|
80
|
+
case "-w":
|
|
81
|
+
case "--workdir":
|
|
82
|
+
opts.workdir = value();
|
|
83
|
+
break;
|
|
84
|
+
case "-u":
|
|
85
|
+
case "--user":
|
|
86
|
+
opts.user = value();
|
|
87
|
+
break;
|
|
88
|
+
case "-e":
|
|
89
|
+
case "--env": {
|
|
90
|
+
const pair = value() ?? "";
|
|
91
|
+
const eq = pair.indexOf("=");
|
|
92
|
+
if (eq > 0) opts.env[pair.slice(0, eq)] = pair.slice(eq + 1);
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
case "-p":
|
|
96
|
+
case "--publish":
|
|
97
|
+
opts.publish.push(Number(value()));
|
|
98
|
+
break;
|
|
99
|
+
case "-H":
|
|
100
|
+
case "--hostname":
|
|
101
|
+
opts.hostname = value();
|
|
102
|
+
break;
|
|
103
|
+
case "--network":
|
|
104
|
+
opts.network = true;
|
|
105
|
+
break;
|
|
106
|
+
case "--allow":
|
|
107
|
+
opts.network = true;
|
|
108
|
+
opts.allow.push(value());
|
|
109
|
+
break;
|
|
110
|
+
case "--memory":
|
|
111
|
+
opts.memory = Number(value());
|
|
112
|
+
break;
|
|
113
|
+
case "--cpus":
|
|
114
|
+
opts.cpus = Number(value());
|
|
115
|
+
break;
|
|
116
|
+
case "--timeout":
|
|
117
|
+
opts.timeout = Number(value());
|
|
118
|
+
break;
|
|
119
|
+
default:
|
|
120
|
+
if (arg.startsWith("-") && arg.length > 1) {
|
|
121
|
+
process.stderr.write(`sandboxedjs: unknown option ${arg}\n`);
|
|
122
|
+
process.exit(2);
|
|
123
|
+
}
|
|
124
|
+
rest.push(arg);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
opts.rest = rest;
|
|
128
|
+
return opts;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function packageVersion() {
|
|
132
|
+
try {
|
|
133
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
134
|
+
const manifest = JSON.parse(await readFile(path.join(here, "..", "package.json"), "utf8"));
|
|
135
|
+
return manifest.version ?? "0.0.0";
|
|
136
|
+
} catch {
|
|
137
|
+
return "0.0.0";
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function main() {
|
|
142
|
+
const opts = parseCli(process.argv.slice(2));
|
|
143
|
+
|
|
144
|
+
if (opts.help) {
|
|
145
|
+
process.stdout.write(USAGE);
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
if (opts.version) {
|
|
149
|
+
process.stdout.write((await packageVersion()) + "\n");
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const box = await createContainer({
|
|
154
|
+
hostname: opts.hostname,
|
|
155
|
+
user: opts.user === "root" ? "root" : opts.user,
|
|
156
|
+
cwd: opts.workdir,
|
|
157
|
+
env: opts.env,
|
|
158
|
+
...(opts.memory ? { memory: opts.memory } : {}),
|
|
159
|
+
...(opts.cpus ? { cpus: opts.cpus } : {}),
|
|
160
|
+
network: { allowOutbound: opts.network, allowedHosts: opts.allow.length ? opts.allow : null },
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// Mount host directories.
|
|
164
|
+
for (const spec of opts.volumes) {
|
|
165
|
+
const sep = spec.lastIndexOf(":");
|
|
166
|
+
if (sep <= 0) {
|
|
167
|
+
process.stderr.write(`sandboxedjs: bad volume '${spec}' (expected host:container)\n`);
|
|
168
|
+
box.dispose();
|
|
169
|
+
return 2;
|
|
170
|
+
}
|
|
171
|
+
const hostPath = path.resolve(spec.slice(0, sep));
|
|
172
|
+
const containerPath = spec.slice(sep + 1);
|
|
173
|
+
try {
|
|
174
|
+
await box.copyIn(hostPath, containerPath);
|
|
175
|
+
} catch (e) {
|
|
176
|
+
process.stderr.write(`sandboxedjs: cannot mount ${hostPath}: ${e.message}\n`);
|
|
177
|
+
box.dispose();
|
|
178
|
+
return 1;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Publish ports.
|
|
183
|
+
const bridges = [];
|
|
184
|
+
for (const port of opts.publish) {
|
|
185
|
+
const bridge = await box.expose(port);
|
|
186
|
+
bridges.push(bridge);
|
|
187
|
+
process.stderr.write(`sandboxedjs: container :${port} -> ${bridge.url}\n`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const finish = async (code) => {
|
|
191
|
+
for (const spec of opts.outputs) {
|
|
192
|
+
const sep = spec.lastIndexOf(":");
|
|
193
|
+
if (sep <= 0) continue;
|
|
194
|
+
try {
|
|
195
|
+
await box.copyOut(spec.slice(0, sep), path.resolve(spec.slice(sep + 1)));
|
|
196
|
+
} catch (e) {
|
|
197
|
+
process.stderr.write(`sandboxedjs: cannot copy out ${spec}: ${e.message}\n`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
for (const bridge of bridges) await bridge.close();
|
|
201
|
+
box.dispose();
|
|
202
|
+
return code;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// ── one-shot modes ────────────────────────────────────────────────────────
|
|
206
|
+
if (opts.command !== undefined) {
|
|
207
|
+
const result = await box.exec(opts.command, {
|
|
208
|
+
onStdout: (t) => process.stdout.write(t),
|
|
209
|
+
onStderr: (t) => process.stderr.write(t),
|
|
210
|
+
...(opts.timeout ? { timeoutMs: opts.timeout } : {}),
|
|
211
|
+
});
|
|
212
|
+
return finish(result.exitCode);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (opts.rest.length > 0) {
|
|
216
|
+
const scriptPath = path.resolve(opts.rest[0]);
|
|
217
|
+
let source;
|
|
218
|
+
try {
|
|
219
|
+
source = await readFile(scriptPath, "utf8");
|
|
220
|
+
} catch {
|
|
221
|
+
process.stderr.write(`sandboxedjs: cannot read ${scriptPath}\n`);
|
|
222
|
+
return finish(1);
|
|
223
|
+
}
|
|
224
|
+
const target = "/tmp/_sbx_script.sh";
|
|
225
|
+
box.mount({ [target]: source });
|
|
226
|
+
const args = opts.rest.slice(1).map((a) => `'${a.replace(/'/g, `'\\''`)}'`).join(" ");
|
|
227
|
+
const result = await box.exec(`sh ${target} ${args}`, {
|
|
228
|
+
onStdout: (t) => process.stdout.write(t),
|
|
229
|
+
onStderr: (t) => process.stderr.write(t),
|
|
230
|
+
...(opts.timeout ? { timeoutMs: opts.timeout } : {}),
|
|
231
|
+
});
|
|
232
|
+
return finish(result.exitCode);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── interactive shell ─────────────────────────────────────────────────────
|
|
236
|
+
if (!process.stdin.isTTY) {
|
|
237
|
+
// Piped input: treat it as a script.
|
|
238
|
+
const chunks = [];
|
|
239
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
240
|
+
const result = await box.exec(Buffer.concat(chunks).toString("utf8"), {
|
|
241
|
+
onStdout: (t) => process.stdout.write(t),
|
|
242
|
+
onStderr: (t) => process.stderr.write(t),
|
|
243
|
+
});
|
|
244
|
+
return finish(result.exitCode);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const session = box.session();
|
|
248
|
+
await new Promise((resolve) => {
|
|
249
|
+
const terminal = new Terminal(session, {
|
|
250
|
+
write: (data) => process.stdout.write(data),
|
|
251
|
+
columns: process.stdout.columns ?? 80,
|
|
252
|
+
rows: process.stdout.rows ?? 24,
|
|
253
|
+
onExit: (code) => {
|
|
254
|
+
cleanup();
|
|
255
|
+
resolve(code);
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const onData = (data) => terminal.input(data.toString("utf8"));
|
|
260
|
+
const onResize = () => terminal.resize(process.stdout.columns ?? 80, process.stdout.rows ?? 24);
|
|
261
|
+
|
|
262
|
+
const cleanup = () => {
|
|
263
|
+
process.stdin.off("data", onData);
|
|
264
|
+
process.stdout.off("resize", onResize);
|
|
265
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
266
|
+
process.stdin.pause();
|
|
267
|
+
process.stdout.write("\r\n");
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
process.stdin.setRawMode(true);
|
|
271
|
+
process.stdin.resume();
|
|
272
|
+
process.stdin.on("data", onData);
|
|
273
|
+
process.stdout.on("resize", onResize);
|
|
274
|
+
terminal.resize(process.stdout.columns ?? 80, process.stdout.rows ?? 24);
|
|
275
|
+
terminal.start();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
return finish(0);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
main()
|
|
282
|
+
.then((code) => process.exit(code ?? 0))
|
|
283
|
+
.catch((e) => {
|
|
284
|
+
process.stderr.write(`sandboxedjs: ${e?.stack ?? e}\n`);
|
|
285
|
+
process.exit(1);
|
|
286
|
+
});
|