import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { config } from "../../src/config.js"; import { createConversationWorkspace, resolveConversationWorkspace } from "../../src/runtime/conversationWorkspace.js"; import { executeSandboxCommand, probeLandlockSandbox } from "../../src/sandbox/landlockSandbox.js"; import { canAccessConversationPath } from "../../.opencode/session-sandbox.js"; import { canAutomaticallyApprove } from "../../src/routes/chatPermissionPolicy.js"; describe("conversation OS sandbox", () => { let root: string; let workspace: string; let other: string; const previousRoot = config.CONVERSATION_WORKSPACE_ROOT; beforeAll(async () => { root = await mkdtemp(join(tmpdir(), "tjwater-sandbox-test-")); config.CONVERSATION_WORKSPACE_ROOT = root; workspace = await createConversationWorkspace(root); other = await createConversationWorkspace(root); await writeFile(join(other, "files", "private.txt"), "other-session"); }); afterAll(async () => { config.CONVERSATION_WORKSPACE_ROOT = previousRoot; await rm(root, { recursive: true, force: true }); }); it("supports Landlock and runs local file computation with a sanitized environment", async () => { expect((await probeLandlockSandbox()).landlockAbi).toBeGreaterThanOrEqual(4); const result = await executeSandboxCommand(workspace, "printf sandbox-ok > result.txt; cat result.txt; python3 -c 'import os; assert not any(k in os.environ for k in [\"AGENT_INTERNAL_TOKEN\",\"DEEPSEEK_API_KEY\",\"OPENCODE_CLIENT_PASSWORD\"]); print(os.getcwd())'", 10); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("sandbox-ok"); expect(result.stdout).toContain(join(workspace, "files")); expect(await readFile(join(workspace, "files", "result.txt"), "utf8")).toBe("sandbox-ok"); }); it("denies other sessions, checkout files, symlink escape and config writes", async () => { await symlink(join(other, "files", "private.txt"), join(workspace, "files", "escape")); await symlink(join(other, "files", "not-created.txt"), join(workspace, "files", "broken-escape")); for (const command of [ `cat '${other}/files/private.txt'`, `cat '${process.cwd()}/src/config.ts'`, "cat escape", "printf malicious > ../opencode.json", "mkdir ../.opencode", "printf denied > /usr/tjwater-sandbox-test", ]) { const result = await executeSandboxCommand(workspace, command, 5); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Permission denied"); } }); it("blocks TCP, UDP, Unix sockets, process inspection and process-group escape", async () => { for (const command of [ "python3 -c 'import socket; socket.socket(socket.AF_INET, socket.SOCK_STREAM)'", "python3 -c 'import socket; socket.socket(socket.AF_INET, socket.SOCK_DGRAM)'", "python3 -c 'import socket; socket.socket(socket.AF_UNIX)'", "python3 -c 'import os; os.setsid()'", `python3 -c 'import os; os.kill(${process.pid}, 0)'`, ]) { const result = await executeSandboxCommand(workspace, command, 5); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Operation not permitted"); } }); it("kills children on timeout and client cancellation", async () => { const timed = await executeSandboxCommand(workspace, "(sleep 2; printf leaked > timeout-leak.txt) & wait", 1); expect(timed.outcome).toBe("timeout"); const controller = new AbortController(); const cancelled = executeSandboxCommand(workspace, "(sleep 2; printf leaked > cancel-leak.txt) & wait", 10, controller.signal); setTimeout(() => controller.abort(), 100); expect((await cancelled).outcome).toBe("cancelled"); await new Promise((resolve) => setTimeout(resolve, 2100)); expect(await Bun.file(join(workspace, "files", "timeout-leak.txt")).exists()).toBeFalse(); expect(await Bun.file(join(workspace, "files", "cancel-leak.txt")).exists()).toBeFalse(); }); it("reaps background children immediately when the foreground shell exits", async () => { const result = await executeSandboxCommand(workspace, "(sleep 1; printf leaked > background-leak.txt) & printf foreground", 5); expect(result.exitCode).toBe(0); expect(result.outcome).toBe("completed"); expect(result.stdout).toBe("foreground"); await new Promise((resolve) => setTimeout(resolve, 1100)); expect(await Bun.file(join(workspace, "files", "background-leak.txt")).exists()).toBeFalse(); }); it("bounds output and rejects untrusted workspace paths", async () => { const result = await executeSandboxCommand(workspace, "python3 -c 'print(\"x\" * 2000000)'", 5); expect(result.outcome).toBe("output_limit"); expect(result.stdout).toBe(""); await expect(resolveConversationWorkspace(root, root)).rejects.toThrow("direct child"); const link = join(root, "conversation-link"); await symlink(workspace, link); await expect(resolveConversationWorkspace(link, root)).rejects.toThrow("symbolic link"); }); it("constrains native file tools and keeps arbitrary execution interactive", () => { expect(canAccessConversationPath(workspace, "files/result.txt", true)).toBeTrue(); expect(canAccessConversationPath(workspace, "opencode.json", true)).toBeFalse(); expect(canAccessConversationPath(workspace, "files/escape")).toBeFalse(); expect(canAccessConversationPath(workspace, "files/broken-escape", true)).toBeFalse(); expect(canAccessConversationPath(workspace, "../other")).toBeFalse(); expect(canAccessConversationPath(workspace, "files/.env")).toBeFalse(); for (const action of ["shell", "edit", "external_directory", "execute"]) expect(canAutomaticallyApprove("always", action)).toBeFalse(); expect(canAutomaticallyApprove("always", "web_search")).toBeTrue(); }); });