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.
Files changed (151) hide show
  1. {controlzero-1.5.7 → controlzero-1.5.8}/.gitignore +4 -0
  2. {controlzero-1.5.7 → controlzero-1.5.8}/CHANGELOG.md +34 -0
  3. {controlzero-1.5.7 → controlzero-1.5.8}/PKG-INFO +1 -1
  4. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/__init__.py +1 -1
  5. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/_internal/enforcer.py +23 -0
  6. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/_internal/types.py +6 -0
  7. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/policy_loader.py +78 -0
  8. {controlzero-1.5.7 → controlzero-1.5.8}/pyproject.toml +1 -1
  9. controlzero-1.5.8/tests/test_hitl_reason_codes.py +72 -0
  10. controlzero-1.5.8/tests/test_hitl_validator_keys.py +260 -0
  11. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_reason_code.py +6 -2
  12. {controlzero-1.5.7 → controlzero-1.5.8}/Dockerfile.test +0 -0
  13. {controlzero-1.5.7 → controlzero-1.5.8}/LICENSE +0 -0
  14. {controlzero-1.5.7 → controlzero-1.5.8}/README.md +0 -0
  15. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/_internal/__init__.py +0 -0
  16. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/_internal/action_aliases.py +0 -0
  17. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/_internal/bundle.py +0 -0
  18. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/_internal/dlp_scanner.py +0 -0
  19. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/_internal/hook_extractors.py +0 -0
  20. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/_internal/tool_extractors.json +0 -0
  21. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/audit_local.py +0 -0
  22. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/audit_remote.py +0 -0
  23. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/__init__.py +0 -0
  24. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/_secrets.py +0 -0
  25. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/console.py +0 -0
  26. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/debug_bundle.py +0 -0
  27. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/doctor.py +0 -0
  28. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/hosts/__init__.py +0 -0
  29. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/hosts/base.py +0 -0
  30. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/hosts/claude_code.py +0 -0
  31. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/hosts/codex_cli.py +0 -0
  32. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/hosts/gemini_cli.py +0 -0
  33. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/hosts/unknown.py +0 -0
  34. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/main.py +0 -0
  35. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/migrate.py +0 -0
  36. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/telemetry_consent.py +0 -0
  37. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/templates/autogen.yaml +0 -0
  38. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/templates/claude-code.yaml +0 -0
  39. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/templates/codex-cli.yaml +0 -0
  40. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/templates/cost-cap.yaml +0 -0
  41. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/templates/crewai.yaml +0 -0
  42. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/templates/cursor.yaml +0 -0
  43. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/templates/gemini-cli.yaml +0 -0
  44. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/templates/generic.yaml +0 -0
  45. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/templates/langchain.yaml +0 -0
  46. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/templates/mcp.yaml +0 -0
  47. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/cli/templates/rag.yaml +0 -0
  48. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/client.py +0 -0
  49. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/device.py +0 -0
  50. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/enrollment.py +0 -0
  51. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/error_codes.py +0 -0
  52. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/errors.py +0 -0
  53. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/hosted_policy.py +0 -0
  54. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/__init__.py +0 -0
  55. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/anthropic.py +0 -0
  56. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/autogen.py +0 -0
  57. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/braintrust.py +0 -0
  58. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/crewai/__init__.py +0 -0
  59. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/crewai/agent.py +0 -0
  60. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/crewai/crew.py +0 -0
  61. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/crewai/task.py +0 -0
  62. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/crewai/tool.py +0 -0
  63. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/google.py +0 -0
  64. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/google_adk/__init__.py +0 -0
  65. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/google_adk/agent.py +0 -0
  66. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/google_adk/tool.py +0 -0
  67. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/langchain/__init__.py +0 -0
  68. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/langchain/agent.py +0 -0
  69. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/langchain/callbacks.py +0 -0
  70. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/langchain/chain.py +0 -0
  71. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/langchain/graph.py +0 -0
  72. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/langchain/modern.py +0 -0
  73. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/langchain/tool.py +0 -0
  74. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/langfuse.py +0 -0
  75. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/litellm.py +0 -0
  76. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/openai.py +0 -0
  77. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/pydantic_ai.py +0 -0
  78. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/integrations/vercel_ai.py +0 -0
  79. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/layout_migration.py +0 -0
  80. {controlzero-1.5.7 → controlzero-1.5.8}/controlzero/tamper.py +0 -0
  81. {controlzero-1.5.7 → controlzero-1.5.8}/examples/hello_world.py +0 -0
  82. {controlzero-1.5.7 → controlzero-1.5.8}/tests/conftest.py +0 -0
  83. {controlzero-1.5.7 → controlzero-1.5.8}/tests/integrations/__init__.py +0 -0
  84. {controlzero-1.5.7 → controlzero-1.5.8}/tests/integrations/test_google.py +0 -0
  85. {controlzero-1.5.7 → controlzero-1.5.8}/tests/parity/action_aliases.json +0 -0
  86. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_action_aliases.py +0 -0
  87. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_action_canonicalization.py +0 -0
  88. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_agent_name_env.py +0 -0
  89. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_api_key_mask.py +0 -0
  90. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_audit_remote.py +0 -0
  91. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_audit_remote_sdk_version.py +0 -0
  92. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_audit_sink_isolation.py +0 -0
  93. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_bundle_parser.py +0 -0
  94. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_bundle_translate.py +0 -0
  95. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_cli_carve_out.py +0 -0
  96. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_cli_debug_bundle.py +0 -0
  97. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_cli_extractor_integration.py +0 -0
  98. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_cli_hook.py +0 -0
  99. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_cli_hosted_refresh.py +0 -0
  100. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_cli_init.py +0 -0
  101. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_cli_init_templates.py +0 -0
  102. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_cli_tail.py +0 -0
  103. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_cli_test.py +0 -0
  104. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_cli_validate.py +0 -0
  105. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_coding_agent_hooks.py +0 -0
  106. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_conditions.py +0 -0
  107. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_console.py +0 -0
  108. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_default_action.py +0 -0
  109. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_device.py +0 -0
  110. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_dlp_scanner.py +0 -0
  111. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_doctor.py +0 -0
  112. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_enrollment.py +0 -0
  113. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_env_dump_438.py +0 -0
  114. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_error_codes.py +0 -0
  115. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_errors_e_codes.py +0 -0
  116. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_fail_closed_eval.py +0 -0
  117. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_glob_matching.py +0 -0
  118. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_hook_extractors.py +0 -0
  119. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_hosted_policy_e2e.py +0 -0
  120. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_hosts_adapter.py +0 -0
  121. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_hybrid_mode_strict.py +0 -0
  122. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_hybrid_mode_warn.py +0 -0
  123. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_install_hook_command.py +0 -0
  124. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_install_hooks.py +0 -0
  125. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_layout_migration_t101.py +0 -0
  126. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_layout_parity_t102.py +0 -0
  127. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_local_mode_dict.py +0 -0
  128. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_local_mode_file_json.py +0 -0
  129. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_local_mode_file_yaml.py +0 -0
  130. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_log_fallback_stderr.py +0 -0
  131. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_log_options_ignored_hosted.py +0 -0
  132. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_log_rotation.py +0 -0
  133. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_migrate.py +0 -0
  134. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_no_policy_no_key.py +0 -0
  135. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_package_rename_shim.py +0 -0
  136. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_policy_freshness.py +0 -0
  137. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_policy_settings.py +0 -0
  138. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_quarantine.py +0 -0
  139. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_refresh.py +0 -0
  140. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_secrets.py +0 -0
  141. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_sql_semantic_class.py +0 -0
  142. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_synthetic_policy_id_t79.py +0 -0
  143. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_t103_precedence.py +0 -0
  144. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_t104_cache_gc.py +0 -0
  145. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_t108_local_override_audit.py +0 -0
  146. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_t96_single_audit_log.py +0 -0
  147. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_t99_install_prefetch_bundle.py +0 -0
  148. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_tamper.py +0 -0
  149. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_tamper_behavior.py +0 -0
  150. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_tamper_hook.py +0 -0
  151. {controlzero-1.5.7 → controlzero-1.5.8}/tests/test_telemetry_consent.py +0 -0
@@ -239,3 +239,7 @@ secrets/*.dec
239
239
 
240
240
  # Local documentation, patent assets, and legal research (sensitive, never commit)
241
241
  doc_local/
242
+
243
+ # Go build/module caches (generated by `go build`, `go test`, etc.)
244
+ **/.gocache/
245
+ **/.gomodcache/
@@ -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.7
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
@@ -28,7 +28,7 @@ from controlzero.errors import (
28
28
  )
29
29
  from controlzero.policy_loader import load_policy
30
30
 
31
- __version__ = "1.5.7"
31
+ __version__ = "1.5.8"
32
32
 
33
33
  __all__ = [
34
34
  "Client",
@@ -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"
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
- assert VALID_REASON_CODES == frozenset({
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