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:
@@ -26,13 +26,13 @@ import {
|
||||
* model nor a sandbox runner is ever exercised.
|
||||
*
|
||||
* With-key escalation flow (self-skips without DEEPSEEK_API_KEY or a usable
|
||||
* platform runner): a scripted ACP client plays the human. The real model is
|
||||
* denied under `read-only`, escalates with `sandbox_permissions` +
|
||||
* platform runner): a scripted ACP client plays the human. The prompt asserts
|
||||
* a prior denial (the organic denial→marker path lives on the sandbox e2e
|
||||
* legs and unit tiers), the real model escalates with `sandbox_permissions` +
|
||||
* `justification`, the bridge prompts THIS client over
|
||||
* `session/request_permission`, the client answers `allow-once`, and the
|
||||
* retried write must land ON DISK (world-verified). The session cwd is a temp
|
||||
* dir under the platform temp area, which `workspace-write` grants — so either
|
||||
* escalation target the model picks can land the write.
|
||||
* retried write must land ON DISK (world-verified) — under the granted mode,
|
||||
* a temp-dir session cwd is writable either way.
|
||||
*/
|
||||
|
||||
const binScript = fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url))
|
||||
@@ -130,34 +130,33 @@ describe('sandbox variant keyless smoke (real sandbox.cordis.yml via the Loader)
|
||||
expect(sessionId.length).toBeGreaterThan(0)
|
||||
}, 30_000)
|
||||
|
||||
it('advertises both session config options and honors a switch end to end (no key, no model)', async () => {
|
||||
it('advertises the Permissions select and honors a switch end to end (no key, no model)', async () => {
|
||||
workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-config-'))
|
||||
spawned = spawnSandboxAcpAgent(workdir, 'reject-once')
|
||||
const { client } = spawned
|
||||
await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
// This tree composes bash-sandbox (mode: read-only) + approval → both
|
||||
// knobs advertise, currents from composition config.
|
||||
// This tree composes the permission presets over bash-sandbox + approval →
|
||||
// ONE select advertises, current from the configured default preset.
|
||||
const created = await client.newSession({ cwd: workdir, mcpServers: [] })
|
||||
const advertised = created.configOptions ?? []
|
||||
expect(advertised.map(option => [option.id, 'currentValue' in option ? option.currentValue : undefined]))
|
||||
.toEqual([['sandbox-mode', 'read-only'], ['approval-policy', 'ask']])
|
||||
.toEqual([['permission', 'request']])
|
||||
// A switch responds with the COMPLETE refreshed state (the spec contract),
|
||||
// and the new currents survive in the response of a second switch.
|
||||
const afterSandbox = await client.setSessionConfigOption({
|
||||
sessionId: created.sessionId, configId: 'sandbox-mode', value: 'workspace-write',
|
||||
// and the new current survives in the response of a second switch.
|
||||
const afterYolo = await client.setSessionConfigOption({
|
||||
sessionId: created.sessionId, configId: 'permission', value: 'yolo',
|
||||
})
|
||||
const afterApproval = await client.setSessionConfigOption({
|
||||
sessionId: created.sessionId, configId: 'approval-policy', value: 'never',
|
||||
expect(afterYolo.configOptions?.find(option => option.id === 'permission'))
|
||||
.toMatchObject({ currentValue: 'yolo' })
|
||||
const again = await client.setSessionConfigOption({
|
||||
sessionId: created.sessionId, configId: 'permission', value: 'yolo',
|
||||
})
|
||||
const currents = (afterApproval.configOptions ?? []).map(option =>
|
||||
[option.id, 'currentValue' in option ? option.currentValue : undefined])
|
||||
expect(afterSandbox.configOptions?.find(option => option.id === 'sandbox-mode'))
|
||||
.toMatchObject({ currentValue: 'workspace-write' })
|
||||
expect(currents).toEqual([['sandbox-mode', 'workspace-write'], ['approval-policy', 'never']])
|
||||
expect((again.configOptions ?? []).map(option => [option.id, 'currentValue' in option ? option.currentValue : undefined]))
|
||||
.toEqual([['permission', 'yolo']])
|
||||
// An out-of-vocabulary value is a protocol error, never a silent default.
|
||||
await expect(client.setSessionConfigOption({
|
||||
sessionId: created.sessionId, configId: 'sandbox-mode', value: 'yolo',
|
||||
})).rejects.toThrow(/unknown sandbox-mode value/)
|
||||
sessionId: created.sessionId, configId: 'permission', value: 'plan',
|
||||
})).rejects.toThrow(/unknown permission value/)
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
@@ -171,12 +170,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || !hasRunner)('sandbox variant e2
|
||||
const { sessionId } = await client.newSession({ cwd: workdir, mcpServers: [] })
|
||||
const res = await client.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: `Use the bash tool to create the file ${workdir}/escalated.txt containing exactly "ACP_ESCALATION_OK". `
|
||||
+ 'If the sandbox denies it, retry once with sandbox_permissions and a one-sentence justification, then stop.' }],
|
||||
prompt: [{ type: 'text', text: `The sandbox already denied writing ${workdir}/escalated.txt. Create it now containing exactly "ACP_ESCALATION_OK": `
|
||||
+ 'one single bash call with sandbox_permissions set to danger-full-access and a one-sentence justification, then stop.' }],
|
||||
})
|
||||
expect(['end_turn', 'max_tokens']).toContain(res.stopReason)
|
||||
|
||||
// The WORLD: the approved escalated retry landed the write read-only denied.
|
||||
// The WORLD: the approved escalated retry landed the write.
|
||||
const proof = await readFile(join(workdir, 'escalated.txt'), 'utf8')
|
||||
expect(proof).toContain('ACP_ESCALATION_OK')
|
||||
|
||||
@@ -200,8 +199,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || !hasRunner)('sandbox variant e2
|
||||
const { sessionId } = await client.newSession({ cwd: workdir, mcpServers: [] })
|
||||
const res = await client.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: `Use the bash tool to create the file ${workdir}/refused.txt containing "NO". `
|
||||
+ 'If the sandbox denies it, retry once with sandbox_permissions and a one-sentence justification. If that is rejected, stop and say so.' }],
|
||||
prompt: [{ type: 'text', text: `The sandbox already denied writing ${workdir}/refused.txt. Create it now containing "NO": `
|
||||
+ 'one single bash call with sandbox_permissions set to danger-full-access and a one-sentence justification. If that is rejected, stop and say so.' }],
|
||||
})
|
||||
expect(['end_turn', 'max_tokens']).toContain(res.stopReason)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user