controlzero 1.5.3__tar.gz → 1.5.4__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 (131) hide show
  1. {controlzero-1.5.3 → controlzero-1.5.4}/CHANGELOG.md +16 -0
  2. {controlzero-1.5.3 → controlzero-1.5.4}/PKG-INFO +1 -1
  3. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/__init__.py +1 -1
  4. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/main.py +84 -8
  5. {controlzero-1.5.3 → controlzero-1.5.4}/pyproject.toml +1 -1
  6. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_cli_carve_out.py +12 -4
  7. controlzero-1.5.4/tests/test_t96_single_audit_log.py +126 -0
  8. controlzero-1.5.4/tests/test_t99_install_prefetch_bundle.py +184 -0
  9. {controlzero-1.5.3 → controlzero-1.5.4}/.gitignore +0 -0
  10. {controlzero-1.5.3 → controlzero-1.5.4}/Dockerfile.test +0 -0
  11. {controlzero-1.5.3 → controlzero-1.5.4}/LICENSE +0 -0
  12. {controlzero-1.5.3 → controlzero-1.5.4}/README.md +0 -0
  13. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/_internal/__init__.py +0 -0
  14. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/_internal/action_aliases.py +0 -0
  15. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/_internal/bundle.py +0 -0
  16. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/_internal/dlp_scanner.py +0 -0
  17. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/_internal/enforcer.py +0 -0
  18. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/_internal/hook_extractors.py +0 -0
  19. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/_internal/tool_extractors.json +0 -0
  20. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/_internal/types.py +0 -0
  21. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/audit_local.py +0 -0
  22. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/audit_remote.py +0 -0
  23. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/__init__.py +0 -0
  24. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/debug_bundle.py +0 -0
  25. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/hosts/__init__.py +0 -0
  26. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/hosts/base.py +0 -0
  27. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/hosts/claude_code.py +0 -0
  28. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/hosts/codex_cli.py +0 -0
  29. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/hosts/gemini_cli.py +0 -0
  30. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/hosts/unknown.py +0 -0
  31. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/templates/autogen.yaml +0 -0
  32. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/templates/claude-code.yaml +0 -0
  33. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/templates/codex-cli.yaml +0 -0
  34. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/templates/cost-cap.yaml +0 -0
  35. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/templates/crewai.yaml +0 -0
  36. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/templates/cursor.yaml +0 -0
  37. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/templates/gemini-cli.yaml +0 -0
  38. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/templates/generic.yaml +0 -0
  39. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/templates/langchain.yaml +0 -0
  40. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/templates/mcp.yaml +0 -0
  41. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/templates/rag.yaml +0 -0
  42. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/client.py +0 -0
  43. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/device.py +0 -0
  44. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/enrollment.py +0 -0
  45. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/errors.py +0 -0
  46. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/hosted_policy.py +0 -0
  47. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/__init__.py +0 -0
  48. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/anthropic.py +0 -0
  49. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/autogen.py +0 -0
  50. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/braintrust.py +0 -0
  51. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/crewai/__init__.py +0 -0
  52. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/crewai/agent.py +0 -0
  53. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/crewai/crew.py +0 -0
  54. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/crewai/task.py +0 -0
  55. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/crewai/tool.py +0 -0
  56. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/google.py +0 -0
  57. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/google_adk/__init__.py +0 -0
  58. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/google_adk/agent.py +0 -0
  59. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/google_adk/tool.py +0 -0
  60. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/langchain/__init__.py +0 -0
  61. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/langchain/agent.py +0 -0
  62. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/langchain/callbacks.py +0 -0
  63. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/langchain/chain.py +0 -0
  64. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/langchain/graph.py +0 -0
  65. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/langchain/modern.py +0 -0
  66. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/langchain/tool.py +0 -0
  67. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/langfuse.py +0 -0
  68. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/litellm.py +0 -0
  69. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/openai.py +0 -0
  70. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/pydantic_ai.py +0 -0
  71. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/vercel_ai.py +0 -0
  72. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/policy_loader.py +0 -0
  73. {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/tamper.py +0 -0
  74. {controlzero-1.5.3 → controlzero-1.5.4}/examples/hello_world.py +0 -0
  75. {controlzero-1.5.3 → controlzero-1.5.4}/tests/conftest.py +0 -0
  76. {controlzero-1.5.3 → controlzero-1.5.4}/tests/integrations/__init__.py +0 -0
  77. {controlzero-1.5.3 → controlzero-1.5.4}/tests/integrations/test_google.py +0 -0
  78. {controlzero-1.5.3 → controlzero-1.5.4}/tests/parity/action_aliases.json +0 -0
  79. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_action_aliases.py +0 -0
  80. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_action_canonicalization.py +0 -0
  81. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_agent_name_env.py +0 -0
  82. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_api_key_mask.py +0 -0
  83. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_audit_remote.py +0 -0
  84. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_audit_sink_isolation.py +0 -0
  85. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_bundle_parser.py +0 -0
  86. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_bundle_translate.py +0 -0
  87. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_cli_debug_bundle.py +0 -0
  88. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_cli_extractor_integration.py +0 -0
  89. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_cli_hook.py +0 -0
  90. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_cli_hosted_refresh.py +0 -0
  91. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_cli_init.py +0 -0
  92. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_cli_init_templates.py +0 -0
  93. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_cli_tail.py +0 -0
  94. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_cli_test.py +0 -0
  95. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_cli_validate.py +0 -0
  96. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_coding_agent_hooks.py +0 -0
  97. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_conditions.py +0 -0
  98. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_default_action.py +0 -0
  99. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_device.py +0 -0
  100. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_dlp_scanner.py +0 -0
  101. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_enrollment.py +0 -0
  102. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_fail_closed_eval.py +0 -0
  103. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_glob_matching.py +0 -0
  104. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_hook_extractors.py +0 -0
  105. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_hosted_policy_e2e.py +0 -0
  106. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_hosts_adapter.py +0 -0
  107. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_hybrid_mode_strict.py +0 -0
  108. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_hybrid_mode_warn.py +0 -0
  109. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_install_hook_command.py +0 -0
  110. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_install_hooks.py +0 -0
  111. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_local_mode_dict.py +0 -0
  112. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_local_mode_file_json.py +0 -0
  113. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_local_mode_file_yaml.py +0 -0
  114. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_log_fallback_stderr.py +0 -0
  115. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_log_options_ignored_hosted.py +0 -0
  116. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_log_rotation.py +0 -0
  117. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_no_policy_no_key.py +0 -0
  118. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_package_rename_shim.py +0 -0
  119. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_policy_freshness.py +0 -0
  120. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_policy_settings.py +0 -0
  121. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_quarantine.py +0 -0
  122. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_reason_code.py +0 -0
  123. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_refresh.py +0 -0
  124. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_sql_semantic_class.py +0 -0
  125. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_synthetic_policy_id_t79.py +0 -0
  126. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_t103_precedence.py +0 -0
  127. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_t104_cache_gc.py +0 -0
  128. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_t108_local_override_audit.py +0 -0
  129. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_tamper.py +0 -0
  130. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_tamper_behavior.py +0 -0
  131. {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_tamper_hook.py +0 -0
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.4 -- 2026-05-13
4
+
5
+ ### Changed
6
+
7
+ - **Single-file local audit log** (T96, part of the SDK_LOCAL_LAYOUT
8
+ spec). Pre-1.5.4 the SDK split rows across `~/.controlzero/audit.log`
9
+ (policy-engine decisions) and `~/.controlzero/events.log` (carve-out +
10
+ hook lifecycle). Customers had to tail two files and `cz debug bundle`
11
+ had to merge both for support. After 1.5.4 every line lands in
12
+ `audit.log` (JSON Lines). Lifecycle entries carry the original `event`
13
+ key so `controlzero status` can still filter for carve-out events.
14
+ `controlzero status` reads BOTH `audit.log` AND `events.log` (legacy
15
+ fallback) so users upgrading from <= 1.5.3 keep seeing pre-upgrade
16
+ history; the migration shim in a follow-up release (T101) will rename
17
+ the legacy file in-place.
18
+
3
19
  ## 1.5.3 -- 2026-05-12
4
20
 
5
21
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: controlzero
3
- Version: 1.5.3
3
+ Version: 1.5.4
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.4"
32
32
 
33
33
  __all__ = [
34
34
  "Client",
@@ -401,9 +401,22 @@ def status_cmd():
401
401
 
402
402
  # Summarize recent carve-out events so the user can see whether
403
403
  # their agent has been allowed-through without policy.
404
+ # 1.5.4 (T96): lifecycle events now land in audit.log alongside
405
+ # decisions. For users who upgraded from <=1.5.3, we ALSO
406
+ # read the legacy events.log so the status summary keeps
407
+ # showing pre-upgrade carve-out history. The migration shim
408
+ # (T101) renames events.log -> audit.log in a follow-up; this
409
+ # read-both behaviour is the one-release grace window.
410
+ _sources = []
411
+ if GLOBAL_AUDIT_PATH.exists():
412
+ _sources.append(GLOBAL_AUDIT_PATH)
404
413
  if GLOBAL_EVENTS_PATH.exists():
414
+ _sources.append(GLOBAL_EVENTS_PATH)
415
+ if _sources:
405
416
  try:
406
- lines = GLOBAL_EVENTS_PATH.read_text(encoding="utf-8").splitlines()
417
+ lines: list[str] = []
418
+ for _src in _sources:
419
+ lines.extend(_src.read_text(encoding="utf-8").splitlines())
407
420
  carve_out_events = [
408
421
  json.loads(l) for l in lines
409
422
  if l.strip() and "unenrolled_first_run_allow" in l
@@ -984,16 +997,28 @@ def _resolve_default_on_missing() -> str:
984
997
 
985
998
 
986
999
  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.
1000
+ """Append a structured lifecycle event to ~/.controlzero/audit.log.
1001
+
1002
+ Used for the unenrolled-first-run carve-out (so `controlzero
1003
+ status` can report whether the hook is allowing through without
1004
+ policy), the T108 LOCAL_OVERRIDE governance event, and any
1005
+ future hook-lifecycle event we want to surface to support.
1006
+
1007
+ 1.5.4 (T96 -- SDK_LOCAL_LAYOUT spec): the SDK now writes ONE
1008
+ JSONL file at ``~/.controlzero/audit.log``. Pre-1.5.4 the SDK
1009
+ split rows across ``audit.log`` (decisions) and ``events.log``
1010
+ (lifecycle). Customers had to tail two files; ``cz debug
1011
+ bundle`` had to read two paths. The single-file layout
1012
+ standardises everything.
1013
+
1014
+ Decision rows are written by the BearerAuditSink / local
1015
+ AuditLogger (in JSON Lines too); we use an explicit
1016
+ ``event_type`` field on lifecycle entries so a reader can
1017
+ filter (decisions land without that key).
993
1018
  """
994
1019
  try:
995
1020
  GLOBAL_POLICY_DIR.mkdir(parents=True, exist_ok=True)
996
- with GLOBAL_EVENTS_PATH.open("a", encoding="utf-8") as f:
1021
+ with GLOBAL_AUDIT_PATH.open("a", encoding="utf-8") as f:
997
1022
  f.write(json.dumps(event) + "\n")
998
1023
  except Exception: # noqa: BLE001
999
1024
  # Best-effort. Logging failure must not propagate.
@@ -1446,6 +1471,54 @@ def _write_api_key_config(api_key: str) -> None:
1446
1471
  )
1447
1472
 
1448
1473
 
1474
+ def _prefetch_bundle(api_key: str) -> None:
1475
+ """Best-effort pre-warm of the hosted policy bundle at install time.
1476
+
1477
+ Called by `controlzero install <agent> --api-key cz_...`. Doing the
1478
+ bundle pull now (a) surfaces an obvious install-time error if the
1479
+ api_key is wrong or the network is dead, instead of failing
1480
+ silently on the first hook call; and (b) eliminates the cold-start
1481
+ spike Claude Code customers see on their first PreToolUse hook
1482
+ after install.
1483
+
1484
+ Never raises -- a failed pre-fetch must not block the install. If
1485
+ the pull fails, the first hook call will retry per the normal
1486
+ hosted-mode path; the user has already gotten an installer warning
1487
+ so they know to investigate.
1488
+ """
1489
+ try:
1490
+ from controlzero.hosted_policy import load_hosted_policy
1491
+ _local_source, parsed = load_hosted_policy(api_key)
1492
+ rule_count = 0
1493
+ try:
1494
+ rule_count = len((parsed or {}).get("rules", []) or [])
1495
+ except Exception: # noqa: BLE001
1496
+ rule_count = 0
1497
+ click.echo(
1498
+ f" Pre-fetched hosted policy ({rule_count} rule"
1499
+ f"{'s' if rule_count != 1 else ''}). First tool call will be fast."
1500
+ )
1501
+ except Exception as exc: # noqa: BLE001
1502
+ # Common failures: auth (HostedAuthError -> wrong api_key),
1503
+ # network (HostedBootstrapError -> backend unreachable), or
1504
+ # tamper (bundle signature mismatch). Surface ALL of them in
1505
+ # the installer output so the user sees the problem now, not
1506
+ # later. We do not exit non-zero: install completes; first
1507
+ # hook call will retry and the user can re-run install once
1508
+ # they fix the env.
1509
+ click.echo("")
1510
+ click.echo(
1511
+ f" WARNING: Could not pre-fetch hosted policy: {exc}",
1512
+ err=True,
1513
+ )
1514
+ click.echo(
1515
+ " The install is complete. The SDK will retry on the "
1516
+ "first tool call. If this keeps failing, double-check the "
1517
+ "api_key + network reachability to https://api.controlzero.ai.",
1518
+ err=True,
1519
+ )
1520
+
1521
+
1449
1522
  def _print_api_key_status(api_key: Optional[str]) -> None:
1450
1523
  """Print API key configuration status after install."""
1451
1524
  if api_key:
@@ -1502,6 +1575,7 @@ def install_claude_code(force: bool, merge: bool, settings: Optional[str], api_k
1502
1575
  )
1503
1576
  sys.exit(1)
1504
1577
  _write_api_key_config(api_key)
1578
+ _prefetch_bundle(api_key)
1505
1579
 
1506
1580
  template_path = TEMPLATE_DIR / "claude-code.yaml"
1507
1581
  if not template_path.exists():
@@ -1657,6 +1731,7 @@ def install_gemini_cli(force: bool, merge: bool, settings: Optional[str], api_ke
1657
1731
  )
1658
1732
  sys.exit(1)
1659
1733
  _write_api_key_config(api_key)
1734
+ _prefetch_bundle(api_key)
1660
1735
 
1661
1736
  template_path = TEMPLATE_DIR / "gemini-cli.yaml"
1662
1737
  if not template_path.exists():
@@ -1825,6 +1900,7 @@ def install_codex_cli(force: bool, merge: bool, config: Optional[str], api_key:
1825
1900
  )
1826
1901
  sys.exit(1)
1827
1902
  _write_api_key_config(api_key)
1903
+ _prefetch_bundle(api_key)
1828
1904
 
1829
1905
  template_path = TEMPLATE_DIR / "codex-cli.yaml"
1830
1906
  if not template_path.exists():
@@ -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.4"
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,126 @@
1
+ """Regression tests for T96 -- single-file local audit log (1.5.4).
2
+
3
+ Before 1.5.4 the SDK wrote decision rows to ``~/.controlzero/audit.log``
4
+ and lifecycle events (carve-out, hook errors) to a separate
5
+ ``~/.controlzero/events.log``. Two log paths confused customers and
6
+ ``cz debug bundle`` had to merge both.
7
+
8
+ After 1.5.4 ``_append_event()`` writes to ``audit.log`` directly.
9
+ ``controlzero status`` still reads BOTH files (legacy events.log
10
+ fallback) so users upgrading from <= 1.5.3 keep seeing pre-upgrade
11
+ carve-out history; the migration shim (T101) renames the legacy file
12
+ in a follow-up release.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+
19
+ import pytest
20
+
21
+ import controlzero.cli.main as cli_main
22
+
23
+
24
+ def test_append_event_writes_to_audit_log_not_events_log(tmp_path, monkeypatch):
25
+ audit_path = tmp_path / "audit.log"
26
+ events_path = tmp_path / "events.log"
27
+ monkeypatch.setattr(cli_main, "GLOBAL_POLICY_DIR", tmp_path)
28
+ monkeypatch.setattr(cli_main, "GLOBAL_AUDIT_PATH", audit_path)
29
+ monkeypatch.setattr(cli_main, "GLOBAL_EVENTS_PATH", events_path)
30
+
31
+ cli_main._append_event({
32
+ "event": "unenrolled_first_run_allow",
33
+ "tool_name": "Bash",
34
+ "reason_code": "RULE_MATCH",
35
+ "ts": 1715000000.0,
36
+ })
37
+
38
+ # T96 invariant: writes land in audit.log, NOT events.log.
39
+ assert audit_path.exists(), "audit.log should exist after _append_event"
40
+ assert not events_path.exists(), (
41
+ "events.log MUST NOT be created by 1.5.4 -- T96 collapsed the "
42
+ "two log files into one"
43
+ )
44
+
45
+ lines = audit_path.read_text().splitlines()
46
+ assert len(lines) == 1
47
+ parsed = json.loads(lines[0])
48
+ assert parsed["event"] == "unenrolled_first_run_allow"
49
+ assert parsed["tool_name"] == "Bash"
50
+
51
+
52
+ def test_status_reads_carve_out_events_from_audit_log(tmp_path, monkeypatch):
53
+ # Status pulls carve-out lifecycle events out of audit.log. Pre-1.5.4
54
+ # they lived in events.log; the status command now sees them in
55
+ # audit.log because _append_event writes there.
56
+ audit_path = tmp_path / "audit.log"
57
+ events_path = tmp_path / "events.log"
58
+ monkeypatch.setattr(cli_main, "GLOBAL_POLICY_DIR", tmp_path)
59
+ monkeypatch.setattr(cli_main, "GLOBAL_AUDIT_PATH", audit_path)
60
+ monkeypatch.setattr(cli_main, "GLOBAL_EVENTS_PATH", events_path)
61
+
62
+ # Mix decision rows + lifecycle rows in audit.log (the post-1.5.4
63
+ # reality). Status must filter to the carve-out rows only.
64
+ audit_path.write_text("\n".join([
65
+ json.dumps({"decision": "allow", "tool": "Read", "ts": 1.0}),
66
+ json.dumps({"event": "unenrolled_first_run_allow", "tool_name": "Bash", "ts": 2.0}),
67
+ json.dumps({"decision": "deny", "tool": "Write", "ts": 3.0}),
68
+ json.dumps({"event": "unenrolled_first_run_allow", "tool_name": "Edit", "ts": 4.0}),
69
+ ]) + "\n")
70
+
71
+ from click.testing import CliRunner
72
+
73
+ runner = CliRunner()
74
+ result = runner.invoke(cli_main.cli, ["status"])
75
+ assert result.exit_code == 0
76
+ assert "carve-out allows: 2" in result.output
77
+
78
+
79
+ def test_status_falls_back_to_legacy_events_log_for_pre_1_5_4_history(
80
+ tmp_path, monkeypatch
81
+ ):
82
+ # The migration grace window: users upgrading from <= 1.5.3 have
83
+ # carve-out history in events.log. status_cmd reads both files so
84
+ # the pre-upgrade summary keeps showing until the migration shim
85
+ # (T101) renames the file.
86
+ audit_path = tmp_path / "audit.log"
87
+ events_path = tmp_path / "events.log"
88
+ monkeypatch.setattr(cli_main, "GLOBAL_POLICY_DIR", tmp_path)
89
+ monkeypatch.setattr(cli_main, "GLOBAL_AUDIT_PATH", audit_path)
90
+ monkeypatch.setattr(cli_main, "GLOBAL_EVENTS_PATH", events_path)
91
+
92
+ # Pre-1.5.4 history lives in events.log:
93
+ events_path.write_text(
94
+ json.dumps(
95
+ {"event": "unenrolled_first_run_allow", "tool_name": "LegacyBash", "ts": 1.0}
96
+ )
97
+ + "\n"
98
+ )
99
+ # Post-upgrade row lands in audit.log:
100
+ audit_path.write_text(
101
+ json.dumps(
102
+ {"event": "unenrolled_first_run_allow", "tool_name": "Bash", "ts": 2.0}
103
+ )
104
+ + "\n"
105
+ )
106
+
107
+ from click.testing import CliRunner
108
+
109
+ runner = CliRunner()
110
+ result = runner.invoke(cli_main.cli, ["status"])
111
+ assert result.exit_code == 0
112
+ # Both rows surface in the summary count.
113
+ assert "carve-out allows: 2" in result.output
114
+
115
+
116
+ def test_append_event_silently_swallows_disk_errors(tmp_path, monkeypatch):
117
+ # The "never brick the agent" guarantee: if the audit.log open
118
+ # fails (read-only volume, full disk, perm denied), _append_event
119
+ # must NOT raise.
120
+ bad_path = tmp_path / "definitely-not-writable" / "audit.log"
121
+ monkeypatch.setattr(cli_main, "GLOBAL_POLICY_DIR", tmp_path / "no-dir")
122
+ monkeypatch.setattr(cli_main, "GLOBAL_AUDIT_PATH", bad_path)
123
+
124
+ # Should not raise even though the directory can't be created in
125
+ # some scenarios; the function is best-effort.
126
+ cli_main._append_event({"event": "x"})
@@ -0,0 +1,184 @@
1
+ """Regression tests for T99 -- `controlzero install --api-key` pre-fetches
2
+ the hosted policy bundle at install time (1.5.4/1.5.5 depending on which
3
+ PR merges first).
4
+
5
+ Before T99 the installer wrote the api_key to ~/.controlzero/config.yaml
6
+ and exited. The first PreToolUse hook call did the bundle fetch, which:
7
+
8
+ * Hid auth errors (wrong api_key) until the customer ran their agent.
9
+ * Added a 1-2s cold-start spike to the first hook call.
10
+
11
+ After T99 the installer calls _prefetch_bundle(api_key) immediately
12
+ after _write_api_key_config(api_key). On success it prints the rule
13
+ count. On failure (auth, network, tamper) it prints a WARNING to stderr
14
+ and exits cleanly -- the install completes either way so the user is
15
+ not left with a half-configured machine, but they SEE the problem now.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from unittest import mock
21
+
22
+ import pytest
23
+
24
+ import controlzero.cli.main as cli_main
25
+
26
+
27
+ def test_prefetch_bundle_prints_rule_count_on_success(capsys):
28
+ fake_parsed = {"rules": [{"id": "r1"}, {"id": "r2"}, {"id": "r3"}]}
29
+
30
+ with mock.patch(
31
+ "controlzero.hosted_policy.load_hosted_policy",
32
+ return_value=("inline", fake_parsed),
33
+ ):
34
+ cli_main._prefetch_bundle("cz_live_abc123")
35
+
36
+ captured = capsys.readouterr()
37
+ assert "Pre-fetched hosted policy (3 rules)" in captured.out
38
+ assert "WARNING" not in captured.err
39
+ assert "WARNING" not in captured.out
40
+
41
+
42
+ def test_prefetch_bundle_singular_rule_text(capsys):
43
+ fake_parsed = {"rules": [{"id": "only"}]}
44
+ with mock.patch(
45
+ "controlzero.hosted_policy.load_hosted_policy",
46
+ return_value=("inline", fake_parsed),
47
+ ):
48
+ cli_main._prefetch_bundle("cz_live_abc123")
49
+ captured = capsys.readouterr()
50
+ assert "1 rule)" in captured.out # not "1 rules"
51
+
52
+
53
+ def test_prefetch_bundle_handles_empty_rules_list(capsys):
54
+ fake_parsed = {"rules": []}
55
+ with mock.patch(
56
+ "controlzero.hosted_policy.load_hosted_policy",
57
+ return_value=("inline", fake_parsed),
58
+ ):
59
+ cli_main._prefetch_bundle("cz_live_abc123")
60
+ captured = capsys.readouterr()
61
+ # Zero is a legitimate (pre-attach) state -- show the count, not
62
+ # an error. Cf. project_424_real_root_causes.md.
63
+ assert "Pre-fetched hosted policy (0 rules)" in captured.out
64
+
65
+
66
+ def test_prefetch_bundle_warns_on_auth_failure_but_does_not_raise(capsys):
67
+ # Common failure: customer pasted the wrong api_key. We must NOT
68
+ # raise -- install must complete -- but we MUST surface the error
69
+ # in stderr so they see it before their first agent run.
70
+ with mock.patch(
71
+ "controlzero.hosted_policy.load_hosted_policy",
72
+ side_effect=RuntimeError("HostedAuthError: 401"),
73
+ ):
74
+ # No exception leaks out:
75
+ cli_main._prefetch_bundle("cz_live_wrong")
76
+
77
+ captured = capsys.readouterr()
78
+ assert "WARNING: Could not pre-fetch hosted policy" in captured.err
79
+ assert "HostedAuthError: 401" in captured.err
80
+ assert "double-check the api_key" in captured.err
81
+
82
+
83
+ def test_prefetch_bundle_warns_on_network_failure(capsys):
84
+ with mock.patch(
85
+ "controlzero.hosted_policy.load_hosted_policy",
86
+ side_effect=ConnectionError("Connection refused"),
87
+ ):
88
+ cli_main._prefetch_bundle("cz_live_abc123")
89
+
90
+ captured = capsys.readouterr()
91
+ assert "WARNING: Could not pre-fetch hosted policy" in captured.err
92
+ assert "Connection refused" in captured.err
93
+ # Must mention the SDK will retry so the user knows install is OK:
94
+ assert "retry on the first tool call" in captured.err
95
+
96
+
97
+ def test_prefetch_bundle_handles_missing_rules_key_gracefully(capsys):
98
+ # Defensive: backend may return a parsed bundle with no rules key
99
+ # (legacy shape, malformed response). The rule_count fallback is
100
+ # 0, no exception.
101
+ with mock.patch(
102
+ "controlzero.hosted_policy.load_hosted_policy",
103
+ return_value=("inline", {"unexpected": "shape"}),
104
+ ):
105
+ cli_main._prefetch_bundle("cz_live_abc123")
106
+ captured = capsys.readouterr()
107
+ assert "Pre-fetched hosted policy (0 rules)" in captured.out
108
+
109
+
110
+ def test_install_claude_code_calls_prefetch_when_api_key_present(tmp_path, monkeypatch):
111
+ """End-to-end: install claude-code --api-key X must call _prefetch_bundle."""
112
+ monkeypatch.setattr(cli_main, "GLOBAL_POLICY_DIR", tmp_path / ".controlzero")
113
+ monkeypatch.setattr(
114
+ cli_main, "GLOBAL_POLICY_PATH", tmp_path / ".controlzero" / "policy.yaml"
115
+ )
116
+ monkeypatch.setattr(
117
+ cli_main, "GLOBAL_CONFIG_PATH", tmp_path / ".controlzero" / "config.yaml"
118
+ )
119
+ monkeypatch.setattr(
120
+ cli_main, "GLOBAL_AUDIT_PATH", tmp_path / ".controlzero" / "audit.log"
121
+ )
122
+
123
+ settings_path = tmp_path / ".claude" / "settings.json"
124
+
125
+ fake_parsed = {"rules": [{"id": "r1"}]}
126
+ with mock.patch(
127
+ "controlzero.hosted_policy.load_hosted_policy",
128
+ return_value=("inline", fake_parsed),
129
+ ) as mocked:
130
+ from click.testing import CliRunner
131
+
132
+ runner = CliRunner()
133
+ result = runner.invoke(
134
+ cli_main.cli,
135
+ [
136
+ "install",
137
+ "claude-code",
138
+ "--api-key",
139
+ "cz_live_testkey",
140
+ "--settings",
141
+ str(settings_path),
142
+ "--force",
143
+ ],
144
+ )
145
+
146
+ assert result.exit_code == 0, result.output
147
+ mocked.assert_called_once_with("cz_live_testkey")
148
+ assert "Pre-fetched hosted policy (1 rule)" in result.output
149
+
150
+
151
+ def test_install_without_api_key_does_NOT_prefetch(tmp_path, monkeypatch):
152
+ """No api_key = no network call (offline install must stay offline)."""
153
+ monkeypatch.setattr(cli_main, "GLOBAL_POLICY_DIR", tmp_path / ".controlzero")
154
+ monkeypatch.setattr(
155
+ cli_main, "GLOBAL_POLICY_PATH", tmp_path / ".controlzero" / "policy.yaml"
156
+ )
157
+ monkeypatch.setattr(
158
+ cli_main, "GLOBAL_CONFIG_PATH", tmp_path / ".controlzero" / "config.yaml"
159
+ )
160
+ monkeypatch.setattr(
161
+ cli_main, "GLOBAL_AUDIT_PATH", tmp_path / ".controlzero" / "audit.log"
162
+ )
163
+
164
+ settings_path = tmp_path / ".claude" / "settings.json"
165
+
166
+ with mock.patch(
167
+ "controlzero.hosted_policy.load_hosted_policy"
168
+ ) as mocked:
169
+ from click.testing import CliRunner
170
+
171
+ runner = CliRunner()
172
+ result = runner.invoke(
173
+ cli_main.cli,
174
+ [
175
+ "install",
176
+ "claude-code",
177
+ "--settings",
178
+ str(settings_path),
179
+ "--force",
180
+ ],
181
+ )
182
+
183
+ assert result.exit_code == 0, result.output
184
+ mocked.assert_not_called()
File without changes
File without changes
File without changes
File without changes