Run agent code in an isolated Linux virtual machine — safely, locally, and with full dev environment capabilities.
The Sandbox is a shared Linux container powered by Apple's Containerization framework. It gives every Osaurus agent access to a real Linux environment with shell, package managers, compilers, and file system access — all running natively on Apple Silicon inside a hardware virtual machine, keeping agent code off your macOS system. (See Security Boundaries for what the VM does and does not isolate.)
Sandbox Tools vs Native Plugins: Osaurus has two distinct extensibility systems. Sandbox tools (this guide) are JSON recipes that run inside the Linux container — no compiler, no code signing, ideal for shell-based workflows. Native plugins are compiled
.dylibfiles with full host API access (inference, storage, HTTP routes, web UIs); seedocs/plugins/README.md. The terms used to overlap; this doc uses Sandbox Tools consistently.
Agents can run arbitrary code, install packages, and modify files inside a disposable, resettable VM instead of on your Mac. If something goes wrong, reset the container and start fresh. The VM boundary protects the host filesystem and processes; sandboxed code can still use whatever network egress and host-bridge capabilities you grant it, so those are policy-controlled separately (see Network Policy).
Agents gain a full Linux environment with shell access, Python (pip), Node.js (npm), system packages (apk), compilers, and standard POSIX tools. This far exceeds what macOS-sandboxed tools can offer, enabling agents to build, test, and run real software.
Each agent gets its own Linux user and home directory. Standard Unix permissions keep one agent's files and processes separate from another's within the shared VM. System-level state is still shared: packages installed with apk (root) are visible to every agent, so a system package one agent installs or upgrades can affect the others. Per-agent language-level environments (pip --user, npm prefix) stay isolated per home directory.
Sandbox tools are simple JSON recipes. No compiled dylibs, no Xcode, no code signing required. Anyone can write, share, and import tools that install dependencies, seed files, and define custom capabilities — dramatically lowering the barrier to extending agent capabilities. (For richer extensibility with full host API access, see native plugins.)
Everything runs on-device using Apple's Virtualization framework. No Docker, no cloud VMs, no network dependency. The container boots in seconds and runs with native performance on Apple Silicon.
Despite running in isolation, agents inside the VM retain full access to Osaurus services — inference, memory, secrets, agent dispatch, and events — via a vsock bridge. The sandbox is isolated but not disconnected.
- macOS 26+ (Tahoe) — required for Apple's Containerization framework
- Apple Silicon (M1 or newer)
On earlier macOS versions the sandbox automatically falls back to a native macOS Seatbelt backend — see Seatbelt Fallback.
On Macs that can't run the Containerization VM, Osaurus still offers sandboxed execution using the system's Seatbelt facility (sandbox-exec). Commands run as regular host processes confined by a deny-by-default sandbox profile: they can read the system but can only write inside the sandbox workspace (~/.osaurus/container/workspace/, seen by agents as /workspace) and a scratch temp directory. The backend is chosen once at launch — macOS 26+ always uses the VM, older systems always use Seatbelt.
Everything in this guide applies to both backends unless noted. The differences:
| Linux VM (macOS 26+) | Seatbelt (earlier) | |
|---|---|---|
| Environment | Alpine Linux, full userland | macOS, BSD userland |
| Package managers | pip, npm, apk |
pip, npm (no apk) |
Tool recipe dependencies |
Supported | Not supported — install via setup with pip/npm |
| Network policy | Off, on, or per-domain allowlist | All-or-nothing. A configured domain allowlist can't be enforced and fails closed to no network |
| Isolation boundary | Hardware VM, separate filesystem | Process-level write confinement. Reads of the host are not blocked |
| Per-agent environments | Separate Linux users, optional per-agent rootfs | Shared workspace tree with per-agent home directories |
| Sandboxed MCP servers | Supported | Not supported — set the provider's Run in to Host |
| Provisioning | Kernel + rootfs download (~1 min) | Instant, no download |
Two behavioral notes for Seatbelt: denied file lookups surface as "No such file or directory" rather than "Operation not permitted" (deliberate macOS anti-probing behavior), and ~ inside sandboxed commands resolves to the agent's workspace home, not your macOS home.
Open the Management window (⌘ Shift M) → Sandbox.
Click Provision to download the Linux kernel and initial filesystem, then boot the container. This is a one-time setup that takes about a minute. You can also leave setup deferred: the first custom-agent chat that needs sandbox execution receives the transient sandbox_init_pending tool. Calling it waits for provisioning and, on success, adds the real sandbox schemas to the next model step in that same run. A startup failure returns an actionable unavailable envelope instead of requiring a blind retry.
Once the container is running, sandbox tools are registered as one canonical process-wide runtime surface. Every call resolves the requesting agent from its chat/work execution context, so concurrent and non-active agent runs keep their own Linux user, home, config, secrets, package manifest, and plugin scope. Calls without an authorized request configuration fail closed. The model sees the stable public workspace names (file_read, file_search, file_write, file_edit, shell_run) plus enabled control-plane tools such as sandbox_install and sandbox_plugin_register; backend-specific sandbox_read_file / sandbox_exec adapters stay private. Capability ids name Osaurus tools/skills; sandbox commands and Python/Node libraries are separate and are checked with shell_run or installed with sandbox_install.
Switch to the Sandbox tab in the Tools manager to browse, import, or create JSON tool recipes that extend your agents with custom capabilities. For native dylib plugins (full host API access), use the Available tab and see docs/plugins/README.md.
Before provisioning, or when a sandbox setup looks unhealthy, open the
Management window (⌘ Shift M) → Sandbox and use Provisioning
Preflight. The report inspects the resolved Osaurus root, config directory
and sandbox.json, cache directory, temporary directory, container workspace,
agent/shared workspace roots, kernel/initfs assets, warm rootfs state, and the
runtime bridge socket.
Readiness is intentionally typed and conservative:
| Readiness | Meaning |
|---|---|
ready |
Required host paths exist, are the expected type, and passed read/write checks. |
needs_setup |
Setup is incomplete or provisioning-created paths/assets are missing but repairable. |
blocked |
A required path, permission, platform, architecture, or disk-space check failed. |
unproven |
A check could not be proven and should not be treated as healthy. |
Each finding includes a code, severity, status, affected path when available, and a concrete repair suggestion. Missing and unproven checks stay visible in the report instead of being coerced to a pass state.
Use Copy JSON on the preflight card to attach a support artifact or CI proof record. The JSON uses stable snake-case keys and includes:
- resolved paths and source (
default_home,environment_override, ortest_override); - configuration source and setup state;
- per-location status, read/write probe result, file size, and volume capacity where available;
- typed findings with repair suggestions;
- overall readiness.
Maintainers can exercise the model and report output with:
swift test --package-path Packages/OsaurusCore --filter SandboxProvisioningDiagnostics┌──────────────────────────────────────────────────────────────┐
│ macOS Host │
│ │
│ ┌──────────────┐ ┌──────────────────────────────┐ │
│ │ Osaurus │ │ Linux VM (Alpine) │ │
│ │ │ │ │ │
│ │ SandboxMgr ─┼─────┤→ /workspace (VirtioFS) │ │
│ │ │ │→ /output (VirtioFS) │ │
│ │ HostAPI ←──┼─vsock─→ /run/osaurus-bridge.sock │ │
│ │ Bridge │ │ │ │
│ │ │ │ agent-alice (Linux user) │ │
│ │ ToolReg ←──┼─────┤ agent-bob (Linux user) │ │
│ │ │ │ ... │ │
│ └──────────────┘ └──────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
Key components:
| Component | Description |
|---|---|
| Linux VM | Alpine Linux with Kata Containers 3.32.0 production ARM64 kernel, 8 GiB root filesystem |
| VirtioFS Mounts | /workspace maps to ~/.osaurus/container/workspace/, /output maps to ~/.osaurus/container/output/ |
| Networking | vmnet-backed interface: shared NAT in outbound mode, host-only + filtering egress proxy in proxy mode, absent in none mode |
| Vsock Bridge | Unix socket relayed via vsock connects the container to the Host API Bridge server |
| Per-Agent Users | Each agent gets a Linux user agent-{name} with home at /workspace/agents/{name}/ |
| Host API Bridge | HTTP server on the host, accessible from the container via osaurus-host CLI shim |
Configure the container via the Management window → Sandbox → Container tab → Resources section.
| Setting | Range | Default | Description |
|---|---|---|---|
| CPUs | 1–8 | 2 | Virtual CPU cores allocated to the VM |
| Memory | 1–8 GB | 2 GB | RAM allocated to the VM |
| Network | outbound / proxy / none | outbound | outbound = unrestricted NAT; proxy = host-only network with a domain-allowlist egress proxy (set per-agent Allowed Domains in Agent settings); none = no networking |
| Per-Agent Environments | on / off | off | Experimental: boot from the provisioning agent's own copy-on-write clone of the base image (see below) |
| Auto-Start | on / off | on | Automatically start the container when Osaurus launches |
Changes require a container restart to take effect.
Config file: ~/.osaurus/config/sandbox.json
{
"autoStart": true,
"cpus": 2,
"memoryGB": 2,
"network": "outbound"
}The first cold boot unpacks the pinned OCI image into an 8 GiB rootfs.ext4
and captures that pristine, never-booted filesystem as an immutable base
template (~/.osaurus/container/templates/, keyed by image digest +
runtime format version) using an APFS clonefile — an instant, block-sharing
copy. Any later boot that can't reuse the previous rootfs (reset, app update
that kept the same image pin, corrupted warm cache) clones the template in
milliseconds instead of re-unpacking. Templates are never booted or mutated;
a damaged clone triggers exactly one fall back to a full unpack, which
recaptures a fresh template.
With Per-Agent Environments on, the VM boots from the provisioning
agent's own clone of the base template
(~/.osaurus/container/environments/<agent>/rootfs.ext4) instead of the
single shared mutable rootfs. System packages an agent installs with apk
persist in its own environment and are invisible to other agents. The pool
is bounded: least-recently-used clones beyond the configured cap (default 3)
are evicted after each boot and re-clone fresh from the template on next
use. Agent home directories live on the virtiofs /workspace mount and are
never affected by environment eviction or reset.
The VM is still one-at-a-time: switching the active agent takes effect at the next sandbox start. Turning the toggle off returns to the shared-rootfs behavior unchanged — that is the rollback path while this feature is staged.
Every sandboxed agent gets a SOUL.md file at ~/SOUL.md inside its home (host path: ~/.osaurus/container/workspace/agents/{name}/SOUL.md). This is the agent-authored complement to the user-authored persona slot — a place for the agent to record stable preferences and patterns it learns about working with you, persisted across sessions.
- Stable user preferences (tooling choices, voice, formatting).
- Recurring patterns the user expects.
- Working agreements established over time.
- Session-specific facts → use Memory.
- Project-specific details → use
AGENTS.mdin folder mode. - Transient context.
| Step | When | What happens |
|---|---|---|
| Seed | First sandbox provision for an agent | A documented seed is written to ~/SOUL.md (idempotent — never overwrites an existing soul). |
| Read | Every chat compose in sandbox mode | Composer reads the file, caps it at 8 KB on a line boundary, and emits a ## SOUL section into the system prompt between persona and operational directives. |
| Edit | Any time during a sandbox session | The agent edits its own soul via file_write or file_edit. Edits apply on the next session — within the active turn the cached system prompt stays byte-stable for KV-cache reuse. |
Persona (the user-authored systemPrompt on the Agent) wins on conflict. Two reinforcements: render order pins persona above SOUL, and the SOUL section's intro line states "the user's instructions in earlier sections take precedence." In practice the two operate at different scopes (persona = role, SOUL = preferences) and direct conflict is rare.
To wipe an agent's soul, delete ~/.osaurus/container/workspace/agents/{name}/SOUL.md. The next provision will re-seed the boilerplate; the next chat compose will pick it up.
Sandbox-only by design — folder-mode agents are short-lived and project-bound.
When the container is running, sandbox tools are automatically registered for the active agent. Model-facing file and shell operations use the same five names as folder mode and route to the sandbox backend through the request execution scope. Write and execution tools require autonomous_exec to be enabled on the agent.
Default ON for new custom agents (where supported): The built-in Default agent is configuration-only and never receives autonomous execution. On supported machines, newly created custom agents default on. To avoid a surprise multi-GB download, a never-set-up sandbox stays un-provisioned until first use. The model initially sees only
sandbox_init_pending; that call awaits boot and per-agent provisioning, then replaces itself with the real public tools in the same run. The placeholder is never frozen into the session baseline. Provisioning from the Sandbox tab or toggling a custom agent off→on also boots immediately; later launches auto-start aftersetupComplete.
| Don't | Do |
|---|---|
cat / head / tail in shell_run |
file_read |
grep / rg / find / ls in shell_run |
file_search |
sed / awk |
file_edit with old_string → new_string |
echo / cat heredoc to create files |
file_write with content |
& / nohup / disown for backgrounding |
shell_run with its background option, then sandbox_process (poll/wait/kill) |
Reserve shell_run for builds, git, processes, network calls, and work without a dedicated tool. Use sandbox_install for package installation. For multi-line scripts, write the script with file_write, then run it with shell_run.
| Tool | Description |
|---|---|
file_read |
Read a file or list a directory in the sandbox |
file_search |
Search sandbox file contents or find files by name |
file_read uses a bounded raw path for plain text, source, and
CSV/TSV: it reads at most 5 MiB before UTF-8 decoding and returns explicit
metadata when that cap truncates the preview. Rich documents and XLSX previews
use the document-adapter limits instead.
When file_read is pointed at a directory (host or, in combined mode, a
/workspace/... sandbox path), it returns a structured kind: "listing"
envelope with entries[] ({name, path, type}) instead of an ASCII tree —
the sandbox route builds it with find -maxdepth N -printf '%y\t%p\n'. Each
entry's path is a ready-to-use argument for the next file_read, so a small
model descends by copying a field rather than parsing a tree. The agent loop's
AgentTaskState harness classifies the listing to steer the next step; see
Agent Loop — Harness Task State.
| Tool | Description |
|---|---|
file_write / file_edit |
Create or replace files, or make an exact in-place edit |
shell_run |
Run a shell command in the sandbox; background jobs pair with sandbox_process |
sandbox_process |
Manage background jobs: action="poll" (alive + log tail), "wait" (block until exit, capped by timeout), "kill" (force:true for SIGKILL). |
sandbox_install |
Install exact package specifiers, one tool for all three managers via the required manager argument: apk (system packages, runs as root, auto-refreshes the index, serializes globally on apk's container-wide lock), pip (Python packages into the agent venv at ~/.venv/, auto-created on first use, --disable-pip-version-check --no-input), or npm (Node packages into a per-agent workspace at ~/.osaurus/node_workspace/, bootstraps package.json, --no-audit --no-fund --no-update-notifier). Try guessed alternative package names in separate calls; one invalid specifier fails a multi-package request. Python/Node libraries and CLI binaries resolve from any shell_run cwd (NODE_PATH includes the per-agent npm workspace). 240s timeout for pip/npm, 120s for apk. |
sandbox_secret_check |
Check whether a secret exists for this agent (never reveals the value) |
sandbox_secret_set |
Store a secret securely — pass value directly or omit to prompt the user |
sandbox_plugin_register |
Register an agent-created plugin (requires pluginCreate permission) |
The backend-specific sandbox_read_file, sandbox_search_files, sandbox_write_file, and sandbox_exec implementations remain registered but private. The public workspace tools route to them only after request mode, path, and execution-scope checks. Control-plane tools stay public only under their owning gates: process management requires background jobs, and registration requires Plugin Creation. Direct manual selection or a stale loaded-tool name cannot restore a hidden adapter or disabled control.
share_artifact is a global built-in (registered in ToolRegistry) and is the only way for sandbox-generated content to reach the chat thread. It's not in this sandbox-specific list because it's available everywhere, not just in sandbox mode.
All file paths are validated on the host side before container execution by SandboxPathSanitizer, which now returns structured rejection reasons (empty, traversal, null byte, dangerous character, outside allowed roots). Tools surface the reason to the model in an invalid_args envelope so the next call self-corrects instead of retrying with the same bad path.
sandbox_install's three managers (apk / pip / npm) share a hardening pipeline:
| Layer | Behaviour |
|---|---|
| Per-agent serialization | SandboxInstallLock queues install operations behind each other per agent so two concurrent calls can't race on node_modules/ / venv / apk db. apk's lock is container-wide, so sandbox_install calls serialize globally across every agent under a synthetic key — a slow apk add on agent A briefly blocks agent B's apk add. npm/pip installs are isolated per-agent and run concurrently across agents. |
| Request validation | A shared normalizer caps package count and specifier length, rejects empty/control-character/option-injection values, and POSIX-quotes every accepted apk/pip/npm argument. Plugin-declared dependencies use the same path. Malformed requests return invalid_args before agent or root execution. |
| Network preflight | A disabled network setting returns a non-retryable rejected envelope. Configured-but-unavailable egress returns retryable unavailable before invoking a package manager. Plugin dependency failures carry the same actionable setting/readiness detail. |
| Auto-recovery | If the first attempt fails AND its output matches a known stale/transient signature (Tracker "idealTree" already exists, EEXIST, ELOCKED for npm; ReadTimeoutError, temporary DNS/network failure, or a stale distutils install for pip; temporary error, unable to lock database for apk), the tool runs a tool-specific cleanup and retries once. The result envelope includes retried: true so the model can see the recovery happened. |
| Failure classification | Package/version-not-found and malformed-specifier failures are deterministic and return retryable:false with failure_class:"package_resolution". Known network/lock failures remain retryable. Every failed attempt identifies the manager, requested specifiers, retry status, exit code, failure class, and a correction hint. |
| Cleanup actions | npm: rm -rf node_modules/.package-lock.json && npm cache clean --force. pip: pip cache purge. apk: apk update. All run in the same exec context (agent for npm/pip, root for apk) as the install attempt. |
| Workspace isolation | npm installs into ~/.osaurus/node_workspace/ (bootstraps package.json on first use). Its node_modules/.bin is on PATH, node_modules is on NODE_PATH, and an absent home-level node_modules is linked to the workspace so CommonJS and ESM files under the agent home use normal upward module lookup. pip installs into the agent's venv at ~/.venv/, whose bin/ is also on PATH. |
| Stable flags | npm: --no-audit --no-fund --no-update-notifier. pip: --disable-pip-version-check --no-input. apk: --no-cache plus a leading apk update --quiet. |
| Timeouts | npm/pip: 240s (covers cold-cache installs of large packages like torch / pandas / scoped npm packages). apk: 120s. |
So the model doesn't re-probe or reinstall what it already has, SandboxPackageManifest keeps a host-side, per-agent record (~/.osaurus/agents/<uuid>/installed-packages.json) of installed packages by manager. Three writers feed it: SandboxAgentProvisioner seeds it once per provision via a cheap lazy reconcile (lists only top-level packages from the agent's pip venv and reads direct npm dependencies from package.json — apk is skipped because the base image carries hundreds of system packages and bare apk add can't succeed unprivileged), sandbox_install appends successful installs, and successful plugin-declared apk dependencies are attributed to the owning agent.
Context propagation has two deliberate phases. In the same tool loop, the successful install result carries installed, manager, summary, and exit_code (plugin registration carries installed_dependencies). The system prompt is not recomposed between tool steps. On the next composed turn, SystemPromptComposer renders the persisted state as a compact, capped "Installed sandbox packages" block inside the dynamic ## Sandbox state section (alongside configured secrets). It states that packages are used from shell/code rather than loaded as capability ids, and directs the model to verify any capped/unlisted name with shell_run. That section sits after the static prefix break, so package/secret changes stay fresh without invalidating the reusable prefix. The manifest is cleared on unprovision so a rebuilt container starts from observed truth.
apk modifies the shared container, so an installed system package is physically visible to every agent even though the prompt manifest records which agent requested it. Plugin registration rolls back failed library/install records and files, but it cannot safely uninstall an apk package that may already be used by another agent; that shared side effect remains explicit and recorded.
Every sandbox tool returns a ToolEnvelope JSON string. Success payloads in result:
- Read/inspect:
{path, content, size}(+ optionalstart_line/line_count/tail_lines/max_chars). - Search:
{pattern, target, path, matches}—targetis"content"or"files". - Exec foreground:
{stdout, stderr, exit_code, cwd}. Background (background:true):{pid, log_file, cwd, background:true}. - Process management:
{pid, alive|exited|killed, log_file, log_tail, ...}. - Install (
sandbox_install):{installed, manager, exit_code, summary}on success — the verbose installer log is trimmed (it's pure context noise); the failure path returns anexecution_errorenvelope with combined output plus{manager, requested, retried, failure_class, exit_code}. Deterministic resolution errors are non-retryable and tell the model to correct/remove the invalid specifier and try alternatives separately. Failure envelopes additionally carrycleanup_failed: trueif the cleanup step itself threw. On success, the installed names are recorded in the host-side package manifest described above.
Malformed inputs use kind: invalid_args with field pointing at the offending argument (path, cwd, content, packages, etc.) so the model can self-correct on the next turn. Configuration refusals use rejected; transient network/backend readiness uses unavailable.
Model-facing shell_run (foreground or background:true) routes to the private sandbox_exec backend in sandbox mode; both routes stream live output into the chat tool-call card while the process runs. The model still gets the final {stdout, stderr, exit_code} blob when the process exits — streaming is purely a side-channel for the user.
When a long-running command starts, the tool-call card mounts an inline terminal pane:
- Status pill in the header:
running 0:42,exited (0),terminated (user). - Live output below: monospaced, ANSI-stripped, auto-follows the tail unless the user scrolls up.
[Copy]button: snapshots the current output to the clipboard.[Terminate]button: red-tinted, only visible while the process is running. Sends SIGTERM, then SIGKILL after a 3 s grace.
The pane is capped at ~14 lines of monospaced text (240 pt); content beyond that scrolls inside the pane rather than growing the row, so a 10 MB build log can't blow up the chat layout.
Phase 1 dropped the wall-clock timeouts that used to kill long commands at ~2 minutes:
- The registry-level 120 s safety net is bypassed for
sandbox_execandshell_runviaOsaurusTool.bypassRegistryTimeout. - The tool's own
timeoutparameter is now an optional inactivity ceiling, not a wall-clock cap. When the model omits it, the command runs to completion or until the user terminates. - Pass
timeout: <seconds>ONLY when you want a hard idle ceiling (kill if no output for N seconds). The user's[Terminate]button is the primary control.
The inactivity timer (when set) resets on every byte of output, so a cargo build that produces silent stretches between status lines won't trip it as long as it's actually progressing.
When the idle ceiling does fire, the failure envelope carries the honest kind: "timeout" (retryable, with wording that explains it was an inactivity kill, not a wall-clock cap — "re-run with a longer timeout or emit progress output"). Sandbox-not-ready states (container not running, VM still provisioning) map to kind: "unavailable" (also retryable) so the model waits and retries instead of pivoting to a different approach. The same taxonomy applies to MCP provider calls: a provider timeout is kind: "timeout", an unreachable/disabled provider is kind: "unavailable".
When the user presses [Terminate]:
- SIGTERM is sent to the process group.
- After a 3 s grace, SIGKILL.
- The result envelope returned to the model carries
killed_by: "user"(alongside the usualstdout/stderr/exit_code) so it can decide whether to retry, fall back, or move on. - The status pill flips to
terminated (user).
Terminating from the chat card races the model the same way sandbox_process(kill) does — both end up at the SIGTERM/SIGKILL path. A model read mid-flight returns the captured output up to termination.
Two related changes catch the silent-pipeline-failure pattern that used to surface as {exit_code: 0, stdout: "", stderr: ""} from a failed curl ... 2>/dev/null | grep ... | head -80:
set -o pipefailis on by default for bothsandbox_execandshell_run. A real upstream failure now surfaces as the rightmost non-zero exit instead of being masked byhead/tee/cat.- Empty-output warning. When
exit_code == 0AND stdout AND stderr are all empty AND the command contained|or2>/dev/null, the result envelope'swarnings:array carries a hint pointing at the suppressed-stderr / pipeline pattern. - SIGPIPE soft note.
cmd | head -n Nlegitimately killscmdwith SIGPIPE (exit 141) onceheadreaches its limit. The result envelope flags this with a softer "captured stdout is still trustworthy" warning so the model doesn't treat it as a failure.
Don't use 2>/dev/null in pipelines. It hides errors from the result envelope. If you genuinely need silent stderr, redirect to a log file you can inspect with file_read later.
Sandbox plugins are JSON recipes that extend agent capabilities inside the container. They can install system dependencies, seed files, define custom tools, and configure secrets — all without compiling code.
{
"name": "Python Data Tools",
"description": "Data analysis toolkit with pandas and matplotlib",
"version": "1.0.0",
"author": "your-name",
"dependencies": ["python3", "py3-pip"],
"setup": "pip install --user pandas matplotlib seaborn",
"files": {
"helpers.py": "import pandas as pd\nimport matplotlib\nmatplotlib.use('Agg')\nimport matplotlib.pyplot as plt\n"
},
"tools": [
{
"id": "analyze_csv",
"description": "Load a CSV file and return summary statistics",
"parameters": {
"file": {
"type": "string",
"description": "Path to the CSV file"
}
},
"run": "cd $HOME/plugins/python-data-tools && python3 -c \"import pandas as pd; df = pd.read_csv('$PARAM_FILE'); print(df.describe().to_string())\""
}
],
"secrets": ["OPENAI_API_KEY"],
"permissions": {
"network": "outbound",
"inference": true
}
}| Property | Type | Required | Description |
|---|---|---|---|
name |
string | Yes | Display name |
description |
string | Yes | Brief description |
version |
string | No | Semantic version |
author |
string | No | Author name |
source |
string | No | Source URL (e.g., GitHub repo) |
dependencies |
string[] | No | System packages installed via apk add (runs as root) |
setup |
string | No | Setup command run as the agent's Linux user |
files |
object | No | Files seeded into the plugin folder (key = relative path, value = contents) |
tools |
SandboxToolSpec[] | No | Custom tool definitions |
secrets |
string[] | No | Secret names the plugin requires (user prompted on install) |
permissions |
object | No | Network policy and inference access |
Plugins are installed per agent. Each agent can have a different set of plugins installed, and each installation is isolated in its own directory within the agent's workspace.
Install flow:
- Validate plugin file paths
- Start the container (if not running)
- Create the agent's Linux user
- Install system dependencies via
apk - Create plugin directory and seed files via VirtioFS
- Configure secrets from Keychain
- Run the setup command
- Register plugin tools
Managing plugins:
- Open Settings → Tools → Custom
- Import plugins from JSON files, URLs, or GitHub repos
- Create new plugins with the built-in editor
- Export and duplicate plugins for sharing
Saving or importing a custom tool publishes its schemas immediately, and app startup republishes every recipe in the library. The recipe is installed into an agent's isolated workspace on that agent's first invocation; no separate manual install step is required.
Each tool in a plugin's tools array becomes an AI-callable tool. The tool name is {pluginId}_{toolId}.
Parameters are passed as environment variables with the prefix PARAM_:
| Parameter Name | Environment Variable |
|---|---|
file |
$PARAM_FILE |
query |
$PARAM_QUERY |
output_format |
$PARAM_OUTPUT_FORMAT |
The run field is a shell command executed as the agent's Linux user with the working directory set to the plugin folder.
Agents can check for and store secrets (API keys, tokens) using sandbox_secret_check and sandbox_secret_set. Secrets are stored in the macOS Keychain, scoped per agent.
| Path | When | How |
|---|---|---|
| Direct | Agent already has the value (e.g., received via Host API or Telegram bot) | Pass value parameter to sandbox_secret_set |
| Prompt | Agent needs the user to provide the value (Chat) | Omit value — a secure overlay appears with SecureField input |
The prompt path keeps secret values out of the conversation history and LLM context entirely. The execution loop pauses via withCheckedContinuation until the user submits or cancels.
- Agent calls
sandbox_secret_setwithoutvalue - Tool returns a
secret_promptmarker (JSON with key, description, instructions) - The chat execution loop intercepts the marker and shows
SecretPromptOverlay - User enters the secret value in a
SecureFieldand submits (or cancels via button/ESC) - The value is stored in Keychain and the tool result is rewritten to
{"stored": true, "key": "..."}(or cancelled) - Execution resumes with the sanitized result — the LLM never sees the secret
SecretPromptStatetracks aresolvedflag, makingsubmit()andcancel()idempotentonDisappearon the overlay callscancel()as a safety net if the view is dismissed unexpectedly- All session reset paths (
cancelExecution,finishExecution, etc.) dismiss pending prompts before clearing state
Storing a secret is only half the problem — the other half is keeping its value out of the model context and the persisted transcript afterwards:
- Output scrubbing. Agent secrets are injected into the exec environment, so
echo $KEYwould otherwise land the value in the model's context.SecretScrubberrewrites every known secret value insandbox_execstdout/stderr, background-job log tails, and sandbox-plugin tool output to[REDACTED:<ENV_KEY>]before the result is enveloped. Longer values scrub first (substring-safe); values under 6 characters are exempt to avoid false positives on ordinary output. - Argument scrubbing. When the agent uses the direct
valuepath ofsandbox_secret_set, the recorded copy of the tool-call arguments is rewritten (value→[REDACTED]) before it re-enters chat history, HTTP agent-run history, plugin complete/complete_stream history, or plugin streamed tool-call argument material; execution still sees the original. Malformed secret-set arguments fail closed rather than echoing the raw input. The prompt path never carries the value through the model at all and remains the recommended flow — the tool description steers models toward it.
Agents can author, package, and register new sandbox plugins at runtime. The plugin-authoring recipe is injected into the system prompt as a ## Building new tools section whenever plugin creation is enabled for the session (compact local models receive a shorter but complete manifest/register recipe). It is not modeled as a loadable skill, so it never appears in the capabilities manifest, discover, search, or load. Both the in-process sandbox_plugin_register tool and the host-API POST /api/plugin/create endpoint funnel through one shared registration pipeline (SandboxPluginRegistration.register) so they cannot drift.
autonomousExec.enabledmust betrueon the agentautonomousExec.pluginCreatemust betrue(the default inAutonomousExecConfig) — this is the single control for plugin creation; turning it off both suppresses the injected section and disablessandbox_plugin_register
- Agent writes script files with
file_writeto~/plugins/{plugin-id}/scripts/(or any subdirectory). - Agent writes a
plugin.jsonmanifest defining the requirednameanddescription, plus tools usingid, simplified parameter specs, andrun. - Agent calls
sandbox_plugin_register({"plugin_id":"<plugin-id>"})(or the host CLI callsPOST /api/plugin/create). - The shared registration pipeline validates the plugin, applies restricted defaults, persists to
SandboxPluginLibrary, runs the install, and hot-registers the tools. - The in-process tool awaits schema buffering before it returns; chat and eval loops activate exactly those schemas for the next model step, so the agent can immediately call and verify the returned prefixed tool.
- A non-blocking toast notifies the user with a Remove action for later review.
When sandbox_plugin_register loads a plugin directory, it recursively collects every UTF-8 readable file (excluding plugin.json itself) and merges them into the plugin's files map. Files explicitly defined in plugin.json take precedence over auto-discovered ones. Binary files are rejected up-front — plugin.files is text-only and silently dropped binaries would break library-driven reinstalls. Either remove them, regenerate them at install time in setup, or fetch them from a setup-allowlisted host.
Every agent-authored plugin is rewritten to enforce safe defaults before persistence:
permissions.networkis sanitised. Wildcard values (outbound) collapse tonone. Comma-separated domain lists are accepted as-is when every entry parses as a valid domain; invalid lists collapse tonone. Plan accordingly — declare exact API hostnames you need.permissions.inferenceis forced tofalse. Agent-authored plugins cannot call inference APIs.metadata.created_byis stamped toagent;metadata.created_viarecordsagent_toolorhost_bridge.
The shared pipeline rejects a registration up-front (no library state is written) when:
- File paths fail
SandboxPathSanitizer.validatePluginFiles - The
setupcommand references a host outsideSandboxNetworkPolicy.setupAllowlist - Any tool's
runcommand references a host outside the same allowlist - A declared
secretsentry has no value inAgentSecretsKeychainfor the requesting agent - The agent exceeds
SandboxRateLimiterquota forservice: "http" - The sandbox container is not running (
unavailable→ HTTP 503)
Registered plugins are saved to the SandboxPluginLibrary (~/.osaurus/sandbox-plugins/) and survive app restarts. Per-agent install state lives under ~/.osaurus/agents/{agent-id}/sandbox-plugins/installed.json. Manage, export, or remove plugins from the Sandbox → Plugins tab.
Screenshot or screen-capture access is policy-defined but not installed as a
default tool. A capture request is denied unless a trusted plugin owns the
capability, the plugin is installed and enabled, the user explicitly opted in,
the plugin has a permission grant, and the request is interactive. Background
capture is always denied. Denials use stable codes:
unknownCapability, pluginNotInstalled, pluginDisabled,
userOptInRequired, missingPermissionGrant, and
backgroundCaptureDenied.
The Host API Bridge connects the container to Osaurus services on the host. Inside the container, the osaurus-host CLI communicates with the bridge server over a vsock-relayed Unix socket.
| Command | Description |
|---|---|
osaurus-host secrets get <name> |
Read a secret from the macOS Keychain |
osaurus-host config get <key> |
Read a plugin config value |
osaurus-host config set <key> <value> |
Write a plugin config value |
osaurus-host inference chat -m <message> |
Run a chat completion through Osaurus |
osaurus-host agent dispatch <id> <task> |
Dispatch a task to an agent |
osaurus-host agent memory query <text> |
Search agent memory |
osaurus-host agent memory store <text> |
Store a memory entry |
osaurus-host events emit <type> [payload] |
Emit a cross-plugin event |
osaurus-host plugin create |
Create a plugin from stdin JSON |
osaurus-host log <message> |
Append to the sandbox log buffer |
Every request authenticates with a per-agent bearer token:
- The host mints a 256-bit token per agent and writes it to
/run/osaurus/<linuxName>.tokeninside the guest, mode0600, owned by that agent's Linux user. The directory is mode0711so users can open their own file by name without enumerating siblings. - The
osaurus-hostshim reads the token (allowed by uid) and sends it asAuthorization: Bearer <token>. The shim refuses to run if the token file is missing or unreadable. - The bridge resolves the token to an
(agentId, linuxName)pair viaSandboxBridgeTokenStore. Unknown or missing tokens get401— there is no fallback to a default agent. X-Osaurus-Useris no longer trusted. Identity is bound to the token, which is bound to a Linux uid by file permissions inside the guest.X-Osaurus-Pluginis still self-reported by the shim. It namespaces config and secrets within an agent but is not a security boundary between plugins of the same agent.
The agent dispatch route additionally rejects any body whose agent_id doesn't match the token-bound identity (403); agent memory query filters results to the calling agent's pinned facts.
Tokens are revoked when the agent is unprovisioned or the container is stopped, and re-minted on the next ensureProvisioned. After an Osaurus upgrade, plugin bridge calls fail closed until the container restarts and the new shim and token files are written — this happens automatically when Sparkle relaunches the app.
Bridge requests are capped at 8 MiB per body. Oversized requests are rejected with 413 Payload Too Large before reaching any handler. Combined with the public HTTP server's pre-auth caps (32 MiB generic, 64 KiB on /pair), this prevents an unauthenticated client from forcing unbounded memory allocation.
All file paths from tool arguments are validated by SandboxPathSanitizer before any container execution. Directory traversal attempts (..) are rejected, and paths are resolved relative to the agent's home directory.
Each agent runs as a separate Linux user (agent-{name}). Standard Unix file permissions prevent agents from accessing each other's files and processes.
Container networking has three modes:
outbound— unrestricted NAT internet access (the default, and an explicit user choice).proxy— the VM boots on a host-only vmnet interface with no NAT to the outside. All egress must go through a filtering HTTP/HTTPS CONNECT proxy that Osaurus runs on the vmnet gateway address. This mode is selected automatically when the provisioning agent has a non-empty Allowed Domains list in its Agent settings.none— no guest networking at all.
In proxy mode, enforcement is per-connection and per-agent:
- Guest processes receive
http_proxy/https_proxyenvironment variables carrying the agent's bridge token; the proxy derives identity from that token alone. - The requested hostname must match the agent's resolved allowlist — the union of the agent's own Allowed Domains and the domain lists declared by that agent's installed plugins (
permissions.network). Patterns areexample.com(exact) or*.example.com(subdomains, not the apex). - IP literals are rejected outright, and resolved addresses are re-checked on the host before connecting: names that resolve to loopback, RFC1918/ULA, link-local, CGNAT, or multicast/reserved space are refused (DNS-rebinding defense).
Known limitation: in proxy mode the guest can still reach the vmnet gateway address itself — i.e. the proxy and any host service bound to that interface. Closing that off from inside the guest requires in-guest firewall (nftables) support that upstream Containerization does not yet expose. Traffic to anything beyond the gateway is blocked by the host-only network itself.
Plugins declare their network requirements in the permissions field; those declarations are validated at install, reinstall, and repair time and feed the runtime allowlist.
SandboxExecLimiter— Limits the number of commands an agent can run per conversation turnSandboxRateLimiter— General rate limiting for sandbox operations and Host API bridge calls
Every external artifact the sandbox depends on is pinned to an immutable digest, and downloaded blobs are verified before they touch the on-disk container store. A registry, CDN, or release-host compromise cannot silently change the boundary the sandbox enforces.
| Artifact | Pin |
|---|---|
GHCR image (ghcr.io/osaurus-ai/sandbox) |
Multi-arch index digest (@sha256:...); the :latest tag is never used at runtime |
| Containerization SDK | Exact SwiftPM pin: 0.41.0 (the SDK used by Apple container 1.3.0) |
| Kata kernel | Kata 3.32.0, Linux 6.18.35-197 production/non-debug binary; both the release tarball and extracted kernel have pinned SHA-256 digests |
| vminit initfs | Containerization vminit:0.41.0 OCI index pinned by digest |
A digest mismatch is fail-closed: the temp file is deleted, alternate mirrors are not tried (silent fallback would mask exactly the upstream-compromise scenario this defends against), and provisioning aborts with SandboxError.integrityCheckFailed. The hashing pass is bounded at 768 MiB to accommodate the pinned Kata archive while stopping a runaway download from turning into a multi-GB hash job.
To rotate a pin (e.g. after intentionally bumping the sandbox image): fetch the new digest with crane digest … or docker buildx imagetools inspect …, paste the multi-arch index digest into containerImage in SandboxManager.swift, and update the corresponding SHA-256 constants alongside the URL in the same file.
The Sandbox UI includes built-in diagnostic checks accessible from the Container tab. Click Run Diagnostics to verify the container is functioning correctly.
| Check | What It Verifies |
|---|---|
| Exec | Can execute commands in the container |
| NAT | Outbound network connectivity |
| Agent User | Agent's Linux user exists and can run commands |
| APK | Package manager is functional |
| Vsock Bridge | Host API bridge is reachable from the container |
- Start — Boots the container (provisions first if needed)
- Stop — Gracefully shuts down the container
Removes the container and re-provisions from scratch. All agent workspaces and installed plugins are preserved (they live in the VirtioFS-mounted /workspace).
Completely removes the container and all associated assets (kernel, init filesystem). Agent workspaces are preserved.
Access these operations from the Container tab → Danger Zone section.
| Path | Description |
|---|---|
~/.osaurus/container/ |
Container root directory |
~/.osaurus/container/kernel/vmlinux |
Linux kernel |
~/.osaurus/container/initfs.ext4 |
Initial filesystem |
~/.osaurus/container/workspace/ |
Mounted as /workspace in the VM |
~/.osaurus/container/workspace/agents/{name}/ |
Per-agent home directory |
~/.osaurus/container/workspace/agents/{name}/SOUL.md |
Per-agent SOUL identity layer (seeded on first provision; agent-editable) |
~/.osaurus/container/output/ |
Mounted as /output in the VM |
~/.osaurus/sandbox-plugins/ |
Plugin library (JSON recipes) |
~/.osaurus/agents/{agentId}/sandbox-plugins/installed.json |
Per-agent installed plugin records |
~/.osaurus/config/sandbox.json |
Sandbox configuration |
~/.osaurus/config/sandbox-agent-map.json |
Linux username to agent UUID mapping |