blob: 28f9a630697c7991e43a7ba3a61588b4c324053c [file]
import {
APPROVALS_REVIEWERS,
APPROVAL_RISK_LEVELS,
type ApprovalRiskLevel,
type ApprovalsReviewer,
} from './permission.js';
import { defineObjectShape, hasExactShape } from './record-schema.js';
import {
InteractionPermissionProjectionError,
decodeInteractionPermissionPrompt,
projectInteractionReviewText,
projectInteractionPermissionPrompt,
type InteractionPermissionPrompt,
type InteractionPermissionProjectionInput,
} from './interaction-permission-review.js';
import {
SANDBOX_BOUNDARY_REQUEST_STATUSES,
validateSandboxBoundaryExpansion,
type SandboxBoundaryExpansion,
type SandboxBoundaryRequestStatus,
} from './sandbox-boundary.js';
export * from './interaction-permission-review.js';
export const INTERACTION_MIN_QUESTIONS = 1;
export const INTERACTION_MAX_QUESTIONS = 3;
export const INTERACTION_MIN_OPTIONS_PER_QUESTION = 2;
export const INTERACTION_MAX_OPTIONS_PER_QUESTION = 3;
export const INTERACTION_ID_MAX_BYTES = 256;
export const INTERACTION_QUESTION_MAX_BYTES = 1024;
export const INTERACTION_OPTION_LABEL_MAX_BYTES = 256;
export const INTERACTION_OPTION_DESCRIPTION_MAX_BYTES = 512;
export const INTERACTION_ANSWER_MAX_BYTES = 2048;
export const INTERACTION_REQUEST_MAX_BYTES = 16 * 1024;
export const INTERACTION_SANDBOX_BOUNDARY_JUSTIFICATION_MAX_CHARS = 2_000;
export const INTERACTION_ANSWER_SERIALIZED_MAX_BYTES = 8 * 1024;
export const INTERACTION_OUTCOME_SERIALIZED_MAX_BYTES = 8 * 1024;
export const INTERACTION_AUTO_REVIEW_RATIONALE_MAX_CHARS = 1_000;
export const INTERACTION_CLOSURE_REASONS = [
'turn_stopped',
'turn_terminal',
'timed_out',
'host_restarted',
] as const;
const UTF8 = new TextEncoder();
export type InteractionClosureReason = (typeof INTERACTION_CLOSURE_REASONS)[number];
export interface InteractionQuestionOption {
readonly label: string;
readonly description?: string;
}
export interface InteractionQuestion {
readonly question: string;
readonly options: readonly InteractionQuestionOption[];
}
export interface InteractionPermissionRequest {
readonly kind: 'permission';
readonly toolUseId: string;
readonly prompt: InteractionPermissionPrompt;
}
export interface InteractionQuestionRequest {
readonly kind: 'question';
readonly toolUseId: string;
readonly questions: readonly InteractionQuestion[];
}
export interface InteractionSandboxBoundaryRequest {
readonly kind: 'sandbox_boundary';
readonly expansion: SandboxBoundaryExpansion;
readonly justification: string;
}
export type InteractionRequest =
| InteractionPermissionRequest
| InteractionQuestionRequest
| InteractionSandboxBoundaryRequest;
export type InteractionPermissionDecisionFields =
| { readonly decision: 'allow'; readonly rememberForTurn: boolean }
| { readonly decision: 'deny'; readonly rememberForTurn: false };
export type InteractionPermissionAnswer = {
readonly kind: 'permission';
} & InteractionPermissionDecisionFields;
export interface InteractionQuestionAnswer {
readonly kind: 'question';
readonly answers: readonly (string | null)[];
}
export interface InteractionSandboxBoundaryAnswer {
readonly kind: 'sandbox_boundary';
readonly decision: 'allow' | 'deny';
}
export type InteractionAnswer =
| InteractionPermissionAnswer
| InteractionQuestionAnswer
| InteractionSandboxBoundaryAnswer;
export type InteractionCanonicalPermissionOutcome = {
readonly kind: 'permission_answer';
readonly reviewer: ApprovalsReviewer;
readonly rationale?: string;
readonly riskLevel?: ApprovalRiskLevel;
readonly committedAt: number;
} & InteractionPermissionDecisionFields;
export interface InteractionCanonicalQuestionOutcome {
readonly kind: 'question_answer';
readonly answers: readonly (string | null)[];
readonly committedAt: number;
}
export interface InteractionCanonicalSandboxBoundaryOutcome {
readonly kind: 'sandbox_boundary_decision';
readonly decision: 'allow' | 'deny';
readonly status: Exclude<SandboxBoundaryRequestStatus, 'pending'>;
readonly committedAt: number;
}
export interface InteractionCanonicalClosureOutcome {
readonly kind: 'closure';
readonly reason: InteractionClosureReason;
readonly committedAt: number;
}
export type InteractionCanonicalOutcome =
| InteractionCanonicalPermissionOutcome
| InteractionCanonicalQuestionOutcome
| InteractionCanonicalSandboxBoundaryOutcome
| InteractionCanonicalClosureOutcome;
export type InteractionQuestionProjectionInput = Pick<
InteractionQuestionRequest,
'toolUseId' | 'questions'
>;
const PERMISSION_REQUEST_SHAPE = defineObjectShape<InteractionPermissionRequest>()(
['kind', 'toolUseId', 'prompt'],
[],
);
const QUESTION_REQUEST_SHAPE = defineObjectShape<InteractionQuestionRequest>()(
['kind', 'toolUseId', 'questions'],
[],
);
const SANDBOX_BOUNDARY_REQUEST_SHAPE = defineObjectShape<InteractionSandboxBoundaryRequest>()(
['kind', 'expansion', 'justification'],
[],
);
const PERMISSION_ANSWER_SHAPE = defineObjectShape<InteractionPermissionAnswer>()(
['kind', 'decision', 'rememberForTurn'],
[],
);
const QUESTION_ANSWER_SHAPE = defineObjectShape<InteractionQuestionAnswer>()(
['kind', 'answers'],
[],
);
const SANDBOX_BOUNDARY_ANSWER_SHAPE = defineObjectShape<InteractionSandboxBoundaryAnswer>()(
['kind', 'decision'],
[],
);
const PERMISSION_OUTCOME_SHAPE = defineObjectShape<InteractionCanonicalPermissionOutcome>()(
['kind', 'reviewer', 'committedAt', 'decision', 'rememberForTurn'],
['rationale', 'riskLevel'],
);
const QUESTION_OUTCOME_SHAPE = defineObjectShape<InteractionCanonicalQuestionOutcome>()(
['kind', 'answers', 'committedAt'],
[],
);
const SANDBOX_BOUNDARY_OUTCOME_SHAPE =
defineObjectShape<InteractionCanonicalSandboxBoundaryOutcome>()(
['kind', 'decision', 'status', 'committedAt'],
[],
);
const CLOSURE_OUTCOME_SHAPE = defineObjectShape<InteractionCanonicalClosureOutcome>()(
['kind', 'reason', 'committedAt'],
[],
);
const QUESTION_SHAPE = defineObjectShape<InteractionQuestion>()(['question', 'options'], []);
const OPTION_SHAPE = defineObjectShape<InteractionQuestionOption>()(['label'], ['description']);
export function decodeInteractionRequest(value: unknown): InteractionRequest {
const record = plainRecord(value, 'Interaction request');
let request: InteractionRequest;
if (record.kind === 'permission') {
exact(record, PERMISSION_REQUEST_SHAPE, 'permission request');
request = {
kind: 'permission',
toolUseId: boundedString(record.toolUseId, 'toolUseId', INTERACTION_ID_MAX_BYTES),
prompt: decodeInteractionPermissionPrompt(record.prompt),
};
} else if (record.kind === 'question') {
exact(record, QUESTION_REQUEST_SHAPE, 'question request');
request = {
kind: 'question',
toolUseId: boundedString(record.toolUseId, 'toolUseId', INTERACTION_ID_MAX_BYTES),
questions: plainArray(
record.questions,
'questions',
INTERACTION_MIN_QUESTIONS,
INTERACTION_MAX_QUESTIONS,
).map(decodeQuestion),
};
} else if (record.kind === 'sandbox_boundary') {
exact(record, SANDBOX_BOUNDARY_REQUEST_SHAPE, 'sandbox boundary request');
const expansion = validateSandboxBoundaryExpansion(record.expansion);
if (!expansion.ok) throw new Error('Invalid sandbox boundary expansion');
request = {
kind: 'sandbox_boundary',
expansion: expansion.expansion,
justification: boundedCharacterString(
record.justification,
'sandbox boundary justification',
INTERACTION_SANDBOX_BOUNDARY_JUSTIFICATION_MAX_CHARS,
),
};
} else {
throw new Error('Invalid Interaction request kind');
}
if (request.kind !== 'sandbox_boundary') {
serializedLimit(request, INTERACTION_REQUEST_MAX_BYTES, 'Interaction request');
}
return deepFreeze(request);
}
export function decodeInteractionAnswer(value: unknown): InteractionAnswer {
const record = plainRecord(value, 'Interaction answer');
let answer: InteractionAnswer;
if (record.kind === 'permission') {
exact(record, PERMISSION_ANSWER_SHAPE, 'permission answer');
const decision = oneOf(record.decision, ['allow', 'deny'] as const, 'decision');
const rememberForTurn = boolean(record.rememberForTurn, 'rememberForTurn');
if (decision === 'deny' && rememberForTurn)
throw new Error('Denied permission cannot be remembered');
answer =
decision === 'deny'
? { kind: 'permission', decision, rememberForTurn: false }
: { kind: 'permission', decision, rememberForTurn };
} else if (record.kind === 'question') {
exact(record, QUESTION_ANSWER_SHAPE, 'question answer');
answer = { kind: 'question', answers: decodeAnswers(record.answers) };
} else if (record.kind === 'sandbox_boundary') {
exact(record, SANDBOX_BOUNDARY_ANSWER_SHAPE, 'sandbox boundary answer');
answer = {
kind: 'sandbox_boundary',
decision: oneOf(record.decision, ['allow', 'deny'] as const, 'decision'),
};
} else {
throw new Error('Invalid Interaction answer kind');
}
serializedLimit(answer, INTERACTION_ANSWER_SERIALIZED_MAX_BYTES, 'Interaction answer');
return deepFreeze(answer);
}
export function decodeInteractionCanonicalOutcome(value: unknown): InteractionCanonicalOutcome {
const record = plainRecord(value, 'Interaction canonical outcome');
let outcome: InteractionCanonicalOutcome;
if (record.kind === 'permission_answer') {
exact(record, PERMISSION_OUTCOME_SHAPE, 'permission outcome');
const decision = oneOf(record.decision, ['allow', 'deny'] as const, 'decision');
const rememberForTurn = boolean(record.rememberForTurn, 'rememberForTurn');
if (decision === 'deny' && rememberForTurn)
throw new Error('Denied permission cannot be remembered');
const reviewer = oneOf(record.reviewer, APPROVALS_REVIEWERS, 'reviewer');
if (record.rationale !== undefined && reviewer !== 'auto_review') {
throw new Error('Only auto-review permission outcomes can include rationale');
}
const common = {
kind: 'permission_answer' as const,
reviewer,
...(record.rationale === undefined
? {}
: {
rationale: boundedCharacterString(
record.rationale,
'rationale',
INTERACTION_AUTO_REVIEW_RATIONALE_MAX_CHARS,
),
}),
...(record.riskLevel === undefined
? {}
: {
riskLevel: oneOf(record.riskLevel, APPROVAL_RISK_LEVELS, 'riskLevel'),
}),
committedAt: safeInteger(record.committedAt, 'committedAt', false),
};
outcome =
decision === 'deny'
? { ...common, decision, rememberForTurn: false }
: { ...common, decision, rememberForTurn };
} else if (record.kind === 'question_answer') {
exact(record, QUESTION_OUTCOME_SHAPE, 'question outcome');
outcome = {
kind: 'question_answer',
answers: decodeAnswers(record.answers),
committedAt: safeInteger(record.committedAt, 'committedAt', false),
};
} else if (record.kind === 'sandbox_boundary_decision') {
exact(record, SANDBOX_BOUNDARY_OUTCOME_SHAPE, 'sandbox boundary outcome');
const status = oneOf(
record.status,
SANDBOX_BOUNDARY_REQUEST_STATUSES,
'sandbox boundary status',
);
if (status === 'pending') throw new Error('Sandbox boundary outcome is still pending');
const decision = oneOf(record.decision, ['allow', 'deny'] as const, 'decision');
if ((status === 'denied') !== (decision === 'deny')) {
throw new Error('Sandbox boundary outcome decision does not match its status');
}
outcome = {
kind: 'sandbox_boundary_decision',
decision,
status,
committedAt: safeInteger(record.committedAt, 'committedAt', false),
};
} else if (record.kind === 'closure') {
exact(record, CLOSURE_OUTCOME_SHAPE, 'closure outcome');
outcome = {
kind: 'closure',
reason: oneOf(record.reason, INTERACTION_CLOSURE_REASONS, 'closure reason'),
committedAt: safeInteger(record.committedAt, 'committedAt', false),
};
} else {
throw new Error('Invalid Interaction canonical outcome kind');
}
serializedLimit(outcome, INTERACTION_OUTCOME_SERIALIZED_MAX_BYTES, 'Interaction outcome');
return deepFreeze(outcome);
}
export function projectInteractionPermissionRequest(
request: InteractionPermissionProjectionInput,
): InteractionPermissionRequest {
const record = plainRecord(request, 'Permission request');
boundedString(record.requestId, 'requestId', INTERACTION_ID_MAX_BYTES);
const projected: InteractionPermissionRequest = {
kind: 'permission',
toolUseId: boundedString(record.toolUseId, 'toolUseId', INTERACTION_ID_MAX_BYTES),
prompt: projectInteractionPermissionPrompt(request),
};
try {
serializedLimit(projected, INTERACTION_REQUEST_MAX_BYTES, 'Interaction request');
} catch (error) {
if (error instanceof InteractionPermissionProjectionError) throw error;
throw new InteractionPermissionProjectionError();
}
return deepFreeze(projected);
}
export function projectInteractionQuestionRequest(
request: InteractionQuestionProjectionInput,
): InteractionQuestionRequest {
const record = plainRecord(request, 'User question request');
const shape = defineObjectShape<InteractionQuestionProjectionInput>()(
['toolUseId', 'questions'],
[],
);
exact(record, shape, 'question projection');
const decoded = decodeInteractionRequest({
kind: 'question',
toolUseId: record.toolUseId,
questions: record.questions,
}) as InteractionQuestionRequest;
const projected = {
kind: 'question' as const,
toolUseId: decoded.toolUseId,
questions: decoded.questions.map((question) => {
const options = question.options.map((option) => ({
label: projectInteractionReviewText(option.label, INTERACTION_OPTION_LABEL_MAX_BYTES),
...(option.description === undefined
? {}
: {
description: projectInteractionReviewText(
option.description,
INTERACTION_OPTION_DESCRIPTION_MAX_BYTES,
),
}),
}));
if (new Set(options.map((option) => option.label)).size !== options.length)
throw new Error('Question option labels collide after safe projection');
return {
question: projectInteractionReviewText(question.question, INTERACTION_QUESTION_MAX_BYTES),
options,
};
}),
};
return decodeInteractionRequest(projected) as InteractionQuestionRequest;
}
export function projectInteractionSandboxBoundaryRequest(input: {
readonly expansion: SandboxBoundaryExpansion;
readonly justification: string;
}): InteractionSandboxBoundaryRequest {
return decodeInteractionRequest({
kind: 'sandbox_boundary',
expansion: input.expansion,
justification: input.justification,
}) as InteractionSandboxBoundaryRequest;
}
export function interactionAnswerMatchesRequestKind(
request: InteractionRequest,
answer: InteractionAnswer,
): boolean {
return request.kind === answer.kind;
}
export function interactionOutcomeMatchesRequestKind(
request: InteractionRequest,
outcome: InteractionCanonicalOutcome,
): boolean {
return (
outcome.kind === 'closure' ||
(request.kind === 'permission'
? outcome.kind === 'permission_answer'
: request.kind === 'question'
? outcome.kind === 'question_answer'
: outcome.kind === 'sandbox_boundary_decision')
);
}
export function interactionQuestionAnswerCountMatchesRequest(
request: InteractionQuestionRequest,
answers: readonly (string | null)[],
): boolean {
return request.questions.length === answers.length;
}
export function interactionRememberForTurnIsEligible(
request: InteractionRequest,
decision: InteractionPermissionDecisionFields,
): boolean {
if (request.kind !== 'permission') return false;
if (!decision.rememberForTurn) return true;
return (
decision.decision === 'allow' &&
request.prompt.kind === 'tool_permission' &&
request.prompt.rememberForTurnAllowed
);
}
export function isInteractionAnswerValidForRequest(
request: InteractionRequest,
answer: InteractionAnswer,
): boolean {
if (!interactionAnswerMatchesRequestKind(request, answer)) return false;
if (answer.kind === 'question') {
return (
request.kind === 'question' &&
interactionQuestionAnswerCountMatchesRequest(request, answer.answers)
);
}
if (answer.kind === 'sandbox_boundary') return request.kind === 'sandbox_boundary';
return interactionRememberForTurnIsEligible(request, answer);
}
export function isInteractionCanonicalOutcomeValidForRequest(
request: InteractionRequest,
outcome: InteractionCanonicalOutcome,
): boolean {
if (!interactionOutcomeMatchesRequestKind(request, outcome)) return false;
if (outcome.kind === 'closure')
return request.kind === 'permission' || outcome.reason !== 'timed_out';
if (outcome.kind === 'permission_answer') {
return interactionRememberForTurnIsEligible(request, outcome);
}
if (outcome.kind === 'question_answer') {
return (
request.kind === 'question' &&
interactionQuestionAnswerCountMatchesRequest(request, outcome.answers)
);
}
return request.kind === 'sandbox_boundary';
}
export function interactionCanonicalOutcomesEquivalent(
left: InteractionCanonicalOutcome,
right: InteractionCanonicalOutcome,
): boolean {
if (left.kind !== right.kind) return false;
if (left.kind === 'permission_answer' && right.kind === 'permission_answer')
return left.decision === right.decision && left.rememberForTurn === right.rememberForTurn;
if (left.kind === 'question_answer' && right.kind === 'question_answer')
return equalAnswers(left.answers, right.answers);
if (left.kind === 'sandbox_boundary_decision' && right.kind === 'sandbox_boundary_decision') {
return left.decision === right.decision && left.status === right.status;
}
return left.kind === 'closure' && right.kind === 'closure' && left.reason === right.reason;
}
function decodeQuestion(value: unknown): InteractionQuestion {
const record = plainRecord(value, 'Interaction question');
exact(record, QUESTION_SHAPE, 'question');
const options = plainArray(
record.options,
'options',
INTERACTION_MIN_OPTIONS_PER_QUESTION,
INTERACTION_MAX_OPTIONS_PER_QUESTION,
).map(decodeOption);
if (new Set(options.map((option) => option.label)).size !== options.length)
throw new Error('Duplicate question option label');
return deepFreeze({
question: boundedString(record.question, 'question', INTERACTION_QUESTION_MAX_BYTES),
options,
});
}
function decodeOption(value: unknown): InteractionQuestionOption {
const record = plainRecord(value, 'Interaction question option');
exact(record, OPTION_SHAPE, 'question option');
return Object.freeze({
label: boundedString(record.label, 'option label', INTERACTION_OPTION_LABEL_MAX_BYTES),
...(record.description === undefined
? {}
: {
description: boundedString(
record.description,
'option description',
INTERACTION_OPTION_DESCRIPTION_MAX_BYTES,
),
}),
});
}
function decodeAnswers(value: unknown): readonly (string | null)[] {
return Object.freeze(
plainArray(value, 'answers', 1, INTERACTION_MAX_QUESTIONS).map((answer) =>
answer === null ? null : boundedString(answer, 'answer', INTERACTION_ANSWER_MAX_BYTES),
),
);
}
function plainRecord(value: unknown, label: string): Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value))
throw new Error(`${label} must be a plain record`);
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null)
throw new Error(`${label} must be a plain record`);
for (const key of Reflect.ownKeys(value)) {
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (
typeof key !== 'string' ||
!descriptor ||
!('value' in descriptor) ||
!descriptor.enumerable
)
throw new Error(`${label} must contain plain data properties`);
}
return value as Record<string, unknown>;
}
function exact(
record: Record<string, unknown>,
shape: Parameters<typeof hasExactShape>[1],
label: string,
): void {
if (!hasExactShape(record, shape)) throw new Error(`Invalid ${label} fields`);
}
function plainArray(value: unknown, label: string, min: number, max: number): unknown[] {
if (
!Array.isArray(value) ||
Object.getPrototypeOf(value) !== Array.prototype ||
value.length < min ||
value.length > max ||
Reflect.ownKeys(value).length !== value.length + 1
)
throw new Error(`Invalid ${label}`);
for (let index = 0; index < value.length; index += 1) {
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
if (!descriptor || !('value' in descriptor) || !descriptor.enumerable)
throw new Error(`Invalid ${label}`);
}
return value;
}
function boundedString(value: unknown, label: string, maxBytes: number): string {
if (typeof value !== 'string' || value.length === 0 || UTF8.encode(value).byteLength > maxBytes)
throw new Error(`Invalid ${label}`);
return value;
}
function boundedCharacterString(value: unknown, label: string, maxChars: number): string {
if (typeof value !== 'string' || value.length === 0 || value.length > maxChars)
throw new Error(`Invalid ${label}`);
return value;
}
function boolean(value: unknown, label: string): boolean {
if (typeof value !== 'boolean') throw new Error(`Invalid ${label}`);
return value;
}
function safeInteger(value: unknown, label: string, positive: boolean): number {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < (positive ? 1 : 0))
throw new Error(`Invalid ${label}`);
return value;
}
function oneOf<const T extends readonly unknown[]>(
value: unknown,
values: T,
label: string,
): T[number] {
if (!values.includes(value)) throw new Error(`Invalid ${label}`);
return value as T[number];
}
function serializedLimit(value: unknown, maxBytes: number, label: string): void {
let serialized: string | undefined;
try {
serialized = JSON.stringify(value);
} catch {
serialized = undefined;
}
if (serialized === undefined || UTF8.encode(serialized).byteLength > maxBytes)
throw new Error(`${label} exceeds serialized byte limit`);
}
function equalAnswers(
left: readonly (string | null)[],
right: readonly (string | null)[],
): boolean {
return left.length === right.length && left.every((answer, index) => answer === right[index]);
}
function deepFreeze<T>(value: T): T {
if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
for (const nested of Object.values(value as Record<string, unknown>)) deepFreeze(nested);
Object.freeze(value);
}
return value;
}