controlzero 1.6.0__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.6.0 → controlzero-1.7.0}/CHANGELOG.md +27 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/PKG-INFO +1 -1
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/__init__.py +1 -1
- controlzero-1.7.0/controlzero/_internal/action_validator.py +182 -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.6.0 → controlzero-1.7.0}/controlzero/audit_remote.py +38 -2
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/client.py +40 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/error_codes.py +55 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/errors.py +68 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/hitl/pending_approval.py +12 -2
- 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.6.0 → controlzero-1.7.0}/controlzero/policy_loader.py +32 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/pyproject.toml +6 -1
- controlzero-1.7.0/tests/test_action_validator_t86.py +112 -0
- controlzero-1.7.0/tests/test_conformance.py +335 -0
- controlzero-1.7.0/tests/test_credential_hook.py +738 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_hitl_6a_pending_approval.py +10 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_hitl_6a_request_approval.py +62 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_hitl_6a_wait.py +78 -0
- controlzero-1.7.0/tests/test_hitl_conformance.py +287 -0
- controlzero-1.7.0/tests/test_policy_source_audit.py +149 -0
- controlzero-1.6.0/.gitignore +0 -248
- controlzero-1.6.0/controlzero/hitl/status.py +0 -46
- {controlzero-1.6.0 → controlzero-1.7.0}/Dockerfile.test +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/LICENSE +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/README.md +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/_internal/__init__.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/_internal/action_aliases.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/_internal/bundle.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/_internal/dlp_scanner.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/_internal/enforcer.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/_internal/hook_extractors.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/_internal/tool_extractors.json +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/_internal/types.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/audit_local.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/canonical.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/__init__.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/_secrets.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/console.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/debug_bundle.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/doctor.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/hosts/__init__.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/hosts/base.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/hosts/claude_code.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/hosts/codex_cli.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/hosts/gemini_cli.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/hosts/unknown.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/main.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/migrate.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/telemetry_consent.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/templates/autogen.yaml +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/templates/claude-code.yaml +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/templates/codex-cli.yaml +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/templates/cost-cap.yaml +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/templates/crewai.yaml +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/templates/cursor.yaml +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/templates/gemini-cli.yaml +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/templates/generic.yaml +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/templates/langchain.yaml +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/templates/mcp.yaml +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/cli/templates/rag.yaml +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/device.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/enrollment.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/hitl/__init__.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/hitl/mock.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/hitl/secret_leak_guard.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/hosted_policy.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/__init__.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/anthropic.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/autogen.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/braintrust.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/crewai/__init__.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/crewai/agent.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/crewai/crew.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/crewai/task.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/crewai/tool.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/google.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/google_adk/__init__.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/google_adk/agent.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/google_adk/tool.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/langchain/__init__.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/langchain/agent.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/langchain/callbacks.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/langchain/chain.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/langchain/graph.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/langchain/modern.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/langchain/tool.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/langfuse.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/litellm.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/openai.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/pydantic_ai.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/integrations/vercel_ai.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/layout_migration.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/controlzero/tamper.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/examples/hello_world.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/_fixtures/jcs_args_hash_vectors.json +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/conftest.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/integrations/__init__.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/integrations/test_google.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/parity/action_aliases.json +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_action_aliases.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_action_canonicalization.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_agent_name_env.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_api_key_mask.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_audit_remote.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_audit_remote_sdk_version.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_audit_sink_isolation.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_bundle_parser.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_bundle_translate.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_canonical_phase1a.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_cli_carve_out.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_cli_debug_bundle.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_cli_extractor_integration.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_cli_hook.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_cli_hosted_refresh.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_cli_init.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_cli_init_templates.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_cli_tail.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_cli_test.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_cli_validate.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_coding_agent_hooks.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_conditions.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_console.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_default_action.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_device.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_dlp_scanner.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_doctor.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_engine_version_consistency.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_enrollment.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_env_dump_438.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_error_codes.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_errors_e_codes.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_fail_closed_eval.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_glob_matching.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_hitl_5d_email_install.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_hitl_6a_cli_flag.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_hitl_6a_exceptions.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_hitl_6a_get_secret_hitl.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_hitl_6a_mock_backend.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_hitl_6a_secret_leak_guard.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_hitl_reason_codes.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_hitl_validator_keys.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_hook_extractors.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_hosted_policy_e2e.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_hosts_adapter.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_hybrid_mode_strict.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_hybrid_mode_warn.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_install_hook_command.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_install_hooks.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_layout_migration_t101.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_layout_parity_t102.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_local_mode_dict.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_local_mode_file_json.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_local_mode_file_yaml.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_log_fallback_stderr.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_log_options_ignored_hosted.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_log_rotation.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_migrate.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_min_sdk_version_gate.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_multi_client_per_project_175.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_no_policy_no_key.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_package_rename_shim.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_policy_engine_version_phase1b.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_policy_freshness.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_policy_settings.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_quarantine.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_reason_code.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_refresh.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_secrets.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_sql_semantic_class.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_synthetic_policy_id_t79.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_t103_precedence.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_t104_cache_gc.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_t108_local_override_audit.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_t96_single_audit_log.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_t99_install_prefetch_bundle.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_tamper.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_tamper_behavior.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_tamper_hook.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_telemetry_consent.py +0 -0
- {controlzero-1.6.0 → controlzero-1.7.0}/tests/test_unsafe_int_boundary.py +0 -0
|
@@ -1,5 +1,32 @@
|
|
|
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
|
+
|
|
3
30
|
## v1.6.0 -- 2026-05-17 (HITL-6a, gh#542)
|
|
4
31
|
|
|
5
32
|
First minor that turns the Human-in-the-Loop approval workflow on. 1.5.8
|
|
@@ -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
|
|
@@ -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"]
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""Credential leak ingest hook (epic #666, PR-2).
|
|
2
|
+
|
|
3
|
+
Wraps the pure-Python `scan_for_credentials` and turns a list of
|
|
4
|
+
matches into:
|
|
5
|
+
|
|
6
|
+
* a possibly-redacted body of text (when action == "redact"),
|
|
7
|
+
* a list of audit-row dicts ready for the existing batched audit
|
|
8
|
+
flush (`audit_remote.py`),
|
|
9
|
+
* an optional raised exception (when action == "block").
|
|
10
|
+
|
|
11
|
+
Design notes:
|
|
12
|
+
|
|
13
|
+
* Redaction never echoes plaintext credentials back to the audit row.
|
|
14
|
+
The matched bytes are replaced in-place with
|
|
15
|
+
`cz:credleak:<sha256_hex>` so the audit row can deterministically
|
|
16
|
+
reference the same secret across calls without holding it.
|
|
17
|
+
* `value_hash` is HMAC-SHA256(per-org key, plaintext bytes), first
|
|
18
|
+
16 hex chars. One-way; rotates with the org HMAC key. The plaintext
|
|
19
|
+
is held only on the stack during this call -- the function returns
|
|
20
|
+
redacted text + hashes so no caller needs to manage the raw value.
|
|
21
|
+
* The 16-byte context window surrounding each match is masked: the
|
|
22
|
+
literal `<MASKED>` replaces the credential body itself so the
|
|
23
|
+
audit row never carries any prefix or suffix of the secret bytes.
|
|
24
|
+
* `CONTROLZERO_CREDLEAK_OFF=1` in the environment downgrades any
|
|
25
|
+
configured action to `warn`, but still emits the audit row with
|
|
26
|
+
`enforcement_downgraded=True`. The env override is the operator's
|
|
27
|
+
break-glass for a noisy false positive in production; the row
|
|
28
|
+
preserves the original intent.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import hashlib
|
|
34
|
+
import hmac
|
|
35
|
+
import os
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
from typing import Any, Literal
|
|
38
|
+
|
|
39
|
+
from controlzero._internal.credential_scanner import scan_for_credentials
|
|
40
|
+
from controlzero.errors import CredentialLeakBlocked
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
Action = Literal["warn", "redact", "block"]
|
|
44
|
+
Source = Literal["tool_output", "tool_stderr", "file_read", "grep_match"]
|
|
45
|
+
|
|
46
|
+
# Operator break-glass: when this env var is set to "1" the handler
|
|
47
|
+
# downgrades any non-warn action to warn and stamps the audit row with
|
|
48
|
+
# `enforcement_downgraded=True`. Useful when a false positive is
|
|
49
|
+
# blocking work in production while the catalog is updated.
|
|
50
|
+
_OFF_ENV_VAR = "CONTROLZERO_CREDLEAK_OFF"
|
|
51
|
+
|
|
52
|
+
# Sentinel inserted into the context window in place of the actual
|
|
53
|
+
# credential bytes. The 16 bytes on either side of the credential
|
|
54
|
+
# are preserved as additional ambient context for the audit reviewer;
|
|
55
|
+
# the credential itself is never echoed.
|
|
56
|
+
_MASK = "<MASKED>"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class _Match:
|
|
61
|
+
"""Internal projection of a single scanner hit. Decouples the
|
|
62
|
+
handler from the raw dict shape `scan_for_credentials` emits."""
|
|
63
|
+
|
|
64
|
+
pattern_id: str
|
|
65
|
+
severity: str
|
|
66
|
+
start: int
|
|
67
|
+
end: int
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _hmac_value_hash(hmac_key: bytes, plaintext: bytes) -> str:
|
|
71
|
+
"""First 16 hex chars of HMAC-SHA256(hmac_key, plaintext).
|
|
72
|
+
|
|
73
|
+
The hash truncation is deliberate: 64 bits is sufficient
|
|
74
|
+
de-duplication granularity for credential matches per org (the
|
|
75
|
+
rotation tracker groups by hash, and even at 100M rows the
|
|
76
|
+
expected collision count stays well under 1), and the shorter
|
|
77
|
+
string keeps the audit_logs.metadata column LOW-cardinality
|
|
78
|
+
friendly. The key MUST be the per-org HMAC key issued at
|
|
79
|
+
enrollment; never reuse across orgs because cross-org hash
|
|
80
|
+
equality would leak `same secret` membership across tenants.
|
|
81
|
+
"""
|
|
82
|
+
return hmac.new(hmac_key, plaintext, hashlib.sha256).hexdigest()[:16]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _build_context_window(text: str, start: int, end: int) -> str:
|
|
86
|
+
"""Sixteen bytes of ambient text on either side of the credential,
|
|
87
|
+
with the credential body itself replaced by `<MASKED>`.
|
|
88
|
+
|
|
89
|
+
The window is computed on the str (not bytes) for simplicity;
|
|
90
|
+
on ASCII text -- the dominant case for tool output of secrets --
|
|
91
|
+
the result is byte-equivalent. Non-printable bytes that survive
|
|
92
|
+
in the window are passed through as-is so the audit reviewer
|
|
93
|
+
sees the operator-visible representation; downstream analytical
|
|
94
|
+
store columns of type `String` accept any UTF-8.
|
|
95
|
+
"""
|
|
96
|
+
text_len = len(text)
|
|
97
|
+
left_start = max(0, start - 16)
|
|
98
|
+
right_end = min(text_len, end + 16)
|
|
99
|
+
left = text[left_start:start]
|
|
100
|
+
right = text[end:right_end]
|
|
101
|
+
return f"{left}{_MASK}{right}"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class CredentialLeakHandler:
|
|
105
|
+
"""Ingest hook that wires the scanner + redactor + audit emit into
|
|
106
|
+
one call.
|
|
107
|
+
|
|
108
|
+
The handler is intentionally instantiated per-client (not per-call)
|
|
109
|
+
so the same configuration -- project id, action posture, HMAC key
|
|
110
|
+
-- applies across every tool-output scan from a given agent. The
|
|
111
|
+
object holds no per-call state; it is safe to share across
|
|
112
|
+
threads.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
*,
|
|
118
|
+
client: Any,
|
|
119
|
+
project_id: str,
|
|
120
|
+
action: Action,
|
|
121
|
+
hmac_key: bytes,
|
|
122
|
+
) -> None:
|
|
123
|
+
if action not in ("warn", "redact", "block"):
|
|
124
|
+
raise ValueError(
|
|
125
|
+
f"CredentialLeakHandler.action must be warn|redact|block, got {action!r}"
|
|
126
|
+
)
|
|
127
|
+
if not isinstance(hmac_key, (bytes, bytearray)):
|
|
128
|
+
raise TypeError("hmac_key must be bytes")
|
|
129
|
+
if len(hmac_key) < 16:
|
|
130
|
+
# 16 bytes is the minimum we accept; HMAC-SHA256 can take
|
|
131
|
+
# any key length but a short key offers no security
|
|
132
|
+
# advantage and almost always indicates a config bug.
|
|
133
|
+
raise ValueError("hmac_key must be at least 16 bytes")
|
|
134
|
+
self._client = client
|
|
135
|
+
self._project_id = project_id
|
|
136
|
+
self._configured_action = action
|
|
137
|
+
self._hmac_key = bytes(hmac_key)
|
|
138
|
+
|
|
139
|
+
# ------------------------------------------------------------------
|
|
140
|
+
# Public entry point.
|
|
141
|
+
# ------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
def handle(
|
|
144
|
+
self,
|
|
145
|
+
*,
|
|
146
|
+
source: Source,
|
|
147
|
+
text: str,
|
|
148
|
+
tool_name: str,
|
|
149
|
+
tool_call_id: str,
|
|
150
|
+
agent_name: str,
|
|
151
|
+
) -> tuple[str, list[dict[str, Any]]]:
|
|
152
|
+
"""Scan `text`, emit audit rows for each hit, return the
|
|
153
|
+
(possibly-redacted) text plus the rows.
|
|
154
|
+
|
|
155
|
+
When no credentials are detected the original text is returned
|
|
156
|
+
verbatim and the row list is empty -- the function is
|
|
157
|
+
zero-effect on innocuous input. When credentials are detected
|
|
158
|
+
the function:
|
|
159
|
+
|
|
160
|
+
1. Resolves the effective action (configured action, possibly
|
|
161
|
+
downgraded to `warn` by the env override).
|
|
162
|
+
2. Builds one audit row per match. The row carries no
|
|
163
|
+
plaintext; the credential is hashed via `_hmac_value_hash`
|
|
164
|
+
and the surrounding context is masked.
|
|
165
|
+
3. Either redacts each match in-place, returns the text
|
|
166
|
+
unchanged (warn), or raises `CredentialLeakBlocked` AFTER
|
|
167
|
+
emitting the rows (block).
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
(returned_text, rows) -- when action == "block" this
|
|
171
|
+
tuple is never observed by the caller because the
|
|
172
|
+
function raises before returning.
|
|
173
|
+
"""
|
|
174
|
+
raw = scan_for_credentials(text)
|
|
175
|
+
if not raw:
|
|
176
|
+
return text, []
|
|
177
|
+
|
|
178
|
+
matches = [
|
|
179
|
+
_Match(
|
|
180
|
+
pattern_id=str(m["pattern_id"]),
|
|
181
|
+
severity=str(m["severity"]),
|
|
182
|
+
start=int(m["start"]),
|
|
183
|
+
end=int(m["end"]),
|
|
184
|
+
)
|
|
185
|
+
for m in raw
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
effective, downgraded = self._resolve_effective_action()
|
|
189
|
+
|
|
190
|
+
# Build audit rows first so the rows reflect the original
|
|
191
|
+
# match positions; redaction shifts byte offsets but the
|
|
192
|
+
# audit row records the pre-redaction span.
|
|
193
|
+
rows = [
|
|
194
|
+
self._build_audit_row(
|
|
195
|
+
m=m,
|
|
196
|
+
text=text,
|
|
197
|
+
source=source,
|
|
198
|
+
tool_name=tool_name,
|
|
199
|
+
tool_call_id=tool_call_id,
|
|
200
|
+
agent_name=agent_name,
|
|
201
|
+
effective_action=effective,
|
|
202
|
+
enforcement_downgraded=downgraded,
|
|
203
|
+
)
|
|
204
|
+
for m in matches
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
# Emit each row through the configured audit sink. Audit
|
|
208
|
+
# delivery is best-effort and never blocks the hot path; on
|
|
209
|
+
# failure the sink retains the row internally for retry.
|
|
210
|
+
for row in rows:
|
|
211
|
+
self._emit(row)
|
|
212
|
+
|
|
213
|
+
if effective == "redact":
|
|
214
|
+
returned_text = self._redact_text(text, matches)
|
|
215
|
+
elif effective == "warn":
|
|
216
|
+
returned_text = text
|
|
217
|
+
else:
|
|
218
|
+
# `block`. Audit rows are emitted BEFORE raising so the
|
|
219
|
+
# operator sees the detection event even when the agent
|
|
220
|
+
# never observes the redacted output.
|
|
221
|
+
assert effective == "block"
|
|
222
|
+
raise CredentialLeakBlocked(
|
|
223
|
+
f"credential leak detected in {source} (tool={tool_name}): "
|
|
224
|
+
f"{len(matches)} match(es); see audit log for details"
|
|
225
|
+
)
|
|
226
|
+
return returned_text, rows
|
|
227
|
+
|
|
228
|
+
# ------------------------------------------------------------------
|
|
229
|
+
# Internal helpers.
|
|
230
|
+
# ------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
def _resolve_effective_action(self) -> tuple[Action, bool]:
|
|
233
|
+
"""Apply the `CONTROLZERO_CREDLEAK_OFF=1` operator override.
|
|
234
|
+
|
|
235
|
+
Returns the effective action plus a `downgraded` flag that
|
|
236
|
+
feeds into the audit row so the dashboard can highlight rows
|
|
237
|
+
whose intended posture was bypassed.
|
|
238
|
+
"""
|
|
239
|
+
if os.environ.get(_OFF_ENV_VAR, "") == "1":
|
|
240
|
+
if self._configured_action != "warn":
|
|
241
|
+
return "warn", True
|
|
242
|
+
return self._configured_action, False
|
|
243
|
+
|
|
244
|
+
def _redact_text(self, text: str, matches: list[_Match]) -> str:
|
|
245
|
+
"""Replace each match with `cz:credleak:<sha256_hex>`.
|
|
246
|
+
|
|
247
|
+
Matches are processed right-to-left so a redaction never
|
|
248
|
+
invalidates the byte offsets of earlier (lower-index)
|
|
249
|
+
matches. Two pieces of context held intentionally:
|
|
250
|
+
|
|
251
|
+
* `cz:credleak:` is a fixed prefix the downstream agent log
|
|
252
|
+
consumer can grep for; supports a "show me everywhere this
|
|
253
|
+
secret was redacted" rotation workflow.
|
|
254
|
+
* The hex digest is SHA-256 of the plaintext credential, not
|
|
255
|
+
the HMAC-keyed hash. The redaction lives inside the agent's
|
|
256
|
+
local output; the HMAC hash lives in the audit row that
|
|
257
|
+
leaves the host. Keeping them distinct means a leak of the
|
|
258
|
+
local log file does not let an attacker correlate a
|
|
259
|
+
previous local redaction with a cross-org audit row.
|
|
260
|
+
"""
|
|
261
|
+
ordered = sorted(matches, key=lambda m: m.start, reverse=True)
|
|
262
|
+
out = text
|
|
263
|
+
for m in ordered:
|
|
264
|
+
plaintext = text[m.start : m.end]
|
|
265
|
+
digest = hashlib.sha256(plaintext.encode("utf-8", errors="replace")).hexdigest()
|
|
266
|
+
replacement = f"cz:credleak:{digest}"
|
|
267
|
+
out = out[: m.start] + replacement + out[m.end :]
|
|
268
|
+
return out
|
|
269
|
+
|
|
270
|
+
def _build_audit_row(
|
|
271
|
+
self,
|
|
272
|
+
*,
|
|
273
|
+
m: _Match,
|
|
274
|
+
text: str,
|
|
275
|
+
source: Source,
|
|
276
|
+
tool_name: str,
|
|
277
|
+
tool_call_id: str,
|
|
278
|
+
agent_name: str,
|
|
279
|
+
effective_action: Action,
|
|
280
|
+
enforcement_downgraded: bool,
|
|
281
|
+
) -> dict[str, Any]:
|
|
282
|
+
"""Construct the wire-shape dict the audit sink already
|
|
283
|
+
accepts. The sink folds additional keys onto the existing
|
|
284
|
+
batch payload (`/api/audit/batch` accepts unknown extra
|
|
285
|
+
fields per the additive-schema contract); backend storage
|
|
286
|
+
lands in PR-5.
|
|
287
|
+
"""
|
|
288
|
+
plaintext = text[m.start : m.end]
|
|
289
|
+
value_hash = _hmac_value_hash(
|
|
290
|
+
self._hmac_key, plaintext.encode("utf-8", errors="replace")
|
|
291
|
+
)
|
|
292
|
+
return {
|
|
293
|
+
# Mark the row as a credential leak so the backend ingest
|
|
294
|
+
# can route it to the rotation tracker view in PR-5.
|
|
295
|
+
"event_kind": "credential_leak_detected",
|
|
296
|
+
"pattern_id": m.pattern_id,
|
|
297
|
+
"severity": m.severity,
|
|
298
|
+
"value_hash": value_hash,
|
|
299
|
+
"context_window": _build_context_window(text, m.start, m.end),
|
|
300
|
+
"source": source,
|
|
301
|
+
"tool_name": tool_name,
|
|
302
|
+
"tool_call_id": tool_call_id,
|
|
303
|
+
"agent_name": agent_name,
|
|
304
|
+
"project_id": self._project_id,
|
|
305
|
+
"enforcement_action": effective_action,
|
|
306
|
+
"enforcement_downgraded": enforcement_downgraded,
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
def _emit(self, row: dict[str, Any]) -> None:
|
|
310
|
+
"""Push one audit row through the client's existing sink.
|
|
311
|
+
|
|
312
|
+
The handler does not own its own batch buffer; it piggy-backs
|
|
313
|
+
on whichever sink the client has wired up (RemoteAuditSink,
|
|
314
|
+
BearerAuditSink, or a test double). Best-effort: if the
|
|
315
|
+
client is missing an audit sink the row is dropped silently
|
|
316
|
+
so an SDK in local-only mode keeps functioning without an
|
|
317
|
+
audit destination.
|
|
318
|
+
"""
|
|
319
|
+
sink = getattr(self._client, "audit_sink", None)
|
|
320
|
+
if sink is None:
|
|
321
|
+
return
|
|
322
|
+
log_fn = getattr(sink, "log", None)
|
|
323
|
+
if log_fn is None:
|
|
324
|
+
return
|
|
325
|
+
try:
|
|
326
|
+
log_fn(row)
|
|
327
|
+
except Exception: # noqa: BLE001
|
|
328
|
+
# The audit pipeline is best-effort by design (matches
|
|
329
|
+
# the existing audit_remote.py contract). The hook must
|
|
330
|
+
# never crash a user's tool call because an audit
|
|
331
|
+
# delivery failed.
|
|
332
|
+
pass
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
__all__ = [
|
|
336
|
+
"Action",
|
|
337
|
+
"Source",
|
|
338
|
+
"CredentialLeakHandler",
|
|
339
|
+
]
|