| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433 |
- /**
- * User-facing permission presets over the independent sandbox-mode and
- * approval-policy knobs. A switch records the selected preset, then writes
- * changed knobs through their canonical setters. Execution, prompt narration,
- * and replay keep reading their knob folds. The preset event preserves user
- * intent when two presets share a bundle. The read side ships as the
- * `permissions` session projection; the write side ships as the
- * `/permission` command — both optional children over the same service.
- *
- * @module dsh-permission
- */
- import { Context, Service } from 'cordis'
- import z from 'schemastery'
- import { z as zod } from 'zod'
- import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
- import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
- import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
- // Side-effect type import: declaration-merges `ctx.bash` (the capability fact
- // `sandboxMode` this service reads), without a value dependency on the seam.
- import type {} from '@deepseek-ai/dsh-bash'
- import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
- import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
- import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
- // Type-only: resolves ctx.sessionProjections / ctx.commands for the optional children.
- import type {} from '@deepseek-ai/dsh-session-projection'
- import type {} from '@deepseek-ai/dsh-commands'
- import type { PermissionSelect, PresetOption } from './types.ts'
- // The `permissions` projection-key declaration lives in src/types.ts (its one
- // home); this re-export projects the type face onto the package root AND
- // keeps the module edge in the emitted index.d.ts, so aggregate programs
- // consuming the declarations still receive the SessionProjectionMap merge.
- export type * from './types.ts'
- declare module 'cordis' {
- interface Context {
- permission: PermissionService
- }
- }
- declare module '@deepseek-ai/dsh-session' {
- interface SessionEventMap {
- /**
- * Records the selected preset as durable, log-only user intent. The knob
- * events follow in the same turn and control execution; this event stays
- * out of the model transcript and lets {@link effectivePermissionPreset}
- * preserve a selection when bundles match.
- */
- 'permission/preset': { preset: string }
- }
- }
- /** One preset's sandbox/approval bundle and optional client presentation. */
- export interface PresetSpec {
- /** The `sandbox/mode` value the preset writes through. */
- sandbox: SandboxMode
- /** The `approval/policy` value the preset writes through. */
- approval: ApprovalPolicy
- /** The display label a client shows for this preset; the raw table key when omitted. */
- name?: string
- /** One user-facing sentence on what the preset means; omitted when not configured. */
- description?: string
- }
- /**
- * Returned when effective knob values match no table entry. Clients may show
- * it as the current value, but it is never a switch target or event payload.
- */
- export const CUSTOM_PRESET = 'custom'
- /** Settings namespace carrying the default for future sessions. */
- export const PERMISSION_SETTINGS_NAMESPACE = settingsNamespace('permission')
- /**
- * Fold the last selected preset from the durable log; replay needs no catch-up
- * state.
- * @param events - session events in log order; other event types are ignored.
- * @returns the last selected preset, or undefined when none was recorded.
- */
- export function effectivePermissionPreset(events: readonly SessionEvent[]): string | undefined {
- for (let index = events.length - 1; index >= 0; index -= 1) {
- const event = events[index] as SessionEvent
- if (event.type === 'permission/preset') return event.data.preset
- }
- return undefined
- }
- /**
- * The projection unit's state: the last seen value of each knob event, null
- * before an override (composition defaults apply at view time). Plain JSON
- * (persisted-cache precondition).
- */
- export interface KnobState {
- /** Last `permission/preset` payload, or null. */
- preset: string | null
- /** Last `sandbox/mode` payload, or null. */
- sandbox: SandboxMode | null
- /** Last `approval/policy` payload, or null. */
- approval: ApprovalPolicy | null
- }
- /** State for the empty log: every knob at its composition default. */
- const EMPTY_KNOBS: KnobState = { preset: null, sandbox: null, approval: null }
- /**
- * One-event knob transition (the projection unit's `apply`). Uninterested
- * events return the same reference — the registry's change gate.
- * @param state - the folded knob state before `event`.
- * @param event - one committed session event.
- * @returns the next state; the same reference when the event is not a knob.
- */
- export function applyKnobEvent(state: KnobState, event: SessionEvent): KnobState {
- switch (event.type) {
- case 'permission/preset':
- return { ...state, preset: event.data.preset }
- case 'sandbox/mode':
- return { ...state, sandbox: event.data.mode }
- case 'approval/policy':
- return { ...state, approval: event.data.policy }
- default:
- return state
- }
- }
- /** Whole-log knob fold (the cold-read parallel of {@link applyKnobEvent}). */
- function foldKnobs(events: readonly SessionEvent[]): KnobState {
- let state = EMPTY_KNOBS
- for (const event of events) state = applyKnobEvent(state, event)
- return state
- }
- /** User setting resolved when a new session receives its initial permission. */
- export interface PermissionSettings {
- /** Preset pinned into a newly created session. */
- defaultPreset: string
- }
- /** The {@link PermissionService} config: preset table and composition default. */
- export interface Config {
- /**
- * The preset table: name → knob bundle. Defaults to `workspace-write`
- * (workspace-write + ask) and `danger-full-access` (danger-full-access +
- * never). The name `custom` is reserved for the derived not-a-preset state.
- */
- presets?: Record<string, PresetSpec>
- /**
- * Default for new sessions. When omitted, the preset matching the composed
- * sandbox and approval defaults is used.
- */
- defaultPreset?: string
- }
- /**
- * Owns the deployment's permission presets and their write path. Requires a
- * confining `ctx.bash` executor and `ctx.approval`; unmatched knob values are
- * reported as {@link CUSTOM_PRESET}, not an error.
- */
- export class PermissionService extends Service {
- // Inline schema call: the config catalog walks `static Config` statically.
- static Config: z<Config> = z.object({
- presets: z.dict(z.object({
- sandbox: z.union(SANDBOX_MODES as SandboxMode[]).required(),
- approval: z.union(APPROVAL_POLICIES as ApprovalPolicy[]).required(),
- name: z.string(),
- description: z.string(),
- })).default({
- 'workspace-write': {
- sandbox: 'workspace-write', approval: 'ask',
- name: 'workspace-write', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.',
- },
- 'danger-full-access': {
- sandbox: 'danger-full-access', approval: 'never',
- name: 'danger-full-access', description: 'Full file access without approval prompts.',
- },
- }),
- defaultPreset: z.string(),
- })
- static inject = ['bash', 'approval', 'sessions']
- private readonly presets: Record<string, PresetSpec>
- private defaultSettings: () => PermissionSettings
- constructor(ctx: Context, config: Config) {
- super(ctx, 'permission')
- // The schema defaulted the table — the cast records that runtime fact.
- this.presets = config.presets as Record<string, PresetSpec>
- if (CUSTOM_PRESET in this.presets) {
- throw new Error(`permission: "${CUSTOM_PRESET}" is reserved for the derived not-a-preset state and cannot name a table entry`)
- }
- if (ctx.bash.sandboxMode === undefined) {
- 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')
- }
- const inferredDefault = this.derive(EMPTY_KNOBS)
- const defaultPreset = config.defaultPreset ?? inferredDefault
- if (defaultPreset === CUSTOM_PRESET) {
- throw new Error('permission: composed sandbox and approval defaults match no preset; configure defaultPreset explicitly')
- }
- this.resolve(defaultPreset)
- const baseSettings: PermissionSettings = { defaultPreset }
- this.defaultSettings = () => baseSettings
- const presetChoices = this.names.map((name) => {
- const choice = z.const(name)
- const label = this.presets[name]?.name
- return label === undefined ? choice : choice.description(label)
- })
- const settingsSchema: z<PermissionSettings> = z.object({
- defaultPreset: z.union(presetChoices).required(),
- })
- installSettingsSection(ctx, PERMISSION_SETTINGS_NAMESPACE, settingsSchema, baseSettings, {
- setSource: (current) => {
- this.defaultSettings = current
- },
- // The source thunk reads the latest scope snapshot at session creation;
- // no process-level registration needs replacement on change.
- onChange: () => {},
- })
- ctx.on('session/created', (session) => {
- this.pinInitialPermission(session)
- })
- for (const session of ctx.sessions.list()) {
- this.pinInitialPermission(session)
- }
- // The permissions projection unit: fold the three whole-value knob
- // events; view derives the select over the composition defaults this
- // service already owns. The unit child activates only when a projection
- // registry is composed (headless assemblies stay unaffected).
- // zod `.optional()` types the key `string | undefined` while the domain
- // says `description?: string`; on the JSON wire the two serialize
- // identically (absent), so the cast records exactly that
- // exactOptionalPropertyTypes widening (the Wire<T> precedent).
- const selectSchema = zod.object({
- options: zod.array(zod.object({
- value: zod.string().min(1),
- name: zod.string().min(1),
- description: zod.string().optional(),
- })),
- currentValue: zod.string().min(1),
- }) as unknown as zod.ZodType<PermissionSelect>
- ctx.inject(['sessionProjections'], (projectionCtx) => {
- projectionCtx.sessionProjections.register<'permissions', KnobState>({
- key: 'permissions',
- schema: selectSchema,
- init: () => EMPTY_KNOBS,
- apply: applyKnobEvent,
- view: state => this.selectFor(state),
- stateVersion: 1,
- })
- })
- // The /permission command: the one write path a web client uses (the
- // popup contribution submits the picked preset as this line). The child
- // activates only when a command registry is composed.
- ctx.inject(['commands'], (commandCtx) => {
- commandCtx.commands.register({
- name: 'permission',
- description: 'Switch the permission preset (sandbox mode + approval policy)',
- input: { hint: '<preset>' },
- // No settlement text labels its value with this command's own name: a
- // surface that renders `name · text` (the web command row) would
- // otherwise read `permission · Permission preset: workspace-write.`
- handler: ({ agent, rawInput }) => {
- const name = rawInput.trim()
- if (name === '') {
- return { kind: 'success', text: `current preset ${this.current(agent.session.events)} (available: ${this.names.join(', ')})` }
- }
- if (!this.names.includes(name)) {
- return { kind: 'error', text: `unknown preset "${name}" (available: ${this.names.join(', ')})` }
- }
- this.apply(agent.session, name, (policy) =>{ this.ctx.approval.setPolicy(agent, policy) })
- return { kind: 'success', text: `preset ${name}` }
- },
- })
- })
- }
- /**
- * The advertised preset names, in the preset table's declaration order.
- * @returns every switchable preset name.
- */
- get names(): readonly string[] {
- return Object.keys(this.presets)
- }
- /**
- * The preset currently selected as the default for future sessions.
- * @returns the resolved settings value, or the composition default without
- * a mounted settings provider.
- */
- get defaultPreset(): string {
- return this.defaultSettings().defaultPreset
- }
- /**
- * Resolve the preset matching the effective knob values. A still-matching
- * last selection wins shared-bundle ties; otherwise the first table match
- * wins, or {@link CUSTOM_PRESET} when no entry matches.
- * @param events - the session's events in log order.
- * @returns the effective preset name, or `custom` when nothing matches.
- */
- current(events: readonly SessionEvent[]): string {
- return this.derive(foldKnobs(events))
- }
- /** Resolve the preset for one folded knob state (the shared mathematics of `current` and the projection unit). */
- private derive(state: KnobState): string {
- const sandbox = state.sandbox ?? this.ctx.bash.sandboxMode
- const approval = state.approval ?? this.ctx.approval.config.policy ?? 'ask'
- const matches = (spec: PresetSpec): boolean => spec.sandbox === sandbox && spec.approval === approval
- if (state.preset !== null) {
- const spec = this.presets[state.preset]
- if (spec !== undefined && matches(spec)) return state.preset
- }
- for (const [name, spec] of Object.entries(this.presets)) {
- if (matches(spec)) return name
- }
- return CUSTOM_PRESET
- }
- /**
- * Build the whole select value for one folded knob state: every table
- * option in declaration order, `custom` appended exactly while derived.
- * @param state - the folded knob overrides.
- * @returns the `permissions` projection payload.
- */
- selectFor(state: KnobState): PermissionSelect {
- const currentValue = this.derive(state)
- return {
- options: [
- ...this.names.map(name => this.optionOf(name)),
- ...currentValue === CUSTOM_PRESET ? [this.optionOf(CUSTOM_PRESET)] : [],
- ],
- currentValue,
- }
- }
- /**
- * Resolve a preset's knob bundle.
- * @param name - the preset name to resolve.
- * @returns the configured bundle.
- * @throws when `name` is not in the table.
- */
- resolve(name: string): PresetSpec {
- const spec = this.presets[name]
- if (spec === undefined) {
- throw new Error(`permission: unknown preset "${name}" (known: ${Object.keys(this.presets).join(', ')})`)
- }
- return spec
- }
- /**
- * Build the client option for a table entry or {@link CUSTOM_PRESET}. A
- * missing label falls back to the table key.
- * @param name - a table key, or `custom`.
- * @returns the option a client renders.
- * @throws when `name` is neither a table key nor `custom`.
- */
- optionOf(name: string): PresetOption {
- if (name === CUSTOM_PRESET) {
- return { value: CUSTOM_PRESET, name: 'Custom', description: 'Current sandbox and approval settings do not match a preset.' }
- }
- const spec = this.resolve(name)
- return { value: name, name: spec.name ?? name, ...spec.description !== undefined ? { description: spec.description } : {} }
- }
- /**
- * Record a changed preset, then update each changed knob through its own
- * setter. Selecting the effective preset again appends nothing.
- * @param session - the session the switch belongs to.
- * @param name - the preset to switch to; unknown names throw.
- */
- set(session: Session, name: string): void {
- this.apply(session, name, (policy) =>{ setApprovalPolicy(session, policy) })
- }
- /** Apply one preset with the caller-selected live or initialization policy writer. */
- private apply(session: Session, name: string, setApproval: (policy: ApprovalPolicy) => void): void {
- const spec = this.resolve(name)
- if (this.current(session.events) !== name) {
- session.append('permission/preset', { preset: name })
- }
- const events = session.events
- if (spec.sandbox !== (effectiveSandboxMode(events) ?? this.ctx.bash.sandboxMode)) {
- setSandboxMode(session, spec.sandbox)
- }
- if (spec.approval !== (effectiveApprovalPolicy(events) ?? this.ctx.approval.config.policy ?? 'ask')) {
- setApproval(spec.approval)
- }
- }
- /**
- * Fill every missing permission fact before a session is published. A
- * genuinely fresh session uses the current user default; seeded or partially
- * initialized sessions preserve their effective knob values and only gain
- * the missing durable facts.
- */
- private pinInitialPermission(session: Session): void {
- const events = session.events
- const selected = effectivePermissionPreset(events)
- const sandbox = effectiveSandboxMode(events)
- const approval = effectiveApprovalPolicy(events)
- const seeded = events.some(event => event.type === 'session/end-seed')
- if (selected === undefined && sandbox === undefined && approval === undefined && !seeded) {
- const name = this.defaultPreset
- const spec = this.resolve(name)
- session.append('permission/preset', { preset: name })
- setSandboxMode(session, spec.sandbox)
- setApprovalPolicy(session, spec.approval)
- return
- }
- const state: KnobState = {
- preset: selected ?? null,
- sandbox: sandbox ?? null,
- approval: approval ?? null,
- }
- const effective = this.derive(state)
- if (selected === undefined && effective !== CUSTOM_PRESET) {
- session.append('permission/preset', { preset: effective })
- }
- if (sandbox === undefined) {
- setSandboxMode(session, this.ctx.bash.sandboxMode as SandboxMode)
- }
- if (approval === undefined) {
- setApprovalPolicy(session, this.ctx.approval.config.policy ?? 'ask')
- }
- }
- }
- export default PermissionService
|