Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
## Reference

* [Technical details](technical_details.md)
* [Security](security.md)
* [Supported Systems](support.md)
* [Troubleshooting](troubleshooting.md)

Expand Down
133 changes: 133 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Security

llamafile can sandbox itself with two [Cosmopolitan
Libc](https://github.com/jart/cosmopolitan) primitives:

- **[pledge()](https://man.openbsd.org/pledge.2)** restricts which system
calls the process may make. On Linux it installs a SECCOMP BPF filter;
on OpenBSD it calls the native `pledge(2)`. **On by default.**
- **[unveil()](https://man.openbsd.org/unveil.2)** restricts which parts of
the filesystem the process can see. On Linux it uses the
[Landlock](https://docs.kernel.org/userspace-api/landlock.html) LSM
(kernel 5.13+); on OpenBSD it calls the native `unveil(2)`. **Opt-in**,
via `--confine-reads`.

Neither needs any kernel configuration or privileges. SECCOMP filtering
requires Linux 3.5+ on x86-64 and 5.13+ (for Landlock) on either
architecture; on older kernels, and on macOS, Windows, and other BSDs, the
calls are no-ops and llamafile logs that sandboxing is unavailable and keeps
running. The pledge() sandbox can be turned off entirely with `--unsecure`.

## The default: pledge()

The `llamafile --server` process runs under `stdio anet rpath` on Linux
(`stdio inet rpath` on OpenBSD). After startup this means:

- **No outbound network.** `anet` allows `accept()` but not `connect()`, so
the only networking the server can do is answer connections it received.
If the server is ever compromised (say, through a bug in the GGUF parser
or an HTTP handler), this is the single most valuable restriction: it cuts
the attacker's ability to exfiltrate anything over the network.
- **No writing, creating, deleting, executing, or forking.** A compromised
server can't modify your files, drop a payload, or launch a program.
(`--slot-save-path` and a prompt cache add write access to *their*
directories only.)
- **Reads are allowed** (`rpath`) — anywhere the process could already read.
This is deliberate: a server routinely opens files whose paths it only
learns at request time (multimodal media, static assets), which cannot be
known when a path allow-list would have to be locked. If you want to
restrict *which* files are readable, see `--confine-reads` below.

`--cli` runs under `stdio rpath tty` and `--chat` under
`stdio rpath wpath cpath tty` — neither has any network access at all.

## Opt-in read confinement: `--confine-reads`

Passing `--confine-reads` to the server additionally applies `unveil()`,
confining reads to the executable and the directories holding the weights
(the model and its shards, `--mmproj`, LoRA adapters, a draft model, control
vectors, `--media-path`, a static `--path` web root) plus the two
name-resolution files a non-numeric `--host` needs. The rest of the
filesystem — `/etc/passwd`, your SSH keys, other users' files — becomes
invisible to the server even though it is world-readable.

Two things to know before relying on it:

- **It confines directories, not individual files.** The model's *parent
directory* is unveiled (so multi-part GGUF shards beside it load), which
means anything else in that directory is readable too. Put weights in a
dedicated directory if you don't want their neighbours exposed.
- **It's incompatible with reading files by arbitrary path at request time.**
Because the readable set is locked at startup, a request that references a
file outside the unveiled directories will be denied. The built-in flows
(media under `--media-path`, static files under `--path`) are covered;
bespoke setups that read elsewhere are not.
- **It requires a filesystem Landlock can govern.** On the handful it cannot
(some network mounts, virtiofs, 9p) llamafile keeps the pledge() sandbox
but skips confinement rather than refusing to load the model, and logs
that it did so.

## When the sandbox is relaxed or skipped

llamafile logs a notice in each case:

- **Outbound features** (`--rpc` for distributed inference, server-side
tools, the MCP proxy) legitimately need to `connect()` out, so the
networking promise is relaxed from `anet` to `inet` for them. Writes and
exec stay blocked.
- **GPU mode gets no sandbox.** GPU backends are loaded dynamically and their
drivers need device access (`ioctl`, `/dev/*`) that no promise set covers,
so the sandbox is skipped whenever a GPU backend is loaded. **This is the
common production case** — a GPU server is not sandboxed. Pass
`--gpu disable` to force CPU inference with the sandbox.
- **Combined mode** (the default `llamafile -m model.gguf`, TUI + server in
one process) hosts an in-process HTTP client that must `connect()` to the
server, so it's skipped. Run `llamafile --server` for the sandboxed server.
- Model downloads (`-hf`, `--model-url`) happen *before* the sandbox is
installed; once the download completes the process is sandboxed normally.

## A note on the penalty mode

On Linux, sandbox violations are configured to return `EPERM` (permission
denied) rather than killing the process, so a blocked syscall surfaces as an
ordinary I/O error. If a server refuses to start with a `pledge failed`
error (an exotic kernel or an outer seccomp policy), `--unsecure` disables
the sandbox. OpenBSD's native `pledge(2)` always terminates a violating
process with `SIGABRT` instead; that behavior is not configurable there.

## Verifying the sandbox

Unit tests exercise every promise set llamafile uses, assert that allowed
operations work while blocked ones fail, and check that `unveil()` confines
reads to the unveiled directory (run on Linux):

```sh
.cosmocc/4.0.2/bin/make o//tests/sandbox_test && o//tests/sandbox_test
```

Integration tests verify the running server end-to-end — every thread of the
server process carries the SECCOMP filter, completions still work inside the
sandbox, a bundled `/zip/` llamafile loads under it, `--confine-reads`
confines while the default does not, and `--unsecure` really disables it:

```sh
cd tests/integration
./run_tests.sh --executable ../../o/llamafile/llamafile \
--model ../../models/TinyLLama-v0.1-5M-F16.gguf -m sandbox
```

You can also check a running server by hand:

```sh
llamafile --server -m model.gguf &
grep Seccomp /proc/$!/status # "Seccomp: 2" means the filter is active
```

## Caveats

Your llamafile is able to protect itself against the outside world, but that
doesn't mean you're protected from llamafile. Sandboxing is self-imposed. If
you obtained your llamafile from an untrusted source then its author could
have simply modified it to not do that. In that case, you can run the
untrusted llamafile inside another sandbox, such as a virtual machine, to
make sure it behaves how you expect.
2 changes: 1 addition & 1 deletion llama.cpp.patches/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ These patches integrate llamafile's file handling APIs for loading models from b

| Patch | Description |
|-------|-------------|
| `tools_server_server.cpp.patch` | Renames upstream's `llama_server()` to `server_main()` and adds `on_ready`/`on_shutdown_available` callbacks for combined TUI+server mode; adds Metal/GPU backend trigger before `common_init()`; adds Cosmopolitan-specific standalone `main()` with `cosmo_args`, verbose flag handling, and GPU pre-initialization; handles `LLAMAFILE_TUI` exit to avoid Metal cleanup crashes |
| `tools_server_server.cpp.patch` | Renames upstream's `llama_server()` to `server_main()` and adds `on_ready`/`on_shutdown_available` callbacks for combined TUI+server mode; adds Metal/GPU backend trigger before `common_init()`; installs the sandbox (`llamafile_sandbox_server()`, issue #930) — the mechanism lives in `llamafile/sandbox.c`; the patch fills a `llamafile_sandbox_spec` with the on-disk paths the loader opens after the lock (model, mmproj, media dir, LoRA, draft model, control vectors, public path as read; slot-save and prompt cache as read-write), detects outbound-needing features (`--rpc` via argv/env, MCP proxy, server tools) to relax `anet`→`inet`, passes `FLAG_confine_reads` for opt-in unveil(), quiesces the log worker (`common_log_pause`/`resume`, so no thread escapes the per-thread filter) around the call, before the HTTP listener spawns and before model load, and skips it in combined/GPU modes; adds Cosmopolitan-specific standalone `main()` with `cosmo_args`, verbose flag handling, `--unsecure` consumption (`llamafile_consume_flag`), and GPU pre-initialization; handles `LLAMAFILE_TUI` exit to avoid Metal cleanup crashes |

The web UI moved upstream from prebuilt `tools/server/public/*` assets to
a Svelte/PWA project under `tools/ui/`, embedded at CMake time via
Expand Down
1 change: 1 addition & 0 deletions llama.cpp.patches/llamafile-files/BUILD.mk
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,7 @@ UI_GEN_OBJ := $(UI_CPP_GEN:%.cpp=%.cpp.o)
TOOL_LLAMAFILE_OBJS := \
o/$(MODE)/llamafile/llamafile.o \
o/$(MODE)/llamafile/gpu.a \
o/$(MODE)/llamafile/sandbox.o \
o/$(MODE)/llamafile/zip.o

# Server objects depend on the llamafile bridge header and on the
Expand Down
107 changes: 102 additions & 5 deletions llama.cpp.patches/patches/tools_server_server.cpp.patch
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,21 @@ diff --git a/tools/server/server.cpp b/tools/server/server.cpp
#include <signal.h>
#include <thread> // for std::thread::hardware_concurrency

@@ -21,6 +22,11 @@
@@ -21,6 +22,13 @@
#include <windows.h>
#endif

+#ifdef COSMOCC
+#include <cerrno>
+#include <cstring>
+#include <cosmo.h>
+#include "llamafile.h"
+#endif
+
static std::function<void(int)> shutdown_handler;
static std::atomic_flag is_terminating = ATOMIC_FLAG_INIT;

@@ -71,15 +77,26 @@ static server_http_context::handler_t ex_wrapper(server_http_context::handler_t
@@ -71,15 +79,26 @@ static server_http_context::handler_t ex_wrapper(server_http_context::handler_t
};
}

Expand Down Expand Up @@ -52,7 +54,94 @@ diff --git a/tools/server/server.cpp b/tools/server/server.cpp
common_init();

if (!common_params_parse(argc, argv, params, LLAMA_EXAMPLE_SERVER)) {
@@ -332,6 +349,13 @@ int llama_server(int argc, char ** argv) {
@@ -297,6 +316,86 @@ int llama_server(int argc, char ** argv) {
llama_backend_free();
};

+#ifdef COSMOCC
+ // Sandbox (pledge()/unveil(), llamafile issue #930). The whole
+ // derivation lives in llamafile/sandbox.c so it stays unit-testable
+ // and off the vendored diff. Placement is constrained on every side:
+ // - after common_params_parse(): -hf downloads need connect()
+ // - after ctx_http.init(): the TLS certificate/key are read there
+ // - before ctx_http.start(): filters only cover threads created
+ // after installation, so the HTTP listener/pool must not exist
+ // - before load_model(): untrusted GGUF data is parsed sandboxed
+ //
+ // Combined mode hosts an in-process HTTP client (the TUI) that needs
+ // connect(), which the accept-only sandbox forbids, so it's skipped.
+ const bool combined_mode = on_ready || on_shutdown_available;
+ if (combined_mode) {
+ SRV_WRN("%s", "sandbox: disabled in combined mode; use --server for a sandboxed server\n");
+ } else {
+ // pledge() (no outbound network / writes / exec) is applied by
+ // default. unveil() path confinement is opt-in (--confine-reads),
+ // because a server opens files by path at request time (multimodal
+ // media, etc.) that can't be known when the ruleset locks.
+ //
+ // cosmo installs the SECCOMP/Landlock filters on the calling thread
+ // only (no TSYNC), so quiesce the llama.cpp log worker across the
+ // call: pause() joins it, resume() respawns it under the filter.
+ //
+ // When --confine-reads is on, unveil() confines reads to these
+ // directories, so collect every on-disk path the loader opens
+ // after the lock. Add to this list when llama.cpp gains a new file
+ // parameter (else it'd be denied under confinement). Paths read
+ // before the sandbox (ssl_file_key/cert in ctx_http.init(), -hf
+ // downloads in parse) need no entry.
+ std::vector<const char *> read_paths, rw_paths;
+ read_paths.push_back(params.model.path.c_str());
+ read_paths.push_back(params.mmproj.path.c_str());
+ read_paths.push_back(params.media_path.c_str()); // request-time media
+ read_paths.push_back(params.speculative.draft.mparams.path.c_str());
+ read_paths.push_back(params.speculative.ngram_cache.lookup_cache_static.c_str());
+ read_paths.push_back(params.public_path.c_str());
+ for (const auto & la : params.lora_adapters)
+ read_paths.push_back(la.path.c_str());
+ for (const auto & cv : params.control_vectors)
+ read_paths.push_back(cv.fname.c_str());
+ rw_paths.push_back(params.slot_save_path.c_str());
+ rw_paths.push_back(params.path_prompt_cache.c_str());
+ rw_paths.push_back(params.speculative.ngram_cache.lookup_cache_dynamic.c_str());
+
+ // Features that dial out at runtime need connect(), which the
+ // accept-only default forbids; relax to full sockets for them
+ // (still no writes/exec). --rpc has no params field -- its handler
+ // registers backends directly -- so detect it on argv/env.
+ const bool needs_outbound =
+ params.ui_mcp_proxy ||
+ !params.server_tools.empty() ||
+ llamafile_has(argv, "--rpc") ||
+ (std::getenv("LLAMA_ARG_RPC") != nullptr);
+
+ llamafile_sandbox_spec spec = {
+ read_paths.data(), (int) read_paths.size(),
+ rw_paths.data(), (int) rw_paths.size(),
+ FLAG_confine_reads, needs_outbound,
+ };
+ char promises[64];
+ common_log_pause(common_log_main());
+ int sandbox = llamafile_sandbox_server(&spec, promises, sizeof(promises));
+ int sandbox_errno = errno; // capture before resume() spawns a thread
+ common_log_resume(common_log_main());
+ if (sandbox == LLAMAFILE_SANDBOX_FAILED) {
+ SRV_ERR("sandbox: pledge failed: %s; retry with --unsecure to disable sandboxing\n",
+ strerror(sandbox_errno));
+ clean_up();
+ return 1;
+ } else if (llamafile_sandbox_is_active(sandbox)) {
+ SRV_INF("sandbox: pledge(\"%s\") %s\n", promises,
+ llamafile_sandbox_describe(sandbox));
+ } else {
+ SRV_WRN("sandbox: %s\n", llamafile_sandbox_describe(sandbox));
+ }
+ }
+#endif
+
// start the HTTP server before loading the model to be able to serve /health requests
if (!ctx_http.start()) {
clean_up();
@@ -332,6 +431,13 @@ int llama_server(int argc, char ** argv) {
// this will unblock start_loop()
ctx_server.terminate();
};
Expand All @@ -66,7 +155,7 @@ diff --git a/tools/server/server.cpp b/tools/server/server.cpp
}

// TODO: refactor in common/console
@@ -368,6 +392,11 @@ int llama_server(int argc, char ** argv) {
@@ -368,6 +474,11 @@ int llama_server(int argc, char ** argv) {
} else {
SRV_INF("server is listening on %s\n", ctx_http.listening_address.c_str());

Expand All @@ -78,7 +167,7 @@ diff --git a/tools/server/server.cpp b/tools/server/server.cpp
// optionally, notify router server that this instance is ready
std::thread monitor_thread;
if (child.is_child()) {
@@ -392,5 +421,48 @@ int llama_server(int argc, char ** argv) {
@@ -392,5 +503,56 @@ int llama_server(int argc, char ** argv) {
}
}

Expand Down Expand Up @@ -111,6 +200,14 @@ diff --git a/tools/server/server.cpp b/tools/server/server.cpp
+ bool verbose = llamafile_has(argv, "--verbose");
+ FLAG_verbose = verbose ? 1 : 0;
+
+ // --unsecure is llamafile's flag, not llama.cpp's: consume it here so
+ // common_params_parse() never sees it (llamafile's own main strips it
+ // via parse_llamafile_args instead)
+ if (llamafile_consume_flag(&argc, argv, "--unsecure"))
+ FLAG_unsecure = true;
+ if (llamafile_consume_flag(&argc, argv, "--confine-reads"))
+ FLAG_confine_reads = true;
+
+ // Initialize GPU support early (must happen BEFORE llama_backend_init())
+ // This triggers dynamic loading of GPU backends (CUDA, ROCm, Metal)
+ // The llamafile_has_* functions use lazy initialization via cosmo_once()
Expand Down
1 change: 1 addition & 0 deletions llamafile/BUILD.mk
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ LLAMAFILE_SRCS_C := \
llamafile/gpu_backend.c \
llamafile/llamafile.c \
llamafile/metal.c \
llamafile/sandbox.c \
llamafile/vulkan.c \
llamafile/zip.c

Expand Down
8 changes: 8 additions & 0 deletions llamafile/args.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ static bool is_llamafile_flag(const char* arg) {
strcmp(arg, "--ascii") == 0 ||
strcmp(arg, "--nologo") == 0 ||
strcmp(arg, "--nothink") == 0 ||
strcmp(arg, "--unsecure") == 0 ||
strcmp(arg, "--confine-reads") == 0 ||
strcmp(arg, "--version") == 0;
}

Expand Down Expand Up @@ -87,6 +89,12 @@ LlamafileArgs parse_llamafile_args(int argc, char** argv) {
FLAG_nologo = llamafile_has(argv, "--nologo");
FLAG_ascii = llamafile_has(argv, "--ascii");

// Check --unsecure flag (disables pledge() sandboxing, see sandbox.c)
FLAG_unsecure = llamafile_has(argv, "--unsecure");

// Check --confine-reads flag (opt-in unveil() path confinement, server mode)
FLAG_confine_reads = llamafile_has(argv, "--confine-reads");

// Filter out llamafile-specific arguments
// These are not recognized by llama.cpp and would cause errors
g_filtered_argv.clear();
Expand Down
11 changes: 11 additions & 0 deletions llamafile/chatbot_cli.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,17 @@ int cli_main(int argc, char **argv) {
params.n_gpu_layers = INT_MAX;
}

// Drop network and filesystem-write access before the untrusted GGUF
// file is parsed. Must happen after common_params_parse() (-hf model
// downloads need the network) and before threads/weights come up.
// Quiesce the log worker across the pledge so it doesn't outlive the
// per-thread filter unsandboxed (see llamafile/sandbox.c rule 1).
common_log_pause(common_log_main());
int sandbox = llamafile_sandbox_enter("stdio rpath tty", FLAG_verbose);
common_log_resume(common_log_main());
if (sandbox == LLAMAFILE_SANDBOX_FAILED)
return 1;

// Load model
llama_model_params model_params = common_model_params_to_llama(params);
llama_model *model = llama_model_load_from_file(params.model.path.c_str(), model_params);
Expand Down
12 changes: 12 additions & 0 deletions llamafile/chatbot_main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,18 @@ int main(int argc, char **argv) {
}
clear_ephemeral();

// Drop network access before the untrusted GGUF file is parsed. Must
// happen after common_params_parse() (-hf model downloads need the
// network) and before threads/weights come up. Unlike --cli, the chat
// TUI keeps wpath+cpath so /dump and /push can still write files.
// Quiesce the log worker across the pledge so it doesn't outlive the
// per-thread filter unsandboxed (see llamafile/sandbox.c rule 1).
common_log_pause(common_log_main());
int sandbox = llamafile_sandbox_enter("stdio rpath wpath cpath tty", verbose);
common_log_resume(common_log_main());
if (sandbox == LLAMAFILE_SANDBOX_FAILED)
exit(1);

// Suppress logging for model loading unless --verbose was specified
// We must set this AFTER common_init() since it overwrites the log callback
// and BEFORE model loading to suppress those logs
Expand Down
Loading