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
313 lines
13 KiB
JavaScript
313 lines
13 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
createPrivacyClient,
|
|
PrivacyContractError,
|
|
} from "../../dockerfiles/hermes-webui-hux/privacy/client.ts";
|
|
import {
|
|
FOUNDATION_FLAG,
|
|
GENERIC_NOTICE,
|
|
PRIVACY_FLAG,
|
|
normalizeNotice,
|
|
normalizeSnapshot,
|
|
privacyControls,
|
|
privacyEnabled,
|
|
withoutNotice,
|
|
} from "../../dockerfiles/hermes-webui-hux/privacy/model.ts";
|
|
import {
|
|
asRecord,
|
|
exactKeys,
|
|
isId,
|
|
isSafeText,
|
|
isUtc,
|
|
normalizeIdentity,
|
|
normalizeScope,
|
|
privacyQuery,
|
|
sameIdentity,
|
|
} from "../../dockerfiles/hermes-webui-hux/privacy/security.ts";
|
|
|
|
const IDENTITY = Object.freeze({
|
|
tenantRef: "tnt_0123456789abcdef",
|
|
userRef: "usr_0123456789abcdef",
|
|
surface: "chat",
|
|
});
|
|
const RAW_IDENTITY = Object.freeze({
|
|
tenant_ref: IDENTITY.tenantRef,
|
|
user_ref: IDENTITY.userRef,
|
|
surface: IDENTITY.surface,
|
|
});
|
|
const SCOPE = Object.freeze({
|
|
sessionId: "ses_alpha1234",
|
|
conversationId: "conv_alpha1234",
|
|
});
|
|
const SHOWN_AT = "2026-08-24T10:00:00Z";
|
|
|
|
function notice(extra = {}) {
|
|
return {
|
|
schema: "hux.privacy_notice.v1",
|
|
topic: "health",
|
|
conversation_id: SCOPE.conversationId,
|
|
text: "This looks like a health topic. It stays in this conversation.",
|
|
controls: [
|
|
"forget_this_conversation", "switch_to_private", "disable_memory_here", "dismiss",
|
|
],
|
|
shown_at: SHOWN_AT,
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
function retention(extra = {}) {
|
|
return {
|
|
scope: "conversation",
|
|
memory_write: "ask",
|
|
topic_context_decay_days: 30,
|
|
audit_interval_days: 1,
|
|
next_audit_at: "2026-08-25T03:00:00Z",
|
|
cross_surface_sharing: "never",
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
function snapshot(extra = {}) {
|
|
return {
|
|
schema: "hux.privacy_snapshot.v1",
|
|
api_version: "hux.v1",
|
|
identity: RAW_IDENTITY,
|
|
session_id: SCOPE.sessionId,
|
|
conversation_id: SCOPE.conversationId,
|
|
revision: 1,
|
|
no_store: false,
|
|
memory_enabled: true,
|
|
retention: retention(),
|
|
notice: notice(),
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
function noStore(extra = {}) {
|
|
return snapshot({
|
|
revision: 2,
|
|
no_store: true,
|
|
memory_enabled: false,
|
|
retention: retention({scope: "session", memory_write: "deny",
|
|
topic_context_decay_days: null}),
|
|
notice: null,
|
|
...extra,
|
|
});
|
|
}
|
|
|
|
function memoryOff(extra = {}) {
|
|
return snapshot({
|
|
revision: 2,
|
|
memory_enabled: false,
|
|
retention: retention({memory_write: "deny", topic_context_decay_days: null}),
|
|
notice: null,
|
|
...extra,
|
|
});
|
|
}
|
|
|
|
function harness(responses, flags = [FOUNDATION_FLAG, PRIVACY_FLAG]) {
|
|
const calls = [];
|
|
const enabled = new Set(flags);
|
|
const foundation = {
|
|
apiVersion: "hux.v1",
|
|
identity: IDENTITY,
|
|
enabled: (flag) => enabled.has(flag),
|
|
endpoint: (path) => `/hux/v1${path}`,
|
|
};
|
|
const fetcher = async (url, init) => {
|
|
calls.push({url, init});
|
|
const response = responses.shift();
|
|
if (!response) throw new Error("unexpected request");
|
|
return {ok: response.ok ?? true, status: response.status ?? 200,
|
|
json: async () => response.body};
|
|
};
|
|
return {calls, foundation,
|
|
client: createPrivacyClient({client: foundation, fetcher})};
|
|
}
|
|
|
|
test("HUX-10 is default-off and requires the foundation", () => {
|
|
assert.equal(privacyEnabled(), false);
|
|
assert.equal(privacyEnabled([]), false);
|
|
assert.equal(privacyEnabled([FOUNDATION_FLAG]), false);
|
|
assert.equal(privacyEnabled([PRIVACY_FLAG]), false);
|
|
assert.equal(privacyEnabled([FOUNDATION_FLAG, PRIVACY_FLAG]), true);
|
|
});
|
|
|
|
test("security primitives bind opaque scope and same-origin query", () => {
|
|
assert.deepEqual(asRecord({a: 1}), {a: 1});
|
|
assert.equal(asRecord(null), null);
|
|
assert.equal(asRecord([]), null);
|
|
assert.equal(exactKeys({a: 1, b: 2}, ["a"], ["b"]), true);
|
|
assert.equal(exactKeys({a: 1, x: 2}, ["a"]), false);
|
|
assert.equal(isId("conv_alpha1234", "conv"), true);
|
|
assert.equal(isId("bad", "conv"), false);
|
|
assert.equal(isId("ses_alpha1234", "conv"), false);
|
|
assert.equal(isUtc("2026-08-24T10:00:00Z"), true);
|
|
assert.equal(isUtc("2026-99-99T10:00:00Z"), false);
|
|
assert.equal(isSafeText("safe", 1, 20), true);
|
|
assert.equal(isSafeText("bad\u0000", 1, 20), false);
|
|
assert.deepEqual(normalizeIdentity(RAW_IDENTITY), IDENTITY);
|
|
for (const raw of [null, [], {}, {...RAW_IDENTITY, extra: true},
|
|
{...RAW_IDENTITY, tenant_ref: "bad"}, {...RAW_IDENTITY, user_ref: "bad"},
|
|
{...RAW_IDENTITY, surface: "browser"}]) assert.equal(normalizeIdentity(raw), null);
|
|
assert.equal(sameIdentity(IDENTITY, IDENTITY), true);
|
|
assert.equal(sameIdentity(IDENTITY, {...IDENTITY, surface: "worker"}), false);
|
|
assert.deepEqual(normalizeScope(SCOPE), SCOPE);
|
|
for (const raw of [null, {...SCOPE, extra: true}, {...SCOPE, sessionId: "bad"},
|
|
{...SCOPE, conversationId: "bad"}]) assert.equal(normalizeScope(raw), null);
|
|
assert.equal(privacyQuery(SCOPE),
|
|
"/privacy/snapshot?session_id=ses_alpha1234&conversation_id=conv_alpha1234");
|
|
assert.throws(() => privacyQuery({...SCOPE, sessionId: "bad"}), TypeError);
|
|
});
|
|
|
|
test("notice validation drops category and source text from view state", () => {
|
|
const item = normalizeNotice(notice(), SCOPE.conversationId, "ask", 30);
|
|
assert.equal(item.text, GENERIC_NOTICE);
|
|
assert.equal("topic" in item, false);
|
|
assert.deepEqual(item.controls, notice().controls);
|
|
const restricted = normalizeNotice(notice({topic: "credentials"}),
|
|
SCOPE.conversationId, "deny", 1);
|
|
assert.equal(restricted.text, GENERIC_NOTICE);
|
|
const invalid = [null, {...notice(), extra: true}, {...notice(), schema: "old"},
|
|
{...notice(), topic: "politics"}, {...notice(), conversation_id: "conv_other1234"},
|
|
{...notice(), text: ""}, {...notice(), text: "bad\u0000"},
|
|
{...notice(), shown_at: "today"}, {...notice(), controls: null},
|
|
{...notice(), controls: []}, {...notice(), controls: [...notice().controls, "dismiss"]},
|
|
{...notice(), controls: ["unknown"]}];
|
|
invalid.forEach((raw) => assert.equal(
|
|
normalizeNotice(raw, SCOPE.conversationId, "ask", 30), null));
|
|
assert.equal(normalizeNotice(notice(), SCOPE.conversationId, "deny", 30), null);
|
|
assert.equal(normalizeNotice(notice(), SCOPE.conversationId, "ask", 1), null);
|
|
});
|
|
|
|
test("snapshot exposes generic retention while enforcing policy semantics", () => {
|
|
const item = normalizeSnapshot(snapshot(), IDENTITY, SCOPE);
|
|
assert.equal(item.notice.text, GENERIC_NOTICE);
|
|
assert.equal(item.retention.topicContextDecayDays, 30);
|
|
const privateItem = normalizeSnapshot(noStore(), IDENTITY, SCOPE);
|
|
assert.equal(privateItem.noStore, true);
|
|
assert.equal(privateItem.retention.scope, "session");
|
|
assert.equal(normalizeSnapshot(memoryOff(), IDENTITY, SCOPE).memoryEnabled, false);
|
|
const restricted = normalizeSnapshot(snapshot({memory_enabled: false,
|
|
retention: retention({memory_write: "deny", topic_context_decay_days: 1}),
|
|
notice: notice({topic: "credentials"})}), IDENTITY, SCOPE);
|
|
assert.equal(restricted.notice.text, GENERIC_NOTICE);
|
|
const changedTopic = normalizeSnapshot(snapshot({notice: null}), IDENTITY, SCOPE);
|
|
assert.equal(changedTopic.notice, null);
|
|
});
|
|
|
|
test("snapshot rejects malformed scope, retention, no-store and notice state", () => {
|
|
const invalid = [null, {...snapshot(), extra: true}, {...snapshot(), schema: "old"},
|
|
{...snapshot(), api_version: "hux.v0"},
|
|
{...snapshot(), identity: {...RAW_IDENTITY, surface: "worker"}},
|
|
{...snapshot(), session_id: "ses_other1234"},
|
|
{...snapshot(), conversation_id: "conv_other1234"},
|
|
{...snapshot(), revision: 0}, {...snapshot(), no_store: "no"},
|
|
{...snapshot(), memory_enabled: "yes"}, {...snapshot(), retention: null},
|
|
{...snapshot(), retention: {...retention(), extra: true}},
|
|
{...snapshot(), retention: retention({scope: "forever"})},
|
|
{...snapshot(), retention: retention({memory_write: "automatic"})},
|
|
{...snapshot(), retention: retention({topic_context_decay_days: 0})},
|
|
{...snapshot(), retention: retention({audit_interval_days: 31})},
|
|
{...snapshot(), retention: retention({next_audit_at: "tomorrow"})},
|
|
{...snapshot(), retention: retention({cross_surface_sharing: "everyone"})},
|
|
{...snapshot(), no_store: true},
|
|
{...noStore(), retention: retention({scope: "session", memory_write: "deny",
|
|
topic_context_decay_days: 1})},
|
|
{...noStore(), memory_enabled: true},
|
|
{...snapshot(), memory_enabled: false},
|
|
{...snapshot(), notice: notice({topic: "credentials"})},
|
|
];
|
|
invalid.forEach((raw) => assert.equal(normalizeSnapshot(raw, IDENTITY, SCOPE), null));
|
|
});
|
|
|
|
test("view helpers suppress repeats and expose only effective controls", () => {
|
|
const item = normalizeSnapshot(snapshot(), IDENTITY, SCOPE);
|
|
assert.equal(withoutNotice(item).notice, null);
|
|
const changed = withoutNotice({...item, notice: null});
|
|
assert.equal(changed.notice, null);
|
|
assert.deepEqual(privacyControls(item), [
|
|
"enable_no_store", "disable_memory_here", "forget_this_conversation",
|
|
"switch_to_private", "dismiss",
|
|
]);
|
|
assert.deepEqual(privacyControls(normalizeSnapshot(noStore(), IDENTITY, SCOPE)),
|
|
["disable_no_store"]);
|
|
});
|
|
|
|
test("client loads scoped no-cache state and does not reiterate notices", async () => {
|
|
const current = harness([{body: snapshot()}, {body: snapshot()},
|
|
{body: snapshot({revision: 2, notice: null})},
|
|
{body: snapshot({revision: 3, notice: notice({topic: "finance",
|
|
shown_at: "2026-08-24T11:00:00Z"})})}]);
|
|
assert.equal(current.client.enabled(), true);
|
|
const first = await current.client.load(SCOPE);
|
|
assert.ok(first.notice);
|
|
assert.equal((await current.client.load(SCOPE)).notice, null);
|
|
assert.equal((await current.client.load(SCOPE)).notice, null);
|
|
assert.ok((await current.client.load(SCOPE)).notice);
|
|
assert.equal(current.calls[0].url,
|
|
"/hux/v1/privacy/snapshot?session_id=ses_alpha1234&conversation_id=conv_alpha1234");
|
|
assert.equal(current.calls[0].init.method, "GET");
|
|
assert.equal(current.calls[0].init.cache, "no-store");
|
|
assert.equal(current.calls[0].init.credentials, "same-origin");
|
|
});
|
|
|
|
test("client applies each explicit control with optimistic scope binding", async () => {
|
|
const cases = [
|
|
["enable_no_store", noStore()],
|
|
["switch_to_private", noStore()],
|
|
["forget_this_conversation", noStore()],
|
|
["disable_memory_here", memoryOff()],
|
|
["dismiss", snapshot({revision: 2, notice: null})],
|
|
];
|
|
for (const [action, result] of cases) {
|
|
const current = harness([{body: snapshot()}, {body: result}]);
|
|
const before = await current.client.load(SCOPE);
|
|
const after = await current.client.control(before, action);
|
|
assert.equal(after.revision, 2);
|
|
assert.equal(current.calls[1].url, "/hux/v1/privacy/controls");
|
|
assert.deepEqual(JSON.parse(current.calls[1].init.body), {
|
|
schema: "hux.privacy_control_request.v1", session_id: SCOPE.sessionId,
|
|
conversation_id: SCOPE.conversationId, expected_revision: 1, action,
|
|
});
|
|
await assert.rejects(current.client.control(before, action), /not bound/);
|
|
}
|
|
const current = harness([{body: noStore({revision: 1})},
|
|
{body: snapshot({revision: 2, notice: null})}]);
|
|
const before = await current.client.load(SCOPE);
|
|
assert.equal((await current.client.control(before, "disable_no_store")).noStore, false);
|
|
});
|
|
|
|
test("client fails closed for disabled, invalid, stale and cross-scope data", async () => {
|
|
const disabled = harness([], [FOUNDATION_FLAG]);
|
|
assert.equal(disabled.client.enabled(), false);
|
|
await assert.rejects(disabled.client.load(SCOPE), /not enabled/);
|
|
disabled.foundation.apiVersion = "hux.v0";
|
|
await assert.rejects(disabled.client.load(SCOPE), /not enabled/);
|
|
const invalidScope = harness([]);
|
|
await assert.rejects(invalidScope.client.load({...SCOPE, sessionId: "bad"}), /scope/);
|
|
await assert.rejects(invalidScope.client.control({}, "enable_no_store"), /not bound/);
|
|
for (const response of [{ok: false, status: 409}, {ok: false, status: 503}]) {
|
|
const current = harness([response]);
|
|
await assert.rejects(current.client.load(SCOPE), PrivacyContractError);
|
|
}
|
|
const malformed = harness([{body: {}}]);
|
|
await assert.rejects(malformed.client.load(SCOPE), /crossed/);
|
|
const wrongEffect = harness([{body: snapshot()}, {body: snapshot({revision: 2})}]);
|
|
const item = await wrongEffect.client.load(SCOPE);
|
|
await assert.rejects(wrongEffect.client.control(item, "enable_no_store"), /verified/);
|
|
const stale = harness([{body: snapshot()}, {body: noStore({revision: 1})}]);
|
|
await assert.rejects(stale.client.control(await stale.client.load(SCOPE),
|
|
"enable_no_store"), /verified/);
|
|
assert.throws(() => createPrivacyClient({client: null, fetcher: async () => ({})}), /requires/);
|
|
assert.throws(() => createPrivacyClient({client: {...invalidScope.foundation,
|
|
identity: {...IDENTITY, userRef: "bad"}}, fetcher: async () => ({})}), /identity/);
|
|
const originalFetch = globalThis.fetch;
|
|
try {
|
|
globalThis.fetch = undefined;
|
|
assert.throws(() => createPrivacyClient({client: invalidScope.foundation}), /requires/);
|
|
} finally { globalThis.fetch = originalFetch; }
|
|
});
|