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.
- {controlzero-1.11.6 → controlzero-1.11.8}/.gitignore +8 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/CHANGELOG.md +84 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/PKG-INFO +1 -1
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/__init__.py +1 -1
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/_internal/dlp_scanner.py +151 -3
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/_internal/enforcer.py +94 -6
- controlzero-1.11.8/controlzero/_internal/io_capture_producer.py +264 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/audit_remote.py +205 -1
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/main.py +439 -39
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/client.py +72 -24
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/enrollment.py +188 -0
- controlzero-1.11.8/controlzero/hook_integrity.py +673 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/pyproject.toml +1 -1
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/spool/test_spool_sink_wiring.py +51 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_audit_remote.py +188 -0
- controlzero-1.11.8/tests/test_audit_remote_io_capture.py +142 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_canonical_phase1a.py +19 -18
- controlzero-1.11.8/tests/test_dlp_mask.py +520 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_enrollment.py +274 -0
- controlzero-1.11.8/tests/test_hook_integrity.py +500 -0
- controlzero-1.11.8/tests/test_io_capture_producer.py +172 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_kiro_cli_e2e.py +49 -1
- {controlzero-1.11.6 → controlzero-1.11.8}/Dockerfile.test +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/LICENSE +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/README.md +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/__main__.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/_internal/__init__.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/_internal/action_aliases.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/_internal/action_validator.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/_internal/bundle.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/_internal/credential_hook.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/_internal/credential_scanner.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/_internal/credentials_data/__init__.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/_internal/credentials_data/built_in.yaml +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/_internal/hook_extractors.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/_internal/tool_extractors.json +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/_internal/types.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/audit_local.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/canonical.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/__init__.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/__main__.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/_secrets.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/console.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/debug_bundle.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/doctor.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/exec_cmd.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/hook_entry.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/hosts/__init__.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/hosts/antigravity.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/hosts/base.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/hosts/claude_code.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/hosts/codex_cli.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/hosts/cursor.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/hosts/cursor_cli.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/hosts/gemini_cli.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/hosts/kiro.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/hosts/unknown.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/kiro_adapter.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/migrate.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/spool_cmd.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/telemetry_consent.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/templates/antigravity/hooks.json +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/templates/antigravity.yaml +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/templates/autogen.yaml +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/templates/claude-code.yaml +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/templates/codex-cli.yaml +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/templates/cost-cap.yaml +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/templates/crewai.yaml +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/templates/cursor.yaml +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/templates/gemini-cli.yaml +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/templates/generic.yaml +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/templates/kiro/ide-file-save.kiro.hook +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/templates/kiro/ide-pre-tool-use.kiro.hook +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/templates/kiro/ide-prompt-submit.kiro.hook +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/templates/kiro/kiro.yaml +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/templates/langchain.yaml +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/templates/mcp.yaml +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/templates/rag.yaml +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/cli/update_cmd.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/device.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/error_codes.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/error_codes.yaml +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/errors.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/hitl/__init__.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/hitl/grant_protocol.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/hitl/mock.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/hitl/pending_approval.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/hitl/secret_leak_guard.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/hitl/status.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/hooks/__init__.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/hooks/tool_output_handler.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/hosted_policy.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/__init__.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/anthropic.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/autogen.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/braintrust.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/crewai/__init__.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/crewai/agent.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/crewai/crew.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/crewai/task.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/crewai/tool.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/google.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/google_adk/__init__.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/google_adk/agent.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/google_adk/tool.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/langchain/__init__.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/langchain/agent.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/langchain/callbacks.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/langchain/chain.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/langchain/graph.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/langchain/modern.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/langchain/tool.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/langfuse.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/litellm.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/openai.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/pydantic_ai.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/integrations/vercel_ai.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/layout_migration.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/policy_loader.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/secrets/__init__.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/secrets/reference.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/secrets/resolver.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/secrets/secretstr.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/spool/__init__.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/spool/_compress.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/spool/_constants.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/spool/_crc32c.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/spool/_crypto.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/spool/_frame.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/spool/_keyring.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/spool/_metrics.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/spool/_spool.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/spool/_state.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/spool/_uploader.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/spool/cz-audit-v1.dict +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/tamper.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/controlzero/tracecontext.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/examples/hello_world.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/_fixtures/antigravity-envelope-expected.json +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/_fixtures/jcs_args_hash_vectors.json +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/conftest.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/integrations/__init__.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/integrations/test_google.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/parity/action_aliases.json +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/spool/__init__.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/spool/conftest.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/spool/test_spool_cli.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/spool/test_spool_concurrency.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/spool/test_spool_conformance.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/spool/test_spool_core.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/spool/test_spool_crash.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/spool/test_spool_diskfull.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/spool/test_spool_durable_default_tamper.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/spool/test_spool_keychain_dek.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/spool/test_spool_transcript_localack.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/spool/test_spool_uploader.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_action_aliases.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_action_canonicalization.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_action_validator_t86.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_adapter_interface.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_agent_name_env.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_agy_docs_no_exit2_1373.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_antigravity_adapter.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_antigravity_envelope_parity.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_antigravity_ga_blockers_1248.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_antigravity_hook_check.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_antigravity_install.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_antigravity_posttooluse_observe_58.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_antigravity_tool_vocab_1303.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_api_key_mask.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_audit_remote_sdk_version.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_audit_sink_isolation.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_bundle_cache_freshness_1303.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_bundle_parser.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_bundle_translate.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_capability_matrix_doc.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_cli_carve_out.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_cli_debug_bundle.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_cli_extractor_integration.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_cli_hook.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_cli_hook_entry.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_cli_hosted_refresh.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_cli_init.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_cli_init_templates.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_cli_tail.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_cli_test.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_cli_validate.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_coding_agent_hooks.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_conditions.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_config_format_parity_1303.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_conformance.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_console.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_credential_hook.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_cursor_adapter.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_default_action.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_device.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_dlp_scanner.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_doctor.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_engine_version_consistency.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_env_dump_438.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_epic_1247_bryan_acceptance.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_error_codes.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_errors_e_codes.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_fail_closed_eval.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_failopen_1303.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_glob_matching.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hitl_5d_email_install.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hitl_6a_cli_flag.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hitl_6a_exceptions.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hitl_6a_get_secret_hitl.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hitl_6a_mock_backend.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hitl_6a_pending_approval.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hitl_6a_request_approval.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hitl_6a_secret_leak_guard.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hitl_6a_wait.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hitl_conformance.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hitl_phase2b_protocol.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hitl_reason_codes.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hitl_validator_keys.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hook_extractors.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hosted_local_audit_1247.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hosted_local_shadow_warn_1265.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hosted_policy_e2e.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hosts_adapter.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hybrid_mode_strict.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_hybrid_mode_warn.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_install_hook_command.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_install_hooks.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_kiro_adapter.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_kiro_cli_hook_pin_1265.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_kiro_hook_templates.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_kiro_install.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_layout_migration_t101.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_layout_parity_t102.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_local_mode_dict.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_local_mode_file_json.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_local_mode_file_yaml.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_log_fallback_stderr.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_log_options_ignored_hosted.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_log_rotation.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_migrate.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_min_sdk_version_gate.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_multi_client_per_project_175.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_no_policy_no_key.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_observe_mode_1247.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_package_rename_shim.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_part3_active_policy_count_1303.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_policy_engine_version_phase1b.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_policy_freshness.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_policy_load_failclosed_f3.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_policy_settings.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_policy_source_audit.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_probe_audit_isolation.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_quarantine.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_reason_code.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_refresh.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_secrets.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_secrets_reference_resolver.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_sql_semantic_class.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_synthetic_policy_id_t79.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_t103_precedence.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_t104_cache_gc.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_t108_local_override_audit.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_t96_single_audit_log.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_t99_install_prefetch_bundle.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_tamper.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_tamper_behavior.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_tamper_hook.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_telemetry_consent.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_tracecontext.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_unsafe_int_boundary.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_update_cmd.py +0 -0
- {controlzero-1.11.6 → controlzero-1.11.8}/tests/test_upgrade_nudge.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
|
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
|
-
|
|
715
|
-
|
|
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
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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
|
|