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.
Files changed (151) hide show
  1. {controlzero-1.5.6 → controlzero-1.5.8}/.gitignore +4 -0
  2. {controlzero-1.5.6 → controlzero-1.5.8}/CHANGELOG.md +57 -0
  3. {controlzero-1.5.6 → controlzero-1.5.8}/PKG-INFO +1 -1
  4. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/__init__.py +1 -1
  5. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/_internal/enforcer.py +23 -0
  6. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/_internal/types.py +6 -0
  7. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/hosts/__init__.py +3 -3
  8. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/hosts/base.py +4 -5
  9. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/hosts/unknown.py +2 -3
  10. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/enrollment.py +3 -4
  11. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/hosted_policy.py +2 -2
  12. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/policy_loader.py +78 -0
  13. {controlzero-1.5.6 → controlzero-1.5.8}/pyproject.toml +1 -1
  14. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_api_key_mask.py +5 -5
  15. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_env_dump_438.py +2 -2
  16. controlzero-1.5.8/tests/test_hitl_reason_codes.py +72 -0
  17. controlzero-1.5.8/tests/test_hitl_validator_keys.py +260 -0
  18. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_install_hook_command.py +2 -2
  19. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_reason_code.py +6 -2
  20. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_t103_precedence.py +4 -4
  21. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_t104_cache_gc.py +23 -23
  22. {controlzero-1.5.6 → controlzero-1.5.8}/Dockerfile.test +0 -0
  23. {controlzero-1.5.6 → controlzero-1.5.8}/LICENSE +0 -0
  24. {controlzero-1.5.6 → controlzero-1.5.8}/README.md +0 -0
  25. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/_internal/__init__.py +0 -0
  26. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/_internal/action_aliases.py +0 -0
  27. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/_internal/bundle.py +0 -0
  28. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/_internal/dlp_scanner.py +0 -0
  29. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/_internal/hook_extractors.py +0 -0
  30. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/_internal/tool_extractors.json +0 -0
  31. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/audit_local.py +0 -0
  32. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/audit_remote.py +0 -0
  33. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/__init__.py +0 -0
  34. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/_secrets.py +0 -0
  35. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/console.py +0 -0
  36. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/debug_bundle.py +0 -0
  37. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/doctor.py +0 -0
  38. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/hosts/claude_code.py +0 -0
  39. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/hosts/codex_cli.py +0 -0
  40. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/hosts/gemini_cli.py +0 -0
  41. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/main.py +0 -0
  42. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/migrate.py +0 -0
  43. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/telemetry_consent.py +0 -0
  44. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/templates/autogen.yaml +0 -0
  45. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/templates/claude-code.yaml +0 -0
  46. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/templates/codex-cli.yaml +0 -0
  47. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/templates/cost-cap.yaml +0 -0
  48. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/templates/crewai.yaml +0 -0
  49. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/templates/cursor.yaml +0 -0
  50. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/templates/gemini-cli.yaml +0 -0
  51. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/templates/generic.yaml +0 -0
  52. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/templates/langchain.yaml +0 -0
  53. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/templates/mcp.yaml +0 -0
  54. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/cli/templates/rag.yaml +0 -0
  55. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/client.py +0 -0
  56. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/device.py +0 -0
  57. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/error_codes.py +0 -0
  58. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/errors.py +0 -0
  59. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/__init__.py +0 -0
  60. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/anthropic.py +0 -0
  61. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/autogen.py +0 -0
  62. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/braintrust.py +0 -0
  63. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/crewai/__init__.py +0 -0
  64. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/crewai/agent.py +0 -0
  65. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/crewai/crew.py +0 -0
  66. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/crewai/task.py +0 -0
  67. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/crewai/tool.py +0 -0
  68. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/google.py +0 -0
  69. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/google_adk/__init__.py +0 -0
  70. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/google_adk/agent.py +0 -0
  71. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/google_adk/tool.py +0 -0
  72. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/langchain/__init__.py +0 -0
  73. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/langchain/agent.py +0 -0
  74. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/langchain/callbacks.py +0 -0
  75. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/langchain/chain.py +0 -0
  76. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/langchain/graph.py +0 -0
  77. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/langchain/modern.py +0 -0
  78. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/langchain/tool.py +0 -0
  79. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/langfuse.py +0 -0
  80. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/litellm.py +0 -0
  81. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/openai.py +0 -0
  82. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/pydantic_ai.py +0 -0
  83. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/integrations/vercel_ai.py +0 -0
  84. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/layout_migration.py +0 -0
  85. {controlzero-1.5.6 → controlzero-1.5.8}/controlzero/tamper.py +0 -0
  86. {controlzero-1.5.6 → controlzero-1.5.8}/examples/hello_world.py +0 -0
  87. {controlzero-1.5.6 → controlzero-1.5.8}/tests/conftest.py +0 -0
  88. {controlzero-1.5.6 → controlzero-1.5.8}/tests/integrations/__init__.py +0 -0
  89. {controlzero-1.5.6 → controlzero-1.5.8}/tests/integrations/test_google.py +0 -0
  90. {controlzero-1.5.6 → controlzero-1.5.8}/tests/parity/action_aliases.json +0 -0
  91. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_action_aliases.py +0 -0
  92. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_action_canonicalization.py +0 -0
  93. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_agent_name_env.py +0 -0
  94. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_audit_remote.py +0 -0
  95. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_audit_remote_sdk_version.py +0 -0
  96. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_audit_sink_isolation.py +0 -0
  97. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_bundle_parser.py +0 -0
  98. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_bundle_translate.py +0 -0
  99. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_cli_carve_out.py +0 -0
  100. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_cli_debug_bundle.py +0 -0
  101. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_cli_extractor_integration.py +0 -0
  102. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_cli_hook.py +0 -0
  103. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_cli_hosted_refresh.py +0 -0
  104. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_cli_init.py +0 -0
  105. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_cli_init_templates.py +0 -0
  106. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_cli_tail.py +0 -0
  107. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_cli_test.py +0 -0
  108. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_cli_validate.py +0 -0
  109. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_coding_agent_hooks.py +0 -0
  110. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_conditions.py +0 -0
  111. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_console.py +0 -0
  112. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_default_action.py +0 -0
  113. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_device.py +0 -0
  114. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_dlp_scanner.py +0 -0
  115. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_doctor.py +0 -0
  116. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_enrollment.py +0 -0
  117. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_error_codes.py +0 -0
  118. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_errors_e_codes.py +0 -0
  119. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_fail_closed_eval.py +0 -0
  120. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_glob_matching.py +0 -0
  121. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_hook_extractors.py +0 -0
  122. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_hosted_policy_e2e.py +0 -0
  123. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_hosts_adapter.py +0 -0
  124. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_hybrid_mode_strict.py +0 -0
  125. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_hybrid_mode_warn.py +0 -0
  126. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_install_hooks.py +0 -0
  127. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_layout_migration_t101.py +0 -0
  128. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_layout_parity_t102.py +0 -0
  129. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_local_mode_dict.py +0 -0
  130. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_local_mode_file_json.py +0 -0
  131. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_local_mode_file_yaml.py +0 -0
  132. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_log_fallback_stderr.py +0 -0
  133. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_log_options_ignored_hosted.py +0 -0
  134. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_log_rotation.py +0 -0
  135. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_migrate.py +0 -0
  136. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_no_policy_no_key.py +0 -0
  137. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_package_rename_shim.py +0 -0
  138. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_policy_freshness.py +0 -0
  139. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_policy_settings.py +0 -0
  140. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_quarantine.py +0 -0
  141. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_refresh.py +0 -0
  142. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_secrets.py +0 -0
  143. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_sql_semantic_class.py +0 -0
  144. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_synthetic_policy_id_t79.py +0 -0
  145. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_t108_local_override_audit.py +0 -0
  146. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_t96_single_audit_log.py +0 -0
  147. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_t99_install_prefetch_bundle.py +0 -0
  148. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_tamper.py +0 -0
  149. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_tamper_behavior.py +0 -0
  150. {controlzero-1.5.6 → controlzero-1.5.8}/tests/test_tamper_hook.py +0 -0
  151. {controlzero-1.5.6 → 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,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.6
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.6"
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
@@ -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 ``audit.NormalizeSource``
40
- alias (``claude_code`` / ``gemini_cli`` / ``codex_cli`` / etc.)
41
- so the audit row's SOURCE column renders correctly.
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 ``audit.NormalizeSource`` alias
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 ``audit.NormalizeSource`` alias. Must be one of the
113
- #: canonical values in ``backend/internal/audit/source.go`` so
114
- #: the dashboard renders the right label. Default is
115
- #: ``"unknown"`` -- subclasses MUST override.
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 mapped in
30
- # ``backend/internal/audit/source.go`` -- renders as ``--`` on
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 ``apps/control-zero-platform/backend/internal/api/
23
- middleware/machine_auth.go`` for the canonical signed string and
24
- header definitions. This module is the source of truth on the
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. John's machine carried a 25-day-old ``bundle-cz_live_566b.bin``
142
- next to a fresh ``bootstrap-cz_live_1af8.json`` because the rotated
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.6"
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("cz_live_7ebef6b600015e3eaeda9149bf6d9c29a")
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("cz_test_abcdef0123456789abcdef0123456789")
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
- # The secret portion of a real production key (anonymised:
46
- # do not use this value as-is, the customer rotated it after the
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 = "7ebef6b600015e3eaeda9149bf6d9c29a3a2a7a3075209112afde20888280de0"
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
- "cz_live_7ebef6b600015e3eaeda9149bf6d9c29a3a2a7a3075209112afde20888280de0",
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 = "7ebef6b600015e3eaeda9149bf6d9c29a"
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