controlzero 1.5.7__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.7 → controlzero-1.5.8}/.gitignore +4 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/CHANGELOG.md +34 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/PKG-INFO +1 -1
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/__init__.py +1 -1
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/_internal/enforcer.py +23 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/_internal/types.py +6 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/policy_loader.py +78 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/pyproject.toml +1 -1
- 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.7 → controlzero-1.5.8}/tests/test_reason_code.py +6 -2
- {controlzero-1.5.7 → controlzero-1.5.8}/Dockerfile.test +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/LICENSE +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/README.md +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/_internal/__init__.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/_internal/action_aliases.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/_internal/bundle.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/_internal/dlp_scanner.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/_internal/hook_extractors.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/_internal/tool_extractors.json +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/audit_local.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/audit_remote.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/__init__.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/_secrets.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/console.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/debug_bundle.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/doctor.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/hosts/__init__.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/hosts/base.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/hosts/claude_code.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/hosts/codex_cli.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/hosts/gemini_cli.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/hosts/unknown.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/main.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/migrate.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/telemetry_consent.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/templates/autogen.yaml +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/templates/claude-code.yaml +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/templates/codex-cli.yaml +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/templates/cost-cap.yaml +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/templates/crewai.yaml +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/templates/cursor.yaml +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/templates/gemini-cli.yaml +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/templates/generic.yaml +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/templates/langchain.yaml +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/templates/mcp.yaml +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/templates/rag.yaml +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/client.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/device.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/enrollment.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/error_codes.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/errors.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/hosted_policy.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/__init__.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/anthropic.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/autogen.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/braintrust.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/crewai/__init__.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/crewai/agent.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/crewai/crew.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/crewai/task.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/crewai/tool.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/google.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/google_adk/__init__.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/google_adk/agent.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/google_adk/tool.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/langchain/__init__.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/langchain/agent.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/langchain/callbacks.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/langchain/chain.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/langchain/graph.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/langchain/modern.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/langchain/tool.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/langfuse.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/litellm.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/openai.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/pydantic_ai.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/vercel_ai.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/layout_migration.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/tamper.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/examples/hello_world.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/conftest.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/integrations/__init__.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/integrations/test_google.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/parity/action_aliases.json +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_action_aliases.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_action_canonicalization.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_agent_name_env.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_api_key_mask.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_audit_remote.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_audit_remote_sdk_version.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_audit_sink_isolation.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_bundle_parser.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_bundle_translate.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_cli_carve_out.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_cli_debug_bundle.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_cli_extractor_integration.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_cli_hook.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_cli_hosted_refresh.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_cli_init.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_cli_init_templates.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_cli_tail.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_cli_test.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_cli_validate.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_coding_agent_hooks.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_conditions.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_console.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_default_action.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_device.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_dlp_scanner.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_doctor.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_enrollment.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_env_dump_438.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_error_codes.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_errors_e_codes.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_fail_closed_eval.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_glob_matching.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_hook_extractors.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_hosted_policy_e2e.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_hosts_adapter.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_hybrid_mode_strict.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_hybrid_mode_warn.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_install_hook_command.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_install_hooks.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_layout_migration_t101.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_layout_parity_t102.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_local_mode_dict.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_local_mode_file_json.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_local_mode_file_yaml.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_log_fallback_stderr.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_log_options_ignored_hosted.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_log_rotation.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_migrate.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_no_policy_no_key.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_package_rename_shim.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_policy_freshness.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_policy_settings.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_quarantine.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_refresh.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_secrets.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_sql_semantic_class.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_synthetic_policy_id_t79.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_t103_precedence.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_t104_cache_gc.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_t108_local_override_audit.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_t96_single_audit_log.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_t99_install_prefetch_bundle.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_tamper.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_tamper_behavior.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_tamper_hook.py +0 -0
- {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_telemetry_consent.py +0 -0
|
@@ -1,5 +1,39 @@
|
|
|
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
|
+
|
|
3
37
|
## v1.5.7 -- 2026-05-16 (PRIVACY)
|
|
4
38
|
|
|
5
39
|
### 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
|
|
@@ -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"}
|
|
@@ -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
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Tests for HITL-5a (gh#538): policy validator additive surface in 1.5.8.
|
|
2
|
+
|
|
3
|
+
Covers every new branch in policy_loader.py:
|
|
4
|
+
- escalate_on_deny is recognized + plumbed into PolicyRule (default False).
|
|
5
|
+
- _levenshtein_le_1 helper: 0/1-edit pairs return True; >=2-edit pairs return False.
|
|
6
|
+
- Typo guardrail: warns on near-miss unknown rule keys (Levenshtein-1).
|
|
7
|
+
- Typo guardrail: silent on unknown keys far from any known one.
|
|
8
|
+
- Existing 1.5.7 policies (no escalate_on_deny field) still parse identically.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from controlzero.policy_loader import (
|
|
13
|
+
_KNOWN_RULE_KEYS,
|
|
14
|
+
_levenshtein_le_1,
|
|
15
|
+
load_policy,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TestLevenshteinHelper:
|
|
20
|
+
"""Pure-function unit tests for _levenshtein_le_1."""
|
|
21
|
+
|
|
22
|
+
def test_identical_strings_return_true(self):
|
|
23
|
+
assert _levenshtein_le_1("escalate_on_deny", "escalate_on_deny") is True
|
|
24
|
+
|
|
25
|
+
def test_empty_strings_return_true(self):
|
|
26
|
+
assert _levenshtein_le_1("", "") is True
|
|
27
|
+
|
|
28
|
+
def test_one_substitution(self):
|
|
29
|
+
# tru -> true is one substitution? No, that's an insertion.
|
|
30
|
+
# 'eqcalate_on_deny' vs 'escalate_on_deny': 'q'->'s' is one sub.
|
|
31
|
+
assert _levenshtein_le_1("eqcalate_on_deny", "escalate_on_deny") is True
|
|
32
|
+
|
|
33
|
+
def test_one_insertion(self):
|
|
34
|
+
assert _levenshtein_le_1("escalate_on_dny", "escalate_on_deny") is True
|
|
35
|
+
|
|
36
|
+
def test_one_deletion(self):
|
|
37
|
+
# symmetric to insertion; helper handles both via a swap.
|
|
38
|
+
assert _levenshtein_le_1("escalate_on_denyx", "escalate_on_deny") is True
|
|
39
|
+
|
|
40
|
+
def test_two_substitutions_return_false(self):
|
|
41
|
+
assert _levenshtein_le_1("escalate_on_dnen", "escalate_on_deny") is False
|
|
42
|
+
|
|
43
|
+
def test_length_diff_gt_1_short_circuit(self):
|
|
44
|
+
# Very different lengths: helper short-circuits without scanning.
|
|
45
|
+
assert _levenshtein_le_1("a", "abc") is False
|
|
46
|
+
assert _levenshtein_le_1("escalate", "escalate_on_deny") is False
|
|
47
|
+
|
|
48
|
+
def test_completely_different_strings(self):
|
|
49
|
+
assert _levenshtein_le_1("foo", "bar") is False
|
|
50
|
+
|
|
51
|
+
def test_one_char_strings(self):
|
|
52
|
+
assert _levenshtein_le_1("a", "a") is True
|
|
53
|
+
assert _levenshtein_le_1("a", "b") is True # one substitution
|
|
54
|
+
assert _levenshtein_le_1("a", "") is True # one deletion
|
|
55
|
+
assert _levenshtein_le_1("", "a") is True # one insertion
|
|
56
|
+
|
|
57
|
+
def test_swap_branch_when_b_shorter_than_a(self):
|
|
58
|
+
# Forces the la > lb swap branch.
|
|
59
|
+
assert _levenshtein_le_1("escalate_on_deny", "escalate_on_den") is True
|
|
60
|
+
|
|
61
|
+
def test_substitution_at_end(self):
|
|
62
|
+
assert _levenshtein_le_1("escalate_on_denz", "escalate_on_deny") is True
|
|
63
|
+
|
|
64
|
+
def test_diff_count_via_remaining_tail(self):
|
|
65
|
+
# When inner loop exhausts a but b has tail; helper adds remaining.
|
|
66
|
+
assert _levenshtein_le_1("foo", "fooxx") is False
|
|
67
|
+
assert _levenshtein_le_1("foo", "foox") is True
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TestKnownRuleKeysAllowlist:
|
|
71
|
+
"""The _KNOWN_RULE_KEYS set is the typo-guardrail allowlist."""
|
|
72
|
+
|
|
73
|
+
def test_escalate_on_deny_is_known(self):
|
|
74
|
+
assert "escalate_on_deny" in _KNOWN_RULE_KEYS
|
|
75
|
+
|
|
76
|
+
def test_legacy_keys_still_known(self):
|
|
77
|
+
for k in ("id", "name", "deny", "allow", "effect", "action", "actions",
|
|
78
|
+
"resource", "resources", "when", "conditions", "reason",
|
|
79
|
+
"reason_code"):
|
|
80
|
+
assert k in _KNOWN_RULE_KEYS, f"legacy key {k!r} missing from _KNOWN_RULE_KEYS"
|
|
81
|
+
|
|
82
|
+
def test_no_unexpected_keys(self):
|
|
83
|
+
# Defensive: if someone adds a new key, this test fails so they
|
|
84
|
+
# remember to update the typo guardrail intentionally.
|
|
85
|
+
expected = {
|
|
86
|
+
"id", "name", "deny", "allow", "effect", "action", "actions",
|
|
87
|
+
"resource", "resources", "when", "conditions", "reason",
|
|
88
|
+
"reason_code", "escalate_on_deny",
|
|
89
|
+
}
|
|
90
|
+
assert _KNOWN_RULE_KEYS == expected
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class TestEscalateOnDenyAdditive:
|
|
94
|
+
"""The new escalate_on_deny field flows into PolicyRule."""
|
|
95
|
+
|
|
96
|
+
def test_default_false_when_absent(self):
|
|
97
|
+
result = load_policy({
|
|
98
|
+
"version": "1",
|
|
99
|
+
"rules": [{"deny": "Bash:sudo *", "reason": "no sudo"}],
|
|
100
|
+
})
|
|
101
|
+
assert result.rules[0].escalate_on_deny is False
|
|
102
|
+
|
|
103
|
+
def test_true_when_set(self):
|
|
104
|
+
result = load_policy({
|
|
105
|
+
"version": "1",
|
|
106
|
+
"rules": [{
|
|
107
|
+
"deny": "Bash:sudo *",
|
|
108
|
+
"reason": "no sudo",
|
|
109
|
+
"escalate_on_deny": True,
|
|
110
|
+
}],
|
|
111
|
+
})
|
|
112
|
+
assert result.rules[0].escalate_on_deny is True
|
|
113
|
+
|
|
114
|
+
def test_explicit_false_stays_false(self):
|
|
115
|
+
result = load_policy({
|
|
116
|
+
"version": "1",
|
|
117
|
+
"rules": [{
|
|
118
|
+
"deny": "Bash:sudo *",
|
|
119
|
+
"escalate_on_deny": False,
|
|
120
|
+
}],
|
|
121
|
+
})
|
|
122
|
+
assert result.rules[0].escalate_on_deny is False
|
|
123
|
+
|
|
124
|
+
def test_truthy_non_bool_coerced_to_bool(self):
|
|
125
|
+
# Defensive: customers may YAML-write `escalate_on_deny: 1` etc.
|
|
126
|
+
result = load_policy({
|
|
127
|
+
"version": "1",
|
|
128
|
+
"rules": [{"deny": "Bash:sudo *", "escalate_on_deny": 1}],
|
|
129
|
+
})
|
|
130
|
+
assert result.rules[0].escalate_on_deny is True
|
|
131
|
+
assert isinstance(result.rules[0].escalate_on_deny, bool)
|
|
132
|
+
|
|
133
|
+
def test_falsy_non_bool_coerced_to_false(self):
|
|
134
|
+
result = load_policy({
|
|
135
|
+
"version": "1",
|
|
136
|
+
"rules": [{"deny": "Bash:sudo *", "escalate_on_deny": 0}],
|
|
137
|
+
})
|
|
138
|
+
assert result.rules[0].escalate_on_deny is False
|
|
139
|
+
|
|
140
|
+
def test_allow_rule_can_also_carry_field(self):
|
|
141
|
+
# Field is rule-level, not effect-coupled.
|
|
142
|
+
result = load_policy({
|
|
143
|
+
"version": "1",
|
|
144
|
+
"rules": [{
|
|
145
|
+
"allow": "Bash:make test",
|
|
146
|
+
"escalate_on_deny": True,
|
|
147
|
+
}],
|
|
148
|
+
})
|
|
149
|
+
assert result.rules[0].escalate_on_deny is True
|
|
150
|
+
assert result.rules[0].effect == "allow"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class TestTypoGuardrailWarnings:
|
|
154
|
+
"""The Levenshtein-1 typo guardrail surfaces 'did you mean?' warnings."""
|
|
155
|
+
|
|
156
|
+
def test_warns_on_near_miss_to_escalate_on_deny(self, capsys):
|
|
157
|
+
# `escalate_on_dny` is one deletion away from `escalate_on_deny`.
|
|
158
|
+
# Levenshtein-1 catches deletions / insertions / substitutions but
|
|
159
|
+
# NOT transpositions (those are distance 2 in basic Levenshtein).
|
|
160
|
+
load_policy({
|
|
161
|
+
"version": "1",
|
|
162
|
+
"rules": [{
|
|
163
|
+
"deny": "Bash:sudo *",
|
|
164
|
+
"escalate_on_dny": True, # one-deletion typo
|
|
165
|
+
}],
|
|
166
|
+
})
|
|
167
|
+
captured = capsys.readouterr()
|
|
168
|
+
assert "escalate_on_dny" in captured.err
|
|
169
|
+
assert "escalate_on_deny" in captured.err
|
|
170
|
+
assert "did you mean" in captured.err
|
|
171
|
+
|
|
172
|
+
def test_silent_on_transposition_typo(self, capsys):
|
|
173
|
+
# Transpositions are distance 2 (not 1), so we explicitly do NOT
|
|
174
|
+
# warn on them. Documented behavior; if the typo guardrail ever
|
|
175
|
+
# upgrades to Damerau-Levenshtein, this test breaks.
|
|
176
|
+
load_policy({
|
|
177
|
+
"version": "1",
|
|
178
|
+
"rules": [{
|
|
179
|
+
"deny": "Bash:sudo *",
|
|
180
|
+
"escalate_on_dney": True, # transposition (n<->e)
|
|
181
|
+
}],
|
|
182
|
+
})
|
|
183
|
+
captured = capsys.readouterr()
|
|
184
|
+
assert captured.err == ""
|
|
185
|
+
|
|
186
|
+
def test_warns_on_actiom_typo(self, capsys):
|
|
187
|
+
load_policy({
|
|
188
|
+
"version": "1",
|
|
189
|
+
"rules": [{
|
|
190
|
+
"actiom": "Bash:make", # near-miss for "action"
|
|
191
|
+
"deny": "Bash:make",
|
|
192
|
+
}],
|
|
193
|
+
})
|
|
194
|
+
captured = capsys.readouterr()
|
|
195
|
+
assert "actiom" in captured.err
|
|
196
|
+
assert "action" in captured.err
|
|
197
|
+
|
|
198
|
+
def test_silent_on_far_unknown_keys(self, capsys):
|
|
199
|
+
# An unknown key with no near-miss to any known key: silent.
|
|
200
|
+
load_policy({
|
|
201
|
+
"version": "1",
|
|
202
|
+
"rules": [{
|
|
203
|
+
"deny": "Bash:sudo *",
|
|
204
|
+
"completely_made_up_field_xyz": "some value",
|
|
205
|
+
}],
|
|
206
|
+
})
|
|
207
|
+
captured = capsys.readouterr()
|
|
208
|
+
assert captured.err == ""
|
|
209
|
+
|
|
210
|
+
def test_silent_when_all_keys_known(self, capsys):
|
|
211
|
+
load_policy({
|
|
212
|
+
"version": "1",
|
|
213
|
+
"rules": [{
|
|
214
|
+
"id": "r1",
|
|
215
|
+
"name": "no sudo",
|
|
216
|
+
"deny": "Bash:sudo *",
|
|
217
|
+
"reason": "policy",
|
|
218
|
+
"escalate_on_deny": True,
|
|
219
|
+
}],
|
|
220
|
+
})
|
|
221
|
+
captured = capsys.readouterr()
|
|
222
|
+
assert captured.err == ""
|
|
223
|
+
|
|
224
|
+
def test_unknown_key_does_not_break_parsing(self):
|
|
225
|
+
# Even with the typo warning fired, the rule should still parse.
|
|
226
|
+
result = load_policy({
|
|
227
|
+
"version": "1",
|
|
228
|
+
"rules": [{
|
|
229
|
+
"deny": "Bash:sudo *",
|
|
230
|
+
"escalate_on_dney": True, # typo
|
|
231
|
+
}],
|
|
232
|
+
})
|
|
233
|
+
assert len(result.rules) == 1
|
|
234
|
+
assert result.rules[0].effect == "deny"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class TestExisting157PoliciesStillParse:
|
|
238
|
+
"""Defensive: 1.5.7-shape policies (no escalate_on_deny) still parse identically."""
|
|
239
|
+
|
|
240
|
+
def test_minimal_policy_unchanged(self):
|
|
241
|
+
result = load_policy({
|
|
242
|
+
"version": "1",
|
|
243
|
+
"rules": [{"deny": "*"}],
|
|
244
|
+
})
|
|
245
|
+
assert result.rules[0].effect == "deny"
|
|
246
|
+
assert result.rules[0].escalate_on_deny is False # default
|
|
247
|
+
|
|
248
|
+
def test_complex_policy_unchanged(self):
|
|
249
|
+
result = load_policy({
|
|
250
|
+
"version": "1",
|
|
251
|
+
"settings": {"tamper_behavior": "warn"},
|
|
252
|
+
"rules": [
|
|
253
|
+
{"id": "r1", "deny": "Bash:sudo *", "reason": "no sudo"},
|
|
254
|
+
{"id": "r2", "allow": "Bash:make *"},
|
|
255
|
+
{"id": "r3", "deny": "*", "when": {"model": "claude-opus-*"}},
|
|
256
|
+
],
|
|
257
|
+
})
|
|
258
|
+
assert len(result.rules) == 3
|
|
259
|
+
for rule in result.rules:
|
|
260
|
+
assert rule.escalate_on_deny is False
|
|
@@ -69,7 +69,11 @@ def test_reason_code_enum_has_all_nine_values():
|
|
|
69
69
|
from controlzero._internal.enforcer import REASON_CODE_LOCAL_OVERRIDE_ACTIVE
|
|
70
70
|
assert REASON_CODE_LOCAL_OVERRIDE_ACTIVE == "LOCAL_OVERRIDE_ACTIVE"
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
# Subset check (not strict equality) so additive code expansions
|
|
73
|
+
# (e.g. HITL-5a 1.5.8: 9 new HITL_* codes added 2026-05-16) do not
|
|
74
|
+
# break this rename-guarantee test. The exact total is asserted by
|
|
75
|
+
# test_hitl_reason_codes.py::test_valid_set_size_grew_by_exactly_9.
|
|
76
|
+
assert frozenset({
|
|
73
77
|
"RULE_MATCH",
|
|
74
78
|
"NO_RULE_MATCH",
|
|
75
79
|
"NO_ACTIVE_POLICIES",
|
|
@@ -79,7 +83,7 @@ def test_reason_code_enum_has_all_nine_values():
|
|
|
79
83
|
"NETWORK_ERROR",
|
|
80
84
|
"DLP_BLOCKED",
|
|
81
85
|
"LOCAL_OVERRIDE_ACTIVE",
|
|
82
|
-
})
|
|
86
|
+
}) <= VALID_REASON_CODES
|
|
83
87
|
|
|
84
88
|
|
|
85
89
|
# ---- NO_ACTIVE_POLICIES emission ------------------------------------------
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|