controlzero 1.9.1__tar.gz → 1.9.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. {controlzero-1.9.1 → controlzero-1.9.2}/CHANGELOG.md +44 -0
  2. {controlzero-1.9.1 → controlzero-1.9.2}/PKG-INFO +1 -1
  3. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/__init__.py +1 -1
  4. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/audit_remote.py +71 -11
  5. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/spool_cmd.py +15 -2
  6. controlzero-1.9.2/controlzero/spool/_keyring.py +311 -0
  7. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/spool/_spool.py +38 -3
  8. controlzero-1.9.2/controlzero/spool/_state.py +356 -0
  9. {controlzero-1.9.1 → controlzero-1.9.2}/pyproject.toml +1 -1
  10. {controlzero-1.9.1 → controlzero-1.9.2}/tests/conftest.py +24 -0
  11. controlzero-1.9.2/tests/spool/test_spool_durable_default_tamper.py +354 -0
  12. controlzero-1.9.2/tests/spool/test_spool_keychain_dek.py +309 -0
  13. {controlzero-1.9.1 → controlzero-1.9.2}/tests/spool/test_spool_sink_wiring.py +55 -6
  14. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hosted_policy_e2e.py +16 -3
  15. controlzero-1.9.1/controlzero/spool/_state.py +0 -154
  16. {controlzero-1.9.1 → controlzero-1.9.2}/.gitignore +0 -0
  17. {controlzero-1.9.1 → controlzero-1.9.2}/Dockerfile.test +0 -0
  18. {controlzero-1.9.1 → controlzero-1.9.2}/LICENSE +0 -0
  19. {controlzero-1.9.1 → controlzero-1.9.2}/README.md +0 -0
  20. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/_internal/__init__.py +0 -0
  21. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/_internal/action_aliases.py +0 -0
  22. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/_internal/action_validator.py +0 -0
  23. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/_internal/bundle.py +0 -0
  24. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/_internal/credential_hook.py +0 -0
  25. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/_internal/credential_scanner.py +0 -0
  26. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/_internal/credentials_data/__init__.py +0 -0
  27. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/_internal/credentials_data/built_in.yaml +0 -0
  28. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/_internal/dlp_scanner.py +0 -0
  29. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/_internal/enforcer.py +0 -0
  30. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/_internal/hook_extractors.py +0 -0
  31. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/_internal/tool_extractors.json +0 -0
  32. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/_internal/types.py +0 -0
  33. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/audit_local.py +0 -0
  34. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/canonical.py +0 -0
  35. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/__init__.py +0 -0
  36. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/_secrets.py +0 -0
  37. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/console.py +0 -0
  38. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/debug_bundle.py +0 -0
  39. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/doctor.py +0 -0
  40. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/hosts/__init__.py +0 -0
  41. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/hosts/antigravity.py +0 -0
  42. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/hosts/base.py +0 -0
  43. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/hosts/claude_code.py +0 -0
  44. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/hosts/codex_cli.py +0 -0
  45. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/hosts/gemini_cli.py +0 -0
  46. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/hosts/kiro.py +0 -0
  47. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/hosts/unknown.py +0 -0
  48. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/kiro_adapter.py +0 -0
  49. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/main.py +0 -0
  50. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/migrate.py +0 -0
  51. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/telemetry_consent.py +0 -0
  52. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/templates/antigravity/hooks.json +0 -0
  53. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/templates/antigravity.yaml +0 -0
  54. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/templates/autogen.yaml +0 -0
  55. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/templates/claude-code.yaml +0 -0
  56. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/templates/codex-cli.yaml +0 -0
  57. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/templates/cost-cap.yaml +0 -0
  58. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/templates/crewai.yaml +0 -0
  59. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/templates/cursor.yaml +0 -0
  60. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/templates/gemini-cli.yaml +0 -0
  61. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/templates/generic.yaml +0 -0
  62. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/templates/kiro/ide-file-save.kiro.hook +0 -0
  63. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/templates/kiro/ide-pre-tool-use.kiro.hook +0 -0
  64. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/templates/kiro/ide-prompt-submit.kiro.hook +0 -0
  65. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/templates/kiro/kiro.yaml +0 -0
  66. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/templates/langchain.yaml +0 -0
  67. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/templates/mcp.yaml +0 -0
  68. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/templates/rag.yaml +0 -0
  69. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/client.py +0 -0
  70. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/device.py +0 -0
  71. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/enrollment.py +0 -0
  72. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/error_codes.py +0 -0
  73. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/error_codes.yaml +0 -0
  74. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/errors.py +0 -0
  75. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/hitl/__init__.py +0 -0
  76. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/hitl/grant_protocol.py +0 -0
  77. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/hitl/mock.py +0 -0
  78. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/hitl/pending_approval.py +0 -0
  79. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/hitl/secret_leak_guard.py +0 -0
  80. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/hitl/status.py +0 -0
  81. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/hooks/__init__.py +0 -0
  82. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/hooks/tool_output_handler.py +0 -0
  83. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/hosted_policy.py +0 -0
  84. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/__init__.py +0 -0
  85. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/anthropic.py +0 -0
  86. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/autogen.py +0 -0
  87. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/braintrust.py +0 -0
  88. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/crewai/__init__.py +0 -0
  89. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/crewai/agent.py +0 -0
  90. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/crewai/crew.py +0 -0
  91. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/crewai/task.py +0 -0
  92. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/crewai/tool.py +0 -0
  93. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/google.py +0 -0
  94. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/google_adk/__init__.py +0 -0
  95. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/google_adk/agent.py +0 -0
  96. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/google_adk/tool.py +0 -0
  97. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/langchain/__init__.py +0 -0
  98. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/langchain/agent.py +0 -0
  99. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/langchain/callbacks.py +0 -0
  100. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/langchain/chain.py +0 -0
  101. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/langchain/graph.py +0 -0
  102. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/langchain/modern.py +0 -0
  103. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/langchain/tool.py +0 -0
  104. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/langfuse.py +0 -0
  105. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/litellm.py +0 -0
  106. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/openai.py +0 -0
  107. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/pydantic_ai.py +0 -0
  108. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/vercel_ai.py +0 -0
  109. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/layout_migration.py +0 -0
  110. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/policy_loader.py +0 -0
  111. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/spool/__init__.py +0 -0
  112. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/spool/_compress.py +0 -0
  113. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/spool/_constants.py +0 -0
  114. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/spool/_crc32c.py +0 -0
  115. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/spool/_crypto.py +0 -0
  116. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/spool/_frame.py +0 -0
  117. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/spool/_metrics.py +0 -0
  118. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/spool/_uploader.py +0 -0
  119. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/spool/cz-audit-v1.dict +0 -0
  120. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/tamper.py +0 -0
  121. {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/tracecontext.py +0 -0
  122. {controlzero-1.9.1 → controlzero-1.9.2}/examples/hello_world.py +0 -0
  123. {controlzero-1.9.1 → controlzero-1.9.2}/tests/_fixtures/jcs_args_hash_vectors.json +0 -0
  124. {controlzero-1.9.1 → controlzero-1.9.2}/tests/integrations/__init__.py +0 -0
  125. {controlzero-1.9.1 → controlzero-1.9.2}/tests/integrations/test_google.py +0 -0
  126. {controlzero-1.9.1 → controlzero-1.9.2}/tests/parity/action_aliases.json +0 -0
  127. {controlzero-1.9.1 → controlzero-1.9.2}/tests/spool/__init__.py +0 -0
  128. {controlzero-1.9.1 → controlzero-1.9.2}/tests/spool/conftest.py +0 -0
  129. {controlzero-1.9.1 → controlzero-1.9.2}/tests/spool/test_spool_cli.py +0 -0
  130. {controlzero-1.9.1 → controlzero-1.9.2}/tests/spool/test_spool_concurrency.py +0 -0
  131. {controlzero-1.9.1 → controlzero-1.9.2}/tests/spool/test_spool_conformance.py +0 -0
  132. {controlzero-1.9.1 → controlzero-1.9.2}/tests/spool/test_spool_core.py +0 -0
  133. {controlzero-1.9.1 → controlzero-1.9.2}/tests/spool/test_spool_crash.py +0 -0
  134. {controlzero-1.9.1 → controlzero-1.9.2}/tests/spool/test_spool_diskfull.py +0 -0
  135. {controlzero-1.9.1 → controlzero-1.9.2}/tests/spool/test_spool_transcript_localack.py +0 -0
  136. {controlzero-1.9.1 → controlzero-1.9.2}/tests/spool/test_spool_uploader.py +0 -0
  137. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_action_aliases.py +0 -0
  138. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_action_canonicalization.py +0 -0
  139. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_action_validator_t86.py +0 -0
  140. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_agent_name_env.py +0 -0
  141. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_antigravity_adapter.py +0 -0
  142. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_antigravity_hook_check.py +0 -0
  143. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_antigravity_install.py +0 -0
  144. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_api_key_mask.py +0 -0
  145. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_audit_remote.py +0 -0
  146. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_audit_remote_sdk_version.py +0 -0
  147. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_audit_sink_isolation.py +0 -0
  148. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_bundle_parser.py +0 -0
  149. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_bundle_translate.py +0 -0
  150. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_canonical_phase1a.py +0 -0
  151. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_cli_carve_out.py +0 -0
  152. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_cli_debug_bundle.py +0 -0
  153. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_cli_extractor_integration.py +0 -0
  154. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_cli_hook.py +0 -0
  155. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_cli_hosted_refresh.py +0 -0
  156. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_cli_init.py +0 -0
  157. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_cli_init_templates.py +0 -0
  158. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_cli_tail.py +0 -0
  159. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_cli_test.py +0 -0
  160. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_cli_validate.py +0 -0
  161. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_coding_agent_hooks.py +0 -0
  162. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_conditions.py +0 -0
  163. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_conformance.py +0 -0
  164. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_console.py +0 -0
  165. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_credential_hook.py +0 -0
  166. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_default_action.py +0 -0
  167. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_device.py +0 -0
  168. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_dlp_scanner.py +0 -0
  169. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_doctor.py +0 -0
  170. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_engine_version_consistency.py +0 -0
  171. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_enrollment.py +0 -0
  172. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_env_dump_438.py +0 -0
  173. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_error_codes.py +0 -0
  174. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_errors_e_codes.py +0 -0
  175. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_fail_closed_eval.py +0 -0
  176. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_glob_matching.py +0 -0
  177. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hitl_5d_email_install.py +0 -0
  178. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hitl_6a_cli_flag.py +0 -0
  179. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hitl_6a_exceptions.py +0 -0
  180. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hitl_6a_get_secret_hitl.py +0 -0
  181. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hitl_6a_mock_backend.py +0 -0
  182. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hitl_6a_pending_approval.py +0 -0
  183. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hitl_6a_request_approval.py +0 -0
  184. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hitl_6a_secret_leak_guard.py +0 -0
  185. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hitl_6a_wait.py +0 -0
  186. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hitl_conformance.py +0 -0
  187. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hitl_phase2b_protocol.py +0 -0
  188. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hitl_reason_codes.py +0 -0
  189. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hitl_validator_keys.py +0 -0
  190. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hook_extractors.py +0 -0
  191. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hosted_local_audit_1247.py +0 -0
  192. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hosts_adapter.py +0 -0
  193. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hybrid_mode_strict.py +0 -0
  194. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hybrid_mode_warn.py +0 -0
  195. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_install_hook_command.py +0 -0
  196. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_install_hooks.py +0 -0
  197. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_kiro_adapter.py +0 -0
  198. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_kiro_hook_templates.py +0 -0
  199. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_kiro_install.py +0 -0
  200. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_layout_migration_t101.py +0 -0
  201. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_layout_parity_t102.py +0 -0
  202. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_local_mode_dict.py +0 -0
  203. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_local_mode_file_json.py +0 -0
  204. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_local_mode_file_yaml.py +0 -0
  205. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_log_fallback_stderr.py +0 -0
  206. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_log_options_ignored_hosted.py +0 -0
  207. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_log_rotation.py +0 -0
  208. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_migrate.py +0 -0
  209. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_min_sdk_version_gate.py +0 -0
  210. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_multi_client_per_project_175.py +0 -0
  211. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_no_policy_no_key.py +0 -0
  212. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_package_rename_shim.py +0 -0
  213. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_policy_engine_version_phase1b.py +0 -0
  214. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_policy_freshness.py +0 -0
  215. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_policy_settings.py +0 -0
  216. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_policy_source_audit.py +0 -0
  217. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_quarantine.py +0 -0
  218. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_reason_code.py +0 -0
  219. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_refresh.py +0 -0
  220. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_secrets.py +0 -0
  221. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_sql_semantic_class.py +0 -0
  222. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_synthetic_policy_id_t79.py +0 -0
  223. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_t103_precedence.py +0 -0
  224. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_t104_cache_gc.py +0 -0
  225. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_t108_local_override_audit.py +0 -0
  226. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_t96_single_audit_log.py +0 -0
  227. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_t99_install_prefetch_bundle.py +0 -0
  228. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_tamper.py +0 -0
  229. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_tamper_behavior.py +0 -0
  230. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_tamper_hook.py +0 -0
  231. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_telemetry_consent.py +0 -0
  232. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_tracecontext.py +0 -0
  233. {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_unsafe_int_boundary.py +0 -0
  234. {controlzero-1.9.1 → controlzero-1.9.2}/tools/cz-kiro-adapter +0 -0
@@ -29,6 +29,50 @@
29
29
  preserved so the local log still records THAT a rule fired, and the remote
30
30
  sink keeps full fidelity.
31
31
 
32
+ ## 1.9.2 -- 2026-06-16 (durable-by-default + keychain-DEK audit spool, epic gh#1247)
33
+
34
+ ### Changed
35
+
36
+ - **Hosted-mode audit is now durable by default.** When a `Client` is
37
+ constructed with an API key (hosted mode) and `CONTROLZERO_SPOOL` is
38
+ unset, the audit sink now defaults to the **durable encrypted spool**:
39
+ every decision is serialized, encrypted, and fsynced to an append-only
40
+ on-disk WAL **before** any network send, then drained opportunistically
41
+ in a background thread. Audit is no longer lost on a backend outage. An
42
+ explicit `CONTROLZERO_SPOOL` value (including `off`) still wins, and the
43
+ enrolled-machine sink keeps its prior off-by-default. Sink init is
44
+ fail-soft: any open/IO error degrades to the prior in-memory path,
45
+ emits `spool_init_degraded_total`, and never crashes or blocks the
46
+ PreToolUse hook.
47
+
48
+ ### Security
49
+
50
+ - **Spool encryption key (DEK) defaults to the OS keystore.** The DEK now
51
+ lives in the macOS Keychain (a `security` generic-password item) or the
52
+ Linux Secret Service (`secret-tool`/libsecret) by default when one is
53
+ available; the on-disk `spool.key` then holds only a sentinel, so a
54
+ file-read of the spool directory can neither **decrypt** nor **forge**
55
+ spooled audit records (the AES-GCM tag and hash chain are keyed on the
56
+ DEK). Keystore access is strictly **non-interactive** and
57
+ hard-timeout-bounded -- a prompt risk, locked keystore, or missing CLI
58
+ degrades to the legacy 0600 `spool.key` (key hardening never costs audit
59
+ durability and never blocks the hook). A pre-existing on-disk DEK is
60
+ migrated into the keystore on first keystore-enabled open. Force the
61
+ legacy file DEK with `CONTROLZERO_SPOOL_KEYCHAIN=0` or
62
+ `CONTROLZERO_SPOOL_KEYCHAIN_DISABLE=1`; require the keystore with
63
+ `CONTROLZERO_SPOOL_KEYCHAIN=1`.
64
+
65
+ ### Tests
66
+
67
+ - New `tests/spool/test_spool_keychain_dek.py` (keystore round-trip,
68
+ sentinel-on-disk, prompt-risk fallback, file opt-out, file->keystore
69
+ migration) and `tests/spool/test_spool_durable_default_tamper.py`
70
+ (hosted durable default, encrypted-WAL-before-send, **tampered-frame
71
+ rejection surfaced via `spool_tamper_records_total{gcm_auth}`**,
72
+ graceful init-failure degrade, WAL append latency budget). Spool
73
+ isolation is enforced suite-wide so tests never touch the real home dir
74
+ or OS keychain.
75
+
32
76
  ## 1.9.0 -- 2026-06-15 (Antigravity install CLI, epic gh#925)
33
77
 
34
78
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: controlzero
3
- Version: 1.9.1
3
+ Version: 1.9.2
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.2"
44
44
 
45
45
  __all__ = [
46
46
  "Client",
@@ -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
@@ -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,
@@ -0,0 +1,311 @@
1
+ """Non-interactive OS-keychain provider for the spool DEK (spec 7.1, 5).
2
+
3
+ The 32-byte device encryption key (DEK) protects the encrypted spool at
4
+ rest. By default the DEK lives in ``spool.key`` (base64, 0600) co-located
5
+ with the ciphertext it protects -- so a single file-read of the spool
6
+ directory recovers the key AND the records. This module moves the DEK
7
+ into the OS keystore so a local file-read of the spool dir can neither
8
+ decrypt nor (because the AEAD tag and hash chain are keyed on the DEK)
9
+ forge spool records.
10
+
11
+ HARD CONSTRAINT (spec section 5, the ``CONTROLZERO_SPOOL_KEYCHAIN`` row):
12
+ the spool runs inside coding-agent hooks (Claude Code / Gemini / Codex
13
+ PreToolUse). A blocking keychain GUI prompt inside a hook is a P0. Every
14
+ keystore call here therefore:
15
+
16
+ - runs NON-INTERACTIVELY (the platform CLI flag that reads/writes a
17
+ stored item without ever raising a GUI unlock dialog), and
18
+ - is wrapped in a hard wall-clock timeout, and
19
+ - on ANY failure (tool missing, locked keystore, prompt risk, timeout,
20
+ non-zero exit) returns None so the caller falls back to the 0600
21
+ file path with a documented warning.
22
+
23
+ The provider never raises into the caller; ``get``/``set`` return None on
24
+ any problem. ``available()`` is a cheap, side-effect-free probe used to
25
+ decide the default DEK source.
26
+
27
+ Service/account identity (stable, per-OS-user, no secrets in the name):
28
+
29
+ macOS Keychain (generic password item):
30
+ service = "com.controlzero.spool.dek"
31
+ account = "<spool_root_realpath>"
32
+ Linux Secret Service (libsecret via secret-tool):
33
+ attributes: service=com.controlzero.spool.dek, path=<spool_root>
34
+
35
+ Keying the item on the spool root path lets two distinct spool roots
36
+ (e.g. a test root and the real ~/.controlzero/spool) hold independent
37
+ DEKs without colliding.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import base64
43
+ import logging
44
+ import os
45
+ import shutil
46
+ import subprocess # noqa: S404 -- fixed argv, no shell, hard timeout
47
+ import sys
48
+
49
+ logger = logging.getLogger("controlzero.spool.keyring")
50
+
51
+ # Stable keystore identity. NOT a secret; safe to hardcode.
52
+ _SERVICE = "com.controlzero.spool.dek"
53
+
54
+ # Hard wall-clock ceiling for any SINGLE keystore subprocess. The hook
55
+ # hot path budget is 2 s total (_SPOOL_HOOK_BUDGET_S); a keystore call
56
+ # that takes longer is treated as unavailable so we fall back to the file
57
+ # path rather than risk the hook. Kept well below the hook budget so that
58
+ # even the worst case (a get that times out followed by no set -- see
59
+ # get_dek/set_dek discipline) stays a fraction of the budget.
60
+ _KEYCHAIN_TIMEOUT_S = 0.5
61
+
62
+ # Opt-out kept narrow and explicit: a value of "0" or "file" forces the
63
+ # legacy on-disk DEK even on a platform with a working keystore. Any
64
+ # other value (or unset) keeps the keychain-default behavior. This is
65
+ # the inverse-sense companion to CONTROLZERO_SPOOL_KEYCHAIN=1 which
66
+ # remains supported as an explicit opt-IN (handled by the caller).
67
+ ENV_KEYCHAIN_DISABLE = "CONTROLZERO_SPOOL_KEYCHAIN_DISABLE"
68
+
69
+
70
+ def _account_for(root: str) -> str:
71
+ """Per-spool-root account id. Realpath so a symlinked vs canonical
72
+ root resolve to the same keystore item."""
73
+ try:
74
+ return os.path.realpath(os.path.expanduser(root))
75
+ except Exception: # noqa: BLE001
76
+ return os.path.expanduser(root)
77
+
78
+
79
+ # Sentinel returned by _run on a hard timeout (vs a clean non-zero exit).
80
+ # A timeout means the keystore is likely prompting / wedged, so the caller
81
+ # should STOP attempting further keystore calls this open (don't burn a
82
+ # second timeout on a set after a get already timed out).
83
+ _TIMEOUT = object()
84
+
85
+
86
+ def _run(argv, input_bytes=None):
87
+ """Run a keystore CLI non-interactively with a hard timeout.
88
+
89
+ Returns (rc, stdout_bytes), ``_TIMEOUT`` (the call exceeded the hard
90
+ budget -- likely a prompt/wedge), or None on any other failure
91
+ (missing binary, OSError). NEVER raises. NEVER inherits a tty, and
92
+ runs in its OWN session/process group so a CLI that tries to open a
93
+ controlling-terminal/agent prompt cannot attach to ours; on timeout
94
+ the whole process group is killed so nothing lingers blocking."""
95
+ try:
96
+ proc = subprocess.Popen( # noqa: S603 -- fixed argv list, shell=False
97
+ argv,
98
+ stdin=subprocess.PIPE,
99
+ stdout=subprocess.PIPE,
100
+ stderr=subprocess.DEVNULL,
101
+ start_new_session=True, # detach from our session/tty + pgid
102
+ )
103
+ except (OSError, ValueError):
104
+ return None
105
+ try:
106
+ out, _err = proc.communicate(input=input_bytes,
107
+ timeout=_KEYCHAIN_TIMEOUT_S)
108
+ except subprocess.TimeoutExpired:
109
+ # Kill the whole process group so a prompting CLI cannot linger.
110
+ try:
111
+ os.killpg(proc.pid, 9)
112
+ except (OSError, AttributeError):
113
+ try:
114
+ proc.kill()
115
+ except OSError:
116
+ pass
117
+ try:
118
+ proc.communicate(timeout=0.2)
119
+ except Exception: # noqa: BLE001
120
+ pass
121
+ logger.debug("keystore call timed out (killed pgroup): %s", argv[0])
122
+ return _TIMEOUT
123
+ except (OSError, ValueError):
124
+ return None
125
+ return proc.returncode, out or b""
126
+
127
+
128
+ # Per-process latch: once a keystore call has timed out (prompt/wedge),
129
+ # treat the keystore as unavailable for the rest of this process so we
130
+ # never burn a second hard timeout on the hook hot path.
131
+ _keystore_timed_out = False
132
+
133
+
134
+ def _decode_get(res):
135
+ """Map a _run() result for a GET into a 32-byte DEK or None.
136
+
137
+ A timeout latches the keystore off for this process and returns None
138
+ (caller falls back to file). A non-zero exit (item absent) is None."""
139
+ global _keystore_timed_out
140
+ if res is _TIMEOUT:
141
+ _keystore_timed_out = True
142
+ return None
143
+ if res is None:
144
+ return None
145
+ rc, out = res
146
+ if rc != 0:
147
+ return None
148
+ text = out.decode("utf-8", "replace").strip()
149
+ if not text:
150
+ return None
151
+ try:
152
+ raw = base64.b64decode(text)
153
+ except Exception: # noqa: BLE001
154
+ return None
155
+ return raw if len(raw) == 32 else None
156
+
157
+
158
+ def _ok_set(res):
159
+ """Map a _run() result for a SET into True/False. Timeout latches the
160
+ keystore off and returns False (caller falls back to file)."""
161
+ global _keystore_timed_out
162
+ if res is _TIMEOUT:
163
+ _keystore_timed_out = True
164
+ return False
165
+ if res is None:
166
+ return False
167
+ rc, _out = res
168
+ return rc == 0
169
+
170
+
171
+ # -- macOS Keychain (security(1) generic-password items) --------------------
172
+
173
+
174
+ def _macos_security_bin():
175
+ return shutil.which("security")
176
+
177
+
178
+ def _macos_get(account: str):
179
+ sec = _macos_security_bin()
180
+ if not sec:
181
+ return None
182
+ # -w prints ONLY the password to stdout. For a generic-password item
183
+ # that this same process created (ACL grants the caller access), the
184
+ # read is non-interactive: the keychain does not raise an unlock
185
+ # dialog for a previously-stored generic password owned by the same
186
+ # login keychain. If the keychain is locked and WOULD prompt, the
187
+ # call blocks -- the hard timeout in _run() bounds that to 1 s and we
188
+ # fall back to file. -g is intentionally NOT passed (it can route to
189
+ # an interactive dialog on some macOS versions).
190
+ res = _run([sec, "find-generic-password", "-s", _SERVICE,
191
+ "-a", account, "-w"])
192
+ return _decode_get(res)
193
+
194
+
195
+ def _macos_set(account: str, dek: bytes) -> bool:
196
+ sec = _macos_security_bin()
197
+ if not sec:
198
+ return False
199
+ b64 = base64.b64encode(dek).decode("ascii")
200
+ # -U updates an existing item in place. We deliberately do NOT pass
201
+ # `-T ""`: an empty trusted-application list forces an access-control
202
+ # evaluation that can BLOCK the `security` process (verified on macOS:
203
+ # `-T ""` hangs add-generic-password). The default ACL trusts the
204
+ # creating process, which is exactly the non-interactive behavior we
205
+ # need -- the same login user's later `security find-generic-password
206
+ # -w` reads it back without a GUI prompt. The hard timeout in _run()
207
+ # still bounds any pathological case.
208
+ res = _run([sec, "add-generic-password", "-s", _SERVICE,
209
+ "-a", account, "-w", b64, "-U"])
210
+ return _ok_set(res)
211
+
212
+
213
+ # -- Linux Secret Service (libsecret via secret-tool(1)) --------------------
214
+
215
+
216
+ def _secret_tool_bin():
217
+ return shutil.which("secret-tool")
218
+
219
+
220
+ def _linux_get(account: str):
221
+ st = _secret_tool_bin()
222
+ if not st:
223
+ return None
224
+ # secret-tool lookup is non-interactive when the collection is
225
+ # already unlocked (the common case: a logged-in desktop session).
226
+ # If the keyring is locked it MAY prompt; the timeout bounds it and
227
+ # we fall back to file. attributes pin the item.
228
+ res = _run([st, "lookup", "service", _SERVICE, "path", account])
229
+ return _decode_get(res)
230
+
231
+
232
+ def _linux_set(account: str, dek: bytes) -> bool:
233
+ st = _secret_tool_bin()
234
+ if not st:
235
+ return False
236
+ b64 = base64.b64encode(dek).decode("ascii") + "\n"
237
+ # secret-tool store reads the secret from stdin.
238
+ res = _run([st, "store", "--label=ControlZero spool DEK",
239
+ "service", _SERVICE, "path", account],
240
+ input_bytes=b64.encode("ascii"))
241
+ return _ok_set(res)
242
+
243
+
244
+ # -- public surface ---------------------------------------------------------
245
+
246
+
247
+ def backend_name():
248
+ """The keystore backend that would be used on this platform, or None.
249
+
250
+ Side-effect-free: only checks the platform and that the CLI exists on
251
+ PATH. Does NOT touch the keystore (no prompt risk)."""
252
+ if os.environ.get(ENV_KEYCHAIN_DISABLE, "").strip().lower() in ("1", "file", "true", "yes"):
253
+ return None
254
+ if sys.platform == "darwin":
255
+ return "macos" if _macos_security_bin() else None
256
+ if sys.platform.startswith("linux"):
257
+ return "secret-service" if _secret_tool_bin() else None
258
+ # Windows DPAPI path is not implemented in v1; the file fallback is
259
+ # used (documented in the spec / spool docs).
260
+ return None
261
+
262
+
263
+ def available():
264
+ """True iff a keystore backend CLI is present on this platform AND
265
+ not disabled by env. Side-effect-free (no keystore access)."""
266
+ return backend_name() is not None
267
+
268
+
269
+ def get_dek(root: str):
270
+ """Return the 32-byte DEK from the OS keystore, or None.
271
+
272
+ None means: no keystore, item absent, locked, prompt-risk hit the
273
+ timeout, or a malformed stored value. The caller falls back to the
274
+ on-disk DEK. Never raises."""
275
+ if _keystore_timed_out:
276
+ return None
277
+ backend = backend_name()
278
+ if backend is None:
279
+ return None
280
+ account = _account_for(root)
281
+ try:
282
+ if backend == "macos":
283
+ return _macos_get(account)
284
+ if backend == "secret-service":
285
+ return _linux_get(account)
286
+ except Exception as exc: # noqa: BLE001 -- defensive: never raise on hot path
287
+ logger.debug("keystore get failed: %s", exc)
288
+ return None
289
+
290
+
291
+ def set_dek(root: str, dek: bytes):
292
+ """Store the 32-byte DEK in the OS keystore. Returns True on success.
293
+
294
+ On any failure returns False; the caller then persists the DEK to the
295
+ 0600 file instead. Never raises."""
296
+ if len(dek) != 32:
297
+ return False
298
+ if _keystore_timed_out:
299
+ return False
300
+ backend = backend_name()
301
+ if backend is None:
302
+ return False
303
+ account = _account_for(root)
304
+ try:
305
+ if backend == "macos":
306
+ return _macos_set(account, dek)
307
+ if backend == "secret-service":
308
+ return _linux_set(account, dek)
309
+ except Exception as exc: # noqa: BLE001
310
+ logger.debug("keystore set failed: %s", exc)
311
+ return False
@@ -21,6 +21,7 @@ import socket
21
21
  import time
22
22
  import uuid
23
23
 
24
+ from . import _keyring
24
25
  from . import _metrics as metrics
25
26
  from ._compress import compress, default_write_flg
26
27
  from ._constants import (
@@ -63,6 +64,7 @@ from ._frame import (
63
64
  walk_records,
64
65
  )
65
66
  from ._state import (
67
+ SpoolKeyUnavailable,
66
68
  atomic_write,
67
69
  ensure_dir,
68
70
  fsync_dir,
@@ -89,6 +91,24 @@ def get_mode():
89
91
  return MODE_OFF
90
92
 
91
93
 
94
+ def _resolve_keychain_env():
95
+ """Resolve the DEK-source tri-state from the environment.
96
+
97
+ Returns:
98
+ True if CONTROLZERO_SPOOL_KEYCHAIN is explicitly truthy (opt-in);
99
+ False if CONTROLZERO_SPOOL_KEYCHAIN is explicitly falsey OR
100
+ CONTROLZERO_SPOOL_KEYCHAIN_DISABLE is set (opt-out);
101
+ None otherwise -> the default keystore-first-when-available path.
102
+ """
103
+ raw = os.environ.get(ENV_KEYCHAIN)
104
+ if raw is not None and raw.strip() != "":
105
+ return raw.strip().lower() in ("1", "true", "yes", "on")
106
+ if os.environ.get(_keyring.ENV_KEYCHAIN_DISABLE, "").strip().lower() in (
107
+ "1", "file", "true", "yes", "on"):
108
+ return False
109
+ return None
110
+
111
+
92
112
  def _env_int(name, default):
93
113
  raw = os.environ.get(name)
94
114
  if raw is None or raw == "":
@@ -115,13 +135,17 @@ class SpoolConfig:
115
135
  segment_age_s=DEFAULT_SEGMENT_AGE_S,
116
136
  retention_s=DEFAULT_RETENTION_S,
117
137
  max_bytes=DEFAULT_MAX_BYTES,
118
- keychain=False):
138
+ keychain=None):
119
139
  self.mode = mode
120
140
  self.root = root or os.path.expanduser(DEFAULT_DIR)
121
141
  self.segment_bytes = segment_bytes
122
142
  self.segment_age_s = segment_age_s
123
143
  self.retention_s = retention_s
124
144
  self.max_bytes = max_bytes
145
+ # Tri-state DEK source (spec 7.1, section 5):
146
+ # None -> keystore-first when available, else 0600 file (DEFAULT);
147
+ # True -> explicit keystore opt-in (CONTROLZERO_SPOOL_KEYCHAIN=1);
148
+ # False -> 0600 file only (explicit opt-out / disable env).
125
149
  self.keychain = keychain
126
150
 
127
151
  @classmethod
@@ -133,7 +157,7 @@ class SpoolConfig:
133
157
  segment_age_s=_env_int(ENV_SEGMENT_AGE, DEFAULT_SEGMENT_AGE_S),
134
158
  retention_s=_env_int(ENV_RETENTION, DEFAULT_RETENTION_S),
135
159
  max_bytes=_env_int(ENV_MAX_BYTES, DEFAULT_MAX_BYTES),
136
- keychain=os.environ.get(ENV_KEYCHAIN, "0") == "1",
160
+ keychain=_resolve_keychain_env(),
137
161
  )
138
162
 
139
163
 
@@ -188,6 +212,17 @@ class Spool:
188
212
  self._init_dirs(create)
189
213
  self.recover()
190
214
  self.cleanup()
215
+ except SpoolKeyUnavailable as exc:
216
+ # The DEK lives in a keystore we cannot read this open and
217
+ # spool.key holds only the sentinel. Minting a new key would
218
+ # orphan the existing keystore-encrypted records, so degrade
219
+ # to memory-only for this process and try again next open
220
+ # (the keystore may be reachable then). Never destructive.
221
+ metrics.incr("spool_key_unavailable_total", "keychain")
222
+ logger.warning(
223
+ "spool DEK unavailable (keystore unreadable, sentinel on "
224
+ "disk); degrading to memory-only this process: %s", exc)
225
+ self._memory_mode = True
191
226
  except OSError as exc:
192
227
  self._degrade(exc, during="init")
193
228
 
@@ -221,7 +256,7 @@ class Spool:
221
256
  now_iso = _iso_from_ms(self._now_ms())
222
257
  self._device_id, self._device_epoch = load_or_create_device(
223
258
  self._root, now_iso)
224
- self._dek = load_or_create_dek(self._root)
259
+ self._dek = load_or_create_dek(self._root, keychain=self._cfg.keychain)
225
260
  if create:
226
261
  ensure_dir(self._stream_dir)
227
262