hexgate 0.2.3__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.3 → hexgate-0.2.4}/PKG-INFO +26 -1
  2. {hexgate-0.2.3 → hexgate-0.2.4}/README.md +25 -0
  3. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/adapters/langchain/agent.py +6 -3
  4. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/agents/factory.py +23 -8
  5. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/audit.py +30 -4
  6. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/cli/_common.py +17 -7
  7. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/cli/register/hexgate.py +1 -1
  8. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/cli/register/models.py +1 -1
  9. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/runtime/context.py +12 -9
  10. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/security/binding.py +16 -0
  11. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate.egg-info/PKG-INFO +26 -1
  12. {hexgate-0.2.3 → hexgate-0.2.4}/pyproject.toml +1 -1
  13. {hexgate-0.2.3 → hexgate-0.2.4}/LICENSE +0 -0
  14. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/__init__.py +0 -0
  15. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/adapters/__init__.py +0 -0
  16. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/adapters/google/__init__.py +0 -0
  17. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/adapters/google/runner.py +0 -0
  18. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/adapters/google/tools.py +0 -0
  19. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/adapters/google/wrapper.py +0 -0
  20. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/adapters/langchain/__init__.py +0 -0
  21. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/adapters/langchain/tools.py +0 -0
  22. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/adapters/langchain/wrapper.py +0 -0
  23. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/adapters/openai/__init__.py +0 -0
  24. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/adapters/openai/runner.py +0 -0
  25. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/adapters/openai/tools.py +0 -0
  26. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/adapters/openai/wrapper.py +0 -0
  27. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/adapters/pydantic_ai/__init__.py +0 -0
  28. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/adapters/pydantic_ai/agent.py +0 -0
  29. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/adapters/pydantic_ai/tools.py +0 -0
  30. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/adapters/pydantic_ai/wrapper.py +0 -0
  31. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/agents/__init__.py +0 -0
  32. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/agents/builtin/__init__.py +0 -0
  33. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/agents/builtin/researcher/agent.yaml +0 -0
  34. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/agents/builtin/researcher/policy.yaml +0 -0
  35. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/agents/builtin/researcher/system.md +0 -0
  36. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/agents/loader.py +0 -0
  37. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/agents/models.py +0 -0
  38. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/agents/prompts/agent_system.md +0 -0
  39. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/bootstrap.py +0 -0
  40. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/cli/__init__.py +0 -0
  41. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/cli/chat.py +0 -0
  42. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/cli/policy/__init__.py +0 -0
  43. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/cli/policy/main.py +0 -0
  44. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/cli/register/__init__.py +0 -0
  45. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/cli/register/google.py +0 -0
  46. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/cli/register/langchain.py +0 -0
  47. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/cli/register/main.py +0 -0
  48. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/cli/register/manifest.py +0 -0
  49. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/cli/register/openai.py +0 -0
  50. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/cli/register/pydantic_ai.py +0 -0
  51. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/cli/register/register.py +0 -0
  52. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/cli/serve.py +0 -0
  53. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/cli/state.py +0 -0
  54. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/cloud/__init__.py +0 -0
  55. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/cloud/attenuate.py +0 -0
  56. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/cloud/biscuit.py +0 -0
  57. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/cloud/client.py +0 -0
  58. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/config/__init__.py +0 -0
  59. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/config/settings.py +0 -0
  60. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/runtime/__init__.py +0 -0
  61. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/runtime/command_policy.py +0 -0
  62. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/runtime/sandbox_runtime.py +0 -0
  63. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/runtime/srt.py +0 -0
  64. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/runtime/workspace.py +0 -0
  65. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/security/__init__.py +0 -0
  66. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/security/bundle.py +0 -0
  67. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/security/constraints.py +0 -0
  68. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/security/decision.py +0 -0
  69. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/security/enforcer.py +0 -0
  70. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/security/errors.py +0 -0
  71. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/security/file_scope.py +0 -0
  72. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/security/models.py +0 -0
  73. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/security/policy.py +0 -0
  74. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/security/policy_set.py +0 -0
  75. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/security/rego.py +0 -0
  76. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/security/rego_wasm.py +0 -0
  77. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/security/signing.py +0 -0
  78. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/security/source.py +0 -0
  79. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/security/wasm_engine.py +0 -0
  80. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/streaming/__init__.py +0 -0
  81. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/streaming/events.py +0 -0
  82. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/streaming/normalize.py +0 -0
  83. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/tools/__init__.py +0 -0
  84. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/tools/bash.py +0 -0
  85. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/tools/decorators.py +0 -0
  86. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/tools/fetch.py +0 -0
  87. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/tools/files/__init__.py +0 -0
  88. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/tools/files/_common.py +0 -0
  89. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/tools/files/edit_file.py +0 -0
  90. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/tools/files/glob.py +0 -0
  91. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/tools/files/grep.py +0 -0
  92. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/tools/files/read_file.py +0 -0
  93. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/tools/files/write_file.py +0 -0
  94. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/tools/refund.py +0 -0
  95. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/tools/websearch.py +0 -0
  96. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/tracing/__init__.py +0 -0
  97. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/tracing/langfuse.py +0 -0
  98. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/utils/__init__.py +0 -0
  99. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate/utils/retry.py +0 -0
  100. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate.egg-info/SOURCES.txt +0 -0
  101. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate.egg-info/dependency_links.txt +0 -0
  102. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate.egg-info/entry_points.txt +0 -0
  103. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate.egg-info/requires.txt +0 -0
  104. {hexgate-0.2.3 → hexgate-0.2.4}/hexgate.egg-info/top_level.txt +0 -0
  105. {hexgate-0.2.3 → hexgate-0.2.4}/setup.cfg +0 -0
  106. {hexgate-0.2.3 → hexgate-0.2.4}/tests/test_bootstrap.py +0 -0
  107. {hexgate-0.2.3 → hexgate-0.2.4}/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.4
4
4
  Summary: Hexgate — authorization infrastructure for AI agents (agent runtime + cloud client).
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.13
@@ -223,6 +223,31 @@ make dashboard-install # pnpm install inside platform/dashboard/
223
223
 
224
224
  Then open http://localhost:5173/playground — type a message, watch the live stream of tool calls and policy decisions from your local agent.
225
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
+
226
251
  ### Audit log (ClickHouse)
227
252
 
228
253
  Policy decisions are written to a local ClickHouse instance for the audit dashboard. Requires Docker.
@@ -184,6 +184,31 @@ make dashboard-install # pnpm install inside platform/dashboard/
184
184
 
185
185
  Then open http://localhost:5173/playground — type a message, watch the live stream of tool calls and policy decisions from your local agent.
186
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
+
187
212
  ### Audit log (ClickHouse)
188
213
 
189
214
  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(
@@ -170,6 +170,22 @@ def platform_policy_from_payload(
170
170
  "Refusing to fall back to the pydantic engine."
171
171
  )
172
172
  else:
173
+ # Loud one-shot signal (fires at load time, not per turn) so an
174
+ # operator running `hexgate serve` doesn't silently get the
175
+ # pydantic engine when they expected the production-shaped
176
+ # WASM path. Common cause: `opa` not installed on the platform
177
+ # host — see compile_bundle() in platform/api/services.py, which
178
+ # logs "opa not on PATH" on the server side too.
179
+ logger.warning(
180
+ "policy for %r served without a WASM bundle — falling back to "
181
+ "the pydantic engine. Decisions are equivalent (parity-tested), "
182
+ "but signature verification and signed-artifact distribution "
183
+ "are off. Install `opa` on the platform host and re-save the "
184
+ "policy to get the WASM path; set %s=true to refuse the "
185
+ "fallback entirely.",
186
+ agent_name,
187
+ _REQUIRE_SIGNATURE_ENV_VAR,
188
+ )
173
189
  policy = load_policy_set_from_dict(
174
190
  yaml.safe_load(payload.get("policy_yaml") or "") or {}
175
191
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hexgate
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: Hexgate — authorization infrastructure for AI agents (agent runtime + cloud client).
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.13
@@ -223,6 +223,31 @@ make dashboard-install # pnpm install inside platform/dashboard/
223
223
 
224
224
  Then open http://localhost:5173/playground — type a message, watch the live stream of tool calls and policy decisions from your local agent.
225
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
+
226
251
  ### Audit log (ClickHouse)
227
252
 
228
253
  Policy decisions are written to a local ClickHouse instance for the audit dashboard. Requires Docker.
@@ -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.4"
13
13
  description = "Hexgate — authorization infrastructure for AI agents (agent runtime + cloud client)."
14
14
  readme = "README.md"
15
15
  license = "MIT"
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