controlzero 1.3.0__tar.gz → 1.4.2__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 (104) hide show
  1. {controlzero-1.3.0 → controlzero-1.4.2}/.gitignore +21 -2
  2. controlzero-1.4.2/CHANGELOG.md +33 -0
  3. {controlzero-1.3.0 → controlzero-1.4.2}/PKG-INFO +52 -1
  4. {controlzero-1.3.0 → controlzero-1.4.2}/README.md +40 -0
  5. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/__init__.py +1 -1
  6. controlzero-1.4.2/controlzero/_internal/bundle.py +440 -0
  7. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/_internal/enforcer.py +33 -0
  8. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/audit_remote.py +165 -0
  9. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/cli/main.py +398 -114
  10. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/cli/templates/autogen.yaml +8 -0
  11. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/cli/templates/claude-code.yaml +8 -0
  12. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/cli/templates/codex-cli.yaml +8 -0
  13. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/cli/templates/cost-cap.yaml +8 -0
  14. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/cli/templates/crewai.yaml +8 -0
  15. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/cli/templates/cursor.yaml +8 -0
  16. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/cli/templates/gemini-cli.yaml +8 -0
  17. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/cli/templates/generic.yaml +8 -0
  18. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/cli/templates/langchain.yaml +8 -0
  19. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/cli/templates/mcp.yaml +8 -0
  20. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/cli/templates/rag.yaml +8 -0
  21. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/client.py +142 -27
  22. controlzero-1.4.2/controlzero/device.py +281 -0
  23. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/enrollment.py +47 -0
  24. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/errors.py +25 -0
  25. controlzero-1.4.2/controlzero/hosted_policy.py +413 -0
  26. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/integrations/anthropic.py +22 -12
  27. controlzero-1.4.2/controlzero/integrations/autogen.py +144 -0
  28. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/integrations/crewai/agent.py +18 -6
  29. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/integrations/crewai/crew.py +30 -9
  30. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/integrations/crewai/task.py +20 -6
  31. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/integrations/crewai/tool.py +28 -7
  32. controlzero-1.4.2/controlzero/integrations/google.py +178 -0
  33. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/integrations/google_adk/agent.py +10 -3
  34. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/integrations/google_adk/tool.py +24 -12
  35. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/integrations/langchain/__init__.py +2 -0
  36. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/integrations/langchain/agent.py +8 -0
  37. controlzero-1.4.2/controlzero/integrations/langchain/modern.py +51 -0
  38. controlzero-1.4.2/controlzero/integrations/litellm.py +271 -0
  39. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/integrations/openai.py +23 -10
  40. controlzero-1.4.2/controlzero/integrations/pydantic_ai.py +194 -0
  41. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/policy_loader.py +36 -5
  42. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/tamper.py +125 -0
  43. {controlzero-1.3.0 → controlzero-1.4.2}/pyproject.toml +17 -1
  44. controlzero-1.4.2/tests/integrations/__init__.py +0 -0
  45. controlzero-1.4.2/tests/integrations/test_google.py +96 -0
  46. controlzero-1.4.2/tests/test_action_canonicalization.py +36 -0
  47. controlzero-1.4.2/tests/test_agent_name_env.py +83 -0
  48. controlzero-1.4.2/tests/test_bundle_parser.py +231 -0
  49. {controlzero-1.3.0 → controlzero-1.4.2}/tests/test_cli_hook.py +123 -5
  50. controlzero-1.4.2/tests/test_cli_hosted_refresh.py +151 -0
  51. {controlzero-1.3.0 → controlzero-1.4.2}/tests/test_coding_agent_hooks.py +19 -12
  52. controlzero-1.4.2/tests/test_conditions.py +202 -0
  53. controlzero-1.4.2/tests/test_device.py +274 -0
  54. {controlzero-1.3.0 → controlzero-1.4.2}/tests/test_enrollment.py +156 -1
  55. controlzero-1.4.2/tests/test_hosted_policy_e2e.py +217 -0
  56. controlzero-1.4.2/tests/test_hybrid_mode_strict.py +86 -0
  57. {controlzero-1.3.0 → controlzero-1.4.2}/tests/test_install_hooks.py +105 -62
  58. controlzero-1.4.2/tests/test_policy_settings.py +86 -0
  59. controlzero-1.4.2/tests/test_quarantine.py +193 -0
  60. controlzero-1.4.2/tests/test_tamper_behavior.py +167 -0
  61. controlzero-1.3.0/controlzero/integrations/google.py +0 -184
  62. controlzero-1.3.0/controlzero/integrations/litellm.py +0 -137
  63. controlzero-1.3.0/tests/test_hybrid_mode_strict.py +0 -53
  64. {controlzero-1.3.0 → controlzero-1.4.2}/Dockerfile.test +0 -0
  65. {controlzero-1.3.0 → controlzero-1.4.2}/LICENSE +0 -0
  66. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/_internal/__init__.py +0 -0
  67. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/_internal/dlp_scanner.py +0 -0
  68. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/_internal/types.py +0 -0
  69. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/audit_local.py +0 -0
  70. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/cli/__init__.py +0 -0
  71. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/integrations/__init__.py +0 -0
  72. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/integrations/braintrust.py +0 -0
  73. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/integrations/crewai/__init__.py +0 -0
  74. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/integrations/google_adk/__init__.py +0 -0
  75. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/integrations/langchain/callbacks.py +0 -0
  76. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/integrations/langchain/chain.py +0 -0
  77. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/integrations/langchain/graph.py +0 -0
  78. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/integrations/langchain/tool.py +0 -0
  79. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/integrations/langfuse.py +0 -0
  80. {controlzero-1.3.0 → controlzero-1.4.2}/controlzero/integrations/vercel_ai.py +0 -0
  81. {controlzero-1.3.0 → controlzero-1.4.2}/examples/hello_world.py +0 -0
  82. {controlzero-1.3.0 → controlzero-1.4.2}/tests/conftest.py +0 -0
  83. {controlzero-1.3.0 → controlzero-1.4.2}/tests/test_audit_remote.py +0 -0
  84. {controlzero-1.3.0 → controlzero-1.4.2}/tests/test_audit_sink_isolation.py +0 -0
  85. {controlzero-1.3.0 → controlzero-1.4.2}/tests/test_cli_init.py +0 -0
  86. {controlzero-1.3.0 → controlzero-1.4.2}/tests/test_cli_init_templates.py +0 -0
  87. {controlzero-1.3.0 → controlzero-1.4.2}/tests/test_cli_tail.py +0 -0
  88. {controlzero-1.3.0 → controlzero-1.4.2}/tests/test_cli_test.py +0 -0
  89. {controlzero-1.3.0 → controlzero-1.4.2}/tests/test_cli_validate.py +0 -0
  90. {controlzero-1.3.0 → controlzero-1.4.2}/tests/test_dlp_scanner.py +0 -0
  91. {controlzero-1.3.0 → controlzero-1.4.2}/tests/test_fail_closed_eval.py +0 -0
  92. {controlzero-1.3.0 → controlzero-1.4.2}/tests/test_glob_matching.py +0 -0
  93. {controlzero-1.3.0 → controlzero-1.4.2}/tests/test_hybrid_mode_warn.py +0 -0
  94. {controlzero-1.3.0 → controlzero-1.4.2}/tests/test_local_mode_dict.py +0 -0
  95. {controlzero-1.3.0 → controlzero-1.4.2}/tests/test_local_mode_file_json.py +0 -0
  96. {controlzero-1.3.0 → controlzero-1.4.2}/tests/test_local_mode_file_yaml.py +0 -0
  97. {controlzero-1.3.0 → controlzero-1.4.2}/tests/test_log_fallback_stderr.py +0 -0
  98. {controlzero-1.3.0 → controlzero-1.4.2}/tests/test_log_options_ignored_hosted.py +0 -0
  99. {controlzero-1.3.0 → controlzero-1.4.2}/tests/test_log_rotation.py +0 -0
  100. {controlzero-1.3.0 → controlzero-1.4.2}/tests/test_no_policy_no_key.py +0 -0
  101. {controlzero-1.3.0 → controlzero-1.4.2}/tests/test_package_rename_shim.py +0 -0
  102. {controlzero-1.3.0 → controlzero-1.4.2}/tests/test_policy_freshness.py +0 -0
  103. {controlzero-1.3.0 → controlzero-1.4.2}/tests/test_tamper.py +0 -0
  104. {controlzero-1.3.0 → controlzero-1.4.2}/tests/test_tamper_hook.py +0 -0
@@ -1,6 +1,17 @@
1
1
  # Worktrees
2
2
  .worktrees/
3
3
 
4
+ # Transient QA / design captures at repo root.
5
+ # Long-term reference screenshots live under docs-site/static/ or in Obsidian.
6
+ /*.png
7
+ /dashboard-*.md
8
+ /uat-*.png
9
+ /audit-*.png
10
+ /billing-*.png
11
+ /sso-*.png
12
+ /notifications-*.png
13
+ /settings-*.png
14
+
4
15
  # Dependencies
5
16
  node_modules/
6
17
  vendor/
@@ -30,6 +41,7 @@ __pycache__/
30
41
 
31
42
  # Environment and secrets (deny-all, allow explicitly)
32
43
  .env*
44
+ !.envrc
33
45
  production-secrets*
34
46
  vault-backup.key
35
47
  *.pem
@@ -188,8 +200,6 @@ scripts/fix-ssh-config.sh
188
200
  scripts/hetzner-post-install.sh
189
201
  docs/knowledge-base/
190
202
  docs/superpowers/
191
- scripts/doppler/doppler-env.values.sh
192
-
193
203
  # Air-gap build artifacts (generated tarballs and package directories)
194
204
  cz-air-gap-*/
195
205
  cz-air-gap-*.tar.gz
@@ -218,3 +228,12 @@ cz-revamp-live.png
218
228
 
219
229
  # Agent worktrees (created by Claude Code during parallel work)
220
230
  .claude/worktrees/
231
+
232
+ # direnv + sops secrets. Encrypted files under secrets/ are safe to
233
+ # commit; anything matching *.plain or *.dec is a decryption artifact
234
+ # and must NEVER land in git.
235
+ .envrc.local
236
+ .direnv/
237
+ secrets/*.plain
238
+ secrets/*.dec
239
+ .claude/scheduled_tasks.lock
@@ -0,0 +1,33 @@
1
+ # Changelog
2
+
3
+ ## 1.4.1 -- 2026-04-15
4
+
5
+ ### Added
6
+
7
+ - `agent_name` arg + `CZ_AGENT_NAME` env var contract on the `Client` constructor (issue #71). Order: explicit arg > env > `default-agent`.
8
+ - `CZ_DEBUG=1` (or `true`/`yes`/`on`) flips the controlzero logger to DEBUG at construction. Cheap escape hatch for support.
9
+ - Optional install extras: `controlzero[google]`, `controlzero[openai]`, `controlzero[anthropic]` (issue #68). Pin the matching upstream SDK so users do not see import errors.
10
+ - Cross-SDK action canonicalization parity test (issue #69). Mirrors the same fixture in the Node and Go SDKs.
11
+ - Smoke + denial + error-propagation tests for the `wrap_google` integration (issue #68).
12
+
13
+ ## 1.4.0 -- 2026-04-15
14
+
15
+ ### Breaking changes
16
+
17
+ - Integration wrappers now emit simplified action names (`llm:generate`, `embedding:generate`, `tool:call`) instead of provider-prefixed ones (`llm:openai:chat.completions.create`). Policies targeting the old action names must be updated. Provider and model move into `context` tags. See docs/integrations for current patterns.
18
+ - Google integration rewritten against `google-genai` (deprecated `google.generativeai` removed). Install `google-genai`.
19
+ - `integrations.langchain.agent.GovernedAgent` wrapping `AgentExecutor` is deprecated in LangChain v1.x. Use `integrations.langchain.modern.create_governed_agent`.
20
+
21
+ ### New integrations
22
+
23
+ - `integrations.autogen` - first-party helper for autogen-agentchat v0.7+
24
+ - `integrations.pydantic_ai` - first-party helper for pydantic-ai v1.x
25
+ - `integrations.langchain.modern` - LangGraph create_agent pattern
26
+
27
+ ### New features
28
+
29
+ - Policy rule `conditions` field is now evaluated in the local enforcer. Conditions are matched against merged context + args with glob patterns. All keys must match.
30
+
31
+ ### Enhancements
32
+
33
+ - `integrations.litellm` - async + success/failure hooks for streaming audit.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: controlzero
3
- Version: 1.3.0
3
+ Version: 1.4.2
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
@@ -26,14 +26,25 @@ Requires-Dist: click>=8.1.0
26
26
  Requires-Dist: loguru>=0.7.0
27
27
  Requires-Dist: pydantic>=2.0.0
28
28
  Requires-Dist: pyyaml>=6.0
29
+ Provides-Extra: anthropic
30
+ Requires-Dist: anthropic>=0.25.0; extra == 'anthropic'
29
31
  Provides-Extra: dev
32
+ Requires-Dist: cryptography>=41.0.0; extra == 'dev'
33
+ Requires-Dist: httpx>=0.25.0; extra == 'dev'
30
34
  Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
31
35
  Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
32
36
  Requires-Dist: pytest>=7.0.0; extra == 'dev'
33
37
  Requires-Dist: pyyaml>=6.0; extra == 'dev'
38
+ Requires-Dist: respx>=0.20.0; extra == 'dev'
39
+ Requires-Dist: zstandard>=0.22.0; extra == 'dev'
40
+ Provides-Extra: google
41
+ Requires-Dist: google-genai>=0.3.0; extra == 'google'
34
42
  Provides-Extra: hosted
35
43
  Requires-Dist: cryptography>=41.0.0; extra == 'hosted'
36
44
  Requires-Dist: httpx>=0.25.0; extra == 'hosted'
45
+ Requires-Dist: zstandard>=0.22.0; extra == 'hosted'
46
+ Provides-Extra: openai
47
+ Requires-Dist: openai>=1.0.0; extra == 'openai'
37
48
  Description-Content-Type: text/markdown
38
49
 
39
50
  # control-zero
@@ -158,6 +169,46 @@ rules:
158
169
  Rules are evaluated top to bottom. The first match wins. If no rule matches,
159
170
  the call is denied (fail-closed).
160
171
 
172
+ ## Tamper detection and quarantine
173
+
174
+ The policy YAML supports a `settings:` section that controls how the SDK
175
+ responds when it detects that the local policy file has been modified outside
176
+ of normal channels (manual edits, unexpected hash changes, etc.):
177
+
178
+ ```yaml
179
+ version: '1'
180
+ settings:
181
+ tamper_behavior: warn # Options: warn | deny | deny-all | quarantine
182
+ rules:
183
+ - deny: 'delete_*'
184
+ - allow: '*'
185
+ ```
186
+
187
+ | Mode | Behavior |
188
+ | ------------ | ------------------------------------------------------------------------ |
189
+ | `warn` | Log a warning but continue evaluating rules normally. |
190
+ | `deny` | Deny the current tool call that triggered the tamper check. |
191
+ | `deny-all` | Deny all tool calls and place the machine in quarantine until recovered. |
192
+ | `quarantine` | Same as `deny-all`, plus report a tamper alert to the backend dashboard. |
193
+
194
+ **Quarantine recovery.** When a machine enters quarantine (`deny-all` or
195
+ `quarantine`), every tool call is denied until you re-establish trust with one
196
+ of these commands:
197
+
198
+ ```bash
199
+ controlzero enroll
200
+ controlzero policy-pull
201
+ controlzero sign-policy
202
+ ```
203
+
204
+ **Org-level policy signing.** When a machine is enrolled via `controlzero enroll`,
205
+ it receives the organization's signing public key. Policy bundles pulled from
206
+ the backend are cryptographically signed and verified by the SDK automatically.
207
+ No extra configuration is required.
208
+
209
+ **Tamper alert reporting.** In `quarantine` mode, the SDK reports a tamper alert
210
+ to the Control Zero backend so your team can see it on the dashboard.
211
+
161
212
  ## Local audit log
162
213
 
163
214
  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.3.0"
31
+ __version__ = "1.4.1"
32
32
 
33
33
  __all__ = [
34
34
  "Client",
@@ -0,0 +1,440 @@
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
+
304
+ Some backend builds produce zstd frames without the frame content
305
+ size in the header (klauspost/compress EncodeAll variants). In that
306
+ case `zstd.ZstdDecompressor().decompress(data)` raises:
307
+
308
+ zstandard.backend_c.ZstdError:
309
+ could not determine content size in frame header
310
+
311
+ To decompress regardless of whether the size is declared, we fall
312
+ back to the streaming API with a hard upper bound of 16 MiB --
313
+ matching MAX_BUNDLE_BYTES enforced one layer up. This is the
314
+ standard robust-decompression pattern recommended by the zstandard
315
+ maintainers for untrusted / variable-source input. #113.
316
+ """
317
+ # 16 MiB upper bound on decompressed size; matches
318
+ # hosted_policy.MAX_BUNDLE_BYTES. Kept inline to avoid an import
319
+ # cycle between _internal and hosted_policy.
320
+ _MAX_DECOMPRESSED = 16 * 1024 * 1024
321
+
322
+ # Preferred: `zstandard` is the de-facto PyPI package.
323
+ try:
324
+ import io
325
+ import zstandard as zstd
326
+
327
+ dctx = zstd.ZstdDecompressor()
328
+ # Streaming read works for frames that lack the declared
329
+ # content size. Cap the output to prevent a zip-bomb class
330
+ # of attack on a tampered bundle.
331
+ with dctx.stream_reader(io.BytesIO(data)) as reader:
332
+ out = reader.read(_MAX_DECOMPRESSED + 1)
333
+ if len(out) > _MAX_DECOMPRESSED:
334
+ raise BundleVerificationError(
335
+ f"zstd decompressed payload exceeds {_MAX_DECOMPRESSED} byte limit"
336
+ )
337
+ return out
338
+ except ImportError:
339
+ pass
340
+
341
+ # Fallback: `pyzstd`. Its `decompress()` handles missing content
342
+ # size natively without needing streaming mode.
343
+ try:
344
+ import pyzstd
345
+
346
+ return pyzstd.decompress(data)
347
+ except ImportError:
348
+ pass
349
+
350
+ raise BundleVerificationError(
351
+ "zstd decompression library not installed; "
352
+ "install with: pip install 'controlzero[hosted]' or pip install zstandard"
353
+ )
354
+
355
+
356
+ # --- Schema translation ----------------------------------------------------
357
+
358
+
359
+ def translate_to_local_policy(payload: dict) -> dict:
360
+ """Translate a decrypted bundle payload to the local policy dict shape.
361
+
362
+ The bundle's `policies` list comes from the backend in the shape
363
+ produced by :func:`BundleHandler.SDKPull` (Go: bundlePolicy). The
364
+ local :class:`PolicyEvaluator` expects the input format from
365
+ :func:`controlzero.policy_loader.load_policy`: a dict with
366
+ ``version: "1"`` and a flat ``rules`` list where each entry is
367
+ either ``{"deny": "<tool>"}``, ``{"allow": "<tool>"}`` or
368
+ ``{"effect": "<deny|allow|warn|audit>", "action": "<tool>"}``.
369
+
370
+ Policies are sorted by ``priority`` ascending so SDKs in every
371
+ language produce identical decisions from identical input.
372
+ """
373
+ policies = payload.get("policies") or []
374
+ policies = sorted(
375
+ [p for p in policies if isinstance(p, dict)],
376
+ key=lambda p: int(p.get("priority", 100)),
377
+ )
378
+
379
+ flat: list[dict] = []
380
+ for pol in policies:
381
+ raw_rules = pol.get("rules")
382
+ if isinstance(raw_rules, dict):
383
+ rule_list = list(raw_rules.values())
384
+ elif isinstance(raw_rules, list):
385
+ rule_list = raw_rules
386
+ else:
387
+ continue
388
+
389
+ for r in rule_list:
390
+ if not isinstance(r, dict):
391
+ continue
392
+ translated = _translate_rule(r, pol.get("id", ""))
393
+ if translated is not None:
394
+ flat.append(translated)
395
+
396
+ if not flat:
397
+ # Empty policy set: default deny-all. An empty hosted project is
398
+ # not "everything allowed" -- it's "nothing configured yet."
399
+ flat.append({
400
+ "effect": "deny",
401
+ "action": "*",
402
+ "reason": (
403
+ "No active policies. Define one in the Control Zero dashboard."
404
+ ),
405
+ })
406
+
407
+ return {"version": "1", "rules": flat}
408
+
409
+
410
+ def _translate_rule(rule: dict, policy_id: str) -> Optional[dict]:
411
+ """Translate a single backend rule to the local rule shape."""
412
+ effect = rule.get("effect") or rule.get("action") or "allow"
413
+ if effect not in ("allow", "deny", "warn", "audit"):
414
+ effect = "allow"
415
+
416
+ # Find the tool pattern. The backend may put it under several keys
417
+ # depending on the policy form (LLM, tool-call, etc.).
418
+ pattern = rule.get("tool")
419
+ if not pattern:
420
+ pattern = rule.get("pattern")
421
+ if not pattern:
422
+ match = rule.get("match")
423
+ if isinstance(match, dict):
424
+ pattern = match.get("tool") or match.get("action")
425
+ if not pattern:
426
+ # Final fallback: universal match. Combined with a non-allow
427
+ # effect, this is still meaningful (e.g. deny-all).
428
+ pattern = "*"
429
+
430
+ translated: dict = {
431
+ "effect": effect,
432
+ "action": pattern,
433
+ "reason": rule.get("reason") or f"policy:{policy_id}",
434
+ }
435
+
436
+ resources = rule.get("resources") or rule.get("resource")
437
+ if resources:
438
+ translated["resources"] = resources
439
+
440
+ return translated