feat(mode): the access cap — plan mode composes with the sandbox instead of banning bash
A ModeDefinition may declare access: the widest sandbox access shell commands run under while the mode holds, on the SANDBOX_MODES ladder. The bash seam gains the resolution point to hang it on: BashExecutor. resolveMode(session) folds override ?? default and dispatches the new bash/resolve-mode waterfall; dsh-tool-bash consults it at both the stamping site and the escalation baseline; dsh-mode's clamp listener takes the ladder minimum per call. Two independent log folds compose at read time — the mode never writes the sandbox knob, so the two switch in any order and the knob re-emerges intact on exit. The built-in plan definition ships access: read-only with the bash trio allowlisted CONDITIONALLY: both policy layers admit bash/bash_output/ bash_kill only while a confining executor is mounted (an unconfinable shell cannot honor the cap), and a bash call carrying sandbox_permissions under a cap is denied at the gate — no widening mid-mode; the widened step belongs in the plan. examples/plan-acp-agent swaps bash-local for sandbox-local + bash-sandbox (workspace-write default, clamped read-only inside plan) plus the approval seam; the re-recorded plan-mode arc runs a real cat inside plan under the clamped sandbox, and modes-advertise now pins the sandbox-mode and approval config options. RFC amended to the landed shape (access cap section, orthogonality FAQ, deferred item resolved into effects self-declaration).
This commit is contained in:
@@ -1419,7 +1419,7 @@ describe('per-session sandbox mode (the bash/sandbox-mode fold)', () => {
|
||||
ctx.tools.execute({ callId: CallId(`call-mode-${++modeCall}`), name: 'bash', arguments: args, ...agent ? { agent } : {} })
|
||||
|
||||
|
||||
it('stamps calls with grant > session override > nothing (executor default)', async () => {
|
||||
it('stamps calls with grant > the seam resolution (override ?? executor default)', async () => {
|
||||
const ctx = await setupModal('read-only', { approval: true })
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
const seen: (string | undefined)[] = []
|
||||
@@ -1430,12 +1430,39 @@ describe('per-session sandbox mode (the bash/sandbox-mode fold)', () => {
|
||||
})
|
||||
const { agent, session } = sessionAgent('sess-stamp-1')
|
||||
const run = { command: 'true', description: 'stamp probe' }
|
||||
await callAs(ctx, agent, run) // no override yet
|
||||
await callAs(ctx, agent, run) // no override yet: the resolved default is stamped explicitly
|
||||
setSandboxMode(session, 'workspace-write')
|
||||
await callAs(ctx, agent, run) // standing override
|
||||
await callAs(ctx, undefined, run) // agent-less caller: no session to fold
|
||||
await callAs(ctx, undefined, run) // agent-less caller: no session to fold — still the resolved default
|
||||
await callAs(ctx, agent, { ...run, sandbox_permissions: 'danger-full-access', justification: 'grant outranks override' })
|
||||
expect(seen).toEqual([undefined, 'workspace-write', undefined, 'danger-full-access'])
|
||||
expect(seen).toEqual(['read-only', 'workspace-write', 'read-only', 'danger-full-access'])
|
||||
})
|
||||
|
||||
it('stamps the bash/resolve-mode waterfall result — a listener narrows both ordinary calls and the escalation baseline', async () => {
|
||||
// A policy listener (dsh-mode's access cap is the shipped one) clamps the
|
||||
// resolution to read-only. An ordinary call is stamped with the clamp, and
|
||||
// the escalation strict-widening check runs against the CLAMPED baseline:
|
||||
// under a workspace-write override, escalating TO workspace-write would be
|
||||
// a non-widening no-op without the clamp — with it, the target is strictly
|
||||
// wider than the call's effective read-only and the grant lands.
|
||||
const ctx = await setupModal('workspace-write', { approval: true })
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
ctx.on('bash/resolve-mode', async (_session, next) => {
|
||||
await next()
|
||||
return 'read-only'
|
||||
})
|
||||
const seen: (string | undefined)[] = []
|
||||
const original = ctx.bash.resolve.bind(ctx.bash)
|
||||
vi.spyOn(ctx.bash, 'resolve').mockImplementation((req) => {
|
||||
seen.push(req.sandboxMode)
|
||||
return original(req)
|
||||
})
|
||||
const { agent, session } = sessionAgent('sess-waterfall')
|
||||
setSandboxMode(session, 'workspace-write')
|
||||
await callAs(ctx, agent, { command: 'true', description: 'clamped probe' })
|
||||
const escalated = await callAs(ctx, agent, { command: 'true', description: 'd', sandbox_permissions: 'workspace-write', justification: 'wider than the clamped baseline' })
|
||||
expect(escalated.isError).toBe(false)
|
||||
expect(seen).toEqual(['read-only', 'workspace-write'])
|
||||
})
|
||||
|
||||
it('escalates relative to the session effective mode, not the executor default (narrower override)', async () => {
|
||||
|
||||
Reference in New Issue
Block a user