# msb-manager documentation > Run coding agents in per-project microVMs with deny-by-default egress, and credentials and SSH keys that never enter the VM. Source: https://github.com/runoverlabs/sandbox-manager --- # Overview > msb-manager runs coding agents in per-project microVMs with deny-by-default egress and credentials that never enter the VM. ![msb-manager](assets/brand/msb-manager-mark-dotmatrix-dark.svg) **msb-manager** runs coding agents against your real repositories inside [microsandbox](https://github.com/microsandbox/microsandbox) microVMs, without giving them your network or your tokens. One sandbox per project, managed by a single command, `msbctl`, and a picker. ```console $ msbctl add myproject # register it: a short wizard $ msbctl start myproject # create the VM and bootstrap it, once $ cd ~/workspaces/myproject $ msbctl shell # a shell in this folder's sandbox; run claude there ``` ## What you get **Deny-by-default egress, per host and per port.** A sandbox reaches the hosts its project needs and nothing else. Egress rules come in named groups (`github`, `npm`, `python`, `go`, …) that a project picks from. A sandbox that needs SSH to one machine gets that port on that machine, not the whole machine. **Tokens never enter the VM.** Inside the sandbox, `$GH_TOKEN` holds a placeholder. The real value is swapped in *on the host*, in request headers only, and only for the hosts it was issued for. `gh api user` works; the token can't be read from inside the sandbox, so it can't leak from there either. **SSH without keys in the guest.** The sandbox talks to a per-sandbox filter in front of your SSH agent. It can list and sign with only the keys you picked, which lets it push over SSH and sign commits. The private keys never cross over. **One central `CLAUDE.md`.** Every sandbox gets msb-manager's own instructions plus your additions, copied in on each start from a read-only mount. Each sandbox has its own `~/.claude`, so none of them holds your real Claude credentials or another project's transcripts. **Config that travels with the repo.** Egress groups, packages and resource limits live in the project's `.msb/` and are committed with it. Host paths and tokens stay in `~/.config/msb/` on your machine. **No dependencies.** `msbctl` is one Python file that uses only the standard library, plus the `msb` binary. Nothing from pip, nothing to break after a year of not being touched. ## How it fits together ```text title="the picture" host microVM (one per project) ────────────────────────────────── ───────────────────────────────── msbctl ── creates/starts ───────────────────▶ /work ← your project folder ~/.config/msb/ your config, tokens ~/.claude ← its own state ssh-agent ◀── filter (only picked keys) ◀──── git push / commit signing msb egress: rules + secret substitution ◀──── every outbound connection │ └─▶ the internet, only where a rule allows ``` The VM sees your project at `/work`, with your own file ownership, so whatever the agent writes is yours on the host. Everything else stays on the host: your config, your tokens and your keys. ## Where to go next - New here? [Install](install.md), then [Get started](quickstart.md). - Already running a sandbox? [Everyday use](everyday.md) covers the commands you'll type every day. - Something refused a connection? Read [Network & egress](egress.md), then [Troubleshooting](troubleshooting.md). - Looking up a flag? See the [command reference](commands.md) and the [configuration reference](configuration.md). > **Tip:** Pointing an agent at these docs? Every page has a Markdown copy > beside it (`install.md` next to `install.html`), [`llms.txt`](llms.txt) > indexes them, and [`llms-full.txt`](llms-full.txt) is all of them in one > file. > **Note:** msb-manager is young and developed for one operator's machine > (Fedora, `msb` 0.7.6 and 0.7.7). It's small, has no dependencies and is > commented for whoever has to debug it at 2am, but it hasn't been run widely. > Issues and PRs are welcome, especially from other distros and newer `msb` > versions. --- # Install > Requirements, the one-line installer, verifying a release, and installing from a checkout. ## Requirements | What | Why | | --- | --- | | Linux with `/dev/kvm` | microsandbox is a libkrun microVM runtime, so this runs on a host, never inside a container | | [`msb`](https://github.com/microsandbox/microsandbox) on `PATH` | the runtime itself. Developed against 0.7.6, re-checked on 0.7.7 | | Python 3.11+ | `msbctl` uses only the standard library (`tomllib` needs 3.11). Nothing to pip install, ever | | `git` | project detection, cloning | | `fzf` *(optional)* | the full-screen menu and the picker; without it you get numbered prompts | | a terminal emulator *(optional)* | for the desktop entry: konsole, alacritty, foot, gnome-terminal and xterm are detected | ## One-line install ```console $ curl -fsSL https://github.com/runoverlabs/sandbox-manager/releases/latest/download/get.sh | sh ``` `get.sh` finds the latest release, downloads it, **verifies its sha256** against the checksums published with it, and runs the installer inside it. If the checksum doesn't match, nothing is installed. If an authenticated [`gh`](https://cli.github.com) is on `PATH`, it also checks the tarball's **build provenance** (see [verifying a release](#verifying-a-release)) and refuses to install if that check fails. To pin a release or pass installer options: ```console $ curl -fsSL .../get.sh | sh -s -- --version v0.2.0 # a specific release $ curl -fsSL .../get.sh | sh -s -- --prefix ~/tools # flags after -- go to install.sh ``` | `install.sh` option | Effect | | --- | --- | | `--prefix DIR` | where versions are kept (default `~/.local/share/msb-manager`) | | `--bin-dir DIR` | where `msbctl` and `msb-picker` are linked (default `~/.local/bin`) | | `--no-desktop` | skip the "Sandboxes" desktop entry | | `--uninstall [--yes]` | remove what the installer put there; your config and sandboxes stay | ### What a package install does The release is copied to `~/.local/share/msb-manager/versions//`, `current` points at it, and `~/.local/bin/msbctl` and `msb-picker` link through `current`. An upgrade is therefore one atomic switch, and the previous version stays around for rolling back. The installed tree is real files only: the installer refuses a release that contains symlinks. ```console $ msbctl self-update # the latest release $ msbctl self-update --version v0.2.0 # a specific one $ ~/.local/share/msb-manager/current/install.sh --uninstall ``` ## Verifying a release A checksum published next to a tarball only proves the download wasn't damaged: anyone who could replace the tarball could replace the checksum too. So each release is also **attested**: a Sigstore-signed statement, made by this repository's release workflow, of exactly which commit built which file. You can check it yourself: ```console $ gh attestation verify msb-manager-0.2.0.tar.gz --repo runoverlabs/sandbox-manager \ --signer-workflow runoverlabs/sandbox-manager/.github/workflows/release.yml ``` `get.sh` and `msbctl self-update` run this check automatically when `gh` is logged in, refuse to install if it fails, and tell you plainly when only the checksum could be checked. Set `MSB_MANAGER_SKIP_ATTEST=1` to skip it. ## From a checkout (development) ```console $ git clone https://github.com/runoverlabs/sandbox-manager msb-manager $ cd msb-manager $ ./install.sh # --dev is implied inside a git checkout ``` Dev mode symlinks the two commands into the checkout, so `git pull` updates the tool with no reinstall. `msbctl self-update` tells you to `git pull` instead. Everything else works the same in both modes. ## What is yours, and what ships The installer seeds two files that **you** own. They're real files, never links into the installed tree, and no install or upgrade overwrites them: - `~/.config/msb/config.toml`: your overrides on top of the shipped `defaults.toml`, such as egress groups, version pins and default resources. Keep it short; `msbctl config` prints the merged result and where each value came from. See the [configuration reference](configuration.md). - `~/.config/msb/CLAUDE.local.md`: your additions to the `CLAUDE.md` every sandbox gets. On each start msb-manager's own text and yours are put together into `~/.local/state/msb/profile/`, and that's what sandboxes see, read-only. `defaults.toml` itself ships with msb-manager and is read straight from the installed tree, so an upgrade updates it. Never edit it; put your changes in `config.toml`. Next: [Get started](quickstart.md). --- # Get started > From a fresh install to Claude Code running in your first sandbox. This page takes you from a fresh [install](install.md) to an agent running in your first sandbox. It takes about ten minutes, most of which is the first bootstrap. ## 1. First run: the machine-level setup ```console $ msbctl ``` The first time `msbctl` runs on a machine with nothing configured, it offers a short walk-through. Each step is optional, and `msbctl setup` returns to any of them later: - **Git identities**: the names and emails a sandbox can commit as, and optionally an SSH key to sign with. They're applied inside the guest with `git config --system`, so a repo's own identity still wins. - **Claude login**: a long-lived token from `claude setup-token`, stored in `~/.config/msb/secrets/global.env` (mode 0600). Sandboxes get a placeholder for it; the real token never enters a VM. - **Your network**: egress groups for your own machines, such as a Git server or a package mirror on your LAN. - **New-sandbox defaults**: where projects live, the default cpus and memory, which identity to use. > **Tip:** Prefer a GitHub *noreply* address for anything that can reach a > public repo. It attributes commits to your account without publishing a real > address. Find yours at . ## 2. Register a project ```console $ msbctl add myproject ``` A bare name resolves under your workspaces folder (`~/workspaces` by default), and the folder is created if it's missing. A path that states its location is taken as written: `/srv/thing`, `~/src/thing`, `./thing`. The wizard then asks, in order: 1. **Sandbox name**: lowercase letters, digits and dashes. It defaults to the folder name. 2. **GitHub repo**: detected from the checkout's `origin`. If the folder is empty, it offers to clone the repo into a subfolder over SSH. 3. **Git identity** and **SSH keys**: which keys from your host agent this sandbox may use. Nothing is ticked by default. 4. **Features and agents**: what the project needs, not raw egress rules. Ticking `python` brings the PyPI egress group *and* the toolchain packages. Features are pre-ticked from the repo's languages on GitHub. 5. **Resources and disks**: cpus, memory, the root disk size and, for podman, a container disk. 6. **Package caches, extra folders and secrets**: all optional. 7. **Image**: offered pinned by digest, so a moving tag can't change the sandbox underneath you. 8. **GitHub token**: a fine-grained PAT for this one repository, read straight into a 0600 file. It's never echoed and never on a command line. Nothing is started yet. The wizard writes three things: | File | What | Committed? | | --- | --- | --- | | `/.msb/sandbox.toml` | the portable policy: egress groups, packages, resources | yes, if the project folder is the checkout | | `/.msb/dev.yaml` | the `msb --conf` file: image, cpus, memory | yes, likewise | | `~/.config/msb/sandboxes/.toml` | the machine-local entry: host path, identity, keys, mounts | never | ### The GitHub token GitHub has no API for creating fine-grained tokens, so this one step is manual, once per project. Create one at with exactly: | Setting | Value | | --- | --- | | Repository access | Only select repositories → this one | | Contents | Read and write | | Pull requests | Read and write | | Actions | Read-only | | Metadata | Read-only (forced) | Inside the sandbox, `GH_TOKEN` is a placeholder. The real value is swapped in on the host, in request headers only, for `github.com` and `api.github.com`. `git push` over SSH doesn't use it at all; that goes through the [filtered SSH agent](credentials.md#ssh-the-filtered-agent). ## 3. Start it ```console $ msbctl start myproject ``` The first start creates the VM and runs the **bootstrap** inside it, once. That installs the base packages, node, `gh`, Claude Code and whatever the features asked for. It takes a few minutes. The bootstrap deliberately doesn't stop at the first failure: it reports every step that failed together at the end, so a missing egress rule shows up in one pass rather than one round trip at a time. Later starts take seconds: the VM keeps its state between stop and start. ## 4. Work in it ```console $ cd ~/workspaces/myproject $ msbctl shell ``` Inside a registered project folder you can leave the sandbox name out (see [Everyday use](everyday.md)). The shell starts in `/work`, which is your project folder, shared from the host. Files the guest writes there belong to you on the host. Run the agent: ```console $ claude ``` It's already logged in through the placeholder token, already has the central `CLAUDE.md`, and can push to GitHub through the filtered SSH agent. ## 5. Stop it ```console $ msbctl stop ``` Stopping keeps everything: installed packages, the Claude state, your files. An idle running sandbox costs about 300 MB, so leaving a few running is cheap. ## Where next - [Everyday use](everyday.md): the picker, `exec`, and the current-folder shortcuts. - [Network & egress](egress.md): what to do when something is refused. - [Credentials & SSH](credentials.md): more tokens, key selection, commit signing. --- # Everyday use > The current-folder shortcuts, shell and exec, the menu and the picker. ## The current folder names the sandbox Like `docker compose`, `msbctl` works out which sandbox you mean from where you are. Inside a registered project folder, or any folder below it: ```console $ msbctl shell # this folder's sandbox $ msbctl exec -- make test # run one command in it $ msbctl exec . make test # the same; "." means "this folder's sandbox" $ msbctl stop # start, rebuild, update, reclaim and purge too $ msbctl stop . other-sandbox # "." mixes with names $ msbctl allow . example.com # "." where more arguments follow ``` It is designed never to guess: - **The registry decides.** The sandbox is the registered one whose project folder contains your current directory. If projects are nested, the deepest one wins, the way git finds a repo. A `.msb/` folder on its own isn't enough: a fresh clone of a project that commits `.msb/` isn't registered on this machine. - **The name can be left out only when it's the only argument.** Where another argument follows (`exec`, `allow`, `observe`, `mount`, …), the name is still required, and `.` is the shorthand for it. `.` can never be a sandbox name, so `msbctl observe on` can't silently mean a sandbox called "on". - **It tells you what it picked.** On a terminal, a dim `name (~/path)` line goes to stderr. Piped output stays clean. - **It stops when it can't decide.** Two sandboxes registered on the same folder, a `.msb/` with no registered sandbox, or a folder outside every project: each is an error that says what to do. `shell` and `exec` also start in the matching subfolder: from `~/proj/src` you land in `/work/src`. ## shell, exec, code ```console $ msbctl shell myproject # bash -l, SSH agent wired up $ msbctl exec myproject -- npm test # one command $ msbctl code myproject # your editor on the project, on the host ``` `shell` and `exec` start a stopped sandbox first, but they never *create* one: creating is the one step that runs the bootstrap and fixes the network policy, so it should never happen as a side effect. Use `msbctl start` (or `msbctl rebuild`) for that. `code` opens `$MSB_EDITOR` (default `codium`) on the project folder **on the host**, detached from the terminal. The editor works on the same files the sandbox sees. ## Looking around ```console $ msbctl ls # one line per sandbox: state, project, pending updates $ msbctl status # one block per sandbox, with RAM and CPU figures $ msbctl show # everything about one: resources, versions, every egress rule $ msbctl config # the merged configuration, and where each value came from ``` `msbctl show` is the first thing to read when a sandbox behaves strangely. It lists the resources, disks and extra folders, the installed versions, the environment, every egress rule in order, and the git identity, SSH keys and secrets that are bound. ## The menu Bare `msbctl` opens a menu: register a project, manage sandboxes, machine setup, your `CLAUDE.md` additions, and refreshing version info. With `fzf` installed it's a full-screen list with a live status sidebar; without it, a numbered prompt offering the same actions. ## The picker **Manage sandboxes** in the menu, `msb-picker`, and the **Sandboxes** desktop entry all open the picker. It lists every registered sandbox with its state and what's out of date inside it, and binds a key to each action: | Key | Action | | --- | --- | | `enter` | open the editor on the project (`msbctl code`) | | `t` | shell | | `s` / `x` | start / stop | | `e` | edit, one wizard section at a time | | `u` | update claude, gh and apt packages | | `l` | reclaim memory | | `f` | refresh version info | | `n` | register a new project | | `R` / `D` | rebuild / purge (capitals, so a slip of the finger can't destroy anything) | | `space`, `a` | select one, select all; most actions apply to every selected row | | `j` / `k`, `q` | move, quit | Sandboxes marked *autostart* are started when the picker opens, so their state and versions are current by the time the list appears. Set `MSB_NO_AUTOSTART=1` to skip that. ## Stop, start, rebuild | Command | What survives | | --- | --- | | `msbctl stop` / `start` | everything: packages, state, files | | `msbctl rebuild` | your files, the Claude state, package caches and container images. Packages installed by hand are lost; the bootstrap reinstalls its own | | `msbctl purge` | nothing but your project folder. It asks first and lists exactly what it will delete | Rebuild whenever something that is **fixed at create time** changes: egress rules, mounts, the image, environment variables. cpus, memory and the root disk don't need one ([`msbctl resize`](commands.md#msbctl-resize)). ## Keeping a sandbox current ```console $ msbctl ls --refresh # check upstream versions $ msbctl update -c claude gh apt # update in place, no rebuild ``` What `update` installs is recorded in the sandbox's registry entry, so a later rebuild reproduces it instead of quietly reverting. ## Memory The VM's memory setting is a ceiling, not a reservation, but the guest's page cache is never given back on its own, so a sandbox that builds a lot drifts up to its ceiling and stays there. That's harmless, and takes about a second to fix: ```console $ msbctl reclaim ``` --- # How it works > The security model, the four configuration layers, and what is fixed at create time. ## The security model in one table | Threat | What stops it | | --- | --- | | The agent sends your code or data somewhere you didn't approve | deny-by-default egress: only hosts in the project's rule groups are reachable, per port | | The agent reads a token and leaks it | tokens never enter the VM. The guest holds a placeholder, and the real value is swapped in on the host, in headers, for named hosts only | | The agent steals an SSH key | keys never enter the VM. A per-sandbox filter in front of your agent only lists and signs, and only with the keys you picked | | The agent rewrites your git identity or host config | identity is set with `git config --system` *inside* the guest, never in `/work/.git/config`; your host config isn't mounted | | One project's sandbox reads another's transcripts or your Claude login | each sandbox has its own `~/.claude`, and the Claude credential is a placeholder too | | The central instructions get edited by the agent | `CLAUDE.md` is copied in from a read-only mount on every start | What it does **not** protect: anything the agent can reach through an allowed host, and your project folder itself, which it can read and write by design. Choose rule groups the way you'd choose what a new contractor can access. ## What runs where ```text title="host and guest" HOST GUEST (microVM, one per project) msbctl ─ creates, starts, edits bootstrap.sh piped in over stdin, once ~/.config/msb/ config, registry, tokens /work your project (shared folder) ~/.local/state/msb/claude/ ────────▶ /root/.claude its own Claude state ~/.local/state/msb/profile/ ─ read-only ─▶ CLAUDE.md, copied in each start msbctl _agent-filter ◀── vsock ────── socat bridge at /tmp/ssh-agent.sock ``` `msbctl` runs on the **host** and needs `/dev/kvm`. None of msb-manager's own code is mounted into the guest: the bootstrap is piped in over stdin, and the `CLAUDE.md` comes from an assembled copy, never from the installed tree. ## The four configuration layers Settings resolve through four TOML files. Later ones win: | # | File | Who writes it | Holds | | --- | --- | --- | --- | | 1 | `defaults.toml` | ships with msb-manager | egress groups, version pins, default resources | | 2 | `~/.config/msb/config.toml` | you | your overrides: identities, your own groups, defaults | | 3 | `/.msb/sandbox.toml` | the wizard, then you | this project's policy; portable, committed | | 4 | `~/.config/msb/sandboxes/.toml` | the wizard, then you | this machine's paths, keys, identity; never committed | **Merging.** Between layers 1 and 2, tables merge key by key and everything else is replaced whole. That includes an egress group: redefining `github` replaces it entirely rather than splicing two host lists together, because rule order matters and half a group is worse than either. For a sandbox, the registry entry overrides the project file, which overrides your machine defaults. `[env]` tables *merge* across the layers, so a project adding one variable keeps the telemetry opt-outs. ```console $ msbctl config # every value, and which file it came from $ msbctl show # what one sandbox resolves to ``` > **Note:** Nothing machine-local goes in `.msb/sandbox.toml`: no host paths, no > tokens, no sandbox name. That's what makes it safe to commit, so the policy > travels with every clone. ### Two project layouts Both are normal: - **The project is the repo.** `/.git` exists, so `.msb/` sits inside the checkout and is committed with it. - **The project contains the repo.** `msbctl add` produces this when it clones for you: `//.git`, with `.msb/` *beside* the checkout. There `.msb/` is machine-local by position, and a fresh clone elsewhere won't have it. ## Create time vs. restart Some settings are baked into the VM when it's created, and only a rebuild changes them. Others can change on a running sandbox. | Change | How it applies | | --- | --- | | egress rules, observe mode, DNS rebind protection | `msbctl rebuild` | | mounts, extra folders, project folder | `msbctl rebuild` | | the image, environment variables, disks | `msbctl rebuild` | | cpus, memory | `msbctl resize`: a restart, state kept | | root disk | `msbctl resize`: grows live | | secrets (add or remove) | `msbctl secret`: live or a restart | | SSH key selection | `msbctl keys`: immediately | | git identity, autostart | the next start | `msbctl edit` knows which is which: it applies what it can immediately and collects the rest into a single rebuild offer when you're done. ## The bootstrap `bootstrap.sh` runs once inside the guest at create time. It: 1. moves apt to HTTPS (plain HTTP is denied by policy), then installs the base tools (`socat`, `jq`, `curl`, `ripgrep`, `fd`, `tree`, `shellcheck`, `yamllint`, `nano`, `openssh-client` and a few more) plus the project's extra packages; 2. links the package caches and sets up podman storage, if the project uses them; 3. installs node from NodeSource, verifying the signing key's fingerprint, then Claude Code, unless the project turned it off; 4. installs `gh`, and `bun` and `gitleaks` if asked for, from pinned, checksum-verified GitHub releases; 5. marks `/work` as a git `safe.directory`, then runs the project's own setup script if it names one. It's deliberately not `set -e`. Every step records its outcome and the failures are summarized together at the end, so a too-tight allowlist can be fixed in one round trip. --- # Network & egress > Rule groups, the rule grammar, the three properties that are easy to get backwards, and adding hosts. Every sandbox is **deny-by-default**. A connection gets out only if a rule allows it, and the rules are built from named groups that each project picks. ## Rule groups A group is a named list of rules. msb-manager ships these in `defaults.toml`: | Group | Allows | | --- | --- | | `base` | DNS, the Ubuntu mirrors over HTTPS, and a **deny** of plain HTTP. Always applied, always first | | `github` | github.com (HTTPS and SSH), the API, release assets, raw content, Actions logs, attestation verification | | `claude` | `api.anthropic.com` and `platform.claude.com`. Claude Code needs both | | `npm` | the npm registry and NodeSource | | `bun` | the GitHub release assets bun is installed from | | `python` | PyPI | | `rust` | crates.io | | `go` | the Go module proxy and checksum database | | `containers` | Docker Hub (all three hosts it needs), ghcr.io | New sandboxes start with `github` and `claude`, and the wizard adds whatever the chosen features need. A project lists its groups in `.msb/sandbox.toml`: ```toml title=".msb/sandbox.toml" rule_groups = ["github", "claude", "npm"] extra_rules = ["allow@mirror.example.com:tcp:443"] # one-offs, emitted last ``` Your own groups go in `~/.config/msb/config.toml` (`msbctl setup` → "your network" writes them for you): ```toml title="~/.config/msb/config.toml" [rules] # By address and port: precise, and safe to use for SSH. my-servers = [ "allow@10.0.0.10:tcp:22", ] # By name over HTTPS. A suffix doesn't cover the apex, so list both. my-services = [ "allow@*.example.com:tcp:443", "allow@example.com:tcp:443", ] ``` ## The rule grammar ```text [:]@[:[:]] allow@github.com:tcp:443 a hostname, HTTPS allow@10.0.0.10:tcp:22 an address and one port deny@public:tcp:80 a class of addresses allow@dns DNS itself ``` ## Three properties that are easy to get backwards ### Rules are first-match-wins A leading deny can't be undone by appending an allow later. That's why `base` is always emitted first, and why nothing a project writes can come before it. In particular, **a plaintext :80 allow can't be added from a project**: the `base` deny has already matched by the time the project's rule is considered. Fix it with the `https://` URL; that almost always works. ### A hostname rule only means a hostname over HTTPS On :443, msb's TLS interception reads the server name (SNI) from each connection, so a name backed by a pool of addresses is fine. On any other port nothing inspects the traffic, and the rule is enforced as *the addresses that name resolved to*, which breaks against address pools. Prefer HTTPS everywhere. For SSH, prefer a rule by address. ### DNS resolution is part of the rule msb allows a connection based on the addresses *it* resolved from the allowed names. A name it can't resolve fails exactly like a name you never allowed: NXDOMAIN inside the guest, then a refused connection. This bites on home and office networks. A name like `git.example.com` can be a real public record pointing at `192.168.x.x`, and msb drops such answers as DNS rebind protection. The fix is per project: ```toml title=".msb/sandbox.toml" allow_private_dns = true ``` A group you list under `private_dns_groups` in your config gets this set automatically by `msbctl add` and `msbctl edit`. ## Adding a host When a connection is refused, treat it as policy first, not an outage. Then add exactly the host and port that was refused: ```console $ msbctl allow . registry.example.com # HTTPS (:443) $ msbctl allow . git.example.com:22 # a TCP port $ msbctl allow . allow@10.0.0.5:tcp:5432 # a full rule $ msbctl allow . pypi.example.com --rebuild # and apply it now ``` `allow` appends to `extra_rules` in the project's `.msb/sandbox.toml` (or in the registry entry, if that already overrides the list). Rules are fixed at create time, so they take effect at the next `msbctl rebuild`. If more than one project needs the same host, make it a group in your `config.toml` instead and add the group to each project's `rule_groups`. ## Finding out what a sandbox needs msb **never logs what it denied**, at any log level. It does log what it allows. So the only way to learn what a sandbox needs is to stop blocking for a while and write down where it goes. That's **observe mode**, covered in [Advanced usage](advanced.md#observe-mode-building-an-allowlist). > **Warning:** In observe mode nothing is blocked. Secrets stay protected > (substitution still happens on the host and only to their named hosts), but > the agent can reach anywhere. Turn it off as soon as you've collected what > you need. --- # Credentials & SSH > Tokens a sandbox can use but never see, the filtered SSH agent, and commit signing. ## Secrets: usable, never visible msb-manager binds tokens with msb's `--secret ENV@HOST`. Inside the guest the variable holds a placeholder, `$MSB_`. When a request leaves the VM, msb replaces the placeholder with the real value, **on the host**, in **request headers only**, and only for the named hosts. ```console $ echo "$GH_TOKEN" # inside the sandbox $MSB_GH_TOKEN $ gh api user --jq .login # still works: substituted on the way out yourhandle ``` What this means in practice: - `curl -H "Authorization: Bearer $GH_TOKEN" https://api.github.com/user` works. - `https://x:$GH_TOKEN@github.com/…` works too: curl turns the user info into an `Authorization` header before sending. - A placeholder in a **query string** or a **request body** is not substituted, unless the binding opts in with `:query` or `:body`. - Sending a token to a host it isn't bound to sends the placeholder text, which is useless to whoever receives it. The real values live in `~/.config/msb/secrets/`: `global.env` for the Claude token, and `.env` per sandbox. Both are mode 0600, outside every project. ### Claude `msbctl setup` → "Claude login" stores a long-lived token from `claude setup-token` in `secrets/global.env`. Every sandbox gets `CLAUDE_CODE_OAUTH_TOKEN` as a placeholder bound to `api.anthropic.com` and `platform.claude.com`, plus its own `~/.claude`, kept on the host at `~/.local/state/msb/claude//`. > **Note:** `claude_auth = "mount"` in your config shares the host's real > `~/.claude` with every sandbox instead. That means shared history, but also > your real credentials and every project's transcripts in every VM. It's a > fallback for when `spikes/07-claude-token-secret.sh` fails, not a preference. ### GitHub One fine-grained token per project, scoped to that one repository (see [Get started](quickstart.md#the-github-token)). It's bound as `GH_TOKEN` to `github.com` and `api.github.com`. Replace or remove it with `msbctl edit` → "GitHub token". ### More secrets ```console $ msbctl secret add . # pick a preset or type your own binding $ msbctl secret ls . $ msbctl secret rm . NPM_TOKEN ``` The presets: `npm`, `dockerhub`, `ghcr`, `pypi`, `crates`. Each knows its variable name, the hosts it goes to, where to create the token and how the tool picks it up. You can add your own presets under `[secret_presets.]` in `config.toml`. A hand-written binding uses msb's own syntax: ```text NPM_TOKEN@registry.npmjs.org MY_TOKEN:query@api.example.com # also substitute in the query string MY_TOKEN@one.example.com,two.example.com ``` A secret doesn't open the network: its hosts must also be allowed by an egress rule. `msbctl add` warns when they aren't; after `msbctl secret add`, check with `msbctl show` and [`msbctl allow`](egress.md#adding-a-host) what's missing. ### Why a placeholder can break an agent, and why it doesn't here msb blocks any outbound request carrying a placeholder in a place where substitution isn't allowed, such as a request body. Coding agents send their whole transcript in the request body on every turn. So once an agent has read `$MSB_GH_TOKEN` (from `env`, say), every later request would carry it and be killed, which Claude Code reports as `API Error: Connection dropped (ECONNRESET)`. msbctl prevents this by adding `passthrough=` for the agent's own API hosts to every binding it emits. Passthrough lets the harmless *placeholder* travel to those hosts and never substitutes the real value, so the real token still goes only into headers, only to its own hosts. ## SSH: the filtered agent A sandbox never gets your SSH agent directly. Its `--vsock` points at a small per-sandbox daemon, `msbctl _agent-filter `, which forwards exactly two kinds of request to your real agent: - **list identities**, answered with only the keys this sandbox may use; - **sign**, refused for any key not on that list. Everything else (adding keys, removing them, locking the agent) is refused. Signing still happens in your host agent, so passphrases, hardware keys and `ssh-add -c` confirmations all keep working. ```console $ msbctl keys . # pick keys: one list, agent keys and ~/.ssh files $ msbctl keys . --all # remove the restriction: every key in the agent ``` The selection is read on every request, so a change takes effect immediately. In the registry entry, `ssh_keys` absent means every key, and `ssh_keys = []` means none. If a selected key isn't loaded in your agent at start, msbctl finds it in `~/.ssh` and `ssh-add`s it, asking for a passphrase if it needs one. Inside the guest, `SSH_AUTH_SOCK` is `/tmp/ssh-agent.sock` in `msbctl shell` and `exec`. Your `~/.ssh/known_hosts` is mounted **read-only**: the sandbox can check hosts you already trust, but can't quietly add a new one. ## Commit signing Give an identity in `config.toml` (or in `msbctl setup` → "Git identities") a `signing_key`, the public half of an SSH key: ```toml title="~/.config/msb/config.toml" [identities.public] name = "yourhandle" email = "12345+yourhandle@users.noreply.github.com" signing_key = "ssh-ed25519 AAAA..." ``` A sandbox using that identity sets `gpg.format=ssh`, `user.signingkey` and commit and tag signing in the guest's `--system` git config, and signs through the filtered agent. An allowed-signers file is written too, so `git log --show-signature` verifies its own commits. Signing is on only while the sandbox is allowed that key. Drop it in `msbctl keys` and signing turns off, with a warning, rather than leaving you with a sandbox where every commit fails. --- # Advanced usage > Observe mode, caches and podman, resizing, version pins, renaming and moving, extra folders, project setup. ## Observe mode: building an allowlist msb doesn't record what it *denies*, only what it *allows*. So the way to learn what a sandbox actually needs is a round trip: ```console $ msbctl observe . on --rebuild # stop blocking, start recording $ # … use the sandbox normally for a while … $ msbctl observe . # every host it reached, and which no rule covers $ msbctl allow . $ msbctl observe . off --rebuild # back to deny-by-default ``` While observe mode is on, the sandbox is created with egress allowed by default, **no** `--net-rule` at all, debug logging and DNS rebind protection off: nothing is blocked and every host is written to the sandbox's runtime log. Your rules stay in the config and keep being curated; they're just not emitted. The report prints a ready-made `msbctl allow` line for whatever no rule covers. The mode lives in the **registry entry only**, never in `.msb/sandbox.toml`. That file is committed, and an unfiltered sandbox must never ship to everyone who clones the project. The report also shows **secret-policy blocks**: requests msb killed because they carried a placeholder somewhere substitution isn't allowed. Those are logged even in normal (enforce) mode. ## Package caches Each package manager can get its own disk for its download cache: `npm`, `bun`, `python` (pip), `rust` (cargo registry) and `go` (module cache). ```toml title=".msb/sandbox.toml" [caches] npm = "5G" ``` Each is a named disk volume, `-cache-`. It's as fast as the root disk, capped at its size, **kept** across rebuilds and deleted by `purge`. ```console $ msbctl cache . # usage per cache $ msbctl cache . clear npm # empty one (all of them without a key) ``` A volume's size is fixed when it's first created. Change it in the config later and msbctl keeps the existing size and warns you; `msbctl cache . clear` then recreates it at the new size. > **Note:** The caches mount at `/mnt/cache/`, and the bootstrap symlinks > the tool's usual directory (such as `/root/.npm`) to it. That detour is > deliberate: msb refuses a named-volume mount whose guest path contains a dot. ## Containers inside a sandbox (podman) The `podman` feature installs podman and opens the `containers` egress group. podman then needs somewhere to keep images. The root filesystem is an overlay that podman's overlay driver can't sit on, so pick one of: - **a container disk** (`containers_disk = "20G"`): a named volume at `/var/lib/containers`. Fast, and pulled images survive rebuilds. This is the recommended option. - **fuse-overlayfs** (`containers_storage = "fuse-overlayfs"` under `[bootstrap]`, plus the package): no extra disk, slower. With neither, podman falls back to `vfs`: slow, and about three times the disk. The wizard asks. ## Resizing without a rebuild ```console $ msbctl resize . --cpus 8 --memory 16G # needs a restart; asks first $ msbctl resize . --root-disk 32G # grows live; shrinking is refused ``` `resize` uses `msb modify`, so the sandbox keeps its state. The new values are written to the registry entry, so a later rebuild reproduces them. ## Versions and updates Component versions are pinned in `defaults.toml` under `[versions]` (node's major, `gh`, `gitleaks`, `bun`), and the image is pinned by digest. ```console $ msbctl ls --refresh # check upstream (cached for 6 hours) $ msbctl update . -c claude gh apt # in place ``` `update` records what it installed in the registry entry's `[versions]` table, so the next rebuild reproduces it rather than silently going back. Delete an entry there to return to the default. The **image** can't be updated in place: change its digest in `.msb/dev.yaml`, then rebuild. The upstream checks use the anonymous GitHub API (60 requests an hour). A `GH_TOKEN` in `secrets/global.env` is used for them if present. ## Renaming a sandbox, or moving its project ```console $ msbctl rename old-name new-name $ msbctl move . ~/src/the-new-place ``` msb can rename neither a sandbox nor a volume, so a **rename** removes the VM and creates it again under the new name, at the cost of a rebuild. Everything msbctl keeps under the name moves with it: the registry entry, its secrets, the Claude state and the version cache. Package caches and container images are kept too: the entry's `volumes` key keeps pointing at the existing disks, and that old name stays reserved until you rename back. A **move** points the sandbox at a different folder, for example after you moved the checkout on disk. The old folder may already be gone. If the new folder has no `.msb/`, msbctl offers to copy it over, re-detects the GitHub repo, and offers the rebuild the new mount needs. Both are also in `msbctl edit` → "Name & folder". ## Extra folders ```console $ msbctl mount . ~/reference # read-only at /mnt/reference $ msbctl mount . ~/datasets:/data --rw --rebuild # read-write, at /data, now ``` Extra host folders are read-only unless you ask, and read-write ones show your own ownership, like `/work`. Host paths are machine-local, so they're stored in the registry entry, never in the project. Like every mount, they're fixed at create time. ## Project setup and environment ```toml title=".msb/sandbox.toml" [bootstrap] packages = ["postgresql-client"] # extra apt packages script = "scripts/sandbox-setup.sh" # run last, inside the guest, from /work [env] SOME_PROJECT_FLAG = "1" # merged over the machine-wide [env] ``` The setup script runs once, at create time, after everything else, for things like `npm ci`. A failure is reported, not fatal. Environment variables are create-time, like mounts. ## Your network For hosts on your LAN (a Git server, a package mirror), define a group once in `config.toml`, or use `msbctl setup` → "your network". If those names resolve to private addresses, list the group under `private_dns_groups`, so projects that use it get `allow_private_dns = true` automatically. See [DNS resolution is part of the rule](egress.md#dns-resolution-is-part-of-the-rule). ## Your CLAUDE.md additions `~/.config/msb/CLAUDE.local.md` is appended to msb-manager's shipped instructions, and the result is copied into every sandbox's `~/.claude` on each start. Edit it from the main menu ("edit your CLAUDE.md additions") or directly. Editing the copy *inside* a sandbox does nothing: it's overwritten on the next start. ## Autostart Mark a sandbox to start whenever the picker opens: `msbctl edit .` → "Identity & autostart". An idle sandbox costs about 300 MB, and a running one is the only kind whose installed versions can be checked. --- # Troubleshooting > Refused connections, dropped API connections, NXDOMAIN on your LAN, and other failures that look like something else. Most failures here are disguised: they look like an outage or a bug, and are actually policy doing its job. Start with `msbctl show` for the sandbox, which lists every rule it emits. ## "connection refused" / a host is unreachable **It's a missing egress rule until proven otherwise.** Don't retry, and don't reach for a proxy or another mirror. Find the exact host and port that was refused, then: ```console $ msbctl allow . that.host.example # :443 $ msbctl allow . that.host.example:22 # another port $ msbctl rebuild . ``` If the failing URL is `http://`, use its `https://` form instead: plain HTTP is denied for every sandbox, and a project can't override that (see [first-match-wins](egress.md#rules-are-first-match-wins)). If you can't tell what's being refused, use [observe mode](advanced.md#observe-mode-building-an-allowlist). ## The bootstrap reports "REQUIRED, FAILED" Almost always a missing rule, not a broken installer. The bootstrap lists every failed step at once. Read the error above the summary for the host it couldn't reach. Before creating a sandbox, msbctl also warns about install hosts the rules don't cover; the usual missing groups are `npm` and `claude`. ## Claude Code: `API Error: Connection dropped (ECONNRESET)` A fresh session works, then every request fails, permanently. This is the **secret policy**, not the network: the agent has read a token placeholder (`$MSB_GH_TOKEN`) at some point, and now carries it in every request body, which msb kills. Sandboxes created by current msb-manager let the placeholder travel to the agent's own endpoints, so this shouldn't happen. If it does: ```console $ msbctl observe . # lists secret-policy blocks, even in enforce mode $ msbctl rebuild . # picks up the current bindings ``` Allowing the host changes nothing here, because it's not a network block. ## Claude Code: "Unable to connect to Anthropic services" The sandbox can't reach `platform.claude.com`, which Claude Code contacts on startup. Both hosts in the `claude` group are needed: check the project's `rule_groups`. ## Claude Code isn't logged in There's no `CLAUDE_CODE_OAUTH_TOKEN` in `~/.config/msb/secrets/global.env`. Run `msbctl setup` → "Claude login" (or `claude setup-token` on the host and paste the result), then restart the sandbox. ## A name on my LAN returns NXDOMAIN msb drops DNS answers that point at private addresses (DNS rebind protection), and the result looks exactly like a missing rule: NXDOMAIN, then a refused connection. Set `allow_private_dns = true` in the project's `.msb/sandbox.toml` and rebuild. See [DNS resolution is part of the rule](egress.md#dns-resolution-is-part-of-the-rule). ## `git commit`: "Please tell me who you are" The repo has no identity of its own, and there's no host-wide git config inside a microVM. Pick an identity: `msbctl edit .` → "Identity & autostart". It applies on the next start. ## Commits aren't signed The identity names a `signing_key`, but this sandbox isn't allowed that key. `msbctl show` says so under "Credentials". Allow it with `msbctl keys .`. ## `git push` over SSH fails - Check that the key is selected: `msbctl keys .`. - Check that it's loaded in your **host** agent (`ssh-add -l`). msbctl tries to load selected keys at start, and warns if it can't. - Sandboxes created before the filtered agent existed need one `msbctl rebuild`. - The host must already be in your `~/.ssh/known_hosts`: it's mounted read-only, so the sandbox can't add a new host. ## `/memory`, `git commit` or ctrl-g opens nothing No editor was set, and the image's `code` command has no VS Code to hand off to inside the VM. Sandboxes get `EDITOR=nano` by default. Older ones need a rebuild, or `export EDITOR=nano` in the shell for now. ## Memory use creeps up to the ceiling and stays there Guest page cache is never handed back on its own. It's harmless, and `msbctl reclaim` fixes it in about a second. ## The sandbox lost something after a rebuild A rebuild keeps your files, the Claude state, package caches and container images, and reinstalls whatever the bootstrap installs. Anything you installed by hand is gone. Put it in `[bootstrap] packages` or the project's setup `script` so it comes back every time. ## `msbctl` says "no /dev/kvm" `msbctl` runs on the host. It can't run inside a container, or inside another VM unless that VM has nested virtualization. --- # Commands > Every msbctl subcommand, its arguments and options. `msbctl --help` and `msbctl --help` are always current. This page explains them. ## Naming the sandbox - **`name`**: optional. Inside a registered project folder it defaults to that folder's sandbox. Elsewhere, it's required. - **`name…`**: optional and repeatable. None, or `.`, means the current folder's sandbox; several names act on each in turn, and every failure is reported, not just the first. - **`name|.`**: required, because another argument follows it. `.` is the shorthand for the current folder's sandbox. See [Everyday use](everyday.md#the-current-folder-names-the-sandbox) for the rules behind this. ## Registering and inspecting ### `msbctl add` ```text msbctl add [dir] ``` Register a project with the setup wizard. Nothing is started. A bare name resolves under your workspaces folder and is created if missing; a path starting with `/`, `~`, `./` or `../` is taken as written. Without an argument it asks, defaulting to the current directory. See [Get started](quickstart.md#2-register-a-project). ### `msbctl ls` ```text msbctl ls [--refresh] [--porcelain] [--picker] ``` One line per sandbox: name, state, project, pending updates, autostart. `--refresh` checks upstream versions first. `--porcelain` prints tab-separated fields for scripts; `--picker` is the format `msb-picker` reads. ### `msbctl status` ```text msbctl status [--quick] ``` One block per sandbox, as shown beside the menu: state, RAM and CPU, identity, image, folder. `--quick` skips reading figures from inside each guest. ### `msbctl show` ```text msbctl show [name] ``` Everything about one sandbox: resources, disks, extra folders, installed versions, environment, every egress rule in the order it's emitted, and the identity, keys and secrets bound to it. ### `msbctl config` ```text msbctl config ``` The merged configuration (`defaults.toml` plus your `config.toml`), with where each value comes from. ### `msbctl entry` ```text msbctl entry [name] ``` Open the sandbox's registry entry (`~/.config/msb/sandboxes/.toml`) in a terminal editor. ## Lifecycle ### `msbctl start` ```text msbctl start [name…] ``` Start a stopped sandbox, or create it if it doesn't exist yet. Creating runs the bootstrap, once. ### `msbctl stop` ```text msbctl stop [name…] ``` Stop it. Everything is kept. ### `msbctl rebuild` ```text msbctl rebuild [name…] ``` Destroy and recreate. Needed after changing anything fixed at create time: egress rules, mounts, the image, environment variables. Your files, the Claude state, package caches and container images survive; packages installed by hand don't. Doesn't ask first. ### `msbctl purge` ```text msbctl purge [name…] [-y] ``` Destroy the sandbox and deregister it: the VM, its Claude state, its disk volumes, its secrets file and its registry entry. Your project folder is never touched. Lists exactly what it will delete, and asks once per sandbox unless `-y` is given. Remember to revoke its GitHub token too. ### `msbctl rename` ```text msbctl rename name|. new-name [-y] ``` Give a sandbox a new name. msb can't rename a VM, so this recreates it, like a rebuild; settings, secrets, Claude state, caches and container images all move with it. See [Advanced usage](advanced.md#renaming-a-sandbox-or-moving-its-project). ### `msbctl move` ```text msbctl move name|. dir [--rebuild] ``` Point a sandbox at another project folder. `dir` is read as it is for `add`. Offers to copy `.msb/` across if the new folder lacks it, and re-detects the GitHub repo. The folder is mounted at create time, so this needs a rebuild: `--rebuild` does it now. ### `msbctl resize` ```text msbctl resize [name] [--cpus N] [--memory SIZE] [--root-disk SIZE] [-y] ``` Change resources and keep the sandbox's state. cpus and memory need a restart (asks first unless `-y` is given); the root disk grows live and can't shrink. With no options it asks. ### `msbctl reclaim` ```text msbctl reclaim [name…] ``` Give the guest's page cache back to the host. Harmless, and takes about a second. ## Working inside ### `msbctl shell` ```text msbctl shell [name] ``` An interactive login shell, with the SSH agent wired up, starting in the subfolder of `/work` that matches your current directory. Starts a stopped sandbox; never creates one. ### `msbctl exec` ```text msbctl exec name|. [--] command… msbctl exec -- command… ``` Run one command in the sandbox, like `shell` but non-interactive. `exec -- cmd` is the short form for the current folder's sandbox. ### `msbctl code` ```text msbctl code [name] ``` Open your editor on the project folder, on the host: `$MSB_EDITOR`, default `codium`. ## Changing a sandbox ### `msbctl edit` ```text msbctl edit [name] ``` The setup wizard as a menu of sections, run one at a time against an existing sandbox: name and folder, features and agents, resources, package caches, container storage, extra folders, egress rules, SSH keys, GitHub token, secrets, identity and autostart, or the files themselves in your editor. Applies what it can immediately and offers one rebuild at the end for the rest. ### `msbctl allow` ```text msbctl allow name|. target… [--rebuild] ``` Add egress rules. Each target is `host` (HTTPS, :443), `host:port` (TCP), or a full rule such as `allow@10.0.0.5:tcp:5432`. Written to the project's `extra_rules`. See [Adding a host](egress.md#adding-a-host). ### `msbctl observe` ```text msbctl observe name|. [report|on|off] [--rebuild] [-y] ``` `on` stops blocking and records every host the sandbox reaches; `report` (the default) reads that record back and prints the `msbctl allow` line for what no rule covers; `off` restores deny-by-default. Turning it on asks first unless `-y` is given. See [Observe mode](advanced.md#observe-mode-building-an-allowlist). ### `msbctl mount` ```text msbctl mount name|. SRC[:DEST][:ro|rw] [--rw] [--rebuild] ``` Add an extra host folder. `DEST` defaults to `/mnt/`; read-only unless `rw` or `--rw` is given. Needs a rebuild. ### `msbctl secret` ```text msbctl secret add name|. msbctl secret ls name|. msbctl secret rm name|. VAR ``` Tokens the sandbox can use but never see. `add` offers the presets or takes your own binding. See [Credentials & SSH](credentials.md#more-secrets). ### `msbctl keys` ```text msbctl keys [name] [--all] ``` Choose which keys from your SSH agent (and `~/.ssh`) the sandbox can list and sign with. Takes effect immediately. `--all` removes the restriction. ### `msbctl cache` ```text msbctl cache name|. [ls|clear] [key] ``` Show the package-cache disks, or empty one (or all of them, if no key is given). See [Package caches](advanced.md#package-caches). ### `msbctl update` ```text msbctl update [name…] [-c component…] ``` Update components in place, without a rebuild. Components: `claude` (the default), `gh`, `gitleaks`, `bun`, `apt`. Versions installed this way are recorded so a rebuild reproduces them. Run `msbctl ls --refresh` first for `gh`, `gitleaks` and `bun`. ## The machine and msb-manager itself ### `msbctl setup` ```text msbctl setup [--welcome] ``` Your machine-level settings as a menu: git identities, Claude login, your network, new-sandbox defaults, or the config file itself. `--welcome` runs the first-time walk-through. ### `msbctl self-update` ```text msbctl self-update [--version vX.Y.Z] ``` Upgrade a packaged install to the latest release, or a given one, verifying it the same way the installer does. In a git checkout it tells you to `git pull`. ## Environment variables | Variable | Effect | | --- | --- | | `MSB_CONFIG_DIR` | config directory (default `~/.config/msb`) | | `MSB_STATE_DIR` | state directory (default `~/.local/state/msb`) | | `MSB_EDITOR` | the editor `msbctl code` opens (default `codium`) | | `MSB_NO_AUTOSTART=1` | the picker doesn't start autostart sandboxes | | `MSB_MANAGER_SKIP_ATTEST=1` | installer and `self-update`: skip the provenance check | | `MSB_MANAGER_REPO`, `MSB_MANAGER_BASE_URL` | installer: another repository, or a mirror (`file://` works; needs `--version`) | --- # Configuration > Every key in config.toml, .msb/sandbox.toml and the registry entry, and where files live. Four TOML files, later ones winning. See [How it works](how-it-works.md#the-four-configuration-layers) for how they merge. The comments in the shipped `defaults.toml` and in the files the wizard writes are the long-form documentation of every key; this page is the map. ## Files and directories | Path | What | | --- | --- | | `/defaults.toml` | the shipped defaults. Never edit; an upgrade replaces it | | `~/.config/msb/config.toml` | your overrides | | `~/.config/msb/CLAUDE.local.md` | your additions to every sandbox's `CLAUDE.md` | | `~/.config/msb/sandboxes/.toml` | one registry entry per sandbox | | `~/.config/msb/secrets/global.env` | the Claude token, and an optional `GH_TOKEN` for version checks (0600) | | `~/.config/msb/secrets/.env` | that sandbox's tokens (0600) | | `/.msb/sandbox.toml` | the project's portable policy | | `/.msb/dev.yaml` | the file passed to `msb --conf`: image, cpus, memory | | `~/.local/state/msb/claude//` | that sandbox's `~/.claude` | | `~/.local/state/msb/profile/` | the assembled `CLAUDE.md`, mounted read-only | | `~/.local/state/msb/agent/` | the SSH-agent filters' sockets and logs | | `~/.local/state/msb/versions.json` | the cached version checks | `MSB_CONFIG_DIR` and `MSB_STATE_DIR` move the two roots. ## config.toml Keep it short: a value copied here from `defaults.toml` stops following the shipped one. Tables merge with the shipped ones key by key; anything else, including a whole egress group, replaces the shipped value. ### `[defaults]` | Key | Default | Meaning | | --- | --- | --- | | `workspaces` | `"~/workspaces"` | where a bare name given to `msbctl add` resolves. `""` resolves against the current directory | | `image` | a pinned devcontainers Ubuntu digest | the image a new sandbox starts from | | `cpus` | `4` | | | `memory` | `"8G"` | a ceiling, not a reservation | | `root_disk` | `"16G"` | the writable root disk | | `rule_groups` | `["github", "claude"]` | groups a new sandbox gets on top of `base` | | `egress_mode` | `"enforce"` | `"observe"` stops blocking (per sandbox is better: `msbctl observe`) | | `private_dns_groups` | `[]` | groups whose names resolve to private addresses; projects using one get `allow_private_dns` | | `claude_auth` | `"secret"` | `"mount"` shares the host's real `~/.claude` instead. A fallback only | | `identity` | `""` | the `[identities]` entry new sandboxes use | ### `[identities.]` ```toml [identities.public] name = "yourhandle" email = "12345+yourhandle@users.noreply.github.com" signing_key = "ssh-ed25519 AAAA..." # optional: sign commits with this key ``` Applied inside the guest with `git config --system`, the lowest of git's three layers, so a repo's own identity still wins. See [commit signing](credentials.md#commit-signing). ### `[rules]` Egress groups, `name = [rule, …]`. Yours are added to the shipped ones; a shipped name redefined here replaces that group entirely. See [Network & egress](egress.md). ### `[env]` Environment variables every sandbox gets. The shipped set turns off telemetry, error reporting and feedback prompts, and sets `EDITOR = "nano"`. Projects merge their own over these. Fixed at create time. ### `[versions]` Component pins for the bootstrap: `node` (major), `gh`, `gitleaks`, `bun`. A sandbox's own `[versions]` overrides these. ### `[version_check]` | Key | Default | Meaning | | --- | --- | --- | | `ttl_hours` | `6` | how long upstream version checks are cached | | `use_global_token` | `true` | use `GH_TOKEN` from `secrets/global.env` for them, if present | ### `[secret_presets.]` What `msbctl secret add` offers. Shipped: `npm`, `dockerhub`, `ghcr`, `pypi`, `crates`. ```toml [secret_presets.mytool] env = "MYTOOL_TOKEN" hosts = ["api.mytool.example"] help = "https://mytool.example/settings/tokens" usage = "mytool login --token \"$MYTOOL_TOKEN\"" ``` ## .msb/sandbox.toml The project's policy. It never contains a host path, a token or a sandbox name, so it's safe to commit. | Key | Meaning | | --- | --- | | `rule_groups` | egress groups, on top of `base` | | `extra_rules` | one-off rules, emitted last | | `allow_private_dns` | accept DNS answers that point at private addresses | | `secret_binds` | extra secrets, as `ENV[:OPTIONS]@HOST[,HOST]`; values live in `secrets/.env` | | `cpus`, `memory`, `root_disk` | resources; omit to use the defaults | | `containers_disk` | a disk for `/var/lib/containers`, kept across rebuilds | | `identity` | an `[identities]` name this project commits as | | `[env]` | extra environment variables, merged over the machine-wide set | | `[caches]` | `key = "size"` per package manager: `npm`, `bun`, `python`, `rust`, `go` | | `[secrets] github` | bind this project's `GH_TOKEN` (default: true when it has a GitHub repo) | ### `[bootstrap]` | Key | Meaning | | --- | --- | | `packages` | extra apt packages | | `claude` | install Claude Code (default true) | | `bun`, `gitleaks` | install these (default false) | | `containers_storage` | `"fuse-overlayfs"` for podman without a container disk | | `script` | a project script run last, inside the guest, from `/work` | ## The registry entry `~/.config/msb/sandboxes/.toml` is machine-local and never committed. Any key it sets overrides the project's file. | Key | Meaning | | --- | --- | | `name`, `project` | the sandbox's name and the absolute path of its project folder | | `repo` | `owner/name` on GitHub, for the token binding and the picker | | `identity` | an `[identities]` name; applied at every start | | `autostart` | started when the picker opens | | `ssh_keys` | key fingerprints this sandbox may use. Absent means all, `[]` means none | | `mounts` | extra folders, as `"HOST[:GUEST][:ro\|rw]"` | | `egress_mode` | `"observe"` while measuring; only ever set here, never in the project | | `volumes` | the disk-volume prefix, set by `msbctl rename` to keep the old disks | | `image`, `cpus`, `memory`, `root_disk`, `conf`, `rule_groups`, `extra_rules` | per-machine overrides | | `[versions]` | what `msbctl update` installed, so a rebuild reproduces it | --- # Contributing > Working on msb-manager: the checks, the rules, the spikes, the docs and releases. Issues and PRs are welcome, especially from anyone on a different distro or a newer `msb`. Read [`CLAUDE.md`](https://github.com/runoverlabs/sandbox-manager/blob/main/CLAUDE.md) in the repo first: despite the name, it's the design guide for humans and agents alike, and it records the failure modes that aren't obvious. ## Getting set up ```console $ git clone https://github.com/runoverlabs/sandbox-manager msb-manager $ cd msb-manager $ ./install.sh # dev mode: the commands link into this checkout ``` ## Before you open a PR ```console $ scripts/check.sh # exactly what CI runs $ scripts/smoke-install.sh # package, then install into a throwaway HOME ``` `check.sh` covers Python syntax, `bash -n` and shellcheck, TOML parsing, the release payload lists agreeing, tracked file modes, gitleaks, and a docs build that fails on a broken link or an undocumented command. The rules: - **No dependencies.** Python 3.11+ standard library and the `msb` binary, nothing else. That includes this documentation site, which `docs/build.py` builds with the standard library alone. - **Every behaviour change gets a one-line `CHANGELOG.md` entry** under `## [Unreleased]`, saying what changed and what a user must do about it (for example "needs a rebuild"). Refactors and CI-only changes need none. - **Comments are the documentation.** Preserve and extend them when behaviour changes. `msbctl --help`, `defaults.toml` and these pages are what users read; update the relevant page in `docs/content/` with the code. - **Egress rules come from an observed denial**, not a guess. The *egress denial* issue form asks for the host, the port and what failed. - **File modes follow one rule:** a file with a shebang is 755, everything else 644, except `bootstrap.sh` (piped into the guest, never executed). `check.sh` reads the git index, so `git add` after a `chmod`. ## Testing against a real msb `msbctl` needs `/dev/kvm` and `msb`. Use a throwaway `MSB_CONFIG_DIR` and `MSB_STATE_DIR`, and `msb rm -f` whatever you create. Without KVM, `scripts/check.sh` is what you can run, plus exercising functions against a stub `msb` on `PATH`. ## The spikes `spikes/` holds one re-runnable script per mechanism the design depends on: the agent bridge, mount ownership, per-port rules, persistence, hostname rules, memory reclaim, both token placeholders, profile seeding and DNS rebind protection. ```console $ ./spikes/07-claude-token-secret.sh ``` Each prints `PASS` or `FAIL` with a reason, and asserts **both halves** of its claim: that the allowed thing works *and* that the denied thing is still denied. A rule set that permits the one service you need is worthless if it also permits the admin page next to it, and only the negative half catches that. Several need addresses on your own network set first, and have no defaults on purpose: a wrong guess makes a spike pass for the wrong reason. ## The docs These pages are Markdown in `docs/content/`, built by `docs/build.py`: ```console $ python3 docs/build.py --check # build into docs/_site and check links $ xdg-open docs/_site/index.html # works straight off the disk ``` Each page starts with `title`, `section` (Start here, Guides, Reference or Project), `order` and a one-sentence `description`, which `--check` requires: it becomes the page's meta description, its link preview and its line in `llms.txt`. The converter supports a deliberate subset of Markdown: headings, paragraphs, lists, fenced code (`console` blocks get prompts, and their copy button copies only the commands), tables, `> **Note:**` / `**Tip:**` / `**Warning:**` callouts, and inline code, emphasis, links and images. A new `msbctl` subcommand needs a `### msbctl ` section on the [Commands](commands.md) page, or `--check` fails. The same build writes what search engines and agents look for: canonical links, Open Graph and JSON-LD tags, `sitemap.xml`, `llms.txt`, `llms-full.txt` and a Markdown copy of every page. None of it is hand-maintained. The social preview image is `docs/assets/social-card.png`. The site deploys to GitHub Pages from `main`. ## Workflows They run on `pull_request`, never `pull_request_target`, with read-only default permissions. Every action is pinned by commit hash with its version in a comment; Dependabot moves the hash and the comment together, so don't unpin one to "fix" something. `zizmor` lints them in CI. ## Releases Maintainers only, and tag-driven: move the `CHANGELOG.md` **Unreleased** entries under `## [X.Y.Z] - YYYY-MM-DD`, set `VERSION`, merge, then: ```console $ git tag vX.Y.Z && git push origin vX.Y.Z ``` The release workflow refuses if the tag, `VERSION` and the changelog disagree. It builds the assets, attests them and publishes them. Nothing is published by hand. To build the same assets locally without publishing: ```console $ scripts/package.sh --version 0.2.0 --repo runoverlabs/sandbox-manager # writes ./dist ``` ## Security A way around the egress rules, a credential readable from inside the guest, or a leak through the agent filter is a vulnerability. Report it privately, as described in [SECURITY.md](https://github.com/runoverlabs/sandbox-manager/blob/main/SECURITY.md), not in a public issue. --- # Changelog > Every user-visible change to msb-manager, by release. All notable changes, one line each, newest first. Format follows [Keep a Changelog](https://keepachangelog.com/); versions follow `VERSION`. Add entries under **Unreleased**; the release process turns that heading into the version being cut, and the release workflow publishes that section as the release notes. ## [Unreleased] ### Changed - The project moved to https://github.com/runoverlabs/sandbox-manager and its documentation to https://sandbox-manager.runoverlabs.dev/; old GitHub links and existing installs keep working through GitHub's redirects, but the old github.io docs address does not. ## [0.2.0] - 2026-10-07 ### Added - Documentation site at https://naerymdan.github.io/sandbox-manager/: install, getting started, guides, command and configuration reference, with search and light/dark themes; agents can read it through `llms.txt`, `llms-full.txt` or the Markdown copy beside each page. - Sandboxes get `EDITOR=nano` (and nano itself), so Claude Code's `/memory`, `git commit` and friends open an editor instead of silently doing nothing; override it under `[env]`. Existing sandboxes need a rebuild. - Inside a project folder msbctl knows which sandbox you mean: leave the name out where it is the only argument (`msbctl shell`, `msbctl stop`), use `.` where more follows (`msbctl exec . make`, `msbctl allow . example.com`), or `msbctl exec -- cmd`; `shell` and `exec` start in the matching subfolder of `/work`. - `msbctl rename ` and `msbctl move ` (also `edit` → Name & folder) rename a sandbox or point it at another project folder; a rename recreates the VM and keeps settings, secrets, Claude state, caches and container images, a move needs a rebuild. - A git identity can name an ssh key to sign commits with (`setup` → Git identities); sandboxes using it sign through the filtered agent (and can verify their own signatures) while the key stays ticked in `msbctl keys`, which flags it and warns before you drop it. Existing sandboxes need a rebuild for `openssh-client` if `ssh-keygen` is missing. ## [0.1.1] - 2026-10-06 First release. msb-manager runs coding agents against real repositories in [microsandbox](https://github.com/microsandbox/microsandbox) microVMs, without giving them your network or your tokens. ### Isolation - **Deny-by-default egress**, per host and per port. Named rule groups (`github`, `npm`, `python`, `go`, `claude`, `bun`, …) compose per project, and plain HTTP is refused outright. - **Tokens never enter the VM.** `--secret` bindings put an opaque placeholder in the guest and substitute the real value host-side, into request headers only, for the hosts you name. `GH_TOKEN` and Claude's own credential work this way, and `msbctl secret` adds more. - **A filtered SSH agent** per sandbox, forwarded over vsock: only identity-list and sign requests, limited to the keys you pick (`msbctl keys`). The private key never crosses. - **A central `CLAUDE.md`** (shipped text plus your own additions) copied into every sandbox on each start from a read-only mount; each sandbox gets its own `~/.claude`. ### Managing sandboxes - **One CLI and an fzf picker** (with a desktop entry) to register, start, stop, rebuild, purge, resize, shell into and run commands in sandboxes: `msbctl add`, `start`, `stop`, `rebuild`, `purge`, `shell`, `exec`, `resize`, `reclaim`, `ls`, `status`, `show`. - **A setup wizard** that asks for features (bun, python, podman, gitleaks, …) rather than raw rule groups, preselects them from your repo's languages, and sizes the disks and package caches; `msbctl edit` and `msbctl setup` revisit any section later, and a first-run walk-through covers a new machine. - **Per-project config that travels with the repo** (`.msb/sandbox.toml`, egress groups, packages, limits), with machine-local paths and tokens kept in `~/.config/msb/` and never committed. `msbctl config` shows the merged result and where each value came from. - **Extra folders, package-cache disks and secrets** added after the fact (`msbctl mount`, `cache`, `secret`), and `msbctl allow` for one more egress rule. - **Egress observe mode** (`msbctl observe on`): when an allowlist is too tight to work in, stop blocking, record every host reached, then read it back with the exact `msbctl allow` lines for the hosts no rule covers. It leaves that sandbox's egress unrestricted until you switch it off. - **Secret placeholders pass through to agent hosts**, so a coding agent that has read `$MSB_GH_TOKEN` no longer breaks its own API calls. - **In-place updates** of claude, gh, gitleaks, bun and apt (`msbctl update`), with the versions recorded so a rebuild reproduces them, and a version display in the picker. ### Install and supply chain - **`curl | sh` installer** (`get.sh`) and `msbctl self-update`, with sha256 verification of the release tarball. - **Signed build-provenance attestations** on every release tarball and `get.sh`, verified by `get.sh` and `self-update` when an authenticated `gh` is installed (`MSB_MANAGER_SKIP_ATTEST=1` skips it; otherwise only the checksum is checked). See the README's "Verifying a release". - **Verified toolchain installs inside the guest**: bun and gh are pinned release assets checked against their published checksums, and node comes from NodeSource's apt repository with a pinned signing-key fingerprint, instead of `curl | bash`. - **CI, CodeQL, zizmor and OpenSSF Scorecard**, a tag-driven release workflow, Dependabot, issue forms, a security policy and a contributing guide. ### Upgrading from a checkout - Rule groups changed: the Claude hosts now come only from the `claude` group, `bun` allows `github.com` instead of `bun.sh`, and `github` allows the Actions log host, Sigstore's trust root and GitHub's attestation storage (so `gh attestation verify` works from a sandbox). Existing sandboxes pick these up, along with the secret pass-through and the new installers, at their next `msbctl rebuild`.