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.
- {controlzero-1.4.7 → controlzero-1.5.1}/CHANGELOG.md +66 -9
- {controlzero-1.4.7 → controlzero-1.5.1}/PKG-INFO +12 -7
- {controlzero-1.4.7 → controlzero-1.5.1}/README.md +11 -6
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/__init__.py +1 -1
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/_internal/enforcer.py +8 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/audit_remote.py +6 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/cli/main.py +103 -87
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/client.py +191 -56
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/hosted_policy.py +84 -1
- {controlzero-1.4.7 → controlzero-1.5.1}/pyproject.toml +1 -1
- controlzero-1.5.1/tests/conftest.py +46 -0
- controlzero-1.5.1/tests/test_api_key_mask.py +59 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_cli_carve_out.py +19 -4
- controlzero-1.5.1/tests/test_cli_hosted_refresh.py +103 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_coding_agent_hooks.py +15 -9
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_hybrid_mode_strict.py +6 -2
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_hybrid_mode_warn.py +3 -3
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_reason_code.py +10 -3
- controlzero-1.5.1/tests/test_t103_precedence.py +167 -0
- controlzero-1.5.1/tests/test_t104_cache_gc.py +174 -0
- controlzero-1.5.1/tests/test_t108_local_override_audit.py +163 -0
- controlzero-1.4.7/tests/conftest.py +0 -30
- controlzero-1.4.7/tests/test_cli_hosted_refresh.py +0 -151
- {controlzero-1.4.7 → controlzero-1.5.1}/.gitignore +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/Dockerfile.test +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/LICENSE +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/_internal/__init__.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/_internal/action_aliases.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/_internal/bundle.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/_internal/dlp_scanner.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/_internal/hook_extractors.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/_internal/tool_extractors.json +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/_internal/types.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/audit_local.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/cli/__init__.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/cli/debug_bundle.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/cli/templates/autogen.yaml +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/cli/templates/claude-code.yaml +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/cli/templates/codex-cli.yaml +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/cli/templates/cost-cap.yaml +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/cli/templates/crewai.yaml +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/cli/templates/cursor.yaml +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/cli/templates/gemini-cli.yaml +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/cli/templates/generic.yaml +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/cli/templates/langchain.yaml +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/cli/templates/mcp.yaml +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/cli/templates/rag.yaml +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/device.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/enrollment.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/errors.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/__init__.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/anthropic.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/autogen.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/braintrust.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/crewai/__init__.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/crewai/agent.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/crewai/crew.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/crewai/task.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/crewai/tool.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/google.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/google_adk/__init__.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/google_adk/agent.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/google_adk/tool.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/langchain/__init__.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/langchain/agent.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/langchain/callbacks.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/langchain/chain.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/langchain/graph.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/langchain/modern.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/langchain/tool.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/langfuse.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/litellm.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/openai.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/pydantic_ai.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/integrations/vercel_ai.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/policy_loader.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/controlzero/tamper.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/examples/hello_world.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/integrations/__init__.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/integrations/test_google.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/parity/action_aliases.json +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_action_aliases.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_action_canonicalization.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_agent_name_env.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_audit_remote.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_audit_sink_isolation.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_bundle_parser.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_bundle_translate.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_cli_debug_bundle.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_cli_extractor_integration.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_cli_hook.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_cli_init.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_cli_init_templates.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_cli_tail.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_cli_test.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_cli_validate.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_conditions.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_default_action.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_device.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_dlp_scanner.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_enrollment.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_fail_closed_eval.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_glob_matching.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_hook_extractors.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_hosted_policy_e2e.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_install_hooks.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_local_mode_dict.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_local_mode_file_json.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_local_mode_file_yaml.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_log_fallback_stderr.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_log_options_ignored_hosted.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_log_rotation.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_no_policy_no_key.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_package_rename_shim.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_policy_freshness.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_policy_settings.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_quarantine.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_refresh.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_sql_semantic_class.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_synthetic_policy_id_t79.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_tamper.py +0 -0
- {controlzero-1.4.7 → controlzero-1.5.1}/tests/test_tamper_behavior.py +0 -0
- {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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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.
|
|
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
|
-
|
|
243
|
-
|
|
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:
|
|
251
|
+
WARNING: controlzero: explicit local policy overrides the hosted bundle. ...
|
|
247
252
|
```
|
|
248
253
|
|
|
249
|
-
This
|
|
250
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
191
|
-
|
|
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:
|
|
199
|
+
WARNING: controlzero: explicit local policy overrides the hosted bundle. ...
|
|
195
200
|
```
|
|
196
201
|
|
|
197
|
-
This
|
|
198
|
-
|
|
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
|
-
#
|
|
207
|
+
# HybridModeError: explicit local policy overrides the hosted bundle ...
|
|
203
208
|
```
|
|
204
209
|
|
|
205
210
|
## Coding agent hooks
|
|
@@ -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
|
-
#
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
#
|
|
554
|
-
#
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
#
|
|
601
|
-
|
|
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
|
|
612
|
-
|
|
613
|
-
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
|
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
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
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
|
|