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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ This format follows [Keep a Changelog](https://keepachangelog.com/) and adheres

## [Unreleased]

### Added
- **Agents can now carry a first-class Microsoft Entra identity that travels
from registration through traces into release evidence.** Before this, a
trace could tell you what an agent did but not which registered agent did it,
so nothing in the evidence pack tied runtime behaviour back to an accountable
owner in the tenant. Three pieces close that loop. `agentops agent register`
creates or adopts an agent identity blueprint in Microsoft Entra (idempotent,
sponsor required, `--dry-run` supported) and records the resolved id under
`.agentops/identity/agent-identity.json`. AgentOps then stamps that id on
every span it emits as the OpenTelemetry resource attribute
`gen_ai.agent.id`, omitting the attribute entirely when no identity is
registered so presence is a meaningful filter. Finally, the release evidence
pack publishes an `agent_identity` section reporting the id and its source.
A read-only Doctor check reports registration posture, contacting Microsoft
Graph only when `identity.verify` is enabled in `agentops.yaml`. `agentops.yaml`
accepts a new optional `identity` block (`display_name`, `sponsor`, `verify`).

### Fixed
- **The official evaluation runner now honours the agent version override.**
`prepare_official_eval` read the agent name and version straight from
Expand Down
14 changes: 13 additions & 1 deletion docs/doctor-checks.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Two conventions across the table:

## Data sources

The Doctor reaches Azure through five sources, all configured in
The Doctor reaches Azure through six sources, all configured in
`.agentops/agent.yaml`:

| Source | Reads |
Expand All @@ -43,6 +43,7 @@ The Doctor reaches Azure through five sources, all configured in
| `azure_monitor` | Application Insights / Log Analytics via REST (KQL) |
| `foundry_control` | Foundry project / agents / evaluation rules via `azure-ai-projects` |
| `azure_resources` | Cognitive Services account properties via `azure-mgmt-cognitiveservices`; inferred from explicit config, AZD `.azure/<env>/.env` when present, or Foundry endpoint/account matching |
| `graph` | Microsoft Entra agent identity blueprints via Microsoft Graph, read-only, and only when `identity.verify` is enabled |

The LLM-judged rules additionally use the Foundry project's OpenAI
client (auto-discovered) as the judge model.
Expand All @@ -64,6 +65,17 @@ stopping the whole run.
| `waf.security.diagnostic_settings` | warning | `azure_resources` | programmatic | account has ≥1 diagnostic setting with a `workspace_id` |
| `safety.runtime.content_filter` | warning | `azure_monitor` | programmatic | KQL hits on `gen_ai.response.finish_reasons contains content_filter` |
| `responsible_ai.llm.prompt_jailbreak_surface` | info / warning | `foundry_control` | llm-judged | judge model scans system prompt for override-phrasing, embedded secrets, unbounded role-play |
| `agent_identity.not_registered` | warning | `graph` | programmatic | no Entra Agent ID in `.agentops/identity/agent-identity.json` or `AGENTOPS_ENTRA_AGENT_ID` |
| `agent_identity.not_recorded` | info | `graph` | programmatic | `identity.verify: true` and Graph finds a blueprint, but no local record exists |
| `agent_identity.lookup_failed` | warning | `graph` | programmatic | `identity.verify: true` and the Graph lookup failed (missing consent, throttling, network) |

!!! info "Identity checks are read-only"
The `agent_identity.*` checks never create or modify anything in Microsoft
Entra. Registration is an explicit, separate action: `agentops agent
register`. Graph is contacted only when `identity.verify` is set to true in
`agentops.yaml`; with it off, the check reads local state only and still
reports whether an identity exists. See
[Agent identity on traces](observe.md#agent-identity-on-traces).

### ⚙️ Operational Excellence

Expand Down
68 changes: 68 additions & 0 deletions docs/observe.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,74 @@ with no monitoring does not look healthy simply because nothing is being graded.
is intended: a real release should investigate latency and errors before
promoting, even when the candidate's eval scores pass.

## Agent identity on traces

Traces tell you what an agent did. They do not, by default, tell you *which*
agent did it in a way an auditor can reconcile with your tenant. Microsoft
Entra Agent ID closes that gap: the agent gets a first-class identity, and the
same identifier travels from registration through traces into release evidence.

The handshake has three steps, and each one is a different tool, so it is worth
being explicit about who writes what.

**1. Register the identity.** `agentops agent register` creates (or adopts) an
agent identity blueprint in Microsoft Entra and records the resolved
application id locally:

```bash
agentops agent register --sponsor owner@contoso.com
```

The sponsor is required. An agent identity with no accountable owner cannot be
governed, so there is no default. The command is idempotent: if a blueprint
with the same display name already exists, AgentOps reuses it instead of
creating a duplicate. Run it with `--dry-run` first to see the resolved display
name and sponsor without calling Microsoft Graph.

The resolved id is written to `.agentops/identity/agent-identity.json`. Declare
the inputs in `agentops.yaml` so they are source-controlled:

```yaml
identity:
display_name: support-agent
sponsor: owner@contoso.com
verify: true
```

`verify: true` tells the Doctor to confirm the blueprint against Microsoft
Graph. It is off by default because that lookup needs tenant admin consent
(`AgentIdentityBlueprint.Read.All`), which most workspaces will not have on day
one. With it off, the Doctor still reports whether an identity is registered at
all, using only local state.

**2. Stamp it on traces.** Once an identity is resolved, AgentOps adds it to
the OpenTelemetry resource as `gen_ai.agent.id`, so every span AgentOps emits
carries the Entra Agent ID. In CI, where the local record is not checked in,
set `AGENTOPS_ENTRA_AGENT_ID` instead and the attribute resolves from the
environment.

The attribute is **omitted** when no identity is registered, never emitted as an
empty string. That distinction matters when you query: filtering on presence
tells you which traffic is attributable and which is not.

```kusto
dependencies
| where isnotempty(customDimensions["gen_ai.agent.id"])
| summarize runs = count() by tostring(customDimensions["gen_ai.agent.id"])
```

**3. Publish it as evidence.** The release evidence pack reads the same record
and adds an `agent_identity` section reporting the id and where it came from
(the local record or the environment variable). When no identity is registered,
the pack raises a warning rather than a blocker, because identity registration
is a governance improvement rather than a correctness gate.

!!! note "AgentOps does not ingest into Agent 365"
There is no public ingestion API for Agent 365 telemetry today. AgentOps
stamps the identifier and publishes it as evidence so the correlation is
possible from the Azure Monitor side. It does not push traces into Agent
365.

## Trace-to-regression promotion

The strongest use of observability is turning real production behavior into new
Expand Down
22 changes: 22 additions & 0 deletions docs/ship.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,28 @@ long steps here:
- [GitHub OIDC with Azure (workload identity federation)](https://learn.microsoft.com/azure/active-directory/workload-identities/workload-identity-federation-create-trust?pivots=identity-wif-apps-methods-azp)
- [Assign Azure roles (RBAC)](https://learn.microsoft.com/azure/role-based-access-control/role-assignments-portal)

### Giving the agent its own identity

The OIDC principal above answers "which pipeline is deploying". It does not
answer "which agent is running in production", and that second question is the
one an auditor asks. A Microsoft Entra agent identity closes the gap: the agent
gets its own directory object with a named human sponsor, and AgentOps then
stamps that id on every trace and republishes it in the release evidence.

Registration is a one-line command and it is idempotent, so re-running it adopts
the existing blueprint instead of creating a duplicate:

```bash
agentops agent register --sponsor owner@contoso.com
```

The generated production workflows carry the same step, disabled by default.
Set the `AGENTOPS_IDENTITY_SPONSOR` repository variable to the sponsor's UPN and
the step turns on. It stays opt-in because it writes to your tenant, which needs
a deliberate decision rather than a default. The full handshake, the
`identity` block in `agentops.yaml`, and the trace query are documented in
[Agent identity on traces](observe.md#agent-identity-on-traces).

## Try it

Generate the CI/CD workflows from the same analysis AgentOps uses, smallest gate
Expand Down
2 changes: 2 additions & 0 deletions src/agentops/agent/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from agentops.agent.checks.errors import run_errors_check
from agentops.agent.checks.foundry_config import run_foundry_config_check
from agentops.agent.checks.agent_identity import run_agent_identity_check
from agentops.agent.checks.governance import run_governance_check
from agentops.agent.checks.latency import run_latency_check
from agentops.agent.checks.observability import run_observability_check
Expand Down Expand Up @@ -151,6 +152,7 @@ def analyze(
findings.extend(run_rbac_openai_data_plane_check(resources))
findings.extend(run_opex_workspace_check(workspace))
findings.extend(run_governance_check(workspace))
findings.extend(run_agent_identity_check(workspace))
findings.extend(run_observability_check(workspace))
findings.extend(run_opex_check(history, config.checks.opex))
findings.extend(run_release_readiness_check(workspace, history, foundry))
Expand Down
144 changes: 144 additions & 0 deletions src/agentops/agent/checks/agent_identity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""Agent 365 registration posture check.

``agentops doctor`` scores a workspace against Well-Architected rules, but it
had no visibility into whether the agent exists as a first-class identity in
Microsoft Entra. Without an agent identity blueprint the agent cannot be
governed by Microsoft Agent 365: it does not appear in the agent inventory,
Conditional Access cannot target it, and its traces cannot be correlated back
to an accountable owner.

The check is deliberately read-only and cheap:

* it first resolves the identity from the workspace record or the
``AGENTOPS_ENTRA_AGENT_ID`` environment variable, which costs nothing,
* it only calls Microsoft Graph when the workspace opts in via
``identity.verify: true``, because the lookup needs tenant admin consent
that most workspaces will not have on day one,
* every Graph failure becomes a warning with a readable sentence, never a
stack trace.
"""

from __future__ import annotations

from pathlib import Path
from typing import Any, List

from agentops.agent.findings import Category, Finding, Severity
from agentops.services.agent_identity import (
REGISTRATION_DOCS_URL,
AgentIdentityError,
load_identity_config,
lookup_blueprint,
resolve_agent_id,
resolve_display_name,
)

SOURCE_NAME = "agent_identity"


def run_agent_identity_check(workspace: Path) -> List[Finding]:
"""Report whether the agent is registered in Microsoft Agent 365."""

workspace = Path(workspace)
identity = load_identity_config(workspace)
display_name = resolve_display_name(workspace) or workspace.resolve().name

if resolve_agent_id(workspace):
return []

if _verify_enabled(identity):
try:
blueprint = lookup_blueprint(display_name)
except AgentIdentityError as exc:
return [_lookup_failed(display_name, str(exc))]
if blueprint is not None:
return [_registered_but_unrecorded(blueprint.app_id, display_name)]

return [_not_registered(display_name)]


# ---------------------------------------------------------------------------
# Findings
# ---------------------------------------------------------------------------


def _not_registered(display_name: str) -> Finding:
return Finding(
id="agent_identity.not_registered",
severity=Severity.WARNING,
category=Category.SECURITY,
title="Agent is not registered in Microsoft Agent 365",
summary=(
"No Entra Agent ID is recorded for this workspace, so the agent has "
"no first-class identity in Microsoft Agent 365. Without it the agent "
"is absent from the tenant agent inventory, Conditional Access cannot "
"target it, and its traces cannot be attributed to an accountable owner."
),
recommendation=(
"Register the agent identity blueprint with "
"'agentops agent register --sponsor <upn-or-object-id>'. Set "
"'identity.sponsor' in agentops.yaml first so the registration is "
f"reproducible in CI. Background: {REGISTRATION_DOCS_URL}"
),
source=SOURCE_NAME,
evidence={"display_name": display_name, "registered": False},
)


def _registered_but_unrecorded(app_id: str, display_name: str) -> Finding:
return Finding(
id="agent_identity.not_recorded",
severity=Severity.INFO,
category=Category.SECURITY,
title="Agent identity exists in Entra but is not recorded locally",
summary=(
f"Microsoft Entra has an agent identity blueprint named "
f"'{display_name}', but this workspace has no local record of it. "
"Traces and the release evidence bundle therefore cannot quote the "
"Entra Agent ID."
),
recommendation=(
"Run 'agentops agent register' to adopt the existing blueprint into "
"this workspace. The command is idempotent and will reuse the "
"blueprint instead of creating a duplicate."
),
source=SOURCE_NAME,
evidence={"display_name": display_name, "app_id": app_id, "registered": True},
)


def _lookup_failed(display_name: str, reason: str) -> Finding:
return Finding(
id="agent_identity.lookup_failed",
severity=Severity.WARNING,
category=Category.SECURITY,
title="Agent 365 registration could not be verified",
summary=(
"AgentOps could not confirm whether this agent has an Entra Agent ID. "
f"{reason}"
),
recommendation=(
"Grant the AgentIdentityBlueprint.Read.All application permission and "
"admin consent, or set 'identity.verify: false' in agentops.yaml to "
"rely on the locally recorded identity instead."
),
source=SOURCE_NAME,
evidence={"display_name": display_name, "reason": reason},
)


# ---------------------------------------------------------------------------
# Config helpers
# ---------------------------------------------------------------------------


def _verify_enabled(identity: dict[str, Any]) -> bool:
value = identity.get("verify")
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return False


__all__ = ["SOURCE_NAME", "run_agent_identity_check"]
Loading
Loading