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.
- {controlzero-1.5.4 → controlzero-1.5.5}/CHANGELOG.md +71 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/PKG-INFO +2 -1
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/__init__.py +1 -1
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/_internal/enforcer.py +1 -2
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/_internal/types.py +1 -1
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/audit_remote.py +41 -2
- controlzero-1.5.5/controlzero/cli/_secrets.py +135 -0
- controlzero-1.5.5/controlzero/cli/console.py +125 -0
- controlzero-1.5.5/controlzero/cli/doctor.py +309 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/hosts/base.py +2 -2
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/main.py +189 -19
- controlzero-1.5.5/controlzero/cli/migrate.py +200 -0
- controlzero-1.5.5/controlzero/cli/telemetry_consent.py +219 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/client.py +17 -2
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/enrollment.py +1 -2
- controlzero-1.5.5/controlzero/error_codes.py +415 -0
- controlzero-1.5.5/controlzero/errors.py +181 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/braintrust.py +0 -1
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/crewai/agent.py +2 -2
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/crewai/task.py +1 -1
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/crewai/tool.py +2 -2
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/google_adk/agent.py +1 -1
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/langchain/agent.py +7 -4
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/langchain/callbacks.py +1 -1
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/langchain/graph.py +1 -1
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/langchain/tool.py +2 -3
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/langfuse.py +0 -1
- controlzero-1.5.5/controlzero/layout_migration.py +85 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/policy_loader.py +1 -1
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/tamper.py +7 -3
- {controlzero-1.5.4 → controlzero-1.5.5}/pyproject.toml +6 -1
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/conftest.py +0 -2
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_audit_remote.py +8 -12
- controlzero-1.5.5/tests/test_audit_remote_sdk_version.py +145 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_bundle_translate.py +0 -1
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_cli_carve_out.py +10 -11
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_cli_debug_bundle.py +0 -2
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_cli_hook.py +1 -1
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_coding_agent_hooks.py +0 -2
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_conditions.py +0 -1
- controlzero-1.5.5/tests/test_console.py +87 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_default_action.py +1 -2
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_dlp_scanner.py +1 -3
- controlzero-1.5.5/tests/test_doctor.py +150 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_enrollment.py +0 -3
- controlzero-1.5.5/tests/test_env_dump_438.py +148 -0
- controlzero-1.5.5/tests/test_error_codes.py +64 -0
- controlzero-1.5.5/tests/test_errors_e_codes.py +209 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_hosted_policy_e2e.py +0 -2
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_hybrid_mode_warn.py +0 -2
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_install_hooks.py +1 -2
- controlzero-1.5.5/tests/test_layout_migration_t101.py +159 -0
- controlzero-1.5.5/tests/test_layout_parity_t102.py +229 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_log_fallback_stderr.py +0 -1
- controlzero-1.5.5/tests/test_migrate.py +128 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_package_rename_shim.py +0 -5
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_policy_freshness.py +0 -3
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_policy_settings.py +0 -2
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_quarantine.py +1 -2
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_refresh.py +10 -8
- controlzero-1.5.5/tests/test_secrets.py +227 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_t103_precedence.py +0 -2
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_t108_local_override_audit.py +0 -1
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_t96_single_audit_log.py +11 -11
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_t99_install_prefetch_bundle.py +0 -1
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_tamper_behavior.py +0 -4
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_tamper_hook.py +0 -2
- controlzero-1.5.5/tests/test_telemetry_consent.py +90 -0
- controlzero-1.5.4/controlzero/errors.py +0 -85
- {controlzero-1.5.4 → controlzero-1.5.5}/.gitignore +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/Dockerfile.test +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/LICENSE +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/README.md +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/_internal/__init__.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/_internal/action_aliases.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/_internal/bundle.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/_internal/dlp_scanner.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/_internal/hook_extractors.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/_internal/tool_extractors.json +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/audit_local.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/__init__.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/debug_bundle.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/hosts/__init__.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/hosts/claude_code.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/hosts/codex_cli.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/hosts/gemini_cli.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/hosts/unknown.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/templates/autogen.yaml +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/templates/claude-code.yaml +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/templates/codex-cli.yaml +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/templates/cost-cap.yaml +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/templates/crewai.yaml +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/templates/cursor.yaml +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/templates/gemini-cli.yaml +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/templates/generic.yaml +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/templates/langchain.yaml +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/templates/mcp.yaml +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/cli/templates/rag.yaml +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/device.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/hosted_policy.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/__init__.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/anthropic.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/autogen.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/crewai/__init__.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/crewai/crew.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/google.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/google_adk/__init__.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/google_adk/tool.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/langchain/__init__.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/langchain/chain.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/langchain/modern.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/litellm.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/openai.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/pydantic_ai.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/controlzero/integrations/vercel_ai.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/examples/hello_world.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/integrations/__init__.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/integrations/test_google.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/parity/action_aliases.json +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_action_aliases.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_action_canonicalization.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_agent_name_env.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_api_key_mask.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_audit_sink_isolation.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_bundle_parser.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_cli_extractor_integration.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_cli_hosted_refresh.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_cli_init.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_cli_init_templates.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_cli_tail.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_cli_test.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_cli_validate.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_device.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_fail_closed_eval.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_glob_matching.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_hook_extractors.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_hosts_adapter.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_hybrid_mode_strict.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_install_hook_command.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_local_mode_dict.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_local_mode_file_json.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_local_mode_file_yaml.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_log_options_ignored_hosted.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_log_rotation.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_no_policy_no_key.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_reason_code.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_sql_semantic_class.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_synthetic_policy_id_t79.py +0 -0
- {controlzero-1.5.4 → controlzero-1.5.5}/tests/test_t104_cache_gc.py +0 -0
- {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.
|
|
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'
|
|
@@ -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
|
|
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
|
)
|
|
@@ -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[
|
|
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)
|