controlzero 1.5.8__tar.gz → 1.6.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.5.8 → controlzero-1.6.0}/.gitignore +3 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/CHANGELOG.md +70 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/PKG-INFO +150 -1
- {controlzero-1.5.8 → controlzero-1.6.0}/README.md +148 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/__init__.py +3 -1
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/_internal/bundle.py +129 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/_internal/enforcer.py +85 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/_internal/types.py +14 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/audit_remote.py +129 -72
- controlzero-1.6.0/controlzero/canonical.py +108 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/cli/main.py +168 -4
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/client.py +591 -6
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/error_codes.py +157 -0
- controlzero-1.6.0/controlzero/errors.py +427 -0
- controlzero-1.6.0/controlzero/hitl/__init__.py +43 -0
- controlzero-1.6.0/controlzero/hitl/mock.py +185 -0
- controlzero-1.6.0/controlzero/hitl/pending_approval.py +450 -0
- controlzero-1.6.0/controlzero/hitl/secret_leak_guard.py +218 -0
- controlzero-1.6.0/controlzero/hitl/status.py +46 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/hosted_policy.py +11 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/policy_loader.py +41 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/pyproject.toml +8 -1
- controlzero-1.6.0/tests/_fixtures/jcs_args_hash_vectors.json +111 -0
- controlzero-1.6.0/tests/test_canonical_phase1a.py +229 -0
- controlzero-1.6.0/tests/test_engine_version_consistency.py +82 -0
- controlzero-1.6.0/tests/test_hitl_5d_email_install.py +137 -0
- controlzero-1.6.0/tests/test_hitl_6a_cli_flag.py +307 -0
- controlzero-1.6.0/tests/test_hitl_6a_exceptions.py +166 -0
- controlzero-1.6.0/tests/test_hitl_6a_get_secret_hitl.py +672 -0
- controlzero-1.6.0/tests/test_hitl_6a_mock_backend.py +343 -0
- controlzero-1.6.0/tests/test_hitl_6a_pending_approval.py +352 -0
- controlzero-1.6.0/tests/test_hitl_6a_request_approval.py +668 -0
- controlzero-1.6.0/tests/test_hitl_6a_secret_leak_guard.py +327 -0
- controlzero-1.6.0/tests/test_hitl_6a_wait.py +586 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_hitl_validator_keys.py +6 -0
- controlzero-1.6.0/tests/test_min_sdk_version_gate.py +206 -0
- controlzero-1.6.0/tests/test_multi_client_per_project_175.py +807 -0
- controlzero-1.6.0/tests/test_policy_engine_version_phase1b.py +73 -0
- controlzero-1.6.0/tests/test_unsafe_int_boundary.py +67 -0
- controlzero-1.5.8/controlzero/errors.py +0 -181
- {controlzero-1.5.8 → controlzero-1.6.0}/Dockerfile.test +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/LICENSE +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/_internal/__init__.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/_internal/action_aliases.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/_internal/dlp_scanner.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/_internal/hook_extractors.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/_internal/tool_extractors.json +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/audit_local.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/cli/__init__.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/cli/_secrets.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/cli/console.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/cli/debug_bundle.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/cli/doctor.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/cli/hosts/__init__.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/cli/hosts/base.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/cli/hosts/claude_code.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/cli/hosts/codex_cli.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/cli/hosts/gemini_cli.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/cli/hosts/unknown.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/cli/migrate.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/cli/telemetry_consent.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/cli/templates/autogen.yaml +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/cli/templates/claude-code.yaml +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/cli/templates/codex-cli.yaml +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/cli/templates/cost-cap.yaml +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/cli/templates/crewai.yaml +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/cli/templates/cursor.yaml +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/cli/templates/gemini-cli.yaml +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/cli/templates/generic.yaml +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/cli/templates/langchain.yaml +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/cli/templates/mcp.yaml +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/cli/templates/rag.yaml +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/device.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/enrollment.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/integrations/__init__.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/integrations/anthropic.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/integrations/autogen.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/integrations/braintrust.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/integrations/crewai/__init__.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/integrations/crewai/agent.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/integrations/crewai/crew.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/integrations/crewai/task.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/integrations/crewai/tool.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/integrations/google.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/integrations/google_adk/__init__.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/integrations/google_adk/agent.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/integrations/google_adk/tool.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/integrations/langchain/__init__.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/integrations/langchain/agent.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/integrations/langchain/callbacks.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/integrations/langchain/chain.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/integrations/langchain/graph.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/integrations/langchain/modern.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/integrations/langchain/tool.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/integrations/langfuse.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/integrations/litellm.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/integrations/openai.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/integrations/pydantic_ai.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/integrations/vercel_ai.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/layout_migration.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/controlzero/tamper.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/examples/hello_world.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/conftest.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/integrations/__init__.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/integrations/test_google.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/parity/action_aliases.json +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_action_aliases.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_action_canonicalization.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_agent_name_env.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_api_key_mask.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_audit_remote.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_audit_remote_sdk_version.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_audit_sink_isolation.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_bundle_parser.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_bundle_translate.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_cli_carve_out.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_cli_debug_bundle.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_cli_extractor_integration.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_cli_hook.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_cli_hosted_refresh.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_cli_init.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_cli_init_templates.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_cli_tail.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_cli_test.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_cli_validate.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_coding_agent_hooks.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_conditions.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_console.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_default_action.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_device.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_dlp_scanner.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_doctor.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_enrollment.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_env_dump_438.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_error_codes.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_errors_e_codes.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_fail_closed_eval.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_glob_matching.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_hitl_reason_codes.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_hook_extractors.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_hosted_policy_e2e.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_hosts_adapter.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_hybrid_mode_strict.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_hybrid_mode_warn.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_install_hook_command.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_install_hooks.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_layout_migration_t101.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_layout_parity_t102.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_local_mode_dict.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_local_mode_file_json.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_local_mode_file_yaml.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_log_fallback_stderr.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_log_options_ignored_hosted.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_log_rotation.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_migrate.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_no_policy_no_key.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_package_rename_shim.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_policy_freshness.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_policy_settings.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_quarantine.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_reason_code.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_refresh.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_secrets.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_sql_semantic_class.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_synthetic_policy_id_t79.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_t103_precedence.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_t104_cache_gc.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_t108_local_override_audit.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_t96_single_audit_log.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_t99_install_prefetch_bundle.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_tamper.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_tamper_behavior.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_tamper_hook.py +0 -0
- {controlzero-1.5.8 → controlzero-1.6.0}/tests/test_telemetry_consent.py +0 -0
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# Worktrees
|
|
2
2
|
.worktrees/
|
|
3
3
|
|
|
4
|
+
# Preflight state (drift-confirmed marker etc.). Local-only, never committed.
|
|
5
|
+
.preflight-state/
|
|
6
|
+
|
|
4
7
|
# Transient QA / design captures at repo root.
|
|
5
8
|
# Long-term reference screenshots live under docs-site/static/ or in Obsidian.
|
|
6
9
|
/*.png
|
|
@@ -1,5 +1,75 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v1.6.0 -- 2026-05-17 (HITL-6a, gh#542)
|
|
4
|
+
|
|
5
|
+
First minor that turns the Human-in-the-Loop approval workflow on. 1.5.8
|
|
6
|
+
prepared the field (escalate_on_deny acknowledged, HITL reason codes
|
|
7
|
+
registered, validator additive); 1.6.0 ships the surface area: 11 exception
|
|
8
|
+
codes, a PendingApproval dataclass with sync + async wait, an in-process
|
|
9
|
+
mock backend, the request_approval HTTP path, the CLI test flag, and the
|
|
10
|
+
secret-value-leak guard that gates every outbound HITL payload.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **11 HITL exception classes** (E1701-E1711) -- `HITLTimeoutError`,
|
|
15
|
+
`HITLBackendUnreachableError`, `HITLPolicyVersionConflictError`,
|
|
16
|
+
`HITLNotConfiguredError`, `HITLNoApproverAvailable`,
|
|
17
|
+
`HITLIdentityNotInOrg`, `HITLIdentityRequired`,
|
|
18
|
+
`HITLIdentityClaimRejected`, `SecretValueLeakInPayload`,
|
|
19
|
+
`SecretApprovalRequired`, `SecretNotFound`. Inheritance is chosen so
|
|
20
|
+
existing `except PolicyDeniedError` blocks treat HITL timeouts and
|
|
21
|
+
approver-pool failures identically to a static deny. (#571)
|
|
22
|
+
- **`controlzero.hitl` subpackage** with the `PendingApproval` dataclass
|
|
23
|
+
and `Status` enum. `PendingApproval` carries `request_id`,
|
|
24
|
+
`idempotency_key`, `status`, `created_at`, `expires_at`,
|
|
25
|
+
`decision_kind`, and `decided_by`; exposes `is_terminal()` and
|
|
26
|
+
`remaining_s()` for non-blocking checks. (#574)
|
|
27
|
+
- **`MockApprovalBackend`** -- in-process fake of the two HITL endpoints
|
|
28
|
+
(`POST /api/approval-requests`, `GET /api/approval-requests/{id}`)
|
|
29
|
+
with five deterministic modes: `approve_after_2s`,
|
|
30
|
+
`approve_timed_after_2s`, `approve_forever_after_2s`,
|
|
31
|
+
`deny_after_2s`, and `timeout`. Thread-safe; one instance can serve
|
|
32
|
+
many concurrent pollers. (#572)
|
|
33
|
+
- **Secret-value-leak guard** -- `is_likely_secret_value`,
|
|
34
|
+
`scan_payload_for_secret_leak`, and `raise_on_leak` reject any
|
|
35
|
+
outbound payload whose redacted-args dict still contains a
|
|
36
|
+
secret-shaped string. The check runs inside `request_approval()`
|
|
37
|
+
before the HTTP send. (#573)
|
|
38
|
+
- **`controlzero test --hitl approve|deny|timeout`** CLI flag drives the
|
|
39
|
+
mock backend end-to-end from a shell, so customers can exercise HITL
|
|
40
|
+
paths without setting up a real approver. (#575)
|
|
41
|
+
- **`PendingApproval.wait()` and `wait_async()`** -- polling loop with
|
|
42
|
+
jittered exponential backoff, capped at the dataclass's `expires_at`.
|
|
43
|
+
Honors a user-supplied `poll_fn` for tests; defaults to the SDK's
|
|
44
|
+
HTTP poller for production. Raises `HITLTimeoutError` on SLA expiry
|
|
45
|
+
and `HITLBackendUnreachableError` on network failure. (#576)
|
|
46
|
+
- **`Client.request_approval(decision, message=..., timeout_s=...)`** --
|
|
47
|
+
POSTs a HITL approval request, builds the body from the
|
|
48
|
+
`PolicyDecision`, threads in `X-CZ-Requestor-Email` from
|
|
49
|
+
`~/.controlzero/config.yaml`, sends a per-call `Idempotency-Key`, and
|
|
50
|
+
returns a `PendingApproval`. Maps backend error codes E1305-E1308 onto
|
|
51
|
+
the matching SDK exceptions. (#577)
|
|
52
|
+
|
|
53
|
+
### Conformance
|
|
54
|
+
|
|
55
|
+
The 49 HITL/secret vectors added to `tests/parity/decisions.json` in
|
|
56
|
+
HITL-3 (gh#536) target this release: `min_sdk_version: "1.6.0"` and
|
|
57
|
+
`requires_backend: true` flags gate them. A Python conformance runner
|
|
58
|
+
that executes those vectors against the SDK is planned for phase 3 of
|
|
59
|
+
issue #409; until that lands, this release is validated against the
|
|
60
|
+
HITL-6a unit tests (155 new test cases across the seven slices) plus
|
|
61
|
+
the pre-existing 156 SDK hook tests.
|
|
62
|
+
|
|
63
|
+
### Unchanged
|
|
64
|
+
|
|
65
|
+
- 11-line Hello World still works.
|
|
66
|
+
- No breaking changes to existing public API: `Client.guard()`,
|
|
67
|
+
`Client.refresh()`, `load_policy()` all behave identically.
|
|
68
|
+
- `escalate_on_deny` still defaults to `False`; policies without HITL
|
|
69
|
+
tags continue to run with zero HITL overhead.
|
|
70
|
+
- Local-mode users pay no HITL cost: imports are lazy, no HTTP client
|
|
71
|
+
is constructed unless `request_approval()` is called.
|
|
72
|
+
|
|
3
73
|
## v1.5.8 -- 2026-05-16 (HITL-5a, gh#538)
|
|
4
74
|
|
|
5
75
|
Additive minor preparing the SDK for the Human-in-the-Loop approval
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: controlzero
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.6.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
|
|
@@ -28,6 +28,7 @@ Requires-Dist: httpx>=0.25.0
|
|
|
28
28
|
Requires-Dist: loguru>=0.7.0
|
|
29
29
|
Requires-Dist: pydantic>=2.0.0
|
|
30
30
|
Requires-Dist: pyyaml>=6.0
|
|
31
|
+
Requires-Dist: rfc8785<0.2,>=0.1.4
|
|
31
32
|
Requires-Dist: rich>=13.0.0
|
|
32
33
|
Requires-Dist: zstandard>=0.22.0
|
|
33
34
|
Provides-Extra: anthropic
|
|
@@ -299,6 +300,154 @@ from controlzero import Client
|
|
|
299
300
|
cz = Client() # picks up the API key from env, audit ships remote
|
|
300
301
|
```
|
|
301
302
|
|
|
303
|
+
## Human-in-the-Loop approvals
|
|
304
|
+
|
|
305
|
+
Approvals let a policy block a tool call until a human approver decides allow or deny.
|
|
306
|
+
An agent calls `client.request_approval(decision, ...)` whenever `guard()` returns a
|
|
307
|
+
deny that is tagged `escalate_on_deny: true`, then waits on the returned
|
|
308
|
+
`PendingApproval` for the human to respond.
|
|
309
|
+
|
|
310
|
+
Basic flow:
|
|
311
|
+
|
|
312
|
+
```python
|
|
313
|
+
from controlzero import Client
|
|
314
|
+
|
|
315
|
+
cz = Client(api_key="cz_live_...") # approvals require hosted mode
|
|
316
|
+
|
|
317
|
+
decision = cz.guard("delete_file", {"path": "/etc/passwd"})
|
|
318
|
+
if decision.decision == "deny" and getattr(decision, "hitl_eligible", False):
|
|
319
|
+
pending = cz.request_approval(
|
|
320
|
+
decision,
|
|
321
|
+
message="agent wants to delete /etc/passwd; please confirm",
|
|
322
|
+
timeout_s=300,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# PendingApproval.wait() requires you to inject a `poll_fn`,
|
|
326
|
+
# a callable that returns the latest backend snapshot of the
|
|
327
|
+
# approval request. In 1.6.0 the SDK does NOT ship a built-in
|
|
328
|
+
# HTTP poller; you wire one yourself (or use the helper that
|
|
329
|
+
# ships with get_secret. See "Secret reads with approvals" below).
|
|
330
|
+
import httpx
|
|
331
|
+
api_url = "https://api.controlzero.ai" # or your self-managed host
|
|
332
|
+
|
|
333
|
+
def poll_fn(request_id: str) -> dict:
|
|
334
|
+
resp = httpx.get(
|
|
335
|
+
f"{api_url}/api/approval-requests/{request_id}",
|
|
336
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
337
|
+
timeout=10,
|
|
338
|
+
)
|
|
339
|
+
resp.raise_for_status()
|
|
340
|
+
return resp.json()
|
|
341
|
+
|
|
342
|
+
# Block until the human approves, denies, or the SLA expires.
|
|
343
|
+
resolved = pending.wait(poll_fn)
|
|
344
|
+
if resolved.status == "approved":
|
|
345
|
+
# proceed with the gated action
|
|
346
|
+
...
|
|
347
|
+
else:
|
|
348
|
+
# denied or timed_out, abort the tool call
|
|
349
|
+
...
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
`wait()` blocks the calling thread. For async code, use `wait_async()`,
|
|
353
|
+
same contract, but the poll callable can be `async def` or a sync
|
|
354
|
+
function (sync calls are dispatched to a thread executor so the event
|
|
355
|
+
loop never blocks):
|
|
356
|
+
|
|
357
|
+
```python
|
|
358
|
+
async def async_poll(request_id: str) -> dict:
|
|
359
|
+
async with httpx.AsyncClient() as client:
|
|
360
|
+
resp = await client.get(
|
|
361
|
+
f"{api_url}/api/approval-requests/{request_id}",
|
|
362
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
363
|
+
timeout=10,
|
|
364
|
+
)
|
|
365
|
+
resp.raise_for_status()
|
|
366
|
+
return resp.json()
|
|
367
|
+
|
|
368
|
+
resolved = await pending.wait_async(async_poll)
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
A built-in HTTP poller is planned for 1.7.0.
|
|
372
|
+
|
|
373
|
+
### Mock backend for tests
|
|
374
|
+
|
|
375
|
+
The SDK ships an in-process `MockApprovalBackend` so tests can exercise approval paths
|
|
376
|
+
without standing up the real backend. Wire it into the polling loop by passing
|
|
377
|
+
`poll_fn`:
|
|
378
|
+
|
|
379
|
+
```python
|
|
380
|
+
from controlzero import PendingApproval
|
|
381
|
+
from controlzero.hitl.mock import MockApprovalBackend
|
|
382
|
+
|
|
383
|
+
backend = MockApprovalBackend("approve_after_2s", delay_s=0.05)
|
|
384
|
+
created = backend.create_request({"canonical_action": "delete_file"})
|
|
385
|
+
pending = PendingApproval(
|
|
386
|
+
request_id=created["request_id"],
|
|
387
|
+
idempotency_key="test-key",
|
|
388
|
+
status="pending",
|
|
389
|
+
created_at=created["created_at"],
|
|
390
|
+
expires_at=created["expires_at"],
|
|
391
|
+
)
|
|
392
|
+
resolved = pending.wait(poll_fn=lambda rid: backend.get_request(rid))
|
|
393
|
+
assert resolved.status == "approved"
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
The five supported modes are `approve_after_2s`, `approve_timed_after_2s`,
|
|
397
|
+
`approve_forever_after_2s`, `deny_after_2s`, and `timeout`.
|
|
398
|
+
|
|
399
|
+
### Identity requirement
|
|
400
|
+
|
|
401
|
+
Every approval request must carry the operator email so the backend can route to a
|
|
402
|
+
real person and stamp identity provenance on the grant. Set it once via the CLI:
|
|
403
|
+
|
|
404
|
+
```bash
|
|
405
|
+
controlzero install <agent> --email you@example.com
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
If the email is missing, `request_approval()` raises `HITLIdentityRequired`
|
|
409
|
+
(E1707) before any HTTP traffic.
|
|
410
|
+
|
|
411
|
+
### Secret reads with approvals
|
|
412
|
+
|
|
413
|
+
When a policy gates a secret behind approval, `client.get_secret(name)` raises
|
|
414
|
+
`SecretApprovalRequired` (E1710) carrying a `pending` attribute the caller waits
|
|
415
|
+
on:
|
|
416
|
+
|
|
417
|
+
```python
|
|
418
|
+
from controlzero.errors import SecretApprovalRequired
|
|
419
|
+
|
|
420
|
+
try:
|
|
421
|
+
value = cz.get_secret("PROD_DB_PASSWORD")
|
|
422
|
+
except SecretApprovalRequired as exc:
|
|
423
|
+
resolved = exc.pending.wait()
|
|
424
|
+
if resolved.status == "approved":
|
|
425
|
+
value = cz.get_secret("PROD_DB_PASSWORD") # retry now that grant exists
|
|
426
|
+
else:
|
|
427
|
+
raise # abort
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
### Exception classes
|
|
431
|
+
|
|
432
|
+
The 11 approval-related exception codes raised by this surface. Class names retain
|
|
433
|
+
the `HITL` prefix because they are part of the stable public SDK API:
|
|
434
|
+
|
|
435
|
+
| Code | Class | Meaning |
|
|
436
|
+
| ----- | -------------------------------- | ---------------------------------------------------------- |
|
|
437
|
+
| E1701 | `HITLTimeoutError` | Approver did not decide before `timeout_s` elapsed. |
|
|
438
|
+
| E1702 | `HITLBackendUnreachableError` | POST to the approval endpoint failed after retries. |
|
|
439
|
+
| E1703 | `HITLPolicyVersionConflictError` | SDK bundle is missing the rule that triggered the request. |
|
|
440
|
+
| E1704 | `HITLNotConfiguredError` | Org has no approval settings row configured. |
|
|
441
|
+
| E1705 | `HITLNoApproverAvailable` | Approver pool is empty or no member is active. |
|
|
442
|
+
| E1706 | `HITLIdentityNotInOrg` | Operator email is not a member of the API key's org. |
|
|
443
|
+
| E1707 | `HITLIdentityRequired` | No operator email set on this install. |
|
|
444
|
+
| E1708 | `HITLIdentityClaimRejected` | Backend rejected the identity claim. |
|
|
445
|
+
| E1709 | `SecretValueLeakInPayload` | Outbound payload contains a secret-shaped string. Aborted. |
|
|
446
|
+
| E1710 | `SecretApprovalRequired` | Secret read requires approval; wait on `exc.pending`. |
|
|
447
|
+
| E1711 | `SecretNotFound` | Named secret does not exist in the configured vault. |
|
|
448
|
+
|
|
449
|
+
Full reference and runbooks: [docs.controlzero.ai/hitl](https://docs.controlzero.ai/hitl).
|
|
450
|
+
|
|
302
451
|
## License
|
|
303
452
|
|
|
304
453
|
Apache 2.0
|
|
@@ -246,6 +246,154 @@ from controlzero import Client
|
|
|
246
246
|
cz = Client() # picks up the API key from env, audit ships remote
|
|
247
247
|
```
|
|
248
248
|
|
|
249
|
+
## Human-in-the-Loop approvals
|
|
250
|
+
|
|
251
|
+
Approvals let a policy block a tool call until a human approver decides allow or deny.
|
|
252
|
+
An agent calls `client.request_approval(decision, ...)` whenever `guard()` returns a
|
|
253
|
+
deny that is tagged `escalate_on_deny: true`, then waits on the returned
|
|
254
|
+
`PendingApproval` for the human to respond.
|
|
255
|
+
|
|
256
|
+
Basic flow:
|
|
257
|
+
|
|
258
|
+
```python
|
|
259
|
+
from controlzero import Client
|
|
260
|
+
|
|
261
|
+
cz = Client(api_key="cz_live_...") # approvals require hosted mode
|
|
262
|
+
|
|
263
|
+
decision = cz.guard("delete_file", {"path": "/etc/passwd"})
|
|
264
|
+
if decision.decision == "deny" and getattr(decision, "hitl_eligible", False):
|
|
265
|
+
pending = cz.request_approval(
|
|
266
|
+
decision,
|
|
267
|
+
message="agent wants to delete /etc/passwd; please confirm",
|
|
268
|
+
timeout_s=300,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
# PendingApproval.wait() requires you to inject a `poll_fn`,
|
|
272
|
+
# a callable that returns the latest backend snapshot of the
|
|
273
|
+
# approval request. In 1.6.0 the SDK does NOT ship a built-in
|
|
274
|
+
# HTTP poller; you wire one yourself (or use the helper that
|
|
275
|
+
# ships with get_secret. See "Secret reads with approvals" below).
|
|
276
|
+
import httpx
|
|
277
|
+
api_url = "https://api.controlzero.ai" # or your self-managed host
|
|
278
|
+
|
|
279
|
+
def poll_fn(request_id: str) -> dict:
|
|
280
|
+
resp = httpx.get(
|
|
281
|
+
f"{api_url}/api/approval-requests/{request_id}",
|
|
282
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
283
|
+
timeout=10,
|
|
284
|
+
)
|
|
285
|
+
resp.raise_for_status()
|
|
286
|
+
return resp.json()
|
|
287
|
+
|
|
288
|
+
# Block until the human approves, denies, or the SLA expires.
|
|
289
|
+
resolved = pending.wait(poll_fn)
|
|
290
|
+
if resolved.status == "approved":
|
|
291
|
+
# proceed with the gated action
|
|
292
|
+
...
|
|
293
|
+
else:
|
|
294
|
+
# denied or timed_out, abort the tool call
|
|
295
|
+
...
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
`wait()` blocks the calling thread. For async code, use `wait_async()`,
|
|
299
|
+
same contract, but the poll callable can be `async def` or a sync
|
|
300
|
+
function (sync calls are dispatched to a thread executor so the event
|
|
301
|
+
loop never blocks):
|
|
302
|
+
|
|
303
|
+
```python
|
|
304
|
+
async def async_poll(request_id: str) -> dict:
|
|
305
|
+
async with httpx.AsyncClient() as client:
|
|
306
|
+
resp = await client.get(
|
|
307
|
+
f"{api_url}/api/approval-requests/{request_id}",
|
|
308
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
309
|
+
timeout=10,
|
|
310
|
+
)
|
|
311
|
+
resp.raise_for_status()
|
|
312
|
+
return resp.json()
|
|
313
|
+
|
|
314
|
+
resolved = await pending.wait_async(async_poll)
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
A built-in HTTP poller is planned for 1.7.0.
|
|
318
|
+
|
|
319
|
+
### Mock backend for tests
|
|
320
|
+
|
|
321
|
+
The SDK ships an in-process `MockApprovalBackend` so tests can exercise approval paths
|
|
322
|
+
without standing up the real backend. Wire it into the polling loop by passing
|
|
323
|
+
`poll_fn`:
|
|
324
|
+
|
|
325
|
+
```python
|
|
326
|
+
from controlzero import PendingApproval
|
|
327
|
+
from controlzero.hitl.mock import MockApprovalBackend
|
|
328
|
+
|
|
329
|
+
backend = MockApprovalBackend("approve_after_2s", delay_s=0.05)
|
|
330
|
+
created = backend.create_request({"canonical_action": "delete_file"})
|
|
331
|
+
pending = PendingApproval(
|
|
332
|
+
request_id=created["request_id"],
|
|
333
|
+
idempotency_key="test-key",
|
|
334
|
+
status="pending",
|
|
335
|
+
created_at=created["created_at"],
|
|
336
|
+
expires_at=created["expires_at"],
|
|
337
|
+
)
|
|
338
|
+
resolved = pending.wait(poll_fn=lambda rid: backend.get_request(rid))
|
|
339
|
+
assert resolved.status == "approved"
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
The five supported modes are `approve_after_2s`, `approve_timed_after_2s`,
|
|
343
|
+
`approve_forever_after_2s`, `deny_after_2s`, and `timeout`.
|
|
344
|
+
|
|
345
|
+
### Identity requirement
|
|
346
|
+
|
|
347
|
+
Every approval request must carry the operator email so the backend can route to a
|
|
348
|
+
real person and stamp identity provenance on the grant. Set it once via the CLI:
|
|
349
|
+
|
|
350
|
+
```bash
|
|
351
|
+
controlzero install <agent> --email you@example.com
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
If the email is missing, `request_approval()` raises `HITLIdentityRequired`
|
|
355
|
+
(E1707) before any HTTP traffic.
|
|
356
|
+
|
|
357
|
+
### Secret reads with approvals
|
|
358
|
+
|
|
359
|
+
When a policy gates a secret behind approval, `client.get_secret(name)` raises
|
|
360
|
+
`SecretApprovalRequired` (E1710) carrying a `pending` attribute the caller waits
|
|
361
|
+
on:
|
|
362
|
+
|
|
363
|
+
```python
|
|
364
|
+
from controlzero.errors import SecretApprovalRequired
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
value = cz.get_secret("PROD_DB_PASSWORD")
|
|
368
|
+
except SecretApprovalRequired as exc:
|
|
369
|
+
resolved = exc.pending.wait()
|
|
370
|
+
if resolved.status == "approved":
|
|
371
|
+
value = cz.get_secret("PROD_DB_PASSWORD") # retry now that grant exists
|
|
372
|
+
else:
|
|
373
|
+
raise # abort
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
### Exception classes
|
|
377
|
+
|
|
378
|
+
The 11 approval-related exception codes raised by this surface. Class names retain
|
|
379
|
+
the `HITL` prefix because they are part of the stable public SDK API:
|
|
380
|
+
|
|
381
|
+
| Code | Class | Meaning |
|
|
382
|
+
| ----- | -------------------------------- | ---------------------------------------------------------- |
|
|
383
|
+
| E1701 | `HITLTimeoutError` | Approver did not decide before `timeout_s` elapsed. |
|
|
384
|
+
| E1702 | `HITLBackendUnreachableError` | POST to the approval endpoint failed after retries. |
|
|
385
|
+
| E1703 | `HITLPolicyVersionConflictError` | SDK bundle is missing the rule that triggered the request. |
|
|
386
|
+
| E1704 | `HITLNotConfiguredError` | Org has no approval settings row configured. |
|
|
387
|
+
| E1705 | `HITLNoApproverAvailable` | Approver pool is empty or no member is active. |
|
|
388
|
+
| E1706 | `HITLIdentityNotInOrg` | Operator email is not a member of the API key's org. |
|
|
389
|
+
| E1707 | `HITLIdentityRequired` | No operator email set on this install. |
|
|
390
|
+
| E1708 | `HITLIdentityClaimRejected` | Backend rejected the identity claim. |
|
|
391
|
+
| E1709 | `SecretValueLeakInPayload` | Outbound payload contains a secret-shaped string. Aborted. |
|
|
392
|
+
| E1710 | `SecretApprovalRequired` | Secret read requires approval; wait on `exc.pending`. |
|
|
393
|
+
| E1711 | `SecretNotFound` | Named secret does not exist in the configured vault. |
|
|
394
|
+
|
|
395
|
+
Full reference and runbooks: [docs.controlzero.ai/hitl](https://docs.controlzero.ai/hitl).
|
|
396
|
+
|
|
249
397
|
## License
|
|
250
398
|
|
|
251
399
|
Apache 2.0
|
|
@@ -26,12 +26,14 @@ from controlzero.errors import (
|
|
|
26
26
|
PolicyLoadError,
|
|
27
27
|
PolicyValidationError,
|
|
28
28
|
)
|
|
29
|
+
from controlzero.hitl import PendingApproval
|
|
29
30
|
from controlzero.policy_loader import load_policy
|
|
30
31
|
|
|
31
|
-
__version__ = "1.
|
|
32
|
+
__version__ = "1.6.0"
|
|
32
33
|
|
|
33
34
|
__all__ = [
|
|
34
35
|
"Client",
|
|
36
|
+
"PendingApproval",
|
|
35
37
|
"PolicyDecision",
|
|
36
38
|
"PolicyDeniedError",
|
|
37
39
|
"PolicyLoadError",
|
|
@@ -353,6 +353,114 @@ def _zstd_decompress(data: bytes) -> bytes:
|
|
|
353
353
|
)
|
|
354
354
|
|
|
355
355
|
|
|
356
|
+
# --- Version gate (gh#602) -------------------------------------------------
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _parse_version_tuple(v: str) -> tuple:
|
|
360
|
+
"""Parse a SemVer-ish version string into a tuple suitable for
|
|
361
|
+
``<`` comparison. Stdlib only; no `packaging` dep on the hot path.
|
|
362
|
+
|
|
363
|
+
Handles three forms we ship across SDKs:
|
|
364
|
+
|
|
365
|
+
- ``"1.5.8"`` -> ``(1, 5, 8)``
|
|
366
|
+
- ``"v1.7.6"`` -> ``(1, 7, 6)`` (Go SDK tag prefix)
|
|
367
|
+
- ``"1.5.5a1"`` -> ``(1, 5, 5, "a1")`` (PEP 440 prerelease)
|
|
368
|
+
|
|
369
|
+
Pre-release suffixes sort BELOW the release of the same numeric
|
|
370
|
+
triple, matching PEP 440 + SemVer. Unknown / empty segments
|
|
371
|
+
fall back to 0 so a malformed value never raises -- the caller
|
|
372
|
+
interprets "unknown version" as "permissive" (do not block).
|
|
373
|
+
|
|
374
|
+
Returned tuples can be compared with normal Python tuple
|
|
375
|
+
comparison: missing trailing elements are treated as zero
|
|
376
|
+
(we left-pad with zeros to keep comparison stable across
|
|
377
|
+
`"1.5"` vs `"1.5.8"`).
|
|
378
|
+
"""
|
|
379
|
+
if not v:
|
|
380
|
+
# Match the canonical 5-tuple shape so empty inputs compare
|
|
381
|
+
# cleanly against parsed releases (release sentinel = 1, suffix "").
|
|
382
|
+
return (0, 0, 0, 1, "")
|
|
383
|
+
s = v.strip()
|
|
384
|
+
if s.startswith("v") or s.startswith("V"):
|
|
385
|
+
s = s[1:]
|
|
386
|
+
# Split off a PEP 440 / SemVer prerelease suffix on the last numeric
|
|
387
|
+
# component. We only need to recognise the floor (alpha < release),
|
|
388
|
+
# not order alphas relative to each other; tuple ordering with the
|
|
389
|
+
# suffix carried as a string handles the rest.
|
|
390
|
+
nums: list = []
|
|
391
|
+
suffix = ""
|
|
392
|
+
parts = s.split(".")
|
|
393
|
+
for i, p in enumerate(parts):
|
|
394
|
+
# Strip non-numeric tail (e.g. "5a1" -> 5, suffix "a1").
|
|
395
|
+
digits = ""
|
|
396
|
+
for ch in p:
|
|
397
|
+
if ch.isdigit():
|
|
398
|
+
digits += ch
|
|
399
|
+
else:
|
|
400
|
+
break
|
|
401
|
+
try:
|
|
402
|
+
nums.append(int(digits) if digits else 0)
|
|
403
|
+
except ValueError:
|
|
404
|
+
nums.append(0)
|
|
405
|
+
if len(digits) < len(p):
|
|
406
|
+
suffix = p[len(digits):]
|
|
407
|
+
# Drop any further parts -- a suffix on a non-final segment
|
|
408
|
+
# is malformed, treat the rest as absent.
|
|
409
|
+
break
|
|
410
|
+
while len(nums) < 3:
|
|
411
|
+
nums.append(0)
|
|
412
|
+
if suffix:
|
|
413
|
+
# Prerelease sorts BELOW release: pad with a sentinel that
|
|
414
|
+
# compares less than the empty string of a release tuple.
|
|
415
|
+
# Python tuple comparison handles mixed types only if the
|
|
416
|
+
# types match at each index; we add the suffix as a string
|
|
417
|
+
# AND lower the patch number by one fractional step using a
|
|
418
|
+
# second tuple element. Simpler: always emit a final element
|
|
419
|
+
# so release tuples are (M, m, p, "") and prereleases are
|
|
420
|
+
# (M, m, p-1, suffix) which sorts below release naturally.
|
|
421
|
+
#
|
|
422
|
+
# Avoiding the subtract path keeps the function pure and
|
|
423
|
+
# easy to reason about: a prerelease ALWAYS appends a
|
|
424
|
+
# non-empty string; release ALWAYS appends "". Comparison
|
|
425
|
+
# then works because "" < "a1" is False but ("a1",) > ("",)
|
|
426
|
+
# in tuple order -- which is the OPPOSITE of what we want.
|
|
427
|
+
# The conventional fix: prerelease suffix gets a leading 0
|
|
428
|
+
# marker so it sorts BELOW the release sentinel.
|
|
429
|
+
return tuple(nums) + (0, suffix)
|
|
430
|
+
return tuple(nums) + (1, "")
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def check_min_sdk_version(payload: dict, sdk_version: str) -> None:
|
|
434
|
+
"""Raise :class:`BundleRequiresNewerSDKError` if the bundle
|
|
435
|
+
declares a higher floor than ``sdk_version``.
|
|
436
|
+
|
|
437
|
+
Bundles without ``metadata.min_sdk_version`` are accepted
|
|
438
|
+
unconditionally (back-compat: every bundle minted before #602
|
|
439
|
+
omits the field).
|
|
440
|
+
|
|
441
|
+
No-op fast path covers 99% of bundles in the field, so the
|
|
442
|
+
cost is one ``.get("metadata", {}).get("min_sdk_version")``
|
|
443
|
+
indirection per load.
|
|
444
|
+
"""
|
|
445
|
+
metadata = payload.get("metadata")
|
|
446
|
+
if not isinstance(metadata, dict):
|
|
447
|
+
return
|
|
448
|
+
required = metadata.get("min_sdk_version")
|
|
449
|
+
if not isinstance(required, str) or not required:
|
|
450
|
+
return
|
|
451
|
+
# Lazy import: errors -> _internal cycle is avoided because
|
|
452
|
+
# this is invoked at runtime, after both modules finished loading.
|
|
453
|
+
from controlzero.errors import BundleRequiresNewerSDKError
|
|
454
|
+
|
|
455
|
+
if _parse_version_tuple(sdk_version) >= _parse_version_tuple(required):
|
|
456
|
+
return
|
|
457
|
+
raise BundleRequiresNewerSDKError(
|
|
458
|
+
required=required,
|
|
459
|
+
actual=sdk_version,
|
|
460
|
+
upgrade_command="pip install -U controlzero",
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
|
|
356
464
|
# --- Schema translation ----------------------------------------------------
|
|
357
465
|
|
|
358
466
|
|
|
@@ -618,4 +726,25 @@ def _translate_rule(rule: dict, policy_id: str) -> Optional[dict]:
|
|
|
618
726
|
if isinstance(rc, str) and rc:
|
|
619
727
|
translated["reason_code"] = rc
|
|
620
728
|
|
|
729
|
+
# gh#175 P1.1 outside-voice review: pass `clients` / `projects`
|
|
730
|
+
# selectors through to the local rule shape. Without this, the
|
|
731
|
+
# backend ships selector-scoped rules in the signed bundle but
|
|
732
|
+
# the SDK loader strips them and the engine treats the rule as
|
|
733
|
+
# unscoped -- the dashboard renders gate_matched="none" for
|
|
734
|
+
# selector-scoped denies (silent over-block).
|
|
735
|
+
clients = rule.get("clients")
|
|
736
|
+
if isinstance(clients, list) and clients:
|
|
737
|
+
translated["clients"] = [str(c) for c in clients if isinstance(c, str)]
|
|
738
|
+
projects = rule.get("projects")
|
|
739
|
+
if isinstance(projects, list) and projects:
|
|
740
|
+
translated["projects"] = [str(p) for p in projects if isinstance(p, str)]
|
|
741
|
+
|
|
742
|
+
# Same pattern (gh#538): the HITL `escalate_on_deny` tag was
|
|
743
|
+
# also being stripped at the bundle layer. Forward it when
|
|
744
|
+
# present so a customer pre-tagging rules for HITL keeps the
|
|
745
|
+
# tag through hosted distribution.
|
|
746
|
+
escalate = rule.get("escalate_on_deny")
|
|
747
|
+
if escalate is not None:
|
|
748
|
+
translated["escalate_on_deny"] = bool(escalate)
|
|
749
|
+
|
|
621
750
|
return translated
|