fix(task): preserve subagent self permissions (#27201)

This commit is contained in:
Kit Langton
2026-05-12 21:36:02 -04:00
committed by GitHub
parent c2c40b5b61
commit c4e676b8a0
2 changed files with 92 additions and 4 deletions

View File

@@ -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(

View File

@@ -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<typeof Permission.fromConfig>[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")
}),
)