controlzero 1.4.6__tar.gz → 1.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. controlzero-1.5.0/CHANGELOG.md +226 -0
  2. {controlzero-1.4.6 → controlzero-1.5.0}/PKG-INFO +12 -7
  3. {controlzero-1.4.6 → controlzero-1.5.0}/README.md +11 -6
  4. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/__init__.py +1 -1
  5. controlzero-1.5.0/controlzero/_internal/action_aliases.py +165 -0
  6. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/_internal/bundle.py +10 -0
  7. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/_internal/enforcer.py +76 -0
  8. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/audit_remote.py +6 -0
  9. controlzero-1.5.0/controlzero/cli/debug_bundle.py +581 -0
  10. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/cli/main.py +113 -87
  11. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/client.py +196 -56
  12. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/device.py +64 -15
  13. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/hosted_policy.py +84 -1
  14. {controlzero-1.4.6 → controlzero-1.5.0}/pyproject.toml +1 -1
  15. controlzero-1.5.0/tests/conftest.py +46 -0
  16. controlzero-1.5.0/tests/parity/action_aliases.json +55 -0
  17. controlzero-1.5.0/tests/test_action_aliases.py +224 -0
  18. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_cli_carve_out.py +19 -4
  19. controlzero-1.5.0/tests/test_cli_debug_bundle.py +392 -0
  20. controlzero-1.5.0/tests/test_cli_hosted_refresh.py +103 -0
  21. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_coding_agent_hooks.py +15 -9
  22. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_device.py +89 -50
  23. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_hybrid_mode_strict.py +6 -2
  24. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_hybrid_mode_warn.py +3 -3
  25. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_reason_code.py +10 -3
  26. controlzero-1.5.0/tests/test_synthetic_policy_id_t79.py +287 -0
  27. controlzero-1.5.0/tests/test_t103_precedence.py +167 -0
  28. controlzero-1.5.0/tests/test_t104_cache_gc.py +174 -0
  29. controlzero-1.5.0/tests/test_t108_local_override_audit.py +163 -0
  30. controlzero-1.4.6/CHANGELOG.md +0 -88
  31. controlzero-1.4.6/tests/conftest.py +0 -30
  32. controlzero-1.4.6/tests/test_cli_hosted_refresh.py +0 -151
  33. {controlzero-1.4.6 → controlzero-1.5.0}/.gitignore +0 -0
  34. {controlzero-1.4.6 → controlzero-1.5.0}/Dockerfile.test +0 -0
  35. {controlzero-1.4.6 → controlzero-1.5.0}/LICENSE +0 -0
  36. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/_internal/__init__.py +0 -0
  37. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/_internal/dlp_scanner.py +0 -0
  38. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/_internal/hook_extractors.py +0 -0
  39. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/_internal/tool_extractors.json +0 -0
  40. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/_internal/types.py +0 -0
  41. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/audit_local.py +0 -0
  42. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/cli/__init__.py +0 -0
  43. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/cli/templates/autogen.yaml +0 -0
  44. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/cli/templates/claude-code.yaml +0 -0
  45. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/cli/templates/codex-cli.yaml +0 -0
  46. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/cli/templates/cost-cap.yaml +0 -0
  47. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/cli/templates/crewai.yaml +0 -0
  48. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/cli/templates/cursor.yaml +0 -0
  49. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/cli/templates/gemini-cli.yaml +0 -0
  50. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/cli/templates/generic.yaml +0 -0
  51. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/cli/templates/langchain.yaml +0 -0
  52. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/cli/templates/mcp.yaml +0 -0
  53. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/cli/templates/rag.yaml +0 -0
  54. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/enrollment.py +0 -0
  55. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/errors.py +0 -0
  56. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/integrations/__init__.py +0 -0
  57. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/integrations/anthropic.py +0 -0
  58. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/integrations/autogen.py +0 -0
  59. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/integrations/braintrust.py +0 -0
  60. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/integrations/crewai/__init__.py +0 -0
  61. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/integrations/crewai/agent.py +0 -0
  62. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/integrations/crewai/crew.py +0 -0
  63. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/integrations/crewai/task.py +0 -0
  64. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/integrations/crewai/tool.py +0 -0
  65. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/integrations/google.py +0 -0
  66. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/integrations/google_adk/__init__.py +0 -0
  67. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/integrations/google_adk/agent.py +0 -0
  68. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/integrations/google_adk/tool.py +0 -0
  69. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/integrations/langchain/__init__.py +0 -0
  70. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/integrations/langchain/agent.py +0 -0
  71. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/integrations/langchain/callbacks.py +0 -0
  72. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/integrations/langchain/chain.py +0 -0
  73. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/integrations/langchain/graph.py +0 -0
  74. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/integrations/langchain/modern.py +0 -0
  75. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/integrations/langchain/tool.py +0 -0
  76. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/integrations/langfuse.py +0 -0
  77. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/integrations/litellm.py +0 -0
  78. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/integrations/openai.py +0 -0
  79. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/integrations/pydantic_ai.py +0 -0
  80. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/integrations/vercel_ai.py +0 -0
  81. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/policy_loader.py +0 -0
  82. {controlzero-1.4.6 → controlzero-1.5.0}/controlzero/tamper.py +0 -0
  83. {controlzero-1.4.6 → controlzero-1.5.0}/examples/hello_world.py +0 -0
  84. {controlzero-1.4.6 → controlzero-1.5.0}/tests/integrations/__init__.py +0 -0
  85. {controlzero-1.4.6 → controlzero-1.5.0}/tests/integrations/test_google.py +0 -0
  86. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_action_canonicalization.py +0 -0
  87. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_agent_name_env.py +0 -0
  88. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_audit_remote.py +0 -0
  89. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_audit_sink_isolation.py +0 -0
  90. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_bundle_parser.py +0 -0
  91. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_bundle_translate.py +0 -0
  92. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_cli_extractor_integration.py +0 -0
  93. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_cli_hook.py +0 -0
  94. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_cli_init.py +0 -0
  95. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_cli_init_templates.py +0 -0
  96. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_cli_tail.py +0 -0
  97. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_cli_test.py +0 -0
  98. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_cli_validate.py +0 -0
  99. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_conditions.py +0 -0
  100. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_default_action.py +0 -0
  101. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_dlp_scanner.py +0 -0
  102. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_enrollment.py +0 -0
  103. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_fail_closed_eval.py +0 -0
  104. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_glob_matching.py +0 -0
  105. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_hook_extractors.py +0 -0
  106. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_hosted_policy_e2e.py +0 -0
  107. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_install_hooks.py +0 -0
  108. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_local_mode_dict.py +0 -0
  109. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_local_mode_file_json.py +0 -0
  110. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_local_mode_file_yaml.py +0 -0
  111. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_log_fallback_stderr.py +0 -0
  112. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_log_options_ignored_hosted.py +0 -0
  113. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_log_rotation.py +0 -0
  114. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_no_policy_no_key.py +0 -0
  115. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_package_rename_shim.py +0 -0
  116. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_policy_freshness.py +0 -0
  117. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_policy_settings.py +0 -0
  118. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_quarantine.py +0 -0
  119. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_refresh.py +0 -0
  120. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_sql_semantic_class.py +0 -0
  121. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_tamper.py +0 -0
  122. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_tamper_behavior.py +0 -0
  123. {controlzero-1.4.6 → controlzero-1.5.0}/tests/test_tamper_hook.py +0 -0
@@ -0,0 +1,226 @@
1
+ # Changelog
2
+
3
+ ## 1.5.0 -- 2026-05-12
4
+
5
+ ### Changed (governance posture; opt-out path documented)
6
+
7
+ - **Hosted policy wins by default when `CONTROLZERO_API_KEY` is set.**
8
+ Before 1.5: a stale local `policy.yaml` silently shadowed the
9
+ dashboard policy. A paying customer ran for 25 days without
10
+ enforcement because of this. After 1.5: an api_key means hosted is
11
+ authoritative; a local file is consulted only when no api_key, or
12
+ when `CONTROLZERO_LOCAL_OVERRIDE=1` is set explicitly as a
13
+ debug/offline escape hatch. An explicit `policy=` / `policy_file=`
14
+ arg passed to `Client(...)` still wins (caller is intentional) but
15
+ emits a loud stderr warning. `strict_hosted=True` upgrades the
16
+ warning to a `HybridModeError`. The active policy source is named
17
+ in a single stderr line at Client init so customer support can
18
+ debug in one glance; `CONTROLZERO_QUIET=1` silences it in CLI
19
+ subprocess contexts.
20
+
21
+ ### Added
22
+
23
+ - **Governance audit event when LOCAL_OVERRIDE is used.** Every Client
24
+ init that bypasses the hosted bundle via
25
+ `CONTROLZERO_LOCAL_OVERRIDE=1` emits a one-shot audit event with
26
+ `reason_code=LOCAL_OVERRIDE_ACTIVE` to the remote audit sink. Ops
27
+ can filter / alert on this code in the audit dashboard so a silent
28
+ bypass is no longer possible.
29
+ - **Cache GC on api_key rotation.** On every fresh bootstrap fetch
30
+ the SDK removes `cache/bootstrap-<scope>.json` +
31
+ `cache/bundle-<scope>.{bin,meta}` files whose scope does NOT match
32
+ the active api_key. Stray user files in the cache dir are
33
+ preserved.
34
+ - **`policy.json` accepted alongside `policy.yaml`.** The cwd
35
+ auto-detect now probes `controlzero.{yaml,yml,json}` in order.
36
+ Schema is identical to YAML; default write is still YAML.
37
+ - New reason_code: `LOCAL_OVERRIDE_ACTIVE`. Extends the SDK
38
+ reason-code enum from 8 to 9. Used only on lifecycle events; no
39
+ impact on guard decisions.
40
+
41
+ ### Refs
42
+
43
+ GH #424 (umbrella), PRs #425 (precedence), #428 (cache GC), #427
44
+ (dashboard dedupe), #429 (governance audit event).
45
+
46
+ ## 1.4.7 -- 2026-05-11
47
+
48
+ ### Added
49
+
50
+ - **`controlzero debug bundle` -- inspect SDK-loaded rules + simulate guards**
51
+ (T87, GH #392). The bundle on disk is encrypted+signed; `cat` returns
52
+ nothing useful, so until now diagnosing a deny-deny incident meant
53
+ shipping the bundle back to engineering or attaching a debugger
54
+ (Bryan's deny-deny took ~3 hours to root-cause for exactly this
55
+ reason). The new subcommand reads the matching `bootstrap-<prefix>.json`
56
+ for keys, decrypts and verifies the cached bundle blob via the same
57
+ parser the SDK uses, and prints a human-readable summary: bundle id,
58
+ created_at / expires_at, default_action / default_on_missing /
59
+ default_on_tamper, every policy (id, name, version, is_enabled,
60
+ priority), and every rule (id, effect, principals, actions, resources,
61
+ conditions).
62
+
63
+ With `--simulate "tool method args"` it also runs the request through
64
+ the same `PolicyEvaluator` the SDK uses and prints the decision plus
65
+ which rule matched and why. The args grammar accepts either a JSON
66
+ object or whitespace-separated `key=value` pairs (values may contain
67
+ spaces, so `sql=SELECT id FROM orders` parses as a single value).
68
+
69
+ The output is designed to be pasted into a support thread: the
70
+ encryption key, signing public key, and API key are NEVER printed,
71
+ enforced by a final `_redact_sensitive` pass before emit.
72
+
73
+ Six tests in `tests/test_cli_debug_bundle.py` cover the happy path,
74
+ simulate-allow, simulate-no-rule-match, missing bootstrap, missing
75
+ bundle, and the privacy contract.
76
+
77
+ ### Fixed
78
+
79
+ - **Pre-#350 customer rules using legacy database action names keep
80
+ matching** (T84, GitHub #389). The SDK started emitting canonical
81
+ SQL semantic classes (`database:read`, `database:write`,
82
+ `database:admin`, `database:exec`) in 1.4.x via #345/#350. That
83
+ silently broke any policy authored before #350 that used legacy
84
+ per-keyword actions like `database:query`, `database:DROP`, or
85
+ `database:execute`: the legacy rule and the modern call no longer
86
+ shared a string, so the rule never fired and the call fell through
87
+ to the default deny.
88
+
89
+ The fix is a bidirectional alias shim. The enforcer now expands
90
+ candidate actions through a single-source-of-truth alias table
91
+ (`controlzero/_internal/action_aliases.py`) so:
92
+ - A pre-#350 rule with `actions: ["database:query"]` keeps matching
93
+ modern SELECT calls (which the SDK emits as `database:read`).
94
+ - A modern rule with `actions: ["database:read"]` keeps matching
95
+ legacy guard calls that pass `method="SELECT"`.
96
+ - The legacy ambiguous `database:delete` (used historically for
97
+ both row DELETE and table DROP) maps to BOTH `database:write`
98
+ and `database:admin`, so neither modern intent silently breaks.
99
+
100
+ The alias table is byte-identical across Python, Node, and Go SDKs
101
+ and is locked by the cross-SDK fixture at
102
+ `tests/parity/action_aliases.json`. Drift in any SDK fails its
103
+ parity test.
104
+
105
+ This is a NO-BREAKING-CHANGES fix: every existing rule continues to
106
+ work, modern rules continue to work, and only previously-broken
107
+ legacy rules start matching again.
108
+
109
+ - **Synthetic policy_id sentinels** (T79, Bryan deny-deny postmortem).
110
+ `PolicyDecision.policy_id` is now stamped with one of six canonical
111
+ `synthetic:*` values whenever the deny was emitted by a fail-closed
112
+ code path (no rule matched, empty bundle, missing bundle, T83-class
113
+ resource gate skip, machine quarantine, evaluator crash). Previously
114
+ these all carried `policy_id=None` and rendered as a blank Policy
115
+ column on the audit dashboard, making four very different bug
116
+ classes look identical. The new sentinels are:
117
+ - `synthetic:NO_RULE_MATCH` -- bundle loaded, no rule's actions
118
+ matched the call.
119
+ - `synthetic:NO_ACTIVE_POLICIES` -- bundle was structurally empty.
120
+ - `synthetic:BUNDLE_MISSING` -- enrolled but no bundle loadable.
121
+ - `synthetic:RESOURCE_GATE_SKIP` -- a rule's actions matched but its
122
+ `resources:` list excluded the call (T83-class signature).
123
+ - `synthetic:QUARANTINE` -- machine in local quarantine.
124
+ - `synthetic:ENGINE_UNAVAILABLE` -- evaluator crashed mid-evaluate.
125
+
126
+ The `reason_code` field is unchanged; the synthetic policy_id is a
127
+ parallel signal that the dashboard reads to switch chip styling and
128
+ link to the matching troubleshooting anchor. Backwards-compatible:
129
+ consumers that only branch on `reason_code` keep working.
130
+
131
+ Constants exported from `controlzero._internal.enforcer` as
132
+ `SYNTHETIC_POLICY_ID_PREFIX`, `SYNTHETIC_NO_RULE_MATCH`, etc., plus
133
+ `VALID_SYNTHETIC_POLICY_IDS` for runtime validation.
134
+
135
+ ## 1.4.6 -- 2026-05-11
136
+
137
+ ### Fixed
138
+
139
+ - **resources:["*"] no longer requires a caller resource** (T83).
140
+ Hosted policy bundles emit `resources:["*"]` for any rule that does
141
+ not scope by resource. The enforcer's resource gate previously
142
+ required a caller-supplied `context.resource` even when the rule's
143
+ resources list was the universal wildcard, causing every rule to be
144
+ silently skipped on calls that didn't pass a resource. Result: every
145
+ `cz.guard()` returned `deny` with `reason_code=NO_RULE_MATCH` regardless
146
+ of what the policy said. Now rules with `*` in their resources match
147
+ universally; non-wildcard resource patterns still require a caller
148
+ resource (no silent broadening). Three regression tests added in
149
+ `tests/test_glob_matching.py` including the exact bundle shape from
150
+ the customer reproduction.
151
+
152
+ ## 1.4.5 -- 2026-05-05
153
+
154
+ ### Added
155
+
156
+ - **Cross-CLI canonical tool coverage** (#341). The shared
157
+ `tool_extractors.json` is now spec_version 2 and adds three new
158
+ canonical tools (`web_search`, `file_search`, `task`) plus extended
159
+ aliases so PowerShell shell-command emission, Codex CLI's
160
+ `apply_patch`, Gemini CLI's `read_many_files`, and the WebFetch
161
+ family all resolve to the existing canonical tools. One rule
162
+ `action: Bash:rm` now covers Claude Code, Gemini CLI, Codex CLI,
163
+ and PowerShell on Windows.
164
+ - **SQL semantic-class layer** (#345/#350). New `sql_semantic_class()`
165
+ helper emits a parallel `database:read|write|admin|exec` action
166
+ alongside the existing per-keyword `database:SELECT|DROP|...`
167
+ action. Write portable rules like `allow: database:read` that cover
168
+ SELECT, EXPLAIN, SHOW, DESCRIBE, and CTE in one rule, without
169
+ enumerating every keyword the dialect accepts. Multi-statement
170
+ piggybacks like `SELECT 1; DROP TABLE x` correctly resolve to
171
+ `database:admin` so deny rules catch them. 21 new parity-test
172
+ fixtures (cross-SDK byte-identical with the Node sibling).
173
+
174
+ Legacy action names continue to work: `database:query`,
175
+ `database:execute`, and `database:delete` are still matched against
176
+ the same calls and remain valid in policy rules. New policies
177
+ should prefer the canonical class names; existing rules keyed on
178
+ the legacy names need no migration.
179
+
180
+ ### Documentation
181
+
182
+ - New customer-facing reference at `docs/sdk/policies/canonical-tools.md`
183
+ explaining canonical tool names, alias coverage per host CLI, and
184
+ the contract for adding new clients.
185
+ - New SQL semantic class section in `canonical-tools.md` plus updated
186
+ quickstart and read-only-database recipe.
187
+
188
+ ## 1.4.4 -- 2026-04-19
189
+
190
+ ### Fixed
191
+
192
+ - 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.
193
+ - 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.
194
+ - 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.
195
+
196
+ ## 1.4.1 -- 2026-04-15
197
+
198
+ ### Added
199
+
200
+ - `agent_name` arg + `CZ_AGENT_NAME` env var contract on the `Client` constructor (issue #71). Order: explicit arg > env > `default-agent`.
201
+ - `CZ_DEBUG=1` (or `true`/`yes`/`on`) flips the controlzero logger to DEBUG at construction. Cheap escape hatch for support.
202
+ - Optional install extras: `controlzero[google]`, `controlzero[openai]`, `controlzero[anthropic]` (issue #68). Pin the matching upstream SDK so users do not see import errors.
203
+ - Cross-SDK action canonicalization parity test (issue #69). Mirrors the same fixture in the Node and Go SDKs.
204
+ - Smoke + denial + error-propagation tests for the `wrap_google` integration (issue #68).
205
+
206
+ ## 1.4.0 -- 2026-04-15
207
+
208
+ ### Breaking changes
209
+
210
+ - 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.
211
+ - Google integration rewritten against `google-genai` (deprecated `google.generativeai` removed). Install `google-genai`.
212
+ - `integrations.langchain.agent.GovernedAgent` wrapping `AgentExecutor` is deprecated in LangChain v1.x. Use `integrations.langchain.modern.create_governed_agent`.
213
+
214
+ ### New integrations
215
+
216
+ - `integrations.autogen` - first-party helper for autogen-agentchat v0.7+
217
+ - `integrations.pydantic_ai` - first-party helper for pydantic-ai v1.x
218
+ - `integrations.langchain.modern` - LangGraph create_agent pattern
219
+
220
+ ### New features
221
+
222
+ - 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.
223
+
224
+ ### Enhancements
225
+
226
+ - `integrations.litellm` - async + success/failure hooks for streaming audit.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: controlzero
3
- Version: 1.4.6
3
+ Version: 1.5.0
4
4
  Summary: AI agent governance: policies, audit, and observability for tool calls. Works locally with no signup.
5
5
  Project-URL: Homepage, https://controlzero.ai
6
6
  Project-URL: Documentation, https://docs.controlzero.ai
@@ -239,19 +239,24 @@ these `log_*` options are ignored with a warning.
239
239
 
240
240
  ## Hybrid mode
241
241
 
242
- If you set both an API key AND pass a local policy, the local policy
243
- **overrides** the dashboard policy and you get a loud WARN log on init:
242
+ Default (T103, 2026-05-12): when `CONTROLZERO_API_KEY` is set, the
243
+ hosted (dashboard) policy wins. Pass `CONTROLZERO_LOCAL_OVERRIDE=1` to
244
+ force the local file as a debug fallback.
245
+
246
+ If you BOTH set an API key AND pass a `policy=` / `policy_file=` arg
247
+ to `Client(...)`, the explicit local arg wins (caller is intentional)
248
+ and you get a loud WARN log on init:
244
249
 
245
250
  ```
246
- WARNING: controlzero: manual policy override detected. ...
251
+ WARNING: controlzero: explicit local policy overrides the hosted bundle. ...
247
252
  ```
248
253
 
249
- This is intentional: it makes accidental prod bypass impossible to miss.
250
- For prod environments, opt into strict mode to raise instead:
254
+ This makes accidental prod bypass impossible to miss. For prod
255
+ environments, opt into strict mode to raise instead:
251
256
 
252
257
  ```python
253
258
  cz = Client(api_key="cz_live_...", policy=local_policy, strict_hosted=True)
254
- # RuntimeError: manual policy override detected ...
259
+ # HybridModeError: explicit local policy overrides the hosted bundle ...
255
260
  ```
256
261
 
257
262
  ## Coding agent hooks
@@ -187,19 +187,24 @@ these `log_*` options are ignored with a warning.
187
187
 
188
188
  ## Hybrid mode
189
189
 
190
- If you set both an API key AND pass a local policy, the local policy
191
- **overrides** the dashboard policy and you get a loud WARN log on init:
190
+ Default (T103, 2026-05-12): when `CONTROLZERO_API_KEY` is set, the
191
+ hosted (dashboard) policy wins. Pass `CONTROLZERO_LOCAL_OVERRIDE=1` to
192
+ force the local file as a debug fallback.
193
+
194
+ If you BOTH set an API key AND pass a `policy=` / `policy_file=` arg
195
+ to `Client(...)`, the explicit local arg wins (caller is intentional)
196
+ and you get a loud WARN log on init:
192
197
 
193
198
  ```
194
- WARNING: controlzero: manual policy override detected. ...
199
+ WARNING: controlzero: explicit local policy overrides the hosted bundle. ...
195
200
  ```
196
201
 
197
- This is intentional: it makes accidental prod bypass impossible to miss.
198
- For prod environments, opt into strict mode to raise instead:
202
+ This makes accidental prod bypass impossible to miss. For prod
203
+ environments, opt into strict mode to raise instead:
199
204
 
200
205
  ```python
201
206
  cz = Client(api_key="cz_live_...", policy=local_policy, strict_hosted=True)
202
- # RuntimeError: manual policy override detected ...
207
+ # HybridModeError: explicit local policy overrides the hosted bundle ...
203
208
  ```
204
209
 
205
210
  ## Coding agent hooks
@@ -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.6"
31
+ __version__ = "1.5.0"
32
32
 
33
33
  __all__ = [
34
34
  "Client",
@@ -0,0 +1,165 @@
1
+ """Bidirectional alias table between legacy and canonical action names.
2
+
3
+ T84 / GitHub #389. Mandate: NO BREAKING CHANGES.
4
+
5
+ Pre-#350 customer rules used legacy database action names directly: a
6
+ rule with ``actions: ["database:query"]`` or ``actions: ["database:DROP"]``
7
+ matched a guard call where the SDK passed ``method="query"`` /
8
+ ``method="DROP"``. Starting with #345/#350 the SDK emits canonical SQL
9
+ semantic classes instead (``database:read``, ``database:write``,
10
+ ``database:admin``, ``database:exec``).
11
+
12
+ Without an alias shim, that change broke every pre-#350 rule on the
13
+ day a customer upgraded the SDK. The alias table fixes this in both
14
+ directions:
15
+
16
+ - A pre-#350 rule with ``actions: ["database:query"]`` keeps matching a
17
+ modern SELECT call (which the SDK emits as ``database:read``)
18
+ because ``database:read`` is the canonical form of ``query``.
19
+ - A modern rule with ``actions: ["database:read"]`` keeps matching a
20
+ legacy guard call where the host passed ``method="SELECT"`` because
21
+ ``SELECT`` is one of the read-class aliases.
22
+
23
+ The enforcer's candidate_actions list is expanded via
24
+ ``expand_candidate_actions`` to include every alias of every
25
+ candidate. Any rule whose actions list contains ANY alias of the
26
+ caller's action will match.
27
+
28
+ The alias table itself is the single source of truth across all three
29
+ SDKs (Python / Node / Go). The cross-SDK fixture at
30
+ ``tests/parity/action_aliases.json`` is byte-identical to the JSON
31
+ dump of this table; drift is caught by the parity test in each SDK.
32
+
33
+ The legacy ``database:delete`` action is intentionally ambiguous:
34
+ older policies used it for both row-level DELETE and table-level DROP.
35
+ We map it to BOTH ``database:write`` AND ``database:admin`` so neither
36
+ modern intent is silently broken.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import json
42
+ from typing import Iterable
43
+
44
+ # Tool covered by this alias table. Currently only "database" carries
45
+ # the legacy <-> canonical split; other tools were added post-#350 and
46
+ # always emitted canonical names from day one.
47
+ TOOL = "database"
48
+
49
+ # Canonical class -> ordered list of legacy aliases. Order matters for
50
+ # the JSON dump that the parity fixture compares against.
51
+ _CLASSES: dict[str, list[str]] = {
52
+ "read": ["query", "SELECT", "EXPLAIN", "SHOW", "DESCRIBE", "FETCH", "READ"],
53
+ "write": ["UPDATE", "INSERT", "DELETE", "MERGE", "UPSERT", "REPLACE"],
54
+ "admin": ["DROP", "CREATE", "TRUNCATE", "ALTER", "GRANT", "REVOKE", "RENAME"],
55
+ "exec": ["execute", "EXECUTE", "EXEC", "CALL", "do"],
56
+ }
57
+
58
+ # Legacy aliases that map to MORE THAN ONE canonical class. A rule
59
+ # written against the legacy ambiguous name keeps firing on either
60
+ # modern intent. The match direction (legacy -> canonical) is the
61
+ # important one here; reverse (canonical -> legacy) is handled by the
62
+ # class table above.
63
+ _AMBIGUOUS: dict[str, list[str]] = {
64
+ "delete": ["write", "admin"],
65
+ }
66
+
67
+
68
+ def _canonical(method: str) -> str:
69
+ return f"{TOOL}:{method}"
70
+
71
+
72
+ # Forward map: legacy alias method -> set of canonical actions.
73
+ # Built once at import time so guard() stays cheap on the hot path.
74
+ _LEGACY_TO_CANONICAL: dict[str, set[str]] = {}
75
+ for cls, aliases in _CLASSES.items():
76
+ canon = _canonical(cls)
77
+ for alias in aliases:
78
+ _LEGACY_TO_CANONICAL.setdefault(alias, set()).add(canon)
79
+ for alias, classes in _AMBIGUOUS.items():
80
+ _LEGACY_TO_CANONICAL.setdefault(alias, set()).update(_canonical(c) for c in classes)
81
+
82
+ # Reverse map: canonical method -> set of legacy aliases (as full
83
+ # tool:method actions).
84
+ _CANONICAL_TO_LEGACY: dict[str, set[str]] = {}
85
+ for cls, aliases in _CLASSES.items():
86
+ canon = _canonical(cls)
87
+ _CANONICAL_TO_LEGACY[canon] = {_canonical(a) for a in aliases}
88
+
89
+
90
+ def expand_candidate_actions(actions: Iterable[str]) -> list[str]:
91
+ """Expand an iterable of candidate actions to include every known alias.
92
+
93
+ Both directions are expanded:
94
+
95
+ - Legacy ``database:query`` adds canonical ``database:read``.
96
+ - Canonical ``database:read`` adds every legacy alias
97
+ (``database:query``, ``database:SELECT``, ...).
98
+ - Ambiguous legacy ``database:delete`` adds BOTH ``database:write``
99
+ and ``database:admin``.
100
+
101
+ The original action is always preserved at the head of the list so
102
+ callers that read the first element (audit trail provenance) keep
103
+ seeing the un-expanded form.
104
+
105
+ Order is stable: original actions first (in input order), then
106
+ expansions in deterministic class+alias order.
107
+ """
108
+ seen: set[str] = set()
109
+ out: list[str] = []
110
+
111
+ for action in actions:
112
+ if action and action not in seen:
113
+ seen.add(action)
114
+ out.append(action)
115
+
116
+ # Second pass: walk every original action and append its aliases.
117
+ # Two-pass keeps the input order for the originals and gives a
118
+ # deterministic ordering for the expansions.
119
+ for action in list(out):
120
+ if not action or ":" not in action:
121
+ continue
122
+ tool, method = action.split(":", 1)
123
+ if tool != TOOL:
124
+ continue
125
+ # Legacy method -> canonical(s).
126
+ for canon in sorted(_LEGACY_TO_CANONICAL.get(method, ())):
127
+ if canon not in seen:
128
+ seen.add(canon)
129
+ out.append(canon)
130
+ # Canonical method -> legacy aliases.
131
+ for legacy in sorted(_CANONICAL_TO_LEGACY.get(action, ())):
132
+ if legacy not in seen:
133
+ seen.add(legacy)
134
+ out.append(legacy)
135
+
136
+ return out
137
+
138
+
139
+ def alias_table_json() -> str:
140
+ """Return the alias table as a deterministic JSON string.
141
+
142
+ Used by the parity test in each SDK to confirm byte-identical
143
+ alias content across Python / Node / Go. The on-disk fixture at
144
+ ``tests/parity/action_aliases.json`` carries an extra ``comment``
145
+ key documenting the contract; this dump omits the comment so each
146
+ SDK's hash check can compare apples to apples.
147
+ """
148
+ payload = {
149
+ "version": 1,
150
+ "tool": TOOL,
151
+ "classes": {
152
+ cls: {
153
+ "canonical": _canonical(cls),
154
+ "aliases": list(aliases),
155
+ }
156
+ for cls, aliases in _CLASSES.items()
157
+ },
158
+ "ambiguous_aliases": {
159
+ alias: list(classes) for alias, classes in _AMBIGUOUS.items()
160
+ },
161
+ }
162
+ return json.dumps(payload, indent=2, sort_keys=False)
163
+
164
+
165
+ __all__ = ["TOOL", "expand_candidate_actions", "alias_table_json"]
@@ -449,6 +449,12 @@ def translate_to_local_policy(payload: dict) -> dict:
449
449
  flat.append({
450
450
  "effect": default_action,
451
451
  "action": "*",
452
+ # T79: stamp a synthetic policy_id so the audit dashboard
453
+ # can render a recognizable chip + tooltip linking to the
454
+ # right troubleshooting anchor. The reason_code stays the
455
+ # same; the synthetic id is purely an audit-presentation
456
+ # contract (audit ingest stores it verbatim).
457
+ "id": "synthetic:NO_ACTIVE_POLICIES",
452
458
  "reason": (
453
459
  "No policies are active on this project. If the dashboard "
454
460
  "shows attached policies, regenerate the policy bundle."
@@ -505,6 +511,10 @@ def make_bundle_missing_policy(
505
511
  {
506
512
  "effect": effect,
507
513
  "action": "*",
514
+ # T79: stamp a synthetic policy_id so the audit
515
+ # dashboard can render a recognizable chip + link to
516
+ # the BUNDLE_MISSING troubleshooting anchor.
517
+ "id": "synthetic:BUNDLE_MISSING",
508
518
  "reason": reason,
509
519
  "reason_code": "BUNDLE_MISSING",
510
520
  }
@@ -59,6 +59,13 @@ REASON_CODE_BUNDLE_TAMPERED = "BUNDLE_TAMPERED"
59
59
  REASON_CODE_MACHINE_QUARANTINED = "MACHINE_QUARANTINED"
60
60
  REASON_CODE_NETWORK_ERROR = "NETWORK_ERROR"
61
61
  REASON_CODE_DLP_BLOCKED = "DLP_BLOCKED"
62
+ # Governance trail (T108, 2026-05-12). Emitted once per Client init when
63
+ # the user has set CONTROLZERO_LOCAL_OVERRIDE=1 with an api_key, telling
64
+ # the SDK to bypass the hosted bundle in favour of a local file. Posted
65
+ # to the remote audit sink so ops can detect override usage via the
66
+ # normal audit dashboard + alert on it. NOT a deny / allow decision --
67
+ # decision is "audit" and policy_id is "<lifecycle>".
68
+ REASON_CODE_LOCAL_OVERRIDE_ACTIVE = "LOCAL_OVERRIDE_ACTIVE"
62
69
 
63
70
  VALID_REASON_CODES = frozenset({
64
71
  REASON_CODE_RULE_MATCH,
@@ -69,6 +76,40 @@ VALID_REASON_CODES = frozenset({
69
76
  REASON_CODE_MACHINE_QUARANTINED,
70
77
  REASON_CODE_NETWORK_ERROR,
71
78
  REASON_CODE_DLP_BLOCKED,
79
+ REASON_CODE_LOCAL_OVERRIDE_ACTIVE,
80
+ })
81
+
82
+ # Synthetic policy_id sentinels (T79 / Bryan deny-deny postmortem,
83
+ # 2026-05-11). When a deny is emitted by anything OTHER than a
84
+ # user-authored rule, the SDK stamps the audit row's `policy_id` with
85
+ # one of these `synthetic:*` values so the audit dashboard can render
86
+ # a recognizable chip and link it to the right troubleshooting
87
+ # anchor. Without this, four very different bug classes (stale
88
+ # bundle, missing resource gate, vocabulary mismatch, genuine
89
+ # no-match) all looked identical in the Policy column (blank
90
+ # placeholder + Decision=Deny + reason_code=NO_RULE_MATCH).
91
+ #
92
+ # The `synthetic:` prefix is a contract: the backend audit ingest
93
+ # stores it verbatim (no validation on policy_id content), the
94
+ # frontend matches on the prefix to switch chip styling, and the
95
+ # values themselves mirror the reason_code enum 1:1 PLUS one new
96
+ # value (RESOURCE_GATE_SKIP) that captures the T83-class bug where
97
+ # every action-matching rule was skipped purely on the resource gate.
98
+ SYNTHETIC_POLICY_ID_PREFIX = "synthetic:"
99
+ SYNTHETIC_NO_RULE_MATCH = "synthetic:NO_RULE_MATCH"
100
+ SYNTHETIC_NO_ACTIVE_POLICIES = "synthetic:NO_ACTIVE_POLICIES"
101
+ SYNTHETIC_BUNDLE_MISSING = "synthetic:BUNDLE_MISSING"
102
+ SYNTHETIC_RESOURCE_GATE_SKIP = "synthetic:RESOURCE_GATE_SKIP"
103
+ SYNTHETIC_QUARANTINE = "synthetic:QUARANTINE"
104
+ SYNTHETIC_ENGINE_UNAVAILABLE = "synthetic:ENGINE_UNAVAILABLE"
105
+
106
+ VALID_SYNTHETIC_POLICY_IDS = frozenset({
107
+ SYNTHETIC_NO_RULE_MATCH,
108
+ SYNTHETIC_NO_ACTIVE_POLICIES,
109
+ SYNTHETIC_BUNDLE_MISSING,
110
+ SYNTHETIC_RESOURCE_GATE_SKIP,
111
+ SYNTHETIC_QUARANTINE,
112
+ SYNTHETIC_ENGINE_UNAVAILABLE,
72
113
  })
73
114
 
74
115
  # Canonical bundle-level default values. These must stay in lockstep
@@ -245,13 +286,31 @@ class PolicyEvaluator:
245
286
  if semantic_action and semantic_action != action:
246
287
  candidate_actions.append(semantic_action)
247
288
 
289
+ # T84: expand candidate_actions through the legacy <-> canonical
290
+ # alias table so pre-#350 customer rules using legacy database
291
+ # action names (database:query, database:DROP, database:execute,
292
+ # ...) keep matching modern SDK calls that emit canonical
293
+ # semantic classes (database:read|write|admin|exec), and vice
294
+ # versa. NO BREAKING CHANGES contract -- see #389.
295
+ from controlzero._internal.action_aliases import expand_candidate_actions
296
+ candidate_actions = expand_candidate_actions(candidate_actions)
297
+
248
298
  resource = ctx_dict.get("resource")
249
299
  evaluated = 0
300
+ # T79: track whether the no-match path was caused PURELY by the
301
+ # resource gate (every action-matching rule was skipped because
302
+ # the resource didn't match) so the synthetic deny can be
303
+ # tagged RESOURCE_GATE_SKIP rather than the more generic
304
+ # NO_RULE_MATCH. This is the T83-class signature -- a rule's
305
+ # actions matched the call but its `resources:` list excluded it.
306
+ action_matched_resource_skipped = False
307
+ action_matched_any = False
250
308
 
251
309
  for rule in self._rules:
252
310
  evaluated += 1
253
311
  if not any(_glob_any(rule.actions, a) for a in candidate_actions):
254
312
  continue
313
+ action_matched_any = True
255
314
  if rule.resources:
256
315
  # T83: a rule whose resources list contains "*" matches
257
316
  # universally and must NOT require the caller to supply
@@ -265,6 +324,7 @@ class PolicyEvaluator:
265
324
  # require a caller-supplied resource and glob-match it.
266
325
  if "*" not in rule.resources:
267
326
  if not resource or not _glob_any(rule.resources, resource):
327
+ action_matched_resource_skipped = True
268
328
  continue
269
329
  if not self._conditions_match(rule.conditions, context, args):
270
330
  continue
@@ -306,8 +366,24 @@ class PolicyEvaluator:
306
366
  reason = "No matching policy rule (default_action=allow)"
307
367
  else: # "warn"
308
368
  reason = "No matching policy rule (default_action=warn)"
369
+
370
+ # T79: distinguish the T83-class signature ("a rule's actions
371
+ # matched but its resources gate excluded the call") from the
372
+ # generic no-match. Both still apply default_action; the
373
+ # synthetic policy_id is what the audit dashboard reads to
374
+ # surface the right remediation.
375
+ if (
376
+ self._default_action == "deny"
377
+ and action_matched_any
378
+ and action_matched_resource_skipped
379
+ ):
380
+ synthetic_id = SYNTHETIC_RESOURCE_GATE_SKIP
381
+ else:
382
+ synthetic_id = SYNTHETIC_NO_RULE_MATCH
383
+
309
384
  return PolicyDecision(
310
385
  effect=self._default_action,
386
+ policy_id=synthetic_id,
311
387
  reason=reason,
312
388
  reason_code=REASON_CODE_NO_RULE_MATCH,
313
389
  evaluated_rules=evaluated,
@@ -295,6 +295,12 @@ class BearerAuditSink:
295
295
  "policy_id": entry.get("policy_id", ""),
296
296
  "rule_id": entry.get("policy_id", ""),
297
297
  "reason": entry.get("reason", ""),
298
+ # T108 (2026-05-12): governance reason_code that the backend
299
+ # audit_logs column already accepts (#228 Phase 2). Lets
300
+ # ops filter / alert on synthetic lifecycle events (e.g.
301
+ # LOCAL_OVERRIDE_ACTIVE) without false-matching them against
302
+ # guard decisions.
303
+ "reason_code": entry.get("reason_code", ""),
298
304
  "hostname": hostname,
299
305
  "user": user,
300
306
  "mode": entry.get("mode", "hosted"),