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.
Files changed (234) hide show
  1. {agentmetry-0.5.0 → agentmetry-0.6.0}/.gitignore +4 -0
  2. {agentmetry-0.5.0 → agentmetry-0.6.0}/PKG-INFO +3 -2
  3. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/cli/__init__.py +30 -7
  4. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/disposition.py +52 -6
  5. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/yaml_config.py +8 -2
  6. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/ingest.py +155 -21
  7. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/trail_anchor.py +55 -6
  8. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/trail_db.py +10 -3
  9. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/bus/events.py +1 -1
  10. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/diagnostics/doctor.py +1 -1
  11. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/diagnostics/driver_paths.py +6 -4
  12. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/diagnostics/mcp_schema.py +175 -10
  13. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/version.py +1 -1
  14. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/hooks/ingest.py +38 -6
  15. {agentmetry-0.5.0 → agentmetry-0.6.0}/pyproject.toml +34 -2
  16. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_agentmetry_ingest_client.py +47 -0
  17. agentmetry-0.6.0/tests/test_cli_commands.py +467 -0
  18. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_detection_disposition.py +47 -0
  19. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_mcp_audit_proxy.py +61 -7
  20. agentmetry-0.6.0/tests/test_mcp_schema.py +490 -0
  21. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_readme_claims.py +53 -0
  22. agentmetry-0.6.0/tests/test_sigma_pack.py +126 -0
  23. agentmetry-0.6.0/tools/generate_sigma_pack.py +198 -0
  24. {agentmetry-0.5.0 → agentmetry-0.6.0}/tools/mcp_audit_proxy.py +81 -7
  25. agentmetry-0.5.0/tests/test_mcp_schema.py +0 -233
  26. {agentmetry-0.5.0 → agentmetry-0.6.0}/.dockerignore +0 -0
  27. {agentmetry-0.5.0 → agentmetry-0.6.0}/.env.agentmetry-demo +0 -0
  28. {agentmetry-0.5.0 → agentmetry-0.6.0}/.env.example +0 -0
  29. {agentmetry-0.5.0 → agentmetry-0.6.0}/Dockerfile +0 -0
  30. {agentmetry-0.5.0 → agentmetry-0.6.0}/README.md +0 -0
  31. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/__init__.py +0 -0
  32. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/api/__init__.py +0 -0
  33. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/api/main.py +0 -0
  34. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/api/routes/__init__.py +0 -0
  35. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/api/routes/audit.py +0 -0
  36. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/api/websocket.py +0 -0
  37. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/api/ws_bridge.py +0 -0
  38. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/cli/__main__.py +0 -0
  39. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/__init__.py +0 -0
  40. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/__init__.py +0 -0
  41. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/adapters/__init__.py +0 -0
  42. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/adapters/agt.py +0 -0
  43. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/adapters/chronicle.py +0 -0
  44. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/adapters/cloudevents.py +0 -0
  45. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/adapters/ecs.py +0 -0
  46. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/adapters/splunk.py +0 -0
  47. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/alerts.py +0 -0
  48. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/atlas.py +0 -0
  49. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/canonical.py +0 -0
  50. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/compliance_digest.py +0 -0
  51. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/__init__.py +0 -0
  52. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/benchmark.py +0 -0
  53. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_approval_denied_then_executed.jsonl +0 -0
  54. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_arbitrary_host_stage_execute.jsonl +0 -0
  55. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_autonomous_unapproved_write.jsonl +0 -0
  56. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_credential_exfil.jsonl +0 -0
  57. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_credential_then_cloud_api.jsonl +0 -0
  58. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_destructive_delete_burst.jsonl +0 -0
  59. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_discovery_then_collect.jsonl +0 -0
  60. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_dotfile_then_git_push.jsonl +0 -0
  61. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_encoded_command_download.jsonl +0 -0
  62. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_env_credential_exfil.jsonl +0 -0
  63. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_hashed_only_no_command.jsonl +0 -0
  64. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_interpreter_egress.jsonl +0 -0
  65. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_pr_merged_without_review.jsonl +0 -0
  66. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_proc_substitution_cradle.jsonl +0 -0
  67. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_remote_pipe_to_shell.jsonl +0 -0
  68. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_remote_staging_then_execute.jsonl +0 -0
  69. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_secret_manager_then_egress.jsonl +0 -0
  70. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_session_tool_burst.jsonl +0 -0
  71. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_single_command_exfil.jsonl +0 -0
  72. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_single_quoted_interpreter_egress.jsonl +0 -0
  73. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_ssh_directory_exfil.jsonl +0 -0
  74. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_subagent_swarm.jsonl +0 -0
  75. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_timestamp_collision.jsonl +0 -0
  76. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/attack_untrusted_input_then_action.jsonl +0 -0
  77. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_authoring_merge_fixtures.jsonl +0 -0
  78. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_autonomous_after_approval.jsonl +0 -0
  79. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_build_artifact_cleanup.jsonl +0 -0
  80. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_ci_artifact_download.jsonl +0 -0
  81. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_commit_message_names_cloud_clis.jsonl +0 -0
  82. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_database_migration.jsonl +0 -0
  83. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_dependency_install_and_build.jsonl +0 -0
  84. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_download_release_archive.jsonl +0 -0
  85. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_fetch_data_then_run_repo_script.jsonl +0 -0
  86. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_fetch_dataset_then_analyse.jsonl +0 -0
  87. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_fetch_lockfile_then_install.jsonl +0 -0
  88. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_git_review_and_push.jsonl +0 -0
  89. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_human_driven_deletes.jsonl +0 -0
  90. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_local_api_probing.jsonl +0 -0
  91. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_long_but_calm_session.jsonl +0 -0
  92. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_loopback_is_not_egress.jsonl +0 -0
  93. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_loopback_pipe_to_interpreter.jsonl +0 -0
  94. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_ordinary_development.jsonl +0 -0
  95. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_package_manager_after_fetch.jsonl +0 -0
  96. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_reading_config_that_is_not_secret.jsonl +0 -0
  97. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_remote_api_call_no_credentials.jsonl +0 -0
  98. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_research_then_docs.jsonl +0 -0
  99. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_reversed_order_is_not_exfil.jsonl +0 -0
  100. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_secret_names_and_docs.jsonl +0 -0
  101. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_test_and_fix_loop.jsonl +0 -0
  102. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/benign_writing_about_credentials.jsonl +0 -0
  103. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/corpus/corpus.yaml +0 -0
  104. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/engine.py +0 -0
  105. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/live.py +0 -0
  106. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/live_store.py +0 -0
  107. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/models.py +0 -0
  108. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/rules.py +0 -0
  109. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/traits.py +0 -0
  110. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/detection/yaml_rules.py +0 -0
  111. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/dlp/__init__.py +0 -0
  112. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/dlp/loader.py +0 -0
  113. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/dlp/models.py +0 -0
  114. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/dlp/scanner.py +0 -0
  115. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/dogfood.py +0 -0
  116. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/evidence_pack.py +0 -0
  117. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/external.py +0 -0
  118. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/hashing.py +0 -0
  119. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/heartbeat.py +0 -0
  120. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/hook_bootstrap.py +0 -0
  121. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/identity.py +0 -0
  122. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/migrate.py +0 -0
  123. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/mitre.py +0 -0
  124. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/policy.py +0 -0
  125. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/redaction.py +0 -0
  126. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/replay.py +0 -0
  127. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/run_context.py +0 -0
  128. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/sinks.py +0 -0
  129. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/spool.py +0 -0
  130. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/tool_policy/__init__.py +0 -0
  131. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/tool_policy/evaluator.py +0 -0
  132. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/tool_policy/loader.py +0 -0
  133. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/tool_policy/models.py +0 -0
  134. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/trail_chain.py +0 -0
  135. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/audit/trail_merkle.py +0 -0
  136. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/auth.py +0 -0
  137. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/bus/__init__.py +0 -0
  138. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/bus/audit_exporter.py +0 -0
  139. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/bus/bridges.py +0 -0
  140. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/bus/bus.py +0 -0
  141. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/bus/outbox.py +0 -0
  142. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/config.py +0 -0
  143. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/diagnostics/__init__.py +0 -0
  144. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/diagnostics/autostart.py +0 -0
  145. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/diagnostics/env_file.py +0 -0
  146. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/diagnostics/hook_coverage.py +0 -0
  147. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/diagnostics/mcp_inventory.py +0 -0
  148. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/drivers/__init__.py +0 -0
  149. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/drivers/host.py +0 -0
  150. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/drivers/permissions.py +0 -0
  151. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/drivers/spec.py +0 -0
  152. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/extensions.py +0 -0
  153. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/core/health.py +0 -0
  154. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/hooks/__init__.py +0 -0
  155. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/policies/detection/manifest.yaml +0 -0
  156. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/policies/dlp/manifest.yaml +0 -0
  157. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/policies/opa/agent_rules.rego +0 -0
  158. {agentmetry-0.5.0 → agentmetry-0.6.0}/agentmetry/policies/tool/manifest.yaml +0 -0
  159. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/conftest.py +0 -0
  160. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/fixtures/agt_filesink_exfil.jsonl +0 -0
  161. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/fixtures/agt_filesink_mixed.jsonl +0 -0
  162. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/fixtures/fake_mcp_server.py +0 -0
  163. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_agentmetry_audit.py +0 -0
  164. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_agt_adapter.py +0 -0
  165. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_atlas_detection_enrichment.py +0 -0
  166. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_atlas_mapping.py +0 -0
  167. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_audit_sinks.py +0 -0
  168. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_audit_stats.py +0 -0
  169. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_audit_tail.py +0 -0
  170. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_auth.py +0 -0
  171. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_autostart.py +0 -0
  172. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_boot_sequence.py +0 -0
  173. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_burst_windows.py +0 -0
  174. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_chinese_agent_hooks.py +0 -0
  175. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_chinese_agent_sprint_b.py +0 -0
  176. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_chinese_agent_sprint_c.py +0 -0
  177. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_chronicle_adapter.py +0 -0
  178. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_cli_backup.py +0 -0
  179. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_cloudevents_adapter.py +0 -0
  180. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_compliance_digest.py +0 -0
  181. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_crewai_adapter.py +0 -0
  182. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_detection_adi.py +0 -0
  183. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_detection_benchmark.py +0 -0
  184. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_detection_default_config.py +0 -0
  185. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_detection_disposition_api.py +0 -0
  186. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_detection_duplicate_emission.py +0 -0
  187. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_detection_emit_durability.py +0 -0
  188. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_detection_engine.py +0 -0
  189. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_detection_evasion.py +0 -0
  190. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_detection_hf_incident.py +0 -0
  191. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_detection_quoted_command_words.py +0 -0
  192. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_detection_rule_identity.py +0 -0
  193. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_detection_rules_v2.py +0 -0
  194. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_dlp_agent_env_override.py +0 -0
  195. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_dlp_invisible_unicode.py +0 -0
  196. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_dlp_markdown_exfil.py +0 -0
  197. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_dlp_scanner.py +0 -0
  198. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_doctor.py +0 -0
  199. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_dogfood.py +0 -0
  200. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_dogfood_freeze.py +0 -0
  201. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_driver_paths.py +0 -0
  202. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_ecs_threat.py +0 -0
  203. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_event_bus.py +0 -0
  204. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_evidence_pack.py +0 -0
  205. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_extensions.py +0 -0
  206. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_external_ingest.py +0 -0
  207. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_forwarding_shape.py +0 -0
  208. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_heartbeat.py +0 -0
  209. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_hook_bootstrap.py +0 -0
  210. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_hook_coverage.py +0 -0
  211. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_hook_enforcement_timing.py +0 -0
  212. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_hook_spool.py +0 -0
  213. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_isolation.py +0 -0
  214. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_launch_targets.py +0 -0
  215. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_live_detection.py +0 -0
  216. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_live_detection_store.py +0 -0
  217. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_mcp_inventory.py +0 -0
  218. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_mitre_dlp.py +0 -0
  219. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_opensre_adapter.py +0 -0
  220. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_redaction.py +0 -0
  221. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_replay.py +0 -0
  222. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_static_serving.py +0 -0
  223. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_subagent_lifecycle.py +0 -0
  224. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_tool_policy.py +0 -0
  225. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_tool_policy_agent_cli.py +0 -0
  226. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_tool_policy_git_hooks.py +0 -0
  227. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_trail_anchor.py +0 -0
  228. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_trail_chain.py +0 -0
  229. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_trail_concurrency.py +0 -0
  230. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_trail_merkle.py +0 -0
  231. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_unattended_agent_policy.py +0 -0
  232. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_version.py +0 -0
  233. {agentmetry-0.5.0 → agentmetry-0.6.0}/tests/test_yaml_detection_rules.py +0 -0
  234. {agentmetry-0.5.0 → agentmetry-0.6.0}/tools/vault_fs_server.py +0 -0
@@ -96,3 +96,7 @@ docs/*-review-*.md
96
96
  docs/*audit*prompt*.md
97
97
  docs/audit-*.md
98
98
  docs/*-audit-*.md
99
+
100
+ # pytest-cov data file
101
+ .coverage
102
+ .coverage.*
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentmetry
3
- Version: 0.5.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.0; extra == 'dev'
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
- pass
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 not coverage.ok:
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 - depends on the host console
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("Migrated disposition %s -> %s after rule rename", stale_key, key)
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
- pass
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
- def _schema_payload_fields(payload: dict[str, Any]) -> tuple[str, str, int, str]:
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
- return server, fingerprint, tool_count, source
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
- def build_schema_canonical(payload: dict[str, Any], status: str) -> dict[str, Any]:
193
- """Attestation that a `tools/list` was observed. Names and descriptions stay off it."""
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
- server, fingerprint, tool_count, _source = _schema_payload_fields(payload)
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
- "fingerprint": fingerprint,
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
- server, fingerprint, tool_count, source = _schema_payload_fields(payload)
264
- status = classify_observation(server, fingerprint)
265
- canonical = build_schema_canonical(payload, status)
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
- record_observation(server, fingerprint, tool_count, source=source)
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
- record_observation(server, fingerprint, tool_count, source=source)
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
- if str(payload.get("event_type") or "") == "mcp_schema":
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
- pass
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
- pass
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(cp, False, f"checkpoint is for a different trail ({cp.trail_name})")
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"