hexgate 0.2.4__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.4 → hexgate-0.2.5}/PKG-INFO +3 -1
- {hexgate-0.2.4 → hexgate-0.2.5}/README.md +1 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/security/__init__.py +2 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/security/binding.py +31 -2
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/security/source.py +135 -40
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate.egg-info/PKG-INFO +3 -1
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate.egg-info/requires.txt +1 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/pyproject.toml +27 -1
- {hexgate-0.2.4 → hexgate-0.2.5}/LICENSE +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/__init__.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/adapters/__init__.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/adapters/google/__init__.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/adapters/google/runner.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/adapters/google/tools.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/adapters/google/wrapper.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/adapters/langchain/__init__.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/adapters/langchain/agent.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/adapters/langchain/tools.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/adapters/langchain/wrapper.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/adapters/openai/__init__.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/adapters/openai/runner.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/adapters/openai/tools.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/adapters/openai/wrapper.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/adapters/pydantic_ai/__init__.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/adapters/pydantic_ai/agent.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/adapters/pydantic_ai/tools.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/adapters/pydantic_ai/wrapper.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/agents/__init__.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/agents/builtin/__init__.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/agents/builtin/researcher/agent.yaml +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/agents/builtin/researcher/policy.yaml +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/agents/builtin/researcher/system.md +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/agents/factory.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/agents/loader.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/agents/models.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/agents/prompts/agent_system.md +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/audit.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/bootstrap.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cli/__init__.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cli/_common.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cli/chat.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cli/policy/__init__.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cli/policy/main.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cli/register/__init__.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cli/register/google.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cli/register/hexgate.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cli/register/langchain.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cli/register/main.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cli/register/manifest.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cli/register/models.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cli/register/openai.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cli/register/pydantic_ai.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cli/register/register.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cli/serve.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cli/state.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cloud/__init__.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cloud/attenuate.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cloud/biscuit.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cloud/client.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/config/__init__.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/config/settings.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/runtime/__init__.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/runtime/command_policy.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/runtime/context.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/runtime/sandbox_runtime.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/runtime/srt.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/runtime/workspace.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/security/bundle.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/security/constraints.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/security/decision.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/security/enforcer.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/security/errors.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/security/file_scope.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/security/models.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/security/policy.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/security/policy_set.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/security/rego.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/security/rego_wasm.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/security/signing.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/security/wasm_engine.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/streaming/__init__.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/streaming/events.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/streaming/normalize.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/tools/__init__.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/tools/bash.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/tools/decorators.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/tools/fetch.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/tools/files/__init__.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/tools/files/_common.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/tools/files/edit_file.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/tools/files/glob.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/tools/files/grep.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/tools/files/read_file.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/tools/files/write_file.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/tools/refund.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/tools/websearch.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/tracing/__init__.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/tracing/langfuse.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/utils/__init__.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/utils/retry.py +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate.egg-info/SOURCES.txt +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate.egg-info/dependency_links.txt +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate.egg-info/entry_points.txt +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/hexgate.egg-info/top_level.txt +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/setup.cfg +0 -0
- {hexgate-0.2.4 → hexgate-0.2.5}/tests/test_bootstrap.py +0 -0
- {hexgate-0.2.4 → 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
|
|
|
@@ -11,6 +11,7 @@ Policy enforcement, signed policy bundles, per-request user scope, audit trail
|
|
|
11
11
|
|
|
12
12
|
[](https://pypi.org/project/hexgate/)
|
|
13
13
|
[](https://github.com/HexamindOrganisation/hexgate/actions/workflows/tests.yml)
|
|
14
|
+
[](https://codecov.io/gh/HexamindOrganisation/hexgate)
|
|
14
15
|
[](https://pypi.org/project/hexgate/)
|
|
15
16
|
[](LICENSE)
|
|
16
17
|
|
|
@@ -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",
|
|
@@ -190,8 +201,26 @@ def platform_policy_from_payload(
|
|
|
190
201
|
yaml.safe_load(payload.get("policy_yaml") or "") or {}
|
|
191
202
|
)
|
|
192
203
|
|
|
193
|
-
# 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
|
|
194
219
|
source = PlatformPolicySource(
|
|
195
|
-
client,
|
|
220
|
+
client,
|
|
221
|
+
agent_name,
|
|
222
|
+
initial_engine=policy,
|
|
223
|
+
initial_etag=seed_etag,
|
|
224
|
+
initial_yaml_hash=yaml_hash,
|
|
196
225
|
)
|
|
197
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,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
|
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|