agentmetry 0.9.0__tar.gz → 0.9.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agentmetry-0.9.0 → agentmetry-0.9.2}/.env.example +9 -1
- {agentmetry-0.9.0 → agentmetry-0.9.2}/PKG-INFO +2 -2
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/api/routes/audit.py +15 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/cli/__init__.py +8 -1
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/disposition.py +10 -4
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/dlp/loader.py +8 -1
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/dlp/scanner.py +7 -4
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/evidence_pack.py +2 -1
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/external.py +9 -7
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/heartbeat.py +9 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/ingest.py +1 -0
- agentmetry-0.9.2/agentmetry/core/audit/mcp_inventory_event.py +119 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/otel_ingest.py +1 -1
- agentmetry-0.9.2/agentmetry/core/audit/policy_settings.py +162 -0
- agentmetry-0.9.2/agentmetry/core/audit/run_context.py +197 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/sinks.py +13 -2
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/tool_policy/evaluator.py +4 -3
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/tool_policy/loader.py +7 -1
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/bus/audit_exporter.py +1 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/config.py +15 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/diagnostics/mcp_inventory.py +43 -0
- agentmetry-0.9.2/agentmetry/core/operator_identity.py +87 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/version.py +1 -1
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/hooks/ingest.py +67 -24
- {agentmetry-0.9.0 → agentmetry-0.9.2}/pyproject.toml +1 -1
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/conftest.py +8 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_audit_sinks.py +65 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_cli_commands.py +6 -1
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_dlp_scanner.py +10 -7
- agentmetry-0.9.2/tests/test_hook_import_cost.py +179 -0
- agentmetry-0.9.2/tests/test_hook_real_process.py +100 -0
- agentmetry-0.9.2/tests/test_mcp_inventory_event.py +189 -0
- agentmetry-0.9.2/tests/test_operator_attribution.py +280 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_readme_claims.py +22 -3
- agentmetry-0.9.0/agentmetry/core/audit/run_context.py +0 -129
- {agentmetry-0.9.0 → agentmetry-0.9.2}/.dockerignore +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/.env.agentmetry-demo +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/.gitignore +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/Dockerfile +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/README.md +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/__init__.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/api/__init__.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/api/main.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/api/routes/__init__.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/api/websocket.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/api/ws_bridge.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/cli/__main__.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/__init__.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/__init__.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/adapters/__init__.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/adapters/agt.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/adapters/chronicle.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/adapters/cloudevents.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/adapters/ecs.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/adapters/splunk.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/alerts.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/atlas.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/canonical.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/compliance_digest.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/__init__.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/benchmark.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_approval_denied_then_executed.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_arbitrary_host_stage_execute.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_autonomous_unapproved_write.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_credential_exfil.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_credential_then_cloud_api.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_destructive_delete_burst.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_discovery_then_collect.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_dotfile_then_git_push.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_encoded_command_download.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_env_credential_exfil.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_hashed_only_no_command.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_interpreter_egress.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_pr_merged_without_review.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_proc_substitution_cradle.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_remote_pipe_to_shell.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_remote_staging_then_execute.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_secret_manager_then_egress.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_session_tool_burst.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_single_command_exfil.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_single_quoted_interpreter_egress.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_ssh_directory_exfil.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_subagent_swarm.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_timestamp_collision.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/attack_untrusted_input_then_action.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_authoring_merge_fixtures.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_autonomous_after_approval.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_build_artifact_cleanup.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_ci_artifact_download.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_commit_message_names_cloud_clis.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_database_migration.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_dependency_install_and_build.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_download_release_archive.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_env_example_read.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_fetch_data_then_run_repo_script.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_fetch_dataset_then_analyse.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_fetch_lockfile_then_install.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_fetch_piped_into_own_script.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_git_review_and_push.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_github_raw_docs_then_script.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_human_driven_deletes.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_local_api_probing.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_long_but_calm_session.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_loopback_is_not_egress.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_loopback_pipe_to_interpreter.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_mcp_text_mentions_credential_path.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_ordinary_development.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_package_manager_after_fetch.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_reading_config_that_is_not_secret.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_remote_api_call_no_credentials.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_research_then_docs.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_reversed_order_is_not_exfil.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_secret_names_and_docs.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_test_and_fix_loop.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/benign_writing_about_credentials.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/corpus/corpus.yaml +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/engine.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/live.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/live_store.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/models.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/rules.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/traits.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/yaml_config.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/detection/yaml_rules.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/dlp/__init__.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/dlp/models.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/dogfood.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/hashing.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/hook_bootstrap.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/identity.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/migrate.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/mitre.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/policy.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/redaction.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/replay.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/spool.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/tool_policy/__init__.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/tool_policy/capability.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/tool_policy/models.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/trail_anchor.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/trail_chain.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/trail_db.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/audit/trail_merkle.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/auth.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/bus/__init__.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/bus/bridges.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/bus/bus.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/bus/events.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/bus/outbox.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/diagnostics/__init__.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/diagnostics/autostart.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/diagnostics/doctor.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/diagnostics/driver_paths.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/diagnostics/env_file.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/diagnostics/hook_coverage.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/diagnostics/mcp_schema.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/drivers/__init__.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/drivers/host.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/drivers/permissions.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/drivers/spec.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/extensions.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/core/health.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/hooks/__init__.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/policies/detection/manifest.yaml +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/policies/dlp/manifest.yaml +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/policies/opa/agent_rules.rego +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/agentmetry/policies/tool/manifest.yaml +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/fixtures/agt_filesink_exfil.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/fixtures/agt_filesink_mixed.jsonl +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/fixtures/fake_mcp_server.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/fixtures/otel_claude_code_session.json +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_agentmetry_audit.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_agentmetry_ingest_client.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_agt_adapter.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_ask_gate_phase_a.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_atlas_detection_enrichment.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_atlas_mapping.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_audit_stats.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_audit_tail.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_auth.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_autostart.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_boot_sequence.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_burst_windows.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_chinese_agent_hooks.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_chinese_agent_sprint_b.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_chinese_agent_sprint_c.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_chronicle_adapter.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_cli_backup.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_cli_env_var_names.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_cloudevents_adapter.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_compliance_digest.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_crewai_adapter.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_detection_adi.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_detection_benchmark.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_detection_default_config.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_detection_disposition.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_detection_disposition_api.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_detection_duplicate_emission.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_detection_emit_durability.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_detection_engine.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_detection_evasion.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_detection_hf_incident.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_detection_quoted_command_words.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_detection_rule_identity.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_detection_rules_v2.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_dlp_agent_env_override.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_dlp_invisible_unicode.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_dlp_markdown_exfil.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_doctor.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_dogfood.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_dogfood_freeze.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_driver_paths.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_ecs_threat.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_event_bus.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_evidence_pack.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_extensions.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_external_ingest.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_forwarding_shape.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_funding_manifest.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_heartbeat.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_hook_bootstrap.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_hook_coverage.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_hook_decision_format.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_hook_enforcement_timing.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_hook_spool.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_identity_enrich.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_ingest_roundtrip.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_isolation.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_launch_targets.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_live_detection.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_live_detection_store.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_mcp_audit_proxy.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_mcp_concealed_text.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_mcp_inventory.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_mcp_meta_fingerprint.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_mcp_schema.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_mitre_dlp.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_opensre_adapter.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_otel_ingest.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_powershell_encoding.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_redaction.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_replay.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_sigma_pack.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_static_serving.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_subagent_lifecycle.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_suite_leaves_real_home_alone.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_tool_policy.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_tool_policy_agent_cli.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_tool_policy_git_hooks.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_trail_anchor.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_trail_chain.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_trail_concurrency.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_trail_merkle.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_unattended_agent_policy.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_version.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tests/test_yaml_detection_rules.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tools/generate_sigma_pack.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tools/mcp_audit_proxy.py +0 -0
- {agentmetry-0.9.0 → agentmetry-0.9.2}/tools/vault_fs_server.py +0 -0
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
# Demo MCP vault path (relative to this directory)
|
|
6
6
|
AGENTMETRY_VAULT_PATH=../../vault
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
# Who ran the agent. Leave empty and every event records the OS account the
|
|
9
|
+
# agent ran as (DOMAIN\user on a domain or Entra Windows account). Set it to
|
|
10
|
+
# override: an email, or a pseudonym to keep account names out of a SIEM.
|
|
11
|
+
AGENTMETRY_OPERATOR_ID=
|
|
9
12
|
AGENTMETRY_FLEET_ID=
|
|
10
13
|
AGENTMETRY_API_KEY=
|
|
11
14
|
|
|
@@ -24,4 +27,9 @@ AGENTMETRY_AUDIT_SINK=file
|
|
|
24
27
|
# host. See docs/anchoring.md.
|
|
25
28
|
# AGENTMETRY_ANCHOR_LOG=../agentmetry-anchors/home-lab/agentmetry-trail.anchors.jsonl
|
|
26
29
|
|
|
30
|
+
# The configured MCP servers as an `mcp_inventory` event, for a fleet that wants
|
|
31
|
+
# them in the SIEM: names, launchers, package names and hosts, never arguments or
|
|
32
|
+
# env values. Off by default, because the heartbeat names no server.
|
|
33
|
+
# AGENTMETRY_MCP_INVENTORY=1
|
|
34
|
+
|
|
27
35
|
# More tuning: see repo root .env.example and docs/agentmetry-dogfood-checklist.md
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: agentmetry
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.2
|
|
4
4
|
Summary: Local-first flight recorder for AI coding agents: hash-chained audit trail, MITRE-mapped sequence detection, DLP
|
|
5
5
|
Project-URL: Homepage, https://agentmetry.ai
|
|
6
6
|
Project-URL: Source, https://github.com/blitzcrieg1/agentmetry
|
|
@@ -37,7 +37,7 @@ Provides-Extra: dev
|
|
|
37
37
|
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
38
38
|
Requires-Dist: pytest-cov>=6.0; extra == 'dev'
|
|
39
39
|
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
40
|
-
Requires-Dist: ruff==0.16.
|
|
40
|
+
Requires-Dist: ruff==0.16.9; extra == 'dev'
|
|
41
41
|
Description-Content-Type: text/markdown
|
|
42
42
|
|
|
43
43
|
# 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,12 @@ def cmd_detections(args: argparse.Namespace) -> int:
|
|
|
284
284
|
return 0
|
|
285
285
|
|
|
286
286
|
|
|
287
|
+
def _triage_operator() -> str:
|
|
288
|
+
from agentmetry.core.operator_identity import os_operator, stated
|
|
289
|
+
|
|
290
|
+
return stated(os.environ.get("AGENTMETRY_OPERATOR_ID", "")) or os_operator()
|
|
291
|
+
|
|
292
|
+
|
|
287
293
|
def cmd_disposition(args: argparse.Namespace) -> int:
|
|
288
294
|
"""Close a detection from the shell, using the same API as the dashboard."""
|
|
289
295
|
note = args.note.strip()
|
|
@@ -299,7 +305,8 @@ def cmd_disposition(args: argparse.Namespace) -> int:
|
|
|
299
305
|
"rule_id": args.rule_id,
|
|
300
306
|
"status": args.status,
|
|
301
307
|
"note": note,
|
|
302
|
-
|
|
308
|
+
# The CLI runs as the person triaging; the orchestrator may not.
|
|
309
|
+
"decided_by": args.decided_by or _triage_operator(),
|
|
303
310
|
},
|
|
304
311
|
headers=_api_headers(),
|
|
305
312
|
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.
|
|
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
|
|
447
|
-
"initiator": {"type": "human", "id": decided_by
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
26
|
+
cfg = policy_settings()
|
|
27
|
+
rules = load_dlp_rules(cfg.dlp_rules_path)
|
|
25
28
|
for r in rules:
|
|
26
|
-
if not
|
|
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 =
|
|
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":
|
|
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
|
|
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":
|
|
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(
|
|
@@ -297,11 +297,14 @@ async def heartbeat_forever(interval: float | None = None) -> None:
|
|
|
297
297
|
"""
|
|
298
298
|
import asyncio
|
|
299
299
|
|
|
300
|
+
from agentmetry.core.audit.mcp_inventory_event import InventoryState, maybe_emit_inventory
|
|
301
|
+
|
|
300
302
|
period = interval if interval is not None else interval_seconds()
|
|
301
303
|
if not period:
|
|
302
304
|
logger.info("Heartbeat disabled (AGENTMETRY_HEARTBEAT_SECONDS=0)")
|
|
303
305
|
return
|
|
304
306
|
|
|
307
|
+
inventory_state = InventoryState()
|
|
305
308
|
while True:
|
|
306
309
|
try:
|
|
307
310
|
event = await emit_heartbeat()
|
|
@@ -310,4 +313,10 @@ async def heartbeat_forever(interval: float | None = None) -> None:
|
|
|
310
313
|
except Exception:
|
|
311
314
|
# Never let attestation kill the recorder it attests for.
|
|
312
315
|
logger.exception("Heartbeat failed")
|
|
316
|
+
try:
|
|
317
|
+
# Opt-in (#169). Separate from the beat, so the beat stays
|
|
318
|
+
# name-free, and in its own try, so it cannot stop the beat.
|
|
319
|
+
await maybe_emit_inventory(inventory_state)
|
|
320
|
+
except Exception:
|
|
321
|
+
logger.exception("MCP inventory event failed")
|
|
313
322
|
await asyncio.sleep(period)
|
|
@@ -144,6 +144,7 @@ def _get_sink():
|
|
|
144
144
|
webhook_url=settings.audit_webhook_url,
|
|
145
145
|
webhook_timeout_seconds=settings.audit_webhook_timeout_seconds,
|
|
146
146
|
webhook_format=settings.audit_webhook_format,
|
|
147
|
+
webhook_token=settings.audit_webhook_token,
|
|
147
148
|
elastic_url=settings.audit_elastic_url,
|
|
148
149
|
elastic_index=settings.audit_elastic_index,
|
|
149
150
|
elastic_api_key=settings.audit_elastic_api_key,
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""The configured MCP servers as an event a SIEM can see, when an operator asks (#169).
|
|
2
|
+
|
|
3
|
+
`agentmetry mcp` answers "what are the agents on this machine wired to", but
|
|
4
|
+
only for whoever runs it, on that machine. Nothing reached the trail, so no SIEM
|
|
5
|
+
could answer "which machines have this server" or "when did it first appear".
|
|
6
|
+
|
|
7
|
+
This is deliberately not the heartbeat. The heartbeat goes to every sink on
|
|
8
|
+
every install and commits to the MCP surface by digest alone, so that recording
|
|
9
|
+
your own laptop never ships your tool inventory to a SOC. That property stays.
|
|
10
|
+
The inventory is a separate event, off unless `AGENTMETRY_MCP_INVENTORY=1`, for
|
|
11
|
+
the fleet that wants it.
|
|
12
|
+
|
|
13
|
+
When on, it is emitted when the recorder starts, whenever the configured
|
|
14
|
+
surface changes, and once a day regardless, so a dashboard over the last 24
|
|
15
|
+
hours sees every host. Each server is reduced to `McpServer.wire_entry()`: no
|
|
16
|
+
arguments, no environment, no URL beyond its host.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
import time
|
|
23
|
+
import uuid
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from agentmetry.core.audit.canonical import SCHEMA_VERSION
|
|
28
|
+
from agentmetry.core.audit.identity import identity_fields
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
#: Re-emit an unchanged inventory this often, so a fleet view over a day is whole.
|
|
33
|
+
SNAPSHOT_SECONDS = 24 * 3600
|
|
34
|
+
|
|
35
|
+
#: The event goes to every sink. A machine with more servers than this is
|
|
36
|
+
#: reported by count, with the list cut and `truncated` set.
|
|
37
|
+
LIMIT = 100
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def enabled() -> bool:
|
|
41
|
+
from agentmetry.core.config import settings
|
|
42
|
+
|
|
43
|
+
return bool(getattr(settings, "mcp_inventory_enabled", False))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class InventoryState:
|
|
48
|
+
"""What was last emitted, so an unchanged surface is not re-sent every beat."""
|
|
49
|
+
|
|
50
|
+
digest: str | None = None
|
|
51
|
+
emitted_at: float = 0.0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_inventory_event(now_utc: str, inventory: Any, *, outcome: str) -> dict[str, Any]:
|
|
55
|
+
servers = list(inventory.servers)
|
|
56
|
+
entries = [s.wire_entry() for s in servers[:LIMIT]]
|
|
57
|
+
flagged = sum(1 for e in entries if e["findings"])
|
|
58
|
+
return {
|
|
59
|
+
"schema_version": SCHEMA_VERSION,
|
|
60
|
+
"event_id": str(uuid.uuid4()),
|
|
61
|
+
"session_id": "",
|
|
62
|
+
"correlation_id": "",
|
|
63
|
+
"timestamp_utc": now_utc,
|
|
64
|
+
**identity_fields(),
|
|
65
|
+
"source_topic": "agentmetry/mcp_inventory",
|
|
66
|
+
"source": {"tier": "agentmetry", "app": "agentmetry", "adapter": "mcp_inventory"},
|
|
67
|
+
"initiator": {"actor_type": "system", "trigger": "scheduled", "operator_id": ""},
|
|
68
|
+
"actor": {"type": "system", "id": "agentmetry", "role": "recorder"},
|
|
69
|
+
"action": {
|
|
70
|
+
"type": "mcp_inventory",
|
|
71
|
+
"outcome": outcome,
|
|
72
|
+
"reason": f"{len(servers)} MCP server(s) configured, {flagged} flagged",
|
|
73
|
+
},
|
|
74
|
+
"agent": {"name": "agentmetry", "skill_id": ""},
|
|
75
|
+
"mcp_inventory": {
|
|
76
|
+
"servers": entries,
|
|
77
|
+
"server_count": len(servers),
|
|
78
|
+
"truncated": len(servers) > LIMIT,
|
|
79
|
+
"config_digest": inventory.digest()[:16] if servers else "",
|
|
80
|
+
"files_read": len(inventory.files_read),
|
|
81
|
+
"files_unreadable": len(inventory.unreadable),
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def due(state: InventoryState, digest: str, now_monotonic: float) -> str | None:
|
|
87
|
+
"""`snapshot`, `changed`, or None when there is nothing to say."""
|
|
88
|
+
if state.digest is None:
|
|
89
|
+
return "snapshot"
|
|
90
|
+
if digest != state.digest:
|
|
91
|
+
return "changed"
|
|
92
|
+
if now_monotonic - state.emitted_at >= SNAPSHOT_SECONDS:
|
|
93
|
+
return "snapshot"
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def maybe_emit_inventory(state: InventoryState) -> dict[str, Any] | None:
|
|
98
|
+
"""Emit the inventory if the operator turned it on and there is news."""
|
|
99
|
+
if not enabled():
|
|
100
|
+
return None
|
|
101
|
+
from datetime import datetime, timezone
|
|
102
|
+
|
|
103
|
+
from agentmetry.core.audit.ingest import _get_sink
|
|
104
|
+
from agentmetry.core.audit.trail_db import get_trail_db
|
|
105
|
+
from agentmetry.core.diagnostics.mcp_inventory import collect
|
|
106
|
+
|
|
107
|
+
inventory = collect()
|
|
108
|
+
digest = inventory.digest() if inventory.servers else ""
|
|
109
|
+
now = time.monotonic()
|
|
110
|
+
outcome = due(state, digest, now)
|
|
111
|
+
if outcome is None:
|
|
112
|
+
return None
|
|
113
|
+
event = build_inventory_event(datetime.now(timezone.utc).isoformat(), inventory, outcome=outcome)
|
|
114
|
+
# The heartbeat's durability contract: the local insert is the guarantee,
|
|
115
|
+
# network sinks swallow their own failures.
|
|
116
|
+
get_trail_db().insert(event)
|
|
117
|
+
await _get_sink().emit(event)
|
|
118
|
+
state.digest, state.emitted_at = digest, now
|
|
119
|
+
return event
|
|
@@ -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": "
|
|
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
|
+
)
|