diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index aa68852a1b..ba702d3102 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -332,6 +332,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 27b2c4197c..bc7c409a58 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. When a rule or policy decides the answer, read its full text (not a preview), enumerate each competing clause, and evaluate every condition as satisfied, violated or unknown against the row's own fields and dates before choosing. 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..2f2809d0ba --- /dev/null +++ b/packages/opencode/src/altimate/tools/python-exec.ts @@ -0,0 +1,672 @@ +/** + * `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 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, 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" +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" + +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 log = Log.create({ service: "tool.python_exec" }) + +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]) +` + +export 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__") +` + +let confinement: { python: string; result: Promise } | undefined + +/** + * 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 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 and registration verdict (tests). */ +export function resetConfinement() { + confinement = undefined + registration = undefined +} + +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 = 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, + }, + ) + const executable = resolve.status === 0 ? resolve.stdout.trim() : "" + if (!executable || !path.isAbsolute(executable)) return + const probe = await Sandbox.execute([executable, "-I", "-c", "import pandas, duckdb"], { + cwd: directory, + env: childEnv(directory), + timeout: 10000, + }) + return probe.status === 0 ? executable : undefined + } finally { + await fs.rm(directory, { recursive: true, force: true }) + } + })().catch(() => undefined) + discovery = { key, result } + 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 { + 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. */ +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 +} + +/** 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. + * 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 = { + code: number | null + signal: string | null + stdout: string + 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( + 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) + }) + let exitCode: number | null = null + let exitSignal: string | null = null + let settled = false + let grace: ReturnType | undefined + // 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=,uid=,lstart=,comm="], + // 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 ?? "")), + ) + }) + let sweeping: Promise | undefined + const track = () => { + if (!child.pid || !trackDescendants) return Promise.resolve(new Map() as ProcessTable) + sweeping ??= snapshot() + .then((table) => { + root ??= table.get(child.pid!) + orphaned.push(...reconcileDescendants(known, table, child.pid!, root)) + return table + }) + .finally(() => { + sweeping = undefined + }) + return sweeping + } + const tracker = trackDescendants ? setInterval(() => void track(), TRACK_INTERVAL_MS) : undefined + // 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. + // 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(() => {}) + const table = await track() + 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) + } + 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 killing?.catch(() => {}) + await checking + resolve({ code: exitCode, signal: exitSignal, stdout, stderr, timedOut, workspaceError, orphaned }) + } + const timer = setTimeout(() => { + timedOut = true + void kill() + }, timeoutMs) + const onAbort = () => void kill() + abort.addEventListener("abort", onAbort, { once: true }) + 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 + const monitor = setInterval(() => { + if (checking) return + checking = workspaceSize(privateDirectory) + .then( + () => {}, + (error) => { + workspaceError = error instanceof Error ? error.message : String(error) + void 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, and one that + // keeps stdout/stderr open must not keep the call from returning. + child.on("exit", (code, signal) => { + exitCode = code + exitSignal = signal + void kill() + }) + child.on("close", () => void finish()) + }) +} + +export const PythonExecTool = Tool.define("python_exec", { + description: + "Run Python for non-SQL computation over scratch data; no warehouse connections or inherited credentials. " + + "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 " + + "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"), + 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 detected = await confinementFor(python) + if (!detected.ok) return failure(detected.reason) + const sandbox = detected.confinement + const timeoutMs = clampTimeout(args.timeout_ms) + 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( + 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, + 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: 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("; ")}`) + 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, + filesystem: sandbox.filesystem, + network: sandbox.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/altimate/tools/python-sandbox.ts b/packages/opencode/src/altimate/tools/python-sandbox.ts new file mode 100644 index 0000000000..9bdf1fe835 --- /dev/null +++ b/packages/opencode/src/altimate/tools/python-sandbox.ts @@ -0,0 +1,635 @@ +/** + * 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, 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, + * 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 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" +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, 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)})`), + ...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)", + // 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))", + `(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] +} + +/** 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 { + 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] }] : [] +} + +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 { + return readFileSync("/proc/sys/kernel/apparmor_restrict_unprivileged_userns", "utf8").trim() === "1" + } catch { + return false + } +} + +/** 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, + 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}` +} + +/** + * Pick the strongest working mechanism. A mechanism is accepted only if the sandboxed + * 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 + /** 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[] + /** Override the AppArmor user-namespace restriction probe (tests). */ + appArmorRestricted?: () => boolean +}): Promise { + const platform = input.platform ?? process.platform + const options = input.mechanisms ?? candidates(platform) + // 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" && evidence.appArmor === true && (input.appArmorRestricted ?? appArmorRestrictsUserNamespaces)(), + }) + 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-"))) + let canaryDirs: string[] = [] + let environCanary: ReturnType | undefined + 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 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}` } + // 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 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 }, + } + 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 + 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}: ${ + 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") + return { + ok: false, + reason: `python_exec refused: sandbox-exec self-test failed: ${failures.join("; ")}. ${REFUSAL_TAIL}`, + } + const denied = failures.some(indicatesUserNamespaceDenial) + 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/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 67433909ce..ec091c8922 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -65,6 +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" +import { PythonExecTool, pythonExecAvailable } from "../altimate/tools/python-exec" import { TextLabelTool } from "../altimate/tools/text-label" import { SqlCheckTool } from "../altimate/tools/sql-check" import { AnswerCommit } from "../altimate/tools/answer-commit" @@ -542,9 +543,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 } @@ -585,6 +588,12 @@ export namespace ToolRegistry { tools.push(await AppRuntime.runPromise(AnswerCommit.AnswerCommitTool)) } // altimate_change end + // 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 // default; ALTIMATE_TEXT_LABEL=0 disables registration. Other agents never receive it. if ((agent?.name === "analyst" || agent?.name === "data-explorer") && process.env.ALTIMATE_TEXT_LABEL !== "0") { 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..d1603a14cb --- /dev/null +++ b/packages/opencode/test/agent/python-exec.test.ts @@ -0,0 +1,127 @@ +// 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> + +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") + }), +) +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 new file mode 100644 index 0000000000..62f9006f7d --- /dev/null +++ b/packages/opencode/test/altimate/tools/python-exec.test.ts @@ -0,0 +1,1210 @@ +// 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" +import { SessionID, MessageID } from "../../../src/session/schema" +import { + PythonExecTool, + childEnv, + clampTimeout, + clip, + BOOTSTRAP, + parseProcessTable, + reconcileDescendants, + verifiedKillTargets, + 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" + +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 +// 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 +// 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"}`) + +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 + // 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 { + 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", () => { + sandboxed( + "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).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 + expect(rows.map((row) => Number(row[1]))).toEqual([2, 1]) + }, + 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 () => { + 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, + ) + + sandboxed( + "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, + ) + + sandboxed( + "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, + ) + + sandboxed( + "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, + ) + + sandboxed( + "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(path.join("in", "previous_input.parquet")) + }, + 15_000, + ) + + sandboxed( + "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, + ) + + sandboxed( + "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, + ) + + sandboxed( + "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, + ) + + sandboxed( + "reports the confinement mechanism and blocks network access", + async () => { + const tool = await initTool(PythonExecTool) + const code = + "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"]).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") + }, + 30_000, + ) + + sandboxed( + "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, + ) + + bwrapSandboxed( + "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, + ) + + sandboxed( + "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, + ) + + sandboxed( + "confines writes to the session workspace", + async () => { + 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("DENIED") + await expect(fs.stat(outside)).rejects.toHaveProperty("code", "ENOENT") + } finally { + await fs.rm(outside, { force: true }) + } + }, + 30_000, + ) + + sandboxed( + "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 () => { + 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") + }) + + sandboxed( + "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, + ) + + bwrapSandboxed( + "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, + ) + + bwrapSandboxed( + "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, + ) + + 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) + 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") + }) + + test.skipIf(process.platform !== "darwin" || !detected?.ok)( + "sandboxed code cannot signal the agent or other host processes", + async () => { + const outside = Bun.spawn(["sleep", "7333"]) + try { + const tool = await initTool(PythonExecTool) + const result = await tool.execute( + { + code: [ + "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')", + "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("self 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. + 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).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))") + 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"\)/) + 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("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("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({ + 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( + "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 () => { + 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") + 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, 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") + }, + 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, + ) +}) + +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 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 8c080c3d20..1b1d8f20cb 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( @@ -161,6 +162,8 @@ describe("Anthropic tool JSON schemas", () => { ) // altimate_change end 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. 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.