Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)) |

---

Expand Down
3 changes: 2 additions & 1 deletion demo-users.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion demo/agent-server/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 3 additions & 1 deletion demo/agent-server/src/agent_server/agents/shared/hr/hr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
37 changes: 22 additions & 15 deletions demo/agent-server/src/agent_server/agents/shared/hr/hr_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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


Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading
Loading