controlzero 1.4.3__tar.gz → 1.4.6__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.4.3 → controlzero-1.4.6}/.gitignore +3 -1
- controlzero-1.4.6/CHANGELOG.md +88 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/PKG-INFO +15 -3
- {controlzero-1.4.3 → controlzero-1.4.6}/README.md +13 -1
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/__init__.py +1 -1
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/_internal/bundle.py +192 -21
- controlzero-1.4.6/controlzero/_internal/enforcer.py +403 -0
- controlzero-1.4.6/controlzero/_internal/hook_extractors.py +631 -0
- controlzero-1.4.6/controlzero/_internal/tool_extractors.json +68 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/_internal/types.py +6 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/audit_remote.py +8 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/cli/main.py +342 -9
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/client.py +262 -2
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/hosted_policy.py +10 -2
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/policy_loader.py +73 -1
- {controlzero-1.4.3 → controlzero-1.4.6}/pyproject.toml +9 -2
- controlzero-1.4.6/tests/test_bundle_translate.py +266 -0
- controlzero-1.4.6/tests/test_cli_carve_out.py +476 -0
- controlzero-1.4.6/tests/test_cli_extractor_integration.py +473 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_coding_agent_hooks.py +57 -16
- controlzero-1.4.6/tests/test_default_action.py +359 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_glob_matching.py +83 -0
- controlzero-1.4.6/tests/test_hook_extractors.py +567 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_install_hooks.py +6 -1
- controlzero-1.4.6/tests/test_reason_code.py +352 -0
- controlzero-1.4.6/tests/test_refresh.py +565 -0
- controlzero-1.4.6/tests/test_sql_semantic_class.py +213 -0
- controlzero-1.4.3/CHANGELOG.md +0 -33
- controlzero-1.4.3/controlzero/_internal/enforcer.py +0 -243
- {controlzero-1.4.3 → controlzero-1.4.6}/Dockerfile.test +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/LICENSE +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/_internal/__init__.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/_internal/dlp_scanner.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/audit_local.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/cli/__init__.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/cli/templates/autogen.yaml +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/cli/templates/claude-code.yaml +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/cli/templates/codex-cli.yaml +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/cli/templates/cost-cap.yaml +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/cli/templates/crewai.yaml +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/cli/templates/cursor.yaml +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/cli/templates/gemini-cli.yaml +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/cli/templates/generic.yaml +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/cli/templates/langchain.yaml +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/cli/templates/mcp.yaml +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/cli/templates/rag.yaml +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/device.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/enrollment.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/errors.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/__init__.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/anthropic.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/autogen.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/braintrust.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/crewai/__init__.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/crewai/agent.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/crewai/crew.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/crewai/task.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/crewai/tool.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/google.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/google_adk/__init__.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/google_adk/agent.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/google_adk/tool.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/langchain/__init__.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/langchain/agent.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/langchain/callbacks.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/langchain/chain.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/langchain/graph.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/langchain/modern.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/langchain/tool.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/langfuse.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/litellm.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/openai.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/pydantic_ai.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/vercel_ai.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/tamper.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/examples/hello_world.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/conftest.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/integrations/__init__.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/integrations/test_google.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_action_canonicalization.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_agent_name_env.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_audit_remote.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_audit_sink_isolation.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_bundle_parser.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_cli_hook.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_cli_hosted_refresh.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_cli_init.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_cli_init_templates.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_cli_tail.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_cli_test.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_cli_validate.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_conditions.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_device.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_dlp_scanner.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_enrollment.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_fail_closed_eval.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_hosted_policy_e2e.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_hybrid_mode_strict.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_hybrid_mode_warn.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_local_mode_dict.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_local_mode_file_json.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_local_mode_file_yaml.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_log_fallback_stderr.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_log_options_ignored_hosted.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_log_rotation.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_no_policy_no_key.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_package_rename_shim.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_policy_freshness.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_policy_settings.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_quarantine.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_tamper.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_tamper_behavior.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_tamper_hook.py +0 -0
|
@@ -194,7 +194,6 @@ SUMMARY_OFFENSIVE_*.md
|
|
|
194
194
|
docs/deployment/
|
|
195
195
|
docs/SSH_RECOVERY_GUIDE.md
|
|
196
196
|
docs/HETZNER_OS_INSTALLATION.md
|
|
197
|
-
docs/BACKUP_RECOVERY.md
|
|
198
197
|
docs/VERCEL_SETUP_WEBAPPS.md
|
|
199
198
|
scripts/fix-ssh-config.sh
|
|
200
199
|
scripts/hetzner-post-install.sh
|
|
@@ -237,3 +236,6 @@ cz-revamp-live.png
|
|
|
237
236
|
secrets/*.plain
|
|
238
237
|
secrets/*.dec
|
|
239
238
|
.claude/scheduled_tasks.lock
|
|
239
|
+
|
|
240
|
+
# Local documentation, patent assets, and legal research (sensitive, never commit)
|
|
241
|
+
doc_local/
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.4.6 -- 2026-05-11
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **resources:["*"] no longer requires a caller resource** (T83).
|
|
8
|
+
Hosted policy bundles emit `resources:["*"]` for any rule that does
|
|
9
|
+
not scope by resource. The enforcer's resource gate previously
|
|
10
|
+
required a caller-supplied `context.resource` even when the rule's
|
|
11
|
+
resources list was the universal wildcard, causing every rule to be
|
|
12
|
+
silently skipped on calls that didn't pass a resource. Result: every
|
|
13
|
+
`cz.guard()` returned `deny` with `reason_code=NO_RULE_MATCH` regardless
|
|
14
|
+
of what the policy said. Now rules with `*` in their resources match
|
|
15
|
+
universally; non-wildcard resource patterns still require a caller
|
|
16
|
+
resource (no silent broadening). Three regression tests added in
|
|
17
|
+
`tests/test_glob_matching.py` including the exact bundle shape from
|
|
18
|
+
the customer reproduction.
|
|
19
|
+
|
|
20
|
+
## 1.4.5 -- 2026-05-05
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **Cross-CLI canonical tool coverage** (#341). The shared
|
|
25
|
+
`tool_extractors.json` is now spec_version 2 and adds three new
|
|
26
|
+
canonical tools (`web_search`, `file_search`, `task`) plus extended
|
|
27
|
+
aliases so PowerShell shell-command emission, Codex CLI's
|
|
28
|
+
`apply_patch`, Gemini CLI's `read_many_files`, and the WebFetch
|
|
29
|
+
family all resolve to the existing canonical tools. One rule
|
|
30
|
+
`action: Bash:rm` now covers Claude Code, Gemini CLI, Codex CLI,
|
|
31
|
+
and PowerShell on Windows.
|
|
32
|
+
- **SQL semantic-class layer** (#345/#350). New `sql_semantic_class()`
|
|
33
|
+
helper emits a parallel `database:read|write|admin|exec` action
|
|
34
|
+
alongside the existing per-keyword `database:SELECT|DROP|...`
|
|
35
|
+
action. Write portable rules like `allow: database:read` that cover
|
|
36
|
+
SELECT, EXPLAIN, SHOW, DESCRIBE, and CTE in one rule, without
|
|
37
|
+
enumerating every keyword the dialect accepts. Multi-statement
|
|
38
|
+
piggybacks like `SELECT 1; DROP TABLE x` correctly resolve to
|
|
39
|
+
`database:admin` so deny rules catch them. 21 new parity-test
|
|
40
|
+
fixtures (cross-SDK byte-identical with the Node sibling).
|
|
41
|
+
|
|
42
|
+
### Documentation
|
|
43
|
+
|
|
44
|
+
- New customer-facing reference at `docs/sdk/policies/canonical-tools.md`
|
|
45
|
+
explaining canonical tool names, alias coverage per host CLI, and
|
|
46
|
+
the contract for adding new clients.
|
|
47
|
+
- New SQL semantic class section in `canonical-tools.md` plus updated
|
|
48
|
+
quickstart and read-only-database recipe.
|
|
49
|
+
|
|
50
|
+
## 1.4.4 -- 2026-04-19
|
|
51
|
+
|
|
52
|
+
### Fixed
|
|
53
|
+
|
|
54
|
+
- Hosted mode now refreshes the policy bundle periodically (default 60 seconds) so dashboard edits reach long-running clients without a process restart. New `Client(refresh_interval_seconds=...)` constructor arg controls cadence: pass `None` to disable, `0` to refresh on every `guard()` call (tests only). Added public `Client.refresh()` for on-demand reloads and `Client.last_refreshed_at` for ops visibility. Swap is protected by a lock so multi-threaded callers never observe torn state.
|
|
55
|
+
- Bundle translator now reads plural `actions: [...]` rules from the hosted bundle. Previously every such rule collapsed to a universal `*` match, turning narrow denies like `deny database:execute` into deny-all.
|
|
56
|
+
- Backend now stamps `api_keys.last_used_at` on the SDK pull endpoint on both fresh-bundle and 304 paths, so the dashboard can distinguish "SDK online with cached bundle" from "SDK has never connected". Best-effort write; stamp failures never block the response.
|
|
57
|
+
|
|
58
|
+
## 1.4.1 -- 2026-04-15
|
|
59
|
+
|
|
60
|
+
### Added
|
|
61
|
+
|
|
62
|
+
- `agent_name` arg + `CZ_AGENT_NAME` env var contract on the `Client` constructor (issue #71). Order: explicit arg > env > `default-agent`.
|
|
63
|
+
- `CZ_DEBUG=1` (or `true`/`yes`/`on`) flips the controlzero logger to DEBUG at construction. Cheap escape hatch for support.
|
|
64
|
+
- Optional install extras: `controlzero[google]`, `controlzero[openai]`, `controlzero[anthropic]` (issue #68). Pin the matching upstream SDK so users do not see import errors.
|
|
65
|
+
- Cross-SDK action canonicalization parity test (issue #69). Mirrors the same fixture in the Node and Go SDKs.
|
|
66
|
+
- Smoke + denial + error-propagation tests for the `wrap_google` integration (issue #68).
|
|
67
|
+
|
|
68
|
+
## 1.4.0 -- 2026-04-15
|
|
69
|
+
|
|
70
|
+
### Breaking changes
|
|
71
|
+
|
|
72
|
+
- 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.
|
|
73
|
+
- Google integration rewritten against `google-genai` (deprecated `google.generativeai` removed). Install `google-genai`.
|
|
74
|
+
- `integrations.langchain.agent.GovernedAgent` wrapping `AgentExecutor` is deprecated in LangChain v1.x. Use `integrations.langchain.modern.create_governed_agent`.
|
|
75
|
+
|
|
76
|
+
### New integrations
|
|
77
|
+
|
|
78
|
+
- `integrations.autogen` - first-party helper for autogen-agentchat v0.7+
|
|
79
|
+
- `integrations.pydantic_ai` - first-party helper for pydantic-ai v1.x
|
|
80
|
+
- `integrations.langchain.modern` - LangGraph create_agent pattern
|
|
81
|
+
|
|
82
|
+
### New features
|
|
83
|
+
|
|
84
|
+
- 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.
|
|
85
|
+
|
|
86
|
+
### Enhancements
|
|
87
|
+
|
|
88
|
+
- `integrations.litellm` - async + success/failure hooks for streaming audit.
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: controlzero
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.6
|
|
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
|
|
7
7
|
Project-URL: Repository, https://github.com/controlzero/controlzero
|
|
8
8
|
Project-URL: Examples, https://docs.controlzero.ai/sdk/integrations
|
|
9
|
-
Author-email: Control Zero <
|
|
9
|
+
Author-email: Control Zero <team@controlzero.ai>
|
|
10
10
|
License: Apache-2.0
|
|
11
11
|
License-File: LICENSE
|
|
12
12
|
Keywords: agents,ai,audit,governance,guardrails,llm,mcp,policy
|
|
@@ -82,7 +82,7 @@ print(cz.guard("read_file", {"path": "/tmp/foo"}).decision) # "allow"
|
|
|
82
82
|
## Install
|
|
83
83
|
|
|
84
84
|
```bash
|
|
85
|
-
pip install
|
|
85
|
+
pip install controlzero
|
|
86
86
|
```
|
|
87
87
|
|
|
88
88
|
## Why
|
|
@@ -254,6 +254,18 @@ cz = Client(api_key="cz_live_...", policy=local_policy, strict_hosted=True)
|
|
|
254
254
|
# RuntimeError: manual policy override detected ...
|
|
255
255
|
```
|
|
256
256
|
|
|
257
|
+
## Coding agent hooks
|
|
258
|
+
|
|
259
|
+
`controlzero hook-check` runs inside Claude Code, Gemini CLI, and Codex CLI
|
|
260
|
+
on every tool use and evaluates the call against your policy before it fires.
|
|
261
|
+
It extracts a canonical `tool:method` from the tool arguments so rules can
|
|
262
|
+
target `database:SELECT` vs `database:DROP`, or allow `Bash:git` while denying
|
|
263
|
+
`Bash:rm`. Multi-statement SQL and compound shell commands are resolved to the
|
|
264
|
+
most dangerous token, so a `SELECT ... ; DROP TABLE users;` payload matches
|
|
265
|
+
`database:DROP`, not `database:SELECT`. See
|
|
266
|
+
[Hook action extraction](https://docs.controlzero.ai/concepts/hook-extractor)
|
|
267
|
+
for the full extraction rules, security model, and per-tool examples.
|
|
268
|
+
|
|
257
269
|
## Framework examples
|
|
258
270
|
|
|
259
271
|
Full integration guides at [docs.controlzero.ai/sdk/integrations](https://docs.controlzero.ai/sdk/integrations):
|
|
@@ -30,7 +30,7 @@ print(cz.guard("read_file", {"path": "/tmp/foo"}).decision) # "allow"
|
|
|
30
30
|
## Install
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
|
-
pip install
|
|
33
|
+
pip install controlzero
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
## Why
|
|
@@ -202,6 +202,18 @@ cz = Client(api_key="cz_live_...", policy=local_policy, strict_hosted=True)
|
|
|
202
202
|
# RuntimeError: manual policy override detected ...
|
|
203
203
|
```
|
|
204
204
|
|
|
205
|
+
## Coding agent hooks
|
|
206
|
+
|
|
207
|
+
`controlzero hook-check` runs inside Claude Code, Gemini CLI, and Codex CLI
|
|
208
|
+
on every tool use and evaluates the call against your policy before it fires.
|
|
209
|
+
It extracts a canonical `tool:method` from the tool arguments so rules can
|
|
210
|
+
target `database:SELECT` vs `database:DROP`, or allow `Bash:git` while denying
|
|
211
|
+
`Bash:rm`. Multi-statement SQL and compound shell commands are resolved to the
|
|
212
|
+
most dangerous token, so a `SELECT ... ; DROP TABLE users;` payload matches
|
|
213
|
+
`database:DROP`, not `database:SELECT`. See
|
|
214
|
+
[Hook action extraction](https://docs.controlzero.ai/concepts/hook-extractor)
|
|
215
|
+
for the full extraction rules, security model, and per-tool examples.
|
|
216
|
+
|
|
205
217
|
## Framework examples
|
|
206
218
|
|
|
207
219
|
Full integration guides at [docs.controlzero.ai/sdk/integrations](https://docs.controlzero.ai/sdk/integrations):
|
|
@@ -349,7 +349,7 @@ def _zstd_decompress(data: bytes) -> bytes:
|
|
|
349
349
|
|
|
350
350
|
raise BundleVerificationError(
|
|
351
351
|
"zstd decompression library not installed; "
|
|
352
|
-
"install with: pip install
|
|
352
|
+
"install with: pip install -U controlzero (or pip install zstandard)"
|
|
353
353
|
)
|
|
354
354
|
|
|
355
355
|
|
|
@@ -369,7 +369,41 @@ def translate_to_local_policy(payload: dict) -> dict:
|
|
|
369
369
|
|
|
370
370
|
Policies are sorted by ``priority`` ascending so SDKs in every
|
|
371
371
|
language produce identical decisions from identical input.
|
|
372
|
+
|
|
373
|
+
Schema 1.1 (#228 Phase 2) adds three top-level enforcement-default
|
|
374
|
+
knobs -- ``default_action``, ``default_on_missing``, and
|
|
375
|
+
``default_on_tamper`` -- that the translator surfaces into the
|
|
376
|
+
local policy dict's ``settings`` block so downstream
|
|
377
|
+
:func:`controlzero.policy_loader.load_policy` can pick them up
|
|
378
|
+
unchanged. Missing or unknown values fall back to the canonical
|
|
379
|
+
deny/deny/warn (matches pre-1.1 hard-coded behaviour, so old
|
|
380
|
+
bundles keep working).
|
|
372
381
|
"""
|
|
382
|
+
# Lazy import to avoid a cycle: enforcer imports from this file
|
|
383
|
+
# via policy_loader via client. Importing at call time breaks the
|
|
384
|
+
# cycle cleanly while still letting translate_to_local_policy own
|
|
385
|
+
# the canonical-default fallback (one source of truth).
|
|
386
|
+
from controlzero._internal.enforcer import (
|
|
387
|
+
DEFAULT_BUNDLE_ACTION,
|
|
388
|
+
DEFAULT_BUNDLE_ON_MISSING,
|
|
389
|
+
DEFAULT_BUNDLE_ON_TAMPER,
|
|
390
|
+
VALID_DEFAULT_ACTIONS,
|
|
391
|
+
VALID_DEFAULT_ON_MISSING,
|
|
392
|
+
VALID_DEFAULT_ON_TAMPER,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
default_action = payload.get("default_action")
|
|
396
|
+
if default_action not in VALID_DEFAULT_ACTIONS:
|
|
397
|
+
default_action = DEFAULT_BUNDLE_ACTION
|
|
398
|
+
|
|
399
|
+
default_on_missing = payload.get("default_on_missing")
|
|
400
|
+
if default_on_missing not in VALID_DEFAULT_ON_MISSING:
|
|
401
|
+
default_on_missing = DEFAULT_BUNDLE_ON_MISSING
|
|
402
|
+
|
|
403
|
+
default_on_tamper = payload.get("default_on_tamper")
|
|
404
|
+
if default_on_tamper not in VALID_DEFAULT_ON_TAMPER:
|
|
405
|
+
default_on_tamper = DEFAULT_BUNDLE_ON_TAMPER
|
|
406
|
+
|
|
373
407
|
policies = payload.get("policies") or []
|
|
374
408
|
policies = sorted(
|
|
375
409
|
[p for p in policies if isinstance(p, dict)],
|
|
@@ -394,47 +428,184 @@ def translate_to_local_policy(payload: dict) -> dict:
|
|
|
394
428
|
flat.append(translated)
|
|
395
429
|
|
|
396
430
|
if not flat:
|
|
397
|
-
# Empty policy set:
|
|
398
|
-
#
|
|
431
|
+
# Empty policy set: synthetic catchall rule. The effect
|
|
432
|
+
# mirrors the bundle-level default_action so "empty bundle"
|
|
433
|
+
# continues to deliver the operator's chosen posture. Prior
|
|
434
|
+
# to #228 Phase 2 this was hard-coded to deny; now an org
|
|
435
|
+
# that ships an empty-bundle-with-default=allow gets the
|
|
436
|
+
# expected allow (with the same NO_ACTIVE_POLICIES
|
|
437
|
+
# reason_code so dashboards still bucket it as "nothing
|
|
438
|
+
# attached").
|
|
439
|
+
#
|
|
440
|
+
# Copy-choice note (2026-04-19, Bryan's P0): the previous
|
|
441
|
+
# message -- "No active policies. Define one in the Control Zero
|
|
442
|
+
# dashboard." -- presumed the user had not defined any policies.
|
|
443
|
+
# That presumption was wrong in the common case: the user had
|
|
444
|
+
# defined several, but the library-attachments state on the
|
|
445
|
+
# backend did not include any active attachment row, so the
|
|
446
|
+
# bundle arrived with zero policies while the dashboard Library
|
|
447
|
+
# tab still showed them. The new wording removes the
|
|
448
|
+
# presumption and points at the actual recovery action.
|
|
399
449
|
flat.append({
|
|
400
|
-
"effect":
|
|
450
|
+
"effect": default_action,
|
|
401
451
|
"action": "*",
|
|
402
452
|
"reason": (
|
|
403
|
-
"No active
|
|
453
|
+
"No policies are active on this project. If the dashboard "
|
|
454
|
+
"shows attached policies, regenerate the policy bundle."
|
|
404
455
|
),
|
|
456
|
+
"reason_code": "NO_ACTIVE_POLICIES",
|
|
405
457
|
})
|
|
406
458
|
|
|
407
|
-
return {
|
|
459
|
+
return {
|
|
460
|
+
"version": "1",
|
|
461
|
+
"rules": flat,
|
|
462
|
+
"settings": {
|
|
463
|
+
"default_action": default_action,
|
|
464
|
+
"default_on_missing": default_on_missing,
|
|
465
|
+
"default_on_tamper": default_on_tamper,
|
|
466
|
+
},
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def make_bundle_missing_policy(
|
|
471
|
+
default_on_missing: Optional[str] = None,
|
|
472
|
+
) -> dict:
|
|
473
|
+
"""Build the synthetic local-policy dict used when the bundle cannot load.
|
|
474
|
+
|
|
475
|
+
Returned shape matches what :func:`translate_to_local_policy`
|
|
476
|
+
produces, so downstream :func:`controlzero.policy_loader.load_policy`
|
|
477
|
+
accepts it unchanged.
|
|
478
|
+
|
|
479
|
+
Args:
|
|
480
|
+
default_on_missing: Effect to apply -- ``"deny"`` or ``"allow"``.
|
|
481
|
+
``None`` or an unknown value coerces to the canonical deny
|
|
482
|
+
default, matching the pre-#228 hard-coded behaviour.
|
|
483
|
+
|
|
484
|
+
The single catchall rule carries ``reason_code=BUNDLE_MISSING`` so
|
|
485
|
+
audit pipelines can distinguish "no bundle at all" (this path) from
|
|
486
|
+
"bundle loaded but empty" (``NO_ACTIVE_POLICIES``) and from
|
|
487
|
+
"rule set evaluated, nothing matched" (``NO_RULE_MATCH``).
|
|
488
|
+
"""
|
|
489
|
+
from controlzero._internal.enforcer import (
|
|
490
|
+
DEFAULT_BUNDLE_ACTION,
|
|
491
|
+
DEFAULT_BUNDLE_ON_MISSING,
|
|
492
|
+
DEFAULT_BUNDLE_ON_TAMPER,
|
|
493
|
+
VALID_DEFAULT_ON_MISSING,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
effect = default_on_missing if default_on_missing in VALID_DEFAULT_ON_MISSING else DEFAULT_BUNDLE_ON_MISSING
|
|
497
|
+
reason = (
|
|
498
|
+
"Policy bundle could not be loaded (never synced, backend "
|
|
499
|
+
"unreachable, or decrypt failed). "
|
|
500
|
+
f"Honoring default_on_missing={effect}."
|
|
501
|
+
)
|
|
502
|
+
return {
|
|
503
|
+
"version": "1",
|
|
504
|
+
"rules": [
|
|
505
|
+
{
|
|
506
|
+
"effect": effect,
|
|
507
|
+
"action": "*",
|
|
508
|
+
"reason": reason,
|
|
509
|
+
"reason_code": "BUNDLE_MISSING",
|
|
510
|
+
}
|
|
511
|
+
],
|
|
512
|
+
"settings": {
|
|
513
|
+
# Stamp the resolved default triple onto the synthetic
|
|
514
|
+
# policy so downstream code that reads settings sees
|
|
515
|
+
# consistent values. default_action mirrors the missing
|
|
516
|
+
# effect so no-match behaviour is predictable.
|
|
517
|
+
"default_action": effect if effect in {"deny", "allow"} else DEFAULT_BUNDLE_ACTION,
|
|
518
|
+
"default_on_missing": effect,
|
|
519
|
+
"default_on_tamper": DEFAULT_BUNDLE_ON_TAMPER,
|
|
520
|
+
},
|
|
521
|
+
}
|
|
408
522
|
|
|
409
523
|
|
|
410
524
|
def _translate_rule(rule: dict, policy_id: str) -> Optional[dict]:
|
|
411
|
-
"""Translate a single backend rule to the local rule shape.
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
525
|
+
"""Translate a single backend rule to the local rule shape.
|
|
526
|
+
|
|
527
|
+
The backend (:file:`bundle_handler.go` ``PolicyRule``) emits rules
|
|
528
|
+
whose tool pattern lives under plural ``actions: [...]``. This
|
|
529
|
+
function used to ignore that key entirely and fell back to ``"*"``
|
|
530
|
+
for every such rule, causing e.g. a ``deny database:execute`` rule
|
|
531
|
+
to silently become ``deny *`` after round-tripping through the
|
|
532
|
+
bundle. Both the plural (backend wire-format) and the legacy
|
|
533
|
+
singular (``tool`` / ``pattern`` / ``action`` / ``match.tool``) forms
|
|
534
|
+
are now supported; plural wins when both are present.
|
|
535
|
+
"""
|
|
536
|
+
# Accept several spellings of "effect". Policy engine has four
|
|
537
|
+
# canonical effects; anything else is treated as ``allow`` to stay
|
|
538
|
+
# fail-safe on forward-compat.
|
|
539
|
+
effect_raw = rule.get("effect")
|
|
540
|
+
if not effect_raw:
|
|
541
|
+
# Only fall back to rule["action"] for effect if it looks like
|
|
542
|
+
# one of the canonical effects. Otherwise "action" is a tool
|
|
543
|
+
# name and we should not use it here.
|
|
544
|
+
fallback = rule.get("action")
|
|
545
|
+
if fallback in ("allow", "deny", "warn", "audit"):
|
|
546
|
+
effect_raw = fallback
|
|
547
|
+
effect = effect_raw if effect_raw in ("allow", "deny", "warn", "audit") else "allow"
|
|
548
|
+
|
|
549
|
+
# Tool pattern resolution order:
|
|
550
|
+
# 1. plural ``actions`` (list, as emitted by the backend)
|
|
551
|
+
# 2. legacy singular ``tool`` / ``pattern``
|
|
552
|
+
# 3. legacy singular ``action`` (only if it is NOT an effect keyword)
|
|
553
|
+
# 4. nested ``match.tool`` / ``match.action``
|
|
554
|
+
# 5. default ``"*"`` (universal)
|
|
555
|
+
patterns: list[str] = []
|
|
556
|
+
raw_actions = rule.get("actions")
|
|
557
|
+
if isinstance(raw_actions, list):
|
|
558
|
+
patterns = [str(a) for a in raw_actions if isinstance(a, str) and a]
|
|
559
|
+
|
|
560
|
+
if not patterns:
|
|
561
|
+
candidate = rule.get("tool") or rule.get("pattern")
|
|
562
|
+
if candidate:
|
|
563
|
+
patterns = [str(candidate)]
|
|
564
|
+
|
|
565
|
+
if not patterns:
|
|
566
|
+
# Legacy singular "action" field -- only a tool pattern when
|
|
567
|
+
# it is NOT a canonical effect name (effect already captured
|
|
568
|
+
# above).
|
|
569
|
+
candidate = rule.get("action")
|
|
570
|
+
if candidate and candidate not in ("allow", "deny", "warn", "audit"):
|
|
571
|
+
patterns = [str(candidate)]
|
|
572
|
+
|
|
573
|
+
if not patterns:
|
|
422
574
|
match = rule.get("match")
|
|
423
575
|
if isinstance(match, dict):
|
|
424
|
-
|
|
425
|
-
|
|
576
|
+
candidate = match.get("tool") or match.get("action")
|
|
577
|
+
if candidate:
|
|
578
|
+
patterns = [str(candidate)]
|
|
579
|
+
|
|
580
|
+
if not patterns:
|
|
426
581
|
# Final fallback: universal match. Combined with a non-allow
|
|
427
582
|
# effect, this is still meaningful (e.g. deny-all).
|
|
428
|
-
|
|
583
|
+
patterns = ["*"]
|
|
429
584
|
|
|
430
585
|
translated: dict = {
|
|
431
586
|
"effect": effect,
|
|
432
|
-
|
|
587
|
+
# Emit plural ``actions`` so downstream
|
|
588
|
+
# :func:`controlzero.policy_loader.load_policy` can pass the
|
|
589
|
+
# full list through to :class:`PolicyRule.actions` unchanged.
|
|
590
|
+
# The loader accepts either ``action`` or ``actions``.
|
|
591
|
+
"actions": patterns if len(patterns) > 1 else patterns,
|
|
433
592
|
"reason": rule.get("reason") or f"policy:{policy_id}",
|
|
434
593
|
}
|
|
594
|
+
# Keep the singular ``action`` alias for single-pattern rules so
|
|
595
|
+
# older consumers that read the translated dict (tests, docs) still
|
|
596
|
+
# see a scalar. The loader prefers ``action`` when scalar.
|
|
597
|
+
if len(patterns) == 1:
|
|
598
|
+
translated["action"] = patterns[0]
|
|
435
599
|
|
|
436
600
|
resources = rule.get("resources") or rule.get("resource")
|
|
437
601
|
if resources:
|
|
438
602
|
translated["resources"] = resources
|
|
439
603
|
|
|
604
|
+
# Propagate the machine-readable reason_code when the source rule
|
|
605
|
+
# carries one. Today only the synthetic empty-bundle deny (above)
|
|
606
|
+
# sets this; user-authored rules are unaffected.
|
|
607
|
+
rc = rule.get("reason_code")
|
|
608
|
+
if isinstance(rc, str) and rc:
|
|
609
|
+
translated["reason_code"] = rc
|
|
610
|
+
|
|
440
611
|
return translated
|