Chapter 05 · Execution Safety

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.

Process sandbox
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.
Docker sandbox
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.

No silent downgrade

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.cs
  • src/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.

Attack scenario: the fork bomb

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
FileReadRead files
FileWriteWrite files
NetworkAccessMake network connections
SubprocessSpawn child processes
EnvReadRead environment variables
DatabaseReadRead from a database
DatabaseWriteWrite to a database
LlmInvocationCall an LLM

The model has three parts, and the order matters:

1. Declare
A tool states the capabilities it needs by annotating itself with [ToolCapabilityAttribute]. This is a static, reviewable contract — you can read a tool's source and know exactly what it claims to need.
2. Enforce
At run time, 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.
3. Default to nothing
The default grant is NONE. A tool with no declared capability can read no files, touch no network, and spawn no process. You don't lock a tool down — it arrives locked, and you open exactly the doors it proves it needs.
Closed-by-default is the whole point

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.

i
Two similarly-named config types — don't mix them up

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
With 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.

Shell metacharacters

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.

HMAC

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.

Attack scenario: tampering with a cached result

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.

  • CurrentKeyVersion supports 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.
i
Secrets handling has its own page

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.

Why these are not one sandbox

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.