HUX-06/HUX-10: a conversation whose stored friendly mode is private now has network, web_search, send_message, shell and delegate refused by both the approval resolver and the pre-side-effect gate, regardless of autonomy policy — matching the mode catalog's tool contract. Memory writes were already refused by the privacy state. Missing or unbound conversations keep the existing matrix behaviour. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
429 lines
20 KiB
Python
429 lines
20 KiB
Python
"""HUX-05 autonomy: policy documents per scope and the approval queue.
|
|
|
|
A policy fixes the autonomy level, explicit grants and budgets for a scope
|
|
(global, project or conversation). ``rules.effective_decision`` is the only
|
|
resolver: an approval request resolves to allow, ask or deny against the most
|
|
specific policy, and every external side effect asks regardless. Budgets,
|
|
the pre-side-effect gate and cancellation receipts live in ``hux.budgets``;
|
|
this module registers their routes so the family stays one card.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any
|
|
|
|
from hux import contracts, rules
|
|
from hux.errors import BudgetExhausted, Conflict, Forbidden, Invalid
|
|
from hux.http import Request, Response, Router, page
|
|
from hux.identity import Identity
|
|
from hux.store import TenantStore, check_id, new_id
|
|
|
|
CARD = "HUX-05"
|
|
FAMILY = "policy"
|
|
APPROVALS = "approvals"
|
|
SCOPES = ("global", "project", "conversation")
|
|
APPROVAL_TTL = timedelta(hours=24)
|
|
ALWAYS_TTL = timedelta(days=30)
|
|
SESSION_TTL = timedelta(hours=24)
|
|
HUMAN_SURFACES = frozenset({"chat", "telegram", "voice"})
|
|
HUMAN_TRUSTS = frozenset({"router", "relay"})
|
|
DEFAULT_BUDGETS = {
|
|
"tokens_per_run": 200000, "tool_calls_per_run": 40, "wall_clock_seconds": 900,
|
|
"delegations_per_run": 4, "spend_units": 50, "subagents_per_run": 2,
|
|
}
|
|
SCHEMAS = contracts.load_all()
|
|
|
|
|
|
def clock() -> datetime:
|
|
"""Current UTC time; tests replace this to move approvals past expiry."""
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def now() -> datetime:
|
|
"""Indirection so monkeypatching ``clock`` reaches every module."""
|
|
return clock()
|
|
|
|
|
|
def iso(when: datetime) -> str:
|
|
"""RFC 3339 second-precision UTC string."""
|
|
return when.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
|
def parse(stamp: str) -> datetime:
|
|
"""Inverse of ``iso``."""
|
|
return datetime.fromisoformat(stamp.replace("Z", "+00:00"))
|
|
|
|
|
|
def run_key(run_id: str) -> str:
|
|
"""Stable hex handle for a free-form run id so it can name a document."""
|
|
return hashlib.sha256(run_id.encode()).hexdigest()[:32]
|
|
|
|
|
|
def is_human(identity: Identity) -> bool:
|
|
"""True when a person is behind the call: router/relay trust on a chat, telegram or voice surface."""
|
|
return identity.trust in HUMAN_TRUSTS and identity.surface in HUMAN_SURFACES
|
|
|
|
|
|
def require_human(identity: Identity, what: str) -> None:
|
|
"""Forbidden unless a human actor is behind the call (F1: policy writes and allow grants)."""
|
|
if not is_human(identity):
|
|
raise Forbidden(f"{what} only from a human surface")
|
|
|
|
|
|
def require_worker(identity: Identity, what: str) -> None:
|
|
"""Forbidden unless the separately keyed Worker gateway is making the call."""
|
|
if identity.trust != "worker" or identity.surface != "worker":
|
|
raise Forbidden(f"{what} only from worker trust")
|
|
|
|
|
|
def actor_for(identity: Identity) -> dict[str, str]:
|
|
"""The actor a record attributes to this caller: humans are users, hops are system."""
|
|
if is_human(identity):
|
|
return {"type": "user", "id": identity.subject}
|
|
return {"type": "system", "id": identity.surface}
|
|
|
|
|
|
def provenance(identity: Identity) -> dict[str, Any]:
|
|
"""Server-set provenance; nothing here comes from the body."""
|
|
return {"surface": identity.surface, "actor": actor_for(identity), "recorded_at": iso(now())}
|
|
|
|
|
|
def public(record: dict[str, Any], revisioned: bool = False) -> dict[str, Any]:
|
|
"""Strip store-internal fields before a record leaves the service."""
|
|
return {k: v for k, v in record.items() if not k.startswith("_") and (revisioned or k != "revision")}
|
|
|
|
|
|
def emit(store: TenantStore, identity: Identity, conversation_id: str | None, kind: str, summary: str, **extra: Any) -> None:
|
|
"""Record an activity event through the events lane when it is present."""
|
|
if not conversation_id:
|
|
return
|
|
try:
|
|
from hux import events
|
|
except ModuleNotFoundError:
|
|
return
|
|
events.emit(store, identity, conversation_id, kind, summary, **extra)
|
|
|
|
|
|
def checked(record: dict[str, Any]) -> dict[str, Any]:
|
|
"""Raise Invalid unless ``record`` satisfies its contract; internal fields are ignored."""
|
|
if record["schema"] == "hux.policy.v1":
|
|
candidate = public({"revision": 1, **record}, revisioned=True)
|
|
else:
|
|
candidate = public(record)
|
|
problems = contracts.validate_record(candidate, SCHEMAS)
|
|
if problems:
|
|
raise Invalid("record fails contract", problems)
|
|
return record
|
|
|
|
|
|
def body_dict(request: Request) -> dict[str, Any]:
|
|
"""The JSON object body or Invalid."""
|
|
if not isinstance(request.body, dict):
|
|
raise Invalid("body must be a JSON object")
|
|
return request.body
|
|
|
|
|
|
# -- policy documents --------------------------------------------------------
|
|
|
|
def policy_id(level: str, scope_id: str | None) -> str:
|
|
"""Document id for a scope; global has exactly one."""
|
|
if level not in SCOPES:
|
|
raise Invalid("scope must be global, project or conversation")
|
|
if level == "global":
|
|
return "pol_global"
|
|
if scope_id is None or len(scope_id) > 60:
|
|
raise Invalid("scope_id required and at most 60 characters for project and conversation scopes")
|
|
return f"pol_{level}.{check_id(scope_id)}"
|
|
|
|
|
|
def default_policy(identity: Identity) -> dict[str, Any]:
|
|
"""The ``safe`` policy every tenant starts with."""
|
|
return {
|
|
"schema": "hux.policy.v1", "id": "pol_global", "owner": identity.subject, "scope": {"level": "global"},
|
|
"autonomy": "safe", "grants": [], "budgets": dict(DEFAULT_BUDGETS),
|
|
"provenance": provenance(identity), "updated_at": iso(now()), "_budget_epoch": new_id("bep"),
|
|
}
|
|
|
|
|
|
def global_policy(store: TenantStore, identity: Identity) -> dict[str, Any]:
|
|
"""Read the global policy, creating the default on first touch."""
|
|
with store.lock(FAMILY):
|
|
if store.exists(FAMILY, "pol_global"):
|
|
return store.get(FAMILY, "pol_global")
|
|
return store.put(FAMILY, checked({**default_policy(identity), "revision": 1}))
|
|
|
|
|
|
def effective_policy(store: TenantStore, identity: Identity, level: str, scope_id: str | None) -> dict[str, Any]:
|
|
"""Most specific policy along conversation -> project -> global."""
|
|
chain: list[tuple[str, str | None]] = [(level, scope_id)]
|
|
if level == "conversation":
|
|
from hux import organization # lazy: organization never imports policy, but keep import order free
|
|
parent = organization.project_of(store, scope_id)
|
|
if parent:
|
|
chain.append(("project", parent))
|
|
for lvl, sid in chain:
|
|
if lvl != "global" and store.exists(FAMILY, policy_id(lvl, sid)):
|
|
return store.get(FAMILY, policy_id(lvl, sid))
|
|
return global_policy(store, identity)
|
|
|
|
|
|
def normalise_grants(grants: Any, identity: Identity) -> list[dict[str, Any]]:
|
|
"""Validate grants and pin every one to a server-set expiry of at most 30 days (SO-38)."""
|
|
if not isinstance(grants, list):
|
|
raise Invalid("grants must be a list")
|
|
ceiling = now() + ALWAYS_TTL
|
|
out = []
|
|
for grant in grants:
|
|
if not isinstance(grant, dict) or grant.get("capability") not in rules.CAPABILITIES:
|
|
raise Invalid("grant needs a known capability")
|
|
if grant.get("decision") not in ("allow", "ask", "deny"):
|
|
raise Invalid("grant decision must be allow, ask or deny")
|
|
if grant["decision"] == "allow":
|
|
require_human(identity, "allow grants")
|
|
expires = grant.get("expires_at")
|
|
if not isinstance(expires, str) or parse(expires) > ceiling:
|
|
expires = iso(ceiling)
|
|
out.append({"capability": grant["capability"], "decision": grant["decision"], "expires_at": expires, "granted_by": actor_for(identity)})
|
|
return out
|
|
|
|
|
|
def get_policy(request: Request) -> Response:
|
|
"""``GET /hux/v1/policy?scope=&scope_id=``: the effective policy for a scope."""
|
|
level = request.query.get("scope", "global")
|
|
scope_id = request.query.get("scope_id")
|
|
policy_id(level, scope_id)
|
|
record = effective_policy(request.store, request.identity, level, scope_id)
|
|
request.audit("policy.read", record["id"])
|
|
return Response(200, public(record, revisioned=True), {"ETag": str(record["revision"])})
|
|
|
|
|
|
def put_policy(request: Request) -> Response:
|
|
"""``PUT /hux/v1/policy``: replace the policy for the scope named in the body; humans only (F1)."""
|
|
require_human(request.identity, "policy writes")
|
|
body = body_dict(request)
|
|
scope = body.get("scope") or {}
|
|
if not isinstance(scope, dict):
|
|
raise Invalid("scope must be an object")
|
|
record_id = policy_id(scope.get("level", ""), scope.get("scope_id"))
|
|
if body.get("autonomy") not in ("ask_first", "safe", "autonomous"):
|
|
raise Invalid("autonomy must be ask_first, safe or autonomous")
|
|
if body["autonomy"] == "autonomous":
|
|
require_human(request.identity, "autonomy escalation")
|
|
budgets = body.get("budgets", dict(DEFAULT_BUDGETS))
|
|
record = {
|
|
"schema": "hux.policy.v1", "id": record_id, "owner": request.identity.subject, "scope": scope,
|
|
"autonomy": body["autonomy"], "grants": normalise_grants(body.get("grants", []), request.identity),
|
|
"budgets": budgets, "provenance": provenance(request.identity), "updated_at": iso(now()),
|
|
"_budget_epoch": new_id("bep"),
|
|
}
|
|
expected = request.if_match()
|
|
with request.store.lock(FAMILY):
|
|
exists = request.store.exists(FAMILY, record_id)
|
|
stored = request.store.put(FAMILY, checked(record), expected_revision=expected)
|
|
reason = "unconditional_write" if exists and expected is None else ""
|
|
request.audit("policy.write", record_id, reason=reason)
|
|
return Response(200, public(stored, revisioned=True), {"ETag": str(stored["revision"])})
|
|
|
|
|
|
def add_grant(store: TenantStore, identity: Identity, level: str, scope_id: str | None, capability: str, ttl: timedelta) -> None:
|
|
"""Append an allow grant to a scope's policy after a session/always decision; the decider must be human."""
|
|
require_human(identity, "allow grants")
|
|
record_id = policy_id(level, scope_id)
|
|
with store.lock(FAMILY):
|
|
if store.exists(FAMILY, record_id):
|
|
record = store.get(FAMILY, record_id)
|
|
else:
|
|
scope = {"level": level, **({"scope_id": scope_id} if scope_id else {})}
|
|
record = {**global_policy(store, identity), "id": record_id, "scope": scope}
|
|
record.pop("revision")
|
|
expected = record.pop("revision", None)
|
|
budget_epoch = record.get("_budget_epoch") or f"{record['id']}:{expected or 1}"
|
|
grants = [g for g in record["grants"] if g["capability"] != capability]
|
|
grants.append({"capability": capability, "decision": "allow", "expires_at": iso(now() + min(ttl, ALWAYS_TTL)), "granted_by": actor_for(identity)})
|
|
record = {
|
|
**record, "grants": grants[-64:], "provenance": provenance(identity),
|
|
"updated_at": iso(now()), "_budget_epoch": budget_epoch,
|
|
}
|
|
store.put(FAMILY, checked(record), expected_revision=expected)
|
|
|
|
|
|
# -- approvals -----------------------------------------------------------------
|
|
|
|
def refresh(store: TenantStore, record: dict[str, Any]) -> dict[str, Any]:
|
|
"""Expire a pending approval whose window has closed (SO-42); terminal records never change."""
|
|
if record["status"] == "pending" and parse(record["expires_at"]) <= now():
|
|
with store.lock(APPROVALS):
|
|
record = store.put(APPROVALS, {**record, "status": "expired"})
|
|
return record
|
|
|
|
|
|
def load_approval(store: TenantStore, approval_id: str) -> dict[str, Any]:
|
|
"""Read one approval for this tenant; unknown ids are 404 whoever owns them."""
|
|
return refresh(store, store.get(APPROVALS, check_id(approval_id)))
|
|
|
|
|
|
def request_fingerprint(body: dict[str, Any]) -> str:
|
|
"""Canonical digest that binds an Idempotency-Key to exactly one approval request."""
|
|
canonical = json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
|
|
return "sha256:" + hashlib.sha256(canonical).hexdigest()
|
|
|
|
|
|
def replay(store: TenantStore, key: str, fingerprint: str) -> dict[str, Any] | None:
|
|
"""The approval an Idempotency-Key already created, rejecting a changed request body."""
|
|
for row in store.read(APPROVALS, "idempotency"):
|
|
if row["key"] == key:
|
|
record = load_approval(store, row["id"])
|
|
if record.get("_request_fingerprint") != fingerprint:
|
|
raise Conflict("Idempotency-Key was already used for a different approval request")
|
|
return record
|
|
return None
|
|
|
|
|
|
def resolve_request(policy: dict[str, Any], capability: str, external: bool) -> str:
|
|
"""Effective decision for a request; external side effects never auto-allow (SO-39)."""
|
|
decision = rules.effective_decision(policy, capability, now())
|
|
if external and decision == "allow":
|
|
return "ask"
|
|
return decision
|
|
|
|
|
|
PRIVATE_DENIED = frozenset({"network", "web_search", "send_message", "shell", "delegate"})
|
|
|
|
|
|
def private_mode_denies(store: TenantStore, conversation_id: str | None, capability: str) -> bool:
|
|
"""Private conversations never release web, shell, messaging or delegation.
|
|
|
|
The mode catalog says so (``rules.MODE_CATALOG['private']['tools']``);
|
|
memory writes are already refused by the privacy state. The conversation
|
|
record's ``mode`` field is written only by the HUX-06 selection route.
|
|
"""
|
|
if capability not in PRIVATE_DENIED or not conversation_id:
|
|
return False
|
|
try:
|
|
record = store.get("conversations", conversation_id)
|
|
except Exception:
|
|
return False
|
|
return record.get("mode") == "private"
|
|
|
|
|
|
def create_approval(request: Request) -> Response:
|
|
"""``POST /hux/v1/approvals``: the agent hook asks before a gated action."""
|
|
require_worker(request.identity, "approval requests")
|
|
body = body_dict(request)
|
|
key = request.idempotency_key()
|
|
conversation_id = check_id(body.get("conversation_id"))
|
|
capability = body.get("capability")
|
|
if capability not in rules.CAPABILITIES:
|
|
raise Invalid("unknown capability")
|
|
from hux import budgets # lazy: budgets imports this module
|
|
run_id = budgets.checked_run_id(body.get("run_id"))
|
|
bound_conversation = budgets.run_conversation(request.store, run_id)
|
|
if bound_conversation != conversation_id:
|
|
raise Invalid("run is not authoritatively bound to this conversation")
|
|
req = body.get("request") if isinstance(body.get("request"), dict) else {}
|
|
external = bool(req.get("external", False))
|
|
evidence = req.get("evidence") if isinstance(req.get("evidence"), list) else []
|
|
if sum(1 for e in evidence if isinstance(e, dict) and e.get("kind") == "tool_call") != 1:
|
|
raise Invalid("an approval names exactly one tool_call; ask once per side effect (SO-37)")
|
|
fingerprint = request_fingerprint(body)
|
|
if key:
|
|
with request.store.lock(APPROVALS):
|
|
existing = replay(request.store, key, fingerprint)
|
|
if existing is not None:
|
|
request.audit("approvals.create", existing["id"], reason="replayed")
|
|
return Response(200, public(existing))
|
|
state = budgets.budget_state(request.store, request.identity, run_id, conversation_id)
|
|
if state["exhausted"]:
|
|
emit(request.store, request.identity, conversation_id, "budget.exhausted", f"Budget exhausted: {', '.join(state['exhausted'])}", run_id=state["run_id"])
|
|
raise BudgetExhausted("run budget exhausted", state["exhausted"])
|
|
policy = effective_policy(request.store, request.identity, "conversation", conversation_id)
|
|
decision = resolve_request(policy, capability, external)
|
|
if private_mode_denies(request.store, conversation_id, capability):
|
|
decision = "deny"
|
|
stamp = now()
|
|
record: dict[str, Any] = {
|
|
"schema": "hux.approval.v1", "id": new_id("apr"), "run_id": run_id, "conversation_id": conversation_id,
|
|
"capability": capability, "request": {**req, "external": external},
|
|
"status": {"allow": "approved", "deny": "denied", "ask": "pending"}[decision],
|
|
"requested_at": iso(stamp), "expires_at": iso(stamp + APPROVAL_TTL),
|
|
}
|
|
if decision != "ask":
|
|
record["decision"] = {"choice": "once" if decision == "allow" else "deny", "by": {"type": "system", "id": "policy"}, "at": iso(stamp)}
|
|
if key:
|
|
record["idempotency_key"] = key
|
|
record["_request_fingerprint"] = fingerprint
|
|
with request.store.lock(APPROVALS):
|
|
if key:
|
|
existing = replay(request.store, key, fingerprint)
|
|
if existing is not None:
|
|
request.audit("approvals.create", existing["id"], reason="replayed")
|
|
return Response(200, public(existing))
|
|
stored = request.store.put(APPROVALS, checked(record))
|
|
if key:
|
|
request.store.append(APPROVALS, "idempotency", {"key": key, "id": stored["id"]})
|
|
request.audit("approvals.create", stored["id"], reason=f"policy_{decision}")
|
|
kind = "approval.requested" if decision == "ask" else "approval.resolved"
|
|
emit(request.store, request.identity, conversation_id, kind, f"{capability}: {record['request'].get('summary', '')}"[:280],
|
|
run_id=record["run_id"], evidence=[{"kind": "approval", "id": stored["id"]}])
|
|
return Response(201, public(stored))
|
|
|
|
|
|
def list_approvals(request: Request) -> Response:
|
|
"""``GET /hux/v1/approvals?status=``: the queue, oldest request first."""
|
|
wanted = request.query.get("status")
|
|
if wanted and wanted not in rules.APPROVAL_TRANSITIONS:
|
|
raise Invalid("unknown status")
|
|
items = [refresh(request.store, r) for r in request.store.scan(APPROVALS)]
|
|
items = [public(r) for r in items if not wanted or r["status"] == wanted]
|
|
items.sort(key=lambda r: (r["requested_at"], r["id"]))
|
|
request.audit("approvals.list", wanted or "all")
|
|
return page(items)
|
|
|
|
|
|
def decide_approval(request: Request) -> Response:
|
|
"""``POST /hux/v1/approvals/{id}``: a human answers once, session, always or deny (SO-35)."""
|
|
identity = request.identity
|
|
if identity.trust not in HUMAN_TRUSTS or identity.surface not in HUMAN_SURFACES:
|
|
raise Forbidden("approvals are decided only from a human surface")
|
|
choice = body_dict(request).get("choice")
|
|
if choice not in ("once", "session", "always", "deny"):
|
|
raise Invalid("choice must be once, session, always or deny")
|
|
target = "denied" if choice == "deny" else "approved"
|
|
with request.store.lock(APPROVALS):
|
|
record = load_approval(request.store, request.params["id"])
|
|
if not rules.transition_allowed(rules.APPROVAL_TRANSITIONS, record["status"], target):
|
|
raise Conflict(f"approval is {record['status']}; only pending approvals can be decided")
|
|
record = {**record, "status": target, "decision": {"choice": choice, "by": actor_for(identity), "at": iso(now())}}
|
|
stored = request.store.put(APPROVALS, checked(record))
|
|
if choice == "session":
|
|
add_grant(request.store, identity, "conversation", record["conversation_id"], record["capability"], SESSION_TTL)
|
|
elif choice == "always":
|
|
add_grant(request.store, identity, "global", None, record["capability"], ALWAYS_TTL)
|
|
request.audit("approvals.decide", stored["id"], reason=choice)
|
|
emit(request.store, identity, record["conversation_id"], "approval.resolved", f"{record['capability']} {target} ({choice})",
|
|
run_id=record["run_id"], evidence=[{"kind": "approval", "id": stored["id"]}])
|
|
return Response(200, public(stored))
|
|
|
|
|
|
def get_approval(request: Request) -> Response:
|
|
"""``GET /hux/v1/approvals/{id}``: one record."""
|
|
record = load_approval(request.store, request.params["id"])
|
|
request.audit("approvals.read", record["id"])
|
|
return Response(200, public(record))
|
|
|
|
|
|
def register(router: Router) -> None:
|
|
"""Attach HUX-05 routes, including the budget, gate and stop routes from ``hux.budgets``."""
|
|
from hux import budgets
|
|
|
|
router.add("GET", "/hux/v1/policy", CARD, "policy.read", get_policy)
|
|
router.add("PUT", "/hux/v1/policy", CARD, "policy.write", put_policy)
|
|
router.add("POST", "/hux/v1/approvals", CARD, "approvals.create", create_approval)
|
|
router.add("GET", "/hux/v1/approvals", CARD, "approvals.list", list_approvals)
|
|
router.add("GET", "/hux/v1/approvals/{id}", CARD, "approvals.read", get_approval)
|
|
router.add("POST", "/hux/v1/approvals/{id}", CARD, "approvals.decide", decide_approval)
|
|
budgets.register_routes(router)
|