feat(ptc): expose per-program timeout and sandbox approval controls

This commit is contained in:
Tianyi Cui committed 2026-09-12 15:03:02 +08:00
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')
}
+1
View File
@@ -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')
+58 -4
View File
@@ -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
+168 -1
View File
@@ -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() }
})
})