Skip to main content
Real backends serve many users, and different users get different capabilities. Hexgate splits that into two pieces:
  • HexgateContext — the per-request scope. Marks “this invocation acts on behalf of alice, with roles X, and these attributes.” Async context manager; pushes a fact-bearing Biscuit through the agent runtime.
  • Role policies — one policy.yaml per role, optionally inheriting from a base mixin. At call time the runtime evaluates every role the active context carries and takes the most permissive outcome.
The two are deliberately decoupled: tokens carry identity (who is calling), policy files carry rules (what they can do).

Minimal example

No manual attenuate_for_user, extract_facts, or ToolUseContext plumbing at the call site. The runtime mints the per-request token, picks the billing role’s policy file, and evaluates its constraints against each tool call. Pass more than one role and each is evaluated — see most permissive wins.

Filtering on attributes (ctx.*)

Beyond the role, a context can carry an open attributes bag that policy constraints read through the ctx.* namespace — turning role-based access into attribute-based (ABAC) filtering:
A missing ctx.<key> fails closed (deny), like any absent reference.
Attributes are exactly as trustworthy as the code that sets them. ctx.* may gate deny decisions, but it carries the same trust contract as user_roles: populate attributes from trusted server-side data (your auth/session/IdP), never from raw client input. Hexgate does not independently verify attribute values.
The bag is persisted and human-readable — keep raw PII out of it. When audit is configured, the attributes a decision was evaluated against are sent to the platform, stored on policy_decision for 180 days, and shown verbatim in the dashboard’s audit detail drawer to anyone with read access to the project. That is deliberate — it is what makes a ctx.*-driven deny explainable after the fact — but it means the bag is a data-retention surface, not scratch space.Redaction is key-name-only, and applies to the audit copy alone: a key equal to password, passwd, secret, token, api_key, credential or authorization (case-insensitive) becomes "[REDACTED]". The match is on the whole key, unlike the substring rule used for tool arguments — attributes are policy facts, so authorization_tier and access_token_scope are kept verbatim, and a deny they caused stays explainable. Values sensitive by content — email addresses, customer or patient identifiers, free text — pass through unchanged. Prefer the coarsest value that satisfies the policy: {"department": "finance"}, not {"email": "alice@corp.com"}. attributes is never rendered into the LLM-facing error payload either way.

FastAPI pattern

The scope must enclose the streaming iteration itself, because the role is resolved lazily — read on each tool call as the agent runs (see Notes). The robust shape is to open the scope inside the generator that produces the response:
Don’t scope auth in @app.middleware("http"). Starlette’s BaseHTTPMiddleware returns the response before the StreamingResponse body is iterated, so a async with HexgateContext(...): return await call_next(request) exits the scope before the first event is produced. Because hexgate resolves the role at tool-call time, every call during streaming then reads no role and silently falls back to the default policy — a silent authorization downgrade, no error raised. Scope inside the generator (above), or use a pure ASGI middleware that wraps the send channel — not BaseHTTPMiddleware.
For a non-streaming endpoint, async with HexgateContext(...): return await invoke_agent(...) is fine — the agent runs to completion inside the scope.

HexgateContext fields

Multiple roles: most permissive wins

Every role in user_roles is evaluated and the outcomes are combined, so access is granted if any single role grants it:
A caller with user_roles=["support", "billing"] can do everything support can do plus everything billing can do. Adding a role can only ever widen access, never narrow it. Evaluation stops at the first role that allows the call, and the role that granted it is recorded on the decision as deciding_role (granted by: in hexgate chat, and on the Decision your approval handler and any decision observer receive).
The persisted audit trail does not carry deciding_role yet. Its role column keeps its existing meaning — the caller’s first role — so a multi-role decision is stored as who was calling, not which role granted the call. Nothing recorded is wrong, but for genuinely multi-role callers it is incomplete until the audit pipeline lands the full role set. Read provenance from the Decision in-process in the meantime.
Approval is not sticky. If one role allows a tool outright and another would gate it on approval, the call runs with no approval promptALLOW beats NEEDS_APPROVAL. With:
user_roles=["support"] prompts for approval, but user_roles=["support", "billing"] does not. If a tool must always be gated, no role may grant it outright.
Keep default least-privilege. default is the fallback for any role name the policy doesn’t define, and every unrecognised name in user_roles still contributes default’s permissions to the union. A caller who controls their own role list can therefore always reach whatever default grants, simply by carrying a name you never defined. If default allows a tool, treat that as “every caller can call this tool”.default is a fallback, not a floor — a caller whose role is defined never inherits default’s permissions. For a baseline genuinely shared by every role, write it as a mixin and inherits it, so it is explicit in the YAML.hexgate policy validate warns (permissive-default) when the default role grants something no named role grants, and (implicit-default) when a policy declares roles but names none of them default — the loader then aliases one, handing its whole grant set to every undefined name. Run it with --max-severity warning in CI to make either a hard failure.
Two practical limits: at most 32 distinct roles are evaluated per call (extra roles are ignored, with a warning — they can only ever have widened access), and each role costs one policy evaluation, so the first role that allows the call is the cheapest outcome.

Role policies — one file per role

Agents that need per-role behaviour ship a policies/ directory instead of a single policy.yaml:
Each role file is a complete AgentPolicy. Inheritance is left-to-right, child wins on conflicts:
See constraints for the expression grammar (including ctx.*) and a role-scoped end-to-end walkthrough.

Notes

  • Single-file policies still work. A legacy policy.yaml is treated as the default role — no migration needed.
  • Lazy attenuation. HexgateContext.__aenter__ only pushes a contextvar — the cryptographic work happens inside stream_agent / invoke_agent the first time the agent runs. Errors surface at first agent call, not at scope entry.
  • Local agents skip attenuation. A context scope around a load_local_agent agent logs a warning and runs with no facts. The default policy still applies — use load_hexgate_agent for the full signed chain.
  • Explicit override. Passing tool_use_context= explicitly to stream_agent / invoke_agent wins over an active context scope. Useful for tests or one-off bypass.
  • Sync callers. HexgateContext exposes both async with ctx: and ctx.sync_scope(). The async form is the primary API; the sync mirror exists for CLI loops and Runner.run_sync-style callers.