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.
- {hexgate-0.2.3/hexgate.egg-info → hexgate-0.2.5}/PKG-INFO +28 -1
- hexgate-0.2.3/PKG-INFO → hexgate-0.2.5/README.md +26 -39
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/adapters/langchain/agent.py +6 -3
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/agents/factory.py +23 -8
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/audit.py +30 -4
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cli/_common.py +17 -7
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cli/register/hexgate.py +1 -1
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cli/register/models.py +1 -1
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/runtime/context.py +12 -9
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/security/__init__.py +2 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/security/binding.py +47 -2
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/security/source.py +135 -40
- hexgate-0.2.3/README.md → hexgate-0.2.5/hexgate.egg-info/PKG-INFO +66 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate.egg-info/requires.txt +1 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/pyproject.toml +27 -1
- {hexgate-0.2.3 → hexgate-0.2.5}/LICENSE +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/__init__.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/adapters/__init__.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/adapters/google/__init__.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/adapters/google/runner.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/adapters/google/tools.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/adapters/google/wrapper.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/adapters/langchain/__init__.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/adapters/langchain/tools.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/adapters/langchain/wrapper.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/adapters/openai/__init__.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/adapters/openai/runner.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/adapters/openai/tools.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/adapters/openai/wrapper.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/adapters/pydantic_ai/__init__.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/adapters/pydantic_ai/agent.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/adapters/pydantic_ai/tools.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/adapters/pydantic_ai/wrapper.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/agents/__init__.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/agents/builtin/__init__.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/agents/builtin/researcher/agent.yaml +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/agents/builtin/researcher/policy.yaml +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/agents/builtin/researcher/system.md +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/agents/loader.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/agents/models.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/agents/prompts/agent_system.md +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/bootstrap.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cli/__init__.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cli/chat.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cli/policy/__init__.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cli/policy/main.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cli/register/__init__.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cli/register/google.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cli/register/langchain.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cli/register/main.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cli/register/manifest.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cli/register/openai.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cli/register/pydantic_ai.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cli/register/register.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cli/serve.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cli/state.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cloud/__init__.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cloud/attenuate.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cloud/biscuit.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/cloud/client.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/config/__init__.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/config/settings.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/runtime/__init__.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/runtime/command_policy.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/runtime/sandbox_runtime.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/runtime/srt.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/runtime/workspace.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/security/bundle.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/security/constraints.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/security/decision.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/security/enforcer.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/security/errors.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/security/file_scope.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/security/models.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/security/policy.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/security/policy_set.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/security/rego.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/security/rego_wasm.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/security/signing.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/security/wasm_engine.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/streaming/__init__.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/streaming/events.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/streaming/normalize.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/tools/__init__.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/tools/bash.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/tools/decorators.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/tools/fetch.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/tools/files/__init__.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/tools/files/_common.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/tools/files/edit_file.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/tools/files/glob.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/tools/files/grep.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/tools/files/read_file.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/tools/files/write_file.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/tools/refund.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/tools/websearch.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/tracing/__init__.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/tracing/langfuse.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/utils/__init__.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate/utils/retry.py +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate.egg-info/SOURCES.txt +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate.egg-info/dependency_links.txt +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate.egg-info/entry_points.txt +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/hexgate.egg-info/top_level.txt +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/setup.cfg +0 -0
- {hexgate-0.2.3 → hexgate-0.2.5}/tests/test_bootstrap.py +0 -0
- {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
|
+
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
|
[](https://pypi.org/project/hexgate/)
|
|
52
53
|
[](https://github.com/HexamindOrganisation/hexgate/actions/workflows/tests.yml)
|
|
54
|
+
[](https://codecov.io/gh/HexamindOrganisation/hexgate)
|
|
53
55
|
[](https://pypi.org/project/hexgate/)
|
|
54
56
|
[](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
|
[](https://pypi.org/project/hexgate/)
|
|
52
13
|
[](https://github.com/HexamindOrganisation/hexgate/actions/workflows/tests.yml)
|
|
14
|
+
[](https://codecov.io/gh/HexamindOrganisation/hexgate)
|
|
53
15
|
[](https://pypi.org/project/hexgate/)
|
|
54
16
|
[](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,
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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.
|
|
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,
|
|
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
|
-
|
|
252
|
-
|
|
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(
|
|
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.
|
|
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],
|
|
@@ -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
|
|
100
|
-
|
|
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.
|
|
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.
|
|
108
|
-
_CURRENT_USER.
|
|
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.
|
|
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.
|
|
118
|
-
_CURRENT_USER.
|
|
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
|
|
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,
|
|
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
|
|
4
|
-
every agent run; the source decides
|
|
5
|
-
implementations cover the production
|
|
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:`
|
|
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
|
|
56
|
-
(e.g. the platform served no
|
|
57
|
-
|
|
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) ->
|
|
81
|
+
def fetch(self) -> PolicyEngine | None: ...
|
|
61
82
|
|
|
62
83
|
|
|
63
84
|
class PlatformPolicySource:
|
|
64
|
-
"""Pull
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
*
|
|
73
|
-
compile
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
#
|
|
95
|
-
|
|
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
|
-
#
|
|
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) ->
|
|
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.
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
[](https://pypi.org/project/hexgate/)
|
|
13
53
|
[](https://github.com/HexamindOrganisation/hexgate/actions/workflows/tests.yml)
|
|
54
|
+
[](https://codecov.io/gh/HexamindOrganisation/hexgate)
|
|
14
55
|
[](https://pypi.org/project/hexgate/)
|
|
15
56
|
[](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.
|
|
@@ -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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|