Skip to content

Security: lnbits/peasy

Security

docs/security.md

Peasy security model

Peasy treats user text and every byte returned by OpenAI or Ollama as hostile. Model instructions improve usability; they are not a security boundary.

Trust boundaries

  • A person reviews every changing proposal. The daemon independently requires Polkit administrator authorization for each system apply.
  • The selected OpenAI or local Ollama model produces a closed typed value; it has no tools.
  • The Wasm engine validates typed decisions with zero ambient capability.
  • Unprivileged user programs perform confirmed NetworkManager, BlueZ, and calendar handoffs under the user's normal desktop authorization, and perform read-only GitHub release discovery for the external-AppImage fallback.
  • peasy-system performs only package search/verification, deterministic managed-module generation, fixed NixOS builds, and fixed activation.
  • The Nix daemon, systemd, Nixpkgs store path, and administrator-selected host configuration are trusted system components.

Model threat and data boundary

The model may hallucinate, be prompt-injected, or deliberately return a payload such as {"action":"shell","command":"cat /home/user/.ssh/id_ed25519"}. Strict JSON Schema and serde(deny_unknown_fields) reject undeclared fields. Unknown action names are rejected. Declared action fields are converted to bounded Rust strings, enums, dates, or integers before reaching the zero-import Wasm policy engine.

No action variant represents a command, executable, path, HTTP request, arbitrary Nix expression, service definition, or general configuration edit. The system-configuration pea is a bounded exception for reviewed setting names, not general configuration access: an install can include up to eight supporting packages, eight allowlisted Boolean enable options and four allowlisted supplementary groups. Wasm and the daemon validate the plan. The daemon binds groups to the Unix-socket caller's existing account and UID; neither the AI nor IPC can supply a target account. Group prerequisites and normal-account/UID checks apply, and the review warns about powerful libvirt access. No arbitrary service bodies, listeners, firewall/polkit/sudo settings, mkForce, scripts or account creation are exposed. See the exact catalogue and ownership rules.

The model cannot initiate an arbitrary HTTP request: trusted client code alone constructs fixed api.github.com repository/release requests after a package search intent.

The provider request is assembled from a new JSON value. It may contain:

  • the current request, after a local credential-input guard;
  • current local date/time;
  • Peasy's canonical generated managed module, containing only validated package, pinned AppImage, appearance and setup state (including the locally bound account name/UID for managed group contributions);
  • a bounded, allowlisted profile generated from the evaluated active NixOS configuration: release/platform tokens, closed desktop and Peasy-variant enums, and package names only;
  • validated package candidates needed for the current request; and
  • at most one recent validated package for a follow-up such as “install it”.

Peasy does not automatically attach administrator-authored NixOS source, arbitrary Nix option values, files, environment variables, process information, logs, nearby Wi-Fi scan results, Bluetooth addresses, calendar files, or credentials. The only Nix source supplied is Peasy's own canonical generated module, whose value types are closed and validated. Free-form request text is sent to the selected model: do not paste secrets into it. Credential detection is a guardrail, not a guarantee for arbitrary text. The stored OpenAI API key is used only as the HTTPS Authorization header. The Ollama request has no key and is restricted to a loopback HTTP origin; the model cannot choose that origin or any request path. Redirects are disabled; Ollama also ignores proxy environment variables to keep loopback traffic local.

Credentials

The OpenAI key is stored per user at $XDG_CONFIG_HOME/peasy/openai-key (or ~/.config/peasy/openai-key) with mode 0600; its parent directory is 0700. Peasy opens keys without following symlinks, checks ownership and permissions on the open file, bounds reads, and uses create-new plus atomic rename for writes. Provider debug output redacts keys; the active OpenAI client clears its key buffer on drop and refuses requests containing the exact stored key. Keys are not sent over system IPC or placed in Nix configuration. File permissions do not protect against root or a compromised process running as the same user. The selected provider and model are stored separately in provider.json, also mode 0600 with symlinks and unknown fields rejected. Replacing or removing a key is an explicit user action in the settings view.

For Wi-Fi, enter the network name in the request and the password only in the separate local review field. Credential-looking inline requests are rejected before the provider is called, rather than guessing secret boundaries. The separately entered value is passed to nmcli --ask through stdin rather than argv. It is never included in the preview, panel status, system IPC, or persistent Peasy state.

Validation and process execution

Package attribute paths are length-bounded dot-separated segments containing only ASCII letters, digits, _, +, and -. Empty/traversal segments and shell punctuation are rejected. The daemon still evaluates the attribute against its pinned Nixpkgs source before proposing it.

SSID, Bluetooth query, calendar title, local timestamp, duration, theme colour, and colour scheme each have dedicated validators. Wi-Fi names must match a real NetworkManager scan entry. Bluetooth names must resolve to a discovered device whose address has the fixed six-byte hexadecimal form. Calendar timestamps must be real local Gregorian date/time values and durations are 5–1440 minutes.

Native code launches fixed absolute executables with separate argv values. It never calls sh -c or bash -c. Nix search text is regex-escaped. Model output cannot choose an executable or supply an argv sequence.

External AppImage discovery is not a trust decision. Peasy accepts only public GitHub release assets reached through its fixed API client. It filters out drafts, prereleases, forks, archived repositories, incompatible architectures, non-AppImage names, zero-sized assets, and assets above 1 GiB. The typed system record requires an https://github.com/OWNER/REPOSITORY/releases/download/... URL matching its repository, a closed architecture enum, bounded display metadata, and a SHA-256 SRI hash. A mutable or replaced release asset therefore fails the Nix fixed-output hash instead of silently changing. A computed download hash alone does not authenticate a publisher. By default, external installs use source review followed by administrator authentication, without prior hash approval. The user accepts the risk of running third-party native software; Peasy does not establish its safety or publisher authenticity. The root-owned policy generated by services.peasy.appImages.trustedHashes is null for this mode. An optional map enforces exact repository/hash approval at both proposal and Apply; an empty map disables new external installs. Missing or malformed policy files do not enable the review-only mode. Administrators using an allowlist should verify hashes through an independent trusted publisher channel. Removal remains available even if approval is withdrawn. All generated Nix string literals escape interpolation, including release metadata and embedded state JSON. Peasy never runs repository install scripts or executes an AppImage while discovering it.

Confirmation boundaries

System IPC is mode 0660 and group-owned by wheel. Read-only search has no system side effect. Propose verifies the change and records a random token, typed change, complete base state, peer UID, and five-minute expiry. Apply must present the token from the same UID. Tokens are one-use; a proposal is rejected if state changed after its diff was displayed. Wrong-user attempts cannot consume another user's token. Apply also checks Polkit action io.github.peasy.apply, binding the peer UID, PID and process start time. The default policy uses auth_admin, not cached authorization; cancellation consumes the proposal and requires reviewing a new one. Administrator-written Polkit rules remain trusted.

Closing the UI cancels its HTTP requests and read-only subprocesses, discards unapplied proposals, and requests cancellation of an in-progress system change. The typed Cancel request requires the same proposal token and peer UID as Apply; it cannot cancel another user's work. Claimed proposals remain one-use while cancellation and cleanup are in progress. A disconnected IPC client also cancels its request. Before activation, cancellation stops the request's process group, restores the prior managed Nix state, removes its staging directory, and only then releases the apply lock. Cached Nix store outputs may remain reusable; cancellation does not run garbage collection or stop unrelated Nix builds.

Cancellation and activation compete in a single atomic state transition. Once activation wins, neither Cancel nor a client disconnect interrupts the switch. The UI waits for its result without reopening a closed window. Reviewed desktop mutations that have started also finish, since terminating a command cannot undo effects already applied. Stale search/AI results cannot replace a reopened prompt. Aborting an AI HTTP request closes the local transport; it does not guarantee that the provider stops server-side computation or billing. These native lifetime and process helpers are excluded from the Wasm build; they add no AI capabilities.

IPC is bounded to 16 active connections, four per UID, 120 requests per UID per minute, and 64 pending proposals (eight per UID). Expired proposals are pruned. Heavy Nix operations are serialized; concurrent attempts fail promptly as busy. Child execution has output/time limits. The service also has task, memory, swap, and CPU-weight limits; a cold Nixpkgs search can legitimately use several GiB.

Only the GTK Apply/Continue button or CLI affirmative response calls an apply method. Model providers and the Wasm guest cannot call local tools or IPC. Local actions have a separate typed preview and do not claim NixOS rollback semantics.

Wasm sandbox

The engine module must have no imports. The host checks that invariant before instantiation and supplies an empty linker. There are no preopened directories, WASI contexts, environment variables, sockets, clocks, or process interfaces. Tests instantiate a hostile module requesting filesystem, environment, socket, and process imports and prove linking fails.

System services

The main service runs as root because it stages a NixOS build. Its unit uses ProtectHome=tmpfs, ProtectSystem=strict, narrow ReadWritePaths, PrivateTmp, PrivateDevices, NoNewPrivileges, RestrictSUIDSGID, LockPersonality, kernel/control-group protections, restricted address families, IP denial, and a restrictive umask. All capabilities are dropped, dangerous syscall groups are denied, writable/executable mappings and core dumps are disabled. /home and /root are hidden. Process metadata remains visible for PID-bound authorization; the VM tests also probe proc-root escapes.

The service cannot contact a model provider or an Internet host. The separate trusted Nix daemon may fetch substitutes and a reviewed fixed-output GitHub release asset; its content must match the proposal's SHA-256 hash. The daemon cannot activate directly under ProtectSystem=strict, so activation uses a fixed one-shot service with no public IPC and no user-controlled arguments. NixOS activation necessarily needs full host filesystem, device, user-session, and kernel-setting access, so this helper is deliberately not filesystem/device sandboxed. It reads a private root-owned, size-bounded, single-use request containing only the already built store result, canonicalizes and validates that path, sets the system profile, and runs that result's switch-to-configuration switch. The main network-denied service remains sandboxed throughout package search and build.

The host configuration must be readable for evaluation. By default only /etc/nixos is exposed read-only. Administrator-listed configurationReadPaths become narrow read-only binds when a trusted local module is below a protected home. Store-backed modules need no exception.

Filesystem ownership

The root service writes only /run/peasy and the dedicated .peasy directory beside the trusted host configuration. Temporary expressions, proposal staging, and activation requests remain private under /run; the only durable file it can replace is .peasy/peasy-managed.nix. It cannot write configuration.nix, flake.lock, hardware configuration, or unrelated /etc files. The managed file is replaced atomically, restored after a failed build, and the built generation must contain its exact validated state before it can activate.

/etc/peasy/system-profile.json is generated by the NixOS module from the evaluated active configuration. Package derivations are reduced to names; no option values or source text are copied. The client accepts only a closed JSON shape, short ASCII version/platform/package tokens, known desktop/variant enums, at most 256 unique package names, and at most 64 KiB total input. Invalid fields or oversized data cause the declared profile to be ignored.

The settings export is a local operation and never enters a provider request. /etc/peasy/host-configuration-path contains the trusted absolute module path selected by the administrator. The UI copies its bounded configuration tree and the packaged Peasy source into a new mode-0700 directory, writing regular files mode 0600; unsafe absolute symlinks, special files, excessive entries, and trees over 64 MiB are rejected. The imported .peasy/peasy-managed.nix is already part of that tree; no second state export is synthesized. API keys and provider settings are excluded. Because administrator-authored Nix can contain secrets, users should still review an export before sharing it.

The GNOME extension is only a launcher for the fixed peasy-ui executable. It does not handle request text, proposal data, provider credentials, or Wi-Fi passwords. $XDG_RUNTIME_DIR/peasy-user, mode 0700, contains only the non-secret panel-ready marker and mode-0600 calendar files. The model and Wasm guest never receive these paths.

Live GNOME appearance synchronization is also unprivileged. The generated generation contains a non-secret JSON file with only AccentColor and ColorScheme enums. peasy --sync-theme deserializes it with unknown fields denied, checks both fixed GNOME keys are writable, and invokes the package's fixed gsettings executable with separate fixed argv fields. A model provider cannot choose an executable, schema, key, or free-form value. Theme keys are normal user preferences rather than security lockdown controls.

Hyprland integration is likewise unprivileged and talks only to the invoking user's compositor socket through that session's hyprctl. Model output is reduced to native enums before review. Peasy can emit only fixed setting paths, bounded scalar values, and a small harmless dispatcher set; it cannot forward raw hyprctl arguments or Lua, execute programs, manage plugins, kill processes, shut down the compositor, or create/remove outputs. Live Hyprland changes are labelled as session-only and are not represented as NixOS rollback-capable changes.

Rollback limits

NixOS generations and rollbacks protect Nixpkgs package, pinned AppImage, and theme system changes from failed evaluation/build and allow returning to an earlier generation. Activation itself, like nixos-rebuild switch, can partially change a running system before reporting failure; it is not an atomic undo of all services and external effects. Inspect the error and explicitly switch to a known-good generation if activation fails. Peasy has no persistent state database outside the imported managed Nix file. Rollbacks do not undo external side effects: Wi-Fi, Bluetooth, calendar, and live Hyprland changes therefore use their native controls rather than being described as Nix rollbacks. Peasy never automatically deletes old NixOS generations.

There aren't any published security advisories