diff --git a/README.md b/README.md index 59acc9c..c93ecb6 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ The bundled agents demonstrate the contract end to end: | **DevOps** | `google-adk` (OpenAI via LiteLLM) | a real infra-assistant agent; HexGate-gated when `HEXGATE_API_KEY` is set, scoping per-tool policy to the caller's `context.user` role | | **ITSM** | `langchain` (deepagents) | a change-request assistant with a live lifecycle dashboard (refresh button → funnel metrics + change table updates as the agent's tools run) | | **HR** | `langchain` (deepagents) | an internal HR assistant; demonstrates stateful per-user data (`hr_state.py`) and role-gated tools when HexGate is wired | -| **Hexgate Guard** | `hexgate` | a hexgate-wrapped agent that opens `User(user_id, role)` per run and emits audit decisions to the hexgate cloud (separate backend at [`demo/hexgate-agent/`](demo/hexgate-agent/)) | +| **Hexgate Guard** | `hexgate` | a hexgate-wrapped agent that opens `HexgateContext(user_id, user_roles)` per run and emits audit decisions to the hexgate cloud (separate backend at [`demo/hexgate-agent/`](demo/hexgate-agent/)) | --- diff --git a/demo-users.yaml b/demo-users.yaml index 754fa24..7b5b1f3 100644 --- a/demo-users.yaml +++ b/demo-users.yaml @@ -9,7 +9,8 @@ # throwaway accounts. # # `role` is optional and opaque: hexgate-wrapped agents read it via -# `User(role=...)`. HexKit itself never interprets the string — every team +# `HexgateContext(user_roles=[...])`. HexKit itself never interprets the +# string — every team # defines their own role vocabulary in their hexgate policy. A role only # means something to the agent whose policy defines it; talking to the other # agent falls through to that policy's fail-closed `default` (deny). diff --git a/demo/agent-server/pyproject.toml b/demo/agent-server/pyproject.toml index eae85b1..b14f01e 100644 --- a/demo/agent-server/pyproject.toml +++ b/demo/agent-server/pyproject.toml @@ -34,7 +34,7 @@ dev = [ # Opt-in HexGate wrapping (enabled by setting HEXGATE_API_KEY). Not in the default setup.sh; # needs Python >=3.13 (the hexgate floor). Without it, the plain healthcare path # still works. -hexgate = ["hexgate>=0.2.9"] +hexgate = ["hexgate>=0.3.0"] [build-system] requires = ["hatchling"] diff --git a/demo/agent-server/src/agent_server/agents/clinic_org/healthcare/healthcare.py b/demo/agent-server/src/agent_server/agents/clinic_org/healthcare/healthcare.py index 160588d..a3a6a65 100644 --- a/demo/agent-server/src/agent_server/agents/clinic_org/healthcare/healthcare.py +++ b/demo/agent-server/src/agent_server/agents/clinic_org/healthcare/healthcare.py @@ -48,12 +48,13 @@ async def run( # Scope policy decisions to the signed-in HexKit user. `id` / `role` # ride in `context.user` (CONTRACT.md §5); fall back to the static # demo identity and HEXGATE_ROLE for standalone runs that send no - # user block. + # user block. HexgateContext takes a role *set*; the contract + # carries one role per caller, so the set is that single role. caller = protocol.caller(context) user_id = caller.get("id") or "hexkit-demo" role = caller.get("role") or os.getenv("HEXGATE_ROLE", "nurse") events = healthcare_agent.stream_as( - agent_input(input), user_id=user_id, role=role + agent_input(input), user_id=user_id, roles=[role] ) else: events = healthcare_agent.stream(agent_input(input)) diff --git a/demo/agent-server/src/agent_server/agents/clinic_org/healthcare/healthcare_agent.py b/demo/agent-server/src/agent_server/agents/clinic_org/healthcare/healthcare_agent.py index 63c6691..5393d51 100644 --- a/demo/agent-server/src/agent_server/agents/clinic_org/healthcare/healthcare_agent.py +++ b/demo/agent-server/src/agent_server/agents/clinic_org/healthcare/healthcare_agent.py @@ -108,18 +108,26 @@ async def stream(input: Any) -> AsyncIterator[Any]: yield event -async def stream_as(input: Any, *, user_id: str, role: str) -> AsyncIterator[Any]: - """Same as :func:`stream`, but through HexGate as ``user_id`` / ``role`` — +async def stream_as( + input: Any, *, user_id: str, roles: list[str] +) -> AsyncIterator[Any]: + """Same as :func:`stream`, but through HexGate as ``user_id`` / ``roles`` — every tool call is policy-gated against the calling user. - ``user_id`` and ``role`` come from the HexKit caller (``context.user``); the - wrapper in ``healthcare.py`` resolves them. Policy decisions and audit events - are tagged with this identity. + ``user_id`` and ``roles`` come from the HexKit caller (``context.user``); the + wrapper in ``healthcare.py`` resolves them. The per-request + :class:`~hexgate.runtime.HexgateContext` carries both: the id tags policy + decisions and audit events, and every role in ``user_roles`` is evaluated + (most permissive outcome wins). """ from hexgate.adapters.openai import HexgateRunner - from hexgate.runtime import User + from hexgate.runtime import HexgateContext - user = User(user_id=user_id, session_id="hexkit-demo-healthcare", role=role) - result = HexgateRunner().run_streamed(agent, input, user=user) + hexgate_context = HexgateContext( + user_id=user_id, session_id="hexkit-demo-healthcare", user_roles=list(roles) + ) + result = HexgateRunner().run_streamed( + agent, input, hexgate_context=hexgate_context + ) async for event in result.stream_events(): yield event diff --git a/demo/agent-server/src/agent_server/agents/shared/hr/hr.py b/demo/agent-server/src/agent_server/agents/shared/hr/hr.py index af861b8..859144e 100644 --- a/demo/agent-server/src/agent_server/agents/shared/hr/hr.py +++ b/demo/agent-server/src/agent_server/agents/shared/hr/hr.py @@ -45,12 +45,14 @@ async def run( if os.getenv("HEXGATE_API_KEY"): # `name` / `role` ride in `context.user` (CONTRACT.md §5); fall back to # a static identity for standalone runs that send no user block. + # HexgateContext takes a role *set*; the contract carries one role + # per caller, so the set is that single role. caller = protocol.caller(context) identity = caller.get("name") or "hexkit-demo" role = caller.get("role") or os.getenv("HEXGATE_ROLE", "default") if role not in _HR_ROLES: role = "default" - events = hr_agent.stream_as(input, user_id=identity, role=role) + events = hr_agent.stream_as(input, user_id=identity, roles=[role]) else: events = hr_agent.stream(input) diff --git a/demo/agent-server/src/agent_server/agents/shared/hr/hr_agent.py b/demo/agent-server/src/agent_server/agents/shared/hr/hr_agent.py index 59673da..0b7324b 100644 --- a/demo/agent-server/src/agent_server/agents/shared/hr/hr_agent.py +++ b/demo/agent-server/src/agent_server/agents/shared/hr/hr_agent.py @@ -42,21 +42,22 @@ def _actor() -> str: - """The calling employee's NAME from the active User scope (the self-service - tools act on this, never a model-supplied id). Falls back to a demo identity - on the ungated path, where no User scope is set.""" - from hexgate.runtime import get_current_user + """The calling employee's NAME from the active HexgateContext scope (the + self-service tools act on this, never a model-supplied id). Falls back to a + demo identity on the ungated path, where no context scope is set.""" + from hexgate.runtime import get_current_context - user = get_current_user() - return user.user_id if user is not None else "hexkit-demo" + context = get_current_context() + return context.user_id if context is not None else "hexkit-demo" # --------------------------------------------------------------------------- # Tools — stubs (no datastore), so the only gating they demonstrate is the # policy's arg-level check on each call's `args` (e.g. `args.field`, `args.count`). # Checks the constraint engine can't express — row-level "son équipe" scope, -# name → id resolution — would live in the tool body keyed off the trusted User -# identity (as the ITSM agent does via `_actor`); these stubs deliberately don't +# name → id resolution — would live in the tool body keyed off the trusted +# HexgateContext identity (as the ITSM agent does via `_actor`); these stubs +# deliberately don't # implement them, so a caller is bounded by field-by-role gating only, NOT by # which employees are theirs to see. # --------------------------------------------------------------------------- @@ -260,7 +261,7 @@ def _build_agent() -> Any: # Enforced wrapper, built once on first gated use — `wrap_langchain_agent` # mutates TOOLS in place, so re-running it per request would re-wrap them. -# One wrapper serves all users; `user` is passed per call. +# One wrapper serves all users; the `HexgateContext` is passed per call. _enforced: Any | None = None @@ -283,13 +284,19 @@ async def stream(input: Any) -> AsyncIterator[Any]: yield event -async def stream_as(input: Any, *, user_id: str, role: str) -> AsyncIterator[Any]: - """Same as :func:`stream`, but policy-gated against the caller — ``role`` - (default < manager < gestionnaire_rh) flips each decision.""" - from hexgate.runtime import User +async def stream_as( + input: Any, *, user_id: str, roles: list[str] +) -> AsyncIterator[Any]: + """Same as :func:`stream`, but policy-gated against the caller — the + ``user_roles`` on the per-request :class:`~hexgate.runtime.HexgateContext` + (default < manager < gestionnaire_rh) flip each decision. Every role in the + set is evaluated and the most permissive outcome wins.""" + from hexgate.runtime import HexgateContext - user = User(user_id=user_id, role=role, session_id="hexkit-demo-hr") + hexgate_context = HexgateContext( + user_id=user_id, user_roles=list(roles), session_id="hexkit-demo-hr" + ) async for event in _enforced_agent().astream_events( - messages_input(input), user=user + messages_input(input), hexgate_context=hexgate_context ): yield event diff --git a/demo/agent-server/src/agent_server/agents/tech_org/devops/devops.py b/demo/agent-server/src/agent_server/agents/tech_org/devops/devops.py index 6f34042..4279061 100644 --- a/demo/agent-server/src/agent_server/agents/tech_org/devops/devops.py +++ b/demo/agent-server/src/agent_server/agents/tech_org/devops/devops.py @@ -54,13 +54,15 @@ async def run( # Scope policy decisions to the signed-in HexKit user. `id` / `role` # ride in `context.user` (CONTRACT.md §5); fall back to the static # demo identity and HEXGATE_ROLE for standalone runs that send no - # user block. + # user block. HexgateContext takes a role *set* (every role is + # evaluated, most permissive wins) — the contract carries one role + # per caller, so the set is that single role. caller = protocol.caller(context) user_id = caller.get("id") or "hexkit-demo" role = caller.get("role") or os.getenv("HEXGATE_ROLE", "default") if role not in _DEVOPS_ROLES: role = "default" - events = devops_agent.stream_as(text, user_id=user_id, role=role) + events = devops_agent.stream_as(text, user_id=user_id, roles=[role]) else: events = devops_agent.stream(text) diff --git a/demo/agent-server/src/agent_server/agents/tech_org/devops/devops_agent.py b/demo/agent-server/src/agent_server/agents/tech_org/devops/devops_agent.py index ebf7c69..69b80bb 100644 --- a/demo/agent-server/src/agent_server/agents/tech_org/devops/devops_agent.py +++ b/demo/agent-server/src/agent_server/agents/tech_org/devops/devops_agent.py @@ -2,13 +2,14 @@ The tools + ``agent``, and how to invoke it: ``stream`` (plain ADK runner) and ``stream_as`` (the same agent gated by HexGate policy). Vendored from -``hexgate/examples/devops_agent.py``. The HexKit contract wrapper that the +``hexgate/examples/devops_google.py``. The HexKit contract wrapper that the server runs lives in ``devops.py``; the ADK ``Event`` → native projection lives in ``google_adk``. -One agent definition; the caller's ``role`` (viewer < operator < admin) is what -flips the decision — the policy gates ``scale_deployment`` on the replica count -AND the env, and reserves ``delete_resource`` for admin. +One agent definition; the caller's roles (viewer < operator < admin), carried on +the per-request ``HexgateContext``, are what flip the decision — the policy gates +``scale_deployment`` on the replica count AND the env, and reserves +``delete_resource`` for admin. """ from __future__ import annotations @@ -118,23 +119,37 @@ async def stream(text: str) -> AsyncIterator[Any]: yield event -async def stream_as(text: str, *, user_id: str, role: str) -> AsyncIterator[Any]: - """Same as :func:`stream`, but through HexGate as ``user_id`` / ``role`` — - every tool call is policy-gated against the calling user. The caller's - ``role`` (viewer < operator < admin) is what flips each decision. +async def stream_as( + text: str, *, user_id: str, roles: list[str] +) -> AsyncIterator[Any]: + """Same as :func:`stream`, but through HexGate as ``user_id`` / ``roles`` — + every tool call is policy-gated against the calling user. + + The caller's roles (viewer < operator < admin) are what flip each decision. + HexGate evaluates *every* role in the set and takes the most permissive + outcome, so the set — not a single string — is the unit of authorization. ``HexgateRunner`` reads ``HEXGATE_API_KEY`` from the environment. - ``user_id`` and ``role`` come from the HexKit caller (``context.user``); the + ``user_id`` and ``roles`` come from the HexKit caller (``context.user``); the wrapper in ``devops.py`` resolves them. """ from hexgate.adapters.google import HexgateRunner - from hexgate.runtime import User + from hexgate.runtime import HexgateContext - user = User(user_id=user_id, session_id=_SESSION_ID, role=role) + # The per-request scope: identity for audit, `user_roles` for policy + # selection. The runner opens `async with hexgate_context` around the run, + # so the wrapped tools' enforcers resolve the caller off this contextvar. + hexgate_context = HexgateContext( + user_id=user_id, session_id=_SESSION_ID, user_roles=list(roles) + ) session_service = InMemorySessionService() await session_service.create_session( - app_name=_APP_NAME, user_id=user.user_id, session_id=user.session_id + app_name=_APP_NAME, + user_id=hexgate_context.user_id, + session_id=hexgate_context.session_id, ) runner = HexgateRunner(agent=agent, app_name=_APP_NAME, session_service=session_service) - async for event in runner.run_async(new_message=_message(text), user=user): + async for event in runner.run_async( + new_message=_message(text), hexgate_context=hexgate_context + ): yield event diff --git a/demo/agent-server/src/agent_server/agents/tech_org/itsm/itsm.py b/demo/agent-server/src/agent_server/agents/tech_org/itsm/itsm.py index 5e528e3..b79212e 100644 --- a/demo/agent-server/src/agent_server/agents/tech_org/itsm/itsm.py +++ b/demo/agent-server/src/agent_server/agents/tech_org/itsm/itsm.py @@ -40,10 +40,12 @@ async def run( if os.getenv("HEXGATE_API_KEY"): # `name` / `role` ride in `context.user` (CONTRACT.md §5); fall back to # a static identity for standalone runs that send no user block. + # HexgateContext takes a role *set*; the contract carries one role + # per caller, so the set is that single role. caller = protocol.caller(context) identity = caller.get("name") or "hexkit-demo" role = caller.get("role") or os.getenv("HEXGATE_ROLE", "requester") - events = itsm_agent.stream_as(input, user_id=identity, role=role) + events = itsm_agent.stream_as(input, user_id=identity, roles=[role]) else: events = itsm_agent.stream(input) diff --git a/demo/agent-server/src/agent_server/agents/tech_org/itsm/itsm_agent.py b/demo/agent-server/src/agent_server/agents/tech_org/itsm/itsm_agent.py index c11ed49..e6e9a61 100644 --- a/demo/agent-server/src/agent_server/agents/tech_org/itsm/itsm_agent.py +++ b/demo/agent-server/src/agent_server/agents/tech_org/itsm/itsm_agent.py @@ -27,7 +27,7 @@ # register` resolves) needs OPENAI_API_KEY at construction time. load_dotenv() -# Each transition tool maps to (required from_state, to_state). The role is +# Each transition tool maps to (required from_state, to_state). The roles are # enforced by the policy (one tool per role); the from-state, in the tool body. _TRANSITIONS = { @@ -37,14 +37,29 @@ } -def _actor() -> tuple[str, str | None]: - """Trusted caller identity (name, role) from the active User scope.""" - from hexgate.runtime import get_current_user +# Roles that may see every change record (see `list_my_changes`). +_MANAGER_ROLES = frozenset({"change_manager", "cab_manager"}) - user = get_current_user() - if user is None: # no scope → fail closed - return ("anonymous", None) - return (user.user_id, user.role) + +def _actor() -> tuple[str, frozenset[str]]: + """Trusted caller identity (name, roles) from the active HexgateContext scope. + + Returns the caller's *whole* role set, not one role: the policy evaluates + every role a caller carries and allows the call if any of them allows it + (permissive union). The app-level scoping below has to union the same way, + or it would be stricter than the policy that let the call through. + """ + from hexgate.runtime import get_current_context + + context = get_current_context() + if context is None: # no scope → fail closed + return ("anonymous", frozenset()) + return (context.user_id, frozenset(context.user_roles)) + + +def _roles_label(roles: frozenset[str]) -> str | None: + """Audit-log rendering of the caller's role set (``None`` when empty).""" + return ", ".join(sorted(roles)) or None def _fmt(change: dict) -> str: @@ -59,16 +74,17 @@ def _transition(tool_name: str, change_id: str) -> str: """Shared guard for the three transitions: the change must be in the expected ``from`` state (role is already enforced by the policy), and — for the requester's submit — the actor must own the record.""" - actor_name, role = _actor() + actor_name, roles = _actor() + role_label = _roles_label(roles) from_state, to_state = _TRANSITIONS[tool_name] change = db.get_change(change_id) if change is None: - db.audit(action=tool_name, actor=actor_name, role=role, number=change_id, + db.audit(action=tool_name, actor=actor_name, role=role_label, number=change_id, decision="DENY", detail="not found") return f"DENIED: change {change_id} not found." if change["state"] != from_state: - db.audit(action=tool_name, actor=actor_name, role=role, number=change_id, + db.audit(action=tool_name, actor=actor_name, role=role_label, number=change_id, decision="DENY", before=change["state"], detail=f"requires {from_state}") return ( f"DENIED: {tool_name} requires state '{from_state}', but " @@ -77,12 +93,12 @@ def _transition(tool_name: str, change_id: str) -> str: # Only the requester's own draft may be submitted. if tool_name == "submit_for_assessment" and change["requester_name"] != actor_name: - db.audit(action=tool_name, actor=actor_name, role=role, number=change_id, + db.audit(action=tool_name, actor=actor_name, role=role_label, number=change_id, decision="DENY", before=change["state"], detail="not owner") return f"DENIED: {change_id} is not your change." updated = db.set_state(change_id, to_state) - db.audit(action=tool_name, actor=actor_name, role=role, number=change_id, + db.audit(action=tool_name, actor=actor_name, role=role_label, number=change_id, decision="ALLOW", before=from_state, after=to_state) return f"OK: {change_id} transitioned {from_state} → {to_state}.\n{_fmt(updated)}" @@ -98,16 +114,17 @@ async def create_change(ci: str, short_description: str) -> str: `ci` is a CMDB CI name such as 'srv-db-01' or 'CRM'. The new change is forced to state 'new' and owned by the calling user. """ - actor_name, role = _actor() + actor_name, roles = _actor() + role_label = _roles_label(roles) try: created = db.create_change( short_description=short_description, ci_name=ci, requester_name=actor_name ) except ValueError as exc: - db.audit(action="create_change", actor=actor_name, role=role, number=None, + db.audit(action="create_change", actor=actor_name, role=role_label, number=None, decision="DENY", detail=str(exc)) return f"DENIED: {exc} (known CIs: srv-web-01/02, srv-db-01, srv-app-01, CRM, ERP, Billing)." - db.audit(action="create_change", actor=actor_name, role=role, + db.audit(action="create_change", actor=actor_name, role=role_label, number=created["number"], decision="ALLOW", after="new", detail=f"CI={ci}") return f"OK: created {created['number']} in state 'new'.\n{_fmt(created)}" @@ -119,17 +136,18 @@ async def update_change(change_id: str, description: str = "", ci: str = "") -> Allowed only while the change is in state 'new' and owned by the caller — write access expires once the change moves past 'new'. """ - actor_name, role = _actor() + actor_name, roles = _actor() + role_label = _roles_label(roles) change = db.get_change(change_id) if change is None: return f"DENIED: change {change_id} not found." if change["state"] != "new": - db.audit(action="update_change", actor=actor_name, role=role, number=change_id, + db.audit(action="update_change", actor=actor_name, role=role_label, number=change_id, decision="DENY", before=change["state"], detail="not editable past 'new'") return f"DENIED: {change_id} is in '{change['state']}'; drafts are editable only while 'new'." if change["requester_name"] != actor_name: - db.audit(action="update_change", actor=actor_name, role=role, number=change_id, + db.audit(action="update_change", actor=actor_name, role=role_label, number=change_id, decision="DENY", before=change["state"], detail="not owner") return f"DENIED: {change_id} is not your draft." @@ -140,10 +158,10 @@ async def update_change(change_id: str, description: str = "", ci: str = "") -> ci_name=ci or None, ) except ValueError as exc: - db.audit(action="update_change", actor=actor_name, role=role, number=change_id, + db.audit(action="update_change", actor=actor_name, role=role_label, number=change_id, decision="DENY", detail=str(exc)) return f"DENIED: {exc}." - db.audit(action="update_change", actor=actor_name, role=role, number=change_id, + db.audit(action="update_change", actor=actor_name, role=role_label, number=change_id, decision="ALLOW", before="new", after="new") return f"OK: updated {change_id}.\n{_fmt(updated)}" @@ -151,17 +169,22 @@ async def update_change(change_id: str, description: str = "", ci: str = "") -> @tool async def read_change(change_id: str) -> str: """Return a single change request by number (e.g. 'CHG0001').""" - actor_name, role = _actor() + actor_name, roles = _actor() + role_label = _roles_label(roles) change = db.get_change(change_id) - # Implementers may read ONLY changes they are mentioned on. Return + # Implementers may read ONLY changes they are mentioned on. Any *other* role + # the caller carries lifts that restriction, because no other role is + # read-restricted here — matching the policy's permissive union, which + # already allowed this call on the strength of the widest role. Return # 'not found' on a miss so the existence of unrelated changes never leaks. + implementer_only = roles == frozenset({"implementer"}) if change is None or ( - role == "implementer" and change["implementer_name"] != actor_name + implementer_only and change["implementer_name"] != actor_name ): - db.audit(action="read_change", actor=actor_name, role=role, number=change_id, + db.audit(action="read_change", actor=actor_name, role=role_label, number=change_id, decision="DENY", detail="not found / out of scope") return f"Not found: {change_id}." - db.audit(action="read_change", actor=actor_name, role=role, number=change_id, + db.audit(action="read_change", actor=actor_name, role=role_label, number=change_id, decision="ALLOW", before=change["state"]) return _fmt(change) @@ -169,18 +192,23 @@ async def read_change(change_id: str) -> str: @tool async def list_my_changes() -> str: """List the change requests visible to the calling user.""" - actor_name, role = _actor() + actor_name, roles = _actor() + role_label = _roles_label(roles) changes = db.all_changes() # Filter to the caller's scope BEFORE returning — no unrelated rows leak. - if role == "requester": - visible = [c for c in changes if c["requester_name"] == actor_name] - elif role == "implementer": - visible = [c for c in changes if c["implementer_name"] == actor_name] - elif role in ("change_manager", "cab_manager"): + # The scope is the UNION over the caller's roles, so someone who is both a + # requester and an implementer sees both sets. Filtering on one role would + # hide rows the policy plainly grants them. + if roles & _MANAGER_ROLES: visible = changes else: - visible = [] - db.audit(action="list_my_changes", actor=actor_name, role=role, number=None, + visible = [ + c + for c in changes + if ("requester" in roles and c["requester_name"] == actor_name) + or ("implementer" in roles and c["implementer_name"] == actor_name) + ] + db.audit(action="list_my_changes", actor=actor_name, role=role_label, number=None, decision="ALLOW", detail=f"{len(visible)} visible") if not visible: return "No changes visible to you." @@ -196,16 +224,17 @@ async def submit_for_assessment(change_id: str) -> str: @tool async def update_assessment(change_id: str, short_description: str) -> str: """Update assessment details on a change under review (state must be 'Assess').""" - actor_name, role = _actor() + actor_name, roles = _actor() + role_label = _roles_label(roles) change = db.get_change(change_id) if change is None: return f"DENIED: change {change_id} not found." if change["state"] != "Assess": - db.audit(action="update_assessment", actor=actor_name, role=role, number=change_id, + db.audit(action="update_assessment", actor=actor_name, role=role_label, number=change_id, decision="DENY", before=change["state"], detail="requires Assess") return f"DENIED: assessment edits require state 'Assess'; {change_id} is in '{change['state']}'." updated = db.update_change_fields(change_id, description=short_description) - db.audit(action="update_assessment", actor=actor_name, role=role, number=change_id, + db.audit(action="update_assessment", actor=actor_name, role=role_label, number=change_id, decision="ALLOW", before="Assess", after="Assess") return f"OK: updated assessment on {change_id}.\n{_fmt(updated)}" @@ -223,17 +252,18 @@ async def schedule_change(change_id: str, cab_decision: str) -> str: `cab_decision` is the CAB's note (e.g. 'approved for Saturday window'). Decision-only: no change fields are edited here. """ - actor_name, role = _actor() + actor_name, roles = _actor() + role_label = _roles_label(roles) from_state, to_state = _TRANSITIONS["schedule_change"] change = db.get_change(change_id) if change is None: return f"DENIED: change {change_id} not found." if change["state"] != from_state: - db.audit(action="schedule_change", actor=actor_name, role=role, number=change_id, + db.audit(action="schedule_change", actor=actor_name, role=role_label, number=change_id, decision="DENY", before=change["state"], detail=f"requires {from_state}") return f"DENIED: scheduling requires state '{from_state}'; {change_id} is in '{change['state']}'." updated = db.set_state(change_id, to_state) - db.audit(action="schedule_change", actor=actor_name, role=role, number=change_id, + db.audit(action="schedule_change", actor=actor_name, role=role_label, number=change_id, decision="ALLOW", before=from_state, after=to_state, detail=f"CAB: {cab_decision}") return f"OK: {change_id} scheduled (Authorize → Schedule). CAB decision: {cab_decision}.\n{_fmt(updated)}" @@ -292,7 +322,7 @@ def _build_agent() -> Any: # Enforced wrapper, built once on first gated use — `wrap_langchain_agent` # mutates TOOLS in place, so re-running it per request would re-wrap them. -# One wrapper serves all users; `user` is passed per call. +# One wrapper serves all users; the `HexgateContext` is passed per call. _enforced: Any | None = None @@ -320,14 +350,18 @@ async def stream(input: Any) -> AsyncIterator[Any]: yield event -async def stream_as(input: Any, *, user_id: str, role: str) -> AsyncIterator[Any]: +async def stream_as( + input: Any, *, user_id: str, roles: list[str] +) -> AsyncIterator[Any]: """Same as :func:`stream`, but policy-gated against the caller. ``user_id`` is - the caller's NAME (tools read it back via ``get_current_user()`` for ownership - / scope); ``role`` is the opaque role from ``context.user``.""" - from hexgate.runtime import User + the caller's NAME (tools read it back via ``get_current_context()`` for + ownership / scope); ``roles`` are the opaque roles from ``context.user``.""" + from hexgate.runtime import HexgateContext - user = User(user_id=user_id, role=role, session_id="hexkit-demo-itsm") + hexgate_context = HexgateContext( + user_id=user_id, user_roles=list(roles), session_id="hexkit-demo-itsm" + ) async for event in _enforced_agent().astream_events( - messages_input(input), user=user + messages_input(input), hexgate_context=hexgate_context ): yield event diff --git a/demo/gdocs-agent/README.md b/demo/gdocs-agent/README.md index 67ee95c..b8d95d9 100644 --- a/demo/gdocs-agent/README.md +++ b/demo/gdocs-agent/README.md @@ -9,7 +9,8 @@ The "gates" demo backend. It serves **one** hexgate agent (`docs`) over the five **fetched from the hexgate platform** and hot-reloaded every run — edit it in the dashboard's **Policies** tab and the next message reflects it; - runs under the caller's HexKit **role** (`analyst` / `editor` / `admin`) via - `async with hexgate.User(role=...)`, so the same call is allowed for one role + `async with hexgate.HexgateContext(user_id=..., user_roles=[...])`, so the + same call is allowed for one role and denied for another. A denied tool call streams as a hexgate `error` event and shows up as a failed call in the tool-calls widget. diff --git a/demo/gdocs-agent/demo-users.yaml b/demo/gdocs-agent/demo-users.yaml index bd08523..0a81f1b 100644 --- a/demo/gdocs-agent/demo-users.yaml +++ b/demo/gdocs-agent/demo-users.yaml @@ -2,7 +2,8 @@ # PLATFORM_DEMO_USERS_FILE so switching login shows the same call allowed for # one role and denied for another. # -# `role` is opaque to HexKit — the gdocs agent reads it via User(role=...) and +# `role` is opaque to HexKit — the gdocs agent reads it via +# HexgateContext(user_roles=[...]) and # the platform's docs_agent policy resolves the rules. The three roles below # match that policy: analyst (read-only) < editor (create/share internal) < # admin (everything, with guardrails). `agents: [docs]` scopes each user to the diff --git a/demo/gdocs-agent/pyproject.toml b/demo/gdocs-agent/pyproject.toml index 33d2d65..d7c1d65 100644 --- a/demo/gdocs-agent/pyproject.toml +++ b/demo/gdocs-agent/pyproject.toml @@ -8,7 +8,7 @@ requires-python = ">=3.13" dependencies = [ "fastapi>=0.115", "uvicorn[standard]>=0.30", - "hexgate>=0.2.8", # >=0.2.8 has hexgate.mcp (the MCP gate) + "hexgate>=0.3.0", # >=0.3.0 is the HexgateContext API (was User) "langchain-openai>=0.2", # the chat model the agent drives ] diff --git a/demo/gdocs-agent/src/gdocs_agent/agent.py b/demo/gdocs-agent/src/gdocs_agent/agent.py index 1cd81e5..2c9937c 100644 --- a/demo/gdocs-agent/src/gdocs_agent/agent.py +++ b/demo/gdocs-agent/src/gdocs_agent/agent.py @@ -17,7 +17,11 @@ proxy's ``HexgateTranslator`` renders in the tool-calls widget. The caller's HexKit role (``context.user.role`` — analyst / editor / admin) -drives which rules apply, via ``async with hexgate.User(role=...)``. +drives which rules apply, via +``async with hexgate.HexgateContext(user_id=..., user_roles=[...])``. The +``user_roles`` set is what hexgate selects policies with (every role in it is +evaluated, most permissive wins); the contract carries one role per caller, so +the set holds that single role. BYOK: the OpenAI key is never sent by HexKit. It's read from this process's env (``OPENAI_API_KEY``) or handed in-memory to ``POST /byok`` (the demo notebook @@ -142,7 +146,7 @@ async def run_gdocs_agent( } return - from hexgate import User + from hexgate import HexgateContext from hexgate.agents.factory import stream_agent try: @@ -156,7 +160,7 @@ async def run_gdocs_agent( messages = _messages_with_files(input, context) # Bind the run to the HexKit caller's identity — hexgate reads this - # ContextVar to resolve the role's rules from the platform policy. No user + # ContextVar to resolve each role's rules from the platform policy. No user # block = unscoped (falls through to the policy's fail-closed default). caller = (context or {}).get("user") or {} user_id = caller.get("id") @@ -169,7 +173,7 @@ async def run_gdocs_agent( yield event.model_dump(mode="json") return - async with User(user_id=user_id, role=role): + async with HexgateContext(user_id=user_id, user_roles=[role] if role else []): async for event in stream_agent(agent, handler, {"messages": messages}): if cancel.is_set(): return diff --git a/demo/hexgate-agent/README.md b/demo/hexgate-agent/README.md index 84b650b..708e569 100644 --- a/demo/hexgate-agent/README.md +++ b/demo/hexgate-agent/README.md @@ -9,10 +9,12 @@ authorization-infrastructure SDK from the security-platform team) over the five events verbatim and the proxy's `HexgateTranslator` maps them onto the shared `RunEmitter`. The round-trip proves the "same events" decision between the two products holds on the wire. -2. **End-to-end user identity.** When the HexKit proxy sends +2. **End-to-end caller identity.** When the HexKit proxy sends `context.user = {id, name, role}` (CONTRACT.md §5), this backend opens an - `async with hexgate.User(user_id=..., role=...)` block around the run. The - role drives hexgate's per-tool policy decisions, biscuit attenuation, and + `async with hexgate.HexgateContext(user_id=..., user_roles=[...])` block + around the run. Those roles drive hexgate's per-tool policy decisions + (every role in the set is evaluated, most permissive wins), biscuit + attenuation, and audit emission to the hexgate cloud — so the demo's HexKit users show up in the cloud dashboard tagged with whatever role you set in **Settings**. @@ -67,10 +69,10 @@ The proxy's smoke check works against any contract-conformant backend: demo/hexgate-agent/.venv/bin/python demo/scripts/verify_backend.py http://127.0.0.1:8080 ``` -## User identity and policy +## Caller identity and policy [`run_hexgate_agent`](src/hexgate_agent/agent.py) reads `context.user.id` and -`context.user.role` and opens `async with hexgate.User(...)` around +`context.user.role` and opens `async with hexgate.HexgateContext(...)` around `stream_agent(...)`. From there, hexgate's policy enforcement picks the role's rules from your `policy.yaml` (or your registered cloud policy) and audit events stream to the cloud, tagged with the HexKit user. **Policy enforcement** is diff --git a/demo/hexgate-agent/pyproject.toml b/demo/hexgate-agent/pyproject.toml index 8651667..1b7b5e3 100644 --- a/demo/hexgate-agent/pyproject.toml +++ b/demo/hexgate-agent/pyproject.toml @@ -8,7 +8,7 @@ requires-python = ">=3.13" dependencies = [ "fastapi>=0.115", "uvicorn[standard]>=0.30", - "hexgate>=0.2", + "hexgate>=0.3.0", ] [project.optional-dependencies] diff --git a/demo/hexgate-agent/src/hexgate_agent/agent.py b/demo/hexgate-agent/src/hexgate_agent/agent.py index 1afb1dc..8caf2fe 100644 --- a/demo/hexgate-agent/src/hexgate_agent/agent.py +++ b/demo/hexgate-agent/src/hexgate_agent/agent.py @@ -10,12 +10,14 @@ onto the rich internal schema. That round-trip is the whole point: it proves the two products' "same events" decision actually holds on the wire. -2. **The user identity it carries.** When the HexKit proxy sends +2. **The caller identity it carries.** When the HexKit proxy sends ``context.user = {id, name, role}`` (CONTRACT.md §5), this backend opens an - ``async with hexgate.User(user_id=..., role=...)`` block around the run. - That ContextVar drives: + ``async with hexgate.HexgateContext(user_id=..., user_roles=[...])`` block + around the run. ``user_roles`` is a set — hexgate evaluates every role and + takes the most permissive outcome — and the contract carries one role per + caller, so the set holds that single role. That ContextVar drives: - - per-tool policy decisions (``enforce_policy(role, tool, args)``); + - per-tool policy decisions (one policy per role, resolved from the set); - per-request biscuit attenuation by ``HexgateClient``; - audit events POSTed to the hexgate cloud, tagged with the HexKit user. @@ -147,16 +149,16 @@ async def run_hexgate_agent( # Built once and cached (see _get_agent); the env key scopes the cache # entry. stream_agent is imported lazily for the same reason as the SDK # imports inside _get_agent. - from hexgate import User + from hexgate import HexgateContext from hexgate.agents.factory import stream_agent agent, handler = _get_agent(api_key) messages = _messages_with_files(input, context) # Bind the run to the HexKit caller's identity. hexgate reads the ContextVar - # set by `async with User(...)` for policy decisions, biscuit attenuation, - # and audit emission. Missing user block = no scoping (the SDK still runs; - # decisions just won't be tagged with a user). + # set by `async with HexgateContext(...)` for policy decisions, biscuit + # attenuation, and audit emission. Missing user block = no scoping (the SDK + # still runs; decisions just won't be tagged with a caller). caller = (context or {}).get("user") or {} user_id = caller.get("id") role = caller.get("role") @@ -170,7 +172,7 @@ async def run_hexgate_agent( yield event.model_dump(mode="json") return - async with User(user_id=user_id, role=role): + async with HexgateContext(user_id=user_id, user_roles=[role] if role else []): async for event in stream_agent(agent, handler, {"messages": messages}): if cancel.is_set(): return # stop producing; the proxy persists the partial text diff --git a/proxy-server/src/platform_backend/routes/chat.py b/proxy-server/src/platform_backend/routes/chat.py index 09dfb68..f76eaa0 100644 --- a/proxy-server/src/platform_backend/routes/chat.py +++ b/proxy-server/src/platform_backend/routes/chat.py @@ -223,8 +223,8 @@ async def post_message( "files": files_payload, # Caller identity. `role` is an opaque string the developer's # agent can interpret however they like (e.g. opening an - # `async with hexgate.User(role=...)` block for policy - # enforcement). NEVER includes email, password hash, or any + # `async with hexgate.HexgateContext(user_roles=[role])` block for + # policy enforcement). NEVER includes email, password hash, or any # internal ids beyond the user uuid. "user": { "id": str(user.id),