controlzero 1.12.0__tar.gz → 1.13.1__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 (290) hide show
  1. {controlzero-1.12.0 → controlzero-1.13.1}/CHANGELOG.md +52 -0
  2. {controlzero-1.12.0 → controlzero-1.13.1}/PKG-INFO +1 -1
  3. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/__init__.py +1 -1
  4. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/_internal/bundle.py +17 -0
  5. controlzero-1.13.1/controlzero/_internal/condition_ops.py +226 -0
  6. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/_internal/enforcer.py +151 -3
  7. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/_internal/hook_extractors.py +455 -0
  8. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/_internal/io_capture_producer.py +16 -9
  9. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/main.py +26 -1
  10. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/policy_loader.py +53 -1
  11. {controlzero-1.12.0 → controlzero-1.13.1}/pyproject.toml +1 -1
  12. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_coding_agent_hooks.py +11 -8
  13. controlzero-1.13.1/tests/test_condition_operators_1495.py +192 -0
  14. controlzero-1.13.1/tests/test_deny_rule_reconciliation_1011.py +247 -0
  15. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_glob_matching.py +86 -0
  16. controlzero-1.13.1/tests/test_shell_semantic_class.py +244 -0
  17. {controlzero-1.12.0 → controlzero-1.13.1}/.gitignore +0 -0
  18. {controlzero-1.12.0 → controlzero-1.13.1}/Dockerfile.test +0 -0
  19. {controlzero-1.12.0 → controlzero-1.13.1}/LICENSE +0 -0
  20. {controlzero-1.12.0 → controlzero-1.13.1}/README.md +0 -0
  21. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/__main__.py +0 -0
  22. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/_internal/__init__.py +0 -0
  23. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/_internal/_messages.py +0 -0
  24. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/_internal/action_aliases.py +0 -0
  25. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/_internal/action_validator.py +0 -0
  26. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/_internal/credential_hook.py +0 -0
  27. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/_internal/credential_scanner.py +0 -0
  28. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/_internal/credentials_data/__init__.py +0 -0
  29. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/_internal/credentials_data/built_in.yaml +0 -0
  30. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/_internal/dlp_scanner.py +0 -0
  31. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/_internal/source_labels.py +0 -0
  32. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/_internal/tool_extractors.json +0 -0
  33. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/_internal/types.py +0 -0
  34. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/audit_local.py +0 -0
  35. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/audit_remote.py +0 -0
  36. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/canonical.py +0 -0
  37. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/__init__.py +0 -0
  38. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/__main__.py +0 -0
  39. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/_secrets.py +0 -0
  40. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/agent_config.py +0 -0
  41. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/console.py +0 -0
  42. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/debug_bundle.py +0 -0
  43. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/doctor.py +0 -0
  44. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/exec_cmd.py +0 -0
  45. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/hook_entry.py +0 -0
  46. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/hosts/__init__.py +0 -0
  47. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/hosts/antigravity.py +0 -0
  48. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/hosts/base.py +0 -0
  49. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/hosts/claude_code.py +0 -0
  50. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/hosts/codex_cli.py +0 -0
  51. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/hosts/cursor.py +0 -0
  52. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/hosts/cursor_cli.py +0 -0
  53. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/hosts/gemini_cli.py +0 -0
  54. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/hosts/kiro.py +0 -0
  55. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/hosts/unknown.py +0 -0
  56. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/kiro_adapter.py +0 -0
  57. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/migrate.py +0 -0
  58. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/output.py +0 -0
  59. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/spool_cmd.py +0 -0
  60. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/telemetry_consent.py +0 -0
  61. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/templates/antigravity/hooks.json +0 -0
  62. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/templates/antigravity.yaml +0 -0
  63. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/templates/autogen.yaml +0 -0
  64. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/templates/claude-code.yaml +0 -0
  65. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/templates/codex-cli.yaml +0 -0
  66. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/templates/cost-cap.yaml +0 -0
  67. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/templates/crewai.yaml +0 -0
  68. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/templates/cursor.yaml +0 -0
  69. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/templates/gemini-cli.yaml +0 -0
  70. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/templates/generic.yaml +0 -0
  71. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/templates/kiro/ide-file-save.kiro.hook +0 -0
  72. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/templates/kiro/ide-pre-tool-use.kiro.hook +0 -0
  73. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/templates/kiro/ide-prompt-submit.kiro.hook +0 -0
  74. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/templates/kiro/kiro.yaml +0 -0
  75. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/templates/langchain.yaml +0 -0
  76. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/templates/mcp.yaml +0 -0
  77. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/templates/rag.yaml +0 -0
  78. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/cli/update_cmd.py +0 -0
  79. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/client.py +0 -0
  80. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/device.py +0 -0
  81. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/enrollment.py +0 -0
  82. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/error_codes.py +0 -0
  83. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/error_codes.yaml +0 -0
  84. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/errors.py +0 -0
  85. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/hitl/__init__.py +0 -0
  86. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/hitl/grant_protocol.py +0 -0
  87. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/hitl/mock.py +0 -0
  88. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/hitl/pending_approval.py +0 -0
  89. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/hitl/secret_leak_guard.py +0 -0
  90. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/hitl/status.py +0 -0
  91. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/hook_integrity.py +0 -0
  92. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/hooks/__init__.py +0 -0
  93. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/hooks/tool_output_handler.py +0 -0
  94. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/hosted_policy.py +0 -0
  95. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/integrations/__init__.py +0 -0
  96. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/integrations/anthropic.py +0 -0
  97. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/integrations/autogen.py +0 -0
  98. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/integrations/braintrust.py +0 -0
  99. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/integrations/crewai/__init__.py +0 -0
  100. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/integrations/crewai/agent.py +0 -0
  101. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/integrations/crewai/crew.py +0 -0
  102. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/integrations/crewai/task.py +0 -0
  103. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/integrations/crewai/tool.py +0 -0
  104. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/integrations/google.py +0 -0
  105. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/integrations/google_adk/__init__.py +0 -0
  106. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/integrations/google_adk/agent.py +0 -0
  107. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/integrations/google_adk/tool.py +0 -0
  108. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/integrations/langchain/__init__.py +0 -0
  109. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/integrations/langchain/agent.py +0 -0
  110. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/integrations/langchain/callbacks.py +0 -0
  111. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/integrations/langchain/chain.py +0 -0
  112. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/integrations/langchain/graph.py +0 -0
  113. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/integrations/langchain/modern.py +0 -0
  114. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/integrations/langchain/tool.py +0 -0
  115. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/integrations/langfuse.py +0 -0
  116. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/integrations/litellm.py +0 -0
  117. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/integrations/openai.py +0 -0
  118. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/integrations/pydantic_ai.py +0 -0
  119. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/integrations/vercel_ai.py +0 -0
  120. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/layout_migration.py +0 -0
  121. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/secrets/__init__.py +0 -0
  122. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/secrets/reference.py +0 -0
  123. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/secrets/resolver.py +0 -0
  124. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/secrets/secretstr.py +0 -0
  125. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/spool/__init__.py +0 -0
  126. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/spool/_compress.py +0 -0
  127. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/spool/_constants.py +0 -0
  128. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/spool/_crc32c.py +0 -0
  129. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/spool/_crypto.py +0 -0
  130. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/spool/_frame.py +0 -0
  131. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/spool/_keyring.py +0 -0
  132. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/spool/_metrics.py +0 -0
  133. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/spool/_spool.py +0 -0
  134. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/spool/_state.py +0 -0
  135. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/spool/_uploader.py +0 -0
  136. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/spool/cz-audit-v1.dict +0 -0
  137. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/tamper.py +0 -0
  138. {controlzero-1.12.0 → controlzero-1.13.1}/controlzero/tracecontext.py +0 -0
  139. {controlzero-1.12.0 → controlzero-1.13.1}/examples/hello_world.py +0 -0
  140. {controlzero-1.12.0 → controlzero-1.13.1}/tests/_fixtures/antigravity-envelope-expected.json +0 -0
  141. {controlzero-1.12.0 → controlzero-1.13.1}/tests/_fixtures/jcs_args_hash_vectors.json +0 -0
  142. {controlzero-1.12.0 → controlzero-1.13.1}/tests/conftest.py +0 -0
  143. {controlzero-1.12.0 → controlzero-1.13.1}/tests/integrations/__init__.py +0 -0
  144. {controlzero-1.12.0 → controlzero-1.13.1}/tests/integrations/test_google.py +0 -0
  145. {controlzero-1.12.0 → controlzero-1.13.1}/tests/parity/action_aliases.json +0 -0
  146. {controlzero-1.12.0 → controlzero-1.13.1}/tests/spool/__init__.py +0 -0
  147. {controlzero-1.12.0 → controlzero-1.13.1}/tests/spool/conftest.py +0 -0
  148. {controlzero-1.12.0 → controlzero-1.13.1}/tests/spool/test_spool_cli.py +0 -0
  149. {controlzero-1.12.0 → controlzero-1.13.1}/tests/spool/test_spool_concurrency.py +0 -0
  150. {controlzero-1.12.0 → controlzero-1.13.1}/tests/spool/test_spool_conformance.py +0 -0
  151. {controlzero-1.12.0 → controlzero-1.13.1}/tests/spool/test_spool_core.py +0 -0
  152. {controlzero-1.12.0 → controlzero-1.13.1}/tests/spool/test_spool_crash.py +0 -0
  153. {controlzero-1.12.0 → controlzero-1.13.1}/tests/spool/test_spool_diskfull.py +0 -0
  154. {controlzero-1.12.0 → controlzero-1.13.1}/tests/spool/test_spool_durable_default_tamper.py +0 -0
  155. {controlzero-1.12.0 → controlzero-1.13.1}/tests/spool/test_spool_keychain_dek.py +0 -0
  156. {controlzero-1.12.0 → controlzero-1.13.1}/tests/spool/test_spool_sink_wiring.py +0 -0
  157. {controlzero-1.12.0 → controlzero-1.13.1}/tests/spool/test_spool_transcript_localack.py +0 -0
  158. {controlzero-1.12.0 → controlzero-1.13.1}/tests/spool/test_spool_uploader.py +0 -0
  159. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_action_aliases.py +0 -0
  160. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_action_canonicalization.py +0 -0
  161. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_action_validator_t86.py +0 -0
  162. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_adapter_interface.py +0 -0
  163. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_agent_name_env.py +0 -0
  164. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_agy_docs_no_exit2_1373.py +0 -0
  165. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_antigravity_adapter.py +0 -0
  166. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_antigravity_envelope_parity.py +0 -0
  167. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_antigravity_ga_blockers_1248.py +0 -0
  168. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_antigravity_hook_check.py +0 -0
  169. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_antigravity_install.py +0 -0
  170. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_antigravity_posttooluse_observe_58.py +0 -0
  171. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_antigravity_tool_vocab_1303.py +0 -0
  172. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_api_key_mask.py +0 -0
  173. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_audit_project_id_attribution_pr2.py +0 -0
  174. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_audit_remote.py +0 -0
  175. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_audit_remote_io_capture.py +0 -0
  176. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_audit_remote_sdk_version.py +0 -0
  177. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_audit_sink_isolation.py +0 -0
  178. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_bundle_cache_freshness_1303.py +0 -0
  179. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_bundle_parser.py +0 -0
  180. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_bundle_translate.py +0 -0
  181. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_canonical_phase1a.py +0 -0
  182. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_capability_matrix_doc.py +0 -0
  183. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_cli_carve_out.py +0 -0
  184. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_cli_debug_bundle.py +0 -0
  185. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_cli_extractor_integration.py +0 -0
  186. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_cli_hook.py +0 -0
  187. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_cli_hook_entry.py +0 -0
  188. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_cli_hosted_refresh.py +0 -0
  189. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_cli_init.py +0 -0
  190. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_cli_init_templates.py +0 -0
  191. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_cli_tail.py +0 -0
  192. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_cli_test.py +0 -0
  193. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_cli_validate.py +0 -0
  194. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_conditions.py +0 -0
  195. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_config_format_parity_1303.py +0 -0
  196. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_conformance.py +0 -0
  197. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_console.py +0 -0
  198. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_credential_hook.py +0 -0
  199. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_cursor_adapter.py +0 -0
  200. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_default_action.py +0 -0
  201. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_device.py +0 -0
  202. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_dlp_mask.py +0 -0
  203. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_dlp_scanner.py +0 -0
  204. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_doctor.py +0 -0
  205. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_engine_version_consistency.py +0 -0
  206. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_enrollment.py +0 -0
  207. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_env_dump_438.py +0 -0
  208. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_epic_1247_bryan_acceptance.py +0 -0
  209. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_error_codes.py +0 -0
  210. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_errors_e_codes.py +0 -0
  211. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_fail_closed_eval.py +0 -0
  212. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_failopen_1303.py +0 -0
  213. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_hitl_5d_email_install.py +0 -0
  214. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_hitl_6a_cli_flag.py +0 -0
  215. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_hitl_6a_exceptions.py +0 -0
  216. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_hitl_6a_get_secret_hitl.py +0 -0
  217. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_hitl_6a_mock_backend.py +0 -0
  218. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_hitl_6a_pending_approval.py +0 -0
  219. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_hitl_6a_request_approval.py +0 -0
  220. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_hitl_6a_secret_leak_guard.py +0 -0
  221. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_hitl_6a_wait.py +0 -0
  222. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_hitl_conformance.py +0 -0
  223. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_hitl_phase2b_protocol.py +0 -0
  224. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_hitl_reason_codes.py +0 -0
  225. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_hitl_validator_keys.py +0 -0
  226. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_hook_extractors.py +0 -0
  227. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_hook_integrity.py +0 -0
  228. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_hook_mask_failclosed.py +0 -0
  229. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_hosted_local_audit_1247.py +0 -0
  230. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_hosted_local_shadow_warn_1265.py +0 -0
  231. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_hosted_policy_e2e.py +0 -0
  232. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_hosts_adapter.py +0 -0
  233. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_hybrid_mode_strict.py +0 -0
  234. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_hybrid_mode_warn.py +0 -0
  235. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_install_hook_command.py +0 -0
  236. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_install_hooks.py +0 -0
  237. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_io_capture_producer.py +0 -0
  238. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_kiro_adapter.py +0 -0
  239. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_kiro_cli_e2e.py +0 -0
  240. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_kiro_cli_hook_pin_1265.py +0 -0
  241. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_kiro_hook_templates.py +0 -0
  242. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_kiro_install.py +0 -0
  243. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_layout_migration_t101.py +0 -0
  244. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_layout_parity_t102.py +0 -0
  245. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_local_mode_dict.py +0 -0
  246. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_local_mode_file_json.py +0 -0
  247. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_local_mode_file_yaml.py +0 -0
  248. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_localized_reason.py +0 -0
  249. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_log_fallback_stderr.py +0 -0
  250. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_log_options_ignored_hosted.py +0 -0
  251. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_log_rotation.py +0 -0
  252. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_migrate.py +0 -0
  253. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_min_sdk_version_gate.py +0 -0
  254. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_multi_client_per_project_175.py +0 -0
  255. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_no_policy_no_key.py +0 -0
  256. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_observe_mode_1247.py +0 -0
  257. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_package_rename_shim.py +0 -0
  258. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_part3_active_policy_count_1303.py +0 -0
  259. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_per_client_isolation_1011.py +0 -0
  260. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_policy_engine_version_phase1b.py +0 -0
  261. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_policy_freshness.py +0 -0
  262. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_policy_load_failclosed_f3.py +0 -0
  263. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_policy_settings.py +0 -0
  264. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_policy_source_audit.py +0 -0
  265. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_pr4_install_messaging.py +0 -0
  266. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_pr4_output.py +0 -0
  267. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_pr4_status.py +0 -0
  268. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_probe_audit_isolation.py +0 -0
  269. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_quarantine.py +0 -0
  270. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_reason_code.py +0 -0
  271. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_refresh.py +0 -0
  272. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_secrets.py +0 -0
  273. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_secrets_reference_resolver.py +0 -0
  274. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_source_labels.py +0 -0
  275. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_sql_semantic_class.py +0 -0
  276. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_synthetic_policy_id_t79.py +0 -0
  277. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_t103_precedence.py +0 -0
  278. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_t104_cache_gc.py +0 -0
  279. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_t108_local_override_audit.py +0 -0
  280. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_t96_single_audit_log.py +0 -0
  281. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_t99_install_prefetch_bundle.py +0 -0
  282. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_tamper.py +0 -0
  283. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_tamper_behavior.py +0 -0
  284. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_tamper_hook.py +0 -0
  285. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_telemetry_consent.py +0 -0
  286. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_tracecontext.py +0 -0
  287. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_unsafe_int_boundary.py +0 -0
  288. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_update_cmd.py +0 -0
  289. {controlzero-1.12.0 → controlzero-1.13.1}/tests/test_upgrade_nudge.py +0 -0
  290. {controlzero-1.12.0 → controlzero-1.13.1}/tools/cz-kiro-adapter +0 -0
@@ -1,5 +1,57 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.13.1 (2026-06-27): shell semantic-class enforcement
4
+
5
+ ### Added
6
+
7
+ - **Shell semantic-class extractor (#437/#1459).** The enforcer maps a shell
8
+ command's first meaningful token to a semantic class
9
+ (`file_write`/`file_delete`/`file_read`/`network`), so a `deny: file_write`
10
+ rule also blocks shell-mediated file operations (e.g. `Bash(touch x)` ->
11
+ `file_write`). Additive layer after the per-command action, most-dangerous-wins
12
+ (`file_delete > network > file_write > file_read`), byte-identical to the Node
13
+ and Go SDKs (shared `parity-cases.json`, 118 cases). Closes the cross-OS
14
+ bypass where a denied `Write` was retried as a PowerShell/`cmd`/Unix file
15
+ write through the Bash tool. Unwraps `sh -c`/`powershell -Command`/`cmd /c`
16
+ launchers and prefix wrappers; recurses `find -delete`/`find -exec`;
17
+ whole-line scan for `.NET` method calls and `/dev/tcp/`.
18
+
19
+ ## 1.13.0 (2026-06-26): policy-condition operator vocab
20
+
21
+ ### Added
22
+
23
+ - **Condition operators.** A rule condition value may now be a tagged envelope
24
+ `{op, value}` honoring a closed operator set: `equals`, `contains`, `glob`,
25
+ `matches` (regex, BETA), `gt`/`lt`/`gte`/`lte`, `in`/`not_in`,
26
+ `in_range`/`outside_range`, `exists`/`not_exists`. Read-only aliases
27
+ `matches_pattern`/`greater_than`/`less_than` are canonicalized on load, so
28
+ existing dashboard templates work with zero migration. A legacy scalar value
29
+ (a plain string or number) keeps its prior glob/equals behavior exactly, so
30
+ existing policies and signed bundles evaluate unchanged.
31
+ - **File-path conditions.** `file_path` / `path` / `notebook_path` now feed the
32
+ `input_text` condition value, so a condition like "input_text contains .env"
33
+ fires on Read/Write/Edit tool calls.
34
+
35
+ ### Notes
36
+
37
+ - Fail-closed: a missing field is no-match for every operator except
38
+ `exists`/`not_exists`; an unknown operator is rejected at load; a malformed
39
+ condition fails closed. `matches` is BETA and length-capped to bound regex
40
+ backtracking. Secret detection stays on the DLP scanner, not a regex condition.
41
+
42
+ ## 1.12.1 (2026-06-26): deny rules actually deny
43
+
44
+ ### Fixed
45
+
46
+ - **Deny rules now actually deny (#1011).** A policy DENY rule with a condition
47
+ (e.g. "block Bash when the command contains rm -rf") could silently fail to
48
+ match, so the action ran anyway. Reconciled the rule-authoring vocabulary with
49
+ the engine: tool names match case-insensitively (a rule for `bash:*` catches
50
+ the canonical `Bash:rm`), the command text is matched as `input_text` by
51
+ CONTAINS, and conditions are no longer dropped when a hosted policy is
52
+ translated for local enforcement. A present-but-malformed condition now fails
53
+ closed at load instead of silently becoming an unconditional allow.
54
+
3
55
  ## 1.12.0 -- 2026-06-25 (per-agent key authority + per-agent bundle cache + whoami)
4
56
 
5
57
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: controlzero
3
- Version: 1.12.0
3
+ Version: 1.13.1
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.1"
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
@@ -470,6 +470,30 @@ class PolicyEvaluator:
470
470
  cls = sql_semantic_class(sql_text)
471
471
  if cls:
472
472
  semantic_action = f"{tool}:{cls}"
473
+ # Shell semantic-class layer (#437): the SAME additive pattern for
474
+ # shell-mediated file/network ops. When the tool is the canonical
475
+ # shell (`Bash`) and no class was provided, derive it from the
476
+ # command and promote it into the file_write/file_delete/file_read/
477
+ # network NAMESPACE (`<class>:<class>`) -- NOT `Bash:<class>` --
478
+ # so a rule `deny: file_write` (normalized `file_write:*`) ALSO
479
+ # blocks `Bash(touch x)`. Existing `Bash:*` rules are unaffected
480
+ # because the per-command `action` is always tried first. The
481
+ # hook-check CLI provides the class via
482
+ # ``context['action_semantic_class']``; this is the on-the-fly
483
+ # path for direct ``guard("Bash", args={"command": ...})`` callers.
484
+ if not semantic_action and tool == "Bash" and args:
485
+ from controlzero._internal.hook_extractors import shell_semantic_class
486
+ cmd_text = None
487
+ if isinstance(args, dict):
488
+ for _key in ("command", "CommandLine"):
489
+ _v = args.get(_key)
490
+ if isinstance(_v, str) and _v:
491
+ cmd_text = _v
492
+ break
493
+ if cmd_text:
494
+ cls = shell_semantic_class(cmd_text)
495
+ if cls:
496
+ semantic_action = f"{cls}:{cls}"
473
497
  candidate_actions: list[str] = [action]
474
498
  if semantic_action and semantic_action != action:
475
499
  candidate_actions.append(semantic_action)
@@ -516,7 +540,9 @@ class PolicyEvaluator:
516
540
 
517
541
  for rule in self._rules:
518
542
  evaluated += 1
519
- if not any(_glob_any(rule.actions, a) for a in candidate_actions):
543
+ # Action gate: tool-case-insensitive so `bash:*` matches `Bash:rm`
544
+ # (#1011). Resource/client/project gates below stay case-sensitive.
545
+ if not any(_action_glob_any(rule.actions, a) for a in candidate_actions):
520
546
  continue
521
547
  # Client + project gates (gh#175). A rule whose selector
522
548
  # excludes this request does NOT apply to this surface, so
@@ -676,10 +702,22 @@ class PolicyEvaluator:
676
702
  All condition keys must match. Missing key = no match. Context keys
677
703
  override args on collision. Nested ``context['tags']`` dict is flattened.
678
704
  """
679
- if not rule_conditions:
705
+ # #1011 follow-up (codex P1): the non-dict guard MUST run before the
706
+ # falsy short-circuit, otherwise a falsy-but-malformed conditions ([],
707
+ # "", 0, False) would be read as "no conditions" and the rule would match
708
+ # unconditionally (an allow rule -> unconditional allow, fail-open). A
709
+ # corrupt/tampered or future-shaped signed rule is unverifiable, so the
710
+ # rule does NOT match: an allow rule then does not fire and the default
711
+ # posture governs (fail-closed); a deny rule simply does not contribute.
712
+ # `None` is the genuine "no conditions" case and matches.
713
+ if not isinstance(rule_conditions, dict):
714
+ return rule_conditions is None
715
+ if not rule_conditions: # empty dict -> no conditions to satisfy
680
716
  return True
681
717
  import fnmatch as _fn
682
718
 
719
+ from .io_capture_producer import _INPUT_TEXT_KEYS
720
+
683
721
  merged: dict = {}
684
722
  if args:
685
723
  merged.update(args)
@@ -688,11 +726,71 @@ class PolicyEvaluator:
688
726
  tags = context.get("tags") or {}
689
727
  if isinstance(tags, dict):
690
728
  merged.update(tags)
729
+ # #1011 follow-up: derive `input_text` (the key the dashboard/templates
730
+ # author, "command/input contains X") DETERMINISTICALLY from the payload
731
+ # rather than trusting a caller-supplied label. The raw command/content
732
+ # is the GROUND TRUTH of what runs, so it wins first: neither a benign
733
+ # `input_text` nor a benign pre-extracted `input_payload` can shadow a
734
+ # dangerous `command` (codex P1: a payload setting input_text="ls" or
735
+ # input_payload="ls" while command="rm -rf /" would otherwise bypass the
736
+ # deny under default allow). Precedence: the canonical _INPUT_TEXT_KEYS
737
+ # scan (the SAME order audit extraction uses, so enforcement and audit
738
+ # never drift), then the host's pre-extracted input_payload, then any
739
+ # existing input_text, each only as a fallback when nothing richer is
740
+ # present.
741
+ _derived = None
742
+ for _k in _INPUT_TEXT_KEYS:
743
+ _v = merged.get(_k)
744
+ if _v:
745
+ _derived = _v
746
+ break
747
+ if not _derived:
748
+ _derived = merged.get("input_payload")
749
+ if not _derived:
750
+ _derived = merged.get("input_text")
751
+ if _derived:
752
+ merged["input_text"] = _derived
753
+ from .condition_ops import (
754
+ RESERVED_STRUCTURED_KEYS,
755
+ is_envelope,
756
+ match_operator,
757
+ )
758
+
691
759
  for key, pattern in rule_conditions.items():
760
+ # #1495: a TAGGED ENVELOPE {op, value} dispatches to the shared
761
+ # operator table (equals/contains/matches/gt/lt/in/not_in/range/
762
+ # exists/...). Everything else is a LEGACY SCALAR and keeps the exact
763
+ # pre-#1495 glob/CONTAINS path below, so existing policies and signed
764
+ # bundles evaluate byte-identically. exists/not_exists are the only
765
+ # ops where a missing field can match, so the envelope branch runs
766
+ # BEFORE the missing-key short-circuit. A RESERVED structured key is
767
+ # matched by key name, never as an envelope (the tags:{op:...} hole).
768
+ if key not in RESERVED_STRUCTURED_KEYS and is_envelope(pattern):
769
+ if not match_operator(
770
+ pattern["op"],
771
+ merged.get(key),
772
+ pattern.get("value"),
773
+ bool(pattern.get("case_insensitive")),
774
+ ):
775
+ return False
776
+ continue
692
777
  value = merged.get(key)
693
778
  if value is None:
694
779
  return False
695
- if not _fn.fnmatchcase(str(value), str(pattern)):
780
+ sval, spat = str(value), str(pattern)
781
+ # Only `input_text` (the dashboard's authored text-content key) uses
782
+ # CONTAINS for a plain pattern, so `input_text: "rm -rf"` catches
783
+ # `rm -rf /tmp/foo`. A pattern with glob metacharacters still uses
784
+ # fnmatch; EVERY other key (including the raw `command`/`prompt`
785
+ # sources) keeps exact-glob semantics, so an existing exact-match
786
+ # allowlist is never silently broadened into a substring match
787
+ # (codex P1 fail-open regression).
788
+ if key.casefold() in _TEXT_CONTENT_CONDITION_KEYS and not any(
789
+ c in spat for c in "*?["
790
+ ):
791
+ if spat not in sval:
792
+ return False
793
+ elif not _fn.fnmatchcase(sval, spat):
696
794
  return False
697
795
  return True
698
796
 
@@ -836,8 +934,58 @@ def _glob_any(patterns: list[str], value: str) -> bool:
836
934
 
837
935
  fnmatchcase is case-sensitive (matches policy semantics) and supports the
838
936
  same syntax users learn from .gitignore: *, ?, [seq], [!seq].
937
+
938
+ NOTE: this stays case-sensitive and is used for the resource / client /
939
+ project selector gates. Action matching uses :func:`_action_glob_any`,
940
+ which case-folds the tool segment (see below).
839
941
  """
840
942
  for p in patterns:
841
943
  if fnmatch.fnmatchcase(value, p):
842
944
  return True
843
945
  return False
946
+
947
+
948
+ def _norm_action_tool(s: str) -> str:
949
+ """Case-fold ONLY the tool segment of an action token (the part before the
950
+ first ``:``); leave the method/arg glob untouched.
951
+
952
+ The canonical action the SDK emits is capital-tool (e.g. ``Bash:rm``, the
953
+ canonical TOOL NAME from the extractor spec), but a user or a policy
954
+ template may author the tool in any case (``bash:*``). The tool name is a
955
+ fixed, known vocabulary, so case is never meaningful for it; folding it lets
956
+ a user-authored rule reconcile with the call the agent actually makes. The
957
+ METHOD stays case-sensitive so method-cased semantics (e.g. ``database:DROP``
958
+ vs ``database:drop``) are preserved. A token with no ``:`` (e.g. the
959
+ universal ``*``) is folded whole, a no-op for glob metacharacters.
960
+ """
961
+ tool, sep, method = s.partition(":")
962
+ return (tool.casefold() + sep + method) if sep else s.casefold()
963
+
964
+
965
+ def _action_glob_any(patterns: list[str], action: str) -> bool:
966
+ """Action-gate matcher: like :func:`_glob_any` but case-INSENSITIVE on the
967
+ tool segment of both the rule pattern and the action (see
968
+ :func:`_norm_action_tool`).
969
+
970
+ This is what makes a deny rule authored as ``bash:*`` actually match the
971
+ canonical ``Bash:rm`` (#1011 follow-up: a customer's deny rule silently
972
+ never fired because the matcher compared the tool case-sensitively).
973
+ """
974
+ a = _norm_action_tool(action)
975
+ for p in patterns:
976
+ if fnmatch.fnmatchcase(a, _norm_action_tool(p)):
977
+ return True
978
+ return False
979
+
980
+
981
+ # The ONE condition key whose plain (metachar-free) pattern is matched by
982
+ # CONTAINS (substring): `input_text`. It is the key the dashboard/templates
983
+ # author for "the command/input contains X", and a user typing `rm -rf` means
984
+ # "contains rm -rf", not "IS exactly rm -rf". Patterns WITH glob metachars still
985
+ # use fnmatch. This set is deliberately NARROW: the raw payload keys (command,
986
+ # input_payload, prompt, ...) are seeding SOURCES for input_text, not author-
987
+ # facing condition keys. Briefly including them here broadened any existing
988
+ # exact-match allowlist keyed on `command` into a substring match, a fail-open
989
+ # regression (codex P1). Every key except `input_text` keeps exact-glob
990
+ # semantics. (#1011 follow-up.)
991
+ _TEXT_CONTENT_CONDITION_KEYS = frozenset({"input_text"})