confluence_web/agent/validation.ts
Artur Mukhamadiev 38a8ca67f7 agent: pi runtime track handoff (contract revision 1)
Pinned pi SDK 0.85.1 bridge, Python supervisor, artifact exporter,
scripted backend peer, image checks and boundary checks under agent/**.
Review findings F1-F3 are recorded in docs/implementation/PI_AGENT_REVIEW.md.
2026-09-14 21:57:54 +03:00

102 lines
6.3 KiB
TypeScript

import { jsonBytes } from './json.js';
import { LIMITS } from './types.js';
import { isValidId } from './framing.js';
export function object(x: unknown): x is Record<string, any> {
return !!x && typeof x === 'object' && !Array.isArray(x);
}
export function keys(x: unknown, required: string[], optional: string[] = []): asserts x is Record<string, any> {
if (!object(x) || required.some(k => !Object.hasOwn(x, k)) || Object.keys(x).some(k => !required.includes(k) && !optional.includes(k))) throw new Error('Invalid fields');
}
export function integer(x: unknown, min = 0, max = Number.MAX_SAFE_INTEGER): void {
if (!Number.isSafeInteger(x) || (x as number) < min || (x as number) > max) throw new Error('Invalid integer');
}
export function text(x: unknown, max: number, nonempty = false): void {
if (typeof x !== 'string' || Buffer.byteLength(x) > max || (nonempty && !x.trim())) throw new Error('Invalid text');
}
export function warning(x: unknown): void {
keys(x, ['code', 'message'], ['tool_call_id', 'name']);
text(x.code, 128, true); text(x.message, 1024);
if (x.tool_call_id !== undefined && !isValidId(x.tool_call_id)) throw new Error('Invalid warning ID');
if (x.name !== undefined) text(x.name, 1024);
}
export function envelope(x: any): void {
keys(x, ['v', 'type', 'id', 'payload'], ['reply_to']);
if (x.v !== 1 || !isValidId(x.id) || !x.id.startsWith('b_')) throw new Error('Invalid envelope');
if (x.type === 'start') {
if (Object.hasOwn(x, 'reply_to')) throw new Error('Unexpected correlation');
keys(x.payload, ['prompt', 'system_instruction', 'remaining_ms', 'model']);
text(x.payload.prompt, LIMITS.USER_PROMPT_MAX_BYTES, true);
text(x.payload.system_instruction, LIMITS.ENVELOPE_ALLOWANCE_BYTES, true);
integer(x.payload.remaining_ms, 1, LIMITS.MAX_DEADLINE_MS);
const m = x.payload.model;
keys(m, ['id', 'context_window_tokens', 'max_output_tokens']);
text(m.id, 1024, true); integer(m.context_window_tokens, 1); integer(m.max_output_tokens, 1, m.context_window_tokens);
const metadata = { ...x, payload: { ...x.payload, prompt: '' } };
if (jsonBytes(metadata, LIMITS.ENVELOPE_ALLOWANCE_BYTES) > LIMITS.ENVELOPE_ALLOWANCE_BYTES) throw new Error('Start metadata too large');
} else {
if (!isValidId(x.reply_to) || !x.reply_to.startsWith('a_')) throw new Error('Invalid correlation');
if (x.type === 'collection_ready') keys(x.payload, []);
else if (x.type === 'artifact_ack') {
keys(x.payload, ['transfer_id', 'decision', 'warning']);
if (!isValidId(x.payload.transfer_id) || !['accept', 'skip', 'stored'].includes(x.payload.decision)) throw new Error('Invalid acknowledgement');
if (x.payload.warning !== null) warning(x.payload.warning);
if (x.payload.decision === 'stored' && x.payload.warning !== null) throw new Error('Unexpected stored warning');
} else if (x.type === 'tool_response' || x.type === 'model_response') {
keys(x.payload, ['result', 'error']);
if (x.payload.error !== null) {
keys(x.payload.error, ['code', 'message']); text(x.payload.error.code, 128, true); text(x.payload.error.message, 1024);
if (x.payload.result !== null) throw new Error('Invalid error result');
} else {
if (!object(x.payload.result)) throw new Error('Invalid success result');
if (x.type === 'model_response') modelResponse(x.payload.result);
}
if (jsonBytes(x.payload, LIMITS.MODEL_RESULT_PAYLOAD_MAX_BYTES) > LIMITS.MODEL_RESULT_PAYLOAD_MAX_BYTES) throw new Error('Payload too large');
} else throw new Error('Unknown frame');
}
}
export function modelResponse(x: any): void {
keys(x, ['content', 'stop_reason', 'usage'], ['provider_state']);
if (!Array.isArray(x.content) || !['stop', 'tool_calls', 'length'].includes(x.stop_reason)) throw new Error('Invalid model result');
const ids = new Set<string>();
for (const c of x.content) {
if (c.type === 'text') { keys(c, ['type', 'text']); text(c.text, LIMITS.FINAL_MARKDOWN_MAX_BYTES); }
else if (c.type === 'tool_call') {
keys(c, ['type', 'id', 'name', 'arguments']);
if (!isValidId(c.id) || ids.has(c.id) || !object(c.arguments)) throw new Error('Invalid model call');
ids.add(c.id); text(c.name, 128, true);
if (jsonBytes(c.arguments, LIMITS.TOOL_REQUEST_PAYLOAD_MAX_BYTES) > LIMITS.TOOL_REQUEST_PAYLOAD_MAX_BYTES) throw new Error('Tool arguments too large');
} else throw new Error('Invalid content');
}
if ((ids.size > 0) !== (x.stop_reason === 'tool_calls') && x.stop_reason !== 'length') throw new Error('Invalid stop reason');
keys(x.usage, ['input_tokens', 'output_tokens']); integer(x.usage.input_tokens); integer(x.usage.output_tokens);
if (x.provider_state !== undefined) text(x.provider_state, 64 * 1024);
}
export function confluenceResult(x: any, tool: string): void {
const page = (p: any, extra: string[]) => {
keys(p, ['page_id', 'title', 'space', 'url', ...extra]);
text(p.page_id, 128, true);
if (!/^[0-9]+$/.test(p.page_id)) throw new Error('Invalid page ID');
for (const field of ['title', 'space', 'url']) text(p[field], LIMITS.TOOL_RESULT_PAYLOAD_MAX_BYTES);
};
const pagination = (p: any, count: number) => {
keys(p, ['offset', 'limit', 'has_more']);
integer(p.offset, 0, 10000); integer(p.limit, 1, 50);
if (typeof p.has_more !== 'boolean' || count > p.limit || (!count && p.has_more)) throw new Error('Invalid pagination');
};
if (tool === 'confluence_view') {
page(x, ['markdown', 'truncated']); text(x.markdown, LIMITS.TOOL_RESULT_PAYLOAD_MAX_BYTES);
if (typeof x.truncated !== 'boolean') throw new Error('Invalid truncation');
} else if (tool === 'confluence_search') {
keys(x, ['pages', 'pagination']);
if (!Array.isArray(x.pages) || x.pages.length > 50) throw new Error('Invalid pages');
for (const p of x.pages) { page(p, ['snippet']); text(p.snippet, LIMITS.TOOL_RESULT_PAYLOAD_MAX_BYTES); }
pagination(x.pagination, x.pages.length);
} else if (tool === 'confluence_list_spaces') {
keys(x, ['spaces', 'pagination']);
if (!Array.isArray(x.spaces) || x.spaces.length > 50) throw new Error('Invalid spaces');
for (const s of x.spaces) { keys(s, ['key', 'name']); text(s.key, LIMITS.TOOL_RESULT_PAYLOAD_MAX_BYTES); text(s.name, LIMITS.TOOL_RESULT_PAYLOAD_MAX_BYTES); }
pagination(x.pagination, x.spaces.length);
} else throw new Error('Invalid Confluence operation');
}