plan-mode-subagent-bypass.test.ts 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. import { PermissionV1 } from "@opencode-ai/core/v1/permission"
  2. import { expect } from "bun:test"
  3. import { Effect } from "effect"
  4. import { Agent } from "../../src/agent/agent"
  5. import { deriveSubagentSessionPermission } from "../../src/agent/subagent-permissions"
  6. import { Permission } from "../../src/permission"
  7. import { testEffect } from "../lib/effect"
  8. const it = testEffect(Agent.defaultLayer)
  9. function testAgent(input: {
  10. name: string
  11. mode: Agent.Info["mode"]
  12. permission: Parameters<typeof Permission.fromConfig>[0]
  13. }) {
  14. return {
  15. name: input.name,
  16. mode: input.mode,
  17. permission: Permission.fromConfig(input.permission),
  18. options: {},
  19. } satisfies Agent.Info
  20. }
  21. // `deriveSubagentSessionPermission` is imported from production. The test
  22. // exercises the actual helper that task.ts uses to build the subagent's
  23. // session permission, so any regression in that helper trips this test.
  24. it.instance("subagent permissions take precedence over parent agent restrictions", () =>
  25. Effect.gen(function* () {
  26. const planAgent = yield* Agent.use.get("plan")
  27. const generalAgent = yield* Agent.use.get("general")
  28. expect(planAgent).toBeDefined()
  29. expect(generalAgent).toBeDefined()
  30. // Sanity: the plan agent itself blocks edit. (Note: `write` and
  31. // `apply_patch` route through the `edit` permission at the runtime
  32. // tool layer — see Permission.disabled / EDIT_TOOLS.)
  33. expect(Permission.evaluate("edit", "/some/file.ts", planAgent!.permission).action).toBe("deny")
  34. const parentSessionPermission: PermissionV1.Ruleset = []
  35. const subagentSessionPermission = deriveSubagentSessionPermission({
  36. parentSessionPermission,
  37. subagent: generalAgent!,
  38. })
  39. // Mirror the runtime evaluation in session/prompt.ts (~line 410, 639):
  40. // ruleset: Permission.merge(agent.permission, session.permission ?? [])
  41. const effective = Permission.merge(generalAgent!.permission, subagentSessionPermission)
  42. expect(Permission.evaluate("edit", "/some/file.ts", effective).action).not.toBe("deny")
  43. expect(Permission.disabled(["edit", "write", "apply_patch"], effective)).toEqual(new Set())
  44. }),
  45. )
  46. it.instance("subagent's own read-only restriction remains effective", () =>
  47. Effect.gen(function* () {
  48. const explore = yield* Agent.use.get("explore")
  49. expect(explore).toBeDefined()
  50. const parentSessionPermission: PermissionV1.Ruleset = []
  51. const subagentSessionPermission = deriveSubagentSessionPermission({
  52. parentSessionPermission,
  53. subagent: explore!,
  54. })
  55. const effective = Permission.merge(explore!.permission, subagentSessionPermission)
  56. expect(Permission.evaluate("edit", "/x.ts", effective).action).toBe("deny")
  57. }),
  58. )
  59. it.instance(
  60. "custom subagent can explicitly enable edits denied to its parent agent",
  61. () =>
  62. Effect.gen(function* () {
  63. const planAgent = yield* Agent.use.get("plan")
  64. const my = yield* Agent.use.get("my_subagent")
  65. expect(planAgent).toBeDefined()
  66. expect(my).toBeDefined()
  67. const parentSessionPermission: PermissionV1.Ruleset = []
  68. const subagentSessionPermission = deriveSubagentSessionPermission({
  69. parentSessionPermission,
  70. subagent: my!,
  71. })
  72. const effective = Permission.merge(my!.permission, subagentSessionPermission)
  73. expect(Permission.evaluate("edit", "/some/file.ts", planAgent!.permission).action).toBe("deny")
  74. expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("allow")
  75. expect(Permission.disabled(["edit", "write", "apply_patch"], effective)).toEqual(new Set())
  76. }),
  77. {
  78. config: {
  79. agent: {
  80. my_subagent: {
  81. description: "A user-defined subagent",
  82. mode: "subagent",
  83. permission: {
  84. edit: "allow",
  85. },
  86. },
  87. },
  88. },
  89. },
  90. )
  91. it.effect("subagent self permissions are preserved", () =>
  92. Effect.sync(() => {
  93. const executor = testAgent({
  94. name: "executor",
  95. mode: "subagent",
  96. permission: {
  97. "*": "deny",
  98. read: "allow",
  99. bash: "allow",
  100. task: {
  101. "*": "deny",
  102. worker: "allow",
  103. },
  104. edit: "allow",
  105. },
  106. })
  107. const effective = Permission.merge(
  108. executor.permission,
  109. deriveSubagentSessionPermission({
  110. parentSessionPermission: [],
  111. subagent: executor,
  112. }),
  113. )
  114. expect(Permission.evaluate("read", "README.md", effective).action).toBe("allow")
  115. expect(Permission.evaluate("bash", "git status", effective).action).toBe("allow")
  116. expect(Permission.evaluate("task", "worker", effective).action).toBe("allow")
  117. expect(Permission.evaluate("task", "other", effective).action).toBe("deny")
  118. expect(Permission.disabled(["edit", "write", "apply_patch"], effective)).toEqual(new Set())
  119. }),
  120. )
  121. it.effect("subagent inherits parent session deny rules as hard runtime ceilings", () =>
  122. Effect.sync(() => {
  123. const executor = testAgent({
  124. name: "executor",
  125. mode: "subagent",
  126. permission: {
  127. bash: "allow",
  128. },
  129. })
  130. const effective = Permission.merge(
  131. executor.permission,
  132. deriveSubagentSessionPermission({
  133. parentSessionPermission: Permission.fromConfig({ bash: "deny" }),
  134. subagent: executor,
  135. }),
  136. )
  137. expect(Permission.evaluate("bash", "git status", effective).action).toBe("deny")
  138. }),
  139. )