pacman-rc.conf shipped with [omarchy] on pkgs.omarchy.org/edge — a
leftover from when release candidates published there. Candidates now
publish to a dedicated rc channel, so a machine switched to rc with
omarchy-refresh-pacman was pairing the rc Arch mirror with edge omarchy
packages, and omarchy-version-channel could not name the rc repository
at all (an rc install reported 'rc / unknown').
Point the conf at pkgs.omarchy.org/rc, teach omarchy-version-channel
the rc repository, and repoint existing rc-channel machines with a
migration. The migration only rewrites the shipped pairing (rc mirror +
edge [omarchy]); an administrator's deliberate combination is kept.
Apply the Omabot patch on Quattro, verify effective SSH hardening, prevent stored provisioning state from restoring the blanket input-group grant, and stop Omarchy from shipping asdcontrol authorization that belongs to the package.
Co-authored-by: David Heinemeier Hansson <david@hey.com>
* Quote install-app and install-font names like install-and-launch
* Quote the package list too, not just the display name
The display name was quoted but omarchy-pkg-add's own arguments were still interpolated into the bash -c string raw, so `omarchy install app Vim 'vim; id'` ran id. The list has to reach the helper as several words, so it cannot be quoted whole: it is split the way the unquoted expansion split it and each word is quoted on its own. Reading with -d '' keeps a newline-separated list intact instead of dropping every package after the first, which plain read -a would. install-font's package is singular and is quoted whole, and install-and-launch carried the same flaw.
Reported by acrogenesis in review of #7843.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>
* Test that install-font skips font-set when pkg-add fails
The hostile-package case was asserting the family still got set, which only held because the mock always exits 0. pacman would reject that name and the && chain would skip font-set.
* Keep the installers working when errexit is inherited
read -d '' always ends at EOF rather than on its delimiter, so it reports failure on every input. Under an inherited errexit the installers exited there and built no command at all.
Reported by Codex XHigh in review of #7843.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>
---------
Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Codex XHigh <noreply@openai.com>
install/post-install/first-run-mode.sh shipped on quattro between 53e26115 and 75cb4f71, and its final body writes `Cmnd_Alias FIRST_RUN_CLEANUP = /usr/bin/rm -f /etc/sudoers.d/first-run, /bin/rm -f /etc/sudoers.d/first-run`. The predicate's case listed only the two `/bin/rm` spellings, so that line fell through to the user-spec test, failed it, and the whole file read as hand-written. The migration then left it alone and wrote its machine marker, which is permanent: on an offline install from that window the account keeps passwordless `/usr/bin/systemctl` for good, and nothing looks at the file again.
Adding the string is the whole fix. The test now carries all nine bodies the installer wrote across both locations rather than the eight from install/preflight.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <codex@openai.com>
`>|` is a plain redirect with noclobber overridden, not a redirect followed by a pipe. command_destinations detached `>` from its target before looking at the bar, so the target read as `|` and the privileged path behind it was never examined: `cat <<EOF >| /etc/udev/rules.d/99-x.rules` with `$HOME` in the body produced no finding at all, while the same write through `>` produced one.
Normalizing `>|` to `>` alongside the existing `>>` handling closes it. The fixture fails without the normalization.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test/shell.d/config-test.sh greps every file under migrations/ for `upgrade-to-quattro` and fails the suite when one matches, because pre-4 layout work belongs in the upgrade command rather than in a migration. The comment explaining why this particular cleanup is the exception named that command literally, so it matched the guard and config-test.sh failed on this branch while passing on quattro.
The comment now names the Omarchy 4 upgrade command without spelling the file, which leaves the guard able to catch a migration that actually reaches for it. agents/skills/migrations.md still names `bin/omarchy-upgrade-to-quattro` in full, and it is not under migrations/.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The failure advice printed a ready-to-paste annotation with the scan's
verdict already filled in, so the shortest way past the check was to copy
back what it had just concluded. That is worst exactly where the scan is
weakest: a path it cannot follow through a variable reads as an ordinary
value, and the annotation it offers for that case is paths=none.
Print the annotation with the path list left blank and say why the author
has to fill it in. The scan's own reading stays in the report above it, so
nothing diagnostic is lost.
Running the migration on a real machine with no cached sudo credentials
printed sudo's "a terminal is required to read the password" and still
exited 0. bin/omarchy-migrate writes the completion marker on a zero exit,
so the cleanup would have been recorded as done on every install that runs
migrations without a terminal, and never tried again.
Probe for elevation before the combined existence check and exit non-zero
when it fails, so the marker stays unwritten and the next run retries. The
probe is skipped when the directory is readable as-is, which is the case
when migrations run as root.
Review of the previous commits turned up four places where the predicates
and their tests disagreed with the tools they are modelling, each checked
against udevadm verify, systemd-analyze verify and visudo -cf rather than
against reading of the sources.
An empty ExecStop= resets the list, so a unit an administrator neutralised
that way runs nothing at shutdown and is no longer ours to remove; the
predicate now tracks the last state instead of returning on the first home
path it sees. A file whose last line ends in a backslash still carries a
live directive for systemd, so the pending logical line is emitted at EOF;
udev ignores such a line and sudo rejects the file outright, so this costs
those two nothing. The scanner's taint pass now reads += appends, which its
own comment already promised: the value of an append is no use, but a name
that reaches a user root through one has to be judged on it.
Two regression guards passed against the implementations they were written
for. The udev continuation fixture put the whole RUN+= below the comment, so
it matched whether or not the pending half was carried across; the split now
falls inside the RUN+= value. The sudoers one kept its file on the strength
of a spec above the comment, so it could not fail either; the hand-written
spec now sits below. Both fail against a mutant that discards the pending
line. The comment above the second also claimed a continued comment stays a
comment, which visudo contradicts.
An installer that writes a root-owned file through a heredoc with an
unquoted delimiter (<<EOF rather than <<'EOF') has the installing user's
shell expand the body first, so a user-controlled value is baked in as a
literal. Send that into /etc and root later reads or executes a path the
unprivileged user picked: a udev rule carrying
$HOME/.local/share/omarchy/bin/... resolves through a symlink that user
owns, so replacing the symlink gets their code run as root.
Add the static check. A heredoc is flagged when its delimiter is
unquoted, its body contains an install-time expansion (escaped \$VAR does
not count, since that is left for a root daemon to expand at runtime),
and its output reaches /etc, /usr, /opt, /srv, /boot or /var/lib via sudo
tee, sudo dd, a redirect, or an install/cp/mv of the generated scratch
file. Destinations written as variables are resolved from the file's own
assignments.
Sites that genuinely need install-time expansion declare it inline:
# omarchy:heredoc-expands paths=none -- $servers is a validated IP list
paths= is machine-checked against the expansions the scanner finds to be
path-shaped, so this cannot become a rubber stamp: adding a $HOME/... to
an already-annotated heredoc makes the declaration false and trips the
check again. Path expansions anchored under a root-owned prefix, as in
"/etc/systemd/system/$unit", are correctly not path-shaped.
Annotate the sites the scan reports, each of which expands a scalar: DNS
addresses in omarchy-dns, a literal PAM line in
omarchy-setup-security-fingerprint, kernel cmdline parameters and
usernames in omarchy-upgrade-to-quattro. omarchy-provision-owner expanded
a unit name that was already a constant, so its delimiter is now quoted
and the name hardcoded; the generated unit file is byte-identical.
omarchy-windows-vm declares paths=storage,shared, the only site that
interpolates a user-chosen path.
Fixtures prove non-vacuity in both directions: the write routes other
than a pipe into sudo tee, the shapes that must stay quiet, udev rules
and a shutdown unit taken verbatim from this repository's history, and
the rubber-stamp case where a paths=none annotation on a baked $HOME path
still fails.
Three installers that no longer exist each left a root-owned file on
disk, and nothing in Omarchy has ever removed any of them.
/etc/sudoers.d/first-run granted the installing account passwordless
sudo for the rest of the first boot, unrestricted /usr/bin/systemctl
included from 2025-10-14 on. omarchy-first-run clears its first-run.mode
guard before eight set -e steps and only deletes the grant after them,
so any failure in between strands it with nothing left to retry.
/etc/sudoers.d/tsui named whatever $(which tsui) resolved to for the
installing user, normally a binary under their own home that the vendor
script had just written without sudo.
/etc/systemd/system/omarchy-plymouth-shutdown.service ran an ExecStop
under the installing user's home as uid 0 on every shutdown.
Each file is judged against what the installer that wrote it actually
produced. The first-run grant was rewritten eight times and only the
last four carry both Cmnd_Alias lines, so rather than key on those, every
active line must be one the installer emitted and one of them must be
its own self-cleanup. The shutdown unit is disabled but never stopped:
stopping it is what would run the ExecStop being taken away.
Generalize the migrations.md exception, which framed itself around pre-4
layout transitions and so did not cover installers retired on their own.
Omarchy 3 wrote 99-power-profile.rules and 99-wifi-powersave.rules with
an unquoted heredoc, baking the installing user's home into a rule udev
runs as root. That path resolves through ~/.local/share/omarchy, a
symlink the unprivileged user owns, so replacing it and provoking a
power_supply event runs their code as root. HEAD points the rules at
/usr/bin under new names, but the one-shot cleanup for the old
filenames was dropped, leaving the file on every install that came up
through the 3.x line.
Remove a legacy file only when an active RUN+= really does run that
filename's binary out of a home directory, so a rule of the same name a
user wrote themselves stays, comments and all.
The upgrade command only runs on a machine still crossing 3 to 4, so a
vulnerable file an old installer wrote never gets swept on an install
that crossed already. It ends by running omarchy-migrate, so a single
migration reaches both populations.
Validating /etc/omarchy.conf walks its parent chain to /, and that walk leaves
its own subject behind in the global failure_context. The comparison that
follows -- the one deciding whether the authorization actually names this
checkout -- sets no context of its own, so an unauthorized development tree
refused with:
refusing to publish: directory / (must be root-owned and not group- or
world-writable) failed validation
naming a directory that had just passed, and pointing the reader at a
filesystem problem that is not there. Seen on a worker VM running an
unauthorized checkout, where / is 0555 root-owned.
The hint line underneath was already right, which is what kept this from being
worse. Restore the context before the comparison so the first line agrees with
it, and assert both halves: that the refusal names the authorization, and that
it does not blame the root directory.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0115LngksSpXLD9NSXBEP3ki
Quattro stopped making the Chromium managed-policy directory world-writable while this branch was open, and the block it deleted from the theme install leaf sat directly above the comment this branch rewrites, so the two edits landed in one hunk. The resolution keeps the hardening — the policy directory is set up through install/config/browser-policy.sh now — along with the first-run seed and the comment that names both things the seed does.
omarchy-dev-link told people /usr/share/plymouth was not covered by a link
and to reach for omarchy-dev-pkg-test. Since the publisher authorizes a
dev-linked checkout out of the root-owned /etc/omarchy.conf, the Plymouth and
SDDM themes are republished from the checkout like every other
$OMARCHY_PATH-resolved tree, so the help was left describing the old
behaviour.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Checking a queue for jobs and then deleting it leaves a window in between, and the sudo that does the deleting can sit at a password prompt for as long as someone takes to type. A job submitted in that window was cancelled by a deletion that had already decided the queue was empty. cupsreject closes the queue to new work first, which also stops more jobs piling onto one that is being left behind and can no longer route them. It comes from the cups package, which stays.
What a queue with jobs on it means is now said rather than implied: whatever is already at the printer finishes, whatever is still waiting cannot be routed with the daemon gone, and the person who owns them has to cancel what is left.
A queue that another administrator removed while this was running is the outcome wanted, not a failure to keep the package installed for, so a deletion that fails is checked against whether the destination is still there.
Co-Authored-By: Codex XHigh <noreply@openai.com>
Mutating each root-side control one at a time showed three that no test could
see: a destination directory root does not own, a single user-owned asset
inside an otherwise root-owned packaged directory, and an asset left group- or
world-writable by its own mode. Deleting any of the three left the suite green,
because the existing cases mark a whole tree untrusted and are caught by the
directory check before the per-file one is reached.
The harness already had the hook for the ownership pair: TEST_UNTRUSTED_SOURCE
makes the stat shim report a chosen prefix as uid 1000, so those two only need
it pointed at a destination directory and at a single file rather than at a
whole tree. A mode has to be real, so that case stages a copy of the packaged
tree the shim reports as root-owned and loosens one asset in it.
The empty logo is refused by the destination size bound rather than the
caller-side one, so that case pins the behaviour without isolating the check;
the two bounds are exactly redundant.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0115LngksSpXLD9NSXBEP3ki
cups-browsed is the daemon that watches the network and creates print queues by itself. Hardening it took a root daemon with a predictable cache down to a confined service account, but a daemon that turns anything advertising itself on the network into a print queue is a lot of exposure for a convenience, so it comes out of the default install while that is reworked. Only the discovery half: CUPS itself stays and printing keeps working, with each printer added by hand in Print Settings.
The migration disables the unit before removing the package because that is the only order that works: pacman deletes the unit file but not the enable symlink, and once the unit is gone systemd can no longer resolve it by name to clean that up.
It then removes the queues discovery generated. cups-browsed keeps those when it stops, since KeepGeneratedQueuesOnShutdown defaults to Yes, and they route through its own implicitclass backend, which goes with the package, so they cannot print again. Idle ones go. A queue with jobs on it is left alone and named: implicitclass only needs cups-browsed to choose a destination, so a job already past that point finishes on its own, and deleting the queue would abort it. One printer's job does not hold up the removal. A printer added by hand has an ipp:// or usb:// device and is left where it is.
A queue whose jobs cannot be asked about is left alone rather than assumed idle, including one named so that lpstat would misread it -- "all" is its word for every destination, and a leading dash or a comma reads as another option or a list.
Where CUPS does not answer at all, or a queue will not delete, discovery is still stopped but the package stays and no marker is written. omarchy-migrate records a migration for the user as soon as it exits zero, so that is where the machine stays until someone removes the package by hand, and the message says so rather than implying a retry.
The queue list is read under LC_ALL=C because lpstat translates "device for", and captured rather than piped, so a cupsd it cannot reach is reported instead of reading like a machine with nothing to clean up.
It removes with plain pacman -R rather than omarchy-pkg-drop, which passes -n and would discard /etc/cups/cups-browsed.conf instead of keeping it as a .pacsave. A removal meant to be temporary should not delete the machine's copy of its own configuration. Without -s either, so it only ever removes the package it names: sweeping newly unneeded dependencies is nothing today, but it is not a promise a rolling dependency graph can keep.
Queue names come off the network, since cups-browsed names its queues after what the printer advertised. CUPS allows every printable character but space, tab, / and #, and lpstat and lpadmin take a destination as an option value, so a name with a leading dash or a comma is reported rather than passed to them and guessed at.
Migration state is per user, so a machine-wide marker records the one removal. Without it, an account whose first migration run came after someone deliberately reinstalled discovery would quietly take it back out again.
The install-time override for cups-browsed.conf now waits for cups-browsed rather than for CUPS. Guarding it on a file CUPS still ships would write a configuration file for a package nothing installed, and pacman would later land the package's own copy beside it as a .pacnew.
The hardened configuration stays in the tree. omarchy-settings still ships the cups-browsed.conf override, the sysusers account and the service drop-in, so they are what discovery returns onto.
Co-Authored-By: Codex XHigh <noreply@openai.com>
The comment above the seed was the only thing recording that color_scheme and color_scheme2 are both zero in order to follow system appearance rather than force dark. Generalizing it to "first-run defaults" left two magic numbers with nothing to explain them, so the next person touching an unrelated first-run setting has no way to tell that changing them regresses theme following. Name both things the seed does.
Co-Authored-By: Codex XHigh <noreply@openai.com>
Chromium 151 flipped MasterPrefs::eula_required from false to true, so a first run with no seed now stops on a blank terms-of-service dialog before the browser opens. The opt-out is written in two places that each run exactly once: the install leaf that runs during ISO finalization, and the one-time 3.x upgrade. A machine already on Quattro runs neither again, so it keeps the old seed and still meets the dialog the first time anyone launches Chromium, including from every user account created after the install.
The migration writes the same seed those two paths write. It compares before writing so the second user on a shared machine no-ops rather than repeating a machine-wide repair, and the literal is duplicated rather than sourced because a migration repairs the state of its own moment and must not drift when the seed later changes.
Co-Authored-By: Codex XHigh <noreply@openai.com>
Deleting the whole ancestor walk from validate_trusted_directory and checking
only the immediate parent left the suite green, so the invariant the design
rests on had no coverage: a writable ancestor lets an attacker swap a validated
directory out from under the leaf. Reject a run whose destination grandparent
is world-writable while the destination itself is pristine.
The harness also rewrites the root script's fixed /usr/share paths by string
substitution. A drifted string silently no-ops that rewrite and would point the
simulation at the real system tree, so assert each substitution landed. Assert
the scratch directory exists too: a failed mktemp left it empty and the suite
carried on building paths from it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>
Every check inside the root shell is a bare [[ ]] or (( )) assertion that
aborts under set -e, so a refusal exited with status 1 and no output at all.
The floating-terminal wrapper then printed its green "Done!" for any status
but 130, so a failed boot-theme change read as a success.
The refusal a working machine actually hits is omarchy dev link, which points
OMARCHY_PATH at a checkout the desktop user owns. Name that case outright and
point at omarchy dev unlink; report every other rejection through an ERR trap
that names what failed validation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>