The security model
Permissions, secrets, containers, and the MCP trust boundary.
Luxe is permissive by default (parity with Pi/OpenCode) so the autonomous loop is never blocked by a mandatory gate — but it ships a small set of always-on guardrails, and real allow/ask/deny control + isolation are one flag or one config file away. This page describes the model and how to tighten it.
Threat model, in one line
Luxe runs an LLM that can read/write files and execute shell commands on your machine. Treat it like any tool that runs code: give it a workspace you’re comfortable with, and turn on the isolation you need. Model output and any external tool/MCP content are untrusted data, never instructions.
Permissions
Every tool call is evaluated to allow (run), ask (prompt first), or deny (block).
The policy is a set of rules keyed by tool, plus two safety guards. It’s permissive out of the box,
but the guardrails below always apply unless you explicitly opt out (--yolo).
Defaults (permissive + guardrails)
With no config, Luxe allows almost everything, then layers these guards (any of which a more specific rule you write can override):
- Secrets: reading
.env/.env.*files is denied (.env.exampleis allowed), so a stray read can’t leak credentials. - Work outside the project (
external_directory): any tool touching a path outside the project root defaults toask. - Runaway loop (
doom_loop): the same tool call repeated 3× in a row defaults toask. - Catastrophic shell commands are denied: root/home/system-dir wipes (
rm -rf /,rm -rf ~,rm -rf /etc…),mkfs,dd of=/dev/…, and fork bombs. Matching splits the command on&&/;/|and matches each segment, so a risky command hidden in a chain is still caught. This is a guardrail, not a security boundary — a determined model can obfuscate around shell matching; for real isolation use a container (below). - Dangerous-but-sometimes-intended commands
askfirst:git push --force,sudo ….
Modes
Pick a posture with --permission-mode <mode> (or the shortcuts):
| Mode | Behavior |
|---|---|
default | Permissive + the guardrails above. |
strict | Ask before every mutating tool (write/edit/bash/git/debug); guardrails still apply. |
read-only (--read-only) | Deny every mutating tool; reads/search/LSP still work. A look-but-don’t-touch review pass. |
yolo (--yolo) | Allow everything, guardrails off. An explicit opt-out — use with care. |
Config file
Rules live in ~/.config/luxe/permissions.json (global) and, optionally,
.luxe/permissions.json (project-local). Each value is either a single action or a map of
pattern → action; the most specific matching pattern wins.
{
"mode": "default",
"permission": {
"bash": { "*": "allow", "rm *": "ask", "git push * --force *": "deny" },
"edit": "allow",
"read": { "*": "allow", "*.env": "deny", "*.env.example": "allow" },
"external_directory": { "~/scratch/**": "allow" },
"webfetch": "allow"
}
}
- Keys are tool names (
bash,edit— which also coverswrite—read,glob,grep,git,webfetch,websearch,mcp, …) plus the guardsexternal_directoryanddoom_loop. Anmcpcall is matched against itsserver/nametarget. - Patterns:
*matches any run,?one char; a trailing*also matches the bare prefix (git *matchesgitandgit status).bashpatterns match the parsed command (head + args). - Home expansion:
~/$HOMEat the start of a pattern expand to your home directory (useful forexternal_directoryallow-lists).
Project config is tighten-only
A cloned repo could ship a .luxe/permissions.json. By default a project file can only make the
policy stricter than your global/default baseline (deny > ask > allow) — it can raise allow
to ask/deny, but it can never loosen your baseline or disable a guardrail. Pass
--trust-project to let a project file loosen policy (and set its own mode).
The ask prompt
When a tool needs approval and you’re in the TUI, a yellow prompt appears above the composer with four choices:
- Allow once — run just this call.
- Allow for this session — remember the matching pattern (shown in the prompt) so you’re not re-asked for that command family this session.
- Allow always — the same rules, written to the global
~/.config/luxe/permissions.jsonso the approval survives a restart. (Always the global file: a project ruleset is tighten-only, so an approval stored there would never take effect. The write merges into the existing file and leaves every other key alone.) - Reject — deny the call (the model sees the denial and continues).
The patterns an “always” answer would add are shown in the prompt before you pick it. They’re
scoped to be useful: bash generalizes to the command family (cargo *), and a path outside the
project approves the containing directory — both the external_directory guard that actually
fired and the tool’s own key (read/edit) — so approving one file doesn’t leave you re-approving
its siblings one by one.
Keyboard: arrows + Enter, 1/2/3/4, a/s/w/r, or Esc to reject. Toggle auto-approve
(walk-away mode) with /permissions on|off — when on, asks are auto-allowed but every deny is
still enforced. Headless and loop runs auto-allow ask by default (they can’t block on a
human) while still enforcing every deny, so the guardrails hold even unattended.
Secrets
-
Sources & precedence (highest first):
--api-keyCLI flag → environment (LUXE_API_KEY, then the provider’s own variable) → the credential stored in~/.config/luxe/auth.json→ a provider block inauth.json/models.json. -
Auth file lives at
~/.config/luxe/auth.json, mode0600. Luxe warns at startup ifauth.json/models.jsonis group/world-readable, and writes its own entries0600with the mode set at open time (never world-readable for an instant). A top-level key is one provider’s credential;providers/models/default_provider/default_modelkeep their config meaning, so the older nested schema still works:{ "openai": { "type": "api_key", "key": "sk-..." }, "github-copilot": { "type": "oauth", "access": "...", "refresh": "...", "expires": 1785964482483 }, "providers": { "anthropic": { "api_key": "sk-ant-..." } }, "default_provider": "openai" } -
Writes are lossless and serialized. Every mutation is a read-modify-write of the raw JSON under a cross-process lock, landing through a temp file + rename: unknown keys and hand-written blocks survive
luxe auth login, a crash can’t truncate the file, and a malformed file blocks a write rather than being replaced. An expiring OAuth token is refreshed once under that same lock, with the expiry re-checked inside it, so two Luxe processes can’t both refresh and lose a rotating refresh token. -
Sign-in flows use PKCE (S256) with a
statethe loopback listener validates before any code is exchanged, or RFC 8628 device codes honoringinterval/slow_down/expiry. The listener binds127.0.0.1only, serves exactly one redirect, and closes with the flow — including when the user cancels. A login writes nothing until the credential is known good. -
Subscription auth is limited to providers that permit third-party clients (OpenAI Codex, GitHub Copilot, OpenRouter). Anthropic and Google are API-key only in Luxe: their terms forbid harness use of a subscription, and shipping the flow anyway would put a user’s account at risk on our say-so.
-
Indirection — any key/base-url value may be
$ENV,${ENV}, or!command(the command’s stdout is used).$$/$!escape a literal$/!. This keeps raw secrets out of the file (e.g."api_key": "!op read op://vault/openai/key"). The config file is a trust boundary: a!commandruns at startup, so treat it like a shell rc file. -
In memory the resolved key is wrapped in an
ApiKeynewtype backed bysecrecy::SecretString(zeroized on drop). ItsDebugandSerializeboth emit[REDACTED], so it can’t leak through a log line or an accidental config dump; the raw value is exposed only where the outbound auth header is built. MCP OAuth tokens are stored0600with the same in-memory hygiene. -
Redaction — a central layer (
luxe-core::redact) scrubs common secret shapes (OpenAI/Anthropic/Google/Stripe keys,Bearertokens, GitHub/Slack/AWS tokens, JWTs, private-key PEM blocks,user:pass@URL credentials, andpassword=/token=/secret=assignments) from tool output before it reaches the transcript, the event stream, hooks, or the model’s next turn, and from anything logged.
Network & the web tools
- Outbound
web_fetchand thescreenshottool’smode=urlboth run through an SSRF guard: http/https only,localhost/file://refused, and any private / loopback / link-local / cloud-metadata (169.254.169.254) / CGNAT / ULA address rejected. Redirects are followed manually and re-validated per hop, and the connection is pinned to the validated IP to close the DNS-rebinding window.LUXE_WEB_ALLOW_PRIVATE=1is an explicit, coarse opt-out for intentional internal fetches / local dev-server captures. web_searchtalks only to the fixed search-provider API you configured (Brave/Tavily/Exa), not model-controlled URLs.- TLS uses rustls (bundled webpki roots), not the platform OpenSSL —
luxeis a self-contained binary with no dynamic link to a systemlibssl. Certificate verification is never disabled.
Containers / isolation
Permissions are a policy layer, not a sandbox. For hard isolation, run Luxe inside a container or VM that mounts only the workspace you want it to touch:
docker run --rm -it \
-v "$PWD":/work -w /work \
-e LUXE_API_KEY \
<your-rust-image> luxe --headless -p "..."
Guidance:
- Mount only the project directory; don’t bind-mount your home or SSH keys.
- Drop network access (
--network none) for offline work; Luxe is offline-capable except for the LLM provider call, theweb_*tools, and the models.dev refresh. - Combine with
--read-onlyfor a look-but-don’t-touch review pass.
MCP servers (untrusted)
MCP is opt-in: servers are added explicitly (luxe mcp add) and persisted to
.luxe/mcp.json. When present they are treated as untrusted:
- MCP is exposed as a single
mcpgateway tool (discover → describe → call), so a call goes through the permission engine keyed by itsserver/nametarget: rules like"mcp": { "*": "allow", "github/*": "ask" }gate per server/tool. Discovery (search/describe) has no target and isn’t tool-gated. - Server-provided tool descriptions and results are data, surfaced to the model as tool output — never interpreted as instructions.
- Only the stdio transport is driven from core (a locally-spawned process). HTTP/SSE is not enabled by default.
- A misbehaving or unreachable server is skipped (best-effort, timeout-bounded); it can’t hang startup.
Command hooks (extensibility)
Command hooks run a user-configured external command at an agent-loop hook point
(SessionStart/SessionEnd, UserPromptSubmit, BeforeAgentStart/AgentStart/AgentEnd,
BeforeToolUse/AfterToolUse, FileChanged, BeforeCompact/AfterCompact). The command
receives the event as JSON on stdin and returns a decision on stdout; a deny at
BeforeToolUse blocks the tool and at UserPromptSubmit blocks the prompt (every other point is
observe-only). This is a general policy/automation extension point (parity with Claude Code
hooks). Full reference: hooks.md.
Trust model (important). A hook runs an arbitrary command, so hooks are loaded from the
global config ~/.config/luxe/hooks.json only. A project’s .luxe/hooks.json is honored
only under --trust-project — otherwise merely opening a cloned repo could execute code on the
next tool call (the same footgun as malicious git hooks). This mirrors the tighten-only project
permission model.
// ~/.config/luxe/hooks.json
{
"BeforeToolUse": [
{ "command": ["/abs/path/guard.sh"], "timeout_ms": 5000, "on_timeout": "deny" }
],
"AfterToolUse": [
{ "command": ["./notify.sh"] }
]
}
- The hook reads the event JSON on stdin and writes
{"decision":"allow"|"deny","reason":"..."}on stdout. Any non-denydecision (or no output) continues normally. timeout_msbounds the run (default 5000); on timeout/crash/garbage theon_timeoutfail-safe applies (allow— default — ordeny). Hooks spawn as their own process group and are SIGKILLed as a group on timeout, so a hung hook can’t orphan a subprocess tree.- A hook at
BeforeToolUseadds a subprocess spawn to every tool call — opt-in latency; keep such hooks fast.
No telemetry
There is no telemetry and no phone-home. The only outbound network calls are:
- the LLM provider you configured,
- the
web_search/web_fetchtools (only when the model invokes them), and - the models.dev metadata refresh (cached, TTL’d, and offline-safe — explicit config always wins).
All three are toggleable/avoidable; nothing else leaves the machine.
Supply chain
Luxe is Rust with unsafe_code = "forbid" workspace-wide (no unsafe blocks), a lean dependency
tree, and a cargo-deny gate (RustSec advisories, license allow-list, banned/duplicate crates,
source registries) run in CI with --locked everywhere so Cargo.lock can’t silently drift.
CI GitHub Actions are pinned to commit SHAs (not moving tags), and Dependabot keeps both the
action pins and the Cargo dependencies current with vetted PRs.