controlzero 1.4.2__tar.gz → 1.4.5__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 (112) hide show
  1. {controlzero-1.4.2 → controlzero-1.4.5}/.gitignore +3 -1
  2. controlzero-1.4.5/CHANGELOG.md +71 -0
  3. {controlzero-1.4.2 → controlzero-1.4.5}/PKG-INFO +18 -3
  4. {controlzero-1.4.2 → controlzero-1.4.5}/README.md +13 -1
  5. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/__init__.py +1 -1
  6. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/_internal/bundle.py +192 -21
  7. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/_internal/enforcer.py +155 -6
  8. controlzero-1.4.5/controlzero/_internal/hook_extractors.py +631 -0
  9. controlzero-1.4.5/controlzero/_internal/tool_extractors.json +68 -0
  10. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/_internal/types.py +6 -0
  11. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/audit_remote.py +8 -0
  12. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/cli/main.py +342 -9
  13. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/client.py +262 -2
  14. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/policy_loader.py +73 -1
  15. {controlzero-1.4.2 → controlzero-1.4.5}/pyproject.toml +20 -2
  16. controlzero-1.4.5/tests/test_bundle_translate.py +266 -0
  17. controlzero-1.4.5/tests/test_cli_carve_out.py +476 -0
  18. controlzero-1.4.5/tests/test_cli_extractor_integration.py +473 -0
  19. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_coding_agent_hooks.py +57 -16
  20. controlzero-1.4.5/tests/test_default_action.py +359 -0
  21. controlzero-1.4.5/tests/test_hook_extractors.py +567 -0
  22. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_install_hooks.py +6 -1
  23. controlzero-1.4.5/tests/test_reason_code.py +352 -0
  24. controlzero-1.4.5/tests/test_refresh.py +560 -0
  25. controlzero-1.4.5/tests/test_sql_semantic_class.py +213 -0
  26. controlzero-1.4.2/CHANGELOG.md +0 -33
  27. {controlzero-1.4.2 → controlzero-1.4.5}/Dockerfile.test +0 -0
  28. {controlzero-1.4.2 → controlzero-1.4.5}/LICENSE +0 -0
  29. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/_internal/__init__.py +0 -0
  30. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/_internal/dlp_scanner.py +0 -0
  31. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/audit_local.py +0 -0
  32. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/cli/__init__.py +0 -0
  33. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/cli/templates/autogen.yaml +0 -0
  34. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/cli/templates/claude-code.yaml +0 -0
  35. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/cli/templates/codex-cli.yaml +0 -0
  36. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/cli/templates/cost-cap.yaml +0 -0
  37. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/cli/templates/crewai.yaml +0 -0
  38. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/cli/templates/cursor.yaml +0 -0
  39. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/cli/templates/gemini-cli.yaml +0 -0
  40. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/cli/templates/generic.yaml +0 -0
  41. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/cli/templates/langchain.yaml +0 -0
  42. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/cli/templates/mcp.yaml +0 -0
  43. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/cli/templates/rag.yaml +0 -0
  44. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/device.py +0 -0
  45. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/enrollment.py +0 -0
  46. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/errors.py +0 -0
  47. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/hosted_policy.py +0 -0
  48. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/integrations/__init__.py +0 -0
  49. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/integrations/anthropic.py +0 -0
  50. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/integrations/autogen.py +0 -0
  51. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/integrations/braintrust.py +0 -0
  52. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/integrations/crewai/__init__.py +0 -0
  53. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/integrations/crewai/agent.py +0 -0
  54. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/integrations/crewai/crew.py +0 -0
  55. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/integrations/crewai/task.py +0 -0
  56. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/integrations/crewai/tool.py +0 -0
  57. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/integrations/google.py +0 -0
  58. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/integrations/google_adk/__init__.py +0 -0
  59. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/integrations/google_adk/agent.py +0 -0
  60. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/integrations/google_adk/tool.py +0 -0
  61. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/integrations/langchain/__init__.py +0 -0
  62. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/integrations/langchain/agent.py +0 -0
  63. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/integrations/langchain/callbacks.py +0 -0
  64. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/integrations/langchain/chain.py +0 -0
  65. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/integrations/langchain/graph.py +0 -0
  66. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/integrations/langchain/modern.py +0 -0
  67. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/integrations/langchain/tool.py +0 -0
  68. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/integrations/langfuse.py +0 -0
  69. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/integrations/litellm.py +0 -0
  70. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/integrations/openai.py +0 -0
  71. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/integrations/pydantic_ai.py +0 -0
  72. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/integrations/vercel_ai.py +0 -0
  73. {controlzero-1.4.2 → controlzero-1.4.5}/controlzero/tamper.py +0 -0
  74. {controlzero-1.4.2 → controlzero-1.4.5}/examples/hello_world.py +0 -0
  75. {controlzero-1.4.2 → controlzero-1.4.5}/tests/conftest.py +0 -0
  76. {controlzero-1.4.2 → controlzero-1.4.5}/tests/integrations/__init__.py +0 -0
  77. {controlzero-1.4.2 → controlzero-1.4.5}/tests/integrations/test_google.py +0 -0
  78. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_action_canonicalization.py +0 -0
  79. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_agent_name_env.py +0 -0
  80. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_audit_remote.py +0 -0
  81. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_audit_sink_isolation.py +0 -0
  82. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_bundle_parser.py +0 -0
  83. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_cli_hook.py +0 -0
  84. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_cli_hosted_refresh.py +0 -0
  85. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_cli_init.py +0 -0
  86. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_cli_init_templates.py +0 -0
  87. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_cli_tail.py +0 -0
  88. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_cli_test.py +0 -0
  89. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_cli_validate.py +0 -0
  90. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_conditions.py +0 -0
  91. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_device.py +0 -0
  92. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_dlp_scanner.py +0 -0
  93. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_enrollment.py +0 -0
  94. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_fail_closed_eval.py +0 -0
  95. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_glob_matching.py +0 -0
  96. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_hosted_policy_e2e.py +0 -0
  97. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_hybrid_mode_strict.py +0 -0
  98. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_hybrid_mode_warn.py +0 -0
  99. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_local_mode_dict.py +0 -0
  100. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_local_mode_file_json.py +0 -0
  101. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_local_mode_file_yaml.py +0 -0
  102. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_log_fallback_stderr.py +0 -0
  103. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_log_options_ignored_hosted.py +0 -0
  104. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_log_rotation.py +0 -0
  105. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_no_policy_no_key.py +0 -0
  106. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_package_rename_shim.py +0 -0
  107. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_policy_freshness.py +0 -0
  108. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_policy_settings.py +0 -0
  109. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_quarantine.py +0 -0
  110. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_tamper.py +0 -0
  111. {controlzero-1.4.2 → controlzero-1.4.5}/tests/test_tamper_behavior.py +0 -0
  112. {controlzero-1.4.2 → controlzero-1.4.5}/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,71 @@
1
+ # Changelog
2
+
3
+ ## 1.4.5 -- 2026-05-05
4
+
5
+ ### Added
6
+
7
+ - **Cross-CLI canonical tool coverage** (#341). The shared
8
+ `tool_extractors.json` is now spec_version 2 and adds three new
9
+ canonical tools (`web_search`, `file_search`, `task`) plus extended
10
+ aliases so PowerShell shell-command emission, Codex CLI's
11
+ `apply_patch`, Gemini CLI's `read_many_files`, and the WebFetch
12
+ family all resolve to the existing canonical tools. One rule
13
+ `action: Bash:rm` now covers Claude Code, Gemini CLI, Codex CLI,
14
+ and PowerShell on Windows.
15
+ - **SQL semantic-class layer** (#345/#350). New `sql_semantic_class()`
16
+ helper emits a parallel `database:read|write|admin|exec` action
17
+ alongside the existing per-keyword `database:SELECT|DROP|...`
18
+ action. Write portable rules like `allow: database:read` that cover
19
+ SELECT, EXPLAIN, SHOW, DESCRIBE, and CTE in one rule, without
20
+ enumerating every keyword the dialect accepts. Multi-statement
21
+ piggybacks like `SELECT 1; DROP TABLE x` correctly resolve to
22
+ `database:admin` so deny rules catch them. 21 new parity-test
23
+ fixtures (cross-SDK byte-identical with the Node sibling).
24
+
25
+ ### Documentation
26
+
27
+ - New customer-facing reference at `docs/sdk/policies/canonical-tools.md`
28
+ explaining canonical tool names, alias coverage per host CLI, and
29
+ the contract for adding new clients.
30
+ - New SQL semantic class section in `canonical-tools.md` plus updated
31
+ quickstart and read-only-database recipe.
32
+
33
+ ## 1.4.4 -- 2026-04-19
34
+
35
+ ### Fixed
36
+
37
+ - 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.
38
+ - 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.
39
+ - 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.
40
+
41
+ ## 1.4.1 -- 2026-04-15
42
+
43
+ ### Added
44
+
45
+ - `agent_name` arg + `CZ_AGENT_NAME` env var contract on the `Client` constructor (issue #71). Order: explicit arg > env > `default-agent`.
46
+ - `CZ_DEBUG=1` (or `true`/`yes`/`on`) flips the controlzero logger to DEBUG at construction. Cheap escape hatch for support.
47
+ - Optional install extras: `controlzero[google]`, `controlzero[openai]`, `controlzero[anthropic]` (issue #68). Pin the matching upstream SDK so users do not see import errors.
48
+ - Cross-SDK action canonicalization parity test (issue #69). Mirrors the same fixture in the Node and Go SDKs.
49
+ - Smoke + denial + error-propagation tests for the `wrap_google` integration (issue #68).
50
+
51
+ ## 1.4.0 -- 2026-04-15
52
+
53
+ ### Breaking changes
54
+
55
+ - 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.
56
+ - Google integration rewritten against `google-genai` (deprecated `google.generativeai` removed). Install `google-genai`.
57
+ - `integrations.langchain.agent.GovernedAgent` wrapping `AgentExecutor` is deprecated in LangChain v1.x. Use `integrations.langchain.modern.create_governed_agent`.
58
+
59
+ ### New integrations
60
+
61
+ - `integrations.autogen` - first-party helper for autogen-agentchat v0.7+
62
+ - `integrations.pydantic_ai` - first-party helper for pydantic-ai v1.x
63
+ - `integrations.langchain.modern` - LangGraph create_agent pattern
64
+
65
+ ### New features
66
+
67
+ - 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.
68
+
69
+ ### Enhancements
70
+
71
+ - `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.2
3
+ Version: 1.4.5
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
@@ -23,9 +23,12 @@ Classifier: Topic :: Security
23
23
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
24
  Requires-Python: >=3.9
25
25
  Requires-Dist: click>=8.1.0
26
+ Requires-Dist: cryptography>=41.0.0
27
+ Requires-Dist: httpx>=0.25.0
26
28
  Requires-Dist: loguru>=0.7.0
27
29
  Requires-Dist: pydantic>=2.0.0
28
30
  Requires-Dist: pyyaml>=6.0
31
+ Requires-Dist: zstandard>=0.22.0
29
32
  Provides-Extra: anthropic
30
33
  Requires-Dist: anthropic>=0.25.0; extra == 'anthropic'
31
34
  Provides-Extra: dev
@@ -79,7 +82,7 @@ print(cz.guard("read_file", {"path": "/tmp/foo"}).decision) # "allow"
79
82
  ## Install
80
83
 
81
84
  ```bash
82
- pip install control-zero
85
+ pip install controlzero
83
86
  ```
84
87
 
85
88
  ## Why
@@ -251,6 +254,18 @@ cz = Client(api_key="cz_live_...", policy=local_policy, strict_hosted=True)
251
254
  # RuntimeError: manual policy override detected ...
252
255
  ```
253
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
+
254
269
  ## Framework examples
255
270
 
256
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.1"
31
+ __version__ = "1.4.5"
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
@@ -30,6 +30,64 @@ from controlzero._internal.dlp_scanner import (
30
30
  from controlzero._internal.types import PolicyRule
31
31
 
32
32
 
33
+ # Canonical reason_code enum values. Stable machine-readable labels
34
+ # so integrations + dashboards can branch on decision provenance
35
+ # without regex-matching the human-readable `reason` string.
36
+ #
37
+ # Added 2026-04-19 after Bryan's P0: the SDK fail-closed with "No
38
+ # active policies. Define one in the Control Zero dashboard." when
39
+ # the user had in fact defined three; the three surfaces disagreed
40
+ # because policy_attachments was empty. reason_code lets the hosted
41
+ # dashboard, local-mode CLI, and audit ingestion distinguish the
42
+ # fail-closed paths without parsing English text.
43
+ #
44
+ # Extended 2026-04-20 (#228 Phase 2) from 5 values to 8. Additions
45
+ # are strictly additive: existing consumers that switch on the old
46
+ # five keep working because the enum only grows. The three new codes
47
+ # correspond to surfaces that previously had no machine label at all:
48
+ #
49
+ # - RULE_MATCH -- a user-authored rule fired (allow OR deny).
50
+ # - BUNDLE_MISSING -- bundle could not be loaded (never synced,
51
+ # backend 4xx/5xx, decrypt failure). Honors
52
+ # `default_on_missing`.
53
+ # - DLP_BLOCKED -- DLP rule overrode the policy-level allow.
54
+ REASON_CODE_RULE_MATCH = "RULE_MATCH"
55
+ REASON_CODE_NO_RULE_MATCH = "NO_RULE_MATCH"
56
+ REASON_CODE_NO_ACTIVE_POLICIES = "NO_ACTIVE_POLICIES"
57
+ REASON_CODE_BUNDLE_MISSING = "BUNDLE_MISSING"
58
+ REASON_CODE_BUNDLE_TAMPERED = "BUNDLE_TAMPERED"
59
+ REASON_CODE_MACHINE_QUARANTINED = "MACHINE_QUARANTINED"
60
+ REASON_CODE_NETWORK_ERROR = "NETWORK_ERROR"
61
+ REASON_CODE_DLP_BLOCKED = "DLP_BLOCKED"
62
+
63
+ VALID_REASON_CODES = frozenset({
64
+ REASON_CODE_RULE_MATCH,
65
+ REASON_CODE_NO_RULE_MATCH,
66
+ REASON_CODE_NO_ACTIVE_POLICIES,
67
+ REASON_CODE_BUNDLE_MISSING,
68
+ REASON_CODE_BUNDLE_TAMPERED,
69
+ REASON_CODE_MACHINE_QUARANTINED,
70
+ REASON_CODE_NETWORK_ERROR,
71
+ REASON_CODE_DLP_BLOCKED,
72
+ })
73
+
74
+ # Canonical bundle-level default values. These must stay in lockstep
75
+ # with the backend's `DefaultBundleAction` / `DefaultBundleOnMissing`
76
+ # / `DefaultBundleOnTamper` constants (bundle_handler.go). They are
77
+ # the values the SDK falls back to when a bundle (a) was built by an
78
+ # old backend that predates schema 1.1 and has no default_* fields,
79
+ # or (b) parses one of the fields as an unknown value. Matches the
80
+ # pre-#228 hard-coded behaviour: no-match deny, missing-bundle deny,
81
+ # tamper warn.
82
+ DEFAULT_BUNDLE_ACTION = "deny"
83
+ DEFAULT_BUNDLE_ON_MISSING = "deny"
84
+ DEFAULT_BUNDLE_ON_TAMPER = "warn"
85
+
86
+ VALID_DEFAULT_ACTIONS = frozenset({"deny", "allow", "warn"})
87
+ VALID_DEFAULT_ON_MISSING = frozenset({"deny", "allow"})
88
+ VALID_DEFAULT_ON_TAMPER = frozenset({"warn", "deny", "deny-all", "quarantine"})
89
+
90
+
33
91
  @dataclass
34
92
  class PolicyDecision:
35
93
  """Result of a policy evaluation.
@@ -38,13 +96,21 @@ class PolicyDecision:
38
96
  effect: One of "allow", "deny", "warn", "audit".
39
97
  decision: Alias for effect, for ergonomic Hello World code.
40
98
  policy_id: ID of the rule that matched, or None if no match.
41
- reason: Human-readable reason for the decision.
99
+ reason: Human-readable reason for the decision. Keep this for
100
+ display / error messages; it can be translated, re-worded,
101
+ or localized without breaking consumers.
102
+ reason_code: Machine-readable code from the REASON_CODE_* set.
103
+ Stable across SDK versions -- automation should branch on
104
+ this, not on `reason`. Empty string when the decision
105
+ carries no structured reason (e.g. a plain user-policy
106
+ match).
42
107
  evaluated_rules: How many rules were checked before a match.
43
108
  dlp_findings: List of DLP match dicts for the audit trail.
44
109
  """
45
110
  effect: str
46
111
  policy_id: Optional[str] = None
47
112
  reason: Optional[str] = None
113
+ reason_code: str = ""
48
114
  evaluated_rules: int = 0
49
115
  dlp_findings: list[dict] = field(default_factory=list)
50
116
 
@@ -96,9 +162,16 @@ class PolicyEvaluator:
96
162
  self,
97
163
  rules: Optional[list[PolicyRule]] = None,
98
164
  dlp_scanner: Optional[DLPScanner] = None,
165
+ default_action: str = DEFAULT_BUNDLE_ACTION,
99
166
  ):
100
167
  self._rules: list[PolicyRule] = rules or []
101
168
  self._dlp_scanner: Optional[DLPScanner] = dlp_scanner
169
+ # Coerce unknown values to the canonical deny default. Keeps the
170
+ # evaluator safe even if a caller hands in a stale / bad value
171
+ # from a future schema -- SDK fails closed, never raises.
172
+ self._default_action: str = (
173
+ default_action if default_action in VALID_DEFAULT_ACTIONS else DEFAULT_BUNDLE_ACTION
174
+ )
102
175
 
103
176
  def load(self, rules: list[PolicyRule]) -> None:
104
177
  """Replace the rule set."""
@@ -108,6 +181,24 @@ class PolicyEvaluator:
108
181
  """Set or replace the DLP scanner."""
109
182
  self._dlp_scanner = scanner
110
183
 
184
+ def set_default_action(self, action: str) -> None:
185
+ """Replace the no-match default action.
186
+
187
+ Called by the Client after a hosted-policy refresh so the
188
+ bundle's resolved ``default_action`` takes effect without
189
+ rebuilding the evaluator. Unknown values coerce to the
190
+ canonical deny default, matching pre-#228 fail-closed
191
+ behaviour.
192
+ """
193
+ self._default_action = (
194
+ action if action in VALID_DEFAULT_ACTIONS else DEFAULT_BUNDLE_ACTION
195
+ )
196
+
197
+ @property
198
+ def default_action(self) -> str:
199
+ """The effective no-match default action."""
200
+ return self._default_action
201
+
111
202
  def evaluate(
112
203
  self,
113
204
  tool: str,
@@ -128,24 +219,58 @@ class PolicyEvaluator:
128
219
  PolicyDecision. Always returns; never raises.
129
220
  """
130
221
  action = f"{tool}:{method}"
131
- resource = (context or {}).get("resource")
222
+
223
+ # Semantic-class layer (#345): when the action carries a
224
+ # canonical SQL class (database:read|write|admin|exec) the
225
+ # evaluator matches rules against BOTH the per-keyword action
226
+ # above AND this class action. A rule whose actions list
227
+ # contains `database:read` therefore fires for any read-shaped
228
+ # SQL (SELECT, EXPLAIN, SHOW, CTE, ...) regardless of dialect
229
+ # spelling, while existing per-keyword rules
230
+ # (`database:DROP`) keep working unchanged. Two sources for
231
+ # the class action: the caller may provide it in
232
+ # ``context['action_semantic_class']`` (used by the hook-check
233
+ # CLI), or we derive it on the fly from ``args['sql']`` when
234
+ # the tool is ``database`` and no class was provided.
235
+ ctx_dict = context or {}
236
+ semantic_action = ctx_dict.get("action_semantic_class") or ""
237
+ if not semantic_action and tool == "database" and args:
238
+ from controlzero._internal.hook_extractors import sql_semantic_class
239
+ sql_text = args.get("sql") if isinstance(args, dict) else None
240
+ if isinstance(sql_text, str) and sql_text:
241
+ cls = sql_semantic_class(sql_text)
242
+ if cls:
243
+ semantic_action = f"{tool}:{cls}"
244
+ candidate_actions: list[str] = [action]
245
+ if semantic_action and semantic_action != action:
246
+ candidate_actions.append(semantic_action)
247
+
248
+ resource = ctx_dict.get("resource")
132
249
  evaluated = 0
133
250
 
134
251
  for rule in self._rules:
135
252
  evaluated += 1
136
- if not _glob_any(rule.actions, action):
253
+ if not any(_glob_any(rule.actions, a) for a in candidate_actions):
137
254
  continue
138
255
  if rule.resources:
139
256
  if not resource or not _glob_any(rule.resources, resource):
140
257
  continue
141
258
  if not self._conditions_match(rule.conditions, context, args):
142
259
  continue
260
+ # Prefer the rule-declared reason_code (only synthetic
261
+ # rules set one today -- e.g. the empty-bundle
262
+ # NO_ACTIVE_POLICIES deny). Everything else defaults to
263
+ # RULE_MATCH so consumers can tell a user-authored rule
264
+ # firing apart from the no-match fallthrough below.
265
+ rule_code = getattr(rule, "reason_code", "") or ""
266
+ decision_code = rule_code or REASON_CODE_RULE_MATCH
143
267
  decision = PolicyDecision(
144
268
  effect=rule.effect,
145
269
  policy_id=rule.id or rule.name or None,
146
270
  # User-provided reason wins. Falls back to canned text only if
147
271
  # the rule had no `reason:` field in the source policy.
148
272
  reason=rule.reason or f"Matched rule {rule.id or rule.name}".strip(),
273
+ reason_code=decision_code,
149
274
  evaluated_rules=evaluated,
150
275
  )
151
276
  # DLP scan: only when the policy decision is "allow" and args exist
@@ -153,10 +278,27 @@ class PolicyEvaluator:
153
278
  decision = self._apply_dlp_scan(decision, args)
154
279
  return decision
155
280
 
156
- # Fail-closed default. No matching rule means deny.
281
+ # No-match default. Pre-#228 this was hard-coded to deny; as
282
+ # of Phase 2 the bundle can override it (default_action =
283
+ # deny|allow|warn). The reason_code stays NO_RULE_MATCH so
284
+ # "rule set evaluated, nothing matched" is distinguishable
285
+ # from "bundle was empty" (NO_ACTIVE_POLICIES) and "bundle
286
+ # was missing" (BUNDLE_MISSING).
287
+ #
288
+ # Reason-text note: the historical "fail-closed default"
289
+ # phrase is kept for the deny case so downstream consumers
290
+ # that regex-match the reason string (a pattern we actively
291
+ # discourage -- use reason_code instead) keep working.
292
+ if self._default_action == "deny":
293
+ reason = "No matching policy rule (fail-closed default)"
294
+ elif self._default_action == "allow":
295
+ reason = "No matching policy rule (default_action=allow)"
296
+ else: # "warn"
297
+ reason = "No matching policy rule (default_action=warn)"
157
298
  return PolicyDecision(
158
- effect="deny",
159
- reason="No matching policy rule (fail-closed default)",
299
+ effect=self._default_action,
300
+ reason=reason,
301
+ reason_code=REASON_CODE_NO_RULE_MATCH,
160
302
  evaluated_rules=evaluated,
161
303
  )
162
304
 
@@ -224,6 +366,13 @@ class PolicyEvaluator:
224
366
  f"DLP rule '{blocking.rule_name}' matched "
225
367
  f"{blocking.category} content in tool arguments"
226
368
  ),
369
+ # DLP_BLOCKED means: the tool-name policy said allow,
370
+ # but a DLP rule found a hit in the arguments and
371
+ # overrode the decision. Dashboards bucket this as a
372
+ # different failure surface from a rule-authored deny
373
+ # so ops can tell "my tool is blocked" from "my
374
+ # argument tripped a DLP guard" apart.
375
+ reason_code=REASON_CODE_DLP_BLOCKED,
227
376
  evaluated_rules=decision.evaluated_rules,
228
377
  dlp_findings=findings,
229
378
  )