feat(ptc): expose per-program timeout and sandbox approval controls
This commit is contained in:
1 parent
c5de252f8c
commit
a248cc4e64
5 files changed
+234
-5
No files matched your search
@@ -93,6 +93,10 @@ export class NodeCodeRuntime extends CodeRuntime {
|
||||
|
||||
override get sandboxMode(): SandboxMode { return this.ctx.sandboxPolicy.defaultMode }
|
||||
|
||||
override get timeout(): { defaultMs: number; maxMs: number } {
|
||||
return { defaultMs: Math.min(this.config.timeoutMs, this.config.maxTimeoutMs), maxMs: this.config.maxTimeoutMs }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an execution under explicit or deployment policy.
|
||||
* @param request - Program, bindings, optional cwd/deadline, and resolved authority.
|
||||
|
||||
@@ -124,6 +124,9 @@ export abstract class CodeRuntime extends Service {
|
||||
/** Deployment file-policy mode, or undefined for a provider without confinement support. */
|
||||
get sandboxMode(): SandboxMode | undefined { return undefined }
|
||||
|
||||
/** Configured elapsed-time defaults and cap, or undefined when per-call overrides are unsupported. */
|
||||
get timeout(): { defaultMs: number; maxMs: number } | undefined { return undefined }
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'codeRuntime')
|
||||
}
|
||||
|
||||
@@ -916,6 +916,7 @@ export class ToolRuntime extends Service {
|
||||
private requireCodeTransport(): ToolDefinition {
|
||||
this.ptcTransport ??= createRunCodeTool(this, {
|
||||
requireRuntime: () => this.requireCodeRuntime(this.defaultMode),
|
||||
peekApprover: () => this.ctx.get('approval'),
|
||||
resolveSandboxPolicy: (exec) => {
|
||||
const policy = this.ctx.get('sandboxPolicy')
|
||||
if (policy === undefined) throw new Error('dsh-tools: confined code runtime requires sandboxPolicy')
|
||||
|
||||
@@ -10,7 +10,9 @@ import { brandString } from '@deepseek-ai/dsh-brand'
|
||||
import { createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { CodeBindingFunction, CodeRunResult, CodeRunSandbox, CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
import { approveEscalation, ESCALATION_TARGETS, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ApprovalService } from '@deepseek-ai/dsh-user-approval'
|
||||
import { snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import { defineTool, parameterSchemaSpecToJsonSchema } from './schema.ts'
|
||||
import { TOOL_RUNTIME_SCHEDULER } from './index.ts'
|
||||
@@ -96,6 +98,30 @@ const RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION
|
||||
+ '5-10 words (shown in the UI). Examples: "Count TODO markers across packages"; '
|
||||
+ '"Read failing test and its fixture"; "Rename config key in every cordis.yml".'
|
||||
|
||||
const RUN_CODE_CONTROLS = {
|
||||
timeoutMs: { type: 'number', description: 'Positive elapsed-time budget in milliseconds, capped by the deployment maximum.' },
|
||||
sandbox_permissions: { type: 'string', enum: [...ESCALATION_TARGETS], description: 'Wider sandbox mode for this complete program execution; requires justification and approval.' },
|
||||
justification: { type: 'string', description: 'Reason this complete program needs wider access, shown to the user for approval.' },
|
||||
} as const
|
||||
|
||||
function controlParameters(runtime: CodeRuntime | undefined) {
|
||||
return {
|
||||
...runtime?.timeout === undefined ? {} : {
|
||||
timeoutMs: { ...RUN_CODE_CONTROLS.timeoutMs,
|
||||
description: `Positive elapsed-time budget in milliseconds, including nested tool and approval waits. Default ${runtime.timeout.defaultMs}; capped at ${runtime.timeout.maxMs}. Zero does not disable the deadline.` },
|
||||
},
|
||||
...runtime?.sandboxMode === undefined ? {} : {
|
||||
sandbox_permissions: RUN_CODE_CONTROLS.sandbox_permissions,
|
||||
justification: RUN_CODE_CONTROLS.justification,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function escalationGuidance(runtime: CodeRuntime | undefined): string {
|
||||
return runtime?.sandboxMode === undefined ? ''
|
||||
: ' A sandbox escalation approves this complete program for one execution only. Nested tools retain their own policies and approvals. Request wider access only after evidence of a denial. Earlier effects may already have completed: inspect them before explicitly retrying. Programs are never replayed automatically.'
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the {@link RunCodeFlavor} for the loaded runtime's language, read at
|
||||
* schema-emission time so the model-visible `run_code` schema always matches
|
||||
@@ -265,6 +291,8 @@ type RunCodeOutput = { logs: string[]; result?: JsonValue; sandbox?: CodeRunSand
|
||||
* off its public service API and flow here as closures instead.
|
||||
*/
|
||||
export interface RunCodeBridgeOptions {
|
||||
/** Reads the approval channel when a program requests a wider sandbox mode. */
|
||||
peekApprover: () => ApprovalService | undefined
|
||||
/** Resolves standing Session authority only for a runtime that enforces file policy. */
|
||||
resolveSandboxPolicy: (exec: ToolRunContext) => SandboxExecutionPolicy
|
||||
/** Resolves `ctx.codeRuntime` or throws the loud misconfiguration error (shared with the registry's assembly-time checks). */
|
||||
@@ -311,6 +339,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO
|
||||
required: true,
|
||||
description: RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION,
|
||||
},
|
||||
...RUN_CODE_CONTROLS,
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
@@ -334,7 +363,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO
|
||||
const rendered = value.result === undefined ? '' : renderValue(value.result)
|
||||
const parts = [value.logs.join('\n'), rendered].filter(part => part.length > 0)
|
||||
if (value.sandbox?.enforcement === 'partial') parts.push('File sandbox enforcement is partial on this host.')
|
||||
if (value.sandbox?.denied) parts.push(`The ${value.sandbox.mode} file sandbox denied an operation.`)
|
||||
if (value.sandbox?.denied) parts.push(`The ${value.sandbox.mode} file sandbox denied an operation.${escalationGuidance(peekRuntime())}`)
|
||||
return [{ type: 'text', text: parts.length > 0 ? parts.join('\n') : '(run_code completed with no output)' }]
|
||||
},
|
||||
},
|
||||
@@ -343,6 +372,29 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO
|
||||
throw new Error('invalid description: expected a non-empty string')
|
||||
}
|
||||
const runtime = requireRuntime()
|
||||
validateEscalationArgs(args.sandbox_permissions, args.justification)
|
||||
if (args.timeoutMs !== undefined && runtime.timeout === undefined) {
|
||||
throw new Error('timeoutMs is not available for this code runtime')
|
||||
}
|
||||
if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
|
||||
throw new Error('invalid timeoutMs: expected a positive finite number')
|
||||
}
|
||||
const standingPolicy = runtime.sandboxMode === undefined ? undefined : options.resolveSandboxPolicy(exec)
|
||||
let policy = standingPolicy
|
||||
if (args.sandbox_permissions !== undefined && args.justification !== undefined) {
|
||||
if (standingPolicy === undefined) throw new Error('sandbox_permissions is not available for this code runtime')
|
||||
const approvedMode = await approveEscalation({
|
||||
requestedMode: args.sandbox_permissions,
|
||||
justification: args.justification,
|
||||
effectiveMode: standingPolicy.mode,
|
||||
subject: 'program',
|
||||
}, {
|
||||
approver: options.peekApprover(), agent: exec.agent, callId: exec.callId,
|
||||
toolName: RUN_CODE_NAME, signal: exec.signal,
|
||||
})
|
||||
policy = { ...standingPolicy, mode: approvedMode }
|
||||
}
|
||||
exec.signal.throwIfAborted()
|
||||
|
||||
// The run-scoped abort: follows the outer signal in, and fires when the
|
||||
// run settles for ANY reason, so an in-flight sub-dispatch is aborted
|
||||
@@ -639,7 +691,8 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO
|
||||
}],
|
||||
signal: runController.signal,
|
||||
...exec.agent?.session.header.cwd !== undefined ? { cwd: exec.agent.session.header.cwd } : {},
|
||||
...runtime.sandboxMode !== undefined ? { sandboxPolicy: options.resolveSandboxPolicy(exec) } : {},
|
||||
...policy !== undefined ? { sandboxPolicy: policy } : {},
|
||||
...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
|
||||
}))
|
||||
} finally {
|
||||
// Abort sub-dispatches and drain every in-flight dispatch before
|
||||
@@ -653,7 +706,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO
|
||||
const logsText = result.logs.length > 0 ? `\nCaptured output:\n${result.logs.join('\n')}` : ''
|
||||
const sandboxText = result.sandbox === undefined ? ''
|
||||
: `\nFile sandbox: ${result.sandbox.mode}${result.sandbox.enforcement === undefined ? '' : `; enforcement: ${result.sandbox.enforcement}`}${result.sandbox.denied ? '; operation denied' : ''}.`
|
||||
throw new CodeRunFailedError(`code run failed (${result.error.kind}): ${result.error.message}${logsText}${sandboxText}`)
|
||||
throw new CodeRunFailedError(`code run failed (${result.error.kind}): ${result.error.message}${logsText}${sandboxText}${result.sandbox?.denied ? escalationGuidance(runtime) : ''}`)
|
||||
}
|
||||
return {
|
||||
logs: result.logs,
|
||||
@@ -682,7 +735,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO
|
||||
// is the least invasive point that still emits the loaded runtime's language.
|
||||
Object.defineProperty(definition, 'description', {
|
||||
enumerable: true,
|
||||
get: () => resolveFlavor(peekRuntime).description,
|
||||
get: () => resolveFlavor(peekRuntime).description + escalationGuidance(peekRuntime()),
|
||||
})
|
||||
Object.defineProperty(definition, 'parameters', {
|
||||
enumerable: true,
|
||||
@@ -691,6 +744,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO
|
||||
get: () => parameterSchemaSpecToJsonSchema({
|
||||
code: { type: 'string', required: true, description: resolveFlavor(peekRuntime).codeDescription },
|
||||
description: { type: 'string', required: true, description: RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION },
|
||||
...controlParameters(peekRuntime()),
|
||||
}) as unknown as Record<string, unknown>,
|
||||
})
|
||||
return definition
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
@@ -10,6 +10,7 @@ import ToolRuntime, { CodeRunFailedError, RUN_CODE_NAME, TOOL_ABORTED_BEFORE_DIS
|
||||
import type { Config, JsonSchemaNode, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
|
||||
import type { SessionEventMap } from '@deepseek-ai/dsh-session'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import SandboxPolicy from '@deepseek-ai/dsh-sandbox-policy'
|
||||
@@ -1968,3 +1969,169 @@ describe('PTC standing file policy and sandbox outcomes', () => {
|
||||
} finally { await ctx.fiber.dispose() }
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-program execution controls', () => {
|
||||
async function controlledSetup(approval = true) {
|
||||
const state = await setup()
|
||||
await state.ctx.plugin(SessionProjections)
|
||||
await state.ctx.plugin(SandboxPolicy, { mode: 'read-only', workspaceRoot: process.cwd() })
|
||||
if (approval) await state.ctx.plugin(ApprovalService, { policy: 'ask' })
|
||||
Object.defineProperties(state.runtime, {
|
||||
sandboxMode: { get: () => 'read-only' },
|
||||
timeout: { get: () => ({ defaultMs: 120_000, maxMs: 600_000 }) },
|
||||
})
|
||||
const session = Session.create(SessionId('program-controls'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const agent = { session } as unknown as Agent
|
||||
const execute = (args: Record<string, unknown>, signal = testToolSignal) => state.tools.execute({
|
||||
callId: ToolCallId('program-controls'), name: RUN_CODE_NAME,
|
||||
arguments: { code: 'program', description: 'Test execution controls', ...args }, agent, signal,
|
||||
})
|
||||
return { ...state, agent, session, execute }
|
||||
}
|
||||
|
||||
it('advertises configured timeout values and one-execution scope only when supported', async () => {
|
||||
const { ctx, tools } = await controlledSetup()
|
||||
try {
|
||||
const schema = tools.schemas().find(tool => tool.name === RUN_CODE_NAME)!
|
||||
expect(JSON.stringify(schema.parameters)).toContain('Default 120000; capped at 600000')
|
||||
expect(JSON.stringify(schema.parameters)).toContain('sandbox_permissions')
|
||||
expect(schema.description).toContain('Nested tools retain their own policies')
|
||||
} finally { await ctx.fiber.dispose() }
|
||||
const python = await setup({ runtime: { language: 'python' } })
|
||||
try {
|
||||
const schema = python.tools.schemas().find(tool => tool.name === RUN_CODE_NAME)!
|
||||
expect(JSON.stringify(schema.parameters)).not.toContain('timeoutMs')
|
||||
expect(JSON.stringify(schema.parameters)).not.toContain('sandbox_permissions')
|
||||
const rejected = await python.tools.execute({
|
||||
callId: ToolCallId('hidden-timeout'), name: RUN_CODE_NAME, signal: testToolSignal,
|
||||
arguments: { code: 'pass', description: 'Try unsupported timeout', timeoutMs: 5 },
|
||||
})
|
||||
expect(rejected.isError).toBe(true)
|
||||
expect(python.runtime.lastRequest).toBeUndefined()
|
||||
} finally { await python.ctx.fiber.dispose() }
|
||||
})
|
||||
|
||||
it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY, '1000'])('rejects invalid timeout %s before runtime execution', async (timeoutMs) => {
|
||||
const { ctx, runtime, execute } = await controlledSetup()
|
||||
try {
|
||||
expect((await execute({ timeoutMs })).isError).toBe(true)
|
||||
expect(runtime.lastRequest).toBeUndefined()
|
||||
} finally { await ctx.fiber.dispose() }
|
||||
})
|
||||
|
||||
it('forwards an explicit timeout to the runtime resolver', async () => {
|
||||
const { ctx, runtime, execute } = await controlledSetup()
|
||||
const resolver = vi.spyOn(runtime, 'resolve')
|
||||
try {
|
||||
expect((await execute({ timeoutMs: 900_000 })).isError).toBe(false)
|
||||
expect(resolver.mock.calls[0]?.[0].timeoutMs).toBe(900_000)
|
||||
} finally { await ctx.fiber.dispose() }
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ sandbox_permissions: 'workspace-write' },
|
||||
{ justification: 'Need writes' },
|
||||
{ sandbox_permissions: 'workspace-write', justification: ' ' },
|
||||
{ sandbox_permissions: 'read-only', justification: 'No widening' },
|
||||
])('rejects invalid escalation pairing or mode: %j', async (args) => {
|
||||
const { ctx, runtime, execute } = await controlledSetup()
|
||||
const ask = vi.fn(() => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
ctx.on('approval/request', ask)
|
||||
try {
|
||||
expect((await execute(args)).isError).toBe(true)
|
||||
expect(ask).not.toHaveBeenCalled()
|
||||
expect(runtime.lastRequest).toBeUndefined()
|
||||
} finally { await ctx.fiber.dispose() }
|
||||
})
|
||||
|
||||
it.each(['rejected', 'cancelled', 'unavailable'] as const)('does not start a program after approval returns %s', async (outcome) => {
|
||||
const { ctx, runtime, session, execute } = await controlledSetup()
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>(outcome))
|
||||
try {
|
||||
expect((await execute({ sandbox_permissions: 'workspace-write', justification: 'Write generated source' })).isError).toBe(true)
|
||||
expect(runtime.lastRequest).toBeUndefined()
|
||||
expect(session.snapshotEvents().filter(event => event.type === 'approval/decided').map(event => event.data)).toMatchObject([{ outcome }])
|
||||
} finally { await ctx.fiber.dispose() }
|
||||
})
|
||||
|
||||
it('fails closed when no approval service is mounted', async () => {
|
||||
const { ctx, runtime, execute } = await controlledSetup(false)
|
||||
try {
|
||||
const result = await execute({ sandbox_permissions: 'workspace-write', justification: 'Write generated source' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(runtime.lastRequest).toBeUndefined()
|
||||
} finally { await ctx.fiber.dispose() }
|
||||
})
|
||||
|
||||
it('cancels an outstanding approval without launching the program', async () => {
|
||||
const { ctx, runtime, execute } = await controlledSetup()
|
||||
const asked = Promise.withResolvers<undefined>()
|
||||
const answer = Promise.withResolvers<ApprovalOutcome>()
|
||||
const controller = new AbortController()
|
||||
ctx.on('approval/request', () => { asked.resolve(undefined); return answer.promise })
|
||||
const result = execute({ sandbox_permissions: 'workspace-write', justification: 'Write generated source' }, controller.signal)
|
||||
try {
|
||||
await asked.promise
|
||||
controller.abort('cancel outer approval')
|
||||
expect((await result).isError).toBe(true)
|
||||
expect(runtime.lastRequest).toBeUndefined()
|
||||
} finally { answer.resolve('cancelled'); await result; await ctx.fiber.dispose() }
|
||||
})
|
||||
|
||||
it('reports prior effects after a denial and never requests approval or replays implicitly', async () => {
|
||||
const { ctx, runtime, execute } = await controlledSetup()
|
||||
const ask = vi.fn(() => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
ctx.on('approval/request', ask)
|
||||
let effects = 0
|
||||
runtime.behavior = () => {
|
||||
effects++
|
||||
return Promise.resolve({ logs: ['first effect completed'],
|
||||
error: { kind: 'exception', message: 'access denied' },
|
||||
sandbox: { mode: 'read-only', denied: true, enforcement: 'full' } })
|
||||
}
|
||||
try {
|
||||
const result = await execute({})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(JSON.stringify(result.content)).toContain('first effect completed')
|
||||
expect(JSON.stringify(result.content)).toContain('Earlier effects may already have completed')
|
||||
expect(effects).toBe(1)
|
||||
expect(ask).not.toHaveBeenCalled()
|
||||
} finally { await ctx.fiber.dispose() }
|
||||
})
|
||||
|
||||
it('waits for outer approval before resolving execution and keeps nested tool approvals independent', async () => {
|
||||
const { ctx, runtime, agent, execute } = await controlledSetup()
|
||||
const asked = Promise.withResolvers<undefined>()
|
||||
const answer = Promise.withResolvers<ApprovalOutcome>()
|
||||
const requests: ApprovalRequest[] = []
|
||||
ctx.on('approval/request', (req) => {
|
||||
requests.push(req)
|
||||
if (req.toolName === RUN_CODE_NAME) { asked.resolve(undefined); return answer.promise }
|
||||
return Promise.resolve<ApprovalOutcome>('rejected')
|
||||
})
|
||||
registerEcho(ctx)
|
||||
ctx.on('tools/pre-execute', (exec, next) => exec.name === 'echo'
|
||||
? Promise.resolve({ kind: 'ask', reason: 'Nested tool has its own approval' }) : next())
|
||||
runtime.behavior = async (request) => {
|
||||
expect(request.sandboxPolicy?.mode).toBe('workspace-write')
|
||||
expect(ctx.sandboxPolicy.resolve({ session: agent.session }).mode).toBe('read-only')
|
||||
const value = await request.bindings[0]!.functions.echo!({ value: 'nested' })
|
||||
.then(() => 'unexpected nested grant', () => 'nested approval rejected')
|
||||
return { logs: [], value }
|
||||
}
|
||||
const resolver = vi.spyOn(runtime, 'resolve')
|
||||
const result = execute({ sandbox_permissions: 'workspace-write', justification: 'Write generated source', timeoutMs: 1234 })
|
||||
try {
|
||||
await asked.promise
|
||||
expect(resolver).not.toHaveBeenCalled()
|
||||
expect(runtime.lastRequest).toBeUndefined()
|
||||
answer.resolve('allowed-once')
|
||||
const completed = await result
|
||||
expect(completed.isError).toBe(false)
|
||||
expect(requests.map(req => req.toolName)).toEqual([RUN_CODE_NAME, 'echo'])
|
||||
expect(runtime.lastRequest?.timeoutMs).toBe(1234)
|
||||
expect(ctx.sandboxPolicy.resolve({ session: agent.session }).mode).toBe('read-only')
|
||||
} finally { answer.resolve('cancelled'); await result; await ctx.fiber.dispose() }
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user