Sandbox
Agent Kernel's sandbox capability gives agents a first-class, permission-bounded way to execute code and shell commands, work in a persistent workspace, and read/write files in an isolated environment. When enabled, agents automatically gain a set of sandbox tools and the usage guidance is injected into their system prompt, so your agent code stays free of sandbox mechanics.
Overview
For a deeper look at the internals behind this diagram (class contracts, broker and worker flows, session lifecycle, error hierarchy), see Architecture → Sandbox Internals.
Key design points:
- Self-describing. With
sandbox.enabled: true, Agent Kernel attaches the sandbox tools to agents and injects their usage into the system prompt. Your agent's own instructions never need to mention the sandbox. - Config-driven. Everything — provider, isolation, lifetime, permissions, identity — is
chosen in
config.yamlthrough workload profiles. No code changes to switch backends. - Pluggable and honest. Providers are selected by short name or a dotted path to your own
SandboxProvidersubclass. Each provider declares the capabilities it actually supports (isolation tier, shell, files, policy enforcement, user identity); Agent Kernel never pretends two backends are interchangeable on security grounds. - Fail closed. A permission or identity requirement a provider cannot honor is rejected
under
strictmode, never silently downgraded.
local_subprocessThe default demo provider, local_subprocess, runs code directly on the host with no
isolation. It is for development and testing only. Production deployments should use an
isolating provider such as docker.
Enabling the sandbox
Add a sandbox block to config.yaml. The minimal form uses the single-backend sugar —
set a type and its config block, and a default profile is synthesized for you:
sandbox:
enabled: true
type: local_subprocess # provider short name (or a dotted path to your own provider)
local_subprocess: {} # the provider's config block
broker:
flavor: thread # local default; see Broker flavors
Everything is inert when sandbox.enabled is false (the default): no tools are attached, no
hooks run, and no provider dependencies are imported.
Agent tools
When enabled, every in-scope agent gains eight tools. All return JSON strings; the execution and
file tools echo a sandbox_session_id (the session tools return session lists/ids and
check_sandbox_task its status). Machinery failures come back as {"error": ...} rather than
raising.
| Tool | Purpose |
|---|---|
run_code(code, language, sandbox_session_id, profile) | Execute code; returns stdout, stderr, exit_code. |
run_command(command, sandbox_session_id, profile) | Execute a shell command. |
write_sandbox_file(path, content, sandbox_session_id, profile) | Write a UTF-8 text file into the workspace. |
read_sandbox_file(path, sandbox_session_id, profile) | Read a UTF-8 text file (truncated to tool_output_max_chars). |
check_sandbox_task(task_id) | Poll a long-running execution that returned status: pending. |
list_sandbox_sessions() | List the sandbox sessions in the conversation (id, name, profile, status). |
new_sandbox_session(name, profile) | Create a fresh, empty session; returns its sandbox_session_id. |
destroy_sandbox_session(sandbox_session_id) | Destroy a session and its sandbox. |
tool_output_max_chars (default 8000) bounds how much stdout/stderr or file content is
returned to the model.
Restricting tools to specific agents
By default the tools attach to all agents. To limit them to named agents, set agents:
sandbox:
enabled: true
agents: [coder, data_analyst] # only these agents get the sandbox tools + prompt
type: local_subprocess
local_subprocess: {}
Omit agents for the current "all agents" behavior; an empty list attaches to none.
Workload profiles
A profile bundles a provider, a lifetime (scope), a permission policy, and an
identity mode. Agents pick a profile per call via the profile= argument; the injected
guidance lists the configured profiles. Define them explicitly for multi-profile setups:
sandbox:
enabled: true
default_profile: workspace
profiles:
workspace: # persistent, for multi-step work
type: local_subprocess
scope: per_session
idle_timeout: 21600 # 6 hours
local_subprocess: {}
scratch: # throwaway, one sandbox per execution
type: local_subprocess
scope: per_call
local_subprocess: {}
broker:
flavor: thread
Scopes
| Scope | Lifetime | Use for |
|---|---|---|
per_session (default) | One sandbox per AK session; workspace persists across turns. | Multi-step work, notebooks, iterative coding. |
per_call | A fresh sandbox per execution, torn down immediately after. | One-off, stateless computations. |
per_runtime | A single shared sandbox per profile, process-wide. | Shared warm environments (executions serialized; no pooling in v1). |
Sessions
Each sandbox is addressed by a sandbox_session_id. State (files, workspace) persists per id:
the agent reuses the id from a previous result to continue in the same environment, or omits it
for the profile's default session. Session ids are system-assigned — the agent creates new
ones with new_sandbox_session (optionally naming them) and finds earlier ones with
list_sandbox_sessions, rather than inventing ids.
Sessions belonging to one AK session are namespace-isolated: they live in that session's
non-volatile cache, so one conversation can never address another's sandboxes. (per_runtime
is the deliberate exception — a single shared entry per profile in process memory.)
Idle timeout and recreation notices
Every profile has an idle_timeout (default 1800 s). When a session is touched after being
idle past that window, its sandbox is reset (workspace discarded) and recreated under the same
id. The same recreation happens if a backend sandbox has vanished (self-heal). Either way the
reset is surfaced to the agent as a notice field on the result, and the injected guidance
tells the agent to relay it — so a wiped workspace is reported, never silently hidden.
(For environment: attached profiles the semantics differ deliberately — see the next
section: expiry only drops the session binding, and a vanished target is never recreated.)
Managed vs attached environments
Every profile declares its environment lifecycle — who owns the thing code runs in:
profiles:
ec2:
type: ec2_ssm
environment: attached # managed (default) | attached
ec2_ssm:
attach_to: i-0123456789abcdef0
managed(default): the provider creates the sandbox and the framework owns its lifecycle end to end — creation, idle reset, self-heal recreation, destruction.attached: the framework deliberately connects to an environment that already exists and never owns it — an EC2 instance over SSM, an existing container, a runtime you operate. This is an explicit opt-in: pointing agents at a live system must be a deliberate configuration choice, never a side effect.
The mode is validated at startup against the provider's declared lifecycle capabilities
(provisions / attaches_external), in both directions:
| Profile mode | Provider can only provision (e2b, daytona, local_subprocess) | Provider supports both (docker, kubernetes) | Provider is attach-only (ec2_ssm) |
|---|---|---|---|
managed (default) | OK | OK (attach_to must be unset) | rejected — attach-only providers require environment: attached |
attached | rejected — cannot attach to an existing environment | OK (attach_to required) | OK (attach_to required) |
Setting attach_to under a managed profile is also rejected, so the deliberate-choice rule
cannot be bypassed.
attached changes the runtime rules to match non-ownership:
- Never destroyed.
destroy_sandbox_session(and idle expiry) only drop the session binding; the environment itself is untouched. - Never recreated. If the attached target becomes unreachable, the failure is surfaced as an error instead of the managed self-heal (which would silently provision a fresh sandbox that is not your system).
- Idle notices say so. An expired attached session reports that its binding was reset and the environment is untouched, instead of "workspace state was discarded".
Policy and permissions
A profile's policy block is the permission and resource envelope for an execution:
profiles:
guarded:
type: docker
scope: per_session
policy:
network_egress: deny # allow | deny | allowlist
network_allow: [] # domains/CIDRs when egress is 'allowlist'
fs_allow_read: [] # filesystem paths readable (empty = provider default)
fs_allow_write: [] # filesystem paths writable
cpu: 1.0 # CPU cores
memory_mb: 512 # memory limit (MB)
timeout: 30.0 # per-execution wall-clock seconds (always enforced)
strict: true # fail closed on unenforceable dimensions
docker:
image: python:3.12-slim
Enforcement is per-provider, and unenforceable is not the same as ignored. Each provider declares which policy dimensions it can enforce. When a profile sets a non-default dimension the provider cannot enforce:
strict: true(default) → the execution is rejected with a policy error. Security is never silently downgraded.strict: false→ it proceeds, with a one-time warning naming the unenforced dimensions.
timeout is always enforceable (framework-side), so it never triggers a strict rejection.
Identity
Sandboxed code can run under the agent's identity or the invoking user's identity. Two settings drive this:
profile.identity.mode—agent(default) oruser.sandbox.principal_resolver— a dotted path to aPrincipalResolverthat maps the current session/agent to aSandboxPrincipal. Omitted → the built-inAgentPrincipalResolver(the agent's own identity).
sandbox:
enabled: true
principal_resolver: myapp.identity.SessionUserPrincipalResolver
profiles:
secure:
type: myapp.providers.MyIdentityProvider # a provider that declares principal_user=True
identity:
mode: user
The shipped local_subprocess and docker providers declare principal_user=False, so
identity.mode: user on them is rejected. Two shipped providers support user identity:
kubernetesmaps a user-mode principal onto RBAC impersonation headers:Impersonate-Userfromcredentials["user"](falling back to the principal'ssubject) and at most oneImpersonate-Groupfromcredentials["groups"](or the principal'sgroups); the Python client cannot send repeated group headers, so more than one group is rejected fail-closed. The API server then enforces the invoking user's own RBAC on pod creation, exec, and per-pod NetworkPolicies; impersonating clients are cached per(user, groups). Two operational notes: the worker's own identity must hold the cluster-scopedimpersonateverb (the ak-k8s chart gates this behindsandboxWorker.rbac.impersonate), and disposal (destroy, the idle sweep) runs under the worker's own identity because it is a platform action with no user in context.ec2_ssmassumescredentials["role_arn"]viasts:AssumeRoleand runs commands ascredentials["run_as"]when set (see the provider table).
A bring-your-own provider (dotted path) can implement any other mapping.
The resolver runs agent-side (it has the session in context); the resolved principal travels
in the broker request and is enforced provider-side where the credentials live. Fail-closed
rule: a user-mode profile requires both a provider that supports user identity and a
resolver that actually returned a user-mode principal — otherwise the execution is rejected. A
user-scoped request can never silently fall back to the agent identity.
See the end-to-end identity example for a REST app that authenticates each request and runs code under the caller's identity.
Providers
Providers are selected per profile by type. Each declares an honest isolation tier so you
know the boundary you are getting:
| Tier | Boundary |
|---|---|
none | No isolation boundary (host process). |
os_policy | OS-level confinement (seccomp/Seatbelt/bubblewrap). |
container | Shared-kernel namespaces (containers). |
syscall_filter | User-space kernel (gVisor-style). |
micro_vm | Firecracker / managed micro-VM. |
wasm | In-process WebAssembly runtime. |
Providers available today:
| Provider | Extra | Isolation | Notes |
|---|---|---|---|
local_subprocess | — (stdlib) | none | Runs on the host. Dev/test only. Shell + files + python/bash. |
docker | sandbox-docker | container | Container per sandbox (sleep infinity); files via archives; pip install. Maps deny→network_mode: none, cpu/memory→limits, fs→read-only rootfs + writable workdir. Requires a Docker daemon. |
e2b | e2b | micro_vm | Managed Firecracker micro-VMs on the E2B cloud (native async SDK). Stateful Jupyter-kernel execution (variables persist across calls); shell, files, pip install. Maps deny |