11import { 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- */
222import { expect } from "bun:test"
233import { Effect } from "effect"
244import { 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
10572it . 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