import { PRODUCT_MCP_TOOL_IDS, QUERY_MCP_TOOL_IDS, queryMcpToolId } from "./product-mcp-tools.js"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { normalizeMcpConfig } from "./mcp-config.js"; import { compileOpencodeModels, loadAgentModelConfig, type AgentModelConfig } from "../chat/modelConfig.js"; import { DEFAULT_SESSION_WORKSPACE_ROOT } from "./conversationWorkspace.js"; import { TASK_AGENT, TASK_READER_AGENT, TASK_READER_TOOLS } from "./task-agent-policy.js"; const agentMessageTools = [queryMcpToolId("list_investigation_tasks"), queryMcpToolId("get_investigation_task"), "send_task_message", queryMcpToolId("read_agent_messages"), "reply_agent_message"]; type PermissionRule = { action: string; resource: string; effect: "allow" | "ask" | "deny" }; const deniedProductQuery = (name: string, rules: PermissionRule[]) => rules.some((rule) => { if (rule.effect !== "deny") return false; const backslash = String.fromCharCode(92); const special = `${backslash}.^$+()[]{}|`; const pattern = Array.from(rule.action).map((char) => char === "*" ? ".*" : char === "?" ? "." : special.includes(char) ? backslash + char : char).join(""); return new RegExp(`^${pattern}$`).test(name); }); const dataPatterns = new Set(["data/**", "**/data/**"]); // Exempt only managed session locations from ancestor data/ denials. Earlier // explicit read/edit denials are reapplied; later administrator rules still win. export const sessionWorkspacePermissions = (rules: PermissionRule[], workspaceRoot: string) => { const root = resolve(workspaceRoot).replaceAll("\\", "/"); const result: PermissionRule[] = []; for (const [index, rule] of rules.entries()) { result.push(rule); if (!["read", "edit"].includes(rule.action) || rule.effect !== "deny" || !dataPatterns.has(rule.resource)) continue; if (rules.slice(index + 1).some((next) => next.action === rule.action && next.effect === "deny" && dataPatterns.has(next.resource))) continue; if (rule.action === "read") { result.push({ action: "read", resource: `${root}/conversation-*`, effect: "allow" }, { action: "read", resource: `${root}/conversation-*/**`, effect: "allow" }); for (const scope of ["project-*", "personal"]) { const workspace = `${root}/actor-*/${scope}/session-*/workspace`; result.push({ action: "read", resource: workspace, effect: "allow" }, { action: "read", resource: `${workspace}/**`, effect: "allow" }); } } else { for (const child of ["files", "tmp", ".cache"]) result.push({ action: "edit", resource: `${root}/conversation-*/${child}/**`, effect: "ask" }); for (const scope of ["project-*", "personal"]) for (const child of ["files", "tmp", ".cache"]) { result.push({ action: "edit", resource: `${root}/actor-*/${scope}/session-*/workspace/${child}/**`, effect: "ask" }); } } result.push(...rules.slice(0, index).filter((previous) => previous.action === rule.action && previous.effect === "deny" && !dataPatterns.has(previous.resource))); } return result; }; // A conversation Location can be outside the checkout. Load only the trusted // service assets explicitly, rather than relying on its current directory. export function serviceOpencodeConfig(input: Record, models: AgentModelConfig = loadAgentModelConfig(), workspaceRoot = process.env.TJWATER_AGENT_WORKSPACE_ROOT ?? process.env.CONVERSATION_WORKSPACE_ROOT ?? DEFAULT_SESSION_WORKSPACE_ROOT) { const agents = (input.agents ?? {}) as Record; const instruction = (agents.instruction ?? {}) as Record; const system = readFileSync(resolve(".opencode/agents/instruction.md"), "utf8") .replace(/^---\s*\n[\s\S]*?\n---\s*\n/, ""); return { ...input, ...compileOpencodeModels(models), permissions: sessionWorkspacePermissions([...(input.permissions ?? []) as PermissionRule[], ...QUERY_MCP_TOOL_IDS.filter((name) => !deniedProductQuery(name, (input.permissions ?? []) as PermissionRule[])).map((action): PermissionRule => ({ action, resource: "*", effect: "allow" }))], workspaceRoot), snapshots: false, mcp: normalizeMcpConfig(input.mcp), plugins: ["*", ...((input.plugins ?? []) as unknown[]), resolve(".opencode/tjwater-plugin"), "-opencode.browser", "-opencode.browser.*", // The wildcard also loads local-model discovery. vLLM probes port 8000, // mistaking TJWater Server's /health for a model server. Models, including // local providers, must come from agent-models.json instead of discovery. "-opencode.provider.vllm", "-opencode.provider.lmstudio", "-opencode.provider.ollama"], skills: [resolve(process.env.OPENCODE_SKILLS_ROOT_DIR ?? ".opencode/skills")], agents: { ...agents, instruction: { description: "TJWater structured data and UI agent", mode: "primary", ...instruction, model: models.defaults.chat, system, permissions: [ ...agentMessageTools.map((action): PermissionRule => ({ action, resource: "*", effect: action.startsWith("mcp_") && deniedProductQuery(action, (input.permissions ?? []) as PermissionRule[]) ? "deny" : "allow" })), ...((instruction.permissions ?? []) as PermissionRule[]), { action: "subagent", resource: "*", effect: "deny" }, ], }, [TASK_AGENT]: { description: "后台工况调查,协调只读子任务并统一发布进展与报告", mode: "primary", model: models.defaults.task, system: `${system}\n\n${readFileSync(resolve(".opencode/agents/investigation.md"), "utf8")}`, permissions: [ ...agentMessageTools.map((action): PermissionRule => ({ action, resource: "*", effect: [queryMcpToolId("read_agent_messages"), "reply_agent_message"].includes(action) && !(action.startsWith("mcp_") && deniedProductQuery(action, (input.permissions ?? []) as PermissionRule[])) ? "allow" : "deny" })), { action: "subagent", resource: "*", effect: "deny" }, { action: "subagent", resource: TASK_READER_AGENT, effect: "allow" }, ], }, [TASK_READER_AGENT]: { description: "独立核查一项调查问题,仅查询当前任务授权资料并返回证据编号、发现与局限", mode: "subagent", system: readFileSync(resolve(".opencode/agents/investigation-reader.md"), "utf8"), permissions: [ { action: "*", resource: "*", effect: "deny" }, ...TASK_READER_TOOLS.filter((name) => !PRODUCT_MCP_TOOL_IDS.includes(name) || !deniedProductQuery(name, (input.permissions ?? []) as PermissionRule[])).map((action) => ({ action, resource: "*", effect: "allow" })), ], }, }, }; }