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