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:
@@ -14,6 +14,14 @@ The `default` mode is the absence of policy: no section, no filtering, no gate.
|
||||
|
||||
**Hard — what can run.** A `tools/pre-execute` listener denies, deny-by-default against the same allowlist, any call the mode does not permit — a hallucinated call to a still-registered (or freshly re-widened) tool cannot run. Agent-less executions and the default mode pass through; the gate judges by the LOGGED mode only, never a pending intent. `run_code` passes both layers as a TRANSPORT: under the registry's Code Mode it is the only wire tool, every bridged sub-call re-enters this gate with the same agent, and the `tools:sdk` section is re-rendered under the mode's visibility rule — the allowlist governs each capability individually and the prompt documents exactly the callable set.
|
||||
|
||||
## The `access` cap
|
||||
|
||||
A definition may declare `access` — the widest sandbox access shell commands run under while the mode is in force, using the `SANDBOX_MODES` ladder from [`@deepseek-ai/dsh-bash`](../../bash/bash/) (`read-only` | `workspace-write` | `danger-full-access`). The built-in `plan` ships `access: 'read-only'`, which is what lets it keep `bash` on the allowlist: exploration commands run for real, and a write is denied by the sandbox itself.
|
||||
|
||||
The cap is a **clamp, not a switch**: a `bash/resolve-mode` waterfall listener returns `min(resolved, access)` on the ladder. The session's own sandbox-mode knob (`bash/sandbox-mode` events) is never written — the two folds compose at read time, so the knob and the mode switch in any order without disturbing each other, and a knob flipped during plan re-emerges intact on exit. Both derive from the same log, so resume restores the composition for free.
|
||||
|
||||
Two consequences ride with a declared cap. The bash trio (`bash`/`bash_output`/`bash_kill`) becomes CONDITIONAL: both policy layers admit it only while a confining executor is mounted (`ctx.bash.sandboxMode` set — an unconfinable shell cannot honor the cap, so under `dsh-bash-local` the trio is hidden and denied like any non-allowlisted tool). And sandbox ESCALATION is denied outright: a `bash` call carrying `sandbox_permissions` gets a deny that points the model at putting the widened step in the plan — the cap would otherwise be pierceable mid-mode by one approval prompt. A mode that allowlists `bash` WITHOUT `access` is the deployment's explicit choice of an uncapped shell in that mode; neither rule applies.
|
||||
|
||||
## `ctx.modes`
|
||||
|
||||
`list()` returns the selectable vocabulary (`default` first, then the configured definitions); `get(agent)` returns the folded mode (a folded name the config no longer defines reads as `default`) plus any pending intent; `set(agent, mode)` validates against `list()` (loud on unknown; `default` is always a valid target) and records a pending intent — every session event is turn-enclosed and an idle agent has no open turn, so the service flushes the intent at the next `turn/start`/`step/end` and, when the flushed mode differs from what the last logged request header told the model, appends one coalesced `context/message` notice in the same frame. A net-zero flip sequence appends nothing.
|
||||
@@ -34,9 +42,10 @@ The model-facing exit tool. Its single required argument is the plan text — a
|
||||
plan:
|
||||
section: |
|
||||
You are in plan mode: ...
|
||||
tools: [read, todo_write, web_search, web_fetch, ask_user_question, structured_output, exit_plan_mode]
|
||||
tools: [read, todo_write, web_search, web_fetch, ask_user_question, structured_output, bash, bash_output, bash_kill, exit_plan_mode]
|
||||
access: read-only
|
||||
```
|
||||
|
||||
Definitions are validated at load (`resolveConfig`): the built-in `plan` (read-only allowlist plus the ask/report channels `ask_user_question`/`structured_output`, `bash`/`subagent` excluded) merges unless overridden, `default` is rejected as a key, and allowlists may name not-yet-registered tools (registration is dynamic). An unknown name fails loudly at `set()` time.
|
||||
Definitions are validated at load (`resolveConfig`): the built-in `plan` (the read-only allowlist above — the ask/report channels `ask_user_question`/`structured_output` included, the bash trio conditional on a confining executor via `access: read-only`, `subagent` excluded) merges unless overridden, `default` is rejected as a key, an `access` outside the `SANDBOX_MODES` ladder throws, and allowlists may name not-yet-registered tools (registration is dynamic). An unknown name fails loudly at `set()` time.
|
||||
|
||||
RFC: [plan mode](../../../docs/rfc/implemented/feature/2026-07-07-plan-mode.md).
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
@@ -32,6 +33,7 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-code-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
* the first shipped definition. A mode names which tools stay visible (the
|
||||
* soft layer, a `system-prompt/assemble` filter plus a guidance section) and
|
||||
* which may run (the hard layer, a deny-by-default `tools/pre-execute` gate);
|
||||
* the mode IN FORCE for an agent is session state, folded from its log
|
||||
* (`mode/set`, last one wins), so resume and fork restore it for free.
|
||||
* a mode may also declare `access` — a cap the bash seam's per-call sandbox
|
||||
* resolution is clamped to while the mode is in force (a `bash/resolve-mode`
|
||||
* listener), composing with the session's own sandbox knob without ever
|
||||
* writing it. The mode IN FORCE for an agent is session state, folded from
|
||||
* its log (`mode/set`, last one wins), so resume and fork restore it for free.
|
||||
*
|
||||
* The default mode is the absence of policy: no section, no filtering, no
|
||||
* gate. An agent that never sees a `mode/set` behaves byte-identically to a
|
||||
@@ -28,6 +31,11 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { defineTool, renderToolsSdk, RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
|
||||
import type { PreToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
// Value import (not type-only): the access-cap vocabulary IS the bash seam's
|
||||
// sandbox-mode ladder, and the import also merges the `bash/resolve-mode`
|
||||
// event and `ctx.bash` declarations the clamp listener and the bash-family
|
||||
// gating read. The seam itself stays optional at runtime (`ctx.get('bash')`).
|
||||
import { SANDBOX_MODES } from '@deepseek-ai/dsh-bash'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-user-interaction'
|
||||
|
||||
@@ -78,14 +86,25 @@ export const PLAN_MODE = 'plan'
|
||||
export const EXIT_PLAN_MODE = 'exit_plan_mode'
|
||||
|
||||
/**
|
||||
* One mode's deployment-configured policy: the guidance section the model sees
|
||||
* and the allowlist of tool names that stay visible and executable.
|
||||
* One mode's deployment-configured policy: the guidance section the model sees,
|
||||
* the allowlist of tool names that stay visible and executable, and an
|
||||
* optional cap on the sandbox access shell commands run under.
|
||||
*/
|
||||
export interface ModeDefinition {
|
||||
/** Guidance text rendered as the `mode:policy` prompt section while the mode is in force. */
|
||||
section: string
|
||||
/** Allowlist of tool NAMES; names may reference not-yet-registered tools (registration is dynamic). */
|
||||
tools: string[]
|
||||
/**
|
||||
* The widest sandbox access shell commands may run under while this mode is
|
||||
* in force — a per-call CAP on the bash seam's resolved mode (a
|
||||
* `bash/resolve-mode` clamp), not a switch: the session's own sandbox knob
|
||||
* keeps its setting and re-emerges intact when the mode ends. Omitted, the
|
||||
* mode leaves the resolution alone. A mode with `access` set exposes the
|
||||
* bash tools only while a confining executor is mounted (an unconfinable
|
||||
* shell cannot honor the cap) and denies sandbox escalation outright.
|
||||
*/
|
||||
access?: (typeof SANDBOX_MODES)[number]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,7 +126,11 @@ export interface ResolvedModes {
|
||||
const PLAN_SECTION
|
||||
= 'You are in plan mode: a read-only planning state. Explore, analyze, and design; '
|
||||
+ 'do not attempt to modify anything — mutating tools are not available and calls '
|
||||
+ 'to them are denied. When a decision or a missing detail blocks the plan, ask the '
|
||||
+ 'to them are denied. Where a bash tool is present it runs under a read-only '
|
||||
+ 'sandbox: commands that only read work normally, while a command that writes is '
|
||||
+ 'denied by the sandbox — that denial marks the edge of plan mode rather than a '
|
||||
+ 'bug, and sandbox escalation is not offered here; put the step in the plan for '
|
||||
+ 'after approval instead. When a decision or a missing detail blocks the plan, ask the '
|
||||
+ 'user through the ask_user_question tool where it is available. A finished plan '
|
||||
+ 'is delivered by calling exit_plan_mode — that call is what puts it in front of '
|
||||
+ 'the user for review, so prefer it over pasting the plan as a plain reply or '
|
||||
@@ -115,12 +138,22 @@ const PLAN_SECTION
|
||||
+ 'its review fails, ask the user to switch the session out of plan mode instead '
|
||||
+ 'of retrying denied tools.'
|
||||
|
||||
/**
|
||||
* The three bash tools an `access` cap conditions on a confining executor:
|
||||
* `bash` runs commands under the capped sandbox; `bash_output`/`bash_kill`
|
||||
* only observe and stop tasks that ran under it.
|
||||
*/
|
||||
const BASH_FAMILY = ['bash', 'bash_output', 'bash_kill']
|
||||
|
||||
// 'structured_output' is a structured subagent child's result channel (pure
|
||||
// reporting, the ask/exit class of read-only-safe): its runtime re-injects the
|
||||
// schema into the FINAL assembly from an outermost per-spawn listener, so
|
||||
// allowlisting is what keeps the soft filter, that re-injection, and the hard
|
||||
// gate telling one consistent story when such a child runs in plan mode.
|
||||
const PLAN_TOOLS = ['read', 'todo_write', 'web_search', 'web_fetch', 'ask_user_question', 'structured_output', EXIT_PLAN_MODE]
|
||||
// The bash trio is allowlisted CONDITIONALLY: plan's read-only `access` cap
|
||||
// can only be honored by a confining executor, so both policy layers admit
|
||||
// these three only while `ctx.bash.sandboxMode` proves one is mounted.
|
||||
const PLAN_TOOLS = ['read', 'todo_write', 'web_search', 'web_fetch', 'ask_user_question', 'structured_output', ...BASH_FAMILY, EXIT_PLAN_MODE]
|
||||
|
||||
/** The review question's approve option label — the answer item is matched by it. */
|
||||
const APPROVE_LABEL = 'Approve'
|
||||
@@ -143,6 +176,11 @@ function firstHeading(plan: string): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Whether a bash call's parsed arguments carry the escalation field (`sandbox_permissions`). */
|
||||
function hasEscalationArgs(args: unknown): boolean {
|
||||
return typeof args === 'object' && args !== null && (args as { sandbox_permissions?: unknown }).sandbox_permissions !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the config and merge the built-in `plan` definition (explicit
|
||||
* resolve step — the `dsh-bash` request/spec template). Fail-loud: a
|
||||
@@ -153,7 +191,7 @@ function firstHeading(plan: string): string | undefined {
|
||||
*/
|
||||
export function resolveConfig(config: ModeConfig): ResolvedModes {
|
||||
const definitions = new Map<string, ModeDefinition>()
|
||||
definitions.set(PLAN_MODE, { section: PLAN_SECTION, tools: [...PLAN_TOOLS] })
|
||||
definitions.set(PLAN_MODE, { section: PLAN_SECTION, tools: [...PLAN_TOOLS], access: 'read-only' })
|
||||
for (const [name, definition] of Object.entries(config.modes ?? {})) {
|
||||
if (name === DEFAULT_MODE) {
|
||||
throw new Error(`ModeConfig: "${DEFAULT_MODE}" is reserved (the absence of policy) and cannot be defined`)
|
||||
@@ -164,7 +202,14 @@ export function resolveConfig(config: ModeConfig): ResolvedModes {
|
||||
if (!Array.isArray(definition.tools) || definition.tools.some(tool => typeof tool !== 'string')) {
|
||||
throw new Error(`ModeConfig: mode "${name}" needs a \`tools\` array of tool names`)
|
||||
}
|
||||
definitions.set(name, { section: definition.section, tools: [...definition.tools] })
|
||||
if (definition.access !== undefined && !SANDBOX_MODES.includes(definition.access)) {
|
||||
throw new Error(`ModeConfig: mode "${name}" has unknown access ${JSON.stringify(definition.access)} — one of: ${SANDBOX_MODES.join(', ')}`)
|
||||
}
|
||||
definitions.set(name, {
|
||||
section: definition.section,
|
||||
tools: [...definition.tools],
|
||||
...definition.access !== undefined ? { access: definition.access } : {},
|
||||
})
|
||||
}
|
||||
return { definitions }
|
||||
}
|
||||
@@ -274,8 +319,15 @@ export class ModesService extends Service {
|
||||
return result
|
||||
}
|
||||
const allowed = new Set(active.definition.tools)
|
||||
// An access-capped mode exposes the bash trio only while a confining
|
||||
// executor is mounted — advertised tools stay honest about the cap.
|
||||
// Read per assembly via ctx.get (never static inject): the executor is
|
||||
// optional to this plugin and may swap at runtime.
|
||||
const bashUsable = active.definition.access === undefined || ctx.get('bash')?.sandboxMode !== undefined
|
||||
const visible = (name: string): boolean =>
|
||||
allowed.has(name) && (name !== EXIT_PLAN_MODE || active.name === PLAN_MODE)
|
||||
allowed.has(name)
|
||||
&& (name !== EXIT_PLAN_MODE || active.name === PLAN_MODE)
|
||||
&& (bashUsable || !BASH_FAMILY.includes(name))
|
||||
// run_code is a TRANSPORT, not a capability: under the registry's Code
|
||||
// Mode it is the only wire tool (filtering it would leave the model
|
||||
// with nothing, not even the exit), and every bridged sub-call
|
||||
@@ -314,13 +366,44 @@ export class ModesService extends Service {
|
||||
// so each sub-call is judged here individually — gating the wrapper
|
||||
// would only remove the vehicle, not widen or narrow any capability.
|
||||
if (exec.name === RUN_CODE_NAME) return next()
|
||||
if (active.definition.tools.includes(exec.name)) return next()
|
||||
// The bash trio is conditional under an access cap: without a confining
|
||||
// executor the cap cannot be honored, so the trio reads as not
|
||||
// allowlisted — the same absence the assemble filter's hiding implies.
|
||||
const capped = active.definition.access !== undefined && BASH_FAMILY.includes(exec.name)
|
||||
const bashUsable = !capped || ctx.get('bash')?.sandboxMode !== undefined
|
||||
if (active.definition.tools.includes(exec.name) && bashUsable) {
|
||||
// A capped mode admits `bash` but no widening: escalation would pierce
|
||||
// the cap mid-mode. Denied HERE, before tool-bash's escalation path
|
||||
// would treat the clamped resolution as a legitimate baseline and
|
||||
// raise the approval prompt.
|
||||
if (capped && exec.name === 'bash' && hasEscalationArgs(exec.arguments)) {
|
||||
return Promise.resolve({
|
||||
kind: 'deny',
|
||||
reason: `sandbox escalation is not available in ${active.name} mode — the sandbox stays ${active.definition.access} while it is in force; put the wider-access step in the plan for after approval`,
|
||||
})
|
||||
}
|
||||
return next()
|
||||
}
|
||||
const reason = active.name === PLAN_MODE
|
||||
? `tool "${exec.name}" is not available in plan mode; continue planning and present your plan with ${EXIT_PLAN_MODE} when ready`
|
||||
: `tool "${exec.name}" is not available in "${active.name}" mode`
|
||||
return Promise.resolve({ kind: 'deny', reason })
|
||||
})
|
||||
|
||||
// The access cap made real: clamp the bash seam's per-call resolution to
|
||||
// the active mode's declared access. Read-time composition of two
|
||||
// independent folds — the sandbox knob's and the mode's — neither writes
|
||||
// the other, so the knob re-emerges intact when the mode ends and a crash
|
||||
// between them can strand nothing. SANDBOX_MODES is the narrowest-first
|
||||
// ladder; the clamp is an index min.
|
||||
ctx.on('bash/resolve-mode', async (session, next) => {
|
||||
const base = await next()
|
||||
if (session === undefined) return base
|
||||
const access = this.activeDefinition(session)?.definition.access
|
||||
if (access === undefined) return base
|
||||
return SANDBOX_MODES.indexOf(base) <= SANDBOX_MODES.indexOf(access) ? base : access
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: EXIT_PLAN_MODE,
|
||||
description: EXIT_DESCRIPTION,
|
||||
|
||||
@@ -8,6 +8,8 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction'
|
||||
import { CodeRuntime, type CodeRunRequest, type CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||||
import { BashExecutor, setSandboxMode } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash'
|
||||
import ModesService, { DEFAULT_MODE, EXIT_PLAN_MODE, PLAN_MODE, foldMode, resolveConfig } from '../src/index.ts'
|
||||
import type { ModeConfig } from '../src/index.ts'
|
||||
|
||||
@@ -76,8 +78,9 @@ describe('resolveConfig', () => {
|
||||
it('merges the built-in plan definition with the read-only allowlist', () => {
|
||||
const resolved = resolveConfig({})
|
||||
const plan = resolved.definitions.get(PLAN_MODE)
|
||||
expect(plan?.tools).toEqual(['read', 'todo_write', 'web_search', 'web_fetch', 'ask_user_question', 'structured_output', EXIT_PLAN_MODE])
|
||||
expect(plan?.tools).toEqual(['read', 'todo_write', 'web_search', 'web_fetch', 'ask_user_question', 'structured_output', 'bash', 'bash_output', 'bash_kill', EXIT_PLAN_MODE])
|
||||
expect(plan?.section).toContain('plan mode')
|
||||
expect(plan?.access).toBe('read-only')
|
||||
})
|
||||
|
||||
it('lets config override plan and add further modes', () => {
|
||||
@@ -102,6 +105,13 @@ describe('resolveConfig', () => {
|
||||
expect(() => resolveConfig({ modes: { bad: { section: '', tools: [7] } as unknown as { section: string; tools: string[] } } }))
|
||||
.toThrow('needs a `tools` array')
|
||||
})
|
||||
|
||||
it('validates access against the sandbox-mode ladder', () => {
|
||||
expect(() => resolveConfig({ modes: { locked: { section: 's', tools: [], access: 'sealed' as never } } }))
|
||||
.toThrow('unknown access "sealed" — one of: read-only, workspace-write, danger-full-access')
|
||||
const resolved = resolveConfig({ modes: { locked: { section: 's', tools: ['bash'], access: 'workspace-write' } } })
|
||||
expect(resolved.definitions.get('locked')).toEqual({ section: 's', tools: ['bash'], access: 'workspace-write' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('foldMode', () => {
|
||||
@@ -715,3 +725,187 @@ describe('exit_plan_mode', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* A minimal confining executor for the access-cap tests: only `sandboxMode`
|
||||
* (the capability fact both policy layers and `resolveMode` read) matters;
|
||||
* the task API is never exercised here.
|
||||
*/
|
||||
class FakeSandboxExecutor extends BashExecutor {
|
||||
constructor(ctx: Context, private readonly config: { mode?: 'read-only' | 'workspace-write' | 'danger-full-access' } = {}) {
|
||||
super(ctx)
|
||||
}
|
||||
|
||||
override get sandboxMode() {
|
||||
return this.config.mode
|
||||
}
|
||||
|
||||
resolve(request: BashExecRequest): BashExecSpec {
|
||||
return { command: request.command, workdir: '/w', timeoutMs: 1000, owner: request.owner, sandboxMode: request.sandboxMode }
|
||||
}
|
||||
|
||||
run(_spec: BashExecSpec): Promise<BashRunResult> {
|
||||
return Promise.resolve({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
timeoutMs: 1000,
|
||||
stdout: { text: '', truncated: false },
|
||||
stderr: { text: '', truncated: false },
|
||||
})
|
||||
}
|
||||
|
||||
start(_spec: BashExecSpec): BashTask { throw new Error('unused in access-cap tests') }
|
||||
get(): BashTask | undefined { return undefined }
|
||||
ownerOf(): OwnerToken | undefined { return undefined }
|
||||
list(): BashTask[] { return [] }
|
||||
readOutput(): BashTaskRead { throw new Error('unused in access-cap tests') }
|
||||
kill(): boolean { return false }
|
||||
}
|
||||
|
||||
describe('the access cap (bash/resolve-mode clamp)', () => {
|
||||
async function sandboxSetup(mode: 'read-only' | 'workspace-write' | 'danger-full-access' | undefined, config?: ModeConfig): Promise<Context> {
|
||||
const ctx = await setup(config)
|
||||
await ctx.plugin(FakeSandboxExecutor, mode !== undefined ? { mode } : {})
|
||||
return ctx
|
||||
}
|
||||
|
||||
it('clamps the plan-mode resolution to read-only over a wider knob and default', async () => {
|
||||
const ctx = await sandboxSetup('workspace-write')
|
||||
const agent = agentWithSession()
|
||||
agent.session.append('mode/set', { mode: PLAN_MODE })
|
||||
expect(await ctx.bash.resolveMode(agent.session)).toBe('read-only')
|
||||
setSandboxMode(agent.session, 'danger-full-access')
|
||||
expect(await ctx.bash.resolveMode(agent.session)).toBe('read-only')
|
||||
})
|
||||
|
||||
it('leaves the default-mode resolution alone (knob ?? executor default)', async () => {
|
||||
const ctx = await sandboxSetup('workspace-write')
|
||||
const agent = agentWithSession()
|
||||
expect(await ctx.bash.resolveMode(agent.session)).toBe('workspace-write')
|
||||
setSandboxMode(agent.session, 'danger-full-access')
|
||||
expect(await ctx.bash.resolveMode(agent.session)).toBe('danger-full-access')
|
||||
})
|
||||
|
||||
it('is a min, not a replace: a knob narrower than the cap stays', async () => {
|
||||
const ctx = await sandboxSetup('danger-full-access', { modes: { locked: { section: 's', tools: ['bash'], access: 'workspace-write' } } })
|
||||
const agent = agentWithSession()
|
||||
agent.session.append('mode/set', { mode: 'locked' })
|
||||
expect(await ctx.bash.resolveMode(agent.session)).toBe('workspace-write')
|
||||
setSandboxMode(agent.session, 'read-only')
|
||||
expect(await ctx.bash.resolveMode(agent.session)).toBe('read-only')
|
||||
})
|
||||
|
||||
it('a mode without access leaves the resolution alone', async () => {
|
||||
const ctx = await sandboxSetup('read-only', { modes: { review: { section: 's', tools: ['bash'] } } })
|
||||
const agent = agentWithSession()
|
||||
agent.session.append('mode/set', { mode: 'review' })
|
||||
setSandboxMode(agent.session, 'danger-full-access')
|
||||
expect(await ctx.bash.resolveMode(agent.session)).toBe('danger-full-access')
|
||||
})
|
||||
|
||||
it('a sessionless resolution passes through the clamp untouched', async () => {
|
||||
const ctx = await sandboxSetup('workspace-write')
|
||||
expect(await ctx.bash.resolveMode(undefined)).toBe('workspace-write')
|
||||
})
|
||||
|
||||
it('orthogonality: the knob set during plan is capped, then re-emerges intact on exit', async () => {
|
||||
const ctx = await sandboxSetup('workspace-write')
|
||||
const agent = agentWithSession()
|
||||
// Enter plan, then flip the knob mid-mode: the cap holds it down…
|
||||
agent.session.append('mode/set', { mode: PLAN_MODE })
|
||||
setSandboxMode(agent.session, 'danger-full-access')
|
||||
expect(await ctx.bash.resolveMode(agent.session)).toBe('read-only')
|
||||
// …and leaving plan uncovers the standing knob, unwritten by the cap.
|
||||
agent.session.append('mode/set', { mode: DEFAULT_MODE })
|
||||
expect(await ctx.bash.resolveMode(agent.session)).toBe('danger-full-access')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the bash trio under an access cap', () => {
|
||||
const TRIO = ['bash', 'bash_output', 'bash_kill']
|
||||
|
||||
it('exposes and admits the trio in plan mode under a confining executor', async () => {
|
||||
const ctx = await setup()
|
||||
await ctx.plugin(FakeSandboxExecutor, { mode: 'workspace-write' })
|
||||
registerNamedTools(ctx, ['read', 'write', ...TRIO])
|
||||
const agent = agentWithSession()
|
||||
agent.session.append('mode/set', { mode: PLAN_MODE })
|
||||
const assembly = await ctx.systemPrompt.assemble({ agent })
|
||||
expect(assembly.tools.map(tool => tool.name).sort()).toEqual(['bash', 'bash_kill', 'bash_output', EXIT_PLAN_MODE, 'read'])
|
||||
for (const name of TRIO) {
|
||||
const result = await execute(ctx, name, agent)
|
||||
expect(result.isError).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('hides and denies the trio in plan mode without any executor', async () => {
|
||||
const ctx = await setup()
|
||||
registerNamedTools(ctx, ['read', ...TRIO])
|
||||
const agent = agentWithSession()
|
||||
agent.session.append('mode/set', { mode: PLAN_MODE })
|
||||
const assembly = await ctx.systemPrompt.assemble({ agent })
|
||||
expect(assembly.tools.map(tool => tool.name).sort()).toEqual([EXIT_PLAN_MODE, 'read'])
|
||||
const denied = await execute(ctx, 'bash', agent)
|
||||
expect(denied.isError).toBe(true)
|
||||
expect(denied.content).toEqual([{
|
||||
type: 'text',
|
||||
text: 'Error: tool "bash" is not available in plan mode; continue planning and present your plan with exit_plan_mode when ready',
|
||||
}])
|
||||
})
|
||||
|
||||
it('hides and denies the trio in plan mode under a never-confining executor', async () => {
|
||||
const ctx = await setup()
|
||||
await ctx.plugin(FakeSandboxExecutor, {})
|
||||
registerNamedTools(ctx, ['read', ...TRIO])
|
||||
const agent = agentWithSession()
|
||||
agent.session.append('mode/set', { mode: PLAN_MODE })
|
||||
const assembly = await ctx.systemPrompt.assemble({ agent })
|
||||
expect(assembly.tools.map(tool => tool.name).sort()).toEqual([EXIT_PLAN_MODE, 'read'])
|
||||
const denied = await execute(ctx, 'bash_output', agent)
|
||||
expect(denied.isError).toBe(true)
|
||||
expect(denied.content).toEqual([{
|
||||
type: 'text',
|
||||
text: 'Error: tool "bash_output" is not available in plan mode; continue planning and present your plan with exit_plan_mode when ready',
|
||||
}])
|
||||
})
|
||||
|
||||
it('denies a bash call carrying sandbox_permissions under the cap (no widening mid-mode)', async () => {
|
||||
const ctx = await setup()
|
||||
await ctx.plugin(FakeSandboxExecutor, { mode: 'workspace-write' })
|
||||
registerNamedTools(ctx, TRIO)
|
||||
const agent = agentWithSession()
|
||||
agent.session.append('mode/set', { mode: PLAN_MODE })
|
||||
const denied = await ctx.tools.execute({
|
||||
callId: CallId(`call-${++callCounter}`),
|
||||
name: 'bash',
|
||||
arguments: { command: 'rm -rf x', description: 'd', sandbox_permissions: 'workspace-write', justification: 'j' },
|
||||
agent,
|
||||
})
|
||||
expect(denied.isError).toBe(true)
|
||||
expect(denied.content).toEqual([{
|
||||
type: 'text',
|
||||
text: 'Error: sandbox escalation is not available in plan mode — the sandbox stays read-only while it is in force; put the wider-access step in the plan for after approval',
|
||||
}])
|
||||
// The same command WITHOUT the escalation fields passes the gate.
|
||||
const plain = await ctx.tools.execute({
|
||||
callId: CallId(`call-${++callCounter}`),
|
||||
name: 'bash',
|
||||
arguments: { command: 'ls', description: 'd' },
|
||||
agent,
|
||||
})
|
||||
expect(plain.isError).toBe(false)
|
||||
})
|
||||
|
||||
it('a mode without access exposes bash regardless of the executor (explicit deployment choice)', async () => {
|
||||
const ctx = await setup({ modes: { shell: { section: 's', tools: ['bash'] } } })
|
||||
registerNamedTools(ctx, ['bash'])
|
||||
const agent = agentWithSession()
|
||||
agent.session.append('mode/set', { mode: 'shell' })
|
||||
const assembly = await ctx.systemPrompt.assemble({ agent })
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['bash'])
|
||||
const result = await execute(ctx, 'bash', agent)
|
||||
expect(result.isError).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-interaction"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user