controlzero 1.2.0__tar.gz → 1.4.0__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 (96) hide show
  1. {controlzero-1.2.0 → controlzero-1.4.0}/PKG-INFO +46 -1
  2. {controlzero-1.2.0 → controlzero-1.4.0}/README.md +40 -0
  3. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/__init__.py +1 -1
  4. controlzero-1.4.0/controlzero/_internal/bundle.py +409 -0
  5. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/audit_remote.py +165 -0
  6. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/cli/main.py +522 -122
  7. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/cli/templates/autogen.yaml +8 -0
  8. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/cli/templates/claude-code.yaml +8 -0
  9. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/cli/templates/codex-cli.yaml +8 -0
  10. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/cli/templates/cost-cap.yaml +8 -0
  11. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/cli/templates/crewai.yaml +8 -0
  12. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/cli/templates/cursor.yaml +8 -0
  13. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/cli/templates/gemini-cli.yaml +8 -0
  14. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/cli/templates/generic.yaml +8 -0
  15. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/cli/templates/langchain.yaml +8 -0
  16. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/cli/templates/mcp.yaml +8 -0
  17. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/cli/templates/rag.yaml +8 -0
  18. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/client.py +122 -27
  19. controlzero-1.4.0/controlzero/device.py +281 -0
  20. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/enrollment.py +47 -0
  21. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/errors.py +25 -0
  22. controlzero-1.4.0/controlzero/hosted_policy.py +413 -0
  23. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/policy_loader.py +36 -5
  24. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/tamper.py +125 -0
  25. controlzero-1.4.0/controlzero.2026-04-07_22-05-15_956171.log +2 -0
  26. controlzero-1.4.0/controlzero.2026-04-08_16-53-22_852394.log +6 -0
  27. controlzero-1.4.0/controlzero.log +49 -0
  28. {controlzero-1.2.0 → controlzero-1.4.0}/pyproject.toml +6 -1
  29. controlzero-1.4.0/tests/test_bundle_parser.py +231 -0
  30. {controlzero-1.2.0 → controlzero-1.4.0}/tests/test_cli_hook.py +123 -5
  31. controlzero-1.4.0/tests/test_cli_hosted_refresh.py +151 -0
  32. {controlzero-1.2.0 → controlzero-1.4.0}/tests/test_coding_agent_hooks.py +227 -5
  33. controlzero-1.4.0/tests/test_device.py +274 -0
  34. {controlzero-1.2.0 → controlzero-1.4.0}/tests/test_enrollment.py +156 -1
  35. controlzero-1.4.0/tests/test_hosted_policy_e2e.py +217 -0
  36. controlzero-1.4.0/tests/test_hybrid_mode_strict.py +86 -0
  37. {controlzero-1.2.0 → controlzero-1.4.0}/tests/test_install_hooks.py +105 -62
  38. controlzero-1.4.0/tests/test_policy_settings.py +86 -0
  39. controlzero-1.4.0/tests/test_quarantine.py +193 -0
  40. controlzero-1.4.0/tests/test_tamper_behavior.py +167 -0
  41. controlzero-1.2.0/.gitignore +0 -220
  42. controlzero-1.2.0/tests/test_hybrid_mode_strict.py +0 -53
  43. {controlzero-1.2.0 → controlzero-1.4.0}/Dockerfile.test +0 -0
  44. {controlzero-1.2.0 → controlzero-1.4.0}/LICENSE +0 -0
  45. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/_internal/__init__.py +0 -0
  46. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/_internal/dlp_scanner.py +0 -0
  47. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/_internal/enforcer.py +0 -0
  48. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/_internal/types.py +0 -0
  49. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/audit_local.py +0 -0
  50. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/cli/__init__.py +0 -0
  51. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/integrations/__init__.py +0 -0
  52. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/integrations/anthropic.py +0 -0
  53. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/integrations/braintrust.py +0 -0
  54. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/integrations/crewai/__init__.py +0 -0
  55. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/integrations/crewai/agent.py +0 -0
  56. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/integrations/crewai/crew.py +0 -0
  57. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/integrations/crewai/task.py +0 -0
  58. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/integrations/crewai/tool.py +0 -0
  59. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/integrations/google.py +0 -0
  60. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/integrations/google_adk/__init__.py +0 -0
  61. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/integrations/google_adk/agent.py +0 -0
  62. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/integrations/google_adk/tool.py +0 -0
  63. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/integrations/langchain/__init__.py +0 -0
  64. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/integrations/langchain/agent.py +0 -0
  65. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/integrations/langchain/callbacks.py +0 -0
  66. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/integrations/langchain/chain.py +0 -0
  67. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/integrations/langchain/graph.py +0 -0
  68. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/integrations/langchain/tool.py +0 -0
  69. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/integrations/langfuse.py +0 -0
  70. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/integrations/litellm.py +0 -0
  71. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/integrations/openai.py +0 -0
  72. {controlzero-1.2.0 → controlzero-1.4.0}/controlzero/integrations/vercel_ai.py +0 -0
  73. {controlzero-1.2.0 → controlzero-1.4.0}/examples/hello_world.py +0 -0
  74. {controlzero-1.2.0 → controlzero-1.4.0}/tests/conftest.py +0 -0
  75. {controlzero-1.2.0 → controlzero-1.4.0}/tests/test_audit_remote.py +0 -0
  76. {controlzero-1.2.0 → controlzero-1.4.0}/tests/test_audit_sink_isolation.py +0 -0
  77. {controlzero-1.2.0 → controlzero-1.4.0}/tests/test_cli_init.py +0 -0
  78. {controlzero-1.2.0 → controlzero-1.4.0}/tests/test_cli_init_templates.py +0 -0
  79. {controlzero-1.2.0 → controlzero-1.4.0}/tests/test_cli_tail.py +0 -0
  80. {controlzero-1.2.0 → controlzero-1.4.0}/tests/test_cli_test.py +0 -0
  81. {controlzero-1.2.0 → controlzero-1.4.0}/tests/test_cli_validate.py +0 -0
  82. {controlzero-1.2.0 → controlzero-1.4.0}/tests/test_dlp_scanner.py +0 -0
  83. {controlzero-1.2.0 → controlzero-1.4.0}/tests/test_fail_closed_eval.py +0 -0
  84. {controlzero-1.2.0 → controlzero-1.4.0}/tests/test_glob_matching.py +0 -0
  85. {controlzero-1.2.0 → controlzero-1.4.0}/tests/test_hybrid_mode_warn.py +0 -0
  86. {controlzero-1.2.0 → controlzero-1.4.0}/tests/test_local_mode_dict.py +0 -0
  87. {controlzero-1.2.0 → controlzero-1.4.0}/tests/test_local_mode_file_json.py +0 -0
  88. {controlzero-1.2.0 → controlzero-1.4.0}/tests/test_local_mode_file_yaml.py +0 -0
  89. {controlzero-1.2.0 → controlzero-1.4.0}/tests/test_log_fallback_stderr.py +0 -0
  90. {controlzero-1.2.0 → controlzero-1.4.0}/tests/test_log_options_ignored_hosted.py +0 -0
  91. {controlzero-1.2.0 → controlzero-1.4.0}/tests/test_log_rotation.py +0 -0
  92. {controlzero-1.2.0 → controlzero-1.4.0}/tests/test_no_policy_no_key.py +0 -0
  93. {controlzero-1.2.0 → controlzero-1.4.0}/tests/test_package_rename_shim.py +0 -0
  94. {controlzero-1.2.0 → controlzero-1.4.0}/tests/test_policy_freshness.py +0 -0
  95. {controlzero-1.2.0 → controlzero-1.4.0}/tests/test_tamper.py +0 -0
  96. {controlzero-1.2.0 → controlzero-1.4.0}/tests/test_tamper_hook.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: controlzero
3
- Version: 1.2.0
3
+ Version: 1.4.0
4
4
  Summary: AI agent governance: policies, audit, and observability for tool calls. Works locally with no signup.
5
5
  Project-URL: Homepage, https://controlzero.ai
6
6
  Project-URL: Documentation, https://docs.controlzero.ai
@@ -27,13 +27,18 @@ Requires-Dist: loguru>=0.7.0
27
27
  Requires-Dist: pydantic>=2.0.0
28
28
  Requires-Dist: pyyaml>=6.0
29
29
  Provides-Extra: dev
30
+ Requires-Dist: cryptography>=41.0.0; extra == 'dev'
31
+ Requires-Dist: httpx>=0.25.0; extra == 'dev'
30
32
  Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
31
33
  Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
32
34
  Requires-Dist: pytest>=7.0.0; extra == 'dev'
33
35
  Requires-Dist: pyyaml>=6.0; extra == 'dev'
36
+ Requires-Dist: respx>=0.20.0; extra == 'dev'
37
+ Requires-Dist: zstandard>=0.22.0; extra == 'dev'
34
38
  Provides-Extra: hosted
35
39
  Requires-Dist: cryptography>=41.0.0; extra == 'hosted'
36
40
  Requires-Dist: httpx>=0.25.0; extra == 'hosted'
41
+ Requires-Dist: zstandard>=0.22.0; extra == 'hosted'
37
42
  Description-Content-Type: text/markdown
38
43
 
39
44
  # control-zero
@@ -158,6 +163,46 @@ rules:
158
163
  Rules are evaluated top to bottom. The first match wins. If no rule matches,
159
164
  the call is denied (fail-closed).
160
165
 
166
+ ## Tamper detection and quarantine
167
+
168
+ The policy YAML supports a `settings:` section that controls how the SDK
169
+ responds when it detects that the local policy file has been modified outside
170
+ of normal channels (manual edits, unexpected hash changes, etc.):
171
+
172
+ ```yaml
173
+ version: '1'
174
+ settings:
175
+ tamper_behavior: warn # Options: warn | deny | deny-all | quarantine
176
+ rules:
177
+ - deny: 'delete_*'
178
+ - allow: '*'
179
+ ```
180
+
181
+ | Mode | Behavior |
182
+ | ------------ | ------------------------------------------------------------------------ |
183
+ | `warn` | Log a warning but continue evaluating rules normally. |
184
+ | `deny` | Deny the current tool call that triggered the tamper check. |
185
+ | `deny-all` | Deny all tool calls and place the machine in quarantine until recovered. |
186
+ | `quarantine` | Same as `deny-all`, plus report a tamper alert to the backend dashboard. |
187
+
188
+ **Quarantine recovery.** When a machine enters quarantine (`deny-all` or
189
+ `quarantine`), every tool call is denied until you re-establish trust with one
190
+ of these commands:
191
+
192
+ ```bash
193
+ controlzero enroll
194
+ controlzero policy-pull
195
+ controlzero sign-policy
196
+ ```
197
+
198
+ **Org-level policy signing.** When a machine is enrolled via `controlzero enroll`,
199
+ it receives the organization's signing public key. Policy bundles pulled from
200
+ the backend are cryptographically signed and verified by the SDK automatically.
201
+ No extra configuration is required.
202
+
203
+ **Tamper alert reporting.** In `quarantine` mode, the SDK reports a tamper alert
204
+ to the Control Zero backend so your team can see it on the dashboard.
205
+
161
206
  ## Local audit log
162
207
 
163
208
  When running without an API key, every decision is written to `./controlzero.log`
@@ -120,6 +120,46 @@ rules:
120
120
  Rules are evaluated top to bottom. The first match wins. If no rule matches,
121
121
  the call is denied (fail-closed).
122
122
 
123
+ ## Tamper detection and quarantine
124
+
125
+ The policy YAML supports a `settings:` section that controls how the SDK
126
+ responds when it detects that the local policy file has been modified outside
127
+ of normal channels (manual edits, unexpected hash changes, etc.):
128
+
129
+ ```yaml
130
+ version: '1'
131
+ settings:
132
+ tamper_behavior: warn # Options: warn | deny | deny-all | quarantine
133
+ rules:
134
+ - deny: 'delete_*'
135
+ - allow: '*'
136
+ ```
137
+
138
+ | Mode | Behavior |
139
+ | ------------ | ------------------------------------------------------------------------ |
140
+ | `warn` | Log a warning but continue evaluating rules normally. |
141
+ | `deny` | Deny the current tool call that triggered the tamper check. |
142
+ | `deny-all` | Deny all tool calls and place the machine in quarantine until recovered. |
143
+ | `quarantine` | Same as `deny-all`, plus report a tamper alert to the backend dashboard. |
144
+
145
+ **Quarantine recovery.** When a machine enters quarantine (`deny-all` or
146
+ `quarantine`), every tool call is denied until you re-establish trust with one
147
+ of these commands:
148
+
149
+ ```bash
150
+ controlzero enroll
151
+ controlzero policy-pull
152
+ controlzero sign-policy
153
+ ```
154
+
155
+ **Org-level policy signing.** When a machine is enrolled via `controlzero enroll`,
156
+ it receives the organization's signing public key. Policy bundles pulled from
157
+ the backend are cryptographically signed and verified by the SDK automatically.
158
+ No extra configuration is required.
159
+
160
+ **Tamper alert reporting.** In `quarantine` mode, the SDK reports a tamper alert
161
+ to the Control Zero backend so your team can see it on the dashboard.
162
+
123
163
  ## Local audit log
124
164
 
125
165
  When running without an API key, every decision is written to `./controlzero.log`
@@ -28,7 +28,7 @@ from controlzero.errors import (
28
28
  )
29
29
  from controlzero.policy_loader import load_policy
30
30
 
31
- __version__ = "1.2.0"
31
+ __version__ = "1.3.0"
32
32
 
33
33
  __all__ = [
34
34
  "Client",
@@ -0,0 +1,409 @@
1
+ """Policy bundle parser (`.czpolicy`).
2
+
3
+ See `docs/designs/policy-bundle-format.md` for the authoritative spec.
4
+
5
+ This module is a *pure function* parser: given bundle bytes plus the
6
+ project encryption key and the project signing public key, it either
7
+ returns the decrypted policy payload as a dict, or raises a
8
+ :class:`BundleVerificationError` / :class:`BundleFormatError`.
9
+
10
+ The parser performs **no I/O**. It does not read from disk. It does not
11
+ make network calls. It does not mutate global state. This is
12
+ intentional: the integrity of the policy flow depends on the parser
13
+ being auditable in isolation. All I/O (fetching bundles, caching,
14
+ retries) lives in :mod:`controlzero.hosted_policy`.
15
+
16
+ Wire format (little-endian throughout):
17
+
18
+ offset size field
19
+ 0 4 magic ASCII "CZ01"
20
+ 4 2 schema_version uint16
21
+ 6 8 created_at uint64 UNIX seconds
22
+ 14 2 policy_count uint16 (informational)
23
+ 16 4 sig_offset uint32 (absolute byte offset of signature)
24
+ 20 4 sig_len uint32 (must be 64)
25
+ 24 8 reserved must be zero
26
+ 32 N payload authenticated-encryption over zstd(json)
27
+ 32+N 64 signature signature over header[0:32] || payload
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import struct
34
+ from dataclasses import dataclass
35
+ from typing import Optional # noqa: F401 (used in type annotations below)
36
+
37
+ # --- Exceptions ------------------------------------------------------------
38
+
39
+
40
+ class BundleFormatError(ValueError):
41
+ """Bundle bytes do not match the expected wire format.
42
+
43
+ Raised for structural problems: wrong magic, impossible offsets,
44
+ truncated data, unknown schema version. Never raised for cryptographic
45
+ failures -- use :class:`BundleVerificationError` for those.
46
+ """
47
+
48
+
49
+ class BundleVerificationError(ValueError):
50
+ """Bundle signature or AES-GCM authentication tag failed to verify.
51
+
52
+ This is the fail-closed signal. The SDK MUST NOT use any policy data
53
+ extracted before this exception was raised.
54
+ """
55
+
56
+
57
+ # --- Constants -------------------------------------------------------------
58
+
59
+ _MAGIC = b"CZ01"
60
+ _HEADER_SIZE = 32
61
+ _SIG_LEN = 64
62
+ _SCHEMA_VERSION_MAX = 1
63
+
64
+ # AES-GCM constants. Matches the backend's encryptAESGCM helper:
65
+ # nonce(12) || ciphertext || tag(16)
66
+ _GCM_NONCE_SIZE = 12
67
+ _GCM_TAG_SIZE = 16
68
+
69
+
70
+ # --- Parsed structures -----------------------------------------------------
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class BundleHeader:
75
+ schema_version: int
76
+ created_at: int
77
+ policy_count: int
78
+ sig_offset: int
79
+ sig_len: int
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class ParsedBundle:
84
+ """The decrypted and authenticated policy bundle."""
85
+
86
+ header: BundleHeader
87
+ payload: dict # the JSON-decoded policy bundle
88
+
89
+
90
+ # --- Public API ------------------------------------------------------------
91
+
92
+
93
+ def parse_bundle(
94
+ blob: bytes,
95
+ encryption_key: bytes,
96
+ signing_pubkey: bytes,
97
+ *,
98
+ max_bundle_bytes: int = 16 * 1024 * 1024,
99
+ ) -> ParsedBundle:
100
+ """Verify signature, decrypt, and decode a policy bundle.
101
+
102
+ Args:
103
+ blob: The raw bundle bytes as returned by ``/v1/sdk/policies/pull``.
104
+ encryption_key: 32-byte symmetric key for the project.
105
+ signing_pubkey: 32-byte signing public key for the project.
106
+ max_bundle_bytes: Defensive cap on bundle size. Rejects huge blobs
107
+ before doing any crypto work (default 16 MiB).
108
+
109
+ Returns:
110
+ A :class:`ParsedBundle` with the decoded header and payload dict.
111
+
112
+ Raises:
113
+ BundleFormatError: if the wire format is malformed.
114
+ BundleVerificationError: if the signature does not verify, or if
115
+ authenticated-encryption tag validation fails.
116
+ """
117
+ if not isinstance(blob, (bytes, bytearray)):
118
+ raise BundleFormatError(
119
+ f"bundle must be bytes, got {type(blob).__name__}"
120
+ )
121
+ blob = bytes(blob)
122
+
123
+ if len(blob) > max_bundle_bytes:
124
+ raise BundleFormatError(
125
+ f"bundle size {len(blob)} exceeds max {max_bundle_bytes}"
126
+ )
127
+
128
+ header, encrypted_payload, signature = _split_bundle(blob)
129
+
130
+ # Signature covers header[0:32] || encrypted_payload. Verify BEFORE
131
+ # decrypt so we never touch ciphertext for an unauthenticated blob.
132
+ if len(signing_pubkey) != 32:
133
+ raise BundleVerificationError(
134
+ f"signing public key must be 32 bytes, got {len(signing_pubkey)}"
135
+ )
136
+ _verify_ed25519(signing_pubkey, blob[:_HEADER_SIZE] + encrypted_payload, signature)
137
+
138
+ # Decrypt the payload.
139
+ if len(encryption_key) != 32:
140
+ raise BundleVerificationError(
141
+ f"encryption key must be 32 bytes, got {len(encryption_key)}"
142
+ )
143
+ plaintext_compressed = _decrypt_aes_gcm(encryption_key, encrypted_payload)
144
+
145
+ # zstd decompress.
146
+ plaintext_json = _zstd_decompress(plaintext_compressed)
147
+
148
+ # Parse JSON.
149
+ try:
150
+ payload = json.loads(plaintext_json.decode("utf-8"))
151
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
152
+ # We already verified the signature, so this is either a server
153
+ # bug or a successful decryption of corrupted content. Report as
154
+ # format error so the caller surfaces a clear message.
155
+ raise BundleFormatError(f"payload is not valid JSON: {exc}") from exc
156
+
157
+ if not isinstance(payload, dict):
158
+ raise BundleFormatError(
159
+ f"payload root must be an object, got {type(payload).__name__}"
160
+ )
161
+
162
+ return ParsedBundle(header=header, payload=payload)
163
+
164
+
165
+ # --- Header / split --------------------------------------------------------
166
+
167
+
168
+ def _split_bundle(blob: bytes) -> tuple[BundleHeader, bytes, bytes]:
169
+ """Validate the header and split the blob into (header, payload, signature)."""
170
+ if len(blob) < _HEADER_SIZE + _SIG_LEN:
171
+ raise BundleFormatError(
172
+ f"bundle too short: {len(blob)} bytes, need at least "
173
+ f"{_HEADER_SIZE + _SIG_LEN}"
174
+ )
175
+
176
+ if blob[:4] != _MAGIC:
177
+ raise BundleFormatError(
178
+ f"bad magic: expected CZ01, got {blob[:4]!r}"
179
+ )
180
+
181
+ # Parse the fixed-layout header.
182
+ (
183
+ schema_version,
184
+ created_at,
185
+ policy_count,
186
+ sig_offset,
187
+ sig_len,
188
+ ) = struct.unpack_from("<HQHII", blob, 4)
189
+
190
+ # The reserved 8 bytes at offset 24 must be zero. This gives us room
191
+ # to extend the header in a backwards-compatible way later.
192
+ reserved = blob[24:32]
193
+ if reserved != b"\x00" * 8:
194
+ raise BundleFormatError(f"reserved bytes must be zero, got {reserved!r}")
195
+
196
+ if schema_version < 1 or schema_version > _SCHEMA_VERSION_MAX:
197
+ raise BundleFormatError(
198
+ f"unsupported schema version {schema_version}, this SDK "
199
+ f"supports 1..{_SCHEMA_VERSION_MAX}. Upgrade the SDK."
200
+ )
201
+
202
+ if sig_len != _SIG_LEN:
203
+ raise BundleFormatError(
204
+ f"unexpected signature length {sig_len}, want {_SIG_LEN}"
205
+ )
206
+
207
+ if sig_offset < _HEADER_SIZE:
208
+ raise BundleFormatError(
209
+ f"sig_offset {sig_offset} must be >= header size {_HEADER_SIZE}"
210
+ )
211
+
212
+ if sig_offset + sig_len > len(blob):
213
+ raise BundleFormatError(
214
+ f"signature extends past end of bundle: sig_offset={sig_offset} "
215
+ f"sig_len={sig_len} bundle_len={len(blob)}"
216
+ )
217
+
218
+ if sig_offset + sig_len != len(blob):
219
+ # Trailing bytes after the signature are not allowed. This prevents
220
+ # smuggling data past the authenticated region.
221
+ raise BundleFormatError(
222
+ f"trailing bytes after signature: "
223
+ f"sig_offset+sig_len={sig_offset + sig_len} bundle_len={len(blob)}"
224
+ )
225
+
226
+ encrypted_payload = blob[_HEADER_SIZE:sig_offset]
227
+ signature = blob[sig_offset : sig_offset + sig_len]
228
+
229
+ return (
230
+ BundleHeader(
231
+ schema_version=schema_version,
232
+ created_at=created_at,
233
+ policy_count=policy_count,
234
+ sig_offset=sig_offset,
235
+ sig_len=sig_len,
236
+ ),
237
+ encrypted_payload,
238
+ signature,
239
+ )
240
+
241
+
242
+ # --- Crypto primitives -----------------------------------------------------
243
+
244
+
245
+ def _verify_ed25519(pubkey: bytes, message: bytes, signature: bytes) -> None:
246
+ """Verify a detached signature. Raises BundleVerificationError on failure."""
247
+ try:
248
+ from cryptography.exceptions import InvalidSignature
249
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
250
+ Ed25519PublicKey,
251
+ )
252
+ except ImportError as exc:
253
+ # The cryptography package is a hard dependency of the SDK. If it
254
+ # is missing, we cannot verify a bundle and MUST NOT accept it.
255
+ raise BundleVerificationError(
256
+ "cryptography library is not installed; cannot verify bundle"
257
+ ) from exc
258
+
259
+ try:
260
+ pk = Ed25519PublicKey.from_public_bytes(pubkey)
261
+ pk.verify(signature, message)
262
+ except InvalidSignature as exc:
263
+ raise BundleVerificationError(
264
+ "signature verification failed: bundle is not authentic"
265
+ ) from exc
266
+ except Exception as exc: # noqa: BLE001
267
+ raise BundleVerificationError(
268
+ f"signature verification error: {exc}"
269
+ ) from exc
270
+
271
+
272
+ def _decrypt_aes_gcm(key: bytes, encrypted: bytes) -> bytes:
273
+ """Authenticated-encryption decrypt. Expects nonce(12) || ciphertext || tag(16)."""
274
+ try:
275
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
276
+ except ImportError as exc:
277
+ raise BundleVerificationError(
278
+ "cryptography library is not installed; cannot decrypt bundle"
279
+ ) from exc
280
+
281
+ if len(encrypted) < _GCM_NONCE_SIZE + _GCM_TAG_SIZE:
282
+ raise BundleFormatError(
283
+ f"encrypted payload too short ({len(encrypted)} bytes) to contain "
284
+ f"nonce and tag"
285
+ )
286
+
287
+ nonce = encrypted[:_GCM_NONCE_SIZE]
288
+ ciphertext_with_tag = encrypted[_GCM_NONCE_SIZE:]
289
+
290
+ try:
291
+ aesgcm = AESGCM(key)
292
+ return aesgcm.decrypt(nonce, ciphertext_with_tag, None)
293
+ except Exception as exc: # noqa: BLE001
294
+ # Authenticated-encryption tag check failed or key mismatch.
295
+ # Either way, the bundle is not trusted.
296
+ raise BundleVerificationError(
297
+ f"bundle decryption failed: {exc}"
298
+ ) from exc
299
+
300
+
301
+ def _zstd_decompress(data: bytes) -> bytes:
302
+ """Zstd decompress. Tries multiple Python bindings for portability."""
303
+ # Preferred: `zstandard` is the de-facto PyPI package.
304
+ try:
305
+ import zstandard as zstd
306
+
307
+ return zstd.ZstdDecompressor().decompress(data)
308
+ except ImportError:
309
+ pass
310
+
311
+ # Fallback: `pyzstd`.
312
+ try:
313
+ import pyzstd
314
+
315
+ return pyzstd.decompress(data)
316
+ except ImportError:
317
+ pass
318
+
319
+ raise BundleVerificationError(
320
+ "zstd decompression library not installed; "
321
+ "install with: pip install 'controlzero[hosted]' or pip install zstandard"
322
+ )
323
+
324
+
325
+ # --- Schema translation ----------------------------------------------------
326
+
327
+
328
+ def translate_to_local_policy(payload: dict) -> dict:
329
+ """Translate a decrypted bundle payload to the local policy dict shape.
330
+
331
+ The bundle's `policies` list comes from the backend in the shape
332
+ produced by :func:`BundleHandler.SDKPull` (Go: bundlePolicy). The
333
+ local :class:`PolicyEvaluator` expects the input format from
334
+ :func:`controlzero.policy_loader.load_policy`: a dict with
335
+ ``version: "1"`` and a flat ``rules`` list where each entry is
336
+ either ``{"deny": "<tool>"}``, ``{"allow": "<tool>"}`` or
337
+ ``{"effect": "<deny|allow|warn|audit>", "action": "<tool>"}``.
338
+
339
+ Policies are sorted by ``priority`` ascending so SDKs in every
340
+ language produce identical decisions from identical input.
341
+ """
342
+ policies = payload.get("policies") or []
343
+ policies = sorted(
344
+ [p for p in policies if isinstance(p, dict)],
345
+ key=lambda p: int(p.get("priority", 100)),
346
+ )
347
+
348
+ flat: list[dict] = []
349
+ for pol in policies:
350
+ raw_rules = pol.get("rules")
351
+ if isinstance(raw_rules, dict):
352
+ rule_list = list(raw_rules.values())
353
+ elif isinstance(raw_rules, list):
354
+ rule_list = raw_rules
355
+ else:
356
+ continue
357
+
358
+ for r in rule_list:
359
+ if not isinstance(r, dict):
360
+ continue
361
+ translated = _translate_rule(r, pol.get("id", ""))
362
+ if translated is not None:
363
+ flat.append(translated)
364
+
365
+ if not flat:
366
+ # Empty policy set: default deny-all. An empty hosted project is
367
+ # not "everything allowed" -- it's "nothing configured yet."
368
+ flat.append({
369
+ "effect": "deny",
370
+ "action": "*",
371
+ "reason": (
372
+ "No active policies. Define one in the Control Zero dashboard."
373
+ ),
374
+ })
375
+
376
+ return {"version": "1", "rules": flat}
377
+
378
+
379
+ def _translate_rule(rule: dict, policy_id: str) -> Optional[dict]:
380
+ """Translate a single backend rule to the local rule shape."""
381
+ effect = rule.get("effect") or rule.get("action") or "allow"
382
+ if effect not in ("allow", "deny", "warn", "audit"):
383
+ effect = "allow"
384
+
385
+ # Find the tool pattern. The backend may put it under several keys
386
+ # depending on the policy form (LLM, tool-call, etc.).
387
+ pattern = rule.get("tool")
388
+ if not pattern:
389
+ pattern = rule.get("pattern")
390
+ if not pattern:
391
+ match = rule.get("match")
392
+ if isinstance(match, dict):
393
+ pattern = match.get("tool") or match.get("action")
394
+ if not pattern:
395
+ # Final fallback: universal match. Combined with a non-allow
396
+ # effect, this is still meaningful (e.g. deny-all).
397
+ pattern = "*"
398
+
399
+ translated: dict = {
400
+ "effect": effect,
401
+ "action": pattern,
402
+ "reason": rule.get("reason") or f"policy:{policy_id}",
403
+ }
404
+
405
+ resources = rule.get("resources") or rule.get("resource")
406
+ if resources:
407
+ translated["resources"] = resources
408
+
409
+ return translated