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.
Files changed (82) hide show
  1. {controlzero-1.2.0 → controlzero-1.3.0}/PKG-INFO +1 -1
  2. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/__init__.py +1 -1
  3. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/cli/main.py +128 -12
  4. {controlzero-1.2.0 → controlzero-1.3.0}/pyproject.toml +1 -1
  5. {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_coding_agent_hooks.py +215 -0
  6. {controlzero-1.2.0 → controlzero-1.3.0}/.gitignore +0 -0
  7. {controlzero-1.2.0 → controlzero-1.3.0}/Dockerfile.test +0 -0
  8. {controlzero-1.2.0 → controlzero-1.3.0}/LICENSE +0 -0
  9. {controlzero-1.2.0 → controlzero-1.3.0}/README.md +0 -0
  10. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/_internal/__init__.py +0 -0
  11. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/_internal/dlp_scanner.py +0 -0
  12. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/_internal/enforcer.py +0 -0
  13. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/_internal/types.py +0 -0
  14. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/audit_local.py +0 -0
  15. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/audit_remote.py +0 -0
  16. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/cli/__init__.py +0 -0
  17. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/cli/templates/autogen.yaml +0 -0
  18. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/cli/templates/claude-code.yaml +0 -0
  19. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/cli/templates/codex-cli.yaml +0 -0
  20. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/cli/templates/cost-cap.yaml +0 -0
  21. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/cli/templates/crewai.yaml +0 -0
  22. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/cli/templates/cursor.yaml +0 -0
  23. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/cli/templates/gemini-cli.yaml +0 -0
  24. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/cli/templates/generic.yaml +0 -0
  25. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/cli/templates/langchain.yaml +0 -0
  26. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/cli/templates/mcp.yaml +0 -0
  27. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/cli/templates/rag.yaml +0 -0
  28. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/client.py +0 -0
  29. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/enrollment.py +0 -0
  30. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/errors.py +0 -0
  31. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/__init__.py +0 -0
  32. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/anthropic.py +0 -0
  33. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/braintrust.py +0 -0
  34. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/crewai/__init__.py +0 -0
  35. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/crewai/agent.py +0 -0
  36. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/crewai/crew.py +0 -0
  37. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/crewai/task.py +0 -0
  38. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/crewai/tool.py +0 -0
  39. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/google.py +0 -0
  40. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/google_adk/__init__.py +0 -0
  41. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/google_adk/agent.py +0 -0
  42. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/google_adk/tool.py +0 -0
  43. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/langchain/__init__.py +0 -0
  44. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/langchain/agent.py +0 -0
  45. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/langchain/callbacks.py +0 -0
  46. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/langchain/chain.py +0 -0
  47. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/langchain/graph.py +0 -0
  48. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/langchain/tool.py +0 -0
  49. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/langfuse.py +0 -0
  50. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/litellm.py +0 -0
  51. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/openai.py +0 -0
  52. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/integrations/vercel_ai.py +0 -0
  53. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/policy_loader.py +0 -0
  54. {controlzero-1.2.0 → controlzero-1.3.0}/controlzero/tamper.py +0 -0
  55. {controlzero-1.2.0 → controlzero-1.3.0}/examples/hello_world.py +0 -0
  56. {controlzero-1.2.0 → controlzero-1.3.0}/tests/conftest.py +0 -0
  57. {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_audit_remote.py +0 -0
  58. {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_audit_sink_isolation.py +0 -0
  59. {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_cli_hook.py +0 -0
  60. {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_cli_init.py +0 -0
  61. {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_cli_init_templates.py +0 -0
  62. {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_cli_tail.py +0 -0
  63. {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_cli_test.py +0 -0
  64. {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_cli_validate.py +0 -0
  65. {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_dlp_scanner.py +0 -0
  66. {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_enrollment.py +0 -0
  67. {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_fail_closed_eval.py +0 -0
  68. {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_glob_matching.py +0 -0
  69. {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_hybrid_mode_strict.py +0 -0
  70. {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_hybrid_mode_warn.py +0 -0
  71. {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_install_hooks.py +0 -0
  72. {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_local_mode_dict.py +0 -0
  73. {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_local_mode_file_json.py +0 -0
  74. {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_local_mode_file_yaml.py +0 -0
  75. {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_log_fallback_stderr.py +0 -0
  76. {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_log_options_ignored_hosted.py +0 -0
  77. {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_log_rotation.py +0 -0
  78. {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_no_policy_no_key.py +0 -0
  79. {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_package_rename_shim.py +0 -0
  80. {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_policy_freshness.py +0 -0
  81. {controlzero-1.2.0 → controlzero-1.3.0}/tests/test_tamper.py +0 -0
  82. {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.2.0
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
@@ -28,7 +28,7 @@ from controlzero.errors import (
28
28
  )
29
29
  from controlzero.policy_loader import load_policy
30
30
 
31
- __version__ = "1.2.0"
31
+ __version__ = "1.3.0"
32
32
 
33
33
  __all__ = [
34
34
  "Client",
@@ -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
- def install_claude_code(force: bool, merge: bool, settings: Optional[str]):
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": "controlzero hook-check",
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) and h.get("command") == "controlzero hook-check"
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
- def install_gemini_cli(force: bool, merge: bool, settings: Optional[str]):
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": "controlzero hook-check",
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 (isinstance(b, dict) and b.get("command") == "controlzero hook-check")
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
- def install_codex_cli(force: bool, merge: bool, config: Optional[str]):
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
- "exec controlzero hook-check\n",
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) and h.get("command") == "controlzero hook-check"
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 (isinstance(b, dict) and b.get("command") == "controlzero hook-check")
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.2.0"
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