index.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  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. The read side ships as the
  7. * `permissions` session projection; the write side ships as the
  8. * `/permission` command — both optional children over the same service.
  9. *
  10. * @module dsh-permission
  11. */
  12. import { Context, Service } from 'cordis'
  13. import z from 'schemastery'
  14. import { z as zod } from 'zod'
  15. import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
  16. import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
  17. import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
  18. // Side-effect type import: declaration-merges `ctx.bash` (the capability fact
  19. // `sandboxMode` this service reads), without a value dependency on the seam.
  20. import type {} from '@deepseek-ai/dsh-bash'
  21. import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
  22. import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
  23. import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
  24. // Type-only: resolves ctx.sessionProjections / ctx.commands for the optional children.
  25. import type {} from '@deepseek-ai/dsh-session-projection'
  26. import type {} from '@deepseek-ai/dsh-commands'
  27. import type { PermissionSelect, PresetOption } from './types.ts'
  28. // The `permissions` projection-key declaration lives in src/types.ts (its one
  29. // home); this re-export projects the type face onto the package root AND
  30. // keeps the module edge in the emitted index.d.ts, so aggregate programs
  31. // consuming the declarations still receive the SessionProjectionMap merge.
  32. export type * from './types.ts'
  33. declare module 'cordis' {
  34. interface Context {
  35. permission: PermissionService
  36. }
  37. }
  38. declare module '@deepseek-ai/dsh-session' {
  39. interface SessionEventMap {
  40. /**
  41. * Records the selected preset as durable, log-only user intent. The knob
  42. * events follow in the same turn and control execution; this event stays
  43. * out of the model transcript and lets {@link effectivePermissionPreset}
  44. * preserve a selection when bundles match.
  45. */
  46. 'permission/preset': { preset: string }
  47. }
  48. }
  49. /** One preset's sandbox/approval bundle and optional client presentation. */
  50. export interface PresetSpec {
  51. /** The `sandbox/mode` value the preset writes through. */
  52. sandbox: SandboxMode
  53. /** The `approval/policy` value the preset writes through. */
  54. approval: ApprovalPolicy
  55. /** The display label a client shows for this preset; the raw table key when omitted. */
  56. name?: string
  57. /** One user-facing sentence on what the preset means; omitted when not configured. */
  58. description?: string
  59. }
  60. /**
  61. * Returned when effective knob values match no table entry. Clients may show
  62. * it as the current value, but it is never a switch target or event payload.
  63. */
  64. export const CUSTOM_PRESET = 'custom'
  65. /** Settings namespace carrying the default for future sessions. */
  66. export const PERMISSION_SETTINGS_NAMESPACE = settingsNamespace('permission')
  67. /**
  68. * Fold the last selected preset from the durable log; replay needs no catch-up
  69. * state.
  70. * @param events - session events in log order; other event types are ignored.
  71. * @returns the last selected preset, or undefined when none was recorded.
  72. */
  73. export function effectivePermissionPreset(events: readonly SessionEvent[]): string | undefined {
  74. for (let index = events.length - 1; index >= 0; index -= 1) {
  75. const event = events[index] as SessionEvent
  76. if (event.type === 'permission/preset') return event.data.preset
  77. }
  78. return undefined
  79. }
  80. /**
  81. * The projection unit's state: the last seen value of each knob event, null
  82. * before an override (composition defaults apply at view time). Plain JSON
  83. * (persisted-cache precondition).
  84. */
  85. export interface KnobState {
  86. /** Last `permission/preset` payload, or null. */
  87. preset: string | null
  88. /** Last `sandbox/mode` payload, or null. */
  89. sandbox: SandboxMode | null
  90. /** Last `approval/policy` payload, or null. */
  91. approval: ApprovalPolicy | null
  92. }
  93. /** State for the empty log: every knob at its composition default. */
  94. const EMPTY_KNOBS: KnobState = { preset: null, sandbox: null, approval: null }
  95. /**
  96. * One-event knob transition (the projection unit's `apply`). Uninterested
  97. * events return the same reference — the registry's change gate.
  98. * @param state - the folded knob state before `event`.
  99. * @param event - one committed session event.
  100. * @returns the next state; the same reference when the event is not a knob.
  101. */
  102. export function applyKnobEvent(state: KnobState, event: SessionEvent): KnobState {
  103. switch (event.type) {
  104. case 'permission/preset':
  105. return { ...state, preset: event.data.preset }
  106. case 'sandbox/mode':
  107. return { ...state, sandbox: event.data.mode }
  108. case 'approval/policy':
  109. return { ...state, approval: event.data.policy }
  110. default:
  111. return state
  112. }
  113. }
  114. /** Whole-log knob fold (the cold-read parallel of {@link applyKnobEvent}). */
  115. function foldKnobs(events: readonly SessionEvent[]): KnobState {
  116. let state = EMPTY_KNOBS
  117. for (const event of events) state = applyKnobEvent(state, event)
  118. return state
  119. }
  120. /** User setting resolved when a new session receives its initial permission. */
  121. export interface PermissionSettings {
  122. /** Preset pinned into a newly created session. */
  123. defaultPreset: string
  124. }
  125. /** The {@link PermissionService} config: preset table and composition default. */
  126. export interface Config {
  127. /**
  128. * The preset table: name → knob bundle. Defaults to `workspace-write`
  129. * (workspace-write + ask) and `danger-full-access` (danger-full-access +
  130. * never). The name `custom` is reserved for the derived not-a-preset state.
  131. */
  132. presets?: Record<string, PresetSpec>
  133. /**
  134. * Default for new sessions. When omitted, the preset matching the composed
  135. * sandbox and approval defaults is used.
  136. */
  137. defaultPreset?: string
  138. }
  139. /**
  140. * Owns the deployment's permission presets and their write path. Requires a
  141. * confining `ctx.bash` executor and `ctx.approval`; unmatched knob values are
  142. * reported as {@link CUSTOM_PRESET}, not an error.
  143. */
  144. export class PermissionService extends Service {
  145. // Inline schema call: the config catalog walks `static Config` statically.
  146. static Config: z<Config> = z.object({
  147. presets: z.dict(z.object({
  148. sandbox: z.union(SANDBOX_MODES as SandboxMode[]).required(),
  149. approval: z.union(APPROVAL_POLICIES as ApprovalPolicy[]).required(),
  150. name: z.string(),
  151. description: z.string(),
  152. })).default({
  153. 'workspace-write': {
  154. sandbox: 'workspace-write', approval: 'ask',
  155. name: 'workspace-write', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.',
  156. },
  157. 'danger-full-access': {
  158. sandbox: 'danger-full-access', approval: 'never',
  159. name: 'danger-full-access', description: 'Full file access without approval prompts.',
  160. },
  161. }),
  162. defaultPreset: z.string(),
  163. })
  164. static inject = ['bash', 'approval', 'sessions']
  165. private readonly presets: Record<string, PresetSpec>
  166. private defaultSettings: () => PermissionSettings
  167. constructor(ctx: Context, config: Config) {
  168. super(ctx, 'permission')
  169. // The schema defaulted the table — the cast records that runtime fact.
  170. this.presets = config.presets as Record<string, PresetSpec>
  171. if (CUSTOM_PRESET in this.presets) {
  172. throw new Error(`permission: "${CUSTOM_PRESET}" is reserved for the derived not-a-preset state and cannot name a table entry`)
  173. }
  174. if (ctx.bash.sandboxMode === undefined) {
  175. 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')
  176. }
  177. const inferredDefault = this.derive(EMPTY_KNOBS)
  178. const defaultPreset = config.defaultPreset ?? inferredDefault
  179. if (defaultPreset === CUSTOM_PRESET) {
  180. throw new Error('permission: composed sandbox and approval defaults match no preset; configure defaultPreset explicitly')
  181. }
  182. this.resolve(defaultPreset)
  183. const baseSettings: PermissionSettings = { defaultPreset }
  184. this.defaultSettings = () => baseSettings
  185. const presetChoices = this.names.map((name) => {
  186. const choice = z.const(name)
  187. const label = this.presets[name]?.name
  188. return label === undefined ? choice : choice.description(label)
  189. })
  190. const settingsSchema: z<PermissionSettings> = z.object({
  191. defaultPreset: z.union(presetChoices).required(),
  192. })
  193. installSettingsSection(ctx, PERMISSION_SETTINGS_NAMESPACE, settingsSchema, baseSettings, {
  194. setSource: (current) => {
  195. this.defaultSettings = current
  196. },
  197. // The source thunk reads the latest scope snapshot at session creation;
  198. // no process-level registration needs replacement on change.
  199. onChange: () => {},
  200. })
  201. ctx.on('session/created', (session) => {
  202. this.pinInitialPermission(session)
  203. })
  204. for (const session of ctx.sessions.list()) {
  205. this.pinInitialPermission(session)
  206. }
  207. // The permissions projection unit: fold the three whole-value knob
  208. // events; view derives the select over the composition defaults this
  209. // service already owns. The unit child activates only when a projection
  210. // registry is composed (headless assemblies stay unaffected).
  211. // zod `.optional()` types the key `string | undefined` while the domain
  212. // says `description?: string`; on the JSON wire the two serialize
  213. // identically (absent), so the cast records exactly that
  214. // exactOptionalPropertyTypes widening (the Wire<T> precedent).
  215. const selectSchema = zod.object({
  216. options: zod.array(zod.object({
  217. value: zod.string().min(1),
  218. name: zod.string().min(1),
  219. description: zod.string().optional(),
  220. })),
  221. currentValue: zod.string().min(1),
  222. }) as unknown as zod.ZodType<PermissionSelect>
  223. ctx.inject(['sessionProjections'], (projectionCtx) => {
  224. projectionCtx.sessionProjections.register<'permissions', KnobState>({
  225. key: 'permissions',
  226. schema: selectSchema,
  227. init: () => EMPTY_KNOBS,
  228. apply: applyKnobEvent,
  229. view: state => this.selectFor(state),
  230. stateVersion: 1,
  231. })
  232. })
  233. // The /permission command: the one write path a web client uses (the
  234. // popup contribution submits the picked preset as this line). The child
  235. // activates only when a command registry is composed.
  236. ctx.inject(['commands'], (commandCtx) => {
  237. commandCtx.commands.register({
  238. name: 'permission',
  239. description: 'Switch the permission preset (sandbox mode + approval policy)',
  240. input: { hint: '<preset>' },
  241. // No settlement text labels its value with this command's own name: a
  242. // surface that renders `name · text` (the web command row) would
  243. // otherwise read `permission · Permission preset: workspace-write.`
  244. handler: ({ agent, rawInput }) => {
  245. const name = rawInput.trim()
  246. if (name === '') {
  247. return { kind: 'success', text: `current preset ${this.current(agent.session.events)} (available: ${this.names.join(', ')})` }
  248. }
  249. if (!this.names.includes(name)) {
  250. return { kind: 'error', text: `unknown preset "${name}" (available: ${this.names.join(', ')})` }
  251. }
  252. this.apply(agent.session, name, (policy) =>{ this.ctx.approval.setPolicy(agent, policy) })
  253. return { kind: 'success', text: `preset ${name}` }
  254. },
  255. })
  256. })
  257. }
  258. /**
  259. * The advertised preset names, in the preset table's declaration order.
  260. * @returns every switchable preset name.
  261. */
  262. get names(): readonly string[] {
  263. return Object.keys(this.presets)
  264. }
  265. /**
  266. * The preset currently selected as the default for future sessions.
  267. * @returns the resolved settings value, or the composition default without
  268. * a mounted settings provider.
  269. */
  270. get defaultPreset(): string {
  271. return this.defaultSettings().defaultPreset
  272. }
  273. /**
  274. * Resolve the preset matching the effective knob values. A still-matching
  275. * last selection wins shared-bundle ties; otherwise the first table match
  276. * wins, or {@link CUSTOM_PRESET} when no entry matches.
  277. * @param events - the session's events in log order.
  278. * @returns the effective preset name, or `custom` when nothing matches.
  279. */
  280. current(events: readonly SessionEvent[]): string {
  281. return this.derive(foldKnobs(events))
  282. }
  283. /** Resolve the preset for one folded knob state (the shared mathematics of `current` and the projection unit). */
  284. private derive(state: KnobState): string {
  285. const sandbox = state.sandbox ?? this.ctx.bash.sandboxMode
  286. const approval = state.approval ?? this.ctx.approval.config.policy ?? 'ask'
  287. const matches = (spec: PresetSpec): boolean => spec.sandbox === sandbox && spec.approval === approval
  288. if (state.preset !== null) {
  289. const spec = this.presets[state.preset]
  290. if (spec !== undefined && matches(spec)) return state.preset
  291. }
  292. for (const [name, spec] of Object.entries(this.presets)) {
  293. if (matches(spec)) return name
  294. }
  295. return CUSTOM_PRESET
  296. }
  297. /**
  298. * Build the whole select value for one folded knob state: every table
  299. * option in declaration order, `custom` appended exactly while derived.
  300. * @param state - the folded knob overrides.
  301. * @returns the `permissions` projection payload.
  302. */
  303. selectFor(state: KnobState): PermissionSelect {
  304. const currentValue = this.derive(state)
  305. return {
  306. options: [
  307. ...this.names.map(name => this.optionOf(name)),
  308. ...currentValue === CUSTOM_PRESET ? [this.optionOf(CUSTOM_PRESET)] : [],
  309. ],
  310. currentValue,
  311. }
  312. }
  313. /**
  314. * Resolve a preset's knob bundle.
  315. * @param name - the preset name to resolve.
  316. * @returns the configured bundle.
  317. * @throws when `name` is not in the table.
  318. */
  319. resolve(name: string): PresetSpec {
  320. const spec = this.presets[name]
  321. if (spec === undefined) {
  322. throw new Error(`permission: unknown preset "${name}" (known: ${Object.keys(this.presets).join(', ')})`)
  323. }
  324. return spec
  325. }
  326. /**
  327. * Build the client option for a table entry or {@link CUSTOM_PRESET}. A
  328. * missing label falls back to the table key.
  329. * @param name - a table key, or `custom`.
  330. * @returns the option a client renders.
  331. * @throws when `name` is neither a table key nor `custom`.
  332. */
  333. optionOf(name: string): PresetOption {
  334. if (name === CUSTOM_PRESET) {
  335. return { value: CUSTOM_PRESET, name: 'Custom', description: 'Current sandbox and approval settings do not match a preset.' }
  336. }
  337. const spec = this.resolve(name)
  338. return { value: name, name: spec.name ?? name, ...spec.description !== undefined ? { description: spec.description } : {} }
  339. }
  340. /**
  341. * Record a changed preset, then update each changed knob through its own
  342. * setter. Selecting the effective preset again appends nothing.
  343. * @param session - the session the switch belongs to.
  344. * @param name - the preset to switch to; unknown names throw.
  345. */
  346. set(session: Session, name: string): void {
  347. this.apply(session, name, (policy) =>{ setApprovalPolicy(session, policy) })
  348. }
  349. /** Apply one preset with the caller-selected live or initialization policy writer. */
  350. private apply(session: Session, name: string, setApproval: (policy: ApprovalPolicy) => void): void {
  351. const spec = this.resolve(name)
  352. if (this.current(session.events) !== name) {
  353. session.append('permission/preset', { preset: name })
  354. }
  355. const events = session.events
  356. if (spec.sandbox !== (effectiveSandboxMode(events) ?? this.ctx.bash.sandboxMode)) {
  357. setSandboxMode(session, spec.sandbox)
  358. }
  359. if (spec.approval !== (effectiveApprovalPolicy(events) ?? this.ctx.approval.config.policy ?? 'ask')) {
  360. setApproval(spec.approval)
  361. }
  362. }
  363. /**
  364. * Fill every missing permission fact before a session is published. A
  365. * genuinely fresh session uses the current user default; seeded or partially
  366. * initialized sessions preserve their effective knob values and only gain
  367. * the missing durable facts.
  368. */
  369. private pinInitialPermission(session: Session): void {
  370. const events = session.events
  371. const selected = effectivePermissionPreset(events)
  372. const sandbox = effectiveSandboxMode(events)
  373. const approval = effectiveApprovalPolicy(events)
  374. const seeded = events.some(event => event.type === 'session/end-seed')
  375. if (selected === undefined && sandbox === undefined && approval === undefined && !seeded) {
  376. const name = this.defaultPreset
  377. const spec = this.resolve(name)
  378. session.append('permission/preset', { preset: name })
  379. setSandboxMode(session, spec.sandbox)
  380. setApprovalPolicy(session, spec.approval)
  381. return
  382. }
  383. const state: KnobState = {
  384. preset: selected ?? null,
  385. sandbox: sandbox ?? null,
  386. approval: approval ?? null,
  387. }
  388. const effective = this.derive(state)
  389. if (selected === undefined && effective !== CUSTOM_PRESET) {
  390. session.append('permission/preset', { preset: effective })
  391. }
  392. if (sandbox === undefined) {
  393. setSandboxMode(session, this.ctx.bash.sandboxMode as SandboxMode)
  394. }
  395. if (approval === undefined) {
  396. setApprovalPolicy(session, this.ctx.approval.config.policy ?? 'ask')
  397. }
  398. }
  399. }
  400. export default PermissionService