|
|
@@ -0,0 +1,141 @@
|
|
|
+/**
|
|
|
+ * Reproducer for opencode issue #26514:
|
|
|
+ *
|
|
|
+ * In Plan Mode (the `plan` agent), the main agent's edit/write tools are
|
|
|
+ * blocked by the plan agent's permission ruleset (`edit: { "*": "deny" }`).
|
|
|
+ * However, when the plan agent spawns a subagent via the `task` tool, the
|
|
|
+ * subagent retains full file modification capabilities — a security bypass.
|
|
|
+ *
|
|
|
+ * This test replicates the permission ruleset that would govern a
|
|
|
+ * `general` subagent when launched from a `plan` parent session, mirroring
|
|
|
+ * the logic in `src/tool/task.ts` (filtered parent permissions ++ runtime
|
|
|
+ * subagent agent permissions, evaluated as in `session/prompt.ts`).
|
|
|
+ *
|
|
|
+ * The expected (secure) behavior is that the subagent inherits the plan
|
|
|
+ * mode read-only restriction and `edit`/`write` resolve to `deny`. On
|
|
|
+ * origin/dev this assertion fails because the parent **agent** permissions
|
|
|
+ * are not propagated to the subagent — only the parent **session**
|
|
|
+ * permissions are passed through, and Plan Mode's restrictions live on the
|
|
|
+ * agent, not the session.
|
|
|
+ */
|
|
|
+import { test, expect, afterEach } from "bun:test"
|
|
|
+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 { deriveSubagentSessionPermission } from "../../src/agent/subagent-permissions"
|
|
|
+import { Permission } from "../../src/permission"
|
|
|
+
|
|
|
+afterEach(async () => {
|
|
|
+ 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
|
|
|
+// exercises the actual helper that task.ts uses to build the subagent's
|
|
|
+// 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 () => {
|
|
|
+ await using tmp = await tmpdir()
|
|
|
+ await WithInstance.provide({
|
|
|
+ directory: tmp.path,
|
|
|
+ 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(generalAgent).toBeDefined()
|
|
|
+ // Sanity: the plan agent itself blocks edit. (Note: `write` and
|
|
|
+ // `apply_patch` route through the `edit` permission at the runtime
|
|
|
+ // tool layer — see Permission.disabled / EDIT_TOOLS.)
|
|
|
+ expect(Permission.evaluate("edit", "/some/file.ts", planAgent!.permission).action).toBe("deny")
|
|
|
+
|
|
|
+ // Simulate the plan-mode parent session: in real flow the plan
|
|
|
+ // session's `permission` field is empty (Plan Mode lives on the agent
|
|
|
+ // ruleset, not the session). So we pass [] through as the parent
|
|
|
+ // session permission, exactly like the actual code path.
|
|
|
+ const parentSessionPermission: Permission.Ruleset = []
|
|
|
+
|
|
|
+ const subagentSessionPermission = deriveSubagentSessionPermission({
|
|
|
+ parentSessionPermission,
|
|
|
+ parentAgent: planAgent,
|
|
|
+ subagent: generalAgent!,
|
|
|
+ })
|
|
|
+
|
|
|
+ // Mirror the runtime evaluation in session/prompt.ts (~line 410, 639):
|
|
|
+ // ruleset: Permission.merge(agent.permission, session.permission ?? [])
|
|
|
+ const effective = Permission.merge(generalAgent!.permission, subagentSessionPermission)
|
|
|
+
|
|
|
+ expect(Permission.evaluate("edit", "/some/file.ts", 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 () => {
|
|
|
+ // Sibling check: even though `explore` is intrinsically read-only, the
|
|
|
+ // bug surface is the same. Including this case to document that the fix
|
|
|
+ // should propagate the parent **agent** permissions, not just deny edit
|
|
|
+ // when the subagent happens to already deny it.
|
|
|
+ await using tmp = await tmpdir()
|
|
|
+ await WithInstance.provide({
|
|
|
+ directory: tmp.path,
|
|
|
+ fn: async () => {
|
|
|
+ const planAgent = await load(tmp.path, (svc) => svc.get("plan"))
|
|
|
+ const explore = await load(tmp.path, (svc) => svc.get("explore"))
|
|
|
+ expect(planAgent).toBeDefined()
|
|
|
+ expect(explore).toBeDefined()
|
|
|
+
|
|
|
+ const parentSessionPermission: Permission.Ruleset = []
|
|
|
+ const subagentSessionPermission = deriveSubagentSessionPermission({
|
|
|
+ parentSessionPermission,
|
|
|
+ parentAgent: planAgent,
|
|
|
+ subagent: explore!,
|
|
|
+ })
|
|
|
+ const effective = Permission.merge(explore!.permission, subagentSessionPermission)
|
|
|
+
|
|
|
+ // Already deny — sanity check.
|
|
|
+ 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 () => {
|
|
|
+ // The most damaging case: a user-defined subagent with default
|
|
|
+ // permissions (allow-by-default, like `general`). The subagent must NOT
|
|
|
+ // be able to edit when the parent agent is `plan`.
|
|
|
+ await using tmp = await tmpdir({
|
|
|
+ config: {
|
|
|
+ agent: {
|
|
|
+ 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(my).toBeDefined()
|
|
|
+
|
|
|
+ const parentSessionPermission: Permission.Ruleset = []
|
|
|
+ const subagentSessionPermission = deriveSubagentSessionPermission({
|
|
|
+ parentSessionPermission,
|
|
|
+ parentAgent: planAgent,
|
|
|
+ subagent: my!,
|
|
|
+ })
|
|
|
+ const effective = Permission.merge(my!.permission, subagentSessionPermission)
|
|
|
+
|
|
|
+ // BUG: on origin/dev edit resolves to "allow" because the plan
|
|
|
+ // agent's `edit: deny *` rule never reaches the subagent.
|
|
|
+ expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny")
|
|
|
+ },
|
|
|
+ })
|
|
|
+})
|