Upgrade the runtime and native plugin API to OpenCode 2.0.24. Restore session-scoped learning, credential renewal, typed project queries and controlled frontend actions from the legacy Agent. Replace host Shell execution with an approved Landlock/seccomp executor and persistent per-session workspaces. Add typed Bocha search and Tianditu geocoding bridges using the owning session credential. Require a working kernel sandbox before startup and persist workspace files in deployments. Validation completed before this commit: TypeScript checks, 150 Bun tests, Docker build and UID 10001 sandbox execution, native Shell approval and session recovery. Tianditu succeeded; Bocha currently reports exhausted account quota. Unrelated investigation-report contract work is excluded.
110 lines
6.0 KiB
TypeScript
110 lines
6.0 KiB
TypeScript
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<Plugin.Plugin["setup"]>[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 禁止网络与跨会话访问,业务数据、网页搜索和地理编码使用类型化工具。` });
|
|
});
|
|
}
|