controlzero 1.9.2__tar.gz → 1.9.3__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 (234) hide show
  1. {controlzero-1.9.2 → controlzero-1.9.3}/CHANGELOG.md +77 -0
  2. {controlzero-1.9.2 → controlzero-1.9.3}/PKG-INFO +1 -1
  3. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/__init__.py +1 -1
  4. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/_internal/bundle.py +78 -32
  5. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/_internal/enforcer.py +95 -3
  6. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/main.py +12 -0
  7. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/policy_loader.py +15 -0
  8. {controlzero-1.9.2 → controlzero-1.9.3}/pyproject.toml +1 -1
  9. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_bundle_parser.py +11 -3
  10. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_default_action.py +160 -12
  11. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hitl_reason_codes.py +6 -5
  12. controlzero-1.9.3/tests/test_observe_mode_1247.py +362 -0
  13. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_reason_code.py +41 -17
  14. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_synthetic_policy_id_t79.py +40 -14
  15. {controlzero-1.9.2 → controlzero-1.9.3}/.gitignore +0 -0
  16. {controlzero-1.9.2 → controlzero-1.9.3}/Dockerfile.test +0 -0
  17. {controlzero-1.9.2 → controlzero-1.9.3}/LICENSE +0 -0
  18. {controlzero-1.9.2 → controlzero-1.9.3}/README.md +0 -0
  19. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/_internal/__init__.py +0 -0
  20. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/_internal/action_aliases.py +0 -0
  21. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/_internal/action_validator.py +0 -0
  22. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/_internal/credential_hook.py +0 -0
  23. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/_internal/credential_scanner.py +0 -0
  24. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/_internal/credentials_data/__init__.py +0 -0
  25. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/_internal/credentials_data/built_in.yaml +0 -0
  26. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/_internal/dlp_scanner.py +0 -0
  27. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/_internal/hook_extractors.py +0 -0
  28. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/_internal/tool_extractors.json +0 -0
  29. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/_internal/types.py +0 -0
  30. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/audit_local.py +0 -0
  31. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/audit_remote.py +0 -0
  32. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/canonical.py +0 -0
  33. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/__init__.py +0 -0
  34. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/_secrets.py +0 -0
  35. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/console.py +0 -0
  36. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/debug_bundle.py +0 -0
  37. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/doctor.py +0 -0
  38. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/hosts/__init__.py +0 -0
  39. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/hosts/antigravity.py +0 -0
  40. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/hosts/base.py +0 -0
  41. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/hosts/claude_code.py +0 -0
  42. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/hosts/codex_cli.py +0 -0
  43. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/hosts/gemini_cli.py +0 -0
  44. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/hosts/kiro.py +0 -0
  45. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/hosts/unknown.py +0 -0
  46. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/kiro_adapter.py +0 -0
  47. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/migrate.py +0 -0
  48. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/spool_cmd.py +0 -0
  49. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/telemetry_consent.py +0 -0
  50. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/templates/antigravity/hooks.json +0 -0
  51. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/templates/antigravity.yaml +0 -0
  52. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/templates/autogen.yaml +0 -0
  53. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/templates/claude-code.yaml +0 -0
  54. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/templates/codex-cli.yaml +0 -0
  55. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/templates/cost-cap.yaml +0 -0
  56. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/templates/crewai.yaml +0 -0
  57. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/templates/cursor.yaml +0 -0
  58. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/templates/gemini-cli.yaml +0 -0
  59. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/templates/generic.yaml +0 -0
  60. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/templates/kiro/ide-file-save.kiro.hook +0 -0
  61. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/templates/kiro/ide-pre-tool-use.kiro.hook +0 -0
  62. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/templates/kiro/ide-prompt-submit.kiro.hook +0 -0
  63. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/templates/kiro/kiro.yaml +0 -0
  64. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/templates/langchain.yaml +0 -0
  65. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/templates/mcp.yaml +0 -0
  66. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/templates/rag.yaml +0 -0
  67. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/client.py +0 -0
  68. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/device.py +0 -0
  69. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/enrollment.py +0 -0
  70. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/error_codes.py +0 -0
  71. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/error_codes.yaml +0 -0
  72. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/errors.py +0 -0
  73. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/hitl/__init__.py +0 -0
  74. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/hitl/grant_protocol.py +0 -0
  75. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/hitl/mock.py +0 -0
  76. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/hitl/pending_approval.py +0 -0
  77. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/hitl/secret_leak_guard.py +0 -0
  78. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/hitl/status.py +0 -0
  79. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/hooks/__init__.py +0 -0
  80. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/hooks/tool_output_handler.py +0 -0
  81. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/hosted_policy.py +0 -0
  82. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/__init__.py +0 -0
  83. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/anthropic.py +0 -0
  84. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/autogen.py +0 -0
  85. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/braintrust.py +0 -0
  86. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/crewai/__init__.py +0 -0
  87. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/crewai/agent.py +0 -0
  88. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/crewai/crew.py +0 -0
  89. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/crewai/task.py +0 -0
  90. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/crewai/tool.py +0 -0
  91. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/google.py +0 -0
  92. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/google_adk/__init__.py +0 -0
  93. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/google_adk/agent.py +0 -0
  94. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/google_adk/tool.py +0 -0
  95. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/langchain/__init__.py +0 -0
  96. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/langchain/agent.py +0 -0
  97. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/langchain/callbacks.py +0 -0
  98. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/langchain/chain.py +0 -0
  99. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/langchain/graph.py +0 -0
  100. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/langchain/modern.py +0 -0
  101. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/langchain/tool.py +0 -0
  102. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/langfuse.py +0 -0
  103. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/litellm.py +0 -0
  104. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/openai.py +0 -0
  105. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/pydantic_ai.py +0 -0
  106. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/vercel_ai.py +0 -0
  107. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/layout_migration.py +0 -0
  108. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/spool/__init__.py +0 -0
  109. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/spool/_compress.py +0 -0
  110. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/spool/_constants.py +0 -0
  111. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/spool/_crc32c.py +0 -0
  112. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/spool/_crypto.py +0 -0
  113. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/spool/_frame.py +0 -0
  114. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/spool/_keyring.py +0 -0
  115. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/spool/_metrics.py +0 -0
  116. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/spool/_spool.py +0 -0
  117. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/spool/_state.py +0 -0
  118. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/spool/_uploader.py +0 -0
  119. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/spool/cz-audit-v1.dict +0 -0
  120. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/tamper.py +0 -0
  121. {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/tracecontext.py +0 -0
  122. {controlzero-1.9.2 → controlzero-1.9.3}/examples/hello_world.py +0 -0
  123. {controlzero-1.9.2 → controlzero-1.9.3}/tests/_fixtures/jcs_args_hash_vectors.json +0 -0
  124. {controlzero-1.9.2 → controlzero-1.9.3}/tests/conftest.py +0 -0
  125. {controlzero-1.9.2 → controlzero-1.9.3}/tests/integrations/__init__.py +0 -0
  126. {controlzero-1.9.2 → controlzero-1.9.3}/tests/integrations/test_google.py +0 -0
  127. {controlzero-1.9.2 → controlzero-1.9.3}/tests/parity/action_aliases.json +0 -0
  128. {controlzero-1.9.2 → controlzero-1.9.3}/tests/spool/__init__.py +0 -0
  129. {controlzero-1.9.2 → controlzero-1.9.3}/tests/spool/conftest.py +0 -0
  130. {controlzero-1.9.2 → controlzero-1.9.3}/tests/spool/test_spool_cli.py +0 -0
  131. {controlzero-1.9.2 → controlzero-1.9.3}/tests/spool/test_spool_concurrency.py +0 -0
  132. {controlzero-1.9.2 → controlzero-1.9.3}/tests/spool/test_spool_conformance.py +0 -0
  133. {controlzero-1.9.2 → controlzero-1.9.3}/tests/spool/test_spool_core.py +0 -0
  134. {controlzero-1.9.2 → controlzero-1.9.3}/tests/spool/test_spool_crash.py +0 -0
  135. {controlzero-1.9.2 → controlzero-1.9.3}/tests/spool/test_spool_diskfull.py +0 -0
  136. {controlzero-1.9.2 → controlzero-1.9.3}/tests/spool/test_spool_durable_default_tamper.py +0 -0
  137. {controlzero-1.9.2 → controlzero-1.9.3}/tests/spool/test_spool_keychain_dek.py +0 -0
  138. {controlzero-1.9.2 → controlzero-1.9.3}/tests/spool/test_spool_sink_wiring.py +0 -0
  139. {controlzero-1.9.2 → controlzero-1.9.3}/tests/spool/test_spool_transcript_localack.py +0 -0
  140. {controlzero-1.9.2 → controlzero-1.9.3}/tests/spool/test_spool_uploader.py +0 -0
  141. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_action_aliases.py +0 -0
  142. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_action_canonicalization.py +0 -0
  143. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_action_validator_t86.py +0 -0
  144. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_agent_name_env.py +0 -0
  145. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_antigravity_adapter.py +0 -0
  146. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_antigravity_hook_check.py +0 -0
  147. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_antigravity_install.py +0 -0
  148. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_api_key_mask.py +0 -0
  149. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_audit_remote.py +0 -0
  150. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_audit_remote_sdk_version.py +0 -0
  151. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_audit_sink_isolation.py +0 -0
  152. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_bundle_translate.py +0 -0
  153. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_canonical_phase1a.py +0 -0
  154. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_cli_carve_out.py +0 -0
  155. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_cli_debug_bundle.py +0 -0
  156. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_cli_extractor_integration.py +0 -0
  157. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_cli_hook.py +0 -0
  158. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_cli_hosted_refresh.py +0 -0
  159. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_cli_init.py +0 -0
  160. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_cli_init_templates.py +0 -0
  161. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_cli_tail.py +0 -0
  162. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_cli_test.py +0 -0
  163. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_cli_validate.py +0 -0
  164. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_coding_agent_hooks.py +0 -0
  165. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_conditions.py +0 -0
  166. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_conformance.py +0 -0
  167. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_console.py +0 -0
  168. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_credential_hook.py +0 -0
  169. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_device.py +0 -0
  170. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_dlp_scanner.py +0 -0
  171. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_doctor.py +0 -0
  172. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_engine_version_consistency.py +0 -0
  173. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_enrollment.py +0 -0
  174. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_env_dump_438.py +0 -0
  175. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_error_codes.py +0 -0
  176. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_errors_e_codes.py +0 -0
  177. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_fail_closed_eval.py +0 -0
  178. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_glob_matching.py +0 -0
  179. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hitl_5d_email_install.py +0 -0
  180. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hitl_6a_cli_flag.py +0 -0
  181. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hitl_6a_exceptions.py +0 -0
  182. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hitl_6a_get_secret_hitl.py +0 -0
  183. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hitl_6a_mock_backend.py +0 -0
  184. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hitl_6a_pending_approval.py +0 -0
  185. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hitl_6a_request_approval.py +0 -0
  186. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hitl_6a_secret_leak_guard.py +0 -0
  187. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hitl_6a_wait.py +0 -0
  188. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hitl_conformance.py +0 -0
  189. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hitl_phase2b_protocol.py +0 -0
  190. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hitl_validator_keys.py +0 -0
  191. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hook_extractors.py +0 -0
  192. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hosted_local_audit_1247.py +0 -0
  193. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hosted_policy_e2e.py +0 -0
  194. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hosts_adapter.py +0 -0
  195. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hybrid_mode_strict.py +0 -0
  196. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hybrid_mode_warn.py +0 -0
  197. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_install_hook_command.py +0 -0
  198. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_install_hooks.py +0 -0
  199. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_kiro_adapter.py +0 -0
  200. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_kiro_hook_templates.py +0 -0
  201. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_kiro_install.py +0 -0
  202. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_layout_migration_t101.py +0 -0
  203. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_layout_parity_t102.py +0 -0
  204. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_local_mode_dict.py +0 -0
  205. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_local_mode_file_json.py +0 -0
  206. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_local_mode_file_yaml.py +0 -0
  207. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_log_fallback_stderr.py +0 -0
  208. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_log_options_ignored_hosted.py +0 -0
  209. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_log_rotation.py +0 -0
  210. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_migrate.py +0 -0
  211. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_min_sdk_version_gate.py +0 -0
  212. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_multi_client_per_project_175.py +0 -0
  213. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_no_policy_no_key.py +0 -0
  214. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_package_rename_shim.py +0 -0
  215. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_policy_engine_version_phase1b.py +0 -0
  216. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_policy_freshness.py +0 -0
  217. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_policy_settings.py +0 -0
  218. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_policy_source_audit.py +0 -0
  219. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_quarantine.py +0 -0
  220. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_refresh.py +0 -0
  221. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_secrets.py +0 -0
  222. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_sql_semantic_class.py +0 -0
  223. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_t103_precedence.py +0 -0
  224. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_t104_cache_gc.py +0 -0
  225. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_t108_local_override_audit.py +0 -0
  226. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_t96_single_audit_log.py +0 -0
  227. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_t99_install_prefetch_bundle.py +0 -0
  228. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_tamper.py +0 -0
  229. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_tamper_behavior.py +0 -0
  230. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_tamper_hook.py +0 -0
  231. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_telemetry_consent.py +0 -0
  232. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_tracecontext.py +0 -0
  233. {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_unsafe_int_boundary.py +0 -0
  234. {controlzero-1.9.2 → controlzero-1.9.3}/tools/cz-kiro-adapter +0 -0
@@ -1,5 +1,82 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.3 -- 2026-06-16 (posture release: empty-bundle OBSERVE + self-explaining no-rule-match deny, epic gh#1247)
4
+
5
+ The consolidated **posture release** for epic gh#1247 (customer Bryan).
6
+ Three customer-visible behaviors land together so the engine's "what
7
+ happens when a tool isn't covered by a rule" story is coherent across
8
+ both SDKs and the backend:
9
+
10
+ 1. **Empty bundle -> OBSERVE** (was the only content of the superseded
11
+ 1.9.2-observe draft).
12
+ 2. **Non-empty no-rule-match deny is now self-explaining** (folds in the
13
+ Python-only fix from gh#1257, which is superseded by this release).
14
+ 3. The backend `default_on_empty` knob on the bundle handler.
15
+
16
+ ### Fixed
17
+
18
+ - **Hosted no-rule-match deny is now self-explaining (folds gh#1257).** A
19
+ non-empty bundle with `default_action=deny` and zero rule matches (the
20
+ exact shape of Bryan's "Db read only" allow-list, which excludes
21
+ `bash`) used to deny with the bare, bug-looking message
22
+ `No matching policy rule (fail-closed default)` -- no path out. The
23
+ deny reason now NAMES the unmatched action (e.g. `bash:find`) and the
24
+ exact remediation (add a catch-all `allow: '*'`, allow the specific
25
+ action, or flip the project/org default to allow) and tells the user
26
+ to do it in the Control Zero dashboard. `reason_code` stays
27
+ `NO_RULE_MATCH`; the legacy `fail-closed default` substring is retained
28
+ for any downstream regex consumer. The empty-bundle OBSERVE catch-all
29
+ matches FIRST, so a genuinely-empty bundle still observes and never
30
+ reaches this deny -- the two behaviors compose cleanly. Regression
31
+ tests in `tests/test_default_action.py`
32
+ (`test_1247_no_rule_match_deny_*`).
33
+
34
+ ### Changed
35
+
36
+ A genuinely-EMPTY hosted bundle -- one that resolved successfully but has
37
+ zero attached/active policies -- now defaults to **OBSERVE** (allow +
38
+ audit + a loud "monitoring, not enforcing" signal) instead of the old
39
+ day-one deny-brick. A fresh hosted project no longer blocks every tool
40
+ call before the operator has authored a single rule; instead Control
41
+ Zero allows the call through and audits it, loudly flagged, so the
42
+ operator can see the engine is wired up and watching, then attach a
43
+ policy to start enforcing.
44
+
45
+ This is a founder-approved posture refinement (validated by a 4-lens
46
+ second-opinion). It is deliberately **narrow and gated**:
47
+
48
+ - **Only the genuinely-empty, successfully-resolved case observes.** A
49
+ non-empty bundle whose rules evaluate but nothing matches still
50
+ **denies** (`NO_RULE_MATCH`, `default_action` canonical deny) --
51
+ authored allow-lists stay secure. A bundle RESOLUTION ERROR / failed
52
+ pull / RLS / auth / decrypt failure still **fails closed** (deny,
53
+ `BUNDLE_MISSING`, honoring `default_on_missing`). Observe mode can
54
+ never mask a resolution error as an allow.
55
+ - **The empty-vs-error boundary is structural, not a runtime flag.** The
56
+ empty path (`translate_to_local_policy`, reached only on successful
57
+ resolution) and the error path (`make_bundle_missing_policy`, reached
58
+ only on resolution failure) are separate functions with separate
59
+ reason codes, so they cannot be confused.
60
+
61
+ ### Added
62
+
63
+ - **New `reason_code=OBSERVE_MODE_NO_POLICY`** and synthetic policy_id
64
+ `synthetic:OBSERVE_MODE_NO_POLICY` for the empty-bundle observe allow,
65
+ distinct from `NO_ACTIVE_POLICIES` (the deny/warn/allow empty postures)
66
+ and `BUNDLE_MISSING` (the fail-closed resolution-error path).
67
+ - **`PolicyDecision.observe: bool`** -- True only on an observe-mode
68
+ allow. The CLI `guard` output now prints a loud yellow "OBSERVE MODE:
69
+ monitoring, not enforcing" line so an observe allow can never be
70
+ mistaken for a normal rule-driven allow. Gated strictly on the
71
+ reason_code, so no user-authored rule can ever set it.
72
+ - **New `default_on_empty` knob** (`observe`|`deny`|`warn`|`allow`,
73
+ canonical `observe`), separate from `default_action`. An operator can
74
+ declare allow-list-vs-deny-list intent for the empty case instead of
75
+ it being inferred. Plumbed end-to-end: backend bundle payload ->
76
+ bundle translator -> `PolicySettings`. Local YAML policies may set
77
+ `settings.default_on_empty`. The dashboard observe-mode indicator +
78
+ CLI `status` line remain a separate frontend/CLI follow-up.
79
+
3
80
  ## 1.9.1 -- 2026-06-16 (hosted-mode local audit log P0, epic gh#1247)
4
81
 
5
82
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: controlzero
3
- Version: 1.9.2
3
+ Version: 1.9.3
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
@@ -40,7 +40,7 @@ from controlzero.hitl.grant_protocol import (
40
40
  )
41
41
  from controlzero.policy_loader import load_policy
42
42
 
43
- __version__ = "1.9.2"
43
+ __version__ = "1.9.3"
44
44
 
45
45
  __all__ = [
46
46
  "Client",
@@ -493,9 +493,11 @@ def translate_to_local_policy(payload: dict) -> dict:
493
493
  # the canonical-default fallback (one source of truth).
494
494
  from controlzero._internal.enforcer import (
495
495
  DEFAULT_BUNDLE_ACTION,
496
+ DEFAULT_BUNDLE_ON_EMPTY,
496
497
  DEFAULT_BUNDLE_ON_MISSING,
497
498
  DEFAULT_BUNDLE_ON_TAMPER,
498
499
  VALID_DEFAULT_ACTIONS,
500
+ VALID_DEFAULT_ON_EMPTY,
499
501
  VALID_DEFAULT_ON_MISSING,
500
502
  VALID_DEFAULT_ON_TAMPER,
501
503
  )
@@ -508,6 +510,19 @@ def translate_to_local_policy(payload: dict) -> dict:
508
510
  if default_on_missing not in VALID_DEFAULT_ON_MISSING:
509
511
  default_on_missing = DEFAULT_BUNDLE_ON_MISSING
510
512
 
513
+ # default_on_empty (#1247 item 3): empty-bundle posture, resolved
514
+ # server-side org->project->canonical("observe"). Separate from
515
+ # default_action so an authored allow-list (no-match -> deny) and an
516
+ # empty project (observe) do not have to share one knob. Unknown /
517
+ # absent values coerce to the canonical "observe" so a fresh hosted
518
+ # project is observe-by-default instead of bricked day-one. NOTE:
519
+ # this knob is read ONLY here, on the SUCCESSFUL-resolution empty
520
+ # branch below -- the resolution-ERROR path (make_bundle_missing_
521
+ # policy) does not consult it and still fails closed.
522
+ default_on_empty = payload.get("default_on_empty")
523
+ if default_on_empty not in VALID_DEFAULT_ON_EMPTY:
524
+ default_on_empty = DEFAULT_BUNDLE_ON_EMPTY
525
+
511
526
  default_on_tamper = payload.get("default_on_tamper")
512
527
  if default_on_tamper not in VALID_DEFAULT_ON_TAMPER:
513
528
  default_on_tamper = DEFAULT_BUNDLE_ON_TAMPER
@@ -536,39 +551,69 @@ def translate_to_local_policy(payload: dict) -> dict:
536
551
  flat.append(translated)
537
552
 
538
553
  if not flat:
539
- # Empty policy set: synthetic catchall rule. The effect
540
- # mirrors the bundle-level default_action so "empty bundle"
541
- # continues to deliver the operator's chosen posture. Prior
542
- # to #228 Phase 2 this was hard-coded to deny; now an org
543
- # that ships an empty-bundle-with-default=allow gets the
544
- # expected allow (with the same NO_ACTIVE_POLICIES
545
- # reason_code so dashboards still bucket it as "nothing
546
- # attached").
554
+ # Empty policy set (RESOLVED SUCCESSFULLY, zero translatable
555
+ # rules): synthetic catch-all rule whose posture is driven by
556
+ # default_on_empty (#1247 item 3 / #1252), NOT default_action.
557
+ #
558
+ # Why a separate knob: default_action governs the NON-EMPTY
559
+ # no-match path -- an org that authored an allow-list wants that
560
+ # to stay deny. But an org that has attached NOTHING YET should
561
+ # not be bricked day-one. The founder-approved refinement makes
562
+ # the genuinely-empty case OBSERVE by default: the call is
563
+ # ALLOWED THROUGH (effect="allow") but loudly flagged as
564
+ # monitoring-only (reason_code=OBSERVE_MODE_NO_POLICY, which the
565
+ # evaluator turns into observe=True on the decision) and
566
+ # audited, so the operator KNOWS the engine is wired up and
567
+ # watching, not enforcing. An operator can override
568
+ # default_on_empty to deny (fail-closed empty), warn (shadow),
569
+ # or allow (silent allow).
570
+ #
571
+ # SAFETY INVARIANT (the empty-vs-error boundary): this branch is
572
+ # reached ONLY when bundle resolution SUCCEEDED and produced
573
+ # zero rules. A resolution FAILURE (pull error, RLS/auth denial,
574
+ # decrypt failure) never reaches here -- it is caught upstream
575
+ # and routed to make_bundle_missing_policy(), which honors
576
+ # default_on_missing and STILL FAILS CLOSED (deny). So observe
577
+ # mode can never mask a resolution error as an allow.
547
578
  #
548
- # Copy-choice note (2026-04-19, the 2026-04-19 P0): the previous
549
- # message -- "No active policies. Define one in the Control Zero
550
- # dashboard." -- presumed the user had not defined any policies.
551
- # That presumption was wrong in the common case: the user had
552
- # defined several, but the library-attachments state on the
553
- # backend did not include any active attachment row, so the
554
- # bundle arrived with zero policies while the dashboard Library
555
- # tab still showed them. The new wording removes the
556
- # presumption and points at the actual recovery action.
557
- flat.append({
558
- "effect": default_action,
559
- "action": "*",
560
- # T79: stamp a synthetic policy_id so the audit dashboard
561
- # can render a recognizable chip + tooltip linking to the
562
- # right troubleshooting anchor. The reason_code stays the
563
- # same; the synthetic id is purely an audit-presentation
564
- # contract (audit ingest stores it verbatim).
565
- "id": "synthetic:NO_ACTIVE_POLICIES",
566
- "reason": (
567
- "No policies are active on this project. If the dashboard "
568
- "shows attached policies, regenerate the policy bundle."
569
- ),
570
- "reason_code": "NO_ACTIVE_POLICIES",
571
- })
579
+ # Copy-choice note (2026-04-19, the 2026-04-19 P0): the original
580
+ # NO_ACTIVE_POLICIES message presumed the user had defined no
581
+ # policies; in the common case the library-attachments state was
582
+ # simply empty while the dashboard still showed them. The wording
583
+ # below removes that presumption and points at recovery.
584
+ if default_on_empty == "observe":
585
+ # OBSERVE: allow + loud monitoring signal + audit.
586
+ flat.append({
587
+ "effect": "allow",
588
+ "action": "*",
589
+ "id": "synthetic:OBSERVE_MODE_NO_POLICY",
590
+ "reason": (
591
+ "OBSERVE MODE: no policies are active on this project, so "
592
+ "Control Zero is monitoring and auditing tool calls but "
593
+ "NOT enforcing -- every call is allowed and logged. Attach "
594
+ "a policy (or set the empty-project default to deny) in the "
595
+ "Control Zero dashboard to start enforcing."
596
+ ),
597
+ "reason_code": "OBSERVE_MODE_NO_POLICY",
598
+ })
599
+ else:
600
+ # Explicit non-observe empty posture (deny / warn / allow).
601
+ # Keeps the historical NO_ACTIVE_POLICIES reason_code so
602
+ # dashboards still bucket it as "nothing attached". The
603
+ # effect honors the operator's declared default_on_empty.
604
+ flat.append({
605
+ "effect": default_on_empty,
606
+ "action": "*",
607
+ # T79: stamp a synthetic policy_id so the audit dashboard
608
+ # can render a recognizable chip + tooltip linking to the
609
+ # right troubleshooting anchor.
610
+ "id": "synthetic:NO_ACTIVE_POLICIES",
611
+ "reason": (
612
+ "No policies are active on this project. If the dashboard "
613
+ "shows attached policies, regenerate the policy bundle."
614
+ ),
615
+ "reason_code": "NO_ACTIVE_POLICIES",
616
+ })
572
617
 
573
618
  out = {
574
619
  "version": "1",
@@ -576,6 +621,7 @@ def translate_to_local_policy(payload: dict) -> dict:
576
621
  "settings": {
577
622
  "default_action": default_action,
578
623
  "default_on_missing": default_on_missing,
624
+ "default_on_empty": default_on_empty,
579
625
  "default_on_tamper": default_on_tamper,
580
626
  },
581
627
  }
@@ -62,6 +62,20 @@ POLICY_ENGINE_VERSION = "0.1.0"
62
62
  REASON_CODE_RULE_MATCH = "RULE_MATCH"
63
63
  REASON_CODE_NO_RULE_MATCH = "NO_RULE_MATCH"
64
64
  REASON_CODE_NO_ACTIVE_POLICIES = "NO_ACTIVE_POLICIES"
65
+ # OBSERVE_MODE_NO_POLICY (#1247 item 3 / #1252): a genuinely-empty,
66
+ # rule-less bundle that RESOLVED SUCCESSFULLY (zero attached/active
67
+ # policies) and whose resolved empty-policy posture is "observe". The
68
+ # call is ALLOWED THROUGH but the decision is loudly flagged as
69
+ # monitoring-only (observe=True on the decision) and audited, so the
70
+ # operator knows the engine is NOT enforcing. This is deliberately a
71
+ # DISTINCT code from NO_ACTIVE_POLICIES (which remains for the
72
+ # deny/warn empty-posture cases and pre-refinement consumers) and from
73
+ # BUNDLE_MISSING (a resolution FAILURE, which still fails closed -- see
74
+ # make_bundle_missing_policy). The empty-vs-error boundary is the
75
+ # security-critical invariant here: OBSERVE_MODE_NO_POLICY is ONLY ever
76
+ # emitted on the SUCCESSFUL-resolution empty path, never on a pull /
77
+ # RLS / auth error.
78
+ REASON_CODE_OBSERVE_MODE_NO_POLICY = "OBSERVE_MODE_NO_POLICY"
65
79
  REASON_CODE_BUNDLE_MISSING = "BUNDLE_MISSING"
66
80
  REASON_CODE_BUNDLE_TAMPERED = "BUNDLE_TAMPERED"
67
81
  REASON_CODE_MACHINE_QUARANTINED = "MACHINE_QUARANTINED"
@@ -93,6 +107,7 @@ VALID_REASON_CODES = frozenset({
93
107
  REASON_CODE_RULE_MATCH,
94
108
  REASON_CODE_NO_RULE_MATCH,
95
109
  REASON_CODE_NO_ACTIVE_POLICIES,
110
+ REASON_CODE_OBSERVE_MODE_NO_POLICY,
96
111
  REASON_CODE_BUNDLE_MISSING,
97
112
  REASON_CODE_BUNDLE_TAMPERED,
98
113
  REASON_CODE_MACHINE_QUARANTINED,
@@ -129,6 +144,7 @@ VALID_REASON_CODES = frozenset({
129
144
  SYNTHETIC_POLICY_ID_PREFIX = "synthetic:"
130
145
  SYNTHETIC_NO_RULE_MATCH = "synthetic:NO_RULE_MATCH"
131
146
  SYNTHETIC_NO_ACTIVE_POLICIES = "synthetic:NO_ACTIVE_POLICIES"
147
+ SYNTHETIC_OBSERVE_MODE_NO_POLICY = "synthetic:OBSERVE_MODE_NO_POLICY"
132
148
  SYNTHETIC_BUNDLE_MISSING = "synthetic:BUNDLE_MISSING"
133
149
  SYNTHETIC_RESOURCE_GATE_SKIP = "synthetic:RESOURCE_GATE_SKIP"
134
150
  SYNTHETIC_QUARANTINE = "synthetic:QUARANTINE"
@@ -137,6 +153,7 @@ SYNTHETIC_ENGINE_UNAVAILABLE = "synthetic:ENGINE_UNAVAILABLE"
137
153
  VALID_SYNTHETIC_POLICY_IDS = frozenset({
138
154
  SYNTHETIC_NO_RULE_MATCH,
139
155
  SYNTHETIC_NO_ACTIVE_POLICIES,
156
+ SYNTHETIC_OBSERVE_MODE_NO_POLICY,
140
157
  SYNTHETIC_BUNDLE_MISSING,
141
158
  SYNTHETIC_RESOURCE_GATE_SKIP,
142
159
  SYNTHETIC_QUARANTINE,
@@ -155,8 +172,30 @@ DEFAULT_BUNDLE_ACTION = "deny"
155
172
  DEFAULT_BUNDLE_ON_MISSING = "deny"
156
173
  DEFAULT_BUNDLE_ON_TAMPER = "warn"
157
174
 
175
+ # default_on_empty (#1247 item 3 / #1252, founder-approved posture
176
+ # refinement): the effect for a genuinely-EMPTY bundle -- one that
177
+ # RESOLVED SUCCESSFULLY but has zero attached/active rules. This is a
178
+ # SEPARATE knob from default_action (which governs the non-empty
179
+ # no-match path). The distinction is the whole point: an org that has
180
+ # authored an allow-list deliberately wants no-match -> deny
181
+ # (default_action), while an org that has attached NOTHING YET should
182
+ # not be bricked day-one -- it observes (allow + audit + a loud
183
+ # "monitoring, not enforcing" signal) instead.
184
+ #
185
+ # Canonical default is "observe": a fresh/empty hosted project allows
186
+ # tool calls through but loudly flags + audits every one as
187
+ # OBSERVE_MODE_NO_POLICY, so the operator sees the engine is wired up
188
+ # and watching, then authors real rules. An operator can override to
189
+ # "deny" (fail-closed empty), "warn" (shadow), or "allow" (silent
190
+ # allow, discouraged). CRITICAL: this only ever applies on the
191
+ # SUCCESSFUL-resolution empty path; a resolution ERROR routes through
192
+ # make_bundle_missing_policy + default_on_missing and STILL FAILS
193
+ # CLOSED -- default_on_empty never weakens the error path.
194
+ DEFAULT_BUNDLE_ON_EMPTY = "observe"
195
+
158
196
  VALID_DEFAULT_ACTIONS = frozenset({"deny", "allow", "warn"})
159
197
  VALID_DEFAULT_ON_MISSING = frozenset({"deny", "allow"})
198
+ VALID_DEFAULT_ON_EMPTY = frozenset({"observe", "deny", "allow", "warn"})
160
199
  VALID_DEFAULT_ON_TAMPER = frozenset({"warn", "deny", "deny-all", "quarantine"})
161
200
 
162
201
 
@@ -217,6 +256,19 @@ class PolicyDecision:
217
256
  # requires_approval is True. None falls back to policy_id at
218
257
  # request time so the approver always sees a label.
219
258
  approval_action: Optional[str] = None
259
+ # observe (#1247 item 3 / #1252): True when this decision is an
260
+ # OBSERVE-MODE allow -- the call is permitted (effect="allow") but
261
+ # the engine is NOT enforcing because the bundle is genuinely empty
262
+ # (zero attached/active policies) and the resolved empty-policy
263
+ # posture is "observe". This is the LOUD signal the security review
264
+ # mandated: an observe allow must never look like a normal,
265
+ # rule-driven allow. Consumers (CLI output, audit annotation, hook
266
+ # banners) branch on this to print "OBSERVE MODE: monitoring, not
267
+ # enforcing". It is set ONLY alongside
268
+ # reason_code=OBSERVE_MODE_NO_POLICY and is False on every other
269
+ # path (including a real rule that happens to allow, and the
270
+ # fail-closed BUNDLE_MISSING / NO_RULE_MATCH paths).
271
+ observe: bool = False
220
272
 
221
273
  @property
222
274
  def decision(self) -> str:
@@ -457,6 +509,25 @@ class PolicyEvaluator:
457
509
  reason_code=decision_code,
458
510
  evaluated_rules=evaluated,
459
511
  gate_matched=gate_matched_value,
512
+ # #1247 item 3: the empty-bundle observe catch-all
513
+ # (synthetic OBSERVE_MODE_NO_POLICY rule) is an allow
514
+ # that must be loudly flagged as monitoring-only. Set
515
+ # the observe signal here so CLI / hook / audit
516
+ # consumers can distinguish it from a normal
517
+ # rule-driven allow.
518
+ #
519
+ # Hardening (security review L1): gate on BOTH the
520
+ # reason_code AND the synthetic policy_id, so that even a
521
+ # (backend-signed) bundle rule that carried a forged
522
+ # reason_code="OBSERVE_MODE_NO_POLICY" cannot mislabel a
523
+ # rule-driven allow as observe -- the synthetic id is
524
+ # only ever stamped by translate_to_local_policy's empty
525
+ # branch. observe is an audit/UX label, never an
526
+ # authorization input, so this is belt-and-suspenders.
527
+ observe=(
528
+ decision_code == REASON_CODE_OBSERVE_MODE_NO_POLICY
529
+ and (rule.id or "") == SYNTHETIC_OBSERVE_MODE_NO_POLICY
530
+ ),
460
531
  )
461
532
  # DLP scan: only when the policy decision is "allow" and args exist
462
533
  if decision.allowed and args and self._dlp_scanner is not None:
@@ -475,11 +546,32 @@ class PolicyEvaluator:
475
546
  # that regex-match the reason string (a pattern we actively
476
547
  # discourage -- use reason_code instead) keep working.
477
548
  if self._default_action == "deny":
478
- reason = "No matching policy rule (fail-closed default)"
549
+ # #1247 (folded from #1257): a deny-by-default no-rule-match is
550
+ # the single most confusing block a customer hits -- a benign
551
+ # tool (e.g. `bash:find`) is denied not because a rule forbids
552
+ # it but because no rule mentions it and the bundle's
553
+ # default_on_missing is deny. The historical message ("fail-
554
+ # closed default") gave no path out and looked like a bug.
555
+ # Name the unmatched action and the exact remediation so the
556
+ # deny is self-explaining. The leading phrase keeps the legacy
557
+ # "fail-closed default" substring for any downstream regex
558
+ # consumer (reason_code=NO_RULE_MATCH is the supported signal;
559
+ # this is belt-and-suspenders). NOTE: this is the NON-EMPTY
560
+ # bundle path -- a genuinely empty bundle short-circuits to the
561
+ # synthetic OBSERVE catch-all above and never reaches here, so
562
+ # observe and self-explaining-deny compose cleanly.
563
+ reason = (
564
+ f"No matching policy rule for '{action}' (fail-closed default; "
565
+ "default_on_missing=deny). This tool is neither allowed nor "
566
+ "denied by any rule in your hosted policy, so it is blocked. "
567
+ "Add a rule that allows it (e.g. allow: '*' as a catch-all, or "
568
+ f"allow: '{action}'), or set the project/org default to allow, "
569
+ "in the Control Zero dashboard."
570
+ )
479
571
  elif self._default_action == "allow":
480
- reason = "No matching policy rule (default_action=allow)"
572
+ reason = f"No matching policy rule for '{action}' (default_action=allow)"
481
573
  else: # "warn"
482
- reason = "No matching policy rule (default_action=warn)"
574
+ reason = f"No matching policy rule for '{action}' (default_action=warn)"
483
575
 
484
576
  # T79: distinguish the T83-class signature ("a rule's actions
485
577
  # matched but its resources gate excluded the call") from the
@@ -214,6 +214,18 @@ def test(tool: str, method: str, policy: str, args: str, hitl: Optional[str], hi
214
214
  click.echo(f"args: {args_dict}")
215
215
  click.echo("")
216
216
  click.secho(f"DECISION: {decision.effect.upper()}", fg=color, bold=True)
217
+ # #1247 item 3: an observe-mode allow is NOT a normal allow -- the
218
+ # engine is monitoring, not enforcing, because the project has no
219
+ # active policies. Print a distinct, loud line so the operator can
220
+ # never mistake an empty-bundle observe for "my rules allowed this".
221
+ if getattr(decision, "observe", False):
222
+ click.secho(
223
+ "OBSERVE MODE: monitoring, not enforcing -- this project has "
224
+ "no active policies, so every tool call is allowed and logged. "
225
+ "Attach a policy to start enforcing.",
226
+ fg="yellow",
227
+ bold=True,
228
+ )
217
229
  if decision.policy_id:
218
230
  click.echo(f"matched: {decision.policy_id}")
219
231
  if decision.reason:
@@ -33,9 +33,11 @@ from typing import Union
33
33
  from controlzero._internal.action_validator import validate_actions
34
34
  from controlzero._internal.enforcer import (
35
35
  DEFAULT_BUNDLE_ACTION,
36
+ DEFAULT_BUNDLE_ON_EMPTY,
36
37
  DEFAULT_BUNDLE_ON_MISSING,
37
38
  DEFAULT_BUNDLE_ON_TAMPER,
38
39
  VALID_DEFAULT_ACTIONS,
40
+ VALID_DEFAULT_ON_EMPTY,
39
41
  VALID_DEFAULT_ON_MISSING,
40
42
  VALID_DEFAULT_ON_TAMPER,
41
43
  )
@@ -134,6 +136,9 @@ class PolicySettings:
134
136
  tamper_behavior: str = "warn"
135
137
  default_action: str = DEFAULT_BUNDLE_ACTION
136
138
  default_on_missing: str = DEFAULT_BUNDLE_ON_MISSING
139
+ # default_on_empty (#1247 item 3): empty-bundle posture. Canonical
140
+ # "observe" so a rule-less project monitors+audits without bricking.
141
+ default_on_empty: str = DEFAULT_BUNDLE_ON_EMPTY
137
142
  default_on_tamper: str = DEFAULT_BUNDLE_ON_TAMPER
138
143
 
139
144
 
@@ -294,6 +299,16 @@ def _validate_and_translate(data: dict, source_label: str) -> ParsedPolicy:
294
299
  else:
295
300
  settings.default_on_missing = dom
296
301
 
302
+ doe = settings_raw.get("default_on_empty")
303
+ if doe is not None:
304
+ if doe not in VALID_DEFAULT_ON_EMPTY:
305
+ errors.append(
306
+ "settings.default_on_empty must be one of "
307
+ f"{sorted(VALID_DEFAULT_ON_EMPTY)}, got {doe!r}"
308
+ )
309
+ else:
310
+ settings.default_on_empty = doe
311
+
297
312
  dot = settings_raw.get("default_on_tamper")
298
313
  if dot is not None:
299
314
  if dot not in VALID_DEFAULT_ON_TAMPER:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "controlzero"
7
- version = "1.9.2"
7
+ version = "1.9.3"
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"}
@@ -124,16 +124,24 @@ def test_translate_to_local_policy(keys, valid_payload):
124
124
  assert "*" in actions
125
125
 
126
126
 
127
- def test_empty_policies_becomes_deny_all(keys):
128
- """An empty policy set translates to deny-all, not allow-all."""
127
+ def test_empty_policies_becomes_observe_allow(keys):
128
+ """#1247 item 3 / #1252: an empty policy set now translates to a
129
+ single OBSERVE catch-all -- allow + audit + a loud monitoring
130
+ signal -- NOT the old deny-all (and never allow-all silently). The
131
+ canonical empty-bundle posture (default_on_empty) is "observe", so a
132
+ fresh hosted project monitors instead of bricking on day one. A
133
+ resolution ERROR still fails closed via the separate BUNDLE_MISSING
134
+ path (see test_synthetic_policy_id_t79 / make_bundle_missing_policy).
135
+ """
129
136
  enc_key, priv, pub = keys
130
137
  payload = json.dumps({"policies": []}).encode("utf-8")
131
138
  bundle = _build_bundle(payload, enc_key, priv, policy_count=0)
132
139
  parsed = parse_bundle(bundle, enc_key, pub)
133
140
  local = translate_to_local_policy(parsed.payload)
134
141
  assert len(local["rules"]) == 1
135
- assert local["rules"][0]["effect"] == "deny"
142
+ assert local["rules"][0]["effect"] == "allow"
136
143
  assert local["rules"][0]["action"] == "*"
144
+ assert local["rules"][0]["reason_code"] == "OBSERVE_MODE_NO_POLICY"
137
145
 
138
146
 
139
147
  # --- Tamper detection (security-critical) ---------------------------------