agentmetry 0.7.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.7.0 → agentmetry-0.8.0}/PKG-INFO +3 -3
  2. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/api/routes/audit.py +4 -0
  3. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/cli/__init__.py +92 -1
  4. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/ingest.py +40 -7
  5. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/diagnostics/mcp_schema.py +294 -3
  6. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/version.py +1 -1
  7. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/policies/dlp/manifest.yaml +26 -0
  8. {agentmetry-0.7.0 → agentmetry-0.8.0}/pyproject.toml +7 -2
  9. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_cli_commands.py +242 -0
  10. agentmetry-0.8.0/tests/test_cli_env_var_names.py +90 -0
  11. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_dlp_scanner.py +49 -0
  12. agentmetry-0.8.0/tests/test_funding_manifest.py +96 -0
  13. agentmetry-0.8.0/tests/test_identity_enrich.py +348 -0
  14. agentmetry-0.8.0/tests/test_mcp_concealed_text.py +302 -0
  15. agentmetry-0.8.0/tests/test_mcp_meta_fingerprint.py +203 -0
  16. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_mcp_schema.py +26 -5
  17. agentmetry-0.8.0/tests/test_powershell_encoding.py +102 -0
  18. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_readme_claims.py +43 -0
  19. {agentmetry-0.7.0 → agentmetry-0.8.0}/tools/mcp_audit_proxy.py +6 -0
  20. {agentmetry-0.7.0 → agentmetry-0.8.0}/.dockerignore +0 -0
  21. {agentmetry-0.7.0 → agentmetry-0.8.0}/.env.agentmetry-demo +0 -0
  22. {agentmetry-0.7.0 → agentmetry-0.8.0}/.env.example +0 -0
  23. {agentmetry-0.7.0 → agentmetry-0.8.0}/.gitignore +0 -0
  24. {agentmetry-0.7.0 → agentmetry-0.8.0}/Dockerfile +0 -0
  25. {agentmetry-0.7.0 → agentmetry-0.8.0}/README.md +0 -0
  26. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/__init__.py +0 -0
  27. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/api/__init__.py +0 -0
  28. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/api/main.py +0 -0
  29. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/api/routes/__init__.py +0 -0
  30. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/api/websocket.py +0 -0
  31. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/api/ws_bridge.py +0 -0
  32. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/cli/__main__.py +0 -0
  33. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/__init__.py +0 -0
  34. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/__init__.py +0 -0
  35. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/adapters/__init__.py +0 -0
  36. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/adapters/agt.py +0 -0
  37. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/adapters/chronicle.py +0 -0
  38. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/adapters/cloudevents.py +0 -0
  39. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/adapters/ecs.py +0 -0
  40. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/adapters/splunk.py +0 -0
  41. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/alerts.py +0 -0
  42. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/atlas.py +0 -0
  43. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/canonical.py +0 -0
  44. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/compliance_digest.py +0 -0
  45. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/__init__.py +0 -0
  46. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/benchmark.py +0 -0
  47. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_approval_denied_then_executed.jsonl +0 -0
  48. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_arbitrary_host_stage_execute.jsonl +0 -0
  49. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_autonomous_unapproved_write.jsonl +0 -0
  50. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_credential_exfil.jsonl +0 -0
  51. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_credential_then_cloud_api.jsonl +0 -0
  52. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_destructive_delete_burst.jsonl +0 -0
  53. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_discovery_then_collect.jsonl +0 -0
  54. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_dotfile_then_git_push.jsonl +0 -0
  55. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_encoded_command_download.jsonl +0 -0
  56. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_env_credential_exfil.jsonl +0 -0
  57. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_hashed_only_no_command.jsonl +0 -0
  58. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_interpreter_egress.jsonl +0 -0
  59. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_pr_merged_without_review.jsonl +0 -0
  60. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_proc_substitution_cradle.jsonl +0 -0
  61. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_remote_pipe_to_shell.jsonl +0 -0
  62. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_remote_staging_then_execute.jsonl +0 -0
  63. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_secret_manager_then_egress.jsonl +0 -0
  64. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_session_tool_burst.jsonl +0 -0
  65. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_single_command_exfil.jsonl +0 -0
  66. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_single_quoted_interpreter_egress.jsonl +0 -0
  67. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_ssh_directory_exfil.jsonl +0 -0
  68. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_subagent_swarm.jsonl +0 -0
  69. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_timestamp_collision.jsonl +0 -0
  70. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_untrusted_input_then_action.jsonl +0 -0
  71. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_authoring_merge_fixtures.jsonl +0 -0
  72. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_autonomous_after_approval.jsonl +0 -0
  73. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_build_artifact_cleanup.jsonl +0 -0
  74. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_ci_artifact_download.jsonl +0 -0
  75. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_commit_message_names_cloud_clis.jsonl +0 -0
  76. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_database_migration.jsonl +0 -0
  77. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_dependency_install_and_build.jsonl +0 -0
  78. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_download_release_archive.jsonl +0 -0
  79. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_env_example_read.jsonl +0 -0
  80. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_fetch_data_then_run_repo_script.jsonl +0 -0
  81. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_fetch_dataset_then_analyse.jsonl +0 -0
  82. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_fetch_lockfile_then_install.jsonl +0 -0
  83. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_fetch_piped_into_own_script.jsonl +0 -0
  84. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_git_review_and_push.jsonl +0 -0
  85. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_github_raw_docs_then_script.jsonl +0 -0
  86. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_human_driven_deletes.jsonl +0 -0
  87. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_local_api_probing.jsonl +0 -0
  88. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_long_but_calm_session.jsonl +0 -0
  89. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_loopback_is_not_egress.jsonl +0 -0
  90. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_loopback_pipe_to_interpreter.jsonl +0 -0
  91. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_mcp_text_mentions_credential_path.jsonl +0 -0
  92. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_ordinary_development.jsonl +0 -0
  93. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_package_manager_after_fetch.jsonl +0 -0
  94. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_reading_config_that_is_not_secret.jsonl +0 -0
  95. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_remote_api_call_no_credentials.jsonl +0 -0
  96. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_research_then_docs.jsonl +0 -0
  97. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_reversed_order_is_not_exfil.jsonl +0 -0
  98. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_secret_names_and_docs.jsonl +0 -0
  99. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_test_and_fix_loop.jsonl +0 -0
  100. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_writing_about_credentials.jsonl +0 -0
  101. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/corpus.yaml +0 -0
  102. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/disposition.py +0 -0
  103. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/engine.py +0 -0
  104. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/live.py +0 -0
  105. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/live_store.py +0 -0
  106. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/models.py +0 -0
  107. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/rules.py +0 -0
  108. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/traits.py +0 -0
  109. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/yaml_config.py +0 -0
  110. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/yaml_rules.py +0 -0
  111. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/dlp/__init__.py +0 -0
  112. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/dlp/loader.py +0 -0
  113. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/dlp/models.py +0 -0
  114. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/dlp/scanner.py +0 -0
  115. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/dogfood.py +0 -0
  116. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/evidence_pack.py +0 -0
  117. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/external.py +0 -0
  118. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/hashing.py +0 -0
  119. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/heartbeat.py +0 -0
  120. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/hook_bootstrap.py +0 -0
  121. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/identity.py +0 -0
  122. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/migrate.py +0 -0
  123. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/mitre.py +0 -0
  124. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/policy.py +0 -0
  125. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/redaction.py +0 -0
  126. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/replay.py +0 -0
  127. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/run_context.py +0 -0
  128. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/sinks.py +0 -0
  129. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/spool.py +0 -0
  130. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/tool_policy/__init__.py +0 -0
  131. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/tool_policy/evaluator.py +0 -0
  132. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/tool_policy/loader.py +0 -0
  133. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/tool_policy/models.py +0 -0
  134. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/trail_anchor.py +0 -0
  135. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/trail_chain.py +0 -0
  136. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/trail_db.py +0 -0
  137. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/trail_merkle.py +0 -0
  138. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/auth.py +0 -0
  139. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/bus/__init__.py +0 -0
  140. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/bus/audit_exporter.py +0 -0
  141. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/bus/bridges.py +0 -0
  142. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/bus/bus.py +0 -0
  143. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/bus/events.py +0 -0
  144. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/bus/outbox.py +0 -0
  145. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/config.py +0 -0
  146. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/diagnostics/__init__.py +0 -0
  147. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/diagnostics/autostart.py +0 -0
  148. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/diagnostics/doctor.py +0 -0
  149. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/diagnostics/driver_paths.py +0 -0
  150. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/diagnostics/env_file.py +0 -0
  151. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/diagnostics/hook_coverage.py +0 -0
  152. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/diagnostics/mcp_inventory.py +0 -0
  153. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/drivers/__init__.py +0 -0
  154. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/drivers/host.py +0 -0
  155. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/drivers/permissions.py +0 -0
  156. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/drivers/spec.py +0 -0
  157. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/extensions.py +0 -0
  158. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/health.py +0 -0
  159. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/hooks/__init__.py +0 -0
  160. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/hooks/ingest.py +0 -0
  161. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/policies/detection/manifest.yaml +0 -0
  162. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/policies/opa/agent_rules.rego +0 -0
  163. {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/policies/tool/manifest.yaml +0 -0
  164. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/conftest.py +0 -0
  165. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/fixtures/agt_filesink_exfil.jsonl +0 -0
  166. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/fixtures/agt_filesink_mixed.jsonl +0 -0
  167. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/fixtures/fake_mcp_server.py +0 -0
  168. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_agentmetry_audit.py +0 -0
  169. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_agentmetry_ingest_client.py +0 -0
  170. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_agt_adapter.py +0 -0
  171. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_atlas_detection_enrichment.py +0 -0
  172. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_atlas_mapping.py +0 -0
  173. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_audit_sinks.py +0 -0
  174. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_audit_stats.py +0 -0
  175. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_audit_tail.py +0 -0
  176. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_auth.py +0 -0
  177. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_autostart.py +0 -0
  178. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_boot_sequence.py +0 -0
  179. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_burst_windows.py +0 -0
  180. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_chinese_agent_hooks.py +0 -0
  181. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_chinese_agent_sprint_b.py +0 -0
  182. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_chinese_agent_sprint_c.py +0 -0
  183. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_chronicle_adapter.py +0 -0
  184. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_cli_backup.py +0 -0
  185. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_cloudevents_adapter.py +0 -0
  186. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_compliance_digest.py +0 -0
  187. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_crewai_adapter.py +0 -0
  188. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_detection_adi.py +0 -0
  189. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_detection_benchmark.py +0 -0
  190. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_detection_default_config.py +0 -0
  191. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_detection_disposition.py +0 -0
  192. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_detection_disposition_api.py +0 -0
  193. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_detection_duplicate_emission.py +0 -0
  194. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_detection_emit_durability.py +0 -0
  195. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_detection_engine.py +0 -0
  196. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_detection_evasion.py +0 -0
  197. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_detection_hf_incident.py +0 -0
  198. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_detection_quoted_command_words.py +0 -0
  199. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_detection_rule_identity.py +0 -0
  200. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_detection_rules_v2.py +0 -0
  201. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_dlp_agent_env_override.py +0 -0
  202. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_dlp_invisible_unicode.py +0 -0
  203. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_dlp_markdown_exfil.py +0 -0
  204. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_doctor.py +0 -0
  205. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_dogfood.py +0 -0
  206. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_dogfood_freeze.py +0 -0
  207. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_driver_paths.py +0 -0
  208. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_ecs_threat.py +0 -0
  209. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_event_bus.py +0 -0
  210. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_evidence_pack.py +0 -0
  211. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_extensions.py +0 -0
  212. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_external_ingest.py +0 -0
  213. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_forwarding_shape.py +0 -0
  214. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_heartbeat.py +0 -0
  215. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_hook_bootstrap.py +0 -0
  216. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_hook_coverage.py +0 -0
  217. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_hook_enforcement_timing.py +0 -0
  218. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_hook_spool.py +0 -0
  219. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_ingest_roundtrip.py +0 -0
  220. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_isolation.py +0 -0
  221. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_launch_targets.py +0 -0
  222. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_live_detection.py +0 -0
  223. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_live_detection_store.py +0 -0
  224. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_mcp_audit_proxy.py +0 -0
  225. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_mcp_inventory.py +0 -0
  226. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_mitre_dlp.py +0 -0
  227. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_opensre_adapter.py +0 -0
  228. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_redaction.py +0 -0
  229. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_replay.py +0 -0
  230. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_sigma_pack.py +0 -0
  231. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_static_serving.py +0 -0
  232. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_subagent_lifecycle.py +0 -0
  233. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_tool_policy.py +0 -0
  234. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_tool_policy_agent_cli.py +0 -0
  235. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_tool_policy_git_hooks.py +0 -0
  236. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_trail_anchor.py +0 -0
  237. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_trail_chain.py +0 -0
  238. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_trail_concurrency.py +0 -0
  239. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_trail_merkle.py +0 -0
  240. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_unattended_agent_policy.py +0 -0
  241. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_version.py +0 -0
  242. {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_yaml_detection_rules.py +0 -0
  243. {agentmetry-0.7.0 → agentmetry-0.8.0}/tools/generate_sigma_pack.py +0 -0
  244. {agentmetry-0.7.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.7.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
@@ -122,6 +122,10 @@ class ExternalIngestBody(BaseModel):
122
122
  # fields that separate a shipped release from a rug pull. All hashes and
123
123
  # flags: no tool name, no description, nothing model-visible.
124
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)
125
129
  server_version: str = ""
126
130
  list_changed: bool | None = None
127
131
 
@@ -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,
@@ -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
@@ -42,8 +42,35 @@ logger = logging.getLogger(__name__)
42
42
 
43
43
  _lock = threading.Lock()
44
44
 
45
- #: Keys that change between calls without changing what the model is told.
46
- _VOLATILE = frozenset({"_meta"})
45
+ #: Bump when the bytes fed to the hash change.
46
+ #:
47
+ #: A stored fingerprint is only comparable to one computed the same way, so a
48
+ #: baseline written under an older version is re-baselined rather than reported
49
+ #: as a change. Without that, the upgrade that fixes a blind spot hands every
50
+ #: existing user a rug-pull alert on the same morning.
51
+ #:
52
+ #: 1: `_meta` stripped before hashing.
53
+ #: 2: `_meta` hashed. See below.
54
+ FINGERPRINT_VERSION = 2
55
+
56
+ #: Keys dropped before hashing, because they change between calls without
57
+ #: changing what the model is told.
58
+ #:
59
+ #: **Empty, and it is meant to stay nearly empty.** It used to hold `_meta`, on
60
+ #: the reasonable-sounding grounds that `_meta` is transport bookkeeping. It is
61
+ #: not. `_meta` rides on the tool object, reaches clients, and whether it
62
+ #: reaches the *model* is client-dependent, which makes it the obvious place to
63
+ #: move behaviour-bearing text once descriptions are being watched. Stripping it
64
+ #: meant a poisoned listing hashed identically to a clean one: not a weakened
65
+ #: signal, an absent one (issue #142).
66
+ #:
67
+ #: The rules in this project are public on purpose, so its exemptions are public
68
+ #: too, and an exemption is a documented bypass.
69
+ #:
70
+ #: The bar for adding a key here is therefore evidence that it genuinely varies
71
+ #: per call on a real server, plus a note saying which server and why. "It looks
72
+ #: like metadata" is how the last entry got in.
73
+ _VOLATILE: frozenset[str] = frozenset()
47
74
 
48
75
 
49
76
  def _canonical_entries(tools: list[Any] | None) -> list[dict[str, Any]]:
@@ -98,6 +125,244 @@ def fingerprint_each_tool(tools: list[Any] | None) -> dict[str, str]:
98
125
  }
99
126
 
100
127
 
128
+ #: Characters that render as nothing, or reverse rendering order, while still
129
+ #: reaching the model. Grouped by category so a finding can say what kind of
130
+ #: concealment it is without carrying the text.
131
+ #:
132
+ #: The TAG block is the one that matters most: U+E0000 to U+E007F mirrors ASCII,
133
+ #: so an entire second instruction can be written in it and displayed as an
134
+ #: empty string. Research calls the result an approval-view fidelity gap, and
135
+ #: the phrase is exact: the human approving a tool reads one string and the
136
+ #: model receives another.
137
+ #:
138
+ #: **Every one of these ranges has a legitimate use**, which the first version
139
+ #: of this module denied in a comment that said so in as many words. A reader on
140
+ #: r/mcp took it apart within a day of the release being cut:
141
+ #:
142
+ #: * TAG characters spell the subdivision flags. Scotland, Wales and England
143
+ #: are emoji tag sequences, a U+1F3F4 base followed by tag letters and a
144
+ #: U+E007F terminator.
145
+ #: * U+200D joins every emoji ZWJ sequence, so a family or a profession emoji
146
+ #: carries one or more.
147
+ #: * U+200C is required Persian and Arabic orthography and is used across
148
+ #: Indic scripts. It is spelling, not decoration, so flagging it penalises
149
+ #: correctly written non-Latin text.
150
+ #: * Bidi isolates are the modern, recommended way to mix scripts, so any
151
+ #: description containing Arabic or Hebrew may legitimately carry them.
152
+ #:
153
+ #: So the check is contextual rather than range-based. A character counts only
154
+ #: when nothing in its surroundings explains it, which keeps the detection
155
+ #: (TAG-encoded ASCII is still caught, because it has no flag base in front of
156
+ #: it) and drops the false positives.
157
+ #:
158
+ #: Private Use Area is deliberately absent. It is genuinely used for icon fonts
159
+ #: and would fire on legitimate descriptions, and a category that cries wolf
160
+ #: costs more than the one case it might catch.
161
+
162
+ #: Tag characters, and the two that bracket a legitimate emoji tag sequence.
163
+ _TAG_RANGE = (0xE0000, 0xE007F)
164
+ _TAG_TERMINATOR = 0xE007F
165
+ _TAG_BASE = 0x1F3F4 # waving black flag, the only base an emoji tag sequence uses
166
+
167
+ #: Zero-width and format characters, split by whether context can explain them.
168
+ _ZWJ = 0x200D
169
+ _ZWNJ = 0x200C
170
+
171
+ #: The category for characters that are typography rather than evidence.
172
+ #:
173
+ #: These three were counted as concealment until the reader who found the flag
174
+ #: and Persian cases was asked directly whether they had innocent uses. They do,
175
+ #: and descriptions imported from formatted documentation carry them:
176
+ #:
177
+ #: * U+200B gives a line-break opportunity without a visible space.
178
+ #: * U+00AD is a soft hyphen, an optional hyphenation point.
179
+ #: * U+FEFF is legacy zero-width no-break space. U+2060 is preferred for new
180
+ #: text, and a byte order mark belongs to the input stream rather than to
181
+ #: every description string in it.
182
+ #:
183
+ #: Unlike ZWJ and ZWNJ there is no neighbouring character that settles the
184
+ #: question, so no positional test can clear them. They are reported under their
185
+ #: own key instead, so an operator can inspect them without a formatting quirk
186
+ #: reading as a poisoned tool. Presence alone is not evidence of anything.
187
+ FORMATTING_CATEGORY = "formatting"
188
+ _FORMATTING_CONTROLS = frozenset({0x200B, 0xFEFF, 0x00AD})
189
+
190
+ _BIDI_CONTROLS = frozenset(
191
+ list(range(0x202A, 0x202F)) + list(range(0x2066, 0x206A))
192
+ )
193
+
194
+ #: Rough pictographic ranges. Only used to decide whether a ZWJ sits between two
195
+ #: emoji, so being generous here costs a missed detection in a case that would
196
+ #: need an attacker to hide a payload inside an emoji sequence, and being narrow
197
+ #: costs a false positive on ordinary text. Generous is the right trade.
198
+ _PICTOGRAPHIC = (
199
+ (0x00A9, 0x00AE),
200
+ (0x203C, 0x3299),
201
+ (0x1F000, 0x1FAFF),
202
+ (0xFE0F, 0xFE0F), # variation selector 16, sits inside ZWJ sequences
203
+ (0x1F3FB, 0x1F3FF), # skin tone modifiers
204
+ )
205
+
206
+ #: Scripts that use ZWNJ as orthography rather than decoration.
207
+ _ZWNJ_SCRIPTS = (
208
+ (0x0600, 0x06FF), # Arabic
209
+ (0x0750, 0x077F),
210
+ (0x08A0, 0x08FF),
211
+ (0xFB50, 0xFDFF),
212
+ (0xFE70, 0xFEFF),
213
+ (0x0900, 0x0DFF), # Devanagari through Sinhala
214
+ (0x0700, 0x074F), # Syriac
215
+ )
216
+
217
+ #: Right-to-left scripts. A bidi control in text with none of these has nothing
218
+ #: to reorder, which is what makes it unexplained.
219
+ _RTL_SCRIPTS = (
220
+ (0x0590, 0x05FF), # Hebrew
221
+ (0x0600, 0x06FF), # Arabic
222
+ (0x0700, 0x074F), # Syriac
223
+ (0x0780, 0x07BF), # Thaana
224
+ (0x07C0, 0x07FF), # N'Ko
225
+ (0x0800, 0x085F),
226
+ (0xFB1D, 0xFDFF),
227
+ (0xFE70, 0xFEFF),
228
+ )
229
+
230
+
231
+ def _in(point: int, ranges: tuple[tuple[int, int], ...]) -> bool:
232
+ return any(low <= point <= high for low, high in ranges)
233
+
234
+
235
+ def _explained_tag_indices(points: list[int]) -> set[int]:
236
+ """Indices belonging to a well-formed emoji tag sequence.
237
+
238
+ The grammar is narrow: a U+1F3F4 base, one or more tag characters, then a
239
+ U+E007F terminator. Tag characters anywhere else have no other use, so this
240
+ keeps the detection while letting the subdivision flags through.
241
+ """
242
+ explained: set[int] = set()
243
+ i = 0
244
+ while i < len(points):
245
+ if points[i] != _TAG_BASE:
246
+ i += 1
247
+ continue
248
+ j = i + 1
249
+ while j < len(points) and _TAG_RANGE[0] <= points[j] < _TAG_TERMINATOR:
250
+ j += 1
251
+ if j > i + 1 and j < len(points) and points[j] == _TAG_TERMINATOR:
252
+ explained.update(range(i, j + 1))
253
+ i = j + 1
254
+ else:
255
+ i += 1
256
+ return explained
257
+
258
+
259
+ def _neighbour(points: list[int], index: int, step: int) -> int | None:
260
+ """The nearest code point either side, skipping variation selectors."""
261
+ i = index + step
262
+ while 0 <= i < len(points):
263
+ if points[i] != 0xFE0F:
264
+ return points[i]
265
+ i += step
266
+ return None
267
+
268
+
269
+ def _scan_text(text: str, found: dict[str, int]) -> None:
270
+ points = [ord(c) for c in text]
271
+ tag_ok = _explained_tag_indices(points)
272
+ has_rtl = any(_in(p, _RTL_SCRIPTS) for p in points)
273
+
274
+ for i, point in enumerate(points):
275
+ if _TAG_RANGE[0] <= point <= _TAG_RANGE[1]:
276
+ if i not in tag_ok:
277
+ found["tag_block"] = found.get("tag_block", 0) + 1
278
+ continue
279
+
280
+ if point in _FORMATTING_CONTROLS:
281
+ found[FORMATTING_CATEGORY] = found.get(FORMATTING_CATEGORY, 0) + 1
282
+ continue
283
+
284
+ if point == _ZWJ:
285
+ before = _neighbour(points, i, -1)
286
+ after = _neighbour(points, i, 1)
287
+ joins_emoji = (
288
+ before is not None
289
+ and after is not None
290
+ and _in(before, _PICTOGRAPHIC)
291
+ and _in(after, _PICTOGRAPHIC)
292
+ )
293
+ if not joins_emoji:
294
+ found["zero_width"] = found.get("zero_width", 0) + 1
295
+ continue
296
+
297
+ if point == _ZWNJ:
298
+ before = _neighbour(points, i, -1)
299
+ after = _neighbour(points, i, 1)
300
+ orthographic = (before is not None and _in(before, _ZWNJ_SCRIPTS)) or (
301
+ after is not None and _in(after, _ZWNJ_SCRIPTS)
302
+ )
303
+ if not orthographic:
304
+ found["zero_width"] = found.get("zero_width", 0) + 1
305
+ continue
306
+
307
+ if point in _BIDI_CONTROLS and not has_rtl:
308
+ found["bidi_control"] = found.get("bidi_control", 0) + 1
309
+
310
+
311
+ def _walk_strings(value: Any):
312
+ """Every string anywhere in a tool definition.
313
+
314
+ Deliberately not a list of field names. #103 settled that argument for the
315
+ severity split and it applies here for the same reason: an allowlist fails
316
+ open on whatever the spec adds next, and a field nobody has thought about
317
+ yet should default to inspected rather than invisible.
318
+ """
319
+ if isinstance(value, str):
320
+ yield value
321
+ elif isinstance(value, dict):
322
+ for key, item in value.items():
323
+ yield from _walk_strings(key)
324
+ yield from _walk_strings(item)
325
+ elif isinstance(value, list):
326
+ for item in value:
327
+ yield from _walk_strings(item)
328
+
329
+
330
+ def scan_concealed_text(tools: list[Any] | None) -> dict[str, int]:
331
+ """Counts of unexplained concealed characters, or an empty dict if clean.
332
+
333
+ Counts only. Never the text, never which tool, never the surrounding
334
+ string. A finding that carries the payload has stored the payload, which is
335
+ the rule the whole module is built on.
336
+
337
+ This catches one narrow class of poisoning on a single observation, which
338
+ the fingerprint cannot do at all. That limit is worth stating precisely,
339
+ because "blind on the first listing" understates it: the fingerprint answers
340
+ "did this move", never "should this have been here". A server hostile at its
341
+ first release that never changed since has a stable digest forever and is
342
+ never flagged by it, at listing one or listing one hundred.
343
+
344
+ This check does **not** make a clean listing trustworthy either, first or
345
+ otherwise. An instruction written in ordinary visible text needs none of
346
+ these characters and is invisible here.
347
+
348
+ "Unexplained" is doing real work in the first line. Every range here has a
349
+ legitimate use, so context decides: a tag character inside a subdivision
350
+ flag, a ZWJ between two emoji, a ZWNJ next to Persian or Devanagari, and a
351
+ bidi control in text that actually contains a right-to-left script are all
352
+ silent. See the comment above `_TAG_RANGE` for who found that out and how.
353
+
354
+ The result carries two grades of finding under different keys. `tag_block`,
355
+ `zero_width` and `bidi_control` are concealment: something is hidden and
356
+ nothing around it explains why. `formatting` is typography that no
357
+ positional test can clear, and on its own it is not evidence of poisoning.
358
+ `build_schema_canonical` keeps them in separate blocks for that reason.
359
+ """
360
+ found: dict[str, int] = {}
361
+ for text in _walk_strings(_canonical_entries(tools)):
362
+ _scan_text(text, found)
363
+ return found
364
+
365
+
101
366
  def server_id(name: str) -> str:
102
367
  """Opaque 16-hex id for a server name. Publish this, never the name."""
103
368
  return hashlib.sha256(name.encode("utf-8")).hexdigest()[:16]
@@ -201,6 +466,9 @@ class SchemaRecord:
201
466
  #: is why a delta against an empty baseline reports nothing changed rather
202
467
  #: than reporting every tool as new.
203
468
  tool_digests: dict[str, str] = field(default_factory=dict)
469
+ #: Which hashing this fingerprint was computed with. Defaults to 1 because
470
+ #: a record that does not say was written before the field existed.
471
+ fingerprint_version: int = 1
204
472
 
205
473
 
206
474
  @dataclass
@@ -256,13 +524,14 @@ def load_store(path: Path | None = None) -> SchemaStore:
256
524
  }
257
525
  if isinstance(digests, dict)
258
526
  else {},
527
+ fingerprint_version=int(rec.get("fingerprint_version") or 1),
259
528
  )
260
529
  return SchemaStore(servers=servers)
261
530
 
262
531
 
263
532
  def _dump(store: SchemaStore) -> dict[str, Any]:
264
533
  return {
265
- "schema_version": 2,
534
+ "schema_version": 3,
266
535
  "servers": {
267
536
  name: {
268
537
  "fingerprint": rec.fingerprint,
@@ -273,6 +542,7 @@ def _dump(store: SchemaStore) -> dict[str, Any]:
273
542
  **({"server_version": rec.server_version} if rec.server_version else {}),
274
543
  **({"list_changed": rec.list_changed} if rec.list_changed is not None else {}),
275
544
  **({"tool_digests": rec.tool_digests} if rec.tool_digests else {}),
545
+ "fingerprint_version": rec.fingerprint_version,
276
546
  }
277
547
  for name, rec in sorted(store.servers.items())
278
548
  },
@@ -312,6 +582,25 @@ def classify_observation(
312
582
  existing = load_store(path or store_path()).servers.get(name)
313
583
  if existing is None:
314
584
  return "new"
585
+ if existing.fingerprint_version != FINGERPRINT_VERSION:
586
+ # The stored digest was computed over different bytes, so it is not
587
+ # comparable and a mismatch says nothing about the server. Re-baseline
588
+ # instead. Without this, the release that closed the `_meta` blind spot
589
+ # would have reported a rug pull on every server every user had ever
590
+ # observed, on upgrade day, and taught them the alert is noise.
591
+ #
592
+ # `rebaselined` rather than `new`, and the distinction is the whole
593
+ # point (issue #146). `new` says "we had never seen this server". This
594
+ # says "we had a baseline, our hashing changed, and we are trusting
595
+ # whatever the server says today without comparing it to anything".
596
+ #
597
+ # Those carry different evidence. If a server was already poisoned
598
+ # before the upgrade, this observation adopts the poisoned state and an
599
+ # operator reading a quiet week must be able to see that, rather than
600
+ # find a word that also means "nothing has ever been wrong here".
601
+ # Trading a loud false positive for a silent false negative is the
602
+ # worse half of that trade for a recorder.
603
+ return "rebaselined"
315
604
  if existing.fingerprint == fp:
316
605
  return "same"
317
606
  if existing.tool_count == 0 and tool_count > 0:
@@ -384,6 +673,7 @@ def record_observation(
384
673
  existing.server_version = server_version
385
674
  if list_changed is not None:
386
675
  existing.list_changed = list_changed
676
+ existing.fingerprint_version = FINGERPRINT_VERSION
387
677
  if tool_digests:
388
678
  # An unchanged listing backfills the per-tool map for records
389
679
  # written before it existed, so the first real change after an
@@ -402,6 +692,7 @@ def record_observation(
402
692
  server_version=server_version,
403
693
  list_changed=list_changed,
404
694
  tool_digests=dict(tool_digests or {}),
695
+ fingerprint_version=FINGERPRINT_VERSION,
405
696
  )
406
697
  _write_store(store, target)
407
698
  return "changed" if previous else "new"
@@ -10,4 +10,4 @@ Bump this here and add the matching CHANGELOG section in the same commit.
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
- __version__ = "0.7.0"
13
+ __version__ = "0.8.0"
@@ -159,3 +159,29 @@ rules:
159
159
  pattern: "(?i)\\b(ANTHROPIC_BASE_URL|ANTHROPIC_AUTH_TOKEN|OPENAI_BASE_URL|OPENAI_API_BASE|GEMINI_BASE_URL|MOONSHOT_API_KEY|DASHSCOPE_API_KEY|DEEPSEEK_API_KEY|ZHIPUAI_API_KEY|MINIMAX_API_KEY|QWEN_API_KEY|HTTP_PROXY|HTTPS_PROXY|NODE_OPTIONS|PYTHONSTARTUP|LD_PRELOAD)[\"']?\\s*[=:]"
160
160
  category: "execution_hijack"
161
161
  severity: "critical"
162
+
163
+ - id: "stripe_api_key"
164
+ name: "Stripe API Key"
165
+ description: "Detects Stripe live/test secret keys"
166
+ pattern: "(?i)\\bsk_(live|test)_[0-9a-z]{24,}\\b"
167
+ category: "credentials"
168
+ severity: "critical"
169
+
170
+ # Anthropic keys (sk-ant-api03-...) don't fit the generic sk-[a-zA-Z0-9]{20,}
171
+ # shape below: the hyphen after "ant" (and again after "api03") breaks the
172
+ # alnum-only run at 3 characters, so the generic rule never fires on them
173
+ # despite starting with the same "sk-" prefix. Needs its own rule, and a
174
+ # distinct rule id is more useful in a detection than a generic match anyway.
175
+ - id: "anthropic_api_key"
176
+ name: "Anthropic API Key"
177
+ description: "Detects Anthropic API keys (sk-ant-...)"
178
+ pattern: "(?i)\\bsk-ant-[a-zA-Z0-9_-]{20,}"
179
+ category: "credentials"
180
+ severity: "critical"
181
+
182
+ - id: "generic_provider_api_key"
183
+ name: "Generic LLM Provider API Key"
184
+ description: "Detects OpenAI-shaped provider API keys (sk-...); Anthropic and DashScope keys have their own rules above"
185
+ pattern: "(?i)\\bsk-[a-zA-Z0-9]{20,}\\b"
186
+ category: "credentials"
187
+ severity: "critical"
@@ -34,7 +34,12 @@ dependencies = [
34
34
  "websockets>=14.0",
35
35
  "sqlalchemy>=2.0.0",
36
36
  "aiosqlite>=0.20.0",
37
- "mcp>=1.2",
37
+ # Upper bound is load-bearing, not caution. MCP 2.0 removed
38
+ # `mcp.server.fastmcp.FastMCP`, which `tools/vault_fs_server.py` imports,
39
+ # so an unconstrained `>=1.2` resolved to 2.x on any fresh install and the
40
+ # bundled server failed on first run (issue #138). Lift this when that
41
+ # import is ported to the 2.x API, not before.
42
+ "mcp>=1.2,<2",
38
43
  # Windows ships no IANA timezone database, so `zoneinfo` cannot resolve
39
44
  # AGENTMETRY_BUSINESS_TZ there without this. Without it the off-hours rule
40
45
  # silently fell back to UTC and produced wrong findings on the platform this
@@ -47,7 +52,7 @@ dependencies = [
47
52
  # so a linter release could turn a green branch red with no change to this repo.
48
53
  # It did: 0.16.0 widened the default rule set and CI reported 132 errors against
49
54
  # code that passed locally on 0.15.x. Bump this deliberately, not by accident.
50
- dev = ["pytest>=8.0", "pytest-asyncio>=0.24", "pytest-cov>=6.0", "ruff==0.16.4"]
55
+ dev = ["pytest>=8.0", "pytest-asyncio>=0.24", "pytest-cov>=6.0", "ruff==0.16.6"]
51
56
 
52
57
  [project.urls]
53
58
  Homepage = "https://agentmetry.ai"