docs/internal/workspace-trust-sandbox-design.md
Status: design proposal. Supersedes the fuzzy "Restricted" middle level
sketched in remote-env-manager-design.md §Threat model with a containment
model. The enforcement core (crates/fresh-editor/src/services/workspace_trust.rs)
and per-project persistence already exist; this doc specifies the level semantics
we want to grow into and the cross-platform constraints that shape them.
Current state (WIP, intentional): the auto trust prompt on open is temporarily disabled, and undecided workspaces default to Trusted (full execution) — see
Editor::maybe_prompt_workspace_trustincrates/fresh-editor/src/app/popup_dialogs.rs. This restores pre-Workspace-Trust behavior so the surrounding work can merge while the trust UX is redesigned around the sandboxed-execution model below. The enforcement core, persistence, and the manualWorkspace: Set Trust Levelcommand are untouched, and an explicitly recorded decision is still honored — so re-enabling the gate is a one-function revert. Re-enabling (with the sandbox model) is tracked as the follow-up this doc specifies.
Workspace Trust gates one thing: a freshly-opened project may contain
attacker-controlled content that only becomes dangerous when executed
(a .envrc, a repo-placed ./.venv/bin/python, dotnet restore evaluating a
malicious .csproj, an npm preinstall, a Cargo build.rs, git reading a
repo-set core.pager/hook).
The current "Restricted" level tries to answer "which spawns are dangerous?"
syntactically — allow bare $PATH names, refuse executables resolved inside the
workspace. That question is not precisely answerable:
dotnet, make, npm, cargo, gradle, mvn)
resolve via $PATH yet read repo-controlled config and run arbitrary code —
they slip through the bare-name allowance. This is the reported
"opening a .cs file runs project commands" bug.git + repo .git/config core.fsmonitor/core.pager/hooks —
the CVE-2022-24765 class). So no allow-list of "safe" tools is actually safe.The escape is to stop classifying spawns and instead contain them. We don't
need to know whether dotnet restore is dangerous if it runs in a throwaway
sandbox with no access to the host filesystem, the user's credentials, or
(optionally) the network. This converts an unsolvable classification problem
into a tractable containment one, and it matches how CI systems and
sandbox-first editors (Zed's WASM extension host) treat untrusted code.
The axis becomes where code runs, not which spawns are permitted:
Blocked — nothing runs. No spawn of any kind succeeds (optionally: prompt-per-spawn as an audit mode). For reading genuinely hostile code with zero process execution. Unchanged from today; always available.
Sandboxed (the new default, replacing today's Restricted) — everything
runs, but inside an ad-hoc container that mounts the project and nothing
else sensitive. Build drivers, env activation, LSP servers, analyzers all
execute; the blast radius is the container, not the host. Repo-controlled
code cannot read ~/.ssh, ~/.aws, host env secrets, or the host
filesystem outside the mounted project.
Trusted — everything runs on the host, with the user's real environment, credentials, and tools. The user has vouched for the project.
The trust prompt on first open of an undecided project becomes a clean trichotomy: Open sandboxed (default) / Trust on host / Block. "Sandboxed" being the default means the safe choice is also the usable one — unlike a gate that silently suppresses tooling and leaves the user wondering why their LSP is dead.
Note: containment protects the host, not the repo. The project tree is mounted read-write (you are editing and building it), so repo code can still mutate the repo and reach whatever we mount or leave reachable. That is acceptable — the repo is already attacker-controlled — but it is why network policy and mount scope (below) matter, and why Blocked must remain for the "zero execution" case.
When the user opens an undecided project and chooses (or defaults to) Sandboxed:
devcontainer.json, we do not trust it blindly — a
committed devcontainer is itself repo-controlled content (its build,
postCreateCommand, features run code). Under Sandboxed we may use its
image/toolchain hints but run with our own hardened runArgs (no host mounts
beyond the project, our network policy), ignoring lifecycle hooks unless the
user escalates to Trusted. (Open question: how much of a shipped devcontainer
to honor — see below.)The synthesized config never lands in the project tree. Littering a
Fresh-specific .devcontainer/ into every untrusted repo is exactly the
artifact-in-the-tree problem the env-manager design rejected for committed
config. Instead the generated Dockerfile/compose/runArgs live in the user's
data dir, keyed by canonical workspace path — the same place trust decisions and
per-workspace overrides already live (<data_dir>/workspaces/<encoded-path>/,
alongside trust.json). The repo stays clean; teammates on other editors see
nothing.
A single "comes with everything" base image (dotnet + node + python + go + rust
.tool-versions/mise/asdf/pyenv exist. A repo pinned to
Node 18 or .NET 6 gets the wrong runtime from a fat "latest" image.Instead, derive the toolchain from the project's own declared files, in priority order:
devcontainer.json image/features (used as a hint, hardened — see
above)..tool-versions, mise.toml,
.nvmrc, .python-version, global.json (.NET SDK), rust-toolchain.toml,
.ruby-version.package.json ⇒ Node LTS,
*.csproj/*.sln ⇒ matching .NET SDK, Cargo.toml ⇒ stable Rust,
pyproject.toml/requirements.txt ⇒ Python, go.mod ⇒ Go).Build a minimal per-project image from a small common base plus the detected toolchain(s), cached by a content-hash of the inputs so the second open is instant. This reuses the existing devcontainer build/cache machinery rather than introducing a parallel one. A small curated base (shell, git, common build essentials, the language runtime) covers the long tail; exotic native deps fall to the user escalating to a shipped devcontainer or to Trusted.
~, no ~/.ssh/~/.aws/~/.config, no host
Docker socket (a mounted socket is host root — never mount it under
Sandboxed), no host env passthrough beyond a minimal safe set
(TERM, LANG, …). Secrets and credentials stay on the host.~/.cargo/~/.npm/~/.nuget is not (a poisoned build could write into them
and affect host/Trusted runs).Default-deny network is the safest, but it breaks npm install/dotnet restore/go mod download/cargo fetch — which is most of why anyone runs a
build at all. Options, to be decided per the security bar we want:
Recommendation: default-deny with a one-click "allow network for this project's builds" surfaced exactly when a sandboxed spawn looks like a dependency fetch — so the common case (read + light build of trusted-enough code) stays offline, and enabling network is an explicit, per-project, informed choice.
Sandboxing needs a container runtime, and one is not always present: SSH sessions to bare hosts, machines without Docker/Podman, locked-down corporate environments, native Windows without WSL. Sandboxed cannot silently degrade to "run on host" when there's no runtime — that reopens the exact hole we are closing.
So when no runtime is available, "Sandboxed" degrades to one of:
workspace_trust.rs.Either way the rule holds: no environment silently runs untrusted code on the host. The UI must state which mode is actually in effect ("Sandboxed via Docker" vs "Restricted (no container runtime — host gate)" vs "Blocked").
The runtime must work on macOS, Linux, and Windows/WSL2 at minimum.
Native containers (Docker or Podman, rootless preferred). Fast, cheap, good bind-mount performance. The reference platform. Rootless Podman is attractive because it avoids a root daemon and reduces the "container escape ⇒ host root" surface.
Containers run inside a Linux VM (Docker Desktop / Podman Machine / Colima / Lima). Works, but two costs:
We should detect any of the common providers rather than hard-requiring Docker
Desktop (license considerations too): probe for docker, then podman, then a
Colima/Lima-managed socket.
WSL2 is a real Linux kernel, so Linux containers run well via Docker Desktop's
WSL2 backend or docker/podman installed inside a WSL2 distro. Treat
"Windows + WSL2" as effectively the Linux path: the project should be opened
from within the WSL2 filesystem for good mount performance (mounting across the
/mnt/c Windows↔WSL boundary is slow, the macOS-bind-mount problem again). When
Fresh runs inside WSL2 it is just Linux; when Fresh runs as a native Windows
process talking to a WSL2 docker, the path translation (C:\… ↔ /mnt/c/…)
must be handled.
This is the uncertain one, and we should be honest about it:
Pragmatic stance for native Windows: require WSL2 for the Sandboxed level, and where WSL2 is genuinely unavailable, fall back to the host syntactic gate or Blocked (the no-runtime path above), with the UI saying so. We are not going to build a bespoke Windows sandbox; the honest options on locked-down Windows are "gate on host" or "Block." (A future, heavier option — a microVM such as Firecracker, or gVisor — is out of scope and also Linux/KVM-bound.)
Running an editor against an SSH host and sandboxing untrusted code means the
container must come up on the remote host (where the repo lives), not on the
user's local machine. The current Authority model does not support this, and
it's worth being precise about why.
Authority holds one process_spawner and one
long_running_spawner (authority/mod.rs:245). They are chosen exclusively:
an SSH authority wires the SSH-routed spawners; a container authority wires
the docker-exec spawners. There is no nesting and no decorator — the spawner
is a single Arc<dyn ProcessSpawner>.DockerExecSpawner runs Command::new("docker") directly
(docker_spawner.rs:141, :256, :285). It builds docker exec … argv and
executes it on whatever host the editor process runs on — i.e. always the
local docker daemon. There is no transport seam.spawnHostProcess — the op the devcontainer plugin uses to run
devcontainer up (bring the container up) — hardcodes a local
TokioCommand::new(&command) and deliberately bypasses the active authority
(plugin_dispatch.rs:2076, :2127). So container lifecycle is local-only
as well.So an SSH authority + DockerExec spawner can't be expressed: the docker spawner would talk to the local daemon, whose containers can't see the remote filesystem, while the SSH filesystem points at the remote host. The two backends are mutually exclusive, not stackable.
Option A — transport-agnostic docker spawner (compose over the active backend; recommended).
Refactor DockerExecSpawner/DockerLongRunningSpawner to delegate the actual
run to an inner: Arc<dyn ProcessSpawner> (and inner long-running) instead of
calling Command::new("docker"). The docker spawner's job becomes pure argv
rewriting — (cmd, args, cwd) → ("docker", ["exec", …], None) — and it hands
that to inner. Then:
inner = LocalProcessSpawner → container on the local daemon (today's behavior).inner = RemoteProcessSpawner (the SSH agent) → the agent runs docker exec
on the remote host, so the container lives remote-side. This reuses the
existing remote exec path and the StdioChild-over-SSH plumbing the LSP
already relies on (remote/spawner.rs), so the long-running/docker exec -i
case works the same way.This keeps the single-spawner Authority contract intact — the composition is
internal to building the spawner, not a new field. It is the most
Fresh-idiomatic shape (a spawner decorator over the existing trait objects).
Two concrete blockers to fix alongside it:
devcontainer up / docker run currently go through spawnHostProcess, which is hardwired local.
Either route lifecycle through the active authority's spawner, or redefine
"host process" to mean the backend's host (the remote under SSH) rather
than the local machine. The latter is the cleaner mental model and fixes a
latent inconsistency regardless of sandboxing.PathTranslation is a single
host↔remote pair (authority/mod.rs:67). With SSH the editor's "host" paths
are already remote-host paths (the FS is the SSH FS), so the container case
is remote-host-path ↔ container-mount-path — likely still a single
translation, but the derivation changes (the mount source is a remote path,
resolved by the remote daemon). This needs to be worked through so LSP URIs
still round-trip.Option B — DOCKER_HOST=ssh://user@host / docker context (local CLI, remote daemon).
Keep the docker spawner running the local docker CLI but point it at the
remote daemon over SSH via DOCKER_HOST/a docker context. Smallest change to
the spawner itself, but: it requires a local docker CLI and local→remote
daemon SSH access (a different, sometimes awkward setup than the agent), the
workspace must still live remote-side (the daemon resolves bind mounts there),
and it introduces a second remoting mechanism parallel to the agent. Weaker
fit with the existing architecture; reasonable only if Option A's refactor is
too large to take on now.
Option C — run the editor/agent itself on the remote (no nesting at all).
If fresh runs on the remote host (the user runs it there, or a remote agent
process hosts the editor), then "local sandbox" on that machine is remote
sandbox from the user's point of view — docker is local to the agent and Option
A's local path just works. This sidesteps composition entirely but is a
different deployment model (editor-on-server vs editor-local-acting-on-remote).
Worth keeping in mind: for some remote workflows it is strictly simpler than
nesting, and it may already be a supported mode.
Pursue Option A: make the docker spawner transport-agnostic by composing it
over the active authority's spawner, and redefine container lifecycle to
target the backend's host. It reuses the SSH agent exec + StdioChild-over-SSH
machinery, preserves the single-spawner Authority contract, and the two
blockers (remote lifecycle, path-translation layering) are tractable and worth
fixing on their own merits. Treat Option C as the pragmatic fallback for
deployments where the editor already runs remote-side, and Option B only if
A can't be scheduled.
workspace_trust.rs TrustStore, design rule #2).This is deliberately not a new subsystem. It reuses what the devcontainer work
already proved out (crates/fresh-editor/plugins/devcontainer.ts,
docs/internal/DEVCONTAINER_PLUGIN_DESIGN.md, AUTHORITY_DESIGN.md):
Authority + SpawnerSpec::DockerExec already route every spawn
(one-shot, LSP, terminal) through a container with an injected env. A
Sandboxed authority is a DockerExec authority pointed at the ad-hoc image with
hardened runArgs.Authority::with_trust, workspace_trust::gate) stays
the enforcement point: under Sandboxed the spawner is the container; under
the no-runtime fallback it applies the host syntactic gate; under Blocked it
refuses.EnvProvider capture works through the active backend, so env activation
inside the sandbox is the existing docker exec sh -lc capture path (the
"containers" follow-up already noted in remote-env-manager-design.md).TrustStore) extends to hold the synthesized
container config + the chosen network policy, same file location.devcontainer.json under Sandboxed. How much to use
(image/features) vs ignore (lifecycle hooks, host mounts)? A shipped
devcontainer is repo-controlled; running its postCreateCommand is execution.
Likely: use image/toolchain, run our own hardened args, skip lifecycle unless
Trusted.