2026-07-12 21:03:41 +08:00
/**
2026-07-14 12:34:14 +08:00
* 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
2026-07-28 22:23:39 +08:00
* 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.
2026-07-12 21:03:41 +08:00
*
* @module dsh-permission
*/
import { Context , Service } from 'cordis'
import z from 'schemastery'
2026-07-28 22:23:39 +08:00
import { z as zod } from 'zod'
2026-07-12 21:03:41 +08:00
import type { Session , SessionEvent } from '@deepseek-ai/dsh-session'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
2026-07-14 20:05:57 +08:00
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'
2026-07-12 21:03:41 +08:00
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
import { APPROVAL_POLICIES , effectiveApprovalPolicy , setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
2026-07-28 22:23:39 +08:00
// 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'
2026-07-12 21:03:41 +08:00
declare module 'cordis' {
interface Context {
permission : PermissionService
}
}
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/**
2026-07-14 12:34:14 +08:00
* 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.
2026-07-12 21:03:41 +08:00
*/
'permission/preset' : { preset : string }
}
}
2026-07-14 12:34:14 +08:00
/** One preset's sandbox/approval bundle and optional client presentation. */
2026-07-12 21:03:41 +08:00
export interface PresetSpec {
2026-07-14 20:05:57 +08:00
/** The `sandbox/mode` value the preset writes through. */
2026-07-12 21:03:41 +08:00
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
}
/**
2026-07-14 12:34:14 +08:00
* 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.
2026-07-12 21:03:41 +08:00
*/
export const CUSTOM_PRESET = 'custom'
/**
2026-07-14 12:34:14 +08:00
* 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.
2026-07-12 21:03:41 +08:00
*/
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
}
2026-07-28 22:23:39 +08:00
/**
* 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
}
2026-07-12 21:03:41 +08:00
/** The {@link PermissionService} config: the deployment's preset table. */
export interface Config {
/**
2026-07-14 01:09:44 +08:00
* 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.
2026-07-12 21:03:41 +08:00
*/
presets? : Record < string , PresetSpec >
}
/**
2026-07-14 12:34:14 +08:00
* 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.
2026-07-12 21:03:41 +08:00
*/
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 ( {
2026-07-14 01:09:44 +08:00
'workspace-write' : {
2026-07-12 21:03:41 +08:00
sandbox : 'workspace-write' , approval : 'ask' ,
2026-07-14 16:21:41 +08:00
name : 'workspace-write' , description : 'Write inside the workspace and permitted temporary directories; wider retries require approval.' ,
2026-07-12 21:03:41 +08:00
} ,
2026-07-14 01:09:44 +08:00
'danger-full-access' : {
2026-07-12 21:03:41 +08:00
sandbox : 'danger-full-access' , approval : 'never' ,
2026-07-14 12:34:14 +08:00
name : 'danger-full-access' , description : 'Full file access without approval prompts.' ,
2026-07-12 21:03:41 +08:00
} ,
} ) ,
} )
static inject = [ 'bash' , 'approval' ]
private readonly presets : Record < string , PresetSpec >
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' )
}
2026-07-28 22:23:39 +08:00
// 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>' } ,
handler : ( { agent , rawInput } ) = > {
const name = rawInput . trim ( )
if ( name === '' ) {
return { kind : 'success' , text : ` Current permission preset: ${ this . current ( agent . session . events ) } . Available: ${ this . names . join ( ', ' ) } . ` }
}
if ( ! this . names . includes ( name ) ) {
return { kind : 'error' , text : ` unknown permission preset " ${ name } " (available: ${ this . names . join ( ', ' ) } ) ` }
}
this . set ( agent . session , name )
return { kind : 'success' , text : ` Permission preset: ${ name } . ` }
} ,
} )
} )
2026-07-12 21:03:41 +08:00
}
/**
* 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 )
}
/**
2026-07-14 12:34:14 +08:00
* 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.
2026-07-12 21:03:41 +08:00
* @param events - the session's events in log order.
* @returns the effective preset name, or `custom` when nothing matches.
*/
current ( events : readonly SessionEvent [ ] ) : string {
2026-07-28 22:23:39 +08:00
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'
2026-07-12 21:03:41 +08:00
const matches = ( spec : PresetSpec ) : boolean = > spec . sandbox === sandbox && spec . approval === approval
2026-07-28 22:23:39 +08:00
if ( state . preset !== null ) {
const spec = this . presets [ state . preset ]
if ( spec !== undefined && matches ( spec ) ) return state . preset
2026-07-12 21:03:41 +08:00
}
for ( const [ name , spec ] of Object . entries ( this . presets ) ) {
if ( matches ( spec ) ) return name
}
return CUSTOM_PRESET
}
2026-07-28 22:23:39 +08:00
/**
* 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 ,
}
}
2026-07-12 21:03:41 +08:00
/**
2026-07-14 12:34:14 +08:00
* Resolve a preset's knob bundle.
2026-07-12 21:03:41 +08:00
* @param name - the preset name to resolve.
2026-07-14 12:34:14 +08:00
* @returns the configured bundle.
* @throws when `name` is not in the table.
2026-07-12 21:03:41 +08:00
*/
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
}
/**
2026-07-14 12:34:14 +08:00
* Build the client option for a table entry or {@link CUSTOM_PRESET}. A
* missing label falls back to the table key.
2026-07-12 21:03:41 +08:00
* @param name - a table key, or `custom`.
2026-07-14 12:34:14 +08:00
* @returns the option a client renders.
* @throws when `name` is neither a table key nor `custom`.
2026-07-12 21:03:41 +08:00
*/
optionOf ( name : string ) : PresetOption {
if ( name === CUSTOM_PRESET ) {
2026-07-14 12:34:14 +08:00
return { value : CUSTOM_PRESET , name : 'Custom' , description : 'Current sandbox and approval settings do not match a preset.' }
2026-07-12 21:03:41 +08:00
}
const spec = this . resolve ( name )
return { value : name , name : spec.name ? ? name , . . . spec . description !== undefined ? { description : spec.description } : { } }
}
/**
2026-07-14 12:34:14 +08:00
* Record a changed preset, then update each changed knob through its own
* setter. Selecting the effective preset again appends nothing.
2026-07-12 21:03:41 +08:00
* @param session - the session the switch belongs to.
2026-07-14 12:34:14 +08:00
* @param name - the preset to switch to; unknown names throw.
2026-07-12 21:03:41 +08:00
*/
set ( session : Session , name : string ) : 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' ) ) {
setApprovalPolicy ( session , spec . approval )
}
}
}
export default PermissionService