controlzero 1.4.7__tar.gz → 1.5.1__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.4.7 → controlzero-1.5.1}/CHANGELOG.md +66 -9
  2. {controlzero-1.4.7 → controlzero-1.5.1}/PKG-INFO +12 -7
  3. {controlzero-1.4.7 → controlzero-1.5.1}/README.md +11 -6
  4. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/__init__.py +1 -1
  5. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/_internal/enforcer.py +8 -0
  6. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/audit_remote.py +6 -0
  7. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/cli/main.py +103 -87
  8. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/client.py +191 -56
  9. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/hosted_policy.py +84 -1
  10. {controlzero-1.4.7 → controlzero-1.5.1}/pyproject.toml +1 -1
  11. controlzero-1.5.1/tests/conftest.py +46 -0
  12. controlzero-1.5.1/tests/test_api_key_mask.py +59 -0
  13. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_cli_carve_out.py +19 -4
  14. controlzero-1.5.1/tests/test_cli_hosted_refresh.py +103 -0
  15. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_coding_agent_hooks.py +15 -9
  16. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_hybrid_mode_strict.py +6 -2
  17. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_hybrid_mode_warn.py +3 -3
  18. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_reason_code.py +10 -3
  19. controlzero-1.5.1/tests/test_t103_precedence.py +167 -0
  20. controlzero-1.5.1/tests/test_t104_cache_gc.py +174 -0
  21. controlzero-1.5.1/tests/test_t108_local_override_audit.py +163 -0
  22. controlzero-1.4.7/tests/conftest.py +0 -30
  23. controlzero-1.4.7/tests/test_cli_hosted_refresh.py +0 -151
  24. {controlzero-1.4.7 → controlzero-1.5.1}/.gitignore +0 -0
  25. {controlzero-1.4.7 → controlzero-1.5.1}/Dockerfile.test +0 -0
  26. {controlzero-1.4.7 → controlzero-1.5.1}/LICENSE +0 -0
  27. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/_internal/__init__.py +0 -0
  28. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/_internal/action_aliases.py +0 -0
  29. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/_internal/bundle.py +0 -0
  30. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/_internal/dlp_scanner.py +0 -0
  31. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/_internal/hook_extractors.py +0 -0
  32. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/_internal/tool_extractors.json +0 -0
  33. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/_internal/types.py +0 -0
  34. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/audit_local.py +0 -0
  35. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/cli/__init__.py +0 -0
  36. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/cli/debug_bundle.py +0 -0
  37. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/cli/templates/autogen.yaml +0 -0
  38. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/cli/templates/claude-code.yaml +0 -0
  39. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/cli/templates/codex-cli.yaml +0 -0
  40. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/cli/templates/cost-cap.yaml +0 -0
  41. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/cli/templates/crewai.yaml +0 -0
  42. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/cli/templates/cursor.yaml +0 -0
  43. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/cli/templates/gemini-cli.yaml +0 -0
  44. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/cli/templates/generic.yaml +0 -0
  45. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/cli/templates/langchain.yaml +0 -0
  46. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/cli/templates/mcp.yaml +0 -0
  47. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/cli/templates/rag.yaml +0 -0
  48. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/device.py +0 -0
  49. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/enrollment.py +0 -0
  50. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/errors.py +0 -0
  51. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/__init__.py +0 -0
  52. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/anthropic.py +0 -0
  53. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/autogen.py +0 -0
  54. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/braintrust.py +0 -0
  55. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/crewai/__init__.py +0 -0
  56. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/crewai/agent.py +0 -0
  57. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/crewai/crew.py +0 -0
  58. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/crewai/task.py +0 -0
  59. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/crewai/tool.py +0 -0
  60. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/google.py +0 -0
  61. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/google_adk/__init__.py +0 -0
  62. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/google_adk/agent.py +0 -0
  63. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/google_adk/tool.py +0 -0
  64. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/langchain/__init__.py +0 -0
  65. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/langchain/agent.py +0 -0
  66. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/langchain/callbacks.py +0 -0
  67. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/langchain/chain.py +0 -0
  68. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/langchain/graph.py +0 -0
  69. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/langchain/modern.py +0 -0
  70. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/langchain/tool.py +0 -0
  71. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/langfuse.py +0 -0
  72. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/litellm.py +0 -0
  73. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/openai.py +0 -0
  74. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/pydantic_ai.py +0 -0
  75. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/vercel_ai.py +0 -0
  76. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/policy_loader.py +0 -0
  77. {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/tamper.py +0 -0
  78. {controlzero-1.4.7 → controlzero-1.5.1}/examples/hello_world.py +0 -0
  79. {controlzero-1.4.7 → controlzero-1.5.1}/tests/integrations/__init__.py +0 -0
  80. {controlzero-1.4.7 → controlzero-1.5.1}/tests/integrations/test_google.py +0 -0
  81. {controlzero-1.4.7 → controlzero-1.5.1}/tests/parity/action_aliases.json +0 -0
  82. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_action_aliases.py +0 -0
  83. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_action_canonicalization.py +0 -0
  84. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_agent_name_env.py +0 -0
  85. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_audit_remote.py +0 -0
  86. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_audit_sink_isolation.py +0 -0
  87. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_bundle_parser.py +0 -0
  88. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_bundle_translate.py +0 -0
  89. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_cli_debug_bundle.py +0 -0
  90. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_cli_extractor_integration.py +0 -0
  91. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_cli_hook.py +0 -0
  92. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_cli_init.py +0 -0
  93. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_cli_init_templates.py +0 -0
  94. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_cli_tail.py +0 -0
  95. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_cli_test.py +0 -0
  96. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_cli_validate.py +0 -0
  97. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_conditions.py +0 -0
  98. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_default_action.py +0 -0
  99. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_device.py +0 -0
  100. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_dlp_scanner.py +0 -0
  101. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_enrollment.py +0 -0
  102. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_fail_closed_eval.py +0 -0
  103. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_glob_matching.py +0 -0
  104. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_hook_extractors.py +0 -0
  105. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_hosted_policy_e2e.py +0 -0
  106. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_install_hooks.py +0 -0
  107. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_local_mode_dict.py +0 -0
  108. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_local_mode_file_json.py +0 -0
  109. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_local_mode_file_yaml.py +0 -0
  110. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_log_fallback_stderr.py +0 -0
  111. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_log_options_ignored_hosted.py +0 -0
  112. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_log_rotation.py +0 -0
  113. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_no_policy_no_key.py +0 -0
  114. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_package_rename_shim.py +0 -0
  115. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_policy_freshness.py +0 -0
  116. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_policy_settings.py +0 -0
  117. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_quarantine.py +0 -0
  118. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_refresh.py +0 -0
  119. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_sql_semantic_class.py +0 -0
  120. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_synthetic_policy_id_t79.py +0 -0
  121. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_tamper.py +0 -0
  122. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_tamper_behavior.py +0 -0
  123. {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_tamper_hook.py +0 -0
@@ -1,8 +1,66 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.1 -- 2026-05-12 (SECURITY)
4
+
5
+ ### Fixed
6
+
7
+ - **API key leak in the active-source stderr notification.** The T103
8
+ startup line `controlzero: active policy source = hosted (...)`
9
+ printed the first 14 characters of `CONTROLZERO_API_KEY`, which for
10
+ a `cz_live_...` or `cz_test_...` key meant 6 characters of the
11
+ customer secret reached stderr (visible in terminals, screen shares,
12
+ support transcripts, and CI logs). The hint is now masked to
13
+ `cz_live_***` or `cz_test_***` so the mode is still observable but
14
+ no secret bytes are exposed. Upgrade ASAP if you ran 1.5.0 in any
15
+ environment where stderr is observable. 1.5.0 is yanked on PyPI.
16
+
17
+ ## 1.5.0 -- 2026-05-12
18
+
19
+ ### Changed (governance posture; opt-out path documented)
20
+
21
+ - **Hosted policy wins by default when `CONTROLZERO_API_KEY` is set.**
22
+ Before 1.5: a stale local `policy.yaml` silently shadowed the
23
+ dashboard policy. A paying customer ran for 25 days without
24
+ enforcement because of this. After 1.5: an api_key means hosted is
25
+ authoritative; a local file is consulted only when no api_key, or
26
+ when `CONTROLZERO_LOCAL_OVERRIDE=1` is set explicitly as a
27
+ debug/offline escape hatch. An explicit `policy=` / `policy_file=`
28
+ arg passed to `Client(...)` still wins (caller is intentional) but
29
+ emits a loud stderr warning. `strict_hosted=True` upgrades the
30
+ warning to a `HybridModeError`. The active policy source is named
31
+ in a single stderr line at Client init so customer support can
32
+ debug in one glance; `CONTROLZERO_QUIET=1` silences it in CLI
33
+ subprocess contexts.
34
+
35
+ ### Added
36
+
37
+ - **Governance audit event when LOCAL_OVERRIDE is used.** Every Client
38
+ init that bypasses the hosted bundle via
39
+ `CONTROLZERO_LOCAL_OVERRIDE=1` emits a one-shot audit event with
40
+ `reason_code=LOCAL_OVERRIDE_ACTIVE` to the remote audit sink. Ops
41
+ can filter / alert on this code in the audit dashboard so a silent
42
+ bypass is no longer possible.
43
+ - **Cache GC on api_key rotation.** On every fresh bootstrap fetch
44
+ the SDK removes `cache/bootstrap-<scope>.json` +
45
+ `cache/bundle-<scope>.{bin,meta}` files whose scope does NOT match
46
+ the active api_key. Stray user files in the cache dir are
47
+ preserved.
48
+ - **`policy.json` accepted alongside `policy.yaml`.** The cwd
49
+ auto-detect now probes `controlzero.{yaml,yml,json}` in order.
50
+ Schema is identical to YAML; default write is still YAML.
51
+ - New reason_code: `LOCAL_OVERRIDE_ACTIVE`. Extends the SDK
52
+ reason-code enum from 8 to 9. Used only on lifecycle events; no
53
+ impact on guard decisions.
54
+
55
+ ### Refs
56
+
57
+ GH #424 (umbrella), PRs #425 (precedence), #428 (cache GC), #427
58
+ (dashboard dedupe), #429 (governance audit event).
59
+
3
60
  ## 1.4.7 -- 2026-05-11
4
61
 
5
62
  ### Added
63
+
6
64
  - **`controlzero debug bundle` -- inspect SDK-loaded rules + simulate guards**
7
65
  (T87, GH #392). The bundle on disk is encrypted+signed; `cat` returns
8
66
  nothing useful, so until now diagnosing a deny-deny incident meant
@@ -45,14 +103,13 @@
45
103
  The fix is a bidirectional alias shim. The enforcer now expands
46
104
  candidate actions through a single-source-of-truth alias table
47
105
  (`controlzero/_internal/action_aliases.py`) so:
48
-
49
- - A pre-#350 rule with `actions: ["database:query"]` keeps matching
50
- modern SELECT calls (which the SDK emits as `database:read`).
51
- - A modern rule with `actions: ["database:read"]` keeps matching
52
- legacy guard calls that pass `method="SELECT"`.
53
- - The legacy ambiguous `database:delete` (used historically for
54
- both row DELETE and table DROP) maps to BOTH `database:write`
55
- and `database:admin`, so neither modern intent silently breaks.
106
+ - A pre-#350 rule with `actions: ["database:query"]` keeps matching
107
+ modern SELECT calls (which the SDK emits as `database:read`).
108
+ - A modern rule with `actions: ["database:read"]` keeps matching
109
+ legacy guard calls that pass `method="SELECT"`.
110
+ - The legacy ambiguous `database:delete` (used historically for
111
+ both row DELETE and table DROP) maps to BOTH `database:write`
112
+ and `database:admin`, so neither modern intent silently breaks.
56
113
 
57
114
  The alias table is byte-identical across Python, Node, and Go SDKs
58
115
  and is locked by the cross-SDK fixture at
@@ -71,7 +128,6 @@
71
128
  these all carried `policy_id=None` and rendered as a blank Policy
72
129
  column on the audit dashboard, making four very different bug
73
130
  classes look identical. The new sentinels are:
74
-
75
131
  - `synthetic:NO_RULE_MATCH` -- bundle loaded, no rule's actions
76
132
  matched the call.
77
133
  - `synthetic:NO_ACTIVE_POLICIES` -- bundle was structurally empty.
@@ -89,6 +145,7 @@
89
145
  Constants exported from `controlzero._internal.enforcer` as
90
146
  `SYNTHETIC_POLICY_ID_PREFIX`, `SYNTHETIC_NO_RULE_MATCH`, etc., plus
91
147
  `VALID_SYNTHETIC_POLICY_IDS` for runtime validation.
148
+
92
149
  ## 1.4.6 -- 2026-05-11
93
150
 
94
151
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: controlzero
3
- Version: 1.4.7
3
+ Version: 1.5.1
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.7"
31
+ __version__ = "1.5.1"
32
32
 
33
33
  __all__ = [
34
34
  "Client",
@@ -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,7 @@ 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,
72
80
  })
73
81
 
74
82
  # Synthetic policy_id sentinels (T79 / Bryan deny-deny postmortem,
@@ -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"),
@@ -547,21 +547,65 @@ def hook_check(policy: Optional[str]):
547
547
  }))
548
548
  sys.exit(2)
549
549
 
550
- # Resolve policy file
551
- policy_path = _resolve_hook_policy(policy)
552
-
553
- # Freshness check: if this machine is enrolled and the policy file is
554
- # stale, attempt a quick background sync from the backend. This is a
555
- # no-op for local-only (unenrolled) machines.
556
- if policy_path is not None:
557
- _maybe_refresh_policy(policy_path)
558
- elif policy is None:
559
- # Policy not found yet -- maybe a sync will create it.
560
- _maybe_refresh_policy(GLOBAL_POLICY_PATH)
561
- # Re-resolve after potential sync.
550
+ # Hoist api_key resolution above the precedence decision so a key
551
+ # stored only in ~/.controlzero/config.yaml correctly enables hosted
552
+ # mode. Pre-T103 this happened AFTER the resolver, so a user with the
553
+ # key in config.yaml but not in env fell through to the local-file
554
+ # path on every hook-check.
555
+ if not os.environ.get("CONTROLZERO_API_KEY"):
556
+ _config_path = GLOBAL_POLICY_DIR / "config.yaml"
557
+ if _config_path.exists():
558
+ try:
559
+ _config = yaml.safe_load(_config_path.read_text(encoding="utf-8"))
560
+ if _config and _config.get("api_key"):
561
+ os.environ["CONTROLZERO_API_KEY"] = _config["api_key"]
562
+ except Exception:
563
+ pass
564
+
565
+ # T103 review (B1, B2): the CLI hook-check is a non-interactive
566
+ # subprocess that Claude Code / Gemini CLI / Codex CLI spawn fresh
567
+ # for every PreToolUse hook. Module-level "warn once per process"
568
+ # guards do nothing here: every tool call is a new process.
569
+ # Without this env var, every Bash / Edit / Read in an agent
570
+ # session would emit an active-source notification to stderr.
571
+ # Suppress for the hook subprocess context only; library callers
572
+ # still see it once per process. CLI runs may opt in by setting
573
+ # CONTROLZERO_VERBOSE_HOOK=1.
574
+ if os.environ.get("CONTROLZERO_VERBOSE_HOOK", "").lower() not in ("1", "true", "yes", "on"):
575
+ os.environ.setdefault("CONTROLZERO_QUIET", "1")
576
+
577
+ # T103 precedence (2026-05-12):
578
+ # 1. --policy <path> wins unconditionally.
579
+ # 2. api_key set => hosted mode. Client(api_key=...) loads the cached
580
+ # bundle via hosted_policy and conditional-refreshes it on every
581
+ # construction (content-hash via If-None-Match, not mtime). This
582
+ # is the path John Na should have hit on every hook-check.
583
+ # 3. CONTROLZERO_LOCAL_OVERRIDE=1 with api_key => fall back to file.
584
+ # 4. No api_key, no enrollment => local file path.
585
+ #
586
+ # Before T103 the resolver fell back to ~/.controlzero/policy.yaml
587
+ # whenever it existed, even with a valid api_key. John Na's manually
588
+ # edited policy.yaml shadowed the dashboard policy with no warning.
589
+ _local_override = os.environ.get(
590
+ "CONTROLZERO_LOCAL_OVERRIDE", ""
591
+ ).lower() in ("1", "true", "yes", "on")
592
+ _hosted_active = bool(os.environ.get("CONTROLZERO_API_KEY")) and not _local_override
593
+
594
+ if policy is not None:
595
+ # Explicit --policy wins.
562
596
  policy_path = _resolve_hook_policy(policy)
597
+ elif _hosted_active:
598
+ # Hosted mode. No file resolution; the Client constructor will
599
+ # read the cache + conditional-refresh. policy_path stays None so
600
+ # we route through the hosted branch below.
601
+ policy_path = None
602
+ else:
603
+ # Local mode: enrollment-based pull (if enrolled) or static file.
604
+ policy_path = _resolve_hook_policy(policy)
605
+ if policy_path is not None:
606
+ _maybe_refresh_policy(policy_path)
563
607
 
564
- if policy_path is None:
608
+ if policy_path is None and not _hosted_active:
565
609
  # No policy installed AND the carve-out already passed (user
566
610
  # is enrolled or has an API key). This is the BUNDLE_MISSING
567
611
  # path -- backend bundle never synced, sync failed, or the
@@ -597,20 +641,15 @@ def hook_check(policy: Optional[str]):
597
641
  }))
598
642
  sys.exit(2)
599
643
 
600
- # --- Load API key from config.yaml if not in environment ---
601
- if not os.environ.get("CONTROLZERO_API_KEY"):
602
- config_path = GLOBAL_POLICY_DIR / "config.yaml"
603
- if config_path.exists():
604
- try:
605
- config = yaml.safe_load(config_path.read_text(encoding="utf-8"))
606
- if config and config.get("api_key"):
607
- os.environ["CONTROLZERO_API_KEY"] = config["api_key"]
608
- except Exception:
609
- pass
644
+ # api_key resolution moved above the precedence decision (see T103
645
+ # block). Do not re-load here.
610
646
 
611
- # --- Tamper detection: policy file HMAC check ---
612
- tamper_detected = False
613
- tamper_detected = _check_policy_tamper(policy_path)
647
+ # --- Tamper detection ---
648
+ # Policy-file HMAC check applies ONLY to file-backed policies. In
649
+ # hosted mode (policy_path is None) the bundle is cryptographically
650
+ # signed and verified inside hosted_policy.load_hosted_policy at
651
+ # Client init time; a separate HMAC over a local file is meaningless.
652
+ tamper_detected = _check_policy_tamper(policy_path) if policy_path is not None else False
614
653
 
615
654
  # --- Tamper detection: audit log hash chain check ---
616
655
  audit_chain_broken = _check_audit_chain()
@@ -642,14 +681,30 @@ def hook_check(policy: Optional[str]):
642
681
  sys.exit(2)
643
682
 
644
683
  try:
645
- cz = Client(
646
- policy_file=str(policy_path),
647
- log_path=str(GLOBAL_AUDIT_PATH),
648
- )
684
+ if _hosted_active and policy_path is None:
685
+ # T103: hosted mode. Client() reads the cache and conditional-
686
+ # refreshes it on construction. No file-path argument.
687
+ cz = Client(log_path=str(GLOBAL_AUDIT_PATH))
688
+ else:
689
+ cz = Client(
690
+ policy_file=str(policy_path),
691
+ log_path=str(GLOBAL_AUDIT_PATH),
692
+ )
649
693
  except (PolicyLoadError, PolicyValidationError, PermissionError, OSError) as e:
650
694
  # Bad/unreadable policy file: log + allow (do not silently break the agent)
651
695
  click.echo(f"controlzero: policy file invalid ({e}); allowing", err=True)
652
696
  sys.exit(0)
697
+ except Exception as e: # noqa: BLE001
698
+ # Hosted-pull failure (HostedAuthError, HostedBootstrapError, etc).
699
+ # Fail-closed with a clear reason; do NOT silently allow because
700
+ # that's what John Na is suffering from today.
701
+ click.echo(f"controlzero: hosted policy load failed ({e})", err=True)
702
+ click.echo(json.dumps({
703
+ "decision": "block",
704
+ "reason": f"[Control Zero] Hosted policy load failed: {e}",
705
+ "reason_code": REASON_CODE_BUNDLE_MISSING,
706
+ }))
707
+ sys.exit(2)
653
708
 
654
709
  decision = cz.guard(
655
710
  canonical_tool,
@@ -1095,65 +1150,33 @@ def _do_pull_and_write(state: object, policy_path: Path, state_dir: Path, result
1095
1150
  result["error"] = str(exc)
1096
1151
 
1097
1152
 
1098
- def _do_hosted_pull_and_write(
1099
- api_key: str, policy_path: Path, result: dict
1100
- ) -> None:
1101
- """Worker: pull + verify + decrypt the signed bundle using the Bearer
1102
- API key path (no enrollment) and write the translated policy to disk.
1103
-
1104
- Mirrors _do_pull_and_write but uses controlzero.hosted_policy instead
1105
- of the enrollment module. Enables CLI hook-check policy refresh for
1106
- users who configured the SDK with CONTROLZERO_API_KEY but never ran
1107
- `controlzero enroll`.
1108
- """
1109
- try:
1110
- from controlzero.hosted_policy import load_hosted_policy
1111
- bundle, _parsed = load_hosted_policy(api_key)
1112
- yaml_content = yaml.safe_dump(
1113
- {
1114
- "version": str(bundle.get("version", "1")),
1115
- "rules": bundle.get("rules", []),
1116
- },
1117
- default_flow_style=False,
1118
- allow_unicode=True,
1119
- )
1120
- tmp = policy_path.with_suffix(".yaml.tmp")
1121
- tmp.write_text(yaml_content, encoding="utf-8")
1122
- tmp.replace(policy_path)
1123
- result["refreshed"] = True
1124
- result["error"] = None
1125
- except Exception as exc: # noqa: BLE001
1126
- result["refreshed"] = False
1127
- result["error"] = str(exc)
1128
-
1129
-
1130
1153
  def _maybe_refresh_policy(
1131
1154
  policy_path: Path,
1132
1155
  state_dir: Path = GLOBAL_POLICY_DIR,
1133
1156
  threshold_s: float = POLICY_STALENESS_THRESHOLD_S,
1134
1157
  timeout_s: float = POLICY_SYNC_TIMEOUT_S,
1135
1158
  ) -> None:
1136
- """If the policy is stale, attempt a sync in the background.
1159
+ """If an ENROLLED machine's policy file is stale, sync in background.
1160
+
1161
+ T103 (2026-05-12): the Bearer api_key refresh branch was removed
1162
+ here. api_key clients now go through ``Client(api_key=...)`` which
1163
+ reads the signed bundle cache and conditional-refreshes via
1164
+ ``hosted_policy.load_hosted_policy`` (If-None-Match etag). The CLI
1165
+ no longer clobbers ``~/.controlzero/policy.yaml`` from a hosted pull,
1166
+ because that destroyed user-edited content (John Na 2026-05-12).
1137
1167
 
1138
- Two refresh paths, tried in priority order:
1139
- 1. Enrollment (signed per-machine requests) -- used when enrollment.json exists.
1140
- 2. Bearer API key (hosted mode) -- used when CONTROLZERO_API_KEY is set
1141
- but the machine is not enrolled. Relies on the signed .czpolicy
1142
- bundle flow added in SDK 1.4.
1168
+ This function now serves only the enrollment-based path used by
1169
+ machines that ran ``controlzero login`` and persisted
1170
+ enrollment.json. If no enrollment state, returns immediately.
1143
1171
 
1144
1172
  Uses a background thread with a join timeout so the hook never stalls
1145
- for longer than *timeout_s* seconds. On timeout or network failure,
1146
- falls back to the cached policy and logs a warning to stderr.
1173
+ for longer than *timeout_s* seconds.
1147
1174
  """
1148
1175
  if not _is_policy_stale(policy_path, threshold_s):
1149
1176
  return
1150
1177
 
1151
1178
  state = _load_enrollment_state(state_dir)
1152
- api_key = os.environ.get("CONTROLZERO_API_KEY", "")
1153
- use_hosted = state is None and bool(api_key)
1154
-
1155
- if state is None and not use_hosted:
1156
- # No enrollment and no API key -- local-only mode, nothing to sync.
1179
+ if state is None:
1157
1180
  return
1158
1181
 
1159
1182
  # Compute staleness for the warning message.
@@ -1163,18 +1186,11 @@ def _maybe_refresh_policy(
1163
1186
  age_min = -1
1164
1187
 
1165
1188
  result: dict = {"refreshed": False, "error": None}
1166
- if use_hosted:
1167
- worker = threading.Thread(
1168
- target=_do_hosted_pull_and_write,
1169
- args=(api_key, policy_path, result),
1170
- daemon=True,
1171
- )
1172
- else:
1173
- worker = threading.Thread(
1174
- target=_do_pull_and_write,
1175
- args=(state, policy_path, state_dir, result),
1176
- daemon=True,
1177
- )
1189
+ worker = threading.Thread(
1190
+ target=_do_pull_and_write,
1191
+ args=(state, policy_path, state_dir, result),
1192
+ daemon=True,
1193
+ )
1178
1194
  worker.start()
1179
1195
  worker.join(timeout=timeout_s)
1180
1196