Please report security issues privately using GitHub's "Report a vulnerability" button (the repository's Security tab -> Advisories), rather than opening a public issue. We will acknowledge the report and work with you on a fix and a disclosure timeline.
localm is a local-first, single-owner application. API access is gated by a bearer key:
- When no key is configured, the inference API and ordinary reads (model listing, health) are fail-open by design - the server binds to localhost and serves them without auth, for frictionless local use. State-changing management routes, and reads of management/metadata endpoints specifically (named keys, server config, host stats, the filesystem browser), still require the loopback shell token (see State-changing endpoints below) - that stops a malicious web page, not another local program, which can obtain the token the same way the GUI shell does (see below).
- When a key is configured (
LOCALM_API_KEY), every/v1and/apiroute requiresAuthorization: Bearer <key>, gated by capability scopes: model-read routes (GET /v1/models,GET /v1/models/{id}) needmodels:read, plugin routes their per-plugin scope, key/config/plugin administration their privileged scopes (the owner key implies every scope). The sole exception isGET /health, an unauthenticated liveness probe that returns the status, model name, and load state.
Manage the key from the CLI: localm key show / generate / set / clear, and
mint named, scope-limited keys with localm key create --scope <scope> - by
default key create refuses to mint a privileged scope into a named key (pass
--allow-privileged to override, since a terminal on this machine is already
owner-equivalent trust); an owner-authenticated API call (POST /v1/keys) can
mint one too (see Capability scopes below). A named key can also carry an
expiry (--expires-in / expires_in on POST /v1/keys); verify() rejects it
once it has passed.
The owner key itself can be rolled or set from the GUI (Settings > Security)
as well as the CLI: POST /api/auth/key/rotate requires the admin scope
specifically (not just config:write, which governs the sibling clear route -
setting a caller-chosen key is privilege-equivalent to minting yourself owner,
so only an existing owner may do it). It is not loopback-restricted: reaching
it already requires the owner credential, which grants no new authority.
Rolling or setting the key does not revoke existing browser sessions (they are
decoupled from the key's value by design); localm key recover, run locally,
is the compromise-recovery path that does.
A key you chose yourself (localm key set, LOCALM_API_KEY, or writing
auth.key by hand) can be short or memorable, so its fingerprint - recorded in
sessions.json and jobs.json to check ownership - uses a slow, salted
derivation (scrypt) rather than a fast hash, so it cannot be brute-forced
offline from those files. A key localm generates for you is random and long
enough that this does not matter, and keeps using the cheap path.
localm key set / POST /api/auth/key/rotate refuse a key under 8 characters
outright; that floor is enforced only when the key is CHOSEN through localm,
not when it arrives via the LOCALM_API_KEY environment variable or a
hand-edited auth.key, and even a key that clears it is only "not instantly
guessable", never "strong" - only a randomly generated key is.
Because the default is fail-open for reads, a network bind without a key is unsafe,
so both localm gui and localm serve refuse to bind past loopback unless an API
key of at least 8 characters is set (printing how to set one) - a configured key
shorter than that is treated the same as no key at all. --insecure overrides this
for a trusted, isolated network - it then serves unauthenticated, the GUI's coder
agent included. A network bind also gets built-in TLS automatically (see
docs/tls.md), so the key and all traffic are encrypted. Exposing the GUI exposes
the coder agent, which can run shell commands.
The refusal above is a hard exit only for an explicit CLI bind. The bind
address and the TLS toggle/cert/key are also settable from the GUI (Settings >
Server; bind_host, tls_enabled, tls_cert, tls_key, each admin_only -
see docs/tls.md) and take effect on the next restart. --insecure
has no config form, so it can only ever be supplied from a terminal; a
Settings-driven bind that fails the key check (or whose TLS setup fails, or
whose configured address is not bindable on this machine right now) therefore
cannot be forced open the way a CLI bind can. It degrades LOUDLY to loopback
instead of exiting: a browser-only user with no terminal would otherwise be
locked out of a server that refuses to start, with no way back in.
Every state-changing endpoint (POST/PUT/PATCH/DELETE) is same-origin
only by default, so a web page on another localhost port (a dev server, an
npm postinstall page) cannot drive it from your browser. The guard is
allowlist-by-default: it covers plugin data routes (/api/rag, /api/coder,
...) too, not just key/config/plugin administration, and a new route is
protected the moment it is added. Three groups are exempt from the check itself,
each because it carries its own credential instead: the OpenAI-compatible
inference API (/v1/chat/completions, /v1/completions, /v1/embeddings),
left cross-origin callable so a local app can use it; /v1/surfaces/gui
(on-demand GUI mount), gated on its own attach token or the owner key; and
/v1/instances/cooperate-unload, /v1/instances/vouch and
/v1/instances/status (multi-instance GPU coordination between instances on one
machine): they answer only on an instance that coordinates, refuse any request
that carries an Origin header, and an unload happens only after the asking
instance confirms the request on /vouch. Each is an exact route, not a prefix - exempting a
future sibling route needs its own deliberate addition. A configured
"cors_origins" (or "*") opts specific origins into cross-origin use for
everything else.
When no key is configured (open mode), those same state-changing routes also
require a per-process shell token, served by GET / to whatever passes the
same-origin-or-loopback-Host check above. The loopback GUI shell is the intended
recipient, but that check cannot tell it apart from any other local program making
the identical request - so a local non-browser client (curl, a script) needs one
extra, unauthenticated GET / to obtain the token, and from there has the same
reach as the shell: mint a key, change config, install a plugin, load a model,
drive the coder agent, index files. This matches open mode's own
frictionless-local-use design (see above); it is not a credential boundary between
the owner and other software already running on the machine. Set a key (localm key generate) if that reach should not be available to other local programs. Reads
and the inference API stay open. A configured cors_origins (including "*") is
trusted for cross-origin reads of ordinary API responses, but state changes still
need a key or the shell token, so a forged Origin header cannot be used as a
management credential. The one exception is GET / itself: the page that
carries the shell token is served to same-origin requests only, regardless of
cors_origins - CORS governs whether another origin may read a response, not
whether the token was safe to put in one, so the token's own delivery is never
covered by that trust.
The same-origin requirement above also applies to GET reads of management and
metadata endpoints (named keys, server config, host stats, uploads/conversations/
plugins listings, and the filesystem browser used by the file picker) when no key
is configured: the open-mode shell token those routes accept is bound to the SAME
same-origin-or-allowlist check as a state change, not just to token possession. This
closes a specific gap CORS's own permissive localhost/127.0.0.1 origin policy
would otherwise create: without it, any other program on the machine that can read
the loopback GUI shell's HTML (CORS trusts every localhost:PORT origin to read a
matching response) could lift the embedded token and replay it cross-origin against
these routes. GET /v1/models and GET /health are exempt (unauthenticated by
design, matching the inference API's own cross-origin posture).
Every response from the GUI shell carries an enforcing (not report-only)
Content-Security-Policy, plus X-Content-Type-Options: nosniff. script-src
allows 'self', blob:, 'wasm-unsafe-eval', and a fresh, per-request nonce
(no 'unsafe-inline'), so an injected <script> in rendered chat content
cannot execute even though chat content is otherwise sanitised with DOMPurify
before it is inserted into the page - the CSP is the backstop, not the
primary defence. form-action 'none' additionally blocks a
sanitiser-surviving <form> from submitting anywhere, same-origin included.
connect-src allows 'self', blob:, and the two hosts a first-party
plugin fetches model weights from in-browser (huggingface.co, *.hf.co) -
no CDN origin is allowed or needed.
Some capabilities reach the host filesystem and process by design, bounded by the localm process's own permissions rather than a sandbox:
coder:fullruns shell commands and reads/writes files (the--scopeglob narrows which files;run_shellis intentionally unscoped). The plaincoderscope is restricted - read plus confined file edits within the scope, no shell. A restricted session starts none of the project's MCP servers, plugin tools or skills, and cannot create or change anything inside a.localcoderdirectory, which configures later coder sessions.browserlets the coding agent drive a real, automated browser session - navigate, read, click, fill forms, screenshot, and read the page's console and network activity - separate fromcoder/coder:full's shell and file access, so a key can be granted one without the other. Every request the driven page makes, including images and scripts it pulls in on its own and every hop of a redirect, is checked against the same outbound network policy asfetch_url/web_search, and a WebSocket connection is refused rather than relayed. See docs/network.md.ragindexing over the HTTP API is confined to your home folder, the working directory, and any folders you explicitly allow, and always refuses credential folders (~/.ssh, ...) wherever they appear, so an API client cannot index-and-read arbitrary system files. The localm data directory is NOT specially excluded from that confinement - if it falls within an allowed folder, its contents are indexable like any other file, on the reasoning that the owner already has direct filesystem access to their own data; this is a deliberate choice for a local, single-user tool, not an oversight. Thelocalm ragCLI is unconfined (a local user can already read their own files). Arag-scoped key can still read documents under the allowed roots back through a query, so issue one only to clients you trust. A key can also be confined to a specific folder allowlist at mint time (rag_roots) instead of that default reach - which REPLACES it rather than narrowing it, so it can point at a folder outside the default reach too, and only the owner key may grant it.image/video/musicread a source image for img2img only from the uploads folder and the generated-media galleries - never the rest of the data directory (which holds your owner key and sessions) and never the localm install directory - and a file with no image signature is refused before it is uploaded to ComfyUI (which may be another machine, over plain http). Moving a generated file OUT of the data directory needs host filesystem access, the same dial as the folder picker that chooses the destination. The same rule covers thegenerate_imageMCP tool, which reaches the same upload. Known limit, if you issue several media keys: those source folders are SHARED, so one media key can use another's generated image as an img2img source. The uploads inbox has no per-key ownership at all, so this is a property of the folders rather than of one route. It is narrower than it looks - the folders hold generated media and files you uploaded, not your keys or sessions - but if that matters to you, issue one media key.- Host filesystem access is a separate dial, and it gates the model and media routes that can name a path on the server: pulling a model by naming a path that already exists there, scanning for ComfyUI models, downloading a curated ComfyUI model, and moving a generated media file out of the data directory (see above). Without it, a key holding those scopes is confined to HuggingFace-by-name pulls and the folders localm already manages. It is a per-route gate on those routes, not a blanket property of every scope.
- Installing a plugin (a store name, or a third-party directory via
localm plugin install <path>/POST /api/plugins/install-external) refuses a source tree containing any symlink or Windows junction, so a malicious plugin source cannot smuggle a file's contents out through a link or drive a large copy through a self-referencing cycle. config:write/plugins:admin/keys:admin/coder:full/adminare privileged and are never granted implicitly: an owner-authenticatedPOST /v1/keyscall, orlocalm key create --allow-privilegedfrom this machine's terminal, must ask for one deliberately.
These are deliberate grants to you: a scoped key (or an exposed GUI) grants its holder that capability on your machine, so only issue keys to - or expose the GUI to - clients you trust.
- A model name from the API, an MCP client, or a scheduled job must be one
you have already registered - it is never treated as a filesystem path, so
a request cannot point the server at an arbitrary folder (which, for a
HuggingFace-format model, would otherwise run that folder's own bundled
Python unconditionally). Naming a model straight from a path on the command
line (
localm run D:\models\foo.gguf,localm gui <path>,localm mcp --model <path>) is unchanged and still allowed - you typed it yourself. - A model's own bundled code does not run just because you loaded it.
Custom model code (
trust_remote_code) is off by default; a model that needs it is refused with an explanation, and the owner-only "Allow model-bundled custom code" setting (Settings > Model) turns it back on for a model you trust. - A downloaded model or vision-projector filename cannot resolve to
something other than the plain file it appears to be: a repo-supplied name
is rejected if it contains a colon (which can open a hidden Windows
alternate-data-stream), matches an 8.3 short-name alias for an unrelated
file you already have, names a reserved Windows device, or ends in a dot or
space (which Windows silently strips). This applies to
localm pull, the same-repo vision-projector auto-attach, and--mmproj. - Routing a chat request to a model loaded on another localm instance on this machine verifies the target before forwarding anything: accepting a peer offer (Models page, when another running instance already has the model loaded) checks that instance's own API key against it once, or no key for an instance that has none, before anything is stored, and every request after that is only ever forwarded to a target confirmed to resolve to loopback over plain HTTP or HTTPS - a forged or LAN-facing offer cannot redirect your requests off this machine. The peer's key lives in this process's memory only; it is never written to disk or logged.
localm is offline-first, and the paths that CAN reach the network run through
one policy choke point (netpolicy.check_url). The full model, including the
domain lists and the mode semantics, is in docs/network.md;
this section states the security properties and, more importantly, their edges.
- What it covers. The coder's and chat's web access, HuggingFace model discovery, and model pulls all go through the policy. It is not only a model-facing guard.
- What it does not cover. Several outbound paths deliberately do not use
it, including bug-report upload and requests to your ComfyUI instance.
ComfyUI has its own, narrower guards instead: a configured
comfy_api_urlthat targets a link-local / cloud-metadata address is refused (CHK-COMFY-APIURL), and the connection itself refuses any HTTP redirect outright, so a hostile or compromised ComfyUI cannot use a 3xx response to steer the request elsewhere. Loopback and LAN are both normal, unchecked ComfyUI deployments - treat the policy as governing the paths named above, not as a blanket statement about every socket localm opens. The embedding-model and Whisper downloads described below are another: they respectnet_mode(includingoff) but fetch from a fixed, hardcoded repository named by localm's own code rather than a caller-supplied URL, so they never callcheck_urland are not subject to the domain lists, the SSRF guard, or the DNS-rebinding pin - none of which a fixed destination needs. - No redirect off HTTPS, on any of them. Separate from the policy above and narrower: every outbound client that uses localm's shared verified opener (setup-llama's runtime download and its GitHub and PyPI lookups, the update check and download, the issues list, the bug-report upload) refuses a redirect that leaves HTTPS for a weaker scheme. Verifying the first hop's certificate says nothing about the hops after it, and a redirect target is chosen by the server, after any check on the URL you configured has already run. This is a transport guarantee only: it says the bytes stay encrypted in transit, not that the host they came from was policy-checked.
offis the meaningful setting. At the policy layeraskandalloware the same thing: the only mode branch that refuses isoff. The prompt you see foraskcomes from the coder's own confirmation step, one layer up, and an auto-approve session does not show it. Do not readaskas a guarantee that something will stop and ask.- A one-time, explicit download is consent, not a policy exception. Two
GUI actions (fetching the embedding model on the Knowledge page, the Whisper
speech-to-text model on the mic button) let a
config:write-or-better caller authorize exactly one fetch of the currently-configured internal model even underask, the same way/weborlocalm pullare consent by definition. The authorization is a single call argument, never written to config or any other state, and it changes nothing aboutnet_modefor any other request.net_mode = offstill refuses it unconditionally - this bypassesask's friction, neveroff's kill switch. offhas one documented exception, and it is not the one above. An admin-only setting (update_ignore_net_policy, off by default) lets the update check run regardless ofoff. Nothing else opts out ofoff.- Private-address guard. Requests to loopback, link-local, CGNAT and
private ranges are refused, and the check is re-applied to the resolved
address rather than the name. It classifies by ADDRESS TYPE, so a service
reachable on a globally-routable address is not "internal" to this guard.
Setting
net_allow_privatetrue removes both the pre-flight check and the pin-time re-check, not just the former. - DNS-rebinding pin. A permitted request is pinned to the address that was
validated, so a name cannot resolve to something else between the check and
the connection. The pinned session also disables environment trust, so a
proxy environment variable cannot route the connection somewhere the pin
never saw, and
.netrccredentials are not auto-attached to a request the caller never asked to authenticate. The pin applies to sessions built for this purpose, not to every HTTP client in the process. - Redirects. Page fetches and model pulls re-validate each hop, so a permitted URL cannot redirect its way to a refused one. That re-validation is not present on every network path.
- Domain allow and deny lists.
net_modeand thenet_deny/net_allowlists are read from config once, together, per call tocheck_url. If that read fails, the request is refused outright (fail closed) rather than falling back to a permissive default - a denied host cannot slip through because the config happened to be unreadable that one time. - Response size. Fetches are capped, but the cap is a default that callers may raise; it is not a fixed ceiling.
What this is not. The policy decides whether a request may be made. It says nothing about whether the content that comes back is trustworthy. Fetched pages and search snippets are untrusted input to the model, and docs/network.md is explicit about that.
- Automatic past loopback, not before it. A bind beyond loopback generates a local certificate authority and a leaf certificate and serves HTTPS. A default loopback bind is plain HTTP and generates no certificate at all, which is why a normal local install has none.
- What the certificate covers. The SANs are built from this host's own
addresses (via the OS resolver), its primary outbound LAN address, any
Tailscale address, and the mDNS/Tailscale MagicDNS names - all stdlib-only,
so this works with no optional dependency. Reaching localm over a VPN or
overlay network may therefore land on an address the certificate does not
name. A VPN's own tunnel adapter is deliberately excluded from the LAN
address (see docs/tls.md). The optional
[monitor]extra (psutil) widens a different, non-security-relevant address list - the addresses shown on the phone-pairing card - not the certificate's SANs. - Regeneration. The leaf is regenerated when it no longer covers a required name, not on every address change.
- Key file permissions. The CA and leaf private keys are written 0600 from
the moment they exist, then restricted to the current user:
chmodon POSIX,icacls(an explicit, sole full-control ACE, inherited entries dropped) on Windows. Both are best-effort - a failure is logged as a warning rather than blocking startup, and the data directory's own access control is the fallback boundary if the OS-level restriction cannot be applied (a non-NTFS volume, a missingUSERNAME,icaclsitself absent). - Trusting the CA. Clients need the generated CA to validate the connection.
GET /localm-ca.crtserves it, and that route is deliberately public. - A self-call caveat. When the CA file is missing, localm's own internal calls fall back to not verifying rather than failing.
Setup, distribution to phones and browsers, and reverse-proxy alternatives are in docs/tls.md.
localm never updates itself. An update runs only when you initiate it (localm update, or the GUI "Update now" button). The client is signature-verifying:
- Signed builds. A downloaded build is verified against an Ed25519 public key pinned in localm's own source before anything is extracted or executed. A missing, invalid, or tampered signature is refused before any file is swapped (fail closed). The pinned key is a list, so a key can be rotated in before an old one retires.
- No downgrades. A validly signed but older, equal, or version-less build is refused: a signature proves authenticity, not freshness.
- HTTPS only. The download endpoint must be HTTPS and a redirect that would downgrade to plain HTTP is blocked.
- Your data is never in the swap. The updater never touches the venv,
.git, your data directory (models, config, sessions), or.localcoder; provisioned native binaries (the llama.cpp runtime) are preserved across the swap. It backs up first and rolls back on failure rather than leaving a half-applied tree.
Honest limits: signature verification enforces only while a key is pinned. Shipped
builds do pin one (a test keeps the pin non-empty), but if the pin were ever empty
the updater would fall back to transport trust (HTTPS plus the private channel)
rather than brick itself. The GUI "Update now" button's restart (the only
transition that happens with nobody watching) is followed by a detached watchdog
that polls the relaunched build's /whoami for the expected version and
auto-rolls-back if it does not come up healthy within 90 seconds. localm update
from the CLI applies the same way but does not restart for you - you relaunch by
hand, so a build that misbehaves after that manual restart has no automatic
watchdog and is recovered with localm update --rollback (or by restoring the
backup directory the update left behind). The GUI has the same rollback as a
button (Settings > Updates), backed by GET/POST /api/update/rollback: the
GET is a read-only check for whether a backup exists and which build it would
restore; the POST requires the admin scope specifically (not merely
config:write) and restores the previous build's files from the local backup
with no signature check and no network request - it is not a download, so
the signing model above does not apply to it. It restores files only, not a
deps-class update's package installs.
A first-time (non-updater) install can be verified too. scripts/verify_release.py
checks a downloaded release Asset (localm-<version>.zip plus its .zip.sig) against
the same pinned Ed25519 key described above, and/or a plain SHA256 digest - useful if
you obtained the zip some other way than the recommended git clone install. The
git clone path itself is unchanged by this: it never downloads a zip, so it continues
to rely on git+HTTPS+GitHub's own trust model, not this signing mechanism.
Every release carries build provenance that proves which workflow built a file and from which commit, independent of the Ed25519 update signature above. Two sets of files are covered:
-
GitHub release files. The release zip, the sdist (
localm-<version>.tar.gz), the wheel (localm-<version>-py3-none-any.whl) and the CycloneDX SBOM (localm-<version>-sbom.cdx.json) are attested with GitHub Artifact Attestations (SLSA build provenance, Sigstore). Download the file from the release page, then:gh attestation verify localm-<version>.zip --repo Matlan1/localm --signer-workflow Matlan1/localm/.github/workflows/release-attest.ymlThe same command verifies the sdist, the wheel and the SBOM. A file that was modified after the build, or built by any other workflow or repository, fails.
The release also carries
localm-<version>.intoto.jsonl, the Sigstore bundle of those attestations, so the provenance can be checked offline withgh attestation verify --bundle. -
PyPI files. The PyPI publish workflow uses Trusted Publishing, which uploads PEP 740 attestations next to each file. Verify what
pipwould install with:pip install pypi-attestations pypi-attestations verify pypi --repository https://github.com/Matlan1/localm pypi:localm-<version>-py3-none-any.whl
The SBOM lists every package pinned in uv.lock for a runtime install (the base
dependencies plus every optional extra except dev), one component each with its
version, pkg:pypi package URL and the SHA-256 of each distribution file.
localm is pre-1.0; security fixes land on the latest master.