Permission Presets

September 6, 2026 · View on GitHub

English | 中文

The permission-preset layer of dsh-permission-presets (ctx.permissionPresets, PermissionPresetService) bundles the two independent enforcement knobs — sandbox mode (sandbox/mode) and approval policy (approval/policy) — into named presets a client offers as one Permissions selector. It is one optional capability, not part of the agent-loop spine, and it owns no enforcement: execution, prompt narration, and replay keep reading their knob folds, and a preset switch only records intent and writes through each knob's canonical setter. The package README owns composition status and limitations; the sandbox switching design owns the rationale.

Source: packages/interaction/permission-presets/src/index.ts

The preset table

A preset is a table key mapping to one sandbox/approval bundle plus optional client presentation; the default table ships workspace-write (workspace-write + ask) and danger-full-access (danger-full-access + never).

/** One preset's sandbox/approval bundle and optional client presentation. */
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
}
/** The {@link PermissionPresetService} config: preset table and composition default. */
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
}

The service requires a confining ctx.shell executor and ctx.approval, and misconfiguration fails at plugin load: a table entry named custom throws (the name is reserved for the derived not-a-preset state), and composing over a bash executor that does not confine (no sandboxMode capability fact) throws, because presets bundle a sandbox mode.

Current preset and the derived custom

current(session) derives the effective preset from the optionally registered permissions projection. The unit folds the session's sandbox mode, approval policy, and recorded selection; values absent within that state fall back to the executor's configured mode and the approval service config, then ask. A missing registry or projection key fails explicitly. The service prefers a still-matching selection, then the first matching table entry in declaration order, and otherwise returns CUSTOM_PRESET ('custom'). custom is derived-only: clients may display it as the current value, but it is never a switch target or an event payload.

names lists the switchable presets in table declaration order; optionOf(name) builds the option a client renders for a table key (label falls back to the key) or for custom, and throws for any other name.

/** The select-option shape a presentation layer advertises for one preset (or for the derived `custom` state). */
interface PresetOption {
  /** Stable option value: the table key, or `custom`. */
  value: string
  /** The display label. */
  name: string
  /** One user-facing sentence on what the value means; omitted when not configured. */
  description?: string
}

Switching and the permission/preset event

set(session, name) resolves the preset (unknown names throw), appends a log-only permission/preset event unless name is already the effective preset, then writes each knob through its own setter — setSandboxMode from dsh-sandbox-policy and setApprovalPolicy from dsh-user-approval — only when that knob's effective value changes. The selection event precedes the knob events in the same turn, and re-selecting the effective preset appends nothing.

permission/preset is durable, log-only user intent: it stays out of the model transcript (the knob events own the model-visible consequences through their consumers), and it exists so current() can preserve WHICH preset the user chose when two presets share a bundle. The permissions projection folds that selection with both knob events and retains the session/end-seed boundary used to distinguish a restored empty seed from a fresh session; replay needs no catch-up state or raw-log rescan. The complete event declaration is in the persistence log event catalog; the method signatures are in the generated service catalog.

Cordis API

Generated from source by scripts/gen-cordis-catalog.ts (verified fresh by pnpm run verify-cordis-catalog in doc-sync; regenerate with pnpm run gen-cordis-catalog) — the language sides differ only in locale-specific paired document paths. Signature blocks use a ts cordis-catalog fence and keep the original source JSDoc; dispatch modes are defined in the primer, and the framework-inherited ctx API lives in cordis-api/inherited.md.

ctx.permissionPresetsPermissionPresetService

Owns the deployment's permission presets and their write path. Requires a confining ctx.shell executor and ctx.approval; unmatched knob values are reported as CUSTOM_PRESET, not an error.

/**
 * 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 session - the session whose knob state is read.
 * @returns the effective preset name, or `custom` when nothing matches.
 */
current(session: Session): string

/**
 * 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

/**
 * 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

/**
 * 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

/**
 * 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

Types: Session

Source: packages/interaction/permission-presets/src/index.ts