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:
@@ -21,6 +21,7 @@ The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool su
|
||||
| `start(spec)` | Background execution. Returns a `BashTask` handle immediately; **no timeout applies** (stop tasks via `kill`). |
|
||||
| `get(id)` / `list()` | Task lookup. |
|
||||
| `sandboxMode` | The capability fact for the tool layer: the default mode a SANDBOXING executor confines under (`undefined` in the base class — "this executor does not sandbox"). `dsh-tool-bash` reads it at registration to advertise the escalation fields only when the composition honors them. |
|
||||
| `resolveMode(session)` | The per-call sandbox-mode resolution: the session's standing override falling back to the executor default, dispatched through the **`bash/resolve-mode` waterfall** so policy plugins narrow it per call (`dsh-mode`'s `access` cap is the shipped listener). Returns `undefined` — without consulting the waterfall — for a never-confining executor. `dsh-tool-bash` stamps the result onto each request; a freshly-approved escalation grant outranks it. |
|
||||
| `ownerOf(id)` | The opaque OWNER token recorded for a background task at `start` (from the spec's `owner`), or `undefined` for an unknown id OR a known-but-ownerless task. The executor stores/returns it verbatim and NEVER interprets it — the access POLICY lives in the consumer (`dsh-tool-bash`), which compares `ownerOf(id)` to the caller's token. Storing ownership here (disposed with the executor's fiber) is what makes it survive a consumer HMR reload. |
|
||||
| `readOutput(id)` | **Incremental** output read — consecutive reads never re-deliver. Reads that lost data to buffer bounds flag `lossy` and point at full-stream spill files. Throws for unknown ids. |
|
||||
| `kill(id)` | Kill a running task. Returns `false` when it already finished; throws for unknown ids. |
|
||||
@@ -32,6 +33,6 @@ Implementations subclass `BashExecutor`, implement the abstract methods, and cal
|
||||
|
||||
`BashExecRequest` (command, workdir?, timeoutMs?, signal?, stdin?, env?, owner?, sandboxMode?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, stdin?, env?, owner, sandboxMode) before execution; `owner` and `sandboxMode` are optional on the request and **required-but-nullable** on the resolved spec, so a forgotten one is a visible `undefined` rather than a silently-absent property. `sandboxMode` is the explicit per-call sandbox-policy input: an escalation grant a human just issued ([the sandbox RFC § Escalation](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md), which outranks) or the session's standing override ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)); a sandboxing executor's `resolve()` stamps its configured default when the request carries none, and a non-sandboxing executor carries the field verbatim and confines nothing.
|
||||
|
||||
The seam also owns the per-session mode override vocabulary (the sandbox RFC § Per-session mode switching): the log-only `'bash/sandbox-mode'` session event, the pure fold `effectiveSandboxMode(events)` (last event wins; `undefined` means "apply the executor default"), and THE write path `setSandboxMode(session, mode)` — the session log is the store, so an override survives restart by replay and two sessions can never see each other's mode. Writers must respect turn-enclosure: the ACP bridge anchors an idle switch at the next turn rather than appending between turns. The task id (`BashTaskId`) and the `owner` token (`OwnerToken`) are [branded](../../util/brand) — `OwnerToken` is a DISTINCT brand from `SessionId` (the seam never imports `dsh-session`; the `dsh-tool-bash` consumer is the single boundary that casts its `SessionId` into one). `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. A sandboxing executor additionally stamps `sandbox` result facts on results and settled tasks (`BashSandboxInfo`: the mode it executed under, the conservative `denied` classification, and — for confined modes — the backend's `enforcement` completeness); the mode/enforcement vocabulary is owned by the [`dsh-sandbox`](../../sandbox/sandbox/) seam, and the facts are documented in [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md). See `src/types.ts` for the full contracts.
|
||||
The seam also owns the per-session mode override vocabulary (the sandbox RFC § Per-session mode switching): the log-only `'bash/sandbox-mode'` session event, the pure fold `effectiveSandboxMode(events)` (last event wins; `undefined` means "apply the executor default"), THE write path `setSandboxMode(session, mode)` — the session log is the store, so an override survives restart by replay and two sessions can never see each other's mode — and THE read path `resolveMode(session)` above, which folds the override and runs the `bash/resolve-mode` waterfall around it. `SANDBOX_MODES` is the narrowest→widest ladder; the ordering is part of the contract (the escalation widening check and a mode's access clamp compare by index). Writers must respect turn-enclosure: the ACP bridge anchors an idle switch at the next turn rather than appending between turns. The task id (`BashTaskId`) and the `owner` token (`OwnerToken`) are [branded](../../util/brand) — `OwnerToken` is a DISTINCT brand from `SessionId`, deliberately never unified with it (the `dsh-tool-bash` consumer is the single boundary that casts its `SessionId` into one). `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. A sandboxing executor additionally stamps `sandbox` result facts on results and settled tasks (`BashSandboxInfo`: the mode it executed under, the conservative `denied` classification, and — for confined modes — the backend's `enforcement` completeness); the mode/enforcement vocabulary is owned by the [`dsh-sandbox`](../../sandbox/sandbox/) seam, and the facts are documented in [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md). See `src/types.ts` for the full contracts.
|
||||
|
||||
`stdin` and `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload on stdin and its `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` env. The model-facing `dsh-tool-bash` tool does not expose them as parameters — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), so they would be redundant tool params. This is not a security boundary: the implementation's credential scrub (not these fields) is what keeps the harness's ambient secrets out of a spawned command. They are plain optionals on the resolved spec (unlike `owner`'s required-but-nullable): a missing one means "none", the safe default. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import { effectiveSandboxMode } from './session-mode.ts'
|
||||
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskId, BashTaskListener, BashTaskRead, OwnerToken } from './types.ts'
|
||||
|
||||
export { BashTaskId, OwnerToken } from './types.ts'
|
||||
@@ -36,6 +38,24 @@ declare module 'cordis' {
|
||||
interface Context {
|
||||
bash: BashExecutor
|
||||
}
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* Waterfall around {@link BashExecutor.resolveMode}'s base — the session's
|
||||
* standing override falling back to the executor's configured default. A
|
||||
* policy plugin narrows the resolution per call by clamping `await next()`
|
||||
* (a session mode's `access` cap is the shipped example); returning
|
||||
* without `next()` replaces the resolution outright. Dispatched only for
|
||||
* a confining executor — a never-confining one resolves `undefined`
|
||||
* without consulting listeners, so a listener always receives a real
|
||||
* base mode from `next()`.
|
||||
* @param session - the session the call belongs to (its log carries the
|
||||
* override fold and any mode state a listener clamps by); `undefined`
|
||||
* for a sessionless caller.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'bash/resolve-mode'(this: BashExecutor, session: Session | undefined, next: () => Promise<SandboxMode>): Promise<SandboxMode>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,6 +109,30 @@ export abstract class BashExecutor extends Service {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the sandbox mode a call for `session` runs under: the session's
|
||||
* standing override (the `bash/sandbox-mode` fold) falling back to this
|
||||
* executor's configured default, dispatched through the `bash/resolve-mode`
|
||||
* waterfall so policy plugins can narrow the base per call — read-time
|
||||
* composition over independent folds, nothing written back to any store.
|
||||
* Returns `undefined` — without consulting the waterfall — when this
|
||||
* executor never confines ({@link sandboxMode} `undefined`): there is no
|
||||
* mode to resolve and nothing would honor one. An escalation grant is not
|
||||
* this method's business: the tool layer resolves grants separately and
|
||||
* stamps them with higher precedence.
|
||||
* @param session - the session whose override fold applies; `undefined`
|
||||
* for a sessionless caller (the executor default alone seeds the
|
||||
* waterfall).
|
||||
* @returns the effective mode for a confining executor; `undefined` for
|
||||
* one that never confines.
|
||||
*/
|
||||
async resolveMode(session: Session | undefined): Promise<SandboxMode | undefined> {
|
||||
const fallback = this.sandboxMode
|
||||
if (fallback === undefined) return undefined
|
||||
const base = (session === undefined ? undefined : effectiveSandboxMode(session.events)) ?? fallback
|
||||
return this.ctx.waterfall(this, 'bash/resolve-mode', session, () => Promise.resolve(base))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a caller's {@link BashExecRequest} into a fully-specified
|
||||
* {@link BashExecSpec}, applying this implementation's config defaults and
|
||||
|
||||
@@ -7,10 +7,13 @@
|
||||
* other's state, and there is no external config store. The event is
|
||||
* log-only (the `approval/*` precedent): the model learns the mode from the
|
||||
* prompt section and the boundary notices in `@deepseek-ai/dsh-tool-bash`,
|
||||
* never from the event itself. EXECUTION honors the fold in the tool layer —
|
||||
* it stamps the effective mode onto each call's `BashExecRequest.sandboxMode`
|
||||
* (weakest-precedence: an escalation grant for the call outranks it) — the
|
||||
* executor itself stays a config-fixed default plus per-call overrides.
|
||||
* never from the event itself. EXECUTION honors the fold through the seam's
|
||||
* own resolution — `BashExecutor.resolveMode` computes `override ?? default`
|
||||
* and dispatches it through the `bash/resolve-mode` waterfall so policy
|
||||
* plugins can narrow it per call — and the tool layer stamps the resolved
|
||||
* mode onto each call's `BashExecRequest.sandboxMode` (weakest-precedence: an
|
||||
* escalation grant for the call outranks it); the executor itself stays a
|
||||
* config-fixed default plus per-call overrides.
|
||||
*
|
||||
* @module dsh-bash/session-mode
|
||||
*/
|
||||
@@ -32,7 +35,12 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Every {@link SandboxMode}, for option advertisement and runtime validation of untrusted mode strings. */
|
||||
/**
|
||||
* Every {@link SandboxMode}, for option advertisement and runtime validation
|
||||
* of untrusted mode strings. Ordered narrowest → widest — the ladder is part
|
||||
* of the contract; consumers (the escalation widening check, a mode's access
|
||||
* clamp) compare by index.
|
||||
*/
|
||||
export const SANDBOX_MODES: readonly SandboxMode[] = ['read-only', 'workspace-write', 'danger-full-access']
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { BashExecutor, BashTaskId, OwnerToken } from '@deepseek-ai/dsh-bash'
|
||||
import { BashExecutor, BashTaskId, OwnerToken, setSandboxMode } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead } from '@deepseek-ai/dsh-bash'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
/** Minimal concrete executor: records calls, lets tests drive completions. */
|
||||
class StubExecutor extends BashExecutor {
|
||||
@@ -149,3 +151,50 @@ describe('BashExecutor service seam', () => {
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
/** A confining stub: the same executor with a configured default sandbox mode. */
|
||||
class ConfiningStub extends StubExecutor {
|
||||
override get sandboxMode(): SandboxMode {
|
||||
return 'workspace-write'
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveMode (the bash/resolve-mode seam)', () => {
|
||||
it('resolves undefined for a never-confining executor without consulting the waterfall', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
const listener = vi.fn()
|
||||
ctx.on('bash/resolve-mode', listener)
|
||||
expect(await bash.resolveMode(new Session(SessionId('rm-none')))).toBeUndefined()
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves the executor default without a session and without an override', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ConfiningStub)
|
||||
expect(await ctx.bash.resolveMode(undefined)).toBe('workspace-write')
|
||||
expect(await ctx.bash.resolveMode(new Session(SessionId('rm-default')))).toBe('workspace-write')
|
||||
})
|
||||
|
||||
it('resolves the session override over the executor default', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ConfiningStub)
|
||||
const session = new Session(SessionId('rm-override'))
|
||||
setSandboxMode(session, 'danger-full-access')
|
||||
expect(await ctx.bash.resolveMode(session)).toBe('danger-full-access')
|
||||
})
|
||||
|
||||
it('a waterfall listener narrows the base per call and sees the session', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ConfiningStub)
|
||||
const session = new Session(SessionId('rm-clamp'))
|
||||
setSandboxMode(session, 'danger-full-access')
|
||||
const seen: (Session | undefined)[] = []
|
||||
ctx.on('bash/resolve-mode', async (sess, next) => {
|
||||
seen.push(sess)
|
||||
await next()
|
||||
return 'read-only'
|
||||
})
|
||||
expect(await ctx.bash.resolveMode(session)).toBe('read-only')
|
||||
expect(seen).toEqual([session])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -46,7 +46,10 @@
|
||||
* Per-session mode switching (the sandbox RFC § Per-session mode switching): a session may carry a
|
||||
* standing sandbox-mode override — the `bash/sandbox-mode` event fold from
|
||||
* `@deepseek-ai/dsh-bash` — which this plugin makes real at EXECUTION: each
|
||||
* call is stamped `escalation grant > session override > executor default`.
|
||||
* call is stamped `escalation grant > ctx.bash.resolveMode()` (the seam's
|
||||
* resolution: session override ?? executor default, run through the
|
||||
* `bash/resolve-mode` waterfall so policy plugins — e.g. a session mode's
|
||||
* `access` cap — narrow it per call).
|
||||
* The prompt deliberately does NOT state the mode and no switch is narrated:
|
||||
* the model learns the boundary from the denial marker (which names the mode
|
||||
* it ran under) exactly when it matters, instead of preemptively refusing
|
||||
@@ -67,7 +70,7 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
// stays optional at runtime, same pattern as dsh-tools' ask routing).
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import { BashTaskId, OwnerToken, effectiveSandboxMode } from '@deepseek-ai/dsh-bash'
|
||||
import { BashTaskId, OwnerToken } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashRunResult, BashTask, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
export const name = 'tool-bash'
|
||||
@@ -480,19 +483,6 @@ export function apply(ctx: Context): void {
|
||||
const defaultMode = ctx.bash.sandboxMode
|
||||
const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS
|
||||
|
||||
/**
|
||||
* The session's standing mode override for an ordinary (non-escalating)
|
||||
* call: the `bash/sandbox-mode` fold of the calling agent's log, stamped
|
||||
* onto the request so EXECUTION follows the same effective mode the prompt
|
||||
* section states. Weakest precedence — an escalation grant (freshly
|
||||
* approved for exactly this call) outranks it, and without either the
|
||||
* executor's `resolve()` applies its configured default. Undefined for a
|
||||
* non-sandboxing executor (nothing honors it) and for agent-less callers
|
||||
* (no session to fold).
|
||||
*/
|
||||
const sessionOverride = (exec: ToolExecution): SandboxMode | undefined =>
|
||||
defaultMode === undefined || exec.agent === undefined ? undefined : effectiveSandboxMode(exec.agent.session.events)
|
||||
|
||||
/**
|
||||
* Resolve a sandbox-escalation request through `ctx.approval` BEFORE
|
||||
* anything executes. Returns the granted mode to stamp onto the bash
|
||||
@@ -513,12 +503,14 @@ export function apply(ctx: Context): void {
|
||||
throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)')
|
||||
}
|
||||
// Strict widening is an EXECUTION check against the call's effective
|
||||
// mode — session override ?? executor default, the same fold ordinary
|
||||
// mode — the seam's resolveMode (session override ?? executor default,
|
||||
// through the bash/resolve-mode waterfall), the same resolution ordinary
|
||||
// calls are stamped with — deliberately not a schema constraint (the
|
||||
// enum is the closed target vocabulary; the effective mode is per-call
|
||||
// truth). A non-widening request fails closed here and never prompts a
|
||||
// human.
|
||||
const effectiveMode = (sessionOverride(exec) ?? defaultMode) as SandboxMode
|
||||
// human. The cast is exact: escalationModes non-empty proved the executor
|
||||
// confines, which is resolveMode's only undefined path.
|
||||
const effectiveMode = (await ctx.bash.resolveMode(exec.agent?.session)) as SandboxMode
|
||||
if (!(WIDER_MODES[effectiveMode] ?? []).includes(mode as SandboxMode)) {
|
||||
throw new Error(`sandbox escalation to "${mode}" is not strictly wider than this call's current "${effectiveMode}" mode`)
|
||||
}
|
||||
@@ -586,11 +578,13 @@ export function apply(ctx: Context): void {
|
||||
// An escalating call resolves approval BEFORE anything executes; every
|
||||
// non-grant outcome throws its distinct error text and runs nothing.
|
||||
// (validateBashArgs pinned the pairing, so the double narrow is exact.)
|
||||
// An ordinary call carries the session's standing override instead —
|
||||
// grant > session override > executor default (see sessionOverride).
|
||||
// An ordinary call carries the seam's resolution instead — grant >
|
||||
// ctx.bash.resolveMode() (session override ?? executor default, run
|
||||
// through the bash/resolve-mode waterfall); undefined — stamp nothing —
|
||||
// for a never-confining executor.
|
||||
const sandboxMode = args.sandbox_permissions !== undefined && args.justification !== undefined
|
||||
? await approveEscalation(args.sandbox_permissions, args.justification, exec)
|
||||
: sessionOverride(exec)
|
||||
: await ctx.bash.resolveMode(exec.agent?.session)
|
||||
// Default the workdir to the calling agent's session cwd so each ACP
|
||||
// session runs in its own workspace (see resolveWorkdir); an explicit
|
||||
// model workdir still wins.
|
||||
|
||||
@@ -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