Skip to content

Keep the update transcript out of world-writable /tmp - #12957

Closed
taufderl wants to merge 1 commit into
omacom:quattrofrom
taufderl:fix/update-log-not-in-tmp
Closed

taufderl wants to merge 1 commit into
omacom:quattrofrom
taufderl:fix/update-log-not-in-tmp

Conversation

@taufderl

Copy link
Copy Markdown

omarchy-update re-execs itself through script(1) into a hardcoded path, as the first
thing it does, and omarchy-update-analyze-logs reads the same path back:

exec env OMARCHY_UPDATE_LOGGED=1 script -qefc "$script_command" "/tmp/omarchy-update.log"

/tmp is world-writable and nothing checks who owns that file. script opens it with
O_CREAT, which fs.protected_regular=1 refuses when the file belongs to someone else. So
whoever owns the file decides who may run omarchy update.

This is not the same issue as #11630. That one exits 0 with no log created at all. This one
exits 1 with script: cannot open /tmp/omarchy-update.log: Permission denied, and needs the
file to already exist owned by another user.

Two ways it goes wrong

Another local user blocks everyone's updates. Verified on 4.0.4 in a VM:

mallory$ echo x > /tmp/omarchy-update.log; chmod 666 /tmp/omarchy-update.log

dev$ omarchy-update
script: cannot open /tmp/omarchy-update.log: Permission denied
$ echo $?
1

It aborts before the confirmation prompt and before any work. The sticky bit means the
victim cannot remove the file, and mallory can recreate it. The practical effect is that
the machine stops receiving updates.

One sudo omarchy update does the same thing with no attacker involved. There is no root
check today, so the transcript is left root-owned and every later unprivileged run fails the
same way:

$ sudo omarchy update
$ ls -l /tmp/omarchy-update.log
-rw-r--r-- 1 root root 187 /tmp/omarchy-update.log
$ omarchy update
script: cannot open /tmp/omarchy-update.log: Permission denied

The error names script, not Omarchy, and nothing points at a stale file in /tmp, so the
likely outcome is a user who concludes updates are broken.

The change

Put the transcript in the per-user runtime directory, which is 0700 and owned by the user,
and export the path so the consumer reads the same file instead of hardcoding it a second
time:

OMARCHY_UPDATE_LOG="${OMARCHY_UPDATE_LOG:-${XDG_RUNTIME_DIR:-/tmp}/omarchy-update.log}"
export OMARCHY_UPDATE_LOG

Refuse to run as root. XDG_RUNTIME_DIR is unset under sudo, so the path change alone
would leave the second case unfixed. Every step of the update already calls sudo for itself
where it needs to, and omarchy-dev-link refuses root in exactly this way, so this is the
existing idiom rather than a new constraint. It also cannot break omarchy channel set,
which calls omarchy-dev-link and therefore already could not run under sudo.

omarchy-update-analyze-logs exits quietly when there is no transcript. That closes
#11204, which reports a raw grep error when the log does not exist.

Scope

Two scripts, docs/update-process.md (four references), and a new test. No behaviour change
to the update flow itself. The fallback to /tmp is kept for the case where
XDG_RUNTIME_DIR is genuinely unset, which the root refusal makes much rarer.

Verification

Both cases were reproduced and then re-tested against the patched scripts in a VM. With
another user's file planted at the old path, omarchy update now reaches the confirmation
prompt normally; sudo omarchy-update is refused with a clear message; and the transcript
lands at /run/user/1000/omarchy-update.log owned by the user.

New test/shell.d/update-log-path-test.sh stubs script(1) and asserts the transcript path
under a runtime dir, that the shared /tmp path is no longer used, the explicit override,
the fallback, and the root refusal (via fakeroot), plus the three analyze-logs behaviours.
Confirmed it fails when the old behaviour is restored.

🤖 Generated with Claude Code

@llstrk

llstrk commented Sep 22, 2026

Copy link
Copy Markdown

Verified: At 5dc63e6, no actionable defect was found in the private-runtime-directory path. The transcript path survives the script(1) and update-lock re-execs, and the analyzer reads that transcript.

Case Observed result
Valid private XDG_RUNTIME_DIR Transcript and lock stay in that directory; no shared /tmp transcript is created.
Explicit OMARCHY_UPDATE_LOG The supplied path is passed to script (stub-verified).
UID 0 in a user namespace Clear refusal before any update step or transcript creation.
Missing transcript Analyzer exits 0 without the previous raw grep diagnostic.
Synthetic initcpio failure/success text Analyzer warns for the missing success marker and stays quiet when it is present.

Scope limit: With both path variables unset, the intentionally retained fallback still selects /tmp/omarchy-update.log. The shared-path collision risk is therefore not removed in that mode; this is a retained limitation, not a new regression.


Review information

Test scope: Real update entrypoint, util-linux script, lock and analyzer with package/update actions stubbed; separate path-selection and synthetic-log checks against the parent revision. No real package update, interactive-terminal test, cross-user collision or sudo invocation was performed.

Community review: Independent automated community review, unaffiliated with the Omarchy team, intended to help prepare PRs for their review.

Automated AI review: Astra Medium initial inspection and synthesis, independent Opus 5.5 High and GPT 6 Sol Xhigh technical reviews, followed by verification of the stated claims against targeted evidence.

@sanjyay

sanjyay commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

Tested in the Omarchy VM running Omarchy 4.0.4-1 with util-linux 2.42.3:

  • Ran bash test/shell.d/update-log-path-test.sh: all 8 tests passed cleanly.
  • Ran all update test suites (test/shell.d/update-*.sh): all 74 tests passed with no regressions.
  • Verified in the Omarchy VM:
    1. omarchy-update refuses execution under root (EUID == 0) immediately with a clear error (Error: run omarchy-update as your user, not under sudo.), preventing root-owned transcript creation that locks out subsequent unprivileged updates.
    2. Transcript default path now targets $XDG_RUNTIME_DIR/omarchy-update.log (falling back to /tmp only if XDG_RUNTIME_DIR is unset, or honouring OMARCHY_UPDATE_LOG), protecting the update flow from pre-created/colliding files in world-writable /tmp.
    3. omarchy-update-analyze-logs exits cleanly with status 0 when the transcript is absent (fixing omarchy update analyze logs fails with a raw grep error when the update log doesn't exist #11204), and correctly identifies initramfs generation failure/success when present.

Works as expected with no issues.

@llstrk

llstrk commented Sep 30, 2026

Copy link
Copy Markdown

Automated AI review

Community review: Independent automated community review, unaffiliated with the Omarchy team, intended to help prepare PRs for their review.

Outcome: At cf919bc (rebased since the earlier review of 5dc63e6), a plain sudo omarchy update no longer reaches the new root refusal.

Earlier review: all verified results and the /tmp fallback limit still apply, except the root refusal.

Plain sudo stops before the root refusal

The rebase put the EUID check (line 91) after omarchy_security_require_source_root (line 19). sudo's environment reset drops OMARCHY_PATH, so the run exits with OMARCHY_PATH does not match this Omarchy command., not the new refusal. Impact: no transcript is written, but plain sudo gets that error instead of the clear refusal the PR describes; the fakeroot test keeps OMARCHY_PATH.

Suggested change: move the EUID block and its comment to right after set -e.

Reproducer (fails at cf919bc)

Append this case to test/shell.d/update-log-path-test.sh (it reuses that file's fixture, script stub and $script_calls). env -i stands in for sudo's default environment reset, which drops OMARCHY_PATH; fakeroot makes the run UID 0, as the existing root case in the file already does.

# sudo resets the environment by default, so a plain `sudo omarchy update`
# arrives without OMARCHY_PATH. The root refusal should still be what it prints.
plain_sudo_out=$(env -i PATH=/usr/bin SCRIPT_CALLS="$script_calls" \
  fakeroot "$SUDO_TEST_ROOT/bin/omarchy-update" 2>&1 || true)
grep -q "not under sudo" <<<"$plain_sudo_out" ||
  fail "update refuses a plain sudo run (environment reset) as root" "$plain_sudo_out"
pass "update refuses a plain sudo run (environment reset) as root"

At cf919bc:

$ bash test/shell.d/update-log-path-test.sh
ok - update writes its transcript under XDG_RUNTIME_DIR
ok - update no longer uses the shared /tmp/omarchy-update.log
ok - update falls back to /tmp when no runtime dir is set
ok - update honours an explicit OMARCHY_UPDATE_LOG
ok - update refuses to run as root
ok - analyze stays quiet when initramfs generation succeeded
ok - analyze still reports a failed initramfs generation
ok - analyze exits cleanly and silently when no transcript exists
OMARCHY_PATH does not match this Omarchy command.
not ok - update refuses a plain sudo run (environment reset) as root

With the EUID block moved to directly after set -e in bin/omarchy-update (nothing else changed):

@@ -15,6 +15,10 @@
 source "${security_entrypoint%/*}/omarchy-security-functions" || exit 126
 omarchy_security_require_privileged_bash_startup || exit 126
 set -e
+if (( EUID == 0 )); then
+  echo "Error: run omarchy-update as your user, not under sudo." >&2
+  exit 1
+fi
 omarchy_security_sanitize_bash_environment "$0" "$@"
 omarchy_security_require_source_root "$0"
 # Logging and lock acquisition re-exec this command with a sanitized PATH.
@@ -88,10 +92,6 @@
 # the next unprivileged run cannot open it -- the update then fails with an
 # error naming script(1) rather than Omarchy. omarchy-dev-link already refuses
 # root the same way, so `omarchy channel set` could not be run under sudo either.
-if (( EUID == 0 )); then
-  echo "Error: run omarchy-update as your user, not under sudo." >&2
-  exit 1
-fi
 
 # The transcript goes in the per-user runtime directory, which is 0700 and owned
 # by the user, rather than a fixed path in world-writable /tmp where any other
$ bash test/shell.d/update-log-path-test.sh
ok - update writes its transcript under XDG_RUNTIME_DIR
ok - update no longer uses the shared /tmp/omarchy-update.log
ok - update falls back to /tmp when no runtime dir is set
ok - update honours an explicit OMARCHY_UPDATE_LOG
ok - update refuses to run as root
ok - analyze stays quiet when initramfs generation succeeded
ok - analyze still reports a failed initramfs generation
ok - analyze exits cleanly and silently when no transcript exists
ok - update refuses a plain sudo run (environment reset) as root
More findings: the shell tests now fail when run as root

Update tests fail as root

Five existing test files that run omarchy-update (update-user-path, update-hook-security, update-disk-space, update-sequence, channel-sudo-boundary) pass as root at the base 324f0ba but fail at cf919bc. The new update-log-path-test.sh also fails as root. Impact: this matters only if the suite is run as root (for example in a root container). Some update tests already have root-specific branches (update-lock-test.sh, update-stay-awake-security-test.sh). As an ordinary user, nothing regresses.

Suggested change: if root runs are supported, give these tests an EUID 0 path (at least a skip for the new file's update cases). Otherwise, document that the shell suite runs as an ordinary user.

Run the update tests as root, here as user-namespace root (the same UID 0 a root container gives), from the repository root.

At cf919bc:

$ unshare --user --map-root-user -- bash test/shell.d/update-log-path-test.sh
not ok - update writes its transcript under XDG_RUNTIME_DIR

$ unshare --user --map-root-user -- bash test/shell.d/update-user-path-test.sh
Error: run omarchy-update as your user, not under sudo.
not ok - fresh update lost the original user PATH

$ unshare --user --map-root-user -- bash test/shell.d/update-hook-security-test.sh
Error: run omarchy-update as your user, not under sudo.
not ok - update failed

$ unshare --user --map-root-user -- bash test/shell.d/update-disk-space-test.sh
ok - free-space helper reports low disk space through its exit status
Error: run omarchy-update as your user, not under sudo.
not ok - low disk space emits a warning

$ unshare --user --map-root-user -- bash test/shell.d/update-sequence-test.sh
not ok - an update where everything works reports a failure

$ unshare --user --map-root-user -- bash test/shell.d/channel-sudo-boundary-test.sh
Setting channel to stable
Error: run omarchy-update as your user, not under sudo.

The channel switch did not complete. Review the error above, then rerun: omarchy-channel-set stable
not ok - stable failed

fakeroot -- bash test/shell.d/update-log-path-test.sh gives the same first failure.

At the base, 324f0ba, the same five existing files pass as root (exit 0 each):

$ unshare --user --map-root-user -- bash test/shell.d/update-user-path-test.sh
ok - fresh update preserves the original user PATH through logging and locking and shares its authorization
ok - logged update preserves the original user PATH through logging and locking and shares its authorization
ok - locked update preserves the original user PATH through logging and locking and shares its authorization

$ unshare --user --map-root-user -- bash test/shell.d/update-sequence-test.sh
ok - an update where every step works runs all of them, in order
ok - -y is what marks an update unattended, not the update itself
ok - a blocked package upgrade stops the update before it migrates

$ unshare --user --map-root-user -- bash test/shell.d/update-disk-space-test.sh
ok - free-space helper reports low disk space through its exit status
ok - non-interactive update stops with low disk space
ok - interactive update stops before confirmation with low disk space
ok - forced update skips the free-space requirement
ok - disk-space threshold includes the exact boundary
ok - interactive update keeps the normal confirmation prompt when space is sufficient
ok - failed disk-space detection silently continues

(update-hook-security-test.sh: 23 ok lines, exit 0. channel-sudo-boundary-test.sh: 19 ok lines, exit 0.)

Run as an ordinary user, all six files pass at cf919bc, and the five existing ones also pass at 324f0ba.

Details

Earlier review claims at cf919bc:

Earlier claim Status at cf919bc
Transcript path survives the script(1) and lock re-execs; analyzer reads it Still holds
Valid private XDG_RUNTIME_DIR keeps transcript and lock there; no /tmp transcript Still holds
Explicit OMARCHY_UPDATE_LOG is passed to script Still holds
UID 0 refused clearly before any update step or transcript Holds when OMARCHY_PATH is kept (sudo -E, sudo -i, root shell). Plain sudo is stopped earlier with the source-root message (finding above)
Missing transcript: analyzer exits 0 without the raw grep error Still holds
Analyzer warns without the initcpio success marker, quiet with it Still holds
Scope limit: both path variables unset still selects /tmp/omarchy-update.log Still applies

Root runs, simulated with mock sudo (UID 0 from fakeroot and from user-namespace root, same results):

Root environment base 324f0ba head cf919bc head, refusal after set -e
plain sudo (no OMARCHY_PATH) source-root error, no script same as base clear refusal
sudo -E reaches script with /tmp/omarchy-update.log clear refusal, after sudo -k and sudo -h clear refusal, no sudo calls
sudo -i reaches script with /tmp/omarchy-update.log clear refusal, after sudo -k and sudo -h clear refusal, no sudo calls

PR description: at the current base, a plain sudo omarchy update already stops at the source-root check before script runs, so it cannot leave a root-owned /tmp/omarchy-update.log. That failure mode applies to 4.0.4 and, at the base, to sudo -E, sudo -i and root shells, which the new refusal covers.

Related open PRs:

Optional notes:

  • docs/update-process.md:26 and :311 leave out the retained /tmp fallback. Writing ${XDG_RUNTIME_DIR:-/tmp}/omarchy-update.log (override: OMARCHY_UPDATE_LOG), as the lock row does, would match the code.
  • The new test still passes 8/8 when the analyzer's fallback is changed to /tmp, or when export OMARCHY_UPDATE_LOG and the env pass-through are both dropped. The root case also never checks that script was not called. A standalone analyzer case with only XDG_RUNTIME_DIR set would catch the fallback change; having the script stub also record $OMARCHY_UPDATE_LOG from its environment would catch the dropped export; an empty-$script_calls assertion would pin the root case.
  • The description's That closes **#11204** is not registered as a closing reference. Closes #11204 would link the issue.

Review information

Test scope: Source and sandbox tests at cf919bc (real script(1), lock and analyzer, other steps stubbed); sudo simulated, no cross-user test.

AI process: Opus 5.5 Medium coordination and synthesis, Opus 5.5 Xhigh technical review and final fact check, GPT 6 Sol Xhigh search for related issues, Opus 5.5 Medium editorial check.

Opt out: To stop receiving these reviews, reply to this comment saying so.

omarchy-update re-execs itself through script(1) into a hardcoded
/tmp/omarchy-update.log, and omarchy-update-analyze-logs reads the same
path back. /tmp is world-writable and nothing checks the file's owner.
script opens it with O_CREAT, which fs.protected_regular refuses when the
file belongs to someone else, so whoever owns that file decides who may
run omarchy update.

Two ways that goes wrong, both reproduced on 4.0.4 in a VM:

  - another local user creates the file and every other user's update
    aborts with EACCES before the confirmation prompt. The sticky bit
    means the victim cannot remove it.
  - with no attacker at all, one `sudo omarchy update` leaves the
    transcript root-owned and every later unprivileged run fails the same
    way, with an error naming script(1) rather than Omarchy.

Write the transcript to the per-user runtime directory instead, and
export the path so the consumer reads the same file rather than
hardcoding it twice.

Refuse to run as root. XDG_RUNTIME_DIR is unset under sudo, so the path
change alone would leave the second case unfixed. Every step already
calls sudo for itself, omarchy-dev-link refuses root the same way, and
omarchy channel set could not run under sudo before this either since it
calls omarchy-dev-link.

omarchy-update-analyze-logs now exits quietly when no transcript exists,
which closes omacom#11204. This is not omacom#11630, which is a different symptom on
the same line: exit 0 with no log created at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@taufderl
taufderl force-pushed the fix/update-log-not-in-tmp branch from cf919bc to 047a6af Compare October 1, 2026 12:57
@greptile-apps

greptile-apps Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 2/5

[Medium risk] Changes where update transcripts are written.

The PR is not ready to merge while the supported fallback permits the original update-blocking attack and the new test can fail solely because fakeroot is unavailable.

Findings

  1. P1 Security Shared fallback still blocks updates ▶
  2. P1 Root tests require fakeroot ▶
  3. P2 Failure transcripts disappear at logout ▶

Summary

This PR moves the update transcript to a per-user runtime path, passes that path to log analysis, refuses root execution, and adds path tests.

  • The shared /tmp filename remains the supported fallback when XDG_RUNTIME_DIR is unset.
  • The new test introduces an undeclared fakeroot requirement, and runtime-directory storage limits how long failure transcripts remain available.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  U[omarchy-update] --> R{XDG_RUNTIME_DIR set?}
  R -->|Yes| P[Per-user runtime transcript]
  R -->|No| T[Shared /tmp transcript]
  P --> A[Analyze transcript]
  T --> A
Loading

Reviews (1) · Last reviewed commit: "Keep the update transcript out of world-..."

Comment thread bin/omarchy-update
# local user could pre-create it and lock this one out of updating. The path is
# exported so omarchy-update-analyze-logs reads the same file instead of
# hardcoding it a second time.
OMARCHY_UPDATE_LOG="${OMARCHY_UPDATE_LOG:-${XDG_RUNTIME_DIR:-/tmp}/omarchy-update.log}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Shared fallback still blocks updates

If a non-root user runs an update without XDG_RUNTIME_DIR, this fallback passes the fixed /tmp/omarchy-update.log path to script. Another local user can pre-create that file and prevent the transcript from opening, blocking the update before confirmation. The new test explicitly preserves this fallback, so the cross-user lockout remains in a supported case.

How this was verified: The unset-runtime path passes the shared /tmp filename directly to script, where another user's pre-created file prevents the transcript from opening.

Comment on lines +60 to +62
root_out=$(fakeroot env -u OMARCHY_UPDATE_LOGGED -u OMARCHY_UPDATE_LOG \
SCRIPT_CALLS="$script_calls" "$SUDO_TEST_ROOT/bin/omarchy-update" 2>&1 || true)
grep -q "not under sudo" <<<"$root_out" ||

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Root tests require fakeroot

If a test host lacks fakeroot, both root-refusal checks fail because their command cannot start, even when the updater works. The shell suite discovers and runs this test automatically, but the test does not declare or check that dependency.

Comment thread bin/omarchy-update
# local user could pre-create it and lock this one out of updating. The path is
# exported so omarchy-update-analyze-logs reads the same file instead of
# hardcoding it a second time.
OMARCHY_UPDATE_LOG="${OMARCHY_UPDATE_LOG:-${XDG_RUNTIME_DIR:-/tmp}/omarchy-update.log}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Failure transcripts disappear at logout

The update now keeps its only transcript in XDG_RUNTIME_DIR, which is removed at logout. A user who returns later to diagnose a failed update can no longer inspect the transcript that the update documentation recommends for debugging. A durable diagnostic copy or a documented retention limit would make that cost clear.

Knowledge Base Used: System updates and migrations

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@dhh

dhh commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

Closing after maintainer review. We are not accepting this as a security vulnerability that warrants these changes for Omarchy's intended single-user desktop setup.

The cross-account disclosure scenario requires an additional untrusted local account, or a separately compromised service account, plus a run of the relevant command. The report has not demonstrated a sensitive credential disclosure or a consequential exploit in the default setup. Root can already read this information, and malicious programs running as the desktop user can still read it after a move to a private file or directory. Hostname, hardware details, and package inventory alone do not establish a security vulnerability.

The default systemd protections (fs.protected_symlinks and fs.protected_regular) block the described cross-user symlink redirect and writes into another user's pre-created regular file in sticky /tmp. World-writable /tmp by itself is not evidence of arbitrary file overwrite or privilege escalation. These protections do not prevent reading an existing 0644 file; that narrow cross-account exposure is acknowledged.

The default /tmp is tmpfs-backed and is cleared on reboot. These logs are not permanently retained: the exposure, where an untrusted second account exists, lasts only while the files remain during that boot. Reboot limits the exposure; it does not make cross-account reads impossible before then.

Private staging and explicit cleanup can be reasonable housekeeping, but we do not consider the demonstrated behavior a security priority for the default Omarchy threat model. We are declining this family of security-motivated changes on that basis.

References: systemd default protections, kernel documentation, default tmpfs mount.

The reproduced root-owned transcript after sudo omarchy update is a distinct functional problem, and we acknowledge it can affect a single-user machine. It is not proof of an attacker in the default setup; its /tmp leftover is also cleared on reboot. If pursued, root-invocation handling should be addressed as a focused functional fix rather than as this broader security change. The separate missing-log issue #11204 remains open.

Omabot on behalf of DHH

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants