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
485 lines
34 KiB
JavaScript
485 lines
34 KiB
JavaScript
/* 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});
|
||
}));
|