Merge remote-tracking branch 'origin/master' into cross-family-fs-sandbox

# Conflicts:
#	docs/config-catalog.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/event-producer-consumer.md
#	docs/persistence-catalog.md
#	examples/acp-agent/cordis.yml
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl
#	examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/permission-switching/session.jsonl
#	examples/acp-agent/tests/snapshots/skill-load/session.jsonl
#	examples/acp-agent/tests/snapshots/text-turn/session.jsonl
#	packages/bash/bash-sandbox/src/index.ts
#	packages/bash/bash/src/types.ts
#	packages/bash/tool-bash/src/index.ts
#	packages/fs/fs/src/index.ts
#	packages/sandbox/sandbox-policy/src/session-mode.ts
#	packages/ui/permission/src/index.ts
#	packages/ui/permission/tests/permission.spec.ts
#	scripts/doc-budgets.manifest.json
This commit is contained in:
kingwl
2026-07-14 21:25:36 +08:00
608 changed files with 7195 additions and 12508 deletions

View File

@@ -1,7 +1,17 @@
# @deepseek-ai/dsh-permission
User-facing permission presets. Owns the `ctx.permission` service ([`PermissionService`](src/index.ts)): a config-defined preset table — by default `workspace-write` (`workspace-write` + `ask`) and `danger-full-access` (`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.
User-facing permission presets through `ctx.permission` ([`PermissionService`](src/index.ts)). Each configured name bundles `bash/sandbox-mode` with `approval/policy`; the defaults are `workspace-write` (`workspace-write` + `ask`) and `danger-full-access` (`danger-full-access` + `never`). The ACP bridge exposes them as one `Permissions` select, while sandbox execution and approval continue to consume their own knobs.
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.
`set(session, name)` records a changed selection in a log-only `permission/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it.
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 default tree](../../../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.
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 [acp-agent composition](../../../examples/acp-agent/) and [sandbox switching design](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md).
## 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.
## Known Limitations and Deferred Work
- **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.

View File

@@ -1,16 +1,9 @@
/**
* User-facing PERMISSION PRESETS: one product-level knob over the two
* mechanism knobs. A preset names a bundle — its sandbox mode
* (`sandbox/mode`) and its approval policy (`approval/policy`) — so a
* user picks `workspace-write` or `danger-full-access` while 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.
* User-facing permission presets over the independent sandbox-mode and
* approval-policy knobs. A switch records the selected preset, then writes
* changed knobs through their canonical setters. Execution, prompt narration,
* and replay keep reading their knob folds. The preset event preserves user
* intent when two presets share a bundle.
*
* @module dsh-permission
*/
@@ -35,21 +28,16 @@ declare module 'cordis' {
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/**
* The session's permission preset was switched — log-only (the
* `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.
* Records the selected preset as durable, log-only user intent. The knob
* events follow in the same turn and control execution; this event stays
* out of the model transcript and lets {@link effectivePermissionPreset}
* preserve a selection when bundles match.
*/
'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.
*/
/** One preset's sandbox/approval bundle and optional client presentation. */
export interface PresetSpec {
/** The `sandbox/mode` value the preset writes through. */
sandbox: SandboxMode
@@ -72,21 +60,16 @@ export interface PresetOption {
}
/**
* 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.
* 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.
*/
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.
* Fold the last selected preset from the durable log; replay needs no catch-up
* state.
* @param events - session events in log order; other event types are ignored.
* @returns the last selected preset, or undefined when none was recorded.
*/
export function effectivePermissionPreset(events: readonly SessionEvent[]): string | undefined {
for (let index = events.length - 1; index >= 0; index -= 1) {
@@ -107,13 +90,9 @@ export interface Config {
}
/**
* 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.
* Owns the deployment's permission presets and their write path. Requires a
* confining `ctx.bash` executor and `ctx.approval`; unmatched knob values are
* reported as {@link CUSTOM_PRESET}, not an error.
*/
export class PermissionService extends Service {
// Inline schema call: the config catalog walks `static Config` statically.
@@ -124,14 +103,13 @@ export class PermissionService extends Service {
name: z.string(),
description: z.string(),
})).default({
// Keep the user-facing preset names explicit about filesystem reach.
'workspace-write': {
sandbox: 'workspace-write', approval: 'ask',
name: 'workspace-write', description: 'Write inside the workspace; anything wider asks for your approval.',
name: 'workspace-write', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.',
},
'danger-full-access': {
sandbox: 'danger-full-access', approval: 'never',
name: 'danger-full-access', description: 'Full file access, no approval prompts.',
name: 'danger-full-access', description: 'Full file access without approval prompts.',
},
}),
})
@@ -161,11 +139,9 @@ export class PermissionService extends Service {
}
/**
* 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.
* Resolve the preset matching the effective knob values. A still-matching
* last selection wins shared-bundle ties; otherwise the first table match
* wins, or {@link CUSTOM_PRESET} when no entry matches.
* @param events - the session's events in log order.
* @returns the effective preset name, or `custom` when nothing matches.
*/
@@ -185,10 +161,10 @@ export class PermissionService extends Service {
}
/**
* A preset's knob bundle, for consumers presenting or validating one.
* Resolve a preset's knob bundle.
* @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).
* @returns the configured bundle.
* @throws when `name` is not in the table.
*/
resolve(name: string): PresetSpec {
const spec = this.presets[name]
@@ -199,28 +175,25 @@ export class PermissionService extends Service {
}
/**
* 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.
* Build the client option for a table entry or {@link CUSTOM_PRESET}. A
* missing label falls back to the table key.
* @param name - a table key, or `custom`.
* @returns the option a client renders; throws on any other name.
* @returns the option a client renders.
* @throws when `name` is neither a table key nor `custom`.
*/
optionOf(name: string): PresetOption {
if (name === CUSTOM_PRESET) {
return { value: CUSTOM_PRESET, name: 'Custom', description: 'A hand-set knob combination outside the preset table.' }
return { value: CUSTOM_PRESET, name: 'Custom', description: 'Current sandbox and approval settings do not match a preset.' }
}
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).
* Record a changed preset, then update each changed knob through its own
* setter. Selecting the effective preset again appends nothing.
* @param session - the session the switch belongs to.
* @param name - the preset to switch to (validated via {@link resolve}).
* @param name - the preset to switch to; unknown names throw.
*/
set(session: Session, name: string): void {
const spec = this.resolve(name)

View File

@@ -6,7 +6,6 @@ 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
@@ -19,7 +18,6 @@ async function mounted(options: {
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))
}
@@ -55,8 +53,6 @@ describe('PermissionService', () => {
const session = freshSession('sess-custom')
session.append('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, 'danger-full-access')
expect(ctx.permission.current(session.events)).toBe('danger-full-access')
expect(() => ctx.permission.resolve(CUSTOM_PRESET)).toThrow(/unknown preset/)
@@ -75,10 +71,8 @@ describe('PermissionService', () => {
'danger-full-access': { sandbox: 'danger-full-access', approval: 'never' },
} } })
const session = freshSession('sess-tie')
// Same bundle as workspace-write, 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('sandbox/mode', { mode: 'danger-full-access' })
expect(ctx.permission.current(session.events)).toBe('danger-full-access')
@@ -106,9 +100,8 @@ describe('PermissionService', () => {
const ctx = await mounted()
const session = freshSession('sess-drift')
ctx.permission.set(session, 'danger-full-access')
// 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.
// Re-selecting from a drifted state records the choice and repairs only
// the changed knob.
session.append('sandbox/mode', { mode: 'read-only' })
ctx.permission.set(session, 'danger-full-access')
const tail = session.events.slice(4)
@@ -125,8 +118,8 @@ describe('PermissionService', () => {
it('optionOf() presents shipped labels/descriptions, falls back to the raw key, and fixes custom', async () => {
const ctx = await mounted()
expect(ctx.permission.optionOf('danger-full-access')).toEqual({ value: 'danger-full-access', name: 'danger-full-access', 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.' })
expect(ctx.permission.optionOf('danger-full-access')).toEqual({ value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access without approval prompts.' })
expect(ctx.permission.optionOf('custom')).toEqual({ value: 'custom', name: 'Custom', description: 'Current sandbox and approval settings do not match a preset.' })
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/)