controlzero 1.2.0__tar.gz → 1.3.0__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.2.0 → controlzero-1.3.0}/PKG-INFO +1 -1
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/__init__.py +1 -1
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/cli/main.py +128 -12
- {controlzero-1.2.0 → controlzero-1.3.0}/pyproject.toml +1 -1
- {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_coding_agent_hooks.py +215 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/.gitignore +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/Dockerfile.test +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/LICENSE +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/README.md +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/_internal/__init__.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/_internal/dlp_scanner.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/_internal/enforcer.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/_internal/types.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/audit_local.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/audit_remote.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/cli/__init__.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/cli/templates/autogen.yaml +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/cli/templates/claude-code.yaml +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/cli/templates/codex-cli.yaml +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/cli/templates/cost-cap.yaml +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/cli/templates/crewai.yaml +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/cli/templates/cursor.yaml +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/cli/templates/gemini-cli.yaml +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/cli/templates/generic.yaml +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/cli/templates/langchain.yaml +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/cli/templates/mcp.yaml +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/cli/templates/rag.yaml +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/client.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/enrollment.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/errors.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/__init__.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/anthropic.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/braintrust.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/crewai/__init__.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/crewai/agent.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/crewai/crew.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/crewai/task.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/crewai/tool.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/google.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/google_adk/__init__.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/google_adk/agent.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/google_adk/tool.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/langchain/__init__.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/langchain/agent.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/langchain/callbacks.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/langchain/chain.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/langchain/graph.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/langchain/tool.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/langfuse.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/litellm.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/openai.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/vercel_ai.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/policy_loader.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/tamper.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/examples/hello_world.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/tests/conftest.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_audit_remote.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_audit_sink_isolation.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_cli_hook.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_cli_init.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_cli_init_templates.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_cli_tail.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_cli_test.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_cli_validate.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_dlp_scanner.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_enrollment.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_fail_closed_eval.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_glob_matching.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_hybrid_mode_strict.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_hybrid_mode_warn.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_install_hooks.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_local_mode_dict.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_local_mode_file_json.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_local_mode_file_yaml.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_log_fallback_stderr.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_log_options_ignored_hosted.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_log_rotation.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_no_policy_no_key.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_package_rename_shim.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_policy_freshness.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_tamper.py +0 -0
- {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_tamper_hook.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: controlzero
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
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
|
|
@@ -15,6 +15,7 @@ from pathlib import Path
|
|
|
15
15
|
from typing import Optional
|
|
16
16
|
|
|
17
17
|
import click
|
|
18
|
+
import yaml
|
|
18
19
|
|
|
19
20
|
from controlzero import Client, __version__
|
|
20
21
|
from controlzero.errors import PolicyLoadError, PolicyValidationError
|
|
@@ -390,6 +391,17 @@ def hook_check(policy: Optional[str]):
|
|
|
390
391
|
)
|
|
391
392
|
sys.exit(0)
|
|
392
393
|
|
|
394
|
+
# --- Load API key from config.yaml if not in environment ---
|
|
395
|
+
if not os.environ.get("CONTROLZERO_API_KEY"):
|
|
396
|
+
config_path = GLOBAL_POLICY_DIR / "config.yaml"
|
|
397
|
+
if config_path.exists():
|
|
398
|
+
try:
|
|
399
|
+
config = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
|
400
|
+
if config and config.get("api_key"):
|
|
401
|
+
os.environ["CONTROLZERO_API_KEY"] = config["api_key"]
|
|
402
|
+
except Exception:
|
|
403
|
+
pass
|
|
404
|
+
|
|
393
405
|
# --- Tamper detection: policy file HMAC check ---
|
|
394
406
|
tamper_detected = False
|
|
395
407
|
tamper_detected = _check_policy_tamper(policy_path)
|
|
@@ -582,7 +594,6 @@ def _do_pull_and_write(state: object, policy_path: Path, state_dir: Path, result
|
|
|
582
594
|
result["error"] = None
|
|
583
595
|
return
|
|
584
596
|
# Write the bundle rules as YAML to the policy file.
|
|
585
|
-
import yaml
|
|
586
597
|
yaml_content = yaml.safe_dump(
|
|
587
598
|
{
|
|
588
599
|
"version": str(bundle.get("version", "1")),
|
|
@@ -683,7 +694,6 @@ def _policy_has_deny_rules(policy_path: Path) -> bool:
|
|
|
683
694
|
if the policy only has allow rules (or is empty/unparseable).
|
|
684
695
|
"""
|
|
685
696
|
try:
|
|
686
|
-
import yaml
|
|
687
697
|
text = policy_path.read_text(encoding="utf-8")
|
|
688
698
|
data = yaml.safe_load(text)
|
|
689
699
|
if not isinstance(data, dict):
|
|
@@ -834,6 +844,43 @@ def _warn_no_deny_rules(agent_name: str, template_path: Path) -> None:
|
|
|
834
844
|
click.echo(f" {line}", err=True)
|
|
835
845
|
|
|
836
846
|
|
|
847
|
+
GLOBAL_CONFIG_PATH = GLOBAL_POLICY_DIR / "config.yaml"
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _validate_api_key(api_key: str) -> bool:
|
|
851
|
+
"""Return True if the API key has a valid cz_live_ or cz_test_ prefix."""
|
|
852
|
+
return api_key.startswith("cz_live_") or api_key.startswith("cz_test_")
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def _write_api_key_config(api_key: str) -> None:
|
|
856
|
+
"""Write the API key to ~/.controlzero/config.yaml."""
|
|
857
|
+
GLOBAL_POLICY_DIR.mkdir(parents=True, exist_ok=True)
|
|
858
|
+
config_data: dict = {}
|
|
859
|
+
if GLOBAL_CONFIG_PATH.exists():
|
|
860
|
+
try:
|
|
861
|
+
existing = yaml.safe_load(GLOBAL_CONFIG_PATH.read_text(encoding="utf-8"))
|
|
862
|
+
if isinstance(existing, dict):
|
|
863
|
+
config_data = existing
|
|
864
|
+
except Exception:
|
|
865
|
+
pass
|
|
866
|
+
config_data["api_key"] = api_key
|
|
867
|
+
GLOBAL_CONFIG_PATH.write_text(
|
|
868
|
+
yaml.safe_dump(config_data, default_flow_style=False),
|
|
869
|
+
encoding="utf-8",
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def _print_api_key_status(api_key: Optional[str]) -> None:
|
|
874
|
+
"""Print API key configuration status after install."""
|
|
875
|
+
if api_key:
|
|
876
|
+
click.echo("")
|
|
877
|
+
click.echo("API key configured. Audit logs will sync to the Control Zero dashboard.")
|
|
878
|
+
click.echo("View them at: https://app.controlzero.ai/audit")
|
|
879
|
+
else:
|
|
880
|
+
click.echo("")
|
|
881
|
+
click.echo("Tip: Pass --api-key cz_live_xxx to sync audit logs to the dashboard.")
|
|
882
|
+
|
|
883
|
+
|
|
837
884
|
@cli.group()
|
|
838
885
|
def install():
|
|
839
886
|
"""Install controlzero into a coding agent (Claude Code, Cursor, etc)."""
|
|
@@ -858,13 +905,28 @@ def install():
|
|
|
858
905
|
default=None,
|
|
859
906
|
help="Path to Claude Code settings.json. Defaults to ~/.claude/settings.json",
|
|
860
907
|
)
|
|
861
|
-
|
|
908
|
+
@click.option(
|
|
909
|
+
"--api-key",
|
|
910
|
+
"-k",
|
|
911
|
+
default=None,
|
|
912
|
+
help="Control Zero API key (cz_live_* or cz_test_*). Enables audit log sync to the dashboard.",
|
|
913
|
+
)
|
|
914
|
+
def install_claude_code(force: bool, merge: bool, settings: Optional[str], api_key: Optional[str]):
|
|
862
915
|
"""Install controlzero as a Claude Code PreToolUse hook.
|
|
863
916
|
|
|
864
917
|
Writes ~/.controlzero/policy.yaml from the claude-code template (idempotent
|
|
865
918
|
unless --force is set) and merges a hook block into ~/.claude/settings.json.
|
|
866
919
|
Existing hooks are preserved.
|
|
867
920
|
"""
|
|
921
|
+
if api_key is not None:
|
|
922
|
+
if not _validate_api_key(api_key):
|
|
923
|
+
click.echo(
|
|
924
|
+
"error: Invalid API key format. Must start with 'cz_live_' or 'cz_test_'.",
|
|
925
|
+
err=True,
|
|
926
|
+
)
|
|
927
|
+
sys.exit(1)
|
|
928
|
+
_write_api_key_config(api_key)
|
|
929
|
+
|
|
868
930
|
template_path = TEMPLATE_DIR / "claude-code.yaml"
|
|
869
931
|
if not template_path.exists():
|
|
870
932
|
click.echo(f"error: claude-code template missing at {template_path}", err=True)
|
|
@@ -914,12 +976,15 @@ def install_claude_code(force: bool, merge: bool, settings: Optional[str]):
|
|
|
914
976
|
pre_tool = hooks.setdefault("PreToolUse", [])
|
|
915
977
|
|
|
916
978
|
# Idempotency: replace any existing controlzero hook block, preserve others
|
|
979
|
+
hook_command = "controlzero hook-check"
|
|
980
|
+
if api_key:
|
|
981
|
+
hook_command = f"CONTROLZERO_API_KEY={api_key} controlzero hook-check"
|
|
917
982
|
new_block = {
|
|
918
983
|
"matcher": "*",
|
|
919
984
|
"hooks": [
|
|
920
985
|
{
|
|
921
986
|
"type": "command",
|
|
922
|
-
"command":
|
|
987
|
+
"command": hook_command,
|
|
923
988
|
"timeout": 5000,
|
|
924
989
|
}
|
|
925
990
|
],
|
|
@@ -929,7 +994,9 @@ def install_claude_code(force: bool, merge: bool, settings: Optional[str]):
|
|
|
929
994
|
if not (
|
|
930
995
|
isinstance(b, dict)
|
|
931
996
|
and any(
|
|
932
|
-
isinstance(h, dict)
|
|
997
|
+
isinstance(h, dict)
|
|
998
|
+
and isinstance(h.get("command"), str)
|
|
999
|
+
and "controlzero hook-check" in h.get("command", "")
|
|
933
1000
|
for h in b.get("hooks", [])
|
|
934
1001
|
)
|
|
935
1002
|
)
|
|
@@ -949,6 +1016,7 @@ def install_claude_code(force: bool, merge: bool, settings: Optional[str]):
|
|
|
949
1016
|
click.echo("")
|
|
950
1017
|
click.echo(f"Edit your policy: {GLOBAL_POLICY_PATH}")
|
|
951
1018
|
click.echo(f"Audit log: {GLOBAL_AUDIT_PATH}")
|
|
1019
|
+
_print_api_key_status(api_key)
|
|
952
1020
|
|
|
953
1021
|
|
|
954
1022
|
# =============================================================================
|
|
@@ -982,13 +1050,28 @@ def install_claude_code(force: bool, merge: bool, settings: Optional[str]):
|
|
|
982
1050
|
default=None,
|
|
983
1051
|
help="Path to Gemini CLI settings.json. Defaults to ~/.gemini/settings.json",
|
|
984
1052
|
)
|
|
985
|
-
|
|
1053
|
+
@click.option(
|
|
1054
|
+
"--api-key",
|
|
1055
|
+
"-k",
|
|
1056
|
+
default=None,
|
|
1057
|
+
help="Control Zero API key (cz_live_* or cz_test_*). Enables audit log sync to the dashboard.",
|
|
1058
|
+
)
|
|
1059
|
+
def install_gemini_cli(force: bool, merge: bool, settings: Optional[str], api_key: Optional[str]):
|
|
986
1060
|
"""Install controlzero as a Gemini CLI pre-tool-call hook.
|
|
987
1061
|
|
|
988
1062
|
Writes ~/.controlzero/policy.yaml from the gemini-cli template
|
|
989
1063
|
(idempotent unless --force is set) and merges a hook block into
|
|
990
1064
|
~/.gemini/settings.json. Existing hooks are preserved.
|
|
991
1065
|
"""
|
|
1066
|
+
if api_key is not None:
|
|
1067
|
+
if not _validate_api_key(api_key):
|
|
1068
|
+
click.echo(
|
|
1069
|
+
"error: Invalid API key format. Must start with 'cz_live_' or 'cz_test_'.",
|
|
1070
|
+
err=True,
|
|
1071
|
+
)
|
|
1072
|
+
sys.exit(1)
|
|
1073
|
+
_write_api_key_config(api_key)
|
|
1074
|
+
|
|
992
1075
|
template_path = TEMPLATE_DIR / "gemini-cli.yaml"
|
|
993
1076
|
if not template_path.exists():
|
|
994
1077
|
click.echo(f"error: gemini-cli template missing at {template_path}", err=True)
|
|
@@ -1035,16 +1118,23 @@ def install_gemini_cli(force: bool, merge: bool, settings: Optional[str]):
|
|
|
1035
1118
|
hooks_root = existing.setdefault("hooks", {})
|
|
1036
1119
|
pre_call = hooks_root.setdefault("BeforeTool", [])
|
|
1037
1120
|
|
|
1121
|
+
hook_command = "controlzero hook-check"
|
|
1122
|
+
if api_key:
|
|
1123
|
+
hook_command = f"CONTROLZERO_API_KEY={api_key} controlzero hook-check"
|
|
1038
1124
|
new_block = {
|
|
1039
1125
|
"matcher": "*",
|
|
1040
|
-
"command":
|
|
1126
|
+
"command": hook_command,
|
|
1041
1127
|
"timeout_ms": 5000,
|
|
1042
1128
|
}
|
|
1043
1129
|
# Idempotency: replace any existing controlzero hook block.
|
|
1044
1130
|
pre_call[:] = [
|
|
1045
1131
|
b
|
|
1046
1132
|
for b in pre_call
|
|
1047
|
-
if not (
|
|
1133
|
+
if not (
|
|
1134
|
+
isinstance(b, dict)
|
|
1135
|
+
and isinstance(b.get("command"), str)
|
|
1136
|
+
and "controlzero hook-check" in b.get("command", "")
|
|
1137
|
+
)
|
|
1048
1138
|
]
|
|
1049
1139
|
pre_call.append(new_block)
|
|
1050
1140
|
|
|
@@ -1061,6 +1151,7 @@ def install_gemini_cli(force: bool, merge: bool, settings: Optional[str]):
|
|
|
1061
1151
|
click.echo("")
|
|
1062
1152
|
click.echo(f"Edit your policy: {GLOBAL_POLICY_PATH}")
|
|
1063
1153
|
click.echo(f"Audit log: {GLOBAL_AUDIT_PATH}")
|
|
1154
|
+
_print_api_key_status(api_key)
|
|
1064
1155
|
|
|
1065
1156
|
|
|
1066
1157
|
# =============================================================================
|
|
@@ -1101,7 +1192,13 @@ def install_gemini_cli(force: bool, merge: bool, settings: Optional[str]):
|
|
|
1101
1192
|
default=None,
|
|
1102
1193
|
help="Path to Codex CLI config.toml. Defaults to ~/.codex/config.toml",
|
|
1103
1194
|
)
|
|
1104
|
-
|
|
1195
|
+
@click.option(
|
|
1196
|
+
"--api-key",
|
|
1197
|
+
"-k",
|
|
1198
|
+
default=None,
|
|
1199
|
+
help="Control Zero API key (cz_live_* or cz_test_*). Enables audit log sync to the dashboard.",
|
|
1200
|
+
)
|
|
1201
|
+
def install_codex_cli(force: bool, merge: bool, config: Optional[str], api_key: Optional[str]):
|
|
1105
1202
|
"""Install controlzero as a Codex CLI tool approval hook.
|
|
1106
1203
|
|
|
1107
1204
|
Writes ~/.controlzero/policy.yaml from the codex-cli template
|
|
@@ -1110,6 +1207,15 @@ def install_codex_cli(force: bool, merge: bool, config: Optional[str]):
|
|
|
1110
1207
|
`[tool_approval_policy]` block to ~/.codex/config.toml pointing at
|
|
1111
1208
|
the wrapper.
|
|
1112
1209
|
"""
|
|
1210
|
+
if api_key is not None:
|
|
1211
|
+
if not _validate_api_key(api_key):
|
|
1212
|
+
click.echo(
|
|
1213
|
+
"error: Invalid API key format. Must start with 'cz_live_' or 'cz_test_'.",
|
|
1214
|
+
err=True,
|
|
1215
|
+
)
|
|
1216
|
+
sys.exit(1)
|
|
1217
|
+
_write_api_key_config(api_key)
|
|
1218
|
+
|
|
1113
1219
|
template_path = TEMPLATE_DIR / "codex-cli.yaml"
|
|
1114
1220
|
if not template_path.exists():
|
|
1115
1221
|
click.echo(f"error: codex-cli template missing at {template_path}", err=True)
|
|
@@ -1142,13 +1248,16 @@ def install_codex_cli(force: bool, merge: bool, config: Optional[str]):
|
|
|
1142
1248
|
hooks_dir = codex_dir / "hooks"
|
|
1143
1249
|
hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
1144
1250
|
wrapper = hooks_dir / "controlzero.sh"
|
|
1251
|
+
exec_line = "exec controlzero hook-check\n"
|
|
1252
|
+
if api_key:
|
|
1253
|
+
exec_line = f"exec env CONTROLZERO_API_KEY={api_key} controlzero hook-check\n"
|
|
1145
1254
|
wrapper.write_text(
|
|
1146
1255
|
"#!/usr/bin/env bash\n"
|
|
1147
1256
|
"# Auto-generated by `controlzero install codex-cli`. Do not edit.\n"
|
|
1148
1257
|
"# Codex CLI calls this wrapper with the tool call JSON on stdin;\n"
|
|
1149
1258
|
"# we forward it to `controlzero hook-check` and propagate the\n"
|
|
1150
1259
|
"# exit code. Exit 0 = allow, non-zero = deny.\n"
|
|
1151
|
-
|
|
1260
|
+
+ exec_line,
|
|
1152
1261
|
encoding="utf-8",
|
|
1153
1262
|
)
|
|
1154
1263
|
wrapper.chmod(0o755)
|
|
@@ -1205,6 +1314,7 @@ def install_codex_cli(force: bool, merge: bool, config: Optional[str]):
|
|
|
1205
1314
|
click.echo("")
|
|
1206
1315
|
click.echo(f"Edit your policy: {GLOBAL_POLICY_PATH}")
|
|
1207
1316
|
click.echo(f"Audit log: {GLOBAL_AUDIT_PATH}")
|
|
1317
|
+
_print_api_key_status(api_key)
|
|
1208
1318
|
|
|
1209
1319
|
|
|
1210
1320
|
# =============================================================================
|
|
@@ -1256,7 +1366,9 @@ def uninstall_claude_code(settings: Optional[str]):
|
|
|
1256
1366
|
if not (
|
|
1257
1367
|
isinstance(b, dict)
|
|
1258
1368
|
and any(
|
|
1259
|
-
isinstance(h, dict)
|
|
1369
|
+
isinstance(h, dict)
|
|
1370
|
+
and isinstance(h.get("command"), str)
|
|
1371
|
+
and "controlzero hook-check" in h.get("command", "")
|
|
1260
1372
|
for h in b.get("hooks", [])
|
|
1261
1373
|
)
|
|
1262
1374
|
)
|
|
@@ -1313,7 +1425,11 @@ def uninstall_gemini_cli(settings: Optional[str]):
|
|
|
1313
1425
|
original_count = len(pre_call)
|
|
1314
1426
|
cleaned = [
|
|
1315
1427
|
b for b in pre_call
|
|
1316
|
-
if not (
|
|
1428
|
+
if not (
|
|
1429
|
+
isinstance(b, dict)
|
|
1430
|
+
and isinstance(b.get("command"), str)
|
|
1431
|
+
and "controlzero hook-check" in b.get("command", "")
|
|
1432
|
+
)
|
|
1317
1433
|
]
|
|
1318
1434
|
|
|
1319
1435
|
if len(cleaned) == original_count:
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "controlzero"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.3.0"
|
|
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"}
|
|
@@ -38,6 +38,7 @@ from __future__ import annotations
|
|
|
38
38
|
|
|
39
39
|
import importlib
|
|
40
40
|
import json
|
|
41
|
+
import os
|
|
41
42
|
from pathlib import Path
|
|
42
43
|
|
|
43
44
|
import pytest
|
|
@@ -604,3 +605,217 @@ class TestHelperFunctions:
|
|
|
604
605
|
from controlzero.cli.main import _policy_has_deny_rules
|
|
605
606
|
p = tmp_path / "nonexistent.yaml"
|
|
606
607
|
assert _policy_has_deny_rules(p) is False
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
# ===========================================================================
|
|
611
|
+
# API KEY OPTION TESTS
|
|
612
|
+
# ===========================================================================
|
|
613
|
+
|
|
614
|
+
class TestApiKeyOption:
|
|
615
|
+
"""Tests for the --api-key option on install commands."""
|
|
616
|
+
|
|
617
|
+
def test_api_key_validation_valid_live(self):
|
|
618
|
+
"""cz_live_ prefix is accepted."""
|
|
619
|
+
from controlzero.cli.main import _validate_api_key
|
|
620
|
+
assert _validate_api_key("cz_live_abc123") is True
|
|
621
|
+
|
|
622
|
+
def test_api_key_validation_valid_test(self):
|
|
623
|
+
"""cz_test_ prefix is accepted."""
|
|
624
|
+
from controlzero.cli.main import _validate_api_key
|
|
625
|
+
assert _validate_api_key("cz_test_abc123") is True
|
|
626
|
+
|
|
627
|
+
def test_api_key_validation_invalid(self):
|
|
628
|
+
"""Invalid prefixes are rejected."""
|
|
629
|
+
from controlzero.cli.main import _validate_api_key
|
|
630
|
+
assert _validate_api_key("sk_live_abc123") is False
|
|
631
|
+
assert _validate_api_key("invalid") is False
|
|
632
|
+
assert _validate_api_key("") is False
|
|
633
|
+
assert _validate_api_key("cz_invalid_abc") is False
|
|
634
|
+
|
|
635
|
+
def test_install_claude_code_with_api_key_writes_config(
|
|
636
|
+
self, tmp_path, monkeypatch
|
|
637
|
+
):
|
|
638
|
+
"""install claude-code --api-key writes config.yaml with the key."""
|
|
639
|
+
fake_home, cli_main = _setup_fake_home(tmp_path, monkeypatch)
|
|
640
|
+
|
|
641
|
+
runner = CliRunner()
|
|
642
|
+
result = runner.invoke(
|
|
643
|
+
cli_main.cli,
|
|
644
|
+
["install", "claude-code", "--api-key", "cz_live_testkey123"],
|
|
645
|
+
)
|
|
646
|
+
assert result.exit_code == 0
|
|
647
|
+
|
|
648
|
+
config_path = fake_home / ".controlzero" / "config.yaml"
|
|
649
|
+
assert config_path.exists()
|
|
650
|
+
config = yaml.safe_load(config_path.read_text())
|
|
651
|
+
assert config["api_key"] == "cz_live_testkey123"
|
|
652
|
+
|
|
653
|
+
def test_install_claude_code_with_api_key_updates_hook_command(
|
|
654
|
+
self, tmp_path, monkeypatch
|
|
655
|
+
):
|
|
656
|
+
"""install claude-code --api-key includes env var in hook command."""
|
|
657
|
+
fake_home, cli_main = _setup_fake_home(tmp_path, monkeypatch)
|
|
658
|
+
|
|
659
|
+
runner = CliRunner()
|
|
660
|
+
result = runner.invoke(
|
|
661
|
+
cli_main.cli,
|
|
662
|
+
["install", "claude-code", "--api-key", "cz_live_testkey123"],
|
|
663
|
+
)
|
|
664
|
+
assert result.exit_code == 0
|
|
665
|
+
|
|
666
|
+
settings_path = fake_home / ".claude" / "settings.json"
|
|
667
|
+
settings = json.loads(settings_path.read_text())
|
|
668
|
+
pre_tool = settings["hooks"]["PreToolUse"]
|
|
669
|
+
assert len(pre_tool) == 1
|
|
670
|
+
hook_cmd = pre_tool[0]["hooks"][0]["command"]
|
|
671
|
+
assert "CONTROLZERO_API_KEY=cz_live_testkey123" in hook_cmd
|
|
672
|
+
assert "controlzero hook-check" in hook_cmd
|
|
673
|
+
|
|
674
|
+
def test_install_gemini_cli_with_api_key_updates_hook_command(
|
|
675
|
+
self, tmp_path, monkeypatch
|
|
676
|
+
):
|
|
677
|
+
"""install gemini-cli --api-key includes env var in hook command."""
|
|
678
|
+
fake_home, cli_main = _setup_fake_home(tmp_path, monkeypatch)
|
|
679
|
+
|
|
680
|
+
runner = CliRunner()
|
|
681
|
+
result = runner.invoke(
|
|
682
|
+
cli_main.cli,
|
|
683
|
+
["install", "gemini-cli", "--api-key", "cz_test_geminikey"],
|
|
684
|
+
)
|
|
685
|
+
assert result.exit_code == 0
|
|
686
|
+
|
|
687
|
+
settings_path = fake_home / ".gemini" / "settings.json"
|
|
688
|
+
settings = json.loads(settings_path.read_text())
|
|
689
|
+
before_tool = settings["hooks"]["BeforeTool"]
|
|
690
|
+
assert len(before_tool) == 1
|
|
691
|
+
assert "CONTROLZERO_API_KEY=cz_test_geminikey" in before_tool[0]["command"]
|
|
692
|
+
|
|
693
|
+
def test_install_codex_cli_with_api_key_updates_wrapper(
|
|
694
|
+
self, tmp_path, monkeypatch
|
|
695
|
+
):
|
|
696
|
+
"""install codex-cli --api-key includes env var in wrapper script."""
|
|
697
|
+
fake_home, cli_main = _setup_fake_home(tmp_path, monkeypatch)
|
|
698
|
+
|
|
699
|
+
runner = CliRunner()
|
|
700
|
+
result = runner.invoke(
|
|
701
|
+
cli_main.cli,
|
|
702
|
+
["install", "codex-cli", "--api-key", "cz_live_codexkey"],
|
|
703
|
+
)
|
|
704
|
+
assert result.exit_code == 0
|
|
705
|
+
|
|
706
|
+
wrapper_path = fake_home / ".codex" / "hooks" / "controlzero.sh"
|
|
707
|
+
assert wrapper_path.exists()
|
|
708
|
+
wrapper_text = wrapper_path.read_text()
|
|
709
|
+
assert "CONTROLZERO_API_KEY=cz_live_codexkey" in wrapper_text
|
|
710
|
+
|
|
711
|
+
def test_install_with_invalid_api_key_exits_with_error(
|
|
712
|
+
self, tmp_path, monkeypatch
|
|
713
|
+
):
|
|
714
|
+
"""install with invalid --api-key prints error and exits non-zero."""
|
|
715
|
+
fake_home, cli_main = _setup_fake_home(tmp_path, monkeypatch)
|
|
716
|
+
|
|
717
|
+
runner = CliRunner()
|
|
718
|
+
result = runner.invoke(
|
|
719
|
+
cli_main.cli,
|
|
720
|
+
["install", "claude-code", "--api-key", "invalid_key"],
|
|
721
|
+
)
|
|
722
|
+
assert result.exit_code != 0
|
|
723
|
+
assert "Invalid API key format" in result.output
|
|
724
|
+
|
|
725
|
+
def test_install_without_api_key_shows_hint(
|
|
726
|
+
self, tmp_path, monkeypatch
|
|
727
|
+
):
|
|
728
|
+
"""install without --api-key shows tip about the option."""
|
|
729
|
+
fake_home, cli_main = _setup_fake_home(tmp_path, monkeypatch)
|
|
730
|
+
|
|
731
|
+
runner = CliRunner()
|
|
732
|
+
result = runner.invoke(
|
|
733
|
+
cli_main.cli,
|
|
734
|
+
["install", "claude-code"],
|
|
735
|
+
)
|
|
736
|
+
assert result.exit_code == 0
|
|
737
|
+
assert "Tip: Pass --api-key" in result.output
|
|
738
|
+
|
|
739
|
+
def test_install_with_api_key_shows_dashboard_message(
|
|
740
|
+
self, tmp_path, monkeypatch
|
|
741
|
+
):
|
|
742
|
+
"""install with --api-key shows dashboard sync message."""
|
|
743
|
+
fake_home, cli_main = _setup_fake_home(tmp_path, monkeypatch)
|
|
744
|
+
|
|
745
|
+
runner = CliRunner()
|
|
746
|
+
result = runner.invoke(
|
|
747
|
+
cli_main.cli,
|
|
748
|
+
["install", "claude-code", "--api-key", "cz_live_abc"],
|
|
749
|
+
)
|
|
750
|
+
assert result.exit_code == 0
|
|
751
|
+
assert "Audit logs will sync" in result.output
|
|
752
|
+
assert "https://app.controlzero.ai/audit" in result.output
|
|
753
|
+
|
|
754
|
+
def test_hook_check_reads_api_key_from_config(
|
|
755
|
+
self, tmp_path, monkeypatch
|
|
756
|
+
):
|
|
757
|
+
"""hook-check reads API key from config.yaml when env var not set."""
|
|
758
|
+
fake_home, cli_main = _setup_fake_home(tmp_path, monkeypatch)
|
|
759
|
+
|
|
760
|
+
# Write config.yaml with API key
|
|
761
|
+
cz_dir = fake_home / ".controlzero"
|
|
762
|
+
cz_dir.mkdir(parents=True)
|
|
763
|
+
config_path = cz_dir / "config.yaml"
|
|
764
|
+
config_path.write_text(yaml.safe_dump({"api_key": "cz_live_fromconfig"}))
|
|
765
|
+
|
|
766
|
+
# Write a simple allow-all policy
|
|
767
|
+
policy_path = cz_dir / "policy.yaml"
|
|
768
|
+
policy_path.write_text(yaml.safe_dump({
|
|
769
|
+
"version": "1",
|
|
770
|
+
"rules": [{"allow": "*"}],
|
|
771
|
+
}))
|
|
772
|
+
|
|
773
|
+
# Ensure env var is NOT set
|
|
774
|
+
monkeypatch.delenv("CONTROLZERO_API_KEY", raising=False)
|
|
775
|
+
# Patch GLOBAL_POLICY_PATH and GLOBAL_POLICY_DIR to use fake home
|
|
776
|
+
monkeypatch.setattr(cli_main, "GLOBAL_POLICY_DIR", cz_dir)
|
|
777
|
+
monkeypatch.setattr(cli_main, "GLOBAL_POLICY_PATH", policy_path)
|
|
778
|
+
monkeypatch.setattr(cli_main, "GLOBAL_AUDIT_PATH", cz_dir / "audit.log")
|
|
779
|
+
|
|
780
|
+
runner = CliRunner()
|
|
781
|
+
payload = json.dumps({"tool_name": "Read", "tool_input": {}})
|
|
782
|
+
result = runner.invoke(cli_main.cli, ["hook-check"], input=payload)
|
|
783
|
+
assert result.exit_code == 0
|
|
784
|
+
|
|
785
|
+
# The env var should have been set by hook-check reading config.yaml
|
|
786
|
+
assert os.environ.get("CONTROLZERO_API_KEY") == "cz_live_fromconfig"
|
|
787
|
+
|
|
788
|
+
# Clean up env var
|
|
789
|
+
monkeypatch.delenv("CONTROLZERO_API_KEY", raising=False)
|
|
790
|
+
|
|
791
|
+
def test_hook_check_env_var_takes_precedence(
|
|
792
|
+
self, tmp_path, monkeypatch
|
|
793
|
+
):
|
|
794
|
+
"""CONTROLZERO_API_KEY env var takes precedence over config.yaml."""
|
|
795
|
+
fake_home, cli_main = _setup_fake_home(tmp_path, monkeypatch)
|
|
796
|
+
|
|
797
|
+
cz_dir = fake_home / ".controlzero"
|
|
798
|
+
cz_dir.mkdir(parents=True)
|
|
799
|
+
config_path = cz_dir / "config.yaml"
|
|
800
|
+
config_path.write_text(yaml.safe_dump({"api_key": "cz_live_fromconfig"}))
|
|
801
|
+
|
|
802
|
+
policy_path = cz_dir / "policy.yaml"
|
|
803
|
+
policy_path.write_text(yaml.safe_dump({
|
|
804
|
+
"version": "1",
|
|
805
|
+
"rules": [{"allow": "*"}],
|
|
806
|
+
}))
|
|
807
|
+
|
|
808
|
+
monkeypatch.setenv("CONTROLZERO_API_KEY", "cz_live_fromenv")
|
|
809
|
+
monkeypatch.setattr(cli_main, "GLOBAL_POLICY_DIR", cz_dir)
|
|
810
|
+
monkeypatch.setattr(cli_main, "GLOBAL_POLICY_PATH", policy_path)
|
|
811
|
+
monkeypatch.setattr(cli_main, "GLOBAL_AUDIT_PATH", cz_dir / "audit.log")
|
|
812
|
+
|
|
813
|
+
runner = CliRunner()
|
|
814
|
+
payload = json.dumps({"tool_name": "Read", "tool_input": {}})
|
|
815
|
+
result = runner.invoke(cli_main.cli, ["hook-check"], input=payload)
|
|
816
|
+
assert result.exit_code == 0
|
|
817
|
+
|
|
818
|
+
# Env var should remain as the original, not overwritten by config
|
|
819
|
+
assert os.environ.get("CONTROLZERO_API_KEY") == "cz_live_fromenv"
|
|
820
|
+
|
|
821
|
+
monkeypatch.delenv("CONTROLZERO_API_KEY", raising=False)
|
|
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
|