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