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.
Files changed (107) hide show
  1. {hexgate-0.2.4 → hexgate-0.2.5}/PKG-INFO +3 -1
  2. {hexgate-0.2.4 → hexgate-0.2.5}/README.md +1 -0
  3. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/security/__init__.py +2 -0
  4. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/security/binding.py +31 -2
  5. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/security/source.py +135 -40
  6. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate.egg-info/PKG-INFO +3 -1
  7. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate.egg-info/requires.txt +1 -0
  8. {hexgate-0.2.4 → hexgate-0.2.5}/pyproject.toml +27 -1
  9. {hexgate-0.2.4 → hexgate-0.2.5}/LICENSE +0 -0
  10. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/__init__.py +0 -0
  11. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/adapters/__init__.py +0 -0
  12. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/adapters/google/__init__.py +0 -0
  13. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/adapters/google/runner.py +0 -0
  14. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/adapters/google/tools.py +0 -0
  15. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/adapters/google/wrapper.py +0 -0
  16. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/adapters/langchain/__init__.py +0 -0
  17. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/adapters/langchain/agent.py +0 -0
  18. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/adapters/langchain/tools.py +0 -0
  19. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/adapters/langchain/wrapper.py +0 -0
  20. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/adapters/openai/__init__.py +0 -0
  21. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/adapters/openai/runner.py +0 -0
  22. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/adapters/openai/tools.py +0 -0
  23. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/adapters/openai/wrapper.py +0 -0
  24. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/adapters/pydantic_ai/__init__.py +0 -0
  25. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/adapters/pydantic_ai/agent.py +0 -0
  26. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/adapters/pydantic_ai/tools.py +0 -0
  27. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/adapters/pydantic_ai/wrapper.py +0 -0
  28. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/agents/__init__.py +0 -0
  29. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/agents/builtin/__init__.py +0 -0
  30. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/agents/builtin/researcher/agent.yaml +0 -0
  31. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/agents/builtin/researcher/policy.yaml +0 -0
  32. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/agents/builtin/researcher/system.md +0 -0
  33. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/agents/factory.py +0 -0
  34. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/agents/loader.py +0 -0
  35. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/agents/models.py +0 -0
  36. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/agents/prompts/agent_system.md +0 -0
  37. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/audit.py +0 -0
  38. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/bootstrap.py +0 -0
  39. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cli/__init__.py +0 -0
  40. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cli/_common.py +0 -0
  41. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cli/chat.py +0 -0
  42. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cli/policy/__init__.py +0 -0
  43. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cli/policy/main.py +0 -0
  44. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cli/register/__init__.py +0 -0
  45. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cli/register/google.py +0 -0
  46. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cli/register/hexgate.py +0 -0
  47. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cli/register/langchain.py +0 -0
  48. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cli/register/main.py +0 -0
  49. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cli/register/manifest.py +0 -0
  50. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cli/register/models.py +0 -0
  51. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cli/register/openai.py +0 -0
  52. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cli/register/pydantic_ai.py +0 -0
  53. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cli/register/register.py +0 -0
  54. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cli/serve.py +0 -0
  55. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cli/state.py +0 -0
  56. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cloud/__init__.py +0 -0
  57. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cloud/attenuate.py +0 -0
  58. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cloud/biscuit.py +0 -0
  59. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/cloud/client.py +0 -0
  60. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/config/__init__.py +0 -0
  61. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/config/settings.py +0 -0
  62. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/runtime/__init__.py +0 -0
  63. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/runtime/command_policy.py +0 -0
  64. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/runtime/context.py +0 -0
  65. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/runtime/sandbox_runtime.py +0 -0
  66. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/runtime/srt.py +0 -0
  67. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/runtime/workspace.py +0 -0
  68. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/security/bundle.py +0 -0
  69. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/security/constraints.py +0 -0
  70. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/security/decision.py +0 -0
  71. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/security/enforcer.py +0 -0
  72. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/security/errors.py +0 -0
  73. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/security/file_scope.py +0 -0
  74. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/security/models.py +0 -0
  75. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/security/policy.py +0 -0
  76. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/security/policy_set.py +0 -0
  77. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/security/rego.py +0 -0
  78. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/security/rego_wasm.py +0 -0
  79. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/security/signing.py +0 -0
  80. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/security/wasm_engine.py +0 -0
  81. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/streaming/__init__.py +0 -0
  82. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/streaming/events.py +0 -0
  83. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/streaming/normalize.py +0 -0
  84. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/tools/__init__.py +0 -0
  85. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/tools/bash.py +0 -0
  86. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/tools/decorators.py +0 -0
  87. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/tools/fetch.py +0 -0
  88. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/tools/files/__init__.py +0 -0
  89. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/tools/files/_common.py +0 -0
  90. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/tools/files/edit_file.py +0 -0
  91. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/tools/files/glob.py +0 -0
  92. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/tools/files/grep.py +0 -0
  93. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/tools/files/read_file.py +0 -0
  94. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/tools/files/write_file.py +0 -0
  95. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/tools/refund.py +0 -0
  96. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/tools/websearch.py +0 -0
  97. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/tracing/__init__.py +0 -0
  98. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/tracing/langfuse.py +0 -0
  99. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/utils/__init__.py +0 -0
  100. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate/utils/retry.py +0 -0
  101. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate.egg-info/SOURCES.txt +0 -0
  102. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate.egg-info/dependency_links.txt +0 -0
  103. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate.egg-info/entry_points.txt +0 -0
  104. {hexgate-0.2.4 → hexgate-0.2.5}/hexgate.egg-info/top_level.txt +0 -0
  105. {hexgate-0.2.4 → hexgate-0.2.5}/setup.cfg +0 -0
  106. {hexgate-0.2.4 → hexgate-0.2.5}/tests/test_bootstrap.py +0 -0
  107. {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.4
3
+ Version: 0.2.5
4
4
  Summary: Hexgate — authorization infrastructure for AI agents (agent runtime + cloud client).
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.13
@@ -34,6 +34,7 @@ Requires-Dist: ipykernel; extra == "dev"
34
34
  Requires-Dist: jupyter; extra == "dev"
35
35
  Requires-Dist: pytest>=8.4.1; extra == "dev"
36
36
  Requires-Dist: pytest-asyncio>=1.0.0; extra == "dev"
37
+ Requires-Dist: pytest-cov>=6.0.0; extra == "dev"
37
38
  Requires-Dist: ruff>=0.12.2; extra == "dev"
38
39
  Dynamic: license-file
39
40
 
@@ -50,6 +51,7 @@ Policy enforcement, signed policy bundles, per-request user scope, audit trail
50
51
 
51
52
  [![PyPI](https://img.shields.io/pypi/v/hexgate?color=blue&logo=pypi&logoColor=white)](https://pypi.org/project/hexgate/)
52
53
  [![CI](https://github.com/HexamindOrganisation/hexgate/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/HexamindOrganisation/hexgate/actions/workflows/tests.yml)
54
+ [![codecov](https://codecov.io/gh/HexamindOrganisation/hexgate/branch/main/graph/badge.svg?flag=sdk)](https://codecov.io/gh/HexamindOrganisation/hexgate)
53
55
  [![Downloads](https://img.shields.io/pypi/dm/hexgate?color=blueviolet)](https://pypi.org/project/hexgate/)
54
56
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
55
57
 
@@ -11,6 +11,7 @@ Policy enforcement, signed policy bundles, per-request user scope, audit trail
11
11
 
12
12
  [![PyPI](https://img.shields.io/pypi/v/hexgate?color=blue&logo=pypi&logoColor=white)](https://pypi.org/project/hexgate/)
13
13
  [![CI](https://github.com/HexamindOrganisation/hexgate/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/HexamindOrganisation/hexgate/actions/workflows/tests.yml)
14
+ [![codecov](https://codecov.io/gh/HexamindOrganisation/hexgate/branch/main/graph/badge.svg?flag=sdk)](https://codecov.io/gh/HexamindOrganisation/hexgate)
14
15
  [![Downloads](https://img.shields.io/pypi/dm/hexgate?color=blueviolet)](https://pypi.org/project/hexgate/)
15
16
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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 unless the policy changed.
204
+ # Pre-seeded so the next refresh is a 304 (bundle path) or a cache
205
+ # hit (pydantic-fallback path) unless the policy actually changed.
206
+ # The yaml hash is only relevant on the pydantic-fallback path —
207
+ # compute it from the same `policy_yaml` we just parsed above so the
208
+ # source's first refresh comparison matches load-time exactly.
209
+ import hashlib
210
+
211
+ yaml_hash: str | None = None
212
+ # (#3) Mirror fetch()'s guard: don't seed an ETag on the no-bundle
213
+ # path or the first refresh could 304 and swallow an edit.
214
+ seed_etag: str | None = etag
215
+ if bundle is None:
216
+ yaml_text = payload.get("policy_yaml") or ""
217
+ yaml_hash = hashlib.sha256(yaml_text.encode("utf-8")).hexdigest()
218
+ seed_etag = None
194
219
  source = PlatformPolicySource(
195
- client, agent_name, initial_bundle=bundle, initial_etag=etag
220
+ client,
221
+ agent_name,
222
+ initial_engine=policy,
223
+ initial_etag=seed_etag,
224
+ initial_yaml_hash=yaml_hash,
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:`PolicyBundle` (or ``None``) from a source at
4
- every agent run; the source decides whether that's cheap or not. Three
5
- implementations cover the production + local-dev workflows:
3
+ The runtime fetches a :class:`~hexgate.security.decision.PolicyEngine`
4
+ (or ``None``) from a source at every agent run; the source decides
5
+ whether that's cheap or not. Three implementations cover the production
6
+ + local-dev workflows:
6
7
 
7
8
  * :class:`PlatformPolicySource` — HTTP fetch with ``If-None-Match`` /
8
9
  ``304 Not Modified``, so unchanged bundles cost one tiny round trip
9
10
  instead of a full payload + signature verify + wasm re-instantiation.
11
+ Falls back to the pydantic engine (a :class:`PolicySet` derived from
12
+ the response's ``policy_yaml``) when the platform served no compiled
13
+ bundle — the typical Modal / no-opa demo deployment shape.
10
14
  * :class:`BundleDirPolicySource` — refresh a pre-built bundle directory
11
15
  on disk (today's ``HEXGATE_LOCAL_POLICY=<dir>`` path, made mtime-aware
12
16
  so a rebuild via ``hexgate policy build`` takes effect on the next run).
@@ -21,12 +25,15 @@ on the protocol, not on any concrete type.
21
25
  from __future__ import annotations
22
26
 
23
27
  import base64
28
+ import hashlib
24
29
  import logging
25
30
  import os
26
31
  import threading
27
32
  from pathlib import Path
28
33
  from typing import TYPE_CHECKING, Protocol
29
34
 
35
+ import yaml
36
+
30
37
  from hexgate.security.bundle import (
31
38
  BundleIntegrityError,
32
39
  BundleLoadError,
@@ -34,6 +41,8 @@ from hexgate.security.bundle import (
34
41
  PolicyBundle,
35
42
  build_signed_bundle,
36
43
  )
44
+ from hexgate.security.decision import PolicyEngine
45
+ from hexgate.security.policy_set import PolicySetError, load_policy_set_from_dict
37
46
  from hexgate.security.signing import SignatureError, decode_key
38
47
 
39
48
  if TYPE_CHECKING:
@@ -45,37 +54,58 @@ if TYPE_CHECKING:
45
54
  logger = logging.getLogger("hexgate.security.source")
46
55
 
47
56
 
57
+ class PolicyContentError(RuntimeError):
58
+ """Platform served a payload, but the policy content is invalid.
59
+
60
+ Distinct from transient errors (network, signature) so
61
+ :meth:`PolicyBinding.refresh` can log at ``error`` level —
62
+ dashboard-saved-but-runtime-rejected is a correctness drift, not
63
+ "retry later".
64
+ """
65
+
66
+
48
67
  class PolicySource(Protocol):
49
- """Produces a current :class:`PolicyBundle` (or ``None``) on demand.
68
+ """Produces a current :class:`PolicyEngine` (or ``None``) on demand.
50
69
 
51
70
  Implementations are expected to be **cheap when nothing has changed**
52
71
  — caching, ETags, or mtime checks — so the agent runtime can call
53
72
  :meth:`fetch` at the top of every run without measurable cost.
54
73
 
55
- A returned ``None`` means "no bundle is configured for this source"
56
- (e.g. the platform served no compiled bundle). Callers fall back to
57
- whatever they had before (pydantic engine on raw YAML).
74
+ A returned ``None`` means "no engine is configured for this source"
75
+ (e.g. the platform served no policy at all). Callers keep whatever
76
+ engine they had before. The runtime's :class:`PolicyBinding.refresh`
77
+ relies on this: it only swaps when ``fetch()`` returns something
78
+ distinct from the current engine.
58
79
  """
59
80
 
60
- def fetch(self) -> PolicyBundle | None: ...
81
+ def fetch(self) -> PolicyEngine | None: ...
61
82
 
62
83
 
63
84
  class PlatformPolicySource:
64
- """Pull + verify a signed bundle from the platform, with ETag/304.
65
-
66
- Holds the last seen bundle and its ``wasm_hash`` (the ETag the
67
- platform serves). Each :meth:`fetch` sends ``If-None-Match`` and:
68
-
69
- * ``304`` returns the cached bundle without touching wasmtime or
70
- the signature path.
71
- * ``200`` decodes + verifies the new payload, caches it, returns.
72
- * payload with no bundle returns ``None`` (the platform couldn't
73
- compile, e.g. opa missing on the control plane the SDK then
74
- falls back to its pydantic engine).
75
-
76
- Verification fails are fatal (a tampered platform bundle is never
77
- silently downgraded). The signature is checked against the same
78
- public key the SDK already trusts for biscuit verification.
85
+ """Pull a current policy engine from the platform, with ETag/304.
86
+
87
+ Two engines flow out, depending on what the platform has compiled:
88
+
89
+ * **WASM bundle** (production shape) — when the platform's
90
+ ``compiled_wasm`` is populated, we get a signed bundle back and
91
+ return a verified :class:`PolicyBundle`. ETag = ``wasm_hash``;
92
+ unchanged bundles hit ``304`` and re-use the cached object.
93
+ * **Pydantic fallback** (no-opa / demo shape) when the platform
94
+ couldn't compile (no ``opa`` on the control plane), the response
95
+ carries ``policy_yaml`` but null bundle fields. We hash the yaml,
96
+ compare against the last seen hash, and re-construct a fresh
97
+ :class:`PolicySet` only when the yaml content actually changed.
98
+
99
+ Without the pydantic-fallback branch a policy edit would silently
100
+ no-op for any deployment without opa — :meth:`fetch` would always
101
+ return ``None`` (no bundle), :class:`PolicyBinding.refresh` would
102
+ treat that as "nothing served" and skip the swap, and the initial
103
+ engine built by :func:`platform_policy_from_payload` would stay
104
+ frozen forever.
105
+
106
+ Verification fails on the bundle path are fatal (a tampered bundle
107
+ is never silently downgraded). Verification uses the same public
108
+ key the SDK already trusts for biscuit verification.
79
109
  """
80
110
 
81
111
  def __init__(
@@ -85,46 +115,111 @@ class PlatformPolicySource:
85
115
  *,
86
116
  initial_bundle: PolicyBundle | None = None,
87
117
  initial_etag: str | None = None,
118
+ initial_engine: PolicyEngine | None = None,
119
+ initial_yaml_hash: str | None = None,
88
120
  ) -> None:
89
121
  self._client = client
90
122
  self._agent_name = agent_name
91
- # Pre-seed when the caller already fetched + verified the bundle
92
- # (typical at agent load time). Avoids a redundant 200 round-trip
93
- # on the first refresh that call will send If-None-Match and
94
- # get a cheap 304.
95
- self._cached_bundle: PolicyBundle | None = initial_bundle
123
+ # Pre-seed when the caller already fetched + verified at load time.
124
+ # `initial_engine` covers both shapes (a PolicyBundle on the WASM
125
+ # path, a PolicySet on the pydantic-fallback path); `initial_bundle`
126
+ # stays as a back-compat alias that callers used before we
127
+ # broadened the engine type.
128
+ self._cached_engine: PolicyEngine | None = (
129
+ initial_engine if initial_engine is not None else initial_bundle
130
+ )
96
131
  self._cached_etag: str | None = initial_etag
132
+ # Hash of the `policy_yaml` text that produced the cached *pydantic*
133
+ # engine. Used solely on the no-bundle branch to decide whether
134
+ # the platform's response represents a real change: a same-hash
135
+ # response returns the cached PolicySet (preserves identity → the
136
+ # binding's `is policy` check skips the swap); a new hash builds
137
+ # and caches a fresh one. ``None`` on the bundle path (we use
138
+ # ``_cached_etag`` for that).
139
+ self._cached_yaml_hash: str | None = initial_yaml_hash
97
140
  # Serialize the (read cached_etag → HTTP → write cached_*) cycle.
98
141
  # Refresh runs on a to_thread worker, so two concurrent agent runs
99
142
  # sharing one source could otherwise interleave a write to
100
- # _cached_bundle with another's read of _cached_etag and pair the
143
+ # _cached_engine with another's read of _cached_etag and pair the
101
144
  # bundle from one response with the etag from another → a later
102
145
  # spurious 200/304. The cost is serializing refreshes for shared
103
146
  # sources, which is fine: refresh is best-effort and rare-ish per
104
147
  # turn (most calls hit a cheap 304).
105
148
  self._lock = threading.Lock()
106
149
 
107
- def fetch(self) -> PolicyBundle | None:
150
+ def fetch(self) -> PolicyEngine | None:
108
151
  with self._lock:
109
152
  payload, etag = self._client.get_agent(
110
153
  self._agent_name, if_none_match=self._cached_etag
111
154
  )
112
155
  # 304 — nothing changed since last fetch. Cheap path.
113
156
  if payload is None:
114
- return self._cached_bundle
157
+ return self._cached_engine
115
158
 
116
159
  bundle = decode_and_verify_platform_bundle(
117
160
  payload, self._client.public_key_bytes()
118
161
  )
119
- self._cached_bundle = bundle
120
- # Server-supplied ETag wins; fall back to wasm_hash for when the
121
- # response lacked an ETag header (older platform versions).
122
- self._cached_etag = etag or (
123
- f'"{bundle.wasm_hash}"'
124
- if bundle is not None and bundle.wasm_hash
125
- else None
126
- )
127
- return bundle
162
+ if bundle is not None:
163
+ # WASM path. ETag tracking is on the wasm_hash; the yaml
164
+ # hash is irrelevant here, clear it so a later transition
165
+ # to the pydantic branch (platform loses opa) doesn't
166
+ # mistakenly reuse a stale hash from the old wasm world.
167
+ self._cached_engine = bundle
168
+ self._cached_etag = etag or (
169
+ f'"{bundle.wasm_hash}"' if bundle.wasm_hash else None
170
+ )
171
+ self._cached_yaml_hash = None
172
+ return bundle
173
+
174
+ # No bundle — platform couldn't compile (no opa, etc.) but
175
+ # served the raw policy_yaml. Build a PolicySet from it.
176
+
177
+ # (#1) Refuse the downgrade under strict mode. Load-time
178
+ # already refuses; this catches opa-went-down mid-session.
179
+ # Caught by binding.refresh → keeps last verified bundle.
180
+ if _truthy(os.environ.get(_REQUIRE_SIGNATURE_ENV_VAR)):
181
+ raise RuntimeError(
182
+ f"{_REQUIRE_SIGNATURE_ENV_VAR} is set but no signed "
183
+ f"bundle served for {self._agent_name!r} on refresh — "
184
+ "keeping last verified policy."
185
+ )
186
+
187
+ # (#3) Ignore server ETag on this branch — its semantics
188
+ # aren't defined here, and a 304 would skip the hash check
189
+ # below and swallow an edit. Yaml-hash is the change detector.
190
+ yaml_text = payload.get("policy_yaml") or ""
191
+ new_hash = hashlib.sha256(yaml_text.encode("utf-8")).hexdigest()
192
+ if new_hash == self._cached_yaml_hash and self._cached_engine is not None:
193
+ # Identity preserved → binding's `is` check skips the swap.
194
+ self._cached_etag = None
195
+ return self._cached_engine
196
+
197
+ # (#2) Surface parse/validate failures as PolicyContentError
198
+ # so binding logs at ERROR — silent swallow would recreate
199
+ # the original bug for invalid edits.
200
+ try:
201
+ parsed = yaml.safe_load(yaml_text) or {}
202
+ except yaml.YAMLError as exc:
203
+ raise PolicyContentError(
204
+ f"unparseable policy_yaml for {self._agent_name!r}: {exc}"
205
+ ) from exc
206
+ try:
207
+ new_engine = load_policy_set_from_dict(parsed)
208
+ except (PolicySetError, ValueError, TypeError) as exc:
209
+ # ValueError covers pydantic ValidationError too.
210
+ raise PolicyContentError(
211
+ f"invalid policy_yaml for {self._agent_name!r}: {exc}"
212
+ ) from exc
213
+
214
+ # (#4) Per-turn cost = full GET + sha256; parse only on change.
215
+ # Can't 304 without server ETag-on-policy_yaml (future fix).
216
+ # Until then the per-turn cost is one round trip + a sha256,
217
+ # acceptable for the demo-shaped deployments this branch
218
+ # targets.
219
+ self._cached_engine = new_engine
220
+ self._cached_yaml_hash = new_hash
221
+ self._cached_etag = None
222
+ return self._cached_engine
128
223
 
129
224
 
130
225
  def decode_and_verify_platform_bundle(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hexgate
3
- Version: 0.2.4
3
+ Version: 0.2.5
4
4
  Summary: Hexgate — authorization infrastructure for AI agents (agent runtime + cloud client).
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.13
@@ -34,6 +34,7 @@ Requires-Dist: ipykernel; extra == "dev"
34
34
  Requires-Dist: jupyter; extra == "dev"
35
35
  Requires-Dist: pytest>=8.4.1; extra == "dev"
36
36
  Requires-Dist: pytest-asyncio>=1.0.0; extra == "dev"
37
+ Requires-Dist: pytest-cov>=6.0.0; extra == "dev"
37
38
  Requires-Dist: ruff>=0.12.2; extra == "dev"
38
39
  Dynamic: license-file
39
40
 
@@ -50,6 +51,7 @@ Policy enforcement, signed policy bundles, per-request user scope, audit trail
50
51
 
51
52
  [![PyPI](https://img.shields.io/pypi/v/hexgate?color=blue&logo=pypi&logoColor=white)](https://pypi.org/project/hexgate/)
52
53
  [![CI](https://github.com/HexamindOrganisation/hexgate/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/HexamindOrganisation/hexgate/actions/workflows/tests.yml)
54
+ [![codecov](https://codecov.io/gh/HexamindOrganisation/hexgate/branch/main/graph/badge.svg?flag=sdk)](https://codecov.io/gh/HexamindOrganisation/hexgate)
53
55
  [![Downloads](https://img.shields.io/pypi/dm/hexgate?color=blueviolet)](https://pypi.org/project/hexgate/)
54
56
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
55
57
 
@@ -27,4 +27,5 @@ ipykernel
27
27
  jupyter
28
28
  pytest>=8.4.1
29
29
  pytest-asyncio>=1.0.0
30
+ pytest-cov>=6.0.0
30
31
  ruff>=0.12.2
@@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta"
9
9
  # 0.2.0 (the original package name was taken on PyPI by a 2014
10
10
  # abandoned project; the team consolidated on `hexgate` for everything).
11
11
  name = "hexgate"
12
- version = "0.2.4"
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