jenkins b2cfbc94e8 hermes(webui): track the ignored HUX-04 artifacts card models
The artifacts/ directory matched a repo-wide ignore rule and was left
out of the UI card commit; its suites only passed locally because the
files existed on disk. Force-track the complete card so the source
push carries every module the tests import.

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

240 lines
12 KiB
TypeScript

/** Fail-closed normalization and immutable-history invariants for HUX-04. */
import {
ArtifactContractError,
isMime,
isOpaqueId,
isSha256,
isUtc,
normalizeIdentity,
normalizeScope,
safeBlobUrl,
safePreviewText,
safeText,
sameIdentity,
sameScope,
} from "./security.ts";
import type {
Artifact,
ArtifactAttachments,
ArtifactAuthorization,
ArtifactPreview,
ArtifactRenderer,
ArtifactScope,
ArtifactType,
ArtifactVersion,
AuthorizationStatus,
HuxIdentity,
RawArtifact,
RawArtifactEnvelope,
RawArtifactVersion,
RawPreviewPayload,
Sensitivity,
} from "./types.ts";
export const FOUNDATION_FLAG = "hux.foundation";
export const PROJECTS_FLAG = "hux.projects";
export const ARTIFACTS_FLAG = "hux.artifacts";
const TYPES = new Set<ArtifactType>([
"markdown", "code", "html", "svg", "image", "json", "csv", "document", "audio",
]);
const SENSITIVITIES = new Set<Sensitivity>(["public", "personal", "sensitive", "restricted"]);
const AUTHORIZATION = new Set<AuthorizationStatus>(["unavailable", "requires_approval", "authorized"]);
const ACTORS = new Set(["user", "assistant", "tool", "system", "operator"]);
const MAX_ARTIFACTS = 200;
const MAX_VERSIONS = 200;
export function artifactsEnabled(flags?: Iterable<string>): boolean {
const active = new Set(flags || []);
return active.has(FOUNDATION_FLAG) && active.has(PROJECTS_FLAG) && active.has(ARTIFACTS_FLAG);
}
function rendererFor(type: ArtifactType, language: string): ArtifactRenderer {
if (type === "image") return "image";
if (type === "audio") return "audio";
if (["code", "html", "svg"].includes(type)) return "code";
if (["json", "csv"].includes(type)) return "data";
if (/^(report|research|analysis)$/i.test(language)) return "report";
return "document";
}
function mimeAllowed(type: ArtifactType, mime: string): boolean {
if (type === "image") return ["image/png", "image/jpeg", "image/webp", "image/gif"].includes(mime);
if (type === "audio") return ["audio/mpeg", "audio/ogg", "audio/wav", "audio/webm"].includes(mime);
if (type === "html") return mime === "text/html";
if (type === "svg") return mime === "image/svg+xml";
if (type === "json") return mime === "application/json" || mime === "text/plain";
if (type === "csv") return mime === "text/csv" || mime === "text/plain";
if (type === "markdown") return mime === "text/markdown" || mime === "text/plain";
if (type === "code") return mime.startsWith("text/") || mime === "application/json";
return mime === "application/pdf" || mime === "text/plain" || mime === "text/markdown";
}
function normalizeVersion(raw: RawArtifactVersion, type: ArtifactType): ArtifactVersion | null {
const actor = raw.created_by as Record<string, unknown> | null;
const content = raw.content_ref as Record<string, unknown> | null;
if (!Number.isInteger(raw.version) || (raw.version as number) < 1 || !isUtc(raw.created_at) ||
!actor || !ACTORS.has(String(actor.type)) || typeof actor.id !== "string" ||
actor.id.length < 1 || actor.id.length > 120 || !content ||
!isSha256(content.hash) || !Number.isSafeInteger(content.bytes) ||
(content.bytes as number) < 0 || !isMime(content.mime) ||
!mimeAllowed(type, content.mime) || (raw.diff_from !== undefined &&
(!Number.isInteger(raw.diff_from) || (raw.diff_from as number) < 1))) return null;
let lineage: ArtifactVersion["lineage"] = null;
if (raw.lineage !== undefined) {
const value = raw.lineage as Record<string, unknown> | null;
if (!value || !isOpaqueId(value.artifact_id, "art") ||
!Number.isInteger(value.version) || (value.version as number) < 1) return null;
lineage = {artifactId: value.artifact_id, version: value.version as number};
}
return {
version: raw.version as number,
createdAt: raw.created_at,
createdBy: String(actor.type),
contentRef: {hash: content.hash, bytes: content.bytes as number, mime: content.mime},
diffFrom: raw.diff_from as number | undefined || null,
lineage,
note: safeText(raw.note, "", 200),
};
}
export function normalizeArtifact(raw: RawArtifact, expectedOwner: string, scope: ArtifactScope): Artifact | null {
if (raw.schema !== "hux.artifact.v1" || !isOpaqueId(raw.id, "art") ||
raw.owner !== expectedOwner || raw.project_id !== scope.projectId ||
raw.conversation_id !== scope.conversationId || !TYPES.has(raw.type as ArtifactType) ||
!Number.isInteger(raw.current_version) || (raw.current_version as number) < 1 ||
!Array.isArray(raw.versions) || raw.versions.length < 1 || raw.versions.length > MAX_VERSIONS ||
!SENSITIVITIES.has(raw.sensitivity as Sensitivity) || !isUtc(raw.created_at) ||
!isUtc(raw.updated_at) || raw.updated_at < raw.created_at) return null;
const type = raw.type as ArtifactType;
const language = safeText(raw.language, "", 40);
const title = safeText(raw.title, "", 200);
if (!title) return null;
const versions = raw.versions.map((item) => normalizeVersion(item as RawArtifactVersion, type));
if (versions.some((item) => item === null)) return null;
const ordered = versions as ArtifactVersion[];
if (ordered.some((item, index) => item.version !== index + 1 ||
(index > 0 && item.createdAt < ordered[index - 1].createdAt) ||
(item.diffFrom !== null && item.diffFrom >= item.version) ||
(item.lineage?.artifactId === raw.id && item.lineage.version >= item.version)) ||
raw.current_version !== ordered.length) return null;
let promotedVersion: number | null = null;
if (raw.promotion !== undefined) {
const promotion = raw.promotion as Record<string, unknown> | null;
if (!promotion || promotion.project_id !== scope.projectId ||
!Number.isInteger(promotion.version) || (promotion.version as number) < 1 ||
(promotion.version as number) > ordered.length || !isUtc(promotion.at)) return null;
promotedVersion = promotion.version as number;
}
return {
id: raw.id, owner: expectedOwner, projectId: scope.projectId,
conversationId: scope.conversationId, type, renderer: rendererFor(type, language), language,
title, currentVersion: raw.current_version as number, versions: ordered,
sensitivity: raw.sensitivity as Sensitivity, promotedVersion, updatedAt: raw.updated_at,
};
}
function normalizeAttachments(raw: unknown, allowed: ReadonlySet<string>): ArtifactAttachments[] {
if (!Array.isArray(raw)) return [];
const result: ArtifactAttachments[] = [];
const seen = new Set<string>();
for (const item of raw.slice(0, MAX_ARTIFACTS)) {
const value = item as Record<string, unknown> | null;
const sources = value?.source_ids;
const citations = value?.citation_ids;
if (!value || !isOpaqueId(value.artifact_id, "art") || !allowed.has(value.artifact_id) ||
seen.has(value.artifact_id) || !Array.isArray(sources) || !Array.isArray(citations) ||
sources.length > 500 || citations.length > 500 ||
sources.some((id) => !isOpaqueId(id, "src")) ||
citations.some((id) => !isOpaqueId(id, "cit")) ||
new Set(sources).size !== sources.length || new Set(citations).size !== citations.length) continue;
seen.add(value.artifact_id);
result.push({artifactId: value.artifact_id, sourceCount: sources.length, citationCount: citations.length});
}
return result;
}
function normalizeAuthorizations(raw: unknown, allowed: ReadonlySet<string>): ArtifactAuthorization[] {
if (!Array.isArray(raw)) return [];
const result: ArtifactAuthorization[] = [];
const seen = new Set<string>();
for (const item of raw.slice(0, MAX_ARTIFACTS)) {
const value = item as Record<string, unknown> | null;
if (!value || !isOpaqueId(value.artifact_id, "art") || !allowed.has(value.artifact_id) ||
seen.has(value.artifact_id) || !AUTHORIZATION.has(value.download as AuthorizationStatus) ||
!AUTHORIZATION.has(value.share as AuthorizationStatus)) continue;
seen.add(value.artifact_id);
result.push({artifactId: value.artifact_id, download: value.download as AuthorizationStatus,
share: value.share as AuthorizationStatus});
}
return result;
}
export function normalizeArtifactWorkspace(raw: RawArtifactEnvelope, expected: HuxIdentity,
scope: ArtifactScope) {
const trustedIdentity = normalizeIdentity({tenant_ref: expected.tenantRef,
user_ref: expected.userRef, surface: expected.surface});
if (!raw || typeof raw !== "object" || raw.schema !== "hux.artifact_workspace.v1" ||
raw.api_version !== "hux.v1" || !sameIdentity(normalizeIdentity(raw.identity), trustedIdentity) ||
!sameScope(normalizeScope(raw.binding), scope)) {
throw new ArtifactContractError("Artifact workspace crossed its scope boundary");
}
const records = Array.isArray(raw.artifacts) ? raw.artifacts : [];
const normalized = records.slice(0, MAX_ARTIFACTS)
.map((item) => normalizeArtifact(item as RawArtifact, trustedIdentity.userRef, scope))
.filter((item): item is Artifact => item !== null);
const ids = new Set<string>();
const artifacts = normalized.filter((item) => !ids.has(item.id) && Boolean(ids.add(item.id)));
const allowed = new Set(artifacts.map((item) => item.id));
return {
artifacts,
attachments: normalizeAttachments(raw.attachments, allowed),
authorizations: normalizeAuthorizations(raw.authorizations, allowed),
rejected: records.length - artifacts.length,
};
}
export function normalizePreview(raw: RawPreviewPayload, artifact: Artifact, expected: HuxIdentity,
scope: ArtifactScope): ArtifactPreview | null {
try {
const identity = normalizeIdentity(raw.identity);
const trustedIdentity = normalizeIdentity({tenant_ref: expected.tenantRef,
user_ref: expected.userRef, surface: expected.surface});
const binding = normalizeScope(raw.binding);
const version = artifact.versions.find((item) => item.version === raw.version);
if (raw.schema !== "hux.artifact_preview.v1" || !sameIdentity(identity, trustedIdentity) ||
!sameScope(binding, scope) || raw.artifact_id !== artifact.id || !version ||
raw.hash !== version.contentRef.hash || raw.mime !== version.contentRef.mime) return null;
if (artifact.renderer === "image" || artifact.renderer === "audio") {
const blobUrl = safeBlobUrl(raw.blob_url);
if (!blobUrl || raw.text !== undefined) return null;
return {artifactId: artifact.id, version: version.version, renderer: artifact.renderer,
mime: version.contentRef.mime, text: null, blobUrl};
}
if (raw.blob_url !== undefined) return null;
const text = safePreviewText(raw.text);
if (text === null) return null;
return {artifactId: artifact.id, version: version.version, renderer: artifact.renderer,
mime: version.contentRef.mime, text, blobUrl: null};
} catch {
return null;
}
}
function sameVersion(left: ArtifactVersion, right: ArtifactVersion): boolean {
return left.version === right.version && left.createdAt === right.createdAt &&
left.createdBy === right.createdBy && left.contentRef.hash === right.contentRef.hash &&
left.contentRef.bytes === right.contentRef.bytes && left.contentRef.mime === right.contentRef.mime &&
left.diffFrom === right.diffFrom && left.lineage?.artifactId === right.lineage?.artifactId &&
left.lineage?.version === right.lineage?.version && left.note === right.note;
}
/** A server mutation may append versions but never rewrite history or detach scope. */
export function preservesImmutableHistory(before: Artifact, after: Artifact): boolean {
return before.id === after.id && before.owner === after.owner &&
before.projectId === after.projectId && before.conversationId === after.conversationId &&
before.type === after.type && before.versions.length <= after.versions.length &&
before.versions.every((version, index) => sameVersion(version, after.versions[index])) &&
after.currentVersion === after.versions.length;
}