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
This commit is contained in:
jenkins 2026-08-24 13:53:00 -03:00
parent d1225bcab4
commit 3bca7b7465
8 changed files with 257 additions and 25 deletions

View File

@ -46,9 +46,9 @@ CAPABILITIES = (
"read_files", "write_files", "shell", "network", "web_search", "send_message",
"memory_write", "artifact_write", "spend_tokens", "delegate", "deploy", "external_side_effect",
)
_READ_ONLY = frozenset({"read_files", "web_search", "spend_tokens"})
_READ_ONLY = frozenset({"read_files", "spend_tokens"})
_MUTATING = frozenset({"write_files", "shell", "send_message", "memory_write", "artifact_write", "delegate"})
_EXTERNAL = frozenset({"network", "deploy"})
_NETWORK = frozenset({"network", "web_search"})
_ALWAYS_ASK = frozenset({"deploy", "external_side_effect"})
MODE_CATALOG: dict[str, dict[str, Any]] = {
@ -142,7 +142,13 @@ def memory_policy_violations(entry: dict[str, Any]) -> list[str]:
def default_capability_matrix() -> dict[str, dict[str, str]]:
"""Autonomy level -> capability -> allow|ask|deny. Deploy and external side effects always ask."""
"""Autonomy level -> capability -> allow|ask. Deploy and external side effects always ask (SO-39).
Network access (``network``/``web_search``) asks below ``autonomous`` instead of
denying: for a chat assistant whose core value includes browsing, the right
default is a visible approval prompt, never a silent block. No default is
``deny``; denials only come from explicit grants or private mode.
"""
matrix: dict[str, dict[str, str]] = {}
for level in ("ask_first", "safe", "autonomous"):
row: dict[str, str] = {}
@ -151,10 +157,9 @@ def default_capability_matrix() -> dict[str, dict[str, str]]:
row[capability] = "ask"
elif capability in _READ_ONLY:
row[capability] = "allow"
elif capability in _MUTATING:
else:
# _NETWORK and _MUTATING park with a visible prompt below autonomous.
row[capability] = "ask" if level != "autonomous" else "allow"
elif capability in _EXTERNAL:
row[capability] = {"ask_first": "ask", "safe": "deny", "autonomous": "allow"}[level]
matrix[level] = row
return matrix

View File

@ -117,6 +117,48 @@
border-color: color-mix(in srgb, #22d3ee 30%, currentColor);
}
/* Inline approval prompts: a fixed dock near the composer so parked tool
calls surface in the chat flow instead of hiding in the drawer. */
.hux-runtime-inline-dock {
bottom: 6.5rem;
display: flex;
flex-direction: column;
gap: 0.45rem;
left: 0;
margin: 0 auto;
max-height: 50vh;
max-width: 44rem;
overflow-y: auto;
padding: 0 0.75rem;
pointer-events: none;
position: fixed;
right: 0;
z-index: 60;
}
.hux-runtime-inline-dock[hidden] {
display: none;
}
.hux-runtime-inline-title {
align-self: flex-start;
background: Canvas;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 999px;
color: CanvasText;
font-size: 0.625rem;
margin: 0;
padding: 0.15rem 0.6rem;
}
.hux-runtime-approval.hux-runtime-inline-card {
background: Canvas;
box-shadow: 0 0.5rem 1.5rem rgb(0 0 0 / 0.25);
color: CanvasText;
margin-top: 0;
pointer-events: auto;
}
@container (max-width: 34rem) {
.hux-runtime-summary {
grid-template-columns: 1fr;

View File

@ -101,7 +101,8 @@
!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, expiresAt: value.expires_at};
risk: request.risk, external: request.external, status: value.status,
requestedAt: value.requested_at, expiresAt: value.expires_at};
}
function normalizeApprovalPage(raw, conversationId) {
@ -203,7 +204,10 @@
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 ||
@ -252,6 +256,64 @@
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();
@ -277,6 +339,7 @@
}
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);
@ -290,17 +353,8 @@
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) => {
const card = el(doc, 'article', {class: `hux-runtime-approval 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'});
[['once', 'Allow once'], ['session', 'Allow this session'], ['always', 'Always allow'], ['deny', 'Deny']].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); queue.appendChild(card);
});
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');
@ -372,9 +426,11 @@
}
const autonomyExtension = {id: 'autonomy-controls', flag: 'hux.autonomy', label: 'Autonomy', order: 30, render(context) {
client = assertClient(context.client, 'hux.autonomy'); autonomyRoot = context.container; renderAutonomy();
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();
@ -385,13 +441,16 @@
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, stopRun, privacyChoice});
savePolicy, decideApproval, refreshApprovals, stopRun, privacyChoice});
}
function createAutonomyPrivacyRuntime(options) {

View File

@ -160,16 +160,24 @@ def test_capability_matrix_shape():
assert row["external_side_effect"] == "ask", level
assert row["read_files"] == "allow"
assert matrix["ask_first"]["shell"] == "ask"
assert matrix["safe"]["network"] == "deny"
assert matrix["safe"]["network"] == "ask", "browsing under safe parks a visible prompt, never a silent deny"
assert matrix["safe"]["web_search"] == "ask"
assert matrix["ask_first"]["network"] == "ask" and matrix["ask_first"]["web_search"] == "ask"
assert matrix["autonomous"]["shell"] == "allow"
assert matrix["autonomous"]["network"] == "allow"
assert matrix["autonomous"]["web_search"] == "allow"
assert "deny" not in {value for row in matrix.values() for value in row.values()}, "no default silently denies"
assert set(policy.CAPABILITIES) == set(SCHEMAS["permission.schema.json"]["$defs"]["capability"]["enum"])
def test_effective_decision_honours_grants_expiry_and_deny():
base = copy.deepcopy(EXAMPLES["policy"])
assert policy.effective_decision(base, "network", NOW) == "ask"
assert policy.effective_decision(base, "network", datetime(2026, 9, 1, tzinfo=timezone.utc)) == "deny"
base["grants"][0]["decision"] = "allow"
assert policy.effective_decision(base, "network", NOW) == "allow"
assert policy.effective_decision(base, "network", datetime(2026, 9, 1, tzinfo=timezone.utc)) == "ask", (
"an expired allow grant falls back to the safe matrix, which asks for network"
)
base["grants"] = [{"capability": "shell", "decision": "allow"}, {"capability": "shell", "decision": "deny"}]
assert policy.effective_decision(base, "shell", NOW) == "deny"
base["grants"] = [{"capability": "deploy", "decision": "allow"}]

View File

@ -98,8 +98,10 @@ def test_safe_policy_auto_allows_reads_and_queues_mutations(router, frozen, even
assert status == 201 and valid(body)["status"] == "pending" and "decision" not in body
assert body["expires_at"] == policy.iso(T0 + timedelta(hours=24))
status, body = call(router, "POST", "/hux/v1/approvals", request_body("network"), WORKER)
assert status == 201 and valid(body)["status"] == "denied" and body["decision"]["choice"] == "deny"
assert [e["kind"] for e in events] == ["approval.resolved", "approval.requested", "approval.resolved"]
assert status == 201 and valid(body)["status"] == "pending" and "decision" not in body, "safe parks network visibly"
status, body = call(router, "POST", "/hux/v1/approvals", request_body("web_search"), WORKER)
assert status == 201 and valid(body)["status"] == "pending", "safe parks web_search visibly"
assert [e["kind"] for e in events] == ["approval.resolved", "approval.requested", "approval.requested", "approval.requested"]
assert events[0]["evidence"] == [{"kind": "approval", "id": events[0]["evidence"][0]["id"]}]

View File

@ -107,7 +107,29 @@ def test_grants_get_server_set_expiry_and_actor(router, frozen):
assert policy.resolve_request(body, "shell", False) == "ask", "expired grant falls back to the matrix"
assert policy.resolve_request(body, "network", False) == "allow"
frozen["now"] = T0 + timedelta(days=31)
assert policy.resolve_request(body, "network", False) == "deny", "safe denies network once the grant lapses"
assert policy.resolve_request(body, "network", False) == "ask", "safe asks for network once the grant lapses"
def test_safe_and_ask_first_park_network_with_a_visible_prompt(router):
"""The chat surface browses: network and web_search resolve to ask, never a silent deny."""
matrix = rules.default_capability_matrix()
for level in ("ask_first", "safe"):
assert matrix[level]["network"] == "ask" and matrix[level]["web_search"] == "ask", level
assert matrix["autonomous"]["network"] == "allow" and matrix["autonomous"]["web_search"] == "allow"
assert "deny" not in {value for row in matrix.values() for value in row.values()}
status, body, _ = call(router, "GET", "/hux/v1/policy")
assert status == 200 and body["autonomy"] == "safe"
for capability in ("network", "web_search"):
assert policy.resolve_request(body, capability, external=False) == "ask"
assert policy.resolve_request(body, capability, external=True) == "ask"
# SO-39 stays intact: external effects never auto-allow, even under autonomous.
status, autonomous, _ = call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": "autonomous"})
assert status == 200
assert policy.resolve_request(autonomous, "network", external=False) == "allow"
assert policy.resolve_request(autonomous, "network", external=True) == "ask"
assert policy.resolve_request(autonomous, "web_search", external=True) == "ask"
for always_ask in ("deploy", "external_side_effect"):
assert policy.resolve_request(autonomous, always_ask, external=False) == "ask"
def test_put_honours_if_match_and_audits_unconditional_writes(router, tmp_path):

View File

@ -39,6 +39,8 @@ def test_runtime_pair_is_scoped_dependency_free_and_inert():
assert 'credentials: \'same-origin\'' in js and "cache: 'no-store'" in js
assert "'If-Match'" in js and "'Idempotency-Key'" in js
assert "Stop receipt" in js and "GENERIC_NOTICE" in js
assert "hux-runtime-inline-dock" in js and "hux-runtime-inline-dock" in css
assert "'aria-live': 'polite'" in js and "Approvals needed" in js
assert ".hux-runtime" in css and "body" not in css and ":root" not in css
assert "focus-visible" in css and "prefers-reduced-motion" in css
assert len(js.splitlines()) < 500 and len(css.splitlines()) < 500
@ -68,5 +70,6 @@ def test_runtime_uses_canonical_backend_routes_and_explicit_safety_language():
assert route in js
assert "Pending approvals" in js
assert "Allow once" in js and "Always allow" in js and "Deny" in js
assert "Approval needed" in js and "more waiting in the workspace drawer" in js
assert "prior sensitive details are not repeated" in js
assert "process_registry_empty: false" in js

View File

@ -497,3 +497,94 @@ test('event handlers contain failed mutations without claiming success', async (
container: plainRoot}); await ticks();
assert.equal(allText(plainRoot).includes('external'), false);
});
function inlineItems(count) {
return Array.from({length: count}, (_, index) => approval({id: `apr_inline000${index + 1}`,
requested_at: `2026-08-24T10:0${index + 1}:00Z`,
request: {...approval().request, summary: `req ${index + 1}`}}));
}
test('inline approval prompts stack newest first near the chat flow and decide in place', async () => {
const inlineHost = new FakeNode('main');
const h = harness([response(200, policy(), {ETag: '1'}), response(200, approvalPage(inlineItems(4))),
response(200, approval({id: 'apr_inline0004', requested_at: '2026-08-24T10:04:00Z',
request: {...approval().request, summary: 'req 4'}, status: 'approved',
decision: {choice: 'always', by: {type: 'user', id: IDENTITY.userRef}, at: STAMP}})),
response(200, policy(), {ETag: '1'}), response(200, approvalPage([]))]);
const runtime = api.createRuntime({document, conversationId: CONVERSATION, fetcher: h.fetcher, inlineHost});
const root = new FakeNode('section'); runtime.extensions[0].render({client: h.client, container: root}); await ticks();
const dock = inlineHost.children[0];
assert.equal(dock.attributes.role, 'region');
assert.equal(dock.attributes['aria-live'], 'polite');
assert.equal(dock.attributes['aria-label'], 'Approvals needed');
assert.equal(dock.hidden, false);
const cards = dock.children.filter((node) => node.tagName === 'ARTICLE');
assert.equal(cards.length, 3, 'at most three prompts are visible');
assert.deepEqual(cards.map((card) => card.children[0].textContent), ['req 4', 'req 3', 'req 2']);
assert.match(allText(dock), /1 more waiting in the workspace drawer/);
assert(findText(cards[0], 'Allow once') && findText(cards[0], 'Always allow') && findText(cards[0], 'Deny'));
assert.equal(findText(cards[0], 'Allow this session'), undefined, 'inline offers exactly three choices');
findText(cards[0], 'Always allow').click(); await ticks();
assert.match(h.calls[2].init.headers['Idempotency-Key'], /^hux:approval-always:/);
assert.deepEqual(JSON.parse(h.calls[2].init.body), {choice: 'always'});
const after = inlineHost.children[0].children.filter((node) => node.tagName === 'ARTICLE');
assert.deepEqual(after.map((card) => card.children[0].textContent), ['req 3', 'req 2', 'req 1']);
assert.equal(allText(inlineHost).includes('more waiting'), false);
assert.match(allText(root), /Pending approvals \(3\)/, 'drawer stays in sync');
runtime.extensions[0].render({client: h.client, container: root}); await ticks();
assert.equal(inlineHost.children.length, 1, 'a re-render never mounts a second dock');
runtime.destroy(); runtime.destroy();
assert.equal(inlineHost.children.length, 0, 'destroy removes the inline dock');
});
test('parked approvals poll only while a turn is active and stop with the runtime', async () => {
let active = true; let tick = null; let cleared = 0;
const timersApi = {setInterval(callback, ms) { tick = callback; assert.equal(ms, 4000); return 0; },
clearInterval(handle) { assert.equal(handle, 0); cleared += 1; }};
const inlineHost = new FakeNode('main');
const h = harness([response(200, policy(), {ETag: '1'}), response(200, approvalPage([])),
response(200, approvalPage([approval()])), response(500, {}),
response(200, {items: 'nope', next: null})]);
const runtime = api.createRuntime({document, conversationId: CONVERSATION, fetcher: h.fetcher,
inlineHost, timers: timersApi, canStopModelResponse: () => active});
const root = new FakeNode('section'); runtime.extensions[0].render({client: h.client, container: root}); await ticks();
assert.equal(inlineHost.children[0].hidden, true, 'no pending approvals keeps the dock hidden');
active = false; tick(); await ticks();
assert.equal(h.calls.length, 2, 'no polling without an active turn');
active = true; tick(); await ticks();
assert.equal(h.calls.length, 3);
assert.equal(inlineHost.children[0].hidden, false, 'a parked approval surfaces within one poll');
assert.match(allText(inlineHost), /Send the finished report/);
tick(); await ticks();
tick(); await ticks();
assert.match(allText(inlineHost), /Send the finished report/, 'poll failures keep the last prompt');
runtime.destroy(); runtime.destroy();
assert.equal(cleared, 1);
assert.equal(await runtime.refreshApprovals(), null, 'a destroyed runtime never fetches');
});
test('inline dock defaults to the document element root and tolerates a missing approvals page', async () => {
const bodyDoc = {createElement: (tag) => new FakeNode(tag), body: new FakeNode('div')};
const fallback = api.createRuntime({document: bodyDoc, conversationId: CONVERSATION,
approvalPollMs: 3600000, fetcher: async () => response(500, {})});
fallback.extensions[0].render({client: harness([]).client, container: new FakeNode('section')}); await ticks();
assert.equal(bodyDoc.body.children[0].attributes.class, 'hux-runtime-inline-dock');
assert.equal(bodyDoc.body.children[0].hidden, true);
fallback.destroy();
assert.equal(bodyDoc.body.children.length, 0);
let tick = null;
const timersApi = {setInterval(callback) { tick = callback; return 9; }, clearInterval() {}};
const inlineHost = new FakeNode('main');
const bad = harness([response(200, policy(), {ETag: '1'}), response(200, {items: 'nope', next: null}),
response(200, policy({autonomy: 'ask_first', revision: 2}), {ETag: '2'})]);
const runtime = api.createRuntime({document, conversationId: CONVERSATION, fetcher: bad.fetcher,
inlineHost, timers: timersApi});
const root = new FakeNode('section'); runtime.extensions[0].render({client: bad.client, container: root}); await ticks();
assert.match(allText(root), /temporarily unavailable/);
await runtime.savePolicy('ask_first');
assert.equal(inlineHost.children[0].hidden, true, 'no approvals page renders no inline prompt');
tick(); await ticks();
assert.equal(bad.responses.length, 0);
runtime.destroy();
});