controlzero 1.9.6__tar.gz → 1.9.9__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 (241) hide show
  1. {controlzero-1.9.6 → controlzero-1.9.9}/CHANGELOG.md +83 -0
  2. {controlzero-1.9.6 → controlzero-1.9.9}/PKG-INFO +9 -7
  3. {controlzero-1.9.6 → controlzero-1.9.9}/README.md +5 -3
  4. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/__init__.py +1 -1
  5. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/_internal/bundle.py +48 -5
  6. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/_internal/hook_extractors.py +24 -4
  7. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/_internal/tool_extractors.json +5 -4
  8. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/audit_remote.py +130 -4
  9. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/main.py +52 -16
  10. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/templates/antigravity.yaml +35 -13
  11. {controlzero-1.9.6 → controlzero-1.9.9}/pyproject.toml +9 -4
  12. {controlzero-1.9.6 → controlzero-1.9.9}/tests/spool/test_spool_sink_wiring.py +127 -3
  13. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_antigravity_hook_check.py +8 -5
  14. controlzero-1.9.9/tests/test_antigravity_tool_vocab_1303.py +323 -0
  15. controlzero-1.9.9/tests/test_config_format_parity_1303.py +145 -0
  16. controlzero-1.9.9/tests/test_failopen_1303.py +96 -0
  17. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hosted_policy_e2e.py +7 -5
  18. {controlzero-1.9.6 → controlzero-1.9.9}/.gitignore +0 -0
  19. {controlzero-1.9.6 → controlzero-1.9.9}/Dockerfile.test +0 -0
  20. {controlzero-1.9.6 → controlzero-1.9.9}/LICENSE +0 -0
  21. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/_internal/__init__.py +0 -0
  22. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/_internal/action_aliases.py +0 -0
  23. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/_internal/action_validator.py +0 -0
  24. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/_internal/credential_hook.py +0 -0
  25. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/_internal/credential_scanner.py +0 -0
  26. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/_internal/credentials_data/__init__.py +0 -0
  27. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/_internal/credentials_data/built_in.yaml +0 -0
  28. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/_internal/dlp_scanner.py +0 -0
  29. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/_internal/enforcer.py +0 -0
  30. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/_internal/types.py +0 -0
  31. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/audit_local.py +0 -0
  32. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/canonical.py +0 -0
  33. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/__init__.py +0 -0
  34. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/__main__.py +0 -0
  35. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/_secrets.py +0 -0
  36. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/console.py +0 -0
  37. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/debug_bundle.py +0 -0
  38. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/doctor.py +0 -0
  39. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/hosts/__init__.py +0 -0
  40. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/hosts/antigravity.py +0 -0
  41. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/hosts/base.py +0 -0
  42. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/hosts/claude_code.py +0 -0
  43. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/hosts/codex_cli.py +0 -0
  44. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/hosts/gemini_cli.py +0 -0
  45. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/hosts/kiro.py +0 -0
  46. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/hosts/unknown.py +0 -0
  47. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/kiro_adapter.py +0 -0
  48. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/migrate.py +0 -0
  49. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/spool_cmd.py +0 -0
  50. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/telemetry_consent.py +0 -0
  51. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/templates/antigravity/hooks.json +0 -0
  52. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/templates/autogen.yaml +0 -0
  53. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/templates/claude-code.yaml +0 -0
  54. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/templates/codex-cli.yaml +0 -0
  55. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/templates/cost-cap.yaml +0 -0
  56. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/templates/crewai.yaml +0 -0
  57. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/templates/cursor.yaml +0 -0
  58. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/templates/gemini-cli.yaml +0 -0
  59. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/templates/generic.yaml +0 -0
  60. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/templates/kiro/ide-file-save.kiro.hook +0 -0
  61. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/templates/kiro/ide-pre-tool-use.kiro.hook +0 -0
  62. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/templates/kiro/ide-prompt-submit.kiro.hook +0 -0
  63. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/templates/kiro/kiro.yaml +0 -0
  64. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/templates/langchain.yaml +0 -0
  65. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/templates/mcp.yaml +0 -0
  66. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/templates/rag.yaml +0 -0
  67. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/client.py +0 -0
  68. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/device.py +0 -0
  69. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/enrollment.py +0 -0
  70. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/error_codes.py +0 -0
  71. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/error_codes.yaml +0 -0
  72. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/errors.py +0 -0
  73. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/hitl/__init__.py +0 -0
  74. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/hitl/grant_protocol.py +0 -0
  75. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/hitl/mock.py +0 -0
  76. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/hitl/pending_approval.py +0 -0
  77. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/hitl/secret_leak_guard.py +0 -0
  78. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/hitl/status.py +0 -0
  79. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/hooks/__init__.py +0 -0
  80. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/hooks/tool_output_handler.py +0 -0
  81. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/hosted_policy.py +0 -0
  82. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/__init__.py +0 -0
  83. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/anthropic.py +0 -0
  84. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/autogen.py +0 -0
  85. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/braintrust.py +0 -0
  86. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/crewai/__init__.py +0 -0
  87. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/crewai/agent.py +0 -0
  88. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/crewai/crew.py +0 -0
  89. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/crewai/task.py +0 -0
  90. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/crewai/tool.py +0 -0
  91. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/google.py +0 -0
  92. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/google_adk/__init__.py +0 -0
  93. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/google_adk/agent.py +0 -0
  94. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/google_adk/tool.py +0 -0
  95. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/langchain/__init__.py +0 -0
  96. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/langchain/agent.py +0 -0
  97. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/langchain/callbacks.py +0 -0
  98. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/langchain/chain.py +0 -0
  99. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/langchain/graph.py +0 -0
  100. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/langchain/modern.py +0 -0
  101. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/langchain/tool.py +0 -0
  102. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/langfuse.py +0 -0
  103. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/litellm.py +0 -0
  104. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/openai.py +0 -0
  105. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/pydantic_ai.py +0 -0
  106. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/vercel_ai.py +0 -0
  107. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/layout_migration.py +0 -0
  108. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/policy_loader.py +0 -0
  109. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/spool/__init__.py +0 -0
  110. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/spool/_compress.py +0 -0
  111. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/spool/_constants.py +0 -0
  112. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/spool/_crc32c.py +0 -0
  113. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/spool/_crypto.py +0 -0
  114. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/spool/_frame.py +0 -0
  115. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/spool/_keyring.py +0 -0
  116. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/spool/_metrics.py +0 -0
  117. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/spool/_spool.py +0 -0
  118. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/spool/_state.py +0 -0
  119. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/spool/_uploader.py +0 -0
  120. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/spool/cz-audit-v1.dict +0 -0
  121. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/tamper.py +0 -0
  122. {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/tracecontext.py +0 -0
  123. {controlzero-1.9.6 → controlzero-1.9.9}/examples/hello_world.py +0 -0
  124. {controlzero-1.9.6 → controlzero-1.9.9}/tests/_fixtures/jcs_args_hash_vectors.json +0 -0
  125. {controlzero-1.9.6 → controlzero-1.9.9}/tests/conftest.py +0 -0
  126. {controlzero-1.9.6 → controlzero-1.9.9}/tests/integrations/__init__.py +0 -0
  127. {controlzero-1.9.6 → controlzero-1.9.9}/tests/integrations/test_google.py +0 -0
  128. {controlzero-1.9.6 → controlzero-1.9.9}/tests/parity/action_aliases.json +0 -0
  129. {controlzero-1.9.6 → controlzero-1.9.9}/tests/spool/__init__.py +0 -0
  130. {controlzero-1.9.6 → controlzero-1.9.9}/tests/spool/conftest.py +0 -0
  131. {controlzero-1.9.6 → controlzero-1.9.9}/tests/spool/test_spool_cli.py +0 -0
  132. {controlzero-1.9.6 → controlzero-1.9.9}/tests/spool/test_spool_concurrency.py +0 -0
  133. {controlzero-1.9.6 → controlzero-1.9.9}/tests/spool/test_spool_conformance.py +0 -0
  134. {controlzero-1.9.6 → controlzero-1.9.9}/tests/spool/test_spool_core.py +0 -0
  135. {controlzero-1.9.6 → controlzero-1.9.9}/tests/spool/test_spool_crash.py +0 -0
  136. {controlzero-1.9.6 → controlzero-1.9.9}/tests/spool/test_spool_diskfull.py +0 -0
  137. {controlzero-1.9.6 → controlzero-1.9.9}/tests/spool/test_spool_durable_default_tamper.py +0 -0
  138. {controlzero-1.9.6 → controlzero-1.9.9}/tests/spool/test_spool_keychain_dek.py +0 -0
  139. {controlzero-1.9.6 → controlzero-1.9.9}/tests/spool/test_spool_transcript_localack.py +0 -0
  140. {controlzero-1.9.6 → controlzero-1.9.9}/tests/spool/test_spool_uploader.py +0 -0
  141. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_action_aliases.py +0 -0
  142. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_action_canonicalization.py +0 -0
  143. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_action_validator_t86.py +0 -0
  144. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_agent_name_env.py +0 -0
  145. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_antigravity_adapter.py +0 -0
  146. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_antigravity_ga_blockers_1248.py +0 -0
  147. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_antigravity_install.py +0 -0
  148. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_api_key_mask.py +0 -0
  149. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_audit_remote.py +0 -0
  150. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_audit_remote_sdk_version.py +0 -0
  151. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_audit_sink_isolation.py +0 -0
  152. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_bundle_parser.py +0 -0
  153. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_bundle_translate.py +0 -0
  154. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_canonical_phase1a.py +0 -0
  155. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_cli_carve_out.py +0 -0
  156. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_cli_debug_bundle.py +0 -0
  157. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_cli_extractor_integration.py +0 -0
  158. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_cli_hook.py +0 -0
  159. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_cli_hosted_refresh.py +0 -0
  160. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_cli_init.py +0 -0
  161. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_cli_init_templates.py +0 -0
  162. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_cli_tail.py +0 -0
  163. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_cli_test.py +0 -0
  164. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_cli_validate.py +0 -0
  165. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_coding_agent_hooks.py +0 -0
  166. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_conditions.py +0 -0
  167. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_conformance.py +0 -0
  168. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_console.py +0 -0
  169. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_credential_hook.py +0 -0
  170. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_default_action.py +0 -0
  171. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_device.py +0 -0
  172. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_dlp_scanner.py +0 -0
  173. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_doctor.py +0 -0
  174. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_engine_version_consistency.py +0 -0
  175. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_enrollment.py +0 -0
  176. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_env_dump_438.py +0 -0
  177. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_epic_1247_bryan_acceptance.py +0 -0
  178. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_error_codes.py +0 -0
  179. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_errors_e_codes.py +0 -0
  180. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_fail_closed_eval.py +0 -0
  181. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_glob_matching.py +0 -0
  182. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hitl_5d_email_install.py +0 -0
  183. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hitl_6a_cli_flag.py +0 -0
  184. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hitl_6a_exceptions.py +0 -0
  185. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hitl_6a_get_secret_hitl.py +0 -0
  186. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hitl_6a_mock_backend.py +0 -0
  187. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hitl_6a_pending_approval.py +0 -0
  188. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hitl_6a_request_approval.py +0 -0
  189. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hitl_6a_secret_leak_guard.py +0 -0
  190. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hitl_6a_wait.py +0 -0
  191. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hitl_conformance.py +0 -0
  192. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hitl_phase2b_protocol.py +0 -0
  193. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hitl_reason_codes.py +0 -0
  194. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hitl_validator_keys.py +0 -0
  195. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hook_extractors.py +0 -0
  196. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hosted_local_audit_1247.py +0 -0
  197. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hosts_adapter.py +0 -0
  198. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hybrid_mode_strict.py +0 -0
  199. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hybrid_mode_warn.py +0 -0
  200. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_install_hook_command.py +0 -0
  201. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_install_hooks.py +0 -0
  202. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_kiro_adapter.py +0 -0
  203. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_kiro_cli_e2e.py +0 -0
  204. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_kiro_hook_templates.py +0 -0
  205. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_kiro_install.py +0 -0
  206. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_layout_migration_t101.py +0 -0
  207. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_layout_parity_t102.py +0 -0
  208. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_local_mode_dict.py +0 -0
  209. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_local_mode_file_json.py +0 -0
  210. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_local_mode_file_yaml.py +0 -0
  211. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_log_fallback_stderr.py +0 -0
  212. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_log_options_ignored_hosted.py +0 -0
  213. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_log_rotation.py +0 -0
  214. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_migrate.py +0 -0
  215. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_min_sdk_version_gate.py +0 -0
  216. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_multi_client_per_project_175.py +0 -0
  217. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_no_policy_no_key.py +0 -0
  218. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_observe_mode_1247.py +0 -0
  219. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_package_rename_shim.py +0 -0
  220. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_policy_engine_version_phase1b.py +0 -0
  221. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_policy_freshness.py +0 -0
  222. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_policy_settings.py +0 -0
  223. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_policy_source_audit.py +0 -0
  224. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_quarantine.py +0 -0
  225. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_reason_code.py +0 -0
  226. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_refresh.py +0 -0
  227. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_secrets.py +0 -0
  228. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_sql_semantic_class.py +0 -0
  229. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_synthetic_policy_id_t79.py +0 -0
  230. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_t103_precedence.py +0 -0
  231. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_t104_cache_gc.py +0 -0
  232. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_t108_local_override_audit.py +0 -0
  233. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_t96_single_audit_log.py +0 -0
  234. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_t99_install_prefetch_bundle.py +0 -0
  235. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_tamper.py +0 -0
  236. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_tamper_behavior.py +0 -0
  237. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_tamper_hook.py +0 -0
  238. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_telemetry_consent.py +0 -0
  239. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_tracecontext.py +0 -0
  240. {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_unsafe_int_boundary.py +0 -0
  241. {controlzero-1.9.6 → controlzero-1.9.9}/tools/cz-kiro-adapter +0 -0
@@ -1,5 +1,88 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.9 -- 2026-06-17 (Antigravity tool vocab, JSON+YAML config parity, spool reliability)
4
+
5
+ Follow-ups to the gh#1303 fail-open work plus two correctness fixes.
6
+
7
+ ### Fixed
8
+
9
+ - **Antigravity enforcement (gh#1303 part 2).** Real Antigravity emits
10
+ `run_command` (args under `CommandLine`) / `ListDir` / `view_file` /
11
+ `write_to_file` / `replace_file_content`, but the inbound alias table and the
12
+ dangerous-shell extractor assumed `run_command`/`read_file` with
13
+ `args.command`. Two compounding fail-opens against allow-by-default: a
14
+ canonical `deny: Bash` never matched Antigravity's `run_command`, and
15
+ `rm -rf` inside `CommandLine` was invisible to the argument-level scanner.
16
+ Added inbound aliases and made `Bash` read either `command` or `CommandLine`
17
+ (first non-empty wins, fail-closed fallback). Inbound-only -- the policy
18
+ vocabulary the four working hosts match is unchanged.
19
+ - **CLI hook path now auto-discovers JSON/YAML policy files (gh#67).** All
20
+ three SDKs already parse `.yaml`/`.yml`/`.json` and the in-process `Client`
21
+ auto-discovers all three in the working directory, but the CLI `hook-check`
22
+ resolver searched only `controlzero.yaml`. A project authored as
23
+ `controlzero.json` (no `.yaml`) was enforced by the `Client` yet INVISIBLE to
24
+ the enforcement hook -- it fell through to the global policy / BUNDLE_MISSING.
25
+ `hook-check` now searches `controlzero.{yaml,yml,json}` in the working
26
+ directory and `policy.{yaml,yml,json}` globally (first existing,
27
+ `.yaml` > `.yml` > `.json`), matching the `Client`.
28
+ - **Offline audit spool reliability on Python 3.13 (gh#1315).** The encrypted
29
+ spool needs `zstandard` and `cryptography`. `zstandard` 0.22.0 has no cp313
30
+ wheel, so on Python 3.13 the import could fail, the error was swallowed in
31
+ `BearerAuditSink._init_spool`, and the spool silently never formed -- hosted
32
+ audit fell back to a non-durable in-memory buffer. The `zstandard` floor is
33
+ raised to `>=0.23.0` (first cp313 wheels), the import failure is now logged
34
+ loudly and actionably (naming the missing dependency), and a
35
+ `_spool_unavailable_reason` is recorded for diagnostics.
36
+
37
+ ## 1.9.8 -- 2026-06-17 (P0 enforcement fail-open: degraded/empty bundle, gh#1303)
38
+
39
+ Closes a P0 SECURITY fail-open found via a customer report: a hosted-policy
40
+ customer's destructive tool calls (e.g. `rm -rf`) were intermittently ALLOWED
41
+ across agent surfaces.
42
+
43
+ ### Fixed
44
+
45
+ - **Degraded/partial/stale bundle no longer fails OPEN (gh#1303).** The shared
46
+ decision core (`translate_to_local_policy`) synthesized an allow-all OBSERVE
47
+ rule whenever the translated rule set was empty -- but that fired not only for
48
+ a genuinely-empty project (intended observe, gh#1247) but also for a degraded
49
+ bundle (policies attached yet zero translatable rules) or a missing/malformed
50
+ `policies` key (truncated/stale). A customer who HAS a policy could thus get
51
+ allow-all. The translator now distinguishes a genuinely-empty project (the
52
+ backend ships an explicit empty list) -- which still OBSERVES (gh#1247
53
+ preserved) -- from any other zero-rule outcome, which now FAILS CLOSED (deny)
54
+ via `default_on_missing`. This lives in the shared core, so all five host
55
+ surfaces (Claude Code, Gemini CLI, Codex CLI, Antigravity, Kiro) inherit it.
56
+ - **Unrecognized rule effect now fails closed.** A validly-signed rule carrying
57
+ an unknown/future/typo effect was coerced to `allow` (allow-* for its
58
+ pattern); it now defaults to `deny`.
59
+
60
+ Residual stale-empty-cache replay window (backend bundle provenance + cache
61
+ freshness) and the Antigravity tool-vocabulary gap are tracked in gh#1303 and
62
+ land in follow-up releases.
63
+
64
+ ## 1.9.7 -- 2026-06-16 (hosted audit delivery for short-lived processes, gh#1292)
65
+
66
+ Fixes a P0 found via a customer report: hosted-mode audit rows were written to
67
+ the local `~/.controlzero/audit.log` but never reached the dashboard for
68
+ short-lived processes (PreToolUse hooks, CLI one-shots).
69
+
70
+ ### Fixed
71
+
72
+ - **Remote audit never delivered for short-lived processes (gh#1292).** Since
73
+ 1.9.2 the hosted (`BearerAuditSink`) and enrolled (`RemoteAuditSink`) sinks
74
+ default to the durable encrypted spool, where `log()` WALs the row and an
75
+ opportunistic `daemon=True` thread drains it. A short-lived process exits
76
+ before that thread can finish its HTTPS POST, and `close()` was a fsync-only
77
+ boundary, so the row stayed on local disk and never shipped. The hosted sink
78
+ now hands the drain to a **detached `controlzero spool flush` child** that
79
+ outlives the process (no latency on the agent hot path; the keystore and
80
+ network work happen in the child, never on the close path). The api key
81
+ travels via the child's environment, never on its argv. The enrolled sink
82
+ (opt-in, off the hosted hot path) does a bounded, fail-open in-process drain
83
+ on close instead. Both paths are fail-open: any error leaves the durable WAL
84
+ intact for a later drain. The durable WAL guarantee is unchanged.
85
+
3
86
  ## 1.9.6 -- 2026-06-16 (Antigravity GA-blocker hardening, gh#1248 / epic gh#925)
4
87
 
5
88
  Closes 3 of the prod-readiness GA blockers for the Antigravity (`agy`)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: controlzero
3
- Version: 1.9.6
3
+ Version: 1.9.9
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
@@ -30,7 +30,7 @@ Requires-Dist: pydantic>=2.0.0
30
30
  Requires-Dist: pyyaml>=6.0
31
31
  Requires-Dist: rfc8785<0.2,>=0.1.4
32
32
  Requires-Dist: rich>=13.0.0
33
- Requires-Dist: zstandard>=0.22.0
33
+ Requires-Dist: zstandard>=0.23.0
34
34
  Provides-Extra: anthropic
35
35
  Requires-Dist: anthropic>=0.25.0; extra == 'anthropic'
36
36
  Provides-Extra: dev
@@ -41,13 +41,13 @@ Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
41
41
  Requires-Dist: pytest>=7.0.0; extra == 'dev'
42
42
  Requires-Dist: pyyaml>=6.0; extra == 'dev'
43
43
  Requires-Dist: respx>=0.20.0; extra == 'dev'
44
- Requires-Dist: zstandard>=0.22.0; extra == 'dev'
44
+ Requires-Dist: zstandard>=0.23.0; extra == 'dev'
45
45
  Provides-Extra: google
46
46
  Requires-Dist: google-genai>=0.3.0; extra == 'google'
47
47
  Provides-Extra: hosted
48
48
  Requires-Dist: cryptography>=41.0.0; extra == 'hosted'
49
49
  Requires-Dist: httpx>=0.25.0; extra == 'hosted'
50
- Requires-Dist: zstandard>=0.22.0; extra == 'hosted'
50
+ Requires-Dist: zstandard>=0.23.0; extra == 'hosted'
51
51
  Provides-Extra: openai
52
52
  Requires-Dist: openai>=1.0.0; extra == 'openai'
53
53
  Description-Content-Type: text/markdown
@@ -93,7 +93,7 @@ Your AI agents call tools. Some of those tools should never be called by an
93
93
  agent without a human in the loop. `controlzero` is the policy layer between
94
94
  the model's output and the tool execution. Decisions are fail-closed by default.
95
95
 
96
- You can use it offline with a local YAML file or Python dict. When you want to
96
+ You can use it offline with a local YAML or JSON file or Python dict. When you want to
97
97
  share policies across a team or get a hosted audit dashboard, sign up at
98
98
  [controlzero.ai](https://controlzero.ai) and set `CONTROLZERO_API_KEY`.
99
99
 
@@ -155,8 +155,10 @@ cz = Client(policy_file="./controlzero.yaml")
155
155
  cz = Client()
156
156
  ```
157
157
 
158
- If `./controlzero.yaml` exists in the current directory, it is picked up
159
- automatically. No environment variable needed.
158
+ If a policy file exists in the current directory it is picked up
159
+ automatically -- `controlzero.yaml`, `controlzero.yml`, or `controlzero.json`
160
+ are auto-detected in that order (first existing wins). No environment
161
+ variable needed. The file may be YAML or JSON; both use the identical schema.
160
162
 
161
163
  ## Policy schema
162
164
 
@@ -39,7 +39,7 @@ Your AI agents call tools. Some of those tools should never be called by an
39
39
  agent without a human in the loop. `controlzero` is the policy layer between
40
40
  the model's output and the tool execution. Decisions are fail-closed by default.
41
41
 
42
- You can use it offline with a local YAML file or Python dict. When you want to
42
+ You can use it offline with a local YAML or JSON file or Python dict. When you want to
43
43
  share policies across a team or get a hosted audit dashboard, sign up at
44
44
  [controlzero.ai](https://controlzero.ai) and set `CONTROLZERO_API_KEY`.
45
45
 
@@ -101,8 +101,10 @@ cz = Client(policy_file="./controlzero.yaml")
101
101
  cz = Client()
102
102
  ```
103
103
 
104
- If `./controlzero.yaml` exists in the current directory, it is picked up
105
- automatically. No environment variable needed.
104
+ If a policy file exists in the current directory it is picked up
105
+ automatically -- `controlzero.yaml`, `controlzero.yml`, or `controlzero.json`
106
+ are auto-detected in that order (first existing wins). No environment
107
+ variable needed. The file may be YAML or JSON; both use the identical schema.
106
108
 
107
109
  ## Policy schema
108
110
 
@@ -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.6"
43
+ __version__ = "1.9.9"
44
44
 
45
45
  __all__ = [
46
46
  "Client",
@@ -527,7 +527,13 @@ def translate_to_local_policy(payload: dict) -> dict:
527
527
  if default_on_tamper not in VALID_DEFAULT_ON_TAMPER:
528
528
  default_on_tamper = DEFAULT_BUNDLE_ON_TAMPER
529
529
 
530
- policies = payload.get("policies") or []
530
+ # #1303: keep the RAW policies value to distinguish a genuinely-empty
531
+ # project (the backend ships an explicit empty list `policies: []`) from a
532
+ # DEGRADED/partial bundle (policies attached but zero translatable rules, or
533
+ # a missing/malformed `policies` key). The former is observe (#1247); the
534
+ # latter must fail CLOSED, never observe-allow.
535
+ raw_policies = payload.get("policies")
536
+ policies = raw_policies or []
531
537
  policies = sorted(
532
538
  [p for p in policies if isinstance(p, dict)],
533
539
  key=lambda p: int(p.get("priority", 100)),
@@ -550,6 +556,40 @@ def translate_to_local_policy(payload: dict) -> dict:
550
556
  if translated is not None:
551
557
  flat.append(translated)
552
558
 
559
+ if not flat and not (isinstance(raw_policies, list) and len(raw_policies) == 0):
560
+ # #1303 FAIL-OPEN FIX (the empty-vs-degraded boundary). We have zero
561
+ # translatable rules, but this is NOT a genuinely-empty project: the
562
+ # payload carried attached policies that produced no enforceable rules,
563
+ # OR a missing / non-list `policies` key (truncated / malformed / stale
564
+ # bundle). Treating that as observe would ALLOW EVERY tool call for a
565
+ # customer who HAS a policy -- the reproduced rm-rf fail-open. Fail
566
+ # CLOSED here via default_on_missing (canonical deny), with a distinct
567
+ # reason_code so it is not confused with a genuine empty project. The
568
+ # genuinely-empty observe path (#1247) below is reached ONLY when the
569
+ # backend shipped an explicit empty list (`policies: []`).
570
+ #
571
+ # NOTE: a stale CACHED bundle that legitimately held `policies: []` from
572
+ # when the project WAS empty is NOT caught here (it looks genuinely
573
+ # empty); that residual replay window is closed by bundle provenance +
574
+ # freshness (#1303 part 3 / backend active_policy_count).
575
+ # Reuse the registered BUNDLE_MISSING reason_code / synthetic id (both
576
+ # already in VALID_REASON_CODES / VALID_SYNTHETIC_POLICY_IDS) so no new
577
+ # error-catalog entry is introduced; the human-readable reason names the
578
+ # degraded/partial/stale cause. effect honors default_on_missing (deny).
579
+ flat.append({
580
+ "effect": default_on_missing,
581
+ "action": "*",
582
+ "id": "synthetic:BUNDLE_MISSING",
583
+ "reason": (
584
+ "Your project has attached policies but the resolved bundle "
585
+ "produced zero enforceable rules (a degraded, partial, or stale "
586
+ "bundle). Control Zero is failing CLOSED (deny) rather than "
587
+ "allowing every tool call. Regenerate the policy bundle in the "
588
+ "Control Zero dashboard; contact support if this persists."
589
+ ),
590
+ "reason_code": "BUNDLE_MISSING",
591
+ })
592
+
553
593
  if not flat:
554
594
  # Empty policy set (RESOLVED SUCCESSFULLY, zero translatable
555
595
  # rules): synthetic catch-all rule whose posture is driven by
@@ -711,9 +751,12 @@ def _translate_rule(rule: dict, policy_id: str) -> Optional[dict]:
711
751
  singular (``tool`` / ``pattern`` / ``action`` / ``match.tool``) forms
712
752
  are now supported; plural wins when both are present.
713
753
  """
714
- # Accept several spellings of "effect". Policy engine has four
715
- # canonical effects; anything else is treated as ``allow`` to stay
716
- # fail-safe on forward-compat.
754
+ # Accept several spellings of "effect". Policy engine has four canonical
755
+ # effects; an UNRECOGNIZED effect on a validly-signed rule (typo, future
756
+ # effect, corruption) must fail CLOSED. Coercing it to "allow" (the old
757
+ # behavior) turned an unknown rule into allow-*-for-its-pattern -- a
758
+ # fail-open for a security gate (#1303). A deny here over-denies that one
759
+ # pattern at worst, which is the safe direction.
717
760
  effect_raw = rule.get("effect")
718
761
  if not effect_raw:
719
762
  # Only fall back to rule["action"] for effect if it looks like
@@ -722,7 +765,7 @@ def _translate_rule(rule: dict, policy_id: str) -> Optional[dict]:
722
765
  fallback = rule.get("action")
723
766
  if fallback in ("allow", "deny", "warn", "audit"):
724
767
  effect_raw = fallback
725
- effect = effect_raw if effect_raw in ("allow", "deny", "warn", "audit") else "allow"
768
+ effect = effect_raw if effect_raw in ("allow", "deny", "warn", "audit") else "deny"
726
769
 
727
770
  # Tool pattern resolution order:
728
771
  # 1. plural ``actions`` (list, as emitted by the backend)
@@ -566,7 +566,14 @@ def extract_method(
566
566
  2. If the tool is unknown, method = ``"*"``.
567
567
  3. Read ``args_path`` from the entry. If non-null, ``raw =
568
568
  args[args_path]``; else ``raw = tool_name`` (used by
569
- ``file_read`` / ``file_write``).
569
+ ``file_read`` / ``file_write``). ``args_path`` may be a single
570
+ key (string) or a list of candidate keys, in which case the
571
+ first key that resolves to a non-empty string is used. The list
572
+ form lets one canonical tool read the differently-named command
573
+ argument each host emits -- e.g. the ``Bash`` shell tool reads
574
+ ``command`` (Claude Code / Gemini CLI) or ``CommandLine``
575
+ (Antigravity ``run_command``) so the dangerous-command scanner
576
+ fires regardless of which host sent the call.
570
577
  4. Apply the ``extract`` function. Non-empty result = method.
571
578
  5. Empty result -> ``fallback_method``.
572
579
 
@@ -593,11 +600,24 @@ def extract_method(
593
600
  else:
594
601
  if not isinstance(args, dict):
595
602
  return canonical, fallback
596
- raw = args.get(args_path)
603
+ # ``args_path`` is either a single key (string) or a list of
604
+ # candidate keys. The list form lets one canonical tool read
605
+ # the differently-named command argument each host emits --
606
+ # e.g. ``Bash`` reads ``command`` (Claude Code / Gemini CLI)
607
+ # or ``CommandLine`` (Antigravity ``run_command``). The first
608
+ # key that resolves to a non-empty string wins; if none do,
609
+ # fall back so the no-arg path stays fail-closed.
610
+ candidates = (
611
+ args_path if isinstance(args_path, list) else [args_path]
612
+ )
613
+ raw = None
614
+ for key in candidates:
615
+ value = args.get(key)
616
+ if isinstance(value, str) and value != "":
617
+ raw = value
618
+ break
597
619
  if raw is None:
598
620
  return canonical, fallback
599
- if not isinstance(raw, str):
600
- return canonical, fallback
601
621
 
602
622
  func = _EXTRACTORS.get(extract_name)
603
623
  if func is None:
@@ -9,7 +9,7 @@
9
9
  "aliases": ["sql", "Database", "PostgreSQL", "MySQL", "postgres", "sqlite"]
10
10
  },
11
11
  "Bash": {
12
- "args_path": "command",
12
+ "args_path": ["command", "CommandLine"],
13
13
  "extract": "most_dangerous_shell_command",
14
14
  "fallback_method": "*",
15
15
  "aliases": [
@@ -17,6 +17,7 @@
17
17
  "shell",
18
18
  "ShellTool",
19
19
  "run_shell_command",
20
+ "run_command",
20
21
  "PowerShell",
21
22
  "powershell",
22
23
  "Shell"
@@ -44,19 +45,19 @@
44
45
  "args_path": null,
45
46
  "extract": "tool_name_as_method",
46
47
  "fallback_method": "read",
47
- "aliases": ["read_file", "Read", "ReadFile", "read_many_files"]
48
+ "aliases": ["read_file", "Read", "ReadFile", "read_many_files", "view_file"]
48
49
  },
49
50
  "file_write": {
50
51
  "args_path": null,
51
52
  "extract": "tool_name_as_method",
52
53
  "fallback_method": "write",
53
- "aliases": ["write_file", "Write", "WriteFile", "edit_file", "Edit", "replace", "apply_patch"]
54
+ "aliases": ["write_file", "Write", "WriteFile", "edit_file", "Edit", "replace", "apply_patch", "write_to_file", "replace_file_content"]
54
55
  },
55
56
  "file_search": {
56
57
  "args_path": null,
57
58
  "extract": "tool_name_as_method",
58
59
  "fallback_method": "search",
59
- "aliases": ["Grep", "grep_search", "Glob", "glob"]
60
+ "aliases": ["Grep", "grep_search", "Glob", "glob", "ListDir"]
60
61
  },
61
62
  "task": {
62
63
  "args_path": null,
@@ -24,6 +24,7 @@ import json
24
24
  import logging
25
25
  import os
26
26
  import platform
27
+ import sys
27
28
  import threading
28
29
  import uuid
29
30
  from datetime import datetime, timezone
@@ -110,6 +111,15 @@ _SPOOL_DEFAULT_DIR = "~/.controlzero/spool"
110
111
  # processes.
111
112
  _SPOOL_HOOK_BUDGET_S = 2.0
112
113
  _SPOOL_TIMER_BUDGET_S = 25.0
114
+ # close() handoff budgets (#1292). A short-lived process (PreToolUse hook /
115
+ # CLI one-shot) exits before the daemon drain ships its spooled rows, and
116
+ # close() was a fsync-only boundary -- so the rows never reached the backend.
117
+ # The hosted (Bearer) sink now hands the drain to a DETACHED uploader child
118
+ # that outlives this process (no latency on the agent hot path, keystore +
119
+ # network work happen off the close path). The enrolled sink -- opt-in, off
120
+ # the hosted hot path -- does a bounded, fail-open in-process drain instead.
121
+ _DETACHED_DRAIN_BUDGET_S = 30.0
122
+ _CLOSE_DRAIN_BUDGET_S = 2.0
113
123
 
114
124
 
115
125
  def _build_wire_entry(
@@ -284,8 +294,18 @@ class _SpoolWiringMixin:
284
294
  self._drain_inflight = False
285
295
  self._drain_again = False
286
296
  self._drain_auth_blocked = False
297
+ # #1292: whether this process WAL-logged at least one entry (gates the
298
+ # close-time handoff so a pure-read process never spawns a drainer) and
299
+ # whether the detached drainer was already spawned (coalesce to one).
300
+ self._spool_logged = False
301
+ self._detached_drain_spawned = False
287
302
  self._spool = None
288
303
  self._spool_mode = "off"
304
+ # #1315: when the durable spool cannot form (e.g. zstandard/cryptography
305
+ # missing or binary-incompatible on this interpreter), record WHY so
306
+ # diagnostics (`controlzero doctor`) can surface it instead of the user
307
+ # silently running on the ephemeral in-memory buffer. None == healthy.
308
+ self._spool_unavailable_reason = None
289
309
  # Fast path: flag off/unset, NO hosted default, AND no spool
290
310
  # directory on disk -- skip the spool import entirely so the
291
311
  # legacy default path stays byte-identical. When default_mode is
@@ -327,8 +347,29 @@ class _SpoolWiringMixin:
327
347
  if (self._spool is not None
328
348
  and getattr(self._spool, "in_memory_mode", False)):
329
349
  self._spool_init_degraded("memory_mode")
350
+ except ImportError as exc:
351
+ # #1315: the durable spool needs `zstandard` (compression) and
352
+ # `cryptography` (AES-GCM). A missing or binary-incompatible wheel
353
+ # (classically zstandard 0.22.0 on Python 3.13 -- no cp313 wheel)
354
+ # raises here BEFORE the spool dir is created. Name the cause loudly
355
+ # so the operator can fix the dependency instead of unknowingly
356
+ # running on the non-durable in-memory buffer. The dependency floor
357
+ # was raised (zstandard>=0.23.0) to prevent the common case.
358
+ self._spool_init_degraded("import_error")
359
+ self._spool_unavailable_reason = f"import_error: {exc}"
360
+ logger.warning(
361
+ "controlzero: audit spool dependencies unavailable (%s); "
362
+ "the encrypted offline spool is DISABLED and audit falls back "
363
+ "to in-memory buffering (durability reduced). Ensure "
364
+ "'zstandard>=0.23.0' and 'cryptography' are installed for this "
365
+ "Python (pip install -U controlzero).",
366
+ exc,
367
+ )
368
+ self._spool = None
369
+ self._spool_mode = "off"
330
370
  except Exception as exc: # noqa: BLE001
331
371
  self._spool_init_degraded("exception")
372
+ self._spool_unavailable_reason = f"exception: {exc}"
332
373
  logger.warning(
333
374
  "controlzero: audit spool unavailable (%s); "
334
375
  "falling back to in-memory buffering",
@@ -365,6 +406,10 @@ class _SpoolWiringMixin:
365
406
  except Exception as exc: # noqa: BLE001
366
407
  logger.warning("controlzero: spool append failed (%s)", exc)
367
408
  return False
409
+ # This process has durably WAL-logged at least one entry; close() may
410
+ # need to hand its drain to a detached uploader so a short-lived
411
+ # process does not strand it (#1292).
412
+ self._spool_logged = True
368
413
  if self._spool_mode == _SPOOL_MODE_DURABLE:
369
414
  self._drain_async(budget_s=_SPOOL_HOOK_BUDGET_S)
370
415
  return True
@@ -377,6 +422,73 @@ class _SpoolWiringMixin:
377
422
  def _spool_drain_orphans(self) -> bool:
378
423
  return False
379
424
 
425
+ def _spawn_detached_drain(self) -> None:
426
+ """Hand the spool drain to a DETACHED child that outlives this
427
+ process, then return immediately (#1292).
428
+
429
+ A short-lived hosted process (PreToolUse hook / CLI one-shot) exits
430
+ before the in-process daemon drain can finish its HTTPS POST, so
431
+ durably-spooled rows never reach the backend -- close() was a
432
+ fsync-only boundary that stranded them. Spawning ``controlzero spool
433
+ flush`` in a new session lets the upload complete after this process
434
+ is gone: no latency on the agent hot path, and the keystore + network
435
+ work happen in the child, never on this close path (the founder
436
+ non-blocking-hook and no-keystore-on-close constraints).
437
+
438
+ Only the Bearer (hosted api-key) sink can be drained this way --
439
+ ``cz spool flush`` authenticates with the api key, which it reads from
440
+ the environment (never argv, so the key never lands in ``ps``). The
441
+ enrolled sink has no such entrypoint and uses an in-process close
442
+ drain instead.
443
+
444
+ Fail-open: any error leaves the durable WAL intact for a later drain
445
+ (next long-lived process, the timer, or an explicit ``cz spool
446
+ flush``)."""
447
+ if getattr(self, "_detached_drain_spawned", False):
448
+ return
449
+ if self._spool is None or not getattr(self, "_spool_logged", False):
450
+ return
451
+ api_key = getattr(self, "_api_key", None)
452
+ if not api_key:
453
+ return
454
+ try:
455
+ import subprocess
456
+
457
+ env = dict(os.environ)
458
+ env["CONTROLZERO_API_KEY"] = api_key
459
+ api_url = getattr(self, "_api_url", None)
460
+ if api_url:
461
+ env["CONTROLZERO_API_URL"] = api_url
462
+ popen_kwargs = dict(
463
+ stdin=subprocess.DEVNULL,
464
+ stdout=subprocess.DEVNULL,
465
+ stderr=subprocess.DEVNULL,
466
+ env=env,
467
+ close_fds=True,
468
+ )
469
+ if os.name == "nt": # detach on Windows
470
+ popen_kwargs["creationflags"] = (
471
+ getattr(subprocess, "DETACHED_PROCESS", 0)
472
+ | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
473
+ )
474
+ else: # POSIX: escape the process group so it outlives the parent
475
+ popen_kwargs["start_new_session"] = True
476
+ subprocess.Popen( # noqa: S603 -- fixed argv, no shell, no user input
477
+ [
478
+ sys.executable, "-m", "controlzero.cli.main",
479
+ "spool", "flush",
480
+ "--timeout", str(_DETACHED_DRAIN_BUDGET_S),
481
+ ],
482
+ **popen_kwargs,
483
+ )
484
+ self._detached_drain_spawned = True
485
+ except Exception as exc: # noqa: BLE001 -- never block/crash the host
486
+ logger.warning(
487
+ "controlzero: detached spool drain spawn failed (%s); audit "
488
+ "rows remain durable in the spool for a later drain",
489
+ exc,
490
+ )
491
+
380
492
  def _drain_async(self, budget_s: float = _SPOOL_HOOK_BUDGET_S) -> None:
381
493
  """One opportunistic non-blocking drain in a daemon thread.
382
494
  Coalesced: at most one drain in flight per sink instance; an
@@ -506,8 +618,17 @@ class RemoteAuditSink(_SpoolWiringMixin):
506
618
  self._closed = True
507
619
  self._cancel_flush_timer()
508
620
  if self._spool_wal:
509
- # Spool mode: every entry is already durable on disk; close
510
- # is a final fsync boundary, NEVER a blocking network flush.
621
+ # DURABLE mode (enrolled, opt-in, off the hosted hot path) ships
622
+ # synchronously with a bounded, fail-open budget before the final
623
+ # fsync so a short-lived process does not strand its rows (#1292).
624
+ # spool_only is replay-only and must NEVER live-send on close.
625
+ if (self._spool_mode == _SPOOL_MODE_DURABLE
626
+ and getattr(self, "_spool_logged", False)):
627
+ try:
628
+ self._drain_once(blocking=True,
629
+ budget_s=_CLOSE_DRAIN_BUDGET_S)
630
+ except Exception: # noqa: BLE001 -- fail-open; WAL preserved
631
+ pass
511
632
  self._spool_close()
512
633
  return
513
634
  # Synchronous flush -- we are shutting down
@@ -748,8 +869,13 @@ class BearerAuditSink(_SpoolWiringMixin):
748
869
  self._closed = True
749
870
  self._cancel_flush_timer()
750
871
  if self._spool_wal:
751
- # Spool mode: entries are already durable; close is a final
752
- # fsync boundary, NEVER a blocking network flush.
872
+ # DURABLE mode hands the drain to a DETACHED child that outlives
873
+ # this (possibly short-lived) process so its rows are delivered;
874
+ # close() makes NO in-process network call -- the child does the
875
+ # keystore + network work off this hot path (#1292). spool_only is
876
+ # replay-only and must NEVER live-send on close -- only fsync.
877
+ if self._spool_mode == _SPOOL_MODE_DURABLE:
878
+ self._spawn_detached_drain()
753
879
  self._spool_close()
754
880
  return
755
881
  self._flush_sync()
@@ -50,11 +50,47 @@ DEFAULT_TEMPLATES = [
50
50
  ]
51
51
 
52
52
  # Where the global policy lives when controlzero is used as a Claude Code hook.
53
- # Per-project ./controlzero.yaml takes precedence (the SDK already does this).
53
+ # Per-project ./controlzero.{yaml,yml,json} takes precedence (the SDK already
54
+ # does this).
54
55
  GLOBAL_POLICY_DIR = Path.home() / ".controlzero"
55
56
  GLOBAL_POLICY_PATH = GLOBAL_POLICY_DIR / "policy.yaml"
56
57
  GLOBAL_POLICY_SIG_PATH = GLOBAL_POLICY_DIR / "policy.yaml.sig"
57
58
  GLOBAL_TAMPER_KEY_PATH = GLOBAL_POLICY_DIR / "tamper.key"
59
+
60
+ # Policy-file extensions auto-discovered in the hook resolution order, in
61
+ # precedence order. The local policy loader accepts all three (YAML and JSON
62
+ # -- see ``policy_loader._load_from_file``) and the in-process ``Client``
63
+ # already auto-discovers all three in cwd; the CLI hook path must search the
64
+ # same set so a project authored as ``controlzero.json`` is enforced by the
65
+ # hook exactly like ``controlzero.yaml`` (else it falls through to the global
66
+ # policy / BUNDLE_MISSING -- a JSON-config user would otherwise be invisible
67
+ # to enforcement). Keep this list in sync with ``client._resolve_local_source``.
68
+ _POLICY_FILE_EXTS = (".yaml", ".yml", ".json")
69
+
70
+
71
+ def _first_existing_policy(directory: Path, basename: str) -> Optional[Path]:
72
+ """Return the first existing ``<directory>/<basename><ext>`` for ``ext`` in
73
+ :data:`_POLICY_FILE_EXTS` (``.yaml`` -> ``.yml`` -> ``.json``), else None."""
74
+ for ext in _POLICY_FILE_EXTS:
75
+ candidate = directory / f"{basename}{ext}"
76
+ if candidate.exists():
77
+ return candidate
78
+ return None
79
+
80
+
81
+ def _first_existing_variant(base: Path) -> Optional[Path]:
82
+ """Return the first existing ``base`` with each policy extension swapped in
83
+ (``.yaml`` -> ``.yml`` -> ``.json``), else None.
84
+
85
+ Anchored on ``base`` (not its directory) so the global lookup honors a
86
+ monkeypatched ``GLOBAL_POLICY_PATH`` -- the established test contract
87
+ (e.g. ``test_install_hooks`` sets ``GLOBAL_POLICY_PATH`` to a non-existent
88
+ file to assert the missing-policy path)."""
89
+ for ext in _POLICY_FILE_EXTS:
90
+ candidate = base.with_suffix(ext)
91
+ if candidate.exists():
92
+ return candidate
93
+ return None
58
94
  GLOBAL_AUDIT_PATH = GLOBAL_POLICY_DIR / "audit.log"
59
95
  # Where the hook-check CLI logs surface-level events that don't flow
60
96
  # through the policy evaluator -- today just the unenrolled first-run
@@ -1349,20 +1385,17 @@ def sign_policy(policy: Optional[str], verify_only: bool):
1349
1385
 
1350
1386
 
1351
1387
  def _has_resolvable_policy_file() -> bool:
1352
- """True when a ``./controlzero.yaml`` or ``~/.controlzero/policy.yaml``
1353
- exists and looks loadable.
1388
+ """True when a ``./controlzero.{yaml,yml,json}`` or
1389
+ ``~/.controlzero/policy.{yaml,yml,json}`` exists and looks loadable.
1354
1390
 
1355
1391
  Scoped to the carve-out logic: "do we already have a policy in
1356
1392
  the standard search paths?" If yes, the user is past first-run
1357
1393
  and the carve-out banner is noise. Mirrors the happy-path search
1358
1394
  order in :func:`_resolve_hook_policy` so the two stay in sync.
1359
1395
  """
1360
- cwd_policy = Path.cwd() / "controlzero.yaml"
1361
- if cwd_policy.exists():
1362
- return True
1363
- if GLOBAL_POLICY_PATH.exists():
1396
+ if _first_existing_policy(Path.cwd(), "controlzero") is not None:
1364
1397
  return True
1365
- return False
1398
+ return _first_existing_variant(GLOBAL_POLICY_PATH) is not None
1366
1399
 
1367
1400
 
1368
1401
  def _is_unenrolled_first_run() -> bool:
@@ -1783,19 +1816,22 @@ def _maybe_refresh_policy(
1783
1816
  def _resolve_hook_policy(explicit: Optional[str]) -> Optional[Path]:
1784
1817
  """Find a policy file using the hook-time resolution order:
1785
1818
 
1786
- 1. Explicit --policy argument
1787
- 2. ./controlzero.yaml (per-project)
1788
- 3. ~/.controlzero/policy.yaml (global)
1819
+ 1. Explicit --policy argument (any extension; the loader dispatches on it)
1820
+ 2. ./controlzero.{yaml,yml,json} (per-project, first existing)
1821
+ 3. ~/.controlzero/policy.{yaml,yml,json} (global, first existing)
1822
+
1823
+ The per-project and global searches probe all three extensions so a
1824
+ project authored as ``controlzero.json`` (or ``.yml``) is enforced by the
1825
+ hook identically to ``controlzero.yaml`` -- matching the in-process
1826
+ ``Client`` (:func:`client._resolve_local_source`).
1789
1827
  """
1790
1828
  if explicit:
1791
1829
  p = Path(explicit)
1792
1830
  return p if p.exists() else None
1793
- cwd = Path.cwd() / "controlzero.yaml"
1794
- if cwd.exists():
1831
+ cwd = _first_existing_policy(Path.cwd(), "controlzero")
1832
+ if cwd is not None:
1795
1833
  return cwd
1796
- if GLOBAL_POLICY_PATH.exists():
1797
- return GLOBAL_POLICY_PATH
1798
- return None
1834
+ return _first_existing_variant(GLOBAL_POLICY_PATH)
1799
1835
 
1800
1836
 
1801
1837
  def _policy_has_deny_rules(policy_path: Path) -> bool: