agentmetry 0.9.0__tar.gz → 0.9.1__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 (257) hide show
  1. {agentmetry-0.9.0 → agentmetry-0.9.1}/PKG-INFO +1 -1
  2. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/api/routes/audit.py +15 -0
  3. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/cli/__init__.py +11 -1
  4. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/disposition.py +10 -4
  5. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/dlp/loader.py +8 -1
  6. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/dlp/scanner.py +7 -4
  7. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/evidence_pack.py +2 -1
  8. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/external.py +9 -7
  9. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/otel_ingest.py +1 -1
  10. agentmetry-0.9.1/agentmetry/core/audit/policy_settings.py +162 -0
  11. agentmetry-0.9.1/agentmetry/core/audit/run_context.py +197 -0
  12. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/tool_policy/evaluator.py +4 -3
  13. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/tool_policy/loader.py +7 -1
  14. agentmetry-0.9.1/agentmetry/core/operator_identity.py +80 -0
  15. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/version.py +1 -1
  16. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/hooks/ingest.py +62 -24
  17. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/conftest.py +8 -0
  18. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_cli_commands.py +6 -1
  19. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_dlp_scanner.py +10 -7
  20. agentmetry-0.9.1/tests/test_hook_import_cost.py +179 -0
  21. agentmetry-0.9.1/tests/test_hook_real_process.py +100 -0
  22. agentmetry-0.9.1/tests/test_operator_attribution.py +231 -0
  23. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_readme_claims.py +22 -3
  24. agentmetry-0.9.0/agentmetry/core/audit/run_context.py +0 -129
  25. {agentmetry-0.9.0 → agentmetry-0.9.1}/.dockerignore +0 -0
  26. {agentmetry-0.9.0 → agentmetry-0.9.1}/.env.agentmetry-demo +0 -0
  27. {agentmetry-0.9.0 → agentmetry-0.9.1}/.env.example +0 -0
  28. {agentmetry-0.9.0 → agentmetry-0.9.1}/.gitignore +0 -0
  29. {agentmetry-0.9.0 → agentmetry-0.9.1}/Dockerfile +0 -0
  30. {agentmetry-0.9.0 → agentmetry-0.9.1}/README.md +0 -0
  31. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/__init__.py +0 -0
  32. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/api/__init__.py +0 -0
  33. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/api/main.py +0 -0
  34. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/api/routes/__init__.py +0 -0
  35. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/api/websocket.py +0 -0
  36. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/api/ws_bridge.py +0 -0
  37. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/cli/__main__.py +0 -0
  38. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/__init__.py +0 -0
  39. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/__init__.py +0 -0
  40. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/adapters/__init__.py +0 -0
  41. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/adapters/agt.py +0 -0
  42. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/adapters/chronicle.py +0 -0
  43. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/adapters/cloudevents.py +0 -0
  44. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/adapters/ecs.py +0 -0
  45. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/adapters/splunk.py +0 -0
  46. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/alerts.py +0 -0
  47. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/atlas.py +0 -0
  48. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/canonical.py +0 -0
  49. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/compliance_digest.py +0 -0
  50. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/__init__.py +0 -0
  51. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/benchmark.py +0 -0
  52. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/attack_approval_denied_then_executed.jsonl +0 -0
  53. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/attack_arbitrary_host_stage_execute.jsonl +0 -0
  54. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/attack_autonomous_unapproved_write.jsonl +0 -0
  55. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/attack_credential_exfil.jsonl +0 -0
  56. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/attack_credential_then_cloud_api.jsonl +0 -0
  57. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/attack_destructive_delete_burst.jsonl +0 -0
  58. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/attack_discovery_then_collect.jsonl +0 -0
  59. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/attack_dotfile_then_git_push.jsonl +0 -0
  60. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/attack_encoded_command_download.jsonl +0 -0
  61. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/attack_env_credential_exfil.jsonl +0 -0
  62. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/attack_hashed_only_no_command.jsonl +0 -0
  63. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/attack_interpreter_egress.jsonl +0 -0
  64. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/attack_pr_merged_without_review.jsonl +0 -0
  65. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/attack_proc_substitution_cradle.jsonl +0 -0
  66. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/attack_remote_pipe_to_shell.jsonl +0 -0
  67. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/attack_remote_staging_then_execute.jsonl +0 -0
  68. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/attack_secret_manager_then_egress.jsonl +0 -0
  69. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/attack_session_tool_burst.jsonl +0 -0
  70. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/attack_single_command_exfil.jsonl +0 -0
  71. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/attack_single_quoted_interpreter_egress.jsonl +0 -0
  72. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/attack_ssh_directory_exfil.jsonl +0 -0
  73. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/attack_subagent_swarm.jsonl +0 -0
  74. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/attack_timestamp_collision.jsonl +0 -0
  75. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/attack_untrusted_input_then_action.jsonl +0 -0
  76. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/benign_authoring_merge_fixtures.jsonl +0 -0
  77. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/benign_autonomous_after_approval.jsonl +0 -0
  78. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/benign_build_artifact_cleanup.jsonl +0 -0
  79. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/benign_ci_artifact_download.jsonl +0 -0
  80. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/benign_commit_message_names_cloud_clis.jsonl +0 -0
  81. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/benign_database_migration.jsonl +0 -0
  82. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/benign_dependency_install_and_build.jsonl +0 -0
  83. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/benign_download_release_archive.jsonl +0 -0
  84. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/benign_env_example_read.jsonl +0 -0
  85. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/benign_fetch_data_then_run_repo_script.jsonl +0 -0
  86. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/benign_fetch_dataset_then_analyse.jsonl +0 -0
  87. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/benign_fetch_lockfile_then_install.jsonl +0 -0
  88. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/benign_fetch_piped_into_own_script.jsonl +0 -0
  89. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/benign_git_review_and_push.jsonl +0 -0
  90. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/benign_github_raw_docs_then_script.jsonl +0 -0
  91. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/benign_human_driven_deletes.jsonl +0 -0
  92. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/benign_local_api_probing.jsonl +0 -0
  93. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/benign_long_but_calm_session.jsonl +0 -0
  94. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/benign_loopback_is_not_egress.jsonl +0 -0
  95. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/benign_loopback_pipe_to_interpreter.jsonl +0 -0
  96. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/benign_mcp_text_mentions_credential_path.jsonl +0 -0
  97. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/benign_ordinary_development.jsonl +0 -0
  98. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/benign_package_manager_after_fetch.jsonl +0 -0
  99. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/benign_reading_config_that_is_not_secret.jsonl +0 -0
  100. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/benign_remote_api_call_no_credentials.jsonl +0 -0
  101. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/benign_research_then_docs.jsonl +0 -0
  102. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/benign_reversed_order_is_not_exfil.jsonl +0 -0
  103. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/benign_secret_names_and_docs.jsonl +0 -0
  104. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/benign_test_and_fix_loop.jsonl +0 -0
  105. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/benign_writing_about_credentials.jsonl +0 -0
  106. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/corpus/corpus.yaml +0 -0
  107. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/engine.py +0 -0
  108. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/live.py +0 -0
  109. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/live_store.py +0 -0
  110. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/models.py +0 -0
  111. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/rules.py +0 -0
  112. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/traits.py +0 -0
  113. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/yaml_config.py +0 -0
  114. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/detection/yaml_rules.py +0 -0
  115. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/dlp/__init__.py +0 -0
  116. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/dlp/models.py +0 -0
  117. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/dogfood.py +0 -0
  118. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/hashing.py +0 -0
  119. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/heartbeat.py +0 -0
  120. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/hook_bootstrap.py +0 -0
  121. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/identity.py +0 -0
  122. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/ingest.py +0 -0
  123. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/migrate.py +0 -0
  124. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/mitre.py +0 -0
  125. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/policy.py +0 -0
  126. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/redaction.py +0 -0
  127. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/replay.py +0 -0
  128. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/sinks.py +0 -0
  129. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/spool.py +0 -0
  130. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/tool_policy/__init__.py +0 -0
  131. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/tool_policy/capability.py +0 -0
  132. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/tool_policy/models.py +0 -0
  133. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/trail_anchor.py +0 -0
  134. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/trail_chain.py +0 -0
  135. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/trail_db.py +0 -0
  136. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/audit/trail_merkle.py +0 -0
  137. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/auth.py +0 -0
  138. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/bus/__init__.py +0 -0
  139. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/bus/audit_exporter.py +0 -0
  140. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/bus/bridges.py +0 -0
  141. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/bus/bus.py +0 -0
  142. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/bus/events.py +0 -0
  143. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/bus/outbox.py +0 -0
  144. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/config.py +0 -0
  145. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/diagnostics/__init__.py +0 -0
  146. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/diagnostics/autostart.py +0 -0
  147. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/diagnostics/doctor.py +0 -0
  148. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/diagnostics/driver_paths.py +0 -0
  149. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/diagnostics/env_file.py +0 -0
  150. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/diagnostics/hook_coverage.py +0 -0
  151. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/diagnostics/mcp_inventory.py +0 -0
  152. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/diagnostics/mcp_schema.py +0 -0
  153. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/drivers/__init__.py +0 -0
  154. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/drivers/host.py +0 -0
  155. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/drivers/permissions.py +0 -0
  156. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/drivers/spec.py +0 -0
  157. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/extensions.py +0 -0
  158. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/core/health.py +0 -0
  159. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/hooks/__init__.py +0 -0
  160. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/policies/detection/manifest.yaml +0 -0
  161. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/policies/dlp/manifest.yaml +0 -0
  162. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/policies/opa/agent_rules.rego +0 -0
  163. {agentmetry-0.9.0 → agentmetry-0.9.1}/agentmetry/policies/tool/manifest.yaml +0 -0
  164. {agentmetry-0.9.0 → agentmetry-0.9.1}/pyproject.toml +0 -0
  165. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/fixtures/agt_filesink_exfil.jsonl +0 -0
  166. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/fixtures/agt_filesink_mixed.jsonl +0 -0
  167. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/fixtures/fake_mcp_server.py +0 -0
  168. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/fixtures/otel_claude_code_session.json +0 -0
  169. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_agentmetry_audit.py +0 -0
  170. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_agentmetry_ingest_client.py +0 -0
  171. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_agt_adapter.py +0 -0
  172. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_ask_gate_phase_a.py +0 -0
  173. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_atlas_detection_enrichment.py +0 -0
  174. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_atlas_mapping.py +0 -0
  175. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_audit_sinks.py +0 -0
  176. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_audit_stats.py +0 -0
  177. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_audit_tail.py +0 -0
  178. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_auth.py +0 -0
  179. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_autostart.py +0 -0
  180. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_boot_sequence.py +0 -0
  181. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_burst_windows.py +0 -0
  182. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_chinese_agent_hooks.py +0 -0
  183. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_chinese_agent_sprint_b.py +0 -0
  184. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_chinese_agent_sprint_c.py +0 -0
  185. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_chronicle_adapter.py +0 -0
  186. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_cli_backup.py +0 -0
  187. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_cli_env_var_names.py +0 -0
  188. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_cloudevents_adapter.py +0 -0
  189. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_compliance_digest.py +0 -0
  190. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_crewai_adapter.py +0 -0
  191. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_detection_adi.py +0 -0
  192. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_detection_benchmark.py +0 -0
  193. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_detection_default_config.py +0 -0
  194. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_detection_disposition.py +0 -0
  195. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_detection_disposition_api.py +0 -0
  196. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_detection_duplicate_emission.py +0 -0
  197. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_detection_emit_durability.py +0 -0
  198. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_detection_engine.py +0 -0
  199. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_detection_evasion.py +0 -0
  200. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_detection_hf_incident.py +0 -0
  201. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_detection_quoted_command_words.py +0 -0
  202. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_detection_rule_identity.py +0 -0
  203. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_detection_rules_v2.py +0 -0
  204. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_dlp_agent_env_override.py +0 -0
  205. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_dlp_invisible_unicode.py +0 -0
  206. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_dlp_markdown_exfil.py +0 -0
  207. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_doctor.py +0 -0
  208. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_dogfood.py +0 -0
  209. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_dogfood_freeze.py +0 -0
  210. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_driver_paths.py +0 -0
  211. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_ecs_threat.py +0 -0
  212. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_event_bus.py +0 -0
  213. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_evidence_pack.py +0 -0
  214. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_extensions.py +0 -0
  215. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_external_ingest.py +0 -0
  216. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_forwarding_shape.py +0 -0
  217. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_funding_manifest.py +0 -0
  218. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_heartbeat.py +0 -0
  219. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_hook_bootstrap.py +0 -0
  220. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_hook_coverage.py +0 -0
  221. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_hook_decision_format.py +0 -0
  222. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_hook_enforcement_timing.py +0 -0
  223. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_hook_spool.py +0 -0
  224. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_identity_enrich.py +0 -0
  225. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_ingest_roundtrip.py +0 -0
  226. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_isolation.py +0 -0
  227. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_launch_targets.py +0 -0
  228. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_live_detection.py +0 -0
  229. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_live_detection_store.py +0 -0
  230. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_mcp_audit_proxy.py +0 -0
  231. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_mcp_concealed_text.py +0 -0
  232. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_mcp_inventory.py +0 -0
  233. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_mcp_meta_fingerprint.py +0 -0
  234. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_mcp_schema.py +0 -0
  235. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_mitre_dlp.py +0 -0
  236. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_opensre_adapter.py +0 -0
  237. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_otel_ingest.py +0 -0
  238. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_powershell_encoding.py +0 -0
  239. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_redaction.py +0 -0
  240. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_replay.py +0 -0
  241. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_sigma_pack.py +0 -0
  242. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_static_serving.py +0 -0
  243. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_subagent_lifecycle.py +0 -0
  244. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_suite_leaves_real_home_alone.py +0 -0
  245. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_tool_policy.py +0 -0
  246. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_tool_policy_agent_cli.py +0 -0
  247. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_tool_policy_git_hooks.py +0 -0
  248. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_trail_anchor.py +0 -0
  249. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_trail_chain.py +0 -0
  250. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_trail_concurrency.py +0 -0
  251. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_trail_merkle.py +0 -0
  252. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_unattended_agent_policy.py +0 -0
  253. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_version.py +0 -0
  254. {agentmetry-0.9.0 → agentmetry-0.9.1}/tests/test_yaml_detection_rules.py +0 -0
  255. {agentmetry-0.9.0 → agentmetry-0.9.1}/tools/generate_sigma_pack.py +0 -0
  256. {agentmetry-0.9.0 → agentmetry-0.9.1}/tools/mcp_audit_proxy.py +0 -0
  257. {agentmetry-0.9.0 → agentmetry-0.9.1}/tools/vault_fs_server.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentmetry
3
- Version: 0.9.0
3
+ Version: 0.9.1
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
@@ -70,6 +70,19 @@ class IngestInitiatorBody(BaseModel):
70
70
  return v if v in _ACTOR_TYPES else "agent"
71
71
 
72
72
 
73
+ class IngestOperatorBody(BaseModel):
74
+ """Which account the capture process ran as, and how it knew.
75
+
76
+ Separate from `initiator`, which says what kind of actor acted. A claim,
77
+ like everything a client sends: `run_context.resolve_operator` decides what
78
+ is recorded, and an orchestrator-side AGENTMETRY_OPERATOR_ID overrides an
79
+ id the hook only read from the operating system.
80
+ """
81
+
82
+ id: str = ""
83
+ source: str = ""
84
+
85
+
73
86
  class ExternalIngestBody(BaseModel):
74
87
  """Adapter payload — normalized to canonical v1.2 on ingest.
75
88
 
@@ -113,6 +126,8 @@ class ExternalIngestBody(BaseModel):
113
126
  tool_policy: dict[str, Any] | None = None
114
127
  # Who the capture surface says triggered this. Honoured by external.py.
115
128
  initiator: IngestInitiatorBody | None = None
129
+ # Which account ran the agent, resolved by the hook as the developer.
130
+ operator: IngestOperatorBody | None = None
116
131
  # Compact `tools/list` fingerprint from mcp_audit_proxy. Hash only: the
117
132
  # description never leaves the proxy process.
118
133
  schema_fingerprint: str = ""
@@ -284,6 +284,15 @@ def cmd_detections(args: argparse.Namespace) -> int:
284
284
  return 0
285
285
 
286
286
 
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
292
+
293
+ return os_operator()
294
+
295
+
287
296
  def cmd_disposition(args: argparse.Namespace) -> int:
288
297
  """Close a detection from the shell, using the same API as the dashboard."""
289
298
  note = args.note.strip()
@@ -299,7 +308,8 @@ def cmd_disposition(args: argparse.Namespace) -> int:
299
308
  "rule_id": args.rule_id,
300
309
  "status": args.status,
301
310
  "note": note,
302
- "decided_by": args.decided_by,
311
+ # The CLI runs as the person triaging; the orchestrator may not.
312
+ "decided_by": args.decided_by or _triage_operator(),
303
313
  },
304
314
  headers=_api_headers(),
305
315
  timeout=10.0,
@@ -431,8 +431,14 @@ def build_disposition_event(
431
431
  `action.type:detection_disposition AND action.outcome:risk_accepted`
432
432
  without understanding Agentmetry's vocabulary. The note is operator-written
433
433
  text, never captured command content, so it is safe to forward.
434
+
435
+ Who decided falls back to the resolved operator, not to the raw setting:
436
+ with AGENTMETRY_OPERATOR_ID unset that was an empty string, so a triage
437
+ record could not say who triaged.
434
438
  """
435
- from agentmetry.core.config import settings
439
+ from agentmetry.core.audit.run_context import resolve_operator
440
+
441
+ decided_by = decided_by or resolve_operator()[0]
436
442
 
437
443
  return {
438
444
  "schema_version": SCHEMA_VERSION,
@@ -443,8 +449,8 @@ def build_disposition_event(
443
449
  **identity_fields(),
444
450
  "source_topic": f"disposition/{rule_id}",
445
451
  "source": {"tier": "detection", "app": "agentmetry"},
446
- "actor": {"id": decided_by or settings.operator_id, "type": "human"},
447
- "initiator": {"type": "human", "id": decided_by or settings.operator_id},
452
+ "actor": {"id": decided_by, "type": "human"},
453
+ "initiator": {"type": "human", "id": decided_by},
448
454
  "action": {
449
455
  "type": DISPOSITION_EVENT_TYPE,
450
456
  "outcome": status,
@@ -459,7 +465,7 @@ def build_disposition_event(
459
465
  "previous_status": previous_status,
460
466
  "assignee": assignee,
461
467
  "note": note,
462
- "decided_by": decided_by or settings.operator_id,
468
+ "decided_by": decided_by,
463
469
  },
464
470
  }
465
471
 
@@ -4,6 +4,13 @@ from typing import List
4
4
 
5
5
  from .models import RuleMeta
6
6
 
7
+ #: libyaml where it is installed. The pure-Python parser was most of the hook's
8
+ #: runtime: the DLP and tool manifests are re-read by every hook process, and
9
+ #: parsing them took ~55 ms of an ~85 ms tool call (#171). Same SafeConstructor,
10
+ #: so the same Python objects, which `test_hook_import_cost.py` checks.
11
+ SAFE_LOADER = getattr(yaml, "CSafeLoader", yaml.SafeLoader)
12
+
13
+
7
14
  def load_dlp_rules(manifest_path: Path | str) -> List[RuleMeta]:
8
15
  """Load DLP rules from a YAML manifest file."""
9
16
  path = Path(manifest_path)
@@ -11,7 +18,7 @@ def load_dlp_rules(manifest_path: Path | str) -> List[RuleMeta]:
11
18
  return []
12
19
 
13
20
  with open(path, "r", encoding="utf-8") as f:
14
- data = yaml.safe_load(f)
21
+ data = yaml.load(f, Loader=SAFE_LOADER) # noqa: S506 - a safe loader
15
22
 
16
23
  if not data or "rules" not in data:
17
24
  return []
@@ -5,7 +5,9 @@ from typing import Any, Dict
5
5
 
6
6
  from .models import DlpVerdict, DlpMatch
7
7
  from .loader import load_dlp_rules
8
- from ...config import settings
8
+ # Not core.config: that import is pydantic, and the hook paid ~220 ms for it
9
+ # on every tool call to read three values (#171).
10
+ from ..policy_settings import policy_settings
9
11
 
10
12
  logger = logging.getLogger(__name__)
11
13
 
@@ -21,9 +23,10 @@ def reset_rules() -> None:
21
23
  def _init_rules() -> None:
22
24
  if _COMPILED_RULES:
23
25
  return
24
- rules = load_dlp_rules(settings.dlp_rules_path)
26
+ cfg = policy_settings()
27
+ rules = load_dlp_rules(cfg.dlp_rules_path)
25
28
  for r in rules:
26
- if not settings.dlp_pii and r.category == "pii":
29
+ if not cfg.dlp_pii and r.category == "pii":
27
30
  continue
28
31
  try:
29
32
  _COMPILED_RULES.append((re.compile(r.pattern), r))
@@ -59,7 +62,7 @@ def scan(tool_qualified: str, arguments: Dict[str, Any] | str, mode: str | None
59
62
  metadata is returned — never the matched value.
60
63
  """
61
64
  if mode is None:
62
- mode = settings.dlp_mode
65
+ mode = policy_settings().dlp_mode
63
66
  if mode == "disable":
64
67
  return DlpVerdict(matched=False, mode=mode)
65
68
 
@@ -23,6 +23,7 @@ from pathlib import Path
23
23
  from typing import Any
24
24
 
25
25
  from agentmetry.core.audit.detection.disposition import extract_dispositions
26
+ from agentmetry.core.audit.run_context import resolve_operator
26
27
  from agentmetry.core.config import settings
27
28
  from agentmetry.core.version import __version__
28
29
 
@@ -290,7 +291,7 @@ def _controls_snapshot() -> dict[str, Any]:
290
291
  "detection": {
291
292
  "off_hours_enabled": settings.detect_off_hours,
292
293
  },
293
- "operator_id": settings.operator_id.strip() or "local",
294
+ "operator_id": resolve_operator()[0],
294
295
  }
295
296
 
296
297
 
@@ -10,7 +10,11 @@ from agentmetry.core.audit.canonical import SCHEMA_VERSION
10
10
  from agentmetry.core.audit.identity import identity_fields
11
11
  from agentmetry.core.audit.hashing import arguments_sha256
12
12
  from agentmetry.core.audit.redaction import scrub_arg_values, scrub_secrets
13
- from agentmetry.core.audit.run_context import actor_from_initiator, build_initiator
13
+ from agentmetry.core.audit.run_context import (
14
+ actor_from_initiator,
15
+ build_initiator,
16
+ operator_from_payload,
17
+ )
14
18
  from agentmetry.core.config import settings
15
19
 
16
20
  ExternalApp = Literal[
@@ -34,10 +38,6 @@ _ACTION_MAP = {
34
38
  }
35
39
 
36
40
 
37
- def _operator_id() -> str:
38
- return settings.operator_id.strip() or "local"
39
-
40
-
41
41
  def _utc_now() -> str:
42
42
  return datetime.now(timezone.utc).isoformat()
43
43
 
@@ -63,16 +63,18 @@ def build_external_canonical(payload: dict[str, Any]) -> dict[str, Any]:
63
63
  correlation_id = str(payload.get("correlation_id") or payload.get("thread_id") or "")
64
64
  session_id = str(payload.get("session_id") or "")
65
65
 
66
+ operator = operator_from_payload(payload)
66
67
  initiator_raw = payload.get("initiator")
67
68
  if isinstance(initiator_raw, dict) and initiator_raw.get("actor_type"):
68
69
  initiator = {
69
70
  "actor_type": str(initiator_raw.get("actor_type") or "human"),
70
71
  "trigger": str(initiator_raw.get("trigger") or "manual"),
71
- "operator_id": str(initiator_raw.get("operator_id") or _operator_id()),
72
+ "operator_id": operator[0],
73
+ "operator_source": operator[1],
72
74
  }
73
75
  else:
74
76
  trigger = str(payload.get("triggered_by") or "manual")
75
- initiator = build_initiator(trigger)
77
+ initiator = build_initiator(trigger, operator)
76
78
 
77
79
  tool_block = payload.get("tool") if isinstance(payload.get("tool"), dict) else {}
78
80
  tool_qualified = str(
@@ -276,7 +276,7 @@ def _map_tool_decision(attrs: dict[str, Any], ts_iso: str) -> tuple[dict[str, An
276
276
  "outcome": "success",
277
277
  "reason": f"otel_decision:{source}",
278
278
  "tool_qualified": str(attrs.get("tool_name") or ""),
279
- "initiator": {"actor_type": "human", "trigger": "manual", "operator_id": "local"},
279
+ "initiator": {"actor_type": "human", "trigger": "manual", "operator_id": ""},
280
280
  })
281
281
  return payload, ""
282
282
  if decision == "reject" and source in _HUMAN_REJECT:
@@ -0,0 +1,162 @@
1
+ """The five settings DLP and tool policy read, without importing pydantic (#171).
2
+
3
+ The hook runs in front of every tool call, and on a blocking hook the agent waits
4
+ for it. The DLP scanner and the tool-policy evaluator used to import
5
+ `core.config` to learn a mode and a manifest path each, and that one import
6
+ pulled in pydantic and pydantic-settings: about 220 ms of a 626 ms hook, paid on
7
+ every tool call to read five values.
8
+
9
+ So there are two ways to get them, and one answer:
10
+
11
+ * Where `core.config` is already loaded, the orchestrator and the test suite,
12
+ the real `settings` object is read. A test that monkeypatches
13
+ `settings.dlp_mode` still changes what the scanner sees.
14
+ * Where it is not, the hook, they are resolved here the way `Settings` resolves
15
+ them: environment first, matched case-insensitively, then the orchestrator's
16
+ `.env`, then the same defaults. `test_hook_import_cost.py` checks the two
17
+ agree.
18
+
19
+ Standard library only. That is the whole point of the module.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ import sys
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+
29
+ _HERE = Path(__file__).resolve()
30
+ _PACKAGE_ROOT = _HERE.parents[2] # agentmetry/, as config._PACKAGE_ROOT
31
+ _ORCHESTRATOR_ROOT = _HERE.parents[3] # where Settings reads .env, as config._ORCHESTRATOR_ROOT
32
+
33
+ #: pydantic's own boolean vocabulary, so `AGENTMETRY_DLP_PII=off` means the same
34
+ #: thing to the hook as to the orchestrator.
35
+ _TRUE = frozenset({"1", "on", "t", "true", "y", "yes"})
36
+ _FALSE = frozenset({"0", "off", "f", "false", "n", "no"})
37
+
38
+ _DOTENV_CACHE: tuple[tuple[str, float, int] | None, dict[str, str]] = (None, {})
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class PolicySettings:
43
+ dlp_mode: str
44
+ dlp_rules_path: Path
45
+ dlp_pii: bool
46
+ tool_policy_mode: str
47
+ tool_policy_path: Path
48
+
49
+
50
+ def _defaults() -> PolicySettings:
51
+ policies = _PACKAGE_ROOT / "policies"
52
+ return PolicySettings(
53
+ dlp_mode="log",
54
+ dlp_rules_path=policies / "dlp" / "manifest.yaml",
55
+ dlp_pii=True,
56
+ tool_policy_mode="log",
57
+ tool_policy_path=policies / "tool" / "manifest.yaml",
58
+ )
59
+
60
+
61
+ def _parse_dotenv(text: str) -> dict[str, str]:
62
+ """The subset of python-dotenv that a `.env` for this project uses.
63
+
64
+ `export` prefixes, single or double quotes, and a ` #` comment after an
65
+ unquoted value. Keys are lowercased, because Settings matches them
66
+ case-insensitively.
67
+ """
68
+ out: dict[str, str] = {}
69
+ for raw in text.splitlines():
70
+ line = raw.strip()
71
+ if not line or line.startswith("#") or "=" not in line:
72
+ continue
73
+ if line.startswith("export "):
74
+ line = line[len("export "):].lstrip()
75
+ key, _, value = line.partition("=")
76
+ value = value.strip()
77
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
78
+ value = value[1:-1]
79
+ elif " #" in value:
80
+ value = value.split(" #", 1)[0].rstrip()
81
+ out[key.strip().lower()] = value
82
+ return out
83
+
84
+
85
+ def _dotenv() -> dict[str, str]:
86
+ """The orchestrator's `.env`, parsed once per change to the file."""
87
+ global _DOTENV_CACHE
88
+ path = _ORCHESTRATOR_ROOT / ".env"
89
+ try:
90
+ stat = path.stat()
91
+ except OSError:
92
+ return {}
93
+ stamp = (str(path), stat.st_mtime, stat.st_size)
94
+ if _DOTENV_CACHE[0] == stamp:
95
+ return _DOTENV_CACHE[1]
96
+ try:
97
+ parsed = _parse_dotenv(path.read_text(encoding="utf-8"))
98
+ except (OSError, UnicodeDecodeError):
99
+ return {}
100
+ _DOTENV_CACHE = (stamp, parsed)
101
+ return parsed
102
+
103
+
104
+ def _lookup(name: str, environ: dict[str, str], dotenv: dict[str, str]) -> str | None:
105
+ # Present-but-empty counts as set, as it does for Settings (env_ignore_empty
106
+ # is off): an empty mode is not "use the default".
107
+ key = name.lower()
108
+ if key in environ:
109
+ return environ[key]
110
+ return dotenv.get(key)
111
+
112
+
113
+ def _bool(raw: str | None, default: bool) -> bool:
114
+ if raw is None:
115
+ return default
116
+ text = raw.strip().lower()
117
+ if text in _TRUE:
118
+ return True
119
+ if text in _FALSE:
120
+ return False
121
+ # Settings refuses to start on this. The hook must not fail a tool call over
122
+ # a typo, so it keeps the default and the orchestrator's error stays the
123
+ # place the operator finds out.
124
+ return default
125
+
126
+
127
+ def resolve() -> PolicySettings:
128
+ """Resolve without pydantic, as the hook does."""
129
+ environ = {k.lower(): v for k, v in os.environ.items()}
130
+ dotenv = _dotenv()
131
+ base = _defaults()
132
+
133
+ def text(name: str, default: str) -> str:
134
+ value = _lookup(name, environ, dotenv)
135
+ return default if value is None else value
136
+
137
+ def path(name: str, default: Path) -> Path:
138
+ value = _lookup(name, environ, dotenv)
139
+ return default if value is None else Path(value)
140
+
141
+ return PolicySettings(
142
+ dlp_mode=text("AGENTMETRY_DLP_MODE", base.dlp_mode),
143
+ dlp_rules_path=path("AGENTMETRY_DLP_RULES_PATH", base.dlp_rules_path),
144
+ dlp_pii=_bool(_lookup("AGENTMETRY_DLP_PII", environ, dotenv), base.dlp_pii),
145
+ tool_policy_mode=text("AGENTMETRY_TOOL_POLICY_MODE", base.tool_policy_mode),
146
+ tool_policy_path=path("AGENTMETRY_TOOL_POLICY_PATH", base.tool_policy_path),
147
+ )
148
+
149
+
150
+ def policy_settings() -> PolicySettings:
151
+ """The settings, from the loaded `Settings` object when there is one."""
152
+ config = sys.modules.get("agentmetry.core.config")
153
+ settings = getattr(config, "settings", None) if config is not None else None
154
+ if settings is None:
155
+ return resolve()
156
+ return PolicySettings(
157
+ dlp_mode=settings.dlp_mode,
158
+ dlp_rules_path=Path(settings.dlp_rules_path),
159
+ dlp_pii=bool(settings.dlp_pii),
160
+ tool_policy_mode=settings.tool_policy_mode,
161
+ tool_policy_path=Path(settings.tool_policy_path),
162
+ )
@@ -0,0 +1,197 @@
1
+ """Per-run audit context — initiator provenance and last gated tool (schema v1.1)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import lru_cache
6
+ from typing import Any
7
+
8
+ from agentmetry.core import operator_identity as oid
9
+ from agentmetry.core.config import settings
10
+
11
+ # thread_id → initiator block (set at run start, server-derived only)
12
+ _thread_initiators: dict[str, dict[str, str]] = {}
13
+ # thread_id → last successful tool call on this run (for approval binding)
14
+ _thread_last_tool: dict[str, dict[str, str]] = {}
15
+
16
+
17
+ @lru_cache(maxsize=1)
18
+ def _orchestrator_os() -> str:
19
+ # The account the recorder runs as does not change under a running process.
20
+ return oid.os_operator()
21
+
22
+
23
+ def resolve_operator(client_id: object = "", client_source: object = "") -> tuple[str, str]:
24
+ """The operator id to record, and where it came from (`initiator.operator_source`).
25
+
26
+ Every event used to say `local`, so the trail could prove a record was not
27
+ altered and could not say who it was about (#168). In order:
28
+
29
+ 1. An id the developer configured on their side, which the hook reports as
30
+ `configured` (`hook_configured`). The most specific statement anybody
31
+ made.
32
+ 2. AGENTMETRY_OPERATOR_ID on this orchestrator (`orchestrator_configured`):
33
+ an admin override, for a service account or a shared machine. It used to
34
+ apply to no hook event at all, because every hook sent `local` and a
35
+ client value always won.
36
+ 3. The account the hook ran as (`hook_os`). The hook runs inside the
37
+ developer's agent, so this is the developer.
38
+ 4. An id a client sent without saying how (`client`).
39
+ 5. The account this orchestrator runs as (`orchestrator_os`). On one
40
+ developer's machine that is the developer; under a fleet service it is
41
+ the service account, and the label says so rather than letting it pass
42
+ for a person.
43
+ 6. `local`, labelled `default`, when nothing resolves.
44
+
45
+ Everything a client sends is a claim, and the `hook_` and `client` labels
46
+ say so. The legacy `local` a hook up to 0.9.0 sends counts as not stated.
47
+ """
48
+ claimed = oid.stated(client_id)
49
+ source = str(client_source or "")
50
+ if claimed and source == oid.CONFIGURED:
51
+ return claimed, oid.HOOK_CONFIGURED
52
+ configured = settings.operator_id.strip()
53
+ if configured:
54
+ return configured, oid.ORCHESTRATOR_CONFIGURED
55
+ if claimed:
56
+ return claimed, oid.HOOK_OS if source == oid.OS else oid.CLIENT
57
+ local = _orchestrator_os()
58
+ if local:
59
+ return local, oid.ORCHESTRATOR_OS
60
+ return oid.LEGACY_PLACEHOLDER, oid.DEFAULT
61
+
62
+
63
+ def operator_from_payload(payload: dict[str, Any]) -> tuple[str, str]:
64
+ """Resolve from an ingest body: its `operator` block, else `initiator.operator_id`.
65
+
66
+ An initiator this orchestrator built already carries a recorded source, and
67
+ re-resolving it would relabel the recorder's own answer as a client claim.
68
+ Only internal payloads can carry one: `IngestInitiatorBody` does not declare
69
+ `operator_source`, so pydantic drops it from anything sent over HTTP.
70
+ """
71
+ op = payload.get("operator")
72
+ if isinstance(op, dict) and oid.stated(op.get("id")):
73
+ return resolve_operator(op.get("id"), op.get("source"))
74
+ init = payload.get("initiator")
75
+ if isinstance(init, dict):
76
+ recorded = str(init.get("operator_source") or "")
77
+ if recorded in oid.RECORDED_SOURCES and oid.stated(init.get("operator_id")):
78
+ return str(init["operator_id"]), recorded
79
+ return resolve_operator(init.get("operator_id"), init.get("operator_source"))
80
+ return resolve_operator()
81
+
82
+
83
+ def _operator_id() -> str:
84
+ return resolve_operator()[0]
85
+
86
+
87
+ def build_initiator(
88
+ triggered_by: str, operator: tuple[str, str] | None = None
89
+ ) -> dict[str, str]:
90
+ """Derive initiator from run origin. Must never trust client-supplied headers."""
91
+ operator_id, operator_source = operator or resolve_operator()
92
+ if triggered_by == "manual":
93
+ actor_type, trigger = "human", "manual"
94
+ elif triggered_by.startswith("channel:"):
95
+ actor_type, trigger = "human", "channel"
96
+ else:
97
+ # cron, vault_watch, ingress, recovery, and anything unrecognised.
98
+ actor_type, trigger = "autonomous", triggered_by
99
+ return {
100
+ "actor_type": actor_type,
101
+ "trigger": trigger,
102
+ "operator_id": operator_id,
103
+ "operator_source": operator_source,
104
+ }
105
+
106
+
107
+ def actor_from_initiator(initiator: dict[str, str]) -> dict[str, str]:
108
+ human = initiator.get("actor_type") == "human"
109
+ return {
110
+ "type": "user" if human else "agent",
111
+ "id": initiator.get("operator_id") or _operator_id(),
112
+ "role": "operator",
113
+ }
114
+
115
+
116
+ def default_initiator() -> dict[str, str]:
117
+ return build_initiator("manual")
118
+
119
+
120
+ def set_thread_initiator(thread_id: str, triggered_by: str) -> dict[str, str]:
121
+ initiator = build_initiator(triggered_by)
122
+ _thread_initiators[thread_id] = initiator
123
+ return initiator
124
+
125
+
126
+ def get_thread_initiator(thread_id: str) -> dict[str, str] | None:
127
+ return _thread_initiators.get(thread_id)
128
+
129
+
130
+ def resolve_initiator(
131
+ payload: dict[str, Any], thread_id: str = ""
132
+ ) -> dict[str, str]:
133
+ """Read initiator from payload or thread cache; fallback manual human."""
134
+ raw = payload.get("initiator")
135
+ if isinstance(raw, dict) and raw.get("actor_type"):
136
+ operator_id, operator_source = operator_from_payload(payload)
137
+ return {
138
+ "actor_type": str(raw.get("actor_type") or "human"),
139
+ "trigger": str(raw.get("trigger") or "manual"),
140
+ "operator_id": operator_id,
141
+ "operator_source": operator_source,
142
+ }
143
+ triggered_by = str(payload.get("triggered_by") or "")
144
+ if triggered_by:
145
+ return build_initiator(triggered_by, operator_from_payload(payload))
146
+ if thread_id:
147
+ cached = _thread_initiators.get(thread_id)
148
+ if cached:
149
+ return cached
150
+ return default_initiator()
151
+
152
+
153
+ def record_tool_call(thread_id: str, qualified: str, arguments_sha256: str) -> None:
154
+ if not thread_id:
155
+ return
156
+ server = qualified.split(".", 1)[0] if "." in qualified else ""
157
+ _thread_last_tool[thread_id] = {
158
+ "tool": qualified,
159
+ "server": server,
160
+ "input_hash": arguments_sha256,
161
+ }
162
+
163
+
164
+ def last_gated_action(thread_id: str) -> dict[str, str] | None:
165
+ if not thread_id:
166
+ return None
167
+ action = _thread_last_tool.get(thread_id)
168
+ if not action or not action.get("tool"):
169
+ return None
170
+ return dict(action)
171
+
172
+
173
+ def clear_run_context(thread_id: str) -> None:
174
+ _thread_initiators.pop(thread_id, None)
175
+ _thread_last_tool.pop(thread_id, None)
176
+
177
+
178
+ def audit_payload(
179
+ thread_id: str,
180
+ triggered_by: str | None = None,
181
+ *,
182
+ initiator: dict[str, str] | None = None,
183
+ ) -> dict[str, Any]:
184
+ """Extra bus payload fields for canonical v1.1."""
185
+ init = initiator or (
186
+ get_thread_initiator(thread_id)
187
+ if thread_id
188
+ else None
189
+ )
190
+ if init is None and triggered_by:
191
+ init = build_initiator(triggered_by)
192
+ if init is None:
193
+ init = default_initiator()
194
+ out: dict[str, Any] = {"initiator": init}
195
+ if triggered_by:
196
+ out["triggered_by"] = triggered_by
197
+ return out
@@ -6,7 +6,8 @@ from typing import Any
6
6
 
7
7
  from .loader import load_tool_policy
8
8
  from .models import ToolPolicyMatch, ToolPolicyRule, ToolPolicyVerdict
9
- from ...config import settings
9
+ # Not core.config, for the same reason as the DLP scanner (#171).
10
+ from ..policy_settings import policy_settings
10
11
 
11
12
  logger = logging.getLogger(__name__)
12
13
 
@@ -22,7 +23,7 @@ def reset_policy() -> None:
22
23
  def _init_policy() -> tuple[list[ToolPolicyRule], str]:
23
24
  global _POLICY
24
25
  if _POLICY is None:
25
- _POLICY = load_tool_policy(settings.tool_policy_path)
26
+ _POLICY = load_tool_policy(policy_settings().tool_policy_path)
26
27
  return _POLICY
27
28
 
28
29
 
@@ -143,7 +144,7 @@ def evaluate(
143
144
  ) -> ToolPolicyVerdict:
144
145
  """Evaluate tool allow/deny policy. Runs on plaintext hook data before hashing."""
145
146
  if mode is None:
146
- mode = settings.tool_policy_mode
147
+ mode = policy_settings().tool_policy_mode
147
148
  if mode == "disable":
148
149
  return ToolPolicyVerdict(matched=False, blocked=False, mode=mode)
149
150
 
@@ -7,6 +7,12 @@ from .models import ToolPolicyRule
7
7
 
8
8
  logger = logging.getLogger(__name__)
9
9
 
10
+ #: libyaml where it is installed. The pure-Python parser was most of the hook's
11
+ #: runtime: the DLP and tool manifests are re-read by every hook process, and
12
+ #: parsing them took ~55 ms of an ~85 ms tool call (#171). Same SafeConstructor,
13
+ #: so the same Python objects, which `test_hook_import_cost.py` checks.
14
+ SAFE_LOADER = getattr(yaml, "CSafeLoader", yaml.SafeLoader)
15
+
10
16
  ACTIONS = ("allow", "deny", "ask")
11
17
 
12
18
  #: `ask` is not a valid default yet. "Ask about everything not allowlisted" is
@@ -47,7 +53,7 @@ def load_tool_policy(manifest_path: Path | str) -> tuple[list[ToolPolicyRule], s
47
53
  return [], "allow"
48
54
 
49
55
  with open(path, encoding="utf-8") as fh:
50
- data = yaml.safe_load(fh)
56
+ data = yaml.load(fh, Loader=SAFE_LOADER) # noqa: S506 - a safe loader
51
57
 
52
58
  if not data or "rules" not in data:
53
59
  return [], _normalise_default(data.get("default") if data else None)