diff --git a/packages/core/src/__tests__/sandbox-boundary.test.ts b/packages/core/src/__tests__/sandbox-boundary.test.ts index 500fbe00d1..9207cee161 100644 --- a/packages/core/src/__tests__/sandbox-boundary.test.ts +++ b/packages/core/src/__tests__/sandbox-boundary.test.ts @@ -26,8 +26,11 @@ import { decodeExecutionBoundary, executionBoundaryContains, executionBoundaryDisplayMode, + projectSandboxBoundaryNegotiation as projectSandboxBoundaryNegotiationImpl, + type SandboxBoundaryRequest, validateSandboxBoundaryExpansion, } from '../sandbox-boundary.js'; +import type { RuntimeEvent } from '../runtime-event.js'; import { canReadPath, canWritePath, @@ -37,6 +40,11 @@ import { type PermissionProfileManaged, } from '../permission-profile.js'; +const projectSandboxBoundaryNegotiation = ( + events: readonly RuntimeEvent[], + durableRequests: readonly SandboxBoundaryRequest[] = [], +) => projectSandboxBoundaryNegotiationImpl(events, durableRequests); + describe('executionBoundaryDisplayMode', () => { test('keeps the read-only/writable distinction the boundary carries (#1611)', () => { assert.strictEqual( @@ -387,6 +395,490 @@ describe('SandboxBoundaryExpansion', () => { }); }); +describe('projectSandboxBoundaryNegotiation', () => { + const base = (id: string, partial: Partial): RuntimeEvent => ({ + id, + invocationId: 'invocation-1', + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-1', + ts: 1, + partial: false, + role: 'system', + author: 'system', + ...partial, + }); + + const request = (id: string, requestId: string, toolUseId: string): RuntimeEvent => + base(id, { + refs: { toolCallId: toolUseId }, + actions: { + stateDelta: { + sandboxBoundaryRequest: { + requestId, + toolUseId, + justification: 'Need the smallest boundary expansion.', + expansion: { network: { enabled: true } }, + }, + }, + }, + }); + + const decision = ( + id: string, + requestId: string, + toolUseId: string, + status: 'approved' | 'denied' | 'conflict', + ): RuntimeEvent => + base(id, { + author: 'user', + refs: { toolCallId: toolUseId }, + actions: { + stateDelta: { + sandboxBoundaryDecision: { + requestId, + decision: status === 'denied' ? 'deny' : 'allow', + status, + revision: status === 'approved' ? 1 : 0, + }, + }, + }, + }); + + const durableRequest = ( + requestId: string, + status: SandboxBoundaryRequest['status'], + ): SandboxBoundaryRequest => ({ + sessionId: 'session-1', + requestId, + status, + baseRevision: 0, + expansion: { network: { enabled: true } }, + justification: 'Need network access.', + createdAt: 1, + settledAt: 2, + ...(status === 'approved' ? { appliedRevision: 1 } : {}), + turnId: 'turn-1', + runId: 'run-1', + }); + + const failurePair = ( + id: string, + toolName: string, + toolCallId: string, + reason: 'invalid_boundary_declaration' | 'sandbox_boundary_required', + hidden = false, + ): RuntimeEvent[] => [ + base(`${id}-call`, { + role: 'model', + author: 'agent', + ...(hidden ? { modelVisibility: 'hidden' as const } : {}), + refs: { toolCallId, stepId: `${id}-step` }, + content: { + kind: 'function_call', + id: toolCallId, + name: toolName, + args: toolName === 'Bash' ? { boundary_intent: 'expand' } : {}, + }, + }), + base(`${id}-response`, { + role: 'tool', + author: 'tool', + ...(hidden ? { modelVisibility: 'hidden' as const } : {}), + refs: { toolCallId, stepId: `${id}-step` }, + content: { + kind: 'function_response', + id: toolCallId, + name: toolName, + isError: true, + result: { + kind: 'text', + text: 'Sandbox boundary correction failed.', + sandboxFailure: { reason }, + }, + }, + }), + ]; + + test('restores denial and both correction budgets, including hidden Code Mode calls', () => { + const events = [ + request('request-1', 'boundary-1', 'tool-1'), + decision('decision-1', 'boundary-1', 'tool-1', 'denied'), + ...failurePair( + 'invalid-1', + 'request_sandbox_boundary', + 'tool-2', + 'invalid_boundary_declaration', + ), + ...failurePair('unresolved-1', 'Bash', 'tool-3', 'sandbox_boundary_required', true), + ]; + + assert.deepEqual(projectSandboxBoundaryNegotiation(events), { + kind: 'valid', + state: { + denied: true, + invalidRounds: 0, + unresolvedRounds: 0, + finalizationRequested: true, + }, + }); + }); + + test('counts nested Code Mode boundary failures once per parent tool step', () => { + const events = ['one', 'two', 'three'].flatMap((id) => { + const toolCallId = `nested-${id}`; + const refs = { toolCallId, parentToolCallId: 'code-cell-1' }; + return [ + base(`${id}-call`, { + role: 'model', + author: 'agent', + modelVisibility: 'hidden', + refs, + content: { + kind: 'function_call', + id: toolCallId, + name: 'Bash', + args: { boundary_intent: 'expand' }, + }, + }), + base(`${id}-response`, { + role: 'tool', + author: 'tool', + modelVisibility: 'hidden', + refs, + content: { + kind: 'function_response', + id: toolCallId, + name: 'Bash', + isError: true, + result: { + kind: 'text', + text: 'Sandbox boundary correction failed.', + sandboxFailure: { reason: 'invalid_boundary_declaration' }, + }, + }, + }), + ]; + }); + + assert.deepEqual(projectSandboxBoundaryNegotiation(events), { + kind: 'valid', + state: { + denied: false, + invalidRounds: 1, + unresolvedRounds: 0, + finalizationRequested: false, + }, + }); + }); + + test('fails closed when approval reopens a denied or finalized negotiation', () => { + const deniedThenApproved = [ + request('request-1', 'boundary-1', 'tool-1'), + decision('decision-1', 'boundary-1', 'tool-1', 'denied'), + request('request-2', 'boundary-2', 'tool-2'), + decision('decision-2', 'boundary-2', 'tool-2', 'approved'), + ]; + assert.equal(projectSandboxBoundaryNegotiation(deniedThenApproved).kind, 'invalid'); + + const finalizedThenApproved = [ + ...failurePair( + 'invalid-1', + 'request_sandbox_boundary', + 'tool-1', + 'invalid_boundary_declaration', + ), + ...failurePair( + 'invalid-2', + 'request_sandbox_boundary', + 'tool-2', + 'invalid_boundary_declaration', + ), + ...failurePair( + 'invalid-3', + 'request_sandbox_boundary', + 'tool-3', + 'invalid_boundary_declaration', + ), + request('request-1', 'boundary-1', 'tool-4'), + decision('decision-1', 'boundary-1', 'tool-4', 'approved'), + ]; + assert.equal(projectSandboxBoundaryNegotiation(finalizedThenApproved).kind, 'invalid'); + }); + + test('approved requests reset prior correction state', () => { + const events = [ + ...failurePair( + 'invalid-1', + 'request_sandbox_boundary', + 'tool-1', + 'invalid_boundary_declaration', + ), + request('request-1', 'boundary-1', 'tool-2'), + decision('decision-1', 'boundary-1', 'tool-2', 'approved'), + ]; + assert.deepEqual(projectSandboxBoundaryNegotiation(events), { + kind: 'valid', + state: { + denied: false, + invalidRounds: 0, + unresolvedRounds: 0, + finalizationRequested: false, + }, + }); + }); + + test('fails closed for malformed or legacy boundary facts', () => { + const malformed = request('request-1', 'boundary-1', 'tool-1'); + malformed.actions!.stateDelta!.sandboxBoundaryRequest = { + requestId: 'boundary-1', + toolUseId: 'tool-1', + justification: 'missing expansion', + }; + assert.equal(projectSandboxBoundaryNegotiation([malformed]).kind, 'invalid'); + + const [call, response] = failurePair( + 'legacy-1', + 'request_sandbox_boundary', + 'tool-1', + 'invalid_boundary_declaration', + ); + (response.content as Extract).result = { + kind: 'text', + text: 'Tool arguments failed validation', + }; + assert.deepEqual(projectSandboxBoundaryNegotiation([call, response]), { + kind: 'valid', + state: { + denied: false, + invalidRounds: 0, + unresolvedRounds: 0, + finalizationRequested: false, + }, + }); + + const blankJustification = request('request-2', 'boundary-2', 'tool-2'); + ( + blankJustification.actions!.stateDelta!.sandboxBoundaryRequest as { justification: string } + ).justification = ' '; + assert.equal(projectSandboxBoundaryNegotiation([blankJustification]).kind, 'invalid'); + + const validRequest = request('request-3', 'boundary-3', 'tool-3'); + const malformedRevision = decision('decision-3', 'boundary-3', 'tool-3', 'denied'); + ( + malformedRevision.actions!.stateDelta!.sandboxBoundaryDecision as { revision: number } + ).revision = 1.5; + assert.equal( + projectSandboxBoundaryNegotiation([validRequest, malformedRevision]).kind, + 'invalid', + ); + }); + + test('fails closed when boundary facts do not preserve call identity', () => { + const mismatchedDecision = decision('decision-1', 'boundary-1', 'other-tool', 'denied'); + assert.equal( + projectSandboxBoundaryNegotiation([ + request('request-1', 'boundary-1', 'tool-1'), + mismatchedDecision, + ]).kind, + 'invalid', + ); + + const [call, response] = failurePair( + 'invalid-1', + 'request_sandbox_boundary', + 'tool-1', + 'invalid_boundary_declaration', + ); + (response.content as Extract).name = + 'Bash'; + assert.equal(projectSandboxBoundaryNegotiation([call, response]).kind, 'invalid'); + + const originalRequest = request('request-2', 'boundary-2', 'tool-2'); + const mismatchedIdentityDecision = decision('decision-2', 'boundary-2', 'tool-2', 'denied'); + mismatchedIdentityDecision.invocationId = 'other-invocation'; + assert.equal( + projectSandboxBoundaryNegotiation([originalRequest, mismatchedIdentityDecision]).kind, + 'invalid', + ); + }); + + test('fails closed when a boundary failure marker is attached to a non-boundary tool', () => { + const [call, response] = failurePair( + 'forged-1', + 'Read', + 'tool-1', + 'invalid_boundary_declaration', + ); + assert.equal(projectSandboxBoundaryNegotiation([call, response]).kind, 'invalid'); + }); + + test('counts internal invalid repair calls as boundary attempts', () => { + const events = [ + base('repair-call', { + role: 'model', + author: 'agent', + refs: { toolCallId: 'repair-tool' }, + content: { + kind: 'function_call', + id: 'repair-tool', + name: 'invalid', + args: { + tool: 'request_sandbox_boundary', + error: 'boundary was denied', + sandboxBoundaryAttempt: true, + }, + }, + }), + base('repair-response', { + role: 'tool', + author: 'tool', + refs: { toolCallId: 'repair-tool' }, + content: { + kind: 'function_response', + id: 'repair-tool', + name: 'invalid', + isError: true, + result: { + kind: 'text', + text: 'Sandbox boundary correction failed.', + sandboxFailure: { reason: 'invalid_boundary_declaration' }, + }, + }, + }), + ]; + + assert.deepEqual(projectSandboxBoundaryNegotiation(events), { + kind: 'valid', + state: { + denied: false, + invalidRounds: 1, + unresolvedRounds: 0, + finalizationRequested: false, + }, + }); + }); + + test('requests finalization after the bounded correction budget', () => { + const events = [ + ...failurePair( + 'invalid-1', + 'request_sandbox_boundary', + 'tool-1', + 'invalid_boundary_declaration', + ), + ...failurePair( + 'invalid-2', + 'request_sandbox_boundary', + 'tool-2', + 'invalid_boundary_declaration', + ), + ...failurePair( + 'invalid-3', + 'request_sandbox_boundary', + 'tool-3', + 'invalid_boundary_declaration', + ), + ]; + const result = projectSandboxBoundaryNegotiation(events); + assert.equal(result.kind, 'valid'); + if (result.kind === 'valid') assert.equal(result.state.finalizationRequested, true); + }); + + test('restores a durable denial when the RuntimeEvent ack was lost', () => { + const durable = durableRequest('boundary-1', 'denied'); + assert.deepEqual( + projectSandboxBoundaryNegotiation( + [base('source-event', { turnId: 'turn-1', runId: 'run-1' })], + [durable], + ), + { + kind: 'valid', + state: { + denied: true, + invalidRounds: 0, + unresolvedRounds: 0, + finalizationRequested: false, + }, + }, + ); + }); + + test('fails closed when durable settlement order is unavailable', () => { + const approved = durableRequest('boundary-1', 'approved'); + assert.equal( + projectSandboxBoundaryNegotiation( + [ + request('request-1', 'boundary-1', 'tool-1'), + ...failurePair( + 'invalid-1', + 'request_sandbox_boundary', + 'tool-2', + 'invalid_boundary_declaration', + ), + ], + [approved], + ).kind, + 'invalid', + ); + + const denied = durableRequest('boundary-1', 'denied'); + assert.equal( + projectSandboxBoundaryNegotiation( + [ + request('request-1', 'boundary-1', 'tool-1'), + request('request-2', 'boundary-2', 'tool-2'), + decision('decision-2', 'boundary-2', 'tool-2', 'approved'), + ], + [denied], + ).kind, + 'invalid', + ); + }); + + test('does not treat a host-restart closure as a user denial', () => { + const restartClosed = { + ...durableRequest('boundary-1', 'denied'), + outcomeReason: 'host_restarted', + }; + assert.deepEqual( + projectSandboxBoundaryNegotiation( + [request('request-1', 'boundary-1', 'tool-1')], + [restartClosed], + ), + { + kind: 'valid', + state: { + denied: false, + invalidRounds: 0, + unresolvedRounds: 0, + finalizationRequested: false, + }, + }, + ); + + assert.deepEqual( + projectSandboxBoundaryNegotiation( + [ + request('request-1', 'boundary-1', 'tool-1'), + decision('decision-1', 'boundary-1', 'tool-1', 'approved'), + request('request-2', 'boundary-2', 'tool-2'), + ], + [{ ...restartClosed, requestId: 'boundary-2' }], + ), + { + kind: 'valid', + state: { + denied: false, + invalidRounds: 0, + unresolvedRounds: 0, + finalizationRequested: false, + }, + }, + ); + }); +}); + describe('ExecutionBoundary', () => { test('decodes only a complete full boundary snapshot', () => { const managed = createGenesisExecutionBoundary('ask'); diff --git a/packages/core/src/backend-types.ts b/packages/core/src/backend-types.ts index 9f326ca20f..5bf7733de7 100644 --- a/packages/core/src/backend-types.ts +++ b/packages/core/src/backend-types.ts @@ -39,7 +39,11 @@ import type { } from './events.js'; import type { InteractionClosureReason } from './interaction.js'; import type { RuntimeEvent } from './runtime-event.js'; -import type { SandboxBoundaryResponse, SandboxBoundarySettlement } from './sandbox-boundary.js'; +import type { + SandboxBoundaryNegotiationState, + SandboxBoundaryResponse, + SandboxBoundarySettlement, +} from './sandbox-boundary.js'; import type { StoredMessage, PersistedBackendKind } from './session.js'; import type { AgentRunHeader } from './agent-run.js'; import type { UserQuestionResponse } from './user-question.js'; @@ -52,6 +56,8 @@ export interface RuntimeContinuationMetadata { sourceRunId: string; sourceTurnId: string; sourceRuntimeEventHighWater: number; + /** Authenticated negotiation projection; never grants execution authority. */ + sandboxBoundaryNegotiationState: SandboxBoundaryNegotiationState; } export interface BackendSendInput { diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 52af0d0727..d427751d4d 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -775,7 +775,7 @@ export interface SandboxDenialRecovery extends SandboxDenialSignal { } export interface SandboxBoundaryFailureSignal { - reason: 'sandbox_boundary_required' | 'requires_bypass'; + reason: 'invalid_boundary_declaration' | 'sandbox_boundary_required' | 'requires_bypass'; requiredExpansion?: SandboxBoundaryExpansion; source?: 'client_capability'; } diff --git a/packages/core/src/sandbox-boundary.ts b/packages/core/src/sandbox-boundary.ts index 208fd1f2b3..5447af369c 100644 --- a/packages/core/src/sandbox-boundary.ts +++ b/packages/core/src/sandbox-boundary.ts @@ -18,6 +18,7 @@ */ import type { PermissionMode } from './permission.js'; +import type { RuntimeEvent } from './runtime-event.js'; import { isNormalizedAbsolutePath, pathWithinRoot, @@ -45,6 +46,38 @@ export type SandboxBoundaryAccess = (typeof SANDBOX_BOUNDARY_ACCESS_MODES)[numbe export const SANDBOX_BOUNDARY_SCOPES = ['exact', 'subtree'] as const; export type SandboxBoundaryScope = (typeof SANDBOX_BOUNDARY_SCOPES)[number]; +/** Maximum number of distinct correction rounds allowed for one negotiation kind. */ +export const SANDBOX_BOUNDARY_FAILURE_ROUND_LIMIT = 3; + +/** + * The small amount of sandbox negotiation state that may cross a safe + * continuation boundary. It is deliberately not an execution authority: the + * live ExecutionBoundary remains the only source that can grant capability. + */ +export interface SandboxBoundaryNegotiationState { + readonly denied: boolean; + readonly invalidRounds: number; + readonly unresolvedRounds: number; + readonly finalizationRequested: boolean; +} + +/** + * State used when a continuation's boundary projection cannot be trusted. + * Callers may report the blocked Turn, but must not reopen negotiation. + */ +export function createSandboxBoundaryFinalizationState(): SandboxBoundaryNegotiationState { + return { + denied: false, + invalidRounds: 0, + unresolvedRounds: 0, + finalizationRequested: true, + }; +} + +export type SandboxBoundaryNegotiationProjection = + | { readonly kind: 'valid'; readonly state: SandboxBoundaryNegotiationState } + | { readonly kind: 'invalid'; readonly reason: string }; + export const MAX_SANDBOX_BOUNDARY_FILESYSTEM_ENTRIES = 32; export const MAX_SANDBOX_BOUNDARY_PATH_CHARS = 4096; export const MAX_SANDBOX_BOUNDARY_SERIALIZED_BYTES = 64 * 1024; @@ -157,6 +190,379 @@ export interface SandboxBoundarySettlement { readonly changed: boolean; } +/** + * Rebuild negotiation control state from an authenticated RuntimeEvent + * projection. Only canonical sandbox request/decision events and structured + * boundary failures participate; prompt text and unstructured error strings do + * not. A malformed or incomplete relevant fact returns `invalid`, allowing a + * caller to fail closed without guessing state. + */ +export function projectSandboxBoundaryNegotiation( + events: readonly RuntimeEvent[], + durableRequests: readonly SandboxBoundaryRequest[], +): SandboxBoundaryNegotiationProjection { + let denied = false; + let invalidRounds = 0; + let unresolvedRounds = 0; + let finalizationRequested = false; + // RuntimeEvent order is authoritative inside the immutable ledger, but the + // durable interaction rows live in a separate append/settlement path. When + // a settlement has no matching decision ack, there is no shared sequence + // number that can place it relative to later failures or decisions. + let hasStatefulEvent = false; + const invalidSteps = new Set(); + const unresolvedSteps = new Set(); + const requests = new Set(); + const requestToolUseIds = new Map(); + const requestIdentityKeys = new Map(); + const settledRequests = new Set(); + const requestEvents = new Set(); + const decisionEvents = new Map(); + const eventTurnIds = new Set(); + const eventRunIds = new Set(); + const eventIdentityPairs = new Set(); + const toolCalls = new Map(); + const boundaryCalls = new Map(); + const boundaryResponses = new Set(); + + const invalid = (reason: string): SandboxBoundaryNegotiationProjection => ({ + kind: 'invalid', + reason, + }); + const applyApproval = (requestId: string): SandboxBoundaryNegotiationProjection | undefined => { + if (denied || finalizationRequested) { + return invalid(`sandbox boundary approval ${requestId} reopens a closed negotiation`); + } + denied = false; + invalidRounds = 0; + unresolvedRounds = 0; + invalidSteps.clear(); + unresolvedSteps.clear(); + finalizationRequested = false; + return undefined; + }; + const addFailure = (kind: 'invalid' | 'unresolved', step: string): void => { + hasStatefulEvent = true; + if (denied) { + finalizationRequested = true; + return; + } + if (finalizationRequested) return; + const steps = kind === 'invalid' ? invalidSteps : unresolvedSteps; + if (steps.has(step)) return; + steps.add(step); + if (kind === 'invalid') invalidRounds += 1; + else unresolvedRounds += 1; + if ( + invalidRounds >= SANDBOX_BOUNDARY_FAILURE_ROUND_LIMIT || + unresolvedRounds >= SANDBOX_BOUNDARY_FAILURE_ROUND_LIMIT + ) { + finalizationRequested = true; + } + }; + + for (const event of events) { + eventTurnIds.add(event.turnId); + eventRunIds.add(event.runId); + eventIdentityPairs.add(`${event.runId}\u0000${event.turnId}`); + const delta = event.actions?.stateDelta; + const request = delta?.sandboxBoundaryRequest; + const decision = delta?.sandboxBoundaryDecision; + if (request !== undefined || decision !== undefined) { + if (request !== undefined && decision !== undefined) { + return invalid(`sandbox boundary event ${event.id} contains request and decision facts`); + } + if ( + event.role !== 'system' || + typeof event.refs?.toolCallId !== 'string' || + event.refs.toolCallId.length === 0 + ) { + return invalid(`sandbox boundary event ${event.id} has non-canonical identity`); + } + if (request !== undefined) { + if ( + event.author !== 'system' || + !isRecord(request) || + !hasExactKeys(request, ['requestId', 'toolUseId', 'justification', 'expansion']) || + !nonEmptyString(request.requestId) || + !nonEmptyString(request.toolUseId) || + typeof request.justification !== 'string' || + request.justification.trim().length === 0 || + !validateSandboxBoundaryExpansion(request.expansion).ok || + request.toolUseId !== event.refs.toolCallId + ) { + return invalid(`sandbox boundary request ${event.id} is incomplete`); + } + if (requests.has(request.requestId) || settledRequests.has(request.requestId)) { + return invalid(`sandbox boundary request ${request.requestId} is duplicated`); + } + requests.add(request.requestId); + requestToolUseIds.set(request.requestId, request.toolUseId); + requestIdentityKeys.set( + request.requestId, + `${event.sessionId}\u0000${event.invocationId}\u0000${event.runId}\u0000${event.turnId}`, + ); + requestEvents.add(request.requestId); + continue; + } + const requestToolUseId = + isRecord(decision) && nonEmptyString(decision.requestId) + ? requestToolUseIds.get(decision.requestId) + : undefined; + const requestIdentityKey = + isRecord(decision) && nonEmptyString(decision.requestId) + ? requestIdentityKeys.get(decision.requestId) + : undefined; + const decisionIdentityKey = `${event.sessionId}\u0000${event.invocationId}\u0000${event.runId}\u0000${event.turnId}`; + if ( + event.author !== 'user' || + !isRecord(decision) || + !hasExactKeys(decision, ['requestId', 'decision', 'status', 'revision']) || + !nonEmptyString(decision.requestId) || + (decision.decision !== 'allow' && decision.decision !== 'deny') || + (decision.status !== 'approved' && + decision.status !== 'denied' && + decision.status !== 'conflict') || + typeof decision.revision !== 'number' || + !Number.isSafeInteger(decision.revision) || + decision.revision < 0 || + requestToolUseId === undefined || + requestIdentityKey !== decisionIdentityKey || + event.refs.toolCallId !== requestToolUseId || + settledRequests.has(decision.requestId) || + (decision.status === 'approved' && decision.decision !== 'allow') || + (decision.status === 'denied' && decision.decision !== 'deny') || + (decision.status === 'conflict' && decision.decision !== 'allow') + ) { + return invalid(`sandbox boundary decision ${event.id} is incomplete`); + } + settledRequests.add(decision.requestId); + decisionEvents.set(decision.requestId, { status: decision.status }); + hasStatefulEvent = true; + if (decision.status === 'denied') { + denied = true; + } else if (decision.status === 'approved') { + const approvalError = applyApproval(decision.requestId); + if (approvalError) return approvalError; + } else { + addFailure('unresolved', `request:${decision.requestId}`); + } + continue; + } + + const content = event.content; + if (content?.kind === 'function_call') { + const isBoundaryCall = isBoundaryAuthorityCall(content.name, content.args); + if ( + isBoundaryCall && + (event.role !== 'model' || + event.author !== 'agent' || + event.refs?.toolCallId !== content.id || + !nonEmptyString(content.name)) + ) { + return invalid(`sandbox boundary call ${event.id} has non-canonical identity`); + } + const call = { + name: content.name, + step: + event.refs?.stepId ?? + event.refs?.parentToolCallId ?? + event.refs?.parentOperationId ?? + event.refs?.toolCallId ?? + content.id, + }; + if (toolCalls.has(content.id)) { + return invalid(`tool call ${content.id} is duplicated`); + } + toolCalls.set(content.id, call); + if (isBoundaryCall) { + if (boundaryCalls.has(content.id)) { + return invalid(`sandbox boundary call ${content.id} is duplicated`); + } + boundaryCalls.set(content.id, { step: call.step }); + } + continue; + } + if (content?.kind !== 'function_response') continue; + const call = toolCalls.get(content.id); + const boundaryCall = boundaryCalls.get(content.id); + const result = content.result; + if ( + boundaryCall && + (event.role !== 'tool' || + event.author !== 'tool' || + event.refs?.toolCallId !== content.id || + content.name !== call?.name) + ) { + return invalid(`sandbox boundary response ${event.id} has non-canonical identity`); + } + const failure = readBoundaryFailure(result); + if (failure === 'malformed') { + return invalid(`sandbox boundary failure on ${event.id} is malformed`); + } + if (failure !== undefined) { + if (!boundaryCall || !call || content.isError !== true) { + return invalid(`sandbox boundary failure on ${event.id} has no canonical call`); + } + const step = event.refs?.stepId ?? boundaryCall?.step ?? call.step; + addFailure(failure, step); + } + if (boundaryCall) { + if (boundaryResponses.has(content.id)) { + return invalid(`sandbox boundary response ${content.id} is duplicated`); + } + boundaryResponses.add(content.id); + } + continue; + } + + for (const callId of boundaryCalls.keys()) { + if (!boundaryResponses.has(callId)) { + return invalid(`sandbox boundary call ${callId} has no durable response`); + } + } + + const durableById = new Map(); + const durableSettlementsWithoutDecision: SandboxBoundaryRequest[] = []; + for (const request of durableRequests) { + const hasProvenance = request.turnId !== undefined || request.runId !== undefined; + const attributable = hasProvenance + ? request.turnId !== undefined && request.runId !== undefined + ? eventIdentityPairs.has(`${request.runId}\u0000${request.turnId}`) + : (request.turnId === undefined || eventTurnIds.has(request.turnId)) && + (request.runId === undefined || eventRunIds.has(request.runId)) + : requestEvents.has(request.requestId) || decisionEvents.has(request.requestId); + if (!attributable) continue; + if ( + !nonEmptyString(request.requestId) || + !SANDBOX_BOUNDARY_REQUEST_STATUSES.includes(request.status) || + !validateSandboxBoundaryExpansion(request.expansion).ok || + typeof request.justification !== 'string' || + request.justification.trim().length === 0 || + !Number.isSafeInteger(request.createdAt) || + request.createdAt < 0 || + (request.turnId !== undefined && !nonEmptyString(request.turnId)) || + (request.runId !== undefined && !nonEmptyString(request.runId)) + ) { + return invalid(`sandbox boundary durable request ${String(request.requestId)} is malformed`); + } + if (durableById.has(request.requestId)) { + return invalid(`sandbox boundary durable request ${request.requestId} is duplicated`); + } + durableById.set(request.requestId, request); + const eventDecision = decisionEvents.get(request.requestId); + if (eventDecision && eventDecision.status !== request.status) { + return invalid( + `sandbox boundary durable request ${request.requestId} changed decision status`, + ); + } + if (request.status === 'pending') { + return invalid(`sandbox boundary durable request ${request.requestId} is unresolved`); + } + if (!eventDecision) { + // A host-restart closure is a lifecycle cleanup, not a user decision. + // It closes the old request id, but must not turn a request the user + // never saw into a permanent denial or participate in the ordering + // guard as if it were an approval/denial transition. + settledRequests.add(request.requestId); + if (isSandboxBoundaryRestartClosure(request)) { + continue; + } + durableSettlementsWithoutDecision.push(request); + if (request.status === 'denied') { + denied = true; + } else if (request.status === 'approved') { + const approvalError = applyApproval(request.requestId); + if (approvalError) return approvalError; + } else { + addFailure('unresolved', `request:${request.requestId}`); + } + } + } + + // Do not guess at the order between an interaction-row settlement and + // RuntimeEvent facts from the same source run. An approved durable row + // applied after the event projection could erase later failure budget, and + // a denied row could overwrite a later approval. The only safe exception is + // the ack-loss recovery case where the durable row is the sole stateful fact + // and can be applied without crossing another state transition. + if ( + durableSettlementsWithoutDecision.length > 1 || + (durableSettlementsWithoutDecision.length > 0 && hasStatefulEvent) + ) { + return invalid('sandbox boundary durable settlement ordering is unavailable'); + } + + for (const requestId of requests) { + if (!settledRequests.has(requestId)) { + return invalid(`sandbox boundary request ${requestId} has no durable decision`); + } + } + return { + kind: 'valid', + state: { + denied, + invalidRounds, + unresolvedRounds, + finalizationRequested, + }, + }; +} + +function isBoundaryAuthorityCall(toolName: string, args: unknown): boolean { + if (toolName === 'request_sandbox_boundary') return true; + if (toolName === 'invalid' && isRecord(args) && args.sandboxBoundaryAttempt === true) { + return true; + } + if (toolName !== 'Bash' || !isRecord(args)) return false; + return args.boundary_intent !== undefined && args.boundary_intent !== 'current'; +} + +function readBoundaryFailure(result: unknown): 'invalid' | 'unresolved' | 'malformed' | undefined { + if (!isRecord(result) || result.kind !== 'text') return undefined; + const failure = result.sandboxFailure; + if (failure === undefined) return undefined; + if (!isRecord(failure) || !hasOnlyKeys(failure, ['reason', 'requiredExpansion', 'source'])) { + return 'malformed'; + } + if ( + failure.reason === 'invalid_boundary_declaration' && + (failure.source !== undefined || failure.requiredExpansion !== undefined) + ) { + return 'malformed'; + } + if (failure.reason === 'sandbox_boundary_required' || failure.reason === 'requires_bypass') { + if ( + failure.source !== undefined && + !(failure.reason === 'requires_bypass' && failure.source === 'client_capability') + ) { + return 'malformed'; + } + if ( + failure.requiredExpansion !== undefined && + !validateSandboxBoundaryExpansion(failure.requiredExpansion).ok + ) { + return 'malformed'; + } + return 'unresolved'; + } + if (failure.reason === 'invalid_boundary_declaration') return 'invalid'; + return 'malformed'; +} + +function hasExactKeys(value: Record, required: readonly string[]): boolean { + const keys = Object.keys(value).sort(); + return keys.length === required.length && required.every((key) => keys.includes(key)); +} + +function hasOnlyKeys(value: Record, allowed: readonly string[]): boolean { + return Object.keys(value).every((key) => allowed.includes(key)); +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + export type SandboxProfile = PermissionProfileManaged; export type ExecutionBoundary = diff --git a/packages/core/src/tool-result-record-schema.ts b/packages/core/src/tool-result-record-schema.ts index dc0b090285..b0d77a0890 100644 --- a/packages/core/src/tool-result-record-schema.ts +++ b/packages/core/src/tool-result-record-schema.ts @@ -207,13 +207,17 @@ function isNonShellToolResultContent(value: unknown): value is ToolResultContent (value.sandboxFailure === undefined || (isRecord(value.sandboxFailure) && hasExactShape(value.sandboxFailure, SANDBOX_FAILURE_SHAPE) && - (value.sandboxFailure.reason === 'sandbox_boundary_required' || + (value.sandboxFailure.reason === 'invalid_boundary_declaration' || + value.sandboxFailure.reason === 'sandbox_boundary_required' || value.sandboxFailure.reason === 'requires_bypass') && - (value.sandboxFailure.source === undefined || - (value.sandboxFailure.reason === 'requires_bypass' && - value.sandboxFailure.source === 'client_capability')) && - (value.sandboxFailure.requiredExpansion === undefined || - validateSandboxBoundaryExpansion(value.sandboxFailure.requiredExpansion).ok))) && + (value.sandboxFailure.reason === 'invalid_boundary_declaration' + ? value.sandboxFailure.source === undefined && + value.sandboxFailure.requiredExpansion === undefined + : (value.sandboxFailure.source === undefined || + (value.sandboxFailure.reason === 'requires_bypass' && + value.sandboxFailure.source === 'client_capability')) && + (value.sandboxFailure.requiredExpansion === undefined || + validateSandboxBoundaryExpansion(value.sandboxFailure.requiredExpansion).ok)))) && (value.uncertainOutcome === undefined || (isRecord(value.uncertainOutcome) && hasExactShape(value.uncertainOutcome, UNCERTAIN_OUTCOME_SHAPE) && diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 83883daec7..b522a4bdf0 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -100,14 +100,16 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 94 as const; -// 94: A failed Turn snapshot no longer carries contextBudgetExhaustedDetail; the -// retired outcome reads as context_overflow at the ledger boundary, and an older -// Host still sending the field fails a newer client's closed snapshot decode. +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 95 as const; +// 95: Session continuity carries authenticated sandbox-boundary negotiation +// facts; older Clients cannot safely preserve the new fail-closed contract. // 93: Configuration credential transfer binds proxy destinations and // Connection credentials to exact Host-owned targets before secret access. // Proxy policy and credentials commit through one recoverable Host command; // older peers can split the writes and violate the shared credential basis. +// 94: A failed Turn snapshot no longer carries contextBudgetExhaustedDetail; the +// retired outcome reads as context_overflow at the ledger boundary, and an older +// Host still sending the field fails a newer client's closed snapshot decode. // 92: Owners can query their complete pending Session Turn-request inbox. // 91: Host status publishes the live Direct peer endpoint so newly issued // connection invitations do not preserve stale startup routes. diff --git a/packages/runtime-host/src/protocol/session-continuity.ts b/packages/runtime-host/src/protocol/session-continuity.ts index 4219b6ab34..5f2db1f825 100644 --- a/packages/runtime-host/src/protocol/session-continuity.ts +++ b/packages/runtime-host/src/protocol/session-continuity.ts @@ -986,7 +986,13 @@ function decodeSessionToolEvent(value: unknown): SessionToolEvent { } function requireSandboxFailureReason(value: unknown): SandboxBoundaryFailureSignal['reason'] { - if (value === 'sandbox_boundary_required' || value === 'requires_bypass') return value; + if ( + value === 'invalid_boundary_declaration' || + value === 'sandbox_boundary_required' || + value === 'requires_bypass' + ) { + return value; + } throw invalidProtocolFrame('Invalid Session tool result sandbox failure reason'); } diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index a021546196..da12261653 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -31,7 +31,10 @@ import type { AttachmentByteReader } from '@maka/core/attachments'; import type { BackendSendInput } from '@maka/core/backend-types'; import type { LlmConnection } from '@maka/core/llm-connections'; import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; -import { createManagedExecutionBoundary } from '@maka/core/sandbox-boundary'; +import { + createManagedExecutionBoundary, + type SandboxBoundaryNegotiationState, +} from '@maka/core/sandbox-boundary'; import type { SessionHeader } from '@maka/core/session'; import type { StorageRef } from '@maka/core/events'; import { encodeCanonicalRuntimeEvent } from '@maka/core/canonical-runtime-event'; @@ -1241,6 +1244,348 @@ describe('AiSdkBackend Memory Extraction triggers', () => { }); describe('AiSdkBackend sandbox boundary convergence', () => { + test('restores a denied negotiation on a fresh continuation segment', async () => { + let streamCalls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + streamCalls += 1; + const chunks: LanguageModelV4StreamPart[] = + streamCalls === 1 + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'continuation-boundary-retry', + toolName: 'request_sandbox_boundary', + input: JSON.stringify({ + expansion: { network: { enabled: true } }, + justification: 'Try the denied expansion again.', + }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'continuation-final' }, + { + type: 'text-delta', + id: 'continuation-final', + delta: 'The prior denial remains in force.', + }, + { type: 'text-end', id: 'continuation-final' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ]; + return { + stream: simulateReadableStream({ + chunks, + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const sourceTurnId = 'turn-source'; + const sourceRuntimeContext: RuntimeEvent[] = [ + runtimeTextEvent({ + id: 'source-user', + turnId: sourceTurnId, + role: 'user', + author: 'user', + text: 'Need network access.', + }), + runtimeEvent({ + id: 'source-boundary-request', + turnId: sourceTurnId, + role: 'system', + author: 'system', + refs: { toolCallId: 'source-boundary-call' }, + actions: { + stateDelta: { + sandboxBoundaryRequest: { + requestId: 'source-boundary', + toolUseId: 'source-boundary-call', + justification: 'Need network access.', + expansion: { network: { enabled: true } }, + }, + }, + }, + }), + runtimeEvent({ + id: 'source-boundary-decision', + turnId: sourceTurnId, + role: 'system', + author: 'user', + refs: { toolCallId: 'source-boundary-call' }, + actions: { + stateDelta: { + sandboxBoundaryDecision: { + requestId: 'source-boundary', + decision: 'deny', + status: 'denied', + revision: 0, + }, + }, + }, + }), + ]; + let createCalls = 0; + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [buildRequestSandboxBoundaryTool()], + readExecutionBoundary: async () => + createManagedExecutionBoundary(createWorkspaceWritePermissionProfile(), 0), + createSandboxBoundaryRequest: async () => { + createCalls += 1; + throw new Error('a denied continuation must not reopen the request'); + }, + settleSandboxBoundaryRequest: async () => { + throw new Error('a denied continuation must not settle a new request'); + }, + loadTurnRuntimeEvents: async () => [], + maxSteps: 3, + newId: idGenerator(), + now: monotonicClock(), + }); + const events: SessionEvent[] = []; + await collectEvents( + backend.send({ + turnId: 'turn-continuation', + text: '', + context: [], + runtimeContext: sourceRuntimeContext, + continuation: { + sourceInvocationId: 'inv-1', + sourceRunId: 'run-prev', + sourceTurnId, + sourceRuntimeEventHighWater: sourceRuntimeContext.length, + sandboxBoundaryNegotiationState: { + denied: true, + invalidRounds: 0, + unresolvedRounds: 0, + finalizationRequested: false, + }, + }, + }), + events, + ); + + assert.equal(createCalls, 0); + assert.equal(streamCalls, 2); + assert.equal(events.filter((event) => event.type === 'sandbox_boundary_request').length, 0); + assert.equal( + events.find((event) => event.type === 'complete')?.stopReason, + 'permission_handoff', + ); + await backend.dispose(); + }); + + test('rejects a continuation that omits its authenticated negotiation state', async () => { + const model = completionModel(); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + const incompleteContinuation = { + sourceInvocationId: 'invocation-source', + sourceRunId: 'run-source', + sourceTurnId: 'turn-source', + sourceRuntimeEventHighWater: 1, + } as unknown as NonNullable; + + try { + await assert.rejects( + collectEvents( + backend.send({ + turnId: 'turn-continuation', + text: '', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'source-user', + turnId: 'turn-source', + role: 'user', + author: 'user', + text: 'continue', + }), + ], + continuation: incompleteContinuation, + }), + [], + ), + /missing authenticated sandbox negotiation state/, + ); + assert.equal(model.doStreamCalls.length, 0); + } finally { + await backend.dispose(); + } + }); + + test('starts a genuinely new user Turn with a clean negotiation state', async () => { + let streamCalls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + streamCalls += 1; + const chunks: LanguageModelV4StreamPart[] = + streamCalls === 1 + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'new-turn-boundary-request', + toolName: 'request_sandbox_boundary', + input: JSON.stringify({ + expansion: { network: { enabled: true } }, + justification: 'This is a new user Turn.', + }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'new-turn-final' }, + { + type: 'text-delta', + id: 'new-turn-final', + delta: 'The new Turn handled its own boundary decision.', + }, + { type: 'text-end', id: 'new-turn-final' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ]; + return { + stream: simulateReadableStream({ + chunks, + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const managed = createManagedExecutionBoundary(createWorkspaceWritePermissionProfile(), 0); + let pendingRequest: + | Awaited>> + | undefined; + let createCalls = 0; + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [buildRequestSandboxBoundaryTool()], + readExecutionBoundary: async () => managed, + createSandboxBoundaryRequest: async (input) => { + createCalls += 1; + pendingRequest = { ...input, status: 'pending', baseRevision: 0, createdAt: 1 }; + return pendingRequest; + }, + settleSandboxBoundaryRequest: async () => { + assert.ok(pendingRequest); + pendingRequest = { ...pendingRequest, status: 'denied', settledAt: 2 }; + return { request: pendingRequest, boundary: managed, changed: false }; + }, + loadTurnRuntimeEvents: async () => [], + maxSteps: 3, + newId: idGenerator(), + now: monotonicClock(), + }); + const priorDeniedRuntimeContext: RuntimeEvent[] = [ + runtimeTextEvent({ + id: 'old-user', + turnId: 'turn-old', + role: 'user', + author: 'user', + text: 'Old Turn.', + }), + runtimeEvent({ + id: 'old-boundary-request', + turnId: 'turn-old', + role: 'system', + author: 'system', + refs: { toolCallId: 'old-boundary-call' }, + actions: { + stateDelta: { + sandboxBoundaryRequest: { + requestId: 'old-boundary', + toolUseId: 'old-boundary-call', + justification: 'Old request.', + expansion: { network: { enabled: true } }, + }, + }, + }, + }), + runtimeEvent({ + id: 'old-boundary-decision', + turnId: 'turn-old', + role: 'system', + author: 'user', + refs: { toolCallId: 'old-boundary-call' }, + actions: { + stateDelta: { + sandboxBoundaryDecision: { + requestId: 'old-boundary', + decision: 'deny', + status: 'denied', + revision: 0, + }, + }, + }, + }), + ]; + const events: SessionEvent[] = []; + const consuming = collectEvents( + backend.send({ + turnId: 'turn-new', + text: 'Start fresh.', + context: [], + runtimeContext: priorDeniedRuntimeContext, + }), + events, + ); + + await waitFor(() => events.some((event) => event.type === 'sandbox_boundary_request')); + const request = events.find((event) => event.type === 'sandbox_boundary_request'); + assert.ok(request?.type === 'sandbox_boundary_request'); + await backend.respondToSandboxBoundary({ requestId: request.requestId, decision: 'deny' }); + await consuming; + + assert.equal(createCalls, 1); + assert.equal(streamCalls, 2); + assert.equal(events.filter((event) => event.type === 'sandbox_boundary_request').length, 1); + await backend.dispose(); + }); + test('bounds an expansion retry after denial with one tool-free final step', async () => { const cwd = process.cwd(); const calls = [ @@ -1987,6 +2332,7 @@ describe('AiSdkBackend model history', () => { sourceRunId: 'run-source', sourceTurnId: 'turn-source', sourceRuntimeEventHighWater: 1, + sandboxBoundaryNegotiationState: cleanSandboxBoundaryNegotiationState(), }, }), ); @@ -2041,6 +2387,7 @@ describe('AiSdkBackend model history', () => { sourceRunId: 'run-source', sourceTurnId: 'turn-source', sourceRuntimeEventHighWater: 2, + sandboxBoundaryNegotiationState: cleanSandboxBoundaryNegotiationState(), }, }), ); @@ -2088,6 +2435,7 @@ describe('AiSdkBackend model history', () => { sourceRunId: 'run-source', sourceTurnId: 'turn-source', sourceRuntimeEventHighWater: 1, + sandboxBoundaryNegotiationState: cleanSandboxBoundaryNegotiationState(), }, })) { events.push(event); @@ -2152,6 +2500,7 @@ describe('AiSdkBackend model history', () => { sourceRunId: 'run-source', sourceTurnId: 'turn-source', sourceRuntimeEventHighWater: 2, + sandboxBoundaryNegotiationState: cleanSandboxBoundaryNegotiationState(), }, }), ); @@ -2245,6 +2594,7 @@ describe('AiSdkBackend model history', () => { sourceRunId: 'run-source', sourceTurnId: 'turn-source', sourceRuntimeEventHighWater: 3, + sandboxBoundaryNegotiationState: cleanSandboxBoundaryNegotiationState(), }, }), ); @@ -2295,6 +2645,7 @@ describe('AiSdkBackend model history', () => { sourceRunId: 'run-source', sourceTurnId: 'turn-source', sourceRuntimeEventHighWater: 2, + sandboxBoundaryNegotiationState: cleanSandboxBoundaryNegotiationState(), }, }), ); @@ -2363,6 +2714,7 @@ describe('AiSdkBackend model history', () => { sourceRunId: 'run-source', sourceTurnId: 'turn-source', sourceRuntimeEventHighWater: 3, + sandboxBoundaryNegotiationState: cleanSandboxBoundaryNegotiationState(), }, }), ); @@ -3647,6 +3999,7 @@ describe('AiSdkBackend model history', () => { sourceRunId: 'run-source', sourceTurnId: 'turn-prev', sourceRuntimeEventHighWater: 4, + sandboxBoundaryNegotiationState: cleanSandboxBoundaryNegotiationState(), }, }), ); @@ -3757,6 +4110,7 @@ describe('AiSdkBackend model history', () => { sourceRunId: 'run-source', sourceTurnId: 'turn-prev', sourceRuntimeEventHighWater: 6, + sandboxBoundaryNegotiationState: cleanSandboxBoundaryNegotiationState(), }, }), ); @@ -10789,6 +11143,7 @@ describe('AiSdkBackend RunTrace', () => { sourceRunId: 'run-source', sourceTurnId: 'turn-source', sourceRuntimeEventHighWater: 2, + sandboxBoundaryNegotiationState: cleanSandboxBoundaryNegotiationState(), }, }), ); @@ -16118,6 +16473,15 @@ function sameRouteReplayProvenance( }; } +function cleanSandboxBoundaryNegotiationState(): SandboxBoundaryNegotiationState { + return { + denied: false, + invalidRounds: 0, + unresolvedRounds: 0, + finalizationRequested: false, + }; +} + function connection(): LlmConnection { return { slug: 'anthropic-main', diff --git a/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts b/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts index 62c815dae5..50fee41cb6 100644 --- a/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts +++ b/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts @@ -27,6 +27,8 @@ import { fileURLToPath } from 'node:url'; import { describe, test } from 'node:test'; import type { AgentRunHeader } from '@maka/core/agent-run'; +import type { BackendSendInput } from '@maka/core/backend-types'; +import type { SessionEvent } from '@maka/core/events'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { createSessionStore } from '@maka/storage/session-store'; @@ -53,6 +55,175 @@ if (process.env[CRASH_CHILD_ENV] === '1') { await runCrashChild(); } else { describe('runtime resume phase 1 process crash harness', () => { + test('reopens the boundary log and authenticates denial through continuation admission', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-runtime-boundary-restart-')); + let observedContinuation: BackendSendInput | undefined; + const createHarness = () => { + const store = createSessionStore(root); + const runStore = createSqliteAgentRunStore(root); + const runtimeEventStore = createCrashRuntimeStore(root); + const backends = new BackendRegistry(); + backends.register('ai-sdk', (ctx) => { + const backend = new FakeBackend({ + sessionId: ctx.sessionId, + header: ctx.header, + store: ctx.store, + appendMessage: ctx.appendMessage, + }); + return { + kind: backend.kind, + sessionId: backend.sessionId, + async *send(input: BackendSendInput): AsyncIterable { + if (input.continuation) observedContinuation = input; + yield* backend.send(input); + }, + stop: () => backend.stop(), + respondToSandboxBoundary: (response) => backend.respondToSandboxBoundary(response), + respondToUserQuestion: (response) => backend.respondToUserQuestion(response), + dispose: () => backend.dispose(), + }; + }); + return { + store, + runStore, + runtimeEventStore, + manager: new SessionManager({ + store, + runStore, + runtimeEventStore, + backends, + safeBoundaryResumeEnabled: true, + inspectContinuationSafety: async () => stableSafetyObservation(), + newId: (() => { + let id = 0; + return () => `restart-id-${++id}`; + })(), + now: Date.now, + }), + }; + }; + + const first = createHarness(); + try { + const session = await first.manager.createSession({ + cwd: root, + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + name: 'boundary restart authentication', + }); + const identity = { + sessionId: session.id, + invocationId: 'source-invocation', + runId: 'source-run', + turnId: 'source-turn', + }; + await first.runStore.createRun(sourceHeader(session.id, root)); + await first.runtimeEventStore.appendRuntimeEvent(session.id, 'source-run', { + ...identity, + id: 'source-user', + ts: 0, + partial: false, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'continue after a denied boundary request' }, + }); + await first.runtimeEventStore.appendRuntimeEvent(session.id, 'source-run', { + ...identity, + id: 'boundary-request-event', + ts: 1, + partial: false, + role: 'system', + author: 'system', + refs: { toolCallId: 'boundary-tool' }, + actions: { + stateDelta: { + sandboxBoundaryRequest: { + requestId: 'boundary-1', + toolUseId: 'boundary-tool', + justification: 'Need network access.', + expansion: { network: { enabled: true } }, + }, + }, + }, + }); + await first.runtimeEventStore.appendRuntimeEvent(session.id, 'source-run', { + ...identity, + id: 'boundary-decision-event', + ts: 2, + partial: false, + role: 'system', + author: 'user', + refs: { toolCallId: 'boundary-tool' }, + actions: { + stateDelta: { + sandboxBoundaryDecision: { + requestId: 'boundary-1', + decision: 'deny', + status: 'denied', + revision: 0, + }, + }, + }, + }); + await first.runtimeEventStore.appendRuntimeEvent(session.id, 'source-run', { + ...identity, + id: 'source-terminal', + ts: 2, + partial: false, + role: 'system', + author: 'system', + status: 'failed', + actions: { endInvocation: true, stateDelta: { failureClass: 'app_restarted' } }, + }); + await first.store.createSandboxBoundaryRequest({ + sessionId: session.id, + requestId: 'boundary-1', + turnId: 'source-turn', + runId: 'source-run', + expansion: { network: { enabled: true } }, + justification: 'Need network access.', + }); + await first.store.settleSandboxBoundaryRequest({ + sessionId: session.id, + requestId: 'boundary-1', + decision: 'deny', + }); + await first.store.close?.(); + first.runStore.close?.(); + first.runtimeEventStore.close(); + + const reopened = createHarness(); + try { + const durableRequests = await reopened.store.listSandboxBoundaryRequests(session.id); + assert.equal(durableRequests[0]?.status, 'denied'); + const plan = await reopened.manager.planAuthoritativeSafeBoundaryContinuation( + session.id, + { + sourceRunId: 'source-run', + }, + ); + assert.equal(plan.disposition, 'continue'); + if (!plan.continuation) throw new Error('expected a continuation plan'); + const resumed = reopened.manager.resumeSafeBoundaryContinuation(plan.continuation); + for await (const _event of resumed) { + // Drain the restarted continuation so backend admission completes. + } + assert.equal( + observedContinuation?.continuation?.sandboxBoundaryNegotiationState?.denied, + true, + ); + } finally { + await reopened.store.close?.(); + reopened.runStore.close?.(); + reopened.runtimeEventStore.close(); + } + } finally { + first.runtimeEventStore.close(); + await rm(root, { recursive: true, force: true }); + } + }); + test('reopens and repairs every committed continuation prefix after SIGKILL', { timeout: CRASH_HARNESS_TIMEOUT_MS, }, async () => { diff --git a/packages/runtime/src/__tests__/runtime-continuation.test.ts b/packages/runtime/src/__tests__/runtime-continuation.test.ts index 678a0f96fe..aaa39b5000 100644 --- a/packages/runtime/src/__tests__/runtime-continuation.test.ts +++ b/packages/runtime/src/__tests__/runtime-continuation.test.ts @@ -81,6 +81,7 @@ test('RuntimeContinuationPlanner reads the durable source boundary and allocates const planner = new RuntimeContinuationPlanner({ readSourceRun: async () => runHeader('run-1'), readImmutableRuntimePrefix: async () => sourcePrefix, + readSandboxBoundaryRequests: async () => [], newId: () => ids.shift() ?? 'unexpected-id', }); @@ -129,6 +130,97 @@ test('RuntimeContinuationPlanner reads the durable source boundary and allocates }); }); +test('RuntimeContinuationPlanner does not carry the durable negotiation projection', async () => { + const sourceEvents = [ + event({ + id: 'source-user', + role: 'user', + author: 'user', + content: { kind: 'text', text: 'Need network access.' }, + }), + event({ + id: 'source-terminal', + role: 'system', + author: 'system', + status: 'failed', + actions: { endInvocation: true }, + }), + ]; + const planner = new RuntimeContinuationPlanner({ + readSourceRun: async () => runHeader('run-1'), + readImmutableRuntimePrefix: async () => immutablePrefix(sourceEvents), + readSandboxBoundaryRequests: async () => [ + { + sessionId: 'session-1', + requestId: 'boundary-1', + status: 'denied', + baseRevision: 0, + expansion: { network: { enabled: true } }, + justification: 'Need network access.', + createdAt: 1, + settledAt: 2, + turnId: 'turn-1', + runId: 'run-1', + }, + ], + newId: (() => { + const ids = ['invocation-2', 'run-2', 'turn-2', 'claim-2']; + return () => ids.shift() ?? 'unexpected-id'; + })(), + }); + + const plan = await planner.plan({ + sessionId: 'session-1', + sourceRunId: 'run-1', + currentCwd: '/workspace/repo', + sourceWorkspaceIdentity: 'workspace-1', + currentWorkspaceIdentity: 'workspace-1', + backgroundOperationsSettled: true, + admissionRoute: sameRouteAdmission(), + availableToolNames: [], + }); + + assert.equal(plan.disposition, 'continue'); + assert.equal('sandboxBoundaryNegotiationState' in (plan.continuation ?? {}), false); +}); + +test('RuntimeContinuationPlanner parks when the durable boundary reader is absent', async () => { + const sourceEvents = [ + event({ + id: 'source-user', + role: 'user', + author: 'user', + content: { kind: 'text', text: 'continue' }, + }), + event({ + id: 'source-terminal', + role: 'system', + author: 'system', + status: 'failed', + actions: { endInvocation: true }, + }), + ]; + const planner = new RuntimeContinuationPlanner({ + readSourceRun: async () => runHeader('run-1'), + readImmutableRuntimePrefix: async () => immutablePrefix(sourceEvents), + newId: () => 'unused', + }); + + const plan = await planner.plan({ + sessionId: 'session-1', + sourceRunId: 'run-1', + admissionRoute: sameRouteAdmission(), + currentCwd: '/workspace/repo', + sourceWorkspaceIdentity: 'workspace-1', + currentWorkspaceIdentity: 'workspace-1', + backgroundOperationsSettled: true, + availableToolNames: [], + }); + + assert.equal(plan.disposition, 'park'); + assert.deepEqual(plan.rejectionReasons, ['continuation_authority_unavailable']); +}); + test('RuntimeContinuationPlanner parks with a stable reason when the ledger cannot be read', async () => { const planner = new RuntimeContinuationPlanner({ readSourceRun: async () => runHeader('run-1'), @@ -165,6 +257,7 @@ test('RuntimeContinuationPlanner derives terminal repair from durable run and ev content: { kind: 'text', text: 'continue' }, }), ]), + readSandboxBoundaryRequests: async () => [], newId: () => 'fresh-id', }); @@ -202,6 +295,7 @@ test('RuntimeContinuationPlanner parks when the terminal run header disagrees wi actions: { endInvocation: true }, }), ]), + readSandboxBoundaryRequests: async () => [], newId: () => 'fresh-id', }); @@ -246,6 +340,7 @@ test('RuntimeContinuationPlanner rejects immutable output after the source termi content: { kind: 'text', text: 'must invalidate the boundary' }, }), ]), + readSandboxBoundaryRequests: async () => [], newId: () => 'fresh-id', }); @@ -292,6 +387,7 @@ test('RuntimeContinuationPlanner uses canonical provider items for composite hea actions: { endInvocation: true }, }), ]), + readSandboxBoundaryRequests: async () => [], newId: () => `fresh-id-${++nextId}`, }); diff --git a/packages/runtime/src/__tests__/runtime-resume.test.ts b/packages/runtime/src/__tests__/runtime-resume.test.ts index a751d8d894..0a3a2375dc 100644 --- a/packages/runtime/src/__tests__/runtime-resume.test.ts +++ b/packages/runtime/src/__tests__/runtime-resume.test.ts @@ -307,6 +307,7 @@ describe('runtime resume phase 1 safe-boundary continuation', () => { const events = runId === 'run-2' ? childEvents : rootEvents; return immutablePrefix(upToEventSeq === undefined ? events : events.slice(0, upToEventSeq)); }, + readSandboxBoundaryRequests: async () => [], newId: (() => { let next = 2; return () => `generated-${++next}`; diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 4d77f850d0..34cdf294bc 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -13257,6 +13257,12 @@ class MemorySessionStore implements SessionStore { return request; } + async listSandboxBoundaryRequests(sessionId: string): Promise { + return [...this.sandboxBoundaryRequests.values()].filter( + (request) => request.sessionId === sessionId, + ); + } + async listPendingSandboxBoundaryRequests(sessionId: string): Promise { return [...this.sandboxBoundaryRequests.values()].filter( (request) => request.sessionId === sessionId && request.status === 'pending', diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index fe67a5fffb..3d87af07f5 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -78,6 +78,7 @@ import type { HostedInteractionBridge, } from '@maka/core/backend-types'; import type { RuntimeEvent } from '@maka/core/runtime-event'; +import type { SandboxBoundaryNegotiationState } from '@maka/core/sandbox-boundary'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import type { UserQuestionResponse } from '@maka/core/user-question'; import { DEFAULT_TOOL_MODE, isToolMode, type ToolMode } from '@maka/core/tool-mode'; @@ -1325,6 +1326,7 @@ export class AiSdkBackend implements AgentBackend { invocationId: string | undefined; hostedInteraction: HostedInteractionBridge | undefined; orchestrationMode: EffectiveOrchestration['mode']; + sandboxBoundaryNegotiationState?: SandboxBoundaryNegotiationState; scope: () => TurnScope; }): ToolRuntime { const input = this.input; @@ -1345,6 +1347,9 @@ export class AiSdkBackend implements AgentBackend { ...(identity.runId ? { runId: identity.runId } : {}), orchestrationMode: identity.orchestrationMode, ...(identity.invocationId ? { invocationId: identity.invocationId } : {}), + ...(identity.sandboxBoundaryNegotiationState + ? { sandboxBoundaryNegotiationState: identity.sandboxBoundaryNegotiationState } + : {}), prepareDurableProjectionArtifact: input.prepareDurableProjectionArtifact, spawnChildSession: input.spawnChildSession, listChildAgents: input.listChildAgents, @@ -1400,7 +1405,11 @@ export class AiSdkBackend implements AgentBackend { const orchestration = input.orchestration ?? resolveEffectiveOrchestration(this.input.header.orchestrationMode, undefined); + if (input.continuation && input.continuation.sandboxBoundaryNegotiationState === undefined) { + throw new Error('Runtime continuation is missing authenticated sandbox negotiation state'); + } let scope: TurnScope; + const sandboxBoundaryNegotiationState = input.continuation?.sandboxBoundaryNegotiationState; scope = new TurnScope( input.turnId, input.runId, @@ -1411,6 +1420,7 @@ export class AiSdkBackend implements AgentBackend { invocationId: input.invocationId ?? input.runId, hostedInteraction: input.hostedInteraction, orchestrationMode: orchestration.mode, + ...(sandboxBoundaryNegotiationState ? { sandboxBoundaryNegotiationState } : {}), scope: () => scope, }), ); diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index 1da029c273..de9f0546fe 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -49,6 +49,12 @@ import type { TurnStateMessage, } from '@maka/core/session'; import { isDeepStrictEqual } from 'node:util'; +import { + createSandboxBoundaryFinalizationState, + projectSandboxBoundaryNegotiation, + type SandboxBoundaryRequest, + type SandboxBoundaryNegotiationState, +} from '@maka/core/sandbox-boundary'; import type { UserMessageInput } from '@maka/core/runtime-inputs'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import { @@ -760,11 +766,13 @@ export class RuntimeKernel implements RuntimeKernelLike { targetProviderStateIdentity, targetModelId: header.model, }; - const sourceEvents = await revalidateContinuationBoundary( + const revalidatedBoundary = await revalidateContinuationBoundary( continuationAuthority, continuation, admissionRoute, + await readRequiredSandboxBoundaryRequests(this.deps.store, continuation.sessionId), ); + const sourceEvents = revalidatedBoundary.events; assertContinuationSourceUnchanged(continuation, sourceRun, sourceEvents); await this.revalidateContinuationSafety(continuation); @@ -918,6 +926,7 @@ export class RuntimeKernel implements RuntimeKernelLike { }, options.onRunStarted, () => this.revalidateContinuationSafety(continuation), + revalidatedBoundary.sandboxBoundaryNegotiationState, ); } @@ -1291,6 +1300,7 @@ export class RuntimeKernel implements RuntimeKernelLike { messageOwner?: RuntimeMessageRunIdentity, onRunStarted?: () => void | Promise, revalidateSafety?: () => Promise, + authenticatedSandboxBoundaryNegotiationState?: SandboxBoundaryNegotiationState, ): AsyncIterable { const sessionEvents = new DeliveryAckQueue(); const { abortController, release: releaseExecutionAbort } = @@ -1345,6 +1355,11 @@ export class RuntimeKernel implements RuntimeKernelLike { throw new Error('Durable continuation is missing its start admission'); })(), ...(run.toolBoundaryProtocol ? { toolBoundaryProtocol: run.toolBoundaryProtocol } : {}), + authenticatedSandboxBoundaryNegotiationState: + authenticatedSandboxBoundaryNegotiationState ?? + (() => { + throw new Error('Durable continuation is missing sandbox negotiation state'); + })(), }); } catch (error) { releaseExecutionAbort(); @@ -2778,7 +2793,11 @@ async function revalidateContinuationBoundary( store: RuntimeContinuationAuthorityStore, continuation: RuntimeContinuation, admissionRoute: ContinuationReplayAdmissionRoute, -): Promise { + durableSandboxBoundaryRequests: readonly SandboxBoundaryRequest[], +): Promise<{ + events: RuntimeEvent[]; + sandboxBoundaryNegotiationState: SandboxBoundaryNegotiationState; +}> { if ( !continuation.boundary || !continuation.providerReplayDigest || @@ -2828,7 +2847,35 @@ async function revalidateContinuationBoundary( 'Runtime continuation replay changed after planning', ); } - return [...prefixes.at(-1)!.events]; + const trimmedSuffixEventIds = new Set( + replay.plan.segments.flatMap((segment) => segment.trimmedSuffixEventIds), + ); + const negotiationEvents = prefixes + .flatMap((prefix) => prefix.events) + .filter((event) => !trimmedSuffixEventIds.has(event.id)); + const negotiation = projectSandboxBoundaryNegotiation( + negotiationEvents, + durableSandboxBoundaryRequests, + ); + if (negotiation.kind !== 'valid') { + throw new RuntimeContinuationRevalidationError( + 'source_replay_changed', + `Runtime continuation sandbox negotiation is invalid: ${negotiation.reason}`, + ); + } + const sandboxBoundaryNegotiationState = negotiation.state; + return { events: [...prefixes.at(-1)!.events], sandboxBoundaryNegotiationState }; +} + +async function readRequiredSandboxBoundaryRequests( + store: SessionStore, + sessionId: string, +): Promise { + const reader = store.listSandboxBoundaryRequests; + if (typeof reader !== 'function') { + throw new Error('Runtime continuation requires a durable sandbox boundary request reader'); + } + return reader.call(store, sessionId); } function continuationClaimForExecution( @@ -2944,6 +2991,7 @@ function consumeAdmittedRuntimeContinuation(input: { admissionRoute: ContinuationReplayAdmissionRoute; startAdmission: RuntimeContinuationStartAdmissionProof; toolBoundaryProtocol?: ToolBoundaryProtocol; + authenticatedSandboxBoundaryNegotiationState: SandboxBoundaryNegotiationState; }): RuntimeContinuationMetadata { const { continuation } = input; assertRuntimeContinuationEnvelope(continuation); @@ -3013,6 +3061,7 @@ function consumeAdmittedRuntimeContinuation(input: { sourceRunId: continuation.sourceRunId, sourceTurnId: continuation.sourceTurnId, sourceRuntimeEventHighWater: continuation.sourceRuntimeEventHighWater, + sandboxBoundaryNegotiationState: input.authenticatedSandboxBoundaryNegotiationState, }; } diff --git a/packages/runtime/src/runtime-resume.ts b/packages/runtime/src/runtime-resume.ts index 849972c74a..63cc199da8 100644 --- a/packages/runtime/src/runtime-resume.ts +++ b/packages/runtime/src/runtime-resume.ts @@ -34,6 +34,7 @@ import type { } from '@maka/core/runtime-boundary'; import type { AgentRunHeader } from '@maka/core/agent-run'; import type { ContinuationClaimStateV1 } from '@maka/core/runtime-event-store'; +import { type SandboxBoundaryRequest } from '@maka/core/sandbox-boundary'; import { isDeepStrictEqual } from 'node:util'; import { buildContinuationReplayPlan, @@ -376,6 +377,8 @@ export interface RuntimeContinuationPlannerDeps { runId: string; upToEventSeq?: number; }): Promise; + /** Authoritative interaction log used to cover event/row crash gaps; absence parks admission. */ + readSandboxBoundaryRequests?(sessionId: string): Promise; readContinuationClaimStateByBoundary?( boundaryDigest: RuntimeBoundaryDigest, ): Promise; @@ -443,6 +446,12 @@ export class RuntimeContinuationPlanner { `continuation replay segment ${replay.segmentIndex} is not replayable: ${replay.reason}`, ); } + if (!this.deps.readSandboxBoundaryRequests) { + return parkedPlan( + 'continuation_authority_unavailable', + 'sandbox boundary interaction log is unavailable', + ); + } let durableClaimState: ContinuationClaimStateV1 | undefined; try { durableClaimState = await this.deps.readContinuationClaimStateByBoundary?.( diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index fe98e096f7..ffa5f47c0a 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -574,6 +574,7 @@ export interface SessionStore { createSandboxBoundaryRequest?( input: CreateSandboxBoundaryRequest, ): Promise; + listSandboxBoundaryRequests?(sessionId: string): Promise; listPendingSandboxBoundaryRequests?(sessionId: string): Promise; listSandboxBoundaryRestartClosures?(sessionId: string): Promise; settleSandboxBoundaryRequest?( @@ -2060,6 +2061,13 @@ export class SessionManager { } return authority.readImmutableRuntimePrefix(prefixInput); }, + readSandboxBoundaryRequests: async (targetSessionId) => { + const reader = this.deps.store.listSandboxBoundaryRequests; + if (typeof reader !== 'function') { + throw new Error('sandbox boundary interaction log is unavailable'); + } + return reader.call(this.deps.store, targetSessionId); + }, readContinuationClaimStateByBoundary: async (boundaryDigest) => { const authority = runtimeContinuationAuthority(this.deps.runtimeEventStore); if (!authority) throw new Error('Continuation authority is not configured'); diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index 5214ffda49..5bde8dcded 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -28,6 +28,8 @@ import { type SandboxBoundaryRequest, type SandboxBoundarySettlement, type SettleSandboxBoundaryRequest, + SANDBOX_BOUNDARY_FAILURE_ROUND_LIMIT, + type SandboxBoundaryNegotiationState, } from '@maka/core/sandbox-boundary'; import { serializedByteLength } from '@maka/core/serialized-byte-length'; import { encodeToolStepProgress, ToolOutcomeUnknownError } from '@maka/core/events'; @@ -326,7 +328,6 @@ export const DEFAULT_PERMISSION_TIMEOUT_MS = 300_000; * identical *failures* is. */ export const LOOP_GATE_IDENTICAL_THRESHOLD = 3; -const SANDBOX_BOUNDARY_FAILURE_ROUND_LIMIT = 3; type SandboxBoundaryFailureKind = 'invalid' | 'unresolved'; type SandboxBoundaryFailureDetails = Extract['sandboxFailure']; @@ -426,6 +427,8 @@ export interface ToolRuntimeInput { recordToolArtifacts?: ToolArtifactRecorder; /** Optional Phase 2 T1/T2 commit boundary for hosts that persist RuntimeEvents. */ runtimeCommitSink?: RuntimeCommitSink; + /** Authenticated continuation projection; never grants execution authority. */ + sandboxBoundaryNegotiationState?: SandboxBoundaryNegotiationState; /** Host-owned managed mutation admission. It may never fall back after returning a profile. */ admitManagedMutation?: (input: { readonly operationId: string; @@ -590,6 +593,13 @@ export class ToolRuntime { this.turnId = input.turnId; this.hostedInteraction = hosted; this.readExecutionBoundary = input.readExecutionBoundary; + const negotiation = input.sandboxBoundaryNegotiationState; + if (negotiation) { + this.sandboxBoundaryDenied = negotiation.denied; + this.sandboxBoundaryInvalidRounds = negotiation.invalidRounds; + this.sandboxBoundaryUnresolvedRounds = negotiation.unresolvedRounds; + this.sandboxBoundaryFinalizationRequested = negotiation.finalizationRequested; + } } async endTurn(reason: 'completed' | 'aborted' = 'completed'): Promise { @@ -1255,6 +1265,9 @@ export class ToolRuntime { }; if (admissionFailure) { const boundaryKind = boundaryAuthorityAttempt ? ('invalid' as const) : undefined; + const boundaryFailure = boundaryKind + ? ({ reason: 'invalid_boundary_declaration' } as const) + : undefined; if (boundaryKind) { this.recordSandboxBoundaryFailure( boundaryKind, @@ -1262,7 +1275,7 @@ export class ToolRuntime { sandboxBoundaryDecisionGeneration, ); } - await refuseBeforeDispatch(admissionFailure); + await refuseBeforeDispatch(admissionFailure, boundaryFailure); trace?.emit('tool', 'tool_failed', 'Tool rejected by exclusive-step admission', { toolUseId, toolName: tool.name, @@ -1270,11 +1283,14 @@ export class ToolRuntime { status: 'error', errorClass: 'ExclusiveStepConflict', }); - this.recordLoopGateOutcome(callSignature, true, boundaryKind); + this.recordLoopGateOutcome(callSignature, true, boundaryKind, boundaryFailure); return this.errorReturn(admissionFailure); } if (permissionArgsError !== undefined) { const boundaryKind = boundaryAuthorityAttempt ? ('invalid' as const) : undefined; + const boundaryFailure = boundaryKind + ? ({ reason: 'invalid_boundary_declaration' } as const) + : undefined; if (boundaryKind) { this.recordSandboxBoundaryFailure( boundaryKind, @@ -1305,7 +1321,7 @@ export class ToolRuntime { args: executionArgs, error: permissionArgsError, }); - await refuseBeforeDispatch(msg); + await refuseBeforeDispatch(msg, boundaryFailure); this.input.recordToolInvocation?.({ sessionId: this.input.sessionId, turnId, @@ -1341,7 +1357,7 @@ export class ToolRuntime { status: 'error', errorClass: 'InvalidArguments', }); - this.recordLoopGateOutcome(callSignature, true, boundaryKind); + this.recordLoopGateOutcome(callSignature, true, boundaryKind, boundaryFailure); return this.errorReturn(msg); } @@ -3320,9 +3336,16 @@ export function formatToolArgsViolationText(input: { function sandboxBoundaryFailureSignal( metadata: ReturnType, ): Extract['sandboxFailure'] { - if (metadata?.reason !== 'sandbox_boundary_required' && metadata?.reason !== 'requires_bypass') { + if ( + metadata?.reason !== 'invalid_boundary_declaration' && + metadata?.reason !== 'sandbox_boundary_required' && + metadata?.reason !== 'requires_bypass' + ) { return undefined; } + if (metadata.reason === 'invalid_boundary_declaration') { + return { reason: 'invalid_boundary_declaration' }; + } return { reason: metadata.reason, ...(metadata.requiredExpansion diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index dc62748e68..5772859cc6 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -381,6 +381,8 @@ async function createExecutionStoresForWrite sessionStore.readExecutionBoundary(sessionId)), createSandboxBoundaryRequest: (input) => run(() => sessionStore.createSandboxBoundaryRequest(input)), + listSandboxBoundaryRequests: (sessionId) => + run(() => sessionStore.listSandboxBoundaryRequests(sessionId)), readSandboxBoundaryRequest: (sessionId, requestId) => run(() => sessionStore.readSandboxBoundaryRequest(sessionId, requestId)), listPendingSandboxBoundaryRequests: (sessionId) => diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index 1831eb21b9..34fd0587b9 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -390,6 +390,7 @@ export interface SessionAuthorityStore extends SessionStore, MessageAdmissionSto createSandboxBoundaryRequest( input: CreateSandboxBoundaryRequest, ): Promise; + listSandboxBoundaryRequests(sessionId: string): Promise; readSandboxBoundaryRequest( sessionId: string, requestId: string, @@ -812,6 +813,11 @@ class SqliteSessionStore implements SessionAuthorityStore { return this.metadata.readSandboxBoundaryRequest(sessionId, requestId); } + async listSandboxBoundaryRequests(sessionId: string): Promise { + await this.ensureReady(); + return this.metadata.listSandboxBoundaryRequests(sessionId); + } + async listPendingSandboxBoundaryRequests(sessionId: string): Promise { await this.ensureReady(); return this.metadata.listPendingSandboxBoundaryRequests(sessionId); diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts index 770147b5d3..8e21323c87 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -626,6 +626,27 @@ export class SqliteSessionMetadataStore { }); } + async listSandboxBoundaryRequests(sessionId: string): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + return this.transaction(() => { + const record = this.readRecordSync(sessionId); + if (!record) throw new SessionNotFoundError(sessionId); + this.ensureGenesisExecutionBoundary(record.header); + const rows = this.db + .prepare( + ` + SELECT ${SANDBOX_BOUNDARY_REQUEST_COLUMNS} + FROM sandbox_boundary_log + WHERE session_id = ? AND entry_kind = 'expansion_request' + ORDER BY created_at, entry_id + `, + ) + .all(sessionId) as unknown as SandboxBoundaryRequestRow[]; + return rows.map(decodeSandboxBoundaryRequestRow); + }); + } + async listPendingSandboxBoundaryRequests(sessionId: string): Promise { this.assertOpen(); assertSafeSessionId(sessionId);