controlzero 1.5.8__tar.gz → 1.7.0__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 (186) hide show
  1. {controlzero-1.5.8 → controlzero-1.7.0}/CHANGELOG.md +97 -0
  2. {controlzero-1.5.8 → controlzero-1.7.0}/PKG-INFO +150 -1
  3. {controlzero-1.5.8 → controlzero-1.7.0}/README.md +148 -0
  4. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/__init__.py +3 -1
  5. controlzero-1.7.0/controlzero/_internal/action_validator.py +182 -0
  6. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/_internal/bundle.py +129 -0
  7. controlzero-1.7.0/controlzero/_internal/credential_hook.py +339 -0
  8. controlzero-1.7.0/controlzero/_internal/credential_scanner.py +391 -0
  9. controlzero-1.7.0/controlzero/_internal/credentials_data/__init__.py +12 -0
  10. controlzero-1.7.0/controlzero/_internal/credentials_data/built_in.yaml +2259 -0
  11. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/_internal/enforcer.py +85 -0
  12. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/_internal/types.py +14 -0
  13. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/audit_remote.py +165 -72
  14. controlzero-1.7.0/controlzero/canonical.py +108 -0
  15. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/main.py +168 -4
  16. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/client.py +631 -6
  17. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/error_codes.py +212 -0
  18. controlzero-1.7.0/controlzero/errors.py +495 -0
  19. controlzero-1.7.0/controlzero/hitl/__init__.py +43 -0
  20. controlzero-1.7.0/controlzero/hitl/mock.py +185 -0
  21. controlzero-1.7.0/controlzero/hitl/pending_approval.py +460 -0
  22. controlzero-1.7.0/controlzero/hitl/secret_leak_guard.py +218 -0
  23. controlzero-1.7.0/controlzero/hitl/status.py +77 -0
  24. controlzero-1.7.0/controlzero/hooks/__init__.py +8 -0
  25. controlzero-1.7.0/controlzero/hooks/tool_output_handler.py +94 -0
  26. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/hosted_policy.py +11 -0
  27. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/policy_loader.py +73 -0
  28. {controlzero-1.5.8 → controlzero-1.7.0}/pyproject.toml +13 -1
  29. controlzero-1.7.0/tests/_fixtures/jcs_args_hash_vectors.json +111 -0
  30. controlzero-1.7.0/tests/test_action_validator_t86.py +112 -0
  31. controlzero-1.7.0/tests/test_canonical_phase1a.py +229 -0
  32. controlzero-1.7.0/tests/test_conformance.py +335 -0
  33. controlzero-1.7.0/tests/test_credential_hook.py +738 -0
  34. controlzero-1.7.0/tests/test_engine_version_consistency.py +82 -0
  35. controlzero-1.7.0/tests/test_hitl_5d_email_install.py +137 -0
  36. controlzero-1.7.0/tests/test_hitl_6a_cli_flag.py +307 -0
  37. controlzero-1.7.0/tests/test_hitl_6a_exceptions.py +166 -0
  38. controlzero-1.7.0/tests/test_hitl_6a_get_secret_hitl.py +672 -0
  39. controlzero-1.7.0/tests/test_hitl_6a_mock_backend.py +343 -0
  40. controlzero-1.7.0/tests/test_hitl_6a_pending_approval.py +362 -0
  41. controlzero-1.7.0/tests/test_hitl_6a_request_approval.py +730 -0
  42. controlzero-1.7.0/tests/test_hitl_6a_secret_leak_guard.py +327 -0
  43. controlzero-1.7.0/tests/test_hitl_6a_wait.py +664 -0
  44. controlzero-1.7.0/tests/test_hitl_conformance.py +287 -0
  45. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_hitl_validator_keys.py +6 -0
  46. controlzero-1.7.0/tests/test_min_sdk_version_gate.py +206 -0
  47. controlzero-1.7.0/tests/test_multi_client_per_project_175.py +807 -0
  48. controlzero-1.7.0/tests/test_policy_engine_version_phase1b.py +73 -0
  49. controlzero-1.7.0/tests/test_policy_source_audit.py +149 -0
  50. controlzero-1.7.0/tests/test_unsafe_int_boundary.py +67 -0
  51. controlzero-1.5.8/.gitignore +0 -245
  52. controlzero-1.5.8/controlzero/errors.py +0 -181
  53. {controlzero-1.5.8 → controlzero-1.7.0}/Dockerfile.test +0 -0
  54. {controlzero-1.5.8 → controlzero-1.7.0}/LICENSE +0 -0
  55. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/_internal/__init__.py +0 -0
  56. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/_internal/action_aliases.py +0 -0
  57. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/_internal/dlp_scanner.py +0 -0
  58. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/_internal/hook_extractors.py +0 -0
  59. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/_internal/tool_extractors.json +0 -0
  60. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/audit_local.py +0 -0
  61. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/__init__.py +0 -0
  62. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/_secrets.py +0 -0
  63. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/console.py +0 -0
  64. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/debug_bundle.py +0 -0
  65. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/doctor.py +0 -0
  66. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/hosts/__init__.py +0 -0
  67. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/hosts/base.py +0 -0
  68. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/hosts/claude_code.py +0 -0
  69. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/hosts/codex_cli.py +0 -0
  70. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/hosts/gemini_cli.py +0 -0
  71. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/hosts/unknown.py +0 -0
  72. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/migrate.py +0 -0
  73. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/telemetry_consent.py +0 -0
  74. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/templates/autogen.yaml +0 -0
  75. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/templates/claude-code.yaml +0 -0
  76. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/templates/codex-cli.yaml +0 -0
  77. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/templates/cost-cap.yaml +0 -0
  78. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/templates/crewai.yaml +0 -0
  79. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/templates/cursor.yaml +0 -0
  80. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/templates/gemini-cli.yaml +0 -0
  81. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/templates/generic.yaml +0 -0
  82. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/templates/langchain.yaml +0 -0
  83. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/templates/mcp.yaml +0 -0
  84. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/templates/rag.yaml +0 -0
  85. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/device.py +0 -0
  86. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/enrollment.py +0 -0
  87. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/__init__.py +0 -0
  88. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/anthropic.py +0 -0
  89. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/autogen.py +0 -0
  90. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/braintrust.py +0 -0
  91. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/crewai/__init__.py +0 -0
  92. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/crewai/agent.py +0 -0
  93. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/crewai/crew.py +0 -0
  94. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/crewai/task.py +0 -0
  95. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/crewai/tool.py +0 -0
  96. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/google.py +0 -0
  97. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/google_adk/__init__.py +0 -0
  98. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/google_adk/agent.py +0 -0
  99. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/google_adk/tool.py +0 -0
  100. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/langchain/__init__.py +0 -0
  101. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/langchain/agent.py +0 -0
  102. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/langchain/callbacks.py +0 -0
  103. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/langchain/chain.py +0 -0
  104. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/langchain/graph.py +0 -0
  105. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/langchain/modern.py +0 -0
  106. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/langchain/tool.py +0 -0
  107. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/langfuse.py +0 -0
  108. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/litellm.py +0 -0
  109. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/openai.py +0 -0
  110. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/pydantic_ai.py +0 -0
  111. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/vercel_ai.py +0 -0
  112. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/layout_migration.py +0 -0
  113. {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/tamper.py +0 -0
  114. {controlzero-1.5.8 → controlzero-1.7.0}/examples/hello_world.py +0 -0
  115. {controlzero-1.5.8 → controlzero-1.7.0}/tests/conftest.py +0 -0
  116. {controlzero-1.5.8 → controlzero-1.7.0}/tests/integrations/__init__.py +0 -0
  117. {controlzero-1.5.8 → controlzero-1.7.0}/tests/integrations/test_google.py +0 -0
  118. {controlzero-1.5.8 → controlzero-1.7.0}/tests/parity/action_aliases.json +0 -0
  119. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_action_aliases.py +0 -0
  120. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_action_canonicalization.py +0 -0
  121. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_agent_name_env.py +0 -0
  122. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_api_key_mask.py +0 -0
  123. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_audit_remote.py +0 -0
  124. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_audit_remote_sdk_version.py +0 -0
  125. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_audit_sink_isolation.py +0 -0
  126. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_bundle_parser.py +0 -0
  127. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_bundle_translate.py +0 -0
  128. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_cli_carve_out.py +0 -0
  129. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_cli_debug_bundle.py +0 -0
  130. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_cli_extractor_integration.py +0 -0
  131. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_cli_hook.py +0 -0
  132. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_cli_hosted_refresh.py +0 -0
  133. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_cli_init.py +0 -0
  134. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_cli_init_templates.py +0 -0
  135. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_cli_tail.py +0 -0
  136. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_cli_test.py +0 -0
  137. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_cli_validate.py +0 -0
  138. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_coding_agent_hooks.py +0 -0
  139. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_conditions.py +0 -0
  140. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_console.py +0 -0
  141. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_default_action.py +0 -0
  142. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_device.py +0 -0
  143. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_dlp_scanner.py +0 -0
  144. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_doctor.py +0 -0
  145. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_enrollment.py +0 -0
  146. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_env_dump_438.py +0 -0
  147. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_error_codes.py +0 -0
  148. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_errors_e_codes.py +0 -0
  149. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_fail_closed_eval.py +0 -0
  150. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_glob_matching.py +0 -0
  151. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_hitl_reason_codes.py +0 -0
  152. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_hook_extractors.py +0 -0
  153. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_hosted_policy_e2e.py +0 -0
  154. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_hosts_adapter.py +0 -0
  155. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_hybrid_mode_strict.py +0 -0
  156. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_hybrid_mode_warn.py +0 -0
  157. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_install_hook_command.py +0 -0
  158. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_install_hooks.py +0 -0
  159. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_layout_migration_t101.py +0 -0
  160. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_layout_parity_t102.py +0 -0
  161. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_local_mode_dict.py +0 -0
  162. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_local_mode_file_json.py +0 -0
  163. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_local_mode_file_yaml.py +0 -0
  164. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_log_fallback_stderr.py +0 -0
  165. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_log_options_ignored_hosted.py +0 -0
  166. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_log_rotation.py +0 -0
  167. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_migrate.py +0 -0
  168. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_no_policy_no_key.py +0 -0
  169. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_package_rename_shim.py +0 -0
  170. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_policy_freshness.py +0 -0
  171. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_policy_settings.py +0 -0
  172. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_quarantine.py +0 -0
  173. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_reason_code.py +0 -0
  174. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_refresh.py +0 -0
  175. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_secrets.py +0 -0
  176. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_sql_semantic_class.py +0 -0
  177. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_synthetic_policy_id_t79.py +0 -0
  178. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_t103_precedence.py +0 -0
  179. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_t104_cache_gc.py +0 -0
  180. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_t108_local_override_audit.py +0 -0
  181. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_t96_single_audit_log.py +0 -0
  182. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_t99_install_prefetch_bundle.py +0 -0
  183. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_tamper.py +0 -0
  184. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_tamper_behavior.py +0 -0
  185. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_tamper_hook.py +0 -0
  186. {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_telemetry_consent.py +0 -0
@@ -1,5 +1,102 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.7.0 -- 2026-05-19 (T86, gh#391)
4
+
5
+ ### Added
6
+
7
+ - **Unknown-action validator at policy-load time** (T86, GitHub #391).
8
+ When `controlzero.policy_loader.load_policy()` parses a policy whose
9
+ rules target an action name that is not in the canonical-or-alias
10
+ table (typo, made-up name like `database:queryy`), the loader now
11
+ emits a `logging.WARNING` per offending action with a did-you-mean
12
+ suggestion list. The policy still loads -- the validator is
13
+ warn-not-block at the SDK level (the platform backend blocks publish
14
+ with 422 on the same condition).
15
+
16
+ This catches the silent "rule lands but never fires" class of bug
17
+ that T84's alias shim was created to prevent: a customer typing
18
+ `database:queryy` gets a one-line warning pointing at
19
+ `database:query (legacy)` instead of the rule silently never matching.
20
+
21
+ The validator's known-action set is the union of canonical SDK
22
+ extractor tools, host-tool aliases (e.g. `Read` -> `file_read`), the
23
+ four canonical SQL semantic classes plus every legacy alias from the
24
+ T84 alias table, and wildcards (`*`, `tool:*`, `*:method`). Adding
25
+ a new alias to `_internal/action_aliases.py` automatically widens
26
+ what the validator accepts.
27
+
28
+ See `docs/concepts/policies.md#validation` for the full contract.
29
+
30
+ ## v1.6.0 -- 2026-05-17 (HITL-6a, gh#542)
31
+
32
+ First minor that turns the Human-in-the-Loop approval workflow on. 1.5.8
33
+ prepared the field (escalate_on_deny acknowledged, HITL reason codes
34
+ registered, validator additive); 1.6.0 ships the surface area: 11 exception
35
+ codes, a PendingApproval dataclass with sync + async wait, an in-process
36
+ mock backend, the request_approval HTTP path, the CLI test flag, and the
37
+ secret-value-leak guard that gates every outbound HITL payload.
38
+
39
+ ### Added
40
+
41
+ - **11 HITL exception classes** (E1701-E1711) -- `HITLTimeoutError`,
42
+ `HITLBackendUnreachableError`, `HITLPolicyVersionConflictError`,
43
+ `HITLNotConfiguredError`, `HITLNoApproverAvailable`,
44
+ `HITLIdentityNotInOrg`, `HITLIdentityRequired`,
45
+ `HITLIdentityClaimRejected`, `SecretValueLeakInPayload`,
46
+ `SecretApprovalRequired`, `SecretNotFound`. Inheritance is chosen so
47
+ existing `except PolicyDeniedError` blocks treat HITL timeouts and
48
+ approver-pool failures identically to a static deny. (#571)
49
+ - **`controlzero.hitl` subpackage** with the `PendingApproval` dataclass
50
+ and `Status` enum. `PendingApproval` carries `request_id`,
51
+ `idempotency_key`, `status`, `created_at`, `expires_at`,
52
+ `decision_kind`, and `decided_by`; exposes `is_terminal()` and
53
+ `remaining_s()` for non-blocking checks. (#574)
54
+ - **`MockApprovalBackend`** -- in-process fake of the two HITL endpoints
55
+ (`POST /api/approval-requests`, `GET /api/approval-requests/{id}`)
56
+ with five deterministic modes: `approve_after_2s`,
57
+ `approve_timed_after_2s`, `approve_forever_after_2s`,
58
+ `deny_after_2s`, and `timeout`. Thread-safe; one instance can serve
59
+ many concurrent pollers. (#572)
60
+ - **Secret-value-leak guard** -- `is_likely_secret_value`,
61
+ `scan_payload_for_secret_leak`, and `raise_on_leak` reject any
62
+ outbound payload whose redacted-args dict still contains a
63
+ secret-shaped string. The check runs inside `request_approval()`
64
+ before the HTTP send. (#573)
65
+ - **`controlzero test --hitl approve|deny|timeout`** CLI flag drives the
66
+ mock backend end-to-end from a shell, so customers can exercise HITL
67
+ paths without setting up a real approver. (#575)
68
+ - **`PendingApproval.wait()` and `wait_async()`** -- polling loop with
69
+ jittered exponential backoff, capped at the dataclass's `expires_at`.
70
+ Honors a user-supplied `poll_fn` for tests; defaults to the SDK's
71
+ HTTP poller for production. Raises `HITLTimeoutError` on SLA expiry
72
+ and `HITLBackendUnreachableError` on network failure. (#576)
73
+ - **`Client.request_approval(decision, message=..., timeout_s=...)`** --
74
+ POSTs a HITL approval request, builds the body from the
75
+ `PolicyDecision`, threads in `X-CZ-Requestor-Email` from
76
+ `~/.controlzero/config.yaml`, sends a per-call `Idempotency-Key`, and
77
+ returns a `PendingApproval`. Maps backend error codes E1305-E1308 onto
78
+ the matching SDK exceptions. (#577)
79
+
80
+ ### Conformance
81
+
82
+ The 49 HITL/secret vectors added to `tests/parity/decisions.json` in
83
+ HITL-3 (gh#536) target this release: `min_sdk_version: "1.6.0"` and
84
+ `requires_backend: true` flags gate them. A Python conformance runner
85
+ that executes those vectors against the SDK is planned for phase 3 of
86
+ issue #409; until that lands, this release is validated against the
87
+ HITL-6a unit tests (155 new test cases across the seven slices) plus
88
+ the pre-existing 156 SDK hook tests.
89
+
90
+ ### Unchanged
91
+
92
+ - 11-line Hello World still works.
93
+ - No breaking changes to existing public API: `Client.guard()`,
94
+ `Client.refresh()`, `load_policy()` all behave identically.
95
+ - `escalate_on_deny` still defaults to `False`; policies without HITL
96
+ tags continue to run with zero HITL overhead.
97
+ - Local-mode users pay no HITL cost: imports are lazy, no HTTP client
98
+ is constructed unless `request_approval()` is called.
99
+
3
100
  ## v1.5.8 -- 2026-05-16 (HITL-5a, gh#538)
4
101
 
5
102
  Additive minor preparing the SDK for the Human-in-the-Loop approval
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: controlzero
3
- Version: 1.5.8
3
+ Version: 1.7.0
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,6 +28,7 @@ Requires-Dist: httpx>=0.25.0
28
28
  Requires-Dist: loguru>=0.7.0
29
29
  Requires-Dist: pydantic>=2.0.0
30
30
  Requires-Dist: pyyaml>=6.0
31
+ Requires-Dist: rfc8785<0.2,>=0.1.4
31
32
  Requires-Dist: rich>=13.0.0
32
33
  Requires-Dist: zstandard>=0.22.0
33
34
  Provides-Extra: anthropic
@@ -299,6 +300,154 @@ from controlzero import Client
299
300
  cz = Client() # picks up the API key from env, audit ships remote
300
301
  ```
301
302
 
303
+ ## Human-in-the-Loop approvals
304
+
305
+ Approvals let a policy block a tool call until a human approver decides allow or deny.
306
+ An agent calls `client.request_approval(decision, ...)` whenever `guard()` returns a
307
+ deny that is tagged `escalate_on_deny: true`, then waits on the returned
308
+ `PendingApproval` for the human to respond.
309
+
310
+ Basic flow:
311
+
312
+ ```python
313
+ from controlzero import Client
314
+
315
+ cz = Client(api_key="cz_live_...") # approvals require hosted mode
316
+
317
+ decision = cz.guard("delete_file", {"path": "/etc/passwd"})
318
+ if decision.decision == "deny" and getattr(decision, "hitl_eligible", False):
319
+ pending = cz.request_approval(
320
+ decision,
321
+ message="agent wants to delete /etc/passwd; please confirm",
322
+ timeout_s=300,
323
+ )
324
+
325
+ # PendingApproval.wait() requires you to inject a `poll_fn`,
326
+ # a callable that returns the latest backend snapshot of the
327
+ # approval request. In 1.6.0 the SDK does NOT ship a built-in
328
+ # HTTP poller; you wire one yourself (or use the helper that
329
+ # ships with get_secret. See "Secret reads with approvals" below).
330
+ import httpx
331
+ api_url = "https://api.controlzero.ai" # or your self-managed host
332
+
333
+ def poll_fn(request_id: str) -> dict:
334
+ resp = httpx.get(
335
+ f"{api_url}/api/approval-requests/{request_id}",
336
+ headers={"Authorization": f"Bearer {api_key}"},
337
+ timeout=10,
338
+ )
339
+ resp.raise_for_status()
340
+ return resp.json()
341
+
342
+ # Block until the human approves, denies, or the SLA expires.
343
+ resolved = pending.wait(poll_fn)
344
+ if resolved.status == "approved":
345
+ # proceed with the gated action
346
+ ...
347
+ else:
348
+ # denied or timed_out, abort the tool call
349
+ ...
350
+ ```
351
+
352
+ `wait()` blocks the calling thread. For async code, use `wait_async()`,
353
+ same contract, but the poll callable can be `async def` or a sync
354
+ function (sync calls are dispatched to a thread executor so the event
355
+ loop never blocks):
356
+
357
+ ```python
358
+ async def async_poll(request_id: str) -> dict:
359
+ async with httpx.AsyncClient() as client:
360
+ resp = await client.get(
361
+ f"{api_url}/api/approval-requests/{request_id}",
362
+ headers={"Authorization": f"Bearer {api_key}"},
363
+ timeout=10,
364
+ )
365
+ resp.raise_for_status()
366
+ return resp.json()
367
+
368
+ resolved = await pending.wait_async(async_poll)
369
+ ```
370
+
371
+ A built-in HTTP poller is planned for 1.7.0.
372
+
373
+ ### Mock backend for tests
374
+
375
+ The SDK ships an in-process `MockApprovalBackend` so tests can exercise approval paths
376
+ without standing up the real backend. Wire it into the polling loop by passing
377
+ `poll_fn`:
378
+
379
+ ```python
380
+ from controlzero import PendingApproval
381
+ from controlzero.hitl.mock import MockApprovalBackend
382
+
383
+ backend = MockApprovalBackend("approve_after_2s", delay_s=0.05)
384
+ created = backend.create_request({"canonical_action": "delete_file"})
385
+ pending = PendingApproval(
386
+ request_id=created["request_id"],
387
+ idempotency_key="test-key",
388
+ status="pending",
389
+ created_at=created["created_at"],
390
+ expires_at=created["expires_at"],
391
+ )
392
+ resolved = pending.wait(poll_fn=lambda rid: backend.get_request(rid))
393
+ assert resolved.status == "approved"
394
+ ```
395
+
396
+ The five supported modes are `approve_after_2s`, `approve_timed_after_2s`,
397
+ `approve_forever_after_2s`, `deny_after_2s`, and `timeout`.
398
+
399
+ ### Identity requirement
400
+
401
+ Every approval request must carry the operator email so the backend can route to a
402
+ real person and stamp identity provenance on the grant. Set it once via the CLI:
403
+
404
+ ```bash
405
+ controlzero install <agent> --email you@example.com
406
+ ```
407
+
408
+ If the email is missing, `request_approval()` raises `HITLIdentityRequired`
409
+ (E1707) before any HTTP traffic.
410
+
411
+ ### Secret reads with approvals
412
+
413
+ When a policy gates a secret behind approval, `client.get_secret(name)` raises
414
+ `SecretApprovalRequired` (E1710) carrying a `pending` attribute the caller waits
415
+ on:
416
+
417
+ ```python
418
+ from controlzero.errors import SecretApprovalRequired
419
+
420
+ try:
421
+ value = cz.get_secret("PROD_DB_PASSWORD")
422
+ except SecretApprovalRequired as exc:
423
+ resolved = exc.pending.wait()
424
+ if resolved.status == "approved":
425
+ value = cz.get_secret("PROD_DB_PASSWORD") # retry now that grant exists
426
+ else:
427
+ raise # abort
428
+ ```
429
+
430
+ ### Exception classes
431
+
432
+ The 11 approval-related exception codes raised by this surface. Class names retain
433
+ the `HITL` prefix because they are part of the stable public SDK API:
434
+
435
+ | Code | Class | Meaning |
436
+ | ----- | -------------------------------- | ---------------------------------------------------------- |
437
+ | E1701 | `HITLTimeoutError` | Approver did not decide before `timeout_s` elapsed. |
438
+ | E1702 | `HITLBackendUnreachableError` | POST to the approval endpoint failed after retries. |
439
+ | E1703 | `HITLPolicyVersionConflictError` | SDK bundle is missing the rule that triggered the request. |
440
+ | E1704 | `HITLNotConfiguredError` | Org has no approval settings row configured. |
441
+ | E1705 | `HITLNoApproverAvailable` | Approver pool is empty or no member is active. |
442
+ | E1706 | `HITLIdentityNotInOrg` | Operator email is not a member of the API key's org. |
443
+ | E1707 | `HITLIdentityRequired` | No operator email set on this install. |
444
+ | E1708 | `HITLIdentityClaimRejected` | Backend rejected the identity claim. |
445
+ | E1709 | `SecretValueLeakInPayload` | Outbound payload contains a secret-shaped string. Aborted. |
446
+ | E1710 | `SecretApprovalRequired` | Secret read requires approval; wait on `exc.pending`. |
447
+ | E1711 | `SecretNotFound` | Named secret does not exist in the configured vault. |
448
+
449
+ Full reference and runbooks: [docs.controlzero.ai/hitl](https://docs.controlzero.ai/hitl).
450
+
302
451
  ## License
303
452
 
304
453
  Apache 2.0
@@ -246,6 +246,154 @@ from controlzero import Client
246
246
  cz = Client() # picks up the API key from env, audit ships remote
247
247
  ```
248
248
 
249
+ ## Human-in-the-Loop approvals
250
+
251
+ Approvals let a policy block a tool call until a human approver decides allow or deny.
252
+ An agent calls `client.request_approval(decision, ...)` whenever `guard()` returns a
253
+ deny that is tagged `escalate_on_deny: true`, then waits on the returned
254
+ `PendingApproval` for the human to respond.
255
+
256
+ Basic flow:
257
+
258
+ ```python
259
+ from controlzero import Client
260
+
261
+ cz = Client(api_key="cz_live_...") # approvals require hosted mode
262
+
263
+ decision = cz.guard("delete_file", {"path": "/etc/passwd"})
264
+ if decision.decision == "deny" and getattr(decision, "hitl_eligible", False):
265
+ pending = cz.request_approval(
266
+ decision,
267
+ message="agent wants to delete /etc/passwd; please confirm",
268
+ timeout_s=300,
269
+ )
270
+
271
+ # PendingApproval.wait() requires you to inject a `poll_fn`,
272
+ # a callable that returns the latest backend snapshot of the
273
+ # approval request. In 1.6.0 the SDK does NOT ship a built-in
274
+ # HTTP poller; you wire one yourself (or use the helper that
275
+ # ships with get_secret. See "Secret reads with approvals" below).
276
+ import httpx
277
+ api_url = "https://api.controlzero.ai" # or your self-managed host
278
+
279
+ def poll_fn(request_id: str) -> dict:
280
+ resp = httpx.get(
281
+ f"{api_url}/api/approval-requests/{request_id}",
282
+ headers={"Authorization": f"Bearer {api_key}"},
283
+ timeout=10,
284
+ )
285
+ resp.raise_for_status()
286
+ return resp.json()
287
+
288
+ # Block until the human approves, denies, or the SLA expires.
289
+ resolved = pending.wait(poll_fn)
290
+ if resolved.status == "approved":
291
+ # proceed with the gated action
292
+ ...
293
+ else:
294
+ # denied or timed_out, abort the tool call
295
+ ...
296
+ ```
297
+
298
+ `wait()` blocks the calling thread. For async code, use `wait_async()`,
299
+ same contract, but the poll callable can be `async def` or a sync
300
+ function (sync calls are dispatched to a thread executor so the event
301
+ loop never blocks):
302
+
303
+ ```python
304
+ async def async_poll(request_id: str) -> dict:
305
+ async with httpx.AsyncClient() as client:
306
+ resp = await client.get(
307
+ f"{api_url}/api/approval-requests/{request_id}",
308
+ headers={"Authorization": f"Bearer {api_key}"},
309
+ timeout=10,
310
+ )
311
+ resp.raise_for_status()
312
+ return resp.json()
313
+
314
+ resolved = await pending.wait_async(async_poll)
315
+ ```
316
+
317
+ A built-in HTTP poller is planned for 1.7.0.
318
+
319
+ ### Mock backend for tests
320
+
321
+ The SDK ships an in-process `MockApprovalBackend` so tests can exercise approval paths
322
+ without standing up the real backend. Wire it into the polling loop by passing
323
+ `poll_fn`:
324
+
325
+ ```python
326
+ from controlzero import PendingApproval
327
+ from controlzero.hitl.mock import MockApprovalBackend
328
+
329
+ backend = MockApprovalBackend("approve_after_2s", delay_s=0.05)
330
+ created = backend.create_request({"canonical_action": "delete_file"})
331
+ pending = PendingApproval(
332
+ request_id=created["request_id"],
333
+ idempotency_key="test-key",
334
+ status="pending",
335
+ created_at=created["created_at"],
336
+ expires_at=created["expires_at"],
337
+ )
338
+ resolved = pending.wait(poll_fn=lambda rid: backend.get_request(rid))
339
+ assert resolved.status == "approved"
340
+ ```
341
+
342
+ The five supported modes are `approve_after_2s`, `approve_timed_after_2s`,
343
+ `approve_forever_after_2s`, `deny_after_2s`, and `timeout`.
344
+
345
+ ### Identity requirement
346
+
347
+ Every approval request must carry the operator email so the backend can route to a
348
+ real person and stamp identity provenance on the grant. Set it once via the CLI:
349
+
350
+ ```bash
351
+ controlzero install <agent> --email you@example.com
352
+ ```
353
+
354
+ If the email is missing, `request_approval()` raises `HITLIdentityRequired`
355
+ (E1707) before any HTTP traffic.
356
+
357
+ ### Secret reads with approvals
358
+
359
+ When a policy gates a secret behind approval, `client.get_secret(name)` raises
360
+ `SecretApprovalRequired` (E1710) carrying a `pending` attribute the caller waits
361
+ on:
362
+
363
+ ```python
364
+ from controlzero.errors import SecretApprovalRequired
365
+
366
+ try:
367
+ value = cz.get_secret("PROD_DB_PASSWORD")
368
+ except SecretApprovalRequired as exc:
369
+ resolved = exc.pending.wait()
370
+ if resolved.status == "approved":
371
+ value = cz.get_secret("PROD_DB_PASSWORD") # retry now that grant exists
372
+ else:
373
+ raise # abort
374
+ ```
375
+
376
+ ### Exception classes
377
+
378
+ The 11 approval-related exception codes raised by this surface. Class names retain
379
+ the `HITL` prefix because they are part of the stable public SDK API:
380
+
381
+ | Code | Class | Meaning |
382
+ | ----- | -------------------------------- | ---------------------------------------------------------- |
383
+ | E1701 | `HITLTimeoutError` | Approver did not decide before `timeout_s` elapsed. |
384
+ | E1702 | `HITLBackendUnreachableError` | POST to the approval endpoint failed after retries. |
385
+ | E1703 | `HITLPolicyVersionConflictError` | SDK bundle is missing the rule that triggered the request. |
386
+ | E1704 | `HITLNotConfiguredError` | Org has no approval settings row configured. |
387
+ | E1705 | `HITLNoApproverAvailable` | Approver pool is empty or no member is active. |
388
+ | E1706 | `HITLIdentityNotInOrg` | Operator email is not a member of the API key's org. |
389
+ | E1707 | `HITLIdentityRequired` | No operator email set on this install. |
390
+ | E1708 | `HITLIdentityClaimRejected` | Backend rejected the identity claim. |
391
+ | E1709 | `SecretValueLeakInPayload` | Outbound payload contains a secret-shaped string. Aborted. |
392
+ | E1710 | `SecretApprovalRequired` | Secret read requires approval; wait on `exc.pending`. |
393
+ | E1711 | `SecretNotFound` | Named secret does not exist in the configured vault. |
394
+
395
+ Full reference and runbooks: [docs.controlzero.ai/hitl](https://docs.controlzero.ai/hitl).
396
+
249
397
  ## License
250
398
 
251
399
  Apache 2.0
@@ -26,12 +26,14 @@ from controlzero.errors import (
26
26
  PolicyLoadError,
27
27
  PolicyValidationError,
28
28
  )
29
+ from controlzero.hitl import PendingApproval
29
30
  from controlzero.policy_loader import load_policy
30
31
 
31
- __version__ = "1.5.8"
32
+ __version__ = "1.7.0"
32
33
 
33
34
  __all__ = [
34
35
  "Client",
36
+ "PendingApproval",
35
37
  "PolicyDecision",
36
38
  "PolicyDeniedError",
37
39
  "PolicyLoadError",
@@ -0,0 +1,182 @@
1
+ """T86 / GitHub #391 -- unknown-action validator (warn-only at SDK load).
2
+
3
+ Pairs with the backend validator at
4
+ ``apps/control-zero-platform/backend/internal/policy/action_aliases.go``.
5
+ The backend BLOCKS publish on unknown actions (422); the SDK
6
+ WARNS at load time so a customer running local-policy mode (no
7
+ backend) still sees the typo before the rule silently never fires.
8
+
9
+ The known-action set is the union of:
10
+
11
+ - Canonical tools (``database``, ``Bash``, ``http``, ``web_search``,
12
+ ``browser``, ``file_read``, ``file_write``, ``file_search``,
13
+ ``task``) plus their host-tool aliases from the SDK extractor
14
+ spec (``sdks/python/controlzero/controlzero/_internal/tool_extractors.json``).
15
+ - For the ``database`` tool: the four canonical SQL semantic classes
16
+ (``read``/``write``/``admin``/``exec``), every legacy alias from
17
+ the T84 alias table, and the ambiguous ``delete`` alias.
18
+
19
+ For every other tool the validator accepts ANY method (open
20
+ extractor outputs -- Bash basenames, HTTP verbs, browser action
21
+ strings, etc.). Wildcards (``*``, ``tool:*``, ``*:method``) always
22
+ pass.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ from typing import Iterable
27
+
28
+ from controlzero._internal.action_aliases import TOOL as _ALIAS_TOOL
29
+ from controlzero._internal.action_aliases import _AMBIGUOUS, _CLASSES
30
+
31
+ # Mirror of the canonical tool set + host-aliases the extractors
32
+ # accept. Source of truth is tool_extractors.json; this list is
33
+ # updated alongside it.
34
+ _CANONICAL_TOOLS: set[str] = {
35
+ "Bash", "database", "http", "web_search", "browser",
36
+ "file_read", "file_write", "file_search", "task",
37
+ # database aliases
38
+ "sql", "Database", "PostgreSQL", "MySQL", "postgres", "sqlite",
39
+ # Bash aliases
40
+ "bash", "shell", "ShellTool", "run_shell_command",
41
+ "PowerShell", "powershell", "Shell",
42
+ # http aliases
43
+ "fetch", "web_fetch", "WebFetch", "HTTPRequest", "request",
44
+ # web_search aliases
45
+ "WebSearch", "google_web_search", "SearchTool",
46
+ # browser aliases
47
+ "playwright", "Puppeteer",
48
+ # file_read aliases
49
+ "read_file", "Read", "ReadFile", "read_many_files",
50
+ # file_write aliases
51
+ "write_file", "Write", "WriteFile", "edit_file", "Edit",
52
+ "replace", "apply_patch",
53
+ # file_search aliases
54
+ "Grep", "grep_search", "Glob", "glob",
55
+ # task aliases
56
+ "Task", "Agent", "subagent", "spawn_agent",
57
+ }
58
+
59
+ _DATABASE_TOOL_ALIASES = {
60
+ "database", "sql", "Database", "PostgreSQL", "MySQL", "postgres", "sqlite",
61
+ }
62
+
63
+
64
+ def _build_known_database_methods() -> set[str]:
65
+ out: set[str] = {"*"}
66
+ for cls, aliases in _CLASSES.items():
67
+ out.add(cls)
68
+ for a in aliases:
69
+ out.add(a)
70
+ for alias in _AMBIGUOUS:
71
+ out.add(alias)
72
+ return out
73
+
74
+
75
+ _KNOWN_DATABASE_METHODS = _build_known_database_methods()
76
+
77
+
78
+ def is_known_action(action: str) -> bool:
79
+ """Return True if ``action`` is recognised by the SDK extractors / aliases."""
80
+ if not action:
81
+ return False
82
+ if action == "*":
83
+ return True
84
+ if ":" not in action:
85
+ return action in _CANONICAL_TOOLS
86
+ tool, _, method = action.partition(":")
87
+ if tool == "*":
88
+ return True
89
+ if tool not in _CANONICAL_TOOLS:
90
+ return False
91
+ if method == "*" or method == "":
92
+ return True
93
+ if tool in _DATABASE_TOOL_ALIASES:
94
+ return method in _KNOWN_DATABASE_METHODS
95
+ # Other tools: any method accepted (open extractor outputs).
96
+ return True
97
+
98
+
99
+ def _levenshtein(a: str, b: str) -> int:
100
+ if a == b:
101
+ return 0
102
+ if not a:
103
+ return len(b)
104
+ if not b:
105
+ return len(a)
106
+ prev = list(range(len(b) + 1))
107
+ curr = [0] * (len(b) + 1)
108
+ for i in range(1, len(a) + 1):
109
+ curr[0] = i
110
+ for j in range(1, len(b) + 1):
111
+ cost = 0 if a[i - 1] == b[j - 1] else 1
112
+ curr[j] = min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost)
113
+ prev, curr = curr, prev
114
+ return prev[len(b)]
115
+
116
+
117
+ def _shares_tool_prefix(a: str, b: str) -> bool:
118
+ if ":" not in a or ":" not in b:
119
+ return False
120
+ ta, _, ma = a.partition(":")
121
+ tb, _, mb = b.partition(":")
122
+ if ta != tb or not ma or not mb:
123
+ return False
124
+ short = min(len(ma), len(mb))
125
+ overlap = 0
126
+ for i in range(short):
127
+ if ma[i].lower() != mb[i].lower():
128
+ break
129
+ overlap += 1
130
+ return overlap * 2 >= short
131
+
132
+
133
+ def _candidates() -> list[tuple[str, bool]]:
134
+ """Enumerate (name, is_legacy) tuples for suggestion ranking."""
135
+ out: list[tuple[str, bool]] = []
136
+ for cls in _CLASSES:
137
+ out.append((f"{_ALIAS_TOOL}:{cls}", False))
138
+ for aliases in _CLASSES.values():
139
+ for a in aliases:
140
+ out.append((f"{_ALIAS_TOOL}:{a}", True))
141
+ for alias in _AMBIGUOUS:
142
+ out.append((f"{_ALIAS_TOOL}:{alias}", True))
143
+ for tool in _CANONICAL_TOOLS:
144
+ out.append((f"{tool}:*", False))
145
+ return out
146
+
147
+
148
+ def suggest_for_action(action: str, max_suggestions: int = 3) -> list[str]:
149
+ """Return up to ``max_suggestions`` did-you-mean candidates for ``action``."""
150
+ max_distance = 3
151
+ cands = _candidates()
152
+ hits: list[tuple[str, int, bool]] = []
153
+ for name, legacy in cands:
154
+ d = _levenshtein(action, name)
155
+ if d > max_distance and not _shares_tool_prefix(action, name):
156
+ continue
157
+ hits.append((name, d, legacy))
158
+ # Sort by distance, then prefer canonical over legacy, then name.
159
+ hits.sort(key=lambda h: (h[1], h[2], h[0]))
160
+ out: list[str] = []
161
+ for name, _d, legacy in hits[:max_suggestions]:
162
+ out.append(f"{name} (legacy)" if legacy else name)
163
+ return out
164
+
165
+
166
+ def validate_actions(actions: Iterable[str]) -> tuple[list[str], dict[str, list[str]]]:
167
+ """Return (unknown_actions, suggestions_map) for the given action list."""
168
+ unknown: list[str] = []
169
+ suggestions: dict[str, list[str]] = {}
170
+ seen: set[str] = set()
171
+ for a in actions:
172
+ if is_known_action(a):
173
+ continue
174
+ if a in seen:
175
+ continue
176
+ seen.add(a)
177
+ unknown.append(a)
178
+ suggestions[a] = suggest_for_action(a)
179
+ return unknown, suggestions
180
+
181
+
182
+ __all__ = ["is_known_action", "suggest_for_action", "validate_actions"]