feat(permission): user-facing permission presets — one Permissions select over the two knobs
A preset names a bundle of the two mechanism knobs — request = workspace-write + ask, yolo = danger-full-access + never — so the editor shows ONE 'Permissions' select where the sandbox-mode and approval-policy tiers stay orthogonal capabilities (the Codex /approvals shape: presets over two dials). ctx.permission (dsh-permission) owns the config-defined table, validates the default preset's bundle against the composed knob defaults at load (fails loud), and writes a switch THROUGH: one log-only permission/preset event (the audit fact reverse-mapping cannot recover — the planned 'agent' preset shares request's knob values and differs only in composed policy) plus each knob event via its own setter, deduped — a net-zero switch appends nothing. Every knob consumer keeps reading its own fold, untouched. The current preset DERIVES from the effective knob values — the fold breaks bundle ties, a knob state outside the table is the reserved 'custom' value (a state, not an error: shown while it holds, switchable FROM, never a target), and defaultPreset disappears (zero-event state reverse-maps from the composition defaults). The ACP bridge drops the two per-knob selects for the one preset select (advertised only when ctx.permission is composed); pending/anchor/no-op semantics carry over unchanged, with the no-op echo acknowledged before vocabulary validation so a client re-pushing a derived 'custom' current never errors. The sandbox variant example composes the service with a workspace-write default; the permission-switching, escalation-approved and escalation-rejected scenarios are re-recorded under it (escalations now target an outside-workspace /tmp path under danger-full-access, self-cleaning) and config-options is re-authored on the single-select wire.
This commit is contained in:
@@ -6,6 +6,7 @@ Integrations that expose the agent to an external editor or client. These are **
|
||||
|---|---|---|
|
||||
| `acp/` | Agent Client Protocol bridge: serves the agent to an ACP editor (Zed) over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) |
|
||||
| `user-approval/` | One-shot user-approval mechanism, closed outcome vocabulary, audit events, and per-session approval policy | `ctx.approval` |
|
||||
| `permission/` | User-facing permission presets (request/yolo): one product-level select bundling the sandbox-mode and approval-policy knobs, written through to their session events | `ctx.permission` |
|
||||
| `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` |
|
||||
| `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) |
|
||||
| `stdio-agent/` | Terminal stdio chat APP: the agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) |
|
||||
|
||||
@@ -28,36 +28,38 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-approval": "^0.0.1",
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-permission": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-approval": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-permission": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
|
||||
@@ -74,10 +74,8 @@ import { assertNever, CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-bash'
|
||||
import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
|
||||
// Side-effect type import: resolves `ctx.get('permission')` to the service.
|
||||
import type {} from '@deepseek-ai/dsh-permission'
|
||||
import type { SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } from '@deepseek-ai/dsh-tools'
|
||||
// Side-effect type import: declaration-merges `ctx.sessionPersistence` onto
|
||||
@@ -326,7 +324,7 @@ interface SessionRecord {
|
||||
* overlay it truthfully, and a restart before the next turn reverts it —
|
||||
* which `session/load` then reports honestly from the log's fold.
|
||||
*/
|
||||
pendingSwitches: { sandboxMode?: SandboxMode; approvalPolicy?: ApprovalPolicy }
|
||||
pendingSwitches: { preset?: string }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -545,46 +543,37 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// --- The ACP Agent method surface -----------------------------------------
|
||||
|
||||
/**
|
||||
* The session config options this composition can honor, with current
|
||||
* values folded from the AGENT'S OWN session log (`effectiveSandboxMode` /
|
||||
* `effectiveApprovalPolicy` — the log is the per-session store, so a
|
||||
* `session/load` reports a resumed session's overrides with no catch-up
|
||||
* machinery), overlaid with the record's not-yet-anchored pending switches
|
||||
* (see {@link SessionRecord.pendingSwitches}). Capability-gated like every
|
||||
* advertised lever: the sandbox option exists only when the mounted
|
||||
* executor confines (`ctx.get('bash')?.sandboxMode` defined), the approval
|
||||
* option only when the approval seam is composed — both read
|
||||
* The session config options this composition can honor: ONE `Mode`
|
||||
* select over the composed preset table (`ctx.permission` — the product
|
||||
* layer bundling the sandbox-mode and approval-policy knobs), its current
|
||||
* value folded from the AGENT'S OWN session log (the log is the
|
||||
* per-session store, so a `session/load` reports a resumed session's
|
||||
* preset with no catch-up machinery), overlaid with the record's
|
||||
* not-yet-anchored pending switch (see
|
||||
* {@link SessionRecord.pendingSwitches}). Capability-gated like every
|
||||
* advertised lever: no preset service composed, no options — read
|
||||
* opportunistically so this bridge keeps working in compositions without
|
||||
* them.
|
||||
* it.
|
||||
*/
|
||||
const configOptionsFor = (agent: Agent, pending: SessionRecord['pendingSwitches'] = {}): SessionConfigOption[] => {
|
||||
const options: SessionConfigOption[] = []
|
||||
const defaultMode = ctx.get('bash')?.sandboxMode
|
||||
if (defaultMode !== undefined) {
|
||||
options.push({
|
||||
id: 'sandbox-mode',
|
||||
name: 'Sandbox',
|
||||
description: 'The file sandbox mode bash commands in this session run under.',
|
||||
category: 'mode',
|
||||
type: 'select',
|
||||
currentValue: pending.sandboxMode ?? effectiveSandboxMode(agent.session.events) ?? defaultMode,
|
||||
options: SANDBOX_MODES.map(mode => ({ value: mode, name: mode })),
|
||||
})
|
||||
}
|
||||
const approval = ctx.get('approval')
|
||||
if (approval !== undefined) {
|
||||
options.push({
|
||||
id: 'approval-policy',
|
||||
name: 'Approvals',
|
||||
description: 'ask: permission prompts reach you; never: they are rejected automatically.',
|
||||
type: 'select',
|
||||
// `?? 'ask'` also shields against a provided stand-in whose config
|
||||
// never went through the plugin schema (tests do this).
|
||||
currentValue: pending.approvalPolicy ?? effectiveApprovalPolicy(agent.session.events) ?? approval.config.policy ?? 'ask',
|
||||
options: APPROVAL_POLICIES.map(policy => ({ value: policy, name: policy })),
|
||||
})
|
||||
}
|
||||
return options
|
||||
const presets = ctx.get('permission')
|
||||
if (presets === undefined) return []
|
||||
const currentValue = pending.preset ?? presets.current(agent.session.events)
|
||||
return [{
|
||||
id: 'permission',
|
||||
name: 'Permissions',
|
||||
description: 'The session permission preset: each choice bundles a sandbox mode and an approval policy.',
|
||||
category: 'mode',
|
||||
type: 'select',
|
||||
currentValue,
|
||||
options: [
|
||||
...presets.names.map((name: string) => presets.optionOf(name)),
|
||||
// The derived not-a-preset state: visible exactly while it IS the
|
||||
// current value (a knob state outside the table), switchable FROM,
|
||||
// never a target — set() below rejects it like any unknown name.
|
||||
...currentValue === 'custom' ? [presets.optionOf('custom')] : [],
|
||||
],
|
||||
}]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -614,15 +603,12 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
const flushPendingSwitches = (rec: SessionRecord): void => {
|
||||
const pending = rec.pendingSwitches
|
||||
rec.pendingSwitches = {}
|
||||
const events = rec.agent.session.events
|
||||
if (pending.sandboxMode !== undefined
|
||||
&& pending.sandboxMode !== (effectiveSandboxMode(events) ?? ctx.get('bash')?.sandboxMode)) {
|
||||
setSandboxMode(rec.agent.session, pending.sandboxMode)
|
||||
}
|
||||
if (pending.approvalPolicy !== undefined
|
||||
&& pending.approvalPolicy !== (effectiveApprovalPolicy(events) ?? ctx.get('approval')?.config.policy ?? 'ask')) {
|
||||
setApprovalPolicy(rec.agent.session, pending.approvalPolicy)
|
||||
}
|
||||
if (pending.preset === undefined) return
|
||||
const presets = ctx.get('permission')
|
||||
/* v8 ignore next -- a pending preset exists only if the service answered the
|
||||
switch; it cannot unmount between that and the next turn in any composition. */
|
||||
if (presets === undefined) return
|
||||
presets.set(rec.agent.session, pending.preset)
|
||||
}
|
||||
|
||||
// Idle-accepted switches anchor at the next turn's prompt-submit: the turn
|
||||
@@ -881,32 +867,22 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// advertised; an id this composition never advertised (or an unknown
|
||||
// one) rejects.
|
||||
switch (params.configId) {
|
||||
case 'sandbox-mode': {
|
||||
const defaultMode = ctx.get('bash')?.sandboxMode
|
||||
if (defaultMode === undefined || !SANDBOX_MODES.includes(params.value as SandboxMode)) {
|
||||
throw invalidParams(`unknown sandbox-mode value ${JSON.stringify(params.value)}`)
|
||||
case 'permission': {
|
||||
const presets = ctx.get('permission')
|
||||
if (presets === undefined) {
|
||||
throw invalidParams(`unknown permission value ${JSON.stringify(params.value)}`)
|
||||
}
|
||||
const value = params.value as SandboxMode
|
||||
// A no-op switch (the value the session already shows — pending,
|
||||
// else fold, else default) is acknowledged without recording
|
||||
// anything: clients that re-push current selections on session
|
||||
// start must not mint override events out of thin air.
|
||||
const current = rec.pendingSwitches.sandboxMode ?? effectiveSandboxMode(rec.agent.session.events) ?? defaultMode
|
||||
if (value === current) break
|
||||
if (isTurnOpen(rec.agent)) setSandboxMode(rec.agent.session, value)
|
||||
else rec.pendingSwitches.sandboxMode = value
|
||||
break
|
||||
}
|
||||
case 'approval-policy': {
|
||||
const approval = ctx.get('approval')
|
||||
if (approval === undefined || !APPROVAL_POLICIES.includes(params.value as ApprovalPolicy)) {
|
||||
throw invalidParams(`unknown approval-policy value ${JSON.stringify(params.value)}`)
|
||||
// else derived) is acknowledged FIRST and records nothing:
|
||||
// clients re-push current selections on session start, and the
|
||||
// derived 'custom' current is only ever valid as such an echo.
|
||||
const current = rec.pendingSwitches.preset ?? presets.current(rec.agent.session.events)
|
||||
if (params.value === current) break
|
||||
if (!presets.names.includes(params.value)) {
|
||||
throw invalidParams(`unknown permission value ${JSON.stringify(params.value)}`)
|
||||
}
|
||||
const value = params.value as ApprovalPolicy
|
||||
const current = rec.pendingSwitches.approvalPolicy ?? effectiveApprovalPolicy(rec.agent.session.events) ?? approval.config.policy ?? 'ask'
|
||||
if (value === current) break
|
||||
if (isTurnOpen(rec.agent)) setApprovalPolicy(rec.agent.session, value)
|
||||
else rec.pendingSwitches.approvalPolicy = value
|
||||
if (isTurnOpen(rec.agent)) presets.set(rec.agent.session, params.value)
|
||||
else rec.pendingSwitches.preset = params.value
|
||||
break
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* Session config options over the bridge: the two per-session knobs
|
||||
* (`sandbox-mode`, `approval-policy`) advertised from composition capability,
|
||||
* their current values folded from each session's own log, switching via
|
||||
* `session/set_config_option` (one log-only event per switch — the log is the
|
||||
* store), and a resumed session reporting its overrides back on
|
||||
* `session/load` with no catch-up machinery.
|
||||
* Session config options over the bridge: ONE user-facing `Permissions`
|
||||
* select (`ctx.permission`'s preset table — each choice bundles a sandbox
|
||||
* mode and an approval policy), its current value folded from each session's
|
||||
* own log, switching via `session/set_config_option` (the preset event plus
|
||||
* its knob write-throughs — the log is the store), and a resumed session
|
||||
* reporting its preset back on `session/load` with no catch-up machinery.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
@@ -14,50 +14,37 @@ import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import ApprovalService from '@deepseek-ai/dsh-user-approval'
|
||||
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import PermissionService from '@deepseek-ai/dsh-permission'
|
||||
import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
|
||||
|
||||
/**
|
||||
* The REAL local executor reporting a confining default — `sandboxMode` is
|
||||
* the documented capability override point (`dsh-bash-sandbox` overrides it
|
||||
* the same way), so the bridge sees exactly what a sandboxing composition
|
||||
* advertises without this suite dragging in a kernel sandbox stack.
|
||||
* advertises without this suite dragging in a kernel sandbox stack. It
|
||||
* reports `workspace-write`: the shipped `request` preset's bundle, which
|
||||
* the permission service validates the composition defaults against.
|
||||
*/
|
||||
class SandboxedLocalExecutor extends LocalBashExecutor {
|
||||
override get sandboxMode(): SandboxMode {
|
||||
return 'read-only'
|
||||
return 'workspace-write'
|
||||
}
|
||||
}
|
||||
|
||||
/** The exact option payloads the bridge advertises (pinned verbatim). */
|
||||
function sandboxOption(currentValue: SandboxMode): object {
|
||||
/** The exact option payload the bridge advertises (pinned verbatim). */
|
||||
function permissionOption(currentValue: string): object {
|
||||
return {
|
||||
id: 'sandbox-mode',
|
||||
name: 'Sandbox',
|
||||
description: 'The file sandbox mode bash commands in this session run under.',
|
||||
id: 'permission',
|
||||
name: 'Permissions',
|
||||
description: 'The session permission preset: each choice bundles a sandbox mode and an approval policy.',
|
||||
category: 'mode',
|
||||
type: 'select',
|
||||
currentValue,
|
||||
options: [
|
||||
{ value: 'read-only', name: 'read-only' },
|
||||
{ value: 'workspace-write', name: 'workspace-write' },
|
||||
{ value: 'danger-full-access', name: 'danger-full-access' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function approvalOption(currentValue: ApprovalPolicy): object {
|
||||
return {
|
||||
id: 'approval-policy',
|
||||
name: 'Approvals',
|
||||
description: 'ask: permission prompts reach you; never: they are rejected automatically.',
|
||||
type: 'select',
|
||||
currentValue,
|
||||
options: [
|
||||
{ value: 'ask', name: 'ask' },
|
||||
{ value: 'never', name: 'never' },
|
||||
{ value: 'request', name: 'Request', description: 'Write inside the workspace; anything wider asks for your approval.' },
|
||||
{ value: 'yolo', name: 'YOLO', description: 'Full file access, no approval prompts.' },
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -75,144 +62,113 @@ describe('acp bridge — session config options', () => {
|
||||
await rm(storageDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** A harness whose composition can honor both knobs (sandboxed executor + approval seam). */
|
||||
async function bothKnobs(options: { policy?: ApprovalPolicy; script?: NonNullable<Parameters<typeof makeBridgeHarness>[0]>['script'] } = {}): Promise<BridgeHarness> {
|
||||
/** A harness composing the full preset stack (confining executor + approval seam + permission presets). */
|
||||
async function presetStack(options: { script?: NonNullable<Parameters<typeof makeBridgeHarness>[0]>['script'] } = {}): Promise<BridgeHarness> {
|
||||
const harness = await makeBridgeHarness({ storageDir, ...options.script !== undefined ? { script: options.script } : {} })
|
||||
// The dev invariants police turn-enclosure: an idle switch that appended
|
||||
// outside a turn would throw right here in the suite, not in production.
|
||||
await harness.ctx.plugin(Invariants)
|
||||
await harness.ctx.plugin(SandboxedLocalExecutor, { timeoutMs: 10_000 })
|
||||
await harness.ctx.plugin(ApprovalService, options.policy !== undefined ? { policy: options.policy } : {})
|
||||
await harness.ctx.plugin(ApprovalService)
|
||||
await harness.ctx.plugin(PermissionService)
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
return harness
|
||||
}
|
||||
|
||||
it('advertises no configOptions in a composition with neither knob', async () => {
|
||||
it('advertises no configOptions without the permission service — even with both knobs composed', async () => {
|
||||
h = await makeBridgeHarness({ storageDir })
|
||||
await h.ctx.plugin(SandboxedLocalExecutor, { timeoutMs: 10_000 })
|
||||
await h.ctx.plugin(ApprovalService)
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.configOptions).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a non-confining executor advertises no sandbox option (nothing would honor it)', async () => {
|
||||
h = await makeBridgeHarness({ storageDir, withBash: true })
|
||||
await h.ctx.plugin(ApprovalService)
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
it('advertises the Permissions select with the default preset current', async () => {
|
||||
h = await presetStack()
|
||||
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.configOptions).toEqual([approvalOption('ask')])
|
||||
})
|
||||
|
||||
it('advertises both knobs with capability-derived currents (config default included)', async () => {
|
||||
h = await bothKnobs({ policy: 'never' })
|
||||
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.configOptions).toEqual([sandboxOption('read-only'), approvalOption('never')])
|
||||
expect(res.configOptions).toEqual([permissionOption('request')])
|
||||
})
|
||||
|
||||
it('an idle switch is pending (overlaid, not yet logged), then anchors INSIDE the next turn', async () => {
|
||||
h = await bothKnobs({ script: [textResponse('ok')] })
|
||||
h = await presetStack({ script: [textResponse('ok')] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
const afterSandbox = await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
|
||||
expect(afterSandbox.configOptions).toEqual([sandboxOption('workspace-write'), approvalOption('ask')])
|
||||
const afterApproval = await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'never' })
|
||||
expect(afterApproval.configOptions).toEqual([sandboxOption('workspace-write'), approvalOption('never')])
|
||||
const after = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'yolo' })
|
||||
expect(after.configOptions).toEqual([permissionOption('yolo')])
|
||||
|
||||
// Idle: nothing in the log yet — turn-enclosure forbids a bare append
|
||||
// (the dev invariants in this suite would throw), so the switch lives on
|
||||
// the record until a turn opens.
|
||||
// Idle: nothing in the log yet — turn-enclosure forbids a bare append.
|
||||
const session = h.ctx.agents.list()[0]?.session
|
||||
expect(session?.events.some(e => e.type === 'bash/sandbox-mode' || e.type === 'approval/policy')).toBe(false)
|
||||
expect(session?.events.some(e => e.type === 'permission/preset' || e.type === 'bash/sandbox-mode' || e.type === 'approval/policy')).toBe(false)
|
||||
|
||||
// The next turn anchors both switches inside itself, one event per knob.
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
|
||||
const events = session?.events ?? []
|
||||
expect(events.filter(e => e.type === 'bash/sandbox-mode').map(e => e.data)).toEqual([{ mode: 'workspace-write' }])
|
||||
expect(events.filter(e => e.type === 'permission/preset').map(e => e.data)).toEqual([{ preset: 'yolo' }])
|
||||
expect(events.filter(e => e.type === 'bash/sandbox-mode').map(e => e.data)).toEqual([{ mode: 'danger-full-access' }])
|
||||
expect(events.filter(e => e.type === 'approval/policy').map(e => e.data)).toEqual([{ policy: 'never' }])
|
||||
const turnStart = events.findIndex(e => e.type === 'turn/start')
|
||||
const anchored = events.findIndex(e => e.type === 'bash/sandbox-mode')
|
||||
const anchored = events.findIndex(e => e.type === 'permission/preset')
|
||||
expect(turnStart).toBeGreaterThanOrEqual(0)
|
||||
expect(anchored).toBeGreaterThan(turnStart)
|
||||
})
|
||||
|
||||
it('an idle flip-flop anchors as ONE event (last write per knob wins)', async () => {
|
||||
h = await bothKnobs({ script: [textResponse('ok')] })
|
||||
it('an idle flip-flop anchors as ONE switch (last write wins)', async () => {
|
||||
h = await presetStack({ script: [textResponse('ok')] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'danger-full-access' })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'yolo' })
|
||||
const again = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'yolo' })
|
||||
expect(again.configOptions).toEqual([permissionOption('yolo')])
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
|
||||
const events = h.ctx.agents.list()[0]?.session.events ?? []
|
||||
expect(events.filter(e => e.type === 'bash/sandbox-mode').map(e => e.data)).toEqual([{ mode: 'danger-full-access' }])
|
||||
// Idle again AFTER a completed turn (the log now ends in turn/end): a new
|
||||
// switch pends rather than appending outside the closed turn.
|
||||
const again = await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'read-only' })
|
||||
expect(again.configOptions?.find(option => option.id === 'sandbox-mode')).toMatchObject({ currentValue: 'read-only' })
|
||||
expect(events.filter(e => e.type === 'bash/sandbox-mode')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('a no-op switch (the value already shown) records nothing and keeps a live pending', async () => {
|
||||
h = await bothKnobs({ script: [textResponse('ok')] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
// Re-pushing the composition default (what clients that echo current
|
||||
// selections on session start do) must not mint an override event.
|
||||
const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'ask' })
|
||||
expect(echo.configOptions?.find(option => option.id === 'approval-policy')).toMatchObject({ currentValue: 'ask' })
|
||||
// Re-sending a PENDING value keeps the pending switch alive (it is what
|
||||
// the session shows), rather than cancelling it.
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
|
||||
const repeat = await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
|
||||
expect(repeat.configOptions?.find(option => option.id === 'sandbox-mode')).toMatchObject({ currentValue: 'workspace-write' })
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
|
||||
const events = h.ctx.agents.list()[0]?.session.events ?? []
|
||||
expect(events.filter(e => e.type === 'approval/policy')).toHaveLength(0)
|
||||
expect(events.filter(e => e.type === 'bash/sandbox-mode').map(e => e.data)).toEqual([{ mode: 'workspace-write' }])
|
||||
expect(events.filter(e => e.type === 'permission/preset')).toHaveLength(1)
|
||||
// Between turns (a closed turn in the log) a switch still pends — the
|
||||
// enclosure fold walks past the turn/end — and anchors with the NEXT turn.
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'request' })
|
||||
expect(h.ctx.agents.list()[0]?.session.events.filter(e => e.type === 'permission/preset')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('a net-zero idle flip-flop anchors NOTHING (switches are recorded, select clicks are not)', async () => {
|
||||
h = await bothKnobs({ script: [textResponse('ok')] })
|
||||
h = await presetStack({ script: [textResponse('ok')] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
|
||||
const back = await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'read-only' })
|
||||
expect(back.configOptions?.find(option => option.id === 'sandbox-mode')).toMatchObject({ currentValue: 'read-only' })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'yolo' })
|
||||
const back = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'request' })
|
||||
expect(back.configOptions).toEqual([permissionOption('request')])
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
|
||||
const events = h.ctx.agents.list()[0]?.session.events ?? []
|
||||
expect(events.filter(e => e.type === 'bash/sandbox-mode')).toHaveLength(0)
|
||||
expect(events.some(e => e.type === 'permission/preset' || e.type === 'bash/sandbox-mode' || e.type === 'approval/policy')).toBe(false)
|
||||
})
|
||||
|
||||
it('a no-op switch (the value already shown) records nothing and keeps a live pending', async () => {
|
||||
h = await presetStack({ script: [textResponse('ok')] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'request' })
|
||||
expect(echo.configOptions).toEqual([permissionOption('request')])
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'yolo' })
|
||||
const repeat = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'yolo' })
|
||||
expect(repeat.configOptions).toEqual([permissionOption('yolo')])
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
|
||||
const events = h.ctx.agents.list()[0]?.session.events ?? []
|
||||
expect(events.filter(e => e.type === 'permission/preset').map(e => e.data)).toEqual([{ preset: 'yolo' }])
|
||||
})
|
||||
|
||||
it('a mid-turn switch anchors immediately (the open turn encloses it)', async () => {
|
||||
h = await bothKnobs({ script: ['hang'] })
|
||||
h = await presetStack({ script: ['hang'] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const hung = h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
// Give the loop a tick to open the turn (the turns.spec hang idiom).
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'never' })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'yolo' })
|
||||
const events = h.ctx.agents.list()[0]?.session.events ?? []
|
||||
const turnStart = events.findIndex(e => e.type === 'turn/start')
|
||||
const anchored = events.findIndex(e => e.type === 'bash/sandbox-mode')
|
||||
const anchored = events.findIndex(e => e.type === 'permission/preset')
|
||||
expect(turnStart).toBeGreaterThanOrEqual(0)
|
||||
expect(anchored).toBeGreaterThan(turnStart)
|
||||
expect(events.some(e => e.type === 'bash/sandbox-mode')).toBe(true)
|
||||
expect(events.some(e => e.type === 'approval/policy')).toBe(true)
|
||||
await h.client.cancel({ sessionId })
|
||||
await hung
|
||||
})
|
||||
|
||||
it('tolerates a provided approval stand-in whose config skipped the plugin schema', async () => {
|
||||
h = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] })
|
||||
h.ctx.provide('approval', { config: {} } as unknown as InstanceType<typeof ApprovalService>)
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.configOptions).toEqual([approvalOption('ask')])
|
||||
const sessionId = res.sessionId
|
||||
// The schema-less config also shields the no-op guard ('ask' by the ?? fallback)…
|
||||
const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'ask' })
|
||||
expect(echo.configOptions).toEqual([approvalOption('ask')])
|
||||
// …and the anchor-time comparison: a real switch under the stand-in still anchors.
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'never' })
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
|
||||
const events = h.ctx.agents.list()[0]?.session.events ?? []
|
||||
expect(events.filter(e => e.type === 'approval/policy').map(e => e.data)).toEqual([{ policy: 'never' }])
|
||||
})
|
||||
|
||||
it('rejects unknown ids, unadvertised ids, boolean values, and out-of-vocabulary values', async () => {
|
||||
h = await makeBridgeHarness({ storageDir })
|
||||
await h.ctx.plugin(ApprovalService)
|
||||
@@ -221,40 +177,72 @@ describe('acp bridge — session config options', () => {
|
||||
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'reasoning-effort', value: 'max' }))
|
||||
.rejects.toThrow(/unknown config option/)
|
||||
// sandbox-mode exists as a concept but THIS composition never advertised it.
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' }))
|
||||
.rejects.toThrow(/unknown sandbox-mode value/)
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', type: 'boolean', value: true }))
|
||||
// `permission` exists as a concept but THIS composition never advertised it.
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'yolo' }))
|
||||
.rejects.toThrow(/unknown permission value/)
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', type: 'boolean', value: true }))
|
||||
.rejects.toThrow(/select; boolean values are not accepted/)
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'always' }))
|
||||
.rejects.toThrow(/unknown approval-policy value/)
|
||||
})
|
||||
|
||||
it('rejects an out-of-vocabulary preset on an advertising composition', async () => {
|
||||
h = await presetStack()
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'plan' }))
|
||||
.rejects.toThrow(/unknown permission value/)
|
||||
})
|
||||
|
||||
it('a switch in one session never leaks into a concurrent one (state and pending both per-session)', async () => {
|
||||
h = await bothKnobs()
|
||||
h = await presetStack()
|
||||
const a = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const b = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'sandbox-mode', value: 'danger-full-access' })
|
||||
// B sees its own composition defaults, not A's pending switch...
|
||||
const bAfter = await h.client.setSessionConfigOption({ sessionId: b.sessionId, configId: 'approval-policy', value: 'never' })
|
||||
expect(bAfter.configOptions).toEqual([sandboxOption('read-only'), approvalOption('never')])
|
||||
await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'permission', value: 'yolo' })
|
||||
// B sees the composition default, not A's pending switch...
|
||||
const bAfter = await h.client.setSessionConfigOption({ sessionId: b.sessionId, configId: 'permission', value: 'request' })
|
||||
expect(bAfter.configOptions).toEqual([permissionOption('request')])
|
||||
// ...and A keeps its own state, untouched by B's.
|
||||
const aAfter = await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'sandbox-mode', value: 'danger-full-access' })
|
||||
expect(aAfter.configOptions).toEqual([sandboxOption('danger-full-access'), approvalOption('ask')])
|
||||
const aAfter = await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'permission', value: 'yolo' })
|
||||
expect(aAfter.configOptions).toEqual([permissionOption('yolo')])
|
||||
})
|
||||
|
||||
it('session/load reports a resumed session\'s overrides from its own log', async () => {
|
||||
h = await bothKnobs({ script: [textResponse('ok')] })
|
||||
it('a knob drifted outside the table derives a visible-but-untargetable custom current', async () => {
|
||||
h = await presetStack()
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'danger-full-access' })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'never' })
|
||||
// Drift a knob out from under the table (a plugin writing the knob
|
||||
// directly — the raw setters remain public mechanism), inside its own
|
||||
// turn: the dev invariants enforce turn-enclosure here too.
|
||||
const agent = h.ctx.agents.list()[0]
|
||||
if (agent === undefined) throw new Error('expected an agent')
|
||||
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
agent.session.append('bash/sandbox-mode', { mode: 'read-only' })
|
||||
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
// The echo of the derived current is a no-op, not an unknown-value error…
|
||||
const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'custom' })
|
||||
const option = echo.configOptions?.[0]
|
||||
expect(option).toMatchObject({ currentValue: 'custom' })
|
||||
if (option === undefined || !('options' in option)) throw new Error('expected a select option')
|
||||
expect(option.options.map(o => 'value' in o ? o.value : o)).toEqual(['request', 'yolo', 'custom'])
|
||||
// …while custom as a TARGET from a real preset stays rejected: switching
|
||||
// away is ordinary, and the custom entry disappears from the options.
|
||||
const away = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'yolo' })
|
||||
const afterOption = away.configOptions?.[0]
|
||||
expect(afterOption).toMatchObject({ currentValue: 'yolo' })
|
||||
if (afterOption === undefined || !('options' in afterOption)) throw new Error('expected a select option')
|
||||
expect(afterOption.options.map(o => 'value' in o ? o.value : o)).toEqual(['request', 'yolo'])
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'custom' }))
|
||||
.rejects.toThrow(/unknown permission value/)
|
||||
})
|
||||
|
||||
it('session/load reports a resumed session\'s preset from its own log', async () => {
|
||||
h = await presetStack({ script: [textResponse('ok')] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'yolo' })
|
||||
// One turn checkpoints the log (the switch events flush with it).
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'persist me' }] })
|
||||
await h.dispose()
|
||||
h = undefined
|
||||
|
||||
loader = await bothKnobs()
|
||||
loader = await presetStack()
|
||||
const res = await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.configOptions).toEqual([sandboxOption('danger-full-access'), approvalOption('never')])
|
||||
expect(res.configOptions).toEqual([permissionOption('yolo')])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -38,6 +38,9 @@
|
||||
{
|
||||
"path": "../user-approval"
|
||||
},
|
||||
{
|
||||
"path": "../permission"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
|
||||
7
packages/ui/permission/README.md
Normal file
7
packages/ui/permission/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# @deepseek-ai/dsh-permission
|
||||
|
||||
User-facing permission presets. Owns the `ctx.permission` service ([`PermissionService`](src/index.ts)): a config-defined preset table — by default `request` (`workspace-write` + `ask`) and `yolo` (`danger-full-access` + `never`) — where each name bundles the two mechanism knobs, `bash/sandbox-mode` and `approval/policy`. The product surface (the ACP bridge's single `Permissions` select) advertises `names` and calls `set()`; the mechanism tiers stay orthogonal capabilities that never learn the product vocabulary.
|
||||
|
||||
A switch WRITES THROUGH: `set(session, name)` appends one log-only `permission/preset` event when the name differs from the session's current preset (the audit fact reverse-mapping cannot recover — two presets may share knob values and differ only in composed policy, the planned `agent` preset being the standing example), then each knob event through its own THE-write-path setter, skipping values the session already effectively has — a net-zero switch appends nothing. The current preset DERIVES from the effective knob values (fold ?? composition default per knob): the last-chosen preset when its bundle still matches (presets may share bundles — the fold breaks the tie), else the first matching table entry, else the reserved `custom` — the honest not-a-preset state, shown as the current value only while it holds, switchable FROM and never a target. Every existing knob consumer (executor stamping, the approval gate, narrators, resume) keeps reading its own fold, untouched.
|
||||
|
||||
Composing it requires a confining `ctx.bash` executor and the `ctx.approval` seam; a table entry named `custom` throws at load (the name is reserved), while composition defaults outside the table are not an error — a zero-event session simply derives `custom`. See [the acp-agent example's sandbox variant](../../../examples/acp-agent/) for the composed leaf and [the sandbox RFC § Per-session modes](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md) for the switching design this layers over.
|
||||
41
packages/ui/permission/package.json
Normal file
41
packages/ui/permission/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-permission",
|
||||
"description": "User-facing permission presets (ctx.permission) for the DeepSeek Harness: one product-level Permissions select bundling the sandbox-mode and approval-policy knobs, written through to their own session events",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-approval": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
236
packages/ui/permission/src/index.ts
Normal file
236
packages/ui/permission/src/index.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* User-facing PERMISSION PRESETS: one product-level knob over the two
|
||||
* mechanism knobs. A preset names a bundle — its sandbox mode
|
||||
* (`bash/sandbox-mode`) and its approval policy (`approval/policy`) — so a
|
||||
* user picks `request` or `yolo` where the mechanism tiers stay orthogonal
|
||||
* capabilities. Switching a preset WRITES THROUGH: one `permission/preset` event
|
||||
* records the chosen bundle (the audit fact reverse-mapping cannot recover —
|
||||
* two presets may share knob values and differ only in composed policy, the
|
||||
* planned `agent` preset being the standing example), then each knob event
|
||||
* follows through its own THE-write-path setter, skipping values the session
|
||||
* already effectively has. Every existing consumer (executor stamping, the
|
||||
* approval gate, narrators, resume) keeps reading its own knob fold,
|
||||
* untouched.
|
||||
*
|
||||
* @module dsh-permission
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
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-bash'
|
||||
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
|
||||
import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
permission: PermissionService
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/**
|
||||
* The session's permission preset was switched — log-only (the
|
||||
* `bash/sandbox-mode` precedent): durable and replayable, never in the
|
||||
* model transcript. The LAST such event is the session's preset
|
||||
* ({@link effectivePermissionPreset}); the knob events the switch wrote
|
||||
* through follow it in the same turn, and they — not this record of the
|
||||
* user's choice — are what execution reads.
|
||||
*/
|
||||
'permission/preset': { preset: string }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One preset's knob bundle — the sandbox mode and approval policy a session
|
||||
* runs under while the preset is active — plus its presentation.
|
||||
*/
|
||||
export interface PresetSpec {
|
||||
/** The `bash/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 select-option shape a presentation layer advertises for one preset (or for the derived `custom` state). */
|
||||
export interface PresetOption {
|
||||
/** The machine value (`session/set_config_option` vocabulary): the table key, or `custom`. */
|
||||
value: string
|
||||
/** The display label. */
|
||||
name: string
|
||||
/** One user-facing sentence on what the value means. */
|
||||
description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The derived not-a-preset state: the session's effective knob values match
|
||||
* no table entry (composition defaults outside the table, or a knob moved
|
||||
* out from under the last-chosen preset). Never a switch target and never
|
||||
* an event payload — {@link PermissionService.current} derives it, and the
|
||||
* presentation layer shows it as a selectable-FROM-only current value.
|
||||
*/
|
||||
export const CUSTOM_PRESET = 'custom'
|
||||
|
||||
/**
|
||||
* The session's permission-preset override: the last `permission/preset` event in the
|
||||
* log, or undefined when the session never switched (callers apply the
|
||||
* plugin's configured default). The pure fold — resume needs no catch-up
|
||||
* machinery because replaying the log IS the state.
|
||||
* @param events - session events in log order (other event types are skipped).
|
||||
* @returns the preset of the last switch event, or undefined without one.
|
||||
*/
|
||||
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 {@link PermissionService} config: the deployment's preset table. */
|
||||
export interface Config {
|
||||
/**
|
||||
* The preset table: name → knob bundle. Defaults to `request`
|
||||
* (workspace-write + ask) and `yolo` (danger-full-access + never). The
|
||||
* name `custom` is reserved for the derived not-a-preset state.
|
||||
*/
|
||||
presets?: Record<string, PresetSpec>
|
||||
}
|
||||
|
||||
/**
|
||||
* The permission service (`ctx.permission`). Owns the deployment's preset
|
||||
* table and THE write path for preset switches; presentation layers (the ACP
|
||||
* bridge's single `Permissions` select) advertise {@link names} and call
|
||||
* {@link set}. Composing it REQUIRES both mechanism knobs — a confining
|
||||
* `ctx.bash` executor and the `ctx.approval` seam. A knob state matching no
|
||||
* table entry is not an error but the derived {@link CUSTOM_PRESET} state:
|
||||
* shown as the current value, never a switch target.
|
||||
*/
|
||||
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({
|
||||
request: {
|
||||
sandbox: 'workspace-write', approval: 'ask',
|
||||
name: 'Request', description: 'Write inside the workspace; anything wider asks for your approval.',
|
||||
},
|
||||
yolo: {
|
||||
sandbox: 'danger-full-access', approval: 'never',
|
||||
name: 'YOLO', description: 'Full file access, no approval prompts.',
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 a session is on right now, derived from the EFFECTIVE knob
|
||||
* values (fold ?? composition default per knob): the last-chosen preset
|
||||
* when its bundle still matches (presets may share bundles — the fold
|
||||
* breaks the tie), else the first table entry that matches, else
|
||||
* {@link CUSTOM_PRESET} — a mismatch is a state, not an error.
|
||||
* @param events - the session's events in log order.
|
||||
* @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'
|
||||
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
|
||||
}
|
||||
for (const [name, spec] of Object.entries(this.presets)) {
|
||||
if (matches(spec)) return name
|
||||
}
|
||||
return CUSTOM_PRESET
|
||||
}
|
||||
|
||||
/**
|
||||
* A preset's knob bundle, for consumers presenting or validating one.
|
||||
* @param name - the preset name to resolve.
|
||||
* @returns the bundle; throws on a name outside the table (fails loud —
|
||||
* an unvalidated caller handed the service an unknown preset).
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* The select-option presentation of one advertisable value: a table entry
|
||||
* (label/description from its spec, the raw key standing in for a missing
|
||||
* label) or the derived {@link CUSTOM_PRESET} with its fixed presentation.
|
||||
* @param name - a table key, or `custom`.
|
||||
* @returns the option a client renders; throws on any other name.
|
||||
*/
|
||||
optionOf(name: string): PresetOption {
|
||||
if (name === CUSTOM_PRESET) {
|
||||
return { value: CUSTOM_PRESET, name: 'Custom', description: 'A hand-set knob combination outside the preset table.' }
|
||||
}
|
||||
const spec = this.resolve(name)
|
||||
return { value: name, name: spec.name ?? name, ...spec.description !== undefined ? { description: spec.description } : {} }
|
||||
}
|
||||
|
||||
/**
|
||||
* THE write path for a preset switch: appends one `permission/preset` event when
|
||||
* `name` differs from the session's current preset, then writes each knob
|
||||
* through its own setter, skipping values the session already effectively
|
||||
* has — a net-zero switch appends nothing (the log records switches, not
|
||||
* select clicks).
|
||||
* @param session - the session the switch belongs to.
|
||||
* @param name - the preset to switch to (validated via {@link resolve}).
|
||||
*/
|
||||
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
|
||||
147
packages/ui/permission/tests/permission.spec.ts
Normal file
147
packages/ui/permission/tests/permission.spec.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
|
||||
import PermissionService, { CUSTOM_PRESET, effectivePermissionPreset } from '@deepseek-ai/dsh-permission'
|
||||
import type { Config } from '@deepseek-ai/dsh-permission'
|
||||
|
||||
/** Mount the service over stand-in bash/approval capabilities (the two facts it validates against). */
|
||||
async function mounted(options: {
|
||||
config?: Config
|
||||
bashDefault?: SandboxMode | undefined
|
||||
approvalDefault?: ApprovalPolicy | undefined
|
||||
} = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
ctx.provide('bash', { sandboxMode: 'bashDefault' in options ? options.bashDefault : 'workspace-write' })
|
||||
ctx.provide('approval', { config: { policy: 'approvalDefault' in options ? options.approvalDefault : 'ask' } })
|
||||
await ctx.plugin(PermissionService, options.config ?? {})
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** A real Session seeded with one opened turn (events append without ceremony in unit scope). */
|
||||
function freshSession(id: string): Session {
|
||||
return new Session(SessionId(id))
|
||||
}
|
||||
|
||||
describe('effectivePermissionPreset', () => {
|
||||
it('folds to the last event, or undefined without one', () => {
|
||||
const session = freshSession('sess-fold')
|
||||
expect(effectivePermissionPreset(session.events)).toBeUndefined()
|
||||
session.append('permission/preset', { preset: 'yolo' })
|
||||
session.append('permission/preset', { preset: 'request' })
|
||||
expect(effectivePermissionPreset(session.events)).toBe('request')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PermissionService', () => {
|
||||
it('advertises the preset table in declaration order and resolves bundles', async () => {
|
||||
const ctx = await mounted()
|
||||
expect(ctx.permission.names).toEqual(['request', 'yolo'])
|
||||
expect(ctx.permission.resolve('yolo')).toMatchObject({ sandbox: 'danger-full-access', approval: 'never' })
|
||||
expect(() => ctx.permission.resolve('plan')).toThrow(/unknown preset "plan"/)
|
||||
})
|
||||
|
||||
it('current() derives from the effective knobs: composition defaults hit request, a switch hits its preset', async () => {
|
||||
const ctx = await mounted()
|
||||
const session = freshSession('sess-current')
|
||||
expect(ctx.permission.current(session.events)).toBe('request')
|
||||
ctx.permission.set(session, 'yolo')
|
||||
expect(ctx.permission.current(session.events)).toBe('yolo')
|
||||
})
|
||||
|
||||
it('a knob state matching no table entry derives custom — a state, not an error', async () => {
|
||||
const ctx = await mounted()
|
||||
const session = freshSession('sess-custom')
|
||||
session.append('bash/sandbox-mode', { mode: 'read-only' })
|
||||
expect(ctx.permission.current(session.events)).toBe(CUSTOM_PRESET)
|
||||
// Switching FROM custom is an ordinary write-through; custom itself is
|
||||
// never a target.
|
||||
ctx.permission.set(session, 'yolo')
|
||||
expect(ctx.permission.current(session.events)).toBe('yolo')
|
||||
expect(() => ctx.permission.resolve(CUSTOM_PRESET)).toThrow(/unknown preset/)
|
||||
})
|
||||
|
||||
it('composition defaults outside the table derive custom at zero events', async () => {
|
||||
const ctx = await mounted({ approvalDefault: 'never' })
|
||||
const session = freshSession('sess-defaults-custom')
|
||||
expect(ctx.permission.current(session.events)).toBe(CUSTOM_PRESET)
|
||||
})
|
||||
|
||||
it('the fold breaks bundle ties; a stale fold no longer matching falls back to table order', async () => {
|
||||
const ctx = await mounted({ config: { presets: {
|
||||
request: { sandbox: 'workspace-write', approval: 'ask' },
|
||||
agentish: { sandbox: 'workspace-write', approval: 'ask' },
|
||||
yolo: { sandbox: 'danger-full-access', approval: 'never' },
|
||||
} } })
|
||||
const session = freshSession('sess-tie')
|
||||
// Same bundle as request, chosen explicitly: the fold names it.
|
||||
ctx.permission.set(session, 'agentish')
|
||||
expect(ctx.permission.current(session.events)).toBe('agentish')
|
||||
// A knob drifts: the fold's bundle no longer matches → reverse map wins.
|
||||
session.append('approval/policy', { policy: 'never' })
|
||||
session.append('bash/sandbox-mode', { mode: 'danger-full-access' })
|
||||
expect(ctx.permission.current(session.events)).toBe('yolo')
|
||||
})
|
||||
|
||||
it('set() writes through: one preset event plus both knob events', async () => {
|
||||
const ctx = await mounted()
|
||||
const session = freshSession('sess-set')
|
||||
ctx.permission.set(session, 'yolo')
|
||||
expect(session.events.map(e => [e.type, e.data])).toEqual([
|
||||
['permission/preset', { preset: 'yolo' }],
|
||||
['bash/sandbox-mode', { mode: 'danger-full-access' }],
|
||||
['approval/policy', { policy: 'never' }],
|
||||
])
|
||||
})
|
||||
|
||||
it('set() to the current preset is a no-op when the knobs already match (clicks are not switches)', async () => {
|
||||
const ctx = await mounted()
|
||||
const session = freshSession('sess-noop')
|
||||
ctx.permission.set(session, 'request')
|
||||
expect(session.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('re-asserting a preset from a drifted (custom) state re-records the choice and repairs the knob', async () => {
|
||||
const ctx = await mounted()
|
||||
const session = freshSession('sess-drift')
|
||||
ctx.permission.set(session, 'yolo')
|
||||
// A knob drifts out from under the preset (a direct setter call, a test
|
||||
// scenario): the session derives custom, and re-asserting the preset is
|
||||
// a real switch again — choice re-recorded, only the drifted knob moves.
|
||||
session.append('bash/sandbox-mode', { mode: 'read-only' })
|
||||
ctx.permission.set(session, 'yolo')
|
||||
const tail = session.events.slice(4)
|
||||
expect(tail.map(e => [e.type, e.data])).toEqual([
|
||||
['permission/preset', { preset: 'yolo' }],
|
||||
['bash/sandbox-mode', { mode: 'danger-full-access' }],
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects composition over a non-confining executor at load', async () => {
|
||||
await expect(mounted({ bashDefault: undefined }))
|
||||
.rejects.toThrow(/does not confine/)
|
||||
})
|
||||
|
||||
it('optionOf() presents shipped labels/descriptions, falls back to the raw key, and fixes custom', async () => {
|
||||
const ctx = await mounted()
|
||||
expect(ctx.permission.optionOf('yolo')).toEqual({ value: 'yolo', name: 'YOLO', description: 'Full file access, no approval prompts.' })
|
||||
expect(ctx.permission.optionOf('custom')).toEqual({ value: 'custom', name: 'Custom', description: 'A hand-set knob combination outside the preset table.' })
|
||||
const bare = await mounted({ config: { presets: { plain: { sandbox: 'workspace-write', approval: 'ask' } } } })
|
||||
expect(bare.permission.optionOf('plain')).toEqual({ value: 'plain', name: 'plain' })
|
||||
expect(() => ctx.permission.optionOf('plan')).toThrow(/unknown preset/)
|
||||
})
|
||||
|
||||
it('rejects a table entry named custom (reserved for the derived state)', async () => {
|
||||
await expect(mounted({ config: { presets: { custom: { sandbox: 'read-only', approval: 'ask' } } } }))
|
||||
.rejects.toThrow(/reserved for the derived not-a-preset state/)
|
||||
})
|
||||
|
||||
it('reads a schema-less approval stand-in as the ask default', async () => {
|
||||
const ctx = await mounted({ approvalDefault: undefined })
|
||||
const session = freshSession('sess-standin')
|
||||
ctx.permission.set(session, 'request')
|
||||
expect(session.events).toHaveLength(0)
|
||||
expect(ctx.permission.current(session.events)).toBe('request')
|
||||
})
|
||||
})
|
||||
33
packages/ui/permission/tsconfig.json
Normal file
33
packages/ui/permission/tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../user-approval"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user