Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/justbash-forward-sandbox-options.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"eve": minor
---

Forward just-bash `SandboxOptions` through the `justbash()` backend via `JustBashSandboxCreateOptions`, and enable its bundled interpreters.

- Execution controls the backend previously hardcoded or omitted: `timeoutMs`, `maxCallDepth`, `maxCommandCount`, `maxLoopIterations`, and `defenseInDepth`.
- Bundled-interpreter capability flags `python` (stdlib-only CPython via WebAssembly) and `javascript` (js-exec via QuickJS), both off by default. Passing `justbash({ python: true })` now runs `python3` in the sandbox, closing #431.

Each field is optional and falls back to just-bash's own default when omitted, so existing apps are unaffected.

**Requires `just-bash@^3.1.0`** (the peer floor is raised from `^3.0.0`). This is load-bearing, not housekeeping: `python`/`javascript` only reach the interpreter through `Sandbox.create` as of just-bash 3.1.0 (vercel-labs/just-bash#284). On older just-bash they are silently dropped — `python: true` would no-op rather than error — so the version floor is the only guard against that silent failure.

The remaining just-bash capability flags (`commands`, `customCommands`, `fetch`) are intentionally not surfaced: their just-bash types cannot be honestly restated as primitives, and exposing them would put just-bash's types on eve's public API (the same reason `defenseInDepth` is narrowed to a plain boolean).
4 changes: 2 additions & 2 deletions packages/eve/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@
"gray-matter": "4.0.3",
"jose": "6.2.3",
"jsonc-parser": "3.3.1",
"just-bash": "3.0.1",
"just-bash": "3.1.0",
"microsandbox": "0.5.5",
"next": "catalog:",
"picocolors": "catalog:",
Expand All @@ -349,7 +349,7 @@
"@opentelemetry/api": "^1.0.0",
"ai": "catalog:",
"braintrust": "^3.0.0",
"just-bash": "^3.0.0",
"just-bash": "^3.1.0",
"microsandbox": "^0.5.0"
},
"peerDependenciesMeta": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { createJustBashSandboxBackend } from "#execution/sandbox/bindings/just-bash.js";
import { useTemporaryDirectories } from "#internal/testing/use-temporary-app-roots.js";

// Capture every `Sandbox.create(options)` call so we can assert which
// capability options the just-bash backend forwards. `vi.hoisted` runs
// before the `vi.mock` factory, so the array is in scope inside it.
const created = vi.hoisted(() => ({ options: [] as unknown[] }));

// A minimal functional stand-in for the optional `just-bash` package:
// just enough surface for `createBashSandbox` to build a sandbox handle
// without a real interpreter. `Sandbox.create` records its options.
vi.mock("just-bash", () => {
class ReadWriteFs {
async mkdir(): Promise<void> {}
async writeFile(): Promise<void> {}
async readFileBuffer(): Promise<Uint8Array> {
return new Uint8Array();
}
async rm(): Promise<void> {}
}
const Sandbox = {
create: vi.fn(async (options: unknown) => {
created.options.push(options);
return {
bashEnvInstance: { getEnv: () => ({}) },
async runCommand() {
return {};
},
async stop() {},
};
}),
};
return { ReadWriteFs, Sandbox };
});

const createScratchDirectory = useTemporaryDirectories();

beforeEach(() => {
created.options.length = 0;
});

afterEach(() => {
vi.clearAllMocks();
});

describe("just-bash backend forwards capability options to Sandbox.create", () => {
it("threads author-supplied SandboxOptions through to the interpreter", async () => {
const appRoot = await createScratchDirectory("eve-just-bash-options-");
const backend = createJustBashSandboxBackend({
createOptions: {
defenseInDepth: false,
javascript: true,
maxCallDepth: 64,
maxCommandCount: 5000,
maxLoopIterations: 100000,
python: true,
timeoutMs: 30000,
},
});

await backend.create({
runtimeContext: { appRoot },
sessionKey: "session-with-options",
templateKey: null,
});

expect(created.options).toHaveLength(1);
expect(created.options[0]).toMatchObject({
defenseInDepth: false,
// Bundled-interpreter capability flags (just-bash >= 3.1.0).
javascript: true,
maxCallDepth: 64,
maxCommandCount: 5000,
maxLoopIterations: 100000,
python: true,
timeoutMs: 30000,
// Untouched defaults the backend has always set.
network: { dangerouslyAllowFullInternetAccess: true },
});
});

it("leaves the Sandbox.create call unchanged when no options are passed", async () => {
const appRoot = await createScratchDirectory("eve-just-bash-no-options-");
const backend = createJustBashSandboxBackend();

await backend.create({
runtimeContext: { appRoot },
sessionKey: "session-without-options",
templateKey: null,
});

expect(created.options).toHaveLength(1);
const options = created.options[0] as Record<string, unknown>;
// Every capability field defaults to just-bash's own behavior, i.e.
// it is forwarded as `undefined` (equivalent to absent).
expect(options.defenseInDepth).toBeUndefined();
expect(options.javascript).toBeUndefined();
expect(options.maxCallDepth).toBeUndefined();
expect(options.maxCommandCount).toBeUndefined();
expect(options.maxLoopIterations).toBeUndefined();
expect(options.python).toBeUndefined();
expect(options.timeoutMs).toBeUndefined();
// The pre-existing hardcoded defaults remain.
expect(options.network).toEqual({ dangerouslyAllowFullInternetAccess: true });
});
});
41 changes: 41 additions & 0 deletions packages/eve/src/execution/sandbox/bindings/just-bash-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,37 @@ interface LocalSandboxMetadata {
readonly version: typeof LOCAL_SANDBOX_METADATA_VERSION;
}

/**
* Capability options forwarded into just-bash's
* `Sandbox.create(SandboxOptions)`. Defined locally (rather than imported
* from `just-bash`) because the package is an optional peer dependency —
* eve must type-check without it installed. The shape is a structural
* subset of just-bash's `SandboxOptions`, so the object assigns directly
* into the `Sandbox.create` call.
*
* Includes the bundled-interpreter capability flags `python` and
* `javascript`. These forward through `Sandbox.create` into the internal
* `Bash` as of just-bash 3.1.0 (vercel-labs/just-bash#284); on older
* just-bash they are silently dropped, which is why eve's peer dependency
* floor is `just-bash@^3.1.0`. Both are surfaced as plain booleans,
* matching the public `JustBashSandboxCreateOptions` and keeping just-bash's
* richer types (`JavaScriptConfig`, `CommandName`, …) off eve's surface.
*
* The remaining capability flags (`commands`, `customCommands`, `fetch`)
* are intentionally NOT surfaced: their just-bash types cannot be honestly
* restated as primitives, and importing them would put just-bash's types on
* eve's public API — see issue #431.
*/
export interface JustBashSandboxOptions {
readonly defenseInDepth?: boolean;
readonly javascript?: boolean;
readonly maxCallDepth?: number;
readonly maxCommandCount?: number;
readonly maxLoopIterations?: number;
readonly python?: boolean;
readonly timeoutMs?: number;
}

export interface BashSandbox {
captureState(): Promise<Record<string, unknown> | null>;
dispose(): Promise<void>;
Expand Down Expand Up @@ -75,6 +106,7 @@ export async function createBashSandbox(input: {
readonly appRoot: string;
readonly autoInstall: boolean;
readonly rootPath: string;
readonly sandboxOptions?: JustBashSandboxOptions;
readonly sessionKey: string;
}): Promise<BashSandbox> {
const { ReadWriteFs, Sandbox } = await loadJustBashModule({
Expand All @@ -97,11 +129,20 @@ export async function createBashSandbox(input: {

const sandbox = await Sandbox.create({
cwd: WORKSPACE_ROOT,
// Author-supplied capability options (undefined entries fall back to
// just-bash's own defaults, keeping behavior unchanged when omitted).
defenseInDepth: input.sandboxOptions?.defenseInDepth,
env: metadata?.env as Record<string, string> | undefined,
fs: filesystem,
javascript: input.sandboxOptions?.javascript,
maxCallDepth: input.sandboxOptions?.maxCallDepth,
maxCommandCount: input.sandboxOptions?.maxCommandCount,
maxLoopIterations: input.sandboxOptions?.maxLoopIterations,
network: {
dangerouslyAllowFullInternetAccess: true,
},
python: input.sandboxOptions?.python,
timeoutMs: input.sandboxOptions?.timeoutMs,
});

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,58 @@ describe("createLocalSandboxBackend with the just-bash engine", () => {
expect(existsSync(staleTemporaryRoot)).toBe(false);
});

it("runs python3 only when the python capability is enabled", async () => {
const disabledAppRoot = await createTemporaryCacheDirectory("python-disabled");
const disabledBackend = createJustBashSandboxBackend();
const disabledHandle = await disabledBackend.create({
runtimeContext: { appRoot: disabledAppRoot },
sessionKey: "session-python-disabled",
templateKey: null,
});
const disabled = await disabledHandle.session.run({ command: "python3 -c 'print(1 + 1)'" });
// Command-not-found: the interpreter is absent by default.
expect(disabled.exitCode).toBe(127);

const enabledAppRoot = await createTemporaryCacheDirectory("python-enabled");
const enabledBackend = createJustBashSandboxBackend({ createOptions: { python: true } });
const enabledHandle = await enabledBackend.create({
runtimeContext: { appRoot: enabledAppRoot },
sessionKey: "session-python-enabled",
templateKey: null,
});
const enabled = await enabledHandle.session.run({ command: "python3 -c 'print(1 + 1)'" });
expect(enabled.exitCode).toBe(0);
expect(enabled.stdout.trim()).toBe("2");
}, 60_000);

it("runs js-exec only when the javascript capability is enabled", async () => {
const disabledAppRoot = await createTemporaryCacheDirectory("js-disabled");
const disabledBackend = createJustBashSandboxBackend();
const disabledHandle = await disabledBackend.create({
runtimeContext: { appRoot: disabledAppRoot },
sessionKey: "session-js-disabled",
templateKey: null,
});
const disabled = await disabledHandle.session.run({
command: "js-exec -c 'console.log(1 + 1)'",
});
// Command-not-found: js-exec is absent by default.
expect(disabled.exitCode).toBe(127);

const enabledAppRoot = await createTemporaryCacheDirectory("js-enabled");
const enabledBackend = createJustBashSandboxBackend({ createOptions: { javascript: true } });
const enabledHandle = await enabledBackend.create({
runtimeContext: { appRoot: enabledAppRoot },
sessionKey: "session-js-enabled",
templateKey: null,
});
const enabled = await enabledHandle.session.run({
command: "js-exec -c 'console.log(1 + 1)'",
});
expect(enabled.exitCode).toBe(0);
expect(enabled.stdout.trim()).toBe("2");
}, 60_000);

it("touches a reused template so cleanup keeps the active template", async () => {
const appRoot = await createTemporaryCacheDirectory("template-touch");
const backend = createJustBashBackend();
Expand Down
16 changes: 16 additions & 0 deletions packages/eve/src/execution/sandbox/bindings/just-bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
createBashSandbox,
createJustBashHandle,
justBashSetNetworkPolicyUnsupported,
type JustBashSandboxOptions,
} from "#execution/sandbox/bindings/just-bash-runtime.js";
import {
LOCAL_SANDBOX_TEMPLATE_RECENT_WINDOW_MS,
Expand Down Expand Up @@ -64,6 +65,19 @@ export function createJustBashSandboxBackend(
input: CreateJustBashSandboxBackendInput = {},
): SandboxBackend {
const autoInstall = input.createOptions?.autoInstall ?? true;
// Capability options forwarded into just-bash's `Sandbox.create`
// (everything on the public surface except the eve-only `autoInstall`).
// Undefined fields are dropped so each one falls back to just-bash's
// own default and behavior stays unchanged when nothing is passed.
const sandboxOptions: JustBashSandboxOptions = {
defenseInDepth: input.createOptions?.defenseInDepth,
javascript: input.createOptions?.javascript,
maxCallDepth: input.createOptions?.maxCallDepth,
maxCommandCount: input.createOptions?.maxCommandCount,
maxLoopIterations: input.createOptions?.maxLoopIterations,
python: input.createOptions?.python,
timeoutMs: input.createOptions?.timeoutMs,
};
return {
name: JUST_BASH_BACKEND_NAME,
async prewarm(prewarmInput: SandboxBackendPrewarmInput): Promise<SandboxBackendPrewarmResult> {
Expand All @@ -81,6 +95,7 @@ export function createJustBashSandboxBackend(
appRoot: prewarmInput.runtimeContext.appRoot,
autoInstall,
rootPath: temporaryTemplateRootPath,
sandboxOptions,
sessionKey: prewarmInput.templateKey,
});
const templateSession = buildSandboxSession(
Expand Down Expand Up @@ -158,6 +173,7 @@ export function createJustBashSandboxBackend(
appRoot: createInput.runtimeContext.appRoot,
autoInstall,
rootPath: sessionRootPath,
sandboxOptions,
sessionKey: createInput.sessionKey,
});

Expand Down
71 changes: 71 additions & 0 deletions packages/eve/src/public/sandbox/just-bash-sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,28 @@
* interpreter with a virtual filesystem — no daemon or VM required, but
* no real binaries either. The `just-bash` package is not bundled with
* eve; it is loaded lazily from the application install.
*
* Beyond `autoInstall`, the remaining fields forward through to
* just-bash's `Sandbox.create(SandboxOptions)` call. They mirror the
* `SandboxOptions` surface that just-bash actually accepts at sandbox
* creation — execution limits, an overall timeout, and the
* defense-in-depth toggle. Each defaults to just-bash's own default
* (i.e. unchanged behavior) when omitted, so existing applications are
* unaffected.
*
* The `python` and `javascript` flags enable just-bash's bundled
* interpreters (`python3`, the `js-exec` QuickJS runtime). These forward
* through `Sandbox.create` into the underlying `Bash` as of just-bash 3.1.0
* (vercel-labs/just-bash#284); eve's peer floor is `just-bash@^3.1.0`
* because older releases silently drop them, so `python: true` would no-op
* rather than error.
*
* The remaining just-bash capability flags — a restricted `commands`
* allow-list, `customCommands`, and a custom `fetch` — are not exposed
* here: their just-bash types cannot be honestly restated as primitives,
* and importing them would leak just-bash's types onto eve's public API
* (which is why even `defenseInDepth` is narrowed to a plain boolean). See
* issue #431.
*/
export interface JustBashSandboxCreateOptions {
/**
Expand All @@ -14,4 +36,53 @@ export interface JustBashSandboxCreateOptions {
* actionable install error instead. Defaults to `true`.
*/
readonly autoInstall?: boolean;
/**
* Enable just-bash's bundled `python3`/`python` commands (stdlib-only
* CPython compiled to WebAssembly — no `pip`). Disabled by default;
* Python adds security surface (arbitrary code execution via CPython).
* Maps to just-bash `SandboxOptions.python`. Requires `just-bash@^3.1.0`
* — on older releases it silently no-ops.
*/
readonly python?: boolean;
/**
* Enable just-bash's bundled `js-exec` command (sandboxed JavaScript via
* QuickJS). Disabled by default. Maps to just-bash
* `SandboxOptions.javascript`; eve exposes only the boolean form (pass
* `true` to enable), not just-bash's richer `JavaScriptConfig` object.
* Requires `just-bash@^3.1.0` — on older releases it silently no-ops.
*/
readonly javascript?: boolean;
/**
* Overall wall-clock timeout for the sandbox, in milliseconds. Maps to
* just-bash `SandboxOptions.timeoutMs`. Omit to use just-bash's
* default (no eve-imposed timeout).
*/
readonly timeoutMs?: number;
/**
* Maximum function/subshell call depth before the interpreter aborts.
* Maps to just-bash `SandboxOptions.maxCallDepth`. Guards against
* runaway recursion. Omit to use just-bash's default.
*/
readonly maxCallDepth?: number;
/**
* Maximum number of commands a single script may execute before the
* interpreter aborts. Maps to just-bash `SandboxOptions.maxCommandCount`.
* Omit to use just-bash's default.
*/
readonly maxCommandCount?: number;
/**
* Maximum loop iterations before the interpreter aborts. Maps to
* just-bash `SandboxOptions.maxLoopIterations`. Omit to use just-bash's
* default.
*/
readonly maxLoopIterations?: number;
/**
* Defense-in-depth hardening: monkey-patches dangerous JavaScript
* globals (`Function`, `eval`, `setTimeout`, `process`, …) while a
* script runs, as a secondary escape-mitigation layer. Maps to
* just-bash `SandboxOptions.defenseInDepth`. just-bash enables this by
* default; pass `false` to opt out. Omit to use just-bash's default
* (enabled).
*/
readonly defenseInDepth?: boolean;
}
Loading