controlzero 1.11.6__tar.gz → 1.11.8__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 (274) hide show
  1. {controlzero-1.11.6 → controlzero-1.11.8}/.gitignore +8 -0
  2. {controlzero-1.11.6 → controlzero-1.11.8}/CHANGELOG.md +84 -0
  3. {controlzero-1.11.6 → controlzero-1.11.8}/PKG-INFO +1 -1
  4. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/__init__.py +1 -1
  5. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/_internal/dlp_scanner.py +151 -3
  6. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/_internal/enforcer.py +94 -6
  7. controlzero-1.11.8/controlzero/_internal/io_capture_producer.py +264 -0
  8. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/audit_remote.py +205 -1
  9. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/main.py +439 -39
  10. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/client.py +72 -24
  11. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/enrollment.py +188 -0
  12. controlzero-1.11.8/controlzero/hook_integrity.py +673 -0
  13. {controlzero-1.11.6 → controlzero-1.11.8}/pyproject.toml +1 -1
  14. {controlzero-1.11.6 → controlzero-1.11.8}/tests/spool/test_spool_sink_wiring.py +51 -0
  15. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_audit_remote.py +188 -0
  16. controlzero-1.11.8/tests/test_audit_remote_io_capture.py +142 -0
  17. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_canonical_phase1a.py +19 -18
  18. controlzero-1.11.8/tests/test_dlp_mask.py +520 -0
  19. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_enrollment.py +274 -0
  20. controlzero-1.11.8/tests/test_hook_integrity.py +500 -0
  21. controlzero-1.11.8/tests/test_io_capture_producer.py +172 -0
  22. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_kiro_cli_e2e.py +49 -1
  23. {controlzero-1.11.6 → controlzero-1.11.8}/Dockerfile.test +0 -0
  24. {controlzero-1.11.6 → controlzero-1.11.8}/LICENSE +0 -0
  25. {controlzero-1.11.6 → controlzero-1.11.8}/README.md +0 -0
  26. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/__main__.py +0 -0
  27. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/_internal/__init__.py +0 -0
  28. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/_internal/action_aliases.py +0 -0
  29. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/_internal/action_validator.py +0 -0
  30. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/_internal/bundle.py +0 -0
  31. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/_internal/credential_hook.py +0 -0
  32. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/_internal/credential_scanner.py +0 -0
  33. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/_internal/credentials_data/__init__.py +0 -0
  34. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/_internal/credentials_data/built_in.yaml +0 -0
  35. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/_internal/hook_extractors.py +0 -0
  36. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/_internal/tool_extractors.json +0 -0
  37. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/_internal/types.py +0 -0
  38. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/audit_local.py +0 -0
  39. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/canonical.py +0 -0
  40. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/__init__.py +0 -0
  41. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/__main__.py +0 -0
  42. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/_secrets.py +0 -0
  43. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/console.py +0 -0
  44. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/debug_bundle.py +0 -0
  45. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/doctor.py +0 -0
  46. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/exec_cmd.py +0 -0
  47. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/hook_entry.py +0 -0
  48. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/hosts/__init__.py +0 -0
  49. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/hosts/antigravity.py +0 -0
  50. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/hosts/base.py +0 -0
  51. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/hosts/claude_code.py +0 -0
  52. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/hosts/codex_cli.py +0 -0
  53. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/hosts/cursor.py +0 -0
  54. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/hosts/cursor_cli.py +0 -0
  55. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/hosts/gemini_cli.py +0 -0
  56. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/hosts/kiro.py +0 -0
  57. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/hosts/unknown.py +0 -0
  58. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/kiro_adapter.py +0 -0
  59. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/migrate.py +0 -0
  60. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/spool_cmd.py +0 -0
  61. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/telemetry_consent.py +0 -0
  62. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/templates/antigravity/hooks.json +0 -0
  63. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/templates/antigravity.yaml +0 -0
  64. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/templates/autogen.yaml +0 -0
  65. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/templates/claude-code.yaml +0 -0
  66. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/templates/codex-cli.yaml +0 -0
  67. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/templates/cost-cap.yaml +0 -0
  68. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/templates/crewai.yaml +0 -0
  69. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/templates/cursor.yaml +0 -0
  70. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/templates/gemini-cli.yaml +0 -0
  71. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/templates/generic.yaml +0 -0
  72. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/templates/kiro/ide-file-save.kiro.hook +0 -0
  73. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/templates/kiro/ide-pre-tool-use.kiro.hook +0 -0
  74. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/templates/kiro/ide-prompt-submit.kiro.hook +0 -0
  75. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/templates/kiro/kiro.yaml +0 -0
  76. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/templates/langchain.yaml +0 -0
  77. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/templates/mcp.yaml +0 -0
  78. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/templates/rag.yaml +0 -0
  79. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/update_cmd.py +0 -0
  80. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/device.py +0 -0
  81. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/error_codes.py +0 -0
  82. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/error_codes.yaml +0 -0
  83. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/errors.py +0 -0
  84. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/hitl/__init__.py +0 -0
  85. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/hitl/grant_protocol.py +0 -0
  86. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/hitl/mock.py +0 -0
  87. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/hitl/pending_approval.py +0 -0
  88. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/hitl/secret_leak_guard.py +0 -0
  89. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/hitl/status.py +0 -0
  90. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/hooks/__init__.py +0 -0
  91. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/hooks/tool_output_handler.py +0 -0
  92. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/hosted_policy.py +0 -0
  93. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/__init__.py +0 -0
  94. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/anthropic.py +0 -0
  95. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/autogen.py +0 -0
  96. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/braintrust.py +0 -0
  97. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/crewai/__init__.py +0 -0
  98. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/crewai/agent.py +0 -0
  99. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/crewai/crew.py +0 -0
  100. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/crewai/task.py +0 -0
  101. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/crewai/tool.py +0 -0
  102. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/google.py +0 -0
  103. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/google_adk/__init__.py +0 -0
  104. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/google_adk/agent.py +0 -0
  105. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/google_adk/tool.py +0 -0
  106. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/langchain/__init__.py +0 -0
  107. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/langchain/agent.py +0 -0
  108. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/langchain/callbacks.py +0 -0
  109. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/langchain/chain.py +0 -0
  110. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/langchain/graph.py +0 -0
  111. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/langchain/modern.py +0 -0
  112. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/langchain/tool.py +0 -0
  113. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/langfuse.py +0 -0
  114. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/litellm.py +0 -0
  115. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/openai.py +0 -0
  116. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/pydantic_ai.py +0 -0
  117. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/vercel_ai.py +0 -0
  118. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/layout_migration.py +0 -0
  119. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/policy_loader.py +0 -0
  120. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/secrets/__init__.py +0 -0
  121. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/secrets/reference.py +0 -0
  122. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/secrets/resolver.py +0 -0
  123. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/secrets/secretstr.py +0 -0
  124. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/spool/__init__.py +0 -0
  125. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/spool/_compress.py +0 -0
  126. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/spool/_constants.py +0 -0
  127. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/spool/_crc32c.py +0 -0
  128. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/spool/_crypto.py +0 -0
  129. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/spool/_frame.py +0 -0
  130. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/spool/_keyring.py +0 -0
  131. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/spool/_metrics.py +0 -0
  132. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/spool/_spool.py +0 -0
  133. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/spool/_state.py +0 -0
  134. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/spool/_uploader.py +0 -0
  135. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/spool/cz-audit-v1.dict +0 -0
  136. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/tamper.py +0 -0
  137. {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/tracecontext.py +0 -0
  138. {controlzero-1.11.6 → controlzero-1.11.8}/examples/hello_world.py +0 -0
  139. {controlzero-1.11.6 → controlzero-1.11.8}/tests/_fixtures/antigravity-envelope-expected.json +0 -0
  140. {controlzero-1.11.6 → controlzero-1.11.8}/tests/_fixtures/jcs_args_hash_vectors.json +0 -0
  141. {controlzero-1.11.6 → controlzero-1.11.8}/tests/conftest.py +0 -0
  142. {controlzero-1.11.6 → controlzero-1.11.8}/tests/integrations/__init__.py +0 -0
  143. {controlzero-1.11.6 → controlzero-1.11.8}/tests/integrations/test_google.py +0 -0
  144. {controlzero-1.11.6 → controlzero-1.11.8}/tests/parity/action_aliases.json +0 -0
  145. {controlzero-1.11.6 → controlzero-1.11.8}/tests/spool/__init__.py +0 -0
  146. {controlzero-1.11.6 → controlzero-1.11.8}/tests/spool/conftest.py +0 -0
  147. {controlzero-1.11.6 → controlzero-1.11.8}/tests/spool/test_spool_cli.py +0 -0
  148. {controlzero-1.11.6 → controlzero-1.11.8}/tests/spool/test_spool_concurrency.py +0 -0
  149. {controlzero-1.11.6 → controlzero-1.11.8}/tests/spool/test_spool_conformance.py +0 -0
  150. {controlzero-1.11.6 → controlzero-1.11.8}/tests/spool/test_spool_core.py +0 -0
  151. {controlzero-1.11.6 → controlzero-1.11.8}/tests/spool/test_spool_crash.py +0 -0
  152. {controlzero-1.11.6 → controlzero-1.11.8}/tests/spool/test_spool_diskfull.py +0 -0
  153. {controlzero-1.11.6 → controlzero-1.11.8}/tests/spool/test_spool_durable_default_tamper.py +0 -0
  154. {controlzero-1.11.6 → controlzero-1.11.8}/tests/spool/test_spool_keychain_dek.py +0 -0
  155. {controlzero-1.11.6 → controlzero-1.11.8}/tests/spool/test_spool_transcript_localack.py +0 -0
  156. {controlzero-1.11.6 → controlzero-1.11.8}/tests/spool/test_spool_uploader.py +0 -0
  157. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_action_aliases.py +0 -0
  158. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_action_canonicalization.py +0 -0
  159. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_action_validator_t86.py +0 -0
  160. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_adapter_interface.py +0 -0
  161. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_agent_name_env.py +0 -0
  162. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_agy_docs_no_exit2_1373.py +0 -0
  163. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_antigravity_adapter.py +0 -0
  164. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_antigravity_envelope_parity.py +0 -0
  165. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_antigravity_ga_blockers_1248.py +0 -0
  166. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_antigravity_hook_check.py +0 -0
  167. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_antigravity_install.py +0 -0
  168. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_antigravity_posttooluse_observe_58.py +0 -0
  169. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_antigravity_tool_vocab_1303.py +0 -0
  170. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_api_key_mask.py +0 -0
  171. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_audit_remote_sdk_version.py +0 -0
  172. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_audit_sink_isolation.py +0 -0
  173. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_bundle_cache_freshness_1303.py +0 -0
  174. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_bundle_parser.py +0 -0
  175. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_bundle_translate.py +0 -0
  176. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_capability_matrix_doc.py +0 -0
  177. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_cli_carve_out.py +0 -0
  178. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_cli_debug_bundle.py +0 -0
  179. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_cli_extractor_integration.py +0 -0
  180. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_cli_hook.py +0 -0
  181. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_cli_hook_entry.py +0 -0
  182. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_cli_hosted_refresh.py +0 -0
  183. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_cli_init.py +0 -0
  184. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_cli_init_templates.py +0 -0
  185. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_cli_tail.py +0 -0
  186. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_cli_test.py +0 -0
  187. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_cli_validate.py +0 -0
  188. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_coding_agent_hooks.py +0 -0
  189. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_conditions.py +0 -0
  190. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_config_format_parity_1303.py +0 -0
  191. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_conformance.py +0 -0
  192. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_console.py +0 -0
  193. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_credential_hook.py +0 -0
  194. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_cursor_adapter.py +0 -0
  195. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_default_action.py +0 -0
  196. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_device.py +0 -0
  197. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_dlp_scanner.py +0 -0
  198. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_doctor.py +0 -0
  199. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_engine_version_consistency.py +0 -0
  200. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_env_dump_438.py +0 -0
  201. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_epic_1247_bryan_acceptance.py +0 -0
  202. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_error_codes.py +0 -0
  203. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_errors_e_codes.py +0 -0
  204. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_fail_closed_eval.py +0 -0
  205. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_failopen_1303.py +0 -0
  206. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_glob_matching.py +0 -0
  207. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hitl_5d_email_install.py +0 -0
  208. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hitl_6a_cli_flag.py +0 -0
  209. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hitl_6a_exceptions.py +0 -0
  210. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hitl_6a_get_secret_hitl.py +0 -0
  211. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hitl_6a_mock_backend.py +0 -0
  212. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hitl_6a_pending_approval.py +0 -0
  213. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hitl_6a_request_approval.py +0 -0
  214. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hitl_6a_secret_leak_guard.py +0 -0
  215. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hitl_6a_wait.py +0 -0
  216. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hitl_conformance.py +0 -0
  217. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hitl_phase2b_protocol.py +0 -0
  218. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hitl_reason_codes.py +0 -0
  219. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hitl_validator_keys.py +0 -0
  220. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hook_extractors.py +0 -0
  221. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hosted_local_audit_1247.py +0 -0
  222. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hosted_local_shadow_warn_1265.py +0 -0
  223. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hosted_policy_e2e.py +0 -0
  224. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hosts_adapter.py +0 -0
  225. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hybrid_mode_strict.py +0 -0
  226. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hybrid_mode_warn.py +0 -0
  227. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_install_hook_command.py +0 -0
  228. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_install_hooks.py +0 -0
  229. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_kiro_adapter.py +0 -0
  230. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_kiro_cli_hook_pin_1265.py +0 -0
  231. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_kiro_hook_templates.py +0 -0
  232. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_kiro_install.py +0 -0
  233. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_layout_migration_t101.py +0 -0
  234. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_layout_parity_t102.py +0 -0
  235. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_local_mode_dict.py +0 -0
  236. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_local_mode_file_json.py +0 -0
  237. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_local_mode_file_yaml.py +0 -0
  238. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_log_fallback_stderr.py +0 -0
  239. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_log_options_ignored_hosted.py +0 -0
  240. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_log_rotation.py +0 -0
  241. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_migrate.py +0 -0
  242. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_min_sdk_version_gate.py +0 -0
  243. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_multi_client_per_project_175.py +0 -0
  244. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_no_policy_no_key.py +0 -0
  245. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_observe_mode_1247.py +0 -0
  246. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_package_rename_shim.py +0 -0
  247. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_part3_active_policy_count_1303.py +0 -0
  248. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_policy_engine_version_phase1b.py +0 -0
  249. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_policy_freshness.py +0 -0
  250. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_policy_load_failclosed_f3.py +0 -0
  251. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_policy_settings.py +0 -0
  252. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_policy_source_audit.py +0 -0
  253. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_probe_audit_isolation.py +0 -0
  254. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_quarantine.py +0 -0
  255. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_reason_code.py +0 -0
  256. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_refresh.py +0 -0
  257. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_secrets.py +0 -0
  258. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_secrets_reference_resolver.py +0 -0
  259. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_sql_semantic_class.py +0 -0
  260. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_synthetic_policy_id_t79.py +0 -0
  261. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_t103_precedence.py +0 -0
  262. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_t104_cache_gc.py +0 -0
  263. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_t108_local_override_audit.py +0 -0
  264. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_t96_single_audit_log.py +0 -0
  265. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_t99_install_prefetch_bundle.py +0 -0
  266. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_tamper.py +0 -0
  267. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_tamper_behavior.py +0 -0
  268. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_tamper_hook.py +0 -0
  269. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_telemetry_consent.py +0 -0
  270. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_tracecontext.py +0 -0
  271. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_unsafe_int_boundary.py +0 -0
  272. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_update_cmd.py +0 -0
  273. {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_upgrade_nudge.py +0 -0
  274. {controlzero-1.11.6 → controlzero-1.11.8}/tools/cz-kiro-adapter +0 -0
@@ -29,7 +29,15 @@ out/
29
29
  apps/control-zero-platform/backend/server
30
30
 
31
31
  # Test & Coverage
32
+ # NOTE: the bare `coverage/` pattern is unanchored, so git matches a
33
+ # directory named `coverage` at ANY depth -- including the Next.js app
34
+ # route `app/[locale]/governance/coverage/`. That silently kept the
35
+ # Coverage admin page out of every build and 404'd it in prod (#1180).
36
+ # Re-include the application route below so it ships. The brackets in
37
+ # `[locale]` are escaped because git treats an unescaped `[...]` as a
38
+ # character class, which would make the negation silently not match.
32
39
  coverage/
40
+ !apps/control-zero-platform/frontend/app/\[locale\]/governance/coverage/
33
41
  htmlcov/
34
42
  .coverage
35
43
  *.lcov
@@ -1,5 +1,89 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.11.8 -- 2026-06-22 (DLP mask enforcement + I/O capture: enrolled-machine capture fix + server-side PII redaction)
4
+
5
+ ### Added
6
+
7
+ - **I/O capture now fires on the enrolled-agent path (#1390 / #1432).** The
8
+ agent-hook I/O capture producer ships input/output payloads on every tool
9
+ call (`capture_resolved` defaults true); the server cascade + master gate
10
+ decide persistence. Pairs with the backend fix that threads the enrolled
11
+ machine's `project_id` through the capture cascade so a project-scoped
12
+ install actually captures (previously the machine-auth ingest hardcoded the
13
+ project override off and captured nothing). The producer performs no
14
+ client-side redaction by design -- secret + structured-PII redaction happen
15
+ server-side before persist (#1433), and captured payloads are sealed under a
16
+ per-record DEK.
17
+
18
+ - **DLP `mask` action enforced in the SDK (#1278 kakaopay checklist #6).**
19
+ The `mask` action now redacts matched sensitive spans in the tool arguments
20
+ (modify-and-proceed) instead of only being recorded as a finding. When a DLP
21
+ rule matches with `action="mask"` on an `allow` decision, the enforcer
22
+ rewrites each sensitive span to a value-free `[REDACTED-<RULE_ID-UPPER>]`
23
+ token, stashes the redacted args on `PolicyDecision.masked_args`, and records
24
+ the placeholder (never the raw value) in `dlp_findings`. The placeholder
25
+ matches the gateway `response_dlp.mask_text` path and the Rust core
26
+ `pii::redact_placeholder` byte-for-byte. `block` still hard-vetoes to deny
27
+ (and wins over a co-matching mask); `detect`, `allow`, `warn`, and
28
+ policy-level deny are unchanged. Byte-identical with the Node SDK via the
29
+ shared `tests/parity/dlp_mask.json` conformance vector. Masking is leak-safe
30
+ on three edge cases code review flagged: non-string scalar leaves (a JSON
31
+ number like `{"card": 4111111111111111}` is masked, not passed through raw),
32
+ overlapping/nested mask spans (coalesced into a single clean placeholder with
33
+ no stale-offset residue), and cross-argument matches (DLP detects on the
34
+ flattened arg text but masks per-leaf; a mask match that spans two args has
35
+ no covering leaf, so the enforcer re-scans the masked args and FAILS CLOSED
36
+ to a `DLP_BLOCKED` deny rather than forwarding an un-redactable value). On
37
+ that fail-closed deny path the audit records ONLY the value-free mask
38
+ placeholder -- a co-matching `detect` rule's raw matched value is excluded so
39
+ no raw PII lands in the audit even on a block (#202).
40
+
41
+ ## 1.11.7 -- 2026-06-22 (agent tamper-resistance: hook-integrity baseline + detect/audit/alert)
42
+
43
+ Adds agent tamper-resistance detection for the coding-agent hooks the SDK
44
+ installs. A security evaluator's first move is to install the guard and then
45
+ defeat it by deleting or editing the hook; before this release that removal was
46
+ silent until the next tool call (which, with the hook gone, never ran). Now it
47
+ is detected, audited, and alerted.
48
+
49
+ ### Added
50
+
51
+ - **Hook-integrity baseline (`controlzero/hook_integrity.py`).** Every
52
+ `install` / `kiro init` host writer (Claude Code, Gemini CLI, Codex CLI,
53
+ Cursor, Antigravity, Kiro CLI v2/v3, Kiro IDE) now records a signed baseline
54
+ (`~/.controlzero/hook_baseline.json`) of the Control Zero hook command(s) and
55
+ a hash of each host config it wrote. The baseline is HMAC-signed with the same
56
+ `tamper.key` the policy-bundle signer uses, so a forged baseline that hides a
57
+ removed hook needs the secret.
58
+ - **Drift detection + audit + alert.** At `hook-check`, `heartbeat`, and a new
59
+ `controlzero verify` command, the SDK compares live host configs against the
60
+ baseline and classifies each as `missing` (hook command removed), `modified`
61
+ (command changed/repointed, OR a load-bearing enablement field flipped to
62
+ disable the hook while the command stays in place), or `config_missing`
63
+ (config file deleted). The `modified` check uses a SHA-256 over the canonical
64
+ Control-Zero region (the hook entries carrying a CZ command WITH their
65
+ enablement siblings: Cursor `failClosed`, Kiro `enabled`, Codex `config.toml`
66
+ `codex_hooks`), so disabling the hook without removing the command string is
67
+ detected -- while an unrelated edit elsewhere in the config does NOT alarm. A
68
+ finding writes a `hook_tamper_detected` / `enforcement_disabled` event to the
69
+ local audit log and fires the existing best-effort `/v1/sdk/tamper-alert`
70
+ backend alert the admin console surfaces. DETECT + AUDIT + ALERT only -- the
71
+ tool decision is not changed (a benign config edit never bricks the agent).
72
+ - **Bypass env-var detection.** Flags enforcement-downgrade environment
73
+ variables (`CONTROLZERO_CREDLEAK_OFF`, `CONTROLZERO_DLP_OFF`,
74
+ `CONTROLZERO_LOCAL_OVERRIDE`, and the general `CONTROLZERO_*_OFF` /
75
+ `*_DISABLE` pattern) at the same surfaces. Benign operational flags
76
+ (`CONTROLZERO_SPOOL_KEYCHAIN_DISABLE`, `CONTROLZERO_QUIET`) are allow-listed
77
+ so legit config never raises a false alert.
78
+ - **`controlzero verify`** command: on-demand / heartbeat integrity check. Exit
79
+ 0 = clean, 3 = tamper detected, 4 = no baseline recorded yet. `--json` for
80
+ machine output; `--no-report` to inspect without auditing/alerting.
81
+
82
+ Honest scope (#202): this is DETECT + AUDIT, not prevention. It makes hook
83
+ removal / modification / disable visible in the audit log and admin console; it
84
+ does not, by itself, stop an operator with write access from removing the hook
85
+ (that needs OS-level immutability / MDM, out of SDK scope).
86
+
3
87
  ## 1.11.6 -- 2026-06-21 (cross-surface enforcement hardening: Kiro v3 + Antigravity honesty + warn-mode)
4
88
 
5
89
  Ships the #1373 / #65 cross-surface enforcement work and follow-ups that landed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: controlzero
3
- Version: 1.11.6
3
+ Version: 1.11.8
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
@@ -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.6"
44
+ __version__ = "1.11.8"
45
45
 
46
46
  __all__ = [
47
47
  "Client",
@@ -597,6 +597,29 @@ class DLPMatch:
597
597
  matched_text: str # plaintext for pii/financial, SHA-256 hash for secret
598
598
  offset: int
599
599
  count: int = 1
600
+ # Length of the matched span in the SOURCE text. Carried so the
601
+ # masker can splice the exact span out of a string value without
602
+ # re-running the regex. Defaults to len(matched_text) so DLPMatch
603
+ # objects built by older callers (and the secret path, where
604
+ # matched_text is a hash, not the raw value) still report a sensible
605
+ # length -- but the masker only ever uses the span length recorded at
606
+ # scan time, never the hash length.
607
+ length: int = 0
608
+
609
+
610
+ def mask_placeholder(rule_id: str) -> str:
611
+ """Stable, value-free mask placeholder matching gateway ENFORCEMENT exactly.
612
+
613
+ Production masking (Rust ``pii::redact_placeholder``, standardized in #961,
614
+ the gateway ``response_dlp.mask_text`` path, and the DLP test panel
615
+ ``dlp_test_engine._redact_placeholder``) renders a detector/rule as
616
+ ``[REDACTED-<ID-UPPER>]`` -- derived ONLY from the rule id, never from the
617
+ matched value, so it cannot leak any portion of the original sensitive
618
+ bytes. The SDK mask action MUST emit the identical token or the SDK and
619
+ gateway surfaces would mask the same value differently (#202 honesty).
620
+ """
621
+ base = (rule_id or "PII").upper().replace(" ", "_")
622
+ return f"[REDACTED-{base}]"
600
623
 
601
624
 
602
625
  class DLPScanner:
@@ -688,6 +711,10 @@ class DLPScanner:
688
711
  action=rule.action,
689
712
  matched_text=display_text,
690
713
  offset=m.start(),
714
+ # Span length in the SOURCE text (NOT len(display_text),
715
+ # which for secrets is a 64-char hash). The masker splices
716
+ # exactly [offset, offset+length) out of the string value.
717
+ length=len(raw_text),
691
718
  )
692
719
  )
693
720
 
@@ -697,11 +724,125 @@ class DLPScanner:
697
724
  """Check if any match has action='block'."""
698
725
  return any(m.action == "block" for m in matches)
699
726
 
727
+ def has_masking_match(self, matches: list[DLPMatch]) -> bool:
728
+ """Check if any match has action='mask'."""
729
+ return any(m.action == "mask" for m in matches)
730
+
731
+ def mask_text(self, text: str, matches: list[DLPMatch]) -> str:
732
+ """Redact every action='mask' span in ``text`` with a value-free token.
733
+
734
+ Matches the gateway ``response_dlp.mask_text`` semantics:
735
+ each span is replaced by ``[REDACTED-<RULE_ID-UPPER>]`` -- a token
736
+ derived only from the rule id, never from the matched bytes.
737
+
738
+ Overlapping / nested spans are COALESCED before splicing. Two different
739
+ rules can match overlapping byte ranges (e.g. a broad ``ABCDEF`` rule
740
+ and a narrow ``CD`` rule, or a card rule whose span contains a digit-run
741
+ rule). A naive right-to-left splice would apply the inner span, mutate
742
+ the string, then apply the outer span against STALE offsets -- leaving
743
+ original tail bytes and a corrupted placeholder (a SILENT LEAK). So we
744
+ first merge every overlapping/adjacent mask span into one
745
+ non-overlapping interval, each rendered as a single clean placeholder
746
+ (the earliest-starting, then widest, rule's id wins for the merged
747
+ interval). The merged intervals are then spliced right-to-left so each
748
+ replacement leaves the offsets of the not-yet-applied intervals valid.
749
+
750
+ Only mask-action matches are spliced; detect/block matches are left in
751
+ place (block is handled separately as a deny, detect is observe-only).
752
+ Returns ``text`` unchanged when no mask span applies.
753
+ """
754
+ mask_matches = [m for m in matches if m.action == "mask" and m.length > 0]
755
+ if not mask_matches:
756
+ return text
757
+
758
+ # Sort by start, then by widest span first, so a covering span is seen
759
+ # before the spans it contains and its id labels the merged interval.
760
+ ordered = sorted(
761
+ mask_matches, key=lambda m: (m.offset, -(m.offset + m.length))
762
+ )
763
+ # Coalesce overlapping / nested / adjacent spans into clean intervals.
764
+ intervals: list[tuple[int, int, str]] = [] # (start, end, rule_id)
765
+ for m in ordered:
766
+ start = m.offset
767
+ end = m.offset + m.length
768
+ if intervals and start <= intervals[-1][1]:
769
+ # Overlaps (or is adjacent to) the current interval: extend it.
770
+ # Keep the FIRST rule_id (the covering / earliest span's label)
771
+ # so a nested match never produces a second mangled placeholder.
772
+ prev_start, prev_end, prev_id = intervals[-1]
773
+ intervals[-1] = (prev_start, max(prev_end, end), prev_id)
774
+ else:
775
+ intervals.append((start, end, m.rule_id))
776
+
777
+ # Splice right-to-left so earlier intervals' offsets stay valid.
778
+ out = text
779
+ for start, end, rule_id in sorted(intervals, key=lambda iv: iv[0], reverse=True):
780
+ out = out[:start] + mask_placeholder(rule_id) + out[end:]
781
+ return out
782
+
783
+ def mask_args(self, args: Any) -> tuple[Any, list[DLPMatch]]:
784
+ """Walk ``args`` and mask every action='mask' span in each string leaf.
785
+
786
+ Modify-and-proceed: returns a NEW args structure (the original is never
787
+ mutated) with sensitive spans replaced by value-free
788
+ ``[REDACTED-<RULE_ID-UPPER>]`` tokens, plus the flat list of every
789
+ DLPMatch seen across all string leaves (so the caller can build the
790
+ audit findings and decide whether any block/detect action also fired).
791
+
792
+ Each string leaf is scanned INDEPENDENTLY (not the joined-with-newline
793
+ blob ``extract_text_from_args`` produces) so the per-leaf offsets the
794
+ masker splices are valid against that exact leaf. This is the key
795
+ difference from detect/block, which only need the flattened text.
796
+ """
797
+ all_matches: list[DLPMatch] = []
798
+ masked = self._mask_recursive(args, all_matches)
799
+ return masked, all_matches
800
+
801
+ def _mask_recursive(self, obj: Any, all_matches: list[DLPMatch]) -> Any:
802
+ """Recursively rebuild ``obj`` with mask spans redacted in every leaf."""
803
+ if isinstance(obj, str):
804
+ leaf_matches = self.scan(obj)
805
+ if leaf_matches:
806
+ all_matches.extend(leaf_matches)
807
+ return self.mask_text(obj, leaf_matches)
808
+ if isinstance(obj, dict):
809
+ return {k: self._mask_recursive(v, all_matches) for k, v in obj.items()}
810
+ if isinstance(obj, (list, tuple)):
811
+ rebuilt = [self._mask_recursive(item, all_matches) for item in obj]
812
+ return type(obj)(rebuilt) if isinstance(obj, tuple) else rebuilt
813
+ # Non-string scalar leaves (numbers, bools). The DETECTION path
814
+ # stringifies these (extract_text_from_args -> str(obj)), so a JSON
815
+ # NUMBER like {"card": 4111111111111111} matches a mask rule and enters
816
+ # the mask path. If the masker returned the raw scalar unchanged, the
817
+ # sensitive value would survive in masked_args AND no audit finding
818
+ # would be emitted -- a silent leak, and the common case for Korean
819
+ # card / account / RRN values that arrive as numbers. So we mask
820
+ # non-string scalars too: stringify, scan, and if any mask span fired
821
+ # return the MASKED STRING (number -> string in the masked output is
822
+ # correct; the agent must not receive the raw value). None and other
823
+ # non-scalars carry no maskable span and pass through unchanged.
824
+ if obj is None:
825
+ return obj
826
+ if isinstance(obj, (int, float, bool)):
827
+ as_text = str(obj)
828
+ leaf_matches = self.scan(as_text)
829
+ mask_leaf = [m for m in leaf_matches if m.action == "mask" and m.length > 0]
830
+ if mask_leaf:
831
+ all_matches.extend(leaf_matches)
832
+ return self.mask_text(as_text, leaf_matches)
833
+ # No mask span -> preserve the original scalar type unchanged.
834
+ return obj
835
+ return obj
836
+
700
837
  def get_findings_for_audit(self, matches: list[DLPMatch]) -> list[dict]:
701
838
  """Convert matches to audit-safe findings.
702
839
 
703
840
  For secret-category matches, only count is included (no text).
704
- For other categories, the matched text is included.
841
+ For mask-action matches, the raw value is REPLACED by the value-free
842
+ mask placeholder ("audit shows the masked value, never the secret") --
843
+ consistent with the gateway, which renders a mask as a value-free
844
+ ``[REDACTED-...]`` token everywhere. For other (detect) categories the
845
+ matched text is included.
705
846
  """
706
847
  findings: list[dict] = []
707
848
  for m in matches:
@@ -711,8 +852,15 @@ class DLPScanner:
711
852
  "action": m.action,
712
853
  "count": m.count,
713
854
  }
714
- # Never include matched text for secrets in audit
715
- if m.category != "secret":
855
+ if m.action == "mask":
856
+ # Mask is enforcement, not observation: the audit row records
857
+ # WHAT the value was masked TO (the value-free placeholder),
858
+ # never the raw bytes -- even for pii/financial categories that
859
+ # would otherwise echo plaintext under "detect".
860
+ entry["masked"] = True
861
+ entry["matched_text"] = mask_placeholder(m.rule_id)
862
+ elif m.category != "secret":
863
+ # Never include matched text for secrets in audit
716
864
  entry["matched_text"] = m.matched_text
717
865
  findings.append(entry)
718
866
  return findings
@@ -224,6 +224,16 @@ class PolicyDecision:
224
224
  reason_code: str = ""
225
225
  evaluated_rules: int = 0
226
226
  dlp_findings: list[dict] = field(default_factory=list)
227
+ # DLP MASK action (#1278 kakaopay checklist #6, parity with the gateway
228
+ # ``response_dlp.mask_text`` path). When a DLP rule matches with
229
+ # action="mask" on an ALLOW decision, the enforcer redacts the sensitive
230
+ # spans in the tool arguments (modify-and-proceed) and stashes the
231
+ # redacted args here. None when no mask rule fired -- the caller then
232
+ # forwards the ORIGINAL args unchanged. When set, the host/caller MUST
233
+ # forward THESE args instead so the sensitive value never reaches the
234
+ # tool. The redaction uses the value-free ``[REDACTED-<RULE_ID-UPPER>]``
235
+ # token; the original plaintext is never carried on the decision.
236
+ masked_args: Optional[dict] = None
227
237
  # Phase 1B (#451): version of the policy engine that produced this
228
238
  # decision. Sourced from crates/controlzero-core's Cargo workspace
229
239
  # version today (constant POLICY_ENGINE_VERSION in this module).
@@ -631,9 +641,18 @@ class PolicyEvaluator:
631
641
  ) -> PolicyDecision:
632
642
  """Run DLP scanner on tool arguments and update decision if needed.
633
643
 
634
- If any DLP rule has action="block", the decision is overridden to DENY.
635
- If rules have action="detect" or "mask", findings are attached to the
636
- decision for audit but the call is still allowed.
644
+ Action precedence (highest first):
645
+
646
+ * ``block`` -- the decision is overridden to DENY. block is a
647
+ hard veto: even if other rules want to mask, a single block hit
648
+ fails the call closed.
649
+ * ``mask`` -- the call is ALLOWED but the sensitive spans in the
650
+ tool arguments are redacted in place (modify-and-proceed). The
651
+ redacted args are stashed on ``decision.masked_args`` and the
652
+ audit findings record the value-free ``[REDACTED-...]`` token,
653
+ matching the gateway ``response_dlp.mask_text`` semantics.
654
+ * ``detect`` -- findings are attached for audit; the call is allowed
655
+ and the arguments are forwarded unchanged.
637
656
  """
638
657
  if self._dlp_scanner is None:
639
658
  return decision
@@ -646,10 +665,11 @@ class PolicyEvaluator:
646
665
  if not matches:
647
666
  return decision
648
667
 
649
- findings = self._dlp_scanner.get_findings_for_audit(matches)
650
- decision.dlp_findings = findings
651
-
652
668
  if self._dlp_scanner.has_blocking_match(matches):
669
+ # Block wins over mask/detect. Build the audit findings from the
670
+ # FLAT scan (block is a deny -- no arg rewrite happens).
671
+ findings = self._dlp_scanner.get_findings_for_audit(matches)
672
+ decision.dlp_findings = findings
653
673
  # Find the first blocking rule for the reason message
654
674
  blocking = next(m for m in matches if m.action == "block")
655
675
  return PolicyDecision(
@@ -674,6 +694,74 @@ class PolicyEvaluator:
674
694
  gate_matched=decision.gate_matched,
675
695
  )
676
696
 
697
+ if self._dlp_scanner.has_masking_match(matches):
698
+ # MASK: modify-and-proceed. Re-scan each string leaf
699
+ # INDEPENDENTLY so the per-leaf offsets the masker splices are
700
+ # valid against that exact leaf (the flat blob's offsets are
701
+ # not). ``mask_args`` returns the redacted args + the per-leaf
702
+ # matches; audit findings come from those so a mask never echoes
703
+ # the raw value.
704
+ masked_args, leaf_matches = self._dlp_scanner.mask_args(args)
705
+
706
+ # FAIL-CLOSED leak guard (codex deep review of #1427). DLP DETECTS
707
+ # on the FLATTENED arg text (extract_text_from_args concatenates
708
+ # every leaf value with "\n") but MASKS per-leaf. A flat mask match
709
+ # that does NOT map to any single leaf -- a pattern spanning the
710
+ # boundary between two concatenated args, or any flat match with no
711
+ # covering leaf -- would NOT be redacted by the per-leaf masker,
712
+ # yet has_masking_match(flat) already routed us here. Returning an
713
+ # allow-with-masked_args in that case leaks the raw value with a
714
+ # "masked" verdict. So we VERIFY the post-condition: re-scan the
715
+ # MASKED args' flattened text and confirm no mask-action match
716
+ # survives. If any does, the secret was not fully redacted -- fail
717
+ # CLOSED to a DLP_BLOCKED deny (better to block the call than ship
718
+ # an un-maskable secret).
719
+ masked_text = extract_text_from_args(masked_args)
720
+ residual = self._dlp_scanner.scan(masked_text) if masked_text else []
721
+ if self._dlp_scanner.has_masking_match(residual):
722
+ # Build the deny's audit findings from ONLY the residual
723
+ # MASK-action matches. A co-matching DETECT-action rule (e.g. a
724
+ # detect rule on "SECRET" that also fires on the un-maskable
725
+ # value) would otherwise have get_findings_for_audit echo its
726
+ # RAW matched_text into the audit row -- raw PII in the audit
727
+ # even on a block is a #202 leak (codex re-review of #1427).
728
+ # Mask findings render as the value-free [REDACTED-...]
729
+ # placeholder, so this records THAT a mask rule fired and was
730
+ # blocked, never the matched bytes. (Block-action matches can't
731
+ # appear here: a block hit is handled earlier and short-circuits
732
+ # to deny before the mask branch.)
733
+ residual_mask_only = [m for m in residual if m.action == "mask"]
734
+ offending = residual_mask_only[0]
735
+ return PolicyDecision(
736
+ effect="deny",
737
+ 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"
743
+ ),
744
+ reason_code=REASON_CODE_DLP_BLOCKED,
745
+ evaluated_rules=decision.evaluated_rules,
746
+ # Audit the firing MASK rule(s) WITHOUT echoing the raw
747
+ # value: get_findings_for_audit renders mask findings as the
748
+ # value-free placeholder, so the deny row records that a
749
+ # mask rule fired, never the secret. Detect findings (which
750
+ # carry raw text) are deliberately excluded from this path.
751
+ dlp_findings=self._dlp_scanner.get_findings_for_audit(
752
+ residual_mask_only
753
+ ),
754
+ gate_matched=decision.gate_matched,
755
+ )
756
+
757
+ decision.masked_args = masked_args
758
+ decision.dlp_findings = self._dlp_scanner.get_findings_for_audit(
759
+ leaf_matches
760
+ )
761
+ return decision
762
+
763
+ # detect-only: attach findings, forward args unchanged.
764
+ decision.dlp_findings = self._dlp_scanner.get_findings_for_audit(matches)
677
765
  return decision
678
766
 
679
767