agentmetry 0.8.0__tar.gz → 0.9.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 (252) hide show
  1. {agentmetry-0.8.0 → agentmetry-0.9.0}/PKG-INFO +1 -1
  2. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/cli/__init__.py +49 -0
  3. agentmetry-0.9.0/agentmetry/core/audit/otel_ingest.py +526 -0
  4. agentmetry-0.9.0/agentmetry/core/audit/tool_policy/capability.py +42 -0
  5. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/tool_policy/evaluator.py +20 -12
  6. agentmetry-0.9.0/agentmetry/core/audit/tool_policy/loader.py +83 -0
  7. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/tool_policy/models.py +4 -1
  8. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/diagnostics/doctor.py +37 -0
  9. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/version.py +1 -1
  10. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/hooks/ingest.py +97 -9
  11. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/conftest.py +29 -0
  12. agentmetry-0.9.0/tests/fixtures/otel_claude_code_session.json +902 -0
  13. agentmetry-0.9.0/tests/test_ask_gate_phase_a.py +289 -0
  14. agentmetry-0.9.0/tests/test_hook_decision_format.py +198 -0
  15. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_hook_enforcement_timing.py +7 -2
  16. agentmetry-0.9.0/tests/test_otel_ingest.py +354 -0
  17. agentmetry-0.9.0/tests/test_suite_leaves_real_home_alone.py +40 -0
  18. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_tool_policy_agent_cli.py +7 -1
  19. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_tool_policy_git_hooks.py +7 -1
  20. agentmetry-0.8.0/agentmetry/core/audit/tool_policy/loader.py +0 -44
  21. {agentmetry-0.8.0 → agentmetry-0.9.0}/.dockerignore +0 -0
  22. {agentmetry-0.8.0 → agentmetry-0.9.0}/.env.agentmetry-demo +0 -0
  23. {agentmetry-0.8.0 → agentmetry-0.9.0}/.env.example +0 -0
  24. {agentmetry-0.8.0 → agentmetry-0.9.0}/.gitignore +0 -0
  25. {agentmetry-0.8.0 → agentmetry-0.9.0}/Dockerfile +0 -0
  26. {agentmetry-0.8.0 → agentmetry-0.9.0}/README.md +0 -0
  27. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/__init__.py +0 -0
  28. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/api/__init__.py +0 -0
  29. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/api/main.py +0 -0
  30. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/api/routes/__init__.py +0 -0
  31. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/api/routes/audit.py +0 -0
  32. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/api/websocket.py +0 -0
  33. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/api/ws_bridge.py +0 -0
  34. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/cli/__main__.py +0 -0
  35. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/__init__.py +0 -0
  36. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/__init__.py +0 -0
  37. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/adapters/__init__.py +0 -0
  38. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/adapters/agt.py +0 -0
  39. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/adapters/chronicle.py +0 -0
  40. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/adapters/cloudevents.py +0 -0
  41. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/adapters/ecs.py +0 -0
  42. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/adapters/splunk.py +0 -0
  43. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/alerts.py +0 -0
  44. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/atlas.py +0 -0
  45. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/canonical.py +0 -0
  46. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/compliance_digest.py +0 -0
  47. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/__init__.py +0 -0
  48. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/benchmark.py +0 -0
  49. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_approval_denied_then_executed.jsonl +0 -0
  50. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_arbitrary_host_stage_execute.jsonl +0 -0
  51. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_autonomous_unapproved_write.jsonl +0 -0
  52. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_credential_exfil.jsonl +0 -0
  53. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_credential_then_cloud_api.jsonl +0 -0
  54. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_destructive_delete_burst.jsonl +0 -0
  55. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_discovery_then_collect.jsonl +0 -0
  56. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_dotfile_then_git_push.jsonl +0 -0
  57. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_encoded_command_download.jsonl +0 -0
  58. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_env_credential_exfil.jsonl +0 -0
  59. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_hashed_only_no_command.jsonl +0 -0
  60. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_interpreter_egress.jsonl +0 -0
  61. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_pr_merged_without_review.jsonl +0 -0
  62. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_proc_substitution_cradle.jsonl +0 -0
  63. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_remote_pipe_to_shell.jsonl +0 -0
  64. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_remote_staging_then_execute.jsonl +0 -0
  65. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_secret_manager_then_egress.jsonl +0 -0
  66. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_session_tool_burst.jsonl +0 -0
  67. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_single_command_exfil.jsonl +0 -0
  68. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_single_quoted_interpreter_egress.jsonl +0 -0
  69. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_ssh_directory_exfil.jsonl +0 -0
  70. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_subagent_swarm.jsonl +0 -0
  71. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_timestamp_collision.jsonl +0 -0
  72. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/attack_untrusted_input_then_action.jsonl +0 -0
  73. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_authoring_merge_fixtures.jsonl +0 -0
  74. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_autonomous_after_approval.jsonl +0 -0
  75. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_build_artifact_cleanup.jsonl +0 -0
  76. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_ci_artifact_download.jsonl +0 -0
  77. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_commit_message_names_cloud_clis.jsonl +0 -0
  78. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_database_migration.jsonl +0 -0
  79. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_dependency_install_and_build.jsonl +0 -0
  80. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_download_release_archive.jsonl +0 -0
  81. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_env_example_read.jsonl +0 -0
  82. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_fetch_data_then_run_repo_script.jsonl +0 -0
  83. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_fetch_dataset_then_analyse.jsonl +0 -0
  84. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_fetch_lockfile_then_install.jsonl +0 -0
  85. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_fetch_piped_into_own_script.jsonl +0 -0
  86. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_git_review_and_push.jsonl +0 -0
  87. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_github_raw_docs_then_script.jsonl +0 -0
  88. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_human_driven_deletes.jsonl +0 -0
  89. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_local_api_probing.jsonl +0 -0
  90. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_long_but_calm_session.jsonl +0 -0
  91. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_loopback_is_not_egress.jsonl +0 -0
  92. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_loopback_pipe_to_interpreter.jsonl +0 -0
  93. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_mcp_text_mentions_credential_path.jsonl +0 -0
  94. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_ordinary_development.jsonl +0 -0
  95. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_package_manager_after_fetch.jsonl +0 -0
  96. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_reading_config_that_is_not_secret.jsonl +0 -0
  97. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_remote_api_call_no_credentials.jsonl +0 -0
  98. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_research_then_docs.jsonl +0 -0
  99. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_reversed_order_is_not_exfil.jsonl +0 -0
  100. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_secret_names_and_docs.jsonl +0 -0
  101. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_test_and_fix_loop.jsonl +0 -0
  102. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/benign_writing_about_credentials.jsonl +0 -0
  103. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/corpus/corpus.yaml +0 -0
  104. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/disposition.py +0 -0
  105. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/engine.py +0 -0
  106. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/live.py +0 -0
  107. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/live_store.py +0 -0
  108. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/models.py +0 -0
  109. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/rules.py +0 -0
  110. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/traits.py +0 -0
  111. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/yaml_config.py +0 -0
  112. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/detection/yaml_rules.py +0 -0
  113. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/dlp/__init__.py +0 -0
  114. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/dlp/loader.py +0 -0
  115. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/dlp/models.py +0 -0
  116. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/dlp/scanner.py +0 -0
  117. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/dogfood.py +0 -0
  118. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/evidence_pack.py +0 -0
  119. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/external.py +0 -0
  120. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/hashing.py +0 -0
  121. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/heartbeat.py +0 -0
  122. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/hook_bootstrap.py +0 -0
  123. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/identity.py +0 -0
  124. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/ingest.py +0 -0
  125. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/migrate.py +0 -0
  126. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/mitre.py +0 -0
  127. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/policy.py +0 -0
  128. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/redaction.py +0 -0
  129. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/replay.py +0 -0
  130. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/run_context.py +0 -0
  131. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/sinks.py +0 -0
  132. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/spool.py +0 -0
  133. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/tool_policy/__init__.py +0 -0
  134. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/trail_anchor.py +0 -0
  135. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/trail_chain.py +0 -0
  136. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/trail_db.py +0 -0
  137. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/audit/trail_merkle.py +0 -0
  138. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/auth.py +0 -0
  139. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/bus/__init__.py +0 -0
  140. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/bus/audit_exporter.py +0 -0
  141. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/bus/bridges.py +0 -0
  142. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/bus/bus.py +0 -0
  143. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/bus/events.py +0 -0
  144. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/bus/outbox.py +0 -0
  145. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/config.py +0 -0
  146. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/diagnostics/__init__.py +0 -0
  147. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/diagnostics/autostart.py +0 -0
  148. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/diagnostics/driver_paths.py +0 -0
  149. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/diagnostics/env_file.py +0 -0
  150. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/diagnostics/hook_coverage.py +0 -0
  151. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/diagnostics/mcp_inventory.py +0 -0
  152. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/diagnostics/mcp_schema.py +0 -0
  153. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/drivers/__init__.py +0 -0
  154. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/drivers/host.py +0 -0
  155. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/drivers/permissions.py +0 -0
  156. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/drivers/spec.py +0 -0
  157. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/extensions.py +0 -0
  158. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/core/health.py +0 -0
  159. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/hooks/__init__.py +0 -0
  160. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/policies/detection/manifest.yaml +0 -0
  161. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/policies/dlp/manifest.yaml +0 -0
  162. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/policies/opa/agent_rules.rego +0 -0
  163. {agentmetry-0.8.0 → agentmetry-0.9.0}/agentmetry/policies/tool/manifest.yaml +0 -0
  164. {agentmetry-0.8.0 → agentmetry-0.9.0}/pyproject.toml +0 -0
  165. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/fixtures/agt_filesink_exfil.jsonl +0 -0
  166. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/fixtures/agt_filesink_mixed.jsonl +0 -0
  167. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/fixtures/fake_mcp_server.py +0 -0
  168. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_agentmetry_audit.py +0 -0
  169. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_agentmetry_ingest_client.py +0 -0
  170. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_agt_adapter.py +0 -0
  171. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_atlas_detection_enrichment.py +0 -0
  172. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_atlas_mapping.py +0 -0
  173. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_audit_sinks.py +0 -0
  174. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_audit_stats.py +0 -0
  175. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_audit_tail.py +0 -0
  176. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_auth.py +0 -0
  177. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_autostart.py +0 -0
  178. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_boot_sequence.py +0 -0
  179. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_burst_windows.py +0 -0
  180. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_chinese_agent_hooks.py +0 -0
  181. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_chinese_agent_sprint_b.py +0 -0
  182. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_chinese_agent_sprint_c.py +0 -0
  183. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_chronicle_adapter.py +0 -0
  184. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_cli_backup.py +0 -0
  185. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_cli_commands.py +0 -0
  186. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_cli_env_var_names.py +0 -0
  187. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_cloudevents_adapter.py +0 -0
  188. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_compliance_digest.py +0 -0
  189. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_crewai_adapter.py +0 -0
  190. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_detection_adi.py +0 -0
  191. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_detection_benchmark.py +0 -0
  192. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_detection_default_config.py +0 -0
  193. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_detection_disposition.py +0 -0
  194. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_detection_disposition_api.py +0 -0
  195. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_detection_duplicate_emission.py +0 -0
  196. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_detection_emit_durability.py +0 -0
  197. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_detection_engine.py +0 -0
  198. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_detection_evasion.py +0 -0
  199. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_detection_hf_incident.py +0 -0
  200. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_detection_quoted_command_words.py +0 -0
  201. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_detection_rule_identity.py +0 -0
  202. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_detection_rules_v2.py +0 -0
  203. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_dlp_agent_env_override.py +0 -0
  204. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_dlp_invisible_unicode.py +0 -0
  205. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_dlp_markdown_exfil.py +0 -0
  206. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_dlp_scanner.py +0 -0
  207. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_doctor.py +0 -0
  208. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_dogfood.py +0 -0
  209. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_dogfood_freeze.py +0 -0
  210. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_driver_paths.py +0 -0
  211. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_ecs_threat.py +0 -0
  212. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_event_bus.py +0 -0
  213. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_evidence_pack.py +0 -0
  214. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_extensions.py +0 -0
  215. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_external_ingest.py +0 -0
  216. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_forwarding_shape.py +0 -0
  217. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_funding_manifest.py +0 -0
  218. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_heartbeat.py +0 -0
  219. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_hook_bootstrap.py +0 -0
  220. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_hook_coverage.py +0 -0
  221. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_hook_spool.py +0 -0
  222. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_identity_enrich.py +0 -0
  223. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_ingest_roundtrip.py +0 -0
  224. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_isolation.py +0 -0
  225. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_launch_targets.py +0 -0
  226. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_live_detection.py +0 -0
  227. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_live_detection_store.py +0 -0
  228. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_mcp_audit_proxy.py +0 -0
  229. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_mcp_concealed_text.py +0 -0
  230. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_mcp_inventory.py +0 -0
  231. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_mcp_meta_fingerprint.py +0 -0
  232. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_mcp_schema.py +0 -0
  233. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_mitre_dlp.py +0 -0
  234. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_opensre_adapter.py +0 -0
  235. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_powershell_encoding.py +0 -0
  236. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_readme_claims.py +0 -0
  237. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_redaction.py +0 -0
  238. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_replay.py +0 -0
  239. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_sigma_pack.py +0 -0
  240. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_static_serving.py +0 -0
  241. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_subagent_lifecycle.py +0 -0
  242. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_tool_policy.py +0 -0
  243. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_trail_anchor.py +0 -0
  244. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_trail_chain.py +0 -0
  245. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_trail_concurrency.py +0 -0
  246. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_trail_merkle.py +0 -0
  247. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_unattended_agent_policy.py +0 -0
  248. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_version.py +0 -0
  249. {agentmetry-0.8.0 → agentmetry-0.9.0}/tests/test_yaml_detection_rules.py +0 -0
  250. {agentmetry-0.8.0 → agentmetry-0.9.0}/tools/generate_sigma_pack.py +0 -0
  251. {agentmetry-0.8.0 → agentmetry-0.9.0}/tools/mcp_audit_proxy.py +0 -0
  252. {agentmetry-0.8.0 → agentmetry-0.9.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.8.0
3
+ Version: 0.9.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
@@ -896,6 +896,35 @@ def cmd_mcp(args: argparse.Namespace) -> int:
896
896
  return 0
897
897
 
898
898
 
899
+ def cmd_otel(args: argparse.Namespace) -> int:
900
+ """Receive Claude Code's native OpenTelemetry stream and record it.
901
+
902
+ The other way in for Claude Code, with no hooks installed. It sees what ran,
903
+ after it ran, so it records and detects but enforces nothing.
904
+ """
905
+ from agentmetry.core.audit import otel_ingest
906
+
907
+ port = args.listen_port or otel_ingest.default_port()
908
+ if args.print_mapping:
909
+ for line in otel_ingest.mapping_lines():
910
+ print(line)
911
+ return 0
912
+ if args.print_env:
913
+ for line in otel_ingest.claude_env_lines(port):
914
+ print(line)
915
+ return 0
916
+
917
+ # The receiver forwards through the hook client, which reads its target and
918
+ # its command-text setting from the environment. Setting them here, in this
919
+ # process only, is what makes --port and --keep-command mean the same thing
920
+ # they mean everywhere else.
921
+ orchestrator = _api_base_url(args.port)
922
+ os.environ["AGENTMETRY_URL"] = orchestrator
923
+ if args.keep_command:
924
+ os.environ["AGENTMETRY_LOG_COMMANDS"] = "1"
925
+ return otel_ingest.serve(port, orchestrator)
926
+
927
+
899
928
  def cmd_anchor(args: argparse.Namespace) -> int:
900
929
  """Publish, list, or check the checkpoints that commit the trail externally.
901
930
 
@@ -1308,6 +1337,25 @@ def main(argv: list[str] | None = None) -> int:
1308
1337
  "--digest", action="store_true",
1309
1338
  help="also print the config digest, which names no server and is safe to publish",
1310
1339
  )
1340
+ otel = sub.add_parser(
1341
+ "otel", help="receive Claude Code's native OpenTelemetry stream (no hooks needed)"
1342
+ )
1343
+ otel.add_argument(
1344
+ "--listen-port", type=int, default=None,
1345
+ help="port on 127.0.0.1 to receive OTLP on (default AGENTMETRY_OTEL_PORT or 4318)",
1346
+ )
1347
+ otel.add_argument(
1348
+ "--keep-command", action="store_true",
1349
+ help="keep scrubbed command text, as AGENTMETRY_LOG_COMMANDS=1 does for hooks",
1350
+ )
1351
+ otel.add_argument(
1352
+ "--print-mapping", action="store_true",
1353
+ help="list which events are mapped, counted, or unmapped, and exit",
1354
+ )
1355
+ otel.add_argument(
1356
+ "--print-env", action="store_true",
1357
+ help="print the environment Claude Code needs to send here, and exit",
1358
+ )
1311
1359
  verify = sub.add_parser("verify", help="verify evidence pack or JSONL trail chain")
1312
1360
  verify.add_argument(
1313
1361
  "path",
@@ -1374,6 +1422,7 @@ def main(argv: list[str] | None = None) -> int:
1374
1422
  "prove": cmd_prove,
1375
1423
  "anchor": cmd_anchor,
1376
1424
  "mcp": cmd_mcp,
1425
+ "otel": cmd_otel,
1377
1426
  "import-agt": cmd_import_agt,
1378
1427
  "doctor": cmd_doctor,
1379
1428
  "benchmark": cmd_benchmark,
@@ -0,0 +1,526 @@
1
+ """Claude Code's native OpenTelemetry stream, as Agentmetry events.
2
+
3
+ Claude Code exports its own telemetry over OTLP, and its documentation leaves
4
+ anomaly detection, correlation and alerting to the backend. This module is that
5
+ backend's front door: a loopback OTLP/HTTP JSON receiver that maps the
6
+ ``claude_code.*`` log events it can onto the external-ingest body and sends
7
+ them through the hook client's own ``post_ingest``, so they get the same
8
+ argument hashing, trait labels, MITRE and ATLAS enrichment, and the same spool
9
+ when the orchestrator is down.
10
+
11
+ Every event that is not forwarded is counted under a name, never dropped
12
+ silently. The unmapped counts are the input to canonical schema v1.3.
13
+
14
+ What this path cannot see, stated where the code is: OTel reports what happened,
15
+ after it happened. There is no pre-execution decision here, so no policy can be
16
+ enforced from it. Hooks remain the path for deny and ask.
17
+
18
+ Claude Code side (code.claude.com/docs/en/monitoring-usage)::
19
+
20
+ CLAUDE_CODE_ENABLE_TELEMETRY=1
21
+ OTEL_LOGS_EXPORTER=otlp
22
+ OTEL_EXPORTER_OTLP_PROTOCOL=http/json
23
+ OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318
24
+ OTEL_LOG_TOOL_DETAILS=1 # without it, no tool arguments: hash and traits are empty
25
+ OTEL_METRICS_EXPORTER=none # metrics are aggregates, acknowledged and not mapped
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import gzip
31
+ import json
32
+ import os
33
+ import sys
34
+ import threading
35
+ from collections.abc import Callable, Iterator
36
+ from datetime import datetime, timezone
37
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
38
+ from typing import Any
39
+
40
+ from agentmetry.hooks.ingest import _hash_tool_args, post_ingest, redact_arguments
41
+
42
+ #: The receiver binds here and nowhere else. The stream carries tool arguments
43
+ #: when OTEL_LOG_TOOL_DETAILS is on, and nothing about it needs another host.
44
+ LISTEN_HOST = "127.0.0.1"
45
+ DEFAULT_PORT = 4318
46
+
47
+ #: Documented event types with no canonical home yet: the schema v1.3 input.
48
+ #: `user_prompt` and `assistant_response` would never be forwarded regardless.
49
+ #: They are the model's words, and the recorder's default is structure, not
50
+ #: content.
51
+ KNOWN_UNMAPPED = frozenset({
52
+ "user_prompt",
53
+ "assistant_response",
54
+ "api_request",
55
+ "api_error",
56
+ "api_refusal",
57
+ "api_request_body",
58
+ "api_response_body",
59
+ "api_retries_exhausted",
60
+ "internal_error",
61
+ "permission_mode_changed",
62
+ "auth",
63
+ "plugin_installed",
64
+ "plugin_loaded",
65
+ "skill_activated",
66
+ "at_mention",
67
+ "hook_registered",
68
+ "hook_execution_start",
69
+ "hook_execution_complete",
70
+ "hook_plugin_metrics",
71
+ "compaction",
72
+ "subagent_completed",
73
+ "feedback_survey",
74
+ "retention_sweep",
75
+ })
76
+
77
+ #: `tool_decision.source` values that mean a person answered a prompt.
78
+ _HUMAN_ACCEPT = frozenset({"user_temporary", "user_permanent"})
79
+ _HUMAN_REJECT = frozenset({"user_reject", "user_abort"})
80
+
81
+ Forward = Callable[[dict[str, Any]], bool]
82
+
83
+
84
+ # --------------------------------------------------------------------- decoding
85
+
86
+
87
+ def otlp_value(value: Any) -> Any:
88
+ """Decode one OTLP JSON ``AnyValue`` into a plain Python value."""
89
+ if not isinstance(value, dict):
90
+ return value
91
+ for key, cast in (
92
+ ("stringValue", str),
93
+ ("boolValue", bool),
94
+ ("intValue", int),
95
+ ("doubleValue", float),
96
+ ):
97
+ if key in value:
98
+ try:
99
+ return cast(value[key])
100
+ except (TypeError, ValueError):
101
+ return value[key]
102
+ if "arrayValue" in value:
103
+ return [otlp_value(v) for v in (value["arrayValue"] or {}).get("values", [])]
104
+ if "kvlistValue" in value:
105
+ return otlp_attributes((value["kvlistValue"] or {}).get("values", []))
106
+ if "bytesValue" in value:
107
+ return repr(value["bytesValue"])
108
+ return value
109
+
110
+
111
+ def otlp_attributes(items: Any) -> dict[str, Any]:
112
+ out: dict[str, Any] = {}
113
+ for item in items or []:
114
+ if isinstance(item, dict) and item.get("key"):
115
+ out[item["key"]] = otlp_value(item.get("value", {}))
116
+ return out
117
+
118
+
119
+ def _merge_body(record: dict[str, Any], attrs: dict[str, Any]) -> None:
120
+ """Let a JSON body backfill attributes. An attribute wins on conflict.
121
+
122
+ Claude Code documents its events by attributes; some exporter paths put the
123
+ same payload in the body instead.
124
+ """
125
+ body = record.get("body")
126
+ text = body.get("stringValue") if isinstance(body, dict) else None
127
+ if isinstance(text, str) and text.startswith("{"):
128
+ try:
129
+ parsed = json.loads(text)
130
+ except ValueError:
131
+ return
132
+ if isinstance(parsed, dict):
133
+ for key, val in parsed.items():
134
+ attrs.setdefault(key, val)
135
+
136
+
137
+ def _timestamp(record: dict[str, Any], attrs: dict[str, Any]) -> str:
138
+ """When it happened, by the exporter's clock, never ours if it said."""
139
+ for ns in (record.get("timeUnixNano"), record.get("observedTimeUnixNano")):
140
+ try:
141
+ if ns and int(ns) > 0:
142
+ return datetime.fromtimestamp(int(ns) / 1e9, tz=timezone.utc).isoformat()
143
+ except (TypeError, ValueError, OSError, OverflowError):
144
+ continue
145
+ stamped = attrs.get("event.timestamp")
146
+ if isinstance(stamped, str) and stamped:
147
+ return stamped
148
+ return datetime.now(timezone.utc).isoformat()
149
+
150
+
151
+ def _flag(value: Any, default: bool = True) -> bool:
152
+ """Claude Code sends `success` as the string "true" or "false".
153
+
154
+ `bool("false")` is True, so reading it as a truthy value recorded every
155
+ failed tool call as a success.
156
+ """
157
+ if value is None:
158
+ return default
159
+ if isinstance(value, bool):
160
+ return value
161
+ if isinstance(value, (int, float)):
162
+ return value != 0
163
+ text = str(value).strip().lower()
164
+ if text in ("false", "0", "no", ""):
165
+ return False
166
+ return True
167
+
168
+
169
+ def _json_object(value: Any) -> dict[str, Any] | None:
170
+ """`tool_input` and `tool_parameters` arrive as JSON strings, not objects."""
171
+ if isinstance(value, dict):
172
+ return value
173
+ if isinstance(value, str) and value.lstrip().startswith("{"):
174
+ try:
175
+ parsed = json.loads(value)
176
+ except ValueError:
177
+ return None
178
+ return parsed if isinstance(parsed, dict) else None
179
+ return None
180
+
181
+
182
+ def tool_arguments(attrs: dict[str, Any]) -> dict[str, Any] | None:
183
+ """The tool's arguments, as close to the hook's `tool_input` as OTel gets.
184
+
185
+ `tool_input` is the serialized arguments themselves, so it is preferred: it
186
+ is what the hook hashes, and its `command` key is what the traits read.
187
+ Claude Code truncates its values at 512 characters, so a long argument
188
+ hashes differently here than on the hook path.
189
+
190
+ `tool_parameters` is a summary, not the arguments. For Bash it carries
191
+ `full_command`, which is enough for traits and MITRE, so that alone is used.
192
+ For an MCP tool it carries only the server and tool names, and a hash of
193
+ those would claim to be a hash of arguments it never saw, so it is not used.
194
+
195
+ None when OTEL_LOG_TOOL_DETAILS is off: no argument detail is exported.
196
+ """
197
+ args = _json_object(attrs.get("tool_input"))
198
+ if args is not None:
199
+ return args
200
+ params = _json_object(attrs.get("tool_parameters"))
201
+ if params and params.get("full_command"):
202
+ return {"command": str(params["full_command"])}
203
+ return None
204
+
205
+
206
+ # ---------------------------------------------------------------------- mapping
207
+
208
+
209
+ def _base(attrs: dict[str, Any], ts_iso: str) -> dict[str, Any]:
210
+ # The hook path uses Claude Code's session id for both fields, and OTel's
211
+ # `session.id` is the same value, so one session correlates the same way
212
+ # whichever path recorded it.
213
+ session = str(attrs.get("session.id") or attrs.get("session_id") or "")
214
+ return {
215
+ "source_app": "claude",
216
+ "adapter": "otel",
217
+ "correlation_id": session,
218
+ "session_id": session,
219
+ "timestamp_utc": ts_iso,
220
+ "triggered_by": "otel_receiver",
221
+ }
222
+
223
+
224
+ def _map_tool_result(attrs: dict[str, Any], ts_iso: str) -> dict[str, Any]:
225
+ tool_name = str(attrs.get("tool_name") or "unknown")
226
+ succeeded = _flag(attrs.get("success"))
227
+ args = tool_arguments(attrs)
228
+
229
+ tool: dict[str, Any] = {"qualified": tool_name, "server": "claude"}
230
+ if args is not None:
231
+ tool["arguments"] = redact_arguments(args)
232
+
233
+ reason = ""
234
+ if not succeeded:
235
+ reason = str(attrs.get("error_type") or "tool_failed")
236
+ elif tool_name == "Task" and args is not None:
237
+ # The hook path's subagent marker, so subagent-swarm-burst counts a
238
+ # Task spawn the same way whichever path recorded it.
239
+ reason = f"subagent_start:{args.get('subagent_type') or 'subagent'}"
240
+
241
+ payload = _base(attrs, ts_iso)
242
+ payload.update({
243
+ "event_type": "tool_called" if succeeded else "tool_failed",
244
+ "outcome": "success" if succeeded else "error",
245
+ "reason": reason,
246
+ "tool_qualified": tool_name,
247
+ "model_id": str(attrs.get("model") or attrs.get("gen_ai.request.model") or ""),
248
+ "tool": tool,
249
+ })
250
+ # The hook client's own enrichment: hash the arguments, label traits, map
251
+ # MITRE and ATLAS while the plaintext is visible, keep command text only
252
+ # when AGENTMETRY_LOG_COMMANDS says to. Identical code, so identical output.
253
+ return _hash_tool_args(payload) or payload
254
+
255
+
256
+ def _map_tool_decision(attrs: dict[str, Any], ts_iso: str) -> tuple[dict[str, Any] | None, str]:
257
+ """Forward a human's yes. Count everything else, and say why.
258
+
259
+ Detection reads `approval_response` as a person's decision: `success` resets
260
+ the unapproved-write gate, and a `denied` arms approval-denied-then-executed.
261
+
262
+ * A `config` or `hook` decision was made by a settings rule or a hook, not a
263
+ person, so recording it as an approval would be false.
264
+ * A human reject is real, but cannot be forwarded yet. That rule binds a
265
+ denial to a later call by argument hash or command, and `tool_decision`
266
+ carries neither, so it would fall back to the tool name and convict every
267
+ later call of the same tool. That is the false positive 0.7.0 removed.
268
+ Binding by `tool_use_id` is a rule change, and the rules are frozen.
269
+ """
270
+ decision = str(attrs.get("decision") or "").lower()
271
+ source = str(attrs.get("source") or "").lower()
272
+ if decision == "accept" and source in _HUMAN_ACCEPT:
273
+ payload = _base(attrs, ts_iso)
274
+ payload.update({
275
+ "event_type": "approval_response",
276
+ "outcome": "success",
277
+ "reason": f"otel_decision:{source}",
278
+ "tool_qualified": str(attrs.get("tool_name") or ""),
279
+ "initiator": {"actor_type": "human", "trigger": "manual", "operator_id": "local"},
280
+ })
281
+ return payload, ""
282
+ if decision == "reject" and source in _HUMAN_REJECT:
283
+ return None, "tool_decision:human_reject"
284
+ return None, f"tool_decision:{source or 'unknown'}"
285
+
286
+
287
+ def map_record(event_name: str, attrs: dict[str, Any], ts_iso: str) -> tuple[dict[str, Any] | None, str]:
288
+ """One `claude_code.*` event as an ingest body, or None and the name it is counted under."""
289
+ if event_name == "tool_result":
290
+ return _map_tool_result(attrs, ts_iso), ""
291
+ if event_name == "tool_decision":
292
+ return _map_tool_decision(attrs, ts_iso)
293
+ if event_name == "mcp_server_connection":
294
+ # Claude Code documents no fingerprint on this event, so in practice it
295
+ # is counted. The mapping stays for an exporter that does carry one.
296
+ fingerprint = attrs.get("server_fingerprint")
297
+ if not fingerprint:
298
+ return None, event_name
299
+ payload = _base(attrs, ts_iso)
300
+ payload.update({
301
+ "event_type": "mcp_schema",
302
+ "schema_fingerprint": str(fingerprint),
303
+ "reason": str(attrs.get("server_name") or ""),
304
+ })
305
+ return payload, ""
306
+ return None, event_name
307
+
308
+
309
+ def iter_records(export: dict[str, Any]) -> Iterator[tuple[str, dict[str, Any], str]]:
310
+ """(event name, attributes, timestamp) for every log record in an OTLP export."""
311
+ for res_log in export.get("resourceLogs", []) or []:
312
+ for scope in (res_log or {}).get("scopeLogs", []) or []:
313
+ for record in (scope or {}).get("logRecords", []) or []:
314
+ if not isinstance(record, dict):
315
+ continue
316
+ attrs = otlp_attributes(record.get("attributes"))
317
+ _merge_body(record, attrs)
318
+ name = str(attrs.get("event.name") or record.get("eventName") or "")
319
+ name = name.removeprefix("claude_code.")
320
+ if name:
321
+ yield name, attrs, _timestamp(record, attrs)
322
+
323
+
324
+ # ------------------------------------------------------------------------ stats
325
+
326
+
327
+ class ReceiverStats:
328
+ """What came in, what went out, and what was held back, by name."""
329
+
330
+ def __init__(self) -> None:
331
+ self._lock = threading.Lock()
332
+ self.received: dict[str, int] = {}
333
+ self.unmapped: dict[str, int] = {}
334
+ self.unknown: dict[str, int] = {}
335
+ self.forwarded = 0
336
+ self.spooled = 0
337
+ self.metrics_acked = 0
338
+
339
+ def _bump(self, table: dict[str, int], key: str) -> int:
340
+ with self._lock:
341
+ table[key] = table.get(key, 0) + 1
342
+ return table[key]
343
+
344
+ def note_received(self, name: str) -> None:
345
+ self._bump(self.received, name)
346
+
347
+ def note_unmapped(self, name: str) -> bool:
348
+ """Count it. True the first time an undocumented name is seen."""
349
+ base = name.split(":", 1)[0]
350
+ if base in KNOWN_UNMAPPED or base in ("tool_decision", "mcp_server_connection"):
351
+ self._bump(self.unmapped, name)
352
+ return False
353
+ return self._bump(self.unknown, name) == 1
354
+
355
+ def note_forward(self, ok: bool) -> None:
356
+ with self._lock:
357
+ if ok:
358
+ self.forwarded += 1
359
+ else:
360
+ self.spooled += 1
361
+
362
+ def note_metrics(self) -> None:
363
+ with self._lock:
364
+ self.metrics_acked += 1
365
+
366
+ def snapshot(self) -> dict[str, Any]:
367
+ with self._lock:
368
+ return {
369
+ "forwarded": self.forwarded,
370
+ # post_ingest spools what it cannot deliver; the orchestrator
371
+ # drains the spool at boot.
372
+ "not_delivered_spooled": self.spooled,
373
+ "received_by_event": dict(self.received),
374
+ "unmapped_by_event": dict(self.unmapped),
375
+ "undocumented_by_event": dict(self.unknown),
376
+ "metrics_exports_acked": self.metrics_acked,
377
+ }
378
+
379
+
380
+ def translate(export: dict[str, Any], stats: ReceiverStats) -> list[dict[str, Any]]:
381
+ """Map one OTLP logs export, counting every record under a name."""
382
+ out: list[dict[str, Any]] = []
383
+ for name, attrs, ts_iso in iter_records(export):
384
+ stats.note_received(name)
385
+ payload, counted_as = map_record(name, attrs, ts_iso)
386
+ if payload is not None:
387
+ out.append(payload)
388
+ elif stats.note_unmapped(counted_as):
389
+ _log(f"undocumented event {counted_as!r}: counted, not forwarded")
390
+ return out
391
+
392
+
393
+ def _log(msg: str) -> None:
394
+ print(f"[otel {datetime.now().strftime('%H:%M:%S')}] {msg}", file=sys.stderr, flush=True)
395
+
396
+
397
+ # ----------------------------------------------------------------------- server
398
+
399
+
400
+ def _quiet_post(payload: dict[str, Any]) -> bool:
401
+ return post_ingest(payload, quiet=True)
402
+
403
+
404
+ class _Handler(BaseHTTPRequestHandler):
405
+ server_version = "agentmetry-otel/1"
406
+ # OTLP exporters keep the connection alive; HTTP/1.0 semantics stall them.
407
+ protocol_version = "HTTP/1.1"
408
+
409
+ def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
410
+ stats: ReceiverStats = self.server.stats # type: ignore[attr-defined]
411
+ forward: Forward = self.server.forward # type: ignore[attr-defined]
412
+ length = int(self.headers.get("Content-Length") or 0)
413
+ raw = self.rfile.read(length) if length else b""
414
+
415
+ if self.path.startswith("/v1/metrics"):
416
+ # Aggregates, not session events. Acknowledge so the exporter does
417
+ # not retry forever.
418
+ stats.note_metrics()
419
+ self._reply(200)
420
+ return
421
+ if not self.path.startswith("/v1/logs"):
422
+ self._reply(404)
423
+ return
424
+ if "protobuf" in (self.headers.get("Content-Type") or "").lower():
425
+ if not getattr(self.server, "warned_protobuf", False):
426
+ self.server.warned_protobuf = True # type: ignore[attr-defined]
427
+ _log("received protobuf; set OTEL_EXPORTER_OTLP_PROTOCOL=http/json")
428
+ self._reply(415)
429
+ return
430
+ if (self.headers.get("Content-Encoding") or "").lower() == "gzip":
431
+ try:
432
+ raw = gzip.decompress(raw)
433
+ except OSError:
434
+ self._reply(400)
435
+ return
436
+ try:
437
+ export = json.loads(raw or b"{}")
438
+ except ValueError:
439
+ self._reply(400)
440
+ return
441
+ if not isinstance(export, dict):
442
+ self._reply(400)
443
+ return
444
+
445
+ for payload in translate(export, stats):
446
+ stats.note_forward(forward(payload))
447
+ self._reply(200, b"{}")
448
+
449
+ def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
450
+ if self.path in ("/healthz", "/"):
451
+ stats: ReceiverStats = self.server.stats # type: ignore[attr-defined]
452
+ self._reply(200, json.dumps(stats.snapshot()).encode())
453
+ return
454
+ self._reply(404)
455
+
456
+ def _reply(self, code: int, body: bytes = b"") -> None:
457
+ # Headers end before the body, or HTTP/1.1 clients wait for a separator.
458
+ self.send_response(code)
459
+ self.send_header("Content-Type", "application/json")
460
+ self.send_header("Content-Length", str(len(body)))
461
+ self.end_headers()
462
+ if body:
463
+ self.wfile.write(body)
464
+
465
+ def log_message(self, format: str, *args: Any) -> None: # noqa: A002
466
+ return
467
+
468
+
469
+ def make_server(port: int = DEFAULT_PORT, *, forward: Forward | None = None,
470
+ stats: ReceiverStats | None = None) -> ThreadingHTTPServer:
471
+ """A receiver bound to loopback. Port 0 picks a free one (tests)."""
472
+ server = ThreadingHTTPServer((LISTEN_HOST, port), _Handler)
473
+ server.stats = stats or ReceiverStats() # type: ignore[attr-defined]
474
+ server.forward = forward or _quiet_post # type: ignore[attr-defined]
475
+ return server
476
+
477
+
478
+ def default_port() -> int:
479
+ try:
480
+ return int(os.environ.get("AGENTMETRY_OTEL_PORT", "") or DEFAULT_PORT)
481
+ except ValueError:
482
+ return DEFAULT_PORT
483
+
484
+
485
+ def claude_env_lines(port: int) -> list[str]:
486
+ return [
487
+ "CLAUDE_CODE_ENABLE_TELEMETRY=1",
488
+ "OTEL_LOGS_EXPORTER=otlp",
489
+ "OTEL_EXPORTER_OTLP_PROTOCOL=http/json",
490
+ f"OTEL_EXPORTER_OTLP_ENDPOINT=http://{LISTEN_HOST}:{port}",
491
+ "OTEL_LOG_TOOL_DETAILS=1",
492
+ "OTEL_METRICS_EXPORTER=none",
493
+ ]
494
+
495
+
496
+ def mapping_lines() -> list[str]:
497
+ lines = [
498
+ "mapped: tool_result -> tool_called / tool_failed (hash, traits, MITRE, ATLAS)",
499
+ "mapped: tool_decision accepted by a person -> approval_response success",
500
+ "counted: tool_decision rejected by a person (a denial needs a binding the event lacks)",
501
+ "counted: tool_decision by config or hook (not a human decision)",
502
+ "partial: mcp_server_connection -> mcp_schema, only with a fingerprint attribute",
503
+ ]
504
+ lines += [f"unmapped: {name}" for name in sorted(KNOWN_UNMAPPED)]
505
+ return lines
506
+
507
+
508
+ def serve(port: int, orchestrator: str) -> int:
509
+ """Run in the foreground until interrupted, then print the counts."""
510
+ try:
511
+ server = make_server(port)
512
+ except OSError as exc:
513
+ print(f"Cannot listen on {LISTEN_HOST}:{port}: {exc}", file=sys.stderr)
514
+ return 1
515
+ _log(f"OTLP receiver on http://{LISTEN_HOST}:{port} -> {orchestrator}")
516
+ _log("Start Claude Code with:")
517
+ for line in claude_env_lines(port):
518
+ _log(f" {line}")
519
+ try:
520
+ server.serve_forever()
521
+ except KeyboardInterrupt:
522
+ pass
523
+ finally:
524
+ server.server_close()
525
+ _log(json.dumps(server.stats.snapshot())) # type: ignore[attr-defined]
526
+ return 0
@@ -0,0 +1,42 @@
1
+ """Where a policy `ask` reaches a human as a prompt, per agent and hook.
2
+
3
+ An ask the agent ignores is an allow. Every vendor that does not recognise the
4
+ ask value treats the hook as having said nothing, and the tool runs. That is the
5
+ worst way for this control to fail, because the operator wrote a rule asking
6
+ for a human and got no human and no block.
7
+
8
+ So the default for every agent and hook is that an ask is NOT honoured, and the
9
+ hook enforces it as deny. Only the pairs below, each checked against the
10
+ vendor's published hook documentation, are trusted to show a real prompt.
11
+
12
+ Deliberately absent, because their documentation says they do not honour it:
13
+
14
+ Cursor preToolUse "accepted by the schema but not enforced today"
15
+ any PermissionRequest Claude Code's schema takes allow|deny only
16
+
17
+ Absent because nobody has checked: every other agent and hook. That includes
18
+ Qwen, Qoder, CodeBuddy and Kimi, which speak Claude Code's hook protocol and so
19
+ receive the same encoding, but whose vendors have not been verified to honour an
20
+ ask. A pair is added here when somebody has read that vendor's documentation,
21
+ not because the protocol looks compatible.
22
+
23
+ Pure stdlib on purpose. The hook imports this on every tool call, and #171 is
24
+ about what the hook already pays to import.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ ASK_HONOURED: frozenset[tuple[str, str]] = frozenset(
30
+ {
31
+ # Claude Code: hookSpecificOutput.permissionDecision = "ask".
32
+ ("claude", "PreToolUse"),
33
+ # Cursor: permission = "ask" returns a prompt on these two only.
34
+ ("cursor", "beforeShellExecution"),
35
+ ("cursor", "beforeMCPExecution"),
36
+ }
37
+ )
38
+
39
+
40
+ def ask_honoured(source_app: str, hook_name: str) -> bool:
41
+ """True only where an ask is known to reach a human."""
42
+ return (str(source_app).lower(), hook_name) in ASK_HONOURED