controlzero 1.9.1__tar.gz → 1.9.2__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.1 → controlzero-1.9.2}/CHANGELOG.md +44 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/PKG-INFO +1 -1
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/__init__.py +1 -1
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/audit_remote.py +71 -11
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/spool_cmd.py +15 -2
- controlzero-1.9.2/controlzero/spool/_keyring.py +311 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/spool/_spool.py +38 -3
- controlzero-1.9.2/controlzero/spool/_state.py +356 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/pyproject.toml +1 -1
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/conftest.py +24 -0
- controlzero-1.9.2/tests/spool/test_spool_durable_default_tamper.py +354 -0
- controlzero-1.9.2/tests/spool/test_spool_keychain_dek.py +309 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/spool/test_spool_sink_wiring.py +55 -6
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hosted_policy_e2e.py +16 -3
- controlzero-1.9.1/controlzero/spool/_state.py +0 -154
- {controlzero-1.9.1 → controlzero-1.9.2}/.gitignore +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/Dockerfile.test +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/LICENSE +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/README.md +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/_internal/__init__.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/_internal/action_aliases.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/_internal/action_validator.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/_internal/bundle.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/_internal/credential_hook.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/_internal/credential_scanner.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/_internal/credentials_data/__init__.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/_internal/credentials_data/built_in.yaml +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/_internal/dlp_scanner.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/_internal/enforcer.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/_internal/hook_extractors.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/_internal/tool_extractors.json +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/_internal/types.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/audit_local.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/canonical.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/__init__.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/_secrets.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/console.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/debug_bundle.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/doctor.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/hosts/__init__.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/hosts/antigravity.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/hosts/base.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/hosts/claude_code.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/hosts/codex_cli.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/hosts/gemini_cli.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/hosts/kiro.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/hosts/unknown.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/kiro_adapter.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/main.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/migrate.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/telemetry_consent.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/templates/antigravity/hooks.json +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/templates/antigravity.yaml +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/templates/autogen.yaml +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/templates/claude-code.yaml +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/templates/codex-cli.yaml +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/templates/cost-cap.yaml +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/templates/crewai.yaml +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/templates/cursor.yaml +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/templates/gemini-cli.yaml +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/templates/generic.yaml +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/templates/kiro/ide-file-save.kiro.hook +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/templates/kiro/ide-pre-tool-use.kiro.hook +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/templates/kiro/ide-prompt-submit.kiro.hook +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/templates/kiro/kiro.yaml +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/templates/langchain.yaml +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/templates/mcp.yaml +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/cli/templates/rag.yaml +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/client.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/device.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/enrollment.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/error_codes.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/error_codes.yaml +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/errors.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/hitl/__init__.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/hitl/grant_protocol.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/hitl/mock.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/hitl/pending_approval.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/hitl/secret_leak_guard.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/hitl/status.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/hooks/__init__.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/hooks/tool_output_handler.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/hosted_policy.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/__init__.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/anthropic.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/autogen.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/braintrust.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/crewai/__init__.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/crewai/agent.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/crewai/crew.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/crewai/task.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/crewai/tool.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/google.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/google_adk/__init__.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/google_adk/agent.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/google_adk/tool.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/langchain/__init__.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/langchain/agent.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/langchain/callbacks.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/langchain/chain.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/langchain/graph.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/langchain/modern.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/langchain/tool.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/langfuse.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/litellm.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/openai.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/pydantic_ai.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/integrations/vercel_ai.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/layout_migration.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/policy_loader.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/spool/__init__.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/spool/_compress.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/spool/_constants.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/spool/_crc32c.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/spool/_crypto.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/spool/_frame.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/spool/_metrics.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/spool/_uploader.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/spool/cz-audit-v1.dict +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/tamper.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/controlzero/tracecontext.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/examples/hello_world.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/_fixtures/jcs_args_hash_vectors.json +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/integrations/__init__.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/integrations/test_google.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/parity/action_aliases.json +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/spool/__init__.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/spool/conftest.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/spool/test_spool_cli.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/spool/test_spool_concurrency.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/spool/test_spool_conformance.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/spool/test_spool_core.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/spool/test_spool_crash.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/spool/test_spool_diskfull.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/spool/test_spool_transcript_localack.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/spool/test_spool_uploader.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_action_aliases.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_action_canonicalization.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_action_validator_t86.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_agent_name_env.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_antigravity_adapter.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_antigravity_hook_check.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_antigravity_install.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_api_key_mask.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_audit_remote.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_audit_remote_sdk_version.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_audit_sink_isolation.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_bundle_parser.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_bundle_translate.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_canonical_phase1a.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_cli_carve_out.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_cli_debug_bundle.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_cli_extractor_integration.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_cli_hook.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_cli_hosted_refresh.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_cli_init.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_cli_init_templates.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_cli_tail.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_cli_test.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_cli_validate.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_coding_agent_hooks.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_conditions.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_conformance.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_console.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_credential_hook.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_default_action.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_device.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_dlp_scanner.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_doctor.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_engine_version_consistency.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_enrollment.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_env_dump_438.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_error_codes.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_errors_e_codes.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_fail_closed_eval.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_glob_matching.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hitl_5d_email_install.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hitl_6a_cli_flag.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hitl_6a_exceptions.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hitl_6a_get_secret_hitl.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hitl_6a_mock_backend.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hitl_6a_pending_approval.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hitl_6a_request_approval.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hitl_6a_secret_leak_guard.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hitl_6a_wait.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hitl_conformance.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hitl_phase2b_protocol.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hitl_reason_codes.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hitl_validator_keys.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hook_extractors.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hosted_local_audit_1247.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hosts_adapter.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hybrid_mode_strict.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_hybrid_mode_warn.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_install_hook_command.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_install_hooks.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_kiro_adapter.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_kiro_hook_templates.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_kiro_install.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_layout_migration_t101.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_layout_parity_t102.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_local_mode_dict.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_local_mode_file_json.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_local_mode_file_yaml.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_log_fallback_stderr.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_log_options_ignored_hosted.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_log_rotation.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_migrate.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_min_sdk_version_gate.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_multi_client_per_project_175.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_no_policy_no_key.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_package_rename_shim.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_policy_engine_version_phase1b.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_policy_freshness.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_policy_settings.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_policy_source_audit.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_quarantine.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_reason_code.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_refresh.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_secrets.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_sql_semantic_class.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_synthetic_policy_id_t79.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_t103_precedence.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_t104_cache_gc.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_t108_local_override_audit.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_t96_single_audit_log.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_t99_install_prefetch_bundle.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_tamper.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_tamper_behavior.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_tamper_hook.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_telemetry_consent.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_tracecontext.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tests/test_unsafe_int_boundary.py +0 -0
- {controlzero-1.9.1 → controlzero-1.9.2}/tools/cz-kiro-adapter +0 -0
|
@@ -29,6 +29,50 @@
|
|
|
29
29
|
preserved so the local log still records THAT a rule fired, and the remote
|
|
30
30
|
sink keeps full fidelity.
|
|
31
31
|
|
|
32
|
+
## 1.9.2 -- 2026-06-16 (durable-by-default + keychain-DEK audit spool, epic gh#1247)
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- **Hosted-mode audit is now durable by default.** When a `Client` is
|
|
37
|
+
constructed with an API key (hosted mode) and `CONTROLZERO_SPOOL` is
|
|
38
|
+
unset, the audit sink now defaults to the **durable encrypted spool**:
|
|
39
|
+
every decision is serialized, encrypted, and fsynced to an append-only
|
|
40
|
+
on-disk WAL **before** any network send, then drained opportunistically
|
|
41
|
+
in a background thread. Audit is no longer lost on a backend outage. An
|
|
42
|
+
explicit `CONTROLZERO_SPOOL` value (including `off`) still wins, and the
|
|
43
|
+
enrolled-machine sink keeps its prior off-by-default. Sink init is
|
|
44
|
+
fail-soft: any open/IO error degrades to the prior in-memory path,
|
|
45
|
+
emits `spool_init_degraded_total`, and never crashes or blocks the
|
|
46
|
+
PreToolUse hook.
|
|
47
|
+
|
|
48
|
+
### Security
|
|
49
|
+
|
|
50
|
+
- **Spool encryption key (DEK) defaults to the OS keystore.** The DEK now
|
|
51
|
+
lives in the macOS Keychain (a `security` generic-password item) or the
|
|
52
|
+
Linux Secret Service (`secret-tool`/libsecret) by default when one is
|
|
53
|
+
available; the on-disk `spool.key` then holds only a sentinel, so a
|
|
54
|
+
file-read of the spool directory can neither **decrypt** nor **forge**
|
|
55
|
+
spooled audit records (the AES-GCM tag and hash chain are keyed on the
|
|
56
|
+
DEK). Keystore access is strictly **non-interactive** and
|
|
57
|
+
hard-timeout-bounded -- a prompt risk, locked keystore, or missing CLI
|
|
58
|
+
degrades to the legacy 0600 `spool.key` (key hardening never costs audit
|
|
59
|
+
durability and never blocks the hook). A pre-existing on-disk DEK is
|
|
60
|
+
migrated into the keystore on first keystore-enabled open. Force the
|
|
61
|
+
legacy file DEK with `CONTROLZERO_SPOOL_KEYCHAIN=0` or
|
|
62
|
+
`CONTROLZERO_SPOOL_KEYCHAIN_DISABLE=1`; require the keystore with
|
|
63
|
+
`CONTROLZERO_SPOOL_KEYCHAIN=1`.
|
|
64
|
+
|
|
65
|
+
### Tests
|
|
66
|
+
|
|
67
|
+
- New `tests/spool/test_spool_keychain_dek.py` (keystore round-trip,
|
|
68
|
+
sentinel-on-disk, prompt-risk fallback, file opt-out, file->keystore
|
|
69
|
+
migration) and `tests/spool/test_spool_durable_default_tamper.py`
|
|
70
|
+
(hosted durable default, encrypted-WAL-before-send, **tampered-frame
|
|
71
|
+
rejection surfaced via `spool_tamper_records_total{gcm_auth}`**,
|
|
72
|
+
graceful init-failure degrade, WAL append latency budget). Spool
|
|
73
|
+
isolation is enforced suite-wide so tests never touch the real home dir
|
|
74
|
+
or OS keychain.
|
|
75
|
+
|
|
32
76
|
## 1.9.0 -- 2026-06-15 (Antigravity install CLI, epic gh#925)
|
|
33
77
|
|
|
34
78
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: controlzero
|
|
3
|
-
Version: 1.9.
|
|
3
|
+
Version: 1.9.2
|
|
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
|
|
@@ -261,26 +261,74 @@ class _SpoolWiringMixin:
|
|
|
261
261
|
_spool = None
|
|
262
262
|
_spool_mode = "off"
|
|
263
263
|
|
|
264
|
-
def _init_spool(self, stream_key: str) -> None:
|
|
264
|
+
def _init_spool(self, stream_key: str, default_mode: str = "off") -> None:
|
|
265
|
+
"""Open the offline audit spool for this sink.
|
|
266
|
+
|
|
267
|
+
``default_mode`` is the mode used when ``CONTROLZERO_SPOOL`` is
|
|
268
|
+
UNSET. The hosted (API-key) sink passes ``"durable"`` so audit is
|
|
269
|
+
never lost on a backend outage even when the operator has not set
|
|
270
|
+
the env knob -- the founder requirement that hosted-mode audit
|
|
271
|
+
WALs to encrypted disk before send by default. The enrolled sink
|
|
272
|
+
keeps ``"off"`` (no behavior change). An explicit ``CONTROLZERO_SPOOL``
|
|
273
|
+
value ALWAYS wins (operators can force ``off``/``spool_only``).
|
|
274
|
+
|
|
275
|
+
Non-blocking guarantee (founder constraint): this runs inside the
|
|
276
|
+
PreToolUse hook hot path. Every failure mode -- spool import
|
|
277
|
+
error, keystore prompt risk, IO error, ENOSPC -- DEGRADES to the
|
|
278
|
+
prior in-memory behavior and emits ``spool_init_degraded_total``;
|
|
279
|
+
it never crashes or blocks the agent. Spool open itself is
|
|
280
|
+
bounded (one recovery scan + the 200 ms seq.lock budget); it does
|
|
281
|
+
no network IO.
|
|
282
|
+
"""
|
|
265
283
|
self._drain_state_lock = threading.Lock()
|
|
266
284
|
self._drain_inflight = False
|
|
267
285
|
self._drain_again = False
|
|
268
286
|
self._drain_auth_blocked = False
|
|
269
287
|
self._spool = None
|
|
270
288
|
self._spool_mode = "off"
|
|
271
|
-
# Fast path: flag off AND no spool
|
|
272
|
-
# spool import entirely so the
|
|
273
|
-
|
|
289
|
+
# Fast path: flag off/unset, NO hosted default, AND no spool
|
|
290
|
+
# directory on disk -- skip the spool import entirely so the
|
|
291
|
+
# legacy default path stays byte-identical. When default_mode is
|
|
292
|
+
# durable (hosted), we must NOT take this shortcut: the whole
|
|
293
|
+
# point is to open a durable spool with no env set.
|
|
294
|
+
env_present = os.environ.get("CONTROLZERO_SPOOL")
|
|
295
|
+
mode_raw = (env_present or "").strip().lower()
|
|
274
296
|
spool_dir = os.path.expanduser(
|
|
275
297
|
os.environ.get("CONTROLZERO_SPOOL_DIR") or _SPOOL_DEFAULT_DIR)
|
|
276
|
-
|
|
298
|
+
# "Unset" means the env var is absent or an empty/whitespace
|
|
299
|
+
# string. An EXPLICIT "off" is a deliberate operator choice and
|
|
300
|
+
# MUST win over the sink default -- it is NOT treated as unset.
|
|
301
|
+
env_unset = (env_present is None) or (mode_raw == "")
|
|
302
|
+
env_off = mode_raw == "off"
|
|
303
|
+
# Effective mode is "off" when: env explicitly off, OR env unset
|
|
304
|
+
# AND this sink's default is off. In that case, with no spool on
|
|
305
|
+
# disk, skip the spool import entirely (byte-identical legacy).
|
|
306
|
+
effective_off = env_off or (env_unset and default_mode in ("", "off"))
|
|
307
|
+
if effective_off and not os.path.isdir(spool_dir):
|
|
277
308
|
return
|
|
278
309
|
try:
|
|
279
|
-
from controlzero.spool import Spool, get_mode
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
310
|
+
from controlzero.spool import Spool, SpoolConfig, get_mode
|
|
311
|
+
|
|
312
|
+
# Resolve the effective mode: an explicit env value (incl.
|
|
313
|
+
# "off") wins; only a truly UNSET/blank env falls back to
|
|
314
|
+
# this sink's default_mode.
|
|
315
|
+
resolved = get_mode() # "off" when unset/blank or unknown
|
|
316
|
+
if env_unset and default_mode not in ("", "off"):
|
|
317
|
+
resolved = default_mode
|
|
318
|
+
cfg = SpoolConfig.from_env()
|
|
319
|
+
cfg.mode = resolved
|
|
320
|
+
self._spool_mode = resolved
|
|
321
|
+
self._spool = Spool.maybe_open(stream_key, config=cfg)
|
|
322
|
+
# If the spool opened but immediately degraded to memory-only
|
|
323
|
+
# (e.g. ENOSPC during the open-time recovery/cleanup), surface
|
|
324
|
+
# it as a metric. The sink still functions -- WAL just buffers
|
|
325
|
+
# in memory until disk frees up -- so this is a warning, not a
|
|
326
|
+
# crash (C15).
|
|
327
|
+
if (self._spool is not None
|
|
328
|
+
and getattr(self._spool, "in_memory_mode", False)):
|
|
329
|
+
self._spool_init_degraded("memory_mode")
|
|
283
330
|
except Exception as exc: # noqa: BLE001
|
|
331
|
+
self._spool_init_degraded("exception")
|
|
284
332
|
logger.warning(
|
|
285
333
|
"controlzero: audit spool unavailable (%s); "
|
|
286
334
|
"falling back to in-memory buffering",
|
|
@@ -289,6 +337,15 @@ class _SpoolWiringMixin:
|
|
|
289
337
|
self._spool = None
|
|
290
338
|
self._spool_mode = "off"
|
|
291
339
|
|
|
340
|
+
@staticmethod
|
|
341
|
+
def _spool_init_degraded(reason: str) -> None:
|
|
342
|
+
"""Emit the spool-init degrade metric. Never raises."""
|
|
343
|
+
try:
|
|
344
|
+
from controlzero.spool import metrics as _m
|
|
345
|
+
_m.incr("spool_init_degraded_total", reason)
|
|
346
|
+
except Exception: # noqa: BLE001
|
|
347
|
+
pass
|
|
348
|
+
|
|
292
349
|
@property
|
|
293
350
|
def _spool_wal(self) -> bool:
|
|
294
351
|
"""True when log() must take the spool-first WAL path."""
|
|
@@ -660,8 +717,11 @@ class BearerAuditSink(_SpoolWiringMixin):
|
|
|
660
717
|
self._closed = False
|
|
661
718
|
|
|
662
719
|
# Offline audit spool (Phase 2): the stream is keyed by the api
|
|
663
|
-
# key fingerprint, exactly the spec's stream identity.
|
|
664
|
-
|
|
720
|
+
# key fingerprint, exactly the spec's stream identity. HOSTED
|
|
721
|
+
# mode defaults to durable WAL-to-encrypted-disk so audit is
|
|
722
|
+
# never lost on a backend outage even with no env set (founder
|
|
723
|
+
# requirement). An explicit CONTROLZERO_SPOOL value still wins.
|
|
724
|
+
self._init_spool(api_key, default_mode=_SPOOL_MODE_DURABLE)
|
|
665
725
|
|
|
666
726
|
self._start_flush_timer()
|
|
667
727
|
# Drain-only rule (plan section 10): an existing non-empty spool
|
|
@@ -203,9 +203,22 @@ def spool_verify(as_json):
|
|
|
203
203
|
click.echo("error: spool.key missing; cannot verify", err=True)
|
|
204
204
|
sys.exit(2)
|
|
205
205
|
from controlzero.spool import assess_segment, derive_key
|
|
206
|
-
from controlzero.spool._state import
|
|
206
|
+
from controlzero.spool._state import (
|
|
207
|
+
SpoolKeyUnavailable,
|
|
208
|
+
load_or_create_dek,
|
|
209
|
+
secure_read,
|
|
210
|
+
)
|
|
207
211
|
|
|
208
|
-
|
|
212
|
+
try:
|
|
213
|
+
dek = load_or_create_dek(root)
|
|
214
|
+
except SpoolKeyUnavailable:
|
|
215
|
+
click.echo(
|
|
216
|
+
"error: spool DEK lives in the OS keystore but the keystore is "
|
|
217
|
+
"not readable right now (locked, or run on a different machine). "
|
|
218
|
+
"Unlock the keystore (or run on the originating host) and retry.",
|
|
219
|
+
err=True,
|
|
220
|
+
)
|
|
221
|
+
sys.exit(2)
|
|
209
222
|
|
|
210
223
|
def key_provider(header):
|
|
211
224
|
return derive_key(dek, header.device_id, header.stream_fp,
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Non-interactive OS-keychain provider for the spool DEK (spec 7.1, 5).
|
|
2
|
+
|
|
3
|
+
The 32-byte device encryption key (DEK) protects the encrypted spool at
|
|
4
|
+
rest. By default the DEK lives in ``spool.key`` (base64, 0600) co-located
|
|
5
|
+
with the ciphertext it protects -- so a single file-read of the spool
|
|
6
|
+
directory recovers the key AND the records. This module moves the DEK
|
|
7
|
+
into the OS keystore so a local file-read of the spool dir can neither
|
|
8
|
+
decrypt nor (because the AEAD tag and hash chain are keyed on the DEK)
|
|
9
|
+
forge spool records.
|
|
10
|
+
|
|
11
|
+
HARD CONSTRAINT (spec section 5, the ``CONTROLZERO_SPOOL_KEYCHAIN`` row):
|
|
12
|
+
the spool runs inside coding-agent hooks (Claude Code / Gemini / Codex
|
|
13
|
+
PreToolUse). A blocking keychain GUI prompt inside a hook is a P0. Every
|
|
14
|
+
keystore call here therefore:
|
|
15
|
+
|
|
16
|
+
- runs NON-INTERACTIVELY (the platform CLI flag that reads/writes a
|
|
17
|
+
stored item without ever raising a GUI unlock dialog), and
|
|
18
|
+
- is wrapped in a hard wall-clock timeout, and
|
|
19
|
+
- on ANY failure (tool missing, locked keystore, prompt risk, timeout,
|
|
20
|
+
non-zero exit) returns None so the caller falls back to the 0600
|
|
21
|
+
file path with a documented warning.
|
|
22
|
+
|
|
23
|
+
The provider never raises into the caller; ``get``/``set`` return None on
|
|
24
|
+
any problem. ``available()`` is a cheap, side-effect-free probe used to
|
|
25
|
+
decide the default DEK source.
|
|
26
|
+
|
|
27
|
+
Service/account identity (stable, per-OS-user, no secrets in the name):
|
|
28
|
+
|
|
29
|
+
macOS Keychain (generic password item):
|
|
30
|
+
service = "com.controlzero.spool.dek"
|
|
31
|
+
account = "<spool_root_realpath>"
|
|
32
|
+
Linux Secret Service (libsecret via secret-tool):
|
|
33
|
+
attributes: service=com.controlzero.spool.dek, path=<spool_root>
|
|
34
|
+
|
|
35
|
+
Keying the item on the spool root path lets two distinct spool roots
|
|
36
|
+
(e.g. a test root and the real ~/.controlzero/spool) hold independent
|
|
37
|
+
DEKs without colliding.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import base64
|
|
43
|
+
import logging
|
|
44
|
+
import os
|
|
45
|
+
import shutil
|
|
46
|
+
import subprocess # noqa: S404 -- fixed argv, no shell, hard timeout
|
|
47
|
+
import sys
|
|
48
|
+
|
|
49
|
+
logger = logging.getLogger("controlzero.spool.keyring")
|
|
50
|
+
|
|
51
|
+
# Stable keystore identity. NOT a secret; safe to hardcode.
|
|
52
|
+
_SERVICE = "com.controlzero.spool.dek"
|
|
53
|
+
|
|
54
|
+
# Hard wall-clock ceiling for any SINGLE keystore subprocess. The hook
|
|
55
|
+
# hot path budget is 2 s total (_SPOOL_HOOK_BUDGET_S); a keystore call
|
|
56
|
+
# that takes longer is treated as unavailable so we fall back to the file
|
|
57
|
+
# path rather than risk the hook. Kept well below the hook budget so that
|
|
58
|
+
# even the worst case (a get that times out followed by no set -- see
|
|
59
|
+
# get_dek/set_dek discipline) stays a fraction of the budget.
|
|
60
|
+
_KEYCHAIN_TIMEOUT_S = 0.5
|
|
61
|
+
|
|
62
|
+
# Opt-out kept narrow and explicit: a value of "0" or "file" forces the
|
|
63
|
+
# legacy on-disk DEK even on a platform with a working keystore. Any
|
|
64
|
+
# other value (or unset) keeps the keychain-default behavior. This is
|
|
65
|
+
# the inverse-sense companion to CONTROLZERO_SPOOL_KEYCHAIN=1 which
|
|
66
|
+
# remains supported as an explicit opt-IN (handled by the caller).
|
|
67
|
+
ENV_KEYCHAIN_DISABLE = "CONTROLZERO_SPOOL_KEYCHAIN_DISABLE"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _account_for(root: str) -> str:
|
|
71
|
+
"""Per-spool-root account id. Realpath so a symlinked vs canonical
|
|
72
|
+
root resolve to the same keystore item."""
|
|
73
|
+
try:
|
|
74
|
+
return os.path.realpath(os.path.expanduser(root))
|
|
75
|
+
except Exception: # noqa: BLE001
|
|
76
|
+
return os.path.expanduser(root)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# Sentinel returned by _run on a hard timeout (vs a clean non-zero exit).
|
|
80
|
+
# A timeout means the keystore is likely prompting / wedged, so the caller
|
|
81
|
+
# should STOP attempting further keystore calls this open (don't burn a
|
|
82
|
+
# second timeout on a set after a get already timed out).
|
|
83
|
+
_TIMEOUT = object()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _run(argv, input_bytes=None):
|
|
87
|
+
"""Run a keystore CLI non-interactively with a hard timeout.
|
|
88
|
+
|
|
89
|
+
Returns (rc, stdout_bytes), ``_TIMEOUT`` (the call exceeded the hard
|
|
90
|
+
budget -- likely a prompt/wedge), or None on any other failure
|
|
91
|
+
(missing binary, OSError). NEVER raises. NEVER inherits a tty, and
|
|
92
|
+
runs in its OWN session/process group so a CLI that tries to open a
|
|
93
|
+
controlling-terminal/agent prompt cannot attach to ours; on timeout
|
|
94
|
+
the whole process group is killed so nothing lingers blocking."""
|
|
95
|
+
try:
|
|
96
|
+
proc = subprocess.Popen( # noqa: S603 -- fixed argv list, shell=False
|
|
97
|
+
argv,
|
|
98
|
+
stdin=subprocess.PIPE,
|
|
99
|
+
stdout=subprocess.PIPE,
|
|
100
|
+
stderr=subprocess.DEVNULL,
|
|
101
|
+
start_new_session=True, # detach from our session/tty + pgid
|
|
102
|
+
)
|
|
103
|
+
except (OSError, ValueError):
|
|
104
|
+
return None
|
|
105
|
+
try:
|
|
106
|
+
out, _err = proc.communicate(input=input_bytes,
|
|
107
|
+
timeout=_KEYCHAIN_TIMEOUT_S)
|
|
108
|
+
except subprocess.TimeoutExpired:
|
|
109
|
+
# Kill the whole process group so a prompting CLI cannot linger.
|
|
110
|
+
try:
|
|
111
|
+
os.killpg(proc.pid, 9)
|
|
112
|
+
except (OSError, AttributeError):
|
|
113
|
+
try:
|
|
114
|
+
proc.kill()
|
|
115
|
+
except OSError:
|
|
116
|
+
pass
|
|
117
|
+
try:
|
|
118
|
+
proc.communicate(timeout=0.2)
|
|
119
|
+
except Exception: # noqa: BLE001
|
|
120
|
+
pass
|
|
121
|
+
logger.debug("keystore call timed out (killed pgroup): %s", argv[0])
|
|
122
|
+
return _TIMEOUT
|
|
123
|
+
except (OSError, ValueError):
|
|
124
|
+
return None
|
|
125
|
+
return proc.returncode, out or b""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# Per-process latch: once a keystore call has timed out (prompt/wedge),
|
|
129
|
+
# treat the keystore as unavailable for the rest of this process so we
|
|
130
|
+
# never burn a second hard timeout on the hook hot path.
|
|
131
|
+
_keystore_timed_out = False
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _decode_get(res):
|
|
135
|
+
"""Map a _run() result for a GET into a 32-byte DEK or None.
|
|
136
|
+
|
|
137
|
+
A timeout latches the keystore off for this process and returns None
|
|
138
|
+
(caller falls back to file). A non-zero exit (item absent) is None."""
|
|
139
|
+
global _keystore_timed_out
|
|
140
|
+
if res is _TIMEOUT:
|
|
141
|
+
_keystore_timed_out = True
|
|
142
|
+
return None
|
|
143
|
+
if res is None:
|
|
144
|
+
return None
|
|
145
|
+
rc, out = res
|
|
146
|
+
if rc != 0:
|
|
147
|
+
return None
|
|
148
|
+
text = out.decode("utf-8", "replace").strip()
|
|
149
|
+
if not text:
|
|
150
|
+
return None
|
|
151
|
+
try:
|
|
152
|
+
raw = base64.b64decode(text)
|
|
153
|
+
except Exception: # noqa: BLE001
|
|
154
|
+
return None
|
|
155
|
+
return raw if len(raw) == 32 else None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _ok_set(res):
|
|
159
|
+
"""Map a _run() result for a SET into True/False. Timeout latches the
|
|
160
|
+
keystore off and returns False (caller falls back to file)."""
|
|
161
|
+
global _keystore_timed_out
|
|
162
|
+
if res is _TIMEOUT:
|
|
163
|
+
_keystore_timed_out = True
|
|
164
|
+
return False
|
|
165
|
+
if res is None:
|
|
166
|
+
return False
|
|
167
|
+
rc, _out = res
|
|
168
|
+
return rc == 0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# -- macOS Keychain (security(1) generic-password items) --------------------
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _macos_security_bin():
|
|
175
|
+
return shutil.which("security")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _macos_get(account: str):
|
|
179
|
+
sec = _macos_security_bin()
|
|
180
|
+
if not sec:
|
|
181
|
+
return None
|
|
182
|
+
# -w prints ONLY the password to stdout. For a generic-password item
|
|
183
|
+
# that this same process created (ACL grants the caller access), the
|
|
184
|
+
# read is non-interactive: the keychain does not raise an unlock
|
|
185
|
+
# dialog for a previously-stored generic password owned by the same
|
|
186
|
+
# login keychain. If the keychain is locked and WOULD prompt, the
|
|
187
|
+
# call blocks -- the hard timeout in _run() bounds that to 1 s and we
|
|
188
|
+
# fall back to file. -g is intentionally NOT passed (it can route to
|
|
189
|
+
# an interactive dialog on some macOS versions).
|
|
190
|
+
res = _run([sec, "find-generic-password", "-s", _SERVICE,
|
|
191
|
+
"-a", account, "-w"])
|
|
192
|
+
return _decode_get(res)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _macos_set(account: str, dek: bytes) -> bool:
|
|
196
|
+
sec = _macos_security_bin()
|
|
197
|
+
if not sec:
|
|
198
|
+
return False
|
|
199
|
+
b64 = base64.b64encode(dek).decode("ascii")
|
|
200
|
+
# -U updates an existing item in place. We deliberately do NOT pass
|
|
201
|
+
# `-T ""`: an empty trusted-application list forces an access-control
|
|
202
|
+
# evaluation that can BLOCK the `security` process (verified on macOS:
|
|
203
|
+
# `-T ""` hangs add-generic-password). The default ACL trusts the
|
|
204
|
+
# creating process, which is exactly the non-interactive behavior we
|
|
205
|
+
# need -- the same login user's later `security find-generic-password
|
|
206
|
+
# -w` reads it back without a GUI prompt. The hard timeout in _run()
|
|
207
|
+
# still bounds any pathological case.
|
|
208
|
+
res = _run([sec, "add-generic-password", "-s", _SERVICE,
|
|
209
|
+
"-a", account, "-w", b64, "-U"])
|
|
210
|
+
return _ok_set(res)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# -- Linux Secret Service (libsecret via secret-tool(1)) --------------------
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _secret_tool_bin():
|
|
217
|
+
return shutil.which("secret-tool")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _linux_get(account: str):
|
|
221
|
+
st = _secret_tool_bin()
|
|
222
|
+
if not st:
|
|
223
|
+
return None
|
|
224
|
+
# secret-tool lookup is non-interactive when the collection is
|
|
225
|
+
# already unlocked (the common case: a logged-in desktop session).
|
|
226
|
+
# If the keyring is locked it MAY prompt; the timeout bounds it and
|
|
227
|
+
# we fall back to file. attributes pin the item.
|
|
228
|
+
res = _run([st, "lookup", "service", _SERVICE, "path", account])
|
|
229
|
+
return _decode_get(res)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _linux_set(account: str, dek: bytes) -> bool:
|
|
233
|
+
st = _secret_tool_bin()
|
|
234
|
+
if not st:
|
|
235
|
+
return False
|
|
236
|
+
b64 = base64.b64encode(dek).decode("ascii") + "\n"
|
|
237
|
+
# secret-tool store reads the secret from stdin.
|
|
238
|
+
res = _run([st, "store", "--label=ControlZero spool DEK",
|
|
239
|
+
"service", _SERVICE, "path", account],
|
|
240
|
+
input_bytes=b64.encode("ascii"))
|
|
241
|
+
return _ok_set(res)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# -- public surface ---------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def backend_name():
|
|
248
|
+
"""The keystore backend that would be used on this platform, or None.
|
|
249
|
+
|
|
250
|
+
Side-effect-free: only checks the platform and that the CLI exists on
|
|
251
|
+
PATH. Does NOT touch the keystore (no prompt risk)."""
|
|
252
|
+
if os.environ.get(ENV_KEYCHAIN_DISABLE, "").strip().lower() in ("1", "file", "true", "yes"):
|
|
253
|
+
return None
|
|
254
|
+
if sys.platform == "darwin":
|
|
255
|
+
return "macos" if _macos_security_bin() else None
|
|
256
|
+
if sys.platform.startswith("linux"):
|
|
257
|
+
return "secret-service" if _secret_tool_bin() else None
|
|
258
|
+
# Windows DPAPI path is not implemented in v1; the file fallback is
|
|
259
|
+
# used (documented in the spec / spool docs).
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def available():
|
|
264
|
+
"""True iff a keystore backend CLI is present on this platform AND
|
|
265
|
+
not disabled by env. Side-effect-free (no keystore access)."""
|
|
266
|
+
return backend_name() is not None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def get_dek(root: str):
|
|
270
|
+
"""Return the 32-byte DEK from the OS keystore, or None.
|
|
271
|
+
|
|
272
|
+
None means: no keystore, item absent, locked, prompt-risk hit the
|
|
273
|
+
timeout, or a malformed stored value. The caller falls back to the
|
|
274
|
+
on-disk DEK. Never raises."""
|
|
275
|
+
if _keystore_timed_out:
|
|
276
|
+
return None
|
|
277
|
+
backend = backend_name()
|
|
278
|
+
if backend is None:
|
|
279
|
+
return None
|
|
280
|
+
account = _account_for(root)
|
|
281
|
+
try:
|
|
282
|
+
if backend == "macos":
|
|
283
|
+
return _macos_get(account)
|
|
284
|
+
if backend == "secret-service":
|
|
285
|
+
return _linux_get(account)
|
|
286
|
+
except Exception as exc: # noqa: BLE001 -- defensive: never raise on hot path
|
|
287
|
+
logger.debug("keystore get failed: %s", exc)
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def set_dek(root: str, dek: bytes):
|
|
292
|
+
"""Store the 32-byte DEK in the OS keystore. Returns True on success.
|
|
293
|
+
|
|
294
|
+
On any failure returns False; the caller then persists the DEK to the
|
|
295
|
+
0600 file instead. Never raises."""
|
|
296
|
+
if len(dek) != 32:
|
|
297
|
+
return False
|
|
298
|
+
if _keystore_timed_out:
|
|
299
|
+
return False
|
|
300
|
+
backend = backend_name()
|
|
301
|
+
if backend is None:
|
|
302
|
+
return False
|
|
303
|
+
account = _account_for(root)
|
|
304
|
+
try:
|
|
305
|
+
if backend == "macos":
|
|
306
|
+
return _macos_set(account, dek)
|
|
307
|
+
if backend == "secret-service":
|
|
308
|
+
return _linux_set(account, dek)
|
|
309
|
+
except Exception as exc: # noqa: BLE001
|
|
310
|
+
logger.debug("keystore set failed: %s", exc)
|
|
311
|
+
return False
|
|
@@ -21,6 +21,7 @@ import socket
|
|
|
21
21
|
import time
|
|
22
22
|
import uuid
|
|
23
23
|
|
|
24
|
+
from . import _keyring
|
|
24
25
|
from . import _metrics as metrics
|
|
25
26
|
from ._compress import compress, default_write_flg
|
|
26
27
|
from ._constants import (
|
|
@@ -63,6 +64,7 @@ from ._frame import (
|
|
|
63
64
|
walk_records,
|
|
64
65
|
)
|
|
65
66
|
from ._state import (
|
|
67
|
+
SpoolKeyUnavailable,
|
|
66
68
|
atomic_write,
|
|
67
69
|
ensure_dir,
|
|
68
70
|
fsync_dir,
|
|
@@ -89,6 +91,24 @@ def get_mode():
|
|
|
89
91
|
return MODE_OFF
|
|
90
92
|
|
|
91
93
|
|
|
94
|
+
def _resolve_keychain_env():
|
|
95
|
+
"""Resolve the DEK-source tri-state from the environment.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
True if CONTROLZERO_SPOOL_KEYCHAIN is explicitly truthy (opt-in);
|
|
99
|
+
False if CONTROLZERO_SPOOL_KEYCHAIN is explicitly falsey OR
|
|
100
|
+
CONTROLZERO_SPOOL_KEYCHAIN_DISABLE is set (opt-out);
|
|
101
|
+
None otherwise -> the default keystore-first-when-available path.
|
|
102
|
+
"""
|
|
103
|
+
raw = os.environ.get(ENV_KEYCHAIN)
|
|
104
|
+
if raw is not None and raw.strip() != "":
|
|
105
|
+
return raw.strip().lower() in ("1", "true", "yes", "on")
|
|
106
|
+
if os.environ.get(_keyring.ENV_KEYCHAIN_DISABLE, "").strip().lower() in (
|
|
107
|
+
"1", "file", "true", "yes", "on"):
|
|
108
|
+
return False
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
92
112
|
def _env_int(name, default):
|
|
93
113
|
raw = os.environ.get(name)
|
|
94
114
|
if raw is None or raw == "":
|
|
@@ -115,13 +135,17 @@ class SpoolConfig:
|
|
|
115
135
|
segment_age_s=DEFAULT_SEGMENT_AGE_S,
|
|
116
136
|
retention_s=DEFAULT_RETENTION_S,
|
|
117
137
|
max_bytes=DEFAULT_MAX_BYTES,
|
|
118
|
-
keychain=
|
|
138
|
+
keychain=None):
|
|
119
139
|
self.mode = mode
|
|
120
140
|
self.root = root or os.path.expanduser(DEFAULT_DIR)
|
|
121
141
|
self.segment_bytes = segment_bytes
|
|
122
142
|
self.segment_age_s = segment_age_s
|
|
123
143
|
self.retention_s = retention_s
|
|
124
144
|
self.max_bytes = max_bytes
|
|
145
|
+
# Tri-state DEK source (spec 7.1, section 5):
|
|
146
|
+
# None -> keystore-first when available, else 0600 file (DEFAULT);
|
|
147
|
+
# True -> explicit keystore opt-in (CONTROLZERO_SPOOL_KEYCHAIN=1);
|
|
148
|
+
# False -> 0600 file only (explicit opt-out / disable env).
|
|
125
149
|
self.keychain = keychain
|
|
126
150
|
|
|
127
151
|
@classmethod
|
|
@@ -133,7 +157,7 @@ class SpoolConfig:
|
|
|
133
157
|
segment_age_s=_env_int(ENV_SEGMENT_AGE, DEFAULT_SEGMENT_AGE_S),
|
|
134
158
|
retention_s=_env_int(ENV_RETENTION, DEFAULT_RETENTION_S),
|
|
135
159
|
max_bytes=_env_int(ENV_MAX_BYTES, DEFAULT_MAX_BYTES),
|
|
136
|
-
keychain=
|
|
160
|
+
keychain=_resolve_keychain_env(),
|
|
137
161
|
)
|
|
138
162
|
|
|
139
163
|
|
|
@@ -188,6 +212,17 @@ class Spool:
|
|
|
188
212
|
self._init_dirs(create)
|
|
189
213
|
self.recover()
|
|
190
214
|
self.cleanup()
|
|
215
|
+
except SpoolKeyUnavailable as exc:
|
|
216
|
+
# The DEK lives in a keystore we cannot read this open and
|
|
217
|
+
# spool.key holds only the sentinel. Minting a new key would
|
|
218
|
+
# orphan the existing keystore-encrypted records, so degrade
|
|
219
|
+
# to memory-only for this process and try again next open
|
|
220
|
+
# (the keystore may be reachable then). Never destructive.
|
|
221
|
+
metrics.incr("spool_key_unavailable_total", "keychain")
|
|
222
|
+
logger.warning(
|
|
223
|
+
"spool DEK unavailable (keystore unreadable, sentinel on "
|
|
224
|
+
"disk); degrading to memory-only this process: %s", exc)
|
|
225
|
+
self._memory_mode = True
|
|
191
226
|
except OSError as exc:
|
|
192
227
|
self._degrade(exc, during="init")
|
|
193
228
|
|
|
@@ -221,7 +256,7 @@ class Spool:
|
|
|
221
256
|
now_iso = _iso_from_ms(self._now_ms())
|
|
222
257
|
self._device_id, self._device_epoch = load_or_create_device(
|
|
223
258
|
self._root, now_iso)
|
|
224
|
-
self._dek = load_or_create_dek(self._root)
|
|
259
|
+
self._dek = load_or_create_dek(self._root, keychain=self._cfg.keychain)
|
|
225
260
|
if create:
|
|
226
261
|
ensure_dir(self._stream_dir)
|
|
227
262
|
|