{
    "type": "article",
    "title": "Safe by default: a local AI server and a cheap model with a shell",
    "canonical": "https://www.dealmobile.co.uk/writing/safe-by-default-a-local-ai-server-and-a-cheap-model-with-a-shell",
    "path": "/writing/safe-by-default-a-local-ai-server-and-a-cheap-model-with-a-shell",
    "url": "https://www.dealmobile.co.uk/writing/safe-by-default-a-local-ai-server-and-a-cheap-model-with-a-shell",
    "markdown": "https://www.dealmobile.co.uk/writing/safe-by-default-a-local-ai-server-and-a-cheap-model-with-a-shell.md",
    "json": "https://www.dealmobile.co.uk/writing/safe-by-default-a-local-ai-server-and-a-cheap-model-with-a-shell.json",
    "summary": "A hardening pass on Mullet: a local server that a web page could not be stopped from calling, tools that fetched any address, and cheap models with more tools than they needed. What each guard checks and where to change it.",
    "published": "2026-10-25T17:30:00+00:00",
    "modified": "2026-10-25T19:08:26+00:00",
    "image": {
        "url": "https://www.dealmobile.co.uk/sites/default/files/content/images/posts/safe-by-default-a-local-ai-server-and-a-cheap-model-with-a-shell/hero.png",
        "alt": "A list of what Mullet now guards by default. The local server: Host, Origin and a token. Fetched addresses: public ones only. Cheap models: web and browser tools. Files: no SSH keys or env files. Job changes: editor and shell files are protected."
    },
    "fields": {
        "image_light": 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>Before releasing Mullet 2.1.0 I spent a day on what it trusts by default. Most of it comes down to the same question: if something hostile reaches this, what can it do? Here are the answers I didn’t like, and what changed.</p>\n<h2 id=\"a-web-page-can-call-your-local-server\">A web page can call your local server</h2>\n<p><code>mullet serve</code> runs on your machine. Any page open in your browser can send a request to a server on <code>localhost</code>, whoever wrote the page. CORS headers don’t help: they decide whether the page may read the reply, not whether the request is sent, and a request that does something has already done it.</p>\n<p>So 2.1.0 added three checks. The server refuses a foreign <code>Host</code> or <code>Origin</code>, and for a single user it refuses any request without the install’s token. The token is a file in the config directory, readable only by you, and <code>mullet serve --show-token</code> prints it. <code>server.require_token</code>, <code>server.allowed_origins</code> and <code>server.allowed_hosts</code> change what the server accepts. A second fix closed a gap in the same place: an empty WebSocket token no longer becomes the single user outside single-user mode.</p>\n<h2 id=\"tools-that-fetch-any-address\">Tools that fetch any address</h2>\n<p>Mullet’s tools fetch web pages, and a page can send a model to <code>http://localhost</code> or an internal address. Every URL a tool fetches is now resolved first and must be a public address, again on every redirect, and a failed lookup refuses the URL. That also shuts the tricks that dodge a simple check: a name like <code>localtest.me</code> that points at your own machine, and link-local and carrier-grade ranges.</p>\n<h2 id=\"a-cheap-model-doesnt-need-a-shell\">A cheap model doesn’t need a shell</h2>\n<p>This is the one I care most about. The cheaper models on the <a href=\"/writing/the-delegation-ladder\">delegation ladder</a> read web pages, and a model that has read a hostile page should not also have a terminal. Models from providers other than Claude’s own now get web, browser, session and vision tools by default and nothing else. You give a provider more, deliberately:</p>\n<pre><code class=\"language-yaml\">delegation:\n  toolsets:\n    kimi: [web, browser, terminal, file]\n</code></pre>\n<p>The file and docker tools also refuse locations that hold credentials: your SSH keys, cloud and token files, Mullet’s own state and any <code>.env</code> file. They follow symlinks to decide, and refuse a docker mount that holds or contains one. Docker also refuses the host’s IPC, user and UTS namespaces and <code>--device</code>.</p>\n<h2 id=\"a-job-cant-rewrite-your-tooling\">A job can’t rewrite your tooling</h2>\n<p>A job’s changes are checked against protected paths, and that list now covers files that run code without being asked: <code>.envrc</code>, <code>.vscode/**</code>, <code>.devcontainer/**</code>, <code>.npmrc</code>, <code>AGENTS.md</code> and other editor, dev container, shell hook and package manager files. A step that touches one fails its scope gate, the same as a step that edits a file it was told to leave alone.</p>\n<h2 id=\"the-key-proxy\">The key proxy</h2>\n<p>Workers never see an API key. They talk to a proxy on a local socket that adds it, pinned to one model. The proxy now forwards an allowlist of headers instead of everything it was sent, clamps the <code>n</code> parameter, and can clamp output tokens.</p>\n<h2 id=\"what-i-left-alone\">What I left alone</h2>\n<p>Setup commands, such as installing dependencies, still run on the host’s network, because package managers need it. They can reach anything the host can, so they are documented as the one place the sandbox’s network rule doesn’t apply. Jobs still refuse to start on macOS, because I have no way to test a sandbox there.</p>\n<p>Everything above is a setting, and <code>mullet config show</code> lists each one and where its value came from. The code is at <a href=\"https://github.com/tomgould/mullet-public\">https://github.com/tomgould/mullet-public</a>.</p>"
    },
    "markdown_text": "---\ntitle: 'Safe by default: a local AI server and a cheap model with a shell'\ncanonical: 'https://www.dealmobile.co.uk/writing/safe-by-default-a-local-ai-server-and-a-cheap-model-with-a-shell'\nurl: 'https://www.dealmobile.co.uk/writing/safe-by-default-a-local-ai-server-and-a-cheap-model-with-a-shell'\ntype: Post\npublished: '2026-10-25'\nsummary: 'A hardening pass on Mullet: a local server that a web page could not be stopped from calling, tools that fetched any address, and cheap models with more tools than they needed. What each guard checks and where to change it.'\n---\n\n# Safe by default: a local AI server and a cheap model with a shell\n\nPublished 2026-10-25\n\n![A list of what Mullet now guards by default. The local server: Host, Origin and a token. Fetched addresses: public ones only. Cheap models: web and browser tools. Files: no SSH keys or env files. Job changes: editor and shell files are protected.](https://www.dealmobile.co.uk/sites/default/files/styles/almanac_shot_md/public/content/images/posts/safe-by-default-a-local-ai-server-and-a-cheap-model-with-a-shell/hero.png.webp?itok=4RwSKGyF)\n\nBefore releasing Mullet 2.1.0 I spent a day on what it trusts by default. Most of it comes down to the same question: if something hostile reaches this, what can it do? Here are the answers I didn’t like, and what changed.\n\n## A web page can call your local server\n\n`mullet serve` runs on your machine. Any page open in your browser can send a request to a server on `localhost`, whoever wrote the page. CORS headers don’t help: they decide whether the page may read the reply, not whether the request is sent, and a request that does something has already done it.\n\nSo 2.1.0 added three checks. The server refuses a foreign `Host` or `Origin`, and for a single user it refuses any request without the install’s token. The token is a file in the config directory, readable only by you, and `mullet serve --show-token` prints it. `server.require_token`, `server.allowed_origins` and `server.allowed_hosts` change what the server accepts. A second fix closed a gap in the same place: an empty WebSocket token no longer becomes the single user outside single-user mode.\n\n## Tools that fetch any address\n\nMullet’s tools fetch web pages, and a page can send a model to `http://localhost` or an internal address. Every URL a tool fetches is now resolved first and must be a public address, again on every redirect, and a failed lookup refuses the URL. That also shuts the tricks that dodge a simple check: a name like `localtest.me` that points at your own machine, and link-local and carrier-grade ranges.\n\n## A cheap model doesn’t need a shell\n\nThis is the one I care most about. The cheaper models on the [delegation ladder](https://www.dealmobile.co.uk/writing/the-delegation-ladder.md) read web pages, and a model that has read a hostile page should not also have a terminal. Models from providers other than Claude’s own now get web, browser, session and vision tools by default and nothing else. You give a provider more, deliberately:\n\n```yaml\ndelegation:\n  toolsets:\n    kimi: [web, browser, terminal, file]\n\n```\n\nThe file and docker tools also refuse locations that hold credentials: your SSH keys, cloud and token files, Mullet’s own state and any `.env` file. They follow symlinks to decide, and refuse a docker mount that holds or contains one. Docker also refuses the host’s IPC, user and UTS namespaces and `--device`.\n\n## A job can’t rewrite your tooling\n\nA job’s changes are checked against protected paths, and that list now covers files that run code without being asked: `.envrc`, `.vscode/**`, `.devcontainer/**`, `.npmrc`, `AGENTS.md` and other editor, dev container, shell hook and package manager files. A step that touches one fails its scope gate, the same as a step that edits a file it was told to leave alone.\n\n## The key proxy\n\nWorkers never see an API key. They talk to a proxy on a local socket that adds it, pinned to one model. The proxy now forwards an allowlist of headers instead of everything it was sent, clamps the `n` parameter, and can clamp output tokens.\n\n## What I left alone\n\nSetup commands, such as installing dependencies, still run on the host’s network, because package managers need it. They can reach anything the host can, so they are documented as the one place the sandbox’s network rule doesn’t apply. Jobs still refuse to start on macOS, because I have no way to test a sandbox there.\n\nEverything above is a setting, and `mullet config show` lists each one and where its value came from. 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"
}