jenkins 3bca7b7465 hermes(hux): inline approval prompts and ask-not-deny defaults
Re-enforcement prerequisites, code-side complete: the autonomy runtime
now docks pending-approval cards above the composer (newest first, cap
three, aria-live, allow-once / always / deny wired to the existing
decide route with idempotency; polling gated to active turns and
fail-tolerant), so parked tool calls are never silent. The default
capability matrix no longer denies by default: network and web_search
ask below autonomous (visible prompt) and nothing resolves to deny
except explicit grants or private mode; SO-39 stays intact - deploy and
external side effects always ask and external never auto-allows.
rules.py at 100% line+branch; 744 hux-lane tests green.

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

485 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* node:coverage disable */
(function (root, factory) {
'use strict';
const contract = typeof module === 'object' && module.exports ? require('./wave_a_contract.js') : root.HermesHuxWaveAContract;
const shellApi = typeof module === 'object' && module.exports ? require('../shell.js') : root.HermesHuxShell;
const api = factory(contract, shellApi);
if (typeof module === 'object' && module.exports) module.exports = api;
else root.HermesHuxAutonomyPrivacy = api;
}(typeof globalThis === 'object' ? globalThis : this, function (contract, shellApi) {
/* node:coverage enable */
'use strict';
const ACCEPT = 'application/vnd.hermes.hux+json; version=1';
const ID = /^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$/;
const SLOT = /^slot-[0-9]{1,3}$/;
const USER = /^usr_[0-9a-f]{16,64}$/;
const UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/;
const CONTROL = /[\u0000-\u001f\u007f]/;
const AUTONOMY = new Set(['ask_first', 'safe', 'autonomous']);
const DECISIONS = new Set(['allow', 'ask', 'deny']);
const CHOICES = new Set(['once', 'session', 'always', 'deny']);
const OUTCOMES = new Set(['cancelled', 'already_complete', 'failed_to_cancel']);
const TOPICS = new Set(['health', 'finance', 'legal', 'relationships', 'credentials', 'minors', 'location', 'biometric']);
const NOTICE_CONTROLS = new Set(['forget_this_conversation', 'switch_to_private', 'disable_memory_here', 'dismiss']);
const CAPABILITIES = new Set(['read_files', 'write_files', 'shell', 'network', 'web_search', 'send_message', 'memory_write', 'artifact_write', 'spend_tokens', 'delegate', 'deploy', 'external_side_effect']);
const BUDGETS = ['tokens_per_run', 'tool_calls_per_run', 'wall_clock_seconds', 'delegations_per_run', 'spend_units', 'subagents_per_run'];
const GENERIC_NOTICE = 'Sensitive-topic protections are active. Details stay scoped to this conversation and are not remembered without approval.';
const UNVERIFIED_STOP = 'Model response stopped; tool/side-effect cancellation unverified.';
function record(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : null; }
function exact(value, required, optional) {
const allowed = new Set(required.concat(optional || []));
return required.every((key) => Object.prototype.hasOwnProperty.call(value, key)) &&
Object.keys(value).every((key) => allowed.has(key));
}
function id(value, prefix) { return typeof value === 'string' && ID.test(value) && (!prefix || value.startsWith(`${prefix}_`)); }
function timestamp(value) { return typeof value === 'string' && UTC.test(value) && !Number.isNaN(Date.parse(value)); }
function text(value, max) { return typeof value === 'string' && value.length > 0 && value.length <= max && !CONTROL.test(value); }
function integer(value, min, max) { return Number.isSafeInteger(value) && value >= min && value <= max; }
function sameIdentity(owner, identity) { return owner === identity.userRef; }
function safeRun(value) { return id(value, 'run'); }
function normalizeBudgets(raw) {
const value = record(raw);
if (!value || !exact(value, [], BUDGETS.concat(['scope']))) return null;
const result = {};
for (const key of BUDGETS) {
if (value[key] !== undefined && !integer(value[key], 0, Number.MAX_SAFE_INTEGER)) return null;
if (value[key] !== undefined) result[key] = value[key];
}
if (value.scope !== undefined) {
const scope = record(value.scope);
if (!scope || !exact(scope, [], ['conversations', 'paths'])) return null;
const conversations = scope.conversations || [];
const paths = scope.paths || [];
if (!Array.isArray(conversations) || conversations.length > 64 || conversations.some((item) => !id(item, 'conv')) ||
!Array.isArray(paths) || paths.length > 64 || paths.some((item) => !text(item, 300))) return null;
result.scope = {conversations: conversations.slice(), paths: paths.slice()};
}
return result;
}
function normalizePolicy(raw, identity, conversationId, etag) {
const value = record(raw);
if (!value || !exact(value, ['schema', 'id', 'owner', 'scope', 'autonomy', 'grants', 'budgets', 'provenance', 'updated_at', 'revision']) ||
value.schema !== 'hux.policy.v1' || !id(value.id, 'pol') || !sameIdentity(value.owner, identity) ||
!AUTONOMY.has(value.autonomy) || !timestamp(value.updated_at) || !integer(value.revision, 1, Number.MAX_SAFE_INTEGER) ||
String(etag || '').replaceAll('"', '') !== String(value.revision)) return null;
const scope = record(value.scope);
if (!scope || !exact(scope, ['level'], ['scope_id']) || !['global', 'project', 'conversation'].includes(scope.level) ||
(scope.level === 'global' ? scope.scope_id !== undefined : !id(scope.scope_id, scope.level === 'project' ? 'prj' : 'conv')) ||
(scope.level === 'global' ? value.id !== 'pol_global' : !value.id.startsWith(`pol_${scope.level}.`)) ||
(scope.level === 'conversation' && scope.scope_id !== conversationId) ||
!record(value.provenance) || !Array.isArray(value.grants) || value.grants.length > 64) return null;
const grants = [];
for (const rawGrant of value.grants) {
const grant = record(rawGrant);
if (!grant || !exact(grant, ['capability', 'decision'], ['expires_at', 'granted_by']) ||
!CAPABILITIES.has(grant.capability) || !DECISIONS.has(grant.decision) ||
(grant.expires_at !== undefined && !timestamp(grant.expires_at))) return null;
grants.push({capability: grant.capability, decision: grant.decision, ...(grant.expires_at ? {expires_at: grant.expires_at} : {})});
}
const budgets = normalizeBudgets(value.budgets);
return budgets ? {id: value.id, owner: value.owner, effectiveScope: {...scope},
scope: {level: 'conversation', scope_id: conversationId}, autonomy: value.autonomy, grants, budgets,
revision: value.revision, ifMatch: scope.level === 'conversation' ? value.revision : 0, updatedAt: value.updated_at} : null;
}
function normalizeApproval(raw, conversationId, statuses, expectedChoice, identity) {
const value = record(raw);
if (!value || !exact(value, ['schema', 'id', 'run_id', 'conversation_id', 'capability', 'request', 'status', 'requested_at', 'expires_at'], ['decision', 'idempotency_key']) ||
value.schema !== 'hux.approval.v1' || !id(value.id, 'apr') || !safeRun(value.run_id) ||
value.conversation_id !== conversationId || !CAPABILITIES.has(value.capability) ||
!statuses.has(value.status) || !timestamp(value.requested_at) || !timestamp(value.expires_at)) return null;
const request = record(value.request);
if (!request || !exact(request, ['summary', 'risk', 'external'], ['detail', 'evidence']) || !text(request.summary, 280) ||
!['low', 'medium', 'high'].includes(request.risk) || typeof request.external !== 'boolean') return null;
if (expectedChoice !== undefined) {
const decision = record(value.decision); const actor = decision && record(decision.by);
if (!decision || !exact(decision, ['choice', 'by', 'at']) || decision.choice !== expectedChoice || !timestamp(decision.at) ||
!identity || !actor || !exact(actor, ['type', 'id'], ['display']) || actor.type !== 'user' || actor.id !== identity.userRef) return null;
} else if (value.decision !== undefined) return null;
return {id: value.id, runId: value.run_id, capability: value.capability, summary: request.summary,
risk: request.risk, external: request.external, status: value.status,
requestedAt: value.requested_at, expiresAt: value.expires_at};
}
function normalizeApprovalPage(raw, conversationId) {
const value = record(raw);
if (!value || !exact(value, ['items', 'next']) || !Array.isArray(value.items) || value.items.length > 200 || value.next !== null) return null;
const items = value.items.map((item) => normalizeApproval(item, conversationId, new Set(['pending'])));
return items.some((item) => item === null) || new Set(items.map((item) => item.id)).size !== items.length ? null : items;
}
function normalizeReceipt(raw, runId, conversationId, identity) {
const value = record(raw);
const actor = value && record(value.requested_by);
const human = identity && ['chat', 'telegram', 'voice'].includes(identity.surface);
if (!value || !exact(value, ['schema', 'id', 'run_id', 'requested_by', 'requested_at', 'outcome', 'side_effects'], ['acknowledged_at', 'completed_at', 'conversation_id']) ||
value.schema !== 'hux.cancel_receipt.v1' || !id(value.id, 'rcpt') || value.run_id !== runId || !actor ||
!exact(actor, ['type', 'id'], ['display']) || !identity ||
(human ? actor.type !== 'user' || actor.id !== identity.userRef : actor.type !== 'system' || actor.id !== identity.surface) ||
!timestamp(value.requested_at) || !OUTCOMES.has(value.outcome) ||
(value.conversation_id !== undefined && value.conversation_id !== conversationId) || !Array.isArray(value.side_effects) || value.side_effects.length > 64) return null;
const effects = value.side_effects.map((rawEffect) => {
const effect = record(rawEffect);
return effect && exact(effect, ['description', 'reverted'], ['evidence']) && text(effect.description, 280) && typeof effect.reverted === 'boolean' ?
{description: effect.description, reverted: effect.reverted} : null;
});
return effects.some((item) => item === null) ? null : {id: value.id, runId, outcome: value.outcome, sideEffects: effects};
}
function normalizePrivacyPolicy(raw, auditStale) {
const value = record(raw);
if (!value || !exact(value, ['schema', 'version', 'topics', 'topic_scoping', 'retention_audit'], ['audit_stale']) ||
value.schema !== 'hux.privacy_policy.v1' || !integer(value.version, 1, Number.MAX_SAFE_INTEGER) ||
!Array.isArray(value.topics) || value.topics.length !== TOPICS.size) return null;
const names = new Set(); let shortest = 366; let longest = 0;
for (const rawTopic of value.topics) {
const topic = record(rawTopic);
if (!topic || !exact(topic, ['topic', 'sensitivity', 'memory_write', 'decay_days', 'notice']) || !TOPICS.has(topic.topic) ||
names.has(topic.topic) || !['sensitive', 'restricted'].includes(topic.sensitivity) || !['ask', 'deny'].includes(topic.memory_write) ||
!integer(topic.decay_days, 1, 365) || !text(topic.notice, 280)) return null;
names.add(topic.topic); shortest = Math.min(shortest, topic.decay_days); longest = Math.max(longest, topic.decay_days);
}
const scoping = record(value.topic_scoping); const audit = record(value.retention_audit);
if (!scoping || !exact(scoping, ['scope_to_conversation', 'cross_surface_sharing']) || scoping.scope_to_conversation !== true ||
!['never', 'same_owner_only'].includes(scoping.cross_surface_sharing) || !audit || !exact(audit, ['interval_days', 'actions']) ||
!integer(audit.interval_days, 1, 30) || !Array.isArray(audit.actions) || audit.actions.length < 1) return null;
return {version: value.version, protectedCount: names.size, shortestDecay: shortest, longestDecay: longest,
crossSurfaceSharing: scoping.cross_surface_sharing, auditIntervalDays: audit.interval_days, auditStale: auditStale === 'true'};
}
function normalizeAuditPage(raw) {
const value = record(raw);
if (!value || !exact(value, ['items', 'next']) || !Array.isArray(value.items) || value.items.length > 200) return null;
const rows = value.items.map((rawItem) => {
const item = record(rawItem);
if (!item || !exact(item, ['schema', 'id', 'ran_at', 'policy_version', 'results']) || item.schema !== 'hux.retention_audit.v1' ||
!id(item.id, 'aud') || !timestamp(item.ran_at) || !integer(item.policy_version, 1, Number.MAX_SAFE_INTEGER) || !Array.isArray(item.results)) return null;
let actions = 0; let affected = 0;
for (const rawResult of item.results) {
const result = record(rawResult);
if (!result || !exact(result, ['action', 'count']) || !['expire_memory', 'decay_topic_context', 'purge_forgotten_content', 'report'].includes(result.action) ||
!integer(result.count, 0, Number.MAX_SAFE_INTEGER)) return null;
actions += 1; affected += result.action === 'report' ? 0 : result.count;
}
return {id: item.id, ranAt: item.ran_at, policyVersion: item.policy_version, actions, affected};
});
return rows.some((item) => item === null) ? null : rows;
}
function normalizeNotice(raw, conversationId) {
const value = record(raw);
if (!value || !exact(value, ['schema', 'topic', 'conversation_id', 'text', 'controls', 'shown_at']) || value.schema !== 'hux.privacy_notice.v1' ||
!TOPICS.has(value.topic) || value.conversation_id !== conversationId || !text(value.text, 280) || !timestamp(value.shown_at) ||
!Array.isArray(value.controls) || value.controls.length < 1 || value.controls.some((item) => !NOTICE_CONTROLS.has(item))) return null;
return {text: GENERIC_NOTICE, controls: [...new Set(value.controls)], shownAt: value.shown_at};
}
function normalizeForget(raw, conversationId) {
const value = record(raw);
return value && exact(value, ['conversation_id', 'forgotten', 'memory_forgotten', 'events_redacted']) && value.conversation_id === conversationId &&
value.forgotten === true && integer(value.memory_forgotten, 0, Number.MAX_SAFE_INTEGER) && integer(value.events_redacted, 0, Number.MAX_SAFE_INTEGER) ?
{forgotten: true, memoryForgotten: value.memory_forgotten, eventsRedacted: value.events_redacted} : null;
}
function el(doc, tag, attrs, content) {
const node = doc.createElement(tag);
Object.entries(attrs || {}).forEach(([key, value]) => node.setAttribute(key, value));
if (content !== undefined) node.textContent = String(content);
return node;
}
function status(doc, message, role) { return el(doc, 'p', {class: 'hux-runtime-status', role: role || 'status', 'aria-live': 'polite'}, message); }
function safeError(doc) { return status(doc, 'This workspace control is temporarily unavailable.', 'alert'); }
function responseHeader(response, name) { return response.headers && typeof response.headers.get === 'function' ? response.headers.get(name) : ''; }
function createRuntime(options) {
const settings = options || {}; const doc = settings.document || (typeof document === 'object' ? document : null);
if (!doc || !id(settings.conversationId, 'conv')) throw new TypeError('Runtime needs a document and conversation id');
if (settings.runId !== undefined && !safeRun(settings.runId)) throw new TypeError('Runtime run id is invalid');
const fetcher = settings.fetcher || (typeof fetch === 'function' ? fetch.bind(globalThis) : null);
if (!fetcher) throw new TypeError('Runtime needs fetch');
let client = null; let autonomyRoot = null; let privacyRoot = null; let policy = null; let approvals = [];
let receipt = null; let privacyPolicy = null; let audits = []; let activeTopic = null; let privacyNotice = null;
let noticeShown = false; let noticePending = false; let privacyMessage = ''; let stopMessage = '';
let inlineDock = null; let pollTimer = null;
const issuedApprovals = new Set();
const pollMs = Number.isSafeInteger(settings.approvalPollMs) && settings.approvalPollMs >= 1 ? settings.approvalPollMs : 4000;
function timers() { return settings.timers || globalThis; }
function assertClient(candidate, flag) {
if (!candidate || candidate.apiVersion !== 'hux.v1' || !candidate.identity ||
!SLOT.test(candidate.identity.tenantSlot || '') || !USER.test(candidate.identity.userRef || '') ||
!['chat', 'worker', 'telegram', 'voice', 'api'].includes(candidate.identity.surface) ||
!['router', 'relay', 'worker'].includes(candidate.identity.trust) ||
!candidate.enabled('hux.foundation') || !candidate.enabled(flag)) throw new Error('HUX capability unavailable');
return candidate;
}
async function request(path, init) {
const customHeaders = (init && init.headers) || {};
const response = await fetcher(client.endpoint(path), {...(init || {}), cache: 'no-store', credentials: 'same-origin',
headers: {Accept: ACCEPT, ...((init && init.body) ? {'Content-Type': 'application/json'} : {}), ...customHeaders}});
if (!response.ok) throw new Error(`HUX request failed (${response.status})`);
return response;
}
function key(action, target) { return `hux:${action}:${target}`.slice(0, 120); }
async function loadAutonomy() {
const scope = encodeURIComponent(settings.conversationId);
const [policyResponse, approvalResponse] = await Promise.all([
request(`/policy?scope=conversation&scope_id=${scope}`, {method: 'GET'}),
request('/approvals?status=pending', {method: 'GET'}),
]);
policy = normalizePolicy(await policyResponse.json(), client.identity, settings.conversationId, responseHeader(policyResponse, 'ETag'));
approvals = normalizeApprovalPage(await approvalResponse.json(), settings.conversationId);
if (!policy || !approvals) throw new Error('Invalid autonomy response');
approvals.forEach((item) => issuedApprovals.add(item.id)); renderAutonomy();
}
async function savePolicy(level) {
if (!policy || !AUTONOMY.has(level)) throw new TypeError('Policy choice is invalid');
const body = {scope: policy.scope, autonomy: level, grants: policy.grants, budgets: policy.budgets};
const response = await request('/policy', {method: 'PUT', headers: {'If-Match': String(policy.ifMatch)}, body: JSON.stringify(body)});
const updated = normalizePolicy(await response.json(), client.identity, settings.conversationId, responseHeader(response, 'ETag'));
if (!updated || updated.effectiveScope.level !== 'conversation' || updated.revision <= policy.ifMatch) throw new Error('Policy revision did not advance');
policy = updated; renderAutonomy(); return policy;
}
async function decideApproval(approvalId, choice) {
if (!issuedApprovals.has(approvalId) || !CHOICES.has(choice)) throw new TypeError('Approval decision was not presented here');
const response = await request(`/approvals/${encodeURIComponent(approvalId)}`, {method: 'POST',
headers: {'Idempotency-Key': key(`approval-${choice}`, approvalId)}, body: JSON.stringify({choice})});
const resolved = normalizeApproval(await response.json(), settings.conversationId, new Set(['approved', 'denied']), choice, client.identity);
if (!resolved || resolved.id !== approvalId) throw new Error('Approval result is invalid');
issuedApprovals.delete(approvalId); approvals = approvals.filter((item) => item.id !== approvalId); renderAutonomy(); return resolved;
}
async function refreshApprovals() {
if (!client) return null;
try {
const response = await request('/approvals?status=pending', {method: 'GET'});
const page = normalizeApprovalPage(await response.json(), settings.conversationId);
if (!page) return null;
approvals = page; page.forEach((item) => issuedApprovals.add(item.id));
renderAutonomy(); return page;
} catch (_) { return null; }
}
function startApprovalPolling() {
if (pollTimer !== null) return;
pollTimer = timers().setInterval(() => {
if (typeof settings.canStopModelResponse === 'function' && !settings.canStopModelResponse()) return;
void refreshApprovals();
}, pollMs);
if (pollTimer && typeof pollTimer.unref === 'function') pollTimer.unref();
}
function stopApprovalPolling() {
if (pollTimer === null) return;
timers().clearInterval(pollTimer); pollTimer = null;
}
function mountInlineDock() {
const host = settings.inlineHost || doc.body || null;
if (inlineDock || !host) return;
inlineDock = el(doc, 'section', {class: 'hux-runtime-inline-dock', role: 'region',
'aria-label': 'Approvals needed', 'aria-live': 'polite'});
inlineDock.hidden = true; host.appendChild(inlineDock);
}
function approvalCard(item, choices, extraClass) {
const card = el(doc, 'article', {class: `hux-runtime-approval${extraClass} risk-${item.risk}`});
card.appendChild(el(doc, 'strong', {}, item.summary));
card.appendChild(el(doc, 'p', {}, `${item.capability.replaceAll('_', ' ')} · ${item.risk} risk${item.external ? ' · external' : ''}`));
const actions = el(doc, 'div', {class: 'hux-runtime-actions'});
choices.forEach(([choice, label]) => {
const button = el(doc, 'button', {type: 'button'}, label);
button.addEventListener('click', () => { void decideApproval(item.id, choice).catch(() => card.appendChild(safeError(doc))); });
actions.appendChild(button);
});
card.appendChild(actions); return card;
}
function renderInline() {
if (!inlineDock) return;
inlineDock.replaceChildren();
const pending = (approvals || []).slice().sort((a, b) => b.requestedAt.localeCompare(a.requestedAt));
inlineDock.hidden = pending.length === 0;
if (!pending.length) return;
inlineDock.appendChild(el(doc, 'h2', {class: 'hux-runtime-inline-title'}, 'Approval needed'));
pending.slice(0, 3).forEach((item) => inlineDock.appendChild(approvalCard(item,
[['once', 'Allow once'], ['always', 'Always allow'], ['deny', 'Deny']], ' hux-runtime-inline-card')));
if (pending.length > 3) inlineDock.appendChild(status(doc, `${pending.length - 3} more waiting in the workspace drawer.`));
}
async function stopRun() {
if (typeof settings.stopModelResponse !== 'function') throw new TypeError('No owned model response is bound to this view');
const model = await settings.stopModelResponse();
if (!record(model) || model.accepted !== true) {
stopMessage = 'Model response stop could not be verified; tool/side-effect cancellation unverified.';
renderAutonomy(); return {modelStopped: false, verified: false};
}
stopMessage = UNVERIFIED_STOP; receipt = null; renderAutonomy();
if (!settings.runId) return {modelStopped: true, verified: false};
try {
const body = {conversation_id: settings.conversationId, process_registry_empty: false};
const response = await request(`/runs/${encodeURIComponent(settings.runId)}/stop`, {method: 'POST',
headers: {'Idempotency-Key': key('stop', settings.runId)}, body: JSON.stringify(body)});
receipt = normalizeReceipt(await response.json(), settings.runId, settings.conversationId, client.identity);
const verified = Boolean(receipt && receipt.outcome === 'cancelled' &&
receipt.sideEffects.every((effect) => effect.reverted));
stopMessage = verified ? 'Model response and recorded side effects stopped.' : UNVERIFIED_STOP;
renderAutonomy(); return {modelStopped: true, verified, receipt};
} catch (_) {
stopMessage = UNVERIFIED_STOP; renderAutonomy();
return {modelStopped: true, verified: false};
}
}
function renderAutonomy() {
renderInline();
if (!autonomyRoot) return; autonomyRoot.replaceChildren();
if (!policy || !approvals) { autonomyRoot.appendChild(status(doc, 'Loading autonomy controls…')); return; }
const title = el(doc, 'h2', {class: 'hux-runtime-title'}, 'Autonomy and approvals'); autonomyRoot.appendChild(title);
const picker = el(doc, 'label', {class: 'hux-runtime-field'}, 'Autonomy level');
const select = el(doc, 'select', {'aria-label': 'Autonomy level'});
[['ask_first', 'Ask first'], ['safe', 'Safe actions'], ['autonomous', 'Autonomous']].forEach(([value, label]) => {
const option = el(doc, 'option', {value}, label); option.selected = value === policy.autonomy; select.appendChild(option);
});
picker.appendChild(select); const save = el(doc, 'button', {type: 'button'}, 'Save autonomy');
save.addEventListener('click', () => { void savePolicy(select.value).catch(() => autonomyRoot.appendChild(safeError(doc))); });
picker.appendChild(save); autonomyRoot.appendChild(picker);
const queue = el(doc, 'section', {'aria-labelledby': 'hux-runtime-approvals'});
queue.appendChild(el(doc, 'h3', {id: 'hux-runtime-approvals'}, `Pending approvals (${approvals.length})`));
approvals.forEach((item) => queue.appendChild(approvalCard(item,
[['once', 'Allow once'], ['session', 'Allow this session'], ['always', 'Always allow'], ['deny', 'Deny']], '')));
autonomyRoot.appendChild(queue);
const canStop = typeof settings.canStopModelResponse === 'function' && settings.canStopModelResponse();
if (canStop) { const stop = el(doc, 'button', {type: 'button', class: 'hux-runtime-stop'}, 'Stop model response');
stop.addEventListener('click', () => { void stopRun(); }); autonomyRoot.appendChild(stop); }
if (stopMessage) autonomyRoot.appendChild(status(doc, stopMessage,
stopMessage === 'Model response and recorded side effects stopped.' ? 'status' : 'alert'));
if (receipt) { const box = el(doc, 'section', {class: 'hux-runtime-receipt', role: 'status', 'aria-live': 'polite'});
box.appendChild(el(doc, 'h3', {}, 'Stop receipt')); box.appendChild(el(doc, 'p', {}, `Outcome: ${receipt.outcome.replaceAll('_', ' ')}`));
const list = el(doc, 'ul'); receipt.sideEffects.forEach((effect) => list.appendChild(el(doc, 'li', {}, `${effect.reverted ? 'Reverted' : 'Not reverted'}: ${effect.description}`)));
box.appendChild(list); autonomyRoot.appendChild(box); }
}
async function loadPrivacy() {
const [policyResponse, auditResponse] = await Promise.all([
request('/privacy/policy', {method: 'GET'}), request('/privacy/audit', {method: 'GET'}),
]);
privacyPolicy = normalizePrivacyPolicy(await policyResponse.json(), responseHeader(policyResponse, 'HUX-Audit-Stale'));
audits = normalizeAuditPage(await auditResponse.json());
if (!privacyPolicy || !audits) throw new Error('Invalid privacy response'); renderPrivacy();
}
async function showPrivacyNotice(topic) {
if (noticeShown || noticePending || !TOPICS.has(topic) || !client) return null;
noticePending = true; activeTopic = topic;
try {
const response = await request('/privacy/notices', {method: 'POST', headers: {'Idempotency-Key': key('privacy-notice', settings.conversationId)},
body: JSON.stringify({topic, conversation_id: settings.conversationId})});
privacyNotice = normalizeNotice(await response.json(), settings.conversationId);
if (!privacyNotice) throw new Error('Invalid privacy notice'); noticeShown = true; renderPrivacy(); return privacyNotice;
} catch (error) { activeTopic = null; privacyNotice = null; renderPrivacy(); throw error; }
finally { noticePending = false; }
}
async function privacyChoice(choice) {
if (!privacyNotice || !privacyNotice.controls.includes(choice)) throw new TypeError('Privacy control was not presented here');
if (choice === 'dismiss') { activeTopic = null; privacyNotice = null; privacyMessage = 'Privacy notice dismissed.'; renderPrivacy(); return null; }
if (choice === 'forget_this_conversation') {
const response = await request(`/conversations/${encodeURIComponent(settings.conversationId)}/forget`, {method: 'POST',
headers: {'Idempotency-Key': key('forget', settings.conversationId)}, body: JSON.stringify({})});
const result = normalizeForget(await response.json(), settings.conversationId);
if (!result) throw new Error('Forget result is invalid'); privacyMessage = `Conversation forgotten; ${result.memoryForgotten} memory entries removed.`;
} else {
const response = await request('/privacy/notices', {method: 'POST', headers: {'Idempotency-Key': key(`privacy-${choice}`, settings.conversationId)},
body: JSON.stringify({topic: activeTopic, conversation_id: settings.conversationId, chosen: choice})});
if (!normalizeNotice(await response.json(), settings.conversationId)) throw new Error('Privacy choice is invalid');
if (choice === 'switch_to_private' && typeof settings.onPrivateMode === 'function') await settings.onPrivateMode();
privacyMessage = choice === 'disable_memory_here' ? 'Memory is disabled for this conversation.' : 'Private mode was requested.';
}
activeTopic = null; privacyNotice = null; renderPrivacy(); return true;
}
function renderPrivacy() {
if (!privacyRoot) return; privacyRoot.replaceChildren();
if (!privacyPolicy || !audits) { privacyRoot.appendChild(status(doc, 'Loading privacy controls…')); return; }
privacyRoot.appendChild(el(doc, 'h2', {class: 'hux-runtime-title'}, 'Privacy and retention'));
const summary = el(doc, 'dl', {class: 'hux-runtime-summary', 'aria-label': 'Privacy policy summary'});
[['Sensitive categories', `${privacyPolicy.protectedCount} protected`], ['Context minimization', `${privacyPolicy.shortestDecay}${privacyPolicy.longestDecay} days`],
['Cross-surface sharing', privacyPolicy.crossSurfaceSharing === 'never' ? 'Never' : 'Same owner only'], ['Retention audit', privacyPolicy.auditStale ? 'Overdue' : `Every ${privacyPolicy.auditIntervalDays} days`]].forEach(([term, value]) => {
const row = el(doc, 'div'); row.appendChild(el(doc, 'dt', {}, term)); row.appendChild(el(doc, 'dd', {}, value)); summary.appendChild(row);
});
privacyRoot.appendChild(summary);
privacyRoot.appendChild(el(doc, 'p', {class: 'hux-runtime-minimize'}, 'When the topic changes, prior sensitive details are not repeated.'));
if (privacyNotice) { const notice = el(doc, 'aside', {class: 'hux-runtime-notice', 'aria-labelledby': 'hux-runtime-privacy-notice'});
notice.appendChild(el(doc, 'h3', {id: 'hux-runtime-privacy-notice'}, 'Sensitive-topic protection')); notice.appendChild(el(doc, 'p', {}, privacyNotice.text));
const actions = el(doc, 'div', {class: 'hux-runtime-actions'}); privacyNotice.controls.forEach((choice) => { const button = el(doc, 'button', {type: 'button'}, choice.replaceAll('_', ' '));
button.addEventListener('click', () => { void privacyChoice(choice).catch(() => notice.appendChild(safeError(doc))); }); actions.appendChild(button); }); notice.appendChild(actions); privacyRoot.appendChild(notice); }
const latest = audits[0]; privacyRoot.appendChild(status(doc, latest ? `Last retention audit: ${latest.ranAt}; ${latest.affected} records affected.` : 'No retention audit has been recorded.'));
if (privacyMessage) privacyRoot.appendChild(status(doc, privacyMessage));
}
const autonomyExtension = {id: 'autonomy-controls', flag: 'hux.autonomy', label: 'Autonomy', order: 30, render(context) {
client = assertClient(context.client, 'hux.autonomy'); autonomyRoot = context.container;
mountInlineDock(); renderAutonomy();
const target = autonomyRoot;
void loadAutonomy().catch(() => { if (autonomyRoot === target) target.replaceChildren(safeError(doc)); });
startApprovalPolling();
}};
const privacyExtension = {id: 'privacy-controls', flag: 'hux.privacy', label: 'Privacy', order: 40, render(context) {
client = assertClient(context.client, 'hux.privacy'); privacyRoot = context.container; renderPrivacy();
const target = privacyRoot;
void loadPrivacy().catch(() => { if (privacyRoot === target) target.replaceChildren(safeError(doc)); });
}};
function register(shell) { if (!shell || typeof shell.register !== 'function') throw new TypeError('HUX shell is required');
const removeAutonomy = shell.register(autonomyExtension); const removePrivacy = shell.register(privacyExtension);
return () => { removePrivacy(); removeAutonomy(); }; }
function destroy() {
stopApprovalPolling();
if (inlineDock && inlineDock.parentNode) inlineDock.parentNode.removeChild(inlineDock);
inlineDock = null;
client = null; autonomyRoot = null; privacyRoot = null; policy = null; approvals = [];
receipt = null; privacyPolicy = null; audits = []; activeTopic = null; privacyNotice = null;
stopMessage = ''; issuedApprovals.clear();
}
return Object.freeze({extensions: Object.freeze([autonomyExtension, privacyExtension]), register, destroy,
showPrivacyNotice, clearPrivacyNotice() { activeTopic = null; privacyNotice = null; if (privacyRoot) renderPrivacy(); },
savePolicy, decideApproval, refreshApprovals, stopRun, privacyChoice});
}
function createAutonomyPrivacyRuntime(options) {
const settings = options || {};
const doc = settings.document || (typeof document === 'object' ? document : null);
if (!doc) throw new TypeError('Autonomy/privacy runtime requires a document');
const client = contract.createCanonicalClient(settings);
const shell = shellApi.createShell({client, document: doc,
instanceId: settings.instanceId || 'governance'});
const runtime = createRuntime(settings);
const removers = runtime.extensions.map((extension) => shell.register(extension));
let mounted = false; let destroyed = false;
return Object.freeze({client,
async mount(target) {
if (destroyed) throw new Error('Autonomy/privacy runtime was destroyed');
if (mounted) throw new Error('Autonomy/privacy runtime is already mounted');
mounted = true; shell.mount(target); await client.negotiate();
},
destroy() {
if (destroyed) return;
destroyed = true; shell.destroy();
removers.slice().reverse().forEach((remove) => remove());
runtime.destroy(); mounted = false;
},
});
}
return Object.freeze({GENERIC_NOTICE, UNVERIFIED_STOP, createAutonomyPrivacyRuntime, createRuntime,
normalizeApproval, normalizeApprovalPage, normalizeAuditPage,
normalizeForget, normalizeNotice, normalizePolicy, normalizePrivacyPolicy, normalizeReceipt});
}));