controlzero 1.9.1__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 (235) hide show
  1. {controlzero-1.9.1 → controlzero-1.9.3}/CHANGELOG.md +121 -0
  2. {controlzero-1.9.1 → controlzero-1.9.3}/PKG-INFO +1 -1
  3. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/__init__.py +1 -1
  4. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/_internal/bundle.py +78 -32
  5. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/_internal/enforcer.py +95 -3
  6. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/audit_remote.py +71 -11
  7. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/cli/main.py +12 -0
  8. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/cli/spool_cmd.py +15 -2
  9. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/policy_loader.py +15 -0
  10. controlzero-1.9.3/controlzero/spool/_keyring.py +311 -0
  11. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/spool/_spool.py +38 -3
  12. controlzero-1.9.3/controlzero/spool/_state.py +356 -0
  13. {controlzero-1.9.1 → controlzero-1.9.3}/pyproject.toml +1 -1
  14. {controlzero-1.9.1 → controlzero-1.9.3}/tests/conftest.py +24 -0
  15. controlzero-1.9.3/tests/spool/test_spool_durable_default_tamper.py +354 -0
  16. controlzero-1.9.3/tests/spool/test_spool_keychain_dek.py +309 -0
  17. {controlzero-1.9.1 → controlzero-1.9.3}/tests/spool/test_spool_sink_wiring.py +55 -6
  18. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_bundle_parser.py +11 -3
  19. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_default_action.py +160 -12
  20. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_hitl_reason_codes.py +6 -5
  21. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_hosted_policy_e2e.py +16 -3
  22. controlzero-1.9.3/tests/test_observe_mode_1247.py +362 -0
  23. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_reason_code.py +41 -17
  24. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_synthetic_policy_id_t79.py +40 -14
  25. controlzero-1.9.1/controlzero/spool/_state.py +0 -154
  26. {controlzero-1.9.1 → controlzero-1.9.3}/.gitignore +0 -0
  27. {controlzero-1.9.1 → controlzero-1.9.3}/Dockerfile.test +0 -0
  28. {controlzero-1.9.1 → controlzero-1.9.3}/LICENSE +0 -0
  29. {controlzero-1.9.1 → controlzero-1.9.3}/README.md +0 -0
  30. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/_internal/__init__.py +0 -0
  31. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/_internal/action_aliases.py +0 -0
  32. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/_internal/action_validator.py +0 -0
  33. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/_internal/credential_hook.py +0 -0
  34. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/_internal/credential_scanner.py +0 -0
  35. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/_internal/credentials_data/__init__.py +0 -0
  36. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/_internal/credentials_data/built_in.yaml +0 -0
  37. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/_internal/dlp_scanner.py +0 -0
  38. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/_internal/hook_extractors.py +0 -0
  39. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/_internal/tool_extractors.json +0 -0
  40. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/_internal/types.py +0 -0
  41. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/audit_local.py +0 -0
  42. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/canonical.py +0 -0
  43. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/cli/__init__.py +0 -0
  44. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/cli/_secrets.py +0 -0
  45. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/cli/console.py +0 -0
  46. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/cli/debug_bundle.py +0 -0
  47. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/cli/doctor.py +0 -0
  48. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/cli/hosts/__init__.py +0 -0
  49. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/cli/hosts/antigravity.py +0 -0
  50. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/cli/hosts/base.py +0 -0
  51. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/cli/hosts/claude_code.py +0 -0
  52. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/cli/hosts/codex_cli.py +0 -0
  53. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/cli/hosts/gemini_cli.py +0 -0
  54. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/cli/hosts/kiro.py +0 -0
  55. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/cli/hosts/unknown.py +0 -0
  56. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/cli/kiro_adapter.py +0 -0
  57. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/cli/migrate.py +0 -0
  58. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/cli/telemetry_consent.py +0 -0
  59. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/cli/templates/antigravity/hooks.json +0 -0
  60. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/cli/templates/antigravity.yaml +0 -0
  61. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/cli/templates/autogen.yaml +0 -0
  62. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/cli/templates/claude-code.yaml +0 -0
  63. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/cli/templates/codex-cli.yaml +0 -0
  64. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/cli/templates/cost-cap.yaml +0 -0
  65. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/cli/templates/crewai.yaml +0 -0
  66. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/cli/templates/cursor.yaml +0 -0
  67. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/cli/templates/gemini-cli.yaml +0 -0
  68. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/cli/templates/generic.yaml +0 -0
  69. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/cli/templates/kiro/ide-file-save.kiro.hook +0 -0
  70. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/cli/templates/kiro/ide-pre-tool-use.kiro.hook +0 -0
  71. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/cli/templates/kiro/ide-prompt-submit.kiro.hook +0 -0
  72. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/cli/templates/kiro/kiro.yaml +0 -0
  73. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/cli/templates/langchain.yaml +0 -0
  74. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/cli/templates/mcp.yaml +0 -0
  75. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/cli/templates/rag.yaml +0 -0
  76. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/client.py +0 -0
  77. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/device.py +0 -0
  78. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/enrollment.py +0 -0
  79. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/error_codes.py +0 -0
  80. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/error_codes.yaml +0 -0
  81. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/errors.py +0 -0
  82. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/hitl/__init__.py +0 -0
  83. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/hitl/grant_protocol.py +0 -0
  84. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/hitl/mock.py +0 -0
  85. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/hitl/pending_approval.py +0 -0
  86. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/hitl/secret_leak_guard.py +0 -0
  87. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/hitl/status.py +0 -0
  88. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/hooks/__init__.py +0 -0
  89. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/hooks/tool_output_handler.py +0 -0
  90. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/hosted_policy.py +0 -0
  91. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/integrations/__init__.py +0 -0
  92. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/integrations/anthropic.py +0 -0
  93. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/integrations/autogen.py +0 -0
  94. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/integrations/braintrust.py +0 -0
  95. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/integrations/crewai/__init__.py +0 -0
  96. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/integrations/crewai/agent.py +0 -0
  97. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/integrations/crewai/crew.py +0 -0
  98. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/integrations/crewai/task.py +0 -0
  99. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/integrations/crewai/tool.py +0 -0
  100. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/integrations/google.py +0 -0
  101. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/integrations/google_adk/__init__.py +0 -0
  102. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/integrations/google_adk/agent.py +0 -0
  103. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/integrations/google_adk/tool.py +0 -0
  104. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/integrations/langchain/__init__.py +0 -0
  105. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/integrations/langchain/agent.py +0 -0
  106. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/integrations/langchain/callbacks.py +0 -0
  107. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/integrations/langchain/chain.py +0 -0
  108. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/integrations/langchain/graph.py +0 -0
  109. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/integrations/langchain/modern.py +0 -0
  110. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/integrations/langchain/tool.py +0 -0
  111. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/integrations/langfuse.py +0 -0
  112. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/integrations/litellm.py +0 -0
  113. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/integrations/openai.py +0 -0
  114. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/integrations/pydantic_ai.py +0 -0
  115. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/integrations/vercel_ai.py +0 -0
  116. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/layout_migration.py +0 -0
  117. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/spool/__init__.py +0 -0
  118. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/spool/_compress.py +0 -0
  119. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/spool/_constants.py +0 -0
  120. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/spool/_crc32c.py +0 -0
  121. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/spool/_crypto.py +0 -0
  122. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/spool/_frame.py +0 -0
  123. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/spool/_metrics.py +0 -0
  124. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/spool/_uploader.py +0 -0
  125. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/spool/cz-audit-v1.dict +0 -0
  126. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/tamper.py +0 -0
  127. {controlzero-1.9.1 → controlzero-1.9.3}/controlzero/tracecontext.py +0 -0
  128. {controlzero-1.9.1 → controlzero-1.9.3}/examples/hello_world.py +0 -0
  129. {controlzero-1.9.1 → controlzero-1.9.3}/tests/_fixtures/jcs_args_hash_vectors.json +0 -0
  130. {controlzero-1.9.1 → controlzero-1.9.3}/tests/integrations/__init__.py +0 -0
  131. {controlzero-1.9.1 → controlzero-1.9.3}/tests/integrations/test_google.py +0 -0
  132. {controlzero-1.9.1 → controlzero-1.9.3}/tests/parity/action_aliases.json +0 -0
  133. {controlzero-1.9.1 → controlzero-1.9.3}/tests/spool/__init__.py +0 -0
  134. {controlzero-1.9.1 → controlzero-1.9.3}/tests/spool/conftest.py +0 -0
  135. {controlzero-1.9.1 → controlzero-1.9.3}/tests/spool/test_spool_cli.py +0 -0
  136. {controlzero-1.9.1 → controlzero-1.9.3}/tests/spool/test_spool_concurrency.py +0 -0
  137. {controlzero-1.9.1 → controlzero-1.9.3}/tests/spool/test_spool_conformance.py +0 -0
  138. {controlzero-1.9.1 → controlzero-1.9.3}/tests/spool/test_spool_core.py +0 -0
  139. {controlzero-1.9.1 → controlzero-1.9.3}/tests/spool/test_spool_crash.py +0 -0
  140. {controlzero-1.9.1 → controlzero-1.9.3}/tests/spool/test_spool_diskfull.py +0 -0
  141. {controlzero-1.9.1 → controlzero-1.9.3}/tests/spool/test_spool_transcript_localack.py +0 -0
  142. {controlzero-1.9.1 → controlzero-1.9.3}/tests/spool/test_spool_uploader.py +0 -0
  143. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_action_aliases.py +0 -0
  144. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_action_canonicalization.py +0 -0
  145. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_action_validator_t86.py +0 -0
  146. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_agent_name_env.py +0 -0
  147. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_antigravity_adapter.py +0 -0
  148. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_antigravity_hook_check.py +0 -0
  149. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_antigravity_install.py +0 -0
  150. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_api_key_mask.py +0 -0
  151. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_audit_remote.py +0 -0
  152. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_audit_remote_sdk_version.py +0 -0
  153. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_audit_sink_isolation.py +0 -0
  154. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_bundle_translate.py +0 -0
  155. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_canonical_phase1a.py +0 -0
  156. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_cli_carve_out.py +0 -0
  157. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_cli_debug_bundle.py +0 -0
  158. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_cli_extractor_integration.py +0 -0
  159. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_cli_hook.py +0 -0
  160. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_cli_hosted_refresh.py +0 -0
  161. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_cli_init.py +0 -0
  162. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_cli_init_templates.py +0 -0
  163. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_cli_tail.py +0 -0
  164. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_cli_test.py +0 -0
  165. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_cli_validate.py +0 -0
  166. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_coding_agent_hooks.py +0 -0
  167. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_conditions.py +0 -0
  168. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_conformance.py +0 -0
  169. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_console.py +0 -0
  170. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_credential_hook.py +0 -0
  171. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_device.py +0 -0
  172. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_dlp_scanner.py +0 -0
  173. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_doctor.py +0 -0
  174. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_engine_version_consistency.py +0 -0
  175. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_enrollment.py +0 -0
  176. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_env_dump_438.py +0 -0
  177. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_error_codes.py +0 -0
  178. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_errors_e_codes.py +0 -0
  179. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_fail_closed_eval.py +0 -0
  180. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_glob_matching.py +0 -0
  181. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_hitl_5d_email_install.py +0 -0
  182. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_hitl_6a_cli_flag.py +0 -0
  183. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_hitl_6a_exceptions.py +0 -0
  184. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_hitl_6a_get_secret_hitl.py +0 -0
  185. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_hitl_6a_mock_backend.py +0 -0
  186. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_hitl_6a_pending_approval.py +0 -0
  187. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_hitl_6a_request_approval.py +0 -0
  188. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_hitl_6a_secret_leak_guard.py +0 -0
  189. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_hitl_6a_wait.py +0 -0
  190. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_hitl_conformance.py +0 -0
  191. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_hitl_phase2b_protocol.py +0 -0
  192. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_hitl_validator_keys.py +0 -0
  193. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_hook_extractors.py +0 -0
  194. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_hosted_local_audit_1247.py +0 -0
  195. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_hosts_adapter.py +0 -0
  196. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_hybrid_mode_strict.py +0 -0
  197. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_hybrid_mode_warn.py +0 -0
  198. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_install_hook_command.py +0 -0
  199. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_install_hooks.py +0 -0
  200. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_kiro_adapter.py +0 -0
  201. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_kiro_hook_templates.py +0 -0
  202. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_kiro_install.py +0 -0
  203. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_layout_migration_t101.py +0 -0
  204. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_layout_parity_t102.py +0 -0
  205. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_local_mode_dict.py +0 -0
  206. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_local_mode_file_json.py +0 -0
  207. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_local_mode_file_yaml.py +0 -0
  208. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_log_fallback_stderr.py +0 -0
  209. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_log_options_ignored_hosted.py +0 -0
  210. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_log_rotation.py +0 -0
  211. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_migrate.py +0 -0
  212. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_min_sdk_version_gate.py +0 -0
  213. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_multi_client_per_project_175.py +0 -0
  214. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_no_policy_no_key.py +0 -0
  215. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_package_rename_shim.py +0 -0
  216. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_policy_engine_version_phase1b.py +0 -0
  217. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_policy_freshness.py +0 -0
  218. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_policy_settings.py +0 -0
  219. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_policy_source_audit.py +0 -0
  220. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_quarantine.py +0 -0
  221. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_refresh.py +0 -0
  222. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_secrets.py +0 -0
  223. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_sql_semantic_class.py +0 -0
  224. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_t103_precedence.py +0 -0
  225. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_t104_cache_gc.py +0 -0
  226. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_t108_local_override_audit.py +0 -0
  227. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_t96_single_audit_log.py +0 -0
  228. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_t99_install_prefetch_bundle.py +0 -0
  229. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_tamper.py +0 -0
  230. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_tamper_behavior.py +0 -0
  231. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_tamper_hook.py +0 -0
  232. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_telemetry_consent.py +0 -0
  233. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_tracecontext.py +0 -0
  234. {controlzero-1.9.1 → controlzero-1.9.3}/tests/test_unsafe_int_boundary.py +0 -0
  235. {controlzero-1.9.1 → 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
@@ -29,6 +106,50 @@
29
106
  preserved so the local log still records THAT a rule fired, and the remote
30
107
  sink keeps full fidelity.
31
108
 
109
+ ## 1.9.2 -- 2026-06-16 (durable-by-default + keychain-DEK audit spool, epic gh#1247)
110
+
111
+ ### Changed
112
+
113
+ - **Hosted-mode audit is now durable by default.** When a `Client` is
114
+ constructed with an API key (hosted mode) and `CONTROLZERO_SPOOL` is
115
+ unset, the audit sink now defaults to the **durable encrypted spool**:
116
+ every decision is serialized, encrypted, and fsynced to an append-only
117
+ on-disk WAL **before** any network send, then drained opportunistically
118
+ in a background thread. Audit is no longer lost on a backend outage. An
119
+ explicit `CONTROLZERO_SPOOL` value (including `off`) still wins, and the
120
+ enrolled-machine sink keeps its prior off-by-default. Sink init is
121
+ fail-soft: any open/IO error degrades to the prior in-memory path,
122
+ emits `spool_init_degraded_total`, and never crashes or blocks the
123
+ PreToolUse hook.
124
+
125
+ ### Security
126
+
127
+ - **Spool encryption key (DEK) defaults to the OS keystore.** The DEK now
128
+ lives in the macOS Keychain (a `security` generic-password item) or the
129
+ Linux Secret Service (`secret-tool`/libsecret) by default when one is
130
+ available; the on-disk `spool.key` then holds only a sentinel, so a
131
+ file-read of the spool directory can neither **decrypt** nor **forge**
132
+ spooled audit records (the AES-GCM tag and hash chain are keyed on the
133
+ DEK). Keystore access is strictly **non-interactive** and
134
+ hard-timeout-bounded -- a prompt risk, locked keystore, or missing CLI
135
+ degrades to the legacy 0600 `spool.key` (key hardening never costs audit
136
+ durability and never blocks the hook). A pre-existing on-disk DEK is
137
+ migrated into the keystore on first keystore-enabled open. Force the
138
+ legacy file DEK with `CONTROLZERO_SPOOL_KEYCHAIN=0` or
139
+ `CONTROLZERO_SPOOL_KEYCHAIN_DISABLE=1`; require the keystore with
140
+ `CONTROLZERO_SPOOL_KEYCHAIN=1`.
141
+
142
+ ### Tests
143
+
144
+ - New `tests/spool/test_spool_keychain_dek.py` (keystore round-trip,
145
+ sentinel-on-disk, prompt-risk fallback, file opt-out, file->keystore
146
+ migration) and `tests/spool/test_spool_durable_default_tamper.py`
147
+ (hosted durable default, encrypted-WAL-before-send, **tampered-frame
148
+ rejection surfaced via `spool_tamper_records_total{gcm_auth}`**,
149
+ graceful init-failure degrade, WAL append latency budget). Spool
150
+ isolation is enforced suite-wide so tests never touch the real home dir
151
+ or OS keychain.
152
+
32
153
  ## 1.9.0 -- 2026-06-15 (Antigravity install CLI, epic gh#925)
33
154
 
34
155
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: controlzero
3
- Version: 1.9.1
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.1"
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
@@ -261,26 +261,74 @@ class _SpoolWiringMixin:
261
261
  _spool = None
262
262
  _spool_mode = "off"
263
263
 
264
- def _init_spool(self, stream_key: str) -> None:
264
+ def _init_spool(self, stream_key: str, default_mode: str = "off") -> None:
265
+ """Open the offline audit spool for this sink.
266
+
267
+ ``default_mode`` is the mode used when ``CONTROLZERO_SPOOL`` is
268
+ UNSET. The hosted (API-key) sink passes ``"durable"`` so audit is
269
+ never lost on a backend outage even when the operator has not set
270
+ the env knob -- the founder requirement that hosted-mode audit
271
+ WALs to encrypted disk before send by default. The enrolled sink
272
+ keeps ``"off"`` (no behavior change). An explicit ``CONTROLZERO_SPOOL``
273
+ value ALWAYS wins (operators can force ``off``/``spool_only``).
274
+
275
+ Non-blocking guarantee (founder constraint): this runs inside the
276
+ PreToolUse hook hot path. Every failure mode -- spool import
277
+ error, keystore prompt risk, IO error, ENOSPC -- DEGRADES to the
278
+ prior in-memory behavior and emits ``spool_init_degraded_total``;
279
+ it never crashes or blocks the agent. Spool open itself is
280
+ bounded (one recovery scan + the 200 ms seq.lock budget); it does
281
+ no network IO.
282
+ """
265
283
  self._drain_state_lock = threading.Lock()
266
284
  self._drain_inflight = False
267
285
  self._drain_again = False
268
286
  self._drain_auth_blocked = False
269
287
  self._spool = None
270
288
  self._spool_mode = "off"
271
- # Fast path: flag off AND no spool directory on disk -- skip the
272
- # spool import entirely so the default path stays byte-identical.
273
- mode_raw = (os.environ.get("CONTROLZERO_SPOOL") or "").strip().lower()
289
+ # Fast path: flag off/unset, NO hosted default, AND no spool
290
+ # directory on disk -- skip the spool import entirely so the
291
+ # legacy default path stays byte-identical. When default_mode is
292
+ # durable (hosted), we must NOT take this shortcut: the whole
293
+ # point is to open a durable spool with no env set.
294
+ env_present = os.environ.get("CONTROLZERO_SPOOL")
295
+ mode_raw = (env_present or "").strip().lower()
274
296
  spool_dir = os.path.expanduser(
275
297
  os.environ.get("CONTROLZERO_SPOOL_DIR") or _SPOOL_DEFAULT_DIR)
276
- if mode_raw in ("", "off") and not os.path.isdir(spool_dir):
298
+ # "Unset" means the env var is absent or an empty/whitespace
299
+ # string. An EXPLICIT "off" is a deliberate operator choice and
300
+ # MUST win over the sink default -- it is NOT treated as unset.
301
+ env_unset = (env_present is None) or (mode_raw == "")
302
+ env_off = mode_raw == "off"
303
+ # Effective mode is "off" when: env explicitly off, OR env unset
304
+ # AND this sink's default is off. In that case, with no spool on
305
+ # disk, skip the spool import entirely (byte-identical legacy).
306
+ effective_off = env_off or (env_unset and default_mode in ("", "off"))
307
+ if effective_off and not os.path.isdir(spool_dir):
277
308
  return
278
309
  try:
279
- from controlzero.spool import Spool, get_mode
280
-
281
- self._spool_mode = get_mode()
282
- self._spool = Spool.maybe_open(stream_key)
310
+ from controlzero.spool import Spool, SpoolConfig, get_mode
311
+
312
+ # Resolve the effective mode: an explicit env value (incl.
313
+ # "off") wins; only a truly UNSET/blank env falls back to
314
+ # this sink's default_mode.
315
+ resolved = get_mode() # "off" when unset/blank or unknown
316
+ if env_unset and default_mode not in ("", "off"):
317
+ resolved = default_mode
318
+ cfg = SpoolConfig.from_env()
319
+ cfg.mode = resolved
320
+ self._spool_mode = resolved
321
+ self._spool = Spool.maybe_open(stream_key, config=cfg)
322
+ # If the spool opened but immediately degraded to memory-only
323
+ # (e.g. ENOSPC during the open-time recovery/cleanup), surface
324
+ # it as a metric. The sink still functions -- WAL just buffers
325
+ # in memory until disk frees up -- so this is a warning, not a
326
+ # crash (C15).
327
+ if (self._spool is not None
328
+ and getattr(self._spool, "in_memory_mode", False)):
329
+ self._spool_init_degraded("memory_mode")
283
330
  except Exception as exc: # noqa: BLE001
331
+ self._spool_init_degraded("exception")
284
332
  logger.warning(
285
333
  "controlzero: audit spool unavailable (%s); "
286
334
  "falling back to in-memory buffering",
@@ -289,6 +337,15 @@ class _SpoolWiringMixin:
289
337
  self._spool = None
290
338
  self._spool_mode = "off"
291
339
 
340
+ @staticmethod
341
+ def _spool_init_degraded(reason: str) -> None:
342
+ """Emit the spool-init degrade metric. Never raises."""
343
+ try:
344
+ from controlzero.spool import metrics as _m
345
+ _m.incr("spool_init_degraded_total", reason)
346
+ except Exception: # noqa: BLE001
347
+ pass
348
+
292
349
  @property
293
350
  def _spool_wal(self) -> bool:
294
351
  """True when log() must take the spool-first WAL path."""
@@ -660,8 +717,11 @@ class BearerAuditSink(_SpoolWiringMixin):
660
717
  self._closed = False
661
718
 
662
719
  # Offline audit spool (Phase 2): the stream is keyed by the api
663
- # key fingerprint, exactly the spec's stream identity.
664
- self._init_spool(api_key)
720
+ # key fingerprint, exactly the spec's stream identity. HOSTED
721
+ # mode defaults to durable WAL-to-encrypted-disk so audit is
722
+ # never lost on a backend outage even with no env set (founder
723
+ # requirement). An explicit CONTROLZERO_SPOOL value still wins.
724
+ self._init_spool(api_key, default_mode=_SPOOL_MODE_DURABLE)
665
725
 
666
726
  self._start_flush_timer()
667
727
  # Drain-only rule (plan section 10): an existing non-empty spool
@@ -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:
@@ -203,9 +203,22 @@ def spool_verify(as_json):
203
203
  click.echo("error: spool.key missing; cannot verify", err=True)
204
204
  sys.exit(2)
205
205
  from controlzero.spool import assess_segment, derive_key
206
- from controlzero.spool._state import load_or_create_dek, secure_read
206
+ from controlzero.spool._state import (
207
+ SpoolKeyUnavailable,
208
+ load_or_create_dek,
209
+ secure_read,
210
+ )
207
211
 
208
- dek = load_or_create_dek(root)
212
+ try:
213
+ dek = load_or_create_dek(root)
214
+ except SpoolKeyUnavailable:
215
+ click.echo(
216
+ "error: spool DEK lives in the OS keystore but the keystore is "
217
+ "not readable right now (locked, or run on a different machine). "
218
+ "Unlock the keystore (or run on the originating host) and retry.",
219
+ err=True,
220
+ )
221
+ sys.exit(2)
209
222
 
210
223
  def key_provider(header):
211
224
  return derive_key(dek, header.device_id, header.stream_fp,
@@ -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: