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

321 lines
14 KiB
Python

"""Governance rules behind the HUX contracts.
Pure functions only: state machines for memory, approvals and releases, the
autonomy capability matrix, the friendly-mode catalog and its Switchyard
mapping, the sensitive-topic policy, suggestion gating and the feature-flag
registry. The chat router and the WebUI both consume these rules; keeping them
here means one implementation is tested and the other only serialises it.
"""
from __future__ import annotations
import os
from datetime import datetime, timedelta, timezone
from typing import Any
from hux.contracts import load_flags
EFFORT_ORDER = ("low", "medium", "high", "xhigh")
MEMORY_TRANSITIONS: dict[str, frozenset[str]] = {
"proposed": frozenset({"active", "rejected"}),
"active": frozenset({"expired", "forgotten"}),
"expired": frozenset({"forgotten"}),
"rejected": frozenset(),
"forgotten": frozenset(),
}
APPROVAL_TRANSITIONS: dict[str, frozenset[str]] = {
"pending": frozenset({"approved", "denied", "expired", "cancelled"}),
"approved": frozenset(),
"denied": frozenset(),
"expired": frozenset(),
"cancelled": frozenset(),
}
RELEASE_ORDER = ("reviewed", "merged", "built", "verified", "deployed", "converged", "live_verified")
RELEASE_EVIDENCE: dict[str, tuple[str, ...]] = {
"merged": ("merge_commit",),
"built": ("ci_build_url", "image_digest"),
"verified": ("harbor_digest",),
"deployed": ("flux_revision",),
"converged": ("pod_digest",),
"live_verified": ("health_check",),
"rolled_back": ("rollback_target",),
}
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", "spend_tokens"})
_MUTATING = frozenset({"write_files", "shell", "send_message", "memory_write", "artifact_write", "delegate"})
_NETWORK = frozenset({"network", "web_search"})
_ALWAYS_ASK = frozenset({"deploy", "external_side_effect"})
MODE_CATALOG: dict[str, dict[str, Any]] = {
"fast": {
"label": "Fast",
"intent": "Quick answers and small edits. Lowest latency wins; depth is not expected.",
"providers": ["codex", "claude"], "local_only": False,
"effort": {"min": "low", "max": "medium"},
"tools": {"web": "allowed", "shell": "denied", "artifacts": "allowed", "delegate": "denied"},
"memory": {"read": True, "write": True}, "citations_required": False, "retention": "default",
"route_id": "atlas/auto/fast",
},
"thoughtful": {
"label": "Thoughtful",
"intent": "Careful reasoning on one problem. Takes longer, checks its own work, may run tools.",
"providers": ["codex", "claude"], "local_only": False,
"effort": {"min": "medium", "max": "xhigh"},
"tools": {"web": "allowed", "shell": "allowed", "artifacts": "allowed", "delegate": "allowed"},
"memory": {"read": True, "write": True}, "citations_required": False, "retention": "default",
"route_id": "atlas/auto/deep",
},
"research": {
"label": "Research",
"intent": "Find, read and cite sources. Every factual claim carries a citation with a support verdict; assumptions and open questions stay visible.",
"providers": ["codex", "claude"], "local_only": False,
"effort": {"min": "high", "max": "xhigh"},
"tools": {"web": "required", "shell": "denied", "artifacts": "allowed", "delegate": "allowed"},
"memory": {"read": True, "write": True}, "citations_required": True, "retention": "default",
"route_id": "atlas/auto/deep",
},
"create": {
"label": "Create",
"intent": "Produce and iterate on a durable artifact: documents, code, pages, images. Versions are kept and diffable.",
"providers": ["codex", "claude"], "local_only": False,
"effort": {"min": "medium", "max": "high"},
"tools": {"web": "allowed", "shell": "allowed", "artifacts": "encouraged", "delegate": "allowed"},
"memory": {"read": True, "write": True}, "citations_required": False, "retention": "default",
"route_id": "atlas/auto/balanced",
},
"private": {
"label": "Private",
"intent": "Nothing leaves the cluster. Local model only, no memory writes, no web, conversation not retained beyond the session.",
"providers": ["local"], "local_only": True,
"effort": {"min": "low", "max": "medium"},
"tools": {"web": "denied", "shell": "denied", "artifacts": "allowed", "delegate": "denied"},
"memory": {"read": False, "write": False}, "citations_required": False, "retention": "ephemeral",
"route_id": "atlas/manual/local/qwen-14b",
},
}
PRIVACY_TOPICS: dict[str, dict[str, Any]] = {
"health": {"sensitivity": "sensitive", "memory_write": "ask", "decay_days": 30},
"finance": {"sensitivity": "sensitive", "memory_write": "ask", "decay_days": 30},
"legal": {"sensitivity": "sensitive", "memory_write": "ask", "decay_days": 30},
"relationships": {"sensitivity": "sensitive", "memory_write": "ask", "decay_days": 14},
"credentials": {"sensitivity": "restricted", "memory_write": "deny", "decay_days": 1},
"minors": {"sensitivity": "restricted", "memory_write": "deny", "decay_days": 7},
"location": {"sensitivity": "restricted", "memory_write": "deny", "decay_days": 7},
"biometric": {"sensitivity": "restricted", "memory_write": "deny", "decay_days": 1},
}
_NOTICE = "This looks like a {topic} topic. It stays in this conversation and is not remembered unless you say so."
def transition_allowed(table: dict[str, frozenset[str]], current: str, target: str) -> bool:
"""True when ``current -> target`` is a legal move in ``table``."""
return target in table.get(current, frozenset())
def memory_policy_violations(entry: dict[str, Any]) -> list[str]:
"""Rules the schema cannot express: sensitivity drives approval, TTL and topic."""
problems: list[str] = []
sensitivity = entry.get("sensitivity")
topic = entry.get("topic", "general")
if sensitivity == "restricted" and entry.get("status") in {"proposed", "active"}:
problems.append("restricted content may not be remembered")
stored = entry.get("status") not in {"rejected", "no_store", "forgotten"}
if sensitivity == "sensitive" and stored and entry.get("approval_mode") != "ask":
problems.append("sensitive memory requires approval_mode=ask")
if sensitivity == "sensitive" and stored and entry.get("ttl", {}).get("policy") == "never":
problems.append("sensitive memory must expire or decay")
if topic in PRIVACY_TOPICS and PRIVACY_TOPICS[topic]["memory_write"] == "deny" and stored:
problems.append(f"topic {topic} may not be written to memory")
ttl = entry.get("ttl", {})
if ttl.get("policy") == "expires_at" and "expires_at" not in ttl:
problems.append("ttl.policy=expires_at requires expires_at")
if ttl.get("policy") == "decay" and "decay_days" not in ttl:
problems.append("ttl.policy=decay requires decay_days")
if entry.get("status") == "forgotten" and entry.get("content"):
problems.append("forgotten entries must drop their content")
return problems
def default_capability_matrix() -> dict[str, dict[str, str]]:
"""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] = {}
for capability in CAPABILITIES:
if capability in _ALWAYS_ASK:
row[capability] = "ask"
elif capability in _READ_ONLY:
row[capability] = "allow"
else:
# _NETWORK and _MUTATING park with a visible prompt below autonomous.
row[capability] = "ask" if level != "autonomous" else "allow"
matrix[level] = row
return matrix
def effective_decision(policy: dict[str, Any], capability: str, now: datetime | None = None) -> str:
"""Resolve one capability under a policy: explicit unexpired grant beats the matrix, deny beats all."""
now = now or datetime.now(timezone.utc)
matrix = default_capability_matrix()[policy["autonomy"]]
decision = matrix[capability]
for grant in policy.get("grants", []):
if grant["capability"] != capability:
continue
expires = grant.get("expires_at")
if expires and datetime.fromisoformat(expires.replace("Z", "+00:00")) <= now:
continue
if grant["decision"] == "deny":
return "deny"
decision = grant["decision"]
if capability in _ALWAYS_ASK and decision == "allow":
return "ask"
return decision
def release_transition_problems(record: dict[str, Any], target: str) -> list[str]:
"""Why ``record`` may not move to ``target``. Empty means it may."""
current = record["state"]
problems: list[str] = []
if target == "rolled_back":
if current in {"reviewed", "merged"}:
problems.append("nothing to roll back before an image exists")
elif current == "rolled_back":
problems.append("a rolled back release is terminal; open a new release record")
elif RELEASE_ORDER.index(target) != RELEASE_ORDER.index(current) + 1:
problems.append(f"{current} -> {target} skips or reverses the release order")
evidence = record.get("evidence", {})
for key in RELEASE_EVIDENCE.get(target, ()):
if key not in evidence:
problems.append(f"{target} requires evidence.{key}")
if target == "verified" and evidence.get("harbor_digest") != evidence.get("image_digest"):
problems.append("harbor_digest must equal image_digest")
if target == "converged" and evidence.get("pod_digest") != evidence.get("image_digest"):
problems.append("pod_digest must equal image_digest")
if target == "live_verified" and evidence.get("health_check", {}).get("status") != "pass":
problems.append("health_check must pass")
return problems
def mode_contract(mode: str, override_route_id: str | None = None) -> dict[str, Any]:
"""Serialise one friendly mode as a ``hux.mode.v1`` record."""
spec = MODE_CATALOG[mode]
switchyard: dict[str, Any] = {"route_id": spec["route_id"]}
if override_route_id:
if spec["local_only"] and "/local/" not in override_route_id:
raise ValueError("private mode cannot be overridden to a hosted route")
switchyard["override_route_id"] = override_route_id
return {
"schema": "hux.mode.v1",
"mode": mode,
"label": spec["label"],
"intent": spec["intent"],
"constraints": {
key: spec[key]
for key in ("providers", "local_only", "effort", "tools", "memory", "citations_required", "retention")
},
"switchyard": switchyard,
}
def effort_within(mode: str, effort: str) -> bool:
"""True when ``effort`` sits inside the mode's allowed band."""
band = MODE_CATALOG[mode]["effort"]
index = EFFORT_ORDER.index(effort)
return EFFORT_ORDER.index(band["min"]) <= index <= EFFORT_ORDER.index(band["max"])
def privacy_policy(version: int = 1) -> dict[str, Any]:
"""Serialise the sensitive-topic policy as a ``hux.privacy_policy.v1`` record."""
return {
"schema": "hux.privacy_policy.v1",
"version": version,
"topics": [
{"topic": topic, **rule, "notice": _NOTICE.format(topic=topic)}
for topic, rule in PRIVACY_TOPICS.items()
],
"topic_scoping": {"scope_to_conversation": True, "cross_surface_sharing": "never"},
"retention_audit": {
"interval_days": 1,
"actions": ["expire_memory", "decay_topic_context", "purge_forgotten_content", "report"],
},
}
def suggestion_allowed(suggestion: dict[str, Any], state: dict[str, Any] | None, now: datetime) -> bool:
"""Suppression state wins: never-again, dismissed, exhausted shows, or cooling down all block."""
if state is None:
return True
if state.get("never_again") or state.get("dismissed_at") or state.get("acted_at"):
return False
rules = suggestion["suppression"]
if state.get("shows", 0) >= rules["max_shows"]:
return False
last = state.get("last_shown_at")
if last:
shown = datetime.fromisoformat(last.replace("Z", "+00:00"))
if now - shown < timedelta(seconds=rules["cooldown_seconds"]):
return False
return True
def flag_registry() -> dict[str, dict[str, Any]]:
"""Card id -> registry entry."""
return {card["card"]: card for card in load_flags()["cards"]}
def enabled_flags(environ: dict[str, str] | None = None) -> set[str]:
"""Flags switched on through the HUX_FLAGS comma list; unknown names are ignored."""
environ = os.environ if environ is None else environ
known = {card["flag"] for card in flag_registry().values()}
raw = environ.get(load_flags()["env_var"], "")
return {item.strip() for item in raw.split(",") if item.strip() in known}
def flag_enabled(flag: str, environ: dict[str, str] | None = None) -> bool:
"""A flag counts only when it and every card it depends on are enabled."""
registry = flag_registry()
by_flag = {card["flag"]: card for card in registry.values()}
if flag not in by_flag:
return False
on = enabled_flags(environ)
pending = [by_flag[flag]]
while pending:
card = pending.pop()
if card["flag"] not in on:
return False
pending.extend(registry[dep] for dep in card["depends_on"])
return True
def dependency_order() -> list[str]:
"""Cards in an order that satisfies depends_on; raises on cycles."""
registry = flag_registry()
done: list[str] = []
visiting: set[str] = set()
def visit(card_id: str) -> None:
if card_id in done:
return
if card_id in visiting:
raise ValueError(f"dependency cycle through {card_id}")
visiting.add(card_id)
for dep in registry[card_id]["depends_on"]:
visit(dep)
visiting.discard(card_id)
done.append(card_id)
for card_id in sorted(registry):
visit(card_id)
return done