jenkins b66c762f5d hermes(webui): add HUX card UI models with contract-locked suites
Standalone per-card browser model/security/view modules for HUX-01..10
plus node+pytest suites that read the hux.v1 contract schemas directly.
Reconciled drift found on integration: the activity model now accepts
all 32 hux.event.v1 kinds (delegation.*, memory.suppressed,
memory.retrieval_removed, budget.exhausted, side_effect.*), the
autonomy model carries the external_side_effect capability, and the
foundation boundary test now asserts the shipped static HUX surface
exists on disk and that images never bake activated HUX_FLAGS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 04:12:03 -03:00

193 lines
11 KiB
TypeScript

/** Fail-closed multimodal normalization and intent builders for HUX-07. */
import { actionAuthorization } from "../autonomy/model.ts";
import type { ArtifactScope, HuxIdentity } from "../artifacts/types.ts";
import type { ApprovalView, PolicyView } from "../autonomy/types.ts";
import {
isOpaqueId, isUtc, normalizeIdentity, normalizeScope, safeBlobUrl, safePreviewText, safeText,
sameIdentity, sameScope,
} from "./security.ts";
import { validateUpload } from "./security.ts";
import type {
AnnotationIntent, CaptureIntent, CaptureKind, GenerationThread, GenerationVariant,
ImageEditIntent, MediaKind, MediaView, MultimodalPage, RawMediaEnvelope,
TranscriptCorrectionIntent, TranscriptView,
} from "./types.ts";
export const FOUNDATION_FLAG = "hux.foundation";
export const PROJECTS_FLAG = "hux.projects";
export const ARTIFACTS_FLAG = "hux.artifacts";
export const AUTONOMY_FLAG = "hux.autonomy";
export const MULTIMODAL_FLAG = "hux.multimodal";
export const REQUIRED_FLAGS = Object.freeze([
FOUNDATION_FLAG, PROJECTS_FLAG, ARTIFACTS_FLAG, AUTONOMY_FLAG, MULTIMODAL_FLAG,
]);
export function multimodalEnabled(flags?: Iterable<string>): boolean {
const active = new Set(flags || []);
return REQUIRED_FLAGS.every((flag) => active.has(flag));
}
function normalizeAnnotation(raw: unknown): MediaView["annotations"][number] | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const value = raw as Record<string, unknown>;
const page = value.page === null ? null : value.page;
const note = safeText(value.note, "", 1_000);
return isOpaqueId(value.id, "ann") && (page === null ||
(Number.isSafeInteger(page) && (page as number) > 0 && (page as number) <= 100_000)) && note ?
{id: value.id, page: page as number | null, note} : null;
}
function normalizeMedia(raw: unknown, owner: string, scope: ArtifactScope): MediaView | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const value = raw as Record<string, unknown>;
const kind = value.kind as MediaKind;
const fileName = safeText(value.file_name, "", 180);
const altText = safeText(value.alt_text, "Preview", 500);
const upload = validateUpload({name: fileName, type: String(value.mime), size: Number(value.bytes)}, "authorized");
if (value.schema !== "hux.media.v1" || !isOpaqueId(value.id, "med") || value.owner !== owner ||
value.project_id !== scope.projectId || value.conversation_id !== scope.conversationId ||
!isOpaqueId(value.artifact_id, "art") || !Number.isSafeInteger(value.artifact_version) ||
(value.artifact_version as number) < 1 || !["image", "document", "audio"].includes(kind) ||
!fileName || !Number.isSafeInteger(value.bytes) || !upload.ok || upload.kind !== kind ||
typeof value.mime !== "string" || !Array.isArray(value.annotations) || value.annotations.length > 500) return null;
const annotations = value.annotations.map(normalizeAnnotation);
if (annotations.some((item) => item === null)) return null;
const previewUrl = value.preview_url === null || value.preview_url === undefined ? null : safeBlobUrl(value.preview_url);
const previewText = value.preview_text === null || value.preview_text === undefined ? null : safePreviewText(value.preview_text);
if ((value.preview_url && (!previewUrl || value.preview_authorized !== true)) ||
(value.preview_text !== null && value.preview_text !== undefined && (!previewText || kind !== "document")) ||
(previewUrl && kind !== "image") || (previewText && value.preview_authorized !== true)) return null;
let lineage: MediaView["lineage"] = null;
if (value.lineage !== null && value.lineage !== undefined) {
const parent = value.lineage as Record<string, unknown> | null;
if (!parent || !isOpaqueId(parent.media_id, "med") || !isOpaqueId(parent.artifact_id, "art") ||
!Number.isSafeInteger(parent.artifact_version) || (parent.artifact_version as number) < 1 ||
parent.media_id === value.id) return null;
lineage = {mediaId: parent.media_id, artifactId: parent.artifact_id,
artifactVersion: parent.artifact_version as number};
}
return {id: value.id, owner, artifactId: value.artifact_id,
artifactVersion: value.artifact_version as number, kind, fileName, mime: value.mime,
bytes: value.bytes as number, previewUrl, previewText, altText,
annotations: annotations as MediaView["annotations"], lineage};
}
function normalizeVariant(raw: unknown, allowed: ReadonlyMap<string, MediaView>): GenerationVariant | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const value = raw as Record<string, unknown>;
const media = allowed.get(String(value.media_id));
const parent = value.parent_variant_id;
if (!isOpaqueId(value.id, "var") || !media || value.artifact_id !== media.artifactId ||
value.artifact_version !== media.artifactVersion ||
!(parent === null || isOpaqueId(parent, "var")) || !isUtc(value.created_at)) return null;
return {id: value.id, mediaId: media.id, artifactId: media.artifactId,
artifactVersion: media.artifactVersion, parentVariantId: parent as string | null,
createdAt: value.created_at};
}
function normalizeThread(raw: unknown, media: ReadonlyMap<string, MediaView>, owner: string,
scope: ArtifactScope): GenerationThread | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const value = raw as Record<string, unknown>;
const prompt = safeText(value.prompt, "", 2_000);
if (!isOpaqueId(value.id, "thr") || value.owner !== owner || value.project_id !== scope.projectId ||
value.conversation_id !== scope.conversationId || !prompt || !Array.isArray(value.variants) ||
value.variants.length < 1 || value.variants.length > 200) return null;
const variants = value.variants.map((item) => normalizeVariant(item, media));
if (variants.some((item) => item === null)) return null;
const ordered = variants as GenerationVariant[];
const ids = new Set<string>();
for (const variant of ordered) {
if (ids.has(variant.id) || (variant.parentVariantId && !ids.has(variant.parentVariantId))) return null;
ids.add(variant.id);
}
return {id: value.id, prompt, variants: ordered};
}
function normalizeTranscript(raw: unknown, owner: string, scope: ArtifactScope): TranscriptView | null {
if (raw === null || raw === undefined) return null;
if (typeof raw !== "object" || Array.isArray(raw)) return null;
const value = raw as Record<string, unknown>;
const text = safeText(value.text, "", 20_000);
const language = safeText(value.language, "", 40);
return value.schema === "hux.voice_transcript.v1" && value.owner === owner &&
value.project_id === scope.projectId && value.conversation_id === scope.conversationId &&
isOpaqueId(value.id, "trn") &&
isOpaqueId(value.turn_id, "turn") && Number.isSafeInteger(value.revision) &&
(value.revision as number) >= 1 && Boolean(language) && Boolean(text) &&
typeof value.finalized === "boolean" ? {id: value.id, turnId: value.turn_id,
revision: value.revision as number, language, text, finalized: value.finalized} : null;
}
export function normalizeMultimodalPage(raw: RawMediaEnvelope, expected: HuxIdentity,
scope: ArtifactScope): MultimodalPage | null {
if (!raw || typeof raw !== "object" || raw.schema !== "hux.multimodal_page.v1" ||
raw.api_version !== "hux.v1" || !sameIdentity(normalizeIdentity(raw.identity), expected) ||
!sameScope(normalizeScope(raw.binding), scope) || !Array.isArray(raw.media) ||
raw.media.length > 200 || !Array.isArray(raw.threads) || raw.threads.length > 100) return null;
if (!["authorized", "requires_approval", "denied"].includes(String(
(raw as Record<string, unknown>).upload_authorization))) return null;
const normalized = raw.media.map((item) => normalizeMedia(item, expected.userRef, scope));
const ids = new Set<string>();
const unique = normalized.filter((item): item is MediaView => Boolean(item && !ids.has(item.id) && ids.add(item.id)));
const allMedia = new Map(unique.map((item) => [item.id, item]));
const media = unique.filter((item) => !item.lineage || (() => {
const parent = allMedia.get(item.lineage!.mediaId);
return Boolean(parent && parent.artifactId === item.lineage!.artifactId &&
parent.artifactVersion === item.lineage!.artifactVersion);
})());
const mediaMap = new Map(media.map((item) => [item.id, item]));
const threads = raw.threads.map((item) => normalizeThread(item, mediaMap, expected.userRef, scope));
const transcript = normalizeTranscript(raw.transcript, expected.userRef, scope);
return {media, threads: threads.filter((item): item is GenerationThread => item !== null), transcript,
uploadAuthorization: (raw as Record<string, unknown>).upload_authorization as MultimodalPage["uploadAuthorization"],
rejected: normalized.length - media.length + threads.filter((item) => item === null).length +
(raw.transcript && !transcript ? 1 : 0)};
}
function baseIntent(media: MediaView, scope: ArtifactScope) {
return {artifactId: media.artifactId, expectedVersion: media.artifactVersion, scope: {...scope}};
}
export function buildImageEdit(media: MediaView, scope: ArtifactScope,
instruction: string): ImageEditIntent {
const clean = safeText(instruction, "", 2_000);
if (media.kind !== "image" || !clean) throw new TypeError("A bounded image edit instruction is required");
return {...baseIntent(media, scope), mediaId: media.id, instruction: clean,
lineage: {artifactId: media.artifactId, version: media.artifactVersion}};
}
export function buildAnnotation(media: MediaView, scope: ArtifactScope,
note: string, page: number | null): AnnotationIntent {
const clean = safeText(note, "", 1_000);
if (media.kind !== "document" || !clean || !(page === null ||
(Number.isSafeInteger(page) && page > 0 && page <= 100_000))) {
throw new TypeError("A bounded document annotation is required");
}
return {...baseIntent(media, scope), mediaId: media.id, page, note: clean};
}
export function buildTranscriptCorrection(transcript: TranscriptView, correctedText: string,
identity: HuxIdentity, scope: ArtifactScope): TranscriptCorrectionIntent {
const clean = safeText(correctedText, "", 20_000);
if (!transcript.finalized || !clean) throw new TypeError("Only a finalized transcript can be corrected");
return {transcriptId: transcript.id, turnId: transcript.turnId,
expectedRevision: transcript.revision, correctedText: clean, identity: {...identity}, scope: {...scope}};
}
/** Camera and screen input always require a fresh Ask First decision. */
export function captureAuthorization(policy: PolicyView | null, approval: ApprovalView | null,
identity: HuxIdentity): "approval_required" | "deny" {
return actionAuthorization(policy, "network", approval, identity) === "deny" ? "deny" : "approval_required";
}
export function buildCaptureIntent(kind: CaptureKind, policy: PolicyView | null,
identity: HuxIdentity, scope: ArtifactScope): CaptureIntent {
if (!['camera', 'screen'].includes(kind) || captureAuthorization(policy, null, identity) === "deny") {
throw new TypeError("Capture is denied by the autonomy policy");
}
return {kind, autonomyCapability: "network", requiredDecision: "ask",
identity: {...identity}, scope: {...scope}};
}