import { lstatSync, realpathSync } from "node:fs"; import { lstat, readdir } from "node:fs/promises"; import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; import { type Plugin } from "@opencode/plugin"; import { z } from "zod"; import { getInternalBridgeConfig } from "./internalBridge.js"; type Context = Parameters[0]; const inside = (root: string, path: string) => { const value = relative(root, path); return value !== ".." && !value.startsWith(`..${sep}`) && !isAbsolute(value); }; const canonical = (path: string): string => { let existing = path; const exists = (candidate: string) => { try { lstatSync(candidate); return true; } catch { return false; } }; while (!exists(existing)) { const parent = dirname(existing); if (parent === existing) throw new Error("invalid path"); existing = parent; } return resolve(realpathSync(existing), relative(existing, path)); }; export function canAccessConversationPath(workspace: string, path: string, edit = false, skills?: string) { try { const target = canonical(resolve(workspace, path)); const scopedPath = inside(workspace, target) ? relative(workspace, target) : path; if (scopedPath.split(/[\\/]/).some((name) => name.includes(".env") || name === "data" || name === "logs")) return false; if (!edit && skills && inside(canonical(skills), target)) return true; if (!inside(workspace, target)) return false; return !edit || ["files", "tmp", ".cache"].some((name) => inside(resolve(workspace, name), target)); } catch { return false; } } export async function registerSessionSandbox(ctx: Context) { const root = resolve(process.env.TJWATER_AGENT_WORKSPACE_ROOT ?? ".agent-workspaces"); const skills = resolve(process.env.OPENCODE_SKILLS_ROOT_DIR ?? ".opencode/skills"); const workspace = async (sessionID: string) => { const session = await ctx.session.get({ sessionID }); const directory = canonical(session.location.directory); if (dirname(directory) !== canonical(root) || !directory.split(sep).at(-1)?.startsWith("conversation-")) { throw new Error("session has no managed conversation workspace"); } return directory; }; await ctx.tool.transform((editor) => { // A later add replaces the native executor for both direct and Code Mode calls. editor.remove("shell"); editor.add({ name: "shell", description: "在当前会话的 Landlock + seccomp 沙箱运行 Shell。工作目录为会话的 files/;不能联网、读取密钥、源码或其他会话。后台进程不保留。业务查询使用类型化工具。", input: z.object({ command: z.string().min(1).max(20000), description: z.string().max(500).optional(), timeout: z.number().int().min(1).max(1800).optional().describe("Timeout in seconds, default 120."), }).strict(), options: { codemode: false, permission: "shell" }, async execute(input, context) { await workspace(context.sessionID); const { baseUrl, internalToken } = getInternalBridgeConfig(); const response = await fetch(`${baseUrl}/internal/tools/sandbox-shell`, { method: "POST", signal: context.signal, headers: { "Content-Type": "application/json", "x-agent-internal-token": internalToken }, body: JSON.stringify({ ...input, session_id: context.sessionID, call_id: context.id, message_id: context.messageID }), }); const text = await response.text(); if (!response.ok) throw new Error(`sandbox shell failed (${response.status}): ${text}`); return { content: text, metadata: { sandbox: "landlock-seccomp" } }; }, }); for (const name of ["glob", "grep"]) { editor.update(name, (tool) => { const execute = tool.execute; tool.execute = async (input, context) => { const directory = await workspace(context.sessionID); const path = (input as { path?: string }).path ?? directory; if (!canAccessConversationPath(directory, path, false, skills)) throw new Error("search outside conversation workspace denied"); // Native recursive search does not ask 'read' permission per match. // Do not allow a recursive search tree containing links or secrets. const pending = [canonical(resolve(directory, path))]; while (pending.length) { const current = pending.pop()!; if (!canAccessConversationPath(directory, current, false, skills) || (await lstat(current)).isSymbolicLink()) throw new Error("unsafe search tree denied"); if ((await lstat(current)).isDirectory()) for (const entry of await readdir(current)) pending.push(resolve(current, entry)); } return execute(input, context); }; }); } }); // Close alternate native Shell APIs as well. Only the authenticated bridge // above may spawn commands, with kernel restrictions already installed. await ctx.shell.hook("create.before", () => { throw new Error("host shell disabled; use the sandbox shell tool"); }); await ctx.permission.hook("evaluate", async (event) => { if (event.effect === "deny") return; if (event.action === "shell") { event.effect = "ask"; return; } if (event.action === "read" || event.action === "edit" || event.action === "external_directory") { try { const directory = await workspace(event.sessionID); if (event.resources.some((path) => !canAccessConversationPath(directory, path.replace(/\/\*$/, ""), event.action === "edit", skills))) { event.effect = "deny"; } else if (event.action === "edit") event.effect = "ask"; } catch { event.effect = "deny"; } } }); await ctx.session.hook("context", async (event) => { const directory = await workspace(event.sessionID); event.system.push({ type: "text", text: `当前会话目录:${directory}。Shell 的工作目录是 ${directory}/files。写入与计算文件放在 files/;目录根只读。shell 禁止网络与跨会话访问,业务数据、网页搜索和地理编码使用类型化工具。` }); }); }