controlzero 1.9.6__tar.gz → 1.9.9__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {controlzero-1.9.6 → controlzero-1.9.9}/CHANGELOG.md +83 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/PKG-INFO +9 -7
- {controlzero-1.9.6 → controlzero-1.9.9}/README.md +5 -3
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/__init__.py +1 -1
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/_internal/bundle.py +48 -5
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/_internal/hook_extractors.py +24 -4
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/_internal/tool_extractors.json +5 -4
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/audit_remote.py +130 -4
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/main.py +52 -16
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/templates/antigravity.yaml +35 -13
- {controlzero-1.9.6 → controlzero-1.9.9}/pyproject.toml +9 -4
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/spool/test_spool_sink_wiring.py +127 -3
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_antigravity_hook_check.py +8 -5
- controlzero-1.9.9/tests/test_antigravity_tool_vocab_1303.py +323 -0
- controlzero-1.9.9/tests/test_config_format_parity_1303.py +145 -0
- controlzero-1.9.9/tests/test_failopen_1303.py +96 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hosted_policy_e2e.py +7 -5
- {controlzero-1.9.6 → controlzero-1.9.9}/.gitignore +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/Dockerfile.test +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/LICENSE +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/_internal/__init__.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/_internal/action_aliases.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/_internal/action_validator.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/_internal/credential_hook.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/_internal/credential_scanner.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/_internal/credentials_data/__init__.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/_internal/credentials_data/built_in.yaml +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/_internal/dlp_scanner.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/_internal/enforcer.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/_internal/types.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/audit_local.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/canonical.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/__init__.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/__main__.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/_secrets.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/console.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/debug_bundle.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/doctor.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/hosts/__init__.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/hosts/antigravity.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/hosts/base.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/hosts/claude_code.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/hosts/codex_cli.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/hosts/gemini_cli.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/hosts/kiro.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/hosts/unknown.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/kiro_adapter.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/migrate.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/spool_cmd.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/telemetry_consent.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/templates/antigravity/hooks.json +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/templates/autogen.yaml +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/templates/claude-code.yaml +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/templates/codex-cli.yaml +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/templates/cost-cap.yaml +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/templates/crewai.yaml +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/templates/cursor.yaml +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/templates/gemini-cli.yaml +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/templates/generic.yaml +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/templates/kiro/ide-file-save.kiro.hook +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/templates/kiro/ide-pre-tool-use.kiro.hook +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/templates/kiro/ide-prompt-submit.kiro.hook +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/templates/kiro/kiro.yaml +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/templates/langchain.yaml +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/templates/mcp.yaml +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/cli/templates/rag.yaml +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/client.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/device.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/enrollment.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/error_codes.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/error_codes.yaml +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/errors.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/hitl/__init__.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/hitl/grant_protocol.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/hitl/mock.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/hitl/pending_approval.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/hitl/secret_leak_guard.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/hitl/status.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/hooks/__init__.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/hooks/tool_output_handler.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/hosted_policy.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/__init__.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/anthropic.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/autogen.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/braintrust.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/crewai/__init__.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/crewai/agent.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/crewai/crew.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/crewai/task.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/crewai/tool.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/google.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/google_adk/__init__.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/google_adk/agent.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/google_adk/tool.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/langchain/__init__.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/langchain/agent.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/langchain/callbacks.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/langchain/chain.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/langchain/graph.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/langchain/modern.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/langchain/tool.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/langfuse.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/litellm.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/openai.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/pydantic_ai.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/integrations/vercel_ai.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/layout_migration.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/policy_loader.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/spool/__init__.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/spool/_compress.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/spool/_constants.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/spool/_crc32c.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/spool/_crypto.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/spool/_frame.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/spool/_keyring.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/spool/_metrics.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/spool/_spool.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/spool/_state.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/spool/_uploader.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/spool/cz-audit-v1.dict +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/tamper.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/controlzero/tracecontext.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/examples/hello_world.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/_fixtures/jcs_args_hash_vectors.json +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/conftest.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/integrations/__init__.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/integrations/test_google.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/parity/action_aliases.json +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/spool/__init__.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/spool/conftest.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/spool/test_spool_cli.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/spool/test_spool_concurrency.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/spool/test_spool_conformance.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/spool/test_spool_core.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/spool/test_spool_crash.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/spool/test_spool_diskfull.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/spool/test_spool_durable_default_tamper.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/spool/test_spool_keychain_dek.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/spool/test_spool_transcript_localack.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/spool/test_spool_uploader.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_action_aliases.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_action_canonicalization.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_action_validator_t86.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_agent_name_env.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_antigravity_adapter.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_antigravity_ga_blockers_1248.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_antigravity_install.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_api_key_mask.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_audit_remote.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_audit_remote_sdk_version.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_audit_sink_isolation.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_bundle_parser.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_bundle_translate.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_canonical_phase1a.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_cli_carve_out.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_cli_debug_bundle.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_cli_extractor_integration.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_cli_hook.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_cli_hosted_refresh.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_cli_init.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_cli_init_templates.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_cli_tail.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_cli_test.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_cli_validate.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_coding_agent_hooks.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_conditions.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_conformance.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_console.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_credential_hook.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_default_action.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_device.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_dlp_scanner.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_doctor.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_engine_version_consistency.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_enrollment.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_env_dump_438.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_epic_1247_bryan_acceptance.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_error_codes.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_errors_e_codes.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_fail_closed_eval.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_glob_matching.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hitl_5d_email_install.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hitl_6a_cli_flag.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hitl_6a_exceptions.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hitl_6a_get_secret_hitl.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hitl_6a_mock_backend.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hitl_6a_pending_approval.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hitl_6a_request_approval.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hitl_6a_secret_leak_guard.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hitl_6a_wait.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hitl_conformance.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hitl_phase2b_protocol.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hitl_reason_codes.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hitl_validator_keys.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hook_extractors.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hosted_local_audit_1247.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hosts_adapter.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hybrid_mode_strict.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_hybrid_mode_warn.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_install_hook_command.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_install_hooks.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_kiro_adapter.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_kiro_cli_e2e.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_kiro_hook_templates.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_kiro_install.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_layout_migration_t101.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_layout_parity_t102.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_local_mode_dict.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_local_mode_file_json.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_local_mode_file_yaml.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_log_fallback_stderr.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_log_options_ignored_hosted.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_log_rotation.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_migrate.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_min_sdk_version_gate.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_multi_client_per_project_175.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_no_policy_no_key.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_observe_mode_1247.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_package_rename_shim.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_policy_engine_version_phase1b.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_policy_freshness.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_policy_settings.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_policy_source_audit.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_quarantine.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_reason_code.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_refresh.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_secrets.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_sql_semantic_class.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_synthetic_policy_id_t79.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_t103_precedence.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_t104_cache_gc.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_t108_local_override_audit.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_t96_single_audit_log.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_t99_install_prefetch_bundle.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_tamper.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_tamper_behavior.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_tamper_hook.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_telemetry_consent.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_tracecontext.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tests/test_unsafe_int_boundary.py +0 -0
- {controlzero-1.9.6 → controlzero-1.9.9}/tools/cz-kiro-adapter +0 -0
|
@@ -1,5 +1,88 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.9.9 -- 2026-06-17 (Antigravity tool vocab, JSON+YAML config parity, spool reliability)
|
|
4
|
+
|
|
5
|
+
Follow-ups to the gh#1303 fail-open work plus two correctness fixes.
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **Antigravity enforcement (gh#1303 part 2).** Real Antigravity emits
|
|
10
|
+
`run_command` (args under `CommandLine`) / `ListDir` / `view_file` /
|
|
11
|
+
`write_to_file` / `replace_file_content`, but the inbound alias table and the
|
|
12
|
+
dangerous-shell extractor assumed `run_command`/`read_file` with
|
|
13
|
+
`args.command`. Two compounding fail-opens against allow-by-default: a
|
|
14
|
+
canonical `deny: Bash` never matched Antigravity's `run_command`, and
|
|
15
|
+
`rm -rf` inside `CommandLine` was invisible to the argument-level scanner.
|
|
16
|
+
Added inbound aliases and made `Bash` read either `command` or `CommandLine`
|
|
17
|
+
(first non-empty wins, fail-closed fallback). Inbound-only -- the policy
|
|
18
|
+
vocabulary the four working hosts match is unchanged.
|
|
19
|
+
- **CLI hook path now auto-discovers JSON/YAML policy files (gh#67).** All
|
|
20
|
+
three SDKs already parse `.yaml`/`.yml`/`.json` and the in-process `Client`
|
|
21
|
+
auto-discovers all three in the working directory, but the CLI `hook-check`
|
|
22
|
+
resolver searched only `controlzero.yaml`. A project authored as
|
|
23
|
+
`controlzero.json` (no `.yaml`) was enforced by the `Client` yet INVISIBLE to
|
|
24
|
+
the enforcement hook -- it fell through to the global policy / BUNDLE_MISSING.
|
|
25
|
+
`hook-check` now searches `controlzero.{yaml,yml,json}` in the working
|
|
26
|
+
directory and `policy.{yaml,yml,json}` globally (first existing,
|
|
27
|
+
`.yaml` > `.yml` > `.json`), matching the `Client`.
|
|
28
|
+
- **Offline audit spool reliability on Python 3.13 (gh#1315).** The encrypted
|
|
29
|
+
spool needs `zstandard` and `cryptography`. `zstandard` 0.22.0 has no cp313
|
|
30
|
+
wheel, so on Python 3.13 the import could fail, the error was swallowed in
|
|
31
|
+
`BearerAuditSink._init_spool`, and the spool silently never formed -- hosted
|
|
32
|
+
audit fell back to a non-durable in-memory buffer. The `zstandard` floor is
|
|
33
|
+
raised to `>=0.23.0` (first cp313 wheels), the import failure is now logged
|
|
34
|
+
loudly and actionably (naming the missing dependency), and a
|
|
35
|
+
`_spool_unavailable_reason` is recorded for diagnostics.
|
|
36
|
+
|
|
37
|
+
## 1.9.8 -- 2026-06-17 (P0 enforcement fail-open: degraded/empty bundle, gh#1303)
|
|
38
|
+
|
|
39
|
+
Closes a P0 SECURITY fail-open found via a customer report: a hosted-policy
|
|
40
|
+
customer's destructive tool calls (e.g. `rm -rf`) were intermittently ALLOWED
|
|
41
|
+
across agent surfaces.
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- **Degraded/partial/stale bundle no longer fails OPEN (gh#1303).** The shared
|
|
46
|
+
decision core (`translate_to_local_policy`) synthesized an allow-all OBSERVE
|
|
47
|
+
rule whenever the translated rule set was empty -- but that fired not only for
|
|
48
|
+
a genuinely-empty project (intended observe, gh#1247) but also for a degraded
|
|
49
|
+
bundle (policies attached yet zero translatable rules) or a missing/malformed
|
|
50
|
+
`policies` key (truncated/stale). A customer who HAS a policy could thus get
|
|
51
|
+
allow-all. The translator now distinguishes a genuinely-empty project (the
|
|
52
|
+
backend ships an explicit empty list) -- which still OBSERVES (gh#1247
|
|
53
|
+
preserved) -- from any other zero-rule outcome, which now FAILS CLOSED (deny)
|
|
54
|
+
via `default_on_missing`. This lives in the shared core, so all five host
|
|
55
|
+
surfaces (Claude Code, Gemini CLI, Codex CLI, Antigravity, Kiro) inherit it.
|
|
56
|
+
- **Unrecognized rule effect now fails closed.** A validly-signed rule carrying
|
|
57
|
+
an unknown/future/typo effect was coerced to `allow` (allow-* for its
|
|
58
|
+
pattern); it now defaults to `deny`.
|
|
59
|
+
|
|
60
|
+
Residual stale-empty-cache replay window (backend bundle provenance + cache
|
|
61
|
+
freshness) and the Antigravity tool-vocabulary gap are tracked in gh#1303 and
|
|
62
|
+
land in follow-up releases.
|
|
63
|
+
|
|
64
|
+
## 1.9.7 -- 2026-06-16 (hosted audit delivery for short-lived processes, gh#1292)
|
|
65
|
+
|
|
66
|
+
Fixes a P0 found via a customer report: hosted-mode audit rows were written to
|
|
67
|
+
the local `~/.controlzero/audit.log` but never reached the dashboard for
|
|
68
|
+
short-lived processes (PreToolUse hooks, CLI one-shots).
|
|
69
|
+
|
|
70
|
+
### Fixed
|
|
71
|
+
|
|
72
|
+
- **Remote audit never delivered for short-lived processes (gh#1292).** Since
|
|
73
|
+
1.9.2 the hosted (`BearerAuditSink`) and enrolled (`RemoteAuditSink`) sinks
|
|
74
|
+
default to the durable encrypted spool, where `log()` WALs the row and an
|
|
75
|
+
opportunistic `daemon=True` thread drains it. A short-lived process exits
|
|
76
|
+
before that thread can finish its HTTPS POST, and `close()` was a fsync-only
|
|
77
|
+
boundary, so the row stayed on local disk and never shipped. The hosted sink
|
|
78
|
+
now hands the drain to a **detached `controlzero spool flush` child** that
|
|
79
|
+
outlives the process (no latency on the agent hot path; the keystore and
|
|
80
|
+
network work happen in the child, never on the close path). The api key
|
|
81
|
+
travels via the child's environment, never on its argv. The enrolled sink
|
|
82
|
+
(opt-in, off the hosted hot path) does a bounded, fail-open in-process drain
|
|
83
|
+
on close instead. Both paths are fail-open: any error leaves the durable WAL
|
|
84
|
+
intact for a later drain. The durable WAL guarantee is unchanged.
|
|
85
|
+
|
|
3
86
|
## 1.9.6 -- 2026-06-16 (Antigravity GA-blocker hardening, gh#1248 / epic gh#925)
|
|
4
87
|
|
|
5
88
|
Closes 3 of the prod-readiness GA blockers for the Antigravity (`agy`)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: controlzero
|
|
3
|
-
Version: 1.9.
|
|
3
|
+
Version: 1.9.9
|
|
4
4
|
Summary: AI agent governance: policies, audit, and observability for tool calls. Works locally with no signup.
|
|
5
5
|
Project-URL: Homepage, https://controlzero.ai
|
|
6
6
|
Project-URL: Documentation, https://docs.controlzero.ai
|
|
@@ -30,7 +30,7 @@ Requires-Dist: pydantic>=2.0.0
|
|
|
30
30
|
Requires-Dist: pyyaml>=6.0
|
|
31
31
|
Requires-Dist: rfc8785<0.2,>=0.1.4
|
|
32
32
|
Requires-Dist: rich>=13.0.0
|
|
33
|
-
Requires-Dist: zstandard>=0.
|
|
33
|
+
Requires-Dist: zstandard>=0.23.0
|
|
34
34
|
Provides-Extra: anthropic
|
|
35
35
|
Requires-Dist: anthropic>=0.25.0; extra == 'anthropic'
|
|
36
36
|
Provides-Extra: dev
|
|
@@ -41,13 +41,13 @@ Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
|
|
|
41
41
|
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
42
42
|
Requires-Dist: pyyaml>=6.0; extra == 'dev'
|
|
43
43
|
Requires-Dist: respx>=0.20.0; extra == 'dev'
|
|
44
|
-
Requires-Dist: zstandard>=0.
|
|
44
|
+
Requires-Dist: zstandard>=0.23.0; extra == 'dev'
|
|
45
45
|
Provides-Extra: google
|
|
46
46
|
Requires-Dist: google-genai>=0.3.0; extra == 'google'
|
|
47
47
|
Provides-Extra: hosted
|
|
48
48
|
Requires-Dist: cryptography>=41.0.0; extra == 'hosted'
|
|
49
49
|
Requires-Dist: httpx>=0.25.0; extra == 'hosted'
|
|
50
|
-
Requires-Dist: zstandard>=0.
|
|
50
|
+
Requires-Dist: zstandard>=0.23.0; extra == 'hosted'
|
|
51
51
|
Provides-Extra: openai
|
|
52
52
|
Requires-Dist: openai>=1.0.0; extra == 'openai'
|
|
53
53
|
Description-Content-Type: text/markdown
|
|
@@ -93,7 +93,7 @@ Your AI agents call tools. Some of those tools should never be called by an
|
|
|
93
93
|
agent without a human in the loop. `controlzero` is the policy layer between
|
|
94
94
|
the model's output and the tool execution. Decisions are fail-closed by default.
|
|
95
95
|
|
|
96
|
-
You can use it offline with a local YAML file or Python dict. When you want to
|
|
96
|
+
You can use it offline with a local YAML or JSON file or Python dict. When you want to
|
|
97
97
|
share policies across a team or get a hosted audit dashboard, sign up at
|
|
98
98
|
[controlzero.ai](https://controlzero.ai) and set `CONTROLZERO_API_KEY`.
|
|
99
99
|
|
|
@@ -155,8 +155,10 @@ cz = Client(policy_file="./controlzero.yaml")
|
|
|
155
155
|
cz = Client()
|
|
156
156
|
```
|
|
157
157
|
|
|
158
|
-
If
|
|
159
|
-
automatically.
|
|
158
|
+
If a policy file exists in the current directory it is picked up
|
|
159
|
+
automatically -- `controlzero.yaml`, `controlzero.yml`, or `controlzero.json`
|
|
160
|
+
are auto-detected in that order (first existing wins). No environment
|
|
161
|
+
variable needed. The file may be YAML or JSON; both use the identical schema.
|
|
160
162
|
|
|
161
163
|
## Policy schema
|
|
162
164
|
|
|
@@ -39,7 +39,7 @@ Your AI agents call tools. Some of those tools should never be called by an
|
|
|
39
39
|
agent without a human in the loop. `controlzero` is the policy layer between
|
|
40
40
|
the model's output and the tool execution. Decisions are fail-closed by default.
|
|
41
41
|
|
|
42
|
-
You can use it offline with a local YAML file or Python dict. When you want to
|
|
42
|
+
You can use it offline with a local YAML or JSON file or Python dict. When you want to
|
|
43
43
|
share policies across a team or get a hosted audit dashboard, sign up at
|
|
44
44
|
[controlzero.ai](https://controlzero.ai) and set `CONTROLZERO_API_KEY`.
|
|
45
45
|
|
|
@@ -101,8 +101,10 @@ cz = Client(policy_file="./controlzero.yaml")
|
|
|
101
101
|
cz = Client()
|
|
102
102
|
```
|
|
103
103
|
|
|
104
|
-
If
|
|
105
|
-
automatically.
|
|
104
|
+
If a policy file exists in the current directory it is picked up
|
|
105
|
+
automatically -- `controlzero.yaml`, `controlzero.yml`, or `controlzero.json`
|
|
106
|
+
are auto-detected in that order (first existing wins). No environment
|
|
107
|
+
variable needed. The file may be YAML or JSON; both use the identical schema.
|
|
106
108
|
|
|
107
109
|
## Policy schema
|
|
108
110
|
|
|
@@ -527,7 +527,13 @@ def translate_to_local_policy(payload: dict) -> dict:
|
|
|
527
527
|
if default_on_tamper not in VALID_DEFAULT_ON_TAMPER:
|
|
528
528
|
default_on_tamper = DEFAULT_BUNDLE_ON_TAMPER
|
|
529
529
|
|
|
530
|
-
|
|
530
|
+
# #1303: keep the RAW policies value to distinguish a genuinely-empty
|
|
531
|
+
# project (the backend ships an explicit empty list `policies: []`) from a
|
|
532
|
+
# DEGRADED/partial bundle (policies attached but zero translatable rules, or
|
|
533
|
+
# a missing/malformed `policies` key). The former is observe (#1247); the
|
|
534
|
+
# latter must fail CLOSED, never observe-allow.
|
|
535
|
+
raw_policies = payload.get("policies")
|
|
536
|
+
policies = raw_policies or []
|
|
531
537
|
policies = sorted(
|
|
532
538
|
[p for p in policies if isinstance(p, dict)],
|
|
533
539
|
key=lambda p: int(p.get("priority", 100)),
|
|
@@ -550,6 +556,40 @@ def translate_to_local_policy(payload: dict) -> dict:
|
|
|
550
556
|
if translated is not None:
|
|
551
557
|
flat.append(translated)
|
|
552
558
|
|
|
559
|
+
if not flat and not (isinstance(raw_policies, list) and len(raw_policies) == 0):
|
|
560
|
+
# #1303 FAIL-OPEN FIX (the empty-vs-degraded boundary). We have zero
|
|
561
|
+
# translatable rules, but this is NOT a genuinely-empty project: the
|
|
562
|
+
# payload carried attached policies that produced no enforceable rules,
|
|
563
|
+
# OR a missing / non-list `policies` key (truncated / malformed / stale
|
|
564
|
+
# bundle). Treating that as observe would ALLOW EVERY tool call for a
|
|
565
|
+
# customer who HAS a policy -- the reproduced rm-rf fail-open. Fail
|
|
566
|
+
# CLOSED here via default_on_missing (canonical deny), with a distinct
|
|
567
|
+
# reason_code so it is not confused with a genuine empty project. The
|
|
568
|
+
# genuinely-empty observe path (#1247) below is reached ONLY when the
|
|
569
|
+
# backend shipped an explicit empty list (`policies: []`).
|
|
570
|
+
#
|
|
571
|
+
# NOTE: a stale CACHED bundle that legitimately held `policies: []` from
|
|
572
|
+
# when the project WAS empty is NOT caught here (it looks genuinely
|
|
573
|
+
# empty); that residual replay window is closed by bundle provenance +
|
|
574
|
+
# freshness (#1303 part 3 / backend active_policy_count).
|
|
575
|
+
# Reuse the registered BUNDLE_MISSING reason_code / synthetic id (both
|
|
576
|
+
# already in VALID_REASON_CODES / VALID_SYNTHETIC_POLICY_IDS) so no new
|
|
577
|
+
# error-catalog entry is introduced; the human-readable reason names the
|
|
578
|
+
# degraded/partial/stale cause. effect honors default_on_missing (deny).
|
|
579
|
+
flat.append({
|
|
580
|
+
"effect": default_on_missing,
|
|
581
|
+
"action": "*",
|
|
582
|
+
"id": "synthetic:BUNDLE_MISSING",
|
|
583
|
+
"reason": (
|
|
584
|
+
"Your project has attached policies but the resolved bundle "
|
|
585
|
+
"produced zero enforceable rules (a degraded, partial, or stale "
|
|
586
|
+
"bundle). Control Zero is failing CLOSED (deny) rather than "
|
|
587
|
+
"allowing every tool call. Regenerate the policy bundle in the "
|
|
588
|
+
"Control Zero dashboard; contact support if this persists."
|
|
589
|
+
),
|
|
590
|
+
"reason_code": "BUNDLE_MISSING",
|
|
591
|
+
})
|
|
592
|
+
|
|
553
593
|
if not flat:
|
|
554
594
|
# Empty policy set (RESOLVED SUCCESSFULLY, zero translatable
|
|
555
595
|
# rules): synthetic catch-all rule whose posture is driven by
|
|
@@ -711,9 +751,12 @@ def _translate_rule(rule: dict, policy_id: str) -> Optional[dict]:
|
|
|
711
751
|
singular (``tool`` / ``pattern`` / ``action`` / ``match.tool``) forms
|
|
712
752
|
are now supported; plural wins when both are present.
|
|
713
753
|
"""
|
|
714
|
-
# Accept several spellings of "effect". Policy engine has four
|
|
715
|
-
#
|
|
716
|
-
# fail
|
|
754
|
+
# Accept several spellings of "effect". Policy engine has four canonical
|
|
755
|
+
# effects; an UNRECOGNIZED effect on a validly-signed rule (typo, future
|
|
756
|
+
# effect, corruption) must fail CLOSED. Coercing it to "allow" (the old
|
|
757
|
+
# behavior) turned an unknown rule into allow-*-for-its-pattern -- a
|
|
758
|
+
# fail-open for a security gate (#1303). A deny here over-denies that one
|
|
759
|
+
# pattern at worst, which is the safe direction.
|
|
717
760
|
effect_raw = rule.get("effect")
|
|
718
761
|
if not effect_raw:
|
|
719
762
|
# Only fall back to rule["action"] for effect if it looks like
|
|
@@ -722,7 +765,7 @@ def _translate_rule(rule: dict, policy_id: str) -> Optional[dict]:
|
|
|
722
765
|
fallback = rule.get("action")
|
|
723
766
|
if fallback in ("allow", "deny", "warn", "audit"):
|
|
724
767
|
effect_raw = fallback
|
|
725
|
-
effect = effect_raw if effect_raw in ("allow", "deny", "warn", "audit") else "
|
|
768
|
+
effect = effect_raw if effect_raw in ("allow", "deny", "warn", "audit") else "deny"
|
|
726
769
|
|
|
727
770
|
# Tool pattern resolution order:
|
|
728
771
|
# 1. plural ``actions`` (list, as emitted by the backend)
|
|
@@ -566,7 +566,14 @@ def extract_method(
|
|
|
566
566
|
2. If the tool is unknown, method = ``"*"``.
|
|
567
567
|
3. Read ``args_path`` from the entry. If non-null, ``raw =
|
|
568
568
|
args[args_path]``; else ``raw = tool_name`` (used by
|
|
569
|
-
``file_read`` / ``file_write``).
|
|
569
|
+
``file_read`` / ``file_write``). ``args_path`` may be a single
|
|
570
|
+
key (string) or a list of candidate keys, in which case the
|
|
571
|
+
first key that resolves to a non-empty string is used. The list
|
|
572
|
+
form lets one canonical tool read the differently-named command
|
|
573
|
+
argument each host emits -- e.g. the ``Bash`` shell tool reads
|
|
574
|
+
``command`` (Claude Code / Gemini CLI) or ``CommandLine``
|
|
575
|
+
(Antigravity ``run_command``) so the dangerous-command scanner
|
|
576
|
+
fires regardless of which host sent the call.
|
|
570
577
|
4. Apply the ``extract`` function. Non-empty result = method.
|
|
571
578
|
5. Empty result -> ``fallback_method``.
|
|
572
579
|
|
|
@@ -593,11 +600,24 @@ def extract_method(
|
|
|
593
600
|
else:
|
|
594
601
|
if not isinstance(args, dict):
|
|
595
602
|
return canonical, fallback
|
|
596
|
-
|
|
603
|
+
# ``args_path`` is either a single key (string) or a list of
|
|
604
|
+
# candidate keys. The list form lets one canonical tool read
|
|
605
|
+
# the differently-named command argument each host emits --
|
|
606
|
+
# e.g. ``Bash`` reads ``command`` (Claude Code / Gemini CLI)
|
|
607
|
+
# or ``CommandLine`` (Antigravity ``run_command``). The first
|
|
608
|
+
# key that resolves to a non-empty string wins; if none do,
|
|
609
|
+
# fall back so the no-arg path stays fail-closed.
|
|
610
|
+
candidates = (
|
|
611
|
+
args_path if isinstance(args_path, list) else [args_path]
|
|
612
|
+
)
|
|
613
|
+
raw = None
|
|
614
|
+
for key in candidates:
|
|
615
|
+
value = args.get(key)
|
|
616
|
+
if isinstance(value, str) and value != "":
|
|
617
|
+
raw = value
|
|
618
|
+
break
|
|
597
619
|
if raw is None:
|
|
598
620
|
return canonical, fallback
|
|
599
|
-
if not isinstance(raw, str):
|
|
600
|
-
return canonical, fallback
|
|
601
621
|
|
|
602
622
|
func = _EXTRACTORS.get(extract_name)
|
|
603
623
|
if func is None:
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"aliases": ["sql", "Database", "PostgreSQL", "MySQL", "postgres", "sqlite"]
|
|
10
10
|
},
|
|
11
11
|
"Bash": {
|
|
12
|
-
"args_path": "command",
|
|
12
|
+
"args_path": ["command", "CommandLine"],
|
|
13
13
|
"extract": "most_dangerous_shell_command",
|
|
14
14
|
"fallback_method": "*",
|
|
15
15
|
"aliases": [
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"shell",
|
|
18
18
|
"ShellTool",
|
|
19
19
|
"run_shell_command",
|
|
20
|
+
"run_command",
|
|
20
21
|
"PowerShell",
|
|
21
22
|
"powershell",
|
|
22
23
|
"Shell"
|
|
@@ -44,19 +45,19 @@
|
|
|
44
45
|
"args_path": null,
|
|
45
46
|
"extract": "tool_name_as_method",
|
|
46
47
|
"fallback_method": "read",
|
|
47
|
-
"aliases": ["read_file", "Read", "ReadFile", "read_many_files"]
|
|
48
|
+
"aliases": ["read_file", "Read", "ReadFile", "read_many_files", "view_file"]
|
|
48
49
|
},
|
|
49
50
|
"file_write": {
|
|
50
51
|
"args_path": null,
|
|
51
52
|
"extract": "tool_name_as_method",
|
|
52
53
|
"fallback_method": "write",
|
|
53
|
-
"aliases": ["write_file", "Write", "WriteFile", "edit_file", "Edit", "replace", "apply_patch"]
|
|
54
|
+
"aliases": ["write_file", "Write", "WriteFile", "edit_file", "Edit", "replace", "apply_patch", "write_to_file", "replace_file_content"]
|
|
54
55
|
},
|
|
55
56
|
"file_search": {
|
|
56
57
|
"args_path": null,
|
|
57
58
|
"extract": "tool_name_as_method",
|
|
58
59
|
"fallback_method": "search",
|
|
59
|
-
"aliases": ["Grep", "grep_search", "Glob", "glob"]
|
|
60
|
+
"aliases": ["Grep", "grep_search", "Glob", "glob", "ListDir"]
|
|
60
61
|
},
|
|
61
62
|
"task": {
|
|
62
63
|
"args_path": null,
|
|
@@ -24,6 +24,7 @@ import json
|
|
|
24
24
|
import logging
|
|
25
25
|
import os
|
|
26
26
|
import platform
|
|
27
|
+
import sys
|
|
27
28
|
import threading
|
|
28
29
|
import uuid
|
|
29
30
|
from datetime import datetime, timezone
|
|
@@ -110,6 +111,15 @@ _SPOOL_DEFAULT_DIR = "~/.controlzero/spool"
|
|
|
110
111
|
# processes.
|
|
111
112
|
_SPOOL_HOOK_BUDGET_S = 2.0
|
|
112
113
|
_SPOOL_TIMER_BUDGET_S = 25.0
|
|
114
|
+
# close() handoff budgets (#1292). A short-lived process (PreToolUse hook /
|
|
115
|
+
# CLI one-shot) exits before the daemon drain ships its spooled rows, and
|
|
116
|
+
# close() was a fsync-only boundary -- so the rows never reached the backend.
|
|
117
|
+
# The hosted (Bearer) sink now hands the drain to a DETACHED uploader child
|
|
118
|
+
# that outlives this process (no latency on the agent hot path, keystore +
|
|
119
|
+
# network work happen off the close path). The enrolled sink -- opt-in, off
|
|
120
|
+
# the hosted hot path -- does a bounded, fail-open in-process drain instead.
|
|
121
|
+
_DETACHED_DRAIN_BUDGET_S = 30.0
|
|
122
|
+
_CLOSE_DRAIN_BUDGET_S = 2.0
|
|
113
123
|
|
|
114
124
|
|
|
115
125
|
def _build_wire_entry(
|
|
@@ -284,8 +294,18 @@ class _SpoolWiringMixin:
|
|
|
284
294
|
self._drain_inflight = False
|
|
285
295
|
self._drain_again = False
|
|
286
296
|
self._drain_auth_blocked = False
|
|
297
|
+
# #1292: whether this process WAL-logged at least one entry (gates the
|
|
298
|
+
# close-time handoff so a pure-read process never spawns a drainer) and
|
|
299
|
+
# whether the detached drainer was already spawned (coalesce to one).
|
|
300
|
+
self._spool_logged = False
|
|
301
|
+
self._detached_drain_spawned = False
|
|
287
302
|
self._spool = None
|
|
288
303
|
self._spool_mode = "off"
|
|
304
|
+
# #1315: when the durable spool cannot form (e.g. zstandard/cryptography
|
|
305
|
+
# missing or binary-incompatible on this interpreter), record WHY so
|
|
306
|
+
# diagnostics (`controlzero doctor`) can surface it instead of the user
|
|
307
|
+
# silently running on the ephemeral in-memory buffer. None == healthy.
|
|
308
|
+
self._spool_unavailable_reason = None
|
|
289
309
|
# Fast path: flag off/unset, NO hosted default, AND no spool
|
|
290
310
|
# directory on disk -- skip the spool import entirely so the
|
|
291
311
|
# legacy default path stays byte-identical. When default_mode is
|
|
@@ -327,8 +347,29 @@ class _SpoolWiringMixin:
|
|
|
327
347
|
if (self._spool is not None
|
|
328
348
|
and getattr(self._spool, "in_memory_mode", False)):
|
|
329
349
|
self._spool_init_degraded("memory_mode")
|
|
350
|
+
except ImportError as exc:
|
|
351
|
+
# #1315: the durable spool needs `zstandard` (compression) and
|
|
352
|
+
# `cryptography` (AES-GCM). A missing or binary-incompatible wheel
|
|
353
|
+
# (classically zstandard 0.22.0 on Python 3.13 -- no cp313 wheel)
|
|
354
|
+
# raises here BEFORE the spool dir is created. Name the cause loudly
|
|
355
|
+
# so the operator can fix the dependency instead of unknowingly
|
|
356
|
+
# running on the non-durable in-memory buffer. The dependency floor
|
|
357
|
+
# was raised (zstandard>=0.23.0) to prevent the common case.
|
|
358
|
+
self._spool_init_degraded("import_error")
|
|
359
|
+
self._spool_unavailable_reason = f"import_error: {exc}"
|
|
360
|
+
logger.warning(
|
|
361
|
+
"controlzero: audit spool dependencies unavailable (%s); "
|
|
362
|
+
"the encrypted offline spool is DISABLED and audit falls back "
|
|
363
|
+
"to in-memory buffering (durability reduced). Ensure "
|
|
364
|
+
"'zstandard>=0.23.0' and 'cryptography' are installed for this "
|
|
365
|
+
"Python (pip install -U controlzero).",
|
|
366
|
+
exc,
|
|
367
|
+
)
|
|
368
|
+
self._spool = None
|
|
369
|
+
self._spool_mode = "off"
|
|
330
370
|
except Exception as exc: # noqa: BLE001
|
|
331
371
|
self._spool_init_degraded("exception")
|
|
372
|
+
self._spool_unavailable_reason = f"exception: {exc}"
|
|
332
373
|
logger.warning(
|
|
333
374
|
"controlzero: audit spool unavailable (%s); "
|
|
334
375
|
"falling back to in-memory buffering",
|
|
@@ -365,6 +406,10 @@ class _SpoolWiringMixin:
|
|
|
365
406
|
except Exception as exc: # noqa: BLE001
|
|
366
407
|
logger.warning("controlzero: spool append failed (%s)", exc)
|
|
367
408
|
return False
|
|
409
|
+
# This process has durably WAL-logged at least one entry; close() may
|
|
410
|
+
# need to hand its drain to a detached uploader so a short-lived
|
|
411
|
+
# process does not strand it (#1292).
|
|
412
|
+
self._spool_logged = True
|
|
368
413
|
if self._spool_mode == _SPOOL_MODE_DURABLE:
|
|
369
414
|
self._drain_async(budget_s=_SPOOL_HOOK_BUDGET_S)
|
|
370
415
|
return True
|
|
@@ -377,6 +422,73 @@ class _SpoolWiringMixin:
|
|
|
377
422
|
def _spool_drain_orphans(self) -> bool:
|
|
378
423
|
return False
|
|
379
424
|
|
|
425
|
+
def _spawn_detached_drain(self) -> None:
|
|
426
|
+
"""Hand the spool drain to a DETACHED child that outlives this
|
|
427
|
+
process, then return immediately (#1292).
|
|
428
|
+
|
|
429
|
+
A short-lived hosted process (PreToolUse hook / CLI one-shot) exits
|
|
430
|
+
before the in-process daemon drain can finish its HTTPS POST, so
|
|
431
|
+
durably-spooled rows never reach the backend -- close() was a
|
|
432
|
+
fsync-only boundary that stranded them. Spawning ``controlzero spool
|
|
433
|
+
flush`` in a new session lets the upload complete after this process
|
|
434
|
+
is gone: no latency on the agent hot path, and the keystore + network
|
|
435
|
+
work happen in the child, never on this close path (the founder
|
|
436
|
+
non-blocking-hook and no-keystore-on-close constraints).
|
|
437
|
+
|
|
438
|
+
Only the Bearer (hosted api-key) sink can be drained this way --
|
|
439
|
+
``cz spool flush`` authenticates with the api key, which it reads from
|
|
440
|
+
the environment (never argv, so the key never lands in ``ps``). The
|
|
441
|
+
enrolled sink has no such entrypoint and uses an in-process close
|
|
442
|
+
drain instead.
|
|
443
|
+
|
|
444
|
+
Fail-open: any error leaves the durable WAL intact for a later drain
|
|
445
|
+
(next long-lived process, the timer, or an explicit ``cz spool
|
|
446
|
+
flush``)."""
|
|
447
|
+
if getattr(self, "_detached_drain_spawned", False):
|
|
448
|
+
return
|
|
449
|
+
if self._spool is None or not getattr(self, "_spool_logged", False):
|
|
450
|
+
return
|
|
451
|
+
api_key = getattr(self, "_api_key", None)
|
|
452
|
+
if not api_key:
|
|
453
|
+
return
|
|
454
|
+
try:
|
|
455
|
+
import subprocess
|
|
456
|
+
|
|
457
|
+
env = dict(os.environ)
|
|
458
|
+
env["CONTROLZERO_API_KEY"] = api_key
|
|
459
|
+
api_url = getattr(self, "_api_url", None)
|
|
460
|
+
if api_url:
|
|
461
|
+
env["CONTROLZERO_API_URL"] = api_url
|
|
462
|
+
popen_kwargs = dict(
|
|
463
|
+
stdin=subprocess.DEVNULL,
|
|
464
|
+
stdout=subprocess.DEVNULL,
|
|
465
|
+
stderr=subprocess.DEVNULL,
|
|
466
|
+
env=env,
|
|
467
|
+
close_fds=True,
|
|
468
|
+
)
|
|
469
|
+
if os.name == "nt": # detach on Windows
|
|
470
|
+
popen_kwargs["creationflags"] = (
|
|
471
|
+
getattr(subprocess, "DETACHED_PROCESS", 0)
|
|
472
|
+
| getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
473
|
+
)
|
|
474
|
+
else: # POSIX: escape the process group so it outlives the parent
|
|
475
|
+
popen_kwargs["start_new_session"] = True
|
|
476
|
+
subprocess.Popen( # noqa: S603 -- fixed argv, no shell, no user input
|
|
477
|
+
[
|
|
478
|
+
sys.executable, "-m", "controlzero.cli.main",
|
|
479
|
+
"spool", "flush",
|
|
480
|
+
"--timeout", str(_DETACHED_DRAIN_BUDGET_S),
|
|
481
|
+
],
|
|
482
|
+
**popen_kwargs,
|
|
483
|
+
)
|
|
484
|
+
self._detached_drain_spawned = True
|
|
485
|
+
except Exception as exc: # noqa: BLE001 -- never block/crash the host
|
|
486
|
+
logger.warning(
|
|
487
|
+
"controlzero: detached spool drain spawn failed (%s); audit "
|
|
488
|
+
"rows remain durable in the spool for a later drain",
|
|
489
|
+
exc,
|
|
490
|
+
)
|
|
491
|
+
|
|
380
492
|
def _drain_async(self, budget_s: float = _SPOOL_HOOK_BUDGET_S) -> None:
|
|
381
493
|
"""One opportunistic non-blocking drain in a daemon thread.
|
|
382
494
|
Coalesced: at most one drain in flight per sink instance; an
|
|
@@ -506,8 +618,17 @@ class RemoteAuditSink(_SpoolWiringMixin):
|
|
|
506
618
|
self._closed = True
|
|
507
619
|
self._cancel_flush_timer()
|
|
508
620
|
if self._spool_wal:
|
|
509
|
-
#
|
|
510
|
-
#
|
|
621
|
+
# DURABLE mode (enrolled, opt-in, off the hosted hot path) ships
|
|
622
|
+
# synchronously with a bounded, fail-open budget before the final
|
|
623
|
+
# fsync so a short-lived process does not strand its rows (#1292).
|
|
624
|
+
# spool_only is replay-only and must NEVER live-send on close.
|
|
625
|
+
if (self._spool_mode == _SPOOL_MODE_DURABLE
|
|
626
|
+
and getattr(self, "_spool_logged", False)):
|
|
627
|
+
try:
|
|
628
|
+
self._drain_once(blocking=True,
|
|
629
|
+
budget_s=_CLOSE_DRAIN_BUDGET_S)
|
|
630
|
+
except Exception: # noqa: BLE001 -- fail-open; WAL preserved
|
|
631
|
+
pass
|
|
511
632
|
self._spool_close()
|
|
512
633
|
return
|
|
513
634
|
# Synchronous flush -- we are shutting down
|
|
@@ -748,8 +869,13 @@ class BearerAuditSink(_SpoolWiringMixin):
|
|
|
748
869
|
self._closed = True
|
|
749
870
|
self._cancel_flush_timer()
|
|
750
871
|
if self._spool_wal:
|
|
751
|
-
#
|
|
752
|
-
#
|
|
872
|
+
# DURABLE mode hands the drain to a DETACHED child that outlives
|
|
873
|
+
# this (possibly short-lived) process so its rows are delivered;
|
|
874
|
+
# close() makes NO in-process network call -- the child does the
|
|
875
|
+
# keystore + network work off this hot path (#1292). spool_only is
|
|
876
|
+
# replay-only and must NEVER live-send on close -- only fsync.
|
|
877
|
+
if self._spool_mode == _SPOOL_MODE_DURABLE:
|
|
878
|
+
self._spawn_detached_drain()
|
|
753
879
|
self._spool_close()
|
|
754
880
|
return
|
|
755
881
|
self._flush_sync()
|
|
@@ -50,11 +50,47 @@ DEFAULT_TEMPLATES = [
|
|
|
50
50
|
]
|
|
51
51
|
|
|
52
52
|
# Where the global policy lives when controlzero is used as a Claude Code hook.
|
|
53
|
-
# Per-project ./controlzero.yaml takes precedence (the SDK already
|
|
53
|
+
# Per-project ./controlzero.{yaml,yml,json} takes precedence (the SDK already
|
|
54
|
+
# does this).
|
|
54
55
|
GLOBAL_POLICY_DIR = Path.home() / ".controlzero"
|
|
55
56
|
GLOBAL_POLICY_PATH = GLOBAL_POLICY_DIR / "policy.yaml"
|
|
56
57
|
GLOBAL_POLICY_SIG_PATH = GLOBAL_POLICY_DIR / "policy.yaml.sig"
|
|
57
58
|
GLOBAL_TAMPER_KEY_PATH = GLOBAL_POLICY_DIR / "tamper.key"
|
|
59
|
+
|
|
60
|
+
# Policy-file extensions auto-discovered in the hook resolution order, in
|
|
61
|
+
# precedence order. The local policy loader accepts all three (YAML and JSON
|
|
62
|
+
# -- see ``policy_loader._load_from_file``) and the in-process ``Client``
|
|
63
|
+
# already auto-discovers all three in cwd; the CLI hook path must search the
|
|
64
|
+
# same set so a project authored as ``controlzero.json`` is enforced by the
|
|
65
|
+
# hook exactly like ``controlzero.yaml`` (else it falls through to the global
|
|
66
|
+
# policy / BUNDLE_MISSING -- a JSON-config user would otherwise be invisible
|
|
67
|
+
# to enforcement). Keep this list in sync with ``client._resolve_local_source``.
|
|
68
|
+
_POLICY_FILE_EXTS = (".yaml", ".yml", ".json")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _first_existing_policy(directory: Path, basename: str) -> Optional[Path]:
|
|
72
|
+
"""Return the first existing ``<directory>/<basename><ext>`` for ``ext`` in
|
|
73
|
+
:data:`_POLICY_FILE_EXTS` (``.yaml`` -> ``.yml`` -> ``.json``), else None."""
|
|
74
|
+
for ext in _POLICY_FILE_EXTS:
|
|
75
|
+
candidate = directory / f"{basename}{ext}"
|
|
76
|
+
if candidate.exists():
|
|
77
|
+
return candidate
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _first_existing_variant(base: Path) -> Optional[Path]:
|
|
82
|
+
"""Return the first existing ``base`` with each policy extension swapped in
|
|
83
|
+
(``.yaml`` -> ``.yml`` -> ``.json``), else None.
|
|
84
|
+
|
|
85
|
+
Anchored on ``base`` (not its directory) so the global lookup honors a
|
|
86
|
+
monkeypatched ``GLOBAL_POLICY_PATH`` -- the established test contract
|
|
87
|
+
(e.g. ``test_install_hooks`` sets ``GLOBAL_POLICY_PATH`` to a non-existent
|
|
88
|
+
file to assert the missing-policy path)."""
|
|
89
|
+
for ext in _POLICY_FILE_EXTS:
|
|
90
|
+
candidate = base.with_suffix(ext)
|
|
91
|
+
if candidate.exists():
|
|
92
|
+
return candidate
|
|
93
|
+
return None
|
|
58
94
|
GLOBAL_AUDIT_PATH = GLOBAL_POLICY_DIR / "audit.log"
|
|
59
95
|
# Where the hook-check CLI logs surface-level events that don't flow
|
|
60
96
|
# through the policy evaluator -- today just the unenrolled first-run
|
|
@@ -1349,20 +1385,17 @@ def sign_policy(policy: Optional[str], verify_only: bool):
|
|
|
1349
1385
|
|
|
1350
1386
|
|
|
1351
1387
|
def _has_resolvable_policy_file() -> bool:
|
|
1352
|
-
"""True when a ``./controlzero.yaml`` or
|
|
1353
|
-
exists and looks loadable.
|
|
1388
|
+
"""True when a ``./controlzero.{yaml,yml,json}`` or
|
|
1389
|
+
``~/.controlzero/policy.{yaml,yml,json}`` exists and looks loadable.
|
|
1354
1390
|
|
|
1355
1391
|
Scoped to the carve-out logic: "do we already have a policy in
|
|
1356
1392
|
the standard search paths?" If yes, the user is past first-run
|
|
1357
1393
|
and the carve-out banner is noise. Mirrors the happy-path search
|
|
1358
1394
|
order in :func:`_resolve_hook_policy` so the two stay in sync.
|
|
1359
1395
|
"""
|
|
1360
|
-
|
|
1361
|
-
if cwd_policy.exists():
|
|
1362
|
-
return True
|
|
1363
|
-
if GLOBAL_POLICY_PATH.exists():
|
|
1396
|
+
if _first_existing_policy(Path.cwd(), "controlzero") is not None:
|
|
1364
1397
|
return True
|
|
1365
|
-
return
|
|
1398
|
+
return _first_existing_variant(GLOBAL_POLICY_PATH) is not None
|
|
1366
1399
|
|
|
1367
1400
|
|
|
1368
1401
|
def _is_unenrolled_first_run() -> bool:
|
|
@@ -1783,19 +1816,22 @@ def _maybe_refresh_policy(
|
|
|
1783
1816
|
def _resolve_hook_policy(explicit: Optional[str]) -> Optional[Path]:
|
|
1784
1817
|
"""Find a policy file using the hook-time resolution order:
|
|
1785
1818
|
|
|
1786
|
-
1. Explicit --policy argument
|
|
1787
|
-
2. ./controlzero.yaml (per-project)
|
|
1788
|
-
3. ~/.controlzero/policy.yaml (global)
|
|
1819
|
+
1. Explicit --policy argument (any extension; the loader dispatches on it)
|
|
1820
|
+
2. ./controlzero.{yaml,yml,json} (per-project, first existing)
|
|
1821
|
+
3. ~/.controlzero/policy.{yaml,yml,json} (global, first existing)
|
|
1822
|
+
|
|
1823
|
+
The per-project and global searches probe all three extensions so a
|
|
1824
|
+
project authored as ``controlzero.json`` (or ``.yml``) is enforced by the
|
|
1825
|
+
hook identically to ``controlzero.yaml`` -- matching the in-process
|
|
1826
|
+
``Client`` (:func:`client._resolve_local_source`).
|
|
1789
1827
|
"""
|
|
1790
1828
|
if explicit:
|
|
1791
1829
|
p = Path(explicit)
|
|
1792
1830
|
return p if p.exists() else None
|
|
1793
|
-
cwd = Path.cwd()
|
|
1794
|
-
if cwd
|
|
1831
|
+
cwd = _first_existing_policy(Path.cwd(), "controlzero")
|
|
1832
|
+
if cwd is not None:
|
|
1795
1833
|
return cwd
|
|
1796
|
-
|
|
1797
|
-
return GLOBAL_POLICY_PATH
|
|
1798
|
-
return None
|
|
1834
|
+
return _first_existing_variant(GLOBAL_POLICY_PATH)
|
|
1799
1835
|
|
|
1800
1836
|
|
|
1801
1837
|
def _policy_has_deny_rules(policy_path: Path) -> bool:
|