Sandbox & Execution
The first three layers decide whether a tool may run. This is layer 4: when an allowed tool actually runs code, it runs inside a box with no capabilities it wasn't granted — and its result is cryptographically attested. The earlier layers can be tricked by a clever prompt; this one can't, because it isn't asking the model anything. It's an operating-system-level cage with a signed receipt.
Two boxes: process and container
"Sandbox" means a confined execution environment: the code runs, but the walls of the box decide what it can see and do. The harness ships two boxes, picked by how strong the isolation needs to be.
ProcessSandboxExecutor runs the tool as an isolated Windows subprocess, caged by
a kernel-level Job Object (covered next). Lighter weight; no container runtime required.
File:
src/Content/Infrastructure/Infrastructure.AI/Sandbox/ProcessSandboxExecutor.cs.
DockerSandboxExecutor runs the tool inside a Docker container (driven through
the Docker.DotNet client). Stronger isolation — a whole container boundary
between the untrusted code and the host. File:
src/Content/Infrastructure/Infrastructure.AI/Sandbox/DockerSandboxExecutor.cs.
The Docker executor enforces a minimum-isolation invariant. When a request's
permission profile requires MinimumIsolation = Container and Docker is unavailable
at runtime, the execution fails rather than quietly falling back to the weaker
process sandbox.
A common failure pattern: a control "degrades gracefully" when its strong path is unavailable, and nobody notices the box got weaker. The Docker executor refuses to do this. If you asked for container isolation and the container runtime isn't there, you get an error, not a silently downgraded process sandbox. The security posture you configured is the security posture you get — or you get told it can't be met.
Windows Job Objects: the resource cage
The process sandbox is held shut by a Job Object. Defining the term plainly: a Job Object is a Windows kernel object that caps and contains a group of processes — you attach a process (and everything it spawns) to the job, set limits on the job, and the kernel enforces those limits on every process in it. The harness wires this up via P/Invoke (calling the native Windows API directly) in two files:
src/Content/Infrastructure/Infrastructure.AI/Sandbox/WindowsProcessResourceLimiter.cssrc/Content/Infrastructure/Infrastructure.AI/Sandbox/WindowsJobObjectManager.cs
The default limits — all configurable through ResourceLimits — are:
| Limit | Default | Enforced by | What it stops |
|---|---|---|---|
| Memory | 256 MB | ResourceLimits.MemoryLimitBytes |
Memory-exhaustion / OOM attacks on the host |
| CPU time | 30 s | JOB_OBJECT_LIMIT_PROCESS_TIME |
CPU-burning loops, cryptomining |
| Child processes | max 5 | JOB_OBJECT_LIMIT_ACTIVE_PROCESS |
Fork bombs, self-replication |
| Disk quota | 100 MB | ResourceLimits.DiskQuotaBytes |
Disk-filling denial of service |
| Wall-clock timeout | 30 s | CancellationTokenSource.CancelAfter |
Hangs, infinite waits, slow-loris-style stalls |
| Kill on close | always | JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE |
Orphaned / escaped processes outliving the job |
The last row is the one that makes the cage trustworthy.
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE guarantees that when the job handle closes,
every process in the job dies with it — there is no way for a spawned child to survive
the teardown and keep running on the host. Combined with the active-process cap, this closes the
most direct denial-of-service against the sandbox itself.
A tool is coerced (by a prompt-injection payload, say) into running code that spawns children in a loop, each of which spawns more — classic self-replication designed to exhaust the host's process table and bring the whole machine down.
Inside the Job Object it gets nowhere. JOB_OBJECT_LIMIT_ACTIVE_PROCESS caps
the job at 5 processes, so the 6th spawn fails immediately. The bomb can't replicate.
When the tool's turn ends, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE reaps whatever
is left. The host never sees the pressure.
The closed-by-default capability model
Resource limits stop a tool from overwhelming the host. Capabilities stop it from
reaching things it has no business reaching. A capability is a named permission for a
category of effect. The flags live in
src/Content/Domain/Domain.AI/Sandbox/ToolCapability.cs:
| Capability | Grants |
|---|---|
FileRead | Read files |
FileWrite | Write files |
NetworkAccess | Make network connections |
Subprocess | Spawn child processes |
EnvRead | Read environment variables |
DatabaseRead | Read from a database |
DatabaseWrite | Write to a database |
LlmInvocation | Call an LLM |
The model has three parts, and the order matters:
[ToolCapabilityAttribute]. This is a static, reviewable contract — you can read
a tool's source and know exactly what it claims to need.
src/Content/Application/Application.AI.Common/Services/Sandbox/CapabilityEnforcer.cs
checks each requested effect against what was granted. A capability that wasn't granted
yields Result.Forbidden() — the effect simply does not happen.
An allowlist that defaults to "deny everything" cannot be defeated by forgetting to add a rule — forgetting just keeps the door shut. A blocklist that defaults to "allow everything" fails open the moment you miss a case. The capability model is the former: a new tool, a refactored tool, or a tool whose author forgot to declare a capability all fail safe, not silently broad.
Where capabilities are scoped to specific paths — read these files, never those — the harness
uses ToolPermissionProfile with a
deny-overrides-allow rule: if a path appears in both the allow list and the deny
list, deny wins. There is no ambiguity to exploit and no ordering trick that lets an
allow entry beat a deny entry.
SandboxOptions is the Domain-layer configuration for the sandbox model
itself; SandboxExecutionOptions is the Application-layer configuration for a
specific container execution. They are different types with different jobs. If you find
yourself reaching for one and the field you want isn't there, you probably want the
other.
Argument-injection prevention
Even a correctly-caged subprocess can be subverted if you build its command line by gluing
strings together. The harness's execution request,
src/Content/Domain/Domain.AI/Sandbox/SandboxExecutionRequest.cs, removes the chance
entirely: subprocess arguments are passed as an ArgumentList — an array where
each element is handed to the process directly, with no shell interpretation in between.
The contrast is the whole defense. Consider a tool that runs grep over
user-supplied input:
// SAFE — each array element is a literal argument, never parsed by a shell.
var request = new SandboxExecutionRequest
{
ArgumentList = ["grep", userInput] // userInput is ONE argument, whatever it contains
};
// UNSAFE — string concatenation hands the input to a shell to re-parse.
var cmd = $"grep {userInput}"; // a shell now interprets metacharacters in userInput
ArgumentList, if userInput is the string
"; rm -rf /" it is passed to grep as a single literal argument — a
pattern to search for, which matches nothing useful and harms nothing. With the concatenated
string, a shell sees the ; as a command separator and the
rm -rf / as a brand-new command to run.
To force this safe path everywhere, the older string-valued Arguments property is
deprecated with error: true — code that still uses it won't compile, so there is no
quiet legacy path left that re-parses a shell string.
Characters a command shell treats specially instead of literally:
; and && chain commands,
| pipes output, $(...) and backticks substitute command output,
> redirects to a file, * expands file names. Argument
injection is the trick of smuggling these into an argument so the shell runs your command
instead of treating the text as data. Passing an argument array with no shell in the
middle means none of these characters are ever special — there is no shell to interpret
them.
HMAC attestation: a signed receipt for every run
The cage stops a tool from doing damage. Attestation answers a different question:
can you later prove this output really came from a sandboxed run of this input, and wasn't
tampered with afterward? The harness signs an attestation after every sandboxed execution in
src/Content/Infrastructure/Infrastructure.AI/Attestation/HmacAttestationService.cs,
using HMAC-SHA256.
A keyed cryptographic signature over some data. Unlike a plain hash — which anyone can recompute — an HMAC can only be produced or verified by someone holding the secret key. So an HMAC over a tool's result proves two things at once: the result hasn't changed, and it was signed by the harness (the keyholder), not forged by whoever stored it.
The signature covers a fixed payload. On a successful run:
// Success payload that gets HMAC-signed:
{toolName}|{inputHash}|{outputHash}|{timestamp:O}|egress:{egressDigest}
// Failure payload (no output to hash, so the failure reason is hashed instead):
{toolName}|{inputHash}|null|{failureHash}|{timestamp:O}
inputHash and outputHash are SHA-256 hashes of the input and the
output. On failure there is no output, so a hash of the failure reason takes its place. The
timestamp binds when it ran; the egressDigest binds which network
destinations were permitted before the untrusted tool ran — see
Egress & SSRF for that side of containment.
Why this matters: the attestation proves a given output was produced by a real sandboxed execution of a given input, and was not altered after the fact. The egress digest in the payload ties the run to the exact set of network destinations that were allowed when it started, so a result and its network policy can't be silently decoupled later.
An attacker who can reach the result cache swaps a stored tool output for a poisoned one — a doctored summary, a fake "all clear," an injected instruction the agent will later read as truth.
The swap changes the bytes, so the outputHash no longer matches the signed
attestation. Verification fails. Because the attacker doesn't hold the HMAC key, they
can't re-sign the forged payload to make it pass either. A tampered result is detectable,
not silently trusted.
Where the signing key lives
An HMAC is only as trustworthy as the secrecy of its key, so the key handling is deliberate. Keys
come from IOptionsMonitor<AttestationKeyOptions> —
src/Content/Infrastructure/Infrastructure.AI/Attestation/AttestationKeyOptions.cs —
sourced from User Secrets in development and Azure Key Vault in production. They are
never read from appsettings.json.
-
CurrentKeyVersionsupports rotation: the signed payload carries the key version, so keys can be rolled forward without invalidating the ability to identify which key signed an older attestation. -
Key bytes are zeroed after use with
CryptographicOperations.ZeroMemory, so the secret doesn't linger in process memory longer than the signing operation needs it.
The full story on User Secrets, Key Vault, rotation, and why nothing sensitive ever lands
in appsettings.json is on
Data Protection & Privacy. This page only
covers what the attestation service needs from it.
What a skill can read: two file sandboxes, not one
File access on the host runs through two separate sandboxes with deliberately different permissions. They share one implementation of the rules — allow/deny geometry, symlink resolution, hard-link identity checks — so they cannot drift on what "inside" means, but they permit different directories and offer different operations.
| Sandbox | Reachable by | Permits | Operations |
|---|---|---|---|
IFileSystemService |
The model, via the file_system tool |
AppConfig:Infrastructure:FileSystem:AllowedBasePaths, plus the
configured logs directory |
Read, write, list, search |
ISkillFileReader |
The harness's own skill loader only | The configured skill content roots:
AppConfig:AI:Skills, AppConfig:AI:Agents, and the bundle
staging root |
Read only — the interface exposes no write operation |
The guarantee, stated plainly: skill loading is confined to the
configured skill content roots, read-only — and to nothing in the model's own file
allowlist. Everything a skill can load — its SKILL.md, and any reference or
template file it discloses on demand through read_skill_resource — must live
inside one of those roots. A path outside them is refused with an error, never silently
treated as missing: a refusal that read as "this directory holds no skills" would be
indistinguishable from a directory that genuinely holds none, so a misconfigured root would
boot an agent quietly missing its skills instead of failing.
The two allowlists are disjoint by design, so the guarantee is not the union of the two — the model's file tool gains nothing from the skill roots, and skill loading gains nothing from the model's configured paths.
Merging them is the obvious simplification and it is unsafe. Skill content sits outside the
model's sandbox by default — the shipped configuration allows workspace while
skills live in skills — so unifying them means adding the skill roots to the
allowlist the file_system tool uses. That tool exposes an ungated
write. The model would then be able to rewrite its own SKILL.md
files, including the allowed-tools list that constrains which tools it may
call. Two narrow sandboxes over one shared rulebook avoids that; a regression test
(ModelFileSandbox_DoesNotCoverSkillRoots_SoSkillsCannotBeRewritten) fails if
the two allowlists are ever merged.
The skill sandbox resolves its permitted roots from live configuration rather than snapshotting
them at startup. Plugin-supplied skill directories are registered during host start, after the
dependency container is built; a snapshot taken at registration time would refuse exactly the
plugin skills the harness advertises support for. A plugin cannot widen the sandbox arbitrarily
— its skill directory is accepted only after being verified as contained within the plugin's own
directory, and that directory comes from operator configuration under
AppConfig:AI:Plugins:Packages.
Where the network side lives
This layer contains what a tool can do on the host: how much it can consume, what it can touch, and proof of what it produced. The other half of containment is where it can reach on the network — blocking SSRF, cloud-metadata credential theft, and exfiltration. That's the egress allowlist whose digest you saw signed into the attestation payload above. It gets the full treatment on the next page.