Skip to content

Commit 3ad6923

Browse files
authored
fix(opencode): let subagents use their own permissions (anomalyco#31696)
1 parent e4300e9 commit 3ad6923

5 files changed

Lines changed: 50 additions & 84 deletions

File tree

packages/opencode/src/agent/agent.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,9 @@ export const layer = Layer.effect(
160160
Permission.fromConfig({
161161
question: "allow",
162162
plan_exit: "allow",
163+
task: {
164+
general: "deny",
165+
},
163166
external_directory: {
164167
[path.join(Global.Path.data, "plans", "*")]: "allow",
165168
},

packages/opencode/src/agent/subagent-permissions.ts

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,23 @@
11
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
2-
import type { Permission } from "../permission"
32
import type { Agent } from "./agent"
43

54
/**
65
* Build the `permission` ruleset for a subagent's session when it's spawned
76
* via the task tool. Combines:
87
*
9-
* 1. The parent **agent's** edit-class deny rules — Plan Mode's file-edit
10-
* restriction lives on the agent ruleset, not on the session, so a
11-
* subagent that only inherited the parent SESSION's permission would
12-
* silently bypass it. (#26514)
13-
* 2. The parent **session's** deny rules and external_directory rules —
14-
* same forwarding the original code already did.
15-
* 3. Default `todowrite` and `task` denies if the subagent's own ruleset
8+
* 1. The parent session's deny rules and external_directory rules.
9+
* Parent agent restrictions only govern that agent; the subagent's own
10+
* permissions determine its capabilities.
11+
* 2. Default `todowrite` and `task` denies if the subagent's own ruleset
1612
* doesn't already permit them.
1713
*/
1814
export function deriveSubagentSessionPermission(input: {
1915
parentSessionPermission: PermissionV1.Ruleset
20-
parentAgent: Agent.Info | undefined
2116
subagent: Agent.Info
2217
}): PermissionV1.Ruleset {
2318
const canTask = input.subagent.permission.some((rule) => rule.permission === "task")
2419
const canTodo = input.subagent.permission.some((rule) => rule.permission === "todowrite")
25-
const parentAgentDenies =
26-
input.parentAgent?.permission.filter((rule) => rule.action === "deny" && rule.permission === "edit") ?? []
2720
return [
28-
...parentAgentDenies,
2921
...input.parentSessionPermission.filter(
3022
(rule) => rule.permission === "external_directory" || rule.action === "deny",
3123
),

packages/opencode/src/tool/task.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -122,12 +122,8 @@ export const TaskTool = Tool.define(
122122
? yield* sessions.get(SessionID.make(params.task_id)).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
123123
: undefined
124124
const parent = yield* sessions.get(ctx.sessionID)
125-
const parentAgent = parent.agent
126-
? yield* agent.get(parent.agent).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
127-
: undefined
128125
const childPermission = deriveSubagentSessionPermission({
129126
parentSessionPermission: parent.permission ?? [],
130-
parentAgent,
131127
subagent: next,
132128
})
133129
const childToolDenies = [

packages/opencode/test/agent/agent.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,35 @@ it.instance("plan agent denies edits except .opencode/plans/*", () =>
8585
}),
8686
)
8787

88+
it.instance("plan agent denies the general subagent by default", () =>
89+
Effect.gen(function* () {
90+
const plan = yield* load((svc) => svc.get("plan"))
91+
expect(plan).toBeDefined()
92+
expect(Permission.evaluate("task", "general", plan!.permission).action).toBe("deny")
93+
expect(Permission.evaluate("task", "explore", plan!.permission).action).toBe("allow")
94+
expect(Permission.evaluate("task", "custom", plan!.permission).action).toBe("allow")
95+
}),
96+
)
97+
98+
it.instance(
99+
"user permission can allow the general subagent from plan mode",
100+
() =>
101+
Effect.gen(function* () {
102+
const plan = yield* load((svc) => svc.get("plan"))
103+
expect(plan).toBeDefined()
104+
expect(Permission.evaluate("task", "general", plan!.permission).action).toBe("allow")
105+
}),
106+
{
107+
config: {
108+
permission: {
109+
task: {
110+
general: "allow",
111+
},
112+
},
113+
},
114+
},
115+
)
116+
88117
it.instance("explore agent denies edit and write", () =>
89118
Effect.gen(function* () {
90119
const explore = yield* load((svc) => svc.get("explore"))

packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts

Lines changed: 14 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,4 @@
11
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
2-
/**
3-
* Reproducer for opencode issue #26514:
4-
*
5-
* In Plan Mode (the `plan` agent), the main agent's edit/write tools are
6-
* blocked by the plan agent's permission ruleset (`edit: { "*": "deny" }`).
7-
* However, when the plan agent spawns a subagent via the `task` tool, the
8-
* subagent retains full file modification capabilities — a security bypass.
9-
*
10-
* This test replicates the permission ruleset that would govern a
11-
* `general` subagent when launched from a `plan` parent session, mirroring
12-
* the logic in `src/tool/task.ts` (filtered parent permissions ++ runtime
13-
* subagent agent permissions, evaluated as in `session/prompt.ts`).
14-
*
15-
* The expected (secure) behavior is that the subagent inherits the plan
16-
* mode read-only restriction and `edit`/`write` resolve to `deny`. On
17-
* origin/dev this assertion fails because the parent **agent** permissions
18-
* are not propagated to the subagent — only the parent **session**
19-
* permissions are passed through, and Plan Mode's restrictions live on the
20-
* agent, not the session.
21-
*/
222
import { expect } from "bun:test"
233
import { Effect } from "effect"
244
import { Agent } from "../../src/agent/agent"
@@ -45,7 +25,7 @@ function testAgent(input: {
4525
// exercises the actual helper that task.ts uses to build the subagent's
4626
// session permission, so any regression in that helper trips this test.
4727

48-
it.instance("[#26514] subagent spawned from plan mode inherits read-only restriction (edit denied)", () =>
28+
it.instance("subagent permissions take precedence over parent agent restrictions", () =>
4929
Effect.gen(function* () {
5030
const planAgent = yield* Agent.use.get("plan")
5131
const generalAgent = yield* Agent.use.get("general")
@@ -57,56 +37,40 @@ it.instance("[#26514] subagent spawned from plan mode inherits read-only restric
5737
// tool layer — see Permission.disabled / EDIT_TOOLS.)
5838
expect(Permission.evaluate("edit", "/some/file.ts", planAgent!.permission).action).toBe("deny")
5939

60-
// Simulate the plan-mode parent session: in real flow the plan
61-
// session's `permission` field is empty (Plan Mode lives on the agent
62-
// ruleset, not the session). So we pass [] through as the parent
63-
// session permission, exactly like the actual code path.
6440
const parentSessionPermission: PermissionV1.Ruleset = []
6541

6642
const subagentSessionPermission = deriveSubagentSessionPermission({
6743
parentSessionPermission,
68-
parentAgent: planAgent,
6944
subagent: generalAgent!,
7045
})
7146

7247
// Mirror the runtime evaluation in session/prompt.ts (~line 410, 639):
7348
// ruleset: Permission.merge(agent.permission, session.permission ?? [])
7449
const effective = Permission.merge(generalAgent!.permission, subagentSessionPermission)
7550

76-
expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny")
77-
expect(Permission.evaluate("edit", "/another/path/index.tsx", effective).action).toBe("deny")
51+
expect(Permission.evaluate("edit", "/some/file.ts", effective).action).not.toBe("deny")
52+
expect(Permission.disabled(["edit", "write", "apply_patch"], effective)).toEqual(new Set())
7853
}),
7954
)
8055

81-
it.instance("[#26514] explore subagent launched from plan mode also stays read-only", () =>
82-
// Sibling check: even though `explore` is intrinsically read-only, the
83-
// bug surface is the same. Including this case to document that the fix
84-
// should propagate the parent **agent** permissions, not just deny edit
85-
// when the subagent happens to already deny it.
56+
it.instance("subagent's own read-only restriction remains effective", () =>
8657
Effect.gen(function* () {
87-
const planAgent = yield* Agent.use.get("plan")
8858
const explore = yield* Agent.use.get("explore")
89-
expect(planAgent).toBeDefined()
9059
expect(explore).toBeDefined()
9160

9261
const parentSessionPermission: PermissionV1.Ruleset = []
9362
const subagentSessionPermission = deriveSubagentSessionPermission({
9463
parentSessionPermission,
95-
parentAgent: planAgent,
9664
subagent: explore!,
9765
})
9866
const effective = Permission.merge(explore!.permission, subagentSessionPermission)
9967

100-
// Already deny — sanity check.
10168
expect(Permission.evaluate("edit", "/x.ts", effective).action).toBe("deny")
10269
}),
10370
)
10471

10572
it.instance(
106-
"[#26514] custom user subagent launched from plan mode bypasses Plan Mode read-only",
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`.
73+
"custom subagent can explicitly enable edits denied to its parent agent",
11074
() =>
11175
Effect.gen(function* () {
11276
const planAgent = yield* Agent.use.get("plan")
@@ -117,44 +81,31 @@ it.instance(
11781
const parentSessionPermission: PermissionV1.Ruleset = []
11882
const subagentSessionPermission = deriveSubagentSessionPermission({
11983
parentSessionPermission,
120-
parentAgent: planAgent,
12184
subagent: my!,
12285
})
12386
const effective = Permission.merge(my!.permission, subagentSessionPermission)
12487

125-
// BUG: on origin/dev edit resolves to "allow" because the plan
126-
// agent's `edit: deny *` rule never reaches the subagent.
127-
expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny")
88+
expect(Permission.evaluate("edit", "/some/file.ts", planAgent!.permission).action).toBe("deny")
89+
expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("allow")
90+
expect(Permission.disabled(["edit", "write", "apply_patch"], effective)).toEqual(new Set())
12891
}),
12992
{
13093
config: {
13194
agent: {
13295
my_subagent: {
13396
description: "A user-defined subagent",
13497
mode: "subagent",
98+
permission: {
99+
edit: "allow",
100+
},
135101
},
136102
},
137103
},
138104
},
139105
)
140106

141-
it.effect("[#26700] controller self-restrictions do not erase executor permissions", () =>
107+
it.effect("subagent self permissions are preserved", () =>
142108
Effect.sync(() => {
143-
const controller = testAgent({
144-
name: "controller",
145-
mode: "primary",
146-
permission: {
147-
"*": "deny",
148-
read: "deny",
149-
bash: "deny",
150-
task: {
151-
"*": "deny",
152-
executor: "allow",
153-
},
154-
edit: "deny",
155-
write: "deny",
156-
},
157-
})
158109
const executor = testAgent({
159110
name: "executor",
160111
mode: "subagent",
@@ -166,16 +117,14 @@ it.effect("[#26700] controller self-restrictions do not erase executor permissio
166117
"*": "deny",
167118
worker: "allow",
168119
},
169-
edit: "deny",
170-
write: "deny",
120+
edit: "allow",
171121
},
172122
})
173123

174124
const effective = Permission.merge(
175125
executor.permission,
176126
deriveSubagentSessionPermission({
177127
parentSessionPermission: [],
178-
parentAgent: controller,
179128
subagent: executor,
180129
}),
181130
)
@@ -184,9 +133,7 @@ it.effect("[#26700] controller self-restrictions do not erase executor permissio
184133
expect(Permission.evaluate("bash", "git status", effective).action).toBe("allow")
185134
expect(Permission.evaluate("task", "worker", effective).action).toBe("allow")
186135
expect(Permission.evaluate("task", "other", effective).action).toBe("deny")
187-
expect(Permission.disabled(["edit", "write", "apply_patch"], effective)).toEqual(
188-
new Set(["edit", "write", "apply_patch"]),
189-
)
136+
expect(Permission.disabled(["edit", "write", "apply_patch"], effective)).toEqual(new Set())
190137
}),
191138
)
192139

@@ -203,7 +150,6 @@ it.effect("subagent inherits parent session deny rules as hard runtime ceilings"
203150
executor.permission,
204151
deriveSubagentSessionPermission({
205152
parentSessionPermission: Permission.fromConfig({ bash: "deny" }),
206-
parentAgent: undefined,
207153
subagent: executor,
208154
}),
209155
)

0 commit comments

Comments
 (0)