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.
- {controlzero-1.5.3 → controlzero-1.5.4}/CHANGELOG.md +16 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/PKG-INFO +1 -1
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/__init__.py +1 -1
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/main.py +84 -8
- {controlzero-1.5.3 → controlzero-1.5.4}/pyproject.toml +1 -1
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_cli_carve_out.py +12 -4
- controlzero-1.5.4/tests/test_t96_single_audit_log.py +126 -0
- controlzero-1.5.4/tests/test_t99_install_prefetch_bundle.py +184 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/.gitignore +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/Dockerfile.test +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/LICENSE +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/README.md +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/_internal/__init__.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/_internal/action_aliases.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/_internal/bundle.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/_internal/dlp_scanner.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/_internal/enforcer.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/_internal/hook_extractors.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/_internal/tool_extractors.json +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/_internal/types.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/audit_local.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/audit_remote.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/__init__.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/debug_bundle.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/hosts/__init__.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/hosts/base.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/hosts/claude_code.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/hosts/codex_cli.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/hosts/gemini_cli.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/hosts/unknown.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/templates/autogen.yaml +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/templates/claude-code.yaml +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/templates/codex-cli.yaml +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/templates/cost-cap.yaml +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/templates/crewai.yaml +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/templates/cursor.yaml +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/templates/gemini-cli.yaml +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/templates/generic.yaml +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/templates/langchain.yaml +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/templates/mcp.yaml +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/cli/templates/rag.yaml +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/client.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/device.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/enrollment.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/errors.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/hosted_policy.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/__init__.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/anthropic.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/autogen.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/braintrust.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/crewai/__init__.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/crewai/agent.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/crewai/crew.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/crewai/task.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/crewai/tool.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/google.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/google_adk/__init__.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/google_adk/agent.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/google_adk/tool.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/langchain/__init__.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/langchain/agent.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/langchain/callbacks.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/langchain/chain.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/langchain/graph.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/langchain/modern.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/langchain/tool.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/langfuse.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/litellm.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/openai.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/pydantic_ai.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/integrations/vercel_ai.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/policy_loader.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/controlzero/tamper.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/examples/hello_world.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/conftest.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/integrations/__init__.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/integrations/test_google.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/parity/action_aliases.json +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_action_aliases.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_action_canonicalization.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_agent_name_env.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_api_key_mask.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_audit_remote.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_audit_sink_isolation.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_bundle_parser.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_bundle_translate.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_cli_debug_bundle.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_cli_extractor_integration.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_cli_hook.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_cli_hosted_refresh.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_cli_init.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_cli_init_templates.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_cli_tail.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_cli_test.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_cli_validate.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_coding_agent_hooks.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_conditions.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_default_action.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_device.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_dlp_scanner.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_enrollment.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_fail_closed_eval.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_glob_matching.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_hook_extractors.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_hosted_policy_e2e.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_hosts_adapter.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_hybrid_mode_strict.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_hybrid_mode_warn.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_install_hook_command.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_install_hooks.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_local_mode_dict.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_local_mode_file_json.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_local_mode_file_yaml.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_log_fallback_stderr.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_log_options_ignored_hosted.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_log_rotation.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_no_policy_no_key.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_package_rename_shim.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_policy_freshness.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_policy_settings.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_quarantine.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_reason_code.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_refresh.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_sql_semantic_class.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_synthetic_policy_id_t79.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_t103_precedence.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_t104_cache_gc.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_t108_local_override_audit.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_tamper.py +0 -0
- {controlzero-1.5.3 → controlzero-1.5.4}/tests/test_tamper_behavior.py +0 -0
- {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
|
+
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
|
|
@@ -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 =
|
|
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/
|
|
988
|
-
|
|
989
|
-
Used
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
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
|
|
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.
|
|
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
|
|
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,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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|