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.
Files changed (113) hide show
  1. {controlzero-1.4.3 → controlzero-1.4.6}/.gitignore +3 -1
  2. controlzero-1.4.6/CHANGELOG.md +88 -0
  3. {controlzero-1.4.3 → controlzero-1.4.6}/PKG-INFO +15 -3
  4. {controlzero-1.4.3 → controlzero-1.4.6}/README.md +13 -1
  5. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/__init__.py +1 -1
  6. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/_internal/bundle.py +192 -21
  7. controlzero-1.4.6/controlzero/_internal/enforcer.py +403 -0
  8. controlzero-1.4.6/controlzero/_internal/hook_extractors.py +631 -0
  9. controlzero-1.4.6/controlzero/_internal/tool_extractors.json +68 -0
  10. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/_internal/types.py +6 -0
  11. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/audit_remote.py +8 -0
  12. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/cli/main.py +342 -9
  13. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/client.py +262 -2
  14. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/hosted_policy.py +10 -2
  15. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/policy_loader.py +73 -1
  16. {controlzero-1.4.3 → controlzero-1.4.6}/pyproject.toml +9 -2
  17. controlzero-1.4.6/tests/test_bundle_translate.py +266 -0
  18. controlzero-1.4.6/tests/test_cli_carve_out.py +476 -0
  19. controlzero-1.4.6/tests/test_cli_extractor_integration.py +473 -0
  20. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_coding_agent_hooks.py +57 -16
  21. controlzero-1.4.6/tests/test_default_action.py +359 -0
  22. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_glob_matching.py +83 -0
  23. controlzero-1.4.6/tests/test_hook_extractors.py +567 -0
  24. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_install_hooks.py +6 -1
  25. controlzero-1.4.6/tests/test_reason_code.py +352 -0
  26. controlzero-1.4.6/tests/test_refresh.py +565 -0
  27. controlzero-1.4.6/tests/test_sql_semantic_class.py +213 -0
  28. controlzero-1.4.3/CHANGELOG.md +0 -33
  29. controlzero-1.4.3/controlzero/_internal/enforcer.py +0 -243
  30. {controlzero-1.4.3 → controlzero-1.4.6}/Dockerfile.test +0 -0
  31. {controlzero-1.4.3 → controlzero-1.4.6}/LICENSE +0 -0
  32. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/_internal/__init__.py +0 -0
  33. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/_internal/dlp_scanner.py +0 -0
  34. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/audit_local.py +0 -0
  35. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/cli/__init__.py +0 -0
  36. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/cli/templates/autogen.yaml +0 -0
  37. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/cli/templates/claude-code.yaml +0 -0
  38. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/cli/templates/codex-cli.yaml +0 -0
  39. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/cli/templates/cost-cap.yaml +0 -0
  40. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/cli/templates/crewai.yaml +0 -0
  41. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/cli/templates/cursor.yaml +0 -0
  42. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/cli/templates/gemini-cli.yaml +0 -0
  43. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/cli/templates/generic.yaml +0 -0
  44. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/cli/templates/langchain.yaml +0 -0
  45. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/cli/templates/mcp.yaml +0 -0
  46. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/cli/templates/rag.yaml +0 -0
  47. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/device.py +0 -0
  48. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/enrollment.py +0 -0
  49. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/errors.py +0 -0
  50. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/__init__.py +0 -0
  51. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/anthropic.py +0 -0
  52. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/autogen.py +0 -0
  53. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/braintrust.py +0 -0
  54. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/crewai/__init__.py +0 -0
  55. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/crewai/agent.py +0 -0
  56. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/crewai/crew.py +0 -0
  57. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/crewai/task.py +0 -0
  58. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/crewai/tool.py +0 -0
  59. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/google.py +0 -0
  60. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/google_adk/__init__.py +0 -0
  61. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/google_adk/agent.py +0 -0
  62. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/google_adk/tool.py +0 -0
  63. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/langchain/__init__.py +0 -0
  64. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/langchain/agent.py +0 -0
  65. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/langchain/callbacks.py +0 -0
  66. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/langchain/chain.py +0 -0
  67. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/langchain/graph.py +0 -0
  68. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/langchain/modern.py +0 -0
  69. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/langchain/tool.py +0 -0
  70. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/langfuse.py +0 -0
  71. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/litellm.py +0 -0
  72. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/openai.py +0 -0
  73. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/pydantic_ai.py +0 -0
  74. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/integrations/vercel_ai.py +0 -0
  75. {controlzero-1.4.3 → controlzero-1.4.6}/controlzero/tamper.py +0 -0
  76. {controlzero-1.4.3 → controlzero-1.4.6}/examples/hello_world.py +0 -0
  77. {controlzero-1.4.3 → controlzero-1.4.6}/tests/conftest.py +0 -0
  78. {controlzero-1.4.3 → controlzero-1.4.6}/tests/integrations/__init__.py +0 -0
  79. {controlzero-1.4.3 → controlzero-1.4.6}/tests/integrations/test_google.py +0 -0
  80. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_action_canonicalization.py +0 -0
  81. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_agent_name_env.py +0 -0
  82. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_audit_remote.py +0 -0
  83. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_audit_sink_isolation.py +0 -0
  84. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_bundle_parser.py +0 -0
  85. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_cli_hook.py +0 -0
  86. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_cli_hosted_refresh.py +0 -0
  87. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_cli_init.py +0 -0
  88. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_cli_init_templates.py +0 -0
  89. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_cli_tail.py +0 -0
  90. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_cli_test.py +0 -0
  91. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_cli_validate.py +0 -0
  92. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_conditions.py +0 -0
  93. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_device.py +0 -0
  94. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_dlp_scanner.py +0 -0
  95. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_enrollment.py +0 -0
  96. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_fail_closed_eval.py +0 -0
  97. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_hosted_policy_e2e.py +0 -0
  98. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_hybrid_mode_strict.py +0 -0
  99. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_hybrid_mode_warn.py +0 -0
  100. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_local_mode_dict.py +0 -0
  101. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_local_mode_file_json.py +0 -0
  102. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_local_mode_file_yaml.py +0 -0
  103. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_log_fallback_stderr.py +0 -0
  104. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_log_options_ignored_hosted.py +0 -0
  105. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_log_rotation.py +0 -0
  106. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_no_policy_no_key.py +0 -0
  107. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_package_rename_shim.py +0 -0
  108. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_policy_freshness.py +0 -0
  109. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_policy_settings.py +0 -0
  110. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_quarantine.py +0 -0
  111. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_tamper.py +0 -0
  112. {controlzero-1.4.3 → controlzero-1.4.6}/tests/test_tamper_behavior.py +0 -0
  113. {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
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 <hello@controlzero.ai>
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 control-zero
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 control-zero
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):
@@ -28,7 +28,7 @@ from controlzero.errors import (
28
28
  )
29
29
  from controlzero.policy_loader import load_policy
30
30
 
31
- __version__ = "1.4.3"
31
+ __version__ = "1.4.6"
32
32
 
33
33
  __all__ = [
34
34
  "Client",
@@ -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 'controlzero[hosted]' or pip install zstandard"
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: default deny-all. An empty hosted project is
398
- # not "everything allowed" -- it's "nothing configured yet."
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": "deny",
450
+ "effect": default_action,
401
451
  "action": "*",
402
452
  "reason": (
403
- "No active policies. Define one in the Control Zero dashboard."
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 {"version": "1", "rules": flat}
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
- 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:
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
- pattern = match.get("tool") or match.get("action")
425
- if not pattern:
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
- pattern = "*"
583
+ patterns = ["*"]
429
584
 
430
585
  translated: dict = {
431
586
  "effect": effect,
432
- "action": pattern,
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