blob: a05a4cde4659ff0a9f76c819d2ad86e59519bb40 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
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';
import {
decodeClientCapabilityGrantTarget,
type ClientCapabilityGrantTarget,
} from './client-capability-grant.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_FORM_MAX_FIELDS = 32;
export const INTERACTION_FORM_MAX_OPTIONS = 64;
export const INTERACTION_FORM_MESSAGE_MAX_BYTES = 2_048;
export const INTERACTION_FORM_REQUESTER_NAME_MAX_BYTES = 256;
export const INTERACTION_FORM_REQUESTER_SOURCE_MAX_BYTES = 512;
export const INTERACTION_FORM_FIELD_NAME_MAX_BYTES = 256;
export const INTERACTION_FORM_FIELD_LABEL_MAX_BYTES = 256;
export const INTERACTION_FORM_FIELD_DESCRIPTION_MAX_BYTES = 512;
export const INTERACTION_FORM_VALUE_MAX_BYTES = 2_048;
export const INTERACTION_CLOSURE_REASONS = [
'turn_stopped',
'turn_terminal',
'producer_cancelled',
'timed_out',
'host_restarted',
'provider_disconnected',
] 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 InteractionRequesterProjection {
/** Human-readable name only. Runtime identity remains the exact Tool invocation. */
readonly name: string;
/** Optional display provenance such as a provider or server name. */
readonly source?: string;
}
export interface InteractionFormOption {
readonly value: string;
readonly label: string;
}
interface InteractionFormFieldBase {
readonly name: string;
readonly label: string;
readonly required: boolean;
readonly description?: string;
}
export type InteractionFormField =
| (InteractionFormFieldBase & {
readonly kind: 'string';
readonly default?: string;
readonly minLength?: number;
readonly maxLength?: number;
readonly format?: 'email' | 'uri' | 'date' | 'date-time';
})
| (InteractionFormFieldBase & {
readonly kind: 'number';
readonly default?: number;
readonly minimum?: number;
readonly maximum?: number;
})
| (InteractionFormFieldBase & {
readonly kind: 'integer';
readonly default?: number;
readonly minimum?: number;
readonly maximum?: number;
})
| (InteractionFormFieldBase & {
readonly kind: 'boolean';
readonly default?: boolean;
})
| (InteractionFormFieldBase & {
readonly kind: 'single_select';
readonly options: readonly InteractionFormOption[];
readonly default?: string;
})
| (InteractionFormFieldBase & {
readonly kind: 'multi_select';
readonly options: readonly InteractionFormOption[];
readonly default?: readonly string[];
readonly minItems?: number;
readonly maxItems?: number;
});
export type InteractionFormValue = string | number | boolean | readonly string[];
export interface InteractionFormRequest {
readonly kind: 'form';
readonly toolUseId: string;
readonly message: string;
readonly requester: InteractionRequesterProjection;
readonly fields: readonly InteractionFormField[];
}
export interface InteractionSandboxBoundaryRequest {
readonly kind: 'sandbox_boundary';
readonly expansion: SandboxBoundaryExpansion;
readonly justification: string;
}
export interface InteractionClientCapabilityRequest {
readonly kind: 'client_capability';
readonly toolUseId: string;
readonly target: ClientCapabilityGrantTarget;
}
export type InteractionRequest =
| InteractionPermissionRequest
| InteractionQuestionRequest
| InteractionFormRequest
| InteractionSandboxBoundaryRequest
| InteractionClientCapabilityRequest;
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 type InteractionFormResult =
| {
readonly action: 'accept';
readonly values: Readonly<Record<string, InteractionFormValue>>;
}
| {
readonly action: 'decline' | 'cancel';
};
export type InteractionFormAnswer = { readonly kind: 'form' } & InteractionFormResult;
export type InteractionFormResponse = { readonly requestId: string } & InteractionFormResult;
export interface InteractionSandboxBoundaryAnswer {
readonly kind: 'sandbox_boundary';
readonly decision: 'allow' | 'deny';
}
export interface InteractionClientCapabilityAnswer {
readonly kind: 'client_capability';
readonly decision: 'allow' | 'deny';
}
export type InteractionAnswer =
| InteractionPermissionAnswer
| InteractionQuestionAnswer
| InteractionFormAnswer
| InteractionSandboxBoundaryAnswer
| InteractionClientCapabilityAnswer;
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 type InteractionCanonicalFormOutcome =
| {
readonly kind: 'form_answer';
readonly action: 'accept';
readonly values: Readonly<Record<string, InteractionFormValue>>;
readonly committedAt: number;
}
| {
readonly kind: 'form_answer';
readonly action: 'decline' | 'cancel';
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 InteractionCanonicalClientCapabilityOutcome {
readonly kind: 'client_capability_decision';
readonly decision: 'allow' | 'deny';
readonly committedAt: number;
}
export interface InteractionCanonicalClosureOutcome {
readonly kind: 'closure';
readonly reason: InteractionClosureReason;
readonly committedAt: number;
}
export type InteractionCanonicalOutcome =
| InteractionCanonicalPermissionOutcome
| InteractionCanonicalQuestionOutcome
| InteractionCanonicalFormOutcome
| InteractionCanonicalSandboxBoundaryOutcome
| InteractionCanonicalClientCapabilityOutcome
| InteractionCanonicalClosureOutcome;
export type InteractionQuestionProjectionInput = Pick<
InteractionQuestionRequest,
'toolUseId' | 'questions'
>;
export type InteractionFormInput = Pick<InteractionFormRequest, 'message' | 'requester' | 'fields'>;
export type InteractionFormProjectionInput = InteractionFormInput & {
readonly toolUseId: string;
};
const PERMISSION_REQUEST_SHAPE = defineObjectShape<InteractionPermissionRequest>()(
['kind', 'toolUseId', 'prompt'],
[],
);
const QUESTION_REQUEST_SHAPE = defineObjectShape<InteractionQuestionRequest>()(
['kind', 'toolUseId', 'questions'],
[],
);
const FORM_REQUEST_SHAPE = defineObjectShape<InteractionFormRequest>()(
['kind', 'toolUseId', 'message', 'requester', 'fields'],
[],
);
const SANDBOX_BOUNDARY_REQUEST_SHAPE = defineObjectShape<InteractionSandboxBoundaryRequest>()(
['kind', 'expansion', 'justification'],
[],
);
const CLIENT_CAPABILITY_REQUEST_SHAPE = defineObjectShape<InteractionClientCapabilityRequest>()(
['kind', 'toolUseId', 'target'],
[],
);
const PERMISSION_ANSWER_SHAPE = defineObjectShape<InteractionPermissionAnswer>()(
['kind', 'decision', 'rememberForTurn'],
[],
);
const QUESTION_ANSWER_SHAPE = defineObjectShape<InteractionQuestionAnswer>()(
['kind', 'answers'],
[],
);
const FORM_ACCEPT_ANSWER_SHAPE = defineObjectShape<
Extract<InteractionFormAnswer, { action: 'accept' }>
>()(['kind', 'action', 'values'], []);
const FORM_EMPTY_ANSWER_SHAPE = defineObjectShape<
Extract<InteractionFormAnswer, { action: 'decline' | 'cancel' }>
>()(['kind', 'action'], []);
const FORM_ACCEPT_RESPONSE_SHAPE = defineObjectShape<
Extract<InteractionFormResponse, { action: 'accept' }>
>()(['requestId', 'action', 'values'], []);
const FORM_EMPTY_RESPONSE_SHAPE = defineObjectShape<
Extract<InteractionFormResponse, { action: 'decline' | 'cancel' }>
>()(['requestId', 'action'], []);
const SANDBOX_BOUNDARY_ANSWER_SHAPE = defineObjectShape<InteractionSandboxBoundaryAnswer>()(
['kind', 'decision'],
[],
);
const CLIENT_CAPABILITY_ANSWER_SHAPE = defineObjectShape<InteractionClientCapabilityAnswer>()(
['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 FORM_ACCEPT_OUTCOME_SHAPE = defineObjectShape<
Extract<InteractionCanonicalFormOutcome, { action: 'accept' }>
>()(['kind', 'action', 'values', 'committedAt'], []);
const FORM_EMPTY_OUTCOME_SHAPE = defineObjectShape<
Extract<InteractionCanonicalFormOutcome, { action: 'decline' | 'cancel' }>
>()(['kind', 'action', 'committedAt'], []);
const SANDBOX_BOUNDARY_OUTCOME_SHAPE =
defineObjectShape<InteractionCanonicalSandboxBoundaryOutcome>()(
['kind', 'decision', 'status', 'committedAt'],
[],
);
const CLIENT_CAPABILITY_OUTCOME_SHAPE =
defineObjectShape<InteractionCanonicalClientCapabilityOutcome>()(
['kind', 'decision', 'committedAt'],
[],
);
const CLOSURE_OUTCOME_SHAPE = defineObjectShape<InteractionCanonicalClosureOutcome>()(
['kind', 'reason', 'committedAt'],
[],
);
const QUESTION_SHAPE = defineObjectShape<InteractionQuestion>()(['question', 'options'], []);
const OPTION_SHAPE = defineObjectShape<InteractionQuestionOption>()(['label'], ['description']);
const FORM_REQUESTER_SHAPE = defineObjectShape<InteractionRequesterProjection>()(
['name'],
['source'],
);
const FORM_OPTION_SHAPE = defineObjectShape<InteractionFormOption>()(['value', 'label'], []);
const FORM_STRING_FIELD_SHAPE = defineObjectShape<
Extract<InteractionFormField, { kind: 'string' }>
>()(
['kind', 'name', 'label', 'required'],
['description', 'default', 'minLength', 'maxLength', 'format'],
);
const FORM_NUMBER_FIELD_SHAPE = defineObjectShape<
Extract<InteractionFormField, { kind: 'number' | 'integer' }>
>()(['kind', 'name', 'label', 'required'], ['description', 'default', 'minimum', 'maximum']);
const FORM_BOOLEAN_FIELD_SHAPE = defineObjectShape<
Extract<InteractionFormField, { kind: 'boolean' }>
>()(['kind', 'name', 'label', 'required'], ['description', 'default']);
const FORM_SINGLE_SELECT_FIELD_SHAPE = defineObjectShape<
Extract<InteractionFormField, { kind: 'single_select' }>
>()(['kind', 'name', 'label', 'required', 'options'], ['description', 'default']);
const FORM_MULTI_SELECT_FIELD_SHAPE = defineObjectShape<
Extract<InteractionFormField, { kind: 'multi_select' }>
>()(
['kind', 'name', 'label', 'required', 'options'],
['description', 'default', 'minItems', 'maxItems'],
);
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 === 'form') {
exact(record, FORM_REQUEST_SHAPE, 'form request');
const fields = plainArray(record.fields, 'form fields', 0, INTERACTION_FORM_MAX_FIELDS).map(
decodeFormField,
);
if (new Set(fields.map((field) => field.name)).size !== fields.length) {
throw new Error('Duplicate form field name');
}
request = {
kind: 'form',
toolUseId: boundedString(record.toolUseId, 'toolUseId', INTERACTION_ID_MAX_BYTES),
message: boundedString(record.message, 'form message', INTERACTION_FORM_MESSAGE_MAX_BYTES),
requester: decodeFormRequester(record.requester),
fields,
};
} 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 if (record.kind === 'client_capability') {
exact(record, CLIENT_CAPABILITY_REQUEST_SHAPE, 'Client Capability request');
request = {
kind: 'client_capability',
toolUseId: boundedString(record.toolUseId, 'toolUseId', INTERACTION_ID_MAX_BYTES),
target: decodeClientCapabilityGrantTarget(record.target),
};
} else {
throw new Error('Invalid Interaction request kind');
}
if (request.kind === 'form') assertFormHasAcceptedAnswer(request);
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 === 'form') {
const action = oneOf(record.action, ['accept', 'decline', 'cancel'] as const, 'form action');
if (action === 'accept') {
exact(record, FORM_ACCEPT_ANSWER_SHAPE, 'accepted form answer');
answer = { kind: 'form', action, values: decodeFormValues(record.values) };
} else {
exact(record, FORM_EMPTY_ANSWER_SHAPE, 'empty form answer');
answer = { kind: 'form', action };
}
} 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 if (record.kind === 'client_capability') {
exact(record, CLIENT_CAPABILITY_ANSWER_SHAPE, 'Client Capability answer');
answer = {
kind: 'client_capability',
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');
if (answer.kind === 'form' && answer.action === 'accept') {
assertAcceptedFormAnswerFitsCanonicalOutcome(answer);
}
return deepFreeze(answer);
}
export function decodeInteractionFormResponse(value: unknown): InteractionFormResponse {
const record = plainRecord(value, 'Interaction form response');
const requestId = boundedString(record.requestId, 'requestId', INTERACTION_ID_MAX_BYTES);
const action = oneOf(record.action, ['accept', 'decline', 'cancel'] as const, 'form action');
if (action === 'accept') {
exact(record, FORM_ACCEPT_RESPONSE_SHAPE, 'accepted form response');
const answer = decodeInteractionAnswer({
kind: 'form',
action,
values: record.values,
});
if (answer.kind !== 'form' || answer.action !== 'accept') {
throw new Error('Invalid accepted form response');
}
return deepFreeze({ requestId, action, values: answer.values });
}
exact(record, FORM_EMPTY_RESPONSE_SHAPE, 'empty form response');
return deepFreeze({ requestId, action });
}
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 === 'form_answer') {
const action = oneOf(record.action, ['accept', 'decline', 'cancel'] as const, 'form action');
const committedAt = safeInteger(record.committedAt, 'committedAt', false);
if (action === 'accept') {
exact(record, FORM_ACCEPT_OUTCOME_SHAPE, 'accepted form outcome');
outcome = {
kind: 'form_answer',
action,
values: decodeFormValues(record.values),
committedAt,
};
} else {
exact(record, FORM_EMPTY_OUTCOME_SHAPE, 'empty form outcome');
outcome = { kind: 'form_answer', action, committedAt };
}
} 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 === 'client_capability_decision') {
exact(record, CLIENT_CAPABILITY_OUTCOME_SHAPE, 'Client Capability outcome');
outcome = {
kind: 'client_capability_decision',
decision: oneOf(record.decision, ['allow', 'deny'] as const, 'decision'),
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 projectInteractionFormRequest(
input: InteractionFormProjectionInput,
): InteractionFormRequest {
const decoded = decodeInteractionRequest({ kind: 'form', ...input }) as InteractionFormRequest;
return decodeInteractionRequest({
...decoded,
message: projectInteractionReviewText(decoded.message, INTERACTION_FORM_MESSAGE_MAX_BYTES),
requester: {
name: projectInteractionReviewText(
decoded.requester.name,
INTERACTION_FORM_REQUESTER_NAME_MAX_BYTES,
),
...(decoded.requester.source === undefined
? {}
: {
source: projectInteractionReviewText(
decoded.requester.source,
INTERACTION_FORM_REQUESTER_SOURCE_MAX_BYTES,
true,
),
}),
},
fields: decoded.fields.map(projectInteractionFormField),
}) as InteractionFormRequest;
}
function projectInteractionFormField(field: InteractionFormField): InteractionFormField {
const display = {
label: projectInteractionReviewText(field.label, INTERACTION_FORM_FIELD_LABEL_MAX_BYTES),
...(field.description === undefined
? {}
: {
description: projectInteractionReviewText(
field.description,
INTERACTION_FORM_FIELD_DESCRIPTION_MAX_BYTES,
true,
),
}),
};
if (field.kind !== 'single_select' && field.kind !== 'multi_select') {
// `name` is a protocol identity returned to the tool. A string default is
// both display text and a canonical answer value, so a safety rewrite must
// not silently change its semantics under the original constraints.
if (field.kind === 'string' && field.default !== undefined) {
const { default: canonicalDefault, ...fieldWithoutDefault } = field;
const projectedDefault = projectInteractionReviewText(
canonicalDefault,
INTERACTION_FORM_VALUE_MAX_BYTES,
true,
);
return {
...fieldWithoutDefault,
...display,
...(projectedDefault === canonicalDefault ? { default: canonicalDefault } : {}),
};
}
return { ...field, ...display };
}
// Select values are protocol identities; labels are their display text.
const options = field.options.map((option) => ({
...option,
label: projectInteractionReviewText(option.label, INTERACTION_FORM_FIELD_LABEL_MAX_BYTES),
}));
if (new Set(options.map((option) => option.label)).size !== options.length) {
throw new Error('Form option labels collide after safe projection');
}
return { ...field, ...display, options };
}
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 projectInteractionClientCapabilityRequest(input: {
readonly toolUseId: string;
readonly target: ClientCapabilityGrantTarget;
}): InteractionClientCapabilityRequest {
return decodeInteractionRequest({
kind: 'client_capability',
toolUseId: input.toolUseId,
target: input.target,
}) as InteractionClientCapabilityRequest;
}
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'
: request.kind === 'form'
? outcome.kind === 'form_answer'
: request.kind === 'sandbox_boundary'
? outcome.kind === 'sandbox_boundary_decision'
: outcome.kind === 'client_capability_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 === 'form') {
return request.kind === 'form' && interactionFormAnswerMatchesRequest(request, answer);
}
if (answer.kind === 'sandbox_boundary') return request.kind === 'sandbox_boundary';
if (answer.kind === 'client_capability') return request.kind === 'client_capability';
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' ||
request.kind === 'client_capability' ||
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)
);
}
if (outcome.kind === 'form_answer') {
return (
request.kind === 'form' &&
interactionFormAnswerMatchesRequest(
request,
outcome.action === 'accept'
? { kind: 'form', action: 'accept', values: outcome.values }
: { kind: 'form', action: outcome.action },
)
);
}
if (outcome.kind === 'client_capability_decision') {
return request.kind === 'client_capability';
}
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 === 'form_answer' && right.kind === 'form_answer') {
return (
left.action === right.action &&
(left.action !== 'accept' ||
(right.action === 'accept' && equalFormValues(left.values, right.values)))
);
}
if (left.kind === 'sandbox_boundary_decision' && right.kind === 'sandbox_boundary_decision') {
return left.decision === right.decision && left.status === right.status;
}
if (left.kind === 'client_capability_decision' && right.kind === 'client_capability_decision') {
return left.decision === right.decision;
}
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 decodeFormRequester(value: unknown): InteractionRequesterProjection {
const record = plainRecord(value, 'Interaction form requester');
exact(record, FORM_REQUESTER_SHAPE, 'form requester');
return Object.freeze({
name: boundedString(
record.name,
'form requester name',
INTERACTION_FORM_REQUESTER_NAME_MAX_BYTES,
),
...(record.source === undefined
? {}
: {
source: boundedText(
record.source,
'form requester source',
INTERACTION_FORM_REQUESTER_SOURCE_MAX_BYTES,
),
}),
});
}
function decodeFormField(value: unknown): InteractionFormField {
const record = plainRecord(value, 'Interaction form field');
const common = decodeFormFieldBase(record);
if (record.kind === 'string') {
exact(record, FORM_STRING_FIELD_SHAPE, 'string form field');
const minLength = optionalBoundedCount(record.minLength, 'minLength');
const maxLength = optionalBoundedCount(record.maxLength, 'maxLength');
if (minLength !== undefined && maxLength !== undefined && minLength > maxLength) {
throw new Error('Invalid form string length range');
}
const field: Extract<InteractionFormField, { kind: 'string' }> = {
...common,
kind: 'string',
...(record.default === undefined
? {}
: {
default: boundedText(
record.default,
'form string default',
INTERACTION_FORM_VALUE_MAX_BYTES,
),
}),
...(minLength === undefined ? {} : { minLength }),
...(maxLength === undefined ? {} : { maxLength }),
...(record.format === undefined
? {}
: {
format: oneOf(
record.format,
['email', 'uri', 'date', 'date-time'] as const,
'form string format',
),
}),
};
if (field.default !== undefined && !isInteractionFormFieldValueValid(field, field.default)) {
throw new Error('Invalid form string default');
}
return deepFreeze(field);
}
if (record.kind === 'number' || record.kind === 'integer') {
exact(record, FORM_NUMBER_FIELD_SHAPE, 'number form field');
const minimum = optionalFiniteNumber(record.minimum, 'minimum');
const maximum = optionalFiniteNumber(record.maximum, 'maximum');
if (minimum !== undefined && maximum !== undefined && minimum > maximum) {
throw new Error('Invalid form number range');
}
const field: Extract<InteractionFormField, { kind: 'number' | 'integer' }> = {
...common,
kind: record.kind,
...(record.default === undefined
? {}
: { default: finiteNumber(record.default, 'form number default') }),
...(minimum === undefined ? {} : { minimum }),
...(maximum === undefined ? {} : { maximum }),
};
if (field.default !== undefined && !isInteractionFormFieldValueValid(field, field.default)) {
throw new Error('Invalid form number default');
}
return deepFreeze(field);
}
if (record.kind === 'boolean') {
exact(record, FORM_BOOLEAN_FIELD_SHAPE, 'boolean form field');
return Object.freeze({
...common,
kind: 'boolean',
...(record.default === undefined
? {}
: { default: boolean(record.default, 'form boolean default') }),
});
}
if (record.kind === 'single_select') {
exact(record, FORM_SINGLE_SELECT_FIELD_SHAPE, 'single-select form field');
const options = decodeFormOptions(record.options);
const field: Extract<InteractionFormField, { kind: 'single_select' }> = {
...common,
kind: 'single_select',
options,
...(record.default === undefined
? {}
: {
default: boundedText(
record.default,
'single-select default',
INTERACTION_FORM_VALUE_MAX_BYTES,
),
}),
};
if (field.default !== undefined && !isInteractionFormFieldValueValid(field, field.default)) {
throw new Error('Invalid single-select default');
}
return deepFreeze(field);
}
if (record.kind === 'multi_select') {
exact(record, FORM_MULTI_SELECT_FIELD_SHAPE, 'multi-select form field');
const options = decodeFormOptions(record.options);
const minItems = optionalBoundedCount(record.minItems, 'minItems', options.length);
const maxItems = optionalBoundedCount(record.maxItems, 'maxItems', options.length);
if (minItems !== undefined && maxItems !== undefined && minItems > maxItems) {
throw new Error('Invalid multi-select item range');
}
const field: Extract<InteractionFormField, { kind: 'multi_select' }> = {
...common,
kind: 'multi_select',
options,
...(record.default === undefined
? {}
: {
default: decodeFormStringArray(record.default, 'multi-select default'),
}),
...(minItems === undefined ? {} : { minItems }),
...(maxItems === undefined ? {} : { maxItems }),
};
if (field.default !== undefined && !isInteractionFormFieldValueValid(field, field.default)) {
throw new Error('Invalid multi-select default');
}
return deepFreeze(field);
}
throw new Error('Invalid form field kind');
}
function decodeFormFieldBase(record: Record<string, unknown>): InteractionFormFieldBase {
return {
name: boundedString(record.name, 'form field name', INTERACTION_FORM_FIELD_NAME_MAX_BYTES),
label: boundedString(record.label, 'form field label', INTERACTION_FORM_FIELD_LABEL_MAX_BYTES),
required: boolean(record.required, 'form field required'),
...(record.description === undefined
? {}
: {
description: boundedText(
record.description,
'form field description',
INTERACTION_FORM_FIELD_DESCRIPTION_MAX_BYTES,
),
}),
};
}
function decodeFormOptions(value: unknown): readonly InteractionFormOption[] {
const options = plainArray(value, 'form options', 1, INTERACTION_FORM_MAX_OPTIONS).map(
(candidate) => {
const record = plainRecord(candidate, 'Interaction form option');
exact(record, FORM_OPTION_SHAPE, 'form option');
return Object.freeze({
value: boundedText(record.value, 'form option value', INTERACTION_FORM_VALUE_MAX_BYTES),
label: boundedString(
record.label,
'form option label',
INTERACTION_FORM_FIELD_LABEL_MAX_BYTES,
),
});
},
);
if (new Set(options.map((option) => option.value)).size !== options.length) {
throw new Error('Duplicate form option value');
}
if (new Set(options.map((option) => option.label)).size !== options.length) {
throw new Error('Duplicate form option label');
}
return Object.freeze(options);
}
function decodeFormValues(value: unknown): Readonly<Record<string, InteractionFormValue>> {
const record = plainRecord(value, 'Interaction form values');
const entries = Object.entries(record);
if (entries.length > INTERACTION_FORM_MAX_FIELDS) throw new Error('Too many form values');
return deepFreeze(
Object.fromEntries(
entries.map(([name, candidate]) => [
boundedString(name, 'form value name', INTERACTION_FORM_FIELD_NAME_MAX_BYTES),
decodeFormValue(candidate),
]),
),
);
}
function decodeFormValue(value: unknown): InteractionFormValue {
if (typeof value === 'string') {
return boundedText(value, 'form string value', INTERACTION_FORM_VALUE_MAX_BYTES);
}
if (typeof value === 'number') return finiteNumber(value, 'form number value');
if (typeof value === 'boolean') return value;
return decodeFormStringArray(value, 'form multi-select value');
}
function decodeFormStringArray(value: unknown, label: string): readonly string[] {
const values = plainArray(value, label, 0, INTERACTION_FORM_MAX_OPTIONS).map((candidate) =>
boundedText(candidate, label, INTERACTION_FORM_VALUE_MAX_BYTES),
);
if (new Set(values).size !== values.length) throw new Error(`Duplicate ${label}`);
return Object.freeze(values);
}
export function interactionFormAnswerMatchesRequest(
request: InteractionFormRequest,
answer: InteractionFormAnswer,
): boolean {
if (answer.action !== 'accept') return true;
const fields = new Map(request.fields.map((field) => [field.name, field]));
const names = Object.keys(answer.values);
if (names.some((name) => !fields.has(name))) return false;
for (const field of request.fields) {
if (!Object.hasOwn(answer.values, field.name)) {
if (field.required) return false;
continue;
}
if (!isInteractionFormFieldValueValid(field, answer.values[field.name])) return false;
}
return true;
}
export function isInteractionFormFieldValueValid(
field: InteractionFormField,
value: InteractionFormValue | undefined,
): boolean {
if (value === undefined) return false;
if (field.kind === 'string') {
if (typeof value !== 'string') return false;
const length = [...value].length;
return (
(field.minLength === undefined || length >= field.minLength) &&
(field.maxLength === undefined || length <= field.maxLength) &&
matchesStringFormat(value, field.format)
);
}
if (field.kind === 'number' || field.kind === 'integer') {
return (
typeof value === 'number' &&
Number.isFinite(value) &&
(field.kind !== 'integer' || Number.isSafeInteger(value)) &&
(field.minimum === undefined || value >= field.minimum) &&
(field.maximum === undefined || value <= field.maximum)
);
}
if (field.kind === 'boolean') return typeof value === 'boolean';
if (field.kind === 'single_select') {
return typeof value === 'string' && field.options.some((option) => option.value === value);
}
if (!Array.isArray(value) || value.some((candidate) => typeof candidate !== 'string')) {
return false;
}
return (
new Set(value).size === value.length &&
(field.minItems === undefined || value.length >= field.minItems) &&
(field.maxItems === undefined || value.length <= field.maxItems) &&
value.every((candidate) => field.options.some((option) => option.value === candidate))
);
}
function matchesStringFormat(
value: string,
format: Extract<InteractionFormField, { kind: 'string' }>['format'],
): boolean {
if (format === undefined) return true;
if (format === 'email') return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
if (format === 'uri') {
try {
return new URL(value).protocol.length > 1;
} catch {
return false;
}
}
if (format === 'date') {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) return false;
return isValidCalendarDate(Number(match[1]), Number(match[2]), Number(match[3]));
}
const match =
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2}))$/.exec(
value,
);
if (!match) return false;
return (
isValidCalendarDate(Number(match[1]), Number(match[2]), Number(match[3])) &&
Number(match[4]) <= 23 &&
Number(match[5]) <= 59 &&
Number(match[6]) <= 59 &&
(match[7] === undefined || Number(match[7]) <= 23) &&
(match[8] === undefined || Number(match[8]) <= 59) &&
Number.isFinite(Date.parse(value))
);
}
function isValidCalendarDate(year: number, month: number, day: number): boolean {
if (month < 1 || month > 12 || day < 1) return false;
const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
return day <= days[month - 1]!;
}
/** Reject a form that can be admitted but can never produce a bounded accepted answer. */
function assertFormHasAcceptedAnswer(request: InteractionFormRequest): void {
const values: Record<string, InteractionFormValue> = {};
for (const field of request.fields) {
if (!field.required) continue;
values[field.name] = formFieldWitness(field);
}
const answer = { kind: 'form' as const, action: 'accept' as const, values };
if (!interactionFormAnswerMatchesRequest(request, answer)) {
throw new Error('Interaction form has no valid accepted answer');
}
serializedLimit(answer, INTERACTION_ANSWER_SERIALIZED_MAX_BYTES, 'Interaction form answer');
assertAcceptedFormAnswerFitsCanonicalOutcome(answer);
assertEveryFormAnswerFitsCanonicalOutcome(request);
}
/**
* Admission must reserve the whole legal answer envelope, not only a smallest
* witness. This intentionally over-approximates format-constrained strings:
* rejecting an over-large form is safe, whereas accepting one that can later
* reject a valid user answer strands the interaction.
*/
function assertEveryFormAnswerFitsCanonicalOutcome(request: InteractionFormRequest): void {
const values = Object.fromEntries(
request.fields.map((field) => [field.name, formFieldMaximumEnvelope(field)]),
);
const answer = { kind: 'form' as const, action: 'accept' as const, values };
serializedLimit(answer, INTERACTION_ANSWER_SERIALIZED_MAX_BYTES, 'Interaction form answer');
assertAcceptedFormAnswerFitsCanonicalOutcome(answer);
}
function formFieldMaximumEnvelope(field: InteractionFormField): InteractionFormValue {
if (field.kind === 'string') {
// Admission must prove every legal answer serializes, so reserve the worst
// value inside the format's legal language, measured post-serialization.
// - date is a fixed-length language over [0-9-]; nothing JSON-escapes.
// - date-time adds only digits and `.:TZ+-`; nothing JSON-escapes, and the
// fractional seconds leave the length unbounded up to the field caps.
// - every other string may legally hold control characters, which
// JSON-escape to six bytes per code point (one raw byte each).
if (field.format === 'date') return '0000-01-01';
const maximumCodePoints = Math.min(
field.maxLength ?? INTERACTION_FORM_VALUE_MAX_BYTES,
INTERACTION_FORM_VALUE_MAX_BYTES,
);
if (field.format === 'date-time') return '0'.repeat(maximumCodePoints);
return '\u0001'.repeat(maximumCodePoints);
}
if (field.kind === 'number' || field.kind === 'integer') return -1.7976931348623157e308;
if (field.kind === 'boolean') return false;
if (field.kind === 'single_select') {
return field.options.reduce(
(longest, option) =>
serializedByteLength(option.value) > serializedByteLength(longest) ? option.value : longest,
field.options[0]!.value,
);
}
return [...field.options]
.sort((left, right) => serializedByteLength(right.value) - serializedByteLength(left.value))
.slice(0, field.maxItems ?? field.options.length)
.map((option) => option.value);
}
function serializedByteLength(value: string): number {
return UTF8.encode(JSON.stringify(value)).byteLength;
}
function assertAcceptedFormAnswerFitsCanonicalOutcome(
answer: Extract<InteractionFormAnswer, { action: 'accept' }>,
): void {
serializedLimit(
{
kind: 'form_answer',
action: 'accept',
values: answer.values,
committedAt: Number.MAX_SAFE_INTEGER,
} satisfies Extract<InteractionCanonicalOutcome, { kind: 'form_answer'; action: 'accept' }>,
INTERACTION_OUTCOME_SERIALIZED_MAX_BYTES,
'Interaction form outcome',
);
}
function formFieldWitness(field: InteractionFormField): InteractionFormValue {
if (field.default !== undefined) return field.default;
if (field.kind === 'string') {
const minLength = field.minLength ?? 0;
let value: string;
if (field.format === 'email') {
value = `${'a'.repeat(Math.max(1, minLength - 5))}@b.co`;
} else if (field.format === 'uri') {
const base = 'https://a.co/';
value = `${base}${'a'.repeat(Math.max(0, minLength - base.length))}`;
} else if (field.format === 'date') {
value = '2000-01-01';
} else if (field.format === 'date-time') {
const base = '2000-01-01T00:00:00';
const fractionalLength = minLength <= 20 ? 0 : Math.max(1, minLength - 21);
value = fractionalLength === 0 ? `${base}Z` : `${base}.${'0'.repeat(fractionalLength)}Z`;
} else {
value = 'a'.repeat(minLength);
}
if (!isInteractionFormFieldValueValid(field, value)) {
throw new Error(`Interaction form field ${field.name} has no valid bounded value`);
}
return value;
}
if (field.kind === 'number' || field.kind === 'integer') {
const lower = field.minimum ?? Number.NEGATIVE_INFINITY;
const upper = field.maximum ?? Number.POSITIVE_INFINITY;
const value =
field.kind === 'integer'
? lower > 0
? Math.ceil(lower)
: upper < 0
? Math.floor(upper)
: 0
: lower > 0
? lower
: upper < 0
? upper
: 0;
if (!isInteractionFormFieldValueValid(field, value)) {
throw new Error(`Interaction form field ${field.name} has no valid bounded value`);
}
return value;
}
if (field.kind === 'boolean') return false;
if (field.kind === 'single_select') return field.options[0].value;
return field.options.slice(0, field.minItems ?? 0).map((option) => option.value);
}
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 boundedText(value: unknown, label: string, maxBytes: number): string {
if (typeof value !== 'string' || 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 optionalBoundedCount(
value: unknown,
label: string,
max = INTERACTION_FORM_VALUE_MAX_BYTES,
): number | undefined {
if (value === undefined) return undefined;
const count = safeInteger(value, label, false);
if (count > max) throw new Error(`Invalid ${label}`);
return count;
}
function finiteNumber(value: unknown, label: string): number {
if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error(`Invalid ${label}`);
return Object.is(value, -0) ? 0 : value;
}
function optionalFiniteNumber(value: unknown, label: string): number | undefined {
return value === undefined ? undefined : finiteNumber(value, label);
}
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 equalFormValues(
left: Readonly<Record<string, InteractionFormValue>>,
right: Readonly<Record<string, InteractionFormValue>>,
): boolean {
const leftKeys = Object.keys(left).sort();
const rightKeys = Object.keys(right).sort();
return (
leftKeys.length === rightKeys.length &&
leftKeys.every((key, index) => {
if (key !== rightKeys[index]) return false;
const leftValue = left[key];
const rightValue = right[key];
return Array.isArray(leftValue) && Array.isArray(rightValue)
? leftValue.length === rightValue.length &&
leftValue.every((candidate, valueIndex) => candidate === rightValue[valueIndex])
: leftValue === rightValue;
})
);
}
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;
}