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

143 lines
6.0 KiB
TypeScript

/** Fail-closed privacy.schema.json adapters for the HUX-10 frontend. */
import {
asRecord,
exactKeys,
isSafeText,
isUtc,
normalizeIdentity,
sameIdentity,
} from "./security.ts";
import type {
PrivacyControl,
PrivacyNoticeView,
PrivacyScope,
PrivacySnapshot,
RawPrivacyNotice,
ScopedIdentity,
} from "./types.ts";
export const FOUNDATION_FLAG = "hux.foundation";
export const PRIVACY_FLAG = "hux.privacy";
export const GENERIC_NOTICE =
"Sensitive-topic protections are active. This context stays in this conversation and is not remembered without your approval.";
const TOPIC_RULES = Object.freeze({
health: {memoryWrite: "ask", decayDays: 30},
finance: {memoryWrite: "ask", decayDays: 30},
legal: {memoryWrite: "ask", decayDays: 30},
relationships: {memoryWrite: "ask", decayDays: 14},
credentials: {memoryWrite: "deny", decayDays: 1},
minors: {memoryWrite: "deny", decayDays: 7},
location: {memoryWrite: "deny", decayDays: 7},
biometric: {memoryWrite: "deny", decayDays: 1},
} as const);
const NOTICE_CONTROLS = new Set<PrivacyControl>([
"forget_this_conversation", "switch_to_private", "disable_memory_here", "dismiss",
]);
const RETENTION_SCOPES = new Set(["conversation", "session"]);
const MEMORY_WRITES = new Set(["ask", "deny"]);
const SHARING = new Set(["never", "same_owner_only"]);
function integer(value: unknown, min: number, max: number): value is number {
return Number.isSafeInteger(value) && (value as number) >= min && (value as number) <= max;
}
export function privacyEnabled(flags?: Iterable<string>): boolean {
if (!flags) return false;
const enabled = new Set(flags);
return enabled.has(FOUNDATION_FLAG) && enabled.has(PRIVACY_FLAG);
}
export function normalizeNotice(
raw: RawPrivacyNotice,
conversationId: string,
expectedMemoryWrite: "ask" | "deny",
expectedDecayDays: number | null,
): PrivacyNoticeView | null {
const value = asRecord(raw);
if (!value || !exactKeys(value, [
"schema", "topic", "conversation_id", "text", "controls", "shown_at",
]) || value.schema !== "hux.privacy_notice.v1" ||
!Object.prototype.hasOwnProperty.call(TOPIC_RULES, value.topic as string) ||
value.conversation_id !== conversationId || !isSafeText(value.text, 1, 280) ||
!isUtc(value.shown_at) || !Array.isArray(value.controls) ||
value.controls.length < 1 || value.controls.length > NOTICE_CONTROLS.size ||
value.controls.some((control) => !NOTICE_CONTROLS.has(control as PrivacyControl)) ||
new Set(value.controls).size !== value.controls.length) return null;
const rule = TOPIC_RULES[value.topic as keyof typeof TOPIC_RULES];
if (rule.memoryWrite !== expectedMemoryWrite || rule.decayDays !== expectedDecayDays)
return null;
// Never carry the category or server text into view state. The server record
// is validated, then reduced to one generic sentence and explicit controls.
return {
schema: "hux.privacy_notice.v1", text: GENERIC_NOTICE,
controls: value.controls as PrivacyControl[], shownAt: value.shown_at as string,
};
}
export function normalizeSnapshot(
raw: unknown,
expectedIdentity: ScopedIdentity,
expectedScope: PrivacyScope,
): PrivacySnapshot | null {
const value = asRecord(raw);
if (!value || !exactKeys(value, [
"schema", "api_version", "identity", "session_id", "conversation_id", "revision",
"no_store", "memory_enabled", "retention", "notice",
]) || value.schema !== "hux.privacy_snapshot.v1" || value.api_version !== "hux.v1" ||
value.session_id !== expectedScope.sessionId ||
value.conversation_id !== expectedScope.conversationId ||
!integer(value.revision, 1, Number.MAX_SAFE_INTEGER) ||
typeof value.no_store !== "boolean" || typeof value.memory_enabled !== "boolean")
return null;
const identity = normalizeIdentity(value.identity);
const retention = asRecord(value.retention);
if (!identity || !sameIdentity(identity, expectedIdentity) || !retention ||
!exactKeys(retention, [
"scope", "memory_write", "topic_context_decay_days", "audit_interval_days",
"next_audit_at", "cross_surface_sharing",
]) || !RETENTION_SCOPES.has(retention.scope as string) ||
!MEMORY_WRITES.has(retention.memory_write as string) ||
(retention.topic_context_decay_days !== null &&
!integer(retention.topic_context_decay_days, 1, 365)) ||
!integer(retention.audit_interval_days, 1, 30) || !isUtc(retention.next_audit_at) ||
!SHARING.has(retention.cross_surface_sharing as string)) return null;
const memoryWrite = retention.memory_write as "ask" | "deny";
const decayDays = retention.topic_context_decay_days as number | null;
if (value.no_store ?
retention.scope !== "session" || value.memory_enabled || memoryWrite !== "deny" || decayDays !== null :
retention.scope !== "conversation" || (value.memory_enabled !== (memoryWrite === "ask")))
return null;
const notice = value.notice === null ? null : normalizeNotice(
value.notice as RawPrivacyNotice, expectedScope.conversationId, memoryWrite, decayDays,
);
if (value.notice !== null && !notice) return null;
return {
schema: "hux.privacy_snapshot.v1", identity, scope: expectedScope,
revision: value.revision as number, noStore: value.no_store,
memoryEnabled: value.memory_enabled,
retention: {
scope: retention.scope as "conversation" | "session", memoryWrite,
topicContextDecayDays: decayDays,
auditIntervalDays: retention.audit_interval_days as number,
nextAuditAt: retention.next_audit_at as string,
crossSurfaceSharing: retention.cross_surface_sharing as "never" | "same_owner_only",
},
notice,
};
}
export function withoutNotice(snapshot: PrivacySnapshot): PrivacySnapshot {
return snapshot.notice ? {...snapshot, notice: null} : snapshot;
}
export function privacyControls(snapshot: PrivacySnapshot): PrivacyControl[] {
const controls: PrivacyControl[] = [
snapshot.noStore ? "disable_no_store" : "enable_no_store",
...(snapshot.memoryEnabled ? ["disable_memory_here" as const] : []),
...(snapshot.notice?.controls || []),
];
return [...new Set(controls)];
}