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.
Files changed (122) hide show
  1. {controlzero-1.5.0 → controlzero-1.5.2}/CHANGELOG.md +43 -0
  2. {controlzero-1.5.0 → controlzero-1.5.2}/PKG-INFO +1 -1
  3. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/__init__.py +1 -1
  4. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/cli/main.py +16 -7
  5. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/client.py +14 -1
  6. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/device.py +8 -2
  7. {controlzero-1.5.0 → controlzero-1.5.2}/pyproject.toml +1 -1
  8. controlzero-1.5.2/tests/test_api_key_mask.py +59 -0
  9. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_coding_agent_hooks.py +14 -4
  10. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_device.py +8 -4
  11. controlzero-1.5.2/tests/test_install_hook_command.py +139 -0
  12. {controlzero-1.5.0 → controlzero-1.5.2}/.gitignore +0 -0
  13. {controlzero-1.5.0 → controlzero-1.5.2}/Dockerfile.test +0 -0
  14. {controlzero-1.5.0 → controlzero-1.5.2}/LICENSE +0 -0
  15. {controlzero-1.5.0 → controlzero-1.5.2}/README.md +0 -0
  16. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/_internal/__init__.py +0 -0
  17. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/_internal/action_aliases.py +0 -0
  18. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/_internal/bundle.py +0 -0
  19. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/_internal/dlp_scanner.py +0 -0
  20. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/_internal/enforcer.py +0 -0
  21. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/_internal/hook_extractors.py +0 -0
  22. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/_internal/tool_extractors.json +0 -0
  23. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/_internal/types.py +0 -0
  24. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/audit_local.py +0 -0
  25. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/audit_remote.py +0 -0
  26. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/cli/__init__.py +0 -0
  27. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/cli/debug_bundle.py +0 -0
  28. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/cli/templates/autogen.yaml +0 -0
  29. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/cli/templates/claude-code.yaml +0 -0
  30. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/cli/templates/codex-cli.yaml +0 -0
  31. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/cli/templates/cost-cap.yaml +0 -0
  32. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/cli/templates/crewai.yaml +0 -0
  33. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/cli/templates/cursor.yaml +0 -0
  34. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/cli/templates/gemini-cli.yaml +0 -0
  35. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/cli/templates/generic.yaml +0 -0
  36. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/cli/templates/langchain.yaml +0 -0
  37. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/cli/templates/mcp.yaml +0 -0
  38. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/cli/templates/rag.yaml +0 -0
  39. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/enrollment.py +0 -0
  40. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/errors.py +0 -0
  41. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/hosted_policy.py +0 -0
  42. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/__init__.py +0 -0
  43. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/anthropic.py +0 -0
  44. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/autogen.py +0 -0
  45. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/braintrust.py +0 -0
  46. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/crewai/__init__.py +0 -0
  47. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/crewai/agent.py +0 -0
  48. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/crewai/crew.py +0 -0
  49. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/crewai/task.py +0 -0
  50. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/crewai/tool.py +0 -0
  51. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/google.py +0 -0
  52. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/google_adk/__init__.py +0 -0
  53. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/google_adk/agent.py +0 -0
  54. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/google_adk/tool.py +0 -0
  55. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/langchain/__init__.py +0 -0
  56. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/langchain/agent.py +0 -0
  57. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/langchain/callbacks.py +0 -0
  58. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/langchain/chain.py +0 -0
  59. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/langchain/graph.py +0 -0
  60. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/langchain/modern.py +0 -0
  61. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/langchain/tool.py +0 -0
  62. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/langfuse.py +0 -0
  63. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/litellm.py +0 -0
  64. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/openai.py +0 -0
  65. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/pydantic_ai.py +0 -0
  66. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/integrations/vercel_ai.py +0 -0
  67. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/policy_loader.py +0 -0
  68. {controlzero-1.5.0 → controlzero-1.5.2}/controlzero/tamper.py +0 -0
  69. {controlzero-1.5.0 → controlzero-1.5.2}/examples/hello_world.py +0 -0
  70. {controlzero-1.5.0 → controlzero-1.5.2}/tests/conftest.py +0 -0
  71. {controlzero-1.5.0 → controlzero-1.5.2}/tests/integrations/__init__.py +0 -0
  72. {controlzero-1.5.0 → controlzero-1.5.2}/tests/integrations/test_google.py +0 -0
  73. {controlzero-1.5.0 → controlzero-1.5.2}/tests/parity/action_aliases.json +0 -0
  74. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_action_aliases.py +0 -0
  75. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_action_canonicalization.py +0 -0
  76. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_agent_name_env.py +0 -0
  77. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_audit_remote.py +0 -0
  78. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_audit_sink_isolation.py +0 -0
  79. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_bundle_parser.py +0 -0
  80. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_bundle_translate.py +0 -0
  81. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_cli_carve_out.py +0 -0
  82. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_cli_debug_bundle.py +0 -0
  83. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_cli_extractor_integration.py +0 -0
  84. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_cli_hook.py +0 -0
  85. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_cli_hosted_refresh.py +0 -0
  86. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_cli_init.py +0 -0
  87. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_cli_init_templates.py +0 -0
  88. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_cli_tail.py +0 -0
  89. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_cli_test.py +0 -0
  90. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_cli_validate.py +0 -0
  91. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_conditions.py +0 -0
  92. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_default_action.py +0 -0
  93. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_dlp_scanner.py +0 -0
  94. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_enrollment.py +0 -0
  95. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_fail_closed_eval.py +0 -0
  96. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_glob_matching.py +0 -0
  97. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_hook_extractors.py +0 -0
  98. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_hosted_policy_e2e.py +0 -0
  99. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_hybrid_mode_strict.py +0 -0
  100. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_hybrid_mode_warn.py +0 -0
  101. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_install_hooks.py +0 -0
  102. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_local_mode_dict.py +0 -0
  103. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_local_mode_file_json.py +0 -0
  104. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_local_mode_file_yaml.py +0 -0
  105. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_log_fallback_stderr.py +0 -0
  106. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_log_options_ignored_hosted.py +0 -0
  107. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_log_rotation.py +0 -0
  108. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_no_policy_no_key.py +0 -0
  109. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_package_rename_shim.py +0 -0
  110. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_policy_freshness.py +0 -0
  111. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_policy_settings.py +0 -0
  112. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_quarantine.py +0 -0
  113. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_reason_code.py +0 -0
  114. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_refresh.py +0 -0
  115. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_sql_semantic_class.py +0 -0
  116. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_synthetic_policy_id_t79.py +0 -0
  117. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_t103_precedence.py +0 -0
  118. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_t104_cache_gc.py +0 -0
  119. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_t108_local_override_audit.py +0 -0
  120. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_tamper.py +0 -0
  121. {controlzero-1.5.0 → controlzero-1.5.2}/tests/test_tamper_behavior.py +0 -0
  122. {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.0
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
@@ -28,7 +28,7 @@ from controlzero.errors import (
28
28
  )
29
29
  from controlzero.policy_loader import load_policy
30
30
 
31
- __version__ = "1.5.0"
31
+ __version__ = "1.5.2"
32
32
 
33
33
  __all__ = [
34
34
  "Client",
@@ -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[:14])
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
- _client_name_cache = "sdk"
140
- return "sdk"
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.0"
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
- assert "CONTROLZERO_API_KEY=cz_live_testkey123" in hook_cmd
713
- assert "controlzero hook-check" in hook_cmd
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
- assert "CONTROLZERO_API_KEY=cz_test_geminikey" in inner_hooks[0]["command"]
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
- assert "CONTROLZERO_API_KEY=cz_live_codexkey" in inner_hooks[0]["command"]
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 test_default_is_sdk(self):
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 "sdk".
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