diff --git a/packages/opencode/src/agent/subagent-permissions.ts b/packages/opencode/src/agent/subagent-permissions.ts index 051f42e37bb3..f64770ae04e1 100644 --- a/packages/opencode/src/agent/subagent-permissions.ts +++ b/packages/opencode/src/agent/subagent-permissions.ts @@ -9,6 +9,9 @@ import type { Agent } from "./agent" * restriction lives on the agent ruleset, not on the session, so a * subagent that only inherited the parent SESSION's permission would * silently bypass it. (#26514) + * Only inherited if the subagent does NOT explicitly allow edit — a + * subagent with `edit: allow` should not have its capability reduced by + * a more-restricted parent. * 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,8 +24,23 @@ 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") + + // Only inherit parent edit:deny if the subagent does NOT explicitly allow edit. + // A subagent with `edit: allow` (or `edit: { "*": "allow" }`) declares its own + // capability — the parent's deny should not override it. + // A subagent without explicit edit declaration (implicit deny) inherits the + // parent's deny as a ceiling. + const subagentAllowsEdit = input.subagent.permission.some( + (rule) => rule.permission === "edit" && rule.action === "allow" && rule.pattern === "*", + ) + const parentAgentDenies = - input.parentAgent?.permission.filter((rule) => rule.action === "deny" && rule.permission === "edit") ?? [] + !subagentAllowsEdit + ? (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 641a929aeb2c..bebd8c977f61 100644 --- a/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts +++ b/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts @@ -210,3 +210,137 @@ it.effect("subagent inherits parent session deny rules as hard runtime ceilings" expect(Permission.evaluate("bash", "git status", effective).action).toBe("deny") }), ) + +it.effect("subagent with explicit edit:allow overrides parent edit:deny", () => + Effect.sync(() => { + const restrictedParent = testAgent({ + name: "restricted-parent", + mode: "primary", + permission: { + edit: "deny", + bash: "deny", + read: "allow", + task: { + "*": "deny", + capableChild: "allow", + }, + }, + }) + const capableChild = testAgent({ + name: "capable-child", + mode: "subagent", + permission: { + edit: "allow", + write: "allow", + bash: "allow", + read: "allow", + task: "deny", + }, + }) + + const effective = Permission.merge( + capableChild.permission, + deriveSubagentSessionPermission({ + parentSessionPermission: [], + parentAgent: restrictedParent, + subagent: capableChild, + }), + ) + + // Child explicitly allows edit — parent deny should not override + expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("allow") + expect(Permission.disabled(["edit", "write", "apply_patch"], effective)).toEqual(new Set()) + }), +) + +it.effect("subagent without explicit edit permission inherits parent edit:deny", () => + Effect.sync(() => { + const restrictedParent = testAgent({ + name: "restricted-parent", + mode: "primary", + permission: { + edit: "deny", + read: "allow", + task: { + "*": "deny", + silentChild: "allow", + }, + }, + }) + const silentChild = testAgent({ + name: "silent-child", + mode: "subagent", + permission: { + read: "allow", + bash: "allow", + task: "deny", + }, + }) + + const effective = Permission.merge( + silentChild.permission, + deriveSubagentSessionPermission({ + parentSessionPermission: [], + parentAgent: restrictedParent, + subagent: silentChild, + }), + ) + + // Child has no explicit edit declaration — parent deny should be inherited + expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny") + expect(Permission.disabled(["edit", "write", "apply_patch"], effective)).toEqual( + new Set(["edit", "write", "apply_patch"]), + ) + }), +) + +it.effect("[orchestrator-pattern] parent edit:deny does not override subagent explicit edit:allow", () => + Effect.sync(() => { + const orchestrator = testAgent({ + name: "orchestrator", + mode: "primary", + permission: { + edit: "deny", + bash: "deny", + read: "allow", + glob: "allow", + grep: "allow", + task: { + "*": "deny", + editor: "allow", + }, + todowrite: "allow", + question: "allow", + webfetch: "allow", + }, + }) + const editor = testAgent({ + name: "editor", + mode: "subagent", + permission: { + edit: "allow", + write: "allow", + bash: "allow", + read: "allow", + glob: "allow", + grep: "allow", + task: "deny", + todowrite: "allow", + }, + }) + + const effective = Permission.merge( + editor.permission, + deriveSubagentSessionPermission({ + parentSessionPermission: [], + parentAgent: orchestrator, + subagent: editor, + }), + ) + + // Editor should have edit available because it explicitly allows it + expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("allow") + // edit/write/apply_patch tools should NOT be in disabled set + expect(Permission.disabled(["edit", "write", "apply_patch"], effective)).toEqual(new Set()) + }), +)