import { readFileSync } from "node:fs"; import { randomUUID } from "node:crypto"; import { writeFile } from "node:fs/promises"; import { resolve } from "node:path"; import { type Express } from "express"; import { z } from "zod"; import { isTrustedInternalBridgeRequest } from "../product/internal-bridge-auth.js"; import { getRuntimeSessionContext } from "../runtime/sessionContext.js"; import { executeSandboxCommand } from "../sandbox/landlockSandbox.js"; import { setSandboxOwnership } from "../runtime/conversationWorkspace.js"; import type { OpencodeRuntimeAdapter } from "../runtime/opencode.js"; import { nativeToolOutputDirectory, stageSessionToolOutputs } from "../sandbox/tool-output.js"; export const sandboxInput = z.object({ session_id: z.string().min(1).max(128), call_id: z.string().min(1).max(128), message_id: z.string().min(1).max(128), command: z.string().min(1).max(20000), description: z.string().max(500).optional(), timeout: z.number().int().min(1).max(1800).default(120), }).strict(); // The replacement tool must retain the service's static command denials as // well as the native approval gate. Command parsing is not the OS boundary. export function isShellCommandDenied(command: string): boolean { const rules = (JSON.parse(readFileSync("opencode.json", "utf8")) as { permissions: { action: string; resource: string; effect: string }[]; }).permissions; let effect = "ask"; for (const rule of rules) { if (rule.action !== "shell" && rule.action !== "*") continue; const pattern = rule.resource.replace(/[.+^${}()|[\]\\]/g, "\\$&") .replaceAll("*", ".*").replaceAll("?", "."); if (new RegExp(`^${pattern}$`, "s").test(command)) effect = rule.effect; } return effect === "deny"; } export function registerSandboxToolBridge(app: Express, options: { internalToken: string; runtime: { messages: OpencodeRuntimeAdapter["messages"]; ensureSessionWorkspace(sessionId: string): Promise; approveSandboxShell(input: { sessionId: string; command: string; description?: string; callId: string; messageId: string }, signal: AbortSignal): Promise; }; }) { app.post("/internal/tools/sandbox-shell", async (req, res) => { if (!isTrustedInternalBridgeRequest(req, options.internalToken)) { res.status(403).json({ message: "forbidden" }); return; } const parsed = sandboxInput.safeParse(req.body); if (!parsed.success) { res.status(400).json({ message: "invalid sandbox input" }); return; } const context = getRuntimeSessionContext(parsed.data.session_id); if (!context) { res.status(404).json({ message: "session context not found" }); return; } if (context.learningMode === "review" || isShellCommandDenied(parsed.data.command)) { res.status(403).json({ message: "shell command denied" }); return; } const controller = new AbortController(); const cancel = () => { if (!res.writableEnded) controller.abort(); }; res.once("close", cancel); try { const workspace = await options.runtime.ensureSessionWorkspace(context.sessionId); await options.runtime.approveSandboxShell({ sessionId: context.sessionId, command: parsed.data.command, description: parsed.data.description, callId: parsed.data.call_id, messageId: parsed.data.message_id, }, controller.signal); if (parsed.data.command.includes(`${nativeToolOutputDirectory()}/`)) { const files = await stageSessionToolOutputs(parsed.data.command, workspace, context.sessionId, await options.runtime.messages(context.sessionId, 100)); if (files.length) { res.json({ outcome: "requires_session_path", files, detail: "命令尚未执行。当前会话的完整工具结果已复制到会话目录。请用返回的 shell_path 重新调用 Shell;relative_path 相对于会话 workspace,shell_path 相对于 Shell 的 files/ 工作目录。" }); return; } } const result = await executeSandboxCommand(workspace, parsed.data.command, parsed.data.timeout, controller.signal); const files: Partial> = {}; for (const stream of ["stdout", "stderr"] as const) { if (Buffer.byteLength(result[stream]) <= 3000) continue; const path = resolve(workspace, "files", `shell-${randomUUID()}-${stream}.txt`); await writeFile(path, result[stream], { flag: "wx", mode: 0o600 }); await setSandboxOwnership(path); files[`${stream}_file`] = path; result[stream] = Buffer.from(result[stream]).subarray(0, 3000).toString("utf8"); } if (!controller.signal.aborted) res.json({ ...result, ...files, workspace_directory: workspace, cwd: `${workspace}/files` }); } catch (error) { const status = error && typeof error === "object" && "status" in error && typeof error.status === "number" ? error.status : 503; if (!controller.signal.aborted) res.status(status).json({ message: error instanceof Error ? error.message : "sandbox unavailable" }); } finally { res.removeListener("close", cancel); } }); }