Files
next-tjwater-agent/tests/runtime/sandbox.test.ts
T
jiang 7383ed6d13 feat(agent): migrate OpenCode 2 runtime and sandbox tools
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.
2026-10-08 15:46:37 +08:00

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();
});
});