Chapter 11 · Untrusted Code

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.

!
Everything in the archive is attacker-controlled input

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.

i
Off by default

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:

  1. Exact subject match (BySubject) wins outright.
  2. 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.
  3. Otherwise the Default envelope 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.

!
A non-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 → nameidentifiersub → 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 identically404 for reads, a silent 204 for 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 bomb check runs twice, and the second one is the real one

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.
!
Anonymous mode is a development mode, and it removes isolation

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 inside IDirectToolInvoker rather 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 ToolInvocationGovernor enforcement 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_system tool 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-Autonomous ceilings 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.