Skip to content

Commit b8ca71d

Browse files
kitlangtonDeveloper
andauthored
fix(task): subagent inherits parent agent's deny rules (Plan Mode security bypass) (anomalyco#26597)
Co-authored-by: Developer <temp@example.com>
1 parent 6849f96 commit b8ca71d

3 files changed

Lines changed: 185 additions & 26 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { Permission } from "../permission"
2+
import type { Agent } from "./agent"
3+
4+
/**
5+
* Build the `permission` ruleset for a subagent's session when it's spawned
6+
* via the task tool. Combines:
7+
*
8+
* 1. The parent **agent's** deny rules — Plan Mode and other agent-level
9+
* restrictions live on the agent ruleset, not on the session, so a
10+
* subagent that only inherited the parent SESSION's permission would
11+
* silently bypass them. (#26514)
12+
* 2. The parent **session's** deny rules and external_directory rules —
13+
* same forwarding the original code already did.
14+
* 3. Default `todowrite` and `task` denies if the subagent's own ruleset
15+
* doesn't already permit them.
16+
*/
17+
export function deriveSubagentSessionPermission(input: {
18+
parentSessionPermission: Permission.Ruleset
19+
parentAgent: Agent.Info | undefined
20+
subagent: Agent.Info
21+
}): Permission.Ruleset {
22+
const canTask = input.subagent.permission.some((rule) => rule.permission === "task")
23+
const canTodo = input.subagent.permission.some((rule) => rule.permission === "todowrite")
24+
const parentAgentDenies = input.parentAgent?.permission.filter((rule) => rule.action === "deny") ?? []
25+
return [
26+
...parentAgentDenies,
27+
...input.parentSessionPermission.filter(
28+
(rule) => rule.permission === "external_directory" || rule.action === "deny",
29+
),
30+
...(canTodo ? [] : [{ permission: "todowrite" as const, pattern: "*" as const, action: "deny" as const }]),
31+
...(canTask ? [] : [{ permission: "task" as const, pattern: "*" as const, action: "deny" as const }]),
32+
]
33+
}

packages/opencode/src/tool/task.ts

Lines changed: 11 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Session } from "@/session/session"
44
import { SessionID, MessageID } from "../session/schema"
55
import { MessageV2 } from "../session/message-v2"
66
import { Agent } from "../agent/agent"
7+
import { deriveSubagentSessionPermission } from "../agent/subagent-permissions"
78
import type { SessionPrompt } from "../session/prompt"
89
import { Config } from "@/config/config"
910
import { Effect, Exit, Schema } from "effect"
@@ -58,41 +59,25 @@ export const TaskTool = Tool.define(
5859
return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`))
5960
}
6061

61-
const canTask = next.permission.some((rule) => rule.permission === id)
62-
const canTodo = next.permission.some((rule) => rule.permission === "todowrite")
63-
6462
const taskID = params.task_id
6563
const session = taskID
6664
? yield* sessions.get(SessionID.make(taskID)).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
6765
: undefined
6866
const parent = yield* sessions.get(ctx.sessionID)
67+
const parentAgent = parent.agent
68+
? yield* agent.get(parent.agent).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
69+
: undefined
6970
const nextSession =
7071
session ??
7172
(yield* sessions.create({
7273
parentID: ctx.sessionID,
7374
title: params.description + ` (@${next.name} subagent)`,
7475
permission: [
75-
...(parent.permission ?? []).filter(
76-
(rule) => rule.permission === "external_directory" || rule.action === "deny",
77-
),
78-
...(canTodo
79-
? []
80-
: [
81-
{
82-
permission: "todowrite" as const,
83-
pattern: "*" as const,
84-
action: "deny" as const,
85-
},
86-
]),
87-
...(canTask
88-
? []
89-
: [
90-
{
91-
permission: id,
92-
pattern: "*" as const,
93-
action: "deny" as const,
94-
},
95-
]),
76+
...deriveSubagentSessionPermission({
77+
parentSessionPermission: parent.permission ?? [],
78+
parentAgent,
79+
subagent: next,
80+
}),
9681
...(cfg.experimental?.primary_tools?.map((item) => ({
9782
pattern: "*",
9883
action: "allow" as const,
@@ -144,8 +129,8 @@ export const TaskTool = Tool.define(
144129
},
145130
agent: next.name,
146131
tools: {
147-
...(canTodo ? {} : { todowrite: false }),
148-
...(canTask ? {} : { task: false }),
132+
...(next.permission.some((rule) => rule.permission === "todowrite") ? {} : { todowrite: false }),
133+
...(next.permission.some((rule) => rule.permission === id) ? {} : { task: false }),
149134
...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])),
150135
},
151136
parts,
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/**
2+
* Reproducer for opencode issue #26514:
3+
*
4+
* In Plan Mode (the `plan` agent), the main agent's edit/write tools are
5+
* blocked by the plan agent's permission ruleset (`edit: { "*": "deny" }`).
6+
* However, when the plan agent spawns a subagent via the `task` tool, the
7+
* subagent retains full file modification capabilities — a security bypass.
8+
*
9+
* This test replicates the permission ruleset that would govern a
10+
* `general` subagent when launched from a `plan` parent session, mirroring
11+
* the logic in `src/tool/task.ts` (filtered parent permissions ++ runtime
12+
* subagent agent permissions, evaluated as in `session/prompt.ts`).
13+
*
14+
* The expected (secure) behavior is that the subagent inherits the plan
15+
* mode read-only restriction and `edit`/`write` resolve to `deny`. On
16+
* origin/dev this assertion fails because the parent **agent** permissions
17+
* are not propagated to the subagent — only the parent **session**
18+
* permissions are passed through, and Plan Mode's restrictions live on the
19+
* agent, not the session.
20+
*/
21+
import { test, expect, afterEach } from "bun:test"
22+
import { Effect } from "effect"
23+
import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture"
24+
import { WithInstance } from "../../src/project/with-instance"
25+
import { Agent } from "../../src/agent/agent"
26+
import { deriveSubagentSessionPermission } from "../../src/agent/subagent-permissions"
27+
import { Permission } from "../../src/permission"
28+
29+
afterEach(async () => {
30+
await disposeAllInstances()
31+
})
32+
33+
function load<A>(dir: string, fn: (svc: Agent.Interface) => Effect.Effect<A>) {
34+
return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer)))
35+
}
36+
37+
// `deriveSubagentSessionPermission` is imported from production. The test
38+
// exercises the actual helper that task.ts uses to build the subagent's
39+
// session permission, so any regression in that helper trips this test.
40+
41+
test("[#26514] subagent spawned from plan mode inherits read-only restriction (edit denied)", async () => {
42+
await using tmp = await tmpdir()
43+
await WithInstance.provide({
44+
directory: tmp.path,
45+
fn: async () => {
46+
const planAgent = await load(tmp.path, (svc) => svc.get("plan"))
47+
const generalAgent = await load(tmp.path, (svc) => svc.get("general"))
48+
49+
expect(planAgent).toBeDefined()
50+
expect(generalAgent).toBeDefined()
51+
// Sanity: the plan agent itself blocks edit. (Note: `write` and
52+
// `apply_patch` route through the `edit` permission at the runtime
53+
// tool layer — see Permission.disabled / EDIT_TOOLS.)
54+
expect(Permission.evaluate("edit", "/some/file.ts", planAgent!.permission).action).toBe("deny")
55+
56+
// Simulate the plan-mode parent session: in real flow the plan
57+
// session's `permission` field is empty (Plan Mode lives on the agent
58+
// ruleset, not the session). So we pass [] through as the parent
59+
// session permission, exactly like the actual code path.
60+
const parentSessionPermission: Permission.Ruleset = []
61+
62+
const subagentSessionPermission = deriveSubagentSessionPermission({
63+
parentSessionPermission,
64+
parentAgent: planAgent,
65+
subagent: generalAgent!,
66+
})
67+
68+
// Mirror the runtime evaluation in session/prompt.ts (~line 410, 639):
69+
// ruleset: Permission.merge(agent.permission, session.permission ?? [])
70+
const effective = Permission.merge(generalAgent!.permission, subagentSessionPermission)
71+
72+
expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny")
73+
expect(Permission.evaluate("edit", "/another/path/index.tsx", effective).action).toBe("deny")
74+
},
75+
})
76+
})
77+
78+
test("[#26514] explore subagent launched from plan mode also stays read-only", async () => {
79+
// Sibling check: even though `explore` is intrinsically read-only, the
80+
// bug surface is the same. Including this case to document that the fix
81+
// should propagate the parent **agent** permissions, not just deny edit
82+
// when the subagent happens to already deny it.
83+
await using tmp = await tmpdir()
84+
await WithInstance.provide({
85+
directory: tmp.path,
86+
fn: async () => {
87+
const planAgent = await load(tmp.path, (svc) => svc.get("plan"))
88+
const explore = await load(tmp.path, (svc) => svc.get("explore"))
89+
expect(planAgent).toBeDefined()
90+
expect(explore).toBeDefined()
91+
92+
const parentSessionPermission: Permission.Ruleset = []
93+
const subagentSessionPermission = deriveSubagentSessionPermission({
94+
parentSessionPermission,
95+
parentAgent: planAgent,
96+
subagent: explore!,
97+
})
98+
const effective = Permission.merge(explore!.permission, subagentSessionPermission)
99+
100+
// Already deny — sanity check.
101+
expect(Permission.evaluate("edit", "/x.ts", effective).action).toBe("deny")
102+
},
103+
})
104+
})
105+
106+
test("[#26514] custom user subagent launched from plan mode bypasses Plan Mode read-only", async () => {
107+
// The most damaging case: a user-defined subagent with default
108+
// permissions (allow-by-default, like `general`). The subagent must NOT
109+
// be able to edit when the parent agent is `plan`.
110+
await using tmp = await tmpdir({
111+
config: {
112+
agent: {
113+
my_subagent: {
114+
description: "A user-defined subagent",
115+
mode: "subagent",
116+
},
117+
},
118+
},
119+
})
120+
await WithInstance.provide({
121+
directory: tmp.path,
122+
fn: async () => {
123+
const planAgent = await load(tmp.path, (svc) => svc.get("plan"))
124+
const my = await load(tmp.path, (svc) => svc.get("my_subagent"))
125+
expect(planAgent).toBeDefined()
126+
expect(my).toBeDefined()
127+
128+
const parentSessionPermission: Permission.Ruleset = []
129+
const subagentSessionPermission = deriveSubagentSessionPermission({
130+
parentSessionPermission,
131+
parentAgent: planAgent,
132+
subagent: my!,
133+
})
134+
const effective = Permission.merge(my!.permission, subagentSessionPermission)
135+
136+
// BUG: on origin/dev edit resolves to "allow" because the plan
137+
// agent's `edit: deny *` rule never reaches the subagent.
138+
expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny")
139+
},
140+
})
141+
})

0 commit comments

Comments
 (0)