controlzero 1.5.5__tar.gz → 1.5.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {controlzero-1.5.5 → controlzero-1.5.6}/CHANGELOG.md +34 -5
- {controlzero-1.5.5 → controlzero-1.5.6}/PKG-INFO +1 -1
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/__init__.py +1 -1
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/_internal/bundle.py +1 -1
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/_internal/enforcer.py +3 -3
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/cli/_secrets.py +5 -5
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/cli/debug_bundle.py +1 -1
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/cli/hosts/claude_code.py +1 -1
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/cli/hosts/gemini_cli.py +1 -1
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/cli/main.py +7 -7
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/client.py +1 -1
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/device.py +2 -2
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/enrollment.py +1 -1
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/hosted_policy.py +3 -3
- {controlzero-1.5.5 → controlzero-1.5.6}/pyproject.toml +1 -1
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_api_key_mask.py +1 -1
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_bundle_translate.py +1 -1
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_cli_extractor_integration.py +3 -3
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_cli_hosted_refresh.py +1 -1
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_device.py +1 -1
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_env_dump_438.py +1 -1
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_glob_matching.py +3 -3
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_hosts_adapter.py +2 -2
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_install_hook_command.py +1 -1
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_install_hooks.py +1 -1
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_reason_code.py +3 -3
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_refresh.py +1 -1
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_secrets.py +7 -7
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_sql_semantic_class.py +1 -1
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_synthetic_policy_id_t79.py +1 -1
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_t103_precedence.py +2 -2
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_t104_cache_gc.py +1 -1
- {controlzero-1.5.5 → controlzero-1.5.6}/.gitignore +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/Dockerfile.test +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/LICENSE +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/README.md +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/_internal/__init__.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/_internal/action_aliases.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/_internal/dlp_scanner.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/_internal/hook_extractors.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/_internal/tool_extractors.json +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/_internal/types.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/audit_local.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/audit_remote.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/cli/__init__.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/cli/console.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/cli/doctor.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/cli/hosts/__init__.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/cli/hosts/base.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/cli/hosts/codex_cli.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/cli/hosts/unknown.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/cli/migrate.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/cli/telemetry_consent.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/cli/templates/autogen.yaml +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/cli/templates/claude-code.yaml +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/cli/templates/codex-cli.yaml +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/cli/templates/cost-cap.yaml +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/cli/templates/crewai.yaml +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/cli/templates/cursor.yaml +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/cli/templates/gemini-cli.yaml +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/cli/templates/generic.yaml +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/cli/templates/langchain.yaml +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/cli/templates/mcp.yaml +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/cli/templates/rag.yaml +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/error_codes.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/errors.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/integrations/__init__.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/integrations/anthropic.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/integrations/autogen.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/integrations/braintrust.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/integrations/crewai/__init__.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/integrations/crewai/agent.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/integrations/crewai/crew.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/integrations/crewai/task.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/integrations/crewai/tool.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/integrations/google.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/integrations/google_adk/__init__.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/integrations/google_adk/agent.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/integrations/google_adk/tool.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/integrations/langchain/__init__.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/integrations/langchain/agent.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/integrations/langchain/callbacks.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/integrations/langchain/chain.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/integrations/langchain/graph.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/integrations/langchain/modern.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/integrations/langchain/tool.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/integrations/langfuse.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/integrations/litellm.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/integrations/openai.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/integrations/pydantic_ai.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/integrations/vercel_ai.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/layout_migration.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/policy_loader.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/controlzero/tamper.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/examples/hello_world.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/conftest.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/integrations/__init__.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/integrations/test_google.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/parity/action_aliases.json +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_action_aliases.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_action_canonicalization.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_agent_name_env.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_audit_remote.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_audit_remote_sdk_version.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_audit_sink_isolation.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_bundle_parser.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_cli_carve_out.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_cli_debug_bundle.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_cli_hook.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_cli_init.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_cli_init_templates.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_cli_tail.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_cli_test.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_cli_validate.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_coding_agent_hooks.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_conditions.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_console.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_default_action.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_dlp_scanner.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_doctor.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_enrollment.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_error_codes.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_errors_e_codes.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_fail_closed_eval.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_hook_extractors.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_hosted_policy_e2e.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_hybrid_mode_strict.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_hybrid_mode_warn.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_layout_migration_t101.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_layout_parity_t102.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_local_mode_dict.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_local_mode_file_json.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_local_mode_file_yaml.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_log_fallback_stderr.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_log_options_ignored_hosted.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_log_rotation.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_migrate.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_no_policy_no_key.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_package_rename_shim.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_policy_freshness.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_policy_settings.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_quarantine.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_t108_local_override_audit.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_t96_single_audit_log.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_t99_install_prefetch_bundle.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_tamper.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_tamper_behavior.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_tamper_hook.py +0 -0
- {controlzero-1.5.5 → controlzero-1.5.6}/tests/test_telemetry_consent.py +0 -0
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v1.5.6 -- 2026-05-15 (PRIVACY)
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **Customer names and identifiable references removed from the
|
|
8
|
+
published wheel.** Earlier 1.5.x releases (including 1.5.5)
|
|
9
|
+
carried customer names and individual contributor names in inline
|
|
10
|
+
comments and docstrings as historical context for past incidents.
|
|
11
|
+
This release replaces every such reference with a generic
|
|
12
|
+
technical description (a customer / an enterprise customer / a
|
|
13
|
+
date-stamped incident label) while preserving the technical
|
|
14
|
+
meaning of the comment. No behavior change. Concretely affects
|
|
15
|
+
inline comments in `client.py`, `device.py`, `hosted_policy.py`,
|
|
16
|
+
`enrollment.py`, `cli/main.py`, `cli/_secrets.py`, `cli/debug_bundle.py`,
|
|
17
|
+
`cli/hosts/claude_code.py`, `cli/hosts/gemini_cli.py`,
|
|
18
|
+
`_internal/bundle.py`, `_internal/enforcer.py`. 1.5.5 is yanked
|
|
19
|
+
on PyPI.
|
|
20
|
+
- **Realistic-looking placeholder key in source replaced.** The
|
|
21
|
+
64-hex-char `cz_live_*` string used as a docstring example and
|
|
22
|
+
test fixture has been swapped for an obviously-synthetic
|
|
23
|
+
`cz_live_aaaa...` placeholder. The original value was a test
|
|
24
|
+
fixture, not a real customer key, but the format was close
|
|
25
|
+
enough to a real key that an external reader could not tell.
|
|
26
|
+
|
|
27
|
+
## v1.5.5 -- 2026-05-15 (YANKED)
|
|
28
|
+
|
|
29
|
+
YANKED on 2026-05-15 due to customer-name leakage in inline comments.
|
|
30
|
+
Use 1.5.6 instead.
|
|
31
|
+
|
|
3
32
|
## Unreleased -- Tier 0a security hotfix (2026-05-15)
|
|
4
33
|
|
|
5
34
|
### Security (P0 hotfix for #174)
|
|
@@ -58,7 +87,7 @@ T96 + T101 layout changes in real customer environments.
|
|
|
58
87
|
not contents), and resolved API URL. With `--from-hook` it parses
|
|
59
88
|
a stdin payload so the output reflects what the hook subprocess
|
|
60
89
|
actually sees. Built for fast Windows-hook triage after the
|
|
61
|
-
2026-05-12
|
|
90
|
+
2026-05-12 incident -- a customer can now run a single
|
|
62
91
|
command and send us the JSON instead of guessing which env vars
|
|
63
92
|
their hook sees.
|
|
64
93
|
|
|
@@ -110,7 +139,7 @@ T96 + T101 layout changes in real customer environments.
|
|
|
110
139
|
Anthropic's PreToolUse hook schema only accepts `"approve" | "block"`
|
|
111
140
|
-- the `"allow"` string crashed Claude Code's validator with
|
|
112
141
|
`Hook JSON output validation failed - (root): Invalid input` and
|
|
113
|
-
Claude Code dropped the hook decision (defaults to proceed).
|
|
142
|
+
Claude Code dropped the hook decision (defaults to proceed). A customer
|
|
114
143
|
saw a `Write` correctly blocked, then a `PowerShell` command bypass
|
|
115
144
|
to the same intent. After 1.5.3 the Claude Code adapter renders
|
|
116
145
|
allow as `"approve"` and deny as `"block"`; Gemini CLI / Codex CLI
|
|
@@ -159,7 +188,7 @@ T96 + T101 layout changes in real customer environments.
|
|
|
159
188
|
|
|
160
189
|
### Customer impact
|
|
161
190
|
|
|
162
|
-
|
|
191
|
+
an enterprise customer admin reported on 2026-05-12 that Claude Code on
|
|
163
192
|
Windows was not sending any audit logs to the dashboard, while the
|
|
164
193
|
direct Python SDK script was sending logs without a populated
|
|
165
194
|
SOURCE column. Both symptoms collapse to the two fixes above.
|
|
@@ -229,7 +258,7 @@ GH #424 (umbrella), PRs #425 (precedence), #428 (cache GC), #427
|
|
|
229
258
|
(T87, GH #392). The bundle on disk is encrypted+signed; `cat` returns
|
|
230
259
|
nothing useful, so until now diagnosing a deny-deny incident meant
|
|
231
260
|
shipping the bundle back to engineering or attaching a debugger
|
|
232
|
-
(
|
|
261
|
+
(the deny-deny took ~3 hours to root-cause for exactly this
|
|
233
262
|
reason). The new subcommand reads the matching `bootstrap-<prefix>.json`
|
|
234
263
|
for keys, decrypts and verifies the cached bundle blob via the same
|
|
235
264
|
parser the SDK uses, and prints a human-readable summary: bundle id,
|
|
@@ -284,7 +313,7 @@ GH #424 (umbrella), PRs #425 (precedence), #428 (cache GC), #427
|
|
|
284
313
|
work, modern rules continue to work, and only previously-broken
|
|
285
314
|
legacy rules start matching again.
|
|
286
315
|
|
|
287
|
-
- **Synthetic policy_id sentinels** (T79,
|
|
316
|
+
- **Synthetic policy_id sentinels** (T79, the deny-deny postmortem).
|
|
288
317
|
`PolicyDecision.policy_id` is now stamped with one of six canonical
|
|
289
318
|
`synthetic:*` values whenever the deny was emitted by a fail-closed
|
|
290
319
|
code path (no rule matched, empty bundle, missing bundle, T83-class
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: controlzero
|
|
3
|
-
Version: 1.5.
|
|
3
|
+
Version: 1.5.6
|
|
4
4
|
Summary: AI agent governance: policies, audit, and observability for tool calls. Works locally with no signup.
|
|
5
5
|
Project-URL: Homepage, https://controlzero.ai
|
|
6
6
|
Project-URL: Documentation, https://docs.controlzero.ai
|
|
@@ -437,7 +437,7 @@ def translate_to_local_policy(payload: dict) -> dict:
|
|
|
437
437
|
# reason_code so dashboards still bucket it as "nothing
|
|
438
438
|
# attached").
|
|
439
439
|
#
|
|
440
|
-
# Copy-choice note (2026-04-19,
|
|
440
|
+
# Copy-choice note (2026-04-19, the 2026-04-19 P0): the previous
|
|
441
441
|
# message -- "No active policies. Define one in the Control Zero
|
|
442
442
|
# dashboard." -- presumed the user had not defined any policies.
|
|
443
443
|
# That presumption was wrong in the common case: the user had
|
|
@@ -33,7 +33,7 @@ from controlzero._internal.types import PolicyRule
|
|
|
33
33
|
# so integrations + dashboards can branch on decision provenance
|
|
34
34
|
# without regex-matching the human-readable `reason` string.
|
|
35
35
|
#
|
|
36
|
-
# Added 2026-04-19 after
|
|
36
|
+
# Added 2026-04-19 after the 2026-04-19 P0: the SDK fail-closed with "No
|
|
37
37
|
# active policies. Define one in the Control Zero dashboard." when
|
|
38
38
|
# the user had in fact defined three; the three surfaces disagreed
|
|
39
39
|
# because policy_attachments was empty. reason_code lets the hosted
|
|
@@ -78,7 +78,7 @@ VALID_REASON_CODES = frozenset({
|
|
|
78
78
|
REASON_CODE_LOCAL_OVERRIDE_ACTIVE,
|
|
79
79
|
})
|
|
80
80
|
|
|
81
|
-
# Synthetic policy_id sentinels (T79 /
|
|
81
|
+
# Synthetic policy_id sentinels (T79 / the deny-deny postmortem,
|
|
82
82
|
# 2026-05-11). When a deny is emitted by anything OTHER than a
|
|
83
83
|
# user-authored rule, the SDK stamps the audit row's `policy_id` with
|
|
84
84
|
# one of these `synthetic:*` values so the audit dashboard can render
|
|
@@ -317,7 +317,7 @@ class PolicyEvaluator:
|
|
|
317
317
|
# resources:["*"] for unscoped rules, so prior to this
|
|
318
318
|
# fix every cz.guard() call without an explicit resource
|
|
319
319
|
# was skipping every rule and falling through to the
|
|
320
|
-
# default deny (
|
|
320
|
+
# default deny (the deny-deny incident, 2026-05-10).
|
|
321
321
|
# Logic now: if any pattern in rule.resources is the
|
|
322
322
|
# universal "*", treat the gate as satisfied; otherwise
|
|
323
323
|
# require a caller-supplied resource and glob-match it.
|
|
@@ -6,7 +6,7 @@ Single source of truth for:
|
|
|
6
6
|
`controlzero doctor` to scan agent settings files + the cross-SDK
|
|
7
7
|
contract test that fails the CI on any leak surface).
|
|
8
8
|
2. Redacting a full key to a last-4 form for display in CLI output
|
|
9
|
-
(`cz_live_***
|
|
9
|
+
(`cz_live_***aaaaa`). Matches the gh CLI / Stripe CLI convention.
|
|
10
10
|
|
|
11
11
|
These helpers MUST stay pure (no I/O, no logging) so the contract test
|
|
12
12
|
that depends on `find_key_leaks` can run in a sandbox without touching
|
|
@@ -22,7 +22,7 @@ from typing import Iterable, List, NamedTuple
|
|
|
22
22
|
# characters in production. We also accept shorter trailing alphanumerics
|
|
23
23
|
# so legacy short-form test keys (cz_test_localdev_...) are caught.
|
|
24
24
|
# The pattern is deliberately greedy on the suffix so we catch:
|
|
25
|
-
#
|
|
25
|
+
# cz_live_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
|
26
26
|
# cz_test_localdev_000000000000000000000000
|
|
27
27
|
# cz_test_xxxxxxxx (rarer, but valid)
|
|
28
28
|
# but stops at a non-alphanumeric so we don't gobble surrounding markup.
|
|
@@ -72,8 +72,8 @@ def find_key_leaks(text: str) -> List[KeyMatch]:
|
|
|
72
72
|
|
|
73
73
|
|
|
74
74
|
def redact_key(key: str) -> str:
|
|
75
|
-
"""Convert `
|
|
76
|
-
to `cz_live_***
|
|
75
|
+
"""Convert `cz_live_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa`
|
|
76
|
+
to `cz_live_***aaaaa` (last-5 of the secret payload).
|
|
77
77
|
|
|
78
78
|
The prefix (`cz_live_` / `cz_test_`) is preserved so the mode is
|
|
79
79
|
still readable: ops can tell at a glance which key family this
|
|
@@ -81,7 +81,7 @@ def redact_key(key: str) -> str:
|
|
|
81
81
|
distinguishable in support tickets without exposing enough to
|
|
82
82
|
reconstruct the key (5 hex chars = 20 bits = 1M combinations,
|
|
83
83
|
not enough to brute-force back to the full key but enough for a
|
|
84
|
-
human to disambiguate "is this
|
|
84
|
+
human to disambiguate "is this customer A's key or customer B's key").
|
|
85
85
|
|
|
86
86
|
Returns the input verbatim if it doesn't match the expected shape,
|
|
87
87
|
so caller-side `print(redact_key(maybe_key))` is always safe.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""``controlzero debug bundle`` -- inspect SDK-loaded rules + simulate guard.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
the deny-deny incident (T87, GH #392) burned ~3 hours of root-cause
|
|
4
4
|
work because the bundle on disk is encrypted+signed. ``cat`` returns
|
|
5
5
|
nothing legible; operators had to attach a debugger or ship the bundle
|
|
6
6
|
back to engineering. This module gives them a self-serve alternative.
|
|
@@ -26,7 +26,7 @@ Critical: ``"decision": "allow"`` is **NOT** valid -- Claude Code
|
|
|
26
26
|
rejects the hook output with
|
|
27
27
|
``Hook JSON output validation failed - (root): Invalid input`` and
|
|
28
28
|
falls back to its default (proceed). That is the 2026-05-12
|
|
29
|
-
|
|
29
|
+
the Windows PowerShell-bypass root cause.
|
|
30
30
|
"""
|
|
31
31
|
|
|
32
32
|
from __future__ import annotations
|
|
@@ -25,7 +25,7 @@ class GeminiCLIAdapter(HostAdapter):
|
|
|
25
25
|
|
|
26
26
|
# Only true CLI runtime signals. ``GEMINI_API_KEY`` is set by
|
|
27
27
|
# anyone using Google's Python SDK directly and must NOT match
|
|
28
|
-
# (
|
|
28
|
+
# (the deny-deny incident, 2026-05-10 -- T78 / T92).
|
|
29
29
|
_ENV_HINTS = ("GEMINI_CLI", "GEMINI_SANDBOX", "GEMINI_SYSTEM_MD")
|
|
30
30
|
|
|
31
31
|
def claim(self, payload: dict, env: Mapping[str, str]) -> bool:
|
|
@@ -391,7 +391,7 @@ def env_dump_cmd(show_secrets: bool, from_hook: bool):
|
|
|
391
391
|
controlzero env-dump --from-hook < /tmp/claude-payload.json
|
|
392
392
|
controlzero env-dump --show-secrets # un-redacts; DANGEROUS
|
|
393
393
|
|
|
394
|
-
Filed as #438 follow-up to the 2026-05-12
|
|
394
|
+
Filed as #438 follow-up to the 2026-05-12 hook-decision regression
|
|
395
395
|
where source-detection on Windows was unknown without instrumenting
|
|
396
396
|
the customer's hook environment.
|
|
397
397
|
"""
|
|
@@ -674,7 +674,7 @@ def hook_check(policy: Optional[str]):
|
|
|
674
674
|
# Before #228 phase 3 the hook-check CLI passed method="*" for every
|
|
675
675
|
# call because it could not reach into tool_input to pull a method
|
|
676
676
|
# out. That meant rules like `deny database:DROP` never matched via
|
|
677
|
-
# the hook path. Now every
|
|
677
|
+
# the hook path. Now every the 2026-04-19-style PreToolUse payload collapses
|
|
678
678
|
# to the same canonical (tool, method) the Gateway and SDK
|
|
679
679
|
# guard() call sites already produce.
|
|
680
680
|
_tool_args_dict = tool_args if isinstance(tool_args, dict) else {}
|
|
@@ -783,12 +783,12 @@ def hook_check(policy: Optional[str]):
|
|
|
783
783
|
# 2. api_key set => hosted mode. Client(api_key=...) loads the cached
|
|
784
784
|
# bundle via hosted_policy and conditional-refreshes it on every
|
|
785
785
|
# construction (content-hash via If-None-Match, not mtime). This
|
|
786
|
-
# is the path
|
|
786
|
+
# is the path the customer should have hit on every hook-check.
|
|
787
787
|
# 3. CONTROLZERO_LOCAL_OVERRIDE=1 with api_key => fall back to file.
|
|
788
788
|
# 4. No api_key, no enrollment => local file path.
|
|
789
789
|
#
|
|
790
790
|
# Before T103 the resolver fell back to ~/.controlzero/policy.yaml
|
|
791
|
-
# whenever it existed, even with a valid api_key.
|
|
791
|
+
# whenever it existed, even with a valid api_key. a customer's manually
|
|
792
792
|
# edited policy.yaml shadowed the dashboard policy with no warning.
|
|
793
793
|
_local_override = os.environ.get(
|
|
794
794
|
"CONTROLZERO_LOCAL_OVERRIDE", ""
|
|
@@ -905,7 +905,7 @@ def hook_check(policy: Optional[str]):
|
|
|
905
905
|
except Exception as e: # noqa: BLE001
|
|
906
906
|
# Hosted-pull failure (HostedAuthError, HostedBootstrapError, etc).
|
|
907
907
|
# Fail-closed with a clear reason; do NOT silently allow because
|
|
908
|
-
# that's what
|
|
908
|
+
# that's what the customer was hitting at the time.
|
|
909
909
|
click.echo(f"controlzero: hosted policy load failed ({e})", err=True)
|
|
910
910
|
_emit_decision(
|
|
911
911
|
effect="deny",
|
|
@@ -1367,7 +1367,7 @@ def _maybe_refresh_policy(
|
|
|
1367
1367
|
reads the signed bundle cache and conditional-refreshes via
|
|
1368
1368
|
``hosted_policy.load_hosted_policy`` (If-None-Match etag). The CLI
|
|
1369
1369
|
no longer clobbers ``~/.controlzero/policy.yaml`` from a hosted pull,
|
|
1370
|
-
because that destroyed user-edited content (
|
|
1370
|
+
because that destroyed user-edited content (customer report 2026-05-12).
|
|
1371
1371
|
|
|
1372
1372
|
This function now serves only the enrollment-based path used by
|
|
1373
1373
|
machines that ran ``controlzero login`` and persisted
|
|
@@ -1799,7 +1799,7 @@ def install_claude_code(force: bool, merge: bool, settings: Optional[str], api_k
|
|
|
1799
1799
|
# `CONTROLZERO_API_KEY=cz_... controlzero hook-check` as a bash-style
|
|
1800
1800
|
# env-prefix was a Windows-portability bug (PowerShell / cmd.exe cannot
|
|
1801
1801
|
# parse the syntax) AND placed the cleartext key into settings.json on
|
|
1802
|
-
# disk. Both go away here. See 2026-05-12
|
|
1802
|
+
# disk. Both go away here. See 2026-05-12 incident.
|
|
1803
1803
|
hook_command = "controlzero hook-check"
|
|
1804
1804
|
new_block = {
|
|
1805
1805
|
"matcher": "*",
|
|
@@ -21,7 +21,7 @@ Resolution order for finding a policy (T103, 2026-05-12):
|
|
|
21
21
|
5. no api_key => ./controlzero.yaml in cwd
|
|
22
22
|
6. nothing => no-op pass-through with one-time stderr warning
|
|
23
23
|
|
|
24
|
-
Before T103, local always won when both were present.
|
|
24
|
+
Before T103, local always won when both were present. an enterprise customer
|
|
25
25
|
hit this in prod: a stale ~/.controlzero/policy.yaml shadowed the dashboard
|
|
26
26
|
policy and silently disabled enforcement. The reversal makes the api_key
|
|
27
27
|
the explicit "I want hosted" signal and forces an env-var opt-out for the
|
|
@@ -59,7 +59,7 @@ def detect_client_name() -> str:
|
|
|
59
59
|
|
|
60
60
|
Detection uses signals each tool ACTUALLY ships, not generic env-var
|
|
61
61
|
prefixes that users might export for unrelated reasons. T92 rewrite,
|
|
62
|
-
after the
|
|
62
|
+
after the the source-mislabel incident (2026-05-10) where a Python
|
|
63
63
|
SDK user with ``GEMINI_API_KEY`` exported for direct Google API access
|
|
64
64
|
was mislabeled as "Gemini CLI" because the prior implementation
|
|
65
65
|
matched any ``GEMINI_`` prefix.
|
|
@@ -127,7 +127,7 @@ def detect_client_name() -> str:
|
|
|
127
127
|
|
|
128
128
|
# 6. Gemini CLI. Match ONLY env vars the CLI runtime sets, NOT the
|
|
129
129
|
# broad GEMINI_ prefix. GEMINI_API_KEY is exported by anyone using
|
|
130
|
-
# Google's Python SDK directly (
|
|
130
|
+
# Google's Python SDK directly (the customer's case). GEMINI_CLI mirrors
|
|
131
131
|
# the Node SDK's convention; GEMINI_SANDBOX / GEMINI_SYSTEM_MD are
|
|
132
132
|
# set by the gemini-cli runtime itself.
|
|
133
133
|
if (os.environ.get("GEMINI_CLI") or
|
|
@@ -135,7 +135,7 @@ def save_cached_bootstrap(api_key: str, keys: BootstrapKeys) -> None:
|
|
|
135
135
|
def gc_stale_cache(active_api_key: str) -> int:
|
|
136
136
|
"""Remove cache files belonging to keys other than the active one.
|
|
137
137
|
|
|
138
|
-
T104 (2026-05-12,
|
|
138
|
+
T104 (2026-05-12, customer report): on key rotation the old key's cache files
|
|
139
139
|
(``bootstrap-<oldscope>.json``, ``bundle-<oldscope>.bin``,
|
|
140
140
|
``bundle-<oldscope>.meta``) accumulated forever next to the new key's
|
|
141
141
|
files. John's machine carried a 25-day-old ``bundle-cz_live_566b.bin``
|
|
@@ -498,7 +498,7 @@ def _compute_etag(blob: bytes) -> str:
|
|
|
498
498
|
stores and serves the full hex string in its ETag header, so the
|
|
499
499
|
SDK must hash to the same width for `If-None-Match` to ever match.
|
|
500
500
|
Truncating to 32 chars (the prior shape) silently broke the 304
|
|
501
|
-
short-circuit, surfacing as
|
|
502
|
-
docs/investigations/
|
|
501
|
+
short-circuit, surfacing as the investigation finding 1 in
|
|
502
|
+
docs/investigations/the 2026-05-08 deny-deny investigation.
|
|
503
503
|
"""
|
|
504
504
|
return hashlib.sha256(blob).hexdigest()
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "controlzero"
|
|
7
|
-
version = "1.5.
|
|
7
|
+
version = "1.5.6"
|
|
8
8
|
description = "AI agent governance: policies, audit, and observability for tool calls. Works locally with no signup."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "Apache-2.0"}
|
|
@@ -42,7 +42,7 @@ def test_mask_empty_string_returns_triple_star():
|
|
|
42
42
|
|
|
43
43
|
|
|
44
44
|
def test_mask_never_leaks_secret_bytes_brute_force():
|
|
45
|
-
# The secret portion of a real
|
|
45
|
+
# The secret portion of a real production key (anonymised:
|
|
46
46
|
# do not use this value as-is, the customer rotated it after the
|
|
47
47
|
# 1.5.0 -> 1.5.1 incident on 2026-05-12).
|
|
48
48
|
secret_tail = "7ebef6b600015e3eaeda9149bf6d9c29a3a2a7a3075209112afde20888280de0"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Tests for bundle _translate_rule against the backend wire format.
|
|
2
2
|
|
|
3
|
-
Covers Bug #2 from the 2026-04-19
|
|
3
|
+
Covers Bug #2 from the 2026-04-19 investigation: before this
|
|
4
4
|
change, :func:`controlzero._internal.bundle._translate_rule` ignored
|
|
5
5
|
the plural ``actions`` list emitted by the Go backend (see
|
|
6
6
|
``bundle_handler.go`` ``PolicyRule.Actions``). Any rule carrying
|
|
@@ -5,7 +5,7 @@ Codex PreToolUse payloads through the ``controlzero hook-check`` CLI
|
|
|
5
5
|
and assert that the canonical ``(tool, method)`` action the extractor
|
|
6
6
|
produced is the action the policy evaluator matched against.
|
|
7
7
|
|
|
8
|
-
The flagship case is
|
|
8
|
+
The flagship case is the customer scenario: a policy that denies
|
|
9
9
|
``database:DROP`` should not fire on
|
|
10
10
|
``{tool: "database", args: {sql: "SELECT * FROM users"}}`` because the
|
|
11
11
|
extractor resolves the action to ``database:SELECT``. Before this
|
|
@@ -77,11 +77,11 @@ def _invoke(policy_path: Path, payload: dict) -> tuple[int, dict, str]:
|
|
|
77
77
|
|
|
78
78
|
|
|
79
79
|
# ---------------------------------------------------------------------------
|
|
80
|
-
#
|
|
80
|
+
# the customer scenario and its evil twin (#228 phase 3 flagship).
|
|
81
81
|
# ---------------------------------------------------------------------------
|
|
82
82
|
|
|
83
83
|
|
|
84
|
-
def
|
|
84
|
+
def test_customer_scenario_database_select_resolves_to_database_colon_select(
|
|
85
85
|
tmp_path: Path,
|
|
86
86
|
):
|
|
87
87
|
"""SELECT payload emits action=database:SELECT (not database:*)."""
|
|
@@ -10,7 +10,7 @@ machines that ran ``controlzero login`` and persisted enrollment.json).
|
|
|
10
10
|
The old hosted-pull-and-rewrite-file tests were dropped because
|
|
11
11
|
``_do_hosted_pull_and_write`` was deleted: it overwrote user-edited
|
|
12
12
|
``policy.yaml`` content with hosted bundle rules, which is what caused
|
|
13
|
-
|
|
13
|
+
the stale-file shadow (2026-05-12).
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
16
|
from __future__ import annotations
|
|
@@ -196,7 +196,7 @@ class TestDetectClientName:
|
|
|
196
196
|
|
|
197
197
|
def test_gemini_api_key_does_NOT_trigger_gemini_cli(self):
|
|
198
198
|
# T92 regression: pre-T92 a GEMINI_API_KEY export silently
|
|
199
|
-
# mislabeled SDK-direct users (
|
|
199
|
+
# mislabeled SDK-direct users (the deny-deny incident,
|
|
200
200
|
# 2026-05-10) as "gemini-cli". GEMINI_API_KEY is a Google API
|
|
201
201
|
# credential, not a Gemini CLI runtime signal. Must default
|
|
202
202
|
# back to the python-sdk fallback (1.5.2 canonical alias).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Regression tests for `controlzero env-dump` (#438).
|
|
2
2
|
|
|
3
|
-
Filed as a follow-up to the 2026-05-12
|
|
3
|
+
Filed as a follow-up to the 2026-05-12 hook-decision regression where
|
|
4
4
|
source-detection on Windows took multiple rounds because we had no way
|
|
5
5
|
to ask the customer "what env vars does YOUR hook subprocess see?"
|
|
6
6
|
|
|
@@ -123,8 +123,8 @@ def test_universal_wildcard(tmp_log):
|
|
|
123
123
|
# ---------------------------------------------------------------------
|
|
124
124
|
# Pre-T83 the dashboard's universal-resource shape (every rule emitted
|
|
125
125
|
# resources:["*"]) skipped every rule when callers passed no
|
|
126
|
-
# context.resource, falling through to default deny.
|
|
127
|
-
# incident, 2026-05-10. The exact bundle
|
|
126
|
+
# context.resource, falling through to default deny. the deny-deny
|
|
127
|
+
# incident, 2026-05-10. The exact bundle a customer ran against is the
|
|
128
128
|
# fixture below.
|
|
129
129
|
|
|
130
130
|
def test_resources_wildcard_matches_without_context_resource(tmp_log):
|
|
@@ -147,7 +147,7 @@ def test_resources_wildcard_matches_without_context_resource(tmp_log):
|
|
|
147
147
|
|
|
148
148
|
|
|
149
149
|
def test_bryan_db_read_only_full_bundle_shape(tmp_log):
|
|
150
|
-
"""End-to-end against the EXACT rule shape from
|
|
150
|
+
"""End-to-end against the EXACT rule shape from the published bundle.
|
|
151
151
|
|
|
152
152
|
This is the smoking-gun reproduction:
|
|
153
153
|
- rule-0 allow database:read (should fire on SELECT)
|
|
@@ -12,7 +12,7 @@ Two pillars under test:
|
|
|
12
12
|
fixed here: Claude Code's allow path. Pre-1.5.3 the SDK emitted
|
|
13
13
|
``decision: "allow"`` which crashed Claude Code's validator with
|
|
14
14
|
``Hook JSON output validation failed - (root): Invalid input``;
|
|
15
|
-
the customer (
|
|
15
|
+
the customer (an enterprise customer admin, 2026-05-12) saw a Write block
|
|
16
16
|
succeed and then a PowerShell command bypass on the same intent.
|
|
17
17
|
After 1.5.3 the Claude Code adapter renders allow as ``approve``
|
|
18
18
|
(the spec-correct value) and deny as ``block``.
|
|
@@ -107,7 +107,7 @@ class TestGeminiCLIClaim:
|
|
|
107
107
|
def test_does_NOT_claim_via_gemini_api_key(self):
|
|
108
108
|
# T78 / T92 regression: GEMINI_API_KEY is a Google API
|
|
109
109
|
# credential, NOT a CLI runtime signal. Must NOT trigger
|
|
110
|
-
# the Gemini adapter (
|
|
110
|
+
# the Gemini adapter (the 2026-05-10 incident).
|
|
111
111
|
assert not GeminiCLIAdapter().claim(
|
|
112
112
|
{}, env={"GEMINI_API_KEY": "fake"}
|
|
113
113
|
)
|
|
@@ -8,7 +8,7 @@ Two invariants the installer MUST satisfy:
|
|
|
8
8
|
key. Embedding ``CONTROLZERO_API_KEY=cz_live_... controlzero
|
|
9
9
|
hook-check`` was a portability bug: bash parses the env-prefix,
|
|
10
10
|
PowerShell / cmd.exe do not, so the hook subprocess failed to
|
|
11
|
-
spawn on Windows and
|
|
11
|
+
spawn on Windows and a customer's Claude Code instance never
|
|
12
12
|
delivered audit logs (2026-05-12 incident).
|
|
13
13
|
2. The api key is persisted to ``~/.controlzero/config.yaml`` so the
|
|
14
14
|
hook subprocess can pick it up at runtime via the
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""End-to-end test suite for the controlzero hook installers and hook-check evaluator.
|
|
2
2
|
|
|
3
3
|
This file is intentionally exhaustive. The audience is procurement / security
|
|
4
|
-
review teams (
|
|
4
|
+
review teams (enterprise customers) who need a clear story of what is covered,
|
|
5
5
|
what is intentionally skipped, and what is known broken.
|
|
6
6
|
|
|
7
7
|
Coverage matrix
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Tests for PolicyDecision.reason_code (
|
|
1
|
+
"""Tests for PolicyDecision.reason_code (2026-04-19 P0).
|
|
2
2
|
|
|
3
3
|
The hosted dashboard + audit ingestion + CLI automation all need a
|
|
4
4
|
machine-readable label for "why did this call get denied?" that is
|
|
@@ -89,7 +89,7 @@ def test_bundle_empty_emits_reason_code_NO_ACTIVE_POLICIES():
|
|
|
89
89
|
"""An empty policy list translated to the local shape MUST emit a
|
|
90
90
|
single synthetic deny whose ``reason_code`` is NO_ACTIVE_POLICIES.
|
|
91
91
|
|
|
92
|
-
This is the code that ran on
|
|
92
|
+
This is the code that ran on the customer's project: the bundle was
|
|
93
93
|
signed + verified + decrypted but carried zero policies because
|
|
94
94
|
policy_attachments was empty. Prior to the fix, the SDK surfaced a
|
|
95
95
|
generic English string; now it surfaces a stable machine label so
|
|
@@ -108,7 +108,7 @@ def test_bundle_empty_new_copy_does_not_presume_user_undefined_policies():
|
|
|
108
108
|
"""The pre-2026-04-19 copy told users to "Define one in the Control
|
|
109
109
|
Zero dashboard." That message is the WRONG recovery action when the
|
|
110
110
|
user has already defined policies but the attachment state is empty
|
|
111
|
-
(
|
|
111
|
+
(the customer's case). This test fails if anyone restores that wording.
|
|
112
112
|
"""
|
|
113
113
|
local = translate_to_local_policy({"policies": []})
|
|
114
114
|
reason = local["rules"][0]["reason"].lower()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Tests for hosted-policy periodic refresh.
|
|
2
2
|
|
|
3
|
-
Covers Bug #1 from the 2026-04-19
|
|
3
|
+
Covers Bug #1 from the 2026-04-19 investigation: before this
|
|
4
4
|
change, :class:`Client._evaluator` was loaded exactly once in
|
|
5
5
|
``__init__`` and held for the life of the process. Updates made on the
|
|
6
6
|
dashboard never reached a long-running SDK client until it restarted.
|
|
@@ -26,10 +26,10 @@ from controlzero.cli._secrets import (
|
|
|
26
26
|
class TestFindKeyLeaks:
|
|
27
27
|
def test_matches_full_64char_live_key(self) -> None:
|
|
28
28
|
# The exact shape from the 2026-05-14 screenshot incident.
|
|
29
|
-
text = "CONTROLZERO_API_KEY=
|
|
29
|
+
text = "CONTROLZERO_API_KEY=cz_live_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa controlzero hook-check"
|
|
30
30
|
matches = find_key_leaks(text)
|
|
31
31
|
assert len(matches) == 1
|
|
32
|
-
assert matches[0].key == "
|
|
32
|
+
assert matches[0].key == "cz_live_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
33
33
|
assert matches[0].line_number == 1
|
|
34
34
|
|
|
35
35
|
def test_matches_test_key_localdev_shape(self) -> None:
|
|
@@ -71,9 +71,9 @@ class TestFindKeyLeaks:
|
|
|
71
71
|
|
|
72
72
|
def test_does_not_match_redacted_form(self) -> None:
|
|
73
73
|
# We must NOT re-flag our own redacted output as a leak.
|
|
74
|
-
# `cz_live_***
|
|
74
|
+
# `cz_live_***aaaaa` should be safe to print without doctor
|
|
75
75
|
# warning about it.
|
|
76
|
-
text = "Active key: cz_live_***
|
|
76
|
+
text = "Active key: cz_live_***aaaaa"
|
|
77
77
|
# The redaction contains *** which is not in [A-Za-z0-9_].
|
|
78
78
|
# The pattern stops at the *. So the match would be too short
|
|
79
79
|
# (cz_live_ alone is < 4 chars suffix). Asserting empty:
|
|
@@ -107,8 +107,8 @@ class TestFindKeyLeaks:
|
|
|
107
107
|
|
|
108
108
|
class TestRedactKey:
|
|
109
109
|
def test_live_key_redacted_to_last_4(self) -> None:
|
|
110
|
-
key = "
|
|
111
|
-
assert redact_key(key) == "cz_live_***
|
|
110
|
+
key = "cz_live_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
111
|
+
assert redact_key(key) == "cz_live_***aaaaa"
|
|
112
112
|
|
|
113
113
|
def test_test_key_redacted_to_last_5(self) -> None:
|
|
114
114
|
key = "cz_test_localdev_000000000000000000000000"
|
|
@@ -221,7 +221,7 @@ def test_redacted_form_is_idempotent() -> None:
|
|
|
221
221
|
"""Running `redact_text(redact_text(x)) == redact_text(x)`. If
|
|
222
222
|
someone changes the redaction to embed key chars, this catches
|
|
223
223
|
it."""
|
|
224
|
-
text = "key=
|
|
224
|
+
text = "key=cz_live_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
225
225
|
once = redact_text(text)
|
|
226
226
|
twice = redact_text(once)
|
|
227
227
|
assert once == twice
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""SQL semantic-class extractor tests (issue #345).
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
a customer reported a `db-read-only` policy that denied legitimate
|
|
4
4
|
read-only SQL because the per-keyword extractor surfaced `WITH`,
|
|
5
5
|
`EXPLAIN`, and `SHOW` as the canonical method while the blueprint
|
|
6
6
|
wrote rules against `database:query`/`database:execute`/`database:delete`
|
|
@@ -72,7 +72,7 @@ def test_all_synthetic_ids_carry_canonical_prefix():
|
|
|
72
72
|
"""Frontend renders chips by `policy_id.startswith("synthetic:")`.
|
|
73
73
|
A regression that emits a sentinel without the prefix would fall
|
|
74
74
|
through to the plain text rendering and re-create the original
|
|
75
|
-
"blank Policy column" bug from
|
|
75
|
+
"blank Policy column" bug from the incident.
|
|
76
76
|
"""
|
|
77
77
|
for value in VALID_SYNTHETIC_POLICY_IDS:
|
|
78
78
|
assert value.startswith(SYNTHETIC_POLICY_ID_PREFIX), value
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""T103 regression tests: hosted-vs-local precedence.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
an enterprise customer (Korea) 2026-05-12: a stale ~/.controlzero/policy.yaml
|
|
4
4
|
shadowed the dashboard policy with no warning. Local always won over
|
|
5
5
|
hosted when both were present. T103 reverses this so api_key implies
|
|
6
6
|
hosted unless CONTROLZERO_LOCAL_OVERRIDE=1.
|
|
@@ -104,7 +104,7 @@ def test_no_api_key_accepts_json_policy(monkeypatch, tmp_path):
|
|
|
104
104
|
def test_john_na_regression_cwd_yaml_bypassed_when_api_key_set(
|
|
105
105
|
monkeypatch, tmp_path
|
|
106
106
|
):
|
|
107
|
-
"""
|
|
107
|
+
"""an enterprise customer (Korea) 2026-05-12 regression.
|
|
108
108
|
|
|
109
109
|
Pre-T103: a stale ~/.controlzero/policy.yaml shadowed the hosted
|
|
110
110
|
bundle for 25 days. Post-T103: api_key + cwd policy.yaml + no
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""T104 regression tests: stale cache GC on key rotation.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
an enterprise customer (Korea) 2026-05-12: rotated his api_key from
|
|
4
4
|
``cz_live_566b...`` to ``cz_live_1af8...``. The new key bootstrapped
|
|
5
5
|
fresh but ``cache/bundle-cz_live_566b.bin`` lingered for 25 days next to
|
|
6
6
|
the new key's files. T104 GC's any cache files whose key-scope does NOT
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|