test(agent): migrate plan bypass tests to Effect runner (#27119)

This commit is contained in:
Kit Langton 2026-05-12 14:56:01 -04:00 committed by GitHub
commit e540daabc4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -18,110 +18,85 @@
* permissions are passed through, and Plan Mode's restrictions live on the * permissions are passed through, and Plan Mode's restrictions live on the
* agent, not the session. * agent, not the session.
*/ */
import { test, expect, afterEach } from "bun:test" import { expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture"
import { WithInstance } from "../../src/project/with-instance"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
import { deriveSubagentSessionPermission } from "../../src/agent/subagent-permissions" import { deriveSubagentSessionPermission } from "../../src/agent/subagent-permissions"
import { Permission } from "../../src/permission" import { Permission } from "../../src/permission"
import { testEffect } from "../lib/effect"
afterEach(async () => { const it = testEffect(Agent.defaultLayer)
await disposeAllInstances()
})
function load<A>(dir: string, fn: (svc: Agent.Interface) => Effect.Effect<A>) {
return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer)))
}
// `deriveSubagentSessionPermission` is imported from production. The test // `deriveSubagentSessionPermission` is imported from production. The test
// exercises the actual helper that task.ts uses to build the subagent's // exercises the actual helper that task.ts uses to build the subagent's
// session permission, so any regression in that helper trips this test. // session permission, so any regression in that helper trips this test.
test("[#26514] subagent spawned from plan mode inherits read-only restriction (edit denied)", async () => { it.instance("[#26514] subagent spawned from plan mode inherits read-only restriction (edit denied)", () =>
await using tmp = await tmpdir() Effect.gen(function* () {
await WithInstance.provide({ const planAgent = yield* Agent.Service.use((svc) => svc.get("plan"))
directory: tmp.path, const generalAgent = yield* Agent.Service.use((svc) => svc.get("general"))
fn: async () => {
const planAgent = await load(tmp.path, (svc) => svc.get("plan"))
const generalAgent = await load(tmp.path, (svc) => svc.get("general"))
expect(planAgent).toBeDefined() expect(planAgent).toBeDefined()
expect(generalAgent).toBeDefined() expect(generalAgent).toBeDefined()
// Sanity: the plan agent itself blocks edit. (Note: `write` and // Sanity: the plan agent itself blocks edit. (Note: `write` and
// `apply_patch` route through the `edit` permission at the runtime // `apply_patch` route through the `edit` permission at the runtime
// tool layer — see Permission.disabled / EDIT_TOOLS.) // tool layer — see Permission.disabled / EDIT_TOOLS.)
expect(Permission.evaluate("edit", "/some/file.ts", planAgent!.permission).action).toBe("deny") expect(Permission.evaluate("edit", "/some/file.ts", planAgent!.permission).action).toBe("deny")
// Simulate the plan-mode parent session: in real flow the plan // Simulate the plan-mode parent session: in real flow the plan
// session's `permission` field is empty (Plan Mode lives on the agent // session's `permission` field is empty (Plan Mode lives on the agent
// ruleset, not the session). So we pass [] through as the parent // ruleset, not the session). So we pass [] through as the parent
// session permission, exactly like the actual code path. // session permission, exactly like the actual code path.
const parentSessionPermission: Permission.Ruleset = [] const parentSessionPermission: Permission.Ruleset = []
const subagentSessionPermission = deriveSubagentSessionPermission({ const subagentSessionPermission = deriveSubagentSessionPermission({
parentSessionPermission, parentSessionPermission,
parentAgent: planAgent, parentAgent: planAgent,
subagent: generalAgent!, subagent: generalAgent!,
}) })
// Mirror the runtime evaluation in session/prompt.ts (~line 410, 639): // Mirror the runtime evaluation in session/prompt.ts (~line 410, 639):
// ruleset: Permission.merge(agent.permission, session.permission ?? []) // ruleset: Permission.merge(agent.permission, session.permission ?? [])
const effective = Permission.merge(generalAgent!.permission, subagentSessionPermission) const effective = Permission.merge(generalAgent!.permission, subagentSessionPermission)
expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny") expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny")
expect(Permission.evaluate("edit", "/another/path/index.tsx", effective).action).toBe("deny") expect(Permission.evaluate("edit", "/another/path/index.tsx", effective).action).toBe("deny")
}, }),
}) )
})
test("[#26514] explore subagent launched from plan mode also stays read-only", async () => { it.instance("[#26514] explore subagent launched from plan mode also stays read-only", () =>
// Sibling check: even though `explore` is intrinsically read-only, the // Sibling check: even though `explore` is intrinsically read-only, the
// bug surface is the same. Including this case to document that the fix // bug surface is the same. Including this case to document that the fix
// should propagate the parent **agent** permissions, not just deny edit // should propagate the parent **agent** permissions, not just deny edit
// when the subagent happens to already deny it. // when the subagent happens to already deny it.
await using tmp = await tmpdir() Effect.gen(function* () {
await WithInstance.provide({ const planAgent = yield* Agent.Service.use((svc) => svc.get("plan"))
directory: tmp.path, const explore = yield* Agent.Service.use((svc) => svc.get("explore"))
fn: async () => { expect(planAgent).toBeDefined()
const planAgent = await load(tmp.path, (svc) => svc.get("plan")) expect(explore).toBeDefined()
const explore = await load(tmp.path, (svc) => svc.get("explore"))
expect(planAgent).toBeDefined()
expect(explore).toBeDefined()
const parentSessionPermission: Permission.Ruleset = [] const parentSessionPermission: Permission.Ruleset = []
const subagentSessionPermission = deriveSubagentSessionPermission({ const subagentSessionPermission = deriveSubagentSessionPermission({
parentSessionPermission, parentSessionPermission,
parentAgent: planAgent, parentAgent: planAgent,
subagent: explore!, subagent: explore!,
}) })
const effective = Permission.merge(explore!.permission, subagentSessionPermission) const effective = Permission.merge(explore!.permission, subagentSessionPermission)
// Already deny — sanity check. // Already deny — sanity check.
expect(Permission.evaluate("edit", "/x.ts", effective).action).toBe("deny") expect(Permission.evaluate("edit", "/x.ts", effective).action).toBe("deny")
}, }),
}) )
})
test("[#26514] custom user subagent launched from plan mode bypasses Plan Mode read-only", async () => { it.instance(
"[#26514] custom user subagent launched from plan mode bypasses Plan Mode read-only",
// The most damaging case: a user-defined subagent with default // The most damaging case: a user-defined subagent with default
// permissions (allow-by-default, like `general`). The subagent must NOT // permissions (allow-by-default, like `general`). The subagent must NOT
// be able to edit when the parent agent is `plan`. // be able to edit when the parent agent is `plan`.
await using tmp = await tmpdir({ () =>
config: { Effect.gen(function* () {
agent: { const planAgent = yield* Agent.Service.use((svc) => svc.get("plan"))
my_subagent: { const my = yield* Agent.Service.use((svc) => svc.get("my_subagent"))
description: "A user-defined subagent",
mode: "subagent",
},
},
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const planAgent = await load(tmp.path, (svc) => svc.get("plan"))
const my = await load(tmp.path, (svc) => svc.get("my_subagent"))
expect(planAgent).toBeDefined() expect(planAgent).toBeDefined()
expect(my).toBeDefined() expect(my).toBeDefined()
@ -136,6 +111,15 @@ test("[#26514] custom user subagent launched from plan mode bypasses Plan Mode r
// BUG: on origin/dev edit resolves to "allow" because the plan // BUG: on origin/dev edit resolves to "allow" because the plan
// agent's `edit: deny *` rule never reaches the subagent. // agent's `edit: deny *` rule never reaches the subagent.
expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny") expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny")
}),
{
config: {
agent: {
my_subagent: {
description: "A user-defined subagent",
mode: "subagent",
},
},
}, },
}) },
}) )