Skip to content

Commit 73e2cef

Browse files
author
testikun
committed
fix(runtime): preserve sandbox negotiation across continuations (#3731)
Generated-by: OpenAI Codex
1 parent 28bdbc6 commit 73e2cef

17 files changed

Lines changed: 1170 additions & 19 deletions

packages/core/src/__tests__/sandbox-boundary.test.ts

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,11 @@ import {
2727
decodeExecutionBoundary,
2828
executionBoundaryContains,
2929
executionBoundaryDisplayMode,
30+
projectSandboxBoundaryNegotiation,
31+
type SandboxBoundaryRequest,
3032
validateSandboxBoundaryExpansion,
3133
} from '../sandbox-boundary.js';
34+
import type { RuntimeEvent } from '../runtime-event.js';
3235
import {
3336
canReadPath,
3437
canWritePath,
@@ -375,6 +378,250 @@ describe('SandboxBoundaryExpansion', () => {
375378
});
376379
});
377380

381+
describe('projectSandboxBoundaryNegotiation', () => {
382+
const base = (id: string, partial: Partial<RuntimeEvent>): RuntimeEvent => ({
383+
id,
384+
invocationId: 'invocation-1',
385+
runId: 'run-1',
386+
sessionId: 'session-1',
387+
turnId: 'turn-1',
388+
ts: 1,
389+
partial: false,
390+
role: 'system',
391+
author: 'system',
392+
...partial,
393+
});
394+
395+
const request = (id: string, requestId: string, toolUseId: string): RuntimeEvent =>
396+
base(id, {
397+
refs: { toolCallId: toolUseId },
398+
actions: {
399+
stateDelta: {
400+
sandboxBoundaryRequest: {
401+
requestId,
402+
toolUseId,
403+
justification: 'Need the smallest boundary expansion.',
404+
expansion: { network: { enabled: true } },
405+
},
406+
},
407+
},
408+
});
409+
410+
const decision = (
411+
id: string,
412+
requestId: string,
413+
toolUseId: string,
414+
status: 'approved' | 'denied' | 'conflict',
415+
): RuntimeEvent =>
416+
base(id, {
417+
author: 'user',
418+
refs: { toolCallId: toolUseId },
419+
actions: {
420+
stateDelta: {
421+
sandboxBoundaryDecision: {
422+
requestId,
423+
decision: status === 'denied' ? 'deny' : 'allow',
424+
status,
425+
revision: status === 'approved' ? 1 : 0,
426+
},
427+
},
428+
},
429+
});
430+
431+
const failurePair = (
432+
id: string,
433+
toolName: string,
434+
toolCallId: string,
435+
reason: 'invalid_boundary_declaration' | 'sandbox_boundary_required',
436+
hidden = false,
437+
): RuntimeEvent[] => [
438+
base(`${id}-call`, {
439+
role: 'model',
440+
author: 'agent',
441+
...(hidden ? { modelVisibility: 'hidden' as const } : {}),
442+
refs: { toolCallId, stepId: `${id}-step` },
443+
content: {
444+
kind: 'function_call',
445+
id: toolCallId,
446+
name: toolName,
447+
args: toolName === 'Bash' ? { boundary_intent: 'expand' } : {},
448+
},
449+
}),
450+
base(`${id}-response`, {
451+
role: 'tool',
452+
author: 'tool',
453+
...(hidden ? { modelVisibility: 'hidden' as const } : {}),
454+
refs: { toolCallId, stepId: `${id}-step` },
455+
content: {
456+
kind: 'function_response',
457+
id: toolCallId,
458+
name: toolName,
459+
isError: true,
460+
result: {
461+
kind: 'text',
462+
text: 'Sandbox boundary correction failed.',
463+
sandboxFailure: { reason },
464+
},
465+
},
466+
}),
467+
];
468+
469+
test('restores denial and both correction budgets, including hidden Code Mode calls', () => {
470+
const events = [
471+
request('request-1', 'boundary-1', 'tool-1'),
472+
decision('decision-1', 'boundary-1', 'tool-1', 'denied'),
473+
...failurePair(
474+
'invalid-1',
475+
'request_sandbox_boundary',
476+
'tool-2',
477+
'invalid_boundary_declaration',
478+
),
479+
...failurePair('unresolved-1', 'Bash', 'tool-3', 'sandbox_boundary_required', true),
480+
];
481+
482+
assert.deepEqual(projectSandboxBoundaryNegotiation(events), {
483+
kind: 'valid',
484+
state: {
485+
denied: true,
486+
invalidRounds: 1,
487+
unresolvedRounds: 1,
488+
finalizationRequested: false,
489+
},
490+
});
491+
});
492+
493+
test('approved requests reset prior correction state', () => {
494+
const events = [
495+
...failurePair(
496+
'invalid-1',
497+
'request_sandbox_boundary',
498+
'tool-1',
499+
'invalid_boundary_declaration',
500+
),
501+
request('request-1', 'boundary-1', 'tool-2'),
502+
decision('decision-1', 'boundary-1', 'tool-2', 'approved'),
503+
];
504+
assert.deepEqual(projectSandboxBoundaryNegotiation(events), {
505+
kind: 'valid',
506+
state: {
507+
denied: false,
508+
invalidRounds: 0,
509+
unresolvedRounds: 0,
510+
finalizationRequested: false,
511+
},
512+
});
513+
});
514+
515+
test('fails closed for malformed or legacy boundary facts', () => {
516+
const malformed = request('request-1', 'boundary-1', 'tool-1');
517+
malformed.actions!.stateDelta!.sandboxBoundaryRequest = {
518+
requestId: 'boundary-1',
519+
toolUseId: 'tool-1',
520+
justification: 'missing expansion',
521+
};
522+
assert.equal(projectSandboxBoundaryNegotiation([malformed]).kind, 'invalid');
523+
524+
const [call, response] = failurePair(
525+
'legacy-1',
526+
'request_sandbox_boundary',
527+
'tool-1',
528+
'invalid_boundary_declaration',
529+
);
530+
(response.content as Extract<RuntimeEvent['content'], { kind: 'function_response' }>).result = {
531+
kind: 'text',
532+
text: 'Tool arguments failed validation',
533+
};
534+
assert.equal(projectSandboxBoundaryNegotiation([call, response]).kind, 'invalid');
535+
});
536+
537+
test('fails closed when boundary facts do not preserve call identity', () => {
538+
const mismatchedDecision = decision('decision-1', 'boundary-1', 'other-tool', 'denied');
539+
assert.equal(
540+
projectSandboxBoundaryNegotiation([
541+
request('request-1', 'boundary-1', 'tool-1'),
542+
mismatchedDecision,
543+
]).kind,
544+
'invalid',
545+
);
546+
547+
const [call, response] = failurePair(
548+
'invalid-1',
549+
'request_sandbox_boundary',
550+
'tool-1',
551+
'invalid_boundary_declaration',
552+
);
553+
(response.content as Extract<RuntimeEvent['content'], { kind: 'function_response' }>).name =
554+
'Bash';
555+
assert.equal(projectSandboxBoundaryNegotiation([call, response]).kind, 'invalid');
556+
});
557+
558+
test('fails closed when a boundary failure marker is attached to a non-boundary tool', () => {
559+
const [call, response] = failurePair(
560+
'forged-1',
561+
'Read',
562+
'tool-1',
563+
'invalid_boundary_declaration',
564+
);
565+
assert.equal(projectSandboxBoundaryNegotiation([call, response]).kind, 'invalid');
566+
});
567+
568+
test('requests finalization after the bounded correction budget', () => {
569+
const events = [
570+
...failurePair(
571+
'invalid-1',
572+
'request_sandbox_boundary',
573+
'tool-1',
574+
'invalid_boundary_declaration',
575+
),
576+
...failurePair(
577+
'invalid-2',
578+
'request_sandbox_boundary',
579+
'tool-2',
580+
'invalid_boundary_declaration',
581+
),
582+
...failurePair(
583+
'invalid-3',
584+
'request_sandbox_boundary',
585+
'tool-3',
586+
'invalid_boundary_declaration',
587+
),
588+
];
589+
const result = projectSandboxBoundaryNegotiation(events);
590+
assert.equal(result.kind, 'valid');
591+
if (result.kind === 'valid') assert.equal(result.state.finalizationRequested, true);
592+
});
593+
594+
test('restores a durable denial when the RuntimeEvent ack was lost', () => {
595+
const durableRequest: SandboxBoundaryRequest = {
596+
sessionId: 'session-1',
597+
requestId: 'boundary-1',
598+
status: 'denied',
599+
baseRevision: 0,
600+
expansion: { network: { enabled: true } },
601+
justification: 'Need network access.',
602+
createdAt: 1,
603+
settledAt: 2,
604+
turnId: 'turn-1',
605+
runId: 'run-1',
606+
};
607+
assert.deepEqual(
608+
projectSandboxBoundaryNegotiation(
609+
[base('source-event', { turnId: 'turn-1', runId: 'run-1' })],
610+
[durableRequest],
611+
),
612+
{
613+
kind: 'valid',
614+
state: {
615+
denied: true,
616+
invalidRounds: 0,
617+
unresolvedRounds: 0,
618+
finalizationRequested: false,
619+
},
620+
},
621+
);
622+
});
623+
});
624+
378625
describe('ExecutionBoundary', () => {
379626
test('decodes only a complete full boundary snapshot', () => {
380627
const managed = createGenesisExecutionBoundary('ask');

packages/core/src/backend-types.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,11 @@ import type {
3838
} from './events.js';
3939
import type { InteractionClosureReason } from './interaction.js';
4040
import type { RuntimeEvent } from './runtime-event.js';
41-
import type { SandboxBoundaryResponse, SandboxBoundarySettlement } from './sandbox-boundary.js';
41+
import type {
42+
SandboxBoundaryNegotiationState,
43+
SandboxBoundaryResponse,
44+
SandboxBoundarySettlement,
45+
} from './sandbox-boundary.js';
4246
import type { StoredMessage, PersistedBackendKind } from './session.js';
4347
import type { UserQuestionResponse } from './user-question.js';
4448
import type { ContextBudgetDiagnostic } from './usage-stats/types.js';
@@ -50,6 +54,8 @@ export interface RuntimeContinuationMetadata {
5054
sourceRunId: string;
5155
sourceTurnId: string;
5256
sourceRuntimeEventHighWater: number;
57+
/** Authenticated negotiation projection; never grants execution authority. */
58+
sandboxBoundaryNegotiationState?: SandboxBoundaryNegotiationState;
5359
}
5460

5561
export interface BackendSendInput {

packages/core/src/events.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -731,7 +731,7 @@ export interface SandboxDenialRecovery extends SandboxDenialSignal {
731731
}
732732

733733
export interface SandboxBoundaryFailureSignal {
734-
reason: 'sandbox_boundary_required' | 'requires_bypass';
734+
reason: 'invalid_boundary_declaration' | 'sandbox_boundary_required' | 'requires_bypass';
735735
requiredExpansion?: SandboxBoundaryExpansion;
736736
source?: 'client_capability';
737737
}

0 commit comments

Comments
 (0)