controlzero 1.11.8__tar.gz → 1.11.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (285) hide show
  1. {controlzero-1.11.8 → controlzero-1.11.9}/CHANGELOG.md +44 -0
  2. {controlzero-1.11.8 → controlzero-1.11.9}/PKG-INFO +27 -1
  3. {controlzero-1.11.8 → controlzero-1.11.9}/README.md +26 -0
  4. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/__init__.py +1 -1
  5. controlzero-1.11.9/controlzero/_internal/_messages.py +273 -0
  6. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/_internal/bundle.py +15 -0
  7. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/_internal/enforcer.py +105 -39
  8. controlzero-1.11.9/controlzero/_internal/source_labels.py +165 -0
  9. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/_internal/types.py +7 -0
  10. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/audit_remote.py +16 -1
  11. controlzero-1.11.9/controlzero/cli/agent_config.py +171 -0
  12. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/hosts/base.py +7 -1
  13. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/main.py +693 -90
  14. controlzero-1.11.9/controlzero/cli/output.py +172 -0
  15. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/client.py +43 -4
  16. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/policy_loader.py +33 -0
  17. {controlzero-1.11.8 → controlzero-1.11.9}/pyproject.toml +1 -1
  18. controlzero-1.11.9/tests/test_audit_project_id_attribution_pr2.py +305 -0
  19. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_hitl_validator_keys.py +6 -1
  20. controlzero-1.11.9/tests/test_hook_mask_failclosed.py +95 -0
  21. controlzero-1.11.9/tests/test_localized_reason.py +302 -0
  22. controlzero-1.11.9/tests/test_pr4_install_messaging.py +377 -0
  23. controlzero-1.11.9/tests/test_pr4_output.py +144 -0
  24. controlzero-1.11.9/tests/test_pr4_status.py +139 -0
  25. controlzero-1.11.9/tests/test_source_labels.py +95 -0
  26. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_synthetic_policy_id_t79.py +6 -2
  27. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_t99_install_prefetch_bundle.py +5 -5
  28. {controlzero-1.11.8 → controlzero-1.11.9}/.gitignore +0 -0
  29. {controlzero-1.11.8 → controlzero-1.11.9}/Dockerfile.test +0 -0
  30. {controlzero-1.11.8 → controlzero-1.11.9}/LICENSE +0 -0
  31. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/__main__.py +0 -0
  32. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/_internal/__init__.py +0 -0
  33. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/_internal/action_aliases.py +0 -0
  34. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/_internal/action_validator.py +0 -0
  35. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/_internal/credential_hook.py +0 -0
  36. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/_internal/credential_scanner.py +0 -0
  37. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/_internal/credentials_data/__init__.py +0 -0
  38. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/_internal/credentials_data/built_in.yaml +0 -0
  39. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/_internal/dlp_scanner.py +0 -0
  40. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/_internal/hook_extractors.py +0 -0
  41. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/_internal/io_capture_producer.py +0 -0
  42. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/_internal/tool_extractors.json +0 -0
  43. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/audit_local.py +0 -0
  44. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/canonical.py +0 -0
  45. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/__init__.py +0 -0
  46. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/__main__.py +0 -0
  47. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/_secrets.py +0 -0
  48. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/console.py +0 -0
  49. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/debug_bundle.py +0 -0
  50. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/doctor.py +0 -0
  51. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/exec_cmd.py +0 -0
  52. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/hook_entry.py +0 -0
  53. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/hosts/__init__.py +0 -0
  54. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/hosts/antigravity.py +0 -0
  55. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/hosts/claude_code.py +0 -0
  56. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/hosts/codex_cli.py +0 -0
  57. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/hosts/cursor.py +0 -0
  58. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/hosts/cursor_cli.py +0 -0
  59. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/hosts/gemini_cli.py +0 -0
  60. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/hosts/kiro.py +0 -0
  61. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/hosts/unknown.py +0 -0
  62. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/kiro_adapter.py +0 -0
  63. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/migrate.py +0 -0
  64. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/spool_cmd.py +0 -0
  65. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/telemetry_consent.py +0 -0
  66. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/templates/antigravity/hooks.json +0 -0
  67. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/templates/antigravity.yaml +0 -0
  68. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/templates/autogen.yaml +0 -0
  69. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/templates/claude-code.yaml +0 -0
  70. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/templates/codex-cli.yaml +0 -0
  71. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/templates/cost-cap.yaml +0 -0
  72. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/templates/crewai.yaml +0 -0
  73. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/templates/cursor.yaml +0 -0
  74. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/templates/gemini-cli.yaml +0 -0
  75. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/templates/generic.yaml +0 -0
  76. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/templates/kiro/ide-file-save.kiro.hook +0 -0
  77. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/templates/kiro/ide-pre-tool-use.kiro.hook +0 -0
  78. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/templates/kiro/ide-prompt-submit.kiro.hook +0 -0
  79. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/templates/kiro/kiro.yaml +0 -0
  80. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/templates/langchain.yaml +0 -0
  81. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/templates/mcp.yaml +0 -0
  82. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/templates/rag.yaml +0 -0
  83. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/cli/update_cmd.py +0 -0
  84. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/device.py +0 -0
  85. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/enrollment.py +0 -0
  86. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/error_codes.py +0 -0
  87. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/error_codes.yaml +0 -0
  88. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/errors.py +0 -0
  89. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/hitl/__init__.py +0 -0
  90. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/hitl/grant_protocol.py +0 -0
  91. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/hitl/mock.py +0 -0
  92. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/hitl/pending_approval.py +0 -0
  93. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/hitl/secret_leak_guard.py +0 -0
  94. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/hitl/status.py +0 -0
  95. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/hook_integrity.py +0 -0
  96. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/hooks/__init__.py +0 -0
  97. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/hooks/tool_output_handler.py +0 -0
  98. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/hosted_policy.py +0 -0
  99. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/integrations/__init__.py +0 -0
  100. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/integrations/anthropic.py +0 -0
  101. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/integrations/autogen.py +0 -0
  102. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/integrations/braintrust.py +0 -0
  103. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/integrations/crewai/__init__.py +0 -0
  104. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/integrations/crewai/agent.py +0 -0
  105. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/integrations/crewai/crew.py +0 -0
  106. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/integrations/crewai/task.py +0 -0
  107. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/integrations/crewai/tool.py +0 -0
  108. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/integrations/google.py +0 -0
  109. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/integrations/google_adk/__init__.py +0 -0
  110. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/integrations/google_adk/agent.py +0 -0
  111. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/integrations/google_adk/tool.py +0 -0
  112. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/integrations/langchain/__init__.py +0 -0
  113. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/integrations/langchain/agent.py +0 -0
  114. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/integrations/langchain/callbacks.py +0 -0
  115. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/integrations/langchain/chain.py +0 -0
  116. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/integrations/langchain/graph.py +0 -0
  117. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/integrations/langchain/modern.py +0 -0
  118. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/integrations/langchain/tool.py +0 -0
  119. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/integrations/langfuse.py +0 -0
  120. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/integrations/litellm.py +0 -0
  121. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/integrations/openai.py +0 -0
  122. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/integrations/pydantic_ai.py +0 -0
  123. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/integrations/vercel_ai.py +0 -0
  124. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/layout_migration.py +0 -0
  125. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/secrets/__init__.py +0 -0
  126. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/secrets/reference.py +0 -0
  127. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/secrets/resolver.py +0 -0
  128. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/secrets/secretstr.py +0 -0
  129. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/spool/__init__.py +0 -0
  130. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/spool/_compress.py +0 -0
  131. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/spool/_constants.py +0 -0
  132. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/spool/_crc32c.py +0 -0
  133. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/spool/_crypto.py +0 -0
  134. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/spool/_frame.py +0 -0
  135. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/spool/_keyring.py +0 -0
  136. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/spool/_metrics.py +0 -0
  137. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/spool/_spool.py +0 -0
  138. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/spool/_state.py +0 -0
  139. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/spool/_uploader.py +0 -0
  140. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/spool/cz-audit-v1.dict +0 -0
  141. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/tamper.py +0 -0
  142. {controlzero-1.11.8 → controlzero-1.11.9}/controlzero/tracecontext.py +0 -0
  143. {controlzero-1.11.8 → controlzero-1.11.9}/examples/hello_world.py +0 -0
  144. {controlzero-1.11.8 → controlzero-1.11.9}/tests/_fixtures/antigravity-envelope-expected.json +0 -0
  145. {controlzero-1.11.8 → controlzero-1.11.9}/tests/_fixtures/jcs_args_hash_vectors.json +0 -0
  146. {controlzero-1.11.8 → controlzero-1.11.9}/tests/conftest.py +0 -0
  147. {controlzero-1.11.8 → controlzero-1.11.9}/tests/integrations/__init__.py +0 -0
  148. {controlzero-1.11.8 → controlzero-1.11.9}/tests/integrations/test_google.py +0 -0
  149. {controlzero-1.11.8 → controlzero-1.11.9}/tests/parity/action_aliases.json +0 -0
  150. {controlzero-1.11.8 → controlzero-1.11.9}/tests/spool/__init__.py +0 -0
  151. {controlzero-1.11.8 → controlzero-1.11.9}/tests/spool/conftest.py +0 -0
  152. {controlzero-1.11.8 → controlzero-1.11.9}/tests/spool/test_spool_cli.py +0 -0
  153. {controlzero-1.11.8 → controlzero-1.11.9}/tests/spool/test_spool_concurrency.py +0 -0
  154. {controlzero-1.11.8 → controlzero-1.11.9}/tests/spool/test_spool_conformance.py +0 -0
  155. {controlzero-1.11.8 → controlzero-1.11.9}/tests/spool/test_spool_core.py +0 -0
  156. {controlzero-1.11.8 → controlzero-1.11.9}/tests/spool/test_spool_crash.py +0 -0
  157. {controlzero-1.11.8 → controlzero-1.11.9}/tests/spool/test_spool_diskfull.py +0 -0
  158. {controlzero-1.11.8 → controlzero-1.11.9}/tests/spool/test_spool_durable_default_tamper.py +0 -0
  159. {controlzero-1.11.8 → controlzero-1.11.9}/tests/spool/test_spool_keychain_dek.py +0 -0
  160. {controlzero-1.11.8 → controlzero-1.11.9}/tests/spool/test_spool_sink_wiring.py +0 -0
  161. {controlzero-1.11.8 → controlzero-1.11.9}/tests/spool/test_spool_transcript_localack.py +0 -0
  162. {controlzero-1.11.8 → controlzero-1.11.9}/tests/spool/test_spool_uploader.py +0 -0
  163. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_action_aliases.py +0 -0
  164. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_action_canonicalization.py +0 -0
  165. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_action_validator_t86.py +0 -0
  166. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_adapter_interface.py +0 -0
  167. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_agent_name_env.py +0 -0
  168. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_agy_docs_no_exit2_1373.py +0 -0
  169. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_antigravity_adapter.py +0 -0
  170. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_antigravity_envelope_parity.py +0 -0
  171. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_antigravity_ga_blockers_1248.py +0 -0
  172. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_antigravity_hook_check.py +0 -0
  173. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_antigravity_install.py +0 -0
  174. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_antigravity_posttooluse_observe_58.py +0 -0
  175. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_antigravity_tool_vocab_1303.py +0 -0
  176. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_api_key_mask.py +0 -0
  177. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_audit_remote.py +0 -0
  178. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_audit_remote_io_capture.py +0 -0
  179. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_audit_remote_sdk_version.py +0 -0
  180. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_audit_sink_isolation.py +0 -0
  181. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_bundle_cache_freshness_1303.py +0 -0
  182. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_bundle_parser.py +0 -0
  183. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_bundle_translate.py +0 -0
  184. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_canonical_phase1a.py +0 -0
  185. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_capability_matrix_doc.py +0 -0
  186. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_cli_carve_out.py +0 -0
  187. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_cli_debug_bundle.py +0 -0
  188. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_cli_extractor_integration.py +0 -0
  189. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_cli_hook.py +0 -0
  190. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_cli_hook_entry.py +0 -0
  191. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_cli_hosted_refresh.py +0 -0
  192. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_cli_init.py +0 -0
  193. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_cli_init_templates.py +0 -0
  194. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_cli_tail.py +0 -0
  195. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_cli_test.py +0 -0
  196. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_cli_validate.py +0 -0
  197. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_coding_agent_hooks.py +0 -0
  198. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_conditions.py +0 -0
  199. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_config_format_parity_1303.py +0 -0
  200. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_conformance.py +0 -0
  201. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_console.py +0 -0
  202. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_credential_hook.py +0 -0
  203. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_cursor_adapter.py +0 -0
  204. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_default_action.py +0 -0
  205. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_device.py +0 -0
  206. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_dlp_mask.py +0 -0
  207. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_dlp_scanner.py +0 -0
  208. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_doctor.py +0 -0
  209. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_engine_version_consistency.py +0 -0
  210. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_enrollment.py +0 -0
  211. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_env_dump_438.py +0 -0
  212. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_epic_1247_bryan_acceptance.py +0 -0
  213. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_error_codes.py +0 -0
  214. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_errors_e_codes.py +0 -0
  215. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_fail_closed_eval.py +0 -0
  216. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_failopen_1303.py +0 -0
  217. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_glob_matching.py +0 -0
  218. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_hitl_5d_email_install.py +0 -0
  219. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_hitl_6a_cli_flag.py +0 -0
  220. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_hitl_6a_exceptions.py +0 -0
  221. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_hitl_6a_get_secret_hitl.py +0 -0
  222. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_hitl_6a_mock_backend.py +0 -0
  223. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_hitl_6a_pending_approval.py +0 -0
  224. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_hitl_6a_request_approval.py +0 -0
  225. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_hitl_6a_secret_leak_guard.py +0 -0
  226. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_hitl_6a_wait.py +0 -0
  227. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_hitl_conformance.py +0 -0
  228. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_hitl_phase2b_protocol.py +0 -0
  229. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_hitl_reason_codes.py +0 -0
  230. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_hook_extractors.py +0 -0
  231. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_hook_integrity.py +0 -0
  232. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_hosted_local_audit_1247.py +0 -0
  233. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_hosted_local_shadow_warn_1265.py +0 -0
  234. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_hosted_policy_e2e.py +0 -0
  235. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_hosts_adapter.py +0 -0
  236. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_hybrid_mode_strict.py +0 -0
  237. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_hybrid_mode_warn.py +0 -0
  238. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_install_hook_command.py +0 -0
  239. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_install_hooks.py +0 -0
  240. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_io_capture_producer.py +0 -0
  241. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_kiro_adapter.py +0 -0
  242. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_kiro_cli_e2e.py +0 -0
  243. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_kiro_cli_hook_pin_1265.py +0 -0
  244. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_kiro_hook_templates.py +0 -0
  245. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_kiro_install.py +0 -0
  246. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_layout_migration_t101.py +0 -0
  247. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_layout_parity_t102.py +0 -0
  248. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_local_mode_dict.py +0 -0
  249. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_local_mode_file_json.py +0 -0
  250. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_local_mode_file_yaml.py +0 -0
  251. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_log_fallback_stderr.py +0 -0
  252. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_log_options_ignored_hosted.py +0 -0
  253. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_log_rotation.py +0 -0
  254. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_migrate.py +0 -0
  255. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_min_sdk_version_gate.py +0 -0
  256. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_multi_client_per_project_175.py +0 -0
  257. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_no_policy_no_key.py +0 -0
  258. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_observe_mode_1247.py +0 -0
  259. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_package_rename_shim.py +0 -0
  260. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_part3_active_policy_count_1303.py +0 -0
  261. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_policy_engine_version_phase1b.py +0 -0
  262. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_policy_freshness.py +0 -0
  263. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_policy_load_failclosed_f3.py +0 -0
  264. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_policy_settings.py +0 -0
  265. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_policy_source_audit.py +0 -0
  266. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_probe_audit_isolation.py +0 -0
  267. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_quarantine.py +0 -0
  268. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_reason_code.py +0 -0
  269. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_refresh.py +0 -0
  270. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_secrets.py +0 -0
  271. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_secrets_reference_resolver.py +0 -0
  272. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_sql_semantic_class.py +0 -0
  273. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_t103_precedence.py +0 -0
  274. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_t104_cache_gc.py +0 -0
  275. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_t108_local_override_audit.py +0 -0
  276. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_t96_single_audit_log.py +0 -0
  277. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_tamper.py +0 -0
  278. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_tamper_behavior.py +0 -0
  279. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_tamper_hook.py +0 -0
  280. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_telemetry_consent.py +0 -0
  281. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_tracecontext.py +0 -0
  282. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_unsafe_int_boundary.py +0 -0
  283. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_update_cmd.py +0 -0
  284. {controlzero-1.11.8 → controlzero-1.11.9}/tests/test_upgrade_nudge.py +0 -0
  285. {controlzero-1.11.8 → controlzero-1.11.9}/tools/cz-kiro-adapter +0 -0
@@ -1,5 +1,49 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.11.9 -- 2026-06-25 (policy-posture remediation: honest hosted/local CLI, per-agent keys, key-validity diagnostics)
4
+
5
+ ### Added
6
+
7
+ - **Severity-level CLI output (`controlzero.cli.output`).** All CLI prose now
8
+ routes through one renderer with INFO / WARNING / SUCCESS / ERROR levels:
9
+ WARNING + ERROR go to stderr, INFO + SUCCESS to stdout, colour honours
10
+ `NO_COLOR` / `CONTROLZERO_NO_COLOR` / `TERM=dumb` / non-TTY. No emojis. The
11
+ hook decision path and wire payloads are untouched -- the renderer is never
12
+ used inside `hook_check`, so decision reasons emit verbatim.
13
+ - **Per-integration (per-agent) API keys.** Each coding agent can bind its own
14
+ project key at `integrations/<agent>/config.yaml`. Resolution order is
15
+ `env (explicit) > per-agent (for that agent) > global`, fully back-compatible
16
+ (no per-agent key -> falls back to the global key). Per-agent dir `0700` /
17
+ file `0600`; the raw key is never echoed. This lets one machine run different
18
+ agents bound to different projects, each enforcing its own hosted bundle.
19
+ - **`controlzero status` rewrite.** Shows resolved runtime truth (mode, config
20
+ source, key, enrolled, audit dest) plus a per-agent bindings table and
21
+ per-key backend validity (active / expired / revoked) so a rejected key is
22
+ diagnosable from the CLI instead of only via the coarse E1101 at first tool
23
+ call (#1472).
24
+
25
+ ### Changed
26
+
27
+ - **Honest hosted/local posture messaging.** One canonical wording each for the
28
+ HOSTED and LOCAL install paths, routed through every installer. The HOSTED
29
+ banner states that the local `policy.yaml` is IGNORED while an API key is set,
30
+ and the unmatched-tool default (DENY fail-closed unless the project is set to
31
+ Allow by default) -- removing the "install said ALLOW ALL but runtime denied"
32
+ ambiguity.
33
+ - **Archive-on-hosted.** When an API key is configured, the local `policy.yaml`
34
+ (and its `.sig`) is moved (not deleted) into `archive/policy-<UTC-ts>.yaml`,
35
+ symlink-safe, every error non-fatal -- a hosted machine never silently runs a
36
+ stale local policy.
37
+
38
+ ### Fixed
39
+
40
+ - **`synthetic:UNATTRIBUTED_DENY`** added to the synthetic policy-id catalog so
41
+ an unattributed fail-closed deny renders a stable, documented policy id across
42
+ every surface instead of an opaque blank (#1472).
43
+ - **Audit attribution.** SDK audit wire builders forward `project_id`
44
+ unconditionally so signed-sink audit events are attributed to the right
45
+ project (pairs with the backend ingest fix).
46
+
3
47
  ## 1.11.8 -- 2026-06-22 (DLP mask enforcement + I/O capture: enrolled-machine capture fix + server-side PII redaction)
4
48
 
5
49
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: controlzero
3
- Version: 1.11.8
3
+ Version: 1.11.9
4
4
  Summary: AI agent governance: policies, audit, and observability for tool calls. Works locally with no signup.
5
5
  Project-URL: Homepage, https://controlzero.ai
6
6
  Project-URL: Documentation, https://docs.controlzero.ai
@@ -185,6 +185,32 @@ rules:
185
185
  Rules are evaluated top to bottom. The first match wins. If no rule matches,
186
186
  the call is denied (fail-closed).
187
187
 
188
+ ## Localized block messages (`reason_localized`)
189
+
190
+ The `reason` shown when a rule fires is plain UTF-8 text, so you can write it
191
+ in any language. To serve more than one language from the same policy, add a
192
+ `reason_localized` map and select a locale with the `CONTROLZERO_LOCALE`
193
+ environment variable (e.g. `ko`, `ko-KR`):
194
+
195
+ ```yaml
196
+ version: '1'
197
+ rules:
198
+ - deny: 'delete_*'
199
+ reason: 'Deletes need human approval' # English default
200
+ reason_localized:
201
+ ko: '삭제는 사람의 승인이 필요합니다'
202
+ - allow: '*'
203
+ ```
204
+
205
+ - When `CONTROLZERO_LOCALE` is unset (or set to a locale the rule has no entry
206
+ for), the plain `reason` is used -- existing behavior is unchanged.
207
+ - The SDK's own built-in messages (no-rule-match, DLP block, empty/observe
208
+ bundle) ship an English default plus a Korean translation, selected the same
209
+ way. The packs are pure in-binary maps (no runtime i18n dependency), so this
210
+ is air-gap safe.
211
+ - Localization is display-only: it never changes which rule matches or the
212
+ decided effect.
213
+
188
214
  ## Tamper detection and quarantine
189
215
 
190
216
  The policy YAML supports a `settings:` section that controls how the SDK
@@ -131,6 +131,32 @@ rules:
131
131
  Rules are evaluated top to bottom. The first match wins. If no rule matches,
132
132
  the call is denied (fail-closed).
133
133
 
134
+ ## Localized block messages (`reason_localized`)
135
+
136
+ The `reason` shown when a rule fires is plain UTF-8 text, so you can write it
137
+ in any language. To serve more than one language from the same policy, add a
138
+ `reason_localized` map and select a locale with the `CONTROLZERO_LOCALE`
139
+ environment variable (e.g. `ko`, `ko-KR`):
140
+
141
+ ```yaml
142
+ version: '1'
143
+ rules:
144
+ - deny: 'delete_*'
145
+ reason: 'Deletes need human approval' # English default
146
+ reason_localized:
147
+ ko: '삭제는 사람의 승인이 필요합니다'
148
+ - allow: '*'
149
+ ```
150
+
151
+ - When `CONTROLZERO_LOCALE` is unset (or set to a locale the rule has no entry
152
+ for), the plain `reason` is used -- existing behavior is unchanged.
153
+ - The SDK's own built-in messages (no-rule-match, DLP block, empty/observe
154
+ bundle) ship an English default plus a Korean translation, selected the same
155
+ way. The packs are pure in-binary maps (no runtime i18n dependency), so this
156
+ is air-gap safe.
157
+ - Localization is display-only: it never changes which rule matches or the
158
+ decided effect.
159
+
134
160
  ## Tamper detection and quarantine
135
161
 
136
162
  The policy YAML supports a `settings:` section that controls how the SDK
@@ -41,7 +41,7 @@ from controlzero.hitl.grant_protocol import (
41
41
  from controlzero.policy_loader import load_policy
42
42
  from controlzero.secrets import SecretRef, SecretStr, secret_ref
43
43
 
44
- __version__ = "1.11.8"
44
+ __version__ = "1.11.9"
45
45
 
46
46
  __all__ = [
47
47
  "Client",
@@ -0,0 +1,273 @@
1
+ """In-binary localization pack for the SDK-GENERATED decision reasons.
2
+
3
+ Why this exists ( checklist #25, gh#1439)
4
+ --------------------------------------------------
5
+ The user-facing ``reason`` string is carried end-to-end as UTF-8, so an
6
+ operator can already localize ANY rule today by typing the target-language
7
+ text into that rule's ``reason:`` -- or, for per-locale overrides, into the
8
+ new ``reason_localized:`` map (a ``{locale: message}`` dict on the rule). That
9
+ covers operator-authored rules. This module covers the OTHER half: the
10
+ messages the SDK itself GENERATES for the no-rule-match / empty-bundle /
11
+ missing-bundle / tampered / DLP paths. Those have no operator-authored text to
12
+ carry, so they need an in-binary pack.
13
+
14
+ No runtime i18n library is used: these are pure dict lookups, which keeps the
15
+ SDK air-gap safe (the PoC ships self-hosted with no network).
16
+
17
+ Contract
18
+ --------
19
+ - The ``en`` value is the single source of truth for the inline paths that now
20
+ read it (no-match, DLP, tamper, quarantine): it MUST stay byte-identical to
21
+ the legacy hard-coded string so existing consumers that regex-match the
22
+ ``reason`` text are unaffected. ``tests/test_localized_reason.py`` pins this.
23
+ - English remains the literal DEFAULT whenever ``CONTROLZERO_LOCALE`` is unset
24
+ or a locale key is missing from a pack entry.
25
+ - For the synthetic empty/missing-bundle rules the English text stays on the
26
+ rule's ``reason`` (unchanged bundle output); the pack supplies ONLY the
27
+ localized (non-en) override, looked up by the rule's ``reason_code``.
28
+
29
+ KOREAN COPY IS A PROFESSIONAL DRAFT and MUST be reviewed/approved by the customer
30
+ before being presented as final. See KO_Localization_2026-06-22 in the project
31
+ vault. The English default is never blocked on that review.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import os
37
+ from typing import Optional
38
+
39
+ DEFAULT_LOCALE = "en"
40
+
41
+
42
+ def resolve_locale(explicit: Optional[str] = None) -> str:
43
+ """Resolve the active locale.
44
+
45
+ Precedence: explicit arg > ``CONTROLZERO_LOCALE`` env > "" (English).
46
+ Returned lower-cased and stripped so lookups are case-insensitive.
47
+ """
48
+ if explicit:
49
+ return explicit.strip().lower()
50
+ return (os.environ.get("CONTROLZERO_LOCALE", "") or "").strip().lower()
51
+
52
+
53
+ def _locale_candidates(locale: str) -> list[str]:
54
+ """Return lookup candidates for a locale, most specific first.
55
+
56
+ ``ko-KR`` / ``ko_KR`` -> ``["ko-kr", "ko"]`` so a pack keyed by the bare
57
+ primary subtag ("ko") still matches a region-qualified request.
58
+ """
59
+ loc = (locale or "").strip().lower()
60
+ if not loc:
61
+ return []
62
+ out = [loc]
63
+ for sep in ("-", "_"):
64
+ if sep in loc:
65
+ primary = loc.split(sep, 1)[0]
66
+ if primary and primary not in out:
67
+ out.append(primary)
68
+ break
69
+ return out
70
+
71
+
72
+ # message key -> {locale -> template}. Templates use str.format placeholders
73
+ # ({action}, {rule_name}, {category}). The English values for the inline paths
74
+ # (NO_RULE_MATCH:*, DLP_BLOCKED:*, BUNDLE_TAMPERED, MACHINE_QUARANTINED) are
75
+ # byte-identical to the legacy strings. For the synthetic-rule codes
76
+ # (OBSERVE_MODE_NO_POLICY, NO_ACTIVE_POLICIES, BUNDLE_MISSING) the English is
77
+ # documentation/parity only -- the live English comes from the rule's reason.
78
+ SYSTEM_MESSAGES: dict[str, dict[str, str]] = {
79
+ # No-rule-match fallthrough (the most common block a Korean operator sees).
80
+ "NO_RULE_MATCH:deny": {
81
+ "en": (
82
+ "No matching policy rule for '{action}' (fail-closed default; "
83
+ "default_on_missing=deny). This tool is neither allowed nor "
84
+ "denied by any rule in your hosted policy, so it is blocked. "
85
+ "Add a rule that allows it (e.g. allow: '*' as a catch-all, or "
86
+ "allow: '{action}'), or set the project/org default to allow, "
87
+ "in the Control Zero dashboard."
88
+ ),
89
+ # ko: "'{action}'에 일치하는 정책 규칙이 없어 기본 차단됩니다 ..."
90
+ "ko": (
91
+ "'{action}'에 일치하는 정책 규칙이 없습니다(기본 차단; "
92
+ "default_on_missing=deny). 이 도구는 정책에서 허용되지도 "
93
+ "거부되지도 않았으므로 차단됩니다. Control Zero 대시보드에서 "
94
+ "이를 허용하는 규칙을 추가하거나(예: 포괄 규칙 allow: '*' 또는 "
95
+ "allow: '{action}'), 프로젝트/조직 기본값을 allow로 "
96
+ "설정하십시오."
97
+ ),
98
+ },
99
+ "NO_RULE_MATCH:allow": {
100
+ "en": "No matching policy rule for '{action}' (default_action=allow)",
101
+ # ko: "'{action}'에 일치하는 정책 규칙이 없습니다 (default_action=allow)"
102
+ "ko": "'{action}'에 일치하는 정책 규칙이 없습니다(default_action=allow)",
103
+ },
104
+ "NO_RULE_MATCH:warn": {
105
+ "en": "No matching policy rule for '{action}' (default_action=warn)",
106
+ # ko: "'{action}'에 일치하는 정책 규칙이 없습니다 (default_action=warn)"
107
+ "ko": "'{action}'에 일치하는 정책 규칙이 없습니다(default_action=warn)",
108
+ },
109
+ # DLP overrides (the tool-name policy allowed, but a DLP rule vetoed).
110
+ "DLP_BLOCKED:match": {
111
+ "en": (
112
+ "DLP rule '{rule_name}' matched {category} content in tool "
113
+ "arguments"
114
+ ),
115
+ # ko: "DLP 규칙 '{rule_name}'이(가) 도구 인자에서 {category} 콘텐츠와 ..."
116
+ "ko": (
117
+ "DLP 규칙 '{rule_name}'이(가) 도구 인자에서 {category} "
118
+ "콘텐츠와 일치하여 호출이 차단되었습니다."
119
+ ),
120
+ },
121
+ "DLP_BLOCKED:boundary": {
122
+ "en": (
123
+ "DLP rule '{rule_name}' matched {category} content that spans "
124
+ "argument boundaries and could not be safely masked; blocking "
125
+ "(fail-closed) rather than forwarding an un-redacted value"
126
+ ),
127
+ # ko: "DLP 규칙 '{rule_name}'이(가) 인자 경계에 걸친 {category} 콘텐츠와 ..."
128
+ "ko": (
129
+ "DLP 규칙 '{rule_name}'이(가) 인자 경계에 걸쳐 있는 "
130
+ "{category} 콘텐츠와 일치하여 안전하게 마스킹할 수 없으므로, "
131
+ "마스킹되지 않은 값을 전달하는 대신 차단합니다(기본 차단)."
132
+ ),
133
+ },
134
+ # Tamper / quarantine (constructed in client.py + cli/main.py).
135
+ "BUNDLE_TAMPERED": {
136
+ "en": (
137
+ "Policy tampering detected. Call blocked by tamper enforcement "
138
+ "policy."
139
+ ),
140
+ # ko: "정책 변조가 감지되었습니다. 변조 시행 정책에 의해 호출이 차단되었습니다."
141
+ "ko": (
142
+ "정책 변조가 감지되었습니다. 변조 시행 정책에 의해 호출이 "
143
+ "차단되었습니다."
144
+ ),
145
+ },
146
+ # CLI hook-check tamper enforcement (cli/main.py). Carries a {source}
147
+ # discriminator (policy_hmac / audit_chain) and the [Control Zero] prefix.
148
+ "BUNDLE_TAMPERED:cli": {
149
+ "en": (
150
+ "[Control Zero] Tamper detected ({source}). Blocked by enforcement "
151
+ "policy."
152
+ ),
153
+ # ko: "[Control Zero] 변조가 감지되었습니다({source}). 시행 정책에 의해 ..."
154
+ "ko": (
155
+ "[Control Zero] 변조가 감지되었습니다({source}). 시행 정책에 "
156
+ "의해 차단되었습니다."
157
+ ),
158
+ },
159
+ "MACHINE_QUARANTINED": {
160
+ "en": (
161
+ "Machine quarantined: policy tampering detected. All calls denied "
162
+ "until recovery."
163
+ ),
164
+ # ko: "머신이 격리되었습니다: 정책 변조가 감지되었습니다. 복구될 때까지 ..."
165
+ "ko": (
166
+ "머신이 격리되었습니다: 정책 변조가 감지되었습니다. 복구될 "
167
+ "때까지 모든 호출이 거부됩니다."
168
+ ),
169
+ },
170
+ # Synthetic empty / missing-bundle rules. English here is parity-only --
171
+ # the live English comes from the rule's `reason` (bundle output unchanged);
172
+ # the enforcer reads ONLY the localized (non-en) value below, by code.
173
+ "OBSERVE_MODE_NO_POLICY": {
174
+ "en": (
175
+ "OBSERVE MODE: no policies are active on this project, so "
176
+ "Control Zero is monitoring and auditing tool calls but "
177
+ "NOT enforcing -- every call is allowed and logged. Attach "
178
+ "a policy (or set the empty-project default to deny) in the "
179
+ "Control Zero dashboard to start enforcing."
180
+ ),
181
+ # ko: "관찰 모드: 이 프로젝트에 활성 정책이 없으므로 ... 차단하지 않습니다 ..."
182
+ "ko": (
183
+ "관찰 모드: 이 프로젝트에 활성 정책이 없으므로 Control Zero는 "
184
+ "도구 호출을 모니터링하고 감사 기록을 남기지만 차단하지는 "
185
+ "않습니다 -- 모든 호출이 허용되고 기록됩니다. 시행을 시작하려면 "
186
+ "Control Zero 대시보드에서 정책을 연결하거나(또는 빈 프로젝트 "
187
+ "기본값을 deny로 설정) 하십시오."
188
+ ),
189
+ },
190
+ "NO_ACTIVE_POLICIES": {
191
+ "en": (
192
+ "No policies are active on this project. If the dashboard "
193
+ "shows attached policies, regenerate the policy bundle."
194
+ ),
195
+ # ko: "이 프로젝트에 활성 정책이 없습니다. 대시보드에 연결된 정책이 표시되면 ..."
196
+ "ko": (
197
+ "이 프로젝트에 활성 정책이 없습니다. 대시보드에 연결된 정책이 "
198
+ "표시되면 정책 번들을 다시 생성하십시오."
199
+ ),
200
+ },
201
+ # BUNDLE_MISSING covers two English variants (degraded bundle vs. bundle
202
+ # not loaded), both reason_code=BUNDLE_MISSING. The localized override is a
203
+ # single accurate general message; the precise English stays on the rule.
204
+ "BUNDLE_MISSING": {
205
+ "en": (
206
+ "Policy bundle could not be loaded or produced zero enforceable "
207
+ "rules. Control Zero is failing CLOSED (deny) rather than allowing "
208
+ "every tool call. Regenerate the policy bundle in the Control Zero "
209
+ "dashboard; contact support if this persists."
210
+ ),
211
+ # ko: "정책 번들을 로드할 수 없거나 시행 가능한 규칙을 생성하지 못했습니다 ..."
212
+ "ko": (
213
+ "정책 번들을 로드할 수 없거나 시행 가능한 규칙을 생성하지 "
214
+ "못했습니다(미동기화, 백엔드 연결 불가, 복호화 실패 또는 손상/"
215
+ "부분/오래된 번들). Control Zero는 모든 호출을 허용하는 대신 "
216
+ "차단(기본 차단)합니다. Control Zero 대시보드에서 정책 번들을 "
217
+ "다시 생성하고, 문제가 지속되면 지원팀에 문의하십시오."
218
+ ),
219
+ },
220
+ }
221
+
222
+
223
+ def system_message(key: str, locale: Optional[str] = None, **fmt: object) -> Optional[str]:
224
+ """Return the fully-resolved system message for ``key``, English-default.
225
+
226
+ Resolution: ``pack[key][locale]`` (region subtag tolerated) falling back to
227
+ ``pack[key]["en"]``. Returns ``None`` only when ``key`` is unknown. Used by
228
+ the inline paths (no-match, DLP, tamper, quarantine) where the pack is the
229
+ source of the English text.
230
+ """
231
+ entry = SYSTEM_MESSAGES.get(key)
232
+ if not entry:
233
+ return None
234
+ template = None
235
+ for cand in _locale_candidates(resolve_locale(locale)):
236
+ if cand in entry:
237
+ template = entry[cand]
238
+ break
239
+ if template is None:
240
+ template = entry.get(DEFAULT_LOCALE)
241
+ if template is None:
242
+ return None
243
+ if fmt:
244
+ try:
245
+ return template.format(**fmt)
246
+ except (KeyError, IndexError):
247
+ return template
248
+ return template
249
+
250
+
251
+ def localized_override(key: str, locale: Optional[str] = None, **fmt: object) -> Optional[str]:
252
+ """Return ONLY a non-English localized message for ``key``, else ``None``.
253
+
254
+ Unlike :func:`system_message` this never falls back to English. Used for
255
+ the synthetic empty/missing-bundle rules, whose English text stays on the
256
+ rule's ``reason`` (bundle output unchanged) -- the enforcer applies this
257
+ override only when an actual localized string exists for the active locale.
258
+ """
259
+ entry = SYSTEM_MESSAGES.get(key)
260
+ if not entry:
261
+ return None
262
+ for cand in _locale_candidates(resolve_locale(locale)):
263
+ if cand == DEFAULT_LOCALE:
264
+ continue
265
+ if cand in entry:
266
+ template = entry[cand]
267
+ if fmt:
268
+ try:
269
+ return template.format(**fmt)
270
+ except (KeyError, IndexError):
271
+ return template
272
+ return template
273
+ return None
@@ -943,4 +943,19 @@ def _translate_rule(rule: dict, policy_id: str) -> Optional[dict]:
943
943
  if escalate is not None:
944
944
  translated["escalate_on_deny"] = bool(escalate)
945
945
 
946
+ # #25 (gh#1439): the SAME allowlist-strip trap (the gh#175 /
947
+ # #1303 family). A backend-signed bundle copies only an allowlist of
948
+ # rule fields into the local rule shape; without this line a hosted
949
+ # policy's `reason_localized` map is silently dropped and a Korean
950
+ # operator would see only the English fallback. Forward it intact.
951
+ # (The backend bundle wire struct must ALSO carry the field -- see
952
+ # bundle_handler.go PolicyRule -- or it never reaches this translator.)
953
+ reason_localized = rule.get("reason_localized")
954
+ if isinstance(reason_localized, dict) and reason_localized:
955
+ translated["reason_localized"] = {
956
+ str(k).strip().lower(): str(v)
957
+ for k, v in reason_localized.items()
958
+ if isinstance(k, str) and isinstance(v, str)
959
+ }
960
+
946
961
  return translated
@@ -26,6 +26,11 @@ from controlzero._internal.dlp_scanner import (
26
26
  DLPScanner,
27
27
  extract_text_from_args,
28
28
  )
29
+ from controlzero._internal._messages import (
30
+ localized_override,
31
+ resolve_locale,
32
+ system_message,
33
+ )
29
34
  from controlzero._internal.types import PolicyRule
30
35
 
31
36
  # Phase 1B (#451): policy engine version stamped onto every
@@ -149,6 +154,10 @@ SYNTHETIC_BUNDLE_MISSING = "synthetic:BUNDLE_MISSING"
149
154
  SYNTHETIC_RESOURCE_GATE_SKIP = "synthetic:RESOURCE_GATE_SKIP"
150
155
  SYNTHETIC_QUARANTINE = "synthetic:QUARANTINE"
151
156
  SYNTHETIC_ENGINE_UNAVAILABLE = "synthetic:ENGINE_UNAVAILABLE"
157
+ # Backend-stamped (audit ingest) on a deny that arrived with an empty
158
+ # policy_id -- e.g. an old field SDK or a CLI fail-closed path. SDKs do not
159
+ # emit it; it is listed here so downstream consumers recognize it.
160
+ SYNTHETIC_UNATTRIBUTED_DENY = "synthetic:UNATTRIBUTED_DENY"
152
161
 
153
162
  VALID_SYNTHETIC_POLICY_IDS = frozenset({
154
163
  SYNTHETIC_NO_RULE_MATCH,
@@ -158,6 +167,7 @@ VALID_SYNTHETIC_POLICY_IDS = frozenset({
158
167
  SYNTHETIC_RESOURCE_GATE_SKIP,
159
168
  SYNTHETIC_QUARANTINE,
160
169
  SYNTHETIC_ENGINE_UNAVAILABLE,
170
+ SYNTHETIC_UNATTRIBUTED_DENY,
161
171
  })
162
172
 
163
173
  # Canonical bundle-level default values. These must stay in lockstep
@@ -224,7 +234,7 @@ class PolicyDecision:
224
234
  reason_code: str = ""
225
235
  evaluated_rules: int = 0
226
236
  dlp_findings: list[dict] = field(default_factory=list)
227
- # DLP MASK action (#1278 kakaopay checklist #6, parity with the gateway
237
+ # DLP MASK action (#1278 the customer checklist #6, parity with the gateway
228
238
  # ``response_dlp.mask_text`` path). When a DLP rule matches with
229
239
  # action="mask" on an ALLOW decision, the enforcer redacts the sensitive
230
240
  # spans in the tool arguments (modify-and-proceed) and stashes the
@@ -329,6 +339,7 @@ class PolicyEvaluator:
329
339
  rules: Optional[list[PolicyRule]] = None,
330
340
  dlp_scanner: Optional[DLPScanner] = None,
331
341
  default_action: str = DEFAULT_BUNDLE_ACTION,
342
+ locale: Optional[str] = None,
332
343
  ):
333
344
  self._rules: list[PolicyRule] = rules or []
334
345
  self._dlp_scanner: Optional[DLPScanner] = dlp_scanner
@@ -338,6 +349,12 @@ class PolicyEvaluator:
338
349
  self._default_action: str = (
339
350
  default_action if default_action in VALID_DEFAULT_ACTIONS else DEFAULT_BUNDLE_ACTION
340
351
  )
352
+ # Active locale for user-facing reason localization (
353
+ # #25, gh#1439). None => read CONTROLZERO_LOCALE at construction.
354
+ # Empty string ("") means English, the default; reason text is
355
+ # then byte-identical to the pre-localization SDK so existing
356
+ # reason-regex consumers are unaffected.
357
+ self._locale: str = resolve_locale(locale)
341
358
 
342
359
  def load(self, rules: list[PolicyRule]) -> None:
343
360
  """Replace the rule set."""
@@ -365,6 +382,52 @@ class PolicyEvaluator:
365
382
  """The effective no-match default action."""
366
383
  return self._default_action
367
384
 
385
+ def set_locale(self, locale: Optional[str]) -> None:
386
+ """Set the active reason-localization locale (#25, gh#1439).
387
+
388
+ ``None`` re-reads ``CONTROLZERO_LOCALE``; an empty string forces
389
+ English. Localization is display-only -- it never changes which
390
+ rule matches or the effect, only the human-readable ``reason`` text.
391
+ """
392
+ self._locale = resolve_locale(locale)
393
+
394
+ @property
395
+ def locale(self) -> str:
396
+ """The effective reason-localization locale ("" == English)."""
397
+ return self._locale
398
+
399
+ def _resolve_reason(self, rule: PolicyRule) -> str:
400
+ """Resolve the human-readable reason for a matched rule, localized.
401
+
402
+ Priority (#25, gh#1439):
403
+ 1. ``rule.reason_localized[locale]`` -- operator-authored override.
404
+ 2. For SDK-generated synthetic rules (id ``synthetic:*``), the
405
+ in-binary localized system message keyed by ``reason_code``.
406
+ 3. The plain ``rule.reason`` (English / operator text) -- the
407
+ DEFAULT whenever locale is unset/English or no override exists,
408
+ so reason-regex consumers see byte-identical output.
409
+ 4. Canned ``Matched rule <id>`` when the rule carried no reason.
410
+
411
+ Localization is display-only; this never affects the decision.
412
+ """
413
+ loc = self._locale
414
+ if loc and loc != "en":
415
+ override = (getattr(rule, "reason_localized", None) or {}).get(loc)
416
+ if override:
417
+ return override
418
+ # Region-subtag tolerance (ko-KR -> ko) for the per-rule map.
419
+ rl = getattr(rule, "reason_localized", None) or {}
420
+ if rl:
421
+ primary = loc.split("-", 1)[0].split("_", 1)[0]
422
+ if primary in rl and rl[primary]:
423
+ return rl[primary]
424
+ rule_id = rule.id or ""
425
+ if rule_id.startswith(SYNTHETIC_POLICY_ID_PREFIX) and getattr(rule, "reason_code", ""):
426
+ sys_msg = localized_override(rule.reason_code, loc)
427
+ if sys_msg:
428
+ return sys_msg
429
+ return rule.reason or f"Matched rule {rule.id or rule.name}".strip()
430
+
368
431
  def evaluate(
369
432
  self,
370
433
  tool: str,
@@ -513,9 +576,11 @@ class PolicyEvaluator:
513
576
  decision = PolicyDecision(
514
577
  effect=rule.effect,
515
578
  policy_id=rule.id or rule.name or None,
516
- # User-provided reason wins. Falls back to canned text only if
517
- # the rule had no `reason:` field in the source policy.
518
- reason=rule.reason or f"Matched rule {rule.id or rule.name}".strip(),
579
+ # User-provided reason wins, localized when CONTROLZERO_LOCALE
580
+ # selects a locale the rule (or system pack) has copy for.
581
+ # Falls back to the plain reason / canned text otherwise, so
582
+ # English output is byte-identical to before (#25).
583
+ reason=self._resolve_reason(rule),
519
584
  reason_code=decision_code,
520
585
  evaluated_rules=evaluated,
521
586
  gate_matched=gate_matched_value,
@@ -555,33 +620,28 @@ class PolicyEvaluator:
555
620
  # phrase is kept for the deny case so downstream consumers
556
621
  # that regex-match the reason string (a pattern we actively
557
622
  # discourage -- use reason_code instead) keep working.
558
- if self._default_action == "deny":
559
- # #1247 (folded from #1257): a deny-by-default no-rule-match is
560
- # the single most confusing block a customer hits -- a benign
561
- # tool (e.g. `bash:find`) is denied not because a rule forbids
562
- # it but because no rule mentions it and the bundle's
563
- # default_on_missing is deny. The historical message ("fail-
564
- # closed default") gave no path out and looked like a bug.
565
- # Name the unmatched action and the exact remediation so the
566
- # deny is self-explaining. The leading phrase keeps the legacy
567
- # "fail-closed default" substring for any downstream regex
568
- # consumer (reason_code=NO_RULE_MATCH is the supported signal;
569
- # this is belt-and-suspenders). NOTE: this is the NON-EMPTY
570
- # bundle path -- a genuinely empty bundle short-circuits to the
571
- # synthetic OBSERVE catch-all above and never reaches here, so
572
- # observe and self-explaining-deny compose cleanly.
573
- reason = (
574
- f"No matching policy rule for '{action}' (fail-closed default; "
575
- "default_on_missing=deny). This tool is neither allowed nor "
576
- "denied by any rule in your hosted policy, so it is blocked. "
577
- "Add a rule that allows it (e.g. allow: '*' as a catch-all, or "
578
- f"allow: '{action}'), or set the project/org default to allow, "
579
- "in the Control Zero dashboard."
580
- )
581
- elif self._default_action == "allow":
582
- reason = f"No matching policy rule for '{action}' (default_action=allow)"
583
- else: # "warn"
584
- reason = f"No matching policy rule for '{action}' (default_action=warn)"
623
+ # #1247 (folded from #1257): a deny-by-default no-rule-match is the
624
+ # single most confusing block a customer hits -- a benign tool (e.g.
625
+ # `bash:find`) is denied not because a rule forbids it but because no
626
+ # rule mentions it and the bundle's default_on_missing is deny. The
627
+ # message names the unmatched action + exact remediation so the deny
628
+ # is self-explaining; it keeps the legacy "fail-closed default"
629
+ # substring for any downstream regex consumer (reason_code=
630
+ # NO_RULE_MATCH is the supported signal). NOTE: this is the NON-EMPTY
631
+ # bundle path -- a genuinely empty bundle short-circuits to the
632
+ # synthetic OBSERVE catch-all above.
633
+ #
634
+ # #25 (gh#1439): the three variant strings now live in the
635
+ # in-binary message pack keyed by NO_RULE_MATCH:<default_action>. The
636
+ # English values are byte-identical to the legacy strings, so this is
637
+ # a no-op when CONTROLZERO_LOCALE is unset; with ko set the Korean
638
+ # variant is surfaced. default_action is always one of deny/allow/warn
639
+ # (coerced in the constructor), so the pack key always resolves.
640
+ reason = system_message(
641
+ f"NO_RULE_MATCH:{self._default_action}",
642
+ self._locale,
643
+ action=action,
644
+ ) or f"No matching policy rule for '{action}'"
585
645
 
586
646
  # T79: distinguish the T83-class signature ("a rule's actions
587
647
  # matched but its resources gate excluded the call") from the
@@ -675,9 +735,13 @@ class PolicyEvaluator:
675
735
  return PolicyDecision(
676
736
  effect="deny",
677
737
  policy_id=decision.policy_id,
678
- reason=(
679
- f"DLP rule '{blocking.rule_name}' matched "
680
- f"{blocking.category} content in tool arguments"
738
+ # #25 (gh#1439): localized via the in-binary pack;
739
+ # English byte-identical to legacy when locale unset.
740
+ reason=system_message(
741
+ "DLP_BLOCKED:match",
742
+ self._locale,
743
+ rule_name=blocking.rule_name,
744
+ category=blocking.category,
681
745
  ),
682
746
  # DLP_BLOCKED means: the tool-name policy said allow,
683
747
  # but a DLP rule found a hit in the arguments and
@@ -735,11 +799,13 @@ class PolicyEvaluator:
735
799
  return PolicyDecision(
736
800
  effect="deny",
737
801
  policy_id=decision.policy_id,
738
- reason=(
739
- f"DLP rule '{offending.rule_name}' matched "
740
- f"{offending.category} content that spans argument "
741
- "boundaries and could not be safely masked; blocking "
742
- "(fail-closed) rather than forwarding an un-redacted value"
802
+ # #25 (gh#1439): localized via the in-binary
803
+ # pack; English byte-identical to legacy when unset.
804
+ reason=system_message(
805
+ "DLP_BLOCKED:boundary",
806
+ self._locale,
807
+ rule_name=offending.rule_name,
808
+ category=offending.category,
743
809
  ),
744
810
  reason_code=REASON_CODE_DLP_BLOCKED,
745
811
  evaluated_rules=decision.evaluated_rules,