Browse Source

feat(permission): permissions projection unit and /permission command

The read side becomes the 'permissions' session projection: src/types.ts is
the key declaration's one home (PermissionSelect = whole select: table
options in declaration order plus a current-only 'custom'), served through
./types and the ./client re-export. The unit folds the three whole-value
knob events (permission/preset, sandbox/mode, approval/policy) into a plain
KnobState and views the select over the composition defaults the service
already owns; current() shares the same derive step, so the fold exists
once. The write side becomes the /permission command (the /plan
registration shape): bare invocation reports the current preset and the
table, a preset argument switches through set() immediately — no turn
anchoring (knob events need no enclosure), no dedicated RPC. Both children
activate only when their registry is composed.
imccyu 2 months ago
parent
commit
451b7b0446

+ 2 - 1
packages/ui/permission/README.md

@@ -8,6 +8,8 @@ User-facing permission presets through `ctx.permission` ([`PermissionService`](s
 
 The service requires a confining `ctx.bash` executor and `ctx.approval`. A table entry named `custom` throws at load; composition defaults outside the table instead make a zero-event session derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
 
+Two optional children ship the product surfaces over the same service: a `permissions` session-projection unit (`src/types.ts` declares the key; the unit folds the three whole-value knob events and views the select — table options plus a current-only `custom` — over the composition defaults) and the `/permission` command (bare invocation reports the current preset and the table; a preset argument switches through `set`). Each child activates only when its registry (`ctx.sessionProjections` / `ctx.commands`) is composed.
+
 ## Model Experience
 
 Indirectly, through `dsh-user-approval` and `dsh-tool-bash`, which render the approval-policy prompt, switch notice, and sandboxed tool outcomes selected by this service's knob events; `permission/preset` itself is log-only.
@@ -18,7 +20,6 @@ No direct invalidation; the named consumer owns any request-prefix changes.
 
 ## Known Limitations and Deferred Work
 
-- **No shipped composition currently mounts the service** — the ACP bridge was its only selector before [ACP became automation-only](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md); the preset table is kept for the interactive front door that next exposes a runtime policy switch.
 - **Only two mechanism knobs are bundled** — presets select sandbox mode and approval policy; an agent/profile choice is not part of `PresetSpec` yet.
 - **`custom` is derived-only** — callers can switch away from an unmatched knob combination but cannot target or persist a named custom preset through this service.
 - **The preset table is process-level** — configuration is fixed for the plugin lifetime; changing available presets requires reloading the plugin.

+ 15 - 1
packages/ui/permission/package.json

@@ -15,12 +15,21 @@
       "types": "./lib/types/invariant.d.ts",
       "default": "./lib/invariant.js"
     },
+    "./types": {
+      "types": "./lib/types/types.d.ts",
+      "default": "./lib/types/types.js"
+    },
+    "./client": {
+      "types": "./lib/types/client.d.ts",
+      "default": "./lib/types/client.js"
+    },
     "./src/*": "./src/*",
     "./package.json": "./package.json"
   },
   "files": [
     "lib/index.js",
     "lib/invariant.js",
+    "lib/types/**/*.js",
     "lib/types/**/*.d.ts",
     "lib/types/**/*.d.ts.map",
     "src"
@@ -28,22 +37,27 @@
   "license": "BSD-3-Clause",
   "peerDependencies": {
     "@deepseek-ai/dsh-bash": "^0.0.1",
+    "@deepseek-ai/dsh-commands": "^0.0.1",
     "@deepseek-ai/dsh-invariants": "^0.0.1",
     "@deepseek-ai/dsh-sandbox": "^0.0.1",
     "@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
     "@deepseek-ai/dsh-session": "^0.0.1",
+    "@deepseek-ai/dsh-session-projection": "^0.0.1",
     "@deepseek-ai/dsh-user-approval": "^0.0.1",
     "cordis": "^4.0.0-rc.7"
   },
   "dependencies": {
-    "schemastery": "^3.18.0"
+    "schemastery": "^3.18.0",
+    "zod": "^4.4.3"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-bash": "workspace:^",
+    "@deepseek-ai/dsh-commands": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/dsh-sandbox": "workspace:^",
     "@deepseek-ai/dsh-sandbox-policy": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-session-projection": "workspace:^",
     "@deepseek-ai/dsh-user-approval": "workspace:^",
     "cordis": "^4.0.0-rc.7"
   }

+ 10 - 0
packages/ui/permission/src/client.ts

@@ -0,0 +1,10 @@
+/**
+ * Client-namespace projection of the permission domain: a pure re-export of
+ * the package's types outlet. Client code imports ONLY the client namespace
+ * (repo discipline), so `./client` projects the same single-source content
+ * `./types` serves to host consumers — zero duplication.
+ *
+ * @module @deepseek-ai/dsh-permission/client
+ */
+
+export type * from './types.ts'

+ 134 - 17
packages/ui/permission/src/index.ts

@@ -3,13 +3,16 @@
  * 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.
+ * 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'
@@ -18,6 +21,16 @@ 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'
+// 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 {
@@ -49,16 +62,6 @@ export interface PresetSpec {
   description?: string
 }
 
-/** The select-option shape a presentation layer advertises for one preset (or for the derived `custom` state). */
-export 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. */
-  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.
@@ -79,6 +82,50 @@ export function effectivePermissionPreset(events: readonly SessionEvent[]): stri
   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
+}
+
 /** The {@link PermissionService} config: the deployment's preset table. */
 export interface Config {
   /**
@@ -128,6 +175,55 @@ 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')
     }
+
+    // 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}.` }
+        },
+      })
+    })
   }
 
   /**
@@ -146,13 +242,17 @@ export class PermissionService extends Service {
    * @returns the effective preset name, or `custom` when nothing matches.
    */
   current(events: readonly SessionEvent[]): string {
-    const sandbox = effectiveSandboxMode(events) ?? this.ctx.bash.sandboxMode
-    const approval = effectiveApprovalPolicy(events) ?? this.ctx.approval.config.policy ?? 'ask'
+    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
-    const folded = effectivePermissionPreset(events)
-    if (folded !== undefined) {
-      const spec = this.presets[folded]
-      if (spec !== undefined && matches(spec)) return folded
+    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
@@ -160,6 +260,23 @@ export class PermissionService extends Service {
     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.

+ 44 - 0
packages/ui/permission/src/types.ts

@@ -0,0 +1,44 @@
+/**
+ * Pure types of the permission domain: the ONE home of the `permissions`
+ * projection-key declaration plus its payload types, free of this package's
+ * host-side value imports (cordis, schemastery). Two namespace projections
+ * serve it — the package root re-export for host consumers, `./client` (the
+ * browser half-entry's re-export) for client aggregates — with zero content
+ * duplication.
+ *
+ * @module @deepseek-ai/dsh-permission/types
+ */
+
+/** The select-option shape a presentation layer advertises for one preset (or for the derived `custom` state). */
+export 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
+}
+
+/**
+ * Whole `permissions` projection value: every switchable preset in table
+ * order (plus the derived current-only `custom` when the knobs match no
+ * entry) and the effective current value.
+ */
+export interface PermissionSelect {
+  /** Switchable presets, plus `custom` appended exactly while it is current. */
+  options: PresetOption[]
+  /** The effective current value: a preset table key, or `custom`. */
+  currentValue: string
+}
+
+declare module '@deepseek-ai/dsh-session-projection/types' {
+  interface SessionProjectionMap {
+    /**
+     * The session's permission select, folded from the three whole-value
+     * knob events (`permission/preset`, `sandbox/mode`, `approval/policy`)
+     * over the composition defaults. Key absence means no permission service
+     * is composed — clients hide the control.
+     */
+    permissions: PermissionSelect
+  }
+}

+ 116 - 0
packages/ui/permission/tests/projection.spec.ts

@@ -0,0 +1,116 @@
+/**
+ * The `permissions` projection unit and the `/permission` command: mounting
+ * the permission service beside the projection registry serves the whole
+ * select (table options + effective current value, `custom` appended exactly
+ * while derived) folded from the three knob events over the composition
+ * defaults; the command child registers `/permission` whose handler switches
+ * through `permission.set` (bare invocation reports, unknown names error);
+ * compositions without either registry are unaffected; unmounting the
+ * service removes the key (HMR safety).
+ */
+
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import type { Session } from '@deepseek-ai/dsh-session'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import { createScope } from '@deepseek-ai/dsh-scope'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import CommandService from '@deepseek-ai/dsh-commands'
+import PermissionService from '@deepseek-ai/dsh-permission'
+import type { Config } from '@deepseek-ai/dsh-permission'
+
+async function harness(options: { withPermission?: boolean; config?: Config } = {}): Promise<{ ctx: Context; session: Session }> {
+  const ctx = new Context()
+  await ctx.plugin(SessionStore)
+  await ctx.plugin(SessionProjectionRegistry)
+  await ctx.plugin(CommandService)
+  ctx.provide('bash', {
+    sandboxMode: 'workspace-write',
+    resolve() { throw new Error('permission tests do not execute bash') },
+    run() { throw new Error('permission tests do not execute bash') },
+    start() { throw new Error('permission tests do not execute bash') },
+  })
+  ctx.provide('approval', { config: { policy: 'ask' } })
+  if (options.withPermission !== false) await ctx.plugin(PermissionService, options.config ?? {})
+  return { ctx, session: ctx.sessions.create(SessionId('perm-projected')) }
+}
+
+/** Mint a scoped agent over a live session (the command executor's addressing shape). */
+async function agentFor(ctx: Context, session: Session): Promise<Agent> {
+  const agent = { id: session.id, session } as Agent
+  await ctx.plugin(Object.assign((inner: Context) => { createScope(inner, agent) }, { inject: ['commands'] }))
+  return agent
+}
+
+describe('permissions projection unit', () => {
+  it('serves the composition-default select at zero events', async () => {
+    const { ctx, session } = await harness()
+    const value = ctx.sessionProjections.snapshot(session).values.permissions
+    expect(value).toMatchObject({ currentValue: 'workspace-write' })
+    expect(value?.options.map(option => option.value)).toEqual(['workspace-write', 'danger-full-access'])
+  })
+
+  it('folds the knob events and notifies the change feed per knob append', async () => {
+    const { ctx, session } = await harness()
+    const changes: { key: string; value: unknown; seq: number }[] = []
+    ctx.sessionProjections.onChanged((_session, key, value, seq) => {
+      changes.push({ key, value, seq })
+    })
+    ctx.permission.set(session, 'danger-full-access')
+    // set() appends preset + sandbox/mode + approval/policy: three knob transitions.
+    expect(changes).toHaveLength(3)
+    expect(changes.at(-1)).toMatchObject({ key: 'permissions', value: { currentValue: 'danger-full-access' } })
+    // Unrelated event: same-reference apply, no notification.
+    session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+    expect(changes).toHaveLength(3)
+  })
+
+  it('appends custom as a current-only option when the knobs match no preset', async () => {
+    const { ctx, session } = await harness()
+    session.append('sandbox/mode', { mode: 'read-only' })
+    const value = ctx.sessionProjections.snapshot(session).values.permissions
+    expect(value?.currentValue).toBe('custom')
+    expect(value?.options.at(-1)).toMatchObject({ value: 'custom', name: 'Custom' })
+  })
+
+  it('has no permissions key without the service, and drops it on unload (HMR safety)', async () => {
+    const { ctx, session } = await harness({ withPermission: false })
+    expect('permissions' in ctx.sessionProjections.snapshot(session).values).toBe(false)
+    const fiber = await ctx.plugin(PermissionService, {})
+    expect(ctx.sessionProjections.snapshot(session).values.permissions).toMatchObject({ currentValue: 'workspace-write' })
+    await fiber.dispose()
+    expect('permissions' in ctx.sessionProjections.snapshot(session).values).toBe(false)
+  })
+})
+
+describe('/permission command', () => {
+  it('switches through permission.set and logs the lifecycle pair', async () => {
+    const { ctx, session } = await harness()
+    const agent = await agentFor(ctx, session)
+    const execution = await ctx.commands.execute(agent, '/permission danger-full-access', new AbortController().signal)
+    expect(execution?.result).toEqual({ kind: 'success', text: 'Permission preset: danger-full-access.' })
+    expect(ctx.permission.current(session.events)).toBe('danger-full-access')
+    const run = session.events.find(event => event.type === 'command/run')
+    expect(run?.data).toMatchObject({ name: 'permission', args: ' danger-full-access' })
+  })
+
+  it('reports the current preset and the table on bare invocation', async () => {
+    const { ctx, session } = await harness()
+    const agent = await agentFor(ctx, session)
+    const execution = await ctx.commands.execute(agent, '/permission', new AbortController().signal)
+    expect(execution?.result).toEqual({
+      kind: 'success',
+      text: 'Current permission preset: workspace-write. Available: workspace-write, danger-full-access.',
+    })
+    expect(session.events.filter(event => event.type === 'permission/preset')).toHaveLength(0)
+  })
+
+  it('rejects an unknown preset without touching the log', async () => {
+    const { ctx, session } = await harness()
+    const agent = await agentFor(ctx, session)
+    const execution = await ctx.commands.execute(agent, '/permission yolo', new AbortController().signal)
+    expect(execution?.result).toMatchObject({ kind: 'error' })
+    expect(session.events.filter(event => event.type !== 'command/run' && event.type !== 'command/done')).toHaveLength(0)
+  })
+})

+ 6 - 0
packages/ui/permission/tsconfig.json

@@ -34,6 +34,12 @@
     },
     {
       "path": "../../support/invariants"
+    },
+    {
+      "path": "../../session-projection/session-projection"
+    },
+    {
+      "path": "../commands"
     }
   ]
 }

+ 9 - 0
pnpm-lock.yaml

@@ -4569,10 +4569,16 @@ importers:
       schemastery:
         specifier: ^3.18.0
         version: 3.18.0
+      zod:
+        specifier: ^4.4.3
+        version: 4.4.3
     devDependencies:
       '@deepseek-ai/dsh-bash':
         specifier: workspace:^
         version: link:../../bash/bash
+      '@deepseek-ai/dsh-commands':
+        specifier: workspace:^
+        version: link:../commands
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
         version: link:../../support/invariants
@@ -4585,6 +4591,9 @@ importers:
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
+      '@deepseek-ai/dsh-session-projection':
+        specifier: workspace:^
+        version: link:../../session-projection/session-projection
       '@deepseek-ai/dsh-user-approval':
         specifier: workspace:^
         version: link:../user-approval