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

240 lines
7.1 KiB
TypeScript

/** Fail-closed normalization for the append-only HUX-02 memory ledger. */
import {
MemoryContractError,
isOpaqueMemoryId,
isUtcTimestamp,
normalizeIdentity,
safeMemoryText,
sameIdentity,
} from "./security.ts";
import type {
HuxIdentity,
MemoryItem,
MemoryKind,
MemoryPage,
MemoryStatus,
MemoryTopic,
RawMemoryEntry,
RawMemoryPage,
Sensitivity,
} from "./types.ts";
export const FOUNDATION_FLAG = "hux.foundation";
export const PRIVACY_FLAG = "hux.privacy";
export const MEMORY_CONTROL_FLAG = "hux.memory_control";
const KINDS = new Set<MemoryKind>([
"preference",
"fact",
"instruction",
"context",
]);
const STATUSES = new Set<MemoryStatus>([
"proposed",
"active",
"rejected",
"expired",
"forgotten",
]);
const SENSITIVITIES = new Set<Sensitivity>([
"public",
"personal",
"sensitive",
"restricted",
]);
export const MEMORY_TOPICS = [
"general",
"health",
"finance",
"legal",
"relationships",
"credentials",
"minors",
"location",
"biometric",
] as const satisfies readonly MemoryTopic[];
const TOPICS = new Set<MemoryTopic>(MEMORY_TOPICS);
const SOURCE_LABELS: Readonly<Record<string, string>> = {
message: "Source message",
tool_call: "Tool request",
tool_result: "Tool evidence",
artifact_version: "Artifact version",
source: "Research source",
passage: "Supporting passage",
memory: "Earlier memory entry",
approval: "Approval decision",
run: "Agent run",
url: "Website",
file: "Workspace file",
build: "Build evidence",
flux: "Flux reconciliation",
pod: "Workload evidence",
};
const MAX_ENTRIES = 200;
export function memoryControlEnabled(flags?: Iterable<string>): boolean {
if (!flags) return false;
const active = new Set(flags);
return (
active.has(FOUNDATION_FLAG) &&
active.has(PRIVACY_FLAG) &&
active.has(MEMORY_CONTROL_FLAG)
);
}
function ttlLabel(raw: unknown): string | null {
const ttl = raw as Record<string, unknown> | null;
if (!ttl || Array.isArray(ttl)) return null;
if (ttl.policy === "never") return "Kept until you forget it";
if (ttl.policy === "expires_at" && isUtcTimestamp(ttl.expires_at)) {
return `Expires ${ttl.expires_at}`;
}
if (
ttl.policy === "decay" &&
Number.isInteger(ttl.decay_days) &&
(ttl.decay_days as number) >= 1 &&
(ttl.decay_days as number) <= 3650
) {
return `Reviewed after ${ttl.decay_days} days`;
}
return null;
}
function scopeLabel(raw: unknown): string | null {
const scope = raw as Record<string, unknown> | null;
if (!scope || Array.isArray(scope)) return null;
if (scope.level === "global" && scope.scope_id === undefined)
return "All conversations";
if (scope.level === "project" && typeof scope.scope_id === "string")
return "This project";
if (scope.level === "conversation" && typeof scope.scope_id === "string")
return "This conversation";
return null;
}
function reasonFor(raw: RawMemoryEntry, hidden: boolean): string {
if (hidden) return "Reason hidden by privacy controls";
const audit = Array.isArray(raw.audit) ? raw.audit : [];
for (let index = audit.length - 1; index >= 0; index -= 1) {
const record = audit[index] as Record<string, unknown> | null;
if (record?.note) return safeMemoryText(record.note, "Saved for future help", 200);
}
return raw.status === "proposed"
? "Hermes suggested this for your approval"
: "Saved for future help";
}
function normalizeEntry(
raw: RawMemoryEntry,
expected: HuxIdentity,
): MemoryItem | null {
if (
raw.schema !== "hux.memory.v1" ||
!isOpaqueMemoryId(raw.id) ||
raw.owner !== expected.userRef ||
!KINDS.has(raw.kind as MemoryKind) ||
!STATUSES.has(raw.status as MemoryStatus) ||
!SENSITIVITIES.has(raw.sensitivity as Sensitivity) ||
!TOPICS.has((raw.topic ?? "general") as MemoryTopic) ||
!["automatic", "ask"].includes(String(raw.approval_mode)) ||
typeof raw.content !== "string" ||
raw.content.length > 2000 ||
!Array.isArray(raw.audit) ||
raw.audit.length < 1 ||
!isUtcTimestamp(raw.created_at) ||
!isUtcTimestamp(raw.updated_at)
) {
return null;
}
const provenance = raw.provenance as Record<string, unknown> | null;
const source = raw.source as Record<string, unknown> | null;
const ttl = ttlLabel(raw.ttl);
const scope = scopeLabel(raw.scope);
if (
!provenance ||
provenance.surface !== expected.surface ||
!isUtcTimestamp(provenance.recorded_at) ||
!source ||
typeof source.id !== "string" ||
source.id.length < 1 ||
source.id.length > 200 ||
typeof source.kind !== "string" ||
!(source.kind in SOURCE_LABELS) ||
!ttl ||
!scope
) {
return null;
}
const actor = provenance.actor as Record<string, unknown> | null;
if (
!actor ||
!["user", "assistant", "tool", "system", "operator"].includes(String(actor.type)) ||
typeof actor.id !== "string" ||
actor.id.length < 1 ||
actor.id.length > 120
)
return null;
const status = raw.status as MemoryStatus;
const sensitivity = raw.sensitivity as Sensitivity;
const hidden =
status === "forgotten" ||
sensitivity === "sensitive" ||
sensitivity === "restricted";
return {
id: raw.id,
kind: raw.kind as MemoryKind,
content: hidden ? null : safeMemoryText(raw.content, "Memory content unavailable", 2000),
contentNotice:
status === "forgotten"
? "Content permanently removed"
: hidden
? "Sensitive value hidden by privacy controls"
: null,
status,
sensitivity,
topic: (raw.topic ?? "general") as MemoryTopic,
approvalMode: raw.approval_mode as "automatic" | "ask",
scopeLabel: scope,
sourceLabel: SOURCE_LABELS[source.kind],
sourceIsMessage: source.kind === "message",
provenanceLabel: `${String(actor.type)} via ${expected.surface}`,
reason: reasonFor(raw, hidden),
savedAt: raw.created_at,
updatedAt: raw.updated_at,
ttlLabel: ttl,
};
}
export function normalizeMemoryPage(
raw: RawMemoryPage,
expectedIdentity: HuxIdentity,
): MemoryPage {
const expected = normalizeIdentity(expectedIdentity);
if (!raw || typeof raw !== "object" || raw.schema !== "hux.memory_page.v1") {
throw new MemoryContractError("Memory response version is unsupported");
}
const actual = normalizeIdentity(raw.identity);
if (!sameIdentity(actual, expected)) {
throw new MemoryContractError("Memory response crossed a scope boundary");
}
if (!Array.isArray(raw.entries)) {
throw new MemoryContractError("Memory ledger entries are missing");
}
const records = raw.entries;
const normalized = records
.map((entry) => normalizeEntry(entry as RawMemoryEntry, expected))
.filter((entry): entry is MemoryItem => entry !== null)
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
const truncated = Math.max(0, normalized.length - MAX_ENTRIES);
return {
entries: truncated ? normalized.slice(0, MAX_ENTRIES) : normalized,
invalid: records.length - normalized.length,
truncated,
nextCursor:
typeof raw.next_cursor === "string" && raw.next_cursor.length <= 200
? raw.next_cursor
: null,
};
}