hexgate 0.2.2__tar.gz → 0.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. {hexgate-0.2.2/hexgate.egg-info → hexgate-0.2.4}/PKG-INFO +56 -18
  2. hexgate-0.2.2/PKG-INFO → hexgate-0.2.4/README.md +54 -55
  3. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/adapters/google/runner.py +1 -1
  4. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/adapters/langchain/agent.py +8 -5
  5. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/adapters/langchain/wrapper.py +2 -2
  6. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/adapters/openai/runner.py +1 -1
  7. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/adapters/pydantic_ai/agent.py +1 -1
  8. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/adapters/pydantic_ai/wrapper.py +1 -1
  9. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/agents/factory.py +23 -8
  10. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/agents/loader.py +4 -4
  11. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/audit.py +30 -4
  12. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/cli/_common.py +18 -8
  13. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/cli/register/hexgate.py +3 -3
  14. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/cli/register/main.py +2 -2
  15. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/cli/register/models.py +1 -1
  16. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/cli/serve.py +3 -3
  17. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/cloud/__init__.py +1 -1
  18. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/cloud/attenuate.py +1 -1
  19. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/cloud/biscuit.py +1 -1
  20. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/cloud/client.py +7 -7
  21. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/runtime/context.py +12 -9
  22. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/security/binding.py +16 -0
  23. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/security/rego.py +1 -1
  24. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/security/wasm_engine.py +3 -3
  25. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/streaming/__init__.py +1 -1
  26. hexgate-0.2.2/README.md → hexgate-0.2.4/hexgate.egg-info/PKG-INFO +93 -16
  27. {hexgate-0.2.2 → hexgate-0.2.4}/pyproject.toml +2 -2
  28. {hexgate-0.2.2 → hexgate-0.2.4}/LICENSE +0 -0
  29. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/__init__.py +0 -0
  30. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/adapters/__init__.py +0 -0
  31. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/adapters/google/__init__.py +0 -0
  32. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/adapters/google/tools.py +0 -0
  33. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/adapters/google/wrapper.py +0 -0
  34. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/adapters/langchain/__init__.py +0 -0
  35. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/adapters/langchain/tools.py +0 -0
  36. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/adapters/openai/__init__.py +0 -0
  37. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/adapters/openai/tools.py +0 -0
  38. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/adapters/openai/wrapper.py +0 -0
  39. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/adapters/pydantic_ai/__init__.py +0 -0
  40. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/adapters/pydantic_ai/tools.py +0 -0
  41. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/agents/__init__.py +0 -0
  42. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/agents/builtin/__init__.py +0 -0
  43. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/agents/builtin/researcher/agent.yaml +0 -0
  44. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/agents/builtin/researcher/policy.yaml +0 -0
  45. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/agents/builtin/researcher/system.md +0 -0
  46. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/agents/models.py +0 -0
  47. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/agents/prompts/agent_system.md +0 -0
  48. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/bootstrap.py +0 -0
  49. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/cli/__init__.py +0 -0
  50. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/cli/chat.py +0 -0
  51. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/cli/policy/__init__.py +0 -0
  52. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/cli/policy/main.py +0 -0
  53. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/cli/register/__init__.py +0 -0
  54. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/cli/register/google.py +0 -0
  55. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/cli/register/langchain.py +0 -0
  56. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/cli/register/manifest.py +0 -0
  57. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/cli/register/openai.py +0 -0
  58. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/cli/register/pydantic_ai.py +0 -0
  59. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/cli/register/register.py +0 -0
  60. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/cli/state.py +0 -0
  61. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/config/__init__.py +0 -0
  62. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/config/settings.py +0 -0
  63. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/runtime/__init__.py +0 -0
  64. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/runtime/command_policy.py +0 -0
  65. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/runtime/sandbox_runtime.py +0 -0
  66. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/runtime/srt.py +0 -0
  67. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/runtime/workspace.py +0 -0
  68. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/security/__init__.py +0 -0
  69. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/security/bundle.py +0 -0
  70. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/security/constraints.py +0 -0
  71. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/security/decision.py +0 -0
  72. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/security/enforcer.py +0 -0
  73. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/security/errors.py +0 -0
  74. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/security/file_scope.py +0 -0
  75. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/security/models.py +0 -0
  76. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/security/policy.py +0 -0
  77. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/security/policy_set.py +0 -0
  78. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/security/rego_wasm.py +0 -0
  79. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/security/signing.py +0 -0
  80. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/security/source.py +0 -0
  81. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/streaming/events.py +0 -0
  82. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/streaming/normalize.py +0 -0
  83. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/tools/__init__.py +0 -0
  84. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/tools/bash.py +0 -0
  85. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/tools/decorators.py +0 -0
  86. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/tools/fetch.py +0 -0
  87. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/tools/files/__init__.py +0 -0
  88. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/tools/files/_common.py +0 -0
  89. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/tools/files/edit_file.py +0 -0
  90. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/tools/files/glob.py +0 -0
  91. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/tools/files/grep.py +0 -0
  92. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/tools/files/read_file.py +0 -0
  93. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/tools/files/write_file.py +0 -0
  94. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/tools/refund.py +0 -0
  95. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/tools/websearch.py +0 -0
  96. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/tracing/__init__.py +0 -0
  97. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/tracing/langfuse.py +0 -0
  98. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/utils/__init__.py +0 -0
  99. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate/utils/retry.py +0 -0
  100. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate.egg-info/SOURCES.txt +0 -0
  101. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate.egg-info/dependency_links.txt +0 -0
  102. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate.egg-info/entry_points.txt +0 -0
  103. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate.egg-info/requires.txt +0 -0
  104. {hexgate-0.2.2 → hexgate-0.2.4}/hexgate.egg-info/top_level.txt +0 -0
  105. {hexgate-0.2.2 → hexgate-0.2.4}/setup.cfg +0 -0
  106. {hexgate-0.2.2 → hexgate-0.2.4}/tests/test_bootstrap.py +0 -0
  107. {hexgate-0.2.2 → hexgate-0.2.4}/tests/test_demo.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hexgate
3
- Version: 0.2.2
4
- Summary: HexaGate — authorization infrastructure for AI agents (agent runtime + cloud client).
3
+ Version: 0.2.4
4
+ Summary: Hexgate — authorization infrastructure for AI agents (agent runtime + cloud client).
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.13
7
7
  Description-Content-Type: text/markdown
@@ -39,29 +39,38 @@ Dynamic: license-file
39
39
 
40
40
  <div align="center">
41
41
 
42
- # HexaGate
42
+ <img src="./icon.svg" alt="Hexgate" width="96" height="96" />
43
+
44
+ # Hexgate
43
45
 
44
46
  **Authorization infrastructure for AI agents.**
45
47
  Policy enforcement, signed policy bundles, per-request user scope, audit trail — for OpenAI Agents, LangChain, Google ADK, Pydantic AI, or a native runtime.
46
48
 
49
+ [**Website**](https://hexgate.ai) · [Docs](https://docs.hexgate.ai) · [PyPI](https://pypi.org/project/hexgate/) · [Discussions](https://github.com/HexamindOrganisation/hexgate/discussions)
50
+
47
51
  [![PyPI](https://img.shields.io/pypi/v/hexgate?color=blue&logo=pypi&logoColor=white)](https://pypi.org/project/hexgate/)
48
- [![Python](https://img.shields.io/pypi/pyversions/hexgate?logo=python&logoColor=white)](https://pypi.org/project/hexgate/)
49
52
  [![CI](https://github.com/HexamindOrganisation/hexgate/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/HexamindOrganisation/hexgate/actions/workflows/tests.yml)
50
53
  [![Downloads](https://img.shields.io/pypi/dm/hexgate?color=blueviolet)](https://pypi.org/project/hexgate/)
51
54
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
52
55
 
53
- [Quick Start](#-quick-start--local-cli) · [Two paths](#-which-path-do-i-pick) · [Framework adapters](#-framework-agent-wrapping) · [Policy bundles](#-policy-bundles--compile-sign-enforce-wasm) · [User scope](#-user-scope--roles) · [Platform](#-hexagate-platform)
56
+ <br />
57
+
58
+ <img src="./assets/hero.png" alt="Control what your agents do — not just what they say. Policy decisions streaming live from the PolicyEnforcer." />
59
+
60
+ <br />
61
+
62
+ [Quick Start](#-quick-start--local-cli) · [Two paths](#-which-path-do-i-pick) · [Framework adapters](#-framework-agent-wrapping) · [Policy bundles](#-policy-bundles--compile-sign-enforce-wasm) · [User scope](#-user-scope--roles) · [Platform](#-hexgate-platform)
54
63
 
55
64
  </div>
56
65
 
57
66
  ---
58
67
 
59
- ## What is HexaGate?
68
+ ## What is Hexgate?
60
69
 
61
- HexaGate is two things that move together:
70
+ Hexgate is two things that move together:
62
71
 
63
72
  - **`hexgate` — the SDK.** A Python runtime that gates every tool call through a typed `Decision` (allow / deny / approval-required), wraps your existing OpenAI / LangChain / Google ADK / Pydantic AI agent without rewriting it, and threads per-request user identity through tracing + audit.
64
- - **The HexaGate platform** *(optional)* — a FastAPI control plane + React dashboard for editing policy in a browser, minting per-project tokens, watching live decisions stream from a serving agent, and shipping signed WASM policy bundles to production.
73
+ - **The Hexgate platform** *(optional)* — a FastAPI control plane + React dashboard for editing policy in a browser, minting per-project tokens, watching live decisions stream from a serving agent, and shipping signed WASM policy bundles to production.
65
74
 
66
75
  You can use the SDK with nothing else (single-process REPL, YAML on disk). Or plug in the platform when you want auditable decisions in ClickHouse, a shared Playground UI, and live policy edits.
67
76
 
@@ -79,7 +88,7 @@ You can use the SDK with nothing else (single-process REPL, YAML on disk). Or pl
79
88
  ┌────────────────┐ ┌──────────────────┐ ┌────────────────┐
80
89
  │ Local policy │ │ Signed WASM │ │ Audit log │
81
90
  │ (YAML / dir, │ │ bundle from │ │ (ClickHouse │
82
- │ hot reload) │ │ HexaGate cloud │ │ via REST) │
91
+ │ hot reload) │ │ Hexgate cloud │ │ via REST) │
83
92
  └────────────────┘ └──────────────────┘ └────────────────┘
84
93
  ```
85
94
 
@@ -103,7 +112,7 @@ You can use the SDK with nothing else (single-process REPL, YAML on disk). Or pl
103
112
  - [Environment](#-environment)
104
113
  - [Tests & dev tooling](#-tests--dev-tooling)
105
114
  - [CLI reference](#-cli-reference)
106
- - [HexaGate platform](#-hexagate-platform)
115
+ - [Hexgate platform](#-hexgate-platform)
107
116
  - [User scope + roles](#-user-scope--roles)
108
117
  - [Stream results](#-stream-results)
109
118
 
@@ -177,7 +186,7 @@ Both commands accept either a plain agent id (`--agent researcher`) or a uvicorn
177
186
 
178
187
  ## 🚀 Quick Start — Platform
179
188
 
180
- To run the full HexaGate control plane locally (FastAPI backend + dashboard + your local agent serving over WebSocket), you need **three terminals**. The Makefile has a target that prints the recipe:
189
+ To run the full Hexgate control plane locally (FastAPI backend + dashboard + your local agent serving over WebSocket), you need **three terminals**. The Makefile has a target that prints the recipe:
181
190
 
182
191
  ```bash
183
192
  make demo-platform # prints the 3-terminal recipe below
@@ -214,6 +223,31 @@ make dashboard-install # pnpm install inside platform/dashboard/
214
223
 
215
224
  Then open http://localhost:5173/playground — type a message, watch the live stream of tool calls and policy decisions from your local agent.
216
225
 
226
+ ### Control-plane database (SQLite / Postgres)
227
+
228
+ `make platform-api` uses a local **SQLite** file — zero setup, fine for
229
+ dev and the test suite. Set `DATABASE_URL` to run on **Postgres** instead
230
+ (what deployments use); bare `postgres://` URLs are normalized to the
231
+ `asyncpg` driver. A local Postgres is in the dev compose:
232
+
233
+ ```bash
234
+ make platform-api-pg # starts Postgres (Docker) + runs the API against it
235
+ # equivalent to:
236
+ make postgres-up # start Postgres, wait until healthy
237
+ DATABASE_URL=postgresql+asyncpg://hexgate:hexgate-dev-password@localhost:5433/hexgate make platform-api
238
+ make postgres-reset # wipe ONLY the Postgres data volume
239
+ ```
240
+
241
+ No migration system: the schema is created with `create_all`, so a model
242
+ change means resetting the volume (`make postgres-reset`), not migrating —
243
+ data is treated as disposable. A managed Postgres (e.g. Scaleway) needs SSL
244
+ in the DSN, e.g. `…/hexgate?ssl=require`.
245
+
246
+ `DATABASE_URL` is read from the real environment or `platform/api/.env`
247
+ (see `.env.sample`). The opt-in `tests/test_postgres_smoke.py` is the only
248
+ test that exercises the Postgres path (the rest run on SQLite); it runs
249
+ when `DATABASE_URL` points at Postgres.
250
+
217
251
  ### Audit log (ClickHouse)
218
252
 
219
253
  Policy decisions are written to a local ClickHouse instance for the audit dashboard. Requires Docker.
@@ -660,7 +694,7 @@ What happens under the hood:
660
694
 
661
695
  Working scripts in `examples/`:
662
696
 
663
- - `examples/customer_bot.py` — canonical HexaGate path: `create_agent(...)` + the dashboard register/serve loop end-to-end.
697
+ - `examples/customer_bot.py` — canonical Hexgate path: `create_agent(...)` + the dashboard register/serve loop end-to-end.
664
698
  - `examples/openai_demo.py` — `HexgateRunner` (OpenAI Agents SDK) end-to-end.
665
699
  - `examples/google_demo.py` — `HexgateRunner` (Google ADK) end-to-end with `InMemorySessionService`.
666
700
  - `examples/pydantic_ai_demo.py` — `wrap_pydantic_agent` (Pydantic AI) end-to-end.
@@ -795,7 +829,7 @@ That means the same agent code can stay simple in development, while deployment
795
829
 
796
830
  ## 🧩 Policy Bundles — Compile, Sign, Enforce (WASM)
797
831
 
798
- HexaGate has **two policy enforcement engines** that return identical decisions (there's a parity test suite that proves it):
832
+ Hexgate has **two policy enforcement engines** that return identical decisions (there's a parity test suite that proves it):
799
833
 
800
834
  - **pydantic** (default) — evaluates constraints in-process. Zero setup; this is what every example above uses.
801
835
  - **WASM** — compiles `policy.yaml` → Rego → a WebAssembly module evaluated via `wasmtime`. This is the path production ships: one compiled artifact, byte-for-byte reproducible, cryptographically signed by the platform.
@@ -1131,7 +1165,7 @@ hexgate chat --use examples/research_agents.py --agent update_researcher --appro
1131
1165
 
1132
1166
  ### `hexgate register` — push a manifest to the platform
1133
1167
 
1134
- Register a code-defined agent's manifest with the HexaGate platform. `--agent`
1168
+ Register a code-defined agent's manifest with the Hexgate platform. `--agent`
1135
1169
  takes a Python import path of the form `module.path:attribute`, the same shape
1136
1170
  as ASGI/WSGI entrypoints. The CLI imports the module, grabs the agent object,
1137
1171
  and POSTs its manifest to `${HEXGATE_API_URL}/v1/agents` using
@@ -1170,7 +1204,7 @@ system prompt directly off the object. No flags needed.
1170
1204
  `--system-prompt` accepts either a literal string or a path to a `.md` /
1171
1205
  `.txt` / `.jinja` file (read as text at register time).
1172
1206
 
1173
- Supported frameworks: OpenAI Agents SDK, Google ADK, Pydantic AI, LangChain/LangGraph, HexaGate agents.
1207
+ Supported frameworks: OpenAI Agents SDK, Google ADK, Pydantic AI, LangChain/LangGraph, Hexgate agents.
1174
1208
 
1175
1209
  ### `hexgate serve` — bridge a local agent to the platform's relay
1176
1210
 
@@ -1207,7 +1241,7 @@ print(manifest.model_dump())
1207
1241
  ```
1208
1242
 
1209
1243
  `create_manifest` dispatches on the framework of `agent`. The supported
1210
- types are the same set `hexgate register` accepts: HexaGate, OpenAI Agents
1244
+ types are the same set `hexgate register` accepts: Hexgate, OpenAI Agents
1211
1245
  SDK, Google ADK, Pydantic AI, and LangChain/LangGraph compiled graphs.
1212
1246
  For LangGraph you must pass `tools=` explicitly, and may pass `model=` /
1213
1247
  `system_prompt=`, since compiled graphs don't expose those fields after
@@ -1217,7 +1251,7 @@ The return value is an `AgentManifest` (a Pydantic model, also re-exported
1217
1251
  from `hexgate` for type annotations) — the same schema the platform
1218
1252
  stores and the dashboard renders.
1219
1253
 
1220
- ## 🌐 HexaGate Platform
1254
+ ## 🌐 Hexgate Platform
1221
1255
 
1222
1256
  The `platform/` directory contains an optional control plane that hosts agent definitions, dev tokens, and a live debug surface. The SDK works fully without it (`load_local_agent`, `load_builtin_agent` keep their existing semantics) — but with it you get:
1223
1257
 
@@ -1336,7 +1370,7 @@ the name from the loaded agent's `.name` attribute — no env var needed.
1336
1370
 
1337
1371
  ## 👤 User Scope + Roles
1338
1372
 
1339
- Real backends serve many users, and different users get different capabilities. HexaGate splits that into two pieces:
1373
+ Real backends serve many users, and different users get different capabilities. Hexgate splits that into two pieces:
1340
1374
 
1341
1375
  - **`User`** — the per-request scope. Marks "this invocation acts on behalf of alice, in role X." Async context manager; pushes a fact-bearing Biscuit through the agent runtime.
1342
1376
  - **Role policies** — one `policy.yaml` per role, optionally inheriting from a base mixin. The runtime picks the right one at call time based on the active `User.role`.
@@ -1486,3 +1520,7 @@ async for event in stream_agent(agent, handler, "latest AI breakthroughs"):
1486
1520
  - assistant text deltas
1487
1521
  - tool lifecycle
1488
1522
  - final run completion
1523
+
1524
+ ---
1525
+
1526
+ If Hexgate looks useful, [give it a ⭐ on GitHub](https://github.com/HexamindOrganisation/hexgate) — it helps more than you'd think. Built by [Hexamind](https://hexgate.ai).
@@ -1,67 +1,37 @@
1
- Metadata-Version: 2.4
2
- Name: hexgate
3
- Version: 0.2.2
4
- Summary: HexaGate — authorization infrastructure for AI agents (agent runtime + cloud client).
5
- License-Expression: MIT
6
- Requires-Python: >=3.13
7
- Description-Content-Type: text/markdown
8
- License-File: LICENSE
9
- Requires-Dist: bashlex>=0.18
10
- Requires-Dist: biscuit-python>=0.4
11
- Requires-Dist: cryptography>=42
12
- Requires-Dist: httpx>=0.28.1
13
- Requires-Dist: langchain
14
- Requires-Dist: langchain-openai
15
- Requires-Dist: langchain-core
16
- Requires-Dist: langfuse
17
- Requires-Dist: pydantic>=2.12.4
18
- Requires-Dist: python-dotenv>=1.1.1
19
- Requires-Dist: pyyaml>=6.0.2
20
- Requires-Dist: rich>=13.9.4
21
- Requires-Dist: websockets>=13.0
22
- Requires-Dist: openai-agents>=0.0.10
23
- Requires-Dist: langgraph>=0.2
24
- Requires-Dist: nest_asyncio>=1.6
25
- Requires-Dist: openinference-instrumentation-openai-agents>=0.1
26
- Requires-Dist: google-adk>=1.0
27
- Requires-Dist: google-genai>=1.0
28
- Requires-Dist: litellm>=1.50
29
- Requires-Dist: openinference-instrumentation-google-adk>=0.1.11
30
- Requires-Dist: pydantic-ai-slim>=1.88.0
31
- Requires-Dist: wasmtime>=20.0
32
- Provides-Extra: dev
33
- Requires-Dist: ipykernel; extra == "dev"
34
- Requires-Dist: jupyter; extra == "dev"
35
- Requires-Dist: pytest>=8.4.1; extra == "dev"
36
- Requires-Dist: pytest-asyncio>=1.0.0; extra == "dev"
37
- Requires-Dist: ruff>=0.12.2; extra == "dev"
38
- Dynamic: license-file
39
-
40
1
  <div align="center">
41
2
 
42
- # HexaGate
3
+ <img src="./icon.svg" alt="Hexgate" width="96" height="96" />
4
+
5
+ # Hexgate
43
6
 
44
7
  **Authorization infrastructure for AI agents.**
45
8
  Policy enforcement, signed policy bundles, per-request user scope, audit trail — for OpenAI Agents, LangChain, Google ADK, Pydantic AI, or a native runtime.
46
9
 
10
+ [**Website**](https://hexgate.ai) · [Docs](https://docs.hexgate.ai) · [PyPI](https://pypi.org/project/hexgate/) · [Discussions](https://github.com/HexamindOrganisation/hexgate/discussions)
11
+
47
12
  [![PyPI](https://img.shields.io/pypi/v/hexgate?color=blue&logo=pypi&logoColor=white)](https://pypi.org/project/hexgate/)
48
- [![Python](https://img.shields.io/pypi/pyversions/hexgate?logo=python&logoColor=white)](https://pypi.org/project/hexgate/)
49
13
  [![CI](https://github.com/HexamindOrganisation/hexgate/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/HexamindOrganisation/hexgate/actions/workflows/tests.yml)
50
14
  [![Downloads](https://img.shields.io/pypi/dm/hexgate?color=blueviolet)](https://pypi.org/project/hexgate/)
51
15
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
52
16
 
53
- [Quick Start](#-quick-start--local-cli) · [Two paths](#-which-path-do-i-pick) · [Framework adapters](#-framework-agent-wrapping) · [Policy bundles](#-policy-bundles--compile-sign-enforce-wasm) · [User scope](#-user-scope--roles) · [Platform](#-hexagate-platform)
17
+ <br />
18
+
19
+ <img src="./assets/hero.png" alt="Control what your agents do — not just what they say. Policy decisions streaming live from the PolicyEnforcer." />
20
+
21
+ <br />
22
+
23
+ [Quick Start](#-quick-start--local-cli) · [Two paths](#-which-path-do-i-pick) · [Framework adapters](#-framework-agent-wrapping) · [Policy bundles](#-policy-bundles--compile-sign-enforce-wasm) · [User scope](#-user-scope--roles) · [Platform](#-hexgate-platform)
54
24
 
55
25
  </div>
56
26
 
57
27
  ---
58
28
 
59
- ## What is HexaGate?
29
+ ## What is Hexgate?
60
30
 
61
- HexaGate is two things that move together:
31
+ Hexgate is two things that move together:
62
32
 
63
33
  - **`hexgate` — the SDK.** A Python runtime that gates every tool call through a typed `Decision` (allow / deny / approval-required), wraps your existing OpenAI / LangChain / Google ADK / Pydantic AI agent without rewriting it, and threads per-request user identity through tracing + audit.
64
- - **The HexaGate platform** *(optional)* — a FastAPI control plane + React dashboard for editing policy in a browser, minting per-project tokens, watching live decisions stream from a serving agent, and shipping signed WASM policy bundles to production.
34
+ - **The Hexgate platform** *(optional)* — a FastAPI control plane + React dashboard for editing policy in a browser, minting per-project tokens, watching live decisions stream from a serving agent, and shipping signed WASM policy bundles to production.
65
35
 
66
36
  You can use the SDK with nothing else (single-process REPL, YAML on disk). Or plug in the platform when you want auditable decisions in ClickHouse, a shared Playground UI, and live policy edits.
67
37
 
@@ -79,7 +49,7 @@ You can use the SDK with nothing else (single-process REPL, YAML on disk). Or pl
79
49
  ┌────────────────┐ ┌──────────────────┐ ┌────────────────┐
80
50
  │ Local policy │ │ Signed WASM │ │ Audit log │
81
51
  │ (YAML / dir, │ │ bundle from │ │ (ClickHouse │
82
- │ hot reload) │ │ HexaGate cloud │ │ via REST) │
52
+ │ hot reload) │ │ Hexgate cloud │ │ via REST) │
83
53
  └────────────────┘ └──────────────────┘ └────────────────┘
84
54
  ```
85
55
 
@@ -103,7 +73,7 @@ You can use the SDK with nothing else (single-process REPL, YAML on disk). Or pl
103
73
  - [Environment](#-environment)
104
74
  - [Tests & dev tooling](#-tests--dev-tooling)
105
75
  - [CLI reference](#-cli-reference)
106
- - [HexaGate platform](#-hexagate-platform)
76
+ - [Hexgate platform](#-hexgate-platform)
107
77
  - [User scope + roles](#-user-scope--roles)
108
78
  - [Stream results](#-stream-results)
109
79
 
@@ -177,7 +147,7 @@ Both commands accept either a plain agent id (`--agent researcher`) or a uvicorn
177
147
 
178
148
  ## 🚀 Quick Start — Platform
179
149
 
180
- To run the full HexaGate control plane locally (FastAPI backend + dashboard + your local agent serving over WebSocket), you need **three terminals**. The Makefile has a target that prints the recipe:
150
+ To run the full Hexgate control plane locally (FastAPI backend + dashboard + your local agent serving over WebSocket), you need **three terminals**. The Makefile has a target that prints the recipe:
181
151
 
182
152
  ```bash
183
153
  make demo-platform # prints the 3-terminal recipe below
@@ -214,6 +184,31 @@ make dashboard-install # pnpm install inside platform/dashboard/
214
184
 
215
185
  Then open http://localhost:5173/playground — type a message, watch the live stream of tool calls and policy decisions from your local agent.
216
186
 
187
+ ### Control-plane database (SQLite / Postgres)
188
+
189
+ `make platform-api` uses a local **SQLite** file — zero setup, fine for
190
+ dev and the test suite. Set `DATABASE_URL` to run on **Postgres** instead
191
+ (what deployments use); bare `postgres://` URLs are normalized to the
192
+ `asyncpg` driver. A local Postgres is in the dev compose:
193
+
194
+ ```bash
195
+ make platform-api-pg # starts Postgres (Docker) + runs the API against it
196
+ # equivalent to:
197
+ make postgres-up # start Postgres, wait until healthy
198
+ DATABASE_URL=postgresql+asyncpg://hexgate:hexgate-dev-password@localhost:5433/hexgate make platform-api
199
+ make postgres-reset # wipe ONLY the Postgres data volume
200
+ ```
201
+
202
+ No migration system: the schema is created with `create_all`, so a model
203
+ change means resetting the volume (`make postgres-reset`), not migrating —
204
+ data is treated as disposable. A managed Postgres (e.g. Scaleway) needs SSL
205
+ in the DSN, e.g. `…/hexgate?ssl=require`.
206
+
207
+ `DATABASE_URL` is read from the real environment or `platform/api/.env`
208
+ (see `.env.sample`). The opt-in `tests/test_postgres_smoke.py` is the only
209
+ test that exercises the Postgres path (the rest run on SQLite); it runs
210
+ when `DATABASE_URL` points at Postgres.
211
+
217
212
  ### Audit log (ClickHouse)
218
213
 
219
214
  Policy decisions are written to a local ClickHouse instance for the audit dashboard. Requires Docker.
@@ -660,7 +655,7 @@ What happens under the hood:
660
655
 
661
656
  Working scripts in `examples/`:
662
657
 
663
- - `examples/customer_bot.py` — canonical HexaGate path: `create_agent(...)` + the dashboard register/serve loop end-to-end.
658
+ - `examples/customer_bot.py` — canonical Hexgate path: `create_agent(...)` + the dashboard register/serve loop end-to-end.
664
659
  - `examples/openai_demo.py` — `HexgateRunner` (OpenAI Agents SDK) end-to-end.
665
660
  - `examples/google_demo.py` — `HexgateRunner` (Google ADK) end-to-end with `InMemorySessionService`.
666
661
  - `examples/pydantic_ai_demo.py` — `wrap_pydantic_agent` (Pydantic AI) end-to-end.
@@ -795,7 +790,7 @@ That means the same agent code can stay simple in development, while deployment
795
790
 
796
791
  ## 🧩 Policy Bundles — Compile, Sign, Enforce (WASM)
797
792
 
798
- HexaGate has **two policy enforcement engines** that return identical decisions (there's a parity test suite that proves it):
793
+ Hexgate has **two policy enforcement engines** that return identical decisions (there's a parity test suite that proves it):
799
794
 
800
795
  - **pydantic** (default) — evaluates constraints in-process. Zero setup; this is what every example above uses.
801
796
  - **WASM** — compiles `policy.yaml` → Rego → a WebAssembly module evaluated via `wasmtime`. This is the path production ships: one compiled artifact, byte-for-byte reproducible, cryptographically signed by the platform.
@@ -1131,7 +1126,7 @@ hexgate chat --use examples/research_agents.py --agent update_researcher --appro
1131
1126
 
1132
1127
  ### `hexgate register` — push a manifest to the platform
1133
1128
 
1134
- Register a code-defined agent's manifest with the HexaGate platform. `--agent`
1129
+ Register a code-defined agent's manifest with the Hexgate platform. `--agent`
1135
1130
  takes a Python import path of the form `module.path:attribute`, the same shape
1136
1131
  as ASGI/WSGI entrypoints. The CLI imports the module, grabs the agent object,
1137
1132
  and POSTs its manifest to `${HEXGATE_API_URL}/v1/agents` using
@@ -1170,7 +1165,7 @@ system prompt directly off the object. No flags needed.
1170
1165
  `--system-prompt` accepts either a literal string or a path to a `.md` /
1171
1166
  `.txt` / `.jinja` file (read as text at register time).
1172
1167
 
1173
- Supported frameworks: OpenAI Agents SDK, Google ADK, Pydantic AI, LangChain/LangGraph, HexaGate agents.
1168
+ Supported frameworks: OpenAI Agents SDK, Google ADK, Pydantic AI, LangChain/LangGraph, Hexgate agents.
1174
1169
 
1175
1170
  ### `hexgate serve` — bridge a local agent to the platform's relay
1176
1171
 
@@ -1207,7 +1202,7 @@ print(manifest.model_dump())
1207
1202
  ```
1208
1203
 
1209
1204
  `create_manifest` dispatches on the framework of `agent`. The supported
1210
- types are the same set `hexgate register` accepts: HexaGate, OpenAI Agents
1205
+ types are the same set `hexgate register` accepts: Hexgate, OpenAI Agents
1211
1206
  SDK, Google ADK, Pydantic AI, and LangChain/LangGraph compiled graphs.
1212
1207
  For LangGraph you must pass `tools=` explicitly, and may pass `model=` /
1213
1208
  `system_prompt=`, since compiled graphs don't expose those fields after
@@ -1217,7 +1212,7 @@ The return value is an `AgentManifest` (a Pydantic model, also re-exported
1217
1212
  from `hexgate` for type annotations) — the same schema the platform
1218
1213
  stores and the dashboard renders.
1219
1214
 
1220
- ## 🌐 HexaGate Platform
1215
+ ## 🌐 Hexgate Platform
1221
1216
 
1222
1217
  The `platform/` directory contains an optional control plane that hosts agent definitions, dev tokens, and a live debug surface. The SDK works fully without it (`load_local_agent`, `load_builtin_agent` keep their existing semantics) — but with it you get:
1223
1218
 
@@ -1336,7 +1331,7 @@ the name from the loaded agent's `.name` attribute — no env var needed.
1336
1331
 
1337
1332
  ## 👤 User Scope + Roles
1338
1333
 
1339
- Real backends serve many users, and different users get different capabilities. HexaGate splits that into two pieces:
1334
+ Real backends serve many users, and different users get different capabilities. Hexgate splits that into two pieces:
1340
1335
 
1341
1336
  - **`User`** — the per-request scope. Marks "this invocation acts on behalf of alice, in role X." Async context manager; pushes a fact-bearing Biscuit through the agent runtime.
1342
1337
  - **Role policies** — one `policy.yaml` per role, optionally inheriting from a base mixin. The runtime picks the right one at call time based on the active `User.role`.
@@ -1486,3 +1481,7 @@ async for event in stream_agent(agent, handler, "latest AI breakthroughs"):
1486
1481
  - assistant text deltas
1487
1482
  - tool lifecycle
1488
1483
  - final run completion
1484
+
1485
+ ---
1486
+
1487
+ If Hexgate looks useful, [give it a ⭐ on GitHub](https://github.com/HexamindOrganisation/hexgate) — it helps more than you'd think. Built by [Hexamind](https://hexgate.ai).
@@ -21,7 +21,7 @@ from hexgate.runtime import User
21
21
 
22
22
 
23
23
  class HexgateRunner:
24
- """Runner for Google ADK agents with HexaGate tool policy and observability."""
24
+ """Runner for Google ADK agents with Hexgate tool policy and observability."""
25
25
 
26
26
  def __init__(
27
27
  self,
@@ -1,8 +1,8 @@
1
- """Proxy around a pre-built ``CompiledStateGraph`` for HexaGate-aware calls."""
1
+ """Proxy around a pre-built ``CompiledStateGraph`` for Hexgate-aware calls."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import TYPE_CHECKING, Any, AsyncIterator, Iterator
5
+ from typing import TYPE_CHECKING, Any, AsyncIterator, Iterator, Literal
6
6
 
7
7
  from langchain_core.runnables import RunnableConfig
8
8
  from langfuse import get_client, propagate_attributes
@@ -60,7 +60,7 @@ class HexgateLangchainAgent:
60
60
  }
61
61
 
62
62
  def _with_callbacks(self, config: RunnableConfig | None) -> RunnableConfig:
63
- """Append the HexaGate callback handler to ``config['callbacks']``."""
63
+ """Append the Hexgate callback handler to ``config['callbacks']``."""
64
64
  merged: RunnableConfig = dict(config) if config else {}
65
65
  callbacks = list(merged.get("callbacks") or [])
66
66
  if self._callback_handler not in callbacks:
@@ -134,10 +134,10 @@ class HexgateLangchainAgent:
134
134
  async def astream_events(
135
135
  self,
136
136
  input: dict[str, Any],
137
- version: str,
138
137
  *,
139
138
  user: User,
140
139
  config: RunnableConfig | None = None,
140
+ version: Literal["v1", "v2"] = "v2",
141
141
  **kwargs: Any,
142
142
  ) -> AsyncIterator[dict[str, Any]]:
143
143
  """Stream the agent events asynchronously inside a User scope."""
@@ -145,7 +145,10 @@ class HexgateLangchainAgent:
145
145
  async with user:
146
146
  with propagate_attributes(**self._propagate_kwargs(user, "astream_events")):
147
147
  async for event in self._agent.astream_events(
148
- input, version, config=self._with_callbacks(config), **kwargs
148
+ input,
149
+ config=self._with_callbacks(config),
150
+ version=version,
151
+ **kwargs,
149
152
  ):
150
153
  yield event
151
154
 
@@ -1,5 +1,5 @@
1
1
  """BYO-graph entry point: retrofit a pre-built ``CompiledStateGraph`` with
2
- HexaGate policy. Tools are mutated in place so the graph keeps its
2
+ Hexgate policy. Tools are mutated in place so the graph keeps its
3
3
  references; the returned :class:`HexgateLangchainAgent` opens a User
4
4
  scope + Langfuse propagation per call. For the manifest-driven path,
5
5
  use :func:`hexgate.enforce_policy` instead.
@@ -28,7 +28,7 @@ def wrap_langchain_agent(
28
28
  tools: list[BaseTool],
29
29
  api_key: str | None = None,
30
30
  ) -> HexgateLangchainAgent:
31
- """Wrap a pre-built LangGraph agent with HexaGate policy enforcement.
31
+ """Wrap a pre-built LangGraph agent with Hexgate policy enforcement.
32
32
 
33
33
  Mutates ``tools`` in place so the graph keeps its references.
34
34
  The returned proxy takes ``user`` per invocation; role resolves at
@@ -32,7 +32,7 @@ from hexgate.security.enforcer import build_enforcer
32
32
 
33
33
 
34
34
  class HexgateRunner:
35
- """Runner for OpenAI agents with HexaGate tool policy and observability."""
35
+ """Runner for OpenAI agents with Hexgate tool policy and observability."""
36
36
 
37
37
  def __init__(self, api_key: str | None = None):
38
38
  self.api_key = api_key or os.getenv("HEXGATE_KEY")
@@ -1,4 +1,4 @@
1
- """Proxy around a pydantic_ai ``Agent`` for HexaGate-aware calls."""
1
+ """Proxy around a pydantic_ai ``Agent`` for Hexgate-aware calls."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -49,7 +49,7 @@ def wrap_pydantic_agent(
49
49
  agent: Agent,
50
50
  api_key: str | None = None,
51
51
  ) -> HexgatePydanticAgent:
52
- """Wrap a pydantic_ai agent with HexaGate policy + observability.
52
+ """Wrap a pydantic_ai agent with Hexgate policy + observability.
53
53
 
54
54
  Returns a :class:`HexgatePydanticAgent` backed by a clone of the
55
55
  caller's ``agent``; the original is not mutated. The proxy takes
@@ -190,6 +190,13 @@ def extract_input_text(input: AgentInput) -> str:
190
190
  return _extract_query_from_messages(input)
191
191
 
192
192
 
193
+ # One-shot dedupe for the "local agent with active User scope" warning
194
+ # inside `_resolve_user_facts`. The dashboard playground hits this once
195
+ # per turn (and the platform-served sessions never hit it at all), so
196
+ # without a flag the warning floods the log on every chat message.
197
+ _warned_local_agent_user_scope: bool = False
198
+
199
+
193
200
  def _resolve_user_facts(agent: HexgateAgent) -> dict[str, list[str | int]] | None:
194
201
  """Lazily attenuate when a :class:`User` scope is active.
195
202
 
@@ -206,14 +213,22 @@ def _resolve_user_facts(agent: HexgateAgent) -> dict[str, list[str | int]] | Non
206
213
  client = agent.hexgate_client
207
214
  if client is None:
208
215
  # Local agent or test stub — User scope is set but there's nothing to
209
- # attenuate against. Surface a single warning so devs see why their
210
- # `requires_user` predicate isn't firing on a local-loaded agent.
211
- import logging
212
-
213
- logging.getLogger(__name__).warning(
214
- "User scope active but agent has no hexgate_client; "
215
- "biscuit_facts will be empty (use load_hexgate_agent for attenuation)"
216
- )
216
+ # attenuate against. Surface a *single* warning so devs see why their
217
+ # `requires_user` predicate isn't firing on a local-loaded agent, then
218
+ # stay quiet: every subsequent turn would re-fire the same message
219
+ # otherwise (3-5x per chat session in the dashboard playground was
220
+ # the symptom).
221
+ global _warned_local_agent_user_scope
222
+ if not _warned_local_agent_user_scope:
223
+ import logging
224
+
225
+ logging.getLogger(__name__).warning(
226
+ "User scope active but agent has no hexgate_client; "
227
+ "biscuit_facts will be empty (use load_hexgate_agent for "
228
+ "attenuation). Subsequent occurrences in this process are "
229
+ "suppressed."
230
+ )
231
+ _warned_local_agent_user_scope = True
217
232
  return None
218
233
  from hexgate.cloud.attenuate import attenuate_for_user
219
234
  from hexgate.cloud.biscuit import (
@@ -462,10 +462,10 @@ def load_hexgate_agent(
462
462
  approval_handler: ApprovalHandler | None = None,
463
463
  decision_observer: "DecisionObserver | None" = None,
464
464
  ) -> tuple[AgentGraph, CallbackHandler]:
465
- """Fetch an agent from HexaGate and return it with policy enforcement applied.
465
+ """Fetch an agent from Hexgate and return it with policy enforcement applied.
466
466
 
467
467
  Mirrors `load_local_agent` but sources the three YAMLs (agent, policy, system)
468
- from the HexaGate API instead of disk. Tool resolution and enforcement are
468
+ from the Hexgate API instead of disk. Tool resolution and enforcement are
469
469
  identical — only the bytes' origin differs.
470
470
 
471
471
  ``name`` is required. The Phase-7 env-var fallback chain
@@ -555,7 +555,7 @@ def load_agent(
555
555
  approval_handler: ApprovalHandler | None = None,
556
556
  decision_observer: "DecisionObserver | None" = None,
557
557
  ) -> tuple[AgentGraph, CallbackHandler]:
558
- """Load an agent from HexaGate (when HEXGATE_KEY is set), local, or builtin.
558
+ """Load an agent from Hexgate (when HEXGATE_KEY is set), local, or builtin.
559
559
 
560
560
  ``name`` is required for every path post-Phase 7 — the
561
561
  HEXGATE_AGENT_NAME env-var fallback was removed when ``hexgate
@@ -584,7 +584,7 @@ def load_agent(
584
584
  decision_observer=decision_observer,
585
585
  )
586
586
  if name is None:
587
- raise ValueError("load_agent() requires a name when not using HexaGate Cloud")
587
+ raise ValueError("load_agent() requires a name when not using Hexgate Cloud")
588
588
  source = resolve_agent_source(name, base_dir)
589
589
  if source == "local":
590
590
  return load_local_agent(
@@ -149,7 +149,13 @@ class AuditSender:
149
149
  # latch onto the first loop that drives them and reject any other
150
150
  # (e.g. a second asyncio.run()). Build them eagerly so configure()
151
151
  # stays sync, but track the loop and rebuild if it rotates.
152
- self._loop: asyncio.AbstractEventLoop | None = None
152
+ #
153
+ # Capture the build-time loop so emit() can reach it from an executor
154
+ # thread (a sync tool under run_in_executor has no loop of its own).
155
+ try:
156
+ self._loop: asyncio.AbstractEventLoop | None = asyncio.get_running_loop()
157
+ except RuntimeError:
158
+ self._loop = None
153
159
  self._semaphore = asyncio.Semaphore(max_in_flight)
154
160
  self._client: httpx.AsyncClient | None = self._new_client()
155
161
  self._tasks: set[asyncio.Task[None]] = set()
@@ -182,11 +188,31 @@ class AuditSender:
182
188
  try:
183
189
  loop = asyncio.get_running_loop()
184
190
  except RuntimeError:
185
- if not self._warned_no_loop:
186
- _log.warning("audit emit called without a running event loop; skipping")
187
- self._warned_no_loop = True
191
+ # Called off-loop (sync tool on a run_in_executor thread): route
192
+ # to the build-time loop instead of dropping the event.
193
+ loop = self._loop
194
+ if loop is None or loop.is_closed():
195
+ if not self._warned_no_loop:
196
+ _log.warning(
197
+ "audit emit called with no running loop and no live "
198
+ "bound loop; skipping"
199
+ )
200
+ self._warned_no_loop = True
201
+ return
202
+ try:
203
+ loop.call_soon_threadsafe(self._spawn_send, event)
204
+ except RuntimeError:
205
+ pass # loop torn down between the is_closed() check and the call
188
206
  return
189
207
  self._ensure_loop_state(loop)
208
+ self._spawn_send(event)
209
+
210
+ def _spawn_send(self, event: AuditEvent) -> None:
211
+ """Create the send task. MUST run on the bound loop's thread —
212
+ ``create_task`` and the loop-bound semaphore require the running loop.
213
+ Reached on-loop from :meth:`emit`, or via ``call_soon_threadsafe``."""
214
+ if self._closing or self._client is None:
215
+ return
190
216
  if self._semaphore.locked():
191
217
  self._dropped += 1
192
218
  if self._dropped % 100 == 1: