controlzero 1.5.8__tar.gz → 1.7.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.7.0}/CHANGELOG.md +97 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/PKG-INFO +150 -1
- {controlzero-1.5.8 → controlzero-1.7.0}/README.md +148 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/__init__.py +3 -1
- controlzero-1.7.0/controlzero/_internal/action_validator.py +182 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/_internal/bundle.py +129 -0
- controlzero-1.7.0/controlzero/_internal/credential_hook.py +339 -0
- controlzero-1.7.0/controlzero/_internal/credential_scanner.py +391 -0
- controlzero-1.7.0/controlzero/_internal/credentials_data/__init__.py +12 -0
- controlzero-1.7.0/controlzero/_internal/credentials_data/built_in.yaml +2259 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/_internal/enforcer.py +85 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/_internal/types.py +14 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/audit_remote.py +165 -72
- controlzero-1.7.0/controlzero/canonical.py +108 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/main.py +168 -4
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/client.py +631 -6
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/error_codes.py +212 -0
- controlzero-1.7.0/controlzero/errors.py +495 -0
- controlzero-1.7.0/controlzero/hitl/__init__.py +43 -0
- controlzero-1.7.0/controlzero/hitl/mock.py +185 -0
- controlzero-1.7.0/controlzero/hitl/pending_approval.py +460 -0
- controlzero-1.7.0/controlzero/hitl/secret_leak_guard.py +218 -0
- controlzero-1.7.0/controlzero/hitl/status.py +77 -0
- controlzero-1.7.0/controlzero/hooks/__init__.py +8 -0
- controlzero-1.7.0/controlzero/hooks/tool_output_handler.py +94 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/hosted_policy.py +11 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/policy_loader.py +73 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/pyproject.toml +13 -1
- controlzero-1.7.0/tests/_fixtures/jcs_args_hash_vectors.json +111 -0
- controlzero-1.7.0/tests/test_action_validator_t86.py +112 -0
- controlzero-1.7.0/tests/test_canonical_phase1a.py +229 -0
- controlzero-1.7.0/tests/test_conformance.py +335 -0
- controlzero-1.7.0/tests/test_credential_hook.py +738 -0
- controlzero-1.7.0/tests/test_engine_version_consistency.py +82 -0
- controlzero-1.7.0/tests/test_hitl_5d_email_install.py +137 -0
- controlzero-1.7.0/tests/test_hitl_6a_cli_flag.py +307 -0
- controlzero-1.7.0/tests/test_hitl_6a_exceptions.py +166 -0
- controlzero-1.7.0/tests/test_hitl_6a_get_secret_hitl.py +672 -0
- controlzero-1.7.0/tests/test_hitl_6a_mock_backend.py +343 -0
- controlzero-1.7.0/tests/test_hitl_6a_pending_approval.py +362 -0
- controlzero-1.7.0/tests/test_hitl_6a_request_approval.py +730 -0
- controlzero-1.7.0/tests/test_hitl_6a_secret_leak_guard.py +327 -0
- controlzero-1.7.0/tests/test_hitl_6a_wait.py +664 -0
- controlzero-1.7.0/tests/test_hitl_conformance.py +287 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_hitl_validator_keys.py +6 -0
- controlzero-1.7.0/tests/test_min_sdk_version_gate.py +206 -0
- controlzero-1.7.0/tests/test_multi_client_per_project_175.py +807 -0
- controlzero-1.7.0/tests/test_policy_engine_version_phase1b.py +73 -0
- controlzero-1.7.0/tests/test_policy_source_audit.py +149 -0
- controlzero-1.7.0/tests/test_unsafe_int_boundary.py +67 -0
- controlzero-1.5.8/.gitignore +0 -245
- controlzero-1.5.8/controlzero/errors.py +0 -181
- {controlzero-1.5.8 → controlzero-1.7.0}/Dockerfile.test +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/LICENSE +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/_internal/__init__.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/_internal/action_aliases.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/_internal/dlp_scanner.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/_internal/hook_extractors.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/_internal/tool_extractors.json +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/audit_local.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/__init__.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/_secrets.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/console.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/debug_bundle.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/doctor.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/hosts/__init__.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/hosts/base.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/hosts/claude_code.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/hosts/codex_cli.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/hosts/gemini_cli.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/hosts/unknown.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/migrate.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/telemetry_consent.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/templates/autogen.yaml +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/templates/claude-code.yaml +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/templates/codex-cli.yaml +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/templates/cost-cap.yaml +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/templates/crewai.yaml +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/templates/cursor.yaml +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/templates/gemini-cli.yaml +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/templates/generic.yaml +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/templates/langchain.yaml +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/templates/mcp.yaml +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/cli/templates/rag.yaml +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/device.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/enrollment.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/__init__.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/anthropic.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/autogen.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/braintrust.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/crewai/__init__.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/crewai/agent.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/crewai/crew.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/crewai/task.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/crewai/tool.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/google.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/google_adk/__init__.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/google_adk/agent.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/google_adk/tool.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/langchain/__init__.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/langchain/agent.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/langchain/callbacks.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/langchain/chain.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/langchain/graph.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/langchain/modern.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/langchain/tool.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/langfuse.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/litellm.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/openai.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/pydantic_ai.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/integrations/vercel_ai.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/layout_migration.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/controlzero/tamper.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/examples/hello_world.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/conftest.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/integrations/__init__.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/integrations/test_google.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/parity/action_aliases.json +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_action_aliases.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_action_canonicalization.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_agent_name_env.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_api_key_mask.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_audit_remote.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_audit_remote_sdk_version.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_audit_sink_isolation.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_bundle_parser.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_bundle_translate.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_cli_carve_out.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_cli_debug_bundle.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_cli_extractor_integration.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_cli_hook.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_cli_hosted_refresh.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_cli_init.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_cli_init_templates.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_cli_tail.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_cli_test.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_cli_validate.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_coding_agent_hooks.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_conditions.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_console.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_default_action.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_device.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_dlp_scanner.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_doctor.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_enrollment.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_env_dump_438.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_error_codes.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_errors_e_codes.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_fail_closed_eval.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_glob_matching.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_hitl_reason_codes.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_hook_extractors.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_hosted_policy_e2e.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_hosts_adapter.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_hybrid_mode_strict.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_hybrid_mode_warn.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_install_hook_command.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_install_hooks.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_layout_migration_t101.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_layout_parity_t102.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_local_mode_dict.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_local_mode_file_json.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_local_mode_file_yaml.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_log_fallback_stderr.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_log_options_ignored_hosted.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_log_rotation.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_migrate.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_no_policy_no_key.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_package_rename_shim.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_policy_freshness.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_policy_settings.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_quarantine.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_reason_code.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_refresh.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_secrets.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_sql_semantic_class.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_synthetic_policy_id_t79.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_t103_precedence.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_t104_cache_gc.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_t108_local_override_audit.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_t96_single_audit_log.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_t99_install_prefetch_bundle.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_tamper.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_tamper_behavior.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_tamper_hook.py +0 -0
- {controlzero-1.5.8 → controlzero-1.7.0}/tests/test_telemetry_consent.py +0 -0
|
@@ -1,5 +1,102 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.7.0 -- 2026-05-19 (T86, gh#391)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Unknown-action validator at policy-load time** (T86, GitHub #391).
|
|
8
|
+
When `controlzero.policy_loader.load_policy()` parses a policy whose
|
|
9
|
+
rules target an action name that is not in the canonical-or-alias
|
|
10
|
+
table (typo, made-up name like `database:queryy`), the loader now
|
|
11
|
+
emits a `logging.WARNING` per offending action with a did-you-mean
|
|
12
|
+
suggestion list. The policy still loads -- the validator is
|
|
13
|
+
warn-not-block at the SDK level (the platform backend blocks publish
|
|
14
|
+
with 422 on the same condition).
|
|
15
|
+
|
|
16
|
+
This catches the silent "rule lands but never fires" class of bug
|
|
17
|
+
that T84's alias shim was created to prevent: a customer typing
|
|
18
|
+
`database:queryy` gets a one-line warning pointing at
|
|
19
|
+
`database:query (legacy)` instead of the rule silently never matching.
|
|
20
|
+
|
|
21
|
+
The validator's known-action set is the union of canonical SDK
|
|
22
|
+
extractor tools, host-tool aliases (e.g. `Read` -> `file_read`), the
|
|
23
|
+
four canonical SQL semantic classes plus every legacy alias from the
|
|
24
|
+
T84 alias table, and wildcards (`*`, `tool:*`, `*:method`). Adding
|
|
25
|
+
a new alias to `_internal/action_aliases.py` automatically widens
|
|
26
|
+
what the validator accepts.
|
|
27
|
+
|
|
28
|
+
See `docs/concepts/policies.md#validation` for the full contract.
|
|
29
|
+
|
|
30
|
+
## v1.6.0 -- 2026-05-17 (HITL-6a, gh#542)
|
|
31
|
+
|
|
32
|
+
First minor that turns the Human-in-the-Loop approval workflow on. 1.5.8
|
|
33
|
+
prepared the field (escalate_on_deny acknowledged, HITL reason codes
|
|
34
|
+
registered, validator additive); 1.6.0 ships the surface area: 11 exception
|
|
35
|
+
codes, a PendingApproval dataclass with sync + async wait, an in-process
|
|
36
|
+
mock backend, the request_approval HTTP path, the CLI test flag, and the
|
|
37
|
+
secret-value-leak guard that gates every outbound HITL payload.
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- **11 HITL exception classes** (E1701-E1711) -- `HITLTimeoutError`,
|
|
42
|
+
`HITLBackendUnreachableError`, `HITLPolicyVersionConflictError`,
|
|
43
|
+
`HITLNotConfiguredError`, `HITLNoApproverAvailable`,
|
|
44
|
+
`HITLIdentityNotInOrg`, `HITLIdentityRequired`,
|
|
45
|
+
`HITLIdentityClaimRejected`, `SecretValueLeakInPayload`,
|
|
46
|
+
`SecretApprovalRequired`, `SecretNotFound`. Inheritance is chosen so
|
|
47
|
+
existing `except PolicyDeniedError` blocks treat HITL timeouts and
|
|
48
|
+
approver-pool failures identically to a static deny. (#571)
|
|
49
|
+
- **`controlzero.hitl` subpackage** with the `PendingApproval` dataclass
|
|
50
|
+
and `Status` enum. `PendingApproval` carries `request_id`,
|
|
51
|
+
`idempotency_key`, `status`, `created_at`, `expires_at`,
|
|
52
|
+
`decision_kind`, and `decided_by`; exposes `is_terminal()` and
|
|
53
|
+
`remaining_s()` for non-blocking checks. (#574)
|
|
54
|
+
- **`MockApprovalBackend`** -- in-process fake of the two HITL endpoints
|
|
55
|
+
(`POST /api/approval-requests`, `GET /api/approval-requests/{id}`)
|
|
56
|
+
with five deterministic modes: `approve_after_2s`,
|
|
57
|
+
`approve_timed_after_2s`, `approve_forever_after_2s`,
|
|
58
|
+
`deny_after_2s`, and `timeout`. Thread-safe; one instance can serve
|
|
59
|
+
many concurrent pollers. (#572)
|
|
60
|
+
- **Secret-value-leak guard** -- `is_likely_secret_value`,
|
|
61
|
+
`scan_payload_for_secret_leak`, and `raise_on_leak` reject any
|
|
62
|
+
outbound payload whose redacted-args dict still contains a
|
|
63
|
+
secret-shaped string. The check runs inside `request_approval()`
|
|
64
|
+
before the HTTP send. (#573)
|
|
65
|
+
- **`controlzero test --hitl approve|deny|timeout`** CLI flag drives the
|
|
66
|
+
mock backend end-to-end from a shell, so customers can exercise HITL
|
|
67
|
+
paths without setting up a real approver. (#575)
|
|
68
|
+
- **`PendingApproval.wait()` and `wait_async()`** -- polling loop with
|
|
69
|
+
jittered exponential backoff, capped at the dataclass's `expires_at`.
|
|
70
|
+
Honors a user-supplied `poll_fn` for tests; defaults to the SDK's
|
|
71
|
+
HTTP poller for production. Raises `HITLTimeoutError` on SLA expiry
|
|
72
|
+
and `HITLBackendUnreachableError` on network failure. (#576)
|
|
73
|
+
- **`Client.request_approval(decision, message=..., timeout_s=...)`** --
|
|
74
|
+
POSTs a HITL approval request, builds the body from the
|
|
75
|
+
`PolicyDecision`, threads in `X-CZ-Requestor-Email` from
|
|
76
|
+
`~/.controlzero/config.yaml`, sends a per-call `Idempotency-Key`, and
|
|
77
|
+
returns a `PendingApproval`. Maps backend error codes E1305-E1308 onto
|
|
78
|
+
the matching SDK exceptions. (#577)
|
|
79
|
+
|
|
80
|
+
### Conformance
|
|
81
|
+
|
|
82
|
+
The 49 HITL/secret vectors added to `tests/parity/decisions.json` in
|
|
83
|
+
HITL-3 (gh#536) target this release: `min_sdk_version: "1.6.0"` and
|
|
84
|
+
`requires_backend: true` flags gate them. A Python conformance runner
|
|
85
|
+
that executes those vectors against the SDK is planned for phase 3 of
|
|
86
|
+
issue #409; until that lands, this release is validated against the
|
|
87
|
+
HITL-6a unit tests (155 new test cases across the seven slices) plus
|
|
88
|
+
the pre-existing 156 SDK hook tests.
|
|
89
|
+
|
|
90
|
+
### Unchanged
|
|
91
|
+
|
|
92
|
+
- 11-line Hello World still works.
|
|
93
|
+
- No breaking changes to existing public API: `Client.guard()`,
|
|
94
|
+
`Client.refresh()`, `load_policy()` all behave identically.
|
|
95
|
+
- `escalate_on_deny` still defaults to `False`; policies without HITL
|
|
96
|
+
tags continue to run with zero HITL overhead.
|
|
97
|
+
- Local-mode users pay no HITL cost: imports are lazy, no HTTP client
|
|
98
|
+
is constructed unless `request_approval()` is called.
|
|
99
|
+
|
|
3
100
|
## v1.5.8 -- 2026-05-16 (HITL-5a, gh#538)
|
|
4
101
|
|
|
5
102
|
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.7.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.7.0"
|
|
32
33
|
|
|
33
34
|
__all__ = [
|
|
34
35
|
"Client",
|
|
36
|
+
"PendingApproval",
|
|
35
37
|
"PolicyDecision",
|
|
36
38
|
"PolicyDeniedError",
|
|
37
39
|
"PolicyLoadError",
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""T86 / GitHub #391 -- unknown-action validator (warn-only at SDK load).
|
|
2
|
+
|
|
3
|
+
Pairs with the backend validator at
|
|
4
|
+
``apps/control-zero-platform/backend/internal/policy/action_aliases.go``.
|
|
5
|
+
The backend BLOCKS publish on unknown actions (422); the SDK
|
|
6
|
+
WARNS at load time so a customer running local-policy mode (no
|
|
7
|
+
backend) still sees the typo before the rule silently never fires.
|
|
8
|
+
|
|
9
|
+
The known-action set is the union of:
|
|
10
|
+
|
|
11
|
+
- Canonical tools (``database``, ``Bash``, ``http``, ``web_search``,
|
|
12
|
+
``browser``, ``file_read``, ``file_write``, ``file_search``,
|
|
13
|
+
``task``) plus their host-tool aliases from the SDK extractor
|
|
14
|
+
spec (``sdks/python/controlzero/controlzero/_internal/tool_extractors.json``).
|
|
15
|
+
- For the ``database`` tool: the four canonical SQL semantic classes
|
|
16
|
+
(``read``/``write``/``admin``/``exec``), every legacy alias from
|
|
17
|
+
the T84 alias table, and the ambiguous ``delete`` alias.
|
|
18
|
+
|
|
19
|
+
For every other tool the validator accepts ANY method (open
|
|
20
|
+
extractor outputs -- Bash basenames, HTTP verbs, browser action
|
|
21
|
+
strings, etc.). Wildcards (``*``, ``tool:*``, ``*:method``) always
|
|
22
|
+
pass.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import Iterable
|
|
27
|
+
|
|
28
|
+
from controlzero._internal.action_aliases import TOOL as _ALIAS_TOOL
|
|
29
|
+
from controlzero._internal.action_aliases import _AMBIGUOUS, _CLASSES
|
|
30
|
+
|
|
31
|
+
# Mirror of the canonical tool set + host-aliases the extractors
|
|
32
|
+
# accept. Source of truth is tool_extractors.json; this list is
|
|
33
|
+
# updated alongside it.
|
|
34
|
+
_CANONICAL_TOOLS: set[str] = {
|
|
35
|
+
"Bash", "database", "http", "web_search", "browser",
|
|
36
|
+
"file_read", "file_write", "file_search", "task",
|
|
37
|
+
# database aliases
|
|
38
|
+
"sql", "Database", "PostgreSQL", "MySQL", "postgres", "sqlite",
|
|
39
|
+
# Bash aliases
|
|
40
|
+
"bash", "shell", "ShellTool", "run_shell_command",
|
|
41
|
+
"PowerShell", "powershell", "Shell",
|
|
42
|
+
# http aliases
|
|
43
|
+
"fetch", "web_fetch", "WebFetch", "HTTPRequest", "request",
|
|
44
|
+
# web_search aliases
|
|
45
|
+
"WebSearch", "google_web_search", "SearchTool",
|
|
46
|
+
# browser aliases
|
|
47
|
+
"playwright", "Puppeteer",
|
|
48
|
+
# file_read aliases
|
|
49
|
+
"read_file", "Read", "ReadFile", "read_many_files",
|
|
50
|
+
# file_write aliases
|
|
51
|
+
"write_file", "Write", "WriteFile", "edit_file", "Edit",
|
|
52
|
+
"replace", "apply_patch",
|
|
53
|
+
# file_search aliases
|
|
54
|
+
"Grep", "grep_search", "Glob", "glob",
|
|
55
|
+
# task aliases
|
|
56
|
+
"Task", "Agent", "subagent", "spawn_agent",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
_DATABASE_TOOL_ALIASES = {
|
|
60
|
+
"database", "sql", "Database", "PostgreSQL", "MySQL", "postgres", "sqlite",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _build_known_database_methods() -> set[str]:
|
|
65
|
+
out: set[str] = {"*"}
|
|
66
|
+
for cls, aliases in _CLASSES.items():
|
|
67
|
+
out.add(cls)
|
|
68
|
+
for a in aliases:
|
|
69
|
+
out.add(a)
|
|
70
|
+
for alias in _AMBIGUOUS:
|
|
71
|
+
out.add(alias)
|
|
72
|
+
return out
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_KNOWN_DATABASE_METHODS = _build_known_database_methods()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def is_known_action(action: str) -> bool:
|
|
79
|
+
"""Return True if ``action`` is recognised by the SDK extractors / aliases."""
|
|
80
|
+
if not action:
|
|
81
|
+
return False
|
|
82
|
+
if action == "*":
|
|
83
|
+
return True
|
|
84
|
+
if ":" not in action:
|
|
85
|
+
return action in _CANONICAL_TOOLS
|
|
86
|
+
tool, _, method = action.partition(":")
|
|
87
|
+
if tool == "*":
|
|
88
|
+
return True
|
|
89
|
+
if tool not in _CANONICAL_TOOLS:
|
|
90
|
+
return False
|
|
91
|
+
if method == "*" or method == "":
|
|
92
|
+
return True
|
|
93
|
+
if tool in _DATABASE_TOOL_ALIASES:
|
|
94
|
+
return method in _KNOWN_DATABASE_METHODS
|
|
95
|
+
# Other tools: any method accepted (open extractor outputs).
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _levenshtein(a: str, b: str) -> int:
|
|
100
|
+
if a == b:
|
|
101
|
+
return 0
|
|
102
|
+
if not a:
|
|
103
|
+
return len(b)
|
|
104
|
+
if not b:
|
|
105
|
+
return len(a)
|
|
106
|
+
prev = list(range(len(b) + 1))
|
|
107
|
+
curr = [0] * (len(b) + 1)
|
|
108
|
+
for i in range(1, len(a) + 1):
|
|
109
|
+
curr[0] = i
|
|
110
|
+
for j in range(1, len(b) + 1):
|
|
111
|
+
cost = 0 if a[i - 1] == b[j - 1] else 1
|
|
112
|
+
curr[j] = min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost)
|
|
113
|
+
prev, curr = curr, prev
|
|
114
|
+
return prev[len(b)]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _shares_tool_prefix(a: str, b: str) -> bool:
|
|
118
|
+
if ":" not in a or ":" not in b:
|
|
119
|
+
return False
|
|
120
|
+
ta, _, ma = a.partition(":")
|
|
121
|
+
tb, _, mb = b.partition(":")
|
|
122
|
+
if ta != tb or not ma or not mb:
|
|
123
|
+
return False
|
|
124
|
+
short = min(len(ma), len(mb))
|
|
125
|
+
overlap = 0
|
|
126
|
+
for i in range(short):
|
|
127
|
+
if ma[i].lower() != mb[i].lower():
|
|
128
|
+
break
|
|
129
|
+
overlap += 1
|
|
130
|
+
return overlap * 2 >= short
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _candidates() -> list[tuple[str, bool]]:
|
|
134
|
+
"""Enumerate (name, is_legacy) tuples for suggestion ranking."""
|
|
135
|
+
out: list[tuple[str, bool]] = []
|
|
136
|
+
for cls in _CLASSES:
|
|
137
|
+
out.append((f"{_ALIAS_TOOL}:{cls}", False))
|
|
138
|
+
for aliases in _CLASSES.values():
|
|
139
|
+
for a in aliases:
|
|
140
|
+
out.append((f"{_ALIAS_TOOL}:{a}", True))
|
|
141
|
+
for alias in _AMBIGUOUS:
|
|
142
|
+
out.append((f"{_ALIAS_TOOL}:{alias}", True))
|
|
143
|
+
for tool in _CANONICAL_TOOLS:
|
|
144
|
+
out.append((f"{tool}:*", False))
|
|
145
|
+
return out
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def suggest_for_action(action: str, max_suggestions: int = 3) -> list[str]:
|
|
149
|
+
"""Return up to ``max_suggestions`` did-you-mean candidates for ``action``."""
|
|
150
|
+
max_distance = 3
|
|
151
|
+
cands = _candidates()
|
|
152
|
+
hits: list[tuple[str, int, bool]] = []
|
|
153
|
+
for name, legacy in cands:
|
|
154
|
+
d = _levenshtein(action, name)
|
|
155
|
+
if d > max_distance and not _shares_tool_prefix(action, name):
|
|
156
|
+
continue
|
|
157
|
+
hits.append((name, d, legacy))
|
|
158
|
+
# Sort by distance, then prefer canonical over legacy, then name.
|
|
159
|
+
hits.sort(key=lambda h: (h[1], h[2], h[0]))
|
|
160
|
+
out: list[str] = []
|
|
161
|
+
for name, _d, legacy in hits[:max_suggestions]:
|
|
162
|
+
out.append(f"{name} (legacy)" if legacy else name)
|
|
163
|
+
return out
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def validate_actions(actions: Iterable[str]) -> tuple[list[str], dict[str, list[str]]]:
|
|
167
|
+
"""Return (unknown_actions, suggestions_map) for the given action list."""
|
|
168
|
+
unknown: list[str] = []
|
|
169
|
+
suggestions: dict[str, list[str]] = {}
|
|
170
|
+
seen: set[str] = set()
|
|
171
|
+
for a in actions:
|
|
172
|
+
if is_known_action(a):
|
|
173
|
+
continue
|
|
174
|
+
if a in seen:
|
|
175
|
+
continue
|
|
176
|
+
seen.add(a)
|
|
177
|
+
unknown.append(a)
|
|
178
|
+
suggestions[a] = suggest_for_action(a)
|
|
179
|
+
return unknown, suggestions
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
__all__ = ["is_known_action", "suggest_for_action", "validate_actions"]
|