Agent Sandboxing
Run OpenCode agent shell tool commands inside an isolated microVM instead of directly in the Manager container. Sandboxing does not restrict trusted OpenCode configuration or extensions and is not a per-project permission boundary.
Overview
When sandboxing is enabled, every command an OpenCode agent runs through the shell tool is executed inside a microVM managed by msb. OpenCode itself continues to run in the Manager container and loads the same global and project configuration, providers, models, plugins, tools, MCP servers, formatters, LSP servers, hooks, and shell settings as it does with sandboxing disabled.
The microVM sees repositories through bind mounts at the same paths used by the Manager. Agent commands therefore operate on the same files while running under a separate kernel without access to Manager configuration, provider credentials, or SSH keys.
What Gets Sandboxed
| Execution path | Through the microVM |
|---|---|
Chat session shell tool calls | Yes |
Scheduled run shell tool calls | Yes |
Subagent shell tool calls | Yes |
WebUI !command shell mode (POST /api/session/:sessionID/shell) | Yes; it spawns through the same Shell.create path as the shell tool, so it is planned and routed into the microVM. It is not badged in the UI because the surface fires no tool.execute.after hook |
Shell API (POST /api/shell) | Yes; it spawns through the same Shell.create path as the shell tool. It is not badged in the UI because the surface fires no tool.execute.after hook |
Slash-command shell templates (!`cmd`, POST /api/session/:sessionID/command) | No; OpenCode expands each interpolation itself with the configured shell, directly on the host and outside Shell.create, so the create.before hook never sees it |
PTY terminals (POST /api/pty, GET /api/pty/:ptyID/connect), including the Manager Terminal panel and project action terminals | No; normal OpenCode behavior, with the user's configured shell. Manager terminals and project actions receive the repo-scoped GitHub token env through CredentialProvider.getGhCliEnv at creation; commits take their identity from git config |
| OpenCode file tools | No; while enforcement is on they are denied the global configuration directory (see Mounts and Secrets) |
| Manager-side git operations | No |
| Plugins and custom tools | No; normal OpenCode behavior |
The ocm tool (ocm-manager.js) | No; runs in the Manager's OpenCode process |
| Local MCP servers | No; normal OpenCode behavior |
| Formatters, LSP servers, and hooks | No; normal OpenCode behavior |
| Custom provider modules | No; normal OpenCode behavior |
OpenCode 2 routes the agent shell tool, WebUI !command shell mode, and the shell API through Shell.create, which triggers the shell domain create.before hook with a mutable { command, cwd, timeout, shell, env } before spawning. The Manager-owned ocm-sandbox.js plugin registers that hook: while enforcement is on, it asks the Manager for the sandbox working directory and pins both the generated shim as the shell and that directory as OCM_SANDBOX_WORKDIR on the event. The event carries no tool call id, so every Shell.create spawn is sandboxed rather than only the ones with a tool call. PTY terminals and slash-command shell templates use separate paths and are not affected.
Both pinned values are locked accessors, and the plugin verifies each lock, so a later plugin cannot silently restore host execution. The shim fails closed as well: without OCM_SANDBOX_WORKDIR it prints an error to stderr and exits 126 instead of running the command on the host. If the sandbox cannot be prepared, the spawn fails instead of running on the host.
The command the agent wrote is never rewritten. It reaches msb exec as a single argument, so the recorded tool call, the permission rules, and the model's own context all keep the original command.
Each sandboxed shell call is marked sandbox in its tool metadata, which the WebUI shows as a green badge on the tool call. Metadata is not sent to the model.
OpenCode Configuration
Sandbox enforcement does not sanitize, rewrite, filter, or replace OpenCode configuration files. Global and project configuration loads normally, configured plugins are installed normally, and config, MCP, and authentication API requests are forwarded unchanged.
Enforcement does not change the configured shell setting. The plugin pins the shim per spawn instead, so the setting is left as the user configured it and the shell it names still applies to PTY terminals and to slash-command shell templates.
Existing .ocm-sandbox-backup and .ocm-quarantine artifacts created by older releases are restored during startup and are no longer created.
Configured extensions execute with OpenCode's normal host-process privileges. This includes plugins, custom tools, local MCP servers, formatters, LSP servers, hooks, custom provider modules, explicit shell configuration, and slash-command shell templates. These are trusted configuration outside the agent shell isolation boundary.
Other Shell Surfaces
WebUI !command shell mode and the shell API (POST /api/shell) are sandboxed: both spawn through the same Shell.create path as the shell tool, so they are planned and routed into the microVM.
PTY terminals follow OpenCode's normal host-process behavior during enforcement and use the user's configured shell. This includes the Manager Terminal panel and project actions: they run as OpenCode PTYs on the host, receive the repo-scoped GitHub token env through CredentialProvider.getGhCliEnv at creation, take their commit identity from git config, and still bypass the sandbox shell hook. Slash-command shell templates are not sandboxed either: when a command template contains a !`cmd` interpolation, OpenCode resolves the configured shell and runs the command directly on the host before the resulting prompt is sent, without going through Shell.create or its create.before hook. That interpolation is trusted configuration outside the agent shell isolation boundary.
There is no fallback to a host shell in the Shell.create path: the shim refuses to run without a pinned sandbox working directory, so a spawn whose plan, shim, or lock is unavailable is refused instead of running on the host.
The OpenCode server binds to the configured OPENCODE_HOST regardless of enforcement; OpenCode 2 always requires a password, so the managed server always starts with the password resolved by the Manager.
Host Requirements
Sandboxing requires KVM on a Linux host and access to Linux /proc for process identity attestation. Start the Manager with the sandbox overlay:
docker compose -f docker-compose.yml -f docker-compose.sandbox.yml up -d
The overlay exposes /dev/kvm, /dev/net/tun, and NET_ADMIN without enabling full container privilege. Docker Desktop on macOS and Windows cannot provide /dev/kvm, so the sandbox toggle remains unavailable there.
Scope and Lifecycle
All projects share one microVM named ocm-workspace:
- It mounts the four project roots plus the OpenCode tool-output, global-skills, and agent temporary directories, at identical guest paths. See Mounts and Secrets.
- Repositories and worktrees created after boot are visible immediately because their parent roots are mounted.
- Each command supplies its own working directory through
msb exec -w. - A session outside the mounted roots is refused rather than executed on the host.
- The Manager verifies the microVM image, resources, user, network policy, mounts (including the
/tmptmpfs size and mount options), and labels before reuse./tmpis the only tmpfs the microVM may carry; any other tmpfs fails attestation. - MSB pulls the configured
SANDBOX_IMAGE(a digest-pinneddocker.io/cstechdev/ocm-sandboxby default) automatically; see Sandbox Guest Image to build your own. - The Manager pins a neutral
/usr/bin/enventrypoint, so the image's own OCI entrypoint is never inherited. - A stale or unverifiable microVM is removed and recreated.
- Manager shutdown and an enforced-to-disabled restart stop the managed microVM.
To remove it manually:
msb rm --force --label ocm.managed=true
Mounts and Secrets
The microVM receives writable bind mounts for:
| Host path | Why it is mounted |
|---|---|
/workspace/repos | Project root |
/workspace/schedule-worktrees | Project root |
/workspace/.opencode/state/opencode/worktree | Project root: worktrees created through OpenCode's worktree API |
/workspace/.opencode/state/opencode/forge/worktrees | Project root: worktrees created by the opencode-forge plugin for its loops |
/workspace/.opencode/state/opencode/tool-output | Where OpenCode saves the full content of a truncated tool result before handing the agent that absolute path |
/workspace/.config/opencode/skills | Global skills, including the scripts and reference files a skill bundles and refers to by absolute path |
/workspace/.opencode/tmp/opencode | The temporary directory OpenCode's environment instructions tell agents to prefer for work outside the workspace |
Each is mounted at the identical guest path. That is the point: OpenCode hands the model absolute host paths, and the host-side read, write, and glob tools resolve them against the container. A path that is not mounted resolves for those tools but not for a sandboxed shell call, which is how an agent ends up searching for a file it was just told the exact location of.
Only the four project roots are accepted as working directories. The other three mounts are readable and writable but are never a valid working directory, so shell calls still have to run inside a repository or a worktree.
No internal API token exists anywhere under the mounted roots. The token lives in the Manager's database and reaches the generated plugins only through the OCM_INTERNAL_TOKEN environment variable of the Manager's own OpenCode process, which is never part of the guest environment.
Because localhost inside the microVM is the guest rather than the Manager, and because the guest has no token to present, an agent command cannot call the internal API with curl. Agents reach the Manager through the ocm tool instead, which executes in the Manager's own OpenCode process. The tool's request action covers settings, repos, OpenCode workspaces, and schedules through an allow-list of internal API routes, while send_notification covers push notifications.
The microVM also mounts a runtime-owned tmpfs at /tmp. It is sized to one quarter of the microVM memory, clamped to 1-512 MiB, so agent commands get guest-only scratch space that is not backed by a host filesystem.
The agent temporary directory is separate from that tmpfs. The Manager sets TMPDIR=/workspace/.opencode/tmp on the OpenCode child, which puts OpenCode's own temporary directory at /workspace/.opencode/tmp/opencode — the path its environment instructions advertise — so the same files are visible to sandboxed commands and to the host-side file tools. Everything else under TMPDIR (temporary files from host-side git, gh, and MCP servers) stays out of the guest. The Manager empties the mounted directory each time it starts the OpenCode child, matching the lifetime it had in the container's /tmp; it clears the contents rather than the directory itself, because replacing the directory would detach a running microVM's bind mount.
The following remain outside the microVM:
| Host path | Contents |
|---|---|
/workspace/config | SSH configuration and known hosts |
/workspace/.ssh-keys | Repository SSH private keys |
/workspace/.config/opencode/plugins, /workspace/.config/ocm | Generated plugins and the shell shim — the enforcement mechanism itself |
/workspace/.config/opencode (except skills) | OpenCode configuration |
/workspace/.opencode/state (except opencode/tool-output, opencode/worktree, and opencode/forge/worktrees) | Provider and MCP credentials, the forge database |
OpenCode's host process still reads these paths normally. They are omitted only from the agent command environment.
The configuration directory also holds service.json, where the Manager writes the managed OpenCode server password for service mode. OpenCode's default agent permissions allow the host-side file tools to read the global configuration directory, and that password grants the full OpenCode API, including unsandboxed PTY terminals. While enforcement is on, ocm-sandbox.js therefore denies, through OpenCode's permission evaluate hook:
readofservice.jsonitself;grepandglobwhose absolute search path is the configuration directory or one of its ancestors;external_directoryaccess to the configuration directory or one of its ancestors.
The denial is specific to service.json. A sibling configuration file such as opencode.json or opencode.jsonc is not denied for read, and the grep/glob check only matches an absolute search path that resolves to the configuration directory or an ancestor — a relative path is not matched. The skills subdirectory is unaffected.
Enabling and Enforcement
- Enable Sandbox in Settings.
- Restart the OpenCode server when prompted.
- The Manager starts the new child with
OCM_SANDBOX_ENFORCED=true. - The Manager writes the shell shim next to the generated plugins and refuses to start an enforced server if it cannot.
- The sandbox plugin resolves the
Shell.createworking directory through the internal planner and pins the shim and that directory for the spawn. - If capability detection, planning, boot, attestation, or shell pinning fails, the spawn fails instead of running on the host.
A directory outside the mounted roots fails with:
Sandbox enforcement is on but the sandbox is unavailable: working directory is outside the sandboxed project roots (/workspace/repos, /workspace/schedule-worktrees, /workspace/.opencode/state/opencode/worktree, /workspace/.opencode/state/opencode/forge/worktrees)
The enforcement stamp remains authoritative for the lifetime of the OpenCode child, even if the setting changes before the required restart.
Worktree Placement
- Scheduled runs use raw git worktrees under
/workspace/schedule-worktrees. - OpenCode's own worktrees (
/workspace/.opencode/state/opencode/worktree, created through its/api/worktreeAPI) and opencode-forge loop worktrees (/workspace/.opencode/state/opencode/forge/worktrees) are project roots, so agentshellcalls run inside them without further configuration. Both live under OpenCode's data directory because the Manager setsXDG_DATA_HOME=/workspace/.opencode/state. - Any other worktree location outside the mounted roots is created normally; only a later agent
shellcall whose working directory is outside the mounts is refused by the planner. - External repositories symlinked into
/workspace/reposremain outside the microVM because the link target is not mounted.
Git Credentials in the Sandbox
By default the guest receives no credentials, so a sandboxed git push, git pull, or gh call fails to authenticate. The repo's git identity (GIT_AUTHOR_* / GIT_COMMITTER_*) is always forwarded, so git commit works either way. This applies to agent shell calls, WebUI !command shell mode, and the shell API alike, since all three are routed into the microVM.
Forwarding is opt-in, off by default:
| Scope | Where | Effect |
|---|---|---|
| Global | Settings → Sandbox → Git credentials in sandbox (preferences.sandbox.gitCredentials) | Default for every repo |
| Per repo | repo_settings.sandboxGitCredentials | Overrides the global default in either direction. The planner honours it when resolving credentials, but nothing writes it yet — there is no UI or API for the per-repo override |
When enabled, the planner resolves credentials on the host and the shim forwards them into the microVM with msb exec -e:
- One
http.<host>.extraheaderpair per configured host, so a command can authenticate against every host you have a credential for, not just the repo's own remote. - Where several credentials share a host, the repo-bound credential wins, then
defaultGitCredentialId. Exactly one credential is ever sent per host — git treatshttp.<url>.extraheaderas multi-valued and would otherwise send competingAuthorizationheaders. GH_TOKEN/GITHUB_TOKENforgh.- At most 16 hosts. Beyond that the Manager logs a warning and forwards the first 16 rather than emitting a
GIT_CONFIG_COUNTthat git would reject.
Both the switch and the credentials themselves are resolved per command, so turning forwarding on or off, or changing a credential, takes effect on the next sandboxed command without restarting the OpenCode server. Only the sandbox enable toggle requires a restart.
Understand the trade-off before enabling it. msb exec -e is the only injection mechanism microsandbox offers, so the token is visible in the msb process arguments on the host and in the guest environment for that command's lifetime. A prompt-injected agent inside the microVM can read and exfiltrate any credential you forward. The global switch is the only exposed control today, so enabling it applies to every repo; leave it off while any agent handles untrusted input.
Sandbox Guest Image
SANDBOX_IMAGE defaults to a digest-pinned reference of docker.io/cstechdev/ocm-sandbox. The Sandbox Image workflow (.github/workflows/sandbox-image.yml) builds Dockerfile.sandbox natively: linux/amd64 on ubuntu-latest and linux/arm64 on ubuntu-24.04-arm. Pull requests from this repository that touch the Dockerfile, the workflow, or scripts/sandbox-dockerd-start.sh run build-time verification without publishing or requiring registry credentials. A manual workflow_dispatch publishes both platforms by digest and merges them into one manifest list, tagged with the commit SHA, package version, and optional tag input (default latest). Publishing requires DOCKERHUB_USERNAME and DOCKERHUB_TOKEN repository secrets. The job summary prints the index digest to pin.
Native runners avoid the x86_64 rustc segmentation fault observed under qemu-user on the arm64 build host. Later uv releases also failed in that environment. Neither platform skips toolchain execution to work around emulation failures. A single-platform local build on an arm64 host is:
docker buildx build --platform linux/arm64 -t ocm-sandbox:local -f Dockerfile.sandbox --load .
After a publish, update the pin in shared/src/config/defaults.ts (SANDBOX.IMAGE) and in the docker-compose.sandbox.yml default to the new @sha256: digest. That bump is what makes deployments adopt a rebuilt guest image, and it is deliberate rather than convenient:
- msb caches images by reference.
msb pull docker.io/cstechdev/ocm-sandbox:latestreports "already cached" without contacting the registry, andpull_policy: IfMissingnever re-pulls, so a host that pulled a mutable tag once keeps that content forever. - Sandbox attestation compares the image reference string. A floating tag therefore keeps passing attestation while its content drifts, so the running microVM is never recreated either.
A digest reference sidesteps both: it is a cache key no host has seen before, so IfMissing pulls it, and it fails the reference comparison against a microVM created from the old reference, so the Manager removes and recreates that microVM on its own. No manual cleanup is required on deploy; to reclaim the superseded image afterwards, run msb image prune or msb image rm <old reference>.
It is built from the same node:24.21.0-trixie tag as the Manager image (Debian 13, buildpack-deps based), so both track one Node and one Debian release and the compile toolchain is already present. pnpm, Bun, uv, fallow, Rust, Go, and Playwright have build-argument version pins; base-image and apt tools follow their package sources:
| Tool | Source | Notes |
|---|---|---|
gcc / g++ / make / ld / pkg-config | base image | GCC 14, GNU Make 4.4 |
glib-2.0 | base image | With pkg-config metadata |
git, ssh, python3, curl, wget, unzip | base image | |
python | apt (python-is-python3) | The base image ships only python3; a skill or script invoking python would otherwise fail |
ping, ip, ss, netstat, dig, host, nslookup, nc, traceroute, lsof, rsync | apt | The base image ships no network diagnostics at all. The image build fails if any of these is missing |
pnpm | npm install -g (PNPM_VERSION) | Installed into /usr/local as root. A project packageManager pin of a different version is honoured by pnpm itself, which downloads it into the pnpm store on first use; the build verifies that as an unknown uid |
bun, bunx | official installer (BUN_VERSION) | Installed to /opt/bun, world-readable, both symlinked onto PATH |
uv, uvx | Astral installer (UV_VERSION) | Standalone binaries on PATH; uv tool shims land in /opt/agent-tools/bin, which is on PATH. Pinned at 0.12.7 |
fallow | npm install -g (FALLOW_VERSION) | Dead-code and unused-export analysis CLI |
rustc, cargo, rustup | rustup (RUST_VERSION, minimal profile) | RUSTUP_HOME=/usr/local/rustup and CARGO_HOME=/usr/local/cargo follow the official rust image layout and are opened to every uid afterwards, so the exec user can cargo install and add toolchains; /usr/local/cargo/bin is on PATH |
go | official tarball (GO_VERSION) | Unpacked to /usr/local/go, on PATH. GOBIN=/opt/agent-tools/bin puts go install binaries next to the other agent tools; GOPATH and the module cache default under the writable HOME |
npm -g, pnpm -g | npm / pnpm | Global installs redirect to /opt/agent-tools (npm_config_prefix, PNPM_HOME), so they are writable for the exec user and their binaries are on PATH |
sudo | apt | Passwordless for every guest user via /etc/sudoers.d/ocm-guest; system-wide apt-get install works from the exec user |
pip, venv | apt | python3-pip and python3-venv on top of the base python3. Debian's externally-managed marker is removed, so pip and uv pip --system are not refused; system-wide writes still need sudo, so use --user or a venv |
jq, ripgrep, less, tree, file, procps | apt | Common CLI tools agent workflows expect; Debian 13 carries jq 1.7 and ripgrep 14 |
gh | official cli.github.com apt repo | Current release. Debian's own package is several years stale |
| Docker Engine, CLI, Buildx, Compose | official Docker Debian apt repo | ocm-dockerd-start starts the guest daemon on demand and waits for readiness. The Manager adds the provisioned exec user to the guest's Docker group. No host Docker socket is mounted |
| Chromium | Playwright (PLAYWRIGHT_VERSION, default 1.63.0) | Installed to PLAYWRIGHT_BROWSERS_PATH=/ms-playwright, world-readable so SANDBOX_EXEC_USER can launch it |
NODE_PATH=/usr/local/lib/node_modules is set so agent code can require("playwright") from any working directory. It is only a resolution fallback; a project-local node_modules still wins.
Before using Docker inside the guest, run ocm-dockerd-start. The helper is safe to call repeatedly, serializes concurrent starts, and probes only the guest's Unix socket regardless of DOCKER_HOST or DOCKER_CONTEXT. Daemon startup logs are in /var/log/dockerd.log. Docker data lives on the 20 GiB sparse ext4 named volume ocm-workspace-docker-data, mounted at /var/lib/docker; Docker's overlay storage cannot use the sandbox's overlay root filesystem. The named volume survives sandbox restarts and recreation, remains private to the guest, and is not shared with the host Docker daemon. Removing that named volume deletes its Docker images, containers, and volumes. Sandbox attestation requires exactly this named mount in addition to the existing project mounts.
The guest runs every command as a numeric host uid that has no /etc/passwd entry in the image, so the uncorrected default HOME is / and anything that writes per-user state — the pnpm store, uv and pip caches, git config, gh config, the cargo registry, the Go module cache — dies with EACCES on the first run. The image therefore sets HOME=/home/ocm-agent (mode 1777), opens the rust homes to every uid, and puts a world-writable /opt/agent-tools/bin on PATH for uv tool, go install and global package-manager installs. Image ENV reaches msb exec commands verbatim, including for unknown uids. The image build verifies the whole toolchain as an unprivileged uid — including a cargo build and a go run — so a root-only regression fails the build instead of the agent.
The pnpm store itself is pinned to a container-internal path with PNPM_CONFIG_STORE_DIR=/home/ocm-agent/.local/share/pnpm/store. The pin goes through pnpm's own config env var because pnpm 11 no longer reads npm_config_* variables; with the store unpinned, pnpm places it on the mounted project filesystem, which pollutes the repository, slows installs over the host bind mount, and can end up committed.
sudo also needs the exec user to exist in the guest, which the image cannot know at build time. The Manager provisions the /etc/passwd, /etc/group, and /etc/shadow entries for the exec uid through an idempotent root exec (verified by getent, so repeats are no-ops) at three points: when the workspace sandbox is created, when a stopped sandbox is started, and at Manager startup. A freshly created microVM does not accept msb exec until its guest agent is up, so provisioning first waits on msb ping (bounded by SANDBOX_START_TIMEOUT_MS) instead of racing the boot. Without the entries, sudo refuses with "you do not exist in the passwd database" or a PAM "account validation failure". Provisioning is deliberately non-fatal: if it fails, the Manager logs a warning and the sandbox still runs commands — only sudo is unavailable. A microVM created from an older image reference is replaced automatically when the digest pin changes, so it picks up both sudo and the fixed toolchain without manual cleanup.
Chromium launches headless as the non-root exec user without extra flags. If your host kernel restricts user namespaces so Chromium's own sandbox fails, pass --no-sandbox — the microVM is already the isolation boundary.
The image is around 5 GB unpacked against a 1.6 GB base; Chromium with its dependencies (about 1.1 GB) and the Rust and Go toolchains (about 0.8 GB) account for most of the rest. When sandboxing is enabled, the Manager gets everything ready before the first command instead of on it: at startup, and whenever enforcement is switched on, it pulls the image and then boots the shared microVM in the background (msb pull, then the same create/start/attest/provision path a command would trigger, bounded by SANDBOX_START_TIMEOUT_MS; raise it on slow links). One microVM serves every repo and schedule worktree, so a single warm-up covers the whole workspace. The pull is a no-op once cached, docker-compose.sandbox.yml persists the microsandbox store in the microsandbox-data volume so the download survives container replacement, and the shutdown handler stops the microVM again. Because the warm-up runs in the background, server startup never waits for it, and a command issued while it is still running joins the same in-flight boot rather than starting a second one.
Using your own image
Point SANDBOX_IMAGE at any OCI reference the host can pull. It must contain every tool the agent expects to run, and a shell at /bin/sh. Changing the value is safe at runtime: the running microVM fails image attestation and is recreated automatically.
To build it on the server instead of pulling:
docker build -f Dockerfile.sandbox -t my-sandbox:local .
# then set SANDBOX_IMAGE=my-sandbox:local
Pin a concrete tag or digest rather than a floating one. Attestation compares the image reference string, so a mutable tag keeps passing attestation while the underlying image drifts.
Override any tool pin at build time with its ARG, for example --build-arg PLAYWRIGHT_VERSION=1.62.0 or --build-arg RUST_VERSION=1.97.0; the build asserts the installed version, so a typo fails early instead of shipping a stale tool. If your project drives Playwright itself, match this version to the one in your package.json; a mismatched browser revision makes Playwright refuse to launch. Rebuild and republish the guest image, then update the SANDBOX.IMAGE digest, whenever you change a pin.
The Manager image itself carries the same Chromium browser and playwright package, installed by playwright install --with-deps chromium for the same PLAYWRIGHT_VERSION at build time and made world-readable. That is what makes browser automation run in a container with sandboxing off, where the agent has no root or sudo to install them at runtime; NODE_PATH=/usr/local/lib/node_modules lets agent code resolve playwright from any working directory. Both images track one pin, so bumping PLAYWRIGHT_VERSION refreshes the sandbox browser and the Manager's browser together.
Caveats
- With sandboxing enabled the image pull and microVM boot happen at Manager startup, so commands normally pay neither. A command that runs before the warm-up finishes waits on that same boot, bounded by
SANDBOX_START_TIMEOUT_MS. SANDBOX_IMAGEmust contain every tool the agent expects to run.SANDBOX_EXEC_USERmust match the workspace owner so commands can write mounted files.- A
shellthe user configured does not apply to theshelltool,!commandshell mode, or the shell API while enforcement is on, because all three are routed into the microVM; PTY terminals and slash-command shell templates still use it. - Credentials injected into OpenCode's host shell environment are not forwarded into the microVM unless git credential forwarding is enabled; see Git Credentials in the Sandbox.
- Message parts recorded by older releases still hold the old
msb execwrapper; the WebUI unwraps them for display and still badges them. - Plugins and other configured host-process extensions are trusted and are not isolated by agent
shellsandboxing.