diff --git a/packages/code-runtime/code-runtime-node/src/index.ts b/packages/code-runtime/code-runtime-node/src/index.ts index 3c1b8c6132..06539ed0b5 100644 --- a/packages/code-runtime/code-runtime-node/src/index.ts +++ b/packages/code-runtime/code-runtime-node/src/index.ts @@ -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. diff --git a/packages/code-runtime/code-runtime/src/index.ts b/packages/code-runtime/code-runtime/src/index.ts index 3cf90313bd..e0eebae9ca 100644 --- a/packages/code-runtime/code-runtime/src/index.ts +++ b/packages/code-runtime/code-runtime/src/index.ts @@ -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') } diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 80e0985161..47dad9bb02 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -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') diff --git a/packages/core/tools/src/ptc.ts b/packages/core/tools/src/ptc.ts index f76f2bfff9..17f4f73623 100644 --- a/packages/core/tools/src/ptc.ts +++ b/packages/core/tools/src/ptc.ts @@ -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, }) return definition diff --git a/packages/core/tools/tests/ptc.spec.ts b/packages/core/tools/tests/ptc.spec.ts index d893d2f5cf..d186c5db5b 100644 --- a/packages/core/tools/tests/ptc.spec.ts +++ b/packages/core/tools/tests/ptc.spec.ts @@ -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, 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('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(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() + const answer = Promise.withResolvers() + 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('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() + const answer = Promise.withResolvers() + 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('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() } + }) +})