agentmetry 0.8.0__tar.gz → 0.9.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.
- {agentmetry-0.8.0 → agentmetry-0.9.0}/PKG-INFO +1 -1
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/cli/__init__.py +49 -0
- agentmetry-0.9.0/agentmetry/core/audit/otel_ingest.py +526 -0
- agentmetry-0.9.0/agentmetry/core/audit/tool_policy/capability.py +42 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/tool_policy/evaluator.py +20 -12
- agentmetry-0.9.0/agentmetry/core/audit/tool_policy/loader.py +83 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/tool_policy/models.py +4 -1
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/diagnostics/doctor.py +37 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/version.py +1 -1
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/hooks/ingest.py +97 -9
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/conftest.py +29 -0
- agentmetry-0.9.0/tests/fixtures/otel_claude_code_session.json +902 -0
- agentmetry-0.9.0/tests/test_ask_gate_phase_a.py +289 -0
- agentmetry-0.9.0/tests/test_hook_decision_format.py +198 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_hook_enforcement_timing.py +7 -2
- agentmetry-0.9.0/tests/test_otel_ingest.py +354 -0
- agentmetry-0.9.0/tests/test_suite_leaves_real_home_alone.py +40 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_tool_policy_agent_cli.py +7 -1
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_tool_policy_git_hooks.py +7 -1
- agentmetry-0.8.0/agentmetry/core/audit/tool_policy/loader.py +0 -44
- {agentmetry-0.8.0 → agentmetry-0.9.0}/.dockerignore +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/.env.agentmetry-demo +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/.env.example +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/.gitignore +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/Dockerfile +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/README.md +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/__init__.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/api/__init__.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/api/main.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/api/routes/__init__.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/api/routes/audit.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/api/websocket.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/api/ws_bridge.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/cli/__main__.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/__init__.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/__init__.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/adapters/__init__.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/adapters/agt.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/adapters/chronicle.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/adapters/cloudevents.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/adapters/ecs.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/adapters/splunk.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/alerts.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/atlas.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/canonical.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/compliance_digest.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/__init__.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/benchmark.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_approval_denied_then_executed.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_arbitrary_host_stage_execute.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_autonomous_unapproved_write.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_credential_exfil.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_credential_then_cloud_api.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_destructive_delete_burst.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_discovery_then_collect.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_dotfile_then_git_push.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_encoded_command_download.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_env_credential_exfil.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_hashed_only_no_command.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_interpreter_egress.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_pr_merged_without_review.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_proc_substitution_cradle.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_remote_pipe_to_shell.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_remote_staging_then_execute.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_secret_manager_then_egress.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_session_tool_burst.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_single_command_exfil.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_single_quoted_interpreter_egress.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_ssh_directory_exfil.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_subagent_swarm.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_timestamp_collision.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_untrusted_input_then_action.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_authoring_merge_fixtures.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_autonomous_after_approval.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_build_artifact_cleanup.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_ci_artifact_download.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_commit_message_names_cloud_clis.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_database_migration.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_dependency_install_and_build.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_download_release_archive.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_env_example_read.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_fetch_data_then_run_repo_script.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_fetch_dataset_then_analyse.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_fetch_lockfile_then_install.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_fetch_piped_into_own_script.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_git_review_and_push.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_github_raw_docs_then_script.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_human_driven_deletes.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_local_api_probing.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_long_but_calm_session.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_loopback_is_not_egress.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_loopback_pipe_to_interpreter.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_mcp_text_mentions_credential_path.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_ordinary_development.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_package_manager_after_fetch.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_reading_config_that_is_not_secret.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_remote_api_call_no_credentials.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_research_then_docs.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_reversed_order_is_not_exfil.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_secret_names_and_docs.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_test_and_fix_loop.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_writing_about_credentials.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/corpus.yaml +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/disposition.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/engine.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/live.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/live_store.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/models.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/rules.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/traits.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/yaml_config.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/yaml_rules.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/dlp/__init__.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/dlp/loader.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/dlp/models.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/dlp/scanner.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/dogfood.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/evidence_pack.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/external.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/hashing.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/heartbeat.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/hook_bootstrap.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/identity.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/ingest.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/migrate.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/mitre.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/policy.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/redaction.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/replay.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/run_context.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/sinks.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/spool.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/tool_policy/__init__.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/trail_anchor.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/trail_chain.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/trail_db.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/trail_merkle.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/auth.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/bus/__init__.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/bus/audit_exporter.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/bus/bridges.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/bus/bus.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/bus/events.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/bus/outbox.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/config.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/diagnostics/__init__.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/diagnostics/autostart.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/diagnostics/driver_paths.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/diagnostics/env_file.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/diagnostics/hook_coverage.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/diagnostics/mcp_inventory.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/diagnostics/mcp_schema.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/drivers/__init__.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/drivers/host.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/drivers/permissions.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/drivers/spec.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/extensions.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/health.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/hooks/__init__.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/policies/detection/manifest.yaml +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/policies/dlp/manifest.yaml +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/policies/opa/agent_rules.rego +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/policies/tool/manifest.yaml +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/pyproject.toml +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/fixtures/agt_filesink_exfil.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/fixtures/agt_filesink_mixed.jsonl +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/fixtures/fake_mcp_server.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_agentmetry_audit.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_agentmetry_ingest_client.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_agt_adapter.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_atlas_detection_enrichment.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_atlas_mapping.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_audit_sinks.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_audit_stats.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_audit_tail.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_auth.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_autostart.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_boot_sequence.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_burst_windows.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_chinese_agent_hooks.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_chinese_agent_sprint_b.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_chinese_agent_sprint_c.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_chronicle_adapter.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_cli_backup.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_cli_commands.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_cli_env_var_names.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_cloudevents_adapter.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_compliance_digest.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_crewai_adapter.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_detection_adi.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_detection_benchmark.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_detection_default_config.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_detection_disposition.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_detection_disposition_api.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_detection_duplicate_emission.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_detection_emit_durability.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_detection_engine.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_detection_evasion.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_detection_hf_incident.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_detection_quoted_command_words.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_detection_rule_identity.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_detection_rules_v2.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_dlp_agent_env_override.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_dlp_invisible_unicode.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_dlp_markdown_exfil.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_dlp_scanner.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_doctor.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_dogfood.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_dogfood_freeze.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_driver_paths.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_ecs_threat.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_event_bus.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_evidence_pack.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_extensions.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_external_ingest.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_forwarding_shape.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_funding_manifest.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_heartbeat.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_hook_bootstrap.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_hook_coverage.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_hook_spool.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_identity_enrich.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_ingest_roundtrip.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_isolation.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_launch_targets.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_live_detection.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_live_detection_store.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_mcp_audit_proxy.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_mcp_concealed_text.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_mcp_inventory.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_mcp_meta_fingerprint.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_mcp_schema.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_mitre_dlp.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_opensre_adapter.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_powershell_encoding.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_readme_claims.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_redaction.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_replay.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_sigma_pack.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_static_serving.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_subagent_lifecycle.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_tool_policy.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_trail_anchor.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_trail_chain.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_trail_concurrency.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_trail_merkle.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_unattended_agent_policy.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_version.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_yaml_detection_rules.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tools/generate_sigma_pack.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tools/mcp_audit_proxy.py +0 -0
- {agentmetry-0.8.0 → agentmetry-0.9.0}/tools/vault_fs_server.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: agentmetry
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: Local-first flight recorder for AI coding agents: hash-chained audit trail, MITRE-mapped sequence detection, DLP
|
|
5
5
|
Project-URL: Homepage, https://agentmetry.ai
|
|
6
6
|
Project-URL: Source, https://github.com/blitzcrieg1/agentmetry
|
|
@@ -896,6 +896,35 @@ def cmd_mcp(args: argparse.Namespace) -> int:
|
|
|
896
896
|
return 0
|
|
897
897
|
|
|
898
898
|
|
|
899
|
+
def cmd_otel(args: argparse.Namespace) -> int:
|
|
900
|
+
"""Receive Claude Code's native OpenTelemetry stream and record it.
|
|
901
|
+
|
|
902
|
+
The other way in for Claude Code, with no hooks installed. It sees what ran,
|
|
903
|
+
after it ran, so it records and detects but enforces nothing.
|
|
904
|
+
"""
|
|
905
|
+
from agentmetry.core.audit import otel_ingest
|
|
906
|
+
|
|
907
|
+
port = args.listen_port or otel_ingest.default_port()
|
|
908
|
+
if args.print_mapping:
|
|
909
|
+
for line in otel_ingest.mapping_lines():
|
|
910
|
+
print(line)
|
|
911
|
+
return 0
|
|
912
|
+
if args.print_env:
|
|
913
|
+
for line in otel_ingest.claude_env_lines(port):
|
|
914
|
+
print(line)
|
|
915
|
+
return 0
|
|
916
|
+
|
|
917
|
+
# The receiver forwards through the hook client, which reads its target and
|
|
918
|
+
# its command-text setting from the environment. Setting them here, in this
|
|
919
|
+
# process only, is what makes --port and --keep-command mean the same thing
|
|
920
|
+
# they mean everywhere else.
|
|
921
|
+
orchestrator = _api_base_url(args.port)
|
|
922
|
+
os.environ["AGENTMETRY_URL"] = orchestrator
|
|
923
|
+
if args.keep_command:
|
|
924
|
+
os.environ["AGENTMETRY_LOG_COMMANDS"] = "1"
|
|
925
|
+
return otel_ingest.serve(port, orchestrator)
|
|
926
|
+
|
|
927
|
+
|
|
899
928
|
def cmd_anchor(args: argparse.Namespace) -> int:
|
|
900
929
|
"""Publish, list, or check the checkpoints that commit the trail externally.
|
|
901
930
|
|
|
@@ -1308,6 +1337,25 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1308
1337
|
"--digest", action="store_true",
|
|
1309
1338
|
help="also print the config digest, which names no server and is safe to publish",
|
|
1310
1339
|
)
|
|
1340
|
+
otel = sub.add_parser(
|
|
1341
|
+
"otel", help="receive Claude Code's native OpenTelemetry stream (no hooks needed)"
|
|
1342
|
+
)
|
|
1343
|
+
otel.add_argument(
|
|
1344
|
+
"--listen-port", type=int, default=None,
|
|
1345
|
+
help="port on 127.0.0.1 to receive OTLP on (default AGENTMETRY_OTEL_PORT or 4318)",
|
|
1346
|
+
)
|
|
1347
|
+
otel.add_argument(
|
|
1348
|
+
"--keep-command", action="store_true",
|
|
1349
|
+
help="keep scrubbed command text, as AGENTMETRY_LOG_COMMANDS=1 does for hooks",
|
|
1350
|
+
)
|
|
1351
|
+
otel.add_argument(
|
|
1352
|
+
"--print-mapping", action="store_true",
|
|
1353
|
+
help="list which events are mapped, counted, or unmapped, and exit",
|
|
1354
|
+
)
|
|
1355
|
+
otel.add_argument(
|
|
1356
|
+
"--print-env", action="store_true",
|
|
1357
|
+
help="print the environment Claude Code needs to send here, and exit",
|
|
1358
|
+
)
|
|
1311
1359
|
verify = sub.add_parser("verify", help="verify evidence pack or JSONL trail chain")
|
|
1312
1360
|
verify.add_argument(
|
|
1313
1361
|
"path",
|
|
@@ -1374,6 +1422,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1374
1422
|
"prove": cmd_prove,
|
|
1375
1423
|
"anchor": cmd_anchor,
|
|
1376
1424
|
"mcp": cmd_mcp,
|
|
1425
|
+
"otel": cmd_otel,
|
|
1377
1426
|
"import-agt": cmd_import_agt,
|
|
1378
1427
|
"doctor": cmd_doctor,
|
|
1379
1428
|
"benchmark": cmd_benchmark,
|
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
"""Claude Code's native OpenTelemetry stream, as Agentmetry events.
|
|
2
|
+
|
|
3
|
+
Claude Code exports its own telemetry over OTLP, and its documentation leaves
|
|
4
|
+
anomaly detection, correlation and alerting to the backend. This module is that
|
|
5
|
+
backend's front door: a loopback OTLP/HTTP JSON receiver that maps the
|
|
6
|
+
``claude_code.*`` log events it can onto the external-ingest body and sends
|
|
7
|
+
them through the hook client's own ``post_ingest``, so they get the same
|
|
8
|
+
argument hashing, trait labels, MITRE and ATLAS enrichment, and the same spool
|
|
9
|
+
when the orchestrator is down.
|
|
10
|
+
|
|
11
|
+
Every event that is not forwarded is counted under a name, never dropped
|
|
12
|
+
silently. The unmapped counts are the input to canonical schema v1.3.
|
|
13
|
+
|
|
14
|
+
What this path cannot see, stated where the code is: OTel reports what happened,
|
|
15
|
+
after it happened. There is no pre-execution decision here, so no policy can be
|
|
16
|
+
enforced from it. Hooks remain the path for deny and ask.
|
|
17
|
+
|
|
18
|
+
Claude Code side (code.claude.com/docs/en/monitoring-usage)::
|
|
19
|
+
|
|
20
|
+
CLAUDE_CODE_ENABLE_TELEMETRY=1
|
|
21
|
+
OTEL_LOGS_EXPORTER=otlp
|
|
22
|
+
OTEL_EXPORTER_OTLP_PROTOCOL=http/json
|
|
23
|
+
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318
|
|
24
|
+
OTEL_LOG_TOOL_DETAILS=1 # without it, no tool arguments: hash and traits are empty
|
|
25
|
+
OTEL_METRICS_EXPORTER=none # metrics are aggregates, acknowledged and not mapped
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import gzip
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import sys
|
|
34
|
+
import threading
|
|
35
|
+
from collections.abc import Callable, Iterator
|
|
36
|
+
from datetime import datetime, timezone
|
|
37
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
from agentmetry.hooks.ingest import _hash_tool_args, post_ingest, redact_arguments
|
|
41
|
+
|
|
42
|
+
#: The receiver binds here and nowhere else. The stream carries tool arguments
|
|
43
|
+
#: when OTEL_LOG_TOOL_DETAILS is on, and nothing about it needs another host.
|
|
44
|
+
LISTEN_HOST = "127.0.0.1"
|
|
45
|
+
DEFAULT_PORT = 4318
|
|
46
|
+
|
|
47
|
+
#: Documented event types with no canonical home yet: the schema v1.3 input.
|
|
48
|
+
#: `user_prompt` and `assistant_response` would never be forwarded regardless.
|
|
49
|
+
#: They are the model's words, and the recorder's default is structure, not
|
|
50
|
+
#: content.
|
|
51
|
+
KNOWN_UNMAPPED = frozenset({
|
|
52
|
+
"user_prompt",
|
|
53
|
+
"assistant_response",
|
|
54
|
+
"api_request",
|
|
55
|
+
"api_error",
|
|
56
|
+
"api_refusal",
|
|
57
|
+
"api_request_body",
|
|
58
|
+
"api_response_body",
|
|
59
|
+
"api_retries_exhausted",
|
|
60
|
+
"internal_error",
|
|
61
|
+
"permission_mode_changed",
|
|
62
|
+
"auth",
|
|
63
|
+
"plugin_installed",
|
|
64
|
+
"plugin_loaded",
|
|
65
|
+
"skill_activated",
|
|
66
|
+
"at_mention",
|
|
67
|
+
"hook_registered",
|
|
68
|
+
"hook_execution_start",
|
|
69
|
+
"hook_execution_complete",
|
|
70
|
+
"hook_plugin_metrics",
|
|
71
|
+
"compaction",
|
|
72
|
+
"subagent_completed",
|
|
73
|
+
"feedback_survey",
|
|
74
|
+
"retention_sweep",
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
#: `tool_decision.source` values that mean a person answered a prompt.
|
|
78
|
+
_HUMAN_ACCEPT = frozenset({"user_temporary", "user_permanent"})
|
|
79
|
+
_HUMAN_REJECT = frozenset({"user_reject", "user_abort"})
|
|
80
|
+
|
|
81
|
+
Forward = Callable[[dict[str, Any]], bool]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# --------------------------------------------------------------------- decoding
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def otlp_value(value: Any) -> Any:
|
|
88
|
+
"""Decode one OTLP JSON ``AnyValue`` into a plain Python value."""
|
|
89
|
+
if not isinstance(value, dict):
|
|
90
|
+
return value
|
|
91
|
+
for key, cast in (
|
|
92
|
+
("stringValue", str),
|
|
93
|
+
("boolValue", bool),
|
|
94
|
+
("intValue", int),
|
|
95
|
+
("doubleValue", float),
|
|
96
|
+
):
|
|
97
|
+
if key in value:
|
|
98
|
+
try:
|
|
99
|
+
return cast(value[key])
|
|
100
|
+
except (TypeError, ValueError):
|
|
101
|
+
return value[key]
|
|
102
|
+
if "arrayValue" in value:
|
|
103
|
+
return [otlp_value(v) for v in (value["arrayValue"] or {}).get("values", [])]
|
|
104
|
+
if "kvlistValue" in value:
|
|
105
|
+
return otlp_attributes((value["kvlistValue"] or {}).get("values", []))
|
|
106
|
+
if "bytesValue" in value:
|
|
107
|
+
return repr(value["bytesValue"])
|
|
108
|
+
return value
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def otlp_attributes(items: Any) -> dict[str, Any]:
|
|
112
|
+
out: dict[str, Any] = {}
|
|
113
|
+
for item in items or []:
|
|
114
|
+
if isinstance(item, dict) and item.get("key"):
|
|
115
|
+
out[item["key"]] = otlp_value(item.get("value", {}))
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _merge_body(record: dict[str, Any], attrs: dict[str, Any]) -> None:
|
|
120
|
+
"""Let a JSON body backfill attributes. An attribute wins on conflict.
|
|
121
|
+
|
|
122
|
+
Claude Code documents its events by attributes; some exporter paths put the
|
|
123
|
+
same payload in the body instead.
|
|
124
|
+
"""
|
|
125
|
+
body = record.get("body")
|
|
126
|
+
text = body.get("stringValue") if isinstance(body, dict) else None
|
|
127
|
+
if isinstance(text, str) and text.startswith("{"):
|
|
128
|
+
try:
|
|
129
|
+
parsed = json.loads(text)
|
|
130
|
+
except ValueError:
|
|
131
|
+
return
|
|
132
|
+
if isinstance(parsed, dict):
|
|
133
|
+
for key, val in parsed.items():
|
|
134
|
+
attrs.setdefault(key, val)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _timestamp(record: dict[str, Any], attrs: dict[str, Any]) -> str:
|
|
138
|
+
"""When it happened, by the exporter's clock, never ours if it said."""
|
|
139
|
+
for ns in (record.get("timeUnixNano"), record.get("observedTimeUnixNano")):
|
|
140
|
+
try:
|
|
141
|
+
if ns and int(ns) > 0:
|
|
142
|
+
return datetime.fromtimestamp(int(ns) / 1e9, tz=timezone.utc).isoformat()
|
|
143
|
+
except (TypeError, ValueError, OSError, OverflowError):
|
|
144
|
+
continue
|
|
145
|
+
stamped = attrs.get("event.timestamp")
|
|
146
|
+
if isinstance(stamped, str) and stamped:
|
|
147
|
+
return stamped
|
|
148
|
+
return datetime.now(timezone.utc).isoformat()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _flag(value: Any, default: bool = True) -> bool:
|
|
152
|
+
"""Claude Code sends `success` as the string "true" or "false".
|
|
153
|
+
|
|
154
|
+
`bool("false")` is True, so reading it as a truthy value recorded every
|
|
155
|
+
failed tool call as a success.
|
|
156
|
+
"""
|
|
157
|
+
if value is None:
|
|
158
|
+
return default
|
|
159
|
+
if isinstance(value, bool):
|
|
160
|
+
return value
|
|
161
|
+
if isinstance(value, (int, float)):
|
|
162
|
+
return value != 0
|
|
163
|
+
text = str(value).strip().lower()
|
|
164
|
+
if text in ("false", "0", "no", ""):
|
|
165
|
+
return False
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _json_object(value: Any) -> dict[str, Any] | None:
|
|
170
|
+
"""`tool_input` and `tool_parameters` arrive as JSON strings, not objects."""
|
|
171
|
+
if isinstance(value, dict):
|
|
172
|
+
return value
|
|
173
|
+
if isinstance(value, str) and value.lstrip().startswith("{"):
|
|
174
|
+
try:
|
|
175
|
+
parsed = json.loads(value)
|
|
176
|
+
except ValueError:
|
|
177
|
+
return None
|
|
178
|
+
return parsed if isinstance(parsed, dict) else None
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def tool_arguments(attrs: dict[str, Any]) -> dict[str, Any] | None:
|
|
183
|
+
"""The tool's arguments, as close to the hook's `tool_input` as OTel gets.
|
|
184
|
+
|
|
185
|
+
`tool_input` is the serialized arguments themselves, so it is preferred: it
|
|
186
|
+
is what the hook hashes, and its `command` key is what the traits read.
|
|
187
|
+
Claude Code truncates its values at 512 characters, so a long argument
|
|
188
|
+
hashes differently here than on the hook path.
|
|
189
|
+
|
|
190
|
+
`tool_parameters` is a summary, not the arguments. For Bash it carries
|
|
191
|
+
`full_command`, which is enough for traits and MITRE, so that alone is used.
|
|
192
|
+
For an MCP tool it carries only the server and tool names, and a hash of
|
|
193
|
+
those would claim to be a hash of arguments it never saw, so it is not used.
|
|
194
|
+
|
|
195
|
+
None when OTEL_LOG_TOOL_DETAILS is off: no argument detail is exported.
|
|
196
|
+
"""
|
|
197
|
+
args = _json_object(attrs.get("tool_input"))
|
|
198
|
+
if args is not None:
|
|
199
|
+
return args
|
|
200
|
+
params = _json_object(attrs.get("tool_parameters"))
|
|
201
|
+
if params and params.get("full_command"):
|
|
202
|
+
return {"command": str(params["full_command"])}
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ---------------------------------------------------------------------- mapping
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _base(attrs: dict[str, Any], ts_iso: str) -> dict[str, Any]:
|
|
210
|
+
# The hook path uses Claude Code's session id for both fields, and OTel's
|
|
211
|
+
# `session.id` is the same value, so one session correlates the same way
|
|
212
|
+
# whichever path recorded it.
|
|
213
|
+
session = str(attrs.get("session.id") or attrs.get("session_id") or "")
|
|
214
|
+
return {
|
|
215
|
+
"source_app": "claude",
|
|
216
|
+
"adapter": "otel",
|
|
217
|
+
"correlation_id": session,
|
|
218
|
+
"session_id": session,
|
|
219
|
+
"timestamp_utc": ts_iso,
|
|
220
|
+
"triggered_by": "otel_receiver",
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _map_tool_result(attrs: dict[str, Any], ts_iso: str) -> dict[str, Any]:
|
|
225
|
+
tool_name = str(attrs.get("tool_name") or "unknown")
|
|
226
|
+
succeeded = _flag(attrs.get("success"))
|
|
227
|
+
args = tool_arguments(attrs)
|
|
228
|
+
|
|
229
|
+
tool: dict[str, Any] = {"qualified": tool_name, "server": "claude"}
|
|
230
|
+
if args is not None:
|
|
231
|
+
tool["arguments"] = redact_arguments(args)
|
|
232
|
+
|
|
233
|
+
reason = ""
|
|
234
|
+
if not succeeded:
|
|
235
|
+
reason = str(attrs.get("error_type") or "tool_failed")
|
|
236
|
+
elif tool_name == "Task" and args is not None:
|
|
237
|
+
# The hook path's subagent marker, so subagent-swarm-burst counts a
|
|
238
|
+
# Task spawn the same way whichever path recorded it.
|
|
239
|
+
reason = f"subagent_start:{args.get('subagent_type') or 'subagent'}"
|
|
240
|
+
|
|
241
|
+
payload = _base(attrs, ts_iso)
|
|
242
|
+
payload.update({
|
|
243
|
+
"event_type": "tool_called" if succeeded else "tool_failed",
|
|
244
|
+
"outcome": "success" if succeeded else "error",
|
|
245
|
+
"reason": reason,
|
|
246
|
+
"tool_qualified": tool_name,
|
|
247
|
+
"model_id": str(attrs.get("model") or attrs.get("gen_ai.request.model") or ""),
|
|
248
|
+
"tool": tool,
|
|
249
|
+
})
|
|
250
|
+
# The hook client's own enrichment: hash the arguments, label traits, map
|
|
251
|
+
# MITRE and ATLAS while the plaintext is visible, keep command text only
|
|
252
|
+
# when AGENTMETRY_LOG_COMMANDS says to. Identical code, so identical output.
|
|
253
|
+
return _hash_tool_args(payload) or payload
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _map_tool_decision(attrs: dict[str, Any], ts_iso: str) -> tuple[dict[str, Any] | None, str]:
|
|
257
|
+
"""Forward a human's yes. Count everything else, and say why.
|
|
258
|
+
|
|
259
|
+
Detection reads `approval_response` as a person's decision: `success` resets
|
|
260
|
+
the unapproved-write gate, and a `denied` arms approval-denied-then-executed.
|
|
261
|
+
|
|
262
|
+
* A `config` or `hook` decision was made by a settings rule or a hook, not a
|
|
263
|
+
person, so recording it as an approval would be false.
|
|
264
|
+
* A human reject is real, but cannot be forwarded yet. That rule binds a
|
|
265
|
+
denial to a later call by argument hash or command, and `tool_decision`
|
|
266
|
+
carries neither, so it would fall back to the tool name and convict every
|
|
267
|
+
later call of the same tool. That is the false positive 0.7.0 removed.
|
|
268
|
+
Binding by `tool_use_id` is a rule change, and the rules are frozen.
|
|
269
|
+
"""
|
|
270
|
+
decision = str(attrs.get("decision") or "").lower()
|
|
271
|
+
source = str(attrs.get("source") or "").lower()
|
|
272
|
+
if decision == "accept" and source in _HUMAN_ACCEPT:
|
|
273
|
+
payload = _base(attrs, ts_iso)
|
|
274
|
+
payload.update({
|
|
275
|
+
"event_type": "approval_response",
|
|
276
|
+
"outcome": "success",
|
|
277
|
+
"reason": f"otel_decision:{source}",
|
|
278
|
+
"tool_qualified": str(attrs.get("tool_name") or ""),
|
|
279
|
+
"initiator": {"actor_type": "human", "trigger": "manual", "operator_id": "local"},
|
|
280
|
+
})
|
|
281
|
+
return payload, ""
|
|
282
|
+
if decision == "reject" and source in _HUMAN_REJECT:
|
|
283
|
+
return None, "tool_decision:human_reject"
|
|
284
|
+
return None, f"tool_decision:{source or 'unknown'}"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def map_record(event_name: str, attrs: dict[str, Any], ts_iso: str) -> tuple[dict[str, Any] | None, str]:
|
|
288
|
+
"""One `claude_code.*` event as an ingest body, or None and the name it is counted under."""
|
|
289
|
+
if event_name == "tool_result":
|
|
290
|
+
return _map_tool_result(attrs, ts_iso), ""
|
|
291
|
+
if event_name == "tool_decision":
|
|
292
|
+
return _map_tool_decision(attrs, ts_iso)
|
|
293
|
+
if event_name == "mcp_server_connection":
|
|
294
|
+
# Claude Code documents no fingerprint on this event, so in practice it
|
|
295
|
+
# is counted. The mapping stays for an exporter that does carry one.
|
|
296
|
+
fingerprint = attrs.get("server_fingerprint")
|
|
297
|
+
if not fingerprint:
|
|
298
|
+
return None, event_name
|
|
299
|
+
payload = _base(attrs, ts_iso)
|
|
300
|
+
payload.update({
|
|
301
|
+
"event_type": "mcp_schema",
|
|
302
|
+
"schema_fingerprint": str(fingerprint),
|
|
303
|
+
"reason": str(attrs.get("server_name") or ""),
|
|
304
|
+
})
|
|
305
|
+
return payload, ""
|
|
306
|
+
return None, event_name
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def iter_records(export: dict[str, Any]) -> Iterator[tuple[str, dict[str, Any], str]]:
|
|
310
|
+
"""(event name, attributes, timestamp) for every log record in an OTLP export."""
|
|
311
|
+
for res_log in export.get("resourceLogs", []) or []:
|
|
312
|
+
for scope in (res_log or {}).get("scopeLogs", []) or []:
|
|
313
|
+
for record in (scope or {}).get("logRecords", []) or []:
|
|
314
|
+
if not isinstance(record, dict):
|
|
315
|
+
continue
|
|
316
|
+
attrs = otlp_attributes(record.get("attributes"))
|
|
317
|
+
_merge_body(record, attrs)
|
|
318
|
+
name = str(attrs.get("event.name") or record.get("eventName") or "")
|
|
319
|
+
name = name.removeprefix("claude_code.")
|
|
320
|
+
if name:
|
|
321
|
+
yield name, attrs, _timestamp(record, attrs)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# ------------------------------------------------------------------------ stats
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class ReceiverStats:
|
|
328
|
+
"""What came in, what went out, and what was held back, by name."""
|
|
329
|
+
|
|
330
|
+
def __init__(self) -> None:
|
|
331
|
+
self._lock = threading.Lock()
|
|
332
|
+
self.received: dict[str, int] = {}
|
|
333
|
+
self.unmapped: dict[str, int] = {}
|
|
334
|
+
self.unknown: dict[str, int] = {}
|
|
335
|
+
self.forwarded = 0
|
|
336
|
+
self.spooled = 0
|
|
337
|
+
self.metrics_acked = 0
|
|
338
|
+
|
|
339
|
+
def _bump(self, table: dict[str, int], key: str) -> int:
|
|
340
|
+
with self._lock:
|
|
341
|
+
table[key] = table.get(key, 0) + 1
|
|
342
|
+
return table[key]
|
|
343
|
+
|
|
344
|
+
def note_received(self, name: str) -> None:
|
|
345
|
+
self._bump(self.received, name)
|
|
346
|
+
|
|
347
|
+
def note_unmapped(self, name: str) -> bool:
|
|
348
|
+
"""Count it. True the first time an undocumented name is seen."""
|
|
349
|
+
base = name.split(":", 1)[0]
|
|
350
|
+
if base in KNOWN_UNMAPPED or base in ("tool_decision", "mcp_server_connection"):
|
|
351
|
+
self._bump(self.unmapped, name)
|
|
352
|
+
return False
|
|
353
|
+
return self._bump(self.unknown, name) == 1
|
|
354
|
+
|
|
355
|
+
def note_forward(self, ok: bool) -> None:
|
|
356
|
+
with self._lock:
|
|
357
|
+
if ok:
|
|
358
|
+
self.forwarded += 1
|
|
359
|
+
else:
|
|
360
|
+
self.spooled += 1
|
|
361
|
+
|
|
362
|
+
def note_metrics(self) -> None:
|
|
363
|
+
with self._lock:
|
|
364
|
+
self.metrics_acked += 1
|
|
365
|
+
|
|
366
|
+
def snapshot(self) -> dict[str, Any]:
|
|
367
|
+
with self._lock:
|
|
368
|
+
return {
|
|
369
|
+
"forwarded": self.forwarded,
|
|
370
|
+
# post_ingest spools what it cannot deliver; the orchestrator
|
|
371
|
+
# drains the spool at boot.
|
|
372
|
+
"not_delivered_spooled": self.spooled,
|
|
373
|
+
"received_by_event": dict(self.received),
|
|
374
|
+
"unmapped_by_event": dict(self.unmapped),
|
|
375
|
+
"undocumented_by_event": dict(self.unknown),
|
|
376
|
+
"metrics_exports_acked": self.metrics_acked,
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def translate(export: dict[str, Any], stats: ReceiverStats) -> list[dict[str, Any]]:
|
|
381
|
+
"""Map one OTLP logs export, counting every record under a name."""
|
|
382
|
+
out: list[dict[str, Any]] = []
|
|
383
|
+
for name, attrs, ts_iso in iter_records(export):
|
|
384
|
+
stats.note_received(name)
|
|
385
|
+
payload, counted_as = map_record(name, attrs, ts_iso)
|
|
386
|
+
if payload is not None:
|
|
387
|
+
out.append(payload)
|
|
388
|
+
elif stats.note_unmapped(counted_as):
|
|
389
|
+
_log(f"undocumented event {counted_as!r}: counted, not forwarded")
|
|
390
|
+
return out
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _log(msg: str) -> None:
|
|
394
|
+
print(f"[otel {datetime.now().strftime('%H:%M:%S')}] {msg}", file=sys.stderr, flush=True)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# ----------------------------------------------------------------------- server
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _quiet_post(payload: dict[str, Any]) -> bool:
|
|
401
|
+
return post_ingest(payload, quiet=True)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
405
|
+
server_version = "agentmetry-otel/1"
|
|
406
|
+
# OTLP exporters keep the connection alive; HTTP/1.0 semantics stall them.
|
|
407
|
+
protocol_version = "HTTP/1.1"
|
|
408
|
+
|
|
409
|
+
def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
|
410
|
+
stats: ReceiverStats = self.server.stats # type: ignore[attr-defined]
|
|
411
|
+
forward: Forward = self.server.forward # type: ignore[attr-defined]
|
|
412
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
413
|
+
raw = self.rfile.read(length) if length else b""
|
|
414
|
+
|
|
415
|
+
if self.path.startswith("/v1/metrics"):
|
|
416
|
+
# Aggregates, not session events. Acknowledge so the exporter does
|
|
417
|
+
# not retry forever.
|
|
418
|
+
stats.note_metrics()
|
|
419
|
+
self._reply(200)
|
|
420
|
+
return
|
|
421
|
+
if not self.path.startswith("/v1/logs"):
|
|
422
|
+
self._reply(404)
|
|
423
|
+
return
|
|
424
|
+
if "protobuf" in (self.headers.get("Content-Type") or "").lower():
|
|
425
|
+
if not getattr(self.server, "warned_protobuf", False):
|
|
426
|
+
self.server.warned_protobuf = True # type: ignore[attr-defined]
|
|
427
|
+
_log("received protobuf; set OTEL_EXPORTER_OTLP_PROTOCOL=http/json")
|
|
428
|
+
self._reply(415)
|
|
429
|
+
return
|
|
430
|
+
if (self.headers.get("Content-Encoding") or "").lower() == "gzip":
|
|
431
|
+
try:
|
|
432
|
+
raw = gzip.decompress(raw)
|
|
433
|
+
except OSError:
|
|
434
|
+
self._reply(400)
|
|
435
|
+
return
|
|
436
|
+
try:
|
|
437
|
+
export = json.loads(raw or b"{}")
|
|
438
|
+
except ValueError:
|
|
439
|
+
self._reply(400)
|
|
440
|
+
return
|
|
441
|
+
if not isinstance(export, dict):
|
|
442
|
+
self._reply(400)
|
|
443
|
+
return
|
|
444
|
+
|
|
445
|
+
for payload in translate(export, stats):
|
|
446
|
+
stats.note_forward(forward(payload))
|
|
447
|
+
self._reply(200, b"{}")
|
|
448
|
+
|
|
449
|
+
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
|
450
|
+
if self.path in ("/healthz", "/"):
|
|
451
|
+
stats: ReceiverStats = self.server.stats # type: ignore[attr-defined]
|
|
452
|
+
self._reply(200, json.dumps(stats.snapshot()).encode())
|
|
453
|
+
return
|
|
454
|
+
self._reply(404)
|
|
455
|
+
|
|
456
|
+
def _reply(self, code: int, body: bytes = b"") -> None:
|
|
457
|
+
# Headers end before the body, or HTTP/1.1 clients wait for a separator.
|
|
458
|
+
self.send_response(code)
|
|
459
|
+
self.send_header("Content-Type", "application/json")
|
|
460
|
+
self.send_header("Content-Length", str(len(body)))
|
|
461
|
+
self.end_headers()
|
|
462
|
+
if body:
|
|
463
|
+
self.wfile.write(body)
|
|
464
|
+
|
|
465
|
+
def log_message(self, format: str, *args: Any) -> None: # noqa: A002
|
|
466
|
+
return
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def make_server(port: int = DEFAULT_PORT, *, forward: Forward | None = None,
|
|
470
|
+
stats: ReceiverStats | None = None) -> ThreadingHTTPServer:
|
|
471
|
+
"""A receiver bound to loopback. Port 0 picks a free one (tests)."""
|
|
472
|
+
server = ThreadingHTTPServer((LISTEN_HOST, port), _Handler)
|
|
473
|
+
server.stats = stats or ReceiverStats() # type: ignore[attr-defined]
|
|
474
|
+
server.forward = forward or _quiet_post # type: ignore[attr-defined]
|
|
475
|
+
return server
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def default_port() -> int:
|
|
479
|
+
try:
|
|
480
|
+
return int(os.environ.get("AGENTMETRY_OTEL_PORT", "") or DEFAULT_PORT)
|
|
481
|
+
except ValueError:
|
|
482
|
+
return DEFAULT_PORT
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def claude_env_lines(port: int) -> list[str]:
|
|
486
|
+
return [
|
|
487
|
+
"CLAUDE_CODE_ENABLE_TELEMETRY=1",
|
|
488
|
+
"OTEL_LOGS_EXPORTER=otlp",
|
|
489
|
+
"OTEL_EXPORTER_OTLP_PROTOCOL=http/json",
|
|
490
|
+
f"OTEL_EXPORTER_OTLP_ENDPOINT=http://{LISTEN_HOST}:{port}",
|
|
491
|
+
"OTEL_LOG_TOOL_DETAILS=1",
|
|
492
|
+
"OTEL_METRICS_EXPORTER=none",
|
|
493
|
+
]
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def mapping_lines() -> list[str]:
|
|
497
|
+
lines = [
|
|
498
|
+
"mapped: tool_result -> tool_called / tool_failed (hash, traits, MITRE, ATLAS)",
|
|
499
|
+
"mapped: tool_decision accepted by a person -> approval_response success",
|
|
500
|
+
"counted: tool_decision rejected by a person (a denial needs a binding the event lacks)",
|
|
501
|
+
"counted: tool_decision by config or hook (not a human decision)",
|
|
502
|
+
"partial: mcp_server_connection -> mcp_schema, only with a fingerprint attribute",
|
|
503
|
+
]
|
|
504
|
+
lines += [f"unmapped: {name}" for name in sorted(KNOWN_UNMAPPED)]
|
|
505
|
+
return lines
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def serve(port: int, orchestrator: str) -> int:
|
|
509
|
+
"""Run in the foreground until interrupted, then print the counts."""
|
|
510
|
+
try:
|
|
511
|
+
server = make_server(port)
|
|
512
|
+
except OSError as exc:
|
|
513
|
+
print(f"Cannot listen on {LISTEN_HOST}:{port}: {exc}", file=sys.stderr)
|
|
514
|
+
return 1
|
|
515
|
+
_log(f"OTLP receiver on http://{LISTEN_HOST}:{port} -> {orchestrator}")
|
|
516
|
+
_log("Start Claude Code with:")
|
|
517
|
+
for line in claude_env_lines(port):
|
|
518
|
+
_log(f" {line}")
|
|
519
|
+
try:
|
|
520
|
+
server.serve_forever()
|
|
521
|
+
except KeyboardInterrupt:
|
|
522
|
+
pass
|
|
523
|
+
finally:
|
|
524
|
+
server.server_close()
|
|
525
|
+
_log(json.dumps(server.stats.snapshot())) # type: ignore[attr-defined]
|
|
526
|
+
return 0
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Where a policy `ask` reaches a human as a prompt, per agent and hook.
|
|
2
|
+
|
|
3
|
+
An ask the agent ignores is an allow. Every vendor that does not recognise the
|
|
4
|
+
ask value treats the hook as having said nothing, and the tool runs. That is the
|
|
5
|
+
worst way for this control to fail, because the operator wrote a rule asking
|
|
6
|
+
for a human and got no human and no block.
|
|
7
|
+
|
|
8
|
+
So the default for every agent and hook is that an ask is NOT honoured, and the
|
|
9
|
+
hook enforces it as deny. Only the pairs below, each checked against the
|
|
10
|
+
vendor's published hook documentation, are trusted to show a real prompt.
|
|
11
|
+
|
|
12
|
+
Deliberately absent, because their documentation says they do not honour it:
|
|
13
|
+
|
|
14
|
+
Cursor preToolUse "accepted by the schema but not enforced today"
|
|
15
|
+
any PermissionRequest Claude Code's schema takes allow|deny only
|
|
16
|
+
|
|
17
|
+
Absent because nobody has checked: every other agent and hook. That includes
|
|
18
|
+
Qwen, Qoder, CodeBuddy and Kimi, which speak Claude Code's hook protocol and so
|
|
19
|
+
receive the same encoding, but whose vendors have not been verified to honour an
|
|
20
|
+
ask. A pair is added here when somebody has read that vendor's documentation,
|
|
21
|
+
not because the protocol looks compatible.
|
|
22
|
+
|
|
23
|
+
Pure stdlib on purpose. The hook imports this on every tool call, and #171 is
|
|
24
|
+
about what the hook already pays to import.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
ASK_HONOURED: frozenset[tuple[str, str]] = frozenset(
|
|
30
|
+
{
|
|
31
|
+
# Claude Code: hookSpecificOutput.permissionDecision = "ask".
|
|
32
|
+
("claude", "PreToolUse"),
|
|
33
|
+
# Cursor: permission = "ask" returns a prompt on these two only.
|
|
34
|
+
("cursor", "beforeShellExecution"),
|
|
35
|
+
("cursor", "beforeMCPExecution"),
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def ask_honoured(source_app: str, hook_name: str) -> bool:
|
|
41
|
+
"""True only where an ask is known to reach a human."""
|
|
42
|
+
return (str(source_app).lower(), hook_name) in ASK_HONOURED
|