controlzero 1.5.3__tar.gz → 1.5.5a1__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.3 → controlzero-1.5.5a1}/CHANGELOG.md +50 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/PKG-INFO +1 -1
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/__init__.py +1 -1
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/main.py +227 -9
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/client.py +15 -0
- controlzero-1.5.5a1/controlzero/layout_migration.py +85 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/pyproject.toml +1 -1
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_cli_carve_out.py +12 -4
- controlzero-1.5.5a1/tests/test_env_dump_438.py +149 -0
- controlzero-1.5.5a1/tests/test_layout_migration_t101.py +160 -0
- controlzero-1.5.5a1/tests/test_layout_parity_t102.py +230 -0
- controlzero-1.5.5a1/tests/test_t96_single_audit_log.py +127 -0
- controlzero-1.5.5a1/tests/test_t99_install_prefetch_bundle.py +184 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/.gitignore +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/Dockerfile.test +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/LICENSE +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/README.md +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/_internal/__init__.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/_internal/action_aliases.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/_internal/bundle.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/_internal/dlp_scanner.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/_internal/enforcer.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/_internal/hook_extractors.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/_internal/tool_extractors.json +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/_internal/types.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/audit_local.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/audit_remote.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/__init__.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/debug_bundle.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/hosts/__init__.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/hosts/base.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/hosts/claude_code.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/hosts/codex_cli.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/hosts/gemini_cli.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/hosts/unknown.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/templates/autogen.yaml +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/templates/claude-code.yaml +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/templates/codex-cli.yaml +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/templates/cost-cap.yaml +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/templates/crewai.yaml +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/templates/cursor.yaml +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/templates/gemini-cli.yaml +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/templates/generic.yaml +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/templates/langchain.yaml +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/templates/mcp.yaml +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/templates/rag.yaml +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/device.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/enrollment.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/errors.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/hosted_policy.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/__init__.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/anthropic.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/autogen.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/braintrust.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/crewai/__init__.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/crewai/agent.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/crewai/crew.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/crewai/task.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/crewai/tool.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/google.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/google_adk/__init__.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/google_adk/agent.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/google_adk/tool.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/langchain/__init__.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/langchain/agent.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/langchain/callbacks.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/langchain/chain.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/langchain/graph.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/langchain/modern.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/langchain/tool.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/langfuse.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/litellm.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/openai.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/pydantic_ai.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/vercel_ai.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/policy_loader.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/tamper.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/examples/hello_world.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/conftest.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/integrations/__init__.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/integrations/test_google.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/parity/action_aliases.json +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_action_aliases.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_action_canonicalization.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_agent_name_env.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_api_key_mask.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_audit_remote.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_audit_sink_isolation.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_bundle_parser.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_bundle_translate.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_cli_debug_bundle.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_cli_extractor_integration.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_cli_hook.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_cli_hosted_refresh.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_cli_init.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_cli_init_templates.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_cli_tail.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_cli_test.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_cli_validate.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_coding_agent_hooks.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_conditions.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_default_action.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_device.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_dlp_scanner.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_enrollment.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_fail_closed_eval.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_glob_matching.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_hook_extractors.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_hosted_policy_e2e.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_hosts_adapter.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_hybrid_mode_strict.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_hybrid_mode_warn.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_install_hook_command.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_install_hooks.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_local_mode_dict.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_local_mode_file_json.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_local_mode_file_yaml.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_log_fallback_stderr.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_log_options_ignored_hosted.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_log_rotation.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_no_policy_no_key.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_package_rename_shim.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_policy_freshness.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_policy_settings.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_quarantine.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_reason_code.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_refresh.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_sql_semantic_class.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_synthetic_policy_id_t79.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_t103_precedence.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_t104_cache_gc.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_t108_local_override_audit.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_tamper.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_tamper_behavior.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_tamper_hook.py +0 -0
|
@@ -1,5 +1,55 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.5.5a1 (pre-release) -- 2026-05-13
|
|
4
|
+
|
|
5
|
+
This is a **pre-release**. `pip install control-zero` continues to
|
|
6
|
+
resolve to the latest stable (1.5.3). To install this alpha:
|
|
7
|
+
|
|
8
|
+
pip install --pre control-zero
|
|
9
|
+
# or pin explicitly:
|
|
10
|
+
pip install control-zero==1.5.5a1
|
|
11
|
+
|
|
12
|
+
Promotion to stable `1.5.5` will follow once we have soak time on the
|
|
13
|
+
T96 + T101 layout changes in real customer environments.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **`controlzero env-dump` diagnostic subcommand** (#438). Prints a
|
|
18
|
+
JSON snapshot of the SDK's effective environment: env vars
|
|
19
|
+
(redacted by default, `--show-secrets` to unredact with a loud
|
|
20
|
+
warning), host-adapter selection, file inventory (size + mtime,
|
|
21
|
+
not contents), and resolved API URL. With `--from-hook` it parses
|
|
22
|
+
a stdin payload so the output reflects what the hook subprocess
|
|
23
|
+
actually sees. Built for fast Windows-hook triage after the
|
|
24
|
+
2026-05-12 CloudShift incident -- a customer can now run a single
|
|
25
|
+
command and send us the JSON instead of guessing which env vars
|
|
26
|
+
their hook sees.
|
|
27
|
+
|
|
28
|
+
- **Layout migration shim** (T101, SDK_LOCAL_LAYOUT spec). Folds any
|
|
29
|
+
legacy `~/.controlzero/events.log` into `audit.log` on first run of
|
|
30
|
+
the CLI or first `Client()` construction, then deletes the legacy
|
|
31
|
+
file. Writes a single `layout_migration` lifecycle marker into
|
|
32
|
+
`audit.log` so support can grep for the migration. Idempotent and
|
|
33
|
+
best-effort: a disk failure here never breaks the user's agent.
|
|
34
|
+
`controlzero status` no longer reads the legacy file; the shim
|
|
35
|
+
guarantees it's gone by the time any subcommand runs.
|
|
36
|
+
|
|
37
|
+
## 1.5.4 -- 2026-05-13
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
|
|
41
|
+
- **Single-file local audit log** (T96, part of the SDK_LOCAL_LAYOUT
|
|
42
|
+
spec). Pre-1.5.4 the SDK split rows across `~/.controlzero/audit.log`
|
|
43
|
+
(policy-engine decisions) and `~/.controlzero/events.log` (carve-out +
|
|
44
|
+
hook lifecycle). Customers had to tail two files and `cz debug bundle`
|
|
45
|
+
had to merge both for support. After 1.5.4 every line lands in
|
|
46
|
+
`audit.log` (JSON Lines). Lifecycle entries carry the original `event`
|
|
47
|
+
key so `controlzero status` can still filter for carve-out events.
|
|
48
|
+
`controlzero status` reads BOTH `audit.log` AND `events.log` (legacy
|
|
49
|
+
fallback) so users upgrading from <= 1.5.3 keep seeing pre-upgrade
|
|
50
|
+
history; the migration shim in a follow-up release (T101) will rename
|
|
51
|
+
the legacy file in-place.
|
|
52
|
+
|
|
3
53
|
## 1.5.3 -- 2026-05-12
|
|
4
54
|
|
|
5
55
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: controlzero
|
|
3
|
-
Version: 1.5.
|
|
3
|
+
Version: 1.5.5a1
|
|
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
|
|
@@ -11,6 +11,7 @@ import os
|
|
|
11
11
|
import sys
|
|
12
12
|
import threading
|
|
13
13
|
import time
|
|
14
|
+
from datetime import datetime, timezone
|
|
14
15
|
from pathlib import Path
|
|
15
16
|
from typing import Optional
|
|
16
17
|
|
|
@@ -79,6 +80,16 @@ def cli():
|
|
|
79
80
|
controlzero test <tool> Dry-run a tool call against the policy
|
|
80
81
|
controlzero tail Follow the local audit log
|
|
81
82
|
"""
|
|
83
|
+
# T101 layout-migration shim: fold any legacy ~/.controlzero/events.log
|
|
84
|
+
# into audit.log before the subcommand runs so downstream commands
|
|
85
|
+
# see the single-file layout the SDK_LOCAL_LAYOUT spec promises.
|
|
86
|
+
# Idempotent and best-effort: no-op when events.log is absent.
|
|
87
|
+
try:
|
|
88
|
+
from controlzero.layout_migration import run_layout_migration
|
|
89
|
+
run_layout_migration(GLOBAL_POLICY_DIR, sdk_version=__version__)
|
|
90
|
+
except Exception: # noqa: BLE001
|
|
91
|
+
# Migration must never break the CLI.
|
|
92
|
+
pass
|
|
82
93
|
|
|
83
94
|
|
|
84
95
|
@cli.command()
|
|
@@ -350,6 +361,146 @@ def policy_pull_cmd():
|
|
|
350
361
|
click.echo(f" ... and {len(rules) - 10} more")
|
|
351
362
|
|
|
352
363
|
|
|
364
|
+
@cli.command("env-dump")
|
|
365
|
+
@click.option(
|
|
366
|
+
"--show-secrets",
|
|
367
|
+
is_flag=True,
|
|
368
|
+
default=False,
|
|
369
|
+
help="Un-redact API keys and secret-like env vars. Use only when sharing with Control Zero support.",
|
|
370
|
+
)
|
|
371
|
+
@click.option(
|
|
372
|
+
"--from-hook",
|
|
373
|
+
is_flag=True,
|
|
374
|
+
default=False,
|
|
375
|
+
help="Read a hook-style JSON payload from stdin and report which host-agent adapter would claim it.",
|
|
376
|
+
)
|
|
377
|
+
def env_dump_cmd(show_secrets: bool, from_hook: bool):
|
|
378
|
+
"""Print a redacted snapshot of the SDK's effective environment.
|
|
379
|
+
|
|
380
|
+
Designed for support: paste the JSON output into a ticket and the
|
|
381
|
+
Control Zero team can tell you (without round-trips) which host
|
|
382
|
+
adapter the SDK is selecting, which env vars are present, which
|
|
383
|
+
config files exist, and what API URL is in effect.
|
|
384
|
+
|
|
385
|
+
Examples:
|
|
386
|
+
|
|
387
|
+
controlzero env-dump
|
|
388
|
+
controlzero env-dump --from-hook < /tmp/claude-payload.json
|
|
389
|
+
controlzero env-dump --show-secrets # un-redacts; DANGEROUS
|
|
390
|
+
|
|
391
|
+
Filed as #438 follow-up to the 2026-05-12 CloudShift Bug E episode
|
|
392
|
+
where source-detection on Windows was unknown without instrumenting
|
|
393
|
+
the customer's hook environment.
|
|
394
|
+
"""
|
|
395
|
+
import platform as _platform
|
|
396
|
+
import re as _re
|
|
397
|
+
|
|
398
|
+
payload: dict = {}
|
|
399
|
+
if from_hook:
|
|
400
|
+
try:
|
|
401
|
+
raw = sys.stdin.read()
|
|
402
|
+
if raw.strip():
|
|
403
|
+
payload = json.loads(raw)
|
|
404
|
+
except (KeyboardInterrupt, EOFError, json.JSONDecodeError):
|
|
405
|
+
payload = {}
|
|
406
|
+
|
|
407
|
+
# Mask CONTROLZERO_API_KEY and *_TOKEN / *_KEY / *_SECRET env vars
|
|
408
|
+
# unless --show-secrets is passed.
|
|
409
|
+
_secret_pat = _re.compile(r"(TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL)$", _re.IGNORECASE)
|
|
410
|
+
env_view: dict = {}
|
|
411
|
+
for k, v in sorted(os.environ.items()):
|
|
412
|
+
if not isinstance(v, str):
|
|
413
|
+
continue
|
|
414
|
+
# Keep ASCII-printable values, length-capped at 200 chars; non-
|
|
415
|
+
# printable env vars are usually accidental binary export and
|
|
416
|
+
# not useful for debugging.
|
|
417
|
+
if any(ord(c) < 32 or ord(c) > 126 for c in v):
|
|
418
|
+
redacted = f"<NON-PRINTABLE-LEN-{len(v)}>"
|
|
419
|
+
elif show_secrets:
|
|
420
|
+
redacted = v
|
|
421
|
+
elif k == "CONTROLZERO_API_KEY":
|
|
422
|
+
# Always preserve the public prefix so "is it a live or
|
|
423
|
+
# test key" is observable without leaking entropy.
|
|
424
|
+
if v.startswith("cz_live_"):
|
|
425
|
+
redacted = "cz_live_***"
|
|
426
|
+
elif v.startswith("cz_test_"):
|
|
427
|
+
redacted = "cz_test_***"
|
|
428
|
+
else:
|
|
429
|
+
redacted = "***"
|
|
430
|
+
elif _secret_pat.search(k):
|
|
431
|
+
redacted = f"<REDACTED-LEN-{len(v)}>"
|
|
432
|
+
else:
|
|
433
|
+
redacted = v if len(v) <= 200 else (v[:200] + f"...<+{len(v) - 200}>")
|
|
434
|
+
env_view[k] = redacted
|
|
435
|
+
|
|
436
|
+
# Host adapter selection.
|
|
437
|
+
try:
|
|
438
|
+
from controlzero.cli.hosts import select_adapter as _select_adapter
|
|
439
|
+
adapter = _select_adapter(payload, os.environ)
|
|
440
|
+
adapter_info = {
|
|
441
|
+
"name": adapter.name,
|
|
442
|
+
"canonical_source": adapter.canonical_source,
|
|
443
|
+
}
|
|
444
|
+
except Exception as exc: # noqa: BLE001
|
|
445
|
+
adapter_info = {"name": "unknown", "error": str(exc)}
|
|
446
|
+
|
|
447
|
+
# Config + log file presence (size + mtime, NOT contents).
|
|
448
|
+
files = {}
|
|
449
|
+
for path in (
|
|
450
|
+
GLOBAL_POLICY_DIR / "config.yaml",
|
|
451
|
+
GLOBAL_POLICY_DIR / "policy.yaml",
|
|
452
|
+
GLOBAL_POLICY_DIR / "audit.log",
|
|
453
|
+
GLOBAL_POLICY_DIR / "events.log", # legacy, pre-1.5.4
|
|
454
|
+
GLOBAL_POLICY_DIR / "enrollment.json",
|
|
455
|
+
Path.home() / ".claude" / "settings.json",
|
|
456
|
+
Path.home() / ".gemini" / "settings.json",
|
|
457
|
+
Path.home() / ".codex" / "hooks.json",
|
|
458
|
+
):
|
|
459
|
+
try:
|
|
460
|
+
if path.exists():
|
|
461
|
+
stat = path.stat()
|
|
462
|
+
files[str(path)] = {
|
|
463
|
+
"exists": True,
|
|
464
|
+
"size_bytes": stat.st_size,
|
|
465
|
+
"mtime_iso": datetime.fromtimestamp(
|
|
466
|
+
stat.st_mtime, tz=timezone.utc
|
|
467
|
+
).isoformat(),
|
|
468
|
+
}
|
|
469
|
+
else:
|
|
470
|
+
files[str(path)] = {"exists": False}
|
|
471
|
+
except Exception as exc: # noqa: BLE001
|
|
472
|
+
files[str(path)] = {"exists": "error", "error": str(exc)}
|
|
473
|
+
|
|
474
|
+
# Resolved hosted API URL.
|
|
475
|
+
api_url = os.environ.get("CONTROLZERO_API_URL", "https://api.controlzero.ai")
|
|
476
|
+
|
|
477
|
+
from controlzero import __version__ as _cz_version
|
|
478
|
+
|
|
479
|
+
dump = {
|
|
480
|
+
"controlzero_version": _cz_version,
|
|
481
|
+
"hostname": _platform.node(),
|
|
482
|
+
"system": _platform.system(),
|
|
483
|
+
"release": _platform.release(),
|
|
484
|
+
"machine": _platform.machine(),
|
|
485
|
+
"python_version": _platform.python_version(),
|
|
486
|
+
"python_executable": sys.executable,
|
|
487
|
+
"api_url": api_url,
|
|
488
|
+
"adapter": adapter_info,
|
|
489
|
+
"from_hook_payload_keys": sorted(payload.keys()) if payload else [],
|
|
490
|
+
"files": files,
|
|
491
|
+
"env": env_view,
|
|
492
|
+
"show_secrets": show_secrets,
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if show_secrets:
|
|
496
|
+
click.echo(
|
|
497
|
+
"WARNING: env-dump was run with --show-secrets. The output below contains unredacted API keys, tokens, and credentials.",
|
|
498
|
+
err=True,
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
click.echo(json.dumps(dump, indent=2, sort_keys=True))
|
|
502
|
+
|
|
503
|
+
|
|
353
504
|
@cli.command("status")
|
|
354
505
|
def status_cmd():
|
|
355
506
|
"""Show enrollment state, policy source, and recent carve-out events.
|
|
@@ -401,9 +552,13 @@ def status_cmd():
|
|
|
401
552
|
|
|
402
553
|
# Summarize recent carve-out events so the user can see whether
|
|
403
554
|
# their agent has been allowed-through without policy.
|
|
404
|
-
|
|
555
|
+
# 1.5.5 (T101): the layout-migration shim runs in cli() before
|
|
556
|
+
# this subcommand, so any legacy events.log content has already
|
|
557
|
+
# been folded into audit.log by the time we get here. Only
|
|
558
|
+
# audit.log is read.
|
|
559
|
+
if GLOBAL_AUDIT_PATH.exists():
|
|
405
560
|
try:
|
|
406
|
-
lines =
|
|
561
|
+
lines = GLOBAL_AUDIT_PATH.read_text(encoding="utf-8").splitlines()
|
|
407
562
|
carve_out_events = [
|
|
408
563
|
json.loads(l) for l in lines
|
|
409
564
|
if l.strip() and "unenrolled_first_run_allow" in l
|
|
@@ -984,16 +1139,28 @@ def _resolve_default_on_missing() -> str:
|
|
|
984
1139
|
|
|
985
1140
|
|
|
986
1141
|
def _append_event(event: dict) -> None:
|
|
987
|
-
"""Append a structured event to ~/.controlzero/
|
|
988
|
-
|
|
989
|
-
Used
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
1142
|
+
"""Append a structured lifecycle event to ~/.controlzero/audit.log.
|
|
1143
|
+
|
|
1144
|
+
Used for the unenrolled-first-run carve-out (so `controlzero
|
|
1145
|
+
status` can report whether the hook is allowing through without
|
|
1146
|
+
policy), the T108 LOCAL_OVERRIDE governance event, and any
|
|
1147
|
+
future hook-lifecycle event we want to surface to support.
|
|
1148
|
+
|
|
1149
|
+
1.5.4 (T96 -- SDK_LOCAL_LAYOUT spec): the SDK now writes ONE
|
|
1150
|
+
JSONL file at ``~/.controlzero/audit.log``. Pre-1.5.4 the SDK
|
|
1151
|
+
split rows across ``audit.log`` (decisions) and ``events.log``
|
|
1152
|
+
(lifecycle). Customers had to tail two files; ``cz debug
|
|
1153
|
+
bundle`` had to read two paths. The single-file layout
|
|
1154
|
+
standardises everything.
|
|
1155
|
+
|
|
1156
|
+
Decision rows are written by the BearerAuditSink / local
|
|
1157
|
+
AuditLogger (in JSON Lines too); we use an explicit
|
|
1158
|
+
``event_type`` field on lifecycle entries so a reader can
|
|
1159
|
+
filter (decisions land without that key).
|
|
993
1160
|
"""
|
|
994
1161
|
try:
|
|
995
1162
|
GLOBAL_POLICY_DIR.mkdir(parents=True, exist_ok=True)
|
|
996
|
-
with
|
|
1163
|
+
with GLOBAL_AUDIT_PATH.open("a", encoding="utf-8") as f:
|
|
997
1164
|
f.write(json.dumps(event) + "\n")
|
|
998
1165
|
except Exception: # noqa: BLE001
|
|
999
1166
|
# Best-effort. Logging failure must not propagate.
|
|
@@ -1446,6 +1613,54 @@ def _write_api_key_config(api_key: str) -> None:
|
|
|
1446
1613
|
)
|
|
1447
1614
|
|
|
1448
1615
|
|
|
1616
|
+
def _prefetch_bundle(api_key: str) -> None:
|
|
1617
|
+
"""Best-effort pre-warm of the hosted policy bundle at install time.
|
|
1618
|
+
|
|
1619
|
+
Called by `controlzero install <agent> --api-key cz_...`. Doing the
|
|
1620
|
+
bundle pull now (a) surfaces an obvious install-time error if the
|
|
1621
|
+
api_key is wrong or the network is dead, instead of failing
|
|
1622
|
+
silently on the first hook call; and (b) eliminates the cold-start
|
|
1623
|
+
spike Claude Code customers see on their first PreToolUse hook
|
|
1624
|
+
after install.
|
|
1625
|
+
|
|
1626
|
+
Never raises -- a failed pre-fetch must not block the install. If
|
|
1627
|
+
the pull fails, the first hook call will retry per the normal
|
|
1628
|
+
hosted-mode path; the user has already gotten an installer warning
|
|
1629
|
+
so they know to investigate.
|
|
1630
|
+
"""
|
|
1631
|
+
try:
|
|
1632
|
+
from controlzero.hosted_policy import load_hosted_policy
|
|
1633
|
+
_local_source, parsed = load_hosted_policy(api_key)
|
|
1634
|
+
rule_count = 0
|
|
1635
|
+
try:
|
|
1636
|
+
rule_count = len((parsed or {}).get("rules", []) or [])
|
|
1637
|
+
except Exception: # noqa: BLE001
|
|
1638
|
+
rule_count = 0
|
|
1639
|
+
click.echo(
|
|
1640
|
+
f" Pre-fetched hosted policy ({rule_count} rule"
|
|
1641
|
+
f"{'s' if rule_count != 1 else ''}). First tool call will be fast."
|
|
1642
|
+
)
|
|
1643
|
+
except Exception as exc: # noqa: BLE001
|
|
1644
|
+
# Common failures: auth (HostedAuthError -> wrong api_key),
|
|
1645
|
+
# network (HostedBootstrapError -> backend unreachable), or
|
|
1646
|
+
# tamper (bundle signature mismatch). Surface ALL of them in
|
|
1647
|
+
# the installer output so the user sees the problem now, not
|
|
1648
|
+
# later. We do not exit non-zero: install completes; first
|
|
1649
|
+
# hook call will retry and the user can re-run install once
|
|
1650
|
+
# they fix the env.
|
|
1651
|
+
click.echo("")
|
|
1652
|
+
click.echo(
|
|
1653
|
+
f" WARNING: Could not pre-fetch hosted policy: {exc}",
|
|
1654
|
+
err=True,
|
|
1655
|
+
)
|
|
1656
|
+
click.echo(
|
|
1657
|
+
" The install is complete. The SDK will retry on the "
|
|
1658
|
+
"first tool call. If this keeps failing, double-check the "
|
|
1659
|
+
"api_key + network reachability to https://api.controlzero.ai.",
|
|
1660
|
+
err=True,
|
|
1661
|
+
)
|
|
1662
|
+
|
|
1663
|
+
|
|
1449
1664
|
def _print_api_key_status(api_key: Optional[str]) -> None:
|
|
1450
1665
|
"""Print API key configuration status after install."""
|
|
1451
1666
|
if api_key:
|
|
@@ -1502,6 +1717,7 @@ def install_claude_code(force: bool, merge: bool, settings: Optional[str], api_k
|
|
|
1502
1717
|
)
|
|
1503
1718
|
sys.exit(1)
|
|
1504
1719
|
_write_api_key_config(api_key)
|
|
1720
|
+
_prefetch_bundle(api_key)
|
|
1505
1721
|
|
|
1506
1722
|
template_path = TEMPLATE_DIR / "claude-code.yaml"
|
|
1507
1723
|
if not template_path.exists():
|
|
@@ -1657,6 +1873,7 @@ def install_gemini_cli(force: bool, merge: bool, settings: Optional[str], api_ke
|
|
|
1657
1873
|
)
|
|
1658
1874
|
sys.exit(1)
|
|
1659
1875
|
_write_api_key_config(api_key)
|
|
1876
|
+
_prefetch_bundle(api_key)
|
|
1660
1877
|
|
|
1661
1878
|
template_path = TEMPLATE_DIR / "gemini-cli.yaml"
|
|
1662
1879
|
if not template_path.exists():
|
|
@@ -1825,6 +2042,7 @@ def install_codex_cli(force: bool, merge: bool, config: Optional[str], api_key:
|
|
|
1825
2042
|
)
|
|
1826
2043
|
sys.exit(1)
|
|
1827
2044
|
_write_api_key_config(api_key)
|
|
2045
|
+
_prefetch_bundle(api_key)
|
|
1828
2046
|
|
|
1829
2047
|
template_path = TEMPLATE_DIR / "codex-cli.yaml"
|
|
1830
2048
|
if not template_path.exists():
|
|
@@ -127,6 +127,21 @@ class Client:
|
|
|
127
127
|
"Pass either `policy` or `policy_file`, not both."
|
|
128
128
|
)
|
|
129
129
|
|
|
130
|
+
# T101 layout-migration shim: fold any legacy
|
|
131
|
+
# ~/.controlzero/events.log into audit.log on every Client
|
|
132
|
+
# construction. Idempotent and best-effort: no-op when the
|
|
133
|
+
# legacy file is absent. Mirrors the CLI shim.
|
|
134
|
+
try:
|
|
135
|
+
from controlzero import __version__ as _cz_version
|
|
136
|
+
from controlzero.layout_migration import run_layout_migration
|
|
137
|
+
_cz_home = Path(
|
|
138
|
+
os.environ.get("CONTROLZERO_HOME") or (Path.home() / ".controlzero")
|
|
139
|
+
)
|
|
140
|
+
run_layout_migration(_cz_home, sdk_version=_cz_version)
|
|
141
|
+
except Exception: # noqa: BLE001
|
|
142
|
+
# Migration must never break the user's agent.
|
|
143
|
+
pass
|
|
144
|
+
|
|
130
145
|
# Resolve API key from arg or env
|
|
131
146
|
self._api_key = api_key or os.environ.get("CONTROLZERO_API_KEY")
|
|
132
147
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""SDK_LOCAL_LAYOUT migration shim (T101).
|
|
2
|
+
|
|
3
|
+
Pre-1.5.4 the Python SDK split CLI lifecycle events into a separate
|
|
4
|
+
``~/.controlzero/events.log`` file. T96 (1.5.4) collapsed everything
|
|
5
|
+
into the single ``audit.log`` JSONL file the SDK_LOCAL_LAYOUT spec
|
|
6
|
+
mandates. This shim handles existing customer disks: if ``events.log``
|
|
7
|
+
is still present, fold its lines into ``audit.log`` (preserving order),
|
|
8
|
+
write a lifecycle marker so support can see the migration happened,
|
|
9
|
+
then delete ``events.log``.
|
|
10
|
+
|
|
11
|
+
Idempotent: no-op when ``events.log`` is absent. Safe to call on every
|
|
12
|
+
CLI invocation and every Client construction. Never raises -- a disk
|
|
13
|
+
failure here must not break the user's agent.
|
|
14
|
+
|
|
15
|
+
The Node and Go SDKs ship the same shim under the same contract so a
|
|
16
|
+
customer who copies ``~/.controlzero/`` from a Python install to a
|
|
17
|
+
Node-only or Go-only install gets the same automatic cleanup.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
from datetime import datetime, timezone
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def run_layout_migration(state_dir: Path, sdk_version: str = "unknown") -> int:
|
|
28
|
+
"""Fold legacy ``events.log`` into ``audit.log`` and delete it.
|
|
29
|
+
|
|
30
|
+
Returns the number of lines migrated. 0 means no migration was
|
|
31
|
+
needed (either ``events.log`` didn't exist or it was empty).
|
|
32
|
+
|
|
33
|
+
Never raises. Any IOError or decode error is swallowed so the
|
|
34
|
+
SDK keeps working even if the migration fails.
|
|
35
|
+
"""
|
|
36
|
+
try:
|
|
37
|
+
events_path = state_dir / "events.log"
|
|
38
|
+
audit_path = state_dir / "audit.log"
|
|
39
|
+
|
|
40
|
+
if not events_path.exists():
|
|
41
|
+
return 0
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
raw = events_path.read_text(encoding="utf-8")
|
|
45
|
+
except OSError:
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
lines = [l for l in raw.splitlines() if l.strip()]
|
|
49
|
+
|
|
50
|
+
# Ensure the directory exists before we write the marker.
|
|
51
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
|
|
53
|
+
# Append the migrated lines to audit.log preserving order, then
|
|
54
|
+
# write a single lifecycle marker so support can grep
|
|
55
|
+
# `"layout_migration"` and see when this customer's disk was
|
|
56
|
+
# upgraded. Marker goes last so the migrated lines keep their
|
|
57
|
+
# original chronological position relative to the rest of the
|
|
58
|
+
# file.
|
|
59
|
+
with audit_path.open("a", encoding="utf-8") as f:
|
|
60
|
+
for line in lines:
|
|
61
|
+
f.write(line + "\n")
|
|
62
|
+
marker = {
|
|
63
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
64
|
+
"kind": "lifecycle",
|
|
65
|
+
"event_type": "layout_migration",
|
|
66
|
+
"sdk": "python",
|
|
67
|
+
"sdk_version": sdk_version,
|
|
68
|
+
"from": "events.log",
|
|
69
|
+
"to": "audit.log",
|
|
70
|
+
"lines_migrated": len(lines),
|
|
71
|
+
}
|
|
72
|
+
f.write(json.dumps(marker) + "\n")
|
|
73
|
+
|
|
74
|
+
# Delete the legacy file. If the unlink fails (permissions,
|
|
75
|
+
# busy file on Windows), leave it -- the next run will retry.
|
|
76
|
+
try:
|
|
77
|
+
events_path.unlink()
|
|
78
|
+
except OSError:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
return len(lines)
|
|
82
|
+
except Exception:
|
|
83
|
+
# Last-resort guard. Migration is best-effort; never break the
|
|
84
|
+
# agent because of it.
|
|
85
|
+
return 0
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "controlzero"
|
|
7
|
-
version = "1.5.
|
|
7
|
+
version = "1.5.5a1"
|
|
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"}
|
|
@@ -85,17 +85,25 @@ def test_carve_out_no_api_key_no_enrollment_allows(tmp_path, monkeypatch):
|
|
|
85
85
|
|
|
86
86
|
|
|
87
87
|
def test_carve_out_logs_event_for_status(tmp_path, monkeypatch):
|
|
88
|
-
"""Carve-out runs must append a JSON line to
|
|
88
|
+
"""Carve-out runs must append a JSON line to the audit log so
|
|
89
89
|
`controlzero status` can report the count.
|
|
90
|
+
|
|
91
|
+
1.5.4 (T96 / SDK_LOCAL_LAYOUT): lifecycle events now land in
|
|
92
|
+
audit.log alongside decisions, not in a separate events.log.
|
|
90
93
|
"""
|
|
91
94
|
cli_main = _reload_cli(tmp_path, monkeypatch)
|
|
92
95
|
runner = CliRunner()
|
|
93
96
|
payload = json.dumps({"tool_name": "Bash", "tool_input": {}})
|
|
94
97
|
runner.invoke(cli_main.cli, ["hook-check"], input=payload)
|
|
95
98
|
|
|
96
|
-
|
|
97
|
-
assert
|
|
98
|
-
|
|
99
|
+
audit_path = cli_main.GLOBAL_AUDIT_PATH
|
|
100
|
+
assert audit_path.exists(), (
|
|
101
|
+
"T96: lifecycle events must land in audit.log after 1.5.4"
|
|
102
|
+
)
|
|
103
|
+
lines = [
|
|
104
|
+
l for l in audit_path.read_text().splitlines()
|
|
105
|
+
if l.strip() and "unenrolled_first_run_allow" in l
|
|
106
|
+
]
|
|
99
107
|
assert len(lines) == 1
|
|
100
108
|
ev = json.loads(lines[0])
|
|
101
109
|
assert ev["event"] == "unenrolled_first_run_allow"
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Regression tests for `controlzero env-dump` (#438).
|
|
2
|
+
|
|
3
|
+
Filed as a follow-up to the 2026-05-12 CloudShift Bug E episode where
|
|
4
|
+
source-detection on Windows took multiple rounds because we had no way
|
|
5
|
+
to ask the customer "what env vars does YOUR hook subprocess see?"
|
|
6
|
+
|
|
7
|
+
The subcommand prints a JSON snapshot of the SDK's effective
|
|
8
|
+
environment: env vars (redacted by default), host-adapter selection,
|
|
9
|
+
file inventory, API URL. \`--from-hook\` reads a stdin payload so the
|
|
10
|
+
output reflects what the hook subprocess would resolve.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
from click.testing import CliRunner
|
|
19
|
+
|
|
20
|
+
from controlzero.cli.main import cli
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _invoke(args, env=None, input=None):
|
|
24
|
+
# CliRunner version-portable: the `mix_stderr=False` parameter was
|
|
25
|
+
# removed in newer click releases. Tests instead use
|
|
26
|
+
# _extract_json() below to slice the JSON payload out of the
|
|
27
|
+
# combined output, ignoring any WARNING preamble from
|
|
28
|
+
# --show-secrets.
|
|
29
|
+
runner = CliRunner(env=env)
|
|
30
|
+
return runner.invoke(cli, ["env-dump", *args], input=input)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _extract_json(output: str) -> dict:
|
|
34
|
+
"""Slice the JSON object out of mixed stdout/stderr output.
|
|
35
|
+
|
|
36
|
+
The env-dump JSON always starts with `{` on its own line and ends
|
|
37
|
+
with `}` on its own line; any WARNING prefix from --show-secrets
|
|
38
|
+
lives above. We grab from the first `{` to the last `}`.
|
|
39
|
+
"""
|
|
40
|
+
start = output.find("{")
|
|
41
|
+
end = output.rfind("}")
|
|
42
|
+
if start < 0 or end < 0 or end < start:
|
|
43
|
+
raise AssertionError(f"no JSON envelope found in output: {output!r}")
|
|
44
|
+
return json.loads(output[start : end + 1])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_env_dump_outputs_valid_json(monkeypatch):
|
|
48
|
+
monkeypatch.delenv("CONTROLZERO_CLIENT", raising=False)
|
|
49
|
+
result = _invoke([])
|
|
50
|
+
assert result.exit_code == 0, result.output
|
|
51
|
+
data = _extract_json(result.output)
|
|
52
|
+
assert "controlzero_version" in data
|
|
53
|
+
assert "hostname" in data
|
|
54
|
+
assert "api_url" in data
|
|
55
|
+
assert data["api_url"].startswith("http")
|
|
56
|
+
assert "adapter" in data
|
|
57
|
+
assert data["adapter"]["name"] in (
|
|
58
|
+
"claude_code",
|
|
59
|
+
"gemini_cli",
|
|
60
|
+
"codex_cli",
|
|
61
|
+
"unknown",
|
|
62
|
+
)
|
|
63
|
+
assert "env" in data
|
|
64
|
+
assert data["show_secrets"] is False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_env_dump_redacts_api_key_by_default(monkeypatch):
|
|
68
|
+
monkeypatch.setenv(
|
|
69
|
+
"CONTROLZERO_API_KEY",
|
|
70
|
+
"cz_live_7ebef6b600015e3eaeda9149bf6d9c29a3a2a7a3075209112afde20888280de0",
|
|
71
|
+
)
|
|
72
|
+
result = _invoke([])
|
|
73
|
+
assert result.exit_code == 0
|
|
74
|
+
data = _extract_json(result.output)
|
|
75
|
+
masked = data["env"]["CONTROLZERO_API_KEY"]
|
|
76
|
+
assert masked == "cz_live_***"
|
|
77
|
+
# Defensive: the secret bytes MUST NOT appear anywhere in the dump.
|
|
78
|
+
secret_tail = "7ebef6b600015e3eaeda9149bf6d9c29a"
|
|
79
|
+
assert secret_tail not in result.output
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_env_dump_redacts_test_key_prefix(monkeypatch):
|
|
83
|
+
monkeypatch.setenv("CONTROLZERO_API_KEY", "cz_test_abcdef1234567890")
|
|
84
|
+
result = _invoke([])
|
|
85
|
+
data = _extract_json(result.output)
|
|
86
|
+
assert data["env"]["CONTROLZERO_API_KEY"] == "cz_test_***"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_env_dump_redacts_generic_secret_envvars(monkeypatch):
|
|
90
|
+
monkeypatch.setenv("MY_SERVICE_TOKEN", "super-secret-value")
|
|
91
|
+
monkeypatch.setenv("DB_PASSWORD", "abc123")
|
|
92
|
+
monkeypatch.setenv("STRIPE_API_KEY", "sk_live_supersecret")
|
|
93
|
+
result = _invoke([])
|
|
94
|
+
data = _extract_json(result.output)
|
|
95
|
+
for k in ("MY_SERVICE_TOKEN", "DB_PASSWORD", "STRIPE_API_KEY"):
|
|
96
|
+
v = data["env"][k]
|
|
97
|
+
assert v.startswith("<REDACTED-LEN"), f"{k} not redacted: {v!r}"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_env_dump_show_secrets_unredacts(monkeypatch):
|
|
101
|
+
monkeypatch.setenv("MY_SERVICE_TOKEN", "super-secret-value")
|
|
102
|
+
result = _invoke(["--show-secrets"])
|
|
103
|
+
data = _extract_json(result.output)
|
|
104
|
+
assert data["env"]["MY_SERVICE_TOKEN"] == "super-secret-value"
|
|
105
|
+
assert data["show_secrets"] is True
|
|
106
|
+
# Always-on warning when secrets are shown -- lives in stderr,
|
|
107
|
+
# NOT stdout (so a downstream `jq` pipe still works).
|
|
108
|
+
# WARNING goes to stderr but CliRunner mixes streams; the JSON
|
|
109
|
+
# extractor strips it from the parsed data, but it still shows
|
|
110
|
+
# in the combined `result.output` string.
|
|
111
|
+
assert "WARNING" in result.output
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_env_dump_from_hook_picks_right_adapter(monkeypatch):
|
|
115
|
+
monkeypatch.delenv("CONTROLZERO_CLIENT", raising=False)
|
|
116
|
+
monkeypatch.delenv("CLAUDECODE", raising=False)
|
|
117
|
+
|
|
118
|
+
# Claude Code's stdin signature:
|
|
119
|
+
payload = json.dumps({
|
|
120
|
+
"session_id": "01HX9Z6R3W6XAMPLE",
|
|
121
|
+
"transcript_path": "/Users/x/.claude/transcripts/abc.jsonl",
|
|
122
|
+
"cwd": "/Users/x/project",
|
|
123
|
+
"tool_name": "Write",
|
|
124
|
+
"tool_input": {"file_path": "/tmp/maruthi"},
|
|
125
|
+
"hook_event_name": "PreToolUse",
|
|
126
|
+
})
|
|
127
|
+
result = _invoke(["--from-hook"], input=payload)
|
|
128
|
+
assert result.exit_code == 0
|
|
129
|
+
data = _extract_json(result.output)
|
|
130
|
+
assert data["adapter"]["name"] == "claude_code"
|
|
131
|
+
assert "session_id" in data["from_hook_payload_keys"]
|
|
132
|
+
assert "tool_input" in data["from_hook_payload_keys"]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_env_dump_lists_settings_files_without_revealing_contents(tmp_path, monkeypatch):
|
|
136
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
137
|
+
claude_settings = tmp_path / ".claude" / "settings.json"
|
|
138
|
+
claude_settings.parent.mkdir(parents=True)
|
|
139
|
+
claude_settings.write_text('{"hooks":{}}')
|
|
140
|
+
|
|
141
|
+
result = _invoke([])
|
|
142
|
+
data = _extract_json(result.output)
|
|
143
|
+
entry = data["files"].get(str(claude_settings))
|
|
144
|
+
assert entry is not None
|
|
145
|
+
assert entry["exists"] is True
|
|
146
|
+
assert entry["size_bytes"] > 0
|
|
147
|
+
assert "mtime_iso" in entry
|
|
148
|
+
# File CONTENTS must NOT be in the dump.
|
|
149
|
+
assert '"hooks":{}' not in result.output
|