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
143 lines
6.0 KiB
TypeScript
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)];
|
|
}
|