controlzero 1.9.2__tar.gz → 1.9.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {controlzero-1.9.2 → controlzero-1.9.4}/CHANGELOG.md +97 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/PKG-INFO +1 -1
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/__init__.py +1 -1
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/_internal/bundle.py +78 -32
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/_internal/enforcer.py +95 -3
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/main.py +12 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/client.py +8 -2
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/error_codes.yaml +10 -5
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/errors.py +105 -2
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/hosted_policy.py +17 -6
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/policy_loader.py +15 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/pyproject.toml +1 -1
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_bundle_parser.py +11 -3
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_default_action.py +160 -12
- controlzero-1.9.4/tests/test_epic_1247_bryan_acceptance.py +529 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_errors_e_codes.py +93 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hitl_reason_codes.py +6 -5
- controlzero-1.9.4/tests/test_observe_mode_1247.py +362 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_reason_code.py +41 -17
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_synthetic_policy_id_t79.py +40 -14
- {controlzero-1.9.2 → controlzero-1.9.4}/.gitignore +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/Dockerfile.test +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/LICENSE +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/README.md +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/_internal/__init__.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/_internal/action_aliases.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/_internal/action_validator.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/_internal/credential_hook.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/_internal/credential_scanner.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/_internal/credentials_data/__init__.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/_internal/credentials_data/built_in.yaml +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/_internal/dlp_scanner.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/_internal/hook_extractors.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/_internal/tool_extractors.json +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/_internal/types.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/audit_local.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/audit_remote.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/canonical.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/__init__.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/_secrets.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/console.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/debug_bundle.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/doctor.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/hosts/__init__.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/hosts/antigravity.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/hosts/base.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/hosts/claude_code.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/hosts/codex_cli.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/hosts/gemini_cli.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/hosts/kiro.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/hosts/unknown.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/kiro_adapter.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/migrate.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/spool_cmd.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/telemetry_consent.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/templates/antigravity/hooks.json +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/templates/antigravity.yaml +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/templates/autogen.yaml +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/templates/claude-code.yaml +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/templates/codex-cli.yaml +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/templates/cost-cap.yaml +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/templates/crewai.yaml +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/templates/cursor.yaml +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/templates/gemini-cli.yaml +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/templates/generic.yaml +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/templates/kiro/ide-file-save.kiro.hook +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/templates/kiro/ide-pre-tool-use.kiro.hook +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/templates/kiro/ide-prompt-submit.kiro.hook +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/templates/kiro/kiro.yaml +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/templates/langchain.yaml +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/templates/mcp.yaml +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/cli/templates/rag.yaml +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/device.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/enrollment.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/error_codes.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/hitl/__init__.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/hitl/grant_protocol.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/hitl/mock.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/hitl/pending_approval.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/hitl/secret_leak_guard.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/hitl/status.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/hooks/__init__.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/hooks/tool_output_handler.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/__init__.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/anthropic.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/autogen.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/braintrust.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/crewai/__init__.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/crewai/agent.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/crewai/crew.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/crewai/task.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/crewai/tool.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/google.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/google_adk/__init__.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/google_adk/agent.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/google_adk/tool.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/langchain/__init__.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/langchain/agent.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/langchain/callbacks.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/langchain/chain.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/langchain/graph.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/langchain/modern.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/langchain/tool.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/langfuse.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/litellm.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/openai.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/pydantic_ai.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/integrations/vercel_ai.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/layout_migration.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/spool/__init__.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/spool/_compress.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/spool/_constants.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/spool/_crc32c.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/spool/_crypto.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/spool/_frame.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/spool/_keyring.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/spool/_metrics.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/spool/_spool.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/spool/_state.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/spool/_uploader.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/spool/cz-audit-v1.dict +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/tamper.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/controlzero/tracecontext.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/examples/hello_world.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/_fixtures/jcs_args_hash_vectors.json +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/conftest.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/integrations/__init__.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/integrations/test_google.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/parity/action_aliases.json +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/spool/__init__.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/spool/conftest.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/spool/test_spool_cli.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/spool/test_spool_concurrency.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/spool/test_spool_conformance.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/spool/test_spool_core.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/spool/test_spool_crash.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/spool/test_spool_diskfull.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/spool/test_spool_durable_default_tamper.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/spool/test_spool_keychain_dek.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/spool/test_spool_sink_wiring.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/spool/test_spool_transcript_localack.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/spool/test_spool_uploader.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_action_aliases.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_action_canonicalization.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_action_validator_t86.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_agent_name_env.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_antigravity_adapter.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_antigravity_hook_check.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_antigravity_install.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_api_key_mask.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_audit_remote.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_audit_remote_sdk_version.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_audit_sink_isolation.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_bundle_translate.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_canonical_phase1a.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_cli_carve_out.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_cli_debug_bundle.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_cli_extractor_integration.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_cli_hook.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_cli_hosted_refresh.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_cli_init.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_cli_init_templates.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_cli_tail.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_cli_test.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_cli_validate.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_coding_agent_hooks.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_conditions.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_conformance.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_console.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_credential_hook.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_device.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_dlp_scanner.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_doctor.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_engine_version_consistency.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_enrollment.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_env_dump_438.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_error_codes.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_fail_closed_eval.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_glob_matching.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hitl_5d_email_install.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hitl_6a_cli_flag.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hitl_6a_exceptions.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hitl_6a_get_secret_hitl.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hitl_6a_mock_backend.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hitl_6a_pending_approval.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hitl_6a_request_approval.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hitl_6a_secret_leak_guard.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hitl_6a_wait.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hitl_conformance.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hitl_phase2b_protocol.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hitl_validator_keys.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hook_extractors.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hosted_local_audit_1247.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hosted_policy_e2e.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hosts_adapter.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hybrid_mode_strict.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_hybrid_mode_warn.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_install_hook_command.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_install_hooks.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_kiro_adapter.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_kiro_hook_templates.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_kiro_install.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_layout_migration_t101.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_layout_parity_t102.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_local_mode_dict.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_local_mode_file_json.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_local_mode_file_yaml.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_log_fallback_stderr.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_log_options_ignored_hosted.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_log_rotation.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_migrate.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_min_sdk_version_gate.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_multi_client_per_project_175.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_no_policy_no_key.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_package_rename_shim.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_policy_engine_version_phase1b.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_policy_freshness.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_policy_settings.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_policy_source_audit.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_quarantine.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_refresh.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_secrets.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_sql_semantic_class.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_t103_precedence.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_t104_cache_gc.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_t108_local_override_audit.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_t96_single_audit_log.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_t99_install_prefetch_bundle.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_tamper.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_tamper_behavior.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_tamper_hook.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_telemetry_consent.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_tracecontext.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tests/test_unsafe_int_boundary.py +0 -0
- {controlzero-1.9.2 → controlzero-1.9.4}/tools/cz-kiro-adapter +0 -0
|
@@ -1,5 +1,102 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.9.4 -- 2026-06-16 (actionable E1101 / API-key-rejected message, #1254, epic gh#1247)
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **(#1254) `HostedAuthError` (E1101) is now actionable instead of an
|
|
8
|
+
opaque "API key rejected (401)".** A dead/revoked/expired/placeholder API key
|
|
9
|
+
in hosted mode used to surface a bare message that told the user neither WHY
|
|
10
|
+
nor what to do. The message now always states the key is invalid/revoked/
|
|
11
|
+
expired and how to fix it: "Generate a new API key in the dashboard (Settings
|
|
12
|
+
-> API Keys, https://app.controlzero.ai/settings/api-keys) and set
|
|
13
|
+
CONTROLZERO_API_KEY to it." When the backend sends a structured 401 body, the
|
|
14
|
+
exception preserves the coarse machine-readable `reason` (e.g.
|
|
15
|
+
`invalid_or_revoked`) and the `remediation` on the exception so programmatic
|
|
16
|
+
callers can branch while humans read the fix. The `E1101` code is unchanged.
|
|
17
|
+
All four 401 raise sites (bootstrap, bundle pull, approval request, get
|
|
18
|
+
secret) now parse the body via `HostedAuthError.from_response(...)`.
|
|
19
|
+
Enumeration-safe: the backend keeps the reason coarse (not_found / revoked /
|
|
20
|
+
expired / placeholder all collapse to `invalid_or_revoked`) so a 401 cannot be
|
|
21
|
+
used to probe which keys exist. Tests:
|
|
22
|
+
`tests/test_errors_e_codes.py::TestHostedAuthErrorActionable`.
|
|
23
|
+
|
|
24
|
+
## 1.9.3 -- 2026-06-16 (posture release: empty-bundle OBSERVE + self-explaining no-rule-match deny, epic gh#1247)
|
|
25
|
+
|
|
26
|
+
The consolidated **posture release** for epic gh#1247 (customer Bryan).
|
|
27
|
+
Three customer-visible behaviors land together so the engine's "what
|
|
28
|
+
happens when a tool isn't covered by a rule" story is coherent across
|
|
29
|
+
both SDKs and the backend:
|
|
30
|
+
|
|
31
|
+
1. **Empty bundle -> OBSERVE** (was the only content of the superseded
|
|
32
|
+
1.9.2-observe draft).
|
|
33
|
+
2. **Non-empty no-rule-match deny is now self-explaining** (folds in the
|
|
34
|
+
Python-only fix from gh#1257, which is superseded by this release).
|
|
35
|
+
3. The backend `default_on_empty` knob on the bundle handler.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- **Hosted no-rule-match deny is now self-explaining (folds gh#1257).** A
|
|
40
|
+
non-empty bundle with `default_action=deny` and zero rule matches (the
|
|
41
|
+
exact shape of Bryan's "Db read only" allow-list, which excludes
|
|
42
|
+
`bash`) used to deny with the bare, bug-looking message
|
|
43
|
+
`No matching policy rule (fail-closed default)` -- no path out. The
|
|
44
|
+
deny reason now NAMES the unmatched action (e.g. `bash:find`) and the
|
|
45
|
+
exact remediation (add a catch-all `allow: '*'`, allow the specific
|
|
46
|
+
action, or flip the project/org default to allow) and tells the user
|
|
47
|
+
to do it in the Control Zero dashboard. `reason_code` stays
|
|
48
|
+
`NO_RULE_MATCH`; the legacy `fail-closed default` substring is retained
|
|
49
|
+
for any downstream regex consumer. The empty-bundle OBSERVE catch-all
|
|
50
|
+
matches FIRST, so a genuinely-empty bundle still observes and never
|
|
51
|
+
reaches this deny -- the two behaviors compose cleanly. Regression
|
|
52
|
+
tests in `tests/test_default_action.py`
|
|
53
|
+
(`test_1247_no_rule_match_deny_*`).
|
|
54
|
+
|
|
55
|
+
### Changed
|
|
56
|
+
|
|
57
|
+
A genuinely-EMPTY hosted bundle -- one that resolved successfully but has
|
|
58
|
+
zero attached/active policies -- now defaults to **OBSERVE** (allow +
|
|
59
|
+
audit + a loud "monitoring, not enforcing" signal) instead of the old
|
|
60
|
+
day-one deny-brick. A fresh hosted project no longer blocks every tool
|
|
61
|
+
call before the operator has authored a single rule; instead Control
|
|
62
|
+
Zero allows the call through and audits it, loudly flagged, so the
|
|
63
|
+
operator can see the engine is wired up and watching, then attach a
|
|
64
|
+
policy to start enforcing.
|
|
65
|
+
|
|
66
|
+
This is a founder-approved posture refinement (validated by a 4-lens
|
|
67
|
+
second-opinion). It is deliberately **narrow and gated**:
|
|
68
|
+
|
|
69
|
+
- **Only the genuinely-empty, successfully-resolved case observes.** A
|
|
70
|
+
non-empty bundle whose rules evaluate but nothing matches still
|
|
71
|
+
**denies** (`NO_RULE_MATCH`, `default_action` canonical deny) --
|
|
72
|
+
authored allow-lists stay secure. A bundle RESOLUTION ERROR / failed
|
|
73
|
+
pull / RLS / auth / decrypt failure still **fails closed** (deny,
|
|
74
|
+
`BUNDLE_MISSING`, honoring `default_on_missing`). Observe mode can
|
|
75
|
+
never mask a resolution error as an allow.
|
|
76
|
+
- **The empty-vs-error boundary is structural, not a runtime flag.** The
|
|
77
|
+
empty path (`translate_to_local_policy`, reached only on successful
|
|
78
|
+
resolution) and the error path (`make_bundle_missing_policy`, reached
|
|
79
|
+
only on resolution failure) are separate functions with separate
|
|
80
|
+
reason codes, so they cannot be confused.
|
|
81
|
+
|
|
82
|
+
### Added
|
|
83
|
+
|
|
84
|
+
- **New `reason_code=OBSERVE_MODE_NO_POLICY`** and synthetic policy_id
|
|
85
|
+
`synthetic:OBSERVE_MODE_NO_POLICY` for the empty-bundle observe allow,
|
|
86
|
+
distinct from `NO_ACTIVE_POLICIES` (the deny/warn/allow empty postures)
|
|
87
|
+
and `BUNDLE_MISSING` (the fail-closed resolution-error path).
|
|
88
|
+
- **`PolicyDecision.observe: bool`** -- True only on an observe-mode
|
|
89
|
+
allow. The CLI `guard` output now prints a loud yellow "OBSERVE MODE:
|
|
90
|
+
monitoring, not enforcing" line so an observe allow can never be
|
|
91
|
+
mistaken for a normal rule-driven allow. Gated strictly on the
|
|
92
|
+
reason_code, so no user-authored rule can ever set it.
|
|
93
|
+
- **New `default_on_empty` knob** (`observe`|`deny`|`warn`|`allow`,
|
|
94
|
+
canonical `observe`), separate from `default_action`. An operator can
|
|
95
|
+
declare allow-list-vs-deny-list intent for the empty case instead of
|
|
96
|
+
it being inferred. Plumbed end-to-end: backend bundle payload ->
|
|
97
|
+
bundle translator -> `PolicySettings`. Local YAML policies may set
|
|
98
|
+
`settings.default_on_empty`. The dashboard observe-mode indicator +
|
|
99
|
+
CLI `status` line remain a separate frontend/CLI follow-up.
|
|
3
100
|
## 1.9.1 -- 2026-06-16 (hosted-mode local audit log P0, epic gh#1247)
|
|
4
101
|
|
|
5
102
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: controlzero
|
|
3
|
-
Version: 1.9.
|
|
3
|
+
Version: 1.9.4
|
|
4
4
|
Summary: AI agent governance: policies, audit, and observability for tool calls. Works locally with no signup.
|
|
5
5
|
Project-URL: Homepage, https://controlzero.ai
|
|
6
6
|
Project-URL: Documentation, https://docs.controlzero.ai
|
|
@@ -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:
|
|
@@ -676,9 +676,11 @@ class Client:
|
|
|
676
676
|
# Non-2xx: map status + body code to the right typed exception.
|
|
677
677
|
body_code = ""
|
|
678
678
|
body_msg = ""
|
|
679
|
+
err_body_dict: dict = {}
|
|
679
680
|
try:
|
|
680
681
|
err_body = resp.json()
|
|
681
682
|
if isinstance(err_body, dict):
|
|
683
|
+
err_body_dict = err_body
|
|
682
684
|
body_code = str(err_body.get("code", "") or "")
|
|
683
685
|
body_msg = str(err_body.get("message", "") or err_body.get("error", "") or "")
|
|
684
686
|
except ValueError:
|
|
@@ -717,7 +719,7 @@ class Client:
|
|
|
717
719
|
body_msg or "Requestor identity claim rejected by backend"
|
|
718
720
|
)
|
|
719
721
|
if status == 401:
|
|
720
|
-
raise HostedAuthError(
|
|
722
|
+
raise HostedAuthError.from_response(status, err_body_dict)
|
|
721
723
|
if status == 404:
|
|
722
724
|
raise HITLNotConfiguredError(
|
|
723
725
|
body_msg or "Approvals not configured for this org"
|
|
@@ -885,7 +887,11 @@ class Client:
|
|
|
885
887
|
if status == 404:
|
|
886
888
|
raise SecretNotFound(f"secret {name!r} not found")
|
|
887
889
|
if status == 401:
|
|
888
|
-
|
|
890
|
+
try:
|
|
891
|
+
secret_err_body = resp.json()
|
|
892
|
+
except ValueError:
|
|
893
|
+
secret_err_body = None
|
|
894
|
+
raise HostedAuthError.from_response(status, secret_err_body)
|
|
889
895
|
if not (200 <= status < 300):
|
|
890
896
|
raise HITLBackendUnreachableError(
|
|
891
897
|
f"GET /api/secrets/{name} returned HTTP {status}"
|
|
@@ -95,11 +95,16 @@ codes:
|
|
|
95
95
|
- code: E1101
|
|
96
96
|
title: API key rejected (401)
|
|
97
97
|
what: >-
|
|
98
|
-
The hosted backend rejected the API key. The key is
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
98
|
+
The hosted backend rejected the API key with HTTP 401. The key is
|
|
99
|
+
unknown, revoked, expired, or a never-activated placeholder. For
|
|
100
|
+
safety the backend does not say which (so a 401 cannot be used to
|
|
101
|
+
probe which keys exist), but in every case the same fix applies:
|
|
102
|
+
the key you are using is no longer valid.
|
|
103
|
+
fix: >-
|
|
104
|
+
Generate a fresh key in the dashboard under Settings -> API Keys
|
|
105
|
+
(https://app.controlzero.ai/settings/api-keys) and set
|
|
106
|
+
CONTROLZERO_API_KEY to it. If you just rotated a key, make sure the
|
|
107
|
+
environment your agent runs in picked up the new value.
|
|
103
108
|
doc: E1101-key-rejected
|
|
104
109
|
|
|
105
110
|
- code: E1102
|
|
@@ -28,6 +28,18 @@ from controlzero._internal.enforcer import PolicyDeniedError, PolicyDecision
|
|
|
28
28
|
from controlzero.error_codes import ErrorCode, get as _get_error_code
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
def _first_str(*values: object) -> Optional[str]:
|
|
32
|
+
"""Return the first value that is a non-empty string, else None.
|
|
33
|
+
|
|
34
|
+
Used to read a field that the backend may place at the top level or
|
|
35
|
+
nested under ``error`` without crashing on unexpected types.
|
|
36
|
+
"""
|
|
37
|
+
for v in values:
|
|
38
|
+
if isinstance(v, str) and v.strip():
|
|
39
|
+
return v
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
31
43
|
def _augment_with_code(message: str, code_key: Optional[str]) -> str:
|
|
32
44
|
"""Append the catalog's fix + docs URL to a raw error message.
|
|
33
45
|
|
|
@@ -137,6 +149,12 @@ class BundleSignatureError(_CZErrorMixin, Exception):
|
|
|
137
149
|
super().__init__(_augment_with_code(message, self.E_CODE))
|
|
138
150
|
|
|
139
151
|
|
|
152
|
+
_DEFAULT_AUTH_REMEDIATION = (
|
|
153
|
+
"Generate a new API key in the dashboard (Settings -> API Keys, "
|
|
154
|
+
"https://app.controlzero.ai/settings/api-keys) and set CONTROLZERO_API_KEY to it."
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
140
158
|
class HostedAuthError(_CZErrorMixin, RuntimeError):
|
|
141
159
|
"""Raised when the project API key is rejected by the backend
|
|
142
160
|
(401/403). Maps to E1101.
|
|
@@ -144,12 +162,97 @@ class HostedAuthError(_CZErrorMixin, RuntimeError):
|
|
|
144
162
|
This is a permanent failure: the caller supplied an invalid or
|
|
145
163
|
revoked API key. Retrying with the same key will not help. The
|
|
146
164
|
SDK surfaces this so the user can correct their configuration.
|
|
165
|
+
|
|
166
|
+
#1254: the bare "API key rejected (401)" gave the user no
|
|
167
|
+
idea WHY or what to do. The message now always tells the user the
|
|
168
|
+
key is invalid/revoked and how to fix it. When the backend supplies
|
|
169
|
+
a structured 401 body (``reason`` + ``remediation``), those are
|
|
170
|
+
preserved on the exception so programmatic callers can branch on
|
|
171
|
+
:attr:`reason` while humans read the actionable message.
|
|
172
|
+
|
|
173
|
+
The backend keeps ``reason`` coarse on purpose (not_found / revoked /
|
|
174
|
+
expired / placeholder all collapse to ``invalid_or_revoked``) so a
|
|
175
|
+
401 is never an enumeration oracle; the SDK does not try to refine it.
|
|
176
|
+
|
|
177
|
+
Attributes:
|
|
178
|
+
reason: coarse machine-readable reason from the backend, e.g.
|
|
179
|
+
``"invalid_or_revoked"``. ``None`` when the backend did not
|
|
180
|
+
send a structured body (older backend).
|
|
181
|
+
remediation: copy-pasteable next step shown to the user.
|
|
147
182
|
"""
|
|
148
183
|
|
|
149
184
|
E_CODE = "E1101"
|
|
150
185
|
|
|
151
|
-
def __init__(
|
|
152
|
-
|
|
186
|
+
def __init__(
|
|
187
|
+
self,
|
|
188
|
+
message: Optional[str] = None,
|
|
189
|
+
*,
|
|
190
|
+
reason: Optional[str] = None,
|
|
191
|
+
remediation: Optional[str] = None,
|
|
192
|
+
):
|
|
193
|
+
self.reason = reason
|
|
194
|
+
self.remediation = remediation or _DEFAULT_AUTH_REMEDIATION
|
|
195
|
+
if message is None:
|
|
196
|
+
message = (
|
|
197
|
+
"Your Control Zero API key was rejected by the backend "
|
|
198
|
+
"(invalid, revoked, or expired)."
|
|
199
|
+
)
|
|
200
|
+
full = f"{message} {self.remediation}"
|
|
201
|
+
super().__init__(_augment_with_code(full, self.E_CODE))
|
|
202
|
+
|
|
203
|
+
@classmethod
|
|
204
|
+
def from_response(
|
|
205
|
+
cls,
|
|
206
|
+
status_code: int,
|
|
207
|
+
body: object = None,
|
|
208
|
+
*,
|
|
209
|
+
context: str = "",
|
|
210
|
+
) -> "HostedAuthError":
|
|
211
|
+
"""Build a HostedAuthError from a backend 401/403 response.
|
|
212
|
+
|
|
213
|
+
``body`` is the parsed JSON (a dict) when available, else None.
|
|
214
|
+
The backend may put the structured fields either at the top
|
|
215
|
+
level (``body["reason"]``) or nested under ``body["error"]``;
|
|
216
|
+
we read both. ``context`` is an optional short phrase like
|
|
217
|
+
"during bundle pull" appended to the headline so logs say which
|
|
218
|
+
request failed. Never raises on a malformed body -- a bad body
|
|
219
|
+
just yields the actionable default message.
|
|
220
|
+
"""
|
|
221
|
+
reason: Optional[str] = None
|
|
222
|
+
remediation: Optional[str] = None
|
|
223
|
+
backend_msg: Optional[str] = None
|
|
224
|
+
if isinstance(body, dict):
|
|
225
|
+
err = body.get("error")
|
|
226
|
+
err_dict = err if isinstance(err, dict) else {}
|
|
227
|
+
reason = _first_str(body.get("reason"), err_dict.get("reason"))
|
|
228
|
+
remediation = _first_str(
|
|
229
|
+
body.get("remediation"), err_dict.get("remediation")
|
|
230
|
+
)
|
|
231
|
+
# The backend message can be body["message"], the nested
|
|
232
|
+
# error.message, or -- for back-compat with non-structured
|
|
233
|
+
# backends -- a plain string in the top-level "error" key
|
|
234
|
+
# (e.g. {"error": "Unauthorized"}).
|
|
235
|
+
err_str = err if isinstance(err, str) else None
|
|
236
|
+
backend_msg = _first_str(
|
|
237
|
+
body.get("message"), err_dict.get("message"), err_str
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
headline = (
|
|
241
|
+
"Your Control Zero API key was rejected by the backend "
|
|
242
|
+
"(invalid, revoked, or expired)."
|
|
243
|
+
)
|
|
244
|
+
if context:
|
|
245
|
+
headline = (
|
|
246
|
+
f"Your Control Zero API key was rejected by the backend "
|
|
247
|
+
f"{context} (invalid, revoked, or expired)."
|
|
248
|
+
)
|
|
249
|
+
# Prefer the backend's own human message when it is more specific
|
|
250
|
+
# than our generic headline, but always keep the headline so the
|
|
251
|
+
# user sees the "what to do" framing even with an old backend.
|
|
252
|
+
message = headline
|
|
253
|
+
if backend_msg and backend_msg.lower() not in ("invalid api key", ""):
|
|
254
|
+
message = f"{headline} (backend: {backend_msg})"
|
|
255
|
+
return cls(message, reason=reason, remediation=remediation)
|
|
153
256
|
|
|
154
257
|
|
|
155
258
|
class HostedBootstrapError(_CZErrorMixin, RuntimeError):
|