Skip to content

Commit b6757f7

Browse files
Add process isolation and persistent sessions for all commands (#59)
* Add process isolation for sandbox commands Implements PID namespace isolation to protect control plane processes (Jupyter, Bun) from sandboxed code. Commands executed via exec() now run in isolated namespaces. Key changes: - Sandboxed commands can no longer see or kill control plane processes - Platform secrets in /proc/1/environ are inaccessible - Ports 8888 (Jupyter) and 3000 (Bun) are protected from hijacking - Commands within sessions now maintain state (pwd, env vars) - Graceful fallback when CAP_SYS_ADMIN not available (dev environments) BREAKING CHANGE: Commands within the same session now share state. Previously each command was stateless. Use createSession() for isolated command execution. * Stop information exposure through stack trace * Implement secure streaming execution with ExecutionSession support - Fix streaming security hole by routing through SessionManager instead of direct spawn() - Add ExecutionSession.execStream() method for secure real-time command streaming - Maintain backward compatibility by bridging sessionId API to ExecutionSessions - Extend SessionManager with streaming capabilities using isolated control processes * Remove sessionId * Make file ops session-aware too * Remove duplicate code paths * Fix streaming and corresponding abort * Fix log fetch endpoint * Minor fixes * Rename back to sessionId * Fix pending name references * Move control script into separate file * Fix type errors * fix biome lint errors * Prevent shell command injection * Move code around * Reorganise code * Update changeset
1 parent 0dad837 commit b6757f7

File tree

24 files changed

+3451
-2363
lines changed

24 files changed

+3451
-2363
lines changed
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@cloudflare/sandbox": minor
3+
---
4+
5+
Add process isolation for sandbox commands
6+
7+
Implements PID namespace isolation to protect control plane processes (Jupyter, Bun) from sandboxed code. Commands executed via `exec()` now run in isolated namespaces that cannot see or interact with system processes.
8+
9+
**Key security improvements:**
10+
- Control plane processes are hidden from sandboxed commands
11+
- Platform secrets in `/proc/1/environ` are inaccessible
12+
- Ports 8888 (Jupyter) and 3000 (Bun) are protected from hijacking
13+
14+
**Breaking changes:**
15+
16+
1. **Removed `sessionId` parameter**: The `sessionId` parameter has been removed from all methods (`exec()`, `execStream()`, `startProcess()`, etc.). Each sandbox now maintains its own persistent session automatically.
17+
18+
```javascript
19+
// Before: manual session management
20+
await sandbox.exec("cd /app", { sessionId: "my-session" });
21+
22+
// After: automatic session per sandbox
23+
await sandbox.exec("cd /app");
24+
```
25+
26+
2. **Commands now maintain state**: Commands within the same sandbox now share state (working directory, environment variables, background processes). Previously each command was stateless.
27+
28+
```javascript
29+
// Before: each exec was independent
30+
await sandbox.exec("cd /app");
31+
await sandbox.exec("pwd"); // Output: /workspace
32+
33+
// After: state persists in session
34+
await sandbox.exec("cd /app");
35+
await sandbox.exec("pwd"); // Output: /app
36+
```
37+
38+
**Migration guide:**
39+
- Remove `sessionId` from all method calls - each sandbox maintains its own session
40+
- If you need isolated execution contexts within the same sandbox, use `sandbox.createSession()`:
41+
```javascript
42+
// Create independent sessions with different environments
43+
const buildSession = await sandbox.createSession({
44+
name: "build",
45+
env: { NODE_ENV: "production" },
46+
cwd: "/build"
47+
});
48+
const testSession = await sandbox.createSession({
49+
name: "test",
50+
env: { NODE_ENV: "test" },
51+
cwd: "/test"
52+
});
53+
```
54+
- Environment variables set in one command persist to the next
55+
- Background processes remain active until explicitly killed
56+
- Requires CAP_SYS_ADMIN (available in production, falls back gracefully in dev)

.gitignore

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,4 +134,9 @@ dist
134134

135135
.DS_Store
136136

137-
container_dist
137+
container_dist
138+
139+
# Compiled TypeScript files in container_src
140+
packages/sandbox/container_src/*.js
141+
packages/sandbox/container_src/**/*.js
142+
!packages/sandbox/container_src/**/*.d.ts

examples/basic/app/index.tsx

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -178,13 +178,14 @@ class SandboxApiClient {
178178
});
179179
}
180180

181-
async *streamProcessLogs(processId: string): AsyncGenerator<any> {
181+
async *streamProcessLogs(processId: string, options?: { signal?: AbortSignal }): AsyncGenerator<any> {
182182
const response = await fetch(
183183
`${this.baseUrl}/api/process/${processId}/stream`,
184184
{
185185
headers: {
186186
Accept: "text/event-stream",
187187
},
188+
signal: options?.signal, // Pass the abort signal to fetch
188189
}
189190
);
190191

@@ -2191,6 +2192,12 @@ function StreamingTab({
21912192
return;
21922193

21932194
const streamId = `logs_${selectedProcessId}_${Date.now()}`;
2195+
2196+
// Create an AbortController for this stream
2197+
const abortController = new AbortController();
2198+
2199+
// Store the abort controller so it can be aborted when user clicks stop
2200+
streamAbortControllers.current.set(streamId, abortController);
21942201

21952202
// Add stream to active streams
21962203
const newStream: ActiveStream = {
@@ -2206,8 +2213,10 @@ function StreamingTab({
22062213
setActiveStreams((prev) => [...prev, newStream]);
22072214

22082215
try {
2209-
// Use the new streamProcessLogs AsyncIterable method
2210-
const logStreamIterable = client.streamProcessLogs(selectedProcessId);
2216+
// Use the new streamProcessLogs AsyncIterable method with abort signal
2217+
const logStreamIterable = client.streamProcessLogs(selectedProcessId, {
2218+
signal: abortController.signal
2219+
});
22112220

22122221
for await (const logEvent of logStreamIterable) {
22132222
const streamEvent: LogStreamEvent = {
@@ -2227,7 +2236,27 @@ function StreamingTab({
22272236
)
22282237
);
22292238
}
2239+
2240+
// Clean up abort controller when stream completes naturally
2241+
streamAbortControllers.current.delete(streamId);
22302242
} catch (error) {
2243+
// Clean up abort controller on error
2244+
streamAbortControllers.current.delete(streamId);
2245+
2246+
// Don't log abort errors or add error events for user cancellation
2247+
if (error instanceof Error && error.name === 'AbortError') {
2248+
console.log("Log streaming aborted by user");
2249+
// Just mark the stream as inactive without adding error event
2250+
setActiveStreams((prev) =>
2251+
prev.map((stream) =>
2252+
stream.id === streamId
2253+
? { ...stream, isActive: false }
2254+
: stream
2255+
)
2256+
);
2257+
return;
2258+
}
2259+
22312260
console.error("Log streaming error:", error);
22322261

22332262
const errorEvent: LogStreamEvent = {
@@ -2254,13 +2283,23 @@ function StreamingTab({
22542283
}
22552284
};
22562285

2286+
// Map to store abort controllers for active streams
2287+
const streamAbortControllers = useRef<Map<string, AbortController>>(new Map());
2288+
22572289
// Stop a stream
22582290
const stopStream = (streamId: string) => {
22592291
setActiveStreams((prev) =>
22602292
prev.map((stream) =>
22612293
stream.id === streamId ? { ...stream, isActive: false } : stream
22622294
)
22632295
);
2296+
2297+
// Abort the fetch if an abort controller exists
2298+
const controller = streamAbortControllers.current.get(streamId);
2299+
if (controller) {
2300+
controller.abort();
2301+
streamAbortControllers.current.delete(streamId);
2302+
}
22642303
};
22652304

22662305
// Clear a stream

examples/basic/src/endpoints/execute.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,12 @@ import { parseJsonBody, errorResponse, jsonResponse } from "../http";
33

44
export async function executeCommand(sandbox: Sandbox<unknown>, request: Request) {
55
const body = await parseJsonBody(request);
6-
const { command, sessionId, cwd, env } = body;
6+
const { command, cwd, env } = body;
77
if (!command) {
88
return errorResponse("Command is required");
99
}
1010

11-
// Use the current SDK API signature: exec(command, options)
12-
const result = await sandbox.exec(command, { sessionId, cwd, env });
11+
const result = await sandbox.exec(command, { cwd, env });
1312
return jsonResponse({
1413
success: result.exitCode === 0,
1514
exitCode: result.exitCode,

examples/basic/src/endpoints/executeStream.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { corsHeaders, errorResponse, parseJsonBody } from "../http";
44

55
export async function executeCommandStream(sandbox: Sandbox<unknown>, request: Request) {
66
const body = await parseJsonBody(request);
7-
const { command, sessionId } = body;
7+
const { command } = body;
88

99
if (!command) {
1010
return errorResponse("Command is required");
@@ -20,7 +20,7 @@ export async function executeCommandStream(sandbox: Sandbox<unknown>, request: R
2020
const encoder = new TextEncoder();
2121

2222
// Get the ReadableStream from sandbox
23-
const stream = await sandbox.execStream(command, { sessionId });
23+
const stream = await sandbox.execStream(command);
2424

2525
// Convert to AsyncIterable using parseSSEStream
2626
for await (const event of parseSSEStream<ExecEvent>(stream)) {

examples/basic/src/endpoints/processLogs.ts

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export async function getProcessLogs(sandbox: Sandbox<unknown>, pathname: string
1717
}
1818
}
1919

20-
export async function streamProcessLogs(sandbox: Sandbox<unknown>, pathname: string) {
20+
export async function streamProcessLogs(sandbox: Sandbox<unknown>, pathname: string, request: Request) {
2121
const pathParts = pathname.split("/");
2222
const processId = pathParts[pathParts.length - 2];
2323

@@ -40,6 +40,9 @@ export async function streamProcessLogs(sandbox: Sandbox<unknown>, pathname: str
4040
// Use the SDK's streaming with beautiful AsyncIterable API
4141
if (typeof sandbox.streamProcessLogs === 'function') {
4242
try {
43+
// Create an AbortController that will be triggered when client disconnects
44+
const abortController = new AbortController();
45+
4346
// Create SSE stream from AsyncIterable
4447
const encoder = new TextEncoder();
4548
const { readable, writable } = new TransformStream();
@@ -48,27 +51,37 @@ export async function streamProcessLogs(sandbox: Sandbox<unknown>, pathname: str
4851
// Stream logs in the background
4952
(async () => {
5053
try {
51-
// Get the ReadableStream from sandbox
54+
// Get the ReadableStream from sandbox (can't pass abort signal through Worker binding)
5255
const stream = await sandbox.streamProcessLogs(processId);
5356

54-
// Convert to AsyncIterable using parseSSEStream
55-
for await (const logEvent of parseSSEStream<LogEvent>(stream)) {
57+
// Convert to AsyncIterable using parseSSEStream with local abort signal
58+
for await (const logEvent of parseSSEStream<LogEvent>(stream, abortController.signal)) {
5659
// Forward each typed event as SSE
5760
await writer.write(encoder.encode(`data: ${JSON.stringify(logEvent)}\n\n`));
5861
}
5962
} catch (error: any) {
60-
// Send error event
61-
await writer.write(encoder.encode(`data: ${JSON.stringify({
62-
type: 'error',
63-
timestamp: new Date().toISOString(),
64-
data: error.message,
65-
processId
66-
})}\n\n`));
63+
// Don't send error event for abort
64+
if (error.name === 'AbortError') {
65+
console.log('Stream aborted by client');
66+
} else {
67+
// Send error event
68+
await writer.write(encoder.encode(`data: ${JSON.stringify({
69+
type: 'error',
70+
timestamp: new Date().toISOString(),
71+
data: error.message,
72+
processId
73+
})}\n\n`));
74+
}
6775
} finally {
6876
await writer.close();
6977
}
7078
})();
7179

80+
// Handle client disconnect by aborting
81+
request.signal.addEventListener('abort', () => {
82+
abortController.abort();
83+
});
84+
7285
// Return stream with proper headers
7386
return new Response(readable, {
7487
headers: {

examples/basic/src/endpoints/processStart.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { errorResponse, jsonResponse, parseJsonBody } from "../http";
33

44
export async function startProcess(sandbox: Sandbox<unknown>, request: Request) {
55
const body = await parseJsonBody(request);
6-
const { command, processId, sessionId, timeout, env: envVars, cwd } = body;
6+
const { command, processId, timeout, env: envVars, cwd } = body;
77

88
if (!command) {
99
return errorResponse("Command is required");
@@ -12,7 +12,6 @@ export async function startProcess(sandbox: Sandbox<unknown>, request: Request)
1212
if (typeof sandbox.startProcess === 'function') {
1313
const process = await sandbox.startProcess(command, {
1414
processId,
15-
sessionId,
1615
timeout,
1716
env: envVars,
1817
cwd

examples/basic/src/index.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ export default {
108108
}
109109

110110
if (pathname.startsWith("/api/process/") && pathname.endsWith("/stream") && request.method === "GET") {
111-
return await streamProcessLogs(sandbox, pathname);
111+
return await streamProcessLogs(sandbox, pathname, request);
112112
}
113113

114114
if (pathname.startsWith("/api/process/") && request.method === "GET") {
@@ -351,18 +351,24 @@ result = x / y
351351
// Session Management APIs
352352
if (pathname === "/api/session/create" && request.method === "POST") {
353353
const body = await parseJsonBody(request);
354-
const sessionId = body.sessionId || `session_${Date.now()}_${generateSecureRandomString()}`;
354+
const { name, env, cwd, isolation = true } = body;
355355

356-
// Sessions are managed automatically by the SDK, just return the ID
357-
return jsonResponse(sessionId);
356+
const session = await sandbox.createSession({
357+
id: name || `session_${Date.now()}_${generateSecureRandomString()}`,
358+
env,
359+
cwd,
360+
isolation
361+
});
362+
363+
return jsonResponse({ sessionId: session.id });
358364
}
359365

360366
if (pathname.startsWith("/api/session/clear/") && request.method === "POST") {
361367
const sessionId = pathname.split("/").pop();
362368

363-
// In a real implementation, you might want to clean up session state
364-
// For now, just return success
365-
return jsonResponse({ message: "Session cleared", sessionId });
369+
// Note: The current SDK doesn't expose a direct session cleanup method
370+
// Sessions are automatically cleaned up by the container lifecycle
371+
return jsonResponse({ message: "Session cleanup initiated", sessionId });
366372
}
367373

368374
// Fallback: serve static assets for all other requests

packages/sandbox/Dockerfile

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ RUN apt-get update && apt-get install -y \
3333
python3-pip \
3434
python3.11-venv \
3535
# Other useful tools
36-
sudo \
3736
ca-certificates \
3837
gnupg \
3938
lsb-release \
@@ -43,13 +42,8 @@ RUN apt-get update && apt-get install -y \
4342
# Set Python 3.11 as default python3
4443
RUN update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.11 1
4544

46-
# Install Node.js 20 LTS
47-
# Using the official NodeSource repository setup script
48-
RUN apt-get update && apt-get install -y ca-certificates curl gnupg \
49-
&& mkdir -p /etc/apt/keyrings \
50-
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
51-
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list \
52-
&& apt-get update \
45+
# Install Node.js 20 LTS using official NodeSource setup script
46+
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
5347
&& apt-get install -y nodejs \
5448
&& rm -rf /var/lib/apt/lists/*
5549

@@ -73,7 +67,7 @@ RUN pip3 install --no-cache-dir \
7367
seaborn
7468

7569
# Install JavaScript kernel (ijavascript) - using E2B's fork
76-
RUN npm install -g --unsafe-perm git+https://github.com/e2b-dev/ijavascript.git \
70+
RUN npm install -g git+https://github.com/e2b-dev/ijavascript.git \
7771
&& ijsinstall --install=global
7872

7973
# Set up container server directory
@@ -93,6 +87,10 @@ RUN bun install
9387

9488
COPY container_src/ ./
9589

90+
# Compile TypeScript control process
91+
# Use npx -p typescript to ensure we get the right tsc command
92+
RUN npx -p typescript tsc control-process.ts --outDir . --module commonjs --target es2020 --esModuleInterop --skipLibCheck
93+
9694
# Create clean workspace directory for users
9795
RUN mkdir -p /workspace
9896

0 commit comments

Comments
 (0)