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.

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. 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.

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

More writing