controlzero 1.9.2__tar.gz → 1.9.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (235) hide show
  1. {controlzero-1.9.2 → controlzero-1.9.4}/CHANGELOG.md +97 -0
  2. {controlzero-1.9.2 → controlzero-1.9.4}/PKG-INFO +1 -1
  3. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/__init__.py +1 -1
  4. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/_internal/bundle.py +78 -32
  5. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/_internal/enforcer.py +95 -3
  6. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/main.py +12 -0
  7. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/client.py +8 -2
  8. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/error_codes.yaml +10 -5
  9. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/errors.py +105 -2
  10. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/hosted_policy.py +17 -6
  11. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/policy_loader.py +15 -0
  12. {controlzero-1.9.2 → controlzero-1.9.4}/pyproject.toml +1 -1
  13. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_bundle_parser.py +11 -3
  14. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_default_action.py +160 -12
  15. controlzero-1.9.4/tests/test_epic_1247_bryan_acceptance.py +529 -0
  16. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_errors_e_codes.py +93 -0
  17. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hitl_reason_codes.py +6 -5
  18. controlzero-1.9.4/tests/test_observe_mode_1247.py +362 -0
  19. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_reason_code.py +41 -17
  20. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_synthetic_policy_id_t79.py +40 -14
  21. {controlzero-1.9.2 → controlzero-1.9.4}/.gitignore +0 -0
  22. {controlzero-1.9.2 → controlzero-1.9.4}/Dockerfile.test +0 -0
  23. {controlzero-1.9.2 → controlzero-1.9.4}/LICENSE +0 -0
  24. {controlzero-1.9.2 → controlzero-1.9.4}/README.md +0 -0
  25. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/_internal/__init__.py +0 -0
  26. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/_internal/action_aliases.py +0 -0
  27. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/_internal/action_validator.py +0 -0
  28. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/_internal/credential_hook.py +0 -0
  29. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/_internal/credential_scanner.py +0 -0
  30. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/_internal/credentials_data/__init__.py +0 -0
  31. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/_internal/credentials_data/built_in.yaml +0 -0
  32. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/_internal/dlp_scanner.py +0 -0
  33. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/_internal/hook_extractors.py +0 -0
  34. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/_internal/tool_extractors.json +0 -0
  35. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/_internal/types.py +0 -0
  36. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/audit_local.py +0 -0
  37. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/audit_remote.py +0 -0
  38. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/canonical.py +0 -0
  39. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/__init__.py +0 -0
  40. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/_secrets.py +0 -0
  41. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/console.py +0 -0
  42. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/debug_bundle.py +0 -0
  43. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/doctor.py +0 -0
  44. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/hosts/__init__.py +0 -0
  45. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/hosts/antigravity.py +0 -0
  46. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/hosts/base.py +0 -0
  47. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/hosts/claude_code.py +0 -0
  48. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/hosts/codex_cli.py +0 -0
  49. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/hosts/gemini_cli.py +0 -0
  50. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/hosts/kiro.py +0 -0
  51. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/hosts/unknown.py +0 -0
  52. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/kiro_adapter.py +0 -0
  53. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/migrate.py +0 -0
  54. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/spool_cmd.py +0 -0
  55. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/telemetry_consent.py +0 -0
  56. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/templates/antigravity/hooks.json +0 -0
  57. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/templates/antigravity.yaml +0 -0
  58. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/templates/autogen.yaml +0 -0
  59. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/templates/claude-code.yaml +0 -0
  60. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/templates/codex-cli.yaml +0 -0
  61. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/templates/cost-cap.yaml +0 -0
  62. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/templates/crewai.yaml +0 -0
  63. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/templates/cursor.yaml +0 -0
  64. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/templates/gemini-cli.yaml +0 -0
  65. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/templates/generic.yaml +0 -0
  66. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/templates/kiro/ide-file-save.kiro.hook +0 -0
  67. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/templates/kiro/ide-pre-tool-use.kiro.hook +0 -0
  68. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/templates/kiro/ide-prompt-submit.kiro.hook +0 -0
  69. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/templates/kiro/kiro.yaml +0 -0
  70. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/templates/langchain.yaml +0 -0
  71. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/templates/mcp.yaml +0 -0
  72. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/templates/rag.yaml +0 -0
  73. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/device.py +0 -0
  74. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/enrollment.py +0 -0
  75. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/error_codes.py +0 -0
  76. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/hitl/__init__.py +0 -0
  77. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/hitl/grant_protocol.py +0 -0
  78. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/hitl/mock.py +0 -0
  79. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/hitl/pending_approval.py +0 -0
  80. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/hitl/secret_leak_guard.py +0 -0
  81. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/hitl/status.py +0 -0
  82. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/hooks/__init__.py +0 -0
  83. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/hooks/tool_output_handler.py +0 -0
  84. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/__init__.py +0 -0
  85. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/anthropic.py +0 -0
  86. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/autogen.py +0 -0
  87. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/braintrust.py +0 -0
  88. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/crewai/__init__.py +0 -0
  89. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/crewai/agent.py +0 -0
  90. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/crewai/crew.py +0 -0
  91. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/crewai/task.py +0 -0
  92. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/crewai/tool.py +0 -0
  93. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/google.py +0 -0
  94. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/google_adk/__init__.py +0 -0
  95. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/google_adk/agent.py +0 -0
  96. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/google_adk/tool.py +0 -0
  97. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/langchain/__init__.py +0 -0
  98. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/langchain/agent.py +0 -0
  99. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/langchain/callbacks.py +0 -0
  100. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/langchain/chain.py +0 -0
  101. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/langchain/graph.py +0 -0
  102. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/langchain/modern.py +0 -0
  103. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/langchain/tool.py +0 -0
  104. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/langfuse.py +0 -0
  105. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/litellm.py +0 -0
  106. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/openai.py +0 -0
  107. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/pydantic_ai.py +0 -0
  108. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/vercel_ai.py +0 -0
  109. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/layout_migration.py +0 -0
  110. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/spool/__init__.py +0 -0
  111. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/spool/_compress.py +0 -0
  112. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/spool/_constants.py +0 -0
  113. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/spool/_crc32c.py +0 -0
  114. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/spool/_crypto.py +0 -0
  115. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/spool/_frame.py +0 -0
  116. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/spool/_keyring.py +0 -0
  117. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/spool/_metrics.py +0 -0
  118. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/spool/_spool.py +0 -0
  119. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/spool/_state.py +0 -0
  120. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/spool/_uploader.py +0 -0
  121. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/spool/cz-audit-v1.dict +0 -0
  122. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/tamper.py +0 -0
  123. {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/tracecontext.py +0 -0
  124. {controlzero-1.9.2 → controlzero-1.9.4}/examples/hello_world.py +0 -0
  125. {controlzero-1.9.2 → controlzero-1.9.4}/tests/_fixtures/jcs_args_hash_vectors.json +0 -0
  126. {controlzero-1.9.2 → controlzero-1.9.4}/tests/conftest.py +0 -0
  127. {controlzero-1.9.2 → controlzero-1.9.4}/tests/integrations/__init__.py +0 -0
  128. {controlzero-1.9.2 → controlzero-1.9.4}/tests/integrations/test_google.py +0 -0
  129. {controlzero-1.9.2 → controlzero-1.9.4}/tests/parity/action_aliases.json +0 -0
  130. {controlzero-1.9.2 → controlzero-1.9.4}/tests/spool/__init__.py +0 -0
  131. {controlzero-1.9.2 → controlzero-1.9.4}/tests/spool/conftest.py +0 -0
  132. {controlzero-1.9.2 → controlzero-1.9.4}/tests/spool/test_spool_cli.py +0 -0
  133. {controlzero-1.9.2 → controlzero-1.9.4}/tests/spool/test_spool_concurrency.py +0 -0
  134. {controlzero-1.9.2 → controlzero-1.9.4}/tests/spool/test_spool_conformance.py +0 -0
  135. {controlzero-1.9.2 → controlzero-1.9.4}/tests/spool/test_spool_core.py +0 -0
  136. {controlzero-1.9.2 → controlzero-1.9.4}/tests/spool/test_spool_crash.py +0 -0
  137. {controlzero-1.9.2 → controlzero-1.9.4}/tests/spool/test_spool_diskfull.py +0 -0
  138. {controlzero-1.9.2 → controlzero-1.9.4}/tests/spool/test_spool_durable_default_tamper.py +0 -0
  139. {controlzero-1.9.2 → controlzero-1.9.4}/tests/spool/test_spool_keychain_dek.py +0 -0
  140. {controlzero-1.9.2 → controlzero-1.9.4}/tests/spool/test_spool_sink_wiring.py +0 -0
  141. {controlzero-1.9.2 → controlzero-1.9.4}/tests/spool/test_spool_transcript_localack.py +0 -0
  142. {controlzero-1.9.2 → controlzero-1.9.4}/tests/spool/test_spool_uploader.py +0 -0
  143. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_action_aliases.py +0 -0
  144. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_action_canonicalization.py +0 -0
  145. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_action_validator_t86.py +0 -0
  146. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_agent_name_env.py +0 -0
  147. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_antigravity_adapter.py +0 -0
  148. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_antigravity_hook_check.py +0 -0
  149. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_antigravity_install.py +0 -0
  150. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_api_key_mask.py +0 -0
  151. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_audit_remote.py +0 -0
  152. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_audit_remote_sdk_version.py +0 -0
  153. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_audit_sink_isolation.py +0 -0
  154. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_bundle_translate.py +0 -0
  155. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_canonical_phase1a.py +0 -0
  156. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_cli_carve_out.py +0 -0
  157. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_cli_debug_bundle.py +0 -0
  158. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_cli_extractor_integration.py +0 -0
  159. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_cli_hook.py +0 -0
  160. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_cli_hosted_refresh.py +0 -0
  161. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_cli_init.py +0 -0
  162. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_cli_init_templates.py +0 -0
  163. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_cli_tail.py +0 -0
  164. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_cli_test.py +0 -0
  165. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_cli_validate.py +0 -0
  166. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_coding_agent_hooks.py +0 -0
  167. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_conditions.py +0 -0
  168. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_conformance.py +0 -0
  169. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_console.py +0 -0
  170. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_credential_hook.py +0 -0
  171. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_device.py +0 -0
  172. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_dlp_scanner.py +0 -0
  173. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_doctor.py +0 -0
  174. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_engine_version_consistency.py +0 -0
  175. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_enrollment.py +0 -0
  176. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_env_dump_438.py +0 -0
  177. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_error_codes.py +0 -0
  178. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_fail_closed_eval.py +0 -0
  179. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_glob_matching.py +0 -0
  180. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hitl_5d_email_install.py +0 -0
  181. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hitl_6a_cli_flag.py +0 -0
  182. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hitl_6a_exceptions.py +0 -0
  183. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hitl_6a_get_secret_hitl.py +0 -0
  184. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hitl_6a_mock_backend.py +0 -0
  185. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hitl_6a_pending_approval.py +0 -0
  186. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hitl_6a_request_approval.py +0 -0
  187. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hitl_6a_secret_leak_guard.py +0 -0
  188. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hitl_6a_wait.py +0 -0
  189. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hitl_conformance.py +0 -0
  190. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hitl_phase2b_protocol.py +0 -0
  191. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hitl_validator_keys.py +0 -0
  192. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hook_extractors.py +0 -0
  193. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hosted_local_audit_1247.py +0 -0
  194. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hosted_policy_e2e.py +0 -0
  195. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hosts_adapter.py +0 -0
  196. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hybrid_mode_strict.py +0 -0
  197. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hybrid_mode_warn.py +0 -0
  198. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_install_hook_command.py +0 -0
  199. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_install_hooks.py +0 -0
  200. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_kiro_adapter.py +0 -0
  201. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_kiro_hook_templates.py +0 -0
  202. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_kiro_install.py +0 -0
  203. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_layout_migration_t101.py +0 -0
  204. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_layout_parity_t102.py +0 -0
  205. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_local_mode_dict.py +0 -0
  206. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_local_mode_file_json.py +0 -0
  207. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_local_mode_file_yaml.py +0 -0
  208. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_log_fallback_stderr.py +0 -0
  209. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_log_options_ignored_hosted.py +0 -0
  210. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_log_rotation.py +0 -0
  211. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_migrate.py +0 -0
  212. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_min_sdk_version_gate.py +0 -0
  213. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_multi_client_per_project_175.py +0 -0
  214. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_no_policy_no_key.py +0 -0
  215. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_package_rename_shim.py +0 -0
  216. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_policy_engine_version_phase1b.py +0 -0
  217. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_policy_freshness.py +0 -0
  218. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_policy_settings.py +0 -0
  219. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_policy_source_audit.py +0 -0
  220. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_quarantine.py +0 -0
  221. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_refresh.py +0 -0
  222. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_secrets.py +0 -0
  223. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_sql_semantic_class.py +0 -0
  224. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_t103_precedence.py +0 -0
  225. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_t104_cache_gc.py +0 -0
  226. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_t108_local_override_audit.py +0 -0
  227. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_t96_single_audit_log.py +0 -0
  228. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_t99_install_prefetch_bundle.py +0 -0
  229. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_tamper.py +0 -0
  230. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_tamper_behavior.py +0 -0
  231. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_tamper_hook.py +0 -0
  232. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_telemetry_consent.py +0 -0
  233. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_tracecontext.py +0 -0
  234. {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_unsafe_int_boundary.py +0 -0
  235. {controlzero-1.9.2 → controlzero-1.9.4}/tools/cz-kiro-adapter +0 -0
@@ -1,5 +1,102 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.4 -- 2026-06-16 (actionable E1101 / API-key-rejected message, #1254, epic gh#1247)
4
+
5
+ ### Fixed
6
+
7
+ - **(#1254) `HostedAuthError` (E1101) is now actionable instead of an
8
+ opaque "API key rejected (401)".** A dead/revoked/expired/placeholder API key
9
+ in hosted mode used to surface a bare message that told the user neither WHY
10
+ nor what to do. The message now always states the key is invalid/revoked/
11
+ expired and how to fix it: "Generate a new API key in the dashboard (Settings
12
+ -> API Keys, https://app.controlzero.ai/settings/api-keys) and set
13
+ CONTROLZERO_API_KEY to it." When the backend sends a structured 401 body, the
14
+ exception preserves the coarse machine-readable `reason` (e.g.
15
+ `invalid_or_revoked`) and the `remediation` on the exception so programmatic
16
+ callers can branch while humans read the fix. The `E1101` code is unchanged.
17
+ All four 401 raise sites (bootstrap, bundle pull, approval request, get
18
+ secret) now parse the body via `HostedAuthError.from_response(...)`.
19
+ Enumeration-safe: the backend keeps the reason coarse (not_found / revoked /
20
+ expired / placeholder all collapse to `invalid_or_revoked`) so a 401 cannot be
21
+ used to probe which keys exist. Tests:
22
+ `tests/test_errors_e_codes.py::TestHostedAuthErrorActionable`.
23
+
24
+ ## 1.9.3 -- 2026-06-16 (posture release: empty-bundle OBSERVE + self-explaining no-rule-match deny, epic gh#1247)
25
+
26
+ The consolidated **posture release** for epic gh#1247 (customer Bryan).
27
+ Three customer-visible behaviors land together so the engine's "what
28
+ happens when a tool isn't covered by a rule" story is coherent across
29
+ both SDKs and the backend:
30
+
31
+ 1. **Empty bundle -> OBSERVE** (was the only content of the superseded
32
+ 1.9.2-observe draft).
33
+ 2. **Non-empty no-rule-match deny is now self-explaining** (folds in the
34
+ Python-only fix from gh#1257, which is superseded by this release).
35
+ 3. The backend `default_on_empty` knob on the bundle handler.
36
+
37
+ ### Fixed
38
+
39
+ - **Hosted no-rule-match deny is now self-explaining (folds gh#1257).** A
40
+ non-empty bundle with `default_action=deny` and zero rule matches (the
41
+ exact shape of Bryan's "Db read only" allow-list, which excludes
42
+ `bash`) used to deny with the bare, bug-looking message
43
+ `No matching policy rule (fail-closed default)` -- no path out. The
44
+ deny reason now NAMES the unmatched action (e.g. `bash:find`) and the
45
+ exact remediation (add a catch-all `allow: '*'`, allow the specific
46
+ action, or flip the project/org default to allow) and tells the user
47
+ to do it in the Control Zero dashboard. `reason_code` stays
48
+ `NO_RULE_MATCH`; the legacy `fail-closed default` substring is retained
49
+ for any downstream regex consumer. The empty-bundle OBSERVE catch-all
50
+ matches FIRST, so a genuinely-empty bundle still observes and never
51
+ reaches this deny -- the two behaviors compose cleanly. Regression
52
+ tests in `tests/test_default_action.py`
53
+ (`test_1247_no_rule_match_deny_*`).
54
+
55
+ ### Changed
56
+
57
+ A genuinely-EMPTY hosted bundle -- one that resolved successfully but has
58
+ zero attached/active policies -- now defaults to **OBSERVE** (allow +
59
+ audit + a loud "monitoring, not enforcing" signal) instead of the old
60
+ day-one deny-brick. A fresh hosted project no longer blocks every tool
61
+ call before the operator has authored a single rule; instead Control
62
+ Zero allows the call through and audits it, loudly flagged, so the
63
+ operator can see the engine is wired up and watching, then attach a
64
+ policy to start enforcing.
65
+
66
+ This is a founder-approved posture refinement (validated by a 4-lens
67
+ second-opinion). It is deliberately **narrow and gated**:
68
+
69
+ - **Only the genuinely-empty, successfully-resolved case observes.** A
70
+ non-empty bundle whose rules evaluate but nothing matches still
71
+ **denies** (`NO_RULE_MATCH`, `default_action` canonical deny) --
72
+ authored allow-lists stay secure. A bundle RESOLUTION ERROR / failed
73
+ pull / RLS / auth / decrypt failure still **fails closed** (deny,
74
+ `BUNDLE_MISSING`, honoring `default_on_missing`). Observe mode can
75
+ never mask a resolution error as an allow.
76
+ - **The empty-vs-error boundary is structural, not a runtime flag.** The
77
+ empty path (`translate_to_local_policy`, reached only on successful
78
+ resolution) and the error path (`make_bundle_missing_policy`, reached
79
+ only on resolution failure) are separate functions with separate
80
+ reason codes, so they cannot be confused.
81
+
82
+ ### Added
83
+
84
+ - **New `reason_code=OBSERVE_MODE_NO_POLICY`** and synthetic policy_id
85
+ `synthetic:OBSERVE_MODE_NO_POLICY` for the empty-bundle observe allow,
86
+ distinct from `NO_ACTIVE_POLICIES` (the deny/warn/allow empty postures)
87
+ and `BUNDLE_MISSING` (the fail-closed resolution-error path).
88
+ - **`PolicyDecision.observe: bool`** -- True only on an observe-mode
89
+ allow. The CLI `guard` output now prints a loud yellow "OBSERVE MODE:
90
+ monitoring, not enforcing" line so an observe allow can never be
91
+ mistaken for a normal rule-driven allow. Gated strictly on the
92
+ reason_code, so no user-authored rule can ever set it.
93
+ - **New `default_on_empty` knob** (`observe`|`deny`|`warn`|`allow`,
94
+ canonical `observe`), separate from `default_action`. An operator can
95
+ declare allow-list-vs-deny-list intent for the empty case instead of
96
+ it being inferred. Plumbed end-to-end: backend bundle payload ->
97
+ bundle translator -> `PolicySettings`. Local YAML policies may set
98
+ `settings.default_on_empty`. The dashboard observe-mode indicator +
99
+ CLI `status` line remain a separate frontend/CLI follow-up.
3
100
  ## 1.9.1 -- 2026-06-16 (hosted-mode local audit log P0, epic gh#1247)
4
101
 
5
102
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: controlzero
3
- Version: 1.9.2
3
+ Version: 1.9.4
4
4
  Summary: AI agent governance: policies, audit, and observability for tool calls. Works locally with no signup.
5
5
  Project-URL: Homepage, https://controlzero.ai
6
6
  Project-URL: Documentation, https://docs.controlzero.ai
@@ -40,7 +40,7 @@ from controlzero.hitl.grant_protocol import (
40
40
  )
41
41
  from controlzero.policy_loader import load_policy
42
42
 
43
- __version__ = "1.9.2"
43
+ __version__ = "1.9.4"
44
44
 
45
45
  __all__ = [
46
46
  "Client",
@@ -493,9 +493,11 @@ def translate_to_local_policy(payload: dict) -> dict:
493
493
  # the canonical-default fallback (one source of truth).
494
494
  from controlzero._internal.enforcer import (
495
495
  DEFAULT_BUNDLE_ACTION,
496
+ DEFAULT_BUNDLE_ON_EMPTY,
496
497
  DEFAULT_BUNDLE_ON_MISSING,
497
498
  DEFAULT_BUNDLE_ON_TAMPER,
498
499
  VALID_DEFAULT_ACTIONS,
500
+ VALID_DEFAULT_ON_EMPTY,
499
501
  VALID_DEFAULT_ON_MISSING,
500
502
  VALID_DEFAULT_ON_TAMPER,
501
503
  )
@@ -508,6 +510,19 @@ def translate_to_local_policy(payload: dict) -> dict:
508
510
  if default_on_missing not in VALID_DEFAULT_ON_MISSING:
509
511
  default_on_missing = DEFAULT_BUNDLE_ON_MISSING
510
512
 
513
+ # default_on_empty (#1247 item 3): empty-bundle posture, resolved
514
+ # server-side org->project->canonical("observe"). Separate from
515
+ # default_action so an authored allow-list (no-match -> deny) and an
516
+ # empty project (observe) do not have to share one knob. Unknown /
517
+ # absent values coerce to the canonical "observe" so a fresh hosted
518
+ # project is observe-by-default instead of bricked day-one. NOTE:
519
+ # this knob is read ONLY here, on the SUCCESSFUL-resolution empty
520
+ # branch below -- the resolution-ERROR path (make_bundle_missing_
521
+ # policy) does not consult it and still fails closed.
522
+ default_on_empty = payload.get("default_on_empty")
523
+ if default_on_empty not in VALID_DEFAULT_ON_EMPTY:
524
+ default_on_empty = DEFAULT_BUNDLE_ON_EMPTY
525
+
511
526
  default_on_tamper = payload.get("default_on_tamper")
512
527
  if default_on_tamper not in VALID_DEFAULT_ON_TAMPER:
513
528
  default_on_tamper = DEFAULT_BUNDLE_ON_TAMPER
@@ -536,39 +551,69 @@ def translate_to_local_policy(payload: dict) -> dict:
536
551
  flat.append(translated)
537
552
 
538
553
  if not flat:
539
- # Empty policy set: synthetic catchall rule. The effect
540
- # mirrors the bundle-level default_action so "empty bundle"
541
- # continues to deliver the operator's chosen posture. Prior
542
- # to #228 Phase 2 this was hard-coded to deny; now an org
543
- # that ships an empty-bundle-with-default=allow gets the
544
- # expected allow (with the same NO_ACTIVE_POLICIES
545
- # reason_code so dashboards still bucket it as "nothing
546
- # attached").
554
+ # Empty policy set (RESOLVED SUCCESSFULLY, zero translatable
555
+ # rules): synthetic catch-all rule whose posture is driven by
556
+ # default_on_empty (#1247 item 3 / #1252), NOT default_action.
557
+ #
558
+ # Why a separate knob: default_action governs the NON-EMPTY
559
+ # no-match path -- an org that authored an allow-list wants that
560
+ # to stay deny. But an org that has attached NOTHING YET should
561
+ # not be bricked day-one. The founder-approved refinement makes
562
+ # the genuinely-empty case OBSERVE by default: the call is
563
+ # ALLOWED THROUGH (effect="allow") but loudly flagged as
564
+ # monitoring-only (reason_code=OBSERVE_MODE_NO_POLICY, which the
565
+ # evaluator turns into observe=True on the decision) and
566
+ # audited, so the operator KNOWS the engine is wired up and
567
+ # watching, not enforcing. An operator can override
568
+ # default_on_empty to deny (fail-closed empty), warn (shadow),
569
+ # or allow (silent allow).
570
+ #
571
+ # SAFETY INVARIANT (the empty-vs-error boundary): this branch is
572
+ # reached ONLY when bundle resolution SUCCEEDED and produced
573
+ # zero rules. A resolution FAILURE (pull error, RLS/auth denial,
574
+ # decrypt failure) never reaches here -- it is caught upstream
575
+ # and routed to make_bundle_missing_policy(), which honors
576
+ # default_on_missing and STILL FAILS CLOSED (deny). So observe
577
+ # mode can never mask a resolution error as an allow.
547
578
  #
548
- # Copy-choice note (2026-04-19, the 2026-04-19 P0): the previous
549
- # message -- "No active policies. Define one in the Control Zero
550
- # dashboard." -- presumed the user had not defined any policies.
551
- # That presumption was wrong in the common case: the user had
552
- # defined several, but the library-attachments state on the
553
- # backend did not include any active attachment row, so the
554
- # bundle arrived with zero policies while the dashboard Library
555
- # tab still showed them. The new wording removes the
556
- # presumption and points at the actual recovery action.
557
- flat.append({
558
- "effect": default_action,
559
- "action": "*",
560
- # T79: stamp a synthetic policy_id so the audit dashboard
561
- # can render a recognizable chip + tooltip linking to the
562
- # right troubleshooting anchor. The reason_code stays the
563
- # same; the synthetic id is purely an audit-presentation
564
- # contract (audit ingest stores it verbatim).
565
- "id": "synthetic:NO_ACTIVE_POLICIES",
566
- "reason": (
567
- "No policies are active on this project. If the dashboard "
568
- "shows attached policies, regenerate the policy bundle."
569
- ),
570
- "reason_code": "NO_ACTIVE_POLICIES",
571
- })
579
+ # Copy-choice note (2026-04-19, the 2026-04-19 P0): the original
580
+ # NO_ACTIVE_POLICIES message presumed the user had defined no
581
+ # policies; in the common case the library-attachments state was
582
+ # simply empty while the dashboard still showed them. The wording
583
+ # below removes that presumption and points at recovery.
584
+ if default_on_empty == "observe":
585
+ # OBSERVE: allow + loud monitoring signal + audit.
586
+ flat.append({
587
+ "effect": "allow",
588
+ "action": "*",
589
+ "id": "synthetic:OBSERVE_MODE_NO_POLICY",
590
+ "reason": (
591
+ "OBSERVE MODE: no policies are active on this project, so "
592
+ "Control Zero is monitoring and auditing tool calls but "
593
+ "NOT enforcing -- every call is allowed and logged. Attach "
594
+ "a policy (or set the empty-project default to deny) in the "
595
+ "Control Zero dashboard to start enforcing."
596
+ ),
597
+ "reason_code": "OBSERVE_MODE_NO_POLICY",
598
+ })
599
+ else:
600
+ # Explicit non-observe empty posture (deny / warn / allow).
601
+ # Keeps the historical NO_ACTIVE_POLICIES reason_code so
602
+ # dashboards still bucket it as "nothing attached". The
603
+ # effect honors the operator's declared default_on_empty.
604
+ flat.append({
605
+ "effect": default_on_empty,
606
+ "action": "*",
607
+ # T79: stamp a synthetic policy_id so the audit dashboard
608
+ # can render a recognizable chip + tooltip linking to the
609
+ # right troubleshooting anchor.
610
+ "id": "synthetic:NO_ACTIVE_POLICIES",
611
+ "reason": (
612
+ "No policies are active on this project. If the dashboard "
613
+ "shows attached policies, regenerate the policy bundle."
614
+ ),
615
+ "reason_code": "NO_ACTIVE_POLICIES",
616
+ })
572
617
 
573
618
  out = {
574
619
  "version": "1",
@@ -576,6 +621,7 @@ def translate_to_local_policy(payload: dict) -> dict:
576
621
  "settings": {
577
622
  "default_action": default_action,
578
623
  "default_on_missing": default_on_missing,
624
+ "default_on_empty": default_on_empty,
579
625
  "default_on_tamper": default_on_tamper,
580
626
  },
581
627
  }
@@ -62,6 +62,20 @@ POLICY_ENGINE_VERSION = "0.1.0"
62
62
  REASON_CODE_RULE_MATCH = "RULE_MATCH"
63
63
  REASON_CODE_NO_RULE_MATCH = "NO_RULE_MATCH"
64
64
  REASON_CODE_NO_ACTIVE_POLICIES = "NO_ACTIVE_POLICIES"
65
+ # OBSERVE_MODE_NO_POLICY (#1247 item 3 / #1252): a genuinely-empty,
66
+ # rule-less bundle that RESOLVED SUCCESSFULLY (zero attached/active
67
+ # policies) and whose resolved empty-policy posture is "observe". The
68
+ # call is ALLOWED THROUGH but the decision is loudly flagged as
69
+ # monitoring-only (observe=True on the decision) and audited, so the
70
+ # operator knows the engine is NOT enforcing. This is deliberately a
71
+ # DISTINCT code from NO_ACTIVE_POLICIES (which remains for the
72
+ # deny/warn empty-posture cases and pre-refinement consumers) and from
73
+ # BUNDLE_MISSING (a resolution FAILURE, which still fails closed -- see
74
+ # make_bundle_missing_policy). The empty-vs-error boundary is the
75
+ # security-critical invariant here: OBSERVE_MODE_NO_POLICY is ONLY ever
76
+ # emitted on the SUCCESSFUL-resolution empty path, never on a pull /
77
+ # RLS / auth error.
78
+ REASON_CODE_OBSERVE_MODE_NO_POLICY = "OBSERVE_MODE_NO_POLICY"
65
79
  REASON_CODE_BUNDLE_MISSING = "BUNDLE_MISSING"
66
80
  REASON_CODE_BUNDLE_TAMPERED = "BUNDLE_TAMPERED"
67
81
  REASON_CODE_MACHINE_QUARANTINED = "MACHINE_QUARANTINED"
@@ -93,6 +107,7 @@ VALID_REASON_CODES = frozenset({
93
107
  REASON_CODE_RULE_MATCH,
94
108
  REASON_CODE_NO_RULE_MATCH,
95
109
  REASON_CODE_NO_ACTIVE_POLICIES,
110
+ REASON_CODE_OBSERVE_MODE_NO_POLICY,
96
111
  REASON_CODE_BUNDLE_MISSING,
97
112
  REASON_CODE_BUNDLE_TAMPERED,
98
113
  REASON_CODE_MACHINE_QUARANTINED,
@@ -129,6 +144,7 @@ VALID_REASON_CODES = frozenset({
129
144
  SYNTHETIC_POLICY_ID_PREFIX = "synthetic:"
130
145
  SYNTHETIC_NO_RULE_MATCH = "synthetic:NO_RULE_MATCH"
131
146
  SYNTHETIC_NO_ACTIVE_POLICIES = "synthetic:NO_ACTIVE_POLICIES"
147
+ SYNTHETIC_OBSERVE_MODE_NO_POLICY = "synthetic:OBSERVE_MODE_NO_POLICY"
132
148
  SYNTHETIC_BUNDLE_MISSING = "synthetic:BUNDLE_MISSING"
133
149
  SYNTHETIC_RESOURCE_GATE_SKIP = "synthetic:RESOURCE_GATE_SKIP"
134
150
  SYNTHETIC_QUARANTINE = "synthetic:QUARANTINE"
@@ -137,6 +153,7 @@ SYNTHETIC_ENGINE_UNAVAILABLE = "synthetic:ENGINE_UNAVAILABLE"
137
153
  VALID_SYNTHETIC_POLICY_IDS = frozenset({
138
154
  SYNTHETIC_NO_RULE_MATCH,
139
155
  SYNTHETIC_NO_ACTIVE_POLICIES,
156
+ SYNTHETIC_OBSERVE_MODE_NO_POLICY,
140
157
  SYNTHETIC_BUNDLE_MISSING,
141
158
  SYNTHETIC_RESOURCE_GATE_SKIP,
142
159
  SYNTHETIC_QUARANTINE,
@@ -155,8 +172,30 @@ DEFAULT_BUNDLE_ACTION = "deny"
155
172
  DEFAULT_BUNDLE_ON_MISSING = "deny"
156
173
  DEFAULT_BUNDLE_ON_TAMPER = "warn"
157
174
 
175
+ # default_on_empty (#1247 item 3 / #1252, founder-approved posture
176
+ # refinement): the effect for a genuinely-EMPTY bundle -- one that
177
+ # RESOLVED SUCCESSFULLY but has zero attached/active rules. This is a
178
+ # SEPARATE knob from default_action (which governs the non-empty
179
+ # no-match path). The distinction is the whole point: an org that has
180
+ # authored an allow-list deliberately wants no-match -> deny
181
+ # (default_action), while an org that has attached NOTHING YET should
182
+ # not be bricked day-one -- it observes (allow + audit + a loud
183
+ # "monitoring, not enforcing" signal) instead.
184
+ #
185
+ # Canonical default is "observe": a fresh/empty hosted project allows
186
+ # tool calls through but loudly flags + audits every one as
187
+ # OBSERVE_MODE_NO_POLICY, so the operator sees the engine is wired up
188
+ # and watching, then authors real rules. An operator can override to
189
+ # "deny" (fail-closed empty), "warn" (shadow), or "allow" (silent
190
+ # allow, discouraged). CRITICAL: this only ever applies on the
191
+ # SUCCESSFUL-resolution empty path; a resolution ERROR routes through
192
+ # make_bundle_missing_policy + default_on_missing and STILL FAILS
193
+ # CLOSED -- default_on_empty never weakens the error path.
194
+ DEFAULT_BUNDLE_ON_EMPTY = "observe"
195
+
158
196
  VALID_DEFAULT_ACTIONS = frozenset({"deny", "allow", "warn"})
159
197
  VALID_DEFAULT_ON_MISSING = frozenset({"deny", "allow"})
198
+ VALID_DEFAULT_ON_EMPTY = frozenset({"observe", "deny", "allow", "warn"})
160
199
  VALID_DEFAULT_ON_TAMPER = frozenset({"warn", "deny", "deny-all", "quarantine"})
161
200
 
162
201
 
@@ -217,6 +256,19 @@ class PolicyDecision:
217
256
  # requires_approval is True. None falls back to policy_id at
218
257
  # request time so the approver always sees a label.
219
258
  approval_action: Optional[str] = None
259
+ # observe (#1247 item 3 / #1252): True when this decision is an
260
+ # OBSERVE-MODE allow -- the call is permitted (effect="allow") but
261
+ # the engine is NOT enforcing because the bundle is genuinely empty
262
+ # (zero attached/active policies) and the resolved empty-policy
263
+ # posture is "observe". This is the LOUD signal the security review
264
+ # mandated: an observe allow must never look like a normal,
265
+ # rule-driven allow. Consumers (CLI output, audit annotation, hook
266
+ # banners) branch on this to print "OBSERVE MODE: monitoring, not
267
+ # enforcing". It is set ONLY alongside
268
+ # reason_code=OBSERVE_MODE_NO_POLICY and is False on every other
269
+ # path (including a real rule that happens to allow, and the
270
+ # fail-closed BUNDLE_MISSING / NO_RULE_MATCH paths).
271
+ observe: bool = False
220
272
 
221
273
  @property
222
274
  def decision(self) -> str:
@@ -457,6 +509,25 @@ class PolicyEvaluator:
457
509
  reason_code=decision_code,
458
510
  evaluated_rules=evaluated,
459
511
  gate_matched=gate_matched_value,
512
+ # #1247 item 3: the empty-bundle observe catch-all
513
+ # (synthetic OBSERVE_MODE_NO_POLICY rule) is an allow
514
+ # that must be loudly flagged as monitoring-only. Set
515
+ # the observe signal here so CLI / hook / audit
516
+ # consumers can distinguish it from a normal
517
+ # rule-driven allow.
518
+ #
519
+ # Hardening (security review L1): gate on BOTH the
520
+ # reason_code AND the synthetic policy_id, so that even a
521
+ # (backend-signed) bundle rule that carried a forged
522
+ # reason_code="OBSERVE_MODE_NO_POLICY" cannot mislabel a
523
+ # rule-driven allow as observe -- the synthetic id is
524
+ # only ever stamped by translate_to_local_policy's empty
525
+ # branch. observe is an audit/UX label, never an
526
+ # authorization input, so this is belt-and-suspenders.
527
+ observe=(
528
+ decision_code == REASON_CODE_OBSERVE_MODE_NO_POLICY
529
+ and (rule.id or "") == SYNTHETIC_OBSERVE_MODE_NO_POLICY
530
+ ),
460
531
  )
461
532
  # DLP scan: only when the policy decision is "allow" and args exist
462
533
  if decision.allowed and args and self._dlp_scanner is not None:
@@ -475,11 +546,32 @@ class PolicyEvaluator:
475
546
  # that regex-match the reason string (a pattern we actively
476
547
  # discourage -- use reason_code instead) keep working.
477
548
  if self._default_action == "deny":
478
- reason = "No matching policy rule (fail-closed default)"
549
+ # #1247 (folded from #1257): a deny-by-default no-rule-match is
550
+ # the single most confusing block a customer hits -- a benign
551
+ # tool (e.g. `bash:find`) is denied not because a rule forbids
552
+ # it but because no rule mentions it and the bundle's
553
+ # default_on_missing is deny. The historical message ("fail-
554
+ # closed default") gave no path out and looked like a bug.
555
+ # Name the unmatched action and the exact remediation so the
556
+ # deny is self-explaining. The leading phrase keeps the legacy
557
+ # "fail-closed default" substring for any downstream regex
558
+ # consumer (reason_code=NO_RULE_MATCH is the supported signal;
559
+ # this is belt-and-suspenders). NOTE: this is the NON-EMPTY
560
+ # bundle path -- a genuinely empty bundle short-circuits to the
561
+ # synthetic OBSERVE catch-all above and never reaches here, so
562
+ # observe and self-explaining-deny compose cleanly.
563
+ reason = (
564
+ f"No matching policy rule for '{action}' (fail-closed default; "
565
+ "default_on_missing=deny). This tool is neither allowed nor "
566
+ "denied by any rule in your hosted policy, so it is blocked. "
567
+ "Add a rule that allows it (e.g. allow: '*' as a catch-all, or "
568
+ f"allow: '{action}'), or set the project/org default to allow, "
569
+ "in the Control Zero dashboard."
570
+ )
479
571
  elif self._default_action == "allow":
480
- reason = "No matching policy rule (default_action=allow)"
572
+ reason = f"No matching policy rule for '{action}' (default_action=allow)"
481
573
  else: # "warn"
482
- reason = "No matching policy rule (default_action=warn)"
574
+ reason = f"No matching policy rule for '{action}' (default_action=warn)"
483
575
 
484
576
  # T79: distinguish the T83-class signature ("a rule's actions
485
577
  # matched but its resources gate excluded the call") from the
@@ -214,6 +214,18 @@ def test(tool: str, method: str, policy: str, args: str, hitl: Optional[str], hi
214
214
  click.echo(f"args: {args_dict}")
215
215
  click.echo("")
216
216
  click.secho(f"DECISION: {decision.effect.upper()}", fg=color, bold=True)
217
+ # #1247 item 3: an observe-mode allow is NOT a normal allow -- the
218
+ # engine is monitoring, not enforcing, because the project has no
219
+ # active policies. Print a distinct, loud line so the operator can
220
+ # never mistake an empty-bundle observe for "my rules allowed this".
221
+ if getattr(decision, "observe", False):
222
+ click.secho(
223
+ "OBSERVE MODE: monitoring, not enforcing -- this project has "
224
+ "no active policies, so every tool call is allowed and logged. "
225
+ "Attach a policy to start enforcing.",
226
+ fg="yellow",
227
+ bold=True,
228
+ )
217
229
  if decision.policy_id:
218
230
  click.echo(f"matched: {decision.policy_id}")
219
231
  if decision.reason:
@@ -676,9 +676,11 @@ class Client:
676
676
  # Non-2xx: map status + body code to the right typed exception.
677
677
  body_code = ""
678
678
  body_msg = ""
679
+ err_body_dict: dict = {}
679
680
  try:
680
681
  err_body = resp.json()
681
682
  if isinstance(err_body, dict):
683
+ err_body_dict = err_body
682
684
  body_code = str(err_body.get("code", "") or "")
683
685
  body_msg = str(err_body.get("message", "") or err_body.get("error", "") or "")
684
686
  except ValueError:
@@ -717,7 +719,7 @@ class Client:
717
719
  body_msg or "Requestor identity claim rejected by backend"
718
720
  )
719
721
  if status == 401:
720
- raise HostedAuthError(body_msg or "hosted API key rejected by backend")
722
+ raise HostedAuthError.from_response(status, err_body_dict)
721
723
  if status == 404:
722
724
  raise HITLNotConfiguredError(
723
725
  body_msg or "Approvals not configured for this org"
@@ -885,7 +887,11 @@ class Client:
885
887
  if status == 404:
886
888
  raise SecretNotFound(f"secret {name!r} not found")
887
889
  if status == 401:
888
- raise HostedAuthError("hosted API key rejected by backend")
890
+ try:
891
+ secret_err_body = resp.json()
892
+ except ValueError:
893
+ secret_err_body = None
894
+ raise HostedAuthError.from_response(status, secret_err_body)
889
895
  if not (200 <= status < 300):
890
896
  raise HITLBackendUnreachableError(
891
897
  f"GET /api/secrets/{name} returned HTTP {status}"
@@ -95,11 +95,16 @@ codes:
95
95
  - code: E1101
96
96
  title: API key rejected (401)
97
97
  what: >-
98
- The hosted backend rejected the API key. The key is either revoked,
99
- from a different environment (test vs live), or never existed.
100
- fix: >-
101
- Check your dashboard for the current key under Settings API Keys.
102
- Make sure CONTROLZERO_API_KEY matches the key shown there.
98
+ The hosted backend rejected the API key with HTTP 401. The key is
99
+ unknown, revoked, expired, or a never-activated placeholder. For
100
+ safety the backend does not say which (so a 401 cannot be used to
101
+ probe which keys exist), but in every case the same fix applies:
102
+ the key you are using is no longer valid.
103
+ fix: >-
104
+ Generate a fresh key in the dashboard under Settings -> API Keys
105
+ (https://app.controlzero.ai/settings/api-keys) and set
106
+ CONTROLZERO_API_KEY to it. If you just rotated a key, make sure the
107
+ environment your agent runs in picked up the new value.
103
108
  doc: E1101-key-rejected
104
109
 
105
110
  - code: E1102
@@ -28,6 +28,18 @@ from controlzero._internal.enforcer import PolicyDeniedError, PolicyDecision
28
28
  from controlzero.error_codes import ErrorCode, get as _get_error_code
29
29
 
30
30
 
31
+ def _first_str(*values: object) -> Optional[str]:
32
+ """Return the first value that is a non-empty string, else None.
33
+
34
+ Used to read a field that the backend may place at the top level or
35
+ nested under ``error`` without crashing on unexpected types.
36
+ """
37
+ for v in values:
38
+ if isinstance(v, str) and v.strip():
39
+ return v
40
+ return None
41
+
42
+
31
43
  def _augment_with_code(message: str, code_key: Optional[str]) -> str:
32
44
  """Append the catalog's fix + docs URL to a raw error message.
33
45
 
@@ -137,6 +149,12 @@ class BundleSignatureError(_CZErrorMixin, Exception):
137
149
  super().__init__(_augment_with_code(message, self.E_CODE))
138
150
 
139
151
 
152
+ _DEFAULT_AUTH_REMEDIATION = (
153
+ "Generate a new API key in the dashboard (Settings -> API Keys, "
154
+ "https://app.controlzero.ai/settings/api-keys) and set CONTROLZERO_API_KEY to it."
155
+ )
156
+
157
+
140
158
  class HostedAuthError(_CZErrorMixin, RuntimeError):
141
159
  """Raised when the project API key is rejected by the backend
142
160
  (401/403). Maps to E1101.
@@ -144,12 +162,97 @@ class HostedAuthError(_CZErrorMixin, RuntimeError):
144
162
  This is a permanent failure: the caller supplied an invalid or
145
163
  revoked API key. Retrying with the same key will not help. The
146
164
  SDK surfaces this so the user can correct their configuration.
165
+
166
+ #1254: the bare "API key rejected (401)" gave the user no
167
+ idea WHY or what to do. The message now always tells the user the
168
+ key is invalid/revoked and how to fix it. When the backend supplies
169
+ a structured 401 body (``reason`` + ``remediation``), those are
170
+ preserved on the exception so programmatic callers can branch on
171
+ :attr:`reason` while humans read the actionable message.
172
+
173
+ The backend keeps ``reason`` coarse on purpose (not_found / revoked /
174
+ expired / placeholder all collapse to ``invalid_or_revoked``) so a
175
+ 401 is never an enumeration oracle; the SDK does not try to refine it.
176
+
177
+ Attributes:
178
+ reason: coarse machine-readable reason from the backend, e.g.
179
+ ``"invalid_or_revoked"``. ``None`` when the backend did not
180
+ send a structured body (older backend).
181
+ remediation: copy-pasteable next step shown to the user.
147
182
  """
148
183
 
149
184
  E_CODE = "E1101"
150
185
 
151
- def __init__(self, message: str = "hosted API key rejected by backend"):
152
- super().__init__(_augment_with_code(message, self.E_CODE))
186
+ def __init__(
187
+ self,
188
+ message: Optional[str] = None,
189
+ *,
190
+ reason: Optional[str] = None,
191
+ remediation: Optional[str] = None,
192
+ ):
193
+ self.reason = reason
194
+ self.remediation = remediation or _DEFAULT_AUTH_REMEDIATION
195
+ if message is None:
196
+ message = (
197
+ "Your Control Zero API key was rejected by the backend "
198
+ "(invalid, revoked, or expired)."
199
+ )
200
+ full = f"{message} {self.remediation}"
201
+ super().__init__(_augment_with_code(full, self.E_CODE))
202
+
203
+ @classmethod
204
+ def from_response(
205
+ cls,
206
+ status_code: int,
207
+ body: object = None,
208
+ *,
209
+ context: str = "",
210
+ ) -> "HostedAuthError":
211
+ """Build a HostedAuthError from a backend 401/403 response.
212
+
213
+ ``body`` is the parsed JSON (a dict) when available, else None.
214
+ The backend may put the structured fields either at the top
215
+ level (``body["reason"]``) or nested under ``body["error"]``;
216
+ we read both. ``context`` is an optional short phrase like
217
+ "during bundle pull" appended to the headline so logs say which
218
+ request failed. Never raises on a malformed body -- a bad body
219
+ just yields the actionable default message.
220
+ """
221
+ reason: Optional[str] = None
222
+ remediation: Optional[str] = None
223
+ backend_msg: Optional[str] = None
224
+ if isinstance(body, dict):
225
+ err = body.get("error")
226
+ err_dict = err if isinstance(err, dict) else {}
227
+ reason = _first_str(body.get("reason"), err_dict.get("reason"))
228
+ remediation = _first_str(
229
+ body.get("remediation"), err_dict.get("remediation")
230
+ )
231
+ # The backend message can be body["message"], the nested
232
+ # error.message, or -- for back-compat with non-structured
233
+ # backends -- a plain string in the top-level "error" key
234
+ # (e.g. {"error": "Unauthorized"}).
235
+ err_str = err if isinstance(err, str) else None
236
+ backend_msg = _first_str(
237
+ body.get("message"), err_dict.get("message"), err_str
238
+ )
239
+
240
+ headline = (
241
+ "Your Control Zero API key was rejected by the backend "
242
+ "(invalid, revoked, or expired)."
243
+ )
244
+ if context:
245
+ headline = (
246
+ f"Your Control Zero API key was rejected by the backend "
247
+ f"{context} (invalid, revoked, or expired)."
248
+ )
249
+ # Prefer the backend's own human message when it is more specific
250
+ # than our generic headline, but always keep the headline so the
251
+ # user sees the "what to do" framing even with an old backend.
252
+ message = headline
253
+ if backend_msg and backend_msg.lower() not in ("invalid api key", ""):
254
+ message = f"{headline} (backend: {backend_msg})"
255
+ return cls(message, reason=reason, remediation=remediation)
153
256
 
154
257
 
155
258
  class HostedBootstrapError(_CZErrorMixin, RuntimeError):