agentmetry 0.6.0__tar.gz → 0.8.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 (244) hide show
  1. {agentmetry-0.6.0 → agentmetry-0.8.0}/PKG-INFO +3 -3
  2. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/api/routes/audit.py +55 -2
  3. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/cli/__init__.py +92 -1
  4. agentmetry-0.8.0/agentmetry/core/audit/detection/corpus/benign_env_example_read.jsonl +4 -0
  5. agentmetry-0.8.0/agentmetry/core/audit/detection/corpus/benign_fetch_piped_into_own_script.jsonl +3 -0
  6. agentmetry-0.8.0/agentmetry/core/audit/detection/corpus/benign_github_raw_docs_then_script.jsonl +3 -0
  7. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_loopback_pipe_to_interpreter.jsonl +3 -3
  8. agentmetry-0.8.0/agentmetry/core/audit/detection/corpus/benign_mcp_text_mentions_credential_path.jsonl +3 -0
  9. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/corpus.yaml +42 -0
  10. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/rules.py +31 -2
  11. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/traits.py +42 -5
  12. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/ingest.py +40 -7
  13. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/mitre.py +42 -2
  14. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/diagnostics/mcp_schema.py +294 -3
  15. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/version.py +1 -1
  16. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/policies/dlp/manifest.yaml +26 -0
  17. {agentmetry-0.6.0 → agentmetry-0.8.0}/pyproject.toml +7 -2
  18. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_cli_commands.py +242 -0
  19. agentmetry-0.8.0/tests/test_cli_env_var_names.py +90 -0
  20. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_detection_adi.py +36 -1
  21. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_detection_rule_identity.py +16 -4
  22. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_dlp_scanner.py +49 -0
  23. agentmetry-0.8.0/tests/test_funding_manifest.py +96 -0
  24. agentmetry-0.8.0/tests/test_identity_enrich.py +348 -0
  25. agentmetry-0.8.0/tests/test_ingest_roundtrip.py +163 -0
  26. agentmetry-0.8.0/tests/test_mcp_concealed_text.py +302 -0
  27. agentmetry-0.8.0/tests/test_mcp_meta_fingerprint.py +203 -0
  28. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_mcp_schema.py +26 -5
  29. agentmetry-0.8.0/tests/test_powershell_encoding.py +102 -0
  30. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_readme_claims.py +43 -0
  31. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_sigma_pack.py +6 -1
  32. {agentmetry-0.6.0 → agentmetry-0.8.0}/tools/generate_sigma_pack.py +6 -1
  33. {agentmetry-0.6.0 → agentmetry-0.8.0}/tools/mcp_audit_proxy.py +6 -0
  34. {agentmetry-0.6.0 → agentmetry-0.8.0}/.dockerignore +0 -0
  35. {agentmetry-0.6.0 → agentmetry-0.8.0}/.env.agentmetry-demo +0 -0
  36. {agentmetry-0.6.0 → agentmetry-0.8.0}/.env.example +0 -0
  37. {agentmetry-0.6.0 → agentmetry-0.8.0}/.gitignore +0 -0
  38. {agentmetry-0.6.0 → agentmetry-0.8.0}/Dockerfile +0 -0
  39. {agentmetry-0.6.0 → agentmetry-0.8.0}/README.md +0 -0
  40. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/__init__.py +0 -0
  41. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/api/__init__.py +0 -0
  42. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/api/main.py +0 -0
  43. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/api/routes/__init__.py +0 -0
  44. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/api/websocket.py +0 -0
  45. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/api/ws_bridge.py +0 -0
  46. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/cli/__main__.py +0 -0
  47. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/__init__.py +0 -0
  48. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/__init__.py +0 -0
  49. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/adapters/__init__.py +0 -0
  50. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/adapters/agt.py +0 -0
  51. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/adapters/chronicle.py +0 -0
  52. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/adapters/cloudevents.py +0 -0
  53. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/adapters/ecs.py +0 -0
  54. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/adapters/splunk.py +0 -0
  55. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/alerts.py +0 -0
  56. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/atlas.py +0 -0
  57. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/canonical.py +0 -0
  58. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/compliance_digest.py +0 -0
  59. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/__init__.py +0 -0
  60. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/benchmark.py +0 -0
  61. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_approval_denied_then_executed.jsonl +0 -0
  62. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_arbitrary_host_stage_execute.jsonl +0 -0
  63. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_autonomous_unapproved_write.jsonl +0 -0
  64. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_credential_exfil.jsonl +0 -0
  65. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_credential_then_cloud_api.jsonl +0 -0
  66. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_destructive_delete_burst.jsonl +0 -0
  67. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_discovery_then_collect.jsonl +0 -0
  68. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_dotfile_then_git_push.jsonl +0 -0
  69. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_encoded_command_download.jsonl +0 -0
  70. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_env_credential_exfil.jsonl +0 -0
  71. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_hashed_only_no_command.jsonl +0 -0
  72. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_interpreter_egress.jsonl +0 -0
  73. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_pr_merged_without_review.jsonl +0 -0
  74. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_proc_substitution_cradle.jsonl +0 -0
  75. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_remote_pipe_to_shell.jsonl +0 -0
  76. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_remote_staging_then_execute.jsonl +0 -0
  77. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_secret_manager_then_egress.jsonl +0 -0
  78. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_session_tool_burst.jsonl +0 -0
  79. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_single_command_exfil.jsonl +0 -0
  80. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_single_quoted_interpreter_egress.jsonl +0 -0
  81. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_ssh_directory_exfil.jsonl +0 -0
  82. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_subagent_swarm.jsonl +0 -0
  83. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_timestamp_collision.jsonl +0 -0
  84. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_untrusted_input_then_action.jsonl +0 -0
  85. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_authoring_merge_fixtures.jsonl +0 -0
  86. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_autonomous_after_approval.jsonl +0 -0
  87. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_build_artifact_cleanup.jsonl +0 -0
  88. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_ci_artifact_download.jsonl +0 -0
  89. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_commit_message_names_cloud_clis.jsonl +0 -0
  90. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_database_migration.jsonl +0 -0
  91. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_dependency_install_and_build.jsonl +0 -0
  92. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_download_release_archive.jsonl +0 -0
  93. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_fetch_data_then_run_repo_script.jsonl +0 -0
  94. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_fetch_dataset_then_analyse.jsonl +0 -0
  95. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_fetch_lockfile_then_install.jsonl +0 -0
  96. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_git_review_and_push.jsonl +0 -0
  97. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_human_driven_deletes.jsonl +0 -0
  98. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_local_api_probing.jsonl +0 -0
  99. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_long_but_calm_session.jsonl +0 -0
  100. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_loopback_is_not_egress.jsonl +0 -0
  101. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_ordinary_development.jsonl +0 -0
  102. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_package_manager_after_fetch.jsonl +0 -0
  103. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_reading_config_that_is_not_secret.jsonl +0 -0
  104. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_remote_api_call_no_credentials.jsonl +0 -0
  105. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_research_then_docs.jsonl +0 -0
  106. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_reversed_order_is_not_exfil.jsonl +0 -0
  107. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_secret_names_and_docs.jsonl +0 -0
  108. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_test_and_fix_loop.jsonl +0 -0
  109. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_writing_about_credentials.jsonl +0 -0
  110. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/disposition.py +0 -0
  111. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/engine.py +0 -0
  112. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/live.py +0 -0
  113. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/live_store.py +0 -0
  114. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/models.py +0 -0
  115. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/yaml_config.py +0 -0
  116. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/yaml_rules.py +0 -0
  117. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/dlp/__init__.py +0 -0
  118. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/dlp/loader.py +0 -0
  119. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/dlp/models.py +0 -0
  120. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/dlp/scanner.py +0 -0
  121. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/dogfood.py +0 -0
  122. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/evidence_pack.py +0 -0
  123. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/external.py +0 -0
  124. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/hashing.py +0 -0
  125. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/heartbeat.py +0 -0
  126. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/hook_bootstrap.py +0 -0
  127. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/identity.py +0 -0
  128. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/migrate.py +0 -0
  129. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/policy.py +0 -0
  130. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/redaction.py +0 -0
  131. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/replay.py +0 -0
  132. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/run_context.py +0 -0
  133. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/sinks.py +0 -0
  134. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/spool.py +0 -0
  135. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/tool_policy/__init__.py +0 -0
  136. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/tool_policy/evaluator.py +0 -0
  137. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/tool_policy/loader.py +0 -0
  138. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/tool_policy/models.py +0 -0
  139. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/trail_anchor.py +0 -0
  140. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/trail_chain.py +0 -0
  141. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/trail_db.py +0 -0
  142. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/audit/trail_merkle.py +0 -0
  143. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/auth.py +0 -0
  144. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/bus/__init__.py +0 -0
  145. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/bus/audit_exporter.py +0 -0
  146. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/bus/bridges.py +0 -0
  147. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/bus/bus.py +0 -0
  148. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/bus/events.py +0 -0
  149. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/bus/outbox.py +0 -0
  150. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/config.py +0 -0
  151. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/diagnostics/__init__.py +0 -0
  152. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/diagnostics/autostart.py +0 -0
  153. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/diagnostics/doctor.py +0 -0
  154. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/diagnostics/driver_paths.py +0 -0
  155. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/diagnostics/env_file.py +0 -0
  156. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/diagnostics/hook_coverage.py +0 -0
  157. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/diagnostics/mcp_inventory.py +0 -0
  158. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/drivers/__init__.py +0 -0
  159. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/drivers/host.py +0 -0
  160. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/drivers/permissions.py +0 -0
  161. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/drivers/spec.py +0 -0
  162. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/extensions.py +0 -0
  163. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/core/health.py +0 -0
  164. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/hooks/__init__.py +0 -0
  165. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/hooks/ingest.py +0 -0
  166. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/policies/detection/manifest.yaml +0 -0
  167. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/policies/opa/agent_rules.rego +0 -0
  168. {agentmetry-0.6.0 → agentmetry-0.8.0}/agentmetry/policies/tool/manifest.yaml +0 -0
  169. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/conftest.py +0 -0
  170. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/fixtures/agt_filesink_exfil.jsonl +0 -0
  171. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/fixtures/agt_filesink_mixed.jsonl +0 -0
  172. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/fixtures/fake_mcp_server.py +0 -0
  173. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_agentmetry_audit.py +0 -0
  174. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_agentmetry_ingest_client.py +0 -0
  175. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_agt_adapter.py +0 -0
  176. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_atlas_detection_enrichment.py +0 -0
  177. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_atlas_mapping.py +0 -0
  178. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_audit_sinks.py +0 -0
  179. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_audit_stats.py +0 -0
  180. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_audit_tail.py +0 -0
  181. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_auth.py +0 -0
  182. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_autostart.py +0 -0
  183. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_boot_sequence.py +0 -0
  184. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_burst_windows.py +0 -0
  185. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_chinese_agent_hooks.py +0 -0
  186. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_chinese_agent_sprint_b.py +0 -0
  187. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_chinese_agent_sprint_c.py +0 -0
  188. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_chronicle_adapter.py +0 -0
  189. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_cli_backup.py +0 -0
  190. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_cloudevents_adapter.py +0 -0
  191. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_compliance_digest.py +0 -0
  192. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_crewai_adapter.py +0 -0
  193. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_detection_benchmark.py +0 -0
  194. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_detection_default_config.py +0 -0
  195. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_detection_disposition.py +0 -0
  196. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_detection_disposition_api.py +0 -0
  197. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_detection_duplicate_emission.py +0 -0
  198. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_detection_emit_durability.py +0 -0
  199. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_detection_engine.py +0 -0
  200. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_detection_evasion.py +0 -0
  201. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_detection_hf_incident.py +0 -0
  202. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_detection_quoted_command_words.py +0 -0
  203. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_detection_rules_v2.py +0 -0
  204. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_dlp_agent_env_override.py +0 -0
  205. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_dlp_invisible_unicode.py +0 -0
  206. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_dlp_markdown_exfil.py +0 -0
  207. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_doctor.py +0 -0
  208. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_dogfood.py +0 -0
  209. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_dogfood_freeze.py +0 -0
  210. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_driver_paths.py +0 -0
  211. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_ecs_threat.py +0 -0
  212. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_event_bus.py +0 -0
  213. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_evidence_pack.py +0 -0
  214. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_extensions.py +0 -0
  215. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_external_ingest.py +0 -0
  216. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_forwarding_shape.py +0 -0
  217. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_heartbeat.py +0 -0
  218. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_hook_bootstrap.py +0 -0
  219. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_hook_coverage.py +0 -0
  220. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_hook_enforcement_timing.py +0 -0
  221. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_hook_spool.py +0 -0
  222. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_isolation.py +0 -0
  223. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_launch_targets.py +0 -0
  224. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_live_detection.py +0 -0
  225. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_live_detection_store.py +0 -0
  226. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_mcp_audit_proxy.py +0 -0
  227. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_mcp_inventory.py +0 -0
  228. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_mitre_dlp.py +0 -0
  229. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_opensre_adapter.py +0 -0
  230. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_redaction.py +0 -0
  231. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_replay.py +0 -0
  232. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_static_serving.py +0 -0
  233. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_subagent_lifecycle.py +0 -0
  234. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_tool_policy.py +0 -0
  235. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_tool_policy_agent_cli.py +0 -0
  236. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_tool_policy_git_hooks.py +0 -0
  237. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_trail_anchor.py +0 -0
  238. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_trail_chain.py +0 -0
  239. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_trail_concurrency.py +0 -0
  240. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_trail_merkle.py +0 -0
  241. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_unattended_agent_policy.py +0 -0
  242. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_version.py +0 -0
  243. {agentmetry-0.6.0 → agentmetry-0.8.0}/tests/test_yaml_detection_rules.py +0 -0
  244. {agentmetry-0.6.0 → agentmetry-0.8.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.6.0
3
+ Version: 0.8.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
@@ -25,7 +25,7 @@ Requires-Python: >=3.11
25
25
  Requires-Dist: aiosqlite>=0.20.0
26
26
  Requires-Dist: fastapi>=0.115.0
27
27
  Requires-Dist: httpx>=0.28.0
28
- Requires-Dist: mcp>=1.2
28
+ Requires-Dist: mcp<2,>=1.2
29
29
  Requires-Dist: pydantic-settings>=2.6.0
30
30
  Requires-Dist: pydantic>=2.9.0
31
31
  Requires-Dist: pyyaml>=6.0
@@ -37,7 +37,7 @@ Provides-Extra: dev
37
37
  Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
38
38
  Requires-Dist: pytest-cov>=6.0; extra == 'dev'
39
39
  Requires-Dist: pytest>=8.0; extra == 'dev'
40
- Requires-Dist: ruff==0.16.4; extra == 'dev'
40
+ Requires-Dist: ruff==0.16.6; extra == 'dev'
41
41
  Description-Content-Type: text/markdown
42
42
 
43
43
  # Agentmetry
@@ -8,7 +8,7 @@ from typing import Any, Literal
8
8
 
9
9
  from fastapi import APIRouter, Depends, HTTPException, Query
10
10
  from fastapi.responses import FileResponse
11
- from pydantic import BaseModel, Field
11
+ from pydantic import BaseModel, Field, field_validator
12
12
 
13
13
  from agentmetry.core.auth import require_api_key
14
14
  from agentmetry.core.audit.detection.disposition import STATUSES, get_disposition_store
@@ -42,8 +42,48 @@ class IngestToolBody(BaseModel):
42
42
  mitre: dict[str, str] | None = None
43
43
 
44
44
 
45
+ #: Actor kinds a capture surface may assert. Anything else is coerced to
46
+ #: `agent`, which is the safe direction: never silently promoted to `human`
47
+ #: (whose approvals reset detection gates) and never to `autonomous` (which
48
+ #: `autonomous-unapproved-write` keys on). A surface that needs a new kind adds
49
+ #: it here deliberately rather than by sending a novel string.
50
+ _ACTOR_TYPES = frozenset({"human", "agent", "autonomous", "system"})
51
+
52
+
53
+ class IngestInitiatorBody(BaseModel):
54
+ """Who triggered this call, as the capture surface saw it.
55
+
56
+ This is not identity. `identity_fields()` stamps `host_id` and `fleet_id` on
57
+ the receiving orchestrator whatever a client sends, and that lockdown is
58
+ unchanged here: this field says what *kind* of actor acted, never which
59
+ machine it was. `external.py` already reads and honours it; without the
60
+ field declared, pydantic dropped it before that code ever saw it.
61
+ """
62
+
63
+ actor_type: str = "human"
64
+ trigger: str = "manual"
65
+ operator_id: str = ""
66
+
67
+ @field_validator("actor_type")
68
+ @classmethod
69
+ def _known_actor(cls, v: str) -> str:
70
+ return v if v in _ACTOR_TYPES else "agent"
71
+
72
+
45
73
  class ExternalIngestBody(BaseModel):
46
- """Adapter payload — normalized to canonical v1.1 on ingest."""
74
+ """Adapter payload — normalized to canonical v1.2 on ingest.
75
+
76
+ Every field a capture surface sends must be declared here. Pydantic ignores
77
+ what it does not know, silently, so an undeclared field is not an error at
78
+ ingest: it is a feature that works in unit tests and disappears over HTTP.
79
+
80
+ That has now happened twice. `traits` and `mitre` were dropped, which made
81
+ hashed-only events invisible to every command rule. Then 0.6.0 shipped MCP
82
+ per-tool digests and the initialize handshake, and those were dropped too,
83
+ so the release headline did not survive the wire. `test_ingest_roundtrip.py`
84
+ exists so there is not a third time: it builds real proxy payloads and fails
85
+ if any key does not survive this model.
86
+ """
47
87
 
48
88
  source_app: str = Field(
49
89
  ...,
@@ -71,10 +111,23 @@ class ExternalIngestBody(BaseModel):
71
111
  # pydantic drops it and a `log`-mode match is silently lost.
72
112
  dlp: dict[str, Any] | None = None
73
113
  tool_policy: dict[str, Any] | None = None
114
+ # Who the capture surface says triggered this. Honoured by external.py.
115
+ initiator: IngestInitiatorBody | None = None
74
116
  # Compact `tools/list` fingerprint from mcp_audit_proxy. Hash only: the
75
117
  # description never leaves the proxy process.
76
118
  schema_fingerprint: str = ""
77
119
  schema_tool_count: int = 0
120
+ # 0.6.0 MCP fields. Per-tool digests so a schema move names the tool that
121
+ # moved rather than only the server, and the two initialize handshake
122
+ # fields that separate a shipped release from a rug pull. All hashes and
123
+ # flags: no tool name, no description, nothing model-visible.
124
+ schema_tool_digests: dict[str, str] = Field(default_factory=dict)
125
+ # Concealed-character counts per category. Declared here because pydantic
126
+ # drops what it does not know, silently, which has already cost this file
127
+ # two shipped bugs.
128
+ schema_concealed: dict[str, int] = Field(default_factory=dict)
129
+ server_version: str = ""
130
+ list_changed: bool | None = None
78
131
 
79
132
 
80
133
  def _parse_event_ts(event: dict[str, Any]) -> datetime | None:
@@ -34,6 +34,7 @@ _BACKUP_EXCLUDE_DIRS = {"logs"}
34
34
  logger = logging.getLogger(__name__)
35
35
 
36
36
  _BACKUP_EXCLUDE_SUFFIXES = {".pid"}
37
+ _CLOSING_DISPOSITIONS = ("resolved", "false_positive", "risk_accepted")
37
38
 
38
39
 
39
40
  def _base_url(port: int, host: str = "127.0.0.1") -> str:
@@ -41,6 +42,15 @@ def _base_url(port: int, host: str = "127.0.0.1") -> str:
41
42
  return f"http://{display}:{port}"
42
43
 
43
44
 
45
+ def _api_base_url(port: int) -> str:
46
+ return os.environ.get("AGENTMETRY_URL", "").strip().rstrip("/") or _base_url(port)
47
+
48
+
49
+ def _api_headers() -> dict[str, str]:
50
+ key = os.environ.get("AGENTMETRY_API_KEY", "").strip()
51
+ return {"X-API-Key": key} if key else {}
52
+
53
+
44
54
  def _lan_ip() -> str | None:
45
55
  """Best-effort local IPv4 for phone/LAN access hints."""
46
56
  try:
@@ -219,7 +229,8 @@ def cmd_stats(args: argparse.Namespace) -> int:
219
229
  return 1
220
230
 
221
231
  if not data.get("enabled", True):
222
- print("Audit export disabled — enable AGENTMETRY_AUDIT_EXPORT to collect stats.")
232
+ print("Audit export is disabled, so no events are recorded and stats are empty.")
233
+ print("Set AGENTMETRY_AUDIT_EXPORT_ENABLED=1 and restart.")
223
234
  return 1
224
235
 
225
236
  days = data.get("window_days", args.days)
@@ -241,6 +252,76 @@ def cmd_stats(args: argparse.Namespace) -> int:
241
252
  return 0
242
253
 
243
254
 
255
+ def cmd_detections(args: argparse.Namespace) -> int:
256
+ try:
257
+ data = httpx.get(
258
+ f"{_base_url(args.port)}/api/v1/audit/detections/{args.correlation_id}",
259
+ timeout=10.0,
260
+ ).json()
261
+ except Exception:
262
+ print("Not running - start Agentmetry first (detections reads via the API).")
263
+ return 1
264
+ if not data.get("enabled", True):
265
+ print("Audit export is disabled, so no sessions are recorded.")
266
+ print("Set AGENTMETRY_AUDIT_EXPORT_ENABLED=1 and restart.")
267
+ return 1
268
+ detections = data.get("detections") or []
269
+ if not detections:
270
+ print(f"No detections for {args.correlation_id}.")
271
+ return 0
272
+ rule_width = max(len("Rule"), *(len(str(d.get("rule_id", ""))) for d in detections))
273
+ severity_width = max(
274
+ len("Severity"), *(len(str(d.get("severity", ""))) for d in detections)
275
+ )
276
+ print(f"{'Rule':<{rule_width}} {'Severity':<{severity_width}} Summary")
277
+ print(f"{'-' * rule_width} {'-' * severity_width} {'-' * len('Summary')}")
278
+ for detection in detections:
279
+ print(
280
+ f"{str(detection.get('rule_id', '')):<{rule_width}} "
281
+ f"{str(detection.get('severity', '')):<{severity_width}} "
282
+ f"{detection.get('summary', '')}"
283
+ )
284
+ return 0
285
+
286
+
287
+ def cmd_disposition(args: argparse.Namespace) -> int:
288
+ """Close a detection from the shell, using the same API as the dashboard."""
289
+ note = args.note.strip()
290
+ if args.status in {"false_positive", "risk_accepted"} and not note:
291
+ print(f"--note is required for {args.status}")
292
+ return 1
293
+
294
+ try:
295
+ resp = httpx.post(
296
+ f"{_api_base_url(args.port)}/api/v1/audit/detections/disposition",
297
+ json={
298
+ "correlation_id": args.correlation_id,
299
+ "rule_id": args.rule_id,
300
+ "status": args.status,
301
+ "note": note,
302
+ "decided_by": args.decided_by,
303
+ },
304
+ headers=_api_headers(),
305
+ timeout=10.0,
306
+ )
307
+ except Exception:
308
+ print("Not running - start Agentmetry first (disposition writes via the API).")
309
+ return 1
310
+
311
+ if resp.status_code >= 400:
312
+ try:
313
+ detail = resp.json().get("detail")
314
+ except Exception:
315
+ detail = None
316
+ print(f"FAILED — {detail or resp.text or f'HTTP {resp.status_code}'}")
317
+ return 1
318
+
319
+ current = resp.json().get("disposition", {})
320
+ status = current.get("status", args.status)
321
+ print(f"Disposition set: {args.correlation_id} {args.rule_id} -> {status}")
322
+ return 0
323
+
324
+
244
325
  def cmd_logs(args: argparse.Namespace) -> int:
245
326
  log = _DATA_DIR / "logs" / "orchestrator.log"
246
327
  if not log.exists():
@@ -1132,6 +1213,14 @@ def main(argv: list[str] | None = None) -> int:
1132
1213
  sub.add_parser("status", help="orchestrator health and audit export status")
1133
1214
  stats = sub.add_parser("stats", help="audit trail metrics for dogfood (events, detections)")
1134
1215
  stats.add_argument("--days", type=int, default=7)
1216
+ detections = sub.add_parser("detections", help="list detections for one session")
1217
+ detections.add_argument("correlation_id", help="correlation_id / session id")
1218
+ disposition = sub.add_parser("disposition", help="close an audit detection from the shell")
1219
+ disposition.add_argument("correlation_id", help="session/correlation id that owns the detection")
1220
+ disposition.add_argument("rule_id", help="detection rule id to close")
1221
+ disposition.add_argument("--status", required=True, choices=_CLOSING_DISPOSITIONS)
1222
+ disposition.add_argument("--note", default="")
1223
+ disposition.add_argument("--decided-by", default="")
1135
1224
  logs = sub.add_parser("logs", help="tail the orchestrator log")
1136
1225
  logs.add_argument("-n", "--lines", type=int, default=50)
1137
1226
  logs.add_argument("-f", "--follow", action="store_true")
@@ -1273,6 +1362,8 @@ def main(argv: list[str] | None = None) -> int:
1273
1362
  "hook": cmd_hook,
1274
1363
  "status": cmd_status,
1275
1364
  "stats": cmd_stats,
1365
+ "detections": cmd_detections,
1366
+ "disposition": cmd_disposition,
1276
1367
  "logs": cmd_logs,
1277
1368
  "backup": cmd_backup,
1278
1369
  "restore": cmd_restore,
@@ -0,0 +1,4 @@
1
+ {"action": {"outcome": "success", "reason": "", "type": "tool_called"}, "actor": {"id": "corpus", "role": "agent", "type": "agent"}, "agent": {"name": "claude", "skill_id": ""}, "correlation_id": "fp44-1", "event_id": "fp44-1-0", "host_id": "corpus-host", "initiator": {"actor_type": "agent", "operator_id": "corpus", "trigger": "autonomous"}, "schema_version": "1.2.0", "session_id": "fp44-1", "source": {"app": "claude"}, "timestamp_utc": "2026-08-26T10:00:00+00:00", "tool": {"command": "cat .env.example", "input_hash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "qualified": "claude.Bash", "server": "claude"}}
2
+ {"action": {"outcome": "success", "reason": "", "type": "tool_called"}, "actor": {"id": "corpus", "role": "agent", "type": "agent"}, "agent": {"name": "claude", "skill_id": ""}, "correlation_id": "fp44-1", "event_id": "fp44-1-1", "host_id": "corpus-host", "initiator": {"actor_type": "agent", "operator_id": "corpus", "trigger": "autonomous"}, "schema_version": "1.2.0", "session_id": "fp44-1", "source": {"app": "claude"}, "timestamp_utc": "2026-08-26T10:01:00+00:00", "tool": {"command": "cp .env.example .env.local", "input_hash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "qualified": "claude.Bash", "server": "claude"}}
3
+ {"action": {"outcome": "success", "reason": "", "type": "tool_called"}, "actor": {"id": "corpus", "role": "agent", "type": "agent"}, "agent": {"name": "claude", "skill_id": ""}, "correlation_id": "fp44-1", "event_id": "fp44-1-2", "host_id": "corpus-host", "initiator": {"actor_type": "agent", "operator_id": "corpus", "trigger": "autonomous"}, "schema_version": "1.2.0", "session_id": "fp44-1", "source": {"app": "claude"}, "timestamp_utc": "2026-08-26T10:02:00+00:00", "tool": {"command": "cat config/.env.template", "input_hash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "qualified": "claude.Read", "server": "claude"}}
4
+ {"action": {"outcome": "success", "reason": "", "type": "tool_called"}, "actor": {"id": "corpus", "role": "agent", "type": "agent"}, "agent": {"name": "claude", "skill_id": ""}, "correlation_id": "fp44-1", "event_id": "fp44-1-3", "host_id": "corpus-host", "initiator": {"actor_type": "agent", "operator_id": "corpus", "trigger": "autonomous"}, "schema_version": "1.2.0", "session_id": "fp44-1", "source": {"app": "claude"}, "timestamp_utc": "2026-08-26T10:03:00+00:00", "tool": {"command": "npm run dev", "input_hash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "qualified": "claude.Bash", "server": "claude"}}
@@ -0,0 +1,3 @@
1
+ {"action": {"outcome": "success", "reason": "", "type": "tool_called"}, "actor": {"id": "corpus", "role": "agent", "type": "agent"}, "agent": {"name": "claude", "skill_id": ""}, "correlation_id": "fp50-1", "event_id": "fp50-1-0", "host_id": "corpus-host", "initiator": {"actor_type": "agent", "operator_id": "corpus", "trigger": "autonomous"}, "schema_version": "1.2.0", "session_id": "fp50-1", "source": {"app": "claude"}, "timestamp_utc": "2026-08-26T10:00:00+00:00", "tool": {"command": "curl -s https://api.example.com/schema.json | python -c \"import sys,json;print(len(json.load(sys.stdin)))\"", "input_hash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "qualified": "claude.Bash", "server": "claude"}}
2
+ {"action": {"outcome": "success", "reason": "", "type": "tool_called"}, "actor": {"id": "corpus", "role": "agent", "type": "agent"}, "agent": {"name": "claude", "skill_id": ""}, "correlation_id": "fp50-1", "event_id": "fp50-1-1", "host_id": "corpus-host", "initiator": {"actor_type": "agent", "operator_id": "corpus", "trigger": "autonomous"}, "schema_version": "1.2.0", "session_id": "fp50-1", "source": {"app": "claude"}, "timestamp_utc": "2026-08-26T10:02:00+00:00", "tool": {"command": "curl -s https://registry.example.com/v1/index | node -e \"let d='';process.stdin.on('data',c=>d+=c)\"", "input_hash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "qualified": "claude.Bash", "server": "claude"}}
3
+ {"action": {"outcome": "success", "reason": "", "type": "tool_called"}, "actor": {"id": "corpus", "role": "agent", "type": "agent"}, "agent": {"name": "claude", "skill_id": ""}, "correlation_id": "fp50-1", "event_id": "fp50-1-2", "host_id": "corpus-host", "initiator": {"actor_type": "agent", "operator_id": "corpus", "trigger": "autonomous"}, "schema_version": "1.2.0", "session_id": "fp50-1", "source": {"app": "claude"}, "timestamp_utc": "2026-08-26T10:04:00+00:00", "tool": {"command": "pytest -q", "input_hash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "qualified": "claude.Bash", "server": "claude"}}
@@ -0,0 +1,3 @@
1
+ {"action": {"outcome": "success", "reason": "", "type": "tool_called"}, "actor": {"id": "corpus", "role": "agent", "type": "agent"}, "agent": {"name": "claude", "skill_id": ""}, "correlation_id": "fp51-1", "event_id": "fp51-1-0", "host_id": "corpus-host", "initiator": {"actor_type": "agent", "operator_id": "corpus", "trigger": "autonomous"}, "schema_version": "1.2.0", "session_id": "fp51-1", "source": {"app": "claude"}, "timestamp_utc": "2026-08-26T10:00:00+00:00", "tool": {"command": "curl -s https://raw.githubusercontent.com/psf/requests/main/README.md", "input_hash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "qualified": "claude.Bash", "server": "claude"}}
2
+ {"action": {"outcome": "success", "reason": "", "type": "tool_called"}, "actor": {"id": "corpus", "role": "agent", "type": "agent"}, "agent": {"name": "claude", "skill_id": ""}, "correlation_id": "fp51-1", "event_id": "fp51-1-1", "host_id": "corpus-host", "initiator": {"actor_type": "agent", "operator_id": "corpus", "trigger": "autonomous"}, "schema_version": "1.2.0", "session_id": "fp51-1", "source": {"app": "claude"}, "timestamp_utc": "2026-08-26T10:06:00+00:00", "tool": {"command": "python -c \"import json;print(json.dumps({'ok':True}))\"", "input_hash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "qualified": "claude.Bash", "server": "claude"}}
3
+ {"action": {"outcome": "success", "reason": "", "type": "tool_called"}, "actor": {"id": "corpus", "role": "agent", "type": "agent"}, "agent": {"name": "claude", "skill_id": ""}, "correlation_id": "fp51-1", "event_id": "fp51-1-2", "host_id": "corpus-host", "initiator": {"actor_type": "agent", "operator_id": "corpus", "trigger": "autonomous"}, "schema_version": "1.2.0", "session_id": "fp51-1", "source": {"app": "claude"}, "timestamp_utc": "2026-08-26T10:09:00+00:00", "tool": {"command": "pytest -q", "input_hash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "qualified": "claude.Bash", "server": "claude"}}
@@ -1,3 +1,3 @@
1
- {"action": {"outcome": "success", "reason": "", "type": "tool_called"}, "actor": {"id": "corpus", "role": "agent", "type": "agent"}, "agent": {"name": "claude", "skill_id": ""}, "correlation_id": "bl-1", "event_id": "bl-1-0", "host_id": "corpus-host", "initiator": {"actor_type": "agent", "operator_id": "corpus", "trigger": "autonomous"}, "schema_version": "1.1.0", "session_id": "bl-1", "source": {"app": "claude"}, "timestamp_utc": "2026-07-30T10:00:00+00:00", "tool": {"command": "curl -s http://127.0.0.1:8000/api/v1/audit/status | python -c \"import sys,json;print(json.load(sys.stdin))\"", "input_hash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "qualified": "claude.Bash", "server": "claude", "traits": ["pipe_to_shell_local", "risky_exec"]}}
2
- {"action": {"outcome": "success", "reason": "", "type": "tool_called"}, "actor": {"id": "corpus", "role": "agent", "type": "agent"}, "agent": {"name": "claude", "skill_id": ""}, "correlation_id": "bl-1", "event_id": "bl-1-1", "host_id": "corpus-host", "initiator": {"actor_type": "agent", "operator_id": "corpus", "trigger": "autonomous"}, "schema_version": "1.1.0", "session_id": "bl-1", "source": {"app": "claude"}, "timestamp_utc": "2026-07-30T10:02:00+00:00", "tool": {"command": "curl -s http://localhost:3000/health | node -e \"process.stdin.pipe(process.stdout)\"", "input_hash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "qualified": "claude.Bash", "server": "claude", "traits": ["pipe_to_shell_local", "risky_exec"]}}
3
- {"action": {"outcome": "success", "reason": "", "type": "tool_called"}, "actor": {"id": "corpus", "role": "agent", "type": "agent"}, "agent": {"name": "claude", "skill_id": ""}, "correlation_id": "bl-1", "event_id": "bl-1-2", "host_id": "corpus-host", "initiator": {"actor_type": "agent", "operator_id": "corpus", "trigger": "autonomous"}, "schema_version": "1.1.0", "session_id": "bl-1", "source": {"app": "claude"}, "timestamp_utc": "2026-07-30T10:05:00+00:00", "tool": {"command": "npm run test", "input_hash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "qualified": "claude.Bash", "server": "claude", "traits": []}}
1
+ {"action": {"outcome": "success", "reason": "", "type": "tool_called"}, "actor": {"id": "corpus", "role": "agent", "type": "agent"}, "agent": {"name": "claude", "skill_id": ""}, "correlation_id": "bl-1", "event_id": "bl-1-0", "host_id": "corpus-host", "initiator": {"actor_type": "agent", "operator_id": "corpus", "trigger": "autonomous"}, "schema_version": "1.2.0", "session_id": "bl-1", "source": {"app": "claude"}, "timestamp_utc": "2026-08-26T10:00:00+00:00", "tool": {"command": "curl -s http://127.0.0.1:8000/tmp/bootstrap.sh | bash", "input_hash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "qualified": "claude.Bash", "server": "claude", "traits": ["pipe_to_shell_local", "risky_exec"]}}
2
+ {"action": {"outcome": "success", "reason": "", "type": "tool_called"}, "actor": {"id": "corpus", "role": "agent", "type": "agent"}, "agent": {"name": "claude", "skill_id": ""}, "correlation_id": "bl-1", "event_id": "bl-1-1", "host_id": "corpus-host", "initiator": {"actor_type": "agent", "operator_id": "corpus", "trigger": "autonomous"}, "schema_version": "1.2.0", "session_id": "bl-1", "source": {"app": "claude"}, "timestamp_utc": "2026-08-26T10:02:00+00:00", "tool": {"command": "curl -s http://localhost:3000/setup.sh | sh", "input_hash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "qualified": "claude.Bash", "server": "claude", "traits": ["pipe_to_shell_local", "risky_exec"]}}
3
+ {"action": {"outcome": "success", "reason": "", "type": "tool_called"}, "actor": {"id": "corpus", "role": "agent", "type": "agent"}, "agent": {"name": "claude", "skill_id": ""}, "correlation_id": "bl-1", "event_id": "bl-1-2", "host_id": "corpus-host", "initiator": {"actor_type": "agent", "operator_id": "corpus", "trigger": "autonomous"}, "schema_version": "1.2.0", "session_id": "bl-1", "source": {"app": "claude"}, "timestamp_utc": "2026-08-26T10:04:00+00:00", "tool": {"command": "npm run test", "input_hash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "qualified": "claude.Bash", "server": "claude"}}
@@ -0,0 +1,3 @@
1
+ {"action": {"outcome": "success", "reason": "", "type": "tool_called"}, "actor": {"id": "corpus", "role": "agent", "type": "agent"}, "agent": {"name": "claude", "skill_id": ""}, "correlation_id": "fp49-1", "event_id": "fp49-1-0", "host_id": "corpus-host", "initiator": {"actor_type": "agent", "operator_id": "corpus", "trigger": "autonomous"}, "schema_version": "1.2.0", "session_id": "fp49-1", "source": {"app": "claude"}, "timestamp_utc": "2026-08-26T10:00:00+00:00", "tool": {"arguments": {"channel": "#eng", "text": "reminder: never commit ~/.aws/credentials or ~/.ssh/id_rsa"}, "input_hash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "qualified": "mcp.slack.post_message", "server": "claude"}}
2
+ {"action": {"outcome": "success", "reason": "", "type": "tool_called"}, "actor": {"id": "corpus", "role": "agent", "type": "agent"}, "agent": {"name": "claude", "skill_id": ""}, "correlation_id": "fp49-1", "event_id": "fp49-1-1", "host_id": "corpus-host", "initiator": {"actor_type": "agent", "operator_id": "corpus", "trigger": "autonomous"}, "schema_version": "1.2.0", "session_id": "fp49-1", "source": {"app": "claude"}, "timestamp_utc": "2026-08-26T10:02:00+00:00", "tool": {"arguments": {"description": "the old key is still in ~/.aws/credentials on the build box", "title": "Rotate keys"}, "input_hash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "qualified": "mcp.linear.create_issue", "server": "claude"}}
3
+ {"action": {"outcome": "success", "reason": "", "type": "tool_called"}, "actor": {"id": "corpus", "role": "agent", "type": "agent"}, "agent": {"name": "claude", "skill_id": ""}, "correlation_id": "fp49-1", "event_id": "fp49-1-2", "host_id": "corpus-host", "initiator": {"actor_type": "agent", "operator_id": "corpus", "trigger": "autonomous"}, "schema_version": "1.2.0", "session_id": "fp49-1", "source": {"app": "claude"}, "timestamp_utc": "2026-08-26T10:04:00+00:00", "tool": {"command": "git status", "input_hash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "qualified": "claude.Bash", "server": "claude"}}
@@ -454,6 +454,48 @@ cases:
454
454
  at low with no T1105 and no TA0011, neither of which a loopback fetch
455
455
  earns.
456
456
 
457
+ - name: fetch piped into the interpreter's own script
458
+ session: benign_fetch_piped_into_own_script.jsonl
459
+ expect: []
460
+ benign: true
461
+ note: >
462
+ Issue #50. `curl api/x.json | python -c 'json.load(sys.stdin)'` was
463
+ critical. An interpreter handed its own program does not execute the pipe,
464
+ it reads it as data, so this is a data pipeline and not a cradle. The
465
+ cradle shapes have no inline script and still fire: see the two cases
466
+ above.
467
+
468
+ - name: read .env.example while setting a project up
469
+ session: benign_env_example_read.jsonl
470
+ expect: []
471
+ benign: true
472
+ note: >
473
+ Issue #44, six of seventeen week-one dogfood findings and every one a
474
+ critical. `.env.example` is the file you commit *instead* of a credential
475
+ file, and reading it is day one in any repository. A bare `.env` and
476
+ `.env.local` still map to credential access.
477
+
478
+ - name: fetch docs from GitHub raw, then run an unrelated script
479
+ session: benign_github_raw_docs_then_script.jsonl
480
+ expect: []
481
+ benign: true
482
+ note: >
483
+ Issue #51. Any staging-host fetch followed by any `python -c` was critical
484
+ with nothing linking the two. GitHub raw serves documentation as well as
485
+ payloads, and a fetch that only printed staged no artifact for a later
486
+ command to run. The bound form, where the file downloaded is the file
487
+ executed, still fires.
488
+
489
+ - name: MCP message that names a credential path
490
+ session: benign_mcp_text_mentions_credential_path.jsonl
491
+ expect: []
492
+ benign: true
493
+ note: >
494
+ Issue #49, the sibling of #44. Structured tool arguments have no shell
495
+ quoting to mask, so a path inside a free-text field read as a credential
496
+ access. Naming `~/.aws/credentials` in a Slack message is a mention. The
497
+ same path arriving in a `path` argument is still a read.
498
+
457
499
  - name: remote pipe to shell is still a cradle
458
500
  session: attack_remote_pipe_to_shell.jsonl
459
501
  expect: [encoded-command-download]
@@ -43,6 +43,7 @@ from .traits import (
43
43
  PR_MERGE_COMMAND as _PR_MERGE_COMMAND,
44
44
  RAW_IP_URL as _RAW_IP_URL,
45
45
  RISKY_EXEC_AFTER_STAGING as _RISKY_EXEC_AFTER_STAGING,
46
+ STAGES_ARTIFACT as _STAGES_ARTIFACT,
46
47
  STAGING_FETCH as _STAGING_FETCH,
47
48
  STAGING_HOST as _STAGING_HOST,
48
49
  UNTRUSTED_INPUT_COMMAND as _UNTRUSTED_INPUT_COMMAND,
@@ -307,7 +308,14 @@ def _is_staging_fetch(event: dict[str, Any]) -> bool:
307
308
  if not _STAGING_HOST.search(cmd):
308
309
  return False
309
310
  words = _command_words(event)
310
- return bool(_STAGING_FETCH.search(words) or _DOWNLOAD_EXEC.search(words))
311
+ if not (_STAGING_FETCH.search(words) or _DOWNLOAD_EXEC.search(words)):
312
+ return False
313
+ # A fetch that only printed staged nothing, so there is no artifact for
314
+ # a later command to run. `curl raw.githubusercontent.com/.../README.md`
315
+ # followed an hour later by any `python -c` was reported as critical
316
+ # staging with no link between the two (issue #51). GitHub raw is where
317
+ # documentation lives as well as where payloads do.
318
+ return bool(_STAGES_ARTIFACT.search(cmd))
311
319
  return _has_trait(event, "staging_fetch")
312
320
 
313
321
 
@@ -1265,9 +1273,30 @@ HOST_REGISTRY = [
1265
1273
  #: and the alternative is calling every rule to find out what it might say.
1266
1274
  #: `test_detection_rule_identity.py` greps this module and fails if the two
1267
1275
  #: disagree, so it cannot drift silently.
1276
+ #: Rules that are in the tree and run, but are NOT part of the published set.
1277
+ #:
1278
+ #: `autonomous-unapproved-write` keys on `initiator.actor_type == "autonomous"`.
1279
+ #: The bus and SDK paths do produce that (cron, vault_watch, ingress, recovery),
1280
+ #: so the rule is correct and still registered. The IDE hook path never does:
1281
+ #: across roughly 32,000 events of real dogfood traffic from five agent
1282
+ #: surfaces, the actor is `human`, `agent` or `system` and never once
1283
+ #: `autonomous`. Ingest coercion deliberately cannot promote a client into it
1284
+ #: either, because that would let anyone fake this rule.
1285
+ #:
1286
+ #: So on every capture surface a user actually installs, it cannot fire. It was
1287
+ #: being counted in "fifteen detection rules", shipped in the Sigma pack at
1288
+ #: severity high, and named in the pitch as the flagship no-default-self-approve
1289
+ #: story. A published rule that cannot fire is a claim, not a detection.
1290
+ #:
1291
+ #: It stays registered so that a session which really is autonomous is still
1292
+ #: caught. It leaves the published set until a capture surface produces the
1293
+ #: signal it reads.
1294
+ EXPERIMENTAL_RULE_IDS: frozenset[str] = frozenset({
1295
+ "autonomous-unapproved-write",
1296
+ })
1297
+
1268
1298
  BUILTIN_RULE_IDS: frozenset[str] = frozenset({
1269
1299
  "credential-exfil",
1270
- "autonomous-unapproved-write",
1271
1300
  "discovery-then-collect",
1272
1301
  "approval-denied-then-executed",
1273
1302
  "encoded-command-download",
@@ -44,11 +44,22 @@ DOWNLOAD_EXEC = re.compile(
44
44
  ENCODED_CMD = re.compile(r"-enc(odedcommand)?\b|frombase64string", re.IGNORECASE)
45
45
 
46
46
  # Fetch remote content and feed it straight to an interpreter (ADI §4.2).
47
+ # An interpreter given its own program does not execute the pipe; it reads it as
48
+ # data. `curl api/x.json | python -c 'json.load(sys.stdin)'` is a data pipeline,
49
+ # and calling it a download cradle put a critical on ordinary scripting (issue
50
+ # #50). `curl x.sh | python` has no script of its own, so the download *is* the
51
+ # program, and that stays a cradle.
52
+ #
53
+ # Residual, stated rather than hidden: `| python -c "exec(sys.stdin.read())"`
54
+ # reaches for the download deliberately and slips this. INLINE_EVAL sees that
55
+ # shape, and a cradle written that way is no longer hiding.
56
+ _INLINE_SCRIPT = r"(?!\s*-\w*[ce]\b)"
57
+
47
58
  PIPE_TO_SHELL = re.compile(
48
59
  r"\b(curl|wget|iwr|invoke-webrequest|invoke-restmethod)\b[^|;&]*[|]\s*"
49
- r"(sudo\s+)?\b(ba|z|k|da)?sh\b|"
60
+ r"(sudo\s+)?\b(ba|z|k|da)?sh\b" + _INLINE_SCRIPT + r"|"
50
61
  r"\b(curl|wget|iwr|invoke-webrequest|invoke-restmethod)\b[^|;&]*[|]\s*"
51
- r"(iex|invoke-expression|python\d?|perl|ruby|node)\b",
62
+ r"(iex|invoke-expression|python\d?|perl|ruby|node)\b" + _INLINE_SCRIPT,
52
63
  re.IGNORECASE,
53
64
  )
54
65
 
@@ -314,6 +325,18 @@ RISKY_EXEC_AFTER_STAGING = re.compile(
314
325
  r"\bpowershell(?:\.exe)?\s+-(?:enc|f|file)\b",
315
326
  re.IGNORECASE,
316
327
  )
328
+ #: A fetch that leaves something behind: written to disk, or handed onward. A
329
+ #: fetch that only printed staged nothing, so there is no artifact for a later
330
+ #: command to run (issue #51).
331
+ STAGES_ARTIFACT = re.compile(
332
+ r"\s-[a-zA-Z]*[oO]|" # curl -o / -O, wget -O
333
+ r"\s--output(?:-document)?|"
334
+ r"\s>\s*\S|" # shell redirect to a file
335
+ r"\s-[a-zA-Z]*P|" # wget -P <dir>
336
+ r"[|]", # piped onward, including into an interpreter
337
+ re.IGNORECASE,
338
+ )
339
+
317
340
  BENIGN_AFTER_STAGING = re.compile(
318
341
  r"\b(npm|yarn|pnpm|pip|pip3|cargo|go)\s+(?:install|run|build)\b",
319
342
  re.IGNORECASE,
@@ -359,13 +382,27 @@ CREDENTIAL_PATH = re.compile(
359
382
  # directly after something that reads a file. A bare mention in prose does not
360
383
  # qualify, which costs nothing: nobody reads a credential file without naming a
361
384
  # path or a verb.
385
+ # `.env.example` is not a credential file. It is the file you commit *instead*
386
+ # of one, and reading it is what every developer does on their first day in a
387
+ # repository. It was six of seventeen week-one dogfood findings, every one a
388
+ # critical, which is the exact rate at which people stop reading criticals
389
+ # (issue #44).
390
+ #
391
+ # The lookahead sits immediately after `\.env` rather than at the end, because
392
+ # at the end it fails and the engine simply matches the shorter `.env` prefix,
393
+ # leaving the placeholder tagged anyway.
394
+ #
395
+ # Placeholders only. `.env.local`, `.env.production` and a bare `.env` still
396
+ # match, because those hold real values.
397
+ _ENV_PLACEHOLDER = r"(?!\.(?:example|sample|template|dist|defaults|placeholder)\b)"
398
+
362
399
  ENV_FILE = re.compile(
363
- r"[\w.~$-]*[/\\]\.env(?:\.[A-Za-z0-9_-]+)?\b|"
400
+ r"[\w.~$-]*[/\\]\.env" + _ENV_PLACEHOLDER + r"(?:\.[A-Za-z0-9_-]+)?\b|"
364
401
  r"\b(?:cat|bat|less|more|head|tail|type|source|export|dotenv|load_dotenv|"
365
402
  r"get-content|gc|cp|mv|scp|rsync|base64|xxd|od|strings|"
366
403
  r"grep|rg|ag|awk|sed|nano|vim|vi|emacs|code|open|start)"
367
- r"\s+(?:-[-\w]+\s+)*\.env(?:\.[A-Za-z0-9_-]+)?\b|"
368
- r"^\s*\.env(?:\.[A-Za-z0-9_-]+)?\b",
404
+ r"\s+(?:-[-\w]+\s+)*\.env" + _ENV_PLACEHOLDER + r"(?:\.[A-Za-z0-9_-]+)?\b|"
405
+ r"^\s*\.env" + _ENV_PLACEHOLDER + r"(?:\.[A-Za-z0-9_-]+)?\b",
369
406
  re.IGNORECASE | re.MULTILINE,
370
407
  )
371
408
 
@@ -187,6 +187,7 @@ class _SchemaFields(NamedTuple):
187
187
  server_version: str
188
188
  list_changed: bool | None
189
189
  tool_digests: dict[str, str]
190
+ concealed: dict[str, int]
190
191
 
191
192
 
192
193
  def _schema_payload_fields(payload: dict[str, Any]) -> _SchemaFields:
@@ -202,6 +203,12 @@ def _schema_payload_fields(payload: dict[str, Any]) -> _SchemaFields:
202
203
  list_changed = payload.get("list_changed")
203
204
  if list_changed is not None and not isinstance(list_changed, bool):
204
205
  list_changed = None
206
+ raw_concealed = payload.get("schema_concealed")
207
+ concealed = (
208
+ {str(k): int(v) for k, v in raw_concealed.items() if isinstance(v, int)}
209
+ if isinstance(raw_concealed, dict)
210
+ else {}
211
+ )
205
212
  raw_digests = payload.get("schema_tool_digests")
206
213
  tool_digests = (
207
214
  {str(k): str(v) for k, v in raw_digests.items() if isinstance(v, str)}
@@ -209,7 +216,8 @@ def _schema_payload_fields(payload: dict[str, Any]) -> _SchemaFields:
209
216
  else {}
210
217
  )
211
218
  return _SchemaFields(
212
- server, fingerprint, tool_count, source, server_version, list_changed, tool_digests
219
+ server, fingerprint, tool_count, source, server_version, list_changed,
220
+ tool_digests, concealed,
213
221
  )
214
222
 
215
223
 
@@ -232,22 +240,47 @@ def build_schema_canonical(
232
240
  from agentmetry.core.audit.canonical import SCHEMA_VERSION
233
241
  from agentmetry.core.audit.identity import identity_fields
234
242
  from agentmetry.core.audit.atlas import RUG_PULL
235
- from agentmetry.core.diagnostics.mcp_schema import server_id
243
+ from agentmetry.core.diagnostics.mcp_schema import FORMATTING_CATEGORY, server_id
236
244
 
237
245
  fields = _schema_payload_fields(payload)
238
246
  server, fingerprint, tool_count = fields.server, fields.fingerprint, fields.tool_count
239
247
  server_version, list_changed = fields.server_version, fields.list_changed
240
248
  outcome = "changed" if status == "changed" else "success"
241
- reason = (
242
- "MCP tool schema changed; config may be unchanged (rug-pull candidate)"
243
- if status == "changed"
244
- else "MCP tool schema observed"
245
- )
249
+ _REASONS = {
250
+ "changed": "MCP tool schema changed; config may be unchanged (rug-pull candidate)",
251
+ # Says what it is and what it is not. A re-baseline adopts whatever the
252
+ # server serves today without comparing it to anything, so if the server
253
+ # was already poisoned before our hashing changed, this event is the
254
+ # moment that state became the trusted one. `new` would have hidden that
255
+ # behind a word that also means "nothing was ever wrong here" (#146).
256
+ "rebaselined": (
257
+ "MCP tool schema re-baselined after a fingerprint change; "
258
+ "trust-on-first-use, not compared against the previous baseline"
259
+ ),
260
+ }
261
+ reason = _REASONS.get(status, "MCP tool schema observed")
262
+ _formatting = {k: v for k, v in fields.concealed.items() if k == FORMATTING_CATEGORY}
263
+ _concealment = {k: v for k, v in fields.concealed.items() if k != FORMATTING_CATEGORY}
246
264
  mcp_schema: dict[str, Any] = {
247
265
  "server_id": server_id(server) if server else "",
248
266
  "fingerprint": fingerprint,
249
267
  "tool_count": tool_count,
250
268
  "status": status,
269
+ # A baseline nobody has verified against a predecessor. Structured
270
+ # rather than left in the reason string so a SIEM can count them
271
+ # instead of matching prose.
272
+ **({"unverified_baseline": True} if status == "rebaselined" else {}),
273
+ # Concealed characters in strings the model reads. Counts per category,
274
+ # never the text. Attached on every status including `new`, because
275
+ # unlike everything else here it does not need a baseline: this is the
276
+ # one poisoning visible on a first sighting.
277
+ **({"concealed": _concealment} if _concealment else {}),
278
+ # Typography, kept apart from the evidence. A soft hyphen or a
279
+ # zero-width space arrives in descriptions imported from formatted
280
+ # documentation, and no neighbouring character can clear it the way one
281
+ # clears a ZWJ. Reporting it beside real concealment would make an
282
+ # operator who reads one finding distrust the next.
283
+ **({"formatting": _formatting} if _formatting else {}),
251
284
  # Only a schema that MOVED is the technique. `new` is the first
252
285
  # sight of a server and `same` is a quiet reconnect; tagging either
253
286
  # as a rug pull would put a Defense Evasion label on installing a
@@ -160,6 +160,33 @@ def _reaches_remote_host(text: str) -> bool:
160
160
  return any(not _LOOPBACK.match(host) for host in hosts)
161
161
 
162
162
 
163
+ #: Argument names that carry free text a human or a model wrote. A credential
164
+ #: path inside one of these is something being talked about.
165
+ _PROSE_KEYS = frozenset(
166
+ {
167
+ "text", "content", "body", "message", "prompt", "query", "description",
168
+ "summary", "comment", "note", "title", "instruction", "instructions",
169
+ "question", "answer", "input", "output", "markdown", "html",
170
+ }
171
+ )
172
+
173
+
174
+ def _path_evidence_text(evidence: Any, fallback: str) -> str:
175
+ """Evidence with prose arguments removed, for path-based rules only.
176
+
177
+ Returns everything except the free-text values, so `{"path": "~/.ssh/id_rsa"}`
178
+ still reads as a credential access and `{"text": "look in ~/.ssh/id_rsa"}`
179
+ does not. Non-dict evidence is unchanged: there are no argument names to
180
+ reason about, and guessing would be worse than not trying.
181
+ """
182
+ if not isinstance(evidence, dict):
183
+ return fallback
184
+ kept = {k: v for k, v in evidence.items() if str(k).lower() not in _PROSE_KEYS}
185
+ if not kept:
186
+ return ""
187
+ return _evidence_text(kept)
188
+
189
+
163
190
  def _shell_text(evidence: Any) -> str | None:
164
191
  """The shell command inside `evidence`, or None if this is not shell text.
165
192
 
@@ -210,8 +237,21 @@ def get_mitre_mapping(
210
237
  # text somebody is writing, not a file somebody is reading. Structured
211
238
  # evidence is not masked at all -- see _shell_text.
212
239
  shell = _shell_text(evidence)
213
- literal = mask_literals(shell, include_double=False).lower() if shell else text
214
- written = mask_literals(shell).lower() if shell else text
240
+ if shell:
241
+ literal = mask_literals(shell, include_double=False).lower()
242
+ written = mask_literals(shell).lower()
243
+ else:
244
+ # Structured args, so there is no shell quoting to mask. Path rules
245
+ # then run against whichever values could plausibly *be* a path.
246
+ #
247
+ # An MCP tool called with `{"text": "check ~/.aws/credentials"}` is
248
+ # a message that names a file, not a read of one, and it was being
249
+ # mapped to T1552 (issue #49). Prose lives in `text`, `content`,
250
+ # `prompt` and their siblings; a path the tool will open arrives in
251
+ # `path`, `file`, `target`. Same defect class as #44: a mention
252
+ # treated as a read.
253
+ literal = _path_evidence_text(evidence, text)
254
+ written = text
215
255
  if PRIVATE_KEY_PATH.search(literal):
216
256
  return _PRIVATE_KEY
217
257
  if (