From da0beea431d9f0f1adcbfda8dfc8859f40f29c22 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 16 Oct 2026 23:27:13 -0700 Subject: [PATCH 01/14] feat(analyst): add sandboxed `python_exec` tool The `analyst` agent denies bash, so it has no general-purpose compute for parsing, statistics or row-wise text logic. Add an analyst-only `python_exec` tool. - Inputs are scratch tables exported to Parquet; `save(df, name)` writes DataFrames back to scratch after a successful call. - The child gets a minimal environment (no credentials or connection strings) and never sees warehouse connections. - Persistent, size-capped, session-private workspace; memory and time limits; network isolation where the OS allows it (`unshare`, `sandbox-exec`). - Registered only for `analyst` when Python with pandas and duckdb is found; `ALTIMATE_PYTHON_EXEC=0` disables it. Bash stays denied. Co-Authored-By: Claude Opus 5.5 --- packages/opencode/src/agent/agent.ts | 3 + .../opencode/src/altimate/prompts/analyst.txt | 2 + .../src/altimate/tools/python-exec.ts | 457 ++++++++++++++++++ packages/opencode/src/tool/registry.ts | 20 +- .../opencode/test/agent/python-exec.test.ts | 81 ++++ .../test/altimate/tools/python-exec.test.ts | 390 +++++++++++++++ .../tools/tool-schema-anthropic.test.ts | 3 + 7 files changed, 953 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/src/altimate/tools/python-exec.ts create mode 100644 packages/opencode/test/agent/python-exec.test.ts create mode 100644 packages/opencode/test/altimate/tools/python-exec.test.ts diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index feea76341b..65fe244d8c 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -329,6 +329,9 @@ export const layer = Layer.effect( "*": "deny", ...readOnlyDataPermission, answer_commit: "allow", + // altimate_change start — sandboxed Python is analyst-only, outside the shared data allow-list. + python_exec: "allow", + // altimate_change end webfetch: "allow", websearch: "allow", question: "allow", diff --git a/packages/opencode/src/altimate/prompts/analyst.txt b/packages/opencode/src/altimate/prompts/analyst.txt index aa2b9cb89e..dc976c0517 100644 --- a/packages/opencode/src/altimate/prompts/analyst.txt +++ b/packages/opencode/src/altimate/prompts/analyst.txt @@ -1,5 +1,7 @@ You are altimate-code: a read-only analyst. Do not modify source data or files. When available, `sql_materialize` may write session-local `scratch` tables. +Use `python_exec`, when available, for non-SQL computation such as parsing, statistics, row-wise text logic, and cross-table work after materializing the needed data to `scratch`. Its session-private workspace can hold intermediate files for later calls. + Inspect connections, schemas and actual rows before joining or parsing; key formats may differ across databases. Establish population, grain, denominator and assumptions. Every clause of the question must bite: a filter that removes nothing usually means the wrong column. When a phrase admits two readings (which population, denominator, transform or owner column), compute both, then choose by the question's own definitions and the data's shape; keep that reasoning out of the final message. Use `sql_check` for keys, fan-out, grain, nulls or extraction coverage when they affect the answer; trivial queries need no ritual. `sql_analyze` is optional. Derive large intermediate sets in-engine. For a cross-engine join, move the smaller side: a few hundred keys go into an IN list or VALUES against the big table; materialize into `scratch` only the side that is small after filtering, never a multi-million-row table when a filter or aggregate could shrink it first. Preserve requested weighting: group means weighted by contributing counts can be exact; unweighted averages change the weighting. For many similar tables, prefer catalog-driven SQL (information_schema, DuckDB query_table/UNION ALL BY NAME) after confirming dialect support. diff --git a/packages/opencode/src/altimate/tools/python-exec.ts b/packages/opencode/src/altimate/tools/python-exec.ts new file mode 100644 index 0000000000..7f2b999688 --- /dev/null +++ b/packages/opencode/src/altimate/tools/python-exec.ts @@ -0,0 +1,457 @@ +/** + * `python_exec` — run analyst Python against copies of scratch tables. + * + * Python never receives warehouse connections or credentials: inputs are scratch tables + * exported to Parquet in a session-private working directory, and DataFrames saved by the + * code are imported back into scratch. The child gets a minimal environment, a memory + * limit, a deadline, and no network where the platform can enforce it (Linux network + * namespace via `unshare`, macOS `sandbox-exec`). + */ +import z from "zod" +import { spawn, spawnSync } from "node:child_process" +import * as fs from "node:fs/promises" +import * as path from "node:path" +import * as os from "node:os" +import { Tool } from "../../tool/tool" +import { withScratchConnection } from "../native/connections/scratch" +import { quoteIdent } from "./sql-materialize" + +export const DEFAULT_TIMEOUT_MS = 120_000 +export const MAX_TIMEOUT_MS = 600_000 +export const OUTPUT_LIMIT = 30_000 +export const WORKSPACE_LIMIT_BYTES = 1024 ** 3 +const MEMORY_LIMIT_BYTES = 8 * 1024 ** 3 +const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/ + +const HELPER = `"""Scratch table exchange for python_exec.""" +import os as _os +import re as _re +import duckdb as _duckdb + +_HERE = _os.path.dirname(_os.path.abspath(__file__)) +workspace = _os.getcwd() + + +def _name(name): + if not isinstance(name, str) or not _re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,62}", name): + raise ValueError("Expected a scratch table name (letters, digits, underscores; no schema prefix)") + return name + + +def load(name): + """Return input table \`name\` (passed in \`inputs\`) as a pandas DataFrame.""" + with _duckdb.connect() as con: + return con.execute("SELECT * FROM read_parquet(?)", [_os.path.join(_HERE, 'in', _name(name) + '.parquet')]).df() + + +def save(df, name): + """Store a pandas DataFrame as scratch table \`name\` after a successful call (replacing it).""" + file = _os.path.join(_HERE, 'out', _name(name) + '.parquet') + with _duckdb.connect() as con: + con.register("_frame", df) + con.execute("COPY _frame TO ? (FORMAT parquet)", [file]) +` + +const BOOTSTRAP = `import os, resource, runpy, sys, threading, time +def limit(kind): + soft, hard = resource.getrlimit(kind) + ceiling = ${MEMORY_LIMIT_BYTES} + if hard != resource.RLIM_INFINITY: + ceiling = min(ceiling, hard) + if soft != resource.RLIM_INFINITY: + ceiling = min(ceiling, soft) + resource.setrlimit(kind, (ceiling, ceiling)) +try: + limit(resource.RLIMIT_AS) +except (ValueError, OSError): + try: + limit(resource.RLIMIT_DATA) + print("Memory limit: data segment plus RSS watchdog (address-space limit unavailable).", file=sys.stderr) + except (ValueError, OSError): + print("Memory limit: cooperative RSS watchdog only (OS limits unavailable).", file=sys.stderr) +else: + print("Memory limit: address space.", file=sys.stderr) + +# Some macOS hosts reject setrlimit for both memory resources. Keep a best-effort +# watchdog there, and report the weaker protection instead of silently running unlimited. +# Native code holding the GIL can delay this check; it is not an OS security boundary. +def watch_memory(): + while True: + rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss + if sys.platform != "darwin": + rss *= 1024 + if rss > ${MEMORY_LIMIT_BYTES}: + os.write(2, b"Python memory limit exceeded; process killed.\\n") + os._exit(137) + time.sleep(0.1) + +threading.Thread(target=watch_memory, daemon=True).start() +sys.path.insert(0, sys.argv[2]) +sys.argv = [sys.argv[1]] +runpy.run_path(sys.argv[0], run_name="__main__") +` + +export type Isolation = { prefix: string[]; network: "isolated" | "not isolated" } + +let isolation: Isolation | undefined + +/** Pick the strongest available no-network wrapper once per process. */ +export function networkIsolation(): Isolation { + if (isolation) return isolation + const works = (cmd: string[]) => { + try { + return spawnSync(cmd[0], [...cmd.slice(1), "true"], { stdio: "ignore", timeout: 5000 }).status === 0 + } catch { + return false + } + } + const candidates: string[][] = + process.platform === "linux" + ? [ + ["unshare", "--net"], + ["unshare", "--user", "--map-root-user", "--net"], + ] + : process.platform === "darwin" + ? [["sandbox-exec", "-p", "(version 1)(allow default)(deny network*)"]] + : [] + const found = candidates.find(works) + isolation = found ? { prefix: found, network: "isolated" } : { prefix: [], network: "not isolated" } + return isolation +} + +let discovery: { key: string; result: Promise } | undefined + +/** Resolve version-manager shims, then verify dependencies with the actual child environment. */ +export function interpreter(): Promise { + const name = process.env.ALTIMATE_PYTHON?.trim() || "python3" + const key = JSON.stringify([name, process.env.PATH, process.env.PYENV_ROOT]) + if (discovery?.key === key) return discovery.result + const result = (async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "altimate-python-probe-")) + try { + // Only locating Python uses the host HOME/PATH, so pyenv/asdf shims can resolve. + // Never inherit credentials, and never run analyst code in this probe. + const resolve = spawnSync(Bun.which(name) ?? name, ["-I", "-c", "import sys; print(sys.executable)"], { + env: { + ...childEnv(directory), + HOME: os.homedir(), + PATH: process.env.PATH ?? "/usr/bin:/bin", + ...(process.env.PYENV_ROOT ? { PYENV_ROOT: process.env.PYENV_ROOT } : {}), + }, + encoding: "utf8", + timeout: 5000, + }) + const executable = resolve.status === 0 ? resolve.stdout.trim() : "" + if (!executable || !path.isAbsolute(executable)) return + const probe = spawnSync(executable, ["-I", "-c", "import pandas, duckdb"], { + cwd: directory, + env: childEnv(directory), + stdio: "ignore", + timeout: 10000, + }) + return probe.status === 0 ? executable : undefined + } finally { + await fs.rm(directory, { recursive: true, force: true }) + } + })().catch(() => undefined) + discovery = { key, result } + return result +} + +export async function pythonExecAvailable(): Promise { + return process.env.ALTIMATE_PYTHON_EXEC !== "0" && (await interpreter()) !== undefined +} + +/** Minimal child environment: no credentials, tokens or connection strings from the parent. */ +export function childEnv(workdir: string): Record { + return { + PATH: "/usr/local/bin:/usr/bin:/bin", + HOME: workdir, + TMPDIR: path.join(workdir, "tmp"), + LANG: "C.UTF-8", + LC_ALL: "C.UTF-8", + PYTHONDONTWRITEBYTECODE: "1", + PYTHONUNBUFFERED: "1", + MPLBACKEND: "Agg", + OMP_NUM_THREADS: "2", + OPENBLAS_NUM_THREADS: "2", + } +} + +export function clip(text: string, limit = OUTPUT_LIMIT): { text: string; clipped: boolean } { + if (text.length <= limit) return { text, clipped: false } + const head = Math.floor(limit * 0.6) + const tail = limit - head + return { + text: `${text.slice(0, head)}\n... [${text.length - limit} characters omitted] ...\n${text.slice(-tail)}`, + clipped: true, + } +} + +export function clampTimeout(ms: number | undefined): number { + if (!ms || !Number.isFinite(ms) || ms <= 0) return DEFAULT_TIMEOUT_MS + return Math.min(Math.max(1000, Math.floor(ms)), MAX_TIMEOUT_MS) +} + +/** Logical sizes also count sparse files; never traverse links outside the private directory. */ +async function workspaceSize(directory: string): Promise { + let bytes = 0 + const entries = await fs.readdir(directory, { withFileTypes: true }).catch((error) => { + if (error.code === "ENOENT") return [] // A running cell may remove a temporary directory. + throw error + }) + for (const entry of entries) { + const file = path.join(directory, entry.name) + const stat = await fs.lstat(file).catch((error) => { + if (error.code === "ENOENT") return undefined // A running cell may remove files. + throw error + }) + if (!stat) continue + if (stat.isSymbolicLink()) throw new Error("Python workspace cannot contain symbolic links.") + if (stat.isDirectory()) bytes += await workspaceSize(file) + else bytes += stat.size + if (bytes > WORKSPACE_LIMIT_BYTES) + throw new Error(`Python workspace size cap exceeded (${WORKSPACE_LIMIT_BYTES} bytes).`) + } + return bytes +} + +type RunResult = { + code: number | null + signal: string | null + stdout: string + stderr: string + timedOut: boolean + workspaceError?: string +} + +function run( + cmd: string[], + cwd: string, + env: Record, + timeoutMs: number, + abort: AbortSignal, + privateDirectory: string, +) { + return new Promise((resolve) => { + const child = spawn(cmd[0], cmd.slice(1), { cwd, env, detached: true, stdio: ["ignore", "pipe", "pipe"] }) + let stdout = "" + let stderr = "" + let timedOut = false + let workspaceError: string | undefined + const cap = OUTPUT_LIMIT * 20 + child.stdout.on("data", (d) => { + if (stdout.length < cap) stdout += d.toString().slice(0, cap - stdout.length) + }) + child.stderr.on("data", (d) => { + if (stderr.length < cap) stderr += d.toString().slice(0, cap - stderr.length) + }) + const kill = () => { + try { + if (child.pid) process.kill(-child.pid, "SIGKILL") + } catch { + child.kill("SIGKILL") + } + } + const timer = setTimeout(() => { + timedOut = true + kill() + }, timeoutMs) + const onAbort = () => kill() + abort.addEventListener("abort", onAbort, { once: true }) + if (abort.aborted) kill() + // Check the whole Python directory, including temporary exports and output, so + // repeated calls cannot bypass the workspace budget through HOME or TMPDIR. + let checking: Promise | undefined + const monitor = setInterval(() => { + if (checking) return + checking = workspaceSize(privateDirectory) + .then( + () => {}, + (error) => { + workspaceError = error instanceof Error ? error.message : String(error) + kill() + }, + ) + .finally(() => { + checking = undefined + }) + }, 100) + child.on("error", (e) => { + stderr += `\n${e.message}` + }) + // A cell may spawn children and exit without waiting. They must not outlive it. + child.on("exit", kill) + child.on("close", async (code, signal) => { + clearTimeout(timer) + clearInterval(monitor) + abort.removeEventListener("abort", onAbort) + await checking + resolve({ code, signal, stdout, stderr, timedOut, workspaceError }) + }) + }) +} + +export const PythonExecTool = Tool.define("python_exec", { + description: + "Run Python for non-SQL computation over scratch data; no warehouse connections or inherited credentials. " + + "Network access is blocked where supported; the result reports isolation. Materialize source data to scratch first. " + + "List scratch tables in inputs, then `from altimate_io import load, save, workspace`: " + + "`load('table')` returns a pandas DataFrame; `save(df, 'name')` replaces that scratch table after a successful call. " + + "The working directory (also `workspace`) is a size-capped, session-private path: files persist across calls " + + "and are deleted with the session; exceeding the cap clears the workspace. " + + "Each call starts a fresh process with pandas and duckdb available. " + + "Print results to see them; long output is clipped. Omit timeout_ms to use the configured default.", + parameters: z.object({ + code: z.string().describe("Python source to run"), + inputs: z.array(z.string()).optional().describe("Scratch tables available to load(); omit for none"), + timeout_ms: z.number().optional().describe("Deadline for this call; omit to use the configured default"), + }), + async execute(args, ctx) { + const failure = (message: string) => ({ title: "Python: ERROR", metadata: { ok: false }, output: message }) + if (ctx.agent !== "analyst") return failure("python_exec is available only to the analyst agent.") + if (process.env.ALTIMATE_PYTHON_EXEC === "0") return failure("python_exec is disabled by ALTIMATE_PYTHON_EXEC=0.") + const inputs = [...new Set((args.inputs ?? []).map((s) => s.trim()).filter(Boolean))] + const bad = inputs.find((t) => !IDENTIFIER.test(t)) + if (bad) + return failure(`Input "${bad}" is not a scratch table name (letters, digits, underscores; no schema prefix).`) + if (!args.code.trim()) return failure("No code to run.") + if (ctx.abort.aborted) return failure("Python call aborted before execution.") + await ctx.ask({ permission: "python_exec", patterns: ["*"], always: ["*"], metadata: {} }) + const python = await interpreter() + if (!python) return failure("python_exec unavailable: no Python with pandas and duckdb was discovered.") + const timeoutMs = clampTimeout(args.timeout_ms) + const iso = networkIsolation() + try { + // Hold the session's scratch lock through execution and import. Concurrent cells + // (including subagent calls sharing a root) cannot race the persistent workspace. + return await withScratchConnection(async (scratch, directory) => { + if (ctx.abort.aborted) return failure("Python call aborted before execution.") + const privateDirectory = path.join(directory, "python") + const workspace = path.join(privateDirectory, "workspace") + await fs.mkdir(workspace, { recursive: true, mode: 0o700 }) + const resetWorkspace = async () => { + await fs.rm(privateDirectory, { recursive: true, force: true }) + await fs.mkdir(workspace, { recursive: true, mode: 0o700 }) + } + try { + await workspaceSize(privateDirectory) + } catch (error) { + await resetWorkspace() + return failure(`${error instanceof Error ? error.message : String(error)} Workspace cleared; retry the call.`) + } + const callDirectory = await fs.mkdtemp(path.join(privateDirectory, "call-")) + try { + for (const name of ["in", "out", "tmp"]) await fs.mkdir(path.join(callDirectory, name)) + await fs.writeFile(path.join(callDirectory, "altimate_io.py"), HELPER) + await fs.writeFile(path.join(callDirectory, "_bootstrap.py"), BOOTSTRAP) + const cell = path.join(callDirectory, "cell.py") + await fs.writeFile(cell, args.code) + for (const table of inputs) { + const file = path.join(callDirectory, "in", `${table}.parquet`).replaceAll("'", "''") + await scratch.execute( + `COPY (SELECT * FROM ${quoteIdent(table)}) TO '${file}' (FORMAT parquet)`, + undefined, + undefined, + { noLimit: true }, + ) + } + try { + await workspaceSize(privateDirectory) + } catch (error) { + await resetWorkspace() + return failure( + `${error instanceof Error ? error.message : String(error)} Workspace cleared; reduce the inputs and retry.`, + ) + } + const started = Date.now() + const result = await run( + [...iso.prefix, python, "-I", path.join(callDirectory, "_bootstrap.py"), cell, callDirectory], + workspace, + { ...childEnv(workspace), TMPDIR: path.join(callDirectory, "tmp") }, + timeoutMs, + ctx.abort, + privateDirectory, + ) + const elapsed = Date.now() - started + try { + await workspaceSize(privateDirectory) + } catch (error) { + result.workspaceError ??= error instanceof Error ? error.message : String(error) + } + if (result.workspaceError) await resetWorkspace() + + const imported: string[] = [] + const importErrors: string[] = [] + // Only publish complete, successful calls; never import partial files after a kill. + if (result.code === 0 && !result.timedOut && !ctx.abort.aborted && !result.workspaceError) { + const outDir = path.join(callDirectory, "out") + const files = await fs.readdir(outDir, { withFileTypes: true }) + for (const file of files) { + if (!file.isFile() || !file.name.endsWith(".parquet") || !IDENTIFIER.test(file.name.slice(0, -8))) + continue + const name = file.name.slice(0, -8) + const location = path.join(outDir, file.name).replaceAll("'", "''") + try { + await scratch.execute( + `CREATE OR REPLACE TABLE ${quoteIdent(name)} AS SELECT * FROM read_parquet('${location}')`, + undefined, + undefined, + { noLimit: true }, + ) + const n = (await scratch.execute(`SELECT count(*) FROM ${quoteIdent(name)}`, 1)).rows[0]?.[0] + imported.push(`scratch.${name} (${n} rows)`) + } catch (error) { + importErrors.push(`${name}: ${error instanceof Error ? error.message : String(error)}`) + } + } + } + const ok = + result.code === 0 && + !result.timedOut && + !ctx.abort.aborted && + !result.workspaceError && + !importErrors.length + const status = result.timedOut + ? `timed out after ${Math.round(timeoutMs / 1000)} s (process killed)` + : ctx.abort.aborted + ? "aborted (process killed)" + : ok + ? `exit 0 in ${(elapsed / 1000).toFixed(1)} s` + : `failed (exit ${result.code ?? result.signal})` + const stdout = clip(result.stdout) + const stderr = clip(result.stderr, OUTPUT_LIMIT / 3) + const parts = [ + `Python ${status}.`, + `Isolation: network ${iso.network}; minimal environment; filesystem access is not OS-isolated.`, + `Workspace: ${workspace}`, + ] + if (result.workspaceError) parts.push(`${result.workspaceError} Workspace cleared; retry with smaller files.`) + if (imported.length) parts.push(`Saved to scratch: ${imported.join(", ")}.`) + if (importErrors.length) parts.push(`Could not import: ${importErrors.join("; ")}`) + if (stdout.text) parts.push(`stdout:\n${stdout.text}`) + if (stderr.text) parts.push(`stderr:\n${stderr.text}`) + if (!stdout.text && !stderr.text) parts.push("(no output; print the values you need)") + return { + title: `Python: ${ok ? "ok" : "failed"}${imported.length ? `, ${imported.length} table(s) saved` : ""}`, + metadata: { + ok, + exitCode: result.code, + timedOut: result.timedOut, + elapsedMs: elapsed, + network: iso.network, + workspace, + workspaceError: result.workspaceError, + imported, + }, + output: parts.join("\n\n"), + } + } finally { + await fs.rm(callDirectory, { recursive: true, force: true }) + } + }, ctx.sessionID) + } catch (error) { + return failure(`Python execution failed: ${error instanceof Error ? error.message : String(error)}`) + } + }, +}) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 426755ea44..31218f9068 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -65,6 +65,9 @@ import { pathToFileURL } from "url" // altimate_change start - import custom data engineering tools import { SqlExecuteTool } from "../altimate/tools/sql-execute" import { SqlMaterializeTool } from "../altimate/tools/sql-materialize" +// altimate_change start — analyst-only Python runtime availability. +import { PythonExecTool, pythonExecAvailable } from "../altimate/tools/python-exec" +// altimate_change end import { SqlCheckTool } from "../altimate/tools/sql-check" import { AnswerCommit } from "../altimate/tools/answer-commit" import { SchemaInspectTool } from "../altimate/tools/schema-inspect" @@ -541,9 +544,11 @@ export namespace ToolRegistry { ) // altimate_change end // altimate_change end - // altimate_change start — reserve the finalizer ID from file/plugin tools even when - // its native candidate is disabled or this catalogue is used without an analyst. - return [...resolved, ...custom, ...pluginCustom].filter((tool) => !Wildcard.match(tool.id, "answer_commit")) + // altimate_change start — reserve analyst-only IDs from file/plugin tools even when + // their native candidates are disabled or this catalogue is used without an analyst. + return [...resolved, ...custom, ...pluginCustom].filter( + (tool) => !Wildcard.match(tool.id, "answer_commit") && !Wildcard.match(tool.id, "python_exec"), + ) // altimate_change end } @@ -584,6 +589,15 @@ export namespace ToolRegistry { tools.push(await AppRuntime.runPromise(AnswerCommit.AnswerCommitTool)) } // altimate_change end + // altimate_change start — expose sandboxed Python only to analysts with a usable runtime. + if (agent?.name === "analyst" && process.env.ALTIMATE_PYTHON_EXEC !== "0") { + if (await pythonExecAvailable()) { + tools.push(await AppRuntime.runPromise(PythonExecTool)) + } else { + log.debug("python_exec not registered: no Python with pandas and duckdb is available") + } + } + // altimate_change end const result = await Promise.all( tools .filter((t) => { diff --git a/packages/opencode/test/agent/python-exec.test.ts b/packages/opencode/test/agent/python-exec.test.ts new file mode 100644 index 0000000000..c1bb18c312 --- /dev/null +++ b/packages/opencode/test/agent/python-exec.test.ts @@ -0,0 +1,81 @@ +// altimate_change start — analyst-only Python registration and unchanged shell denial. +import { afterEach, beforeEach, expect, spyOn } from "bun:test" +import { Effect, Layer } from "effect" +import { Agent } from "../../src/agent/agent" +import * as PythonExec from "../../src/altimate/tools/python-exec" +import { Permission } from "../../src/permission" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { ToolRegistry } from "../../src/tool/registry" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.mergeAll(Agent.defaultLayer, ToolRegistry.layer)) +const model = { providerID: ProviderID.opencode, modelID: ModelID.make("test") } +let previousToggle: string | undefined +let available: ReturnType> + +beforeEach(() => { + previousToggle = process.env.ALTIMATE_PYTHON_EXEC + delete process.env.ALTIMATE_PYTHON_EXEC + available = spyOn(PythonExec, "pythonExecAvailable").mockResolvedValue(true) +}) + +afterEach(() => { + available.mockRestore() + if (previousToggle === undefined) delete process.env.ALTIMATE_PYTHON_EXEC + else process.env.ALTIMATE_PYTHON_EXEC = previousToggle +}) + +it.instance("analyst receives python_exec by default while bash stays denied", () => + Effect.gen(function* () { + const agents = yield* Agent.Service + const registry = yield* ToolRegistry.Service + const analyst = yield* agents.get("analyst") + expect(Permission.evaluate("python_exec", "*", analyst.permission).action).toBe("allow") + expect(Permission.disabled(["bash"], analyst.permission)).toEqual(new Set(["bash"])) + expect(Permission.evaluate("bash", "python3 script.py", analyst.permission).action).toBe("deny") + const tools = yield* registry.tools({ ...model, agent: analyst }) + expect(tools.filter((tool) => tool.id === "python_exec")).toHaveLength(1) + }), +) + +it.instance("python_exec is not registered when Python dependencies are unavailable", () => + Effect.gen(function* () { + available.mockResolvedValue(false) + const agents = yield* Agent.Service + const registry = yield* ToolRegistry.Service + const analyst = yield* agents.get("analyst") + const tools = yield* registry.tools({ ...model, agent: analyst }) + expect(tools.map((tool) => tool.id)).not.toContain("python_exec") + expect(available).toHaveBeenCalled() + }), +) + +it.instance("ALTIMATE_PYTHON_EXEC=0 removes Python and can be toggled back on", () => + Effect.gen(function* () { + const agents = yield* Agent.Service + const registry = yield* ToolRegistry.Service + const analyst = yield* agents.get("analyst") + process.env.ALTIMATE_PYTHON_EXEC = "0" + expect((yield* registry.tools({ ...model, agent: analyst })).map((tool) => tool.id)).not.toContain("python_exec") + expect(available).not.toHaveBeenCalled() + process.env.ALTIMATE_PYTHON_EXEC = "1" + expect((yield* registry.tools({ ...model, agent: analyst })).map((tool) => tool.id)).toContain("python_exec") + }), +) + +it.instance("Python is absent for other agents and from the tool_lookup catalogue", () => + Effect.gen(function* () { + const agents = yield* Agent.Service + const registry = yield* ToolRegistry.Service + for (const name of ["builder", "data-explorer", "reviewer", "plan"]) { + const agent = yield* agents.get(name) + expect((yield* registry.tools({ ...model, agent })).map((tool) => tool.id)).not.toContain("python_exec") + } + expect((yield* registry.tools(model)).map((tool) => tool.id)).not.toContain("python_exec") + expect(yield* registry.ids()).not.toContain("python_exec") + expect(available).not.toHaveBeenCalled() + const explorer = yield* agents.get("data-explorer") + expect(Permission.evaluate("python_exec", "*", explorer.permission).action).toBe("deny") + }), +) +// altimate_change end diff --git a/packages/opencode/test/altimate/tools/python-exec.test.ts b/packages/opencode/test/altimate/tools/python-exec.test.ts new file mode 100644 index 0000000000..d3490aac10 --- /dev/null +++ b/packages/opencode/test/altimate/tools/python-exec.test.ts @@ -0,0 +1,390 @@ +// altimate_change start — sandboxed analyst Python execution regression coverage +import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test" +import { spawnSync } from "node:child_process" +import * as fs from "node:fs/promises" +import * as path from "node:path" +import { SessionID, MessageID } from "../../../src/session/schema" +import { + PythonExecTool, + childEnv, + clampTimeout, + clip, + networkIsolation, + pythonExecAvailable, + DEFAULT_TIMEOUT_MS, + MAX_TIMEOUT_MS, + WORKSPACE_LIMIT_BYTES, +} from "../../../src/altimate/tools/python-exec" +import { getScratch, closeScratch } from "../../../src/altimate/native/connections/scratch" +import { initTool } from "../tool-fixture" +import { tmpdir } from "../../fixture/fixture" + +const ctx = { + sessionID: SessionID.make("ses_python_exec"), + messageID: MessageID.make("msg_python_exec"), + callID: "", + agent: "analyst", + abort: AbortSignal.any([]), + messages: [] as any[], + metadata: () => {}, + ask: async () => {}, +} + +// Resolve version-manager shims before the tool removes the parent's HOME and PATH. +const probe = spawnSync( + process.env.ALTIMATE_PYTHON || "python3", + ["-I", "-c", "import duckdb, pandas, sys; print(sys.executable)"], + { + encoding: "utf8", + timeout: 10_000, + }, +) +const python = probe.status === 0 ? probe.stdout.trim() : "" +const e2e = python ? test : test.skip + +let previous: Record = {} + +beforeAll(() => { + previous = { + ALTIMATE_PYTHON: process.env.ALTIMATE_PYTHON, + ALTIMATE_PYTHON_EXEC: process.env.ALTIMATE_PYTHON_EXEC, + ALTIMATE_TELEMETRY_DISABLED: process.env.ALTIMATE_TELEMETRY_DISABLED, + PYEXEC_TEST_SECRET: process.env.PYEXEC_TEST_SECRET, + AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY, + DATABASE_URL: process.env.DATABASE_URL, + } + if (python) process.env.ALTIMATE_PYTHON = python + delete process.env.ALTIMATE_PYTHON_EXEC + process.env.ALTIMATE_TELEMETRY_DISABLED = "true" + process.env.PYEXEC_TEST_SECRET = "python-exec-test-do-not-leak" + process.env.AWS_SECRET_ACCESS_KEY = "python-exec-test-fake-credential" + process.env.DATABASE_URL = "postgres://python-exec-test-fake-connection" +}) + +afterEach(async () => { + await closeScratch() +}) + +afterAll(() => { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } +}) + +describe("python_exec helpers", () => { + test("discovery reports an unavailable interpreter", async () => { + const original = process.env.ALTIMATE_PYTHON + await using directory = await tmpdir() + try { + process.env.ALTIMATE_PYTHON = path.join(directory.path, "missing-python") + expect(await pythonExecAvailable()).toBe(false) + } finally { + if (original === undefined) delete process.env.ALTIMATE_PYTHON + else process.env.ALTIMATE_PYTHON = original + } + }) + + e2e( + "discovery requires pandas and duckdb in the isolated interpreter", + async () => { + const original = process.env.ALTIMATE_PYTHON + await using directory = await tmpdir() + const setup = spawnSync(python, ["-m", "venv", "--without-pip", directory.path], { + encoding: "utf8", + timeout: 10_000, + }) + expect(setup.status).toBe(0) + try { + process.env.ALTIMATE_PYTHON = path.join( + directory.path, + process.platform === "win32" ? "Scripts/python.exe" : "bin/python", + ) + expect(await pythonExecAvailable()).toBe(false) + } finally { + if (original === undefined) delete process.env.ALTIMATE_PYTHON + else process.env.ALTIMATE_PYTHON = original + } + }, + 15_000, + ) + + e2e( + "discovery accepts the configured interpreter and respects the disable flag", + async () => { + const original = process.env.ALTIMATE_PYTHON_EXEC + try { + delete process.env.ALTIMATE_PYTHON_EXEC + expect(await pythonExecAvailable()).toBe(true) + process.env.ALTIMATE_PYTHON_EXEC = "0" + expect(await pythonExecAvailable()).toBe(false) + } finally { + if (original === undefined) delete process.env.ALTIMATE_PYTHON_EXEC + else process.env.ALTIMATE_PYTHON_EXEC = original + } + }, + 15_000, + ) + + test("child environment carries no parent variables", () => { + const env = childEnv("/tmp/ws") + expect(env.HOME).toBe("/tmp/ws") + expect(env).not.toHaveProperty("DATABASE_URL") + expect(Object.keys(env).some((key) => /AWS|TOKEN|KEY|SECRET|PASSWORD/.test(key))).toBe(false) + }) + + test("timeouts default when empty or invalid and clamp to the ceiling", () => { + for (const value of [undefined, 0, -1, NaN, Infinity]) expect(clampTimeout(value)).toBe(DEFAULT_TIMEOUT_MS) + expect(clampTimeout(10 * MAX_TIMEOUT_MS)).toBe(MAX_TIMEOUT_MS) + expect(clampTimeout(5)).toBe(1000) + expect(clampTimeout(1234.5)).toBe(1234) + }) + + test("long output keeps head and tail", () => { + const result = clip("a".repeat(100) + "b".repeat(100), 50) + expect(result.clipped).toBe(true) + expect(result.text.startsWith("a")).toBe(true) + expect(result.text.endsWith("b")).toBe(true) + expect(clip("short", 50)).toEqual({ text: "short", clipped: false }) + }) +}) + +describe("python_exec", () => { + e2e( + "loads a scratch table and saves a DataFrame back to the same session", + async () => { + const scratch = await getScratch(ctx.sessionID) + await scratch.execute( + "CREATE TABLE src AS SELECT * FROM (VALUES (1, 'Alpha Beta'), (2, 'gamma')) t(id, txt)", + undefined, + undefined, + { + noLimit: true, + }, + ) + const tool = await initTool(PythonExecTool) + const result = await tool.execute( + { + code: [ + "from altimate_io import load, save", + "df = load('src')", + "df['words'] = df['txt'].str.split().str.len()", + "save(df, 'parsed')", + "print(int(df['words'].sum()))", + ].join("\n"), + inputs: ["src"], + }, + ctx, + ) + expect(result.metadata.ok).toBe(true) + expect(result.output).toContain("3") + expect(result.output).toContain("scratch.parsed (2 rows)") + expect(result.output).toContain(result.metadata.network) + const rows = (await scratch.execute("SELECT id, words FROM parsed ORDER BY id")).rows + expect(rows.map((row) => Number(row[1]))).toEqual([2, 1]) + }, + 15_000, + ) + + e2e( + "persists workspace files across calls in one session", + async () => { + const tool = await initTool(PythonExecTool) + const first = await tool.execute( + { code: "import pickle\nwith open('intermediate.pkl', 'wb') as f:\n pickle.dump({'answer': 42}, f)" }, + ctx, + ) + expect(first.metadata.ok).toBe(true) + expect(first.metadata.workspace).toEndWith(path.join("python", "workspace")) + const second = await tool.execute( + { + code: "import os, pickle\nwith open('intermediate.pkl', 'rb') as f:\n print(pickle.load(f)['answer'])\nprint(os.getcwd())", + }, + ctx, + ) + expect(second.metadata.ok).toBe(true) + expect(second.metadata.workspace).toBe(first.metadata.workspace) + expect(second.output).toContain("42") + expect(second.output).toContain(first.metadata.workspace) + }, + 15_000, + ) + + e2e( + "keeps different sessions' workspaces separate and removes files when the session closes", + async () => { + const tool = await initTool(PythonExecTool) + const first = await tool.execute( + { code: "with open('private.txt', 'w') as f:\n f.write('session-private')" }, + ctx, + ) + expect(first.metadata.ok).toBe(true) + const other = { ...ctx, sessionID: SessionID.make("ses_python_exec_other") } + const second = await tool.execute( + { code: "from pathlib import Path\nprint(Path('private.txt').exists())" }, + other, + ) + expect(second.metadata.ok).toBe(true) + expect(second.metadata.workspace).not.toBe(first.metadata.workspace) + expect(second.output).toContain("False") + expect(await fs.readFile(path.join(first.metadata.workspace, "private.txt"), "utf8")).toBe("session-private") + await closeScratch(ctx.sessionID) + await expect(fs.stat(first.metadata.workspace)).rejects.toHaveProperty("code", "ENOENT") + expect((await fs.stat(second.metadata.workspace)).isDirectory()).toBe(true) + }, + 15_000, + ) + + e2e( + "reports the workspace size cap and permits a later call after clearing oversized files", + async () => { + const tool = await initTool(PythonExecTool) + // truncate creates a sparse file: exercise the real cap without allocating gigabytes. + const result = await tool.execute( + { code: `with open('oversized.bin', 'wb') as f:\n f.truncate(${WORKSPACE_LIMIT_BYTES + 1})` }, + ctx, + ) + expect(result.metadata.ok).toBe(false) + expect(result.output).toMatch(/workspace.*(?:size|limit)|(?:size|limit).*workspace/i) + expect(result.output).toMatch(/exceed|limit/i) + const next = await tool.execute({ code: "print('workspace recovered')" }, ctx) + expect(next.metadata.ok).toBe(true) + expect(next.output).toContain("workspace recovered") + await expect(fs.stat(path.join(next.metadata.workspace, "oversized.bin"))).rejects.toHaveProperty( + "code", + "ENOENT", + ) + }, + 15_000, + ) + + e2e( + "does not expose parent credentials, connection strings, or other environment variables", + async () => { + const tool = await initTool(PythonExecTool) + const result = await tool.execute({ code: "import os\nprint(dict(os.environ))", inputs: [] }, ctx) + expect(result.metadata.ok).toBe(true) + for (const value of ["PYEXEC_TEST_SECRET", "AWS_SECRET_ACCESS_KEY", "DATABASE_URL", "python-exec-test-"]) { + expect(result.output).not.toContain(value) + } + }, + 15_000, + ) + + e2e( + "does not retain undeclared scratch inputs from an earlier call", + async () => { + const scratch = await getScratch(ctx.sessionID) + await scratch.execute("CREATE TABLE previous_input AS SELECT 42 AS answer", undefined, undefined, { + noLimit: true, + }) + const tool = await initTool(PythonExecTool) + const first = await tool.execute( + { code: "from altimate_io import load\nprint(load('previous_input'))", inputs: ["previous_input"] }, + ctx, + ) + expect(first.metadata.ok).toBe(true) + const second = await tool.execute( + { code: "from altimate_io import load\nload('previous_input')", inputs: [] }, + ctx, + ) + expect(second.metadata.ok).toBe(false) + expect(second.output).toContain("previous_input") + }, + 15_000, + ) + + e2e( + "rejects path traversal in load and save table names", + async () => { + const tool = await initTool(PythonExecTool) + const result = await tool.execute( + { + code: [ + "import pandas as pd", + "from altimate_io import load, save", + "for operation in (lambda: load('../escape'), lambda: save(pd.DataFrame({'id': [1]}), '../escape')):", + " try:", + " operation()", + " except ValueError:", + " print('REJECTED')", + ].join("\n"), + }, + ctx, + ) + expect(result.metadata.ok).toBe(true) + expect(result.output.match(/REJECTED/g)).toHaveLength(2) + }, + 15_000, + ) + + e2e( + "kills code that runs past its deadline", + async () => { + const tool = await initTool(PythonExecTool) + const result = await tool.execute({ code: "import time\ntime.sleep(30)", inputs: [], timeout_ms: 1000 }, ctx) + expect(result.metadata.ok).toBe(false) + expect(result.metadata.timedOut).toBe(true) + expect(result.output).toContain("timed out") + }, + 15_000, + ) + + e2e( + "does not execute code when the call is already aborted", + async () => { + const tool = await initTool(PythonExecTool) + const result = await tool.execute( + { code: "print('ABORTED_CODE_EXECUTED')" }, + { ...ctx, abort: AbortSignal.abort() }, + ) + expect(result.metadata.ok).toBe(false) + expect(result.output).not.toContain("ABORTED_CODE_EXECUTED") + }, + 15_000, + ) + + e2e( + "reports isolation and blocks network access where isolation is available", + async () => { + const isolation = networkIsolation() + const tool = await initTool(PythonExecTool) + const code = + isolation.network === "isolated" + ? "import socket\ntry:\n socket.create_connection(('1.1.1.1', 53), timeout=1)\n print('CONNECTED')\nexcept OSError:\n print('BLOCKED')" + : "print('network isolation unavailable')" + const result = await tool.execute({ code, inputs: [] }, ctx) + expect(result.metadata.ok).toBe(true) + expect(result.metadata.network).toBe(isolation.network) + expect(result.output).toContain(isolation.network) + if (isolation.network === "isolated") expect(result.output).toContain("BLOCKED") + }, + 15_000, + ) + + test("rejects input names that are not scratch tables", async () => { + const tool = await initTool(PythonExecTool) + const result = await tool.execute({ code: "print(1)", inputs: ["main.src; DROP"] }, ctx) + expect(result.metadata.ok).toBe(false) + expect(result.output).toContain("not a scratch table name") + }) + + test("rejects empty code", async () => { + const tool = await initTool(PythonExecTool) + const result = await tool.execute({ code: " \n", inputs: [] }, ctx) + expect(result.metadata.ok).toBe(false) + expect(result.output).toContain("No code to run") + }) + + e2e( + "reports a Python error with its traceback", + async () => { + const tool = await initTool(PythonExecTool) + const result = await tool.execute({ code: "raise ValueError('boom')", inputs: [] }, ctx) + expect(result.metadata.ok).toBe(false) + expect(result.output).toContain("ValueError: boom") + }, + 15_000, + ) +}) +// altimate_change end diff --git a/packages/opencode/test/altimate/tools/tool-schema-anthropic.test.ts b/packages/opencode/test/altimate/tools/tool-schema-anthropic.test.ts index 0c8a9c25cc..cdb2fc9ecf 100644 --- a/packages/opencode/test/altimate/tools/tool-schema-anthropic.test.ts +++ b/packages/opencode/test/altimate/tools/tool-schema-anthropic.test.ts @@ -15,6 +15,7 @@ import { ToolRegistry } from "@/tool/registry" import { TestConfig } from "../../fixture/config" import { disposeAllInstances } from "../../fixture/fixture" import { testEffect } from "../../lib/effect" +import { pythonExecAvailable } from "../../../src/altimate/tools/python-exec" const fixture = (enableParallel: boolean) => testEffect( @@ -146,6 +147,8 @@ describe("Anthropic tool JSON schemas", () => { const tools = yield* registry.tools({ providerID, modelID: target.id, agent }) expect(tools.map((tool) => tool.id)).toEqual(expect.arrayContaining(["sql_check", "sql_execute", "read"])) expect(tools.some((tool) => tool.id === "answer_commit")).toBe(name === "analyst") + const hasPython = yield* Effect.promise(() => pythonExecAvailable()) + expect(tools.some((tool) => tool.id === "python_exec")).toBe(name === "analyst" && hasPython) // Match prompt.ts / session/tools.ts conversion. Both SDK providers forward this schema unchanged. // Also retain the legacy LLM.stream permission-filtered insertion order for offline diagnostics. From 54ab3dfcfb7aca179e9b49ad834c8f525347bca5 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 16 Oct 2026 23:31:07 -0700 Subject: [PATCH 02/14] fix(analyst): confine `python_exec` writes to the session workspace on macOS Independent review found that the analyst's Python child could write anywhere the user can. On macOS the `sandbox-exec` profile now also denies `file-write*` outside the session's Python directory (plus null/tty devices). Reads and the Linux path are unchanged. The result text states which confinement applied, and a test covers the write denial. Co-Authored-By: Claude Opus 5.5 --- .../src/altimate/tools/python-exec.ts | 18 ++++++++++++++++-- .../test/altimate/tools/python-exec.test.ts | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/altimate/tools/python-exec.ts b/packages/opencode/src/altimate/tools/python-exec.ts index 7f2b999688..d1e90f0b3a 100644 --- a/packages/opencode/src/altimate/tools/python-exec.ts +++ b/packages/opencode/src/altimate/tools/python-exec.ts @@ -119,6 +119,20 @@ export function networkIsolation(): Isolation { return isolation } +/** + * On macOS, also confine writes to the session's Python directory (plus the null/tty devices), + * so a cell cannot modify the user's files. Reads stay unrestricted. Linux relies on the + * network namespace only. + */ +export function confineWrites(iso: Isolation, writeRoot: string): string[] { + if (iso.prefix[0] !== "sandbox-exec") return iso.prefix + const root = writeRoot.replaceAll("\\", "\\\\").replaceAll('"', '\\"') + const profile = + "(version 1)(allow default)(deny network*)" + + `(deny file-write* (require-not (require-any (subpath "${root}") (literal "/dev/null") (literal "/dev/dtracehelper") (regex #"^/dev/tty"))))` + return ["sandbox-exec", "-p", profile] +} + let discovery: { key: string; result: Promise } | undefined /** Resolve version-manager shims, then verify dependencies with the actual child environment. */ @@ -366,7 +380,7 @@ export const PythonExecTool = Tool.define("python_exec", { } const started = Date.now() const result = await run( - [...iso.prefix, python, "-I", path.join(callDirectory, "_bootstrap.py"), cell, callDirectory], + [...confineWrites(iso, await fs.realpath(privateDirectory)), python, "-I", path.join(callDirectory, "_bootstrap.py"), cell, callDirectory], workspace, { ...childEnv(workspace), TMPDIR: path.join(callDirectory, "tmp") }, timeoutMs, @@ -423,7 +437,7 @@ export const PythonExecTool = Tool.define("python_exec", { const stderr = clip(result.stderr, OUTPUT_LIMIT / 3) const parts = [ `Python ${status}.`, - `Isolation: network ${iso.network}; minimal environment; filesystem access is not OS-isolated.`, + `Isolation: network ${iso.network}; minimal environment; reads are not OS-isolated${iso.prefix[0] === "sandbox-exec" ? "; writes are confined to the workspace" : "; writes are not OS-confined"}.`, `Workspace: ${workspace}`, ] if (result.workspaceError) parts.push(`${result.workspaceError} Workspace cleared; retry with smaller files.`) diff --git a/packages/opencode/test/altimate/tools/python-exec.test.ts b/packages/opencode/test/altimate/tools/python-exec.test.ts index d3490aac10..861bbe7638 100644 --- a/packages/opencode/test/altimate/tools/python-exec.test.ts +++ b/packages/opencode/test/altimate/tools/python-exec.test.ts @@ -2,6 +2,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test" import { spawnSync } from "node:child_process" import * as fs from "node:fs/promises" +import * as os from "node:os" import * as path from "node:path" import { SessionID, MessageID } from "../../../src/session/schema" import { @@ -362,6 +363,24 @@ describe("python_exec", () => { 15_000, ) + e2e( + "confines writes to the session workspace where the OS allows it", + async () => { + const confined = networkIsolation().prefix[0] === "sandbox-exec" + const outside = path.join(os.homedir(), `.pyexec-write-probe-${process.pid}`) + const tool = await initTool(PythonExecTool) + const code = `import os\ntry:\n open(${JSON.stringify(outside)}, 'w').write('x')\n print('WROTE')\nexcept OSError:\n print('DENIED')\nopen('inside.txt', 'w').write('ok')\nprint('INSIDE_OK')` + try { + const result = await tool.execute({ code, inputs: [] }, ctx) + expect(result.output).toContain("INSIDE_OK") + expect(result.output).toContain(confined ? "DENIED" : "WROTE") + } finally { + await fs.rm(outside, { force: true }) + } + }, + 15_000, + ) + test("rejects input names that are not scratch tables", async () => { const tool = await initTool(PythonExecTool) const result = await tool.execute({ code: "print(1)", inputs: ["main.src; DROP"] }, ctx) From 511585788fd392446f0e4d022af96d40cd50f1d2 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 16 Oct 2026 23:58:34 -0700 Subject: [PATCH 03/14] fix(analyst): confine `python_exec` reads and fail closed without a sandbox Analyst Python could read any host file and spawn unconfined subprocesses; on Linux nothing was confined. `python_exec` now runs only under an enforced OS sandbox and refuses otherwise. - New `python-sandbox.ts`: read allowlist built from the interpreter (`sys.prefix`, base/exec prefixes, stdlib, `site.getsitepackages()`, `sysconfig` paths, directories of loaded native libraries) plus minimal system paths; roots exposing `/` or the home directory are refused. - macOS: `sandbox-exec` profile denies file reads and writes outside the allowlist, network, Apple Events and Mach services outside a small list. - Linux: `bwrap` with `--unshare-all`, `--die-with-parent`, `--new-session`, `--cap-drop ALL`, fresh `/tmp`, `/dev`, `/proc`, read-only binds of the allowlist, merged-usr symlinks, and a writable bind of the session dir. Without a working `bwrap` the tool refuses (Landlock alone does not mediate pathname Unix socket connects). - Windows and other platforms: refused. - Each mechanism must pass a self-test before use: Python and a `cat` subprocess fail to read a canary outside the allowlist, while reading inside works. Refusals are not cached. - Results report `filesystem ` and `network isolated`. - Interpreter discovery and detection no longer block the event loop. - Reserve `python_exec` from MCP tools; drop nested registry markers. - Tests: host-file and subprocess read denial, pandas/duckdb round trip, refusal when detection fails, fake sandbox rejected by the self-test, exact bwrap argv, macOS profile, allowlist rules, MCP reservation. Co-Authored-By: Claude Opus 5.5 --- .../src/altimate/tools/python-exec.ts | 114 ++--- .../src/altimate/tools/python-sandbox.ts | 406 ++++++++++++++++++ packages/opencode/src/mcp/index.ts | 6 +- packages/opencode/src/tool/registry.ts | 2 - .../test/altimate/tools/python-exec.test.ts | 354 ++++++++++++++- packages/opencode/test/mcp/lifecycle.test.ts | 15 + 6 files changed, 818 insertions(+), 79 deletions(-) create mode 100644 packages/opencode/src/altimate/tools/python-sandbox.ts diff --git a/packages/opencode/src/altimate/tools/python-exec.ts b/packages/opencode/src/altimate/tools/python-exec.ts index d1e90f0b3a..430d2da6f1 100644 --- a/packages/opencode/src/altimate/tools/python-exec.ts +++ b/packages/opencode/src/altimate/tools/python-exec.ts @@ -4,17 +4,19 @@ * Python never receives warehouse connections or credentials: inputs are scratch tables * exported to Parquet in a session-private working directory, and DataFrames saved by the * code are imported back into scratch. The child gets a minimal environment, a memory - * limit, a deadline, and no network where the platform can enforce it (Linux network - * namespace via `unshare`, macOS `sandbox-exec`). + * limit, a deadline, and OS confinement (see `python-sandbox.ts`): reads are limited to the + * session directory, the interpreter installation and required system files, writes to the + * session directory, and network is blocked. Without an enforced mechanism the tool refuses. */ import z from "zod" -import { spawn, spawnSync } from "node:child_process" +import { spawn } from "node:child_process" import * as fs from "node:fs/promises" import * as path from "node:path" import * as os from "node:os" import { Tool } from "../../tool/tool" import { withScratchConnection } from "../native/connections/scratch" import { quoteIdent } from "./sql-materialize" +import * as Sandbox from "./python-sandbox" export const DEFAULT_TIMEOUT_MS = 120_000 export const MAX_TIMEOUT_MS = 600_000 @@ -52,7 +54,7 @@ def save(df, name): con.execute("COPY _frame TO ? (FORMAT parquet)", [file]) ` -const BOOTSTRAP = `import os, resource, runpy, sys, threading, time +export const BOOTSTRAP = `import os, resource, runpy, sys, threading, time def limit(kind): soft, hard = resource.getrlimit(kind) ceiling = ${MEMORY_LIMIT_BYTES} @@ -91,46 +93,32 @@ sys.argv = [sys.argv[1]] runpy.run_path(sys.argv[0], run_name="__main__") ` -export type Isolation = { prefix: string[]; network: "isolated" | "not isolated" } - -let isolation: Isolation | undefined - -/** Pick the strongest available no-network wrapper once per process. */ -export function networkIsolation(): Isolation { - if (isolation) return isolation - const works = (cmd: string[]) => { - try { - return spawnSync(cmd[0], [...cmd.slice(1), "true"], { stdio: "ignore", timeout: 5000 }).status === 0 - } catch { - return false - } - } - const candidates: string[][] = - process.platform === "linux" - ? [ - ["unshare", "--net"], - ["unshare", "--user", "--map-root-user", "--net"], - ] - : process.platform === "darwin" - ? [["sandbox-exec", "-p", "(version 1)(allow default)(deny network*)"]] - : [] - const found = candidates.find(works) - isolation = found ? { prefix: found, network: "isolated" } : { prefix: [], network: "not isolated" } - return isolation -} +let confinement: { python: string; result: Promise } | undefined /** - * On macOS, also confine writes to the session's Python directory (plus the null/tty devices), - * so a cell cannot modify the user's files. Reads stay unrestricted. Linux relies on the - * network namespace only. + * Detect and self-test the confinement mechanism once per interpreter and process. A refusal is + * not cached, so a transient failure (a slow first import) does not disable the tool until restart. */ -export function confineWrites(iso: Isolation, writeRoot: string): string[] { - if (iso.prefix[0] !== "sandbox-exec") return iso.prefix - const root = writeRoot.replaceAll("\\", "\\\\").replaceAll('"', '\\"') - const profile = - "(version 1)(allow default)(deny network*)" + - `(deny file-write* (require-not (require-any (subpath "${root}") (literal "/dev/null") (literal "/dev/dtracehelper") (regex #"^/dev/tty"))))` - return ["sandbox-exec", "-p", profile] +export function confinementFor(python: string): Promise { + if (confinement?.python === python) return confinement.result + const result = Sandbox.detectConfinement({ python, bootstrap: BOOTSTRAP, env: childEnv }) + .catch( + (error): Sandbox.Detection => ({ + ok: false, + reason: `python_exec refused: confinement detection failed (${error instanceof Error ? error.message : String(error)}).`, + }), + ) + .then((detection) => { + if (!detection.ok && confinement?.result === result) confinement = undefined + return detection + }) + confinement = { python, result } + return result +} + +/** Forget the cached detection (tests). */ +export function resetConfinement() { + confinement = undefined } let discovery: { key: string; result: Promise } | undefined @@ -145,22 +133,23 @@ export function interpreter(): Promise { try { // Only locating Python uses the host HOME/PATH, so pyenv/asdf shims can resolve. // Never inherit credentials, and never run analyst code in this probe. - const resolve = spawnSync(Bun.which(name) ?? name, ["-I", "-c", "import sys; print(sys.executable)"], { - env: { - ...childEnv(directory), - HOME: os.homedir(), - PATH: process.env.PATH ?? "/usr/bin:/bin", - ...(process.env.PYENV_ROOT ? { PYENV_ROOT: process.env.PYENV_ROOT } : {}), + const resolve = await Sandbox.execute( + [Bun.which(name) ?? name, "-I", "-c", "import sys; print(sys.executable)"], + { + env: { + ...childEnv(directory), + HOME: os.homedir(), + PATH: process.env.PATH ?? "/usr/bin:/bin", + ...(process.env.PYENV_ROOT ? { PYENV_ROOT: process.env.PYENV_ROOT } : {}), + }, + timeout: 5000, }, - encoding: "utf8", - timeout: 5000, - }) + ) const executable = resolve.status === 0 ? resolve.stdout.trim() : "" if (!executable || !path.isAbsolute(executable)) return - const probe = spawnSync(executable, ["-I", "-c", "import pandas, duckdb"], { + const probe = await Sandbox.execute([executable, "-I", "-c", "import pandas, duckdb"], { cwd: directory, env: childEnv(directory), - stdio: "ignore", timeout: 10000, }) return probe.status === 0 ? executable : undefined @@ -309,7 +298,9 @@ function run( export const PythonExecTool = Tool.define("python_exec", { description: "Run Python for non-SQL computation over scratch data; no warehouse connections or inherited credentials. " + - "Network access is blocked where supported; the result reports isolation. Materialize source data to scratch first. " + + "Runs in an OS sandbox: it can read only its working directory and the Python installation, write only its working directory, " + + "and has no network; the result reports the confinement used. It refuses to run where the host cannot enforce this. " + + "Materialize source data to scratch first. " + "List scratch tables in inputs, then `from altimate_io import load, save, workspace`: " + "`load('table')` returns a pandas DataFrame; `save(df, 'name')` replaces that scratch table after a successful call. " + "The working directory (also `workspace`) is a size-capped, session-private path: files persist across calls " + @@ -334,8 +325,10 @@ export const PythonExecTool = Tool.define("python_exec", { await ctx.ask({ permission: "python_exec", patterns: ["*"], always: ["*"], metadata: {} }) const python = await interpreter() if (!python) return failure("python_exec unavailable: no Python with pandas and duckdb was discovered.") + const detected = await confinementFor(python) + if (!detected.ok) return failure(detected.reason) + const sandbox = detected.confinement const timeoutMs = clampTimeout(args.timeout_ms) - const iso = networkIsolation() try { // Hold the session's scratch lock through execution and import. Concurrent cells // (including subagent calls sharing a root) cannot race the persistent workspace. @@ -380,7 +373,13 @@ export const PythonExecTool = Tool.define("python_exec", { } const started = Date.now() const result = await run( - [...confineWrites(iso, await fs.realpath(privateDirectory)), python, "-I", path.join(callDirectory, "_bootstrap.py"), cell, callDirectory], + Sandbox.launch({ + confinement: sandbox, + write: [await fs.realpath(privateDirectory)], + cwd: workspace, + python, + args: [path.join(callDirectory, "_bootstrap.py"), cell, callDirectory], + }), workspace, { ...childEnv(workspace), TMPDIR: path.join(callDirectory, "tmp") }, timeoutMs, @@ -437,7 +436,7 @@ export const PythonExecTool = Tool.define("python_exec", { const stderr = clip(result.stderr, OUTPUT_LIMIT / 3) const parts = [ `Python ${status}.`, - `Isolation: network ${iso.network}; minimal environment; reads are not OS-isolated${iso.prefix[0] === "sandbox-exec" ? "; writes are confined to the workspace" : "; writes are not OS-confined"}.`, + `Isolation: filesystem ${sandbox.filesystem} (reads limited to the workspace and Python installation; writes to the workspace); network ${sandbox.network}; minimal environment.`, `Workspace: ${workspace}`, ] if (result.workspaceError) parts.push(`${result.workspaceError} Workspace cleared; retry with smaller files.`) @@ -453,7 +452,8 @@ export const PythonExecTool = Tool.define("python_exec", { exitCode: result.code, timedOut: result.timedOut, elapsedMs: elapsed, - network: iso.network, + filesystem: sandbox.filesystem, + network: sandbox.network, workspace, workspaceError: result.workspaceError, imported, diff --git a/packages/opencode/src/altimate/tools/python-sandbox.ts b/packages/opencode/src/altimate/tools/python-sandbox.ts new file mode 100644 index 0000000000..ad1501160d --- /dev/null +++ b/packages/opencode/src/altimate/tools/python-sandbox.ts @@ -0,0 +1,406 @@ +/** + * OS confinement for `python_exec`. + * + * Analyst Python may read only its session directory, the interpreter installation and + * the system files an interpreter needs; it may write only its session directory. + * Subprocesses inherit the same confinement. Where no enforced mechanism exists the tool + * refuses to run instead of running unconfined. + * + * - macOS: `sandbox-exec` profile that denies file reads and writes outside the allowlist, + * network, Apple Events and Mach services outside a small allowlist (LaunchServices could + * otherwise start unconfined helper processes). + * - Linux: `bwrap` with every namespace unshared and only the allowlist mounted. Without a + * working `bwrap` the tool refuses. Landlock alone is not used: it does not mediate + * `connect()` to pathname Unix sockets (Docker, D-Bus, SSH agent), which can reach host files. + * - Other platforms: refused. + * + * Every mechanism must pass a self-test (a sandboxed interpreter and a subprocess fail to read + * a canary file outside the allowlist) before it is used. + */ +import { spawn } from "node:child_process" +import { existsSync, lstatSync, readlinkSync, realpathSync } from "node:fs" +import * as fs from "node:fs/promises" +import * as os from "node:os" +import * as path from "node:path" + +export type Filesystem = "sandbox-exec" | "bwrap" +export type Confinement = { + filesystem: Filesystem + network: "isolated" + /** Sandbox executable that wraps the interpreter command. */ + prefix: string[] + read: ReadAllowlist +} +/** + * Resolved directories (subpaths) and files the interpreter may read, plus system paths that + * are symlinks into them (merged-usr `/bin`, `/lib64`), which bwrap recreates as symlinks. + */ +export type ReadAllowlist = { directories: string[]; files: string[]; links: string[] } +export type Detection = { ok: true; confinement: Confinement } | { ok: false; reason: string } + +/** Imports that load the native libraries analyst code commonly needs, so their directories are allowlisted. */ +const PROBE = `import json, os, site, sys, sysconfig +for name in ("pandas", "duckdb", "numpy", "pyarrow", "ssl", "sqlite3", "decimal", "hashlib", "zlib", "lzma", "bz2", "zoneinfo", "ctypes", "multiprocessing", "subprocess", "csv", "json"): + try: + __import__(name) + except Exception: + pass +roots = {sys.prefix, sys.base_prefix, sys.exec_prefix, sys.base_exec_prefix, os.path.dirname(os.path.realpath(sys.executable))} +roots.update(site.getsitepackages()) +roots.update(value for key, value in sysconfig.get_paths().items() if key in ("stdlib", "platstdlib", "purelib", "platlib")) +libraries = [] +if sys.platform == "darwin": + import ctypes + dyld = ctypes.CDLL(None) + dyld._dyld_get_image_name.restype = ctypes.c_char_p + libraries = [dyld._dyld_get_image_name(i).decode() for i in range(dyld._dyld_image_count())] +elif sys.platform.startswith("linux"): + with open("/proc/self/maps") as maps: + libraries = sorted({line.split(None, 5)[5].strip() for line in maps if len(line.split(None, 5)) == 6 and line.split(None, 5)[5].startswith("/")}) +print(json.dumps({"roots": sorted(r for r in roots if r), "libraries": libraries})) +` + +/** System paths the interpreter needs. Directories are subpaths; the rest are single files. */ +const SYSTEM = { + darwin: { + directories: [ + "/usr/lib", + "/usr/share", + "/System", + "/bin", + "/usr/bin", + "/private/var/db/timezone", + "/private/var/db/dyld", + ], + files: [ + "/", + "/dev/null", + "/dev/zero", + "/dev/random", + "/dev/urandom", + "/dev/dtracehelper", + "/private/etc/localtime", + ], + }, + linux: { + directories: [ + "/usr", + "/bin", + "/lib", + "/lib32", + "/lib64", + "/etc/ld.so.conf.d", + "/etc/alternatives", + "/sys/devices/system/cpu", + ], + files: ["/etc/ld.so.cache", "/etc/ld.so.conf", "/etc/localtime"], + }, +} as const + +/** Mach services macOS Python needs; everything else (LaunchServices, Apple Events, ...) is denied. */ +const MACH_SERVICES = [ + "com.apple.system.logger", + "com.apple.system.notification_center", + "com.apple.system.opendirectoryd.libinfo", +] + +function real(file: string): string | undefined { + try { + return realpathSync(file) + } catch { + return undefined + } +} + +function isLink(file: string): boolean { + try { + return lstatSync(file).isSymbolicLink() + } catch { + return false + } +} + +function within(child: string, parent: string): boolean { + if (parent === "/") return true + return child === parent || child.startsWith(parent.endsWith("/") ? parent : parent + "/") +} + +/** Sorted, de-duplicated paths with nested entries removed. */ +export function collapse(paths: string[]): string[] { + const sorted = [...new Set(paths)].sort((a, b) => a.length - b.length || a.localeCompare(b)) + const kept: string[] = [] + for (const p of sorted) if (!kept.some((parent) => within(p, parent))) kept.push(p) + return kept.sort() +} + +/** + * Read allowlist: interpreter roots, directories of loaded native libraries outside the + * system directories, and system paths that exist. A root that would expose the filesystem + * root or the home directory is rejected. + */ +export function readAllowlist( + platform: NodeJS.Platform, + probe: { roots: string[]; libraries: string[] }, + options: { + home?: string + resolve?: (file: string) => string | undefined + link?: (file: string) => boolean + } = {}, +): ({ ok: true } & ReadAllowlist) | { ok: false; reason: string } { + const resolve = options.resolve ?? real + const link = options.link ?? isLink + const home = options.home ?? os.homedir() + const system = platform === "darwin" ? SYSTEM.darwin : SYSTEM.linux + const systemDirectories = system.directories.flatMap((d) => resolve(d) ?? []) + const roots = probe.roots.flatMap((r) => resolve(r) ?? []) + for (const root of roots) { + if (root === "/" || (home && within(home, root))) + return { + ok: false, + reason: `the Python installation path ${root} would expose the filesystem root or home directory`, + } + } + // Only shared objects and frameworks: never a directory of an arbitrary mapped file (e.g. /dev/shm). + const library = platform === "darwin" ? /\.(dylib|so)$|\.framework\// : /\.so(\.[0-9]+)*$/ + const libraries = probe.libraries + .flatMap((lib) => resolve(lib) ?? []) + .filter((lib) => library.test(lib)) + .filter((lib) => ![...roots, ...systemDirectories].some((dir) => within(lib, dir))) + .map((lib) => path.dirname(lib)) + for (const dir of libraries) { + if (dir === "/" || (home && within(home, dir))) + return { + ok: false, + reason: `the native library directory ${dir} would expose the filesystem root or home directory`, + } + } + const files = system.files.filter((f) => f === "/" || resolve(f) !== undefined) + const directories = collapse([...systemDirectories, ...roots, ...libraries]) + const links = [...system.directories, ...files].filter((p) => p !== "/" && resolve(p) !== undefined && link(p)) + const targets = files.filter((f) => link(f)).flatMap((f) => resolve(f) ?? []) + return { + ok: true, + directories, + files: [...new Set([...files.filter((f) => !link(f)), ...targets])].filter( + (f) => f === "/" || !directories.some((dir) => within(f, dir)), + ), + links: links.sort(), + } +} + +function quote(p: string): string { + return `"${p.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"` +} + +function ancestors(p: string): string[] { + const out: string[] = [] + for (let dir = path.dirname(p); ; dir = path.dirname(dir)) { + out.push(dir) + if (dir === path.dirname(dir)) return out + } +} + +/** macOS Seatbelt profile: allow-default, then deny reads, writes, network and IPC outside the allowlist. */ +export function sandboxProfile(read: ReadAllowlist, write: string[]): string { + const readable = [ + ...read.directories.map((d) => `(subpath ${quote(d)})`), + ...read.files.map((f) => `(literal ${quote(f)})`), + `(regex #"^/dev/tty")`, + ...write.map((d) => `(subpath ${quote(d)})`), + ] + // Path resolution stats each ancestor; metadata reveals no file contents or directory listings. + const metadata = [ + ...new Set([...read.directories, ...write, "/etc", "/var", "/tmp"].flatMap((p) => [p, ...ancestors(p)])), + ].map((p) => `(literal ${quote(p)})`) + const writable = [ + ...write.map((d) => `(subpath ${quote(d)})`), + `(literal "/dev/null")`, + `(literal "/dev/dtracehelper")`, + `(regex #"^/dev/tty")`, + ] + return [ + "(version 1)", + "(allow default)", + "(deny network*)", + "(deny appleevent-send)", + `(deny mach-lookup (require-not (require-any ${MACH_SERVICES.map((s) => `(global-name ${quote(s)})`).join(" ")})))`, + `(deny file-read* (require-not (require-any ${readable.join(" ")})))`, + `(allow file-read-metadata ${metadata.join(" ")})`, + `(deny file-write* (require-not (require-any ${writable.join(" ")})))`, + ].join("\n") +} + +/** + * bubblewrap argv: fresh namespaces (including network and PID), an empty root, read-only binds + * of the allowlist, symlinks for merged-usr paths, a writable bind of the session directory, and + * no bind of the host root or home directory. + */ +export function bwrapArgs( + read: ReadAllowlist, + write: string[], + cwd: string, + options: { readlink?: (p: string) => string } = {}, +): string[] { + const link = options.readlink ?? readlinkSync + const args = ["--unshare-all", "--die-with-parent", "--new-session", "--cap-drop", "ALL"] + args.push("--tmpfs", "/tmp", "--dev", "/dev", "--proc", "/proc") + for (const p of [...read.directories, ...read.files].sort()) args.push("--ro-bind", p, p) + for (const p of read.links) args.push("--symlink", link(p), p) + for (const d of [...write].sort()) args.push("--bind", d, d) + args.push("--chdir", cwd) + return args +} + +type Exit = { status: number | null; stdout: string; stderr: string; error?: string } + +/** Run a command without blocking the event loop; kill it at the deadline. */ +export function execute( + cmd: string[], + options: { cwd?: string; env?: Record; timeout: number }, +): Promise { + return new Promise((resolve) => { + let stdout = "" + let stderr = "" + const child = spawn(cmd[0], cmd.slice(1), { cwd: options.cwd, env: options.env, stdio: ["ignore", "pipe", "pipe"] }) + const timer = setTimeout(() => child.kill("SIGKILL"), options.timeout) + child.stdout.on("data", (d) => (stdout += d)) + child.stderr.on("data", (d) => (stderr += d)) + child.on("error", (e) => { + clearTimeout(timer) + resolve({ status: null, stdout, stderr, error: e.message }) + }) + child.on("close", (status) => { + clearTimeout(timer) + resolve({ status, stdout, stderr }) + }) + }) +} + +function which(name: string): string | undefined { + return Bun.which(name, { PATH: "/usr/bin:/bin:/usr/local/bin:/usr/sbin:/sbin" }) ?? Bun.which(name) ?? undefined +} + +/** Full argv for a confined interpreter. */ +export function launch(input: { + confinement: Confinement + write: string[] + cwd: string + python: string + args: string[] +}): string[] { + const { confinement, write, cwd, python, args } = input + if (confinement.filesystem === "sandbox-exec") + return [...confinement.prefix, "-p", sandboxProfile(confinement.read, write), python, "-I", ...args] + return [...confinement.prefix, ...bwrapArgs(confinement.read, write, cwd), "--", python, "-I", ...args] +} + +async function interpreterPaths(python: string, cwd: string, env: Record) { + const result = await execute([python, "-I", "-c", PROBE], { cwd, env, timeout: 20_000 }) + if (result.status !== 0) return undefined + try { + return JSON.parse(result.stdout) as { roots: string[]; libraries: string[] } + } catch { + return undefined + } +} + +/** Candidate mechanisms for this host, strongest first. */ +function candidates(platform: NodeJS.Platform): Omit[] { + if (platform === "darwin") + return existsSync("/usr/bin/sandbox-exec") + ? [{ filesystem: "sandbox-exec", network: "isolated", prefix: ["/usr/bin/sandbox-exec"] }] + : [] + if (platform !== "linux") return [] + const bwrap = which("bwrap") + return bwrap ? [{ filesystem: "bwrap", network: "isolated", prefix: [bwrap] }] : [] +} + +function unsupported(platform: NodeJS.Platform): string { + return platform === "linux" + ? "python_exec refused: no enforced filesystem confinement is available on this Linux host (bubblewrap `bwrap` is missing or cannot create namespaces). Install bubblewrap and allow unprivileged user namespaces; Python is never run unconfined." + : `python_exec refused: no filesystem confinement mechanism is supported on ${platform}. Only macOS (sandbox-exec) and Linux (bubblewrap) can confine reads; Python is never run unconfined.` +} + +/** + * Pick the strongest working mechanism. A mechanism is accepted only if the sandboxed + * interpreter can import pandas and duckdb and read its own directory, while both Python and a + * `cat` subprocess fail to read a canary file outside the allowlist. + */ +export async function detectConfinement(input: { + python: string + /** Bootstrap that wraps analyst code; the self-test runs through it exactly as calls do. */ + bootstrap: string + env: (workdir: string) => Record + platform?: NodeJS.Platform + /** Override the host's candidate mechanisms (tests). */ + mechanisms?: Omit[] +}): Promise { + const platform = input.platform ?? process.platform + const options = input.mechanisms ?? candidates(platform) + if (!options.length) return { ok: false, reason: unsupported(platform) } + const work = realpathSync(await fs.mkdtemp(path.join(os.tmpdir(), "altimate-python-sandbox-"))) + const elsewhere = realpathSync(await fs.mkdtemp(path.join(os.tmpdir(), "altimate-python-canary-"))) + try { + const paths = await interpreterPaths(input.python, work, input.env(work)) + if (!paths) + return { + ok: false, + reason: "python_exec refused: could not inspect the Python installation to build its read allowlist.", + } + const allow = readAllowlist(platform, paths) + if (!allow.ok) return { ok: false, reason: `python_exec refused: ${allow.reason}.` } + const canary = path.join(elsewhere, "canary.txt") + const inside = path.join(work, "inside.txt") + await fs.writeFile(canary, "outside") + await fs.writeFile(inside, "inside") + await fs.mkdir(path.join(work, "tmp")) + const cell = path.join(work, "selftest.py") + await fs.writeFile( + cell, + [ + "import duckdb, pandas, subprocess", + `assert open(${JSON.stringify(inside)}).read() == "inside"`, + `assert subprocess.run(["cat", ${JSON.stringify(inside)}], capture_output=True).stdout == b"inside"`, + "try:", + ` open(${JSON.stringify(canary)}).read()`, + " print('PYTHON_LEAK')", + "except OSError:", + " print('PYTHON_CONFINED')", + `result = subprocess.run(["cat", ${JSON.stringify(canary)}], capture_output=True)`, + "print('SUBPROCESS_CONFINED' if result.returncode != 0 and b'outside' not in result.stdout else 'SUBPROCESS_LEAK')", + ].join("\n"), + ) + const bootstrap = path.join(work, "_bootstrap.py") + await fs.writeFile(bootstrap, input.bootstrap) + const failures: string[] = [] + for (const option of options) { + const confinement: Confinement = { + ...option, + read: { directories: allow.directories, files: allow.files, links: allow.links }, + } + const argv = launch({ + confinement, + write: [work], + cwd: work, + python: input.python, + args: [bootstrap, cell, work], + }) + const result = await execute(argv, { cwd: work, env: input.env(work), timeout: 30_000 }) + const out = result.stdout + if ( + result.status === 0 && + out.includes("PYTHON_CONFINED") && + out.includes("SUBPROCESS_CONFINED") && + !out.includes("LEAK") + ) + return { ok: true, confinement } + failures.push( + `${option.filesystem}: ${(result.stderr || out || result.error || "no output").trim().split("\n").slice(-1)[0]}`, + ) + } + return { ok: false, reason: `${unsupported(platform)} Self-test results: ${failures.join("; ")}` } + } finally { + await fs.rm(work, { recursive: true, force: true }) + await fs.rm(elsewhere, { recursive: true, force: true }) + } +} diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index da8e75a1ce..2cd7b91320 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -1249,9 +1249,9 @@ export const layer = Layer.effect( const timeout = requestTimeout(s, clientName, mcpConfig, defaultTimeout) for (const mcpTool of listed) { const key = McpCatalog.sanitize(clientName) + "_" + McpCatalog.sanitize(mcpTool.name) - // altimate_change start — reserve the native finalizer's permission and echo identity, - // including when ALTIMATE_ANSWER_COMMIT=0 disables the native tool. - if (Wildcard.match(key, "answer_commit")) continue + // altimate_change start — reserve the native finalizer's and analyst Python's permission and + // identity, including when their native tools are disabled. + if (Wildcard.match(key, "answer_commit") || Wildcard.match(key, "python_exec")) continue // altimate_change end // attach the original client name for source classification downstream. result[key] = Object.assign(McpCatalog.convertTool(mcpTool, client, timeout), { client: clientName }) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 31218f9068..8a821a0ec9 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -65,9 +65,7 @@ import { pathToFileURL } from "url" // altimate_change start - import custom data engineering tools import { SqlExecuteTool } from "../altimate/tools/sql-execute" import { SqlMaterializeTool } from "../altimate/tools/sql-materialize" -// altimate_change start — analyst-only Python runtime availability. import { PythonExecTool, pythonExecAvailable } from "../altimate/tools/python-exec" -// altimate_change end import { SqlCheckTool } from "../altimate/tools/sql-check" import { AnswerCommit } from "../altimate/tools/answer-commit" import { SchemaInspectTool } from "../altimate/tools/schema-inspect" diff --git a/packages/opencode/test/altimate/tools/python-exec.test.ts b/packages/opencode/test/altimate/tools/python-exec.test.ts index 861bbe7638..6afac54700 100644 --- a/packages/opencode/test/altimate/tools/python-exec.test.ts +++ b/packages/opencode/test/altimate/tools/python-exec.test.ts @@ -1,5 +1,5 @@ // altimate_change start — sandboxed analyst Python execution regression coverage -import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test" +import { afterAll, afterEach, beforeAll, describe, expect, spyOn, test } from "bun:test" import { spawnSync } from "node:child_process" import * as fs from "node:fs/promises" import * as os from "node:os" @@ -10,12 +10,15 @@ import { childEnv, clampTimeout, clip, - networkIsolation, + BOOTSTRAP, + confinementFor, pythonExecAvailable, + resetConfinement, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS, WORKSPACE_LIMIT_BYTES, } from "../../../src/altimate/tools/python-exec" +import * as Sandbox from "../../../src/altimate/tools/python-sandbox" import { getScratch, closeScratch } from "../../../src/altimate/native/connections/scratch" import { initTool } from "../tool-fixture" import { tmpdir } from "../../fixture/fixture" @@ -178,7 +181,7 @@ describe("python_exec", () => { ctx, ) expect(result.metadata.ok).toBe(true) - expect(result.output).toContain("3") + expect(result.output).toMatch(/^3$/m) expect(result.output).toContain("scratch.parsed (2 rows)") expect(result.output).toContain(result.metadata.network) const rows = (await scratch.execute("SELECT id, words FROM parsed ORDER BY id")).rows @@ -290,7 +293,7 @@ describe("python_exec", () => { ctx, ) expect(second.metadata.ok).toBe(false) - expect(second.output).toContain("previous_input") + expect(second.output).toContain(path.join("in", "previous_input.parquet")) }, 15_000, ) @@ -346,39 +349,136 @@ describe("python_exec", () => { ) e2e( - "reports isolation and blocks network access where isolation is available", + "reports the confinement mechanism and blocks network access", async () => { - const isolation = networkIsolation() const tool = await initTool(PythonExecTool) const code = - isolation.network === "isolated" - ? "import socket\ntry:\n socket.create_connection(('1.1.1.1', 53), timeout=1)\n print('CONNECTED')\nexcept OSError:\n print('BLOCKED')" - : "print('network isolation unavailable')" + "import socket\ntry:\n socket.create_connection(('1.1.1.1', 53), timeout=1)\n print('CONNECTED')\nexcept OSError:\n print('BLOCKED')" const result = await tool.execute({ code, inputs: [] }, ctx) expect(result.metadata.ok).toBe(true) - expect(result.metadata.network).toBe(isolation.network) - expect(result.output).toContain(isolation.network) - if (isolation.network === "isolated") expect(result.output).toContain("BLOCKED") + expect(["sandbox-exec", "bwrap", "landlock"]).toContain(result.metadata.filesystem) + expect(result.output).toContain(`filesystem ${result.metadata.filesystem}`) + expect(result.output).toContain(`network ${result.metadata.network}`) + if (result.metadata.network === "isolated") expect(result.output).toContain("BLOCKED") }, - 15_000, + 30_000, + ) + + e2e( + "denies reads of host files outside the allowlist", + async () => { + await using elsewhere = await tmpdir() + const secret = path.join(elsewhere.path, "host-secret.txt") + await fs.writeFile(secret, "host-secret-contents") + const targets = [ + secret, + path.join(os.homedir(), ".ssh", "id_rsa"), + path.join(os.homedir(), ".ssh"), + "/etc/hosts", + "/etc/hostname", + os.homedir(), + ] + const tool = await initTool(PythonExecTool) + const code = [ + "import os", + `for target in ${JSON.stringify(targets)}:`, + " try:", + " data = os.listdir(target) if os.path.isdir(target) else open(target).read()", + " print('READ', target, str(data)[:40])", + " except OSError:", + " print('DENIED', target)", + ].join("\n") + const result = await tool.execute({ code, inputs: [] }, ctx) + expect(result.metadata.ok).toBe(true) + expect(result.output).not.toContain("READ ") + expect(result.output).not.toContain("host-secret-contents") + for (const target of targets) expect(result.output).toContain(`DENIED ${target}`) + }, + 30_000, + ) + + e2e( + "subprocesses inherit the read confinement", + async () => { + await using elsewhere = await tmpdir() + const secret = path.join(elsewhere.path, "subprocess-secret.txt") + await fs.writeFile(secret, "subprocess-secret-contents") + const tool = await initTool(PythonExecTool) + const code = [ + "import subprocess", + "open('mine.txt', 'w').write('workspace-contents')", + "print(subprocess.run(['cat', 'mine.txt'], capture_output=True, text=True).stdout)", + `outside = subprocess.run(['cat', ${JSON.stringify(secret)}], capture_output=True, text=True)`, + "print('CAT_EXIT', outside.returncode, outside.stdout)", + `shell = subprocess.run(['/bin/sh', '-c', 'cat ${secret}'], capture_output=True, text=True)`, + "print('SH_OUT', shell.stdout)", + ].join("\n") + const result = await tool.execute({ code, inputs: [] }, ctx) + expect(result.metadata.ok).toBe(true) + expect(result.output).toContain("workspace-contents") + expect(result.output).not.toContain("subprocess-secret-contents") + expect(result.output).not.toMatch(/CAT_EXIT 0/) + }, + 30_000, + ) + + e2e( + "pandas and duckdb read and write workspace files inside the sandbox", + async () => { + const tool = await initTool(PythonExecTool) + const code = [ + "import duckdb, pandas as pd", + "pd.DataFrame({'k': ['a', 'b', 'a'], 'v': [1, 2, 3]}).to_parquet('frame.parquet')", + "print('SUM', duckdb.sql(\"SELECT sum(v) FROM 'frame.parquet' WHERE k = 'a'\").fetchone()[0])", + "print('ROWS', len(pd.read_parquet('frame.parquet')))", + ].join("\n") + const result = await tool.execute({ code, inputs: [] }, ctx) + expect(result.metadata.ok).toBe(true) + expect(result.output).toContain("SUM 4") + expect(result.output).toContain("ROWS 3") + }, + 30_000, ) e2e( - "confines writes to the session workspace where the OS allows it", + "confines writes to the session workspace", async () => { - const confined = networkIsolation().prefix[0] === "sandbox-exec" const outside = path.join(os.homedir(), `.pyexec-write-probe-${process.pid}`) const tool = await initTool(PythonExecTool) const code = `import os\ntry:\n open(${JSON.stringify(outside)}, 'w').write('x')\n print('WROTE')\nexcept OSError:\n print('DENIED')\nopen('inside.txt', 'w').write('ok')\nprint('INSIDE_OK')` try { const result = await tool.execute({ code, inputs: [] }, ctx) expect(result.output).toContain("INSIDE_OK") - expect(result.output).toContain(confined ? "DENIED" : "WROTE") + expect(result.output).toContain("DENIED") + await expect(fs.stat(outside)).rejects.toHaveProperty("code", "ENOENT") } finally { await fs.rm(outside, { force: true }) } }, - 15_000, + 30_000, + ) + + e2e( + "refuses to run when no confinement mechanism is available", + async () => { + const detect = spyOn(Sandbox, "detectConfinement").mockResolvedValue({ + ok: false, + reason: "python_exec refused: no enforced filesystem confinement is available (test).", + }) + resetConfinement() + try { + const tool = await initTool(PythonExecTool) + const result = await tool.execute({ code: "open('ran.txt', 'w').write('x')\nprint('UNCONFINED_RUN')" }, ctx) + expect(detect).toHaveBeenCalled() + expect(result.metadata.ok).toBe(false) + expect(result.output).toContain("python_exec refused") + expect(result.output).not.toContain("UNCONFINED_RUN") + } finally { + detect.mockRestore() + resetConfinement() + } + }, + 30_000, ) test("rejects input names that are not scratch tables", async () => { @@ -406,4 +506,224 @@ describe("python_exec", () => { 15_000, ) }) +describe("python_exec confinement", () => { + // Merged-usr host: /bin, /lib and /lib64 are symlinks into /usr, as is /etc/localtime. + const read = { + directories: ["/opt/py", "/usr"], + files: ["/etc/ld.so.cache"], + links: ["/bin", "/etc/localtime", "/lib", "/lib64"], + } + const symlinks: Record = { + "/bin": "usr/bin", + "/lib": "usr/lib", + "/lib64": "usr/lib64", + "/etc/localtime": "../usr/share/zoneinfo/UTC", + } + + test("bwrap argv binds exactly the allowlist read-only and the session directory writable", () => { + const args = Sandbox.bwrapArgs(read, ["/tmp/scratch/python"], "/tmp/scratch/python/workspace", { + readlink: (p: string) => symlinks[p], + }) + expect(args).toEqual([ + "--unshare-all", + "--die-with-parent", + "--new-session", + "--cap-drop", + "ALL", + "--tmpfs", + "/tmp", + "--dev", + "/dev", + "--proc", + "/proc", + "--ro-bind", + "/etc/ld.so.cache", + "/etc/ld.so.cache", + "--ro-bind", + "/opt/py", + "/opt/py", + "--ro-bind", + "/usr", + "/usr", + "--symlink", + "usr/bin", + "/bin", + "--symlink", + "../usr/share/zoneinfo/UTC", + "/etc/localtime", + "--symlink", + "usr/lib", + "/lib", + "--symlink", + "usr/lib64", + "/lib64", + "--bind", + "/tmp/scratch/python", + "/tmp/scratch/python", + "--chdir", + "/tmp/scratch/python/workspace", + ]) + const binds = args.flatMap((arg, i) => (arg === "--bind" || arg === "--ro-bind" ? [args[i + 1]] : [])) + expect(binds).not.toContain("/") + expect(binds).not.toContain(os.homedir()) + }) + + test("the launched bwrap command runs the interpreter after the sandbox arguments", () => { + const argv = Sandbox.launch({ + confinement: { + filesystem: "bwrap", + network: "isolated", + prefix: ["/usr/bin/bwrap"], + read: { ...read, links: [] }, + }, + write: ["/tmp/scratch/python"], + cwd: "/tmp/scratch/python/workspace", + python: "/opt/py/bin/python3", + args: ["/tmp/scratch/python/call/_bootstrap.py", "cell.py", "/tmp/scratch/python/call"], + }) + expect(argv[0]).toBe("/usr/bin/bwrap") + expect(argv.slice(argv.indexOf("--"))).toEqual([ + "--", + "/opt/py/bin/python3", + "-I", + "/tmp/scratch/python/call/_bootstrap.py", + "cell.py", + "/tmp/scratch/python/call", + ]) + }) + + test("the read allowlist keeps merged-usr symlinks for bwrap and binds their resolved targets", () => { + const targets: Record = { + "/bin": "/usr/bin", + "/lib": "/usr/lib", + "/lib64": "/usr/lib64", + "/etc/localtime": "/usr/share/zoneinfo/UTC", + } + const exists = new Set(["/usr", "/etc/ld.so.cache", "/etc/ld.so.conf", "/opt/py"]) + const result = Sandbox.readAllowlist( + "linux", + { roots: ["/opt/py"], libraries: [] }, + { + home: "/home/u", + resolve: (p) => targets[p] ?? (exists.has(p) ? p : undefined), + link: (p) => p in targets, + }, + ) + expect(result).toEqual({ + ok: true, + directories: ["/opt/py", "/usr"], + files: ["/etc/ld.so.cache", "/etc/ld.so.conf"], + links: ["/bin", "/etc/localtime", "/lib", "/lib64"], + }) + }) + + test("the macOS profile denies reads, writes, network and services outside the allowlist", () => { + const profile = Sandbox.sandboxProfile({ directories: ["/opt/py"], files: ["/", "/dev/null"], links: [] }, [ + "/private/tmp/s", + ]) + expect(profile).toContain("(deny network*)") + expect(profile).toContain("(deny appleevent-send)") + expect(profile).toMatch(/\(deny mach-lookup \(require-not/) + expect(profile).toMatch(/\(deny file-read\* \(require-not \(require-any [^\n]*\(subpath "\/opt\/py"\)/) + expect(profile).toMatch(/\(deny file-write\* \(require-not \(require-any \(subpath "\/private\/tmp\/s"\)/) + expect(profile).not.toContain('(subpath "/")') + expect(profile).not.toContain(`(subpath "${os.homedir()}")`) + }) + + test("the read allowlist rejects interpreter roots that expose the root or home directory", () => { + const resolve = (p: string) => p + expect(Sandbox.readAllowlist("linux", { roots: ["/home/u"], libraries: [] }, { home: "/home/u", resolve }).ok).toBe( + false, + ) + expect(Sandbox.readAllowlist("linux", { roots: ["/"], libraries: [] }, { home: "/home/u", resolve }).ok).toBe(false) + expect( + Sandbox.readAllowlist( + "linux", + { roots: ["/opt/py"], libraries: ["/home/u/libx.so"] }, + { home: "/home/u", resolve }, + ).ok, + ).toBe(false) + const ok = Sandbox.readAllowlist( + "linux", + { + roots: ["/opt/py", "/opt/py/lib/python3.12"], + libraries: [ + "/opt/py/lib/libpython.so", + "/opt/ssl/lib/libssl.so.3", + "/usr/lib/libc.so", + "/dev/shm/sem.mp-1", + "/srv/data/mapped.bin", + ], + }, + { home: "/home/u", resolve }, + ) + expect(ok.ok && ok.directories).toEqual(expect.arrayContaining(["/opt/py", "/opt/ssl/lib", "/usr"])) + expect(ok.ok && ok.directories).not.toContain("/opt/py/lib/python3.12") + expect(ok.ok && ok.directories).not.toContain("/dev/shm") + expect(ok.ok && ok.directories).not.toContain("/srv/data") + }) + + test("unsupported platforms are refused", async () => { + for (const platform of ["win32", "freebsd"] as const) { + const result = await Sandbox.detectConfinement({ + python: "python3", + bootstrap: BOOTSTRAP, + env: childEnv, + platform, + }) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toContain("never run unconfined") + } + }) + + e2e( + "Linux with no working mechanism is refused", + async () => { + const result = await Sandbox.detectConfinement({ + python, + bootstrap: BOOTSTRAP, + env: childEnv, + platform: "linux", + mechanisms: [], + }) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toContain("bubblewrap") + }, + 30_000, + ) + + e2e( + "a mechanism that does not actually confine reads fails the self-test and is refused", + async () => { + await using bin = await tmpdir() + // A fake bwrap that discards the sandbox arguments and runs the interpreter unconfined. + const fake = path.join(bin.path, "bwrap") + await fs.writeFile(fake, '#!/bin/sh\nwhile [ "$1" != "--" ]; do shift; done\nshift\nexec "$@"\n', { mode: 0o755 }) + const result = await Sandbox.detectConfinement({ + python, + bootstrap: BOOTSTRAP, + env: childEnv, + platform: "linux", + mechanisms: [{ filesystem: "bwrap", network: "isolated", prefix: [fake] }], + }) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toContain("Self-test") + }, + 30_000, + ) + + e2e( + "the host mechanism passes its self-test", + async () => { + resetConfinement() + const result = await confinementFor(python) + expect(result.ok).toBe(true) + if (result.ok) + expect(result.confinement.filesystem).toBe( + process.platform === "darwin" ? "sandbox-exec" : result.confinement.filesystem, + ) + }, + 30_000, + ) +}) // altimate_change end diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 93eca27cac..7dfbd6de19 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -1384,6 +1384,21 @@ for (const toggle of ["1", "0"]) { ), ) } + +it.instance("MCP reserves python_exec so external tools cannot inherit analyst Python permission", () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "python" + getOrCreateClientState("python").tools = [ + { name: "exec", inputSchema: { type: "object", properties: {} } }, + { name: "lookup", inputSchema: { type: "object", properties: {} } }, + ] + yield* mcp.add("python", { type: "local", command: ["echo", "test"] }) + expect(Object.keys(yield* mcp.tools())).toEqual(["python_lookup"]) + expect(Object.keys((yield* mcp.snapshot("python")).tools)).toEqual(["python_lookup"]) + }), + ), +) // altimate_change end // altimate_change start — Windows permission matching treats tool IDs case-insensitively. From f7dc69d2367b49b7394ae52953996e8c4ab92f0a Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 17 Oct 2026 00:12:09 -0700 Subject: [PATCH 04/14] fix(analyst): kill the whole `python_exec` sandbox, block host signals, explain Linux refusals - `run` resolves on process `exit` or timeout, kills the process group plus every recorded descendant (including `start_new_session` ones), then closes the pipes, so a descendant that holds stdout open can no longer hang the call. - macOS profile denies `signal` except to the process itself and its children, so sandboxed code cannot signal the agent or other host processes. - Linux refusal names the missing `bubblewrap` package, or the AppArmor `kernel.apparmor_restrict_unprivileged_userns=1` restriction and its admin remedies. - Tests that need a working sandbox skip with a reason when none exists; a dedicated test asserts refusal in that case. Co-Authored-By: Claude Opus 5.5 --- .../src/altimate/tools/python-exec.ts | 74 ++++++++- .../src/altimate/tools/python-sandbox.ts | 47 +++++- .../test/altimate/tools/python-exec.test.ts | 156 ++++++++++++++++-- 3 files changed, 243 insertions(+), 34 deletions(-) diff --git a/packages/opencode/src/altimate/tools/python-exec.ts b/packages/opencode/src/altimate/tools/python-exec.ts index 430d2da6f1..b7b28a96d1 100644 --- a/packages/opencode/src/altimate/tools/python-exec.ts +++ b/packages/opencode/src/altimate/tools/python-exec.ts @@ -9,7 +9,7 @@ * session directory, and network is blocked. Without an enforced mechanism the tool refuses. */ import z from "zod" -import { spawn } from "node:child_process" +import { spawn, spawnSync } from "node:child_process" import * as fs from "node:fs/promises" import * as path from "node:path" import * as os from "node:os" @@ -219,6 +219,9 @@ async function workspaceSize(directory: string): Promise { return bytes } +/** How long output may keep draining after the sandbox is killed before the pipes are closed. */ +const PIPE_GRACE_MS = 300 + type RunResult = { code: number | null signal: string | null @@ -249,12 +252,66 @@ function run( child.stderr.on("data", (d) => { if (stderr.length < cap) stderr += d.toString().slice(0, cap - stderr.length) }) + let exitCode: number | null = null + let exitSignal: string | null = null + let settled = false + let grace: ReturnType | undefined + // Descendants seen so far (pid -> start time). A child that calls setsid leaves the process + // group, and once its parent exits it is re-parented, so the tree must be recorded while alive. + const known = new Map() + const snapshot = () => { + const ps = spawnSync("/bin/ps", ["-A", "-o", "pid=,ppid=,lstart="], { encoding: "utf8", timeout: 2000 }) + const table = new Map() + for (const line of (ps.stdout ?? "").split("\n")) { + const m = /^\s*(\d+)\s+(\d+)\s+(.*\S)\s*$/.exec(line) + if (m) table.set(Number(m[1]), { parent: Number(m[2]), started: m[3] }) + } + return table + } + const track = () => { + if (!child.pid) return + const table = snapshot() + const tree = new Set([...known.keys(), child.pid]) + for (let grew = true; grew; ) { + grew = false + for (const [pid, entry] of table) + if (!tree.has(pid) && tree.has(entry.parent)) { + tree.add(pid) + known.set(pid, entry.started) + grew = true + } + } + } + const tracker = setInterval(track, 100) + // Kill the whole sandbox, not only the process group: the group, then every recorded + // descendant whose start time still matches (so a reused pid is never signalled). On Linux the + // bwrap PID namespace (--die-with-parent) also ends everything when bwrap dies. const kill = () => { + try { + track() + const table = snapshot() + for (const [pid, started] of known) if (table.get(pid)?.started === started) process.kill(pid, "SIGKILL") + } catch {} try { if (child.pid) process.kill(-child.pid, "SIGKILL") } catch { child.kill("SIGKILL") } + // A descendant may still hold the pipes open; stop waiting for them shortly after the kill. + grace ??= setTimeout(finish, PIPE_GRACE_MS) + } + const finish = async () => { + if (settled) return + settled = true + clearTimeout(timer) + clearTimeout(grace) + clearInterval(monitor) + clearInterval(tracker) + abort.removeEventListener("abort", onAbort) + child.stdout.destroy() + child.stderr.destroy() + await checking + resolve({ code: exitCode, signal: exitSignal, stdout, stderr, timedOut, workspaceError }) } const timer = setTimeout(() => { timedOut = true @@ -283,15 +340,14 @@ function run( child.on("error", (e) => { stderr += `\n${e.message}` }) - // A cell may spawn children and exit without waiting. They must not outlive it. - child.on("exit", kill) - child.on("close", async (code, signal) => { - clearTimeout(timer) - clearInterval(monitor) - abort.removeEventListener("abort", onAbort) - await checking - resolve({ code, signal, stdout, stderr, timedOut, workspaceError }) + // A cell may spawn children and exit without waiting. They must not outlive it, and one that + // keeps stdout/stderr open must not keep the call from returning. + child.on("exit", (code, signal) => { + exitCode = code + exitSignal = signal + kill() }) + child.on("close", () => void finish()) }) } diff --git a/packages/opencode/src/altimate/tools/python-sandbox.ts b/packages/opencode/src/altimate/tools/python-sandbox.ts index ad1501160d..7f2cd3c97a 100644 --- a/packages/opencode/src/altimate/tools/python-sandbox.ts +++ b/packages/opencode/src/altimate/tools/python-sandbox.ts @@ -7,7 +7,7 @@ * refuses to run instead of running unconfined. * * - macOS: `sandbox-exec` profile that denies file reads and writes outside the allowlist, - * network, Apple Events and Mach services outside a small allowlist (LaunchServices could + * signals to processes other than itself and its children, network, Apple Events and Mach services outside a small allowlist (LaunchServices could * otherwise start unconfined helper processes). * - Linux: `bwrap` with every namespace unshared and only the allowlist mounted. Without a * working `bwrap` the tool refuses. Landlock alone is not used: it does not mediate @@ -18,7 +18,7 @@ * a canary file outside the allowlist) before it is used. */ import { spawn } from "node:child_process" -import { existsSync, lstatSync, readlinkSync, realpathSync } from "node:fs" +import { existsSync, lstatSync, readFileSync, readlinkSync, realpathSync } from "node:fs" import * as fs from "node:fs/promises" import * as os from "node:os" import * as path from "node:path" @@ -200,7 +200,7 @@ function ancestors(p: string): string[] { } } -/** macOS Seatbelt profile: allow-default, then deny reads, writes, network and IPC outside the allowlist. */ +/** macOS Seatbelt profile: allow-default, then deny reads, writes, network, IPC and signals outside the allowlist. */ export function sandboxProfile(read: ReadAllowlist, write: string[]): string { const readable = [ ...read.directories.map((d) => `(subpath ${quote(d)})`), @@ -223,6 +223,9 @@ export function sandboxProfile(read: ReadAllowlist, write: string[]): string { "(allow default)", "(deny network*)", "(deny appleevent-send)", + // Signals reach only the process itself and its own children, never the agent or any other host process. + "(deny signal)", + "(allow signal (target self) (target children))", `(deny mach-lookup (require-not (require-any ${MACH_SERVICES.map((s) => `(global-name ${quote(s)})`).join(" ")})))`, `(deny file-read* (require-not (require-any ${readable.join(" ")})))`, `(allow file-read-metadata ${metadata.join(" ")})`, @@ -315,10 +318,29 @@ function candidates(platform: NodeJS.Platform): Omit[] { return bwrap ? [{ filesystem: "bwrap", network: "isolated", prefix: [bwrap] }] : [] } -function unsupported(platform: NodeJS.Platform): string { - return platform === "linux" - ? "python_exec refused: no enforced filesystem confinement is available on this Linux host (bubblewrap `bwrap` is missing or cannot create namespaces). Install bubblewrap and allow unprivileged user namespaces; Python is never run unconfined." - : `python_exec refused: no filesystem confinement mechanism is supported on ${platform}. Only macOS (sandbox-exec) and Linux (bubblewrap) can confine reads; Python is never run unconfined.` +const REFUSAL_TAIL = "Python is never run unconfined." + +/** True when the kernel blocks unprivileged user namespaces through AppArmor (Ubuntu 23.10+ default). */ +function appArmorRestrictsUserNamespaces(): boolean { + try { + return readFileSync("/proc/sys/kernel/apparmor_restrict_unprivileged_userns", "utf8").trim() === "1" + } catch { + return false + } +} + +/** Refusal text for a host with no working mechanism. Never changes system settings. */ +export function unsupported( + platform: NodeJS.Platform, + host: { bwrapInstalled?: boolean; appArmorRestricted?: boolean } = {}, +): string { + if (platform !== "linux") + return `python_exec refused: no filesystem confinement mechanism is supported on ${platform}. Only macOS (sandbox-exec) and Linux (bubblewrap) can confine reads; ${REFUSAL_TAIL}` + if (!host.bwrapInstalled) + return `python_exec refused: bubblewrap (\`bwrap\`) is not installed on this Linux host, so no enforced filesystem confinement is available. Install the \`bubblewrap\` package; ${REFUSAL_TAIL}` + if (host.appArmorRestricted) + return `python_exec refused: bubblewrap is installed but cannot create a user namespace because the AppArmor setting kernel.apparmor_restrict_unprivileged_userns=1 blocks unprivileged user namespaces. An administrator can either add an AppArmor profile that lets \`bwrap\` create user namespaces, or set the sysctl kernel.apparmor_restrict_unprivileged_userns=0. That is an admin decision; python_exec never changes system settings. ${REFUSAL_TAIL}` + return `python_exec refused: bubblewrap is installed but cannot create the namespaces it needs on this Linux host. Allow unprivileged user namespaces for \`bwrap\`; ${REFUSAL_TAIL}` } /** @@ -334,10 +356,17 @@ export async function detectConfinement(input: { platform?: NodeJS.Platform /** Override the host's candidate mechanisms (tests). */ mechanisms?: Omit[] + /** Override the AppArmor user-namespace restriction probe (tests). */ + appArmorRestricted?: () => boolean }): Promise { const platform = input.platform ?? process.platform const options = input.mechanisms ?? candidates(platform) - if (!options.length) return { ok: false, reason: unsupported(platform) } + const refusal = () => + unsupported(platform, { + bwrapInstalled: options.some((o) => o.filesystem === "bwrap"), + appArmorRestricted: platform === "linux" && (input.appArmorRestricted ?? appArmorRestrictsUserNamespaces)(), + }) + if (!options.length) return { ok: false, reason: refusal() } const work = realpathSync(await fs.mkdtemp(path.join(os.tmpdir(), "altimate-python-sandbox-"))) const elsewhere = realpathSync(await fs.mkdtemp(path.join(os.tmpdir(), "altimate-python-canary-"))) try { @@ -398,7 +427,7 @@ export async function detectConfinement(input: { `${option.filesystem}: ${(result.stderr || out || result.error || "no output").trim().split("\n").slice(-1)[0]}`, ) } - return { ok: false, reason: `${unsupported(platform)} Self-test results: ${failures.join("; ")}` } + return { ok: false, reason: `${refusal()} Self-test results: ${failures.join("; ")}` } } finally { await fs.rm(work, { recursive: true, force: true }) await fs.rm(elsewhere, { recursive: true, force: true }) diff --git a/packages/opencode/test/altimate/tools/python-exec.test.ts b/packages/opencode/test/altimate/tools/python-exec.test.ts index 6afac54700..241408dce0 100644 --- a/packages/opencode/test/altimate/tools/python-exec.test.ts +++ b/packages/opencode/test/altimate/tools/python-exec.test.ts @@ -45,6 +45,12 @@ const probe = spawnSync( ) const python = probe.status === 0 ? probe.stdout.trim() : "" const e2e = python ? test : test.skip +// Tests that run analyst code need a sandbox that works on this host (macOS sandbox-exec, or Linux +// bubblewrap with user namespaces). Without one they are skipped and the refusal test below runs. +const detected = python ? await confinementFor(python) : undefined +const sandboxed = detected?.ok ? test : test.skip +if (python && !detected?.ok) + console.warn(`python_exec sandbox tests skipped: ${detected && !detected.ok ? detected.reason : "no Python"}`) let previous: Record = {} @@ -154,7 +160,7 @@ describe("python_exec helpers", () => { }) describe("python_exec", () => { - e2e( + sandboxed( "loads a scratch table and saves a DataFrame back to the same session", async () => { const scratch = await getScratch(ctx.sessionID) @@ -190,7 +196,7 @@ describe("python_exec", () => { 15_000, ) - e2e( + sandboxed( "persists workspace files across calls in one session", async () => { const tool = await initTool(PythonExecTool) @@ -214,7 +220,7 @@ describe("python_exec", () => { 15_000, ) - e2e( + sandboxed( "keeps different sessions' workspaces separate and removes files when the session closes", async () => { const tool = await initTool(PythonExecTool) @@ -239,7 +245,7 @@ describe("python_exec", () => { 15_000, ) - e2e( + sandboxed( "reports the workspace size cap and permits a later call after clearing oversized files", async () => { const tool = await initTool(PythonExecTool) @@ -262,7 +268,7 @@ describe("python_exec", () => { 15_000, ) - e2e( + sandboxed( "does not expose parent credentials, connection strings, or other environment variables", async () => { const tool = await initTool(PythonExecTool) @@ -275,7 +281,7 @@ describe("python_exec", () => { 15_000, ) - e2e( + sandboxed( "does not retain undeclared scratch inputs from an earlier call", async () => { const scratch = await getScratch(ctx.sessionID) @@ -298,7 +304,7 @@ describe("python_exec", () => { 15_000, ) - e2e( + sandboxed( "rejects path traversal in load and save table names", async () => { const tool = await initTool(PythonExecTool) @@ -322,7 +328,7 @@ describe("python_exec", () => { 15_000, ) - e2e( + sandboxed( "kills code that runs past its deadline", async () => { const tool = await initTool(PythonExecTool) @@ -334,7 +340,7 @@ describe("python_exec", () => { 15_000, ) - e2e( + sandboxed( "does not execute code when the call is already aborted", async () => { const tool = await initTool(PythonExecTool) @@ -348,7 +354,7 @@ describe("python_exec", () => { 15_000, ) - e2e( + sandboxed( "reports the confinement mechanism and blocks network access", async () => { const tool = await initTool(PythonExecTool) @@ -364,7 +370,7 @@ describe("python_exec", () => { 30_000, ) - e2e( + sandboxed( "denies reads of host files outside the allowlist", async () => { await using elsewhere = await tmpdir() @@ -397,7 +403,7 @@ describe("python_exec", () => { 30_000, ) - e2e( + sandboxed( "subprocesses inherit the read confinement", async () => { await using elsewhere = await tmpdir() @@ -422,7 +428,7 @@ describe("python_exec", () => { 30_000, ) - e2e( + sandboxed( "pandas and duckdb read and write workspace files inside the sandbox", async () => { const tool = await initTool(PythonExecTool) @@ -440,7 +446,7 @@ describe("python_exec", () => { 30_000, ) - e2e( + sandboxed( "confines writes to the session workspace", async () => { const outside = path.join(os.homedir(), `.pyexec-write-probe-${process.pid}`) @@ -458,7 +464,7 @@ describe("python_exec", () => { 30_000, ) - e2e( + sandboxed( "refuses to run when no confinement mechanism is available", async () => { const detect = spyOn(Sandbox, "detectConfinement").mockResolvedValue({ @@ -495,7 +501,7 @@ describe("python_exec", () => { expect(result.output).toContain("No code to run") }) - e2e( + sandboxed( "reports a Python error with its traceback", async () => { const tool = await initTool(PythonExecTool) @@ -505,6 +511,95 @@ describe("python_exec", () => { }, 15_000, ) + + sandboxed( + "returns at the deadline when a session-detached descendant holds stdout open", + async () => { + const tool = await initTool(PythonExecTool) + const started = Date.now() + const result = await tool.execute( + { + code: [ + "import subprocess, time", + "subprocess.Popen(['sleep', '7331'], start_new_session=True)", + "time.sleep(60)", + ].join("\n"), + timeout_ms: 1000, + }, + ctx, + ) + expect(Date.now() - started).toBeLessThan(8000) + expect(result.metadata.timedOut).toBe(true) + expect(result.output).toContain("timed out") + expect(spawnSync("pgrep", ["-f", "sleep 7331"], { encoding: "utf8" }).stdout.trim()).toBe("") + }, + 20_000, + ) + + sandboxed( + "returns promptly when the cell exits leaving a session-detached descendant on stdout", + async () => { + const tool = await initTool(PythonExecTool) + const started = Date.now() + const result = await tool.execute( + { + code: "import subprocess\nsubprocess.Popen(['sleep', '7332'], start_new_session=True)\nprint('done')", + timeout_ms: 30_000, + }, + ctx, + ) + expect(Date.now() - started).toBeLessThan(10_000) + expect(result.output).toContain("done") + spawnSync("pkill", ["-f", "sleep 7332"]) + }, + 20_000, + ) + + test("python_exec refuses when the host has no working sandbox", async () => { + if (detected?.ok || !python) return + const tool = await initTool(PythonExecTool) + const result = await tool.execute({ code: "print(1)" }, ctx) + expect(result.metadata.ok).toBe(false) + expect(result.output).toContain("python_exec refused") + expect(result.output).toContain("never run unconfined") + }) + + sandboxed( + "sandboxed code cannot signal the agent or other host processes", + async () => { + if (process.platform !== "darwin") return + const outside = Bun.spawn(["sleep", "7333"]) + try { + const tool = await initTool(PythonExecTool) + const result = await tool.execute( + { + code: [ + "import os, signal, subprocess", + "for name, pid in (('parent', os.getppid()), ('other', %d)):" .replace("%d", "OUTSIDE"), + " try:", + " os.kill(pid, signal.SIGURG)", + " print(name, 'SIGNALLED')", + " except OSError:", + " print(name, 'DENIED')", + "child = subprocess.Popen(['sleep', '5'])", + "child.send_signal(signal.SIGURG)", + "child.kill()", + "print('child OK')", + ].join("\n").replace("OUTSIDE", String(outside.pid)), + }, + ctx, + ) + expect(result.output).toContain("parent DENIED") + expect(result.output).toContain("other DENIED") + expect(result.output).toContain("child OK") + expect(outside.killed || outside.exitCode !== null).toBe(false) + expect(process.kill(process.pid, 0)).toBe(true) + } finally { + outside.kill() + } + }, + 20_000, + ) }) describe("python_exec confinement", () => { // Merged-usr host: /bin, /lib and /lib64 are symlinks into /usr, as is /etc/localtime. @@ -623,6 +718,8 @@ describe("python_exec confinement", () => { ]) expect(profile).toContain("(deny network*)") expect(profile).toContain("(deny appleevent-send)") + expect(profile).toContain("(deny signal)") + expect(profile).toContain("(allow signal (target self) (target children))") expect(profile).toMatch(/\(deny mach-lookup \(require-not/) expect(profile).toMatch(/\(deny file-read\* \(require-not \(require-any [^\n]*\(subpath "\/opt\/py"\)/) expect(profile).toMatch(/\(deny file-write\* \(require-not \(require-any \(subpath "\/private\/tmp\/s"\)/) @@ -663,6 +760,33 @@ describe("python_exec confinement", () => { expect(ok.ok && ok.directories).not.toContain("/srv/data") }) + test("a missing bubblewrap is refused with an install hint", async () => { + const result = await Sandbox.detectConfinement({ + python: "python3", + bootstrap: BOOTSTRAP, + env: childEnv, + platform: "linux", + mechanisms: [], + appArmorRestricted: () => true, + }) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.reason).toContain("not installed") + expect(result.reason).toContain("Install the `bubblewrap` package") + expect(result.reason).not.toContain("apparmor_restrict_unprivileged_userns") + } + }) + + test("bubblewrap blocked by the AppArmor user-namespace restriction names the remedies", () => { + const reason = Sandbox.unsupported("linux", { bwrapInstalled: true, appArmorRestricted: true }) + expect(reason).toContain("kernel.apparmor_restrict_unprivileged_userns=1") + expect(reason).toContain("AppArmor profile") + expect(reason).toContain("kernel.apparmor_restrict_unprivileged_userns=0") + expect(reason).toContain("admin decision") + expect(reason).toContain("never changes system settings") + expect(Sandbox.unsupported("linux", { bwrapInstalled: true, appArmorRestricted: false })).not.toContain("AppArmor") + }) + test("unsupported platforms are refused", async () => { for (const platform of ["win32", "freebsd"] as const) { const result = await Sandbox.detectConfinement({ From 0d7df67ee945d1f0b583d72ec017bc8335d84350 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 17 Oct 2026 00:21:17 -0700 Subject: [PATCH 05/14] fix(analyst): make `python_exec` descendant tracking non-blocking and clarify macOS refusals - Track macOS descendants with async `execFile("ps")`, one in flight at a time, every 500 ms, with a final sweep at exit and deadline; Linux relies on the bwrap PID namespace - Report "sandbox-exec not found" and "sandbox-exec self-test failed: " on macOS instead of the generic unsupported-platform text - Add an event-loop responsiveness test and unit tests for both macOS refusals Co-Authored-By: Claude Opus 5.5 --- .../src/altimate/tools/python-exec.ts | 83 +++++++++++-------- .../src/altimate/tools/python-sandbox.ts | 14 +++- .../test/altimate/tools/python-exec.test.ts | 60 ++++++++++++++ 3 files changed, 123 insertions(+), 34 deletions(-) diff --git a/packages/opencode/src/altimate/tools/python-exec.ts b/packages/opencode/src/altimate/tools/python-exec.ts index b7b28a96d1..a52c39887a 100644 --- a/packages/opencode/src/altimate/tools/python-exec.ts +++ b/packages/opencode/src/altimate/tools/python-exec.ts @@ -9,7 +9,7 @@ * session directory, and network is blocked. Without an enforced mechanism the tool refuses. */ import z from "zod" -import { spawn, spawnSync } from "node:child_process" +import { execFile, spawn } from "node:child_process" import * as fs from "node:fs/promises" import * as path from "node:path" import * as os from "node:os" @@ -222,6 +222,9 @@ async function workspaceSize(directory: string): Promise { /** How long output may keep draining after the sandbox is killed before the pipes are closed. */ const PIPE_GRACE_MS = 300 +/** Descendant sweep period (macOS only); kept coarse so `ps` never competes with the event loop. */ +const TRACK_INTERVAL_MS = 500 + type RunResult = { code: number | null signal: string | null @@ -259,37 +262,51 @@ function run( // Descendants seen so far (pid -> start time). A child that calls setsid leaves the process // group, and once its parent exits it is re-parented, so the tree must be recorded while alive. const known = new Map() - const snapshot = () => { - const ps = spawnSync("/bin/ps", ["-A", "-o", "pid=,ppid=,lstart="], { encoding: "utf8", timeout: 2000 }) - const table = new Map() - for (const line of (ps.stdout ?? "").split("\n")) { - const m = /^\s*(\d+)\s+(\d+)\s+(.*\S)\s*$/.exec(line) - if (m) table.set(Number(m[1]), { parent: Number(m[2]), started: m[3] }) - } - return table - } + const trackDescendants = process.platform === "darwin" + // Asynchronous so the event loop is never blocked; at most one `ps` is in flight. + const snapshot = () => + new Promise>((done) => { + execFile("/bin/ps", ["-A", "-o", "pid=,ppid=,lstart="], { encoding: "utf8", timeout: 2000 }, (_error, out) => { + const table = new Map() + for (const line of (out ?? "").split("\n")) { + const m = /^\s*(\d+)\s+(\d+)\s+(.*\S)\s*$/.exec(line) + if (m) table.set(Number(m[1]), { parent: Number(m[2]), started: m[3] }) + } + done(table) + }) + }) + let sweeping: Promise> | undefined const track = () => { - if (!child.pid) return - const table = snapshot() - const tree = new Set([...known.keys(), child.pid]) - for (let grew = true; grew; ) { - grew = false - for (const [pid, entry] of table) - if (!tree.has(pid) && tree.has(entry.parent)) { - tree.add(pid) - known.set(pid, entry.started) - grew = true + if (!child.pid || !trackDescendants) return Promise.resolve(new Map()) + sweeping ??= snapshot() + .then((table) => { + const tree = new Set([...known.keys(), child.pid!]) + for (let grew = true; grew; ) { + grew = false + for (const [pid, entry] of table) + if (!tree.has(pid) && tree.has(entry.parent)) { + tree.add(pid) + known.set(pid, entry.started) + grew = true + } } - } + return table + }) + .finally(() => { + sweeping = undefined + }) + return sweeping } - const tracker = setInterval(track, 100) - // Kill the whole sandbox, not only the process group: the group, then every recorded - // descendant whose start time still matches (so a reused pid is never signalled). On Linux the - // bwrap PID namespace (--die-with-parent) also ends everything when bwrap dies. - const kill = () => { + const tracker = trackDescendants ? setInterval(() => void track(), TRACK_INTERVAL_MS) : undefined + // Kill the whole sandbox, not only the process group: a final sweep, then every recorded + // descendant whose start time still matches (so a reused pid is never signalled), then the + // group. On Linux the bwrap PID namespace (--die-with-parent) also ends everything when bwrap dies. + let killing = false + const kill = async () => { + if (killing) return + killing = true try { - track() - const table = snapshot() + const table = await track() for (const [pid, started] of known) if (table.get(pid)?.started === started) process.kill(pid, "SIGKILL") } catch {} try { @@ -315,11 +332,11 @@ function run( } const timer = setTimeout(() => { timedOut = true - kill() + void kill() }, timeoutMs) - const onAbort = () => kill() + const onAbort = () => void kill() abort.addEventListener("abort", onAbort, { once: true }) - if (abort.aborted) kill() + if (abort.aborted) void kill() // Check the whole Python directory, including temporary exports and output, so // repeated calls cannot bypass the workspace budget through HOME or TMPDIR. let checking: Promise | undefined @@ -330,7 +347,7 @@ function run( () => {}, (error) => { workspaceError = error instanceof Error ? error.message : String(error) - kill() + void kill() }, ) .finally(() => { @@ -345,7 +362,7 @@ function run( child.on("exit", (code, signal) => { exitCode = code exitSignal = signal - kill() + void kill() }) child.on("close", () => void finish()) }) diff --git a/packages/opencode/src/altimate/tools/python-sandbox.ts b/packages/opencode/src/altimate/tools/python-sandbox.ts index 7f2cd3c97a..7d9e767ce3 100644 --- a/packages/opencode/src/altimate/tools/python-sandbox.ts +++ b/packages/opencode/src/altimate/tools/python-sandbox.ts @@ -366,7 +366,14 @@ export async function detectConfinement(input: { bwrapInstalled: options.some((o) => o.filesystem === "bwrap"), appArmorRestricted: platform === "linux" && (input.appArmorRestricted ?? appArmorRestrictsUserNamespaces)(), }) - if (!options.length) return { ok: false, reason: refusal() } + if (!options.length) + return { + ok: false, + reason: + platform === "darwin" + ? `python_exec refused: sandbox-exec not found at /usr/bin/sandbox-exec, so no enforced filesystem confinement is available. ${REFUSAL_TAIL}` + : refusal(), + } const work = realpathSync(await fs.mkdtemp(path.join(os.tmpdir(), "altimate-python-sandbox-"))) const elsewhere = realpathSync(await fs.mkdtemp(path.join(os.tmpdir(), "altimate-python-canary-"))) try { @@ -427,6 +434,11 @@ export async function detectConfinement(input: { `${option.filesystem}: ${(result.stderr || out || result.error || "no output").trim().split("\n").slice(-1)[0]}`, ) } + if (platform === "darwin") + return { + ok: false, + reason: `python_exec refused: sandbox-exec self-test failed: ${failures.join("; ")}. ${REFUSAL_TAIL}`, + } return { ok: false, reason: `${refusal()} Self-test results: ${failures.join("; ")}` } } finally { await fs.rm(work, { recursive: true, force: true }) diff --git a/packages/opencode/test/altimate/tools/python-exec.test.ts b/packages/opencode/test/altimate/tools/python-exec.test.ts index 241408dce0..9e205fbfd0 100644 --- a/packages/opencode/test/altimate/tools/python-exec.test.ts +++ b/packages/opencode/test/altimate/tools/python-exec.test.ts @@ -196,6 +196,28 @@ describe("python_exec", () => { 15_000, ) + sandboxed( + "keeps the event loop responsive while a cell runs", + async () => { + const tool = await initTool(PythonExecTool) + let worst = 0 + let last = performance.now() + const timer = setInterval(() => { + const now = performance.now() + worst = Math.max(worst, now - last) + last = now + }, 10) + try { + const result = await tool.execute({ code: "import time\ntime.sleep(2)\nprint('done')" }, ctx) + expect(result.metadata.ok).toBe(true) + } finally { + clearInterval(timer) + } + expect(worst).toBeLessThan(250) + }, + 15_000, + ) + sandboxed( "persists workspace files across calls in one session", async () => { @@ -816,6 +838,44 @@ describe("python_exec confinement", () => { 30_000, ) + e2e( + "macOS without sandbox-exec reports it as missing", + async () => { + const result = await Sandbox.detectConfinement({ + python, + bootstrap: BOOTSTRAP, + env: childEnv, + platform: "darwin", + mechanisms: [], + }) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.reason).toContain("sandbox-exec not found") + expect(result.reason).not.toContain("no filesystem confinement mechanism is supported") + } + }, + 30_000, + ) + + e2e( + "macOS sandbox-exec self-test failure reports the reason", + async () => { + const result = await Sandbox.detectConfinement({ + python, + bootstrap: BOOTSTRAP, + env: childEnv, + platform: "darwin", + mechanisms: [{ filesystem: "sandbox-exec", network: "isolated", prefix: ["/usr/bin/false"] }], + }) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.reason).toContain("sandbox-exec self-test failed:") + expect(result.reason).not.toContain("no filesystem confinement mechanism is supported") + } + }, + 30_000, + ) + e2e( "a mechanism that does not actually confine reads fails the self-test and is refused", async () => { From 87f416caf7a4885636bc455cbaa9a3f2b693c078 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 17 Oct 2026 00:25:32 -0700 Subject: [PATCH 06/14] fix(analyst): never signal a `python_exec` descendant that is not provably ours The sandbox group is killed with `process.kill(-pgid, SIGKILL)` as the primary mechanism. Descendants that escaped via `setsid` are signalled only when a fresh `ps` snapshot shows an unchanged start time, uid and command, continuous observation since first seen, and an unchanged parent or an ancestor chain to a verified member. Anything else is skipped and reported as possibly orphaned. Co-Authored-By: Claude Opus 5.5 --- .../src/altimate/tools/python-exec.ts | 155 ++++++++++++++---- .../test/altimate/tools/python-exec.test.ts | 52 ++++++ 2 files changed, 178 insertions(+), 29 deletions(-) diff --git a/packages/opencode/src/altimate/tools/python-exec.ts b/packages/opencode/src/altimate/tools/python-exec.ts index a52c39887a..60e6668568 100644 --- a/packages/opencode/src/altimate/tools/python-exec.ts +++ b/packages/opencode/src/altimate/tools/python-exec.ts @@ -232,6 +232,96 @@ type RunResult = { stderr: string timedOut: boolean workspaceError?: string + orphaned?: number[] +} + +export type ProcessIdentity = { parent: number; uid: number; started: string; command: string } +export type ProcessTable = Map + +/** Parse `ps -A -o pid=,ppid=,uid=,lstart=,comm=` output. */ +export function parseProcessTable(out: string): ProcessTable { + const table: ProcessTable = new Map() + for (const line of out.split("\n")) { + const m = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\w{3}\s+\w{3}\s+\d+\s+[\d:]+\s+\d{4})\s+(.+?)\s*$/.exec(line) + if (m) table.set(Number(m[1]), { parent: Number(m[2]), uid: Number(m[3]), started: m[4], command: m[5] }) + } + return table +} + +const sameProcess = (a: ProcessIdentity, b: ProcessIdentity) => + a.started === b.started && a.uid === b.uid && a.command === b.command + +/** + * Fold a fresh snapshot into the recorded descendants. An entry survives only while it is observed + * in every snapshot with an unchanged identity (start time, uid, command); the moment that breaks, + * the pid may have been reused, so the entry is dropped and never signalled (reused pids are + * returned). New members are + * processes whose parent is the root or a currently verified member. + */ +export function reconcileDescendants( + known: Map, + table: ProcessTable, + rootPid: number, + root: ProcessIdentity | undefined, +) { + const reused: number[] = [] + for (const [pid, recorded] of [...known]) { + const now = table.get(pid) + if (now && sameProcess(recorded, now)) known.set(pid, { ...recorded, parent: now.parent }) + else { + known.delete(pid) + if (now) reused.push(pid) + } + } + const rootNow = table.get(rootPid) + const tree = new Set(known.keys()) + if (rootNow && root && sameProcess(root, rootNow)) tree.add(rootPid) + for (let grew = true; grew; ) { + grew = false + for (const [pid, entry] of table) + if (!tree.has(pid) && tree.has(entry.parent)) { + tree.add(pid) + known.set(pid, entry) + grew = true + } + } + return reused +} + +/** + * Pids that are provably still the recorded descendants in this snapshot. Anything whose identity + * or ancestry no longer matches is skipped and reported, never signalled. + */ +export function verifiedKillTargets( + known: Map, + table: ProcessTable, + rootPid: number, + root: ProcessIdentity | undefined, +) { + const kill: number[] = [] + const skipped: number[] = [] + const verified = (pid: number, seen = new Set()): boolean => { + if (seen.has(pid)) return false + seen.add(pid) + const now = table.get(pid) + if (!now) return false + if (pid === rootPid) return !!root && sameProcess(root, now) + const recorded = known.get(pid) + return !!recorded && sameProcess(recorded, now) + } + for (const [pid, recorded] of known) { + const now = table.get(pid) + const identity = !!now && sameProcess(recorded, now) + // Parent unchanged, or the chain of current ancestors reaches a verified member. + let lineage = !!now && now.parent === recorded.parent + for (let up = now?.parent, hops = 0; !lineage && up && up > 1 && hops < 64; hops++) { + if (verified(up)) lineage = true + up = table.get(up)?.parent + } + if (identity && lineage) kill.push(pid) + else skipped.push(pid) + } + return { kill, skipped } } function run( @@ -259,37 +349,30 @@ function run( let exitSignal: string | null = null let settled = false let grace: ReturnType | undefined - // Descendants seen so far (pid -> start time). A child that calls setsid leaves the process - // group, and once its parent exits it is re-parented, so the tree must be recorded while alive. - const known = new Map() + // Descendants seen so far, with the identity they had when first observed. A child that calls + // setsid leaves the process group, and once its parent exits it is re-parented, so the tree must + // be recorded while alive. + const known = new Map() + let root: ProcessIdentity | undefined + const orphaned: number[] = [] const trackDescendants = process.platform === "darwin" // Asynchronous so the event loop is never blocked; at most one `ps` is in flight. const snapshot = () => - new Promise>((done) => { - execFile("/bin/ps", ["-A", "-o", "pid=,ppid=,lstart="], { encoding: "utf8", timeout: 2000 }, (_error, out) => { - const table = new Map() - for (const line of (out ?? "").split("\n")) { - const m = /^\s*(\d+)\s+(\d+)\s+(.*\S)\s*$/.exec(line) - if (m) table.set(Number(m[1]), { parent: Number(m[2]), started: m[3] }) - } - done(table) - }) + new Promise((done) => { + execFile( + "/bin/ps", + ["-A", "-o", "pid=,ppid=,uid=,lstart=,comm="], + { encoding: "utf8", timeout: 2000 }, + (_error, out) => done(parseProcessTable(out ?? "")), + ) }) - let sweeping: Promise> | undefined + let sweeping: Promise | undefined const track = () => { - if (!child.pid || !trackDescendants) return Promise.resolve(new Map()) + if (!child.pid || !trackDescendants) return Promise.resolve(new Map() as ProcessTable) sweeping ??= snapshot() .then((table) => { - const tree = new Set([...known.keys(), child.pid!]) - for (let grew = true; grew; ) { - grew = false - for (const [pid, entry] of table) - if (!tree.has(pid) && tree.has(entry.parent)) { - tree.add(pid) - known.set(pid, entry.started) - grew = true - } - } + root ??= table.get(child.pid!) + orphaned.push(...reconcileDescendants(known, table, child.pid!, root)) return table }) .finally(() => { @@ -298,22 +381,32 @@ function run( return sweeping } const tracker = trackDescendants ? setInterval(() => void track(), TRACK_INTERVAL_MS) : undefined - // Kill the whole sandbox, not only the process group: a final sweep, then every recorded - // descendant whose start time still matches (so a reused pid is never signalled), then the - // group. On Linux the bwrap PID namespace (--die-with-parent) also ends everything when bwrap dies. + // Primary mechanism: the sandbox runs in its own process group, so one group SIGKILL ends every + // member with no pid-identity question. Descendants that escaped the group (setsid) are + // signalled only when still provably the same process; anything else is left alone and noted. + // On Linux the bwrap PID namespace (--die-with-parent) also ends everything when bwrap dies. let killing = false const kill = async () => { if (killing) return killing = true + let targets: number[] = [] try { + await sweeping?.catch(() => {}) const table = await track() - for (const [pid, started] of known) if (table.get(pid)?.started === started) process.kill(pid, "SIGKILL") + const verdict = verifiedKillTargets(known, table, child.pid!, root) + targets = verdict.kill + orphaned.push(...verdict.skipped) } catch {} try { if (child.pid) process.kill(-child.pid, "SIGKILL") } catch { child.kill("SIGKILL") } + for (const pid of targets) { + try { + process.kill(pid, "SIGKILL") + } catch {} + } // A descendant may still hold the pipes open; stop waiting for them shortly after the kill. grace ??= setTimeout(finish, PIPE_GRACE_MS) } @@ -328,7 +421,7 @@ function run( child.stdout.destroy() child.stderr.destroy() await checking - resolve({ code: exitCode, signal: exitSignal, stdout, stderr, timedOut, workspaceError }) + resolve({ code: exitCode, signal: exitSignal, stdout, stderr, timedOut, workspaceError, orphaned }) } const timer = setTimeout(() => { timedOut = true @@ -512,6 +605,10 @@ export const PythonExecTool = Tool.define("python_exec", { `Isolation: filesystem ${sandbox.filesystem} (reads limited to the workspace and Python installation; writes to the workspace); network ${sandbox.network}; minimal environment.`, `Workspace: ${workspace}`, ] + if (result.orphaned?.length) + parts.push( + `Note: process(es) ${[...new Set(result.orphaned)].join(", ")} could not be verified as started by this call and were not signalled; they may be orphaned.`, + ) if (result.workspaceError) parts.push(`${result.workspaceError} Workspace cleared; retry with smaller files.`) if (imported.length) parts.push(`Saved to scratch: ${imported.join(", ")}.`) if (importErrors.length) parts.push(`Could not import: ${importErrors.join("; ")}`) diff --git a/packages/opencode/test/altimate/tools/python-exec.test.ts b/packages/opencode/test/altimate/tools/python-exec.test.ts index 9e205fbfd0..b64b784df3 100644 --- a/packages/opencode/test/altimate/tools/python-exec.test.ts +++ b/packages/opencode/test/altimate/tools/python-exec.test.ts @@ -11,6 +11,9 @@ import { clampTimeout, clip, BOOTSTRAP, + parseProcessTable, + reconcileDescendants, + verifiedKillTargets, confinementFor, pythonExecAvailable, resetConfinement, @@ -910,4 +913,53 @@ describe("python_exec confinement", () => { 30_000, ) }) + +describe("descendant identity verification", () => { + const id = (parent: number, started = "Sat Oct 17 10:00:00 2026", command = "sleep", uid = 501) => ({ + parent, + uid, + started, + command, + }) + const table = (rows: Record>) => + new Map(Object.entries(rows).map(([k, v]) => [Number(k), v])) + + test("parses ps output with lstart and command", () => { + const parsed = parseProcessTable(" 12 1 501 Sat Oct 7 10:00:00 2026 /usr/bin/python3\n") + expect(parsed.get(12)).toEqual({ parent: 1, uid: 501, started: "Sat Oct 7 10:00:00 2026", command: "/usr/bin/python3" }) + }) + + test("does not signal a recorded pid whose identity no longer matches", () => { + const root = id(1, undefined, "bwrap") + const known = new Map([[200, id(100)]]) + // pid 200 was reused by an unrelated process that starts in the same second but runs another command + const now = table({ 100: root, 200: id(999, undefined, "Finder") }) + const verdict = verifiedKillTargets(known, now, 100, root) + expect(verdict.kill).toEqual([]) + expect(verdict.skipped).toEqual([200]) + const reused = reconcileDescendants(known, now, 100, root) + expect(reused).toEqual([200]) + expect(known.has(200)).toBe(false) + }) + + test("does not signal when uid differs or the root itself was reused", () => { + const root = id(1, undefined, "bwrap") + const known = new Map([[200, id(100)]]) + expect(verifiedKillTargets(known, table({ 100: root, 200: id(100, undefined, "sleep", 0) }), 100, root).kill).toEqual([]) + const reusedRoot = id(1, "Sat Oct 17 10:00:05 2026", "bwrap") + const moved = new Map([[200, id(150)]]) + expect(verifiedKillTargets(moved, table({ 100: reusedRoot, 200: id(77) }), 100, root).kill).toEqual([]) + }) + + test("signals a continuously observed descendant, including after re-parenting", () => { + const root = id(1, undefined, "bwrap") + const known = new Map>() + reconcileDescendants(known, table({ 100: root, 200: id(100) }), 100, root) + expect(known.has(200)).toBe(true) + // parent exited and the child was re-parented to launchd; it is still the same process + const later = table({ 200: id(1) }) + reconcileDescendants(known, later, 100, root) + expect(verifiedKillTargets(known, later, 100, root).kill).toEqual([200]) + }) +}) // altimate_change end From 0dc826a3893d34a43b01179c5043dd49be40d6a0 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 17 Oct 2026 00:31:08 -0700 Subject: [PATCH 07/14] fix(analyst): make the `python_exec` self-test canaries non-vacuous and tighten sandbox tests - Place self-test canaries in private 0700 directories under the user's home and the working directory, outside every allowlisted path and not under `/tmp` (which `bwrap` replaces with an empty tmpfs); remove them afterwards - Show the AppArmor refusal only when the self-test failure indicates a denied user namespace and the sysctl is set - Drop `landlock` from accepted mechanisms, assert the exact mechanism per platform, and use `skip` gating instead of early returns - Unit-test canary placement and user-namespace denial detection Co-Authored-By: Claude Opus 5.5 --- .../src/altimate/tools/python-sandbox.ts | 78 +++++++++++++++---- .../test/altimate/tools/python-exec.test.ts | 39 ++++++++-- 2 files changed, 95 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/altimate/tools/python-sandbox.ts b/packages/opencode/src/altimate/tools/python-sandbox.ts index 7d9e767ce3..a7a7b7491a 100644 --- a/packages/opencode/src/altimate/tools/python-sandbox.ts +++ b/packages/opencode/src/altimate/tools/python-sandbox.ts @@ -329,6 +329,44 @@ function appArmorRestrictsUserNamespaces(): boolean { } } +/** Self-test output that shows the kernel refused to create a user namespace (not a confinement defect). */ +const USERNS_DENIAL = /RTM_NEWADDR|setting up uid map|Operation not permitted|Permission denied.*namespace|creating new namespace/i + +/** True when a self-test failure message indicates a denied user namespace. */ +export function indicatesUserNamespaceDenial(failure: string): boolean { + return USERNS_DENIAL.test(failure) +} + +/** + * Create canary files that are NOT under any bound or allowlisted path and not under `/tmp` + * (bubblewrap replaces `/tmp` with an empty tmpfs, which would make a `/tmp` canary vacuous). + * One lives in a private directory under the user's home, the other under the process working + * directory: each is readable only if `$HOME` or `/` were exposed to the sandbox. + */ +export async function createCanaries( + excluded: string[], + roots: string[] = [os.homedir(), process.cwd()], +): Promise<{ files: string[]; dirs: string[] }> { + const files: string[] = [] + const dirs: string[] = [] + for (const root of roots) { + try { + const dir = realpathSync(await fs.mkdtemp(path.join(root, ".altimate-python-canary-"))) + await fs.chmod(dir, 0o700) + dirs.push(dir) + // "/" is bound only as a metadata entry, never as readable content. + const inside = excluded.some((e) => e !== "/" && (dir === e || dir.startsWith(e + "/"))) + if (inside || dir === "/tmp" || dir.startsWith("/tmp/")) continue + const file = path.join(dir, "canary.txt") + await fs.writeFile(file, "outside", { mode: 0o600 }) + files.push(file) + } catch { + continue + } + } + return { files, dirs } +} + /** Refusal text for a host with no working mechanism. Never changes system settings. */ export function unsupported( platform: NodeJS.Platform, @@ -361,10 +399,12 @@ export async function detectConfinement(input: { }): Promise { const platform = input.platform ?? process.platform const options = input.mechanisms ?? candidates(platform) - const refusal = () => + // The AppArmor message needs both the sysctl and a self-test failure that shows a denied user namespace. + const refusal = (evidence: { appArmor?: boolean } = {}) => unsupported(platform, { bwrapInstalled: options.some((o) => o.filesystem === "bwrap"), - appArmorRestricted: platform === "linux" && (input.appArmorRestricted ?? appArmorRestrictsUserNamespaces)(), + appArmorRestricted: + platform === "linux" && evidence.appArmor === true && (input.appArmorRestricted ?? appArmorRestrictsUserNamespaces)(), }) if (!options.length) return { @@ -375,7 +415,7 @@ export async function detectConfinement(input: { : refusal(), } const work = realpathSync(await fs.mkdtemp(path.join(os.tmpdir(), "altimate-python-sandbox-"))) - const elsewhere = realpathSync(await fs.mkdtemp(path.join(os.tmpdir(), "altimate-python-canary-"))) + let canaryDirs: string[] = [] try { const paths = await interpreterPaths(input.python, work, input.env(work)) if (!paths) @@ -385,9 +425,12 @@ export async function detectConfinement(input: { } const allow = readAllowlist(platform, paths) if (!allow.ok) return { ok: false, reason: `python_exec refused: ${allow.reason}.` } - const canary = path.join(elsewhere, "canary.txt") + const excluded = [...allow.directories, ...allow.files, ...allow.links, work] + const canaries = await createCanaries(excluded) + canaryDirs = canaries.dirs + if (!canaries.files.length) + return { ok: false, reason: `python_exec refused: could not create a self-test canary outside the sandbox allowlist. ${REFUSAL_TAIL}` } const inside = path.join(work, "inside.txt") - await fs.writeFile(canary, "outside") await fs.writeFile(inside, "inside") await fs.mkdir(path.join(work, "tmp")) const cell = path.join(work, "selftest.py") @@ -397,13 +440,18 @@ export async function detectConfinement(input: { "import duckdb, pandas, subprocess", `assert open(${JSON.stringify(inside)}).read() == "inside"`, `assert subprocess.run(["cat", ${JSON.stringify(inside)}], capture_output=True).stdout == b"inside"`, - "try:", - ` open(${JSON.stringify(canary)}).read()`, - " print('PYTHON_LEAK')", - "except OSError:", - " print('PYTHON_CONFINED')", - `result = subprocess.run(["cat", ${JSON.stringify(canary)}], capture_output=True)`, - "print('SUBPROCESS_CONFINED' if result.returncode != 0 and b'outside' not in result.stdout else 'SUBPROCESS_LEAK')", + "py_ok = sub_ok = True", + `for canary in ${JSON.stringify(canaries.files)}:`, + " try:", + " open(canary).read()", + " py_ok = False", + " except OSError:", + " pass", + ' result = subprocess.run(["cat", canary], capture_output=True)', + " if result.returncode == 0 or b'outside' in result.stdout:", + " sub_ok = False", + "print('PYTHON_CONFINED' if py_ok else 'PYTHON_LEAK')", + "print('SUBPROCESS_CONFINED' if sub_ok else 'SUBPROCESS_LEAK')", ].join("\n"), ) const bootstrap = path.join(work, "_bootstrap.py") @@ -439,9 +487,11 @@ export async function detectConfinement(input: { ok: false, reason: `python_exec refused: sandbox-exec self-test failed: ${failures.join("; ")}. ${REFUSAL_TAIL}`, } - return { ok: false, reason: `${refusal()} Self-test results: ${failures.join("; ")}` } + const denied = failures.some(indicatesUserNamespaceDenial) + const base = refusal({ appArmor: denied }) + return { ok: false, reason: `${base} Self-test results: ${failures.join("; ")}` } } finally { await fs.rm(work, { recursive: true, force: true }) - await fs.rm(elsewhere, { recursive: true, force: true }) + for (const dir of canaryDirs) await fs.rm(dir, { recursive: true, force: true }) } } diff --git a/packages/opencode/test/altimate/tools/python-exec.test.ts b/packages/opencode/test/altimate/tools/python-exec.test.ts index b64b784df3..4de23ab35e 100644 --- a/packages/opencode/test/altimate/tools/python-exec.test.ts +++ b/packages/opencode/test/altimate/tools/python-exec.test.ts @@ -387,7 +387,7 @@ describe("python_exec", () => { "import socket\ntry:\n socket.create_connection(('1.1.1.1', 53), timeout=1)\n print('CONNECTED')\nexcept OSError:\n print('BLOCKED')" const result = await tool.execute({ code, inputs: [] }, ctx) expect(result.metadata.ok).toBe(true) - expect(["sandbox-exec", "bwrap", "landlock"]).toContain(result.metadata.filesystem) + expect(["sandbox-exec", "bwrap"]).toContain(result.metadata.filesystem) expect(result.output).toContain(`filesystem ${result.metadata.filesystem}`) expect(result.output).toContain(`network ${result.metadata.network}`) if (result.metadata.network === "isolated") expect(result.output).toContain("BLOCKED") @@ -580,8 +580,8 @@ describe("python_exec", () => { 20_000, ) - test("python_exec refuses when the host has no working sandbox", async () => { - if (detected?.ok || !python) return + const unsandboxed = python && !detected?.ok ? test : test.skip + unsandboxed("python_exec refuses when the host has no working sandbox", async () => { const tool = await initTool(PythonExecTool) const result = await tool.execute({ code: "print(1)" }, ctx) expect(result.metadata.ok).toBe(false) @@ -589,10 +589,9 @@ describe("python_exec", () => { expect(result.output).toContain("never run unconfined") }) - sandboxed( + test.skipIf(process.platform !== "darwin" || !detected?.ok)( "sandboxed code cannot signal the agent or other host processes", async () => { - if (process.platform !== "darwin") return const outside = Bun.spawn(["sleep", "7333"]) try { const tool = await initTool(PythonExecTool) @@ -812,6 +811,32 @@ describe("python_exec confinement", () => { expect(Sandbox.unsupported("linux", { bwrapInstalled: true, appArmorRestricted: false })).not.toContain("AppArmor") }) + test("the AppArmor message needs a user-namespace denial in the self-test AND the sysctl", () => { + expect(Sandbox.indicatesUserNamespaceDenial("bwrap: setting up uid map: Permission denied")).toBe(true) + expect(Sandbox.indicatesUserNamespaceDenial("bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted")).toBe(true) + expect(Sandbox.indicatesUserNamespaceDenial("PYTHON_LEAK")).toBe(false) + expect(Sandbox.indicatesUserNamespaceDenial("ModuleNotFoundError: No module named 'pandas'")).toBe(false) + }) + + test("self-test canaries are outside every allowlisted path, /tmp and the private dir is 0700", async () => { + const excluded = ["/usr", "/lib", "/etc/ssl", "/tmp", "/tmp/session"] + const base = await fs.mkdtemp(path.join(os.homedir(), ".altimate-canary-test-")) + try { + const { files, dirs } = await Sandbox.createCanaries(excluded, [base]) + expect(files.length).toBe(1) + for (const file of files) + for (const e of excluded) expect(file === e || file.startsWith(e + "/")).toBe(false) + expect(files[0].startsWith("/tmp/")).toBe(false) + expect((await fs.stat(dirs[0])).mode & 0o777).toBe(0o700) + // A root that is itself allowlisted yields no canary. + const none = await Sandbox.createCanaries([base], [base]) + expect(none.files).toEqual([]) + for (const d of [...dirs, ...none.dirs]) await fs.rm(d, { recursive: true, force: true }) + } finally { + await fs.rm(base, { recursive: true, force: true }) + } + }) + test("unsupported platforms are refused", async () => { for (const platform of ["win32", "freebsd"] as const) { const result = await Sandbox.detectConfinement({ @@ -906,9 +931,7 @@ describe("python_exec confinement", () => { const result = await confinementFor(python) expect(result.ok).toBe(true) if (result.ok) - expect(result.confinement.filesystem).toBe( - process.platform === "darwin" ? "sandbox-exec" : result.confinement.filesystem, - ) + expect(result.confinement.filesystem).toBe(process.platform === "darwin" ? "sandbox-exec" : "bwrap") }, 30_000, ) From 6fff449ccd46891dd2dd6d5b6c46578e38b4da19 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 17 Oct 2026 00:34:04 -0700 Subject: [PATCH 08/14] fix(analyst): run the macOS process snapshot under the C locale `ps` inherited the agent locale, so a non-English `lstart` would fail to parse and descendant identity checks would silently skip every process. Co-Authored-By: Claude Opus 5.5 --- packages/opencode/src/altimate/tools/python-exec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/altimate/tools/python-exec.ts b/packages/opencode/src/altimate/tools/python-exec.ts index 60e6668568..9d6ad4761f 100644 --- a/packages/opencode/src/altimate/tools/python-exec.ts +++ b/packages/opencode/src/altimate/tools/python-exec.ts @@ -362,7 +362,8 @@ function run( execFile( "/bin/ps", ["-A", "-o", "pid=,ppid=,uid=,lstart=,comm="], - { encoding: "utf8", timeout: 2000 }, + // A fixed C locale keeps `lstart` in the English format parseProcessTable expects. + { encoding: "utf8", timeout: 2000, env: { PATH: "/usr/bin:/bin", LC_ALL: "C", LANG: "C" } }, (_error, out) => done(parseProcessTable(out ?? "")), ) }) From c248e3240420dfb1ae6318082dbedac1947060ad Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 17 Oct 2026 00:49:09 -0700 Subject: [PATCH 09/14] fix(analyst): return from `python_exec` only after descendant cleanup completes `finish` could resolve while the asynchronous kill of escaped descendants was still in flight, so a detached child could keep writing to the workspace while results were imported. The kill is now a single shared promise that `finish` awaits; the pipe-grace timeout path is unchanged. Co-Authored-By: Claude Opus 5.5 --- packages/opencode/src/altimate/tools/python-exec.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/altimate/tools/python-exec.ts b/packages/opencode/src/altimate/tools/python-exec.ts index 9d6ad4761f..b6fe816efd 100644 --- a/packages/opencode/src/altimate/tools/python-exec.ts +++ b/packages/opencode/src/altimate/tools/python-exec.ts @@ -386,10 +386,10 @@ function run( // member with no pid-identity question. Descendants that escaped the group (setsid) are // signalled only when still provably the same process; anything else is left alone and noted. // On Linux the bwrap PID namespace (--die-with-parent) also ends everything when bwrap dies. - let killing = false - const kill = async () => { - if (killing) return - killing = true + // finish awaits this so the call never returns while descendant cleanup is still in flight. + let killing: Promise | undefined + const kill = () => (killing ??= killNow()) + const killNow = async () => { let targets: number[] = [] try { await sweeping?.catch(() => {}) @@ -421,6 +421,7 @@ function run( abort.removeEventListener("abort", onAbort) child.stdout.destroy() child.stderr.destroy() + await killing?.catch(() => {}) await checking resolve({ code: exitCode, signal: exitSignal, stdout, stderr, timedOut, workspaceError, orphaned }) } From 01379b70f44fcf8d0fe6e34d8b38cadd461bf550 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 17 Oct 2026 08:09:53 -0700 Subject: [PATCH 10/14] fix(storage): serialize first-run database setup across concurrent processes Concurrent fresh starts against one data directory failed with "database is locked". The core `sqlite.bun.ts` opener ran `PRAGMA journal_mode = WAL` before any busy handler existed, and both the legacy `Database.Client` and the core `Database` layer ran migrations whose deferred read-to-write upgrade fails immediately with `SQLITE_BUSY` instead of waiting. - Install `busy_timeout` before the WAL switch in both openers. - Add `setup-lock.ts`: an exclusive transaction on a sentinel database is a cross-process mutex that the OS releases if the holder dies. Synchronous sections block on it; the effect-based migration polls so in-process peers keep progressing. - Add a regression test that opens both database layers from 4 simultaneous processes against a fresh data directory. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/database/database.ts | 37 ++++---- packages/core/src/database/setup-lock.ts | 85 +++++++++++++++++++ packages/core/src/database/sqlite.bun.ts | 24 +++++- packages/core/src/database/sqlite.node.ts | 23 ++++- packages/opencode/src/storage/db.ts | 21 ++++- .../test/storage/concurrent-open.test.ts | 40 +++++++++ .../test/storage/fixture/open-database.ts | 19 +++++ 7 files changed, 230 insertions(+), 19 deletions(-) create mode 100644 packages/core/src/database/setup-lock.ts create mode 100644 packages/opencode/test/storage/concurrent-open.test.ts create mode 100644 packages/opencode/test/storage/fixture/open-database.ts diff --git a/packages/core/src/database/database.ts b/packages/core/src/database/database.ts index 6879212c41..164c506da2 100644 --- a/packages/core/src/database/database.ts +++ b/packages/core/src/database/database.ts @@ -2,6 +2,7 @@ export * as Database from "./database" import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" import { layer as sqliteLayer } from "#sqlite" +import * as SetupLock from "./setup-lock" import { Context, Effect, Layer } from "effect" import { Global } from "../global" import { Flag } from "../flag/flag" @@ -19,25 +20,31 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/storage/Database") {} -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const db = yield* makeDatabase +export const layer = (filename?: string) => + Layer.effect( + Service, + Effect.gen(function* () { + const db = yield* makeDatabase - yield* db.run("PRAGMA journal_mode = WAL") - yield* db.run("PRAGMA synchronous = NORMAL") - yield* db.run("PRAGMA busy_timeout = 5000") - yield* db.run("PRAGMA cache_size = -64000") - yield* db.run("PRAGMA foreign_keys = ON") - yield* db.run("PRAGMA wal_checkpoint(PASSIVE)") - yield* DatabaseMigration.apply(db) + yield* db.run("PRAGMA busy_timeout = 5000") + // altimate_change start — upstream_fix: serialize first-run setup across processes (see setup-lock.ts) + const setup = Effect.gen(function* () { + yield* db.run("PRAGMA journal_mode = WAL") + yield* db.run("PRAGMA synchronous = NORMAL") + yield* db.run("PRAGMA cache_size = -64000") + yield* db.run("PRAGMA foreign_keys = ON") + yield* db.run("PRAGMA wal_checkpoint(PASSIVE)") + yield* DatabaseMigration.apply(db) + }) + yield* filename ? SetupLock.hold(filename, setup) : setup + // altimate_change end - return { db } - }).pipe(Effect.orDie), -) + return { db } + }).pipe(Effect.orDie), + ) export function layerFromPath(filename: string) { - return layer.pipe(Layer.provide(sqliteLayer({ filename }))) + return layer(filename).pipe(Layer.provide(sqliteLayer({ filename }))) } export function path() { diff --git a/packages/core/src/database/setup-lock.ts b/packages/core/src/database/setup-lock.ts new file mode 100644 index 0000000000..ba7eedbb91 --- /dev/null +++ b/packages/core/src/database/setup-lock.ts @@ -0,0 +1,85 @@ +import { Effect, Semaphore } from "effect" +import { openSetupLock } from "#sqlite" + +/** + * Cross-process mutex for first-run database setup (WAL switch and migrations). + * + * SQLite cannot queue a deferred read-to-write upgrade, so concurrent fresh starts failed at once with + * "database is locked". An exclusive transaction on a sentinel database next to the real one serializes + * them, and the OS releases it if the holder dies. + * + * `acquireSync` blocks the thread, so it is only for sections that never yield. A section that yields + * (the effect-based migration) uses `hold`, which polls without blocking. A synchronous caller in the + * same process as an active `hold` skips the lock: blocking would deadlock the holder, and connections + * in one process already queue through their own busy handlers. + */ +const semaphores = new Map() +const active = new Set() + +const sentinel = (filename: string) => filename + ".setup-lock" +const SYNC_TIMEOUT_MS = 60_000 +const POLL_MS = 25 +const HOLD_TIMEOUT_MS = 60_000 + +export function acquireSync(filename: string): () => void { + if (filename === ":memory:" || active.has(filename)) return () => {} + const lock = openSetupLock(sentinel(filename), SYNC_TIMEOUT_MS) + try { + lock.exec("BEGIN EXCLUSIVE") + } catch (error) { + lock.close() + throw error + } + return () => { + try { + lock.exec("ROLLBACK") + } finally { + lock.close() + } + } +} + +function isBusy(error: unknown) { + return /locked|busy/i.test(error instanceof Error ? error.message : String(error)) +} + +/** Runs `body` holding the lock; waiting is async so in-process peers keep making progress. */ +export function hold(filename: string, body: Effect.Effect): Effect.Effect { + if (filename === ":memory:") return body + let semaphore = semaphores.get(filename) + if (!semaphore) { + semaphore = Semaphore.makeUnsafe(1) + semaphores.set(filename, semaphore) + } + const acquire = Effect.gen(function* () { + const started = Date.now() + while (true) { + const lock = openSetupLock(sentinel(filename), 0) + const result = yield* Effect.try({ try: () => lock.exec("BEGIN EXCLUSIVE"), catch: (error) => error }).pipe( + Effect.map(() => lock), + Effect.catch((error) => + Effect.sync(() => lock.close()).pipe( + Effect.flatMap(() => + isBusy(error) && Date.now() - started < HOLD_TIMEOUT_MS ? Effect.succeed(undefined) : Effect.die(error), + ), + ), + ), + ) + if (result) { + active.add(filename) + return result + } + yield* Effect.sleep(POLL_MS) + } + }) + const release = (lock: { exec: (sql: string) => void; close: () => void }) => + Effect.sync(() => { + active.delete(filename) + try { + lock.exec("ROLLBACK") + } finally { + lock.close() + } + }) + return semaphore.withPermits(1)(Effect.acquireUseRelease(acquire, () => body, release)) +} diff --git a/packages/core/src/database/sqlite.bun.ts b/packages/core/src/database/sqlite.bun.ts index e15f4c117e..a99db33330 100644 --- a/packages/core/src/database/sqlite.bun.ts +++ b/packages/core/src/database/sqlite.bun.ts @@ -14,6 +14,9 @@ import type { Connection } from "effect/unstable/sql/SqlConnection" import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError" import * as Statement from "effect/unstable/sql/Statement" import { Sqlite } from "./sqlite" +// altimate_change start — upstream_fix: cross-process first-run setup lock +import * as SetupLock from "./setup-lock" +// altimate_change end const ATTR_DB_SYSTEM_NAME = "db.system.name" @@ -161,7 +164,18 @@ const nativeLayer = (config: Config) => create: config.create ?? true, }) yield* Effect.addFinalizer(() => Effect.sync(() => native.close())) - if (config.disableWAL !== true) native.run("PRAGMA journal_mode = WAL;") + // altimate_change start — upstream_fix: the WAL switch needs a write lock and can fail at once with + // SQLITE_BUSY, so install the busy handler first and serialize the switch across processes. + native.run("PRAGMA busy_timeout = 5000;") + if (config.disableWAL !== true) { + const release = SetupLock.acquireSync(config.filename) + try { + native.run("PRAGMA journal_mode = WAL;") + } finally { + release() + } + } + // altimate_change end return native }), ) @@ -181,3 +195,11 @@ export const layer = (config: Config) => { Layer.provide(Reactivity.layer), ) } + +// altimate_change start — upstream_fix: sentinel database used as a cross-process mutex (see setup-lock.ts) +export function openSetupLock(filename: string, timeoutMs: number) { + const lock = new Database(filename, { create: true }) + lock.run(`PRAGMA busy_timeout = ${timeoutMs}`) + return { exec: (sql: string) => void lock.run(sql), close: () => lock.close() } +} +// altimate_change end diff --git a/packages/core/src/database/sqlite.node.ts b/packages/core/src/database/sqlite.node.ts index 6eaecbee26..ca9e6c4f9e 100644 --- a/packages/core/src/database/sqlite.node.ts +++ b/packages/core/src/database/sqlite.node.ts @@ -14,6 +14,9 @@ import type { Connection } from "effect/unstable/sql/SqlConnection" import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError" import * as Statement from "effect/unstable/sql/Statement" import { Sqlite } from "./sqlite" +// altimate_change start — upstream_fix: cross-process first-run setup lock +import * as SetupLock from "./setup-lock" +// altimate_change end const ATTR_DB_SYSTEM_NAME = "db.system.name" @@ -156,7 +159,18 @@ const nativeLayer = (config: Config) => open: true, }) yield* Effect.addFinalizer(() => Effect.sync(() => native.close())) - if (config.disableWAL !== true && config.readonly !== true) native.exec("PRAGMA journal_mode = WAL;") + // altimate_change start — upstream_fix: the WAL switch needs a write lock and can fail at once with + // SQLITE_BUSY, so install the busy handler first and serialize the switch across processes. + native.exec("PRAGMA busy_timeout = 5000;") + if (config.disableWAL !== true && config.readonly !== true) { + const release = SetupLock.acquireSync(config.filename) + try { + native.exec("PRAGMA journal_mode = WAL;") + } finally { + release() + } + } + // altimate_change end return native }), ) @@ -176,3 +190,10 @@ export const layer = (config: Config) => { Layer.provide(Reactivity.layer), ) } + +// altimate_change start — upstream_fix: sentinel database used as a cross-process mutex (see setup-lock.ts) +export function openSetupLock(filename: string, timeoutMs: number) { + const lock = new DatabaseSync(filename, { timeout: timeoutMs }) + return { exec: (sql: string) => void lock.exec(sql), close: () => lock.close() } +} +// altimate_change end diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index a6d9154361..d263f5ffda 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -14,6 +14,9 @@ import { readFileSync, readdirSync, existsSync } from "fs" import * as schema from "./schema" import { InstallationChannel } from "@opencode-ai/core/installation/version" import { Flag } from "../flag/flag" +// altimate_change start — upstream_fix: cross-process first-run setup lock +import * as SetupLock from "@opencode-ai/core/database/setup-lock" +// altimate_change end import { iife } from "@/util/iife" import { Effect } from "effect" import freshSchema from "@opencode-ai/core/database/schema.gen" @@ -394,13 +397,27 @@ export namespace Database { export const Client = lazy(() => { log.info("opening database", { path: Path }) + // altimate_change start — upstream_fix: serialize first-run setup across processes (see core setup-lock.ts) + const releaseSetupLock = SetupLock.acquireSync(Path) + try { + return open() + } finally { + releaseSetupLock() + } + // altimate_change end + }) + function open() { const sqlite = new BunDatabase(Path, { create: true }) state.sqlite = sqlite + // altimate_change start — upstream_fix: install the busy handler before the first statement that + // takes a lock. Switching a fresh file to WAL needs an exclusive lock, so concurrent first-run + // processes failed immediately with "database is locked" when this came after journal_mode. + sqlite.run("PRAGMA busy_timeout = 5000") + // altimate_change end sqlite.run("PRAGMA journal_mode = WAL") sqlite.run("PRAGMA synchronous = NORMAL") - sqlite.run("PRAGMA busy_timeout = 5000") sqlite.run("PRAGMA cache_size = -64000") sqlite.run("PRAGMA foreign_keys = ON") sqlite.run("PRAGMA wal_checkpoint(PASSIVE)") @@ -446,7 +463,7 @@ export namespace Database { } return db - }) + } export function close() { const sqlite = state.sqlite diff --git a/packages/opencode/test/storage/concurrent-open.test.ts b/packages/opencode/test/storage/concurrent-open.test.ts new file mode 100644 index 0000000000..9d13179ea1 --- /dev/null +++ b/packages/opencode/test/storage/concurrent-open.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import os from "os" +import path from "path" + +const fixture = path.join(import.meta.dir, "fixture", "open-database.ts") + +async function launch(data: string) { + const proc = Bun.spawn(["bun", "run", fixture], { + cwd: path.join(import.meta.dir, "..", ".."), + env: { ...process.env, XDG_DATA_HOME: data, OPENCODE_DB: "" }, + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + return { stdout, stderr, code } +} + +describe("concurrent first-run database setup", () => { + test("simultaneous fresh processes never fail with SQLITE_BUSY", async () => { + // Fresh data directories exercise the WAL switch and migrations, where concurrent starts raced. + for (let round = 0; round < 3; round++) { + const data = await fs.mkdtemp(path.join(os.tmpdir(), "altimate-concurrent-db-")) + try { + const results = await Promise.all(Array.from({ length: 4 }, () => launch(data))) + for (const result of results) { + expect(result.stdout + result.stderr).not.toMatch(/database is locked|SQLITE_BUSY/i) + expect(result.code).toBe(0) + expect(result.stdout).toContain("opened") + } + } finally { + await fs.rm(data, { recursive: true, force: true }) + } + } + }, 120_000) +}) diff --git a/packages/opencode/test/storage/fixture/open-database.ts b/packages/opencode/test/storage/fixture/open-database.ts new file mode 100644 index 0000000000..999ed9396e --- /dev/null +++ b/packages/opencode/test/storage/fixture/open-database.ts @@ -0,0 +1,19 @@ +// Child process for concurrent-open.test.ts: opens both database layers against the shared data directory. +import { Effect } from "effect" +import { Database } from "../../../src/storage/db" +import { Database as CoreDatabase } from "@opencode-ai/core/database/database" + +try { + Database.Client() + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* CoreDatabase.Service + }).pipe(Effect.provide(CoreDatabase.layerFromPath(Database.Path))), + ), + ) + console.log("opened") +} catch (error) { + console.log("FAILED: " + (error instanceof Error ? error.message : String(error))) + process.exitCode = 1 +} From ae84ca4d27280ac2e6fc8fd39b7dfda7038a4e11 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 17 Oct 2026 08:19:10 -0700 Subject: [PATCH 11/14] chore: mark the first-run setup changes in the shared database layer Wraps the setup-lock import, the reworked `layer` and `layerFromPath` in a single `altimate_change` upstream_fix block so Marker Guard passes. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/database/database.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/core/src/database/database.ts b/packages/core/src/database/database.ts index 164c506da2..86121618bc 100644 --- a/packages/core/src/database/database.ts +++ b/packages/core/src/database/database.ts @@ -2,7 +2,9 @@ export * as Database from "./database" import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" import { layer as sqliteLayer } from "#sqlite" +// altimate_change start — upstream_fix: cross-process first-run setup lock import * as SetupLock from "./setup-lock" +// altimate_change end import { Context, Effect, Layer } from "effect" import { Global } from "../global" import { Flag } from "../flag/flag" @@ -20,6 +22,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/storage/Database") {} +// altimate_change start — upstream_fix: set busy_timeout first and serialize first-run setup across processes (see setup-lock.ts) export const layer = (filename?: string) => Layer.effect( Service, @@ -27,7 +30,6 @@ export const layer = (filename?: string) => const db = yield* makeDatabase yield* db.run("PRAGMA busy_timeout = 5000") - // altimate_change start — upstream_fix: serialize first-run setup across processes (see setup-lock.ts) const setup = Effect.gen(function* () { yield* db.run("PRAGMA journal_mode = WAL") yield* db.run("PRAGMA synchronous = NORMAL") @@ -37,7 +39,6 @@ export const layer = (filename?: string) => yield* DatabaseMigration.apply(db) }) yield* filename ? SetupLock.hold(filename, setup) : setup - // altimate_change end return { db } }).pipe(Effect.orDie), @@ -46,6 +47,7 @@ export const layer = (filename?: string) => export function layerFromPath(filename: string) { return layer(filename).pipe(Layer.provide(sqliteLayer({ filename }))) } +// altimate_change end export function path() { if (Flag.OPENCODE_DB) { From 2bf4155b2bf72a2c9197277e672254c925c39191 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 17 Oct 2026 08:46:31 -0700 Subject: [PATCH 12/14] chore: sync the shared database first-open fix with #1541 Co-Authored-By: Claude Opus 5.5 --- packages/core/src/database/database.ts | 2 +- packages/core/src/database/setup-lock.ts | 3 ++- packages/core/src/database/sqlite.bun.ts | 2 +- packages/core/src/database/sqlite.node.ts | 3 ++- packages/opencode/src/storage/db.ts | 15 +++++++++++++-- .../opencode/test/storage/concurrent-open.test.ts | 4 ++-- 6 files changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/core/src/database/database.ts b/packages/core/src/database/database.ts index 86121618bc..419b235ff7 100644 --- a/packages/core/src/database/database.ts +++ b/packages/core/src/database/database.ts @@ -22,7 +22,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/storage/Database") {} -// altimate_change start — upstream_fix: set busy_timeout first and serialize first-run setup across processes (see setup-lock.ts) +// altimate_change start — upstream_fix: `layer` takes the database filename so first-run setup can be serialized export const layer = (filename?: string) => Layer.effect( Service, diff --git a/packages/core/src/database/setup-lock.ts b/packages/core/src/database/setup-lock.ts index ba7eedbb91..739e4936dc 100644 --- a/packages/core/src/database/setup-lock.ts +++ b/packages/core/src/database/setup-lock.ts @@ -69,7 +69,8 @@ export function hold(filename: string, body: Effect.Effect): E active.add(filename) return result } - yield* Effect.sleep(POLL_MS) + // `acquireUseRelease` makes `acquire` uninterruptible; no handle is open while sleeping, so cancel here. + yield* Effect.interruptible(Effect.sleep(POLL_MS)) } }) const release = (lock: { exec: (sql: string) => void; close: () => void }) => diff --git a/packages/core/src/database/sqlite.bun.ts b/packages/core/src/database/sqlite.bun.ts index a99db33330..600575bb7f 100644 --- a/packages/core/src/database/sqlite.bun.ts +++ b/packages/core/src/database/sqlite.bun.ts @@ -167,7 +167,7 @@ const nativeLayer = (config: Config) => // altimate_change start — upstream_fix: the WAL switch needs a write lock and can fail at once with // SQLITE_BUSY, so install the busy handler first and serialize the switch across processes. native.run("PRAGMA busy_timeout = 5000;") - if (config.disableWAL !== true) { + if (config.disableWAL !== true && config.readonly !== true) { const release = SetupLock.acquireSync(config.filename) try { native.run("PRAGMA journal_mode = WAL;") diff --git a/packages/core/src/database/sqlite.node.ts b/packages/core/src/database/sqlite.node.ts index ca9e6c4f9e..b9efd5a4d4 100644 --- a/packages/core/src/database/sqlite.node.ts +++ b/packages/core/src/database/sqlite.node.ts @@ -161,7 +161,7 @@ const nativeLayer = (config: Config) => yield* Effect.addFinalizer(() => Effect.sync(() => native.close())) // altimate_change start — upstream_fix: the WAL switch needs a write lock and can fail at once with // SQLITE_BUSY, so install the busy handler first and serialize the switch across processes. - native.exec("PRAGMA busy_timeout = 5000;") + native.exec(`PRAGMA busy_timeout = ${config.timeout ?? 5000};`) if (config.disableWAL !== true && config.readonly !== true) { const release = SetupLock.acquireSync(config.filename) try { @@ -194,6 +194,7 @@ export const layer = (config: Config) => { // altimate_change start — upstream_fix: sentinel database used as a cross-process mutex (see setup-lock.ts) export function openSetupLock(filename: string, timeoutMs: number) { const lock = new DatabaseSync(filename, { timeout: timeoutMs }) + lock.exec(`PRAGMA busy_timeout = ${timeoutMs}`) return { exec: (sql: string) => void lock.exec(sql), close: () => lock.close() } } // altimate_change end diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index d263f5ffda..8bff197bfe 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -407,6 +407,7 @@ export namespace Database { // altimate_change end }) + // altimate_change start — upstream_fix: setup extracted from the lazy Client so it runs under the setup lock function open() { const sqlite = new BunDatabase(Path, { create: true }) state.sqlite = sqlite @@ -439,8 +440,17 @@ export namespace Database { item.sql = "select 1;" } } - const adopted = adoptCoreOwnedDatabase(sqlite, entries) - const initialized = adopted ? false : initializeFreshDatabase(sqlite, entries) + // altimate_change start — upstream_fix: the schema-state checks share the write transaction, so a + // same-process core migration cannot create the schema between a check and the CREATE TABLEs + let adopted = false + let initialized = false + sqlite + .transaction(() => { + adopted = adoptCoreOwnedDatabase(sqlite, entries) + initialized = adopted ? false : initializeFreshDatabase(sqlite, entries) + }) + .immediate() + // altimate_change end const repaired = adopted || initialized ? false : repairLegacyDatabase(sqlite, entries) // altimate_change start — backfill migration names before migrate backfillMigrationNames(sqlite, entries) @@ -464,6 +474,7 @@ export namespace Database { return db } + // altimate_change end export function close() { const sqlite = state.sqlite diff --git a/packages/opencode/test/storage/concurrent-open.test.ts b/packages/opencode/test/storage/concurrent-open.test.ts index 9d13179ea1..628090a260 100644 --- a/packages/opencode/test/storage/concurrent-open.test.ts +++ b/packages/opencode/test/storage/concurrent-open.test.ts @@ -6,9 +6,9 @@ import path from "path" const fixture = path.join(import.meta.dir, "fixture", "open-database.ts") async function launch(data: string) { - const proc = Bun.spawn(["bun", "run", fixture], { + const proc = Bun.spawn([process.execPath, "run", fixture], { cwd: path.join(import.meta.dir, "..", ".."), - env: { ...process.env, XDG_DATA_HOME: data, OPENCODE_DB: "" }, + env: { ...process.env, XDG_DATA_HOME: data, OPENCODE_DB: "", ALTIMATE_CLI_DB: "" }, stdout: "pipe", stderr: "pipe", }) From b9f6ccf302253656dc9965c30326f26572b3f33c Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 17 Oct 2026 12:08:38 -0700 Subject: [PATCH 13/14] fix: [review] python_exec macOS sandbox denies child processes and fails closed on host environment exposure - macOS profile: `(deny process-fork)` blocks fork, posix_spawn, `subprocess` and `multiprocessing`, so a double-forked `setsid` grandchild can no longer escape cleanup; `(deny process-info*)` + `(allow process-info* (target self))` blocks proc_info inspection of other processes; signals limited to self. - Seatbelt does not mediate the `KERN_PROCARGS2` sysctl (verified on macOS 26.5: `(deny sysctl*)`, name filters, `process-info*` and deny-default all still return same-user process environments). Blocking `ctypes` is not an option because pandas imports it. The self-test now starts a host canary process with a marker in its environment and refuses any mechanism under which sandboxed Python can read it, so macOS currently refuses `python_exec` with that reason. - Self-test on macOS also requires every child-process attempt to fail; on Linux it keeps the subprocess canary check (bwrap already uses `--unshare-all` and `--die-with-parent`). - Tool description notes that child processes are blocked on macOS. - Tests: profile-level sandbox-exec regressions (fork/spawn denial with no surviving grandchild, pandas/NumPy/DuckDB still work, readable host environment forces refusal), self-test fails closed when fork denial is stripped, tool-level host-environment and double-fork checks; subprocess tests are now bwrap-only. Co-Authored-By: Claude Opus 5.5 --- .../src/altimate/tools/python-exec.ts | 11 +- .../src/altimate/tools/python-sandbox.ts | 226 ++++++++++++---- .../test/altimate/tools/python-exec.test.ts | 247 +++++++++++++++++- 3 files changed, 425 insertions(+), 59 deletions(-) diff --git a/packages/opencode/src/altimate/tools/python-exec.ts b/packages/opencode/src/altimate/tools/python-exec.ts index b6fe816efd..a30dc4f30e 100644 --- a/packages/opencode/src/altimate/tools/python-exec.ts +++ b/packages/opencode/src/altimate/tools/python-exec.ts @@ -6,7 +6,9 @@ * code are imported back into scratch. The child gets a minimal environment, a memory * limit, a deadline, and OS confinement (see `python-sandbox.ts`): reads are limited to the * session directory, the interpreter installation and required system files, writes to the - * session directory, and network is blocked. Without an enforced mechanism the tool refuses. + * session directory, network is blocked, host process environments are unreadable, and child + * processes are denied (macOS) or confined to a PID namespace that dies with the sandbox (Linux). + * Without an enforced mechanism the tool refuses. */ import z from "zod" import { execFile, spawn } from "node:child_process" @@ -222,7 +224,11 @@ async function workspaceSize(directory: string): Promise { /** How long output may keep draining after the sandbox is killed before the pipes are closed. */ const PIPE_GRACE_MS = 300 -/** Descendant sweep period (macOS only); kept coarse so `ps` never competes with the event loop. */ +/** + * Descendant sweep period (macOS only); kept coarse so `ps` never competes with the event loop. + * The sandbox profile denies fork there, so the sweep is a second line of defence: a polling sweep + * alone cannot see a double-forked `setsid` grandchild whose parent exits between snapshots. + */ const TRACK_INTERVAL_MS = 500 type RunResult = { @@ -474,6 +480,7 @@ export const PythonExecTool = Tool.define("python_exec", { "The working directory (also `workspace`) is a size-capped, session-private path: files persist across calls " + "and are deleted with the session; exceeding the cap clears the workspace. " + "Each call starts a fresh process with pandas and duckdb available. " + + "On macOS the sandbox blocks child processes (subprocess, multiprocessing, os.fork); use in-process libraries. " + "Print results to see them; long output is clipped. Omit timeout_ms to use the configured default.", parameters: z.object({ code: z.string().describe("Python source to run"), diff --git a/packages/opencode/src/altimate/tools/python-sandbox.ts b/packages/opencode/src/altimate/tools/python-sandbox.ts index a7a7b7491a..9bdf1fe835 100644 --- a/packages/opencode/src/altimate/tools/python-sandbox.ts +++ b/packages/opencode/src/altimate/tools/python-sandbox.ts @@ -2,22 +2,30 @@ * OS confinement for `python_exec`. * * Analyst Python may read only its session directory, the interpreter installation and - * the system files an interpreter needs; it may write only its session directory. - * Subprocesses inherit the same confinement. Where no enforced mechanism exists the tool - * refuses to run instead of running unconfined. + * the system files an interpreter needs; it may write only its session directory, and it may + * not read the arguments or environment of host processes. Where no enforced mechanism exists + * the tool refuses to run instead of running unconfined. * * - macOS: `sandbox-exec` profile that denies file reads and writes outside the allowlist, - * signals to processes other than itself and its children, network, Apple Events and Mach services outside a small allowlist (LaunchServices could - * otherwise start unconfined helper processes). - * - Linux: `bwrap` with every namespace unshared and only the allowlist mounted. Without a - * working `bwrap` the tool refuses. Landlock alone is not used: it does not mediate - * `connect()` to pathname Unix sockets (Docker, D-Bus, SSH agent), which can reach host files. + * child processes (a double-forked, `setsid` grandchild could otherwise detach before any + * cleanup sees it), inspection of and signals to other processes, network, Apple Events and + * Mach services outside a small allowlist (LaunchServices could otherwise start unconfined + * helper processes). Seatbelt does not mediate the `KERN_PROCARGS2` sysctl, which returns + * the environment of any same-user process, so the self-test refuses this mechanism wherever + * that sysctl reaches a host process. + * - Linux: `bwrap` with every namespace unshared (a fresh PID namespace whose members all die + * with the sandbox, and a private `/proc`) and only the allowlist mounted. Subprocesses + * inherit the confinement. Without a working `bwrap` the tool refuses. Landlock alone is not + * used: it does not mediate `connect()` to pathname Unix sockets (Docker, D-Bus, SSH agent), + * which can reach host files. * - Other platforms: refused. * - * Every mechanism must pass a self-test (a sandboxed interpreter and a subprocess fail to read - * a canary file outside the allowlist) before it is used. + * Every mechanism must pass a self-test before it is used: the sandboxed interpreter (and, on + * Linux, a subprocess) fails to read a canary file outside the allowlist, fails to read the + * environment of a canary host process, and, on macOS, fails to start any child process. */ import { spawn } from "node:child_process" +import { randomUUID } from "node:crypto" import { existsSync, lstatSync, readFileSync, readlinkSync, realpathSync } from "node:fs" import * as fs from "node:fs/promises" import * as os from "node:os" @@ -200,7 +208,12 @@ function ancestors(p: string): string[] { } } -/** macOS Seatbelt profile: allow-default, then deny reads, writes, network, IPC and signals outside the allowlist. */ +/** + * macOS Seatbelt profile: allow-default, then deny reads, writes, network, IPC, child processes, + * and inspection of or signals to other processes. Deny-default is not used: it adds no protection + * for `KERN_PROCARGS2` (no Seatbelt operation mediates it) and would need a hand-maintained list of + * every sysctl, IOKit and Mach operation that pandas, NumPy and DuckDB touch on each macOS release. + */ export function sandboxProfile(read: ReadAllowlist, write: string[]): string { const readable = [ ...read.directories.map((d) => `(subpath ${quote(d)})`), @@ -223,9 +236,15 @@ export function sandboxProfile(read: ReadAllowlist, write: string[]): string { "(allow default)", "(deny network*)", "(deny appleevent-send)", - // Signals reach only the process itself and its own children, never the agent or any other host process. + // No fork or posix_spawn: a child could setsid and be re-parented to launchd before any + // descendant sweep sees it, keeping workspace write access after the deadline. + "(deny process-fork)", + // proc_info (paths, open files, pid lists) only for the process itself. + "(deny process-info*)", + "(allow process-info* (target self))", + // Signals reach only the process itself, never the agent or any other host process. "(deny signal)", - "(allow signal (target self) (target children))", + "(allow signal (target self))", `(deny mach-lookup (require-not (require-any ${MACH_SERVICES.map((s) => `(global-name ${quote(s)})`).join(" ")})))`, `(deny file-read* (require-not (require-any ${readable.join(" ")})))`, `(allow file-read-metadata ${metadata.join(" ")})`, @@ -297,7 +316,8 @@ export function launch(input: { return [...confinement.prefix, ...bwrapArgs(confinement.read, write, cwd), "--", python, "-I", ...args] } -async function interpreterPaths(python: string, cwd: string, env: Record) { +/** Installation roots and loaded native libraries of `python`, used to build its read allowlist. */ +export async function interpreterPaths(python: string, cwd: string, env: Record) { const result = await execute([python, "-I", "-c", PROBE], { cwd, env, timeout: 20_000 }) if (result.status !== 0) return undefined try { @@ -320,6 +340,72 @@ function candidates(platform: NodeJS.Platform): Omit[] { const REFUSAL_TAIL = "Python is never run unconfined." +/** Environment variable carried only by the self-test's host canary process. */ +const ENVIRON_CANARY = "ALTIMATE_PYTHON_SELFTEST_CANARY" + +/** + * Python defining `host_environ(pid)`: the initial argv and environment of another process as raw + * bytes (empty when denied). macOS uses the `KERN_PROCARGS2`/`KERN_PROCARGS` sysctls, Linux `/proc`. + */ +export const READ_HOST_ENVIRON = `def host_environ(pid): + import ctypes, sys + if sys.platform == "darwin": + libc = ctypes.CDLL(None, use_errno=True) + out = b"" + for node in (49, 38): # KERN_PROCARGS2, KERN_PROCARGS + mib = (ctypes.c_int * 3)(1, node, pid) + size = ctypes.c_size_t(0) + if libc.sysctl(mib, 3, None, ctypes.byref(size), None, 0) != 0: + continue + buf = ctypes.create_string_buffer(size.value) + if libc.sysctl(mib, 3, buf, ctypes.byref(size), None, 0) == 0: + out += buf.raw[: size.value] + return out + try: + with open("/proc/%d/environ" % pid, "rb") as f: + return f.read() + except OSError: + return b"" +` + +/** Self-test findings that reject a mechanism, keyed by the token the self-test prints. */ +const FINDINGS: Record = { + PYTHON_LEAK: "sandboxed Python read a file outside the allowlist", + SUBPROCESS_LEAK: "a sandboxed subprocess read a file outside the allowlist", + CHILDREN_ALLOWED: "sandboxed Python started a child process, which could detach and outlive the call", + ENVIRON_LEAK: "sandboxed Python read the environment of another process run by this user", +} +/** Why the macOS environment check fails: no Seatbelt operation mediates this sysctl. */ +const PROCARGS_NOTE = " (sandbox-exec cannot block the KERN_PROCARGS2 sysctl on this macOS version)" + +/** + * Start a host process whose environment alone carries `marker`, and resolve once it is running. + * It runs the analyst interpreter rather than a system binary: macOS already hides the environment + * of Apple platform binaries, which would make the check vacuous while the agent's stays readable. + */ +function startEnvironCanary(python: string, marker: string) { + const child = spawn(python, ["-I", "-c", "import time; print('ready', flush=True); time.sleep(300)"], { + env: { PATH: "/usr/bin:/bin", [ENVIRON_CANARY]: marker }, + stdio: ["ignore", "pipe", "ignore"], + }) + const ready = new Promise((resolve) => { + const timer = setTimeout(() => resolve(false), 10_000) + child.stdout.once("data", () => { + clearTimeout(timer) + resolve(true) + }) + child.once("error", () => { + clearTimeout(timer) + resolve(false) + }) + child.once("exit", () => { + clearTimeout(timer) + resolve(false) + }) + }) + return { child, ready } +} + /** True when the kernel blocks unprivileged user namespaces through AppArmor (Ubuntu 23.10+ default). */ function appArmorRestrictsUserNamespaces(): boolean { try { @@ -383,8 +469,10 @@ export function unsupported( /** * Pick the strongest working mechanism. A mechanism is accepted only if the sandboxed - * interpreter can import pandas and duckdb and read its own directory, while both Python and a - * `cat` subprocess fail to read a canary file outside the allowlist. + * interpreter can import pandas, NumPy and DuckDB and read its own directory, while it fails to + * read a canary file outside the allowlist and the environment of a canary host process. On + * macOS it must also fail to fork or spawn; on Linux a `cat` subprocess must fail to read the + * canary file. */ export async function detectConfinement(input: { python: string @@ -416,6 +504,7 @@ export async function detectConfinement(input: { } const work = realpathSync(await fs.mkdtemp(path.join(os.tmpdir(), "altimate-python-sandbox-"))) let canaryDirs: string[] = [] + let environCanary: ReturnType | undefined try { const paths = await interpreterPaths(input.python, work, input.env(work)) if (!paths) @@ -430,34 +519,79 @@ export async function detectConfinement(input: { canaryDirs = canaries.dirs if (!canaries.files.length) return { ok: false, reason: `python_exec refused: could not create a self-test canary outside the sandbox allowlist. ${REFUSAL_TAIL}` } + // A host process whose environment carries a marker: sandboxed code must not be able to read it. + const marker = randomUUID() + environCanary = startEnvironCanary(input.python, marker) + const host = environCanary.child.pid + // Without an unconfined read of the marker the environment check below would prove nothing. + const observed = + host !== undefined && + (await environCanary.ready) && + ( + await execute( + [ + input.python, + "-I", + "-c", + `${READ_HOST_ENVIRON}\nprint("VISIBLE" if ${JSON.stringify(marker)}.encode() in host_environ(${host}) else "HIDDEN")`, + ], + { cwd: work, env: input.env(work), timeout: 20_000 }, + ) + ).stdout.includes("VISIBLE") + if (!observed) + return { + ok: false, + reason: `python_exec refused: could not start a self-test process whose environment is observable outside the sandbox. ${REFUSAL_TAIL}`, + } const inside = path.join(work, "inside.txt") await fs.writeFile(inside, "inside") await fs.mkdir(path.join(work, "tmp")) - const cell = path.join(work, "selftest.py") - await fs.writeFile( - cell, - [ - "import duckdb, pandas, subprocess", - `assert open(${JSON.stringify(inside)}).read() == "inside"`, - `assert subprocess.run(["cat", ${JSON.stringify(inside)}], capture_output=True).stdout == b"inside"`, - "py_ok = sub_ok = True", - `for canary in ${JSON.stringify(canaries.files)}:`, - " try:", - " open(canary).read()", - " py_ok = False", - " except OSError:", - " pass", - ' result = subprocess.run(["cat", canary], capture_output=True)', - " if result.returncode == 0 or b'outside' in result.stdout:", - " sub_ok = False", - "print('PYTHON_CONFINED' if py_ok else 'PYTHON_LEAK')", - "print('SUBPROCESS_CONFINED' if sub_ok else 'SUBPROCESS_LEAK')", - ].join("\n"), - ) const bootstrap = path.join(work, "_bootstrap.py") await fs.writeFile(bootstrap, input.bootstrap) const failures: string[] = [] for (const option of options) { + // sandbox-exec must deny every child process; bwrap's PID namespace confines and reaps them. + const denyChildren = option.filesystem === "sandbox-exec" + const cell = path.join(work, `selftest-${option.filesystem}.py`) + await fs.writeFile( + cell, + [ + "import duckdb, numpy, os, pandas, subprocess", + READ_HOST_ENVIRON, + `assert open(${JSON.stringify(inside)}).read() == "inside"`, + "py_ok = True", + `for canary in ${JSON.stringify(canaries.files)}:`, + " try:", + " open(canary).read()", + " py_ok = False", + " except OSError:", + " pass", + "print('PYTHON_CONFINED' if py_ok else 'PYTHON_LEAK')", + `print('ENVIRON_LEAK' if ${JSON.stringify(marker)}.encode() in host_environ(${host}) else 'ENVIRON_CONFINED')`, + ...(denyChildren + ? [ + "started = []", + "for start in (os.fork, lambda: os.posix_spawn('/bin/echo', ['echo'], {}), lambda: subprocess.Popen(['/bin/echo']).pid):", + " try:", + " pid = start()", + " except OSError:", + " continue", + " if pid == 0:", + " os._exit(0)", + " started.append(pid)", + "print('CHILDREN_ALLOWED' if started else 'CHILDREN_DENIED')", + ] + : [ + `assert subprocess.run(["cat", ${JSON.stringify(inside)}], capture_output=True).stdout == b"inside"`, + "sub_ok = True", + `for canary in ${JSON.stringify(canaries.files)}:`, + ' result = subprocess.run(["cat", canary], capture_output=True)', + " if result.returncode == 0 or b'outside' in result.stdout:", + " sub_ok = False", + "print('SUBPROCESS_CONFINED' if sub_ok else 'SUBPROCESS_LEAK')", + ]), + ].join("\n"), + ) const confinement: Confinement = { ...option, read: { directories: allow.directories, files: allow.files, links: allow.links }, @@ -471,15 +605,18 @@ export async function detectConfinement(input: { }) const result = await execute(argv, { cwd: work, env: input.env(work), timeout: 30_000 }) const out = result.stdout - if ( - result.status === 0 && - out.includes("PYTHON_CONFINED") && - out.includes("SUBPROCESS_CONFINED") && - !out.includes("LEAK") - ) + const required = ["PYTHON_CONFINED", "ENVIRON_CONFINED", denyChildren ? "CHILDREN_DENIED" : "SUBPROCESS_CONFINED"] + const found = Object.keys(FINDINGS).filter((token) => out.includes(token)) + if (result.status === 0 && required.every((token) => out.includes(token)) && !found.length) return { ok: true, confinement } failures.push( - `${option.filesystem}: ${(result.stderr || out || result.error || "no output").trim().split("\n").slice(-1)[0]}`, + `${option.filesystem}: ${ + found.length + ? found + .map((t) => FINDINGS[t] + (t === "ENVIRON_LEAK" && option.filesystem === "sandbox-exec" ? PROCARGS_NOTE : "")) + .join("; ") + : (result.stderr || out || result.error || "no output").trim().split("\n").slice(-1)[0] + }`, ) } if (platform === "darwin") @@ -491,6 +628,7 @@ export async function detectConfinement(input: { const base = refusal({ appArmor: denied }) return { ok: false, reason: `${base} Self-test results: ${failures.join("; ")}` } } finally { + environCanary?.child.kill("SIGKILL") await fs.rm(work, { recursive: true, force: true }) for (const dir of canaryDirs) await fs.rm(dir, { recursive: true, force: true }) } diff --git a/packages/opencode/test/altimate/tools/python-exec.test.ts b/packages/opencode/test/altimate/tools/python-exec.test.ts index 4de23ab35e..e8b6969c47 100644 --- a/packages/opencode/test/altimate/tools/python-exec.test.ts +++ b/packages/opencode/test/altimate/tools/python-exec.test.ts @@ -1,6 +1,7 @@ // altimate_change start — sandboxed analyst Python execution regression coverage import { afterAll, afterEach, beforeAll, describe, expect, spyOn, test } from "bun:test" import { spawnSync } from "node:child_process" +import { existsSync } from "node:fs" import * as fs from "node:fs/promises" import * as os from "node:os" import * as path from "node:path" @@ -52,6 +53,71 @@ const e2e = python ? test : test.skip // bubblewrap with user namespaces). Without one they are skipped and the refusal test below runs. const detected = python ? await confinementFor(python) : undefined const sandboxed = detected?.ok ? test : test.skip +// Subprocess behaviour applies only where the sandbox permits children (bwrap); macOS denies them. +const bwrapSandboxed = detected?.ok && detected.confinement.filesystem === "bwrap" ? test : test.skip +// The macOS profile itself, run through the real sandbox-exec whether or not detection accepts it. +const macProfile = python && process.platform === "darwin" && existsSync("/usr/bin/sandbox-exec") ? test : test.skip + +async function runUnderMacProfile(code: string, directory: string) { + await fs.mkdir(path.join(directory, "tmp"), { recursive: true }) + const paths = await Sandbox.interpreterPaths(python, directory, childEnv(directory)) + if (!paths) throw new Error("could not inspect the interpreter") + const allow = Sandbox.readAllowlist("darwin", paths) + if (!allow.ok) throw new Error(allow.reason) + const argv = Sandbox.launch({ + confinement: { + filesystem: "sandbox-exec", + network: "isolated", + prefix: ["/usr/bin/sandbox-exec"], + read: { directories: allow.directories, files: allow.files, links: allow.links }, + }, + write: [directory], + cwd: directory, + python, + args: ["-c", code], + }) + return Sandbox.execute(argv, { cwd: directory, env: childEnv(directory), timeout: 30_000 }) +} + +/** A host process whose environment alone carries `marker`; resolves once it is running. */ +async function environCanary(marker: string) { + const host = Bun.spawn([python, "-I", "-c", "import time; print('ready', flush=True); time.sleep(60)"], { + env: { PATH: "/usr/bin:/bin", PYEXEC_CANARY_ENV: marker }, + stdout: "pipe", + }) + await host.stdout.getReader().read() + return host +} + +const readEnvironCode = (pid: number, marker: string) => + `${Sandbox.READ_HOST_ENVIRON}\nprint("ENV_READ" if ${JSON.stringify(marker)}.encode() in host_environ(${pid}) else "ENV_DENIED")` + +/** Double fork: the intermediate parent calls setsid and exits, leaving a re-parented grandchild. */ +const doubleForkCode = (tag: string) => + [ + "import os", + "try:", + " first = os.fork()", + "except OSError as error:", + " print('FORK_DENIED', error.errno)", + "else:", + " if first == 0:", + " os.setsid()", + " if os.fork() == 0:", + ` os.execv('/bin/sleep', ['sleep', '${tag}'])`, + " os._exit(0)", + " os.waitpid(first, 0)", + " print('FORKED')", + ].join("\n") + +async function survivors(pattern: string) { + for (let i = 0; i < 20; i++) { + const out = spawnSync("pgrep", ["-f", pattern], { encoding: "utf8" }).stdout.trim() + if (!out) return "" + await Bun.sleep(100) + } + return spawnSync("pgrep", ["-f", pattern], { encoding: "utf8" }).stdout.trim() +} if (python && !detected?.ok) console.warn(`python_exec sandbox tests skipped: ${detected && !detected.ok ? detected.reason : "no Python"}`) @@ -428,7 +494,7 @@ describe("python_exec", () => { 30_000, ) - sandboxed( + bwrapSandboxed( "subprocesses inherit the read confinement", async () => { await using elsewhere = await tmpdir() @@ -537,7 +603,7 @@ describe("python_exec", () => { 15_000, ) - sandboxed( + bwrapSandboxed( "returns at the deadline when a session-detached descendant holds stdout open", async () => { const tool = await initTool(PythonExecTool) @@ -561,7 +627,7 @@ describe("python_exec", () => { 20_000, ) - sandboxed( + bwrapSandboxed( "returns promptly when the cell exits leaving a session-detached descendant on stdout", async () => { const tool = await initTool(PythonExecTool) @@ -580,6 +646,45 @@ describe("python_exec", () => { 20_000, ) + e2e( + "sandboxed code cannot read the environment of a host process", + async () => { + const marker = `pyexec-environ-${crypto.randomUUID()}` + const host = await environCanary(marker) + try { + // The marker is observable outside the sandbox, so the check below is not vacuous. + const unconfined = spawnSync(python, ["-I", "-c", readEnvironCode(host.pid, marker)], { encoding: "utf8" }) + expect(unconfined.stdout).toContain("ENV_READ") + resetConfinement() + const tool = await initTool(PythonExecTool) + const result = await tool.execute({ code: readEnvironCode(host.pid, marker) }, ctx) + // Either the sandbox denies the read, or the self-test found the exposure and the call was refused. + expect(result.output).not.toContain("ENV_READ") + expect(result.output).not.toContain(marker) + if (!result.metadata.ok) expect(result.output).toContain("python_exec refused") + else expect(result.output).toContain("ENV_DENIED") + } finally { + host.kill() + } + }, + 60_000, + ) + + e2e( + "a double-forked setsid grandchild does not survive the call", + async () => { + const tool = await initTool(PythonExecTool) + const result = await tool.execute({ code: doubleForkCode("7344"), timeout_ms: 10_000 }, ctx) + try { + if (process.platform === "darwin") expect(result.output).not.toContain("FORKED") + expect(await survivors("sleep 7344")).toBe("") + } finally { + spawnSync("pkill", ["-f", "sleep 7344"]) + } + }, + 60_000, + ) + const unsandboxed = python && !detected?.ok ? test : test.skip unsandboxed("python_exec refuses when the host has no working sandbox", async () => { const tool = await initTool(PythonExecTool) @@ -598,24 +703,22 @@ describe("python_exec", () => { const result = await tool.execute( { code: [ - "import os, signal, subprocess", + "import os, signal", "for name, pid in (('parent', os.getppid()), ('other', %d)):" .replace("%d", "OUTSIDE"), " try:", " os.kill(pid, signal.SIGURG)", " print(name, 'SIGNALLED')", " except OSError:", " print(name, 'DENIED')", - "child = subprocess.Popen(['sleep', '5'])", - "child.send_signal(signal.SIGURG)", - "child.kill()", - "print('child OK')", + "os.kill(os.getpid(), 0)", + "print('self OK')", ].join("\n").replace("OUTSIDE", String(outside.pid)), }, ctx, ) expect(result.output).toContain("parent DENIED") expect(result.output).toContain("other DENIED") - expect(result.output).toContain("child OK") + expect(result.output).toContain("self OK") expect(outside.killed || outside.exitCode !== null).toBe(false) expect(process.kill(process.pid, 0)).toBe(true) } finally { @@ -742,8 +845,12 @@ describe("python_exec confinement", () => { ]) expect(profile).toContain("(deny network*)") expect(profile).toContain("(deny appleevent-send)") + expect(profile).toContain("(deny process-fork)") + expect(profile).toContain("(deny process-info*)") + expect(profile).toContain("(allow process-info* (target self))") expect(profile).toContain("(deny signal)") - expect(profile).toContain("(allow signal (target self) (target children))") + expect(profile).toContain("(allow signal (target self))") + expect(profile).not.toContain("(target children)") expect(profile).toMatch(/\(deny mach-lookup \(require-not/) expect(profile).toMatch(/\(deny file-read\* \(require-not \(require-any [^\n]*\(subpath "\/opt\/py"\)/) expect(profile).toMatch(/\(deny file-write\* \(require-not \(require-any \(subpath "\/private\/tmp\/s"\)/) @@ -919,21 +1026,135 @@ describe("python_exec confinement", () => { mechanisms: [{ filesystem: "bwrap", network: "isolated", prefix: [fake] }], }) expect(result.ok).toBe(false) - if (!result.ok) expect(result.reason).toContain("Self-test") + if (!result.ok) { + expect(result.reason).toContain("Self-test") + expect(result.reason).toContain("read a file outside the allowlist") + expect(result.reason).toContain("read the environment of another process") + } }, 30_000, ) e2e( - "the host mechanism passes its self-test", + "the host mechanism passes its self-test, or macOS is refused only for host environment exposure", async () => { resetConfinement() const result = await confinementFor(python) + if (process.platform === "darwin" && !result.ok) { + // Seatbelt does not mediate KERN_PROCARGS2; file, fork and other checks must still pass. + expect(result.reason).toContain("sandbox-exec self-test failed") + expect(result.reason).toContain("KERN_PROCARGS2") + expect(result.reason).not.toContain("child process") + expect(result.reason).not.toContain("outside the allowlist") + return + } expect(result.ok).toBe(true) if (result.ok) expect(result.confinement.filesystem).toBe(process.platform === "darwin" ? "sandbox-exec" : "bwrap") }, - 30_000, + 60_000, + ) + + macProfile( + "the macOS profile denies fork, posix_spawn, subprocess and multiprocessing, so no grandchild survives", + async () => { + await using directory = await tmpdir() + const dir = await fs.realpath(directory.path) + const code = [ + doubleForkCode("7345"), + "import multiprocessing, subprocess", + "for name, start in (", + " ('posix_spawn', lambda: os.posix_spawn('/bin/sleep', ['sleep', '7345'], {})),", + " ('subprocess', lambda: subprocess.Popen(['/bin/sleep', '7345'], start_new_session=True)),", + " ('multiprocessing', lambda: multiprocessing.get_context('spawn').Process(target=print).start()),", + "):", + " try:", + " start()", + " print(name, 'ALLOWED')", + " except OSError:", + " print(name, 'DENIED')", + ].join("\n") + try { + const result = await runUnderMacProfile(code, dir) + expect(result.stdout).toContain("FORK_DENIED 1") + expect(result.stdout).not.toContain("FORKED") + for (const name of ["posix_spawn", "subprocess", "multiprocessing"]) expect(result.stdout).toContain(`${name} DENIED`) + expect(result.stdout).not.toContain("ALLOWED") + expect(await survivors("sleep 7345")).toBe("") + } finally { + spawnSync("pkill", ["-f", "sleep 7345"]) + } + }, + 60_000, + ) + + macProfile( + "pandas, NumPy and DuckDB still work under the macOS profile", + async () => { + await using directory = await tmpdir() + const dir = await fs.realpath(directory.path) + const code = [ + "import duckdb, numpy as np, pandas as pd", + "df = pd.DataFrame({'k': ['a', 'b', 'a'], 'v': np.arange(3)})", + "df.to_parquet('frame.parquet')", + "print('DUCKDB', duckdb.sql(\"SELECT k, sum(v) FROM 'frame.parquet' GROUP BY k ORDER BY k\").fetchall())", + "print('PANDAS', df.groupby('k')['v'].sum().to_dict())", + "print('NUMPY', float(np.linalg.inv(np.eye(3) * 2).sum()))", + ].join("\n") + const result = await runUnderMacProfile(code, dir) + expect(result.stderr).not.toContain("Traceback") + expect(result.stdout).toContain("DUCKDB [('a', 2), ('b', 1)]") + expect(result.stdout).toContain("PANDAS {'a': 2, 'b': 1}") + expect(result.stdout).toContain("NUMPY 1.5") + }, + 60_000, + ) + + macProfile( + "a host environment readable under the macOS profile makes detection refuse", + async () => { + await using directory = await tmpdir() + const dir = await fs.realpath(directory.path) + const marker = `pyexec-environ-${crypto.randomUUID()}` + const host = await environCanary(marker) + try { + const result = await runUnderMacProfile(readEnvironCode(host.pid, marker), dir) + expect(result.stdout).toMatch(/ENV_READ|ENV_DENIED/) + resetConfinement() + const detection = await confinementFor(python) + if (result.stdout.includes("ENV_READ")) { + expect(detection.ok).toBe(false) + if (!detection.ok) expect(detection.reason).toContain("read the environment of another process") + } + } finally { + host.kill() + } + }, + 60_000, + ) + + macProfile( + "the macOS self-test fails closed when fork denial is not enforced", + async () => { + await using bin = await tmpdir() + // A sandbox-exec wrapper that drops the fork denial from the profile it is given. + const fake = path.join(bin.path, "sandbox-exec") + await fs.writeFile( + fake, + "#!/bin/sh\nshift\nprofile=$(printf '%s\\n' \"$1\" | grep -v '^(deny process-fork)$')\nshift\nexec /usr/bin/sandbox-exec -p \"$profile\" \"$@\"\n", + { mode: 0o755 }, + ) + const result = await Sandbox.detectConfinement({ + python, + bootstrap: BOOTSTRAP, + env: childEnv, + platform: "darwin", + mechanisms: [{ filesystem: "sandbox-exec", network: "isolated", prefix: [fake] }], + }) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toContain("started a child process") + }, + 60_000, ) }) From 6c34ba93f2e2519bb9578bb4de1b199794463462 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 17 Oct 2026 12:27:23 -0700 Subject: [PATCH 14/14] fix: [review] register `python_exec` only where the sandbox self-test passes - `pythonExecAvailable()` now also requires `confinementFor(python)` to succeed, so hosts whose sandbox self-test refuses (currently macOS, where `KERN_PROCARGS2` exposes host environments) never see the tool. - The verdict, including a refusal, is cached per interpreter and process; the refusal reason is logged once at debug level. `resetConfinement()` clears it. - Registry gate simplified inside its existing `altimate_change` block. - Tests: the analyst catalogue omits `python_exec` when an injected self-test refuses and includes it when it passes, without rerunning the self-test on a second build; discovery test expects availability to follow the self-test. Co-Authored-By: Claude Opus 5.5 --- .../src/altimate/tools/python-exec.ts | 26 ++++++++++- packages/opencode/src/tool/registry.ts | 11 ++--- .../opencode/test/agent/python-exec.test.ts | 46 +++++++++++++++++++ .../test/altimate/tools/python-exec.test.ts | 3 +- 4 files changed, 76 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/altimate/tools/python-exec.ts b/packages/opencode/src/altimate/tools/python-exec.ts index a30dc4f30e..2f2809d0ba 100644 --- a/packages/opencode/src/altimate/tools/python-exec.ts +++ b/packages/opencode/src/altimate/tools/python-exec.ts @@ -16,6 +16,7 @@ import * as fs from "node:fs/promises" import * as path from "node:path" import * as os from "node:os" import { Tool } from "../../tool/tool" +import { Log } from "@/altimate/util/log" import { withScratchConnection } from "../native/connections/scratch" import { quoteIdent } from "./sql-materialize" import * as Sandbox from "./python-sandbox" @@ -26,6 +27,7 @@ export const OUTPUT_LIMIT = 30_000 export const WORKSPACE_LIMIT_BYTES = 1024 ** 3 const MEMORY_LIMIT_BYTES = 8 * 1024 ** 3 const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/ +const log = Log.create({ service: "tool.python_exec" }) const HELPER = `"""Scratch table exchange for python_exec.""" import os as _os @@ -118,9 +120,10 @@ export function confinementFor(python: string): Promise { return result } -/** Forget the cached detection (tests). */ +/** Forget the cached detection and registration verdict (tests). */ export function resetConfinement() { confinement = undefined + registration = undefined } let discovery: { key: string; result: Promise } | undefined @@ -163,8 +166,27 @@ export function interpreter(): Promise { return result } +let registration: { python: string; result: Promise } | undefined + +/** + * Register `python_exec` only where it can run: an interpreter with pandas and duckdb exists and + * the sandbox self-test passes. The verdict, including a refusal, is cached per interpreter and + * process so registration stays cheap; the refusal reason is logged once. + */ export async function pythonExecAvailable(): Promise { - return process.env.ALTIMATE_PYTHON_EXEC !== "0" && (await interpreter()) !== undefined + if (process.env.ALTIMATE_PYTHON_EXEC === "0") return false + const python = await interpreter() + if (!python) { + log.debug("python_exec not registered: no Python with pandas and duckdb is available") + return false + } + if (registration?.python === python) return registration.result + const result = confinementFor(python).then((detection) => { + if (!detection.ok) log.debug(`python_exec not registered: ${detection.reason}`) + return detection.ok + }) + registration = { python, result } + return result } /** Minimal child environment: no credentials, tokens or connection strings from the parent. */ diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index f629e854fb..ec091c8922 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -588,13 +588,10 @@ export namespace ToolRegistry { tools.push(await AppRuntime.runPromise(AnswerCommit.AnswerCommitTool)) } // altimate_change end - // altimate_change start — expose sandboxed Python only to analysts with a usable runtime. - if (agent?.name === "analyst" && process.env.ALTIMATE_PYTHON_EXEC !== "0") { - if (await pythonExecAvailable()) { - tools.push(await AppRuntime.runPromise(PythonExecTool)) - } else { - log.debug("python_exec not registered: no Python with pandas and duckdb is available") - } + // altimate_change start — expose sandboxed Python only to analysts whose host has a usable + // interpreter and passes the sandbox self-test; pythonExecAvailable logs the refusal reason. + if (agent?.name === "analyst" && process.env.ALTIMATE_PYTHON_EXEC !== "0" && (await pythonExecAvailable())) { + tools.push(await AppRuntime.runPromise(PythonExecTool)) } // altimate_change end // altimate_change start — text_label is analyst-scoped (and its read-only data-explorer delegate), on by diff --git a/packages/opencode/test/agent/python-exec.test.ts b/packages/opencode/test/agent/python-exec.test.ts index c1bb18c312..d1603a14cb 100644 --- a/packages/opencode/test/agent/python-exec.test.ts +++ b/packages/opencode/test/agent/python-exec.test.ts @@ -1,14 +1,19 @@ // altimate_change start — analyst-only Python registration and unchanged shell denial. import { afterEach, beforeEach, expect, spyOn } from "bun:test" +import { spawnSync } from "node:child_process" import { Effect, Layer } from "effect" import { Agent } from "../../src/agent/agent" import * as PythonExec from "../../src/altimate/tools/python-exec" +import * as Sandbox from "../../src/altimate/tools/python-sandbox" import { Permission } from "../../src/permission" import { ModelID, ProviderID } from "../../src/provider/schema" import { ToolRegistry } from "../../src/tool/registry" import { testEffect } from "../lib/effect" const it = testEffect(Layer.mergeAll(Agent.defaultLayer, ToolRegistry.layer)) +const hasPython = + spawnSync(process.env.ALTIMATE_PYTHON || "python3", ["-I", "-c", "import duckdb, pandas"], { timeout: 10_000 }) + .status === 0 const model = { providerID: ProviderID.opencode, modelID: ModelID.make("test") } let previousToggle: string | undefined let available: ReturnType> @@ -78,4 +83,45 @@ it.instance("Python is absent for other agents and from the tool_lookup catalogu expect(Permission.evaluate("python_exec", "*", explorer.permission).action).toBe("deny") }), ) +for (const [label, detection] of [ + ["refuses", { ok: false, reason: "python_exec refused: sandbox self-test failed (test)." }], + [ + "passes", + { + ok: true, + confinement: { + filesystem: "sandbox-exec", + network: "isolated", + prefix: ["/usr/bin/sandbox-exec"], + read: { directories: [], files: [], links: [] }, + }, + }, + ], +] as const) { + it.instance(`python_exec registration follows the sandbox self-test when it ${label}`, () => + Effect.gen(function* () { + if (!hasPython) return + // Exercise the real gate: real interpreter discovery, injected self-test verdict. + available.mockRestore() + const detect = spyOn(Sandbox, "detectConfinement").mockResolvedValue(detection as Sandbox.Detection) + PythonExec.resetConfinement() + try { + const agents = yield* Agent.Service + const registry = yield* ToolRegistry.Service + const analyst = yield* agents.get("analyst") + const ids = (yield* registry.tools({ ...model, agent: analyst })).map((tool) => tool.id) + expect(detect).toHaveBeenCalled() + if (detection.ok) expect(ids).toContain("python_exec") + else expect(ids).not.toContain("python_exec") + // The verdict is cached: a second catalogue build does not rerun the self-test. + const calls = detect.mock.calls.length + yield* registry.tools({ ...model, agent: analyst }) + expect(detect.mock.calls.length).toBe(calls) + } finally { + detect.mockRestore() + PythonExec.resetConfinement() + } + }), + ) +} // altimate_change end diff --git a/packages/opencode/test/altimate/tools/python-exec.test.ts b/packages/opencode/test/altimate/tools/python-exec.test.ts index e8b6969c47..62f9006f7d 100644 --- a/packages/opencode/test/altimate/tools/python-exec.test.ts +++ b/packages/opencode/test/altimate/tools/python-exec.test.ts @@ -194,7 +194,8 @@ describe("python_exec helpers", () => { const original = process.env.ALTIMATE_PYTHON_EXEC try { delete process.env.ALTIMATE_PYTHON_EXEC - expect(await pythonExecAvailable()).toBe(true) + // Available only where the sandbox self-test also passes (macOS currently refuses). + expect(await pythonExecAvailable()).toBe(detected?.ok === true) process.env.ALTIMATE_PYTHON_EXEC = "0" expect(await pythonExecAvailable()).toBe(false) } finally {