controlzero 1.5.0__tar.gz → 1.5.2__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.0 → controlzero-1.5.2}/CHANGELOG.md +43 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/PKG-INFO +1 -1
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/__init__.py +1 -1
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/cli/main.py +16 -7
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/client.py +14 -1
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/device.py +8 -2
- {controlzero-1.5.0 → controlzero-1.5.2}/pyproject.toml +1 -1
- controlzero-1.5.2/tests/test_api_key_mask.py +59 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_coding_agent_hooks.py +14 -4
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_device.py +8 -4
- controlzero-1.5.2/tests/test_install_hook_command.py +139 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/.gitignore +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/Dockerfile.test +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/LICENSE +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/README.md +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/_internal/__init__.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/_internal/action_aliases.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/_internal/bundle.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/_internal/dlp_scanner.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/_internal/enforcer.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/_internal/hook_extractors.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/_internal/tool_extractors.json +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/_internal/types.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/audit_local.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/audit_remote.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/cli/__init__.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/cli/debug_bundle.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/cli/templates/autogen.yaml +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/cli/templates/claude-code.yaml +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/cli/templates/codex-cli.yaml +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/cli/templates/cost-cap.yaml +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/cli/templates/crewai.yaml +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/cli/templates/cursor.yaml +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/cli/templates/gemini-cli.yaml +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/cli/templates/generic.yaml +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/cli/templates/langchain.yaml +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/cli/templates/mcp.yaml +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/cli/templates/rag.yaml +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/enrollment.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/errors.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/hosted_policy.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/__init__.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/anthropic.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/autogen.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/braintrust.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/crewai/__init__.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/crewai/agent.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/crewai/crew.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/crewai/task.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/crewai/tool.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/google.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/google_adk/__init__.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/google_adk/agent.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/google_adk/tool.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/langchain/__init__.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/langchain/agent.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/langchain/callbacks.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/langchain/chain.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/langchain/graph.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/langchain/modern.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/langchain/tool.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/langfuse.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/litellm.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/openai.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/pydantic_ai.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/vercel_ai.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/policy_loader.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/tamper.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/examples/hello_world.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/conftest.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/integrations/__init__.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/integrations/test_google.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/parity/action_aliases.json +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_action_aliases.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_action_canonicalization.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_agent_name_env.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_audit_remote.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_audit_sink_isolation.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_bundle_parser.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_bundle_translate.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_cli_carve_out.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_cli_debug_bundle.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_cli_extractor_integration.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_cli_hook.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_cli_hosted_refresh.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_cli_init.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_cli_init_templates.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_cli_tail.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_cli_test.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_cli_validate.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_conditions.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_default_action.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_dlp_scanner.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_enrollment.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_fail_closed_eval.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_glob_matching.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_hook_extractors.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_hosted_policy_e2e.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_hybrid_mode_strict.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_hybrid_mode_warn.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_install_hooks.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_local_mode_dict.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_local_mode_file_json.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_local_mode_file_yaml.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_log_fallback_stderr.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_log_options_ignored_hosted.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_log_rotation.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_no_policy_no_key.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_package_rename_shim.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_policy_freshness.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_policy_settings.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_quarantine.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_reason_code.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_refresh.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_sql_semantic_class.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_synthetic_policy_id_t79.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_t103_precedence.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_t104_cache_gc.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_t108_local_override_audit.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_tamper.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_tamper_behavior.py +0 -0
- {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_tamper_hook.py +0 -0
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.5.2 -- 2026-05-12
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **Windows Claude Code hook never delivered audit logs.** The
|
|
8
|
+
installer wrote `CONTROLZERO_API_KEY=cz_live_... controlzero hook-check`
|
|
9
|
+
into `~/.claude/settings.json`. The `VAR=value command` env-prefix
|
|
10
|
+
is bash-only and PowerShell / cmd.exe cannot parse it, so the
|
|
11
|
+
Claude Code hook subprocess failed to spawn on every PreToolUse
|
|
12
|
+
call. macOS / Linux were unaffected. After 1.5.2 the hook command
|
|
13
|
+
is plain `controlzero hook-check`; the api key flows through
|
|
14
|
+
`~/.controlzero/config.yaml` (already written by `install --api-key`)
|
|
15
|
+
on every supported platform. Side benefit: the cleartext key is
|
|
16
|
+
no longer embedded in `settings.json` on disk.
|
|
17
|
+
- **Audit-log dashboard SOURCE column rendered `--` for direct-SDK
|
|
18
|
+
rows.** `detect_client_name()` returned the generic alias `"sdk"`
|
|
19
|
+
when no agent-runtime env vars were detected; the backend
|
|
20
|
+
`NormalizeSource` mapped `"sdk"` to `unknown`, and the frontend
|
|
21
|
+
rendered `unknown` as `--`. The default is now `"python-sdk"`,
|
|
22
|
+
which the backend maps to the canonical `python_sdk` source value
|
|
23
|
+
and the frontend renders as `Python SDK`.
|
|
24
|
+
|
|
25
|
+
### Customer impact
|
|
26
|
+
|
|
27
|
+
CloudShift / kh.lee reported on 2026-05-12 that Claude Code on
|
|
28
|
+
Windows was not sending any audit logs to the dashboard, while the
|
|
29
|
+
direct Python SDK script was sending logs without a populated
|
|
30
|
+
SOURCE column. Both symptoms collapse to the two fixes above.
|
|
31
|
+
|
|
32
|
+
## 1.5.1 -- 2026-05-12 (SECURITY)
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- **API key leak in the active-source stderr notification.** The T103
|
|
37
|
+
startup line `controlzero: active policy source = hosted (...)`
|
|
38
|
+
printed the first 14 characters of `CONTROLZERO_API_KEY`, which for
|
|
39
|
+
a `cz_live_...` or `cz_test_...` key meant 6 characters of the
|
|
40
|
+
customer secret reached stderr (visible in terminals, screen shares,
|
|
41
|
+
support transcripts, and CI logs). The hint is now masked to
|
|
42
|
+
`cz_live_***` or `cz_test_***` so the mode is still observable but
|
|
43
|
+
no secret bytes are exposed. Upgrade ASAP if you ran 1.5.0 in any
|
|
44
|
+
environment where stderr is observable. 1.5.0 is yanked on PyPI.
|
|
45
|
+
|
|
3
46
|
## 1.5.0 -- 2026-05-12
|
|
4
47
|
|
|
5
48
|
### Changed (governance posture; opt-out path documented)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: controlzero
|
|
3
|
-
Version: 1.5.
|
|
3
|
+
Version: 1.5.2
|
|
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
|
|
@@ -1517,10 +1517,15 @@ def install_claude_code(force: bool, merge: bool, settings: Optional[str], api_k
|
|
|
1517
1517
|
hooks = existing.setdefault("hooks", {})
|
|
1518
1518
|
pre_tool = hooks.setdefault("PreToolUse", [])
|
|
1519
1519
|
|
|
1520
|
-
# Idempotency: replace any existing controlzero hook block, preserve others
|
|
1520
|
+
# Idempotency: replace any existing controlzero hook block, preserve others.
|
|
1521
|
+
# The hook subprocess resolves api_key from ~/.controlzero/config.yaml
|
|
1522
|
+
# (written by _write_api_key_config above) and falls back to
|
|
1523
|
+
# CONTROLZERO_API_KEY in the parent shell env. Embedding
|
|
1524
|
+
# `CONTROLZERO_API_KEY=cz_... controlzero hook-check` as a bash-style
|
|
1525
|
+
# env-prefix was a Windows-portability bug (PowerShell / cmd.exe cannot
|
|
1526
|
+
# parse the syntax) AND placed the cleartext key into settings.json on
|
|
1527
|
+
# disk. Both go away here. See 2026-05-12 CloudShift incident.
|
|
1521
1528
|
hook_command = "controlzero hook-check"
|
|
1522
|
-
if api_key:
|
|
1523
|
-
hook_command = f"CONTROLZERO_API_KEY={api_key} controlzero hook-check"
|
|
1524
1529
|
new_block = {
|
|
1525
1530
|
"matcher": "*",
|
|
1526
1531
|
"hooks": [
|
|
@@ -1665,9 +1670,11 @@ def install_gemini_cli(force: bool, merge: bool, settings: Optional[str], api_ke
|
|
|
1665
1670
|
hooks_root = existing.setdefault("hooks", {})
|
|
1666
1671
|
before_tool = hooks_root.setdefault("BeforeTool", [])
|
|
1667
1672
|
|
|
1673
|
+
# 1.5.2: api_key flows via ~/.controlzero/config.yaml (see
|
|
1674
|
+
# _write_api_key_config above). The legacy bash-only env-prefix
|
|
1675
|
+
# broke Windows; see the claude-code install path for the full
|
|
1676
|
+
# rationale.
|
|
1668
1677
|
hook_command = "controlzero hook-check"
|
|
1669
|
-
if api_key:
|
|
1670
|
-
hook_command = f"CONTROLZERO_API_KEY={api_key} controlzero hook-check"
|
|
1671
1678
|
|
|
1672
1679
|
# Gemini CLI expects a nested structure:
|
|
1673
1680
|
# { matcher: "regex", hooks: [{ type: "command", command: "...", timeout: N }] }
|
|
@@ -1819,9 +1826,11 @@ def install_codex_cli(force: bool, merge: bool, config: Optional[str], api_key:
|
|
|
1819
1826
|
codex_dir.mkdir(parents=True, exist_ok=True)
|
|
1820
1827
|
hooks_json_path = codex_dir / "hooks.json"
|
|
1821
1828
|
|
|
1829
|
+
# 1.5.2: api_key flows via ~/.controlzero/config.yaml (see
|
|
1830
|
+
# _write_api_key_config above). The legacy bash-only env-prefix
|
|
1831
|
+
# broke Windows; see the claude-code install path for the full
|
|
1832
|
+
# rationale.
|
|
1822
1833
|
hook_command = "controlzero hook-check"
|
|
1823
|
-
if api_key:
|
|
1824
|
-
hook_command = f"CONTROLZERO_API_KEY={api_key} controlzero hook-check"
|
|
1825
1834
|
|
|
1826
1835
|
new_hook_entry = {
|
|
1827
1836
|
"matcher": ".*",
|
|
@@ -73,6 +73,19 @@ _DEFAULT_REFRESH_INTERVAL_SECONDS = 60
|
|
|
73
73
|
_refresh_logger = logging.getLogger("controlzero.client.refresh")
|
|
74
74
|
|
|
75
75
|
|
|
76
|
+
def _mask_api_key(api_key: Optional[str]) -> str:
|
|
77
|
+
# Reveal only the public, non-secret prefix (cz_live_ / cz_test_) and mask
|
|
78
|
+
# the rest. Anything past the prefix is entropy from the customer's secret
|
|
79
|
+
# and must never appear on stderr, in logs, or in support transcripts.
|
|
80
|
+
if not api_key:
|
|
81
|
+
return "***"
|
|
82
|
+
if api_key.startswith("cz_live_"):
|
|
83
|
+
return "cz_live_***"
|
|
84
|
+
if api_key.startswith("cz_test_"):
|
|
85
|
+
return "cz_test_***"
|
|
86
|
+
return "***"
|
|
87
|
+
|
|
88
|
+
|
|
76
89
|
class Client:
|
|
77
90
|
"""The ControlZero policy client.
|
|
78
91
|
|
|
@@ -174,7 +187,7 @@ class Client:
|
|
|
174
187
|
self._hosted_etag = cb.etag
|
|
175
188
|
except Exception: # noqa: BLE001
|
|
176
189
|
self._hosted_etag = None
|
|
177
|
-
self._notify_active_source("hosted", source_hint=self._api_key
|
|
190
|
+
self._notify_active_source("hosted", source_hint=_mask_api_key(self._api_key))
|
|
178
191
|
else:
|
|
179
192
|
# Either no api_key, or api_key + LOCAL_OVERRIDE escape hatch.
|
|
180
193
|
local_source = self._resolve_local_source(None, None)
|
|
@@ -136,8 +136,14 @@ def detect_client_name() -> str:
|
|
|
136
136
|
_client_name_cache = "gemini-cli"
|
|
137
137
|
return "gemini-cli"
|
|
138
138
|
|
|
139
|
-
|
|
140
|
-
|
|
139
|
+
# Direct SDK usage (no agent runtime detected) -- emit the canonical
|
|
140
|
+
# backend source value so the dashboard renders "Python SDK" instead
|
|
141
|
+
# of "--". Pre-2026-05-12 we emitted the generic alias "sdk", which
|
|
142
|
+
# the backend NormalizeSource maps to "unknown" and the audit log
|
|
143
|
+
# column rendered as "--", confusing customers into thinking their
|
|
144
|
+
# logs weren't reaching the dashboard.
|
|
145
|
+
_client_name_cache = "python-sdk"
|
|
146
|
+
return "python-sdk"
|
|
141
147
|
|
|
142
148
|
|
|
143
149
|
def detect_client_version() -> str:
|
|
@@ -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.2"
|
|
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"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Regression tests for the masked API-key hint shown in the active-source
|
|
2
|
+
stderr notification (T103 follow-up, 1.5.1 security fix).
|
|
3
|
+
|
|
4
|
+
Before 1.5.1 the SDK printed ``self._api_key[:14]`` to stderr on every
|
|
5
|
+
Client construction in hosted mode. For a ``cz_live_abcdef123456...``
|
|
6
|
+
key that meant 6 characters of the customer secret leaked to terminals,
|
|
7
|
+
screen shares, support transcripts, and CI logs.
|
|
8
|
+
|
|
9
|
+
The mask MUST:
|
|
10
|
+
|
|
11
|
+
* preserve the public ``cz_live_`` / ``cz_test_`` prefix as a mode signal
|
|
12
|
+
* NEVER emit any character beyond that prefix from the input key
|
|
13
|
+
|
|
14
|
+
These tests verify both invariants with a brute-force substring scan so a
|
|
15
|
+
future regression that switches back to a length-prefix slice fails loud.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from controlzero.client import _mask_api_key
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_mask_live_key_emits_only_public_prefix():
|
|
22
|
+
masked = _mask_api_key("cz_live_7ebef6b600015e3eaeda9149bf6d9c29a")
|
|
23
|
+
assert masked == "cz_live_***"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_mask_test_key_emits_only_public_prefix():
|
|
27
|
+
masked = _mask_api_key("cz_test_abcdef0123456789abcdef0123456789")
|
|
28
|
+
assert masked == "cz_test_***"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_mask_unknown_prefix_falls_through_to_triple_star():
|
|
32
|
+
masked = _mask_api_key("plain-token-1234567890")
|
|
33
|
+
assert masked == "***"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_mask_none_returns_triple_star():
|
|
37
|
+
assert _mask_api_key(None) == "***"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_mask_empty_string_returns_triple_star():
|
|
41
|
+
assert _mask_api_key("") == "***"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_mask_never_leaks_secret_bytes_brute_force():
|
|
45
|
+
# The secret portion of a real CloudShift production key (anonymised:
|
|
46
|
+
# do not use this value as-is, the customer rotated it after the
|
|
47
|
+
# 1.5.0 -> 1.5.1 incident on 2026-05-12).
|
|
48
|
+
secret_tail = "7ebef6b600015e3eaeda9149bf6d9c29a3a2a7a3075209112afde20888280de0"
|
|
49
|
+
for prefix in ("cz_live_", "cz_test_"):
|
|
50
|
+
key = prefix + secret_tail
|
|
51
|
+
masked = _mask_api_key(key)
|
|
52
|
+
# Scan every 4-char substring of the secret and assert NONE
|
|
53
|
+
# appears in the masked output. 4 chars is enough entropy that
|
|
54
|
+
# an accidental match is vanishingly unlikely.
|
|
55
|
+
for i in range(len(secret_tail) - 3):
|
|
56
|
+
window = secret_tail[i : i + 4]
|
|
57
|
+
assert window not in masked, (
|
|
58
|
+
f"secret bytes {window!r} leaked into masked output {masked!r}"
|
|
59
|
+
)
|
|
@@ -709,8 +709,14 @@ class TestApiKeyOption:
|
|
|
709
709
|
pre_tool = settings["hooks"]["PreToolUse"]
|
|
710
710
|
assert len(pre_tool) == 1
|
|
711
711
|
hook_cmd = pre_tool[0]["hooks"][0]["command"]
|
|
712
|
-
|
|
713
|
-
|
|
712
|
+
# 1.5.2 portability fix: the hook command is plain
|
|
713
|
+
# `controlzero hook-check`. The api key flows through
|
|
714
|
+
# ~/.controlzero/config.yaml (see _write_api_key_config) so it
|
|
715
|
+
# is no longer embedded in settings.json (Windows shells could
|
|
716
|
+
# not parse the bash-only env-prefix syntax).
|
|
717
|
+
assert hook_cmd == "controlzero hook-check"
|
|
718
|
+
assert "cz_live_" not in hook_cmd
|
|
719
|
+
assert "CONTROLZERO_API_KEY=" not in hook_cmd
|
|
714
720
|
|
|
715
721
|
def test_install_gemini_cli_with_api_key_updates_hook_command(
|
|
716
722
|
self, tmp_path, monkeypatch
|
|
@@ -732,7 +738,9 @@ class TestApiKeyOption:
|
|
|
732
738
|
# Nested format: check inside the hooks array
|
|
733
739
|
inner_hooks = before_tool[0].get("hooks", [])
|
|
734
740
|
assert len(inner_hooks) == 1
|
|
735
|
-
|
|
741
|
+
# 1.5.2: hook command is plain, key flows via config.yaml.
|
|
742
|
+
assert inner_hooks[0]["command"] == "controlzero hook-check"
|
|
743
|
+
assert "cz_test_" not in inner_hooks[0]["command"]
|
|
736
744
|
|
|
737
745
|
def test_install_codex_cli_with_api_key_updates_hooks_json(
|
|
738
746
|
self, tmp_path, monkeypatch
|
|
@@ -754,7 +762,9 @@ class TestApiKeyOption:
|
|
|
754
762
|
assert len(pre_tool) == 1
|
|
755
763
|
inner_hooks = pre_tool[0].get("hooks", [])
|
|
756
764
|
assert len(inner_hooks) == 1
|
|
757
|
-
|
|
765
|
+
# 1.5.2: hook command is plain, key flows via config.yaml.
|
|
766
|
+
assert inner_hooks[0]["command"] == "controlzero hook-check"
|
|
767
|
+
assert "cz_live_" not in inner_hooks[0]["command"]
|
|
758
768
|
|
|
759
769
|
def test_install_with_invalid_api_key_exits_with_error(
|
|
760
770
|
self, tmp_path, monkeypatch
|
|
@@ -141,10 +141,14 @@ def _isolated_env(extras: dict[str, str] | None = None) -> dict[str, str]:
|
|
|
141
141
|
|
|
142
142
|
|
|
143
143
|
class TestDetectClientName:
|
|
144
|
-
def
|
|
144
|
+
def test_default_is_python_sdk(self):
|
|
145
|
+
# 1.5.2: default-emission fix. Previously returned "sdk", which
|
|
146
|
+
# the backend NormalizeSource mapped to SourceUnknown and the
|
|
147
|
+
# dashboard rendered as "--". "python-sdk" is the canonical
|
|
148
|
+
# alias that maps to SourcePythonSDK on the backend.
|
|
145
149
|
with mock.patch.dict(os.environ, _isolated_env(), clear=True):
|
|
146
150
|
_reset_caches()
|
|
147
|
-
assert detect_client_name() == "sdk"
|
|
151
|
+
assert detect_client_name() == "python-sdk"
|
|
148
152
|
|
|
149
153
|
def test_reads_controlzero_client_env(self):
|
|
150
154
|
with mock.patch.dict(os.environ, _isolated_env({"CONTROLZERO_CLIENT": "my-custom-client"}), clear=True):
|
|
@@ -195,10 +199,10 @@ class TestDetectClientName:
|
|
|
195
199
|
# mislabeled SDK-direct users (Bryan deny-deny incident,
|
|
196
200
|
# 2026-05-10) as "gemini-cli". GEMINI_API_KEY is a Google API
|
|
197
201
|
# credential, not a Gemini CLI runtime signal. Must default
|
|
198
|
-
# back to
|
|
202
|
+
# back to the python-sdk fallback (1.5.2 canonical alias).
|
|
199
203
|
with mock.patch.dict(os.environ, _isolated_env({"GEMINI_API_KEY": "fake-google-key"}), clear=True):
|
|
200
204
|
_reset_caches()
|
|
201
|
-
assert detect_client_name() == "sdk"
|
|
205
|
+
assert detect_client_name() == "python-sdk"
|
|
202
206
|
|
|
203
207
|
def test_cursor_with_gemini_api_key_resolves_to_cursor(self):
|
|
204
208
|
# T92 regression: a Cursor user who also exports GEMINI_API_KEY
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Regression tests for the Claude Code / Gemini CLI / Codex CLI installer
|
|
2
|
+
hook command (1.5.2 customer fix).
|
|
3
|
+
|
|
4
|
+
Two invariants the installer MUST satisfy:
|
|
5
|
+
|
|
6
|
+
1. The `command` field in the agent settings JSON is plain
|
|
7
|
+
``controlzero hook-check``. It MUST NOT contain the customer api
|
|
8
|
+
key. Embedding ``CONTROLZERO_API_KEY=cz_live_... controlzero
|
|
9
|
+
hook-check`` was a portability bug: bash parses the env-prefix,
|
|
10
|
+
PowerShell / cmd.exe do not, so the hook subprocess failed to
|
|
11
|
+
spawn on Windows and CloudShift's Claude Code instance never
|
|
12
|
+
delivered audit logs (2026-05-12 incident).
|
|
13
|
+
2. The api key is persisted to ``~/.controlzero/config.yaml`` so the
|
|
14
|
+
hook subprocess can pick it up at runtime via the
|
|
15
|
+
``cli/main.py:hook_check`` env-fallback path.
|
|
16
|
+
|
|
17
|
+
The test runs the installer via the click CLI runner with a synthetic
|
|
18
|
+
HOME directory so the assertions can read both files back.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
import pytest
|
|
27
|
+
import yaml
|
|
28
|
+
from click.testing import CliRunner
|
|
29
|
+
|
|
30
|
+
from controlzero.cli.main import cli
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
LIVE_KEY = "cz_live_7ebef6b600015e3eaeda9149bf6d9c29a3a2a7a3075209112afde20888280de0"
|
|
34
|
+
TEST_KEY = "cz_test_abcdef0123456789abcdef0123456789abcdef0123456789abcdef01234567"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _read_pretooluse_command(settings_path: Path) -> str:
|
|
38
|
+
data = json.loads(settings_path.read_text())
|
|
39
|
+
blocks = data["hooks"]["PreToolUse"]
|
|
40
|
+
# Find the controlzero block (idempotent installer keeps a single
|
|
41
|
+
# block; tests assert the singular contract).
|
|
42
|
+
cz_blocks = [
|
|
43
|
+
b
|
|
44
|
+
for b in blocks
|
|
45
|
+
if any("controlzero hook-check" in h.get("command", "") for h in b.get("hooks", []))
|
|
46
|
+
]
|
|
47
|
+
assert len(cz_blocks) == 1, f"expected one controlzero hook block, got {cz_blocks}"
|
|
48
|
+
return cz_blocks[0]["hooks"][0]["command"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.mark.parametrize("api_key", [LIVE_KEY, TEST_KEY])
|
|
52
|
+
def test_install_claude_code_writes_clean_hook_command(api_key, tmp_path, monkeypatch):
|
|
53
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
54
|
+
# Force the controlzero state dir to match the synthetic HOME so
|
|
55
|
+
# _write_api_key_config and the settings.json patch both land
|
|
56
|
+
# inside tmp_path.
|
|
57
|
+
monkeypatch.setattr(
|
|
58
|
+
"controlzero.cli.main.GLOBAL_POLICY_DIR",
|
|
59
|
+
tmp_path / ".controlzero",
|
|
60
|
+
)
|
|
61
|
+
monkeypatch.setattr(
|
|
62
|
+
"controlzero.cli.main.GLOBAL_POLICY_PATH",
|
|
63
|
+
tmp_path / ".controlzero" / "policy.yaml",
|
|
64
|
+
)
|
|
65
|
+
monkeypatch.setattr(
|
|
66
|
+
"controlzero.cli.main.GLOBAL_CONFIG_PATH",
|
|
67
|
+
tmp_path / ".controlzero" / "config.yaml",
|
|
68
|
+
)
|
|
69
|
+
monkeypatch.setattr(
|
|
70
|
+
"controlzero.cli.main.GLOBAL_AUDIT_PATH",
|
|
71
|
+
tmp_path / ".controlzero" / "audit.log",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
settings_path = tmp_path / ".claude" / "settings.json"
|
|
75
|
+
|
|
76
|
+
runner = CliRunner()
|
|
77
|
+
result = runner.invoke(
|
|
78
|
+
cli,
|
|
79
|
+
[
|
|
80
|
+
"install",
|
|
81
|
+
"claude-code",
|
|
82
|
+
"--api-key",
|
|
83
|
+
api_key,
|
|
84
|
+
"--settings",
|
|
85
|
+
str(settings_path),
|
|
86
|
+
"--force",
|
|
87
|
+
],
|
|
88
|
+
)
|
|
89
|
+
assert result.exit_code == 0, result.output
|
|
90
|
+
|
|
91
|
+
# Invariant 1: hook command MUST NOT embed the api key. Plain
|
|
92
|
+
# `controlzero hook-check` is the only acceptable shape.
|
|
93
|
+
command = _read_pretooluse_command(settings_path)
|
|
94
|
+
assert command == "controlzero hook-check", (
|
|
95
|
+
f"hook command leaked api_key or shell-only syntax: {command!r}"
|
|
96
|
+
)
|
|
97
|
+
assert "cz_live_" not in command
|
|
98
|
+
assert "cz_test_" not in command
|
|
99
|
+
assert "CONTROLZERO_API_KEY=" not in command
|
|
100
|
+
|
|
101
|
+
# Invariant 2: api key MUST be persisted to config.yaml so the
|
|
102
|
+
# hook subprocess can resolve it without an env-prefix.
|
|
103
|
+
config_path = tmp_path / ".controlzero" / "config.yaml"
|
|
104
|
+
assert config_path.exists(), "_write_api_key_config did not run"
|
|
105
|
+
cfg = yaml.safe_load(config_path.read_text())
|
|
106
|
+
assert cfg["api_key"] == api_key, "config.yaml api_key mismatch"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_detect_client_name_default_is_python_sdk(monkeypatch):
|
|
110
|
+
# 1.5.2 default-emission fix: when no agent-runtime env vars are
|
|
111
|
+
# set, detect_client_name() must return the canonical
|
|
112
|
+
# `python-sdk` alias so the backend NormalizeSource pipeline
|
|
113
|
+
# resolves it to SourcePythonSDK instead of SourceUnknown (which
|
|
114
|
+
# the dashboard renders as `--`).
|
|
115
|
+
for var in (
|
|
116
|
+
"CONTROLZERO_CLIENT",
|
|
117
|
+
"CLAUDECODE",
|
|
118
|
+
"CLAUDE_CODE",
|
|
119
|
+
"CODEX_HOME",
|
|
120
|
+
"CODEX_PROFILE",
|
|
121
|
+
"CODEX_CLI",
|
|
122
|
+
"CURSOR_TRACE_ID",
|
|
123
|
+
"CURSOR_AGENT",
|
|
124
|
+
"CURSOR_USER_AGENT",
|
|
125
|
+
"TERM_PROGRAM",
|
|
126
|
+
"WINDSURF_AGENT",
|
|
127
|
+
"WINDSURF_SESSION_ID",
|
|
128
|
+
"GEMINI_CLI",
|
|
129
|
+
"GEMINI_SANDBOX",
|
|
130
|
+
"GEMINI_SYSTEM_MD",
|
|
131
|
+
):
|
|
132
|
+
monkeypatch.delenv(var, raising=False)
|
|
133
|
+
|
|
134
|
+
# Reset the module-level cache so the function actually re-runs.
|
|
135
|
+
import controlzero.device as device
|
|
136
|
+
|
|
137
|
+
monkeypatch.setattr(device, "_client_name_cache", None)
|
|
138
|
+
|
|
139
|
+
assert device.detect_client_name() == "python-sdk"
|
|
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
|