# Security model OmaPreflight runs inside `omarchy-shell`. The marketplace is explicit about what that means: > The marketplace validates listings, not plugin security. Plugins run > unsandboxed. There is no sandbox, no permission prompt, and no privilege boundary between this plugin and the rest of the session. A defect here is a defect in the user's desktop. This document states what the plugin is allowed to do, what enforces each limit, and what remains a real risk after all of it. Every invariant below is enforced in code and re-checked by `scripts/check`, which fails the build when one regresses. A guard that only exists in prose is not a guard. --- ## What OmaPreflight is A read-only diagnostic. It answers "what would break if I update?" by asking the system questions and reporting the answers. It does not update anything, install anything, restore anything, downgrade anything, or contact the network. Everything it learns stays on the machine until the user chooses to share a report. There is exactly one thing it does that is not a read — a named, runtime Hyprland window rule so its own report window opens floating and centred. It is spelled out in full in invariant 11 rather than buried, because a document that claims "read-only" and then quietly does something else is worse than one that does not make the claim. ## Trust boundaries The optional terminal companion adds an explicit update entry point outside the diagnostic service. Its IPC, state, native update lock, and final updater handoff are specified in [ADR-008](adr/ADR-008-optional-update-gate.md). The invariants below describe the QML diagnostic service; the companion never runs package commands itself and delegates updates only on an explicit invocation. Four kinds of input cross into the plugin. Each is treated as untrusted. | Source | Why it is untrusted | Handled by | |---|---|---| | **Command output** — `omarchy`, `hyprctl`, `pacman`, `systemctl`, `df`, `git` | Another program's stdout is data, and a future version can change its shape without warning | `parsers/*.js`, all parsing behind `parsers/Json.js` | | **File contents** — `shell.json`, the plugin's own baseline | User-editable, and may be malformed, mid-write, or not a regular file at all | `core/FileReader.qml` + `core/ReadPolicy.js` + parsers | | **Names and paths from disk** — plugin ids, plugin directories | Anyone who can create a directory chooses its name | `core/CommandRunner.qml` data-argument rules | | **IPC calls** — `omarchy-shell p134c0d3.omapreflight …` | Any process running as the user can call it | `Service.qml` — fixed scan/report/baseline actions; optional scan IDs are compared as data and never executed | The plugin's *own* source, its check definitions, and the literal argv it builds are trusted. That is the line: anything OmaPreflight wrote is trusted, anything it read is not. --- ## Enforced invariants ### 1. No shell, ever — CWE-78 Commands are argv arrays. There is no `sh -c`, no string concatenation into a command, and no place where a shell could re-parse anything. - Enforced in `core/CommandRunner.qml` (`shellBinaries` refusal list). - Guarded by `scripts/check`: `no shell command construction`. This makes classic OS command injection structurally impossible rather than merely avoided. There is no quoting to get wrong because nothing is quoted. ### 2. Untrusted arguments are declared and validated — CWE-88, CWE-22 Removing the shell does not remove *argument* injection. A value the plugin did not author becomes an option the moment it starts with a dash — `--upload-pack=…` passed where a repository path was expected needs no metacharacters at all. Any argv element sourced from outside the plugin must be declared: ```js ctx.exec(["git", "-C", dir, "status", "--porcelain=v1"], { dataArgs: [2], allowedRoots: [ctx.paths.pluginsDir] }, cb) ``` A declared data argument must: - not be empty; - not begin with `-`; - contain no control characters (including NUL); - and, if it looks like a path: be absolute, contain no `..` segment, and sit inside one of `allowedRoots`. Root matching is prefix matching **on a segment boundary**, so an allowlisted `…/omarchy/plugins` is not satisfied by `…/omarchy/plugins-evil`. Traversal detection is segment-wise, so a directory legitimately named `..config` is not a false positive. Checks should also place `--` before positional data where the target program honours it. The leading-dash rule is what covers the programs that do not. The scheme is opt-in-by-declaration rather than inferred, so `grep -rn dataArgs` enumerates every point at which external input reaches a process. ### 3. One place starts a process, one place reads a file `Process` may only be instantiated in `core/CommandJob.qml`. `FileView` may only be instantiated in `core/FileReadJob.qml` (reads) and `core/FileWriteJob.qml` (atomic writes). Both rules are checked structurally by `scripts/check`, which fails if a site appears anywhere else. This is what makes the rules above meaningful. A validation routine that can be bypassed by declaring a `Process` somewhere else is documentation, not security. ### 4. Never privileged — CWE-269 `sudo`, `pkexec`, `doas`, `su`, `run0` and `machinectl` are refused by name before a process is created. A check that would need privilege returns `SKIPPED` and documents the manual command for the user to run themselves. This is why `recovery.snapshot-capability` reports snapshots as a mechanism that exists rather than one OmaPreflight can use: `omarchy snapshot` advertises `requires_sudo: true`, so the capability registry marks the route present and the capability unavailable. ### 5. Reads are narrow and never recursive — CWE-22 `FileReader` carries an explicit allowlist of directory prefixes: the Omarchy config directory and OmaPreflight's own state directory. There is no recursive mode and no API to add one at runtime. `$HOME` is never walked. Two files are ever read for content — `~/.config/omarchy/shell.json` and the plugin's own `baseline.json`. The Hyprland config directory is deliberately *not* on the list: those files are only measured (`stat`, `sha256sum` — size and hash, never contents, §17.3), so read access to them would be wider than the catalog needs. One directory is *listed* rather than read: `~/.config/omarchy/plugins/`, to find plugins the shell silently refused. The listing is one level deep and the bound is in the argv itself — `find -mindepth 1 -maxdepth 1 -type d` — so "this is not a recursive scan" is visible in the command rather than resting on a flag the reader has to know the meaning of. Names that come back are gated by the plugin-id pattern before becoming a path. ### 5a. A read is opened no-follow, type-checked and byte-bounded — CWE-59, CWE-770 An allowlist of *names* cannot say what is at a name. A path inside a directory the user owns can be replaced with a symlink to `~/.ssh/id_ed25519`, with a FIFO that never returns a byte, or with a file that grew to a gigabyte since the last scan. Quickshell's `FileView` — which the read path used until v0.1.1 — exposes no open flags, no file-type check and no size cap, so none of those could be closed at that layer. Reads therefore go through the command path, in two steps (`core/ReadPolicy.js`): | Step | Command | What it settles | |---|---|---| | 1 | `stat -c '%F\|%s' -- ` | Type and size. No `-L`, so a symlink reports as a symlink rather than as its target. A non-regular or oversized path is refused before a byte is read. | | 2 | `dd if= iflag=nofollow,nonblock,fullblock,count_bytes bs=65536 count=262145 status=none` | The read itself. `O_NOFOLLOW`, `O_NONBLOCK`, and a byte ceiling — enforced by the kernel and by the reader, not by trusting step 1. | Step 1 is the diagnosis: it produces the sentence the user reads ("path is a symbolic link, not a regular file"). Step 2 is the enforcement: if the path is swapped between the two steps, `O_NOFOLLOW` fails the open with `ELOOP` rather than following the link, and `O_NONBLOCK` means a substituted FIFO cannot hang the scan. Neither step is sufficient alone, which is why both are there — there is no window in which a swapped-in symlink is followed, because the refusal happens inside the syscall. The ceiling is 256 KiB, the same bound `CommandJob` puts on stdout. The read asks for one byte past it, so "this file is too big" is observed rather than inferred from a length that happened to land on the limit; a file that grew after being measured is refused rather than half-parsed. `dd` and `stat` are coreutils, alongside the `df` and `sha256sum` the plugin already requires. Nothing is bundled and no interpreter is involved. - Enforced in `core/ReadPolicy.js` (the policy and the argv) and `core/FileReader.qml` (the sequencing). - Guarded by `scripts/check`: `FileView` may now only be instantiated in `core/FileWriteJob.qml`, the read command may only be built in `core/ReadPolicy.js`, and the open flags themselves are asserted. - Specified by `tests/tst_ReadPolicy.qml`. This invariant exists because the marketplace security review for the v0.1.0 submission found the earlier `FileView.text()` read unbounded and un-type-checked. See [ADR-006](adr/ADR-006-reads-go-through-the-command-path.md). ### 6. Bounded work — CWE-770 Unbounded resource use inside a shell process is a desktop that stops repainting, so every axis is capped: | Axis | Limit | Where | |---|---|---| | stdout per command | 256 KiB, enforced while the stream arrives | `CommandJob` | | stderr per command | 64 KiB | `CommandJob` | | Bytes per file read | 256 KiB, refused at `stat` and again by `dd`'s byte count | `ReadPolicy` | | Command lifetime | per-command timeout, then SIGTERM → SIGKILL → abandon | `CommandJob` | | Check lifetime | per-check watchdog beyond the command budget | `CheckEngine` | | Scan lifetime | 120 s ceiling; the scan then reports itself incomplete | `CheckEngine` | | Concurrency | exactly one process at a time | `CommandRunner` | | Overlapping scans | refused, not queued | `CheckEngine` | The output caps are enforced *during* collection, not after, so a runaway command is stopped rather than buffered. ### 7. No dynamic code — CWE-94 No `eval`, no `new Function`, no `Qt.createQmlObject`, no `Qt.include`. Every string the plugin holds came from another program's output, and none of it is ever executed. Guarded by `scripts/check`. `JSON.parse` is the only deserializer used. It cannot construct objects or invoke code (unlike, say, a language-level unpickler), and its output is treated as an untyped bag: parsers read fields defensively and coerce types rather than assuming a shape (CWE-502 by construction). ### 8. No network — and nothing to exfiltrate through The MVP makes no network requests of any kind. `git` is only ever invoked with local, read-only subcommands; nothing fetches, and no remote is contacted. Guarded by `scripts/check`. ### 9. Errors are results, not exceptions — CWE-703 Every failure path produces a check result. Parser exceptions are contained, command failures become `UNKNOWN`, missing capabilities become `SKIPPED`, and a scan that did not complete yields readiness `UNKNOWN` rather than a partial verdict presented as a full one. A plugin that throws inside the shell is a plugin that can take the desktop down with it. ### 10. Writes stay in one directory The only writable location is `${XDG_STATE_HOME:-~/.local/state}/omapreflight/`. Nothing is written into the plugin checkout, nothing under `/usr/share/omarchy` is touched, and no configuration file is ever modified — `shell.json` and the Hyprland configs are opened read-only. ### 11. One action is not a read, and it is bounded The diagnostic surface is a real window (ADR-005), which is what makes `SUPER`+drag move it like anything else on the desktop. A Wayland client cannot ask to be floating, so the service registers a Hyprland window rule once per shell session: ```lua hl.window_rule({ name = "omapreflight-window", match = { class = "^org.quickshell$", title = "^OmaPreflight$" }, float = true, center = true }) ``` handed to `hyprctl eval`, because Omarchy configures Hyprland through the Lua parser and `hyprctl keyword` refuses to work with it. Passing a string to another process to evaluate deserves scrutiny, so: - **the string is a literal.** Nothing from the environment, a file, or another command's output is interpolated into it. There is no input to inject. This is the property that makes it defensible, and it is asserted at the point the string is built in `Service.qml`; - **it is scoped** by class *and* title to this plugin's own window; - **it is named**, so re-registering replaces the rule rather than accumulating rules; - **it is runtime-only** — no file is written, no user configuration is touched, and the rule disappears when the compositor restarts; - **it is optional.** If it fails, the window still opens and works, tiled rather than floating, and the reason is logged once. If a future change ever needs to interpolate a value into that string, it should not. Add a check that refuses instead. --- ## Information exposure — CWE-200 Two paths carry collected data outward, and they are the ones worth thinking hardest about. **Diagnostic reports.** A report is written locally and shared only if the user chooses to. It is sanitized first — home paths, hostnames, usernames, addresses, and lines containing obvious secret-shaped key names — and it carries a header saying to review it before posting. Sanitization is best-effort by nature and the report says so; claiming it is complete would be the actual security failure. **IPC.** `status` and `results` return what the scan collected to any process running as the same user. Quickshell's IPC socket has no authentication and cannot be given any. The mitigation is scope, not access control: everything returned is derived from commands and files that the same process could run and read directly. The IPC surface adds convenience, not privilege. ## Residual risks Stated plainly, because a security document that lists only solved problems is not describing reality. - **Paths are not canonicalized.** There is still no `realpath` available to QML, so the allowlist match is on the path as written. What that no longer buys an attacker is the read itself: a symlink at an allowlisted path is refused by `stat` and, if it appears after that, by `O_NOFOLLOW` at the open (invariant 5a). A symlink in a *parent* directory of an allowlisted path is still followed — closing that would need canonicalization QML cannot do, and it requires write access to `~/.config/omarchy` or `~/.local/state`, which is already enough to change what the plugin reads by simply editing the file. - **`PATH` is inherited.** Binaries are resolved through the session's `PATH` rather than pinned to absolute paths. This is deliberate: a diagnostic tool must report on the installation the user actually has, including one placed by `omarchy dev link`. An attacker who can write to a directory on the user's `PATH` already has code execution as that user, so pinning would buy little and cost correctness. - **Environment is inherited.** Children inherit the session environment plus `LC_ALL=C`. `LD_PRELOAD` and friends are not stripped; if they are hostile, the session is already compromised. - **Sanitization is heuristic.** It catches the shapes it knows. Review before posting. - **TOCTOU.** State can change between a check reading it and the user acting on the report. This is inherent to diagnostics and is why every finding carries a timestamp and its evidence. ## Reviewing a change The questions worth asking, in order: 1. Does it introduce a `Process` outside `core/CommandJob.qml`, or a `FileView` outside `core/FileWriteJob.qml`? 2. Does any argv element come from command output, a file, or a directory listing? If so, is it declared in `dataArgs` with the right `allowedRoots`? 3. Does it widen `FileReader.allowedPrefixes`, and does it need to? 4. Does it read a file any way other than through `FileReader` — that is, without the no-follow open, the type check and the byte ceiling? 5. Can it produce unbounded output, an unbounded loop, or an unbounded number of commands? 6. Does every new failure path end in a check result? 7. Does anything new reach the report, and is it sanitized? `scripts/check` answers 1 mechanically and part of 4. The rest need eyes. ## Reporting a vulnerability See [SECURITY.md](../SECURITY.md). ## References The rules above are the local application of general guidance, not invented here: - [OWASP OS Command Injection Defense Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/OS_Command_Injection_Defense_Cheat_Sheet.html) — argv separation, allowlisting, the `--` separator. - [2025 CWE Top 25](https://cwe.mitre.org/data/definitions/1435.html) — CWE-22 path traversal, CWE-78/77 command injection, CWE-94 code injection, CWE-200 information exposure, CWE-502 untrusted deserialization, CWE-770 unbounded resources. - [OWASP Path Traversal](https://owasp.org/www-community/attacks/Path_Traversal) — normalize, then validate against known-good roots. - [OpenSSF argument injection lab](https://best.openssf.org/labs/argument-injection.html) — leading-dash rejection and the `--` separator. - [Omarchy plugin publishing requirements](https://omarchyplugins.com/publish) — the unsandboxed-execution statement this document responds to.