Report privately, not as a public issue: use GitHub's private vulnerability reporting. Everything about a report happens there - filing, discussion, the fix, and the advisory.
Please include what you need to and nothing you don't - a description of the issue, the version or commit, and enough to reproduce it. A proof of concept helps but is not required to file.
We'll acknowledge within 3 business days and give you an assessment within 10. If we agree it's a vulnerability we'll tell you our intended fix and timeline, and credit you in the advisory unless you'd rather we didn't.
The latest release channel. Leviath is pre-1.0 and ships as a rolling
release, so fixes land on main and go out through alpha → beta → latest rather
than as backports to older tags.
Leviath runs LLM-driven tools - shell commands, file writes, HTTP requests, MCP servers, and user-authored Rhai scripts - on your machine. Being clear about what that does and does not defend against matters more than a list of features.
What we defend against. These are treated as real attackers, and a bypass is a vulnerability:
- A malicious or compromised agent package. An
agent.leviathyou installed can only tighten what your~/.leviath/config.tomlallows, never loosen it. For a tool you have not configured there is no setting of yours to clamp against, so a package may raise it no higher than Leviath's own default - with one named exception,web_searchandweb_fetch, which read-only research agents pre-approve and which cannot write or execute. Anything beyond that needs[security] allow_blueprint_permissions, or the tool named under[agent_tool_permissions.<agent>], which is you saying "I trust this one". A package cannot grant itself tools you denied, run an unapproved command at spawn through a region seed, disable taint tracking, or weaken a sandbox you configured - not by turning it off, and not by widening it: it cannot add a bind-mount you did not grant, re-enable a network you isolated, or replace the engine binary.lev addprints what a package asks for before you run it. - Prompt injection reaching an agent's tools. A model told by a fetched web
page to exfiltrate your keys should fail. Script tools cannot read
credential-shaped environment variables without an explicit allowlist entry,
and outbound fetches cannot reach loopback, private, or link-local addresses -
including cloud metadata endpoints - without
[security] allow_local_network. The same address check covers every URL Leviath is given rather than chooses, including a completion webhook posted throughlev serve. - A hostile MCP server. OAuth discovery is bound to the server's own origin,
the issuer is cross-checked per RFC 8414 §3.3, and the whole chain requires
HTTPS off loopback. A server cannot redirect your browser or your token
somewhere else, and redirects are capped so it cannot chain the daemon around
your network. A server entry's
${VAR}headers follow the same credential-name allowlist as script tools, so one cannot be written to post your API keys to a URL of its own choosing. - Another local user. Every control-channel caller must quote a token the
daemon mints at startup into its own owner-only directory, so a connection
that cannot read your files cannot drive your agents. On Unix the socket is
additionally
0600and the daemon checks the peer's uid with the kernel. Secret files are created owner-only rather than tightened afterwards - POSIX0600on Unix, an ACL granting only you on Windows - and run artifacts are owner-only too. - A repository the agent is pointed at. File tools resolve symlinks and
refuse paths that leave the workspace, so a checked-in symlink cannot read
your
~/.ssh. A shell redirect answers to the same fence:> pathoutside the workspace is refused exactly aswrite_filerefuses it, so the shell is not a spelling that gets further. No flag lifts that,--yoloincluded - it is containment rather than permission. The one exception is deliberate and yours to make: an agent may declare extra directories under[read_paths], and those declarations are inert until your config grants them. When granted they are read-only, and every access is checked against the symlink-resolved real path, so a planted symlink inside a granted directory still cannot reach outside it. - The supply chain.
Cargo.lockis committed,cargo-denygates advisories and licences on every PR, all GitHub Actions are SHA-pinned, and release binaries carry signed build provenance. Every install path - the shell installer, the PowerShell installer, the Homebrew formulae and the Scoop manifest - verifies the download against the release'sSHA256SUMSand refuses to install on a mismatch. That catches a corrupted download, a swapped asset, or a mirror serving something else; it is not a substitute for verifying the provenance attestation, which is the stronger check and is tracked separately.
Where the boundary is. Not gaps we haven't got to - these are the edges of what a tool like this can be responsible for, and it is worth being explicit about them:
- The model doing something unwise with permissions you granted it. If you
allow
shell, an agent can run any command you can. Leviath's job is to make that grant explicit and scoped, not to second-guess it. --yolo. It waives approval prompts by design. It does not override a configureddeny- that stays terminal - but everything else runs unattended. That is the point of the flag.- A compromised provider or model. Leviath sends your context to whichever API you configured. Choosing that endpoint is your decision and we cannot audit what happens on the other side of it.
- Which sandbox mode you pick. Tools run on your machine unless you opt into
[sandbox]. Thecontainerkind isolates the filesystem. Thenamespacekind isolates PIDs and optionally the network but shares the host root filesystem - it is not a filesystem sandbox, and both the docs and the code say so where it is defined. Pickcontainerif the filesystem is what you need isolated. - A target with neither POSIX modes nor Windows ACLs. Unix and Windows are
both implemented; anything else gets whatever the platform does by default,
and
leviath-sys's fallback module says so rather than pretending otherwise.
Known gaps. The ones we know about, so nobody has to find them twice. Each is a place where the code is weaker than a reader of this document might assume; none is a case of the code doing something other than what it says.
- The Windows control pipe checks no peer. On Unix the daemon refuses a control connection from another user, having read the peer's uid from the kernel. The Windows pipe has no such check and sets no security descriptor, so what stands between another local user and the daemon there is the control token alone (owner-only file, fresh per daemon). The code says the same where it accepts the connection.
[sandbox]isolates shell commands, not file tools. The boundary covers theshelltool, seed commands and a Rhai tool'sshell(). File tools stay on the host and rely on workdir confinement; a Rhai tool'shttp_get/http_postandread_file/write_filerun on the host too. Pickcontainerwhen the filesystem is what you need isolated, and know that a script tool reaches the network from the host either way.- A run's webhook secret is on disk in plaintext.
callback_secretis persisted in the run'smeta.jsonso the daemon can still sign a webhook for a run it reloaded after a restart. It is stripped from every API response, and the file is0600in a0700directory, which is the same protection the provider keys inconfig.tomlget. It is not encrypted at rest, and it stays on disk until the run is deleted. lev servedoes not check theHostheader. A DNS-rebinding page can therefore make a browser send requests to a local server under an origin the server would accept. The bearer token is what stops that from being useful: every API route requires it and a rebinding page does not have it. Do not embed the token in a page served from anywhere else, and do not use--cors "*"on a machine that browses the web.- OTLP log records carry the run's output unredacted. With
[telemetry]on, every output and runtime log line is exported as it was written. Nothing redacts a credential a tool happened to print on its way out, so point the exporter only at a collector you would trust with the run's transcript. POST /api/updateruns the installer it fetches. On a script install the update step iscurl -fsSL https://leviath.dev/install.sh | sh, over TLS and unpinned; the script then verifies the release checksums, but the script itself is trusted on the strength of the TLS connection. It is behind--allow-adminfor that reason. A pinned install goes through your package manager instead.
If you find something this document claims but the code does not do, that is a vulnerability and we want to hear about it - see the top of this file.
What the scanners say. CodeQL runs on every push and the count is kept at
zero, with nothing dismissed. One class needs explaining so it is not
re-triaged: CodeQL's Rust model treats every parameter of an axum handler as
request data, the shared State included, so a file location read off the
server state counts as a path-injection finding even when the operator set it
at startup. lev serve therefore keeps the config and token-store locations
behind a resolver installed at startup (McpAdmin::paths(), and
UpdateJobs::with_env for the update route). A new admin route reads paths
through that resolver, never through a field on the state. CONTRIBUTING.md
has the recipe for running the same queries locally.
| What | Where | Mode |
|---|---|---|
| Provider API keys | ~/.leviath/config.toml, or the OS keychain |
0600 |
| MCP OAuth access + refresh tokens | ~/.leviath/mcp-auth.json, or the OS keychain |
0600 |
| Run artifacts (prompts, conversations) | ~/.leviath/runs/<id>/ |
0600 in a 0700 dir |
A run's webhook signing secret (callback_secret) |
~/.leviath/runs/<id>/meta.json |
same as the run; never served, see known gaps |
| Control socket | ~/.leviath/control.sock (Unix) |
0600, same-uid peers only, token required |
| Control pipe | \\.\pipe\leviath-control-… (Windows) |
token required |
| Control token | ~/.leviath/control.token |
owner-only; fresh per daemon |
| API server token | LEVIATH_API_TOKEN or --token |
not persisted |
Prefer LEVIATH_API_TOKEN over --token: an argument is visible in ps to
every local user for the lifetime of the process.
By default secrets live in the 0600 files above, which is the same shape as
comparable tools and the only arrangement that works headless, in containers,
over SSH, and on CI. To move them into the OS credential store - macOS Keychain,
Windows Credential Manager, or the Secret Service elsewhere - so that a stolen
~/.leviath directory yields nothing:
# ~/.leviath/config.toml
[security]
credential_store = "keychain"Then move the secrets you already have:
lev auth migrate # config file -> OS keychain
lev auth migrate --dry-run # show what would move
lev auth migrate --to-file # and back again
lev auth status # which backend, and what it holdsBoth kinds of secret move: provider API keys and MCP OAuth grants. In keychain
mode mcp-auth.json keeps only the server names, since the OS stores cannot be
enumerated and lev mcp list still has to be able to say what is logged in - a
server name is not a secret, the access and refresh tokens are.
lev auth migrate writes the destination and reads each secret back before
removing the source, so a store that accepts writes but does not persist them
cannot cost you your API keys. Nothing silently falls back to the file: if the
keychain is configured but unreachable, a write fails loudly rather than putting
plaintext tokens on disk.
This is opt-in rather than the default because an unavailable keychain is not a
degraded experience but a broken one - every inference fails at once - and the
environments Leviath is most useful in are the least likely to have a working
credential store. lev auth status reports whether this machine actually has
one. Builds can also omit credential-store support entirely (the keychain
feature), in which case lev auth status says so rather than offering a
migration that cannot work.
Running lev serve where others can reach it, or on shared infrastructure:
lev serve \
--workdir-root /srv/agent-workspaces \ # agents cannot escape this root
--no-remote-yolo \ # requests cannot waive approvals
--cors https://your-dashboard.example # omit entirely for non-browser clients
# --allow-admin is off by default: the MCP admin endpoints write a spawn
# command into config and are remote code execution by construction.And in ~/.leviath/config.toml:
[security]
allow_seed_commands = false # no manifest command runs before the first prompt
allow_local_network = false # the default; agent fetches cannot reach your LAN
allow_env_vars = [] # the default; scripts read no credential-shaped vars
[tool_permissions]
shell = "ask" # a ceiling no installed agent can raise
[sandbox]
kind = "container" # a manifest cannot turn this offThe installers do the checksum half for you - install.sh, install.ps1, the
Homebrew formulae and the Scoop manifest all verify against the release's
SHA256SUMS and refuse to install on a mismatch. To check by hand, or to verify
the attestation as well:
# Asset names are `leviath-<platform>-<arch>.<ext>`, e.g.:
gh attestation verify leviath-linux-x64.tar.gz --repo GEMISIS/leviath
sha256sum -c SHA256SUMSThe attestation is the stronger check, and the reason the checksum alone is not
enough: anyone who can write a release can rewrite both a binary and its
checksum, but the attestation is signed by GitHub's OIDC identity for the build
workflow and cannot be forged from inside the release. The installers do not
run it automatically yet, so gh attestation verify is a manual step; wiring
it into the installers is planned.
lev.exe ships unsigned. Authenticode signing needs a certificate from a
CA Microsoft trusts, and every such certificate costs money; Leviath is free
and is not paying for one. The consequence is that a new build has no
reputation with Defender or SmartScreen and may be flagged - a false positive
on an unknown file, which the build provenance attestation above answers: it
proves which workflow built the file, which is a stronger statement than a
publisher signature makes.
The release pipeline has no signing step. If signing is ever wanted, the free path for open-source projects is the SignPath Foundation (see CONTRIBUTING).