controlzero 1.12.0__tar.gz → 1.13.0__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.12.0 → controlzero-1.13.0}/CHANGELOG.md +36 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/PKG-INFO +1 -1
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/__init__.py +1 -1
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/_internal/bundle.py +17 -0
- controlzero-1.13.0/controlzero/_internal/condition_ops.py +226 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/_internal/enforcer.py +127 -3
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/_internal/io_capture_producer.py +16 -9
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/policy_loader.py +53 -1
- {controlzero-1.12.0 → controlzero-1.13.0}/pyproject.toml +1 -1
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_coding_agent_hooks.py +11 -8
- controlzero-1.13.0/tests/test_condition_operators_1495.py +192 -0
- controlzero-1.13.0/tests/test_deny_rule_reconciliation_1011.py +247 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/.gitignore +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/Dockerfile.test +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/LICENSE +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/README.md +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/__main__.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/_internal/__init__.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/_internal/_messages.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/_internal/action_aliases.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/_internal/action_validator.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/_internal/credential_hook.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/_internal/credential_scanner.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/_internal/credentials_data/__init__.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/_internal/credentials_data/built_in.yaml +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/_internal/dlp_scanner.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/_internal/hook_extractors.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/_internal/source_labels.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/_internal/tool_extractors.json +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/_internal/types.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/audit_local.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/audit_remote.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/canonical.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/__init__.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/__main__.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/_secrets.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/agent_config.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/console.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/debug_bundle.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/doctor.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/exec_cmd.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/hook_entry.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/hosts/__init__.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/hosts/antigravity.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/hosts/base.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/hosts/claude_code.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/hosts/codex_cli.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/hosts/cursor.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/hosts/cursor_cli.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/hosts/gemini_cli.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/hosts/kiro.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/hosts/unknown.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/kiro_adapter.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/main.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/migrate.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/output.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/spool_cmd.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/telemetry_consent.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/templates/antigravity/hooks.json +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/templates/antigravity.yaml +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/templates/autogen.yaml +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/templates/claude-code.yaml +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/templates/codex-cli.yaml +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/templates/cost-cap.yaml +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/templates/crewai.yaml +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/templates/cursor.yaml +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/templates/gemini-cli.yaml +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/templates/generic.yaml +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/templates/kiro/ide-file-save.kiro.hook +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/templates/kiro/ide-pre-tool-use.kiro.hook +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/templates/kiro/ide-prompt-submit.kiro.hook +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/templates/kiro/kiro.yaml +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/templates/langchain.yaml +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/templates/mcp.yaml +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/templates/rag.yaml +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/cli/update_cmd.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/client.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/device.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/enrollment.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/error_codes.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/error_codes.yaml +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/errors.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/hitl/__init__.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/hitl/grant_protocol.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/hitl/mock.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/hitl/pending_approval.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/hitl/secret_leak_guard.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/hitl/status.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/hook_integrity.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/hooks/__init__.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/hooks/tool_output_handler.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/hosted_policy.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/__init__.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/anthropic.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/autogen.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/braintrust.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/crewai/__init__.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/crewai/agent.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/crewai/crew.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/crewai/task.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/crewai/tool.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/google.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/google_adk/__init__.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/google_adk/agent.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/google_adk/tool.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/langchain/__init__.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/langchain/agent.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/langchain/callbacks.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/langchain/chain.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/langchain/graph.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/langchain/modern.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/langchain/tool.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/langfuse.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/litellm.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/openai.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/pydantic_ai.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/integrations/vercel_ai.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/layout_migration.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/secrets/__init__.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/secrets/reference.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/secrets/resolver.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/secrets/secretstr.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/spool/__init__.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/spool/_compress.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/spool/_constants.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/spool/_crc32c.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/spool/_crypto.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/spool/_frame.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/spool/_keyring.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/spool/_metrics.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/spool/_spool.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/spool/_state.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/spool/_uploader.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/spool/cz-audit-v1.dict +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/tamper.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/controlzero/tracecontext.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/examples/hello_world.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/_fixtures/antigravity-envelope-expected.json +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/_fixtures/jcs_args_hash_vectors.json +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/conftest.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/integrations/__init__.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/integrations/test_google.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/parity/action_aliases.json +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/spool/__init__.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/spool/conftest.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/spool/test_spool_cli.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/spool/test_spool_concurrency.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/spool/test_spool_conformance.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/spool/test_spool_core.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/spool/test_spool_crash.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/spool/test_spool_diskfull.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/spool/test_spool_durable_default_tamper.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/spool/test_spool_keychain_dek.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/spool/test_spool_sink_wiring.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/spool/test_spool_transcript_localack.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/spool/test_spool_uploader.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_action_aliases.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_action_canonicalization.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_action_validator_t86.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_adapter_interface.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_agent_name_env.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_agy_docs_no_exit2_1373.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_antigravity_adapter.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_antigravity_envelope_parity.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_antigravity_ga_blockers_1248.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_antigravity_hook_check.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_antigravity_install.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_antigravity_posttooluse_observe_58.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_antigravity_tool_vocab_1303.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_api_key_mask.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_audit_project_id_attribution_pr2.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_audit_remote.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_audit_remote_io_capture.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_audit_remote_sdk_version.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_audit_sink_isolation.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_bundle_cache_freshness_1303.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_bundle_parser.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_bundle_translate.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_canonical_phase1a.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_capability_matrix_doc.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_cli_carve_out.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_cli_debug_bundle.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_cli_extractor_integration.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_cli_hook.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_cli_hook_entry.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_cli_hosted_refresh.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_cli_init.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_cli_init_templates.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_cli_tail.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_cli_test.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_cli_validate.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_conditions.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_config_format_parity_1303.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_conformance.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_console.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_credential_hook.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_cursor_adapter.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_default_action.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_device.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_dlp_mask.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_dlp_scanner.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_doctor.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_engine_version_consistency.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_enrollment.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_env_dump_438.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_epic_1247_bryan_acceptance.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_error_codes.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_errors_e_codes.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_fail_closed_eval.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_failopen_1303.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_glob_matching.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hitl_5d_email_install.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hitl_6a_cli_flag.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hitl_6a_exceptions.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hitl_6a_get_secret_hitl.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hitl_6a_mock_backend.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hitl_6a_pending_approval.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hitl_6a_request_approval.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hitl_6a_secret_leak_guard.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hitl_6a_wait.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hitl_conformance.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hitl_phase2b_protocol.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hitl_reason_codes.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hitl_validator_keys.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hook_extractors.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hook_integrity.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hook_mask_failclosed.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hosted_local_audit_1247.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hosted_local_shadow_warn_1265.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hosted_policy_e2e.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hosts_adapter.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hybrid_mode_strict.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_hybrid_mode_warn.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_install_hook_command.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_install_hooks.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_io_capture_producer.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_kiro_adapter.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_kiro_cli_e2e.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_kiro_cli_hook_pin_1265.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_kiro_hook_templates.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_kiro_install.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_layout_migration_t101.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_layout_parity_t102.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_local_mode_dict.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_local_mode_file_json.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_local_mode_file_yaml.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_localized_reason.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_log_fallback_stderr.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_log_options_ignored_hosted.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_log_rotation.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_migrate.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_min_sdk_version_gate.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_multi_client_per_project_175.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_no_policy_no_key.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_observe_mode_1247.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_package_rename_shim.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_part3_active_policy_count_1303.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_per_client_isolation_1011.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_policy_engine_version_phase1b.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_policy_freshness.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_policy_load_failclosed_f3.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_policy_settings.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_policy_source_audit.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_pr4_install_messaging.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_pr4_output.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_pr4_status.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_probe_audit_isolation.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_quarantine.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_reason_code.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_refresh.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_secrets.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_secrets_reference_resolver.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_source_labels.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_sql_semantic_class.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_synthetic_policy_id_t79.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_t103_precedence.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_t104_cache_gc.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_t108_local_override_audit.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_t96_single_audit_log.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_t99_install_prefetch_bundle.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_tamper.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_tamper_behavior.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_tamper_hook.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_telemetry_consent.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_tracecontext.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_unsafe_int_boundary.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_update_cmd.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tests/test_upgrade_nudge.py +0 -0
- {controlzero-1.12.0 → controlzero-1.13.0}/tools/cz-kiro-adapter +0 -0
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.13.0 (2026-06-26): policy-condition operator vocab
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Condition operators.** A rule condition value may now be a tagged envelope
|
|
8
|
+
`{op, value}` honoring a closed operator set: `equals`, `contains`, `glob`,
|
|
9
|
+
`matches` (regex, BETA), `gt`/`lt`/`gte`/`lte`, `in`/`not_in`,
|
|
10
|
+
`in_range`/`outside_range`, `exists`/`not_exists`. Read-only aliases
|
|
11
|
+
`matches_pattern`/`greater_than`/`less_than` are canonicalized on load, so
|
|
12
|
+
existing dashboard templates work with zero migration. A legacy scalar value
|
|
13
|
+
(a plain string or number) keeps its prior glob/equals behavior exactly, so
|
|
14
|
+
existing policies and signed bundles evaluate unchanged.
|
|
15
|
+
- **File-path conditions.** `file_path` / `path` / `notebook_path` now feed the
|
|
16
|
+
`input_text` condition value, so a condition like "input_text contains .env"
|
|
17
|
+
fires on Read/Write/Edit tool calls.
|
|
18
|
+
|
|
19
|
+
### Notes
|
|
20
|
+
|
|
21
|
+
- Fail-closed: a missing field is no-match for every operator except
|
|
22
|
+
`exists`/`not_exists`; an unknown operator is rejected at load; a malformed
|
|
23
|
+
condition fails closed. `matches` is BETA and length-capped to bound regex
|
|
24
|
+
backtracking. Secret detection stays on the DLP scanner, not a regex condition.
|
|
25
|
+
|
|
26
|
+
## 1.12.1 (2026-06-26): deny rules actually deny
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- **Deny rules now actually deny (#1011).** A policy DENY rule with a condition
|
|
31
|
+
(e.g. "block Bash when the command contains rm -rf") could silently fail to
|
|
32
|
+
match, so the action ran anyway. Reconciled the rule-authoring vocabulary with
|
|
33
|
+
the engine: tool names match case-insensitively (a rule for `bash:*` catches
|
|
34
|
+
the canonical `Bash:rm`), the command text is matched as `input_text` by
|
|
35
|
+
CONTAINS, and conditions are no longer dropped when a hosted policy is
|
|
36
|
+
translated for local enforcement. A present-but-malformed condition now fails
|
|
37
|
+
closed at load instead of silently becoming an unconditional allow.
|
|
38
|
+
|
|
3
39
|
## 1.12.0 -- 2026-06-25 (per-agent key authority + per-agent bundle cache + whoami)
|
|
4
40
|
|
|
5
41
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: controlzero
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.13.0
|
|
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
|
|
@@ -958,4 +958,21 @@ def _translate_rule(rule: dict, policy_id: str) -> Optional[dict]:
|
|
|
958
958
|
if isinstance(k, str) and isinstance(v, str)
|
|
959
959
|
}
|
|
960
960
|
|
|
961
|
+
# #1011 follow-up: the SAME allowlist-strip trap. A hosted rule's
|
|
962
|
+
# `conditions` (e.g. {input_text: "rm -rf"}) was being dropped here, so a
|
|
963
|
+
# condition authored in the dashboard NEVER reached the local enforcer, so
|
|
964
|
+
# the rule silently matched on actions only (or, with a tool-case mismatch,
|
|
965
|
+
# not at all). Forward BOTH alias keys verbatim whenever present (incl. a
|
|
966
|
+
# malformed/falsy shape: [] / "" / 0 / False), and let the loader apply ONE
|
|
967
|
+
# consistent precedence + shape validation (truthy-first; reject any
|
|
968
|
+
# non-dict; a genuine empty dict {} is "no conditions"). Collapsing to a
|
|
969
|
+
# single key HERE re-introduces a divergent precedence where a present `{}`
|
|
970
|
+
# could shadow a real alias (codex re-review), so the bundle stays dumb and
|
|
971
|
+
# the loader is the single source of truth. (The backend wire struct carries
|
|
972
|
+
# Conditions; see bundle_handler.go PolicyRule.)
|
|
973
|
+
if "conditions" in rule:
|
|
974
|
+
translated["conditions"] = rule["conditions"]
|
|
975
|
+
if "when" in rule:
|
|
976
|
+
translated["when"] = rule["when"]
|
|
977
|
+
|
|
961
978
|
return translated
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Policy-condition operator table (single source of truth) for #1011 / #1495.
|
|
2
|
+
|
|
3
|
+
A rule condition value is either a LEGACY SCALAR (string -> glob, number/bool ->
|
|
4
|
+
equals; evaluated in enforcer._conditions_match, byte-identical to pre-#1495) or
|
|
5
|
+
a TAGGED ENVELOPE ``{"op": <name>, "value": <scalar|array>, "case_insensitive"?:
|
|
6
|
+
bool}``. This module evaluates the envelope operators ONLY; the scalar path stays
|
|
7
|
+
in the enforcer so existing policies and signed bundles evaluate unchanged.
|
|
8
|
+
|
|
9
|
+
This is the Python reference; the Node twin (conditionOps.ts) MUST agree
|
|
10
|
+
byte-for-byte (cross-SDK parity is proven by tests/parity/decisions.json). Go is
|
|
11
|
+
paused and not part of this contract; the Go BACKEND engine evaluates only the
|
|
12
|
+
reserved server-side keys (time_range/ip_whitelist/tags) and defers tool-level
|
|
13
|
+
conditions to the SDK (it has no tool args).
|
|
14
|
+
|
|
15
|
+
FAIL-CLOSED LAW: a MISSING field (value is None) is NO-MATCH for EVERY operator
|
|
16
|
+
except ``exists``/``not_exists``. A regex that will not compile, or a numeric op
|
|
17
|
+
on a non-numeric field, is NO-MATCH (never an error that fails open).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import fnmatch
|
|
23
|
+
import re
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
# Read-only aliases canonicalised on evaluation so the shipped templates
|
|
27
|
+
# (matches_pattern / greater_than / less_than) load and evaluate with ZERO data
|
|
28
|
+
# migration. The dashboard going forward emits the canonical names.
|
|
29
|
+
_OP_ALIASES = {
|
|
30
|
+
"matches_pattern": "matches",
|
|
31
|
+
"greater_than": "gt",
|
|
32
|
+
"less_than": "lt",
|
|
33
|
+
"greater_than_or_equal": "gte",
|
|
34
|
+
"less_than_or_equal": "lte",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# The closed operator set the engine honors. The Go publish-time validator (and
|
|
38
|
+
# the frontend) reject any op not in here, so an inert/typo'd operator can never
|
|
39
|
+
# ship.
|
|
40
|
+
KNOWN_OPS = frozenset(
|
|
41
|
+
{
|
|
42
|
+
"glob",
|
|
43
|
+
"equals",
|
|
44
|
+
"contains",
|
|
45
|
+
"matches",
|
|
46
|
+
"gt",
|
|
47
|
+
"lt",
|
|
48
|
+
"gte",
|
|
49
|
+
"lte",
|
|
50
|
+
"in",
|
|
51
|
+
"not_in",
|
|
52
|
+
"outside_range",
|
|
53
|
+
"in_range",
|
|
54
|
+
"exists",
|
|
55
|
+
"not_exists",
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# Reserved structured condition keys are matched by KEY NAME (the Go backend's
|
|
61
|
+
# server-side matchers) and are NEVER interpreted as a {op,value} envelope, even
|
|
62
|
+
# when their value nests a sub-key literally named `op` (e.g. a tag named "op").
|
|
63
|
+
# This closes the tags:{op:...} collision hole. The caller checks the KEY against
|
|
64
|
+
# this set before treating a value as an envelope.
|
|
65
|
+
RESERVED_STRUCTURED_KEYS = frozenset({"time_range", "ip_whitelist", "tags"})
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def canonicalize_op(op: str) -> str:
|
|
69
|
+
"""Map a read alias to its canonical operator name; pass through otherwise."""
|
|
70
|
+
return _OP_ALIASES.get(op, op)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def is_envelope(value: Any) -> bool:
|
|
74
|
+
"""True iff ``value`` is a tagged operator envelope: a dict with a string
|
|
75
|
+
``op`` key. Reserved structured keys are disambiguated by KEY NAME upstream,
|
|
76
|
+
never here, and a runtime args/context value is never treated as an envelope.
|
|
77
|
+
"""
|
|
78
|
+
return isinstance(value, dict) and isinstance(value.get("op"), str)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _as_float(x: Any):
|
|
82
|
+
"""Coerce to float for numeric ops; return None if not numeric (-> NO-MATCH)."""
|
|
83
|
+
if isinstance(x, bool): # bool is an int subclass; never treat as a number here
|
|
84
|
+
return None
|
|
85
|
+
if isinstance(x, (int, float)):
|
|
86
|
+
return float(x)
|
|
87
|
+
if isinstance(x, str):
|
|
88
|
+
try:
|
|
89
|
+
return float(x.strip())
|
|
90
|
+
except ValueError:
|
|
91
|
+
return None
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _as_list(operand: Any) -> list:
|
|
96
|
+
"""Operand for in/not_in: a JSON array, or a legacy comma-separated string
|
|
97
|
+
('Saturday,Sunday') split by this compat shim so the shipped templates work
|
|
98
|
+
unmodified. A None / empty operand yields an EMPTY list (the caller treats an
|
|
99
|
+
empty operand as NO-MATCH, fail-closed (codex P2: a missing `value` must not
|
|
100
|
+
turn `not_in` into match-everything))."""
|
|
101
|
+
if operand is None:
|
|
102
|
+
return []
|
|
103
|
+
if isinstance(operand, (list, tuple)):
|
|
104
|
+
return [x for x in operand if x is not None]
|
|
105
|
+
if isinstance(operand, str):
|
|
106
|
+
return [p.strip() for p in operand.split(",") if p.strip()]
|
|
107
|
+
return [operand]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# matches (regex) is BETA: Python `re` has no per-match timeout, so a pathological
|
|
111
|
+
# author-supplied pattern can backtrack catastrophically against adversarial field
|
|
112
|
+
# input (codex P1 ReDoS). Until a timeout / RE2 binding lands, bound BOTH the
|
|
113
|
+
# scanned input and the pattern length so practical backtracking is capped; the
|
|
114
|
+
# publish-time validator (#1498) also caps the pattern. A regex error stays
|
|
115
|
+
# fail-closed (NO-MATCH).
|
|
116
|
+
_MATCHES_INPUT_CAP = 8192
|
|
117
|
+
_MATCHES_PATTERN_CAP = 1024
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _range_bounds(operand: Any):
|
|
121
|
+
"""[lo, hi] for in_range/outside_range: a 2-element array, or a legacy
|
|
122
|
+
'lo-hi' / 'HH:MM-HH:MM' string. Returns (lo_str, hi_str) or None if malformed.
|
|
123
|
+
Comparison is lexical for HH:MM (24h zero-padded) and numeric otherwise; both
|
|
124
|
+
sides are compared as strings of the same kind, matching the Go matchTimeRange
|
|
125
|
+
lexical compare for time."""
|
|
126
|
+
if isinstance(operand, (list, tuple)) and len(operand) == 2:
|
|
127
|
+
return str(operand[0]), str(operand[1])
|
|
128
|
+
if isinstance(operand, str) and "-" in operand:
|
|
129
|
+
lo, _, hi = operand.partition("-")
|
|
130
|
+
lo, hi = lo.strip(), hi.strip()
|
|
131
|
+
if lo and hi:
|
|
132
|
+
return lo, hi
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _in_range(value: Any, lo: str, hi: str) -> bool:
|
|
137
|
+
"""Inclusive lo<=value<=hi. Numeric when all three parse as numbers, else a
|
|
138
|
+
lexical string compare (so 'HH:MM' 24h times order correctly)."""
|
|
139
|
+
fv, flo, fhi = _as_float(value), _as_float(lo), _as_float(hi)
|
|
140
|
+
if fv is not None and flo is not None and fhi is not None:
|
|
141
|
+
return flo <= fv <= fhi
|
|
142
|
+
sv = str(value)
|
|
143
|
+
return lo <= sv <= hi
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def match_operator(
|
|
147
|
+
op: str, value: Any, operand: Any, case_insensitive: bool = False
|
|
148
|
+
) -> bool:
|
|
149
|
+
"""Evaluate one field-level envelope operator. ``value`` is the field value
|
|
150
|
+
from the merged context+args dict (None if the field is absent); ``operand``
|
|
151
|
+
is the envelope's ``value``. Returns True iff the condition is satisfied.
|
|
152
|
+
Unknown op -> False (fail-closed; the validator rejects it at publish too)."""
|
|
153
|
+
op = canonicalize_op(op)
|
|
154
|
+
|
|
155
|
+
# Presence ops are the ONLY ones where a missing field can be a match.
|
|
156
|
+
if op == "exists":
|
|
157
|
+
return value is not None
|
|
158
|
+
if op == "not_exists":
|
|
159
|
+
return value is None
|
|
160
|
+
|
|
161
|
+
# Every other op: a missing field is NO-MATCH (uniform, fail-closed).
|
|
162
|
+
if value is None:
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
if op in ("gt", "lt", "gte", "lte"):
|
|
166
|
+
fv, fo = _as_float(value), _as_float(operand)
|
|
167
|
+
if fv is None or fo is None:
|
|
168
|
+
return False # non-numeric operand/field -> NO-MATCH
|
|
169
|
+
if op == "gt":
|
|
170
|
+
return fv > fo
|
|
171
|
+
if op == "lt":
|
|
172
|
+
return fv < fo
|
|
173
|
+
if op == "gte":
|
|
174
|
+
return fv >= fo
|
|
175
|
+
return fv <= fo
|
|
176
|
+
|
|
177
|
+
if op in ("in", "not_in"):
|
|
178
|
+
items = [str(x) for x in _as_list(operand)]
|
|
179
|
+
if not items:
|
|
180
|
+
# Malformed / empty operand -> NO-MATCH for BOTH in and not_in
|
|
181
|
+
# (fail-closed; a deny does not over-fire, an allow does not
|
|
182
|
+
# over-allow). The validator rejects an empty operand at publish.
|
|
183
|
+
return False
|
|
184
|
+
if case_insensitive:
|
|
185
|
+
present = str(value).casefold() in {i.casefold() for i in items}
|
|
186
|
+
else:
|
|
187
|
+
present = str(value) in items
|
|
188
|
+
return present if op == "in" else not present
|
|
189
|
+
|
|
190
|
+
if op in ("in_range", "outside_range"):
|
|
191
|
+
bounds = _range_bounds(operand)
|
|
192
|
+
if bounds is None:
|
|
193
|
+
return False
|
|
194
|
+
inside = _in_range(value, bounds[0], bounds[1])
|
|
195
|
+
return inside if op == "in_range" else not inside
|
|
196
|
+
|
|
197
|
+
# String ops below.
|
|
198
|
+
sval, soperand = str(value), str(operand)
|
|
199
|
+
if case_insensitive:
|
|
200
|
+
sval, soperand = sval.casefold(), soperand.casefold()
|
|
201
|
+
|
|
202
|
+
if op == "equals":
|
|
203
|
+
# Exact equality; wildcards are LITERAL (unlike glob).
|
|
204
|
+
return sval == soperand
|
|
205
|
+
|
|
206
|
+
if op == "contains":
|
|
207
|
+
# Substring; the operand's own glob metachars are literal (not wildcards).
|
|
208
|
+
return soperand in sval
|
|
209
|
+
|
|
210
|
+
if op == "glob":
|
|
211
|
+
return fnmatch.fnmatchcase(sval, soperand)
|
|
212
|
+
|
|
213
|
+
if op == "matches":
|
|
214
|
+
# Regex (re.search). Author owns anchoring. BETA: bound input + pattern
|
|
215
|
+
# length to cap ReDoS backtracking (codex P1); uncompilable / runtime
|
|
216
|
+
# error -> NO-MATCH (fail-closed), never an exception that fails open.
|
|
217
|
+
if len(soperand) > _MATCHES_PATTERN_CAP:
|
|
218
|
+
return False
|
|
219
|
+
flags = re.IGNORECASE if case_insensitive else 0
|
|
220
|
+
try:
|
|
221
|
+
return re.search(soperand, sval[:_MATCHES_INPUT_CAP], flags) is not None
|
|
222
|
+
except re.error:
|
|
223
|
+
return False
|
|
224
|
+
|
|
225
|
+
# Unknown operator: fail-closed (the publish-time validator also rejects it).
|
|
226
|
+
return False
|
|
@@ -516,7 +516,9 @@ class PolicyEvaluator:
|
|
|
516
516
|
|
|
517
517
|
for rule in self._rules:
|
|
518
518
|
evaluated += 1
|
|
519
|
-
|
|
519
|
+
# Action gate: tool-case-insensitive so `bash:*` matches `Bash:rm`
|
|
520
|
+
# (#1011). Resource/client/project gates below stay case-sensitive.
|
|
521
|
+
if not any(_action_glob_any(rule.actions, a) for a in candidate_actions):
|
|
520
522
|
continue
|
|
521
523
|
# Client + project gates (gh#175). A rule whose selector
|
|
522
524
|
# excludes this request does NOT apply to this surface, so
|
|
@@ -676,10 +678,22 @@ class PolicyEvaluator:
|
|
|
676
678
|
All condition keys must match. Missing key = no match. Context keys
|
|
677
679
|
override args on collision. Nested ``context['tags']`` dict is flattened.
|
|
678
680
|
"""
|
|
679
|
-
|
|
681
|
+
# #1011 follow-up (codex P1): the non-dict guard MUST run before the
|
|
682
|
+
# falsy short-circuit, otherwise a falsy-but-malformed conditions ([],
|
|
683
|
+
# "", 0, False) would be read as "no conditions" and the rule would match
|
|
684
|
+
# unconditionally (an allow rule -> unconditional allow, fail-open). A
|
|
685
|
+
# corrupt/tampered or future-shaped signed rule is unverifiable, so the
|
|
686
|
+
# rule does NOT match: an allow rule then does not fire and the default
|
|
687
|
+
# posture governs (fail-closed); a deny rule simply does not contribute.
|
|
688
|
+
# `None` is the genuine "no conditions" case and matches.
|
|
689
|
+
if not isinstance(rule_conditions, dict):
|
|
690
|
+
return rule_conditions is None
|
|
691
|
+
if not rule_conditions: # empty dict -> no conditions to satisfy
|
|
680
692
|
return True
|
|
681
693
|
import fnmatch as _fn
|
|
682
694
|
|
|
695
|
+
from .io_capture_producer import _INPUT_TEXT_KEYS
|
|
696
|
+
|
|
683
697
|
merged: dict = {}
|
|
684
698
|
if args:
|
|
685
699
|
merged.update(args)
|
|
@@ -688,11 +702,71 @@ class PolicyEvaluator:
|
|
|
688
702
|
tags = context.get("tags") or {}
|
|
689
703
|
if isinstance(tags, dict):
|
|
690
704
|
merged.update(tags)
|
|
705
|
+
# #1011 follow-up: derive `input_text` (the key the dashboard/templates
|
|
706
|
+
# author, "command/input contains X") DETERMINISTICALLY from the payload
|
|
707
|
+
# rather than trusting a caller-supplied label. The raw command/content
|
|
708
|
+
# is the GROUND TRUTH of what runs, so it wins first: neither a benign
|
|
709
|
+
# `input_text` nor a benign pre-extracted `input_payload` can shadow a
|
|
710
|
+
# dangerous `command` (codex P1: a payload setting input_text="ls" or
|
|
711
|
+
# input_payload="ls" while command="rm -rf /" would otherwise bypass the
|
|
712
|
+
# deny under default allow). Precedence: the canonical _INPUT_TEXT_KEYS
|
|
713
|
+
# scan (the SAME order audit extraction uses, so enforcement and audit
|
|
714
|
+
# never drift), then the host's pre-extracted input_payload, then any
|
|
715
|
+
# existing input_text, each only as a fallback when nothing richer is
|
|
716
|
+
# present.
|
|
717
|
+
_derived = None
|
|
718
|
+
for _k in _INPUT_TEXT_KEYS:
|
|
719
|
+
_v = merged.get(_k)
|
|
720
|
+
if _v:
|
|
721
|
+
_derived = _v
|
|
722
|
+
break
|
|
723
|
+
if not _derived:
|
|
724
|
+
_derived = merged.get("input_payload")
|
|
725
|
+
if not _derived:
|
|
726
|
+
_derived = merged.get("input_text")
|
|
727
|
+
if _derived:
|
|
728
|
+
merged["input_text"] = _derived
|
|
729
|
+
from .condition_ops import (
|
|
730
|
+
RESERVED_STRUCTURED_KEYS,
|
|
731
|
+
is_envelope,
|
|
732
|
+
match_operator,
|
|
733
|
+
)
|
|
734
|
+
|
|
691
735
|
for key, pattern in rule_conditions.items():
|
|
736
|
+
# #1495: a TAGGED ENVELOPE {op, value} dispatches to the shared
|
|
737
|
+
# operator table (equals/contains/matches/gt/lt/in/not_in/range/
|
|
738
|
+
# exists/...). Everything else is a LEGACY SCALAR and keeps the exact
|
|
739
|
+
# pre-#1495 glob/CONTAINS path below, so existing policies and signed
|
|
740
|
+
# bundles evaluate byte-identically. exists/not_exists are the only
|
|
741
|
+
# ops where a missing field can match, so the envelope branch runs
|
|
742
|
+
# BEFORE the missing-key short-circuit. A RESERVED structured key is
|
|
743
|
+
# matched by key name, never as an envelope (the tags:{op:...} hole).
|
|
744
|
+
if key not in RESERVED_STRUCTURED_KEYS and is_envelope(pattern):
|
|
745
|
+
if not match_operator(
|
|
746
|
+
pattern["op"],
|
|
747
|
+
merged.get(key),
|
|
748
|
+
pattern.get("value"),
|
|
749
|
+
bool(pattern.get("case_insensitive")),
|
|
750
|
+
):
|
|
751
|
+
return False
|
|
752
|
+
continue
|
|
692
753
|
value = merged.get(key)
|
|
693
754
|
if value is None:
|
|
694
755
|
return False
|
|
695
|
-
|
|
756
|
+
sval, spat = str(value), str(pattern)
|
|
757
|
+
# Only `input_text` (the dashboard's authored text-content key) uses
|
|
758
|
+
# CONTAINS for a plain pattern, so `input_text: "rm -rf"` catches
|
|
759
|
+
# `rm -rf /tmp/foo`. A pattern with glob metacharacters still uses
|
|
760
|
+
# fnmatch; EVERY other key (including the raw `command`/`prompt`
|
|
761
|
+
# sources) keeps exact-glob semantics, so an existing exact-match
|
|
762
|
+
# allowlist is never silently broadened into a substring match
|
|
763
|
+
# (codex P1 fail-open regression).
|
|
764
|
+
if key.casefold() in _TEXT_CONTENT_CONDITION_KEYS and not any(
|
|
765
|
+
c in spat for c in "*?["
|
|
766
|
+
):
|
|
767
|
+
if spat not in sval:
|
|
768
|
+
return False
|
|
769
|
+
elif not _fn.fnmatchcase(sval, spat):
|
|
696
770
|
return False
|
|
697
771
|
return True
|
|
698
772
|
|
|
@@ -836,8 +910,58 @@ def _glob_any(patterns: list[str], value: str) -> bool:
|
|
|
836
910
|
|
|
837
911
|
fnmatchcase is case-sensitive (matches policy semantics) and supports the
|
|
838
912
|
same syntax users learn from .gitignore: *, ?, [seq], [!seq].
|
|
913
|
+
|
|
914
|
+
NOTE: this stays case-sensitive and is used for the resource / client /
|
|
915
|
+
project selector gates. Action matching uses :func:`_action_glob_any`,
|
|
916
|
+
which case-folds the tool segment (see below).
|
|
839
917
|
"""
|
|
840
918
|
for p in patterns:
|
|
841
919
|
if fnmatch.fnmatchcase(value, p):
|
|
842
920
|
return True
|
|
843
921
|
return False
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def _norm_action_tool(s: str) -> str:
|
|
925
|
+
"""Case-fold ONLY the tool segment of an action token (the part before the
|
|
926
|
+
first ``:``); leave the method/arg glob untouched.
|
|
927
|
+
|
|
928
|
+
The canonical action the SDK emits is capital-tool (e.g. ``Bash:rm``, the
|
|
929
|
+
canonical TOOL NAME from the extractor spec), but a user or a policy
|
|
930
|
+
template may author the tool in any case (``bash:*``). The tool name is a
|
|
931
|
+
fixed, known vocabulary, so case is never meaningful for it; folding it lets
|
|
932
|
+
a user-authored rule reconcile with the call the agent actually makes. The
|
|
933
|
+
METHOD stays case-sensitive so method-cased semantics (e.g. ``database:DROP``
|
|
934
|
+
vs ``database:drop``) are preserved. A token with no ``:`` (e.g. the
|
|
935
|
+
universal ``*``) is folded whole, a no-op for glob metacharacters.
|
|
936
|
+
"""
|
|
937
|
+
tool, sep, method = s.partition(":")
|
|
938
|
+
return (tool.casefold() + sep + method) if sep else s.casefold()
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def _action_glob_any(patterns: list[str], action: str) -> bool:
|
|
942
|
+
"""Action-gate matcher: like :func:`_glob_any` but case-INSENSITIVE on the
|
|
943
|
+
tool segment of both the rule pattern and the action (see
|
|
944
|
+
:func:`_norm_action_tool`).
|
|
945
|
+
|
|
946
|
+
This is what makes a deny rule authored as ``bash:*`` actually match the
|
|
947
|
+
canonical ``Bash:rm`` (#1011 follow-up: a customer's deny rule silently
|
|
948
|
+
never fired because the matcher compared the tool case-sensitively).
|
|
949
|
+
"""
|
|
950
|
+
a = _norm_action_tool(action)
|
|
951
|
+
for p in patterns:
|
|
952
|
+
if fnmatch.fnmatchcase(a, _norm_action_tool(p)):
|
|
953
|
+
return True
|
|
954
|
+
return False
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
# The ONE condition key whose plain (metachar-free) pattern is matched by
|
|
958
|
+
# CONTAINS (substring): `input_text`. It is the key the dashboard/templates
|
|
959
|
+
# author for "the command/input contains X", and a user typing `rm -rf` means
|
|
960
|
+
# "contains rm -rf", not "IS exactly rm -rf". Patterns WITH glob metachars still
|
|
961
|
+
# use fnmatch. This set is deliberately NARROW: the raw payload keys (command,
|
|
962
|
+
# input_payload, prompt, ...) are seeding SOURCES for input_text, not author-
|
|
963
|
+
# facing condition keys. Briefly including them here broadened any existing
|
|
964
|
+
# exact-match allowlist keyed on `command` into a substring match, a fail-open
|
|
965
|
+
# regression (codex P1). Every key except `input_text` keeps exact-glob
|
|
966
|
+
# semantics. (#1011 follow-up.)
|
|
967
|
+
_TEXT_CONTENT_CONDITION_KEYS = frozenset({"input_text"})
|
|
@@ -69,15 +69,22 @@ _OUTPUT_EVENT_NAMES = frozenset(
|
|
|
69
69
|
# in extraction priority order. The whole tool_input dict is the fallback when
|
|
70
70
|
# none match (rendered as canonical JSON so the capture is still faithful).
|
|
71
71
|
_INPUT_TEXT_KEYS = (
|
|
72
|
-
"command",
|
|
73
|
-
"CommandLine",
|
|
74
|
-
"sql",
|
|
75
|
-
"query",
|
|
76
|
-
"prompt",
|
|
77
|
-
"content",
|
|
78
|
-
"new_string",
|
|
79
|
-
"text",
|
|
80
|
-
"url",
|
|
72
|
+
"command", # Bash / run_command
|
|
73
|
+
"CommandLine", # Antigravity run_command
|
|
74
|
+
"sql", # database
|
|
75
|
+
"query", # search / http
|
|
76
|
+
"prompt", # prompt-style tools
|
|
77
|
+
"content", # Write / file content
|
|
78
|
+
"new_string", # Edit
|
|
79
|
+
"text", # generic text tools
|
|
80
|
+
"url", # http / browser
|
|
81
|
+
# File-target paths (#1011): so a condition like `input_text contains .env`
|
|
82
|
+
# on a Read/Write/Edit fires on the PATH being touched. Lower priority than
|
|
83
|
+
# the command/content keys above (a tool rarely carries both; if it does the
|
|
84
|
+
# executed text wins). These also enrich the audit input_payload.
|
|
85
|
+
"file_path", # Read / Write / Edit
|
|
86
|
+
"path", # Glob / Grep / LS
|
|
87
|
+
"notebook_path", # NotebookEdit
|
|
81
88
|
)
|
|
82
89
|
|
|
83
90
|
# tool_response / tool_output field shapes a host may deliver on a PostToolUse
|
|
@@ -484,6 +484,58 @@ def _validate_and_translate(data: dict, source_label: str) -> ParsedPolicy:
|
|
|
484
484
|
if bad:
|
|
485
485
|
continue
|
|
486
486
|
|
|
487
|
+
# #1011 follow-up (codex P1): validate the conditions SHAPE explicitly.
|
|
488
|
+
# The old `raw.get("when") or raw.get("conditions") or {}` coerced a
|
|
489
|
+
# falsy-but-malformed conditions ([], "", 0, False) to {} == "no
|
|
490
|
+
# conditions", silently turning a conditional allow into an UNCONDITIONAL
|
|
491
|
+
# allow. `when` is the friendly alias for `conditions`; a rule normally
|
|
492
|
+
# sets only one. Validate each present value's SHAPE first (any non-dict
|
|
493
|
+
# is a clear load error, fail-closed), THEN pick with the historical
|
|
494
|
+
# truthy-first precedence (a non-empty `when` wins, else `conditions`,
|
|
495
|
+
# else none) so a degenerate `when: {}` does not shadow a real
|
|
496
|
+
# `conditions` (codex re-review regression).
|
|
497
|
+
_when_raw = raw.get("when")
|
|
498
|
+
_cond_raw = raw.get("conditions")
|
|
499
|
+
_cond_bad = False
|
|
500
|
+
for _cond_name, _cond_val in (("when", _when_raw), ("conditions", _cond_raw)):
|
|
501
|
+
if _cond_val is not None and not isinstance(_cond_val, dict):
|
|
502
|
+
errors.append(
|
|
503
|
+
f"rules[{i}]: '{_cond_name}' must be a mapping of "
|
|
504
|
+
"{field: pattern}"
|
|
505
|
+
)
|
|
506
|
+
_cond_bad = True
|
|
507
|
+
if _cond_bad:
|
|
508
|
+
continue
|
|
509
|
+
conditions = (_when_raw or _cond_raw) or {}
|
|
510
|
+
|
|
511
|
+
# #1495 (codex P1): a tagged-envelope condition with an UNKNOWN operator
|
|
512
|
+
# must be a LOAD error, not a silent runtime NO-MATCH. At runtime an
|
|
513
|
+
# unknown op returns no-match, which for a DENY rule under default-allow
|
|
514
|
+
# is a fail-OPEN (a typo'd `{op:"contaisn"}` deny silently never fires).
|
|
515
|
+
# Reject it here so the policy fails closed (will not load -> deny-all),
|
|
516
|
+
# mirroring the backend publish-time validator.
|
|
517
|
+
if isinstance(conditions, dict):
|
|
518
|
+
from controlzero._internal.condition_ops import (
|
|
519
|
+
KNOWN_OPS,
|
|
520
|
+
RESERVED_STRUCTURED_KEYS,
|
|
521
|
+
canonicalize_op,
|
|
522
|
+
is_envelope,
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
for _ck, _cv in conditions.items():
|
|
526
|
+
if (
|
|
527
|
+
_ck not in RESERVED_STRUCTURED_KEYS
|
|
528
|
+
and is_envelope(_cv)
|
|
529
|
+
and canonicalize_op(_cv["op"]) not in KNOWN_OPS
|
|
530
|
+
):
|
|
531
|
+
errors.append(
|
|
532
|
+
f"rules[{i}]: condition '{_ck}' uses unknown operator "
|
|
533
|
+
f"'{_cv['op']}'"
|
|
534
|
+
)
|
|
535
|
+
_cond_bad = True
|
|
536
|
+
if _cond_bad:
|
|
537
|
+
continue
|
|
538
|
+
|
|
487
539
|
out.append(
|
|
488
540
|
PolicyRule(
|
|
489
541
|
id=str(raw.get("id", "")),
|
|
@@ -491,7 +543,7 @@ def _validate_and_translate(data: dict, source_label: str) -> ParsedPolicy:
|
|
|
491
543
|
effect=effect,
|
|
492
544
|
actions=actions,
|
|
493
545
|
resources=resources,
|
|
494
|
-
conditions=
|
|
546
|
+
conditions=conditions,
|
|
495
547
|
reason=str(raw.get("reason", "")),
|
|
496
548
|
# Machine-readable reason code. Only synthetic rules
|
|
497
549
|
# (e.g. the empty-bundle NO_ACTIVE_POLICIES deny
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "controlzero"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.13.0"
|
|
8
8
|
description = "AI agent governance: policies, audit, and observability for tool calls. Works locally with no signup."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "Apache-2.0"}
|
|
@@ -384,9 +384,12 @@ class TestEdgeCases:
|
|
|
384
384
|
canonical name via the shared extractor spec. ``bash`` is an
|
|
385
385
|
explicit alias for ``Bash`` and so IS caught by
|
|
386
386
|
``deny Bash`` -- that is exactly the fix the spec ships.
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
case-
|
|
387
|
+
|
|
388
|
+
As of #1011 the action gate matches the TOOL segment
|
|
389
|
+
case-INSENSITIVELY (canonical tool names are a fixed vocabulary
|
|
390
|
+
with no case-collisions, so a deny rule must catch every case
|
|
391
|
+
variant; otherwise a host emitting ``BASH`` would silently
|
|
392
|
+
bypass ``deny Bash``). The method/arg glob stays case-sensitive.
|
|
390
393
|
"""
|
|
391
394
|
p = write_policy(tmp_path, [
|
|
392
395
|
{"deny": "Bash", "reason": "Only exact Bash"},
|
|
@@ -406,13 +409,13 @@ class TestEdgeCases:
|
|
|
406
409
|
result = runner.invoke(cli, ["hook-check", "--policy", str(p)], input=payload)
|
|
407
410
|
assert result.exit_code == 2, "'bash' is a Bash alias and should match deny 'Bash'"
|
|
408
411
|
|
|
409
|
-
# Uppercase "BASH" is
|
|
410
|
-
#
|
|
411
|
-
#
|
|
412
|
-
#
|
|
412
|
+
# Uppercase "BASH" is a case variant of the canonical Bash tool.
|
|
413
|
+
# As of #1011 the action gate folds the tool-segment case, so a
|
|
414
|
+
# deny rule for Bash catches every case variant, closing the
|
|
415
|
+
# bypass where a host emitting "BASH" would slip past "deny Bash".
|
|
413
416
|
payload = json.dumps({"tool_name": "BASH", "tool_input": {}})
|
|
414
417
|
result = runner.invoke(cli, ["hook-check", "--policy", str(p)], input=payload)
|
|
415
|
-
assert result.exit_code ==
|
|
418
|
+
assert result.exit_code == 2, "'BASH' is a case variant of Bash and must be caught by deny 'Bash' (#1011)"
|
|
416
419
|
|
|
417
420
|
def test_20_mcp_glob_pattern(self, tmp_path):
|
|
418
421
|
"""Glob: 'mcp__*__delete_*' matches 'mcp__database__delete_table'."""
|