Externally-Authored Agents
Every other page in this guide assumes the agent is yours. You wrote its manifest, you chose its skills, you shipped it. The controls exist to stop that agent being manipulated. This page covers the one surface where that assumption does not hold — where the agent arrives over HTTP, from someone else, and runs anyway.
Why this needs its own chapter
The Bundle API (Presentation.ExecutionApi) accepts a zipped agent from a caller and
executes it. That inverts the guide's usual threat model in a way worth stating plainly.
Everywhere else, identity gates access: the harness checks who you are, then decides whether to let you through a door it already knows the shape of. Here, identity defines the sandbox. The credential that starts a run does not just authenticate the caller — it selects the set of permissions the uploaded agent will execute under. Authentication and authorization collapse into one decision.
The AGENT.md, every SKILL.md, every file path inside the zip,
the entry sizes declared in the zip header, the manifest's allowed-tools
list, and the autonomy tier it asks for. All of it is written by whoever uploaded the
bundle. None of it is a grant. Treat this page as the list of places the harness assumes
that data is hostile.
The consumer-facing integration guide — how to actually call this API, with payloads and a quickstart — is Developer Guide · Chapter 17. This page is the security view of the same surface.
The whole subsystem is gated on
AppConfig:AI:BundleExecution:Enabled, which defaults to
false. While it is off, every endpoint answers 403. If you
have not deliberately turned this on, none of the surface below exists in your
deployment.
The capability envelope
This is the primary control, and the one to understand first. A bundle's manifest can declare
whatever tools it likes. Those declarations are requests. The grant is a
capability envelope — host-side configuration under
AppConfig:AI:BundleExecution:Envelopes that the bundle author can neither see nor
influence.
The envelope is resolved from the credential that starts the run, and it is resolved in this order:
-
Exact subject match (
BySubject) wins outright. - Otherwise the caller's matching roles are combined to the least-privilege result: the intersection of the tool and MCP allowlists, and the minimum autonomy ceiling. Holding more roles can only narrow a grant, never widen it.
-
Otherwise the
Defaultenvelope applies — which grants nothing unless an operator deliberately widened it.
There is no code path on which "no envelope" means "no restriction". An unrecognised
AutonomyCeiling degrades to the most restrictive tier with a warning; a numeric or
comma-composite value is rejected outright rather than being interpreted generously.
Where the envelope is enforced
Enforcement is not advisory and does not live in the controller. Every declared tool the
envelope does not grant produces a bypass-immune Deny rule at priority 1, in
src/Content/Application/Application.Core/Permissions/EnvelopePermissionRuleProvider.cs.
Bypass-immune means exactly what it says: no auto-approve mode, no configuration flag, and
nothing the bundle can declare will re-enable it. This is the same mechanism that makes a
plugin's DeniedTools unconditional — see
Tools & Permissions.
Autonomy is enforced as a ceiling, not a setting. The tier that actually applies is the most restrictive of three things: the envelope's ceiling, the host's own graded-autonomy gate, and the individual tool's blast radius. It can only tighten, never loosen.
Autonomous ceiling suspends tool use rather than queuing approvals
Live mid-tool-call approval routing is deferred, so the governor treats "this action
requires human sign-off" as a fail-closed block. In practice a
Restricted or Supervised ceiling stops the bundle using tools
at all, rather than gating each call. A bundle that must do real work therefore runs
with an Autonomous ceiling and is confined by AllowedTools /
AllowedMcpServers plus the host's own risk and capability gates. This is
consistent with how plugin and tier baselines behave today (see
Autonomy & Governance), but it does
mean the confinement you rely on is the allowlist, not the tier.
Which claim selects the envelope
Two different identifiers are read from the same token, for two different jobs. Getting these
confused is the most common way to misconfigure this surface — and it fails
open-looking: the grant silently falls through to Default rather than
erroring, so a misconfiguration reads as "my permissions were ignored" rather than as a fault.
| Job | Claim used | Why |
|---|---|---|
| Ownership of handles and runs |
First available of oid → object-identifier →
nameidentifier → sub → name
|
Needs the most stable id available, to partition resources and rate limits. |
Envelope lookup (BySubject) |
nameidentifier or sub only |
The subject claim is the one an operator can reason about when writing a grant. |
For most Entra tokens these differ — oid is the directory object id, while
sub is a per-application pairwise identifier. Key BySubject grants on
sub, or use ByRole with an app role.
Ownership binding, and why a stolen handle is not useful
The attack this stops: a caller obtaining another tenant's handle — from a log, a shared trace, a bug — and using it to run work under that tenant's privileges, or to read their results.
Two properties close it:
- The grant follows the invoker, not the handle. Because the envelope is resolved from whoever starts the run, a stolen handle executes under the thief's own envelope. It confers no privilege. This is the reason the envelope is deliberately not captured at registration time.
-
Every operation re-checks ownership. Run, poll, stream, and delete all
verify the caller owns the handle. A resource that is foreign, unknown, or expired is
reported identically —
404for reads, a silent204for delete — so the API never confirms that someone else's handle exists.
The uniform 404 is a deliberate anti-enumeration choice. It costs debuggability
(a caller cannot tell "expired" from "not yours"), and that trade is made on purpose.
Hostile archive handling
A zip file is a classic attack surface, and nothing inside one is parsed until every structural
guard has passed. Any failure deletes the partial extraction before returning. Each rejection is
a 400 whose detail names the guard that fired — never the archive's
contents.
| Attack | Guard | Default |
|---|---|---|
| Oversized upload / resource exhaustion | MaxArchiveBytes, which also caps the multipart body |
10 MiB |
| Entry-count flood | MaxEntryCount |
2 000 |
| Decompression bomb (absolute) | MaxTotalUncompressedBytes |
50 MiB |
| Decompression bomb (ratio) |
MaxCompressionRatio, applied only once a bundle expands past 1 MiB so
small, highly-compressible markdown never trips it
|
100× |
Zip-slip — ../ or absolute entry paths |
Path containment check | rejected |
| Symlink escape | Symlinks resolving outside the staging directory | rejected |
| Malformed / empty archive | Structural validation | rejected |
The first pass is cheap: it sums the entry sizes the archive declares in its own header. But an attacker writes that header. So a second check counts the actual bytes written during extraction, and trips on the same limits. An archive that lies about its entry lengths is caught by the second pass mid-write.
Staging isolation
The attack this stops: a tenant's private skills leaking into the host's global skill pool, where every other agent on the box could load them.
Accepted bundles extract into a unique subdirectory of TempRoot. That root
must not overlap any configured skill or agent discovery path, because the
host's registries scan those roots recursively. If staging lived inside a discovery
path, the registry would walk into it, find an uploaded bundle's skills, and publish them
globally.
The staging service checks for that overlap and refuses to stage rather than letting it happen. Skills declared in a bundle are owned by that bundle's agent: they resolve only for its own runs and never enter the host's global pool.
Fail-closed authentication
Because this surface runs foreign code, it does not share an audience with the agent hub or the
MCP server — it validates tokens against its own audience, api://{ClientId}.
More importantly, it refuses to start in an ambiguous state. The host throws at startup in all three of these cases:
-
Neither configured nor opted out — no Entra scheme and no explicit
Auth:AllowAnonymous. Silence is not consent to serve openly. -
Half-configured — exactly one of
TenantId/ClientId. Treated as a mistake, not as an implicit request to serve openly. -
Contradictory — a configured scheme and
AllowAnonymous: true.
With Auth:AllowAnonymous: true every request authenticates as one
synthetic principal, and the host logs a prominent warning for as long as it runs. Two
consequences: every caller shares one owner, so ownership checks pass
for everyone and anyone can run, poll, or delete anyone's handle; and
every run gets the Default envelope, because the synthetic
principal carries no subject or role claims. Never enable it on a shared host.
Resource limits
Rate limits partition by the caller's stable id, so one tenant cannot exhaust another's budget.
| Surface | Limit | Shape |
|---|---|---|
| Bundle registration | 10 / minute | Fixed window. Tighter than the rest — registration writes to disk. |
| Run, poll, delete | 60 / minute | Fixed window, declared at controller scope. |
| Live streams | MaxConcurrentStreamsPerCaller, default 4 |
Concurrency, not rate — each open connection holds a permit for its whole lifetime.
Excess is rejected 429 immediately rather than queued.
|
Staged bundles and run records are swept on a CleanupInterval (default 60 s)
against a 30-minute handle TTL and a 30-minute run-record TTL, so an abandoned upload cannot
occupy disk indefinitely. An in-flight run holds a lease on its staged bundle, so the sweeper
can never delete a directory out from under a running agent.
Direct tool invocation: the same envelope, a shorter path
Everything above concerns an agent the host runs on a caller's behalf, which chooses
its own tools as it works. The same host can also expose
POST /api/tools/{name}/invoke, where the caller names the tool and the operation
and the host simply runs it. It is worth understanding as a distinct surface, because the
authorization story is identical while the threat model is not: there is no agent between the
caller's intent and the tool call, so nothing except the envelope stands between a granted tool
and arbitrary use of it.
Three controls apply, and the important design point is that none of them is a substitute for the others:
-
It is disabled by default, in every host
(
AppConfig:AI:DirectToolInvocation:Enabled) — deliberately unlike bundle execution and workflow submission, which the execution host ships enabled. Turning it on is an operator decision, never a consequence of adopting a template version. The gate is enforced insideIDirectToolInvokerrather than only at the controller, so a future in-process caller cannot route around it. -
A dedicated role,
Harness.Tools.Invoke. Discovery (GET /api/tools) requires only authentication, because listing a tool confers nothing. Running one is a separate grant, and separating the claims is what lets an operator issue read-only discovery credentials. -
The same capability envelope, armed around the invocation exactly as it is
around a bundle run. Arming it is what switches
ToolInvocationGovernorenforcement on, so the grant is checked twice — by the catalog lookup, and independently by the governor's own envelope check.
Two further properties are worth knowing when you assess this surface. Output and error text are run through the response sanitizers unconditionally, because a tool's failure message is the likeliest place a filesystem path or connection string escapes; and tool-output compression is deliberately not applied, since it substitutes pointers only an in-process agent can expand. A caller receives a bounded, truncation-flagged prefix instead.
Some tools additionally declare IsDirectlyInvocable = false and answer
404 here even when granted. Read that as a coherence filter, not a security
boundary: the render_* family and dashboard_control emit directives for
a browser attached to a live run, and delegate_task expands one call into
open-ended agent turns that would outlive the request authorising them. The envelope remains the
control that decides authority.
What this layer does not cover
Being explicit about the edges matters more here than anywhere else in this guide, because the threat model is genuinely adversarial.
-
The tools themselves are not made safe by the envelope. The envelope
decides which tools; it does not change what an allowed tool can do. A granted
file_systemtool is still confined by the sandbox and its capability model — see Sandbox & Execution. Grant narrowly. - Prompt injection inside a bundle's own skills is not solved here. A bundle author can write whatever instructions they like; content safety and injection defense apply as they do everywhere else (Content Safety). The envelope bounds the blast radius of a successful injection rather than preventing it.
- State is in memory on a single host. Handles, run records, and results do not survive a restart and are not shared across instances. This is an availability and correctness property, not a confidentiality one — but it means a load-balanced deployment needs session affinity.
-
Non-
Autonomousceilings do not currently queue approvals, as described above. If your intended control was "a human reviews each tool call", this surface does not yet provide it.
The assurance layer that tests these claims — including the OWASP Agentic Top-10 eval pack that gates every pull request — is the next page: OWASP Agentic Evals.