permission.spec.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import { Session, SessionId } from '@deepseek-ai/dsh-session'
  4. import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
  5. import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
  6. import PermissionService, { CUSTOM_PRESET, effectivePermissionPreset } from '@deepseek-ai/dsh-permission'
  7. import type { Config } from '@deepseek-ai/dsh-permission'
  8. async function mounted(options: {
  9. config?: Config
  10. bashDefault?: SandboxMode | undefined
  11. approvalDefault?: ApprovalPolicy | undefined
  12. } = {}): Promise<Context> {
  13. const ctx = new Context()
  14. ctx.provide('bash', {
  15. sandboxMode: 'bashDefault' in options ? options.bashDefault : 'workspace-write',
  16. resolve() { throw new Error('permission tests do not execute bash') },
  17. run() { throw new Error('permission tests do not execute bash') },
  18. start() { throw new Error('permission tests do not execute bash') },
  19. })
  20. ctx.provide('approval', { config: { policy: 'approvalDefault' in options ? options.approvalDefault : 'ask' } })
  21. await ctx.plugin(PermissionService, options.config ?? {})
  22. return ctx
  23. }
  24. function freshSession(id: string): Session {
  25. return new Session(SessionId(id))
  26. }
  27. describe('effectivePermissionPreset', () => {
  28. it('folds to the last event, or undefined without one', () => {
  29. const session = freshSession('sess-fold')
  30. expect(effectivePermissionPreset(session.events)).toBeUndefined()
  31. session.append('permission/preset', { preset: 'danger-full-access' })
  32. session.append('permission/preset', { preset: 'workspace-write' })
  33. expect(effectivePermissionPreset(session.events)).toBe('workspace-write')
  34. })
  35. })
  36. describe('PermissionService', () => {
  37. it('advertises the preset table in declaration order and resolves bundles', async () => {
  38. const ctx = await mounted()
  39. expect(ctx.permission.names).toEqual(['workspace-write', 'danger-full-access'])
  40. expect(ctx.permission.resolve('danger-full-access')).toMatchObject({ sandbox: 'danger-full-access', approval: 'never' })
  41. expect(() => ctx.permission.resolve('plan')).toThrow(/unknown preset "plan"/)
  42. })
  43. it('current() derives from the effective knobs: composition defaults hit workspace-write, a switch hits its preset', async () => {
  44. const ctx = await mounted()
  45. const session = freshSession('sess-current')
  46. expect(ctx.permission.current(session.events)).toBe('workspace-write')
  47. ctx.permission.set(session, 'danger-full-access')
  48. expect(ctx.permission.current(session.events)).toBe('danger-full-access')
  49. })
  50. it('a knob state matching no table entry derives custom — a state, not an error', async () => {
  51. const ctx = await mounted()
  52. const session = freshSession('sess-custom')
  53. session.append('sandbox/mode', { mode: 'read-only' })
  54. expect(ctx.permission.current(session.events)).toBe(CUSTOM_PRESET)
  55. ctx.permission.set(session, 'danger-full-access')
  56. expect(ctx.permission.current(session.events)).toBe('danger-full-access')
  57. expect(() => ctx.permission.resolve(CUSTOM_PRESET)).toThrow(/unknown preset/)
  58. })
  59. it('composition defaults outside the table derive custom at zero events', async () => {
  60. const ctx = await mounted({ approvalDefault: 'never' })
  61. const session = freshSession('sess-defaults-custom')
  62. expect(ctx.permission.current(session.events)).toBe(CUSTOM_PRESET)
  63. })
  64. it('the fold breaks bundle ties; a stale fold no longer matching falls back to table order', async () => {
  65. const ctx = await mounted({ config: { presets: {
  66. 'workspace-write': { sandbox: 'workspace-write', approval: 'ask' },
  67. agentish: { sandbox: 'workspace-write', approval: 'ask' },
  68. 'danger-full-access': { sandbox: 'danger-full-access', approval: 'never' },
  69. } } })
  70. const session = freshSession('sess-tie')
  71. ctx.permission.set(session, 'agentish')
  72. expect(ctx.permission.current(session.events)).toBe('agentish')
  73. session.append('approval/policy', { policy: 'never' })
  74. session.append('sandbox/mode', { mode: 'danger-full-access' })
  75. expect(ctx.permission.current(session.events)).toBe('danger-full-access')
  76. })
  77. it('set() writes through: one preset event plus both knob events', async () => {
  78. const ctx = await mounted()
  79. const session = freshSession('sess-set')
  80. ctx.permission.set(session, 'danger-full-access')
  81. expect(session.events.map(e => [e.type, e.data])).toEqual([
  82. ['permission/preset', { preset: 'danger-full-access' }],
  83. ['sandbox/mode', { mode: 'danger-full-access' }],
  84. ['approval/policy', { policy: 'never' }],
  85. ])
  86. })
  87. it('set() to the current preset is a no-op when the knobs already match (clicks are not switches)', async () => {
  88. const ctx = await mounted()
  89. const session = freshSession('sess-noop')
  90. ctx.permission.set(session, 'workspace-write')
  91. expect(session.events).toHaveLength(0)
  92. })
  93. it('re-asserting a preset from a drifted (custom) state re-records the choice and repairs the knob', async () => {
  94. const ctx = await mounted()
  95. const session = freshSession('sess-drift')
  96. ctx.permission.set(session, 'danger-full-access')
  97. // Re-selecting from a drifted state records the choice and repairs only
  98. // the changed knob.
  99. session.append('sandbox/mode', { mode: 'read-only' })
  100. ctx.permission.set(session, 'danger-full-access')
  101. const tail = session.events.slice(4)
  102. expect(tail.map(e => [e.type, e.data])).toEqual([
  103. ['permission/preset', { preset: 'danger-full-access' }],
  104. ['sandbox/mode', { mode: 'danger-full-access' }],
  105. ])
  106. })
  107. it('rejects composition over a non-confining executor at load', async () => {
  108. await expect(mounted({ bashDefault: undefined }))
  109. .rejects.toThrow(/does not confine/)
  110. })
  111. it('optionOf() presents shipped labels/descriptions, falls back to the raw key, and fixes custom', async () => {
  112. const ctx = await mounted()
  113. expect(ctx.permission.optionOf('danger-full-access')).toEqual({ value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access without approval prompts.' })
  114. expect(ctx.permission.optionOf('custom')).toEqual({ value: 'custom', name: 'Custom', description: 'Current sandbox and approval settings do not match a preset.' })
  115. const bare = await mounted({ config: { presets: { plain: { sandbox: 'workspace-write', approval: 'ask' } } } })
  116. expect(bare.permission.optionOf('plain')).toEqual({ value: 'plain', name: 'plain' })
  117. expect(() => ctx.permission.optionOf('plan')).toThrow(/unknown preset/)
  118. })
  119. it('rejects a table entry named custom (reserved for the derived state)', async () => {
  120. await expect(mounted({ config: { presets: { custom: { sandbox: 'read-only', approval: 'ask' } } } }))
  121. .rejects.toThrow(/reserved for the derived not-a-preset state/)
  122. })
  123. it('reads a schema-less approval stand-in as the ask default', async () => {
  124. const ctx = await mounted({ approvalDefault: undefined })
  125. const session = freshSession('sess-standin')
  126. ctx.permission.set(session, 'workspace-write')
  127. expect(session.events).toHaveLength(0)
  128. expect(ctx.permission.current(session.events)).toBe('workspace-write')
  129. })
  130. })