controlzero 1.5.6__tar.gz → 1.5.8__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.6 → controlzero-1.5.8}/.gitignore +4 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/CHANGELOG.md +57 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/PKG-INFO +1 -1
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/__init__.py +1 -1
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/_internal/enforcer.py +23 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/_internal/types.py +6 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/hosts/__init__.py +3 -3
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/hosts/base.py +4 -5
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/hosts/unknown.py +2 -3
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/enrollment.py +3 -4
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/hosted_policy.py +2 -2
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/policy_loader.py +78 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/pyproject.toml +1 -1
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_api_key_mask.py +5 -5
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_env_dump_438.py +2 -2
- controlzero-1.5.8/tests/test_hitl_reason_codes.py +72 -0
- controlzero-1.5.8/tests/test_hitl_validator_keys.py +260 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_install_hook_command.py +2 -2
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_reason_code.py +6 -2
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_t103_precedence.py +4 -4
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_t104_cache_gc.py +23 -23
- {controlzero-1.5.6 → controlzero-1.5.8}/Dockerfile.test +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/LICENSE +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/README.md +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/_internal/__init__.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/_internal/action_aliases.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/_internal/bundle.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/_internal/dlp_scanner.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/_internal/hook_extractors.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/_internal/tool_extractors.json +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/audit_local.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/audit_remote.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/__init__.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/_secrets.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/console.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/debug_bundle.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/doctor.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/hosts/claude_code.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/hosts/codex_cli.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/hosts/gemini_cli.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/main.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/migrate.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/telemetry_consent.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/templates/autogen.yaml +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/templates/claude-code.yaml +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/templates/codex-cli.yaml +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/templates/cost-cap.yaml +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/templates/crewai.yaml +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/templates/cursor.yaml +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/templates/gemini-cli.yaml +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/templates/generic.yaml +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/templates/langchain.yaml +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/templates/mcp.yaml +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/templates/rag.yaml +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/client.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/device.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/error_codes.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/errors.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/__init__.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/anthropic.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/autogen.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/braintrust.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/crewai/__init__.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/crewai/agent.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/crewai/crew.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/crewai/task.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/crewai/tool.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/google.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/google_adk/__init__.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/google_adk/agent.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/google_adk/tool.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/langchain/__init__.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/langchain/agent.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/langchain/callbacks.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/langchain/chain.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/langchain/graph.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/langchain/modern.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/langchain/tool.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/langfuse.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/litellm.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/openai.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/pydantic_ai.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/vercel_ai.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/layout_migration.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/tamper.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/examples/hello_world.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/conftest.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/integrations/__init__.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/integrations/test_google.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/parity/action_aliases.json +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_action_aliases.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_action_canonicalization.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_agent_name_env.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_audit_remote.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_audit_remote_sdk_version.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_audit_sink_isolation.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_bundle_parser.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_bundle_translate.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_cli_carve_out.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_cli_debug_bundle.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_cli_extractor_integration.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_cli_hook.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_cli_hosted_refresh.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_cli_init.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_cli_init_templates.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_cli_tail.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_cli_test.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_cli_validate.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_coding_agent_hooks.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_conditions.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_console.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_default_action.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_device.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_dlp_scanner.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_doctor.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_enrollment.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_error_codes.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_errors_e_codes.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_fail_closed_eval.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_glob_matching.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_hook_extractors.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_hosted_policy_e2e.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_hosts_adapter.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_hybrid_mode_strict.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_hybrid_mode_warn.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_install_hooks.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_layout_migration_t101.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_layout_parity_t102.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_local_mode_dict.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_local_mode_file_json.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_local_mode_file_yaml.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_log_fallback_stderr.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_log_options_ignored_hosted.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_log_rotation.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_migrate.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_no_policy_no_key.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_package_rename_shim.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_policy_freshness.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_policy_settings.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_quarantine.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_refresh.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_secrets.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_sql_semantic_class.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_synthetic_policy_id_t79.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_t108_local_override_audit.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_t96_single_audit_log.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_t99_install_prefetch_bundle.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_tamper.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_tamper_behavior.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_tamper_hook.py +0 -0
- {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_telemetry_consent.py +0 -0
|
@@ -1,5 +1,62 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v1.5.8 -- 2026-05-16 (HITL-5a, gh#538)
|
|
4
|
+
|
|
5
|
+
Additive minor preparing the SDK for the Human-in-the-Loop approval
|
|
6
|
+
workflow that ships in 1.6.0 (HITL-6a, gh#542). Pure additive: no
|
|
7
|
+
behavior change on existing policies; no new public API surface.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`escalate_on_deny: bool` rule key** is acknowledged. Customers may
|
|
12
|
+
pre-tag deny rules for HITL eligibility; 1.5.8 persists the field
|
|
13
|
+
on `PolicyRule` (default `False`). The actual `request_approval()`
|
|
14
|
+
flow ships in 1.6.0; 1.5.8 ensures a customer pre-tagging rules
|
|
15
|
+
doesn't crash an old client.
|
|
16
|
+
- **Typo guardrail** on rule keys. Unknown keys within Levenshtein-1
|
|
17
|
+
of a known key (`escalate_on_dney` -> `escalate_on_deny`) now print
|
|
18
|
+
a one-line "did you mean?" warning to stderr. Unknown keys far from
|
|
19
|
+
any known key remain silently accepted (additive contract).
|
|
20
|
+
- **9 new HITL reason codes** registered in `VALID_REASON_CODES`:
|
|
21
|
+
`HITL_SDK_TIMEOUT`, `HITL_SLA_EXPIRED`, `HITL_BACKEND_UNREACHABLE`,
|
|
22
|
+
`HITL_POLICY_VERSION_CONFLICT`, `HITL_NO_APPROVER_AVAILABLE`,
|
|
23
|
+
`HITL_IDENTITY_NOT_IN_ORG`, `HITL_IDENTITY_REQUIRED`,
|
|
24
|
+
`HITL_IDENTITY_CLAIM_REJECTED`, `HITL_ARGS_HASH_MISMATCH`. 1.5.8
|
|
25
|
+
itself never emits these; registration ensures a 1.6.0+ client's
|
|
26
|
+
audit rows pass 1.5.8 ingest validation during a mixed-version
|
|
27
|
+
rollout.
|
|
28
|
+
|
|
29
|
+
### Unchanged
|
|
30
|
+
|
|
31
|
+
- 11-line Hello World still works.
|
|
32
|
+
- All 156 existing SDK hook tests pass.
|
|
33
|
+
- No new methods on `Client` or `PolicyDecision`.
|
|
34
|
+
- Existing 1.5.7 policies parse identically (escalate_on_deny defaults
|
|
35
|
+
to `False`; legacy keys still in `_KNOWN_RULE_KEYS`).
|
|
36
|
+
|
|
37
|
+
## v1.5.7 -- 2026-05-16 (PRIVACY)
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
|
|
41
|
+
- **Customer-context comments in the published wheel.** Codex +
|
|
42
|
+
Gemini outside-voice review surfaced several customer-context
|
|
43
|
+
strings + private monorepo path references that v1.5.6 missed:
|
|
44
|
+
- `controlzero/hosted_policy.py` referenced a customer-specific
|
|
45
|
+
possessive when describing the T104 cache-GC scenario. Rewritten
|
|
46
|
+
in neutral phrasing that preserves the technical context.
|
|
47
|
+
- `controlzero/enrollment.py` documented the wire-format contract
|
|
48
|
+
via a private monorepo path. Replaced with a pointer to the
|
|
49
|
+
public docs site.
|
|
50
|
+
- `controlzero/cli/hosts/base.py` and
|
|
51
|
+
`controlzero/cli/hosts/unknown.py` referenced an internal
|
|
52
|
+
backend filename in inline comments. Replaced with a generic
|
|
53
|
+
description of the constraint.
|
|
54
|
+
- Tests `test_t104_cache_gc.py` and `test_t103_precedence.py`
|
|
55
|
+
carried a geographic identifier and a customer-derived test
|
|
56
|
+
name (NOT in the published wheel since tests are excluded, but
|
|
57
|
+
scrubbed for consistency).
|
|
58
|
+
- **No behavior change.** Comments + docstrings only.
|
|
59
|
+
|
|
3
60
|
## v1.5.6 -- 2026-05-15 (PRIVACY)
|
|
4
61
|
|
|
5
62
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: controlzero
|
|
3
|
-
Version: 1.5.
|
|
3
|
+
Version: 1.5.8
|
|
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
|
|
@@ -66,6 +66,20 @@ REASON_CODE_DLP_BLOCKED = "DLP_BLOCKED"
|
|
|
66
66
|
# decision is "audit" and policy_id is "<lifecycle>".
|
|
67
67
|
REASON_CODE_LOCAL_OVERRIDE_ACTIVE = "LOCAL_OVERRIDE_ACTIVE"
|
|
68
68
|
|
|
69
|
+
# HITL approval-flow reason codes (HITL-5a, gh#538). The actual
|
|
70
|
+
# request-approval flow ships in 1.6.0 (HITL-6a, gh#542); 1.5.8
|
|
71
|
+
# registers the codes so audit rows from a 1.6.0+ client can be
|
|
72
|
+
# accepted by a 1.5.8 ingest path without rejection.
|
|
73
|
+
REASON_CODE_HITL_SDK_TIMEOUT = "HITL_SDK_TIMEOUT"
|
|
74
|
+
REASON_CODE_HITL_SLA_EXPIRED = "HITL_SLA_EXPIRED"
|
|
75
|
+
REASON_CODE_HITL_BACKEND_UNREACHABLE = "HITL_BACKEND_UNREACHABLE"
|
|
76
|
+
REASON_CODE_HITL_POLICY_VERSION_CONFLICT = "HITL_POLICY_VERSION_CONFLICT"
|
|
77
|
+
REASON_CODE_HITL_NO_APPROVER_AVAILABLE = "HITL_NO_APPROVER_AVAILABLE"
|
|
78
|
+
REASON_CODE_HITL_IDENTITY_NOT_IN_ORG = "HITL_IDENTITY_NOT_IN_ORG"
|
|
79
|
+
REASON_CODE_HITL_IDENTITY_REQUIRED = "HITL_IDENTITY_REQUIRED"
|
|
80
|
+
REASON_CODE_HITL_IDENTITY_CLAIM_REJECTED = "HITL_IDENTITY_CLAIM_REJECTED"
|
|
81
|
+
REASON_CODE_HITL_ARGS_HASH_MISMATCH = "HITL_ARGS_HASH_MISMATCH"
|
|
82
|
+
|
|
69
83
|
VALID_REASON_CODES = frozenset({
|
|
70
84
|
REASON_CODE_RULE_MATCH,
|
|
71
85
|
REASON_CODE_NO_RULE_MATCH,
|
|
@@ -76,6 +90,15 @@ VALID_REASON_CODES = frozenset({
|
|
|
76
90
|
REASON_CODE_NETWORK_ERROR,
|
|
77
91
|
REASON_CODE_DLP_BLOCKED,
|
|
78
92
|
REASON_CODE_LOCAL_OVERRIDE_ACTIVE,
|
|
93
|
+
REASON_CODE_HITL_SDK_TIMEOUT,
|
|
94
|
+
REASON_CODE_HITL_SLA_EXPIRED,
|
|
95
|
+
REASON_CODE_HITL_BACKEND_UNREACHABLE,
|
|
96
|
+
REASON_CODE_HITL_POLICY_VERSION_CONFLICT,
|
|
97
|
+
REASON_CODE_HITL_NO_APPROVER_AVAILABLE,
|
|
98
|
+
REASON_CODE_HITL_IDENTITY_NOT_IN_ORG,
|
|
99
|
+
REASON_CODE_HITL_IDENTITY_REQUIRED,
|
|
100
|
+
REASON_CODE_HITL_IDENTITY_CLAIM_REJECTED,
|
|
101
|
+
REASON_CODE_HITL_ARGS_HASH_MISMATCH,
|
|
79
102
|
})
|
|
80
103
|
|
|
81
104
|
# Synthetic policy_id sentinels (T79 / the deny-deny postmortem,
|
|
@@ -23,3 +23,9 @@ class PolicyRule(BaseModel):
|
|
|
23
23
|
# "NO_ACTIVE_POLICIES"). User-authored rules leave this empty and
|
|
24
24
|
# the evaluator does not promote them to a code.
|
|
25
25
|
reason_code: str = ""
|
|
26
|
+
# HITL escalation tag (HITL-5a, gh#538): when True and the rule's
|
|
27
|
+
# effect is `deny`, the SDK marks the resulting PolicyDecision as
|
|
28
|
+
# `hitl_eligible=True`. The actual approval-request flow ships in
|
|
29
|
+
# 1.6.0 (HITL-6a, gh#542); 1.5.8 just acknowledges the field so a
|
|
30
|
+
# customer pre-tagging rules for HITL doesn't crash an old client.
|
|
31
|
+
escalate_on_deny: bool = False
|
|
@@ -36,9 +36,9 @@ A ``HostAdapter`` owns three responsibilities for one host runtime:
|
|
|
36
36
|
(Windows Claude Code is the canonical case).
|
|
37
37
|
2. ``render(decision)`` -- translate the canonical ``CZDecision``
|
|
38
38
|
into the JSON shape this host's hook validator accepts.
|
|
39
|
-
3. ``canonical_source`` -- the backend
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
3. ``canonical_source`` -- the backend-side source alias
|
|
40
|
+
(``claude_code`` / ``gemini_cli`` / ``codex_cli`` / etc.) so the
|
|
41
|
+
audit row's SOURCE column renders correctly.
|
|
42
42
|
|
|
43
43
|
Adding a new host (Cursor, Windsurf, OpenClaw, Antigravity, the
|
|
44
44
|
next agent that ships) is a single file in this package: subclass
|
|
@@ -84,7 +84,7 @@ class HostAdapter:
|
|
|
84
84
|
A subclass needs three things:
|
|
85
85
|
|
|
86
86
|
* ``name`` -- short identifier used in logs ("claude_code").
|
|
87
|
-
* ``canonical_source`` -- backend
|
|
87
|
+
* ``canonical_source`` -- backend-side source alias
|
|
88
88
|
("claude_code" / "gemini_cli" / "codex_cli" / "unknown").
|
|
89
89
|
This is what the audit row's SOURCE column resolves to so
|
|
90
90
|
the dashboard renders the right label.
|
|
@@ -109,10 +109,9 @@ class HostAdapter:
|
|
|
109
109
|
#: Short identifier used in logs / metrics.
|
|
110
110
|
name: str = "base"
|
|
111
111
|
|
|
112
|
-
#: Backend
|
|
113
|
-
#:
|
|
114
|
-
#:
|
|
115
|
-
#: ``"unknown"`` -- subclasses MUST override.
|
|
112
|
+
#: Backend audit source alias. Must be one of the canonical
|
|
113
|
+
#: values the dashboard accepts so the right label renders.
|
|
114
|
+
#: Default is ``"unknown"`` -- subclasses MUST override.
|
|
116
115
|
canonical_source: str = "unknown"
|
|
117
116
|
|
|
118
117
|
# -- detection --------------------------------------------------
|
|
@@ -26,9 +26,8 @@ from controlzero.cli.hosts.base import CZDecision, HostAdapter
|
|
|
26
26
|
|
|
27
27
|
class UnknownHostAdapter(HostAdapter):
|
|
28
28
|
name = "unknown"
|
|
29
|
-
# Canonical source value
|
|
30
|
-
#
|
|
31
|
-
# the dashboard. Better than mislabelling.
|
|
29
|
+
# Canonical source value the dashboard accepts -- renders as
|
|
30
|
+
# ``--`` on the dashboard. Better than mislabelling.
|
|
32
31
|
canonical_source = "unknown"
|
|
33
32
|
|
|
34
33
|
def claim(self, payload: dict, env: Mapping[str, str]) -> bool:
|
|
@@ -19,10 +19,9 @@ private key lives in ``~/.controlzero/machine.key`` with mode 0600.
|
|
|
19
19
|
A follow-up will plug in Keychain/DPAPI/secret-service per platform;
|
|
20
20
|
the file-on-disk fallback stays as the bottom of the chain.
|
|
21
21
|
|
|
22
|
-
Wire-format: see
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
client side.
|
|
22
|
+
Wire-format contract: see the public docs and OpenAPI spec for the
|
|
23
|
+
machine-auth endpoint at https://docs.controlzero.ai. This module is
|
|
24
|
+
the source of truth on the client side.
|
|
26
25
|
"""
|
|
27
26
|
|
|
28
27
|
from __future__ import annotations
|
|
@@ -138,8 +138,8 @@ def gc_stale_cache(active_api_key: str) -> int:
|
|
|
138
138
|
T104 (2026-05-12, customer report): on key rotation the old key's cache files
|
|
139
139
|
(``bootstrap-<oldscope>.json``, ``bundle-<oldscope>.bin``,
|
|
140
140
|
``bundle-<oldscope>.meta``) accumulated forever next to the new key's
|
|
141
|
-
files.
|
|
142
|
-
next to a fresh ``bootstrap
|
|
141
|
+
files. A customer reported a 25-day-old ``bundle-<oldscope>.bin`` left
|
|
142
|
+
next to a fresh ``bootstrap-<newscope>.json`` because the rotated
|
|
143
143
|
key never invalidated the previous cache. We clean up on every fresh
|
|
144
144
|
bootstrap so the cache directory always reflects exactly one active
|
|
145
145
|
key per machine.
|
|
@@ -44,6 +44,63 @@ PolicyInput = Union[dict, str, Path]
|
|
|
44
44
|
|
|
45
45
|
VALID_TAMPER_BEHAVIORS = {"warn", "deny", "deny-all", "quarantine"}
|
|
46
46
|
|
|
47
|
+
# Known rule-level keys. Used by the typo guardrail (HITL-5a, gh#538)
|
|
48
|
+
# to surface "did you mean?" warnings on near-misses. Unknown keys are
|
|
49
|
+
# still silently accepted (additive contract; never break existing YAML)
|
|
50
|
+
# but a Levenshtein-1 match on a known key fires a `_KNOWN_RULE_KEYS`
|
|
51
|
+
# warning in stderr so the customer can self-correct.
|
|
52
|
+
_KNOWN_RULE_KEYS = frozenset({
|
|
53
|
+
"id",
|
|
54
|
+
"name",
|
|
55
|
+
"deny",
|
|
56
|
+
"allow",
|
|
57
|
+
"effect",
|
|
58
|
+
"action",
|
|
59
|
+
"actions",
|
|
60
|
+
"resource",
|
|
61
|
+
"resources",
|
|
62
|
+
"when",
|
|
63
|
+
"conditions",
|
|
64
|
+
"reason",
|
|
65
|
+
"reason_code",
|
|
66
|
+
"escalate_on_deny", # HITL tag (additive in 1.5.8; behavior in 1.6.0)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _levenshtein_le_1(a: str, b: str) -> bool:
|
|
71
|
+
"""Return True iff edit distance between `a` and `b` is exactly 0 or 1.
|
|
72
|
+
|
|
73
|
+
Used by the typo guardrail to suggest fixes for unknown rule keys
|
|
74
|
+
that are one keystroke away from a known one (e.g. `escalate_on_dnen`
|
|
75
|
+
-> `escalate_on_deny`). Pure-Python; no external dependency.
|
|
76
|
+
"""
|
|
77
|
+
if a == b:
|
|
78
|
+
return True
|
|
79
|
+
la, lb = len(a), len(b)
|
|
80
|
+
if abs(la - lb) > 1:
|
|
81
|
+
return False
|
|
82
|
+
if la > lb:
|
|
83
|
+
a, b = b, a
|
|
84
|
+
la, lb = lb, la
|
|
85
|
+
# la <= lb; check for one substitution (la == lb) or one insertion
|
|
86
|
+
i = j = diffs = 0
|
|
87
|
+
while i < la and j < lb:
|
|
88
|
+
if a[i] != b[j]:
|
|
89
|
+
diffs += 1
|
|
90
|
+
if diffs > 1:
|
|
91
|
+
return False
|
|
92
|
+
if la == lb:
|
|
93
|
+
i += 1
|
|
94
|
+
j += 1
|
|
95
|
+
else:
|
|
96
|
+
j += 1 # insertion in b
|
|
97
|
+
else:
|
|
98
|
+
i += 1
|
|
99
|
+
j += 1
|
|
100
|
+
if j < lb:
|
|
101
|
+
diffs += lb - j
|
|
102
|
+
return diffs <= 1
|
|
103
|
+
|
|
47
104
|
|
|
48
105
|
@dataclass
|
|
49
106
|
class PolicySettings:
|
|
@@ -258,6 +315,21 @@ def _validate_and_translate(data: dict, source_label: str) -> ParsedPolicy:
|
|
|
258
315
|
errors.append(f"rules[{i}]: must be a mapping, got {type(raw).__name__}")
|
|
259
316
|
continue
|
|
260
317
|
|
|
318
|
+
# Typo guardrail (HITL-5a, gh#538): warn on unknown rule keys that
|
|
319
|
+
# are within Levenshtein-1 of a known key. Unknown keys are still
|
|
320
|
+
# silently accepted (additive contract); the warning is stderr-only.
|
|
321
|
+
for k in raw.keys():
|
|
322
|
+
if k in _KNOWN_RULE_KEYS:
|
|
323
|
+
continue
|
|
324
|
+
for known in _KNOWN_RULE_KEYS:
|
|
325
|
+
if _levenshtein_le_1(k, known):
|
|
326
|
+
import sys as _sys
|
|
327
|
+
_sys.stderr.write(
|
|
328
|
+
f"controlzero: rules[{i}] unknown key {k!r}; "
|
|
329
|
+
f"did you mean {known!r}?\n"
|
|
330
|
+
)
|
|
331
|
+
break
|
|
332
|
+
|
|
261
333
|
# Determine effect from the keys present
|
|
262
334
|
has_deny = "deny" in raw
|
|
263
335
|
has_allow = "allow" in raw
|
|
@@ -339,6 +411,12 @@ def _validate_and_translate(data: dict, source_label: str) -> ParsedPolicy:
|
|
|
339
411
|
# emitted by bundle.translate_to_local_policy) set
|
|
340
412
|
# this today; user-authored rules leave it empty.
|
|
341
413
|
reason_code=str(raw.get("reason_code", "")),
|
|
414
|
+
# HITL escalation tag (HITL-5a, gh#538). Coerce truthy
|
|
415
|
+
# values; non-bool / missing => False. The actual
|
|
416
|
+
# request-approval flow ships in 1.6.0 (HITL-6a); 1.5.8
|
|
417
|
+
# just persists the field so old SDKs don't crash on
|
|
418
|
+
# rules that pre-tag for HITL.
|
|
419
|
+
escalate_on_deny=bool(raw.get("escalate_on_deny", False)),
|
|
342
420
|
)
|
|
343
421
|
)
|
|
344
422
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "controlzero"
|
|
7
|
-
version = "1.5.
|
|
7
|
+
version = "1.5.8"
|
|
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"}
|
|
@@ -19,12 +19,12 @@ from controlzero.client import _mask_api_key
|
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
def test_mask_live_key_emits_only_public_prefix():
|
|
22
|
-
masked = _mask_api_key("
|
|
22
|
+
masked = _mask_api_key("cz_live_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
|
23
23
|
assert masked == "cz_live_***"
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
def test_mask_test_key_emits_only_public_prefix():
|
|
27
|
-
masked = _mask_api_key("
|
|
27
|
+
masked = _mask_api_key("cz_test_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
|
|
28
28
|
assert masked == "cz_test_***"
|
|
29
29
|
|
|
30
30
|
|
|
@@ -42,10 +42,10 @@ def test_mask_empty_string_returns_triple_star():
|
|
|
42
42
|
|
|
43
43
|
|
|
44
44
|
def test_mask_never_leaks_secret_bytes_brute_force():
|
|
45
|
-
#
|
|
46
|
-
#
|
|
45
|
+
# Obviously-synthetic placeholder fixture. The 4-char window
|
|
46
|
+
# scan downstream remains meaningful: the masked output must not
|
|
47
47
|
# 1.5.0 -> 1.5.1 incident on 2026-05-12).
|
|
48
|
-
secret_tail = "
|
|
48
|
+
secret_tail = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
49
49
|
for prefix in ("cz_live_", "cz_test_"):
|
|
50
50
|
key = prefix + secret_tail
|
|
51
51
|
masked = _mask_api_key(key)
|
|
@@ -66,7 +66,7 @@ def test_env_dump_outputs_valid_json(monkeypatch):
|
|
|
66
66
|
def test_env_dump_redacts_api_key_by_default(monkeypatch):
|
|
67
67
|
monkeypatch.setenv(
|
|
68
68
|
"CONTROLZERO_API_KEY",
|
|
69
|
-
"
|
|
69
|
+
"cz_live_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
70
70
|
)
|
|
71
71
|
result = _invoke([])
|
|
72
72
|
assert result.exit_code == 0
|
|
@@ -74,7 +74,7 @@ def test_env_dump_redacts_api_key_by_default(monkeypatch):
|
|
|
74
74
|
masked = data["env"]["CONTROLZERO_API_KEY"]
|
|
75
75
|
assert masked == "cz_live_***"
|
|
76
76
|
# Defensive: the secret bytes MUST NOT appear anywhere in the dump.
|
|
77
|
-
secret_tail = "
|
|
77
|
+
secret_tail = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
78
78
|
assert secret_tail not in result.output
|
|
79
79
|
|
|
80
80
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Tests for HITL-5a (gh#538): new HITL reason codes in 1.5.8.
|
|
2
|
+
|
|
3
|
+
The 9 new codes are registered in VALID_REASON_CODES so a 1.6.0+ client
|
|
4
|
+
emitting them via audit doesn't get rejected by a 1.5.8 ingest path.
|
|
5
|
+
1.5.8 itself never EMITS these codes (no HITL flow yet); it just
|
|
6
|
+
recognizes them.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from controlzero._internal.enforcer import (
|
|
11
|
+
REASON_CODE_DLP_BLOCKED, # legacy sanity check
|
|
12
|
+
REASON_CODE_HITL_ARGS_HASH_MISMATCH,
|
|
13
|
+
REASON_CODE_HITL_BACKEND_UNREACHABLE,
|
|
14
|
+
REASON_CODE_HITL_IDENTITY_CLAIM_REJECTED,
|
|
15
|
+
REASON_CODE_HITL_IDENTITY_NOT_IN_ORG,
|
|
16
|
+
REASON_CODE_HITL_IDENTITY_REQUIRED,
|
|
17
|
+
REASON_CODE_HITL_NO_APPROVER_AVAILABLE,
|
|
18
|
+
REASON_CODE_HITL_POLICY_VERSION_CONFLICT,
|
|
19
|
+
REASON_CODE_HITL_SDK_TIMEOUT,
|
|
20
|
+
REASON_CODE_HITL_SLA_EXPIRED,
|
|
21
|
+
VALID_REASON_CODES,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
HITL_CODES = (
|
|
26
|
+
REASON_CODE_HITL_SDK_TIMEOUT,
|
|
27
|
+
REASON_CODE_HITL_SLA_EXPIRED,
|
|
28
|
+
REASON_CODE_HITL_BACKEND_UNREACHABLE,
|
|
29
|
+
REASON_CODE_HITL_POLICY_VERSION_CONFLICT,
|
|
30
|
+
REASON_CODE_HITL_NO_APPROVER_AVAILABLE,
|
|
31
|
+
REASON_CODE_HITL_IDENTITY_NOT_IN_ORG,
|
|
32
|
+
REASON_CODE_HITL_IDENTITY_REQUIRED,
|
|
33
|
+
REASON_CODE_HITL_IDENTITY_CLAIM_REJECTED,
|
|
34
|
+
REASON_CODE_HITL_ARGS_HASH_MISMATCH,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_all_9_hitl_codes_exported():
|
|
39
|
+
# Module-level constants exist + are non-empty strings.
|
|
40
|
+
for c in HITL_CODES:
|
|
41
|
+
assert isinstance(c, str) and c.startswith("HITL_")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_all_9_hitl_codes_in_valid_set():
|
|
45
|
+
for c in HITL_CODES:
|
|
46
|
+
assert c in VALID_REASON_CODES, f"{c!r} missing from VALID_REASON_CODES"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_legacy_codes_still_in_valid_set():
|
|
50
|
+
# No regression: existing 1.5.7 codes remain registered.
|
|
51
|
+
assert REASON_CODE_DLP_BLOCKED in VALID_REASON_CODES
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_hitl_codes_match_design_doc_exactly():
|
|
55
|
+
# Spelling guard: any rename here breaks cross-SDK parity with the
|
|
56
|
+
# design doc + Node + Go. Lock them in.
|
|
57
|
+
assert REASON_CODE_HITL_SDK_TIMEOUT == "HITL_SDK_TIMEOUT"
|
|
58
|
+
assert REASON_CODE_HITL_SLA_EXPIRED == "HITL_SLA_EXPIRED"
|
|
59
|
+
assert REASON_CODE_HITL_BACKEND_UNREACHABLE == "HITL_BACKEND_UNREACHABLE"
|
|
60
|
+
assert REASON_CODE_HITL_POLICY_VERSION_CONFLICT == "HITL_POLICY_VERSION_CONFLICT"
|
|
61
|
+
assert REASON_CODE_HITL_NO_APPROVER_AVAILABLE == "HITL_NO_APPROVER_AVAILABLE"
|
|
62
|
+
assert REASON_CODE_HITL_IDENTITY_NOT_IN_ORG == "HITL_IDENTITY_NOT_IN_ORG"
|
|
63
|
+
assert REASON_CODE_HITL_IDENTITY_REQUIRED == "HITL_IDENTITY_REQUIRED"
|
|
64
|
+
assert REASON_CODE_HITL_IDENTITY_CLAIM_REJECTED == "HITL_IDENTITY_CLAIM_REJECTED"
|
|
65
|
+
assert REASON_CODE_HITL_ARGS_HASH_MISMATCH == "HITL_ARGS_HASH_MISMATCH"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_valid_set_size_grew_by_exactly_9():
|
|
69
|
+
# 1.5.7 had 9 codes; 1.5.8 adds 9 -> 18. If anyone adds another
|
|
70
|
+
# code without updating this test, it fires so the addition is
|
|
71
|
+
# intentional + reviewed.
|
|
72
|
+
assert len(VALID_REASON_CODES) == 9 + 9
|