Discover agents in Entra
Scout answers one question about agents the customer already knows about: what can each one actually do, through which path, and is that path governed.
Status: the first source and Agents page are implemented. The September 18 lab probe read a small real directory and corrected collection and finding behavior. Inherited allAllowed permissions, attribution to a real agent and populated customer directories remain untested in that probe. The integration catalog retains a fixture-tested claim until a complete versioned trial record is available.
What ExecBound does not do
ExecBound does not find agents. The customer's identity provider and platforms already list them, and endpoint, network and browser discovery belongs to the suite vendors — Agent 365 through Defender and Intune, Okta ISPM, SSE products. ExecBound consumes those inventories.
Three consequences the pages state rather than imply:
- Agents with no identity are not counted. Microsoft's own endpoint sweep classifies them; Graph does not serve them. ExecBound never asserts a number that would include them.
- Azure RBAC assignments are not served by Graph at all. An agent's reach into Azure resources is a hole, not a zero.
- A group that carries a directory role is a reach path Scout does not follow yet. A group membership is not a directory role, and the parser refuses to treat it as one.
Every tenant number states its denominator in words: agents this connection could enumerate, never agents that exist.
The certainty ladder
A row's certainty is only ever what its own path can establish.
| Certainty | Established by | What it does not establish |
|---|---|---|
verified | An execution through ExecBound under a credential bound to this agent's linked principal | Anything about routes that do not traverse the kernel |
observed | A Monitor observation of this agent executing the action, such as an entry in the directory's own audit log | That anything stood in the way |
inferred | A grant, role or inherited permission the reviewed grant catalog maps to the action | That the agent has ever used it |
unknown | A claim whose proof has lapsed | That the reach has gone |
A grant the catalog cannot explain produces no reach row at all. A row has to name an action, and naming one that cannot be established would be the invention the catalog exists to prevent. Those grants are counted and shown instead.
Freshness is read, not written: seven days for a grant snapshot, thirty for an execution or an observation, counted from its moment and never from later than the moment reach is derived, since no evidence is fresher than the pass reading it. Every agent's verified and observed reach is derived at each process, a page at a time, however many agents and actions sort before it. An expired row renders as unknown with the moment it lapsed, rather than being deleted, because deleting it would quietly shrink the denominator.
Reach is per path
One row per (action, path_kind, path_ref), never one per action. An agent that holds a directory grant and sends requests through ExecBound has two true answers to "through which path", and collapsing them is what would hide the alternate route.
A grant path can never claim an enforced mode. Nothing ExecBound operates stands between an agent and its own credential, so those rows are unknown. The enforcement modes are defined with the integrations.
Adding a governed path does not close an ungoverned one. Registering an operation mapping, a policy and an executor gives the agent a route that is enforced for requests that traverse it. The directory grant is still there. ungoverned-reach.v1 therefore stays open until the grant is gone at a later collection, or until an administrator accepts the path (below) — which leaves the reach row exactly where it was, because deciding to live with a route is not the route disappearing.
Accountability
Four states that a looser design would collapse into one, and only absent is a finding:
present— sponsors (or, for a classic service principal, owners) are recorded.absent— the directory served the collection and it was empty. This raisessponsorless-agent.v1.unreadable— the directory refused the read. Insufficient evidence, counted separately.not_read— this run has not looked yet.
A sponsor is stored as an object identifier and a kind. No name, address or principal name is collected.
The first source: Entra Agent ID
Read-only, through Microsoft Graph, under a collection app registration separate from any registration that executes. One client secret must never be both the executor and the collector. The collection registration holds AgentIdentity.Read.All, Application.Read.All, AuditLog.Read.All and Directory.Read.All, none of which can write. A registration that does not hold AuditLog.Read.All is still collected; its probe shows the audit log capability as forbidden, and the connection is not reported as complete until it is granted (below). The sign ins capability, beta, needs the same permission and Microsoft Entra ID P1 or P2, and its loss never makes a connection read as incomplete (below).
A run makes exactly one POST, to the pinned login origin, for a token; every read is a GET. Graph returns the next page as a URL inside the response — the one place a request could be built from a response field — so a continuation is followed only when it is the pinned origin and the same path. A delta link is held to the same rule, and to more, because it is also stored (below).
A run reads at most 1,000 principals. A directory that lists more, in its agent identities, its service principals or the two together, is refused whole with AGENT_CAPACITY and nothing from it is written; it is never read in part, because a principal past a silent cut would be marked gone, then stored for the first time by a later read and reported as new. A run reads each collection in at most twelve pages, enough for the bound when Graph fills the pages of 100 it asks for; a directory under the bound that Graph serves in shorter pages, so that the twelfth still offers more, is refused the same way rather than cut short. A delta pass is held to the same bound: one whose changes would leave the directory holding more is refused the same way.
The grant catalog and its gaps are documented in src/execbound/monitoring/grant_catalog.py: Graph permissions are keyed by published name and resolved to an identifier from the tenant's own appRoles, so no vendor identifier is copied into this project, and the directory-role identifiers that could not be established from a primary reference are named rather than recalled.
Between full collections: delta, the baseline and the new-actor rules
A full collection reads the whole directory every six hours by default. Between full collections, each scheduled pass gives a directory a delta pass that reads only what changed, so a new principal is seen within minutes. The full collection stays the reconciliation.
The delta link. A full collection asks Graph for a servicePrincipals/delta link before it reads the directory, so a change made while it reads is reported again rather than missed, and keeps the link only if the collection completes. Only a collection that reads both the agent identities and the service principals asks for one; any other, like one that fails or gets no link it can trust, leaves the stored link where the delta passes left it. It asks for $deltatoken=latest, and where Graph refuses that, walks an initial round for its link and keeps nothing the round lists. The link is handled like any continuation: every next link and delta link must be the pinned Graph origin and exactly /v1.0/servicePrincipals/delta, a delta link must carry a delta token, and the connection keeps the link rebuilt from the pinned origin, the path and the validated query rather than as Graph sent it. A stored link is validated again before every use, and the database holds the same prefix as a check. The request a delta pass sends is always the pinned origin and path with that query; nothing in a response chooses where the next request goes. The pass asks Graph to track only the allowlisted fields servicePrincipal itself defines.
What a delta pass does. It runs when the directory holds a link and a baseline, its last full collection completed, it is not due for a full collection, no run holds it and no administrator's collection request waits. It takes the same lease as any run, through a claim of its own that the database refuses while a request waits and that never answers one, so a request made meanwhile survives it for the full collection to answer. It reads first: the round, then grants, roles and accountability for each principal the round lists. Graph may list a principal more than once in a round, a later entry carrying only what changed, so the pass lays a principal's entries one over the other: a property a later entry leaves out keeps what an earlier one said, such as the organization that owns its application, rather than reading as cleared. Only then does it write. It inserts a principal it has not seen, restores one marked gone, and marks a removed one gone with its grants and paths. Nothing else about a principal already held is rewritten, because Graph represents an updated principal with only some of its properties; its blueprint and name are refreshed by the next full collection. A principal's class is written once, from a record that states it, and is the store's after that, in a full collection as in a delta pass: a delta entry for a principal the store does not hold that carries neither the agent identity @odata.type nor a servicePrincipalType does not say whether it is an agent identity, so it is left for the next full collection, whose agent identity collection says so, rather than stored as a classic principal and read for owners instead of sponsors. It then runs the agent rules, and only then advances the link, so a pass that stops part way reads the same changes again. It never records an outcome, never moves the schedule and never changes the connection's health, even when the collector refuses its key file or platform app before any request, a refusal the next full collection records. That includes a pass that stops while it holds the lease: the lease is marked as a delta pass's, so the next claim takes it back without recording an interrupted run, and the next pass gives the directory another delta pass. A full collection that stops while it holds the lease is still recorded INTERRUPTED by whichever claim comes next, and a full collection, not a delta pass, follows it.
When the link is dropped. A 400 or 410 from Graph, as for an expired token, a stored link that no longer validates, a next link or delta link off the pinned read, a round past 20 pages or 1,000 changes, and changes that would leave the directory past 1,000 principals each drop the link (RESYNC_REQUIRED, MALFORMED, DELTA_OVERFLOW, AGENT_CAPACITY). Nothing from that round is written, the connection stays healthy, and the next full collection takes a new link. Any other refusal from Graph, while the pass reads the round or what its principals hold, keeps the link and writes nothing, and the next pass reads the same round again; that includes a page of the round that cannot be read, such as one that is not JSON or that carries both continuation links or neither (MALFORMED), because a page that does not parse says nothing about the link the pass started from. collect-due counts delta passes per workspace as delta and delta_failed.
The baseline. A connection's first complete full collection that read both its agent identities and its service principals is its baseline: everything is new then, so none of the three rules below speaks, and it opens one initial-inventory.v1 summary on the connection instead, with counts only: actors enumerated, actors with consequential reach, actors with an ungoverned path, actors with nobody accountable and AI-publisher consents (without a sensitive-action list, the reach counts say they were not assessed). The ungoverned-reach.v1 and sponsorless-agent.v1 episodes that collection opens are recorded and listed like any finding, but are not delivered one by one, because the summary counts them. Connecting a directory therefore sends one message, not hundreds. A collection that could not read one of the two takes no baseline, no summary and no link, because a principal a later read stores for the first time was there all along and would be called new. What the connection's first run opens without reading both, as when Graph throttles the probe, is recorded and listed and waits, undelivered, for the summary the next collection is expected to send, and waits no longer: a later collection that cannot send the summary either delivers what waited, and what it opens is delivered one by one, as everything is when the registration is refused a collection outright and no summary can come until an administrator grants the permission. A connection that never reads both is therefore never silent about what it finds. When the summary comes, it counts what waited and still holds, and what waited and ended meanwhile is delivered with it: a finding that waited is counted or delivered, never neither. A connection that had already synchronized when migration 0093_scout_delta was applied takes that upgrade as its baseline, whatever its latest collection's outcome, and raises nothing retroactively, unless it had never read the service principals, which list every principal in a directory: holding none of them, it takes its baseline, and sends its summary, at its first collection that reads both, as a connection that never synchronized does.
The three rules. "Consequential" is the detection profile's sensitive actions, as for ungoverned-reach.v1; without them new-actor.v1 and first-consequential-action.v1 are counted as insufficient evidence, while new-ai-consent.v1, which asks only whether an application comes from a catalogued AI publisher, needs no list. Each raises at most one finding per actor, ever. The finding names the actor, so the findings list shows it with its sponsor or owner count, and it queues a delivery like any finding. A delivery carries the finding's identifier and rule, not the actor, and nothing yet routes a finding to the actor's sponsors. Its episode closes when its condition ends and never reopens; a recurrence is ungoverned-reach.v1's to report.
new-actor.v1: an actor first seen after the baseline, within seven days of that sighting, holds a path to a consequential action that is not an ExecBound path and that no acceptance covers. The seven days catch a consent granted after the principal appeared without calling an actor new months after it arrived. The same path opens the actor'sungoverned-reach.v1episode, which is recorded beside it without a delivery of its own: one event, one message. It closes when the path goes, is accepted or the actor leaves.first-consequential-action.v1: an actor is observed, after the baseline and within the thirty days observations stay current, taking a consequential action with its own directory credential, outside ExecBound, whether or not it is linked and so holds a governed route for that action: a governed route stands in front of the requests that traverse it, not in front of the actor's own credential. The finding carries the earliest such observation; an actor already observed acting before the baseline never raises it. Its episode closes only when no such observation is current or the actor leaves the directory: a governed path appearing closes nothing. It is evaluated byprocess, after observed reach is derived, for every actor however many sort before it. A directory's evidence for it is its own audit log (below), where an observation counts only when the action succeeded, so a refused attempt never raises it, and only when a read after the connection's first complete read found it, and a successful one that matches the execution ExecBound settled for it is left out, so ExecBound's own executions are not reported.new-ai-consent.v1: an actor whose application is in the reviewed AI publisher catalog (src/execbound/monitoring/ai_publishers.py) is first seen after the baseline, within seven days. It closes when the actor leaves the directory, which is how a removed consent appears. The catalog records each entry's primary source and the day it was read, matches an application by itsappId, its owning organization or its verified publisher, and keeps only the entry's key. It holds Anthropic's two Microsoft 365 connector applications; OpenAI publishes no identifier for its applications and is named as a gap, and Microsoft's own applications are not entries.
new-agent.v1, which reported every principal first seen since the last run whatever it could do, is no longer raised; new-actor.v1 reports a newcomer only when it can act. Findings it raised stay readable.
Not yet established. Whether agent identities appear in servicePrincipals/delta, the exact shape Graph gives a removed principal, and whether service principals honour $deltatoken=latest have not yet been confirmed against a real directory. The delta pass does not depend on the answers: an agent identity the delta does not list, or lists without saying what it is, is seen by the next full collection, and a refused latest is answered by an initial round.
What the directory's own audit log shows
Every full collection and every delta pass reads the directory's audit log (auditLogs/directoryAudits, which AuditLog.Read.All grants) once its principals are written. An entry an application initiated that the reviewed activity map covers becomes one Monitor observation of the actor that took it. This is what turns reach into evidence of what an actor did.
The read. It asks for Core Directory entries from the connection's cursor, oldest first, in pages of 100 and at most four pages a run: the reports API allows five requests per ten seconds per app per tenant, and a full collection's probe is the fifth. A next link is followed only on the pinned Graph origin and the audit path, whatever case Graph spells the path in, and the request is always the pinned origin and path with the link's query, so nothing in a response chooses a destination. A page whose entries are out of time order is refused whole. A read has caught up with the log when it reaches the log's end, or when it reads an entry from thirty minutes or less before its own moment, where the next read starts anyway; so a directory busy around the clock, whose log never ends inside a run's four pages, still catches up as long as each run reads further than the time since the last one. A read stopped by its bound short of that is a backlog that the next run continues. A connection's first read looks back thirty days, which Graph answers with what its retention holds: seven days on Microsoft Entra ID Free, thirty on P1 and P2, and on Free only from the first time the reporting API is used. After that, the log is read at every full collection and every delta pass, so every five minutes or so where the directory gets delta passes and at its full collections where it does not, and ExecBound keeps its observations for as long as they are current or a rule reads them (below), so Graph's retention bounds only the first look-back. At four pages a pass that is about 4,800 Core Directory entries an hour, or 400 an hour where only hourly full collections read the log; a directory that logs more falls behind, and what falls behind its retention is lost, which the health finding below says.
The cursor. The Connections page's Audit cursor is where the next read starts, to the second. After a read that caught up it is the read's moment less thirty minutes, so an entry Graph makes available a while after it happened is read by the next run and recognised if it was read already; after a backlog it is the second of the last entry read, never later than that moment, so that entry is read again and recognised. A read that never left the second it started in, as when a bulk change logs more entries inside one second than a run reads, leaves the cursor at its last entry to the microsecond instead, so the next read starts inside that second and reads on rather than reading the same entries again. It never moves back. An entry Graph makes available more than thirty minutes after it happened, once a read has passed it, is not read.
What the read never changes. A refusal while reading the log from Graph (throttled, unavailable, a page out of order, or a next link off the pinned read, whose page is kept) keeps what its earlier pages read and leaves the cursor at the second of the last entry it kept, but never later than thirty minutes before the read's moment, so an entry Graph makes available late is still read, or where it was if it kept none, and is counted in collect-due's audit_failed; a read stopped by its bound short of catching up is counted in audit_backlog, and one that met the connection's observation bound (below) in audit_bounded. None of them changes the run's outcome, the connection's health state, its last refusal or when it is next due, because a run's subject is the directory's inventory. A registration refused the audit log is not asked: its probe records the capability forbidden, a delta pass does not read it either, and the page shows the gap.
When the read falls behind. The connection records the moment its reads stopped catching up, whether stopped by their bound or refused, and a read that catches up clears it. So does a run that does not read the log because its probe found the permission refused: a read Graph refused before the probe recorded the refusal does not leave an episode no read can end, and the first read once the permission is granted begins an episode of its own. Once that has lasted a day, collection-health.v1 is raised once for the episode, Collection has not caught up with the platform's audit backlog, and delivered like any finding: until the read catches up, a connection that has not yet had a read catch up has no activity baseline and raises nothing from its audit log, and entries can age out of Graph's retention unread. A connection's first look-back raises it too where the directory is busy enough to take a day to read through. A read that starts from a cursor further back than thirty days, the longest retention Graph offers, raises Audit retention passed the collection cursor; a window is unrecoverable once for the episode, because what lay between the cursor and that retention is gone. Neither changes the connection's health state, which stays its inventory's.
At the observation bound. A connection's observation bound is 200,000 observations. At the bound a read is not refused: it moves on, its cursor and the activity baseline move as for any read, and of each new entry it keeps only what the rules would miss without it, so one actor's flood cannot hide another's first consequential action. A failed or unknown entry, and one older than thirty days, are dropped. An entry one of ExecBound's executions could account for, settled or not yet settled, is kept, whoever acted, while that execution accounts for fewer than two observations. The first is the one the rule leaves out as ExecBound's own, and keeping it means that an action timed beside that execution, whether the same read or a later one finds it, is never the one observation the execution accounts for, taken for ExecBound's own and hidden; with two, the rule leaves out neither, so an execution keeps no more. Any other entry by a principal of the connection is kept as the principal's first current observation of that action that the rule does not leave out, so ExecBound's own executor's executions never stand in for its first action outside ExecBound, and otherwise only as a repeat more than fifteen days after the principal's newest current observation of that action, ExecBound's own included. Observed reach lasts thirty days from that newest observation, so it stays current for a principal that acts at least every fifteen days, and sponsorless-agent.v1 and ungoverned-reach.v1 do not close and open again each month while it keeps acting. An entry by an actor the inventory does not hold yet, such as a principal created since the last full collection, is kept only as its first current observation of each action, and only while the actors the bound counts, the connection's live principals and the actors not yet inventoried with a current entry stored in the episode, are no more than the inventory's limit of 1,000; past that limit, such an entry is dropped and counted. Once the next collection inventories that principal, its first consequential action raises the rule like any other. The rule counts only executions that have settled, so an entry kept for an execution, and an observation the connection already holds, whichever read stored it, count as a first only when the rule can never leave them out, whatever settles later: one that an execution not yet settled could account for alone may yet be ExecBound's own, and never makes its actor's next action a repeat. That keeps at most two current observations of each actor and action past the bound, 8,000 for a directory's 1,000 actors, and two for each of ExecBound's executions of the last thirty days or not yet settled. An execution not yet settled is read as one whose window has not ended: whatever a read finds before it settles was logged before then, so it falls inside the window the execution settles with.
The read's result counts bounded, kept_at_bound and dropped_at_bound, and collect-due counts it audit_bounded; none of it changes the run's outcome, health state or last refusal. The first read the bound applies to raises Collection reached the connection's observation bound and keeps only each actor's first action at once, once for the episode. The episode ends with the first read after which the connection holds fewer than 180,000 observations, a tenth under the bound, as the retention below brings about: a directory whose month of evidence keeps it at the bound, each purge bringing it just under and the next read filling it again, is one episode and one finding. Meeting the bound after the episode has ended is an episode of its own. A finding raised before this behaviour keeps the title it was raised with, Collection reached the connection's observation bound and records no more.
How long observations are kept. A directory's audit observation is kept while it is current, for the thirty days evidence counts, and for as long as a rule still reads it or may yet read it. The process pass (Monitoring), as its last step once every rule has read them, removes from each Microsoft Entra connection and the synthetic directory, active or revoked, the audit observations that are older than thirty days by the database's own clock, evaluated, and cited by no finding, which stays too. The thirty days are 720 hours, the same thirty days the rules count evidence current for, whatever time zone the database session keeps. It keeps three kinds the rules still read or may yet read:
- For each principal of the connection and each action, the earliest observation that counts the principal as acting in the activity baseline, that ExecBound's own execution does not account for and that no execution still unsettled could account for. An actor in the baseline therefore stays in it once its observations are a month old, as an actor seen acting in it counts as existing behaviour, and an execution settling later cannot turn the observation kept into ExecBound's own.
- An old observation an execution could account for beside one that stays, including one kept as the next kind. Removing it would leave the one that stays the only observation that execution accounts for, taken for ExecBound's own and its actor's action outside ExecBound hidden.
- An old observation that an execution not yet settled, or one whose dispatch-to-settlement window reaches into the thirty days, could account for. Without it, an entry a later read stores beside it, or the execution settling, would leave one observation that execution accounts for, with the same result.
Each batch chooses and deletes under the connection's row lock, which a read's write takes too, so a read never stores an observation between the choice and the delete; a connection whose row a collection holds is passed over that pass rather than waited on. Every connection gets a batch of up to 1,000 each pass, oldest first, and what is left of ten batches goes in turn to the connections whose batch was full: a pass removes at most 10,000 a workspace, or 1,000 a connection when a workspace holds more than ten, and the next pass continues. An operator's process, and the scheduled pass with --deliver, purges after it has delivered what it raised, so a purge never holds back a finding. A delivery that fails holds back no purge either, since delivery reads no observation: the pass purges, then fails with the delivery's code. An evaluation that fails ends the pass with no purge, since what the pass raises must cite its observations before the uncited ones go. The purge logs monitor.audit_purged with the count, even for a pass a later batch fails, since each batch commits on its own, and appends nothing to the audit chain, because observations were never in it. The runtime removes them through one database function, monitor_audit_purge, which deletes only that kind of observation, at that age and in that state, whatever it is asked for, and the runtime holds no other way to delete an observation.
The activity map (src/execbound/monitoring/activity_map.py, version entra-audit.v1) is reviewed like the grant catalog: every entry names Microsoft's audit activity reference and the day it was read, and maps to an action the action catalog holds, which is checked when the module loads. An entry matches on the service that logged it, its activity name and, where one activity covers several changes, the one property it changed, and its one target must be a User:
| Activity (Core Directory) | Condition | Action |
|---|---|---|
| Disable account | identity.disable | |
| Enable account | identity.enable | |
| Update user | AccountEnabled from true to false | identity.disable |
| Update user | AccountEnabled from false to true | identity.enable |
| Reset password | identity.reset_password | |
| Update StsRefreshTokenValidFrom Timestamp | identity.revoke_sessions |
Role assignment is not mapped: the action catalog has no role action, and Scout claims no reach on an action outside it, as for Azure actions. Every other application activity, an entry whose values the map cannot read and one on any other kind of target are counted as unmapped and never stored, and one whose moment or id cannot be read is counted as dropped: whatever a field holds, an entry is counted and the read goes on. So is an entry stamped more than a day after the read's own moment, which has not happened by any clock the read trusts: nothing of it is kept, and since the cursor never passes the read's moment less thirty minutes, every later read meets it again and the first it is no more than a day ahead of keeps it, at the moment Graph stamped, where the clock-skew rule flags it like any entry more than five minutes ahead of its receipt. Every identifier is read whole, as the database's own checks read it, so one carrying anything more, even a final line break, is unreadable rather than stored. An entry a person initiated, or an application acting for a signed-in person, is ignored, and an application entry that names no service principal is counted as unattributed.
What is kept. One entra_audit observation per mapped entry: the actor's service principal object id, the target's object id, the action, the outcome (success, error for a refused attempt, unknown otherwise), the entry's id and its moment. No display name, principal name, address, reason, additional detail, application id or any other property value is kept. One actor's action on one target with one outcome under one correlation id, logged within ten seconds, is one observation, so a change the directory logs twice, as Update user and as Disable account, is recorded once, while a refused attempt and the retry that succeeded under the same correlation id, or two actors' entries under one, are recorded apart. A caller chooses its own correlation id and may use it again, so the same action on the same target under it logged later is an observation of its own, at its own moment: it is never folded into an earlier one, such as one that ExecBound's own execution accounted for, and it keeps the actor's observed reach current. Everything an observation asserts is in its identifier, so an entry Graph serves again under an id already read but asserting another result or another actor is another change, recorded beside the first, and no integrity finding can arise from it.
What an observation raises. A successful one derives observed reach for its actor and counts toward first-consequential-action.v1; a refused attempt is kept as evidence and derives nothing. An observation raises no finding of its own, so an automation that disables users every day sends no message a day, and an entry read long after it happened raises no clock-skew finding, because a log read on a schedule is behind its receipt by design; one ahead of its receipt still does. Each evaluation pass takes up to 400 audit-log observations, as many as one read stores, beside and never instead of its limit of the rest, so a back-filled log never holds back an action a source reports. Two further conditions apply to the rule:
- The activity baseline. A connection's first audit read that catches up with the log sets its activity baseline. What that read finds, with what the backlog reads before it found, is its actors' habit, however long after the directory's baseline it happened: none of it raises the rule, and an actor observed acting in it is part of the baseline. What a later read finds is news, even an entry stamped before the baseline read that Graph made available only after it, because the baseline is what was read, not a moment in the log; the connection dates it by when ExecBound received what it read, never earlier than anything the connection already holds, so a host clock set back after it does not turn a later read into the habit. An entry stamped at or before the directory's baseline is the habit, as for any evidence. An entry ExecBound's own execution accounts for, as the next condition reads it, is never the habit, however old: it is not its actor acting outside ExecBound, so ExecBound's own executor, whose executions the first read finds in its look-back, is still reported the first time its credential acts outside ExecBound. A connection whose reads have not yet caught up raises nothing from its audit log.
- ExecBound's own executions. An audit-log observation is left out only when exactly one execution in the same workspace matches it, one that settled
EXECUTEDthrough the Graph provider for the same action on the same target object id, the object ids compared without regard to case, with the observation within ten minutes of that execution's dispatch and settlement; and that execution matches no other observation. Two executions that match one observation, or one execution that matches two, leave out none of them, so an automation acting on the same target at the same moment is reported rather than hidden. An execution's observations are counted within the connection that holds them, because each connection holds one reading of its directory's log: a connection revoked and the one that connects its directory again hold copies of the same entries, which are one change, not two. An observation left out still derives observed reach, because it is true. The database cannot recognise ExecBound's own service principal, whose client id lives only in runtime configuration, so the match is the whole test. Home's board reads the same match: its band of what monitoring saw pass under the checkpoint draws a successful audit-log observation that the rule does not leave out, and neither one that ExecBound's own execution accounts for nor a refused attempt.
Not yet established. No real directory's audit log has been read: which activities an application's disable, reset and revocation log, how a modified property's values are written, whether Update user and Disable account share a correlation id and how far apart they are logged, whether the combined filter, the ordering and the case of Graph's next links behave as the documentation reads, and whether Graph takes and honours a moment to the microsecond in the filter, which only a read inside one busy second sends, are fixture-verified until a read-only probe of a real directory, with AuditLog.Read.All consented, settles them.
Who drives an actor
Reach says what an actor can do. A driver says what makes it act: the tool or platform behind a service principal or a consented application. Scout keeps it as evidence of its own, in monitor_actor_drivers, never mixed with reach certainty, at one of four levels:
| Level | Established by | What it does not establish |
|---|---|---|
declared | The directory names the driver: the principal's application is in the reviewed AI publisher catalog, or the actor is an agent identity whose blueprint the directory names | That the publisher's product is what is acting now |
network | The actor's successful sign-ins came from a range the reviewed egress catalog attributes to a platform, or from an address an administrator recorded as the Space's Tines tenant's egress | Which of the platform's customers, or which workflow, signed in: every customer of a platform shares its egress |
joined | That Tines network evidence, plus the Tines tenant's credentials allowed to call Microsoft Graph or Microsoft sign-in, plus every story that uses them | Which story made the call: stories share credentials, so a join names them all and never one |
unattributed | None of the above; an actor with no row | That nothing drives it |
A row is marked gone when its condition ends, and cleared when it holds again, dated from the evidence that brought it back rather than from what it held before; nothing is deleted. Driver evidence raises no finding.
Declared. Every full collection and delta pass, once its agent rules have run, records a declared row for each principal whose application the AI publisher catalog names (publisher:<key>), a consented application from another organization as much as one registered here, and for each agent identity whose blueprint the directory names (blueprint:<application id>). The blueprint's own name is not read.
The egress catalog (src/execbound/monitoring/egress.py, version egress.2026-10-18, to which a feed refresh adds the latest day a feed was read and a digest of its networks, so a network row always names what it was matched against) is reviewed like the grant catalog: every entry names its primary source and the day it was read, every network is checked when the catalog is built (global unicast, no wider than a /12 for IPv4 or a /32 for IPv6, and overlapping no other entry's), and an address names one platform or none. It holds Anthropic's published outbound range and Make's and Workato's published egress addresses. OpenAI publishes its egress only as two JSON feeds and Power Automate and Logic Apps use the AzureConnectors service tag: their entries match nothing until scripts/refresh_egress_catalog.py fills them from files a person downloaded, which a reviewed pull request then carries; the collector never fetches a range list. Zapier and n8n Cloud publish no stable egress, so their actors cannot be attributed by network, and the agent detail says so rather than guessing.
Sign-ins (beta). Every full collection and delta pass, after the audit log, reads the directory's service principal sign-ins from auditLogs/signIns, which Graph documents for service principals on beta only: from the connection's sign-in cursor, newest first as Graph serves them, in at most four pages of 500, with next links held to the pinned origin and the sign-ins path. The audit log and sign-ins share one reports budget, five requests in any ten seconds per app and directory: a run waits for the window rather than be throttled, at most twelve seconds in all, and a read the budget stops after its first page has read the newest. A read that stops at its bound has read the newest entries and not the rest, so on a busy directory sign-in evidence is a sample, never a census. The first read looks back thirty days; a read that completes, or stops at its bound, leaves the cursor thirty minutes behind its moment, for sign-ins Graph makes available late; a refused read leaves it. For each successful sign-in by a principal the connection holds, the address is matched in memory against the egress catalog and the Space's recorded Tines egress, and only the match is kept, with its first and last moment: no sign-in address, name or id is stored, logged or shown. A match for a principal the inventory does not hold yet is counted and not kept, and the cursor waits at it, no further back than the thirty days, so the read after the next collection inventories the principal matches it. Neither a refused read nor a sampled one changes the run, so collect-due counts them, sign_ins_failed and sign_ins_sampled.
The user agent is a hint. A client chooses its own user agent, so the actor keeps only the leading product token of its newest successful sign-in's (such as python-requests/2.31.0), dropped when it is shaped like a secret, an address or a mailbox, and it never makes a row.
The capability. The probe classifies sign_ins. Sign-in reports need Microsoft Entra ID P1 or P2 as well as AuditLog.Read.All, so a refusal while the audit log is available is unlicensed, one while the audit log is refused too is forbidden, and one while the audit log could not be told is unknown; the response body, which names the reason, is never read. A delta pass reads under what the last probe established. Losing the capability degrades driver evidence and never changes a run's outcome, health or completeness.
A Tines tenant's egress. Tines shows a cloud tenant's egress addresses at https://<tenant>/info, a page rather than an API, so an administrator of the Space records them on the Tines connection: at most eight single public addresses, none in a private, shared, loopback, link-local, documentation, benchmarking, multicast or reserved block (one list, which the store and admin_set_egress_addresses both hold) and none in a range the egress catalog gives another platform, from the console (a recent second factor, never a personal access token) or with record-egress. admin_set_egress_addresses is the one writer, and each change appends monitor.egress_recorded with how many addresses. Recording them marks that connection's network and joined rows gone and clears the sign-in cursor of the Space's Microsoft Entra connections, so each next read looks back thirty days once against what is recorded now; a read takes its cursor beside the recorded addresses, so one in a run that began before the recording looks back too. The addresses read as recorded by an administrator, and may be shared with other Tines tenants of the same cloud region; Tines does not say.
The join. The Tines inventory reads credentials with their references (include_references=true) and keeps, of each, only whether its allowed hosts name graph.microsoft.com or login.microsoftonline.com (microsoft), only other hosts (other) or none (unrestricted), and the ids of the stories that reference it, at most 100, and for a connection scoped to a team only the stories of references that name that team, since a credential one team shares is referenced by other teams' stories too; no host, metadata or owner is kept. Tines lists at most 100 actions referencing a credential, so one that reaches it is marked "at least". At the end of each sign-in read and of each collection of the Tines inventory, an actor with a live network row for an active Tines connection in its own Space gets a joined row naming every story that uses one of that tenant's microsoft credentials: stories in your Tines tenant that use a credential allowed to call Microsoft Graph or Microsoft sign-in. An allowed host names a service, not a directory, so it is never called "a credential for this directory". A credential with no host restriction is counted on the agent detail and never joined. When no such credential remains the join is gone, at the Tines collection that finds it so even when the directory's sign-ins are no longer read, and revoking the Tines connection ends its network and joined rows at once. The actor's detail is the tenant's, but a Tines connection is its Space's: the detail shows a Tines row, with the connection's label, its stories and its count of unrestricted credentials, only to a session whose Spaces include that connection, as the Connections view does, and to anyone else shows the actor's other drivers alone.
Two limits. An actor with no identity in the directory, such as a script calling a model provider with an API key, is invisible to it; and a platform that publishes no stable egress, or a customer whose automation runs behind its own network, gives no network evidence. Both read as unattributed.
Not yet established. No real directory's sign-ins have been read: the filter, $select, the casing of Graph's next links, and the status of the license and permission refusals are fixture-verified until a read-only probe of a directory with P1 or P2 and AuditLog.Read.All. Whether a shared-cloud Tines tenant's egress addresses are its own is not documented.
The identity classes
An actor is anything non-human that can act here. Its class is what the directory says it is, and there are five:
| Class | What makes it so |
|---|---|
agent_identity | The directory's agent identity cast, @odata.type or servicePrincipalType ServiceIdentity says so |
service_principal | A classic service principal registered here, or another organization's holding no consent here |
managed_identity | The directory states the type ManagedIdentity |
consented_app | Another organization's application (its appOwnerOrganizationId names another directory) holding a live application or delegated permission grant here; Microsoft's own applications holding a consent count too |
tines_story | A Tines story a Tines connection lists |
A class moves only within the classic family: a full collection's stated type decides managed_identity, and whether a classic principal is a consented app is decided again at the end of every write of its grants, in a full collection and a delta pass alike, so it moves as consent is given and withdrawn. A record that states no type keeps the stored class, and an agent identity or a Tines story never moves; the database refuses any other change (CLASS_FIXED). The rules read the class only for their titles: a finding raised from now on names it ("New consented app reaches…"), and findings already raised keep their words. The principal's application id (app_ref, a GUID) is kept so that the page can say what to filter the admin center's enterprise applications by.
The Agents page as the to-do list
Monitoring → Agents lists the actors in the order of a list to work through, cut at 200 after ordering so that nothing needing attention is cut by its name:
- Actors that did something consequential outside ExecBound in the last 30 days, most first.
- Then actors with open paths: live grant, role or inherited paths to a consequential action that nothing governs and no acceptance in force covers, most first.
- Then the rest, by name.
Did counts an actor's successful observations of a consequential action in the last 30 days, attributed to it as observed reach attributes them (its connection and its directory object id), the ones from before the baseline included, since the baseline governs alerts only. An observation ExecBound's own settled execution accounts for is left out, by the same match as first-consequential-action.v1, and so is a failure. While the connection is at its observation bound, or raised its CAPACITY finding within the 30 days, the count reads "at least", because repeats were dropped there. Without a detection profile nothing is consequential, so nothing is done or open, and the page says so; the synthetic directory alone is assessed against the built-in sensitive action set then, and the page names it.
Driver shows the strongest current driver row the reader may see, in the order the agent detail lists them (joined, declared, network), and "and N more"; an actor with none is Unattributed, which is not the same as nothing driving it. The agent page lists every row, the gaps of the egress catalog and the user agent hint, labelled as no evidence.
Governed shows the governed paths beside the open ones and the accepted ones in force, with the earliest expiry.
The tenant line counts the actors each source lists (Microsoft Entra, Tines, the synthetic directory) and the directory actors no current driver row the reader may see attributes, each over what the connections could enumerate. A Tines row counts there only where the reader may see it, as in the Driver column, so the figure matches the rows marked Unattributed.
What each row offers. Govern this agent, as before; Govern this action, which opens Policy; Accept, for an actor with open paths where the reader may accept them (below); and Remove in Entra, since Scout never writes to the directory: for a service principal or a consented app, the admin center's enterprise applications, with the application id to filter by under Application ID starts with, then Permissions, which is Microsoft's documented path (Microsoft documents no address for one application's permissions); for a managed identity a sentence saying its access is removed on the Azure resource it belongs to, and for an agent identity through its blueprint. The link is the console's constant address, and no value from a record becomes part of it. A Tines-driven row, a Tines story or an actor a Tines tenant's egress or credentials attribute by any current row the reader may see, its strongest or not, points at the Tines Gate recipe in integrations/tines/README.md of the release.
Accepting a path
An administrator accepts an actor's open paths from the console, all chosen and any left out, for one of five reasons and 30, 90 (the default), 180 or 365 days, or one path with accept-reach, whose --days 0 leaves it open-ended. The console accepts only a path open now, so one whose acceptance expired is accepted again and renewed, and one in force is withdrawn first; accept-reach renews any. withdraw-reach, or Withdraw on the agent page, withdraws a standing acceptance of any kind, an observed path's included, and is recorded for an actor that has gone from its directory, though nothing new is accepted for one. Both functions refuse an approver, an agent, the scheduler, an inactive administrator and one a membership narrows away from the actor's connection's Space, whoever called them. The Agents page and an actor's page list every actor of the workspace, but an acceptance is its connection's Space's: each row and each actor's page say whether the reader may accept or withdraw there (administered, a Space the reader holds and administers, whichever Space the page shows), and the console offers Accept and Withdraw only then, saying otherwise that an administrator of its Space accepts or withdraws them.
| Code | Shown as |
|---|---|
COMPENSATING_CONTROL | Another control covers this path |
VENDOR_MANAGED | The vendor manages this application |
BUSINESS_REQUIRED | The business needs this access as it is |
GOVERNANCE_PLANNED | Governing it is planned |
BREAK_GLASS | Emergency access |
A code the command line recorded outside the list is shown as it was written; no free text is taken. admin_accept_reach and admin_withdraw_reach are the only writers: a renewal sets the reason, the expiry, who and when, and clears a withdrawal, which the runtime's own insert could not before. Every change is kept, append-only, in monitor_reach_acceptances, and appends monitor.reach_accepted or monitor.reach_withdrawn. The console's routes ask for a recent second factor and refuse a personal access token. An acceptance takes the path out of the page's open paths at once; the ungoverned-reach.v1 episode closes at the next collection, and an expiry or a withdrawal opens a new one there.
The synthetic directory
A workspace that has never connected Microsoft Entra can add one synthetic directory from Monitoring → Connections to see what Scout does before it connects its own. It holds for every Space, since while it is present no Space connects Microsoft Entra, so a workspace administrator adds it, never one a membership narrows to their Spaces. Connections reads whether it is present, and whether one can be added, for the whole workspace, whichever Space it shows: an administrator looking at another Space is told it is in another Space, and is not offered one where any Space connected Microsoft Entra. It is a connection of kind fixture and credential mode synthetic, labelled Synthetic directory, with no key, no consent and one fixed shape: an address in the reserved .invalid domain, which resolves nowhere, and a directory and client no directory holds. admin_add_synthetic_directory is its one writer.
How it is read. The real Graph collector reads it on a directory's schedule, in the scheduled collect-due that every installation's jobs service runs whether or not it holds a collector key, hourly by default and every one to twenty-four hours as an administrator sets it (anything else is INVALID_SCHEDULE), through an in-process transport (httpx.MockTransport) over a fixed dataset in monitoring/synthetic.py: no socket is opened, the token is a constant that authenticates nothing, and no real connection is ever read through it. It takes no delta link and no delta pass, matches its sign-ins against the egress catalog alone and joins no Tines tenant. The dataset holds an agent identity with a blueprint and a sponsor; a consented application with Anthropic's catalogued application id and a delegated consent; a classic service principal that can disable users, signs in from Anthropic's published outbound range and disables a synthetic user every day; a managed identity; a principal with nobody accountable that can revoke sessions and signs in from a documentation address nobody publishes; and one principal served only once the connection has a baseline, so the collection after the first raises new-actor.v1 and ungoverned-reach.v1 for it and process its first-consequential-action.v1. Its people and sponsors are synthetic object ids. With no detection profile set it is assessed against the built-in sensitive action set, the one the sensitive-action rule applies without a profile, so all of this shows the moment it is added; once a profile is set, the profile decides, and a real directory is never assessed against a list nobody chose.
How it is kept apart. It never shares a workspace with a real directory: it is refused where a Microsoft Entra connection exists or ever existed (DIRECTORY_CONNECTED), and while it is present connecting Microsoft Entra is refused (SYNTHETIC_DIRECTORY_PRESENT), by create-connection, by the console before anyone is sent to Microsoft and by the database whichever path inserts. Its findings are never sent to a destination: routing closes their intents SYNTHETIC_DIRECTORY, and the database refuses a destination's delivery of one. They show on Monitoring alone, labelled, and the console assistant's findings tool marks each synthetic: Home, the workspace settings, the Spaces page and the weekly summary count neither them nor a late collection of it. Its actors are never governed or bound, so never billed (SYNTHETIC_ACTOR, refused by the database whichever path binds), but Accept works. Home neither counts it as watching nor draws its observations, and every page that shows it says so. A governed agent's page on Agent connections never offers one of its actors to bind. Remove (the connection's Revoke) takes its actors off the lists, as a full collection leaves a principal it no longer lists: their driver rows, their grants and the paths those grants established stop being live claims, so no page offers to accept a path of one, and what it raised stays, labelled.
Not in it yet. A Foundry project, with its agents and their shared identity, is not part of the dataset yet.
Commands
uv run python -m execbound.monitoring.commands create-connection \
--credential-file <admin> --label "<directory>" --kind entra_agent_id --key-file <private env>
uv run python -m execbound.monitoring.commands collect-entra \
--credential-file <admin> --connection <id> --key-file <private env>
uv run python -m execbound.monitoring.commands link-agent \
--credential-file <admin> --connection <id> --agent <object id> --principal <principal>
uv run python -m execbound.monitoring.commands accept-reach \
--credential-file <admin> --connection <id> --agent <object id> \
--action <action> --path-kind <kind> --path <path> --reason <CODE> [--days 90]
uv run python -m execbound.monitoring.commands withdraw-reach \
--credential-file <admin> --connection <id> --agent <object id> \
--action <action> --path-kind <kind> --path <path>
uv run python -m execbound.monitoring.commands record-egress \
--credential-file <admin> --connection <tines connection id> --address <address> [--address ...]
record-egress records a Tines tenant's egress addresses as the console does, prints what it recorded, and takes --clear alone to clear them.
link-agent binds the agent to an active agent principal through the same functions as the Agents page's Bind (migration 0037), one to one, and reports LINK_CONFLICT for a second principal; unlink-agent withdraws the binding with the reason "withdrawn with unlink-agent".
The private file holds ENTRA_DIRECTORY_ID, ENTRA_CLIENT_ID and ENTRA_CLIENT_SECRET. The connection keeps only the secret's SHA-256 fingerprint; a key file whose fingerprint differs is refused before any request is made.
On a schedule. collect-due --all-tenants --key-files <directory> collects every due connection, in every active workspace, with no workspace credential, where the directory holds that connection's private file as <connection id>.env. An Entra connection is due six hours after its last run, or after its own interval of one to 24 hours, and at once when an administrator asks for a collection; between full collections, each pass gives it a delta pass, which never moves when it is next due. Every run, scheduled or collect-entra, holds the connection's lease for its duration, so two never collect one directory at once, and one whose connection is revoked mid-run writes nothing more. process --all-tenants derives observed and verified reach in every workspace the same way, without a credential. Both are described with their bounds in Tines monitoring.
The collector. Scheduled collection runs as its own process, the collector, which holds what opens a connection's credential and nothing else: operator key files, the private key that opens a Tines key pasted in the console and sealed for it (Connect Tines from the console), and the platform app's certificate below. The web service holds only the collector's public key and the platform app's public values, so it can seal a key and never read one back, and it can send an administrator to Microsoft but never read a directory itself; neither the collector's key nor the app's certificate is given to any other process.
Connect Microsoft Entra from the console
Where the deployment names its own app registration for this (the runtime configuration's scout.entra, beside scout.collector), an administrator connects a directory by its administrator's consent rather than by an operator's private file. Monitoring → Connect Microsoft Entra says what the app reads, who can approve and how to withdraw, takes a connection name, and continues to Microsoft:
- Start. ExecBound records an attempt in the Space you are looking at and sends your browser to Microsoft's sign-in, with a binding cookie in this browser (
HttpOnly,Secure,SameSite=None) that only this browser holds. A full workspace, a Space that already has a Microsoft Entra connection, more than five attempts at once or twenty in a day are refused here, before you leave. The step asks for a recent second factor and refuses a personal access token. - Sign in. A Global Administrator or Privileged Role Administrator of the directory signs in. ExecBound verifies the id_token Microsoft posts back (RS256 against Microsoft's published keys, issued for that directory and for this app, current) and reads two claims only: the directory (
tid) and the administrator's roles (wids). No name, address or object id is read, and no ExecBound person is looked up. A directory any workspace has already connected is refused here, before consent is asked for, with a sentence that names no workspace. - Consent. Microsoft shows the app's read-only permissions and the administrator approves. ExecBound creates one
platform_appconnection in the attempt's Space, asks for its first collection, and the page you return to says what happened from ExecBound's own record, never from the address.
Every refusal ends the attempt, creates nothing and logs only a code. An attempt lives ten minutes, its state, nonce and binding are kept only as digests, and a directory is connected once across the whole deployment, whichever way: create-connection for a directory another connection already reads answers DIRECTORY_UNAVAILABLE.
What the app reads. AgentIdentity.Read.All, Application.Read.All, AuditLog.Read.All and Directory.Read.All, all read-only, nothing that can write. The app is a separate registration from any that executes.
The platform app's credential. A certificate, in the collector's private file (scout-app.json: the app's client id, the certificate and its RSA key). The collector authenticates with a client assertion signed by that key (RFC 7523, RS256, naming the certificate by its SHA-256 thumbprint, five minutes, a fresh jti each time) and refuses a consented connection that names another app's client id (CLIENT_MISMATCH) before any request. Rotate the certificate every 90 days.
The blast radius, stated plainly. That one certificate reads every directory that consented to the app. It is read-only, it lives in the collector's environment alone, and each directory can remove the app at any time.
Consent needs updating. A consent can go stale: the first collection's probe establishes which permissions hold, and when a permission a connection had is refused later, its card says "Consent needs updating" and offers Update consent, which runs the same three steps for the same connection and the same directory only.
Withdrawing consent. Revoking the connection stops collection here and keeps what was collected. Only the directory can withdraw its consent: remove the app's enterprise application in Microsoft Entra, which the Revoke dialog names with its client id.
Pages
/monitor?view=agents and /monitor/agents/{agent_id} are ADMIN-only console views inside the monitoring authority predicate; the former /monitor/agents list address redirects to the query view. The list carries the tenant line and its denominator; the detail page shows every path with its certainty, mode and freshness, every grant with what it reaches, the findings and the observations attributed by object identifier. The detail shows the actor's drivers and its user agent hint (above), every acceptance of its paths with Withdraw, and where its access is removed. Its Govern this agent opens /agents/{agent_id} with the same identifier, where the agent can be governed (a principal, a binding and a first API key in one step) or bound to an agent principal that already exists; a linked agent offers Open the governed agent instead. The link-agent, accept-reach and withdraw-reach commands sit folded under Operator commands where the deployment's console offers them. On the Agents list, a row with consequential reach or an open path and no link offers Govern this agent, a linked row links to its governing agent, and a synthetic directory's row offers neither (above). The way back is the agent page's own note, which links to this agent's Monitoring page.
The Agents page
/agents puts the agents a connection lists beside the agent principals ExecBound governs (SURFACES). What it claims, and what it does not:
- A binding says that one agent principal is one listed agent. An administrator makes that statement with
Bind,Govern this agentorlink-agent; nothing infers it, and a display name that resembles a principal is not evidence. It is one to one while active and is withdrawn with a reason, never deleted. - A binding governs one route, not the agent. The bound principal's executions through ExecBound become that agent's verified reach; every grant, role and inherited path the source reports stays exactly what it was, ungoverned until something stands in front of it. Being bound is never a claim that the agent can do nothing else.
Govern this agentcreates a principal, a binding and a credential in one step. Until the agent's host uses that credential, nothing it does passes through ExecBound; the page says so beside the secret.- The tenant line counts what a connection could enumerate, per source, with Monitoring's own definitions of consequential reach and of reach not established, so the two pages cannot disagree; it never counts agents with no identity, and a source that lists none has not shown that it holds none.
What a live tenant corrected, 18 September 2026
A read-only probe, and then a full collection, ran against a test tenant created that day (Entra ID Free, no premium license). It answered the open questions and found two defects in shipped code and one overstated assumption. The tenant holds one agent identity, one blueprint, one sponsor and twenty-six of Microsoft's own first-party service principals.
Agent ID is served on the free tier. All five capabilities classify available: agent identities, service principals, applications, grants and directory roles. Both Agent ID cast collections answer 200 in a tenant with no P1, P2 or Agent 365. The Agent ID Administrator and Agent ID Developer role templates exist. This was the largest unknown and the answer is the permissive one.
Defect: the cast collection carries no @odata.type. GET /servicePrincipals/microsoft.graph.agentIdentity returns agent identities with the field absent entirely, and parse_agent classified on exactly that. Every agent identity in a real directory would have been filed as a classic service principal with its blueprint reference dropped — Scout would have reported zero agents while showing them under the wrong class. The parser now takes three signals in order: the cast collection is the type assertion, because the URL says so; @odata.type where an endpoint sends it; and servicePrincipalType, documented as "set to ServiceIdentity for all agent identities". A tag is still only evidence and still never decides a class.
Defect: /directoryRoles refuses a page size. It answers 400 Request_UnsupportedQuery — "This resource does not support custom page sizes" — and the capability probe sent $top=1 to every path. The capability classified unknown, and because the detail pass only reads roles for an available capability, every directory-role grant was skipped in silence. Page size is now a property of the resource rather than of the reader.
The expected blocklist holds, and there is a second constraint. User.EnableDisableAccount.All and User.ReadWrite.All are refused for an agent identity: "The specified app role cannot be granted to agent identities." So is a directory role — "the specified role is not allowed for assignment to agentic entities" — which no reference consulted beforehand mentioned. User.Read.All and Directory.Read.All are granted normally.
The consequence is a real narrowing of what Scout can find in Entra, and it belongs on the page rather than in a footnote: an agent identity cannot hold a direct grant to any consequential identity action. Ungoverned consequential reach in an Entra directory therefore comes from classic service principals, which carry no such restriction, from inherited blueprint permissions, and from Azure RBAC, which Graph does not serve at all. An agent identity's own grants are a much shorter story than first assumed.
Two smaller facts. A blueprint's object id and its appId were identical, unlike an ordinary application, which makes resolving agentIdentityBlueprintId cheaper than feared — one sample, not a rule. And the sponsor requirement is enforced at creation: a blueprint without one is refused with "No sponsor specified."
A product question the probe raised, and the answer it got. Twenty-six of the twenty-seven principals enumerated are Microsoft's own first-party service principals, none of them owned, so the first run opened twenty-six sponsorless-agent.v1 episodes — literally true and useless. The directory answers it with a fact rather than a heuristic: appOwnerOrganizationId names the organization whose application a principal represents, and twenty-four of the twenty-six name Microsoft's. The rule now carries two conditions:
- A principal another organization owns is not the customer's to sponsor. Nobody can assign accountability for the Workload Identity Control Plane, and asking is how people are taught to ignore findings. Null means the application was registered in this directory, which is how a locally created agent identity appears, so null counts as theirs.
- A principal that reaches nothing consequential is not worth reporting. Without it the rule degenerates into "this tenant contains service principals", which is always true. With it, every sponsorless finding names something that can act.
Neither condition hides anything: all twenty-seven principals stay in the inventory and in reach computation, because a consented third-party application holding write access to users is a real risk — it is simply not a question about sponsorship. Re-run against the same tenant, the rule raises zero findings and every episode closes, which is the right answer: nothing there is both unowned and dangerous.
What stays fixture-verified. Inherited allAllowed blueprint permissions were not exercised, no observation or execution was attributed to a real agent, and no tenant with a populated directory has been read. Those pages still say so.