fix(sandbox): resolve workspace roots per session
This commit is contained in:
@@ -25,7 +25,8 @@ import { STREAM_MIN_SIZE } from '../src/read.ts'
|
||||
import { formatReadOutput } from '../src/read-render.ts'
|
||||
import type { FileReadOutcome } from '../src/read-render.ts'
|
||||
import ApprovalService from '@deepseek-ai/dsh-user-approval'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
|
||||
|
||||
/** An in-memory fake provider; a test can arm a rejection on any primitive. */
|
||||
class FakeFs extends FileSystem {
|
||||
@@ -584,9 +585,9 @@ describe('read caps are plugin config', () => {
|
||||
})
|
||||
|
||||
describe('sandbox escalation surface (write/edit)', () => {
|
||||
/** A confining fake `ctx.fs`: reports a default mode, records the per-call mode stamped, and can arm a sandbox denial. */
|
||||
/** A confining fake `ctx.fs`: reports a default mode, records each per-call policy, and can arm a sandbox denial. */
|
||||
class SandboxingFakeFs extends FakeFs {
|
||||
stamped: (SandboxMode | undefined)[] = []
|
||||
stamped: (SandboxExecutionPolicy | undefined)[] = []
|
||||
override get sandboxMode(): SandboxMode {
|
||||
return 'workspace-write'
|
||||
}
|
||||
@@ -595,9 +596,9 @@ describe('sandbox escalation surface (write/edit)', () => {
|
||||
content: string,
|
||||
expected?: FsWriteIntent,
|
||||
_signal?: AbortSignal,
|
||||
sandboxMode?: SandboxMode,
|
||||
sandboxPolicy?: SandboxExecutionPolicy,
|
||||
): Promise<FsWriteOutcome> {
|
||||
this.stamped.push(sandboxMode)
|
||||
this.stamped.push(sandboxPolicy)
|
||||
return super.writeText(target, content, expected)
|
||||
}
|
||||
override async editText(
|
||||
@@ -605,9 +606,9 @@ describe('sandbox escalation surface (write/edit)', () => {
|
||||
edit: FsEditRequest,
|
||||
expected?: { version: FsVersion },
|
||||
_signal?: AbortSignal,
|
||||
sandboxMode?: SandboxMode,
|
||||
sandboxPolicy?: SandboxExecutionPolicy,
|
||||
): Promise<FsEditOutcome> {
|
||||
this.stamped.push(sandboxMode)
|
||||
this.stamped.push(sandboxPolicy)
|
||||
return super.editText(target, edit, expected)
|
||||
}
|
||||
}
|
||||
@@ -616,6 +617,7 @@ describe('sandbox escalation surface (write/edit)', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write' })
|
||||
await ctx.plugin(SandboxingFakeFs)
|
||||
await ctx.plugin(FsPolicy)
|
||||
if (opts.approval === true) await ctx.plugin(ApprovalService)
|
||||
@@ -628,7 +630,7 @@ describe('sandbox escalation surface (write/edit)', () => {
|
||||
return {
|
||||
id: 'agent-fs-esc',
|
||||
session: {
|
||||
header: { version: 0, id: 'sess-fs-esc', createdAt: 0 },
|
||||
header: { version: 0, id: 'sess-fs-esc', createdAt: 0, cwd: '/session-project' },
|
||||
events: [{ type: 'turn/start' }, ...events],
|
||||
append: (type: string, data: Record<string, unknown>) => { events.push({ type, data }) },
|
||||
},
|
||||
@@ -641,6 +643,14 @@ describe('sandbox escalation surface (write/edit)', () => {
|
||||
return schema as unknown as { parameters: { properties: Record<string, { enum?: string[] }> } }
|
||||
}
|
||||
|
||||
it('fails load when a confining filesystem has no shared sandbox-policy resolver', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(SandboxingFakeFs)
|
||||
await expect(ctx.plugin(ToolFs)).rejects.toThrow('tool-fs: the mounted filesystem confines but ctx.sandboxPolicy is missing')
|
||||
})
|
||||
|
||||
it('advertises no escalation fields under a non-confining backend', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(ctx.fs.sandboxMode).toBeUndefined()
|
||||
@@ -660,16 +670,16 @@ describe('sandbox escalation surface (write/edit)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('a plain write stamps nothing (backend default) and no session override folds without one', async () => {
|
||||
it('a plain write stamps the default mode with the calling session root', async () => {
|
||||
const { ctx, fs } = await setupConfining()
|
||||
await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent())
|
||||
expect(fs.stamped).toEqual([undefined])
|
||||
expect(fs.stamped).toEqual([{ mode: 'workspace-write', workspaceRoot: '/session-project' }])
|
||||
})
|
||||
|
||||
it('a standing session override folds onto the stamp', async () => {
|
||||
const { ctx, fs } = await setupConfining()
|
||||
await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent([{ type: 'sandbox/mode', data: { mode: 'read-only' } }]))
|
||||
expect(fs.stamped).toEqual(['read-only'])
|
||||
expect(fs.stamped).toEqual([{ mode: 'read-only', workspaceRoot: '/session-project' }])
|
||||
})
|
||||
|
||||
it('a denied write maps to the shared marker plus the escalation hint (isError)', async () => {
|
||||
@@ -702,7 +712,7 @@ describe('sandbox escalation surface (write/edit)', () => {
|
||||
agent: escalationAgent() as never,
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
expect(fs.stamped).toEqual(['danger-full-access'])
|
||||
expect(fs.stamped).toEqual([{ mode: 'danger-full-access', workspaceRoot: '/session-project' }])
|
||||
})
|
||||
|
||||
it('a rejected escalation fails closed with its own text and never mutates', async () => {
|
||||
|
||||
Reference in New Issue
Block a user