agentmetry 0.7.0__tar.gz → 0.8.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agentmetry-0.7.0 → agentmetry-0.8.0}/PKG-INFO +3 -3
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/api/routes/audit.py +4 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/cli/__init__.py +92 -1
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/ingest.py +40 -7
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/diagnostics/mcp_schema.py +294 -3
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/version.py +1 -1
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/policies/dlp/manifest.yaml +26 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/pyproject.toml +7 -2
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_cli_commands.py +242 -0
- agentmetry-0.8.0/tests/test_cli_env_var_names.py +90 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_dlp_scanner.py +49 -0
- agentmetry-0.8.0/tests/test_funding_manifest.py +96 -0
- agentmetry-0.8.0/tests/test_identity_enrich.py +348 -0
- agentmetry-0.8.0/tests/test_mcp_concealed_text.py +302 -0
- agentmetry-0.8.0/tests/test_mcp_meta_fingerprint.py +203 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_mcp_schema.py +26 -5
- agentmetry-0.8.0/tests/test_powershell_encoding.py +102 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_readme_claims.py +43 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tools/mcp_audit_proxy.py +6 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/.dockerignore +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/.env.agentmetry-demo +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/.env.example +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/.gitignore +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/Dockerfile +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/README.md +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/__init__.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/api/__init__.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/api/main.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/api/routes/__init__.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/api/websocket.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/api/ws_bridge.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/cli/__main__.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/__init__.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/__init__.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/adapters/__init__.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/adapters/agt.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/adapters/chronicle.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/adapters/cloudevents.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/adapters/ecs.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/adapters/splunk.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/alerts.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/atlas.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/canonical.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/compliance_digest.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/__init__.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/benchmark.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_approval_denied_then_executed.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_arbitrary_host_stage_execute.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_autonomous_unapproved_write.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_credential_exfil.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_credential_then_cloud_api.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_destructive_delete_burst.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_discovery_then_collect.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_dotfile_then_git_push.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_encoded_command_download.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_env_credential_exfil.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_hashed_only_no_command.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_interpreter_egress.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_pr_merged_without_review.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_proc_substitution_cradle.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_remote_pipe_to_shell.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_remote_staging_then_execute.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_secret_manager_then_egress.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_session_tool_burst.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_single_command_exfil.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_single_quoted_interpreter_egress.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_ssh_directory_exfil.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_subagent_swarm.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_timestamp_collision.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/attack_untrusted_input_then_action.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_authoring_merge_fixtures.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_autonomous_after_approval.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_build_artifact_cleanup.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_ci_artifact_download.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_commit_message_names_cloud_clis.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_database_migration.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_dependency_install_and_build.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_download_release_archive.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_env_example_read.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_fetch_data_then_run_repo_script.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_fetch_dataset_then_analyse.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_fetch_lockfile_then_install.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_fetch_piped_into_own_script.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_git_review_and_push.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_github_raw_docs_then_script.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_human_driven_deletes.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_local_api_probing.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_long_but_calm_session.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_loopback_is_not_egress.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_loopback_pipe_to_interpreter.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_mcp_text_mentions_credential_path.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_ordinary_development.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_package_manager_after_fetch.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_reading_config_that_is_not_secret.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_remote_api_call_no_credentials.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_research_then_docs.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_reversed_order_is_not_exfil.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_secret_names_and_docs.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_test_and_fix_loop.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/benign_writing_about_credentials.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/corpus/corpus.yaml +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/disposition.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/engine.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/live.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/live_store.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/models.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/rules.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/traits.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/yaml_config.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/detection/yaml_rules.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/dlp/__init__.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/dlp/loader.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/dlp/models.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/dlp/scanner.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/dogfood.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/evidence_pack.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/external.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/hashing.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/heartbeat.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/hook_bootstrap.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/identity.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/migrate.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/mitre.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/policy.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/redaction.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/replay.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/run_context.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/sinks.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/spool.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/tool_policy/__init__.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/tool_policy/evaluator.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/tool_policy/loader.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/tool_policy/models.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/trail_anchor.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/trail_chain.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/trail_db.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/audit/trail_merkle.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/auth.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/bus/__init__.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/bus/audit_exporter.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/bus/bridges.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/bus/bus.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/bus/events.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/bus/outbox.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/config.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/diagnostics/__init__.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/diagnostics/autostart.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/diagnostics/doctor.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/diagnostics/driver_paths.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/diagnostics/env_file.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/diagnostics/hook_coverage.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/diagnostics/mcp_inventory.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/drivers/__init__.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/drivers/host.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/drivers/permissions.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/drivers/spec.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/extensions.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/core/health.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/hooks/__init__.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/hooks/ingest.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/policies/detection/manifest.yaml +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/policies/opa/agent_rules.rego +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/agentmetry/policies/tool/manifest.yaml +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/conftest.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/fixtures/agt_filesink_exfil.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/fixtures/agt_filesink_mixed.jsonl +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/fixtures/fake_mcp_server.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_agentmetry_audit.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_agentmetry_ingest_client.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_agt_adapter.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_atlas_detection_enrichment.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_atlas_mapping.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_audit_sinks.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_audit_stats.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_audit_tail.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_auth.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_autostart.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_boot_sequence.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_burst_windows.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_chinese_agent_hooks.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_chinese_agent_sprint_b.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_chinese_agent_sprint_c.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_chronicle_adapter.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_cli_backup.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_cloudevents_adapter.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_compliance_digest.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_crewai_adapter.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_detection_adi.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_detection_benchmark.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_detection_default_config.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_detection_disposition.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_detection_disposition_api.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_detection_duplicate_emission.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_detection_emit_durability.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_detection_engine.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_detection_evasion.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_detection_hf_incident.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_detection_quoted_command_words.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_detection_rule_identity.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_detection_rules_v2.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_dlp_agent_env_override.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_dlp_invisible_unicode.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_dlp_markdown_exfil.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_doctor.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_dogfood.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_dogfood_freeze.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_driver_paths.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_ecs_threat.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_event_bus.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_evidence_pack.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_extensions.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_external_ingest.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_forwarding_shape.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_heartbeat.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_hook_bootstrap.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_hook_coverage.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_hook_enforcement_timing.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_hook_spool.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_ingest_roundtrip.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_isolation.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_launch_targets.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_live_detection.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_live_detection_store.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_mcp_audit_proxy.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_mcp_inventory.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_mitre_dlp.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_opensre_adapter.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_redaction.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_replay.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_sigma_pack.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_static_serving.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_subagent_lifecycle.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_tool_policy.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_tool_policy_agent_cli.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_tool_policy_git_hooks.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_trail_anchor.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_trail_chain.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_trail_concurrency.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_trail_merkle.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_unattended_agent_policy.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_version.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tests/test_yaml_detection_rules.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tools/generate_sigma_pack.py +0 -0
- {agentmetry-0.7.0 → agentmetry-0.8.0}/tools/vault_fs_server.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: agentmetry
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Local-first flight recorder for AI coding agents: hash-chained audit trail, MITRE-mapped sequence detection, DLP
|
|
5
5
|
Project-URL: Homepage, https://agentmetry.ai
|
|
6
6
|
Project-URL: Source, https://github.com/blitzcrieg1/agentmetry
|
|
@@ -25,7 +25,7 @@ Requires-Python: >=3.11
|
|
|
25
25
|
Requires-Dist: aiosqlite>=0.20.0
|
|
26
26
|
Requires-Dist: fastapi>=0.115.0
|
|
27
27
|
Requires-Dist: httpx>=0.28.0
|
|
28
|
-
Requires-Dist: mcp
|
|
28
|
+
Requires-Dist: mcp<2,>=1.2
|
|
29
29
|
Requires-Dist: pydantic-settings>=2.6.0
|
|
30
30
|
Requires-Dist: pydantic>=2.9.0
|
|
31
31
|
Requires-Dist: pyyaml>=6.0
|
|
@@ -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.6; extra == 'dev'
|
|
41
41
|
Description-Content-Type: text/markdown
|
|
42
42
|
|
|
43
43
|
# Agentmetry
|
|
@@ -122,6 +122,10 @@ class ExternalIngestBody(BaseModel):
|
|
|
122
122
|
# fields that separate a shipped release from a rug pull. All hashes and
|
|
123
123
|
# flags: no tool name, no description, nothing model-visible.
|
|
124
124
|
schema_tool_digests: dict[str, str] = Field(default_factory=dict)
|
|
125
|
+
# Concealed-character counts per category. Declared here because pydantic
|
|
126
|
+
# drops what it does not know, silently, which has already cost this file
|
|
127
|
+
# two shipped bugs.
|
|
128
|
+
schema_concealed: dict[str, int] = Field(default_factory=dict)
|
|
125
129
|
server_version: str = ""
|
|
126
130
|
list_changed: bool | None = None
|
|
127
131
|
|
|
@@ -34,6 +34,7 @@ _BACKUP_EXCLUDE_DIRS = {"logs"}
|
|
|
34
34
|
logger = logging.getLogger(__name__)
|
|
35
35
|
|
|
36
36
|
_BACKUP_EXCLUDE_SUFFIXES = {".pid"}
|
|
37
|
+
_CLOSING_DISPOSITIONS = ("resolved", "false_positive", "risk_accepted")
|
|
37
38
|
|
|
38
39
|
|
|
39
40
|
def _base_url(port: int, host: str = "127.0.0.1") -> str:
|
|
@@ -41,6 +42,15 @@ def _base_url(port: int, host: str = "127.0.0.1") -> str:
|
|
|
41
42
|
return f"http://{display}:{port}"
|
|
42
43
|
|
|
43
44
|
|
|
45
|
+
def _api_base_url(port: int) -> str:
|
|
46
|
+
return os.environ.get("AGENTMETRY_URL", "").strip().rstrip("/") or _base_url(port)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _api_headers() -> dict[str, str]:
|
|
50
|
+
key = os.environ.get("AGENTMETRY_API_KEY", "").strip()
|
|
51
|
+
return {"X-API-Key": key} if key else {}
|
|
52
|
+
|
|
53
|
+
|
|
44
54
|
def _lan_ip() -> str | None:
|
|
45
55
|
"""Best-effort local IPv4 for phone/LAN access hints."""
|
|
46
56
|
try:
|
|
@@ -219,7 +229,8 @@ def cmd_stats(args: argparse.Namespace) -> int:
|
|
|
219
229
|
return 1
|
|
220
230
|
|
|
221
231
|
if not data.get("enabled", True):
|
|
222
|
-
print("Audit export disabled
|
|
232
|
+
print("Audit export is disabled, so no events are recorded and stats are empty.")
|
|
233
|
+
print("Set AGENTMETRY_AUDIT_EXPORT_ENABLED=1 and restart.")
|
|
223
234
|
return 1
|
|
224
235
|
|
|
225
236
|
days = data.get("window_days", args.days)
|
|
@@ -241,6 +252,76 @@ def cmd_stats(args: argparse.Namespace) -> int:
|
|
|
241
252
|
return 0
|
|
242
253
|
|
|
243
254
|
|
|
255
|
+
def cmd_detections(args: argparse.Namespace) -> int:
|
|
256
|
+
try:
|
|
257
|
+
data = httpx.get(
|
|
258
|
+
f"{_base_url(args.port)}/api/v1/audit/detections/{args.correlation_id}",
|
|
259
|
+
timeout=10.0,
|
|
260
|
+
).json()
|
|
261
|
+
except Exception:
|
|
262
|
+
print("Not running - start Agentmetry first (detections reads via the API).")
|
|
263
|
+
return 1
|
|
264
|
+
if not data.get("enabled", True):
|
|
265
|
+
print("Audit export is disabled, so no sessions are recorded.")
|
|
266
|
+
print("Set AGENTMETRY_AUDIT_EXPORT_ENABLED=1 and restart.")
|
|
267
|
+
return 1
|
|
268
|
+
detections = data.get("detections") or []
|
|
269
|
+
if not detections:
|
|
270
|
+
print(f"No detections for {args.correlation_id}.")
|
|
271
|
+
return 0
|
|
272
|
+
rule_width = max(len("Rule"), *(len(str(d.get("rule_id", ""))) for d in detections))
|
|
273
|
+
severity_width = max(
|
|
274
|
+
len("Severity"), *(len(str(d.get("severity", ""))) for d in detections)
|
|
275
|
+
)
|
|
276
|
+
print(f"{'Rule':<{rule_width}} {'Severity':<{severity_width}} Summary")
|
|
277
|
+
print(f"{'-' * rule_width} {'-' * severity_width} {'-' * len('Summary')}")
|
|
278
|
+
for detection in detections:
|
|
279
|
+
print(
|
|
280
|
+
f"{str(detection.get('rule_id', '')):<{rule_width}} "
|
|
281
|
+
f"{str(detection.get('severity', '')):<{severity_width}} "
|
|
282
|
+
f"{detection.get('summary', '')}"
|
|
283
|
+
)
|
|
284
|
+
return 0
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def cmd_disposition(args: argparse.Namespace) -> int:
|
|
288
|
+
"""Close a detection from the shell, using the same API as the dashboard."""
|
|
289
|
+
note = args.note.strip()
|
|
290
|
+
if args.status in {"false_positive", "risk_accepted"} and not note:
|
|
291
|
+
print(f"--note is required for {args.status}")
|
|
292
|
+
return 1
|
|
293
|
+
|
|
294
|
+
try:
|
|
295
|
+
resp = httpx.post(
|
|
296
|
+
f"{_api_base_url(args.port)}/api/v1/audit/detections/disposition",
|
|
297
|
+
json={
|
|
298
|
+
"correlation_id": args.correlation_id,
|
|
299
|
+
"rule_id": args.rule_id,
|
|
300
|
+
"status": args.status,
|
|
301
|
+
"note": note,
|
|
302
|
+
"decided_by": args.decided_by,
|
|
303
|
+
},
|
|
304
|
+
headers=_api_headers(),
|
|
305
|
+
timeout=10.0,
|
|
306
|
+
)
|
|
307
|
+
except Exception:
|
|
308
|
+
print("Not running - start Agentmetry first (disposition writes via the API).")
|
|
309
|
+
return 1
|
|
310
|
+
|
|
311
|
+
if resp.status_code >= 400:
|
|
312
|
+
try:
|
|
313
|
+
detail = resp.json().get("detail")
|
|
314
|
+
except Exception:
|
|
315
|
+
detail = None
|
|
316
|
+
print(f"FAILED — {detail or resp.text or f'HTTP {resp.status_code}'}")
|
|
317
|
+
return 1
|
|
318
|
+
|
|
319
|
+
current = resp.json().get("disposition", {})
|
|
320
|
+
status = current.get("status", args.status)
|
|
321
|
+
print(f"Disposition set: {args.correlation_id} {args.rule_id} -> {status}")
|
|
322
|
+
return 0
|
|
323
|
+
|
|
324
|
+
|
|
244
325
|
def cmd_logs(args: argparse.Namespace) -> int:
|
|
245
326
|
log = _DATA_DIR / "logs" / "orchestrator.log"
|
|
246
327
|
if not log.exists():
|
|
@@ -1132,6 +1213,14 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1132
1213
|
sub.add_parser("status", help="orchestrator health and audit export status")
|
|
1133
1214
|
stats = sub.add_parser("stats", help="audit trail metrics for dogfood (events, detections)")
|
|
1134
1215
|
stats.add_argument("--days", type=int, default=7)
|
|
1216
|
+
detections = sub.add_parser("detections", help="list detections for one session")
|
|
1217
|
+
detections.add_argument("correlation_id", help="correlation_id / session id")
|
|
1218
|
+
disposition = sub.add_parser("disposition", help="close an audit detection from the shell")
|
|
1219
|
+
disposition.add_argument("correlation_id", help="session/correlation id that owns the detection")
|
|
1220
|
+
disposition.add_argument("rule_id", help="detection rule id to close")
|
|
1221
|
+
disposition.add_argument("--status", required=True, choices=_CLOSING_DISPOSITIONS)
|
|
1222
|
+
disposition.add_argument("--note", default="")
|
|
1223
|
+
disposition.add_argument("--decided-by", default="")
|
|
1135
1224
|
logs = sub.add_parser("logs", help="tail the orchestrator log")
|
|
1136
1225
|
logs.add_argument("-n", "--lines", type=int, default=50)
|
|
1137
1226
|
logs.add_argument("-f", "--follow", action="store_true")
|
|
@@ -1273,6 +1362,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1273
1362
|
"hook": cmd_hook,
|
|
1274
1363
|
"status": cmd_status,
|
|
1275
1364
|
"stats": cmd_stats,
|
|
1365
|
+
"detections": cmd_detections,
|
|
1366
|
+
"disposition": cmd_disposition,
|
|
1276
1367
|
"logs": cmd_logs,
|
|
1277
1368
|
"backup": cmd_backup,
|
|
1278
1369
|
"restore": cmd_restore,
|
|
@@ -187,6 +187,7 @@ class _SchemaFields(NamedTuple):
|
|
|
187
187
|
server_version: str
|
|
188
188
|
list_changed: bool | None
|
|
189
189
|
tool_digests: dict[str, str]
|
|
190
|
+
concealed: dict[str, int]
|
|
190
191
|
|
|
191
192
|
|
|
192
193
|
def _schema_payload_fields(payload: dict[str, Any]) -> _SchemaFields:
|
|
@@ -202,6 +203,12 @@ def _schema_payload_fields(payload: dict[str, Any]) -> _SchemaFields:
|
|
|
202
203
|
list_changed = payload.get("list_changed")
|
|
203
204
|
if list_changed is not None and not isinstance(list_changed, bool):
|
|
204
205
|
list_changed = None
|
|
206
|
+
raw_concealed = payload.get("schema_concealed")
|
|
207
|
+
concealed = (
|
|
208
|
+
{str(k): int(v) for k, v in raw_concealed.items() if isinstance(v, int)}
|
|
209
|
+
if isinstance(raw_concealed, dict)
|
|
210
|
+
else {}
|
|
211
|
+
)
|
|
205
212
|
raw_digests = payload.get("schema_tool_digests")
|
|
206
213
|
tool_digests = (
|
|
207
214
|
{str(k): str(v) for k, v in raw_digests.items() if isinstance(v, str)}
|
|
@@ -209,7 +216,8 @@ def _schema_payload_fields(payload: dict[str, Any]) -> _SchemaFields:
|
|
|
209
216
|
else {}
|
|
210
217
|
)
|
|
211
218
|
return _SchemaFields(
|
|
212
|
-
server, fingerprint, tool_count, source, server_version, list_changed,
|
|
219
|
+
server, fingerprint, tool_count, source, server_version, list_changed,
|
|
220
|
+
tool_digests, concealed,
|
|
213
221
|
)
|
|
214
222
|
|
|
215
223
|
|
|
@@ -232,22 +240,47 @@ def build_schema_canonical(
|
|
|
232
240
|
from agentmetry.core.audit.canonical import SCHEMA_VERSION
|
|
233
241
|
from agentmetry.core.audit.identity import identity_fields
|
|
234
242
|
from agentmetry.core.audit.atlas import RUG_PULL
|
|
235
|
-
from agentmetry.core.diagnostics.mcp_schema import server_id
|
|
243
|
+
from agentmetry.core.diagnostics.mcp_schema import FORMATTING_CATEGORY, server_id
|
|
236
244
|
|
|
237
245
|
fields = _schema_payload_fields(payload)
|
|
238
246
|
server, fingerprint, tool_count = fields.server, fields.fingerprint, fields.tool_count
|
|
239
247
|
server_version, list_changed = fields.server_version, fields.list_changed
|
|
240
248
|
outcome = "changed" if status == "changed" else "success"
|
|
241
|
-
|
|
242
|
-
"MCP tool schema changed; config may be unchanged (rug-pull candidate)"
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
249
|
+
_REASONS = {
|
|
250
|
+
"changed": "MCP tool schema changed; config may be unchanged (rug-pull candidate)",
|
|
251
|
+
# Says what it is and what it is not. A re-baseline adopts whatever the
|
|
252
|
+
# server serves today without comparing it to anything, so if the server
|
|
253
|
+
# was already poisoned before our hashing changed, this event is the
|
|
254
|
+
# moment that state became the trusted one. `new` would have hidden that
|
|
255
|
+
# behind a word that also means "nothing was ever wrong here" (#146).
|
|
256
|
+
"rebaselined": (
|
|
257
|
+
"MCP tool schema re-baselined after a fingerprint change; "
|
|
258
|
+
"trust-on-first-use, not compared against the previous baseline"
|
|
259
|
+
),
|
|
260
|
+
}
|
|
261
|
+
reason = _REASONS.get(status, "MCP tool schema observed")
|
|
262
|
+
_formatting = {k: v for k, v in fields.concealed.items() if k == FORMATTING_CATEGORY}
|
|
263
|
+
_concealment = {k: v for k, v in fields.concealed.items() if k != FORMATTING_CATEGORY}
|
|
246
264
|
mcp_schema: dict[str, Any] = {
|
|
247
265
|
"server_id": server_id(server) if server else "",
|
|
248
266
|
"fingerprint": fingerprint,
|
|
249
267
|
"tool_count": tool_count,
|
|
250
268
|
"status": status,
|
|
269
|
+
# A baseline nobody has verified against a predecessor. Structured
|
|
270
|
+
# rather than left in the reason string so a SIEM can count them
|
|
271
|
+
# instead of matching prose.
|
|
272
|
+
**({"unverified_baseline": True} if status == "rebaselined" else {}),
|
|
273
|
+
# Concealed characters in strings the model reads. Counts per category,
|
|
274
|
+
# never the text. Attached on every status including `new`, because
|
|
275
|
+
# unlike everything else here it does not need a baseline: this is the
|
|
276
|
+
# one poisoning visible on a first sighting.
|
|
277
|
+
**({"concealed": _concealment} if _concealment else {}),
|
|
278
|
+
# Typography, kept apart from the evidence. A soft hyphen or a
|
|
279
|
+
# zero-width space arrives in descriptions imported from formatted
|
|
280
|
+
# documentation, and no neighbouring character can clear it the way one
|
|
281
|
+
# clears a ZWJ. Reporting it beside real concealment would make an
|
|
282
|
+
# operator who reads one finding distrust the next.
|
|
283
|
+
**({"formatting": _formatting} if _formatting else {}),
|
|
251
284
|
# Only a schema that MOVED is the technique. `new` is the first
|
|
252
285
|
# sight of a server and `same` is a quiet reconnect; tagging either
|
|
253
286
|
# as a rug pull would put a Defense Evasion label on installing a
|
|
@@ -42,8 +42,35 @@ logger = logging.getLogger(__name__)
|
|
|
42
42
|
|
|
43
43
|
_lock = threading.Lock()
|
|
44
44
|
|
|
45
|
-
#:
|
|
46
|
-
|
|
45
|
+
#: Bump when the bytes fed to the hash change.
|
|
46
|
+
#:
|
|
47
|
+
#: A stored fingerprint is only comparable to one computed the same way, so a
|
|
48
|
+
#: baseline written under an older version is re-baselined rather than reported
|
|
49
|
+
#: as a change. Without that, the upgrade that fixes a blind spot hands every
|
|
50
|
+
#: existing user a rug-pull alert on the same morning.
|
|
51
|
+
#:
|
|
52
|
+
#: 1: `_meta` stripped before hashing.
|
|
53
|
+
#: 2: `_meta` hashed. See below.
|
|
54
|
+
FINGERPRINT_VERSION = 2
|
|
55
|
+
|
|
56
|
+
#: Keys dropped before hashing, because they change between calls without
|
|
57
|
+
#: changing what the model is told.
|
|
58
|
+
#:
|
|
59
|
+
#: **Empty, and it is meant to stay nearly empty.** It used to hold `_meta`, on
|
|
60
|
+
#: the reasonable-sounding grounds that `_meta` is transport bookkeeping. It is
|
|
61
|
+
#: not. `_meta` rides on the tool object, reaches clients, and whether it
|
|
62
|
+
#: reaches the *model* is client-dependent, which makes it the obvious place to
|
|
63
|
+
#: move behaviour-bearing text once descriptions are being watched. Stripping it
|
|
64
|
+
#: meant a poisoned listing hashed identically to a clean one: not a weakened
|
|
65
|
+
#: signal, an absent one (issue #142).
|
|
66
|
+
#:
|
|
67
|
+
#: The rules in this project are public on purpose, so its exemptions are public
|
|
68
|
+
#: too, and an exemption is a documented bypass.
|
|
69
|
+
#:
|
|
70
|
+
#: The bar for adding a key here is therefore evidence that it genuinely varies
|
|
71
|
+
#: per call on a real server, plus a note saying which server and why. "It looks
|
|
72
|
+
#: like metadata" is how the last entry got in.
|
|
73
|
+
_VOLATILE: frozenset[str] = frozenset()
|
|
47
74
|
|
|
48
75
|
|
|
49
76
|
def _canonical_entries(tools: list[Any] | None) -> list[dict[str, Any]]:
|
|
@@ -98,6 +125,244 @@ def fingerprint_each_tool(tools: list[Any] | None) -> dict[str, str]:
|
|
|
98
125
|
}
|
|
99
126
|
|
|
100
127
|
|
|
128
|
+
#: Characters that render as nothing, or reverse rendering order, while still
|
|
129
|
+
#: reaching the model. Grouped by category so a finding can say what kind of
|
|
130
|
+
#: concealment it is without carrying the text.
|
|
131
|
+
#:
|
|
132
|
+
#: The TAG block is the one that matters most: U+E0000 to U+E007F mirrors ASCII,
|
|
133
|
+
#: so an entire second instruction can be written in it and displayed as an
|
|
134
|
+
#: empty string. Research calls the result an approval-view fidelity gap, and
|
|
135
|
+
#: the phrase is exact: the human approving a tool reads one string and the
|
|
136
|
+
#: model receives another.
|
|
137
|
+
#:
|
|
138
|
+
#: **Every one of these ranges has a legitimate use**, which the first version
|
|
139
|
+
#: of this module denied in a comment that said so in as many words. A reader on
|
|
140
|
+
#: r/mcp took it apart within a day of the release being cut:
|
|
141
|
+
#:
|
|
142
|
+
#: * TAG characters spell the subdivision flags. Scotland, Wales and England
|
|
143
|
+
#: are emoji tag sequences, a U+1F3F4 base followed by tag letters and a
|
|
144
|
+
#: U+E007F terminator.
|
|
145
|
+
#: * U+200D joins every emoji ZWJ sequence, so a family or a profession emoji
|
|
146
|
+
#: carries one or more.
|
|
147
|
+
#: * U+200C is required Persian and Arabic orthography and is used across
|
|
148
|
+
#: Indic scripts. It is spelling, not decoration, so flagging it penalises
|
|
149
|
+
#: correctly written non-Latin text.
|
|
150
|
+
#: * Bidi isolates are the modern, recommended way to mix scripts, so any
|
|
151
|
+
#: description containing Arabic or Hebrew may legitimately carry them.
|
|
152
|
+
#:
|
|
153
|
+
#: So the check is contextual rather than range-based. A character counts only
|
|
154
|
+
#: when nothing in its surroundings explains it, which keeps the detection
|
|
155
|
+
#: (TAG-encoded ASCII is still caught, because it has no flag base in front of
|
|
156
|
+
#: it) and drops the false positives.
|
|
157
|
+
#:
|
|
158
|
+
#: Private Use Area is deliberately absent. It is genuinely used for icon fonts
|
|
159
|
+
#: and would fire on legitimate descriptions, and a category that cries wolf
|
|
160
|
+
#: costs more than the one case it might catch.
|
|
161
|
+
|
|
162
|
+
#: Tag characters, and the two that bracket a legitimate emoji tag sequence.
|
|
163
|
+
_TAG_RANGE = (0xE0000, 0xE007F)
|
|
164
|
+
_TAG_TERMINATOR = 0xE007F
|
|
165
|
+
_TAG_BASE = 0x1F3F4 # waving black flag, the only base an emoji tag sequence uses
|
|
166
|
+
|
|
167
|
+
#: Zero-width and format characters, split by whether context can explain them.
|
|
168
|
+
_ZWJ = 0x200D
|
|
169
|
+
_ZWNJ = 0x200C
|
|
170
|
+
|
|
171
|
+
#: The category for characters that are typography rather than evidence.
|
|
172
|
+
#:
|
|
173
|
+
#: These three were counted as concealment until the reader who found the flag
|
|
174
|
+
#: and Persian cases was asked directly whether they had innocent uses. They do,
|
|
175
|
+
#: and descriptions imported from formatted documentation carry them:
|
|
176
|
+
#:
|
|
177
|
+
#: * U+200B gives a line-break opportunity without a visible space.
|
|
178
|
+
#: * U+00AD is a soft hyphen, an optional hyphenation point.
|
|
179
|
+
#: * U+FEFF is legacy zero-width no-break space. U+2060 is preferred for new
|
|
180
|
+
#: text, and a byte order mark belongs to the input stream rather than to
|
|
181
|
+
#: every description string in it.
|
|
182
|
+
#:
|
|
183
|
+
#: Unlike ZWJ and ZWNJ there is no neighbouring character that settles the
|
|
184
|
+
#: question, so no positional test can clear them. They are reported under their
|
|
185
|
+
#: own key instead, so an operator can inspect them without a formatting quirk
|
|
186
|
+
#: reading as a poisoned tool. Presence alone is not evidence of anything.
|
|
187
|
+
FORMATTING_CATEGORY = "formatting"
|
|
188
|
+
_FORMATTING_CONTROLS = frozenset({0x200B, 0xFEFF, 0x00AD})
|
|
189
|
+
|
|
190
|
+
_BIDI_CONTROLS = frozenset(
|
|
191
|
+
list(range(0x202A, 0x202F)) + list(range(0x2066, 0x206A))
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
#: Rough pictographic ranges. Only used to decide whether a ZWJ sits between two
|
|
195
|
+
#: emoji, so being generous here costs a missed detection in a case that would
|
|
196
|
+
#: need an attacker to hide a payload inside an emoji sequence, and being narrow
|
|
197
|
+
#: costs a false positive on ordinary text. Generous is the right trade.
|
|
198
|
+
_PICTOGRAPHIC = (
|
|
199
|
+
(0x00A9, 0x00AE),
|
|
200
|
+
(0x203C, 0x3299),
|
|
201
|
+
(0x1F000, 0x1FAFF),
|
|
202
|
+
(0xFE0F, 0xFE0F), # variation selector 16, sits inside ZWJ sequences
|
|
203
|
+
(0x1F3FB, 0x1F3FF), # skin tone modifiers
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
#: Scripts that use ZWNJ as orthography rather than decoration.
|
|
207
|
+
_ZWNJ_SCRIPTS = (
|
|
208
|
+
(0x0600, 0x06FF), # Arabic
|
|
209
|
+
(0x0750, 0x077F),
|
|
210
|
+
(0x08A0, 0x08FF),
|
|
211
|
+
(0xFB50, 0xFDFF),
|
|
212
|
+
(0xFE70, 0xFEFF),
|
|
213
|
+
(0x0900, 0x0DFF), # Devanagari through Sinhala
|
|
214
|
+
(0x0700, 0x074F), # Syriac
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
#: Right-to-left scripts. A bidi control in text with none of these has nothing
|
|
218
|
+
#: to reorder, which is what makes it unexplained.
|
|
219
|
+
_RTL_SCRIPTS = (
|
|
220
|
+
(0x0590, 0x05FF), # Hebrew
|
|
221
|
+
(0x0600, 0x06FF), # Arabic
|
|
222
|
+
(0x0700, 0x074F), # Syriac
|
|
223
|
+
(0x0780, 0x07BF), # Thaana
|
|
224
|
+
(0x07C0, 0x07FF), # N'Ko
|
|
225
|
+
(0x0800, 0x085F),
|
|
226
|
+
(0xFB1D, 0xFDFF),
|
|
227
|
+
(0xFE70, 0xFEFF),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _in(point: int, ranges: tuple[tuple[int, int], ...]) -> bool:
|
|
232
|
+
return any(low <= point <= high for low, high in ranges)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _explained_tag_indices(points: list[int]) -> set[int]:
|
|
236
|
+
"""Indices belonging to a well-formed emoji tag sequence.
|
|
237
|
+
|
|
238
|
+
The grammar is narrow: a U+1F3F4 base, one or more tag characters, then a
|
|
239
|
+
U+E007F terminator. Tag characters anywhere else have no other use, so this
|
|
240
|
+
keeps the detection while letting the subdivision flags through.
|
|
241
|
+
"""
|
|
242
|
+
explained: set[int] = set()
|
|
243
|
+
i = 0
|
|
244
|
+
while i < len(points):
|
|
245
|
+
if points[i] != _TAG_BASE:
|
|
246
|
+
i += 1
|
|
247
|
+
continue
|
|
248
|
+
j = i + 1
|
|
249
|
+
while j < len(points) and _TAG_RANGE[0] <= points[j] < _TAG_TERMINATOR:
|
|
250
|
+
j += 1
|
|
251
|
+
if j > i + 1 and j < len(points) and points[j] == _TAG_TERMINATOR:
|
|
252
|
+
explained.update(range(i, j + 1))
|
|
253
|
+
i = j + 1
|
|
254
|
+
else:
|
|
255
|
+
i += 1
|
|
256
|
+
return explained
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _neighbour(points: list[int], index: int, step: int) -> int | None:
|
|
260
|
+
"""The nearest code point either side, skipping variation selectors."""
|
|
261
|
+
i = index + step
|
|
262
|
+
while 0 <= i < len(points):
|
|
263
|
+
if points[i] != 0xFE0F:
|
|
264
|
+
return points[i]
|
|
265
|
+
i += step
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _scan_text(text: str, found: dict[str, int]) -> None:
|
|
270
|
+
points = [ord(c) for c in text]
|
|
271
|
+
tag_ok = _explained_tag_indices(points)
|
|
272
|
+
has_rtl = any(_in(p, _RTL_SCRIPTS) for p in points)
|
|
273
|
+
|
|
274
|
+
for i, point in enumerate(points):
|
|
275
|
+
if _TAG_RANGE[0] <= point <= _TAG_RANGE[1]:
|
|
276
|
+
if i not in tag_ok:
|
|
277
|
+
found["tag_block"] = found.get("tag_block", 0) + 1
|
|
278
|
+
continue
|
|
279
|
+
|
|
280
|
+
if point in _FORMATTING_CONTROLS:
|
|
281
|
+
found[FORMATTING_CATEGORY] = found.get(FORMATTING_CATEGORY, 0) + 1
|
|
282
|
+
continue
|
|
283
|
+
|
|
284
|
+
if point == _ZWJ:
|
|
285
|
+
before = _neighbour(points, i, -1)
|
|
286
|
+
after = _neighbour(points, i, 1)
|
|
287
|
+
joins_emoji = (
|
|
288
|
+
before is not None
|
|
289
|
+
and after is not None
|
|
290
|
+
and _in(before, _PICTOGRAPHIC)
|
|
291
|
+
and _in(after, _PICTOGRAPHIC)
|
|
292
|
+
)
|
|
293
|
+
if not joins_emoji:
|
|
294
|
+
found["zero_width"] = found.get("zero_width", 0) + 1
|
|
295
|
+
continue
|
|
296
|
+
|
|
297
|
+
if point == _ZWNJ:
|
|
298
|
+
before = _neighbour(points, i, -1)
|
|
299
|
+
after = _neighbour(points, i, 1)
|
|
300
|
+
orthographic = (before is not None and _in(before, _ZWNJ_SCRIPTS)) or (
|
|
301
|
+
after is not None and _in(after, _ZWNJ_SCRIPTS)
|
|
302
|
+
)
|
|
303
|
+
if not orthographic:
|
|
304
|
+
found["zero_width"] = found.get("zero_width", 0) + 1
|
|
305
|
+
continue
|
|
306
|
+
|
|
307
|
+
if point in _BIDI_CONTROLS and not has_rtl:
|
|
308
|
+
found["bidi_control"] = found.get("bidi_control", 0) + 1
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _walk_strings(value: Any):
|
|
312
|
+
"""Every string anywhere in a tool definition.
|
|
313
|
+
|
|
314
|
+
Deliberately not a list of field names. #103 settled that argument for the
|
|
315
|
+
severity split and it applies here for the same reason: an allowlist fails
|
|
316
|
+
open on whatever the spec adds next, and a field nobody has thought about
|
|
317
|
+
yet should default to inspected rather than invisible.
|
|
318
|
+
"""
|
|
319
|
+
if isinstance(value, str):
|
|
320
|
+
yield value
|
|
321
|
+
elif isinstance(value, dict):
|
|
322
|
+
for key, item in value.items():
|
|
323
|
+
yield from _walk_strings(key)
|
|
324
|
+
yield from _walk_strings(item)
|
|
325
|
+
elif isinstance(value, list):
|
|
326
|
+
for item in value:
|
|
327
|
+
yield from _walk_strings(item)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def scan_concealed_text(tools: list[Any] | None) -> dict[str, int]:
|
|
331
|
+
"""Counts of unexplained concealed characters, or an empty dict if clean.
|
|
332
|
+
|
|
333
|
+
Counts only. Never the text, never which tool, never the surrounding
|
|
334
|
+
string. A finding that carries the payload has stored the payload, which is
|
|
335
|
+
the rule the whole module is built on.
|
|
336
|
+
|
|
337
|
+
This catches one narrow class of poisoning on a single observation, which
|
|
338
|
+
the fingerprint cannot do at all. That limit is worth stating precisely,
|
|
339
|
+
because "blind on the first listing" understates it: the fingerprint answers
|
|
340
|
+
"did this move", never "should this have been here". A server hostile at its
|
|
341
|
+
first release that never changed since has a stable digest forever and is
|
|
342
|
+
never flagged by it, at listing one or listing one hundred.
|
|
343
|
+
|
|
344
|
+
This check does **not** make a clean listing trustworthy either, first or
|
|
345
|
+
otherwise. An instruction written in ordinary visible text needs none of
|
|
346
|
+
these characters and is invisible here.
|
|
347
|
+
|
|
348
|
+
"Unexplained" is doing real work in the first line. Every range here has a
|
|
349
|
+
legitimate use, so context decides: a tag character inside a subdivision
|
|
350
|
+
flag, a ZWJ between two emoji, a ZWNJ next to Persian or Devanagari, and a
|
|
351
|
+
bidi control in text that actually contains a right-to-left script are all
|
|
352
|
+
silent. See the comment above `_TAG_RANGE` for who found that out and how.
|
|
353
|
+
|
|
354
|
+
The result carries two grades of finding under different keys. `tag_block`,
|
|
355
|
+
`zero_width` and `bidi_control` are concealment: something is hidden and
|
|
356
|
+
nothing around it explains why. `formatting` is typography that no
|
|
357
|
+
positional test can clear, and on its own it is not evidence of poisoning.
|
|
358
|
+
`build_schema_canonical` keeps them in separate blocks for that reason.
|
|
359
|
+
"""
|
|
360
|
+
found: dict[str, int] = {}
|
|
361
|
+
for text in _walk_strings(_canonical_entries(tools)):
|
|
362
|
+
_scan_text(text, found)
|
|
363
|
+
return found
|
|
364
|
+
|
|
365
|
+
|
|
101
366
|
def server_id(name: str) -> str:
|
|
102
367
|
"""Opaque 16-hex id for a server name. Publish this, never the name."""
|
|
103
368
|
return hashlib.sha256(name.encode("utf-8")).hexdigest()[:16]
|
|
@@ -201,6 +466,9 @@ class SchemaRecord:
|
|
|
201
466
|
#: is why a delta against an empty baseline reports nothing changed rather
|
|
202
467
|
#: than reporting every tool as new.
|
|
203
468
|
tool_digests: dict[str, str] = field(default_factory=dict)
|
|
469
|
+
#: Which hashing this fingerprint was computed with. Defaults to 1 because
|
|
470
|
+
#: a record that does not say was written before the field existed.
|
|
471
|
+
fingerprint_version: int = 1
|
|
204
472
|
|
|
205
473
|
|
|
206
474
|
@dataclass
|
|
@@ -256,13 +524,14 @@ def load_store(path: Path | None = None) -> SchemaStore:
|
|
|
256
524
|
}
|
|
257
525
|
if isinstance(digests, dict)
|
|
258
526
|
else {},
|
|
527
|
+
fingerprint_version=int(rec.get("fingerprint_version") or 1),
|
|
259
528
|
)
|
|
260
529
|
return SchemaStore(servers=servers)
|
|
261
530
|
|
|
262
531
|
|
|
263
532
|
def _dump(store: SchemaStore) -> dict[str, Any]:
|
|
264
533
|
return {
|
|
265
|
-
"schema_version":
|
|
534
|
+
"schema_version": 3,
|
|
266
535
|
"servers": {
|
|
267
536
|
name: {
|
|
268
537
|
"fingerprint": rec.fingerprint,
|
|
@@ -273,6 +542,7 @@ def _dump(store: SchemaStore) -> dict[str, Any]:
|
|
|
273
542
|
**({"server_version": rec.server_version} if rec.server_version else {}),
|
|
274
543
|
**({"list_changed": rec.list_changed} if rec.list_changed is not None else {}),
|
|
275
544
|
**({"tool_digests": rec.tool_digests} if rec.tool_digests else {}),
|
|
545
|
+
"fingerprint_version": rec.fingerprint_version,
|
|
276
546
|
}
|
|
277
547
|
for name, rec in sorted(store.servers.items())
|
|
278
548
|
},
|
|
@@ -312,6 +582,25 @@ def classify_observation(
|
|
|
312
582
|
existing = load_store(path or store_path()).servers.get(name)
|
|
313
583
|
if existing is None:
|
|
314
584
|
return "new"
|
|
585
|
+
if existing.fingerprint_version != FINGERPRINT_VERSION:
|
|
586
|
+
# The stored digest was computed over different bytes, so it is not
|
|
587
|
+
# comparable and a mismatch says nothing about the server. Re-baseline
|
|
588
|
+
# instead. Without this, the release that closed the `_meta` blind spot
|
|
589
|
+
# would have reported a rug pull on every server every user had ever
|
|
590
|
+
# observed, on upgrade day, and taught them the alert is noise.
|
|
591
|
+
#
|
|
592
|
+
# `rebaselined` rather than `new`, and the distinction is the whole
|
|
593
|
+
# point (issue #146). `new` says "we had never seen this server". This
|
|
594
|
+
# says "we had a baseline, our hashing changed, and we are trusting
|
|
595
|
+
# whatever the server says today without comparing it to anything".
|
|
596
|
+
#
|
|
597
|
+
# Those carry different evidence. If a server was already poisoned
|
|
598
|
+
# before the upgrade, this observation adopts the poisoned state and an
|
|
599
|
+
# operator reading a quiet week must be able to see that, rather than
|
|
600
|
+
# find a word that also means "nothing has ever been wrong here".
|
|
601
|
+
# Trading a loud false positive for a silent false negative is the
|
|
602
|
+
# worse half of that trade for a recorder.
|
|
603
|
+
return "rebaselined"
|
|
315
604
|
if existing.fingerprint == fp:
|
|
316
605
|
return "same"
|
|
317
606
|
if existing.tool_count == 0 and tool_count > 0:
|
|
@@ -384,6 +673,7 @@ def record_observation(
|
|
|
384
673
|
existing.server_version = server_version
|
|
385
674
|
if list_changed is not None:
|
|
386
675
|
existing.list_changed = list_changed
|
|
676
|
+
existing.fingerprint_version = FINGERPRINT_VERSION
|
|
387
677
|
if tool_digests:
|
|
388
678
|
# An unchanged listing backfills the per-tool map for records
|
|
389
679
|
# written before it existed, so the first real change after an
|
|
@@ -402,6 +692,7 @@ def record_observation(
|
|
|
402
692
|
server_version=server_version,
|
|
403
693
|
list_changed=list_changed,
|
|
404
694
|
tool_digests=dict(tool_digests or {}),
|
|
695
|
+
fingerprint_version=FINGERPRINT_VERSION,
|
|
405
696
|
)
|
|
406
697
|
_write_store(store, target)
|
|
407
698
|
return "changed" if previous else "new"
|
|
@@ -159,3 +159,29 @@ rules:
|
|
|
159
159
|
pattern: "(?i)\\b(ANTHROPIC_BASE_URL|ANTHROPIC_AUTH_TOKEN|OPENAI_BASE_URL|OPENAI_API_BASE|GEMINI_BASE_URL|MOONSHOT_API_KEY|DASHSCOPE_API_KEY|DEEPSEEK_API_KEY|ZHIPUAI_API_KEY|MINIMAX_API_KEY|QWEN_API_KEY|HTTP_PROXY|HTTPS_PROXY|NODE_OPTIONS|PYTHONSTARTUP|LD_PRELOAD)[\"']?\\s*[=:]"
|
|
160
160
|
category: "execution_hijack"
|
|
161
161
|
severity: "critical"
|
|
162
|
+
|
|
163
|
+
- id: "stripe_api_key"
|
|
164
|
+
name: "Stripe API Key"
|
|
165
|
+
description: "Detects Stripe live/test secret keys"
|
|
166
|
+
pattern: "(?i)\\bsk_(live|test)_[0-9a-z]{24,}\\b"
|
|
167
|
+
category: "credentials"
|
|
168
|
+
severity: "critical"
|
|
169
|
+
|
|
170
|
+
# Anthropic keys (sk-ant-api03-...) don't fit the generic sk-[a-zA-Z0-9]{20,}
|
|
171
|
+
# shape below: the hyphen after "ant" (and again after "api03") breaks the
|
|
172
|
+
# alnum-only run at 3 characters, so the generic rule never fires on them
|
|
173
|
+
# despite starting with the same "sk-" prefix. Needs its own rule, and a
|
|
174
|
+
# distinct rule id is more useful in a detection than a generic match anyway.
|
|
175
|
+
- id: "anthropic_api_key"
|
|
176
|
+
name: "Anthropic API Key"
|
|
177
|
+
description: "Detects Anthropic API keys (sk-ant-...)"
|
|
178
|
+
pattern: "(?i)\\bsk-ant-[a-zA-Z0-9_-]{20,}"
|
|
179
|
+
category: "credentials"
|
|
180
|
+
severity: "critical"
|
|
181
|
+
|
|
182
|
+
- id: "generic_provider_api_key"
|
|
183
|
+
name: "Generic LLM Provider API Key"
|
|
184
|
+
description: "Detects OpenAI-shaped provider API keys (sk-...); Anthropic and DashScope keys have their own rules above"
|
|
185
|
+
pattern: "(?i)\\bsk-[a-zA-Z0-9]{20,}\\b"
|
|
186
|
+
category: "credentials"
|
|
187
|
+
severity: "critical"
|
|
@@ -34,7 +34,12 @@ dependencies = [
|
|
|
34
34
|
"websockets>=14.0",
|
|
35
35
|
"sqlalchemy>=2.0.0",
|
|
36
36
|
"aiosqlite>=0.20.0",
|
|
37
|
-
|
|
37
|
+
# Upper bound is load-bearing, not caution. MCP 2.0 removed
|
|
38
|
+
# `mcp.server.fastmcp.FastMCP`, which `tools/vault_fs_server.py` imports,
|
|
39
|
+
# so an unconstrained `>=1.2` resolved to 2.x on any fresh install and the
|
|
40
|
+
# bundled server failed on first run (issue #138). Lift this when that
|
|
41
|
+
# import is ported to the 2.x API, not before.
|
|
42
|
+
"mcp>=1.2,<2",
|
|
38
43
|
# Windows ships no IANA timezone database, so `zoneinfo` cannot resolve
|
|
39
44
|
# AGENTMETRY_BUSINESS_TZ there without this. Without it the off-hours rule
|
|
40
45
|
# silently fell back to UTC and produced wrong findings on the platform this
|
|
@@ -47,7 +52,7 @@ dependencies = [
|
|
|
47
52
|
# so a linter release could turn a green branch red with no change to this repo.
|
|
48
53
|
# It did: 0.16.0 widened the default rule set and CI reported 132 errors against
|
|
49
54
|
# code that passed locally on 0.15.x. Bump this deliberately, not by accident.
|
|
50
|
-
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.6"]
|
|
51
56
|
|
|
52
57
|
[project.urls]
|
|
53
58
|
Homepage = "https://agentmetry.ai"
|