controlzero 1.6.0__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.6.0 → controlzero-1.7.0}/CHANGELOG.md +27 -0
  2. {controlzero-1.6.0 → controlzero-1.7.0}/PKG-INFO +1 -1
  3. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/__init__.py +1 -1
  4. controlzero-1.7.0/controlzero/_internal/action_validator.py +182 -0
  5. controlzero-1.7.0/controlzero/_internal/credential_hook.py +339 -0
  6. controlzero-1.7.0/controlzero/_internal/credential_scanner.py +391 -0
  7. controlzero-1.7.0/controlzero/_internal/credentials_data/__init__.py +12 -0
  8. controlzero-1.7.0/controlzero/_internal/credentials_data/built_in.yaml +2259 -0
  9. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/audit_remote.py +38 -2
  10. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/client.py +40 -0
  11. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/error_codes.py +55 -0
  12. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/errors.py +68 -0
  13. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/hitl/pending_approval.py +12 -2
  14. controlzero-1.7.0/controlzero/hitl/status.py +77 -0
  15. controlzero-1.7.0/controlzero/hooks/__init__.py +8 -0
  16. controlzero-1.7.0/controlzero/hooks/tool_output_handler.py +94 -0
  17. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/policy_loader.py +32 -0
  18. {controlzero-1.6.0 → controlzero-1.7.0}/pyproject.toml +6 -1
  19. controlzero-1.7.0/tests/test_action_validator_t86.py +112 -0
  20. controlzero-1.7.0/tests/test_conformance.py +335 -0
  21. controlzero-1.7.0/tests/test_credential_hook.py +738 -0
  22. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_hitl_6a_pending_approval.py +10 -0
  23. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_hitl_6a_request_approval.py +62 -0
  24. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_hitl_6a_wait.py +78 -0
  25. controlzero-1.7.0/tests/test_hitl_conformance.py +287 -0
  26. controlzero-1.7.0/tests/test_policy_source_audit.py +149 -0
  27. controlzero-1.6.0/.gitignore +0 -248
  28. controlzero-1.6.0/controlzero/hitl/status.py +0 -46
  29. {controlzero-1.6.0 → controlzero-1.7.0}/Dockerfile.test +0 -0
  30. {controlzero-1.6.0 → controlzero-1.7.0}/LICENSE +0 -0
  31. {controlzero-1.6.0 → controlzero-1.7.0}/README.md +0 -0
  32. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/_internal/__init__.py +0 -0
  33. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/_internal/action_aliases.py +0 -0
  34. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/_internal/bundle.py +0 -0
  35. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/_internal/dlp_scanner.py +0 -0
  36. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/_internal/enforcer.py +0 -0
  37. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/_internal/hook_extractors.py +0 -0
  38. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/_internal/tool_extractors.json +0 -0
  39. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/_internal/types.py +0 -0
  40. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/audit_local.py +0 -0
  41. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/canonical.py +0 -0
  42. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/__init__.py +0 -0
  43. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/_secrets.py +0 -0
  44. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/console.py +0 -0
  45. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/debug_bundle.py +0 -0
  46. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/doctor.py +0 -0
  47. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/hosts/__init__.py +0 -0
  48. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/hosts/base.py +0 -0
  49. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/hosts/claude_code.py +0 -0
  50. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/hosts/codex_cli.py +0 -0
  51. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/hosts/gemini_cli.py +0 -0
  52. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/hosts/unknown.py +0 -0
  53. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/main.py +0 -0
  54. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/migrate.py +0 -0
  55. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/telemetry_consent.py +0 -0
  56. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/templates/autogen.yaml +0 -0
  57. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/templates/claude-code.yaml +0 -0
  58. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/templates/codex-cli.yaml +0 -0
  59. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/templates/cost-cap.yaml +0 -0
  60. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/templates/crewai.yaml +0 -0
  61. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/templates/cursor.yaml +0 -0
  62. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/templates/gemini-cli.yaml +0 -0
  63. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/templates/generic.yaml +0 -0
  64. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/templates/langchain.yaml +0 -0
  65. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/templates/mcp.yaml +0 -0
  66. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/templates/rag.yaml +0 -0
  67. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/device.py +0 -0
  68. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/enrollment.py +0 -0
  69. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/hitl/__init__.py +0 -0
  70. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/hitl/mock.py +0 -0
  71. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/hitl/secret_leak_guard.py +0 -0
  72. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/hosted_policy.py +0 -0
  73. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/__init__.py +0 -0
  74. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/anthropic.py +0 -0
  75. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/autogen.py +0 -0
  76. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/braintrust.py +0 -0
  77. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/crewai/__init__.py +0 -0
  78. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/crewai/agent.py +0 -0
  79. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/crewai/crew.py +0 -0
  80. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/crewai/task.py +0 -0
  81. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/crewai/tool.py +0 -0
  82. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/google.py +0 -0
  83. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/google_adk/__init__.py +0 -0
  84. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/google_adk/agent.py +0 -0
  85. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/google_adk/tool.py +0 -0
  86. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/langchain/__init__.py +0 -0
  87. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/langchain/agent.py +0 -0
  88. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/langchain/callbacks.py +0 -0
  89. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/langchain/chain.py +0 -0
  90. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/langchain/graph.py +0 -0
  91. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/langchain/modern.py +0 -0
  92. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/langchain/tool.py +0 -0
  93. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/langfuse.py +0 -0
  94. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/litellm.py +0 -0
  95. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/openai.py +0 -0
  96. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/pydantic_ai.py +0 -0
  97. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/vercel_ai.py +0 -0
  98. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/layout_migration.py +0 -0
  99. {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/tamper.py +0 -0
  100. {controlzero-1.6.0 → controlzero-1.7.0}/examples/hello_world.py +0 -0
  101. {controlzero-1.6.0 → controlzero-1.7.0}/tests/_fixtures/jcs_args_hash_vectors.json +0 -0
  102. {controlzero-1.6.0 → controlzero-1.7.0}/tests/conftest.py +0 -0
  103. {controlzero-1.6.0 → controlzero-1.7.0}/tests/integrations/__init__.py +0 -0
  104. {controlzero-1.6.0 → controlzero-1.7.0}/tests/integrations/test_google.py +0 -0
  105. {controlzero-1.6.0 → controlzero-1.7.0}/tests/parity/action_aliases.json +0 -0
  106. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_action_aliases.py +0 -0
  107. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_action_canonicalization.py +0 -0
  108. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_agent_name_env.py +0 -0
  109. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_api_key_mask.py +0 -0
  110. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_audit_remote.py +0 -0
  111. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_audit_remote_sdk_version.py +0 -0
  112. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_audit_sink_isolation.py +0 -0
  113. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_bundle_parser.py +0 -0
  114. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_bundle_translate.py +0 -0
  115. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_canonical_phase1a.py +0 -0
  116. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_cli_carve_out.py +0 -0
  117. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_cli_debug_bundle.py +0 -0
  118. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_cli_extractor_integration.py +0 -0
  119. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_cli_hook.py +0 -0
  120. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_cli_hosted_refresh.py +0 -0
  121. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_cli_init.py +0 -0
  122. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_cli_init_templates.py +0 -0
  123. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_cli_tail.py +0 -0
  124. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_cli_test.py +0 -0
  125. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_cli_validate.py +0 -0
  126. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_coding_agent_hooks.py +0 -0
  127. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_conditions.py +0 -0
  128. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_console.py +0 -0
  129. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_default_action.py +0 -0
  130. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_device.py +0 -0
  131. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_dlp_scanner.py +0 -0
  132. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_doctor.py +0 -0
  133. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_engine_version_consistency.py +0 -0
  134. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_enrollment.py +0 -0
  135. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_env_dump_438.py +0 -0
  136. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_error_codes.py +0 -0
  137. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_errors_e_codes.py +0 -0
  138. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_fail_closed_eval.py +0 -0
  139. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_glob_matching.py +0 -0
  140. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_hitl_5d_email_install.py +0 -0
  141. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_hitl_6a_cli_flag.py +0 -0
  142. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_hitl_6a_exceptions.py +0 -0
  143. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_hitl_6a_get_secret_hitl.py +0 -0
  144. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_hitl_6a_mock_backend.py +0 -0
  145. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_hitl_6a_secret_leak_guard.py +0 -0
  146. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_hitl_reason_codes.py +0 -0
  147. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_hitl_validator_keys.py +0 -0
  148. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_hook_extractors.py +0 -0
  149. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_hosted_policy_e2e.py +0 -0
  150. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_hosts_adapter.py +0 -0
  151. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_hybrid_mode_strict.py +0 -0
  152. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_hybrid_mode_warn.py +0 -0
  153. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_install_hook_command.py +0 -0
  154. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_install_hooks.py +0 -0
  155. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_layout_migration_t101.py +0 -0
  156. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_layout_parity_t102.py +0 -0
  157. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_local_mode_dict.py +0 -0
  158. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_local_mode_file_json.py +0 -0
  159. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_local_mode_file_yaml.py +0 -0
  160. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_log_fallback_stderr.py +0 -0
  161. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_log_options_ignored_hosted.py +0 -0
  162. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_log_rotation.py +0 -0
  163. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_migrate.py +0 -0
  164. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_min_sdk_version_gate.py +0 -0
  165. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_multi_client_per_project_175.py +0 -0
  166. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_no_policy_no_key.py +0 -0
  167. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_package_rename_shim.py +0 -0
  168. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_policy_engine_version_phase1b.py +0 -0
  169. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_policy_freshness.py +0 -0
  170. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_policy_settings.py +0 -0
  171. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_quarantine.py +0 -0
  172. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_reason_code.py +0 -0
  173. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_refresh.py +0 -0
  174. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_secrets.py +0 -0
  175. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_sql_semantic_class.py +0 -0
  176. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_synthetic_policy_id_t79.py +0 -0
  177. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_t103_precedence.py +0 -0
  178. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_t104_cache_gc.py +0 -0
  179. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_t108_local_override_audit.py +0 -0
  180. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_t96_single_audit_log.py +0 -0
  181. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_t99_install_prefetch_bundle.py +0 -0
  182. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_tamper.py +0 -0
  183. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_tamper_behavior.py +0 -0
  184. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_tamper_hook.py +0 -0
  185. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_telemetry_consent.py +0 -0
  186. {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_unsafe_int_boundary.py +0 -0
@@ -1,5 +1,32 @@
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
+
3
30
  ## v1.6.0 -- 2026-05-17 (HITL-6a, gh#542)
4
31
 
5
32
  First minor that turns the Human-in-the-Loop approval workflow on. 1.5.8
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: controlzero
3
- Version: 1.6.0
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
@@ -29,7 +29,7 @@ from controlzero.errors import (
29
29
  from controlzero.hitl import PendingApproval
30
30
  from controlzero.policy_loader import load_policy
31
31
 
32
- __version__ = "1.6.0"
32
+ __version__ = "1.7.0"
33
33
 
34
34
  __all__ = [
35
35
  "Client",
@@ -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"]
@@ -0,0 +1,339 @@
1
+ """Credential leak ingest hook (epic #666, PR-2).
2
+
3
+ Wraps the pure-Python `scan_for_credentials` and turns a list of
4
+ matches into:
5
+
6
+ * a possibly-redacted body of text (when action == "redact"),
7
+ * a list of audit-row dicts ready for the existing batched audit
8
+ flush (`audit_remote.py`),
9
+ * an optional raised exception (when action == "block").
10
+
11
+ Design notes:
12
+
13
+ * Redaction never echoes plaintext credentials back to the audit row.
14
+ The matched bytes are replaced in-place with
15
+ `cz:credleak:<sha256_hex>` so the audit row can deterministically
16
+ reference the same secret across calls without holding it.
17
+ * `value_hash` is HMAC-SHA256(per-org key, plaintext bytes), first
18
+ 16 hex chars. One-way; rotates with the org HMAC key. The plaintext
19
+ is held only on the stack during this call -- the function returns
20
+ redacted text + hashes so no caller needs to manage the raw value.
21
+ * The 16-byte context window surrounding each match is masked: the
22
+ literal `<MASKED>` replaces the credential body itself so the
23
+ audit row never carries any prefix or suffix of the secret bytes.
24
+ * `CONTROLZERO_CREDLEAK_OFF=1` in the environment downgrades any
25
+ configured action to `warn`, but still emits the audit row with
26
+ `enforcement_downgraded=True`. The env override is the operator's
27
+ break-glass for a noisy false positive in production; the row
28
+ preserves the original intent.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import hashlib
34
+ import hmac
35
+ import os
36
+ from dataclasses import dataclass
37
+ from typing import Any, Literal
38
+
39
+ from controlzero._internal.credential_scanner import scan_for_credentials
40
+ from controlzero.errors import CredentialLeakBlocked
41
+
42
+
43
+ Action = Literal["warn", "redact", "block"]
44
+ Source = Literal["tool_output", "tool_stderr", "file_read", "grep_match"]
45
+
46
+ # Operator break-glass: when this env var is set to "1" the handler
47
+ # downgrades any non-warn action to warn and stamps the audit row with
48
+ # `enforcement_downgraded=True`. Useful when a false positive is
49
+ # blocking work in production while the catalog is updated.
50
+ _OFF_ENV_VAR = "CONTROLZERO_CREDLEAK_OFF"
51
+
52
+ # Sentinel inserted into the context window in place of the actual
53
+ # credential bytes. The 16 bytes on either side of the credential
54
+ # are preserved as additional ambient context for the audit reviewer;
55
+ # the credential itself is never echoed.
56
+ _MASK = "<MASKED>"
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class _Match:
61
+ """Internal projection of a single scanner hit. Decouples the
62
+ handler from the raw dict shape `scan_for_credentials` emits."""
63
+
64
+ pattern_id: str
65
+ severity: str
66
+ start: int
67
+ end: int
68
+
69
+
70
+ def _hmac_value_hash(hmac_key: bytes, plaintext: bytes) -> str:
71
+ """First 16 hex chars of HMAC-SHA256(hmac_key, plaintext).
72
+
73
+ The hash truncation is deliberate: 64 bits is sufficient
74
+ de-duplication granularity for credential matches per org (the
75
+ rotation tracker groups by hash, and even at 100M rows the
76
+ expected collision count stays well under 1), and the shorter
77
+ string keeps the audit_logs.metadata column LOW-cardinality
78
+ friendly. The key MUST be the per-org HMAC key issued at
79
+ enrollment; never reuse across orgs because cross-org hash
80
+ equality would leak `same secret` membership across tenants.
81
+ """
82
+ return hmac.new(hmac_key, plaintext, hashlib.sha256).hexdigest()[:16]
83
+
84
+
85
+ def _build_context_window(text: str, start: int, end: int) -> str:
86
+ """Sixteen bytes of ambient text on either side of the credential,
87
+ with the credential body itself replaced by `<MASKED>`.
88
+
89
+ The window is computed on the str (not bytes) for simplicity;
90
+ on ASCII text -- the dominant case for tool output of secrets --
91
+ the result is byte-equivalent. Non-printable bytes that survive
92
+ in the window are passed through as-is so the audit reviewer
93
+ sees the operator-visible representation; downstream analytical
94
+ store columns of type `String` accept any UTF-8.
95
+ """
96
+ text_len = len(text)
97
+ left_start = max(0, start - 16)
98
+ right_end = min(text_len, end + 16)
99
+ left = text[left_start:start]
100
+ right = text[end:right_end]
101
+ return f"{left}{_MASK}{right}"
102
+
103
+
104
+ class CredentialLeakHandler:
105
+ """Ingest hook that wires the scanner + redactor + audit emit into
106
+ one call.
107
+
108
+ The handler is intentionally instantiated per-client (not per-call)
109
+ so the same configuration -- project id, action posture, HMAC key
110
+ -- applies across every tool-output scan from a given agent. The
111
+ object holds no per-call state; it is safe to share across
112
+ threads.
113
+ """
114
+
115
+ def __init__(
116
+ self,
117
+ *,
118
+ client: Any,
119
+ project_id: str,
120
+ action: Action,
121
+ hmac_key: bytes,
122
+ ) -> None:
123
+ if action not in ("warn", "redact", "block"):
124
+ raise ValueError(
125
+ f"CredentialLeakHandler.action must be warn|redact|block, got {action!r}"
126
+ )
127
+ if not isinstance(hmac_key, (bytes, bytearray)):
128
+ raise TypeError("hmac_key must be bytes")
129
+ if len(hmac_key) < 16:
130
+ # 16 bytes is the minimum we accept; HMAC-SHA256 can take
131
+ # any key length but a short key offers no security
132
+ # advantage and almost always indicates a config bug.
133
+ raise ValueError("hmac_key must be at least 16 bytes")
134
+ self._client = client
135
+ self._project_id = project_id
136
+ self._configured_action = action
137
+ self._hmac_key = bytes(hmac_key)
138
+
139
+ # ------------------------------------------------------------------
140
+ # Public entry point.
141
+ # ------------------------------------------------------------------
142
+
143
+ def handle(
144
+ self,
145
+ *,
146
+ source: Source,
147
+ text: str,
148
+ tool_name: str,
149
+ tool_call_id: str,
150
+ agent_name: str,
151
+ ) -> tuple[str, list[dict[str, Any]]]:
152
+ """Scan `text`, emit audit rows for each hit, return the
153
+ (possibly-redacted) text plus the rows.
154
+
155
+ When no credentials are detected the original text is returned
156
+ verbatim and the row list is empty -- the function is
157
+ zero-effect on innocuous input. When credentials are detected
158
+ the function:
159
+
160
+ 1. Resolves the effective action (configured action, possibly
161
+ downgraded to `warn` by the env override).
162
+ 2. Builds one audit row per match. The row carries no
163
+ plaintext; the credential is hashed via `_hmac_value_hash`
164
+ and the surrounding context is masked.
165
+ 3. Either redacts each match in-place, returns the text
166
+ unchanged (warn), or raises `CredentialLeakBlocked` AFTER
167
+ emitting the rows (block).
168
+
169
+ Returns:
170
+ (returned_text, rows) -- when action == "block" this
171
+ tuple is never observed by the caller because the
172
+ function raises before returning.
173
+ """
174
+ raw = scan_for_credentials(text)
175
+ if not raw:
176
+ return text, []
177
+
178
+ matches = [
179
+ _Match(
180
+ pattern_id=str(m["pattern_id"]),
181
+ severity=str(m["severity"]),
182
+ start=int(m["start"]),
183
+ end=int(m["end"]),
184
+ )
185
+ for m in raw
186
+ ]
187
+
188
+ effective, downgraded = self._resolve_effective_action()
189
+
190
+ # Build audit rows first so the rows reflect the original
191
+ # match positions; redaction shifts byte offsets but the
192
+ # audit row records the pre-redaction span.
193
+ rows = [
194
+ self._build_audit_row(
195
+ m=m,
196
+ text=text,
197
+ source=source,
198
+ tool_name=tool_name,
199
+ tool_call_id=tool_call_id,
200
+ agent_name=agent_name,
201
+ effective_action=effective,
202
+ enforcement_downgraded=downgraded,
203
+ )
204
+ for m in matches
205
+ ]
206
+
207
+ # Emit each row through the configured audit sink. Audit
208
+ # delivery is best-effort and never blocks the hot path; on
209
+ # failure the sink retains the row internally for retry.
210
+ for row in rows:
211
+ self._emit(row)
212
+
213
+ if effective == "redact":
214
+ returned_text = self._redact_text(text, matches)
215
+ elif effective == "warn":
216
+ returned_text = text
217
+ else:
218
+ # `block`. Audit rows are emitted BEFORE raising so the
219
+ # operator sees the detection event even when the agent
220
+ # never observes the redacted output.
221
+ assert effective == "block"
222
+ raise CredentialLeakBlocked(
223
+ f"credential leak detected in {source} (tool={tool_name}): "
224
+ f"{len(matches)} match(es); see audit log for details"
225
+ )
226
+ return returned_text, rows
227
+
228
+ # ------------------------------------------------------------------
229
+ # Internal helpers.
230
+ # ------------------------------------------------------------------
231
+
232
+ def _resolve_effective_action(self) -> tuple[Action, bool]:
233
+ """Apply the `CONTROLZERO_CREDLEAK_OFF=1` operator override.
234
+
235
+ Returns the effective action plus a `downgraded` flag that
236
+ feeds into the audit row so the dashboard can highlight rows
237
+ whose intended posture was bypassed.
238
+ """
239
+ if os.environ.get(_OFF_ENV_VAR, "") == "1":
240
+ if self._configured_action != "warn":
241
+ return "warn", True
242
+ return self._configured_action, False
243
+
244
+ def _redact_text(self, text: str, matches: list[_Match]) -> str:
245
+ """Replace each match with `cz:credleak:<sha256_hex>`.
246
+
247
+ Matches are processed right-to-left so a redaction never
248
+ invalidates the byte offsets of earlier (lower-index)
249
+ matches. Two pieces of context held intentionally:
250
+
251
+ * `cz:credleak:` is a fixed prefix the downstream agent log
252
+ consumer can grep for; supports a "show me everywhere this
253
+ secret was redacted" rotation workflow.
254
+ * The hex digest is SHA-256 of the plaintext credential, not
255
+ the HMAC-keyed hash. The redaction lives inside the agent's
256
+ local output; the HMAC hash lives in the audit row that
257
+ leaves the host. Keeping them distinct means a leak of the
258
+ local log file does not let an attacker correlate a
259
+ previous local redaction with a cross-org audit row.
260
+ """
261
+ ordered = sorted(matches, key=lambda m: m.start, reverse=True)
262
+ out = text
263
+ for m in ordered:
264
+ plaintext = text[m.start : m.end]
265
+ digest = hashlib.sha256(plaintext.encode("utf-8", errors="replace")).hexdigest()
266
+ replacement = f"cz:credleak:{digest}"
267
+ out = out[: m.start] + replacement + out[m.end :]
268
+ return out
269
+
270
+ def _build_audit_row(
271
+ self,
272
+ *,
273
+ m: _Match,
274
+ text: str,
275
+ source: Source,
276
+ tool_name: str,
277
+ tool_call_id: str,
278
+ agent_name: str,
279
+ effective_action: Action,
280
+ enforcement_downgraded: bool,
281
+ ) -> dict[str, Any]:
282
+ """Construct the wire-shape dict the audit sink already
283
+ accepts. The sink folds additional keys onto the existing
284
+ batch payload (`/api/audit/batch` accepts unknown extra
285
+ fields per the additive-schema contract); backend storage
286
+ lands in PR-5.
287
+ """
288
+ plaintext = text[m.start : m.end]
289
+ value_hash = _hmac_value_hash(
290
+ self._hmac_key, plaintext.encode("utf-8", errors="replace")
291
+ )
292
+ return {
293
+ # Mark the row as a credential leak so the backend ingest
294
+ # can route it to the rotation tracker view in PR-5.
295
+ "event_kind": "credential_leak_detected",
296
+ "pattern_id": m.pattern_id,
297
+ "severity": m.severity,
298
+ "value_hash": value_hash,
299
+ "context_window": _build_context_window(text, m.start, m.end),
300
+ "source": source,
301
+ "tool_name": tool_name,
302
+ "tool_call_id": tool_call_id,
303
+ "agent_name": agent_name,
304
+ "project_id": self._project_id,
305
+ "enforcement_action": effective_action,
306
+ "enforcement_downgraded": enforcement_downgraded,
307
+ }
308
+
309
+ def _emit(self, row: dict[str, Any]) -> None:
310
+ """Push one audit row through the client's existing sink.
311
+
312
+ The handler does not own its own batch buffer; it piggy-backs
313
+ on whichever sink the client has wired up (RemoteAuditSink,
314
+ BearerAuditSink, or a test double). Best-effort: if the
315
+ client is missing an audit sink the row is dropped silently
316
+ so an SDK in local-only mode keeps functioning without an
317
+ audit destination.
318
+ """
319
+ sink = getattr(self._client, "audit_sink", None)
320
+ if sink is None:
321
+ return
322
+ log_fn = getattr(sink, "log", None)
323
+ if log_fn is None:
324
+ return
325
+ try:
326
+ log_fn(row)
327
+ except Exception: # noqa: BLE001
328
+ # The audit pipeline is best-effort by design (matches
329
+ # the existing audit_remote.py contract). The hook must
330
+ # never crash a user's tool call because an audit
331
+ # delivery failed.
332
+ pass
333
+
334
+
335
+ __all__ = [
336
+ "Action",
337
+ "Source",
338
+ "CredentialLeakHandler",
339
+ ]