controlzero 1.5.4__tar.gz → 1.5.5__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 (150) hide show
  1. {controlzero-1.5.4 → controlzero-1.5.5}/CHANGELOG.md +71 -0
  2. {controlzero-1.5.4 → controlzero-1.5.5}/PKG-INFO +2 -1
  3. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/__init__.py +1 -1
  4. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/_internal/enforcer.py +1 -2
  5. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/_internal/types.py +1 -1
  6. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/audit_remote.py +41 -2
  7. controlzero-1.5.5/controlzero/cli/_secrets.py +135 -0
  8. controlzero-1.5.5/controlzero/cli/console.py +125 -0
  9. controlzero-1.5.5/controlzero/cli/doctor.py +309 -0
  10. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/hosts/base.py +2 -2
  11. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/main.py +189 -19
  12. controlzero-1.5.5/controlzero/cli/migrate.py +200 -0
  13. controlzero-1.5.5/controlzero/cli/telemetry_consent.py +219 -0
  14. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/client.py +17 -2
  15. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/enrollment.py +1 -2
  16. controlzero-1.5.5/controlzero/error_codes.py +415 -0
  17. controlzero-1.5.5/controlzero/errors.py +181 -0
  18. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/braintrust.py +0 -1
  19. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/crewai/agent.py +2 -2
  20. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/crewai/task.py +1 -1
  21. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/crewai/tool.py +2 -2
  22. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/google_adk/agent.py +1 -1
  23. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/langchain/agent.py +7 -4
  24. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/langchain/callbacks.py +1 -1
  25. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/langchain/graph.py +1 -1
  26. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/langchain/tool.py +2 -3
  27. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/langfuse.py +0 -1
  28. controlzero-1.5.5/controlzero/layout_migration.py +85 -0
  29. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/policy_loader.py +1 -1
  30. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/tamper.py +7 -3
  31. {controlzero-1.5.4 → controlzero-1.5.5}/pyproject.toml +6 -1
  32. {controlzero-1.5.4 → controlzero-1.5.5}/tests/conftest.py +0 -2
  33. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_audit_remote.py +8 -12
  34. controlzero-1.5.5/tests/test_audit_remote_sdk_version.py +145 -0
  35. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_bundle_translate.py +0 -1
  36. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_cli_carve_out.py +10 -11
  37. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_cli_debug_bundle.py +0 -2
  38. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_cli_hook.py +1 -1
  39. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_coding_agent_hooks.py +0 -2
  40. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_conditions.py +0 -1
  41. controlzero-1.5.5/tests/test_console.py +87 -0
  42. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_default_action.py +1 -2
  43. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_dlp_scanner.py +1 -3
  44. controlzero-1.5.5/tests/test_doctor.py +150 -0
  45. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_enrollment.py +0 -3
  46. controlzero-1.5.5/tests/test_env_dump_438.py +148 -0
  47. controlzero-1.5.5/tests/test_error_codes.py +64 -0
  48. controlzero-1.5.5/tests/test_errors_e_codes.py +209 -0
  49. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_hosted_policy_e2e.py +0 -2
  50. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_hybrid_mode_warn.py +0 -2
  51. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_install_hooks.py +1 -2
  52. controlzero-1.5.5/tests/test_layout_migration_t101.py +159 -0
  53. controlzero-1.5.5/tests/test_layout_parity_t102.py +229 -0
  54. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_log_fallback_stderr.py +0 -1
  55. controlzero-1.5.5/tests/test_migrate.py +128 -0
  56. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_package_rename_shim.py +0 -5
  57. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_policy_freshness.py +0 -3
  58. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_policy_settings.py +0 -2
  59. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_quarantine.py +1 -2
  60. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_refresh.py +10 -8
  61. controlzero-1.5.5/tests/test_secrets.py +227 -0
  62. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_t103_precedence.py +0 -2
  63. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_t108_local_override_audit.py +0 -1
  64. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_t96_single_audit_log.py +11 -11
  65. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_t99_install_prefetch_bundle.py +0 -1
  66. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_tamper_behavior.py +0 -4
  67. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_tamper_hook.py +0 -2
  68. controlzero-1.5.5/tests/test_telemetry_consent.py +90 -0
  69. controlzero-1.5.4/controlzero/errors.py +0 -85
  70. {controlzero-1.5.4 → controlzero-1.5.5}/.gitignore +0 -0
  71. {controlzero-1.5.4 → controlzero-1.5.5}/Dockerfile.test +0 -0
  72. {controlzero-1.5.4 → controlzero-1.5.5}/LICENSE +0 -0
  73. {controlzero-1.5.4 → controlzero-1.5.5}/README.md +0 -0
  74. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/_internal/__init__.py +0 -0
  75. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/_internal/action_aliases.py +0 -0
  76. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/_internal/bundle.py +0 -0
  77. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/_internal/dlp_scanner.py +0 -0
  78. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/_internal/hook_extractors.py +0 -0
  79. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/_internal/tool_extractors.json +0 -0
  80. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/audit_local.py +0 -0
  81. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/__init__.py +0 -0
  82. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/debug_bundle.py +0 -0
  83. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/hosts/__init__.py +0 -0
  84. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/hosts/claude_code.py +0 -0
  85. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/hosts/codex_cli.py +0 -0
  86. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/hosts/gemini_cli.py +0 -0
  87. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/hosts/unknown.py +0 -0
  88. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/templates/autogen.yaml +0 -0
  89. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/templates/claude-code.yaml +0 -0
  90. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/templates/codex-cli.yaml +0 -0
  91. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/templates/cost-cap.yaml +0 -0
  92. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/templates/crewai.yaml +0 -0
  93. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/templates/cursor.yaml +0 -0
  94. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/templates/gemini-cli.yaml +0 -0
  95. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/templates/generic.yaml +0 -0
  96. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/templates/langchain.yaml +0 -0
  97. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/templates/mcp.yaml +0 -0
  98. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/templates/rag.yaml +0 -0
  99. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/device.py +0 -0
  100. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/hosted_policy.py +0 -0
  101. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/__init__.py +0 -0
  102. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/anthropic.py +0 -0
  103. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/autogen.py +0 -0
  104. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/crewai/__init__.py +0 -0
  105. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/crewai/crew.py +0 -0
  106. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/google.py +0 -0
  107. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/google_adk/__init__.py +0 -0
  108. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/google_adk/tool.py +0 -0
  109. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/langchain/__init__.py +0 -0
  110. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/langchain/chain.py +0 -0
  111. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/langchain/modern.py +0 -0
  112. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/litellm.py +0 -0
  113. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/openai.py +0 -0
  114. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/pydantic_ai.py +0 -0
  115. {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/vercel_ai.py +0 -0
  116. {controlzero-1.5.4 → controlzero-1.5.5}/examples/hello_world.py +0 -0
  117. {controlzero-1.5.4 → controlzero-1.5.5}/tests/integrations/__init__.py +0 -0
  118. {controlzero-1.5.4 → controlzero-1.5.5}/tests/integrations/test_google.py +0 -0
  119. {controlzero-1.5.4 → controlzero-1.5.5}/tests/parity/action_aliases.json +0 -0
  120. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_action_aliases.py +0 -0
  121. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_action_canonicalization.py +0 -0
  122. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_agent_name_env.py +0 -0
  123. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_api_key_mask.py +0 -0
  124. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_audit_sink_isolation.py +0 -0
  125. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_bundle_parser.py +0 -0
  126. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_cli_extractor_integration.py +0 -0
  127. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_cli_hosted_refresh.py +0 -0
  128. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_cli_init.py +0 -0
  129. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_cli_init_templates.py +0 -0
  130. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_cli_tail.py +0 -0
  131. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_cli_test.py +0 -0
  132. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_cli_validate.py +0 -0
  133. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_device.py +0 -0
  134. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_fail_closed_eval.py +0 -0
  135. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_glob_matching.py +0 -0
  136. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_hook_extractors.py +0 -0
  137. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_hosts_adapter.py +0 -0
  138. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_hybrid_mode_strict.py +0 -0
  139. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_install_hook_command.py +0 -0
  140. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_local_mode_dict.py +0 -0
  141. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_local_mode_file_json.py +0 -0
  142. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_local_mode_file_yaml.py +0 -0
  143. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_log_options_ignored_hosted.py +0 -0
  144. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_log_rotation.py +0 -0
  145. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_no_policy_no_key.py +0 -0
  146. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_reason_code.py +0 -0
  147. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_sql_semantic_class.py +0 -0
  148. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_synthetic_policy_id_t79.py +0 -0
  149. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_t104_cache_gc.py +0 -0
  150. {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_tamper.py +0 -0
@@ -1,5 +1,76 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased -- Tier 0a security hotfix (2026-05-15)
4
+
5
+ ### Security (P0 hotfix for #174)
6
+
7
+ - **New `controlzero doctor` command**. Scans every known coding-agent
8
+ settings file (claude-code, gemini-cli, codex-cli, cursor, windsurf,
9
+ vscode, cline, antigravity, adal, jetbrains) for plaintext `cz_*_*`
10
+ API keys baked into hook commands. Reports findings compiler-style
11
+ with stable E#### error codes. Exit 1 on any ERROR finding so it
12
+ can run in CI / pre-push hooks.
13
+ - **New `controlzero migrate` command**. Auto-rewrites every leaked
14
+ inline hook of the form `CONTROLZERO_API_KEY=cz_live_... controlzero
15
+ hook-check` into the safe `controlzero hook-check` form, and
16
+ persists the recovered key to `~/.controlzero/config.yaml`
17
+ (mode 0o600, parent dir 0o700). Has `--dry-run`. Idempotent.
18
+ - **Stable error-code catalog** (`controlzero.error_codes`). 24 E####
19
+ codes across security / auth / policy / cache / network / hook /
20
+ runtime ranges. Each entry has title, what, fix, and a docs slug
21
+ for the `docs.controlzero.ai/errors/` URL.
22
+ - **`controlzero telemetry`** group: `full` / `anonymous` / `off`.
23
+ Opt-IN per the 2026-05-14 transparency mandate. Default unset =
24
+ silent. Non-interactive shells never prompt. State in
25
+ `~/.controlzero/telemetry.yaml` (mode 0o600).
26
+ - **Tighter local-file permissions**. `_write_api_key_config` now
27
+ enforces 0o600 on `config.yaml` and 0o700 on the parent directory.
28
+ Best-effort on Windows / FAT (silent skip).
29
+ - **Cross-SDK CI contract test**: `scripts/ci/check-no-key-leaks.sh`
30
+ fails the build on any new `cz_(live|test)_*` leak surface across
31
+ Python, Node, Go, frontend, docs.
32
+ - **Rich CLI palette**: doctor + migrate now use the DESIGN.md
33
+ sage-green theme via `controlzero.cli.console`.
34
+
35
+ If you previously ran `controlzero install <agent>` on a version
36
+ older than 1.5.3, your agent settings file likely still contains
37
+ the plaintext key. Run `controlzero doctor` to check; run
38
+ `controlzero migrate` to fix.
39
+
40
+ ## 1.5.5a1 (pre-release) -- 2026-05-13
41
+
42
+ This is a **pre-release**. `pip install control-zero` continues to
43
+ resolve to the latest stable (1.5.3). To install this alpha:
44
+
45
+ pip install --pre control-zero
46
+ # or pin explicitly:
47
+ pip install control-zero==1.5.5a1
48
+
49
+ Promotion to stable `1.5.5` will follow once we have soak time on the
50
+ T96 + T101 layout changes in real customer environments.
51
+
52
+ ### Added
53
+
54
+ - **`controlzero env-dump` diagnostic subcommand** (#438). Prints a
55
+ JSON snapshot of the SDK's effective environment: env vars
56
+ (redacted by default, `--show-secrets` to unredact with a loud
57
+ warning), host-adapter selection, file inventory (size + mtime,
58
+ not contents), and resolved API URL. With `--from-hook` it parses
59
+ a stdin payload so the output reflects what the hook subprocess
60
+ actually sees. Built for fast Windows-hook triage after the
61
+ 2026-05-12 CloudShift incident -- a customer can now run a single
62
+ command and send us the JSON instead of guessing which env vars
63
+ their hook sees.
64
+
65
+ - **Layout migration shim** (T101, SDK_LOCAL_LAYOUT spec). Folds any
66
+ legacy `~/.controlzero/events.log` into `audit.log` on first run of
67
+ the CLI or first `Client()` construction, then deletes the legacy
68
+ file. Writes a single `layout_migration` lifecycle marker into
69
+ `audit.log` so support can grep for the migration. Idempotent and
70
+ best-effort: a disk failure here never breaks the user's agent.
71
+ `controlzero status` no longer reads the legacy file; the shim
72
+ guarantees it's gone by the time any subcommand runs.
73
+
3
74
  ## 1.5.4 -- 2026-05-13
4
75
 
5
76
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: controlzero
3
- Version: 1.5.4
3
+ Version: 1.5.5
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,6 +28,7 @@ Requires-Dist: httpx>=0.25.0
28
28
  Requires-Dist: loguru>=0.7.0
29
29
  Requires-Dist: pydantic>=2.0.0
30
30
  Requires-Dist: pyyaml>=6.0
31
+ Requires-Dist: rich>=13.0.0
31
32
  Requires-Dist: zstandard>=0.22.0
32
33
  Provides-Extra: anthropic
33
34
  Requires-Dist: anthropic>=0.25.0; extra == 'anthropic'
@@ -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.4"
31
+ __version__ = "1.5.5"
32
32
 
33
33
  __all__ = [
34
34
  "Client",
@@ -20,10 +20,9 @@ from __future__ import annotations
20
20
 
21
21
  import fnmatch
22
22
  from dataclasses import dataclass, field
23
- from typing import Any, Optional
23
+ from typing import Optional
24
24
 
25
25
  from controlzero._internal.dlp_scanner import (
26
- DLPMatch,
27
26
  DLPScanner,
28
27
  extract_text_from_args,
29
28
  )
@@ -1,6 +1,6 @@
1
1
  """Internal type definitions. Public users should import from controlzero, not here."""
2
2
 
3
- from typing import Any, Optional
3
+ from typing import Any
4
4
  from pydantic import BaseModel, Field
5
5
 
6
6
 
@@ -24,13 +24,44 @@ import json
24
24
  import logging
25
25
  import platform
26
26
  import threading
27
- import time
28
27
  import uuid
29
28
  from datetime import datetime, timezone
29
+ from pathlib import Path
30
30
  from typing import Optional
31
31
 
32
32
  from controlzero.device import detect_client_name, detect_client_version
33
33
 
34
+ # #495/v1 (2026-05-14): the normalized controlzero_sdk_version field
35
+ # carried on every audit POST. Format is "<lang>@<version>" so the
36
+ # backend can group/filter across SDKs without ambiguity (a version
37
+ # string alone like "1.9.1" is shared between Python and Node).
38
+ # Capped at 64 chars so a freak __version__ value cannot pollute the
39
+ # LowCardinality column on the backend.
40
+ #
41
+ # Read via importlib.metadata rather than `from controlzero import
42
+ # __version__`: audit_remote.py is imported transitively from
43
+ # controlzero.client.Client, which is imported at the top of
44
+ # controlzero/__init__.py before __version__ is defined. The naive
45
+ # import triggers a circular-import ImportError on package load.
46
+ # importlib.metadata reads from the installed dist-info and avoids
47
+ # the partial-module problem entirely.
48
+ def _resolve_sdk_version_wire() -> str:
49
+ try:
50
+ from importlib.metadata import version as _pkg_version
51
+ v = _pkg_version("controlzero")
52
+ except Exception:
53
+ # Editable install where the dist-info isn't on the metadata
54
+ # path, or any other import-time failure: fall back to empty
55
+ # so the audit pipeline doesn't carry a wrong value.
56
+ return ""
57
+ wire = "python@" + v
58
+ if len(wire) > 64:
59
+ return ""
60
+ return wire
61
+
62
+
63
+ _CZ_SDK_VERSION_WIRE = _resolve_sdk_version_wire()
64
+
34
65
  logger = logging.getLogger("controlzero.audit_remote")
35
66
 
36
67
  # Buffer limits
@@ -48,7 +79,7 @@ class RemoteAuditSink:
48
79
  machine_token: str, # not used for auth header, kept for future
49
80
  org_id: str,
50
81
  machine_id: str,
51
- state_dir: Optional["Path"] = None,
82
+ state_dir: Optional[Path] = None,
52
83
  ):
53
84
  self._api_url = api_url.rstrip("/")
54
85
  self._machine_token = machine_token
@@ -132,6 +163,10 @@ class RemoteAuditSink:
132
163
  # coding-agent hook-check calls; SDK library integrations
133
164
  # pass an empty string until they adopt the same extractor.
134
165
  "extracted_method": entry.get("extracted_method", ""),
166
+ # v1 (2026-05-14): normalized controlzero SDK package version
167
+ # so support can pinpoint which SDK release made this POST.
168
+ # See top of file for the wire-format invariant.
169
+ "controlzero_sdk_version": _CZ_SDK_VERSION_WIRE,
135
170
  }
136
171
 
137
172
  # ---- flush mechanics ----
@@ -312,6 +347,10 @@ class BearerAuditSink:
312
347
  # coding-agent hook-check calls; SDK library integrations
313
348
  # pass an empty string until they adopt the same extractor.
314
349
  "extracted_method": entry.get("extracted_method", ""),
350
+ # v1 (2026-05-14): same controlzero SDK version wire field
351
+ # as the BearerAuditSink path above. Kept in lockstep so
352
+ # both sinks produce identical row shapes on the backend.
353
+ "controlzero_sdk_version": _CZ_SDK_VERSION_WIRE,
315
354
  }
316
355
 
317
356
  def _flush_async(self) -> None:
@@ -0,0 +1,135 @@
1
+ """API key redaction + leak-detection helpers (Tier 0a hotfix 2026-05-15).
2
+
3
+ Single source of truth for:
4
+
5
+ 1. Detecting `cz_(live|test)_*` patterns in arbitrary text (used by
6
+ `controlzero doctor` to scan agent settings files + the cross-SDK
7
+ contract test that fails the CI on any leak surface).
8
+ 2. Redacting a full key to a last-4 form for display in CLI output
9
+ (`cz_live_***f7d7b22`). Matches the gh CLI / Stripe CLI convention.
10
+
11
+ These helpers MUST stay pure (no I/O, no logging) so the contract test
12
+ that depends on `find_key_leaks` can run in a sandbox without touching
13
+ the user's actual filesystem.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from typing import Iterable, List, NamedTuple
20
+
21
+ # A controlzero API key is 8-char prefix (cz_live_ / cz_test_) + 64 hex
22
+ # characters in production. We also accept shorter trailing alphanumerics
23
+ # so legacy short-form test keys (cz_test_localdev_...) are caught.
24
+ # The pattern is deliberately greedy on the suffix so we catch:
25
+ # cz_live_d003253c33902c264d5a20146a3efc15475de1d1fe9e1efb0ccace0d7f7d7b22
26
+ # cz_test_localdev_000000000000000000000000
27
+ # cz_test_xxxxxxxx (rarer, but valid)
28
+ # but stops at a non-alphanumeric so we don't gobble surrounding markup.
29
+ _KEY_PATTERN = re.compile(r"cz_(live|test)_[A-Za-z0-9_]{4,}")
30
+
31
+
32
+ class KeyMatch(NamedTuple):
33
+ """One match from `find_key_leaks`. The `key` field is the FULL
34
+ matched substring; callers that surface this in customer output
35
+ must run it through `redact_key` first."""
36
+
37
+ start: int
38
+ end: int
39
+ key: str
40
+ line_number: int
41
+
42
+
43
+ def find_key_leaks(text: str) -> List[KeyMatch]:
44
+ """Return every `cz_(live|test)_*` substring in `text`.
45
+
46
+ Used by:
47
+ - `controlzero doctor` to scan ~/.claude/settings.json etc.
48
+ - The cross-SDK contract test to fail the PR on any leak.
49
+ - `controlzero migrate` to identify which entries to rewrite.
50
+
51
+ Pure function. No I/O. Deterministic. Safe to call on arbitrary
52
+ untrusted input (no regex catastrophic backtracking; pattern is
53
+ linear).
54
+ """
55
+ matches: List[KeyMatch] = []
56
+ if not text:
57
+ return matches
58
+ for m in _KEY_PATTERN.finditer(text):
59
+ # Compute the 1-indexed line number so doctor output reads
60
+ # like a compiler error: `foo.json:42:5`. Counts newlines
61
+ # from start of text to the match start.
62
+ line_number = text.count("\n", 0, m.start()) + 1
63
+ matches.append(
64
+ KeyMatch(
65
+ start=m.start(),
66
+ end=m.end(),
67
+ key=m.group(0),
68
+ line_number=line_number,
69
+ )
70
+ )
71
+ return matches
72
+
73
+
74
+ def redact_key(key: str) -> str:
75
+ """Convert `cz_live_d003253c33902c264d5a20146a3efc15475de1d1fe9e1efb0ccace0d7f7d7b22`
76
+ to `cz_live_***d7b22` (last-5 of the secret payload).
77
+
78
+ The prefix (`cz_live_` / `cz_test_`) is preserved so the mode is
79
+ still readable: ops can tell at a glance which key family this
80
+ is. The last 5 hex chars are kept so two different keys are
81
+ distinguishable in support tickets without exposing enough to
82
+ reconstruct the key (5 hex chars = 20 bits = 1M combinations,
83
+ not enough to brute-force back to the full key but enough for a
84
+ human to disambiguate "is this Bryan's key or John's key").
85
+
86
+ Returns the input verbatim if it doesn't match the expected shape,
87
+ so caller-side `print(redact_key(maybe_key))` is always safe.
88
+ """
89
+ stripped = key.strip()
90
+ m = _KEY_PATTERN.fullmatch(stripped)
91
+ if not m:
92
+ return key
93
+ mode = m.group(1) # 'live' or 'test'
94
+ last5 = stripped[-5:] if len(stripped) >= 5 else stripped
95
+ return f"cz_{mode}_***{last5}"
96
+
97
+
98
+ def redact_text(text: str) -> str:
99
+ """Apply `redact_key` to every `cz_(live|test)_*` match inside an
100
+ arbitrary string.
101
+
102
+ Used to scrub log lines, error messages, and stderr before the SDK
103
+ prints them. Cheaper than wiring per-call-site escapes; safer too
104
+ because the redaction is centralized.
105
+ """
106
+
107
+ def _replace(m: re.Match[str]) -> str:
108
+ return redact_key(m.group(0))
109
+
110
+ return _KEY_PATTERN.sub(_replace, text)
111
+
112
+
113
+ # Convenience: scan a list of file paths, return a flat list of
114
+ # (path, KeyMatch) pairs for everything found. doctor + migrate both
115
+ # consume this.
116
+ def scan_files(paths: Iterable[str]) -> List[tuple[str, KeyMatch]]:
117
+ """Open each path, scan for key leaks, return matches with file
118
+ path attached. Missing files / unreadable files are silently
119
+ skipped -- the caller (doctor) handles "did we find the agent
120
+ config at all" via a separate codepath.
121
+ """
122
+ import pathlib
123
+
124
+ out: List[tuple[str, KeyMatch]] = []
125
+ for p in paths:
126
+ path = pathlib.Path(p).expanduser()
127
+ if not path.exists():
128
+ continue
129
+ try:
130
+ content = path.read_text(encoding="utf-8", errors="replace")
131
+ except OSError:
132
+ continue
133
+ for match in find_key_leaks(content):
134
+ out.append((str(path), match))
135
+ return out
@@ -0,0 +1,125 @@
1
+ """Centralized Rich console for the controlzero CLI.
2
+
3
+ Tier 0a hotfix piece 6 (#174 / DESIGN.md): every user-facing CLI
4
+ output goes through this singleton so colors, padding, and spinner
5
+ styles stay consistent across `controlzero doctor`, `controlzero
6
+ migrate`, `controlzero install`, and the install-time consent prompt.
7
+
8
+ Color palette pulled straight from DESIGN.md:
9
+
10
+ - accent: #22c55e sage green (logo, active states, success)
11
+ - success: #22c55e same as accent (allowed, healthy)
12
+ - warning: #eab308 yellow (approaching limits, soft errors)
13
+ - error: #ef4444 red (blocked, critical)
14
+ - text-1: #f0f0f3 primary (headings, values)
15
+ - text-2: #8b8b96 secondary (descriptions)
16
+ - text-3: #7a7a86 tertiary (labels)
17
+ - border: rgba(255,255,255,0.06) (rendered as dim grey in terminal)
18
+
19
+ Use the module-level helpers (`success`, `warn`, `error`, `info`,
20
+ `panel`) rather than constructing rich objects in each command. This
21
+ makes the CLI testable: tests can mock the singleton and assert on
22
+ the rendered strings.
23
+
24
+ If the user has NO_COLOR set, rich's automatic detection kicks in
25
+ and we render plain text -- nothing to do here.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from typing import Optional
31
+
32
+ from rich.console import Console
33
+ from rich.panel import Panel
34
+ from rich.style import Style
35
+ from rich.text import Text
36
+ from rich.theme import Theme
37
+
38
+
39
+ # DESIGN.md color tokens. Names mirror the CSS custom property names
40
+ # so a designer can grep across the repo and find every place a token
41
+ # is consumed.
42
+ _CZ_THEME = Theme({
43
+ "cz.accent": Style(color="#22c55e", bold=False),
44
+ "cz.success": Style(color="#22c55e", bold=True),
45
+ "cz.warning": Style(color="#eab308", bold=True),
46
+ "cz.error": Style(color="#ef4444", bold=True),
47
+ "cz.text1": Style(color="#f0f0f3"),
48
+ "cz.text2": Style(color="#8b8b96"),
49
+ "cz.text3": Style(color="#7a7a86"),
50
+ "cz.text4": Style(color="#5a5a65", dim=True),
51
+ "cz.code": Style(color="#f0f0f3", bgcolor="#222225"),
52
+ # Compound helpers used by panels.
53
+ "cz.heading": Style(color="#f0f0f3", bold=True),
54
+ "cz.dim_border": Style(color="#5a5a65", dim=True),
55
+ })
56
+
57
+
58
+ # Two consoles so callers can route errors to stderr without
59
+ # leaking color escape codes into a redirected stdout.
60
+ _stdout = Console(theme=_CZ_THEME, highlight=False)
61
+ _stderr = Console(theme=_CZ_THEME, highlight=False, stderr=True)
62
+
63
+
64
+ def stdout() -> Console:
65
+ """The shared stdout-bound console. Use this for normal CLI
66
+ output. Tests can monkeypatch this to capture rendered output."""
67
+ return _stdout
68
+
69
+
70
+ def stderr() -> Console:
71
+ """The shared stderr-bound console. Use this for warnings + errors."""
72
+ return _stderr
73
+
74
+
75
+ # -----------------------------------------------------------------------------
76
+ # Helpers -- the public surface. Every CLI command should use these
77
+ # rather than touching the underlying console.
78
+ # -----------------------------------------------------------------------------
79
+
80
+
81
+ def success(message: str) -> None:
82
+ """Bold sage-green check mark + message. Used for happy-path
83
+ completion lines like 'Installed claude-code hook'.
84
+ """
85
+ _stdout.print(Text.assemble(("[OK] ", "cz.success"), (message, "cz.text1")))
86
+
87
+
88
+ def warn(message: str) -> None:
89
+ """Yellow warning. Used for soft errors that don't block the
90
+ operation but the user should know about (e.g. 'shell history
91
+ contains a key')."""
92
+ _stderr.print(Text.assemble(("[WARN] ", "cz.warning"), (message, "cz.text1")))
93
+
94
+
95
+ def error(message: str, code: Optional[str] = None) -> None:
96
+ """Red error. If a stable E#### code is supplied, prepend it so
97
+ the user can search the docs site for the canonical fix."""
98
+ prefix = f"[ERROR {code}] " if code else "[ERROR] "
99
+ _stderr.print(Text.assemble((prefix, "cz.error"), (message, "cz.text1")))
100
+
101
+
102
+ def info(message: str) -> None:
103
+ """Plain informational line, no color. Used for hints + status
104
+ lines that aren't the headline output."""
105
+ _stdout.print(Text(message, style="cz.text2"))
106
+
107
+
108
+ def heading(message: str) -> None:
109
+ """Section heading. Bold, primary text color."""
110
+ _stdout.print(Text(message, style="cz.heading"))
111
+
112
+
113
+ def code(snippet: str) -> Text:
114
+ """Render a short code snippet (filename, command, key) with the
115
+ code style. Returns a Text so callers can compose it into a
116
+ larger renderable."""
117
+ return Text(snippet, style="cz.code")
118
+
119
+
120
+ def panel(content: str, title: str = "", style: str = "cz.dim_border") -> None:
121
+ """Outlined box with optional title. Used for the consent prompt
122
+ + the post-install confirmation summary so they don't drown in
123
+ other terminal output."""
124
+ p = Panel.fit(content, title=title or None, border_style=style)
125
+ _stdout.print(p)