From 9da875a5961a4710006815079f15afdd7cc2990a Mon Sep 17 00:00:00 2001 From: "Ioannis L." <44038245+blitzcrieg1@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:32:40 +0200 Subject: [PATCH] feat(account): record which account domain a coding agent reports EDR and SIEM logs show that an agent's process ran, not which account was signed in inside it. A developer using a personal account on a company machine was invisible, because that account is outside the company's tenant and its vendor admin logs. Events now carry an optional `account` block: domain, source, and organization ID where one is reported. The full address needs AGENTMETRY_LOG_ACCOUNT_EMAIL=1. Cursor reports it through `user_email`. Claude Code reports it through OpenTelemetry, and hooks fall back to the undocumented `~/.claude.json`. That fallback means the account last signed in on the machine, so it is labelled `local_config` and skipped when an API key, token, cloud provider, profile or another endpoint selects a different credential. Codex, Antigravity and Copilot document no account field and stay absent. Detection events keep the block, so the alert a SOC reads carries the domain. ECS keeps it under `agentmetry.account` rather than `user.*`, because a reported claim is not the OS user's identity. The HTTP model declares it and external.py bounds it, so the spool replay behaves the same way. The schema stays additive. The fingerprint is unchanged at 15846a0915769d4a. No real Cursor session and no live SIEM has run this yet. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 18 + README.md | 2 +- .../agentmetry/api/routes/audit.py | 17 + apps/orchestrator/agentmetry/core/account.py | 206 +++++++++++ .../core/audit/adapters/chronicle.py | 9 + .../agentmetry/core/audit/detection/live.py | 6 +- .../agentmetry/core/audit/external.py | 11 + .../agentmetry/core/audit/otel_ingest.py | 7 +- apps/orchestrator/agentmetry/hooks/ingest.py | 5 + .../tests/test_account_identity.py | 322 ++++++++++++++++++ .../test_detection_duplicate_emission.py | 26 +- docs/agentmetry-event-schema.md | 88 +++++ docs/integrations/google-secops.md | 30 ++ docs/integrations/splunk-hec.md | 16 + 14 files changed, 756 insertions(+), 7 deletions(-) create mode 100644 apps/orchestrator/agentmetry/core/account.py create mode 100644 apps/orchestrator/tests/test_account_identity.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e461ed0f..3a78ba58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,24 @@ separately (currently `1.2.0`) and changes additively. ## [Unreleased] +### Added + +- **Reported coding-agent account metadata.** Cursor hook payloads and Claude + Code OpenTelemetry events may now carry an optional `account` block with a + lowercased domain, source and reported organization ID. The domain is stored + by default; full email retention requires the explicit + `AGENTMETRY_LOG_ACCOUNT_EMAIL=1` opt-in. Claude hook capture has a bounded + fallback to the undocumented non-secret account fields in `~/.claude.json`, + read per short-lived hook invocation, skipped when the file is oversized, and + skipped when documented API-key, token, provider, profile, federation, or + endpoint overrides are present, except for the official Anthropic API host. + `CLAUDE_CONFIG_DIR` selects the configured account directory; the local claim + remains a last-signed-in-machine hint rather than proof of the current call. + Codex and Antigravity remain absent because their documented hook inputs do + not expose a non-secret account field. The claim stays under the Agentmetry + namespace in ECS and is forwarded to Google SecOps without presenting it as + verified identity. + ### Fixed - **The model Codex reports now reaches the trail.** The hook sent it as a nested diff --git a/README.md b/README.md index de16a0a2..644c06d4 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,7 @@ These are the numbers from commands, not from a slide. That block is what `agentmetry benchmark` printed from the PyPI 0.9.4 package and from this tree. "Rules covered 13" counts rule ids named in corpus expectations. Twelve of those are published rules. The thirteenth is the experimental rule. Two published rules have no corpus case: `host-subagent-swarm-burst` and `off-hours-activity`. They are named in `apps/orchestrator/tools/generate_sigma_pack.py` so the Sigma pack still has them. -2125 tests collected (`pytest --collect-only -q` from `apps/orchestrator`). The +2157 tests collected (`pytest --collect-only -q` from `apps/orchestrator`). The [0.10.6 release PR's CI run](https://github.com/blitzcrieg1/agentmetry/actions/runs/42082231888) on Python 3.12 reported 2089 passed and 19 skipped on Linux and macOS, 2102 passed and 6 skipped on Windows, and 82.13% line coverage on Linux diff --git a/apps/orchestrator/agentmetry/api/routes/audit.py b/apps/orchestrator/agentmetry/api/routes/audit.py index 36822634..3b4adc3f 100644 --- a/apps/orchestrator/agentmetry/api/routes/audit.py +++ b/apps/orchestrator/agentmetry/api/routes/audit.py @@ -109,6 +109,20 @@ def _text_or_empty(cls, v: Any) -> str: return v if isinstance(v, str) else "" +class IngestAccountBody(BaseModel): + """A bounded client-reported agent account claim, never verified identity.""" + + domain: str = "" + source: str = "" + organization_id: str = "" + email: str = "" + + @field_validator("domain", "source", "organization_id", "email", mode="before") + @classmethod + def _text_or_empty(cls, v: Any) -> str: + return v if isinstance(v, str) else "" + + class ExternalIngestBody(BaseModel): """Adapter payload — normalized to canonical v1.2 on ingest. @@ -145,6 +159,9 @@ class ExternalIngestBody(BaseModel): input_hash: str = "" model_id: str = "" model: IngestModelBody | None = None + # Account metadata is additive and optional. external.py validates and + # bounds it again because spool replay bypasses this HTTP model. + account: IngestAccountBody | None = None adapter: str = "" triggered_by: str = "manual" timestamp_utc: str = "" diff --git a/apps/orchestrator/agentmetry/core/account.py b/apps/orchestrator/agentmetry/core/account.py new file mode 100644 index 00000000..4b6a22f8 --- /dev/null +++ b/apps/orchestrator/agentmetry/core/account.py @@ -0,0 +1,206 @@ +"""Validate and minimise the account identity a coding agent reports. + +This module deliberately has no Agentmetry imports. Hook processes import it +before the receiver, and a malformed account claim must never break capture. +The value is a client claim, not proof of identity. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit + +_EMAIL_MAX = 320 +_DOMAIN_MAX = 253 +_ORG_MAX = 256 +_SOURCE_MAX = 32 +_TRUE = frozenset({"1", "true", "yes", "on"}) +_LOCAL_CONFIG_MAX_BYTES = 1024 * 1024 +_CLAUDE_AUTH_OVERRIDE_VARS = ( + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "CLAUDE_CODE_OAUTH_TOKEN", + "ANTHROPIC_PROFILE", +) +_CLAUDE_PROVIDER_FLAGS = ( + "CLAUDE_CODE_USE_BEDROCK", + "CLAUDE_CODE_USE_VERTEX", + "CLAUDE_CODE_USE_FOUNDRY", +) + + +def _clean_text(value: Any, limit: int) -> str: + if not isinstance(value, str): + return "" + value = value.strip() + if not value or len(value) > limit or any(ord(ch) < 32 or ord(ch) == 127 for ch in value): + return "" + return value + + +def _valid_domain(domain: str) -> bool: + if not domain or len(domain) > _DOMAIN_MAX or domain.endswith("."): + return False + labels = domain.split(".") + return all( + label + and len(label) <= 63 + and label[0].isalnum() + and label[-1].isalnum() + and all(ch.isalnum() or ch == "-" for ch in label) + for label in labels + ) + + +def account_from_email( + value: Any, + source: str, + organization_id: Any = "", + *, + include_email: bool | None = None, +) -> dict[str, str]: + """Return a bounded account block, or an empty block for bad input.""" + raw = _clean_text(value, _EMAIL_MAX) + if raw.count("@") != 1: + return {} + local, domain = raw.rsplit("@", 1) + if not local or any(ch.isspace() for ch in raw): + return {} + domain = domain.lower() + if not _valid_domain(domain): + return {} + + source_text = _clean_text(source, _SOURCE_MAX).lower() + if not source_text: + source_text = "client" + org = _clean_text(organization_id, _ORG_MAX) + block = {"domain": domain, "source": source_text} + if org: + block["organization_id"] = org + if include_email is None: + include_email = os.environ.get("AGENTMETRY_LOG_ACCOUNT_EMAIL", "").strip().lower() in _TRUE + if include_email: + # Keep the local part as reported, but normalise the domain. + block["email"] = f"{local}@{domain}" + return block + + +def account_from_domain( + domain: Any, + source: Any, + organization_id: Any = "", + email: Any = "", + *, + include_email: bool | None = None, +) -> dict[str, str]: + """Validate an already-minimised claim, optionally retaining opt-in email.""" + clean_domain = _clean_text(domain, _DOMAIN_MAX).lower() + if not _valid_domain(clean_domain): + return {} + source_text = _clean_text(source, _SOURCE_MAX).lower() or "client" + org = _clean_text(organization_id, _ORG_MAX) + block = {"domain": clean_domain, "source": source_text} + if org: + block["organization_id"] = org + if include_email is None: + include_email = os.environ.get("AGENTMETRY_LOG_ACCOUNT_EMAIL", "").strip().lower() in _TRUE + if include_email: + candidate = account_from_email(email, source_text, org, include_email=True) + if candidate and candidate.get("domain") == clean_domain: + block["email"] = candidate["email"] + return block + + +def account_from_organization(organization_id: Any, source: str) -> dict[str, str]: + """Return an organization-only claim when no email was reported.""" + org = _clean_text(organization_id, _ORG_MAX) + source_text = _clean_text(source, _SOURCE_MAX).lower() or "client" + return {"organization_id": org, "source": source_text} if org else {} + + +def account_from_claim( + domain: Any, + source: Any, + organization_id: Any = "", + email: Any = "", + *, + include_email: bool | None = None, +) -> dict[str, str]: + """Keep a valid domain claim, or retain a valid organization-only claim.""" + account = account_from_domain( + domain, source, organization_id, email, include_email=include_email + ) + return account or account_from_organization(organization_id, source) + + +def _claude_auth_override_present() -> bool: + """Return whether visible environment state selects non-subscription auth.""" + if any(os.environ.get(name, "").strip() for name in _CLAUDE_AUTH_OVERRIDE_VARS): + return True + if any(os.environ.get(name, "").strip().lower() in _TRUE for name in _CLAUDE_PROVIDER_FLAGS): + return True + if os.environ.get("ANTHROPIC_FEDERATION_RULE_ID", "").strip() and os.environ.get( + "ANTHROPIC_ORGANIZATION_ID", "" + ).strip(): + return True + base_url = os.environ.get("ANTHROPIC_BASE_URL", "").strip() + if base_url: + try: + hostname = urlsplit(base_url).hostname + except ValueError: + return True + if hostname != "api.anthropic.com": + return True + return False + + +def _local_claude_account() -> dict[str, str]: + """Read two non-secret fields from Claude's undocumented local file. + + The file and its JSON shape can change between Claude Code versions. It is + bounded and read once per short-lived hook invocation. An oversized file + is ignored rather than partially parsed. Explicit API credentials or a + custom endpoint take precedence over the signed-in account, so the local + fallback is skipped when any documented override is present. + """ + if _claude_auth_override_present(): + return {} + try: + config_dir = os.environ.get("CLAUDE_CONFIG_DIR", "") + path = (Path(config_dir) if config_dir else Path.home()) / ".claude.json" + if not path.is_file() or path.stat().st_size > _LOCAL_CONFIG_MAX_BYTES: + return {} + parsed = json.loads(path.read_bytes()) + oauth = parsed.get("oauthAccount") if isinstance(parsed, dict) else None + email = oauth.get("emailAddress") if isinstance(oauth, dict) else None + organization_id = oauth.get("organizationUuid") if isinstance(oauth, dict) else None + if isinstance(email, str): + account = account_from_email(email, "local_config", organization_id) + if account: + return account + return account_from_organization(organization_id, "local_config") + except (OSError, UnicodeError, ValueError, TypeError): + return {} + + +def account_for_hook(source_app: str, data: dict[str, Any]) -> dict[str, str]: + """Extract an account claim from a documented hook payload.""" + source = source_app.strip().lower() + if source == "cursor": + email = data.get("user_email") + if email is None: + email = data.get("userEmail") + return account_from_email(email, "hook_payload") + if source == "claude": + return _local_claude_account() + return {} + + +def account_for_otel(attrs: dict[str, Any]) -> dict[str, str]: + """Extract Claude's documented OpenTelemetry account attributes.""" + email = attrs.get("user.email") + org = attrs.get("organization.id") + return account_from_email(email, "otel", org) or account_from_organization(org, "otel") diff --git a/apps/orchestrator/agentmetry/core/audit/adapters/chronicle.py b/apps/orchestrator/agentmetry/core/audit/adapters/chronicle.py index c67d9f0e..0a05edac 100644 --- a/apps/orchestrator/agentmetry/core/audit/adapters/chronicle.py +++ b/apps/orchestrator/agentmetry/core/audit/adapters/chronicle.py @@ -246,6 +246,15 @@ def canonical_to_udm(canonical: dict[str, Any]) -> dict[str, Any]: source = canonical.get("source") or {} if source.get("app"): labels.append({"key": "source_app", "value": str(source["app"])}) + account = canonical.get("account") or {} + if account.get("domain"): + labels.append({"key": "agent_account_domain", "value": str(account["domain"])}) + if account.get("source"): + labels.append({"key": "agent_account_source", "value": str(account["source"])}) + if account.get("organization_id"): + labels.append({"key": "agent_account_organization_id", "value": str(account["organization_id"])}) + if account.get("email"): + labels.append({"key": "agent_account_email", "value": str(account["email"])}) if labels: udm["additional"] = {"labels": labels} diff --git a/apps/orchestrator/agentmetry/core/audit/detection/live.py b/apps/orchestrator/agentmetry/core/audit/detection/live.py index 14fafa22..7f994bbb 100644 --- a/apps/orchestrator/agentmetry/core/audit/detection/live.py +++ b/apps/orchestrator/agentmetry/core/audit/detection/live.py @@ -121,7 +121,7 @@ def build_detection_event(detection: Detection, source_event: dict[str, Any]) -> if atlas is not None: detection_block["atlas"] = atlas - return { + event = { "schema_version": source_event.get("schema_version", SCHEMA_VERSION), "event_id": str(uuid.uuid4()), "session_id": source_event.get("session_id", ""), @@ -140,3 +140,7 @@ def build_detection_event(detection: Detection, source_event: dict[str, Any]) -> "agent": agent if isinstance(agent, dict) else {"name": "agentmetry"}, "detection": detection_block, } + account = source_event.get("account") + if isinstance(account, dict) and account: + event["account"] = dict(account) + return event diff --git a/apps/orchestrator/agentmetry/core/audit/external.py b/apps/orchestrator/agentmetry/core/audit/external.py index 79ad59ad..14c35f77 100644 --- a/apps/orchestrator/agentmetry/core/audit/external.py +++ b/apps/orchestrator/agentmetry/core/audit/external.py @@ -8,6 +8,7 @@ from typing import Any, Literal from agentmetry.core.audit.canonical import SCHEMA_VERSION +from agentmetry.core.account import account_from_claim from agentmetry.core.audit.identity import identity_fields from agentmetry.core.audit.hashing import arguments_fingerprint from agentmetry.core.audit.redaction import scrub_arg_values, scrub_secrets @@ -187,6 +188,16 @@ def build_external_canonical(payload: dict[str, Any]) -> dict[str, Any]: }, "model": {"id": model_id, "provider": model_provider}, } + account_raw = payload.get("account") + if isinstance(account_raw, dict): + account = account_from_claim( + account_raw.get("domain"), + account_raw.get("source"), + account_raw.get("organization_id"), + account_raw.get("email"), + ) + if account: + event["account"] = account if claimed_app: event["source"]["claimed_app"] = claimed_app diff --git a/apps/orchestrator/agentmetry/core/audit/otel_ingest.py b/apps/orchestrator/agentmetry/core/audit/otel_ingest.py index a6bc62a7..94e5ac55 100644 --- a/apps/orchestrator/agentmetry/core/audit/otel_ingest.py +++ b/apps/orchestrator/agentmetry/core/audit/otel_ingest.py @@ -37,6 +37,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any +from agentmetry.core.account import account_for_otel from agentmetry.hooks.ingest import _hash_tool_args, post_ingest, redact_arguments #: The receiver binds here and nowhere else. The stream carries tool arguments @@ -211,7 +212,7 @@ def _base(attrs: dict[str, Any], ts_iso: str) -> dict[str, Any]: # `session.id` is the same value, so one session correlates the same way # whichever path recorded it. session = str(attrs.get("session.id") or attrs.get("session_id") or "") - return { + payload = { "source_app": "claude", "adapter": "otel", "correlation_id": session, @@ -219,6 +220,10 @@ def _base(attrs: dict[str, Any], ts_iso: str) -> dict[str, Any]: "timestamp_utc": ts_iso, "triggered_by": "otel_receiver", } + account = account_for_otel(attrs) + if account: + payload["account"] = account + return payload def _map_tool_result(attrs: dict[str, Any], ts_iso: str) -> dict[str, Any]: diff --git a/apps/orchestrator/agentmetry/hooks/ingest.py b/apps/orchestrator/agentmetry/hooks/ingest.py index 40c92e80..cf414a21 100644 --- a/apps/orchestrator/agentmetry/hooks/ingest.py +++ b/apps/orchestrator/agentmetry/hooks/ingest.py @@ -61,6 +61,7 @@ def ask_honoured(source_app: str, hook_name: str) -> bool: # noqa: ARG001 # Standard library only, so it costs the hook nothing. Guarded anyway: an # unresolvable operator must leave the event unattributed, not unrecorded. from agentmetry.core.api_token import ingest_token_path, read_token +from agentmetry.core.account import account_for_hook from agentmetry.core.paths import data_dir, env_file, hook_spool_path try: @@ -1788,6 +1789,10 @@ def map_hook(hook_name: str, data: dict[str, Any]) -> dict[str, Any] | None: payload = map_claude_hook(hook_name, data) else: payload = map_cursor_hook(hook_name, data) + if payload: + account = account_for_hook(str(payload.get("source_app") or source), data) + if account: + payload["account"] = account # Hash tool args in-process so plaintext never crosses the wire. payload = _hash_tool_args(payload) adapter_override = os.environ.get("AGENTMETRY_ADAPTER", "").strip() diff --git a/apps/orchestrator/tests/test_account_identity.py b/apps/orchestrator/tests/test_account_identity.py new file mode 100644 index 00000000..712ea3e9 --- /dev/null +++ b/apps/orchestrator/tests/test_account_identity.py @@ -0,0 +1,322 @@ +"""Account-domain capture is additive, bounded and redacted by default.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from agentmetry.api.routes.audit import ExternalIngestBody +from agentmetry.core.account import account_from_email, account_from_domain +from agentmetry.core.audit.external import build_external_canonical +from agentmetry.core.config import settings + + +_CLAUDE_ENV_KEYS = ( + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", + "CLAUDE_CODE_USE_BEDROCK", + "CLAUDE_CODE_USE_VERTEX", + "CLAUDE_CODE_USE_FOUNDRY", + "CLAUDE_CODE_OAUTH_TOKEN", + "ANTHROPIC_PROFILE", + "ANTHROPIC_FEDERATION_RULE_ID", + "ANTHROPIC_ORGANIZATION_ID", + "CLAUDE_CONFIG_DIR", +) + + +@pytest.fixture(autouse=True) +def _clear_claude_environment(monkeypatch: pytest.MonkeyPatch): + for key in _CLAUDE_ENV_KEYS: + monkeypatch.delenv(key, raising=False) + + +def _survives(payload: dict) -> dict: + return ExternalIngestBody(**payload).model_dump(exclude_none=True) + + +def test_cursor_hook_real_route_stores_only_the_domain(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + from agentmetry.core.audit.ingest import reset_ingest_sink_cache + from agentmetry.core.audit.trail_db import reset_trail_db + from agentmetry.hooks import ingest as hook + + jsonl = tmp_path / "audit.jsonl" + monkeypatch.setattr(settings, "audit_export_path", jsonl) + monkeypatch.setattr(settings, "audit_export_enabled", True) + monkeypatch.setattr(settings, "audit_db_path", tmp_path / "audit.db") + monkeypatch.setattr(settings, "audit_ingest_enabled", True) + monkeypatch.setattr(settings, "audit_sink", "file") + monkeypatch.setattr(settings, "api_key", "") + monkeypatch.setattr(settings, "trusted_hosts", "testserver") + monkeypatch.setattr(settings, "auth_disabled", True) + monkeypatch.setenv("AGENTMETRY_SOURCE_APP", "cursor") + monkeypatch.delenv("AGENTMETRY_LOG_ACCOUNT_EMAIL", raising=False) + reset_trail_db() + reset_ingest_sink_cache() + + payload = hook.map_hook( + "sessionStart", + {"conversation_id": "acct-route", "user_email": "dev@example.com"}, + ) + assert payload is not None + assert payload["account"] == {"domain": "example.com", "source": "hook_payload"} + from agentmetry.api.main import app + + response = TestClient(app).post("/api/v1/audit/ingest", json=payload) + assert response.status_code == 200 + stored = json.loads(jsonl.read_text(encoding="utf-8").splitlines()[0]) + assert stored["event"]["account"] == payload["account"] + assert "dev@example.com" not in jsonl.read_text(encoding="utf-8") + + +def test_spool_replay_path_keeps_the_same_account_block(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("AGENTMETRY_LOG_ACCOUNT_EMAIL", raising=False) + payload = { + "source_app": "cursor", + "event_type": "tool_called", + "account": {"domain": "example.com", "source": "hook_payload", "email": "dev@example.com"}, + } + direct = build_external_canonical(payload) + replay = build_external_canonical(_survives(payload)) + assert direct["account"] == replay["account"] == { + "domain": "example.com", + "source": "hook_payload", + } + + +def test_full_email_requires_explicit_opt_in(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("AGENTMETRY_LOG_ACCOUNT_EMAIL", "1") + assert account_from_email("dev@example.com", "hook_payload") == { + "domain": "example.com", + "source": "hook_payload", + "email": "dev@example.com", + } + monkeypatch.delenv("AGENTMETRY_LOG_ACCOUNT_EMAIL", raising=False) + assert account_from_domain("example.com", "hook_payload", email="dev@example.com") == { + "domain": "example.com", + "source": "hook_payload", + } + + +@pytest.mark.parametrize( + "bad", + [None, 7, "no-at", "a@b@c", "DEV@EXAMPLE.COM.", "a\x00@b.com", "a @b.com", "a@" + "x" * 254 + ".com"], +) +def test_malformed_account_claims_are_absent_and_never_a_422(bad): + assert account_from_email(bad, "hook_payload") == {} + kept = _survives( + { + "source_app": "cursor", + "event_type": "session_start", + "account": {"domain": "", "source": "hook_payload", "email": bad}, + } + ) + assert build_external_canonical(kept).get("account") is None + + +def test_account_field_is_structurally_declared_and_bounded(monkeypatch: pytest.MonkeyPatch): + from agentmetry.hooks import ingest as hook + + monkeypatch.setenv("AGENTMETRY_SOURCE_APP", "cursor") + monkeypatch.delenv("AGENTMETRY_LOG_ACCOUNT_EMAIL", raising=False) + payload = hook.map_hook( + "afterFileEdit", + {"conversation_id": "acct-guard", "user_email": "dev@example.com", "tool_name": "Read"}, + ) + assert payload is not None + kept = _survives(payload) + assert sorted(k for k in payload if k not in kept) == [] + assert build_external_canonical(kept)["account"]["domain"] == "example.com" + + +def test_old_payload_without_account_remains_valid(): + event = build_external_canonical({"source_app": "cursor", "event_type": "session_start"}) + assert "account" not in event + + +def test_claude_otel_claim_and_sink_adapters(): + from agentmetry.core.audit.adapters.chronicle import canonical_to_udm + from agentmetry.core.audit.adapters.ecs import canonical_to_ecs + from agentmetry.core.audit.otel_ingest import _base + + payload = _base( + {"session.id": "otel-acct", "user.email": "dev@example.com", "organization.id": "org-1"}, + "2026-01-01T00:00:00+00:00", + ) + event = build_external_canonical(payload) + assert event["account"] == { + "domain": "example.com", + "source": "otel", + "organization_id": "org-1", + } + ecs = canonical_to_ecs(event) + assert "domain" not in ecs["user"] + assert "email" not in ecs["user"] + assert ecs["agentmetry"]["account"]["domain"] == "example.com" + assert ecs["agentmetry"]["account"]["organization_id"] == "org-1" + labels = {item["key"]: item["value"] for item in canonical_to_udm(event)["additional"]["labels"]} + assert labels["agent_account_domain"] == "example.com" + assert labels["agent_account_organization_id"] == "org-1" + + +def test_claude_local_config_fallback_reads_only_the_selected_account_fields( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +): + import agentmetry.core.account as account + + (tmp_path / ".claude.json").write_text( + json.dumps({"oauthAccount": {"emailAddress": "dev@example.com", "organizationUuid": "org-local", "organizationName": "Example"}, "other": "ignored"}), + encoding="utf-8", + ) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.delenv("AGENTMETRY_LOG_ACCOUNT_EMAIL", raising=False) + claim = account.account_for_hook("claude", {"session_id": "local-config"}) + assert claim == {"domain": "example.com", "source": "local_config", "organization_id": "org-local"} + + +def test_claude_local_config_organization_survives_without_email( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +): + import agentmetry.core.account as account + + (tmp_path / ".claude.json").write_text( + json.dumps({"oauthAccount": {"organizationUuid": "org-only"}}), + encoding="utf-8", + ) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.delenv("AGENTMETRY_LOG_ACCOUNT_EMAIL", raising=False) + claim = account.account_for_hook("claude", {"session_id": "org-only-session"}) + assert claim == { + "organization_id": "org-only", + "source": "local_config", + } + event = build_external_canonical( + {"source_app": "claude", "event_type": "session_start", "account": claim} + ) + assert event["account"] == claim + + +def test_claude_local_config_is_not_cached_between_short_lived_hooks( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +): + import agentmetry.core.account as account + + config = tmp_path / ".claude.json" + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + config.write_text(json.dumps({"oauthAccount": {"emailAddress": "one@example.com"}}), encoding="utf-8") + first = account.account_for_hook("claude", {"session_id": "short-lived-1"}) + config.write_text(json.dumps({"oauthAccount": {"emailAddress": "two@other.example"}}), encoding="utf-8") + second = account.account_for_hook("claude", {"session_id": "short-lived-2"}) + assert first["domain"] == "example.com" + assert second["domain"] == "other.example" + + +@pytest.mark.parametrize( + "override", + ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL"], +) +def test_claude_local_config_is_skipped_for_auth_or_endpoint_override( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, override: str, +): + import agentmetry.core.account as account + + (tmp_path / ".claude.json").write_text( + json.dumps({"oauthAccount": {"emailAddress": "dev@example.com"}}), + encoding="utf-8", + ) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.delenv("AGENTMETRY_LOG_ACCOUNT_EMAIL", raising=False) + monkeypatch.setenv(override, "synthetic-override") + assert account.account_for_hook("claude", {"session_id": "override"}) == {} + + +def test_claude_official_base_url_keeps_the_fallback(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + import agentmetry.core.account as account + + (tmp_path / ".claude.json").write_text( + json.dumps({"oauthAccount": {"emailAddress": "dev@example.com"}}), encoding="utf-8" + ) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://api.anthropic.com") + assert account.account_for_hook("claude", {}) == {"domain": "example.com", "source": "local_config"} + + +@pytest.mark.parametrize( + "base_url", + ["https://gateway.example", "https://api.anthropic.com.evil.example", "not a url"], +) +def test_claude_non_official_or_malformed_base_url_skips_fallback( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, base_url: str, +): + import agentmetry.core.account as account + + (tmp_path / ".claude.json").write_text( + json.dumps({"oauthAccount": {"emailAddress": "dev@example.com"}}), encoding="utf-8" + ) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setenv("ANTHROPIC_BASE_URL", base_url) + assert account.account_for_hook("claude", {}) == {} + + +@pytest.mark.parametrize( + "override", + [ + "CLAUDE_CODE_USE_BEDROCK", + "CLAUDE_CODE_USE_VERTEX", + "CLAUDE_CODE_USE_FOUNDRY", + "CLAUDE_CODE_OAUTH_TOKEN", + "ANTHROPIC_PROFILE", + ], +) +def test_claude_additional_credential_overrides_skip_fallback( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, override: str, +): + import agentmetry.core.account as account + + (tmp_path / ".claude.json").write_text( + json.dumps({"oauthAccount": {"emailAddress": "dev@example.com"}}), encoding="utf-8" + ) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setenv(override, "1") + assert account.account_for_hook("claude", {}) == {} + + +def test_claude_federation_pair_requires_both_values(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + import agentmetry.core.account as account + + (tmp_path / ".claude.json").write_text( + json.dumps({"oauthAccount": {"emailAddress": "dev@example.com"}}), encoding="utf-8" + ) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setenv("ANTHROPIC_FEDERATION_RULE_ID", "rule") + assert account.account_for_hook("claude", {})["domain"] == "example.com" + monkeypatch.setenv("ANTHROPIC_ORGANIZATION_ID", "org") + assert account.account_for_hook("claude", {}) == {} + + +def test_claude_config_dir_selects_the_signed_in_account_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +): + import agentmetry.core.account as account + + config_dir = tmp_path / "claude-work" + config_dir.mkdir() + (config_dir / ".claude.json").write_text( + json.dumps({"oauthAccount": {"emailAddress": "work@example.com"}}), + encoding="utf-8", + ) + (tmp_path / ".claude.json").write_text( + json.dumps({"oauthAccount": {"emailAddress": "personal@example.com"}}), + encoding="utf-8", + ) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(config_dir)) + monkeypatch.delenv("AGENTMETRY_LOG_ACCOUNT_EMAIL", raising=False) + assert account.account_for_hook("claude", {"session_id": "config-dir"}) == { + "domain": "example.com", + "source": "local_config", + } diff --git a/apps/orchestrator/tests/test_detection_duplicate_emission.py b/apps/orchestrator/tests/test_detection_duplicate_emission.py index 6ec28cdf..8780df2d 100644 --- a/apps/orchestrator/tests/test_detection_duplicate_emission.py +++ b/apps/orchestrator/tests/test_detection_duplicate_emission.py @@ -65,14 +65,17 @@ def _trail_detections(corr: str) -> list[dict]: ] -def _cred_read(corr: str) -> dict: - return { +def _cred_read(corr: str, account: dict | None = None) -> dict: + payload = { "source_app": "claude", "event_type": "tool_called", "outcome": "success", "correlation_id": corr, "tool": {"qualified": "cursor.Read", "command": "cat ~/.ssh/id_rsa"}, } + if account: + payload["account"] = account + return payload def _approval_pending(corr: str) -> dict: @@ -85,14 +88,17 @@ def _approval_pending(corr: str) -> dict: } -def _egress(corr: str) -> dict: - return { +def _egress(corr: str, account: dict | None = None) -> dict: + payload = { "source_app": "claude", "event_type": "tool_called", "outcome": "success", "correlation_id": corr, "tool": {"qualified": "WebFetch", "command": "fetch https://example.com"}, } + if account: + payload["account"] = account + return payload async def _session_where_the_rule_completes_on_an_approved_call(corr: str) -> None: @@ -182,3 +188,15 @@ async def test_distinct_rules_in_one_batch_are_all_kept(sink: _CollectingSink): counts[rid] = counts.get(rid, 0) + 1 assert rule_ids, "expected at least one finding" assert all(n == 1 for n in counts.values()), counts + + +@pytest.mark.asyncio +async def test_detection_in_the_real_ingest_trail_keeps_the_completing_account(sink: _CollectingSink): + account = {"domain": "example.com", "source": "hook_payload"} + corr = "sess-account-detection" + await ingest_external_event(_cred_read(corr, account)) + await ingest_external_event(_egress(corr, account)) + + trail = [d for d in _trail_detections(corr) if d["detection"]["rule_id"] == "credential-exfil"] + assert len(trail) == 1 + assert trail[0]["account"] == account diff --git a/docs/agentmetry-event-schema.md b/docs/agentmetry-event-schema.md index ee48fb78..7e244b10 100644 --- a/docs/agentmetry-event-schema.md +++ b/docs/agentmetry-event-schema.md @@ -11,6 +11,10 @@ Set operator identity for multi-user SIEM queries: `AGENTMETRY_OPERATOR_ID=dev_0 Set fleet scope for org-level SIEM queries: `AGENTMETRY_FLEET_ID=consulting-pilot` +Account email capture is off by default. Set `AGENTMETRY_LOG_ACCOUNT_EMAIL=1` +only when the privacy review permits retaining the full address. The default +stores the account domain only. + ### Forward sinks | Env | Default | Description | @@ -123,6 +127,86 @@ which its JSON parser already handles. Nothing moved, nothing changed meaning, nothing became required. Consumers pinning the exact string rather than a lower bound are the only ones that need a change. +## Reported agent account (additive) + +When a supported capture surface reports an account, the canonical event may +carry this optional block. It is a client claim, not verified identity, and the +schema version remains `1.2.0`. + +```json +"account": { + "domain": "example.com", + "source": "hook_payload", + "organization_id": "org-123" +} +``` + +`domain` is lowercased and bounded. `source` identifies the reporting surface: +`hook_payload`, `otel` or `local_config`. `organization_id` is included only +when the agent reports one. The full `email` key is absent unless +`AGENTMETRY_LOG_ACCOUNT_EMAIL=1` is explicitly set. That opt-in is personal +data and should be covered by the customer's privacy review. + +Coverage is deliberately uneven. Cursor's documented hook payload supplies +`user_email`. Claude Code's documented OpenTelemetry attributes supply +`user.email` and `organization.id`; Claude's undocumented local +`oauthAccount.emailAddress` and `oauthAccount.organizationUuid` fields are a +bounded fallback for hook capture and may change between versions. The local +fallback is skipped when API-key, token, provider, profile, OAuth-token, +federation, or routed-access environment state selects another credential. +`ANTHROPIC_BASE_URL=https://api.anthropic.com` is the one exception: its +official host keeps the fallback, while other, look-alike, or malformed URLs +skip it. Codex and Antigravity have no documented non-secret account field in their +hook input, so their block is absent. Sessions with an override and agents +with no account claim are also absent. + +The block is forwarded unchanged in the canonical file and webhook formats. +Elastic ECS keeps the claim under `agentmetry.account.domain` and +`agentmetry.account.organization_id`, rather than ECS `user.*` or +`organization.id`. Google SecOps keeps the values as explicit labels. Splunk +and other consumers can query the nested canonical fields. These fields do not +prove tenant membership, and the sensor does not ship a list of personal +domains. Compare the domain with the customer's approved list in the SIEM. + +Example queries: + +```spl +index=main sourcetype=agentmetry:json account.domain=* +| where NOT in(account.domain, "corp.example", "subsidiary.example") +``` + +```yaral +rule agentmetry_unapproved_agent_account_domain { + meta: + author = "Customer SOC" + description = "Coding agent reported an account domain outside the approved list" + severity = "MEDIUM" + events: + $e.metadata.vendor_name = "Agentmetry" + $label = $e.additional.labels + $label.key = "agent_account_domain" + not $label.value in %agentmetry_approved_account_domains + condition: + $e +} +``` + +An event records nothing before installation, and nothing for an agent with no +hook or no documented account claim. The undocumented Claude local file is +read only when it is at most 1 MiB; larger files produce no local-config claim. +`CLAUDE_CONFIG_DIR` is honoured when locating the file because Claude Code +documents it as the directory selector for separate account configurations. +The hook environment can be scrubbed or differ from the Claude session's +environment, so this check cannot be perfect. `apiKeyHelper`, an active +profile, and gateway sign-in are not inferred by the hook because they require +settings or credential-state parsing. `local_config` means “the account this +machine last signed in with”, not “the account that made this call”. +Browser sign-in is outside this sensor's +view; a CASB or vendor audit log is needed for that surface. EU deployments +should have counsel decide the lawful basis, notice, retention, access controls, +employee-representation obligations and whether a DPIA is required before +enabling the full email opt-in. + ## MITRE ATLAS (`atlas`) ATT&CK describes what the agent did to the host. ATLAS describes what was done @@ -347,6 +431,10 @@ Two integrations outside this repository read the canonical event. The fields ea **Tenzir.** The `agentmetry` package in the Tenzir Library ([tenzir/library#211](https://github.com/tenzir/library/pull/211)) maps events to OCSF 1.9.0. It reads these canonical fields: `event_id`, `schema_version`, `timestamp_utc`, `correlation_id`, `host_id`, `fleet_id`, `source.adapter`, `action.type`, `action.outcome`, `action.reason`, `actor.id`, `actor.type`, `agent.name`, `initiator.operator_id`, `tool.name`, `tool.server`, `tool.command`, `gated_action.tool`, `gated_action.server`, `gated_action.input_hash`, `tool_policy.rule_id`, `detection.rule_id`, `detection.severity`, `detection.title`, `detection.summary`, `detection.correlation_id`, `detection.tactic_ids`, `detection.technique_ids`, `detection.event_ids`, `detection.first_seen_utc`, `detection.last_seen_utc`, `mcp_schema.status`, `mcp_schema.server_id`, `mcp_schema.server_version`. The same rule holds: none of these is renamed or moved within 1.x, and a change that would touch one ships with a matching update to the package, which this project committed to maintain in #211. Everything else on the event, including `tool.mitre`, `tool.atlas`, `tool.input_hash` and `dlp`, reaches the OCSF event under `unmapped`. +The optional `account` block is not currently part of either outside +integration's promoted field promise. AiSOC and Tenzir consumers should treat +it as an additive canonical field until their own packages explicitly map it. + It also reads a trail line as written, unwrapping `{"trail": {"v", "seq", "prev_sha256", "record_sha256"}, "event": {...}}` and keeping `trail.seq`, so that layout is part of the same promise. [anchoring.md](./anchoring.md) covers how the chain fields are computed. For file tools it looks for a path under `tool.arguments.file_path`, `tool.arguments.path` or `tool.arguments.target_file`. Those keys are named by each agent, not by Agentmetry, so they are outside the promise, and they are present only when full-argument capture is on. Its tests pass on recorded events; a live webhook into `accept_http` has not been run. ## Limitations (Tier C) diff --git a/docs/integrations/google-secops.md b/docs/integrations/google-secops.md index 1099a920..cab2a667 100644 --- a/docs/integrations/google-secops.md +++ b/docs/integrations/google-secops.md @@ -79,6 +79,36 @@ AGENTMETRY_CHRONICLE_BEARER_TOKEN=ya29.... ## YARA-L rules +### Account domain outside the approved list + +Agentmetry emits the reported account domain as an explicit label. The label is +not verified identity. Populate the reference list from the customer's tenant +inventory rather than shipping a vendor-owned list of personal domains. + +This rule has **not been run against a live Google SecOps tenant**. It follows +Google's documented repeated-field behavior and reference-list syntax: +[UDM field list](https://docs.cloud.google.com/chronicle/docs/reference/udm-field-list), +[repeated fields](https://docs.cloud.google.com/chronicle/docs/yara-l/repeated-fields), +and [reference-list syntax](https://docs.cloud.google.com/chronicle/docs/yara-l/reference-list-syntax). +The `$label` binding keeps the key and value on the same repeated label object, +and `not $label.value in %...` is the documented negated reference-list form. + +```yaral +rule agentmetry_unapproved_agent_account_domain { + meta: + author = "Customer SOC" + description = "Coding agent reported an account domain outside the approved list" + severity = "MEDIUM" + events: + $e.metadata.vendor_name = "Agentmetry" + $label = $e.additional.labels + $label.key = "agent_account_domain" + not $label.value in %agentmetry_approved_account_domains + condition: + $e +} +``` + ### C1 — Any Agentmetry detection at HIGH or above ```yaral diff --git a/docs/integrations/splunk-hec.md b/docs/integrations/splunk-hec.md index dcb61791..9fca1877 100644 --- a/docs/integrations/splunk-hec.md +++ b/docs/integrations/splunk-hec.md @@ -80,6 +80,22 @@ index=main sourcetype=agentmetry:json index=main sourcetype=agentmetry:json action_outcome=denied ``` +**Reported account domain outside the approved list:** + +This search has **not been run against a live Splunk tenant**. It assumes the +canonical nested JSON is indexed and that the customer's approved-domain list +is represented by the two example values below. + +```spl +index=main sourcetype=agentmetry:json account.domain=* +| where NOT in(account.domain, "corp.example", "subsidiary.example") +| table _time host_id source_app account.domain account.source account.organization_id +``` + +The domain is a client claim, not verified identity. Keep the approved-domain +list in the customer's SIEM configuration. Full email capture is disabled by +default and requires `AGENTMETRY_LOG_ACCOUNT_EMAIL=1`. + **Replay correlation id locally:** ```powershell