index.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. /**
  2. * User-facing permission presets over the independent sandbox-mode and
  3. * approval-policy knobs. A switch records the selected preset, then writes
  4. * changed knobs through their canonical setters. Execution, prompt narration,
  5. * and replay keep reading their knob folds. The preset event preserves user
  6. * intent when two presets share a bundle.
  7. *
  8. * @module dsh-permission
  9. */
  10. import { Context, Service } from 'cordis'
  11. import z from 'schemastery'
  12. import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
  13. import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
  14. import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-bash'
  15. import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
  16. import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
  17. declare module 'cordis' {
  18. interface Context {
  19. permission: PermissionService
  20. }
  21. }
  22. declare module '@deepseek-ai/dsh-session' {
  23. interface SessionEventMap {
  24. /**
  25. * Records the selected preset as durable, log-only user intent. The knob
  26. * events follow in the same turn and control execution; this event stays
  27. * out of the model transcript and lets {@link effectivePermissionPreset}
  28. * preserve a selection when bundles match.
  29. */
  30. 'permission/preset': { preset: string }
  31. }
  32. }
  33. /** One preset's sandbox/approval bundle and optional client presentation. */
  34. export interface PresetSpec {
  35. /** The `bash/sandbox-mode` value the preset writes through. */
  36. sandbox: SandboxMode
  37. /** The `approval/policy` value the preset writes through. */
  38. approval: ApprovalPolicy
  39. /** The display label a client shows for this preset; the raw table key when omitted. */
  40. name?: string
  41. /** One user-facing sentence on what the preset means; omitted when not configured. */
  42. description?: string
  43. }
  44. /** The select-option shape a presentation layer advertises for one preset (or for the derived `custom` state). */
  45. export interface PresetOption {
  46. /** The machine value (`session/set_config_option` vocabulary): the table key, or `custom`. */
  47. value: string
  48. /** The display label. */
  49. name: string
  50. /** One user-facing sentence on what the value means. */
  51. description?: string
  52. }
  53. /**
  54. * Returned when effective knob values match no table entry. Clients may show
  55. * it as the current value, but it is never a switch target or event payload.
  56. */
  57. export const CUSTOM_PRESET = 'custom'
  58. /**
  59. * Fold the last selected preset from the durable log; replay needs no catch-up
  60. * state.
  61. * @param events - session events in log order; other event types are ignored.
  62. * @returns the last selected preset, or undefined when none was recorded.
  63. */
  64. export function effectivePermissionPreset(events: readonly SessionEvent[]): string | undefined {
  65. for (let index = events.length - 1; index >= 0; index -= 1) {
  66. const event = events[index] as SessionEvent
  67. if (event.type === 'permission/preset') return event.data.preset
  68. }
  69. return undefined
  70. }
  71. /** The {@link PermissionService} config: the deployment's preset table. */
  72. export interface Config {
  73. /**
  74. * The preset table: name → knob bundle. Defaults to `workspace-write`
  75. * (workspace-write + ask) and `danger-full-access` (danger-full-access +
  76. * never). The name `custom` is reserved for the derived not-a-preset state.
  77. */
  78. presets?: Record<string, PresetSpec>
  79. }
  80. /**
  81. * Owns the deployment's permission presets and their write path. Requires a
  82. * confining `ctx.bash` executor and `ctx.approval`; unmatched knob values are
  83. * reported as {@link CUSTOM_PRESET}, not an error.
  84. */
  85. export class PermissionService extends Service {
  86. // Inline schema call: the config catalog walks `static Config` statically.
  87. static Config: z<Config> = z.object({
  88. presets: z.dict(z.object({
  89. sandbox: z.union(SANDBOX_MODES as SandboxMode[]).required(),
  90. approval: z.union(APPROVAL_POLICIES as ApprovalPolicy[]).required(),
  91. name: z.string(),
  92. description: z.string(),
  93. })).default({
  94. 'workspace-write': {
  95. sandbox: 'workspace-write', approval: 'ask',
  96. name: 'workspace-write', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.',
  97. },
  98. 'danger-full-access': {
  99. sandbox: 'danger-full-access', approval: 'never',
  100. name: 'danger-full-access', description: 'Full file access without approval prompts.',
  101. },
  102. }),
  103. })
  104. static inject = ['bash', 'approval']
  105. private readonly presets: Record<string, PresetSpec>
  106. constructor(ctx: Context, config: Config) {
  107. super(ctx, 'permission')
  108. // The schema defaulted the table — the cast records that runtime fact.
  109. this.presets = config.presets as Record<string, PresetSpec>
  110. if (CUSTOM_PRESET in this.presets) {
  111. throw new Error(`permission: "${CUSTOM_PRESET}" is reserved for the derived not-a-preset state and cannot name a table entry`)
  112. }
  113. if (ctx.bash.sandboxMode === undefined) {
  114. throw new Error('permission: the mounted bash executor does not confine (no sandboxMode) — presets bundle a sandbox mode, so composing this plugin over an unconfined executor is a misconfiguration')
  115. }
  116. }
  117. /**
  118. * The advertised preset names, in the preset table's declaration order.
  119. * @returns every switchable preset name.
  120. */
  121. get names(): readonly string[] {
  122. return Object.keys(this.presets)
  123. }
  124. /**
  125. * Resolve the preset matching the effective knob values. A still-matching
  126. * last selection wins shared-bundle ties; otherwise the first table match
  127. * wins, or {@link CUSTOM_PRESET} when no entry matches.
  128. * @param events - the session's events in log order.
  129. * @returns the effective preset name, or `custom` when nothing matches.
  130. */
  131. current(events: readonly SessionEvent[]): string {
  132. const sandbox = effectiveSandboxMode(events) ?? this.ctx.bash.sandboxMode
  133. const approval = effectiveApprovalPolicy(events) ?? this.ctx.approval.config.policy ?? 'ask'
  134. const matches = (spec: PresetSpec): boolean => spec.sandbox === sandbox && spec.approval === approval
  135. const folded = effectivePermissionPreset(events)
  136. if (folded !== undefined) {
  137. const spec = this.presets[folded]
  138. if (spec !== undefined && matches(spec)) return folded
  139. }
  140. for (const [name, spec] of Object.entries(this.presets)) {
  141. if (matches(spec)) return name
  142. }
  143. return CUSTOM_PRESET
  144. }
  145. /**
  146. * Resolve a preset's knob bundle.
  147. * @param name - the preset name to resolve.
  148. * @returns the configured bundle.
  149. * @throws when `name` is not in the table.
  150. */
  151. resolve(name: string): PresetSpec {
  152. const spec = this.presets[name]
  153. if (spec === undefined) {
  154. throw new Error(`permission: unknown preset "${name}" (known: ${Object.keys(this.presets).join(', ')})`)
  155. }
  156. return spec
  157. }
  158. /**
  159. * Build the client option for a table entry or {@link CUSTOM_PRESET}. A
  160. * missing label falls back to the table key.
  161. * @param name - a table key, or `custom`.
  162. * @returns the option a client renders.
  163. * @throws when `name` is neither a table key nor `custom`.
  164. */
  165. optionOf(name: string): PresetOption {
  166. if (name === CUSTOM_PRESET) {
  167. return { value: CUSTOM_PRESET, name: 'Custom', description: 'Current sandbox and approval settings do not match a preset.' }
  168. }
  169. const spec = this.resolve(name)
  170. return { value: name, name: spec.name ?? name, ...spec.description !== undefined ? { description: spec.description } : {} }
  171. }
  172. /**
  173. * Record a changed preset, then update each changed knob through its own
  174. * setter. Selecting the effective preset again appends nothing.
  175. * @param session - the session the switch belongs to.
  176. * @param name - the preset to switch to; unknown names throw.
  177. */
  178. set(session: Session, name: string): void {
  179. const spec = this.resolve(name)
  180. if (this.current(session.events) !== name) {
  181. session.append('permission/preset', { preset: name })
  182. }
  183. const events = session.events
  184. if (spec.sandbox !== (effectiveSandboxMode(events) ?? this.ctx.bash.sandboxMode)) {
  185. setSandboxMode(session, spec.sandbox)
  186. }
  187. if (spec.approval !== (effectiveApprovalPolicy(events) ?? this.ctx.approval.config.policy ?? 'ask')) {
  188. setApprovalPolicy(session, spec.approval)
  189. }
  190. }
  191. }
  192. export default PermissionService