agentmetry 0.5.0__tar.gz → 0.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agentmetry-0.5.0 → agentmetry-0.6.0}/.gitignore +4 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/PKG-INFO +3 -2
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/cli/__init__.py +30 -7
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/disposition.py +52 -6
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/yaml_config.py +8 -2
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/ingest.py +155 -21
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/trail_anchor.py +55 -6
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/trail_db.py +10 -3
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/bus/events.py +1 -1
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/diagnostics/doctor.py +1 -1
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/diagnostics/driver_paths.py +6 -4
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/diagnostics/mcp_schema.py +175 -10
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/version.py +1 -1
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/hooks/ingest.py +38 -6
- {agentmetry-0.5.0 → agentmetry-0.6.0}/pyproject.toml +34 -2
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_agentmetry_ingest_client.py +47 -0
- agentmetry-0.6.0/tests/test_cli_commands.py +467 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_detection_disposition.py +47 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_mcp_audit_proxy.py +61 -7
- agentmetry-0.6.0/tests/test_mcp_schema.py +490 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_readme_claims.py +53 -0
- agentmetry-0.6.0/tests/test_sigma_pack.py +126 -0
- agentmetry-0.6.0/tools/generate_sigma_pack.py +198 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tools/mcp_audit_proxy.py +81 -7
- agentmetry-0.5.0/tests/test_mcp_schema.py +0 -233
- {agentmetry-0.5.0 → agentmetry-0.6.0}/.dockerignore +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/.env.agentmetry-demo +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/.env.example +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/Dockerfile +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/README.md +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/__init__.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/api/__init__.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/api/main.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/api/routes/__init__.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/api/routes/audit.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/api/websocket.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/api/ws_bridge.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/cli/__main__.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/__init__.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/__init__.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/adapters/__init__.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/adapters/agt.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/adapters/chronicle.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/adapters/cloudevents.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/adapters/ecs.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/adapters/splunk.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/alerts.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/atlas.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/canonical.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/compliance_digest.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/__init__.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/benchmark.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_approval_denied_then_executed.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_arbitrary_host_stage_execute.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_autonomous_unapproved_write.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_credential_exfil.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_credential_then_cloud_api.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_destructive_delete_burst.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_discovery_then_collect.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_dotfile_then_git_push.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_encoded_command_download.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_env_credential_exfil.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_hashed_only_no_command.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_interpreter_egress.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_pr_merged_without_review.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_proc_substitution_cradle.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_remote_pipe_to_shell.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_remote_staging_then_execute.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_secret_manager_then_egress.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_session_tool_burst.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_single_command_exfil.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_single_quoted_interpreter_egress.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_ssh_directory_exfil.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_subagent_swarm.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_timestamp_collision.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_untrusted_input_then_action.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_authoring_merge_fixtures.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_autonomous_after_approval.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_build_artifact_cleanup.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_ci_artifact_download.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_commit_message_names_cloud_clis.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_database_migration.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_dependency_install_and_build.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_download_release_archive.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_fetch_data_then_run_repo_script.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_fetch_dataset_then_analyse.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_fetch_lockfile_then_install.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_git_review_and_push.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_human_driven_deletes.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_local_api_probing.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_long_but_calm_session.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_loopback_is_not_egress.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_loopback_pipe_to_interpreter.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_ordinary_development.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_package_manager_after_fetch.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_reading_config_that_is_not_secret.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_remote_api_call_no_credentials.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_research_then_docs.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_reversed_order_is_not_exfil.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_secret_names_and_docs.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_test_and_fix_loop.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_writing_about_credentials.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/corpus.yaml +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/engine.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/live.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/live_store.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/models.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/rules.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/traits.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/yaml_rules.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/dlp/__init__.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/dlp/loader.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/dlp/models.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/dlp/scanner.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/dogfood.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/evidence_pack.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/external.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/hashing.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/heartbeat.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/hook_bootstrap.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/identity.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/migrate.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/mitre.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/policy.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/redaction.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/replay.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/run_context.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/sinks.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/spool.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/tool_policy/__init__.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/tool_policy/evaluator.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/tool_policy/loader.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/tool_policy/models.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/trail_chain.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/trail_merkle.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/auth.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/bus/__init__.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/bus/audit_exporter.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/bus/bridges.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/bus/bus.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/bus/outbox.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/config.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/diagnostics/__init__.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/diagnostics/autostart.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/diagnostics/env_file.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/diagnostics/hook_coverage.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/diagnostics/mcp_inventory.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/drivers/__init__.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/drivers/host.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/drivers/permissions.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/drivers/spec.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/extensions.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/health.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/hooks/__init__.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/policies/detection/manifest.yaml +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/policies/dlp/manifest.yaml +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/policies/opa/agent_rules.rego +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/policies/tool/manifest.yaml +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/conftest.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/fixtures/agt_filesink_exfil.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/fixtures/agt_filesink_mixed.jsonl +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/fixtures/fake_mcp_server.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_agentmetry_audit.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_agt_adapter.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_atlas_detection_enrichment.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_atlas_mapping.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_audit_sinks.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_audit_stats.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_audit_tail.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_auth.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_autostart.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_boot_sequence.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_burst_windows.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_chinese_agent_hooks.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_chinese_agent_sprint_b.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_chinese_agent_sprint_c.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_chronicle_adapter.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_cli_backup.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_cloudevents_adapter.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_compliance_digest.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_crewai_adapter.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_detection_adi.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_detection_benchmark.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_detection_default_config.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_detection_disposition_api.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_detection_duplicate_emission.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_detection_emit_durability.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_detection_engine.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_detection_evasion.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_detection_hf_incident.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_detection_quoted_command_words.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_detection_rule_identity.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_detection_rules_v2.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_dlp_agent_env_override.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_dlp_invisible_unicode.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_dlp_markdown_exfil.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_dlp_scanner.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_doctor.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_dogfood.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_dogfood_freeze.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_driver_paths.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_ecs_threat.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_event_bus.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_evidence_pack.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_extensions.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_external_ingest.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_forwarding_shape.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_heartbeat.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_hook_bootstrap.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_hook_coverage.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_hook_enforcement_timing.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_hook_spool.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_isolation.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_launch_targets.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_live_detection.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_live_detection_store.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_mcp_inventory.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_mitre_dlp.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_opensre_adapter.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_redaction.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_replay.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_static_serving.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_subagent_lifecycle.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_tool_policy.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_tool_policy_agent_cli.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_tool_policy_git_hooks.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_trail_anchor.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_trail_chain.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_trail_concurrency.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_trail_merkle.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_unattended_agent_policy.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_version.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_yaml_detection_rules.py +0 -0
- {agentmetry-0.5.0 → agentmetry-0.6.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.6.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
|
|
@@ -35,8 +35,9 @@ Requires-Dist: uvicorn[standard]>=0.32.0
|
|
|
35
35
|
Requires-Dist: websockets>=14.0
|
|
36
36
|
Provides-Extra: dev
|
|
37
37
|
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
38
|
+
Requires-Dist: pytest-cov>=6.0; extra == 'dev'
|
|
38
39
|
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
39
|
-
Requires-Dist: ruff==0.16.
|
|
40
|
+
Requires-Dist: ruff==0.16.4; extra == 'dev'
|
|
40
41
|
Description-Content-Type: text/markdown
|
|
41
42
|
|
|
42
43
|
# Agentmetry
|
|
@@ -7,6 +7,7 @@ Pure stdlib + httpx; never imports the FastAPI app (fast startup, no side effect
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import argparse
|
|
10
|
+
import logging
|
|
10
11
|
import os
|
|
11
12
|
import shutil
|
|
12
13
|
import socket
|
|
@@ -30,11 +31,13 @@ _TASK_NAME = "Agentmetry Orchestrator"
|
|
|
30
31
|
# Paths bundled by backup, relative to the repo root.
|
|
31
32
|
_BACKUP_PREFIXES = ("vault/", "apps/orchestrator/data/")
|
|
32
33
|
_BACKUP_EXCLUDE_DIRS = {"logs"}
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
33
36
|
_BACKUP_EXCLUDE_SUFFIXES = {".pid"}
|
|
34
37
|
|
|
35
38
|
|
|
36
39
|
def _base_url(port: int, host: str = "127.0.0.1") -> str:
|
|
37
|
-
display = host if host != "0.0.0.0" else "127.0.0.1"
|
|
40
|
+
display = host if host != "0.0.0.0" else "127.0.0.1" # noqa: S104
|
|
38
41
|
return f"http://{display}:{port}"
|
|
39
42
|
|
|
40
43
|
|
|
@@ -65,8 +68,12 @@ def _fetch_health(port: int) -> dict | None:
|
|
|
65
68
|
resp = httpx.get(f"{_base_url(port)}/api/v1/health", timeout=10.0)
|
|
66
69
|
if resp.status_code == 200:
|
|
67
70
|
return resp.json()
|
|
68
|
-
except Exception:
|
|
69
|
-
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
# Not running is the expected answer here and `None` says so. Logged at
|
|
73
|
+
# debug anyway: a probe that fails for a reason other than "nothing is
|
|
74
|
+
# listening" (TLS, a proxy, a permission error) otherwise looks identical
|
|
75
|
+
# to a stopped orchestrator, and that is an hour of somebody's afternoon.
|
|
76
|
+
logger.debug("health probe on port %s failed: %s", port, exc)
|
|
70
77
|
return None
|
|
71
78
|
|
|
72
79
|
|
|
@@ -77,7 +84,7 @@ def cmd_start(args: argparse.Namespace) -> int:
|
|
|
77
84
|
host = getattr(args, "host", "127.0.0.1")
|
|
78
85
|
if _fetch_health(args.port):
|
|
79
86
|
print(f"Already running on {_base_url(args.port, host)}")
|
|
80
|
-
if host == "0.0.0.0":
|
|
87
|
+
if host == "0.0.0.0": # noqa: S104
|
|
81
88
|
_print_lan_hint(args.port)
|
|
82
89
|
return 0
|
|
83
90
|
|
|
@@ -112,7 +119,7 @@ def cmd_start(args: argparse.Namespace) -> int:
|
|
|
112
119
|
while time.monotonic() < deadline:
|
|
113
120
|
if _fetch_health(args.port):
|
|
114
121
|
print(f"Agentmetry running on {_base_url(args.port, host)} (pid {proc.pid})")
|
|
115
|
-
if host == "0.0.0.0":
|
|
122
|
+
if host == "0.0.0.0": # noqa: S104
|
|
116
123
|
_print_lan_hint(args.port)
|
|
117
124
|
return 0
|
|
118
125
|
if proc.poll() is not None:
|
|
@@ -937,7 +944,7 @@ def cmd_verify(args: argparse.Namespace) -> int:
|
|
|
937
944
|
print(line)
|
|
938
945
|
if source == "config" and not anchor_log.is_file():
|
|
939
946
|
print(f" AGENTMETRY_ANCHOR_LOG points at {anchor_log}, which does not exist")
|
|
940
|
-
if
|
|
947
|
+
if coverage.tampering:
|
|
941
948
|
# The chain printed OK several lines ago and it was telling the
|
|
942
949
|
# truth: the file is internally consistent, because whoever
|
|
943
950
|
# rewrote it made it so. Leaving that as the last word would let
|
|
@@ -947,6 +954,17 @@ def cmd_verify(args: argparse.Namespace) -> int:
|
|
|
947
954
|
"published anchor. The file was rewritten."
|
|
948
955
|
)
|
|
949
956
|
return 1
|
|
957
|
+
if not coverage.ok:
|
|
958
|
+
# Checkpoints exist and none of them cover this file. Worth
|
|
959
|
+
# failing, because an operator who configured an anchor log
|
|
960
|
+
# believes they are anchored. Not worth calling tampering:
|
|
961
|
+
# nothing here contradicts the trail, and a trust command that
|
|
962
|
+
# says "rewritten" about a clean file gets muted.
|
|
963
|
+
print(
|
|
964
|
+
"FAILED — no checkpoint in this anchor log covers this trail. "
|
|
965
|
+
"Check AGENTMETRY_ANCHOR_LOG, or anchor this file."
|
|
966
|
+
)
|
|
967
|
+
return 1
|
|
950
968
|
return 0
|
|
951
969
|
print(f"FAILED — {result.message}")
|
|
952
970
|
if result.first_bad_line:
|
|
@@ -1055,7 +1073,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1055
1073
|
# platform. Same guard as scripts/demo.py.
|
|
1056
1074
|
try:
|
|
1057
1075
|
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[union-attr]
|
|
1058
|
-
except Exception: # pragma: no cover -
|
|
1076
|
+
except Exception: # noqa: S110 # pragma: no cover - host console dependent
|
|
1077
|
+
# The one swallow with nowhere to swallow into: this runs before logging
|
|
1078
|
+
# is configured, and the failure means the console cannot render UTF-8,
|
|
1079
|
+
# which is also why a log line about it would not render. The fallback is
|
|
1080
|
+
# the console's own encoding, which is what would have happened without
|
|
1081
|
+
# the call.
|
|
1059
1082
|
pass
|
|
1060
1083
|
|
|
1061
1084
|
parser = argparse.ArgumentParser(prog="agentmetry", description="Agentmetry local ops")
|
|
@@ -62,6 +62,44 @@ _NOTE_REQUIRED = frozenset({"false_positive", "risk_accepted"})
|
|
|
62
62
|
#: States that mean no further action is expected.
|
|
63
63
|
CLOSED_STATUSES = frozenset({"resolved", "false_positive", "risk_accepted"})
|
|
64
64
|
|
|
65
|
+
|
|
66
|
+
def _loggable(value: object, *, limit: int = 120) -> str:
|
|
67
|
+
"""Flatten a value so it cannot forge log lines.
|
|
68
|
+
|
|
69
|
+
`rule_id`, `decided_by` and the disposition keys derived from
|
|
70
|
+
`correlation_id` all originate outside this process: a rule id can come from
|
|
71
|
+
an operator's YAML, a correlation id comes from the agent. A newline in any
|
|
72
|
+
of them lets the writer append whatever they like to the orchestrator log,
|
|
73
|
+
including lines that look like ours.
|
|
74
|
+
|
|
75
|
+
The bound is worth stating precisely. The trail is unaffected: canonical
|
|
76
|
+
events are JSON and the encoder escapes a newline inside a string, so
|
|
77
|
+
evidence was never forgeable this way. This protects the operator log, which
|
|
78
|
+
is what a human reads while deciding whether the evidence is worth opening.
|
|
79
|
+
|
|
80
|
+
The replacements are written out one call at a time on purpose. An earlier
|
|
81
|
+
version did the same job with `str.encode("unicode_escape")`, which is
|
|
82
|
+
shorter and which CodeQL cannot follow, so `py/log-injection` stayed open on
|
|
83
|
+
code that was already fixed. A sanitizer the scanner does not recognise
|
|
84
|
+
leaves you arguing with a dashboard instead of reading it.
|
|
85
|
+
|
|
86
|
+
Line breaks become visible escapes rather than disappearing, so a value that
|
|
87
|
+
contained one still looks different from one that did not.
|
|
88
|
+
"""
|
|
89
|
+
text = str(value)
|
|
90
|
+
if len(text) > limit:
|
|
91
|
+
text = text[: limit - 3] + "..."
|
|
92
|
+
# Backslash first, or the escapes introduced below get double-escaped.
|
|
93
|
+
text = text.replace("\\", "\\\\")
|
|
94
|
+
text = text.replace("\r", "\\r")
|
|
95
|
+
text = text.replace("\n", "\\n")
|
|
96
|
+
text = text.replace("\t", "\\t")
|
|
97
|
+
# Anything else non-printable (NUL, the ESC that starts an ANSI sequence)
|
|
98
|
+
# is dropped rather than escaped: it carries no meaning a reader needs and
|
|
99
|
+
# a terminal will act on some of it.
|
|
100
|
+
return "".join(ch for ch in text if ch.isprintable())
|
|
101
|
+
|
|
102
|
+
|
|
65
103
|
_MAX_NOTE_CHARS = 4000
|
|
66
104
|
_MAX_ASSIGNEE_CHARS = 128
|
|
67
105
|
|
|
@@ -234,7 +272,11 @@ class DispositionStore:
|
|
|
234
272
|
"DELETE FROM detection_dispositions WHERE detection_key = ?",
|
|
235
273
|
(stale_key,),
|
|
236
274
|
)
|
|
237
|
-
logger.info(
|
|
275
|
+
logger.info(
|
|
276
|
+
"Migrated disposition %s -> %s after rule rename",
|
|
277
|
+
_loggable(stale_key),
|
|
278
|
+
_loggable(key),
|
|
279
|
+
)
|
|
238
280
|
|
|
239
281
|
history.append({
|
|
240
282
|
"status": normalized,
|
|
@@ -535,14 +577,18 @@ async def apply_disposition(
|
|
|
535
577
|
try:
|
|
536
578
|
await sink.emit(event)
|
|
537
579
|
except Exception:
|
|
538
|
-
logger.exception("Failed to forward disposition for %s", rule_id)
|
|
580
|
+
logger.exception("Failed to forward disposition for %s", _loggable(rule_id))
|
|
539
581
|
|
|
582
|
+
# `previous` and `normalized` are validated against STATUSES before they get
|
|
583
|
+
# here, so passing them raw was safe and CodeQL kept flagging them anyway.
|
|
584
|
+
# Sanitizing a closed-set value is a no-op, and a no-op costs less than a
|
|
585
|
+
# dismissal somebody has to re-justify every time the tab is read.
|
|
540
586
|
logger.info(
|
|
541
587
|
"DISPOSITION %s %s -> %s by %s",
|
|
542
|
-
rule_id,
|
|
543
|
-
previous,
|
|
544
|
-
normalized,
|
|
545
|
-
decided_by or "operator",
|
|
588
|
+
_loggable(rule_id),
|
|
589
|
+
_loggable(previous),
|
|
590
|
+
_loggable(normalized),
|
|
591
|
+
_loggable(decided_by or "operator"),
|
|
546
592
|
)
|
|
547
593
|
return current
|
|
548
594
|
|
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import logging
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
from typing import Any
|
|
7
8
|
|
|
8
9
|
import yaml
|
|
9
10
|
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
10
13
|
_ORCH_ROOT = Path(__file__).resolve().parents[4]
|
|
11
14
|
_DEFAULT_MANIFEST = _ORCH_ROOT.parent.parent / "policies" / "detection" / "manifest.yaml"
|
|
12
15
|
|
|
@@ -35,8 +38,11 @@ def _manifest_path() -> Path:
|
|
|
35
38
|
custom = settings.detection_rules_path
|
|
36
39
|
if custom:
|
|
37
40
|
return Path(custom)
|
|
38
|
-
except Exception:
|
|
39
|
-
|
|
41
|
+
except Exception as exc:
|
|
42
|
+
# Falling back to the packaged manifest is correct, but doing it in
|
|
43
|
+
# silence means an operator who configured their own rules runs the
|
|
44
|
+
# default ones and is never told.
|
|
45
|
+
logger.debug("could not read detection_rules_path from settings: %s", exc)
|
|
40
46
|
return _DEFAULT_MANIFEST
|
|
41
47
|
|
|
42
48
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
-
from typing import Any
|
|
6
|
+
from typing import Any, NamedTuple
|
|
7
7
|
|
|
8
8
|
from agentmetry.core.audit.detection.live import (
|
|
9
9
|
build_detection_event,
|
|
@@ -177,7 +177,19 @@ def _get_sink():
|
|
|
177
177
|
return _sink
|
|
178
178
|
|
|
179
179
|
|
|
180
|
-
|
|
180
|
+
class _SchemaFields(NamedTuple):
|
|
181
|
+
"""Parsed `mcp_schema` payload. A NamedTuple because it outgrew a tuple."""
|
|
182
|
+
|
|
183
|
+
server: str
|
|
184
|
+
fingerprint: str
|
|
185
|
+
tool_count: int
|
|
186
|
+
source: str
|
|
187
|
+
server_version: str
|
|
188
|
+
list_changed: bool | None
|
|
189
|
+
tool_digests: dict[str, str]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _schema_payload_fields(payload: dict[str, Any]) -> _SchemaFields:
|
|
181
193
|
tool = payload.get("tool") if isinstance(payload.get("tool"), dict) else {}
|
|
182
194
|
server = str(tool.get("server") or payload.get("server") or "")
|
|
183
195
|
fingerprint = str(payload.get("schema_fingerprint") or "")
|
|
@@ -186,11 +198,34 @@ def _schema_payload_fields(payload: dict[str, Any]) -> tuple[str, str, int, str]
|
|
|
186
198
|
except (TypeError, ValueError):
|
|
187
199
|
tool_count = 0
|
|
188
200
|
source = str(payload.get("adapter") or "mcp_proxy")
|
|
189
|
-
|
|
201
|
+
server_version = str(payload.get("server_version") or "")
|
|
202
|
+
list_changed = payload.get("list_changed")
|
|
203
|
+
if list_changed is not None and not isinstance(list_changed, bool):
|
|
204
|
+
list_changed = None
|
|
205
|
+
raw_digests = payload.get("schema_tool_digests")
|
|
206
|
+
tool_digests = (
|
|
207
|
+
{str(k): str(v) for k, v in raw_digests.items() if isinstance(v, str)}
|
|
208
|
+
if isinstance(raw_digests, dict)
|
|
209
|
+
else {}
|
|
210
|
+
)
|
|
211
|
+
return _SchemaFields(
|
|
212
|
+
server, fingerprint, tool_count, source, server_version, list_changed, tool_digests
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def build_schema_canonical(
|
|
217
|
+
payload: dict[str, Any], status: str, delta: dict[str, Any] | None = None
|
|
218
|
+
) -> dict[str, Any]:
|
|
219
|
+
"""Attestation that a `tools/list` was observed. Names and descriptions stay off it.
|
|
190
220
|
|
|
221
|
+
`delta` names which tools moved, and is only meaningful when the listing
|
|
222
|
+
changed. It carries hashed tool ids and counts, never a tool name and never
|
|
223
|
+
any part of a description: enough for an operator to say "that one" and
|
|
224
|
+
reach for an inspector, not enough to put the payload in the trail.
|
|
191
225
|
|
|
192
|
-
|
|
193
|
-
|
|
226
|
+
The delta is passed in rather than computed here because it has to be read
|
|
227
|
+
before the store advances, and this function must stay pure.
|
|
228
|
+
"""
|
|
194
229
|
import uuid
|
|
195
230
|
from datetime import datetime, timezone
|
|
196
231
|
|
|
@@ -199,13 +234,40 @@ def build_schema_canonical(payload: dict[str, Any], status: str) -> dict[str, An
|
|
|
199
234
|
from agentmetry.core.audit.atlas import RUG_PULL
|
|
200
235
|
from agentmetry.core.diagnostics.mcp_schema import server_id
|
|
201
236
|
|
|
202
|
-
|
|
237
|
+
fields = _schema_payload_fields(payload)
|
|
238
|
+
server, fingerprint, tool_count = fields.server, fields.fingerprint, fields.tool_count
|
|
239
|
+
server_version, list_changed = fields.server_version, fields.list_changed
|
|
203
240
|
outcome = "changed" if status == "changed" else "success"
|
|
204
241
|
reason = (
|
|
205
242
|
"MCP tool schema changed; config may be unchanged (rug-pull candidate)"
|
|
206
243
|
if status == "changed"
|
|
207
244
|
else "MCP tool schema observed"
|
|
208
245
|
)
|
|
246
|
+
mcp_schema: dict[str, Any] = {
|
|
247
|
+
"server_id": server_id(server) if server else "",
|
|
248
|
+
"fingerprint": fingerprint,
|
|
249
|
+
"tool_count": tool_count,
|
|
250
|
+
"status": status,
|
|
251
|
+
# Only a schema that MOVED is the technique. `new` is the first
|
|
252
|
+
# sight of a server and `same` is a quiet reconnect; tagging either
|
|
253
|
+
# as a rug pull would put a Defense Evasion label on installing a
|
|
254
|
+
# tool. ATT&CK has no id for this at all, which is the clearest
|
|
255
|
+
# case in the product for ATLAS existing alongside it.
|
|
256
|
+
**({"atlas": dict(RUG_PULL)} if status == "changed" else {}),
|
|
257
|
+
}
|
|
258
|
+
if server_version:
|
|
259
|
+
mcp_schema["server_version"] = server_version
|
|
260
|
+
if list_changed is not None:
|
|
261
|
+
mcp_schema["list_changed"] = list_changed
|
|
262
|
+
if status == "changed" and delta and (
|
|
263
|
+
delta.get("changed") or delta.get("added") or delta.get("removed")
|
|
264
|
+
):
|
|
265
|
+
# Only on a move. A `new` server has nothing to diff against, and
|
|
266
|
+
# attaching an empty delta to `same` would put a field on the quietest
|
|
267
|
+
# event class in the trail for no reader.
|
|
268
|
+
mcp_schema["tools_changed"] = list(delta.get("changed") or [])
|
|
269
|
+
mcp_schema["tools_added"] = int(delta.get("added") or 0)
|
|
270
|
+
mcp_schema["tools_removed"] = int(delta.get("removed") or 0)
|
|
209
271
|
return {
|
|
210
272
|
"schema_version": SCHEMA_VERSION,
|
|
211
273
|
"event_id": str(uuid.uuid4()),
|
|
@@ -223,21 +285,74 @@ def build_schema_canonical(payload: dict[str, Any], status: str) -> dict[str, An
|
|
|
223
285
|
"actor": {"type": "system", "id": "agentmetry", "role": "recorder"},
|
|
224
286
|
"action": {"type": "mcp_schema", "outcome": outcome, "reason": reason},
|
|
225
287
|
"agent": {"name": "agentmetry", "skill_id": ""},
|
|
288
|
+
"mcp_schema": mcp_schema,
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def build_schema_unavailable_canonical(payload: dict[str, Any]) -> dict[str, Any]:
|
|
293
|
+
"""A `tools/list` attempt that did not land.
|
|
294
|
+
|
|
295
|
+
Carries no fingerprint, because there is nothing to fingerprint, and no
|
|
296
|
+
ATLAS block, because a failed fetch is not a technique. `status` is
|
|
297
|
+
`unavailable` rather than an absence, so a SIEM can tell a server that has
|
|
298
|
+
not moved from one nobody managed to read.
|
|
299
|
+
"""
|
|
300
|
+
import uuid
|
|
301
|
+
from datetime import datetime, timezone
|
|
302
|
+
|
|
303
|
+
from agentmetry.core.audit.canonical import SCHEMA_VERSION
|
|
304
|
+
from agentmetry.core.audit.identity import identity_fields
|
|
305
|
+
from agentmetry.core.diagnostics.mcp_schema import server_id
|
|
306
|
+
|
|
307
|
+
tool = payload.get("tool") if isinstance(payload.get("tool"), dict) else {}
|
|
308
|
+
server = str(tool.get("server") or payload.get("server") or "")
|
|
309
|
+
reason = str(payload.get("reason") or "tools/list failed")
|
|
310
|
+
return {
|
|
311
|
+
"schema_version": SCHEMA_VERSION,
|
|
312
|
+
"event_id": str(uuid.uuid4()),
|
|
313
|
+
"session_id": str(payload.get("session_id") or ""),
|
|
314
|
+
"correlation_id": str(payload.get("correlation_id") or payload.get("thread_id") or ""),
|
|
315
|
+
"timestamp_utc": str(payload.get("timestamp_utc") or datetime.now(timezone.utc).isoformat()),
|
|
316
|
+
**identity_fields(),
|
|
317
|
+
"source_topic": "agentmetry/mcp_schema",
|
|
318
|
+
"source": {
|
|
319
|
+
"tier": "external",
|
|
320
|
+
"app": str(payload.get("source_app") or "mcp_proxy"),
|
|
321
|
+
"adapter": str(payload.get("adapter") or "mcp_audit_proxy"),
|
|
322
|
+
},
|
|
323
|
+
"initiator": {"actor_type": "system", "trigger": "scheduled", "operator_id": ""},
|
|
324
|
+
"actor": {"type": "system", "id": "agentmetry", "role": "recorder"},
|
|
325
|
+
"action": {
|
|
326
|
+
"type": "mcp_schema",
|
|
327
|
+
"outcome": "unavailable",
|
|
328
|
+
"reason": f"MCP tools/list did not complete: {reason}",
|
|
329
|
+
},
|
|
330
|
+
"agent": {"name": "agentmetry", "skill_id": ""},
|
|
226
331
|
"mcp_schema": {
|
|
227
332
|
"server_id": server_id(server) if server else "",
|
|
228
|
-
"
|
|
229
|
-
"tool_count": tool_count,
|
|
230
|
-
"status": status,
|
|
231
|
-
# Only a schema that MOVED is the technique. `new` is the first
|
|
232
|
-
# sight of a server and `same` is a quiet reconnect; tagging either
|
|
233
|
-
# as a rug pull would put a Defense Evasion label on installing a
|
|
234
|
-
# tool. ATT&CK has no id for this at all, which is the clearest
|
|
235
|
-
# case in the product for ATLAS existing alongside it.
|
|
236
|
-
**({"atlas": dict(RUG_PULL)} if status == "changed" else {}),
|
|
333
|
+
"status": "unavailable",
|
|
237
334
|
},
|
|
238
335
|
}
|
|
239
336
|
|
|
240
337
|
|
|
338
|
+
async def _ingest_unavailable_schema(payload: dict[str, Any]) -> dict[str, Any]:
|
|
339
|
+
"""Record that a listing failed, and leave the stored baseline alone.
|
|
340
|
+
|
|
341
|
+
The store is untouched on purpose. Advancing it from a failure is the
|
|
342
|
+
mechanism that turns a flaky registry into a rug-pull alert, which is the
|
|
343
|
+
thing this event exists to stop.
|
|
344
|
+
"""
|
|
345
|
+
from agentmetry.core.audit.trail_db import get_trail_db
|
|
346
|
+
|
|
347
|
+
canonical = build_schema_unavailable_canonical(payload)
|
|
348
|
+
get_trail_db().insert(canonical)
|
|
349
|
+
sink = _get_sink()
|
|
350
|
+
if sink is None:
|
|
351
|
+
raise RuntimeError("No audit sinks configured")
|
|
352
|
+
await sink.emit(canonical)
|
|
353
|
+
return canonical
|
|
354
|
+
|
|
355
|
+
|
|
241
356
|
async def _ingest_observed_schema(payload: dict[str, Any]) -> dict[str, Any]:
|
|
242
357
|
"""Record a `tools/list` fingerprint. Emit a trail event only when it moves.
|
|
243
358
|
|
|
@@ -257,23 +372,39 @@ async def _ingest_observed_schema(payload: dict[str, Any]) -> dict[str, Any]:
|
|
|
257
372
|
from agentmetry.core.audit.trail_db import get_trail_db
|
|
258
373
|
from agentmetry.core.diagnostics.mcp_schema import (
|
|
259
374
|
classify_observation,
|
|
375
|
+
classify_tool_delta,
|
|
260
376
|
record_observation,
|
|
261
377
|
)
|
|
262
378
|
|
|
263
|
-
|
|
264
|
-
status = classify_observation(server, fingerprint)
|
|
265
|
-
|
|
379
|
+
f = _schema_payload_fields(payload)
|
|
380
|
+
status = classify_observation(f.server, f.fingerprint, tool_count=f.tool_count)
|
|
381
|
+
# Read before the store advances. Afterwards the previous per-tool map is
|
|
382
|
+
# gone and the question "which tool moved" has no answer left.
|
|
383
|
+
delta = classify_tool_delta(f.server, f.tool_digests) if status == "changed" else None
|
|
384
|
+
canonical = build_schema_canonical(payload, status, delta)
|
|
385
|
+
|
|
386
|
+
def _advance() -> str:
|
|
387
|
+
return record_observation(
|
|
388
|
+
f.server,
|
|
389
|
+
f.fingerprint,
|
|
390
|
+
f.tool_count,
|
|
391
|
+
source=f.source,
|
|
392
|
+
server_version=f.server_version,
|
|
393
|
+
list_changed=f.list_changed,
|
|
394
|
+
tool_digests=f.tool_digests,
|
|
395
|
+
)
|
|
396
|
+
|
|
266
397
|
if status == "same":
|
|
267
398
|
# Only the timestamp moves, and nothing alerts on it, so there is
|
|
268
399
|
# nothing to make durable first.
|
|
269
|
-
|
|
400
|
+
_advance()
|
|
270
401
|
return canonical
|
|
271
402
|
get_trail_db().insert(canonical)
|
|
272
403
|
sink = _get_sink()
|
|
273
404
|
if sink is None:
|
|
274
405
|
raise RuntimeError("No audit sinks configured")
|
|
275
406
|
await sink.emit(canonical)
|
|
276
|
-
|
|
407
|
+
_advance()
|
|
277
408
|
return canonical
|
|
278
409
|
|
|
279
410
|
|
|
@@ -282,8 +413,11 @@ async def ingest_external_event(payload: dict[str, Any]) -> dict[str, Any]:
|
|
|
282
413
|
if not settings.audit_ingest_enabled:
|
|
283
414
|
raise ValueError("External audit ingest is disabled")
|
|
284
415
|
|
|
285
|
-
|
|
416
|
+
event_type = str(payload.get("event_type") or "")
|
|
417
|
+
if event_type == "mcp_schema":
|
|
286
418
|
return await _ingest_observed_schema(payload)
|
|
419
|
+
if event_type == "mcp_schema_unavailable":
|
|
420
|
+
return await _ingest_unavailable_schema(payload)
|
|
287
421
|
|
|
288
422
|
canonical = build_external_canonical(payload)
|
|
289
423
|
|
|
@@ -46,6 +46,7 @@ a range and the tail is named as what it is: chain-verified, not anchored.
|
|
|
46
46
|
from __future__ import annotations
|
|
47
47
|
|
|
48
48
|
import json
|
|
49
|
+
import logging
|
|
49
50
|
import os
|
|
50
51
|
import platform
|
|
51
52
|
from dataclasses import dataclass, field
|
|
@@ -55,6 +56,8 @@ from typing import Any, Protocol, runtime_checkable
|
|
|
55
56
|
|
|
56
57
|
from agentmetry.core.audit.trail_merkle import merkle_root, read_leaves
|
|
57
58
|
|
|
59
|
+
logger = logging.getLogger(__name__)
|
|
60
|
+
|
|
58
61
|
ANCHOR_VERSION = 1
|
|
59
62
|
ANCHOR_ALG = "rfc6962-sha256"
|
|
60
63
|
|
|
@@ -96,8 +99,13 @@ def resolve_anchor_log(
|
|
|
96
99
|
configured = str(getattr(settings, "anchor_log_path", "") or "").strip()
|
|
97
100
|
if configured:
|
|
98
101
|
return Path(configured), "config"
|
|
99
|
-
except Exception:
|
|
100
|
-
|
|
102
|
+
except Exception as exc:
|
|
103
|
+
# This one matters more than it looks. Falling back in silence means
|
|
104
|
+
# `verify` compares the trail against a log sitting beside it, which an
|
|
105
|
+
# attacker who rewrote one could rewrite as well. The operator configured
|
|
106
|
+
# an external anchor precisely to avoid that, and would never learn the
|
|
107
|
+
# setting was not read.
|
|
108
|
+
logger.debug("could not read anchor_log_path from settings: %s", exc)
|
|
101
109
|
return anchor_path(Path(trail_path)), "default"
|
|
102
110
|
|
|
103
111
|
|
|
@@ -118,8 +126,8 @@ def default_host_id() -> str:
|
|
|
118
126
|
configured = str(getattr(settings, "operator_id", "") or "").strip()
|
|
119
127
|
if configured:
|
|
120
128
|
return configured
|
|
121
|
-
except Exception:
|
|
122
|
-
|
|
129
|
+
except Exception as exc:
|
|
130
|
+
logger.debug("could not read operator_id from settings: %s", exc)
|
|
123
131
|
return platform.node() or "unknown-host"
|
|
124
132
|
|
|
125
133
|
|
|
@@ -311,6 +319,13 @@ class CheckpointResult:
|
|
|
311
319
|
checkpoint: Checkpoint
|
|
312
320
|
ok: bool
|
|
313
321
|
message: str
|
|
322
|
+
# A checkpoint written for a different file says nothing about this one, in
|
|
323
|
+
# either direction. Scoring it as a failure made `verify --trail` report
|
|
324
|
+
# TAMPERING on a clean trail whenever AGENTMETRY_ANCHOR_LOG held checkpoints
|
|
325
|
+
# for another trail, which is the ordinary case after an export or a rename.
|
|
326
|
+
# A trust command that cries wolf on a good file gets muted, and then it is
|
|
327
|
+
# not a trust command.
|
|
328
|
+
applicable: bool = True
|
|
314
329
|
|
|
315
330
|
|
|
316
331
|
@dataclass(frozen=True)
|
|
@@ -327,13 +342,29 @@ class AnchorCoverage:
|
|
|
327
342
|
def ok(self) -> bool:
|
|
328
343
|
return all(r.ok for r in self.results)
|
|
329
344
|
|
|
345
|
+
@property
|
|
346
|
+
def tampering(self) -> bool:
|
|
347
|
+
"""A checkpoint that *does* cover this trail disagrees with it.
|
|
348
|
+
|
|
349
|
+
Distinct from `not ok`, which is also true when every checkpoint in the
|
|
350
|
+
log was written for some other file. That is a misconfiguration and
|
|
351
|
+
deserves saying, but calling it tampering is a false statement about a
|
|
352
|
+
file nothing has contradicted.
|
|
353
|
+
"""
|
|
354
|
+
return any(r.applicable and not r.ok for r in self.results)
|
|
355
|
+
|
|
330
356
|
@property
|
|
331
357
|
def unanchored(self) -> int:
|
|
332
358
|
return max(0, self.tree_size - self.anchored_through)
|
|
333
359
|
|
|
334
360
|
@property
|
|
335
361
|
def failures(self) -> list[CheckpointResult]:
|
|
336
|
-
return [r for r in self.results if not r.ok]
|
|
362
|
+
return [r for r in self.results if r.applicable and not r.ok]
|
|
363
|
+
|
|
364
|
+
@property
|
|
365
|
+
def not_applicable(self) -> list[CheckpointResult]:
|
|
366
|
+
"""Checkpoints written for a different trail. Reported, never scored."""
|
|
367
|
+
return [r for r in self.results if not r.applicable]
|
|
337
368
|
|
|
338
369
|
|
|
339
370
|
def verify_anchors(
|
|
@@ -350,6 +381,10 @@ def verify_anchors(
|
|
|
350
381
|
chain alone cannot see;
|
|
351
382
|
* the checkpoint is malformed — says nothing about the trail either way, and
|
|
352
383
|
must not be scored as a pass.
|
|
384
|
+
|
|
385
|
+
A checkpoint naming a *different* trail is a fourth case and not a failure.
|
|
386
|
+
It is reported so the count is honest and then ignored, because scoring it
|
|
387
|
+
either way would be an opinion about a file it never committed to.
|
|
353
388
|
"""
|
|
354
389
|
anchors = Path(anchor_file) if anchor_file else anchor_path(Path(trail_path))
|
|
355
390
|
checkpoints = read_checkpoints(anchors)
|
|
@@ -360,7 +395,12 @@ def verify_anchors(
|
|
|
360
395
|
for cp in checkpoints:
|
|
361
396
|
if cp.trail_name and cp.trail_name != Path(trail_path).name:
|
|
362
397
|
results.append(
|
|
363
|
-
CheckpointResult(
|
|
398
|
+
CheckpointResult(
|
|
399
|
+
cp,
|
|
400
|
+
False,
|
|
401
|
+
f"checkpoint is for a different trail ({cp.trail_name})",
|
|
402
|
+
applicable=False,
|
|
403
|
+
)
|
|
364
404
|
)
|
|
365
405
|
continue
|
|
366
406
|
try:
|
|
@@ -416,6 +456,15 @@ def coverage_lines(coverage: AnchorCoverage) -> list[str]:
|
|
|
416
456
|
lines = [f" anchors: {coverage.checkpoints} checkpoint(s)"]
|
|
417
457
|
for failure in coverage.failures:
|
|
418
458
|
lines.append(f" TAMPERING — {failure.message}")
|
|
459
|
+
skipped = coverage.not_applicable
|
|
460
|
+
if skipped:
|
|
461
|
+
# Counted, not listed. One line stays readable when a shared anchor log
|
|
462
|
+
# holds hundreds of checkpoints for other trails.
|
|
463
|
+
names = sorted({r.checkpoint.trail_name for r in skipped if r.checkpoint.trail_name})
|
|
464
|
+
lines.append(
|
|
465
|
+
f" {len(skipped)} checkpoint(s) skipped: written for "
|
|
466
|
+
f"{', '.join(names) or 'another trail'}, not this file"
|
|
467
|
+
)
|
|
419
468
|
|
|
420
469
|
if coverage.anchored_through:
|
|
421
470
|
lines.append(f" records 1-{coverage.anchored_through} anchored")
|
|
@@ -232,6 +232,13 @@ class AuditTrailDB:
|
|
|
232
232
|
if before_utc and after_utc:
|
|
233
233
|
raise ValueError("Use only one of before_utc or after_utc")
|
|
234
234
|
|
|
235
|
+
# The three f-string queries below are suppressed for S608. Every element
|
|
236
|
+
# of `clauses` is a literal built in this function, and the only
|
|
237
|
+
# interpolation inside one is `placeholders`, which is a run of "?".
|
|
238
|
+
# `focus` is validated against a closed set and raises on anything
|
|
239
|
+
# else. Every caller-supplied value reaches sqlite through `params`,
|
|
240
|
+
# never through the string. Adding a clause that interpolates a value
|
|
241
|
+
# would make the suppressions wrong, so add it as a "?" instead.
|
|
235
242
|
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
236
243
|
|
|
237
244
|
conn = self._get_conn()
|
|
@@ -239,7 +246,7 @@ class AuditTrailDB:
|
|
|
239
246
|
if before_utc:
|
|
240
247
|
sql = f"""SELECT event_json FROM audit_events {where}
|
|
241
248
|
{"AND" if clauses else "WHERE"} timestamp_utc < ?
|
|
242
|
-
ORDER BY timestamp_utc DESC, id DESC LIMIT ?"""
|
|
249
|
+
ORDER BY timestamp_utc DESC, id DESC LIMIT ?""" # noqa: S608
|
|
243
250
|
params.extend([before_utc, limit + 1])
|
|
244
251
|
rows = conn.execute(sql, params).fetchall()
|
|
245
252
|
has_older = len(rows) > limit
|
|
@@ -248,7 +255,7 @@ class AuditTrailDB:
|
|
|
248
255
|
elif after_utc:
|
|
249
256
|
sql = f"""SELECT event_json FROM audit_events {where}
|
|
250
257
|
{"AND" if clauses else "WHERE"} timestamp_utc > ?
|
|
251
|
-
ORDER BY timestamp_utc ASC, id ASC LIMIT ?"""
|
|
258
|
+
ORDER BY timestamp_utc ASC, id ASC LIMIT ?""" # noqa: S608
|
|
252
259
|
params.extend([after_utc, limit + 1])
|
|
253
260
|
rows = conn.execute(sql, params).fetchall()
|
|
254
261
|
has_newer = len(rows) > limit
|
|
@@ -257,7 +264,7 @@ class AuditTrailDB:
|
|
|
257
264
|
else:
|
|
258
265
|
# Latest page: get last N rows
|
|
259
266
|
sql = f"""SELECT event_json FROM audit_events {where}
|
|
260
|
-
ORDER BY timestamp_utc DESC, id DESC LIMIT ?"""
|
|
267
|
+
ORDER BY timestamp_utc DESC, id DESC LIMIT ?""" # noqa: S608
|
|
261
268
|
params.append(limit + 1)
|
|
262
269
|
rows = conn.execute(sql, params).fetchall()
|
|
263
270
|
has_older = len(rows) > limit
|
|
@@ -20,7 +20,7 @@ ALERT_COST = "run/cost_alert"
|
|
|
20
20
|
ALERT_DRIFT = "run/drift_alert"
|
|
21
21
|
|
|
22
22
|
# High-volume, ephemeral — excluded from the outbox.
|
|
23
|
-
LLM_TOKEN = "llm/token"
|
|
23
|
+
LLM_TOKEN = "llm/token" # noqa: S105 - an event topic name, not a credential
|
|
24
24
|
|
|
25
25
|
# Vault
|
|
26
26
|
VAULT_FILE_CHANGED = "vault/file_changed"
|