Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions packages/opencode/src/tool/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, boolean> = restricted ? { edit: false, write: false } : {}
const session = await iife(async () => {
if (params.session_id) {
const found = await Session.get(params.session_id).catch(() => {})
Expand All @@ -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 })
Expand Down Expand Up @@ -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,
})
Expand Down
123 changes: 123 additions & 0 deletions packages/opencode/test/tool/task.test.ts
Original file line number Diff line number Diff line change
@@ -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
},
})
})
})