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.
Files changed (135) hide show
  1. {controlzero-1.5.3 → controlzero-1.5.5a1}/CHANGELOG.md +50 -0
  2. {controlzero-1.5.3 → controlzero-1.5.5a1}/PKG-INFO +1 -1
  3. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/__init__.py +1 -1
  4. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/main.py +227 -9
  5. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/client.py +15 -0
  6. controlzero-1.5.5a1/controlzero/layout_migration.py +85 -0
  7. {controlzero-1.5.3 → controlzero-1.5.5a1}/pyproject.toml +1 -1
  8. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_cli_carve_out.py +12 -4
  9. controlzero-1.5.5a1/tests/test_env_dump_438.py +149 -0
  10. controlzero-1.5.5a1/tests/test_layout_migration_t101.py +160 -0
  11. controlzero-1.5.5a1/tests/test_layout_parity_t102.py +230 -0
  12. controlzero-1.5.5a1/tests/test_t96_single_audit_log.py +127 -0
  13. controlzero-1.5.5a1/tests/test_t99_install_prefetch_bundle.py +184 -0
  14. {controlzero-1.5.3 → controlzero-1.5.5a1}/.gitignore +0 -0
  15. {controlzero-1.5.3 → controlzero-1.5.5a1}/Dockerfile.test +0 -0
  16. {controlzero-1.5.3 → controlzero-1.5.5a1}/LICENSE +0 -0
  17. {controlzero-1.5.3 → controlzero-1.5.5a1}/README.md +0 -0
  18. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/_internal/__init__.py +0 -0
  19. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/_internal/action_aliases.py +0 -0
  20. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/_internal/bundle.py +0 -0
  21. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/_internal/dlp_scanner.py +0 -0
  22. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/_internal/enforcer.py +0 -0
  23. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/_internal/hook_extractors.py +0 -0
  24. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/_internal/tool_extractors.json +0 -0
  25. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/_internal/types.py +0 -0
  26. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/audit_local.py +0 -0
  27. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/audit_remote.py +0 -0
  28. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/__init__.py +0 -0
  29. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/debug_bundle.py +0 -0
  30. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/hosts/__init__.py +0 -0
  31. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/hosts/base.py +0 -0
  32. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/hosts/claude_code.py +0 -0
  33. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/hosts/codex_cli.py +0 -0
  34. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/hosts/gemini_cli.py +0 -0
  35. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/hosts/unknown.py +0 -0
  36. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/templates/autogen.yaml +0 -0
  37. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/templates/claude-code.yaml +0 -0
  38. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/templates/codex-cli.yaml +0 -0
  39. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/templates/cost-cap.yaml +0 -0
  40. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/templates/crewai.yaml +0 -0
  41. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/templates/cursor.yaml +0 -0
  42. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/templates/gemini-cli.yaml +0 -0
  43. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/templates/generic.yaml +0 -0
  44. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/templates/langchain.yaml +0 -0
  45. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/templates/mcp.yaml +0 -0
  46. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/cli/templates/rag.yaml +0 -0
  47. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/device.py +0 -0
  48. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/enrollment.py +0 -0
  49. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/errors.py +0 -0
  50. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/hosted_policy.py +0 -0
  51. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/__init__.py +0 -0
  52. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/anthropic.py +0 -0
  53. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/autogen.py +0 -0
  54. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/braintrust.py +0 -0
  55. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/crewai/__init__.py +0 -0
  56. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/crewai/agent.py +0 -0
  57. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/crewai/crew.py +0 -0
  58. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/crewai/task.py +0 -0
  59. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/crewai/tool.py +0 -0
  60. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/google.py +0 -0
  61. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/google_adk/__init__.py +0 -0
  62. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/google_adk/agent.py +0 -0
  63. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/google_adk/tool.py +0 -0
  64. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/langchain/__init__.py +0 -0
  65. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/langchain/agent.py +0 -0
  66. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/langchain/callbacks.py +0 -0
  67. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/langchain/chain.py +0 -0
  68. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/langchain/graph.py +0 -0
  69. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/langchain/modern.py +0 -0
  70. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/langchain/tool.py +0 -0
  71. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/langfuse.py +0 -0
  72. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/litellm.py +0 -0
  73. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/openai.py +0 -0
  74. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/pydantic_ai.py +0 -0
  75. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/integrations/vercel_ai.py +0 -0
  76. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/policy_loader.py +0 -0
  77. {controlzero-1.5.3 → controlzero-1.5.5a1}/controlzero/tamper.py +0 -0
  78. {controlzero-1.5.3 → controlzero-1.5.5a1}/examples/hello_world.py +0 -0
  79. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/conftest.py +0 -0
  80. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/integrations/__init__.py +0 -0
  81. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/integrations/test_google.py +0 -0
  82. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/parity/action_aliases.json +0 -0
  83. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_action_aliases.py +0 -0
  84. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_action_canonicalization.py +0 -0
  85. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_agent_name_env.py +0 -0
  86. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_api_key_mask.py +0 -0
  87. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_audit_remote.py +0 -0
  88. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_audit_sink_isolation.py +0 -0
  89. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_bundle_parser.py +0 -0
  90. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_bundle_translate.py +0 -0
  91. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_cli_debug_bundle.py +0 -0
  92. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_cli_extractor_integration.py +0 -0
  93. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_cli_hook.py +0 -0
  94. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_cli_hosted_refresh.py +0 -0
  95. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_cli_init.py +0 -0
  96. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_cli_init_templates.py +0 -0
  97. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_cli_tail.py +0 -0
  98. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_cli_test.py +0 -0
  99. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_cli_validate.py +0 -0
  100. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_coding_agent_hooks.py +0 -0
  101. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_conditions.py +0 -0
  102. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_default_action.py +0 -0
  103. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_device.py +0 -0
  104. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_dlp_scanner.py +0 -0
  105. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_enrollment.py +0 -0
  106. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_fail_closed_eval.py +0 -0
  107. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_glob_matching.py +0 -0
  108. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_hook_extractors.py +0 -0
  109. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_hosted_policy_e2e.py +0 -0
  110. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_hosts_adapter.py +0 -0
  111. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_hybrid_mode_strict.py +0 -0
  112. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_hybrid_mode_warn.py +0 -0
  113. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_install_hook_command.py +0 -0
  114. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_install_hooks.py +0 -0
  115. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_local_mode_dict.py +0 -0
  116. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_local_mode_file_json.py +0 -0
  117. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_local_mode_file_yaml.py +0 -0
  118. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_log_fallback_stderr.py +0 -0
  119. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_log_options_ignored_hosted.py +0 -0
  120. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_log_rotation.py +0 -0
  121. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_no_policy_no_key.py +0 -0
  122. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_package_rename_shim.py +0 -0
  123. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_policy_freshness.py +0 -0
  124. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_policy_settings.py +0 -0
  125. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_quarantine.py +0 -0
  126. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_reason_code.py +0 -0
  127. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_refresh.py +0 -0
  128. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_sql_semantic_class.py +0 -0
  129. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_synthetic_policy_id_t79.py +0 -0
  130. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_t103_precedence.py +0 -0
  131. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_t104_cache_gc.py +0 -0
  132. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_t108_local_override_audit.py +0 -0
  133. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_tamper.py +0 -0
  134. {controlzero-1.5.3 → controlzero-1.5.5a1}/tests/test_tamper_behavior.py +0 -0
  135. {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
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
@@ -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.3"
31
+ __version__ = "1.5.5a1"
32
32
 
33
33
  __all__ = [
34
34
  "Client",
@@ -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
- if GLOBAL_EVENTS_PATH.exists():
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 = GLOBAL_EVENTS_PATH.read_text(encoding="utf-8").splitlines()
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/events.log.
988
-
989
- Used today for the unenrolled-first-run carve-out so
990
- `controlzero status` can report whether the hook is currently
991
- running in allow-all mode. Append-only JSON lines. Never raises
992
- -- a disk failure here must not break the agent.
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 GLOBAL_EVENTS_PATH.open("a", encoding="utf-8") as f:
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.3"
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 events.log so
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
- events_path = cli_main.GLOBAL_EVENTS_PATH
97
- assert events_path.exists()
98
- lines = events_path.read_text().splitlines()
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