Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions apps/orchestrator/agentmetry/api/routes/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 = ""
Expand Down
206 changes: 206 additions & 0 deletions apps/orchestrator/agentmetry/core/account.py
Original file line number Diff line number Diff line change
@@ -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")
9 changes: 9 additions & 0 deletions apps/orchestrator/agentmetry/core/audit/adapters/chronicle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down
6 changes: 5 additions & 1 deletion apps/orchestrator/agentmetry/core/audit/detection/live.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", ""),
Expand All @@ -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
11 changes: 11 additions & 0 deletions apps/orchestrator/agentmetry/core/audit/external.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
7 changes: 6 additions & 1 deletion apps/orchestrator/agentmetry/core/audit/otel_ingest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -211,14 +212,18 @@ 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,
"session_id": session,
"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]:
Expand Down
5 changes: 5 additions & 0 deletions apps/orchestrator/agentmetry/hooks/ingest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading