diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index bc93f497a91..8d61bedd708 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -31,6 +31,22 @@ export const TaskTool = Tool.define("task", async () => { async execute(params, ctx) { const agent = await Agent.get(params.subagent_type) if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`) + + // Security: Inherit Plan mode restrictions to sub-agents + // When parent agent has edit="deny" (e.g., Plan mode), sub-agents must: + // 1. Run as Plan agent to inherit bash whitelist (read-only commands) + // 2. Have edit/write tools explicitly disabled + // This prevents bypass of Plan mode restrictions via Task tool + const parent = await Agent.get(ctx.agent) + const restricted = parent?.permission.edit === "deny" + const plan = restricted ? await Agent.get("plan") : null + if (restricted && !plan) + throw new Error( + `Cannot spawn sub-agent from restricted parent "${ctx.agent}": Plan agent is required but not available. ` + + `Ensure the Plan agent is not disabled in your configuration.`, + ) + const target = restricted ? plan! : agent + const restrictions: Record = restricted ? { edit: false, write: false } : {} const session = await iife(async () => { if (params.session_id) { const found = await Session.get(params.session_id).catch(() => {}) @@ -39,7 +55,7 @@ export const TaskTool = Tool.define("task", async () => { return await Session.create({ parentID: ctx.sessionID, - title: params.description + ` (@${agent.name} subagent)`, + title: params.description + ` (@${target.name} subagent)`, }) }) const msg = await MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }) @@ -96,13 +112,14 @@ export const TaskTool = Tool.define("task", async () => { modelID: model.modelID, providerID: model.providerID, }, - agent: agent.name, + agent: target.name, tools: { todowrite: false, todoread: false, task: false, ...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])), ...agent.tools, + ...restrictions, }, parts: promptParts, }) diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts new file mode 100644 index 00000000000..22c6e1a91cc --- /dev/null +++ b/packages/opencode/test/tool/task.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { Instance } from "../../src/project/instance" +import { Agent } from "../../src/agent/agent" +import { tmpdir } from "../fixture/fixture" + +describe("tool.task security", () => { + test("Plan agent has edit permission denied", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const plan = await Agent.get("plan") + expect(plan).toBeDefined() + expect(plan?.permission.edit).toBe("deny") + }, + }) + }) + + test("Plan agent has bash whitelist with read-only commands", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const plan = await Agent.get("plan") + expect(plan).toBeDefined() + // Verify read-only commands are allowed + expect(plan?.permission.bash["grep*"]).toBe("allow") + expect(plan?.permission.bash["ls*"]).toBe("allow") + expect(plan?.permission.bash["git status*"]).toBe("allow") + expect(plan?.permission.bash["git diff*"]).toBe("allow") + // Verify wildcard requires ask (not allow) + expect(plan?.permission.bash["*"]).toBe("ask") + }, + }) + }) + + test("Build agent has edit permission allowed", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const build = await Agent.get("build") + expect(build).toBeDefined() + expect(build?.permission.edit).toBe("allow") + }, + }) + }) + + test("explore subagent exists and has edit disabled in tools", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const explore = await Agent.get("explore") + expect(explore).toBeDefined() + expect(explore?.mode).toBe("subagent") + expect(explore?.tools.edit).toBe(false) + expect(explore?.tools.write).toBe(false) + }, + }) + }) + + test("general subagent exists without edit restrictions", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const general = await Agent.get("general") + expect(general).toBeDefined() + expect(general?.mode).toBe("subagent") + // general agent doesn't have edit/write disabled by default + expect(general?.tools.edit).toBeUndefined() + expect(general?.tools.write).toBeUndefined() + }, + }) + }) + + test("Plan agent cannot be disabled when other restricted agents depend on it", async () => { + // This test verifies Plan agent exists by default + // The security fix requires Plan agent to be available for restricted parent agents + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const agents = await Agent.list() + const plan = agents.find((a) => a.name === "plan") + expect(plan).toBeDefined() + expect(plan?.native).toBe(true) + }, + }) + }) + + test("restricted parent detection uses permission.edit === deny", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + // Create a custom restricted agent + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + agent: { + "custom-restricted": { + mode: "primary", + permission: { + edit: "deny", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const custom = await Agent.get("custom-restricted") + expect(custom).toBeDefined() + expect(custom?.permission.edit).toBe("deny") + // This agent would trigger the restricted path in TaskTool + }, + }) + }) +})