agentmetry 0.9.1__tar.gz → 0.9.2__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 (258) hide show
  1. {agentmetry-0.9.1 → agentmetry-0.9.2}/.env.example +9 -1
  2. {agentmetry-0.9.1 → agentmetry-0.9.2}/PKG-INFO +2 -2
  3. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/cli/__init__.py +2 -5
  4. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/heartbeat.py +9 -0
  5. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/ingest.py +1 -0
  6. agentmetry-0.9.2/agentmetry/core/audit/mcp_inventory_event.py +119 -0
  7. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/run_context.py +1 -1
  8. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/sinks.py +13 -2
  9. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/bus/audit_exporter.py +1 -0
  10. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/config.py +15 -0
  11. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/diagnostics/mcp_inventory.py +43 -0
  12. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/operator_identity.py +8 -1
  13. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/version.py +1 -1
  14. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/hooks/ingest.py +9 -4
  15. {agentmetry-0.9.1 → agentmetry-0.9.2}/pyproject.toml +1 -1
  16. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_audit_sinks.py +65 -0
  17. agentmetry-0.9.2/tests/test_mcp_inventory_event.py +189 -0
  18. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_operator_attribution.py +49 -0
  19. {agentmetry-0.9.1 → agentmetry-0.9.2}/.dockerignore +0 -0
  20. {agentmetry-0.9.1 → agentmetry-0.9.2}/.env.agentmetry-demo +0 -0
  21. {agentmetry-0.9.1 → agentmetry-0.9.2}/.gitignore +0 -0
  22. {agentmetry-0.9.1 → agentmetry-0.9.2}/Dockerfile +0 -0
  23. {agentmetry-0.9.1 → agentmetry-0.9.2}/README.md +0 -0
  24. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/__init__.py +0 -0
  25. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/api/__init__.py +0 -0
  26. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/api/main.py +0 -0
  27. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/api/routes/__init__.py +0 -0
  28. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/api/routes/audit.py +0 -0
  29. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/api/websocket.py +0 -0
  30. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/api/ws_bridge.py +0 -0
  31. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/cli/__main__.py +0 -0
  32. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/__init__.py +0 -0
  33. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/__init__.py +0 -0
  34. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/adapters/__init__.py +0 -0
  35. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/adapters/agt.py +0 -0
  36. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/adapters/chronicle.py +0 -0
  37. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/adapters/cloudevents.py +0 -0
  38. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/adapters/ecs.py +0 -0
  39. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/adapters/splunk.py +0 -0
  40. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/alerts.py +0 -0
  41. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/atlas.py +0 -0
  42. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/canonical.py +0 -0
  43. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/compliance_digest.py +0 -0
  44. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/__init__.py +0 -0
  45. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/benchmark.py +0 -0
  46. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_approval_denied_then_executed.jsonl +0 -0
  47. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_arbitrary_host_stage_execute.jsonl +0 -0
  48. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_autonomous_unapproved_write.jsonl +0 -0
  49. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_credential_exfil.jsonl +0 -0
  50. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_credential_then_cloud_api.jsonl +0 -0
  51. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_destructive_delete_burst.jsonl +0 -0
  52. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_discovery_then_collect.jsonl +0 -0
  53. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_dotfile_then_git_push.jsonl +0 -0
  54. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_encoded_command_download.jsonl +0 -0
  55. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_env_credential_exfil.jsonl +0 -0
  56. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_hashed_only_no_command.jsonl +0 -0
  57. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_interpreter_egress.jsonl +0 -0
  58. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_pr_merged_without_review.jsonl +0 -0
  59. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_proc_substitution_cradle.jsonl +0 -0
  60. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_remote_pipe_to_shell.jsonl +0 -0
  61. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_remote_staging_then_execute.jsonl +0 -0
  62. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_secret_manager_then_egress.jsonl +0 -0
  63. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_session_tool_burst.jsonl +0 -0
  64. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_single_command_exfil.jsonl +0 -0
  65. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_single_quoted_interpreter_egress.jsonl +0 -0
  66. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_ssh_directory_exfil.jsonl +0 -0
  67. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_subagent_swarm.jsonl +0 -0
  68. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_timestamp_collision.jsonl +0 -0
  69. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_untrusted_input_then_action.jsonl +0 -0
  70. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_authoring_merge_fixtures.jsonl +0 -0
  71. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_autonomous_after_approval.jsonl +0 -0
  72. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_build_artifact_cleanup.jsonl +0 -0
  73. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_ci_artifact_download.jsonl +0 -0
  74. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_commit_message_names_cloud_clis.jsonl +0 -0
  75. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_database_migration.jsonl +0 -0
  76. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_dependency_install_and_build.jsonl +0 -0
  77. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_download_release_archive.jsonl +0 -0
  78. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_env_example_read.jsonl +0 -0
  79. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_fetch_data_then_run_repo_script.jsonl +0 -0
  80. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_fetch_dataset_then_analyse.jsonl +0 -0
  81. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_fetch_lockfile_then_install.jsonl +0 -0
  82. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_fetch_piped_into_own_script.jsonl +0 -0
  83. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_git_review_and_push.jsonl +0 -0
  84. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_github_raw_docs_then_script.jsonl +0 -0
  85. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_human_driven_deletes.jsonl +0 -0
  86. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_local_api_probing.jsonl +0 -0
  87. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_long_but_calm_session.jsonl +0 -0
  88. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_loopback_is_not_egress.jsonl +0 -0
  89. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_loopback_pipe_to_interpreter.jsonl +0 -0
  90. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_mcp_text_mentions_credential_path.jsonl +0 -0
  91. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_ordinary_development.jsonl +0 -0
  92. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_package_manager_after_fetch.jsonl +0 -0
  93. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_reading_config_that_is_not_secret.jsonl +0 -0
  94. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_remote_api_call_no_credentials.jsonl +0 -0
  95. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_research_then_docs.jsonl +0 -0
  96. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_reversed_order_is_not_exfil.jsonl +0 -0
  97. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_secret_names_and_docs.jsonl +0 -0
  98. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_test_and_fix_loop.jsonl +0 -0
  99. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_writing_about_credentials.jsonl +0 -0
  100. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/corpus.yaml +0 -0
  101. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/disposition.py +0 -0
  102. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/engine.py +0 -0
  103. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/live.py +0 -0
  104. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/live_store.py +0 -0
  105. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/models.py +0 -0
  106. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/rules.py +0 -0
  107. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/traits.py +0 -0
  108. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/yaml_config.py +0 -0
  109. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/detection/yaml_rules.py +0 -0
  110. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/dlp/__init__.py +0 -0
  111. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/dlp/loader.py +0 -0
  112. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/dlp/models.py +0 -0
  113. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/dlp/scanner.py +0 -0
  114. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/dogfood.py +0 -0
  115. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/evidence_pack.py +0 -0
  116. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/external.py +0 -0
  117. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/hashing.py +0 -0
  118. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/hook_bootstrap.py +0 -0
  119. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/identity.py +0 -0
  120. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/migrate.py +0 -0
  121. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/mitre.py +0 -0
  122. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/otel_ingest.py +0 -0
  123. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/policy.py +0 -0
  124. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/policy_settings.py +0 -0
  125. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/redaction.py +0 -0
  126. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/replay.py +0 -0
  127. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/spool.py +0 -0
  128. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/tool_policy/__init__.py +0 -0
  129. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/tool_policy/capability.py +0 -0
  130. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/tool_policy/evaluator.py +0 -0
  131. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/tool_policy/loader.py +0 -0
  132. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/tool_policy/models.py +0 -0
  133. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/trail_anchor.py +0 -0
  134. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/trail_chain.py +0 -0
  135. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/trail_db.py +0 -0
  136. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/audit/trail_merkle.py +0 -0
  137. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/auth.py +0 -0
  138. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/bus/__init__.py +0 -0
  139. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/bus/bridges.py +0 -0
  140. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/bus/bus.py +0 -0
  141. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/bus/events.py +0 -0
  142. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/bus/outbox.py +0 -0
  143. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/diagnostics/__init__.py +0 -0
  144. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/diagnostics/autostart.py +0 -0
  145. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/diagnostics/doctor.py +0 -0
  146. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/diagnostics/driver_paths.py +0 -0
  147. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/diagnostics/env_file.py +0 -0
  148. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/diagnostics/hook_coverage.py +0 -0
  149. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/diagnostics/mcp_schema.py +0 -0
  150. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/drivers/__init__.py +0 -0
  151. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/drivers/host.py +0 -0
  152. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/drivers/permissions.py +0 -0
  153. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/drivers/spec.py +0 -0
  154. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/extensions.py +0 -0
  155. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/core/health.py +0 -0
  156. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/hooks/__init__.py +0 -0
  157. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/policies/detection/manifest.yaml +0 -0
  158. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/policies/dlp/manifest.yaml +0 -0
  159. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/policies/opa/agent_rules.rego +0 -0
  160. {agentmetry-0.9.1 → agentmetry-0.9.2}/agentmetry/policies/tool/manifest.yaml +0 -0
  161. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/conftest.py +0 -0
  162. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/fixtures/agt_filesink_exfil.jsonl +0 -0
  163. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/fixtures/agt_filesink_mixed.jsonl +0 -0
  164. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/fixtures/fake_mcp_server.py +0 -0
  165. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/fixtures/otel_claude_code_session.json +0 -0
  166. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_agentmetry_audit.py +0 -0
  167. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_agentmetry_ingest_client.py +0 -0
  168. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_agt_adapter.py +0 -0
  169. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_ask_gate_phase_a.py +0 -0
  170. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_atlas_detection_enrichment.py +0 -0
  171. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_atlas_mapping.py +0 -0
  172. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_audit_stats.py +0 -0
  173. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_audit_tail.py +0 -0
  174. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_auth.py +0 -0
  175. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_autostart.py +0 -0
  176. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_boot_sequence.py +0 -0
  177. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_burst_windows.py +0 -0
  178. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_chinese_agent_hooks.py +0 -0
  179. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_chinese_agent_sprint_b.py +0 -0
  180. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_chinese_agent_sprint_c.py +0 -0
  181. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_chronicle_adapter.py +0 -0
  182. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_cli_backup.py +0 -0
  183. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_cli_commands.py +0 -0
  184. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_cli_env_var_names.py +0 -0
  185. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_cloudevents_adapter.py +0 -0
  186. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_compliance_digest.py +0 -0
  187. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_crewai_adapter.py +0 -0
  188. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_detection_adi.py +0 -0
  189. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_detection_benchmark.py +0 -0
  190. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_detection_default_config.py +0 -0
  191. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_detection_disposition.py +0 -0
  192. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_detection_disposition_api.py +0 -0
  193. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_detection_duplicate_emission.py +0 -0
  194. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_detection_emit_durability.py +0 -0
  195. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_detection_engine.py +0 -0
  196. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_detection_evasion.py +0 -0
  197. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_detection_hf_incident.py +0 -0
  198. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_detection_quoted_command_words.py +0 -0
  199. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_detection_rule_identity.py +0 -0
  200. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_detection_rules_v2.py +0 -0
  201. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_dlp_agent_env_override.py +0 -0
  202. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_dlp_invisible_unicode.py +0 -0
  203. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_dlp_markdown_exfil.py +0 -0
  204. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_dlp_scanner.py +0 -0
  205. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_doctor.py +0 -0
  206. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_dogfood.py +0 -0
  207. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_dogfood_freeze.py +0 -0
  208. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_driver_paths.py +0 -0
  209. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_ecs_threat.py +0 -0
  210. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_event_bus.py +0 -0
  211. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_evidence_pack.py +0 -0
  212. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_extensions.py +0 -0
  213. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_external_ingest.py +0 -0
  214. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_forwarding_shape.py +0 -0
  215. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_funding_manifest.py +0 -0
  216. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_heartbeat.py +0 -0
  217. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_hook_bootstrap.py +0 -0
  218. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_hook_coverage.py +0 -0
  219. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_hook_decision_format.py +0 -0
  220. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_hook_enforcement_timing.py +0 -0
  221. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_hook_import_cost.py +0 -0
  222. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_hook_real_process.py +0 -0
  223. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_hook_spool.py +0 -0
  224. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_identity_enrich.py +0 -0
  225. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_ingest_roundtrip.py +0 -0
  226. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_isolation.py +0 -0
  227. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_launch_targets.py +0 -0
  228. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_live_detection.py +0 -0
  229. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_live_detection_store.py +0 -0
  230. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_mcp_audit_proxy.py +0 -0
  231. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_mcp_concealed_text.py +0 -0
  232. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_mcp_inventory.py +0 -0
  233. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_mcp_meta_fingerprint.py +0 -0
  234. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_mcp_schema.py +0 -0
  235. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_mitre_dlp.py +0 -0
  236. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_opensre_adapter.py +0 -0
  237. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_otel_ingest.py +0 -0
  238. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_powershell_encoding.py +0 -0
  239. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_readme_claims.py +0 -0
  240. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_redaction.py +0 -0
  241. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_replay.py +0 -0
  242. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_sigma_pack.py +0 -0
  243. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_static_serving.py +0 -0
  244. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_subagent_lifecycle.py +0 -0
  245. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_suite_leaves_real_home_alone.py +0 -0
  246. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_tool_policy.py +0 -0
  247. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_tool_policy_agent_cli.py +0 -0
  248. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_tool_policy_git_hooks.py +0 -0
  249. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_trail_anchor.py +0 -0
  250. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_trail_chain.py +0 -0
  251. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_trail_concurrency.py +0 -0
  252. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_trail_merkle.py +0 -0
  253. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_unattended_agent_policy.py +0 -0
  254. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_version.py +0 -0
  255. {agentmetry-0.9.1 → agentmetry-0.9.2}/tests/test_yaml_detection_rules.py +0 -0
  256. {agentmetry-0.9.1 → agentmetry-0.9.2}/tools/generate_sigma_pack.py +0 -0
  257. {agentmetry-0.9.1 → agentmetry-0.9.2}/tools/mcp_audit_proxy.py +0 -0
  258. {agentmetry-0.9.1 → agentmetry-0.9.2}/tools/vault_fs_server.py +0 -0
@@ -5,7 +5,10 @@
5
5
  # Demo MCP vault path (relative to this directory)
6
6
  AGENTMETRY_VAULT_PATH=../../vault
7
7
 
8
- AGENTMETRY_OPERATOR_ID=local
8
+ # Who ran the agent. Leave empty and every event records the OS account the
9
+ # agent ran as (DOMAIN\user on a domain or Entra Windows account). Set it to
10
+ # override: an email, or a pseudonym to keep account names out of a SIEM.
11
+ AGENTMETRY_OPERATOR_ID=
9
12
  AGENTMETRY_FLEET_ID=
10
13
  AGENTMETRY_API_KEY=
11
14
 
@@ -24,4 +27,9 @@ AGENTMETRY_AUDIT_SINK=file
24
27
  # host. See docs/anchoring.md.
25
28
  # AGENTMETRY_ANCHOR_LOG=../agentmetry-anchors/home-lab/agentmetry-trail.anchors.jsonl
26
29
 
30
+ # The configured MCP servers as an `mcp_inventory` event, for a fleet that wants
31
+ # them in the SIEM: names, launchers, package names and hosts, never arguments or
32
+ # env values. Off by default, because the heartbeat names no server.
33
+ # AGENTMETRY_MCP_INVENTORY=1
34
+
27
35
  # More tuning: see repo root .env.example and docs/agentmetry-dogfood-checklist.md
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentmetry
3
- Version: 0.9.1
3
+ Version: 0.9.2
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
@@ -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.6; extra == 'dev'
40
+ Requires-Dist: ruff==0.16.9; extra == 'dev'
41
41
  Description-Content-Type: text/markdown
42
42
 
43
43
  # Agentmetry
@@ -285,12 +285,9 @@ def cmd_detections(args: argparse.Namespace) -> int:
285
285
 
286
286
 
287
287
  def _triage_operator() -> str:
288
- configured = os.environ.get("AGENTMETRY_OPERATOR_ID", "").strip()
289
- if configured:
290
- return configured
291
- from agentmetry.core.operator_identity import os_operator
288
+ from agentmetry.core.operator_identity import os_operator, stated
292
289
 
293
- return os_operator()
290
+ return stated(os.environ.get("AGENTMETRY_OPERATOR_ID", "")) or os_operator()
294
291
 
295
292
 
296
293
  def cmd_disposition(args: argparse.Namespace) -> int:
@@ -297,11 +297,14 @@ async def heartbeat_forever(interval: float | None = None) -> None:
297
297
  """
298
298
  import asyncio
299
299
 
300
+ from agentmetry.core.audit.mcp_inventory_event import InventoryState, maybe_emit_inventory
301
+
300
302
  period = interval if interval is not None else interval_seconds()
301
303
  if not period:
302
304
  logger.info("Heartbeat disabled (AGENTMETRY_HEARTBEAT_SECONDS=0)")
303
305
  return
304
306
 
307
+ inventory_state = InventoryState()
305
308
  while True:
306
309
  try:
307
310
  event = await emit_heartbeat()
@@ -310,4 +313,10 @@ async def heartbeat_forever(interval: float | None = None) -> None:
310
313
  except Exception:
311
314
  # Never let attestation kill the recorder it attests for.
312
315
  logger.exception("Heartbeat failed")
316
+ try:
317
+ # Opt-in (#169). Separate from the beat, so the beat stays
318
+ # name-free, and in its own try, so it cannot stop the beat.
319
+ await maybe_emit_inventory(inventory_state)
320
+ except Exception:
321
+ logger.exception("MCP inventory event failed")
313
322
  await asyncio.sleep(period)
@@ -144,6 +144,7 @@ def _get_sink():
144
144
  webhook_url=settings.audit_webhook_url,
145
145
  webhook_timeout_seconds=settings.audit_webhook_timeout_seconds,
146
146
  webhook_format=settings.audit_webhook_format,
147
+ webhook_token=settings.audit_webhook_token,
147
148
  elastic_url=settings.audit_elastic_url,
148
149
  elastic_index=settings.audit_elastic_index,
149
150
  elastic_api_key=settings.audit_elastic_api_key,
@@ -0,0 +1,119 @@
1
+ """The configured MCP servers as an event a SIEM can see, when an operator asks (#169).
2
+
3
+ `agentmetry mcp` answers "what are the agents on this machine wired to", but
4
+ only for whoever runs it, on that machine. Nothing reached the trail, so no SIEM
5
+ could answer "which machines have this server" or "when did it first appear".
6
+
7
+ This is deliberately not the heartbeat. The heartbeat goes to every sink on
8
+ every install and commits to the MCP surface by digest alone, so that recording
9
+ your own laptop never ships your tool inventory to a SOC. That property stays.
10
+ The inventory is a separate event, off unless `AGENTMETRY_MCP_INVENTORY=1`, for
11
+ the fleet that wants it.
12
+
13
+ When on, it is emitted when the recorder starts, whenever the configured
14
+ surface changes, and once a day regardless, so a dashboard over the last 24
15
+ hours sees every host. Each server is reduced to `McpServer.wire_entry()`: no
16
+ arguments, no environment, no URL beyond its host.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import time
23
+ import uuid
24
+ from dataclasses import dataclass
25
+ from typing import Any
26
+
27
+ from agentmetry.core.audit.canonical import SCHEMA_VERSION
28
+ from agentmetry.core.audit.identity import identity_fields
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ #: Re-emit an unchanged inventory this often, so a fleet view over a day is whole.
33
+ SNAPSHOT_SECONDS = 24 * 3600
34
+
35
+ #: The event goes to every sink. A machine with more servers than this is
36
+ #: reported by count, with the list cut and `truncated` set.
37
+ LIMIT = 100
38
+
39
+
40
+ def enabled() -> bool:
41
+ from agentmetry.core.config import settings
42
+
43
+ return bool(getattr(settings, "mcp_inventory_enabled", False))
44
+
45
+
46
+ @dataclass
47
+ class InventoryState:
48
+ """What was last emitted, so an unchanged surface is not re-sent every beat."""
49
+
50
+ digest: str | None = None
51
+ emitted_at: float = 0.0
52
+
53
+
54
+ def build_inventory_event(now_utc: str, inventory: Any, *, outcome: str) -> dict[str, Any]:
55
+ servers = list(inventory.servers)
56
+ entries = [s.wire_entry() for s in servers[:LIMIT]]
57
+ flagged = sum(1 for e in entries if e["findings"])
58
+ return {
59
+ "schema_version": SCHEMA_VERSION,
60
+ "event_id": str(uuid.uuid4()),
61
+ "session_id": "",
62
+ "correlation_id": "",
63
+ "timestamp_utc": now_utc,
64
+ **identity_fields(),
65
+ "source_topic": "agentmetry/mcp_inventory",
66
+ "source": {"tier": "agentmetry", "app": "agentmetry", "adapter": "mcp_inventory"},
67
+ "initiator": {"actor_type": "system", "trigger": "scheduled", "operator_id": ""},
68
+ "actor": {"type": "system", "id": "agentmetry", "role": "recorder"},
69
+ "action": {
70
+ "type": "mcp_inventory",
71
+ "outcome": outcome,
72
+ "reason": f"{len(servers)} MCP server(s) configured, {flagged} flagged",
73
+ },
74
+ "agent": {"name": "agentmetry", "skill_id": ""},
75
+ "mcp_inventory": {
76
+ "servers": entries,
77
+ "server_count": len(servers),
78
+ "truncated": len(servers) > LIMIT,
79
+ "config_digest": inventory.digest()[:16] if servers else "",
80
+ "files_read": len(inventory.files_read),
81
+ "files_unreadable": len(inventory.unreadable),
82
+ },
83
+ }
84
+
85
+
86
+ def due(state: InventoryState, digest: str, now_monotonic: float) -> str | None:
87
+ """`snapshot`, `changed`, or None when there is nothing to say."""
88
+ if state.digest is None:
89
+ return "snapshot"
90
+ if digest != state.digest:
91
+ return "changed"
92
+ if now_monotonic - state.emitted_at >= SNAPSHOT_SECONDS:
93
+ return "snapshot"
94
+ return None
95
+
96
+
97
+ async def maybe_emit_inventory(state: InventoryState) -> dict[str, Any] | None:
98
+ """Emit the inventory if the operator turned it on and there is news."""
99
+ if not enabled():
100
+ return None
101
+ from datetime import datetime, timezone
102
+
103
+ from agentmetry.core.audit.ingest import _get_sink
104
+ from agentmetry.core.audit.trail_db import get_trail_db
105
+ from agentmetry.core.diagnostics.mcp_inventory import collect
106
+
107
+ inventory = collect()
108
+ digest = inventory.digest() if inventory.servers else ""
109
+ now = time.monotonic()
110
+ outcome = due(state, digest, now)
111
+ if outcome is None:
112
+ return None
113
+ event = build_inventory_event(datetime.now(timezone.utc).isoformat(), inventory, outcome=outcome)
114
+ # The heartbeat's durability contract: the local insert is the guarantee,
115
+ # network sinks swallow their own failures.
116
+ get_trail_db().insert(event)
117
+ await _get_sink().emit(event)
118
+ state.digest, state.emitted_at = digest, now
119
+ return event
@@ -49,7 +49,7 @@ def resolve_operator(client_id: object = "", client_source: object = "") -> tupl
49
49
  source = str(client_source or "")
50
50
  if claimed and source == oid.CONFIGURED:
51
51
  return claimed, oid.HOOK_CONFIGURED
52
- configured = settings.operator_id.strip()
52
+ configured = oid.stated(settings.operator_id)
53
53
  if configured:
54
54
  return configured, oid.ORCHESTRATOR_CONFIGURED
55
55
  if claimed:
@@ -50,11 +50,17 @@ class WebhookAuditSink(AuditSink):
50
50
  """
51
51
 
52
52
  def __init__(
53
- self, url: str, *, timeout_seconds: float = 5.0, format: str = "canonical"
53
+ self,
54
+ url: str,
55
+ *,
56
+ timeout_seconds: float = 5.0,
57
+ format: str = "canonical",
58
+ token: str = "",
54
59
  ) -> None:
55
60
  self._url = url
56
61
  self._timeout = timeout_seconds
57
62
  self._cloudevents = (format or "").strip().lower() in ("cloudevents", "cloudevent", "ce")
63
+ self._token = (token or "").strip()
58
64
 
59
65
  async def emit(self, canonical: dict[str, Any]) -> None:
60
66
  payload = canonical
@@ -66,12 +72,15 @@ class WebhookAuditSink(AuditSink):
66
72
 
67
73
  payload = canonical_to_cloudevent(canonical)
68
74
  content_type = "application/cloudevents+json; charset=utf-8"
75
+ headers = {"Content-Type": content_type, "User-Agent": "Agentmetry/1.0"}
76
+ if self._token:
77
+ headers["Authorization"] = f"Bearer {self._token}"
69
78
  try:
70
79
  async with httpx.AsyncClient(timeout=self._timeout) as client:
71
80
  response = await client.post(
72
81
  self._url,
73
82
  json=payload,
74
- headers={"Content-Type": content_type, "User-Agent": "Agentmetry/1.0"},
83
+ headers=headers,
75
84
  )
76
85
  response.raise_for_status()
77
86
  except Exception:
@@ -285,6 +294,7 @@ def build_audit_sinks(
285
294
  webhook_url: str,
286
295
  webhook_timeout_seconds: float,
287
296
  webhook_format: str = "canonical",
297
+ webhook_token: str = "",
288
298
  elastic_url: str,
289
299
  elastic_index: str,
290
300
  elastic_api_key: str,
@@ -311,6 +321,7 @@ def build_audit_sinks(
311
321
  webhook_url.strip(),
312
322
  timeout_seconds=webhook_timeout_seconds,
313
323
  format=webhook_format,
324
+ token=webhook_token,
314
325
  )
315
326
  )
316
327
 
@@ -58,6 +58,7 @@ async def audit_exporter(
58
58
  webhook_url=settings.audit_webhook_url,
59
59
  webhook_timeout_seconds=settings.audit_webhook_timeout_seconds,
60
60
  webhook_format=settings.audit_webhook_format,
61
+ webhook_token=settings.audit_webhook_token,
61
62
  elastic_url=settings.audit_elastic_url,
62
63
  elastic_index=settings.audit_elastic_index,
63
64
  elastic_api_key=settings.audit_elastic_api_key,
@@ -100,6 +100,13 @@ class Settings(BaseSettings):
100
100
  default="canonical",
101
101
  validation_alias=AliasChoices("AGENTMETRY_AUDIT_WEBHOOK_FORMAT"),
102
102
  )
103
+ # Optional bearer token on every webhook POST. A hosted ingest that binds
104
+ # tenant and host to the token needs this header; an existing webhook that
105
+ # never asked for auth keeps receiving unauthenticated requests, unchanged.
106
+ audit_webhook_token: str = Field(
107
+ default="",
108
+ validation_alias=AliasChoices("AGENTMETRY_AUDIT_WEBHOOK_TOKEN"),
109
+ )
103
110
  audit_elastic_url: str = Field(
104
111
  default="",
105
112
  validation_alias=AliasChoices(
@@ -200,6 +207,14 @@ class Settings(BaseSettings):
200
207
  _policy("detection", "manifest.yaml")
201
208
  )
202
209
 
210
+ # The configured MCP servers as an `mcp_inventory` event, for a fleet that
211
+ # wants them in the SIEM (#169). Off by default: the heartbeat names no
212
+ # server, and turning this on is the operator's decision, not ours.
213
+ mcp_inventory_enabled: bool = Field(
214
+ default=False,
215
+ validation_alias=AliasChoices("AGENTMETRY_MCP_INVENTORY"),
216
+ )
217
+
203
218
  # Demo MCP vault — doctor, drivers.json, vault_fs server (not a skill runtime).
204
219
  vault_path: Path = Path(__file__).resolve().parents[4] / "vault"
205
220
 
@@ -37,6 +37,7 @@ import re
37
37
  from dataclasses import dataclass, field
38
38
  from pathlib import Path
39
39
  from typing import Any, Iterable
40
+ from urllib.parse import urlparse
40
41
 
41
42
  #: Fetch-and-run launchers. These resolve a package over the network on every
42
43
  #: invocation, so the bytes executed are decided later than the config was read.
@@ -92,6 +93,48 @@ class McpServer:
92
93
  )
93
94
  return hashlib.sha256(material.encode()).hexdigest()
94
95
 
96
+ def finding_codes(self) -> list[str]:
97
+ """`findings()` as stable codes a SIEM can group on, carrying no values."""
98
+ codes: list[str] = []
99
+ if self.transport == "stdio":
100
+ if not self.command:
101
+ return ["no_command"]
102
+ launcher = Path(self.command).stem.lower()
103
+ spec = self.spec()
104
+ if _FETCHING_LAUNCHER.match(launcher) and spec and not _PINNED_SPEC.match(spec):
105
+ codes.append("unpinned_fetch")
106
+ if _AUTO_YES & set(self.args):
107
+ codes.append("auto_confirm")
108
+ elif self.url.startswith("http://"):
109
+ codes.append("plaintext_http")
110
+ return codes
111
+
112
+ def wire_entry(self) -> dict[str, Any]:
113
+ """What a heartbeat may carry about this server, off this machine (#169).
114
+
115
+ Enough to inventory a fleet from the SIEM: which agent, which server,
116
+ how it is launched, whether it is flagged. Nothing that tends to hold a
117
+ secret. Arguments carry tokens and home-directory paths, so they are
118
+ left out, and so are env values and env keys. A URL keeps only its host,
119
+ because query strings carry API keys. The package name is kept only for
120
+ a fetch-and-run launcher, where it is a public registry name and is the
121
+ thing worth knowing. The fingerprint commits to all of it, so a changed
122
+ argument still changes the entry without the argument being shown.
123
+ """
124
+ launcher = Path(self.command).stem.lower() if self.command else ""
125
+ fetching = bool(launcher and _FETCHING_LAUNCHER.match(launcher))
126
+ return {
127
+ "agent": self.agent,
128
+ "name": self.name,
129
+ "scope": self.scope,
130
+ "transport": self.transport,
131
+ "launcher": launcher,
132
+ "package": self.spec() if fetching else "",
133
+ "url_host": (urlparse(self.url).hostname or "") if self.url else "",
134
+ "findings": self.finding_codes(),
135
+ "fingerprint": self.fingerprint()[:16],
136
+ }
137
+
95
138
  def findings(self) -> list[str]:
96
139
  """Configuration facts worth an operator's attention."""
97
140
  out: list[str] = []
@@ -75,6 +75,13 @@ def windows_account(environ: Mapping[str, str]) -> str:
75
75
 
76
76
 
77
77
  def stated(value: object) -> str:
78
- """A client-supplied id, or empty when it said nothing or the legacy constant."""
78
+ """An id somebody actually stated, or empty.
79
+
80
+ Empty for nothing, and for `local`, whether a hook sent it or a `.env` says
81
+ it. Both `.env.example` files shipped `AGENTMETRY_OPERATOR_ID=local` up to
82
+ 0.9.1 and both installers copied it, so on an installed machine `local` in
83
+ the config is the old placeholder, not a choice. Read as configured, it
84
+ overrode the OS account and every event said `local` again.
85
+ """
79
86
  text = str(value or "").strip()
80
87
  return "" if text == LEGACY_PLACEHOLDER else text
@@ -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.9.1"
13
+ __version__ = "0.9.2"
@@ -61,10 +61,14 @@ except ImportError: # pragma: no cover - the package always ships it
61
61
  # Standard library only, so it costs the hook nothing. Guarded anyway: an
62
62
  # unresolvable operator must leave the event unattributed, not unrecorded.
63
63
  try:
64
- from agentmetry.core.operator_identity import os_operator
64
+ from agentmetry.core.operator_identity import os_operator, stated
65
65
  except ImportError: # pragma: no cover - the package always ships it
66
66
  os_operator = None
67
67
 
68
+ def stated(value: object) -> str:
69
+ text = str(value or "").strip()
70
+ return "" if text == "local" else text
71
+
68
72
  # Separate try: a DLP import failure (missing yaml/pydantic) must not also kill
69
73
  # trait/MITRE tagging, which only needs the stdlib.
70
74
  try:
@@ -547,9 +551,10 @@ def _operator() -> dict[str, str]:
547
551
  """
548
552
  global _OPERATOR
549
553
  if _OPERATOR is None:
550
- configured = (
551
- os.environ.get("AGENTMETRY_OPERATOR_ID", "").strip()
552
- or _read_repo_env("AGENTMETRY_OPERATOR_ID").strip()
554
+ # `local` is the placeholder .env.example used to ship, not a choice.
555
+ configured = stated(
556
+ os.environ.get("AGENTMETRY_OPERATOR_ID", "")
557
+ or _read_repo_env("AGENTMETRY_OPERATOR_ID")
553
558
  )
554
559
  if configured:
555
560
  _OPERATOR = {"id": configured, "source": "configured"}
@@ -52,7 +52,7 @@ dependencies = [
52
52
  # so a linter release could turn a green branch red with no change to this repo.
53
53
  # It did: 0.16.0 widened the default rule set and CI reported 132 errors against
54
54
  # code that passed locally on 0.15.x. Bump this deliberately, not by accident.
55
- dev = ["pytest>=8.0", "pytest-asyncio>=0.24", "pytest-cov>=6.0", "ruff==0.16.6"]
55
+ dev = ["pytest>=8.0", "pytest-asyncio>=0.24", "pytest-cov>=6.0", "ruff==0.16.9"]
56
56
 
57
57
  [project.urls]
58
58
  Homepage = "https://agentmetry.ai"
@@ -53,6 +53,71 @@ async def test_webhook_sink_posts_json():
53
53
  mock_client.post.assert_awaited_once()
54
54
 
55
55
 
56
+ @pytest.mark.asyncio
57
+ async def test_webhook_sink_sends_bearer_token_when_configured():
58
+ """A hosted ingest binds tenant and host to the token, so the header must
59
+ arrive on every POST — canonical and CloudEvents shapes alike."""
60
+ sink = WebhookAuditSink(
61
+ "http://collector.test/ingest", timeout_seconds=2.0, token="agm-test-token"
62
+ )
63
+ payload = {"action": {"type": "tool_called", "outcome": "success"}}
64
+
65
+ mock_response = AsyncMock()
66
+ mock_response.raise_for_status = lambda: None
67
+ mock_client = AsyncMock()
68
+ mock_client.post = AsyncMock(return_value=mock_response)
69
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
70
+ mock_client.__aexit__ = AsyncMock(return_value=None)
71
+
72
+ with patch("agentmetry.core.audit.sinks.httpx.AsyncClient", return_value=mock_client):
73
+ await sink.emit(payload)
74
+
75
+ headers = mock_client.post.await_args.kwargs["headers"]
76
+ assert headers["Authorization"] == "Bearer agm-test-token"
77
+
78
+
79
+ @pytest.mark.asyncio
80
+ async def test_webhook_sink_omits_authorization_without_token():
81
+ """Existing webhooks that never asked for auth must keep receiving
82
+ unauthenticated requests: no token configured, no header sent."""
83
+ sink = WebhookAuditSink("http://collector.test/ingest", timeout_seconds=2.0)
84
+ payload = {"action": {"type": "tool_called", "outcome": "success"}}
85
+
86
+ mock_response = AsyncMock()
87
+ mock_response.raise_for_status = lambda: None
88
+ mock_client = AsyncMock()
89
+ mock_client.post = AsyncMock(return_value=mock_response)
90
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
91
+ mock_client.__aexit__ = AsyncMock(return_value=None)
92
+
93
+ with patch("agentmetry.core.audit.sinks.httpx.AsyncClient", return_value=mock_client):
94
+ await sink.emit(payload)
95
+
96
+ assert "Authorization" not in mock_client.post.await_args.kwargs["headers"]
97
+
98
+
99
+ def test_build_audit_sinks_plumbs_webhook_token():
100
+ sink = build_audit_sinks(
101
+ modes={"webhook"},
102
+ file_path=Path("unused.jsonl"),
103
+ webhook_url="http://collector.test/ingest",
104
+ webhook_timeout_seconds=2.0,
105
+ webhook_format="canonical",
106
+ webhook_token="agm-plumbed",
107
+ elastic_url="",
108
+ elastic_index="",
109
+ elastic_api_key="",
110
+ elastic_verify_tls=True,
111
+ splunk_hec_url="",
112
+ splunk_hec_token="",
113
+ splunk_index="",
114
+ splunk_sourcetype="",
115
+ splunk_verify_tls=True,
116
+ )
117
+ assert isinstance(sink, WebhookAuditSink)
118
+ assert sink._token == "agm-plumbed"
119
+
120
+
56
121
  @pytest.mark.asyncio
57
122
  async def test_elastic_sink_posts_ecs_document():
58
123
  canonical = {