controlzero 1.9.2__tar.gz → 1.9.3__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.9.2 → controlzero-1.9.3}/CHANGELOG.md +77 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/PKG-INFO +1 -1
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/__init__.py +1 -1
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/_internal/bundle.py +78 -32
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/_internal/enforcer.py +95 -3
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/main.py +12 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/policy_loader.py +15 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/pyproject.toml +1 -1
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_bundle_parser.py +11 -3
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_default_action.py +160 -12
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hitl_reason_codes.py +6 -5
- controlzero-1.9.3/tests/test_observe_mode_1247.py +362 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_reason_code.py +41 -17
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_synthetic_policy_id_t79.py +40 -14
- {controlzero-1.9.2 → controlzero-1.9.3}/.gitignore +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/Dockerfile.test +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/LICENSE +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/README.md +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/_internal/__init__.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/_internal/action_aliases.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/_internal/action_validator.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/_internal/credential_hook.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/_internal/credential_scanner.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/_internal/credentials_data/__init__.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/_internal/credentials_data/built_in.yaml +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/_internal/dlp_scanner.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/_internal/hook_extractors.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/_internal/tool_extractors.json +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/_internal/types.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/audit_local.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/audit_remote.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/canonical.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/__init__.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/_secrets.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/console.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/debug_bundle.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/doctor.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/hosts/__init__.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/hosts/antigravity.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/hosts/base.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/hosts/claude_code.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/hosts/codex_cli.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/hosts/gemini_cli.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/hosts/kiro.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/hosts/unknown.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/kiro_adapter.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/migrate.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/spool_cmd.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/telemetry_consent.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/templates/antigravity/hooks.json +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/templates/antigravity.yaml +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/templates/autogen.yaml +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/templates/claude-code.yaml +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/templates/codex-cli.yaml +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/templates/cost-cap.yaml +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/templates/crewai.yaml +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/templates/cursor.yaml +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/templates/gemini-cli.yaml +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/templates/generic.yaml +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/templates/kiro/ide-file-save.kiro.hook +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/templates/kiro/ide-pre-tool-use.kiro.hook +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/templates/kiro/ide-prompt-submit.kiro.hook +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/templates/kiro/kiro.yaml +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/templates/langchain.yaml +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/templates/mcp.yaml +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/cli/templates/rag.yaml +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/client.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/device.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/enrollment.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/error_codes.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/error_codes.yaml +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/errors.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/hitl/__init__.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/hitl/grant_protocol.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/hitl/mock.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/hitl/pending_approval.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/hitl/secret_leak_guard.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/hitl/status.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/hooks/__init__.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/hooks/tool_output_handler.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/hosted_policy.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/__init__.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/anthropic.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/autogen.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/braintrust.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/crewai/__init__.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/crewai/agent.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/crewai/crew.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/crewai/task.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/crewai/tool.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/google.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/google_adk/__init__.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/google_adk/agent.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/google_adk/tool.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/langchain/__init__.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/langchain/agent.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/langchain/callbacks.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/langchain/chain.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/langchain/graph.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/langchain/modern.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/langchain/tool.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/langfuse.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/litellm.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/openai.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/pydantic_ai.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/integrations/vercel_ai.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/layout_migration.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/spool/__init__.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/spool/_compress.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/spool/_constants.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/spool/_crc32c.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/spool/_crypto.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/spool/_frame.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/spool/_keyring.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/spool/_metrics.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/spool/_spool.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/spool/_state.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/spool/_uploader.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/spool/cz-audit-v1.dict +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/tamper.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/controlzero/tracecontext.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/examples/hello_world.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/_fixtures/jcs_args_hash_vectors.json +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/conftest.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/integrations/__init__.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/integrations/test_google.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/parity/action_aliases.json +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/spool/__init__.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/spool/conftest.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/spool/test_spool_cli.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/spool/test_spool_concurrency.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/spool/test_spool_conformance.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/spool/test_spool_core.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/spool/test_spool_crash.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/spool/test_spool_diskfull.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/spool/test_spool_durable_default_tamper.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/spool/test_spool_keychain_dek.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/spool/test_spool_sink_wiring.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/spool/test_spool_transcript_localack.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/spool/test_spool_uploader.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_action_aliases.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_action_canonicalization.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_action_validator_t86.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_agent_name_env.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_antigravity_adapter.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_antigravity_hook_check.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_antigravity_install.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_api_key_mask.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_audit_remote.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_audit_remote_sdk_version.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_audit_sink_isolation.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_bundle_translate.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_canonical_phase1a.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_cli_carve_out.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_cli_debug_bundle.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_cli_extractor_integration.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_cli_hook.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_cli_hosted_refresh.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_cli_init.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_cli_init_templates.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_cli_tail.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_cli_test.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_cli_validate.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_coding_agent_hooks.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_conditions.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_conformance.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_console.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_credential_hook.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_device.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_dlp_scanner.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_doctor.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_engine_version_consistency.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_enrollment.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_env_dump_438.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_error_codes.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_errors_e_codes.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_fail_closed_eval.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_glob_matching.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hitl_5d_email_install.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hitl_6a_cli_flag.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hitl_6a_exceptions.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hitl_6a_get_secret_hitl.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hitl_6a_mock_backend.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hitl_6a_pending_approval.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hitl_6a_request_approval.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hitl_6a_secret_leak_guard.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hitl_6a_wait.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hitl_conformance.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hitl_phase2b_protocol.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hitl_validator_keys.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hook_extractors.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hosted_local_audit_1247.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hosted_policy_e2e.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hosts_adapter.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hybrid_mode_strict.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_hybrid_mode_warn.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_install_hook_command.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_install_hooks.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_kiro_adapter.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_kiro_hook_templates.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_kiro_install.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_layout_migration_t101.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_layout_parity_t102.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_local_mode_dict.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_local_mode_file_json.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_local_mode_file_yaml.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_log_fallback_stderr.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_log_options_ignored_hosted.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_log_rotation.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_migrate.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_min_sdk_version_gate.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_multi_client_per_project_175.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_no_policy_no_key.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_package_rename_shim.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_policy_engine_version_phase1b.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_policy_freshness.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_policy_settings.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_policy_source_audit.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_quarantine.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_refresh.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_secrets.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_sql_semantic_class.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_t103_precedence.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_t104_cache_gc.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_t108_local_override_audit.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_t96_single_audit_log.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_t99_install_prefetch_bundle.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_tamper.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_tamper_behavior.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_tamper_hook.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_telemetry_consent.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_tracecontext.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tests/test_unsafe_int_boundary.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.3}/tools/cz-kiro-adapter +0 -0
|
@@ -1,5 +1,82 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.9.3 -- 2026-06-16 (posture release: empty-bundle OBSERVE + self-explaining no-rule-match deny, epic gh#1247)
|
|
4
|
+
|
|
5
|
+
The consolidated **posture release** for epic gh#1247 (customer Bryan).
|
|
6
|
+
Three customer-visible behaviors land together so the engine's "what
|
|
7
|
+
happens when a tool isn't covered by a rule" story is coherent across
|
|
8
|
+
both SDKs and the backend:
|
|
9
|
+
|
|
10
|
+
1. **Empty bundle -> OBSERVE** (was the only content of the superseded
|
|
11
|
+
1.9.2-observe draft).
|
|
12
|
+
2. **Non-empty no-rule-match deny is now self-explaining** (folds in the
|
|
13
|
+
Python-only fix from gh#1257, which is superseded by this release).
|
|
14
|
+
3. The backend `default_on_empty` knob on the bundle handler.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- **Hosted no-rule-match deny is now self-explaining (folds gh#1257).** A
|
|
19
|
+
non-empty bundle with `default_action=deny` and zero rule matches (the
|
|
20
|
+
exact shape of Bryan's "Db read only" allow-list, which excludes
|
|
21
|
+
`bash`) used to deny with the bare, bug-looking message
|
|
22
|
+
`No matching policy rule (fail-closed default)` -- no path out. The
|
|
23
|
+
deny reason now NAMES the unmatched action (e.g. `bash:find`) and the
|
|
24
|
+
exact remediation (add a catch-all `allow: '*'`, allow the specific
|
|
25
|
+
action, or flip the project/org default to allow) and tells the user
|
|
26
|
+
to do it in the Control Zero dashboard. `reason_code` stays
|
|
27
|
+
`NO_RULE_MATCH`; the legacy `fail-closed default` substring is retained
|
|
28
|
+
for any downstream regex consumer. The empty-bundle OBSERVE catch-all
|
|
29
|
+
matches FIRST, so a genuinely-empty bundle still observes and never
|
|
30
|
+
reaches this deny -- the two behaviors compose cleanly. Regression
|
|
31
|
+
tests in `tests/test_default_action.py`
|
|
32
|
+
(`test_1247_no_rule_match_deny_*`).
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
A genuinely-EMPTY hosted bundle -- one that resolved successfully but has
|
|
37
|
+
zero attached/active policies -- now defaults to **OBSERVE** (allow +
|
|
38
|
+
audit + a loud "monitoring, not enforcing" signal) instead of the old
|
|
39
|
+
day-one deny-brick. A fresh hosted project no longer blocks every tool
|
|
40
|
+
call before the operator has authored a single rule; instead Control
|
|
41
|
+
Zero allows the call through and audits it, loudly flagged, so the
|
|
42
|
+
operator can see the engine is wired up and watching, then attach a
|
|
43
|
+
policy to start enforcing.
|
|
44
|
+
|
|
45
|
+
This is a founder-approved posture refinement (validated by a 4-lens
|
|
46
|
+
second-opinion). It is deliberately **narrow and gated**:
|
|
47
|
+
|
|
48
|
+
- **Only the genuinely-empty, successfully-resolved case observes.** A
|
|
49
|
+
non-empty bundle whose rules evaluate but nothing matches still
|
|
50
|
+
**denies** (`NO_RULE_MATCH`, `default_action` canonical deny) --
|
|
51
|
+
authored allow-lists stay secure. A bundle RESOLUTION ERROR / failed
|
|
52
|
+
pull / RLS / auth / decrypt failure still **fails closed** (deny,
|
|
53
|
+
`BUNDLE_MISSING`, honoring `default_on_missing`). Observe mode can
|
|
54
|
+
never mask a resolution error as an allow.
|
|
55
|
+
- **The empty-vs-error boundary is structural, not a runtime flag.** The
|
|
56
|
+
empty path (`translate_to_local_policy`, reached only on successful
|
|
57
|
+
resolution) and the error path (`make_bundle_missing_policy`, reached
|
|
58
|
+
only on resolution failure) are separate functions with separate
|
|
59
|
+
reason codes, so they cannot be confused.
|
|
60
|
+
|
|
61
|
+
### Added
|
|
62
|
+
|
|
63
|
+
- **New `reason_code=OBSERVE_MODE_NO_POLICY`** and synthetic policy_id
|
|
64
|
+
`synthetic:OBSERVE_MODE_NO_POLICY` for the empty-bundle observe allow,
|
|
65
|
+
distinct from `NO_ACTIVE_POLICIES` (the deny/warn/allow empty postures)
|
|
66
|
+
and `BUNDLE_MISSING` (the fail-closed resolution-error path).
|
|
67
|
+
- **`PolicyDecision.observe: bool`** -- True only on an observe-mode
|
|
68
|
+
allow. The CLI `guard` output now prints a loud yellow "OBSERVE MODE:
|
|
69
|
+
monitoring, not enforcing" line so an observe allow can never be
|
|
70
|
+
mistaken for a normal rule-driven allow. Gated strictly on the
|
|
71
|
+
reason_code, so no user-authored rule can ever set it.
|
|
72
|
+
- **New `default_on_empty` knob** (`observe`|`deny`|`warn`|`allow`,
|
|
73
|
+
canonical `observe`), separate from `default_action`. An operator can
|
|
74
|
+
declare allow-list-vs-deny-list intent for the empty case instead of
|
|
75
|
+
it being inferred. Plumbed end-to-end: backend bundle payload ->
|
|
76
|
+
bundle translator -> `PolicySettings`. Local YAML policies may set
|
|
77
|
+
`settings.default_on_empty`. The dashboard observe-mode indicator +
|
|
78
|
+
CLI `status` line remain a separate frontend/CLI follow-up.
|
|
79
|
+
|
|
3
80
|
## 1.9.1 -- 2026-06-16 (hosted-mode local audit log P0, epic gh#1247)
|
|
4
81
|
|
|
5
82
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: controlzero
|
|
3
|
-
Version: 1.9.
|
|
3
|
+
Version: 1.9.3
|
|
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
|
|
@@ -493,9 +493,11 @@ def translate_to_local_policy(payload: dict) -> dict:
|
|
|
493
493
|
# the canonical-default fallback (one source of truth).
|
|
494
494
|
from controlzero._internal.enforcer import (
|
|
495
495
|
DEFAULT_BUNDLE_ACTION,
|
|
496
|
+
DEFAULT_BUNDLE_ON_EMPTY,
|
|
496
497
|
DEFAULT_BUNDLE_ON_MISSING,
|
|
497
498
|
DEFAULT_BUNDLE_ON_TAMPER,
|
|
498
499
|
VALID_DEFAULT_ACTIONS,
|
|
500
|
+
VALID_DEFAULT_ON_EMPTY,
|
|
499
501
|
VALID_DEFAULT_ON_MISSING,
|
|
500
502
|
VALID_DEFAULT_ON_TAMPER,
|
|
501
503
|
)
|
|
@@ -508,6 +510,19 @@ def translate_to_local_policy(payload: dict) -> dict:
|
|
|
508
510
|
if default_on_missing not in VALID_DEFAULT_ON_MISSING:
|
|
509
511
|
default_on_missing = DEFAULT_BUNDLE_ON_MISSING
|
|
510
512
|
|
|
513
|
+
# default_on_empty (#1247 item 3): empty-bundle posture, resolved
|
|
514
|
+
# server-side org->project->canonical("observe"). Separate from
|
|
515
|
+
# default_action so an authored allow-list (no-match -> deny) and an
|
|
516
|
+
# empty project (observe) do not have to share one knob. Unknown /
|
|
517
|
+
# absent values coerce to the canonical "observe" so a fresh hosted
|
|
518
|
+
# project is observe-by-default instead of bricked day-one. NOTE:
|
|
519
|
+
# this knob is read ONLY here, on the SUCCESSFUL-resolution empty
|
|
520
|
+
# branch below -- the resolution-ERROR path (make_bundle_missing_
|
|
521
|
+
# policy) does not consult it and still fails closed.
|
|
522
|
+
default_on_empty = payload.get("default_on_empty")
|
|
523
|
+
if default_on_empty not in VALID_DEFAULT_ON_EMPTY:
|
|
524
|
+
default_on_empty = DEFAULT_BUNDLE_ON_EMPTY
|
|
525
|
+
|
|
511
526
|
default_on_tamper = payload.get("default_on_tamper")
|
|
512
527
|
if default_on_tamper not in VALID_DEFAULT_ON_TAMPER:
|
|
513
528
|
default_on_tamper = DEFAULT_BUNDLE_ON_TAMPER
|
|
@@ -536,39 +551,69 @@ def translate_to_local_policy(payload: dict) -> dict:
|
|
|
536
551
|
flat.append(translated)
|
|
537
552
|
|
|
538
553
|
if not flat:
|
|
539
|
-
# Empty policy set
|
|
540
|
-
#
|
|
541
|
-
#
|
|
542
|
-
#
|
|
543
|
-
#
|
|
544
|
-
#
|
|
545
|
-
#
|
|
546
|
-
#
|
|
554
|
+
# Empty policy set (RESOLVED SUCCESSFULLY, zero translatable
|
|
555
|
+
# rules): synthetic catch-all rule whose posture is driven by
|
|
556
|
+
# default_on_empty (#1247 item 3 / #1252), NOT default_action.
|
|
557
|
+
#
|
|
558
|
+
# Why a separate knob: default_action governs the NON-EMPTY
|
|
559
|
+
# no-match path -- an org that authored an allow-list wants that
|
|
560
|
+
# to stay deny. But an org that has attached NOTHING YET should
|
|
561
|
+
# not be bricked day-one. The founder-approved refinement makes
|
|
562
|
+
# the genuinely-empty case OBSERVE by default: the call is
|
|
563
|
+
# ALLOWED THROUGH (effect="allow") but loudly flagged as
|
|
564
|
+
# monitoring-only (reason_code=OBSERVE_MODE_NO_POLICY, which the
|
|
565
|
+
# evaluator turns into observe=True on the decision) and
|
|
566
|
+
# audited, so the operator KNOWS the engine is wired up and
|
|
567
|
+
# watching, not enforcing. An operator can override
|
|
568
|
+
# default_on_empty to deny (fail-closed empty), warn (shadow),
|
|
569
|
+
# or allow (silent allow).
|
|
570
|
+
#
|
|
571
|
+
# SAFETY INVARIANT (the empty-vs-error boundary): this branch is
|
|
572
|
+
# reached ONLY when bundle resolution SUCCEEDED and produced
|
|
573
|
+
# zero rules. A resolution FAILURE (pull error, RLS/auth denial,
|
|
574
|
+
# decrypt failure) never reaches here -- it is caught upstream
|
|
575
|
+
# and routed to make_bundle_missing_policy(), which honors
|
|
576
|
+
# default_on_missing and STILL FAILS CLOSED (deny). So observe
|
|
577
|
+
# mode can never mask a resolution error as an allow.
|
|
547
578
|
#
|
|
548
|
-
# Copy-choice note (2026-04-19, the 2026-04-19 P0): the
|
|
549
|
-
# message
|
|
550
|
-
#
|
|
551
|
-
#
|
|
552
|
-
#
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
)
|
|
570
|
-
|
|
571
|
-
|
|
579
|
+
# Copy-choice note (2026-04-19, the 2026-04-19 P0): the original
|
|
580
|
+
# NO_ACTIVE_POLICIES message presumed the user had defined no
|
|
581
|
+
# policies; in the common case the library-attachments state was
|
|
582
|
+
# simply empty while the dashboard still showed them. The wording
|
|
583
|
+
# below removes that presumption and points at recovery.
|
|
584
|
+
if default_on_empty == "observe":
|
|
585
|
+
# OBSERVE: allow + loud monitoring signal + audit.
|
|
586
|
+
flat.append({
|
|
587
|
+
"effect": "allow",
|
|
588
|
+
"action": "*",
|
|
589
|
+
"id": "synthetic:OBSERVE_MODE_NO_POLICY",
|
|
590
|
+
"reason": (
|
|
591
|
+
"OBSERVE MODE: no policies are active on this project, so "
|
|
592
|
+
"Control Zero is monitoring and auditing tool calls but "
|
|
593
|
+
"NOT enforcing -- every call is allowed and logged. Attach "
|
|
594
|
+
"a policy (or set the empty-project default to deny) in the "
|
|
595
|
+
"Control Zero dashboard to start enforcing."
|
|
596
|
+
),
|
|
597
|
+
"reason_code": "OBSERVE_MODE_NO_POLICY",
|
|
598
|
+
})
|
|
599
|
+
else:
|
|
600
|
+
# Explicit non-observe empty posture (deny / warn / allow).
|
|
601
|
+
# Keeps the historical NO_ACTIVE_POLICIES reason_code so
|
|
602
|
+
# dashboards still bucket it as "nothing attached". The
|
|
603
|
+
# effect honors the operator's declared default_on_empty.
|
|
604
|
+
flat.append({
|
|
605
|
+
"effect": default_on_empty,
|
|
606
|
+
"action": "*",
|
|
607
|
+
# T79: stamp a synthetic policy_id so the audit dashboard
|
|
608
|
+
# can render a recognizable chip + tooltip linking to the
|
|
609
|
+
# right troubleshooting anchor.
|
|
610
|
+
"id": "synthetic:NO_ACTIVE_POLICIES",
|
|
611
|
+
"reason": (
|
|
612
|
+
"No policies are active on this project. If the dashboard "
|
|
613
|
+
"shows attached policies, regenerate the policy bundle."
|
|
614
|
+
),
|
|
615
|
+
"reason_code": "NO_ACTIVE_POLICIES",
|
|
616
|
+
})
|
|
572
617
|
|
|
573
618
|
out = {
|
|
574
619
|
"version": "1",
|
|
@@ -576,6 +621,7 @@ def translate_to_local_policy(payload: dict) -> dict:
|
|
|
576
621
|
"settings": {
|
|
577
622
|
"default_action": default_action,
|
|
578
623
|
"default_on_missing": default_on_missing,
|
|
624
|
+
"default_on_empty": default_on_empty,
|
|
579
625
|
"default_on_tamper": default_on_tamper,
|
|
580
626
|
},
|
|
581
627
|
}
|
|
@@ -62,6 +62,20 @@ POLICY_ENGINE_VERSION = "0.1.0"
|
|
|
62
62
|
REASON_CODE_RULE_MATCH = "RULE_MATCH"
|
|
63
63
|
REASON_CODE_NO_RULE_MATCH = "NO_RULE_MATCH"
|
|
64
64
|
REASON_CODE_NO_ACTIVE_POLICIES = "NO_ACTIVE_POLICIES"
|
|
65
|
+
# OBSERVE_MODE_NO_POLICY (#1247 item 3 / #1252): a genuinely-empty,
|
|
66
|
+
# rule-less bundle that RESOLVED SUCCESSFULLY (zero attached/active
|
|
67
|
+
# policies) and whose resolved empty-policy posture is "observe". The
|
|
68
|
+
# call is ALLOWED THROUGH but the decision is loudly flagged as
|
|
69
|
+
# monitoring-only (observe=True on the decision) and audited, so the
|
|
70
|
+
# operator knows the engine is NOT enforcing. This is deliberately a
|
|
71
|
+
# DISTINCT code from NO_ACTIVE_POLICIES (which remains for the
|
|
72
|
+
# deny/warn empty-posture cases and pre-refinement consumers) and from
|
|
73
|
+
# BUNDLE_MISSING (a resolution FAILURE, which still fails closed -- see
|
|
74
|
+
# make_bundle_missing_policy). The empty-vs-error boundary is the
|
|
75
|
+
# security-critical invariant here: OBSERVE_MODE_NO_POLICY is ONLY ever
|
|
76
|
+
# emitted on the SUCCESSFUL-resolution empty path, never on a pull /
|
|
77
|
+
# RLS / auth error.
|
|
78
|
+
REASON_CODE_OBSERVE_MODE_NO_POLICY = "OBSERVE_MODE_NO_POLICY"
|
|
65
79
|
REASON_CODE_BUNDLE_MISSING = "BUNDLE_MISSING"
|
|
66
80
|
REASON_CODE_BUNDLE_TAMPERED = "BUNDLE_TAMPERED"
|
|
67
81
|
REASON_CODE_MACHINE_QUARANTINED = "MACHINE_QUARANTINED"
|
|
@@ -93,6 +107,7 @@ VALID_REASON_CODES = frozenset({
|
|
|
93
107
|
REASON_CODE_RULE_MATCH,
|
|
94
108
|
REASON_CODE_NO_RULE_MATCH,
|
|
95
109
|
REASON_CODE_NO_ACTIVE_POLICIES,
|
|
110
|
+
REASON_CODE_OBSERVE_MODE_NO_POLICY,
|
|
96
111
|
REASON_CODE_BUNDLE_MISSING,
|
|
97
112
|
REASON_CODE_BUNDLE_TAMPERED,
|
|
98
113
|
REASON_CODE_MACHINE_QUARANTINED,
|
|
@@ -129,6 +144,7 @@ VALID_REASON_CODES = frozenset({
|
|
|
129
144
|
SYNTHETIC_POLICY_ID_PREFIX = "synthetic:"
|
|
130
145
|
SYNTHETIC_NO_RULE_MATCH = "synthetic:NO_RULE_MATCH"
|
|
131
146
|
SYNTHETIC_NO_ACTIVE_POLICIES = "synthetic:NO_ACTIVE_POLICIES"
|
|
147
|
+
SYNTHETIC_OBSERVE_MODE_NO_POLICY = "synthetic:OBSERVE_MODE_NO_POLICY"
|
|
132
148
|
SYNTHETIC_BUNDLE_MISSING = "synthetic:BUNDLE_MISSING"
|
|
133
149
|
SYNTHETIC_RESOURCE_GATE_SKIP = "synthetic:RESOURCE_GATE_SKIP"
|
|
134
150
|
SYNTHETIC_QUARANTINE = "synthetic:QUARANTINE"
|
|
@@ -137,6 +153,7 @@ SYNTHETIC_ENGINE_UNAVAILABLE = "synthetic:ENGINE_UNAVAILABLE"
|
|
|
137
153
|
VALID_SYNTHETIC_POLICY_IDS = frozenset({
|
|
138
154
|
SYNTHETIC_NO_RULE_MATCH,
|
|
139
155
|
SYNTHETIC_NO_ACTIVE_POLICIES,
|
|
156
|
+
SYNTHETIC_OBSERVE_MODE_NO_POLICY,
|
|
140
157
|
SYNTHETIC_BUNDLE_MISSING,
|
|
141
158
|
SYNTHETIC_RESOURCE_GATE_SKIP,
|
|
142
159
|
SYNTHETIC_QUARANTINE,
|
|
@@ -155,8 +172,30 @@ DEFAULT_BUNDLE_ACTION = "deny"
|
|
|
155
172
|
DEFAULT_BUNDLE_ON_MISSING = "deny"
|
|
156
173
|
DEFAULT_BUNDLE_ON_TAMPER = "warn"
|
|
157
174
|
|
|
175
|
+
# default_on_empty (#1247 item 3 / #1252, founder-approved posture
|
|
176
|
+
# refinement): the effect for a genuinely-EMPTY bundle -- one that
|
|
177
|
+
# RESOLVED SUCCESSFULLY but has zero attached/active rules. This is a
|
|
178
|
+
# SEPARATE knob from default_action (which governs the non-empty
|
|
179
|
+
# no-match path). The distinction is the whole point: an org that has
|
|
180
|
+
# authored an allow-list deliberately wants no-match -> deny
|
|
181
|
+
# (default_action), while an org that has attached NOTHING YET should
|
|
182
|
+
# not be bricked day-one -- it observes (allow + audit + a loud
|
|
183
|
+
# "monitoring, not enforcing" signal) instead.
|
|
184
|
+
#
|
|
185
|
+
# Canonical default is "observe": a fresh/empty hosted project allows
|
|
186
|
+
# tool calls through but loudly flags + audits every one as
|
|
187
|
+
# OBSERVE_MODE_NO_POLICY, so the operator sees the engine is wired up
|
|
188
|
+
# and watching, then authors real rules. An operator can override to
|
|
189
|
+
# "deny" (fail-closed empty), "warn" (shadow), or "allow" (silent
|
|
190
|
+
# allow, discouraged). CRITICAL: this only ever applies on the
|
|
191
|
+
# SUCCESSFUL-resolution empty path; a resolution ERROR routes through
|
|
192
|
+
# make_bundle_missing_policy + default_on_missing and STILL FAILS
|
|
193
|
+
# CLOSED -- default_on_empty never weakens the error path.
|
|
194
|
+
DEFAULT_BUNDLE_ON_EMPTY = "observe"
|
|
195
|
+
|
|
158
196
|
VALID_DEFAULT_ACTIONS = frozenset({"deny", "allow", "warn"})
|
|
159
197
|
VALID_DEFAULT_ON_MISSING = frozenset({"deny", "allow"})
|
|
198
|
+
VALID_DEFAULT_ON_EMPTY = frozenset({"observe", "deny", "allow", "warn"})
|
|
160
199
|
VALID_DEFAULT_ON_TAMPER = frozenset({"warn", "deny", "deny-all", "quarantine"})
|
|
161
200
|
|
|
162
201
|
|
|
@@ -217,6 +256,19 @@ class PolicyDecision:
|
|
|
217
256
|
# requires_approval is True. None falls back to policy_id at
|
|
218
257
|
# request time so the approver always sees a label.
|
|
219
258
|
approval_action: Optional[str] = None
|
|
259
|
+
# observe (#1247 item 3 / #1252): True when this decision is an
|
|
260
|
+
# OBSERVE-MODE allow -- the call is permitted (effect="allow") but
|
|
261
|
+
# the engine is NOT enforcing because the bundle is genuinely empty
|
|
262
|
+
# (zero attached/active policies) and the resolved empty-policy
|
|
263
|
+
# posture is "observe". This is the LOUD signal the security review
|
|
264
|
+
# mandated: an observe allow must never look like a normal,
|
|
265
|
+
# rule-driven allow. Consumers (CLI output, audit annotation, hook
|
|
266
|
+
# banners) branch on this to print "OBSERVE MODE: monitoring, not
|
|
267
|
+
# enforcing". It is set ONLY alongside
|
|
268
|
+
# reason_code=OBSERVE_MODE_NO_POLICY and is False on every other
|
|
269
|
+
# path (including a real rule that happens to allow, and the
|
|
270
|
+
# fail-closed BUNDLE_MISSING / NO_RULE_MATCH paths).
|
|
271
|
+
observe: bool = False
|
|
220
272
|
|
|
221
273
|
@property
|
|
222
274
|
def decision(self) -> str:
|
|
@@ -457,6 +509,25 @@ class PolicyEvaluator:
|
|
|
457
509
|
reason_code=decision_code,
|
|
458
510
|
evaluated_rules=evaluated,
|
|
459
511
|
gate_matched=gate_matched_value,
|
|
512
|
+
# #1247 item 3: the empty-bundle observe catch-all
|
|
513
|
+
# (synthetic OBSERVE_MODE_NO_POLICY rule) is an allow
|
|
514
|
+
# that must be loudly flagged as monitoring-only. Set
|
|
515
|
+
# the observe signal here so CLI / hook / audit
|
|
516
|
+
# consumers can distinguish it from a normal
|
|
517
|
+
# rule-driven allow.
|
|
518
|
+
#
|
|
519
|
+
# Hardening (security review L1): gate on BOTH the
|
|
520
|
+
# reason_code AND the synthetic policy_id, so that even a
|
|
521
|
+
# (backend-signed) bundle rule that carried a forged
|
|
522
|
+
# reason_code="OBSERVE_MODE_NO_POLICY" cannot mislabel a
|
|
523
|
+
# rule-driven allow as observe -- the synthetic id is
|
|
524
|
+
# only ever stamped by translate_to_local_policy's empty
|
|
525
|
+
# branch. observe is an audit/UX label, never an
|
|
526
|
+
# authorization input, so this is belt-and-suspenders.
|
|
527
|
+
observe=(
|
|
528
|
+
decision_code == REASON_CODE_OBSERVE_MODE_NO_POLICY
|
|
529
|
+
and (rule.id or "") == SYNTHETIC_OBSERVE_MODE_NO_POLICY
|
|
530
|
+
),
|
|
460
531
|
)
|
|
461
532
|
# DLP scan: only when the policy decision is "allow" and args exist
|
|
462
533
|
if decision.allowed and args and self._dlp_scanner is not None:
|
|
@@ -475,11 +546,32 @@ class PolicyEvaluator:
|
|
|
475
546
|
# that regex-match the reason string (a pattern we actively
|
|
476
547
|
# discourage -- use reason_code instead) keep working.
|
|
477
548
|
if self._default_action == "deny":
|
|
478
|
-
|
|
549
|
+
# #1247 (folded from #1257): a deny-by-default no-rule-match is
|
|
550
|
+
# the single most confusing block a customer hits -- a benign
|
|
551
|
+
# tool (e.g. `bash:find`) is denied not because a rule forbids
|
|
552
|
+
# it but because no rule mentions it and the bundle's
|
|
553
|
+
# default_on_missing is deny. The historical message ("fail-
|
|
554
|
+
# closed default") gave no path out and looked like a bug.
|
|
555
|
+
# Name the unmatched action and the exact remediation so the
|
|
556
|
+
# deny is self-explaining. The leading phrase keeps the legacy
|
|
557
|
+
# "fail-closed default" substring for any downstream regex
|
|
558
|
+
# consumer (reason_code=NO_RULE_MATCH is the supported signal;
|
|
559
|
+
# this is belt-and-suspenders). NOTE: this is the NON-EMPTY
|
|
560
|
+
# bundle path -- a genuinely empty bundle short-circuits to the
|
|
561
|
+
# synthetic OBSERVE catch-all above and never reaches here, so
|
|
562
|
+
# observe and self-explaining-deny compose cleanly.
|
|
563
|
+
reason = (
|
|
564
|
+
f"No matching policy rule for '{action}' (fail-closed default; "
|
|
565
|
+
"default_on_missing=deny). This tool is neither allowed nor "
|
|
566
|
+
"denied by any rule in your hosted policy, so it is blocked. "
|
|
567
|
+
"Add a rule that allows it (e.g. allow: '*' as a catch-all, or "
|
|
568
|
+
f"allow: '{action}'), or set the project/org default to allow, "
|
|
569
|
+
"in the Control Zero dashboard."
|
|
570
|
+
)
|
|
479
571
|
elif self._default_action == "allow":
|
|
480
|
-
reason = "No matching policy rule (default_action=allow)"
|
|
572
|
+
reason = f"No matching policy rule for '{action}' (default_action=allow)"
|
|
481
573
|
else: # "warn"
|
|
482
|
-
reason = "No matching policy rule (default_action=warn)"
|
|
574
|
+
reason = f"No matching policy rule for '{action}' (default_action=warn)"
|
|
483
575
|
|
|
484
576
|
# T79: distinguish the T83-class signature ("a rule's actions
|
|
485
577
|
# matched but its resources gate excluded the call") from the
|
|
@@ -214,6 +214,18 @@ def test(tool: str, method: str, policy: str, args: str, hitl: Optional[str], hi
|
|
|
214
214
|
click.echo(f"args: {args_dict}")
|
|
215
215
|
click.echo("")
|
|
216
216
|
click.secho(f"DECISION: {decision.effect.upper()}", fg=color, bold=True)
|
|
217
|
+
# #1247 item 3: an observe-mode allow is NOT a normal allow -- the
|
|
218
|
+
# engine is monitoring, not enforcing, because the project has no
|
|
219
|
+
# active policies. Print a distinct, loud line so the operator can
|
|
220
|
+
# never mistake an empty-bundle observe for "my rules allowed this".
|
|
221
|
+
if getattr(decision, "observe", False):
|
|
222
|
+
click.secho(
|
|
223
|
+
"OBSERVE MODE: monitoring, not enforcing -- this project has "
|
|
224
|
+
"no active policies, so every tool call is allowed and logged. "
|
|
225
|
+
"Attach a policy to start enforcing.",
|
|
226
|
+
fg="yellow",
|
|
227
|
+
bold=True,
|
|
228
|
+
)
|
|
217
229
|
if decision.policy_id:
|
|
218
230
|
click.echo(f"matched: {decision.policy_id}")
|
|
219
231
|
if decision.reason:
|
|
@@ -33,9 +33,11 @@ from typing import Union
|
|
|
33
33
|
from controlzero._internal.action_validator import validate_actions
|
|
34
34
|
from controlzero._internal.enforcer import (
|
|
35
35
|
DEFAULT_BUNDLE_ACTION,
|
|
36
|
+
DEFAULT_BUNDLE_ON_EMPTY,
|
|
36
37
|
DEFAULT_BUNDLE_ON_MISSING,
|
|
37
38
|
DEFAULT_BUNDLE_ON_TAMPER,
|
|
38
39
|
VALID_DEFAULT_ACTIONS,
|
|
40
|
+
VALID_DEFAULT_ON_EMPTY,
|
|
39
41
|
VALID_DEFAULT_ON_MISSING,
|
|
40
42
|
VALID_DEFAULT_ON_TAMPER,
|
|
41
43
|
)
|
|
@@ -134,6 +136,9 @@ class PolicySettings:
|
|
|
134
136
|
tamper_behavior: str = "warn"
|
|
135
137
|
default_action: str = DEFAULT_BUNDLE_ACTION
|
|
136
138
|
default_on_missing: str = DEFAULT_BUNDLE_ON_MISSING
|
|
139
|
+
# default_on_empty (#1247 item 3): empty-bundle posture. Canonical
|
|
140
|
+
# "observe" so a rule-less project monitors+audits without bricking.
|
|
141
|
+
default_on_empty: str = DEFAULT_BUNDLE_ON_EMPTY
|
|
137
142
|
default_on_tamper: str = DEFAULT_BUNDLE_ON_TAMPER
|
|
138
143
|
|
|
139
144
|
|
|
@@ -294,6 +299,16 @@ def _validate_and_translate(data: dict, source_label: str) -> ParsedPolicy:
|
|
|
294
299
|
else:
|
|
295
300
|
settings.default_on_missing = dom
|
|
296
301
|
|
|
302
|
+
doe = settings_raw.get("default_on_empty")
|
|
303
|
+
if doe is not None:
|
|
304
|
+
if doe not in VALID_DEFAULT_ON_EMPTY:
|
|
305
|
+
errors.append(
|
|
306
|
+
"settings.default_on_empty must be one of "
|
|
307
|
+
f"{sorted(VALID_DEFAULT_ON_EMPTY)}, got {doe!r}"
|
|
308
|
+
)
|
|
309
|
+
else:
|
|
310
|
+
settings.default_on_empty = doe
|
|
311
|
+
|
|
297
312
|
dot = settings_raw.get("default_on_tamper")
|
|
298
313
|
if dot is not None:
|
|
299
314
|
if dot not in VALID_DEFAULT_ON_TAMPER:
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "controlzero"
|
|
7
|
-
version = "1.9.
|
|
7
|
+
version = "1.9.3"
|
|
8
8
|
description = "AI agent governance: policies, audit, and observability for tool calls. Works locally with no signup."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "Apache-2.0"}
|
|
@@ -124,16 +124,24 @@ def test_translate_to_local_policy(keys, valid_payload):
|
|
|
124
124
|
assert "*" in actions
|
|
125
125
|
|
|
126
126
|
|
|
127
|
-
def
|
|
128
|
-
"""
|
|
127
|
+
def test_empty_policies_becomes_observe_allow(keys):
|
|
128
|
+
"""#1247 item 3 / #1252: an empty policy set now translates to a
|
|
129
|
+
single OBSERVE catch-all -- allow + audit + a loud monitoring
|
|
130
|
+
signal -- NOT the old deny-all (and never allow-all silently). The
|
|
131
|
+
canonical empty-bundle posture (default_on_empty) is "observe", so a
|
|
132
|
+
fresh hosted project monitors instead of bricking on day one. A
|
|
133
|
+
resolution ERROR still fails closed via the separate BUNDLE_MISSING
|
|
134
|
+
path (see test_synthetic_policy_id_t79 / make_bundle_missing_policy).
|
|
135
|
+
"""
|
|
129
136
|
enc_key, priv, pub = keys
|
|
130
137
|
payload = json.dumps({"policies": []}).encode("utf-8")
|
|
131
138
|
bundle = _build_bundle(payload, enc_key, priv, policy_count=0)
|
|
132
139
|
parsed = parse_bundle(bundle, enc_key, pub)
|
|
133
140
|
local = translate_to_local_policy(parsed.payload)
|
|
134
141
|
assert len(local["rules"]) == 1
|
|
135
|
-
assert local["rules"][0]["effect"] == "
|
|
142
|
+
assert local["rules"][0]["effect"] == "allow"
|
|
136
143
|
assert local["rules"][0]["action"] == "*"
|
|
144
|
+
assert local["rules"][0]["reason_code"] == "OBSERVE_MODE_NO_POLICY"
|
|
137
145
|
|
|
138
146
|
|
|
139
147
|
# --- Tamper detection (security-critical) ---------------------------------
|