feat(agent): sandbox conversation analysis
Generic Container CI/CD / test-build-publish (push) Successful in 2m44s
Agent CI/CD v2 / build-test-publish-and-deploy (push) Successful in 2m44s

This commit is contained in:
2026-08-25 16:08:33 +08:00
parent ce04704af2
commit 80cfc1f2ab
27 changed files with 2387 additions and 125 deletions
+32 -2
View File
@@ -130,6 +130,36 @@ describe("permission approval policy", () => {
});
});
it("auto approves sandboxed shell and file access in a conversation workspace", async () => {
const root = await mkdtemp(join(tmpdir(), "permission-conversations-"));
const conversationRoot = join(root, "conversation-workspaces");
const workspaceRoot = join(conversationRoot, "conversation-test");
try {
await mkdir(workspaceRoot, { recursive: true });
expect(
resolvePermissionApproval("auto", "bash", {
workspaceRoot,
metadata: { command: "python3 analysis.py" },
}),
).toMatchObject({ autoApprove: true, autoReject: false });
expect(
canAutoApprovePermission("edit", {
workspaceRoot,
metadata: { filePath: join(workspaceRoot, "result.json") },
}),
).toBe(true);
expect(
canAutoApprovePermission("glob", {
workspaceRoot,
metadata: { path: workspaceRoot, pattern: "**/*.json" },
patterns: ["**/*.json"],
}),
).toBe(true);
} finally {
await rm(root, { force: true, recursive: true });
}
});
it.each([
"rm -rf ./target",
"rm -rf ./target",
@@ -157,14 +187,14 @@ describe("permission approval policy", () => {
});
it.each(["rm tmp.txt", "rm -f tmp.txt", "rm -r tmp-dir", "echo 'rm -rf tmp'"])(
"keeps non-recursive or non-executed removal text available for confirmation: %s",
"allows non-force-recursive or non-executed removal text in always mode: %s",
(command) => {
expect(
resolvePermissionApproval("always", "bash", {
metadata: { command },
patterns: [command],
}),
).toMatchObject({ autoApprove: false, autoReject: false });
).toMatchObject({ autoApprove: true, autoReject: false });
},
);
});
+93
View File
@@ -1,10 +1,29 @@
import { describe, expect, it } from "bun:test";
import { readFile } from "node:fs/promises";
import sandboxBash from "../../.opencode/tools/bash.js";
import tjwaterCli from "../../.opencode/tools/tjwater_cli.js";
import storeRenderRef, {
resolveStoreRenderFilePath,
} from "../../.opencode/tools/store_render_ref.js";
describe("internal OpenCode permissions", () => {
it("pins the runtime, SDK, plugin, and image to OpenCode 1.18.13", async () => {
const [rootPackageText, toolPackageText, dockerfile] = await Promise.all([
readFile("package.json", "utf8"),
readFile(".opencode/package.json", "utf8"),
readFile("Dockerfile", "utf8"),
]);
const rootPackage = JSON.parse(rootPackageText) as {
dependencies?: Record<string, string>;
};
const toolPackage = JSON.parse(toolPackageText) as {
dependencies?: Record<string, string>;
};
expect(rootPackage.dependencies?.["@opencode-ai/sdk"]).toBe("1.18.13");
expect(toolPackage.dependencies?.["@opencode-ai/plugin"]).toBe("1.18.13");
expect(dockerfile.startsWith("FROM smanx/opencode:1.18.13@")).toBe(true);
});
it("keeps protected paths denied in every approval mode", async () => {
const config = JSON.parse(await readFile("opencode.json", "utf8")) as {
permission?: Record<string, string | Record<string, string>>;
@@ -89,3 +108,77 @@ describe("store_render_ref arguments", () => {
});
});
});
describe("sandbox bash tool", () => {
it("forwards commands to the authenticated sandbox endpoint", async () => {
const originalFetch = globalThis.fetch;
const permissionRequests: unknown[] = [];
let requestUrl = "";
let requestBody: unknown;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
requestUrl = String(input);
requestBody = JSON.parse(String(init?.body));
return new Response('{"ok":true,"stdout":"done"}');
}) as unknown as typeof fetch;
try {
const definition = sandboxBash as unknown as {
execute: (args: unknown, context: unknown) => Promise<unknown>;
};
await definition.execute(
{ command: "python3 analysis.py", timeout: 300 },
{
sessionID: "session-test",
ask: async (input: unknown) => permissionRequests.push(input),
} as never,
);
} finally {
globalThis.fetch = originalFetch;
}
expect(requestUrl).toEndWith("/internal/tools/sandbox-shell");
expect(permissionRequests).toEqual([
{
permission: "bash",
patterns: ["python3 analysis.py"],
always: ["python3 analysis.py"],
metadata: {
command: "python3 analysis.py",
},
},
]);
expect(requestBody).toEqual({
session_id: "session-test",
command: "python3 analysis.py",
timeout: 300,
});
});
});
describe("tjwater_cli storage request", () => {
it("forwards store_result so small workflow inputs can be staged", async () => {
const originalFetch = globalThis.fetch;
let requestBody: unknown;
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
requestBody = JSON.parse(String(init?.body));
return new Response('{"ok":true,"data_file":{"file_path":"/tmp/test"}}');
}) as unknown as typeof fetch;
try {
const definition = tjwaterCli as unknown as {
execute: (args: unknown, context: unknown) => Promise<unknown>;
};
await definition.execute(
{
command: "network get-all-reservoirs-properties",
reason: "prepare workflow input",
store_result: true,
},
{ sessionID: "session-test" } as never,
);
} finally {
globalThis.fetch = originalFetch;
}
expect(requestBody).toMatchObject({
session_id: "session-test",
store_result: true,
});
});
});
+98
View File
@@ -0,0 +1,98 @@
import { afterEach, describe, expect, it } from "bun:test";
import { mkdir, readFile, rm, stat } from "node:fs/promises";
import { resolve } from "node:path";
import { config } from "../../src/config.js";
import { type RuntimeSessionContext } from "../../src/runtime/sessionContext.js";
import {
buildLargeCliResult,
stageLargeToolOutput,
} from "../../src/runtime/toolOutputStaging.js";
const createdPaths: string[] = [];
afterEach(async () => {
await Promise.all(
createdPaths.splice(0).map((path) => rm(path, { force: true, recursive: true })),
);
});
const createContext = async (): Promise<RuntimeSessionContext> => {
const importRoot = resolve(config.RESULT_REF_IMPORT_DIR);
await mkdir(importRoot, { recursive: true });
const workspaceDirectory = resolve(
importRoot,
`conversation-staging-${crypto.randomUUID()}`,
);
await mkdir(workspaceDirectory, { mode: 0o700 });
createdPaths.push(workspaceDirectory);
return {
actorKey: "actor-1",
clientSessionId: "client-1",
projectKey: "project-1",
sessionId: "runtime-1",
traceId: "trace-1",
workspaceDirectory,
};
};
describe("tool output staging", () => {
it("keeps small output inline unless storage is forced", async () => {
const context = await createContext();
await expect(
stageLargeToolOutput(context, '{"ok":true}', {
contentType: "application/json",
extension: "json",
prefix: "cli",
}),
).resolves.toBeNull();
const stored = await stageLargeToolOutput(context, '{"ok":true}', {
contentType: "application/json",
extension: "json",
force: true,
prefix: "cli",
});
expect(stored?.file_path).toContain("/tool-data/cli-");
expect(await readFile(stored!.file_path, "utf8")).toBe('{"ok":true}');
expect((await stat(stored!.file_path)).mode & 0o777).toBe(0o600);
});
it("stages output above the inline threshold and returns a compact descriptor", async () => {
const context = await createContext();
const content = JSON.stringify({
ok: true,
schema_version: "tjwater-cli/v1",
data: "x".repeat(config.MAX_INLINE_RESULT_BYTES),
});
const dataFile = await stageLargeToolOutput(context, content, {
contentType: "application/json",
extension: "json",
prefix: "cli",
});
expect(dataFile?.bytes).toBe(Buffer.byteLength(content));
const stored = dataFile!;
expect(buildLargeCliResult(stored)).toEqual({
ok: true,
schema_version: "tjwater-cli/v1",
summary: "CLI 结果已保存到当前对话工作区",
data_file: stored,
});
});
it("does not stage large output for legacy sessions without a workspace", async () => {
await expect(
stageLargeToolOutput(
{
actorKey: "actor-1",
clientSessionId: "client-1",
projectKey: "project-1",
sessionId: "runtime-1",
traceId: "trace-1",
},
"x".repeat(config.MAX_INLINE_RESULT_BYTES + 1),
{ contentType: "text/plain", extension: "txt", prefix: "shell" },
),
).rejects.toThrow("requires a conversation workspace");
});
});
+94
View File
@@ -0,0 +1,94 @@
import { afterEach, describe, expect, it } from "bun:test";
import { mkdir, rm } from "node:fs/promises";
import { resolve } from "node:path";
import { config } from "../../src/config.js";
import {
resolveConversationWorkspace,
setSandboxOwnership,
} from "../../src/runtime/conversationWorkspace.js";
import {
executeSandboxCommand,
probeLandlockSandbox,
} from "../../src/sandbox/landlockSandbox.js";
const createdPaths: string[] = [];
afterEach(async () => {
await Promise.all(
createdPaths.splice(0).map((path) => rm(path, { force: true, recursive: true })),
);
});
const createWorkspace = async (name: string = crypto.randomUUID()) => {
const importRoot = resolve(config.RESULT_REF_IMPORT_DIR);
await mkdir(importRoot, { recursive: true });
const workspace = resolve(importRoot, `conversation-sandbox-${name}`);
await mkdir(workspace, { mode: 0o700 });
await setSandboxOwnership(workspace);
createdPaths.push(workspace);
return workspace;
};
describe("Landlock sandbox", () => {
it("requires Landlock ABI 4 and seccomp", async () => {
const probe = await probeLandlockSandbox();
expect(probe.landlockAbi).toBeGreaterThanOrEqual(4);
expect(probe.seccomp).toBe(true);
});
it("allows local Python analysis while denying filesystem escape, secrets, and network", async () => {
const workspace = await createWorkspace("primary");
const sibling = await createWorkspace("sibling");
await resolveConversationWorkspace(workspace, config.RESULT_REF_IMPORT_DIR);
const allowed = await executeSandboxCommand(
workspace,
'python3 -c "import json; open(\'result.json\', \'w\').write(json.__name__)" && cat result.json',
10,
);
expect(allowed).toMatchObject({ exitCode: 0, stdout: "json" });
const escaped = await executeSandboxCommand(
workspace,
`cat ${resolve("package.json")}`,
10,
);
expect(escaped.exitCode).not.toBe(0);
expect(escaped.stderr).toContain("Permission denied");
const siblingRead = await executeSandboxCommand(
workspace,
`ls ${sibling}`,
10,
);
expect(siblingRead.exitCode).not.toBe(0);
const originalApiKey = process.env.DEEPSEEK_API_KEY;
process.env.DEEPSEEK_API_KEY = "must-not-leak";
try {
const environment = await executeSandboxCommand(
workspace,
'test -z "$DEEPSEEK_API_KEY"',
10,
);
expect(environment.exitCode).toBe(0);
} finally {
if (originalApiKey === undefined) {
delete process.env.DEEPSEEK_API_KEY;
} else {
process.env.DEEPSEEK_API_KEY = originalApiKey;
}
}
for (const socketType of ["SOCK_STREAM", "SOCK_DGRAM"]) {
const network = await executeSandboxCommand(
workspace,
`python3 -c "import socket; socket.socket(socket.AF_INET, socket.${socketType})"`,
10,
);
expect(network.exitCode).not.toBe(0);
expect(network.stderr).toContain("Operation not permitted");
}
});
});
+73
View File
@@ -0,0 +1,73 @@
import { afterEach, describe, expect, it } from "bun:test";
import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const createdPaths: string[] = [];
afterEach(async () => {
await Promise.all(
createdPaths.splice(0).map((path) => rm(path, { force: true, recursive: true })),
);
});
describe("service area workflow script", () => {
it("writes the wrapped render payload required by store_render_ref", async () => {
const directory = await mkdtemp(join(tmpdir(), "service-area-script-"));
createdPaths.push(directory);
const time = "2026-04-01T08:00:00+08:00";
const inputs = {
pipes: { data: [{ id: "P1", node1: "R1", node2: "N1" }] },
reservoirs: { data: [{ id: "R1" }] },
links: { data: [{ id: "P1", flow: 1, time }] },
nodes: {
data: [
{ id: "R1", pressure: 30, actual_demand: 0, time },
{ id: "N1", pressure: 28, actual_demand: 2, time },
],
},
};
const paths = Object.fromEntries(
await Promise.all(
Object.entries(inputs).map(async ([name, value]) => {
const path = join(directory, `${name}.json`);
await writeFile(path, JSON.stringify(value));
return [name, path] as const;
}),
),
);
const outputPath = join(directory, "service-area-wrapper.json");
await execFileAsync(
"python3",
[
resolve(
".opencode/skills/workflow/service-area-analysis/scripts/service_area_partition.py",
),
"--pipe-props",
paths.pipes!,
"--reservoirs",
paths.reservoirs!,
"--links",
paths.links!,
"--nodes",
paths.nodes!,
"--target-time",
time,
"--output",
outputPath,
],
{ cwd: directory },
);
const wrapper = JSON.parse(await readFile(outputPath, "utf8")) as {
data: { node_area_map: Record<string, string> };
location: { file_path: string };
metadata: { schema_version: number };
};
expect(wrapper.location.file_path).toBe(outputPath);
expect(wrapper.metadata.schema_version).toBe(1);
expect(wrapper.data.node_area_map).toMatchObject({ R1: "R1", N1: "R1" });
});
});