{
    "type": "article",
    "title": "Putting the sandbox behind an interface",
    "canonical": "https://www.dealmobile.co.uk/writing/putting-the-sandbox-behind-an-interface",
    "path": "/writing/putting-the-sandbox-behind-an-interface",
    "url": "https://www.dealmobile.co.uk/writing/putting-the-sandbox-behind-an-interface",
    "markdown": "https://www.dealmobile.co.uk/writing/putting-the-sandbox-behind-an-interface.md",
    "json": "https://www.dealmobile.co.uk/writing/putting-the-sandbox-behind-an-interface.json",
    "summary": "Mullet’s bubblewrap sandbox now sits behind a backend interface, with contract tests every backend must pass. Before the change went in, 15,552 command lines from the old code and the new were compared, and none differed.",
    "published": "2026-10-26T06:50:00+00:00",
    "modified": "2026-10-26T08:12:46+00:00",
    "image": {
        "url": "https://www.dealmobile.co.uk/sites/default/files/content/images/posts/putting-the-sandbox-behind-an-interface/hero.png",
        "alt": "Same sandbox, new interface. A table headed check and result. Argument lists compared, old code against new: 15,552. Differences: 0. Below it, two lines: shared tests that every backend must pass, and when no backend works, the work is refused and never runs unsandboxed."
    },
    "fields": {
        "image_dark": null
    },
    "tags": [
        {
            "name": "Python",
            "url": "https://www.dealmobile.co.uk/writing/tag/python",
            "json": "https://www.dealmobile.co.uk/writing/tag/python.json"
        },
        {
            "name": "AI",
            "url": "https://www.dealmobile.co.uk/writing/tag/ai",
            "json": "https://www.dealmobile.co.uk/writing/tag/ai.json"
        },
        {
            "name": "Security",
            "url": "https://www.dealmobile.co.uk/writing/tag/security",
            "json": "https://www.dealmobile.co.uk/writing/tag/security.json"
        },
        {
            "name": "Mullet",
            "url": "https://www.dealmobile.co.uk/writing/tag/mullet",
            "json": "https://www.dealmobile.co.uk/writing/tag/mullet.json"
        }
    ],
    "projects": [
        {
            "name": "Mullet",
            "url": "https://www.dealmobile.co.uk/projects/mullet",
            "json": "https://www.dealmobile.co.uk/projects/mullet.json"
        }
    ],
    "body": {
        "html": "<p>Every command a Mullet job runs on my machine runs in a sandbox: the repo’s setup, its checks, the gates and the opencode worker. Until 2.3.0 that sandbox was bubblewrap, written straight into the module that called it. <a href=\"/writing/sandboxing-an-ai-coding-worker\">Sandboxing an AI coding worker</a> describes what it does. This post is about moving it behind an interface without changing any of that, so a second backend could be added safely.</p>\n<h2 id=\"why-bother\">Why bother</h2>\n<p>Bubblewrap needs working user namespaces. Some Linux machines don’t have them, and macOS has no bubblewrap at all. I wanted a container backend for the first case and room for a macOS one later. Either way, a new backend has to keep exactly the guarantees bubblewrap gives, and the code that calls the sandbox shouldn’t need to know which one it is talking to.</p>\n<h2 id=\"the-interface\">The interface</h2>\n<p><code>mullet/jobs/sandbox_backends/</code> now holds a <code>SandboxBackend</code> protocol: a <code>name</code> and seven methods, <code>check</code>, <code>build_argv</code>, <code>spawn</code>, <code>run</code>, <code>run_shell</code>, <code>nested_argv</code> and <code>nested_check</code>. <code>BubblewrapBackend</code> was the first implementation. The old module, <code>mullet.jobs.sandbox</code>, keeps every name its callers use, picks one backend per process and hands each call to it. Gates and executors need no change to run under a new backend.</p>\n<p>The backend is a setting:</p>\n<pre><code class=\"language-yaml\">sandbox:\n  backend: bubblewrap   # bubblewrap (default), docker, podman or auto\n</code></pre>\n<p><code>auto</code> takes the first backend that works on the machine. <code>seatbelt</code> is a reserved name: choosing it refuses all work, with a message that says so.</p>\n<h2 id=\"a-refactor-checked-by-difference\">A refactor checked by difference</h2>\n<p>A sandbox is a bad place for a subtle change. If a mount moves or a flag goes missing, no test has to fail. The walls are just lower. Ordinary tests check what I thought to check, and a refactor is exactly when I’m not thinking about the parts I didn’t touch.</p>\n<p>So before the change went in, I compared the command lines the old code and the new code built for the same inputs: 15,552 of them, with zero differences. That is what lets me say the refactor changed nothing for anyone already using bubblewrap.</p>\n<p>The comparison itself isn’t in the repo. What stays is a test, <code>test_bubblewrap_argv_is_exactly_the_documented_line</code>, that pins the exact bubblewrap command line for a known spec, with network and without. Another checks that the old module no longer offers <code>build_argv</code> at all, so nothing outside a backend can build a sandbox command line its own way.</p>\n<h2 id=\"the-contract\">The contract</h2>\n<p>Before, the guarantees were only described, in the sandbox module’s docstring. Now they are tests too. Each <code>test_contract_*</code> test in <code>tests/test_sandbox_backends.py</code> takes a <code>backend</code> fixture, runs real commands under that backend and checks what happened:</p>\n<table>\n<thead>\n<tr>\n<th align=\"left\">Contract test</th>\n<th align=\"left\">What it holds the backend to</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td align=\"left\">writes land only in writable paths</td>\n<td align=\"left\">nothing writable outside the clone and the paths the job names</td>\n</tr>\n<tr>\n<td align=\"left\">root and exposed paths are read-only</td>\n<td align=\"left\">the host’s files can’t be changed</td>\n</tr>\n<tr>\n<td align=\"left\">no network</td>\n<td align=\"left\">a worker, check or gate reaches nothing</td>\n</tr>\n<tr>\n<td align=\"left\">environment is an allowlist</td>\n<td align=\"left\">only the variables Mullet chose get in</td>\n</tr>\n<tr>\n<td align=\"left\">process environments hold no parent secret</td>\n<td align=\"left\">Mullet’s own keys never appear inside</td>\n</tr>\n<tr>\n<td align=\"left\">timeout kills the whole tree</td>\n<td align=\"left\">no process the command started outlives it</td>\n</tr>\n<tr>\n<td align=\"left\">the key proxy socket is reachable</td>\n<td align=\"left\">the opencode worker can still reach its provider through the proxy</td>\n</tr>\n<tr>\n<td align=\"left\">spawn confines a long-lived worker</td>\n<td align=\"left\">a worker gets the same walls as a check</td>\n</tr>\n</tbody>\n</table>\n<p>A few more check that <code>build_argv</code> never returns the bare command, and that a refused spec leaves no log behind.</p>\n<p>A backend joins the suite by adding its name to <code>CONTRACT_BACKENDS</code>. On a machine where it can’t run, its contract tests skip, unless the <code>MULLET_REQUIRE_SANDBOX</code> environment variable names it, and then a skip is a failure. CI sets that for Docker, so the container backend can’t quietly stop being tested.</p>\n<h2 id=\"the-rule-i-kept\">The rule I kept</h2>\n<p>There is no fallback to running unsandboxed. An unknown, reserved or broken backend makes <code>check()</code> fail, and every entry point then raises <code>SandboxUnavailable</code>. <code>auto</code> raises when no backend works. Nothing runs instead. A value Mullet doesn’t recognise in the config is reported by <code>mullet config show</code> and <code>mullet doctor</code>, and bubblewrap is kept, which is still a sandbox. <code>mullet doctor</code> also says which backend is active and why.</p>\n<p>That rule shaped the interface more than anything else. <code>nested_argv</code> starts a second sandbox inside the first, one for each of the opencode worker’s shell commands, with no network at all. A backend either does that or raises; there is no best-effort version. It mattered as soon as the container backend arrived, because a container can’t nest another sandbox without loosening the engine’s default security profiles, so opencode is refused under it. <a href=\"/writing/a-container-backend-stricter-than-the-usual-docker-recipe\">A container backend stricter than the usual Docker recipe</a> explains why.</p>\n<p>The design, with what each caller needs from a sandbox, is decision record 0007 in the repo’s <code>docs/adr/</code> (<a href=\"/writing/decision-records-for-a-solo-project\">decision records for a solo project</a> says why there is such a folder).</p>\n<h2 id=\"what-id-do-again\">What I’d do again</h2>\n<p>Check a refactor by difference, not only by tests. Have the old code and the new produce their outputs for the same inputs and compare every one. It catches the change you didn’t know to test for, and “zero differences” is a much stronger statement than “the tests pass”.</p>\n<p>And write the guarantees as tests that take the implementation as a parameter. The container backend was written against those tests, and it is only allowed to run jobs because it passes them. The same will go for macOS: until a backend passes the contract there, macOS jobs stay refused.</p>\n<p>This is in Mullet 2.3.0. The code is at <a href=\"https://github.com/tomgould/mullet-public\">https://github.com/tomgould/mullet-public</a>.</p>"
    },
    "markdown_text": "---\ntitle: 'Putting the sandbox behind an interface'\ncanonical: 'https://www.dealmobile.co.uk/writing/putting-the-sandbox-behind-an-interface'\nurl: 'https://www.dealmobile.co.uk/writing/putting-the-sandbox-behind-an-interface'\ntype: Post\npublished: '2026-10-26'\nsummary: 'Mullet’s bubblewrap sandbox now sits behind a backend interface, with contract tests every backend must pass. Before the change went in, 15,552 command lines from the old code and the new were compared, and none differed.'\n---\n\n# Putting the sandbox behind an interface\n\nPublished 2026-10-26\n\n![Same sandbox, new interface. A table headed check and result. Argument lists compared, old code against new: 15,552. Differences: 0. Below it, two lines: shared tests that every backend must pass, and when no backend works, the work is refused and never runs unsandboxed.](https://www.dealmobile.co.uk/sites/default/files/styles/almanac_shot_md/public/content/images/posts/putting-the-sandbox-behind-an-interface/hero.png.webp?itok=oOJkva5E)\n\nEvery command a Mullet job runs on my machine runs in a sandbox: the repo’s setup, its checks, the gates and the opencode worker. Until 2.3.0 that sandbox was bubblewrap, written straight into the module that called it. [Sandboxing an AI coding worker](https://www.dealmobile.co.uk/writing/sandboxing-an-ai-coding-worker.md) describes what it does. This post is about moving it behind an interface without changing any of that, so a second backend could be added safely.\n\n## Why bother\n\nBubblewrap needs working user namespaces. Some Linux machines don’t have them, and macOS has no bubblewrap at all. I wanted a container backend for the first case and room for a macOS one later. Either way, a new backend has to keep exactly the guarantees bubblewrap gives, and the code that calls the sandbox shouldn’t need to know which one it is talking to.\n\n## The interface\n\n`mullet/jobs/sandbox_backends/` now holds a `SandboxBackend` protocol: a `name` and seven methods, `check`, `build_argv`, `spawn`, `run`, `run_shell`, `nested_argv` and `nested_check`. `BubblewrapBackend` was the first implementation. The old module, `mullet.jobs.sandbox`, keeps every name its callers use, picks one backend per process and hands each call to it. Gates and executors need no change to run under a new backend.\n\nThe backend is a setting:\n\n```yaml\nsandbox:\n  backend: bubblewrap   # bubblewrap (default), docker, podman or auto\n\n```\n\n`auto` takes the first backend that works on the machine. `seatbelt` is a reserved name: choosing it refuses all work, with a message that says so.\n\n## A refactor checked by difference\n\nA sandbox is a bad place for a subtle change. If a mount moves or a flag goes missing, no test has to fail. The walls are just lower. Ordinary tests check what I thought to check, and a refactor is exactly when I’m not thinking about the parts I didn’t touch.\n\nSo before the change went in, I compared the command lines the old code and the new code built for the same inputs: 15,552 of them, with zero differences. That is what lets me say the refactor changed nothing for anyone already using bubblewrap.\n\nThe comparison itself isn’t in the repo. What stays is a test, `test_bubblewrap_argv_is_exactly_the_documented_line`, that pins the exact bubblewrap command line for a known spec, with network and without. Another checks that the old module no longer offers `build_argv` at all, so nothing outside a backend can build a sandbox command line its own way.\n\n## The contract\n\nBefore, the guarantees were only described, in the sandbox module’s docstring. Now they are tests too. Each `test_contract_*` test in `tests/test_sandbox_backends.py` takes a `backend` fixture, runs real commands under that backend and checks what happened:\n\n| Contract test | What it holds the backend to |\n|:--|:--|\n| writes land only in writable paths | nothing writable outside the clone and the paths the job names |\n| root and exposed paths are read-only | the host’s files can’t be changed |\n| no network | a worker, check or gate reaches nothing |\n| environment is an allowlist | only the variables Mullet chose get in |\n| process environments hold no parent secret | Mullet’s own keys never appear inside |\n| timeout kills the whole tree | no process the command started outlives it |\n| the key proxy socket is reachable | the opencode worker can still reach its provider through the proxy |\n| spawn confines a long-lived worker | a worker gets the same walls as a check |\n\nA few more check that `build_argv` never returns the bare command, and that a refused spec leaves no log behind.\n\nA backend joins the suite by adding its name to `CONTRACT_BACKENDS`. On a machine where it can’t run, its contract tests skip, unless the `MULLET_REQUIRE_SANDBOX` environment variable names it, and then a skip is a failure. CI sets that for Docker, so the container backend can’t quietly stop being tested.\n\n## The rule I kept\n\nThere is no fallback to running unsandboxed. An unknown, reserved or broken backend makes `check()` fail, and every entry point then raises `SandboxUnavailable`. `auto` raises when no backend works. Nothing runs instead. A value Mullet doesn’t recognise in the config is reported by `mullet config show` and `mullet doctor`, and bubblewrap is kept, which is still a sandbox. `mullet doctor` also says which backend is active and why.\n\nThat rule shaped the interface more than anything else. `nested_argv` starts a second sandbox inside the first, one for each of the opencode worker’s shell commands, with no network at all. A backend either does that or raises; there is no best-effort version. It mattered as soon as the container backend arrived, because a container can’t nest another sandbox without loosening the engine’s default security profiles, so opencode is refused under it. [A container backend stricter than the usual Docker recipe](https://www.dealmobile.co.uk/writing/a-container-backend-stricter-than-the-usual-docker-recipe.md) explains why.\n\nThe design, with what each caller needs from a sandbox, is decision record 0007 in the repo’s `docs/adr/` ([decision records for a solo project](https://www.dealmobile.co.uk/writing/decision-records-for-a-solo-project.md) says why there is such a folder).\n\n## What I’d do again\n\nCheck a refactor by difference, not only by tests. Have the old code and the new produce their outputs for the same inputs and compare every one. It catches the change you didn’t know to test for, and “zero differences” is a much stronger statement than “the tests pass”.\n\nAnd write the guarantees as tests that take the implementation as a parameter. The container backend was written against those tests, and it is only allowed to run jobs because it passes them. The same will go for macOS: until a backend passes the contract there, macOS jobs stay refused.\n\nThis is in Mullet 2.3.0. The code is at <https://github.com/tomgould/mullet-public>.\n\n- **Projects:** [Mullet](https://www.dealmobile.co.uk/projects/mullet.md)\n- **Tags:** [Python](https://www.dealmobile.co.uk/writing/tag/python.md), [AI](https://www.dealmobile.co.uk/writing/tag/ai.md), [Security](https://www.dealmobile.co.uk/writing/tag/security.md), [Mullet](https://www.dealmobile.co.uk/writing/tag/mullet.md)\n\n---\n\nIndex of this site: [llms.txt](https://www.dealmobile.co.uk/llms.txt)\n"
}