feat(permission): add new-session default setting

This commit is contained in:
Yichen Jiang
2026-07-31 12:48:19 +08:00
parent 4a061f33d0
commit ee74b5b07a
51 changed files with 1452 additions and 226 deletions

View File

@@ -21,6 +21,7 @@ import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-a
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'
@@ -68,6 +69,9 @@ export interface PresetSpec {
*/
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.
@@ -126,7 +130,13 @@ function foldKnobs(events: readonly SessionEvent[]): KnobState {
return state
}
/** The {@link PermissionService} config: the deployment's preset table. */
/** 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`
@@ -134,6 +144,11 @@ export interface Config {
* 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
}
/**
@@ -159,11 +174,13 @@ export class PermissionService extends Service {
name: 'danger-full-access', description: 'Full file access without approval prompts.',
},
}),
defaultPreset: z.string(),
})
static inject = ['bash', 'approval']
private readonly presets: Record<string, PresetSpec>
private defaultSettings: () => PermissionSettings
constructor(ctx: Context, config: Config) {
super(ctx, 'permission')
@@ -175,6 +192,34 @@ export class PermissionService extends Service {
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)
})
// The permissions projection unit: fold the three whole-value knob
// events; view derives the select over the composition defaults this
@@ -237,6 +282,15 @@ export class PermissionService extends Service {
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
@@ -328,6 +382,44 @@ export class PermissionService extends Service {
setApprovalPolicy(session, 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