controlzero 1.3.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.
- {controlzero-1.3.0 → controlzero-1.4.0}/PKG-INFO +46 -1
- {controlzero-1.3.0 → controlzero-1.4.0}/README.md +40 -0
- controlzero-1.4.0/controlzero/_internal/bundle.py +409 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/audit_remote.py +165 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/cli/main.py +398 -114
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/cli/templates/autogen.yaml +8 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/cli/templates/claude-code.yaml +8 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/cli/templates/codex-cli.yaml +8 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/cli/templates/cost-cap.yaml +8 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/cli/templates/crewai.yaml +8 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/cli/templates/cursor.yaml +8 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/cli/templates/gemini-cli.yaml +8 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/cli/templates/generic.yaml +8 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/cli/templates/langchain.yaml +8 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/cli/templates/mcp.yaml +8 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/cli/templates/rag.yaml +8 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/client.py +122 -27
- controlzero-1.4.0/controlzero/device.py +281 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/enrollment.py +47 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/errors.py +25 -0
- controlzero-1.4.0/controlzero/hosted_policy.py +413 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/policy_loader.py +36 -5
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/tamper.py +125 -0
- controlzero-1.4.0/controlzero.2026-04-07_22-05-15_956171.log +2 -0
- controlzero-1.4.0/controlzero.2026-04-08_16-53-22_852394.log +6 -0
- controlzero-1.4.0/controlzero.log +49 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/pyproject.toml +6 -1
- controlzero-1.4.0/tests/test_bundle_parser.py +231 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/tests/test_cli_hook.py +123 -5
- controlzero-1.4.0/tests/test_cli_hosted_refresh.py +151 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/tests/test_coding_agent_hooks.py +19 -12
- controlzero-1.4.0/tests/test_device.py +274 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/tests/test_enrollment.py +156 -1
- controlzero-1.4.0/tests/test_hosted_policy_e2e.py +217 -0
- controlzero-1.4.0/tests/test_hybrid_mode_strict.py +86 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/tests/test_install_hooks.py +105 -62
- controlzero-1.4.0/tests/test_policy_settings.py +86 -0
- controlzero-1.4.0/tests/test_quarantine.py +193 -0
- controlzero-1.4.0/tests/test_tamper_behavior.py +167 -0
- controlzero-1.3.0/.gitignore +0 -220
- controlzero-1.3.0/tests/test_hybrid_mode_strict.py +0 -53
- {controlzero-1.3.0 → controlzero-1.4.0}/Dockerfile.test +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/LICENSE +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/__init__.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/_internal/__init__.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/_internal/dlp_scanner.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/_internal/enforcer.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/_internal/types.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/audit_local.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/cli/__init__.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/integrations/__init__.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/integrations/anthropic.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/integrations/braintrust.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/integrations/crewai/__init__.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/integrations/crewai/agent.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/integrations/crewai/crew.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/integrations/crewai/task.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/integrations/crewai/tool.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/integrations/google.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/integrations/google_adk/__init__.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/integrations/google_adk/agent.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/integrations/google_adk/tool.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/integrations/langchain/__init__.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/integrations/langchain/agent.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/integrations/langchain/callbacks.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/integrations/langchain/chain.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/integrations/langchain/graph.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/integrations/langchain/tool.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/integrations/langfuse.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/integrations/litellm.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/integrations/openai.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/controlzero/integrations/vercel_ai.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/examples/hello_world.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/tests/conftest.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/tests/test_audit_remote.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/tests/test_audit_sink_isolation.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/tests/test_cli_init.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/tests/test_cli_init_templates.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/tests/test_cli_tail.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/tests/test_cli_test.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/tests/test_cli_validate.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/tests/test_dlp_scanner.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/tests/test_fail_closed_eval.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/tests/test_glob_matching.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/tests/test_hybrid_mode_warn.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/tests/test_local_mode_dict.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/tests/test_local_mode_file_json.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/tests/test_local_mode_file_yaml.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/tests/test_log_fallback_stderr.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/tests/test_log_options_ignored_hosted.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/tests/test_log_rotation.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/tests/test_no_policy_no_key.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/tests/test_package_rename_shim.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/tests/test_policy_freshness.py +0 -0
- {controlzero-1.3.0 → controlzero-1.4.0}/tests/test_tamper.py +0 -0
- {controlzero-1.3.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.
|
|
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`
|
|
@@ -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
|