Upgrade the runtime and native plugin API to OpenCode 2.0.24. Restore session-scoped learning, credential renewal, typed project queries and controlled frontend actions from the legacy Agent. Replace host Shell execution with an approved Landlock/seccomp executor and persistent per-session workspaces. Add typed Bocha search and Tianditu geocoding bridges using the owning session credential. Require a working kernel sandbox before startup and persist workspace files in deployments. Validation completed before this commit: TypeScript checks, 150 Bun tests, Docker build and UID 10001 sandbox execution, native Shell approval and session recovery. Tianditu succeeded; Bocha currently reports exhausted account quota. Unrelated investigation-report contract work is excluded.
107 lines
5.9 KiB
TypeScript
107 lines
5.9 KiB
TypeScript
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();
|
|
});
|
|
});
|