Auto-approve sandbox Shell only for host-bound task execution, stage native results within their owning session, and preserve active lease state on response stream loss. The repeated failures came from interactive approval in unattended runs and transport cleanup invalidating execution state; focused regressions cover both boundaries.
93 lines
5.0 KiB
TypeScript
93 lines
5.0 KiB
TypeScript
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<string>;
|
||
approveSandboxShell(input: { sessionId: string; command: string; description?: string; callId: string; messageId: string }, signal: AbortSignal): Promise<void>;
|
||
};
|
||
}) {
|
||
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<Record<"stdout_file" | "stderr_file", string>> = {};
|
||
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); }
|
||
});
|
||
}
|