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

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