controlzero 1.5.1__tar.gz → 1.5.3__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 (129) hide show
  1. {controlzero-1.5.1 → controlzero-1.5.3}/CHANGELOG.md +77 -0
  2. {controlzero-1.5.1 → controlzero-1.5.3}/PKG-INFO +1 -1
  3. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/__init__.py +1 -1
  4. controlzero-1.5.3/controlzero/cli/hosts/__init__.py +110 -0
  5. controlzero-1.5.3/controlzero/cli/hosts/base.py +141 -0
  6. controlzero-1.5.3/controlzero/cli/hosts/claude_code.py +121 -0
  7. controlzero-1.5.3/controlzero/cli/hosts/codex_cli.py +54 -0
  8. controlzero-1.5.3/controlzero/cli/hosts/gemini_cli.py +58 -0
  9. controlzero-1.5.3/controlzero/cli/hosts/unknown.py +52 -0
  10. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/cli/main.py +123 -80
  11. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/device.py +8 -2
  12. {controlzero-1.5.1 → controlzero-1.5.3}/pyproject.toml +1 -1
  13. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_cli_carve_out.py +19 -4
  14. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_cli_extractor_integration.py +14 -2
  15. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_coding_agent_hooks.py +14 -4
  16. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_device.py +8 -4
  17. controlzero-1.5.3/tests/test_hosts_adapter.py +277 -0
  18. controlzero-1.5.3/tests/test_install_hook_command.py +139 -0
  19. {controlzero-1.5.1 → controlzero-1.5.3}/.gitignore +0 -0
  20. {controlzero-1.5.1 → controlzero-1.5.3}/Dockerfile.test +0 -0
  21. {controlzero-1.5.1 → controlzero-1.5.3}/LICENSE +0 -0
  22. {controlzero-1.5.1 → controlzero-1.5.3}/README.md +0 -0
  23. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/_internal/__init__.py +0 -0
  24. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/_internal/action_aliases.py +0 -0
  25. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/_internal/bundle.py +0 -0
  26. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/_internal/dlp_scanner.py +0 -0
  27. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/_internal/enforcer.py +0 -0
  28. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/_internal/hook_extractors.py +0 -0
  29. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/_internal/tool_extractors.json +0 -0
  30. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/_internal/types.py +0 -0
  31. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/audit_local.py +0 -0
  32. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/audit_remote.py +0 -0
  33. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/cli/__init__.py +0 -0
  34. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/cli/debug_bundle.py +0 -0
  35. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/cli/templates/autogen.yaml +0 -0
  36. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/cli/templates/claude-code.yaml +0 -0
  37. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/cli/templates/codex-cli.yaml +0 -0
  38. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/cli/templates/cost-cap.yaml +0 -0
  39. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/cli/templates/crewai.yaml +0 -0
  40. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/cli/templates/cursor.yaml +0 -0
  41. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/cli/templates/gemini-cli.yaml +0 -0
  42. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/cli/templates/generic.yaml +0 -0
  43. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/cli/templates/langchain.yaml +0 -0
  44. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/cli/templates/mcp.yaml +0 -0
  45. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/cli/templates/rag.yaml +0 -0
  46. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/client.py +0 -0
  47. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/enrollment.py +0 -0
  48. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/errors.py +0 -0
  49. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/hosted_policy.py +0 -0
  50. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/integrations/__init__.py +0 -0
  51. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/integrations/anthropic.py +0 -0
  52. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/integrations/autogen.py +0 -0
  53. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/integrations/braintrust.py +0 -0
  54. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/integrations/crewai/__init__.py +0 -0
  55. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/integrations/crewai/agent.py +0 -0
  56. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/integrations/crewai/crew.py +0 -0
  57. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/integrations/crewai/task.py +0 -0
  58. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/integrations/crewai/tool.py +0 -0
  59. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/integrations/google.py +0 -0
  60. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/integrations/google_adk/__init__.py +0 -0
  61. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/integrations/google_adk/agent.py +0 -0
  62. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/integrations/google_adk/tool.py +0 -0
  63. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/integrations/langchain/__init__.py +0 -0
  64. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/integrations/langchain/agent.py +0 -0
  65. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/integrations/langchain/callbacks.py +0 -0
  66. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/integrations/langchain/chain.py +0 -0
  67. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/integrations/langchain/graph.py +0 -0
  68. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/integrations/langchain/modern.py +0 -0
  69. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/integrations/langchain/tool.py +0 -0
  70. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/integrations/langfuse.py +0 -0
  71. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/integrations/litellm.py +0 -0
  72. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/integrations/openai.py +0 -0
  73. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/integrations/pydantic_ai.py +0 -0
  74. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/integrations/vercel_ai.py +0 -0
  75. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/policy_loader.py +0 -0
  76. {controlzero-1.5.1 → controlzero-1.5.3}/controlzero/tamper.py +0 -0
  77. {controlzero-1.5.1 → controlzero-1.5.3}/examples/hello_world.py +0 -0
  78. {controlzero-1.5.1 → controlzero-1.5.3}/tests/conftest.py +0 -0
  79. {controlzero-1.5.1 → controlzero-1.5.3}/tests/integrations/__init__.py +0 -0
  80. {controlzero-1.5.1 → controlzero-1.5.3}/tests/integrations/test_google.py +0 -0
  81. {controlzero-1.5.1 → controlzero-1.5.3}/tests/parity/action_aliases.json +0 -0
  82. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_action_aliases.py +0 -0
  83. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_action_canonicalization.py +0 -0
  84. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_agent_name_env.py +0 -0
  85. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_api_key_mask.py +0 -0
  86. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_audit_remote.py +0 -0
  87. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_audit_sink_isolation.py +0 -0
  88. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_bundle_parser.py +0 -0
  89. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_bundle_translate.py +0 -0
  90. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_cli_debug_bundle.py +0 -0
  91. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_cli_hook.py +0 -0
  92. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_cli_hosted_refresh.py +0 -0
  93. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_cli_init.py +0 -0
  94. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_cli_init_templates.py +0 -0
  95. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_cli_tail.py +0 -0
  96. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_cli_test.py +0 -0
  97. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_cli_validate.py +0 -0
  98. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_conditions.py +0 -0
  99. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_default_action.py +0 -0
  100. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_dlp_scanner.py +0 -0
  101. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_enrollment.py +0 -0
  102. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_fail_closed_eval.py +0 -0
  103. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_glob_matching.py +0 -0
  104. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_hook_extractors.py +0 -0
  105. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_hosted_policy_e2e.py +0 -0
  106. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_hybrid_mode_strict.py +0 -0
  107. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_hybrid_mode_warn.py +0 -0
  108. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_install_hooks.py +0 -0
  109. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_local_mode_dict.py +0 -0
  110. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_local_mode_file_json.py +0 -0
  111. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_local_mode_file_yaml.py +0 -0
  112. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_log_fallback_stderr.py +0 -0
  113. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_log_options_ignored_hosted.py +0 -0
  114. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_log_rotation.py +0 -0
  115. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_no_policy_no_key.py +0 -0
  116. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_package_rename_shim.py +0 -0
  117. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_policy_freshness.py +0 -0
  118. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_policy_settings.py +0 -0
  119. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_quarantine.py +0 -0
  120. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_reason_code.py +0 -0
  121. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_refresh.py +0 -0
  122. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_sql_semantic_class.py +0 -0
  123. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_synthetic_policy_id_t79.py +0 -0
  124. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_t103_precedence.py +0 -0
  125. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_t104_cache_gc.py +0 -0
  126. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_t108_local_override_audit.py +0 -0
  127. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_tamper.py +0 -0
  128. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_tamper_behavior.py +0 -0
  129. {controlzero-1.5.1 → controlzero-1.5.3}/tests/test_tamper_hook.py +0 -0
@@ -1,5 +1,82 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.3 -- 2026-05-12
4
+
5
+ ### Added
6
+
7
+ - **Host-agent adapter base** (`controlzero/cli/hosts/`). A pluggable
8
+ per-host hook adapter pattern. Each adapter owns three things for
9
+ one host runtime: `claim(payload, env) -> bool` (recognise the
10
+ inbound hook call), `render(decision) -> dict` (translate the
11
+ canonical CZ Decision into the host's JSON envelope), and
12
+ `canonical_source` (backend audit alias). First adapter in
13
+ `REGISTRY` whose `claim()` returns True owns the call; a conservative
14
+ `UnknownHostAdapter` is the registry tail and always claims.
15
+ Adding Cursor / Windsurf / OpenClaw / Antigravity / the next
16
+ agent is now one file in `controlzero/cli/hosts/`, no edits to
17
+ `cli/main.py` or `audit_remote.py`.
18
+
19
+ ### Fixed
20
+
21
+ - **Claude Code policy bypass on the allow path.** Pre-1.5.3 the SDK
22
+ emitted `{"decision": "allow", ...}` for every allow hook decision.
23
+ Anthropic's PreToolUse hook schema only accepts `"approve" | "block"`
24
+ -- the `"allow"` string crashed Claude Code's validator with
25
+ `Hook JSON output validation failed - (root): Invalid input` and
26
+ Claude Code dropped the hook decision (defaults to proceed). CloudShift
27
+ saw a `Write` correctly blocked, then a `PowerShell` command bypass
28
+ to the same intent. After 1.5.3 the Claude Code adapter renders
29
+ allow as `"approve"` and deny as `"block"`; Gemini CLI / Codex CLI
30
+ / unknown hosts each get their schema-correct shape. Customers who
31
+ hit this on 1.5.2 must upgrade.
32
+ - **Source label for Claude Code hook on Windows.** Pre-1.5.3
33
+ detection relied on `CLAUDECODE=1` which Anthropic's Windows
34
+ launcher does not always export, so the audit row labelled the
35
+ agent as `python-sdk`. The Claude Code adapter now claims via the
36
+ stdin payload signature (presence of `hook_event_name` /
37
+ `session_id` + `tool_input`) in addition to env vars, so detection
38
+ survives env-var-stripped Windows hook subprocesses. Audit row
39
+ SOURCE column renders `Claude Code` instead of `Python SDK`.
40
+
41
+ ### Known follow-ups (not in 1.5.3)
42
+
43
+ - Canonical-tool extractor coverage for shell-mediated file
44
+ operations (`PowerShell:New-Item`, `Bash:touch`, `Bash:rm`, etc.)
45
+ so a single `file_write` deny rule covers both the native `Write`
46
+ tool AND the equivalent shell command. The adapter base provides
47
+ the right home for this in the next release.
48
+ - Wire-payload field gaps (`latency_ms`, `method_name`, `args`) on
49
+ the `/v1/sdk/audit` endpoint. Needs paired backend change.
50
+
51
+ ## 1.5.2 -- 2026-05-12
52
+
53
+ ### Fixed
54
+
55
+ - **Windows Claude Code hook never delivered audit logs.** The
56
+ installer wrote `CONTROLZERO_API_KEY=cz_live_... controlzero hook-check`
57
+ into `~/.claude/settings.json`. The `VAR=value command` env-prefix
58
+ is bash-only and PowerShell / cmd.exe cannot parse it, so the
59
+ Claude Code hook subprocess failed to spawn on every PreToolUse
60
+ call. macOS / Linux were unaffected. After 1.5.2 the hook command
61
+ is plain `controlzero hook-check`; the api key flows through
62
+ `~/.controlzero/config.yaml` (already written by `install --api-key`)
63
+ on every supported platform. Side benefit: the cleartext key is
64
+ no longer embedded in `settings.json` on disk.
65
+ - **Audit-log dashboard SOURCE column rendered `--` for direct-SDK
66
+ rows.** `detect_client_name()` returned the generic alias `"sdk"`
67
+ when no agent-runtime env vars were detected; the backend
68
+ `NormalizeSource` mapped `"sdk"` to `unknown`, and the frontend
69
+ rendered `unknown` as `--`. The default is now `"python-sdk"`,
70
+ which the backend maps to the canonical `python_sdk` source value
71
+ and the frontend renders as `Python SDK`.
72
+
73
+ ### Customer impact
74
+
75
+ CloudShift / kh.lee reported on 2026-05-12 that Claude Code on
76
+ Windows was not sending any audit logs to the dashboard, while the
77
+ direct Python SDK script was sending logs without a populated
78
+ SOURCE column. Both symptoms collapse to the two fixes above.
79
+
3
80
  ## 1.5.1 -- 2026-05-12 (SECURITY)
4
81
 
5
82
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: controlzero
3
- Version: 1.5.1
3
+ Version: 1.5.3
4
4
  Summary: AI agent governance: policies, audit, and observability for tool calls. Works locally with no signup.
5
5
  Project-URL: Homepage, https://controlzero.ai
6
6
  Project-URL: Documentation, https://docs.controlzero.ai
@@ -28,7 +28,7 @@ from controlzero.errors import (
28
28
  )
29
29
  from controlzero.policy_loader import load_policy
30
30
 
31
- __version__ = "1.5.1"
31
+ __version__ = "1.5.3"
32
32
 
33
33
  __all__ = [
34
34
  "Client",
@@ -0,0 +1,110 @@
1
+ """Host-agent adapter base for the hook-check command (1.5.3).
2
+
3
+ Background
4
+ ----------
5
+ Before 1.5.3 the hook subprocess wrote a single hard-coded JSON
6
+ envelope to stdout regardless of which coding agent invoked it. Two
7
+ bugs followed from that design:
8
+
9
+ * Claude Code's PreToolUse hook spec only accepts
10
+ ``decision: "approve" | "block"``. The SDK emitted
11
+ ``decision: "allow"`` on the allow path, which Claude Code rejects
12
+ as ``Hook JSON output validation failed - (root): Invalid input``.
13
+ Claude Code then drops the hook decision and runs the call. Customer
14
+ saw a Write block correctly, then a PowerShell command bypass on
15
+ the same tool intent (2026-05-12).
16
+
17
+ * Source detection relied on env vars Anthropic only exports on
18
+ macOS / Linux. On Windows the hook subprocess saw no
19
+ ``CLAUDECODE`` and ``detect_client_name()`` fell through to the
20
+ generic ``python-sdk`` default. The audit row mis-labelled the
21
+ agent as a direct SDK call.
22
+
23
+ Both failure modes share a root: the SDK conflated "what decision
24
+ did the policy engine make" with "what shape does the host expect
25
+ on the wire." Splitting those two concerns is what this package
26
+ does.
27
+
28
+ Design
29
+ ------
30
+ A ``HostAdapter`` owns three responsibilities for one host runtime:
31
+
32
+ 1. ``claim(payload, env)`` -- decide whether the inbound hook
33
+ invocation came from this host. First adapter to claim wins.
34
+ Adapters use BOTH stdin-payload signatures and env-var hints so
35
+ detection works even when the host's launcher strips env vars
36
+ (Windows Claude Code is the canonical case).
37
+ 2. ``render(decision)`` -- translate the canonical ``CZDecision``
38
+ into the JSON shape this host's hook validator accepts.
39
+ 3. ``canonical_source`` -- the backend ``audit.NormalizeSource``
40
+ alias (``claude_code`` / ``gemini_cli`` / ``codex_cli`` / etc.)
41
+ so the audit row's SOURCE column renders correctly.
42
+
43
+ Adding a new host (Cursor, Windsurf, OpenClaw, Antigravity, the
44
+ next agent that ships) is a single file in this package: subclass
45
+ ``HostAdapter``, append the instance to ``REGISTRY``. No edits to
46
+ ``cli/main.py``, ``audit_remote.py``, the policy engine, or the
47
+ backend.
48
+
49
+ A conservative ``UnknownHostAdapter`` sits at the bottom of the
50
+ registry and is the final claim. It emits ``{}`` for allow (every
51
+ known host treats no-decision as "proceed") and the universal
52
+ ``{"decision": "block", "reason": ...}`` for deny.
53
+ """
54
+
55
+ from __future__ import annotations
56
+
57
+ from controlzero.cli.hosts.base import CZDecision, HostAdapter
58
+ from controlzero.cli.hosts.claude_code import ClaudeCodeAdapter
59
+ from controlzero.cli.hosts.codex_cli import CodexCLIAdapter
60
+ from controlzero.cli.hosts.gemini_cli import GeminiCLIAdapter
61
+ from controlzero.cli.hosts.unknown import UnknownHostAdapter
62
+
63
+
64
+ # Registry order = claim priority. First adapter whose ``claim()`` returns
65
+ # True owns the call. The unknown fallback always claims so the order
66
+ # above it MUST cover every supported host.
67
+ #
68
+ # IMPORTANT: keep this list ordered from most-specific to least-specific.
69
+ # The Claude Code adapter goes first because Anthropic's PreToolUse
70
+ # stdin payload is the most distinctive (it always carries a
71
+ # ``tool_input`` envelope alongside a ``session_id`` field). Codex
72
+ # / Gemini follow with their own signatures. Unknown is always last.
73
+ REGISTRY: list[HostAdapter] = [
74
+ ClaudeCodeAdapter(),
75
+ CodexCLIAdapter(),
76
+ GeminiCLIAdapter(),
77
+ UnknownHostAdapter(), # always claims; must stay at the tail
78
+ ]
79
+
80
+
81
+ def select_adapter(payload: dict, env) -> HostAdapter:
82
+ """Pick the right host adapter for this hook invocation.
83
+
84
+ Iterates ``REGISTRY`` and returns the first adapter whose
85
+ ``claim()`` returns True. ``UnknownHostAdapter`` is the
86
+ backstop, so this function never raises.
87
+ """
88
+ for adapter in REGISTRY:
89
+ try:
90
+ if adapter.claim(payload, env):
91
+ return adapter
92
+ except Exception: # noqa: BLE001
93
+ # An adapter's claim() must never crash the hook. If one
94
+ # does, skip it and try the next. Unknown is the floor.
95
+ continue
96
+ # Unreachable in practice (UnknownHostAdapter always claims),
97
+ # but mypy/runtime safety.
98
+ return REGISTRY[-1]
99
+
100
+
101
+ __all__ = [
102
+ "CZDecision",
103
+ "HostAdapter",
104
+ "ClaudeCodeAdapter",
105
+ "CodexCLIAdapter",
106
+ "GeminiCLIAdapter",
107
+ "UnknownHostAdapter",
108
+ "REGISTRY",
109
+ "select_adapter",
110
+ ]
@@ -0,0 +1,141 @@
1
+ """Canonical Decision + abstract HostAdapter contract.
2
+
3
+ The CZ canonical Decision is the policy engine's single source of
4
+ truth. It carries everything the audit log + downstream consumers
5
+ need to reason about the decision. Adapters translate it on the
6
+ way out to whatever shape the host hook validator accepts.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import Mapping, Optional
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class CZDecision:
17
+ """The canonical Control Zero decision. Adapter-independent.
18
+
19
+ Adapters never construct one of these directly -- the hook_check
20
+ workflow builds it from the policy engine's
21
+ ``PolicyDecision`` + the resolved extractor + the tamper context.
22
+ Adapters consume it via ``HostAdapter.render(decision)`` to
23
+ produce the host-specific JSON envelope.
24
+
25
+ Fields
26
+ ------
27
+ effect : "allow" | "deny" | "warn"
28
+ Policy engine's verdict.
29
+ reason : str
30
+ Human-readable, branded message ("[Control Zero] ..."). Goes
31
+ to the agent (model-facing).
32
+ reason_code : str
33
+ Machine-readable enum (see ``controlzero._internal.enforcer``
34
+ REASON_CODE_*). Used by downstream consumers that branch on
35
+ why a decision happened.
36
+ policy_id : str
37
+ UUID of the rule that matched (or ``"<noop>"`` on no-rule
38
+ carve-outs). Empty string when not applicable.
39
+ tool : str
40
+ Canonical tool name post-extraction (e.g. ``"file_write"``,
41
+ ``"database"``, ``"network"``). Independent of host tool name.
42
+ method : str
43
+ Extracted method (e.g. SQL keyword for database, file path
44
+ for file_write). Empty string when not extractable.
45
+ extracted_action : str
46
+ ``f"{tool}:{method}"`` convenience shape the audit dashboard
47
+ renders. Empty string when not extractable.
48
+ semantic_class : str
49
+ Class-level normaliser parallel to ``method`` (e.g. SQL
50
+ ``read|write|admin|exec``). Empty outside SQL or when the
51
+ keyword has no class mapping.
52
+ tamper_detected : bool
53
+ True when the policy file HMAC or audit chain failed
54
+ verification. Carries through to host output so the agent
55
+ can surface a tamper notice.
56
+ audit_chain_broken : bool
57
+ True when the local audit chain hash sequence is broken.
58
+ Same surfacing rationale as ``tamper_detected``.
59
+ """
60
+
61
+ effect: str # "allow" | "deny" | "warn"
62
+ reason: str
63
+ reason_code: str
64
+ policy_id: str = ""
65
+ tool: str = ""
66
+ method: str = ""
67
+ extracted_action: str = ""
68
+ semantic_class: str = ""
69
+ tamper_detected: bool = False
70
+ audit_chain_broken: bool = False
71
+
72
+ @property
73
+ def is_deny(self) -> bool:
74
+ return self.effect == "deny"
75
+
76
+ @property
77
+ def is_allow(self) -> bool:
78
+ return self.effect in ("allow", "warn")
79
+
80
+
81
+ class HostAdapter:
82
+ """Per-host hook adapter. Subclass + register in REGISTRY.
83
+
84
+ A subclass needs three things:
85
+
86
+ * ``name`` -- short identifier used in logs ("claude_code").
87
+ * ``canonical_source`` -- backend ``audit.NormalizeSource`` alias
88
+ ("claude_code" / "gemini_cli" / "codex_cli" / "unknown").
89
+ This is what the audit row's SOURCE column resolves to so
90
+ the dashboard renders the right label.
91
+ * ``claim(payload, env)`` -- True when this adapter recognises
92
+ the inbound hook invocation. The registry tries adapters in
93
+ order; first claim wins. Use a combination of stdin payload
94
+ shape AND env vars so detection survives a host that doesn't
95
+ export the canonical env var on every platform.
96
+ * ``render(decision)`` -- map a ``CZDecision`` to the host's
97
+ JSON envelope. Return a ``dict`` ready for ``json.dumps``.
98
+
99
+ Subclasses may also override:
100
+
101
+ * ``extract_method(tool_name, tool_input)`` -- canonical-tool
102
+ extraction overrides. Most adapters inherit the shared
103
+ extractor from ``controlzero._internal.tool_extractors``; only
104
+ override when a host packs its tool args differently (e.g.
105
+ PowerShell's nested command syntax that needs parsing before
106
+ the canonical extractor can see ``file_write``).
107
+ """
108
+
109
+ #: Short identifier used in logs / metrics.
110
+ name: str = "base"
111
+
112
+ #: Backend ``audit.NormalizeSource`` alias. Must be one of the
113
+ #: canonical values in ``backend/internal/audit/source.go`` so
114
+ #: the dashboard renders the right label. Default is
115
+ #: ``"unknown"`` -- subclasses MUST override.
116
+ canonical_source: str = "unknown"
117
+
118
+ # -- detection --------------------------------------------------
119
+
120
+ def claim(self, payload: dict, env: Mapping[str, str]) -> bool:
121
+ """Return True if this adapter recognises this hook call."""
122
+ raise NotImplementedError
123
+
124
+ # -- rendering --------------------------------------------------
125
+
126
+ def render(self, decision: CZDecision) -> dict:
127
+ """Translate a CZDecision into this host's JSON envelope."""
128
+ raise NotImplementedError
129
+
130
+ # -- shared default extractor passthrough ----------------------
131
+
132
+ def extract_method(self, tool_name: str, tool_input: dict) -> tuple[str, str]:
133
+ """Default = shared extractor. Override for host-specific shells."""
134
+ from controlzero._internal.enforcer import extract_method # local import to avoid cycle
135
+
136
+ return extract_method(tool_name, tool_input)
137
+
138
+
139
+ # Convenience type-aliases used by tests.
140
+ HostDecision = dict
141
+ HostPayload = dict
@@ -0,0 +1,121 @@
1
+ """Claude Code PreToolUse hook adapter.
2
+
3
+ Spec reference: https://docs.anthropic.com/en/docs/claude-code/hooks
4
+
5
+ Stdin payload shape Claude Code sends (PreToolUse):
6
+ ::
7
+
8
+ {
9
+ "session_id": "uuid",
10
+ "transcript_path": "/path/to/transcript",
11
+ "cwd": "/working/dir",
12
+ "tool_name": "Write" | "Bash" | "Read" | "Edit" | ...,
13
+ "tool_input": { ...tool args... },
14
+ "hook_event_name": "PreToolUse"
15
+ }
16
+
17
+ Stdout decision shape Claude Code accepts:
18
+ ::
19
+
20
+ {
21
+ "decision": "approve" | "block", // optional; omit = proceed
22
+ "reason": "string" // shown to the model when block
23
+ }
24
+
25
+ Critical: ``"decision": "allow"`` is **NOT** valid -- Claude Code
26
+ rejects the hook output with
27
+ ``Hook JSON output validation failed - (root): Invalid input`` and
28
+ falls back to its default (proceed). That is the 2026-05-12
29
+ CloudShift PowerShell-bypass root cause.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from typing import Mapping
35
+
36
+ from controlzero.cli.hosts.base import CZDecision, HostAdapter
37
+
38
+
39
+ class ClaudeCodeAdapter(HostAdapter):
40
+ name = "claude_code"
41
+ canonical_source = "claude_code"
42
+
43
+ # Env-var hints (best-effort). On macOS / Linux the Anthropic
44
+ # launcher exports CLAUDECODE=1; on Windows the env-var may be
45
+ # missing depending on launcher version, so the stdin shape
46
+ # check below is the authoritative signal.
47
+ _ENV_HINTS = (
48
+ "CLAUDECODE",
49
+ "CLAUDE_CODE",
50
+ "CLAUDE_CODE_HOOK_VERSION",
51
+ "ANTHROPIC_CLI",
52
+ )
53
+
54
+ def claim(self, payload: dict, env: Mapping[str, str]) -> bool:
55
+ # Explicit env-var override always wins. ``CONTROLZERO_CLIENT``
56
+ # lets a power-user or CI environment force the right adapter.
57
+ if (env.get("CONTROLZERO_CLIENT") or "").strip().lower() in (
58
+ "claude-code",
59
+ "claude_code",
60
+ "claudecode",
61
+ ):
62
+ return True
63
+
64
+ # Env-var hint: any of the Anthropic-family vars present.
65
+ for hint in self._ENV_HINTS:
66
+ if env.get(hint):
67
+ return True
68
+
69
+ # Stdin payload signature: PreToolUse always carries
70
+ # ``hook_event_name`` (post-2024-10 schema) OR a
71
+ # ``session_id`` + ``transcript_path`` + ``tool_input``
72
+ # tuple. We accept either to handle older + newer Claude
73
+ # Code releases.
74
+ if isinstance(payload, dict):
75
+ if payload.get("hook_event_name") in (
76
+ "PreToolUse",
77
+ "PostToolUse",
78
+ "Notification",
79
+ "Stop",
80
+ "SubagentStop",
81
+ ):
82
+ return True
83
+ if (
84
+ "session_id" in payload
85
+ and "transcript_path" in payload
86
+ and ("tool_input" in payload or "tool_name" in payload)
87
+ ):
88
+ return True
89
+
90
+ return False
91
+
92
+ def render(self, decision: CZDecision) -> dict:
93
+ # Claude Code's PreToolUse hook spec:
94
+ # decision: "approve" | "block" | (omit)
95
+ # On deny we emit "block". On allow we explicitly emit
96
+ # "approve" so Claude Code records that the hook ran AND
97
+ # decided yes (cleaner audit + bypasses any user-permission
98
+ # prompt that would otherwise interrupt the flow). The
99
+ # legacy "allow" string is REMOVED -- it crashed validation.
100
+ envelope: dict = {
101
+ "reason": decision.reason,
102
+ # Non-spec metadata fields. Claude Code is permissive
103
+ # about extras (per docs, "additional fields are
104
+ # ignored"). These keep the existing fields the SDK
105
+ # was emitting so any external consumer parsing the
106
+ # JSON keeps working.
107
+ "reason_code": decision.reason_code,
108
+ "tool": decision.tool,
109
+ "extracted_method": decision.method,
110
+ "action": decision.extracted_action,
111
+ "action_semantic_class": decision.semantic_class,
112
+ "tamper_detected": decision.tamper_detected,
113
+ "audit_chain_broken": decision.audit_chain_broken,
114
+ }
115
+ if decision.is_deny:
116
+ envelope["decision"] = "block"
117
+ else:
118
+ # Allow path. "approve" is the explicit positive signal;
119
+ # Claude Code accepts it without a permission prompt.
120
+ envelope["decision"] = "approve"
121
+ return envelope
@@ -0,0 +1,54 @@
1
+ """Codex CLI PreToolUse hook adapter.
2
+
3
+ Codex CLI ships its hook config at ``~/.codex/hooks.json`` with
4
+ the PreToolUse contract verified March 2026:
5
+
6
+ * Stdin: ``{ "tool_name", "tool_input", "session_id", "cwd" }``.
7
+ * Stdout decision:
8
+ ``{ "decision": "allow" | "deny", "reason": "..." }``
9
+ or exit 2 + reason on stderr.
10
+ * Codex launcher exports ``CODEX_HOME`` (path to ``~/.codex``),
11
+ ``CODEX_PROFILE`` (active profile name), and ``CODEX_CLI`` (a
12
+ mirror of the cross-SDK convention).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Mapping
18
+
19
+ from controlzero.cli.hosts.base import CZDecision, HostAdapter
20
+
21
+
22
+ class CodexCLIAdapter(HostAdapter):
23
+ name = "codex_cli"
24
+ canonical_source = "codex_cli"
25
+
26
+ _ENV_HINTS = ("CODEX_HOME", "CODEX_PROFILE", "CODEX_CLI")
27
+
28
+ def claim(self, payload: dict, env: Mapping[str, str]) -> bool:
29
+ if (env.get("CONTROLZERO_CLIENT") or "").strip().lower() in (
30
+ "codex-cli",
31
+ "codex_cli",
32
+ "codex",
33
+ ):
34
+ return True
35
+ for hint in self._ENV_HINTS:
36
+ if env.get(hint):
37
+ return True
38
+ return False
39
+
40
+ def render(self, decision: CZDecision) -> dict:
41
+ envelope: dict = {
42
+ "reason": decision.reason,
43
+ "reason_code": decision.reason_code,
44
+ "tool": decision.tool,
45
+ "extracted_method": decision.method,
46
+ "action": decision.extracted_action,
47
+ "action_semantic_class": decision.semantic_class,
48
+ "tamper_detected": decision.tamper_detected,
49
+ "audit_chain_broken": decision.audit_chain_broken,
50
+ }
51
+ # Codex spec uses allow / deny verbs (NOT Anthropic's
52
+ # approve / block).
53
+ envelope["decision"] = "deny" if decision.is_deny else "allow"
54
+ return envelope
@@ -0,0 +1,58 @@
1
+ """Gemini CLI BeforeTool hook adapter.
2
+
3
+ Gemini CLI config lives at ``~/.gemini/settings.json``. The hook
4
+ contract was verified against the public docs (April 2026):
5
+
6
+ * Schema slot: ``hooks.BeforeTool[].hooks[]`` (array of
7
+ ``{ type: "command", command, timeout }``).
8
+ * Stdin: ``{ tool_name, tool_input, session_id, cwd, ... }``.
9
+ * Stdout decision: ``{ "decision": "deny", "reason": "..." }`` or
10
+ exit 2 to block. Anything else proceeds.
11
+
12
+ Reference: https://www.geminicli.com/docs/hooks/reference
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Mapping
18
+
19
+ from controlzero.cli.hosts.base import CZDecision, HostAdapter
20
+
21
+
22
+ class GeminiCLIAdapter(HostAdapter):
23
+ name = "gemini_cli"
24
+ canonical_source = "gemini_cli"
25
+
26
+ # Only true CLI runtime signals. ``GEMINI_API_KEY`` is set by
27
+ # anyone using Google's Python SDK directly and must NOT match
28
+ # (Bryan deny-deny incident, 2026-05-10 -- T78 / T92).
29
+ _ENV_HINTS = ("GEMINI_CLI", "GEMINI_SANDBOX", "GEMINI_SYSTEM_MD")
30
+
31
+ def claim(self, payload: dict, env: Mapping[str, str]) -> bool:
32
+ if (env.get("CONTROLZERO_CLIENT") or "").strip().lower() in (
33
+ "gemini-cli",
34
+ "gemini_cli",
35
+ "gemini",
36
+ ):
37
+ return True
38
+ for hint in self._ENV_HINTS:
39
+ if env.get(hint):
40
+ return True
41
+ return False
42
+
43
+ def render(self, decision: CZDecision) -> dict:
44
+ envelope: dict = {
45
+ "reason": decision.reason,
46
+ "reason_code": decision.reason_code,
47
+ "tool": decision.tool,
48
+ "extracted_method": decision.method,
49
+ "action": decision.extracted_action,
50
+ "action_semantic_class": decision.semantic_class,
51
+ "tamper_detected": decision.tamper_detected,
52
+ "audit_chain_broken": decision.audit_chain_broken,
53
+ }
54
+ if decision.is_deny:
55
+ envelope["decision"] = "deny"
56
+ # Gemini: omit decision on allow. Per docs, any non-deny
57
+ # body or exit 0 with no body proceeds.
58
+ return envelope
@@ -0,0 +1,52 @@
1
+ """Conservative fallback adapter for unrecognised host runtimes.
2
+
3
+ Always claims the call (final entry in REGISTRY) so the SDK never
4
+ crashes when a brand-new agent invokes the hook before we have a
5
+ dedicated adapter for it.
6
+
7
+ Render rules:
8
+
9
+ * On deny -- emit ``{"decision": "block", "reason": ...}``. Block
10
+ is universal: every host runtime we have seen treats ``block``
11
+ as the explicit "do not proceed" signal. If a future host uses
12
+ a different keyword we will catch it with a dedicated adapter
13
+ before the unknown fallback is hit.
14
+ * On allow -- emit a body with NO ``decision`` field. Per every
15
+ known hook protocol, the absence of a decision means "no
16
+ opinion, proceed normally." Safer than guessing ``approve`` /
17
+ ``allow`` / ``ok`` and tripping a strict validator.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Mapping
23
+
24
+ from controlzero.cli.hosts.base import CZDecision, HostAdapter
25
+
26
+
27
+ class UnknownHostAdapter(HostAdapter):
28
+ name = "unknown"
29
+ # Canonical source value mapped in
30
+ # ``backend/internal/audit/source.go`` -- renders as ``--`` on
31
+ # the dashboard. Better than mislabelling.
32
+ canonical_source = "unknown"
33
+
34
+ def claim(self, payload: dict, env: Mapping[str, str]) -> bool:
35
+ # Final-fallback adapter: always claims.
36
+ return True
37
+
38
+ def render(self, decision: CZDecision) -> dict:
39
+ envelope: dict = {
40
+ "reason": decision.reason,
41
+ "reason_code": decision.reason_code,
42
+ "tool": decision.tool,
43
+ "extracted_method": decision.method,
44
+ "action": decision.extracted_action,
45
+ "action_semantic_class": decision.semantic_class,
46
+ "tamper_detected": decision.tamper_detected,
47
+ "audit_chain_broken": decision.audit_chain_broken,
48
+ }
49
+ if decision.is_deny:
50
+ envelope["decision"] = "block"
51
+ # Allow: NO decision field. Universal proceed.
52
+ return envelope