Luxe

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.example is 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 to ask.
  • Runaway loop (doom_loop): the same tool call repeated 3× in a row defaults to ask.
  • 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 ask first: git push --force, sudo ….

Modes

Pick a posture with --permission-mode <mode> (or the shortcuts):

ModeBehavior
defaultPermissive + the guardrails above.
strictAsk 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 covers writeread, glob, grep, git, webfetch, websearch, mcp, …) plus the guards external_directory and doom_loop. An mcp call is matched against its server/name target.
  • Patterns: * matches any run, ? one char; a trailing * also matches the bare prefix (git * matches git and git status). bash patterns match the parsed command (head + args).
  • Home expansion: ~ / $HOME at the start of a pattern expand to your home directory (useful for external_directory allow-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.json so 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-key CLI flag → environment (LUXE_API_KEY, then the provider’s own variable) → the credential stored in ~/.config/luxe/auth.json → a provider block in auth.json / models.json.

  • Auth file lives at ~/.config/luxe/auth.json, mode 0600. Luxe warns at startup if auth.json/models.json is group/world-readable, and writes its own entries 0600 with 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_model keep 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 state the loopback listener validates before any code is exchanged, or RFC 8628 device codes honoring interval/slow_down/expiry. The listener binds 127.0.0.1 only, 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 !command runs at startup, so treat it like a shell rc file.

  • In memory the resolved key is wrapped in an ApiKey newtype backed by secrecy::SecretString (zeroized on drop). Its Debug and Serialize both 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 stored 0600 with the same in-memory hygiene.

  • Redaction — a central layer (luxe-core::redact) scrubs common secret shapes (OpenAI/Anthropic/Google/Stripe keys, Bearer tokens, GitHub/Slack/AWS tokens, JWTs, private-key PEM blocks, user:pass@ URL credentials, and password=/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_fetch and the screenshot tool’s mode=url both 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=1 is an explicit, coarse opt-out for intentional internal fetches / local dev-server captures.
  • web_search talks 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 — luxe is a self-contained binary with no dynamic link to a system libssl. 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, the web_* tools, and the models.dev refresh.
  • Combine with --read-only for 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 mcp gateway tool (discover → describe → call), so a call goes through the permission engine keyed by its server/name target: 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-deny decision (or no output) continues normally.
  • timeout_ms bounds the run (default 5000); on timeout/crash/garbage the on_timeout fail-safe applies (allow — default — or deny). 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 BeforeToolUse adds 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:

  1. the LLM provider you configured,
  2. the web_search / web_fetch tools (only when the model invokes them), and
  3. 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.