A companion post introduced least-privilege drift as the gap between what an AI agent is permitted to do and what it is observed doing. That post covered the concept: why the gap forms, how read-first observation produces a permitted-versus-observed diff, and how policy-as-code closes it at access time. This post goes one layer deeper — into how drift becomes a structured, classified, triageable finding inside the security module, and what happens to it once it exists.
The short version: drift is not a dashboard metric or a log line. It is a persisted entity with a classification, a severity, a confidence level, and a triage lifecycle. It feeds the anomaly queue, enriches forensic timelines, and is honest about what it can and cannot prove.
The three signal sources
Every edge in the access map records an access relationship between an origin (an agent, an identity, a session) and a resource. Each edge carries two boolean flags: Permitted and Observed. The interesting edges are the ones where those flags disagree.
But the flags do not appear from thin air. They come from distinct signal sources, each with a different trust profile:
Policy (permitted). An edge with signal_source=policy or signal_source=scoped_grant represents a declared grant: something a credential, an IAM role, or the platform’s own source-scoping plane says this agent is allowed to do. These edges are permitted=true, observed=false until telemetry corroborates them. They are the ceiling. The identity connectors (WIF issuers, API key rosters, workspace roles) feed this side. A federated service account permitted its rule’s OAuth scope in a workspace is one such edge.
Telemetry (observed). Cooperative signals from OpenTelemetry traces, pgAudit logs, CloudTrail records, MCP annotations, A2A protocol observations, and GitHub/GitLab webhooks. These produce edges where observed=true. Their confidence depends on the source: a pgAudit READ classification is attributed (the database knows who queried and whether it was a read or a write); an MCP readOnlyHint is approximate by specification — the MCP spec itself says tool annotations are untrusted.
Kernel (ground truth). The eBPF backstop (signal_source=ebpf) observes at the syscall level. It is the signal an agent cannot route around. When the eBPF layer sees a connect() or a write() that cooperative telemetry did not report, that is not a logging gap — it is an anti-evasion signal. The security module joins the kernel-side and cooperative-side observations into a correlated anomaly, so an agent that silences its own telemetry becomes a finding, not a blind spot.
The access map is a query over these edges, not a separate schema. Least-privilege drift is the subset where the two flags disagree.
How drift becomes a finding
A disagreement between Permitted and Observed is the raw signal. The engine classifies it into one of two drift kinds before it enters the anomaly queue:
// DriftKind classifies a least-privilege drift between permitted and observed.
type DriftKind string
const (
// DriftUnusedGrant is a permitted access never observed (over-provisioned).
DriftUnusedGrant DriftKind = "unused_grant"
// DriftViolation is an observed access that is not permitted.
DriftViolation DriftKind = "violation"
)
unused_grant means a policy says the agent can do something it has never been seen doing. This is dead privilege — risk carried for no benefit. It is the cleanup signal for periodic access reviews: revoke what is not exercised.
violation means the agent was observed doing something no policy or grant permits. This is the active finding. It is the row in the diff table that reads “unreviewed write” — the edge that matters, the one the anomaly queue prioritizes.
The classification is not binary between “fine” and “problem.” The PrivilegeDrift struct pairs the offending edge with its kind:
// PrivilegeDrift is one least-privilege discrepancy: an edge whose Permitted
// and Observed flags disagree.
type PrivilegeDrift struct {
Edge AccessEdge
Kind DriftKind
}
The edge itself carries the full provenance: the origin (which agent), the resource, the read/write mode, the signal source that produced it, the confidence in the attribution, and the observation window (FirstSeen, LastSeen, OccurrenceCount). A drift finding is never “something is wrong somewhere.” It points to a specific agent, a specific resource, a specific access mode, observed by a specific collector, with a time range.
The anomaly queue: not just “different,” but classified
When the security module builds the anomaly view (the GET /v1/m/security/anomalies endpoint), it pulls drift from the access-edge store and classifies each violation into a prioritized anomaly. The classification adds context the raw edge does not carry:
Access drift. The baseline: an observed-but-not-permitted access. The anomaly is titled “Unexpected access: observed but not permitted,” severity medium, and flagged with confidence=approximate because the store-level drift is the raw signal — not yet reconciled against the full agent-to-identity graph. The reconciled view lives in the access map’s own /drift endpoint; the anomaly queue consumes the raw signal and labels it honestly.
Egress/exfiltration suspected. When the resource on the drift edge is a network endpoint to an external, non-private destination (the eBPF connector emits these as tcp://host:port URIs), the anomaly is reclassified to egress_exfil_suspected and promoted to severity high. An agent writing to an external endpoint it was never granted access to is a different shape of finding than an agent reading a database table it was not scoped for.
Sensitivity escalation. When the resource carries a sensitivity label (high or secret), the severity is promoted regardless of whether the access is egress. A violation against a high-sensitivity resource warrants faster triage even if the destination is internal.
Each anomaly carries an evidence map with the raw details:
ev := map[string]any{
"origin_kind": edge.OriginKind,
"origin_id": edge.OriginID.String(),
"resource_id": edge.ResourceID.String(),
"mode": string(edge.Mode),
"signal_source": string(edge.SignalSource),
"occurrence_count": edge.OccurrenceCount,
"reconciled": false,
}
The reconciled: false is deliberate. It tells the consumer this is the raw store-level signal, not the access map’s reconciled, agent-to-identity view. The anomaly queue does not wait for reconciliation to surface a violation — but it does not pretend the attribution is firm when it is not.
Confidence levels: what can be proven
Every edge in the access map carries a confidence level. The product uses two:
-
Attributed (
confidence=attributed): the access is firmly tied to the origin by the collector’s own evidence. A pgAudit record naming the agent’s database role, a CloudTrail event tied to the agent’s IAM credentials, an eBPF observation tied to a process ID the runtime resolved to an agent. The attribution chain is end-to-end. -
Approximate (
confidence=approximate): the attribution is inferred and may be lossy. The signal came from a shared service account where multiple agents use the same credential, from a lossy store (a Redis instance that does not log per-connection identity), or from an MCP annotation the spec says is untrusted. The edge is still a signal — but the operator knows the proof is weaker.
The confidence level affects the anomaly’s priority score. The priority function (priorityFor) scores each anomaly from 0 to 100, and an approximate-confidence drift is discounted:
if confidence == string(sdkmodel.ConfidenceApproximate) {
// discount: unreconciled drift is noisy
}
This prevents a noisy, shared-identity signal from pushing aside a firmly attributed violation. Both appear in the queue; the approximate one ranks lower. It is the same principle the forensic enrichment applies: when a shared identity (SharedIdentity: true, AgentCount > 1) is found, the timeline notes that “per-agent attribution may be ambiguous” rather than pretending the attribution is exact.
The product never fakes certainty. An MCP annotation-only edge does not carry the same weight as an eBPF-corroborated one. A drift finding from a shared service account does not carry the same weight as one from a per-agent identity. The operator sees the difference and triages accordingly.
Drift in the forensic timeline
Drift findings do not exist only in the anomaly queue. When a forensic case is opened and its timeline is reconstructed from the hash-chained audit ledger, the security module enriches the reconstruction with the subject’s drift:
out.Drift = subjectDrift(r.Context(), sc, c.SubjectRef)
The subjectDrift function queries the access-edge store for violation-kind drift where the subject is the origin, and returns a list of driftRefDTO entries — each carrying the origin, resource, access mode, signal source, occurrence count, and last-seen timestamp. These are read from the store’s own drift computation, not recomputed by the security module.
The timeline also carries the subject’s identity attribution and data lineage, so an investigator reviewing a case sees the full picture: who the agent acts as (and whether that identity is shared), what it accessed that it should not have, and what data it derived answers from. Each of those enrichments is tolerant of absent neighbors — if the knowledge module is not installed, lineage is omitted rather than faked; if the identity has no resolved agent binding, attribution says “not yet bound” rather than inventing one.
The triage lifecycle
A drift-originated finding enters the system with status open. From there it follows the standard finding triage lifecycle:
- open: the finding exists, nobody has acted on it yet.
- triaged: an operator acknowledged it, assigned it for review.
- resolved: the underlying cause was fixed (the grant was revoked, the policy was tightened, the agent was rescoped).
- dismissed: the operator reviewed it and determined it is not a real risk (a known behavior, a false positive from approximate attribution).
Every triage state change is self-audited to the real principal. The act of dismissing a finding is itself recorded in the tamper-evident ledger, so an auditor can see not only what drift occurred but who reviewed it and what they decided. The finding’s evidence (its kind, severity, and detail hash) is immutable after creation; triage changes only the workflow state.
What this means in practice
An agent is given an API key scoped to a Claude workspace. The identity connector reads the workspace’s roster and emits a policy edge: this key is permitted to call the API in this workspace. The agent runs for three weeks. Telemetry shows it calling the API in that workspace — no drift. Then a deployment change gives the agent access to a second workspace’s key. Telemetry observes the agent calling both workspaces. The second workspace has no policy edge. A drift finding of kind violation is created: observed but not permitted. It enters the anomaly queue as access_drift, severity medium, confidence approximate (the edge has not been reconciled against the identity graph yet). If the resource in the second workspace carries a high sensitivity label, the severity promotes. The finding sits in the operator’s queue until someone triages it.
That is the difference between logging and structured drift: the finding is classified, attributed (with honest confidence), prioritized, and triageable. It persists until someone resolves or dismisses it. It appears in the forensic timeline if the agent is later the subject of an incident case. And every action taken on it is recorded in a chain whose integrity the product can prove.
Drift is not a metric to minimize. It is a finding to triage. The gap between what an agent is permitted to do and what it is observed doing is the highest-signal surface for least-privilege enforcement — but only when the gap is structured, classified, and honest about what it can prove.
To see the access map and drift surface on a running estate, visit the access map product page or the product overview.