diff --git a/.changeset/justbash-forward-sandbox-options.md b/.changeset/justbash-forward-sandbox-options.md new file mode 100644 index 000000000..680a69906 --- /dev/null +++ b/.changeset/justbash-forward-sandbox-options.md @@ -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). diff --git a/packages/eve/package.json b/packages/eve/package.json index cda8fdbfd..a17fbb1f2 100644 --- a/packages/eve/package.json +++ b/packages/eve/package.json @@ -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:", @@ -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": { diff --git a/packages/eve/src/execution/sandbox/bindings/just-bash-capability-options.integration.test.ts b/packages/eve/src/execution/sandbox/bindings/just-bash-capability-options.integration.test.ts new file mode 100644 index 000000000..8391c8763 --- /dev/null +++ b/packages/eve/src/execution/sandbox/bindings/just-bash-capability-options.integration.test.ts @@ -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 {} + async writeFile(): Promise {} + async readFileBuffer(): Promise { + return new Uint8Array(); + } + async rm(): Promise {} + } + 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; + // 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 }); + }); +}); diff --git a/packages/eve/src/execution/sandbox/bindings/just-bash-runtime.ts b/packages/eve/src/execution/sandbox/bindings/just-bash-runtime.ts index 6d7205d72..33a262d22 100644 --- a/packages/eve/src/execution/sandbox/bindings/just-bash-runtime.ts +++ b/packages/eve/src/execution/sandbox/bindings/just-bash-runtime.ts @@ -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 | null>; dispose(): Promise; @@ -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 { const { ReadWriteFs, Sandbox } = await loadJustBashModule({ @@ -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 | 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 { diff --git a/packages/eve/src/execution/sandbox/bindings/just-bash.scenario.test.ts b/packages/eve/src/execution/sandbox/bindings/just-bash.scenario.test.ts index 5a895edac..4c70b259d 100644 --- a/packages/eve/src/execution/sandbox/bindings/just-bash.scenario.test.ts +++ b/packages/eve/src/execution/sandbox/bindings/just-bash.scenario.test.ts @@ -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(); diff --git a/packages/eve/src/execution/sandbox/bindings/just-bash.ts b/packages/eve/src/execution/sandbox/bindings/just-bash.ts index 9a123cfcd..c9bed6fb2 100644 --- a/packages/eve/src/execution/sandbox/bindings/just-bash.ts +++ b/packages/eve/src/execution/sandbox/bindings/just-bash.ts @@ -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, @@ -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 { @@ -81,6 +95,7 @@ export function createJustBashSandboxBackend( appRoot: prewarmInput.runtimeContext.appRoot, autoInstall, rootPath: temporaryTemplateRootPath, + sandboxOptions, sessionKey: prewarmInput.templateKey, }); const templateSession = buildSandboxSession( @@ -158,6 +173,7 @@ export function createJustBashSandboxBackend( appRoot: createInput.runtimeContext.appRoot, autoInstall, rootPath: sessionRootPath, + sandboxOptions, sessionKey: createInput.sessionKey, }); diff --git a/packages/eve/src/public/sandbox/just-bash-sandbox.ts b/packages/eve/src/public/sandbox/just-bash-sandbox.ts index 85aa6db12..dd4d7d805 100644 --- a/packages/eve/src/public/sandbox/just-bash-sandbox.ts +++ b/packages/eve/src/public/sandbox/just-bash-sandbox.ts @@ -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 { /** @@ -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; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f018a324b..3b23c6b0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -953,8 +953,8 @@ importers: specifier: 3.3.1 version: 3.3.1 just-bash: - specifier: 3.0.1 - version: 3.0.1 + specifier: 3.1.0 + version: 3.1.0 microsandbox: specifier: 0.5.5 version: 0.5.5 @@ -2629,9 +2629,6 @@ packages: cpu: [x64] os: [win32] - '@nodable/entities@2.1.1': - resolution: {integrity: sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==} - '@nodable/entities@2.2.0': resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==} @@ -9784,8 +9781,8 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} - just-bash@3.0.1: - resolution: {integrity: sha512-YVyzCN08fKarUnwqy7rKOAcX+2MLYLnYInuowmUXn3mqhrtd4ieZNBuzdQG+qYV9DqnIWuv9Whiph0WRIWsBtw==} + just-bash@3.1.0: + resolution: {integrity: sha512-/t28X8EH3+j/zvELGkNhlFV2g6hc3oHBzy6zPBnNq2nqUN0DGS9PsD889JfQ7F6ll08gqFoutiS+VJHK30wZpg==} hasBin: true katex@0.16.47: @@ -12026,9 +12023,6 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} - strnum@2.3.0: - resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} - strnum@2.4.0: resolution: {integrity: sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==} @@ -14628,8 +14622,6 @@ snapshots: '@next/swc-win32-x64-msvc@16.2.6': optional: true - '@nodable/entities@2.1.1': {} - '@nodable/entities@2.2.0': {} '@nodelib/fs.scandir@2.1.5': @@ -21327,10 +21319,10 @@ snapshots: fast-xml-parser@5.8.0: dependencies: - '@nodable/entities': 2.1.1 + '@nodable/entities': 2.2.0 fast-xml-builder: 1.2.0 path-expression-matcher: 1.5.0 - strnum: 2.3.0 + strnum: 2.4.0 xml-naming: 0.1.0 fastq@1.20.1: @@ -22449,7 +22441,7 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 - just-bash@3.0.1: + just-bash@3.1.0: dependencies: diff: 8.0.4 fast-xml-parser: 5.8.0 @@ -25771,8 +25763,6 @@ snapshots: dependencies: js-tokens: 9.0.1 - strnum@2.3.0: {} - strnum@2.4.0: dependencies: anynum: 1.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 89521aecc..08874bf7c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -55,6 +55,7 @@ minimumReleaseAgeExclude: - crossws - experimental-ai-sdk-code-mode - eve + - just-bash - nitro - rolldown - turbo