Safe by default: a local AI server and a cheap model with a shell
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.
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.
A web page can call your local server
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.
So 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.
Tools that fetch any address
Mullet’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.
A cheap model doesn’t need a shell
This is the one I care most about. The cheaper models on the delegation ladder 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:
delegation:
toolsets:
kimi: [web, browser, terminal, file]
The 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.
A job can’t rewrite your tooling
A 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.
The key proxy
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 n parameter, and can clamp output tokens.
What I left alone
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.
Everything 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.
More on Mullet
-
Every session starts with one question: what’s the budget?
A Claude Code hook opens each session by asking how much to spend and how much to delegate to cheaper models, then makes Mullet’s jobs follow the…
-
Mullet is free for your company
Mullet’s licence used to say one developer, one running instance. It now allows free production use for any purpose, including inside a company…
-
We checked the bill, and our ledger was wrong by half
Mullet’s ledger said about $38 for a weekend of jobs. The providers billed about $50. How I found the gap, why one OpenRouter model was recorded at…