From c4e676b8a02d7b5336fd34d3eafc3c37c5c8f045 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 21:36:02 -0400 Subject: [PATCH] fix(task): preserve subagent self permissions (#27201) --- .../src/agent/subagent-permissions.ts | 9 +- .../agent/plan-mode-subagent-bypass.test.ts | 87 +++++++++++++++++++ 2 files changed, 92 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/agent/subagent-permissions.ts b/packages/opencode/src/agent/subagent-permissions.ts index 1174ec31ad..051f42e37b 100644 --- a/packages/opencode/src/agent/subagent-permissions.ts +++ b/packages/opencode/src/agent/subagent-permissions.ts @@ -5,10 +5,10 @@ import type { Agent } from "./agent" * Build the `permission` ruleset for a subagent's session when it's spawned * via the task tool. Combines: * - * 1. The parent **agent's** deny rules — Plan Mode and other agent-level - * restrictions live on the agent ruleset, not on the session, so a + * 1. The parent **agent's** edit-class deny rules — Plan Mode's file-edit + * restriction lives on the agent ruleset, not on the session, so a * subagent that only inherited the parent SESSION's permission would - * silently bypass them. (#26514) + * silently bypass it. (#26514) * 2. The parent **session's** deny rules and external_directory rules — * same forwarding the original code already did. * 3. Default `todowrite` and `task` denies if the subagent's own ruleset @@ -21,7 +21,8 @@ export function deriveSubagentSessionPermission(input: { }): Permission.Ruleset { const canTask = input.subagent.permission.some((rule) => rule.permission === "task") const canTodo = input.subagent.permission.some((rule) => rule.permission === "todowrite") - const parentAgentDenies = input.parentAgent?.permission.filter((rule) => rule.action === "deny") ?? [] + const parentAgentDenies = + input.parentAgent?.permission.filter((rule) => rule.action === "deny" && rule.permission === "edit") ?? [] return [ ...parentAgentDenies, ...input.parentSessionPermission.filter( diff --git a/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts b/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts index 255aea12ee..641a929aeb 100644 --- a/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts +++ b/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts @@ -27,6 +27,19 @@ import { testEffect } from "../lib/effect" const it = testEffect(Agent.defaultLayer) +function testAgent(input: { + name: string + mode: Agent.Info["mode"] + permission: Parameters[0] +}) { + return { + name: input.name, + mode: input.mode, + permission: Permission.fromConfig(input.permission), + options: {}, + } satisfies Agent.Info +} + // `deriveSubagentSessionPermission` is imported from production. The test // exercises the actual helper that task.ts uses to build the subagent's // session permission, so any regression in that helper trips this test. @@ -123,3 +136,77 @@ it.instance( }, }, ) + +it.effect("[#26700] controller self-restrictions do not erase executor permissions", () => + Effect.sync(() => { + const controller = testAgent({ + name: "controller", + mode: "primary", + permission: { + "*": "deny", + read: "deny", + bash: "deny", + task: { + "*": "deny", + executor: "allow", + }, + edit: "deny", + write: "deny", + }, + }) + const executor = testAgent({ + name: "executor", + mode: "subagent", + permission: { + "*": "deny", + read: "allow", + bash: "allow", + task: { + "*": "deny", + worker: "allow", + }, + edit: "deny", + write: "deny", + }, + }) + + const effective = Permission.merge( + executor.permission, + deriveSubagentSessionPermission({ + parentSessionPermission: [], + parentAgent: controller, + subagent: executor, + }), + ) + + expect(Permission.evaluate("read", "README.md", effective).action).toBe("allow") + expect(Permission.evaluate("bash", "git status", effective).action).toBe("allow") + expect(Permission.evaluate("task", "worker", effective).action).toBe("allow") + expect(Permission.evaluate("task", "other", effective).action).toBe("deny") + expect(Permission.disabled(["edit", "write", "apply_patch"], effective)).toEqual( + new Set(["edit", "write", "apply_patch"]), + ) + }), +) + +it.effect("subagent inherits parent session deny rules as hard runtime ceilings", () => + Effect.sync(() => { + const executor = testAgent({ + name: "executor", + mode: "subagent", + permission: { + bash: "allow", + }, + }) + const effective = Permission.merge( + executor.permission, + deriveSubagentSessionPermission({ + parentSessionPermission: Permission.fromConfig({ bash: "deny" }), + parentAgent: undefined, + subagent: executor, + }), + ) + + expect(Permission.evaluate("bash", "git status", effective).action).toBe("deny") + }), +)