jenkins 2eedcd2066 security(hux): private mode denies web, messaging, shell and delegation
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
2026-08-24 04:28:23 -03:00

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)