feat(agent): sandbox conversation analysis
This commit is contained in:
@@ -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 });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user