hexgate 0.2.3__tar.gz → 0.2.5__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.3/hexgate.egg-info → hexgate-0.2.5}/PKG-INFO +28 -1
  2. hexgate-0.2.3/PKG-INFO → hexgate-0.2.5/README.md +26 -39
  3. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/adapters/langchain/agent.py +6 -3
  4. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/agents/factory.py +23 -8
  5. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/audit.py +30 -4
  6. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cli/_common.py +17 -7
  7. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cli/register/hexgate.py +1 -1
  8. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cli/register/models.py +1 -1
  9. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/runtime/context.py +12 -9
  10. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/security/__init__.py +2 -0
  11. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/security/binding.py +47 -2
  12. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/security/source.py +135 -40
  13. hexgate-0.2.3/README.md → hexgate-0.2.5/hexgate.egg-info/PKG-INFO +66 -0
  14. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate.egg-info/requires.txt +1 -0
  15. {hexgate-0.2.3 → hexgate-0.2.5}/pyproject.toml +27 -1
  16. {hexgate-0.2.3 → hexgate-0.2.5}/LICENSE +0 -0
  17. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/__init__.py +0 -0
  18. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/adapters/__init__.py +0 -0
  19. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/adapters/google/__init__.py +0 -0
  20. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/adapters/google/runner.py +0 -0
  21. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/adapters/google/tools.py +0 -0
  22. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/adapters/google/wrapper.py +0 -0
  23. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/adapters/langchain/__init__.py +0 -0
  24. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/adapters/langchain/tools.py +0 -0
  25. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/adapters/langchain/wrapper.py +0 -0
  26. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/adapters/openai/__init__.py +0 -0
  27. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/adapters/openai/runner.py +0 -0
  28. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/adapters/openai/tools.py +0 -0
  29. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/adapters/openai/wrapper.py +0 -0
  30. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/adapters/pydantic_ai/__init__.py +0 -0
  31. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/adapters/pydantic_ai/agent.py +0 -0
  32. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/adapters/pydantic_ai/tools.py +0 -0
  33. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/adapters/pydantic_ai/wrapper.py +0 -0
  34. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/agents/__init__.py +0 -0
  35. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/agents/builtin/__init__.py +0 -0
  36. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/agents/builtin/researcher/agent.yaml +0 -0
  37. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/agents/builtin/researcher/policy.yaml +0 -0
  38. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/agents/builtin/researcher/system.md +0 -0
  39. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/agents/loader.py +0 -0
  40. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/agents/models.py +0 -0
  41. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/agents/prompts/agent_system.md +0 -0
  42. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/bootstrap.py +0 -0
  43. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cli/__init__.py +0 -0
  44. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cli/chat.py +0 -0
  45. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cli/policy/__init__.py +0 -0
  46. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cli/policy/main.py +0 -0
  47. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cli/register/__init__.py +0 -0
  48. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cli/register/google.py +0 -0
  49. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cli/register/langchain.py +0 -0
  50. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cli/register/main.py +0 -0
  51. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cli/register/manifest.py +0 -0
  52. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cli/register/openai.py +0 -0
  53. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cli/register/pydantic_ai.py +0 -0
  54. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cli/register/register.py +0 -0
  55. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cli/serve.py +0 -0
  56. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cli/state.py +0 -0
  57. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cloud/__init__.py +0 -0
  58. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cloud/attenuate.py +0 -0
  59. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cloud/biscuit.py +0 -0
  60. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cloud/client.py +0 -0
  61. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/config/__init__.py +0 -0
  62. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/config/settings.py +0 -0
  63. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/runtime/__init__.py +0 -0
  64. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/runtime/command_policy.py +0 -0
  65. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/runtime/sandbox_runtime.py +0 -0
  66. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/runtime/srt.py +0 -0
  67. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/runtime/workspace.py +0 -0
  68. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/security/bundle.py +0 -0
  69. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/security/constraints.py +0 -0
  70. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/security/decision.py +0 -0
  71. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/security/enforcer.py +0 -0
  72. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/security/errors.py +0 -0
  73. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/security/file_scope.py +0 -0
  74. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/security/models.py +0 -0
  75. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/security/policy.py +0 -0
  76. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/security/policy_set.py +0 -0
  77. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/security/rego.py +0 -0
  78. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/security/rego_wasm.py +0 -0
  79. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/security/signing.py +0 -0
  80. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/security/wasm_engine.py +0 -0
  81. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/streaming/__init__.py +0 -0
  82. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/streaming/events.py +0 -0
  83. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/streaming/normalize.py +0 -0
  84. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/tools/__init__.py +0 -0
  85. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/tools/bash.py +0 -0
  86. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/tools/decorators.py +0 -0
  87. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/tools/fetch.py +0 -0
  88. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/tools/files/__init__.py +0 -0
  89. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/tools/files/_common.py +0 -0
  90. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/tools/files/edit_file.py +0 -0
  91. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/tools/files/glob.py +0 -0
  92. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/tools/files/grep.py +0 -0
  93. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/tools/files/read_file.py +0 -0
  94. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/tools/files/write_file.py +0 -0
  95. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/tools/refund.py +0 -0
  96. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/tools/websearch.py +0 -0
  97. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/tracing/__init__.py +0 -0
  98. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/tracing/langfuse.py +0 -0
  99. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/utils/__init__.py +0 -0
  100. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/utils/retry.py +0 -0
  101. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate.egg-info/SOURCES.txt +0 -0
  102. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate.egg-info/dependency_links.txt +0 -0
  103. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate.egg-info/entry_points.txt +0 -0
  104. {hexgate-0.2.3 → hexgate-0.2.5}/hexgate.egg-info/top_level.txt +0 -0
  105. {hexgate-0.2.3 → hexgate-0.2.5}/setup.cfg +0 -0
  106. {hexgate-0.2.3 → hexgate-0.2.5}/tests/test_bootstrap.py +0 -0
  107. {hexgate-0.2.3 → hexgate-0.2.5}/tests/test_demo.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hexgate
3
- Version: 0.2.3
3
+ Version: 0.2.5
4
4
  Summary: Hexgate — authorization infrastructure for AI agents (agent runtime + cloud client).
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.13
@@ -34,6 +34,7 @@ Requires-Dist: ipykernel; extra == "dev"
34
34
  Requires-Dist: jupyter; extra == "dev"
35
35
  Requires-Dist: pytest>=8.4.1; extra == "dev"
36
36
  Requires-Dist: pytest-asyncio>=1.0.0; extra == "dev"
37
+ Requires-Dist: pytest-cov>=6.0.0; extra == "dev"
37
38
  Requires-Dist: ruff>=0.12.2; extra == "dev"
38
39
  Dynamic: license-file
39
40
 
@@ -50,6 +51,7 @@ Policy enforcement, signed policy bundles, per-request user scope, audit trail
50
51
 
51
52
  [![PyPI](https://img.shields.io/pypi/v/hexgate?color=blue&logo=pypi&logoColor=white)](https://pypi.org/project/hexgate/)
52
53
  [![CI](https://github.com/HexamindOrganisation/hexgate/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/HexamindOrganisation/hexgate/actions/workflows/tests.yml)
54
+ [![codecov](https://codecov.io/gh/HexamindOrganisation/hexgate/branch/main/graph/badge.svg?flag=sdk)](https://codecov.io/gh/HexamindOrganisation/hexgate)
53
55
  [![Downloads](https://img.shields.io/pypi/dm/hexgate?color=blueviolet)](https://pypi.org/project/hexgate/)
54
56
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
55
57
 
@@ -223,6 +225,31 @@ make dashboard-install # pnpm install inside platform/dashboard/
223
225
 
224
226
  Then open http://localhost:5173/playground — type a message, watch the live stream of tool calls and policy decisions from your local agent.
225
227
 
228
+ ### Control-plane database (SQLite / Postgres)
229
+
230
+ `make platform-api` uses a local **SQLite** file — zero setup, fine for
231
+ dev and the test suite. Set `DATABASE_URL` to run on **Postgres** instead
232
+ (what deployments use); bare `postgres://` URLs are normalized to the
233
+ `asyncpg` driver. A local Postgres is in the dev compose:
234
+
235
+ ```bash
236
+ make platform-api-pg # starts Postgres (Docker) + runs the API against it
237
+ # equivalent to:
238
+ make postgres-up # start Postgres, wait until healthy
239
+ DATABASE_URL=postgresql+asyncpg://hexgate:hexgate-dev-password@localhost:5433/hexgate make platform-api
240
+ make postgres-reset # wipe ONLY the Postgres data volume
241
+ ```
242
+
243
+ No migration system: the schema is created with `create_all`, so a model
244
+ change means resetting the volume (`make postgres-reset`), not migrating —
245
+ data is treated as disposable. A managed Postgres (e.g. Scaleway) needs SSL
246
+ in the DSN, e.g. `…/hexgate?ssl=require`.
247
+
248
+ `DATABASE_URL` is read from the real environment or `platform/api/.env`
249
+ (see `.env.sample`). The opt-in `tests/test_postgres_smoke.py` is the only
250
+ test that exercises the Postgres path (the rest run on SQLite); it runs
251
+ when `DATABASE_URL` points at Postgres.
252
+
226
253
  ### Audit log (ClickHouse)
227
254
 
228
255
  Policy decisions are written to a local ClickHouse instance for the audit dashboard. Requires Docker.
@@ -1,42 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: hexgate
3
- Version: 0.2.3
4
- Summary: Hexgate — 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
3
  <img src="./icon.svg" alt="Hexgate" width="96" height="96" />
@@ -50,6 +11,7 @@ Policy enforcement, signed policy bundles, per-request user scope, audit trail
50
11
 
51
12
  [![PyPI](https://img.shields.io/pypi/v/hexgate?color=blue&logo=pypi&logoColor=white)](https://pypi.org/project/hexgate/)
52
13
  [![CI](https://github.com/HexamindOrganisation/hexgate/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/HexamindOrganisation/hexgate/actions/workflows/tests.yml)
14
+ [![codecov](https://codecov.io/gh/HexamindOrganisation/hexgate/branch/main/graph/badge.svg?flag=sdk)](https://codecov.io/gh/HexamindOrganisation/hexgate)
53
15
  [![Downloads](https://img.shields.io/pypi/dm/hexgate?color=blueviolet)](https://pypi.org/project/hexgate/)
54
16
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
55
17
 
@@ -223,6 +185,31 @@ make dashboard-install # pnpm install inside platform/dashboard/
223
185
 
224
186
  Then open http://localhost:5173/playground — type a message, watch the live stream of tool calls and policy decisions from your local agent.
225
187
 
188
+ ### Control-plane database (SQLite / Postgres)
189
+
190
+ `make platform-api` uses a local **SQLite** file — zero setup, fine for
191
+ dev and the test suite. Set `DATABASE_URL` to run on **Postgres** instead
192
+ (what deployments use); bare `postgres://` URLs are normalized to the
193
+ `asyncpg` driver. A local Postgres is in the dev compose:
194
+
195
+ ```bash
196
+ make platform-api-pg # starts Postgres (Docker) + runs the API against it
197
+ # equivalent to:
198
+ make postgres-up # start Postgres, wait until healthy
199
+ DATABASE_URL=postgresql+asyncpg://hexgate:hexgate-dev-password@localhost:5433/hexgate make platform-api
200
+ make postgres-reset # wipe ONLY the Postgres data volume
201
+ ```
202
+
203
+ No migration system: the schema is created with `create_all`, so a model
204
+ change means resetting the volume (`make postgres-reset`), not migrating —
205
+ data is treated as disposable. A managed Postgres (e.g. Scaleway) needs SSL
206
+ in the DSN, e.g. `…/hexgate?ssl=require`.
207
+
208
+ `DATABASE_URL` is read from the real environment or `platform/api/.env`
209
+ (see `.env.sample`). The opt-in `tests/test_postgres_smoke.py` is the only
210
+ test that exercises the Postgres path (the rest run on SQLite); it runs
211
+ when `DATABASE_URL` points at Postgres.
212
+
226
213
  ### Audit log (ClickHouse)
227
214
 
228
215
  Policy decisions are written to a local ClickHouse instance for the audit dashboard. Requires Docker.
@@ -2,7 +2,7 @@
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
@@ -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
 
@@ -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 (
@@ -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:
@@ -209,13 +209,11 @@ def build_runtime_from_local_agent(
209
209
  """
210
210
  import os
211
211
 
212
- import yaml
213
-
214
212
  from hexgate.agents.factory import enforce_policy
215
213
  from hexgate.cli.register.manifest import create_manifest
216
214
  from hexgate.cli.register.register import post_manifest
217
215
  from hexgate.cloud.client import HexgateClient, HexgateConfig
218
- from hexgate.security.policy_set import load_policy_set_from_dict
216
+ from hexgate.security.binding import platform_policy_from_payload
219
217
  from hexgate.tracing.langfuse import get_langfuse_handler
220
218
 
221
219
  manifest = create_manifest(agent_obj, description=description)
@@ -239,7 +237,7 @@ def build_runtime_from_local_agent(
239
237
 
240
238
  config = HexgateConfig.from_env()
241
239
  client = HexgateClient(config)
242
- payload, _etag = client.get_agent(agent_name)
240
+ payload, initial_etag = client.get_agent(agent_name)
243
241
  if payload is None:
244
242
  # Invariant: no If-None-Match was sent, so a 304 is impossible.
245
243
  # Raise so `python -O` can't strip the check.
@@ -248,10 +246,22 @@ def build_runtime_from_local_agent(
248
246
  "on initial fetch (no If-None-Match was sent)"
249
247
  )
250
248
 
251
- policy_payload = yaml.safe_load(payload["policy_yaml"]) or {}
252
- policy = load_policy_set_from_dict(policy_payload)
249
+ # platform_policy_from_payload returns the canonical (engine, source)
250
+ # pair: handles signed-bundle vs pydantic fallback, and seeds the
251
+ # PlatformPolicySource with the bundle + ETag so the next refresh is
252
+ # a 304 unless policy changed. Without the source kwarg, refresh_policy()
253
+ # at the top of every stream_agent() is a no-op and dashboard edits
254
+ # only land at the next `hexgate serve` restart.
255
+ policy, refresh_source = platform_policy_from_payload(
256
+ client, agent_name, payload, initial_etag
257
+ )
253
258
 
254
- enforced = enforce_policy(agent_obj, policy, approval_handler=approval_handler)
259
+ enforced = enforce_policy(
260
+ agent_obj,
261
+ policy,
262
+ approval_handler=approval_handler,
263
+ source=refresh_source,
264
+ )
255
265
 
256
266
  # Fresh handler for the streaming layer. The user's create_agent() call
257
267
  # built its own handler but discarded it; we make a new one bound to
@@ -28,7 +28,7 @@ def create_hexgate_manifest(
28
28
  return AgentManifest(
29
29
  name=agent.name,
30
30
  description=description,
31
- framework=AgentFramework.FORTIFY,
31
+ framework=AgentFramework.HEXGATE,
32
32
  model=_extract_model(agent.model),
33
33
  system_prompt=_extract_system_prompt(agent.system_prompt),
34
34
  tools=[_to_tool_definition(t) for t in agent.tools],
@@ -32,7 +32,7 @@ else:
32
32
  class AgentFramework(StrEnum):
33
33
  """Enum for the framework of an agent."""
34
34
 
35
- FORTIFY = "hexgate"
35
+ HEXGATE = "hexgate"
36
36
  PYDANTIC_AI = "pydantic-ai"
37
37
  LANGCHAIN = "langchain"
38
38
  GOOGLE = "google"
@@ -9,7 +9,6 @@ from collections.abc import Iterator
9
9
  from contextlib import contextmanager
10
10
  from contextvars import ContextVar, Token
11
11
  from dataclasses import dataclass
12
- from typing import Any
13
12
 
14
13
  from pydantic import BaseModel, ConfigDict, PrivateAttr
15
14
 
@@ -96,26 +95,30 @@ class User(BaseModel):
96
95
  session_id: str | None = None
97
96
  ttl_seconds: int | None = None
98
97
 
99
- # Stack so the same User instance survives nested ``async with`` blocks.
100
- _tokens: list[Any] = PrivateAttr(default_factory=list)
98
+ # Stack of shadowed values (supports nested scopes). We save/restore via
99
+ # set() rather than reset(token): async-generator finalizers run __aexit__
100
+ # in a different Context, where a token reset would raise — set() doesn't.
101
+ _saved: list["User | None"] = PrivateAttr(default_factory=list)
101
102
 
102
103
  async def __aenter__(self) -> "User":
103
- self._tokens.append(_CURRENT_USER.set(self))
104
+ self._saved.append(_CURRENT_USER.get())
105
+ _CURRENT_USER.set(self)
104
106
  return self
105
107
 
106
108
  async def __aexit__(self, *_: object) -> None:
107
- if self._tokens:
108
- _CURRENT_USER.reset(self._tokens.pop())
109
+ if self._saved:
110
+ _CURRENT_USER.set(self._saved.pop())
109
111
 
110
112
  @contextmanager
111
113
  def sync_scope(self) -> Iterator["User"]:
112
114
  """Sync mirror of ``async with self`` for sync entry points."""
113
- self._tokens.append(_CURRENT_USER.set(self))
115
+ self._saved.append(_CURRENT_USER.get())
116
+ _CURRENT_USER.set(self)
114
117
  try:
115
118
  yield self
116
119
  finally:
117
- if self._tokens:
118
- _CURRENT_USER.reset(self._tokens.pop())
120
+ if self._saved:
121
+ _CURRENT_USER.set(self._saved.pop())
119
122
 
120
123
 
121
124
  _CURRENT_USER: ContextVar[User | None] = ContextVar(
@@ -74,6 +74,7 @@ from hexgate.security.rego_wasm import (
74
74
  from hexgate.security.source import (
75
75
  BundleDirPolicySource,
76
76
  PlatformPolicySource,
77
+ PolicyContentError,
77
78
  PolicySource,
78
79
  YamlPolicySource,
79
80
  )
@@ -107,6 +108,7 @@ __all__ = [
107
108
  "OpaNotFoundError",
108
109
  "PlatformPolicySource",
109
110
  "PolicyBundle",
111
+ "PolicyContentError",
110
112
  "PolicySource",
111
113
  "YamlPolicySource",
112
114
  "SignatureError",
@@ -22,6 +22,7 @@ from hexgate.security.source import (
22
22
  _LOCAL_POLICY_ENV_VAR,
23
23
  _REQUIRE_SIGNATURE_ENV_VAR,
24
24
  PlatformPolicySource,
25
+ PolicyContentError,
25
26
  PolicySource,
26
27
  _local_policy_override,
27
28
  _truthy,
@@ -129,7 +130,17 @@ class PolicyBinding:
129
130
  return
130
131
  try:
131
132
  new_policy = self.source.fetch()
133
+ except PolicyContentError as exc:
134
+ # Dashboard-saved edit the runtime rejects → ERROR so the
135
+ # UI/runtime drift is grep-able. Still fail-soft.
136
+ logger.error(
137
+ "policy refresh for agent %r rejected platform content: %s",
138
+ getattr(self.enforcer, "agent_name", "?"),
139
+ exc,
140
+ )
141
+ return
132
142
  except Exception as exc: # noqa: BLE001 — refresh must not crash a run
143
+ # Transient (network, 5xx, strict-mode signature refusal) — WARN.
133
144
  logger.warning(
134
145
  "policy refresh for agent %r failed: %s — keeping "
135
146
  "previously loaded policy",
@@ -170,12 +181,46 @@ def platform_policy_from_payload(
170
181
  "Refusing to fall back to the pydantic engine."
171
182
  )
172
183
  else:
184
+ # Loud one-shot signal (fires at load time, not per turn) so an
185
+ # operator running `hexgate serve` doesn't silently get the
186
+ # pydantic engine when they expected the production-shaped
187
+ # WASM path. Common cause: `opa` not installed on the platform
188
+ # host — see compile_bundle() in platform/api/services.py, which
189
+ # logs "opa not on PATH" on the server side too.
190
+ logger.warning(
191
+ "policy for %r served without a WASM bundle — falling back to "
192
+ "the pydantic engine. Decisions are equivalent (parity-tested), "
193
+ "but signature verification and signed-artifact distribution "
194
+ "are off. Install `opa` on the platform host and re-save the "
195
+ "policy to get the WASM path; set %s=true to refuse the "
196
+ "fallback entirely.",
197
+ agent_name,
198
+ _REQUIRE_SIGNATURE_ENV_VAR,
199
+ )
173
200
  policy = load_policy_set_from_dict(
174
201
  yaml.safe_load(payload.get("policy_yaml") or "") or {}
175
202
  )
176
203
 
177
- # Pre-seeded so the next refresh is a 304 unless the policy changed.
204
+ # Pre-seeded so the next refresh is a 304 (bundle path) or a cache
205
+ # hit (pydantic-fallback path) unless the policy actually changed.
206
+ # The yaml hash is only relevant on the pydantic-fallback path —
207
+ # compute it from the same `policy_yaml` we just parsed above so the
208
+ # source's first refresh comparison matches load-time exactly.
209
+ import hashlib
210
+
211
+ yaml_hash: str | None = None
212
+ # (#3) Mirror fetch()'s guard: don't seed an ETag on the no-bundle
213
+ # path or the first refresh could 304 and swallow an edit.
214
+ seed_etag: str | None = etag
215
+ if bundle is None:
216
+ yaml_text = payload.get("policy_yaml") or ""
217
+ yaml_hash = hashlib.sha256(yaml_text.encode("utf-8")).hexdigest()
218
+ seed_etag = None
178
219
  source = PlatformPolicySource(
179
- client, agent_name, initial_bundle=bundle, initial_etag=etag
220
+ client,
221
+ agent_name,
222
+ initial_engine=policy,
223
+ initial_etag=seed_etag,
224
+ initial_yaml_hash=yaml_hash,
180
225
  )
181
226
  return policy, source
@@ -1,12 +1,16 @@
1
1
  """Policy sources — abstractions over "where the current policy lives."
2
2
 
3
- The runtime fetches a :class:`PolicyBundle` (or ``None``) from a source at
4
- every agent run; the source decides whether that's cheap or not. Three
5
- implementations cover the production + local-dev workflows:
3
+ The runtime fetches a :class:`~hexgate.security.decision.PolicyEngine`
4
+ (or ``None``) from a source at every agent run; the source decides
5
+ whether that's cheap or not. Three implementations cover the production
6
+ + local-dev workflows:
6
7
 
7
8
  * :class:`PlatformPolicySource` — HTTP fetch with ``If-None-Match`` /
8
9
  ``304 Not Modified``, so unchanged bundles cost one tiny round trip
9
10
  instead of a full payload + signature verify + wasm re-instantiation.
11
+ Falls back to the pydantic engine (a :class:`PolicySet` derived from
12
+ the response's ``policy_yaml``) when the platform served no compiled
13
+ bundle — the typical Modal / no-opa demo deployment shape.
10
14
  * :class:`BundleDirPolicySource` — refresh a pre-built bundle directory
11
15
  on disk (today's ``HEXGATE_LOCAL_POLICY=<dir>`` path, made mtime-aware
12
16
  so a rebuild via ``hexgate policy build`` takes effect on the next run).
@@ -21,12 +25,15 @@ on the protocol, not on any concrete type.
21
25
  from __future__ import annotations
22
26
 
23
27
  import base64
28
+ import hashlib
24
29
  import logging
25
30
  import os
26
31
  import threading
27
32
  from pathlib import Path
28
33
  from typing import TYPE_CHECKING, Protocol
29
34
 
35
+ import yaml
36
+
30
37
  from hexgate.security.bundle import (
31
38
  BundleIntegrityError,
32
39
  BundleLoadError,
@@ -34,6 +41,8 @@ from hexgate.security.bundle import (
34
41
  PolicyBundle,
35
42
  build_signed_bundle,
36
43
  )
44
+ from hexgate.security.decision import PolicyEngine
45
+ from hexgate.security.policy_set import PolicySetError, load_policy_set_from_dict
37
46
  from hexgate.security.signing import SignatureError, decode_key
38
47
 
39
48
  if TYPE_CHECKING:
@@ -45,37 +54,58 @@ if TYPE_CHECKING:
45
54
  logger = logging.getLogger("hexgate.security.source")
46
55
 
47
56
 
57
+ class PolicyContentError(RuntimeError):
58
+ """Platform served a payload, but the policy content is invalid.
59
+
60
+ Distinct from transient errors (network, signature) so
61
+ :meth:`PolicyBinding.refresh` can log at ``error`` level —
62
+ dashboard-saved-but-runtime-rejected is a correctness drift, not
63
+ "retry later".
64
+ """
65
+
66
+
48
67
  class PolicySource(Protocol):
49
- """Produces a current :class:`PolicyBundle` (or ``None``) on demand.
68
+ """Produces a current :class:`PolicyEngine` (or ``None``) on demand.
50
69
 
51
70
  Implementations are expected to be **cheap when nothing has changed**
52
71
  — caching, ETags, or mtime checks — so the agent runtime can call
53
72
  :meth:`fetch` at the top of every run without measurable cost.
54
73
 
55
- A returned ``None`` means "no bundle is configured for this source"
56
- (e.g. the platform served no compiled bundle). Callers fall back to
57
- whatever they had before (pydantic engine on raw YAML).
74
+ A returned ``None`` means "no engine is configured for this source"
75
+ (e.g. the platform served no policy at all). Callers keep whatever
76
+ engine they had before. The runtime's :class:`PolicyBinding.refresh`
77
+ relies on this: it only swaps when ``fetch()`` returns something
78
+ distinct from the current engine.
58
79
  """
59
80
 
60
- def fetch(self) -> PolicyBundle | None: ...
81
+ def fetch(self) -> PolicyEngine | None: ...
61
82
 
62
83
 
63
84
  class PlatformPolicySource:
64
- """Pull + verify a signed bundle from the platform, with ETag/304.
65
-
66
- Holds the last seen bundle and its ``wasm_hash`` (the ETag the
67
- platform serves). Each :meth:`fetch` sends ``If-None-Match`` and:
68
-
69
- * ``304`` returns the cached bundle without touching wasmtime or
70
- the signature path.
71
- * ``200`` decodes + verifies the new payload, caches it, returns.
72
- * payload with no bundle returns ``None`` (the platform couldn't
73
- compile, e.g. opa missing on the control plane the SDK then
74
- falls back to its pydantic engine).
75
-
76
- Verification fails are fatal (a tampered platform bundle is never
77
- silently downgraded). The signature is checked against the same
78
- public key the SDK already trusts for biscuit verification.
85
+ """Pull a current policy engine from the platform, with ETag/304.
86
+
87
+ Two engines flow out, depending on what the platform has compiled:
88
+
89
+ * **WASM bundle** (production shape) — when the platform's
90
+ ``compiled_wasm`` is populated, we get a signed bundle back and
91
+ return a verified :class:`PolicyBundle`. ETag = ``wasm_hash``;
92
+ unchanged bundles hit ``304`` and re-use the cached object.
93
+ * **Pydantic fallback** (no-opa / demo shape) when the platform
94
+ couldn't compile (no ``opa`` on the control plane), the response
95
+ carries ``policy_yaml`` but null bundle fields. We hash the yaml,
96
+ compare against the last seen hash, and re-construct a fresh
97
+ :class:`PolicySet` only when the yaml content actually changed.
98
+
99
+ Without the pydantic-fallback branch a policy edit would silently
100
+ no-op for any deployment without opa — :meth:`fetch` would always
101
+ return ``None`` (no bundle), :class:`PolicyBinding.refresh` would
102
+ treat that as "nothing served" and skip the swap, and the initial
103
+ engine built by :func:`platform_policy_from_payload` would stay
104
+ frozen forever.
105
+
106
+ Verification fails on the bundle path are fatal (a tampered bundle
107
+ is never silently downgraded). Verification uses the same public
108
+ key the SDK already trusts for biscuit verification.
79
109
  """
80
110
 
81
111
  def __init__(
@@ -85,46 +115,111 @@ class PlatformPolicySource:
85
115
  *,
86
116
  initial_bundle: PolicyBundle | None = None,
87
117
  initial_etag: str | None = None,
118
+ initial_engine: PolicyEngine | None = None,
119
+ initial_yaml_hash: str | None = None,
88
120
  ) -> None:
89
121
  self._client = client
90
122
  self._agent_name = agent_name
91
- # Pre-seed when the caller already fetched + verified the bundle
92
- # (typical at agent load time). Avoids a redundant 200 round-trip
93
- # on the first refresh that call will send If-None-Match and
94
- # get a cheap 304.
95
- self._cached_bundle: PolicyBundle | None = initial_bundle
123
+ # Pre-seed when the caller already fetched + verified at load time.
124
+ # `initial_engine` covers both shapes (a PolicyBundle on the WASM
125
+ # path, a PolicySet on the pydantic-fallback path); `initial_bundle`
126
+ # stays as a back-compat alias that callers used before we
127
+ # broadened the engine type.
128
+ self._cached_engine: PolicyEngine | None = (
129
+ initial_engine if initial_engine is not None else initial_bundle
130
+ )
96
131
  self._cached_etag: str | None = initial_etag
132
+ # Hash of the `policy_yaml` text that produced the cached *pydantic*
133
+ # engine. Used solely on the no-bundle branch to decide whether
134
+ # the platform's response represents a real change: a same-hash
135
+ # response returns the cached PolicySet (preserves identity → the
136
+ # binding's `is policy` check skips the swap); a new hash builds
137
+ # and caches a fresh one. ``None`` on the bundle path (we use
138
+ # ``_cached_etag`` for that).
139
+ self._cached_yaml_hash: str | None = initial_yaml_hash
97
140
  # Serialize the (read cached_etag → HTTP → write cached_*) cycle.
98
141
  # Refresh runs on a to_thread worker, so two concurrent agent runs
99
142
  # sharing one source could otherwise interleave a write to
100
- # _cached_bundle with another's read of _cached_etag and pair the
143
+ # _cached_engine with another's read of _cached_etag and pair the
101
144
  # bundle from one response with the etag from another → a later
102
145
  # spurious 200/304. The cost is serializing refreshes for shared
103
146
  # sources, which is fine: refresh is best-effort and rare-ish per
104
147
  # turn (most calls hit a cheap 304).
105
148
  self._lock = threading.Lock()
106
149
 
107
- def fetch(self) -> PolicyBundle | None:
150
+ def fetch(self) -> PolicyEngine | None:
108
151
  with self._lock:
109
152
  payload, etag = self._client.get_agent(
110
153
  self._agent_name, if_none_match=self._cached_etag
111
154
  )
112
155
  # 304 — nothing changed since last fetch. Cheap path.
113
156
  if payload is None:
114
- return self._cached_bundle
157
+ return self._cached_engine
115
158
 
116
159
  bundle = decode_and_verify_platform_bundle(
117
160
  payload, self._client.public_key_bytes()
118
161
  )
119
- self._cached_bundle = bundle
120
- # Server-supplied ETag wins; fall back to wasm_hash for when the
121
- # response lacked an ETag header (older platform versions).
122
- self._cached_etag = etag or (
123
- f'"{bundle.wasm_hash}"'
124
- if bundle is not None and bundle.wasm_hash
125
- else None
126
- )
127
- return bundle
162
+ if bundle is not None:
163
+ # WASM path. ETag tracking is on the wasm_hash; the yaml
164
+ # hash is irrelevant here, clear it so a later transition
165
+ # to the pydantic branch (platform loses opa) doesn't
166
+ # mistakenly reuse a stale hash from the old wasm world.
167
+ self._cached_engine = bundle
168
+ self._cached_etag = etag or (
169
+ f'"{bundle.wasm_hash}"' if bundle.wasm_hash else None
170
+ )
171
+ self._cached_yaml_hash = None
172
+ return bundle
173
+
174
+ # No bundle — platform couldn't compile (no opa, etc.) but
175
+ # served the raw policy_yaml. Build a PolicySet from it.
176
+
177
+ # (#1) Refuse the downgrade under strict mode. Load-time
178
+ # already refuses; this catches opa-went-down mid-session.
179
+ # Caught by binding.refresh → keeps last verified bundle.
180
+ if _truthy(os.environ.get(_REQUIRE_SIGNATURE_ENV_VAR)):
181
+ raise RuntimeError(
182
+ f"{_REQUIRE_SIGNATURE_ENV_VAR} is set but no signed "
183
+ f"bundle served for {self._agent_name!r} on refresh — "
184
+ "keeping last verified policy."
185
+ )
186
+
187
+ # (#3) Ignore server ETag on this branch — its semantics
188
+ # aren't defined here, and a 304 would skip the hash check
189
+ # below and swallow an edit. Yaml-hash is the change detector.
190
+ yaml_text = payload.get("policy_yaml") or ""
191
+ new_hash = hashlib.sha256(yaml_text.encode("utf-8")).hexdigest()
192
+ if new_hash == self._cached_yaml_hash and self._cached_engine is not None:
193
+ # Identity preserved → binding's `is` check skips the swap.
194
+ self._cached_etag = None
195
+ return self._cached_engine
196
+
197
+ # (#2) Surface parse/validate failures as PolicyContentError
198
+ # so binding logs at ERROR — silent swallow would recreate
199
+ # the original bug for invalid edits.
200
+ try:
201
+ parsed = yaml.safe_load(yaml_text) or {}
202
+ except yaml.YAMLError as exc:
203
+ raise PolicyContentError(
204
+ f"unparseable policy_yaml for {self._agent_name!r}: {exc}"
205
+ ) from exc
206
+ try:
207
+ new_engine = load_policy_set_from_dict(parsed)
208
+ except (PolicySetError, ValueError, TypeError) as exc:
209
+ # ValueError covers pydantic ValidationError too.
210
+ raise PolicyContentError(
211
+ f"invalid policy_yaml for {self._agent_name!r}: {exc}"
212
+ ) from exc
213
+
214
+ # (#4) Per-turn cost = full GET + sha256; parse only on change.
215
+ # Can't 304 without server ETag-on-policy_yaml (future fix).
216
+ # Until then the per-turn cost is one round trip + a sha256,
217
+ # acceptable for the demo-shaped deployments this branch
218
+ # targets.
219
+ self._cached_engine = new_engine
220
+ self._cached_yaml_hash = new_hash
221
+ self._cached_etag = None
222
+ return self._cached_engine
128
223
 
129
224
 
130
225
  def decode_and_verify_platform_bundle(
@@ -1,3 +1,43 @@
1
+ Metadata-Version: 2.4
2
+ Name: hexgate
3
+ Version: 0.2.5
4
+ Summary: Hexgate — 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: pytest-cov>=6.0.0; extra == "dev"
38
+ Requires-Dist: ruff>=0.12.2; extra == "dev"
39
+ Dynamic: license-file
40
+
1
41
  <div align="center">
2
42
 
3
43
  <img src="./icon.svg" alt="Hexgate" width="96" height="96" />
@@ -11,6 +51,7 @@ Policy enforcement, signed policy bundles, per-request user scope, audit trail
11
51
 
12
52
  [![PyPI](https://img.shields.io/pypi/v/hexgate?color=blue&logo=pypi&logoColor=white)](https://pypi.org/project/hexgate/)
13
53
  [![CI](https://github.com/HexamindOrganisation/hexgate/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/HexamindOrganisation/hexgate/actions/workflows/tests.yml)
54
+ [![codecov](https://codecov.io/gh/HexamindOrganisation/hexgate/branch/main/graph/badge.svg?flag=sdk)](https://codecov.io/gh/HexamindOrganisation/hexgate)
14
55
  [![Downloads](https://img.shields.io/pypi/dm/hexgate?color=blueviolet)](https://pypi.org/project/hexgate/)
15
56
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
16
57
 
@@ -184,6 +225,31 @@ make dashboard-install # pnpm install inside platform/dashboard/
184
225
 
185
226
  Then open http://localhost:5173/playground — type a message, watch the live stream of tool calls and policy decisions from your local agent.
186
227
 
228
+ ### Control-plane database (SQLite / Postgres)
229
+
230
+ `make platform-api` uses a local **SQLite** file — zero setup, fine for
231
+ dev and the test suite. Set `DATABASE_URL` to run on **Postgres** instead
232
+ (what deployments use); bare `postgres://` URLs are normalized to the
233
+ `asyncpg` driver. A local Postgres is in the dev compose:
234
+
235
+ ```bash
236
+ make platform-api-pg # starts Postgres (Docker) + runs the API against it
237
+ # equivalent to:
238
+ make postgres-up # start Postgres, wait until healthy
239
+ DATABASE_URL=postgresql+asyncpg://hexgate:hexgate-dev-password@localhost:5433/hexgate make platform-api
240
+ make postgres-reset # wipe ONLY the Postgres data volume
241
+ ```
242
+
243
+ No migration system: the schema is created with `create_all`, so a model
244
+ change means resetting the volume (`make postgres-reset`), not migrating —
245
+ data is treated as disposable. A managed Postgres (e.g. Scaleway) needs SSL
246
+ in the DSN, e.g. `…/hexgate?ssl=require`.
247
+
248
+ `DATABASE_URL` is read from the real environment or `platform/api/.env`
249
+ (see `.env.sample`). The opt-in `tests/test_postgres_smoke.py` is the only
250
+ test that exercises the Postgres path (the rest run on SQLite); it runs
251
+ when `DATABASE_URL` points at Postgres.
252
+
187
253
  ### Audit log (ClickHouse)
188
254
 
189
255
  Policy decisions are written to a local ClickHouse instance for the audit dashboard. Requires Docker.
@@ -27,4 +27,5 @@ ipykernel
27
27
  jupyter
28
28
  pytest>=8.4.1
29
29
  pytest-asyncio>=1.0.0
30
+ pytest-cov>=6.0.0
30
31
  ruff>=0.12.2
@@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta"
9
9
  # 0.2.0 (the original package name was taken on PyPI by a 2014
10
10
  # abandoned project; the team consolidated on `hexgate` for everything).
11
11
  name = "hexgate"
12
- version = "0.2.3"
12
+ version = "0.2.5"
13
13
  description = "Hexgate — authorization infrastructure for AI agents (agent runtime + cloud client)."
14
14
  readme = "README.md"
15
15
  license = "MIT"
@@ -50,9 +50,35 @@ dev = [
50
50
  "jupyter",
51
51
  "pytest>=8.4.1",
52
52
  "pytest-asyncio>=1.0.0",
53
+ "pytest-cov>=6.0.0",
53
54
  "ruff>=0.12.2",
54
55
  ]
55
56
 
57
+ [tool.coverage.run]
58
+ # Branch coverage is more meaningful than line coverage — catches missed
59
+ # else branches that line-coverage rolls over. Source listed explicitly so
60
+ # tests/, examples/, notebooks/, and the platform/ tree don't pollute the
61
+ # SDK's coverage number.
62
+ branch = true
63
+ source = ["hexgate"]
64
+ omit = [
65
+ "*/__init__.py",
66
+ "*/builtin/*", # packaged YAML/MD assets, not executable code
67
+ ]
68
+
69
+ [tool.coverage.report]
70
+ # Don't fail just because one printer-friendly representation is missing —
71
+ # only meaningful when running the SDK module locally as a script.
72
+ exclude_also = [
73
+ "if __name__ == .__main__.:",
74
+ "if TYPE_CHECKING:",
75
+ "raise NotImplementedError",
76
+ "@overload",
77
+ ]
78
+ skip_covered = false
79
+ show_missing = true
80
+ precision = 1
81
+
56
82
  [tool.setuptools.packages.find]
57
83
  include = ["hexgate*"]
58
84
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes