controlzero 1.12.0__tar.gz → 1.13.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 (289) hide show
  1. {controlzero-1.12.0 → controlzero-1.13.0}/CHANGELOG.md +36 -0
  2. {controlzero-1.12.0 → controlzero-1.13.0}/PKG-INFO +1 -1
  3. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/__init__.py +1 -1
  4. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/_internal/bundle.py +17 -0
  5. controlzero-1.13.0/controlzero/_internal/condition_ops.py +226 -0
  6. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/_internal/enforcer.py +127 -3
  7. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/_internal/io_capture_producer.py +16 -9
  8. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/policy_loader.py +53 -1
  9. {controlzero-1.12.0 → controlzero-1.13.0}/pyproject.toml +1 -1
  10. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_coding_agent_hooks.py +11 -8
  11. controlzero-1.13.0/tests/test_condition_operators_1495.py +192 -0
  12. controlzero-1.13.0/tests/test_deny_rule_reconciliation_1011.py +247 -0
  13. {controlzero-1.12.0 → controlzero-1.13.0}/.gitignore +0 -0
  14. {controlzero-1.12.0 → controlzero-1.13.0}/Dockerfile.test +0 -0
  15. {controlzero-1.12.0 → controlzero-1.13.0}/LICENSE +0 -0
  16. {controlzero-1.12.0 → controlzero-1.13.0}/README.md +0 -0
  17. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/__main__.py +0 -0
  18. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/_internal/__init__.py +0 -0
  19. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/_internal/_messages.py +0 -0
  20. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/_internal/action_aliases.py +0 -0
  21. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/_internal/action_validator.py +0 -0
  22. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/_internal/credential_hook.py +0 -0
  23. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/_internal/credential_scanner.py +0 -0
  24. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/_internal/credentials_data/__init__.py +0 -0
  25. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/_internal/credentials_data/built_in.yaml +0 -0
  26. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/_internal/dlp_scanner.py +0 -0
  27. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/_internal/hook_extractors.py +0 -0
  28. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/_internal/source_labels.py +0 -0
  29. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/_internal/tool_extractors.json +0 -0
  30. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/_internal/types.py +0 -0
  31. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/audit_local.py +0 -0
  32. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/audit_remote.py +0 -0
  33. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/canonical.py +0 -0
  34. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/__init__.py +0 -0
  35. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/__main__.py +0 -0
  36. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/_secrets.py +0 -0
  37. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/agent_config.py +0 -0
  38. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/console.py +0 -0
  39. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/debug_bundle.py +0 -0
  40. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/doctor.py +0 -0
  41. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/exec_cmd.py +0 -0
  42. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/hook_entry.py +0 -0
  43. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/hosts/__init__.py +0 -0
  44. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/hosts/antigravity.py +0 -0
  45. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/hosts/base.py +0 -0
  46. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/hosts/claude_code.py +0 -0
  47. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/hosts/codex_cli.py +0 -0
  48. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/hosts/cursor.py +0 -0
  49. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/hosts/cursor_cli.py +0 -0
  50. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/hosts/gemini_cli.py +0 -0
  51. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/hosts/kiro.py +0 -0
  52. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/hosts/unknown.py +0 -0
  53. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/kiro_adapter.py +0 -0
  54. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/main.py +0 -0
  55. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/migrate.py +0 -0
  56. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/output.py +0 -0
  57. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/spool_cmd.py +0 -0
  58. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/telemetry_consent.py +0 -0
  59. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/templates/antigravity/hooks.json +0 -0
  60. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/templates/antigravity.yaml +0 -0
  61. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/templates/autogen.yaml +0 -0
  62. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/templates/claude-code.yaml +0 -0
  63. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/templates/codex-cli.yaml +0 -0
  64. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/templates/cost-cap.yaml +0 -0
  65. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/templates/crewai.yaml +0 -0
  66. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/templates/cursor.yaml +0 -0
  67. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/templates/gemini-cli.yaml +0 -0
  68. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/templates/generic.yaml +0 -0
  69. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/templates/kiro/ide-file-save.kiro.hook +0 -0
  70. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/templates/kiro/ide-pre-tool-use.kiro.hook +0 -0
  71. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/templates/kiro/ide-prompt-submit.kiro.hook +0 -0
  72. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/templates/kiro/kiro.yaml +0 -0
  73. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/templates/langchain.yaml +0 -0
  74. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/templates/mcp.yaml +0 -0
  75. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/templates/rag.yaml +0 -0
  76. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/update_cmd.py +0 -0
  77. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/client.py +0 -0
  78. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/device.py +0 -0
  79. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/enrollment.py +0 -0
  80. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/error_codes.py +0 -0
  81. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/error_codes.yaml +0 -0
  82. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/errors.py +0 -0
  83. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/hitl/__init__.py +0 -0
  84. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/hitl/grant_protocol.py +0 -0
  85. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/hitl/mock.py +0 -0
  86. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/hitl/pending_approval.py +0 -0
  87. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/hitl/secret_leak_guard.py +0 -0
  88. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/hitl/status.py +0 -0
  89. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/hook_integrity.py +0 -0
  90. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/hooks/__init__.py +0 -0
  91. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/hooks/tool_output_handler.py +0 -0
  92. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/hosted_policy.py +0 -0
  93. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/__init__.py +0 -0
  94. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/anthropic.py +0 -0
  95. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/autogen.py +0 -0
  96. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/braintrust.py +0 -0
  97. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/crewai/__init__.py +0 -0
  98. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/crewai/agent.py +0 -0
  99. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/crewai/crew.py +0 -0
  100. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/crewai/task.py +0 -0
  101. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/crewai/tool.py +0 -0
  102. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/google.py +0 -0
  103. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/google_adk/__init__.py +0 -0
  104. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/google_adk/agent.py +0 -0
  105. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/google_adk/tool.py +0 -0
  106. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/langchain/__init__.py +0 -0
  107. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/langchain/agent.py +0 -0
  108. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/langchain/callbacks.py +0 -0
  109. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/langchain/chain.py +0 -0
  110. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/langchain/graph.py +0 -0
  111. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/langchain/modern.py +0 -0
  112. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/langchain/tool.py +0 -0
  113. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/langfuse.py +0 -0
  114. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/litellm.py +0 -0
  115. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/openai.py +0 -0
  116. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/pydantic_ai.py +0 -0
  117. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/vercel_ai.py +0 -0
  118. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/layout_migration.py +0 -0
  119. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/secrets/__init__.py +0 -0
  120. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/secrets/reference.py +0 -0
  121. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/secrets/resolver.py +0 -0
  122. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/secrets/secretstr.py +0 -0
  123. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/spool/__init__.py +0 -0
  124. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/spool/_compress.py +0 -0
  125. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/spool/_constants.py +0 -0
  126. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/spool/_crc32c.py +0 -0
  127. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/spool/_crypto.py +0 -0
  128. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/spool/_frame.py +0 -0
  129. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/spool/_keyring.py +0 -0
  130. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/spool/_metrics.py +0 -0
  131. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/spool/_spool.py +0 -0
  132. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/spool/_state.py +0 -0
  133. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/spool/_uploader.py +0 -0
  134. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/spool/cz-audit-v1.dict +0 -0
  135. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/tamper.py +0 -0
  136. {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/tracecontext.py +0 -0
  137. {controlzero-1.12.0 → controlzero-1.13.0}/examples/hello_world.py +0 -0
  138. {controlzero-1.12.0 → controlzero-1.13.0}/tests/_fixtures/antigravity-envelope-expected.json +0 -0
  139. {controlzero-1.12.0 → controlzero-1.13.0}/tests/_fixtures/jcs_args_hash_vectors.json +0 -0
  140. {controlzero-1.12.0 → controlzero-1.13.0}/tests/conftest.py +0 -0
  141. {controlzero-1.12.0 → controlzero-1.13.0}/tests/integrations/__init__.py +0 -0
  142. {controlzero-1.12.0 → controlzero-1.13.0}/tests/integrations/test_google.py +0 -0
  143. {controlzero-1.12.0 → controlzero-1.13.0}/tests/parity/action_aliases.json +0 -0
  144. {controlzero-1.12.0 → controlzero-1.13.0}/tests/spool/__init__.py +0 -0
  145. {controlzero-1.12.0 → controlzero-1.13.0}/tests/spool/conftest.py +0 -0
  146. {controlzero-1.12.0 → controlzero-1.13.0}/tests/spool/test_spool_cli.py +0 -0
  147. {controlzero-1.12.0 → controlzero-1.13.0}/tests/spool/test_spool_concurrency.py +0 -0
  148. {controlzero-1.12.0 → controlzero-1.13.0}/tests/spool/test_spool_conformance.py +0 -0
  149. {controlzero-1.12.0 → controlzero-1.13.0}/tests/spool/test_spool_core.py +0 -0
  150. {controlzero-1.12.0 → controlzero-1.13.0}/tests/spool/test_spool_crash.py +0 -0
  151. {controlzero-1.12.0 → controlzero-1.13.0}/tests/spool/test_spool_diskfull.py +0 -0
  152. {controlzero-1.12.0 → controlzero-1.13.0}/tests/spool/test_spool_durable_default_tamper.py +0 -0
  153. {controlzero-1.12.0 → controlzero-1.13.0}/tests/spool/test_spool_keychain_dek.py +0 -0
  154. {controlzero-1.12.0 → controlzero-1.13.0}/tests/spool/test_spool_sink_wiring.py +0 -0
  155. {controlzero-1.12.0 → controlzero-1.13.0}/tests/spool/test_spool_transcript_localack.py +0 -0
  156. {controlzero-1.12.0 → controlzero-1.13.0}/tests/spool/test_spool_uploader.py +0 -0
  157. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_action_aliases.py +0 -0
  158. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_action_canonicalization.py +0 -0
  159. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_action_validator_t86.py +0 -0
  160. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_adapter_interface.py +0 -0
  161. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_agent_name_env.py +0 -0
  162. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_agy_docs_no_exit2_1373.py +0 -0
  163. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_antigravity_adapter.py +0 -0
  164. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_antigravity_envelope_parity.py +0 -0
  165. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_antigravity_ga_blockers_1248.py +0 -0
  166. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_antigravity_hook_check.py +0 -0
  167. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_antigravity_install.py +0 -0
  168. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_antigravity_posttooluse_observe_58.py +0 -0
  169. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_antigravity_tool_vocab_1303.py +0 -0
  170. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_api_key_mask.py +0 -0
  171. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_audit_project_id_attribution_pr2.py +0 -0
  172. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_audit_remote.py +0 -0
  173. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_audit_remote_io_capture.py +0 -0
  174. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_audit_remote_sdk_version.py +0 -0
  175. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_audit_sink_isolation.py +0 -0
  176. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_bundle_cache_freshness_1303.py +0 -0
  177. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_bundle_parser.py +0 -0
  178. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_bundle_translate.py +0 -0
  179. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_canonical_phase1a.py +0 -0
  180. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_capability_matrix_doc.py +0 -0
  181. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_cli_carve_out.py +0 -0
  182. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_cli_debug_bundle.py +0 -0
  183. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_cli_extractor_integration.py +0 -0
  184. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_cli_hook.py +0 -0
  185. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_cli_hook_entry.py +0 -0
  186. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_cli_hosted_refresh.py +0 -0
  187. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_cli_init.py +0 -0
  188. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_cli_init_templates.py +0 -0
  189. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_cli_tail.py +0 -0
  190. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_cli_test.py +0 -0
  191. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_cli_validate.py +0 -0
  192. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_conditions.py +0 -0
  193. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_config_format_parity_1303.py +0 -0
  194. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_conformance.py +0 -0
  195. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_console.py +0 -0
  196. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_credential_hook.py +0 -0
  197. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_cursor_adapter.py +0 -0
  198. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_default_action.py +0 -0
  199. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_device.py +0 -0
  200. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_dlp_mask.py +0 -0
  201. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_dlp_scanner.py +0 -0
  202. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_doctor.py +0 -0
  203. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_engine_version_consistency.py +0 -0
  204. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_enrollment.py +0 -0
  205. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_env_dump_438.py +0 -0
  206. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_epic_1247_bryan_acceptance.py +0 -0
  207. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_error_codes.py +0 -0
  208. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_errors_e_codes.py +0 -0
  209. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_fail_closed_eval.py +0 -0
  210. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_failopen_1303.py +0 -0
  211. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_glob_matching.py +0 -0
  212. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hitl_5d_email_install.py +0 -0
  213. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hitl_6a_cli_flag.py +0 -0
  214. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hitl_6a_exceptions.py +0 -0
  215. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hitl_6a_get_secret_hitl.py +0 -0
  216. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hitl_6a_mock_backend.py +0 -0
  217. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hitl_6a_pending_approval.py +0 -0
  218. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hitl_6a_request_approval.py +0 -0
  219. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hitl_6a_secret_leak_guard.py +0 -0
  220. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hitl_6a_wait.py +0 -0
  221. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hitl_conformance.py +0 -0
  222. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hitl_phase2b_protocol.py +0 -0
  223. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hitl_reason_codes.py +0 -0
  224. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hitl_validator_keys.py +0 -0
  225. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hook_extractors.py +0 -0
  226. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hook_integrity.py +0 -0
  227. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hook_mask_failclosed.py +0 -0
  228. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hosted_local_audit_1247.py +0 -0
  229. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hosted_local_shadow_warn_1265.py +0 -0
  230. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hosted_policy_e2e.py +0 -0
  231. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hosts_adapter.py +0 -0
  232. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hybrid_mode_strict.py +0 -0
  233. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hybrid_mode_warn.py +0 -0
  234. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_install_hook_command.py +0 -0
  235. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_install_hooks.py +0 -0
  236. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_io_capture_producer.py +0 -0
  237. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_kiro_adapter.py +0 -0
  238. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_kiro_cli_e2e.py +0 -0
  239. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_kiro_cli_hook_pin_1265.py +0 -0
  240. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_kiro_hook_templates.py +0 -0
  241. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_kiro_install.py +0 -0
  242. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_layout_migration_t101.py +0 -0
  243. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_layout_parity_t102.py +0 -0
  244. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_local_mode_dict.py +0 -0
  245. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_local_mode_file_json.py +0 -0
  246. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_local_mode_file_yaml.py +0 -0
  247. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_localized_reason.py +0 -0
  248. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_log_fallback_stderr.py +0 -0
  249. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_log_options_ignored_hosted.py +0 -0
  250. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_log_rotation.py +0 -0
  251. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_migrate.py +0 -0
  252. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_min_sdk_version_gate.py +0 -0
  253. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_multi_client_per_project_175.py +0 -0
  254. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_no_policy_no_key.py +0 -0
  255. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_observe_mode_1247.py +0 -0
  256. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_package_rename_shim.py +0 -0
  257. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_part3_active_policy_count_1303.py +0 -0
  258. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_per_client_isolation_1011.py +0 -0
  259. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_policy_engine_version_phase1b.py +0 -0
  260. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_policy_freshness.py +0 -0
  261. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_policy_load_failclosed_f3.py +0 -0
  262. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_policy_settings.py +0 -0
  263. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_policy_source_audit.py +0 -0
  264. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_pr4_install_messaging.py +0 -0
  265. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_pr4_output.py +0 -0
  266. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_pr4_status.py +0 -0
  267. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_probe_audit_isolation.py +0 -0
  268. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_quarantine.py +0 -0
  269. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_reason_code.py +0 -0
  270. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_refresh.py +0 -0
  271. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_secrets.py +0 -0
  272. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_secrets_reference_resolver.py +0 -0
  273. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_source_labels.py +0 -0
  274. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_sql_semantic_class.py +0 -0
  275. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_synthetic_policy_id_t79.py +0 -0
  276. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_t103_precedence.py +0 -0
  277. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_t104_cache_gc.py +0 -0
  278. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_t108_local_override_audit.py +0 -0
  279. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_t96_single_audit_log.py +0 -0
  280. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_t99_install_prefetch_bundle.py +0 -0
  281. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_tamper.py +0 -0
  282. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_tamper_behavior.py +0 -0
  283. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_tamper_hook.py +0 -0
  284. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_telemetry_consent.py +0 -0
  285. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_tracecontext.py +0 -0
  286. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_unsafe_int_boundary.py +0 -0
  287. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_update_cmd.py +0 -0
  288. {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_upgrade_nudge.py +0 -0
  289. {controlzero-1.12.0 → controlzero-1.13.0}/tools/cz-kiro-adapter +0 -0
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.13.0 (2026-06-26): policy-condition operator vocab
4
+
5
+ ### Added
6
+
7
+ - **Condition operators.** A rule condition value may now be a tagged envelope
8
+ `{op, value}` honoring a closed operator set: `equals`, `contains`, `glob`,
9
+ `matches` (regex, BETA), `gt`/`lt`/`gte`/`lte`, `in`/`not_in`,
10
+ `in_range`/`outside_range`, `exists`/`not_exists`. Read-only aliases
11
+ `matches_pattern`/`greater_than`/`less_than` are canonicalized on load, so
12
+ existing dashboard templates work with zero migration. A legacy scalar value
13
+ (a plain string or number) keeps its prior glob/equals behavior exactly, so
14
+ existing policies and signed bundles evaluate unchanged.
15
+ - **File-path conditions.** `file_path` / `path` / `notebook_path` now feed the
16
+ `input_text` condition value, so a condition like "input_text contains .env"
17
+ fires on Read/Write/Edit tool calls.
18
+
19
+ ### Notes
20
+
21
+ - Fail-closed: a missing field is no-match for every operator except
22
+ `exists`/`not_exists`; an unknown operator is rejected at load; a malformed
23
+ condition fails closed. `matches` is BETA and length-capped to bound regex
24
+ backtracking. Secret detection stays on the DLP scanner, not a regex condition.
25
+
26
+ ## 1.12.1 (2026-06-26): deny rules actually deny
27
+
28
+ ### Fixed
29
+
30
+ - **Deny rules now actually deny (#1011).** A policy DENY rule with a condition
31
+ (e.g. "block Bash when the command contains rm -rf") could silently fail to
32
+ match, so the action ran anyway. Reconciled the rule-authoring vocabulary with
33
+ the engine: tool names match case-insensitively (a rule for `bash:*` catches
34
+ the canonical `Bash:rm`), the command text is matched as `input_text` by
35
+ CONTAINS, and conditions are no longer dropped when a hosted policy is
36
+ translated for local enforcement. A present-but-malformed condition now fails
37
+ closed at load instead of silently becoming an unconditional allow.
38
+
3
39
  ## 1.12.0 -- 2026-06-25 (per-agent key authority + per-agent bundle cache + whoami)
4
40
 
5
41
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: controlzero
3
- Version: 1.12.0
3
+ Version: 1.13.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
@@ -41,7 +41,7 @@ from controlzero.hitl.grant_protocol import (
41
41
  from controlzero.policy_loader import load_policy
42
42
  from controlzero.secrets import SecretRef, SecretStr, secret_ref
43
43
 
44
- __version__ = "1.12.0"
44
+ __version__ = "1.13.0"
45
45
 
46
46
  __all__ = [
47
47
  "Client",
@@ -958,4 +958,21 @@ def _translate_rule(rule: dict, policy_id: str) -> Optional[dict]:
958
958
  if isinstance(k, str) and isinstance(v, str)
959
959
  }
960
960
 
961
+ # #1011 follow-up: the SAME allowlist-strip trap. A hosted rule's
962
+ # `conditions` (e.g. {input_text: "rm -rf"}) was being dropped here, so a
963
+ # condition authored in the dashboard NEVER reached the local enforcer, so
964
+ # the rule silently matched on actions only (or, with a tool-case mismatch,
965
+ # not at all). Forward BOTH alias keys verbatim whenever present (incl. a
966
+ # malformed/falsy shape: [] / "" / 0 / False), and let the loader apply ONE
967
+ # consistent precedence + shape validation (truthy-first; reject any
968
+ # non-dict; a genuine empty dict {} is "no conditions"). Collapsing to a
969
+ # single key HERE re-introduces a divergent precedence where a present `{}`
970
+ # could shadow a real alias (codex re-review), so the bundle stays dumb and
971
+ # the loader is the single source of truth. (The backend wire struct carries
972
+ # Conditions; see bundle_handler.go PolicyRule.)
973
+ if "conditions" in rule:
974
+ translated["conditions"] = rule["conditions"]
975
+ if "when" in rule:
976
+ translated["when"] = rule["when"]
977
+
961
978
  return translated
@@ -0,0 +1,226 @@
1
+ """Policy-condition operator table (single source of truth) for #1011 / #1495.
2
+
3
+ A rule condition value is either a LEGACY SCALAR (string -> glob, number/bool ->
4
+ equals; evaluated in enforcer._conditions_match, byte-identical to pre-#1495) or
5
+ a TAGGED ENVELOPE ``{"op": <name>, "value": <scalar|array>, "case_insensitive"?:
6
+ bool}``. This module evaluates the envelope operators ONLY; the scalar path stays
7
+ in the enforcer so existing policies and signed bundles evaluate unchanged.
8
+
9
+ This is the Python reference; the Node twin (conditionOps.ts) MUST agree
10
+ byte-for-byte (cross-SDK parity is proven by tests/parity/decisions.json). Go is
11
+ paused and not part of this contract; the Go BACKEND engine evaluates only the
12
+ reserved server-side keys (time_range/ip_whitelist/tags) and defers tool-level
13
+ conditions to the SDK (it has no tool args).
14
+
15
+ FAIL-CLOSED LAW: a MISSING field (value is None) is NO-MATCH for EVERY operator
16
+ except ``exists``/``not_exists``. A regex that will not compile, or a numeric op
17
+ on a non-numeric field, is NO-MATCH (never an error that fails open).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import fnmatch
23
+ import re
24
+ from typing import Any
25
+
26
+ # Read-only aliases canonicalised on evaluation so the shipped templates
27
+ # (matches_pattern / greater_than / less_than) load and evaluate with ZERO data
28
+ # migration. The dashboard going forward emits the canonical names.
29
+ _OP_ALIASES = {
30
+ "matches_pattern": "matches",
31
+ "greater_than": "gt",
32
+ "less_than": "lt",
33
+ "greater_than_or_equal": "gte",
34
+ "less_than_or_equal": "lte",
35
+ }
36
+
37
+ # The closed operator set the engine honors. The Go publish-time validator (and
38
+ # the frontend) reject any op not in here, so an inert/typo'd operator can never
39
+ # ship.
40
+ KNOWN_OPS = frozenset(
41
+ {
42
+ "glob",
43
+ "equals",
44
+ "contains",
45
+ "matches",
46
+ "gt",
47
+ "lt",
48
+ "gte",
49
+ "lte",
50
+ "in",
51
+ "not_in",
52
+ "outside_range",
53
+ "in_range",
54
+ "exists",
55
+ "not_exists",
56
+ }
57
+ )
58
+
59
+
60
+ # Reserved structured condition keys are matched by KEY NAME (the Go backend's
61
+ # server-side matchers) and are NEVER interpreted as a {op,value} envelope, even
62
+ # when their value nests a sub-key literally named `op` (e.g. a tag named "op").
63
+ # This closes the tags:{op:...} collision hole. The caller checks the KEY against
64
+ # this set before treating a value as an envelope.
65
+ RESERVED_STRUCTURED_KEYS = frozenset({"time_range", "ip_whitelist", "tags"})
66
+
67
+
68
+ def canonicalize_op(op: str) -> str:
69
+ """Map a read alias to its canonical operator name; pass through otherwise."""
70
+ return _OP_ALIASES.get(op, op)
71
+
72
+
73
+ def is_envelope(value: Any) -> bool:
74
+ """True iff ``value`` is a tagged operator envelope: a dict with a string
75
+ ``op`` key. Reserved structured keys are disambiguated by KEY NAME upstream,
76
+ never here, and a runtime args/context value is never treated as an envelope.
77
+ """
78
+ return isinstance(value, dict) and isinstance(value.get("op"), str)
79
+
80
+
81
+ def _as_float(x: Any):
82
+ """Coerce to float for numeric ops; return None if not numeric (-> NO-MATCH)."""
83
+ if isinstance(x, bool): # bool is an int subclass; never treat as a number here
84
+ return None
85
+ if isinstance(x, (int, float)):
86
+ return float(x)
87
+ if isinstance(x, str):
88
+ try:
89
+ return float(x.strip())
90
+ except ValueError:
91
+ return None
92
+ return None
93
+
94
+
95
+ def _as_list(operand: Any) -> list:
96
+ """Operand for in/not_in: a JSON array, or a legacy comma-separated string
97
+ ('Saturday,Sunday') split by this compat shim so the shipped templates work
98
+ unmodified. A None / empty operand yields an EMPTY list (the caller treats an
99
+ empty operand as NO-MATCH, fail-closed (codex P2: a missing `value` must not
100
+ turn `not_in` into match-everything))."""
101
+ if operand is None:
102
+ return []
103
+ if isinstance(operand, (list, tuple)):
104
+ return [x for x in operand if x is not None]
105
+ if isinstance(operand, str):
106
+ return [p.strip() for p in operand.split(",") if p.strip()]
107
+ return [operand]
108
+
109
+
110
+ # matches (regex) is BETA: Python `re` has no per-match timeout, so a pathological
111
+ # author-supplied pattern can backtrack catastrophically against adversarial field
112
+ # input (codex P1 ReDoS). Until a timeout / RE2 binding lands, bound BOTH the
113
+ # scanned input and the pattern length so practical backtracking is capped; the
114
+ # publish-time validator (#1498) also caps the pattern. A regex error stays
115
+ # fail-closed (NO-MATCH).
116
+ _MATCHES_INPUT_CAP = 8192
117
+ _MATCHES_PATTERN_CAP = 1024
118
+
119
+
120
+ def _range_bounds(operand: Any):
121
+ """[lo, hi] for in_range/outside_range: a 2-element array, or a legacy
122
+ 'lo-hi' / 'HH:MM-HH:MM' string. Returns (lo_str, hi_str) or None if malformed.
123
+ Comparison is lexical for HH:MM (24h zero-padded) and numeric otherwise; both
124
+ sides are compared as strings of the same kind, matching the Go matchTimeRange
125
+ lexical compare for time."""
126
+ if isinstance(operand, (list, tuple)) and len(operand) == 2:
127
+ return str(operand[0]), str(operand[1])
128
+ if isinstance(operand, str) and "-" in operand:
129
+ lo, _, hi = operand.partition("-")
130
+ lo, hi = lo.strip(), hi.strip()
131
+ if lo and hi:
132
+ return lo, hi
133
+ return None
134
+
135
+
136
+ def _in_range(value: Any, lo: str, hi: str) -> bool:
137
+ """Inclusive lo<=value<=hi. Numeric when all three parse as numbers, else a
138
+ lexical string compare (so 'HH:MM' 24h times order correctly)."""
139
+ fv, flo, fhi = _as_float(value), _as_float(lo), _as_float(hi)
140
+ if fv is not None and flo is not None and fhi is not None:
141
+ return flo <= fv <= fhi
142
+ sv = str(value)
143
+ return lo <= sv <= hi
144
+
145
+
146
+ def match_operator(
147
+ op: str, value: Any, operand: Any, case_insensitive: bool = False
148
+ ) -> bool:
149
+ """Evaluate one field-level envelope operator. ``value`` is the field value
150
+ from the merged context+args dict (None if the field is absent); ``operand``
151
+ is the envelope's ``value``. Returns True iff the condition is satisfied.
152
+ Unknown op -> False (fail-closed; the validator rejects it at publish too)."""
153
+ op = canonicalize_op(op)
154
+
155
+ # Presence ops are the ONLY ones where a missing field can be a match.
156
+ if op == "exists":
157
+ return value is not None
158
+ if op == "not_exists":
159
+ return value is None
160
+
161
+ # Every other op: a missing field is NO-MATCH (uniform, fail-closed).
162
+ if value is None:
163
+ return False
164
+
165
+ if op in ("gt", "lt", "gte", "lte"):
166
+ fv, fo = _as_float(value), _as_float(operand)
167
+ if fv is None or fo is None:
168
+ return False # non-numeric operand/field -> NO-MATCH
169
+ if op == "gt":
170
+ return fv > fo
171
+ if op == "lt":
172
+ return fv < fo
173
+ if op == "gte":
174
+ return fv >= fo
175
+ return fv <= fo
176
+
177
+ if op in ("in", "not_in"):
178
+ items = [str(x) for x in _as_list(operand)]
179
+ if not items:
180
+ # Malformed / empty operand -> NO-MATCH for BOTH in and not_in
181
+ # (fail-closed; a deny does not over-fire, an allow does not
182
+ # over-allow). The validator rejects an empty operand at publish.
183
+ return False
184
+ if case_insensitive:
185
+ present = str(value).casefold() in {i.casefold() for i in items}
186
+ else:
187
+ present = str(value) in items
188
+ return present if op == "in" else not present
189
+
190
+ if op in ("in_range", "outside_range"):
191
+ bounds = _range_bounds(operand)
192
+ if bounds is None:
193
+ return False
194
+ inside = _in_range(value, bounds[0], bounds[1])
195
+ return inside if op == "in_range" else not inside
196
+
197
+ # String ops below.
198
+ sval, soperand = str(value), str(operand)
199
+ if case_insensitive:
200
+ sval, soperand = sval.casefold(), soperand.casefold()
201
+
202
+ if op == "equals":
203
+ # Exact equality; wildcards are LITERAL (unlike glob).
204
+ return sval == soperand
205
+
206
+ if op == "contains":
207
+ # Substring; the operand's own glob metachars are literal (not wildcards).
208
+ return soperand in sval
209
+
210
+ if op == "glob":
211
+ return fnmatch.fnmatchcase(sval, soperand)
212
+
213
+ if op == "matches":
214
+ # Regex (re.search). Author owns anchoring. BETA: bound input + pattern
215
+ # length to cap ReDoS backtracking (codex P1); uncompilable / runtime
216
+ # error -> NO-MATCH (fail-closed), never an exception that fails open.
217
+ if len(soperand) > _MATCHES_PATTERN_CAP:
218
+ return False
219
+ flags = re.IGNORECASE if case_insensitive else 0
220
+ try:
221
+ return re.search(soperand, sval[:_MATCHES_INPUT_CAP], flags) is not None
222
+ except re.error:
223
+ return False
224
+
225
+ # Unknown operator: fail-closed (the publish-time validator also rejects it).
226
+ return False
@@ -516,7 +516,9 @@ class PolicyEvaluator:
516
516
 
517
517
  for rule in self._rules:
518
518
  evaluated += 1
519
- if not any(_glob_any(rule.actions, a) for a in candidate_actions):
519
+ # Action gate: tool-case-insensitive so `bash:*` matches `Bash:rm`
520
+ # (#1011). Resource/client/project gates below stay case-sensitive.
521
+ if not any(_action_glob_any(rule.actions, a) for a in candidate_actions):
520
522
  continue
521
523
  # Client + project gates (gh#175). A rule whose selector
522
524
  # excludes this request does NOT apply to this surface, so
@@ -676,10 +678,22 @@ class PolicyEvaluator:
676
678
  All condition keys must match. Missing key = no match. Context keys
677
679
  override args on collision. Nested ``context['tags']`` dict is flattened.
678
680
  """
679
- if not rule_conditions:
681
+ # #1011 follow-up (codex P1): the non-dict guard MUST run before the
682
+ # falsy short-circuit, otherwise a falsy-but-malformed conditions ([],
683
+ # "", 0, False) would be read as "no conditions" and the rule would match
684
+ # unconditionally (an allow rule -> unconditional allow, fail-open). A
685
+ # corrupt/tampered or future-shaped signed rule is unverifiable, so the
686
+ # rule does NOT match: an allow rule then does not fire and the default
687
+ # posture governs (fail-closed); a deny rule simply does not contribute.
688
+ # `None` is the genuine "no conditions" case and matches.
689
+ if not isinstance(rule_conditions, dict):
690
+ return rule_conditions is None
691
+ if not rule_conditions: # empty dict -> no conditions to satisfy
680
692
  return True
681
693
  import fnmatch as _fn
682
694
 
695
+ from .io_capture_producer import _INPUT_TEXT_KEYS
696
+
683
697
  merged: dict = {}
684
698
  if args:
685
699
  merged.update(args)
@@ -688,11 +702,71 @@ class PolicyEvaluator:
688
702
  tags = context.get("tags") or {}
689
703
  if isinstance(tags, dict):
690
704
  merged.update(tags)
705
+ # #1011 follow-up: derive `input_text` (the key the dashboard/templates
706
+ # author, "command/input contains X") DETERMINISTICALLY from the payload
707
+ # rather than trusting a caller-supplied label. The raw command/content
708
+ # is the GROUND TRUTH of what runs, so it wins first: neither a benign
709
+ # `input_text` nor a benign pre-extracted `input_payload` can shadow a
710
+ # dangerous `command` (codex P1: a payload setting input_text="ls" or
711
+ # input_payload="ls" while command="rm -rf /" would otherwise bypass the
712
+ # deny under default allow). Precedence: the canonical _INPUT_TEXT_KEYS
713
+ # scan (the SAME order audit extraction uses, so enforcement and audit
714
+ # never drift), then the host's pre-extracted input_payload, then any
715
+ # existing input_text, each only as a fallback when nothing richer is
716
+ # present.
717
+ _derived = None
718
+ for _k in _INPUT_TEXT_KEYS:
719
+ _v = merged.get(_k)
720
+ if _v:
721
+ _derived = _v
722
+ break
723
+ if not _derived:
724
+ _derived = merged.get("input_payload")
725
+ if not _derived:
726
+ _derived = merged.get("input_text")
727
+ if _derived:
728
+ merged["input_text"] = _derived
729
+ from .condition_ops import (
730
+ RESERVED_STRUCTURED_KEYS,
731
+ is_envelope,
732
+ match_operator,
733
+ )
734
+
691
735
  for key, pattern in rule_conditions.items():
736
+ # #1495: a TAGGED ENVELOPE {op, value} dispatches to the shared
737
+ # operator table (equals/contains/matches/gt/lt/in/not_in/range/
738
+ # exists/...). Everything else is a LEGACY SCALAR and keeps the exact
739
+ # pre-#1495 glob/CONTAINS path below, so existing policies and signed
740
+ # bundles evaluate byte-identically. exists/not_exists are the only
741
+ # ops where a missing field can match, so the envelope branch runs
742
+ # BEFORE the missing-key short-circuit. A RESERVED structured key is
743
+ # matched by key name, never as an envelope (the tags:{op:...} hole).
744
+ if key not in RESERVED_STRUCTURED_KEYS and is_envelope(pattern):
745
+ if not match_operator(
746
+ pattern["op"],
747
+ merged.get(key),
748
+ pattern.get("value"),
749
+ bool(pattern.get("case_insensitive")),
750
+ ):
751
+ return False
752
+ continue
692
753
  value = merged.get(key)
693
754
  if value is None:
694
755
  return False
695
- if not _fn.fnmatchcase(str(value), str(pattern)):
756
+ sval, spat = str(value), str(pattern)
757
+ # Only `input_text` (the dashboard's authored text-content key) uses
758
+ # CONTAINS for a plain pattern, so `input_text: "rm -rf"` catches
759
+ # `rm -rf /tmp/foo`. A pattern with glob metacharacters still uses
760
+ # fnmatch; EVERY other key (including the raw `command`/`prompt`
761
+ # sources) keeps exact-glob semantics, so an existing exact-match
762
+ # allowlist is never silently broadened into a substring match
763
+ # (codex P1 fail-open regression).
764
+ if key.casefold() in _TEXT_CONTENT_CONDITION_KEYS and not any(
765
+ c in spat for c in "*?["
766
+ ):
767
+ if spat not in sval:
768
+ return False
769
+ elif not _fn.fnmatchcase(sval, spat):
696
770
  return False
697
771
  return True
698
772
 
@@ -836,8 +910,58 @@ def _glob_any(patterns: list[str], value: str) -> bool:
836
910
 
837
911
  fnmatchcase is case-sensitive (matches policy semantics) and supports the
838
912
  same syntax users learn from .gitignore: *, ?, [seq], [!seq].
913
+
914
+ NOTE: this stays case-sensitive and is used for the resource / client /
915
+ project selector gates. Action matching uses :func:`_action_glob_any`,
916
+ which case-folds the tool segment (see below).
839
917
  """
840
918
  for p in patterns:
841
919
  if fnmatch.fnmatchcase(value, p):
842
920
  return True
843
921
  return False
922
+
923
+
924
+ def _norm_action_tool(s: str) -> str:
925
+ """Case-fold ONLY the tool segment of an action token (the part before the
926
+ first ``:``); leave the method/arg glob untouched.
927
+
928
+ The canonical action the SDK emits is capital-tool (e.g. ``Bash:rm``, the
929
+ canonical TOOL NAME from the extractor spec), but a user or a policy
930
+ template may author the tool in any case (``bash:*``). The tool name is a
931
+ fixed, known vocabulary, so case is never meaningful for it; folding it lets
932
+ a user-authored rule reconcile with the call the agent actually makes. The
933
+ METHOD stays case-sensitive so method-cased semantics (e.g. ``database:DROP``
934
+ vs ``database:drop``) are preserved. A token with no ``:`` (e.g. the
935
+ universal ``*``) is folded whole, a no-op for glob metacharacters.
936
+ """
937
+ tool, sep, method = s.partition(":")
938
+ return (tool.casefold() + sep + method) if sep else s.casefold()
939
+
940
+
941
+ def _action_glob_any(patterns: list[str], action: str) -> bool:
942
+ """Action-gate matcher: like :func:`_glob_any` but case-INSENSITIVE on the
943
+ tool segment of both the rule pattern and the action (see
944
+ :func:`_norm_action_tool`).
945
+
946
+ This is what makes a deny rule authored as ``bash:*`` actually match the
947
+ canonical ``Bash:rm`` (#1011 follow-up: a customer's deny rule silently
948
+ never fired because the matcher compared the tool case-sensitively).
949
+ """
950
+ a = _norm_action_tool(action)
951
+ for p in patterns:
952
+ if fnmatch.fnmatchcase(a, _norm_action_tool(p)):
953
+ return True
954
+ return False
955
+
956
+
957
+ # The ONE condition key whose plain (metachar-free) pattern is matched by
958
+ # CONTAINS (substring): `input_text`. It is the key the dashboard/templates
959
+ # author for "the command/input contains X", and a user typing `rm -rf` means
960
+ # "contains rm -rf", not "IS exactly rm -rf". Patterns WITH glob metachars still
961
+ # use fnmatch. This set is deliberately NARROW: the raw payload keys (command,
962
+ # input_payload, prompt, ...) are seeding SOURCES for input_text, not author-
963
+ # facing condition keys. Briefly including them here broadened any existing
964
+ # exact-match allowlist keyed on `command` into a substring match, a fail-open
965
+ # regression (codex P1). Every key except `input_text` keeps exact-glob
966
+ # semantics. (#1011 follow-up.)
967
+ _TEXT_CONTENT_CONDITION_KEYS = frozenset({"input_text"})
@@ -69,15 +69,22 @@ _OUTPUT_EVENT_NAMES = frozenset(
69
69
  # in extraction priority order. The whole tool_input dict is the fallback when
70
70
  # none match (rendered as canonical JSON so the capture is still faithful).
71
71
  _INPUT_TEXT_KEYS = (
72
- "command", # Bash / run_command
73
- "CommandLine", # Antigravity run_command
74
- "sql", # database
75
- "query", # search / http
76
- "prompt", # prompt-style tools
77
- "content", # Write / file content
78
- "new_string", # Edit
79
- "text", # generic text tools
80
- "url", # http / browser
72
+ "command", # Bash / run_command
73
+ "CommandLine", # Antigravity run_command
74
+ "sql", # database
75
+ "query", # search / http
76
+ "prompt", # prompt-style tools
77
+ "content", # Write / file content
78
+ "new_string", # Edit
79
+ "text", # generic text tools
80
+ "url", # http / browser
81
+ # File-target paths (#1011): so a condition like `input_text contains .env`
82
+ # on a Read/Write/Edit fires on the PATH being touched. Lower priority than
83
+ # the command/content keys above (a tool rarely carries both; if it does the
84
+ # executed text wins). These also enrich the audit input_payload.
85
+ "file_path", # Read / Write / Edit
86
+ "path", # Glob / Grep / LS
87
+ "notebook_path", # NotebookEdit
81
88
  )
82
89
 
83
90
  # tool_response / tool_output field shapes a host may deliver on a PostToolUse
@@ -484,6 +484,58 @@ def _validate_and_translate(data: dict, source_label: str) -> ParsedPolicy:
484
484
  if bad:
485
485
  continue
486
486
 
487
+ # #1011 follow-up (codex P1): validate the conditions SHAPE explicitly.
488
+ # The old `raw.get("when") or raw.get("conditions") or {}` coerced a
489
+ # falsy-but-malformed conditions ([], "", 0, False) to {} == "no
490
+ # conditions", silently turning a conditional allow into an UNCONDITIONAL
491
+ # allow. `when` is the friendly alias for `conditions`; a rule normally
492
+ # sets only one. Validate each present value's SHAPE first (any non-dict
493
+ # is a clear load error, fail-closed), THEN pick with the historical
494
+ # truthy-first precedence (a non-empty `when` wins, else `conditions`,
495
+ # else none) so a degenerate `when: {}` does not shadow a real
496
+ # `conditions` (codex re-review regression).
497
+ _when_raw = raw.get("when")
498
+ _cond_raw = raw.get("conditions")
499
+ _cond_bad = False
500
+ for _cond_name, _cond_val in (("when", _when_raw), ("conditions", _cond_raw)):
501
+ if _cond_val is not None and not isinstance(_cond_val, dict):
502
+ errors.append(
503
+ f"rules[{i}]: '{_cond_name}' must be a mapping of "
504
+ "{field: pattern}"
505
+ )
506
+ _cond_bad = True
507
+ if _cond_bad:
508
+ continue
509
+ conditions = (_when_raw or _cond_raw) or {}
510
+
511
+ # #1495 (codex P1): a tagged-envelope condition with an UNKNOWN operator
512
+ # must be a LOAD error, not a silent runtime NO-MATCH. At runtime an
513
+ # unknown op returns no-match, which for a DENY rule under default-allow
514
+ # is a fail-OPEN (a typo'd `{op:"contaisn"}` deny silently never fires).
515
+ # Reject it here so the policy fails closed (will not load -> deny-all),
516
+ # mirroring the backend publish-time validator.
517
+ if isinstance(conditions, dict):
518
+ from controlzero._internal.condition_ops import (
519
+ KNOWN_OPS,
520
+ RESERVED_STRUCTURED_KEYS,
521
+ canonicalize_op,
522
+ is_envelope,
523
+ )
524
+
525
+ for _ck, _cv in conditions.items():
526
+ if (
527
+ _ck not in RESERVED_STRUCTURED_KEYS
528
+ and is_envelope(_cv)
529
+ and canonicalize_op(_cv["op"]) not in KNOWN_OPS
530
+ ):
531
+ errors.append(
532
+ f"rules[{i}]: condition '{_ck}' uses unknown operator "
533
+ f"'{_cv['op']}'"
534
+ )
535
+ _cond_bad = True
536
+ if _cond_bad:
537
+ continue
538
+
487
539
  out.append(
488
540
  PolicyRule(
489
541
  id=str(raw.get("id", "")),
@@ -491,7 +543,7 @@ def _validate_and_translate(data: dict, source_label: str) -> ParsedPolicy:
491
543
  effect=effect,
492
544
  actions=actions,
493
545
  resources=resources,
494
- conditions=raw.get("when") or raw.get("conditions") or {},
546
+ conditions=conditions,
495
547
  reason=str(raw.get("reason", "")),
496
548
  # Machine-readable reason code. Only synthetic rules
497
549
  # (e.g. the empty-bundle NO_ACTIVE_POLICIES deny
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "controlzero"
7
- version = "1.12.0"
7
+ version = "1.13.0"
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"}
@@ -384,9 +384,12 @@ class TestEdgeCases:
384
384
  canonical name via the shared extractor spec. ``bash`` is an
385
385
  explicit alias for ``Bash`` and so IS caught by
386
386
  ``deny Bash`` -- that is exactly the fix the spec ships.
387
- Tool names that are not aliases of ``Bash`` (e.g. ``BASH``
388
- uppercase) still pass through as-is; fnmatch itself remains
389
- case-sensitive for the post-canonicalisation name match.
387
+
388
+ As of #1011 the action gate matches the TOOL segment
389
+ case-INSENSITIVELY (canonical tool names are a fixed vocabulary
390
+ with no case-collisions, so a deny rule must catch every case
391
+ variant; otherwise a host emitting ``BASH`` would silently
392
+ bypass ``deny Bash``). The method/arg glob stays case-sensitive.
390
393
  """
391
394
  p = write_policy(tmp_path, [
392
395
  {"deny": "Bash", "reason": "Only exact Bash"},
@@ -406,13 +409,13 @@ class TestEdgeCases:
406
409
  result = runner.invoke(cli, ["hook-check", "--policy", str(p)], input=payload)
407
410
  assert result.exit_code == 2, "'bash' is a Bash alias and should match deny 'Bash'"
408
411
 
409
- # Uppercase "BASH" is NOT in the alias list. It falls through
410
- # as-is, does not canonicalise, and therefore does not match
411
- # the deny rule. This pins the contract: only spec-listed
412
- # aliases collapse to the canonical name.
412
+ # Uppercase "BASH" is a case variant of the canonical Bash tool.
413
+ # As of #1011 the action gate folds the tool-segment case, so a
414
+ # deny rule for Bash catches every case variant, closing the
415
+ # bypass where a host emitting "BASH" would slip past "deny Bash".
413
416
  payload = json.dumps({"tool_name": "BASH", "tool_input": {}})
414
417
  result = runner.invoke(cli, ["hook-check", "--policy", str(p)], input=payload)
415
- assert result.exit_code == 0, "'BASH' is not a spec alias; deny 'Bash' does NOT match it"
418
+ assert result.exit_code == 2, "'BASH' is a case variant of Bash and must be caught by deny 'Bash' (#1011)"
416
419
 
417
420
  def test_20_mcp_glob_pattern(self, tmp_path):
418
421
  """Glob: 'mcp__*__delete_*' matches 'mcp__database__delete_table'."""