From ec9da050eef10a0d9f1dc5a8dbab41f78e74a1fa Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Fri, 14 Aug 2026 23:47:31 -0400 Subject: [PATCH 01/41] Stop Resolve dialogs from recapturing pointer focus --- default/hypr/apps/davinci-resolve.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/default/hypr/apps/davinci-resolve.lua b/default/hypr/apps/davinci-resolve.lua index 6f6abda3..1af83aa1 100644 --- a/default/hypr/apps/davinci-resolve.lua +++ b/default/hypr/apps/davinci-resolve.lua @@ -3,9 +3,10 @@ o.window(".*[Rr]esolve.*", { float = true, stay_focused = true, + no_follow_mouse = true, tag = "-default-opacity", opacity = "1 1", }) o.window({ class = ".*[Rr]esolve.*", title = "^DaVinci Resolve( Studio)? - .+$" }, { fullscreen = true }) -o.window({ class = ".*[Rr]esolve.*", title = "^(DaVinci Resolve( Studio)? - .+|Project Manager)$" }, { stay_focused = false }) +o.window({ class = ".*[Rr]esolve.*", title = "^(DaVinci Resolve( Studio)? - .+|Project Manager|Preferences|Find Directory)$" }, { stay_focused = false }) From 94a7b70ed4ed514cd120918ee39a965c299dfd24 Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Fri, 14 Aug 2026 23:50:12 -0400 Subject: [PATCH 02/41] Explain Resolve pointer focus override --- default/hypr/apps/davinci-resolve.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/default/hypr/apps/davinci-resolve.lua b/default/hypr/apps/davinci-resolve.lua index 1af83aa1..a2eeef40 100644 --- a/default/hypr/apps/davinci-resolve.lua +++ b/default/hypr/apps/davinci-resolve.lua @@ -3,6 +3,7 @@ o.window(".*[Rr]esolve.*", { float = true, stay_focused = true, + -- Prevent modal dialog pointer warps when focus follows the mouse. no_follow_mouse = true, tag = "-default-opacity", opacity = "1 1", From 7c896d3521f4fe0a22d49226caef69b184b2d222 Mon Sep 17 00:00:00 2001 From: Taksh Date: Mon, 24 Aug 2026 07:16:53 +0530 Subject: [PATCH 03/41] Keep a web app name out of the launcher's directory structure The app name becomes a filename, and omarchy-webapp-install ran `mkdir -p "$(dirname "$DESKTOP_FILE")"` over it, so every slash turned into a directory level. Typing a URL into the Name field -- the reported way in -- wrote the launcher to `~/.local/share/applications/http:/127.0.0.1:4000/.desktop`. Removal could then never reach it. The picker lists the file but displays a name derived from the path, and the removal rebuilt a flat `$DESKTOP_DIR/$APP_NAME.desktop` from that name, so `rm -f` deleted nothing and the app stayed in the launcher with no error. Refuse a name containing a slash rather than silently renaming what the user typed, and delete the file the scan actually found instead of a path rebuilt from its display name. The second half also clears up whatever earlier versions nested, which a reconstructed path cannot address. --- bin/omarchy-webapp-install | 14 ++++++-- bin/omarchy-webapp-remove | 34 +++++++++++++----- test/shell.d/webapp-name-test.sh | 61 ++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 10 deletions(-) create mode 100644 test/shell.d/webapp-name-test.sh diff --git a/bin/omarchy-webapp-install b/bin/omarchy-webapp-install index acfdf858..b7985968 100755 --- a/bin/omarchy-webapp-install +++ b/bin/omarchy-webapp-install @@ -104,6 +104,15 @@ if [[ -z $APP_NAME || -z $APP_URL ]]; then exit 1 fi +# The name becomes a filename. A slash would turn it into directory levels, so +# the launcher lands somewhere omarchy-webapp-remove cannot address and the app +# is stuck in the launcher. Refuse rather than silently renaming what the user +# typed -- most often it is a URL entered in the name field. +if [[ $APP_NAME == */* ]]; then + echo "App name cannot contain '/': $APP_NAME" + exit 1 +fi + if [[ -z $ICON_REF ]]; then ICON_VALUE=$(safe_icon_name "$APP_NAME") mkdir -p "$ICON_DIR" @@ -132,8 +141,9 @@ fi EXEC_COMMAND="${CUSTOM_EXEC:-omarchy-launch-webapp $APP_URL}" # Create application .desktop file -DESKTOP_FILE="$HOME/.local/share/applications/$APP_NAME.desktop" -mkdir -p "$(dirname "$DESKTOP_FILE")" +DESKTOP_DIR="$HOME/.local/share/applications" +DESKTOP_FILE="$DESKTOP_DIR/$APP_NAME.desktop" +mkdir -p "$DESKTOP_DIR" cat >"$DESKTOP_FILE" <"$tmp_dir/bin/$stub" + chmod +x "$tmp_dir/bin/$stub" +done + +run_install() { + HOME="$tmp_dir/home" PATH="$tmp_dir/bin:$PATH" \ + "$ROOT/bin/omarchy-webapp-install" "$@" +} + +run_remove() { + HOME="$tmp_dir/home" PATH="$tmp_dir/bin:$PATH" OMARCHY_REMOVE_NOTIFY=false \ + "$ROOT/bin/omarchy-webapp-remove" "$@" +} + +apps_dir="$tmp_dir/home/.local/share/applications" + +# A URL typed into the name field is the reported way in. Every slash used to +# become a directory level, leaving a launcher nothing could address. +if run_install "http://example.test/oops" "https://example.com" hey >/dev/null 2>&1; then + fail "webapp install rejects a name containing a slash" +fi +[[ -e "$apps_dir/http:" ]] && + fail "webapp install does not create a directory from a slashed name" +pass "webapp install rejects a name that would nest the launcher" + +# A normal name still installs and removes. +run_install "Example App" "https://example.com" hey >/dev/null +[[ -f "$apps_dir/Example App.desktop" ]] || + fail "webapp install writes the launcher for an ordinary name" +run_remove "Example App" >/dev/null +[[ -f "$apps_dir/Example App.desktop" ]] && + fail "webapp remove deletes the launcher it installed" +pass "webapp install and remove round-trip an ordinary name" + +# Anything installed by an older version can still be nested. Removal has to +# reach it, which a path rebuilt from the displayed name never could. +mkdir -p "$apps_dir/http:/127.0.0.1:4000" +cat >"$apps_dir/http:/127.0.0.1:4000/.desktop" <<'DESKTOP' +[Desktop Entry] +Name=http://127.0.0.1:4000 +Exec=omarchy-launch-webapp https://127.0.0.1:4000 +Type=Application +DESKTOP + +# This is the name the picker shows for that file: the script strips .desktop +# from the path and then takes the basename, which lands on the directory. +run_remove "127.0.0.1:4000" >/dev/null +[[ -f "$apps_dir/http:/127.0.0.1:4000/.desktop" ]] && + fail "webapp remove deletes a launcher left nested by an older install" +pass "webapp remove reaches a nested legacy launcher" From 44b00a4e80c0d3bf19464d18bcf9ca2f35e02a78 Mon Sep 17 00:00:00 2001 From: David Helmus Date: Mon, 24 Aug 2026 08:52:25 +0200 Subject: [PATCH 04/41] test: avoid mise shim recursion --- test/shell.d/copy-url-shortcut-migration-test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/shell.d/copy-url-shortcut-migration-test.sh b/test/shell.d/copy-url-shortcut-migration-test.sh index f8cf3497..dd25420e 100644 --- a/test/shell.d/copy-url-shortcut-migration-test.sh +++ b/test/shell.d/copy-url-shortcut-migration-test.sh @@ -28,7 +28,7 @@ write_stale_preferences() { stub_bin="$test_dir/bin" mkdir -p "$stub_bin" -REAL_PYTHON=$(command -v python3) +REAL_PYTHON=$(command -p -v python3) export REAL_PYTHON run_migration() { From e66c27f1e7ed395fce4e6910300b2d7476413640 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Mon, 24 Aug 2026 12:37:46 -0400 Subject: [PATCH 05/41] Require signed packages from the Omarchy repository --- default/pacman/pacman-edge.conf | 1 - default/pacman/pacman-rc.conf | 1 - default/pacman/pacman-stable.conf | 1 - migrations/1787589206.sh | 20 ++++++++++++++++++++ 4 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 migrations/1787589206.sh diff --git a/default/pacman/pacman-edge.conf b/default/pacman/pacman-edge.conf index d83befca..a968d26d 100644 --- a/default/pacman/pacman-edge.conf +++ b/default/pacman/pacman-edge.conf @@ -26,7 +26,6 @@ Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist [omarchy] -SigLevel = Optional TrustAll Server = https://pkgs.omarchy.org/edge/$arch # Repositories for debug symbol packages. diff --git a/default/pacman/pacman-rc.conf b/default/pacman/pacman-rc.conf index 50d2e498..cf8ca40a 100644 --- a/default/pacman/pacman-rc.conf +++ b/default/pacman/pacman-rc.conf @@ -26,5 +26,4 @@ Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist [omarchy] -SigLevel = Optional TrustAll Server = https://pkgs.omarchy.org/edge/$arch diff --git a/default/pacman/pacman-stable.conf b/default/pacman/pacman-stable.conf index 5dafbc84..7e4b5538 100644 --- a/default/pacman/pacman-stable.conf +++ b/default/pacman/pacman-stable.conf @@ -26,5 +26,4 @@ Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist [omarchy] -SigLevel = Optional TrustAll Server = https://pkgs.omarchy.org/stable/$arch diff --git a/migrations/1787589206.sh b/migrations/1787589206.sh new file mode 100644 index 00000000..928905a9 --- /dev/null +++ b/migrations/1787589206.sh @@ -0,0 +1,20 @@ +echo "Require signed packages from the Omarchy repository" + +# The [omarchy] repo predates the Omarchy packaging key, so existing installs +# carry a SigLevel override that also accepts unsigned packages. Packages are +# signed now, so drop the override and let the repo inherit the global +# SigLevel = Required DatabaseOptional like every other repo. Machine-wide and +# self-detecting, so another user's rerun no-ops. +omarchy_sig_override='SigLevel = Optional TrustAll' + +if [[ -f /etc/pacman.conf ]] && + sed -n '/^\[omarchy\]/,/^\[/p' /etc/pacman.conf | grep -qxF "$omarchy_sig_override"; then + # Requiring signatures with an untrusted packaging key would fail every + # omarchy transaction, including the one that could repair it. + if omarchy-pkg-missing omarchy-keyring || + ! sudo pacman-key --list-keys 40DFB630FF42BCFFB047046CF0134EE680CAC571 &>/dev/null; then + omarchy-update-keyring + fi + + sudo sed -i "/^\[omarchy\]/,/^\[/{/^$omarchy_sig_override$/d}" /etc/pacman.conf +fi From 597f57a198f664aaa8604a2625b934c55876278d Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Mon, 24 Aug 2026 22:00:42 -0400 Subject: [PATCH 06/41] Allow Resolve Voiceover to release focus --- default/hypr/apps/davinci-resolve.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/default/hypr/apps/davinci-resolve.lua b/default/hypr/apps/davinci-resolve.lua index a2eeef40..734d2b8f 100644 --- a/default/hypr/apps/davinci-resolve.lua +++ b/default/hypr/apps/davinci-resolve.lua @@ -10,4 +10,5 @@ o.window(".*[Rr]esolve.*", { }) o.window({ class = ".*[Rr]esolve.*", title = "^DaVinci Resolve( Studio)? - .+$" }, { fullscreen = true }) -o.window({ class = ".*[Rr]esolve.*", title = "^(DaVinci Resolve( Studio)? - .+|Project Manager|Preferences|Find Directory)$" }, { stay_focused = false }) +-- Resolve exposes the Voiceover panel under the generic "Dialog" title. +o.window({ class = ".*[Rr]esolve.*", title = "^(DaVinci Resolve( Studio)? - .+|Project Manager|Preferences|Find Directory|Dialog)$" }, { stay_focused = false }) From 30471bf35a7fb28e24d960045e7ecd7cda132f1f Mon Sep 17 00:00:00 2001 From: Basti <233381911+bastidotnet@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:29:48 +0200 Subject: [PATCH 07/41] Guard plugin-add against git transport-helper URLs (match theme-install) (#8067) * Guard plugin-add against git transport-helper URLs omarchy-plugin-add cloned a user-supplied git URL without the transport-helper guard that omarchy-theme-install already applies (added in #7884, which did not touch plugin-add). Port that guard (reject ext::/fd:: and leading-dash forms, keep https/ssh/scp-style incl. IPv6) and add a regression test. Stock systems are unaffected (git default protocol.ext.allow=never); this removes the silent dependency on that default and aligns the two install paths. * Test the plugin-add guard's leading-dash arm via the gum input path The prior leading-dash cases only exercised the argv option parser, not the guard (removing the guard's -* arm left them green). Drive a dash value through the interactive gum prompt under a pty so the post-input guard is actually covered; skip cleanly where util-linux script is unavailable. --- bin/omarchy-plugin-add | 9 +++ test/shell.d/plugin-add-test.sh | 105 ++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/bin/omarchy-plugin-add b/bin/omarchy-plugin-add index f0bdfca5..9fa0e6c0 100755 --- a/bin/omarchy-plugin-add +++ b/bin/omarchy-plugin-add @@ -93,6 +93,15 @@ if [[ -z $url ]]; then [[ -n $url ]] || fail "a git URL is required" fi +# git reads a leading dash as an option, and `::
` as a remote +# helper to run at clone time. Reject both so an untrusted URL cannot smuggle a +# transport helper that executes before the plugin is validated or enabled. An +# scp-style IPv6 host such as git@[2001:db8::1]:org/repo.git carries `::` too and +# must still clone. +if [[ $url == -* || $url =~ ^[A-Za-z0-9][A-Za-z0-9+.-]*:: ]]; then + fail "'$url' names a git option or transport helper, not a repository." +fi + if (( ! ASSUME_YES )); then cat >&2 <::…`) and option-shaped URLs +# before `git clone` runs, matching omarchy-theme-install. A git stub records +# whether clone was reached, so the guard is exercised with no network: reaching +# the stub proves a URL passed the guard; not reaching it proves the guard +# rejected the URL first. + +guard_stubs="$TMPDIR/guard-stubs" +mkdir -p "$guard_stubs" +cat >"$guard_stubs/omarchy-shell" <<'STUB' +#!/bin/bash +exit 0 +STUB +chmod +x "$guard_stubs/omarchy-shell" + +clone_marker="$TMPDIR/git-clone-reached" +cat >"$guard_stubs/git" <"$guard_stubs/gum" <<'STUB' +#!/bin/bash +if [[ $1 == "input" ]]; then + printf '%s\n' "$GUM_INPUT_VALUE" +fi +STUB +chmod +x "$guard_stubs/gum" + +add_url() { + HOME="$test_home" OMARCHY_PATH="$ROOT" PATH="$guard_stubs:$ROOT/bin:$PATH" \ + omarchy-plugin-add "$1" --yes 2>&1 +} + +# Transport helpers reach the guard, are named as such, and never reach clone. +for bad in "ext::sh -c touch /tmp/omarchy-guard-test" "fd::17"; do + rm -f "$clone_marker" + output=$(add_url "$bad") && + fail "plugin add rejects a transport-helper URL: $bad" "$output" + grep -qF "names a git option or transport helper" <<<"$output" || + fail "plugin add names the transport-helper rejection: $bad" "$output" + [[ ! -e $clone_marker ]] || + fail "plugin add reached git clone for a transport-helper URL: $bad" +done +pass "plugin add rejects transport-helper URLs before cloning" + +# Option-shaped URLs on argv are refused before clone — by the option parser +# (`-*` falls to "unknown add option"), not the guard. The guard's own +# leading-dash arm is only reachable through the interactive gum prompt and is +# exercised separately below. +for bad in "-oProxyCommand=x" "--upload-pack=x"; do + rm -f "$clone_marker" + output=$(add_url "$bad") && + fail "plugin add rejects an option-shaped URL: $bad" "$output" + [[ ! -e $clone_marker ]] || + fail "plugin add reached git clone for an option-shaped URL: $bad" +done +pass "plugin add rejects option-shaped URLs before cloning" + +# The guard's leading-dash arm is only reachable through `gum input`: argv +# dashes die in the option parser first. interactive() requires a TTY on stdin +# and stdout, so run this one case on a pty via util-linux `script -qec` (the +# suite's existing pty idiom); gum itself is stubbed, so no rendering happens. +# Probe script's util-linux syntax first and skip cleanly where it is missing. +if script -qec true /dev/null >/dev/null 2>&1; then + rm -f "$clone_marker" + status=0 + raw=$(GUM_INPUT_VALUE="-oProxyCommand=x" HOME="$test_home" OMARCHY_PATH="$ROOT" \ + PATH="$guard_stubs:$ROOT/bin:$PATH" \ + script -qec "omarchy-plugin-add --yes" /dev/null) || status=$? + output=$(tr -d '\r' <<<"$raw") + (( status != 0 )) || + fail "plugin add rejects an option-shaped URL from the gum prompt" "$output" + grep -qF "names a git option or transport helper" <<<"$output" || + fail "plugin add names the guard rejection for the gum-prompt URL" "$output" + [[ ! -e $clone_marker ]] || + fail "plugin add reached git clone for an option-shaped gum-prompt URL" + pass "plugin add guard rejects an option-shaped URL from the interactive prompt" +else + pass "script -qec unavailable; skipping the interactive gum-prompt guard case" +fi + +# Legitimate URL forms pass the guard and reach git clone (stubbed, no network). +for good in \ + "https://github.com/acme/omarchy-weather.git" \ + "git@github.com:acme/repo.git" \ + "ssh://git@github.com/acme/repo.git" \ + "git@[2001:db8::1]:org/repo.git"; do + rm -f "$clone_marker" + output=$(add_url "$good") || true + ! grep -qF "names a git option or transport helper" <<<"$output" || + fail "plugin add wrongly rejected a legitimate URL: $good" "$output" + [[ -e $clone_marker ]] || + fail "plugin add did not reach git clone for a legitimate URL: $good" "$output" +done +pass "plugin add lets legitimate git URLs reach git clone" From 68ab12f77dd0e044c0dbb1f4787fb72b763ab135 Mon Sep 17 00:00:00 2001 From: Omarchybot Date: Tue, 25 Aug 2026 09:10:20 +0200 Subject: [PATCH 08/41] Share the git URL check, and refuse the transports Omarchy does not clone from (#8174) * Share the git URL check between theme-install and plugin-add Both commands clone a URL a stranger can choose, and each carried its own copy of the rule that refuses a git option or a `::
` transport helper before cloning. Two copies of a security check drift: the second one arrived four months after the first, and only because someone went looking for it. The rule now lives in omarchy-git-url-check and the callers ask it. Its absence refuses the URL rather than waving it through, since the callers read a non-zero status as a refusal and a missing command exits 127. Co-Authored-By: Claude Opus 5 Co-Authored-By: Codex XHigh * Refuse a git URL naming a transport Omarchy does not clone from `::
` is only one of the two ways a URL reaches a remote helper. git also resolves git-remote- for `://
` whenever the scheme is not one it connects itself, so `ext::sh -c id` and `ext://sh -c id` arrive at the same helper while only the first was refused. That shape cannot be refused outright, because it is also how every legitimate URL arrives, so the scheme is checked against the transports git still connects itself. `git+ssh` and `ssh+git` are on that list: they are spelled like a helper and read as plain ssh, and leaving them off would refuse a URL that clones today. `ext` and `fd` are off it deliberately -- git ships a helper for each, and `ext` runs whatever command the URL carries. Co-Authored-By: Claude Opus 5 Co-Authored-By: Codex XHigh --------- Co-authored-by: David Heinemeier Hansson Co-authored-by: Claude Opus 5 Co-authored-by: Codex XHigh --- bin/omarchy-git-url-check | 50 ++++++++++++++ bin/omarchy-plugin-add | 14 ++-- bin/omarchy-theme-install | 12 ++-- test/shell.d/git-url-check-test.sh | 79 +++++++++++++++++++++++ test/shell.d/plugin-add-test.sh | 14 ++++ test/shell.d/theme-install-guards-test.sh | 25 ++++++- 6 files changed, 177 insertions(+), 17 deletions(-) create mode 100755 bin/omarchy-git-url-check create mode 100755 test/shell.d/git-url-check-test.sh diff --git a/bin/omarchy-git-url-check b/bin/omarchy-git-url-check new file mode 100755 index 00000000..53109949 --- /dev/null +++ b/bin/omarchy-git-url-check @@ -0,0 +1,50 @@ +#!/bin/bash + +# omarchy:summary=Check that a git URL names a repository, not a transport helper +# omarchy:args= +# omarchy:hidden=true + +set -euo pipefail + +# git picks a remote helper -- an executable it runs at clone time -- out of a URL +# in exactly two shapes, and no others: `::
`, and +# `://
` for any scheme git does not handle itself. A single +# colon is always scp-style ssh, and a bare path is always a path; neither can +# reach a helper. So constraining those two shapes covers the whole surface. +# +# The `::` shape is refused outright, because no helper reachable that way is one +# a theme or plugin URL has business naming, and `ext::` runs a shell command. +# The `://` shape cannot be refused the same way, since it is also how every +# legitimate URL arrives -- so it is allowlisted instead. The list is the +# transports git still connects itself, `git+ssh` and `ssh+git` included: those +# two are spelled like a helper but are read as plain ssh. `ext` and `fd` are +# left out deliberately -- git ships a helper for each, and `ext` runs whatever +# command the URL carries. +TRANSPORTS=(ssh git git+ssh ssh+git http https ftp ftps file) + +fail() { + echo "omarchy-git-url-check: $*" >&2 + exit 1 +} + +url="${1-}" + +if [[ -z $url ]]; then + fail "a git URL is required" +fi + +if [[ $url == -* || $url =~ ^[A-Za-z0-9][A-Za-z0-9+.-]*:: ]]; then + fail "'$url' names a git option or transport helper, not a repository." +fi + +if [[ $url =~ ^([A-Za-z0-9][A-Za-z0-9+.-]*):// ]]; then + scheme="${BASH_REMATCH[1]}" + + for transport in "${TRANSPORTS[@]}"; do + if [[ $scheme == "$transport" ]]; then + exit 0 + fi + done + + fail "'$url' names the '$scheme' transport, which Omarchy does not clone from." +fi diff --git a/bin/omarchy-plugin-add b/bin/omarchy-plugin-add index 9fa0e6c0..d77593e9 100755 --- a/bin/omarchy-plugin-add +++ b/bin/omarchy-plugin-add @@ -93,14 +93,12 @@ if [[ -z $url ]]; then [[ -n $url ]] || fail "a git URL is required" fi -# git reads a leading dash as an option, and `::
` as a remote -# helper to run at clone time. Reject both so an untrusted URL cannot smuggle a -# transport helper that executes before the plugin is validated or enabled. An -# scp-style IPv6 host such as git@[2001:db8::1]:org/repo.git carries `::` too and -# must still clone. -if [[ $url == -* || $url =~ ^[A-Za-z0-9][A-Za-z0-9+.-]*:: ]]; then - fail "'$url' names a git option or transport helper, not a repository." -fi +# Refuse a URL that names a git option or a transport helper before cloning, so +# an untrusted URL cannot run a command before the plugin is validated or +# enabled. The check is shared with omarchy-theme-install and explains itself; a +# missing checker leaves this non-zero, which refuses the URL rather than +# cloning it. +omarchy-git-url-check "$url" || exit 1 if (( ! ASSUME_YES )); then cat >&2 <::
` as a remote -# helper to run. The helper name is a bare word at the very start, which is what -# this matches; an scp-style IPv6 host such as git@[2001:db8::1]:org/repo.git -# carries `::` too and must still clone. -if [[ $REPO_URL == -* || $REPO_URL =~ ^[A-Za-z0-9][A-Za-z0-9+.-]*:: ]]; then - echo "Error: '$REPO_URL' names a git option or transport helper, not a repository." - exit 1 -fi +# Refuse a URL that names a git option or a transport helper before cloning. The +# check is shared with omarchy-plugin-add and explains itself; a missing checker +# leaves this non-zero, which refuses the URL rather than cloning it. +omarchy-git-url-check "$REPO_URL" || exit 1 THEMES_DIR="$HOME/.config/omarchy/themes" diff --git a/test/shell.d/git-url-check-test.sh b/test/shell.d/git-url-check-test.sh new file mode 100755 index 00000000..405fc048 --- /dev/null +++ b/test/shell.d/git-url-check-test.sh @@ -0,0 +1,79 @@ +#!/bin/bash + +set -euo pipefail + +# omarchy-git-url-check decides which URLs omarchy-theme-install and +# omarchy-plugin-add are willing to hand to `git clone`. git resolves a remote +# helper -- a program it runs at clone time -- from exactly two URL shapes, +# `::
` and `://
`, so those are the two shapes +# asserted here, alongside every legitimate form a user is likely to paste. + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +check() { + "$ROOT/bin/omarchy-git-url-check" "$@" 2>&1 +} + +# `::
`, the shape that runs a program. `ext::` is the dangerous +# one: git runs the rest as a shell command once protocol.ext.allow permits it. +for url in "ext::sh -c id" "fd::0,1" "gcrypt::x" "a+b::x" "a.b::x" "a-b::x" "1::x"; do + output=$(check "$url") && + fail "omarchy-git-url-check refuses the transport helper '$url'" "$output" + grep -qF "names a git option or transport helper" <<<"$output" || + fail "omarchy-git-url-check names the helper rejection for '$url'" "$output" +done + +pass "a ::
URL is refused" + +# `://
`, the shape #8067 left open: git looks up +# git-remote- for any scheme it does not implement itself, so an +# allowlist is the only form of this check that holds. +for url in "ext://sh -c id" "fd://17" "gcrypt://example.com/x" "zzz://a" "ZZZ://a" "HTTPS://github.com/a/b"; do + output=$(check "$url") && + fail "omarchy-git-url-check refuses the '$url' transport" "$output" + grep -qF "which Omarchy does not clone from" <<<"$output" || + fail "omarchy-git-url-check names the transport rejection for '$url'" "$output" +done + +pass "a ://
URL outside git's own transports is refused" + +# A leading dash is an option to git, not a URL. +for url in "-x" "--upload-pack=touch /tmp/pwned" "-oProxyCommand=x"; do + output=$(check "$url") && + fail "omarchy-git-url-check refuses the option '$url'" "$output" +done + +pass "a URL shaped like a git option is refused" + +output=$(check "") && fail "omarchy-git-url-check refuses an empty URL" "$output" +output=$(check) && fail "omarchy-git-url-check refuses a missing URL" "$output" + +pass "an empty URL is refused" + +# Everything a user actually pastes. The scp-style forms carry a single colon, +# which git never reads as a helper, and the IPv6 host carries `::` inside +# brackets rather than at the start. +for url in \ + "https://github.com/acme/omarchy-weather.git" \ + "http://example.com/a/b.git" \ + "https://user:token@github.com/acme/repo.git" \ + "ssh://git@github.com/acme/repo.git" \ + "ssh://git@[2001:db8::1]:22/org/repo.git" \ + "git://example.com/repo.git" \ + "git+ssh://git@example.com/acme/repo.git" \ + "ssh+git://git@example.com/acme/repo.git" \ + "ftp://example.com/repo.git" \ + "ftps://example.com/repo.git" \ + "file:///home/me/repo" \ + "git@github.com:acme/repo.git" \ + "git@[2001:db8::1]:org/repo.git" \ + "host:-s/foo.git" \ + "/home/me/repo" \ + "./repo" \ + "../repo" \ + "repo"; do + output=$(check "$url") || + fail "omarchy-git-url-check accepts the legitimate URL '$url'" "$output" +done + +pass "the URL forms a user pastes are accepted" diff --git a/test/shell.d/plugin-add-test.sh b/test/shell.d/plugin-add-test.sh index 477d39a8..3e3ca57a 100644 --- a/test/shell.d/plugin-add-test.sh +++ b/test/shell.d/plugin-add-test.sh @@ -111,6 +111,20 @@ for bad in "ext::sh -c touch /tmp/omarchy-guard-test" "fd::17"; do done pass "plugin add rejects transport-helper URLs before cloning" +# The `://` spelling of the same thing: git resolves git-remote- for any +# scheme it does not implement itself, so `ext::` and `ext://` reach the same +# helper and both have to be refused. +for bad in "ext://sh -c id" "gcrypt://example.com/x"; do + rm -f "$clone_marker" + output=$(add_url "$bad") && + fail "plugin add rejects a transport-scheme URL: $bad" "$output" + grep -qF "which Omarchy does not clone from" <<<"$output" || + fail "plugin add names the transport-scheme rejection: $bad" "$output" + [[ ! -e $clone_marker ]] || + fail "plugin add reached git clone for a transport-scheme URL: $bad" +done +pass "plugin add rejects transport-scheme URLs before cloning" + # Option-shaped URLs on argv are refused before clone — by the option parser # (`-*` falls to "unknown add option"), not the guard. The guard's own # leading-dash arm is only reachable through the interactive gum prompt and is diff --git a/test/shell.d/theme-install-guards-test.sh b/test/shell.d/theme-install-guards-test.sh index be2d41f5..bc29338e 100755 --- a/test/shell.d/theme-install-guards-test.sh +++ b/test/shell.d/theme-install-guards-test.sh @@ -40,7 +40,7 @@ install_theme() { : >"$git_calls" : >"$theme_calls" - HOME="$test_tmp/home" PATH="$mock_bin:$PATH" \ + HOME="$test_tmp/home" PATH="${2-$mock_bin:$ROOT/bin:$PATH}" \ OMARCHY_TEST_GIT_CALLS="$git_calls" OMARCHY_TEST_THEME_CALLS="$theme_calls" \ bash "$ROOT/bin/omarchy-theme-install" "$1" >"$test_tmp/out" 2>&1 || return $? } @@ -58,6 +58,29 @@ done pass "a URL that names a git option or a transport helper never reaches git" +# git resolves git-remote- for any scheme it does not implement itself, +# so the `://` spelling of a helper has to be refused as well as the `::` one. +for url in "ext://sh -c id" "fd://17" "gcrypt://example.com/x"; do + if install_theme "$url"; then + fail "omarchy-theme-install refuses the URL '$url'" + fi + + [[ ! -s $git_calls ]] || fail "omarchy-theme-install refuses '$url' before running git" "$(cat "$git_calls")" +done + +pass "a URL naming a transport git does not implement never reaches git" + +# The checker is a separate command, so its absence has to refuse the URL rather +# than wave it through to git. +if install_theme "https://github.com/example/omarchy-cool-theme.git" "$mock_bin:$PATH"; then + fail "omarchy-theme-install refuses a URL it cannot check" +fi + +[[ ! -s $git_calls ]] || + fail "omarchy-theme-install refuses an unchecked URL before running git" "$(cat "$git_calls")" + +pass "a missing url checker refuses the URL instead of cloning it" + # A URL whose derived name would escape the themes directory. for url in "https://example.com/..git" "https://example.com/.git"; do if install_theme "$url"; then From 4637735aa2e98851c68429df1a71b6c361760609 Mon Sep 17 00:00:00 2001 From: Mehmet INCE <4004716+mdisec@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:37:55 +0100 Subject: [PATCH 09/41] Pin trusted PATH in privileged DNS helper (#8172) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Pin PATH to trusted dirs when omarchy-dns holds root A dev link prepends a user-writable checkout bin/ to sudo's secure_path, so the passwordless `omarchy-dns Cloudflare` sudoers rule lets root resolve a bare helper (dirname, install, tee, nmcli, ...) out of that checkout — turning checkout-write access into arbitrary root execution. Pin PATH to trusted system directories once EUID is 0, leaving the unprivileged wrapper phase free to locate sudo/pkexec on the caller's PATH. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YUWoHbBoKMjsjV6X3nu1H5 * Assert the trusted-PATH pin is gated on root, not merely present The EUID assertion matched `(( EUID == 0 ))` anywhere in the file, and require_root has carried that exact test since long before the pin existed. Deleting the pin left the assertion passing, so it stood for nothing: a run with the pin neutered reached the behavioural probe with both greps green. Anchor on the unindented guard and require the pin to be the line it opens, which no other construct in the script satisfies. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh * Skip the DNS trusted-PATH probe where user namespaces are unavailable `fail` ends the file, so a sandbox or hardened kernel that refuses unprivileged user namespaces did not just lose the probe — it took the two elevation assertions below it down as well, reporting a product defect where there was only a missing capability. The non-graphical suites are meant to run on any machine and treat a skip as a passing test, the way require_compositor and plugin-add-test.sh already do. Gate the probe on the namespace it needs and say so when it is absent; the static checks above and the elevation checks below run either way. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: David Heinemeier Hansson Co-authored-by: Codex XHigh --- bin/omarchy-dns | 12 +++++++ test/shell.d/dns-sudoers-test.sh | 54 ++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/bin/omarchy-dns b/bin/omarchy-dns index a22e028c..60f60e74 100755 --- a/bin/omarchy-dns +++ b/bin/omarchy-dns @@ -6,6 +6,18 @@ set -euo pipefail +# Whenever this runs as root — invoked directly through the passwordless +# sudoers rule, or re-execed by require_root below — sudo's secure_path decides +# where a bare helper resolves, and a dev link (etc/sudoers.d/omarchy-dev-path) +# prepends a user-writable checkout bin/ to it. Every helper this script calls +# by bare name (dirname, install, tee, rm, nmcli, systemctl, awk) is a system +# tool, never an omarchy-* command, so pin PATH to trusted system directories +# and keep root from resolving one out of that checkout. The unprivileged +# wrapper phase keeps the caller's PATH so it can still find sudo/pkexec. +if (( EUID == 0 )); then + export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/sbin:/bin:/sbin +fi + NM_DNS_CONF=/etc/NetworkManager/conf.d/20-omarchy-dns.conf provider_from_arg() { diff --git a/test/shell.d/dns-sudoers-test.sh b/test/shell.d/dns-sudoers-test.sh index e8424e4a..e5390f46 100755 --- a/test/shell.d/dns-sudoers-test.sh +++ b/test/shell.d/dns-sudoers-test.sh @@ -30,6 +30,60 @@ grep -E 'sudo -n -l -l' "$dns" >/dev/null || pass "dns sudoers rule is scoped to the stock providers" +# The privileged half runs as root under sudo's secure_path, and a dev link +# (etc/sudoers.d/omarchy-dev-path) prepends a user-writable checkout bin/ to it. +# Every helper the script calls by bare name -- dirname, install, tee, nmcli, +# systemctl, awk -- is a system tool, so once it holds root the script pins PATH +# to trusted system directories and never resolves one of them out of the +# checkout. The unprivileged wrapper phase keeps the caller's PATH, which is why +# the pin is gated on EUID rather than set unconditionally. +grep -Eq '^\s*export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin' "$dns" || + fail "omarchy-dns pins PATH to trusted system directories when it holds root" +# require_root carries its own `(( EUID == 0 ))`, so matching that text alone +# would pass with the pin deleted. Anchor on the unindented guard and require the +# pin to be the line it opens. +gated=$(grep -A1 -E '^if \(\( EUID == 0 \)\); then$' "$dns" || true) +[[ $gated == *"export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin"* ]] || + fail "omarchy-dns gates the trusted-PATH pin on holding root" + +# The no-argument path only reads DNS config, so exercise the privileged phase +# directly when the suite is root and as namespaced root otherwise. This reaches +# tr while EUID is 0 without giving an ordinary test run any host privileges. +root_runner=() +if (( EUID != 0 )); then + root_runner=(unshare --user --map-root-user) +fi + +# A sandbox or a hardened kernel can refuse unprivileged user namespaces, and +# the non-graphical suites have to stay green on any machine -- a skip is a +# passing test. Only the runtime probe needs the namespace; the static checks +# above and the elevation checks below run either way. +if (( EUID == 0 )) || unshare --user --map-root-user true 2>/dev/null; then + poison_dir=$(mktemp -d) + poison_ran="$poison_dir/ran" + for helper in tr awk dirname install tee; do + cat >"$poison_dir/$helper" <"$poison_ran" +exec "/usr/bin/$helper" "\$@" +SH + chmod +x "$poison_dir/$helper" + done + + if ! PATH="$poison_dir:$PATH" "${root_runner[@]}" bash "$dns" /dev/null 2>&1; then + rm -rf "$poison_dir" + fail "root omarchy-dns failed its read-only trusted-PATH probe" + fi + if [[ -e $poison_ran ]]; then + rm -rf "$poison_dir" + fail "root omarchy-dns resolved a bare helper from the front of PATH instead of a trusted system path" + fi + rm -rf "$poison_dir" + pass "root omarchy-dns resolves system helpers from a trusted PATH, not the invocation PATH" +else + pass "no unprivileged user namespace; skipping the root trusted-PATH probe" +fi + # require_root returns immediately for root, so the stubs below would not stand # between the script and the host's real NetworkManager and resolved config. if (( EUID == 0 )); then From 9285b19d6a72eba3df8537d62a4cd5506a803d89 Mon Sep 17 00:00:00 2001 From: Adrian Rangel Date: Tue, 25 Aug 2026 03:03:12 -0600 Subject: [PATCH 10/41] [Security] Stop USB device names from being executed as Hyprland Lua (#8129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Stop device names from being executed as Hyprland Lua Hyprland input-device and monitor names come from USB descriptors and hyprctl output, so they are attacker-influenceable, yet the toggle and monitor commands interpolated them straight into hyprctl eval and into generated Lua that Hyprland re-executes on every reload. The input-device toggle keys are bound with locked = true, so a malicious USB name reached Lua code execution from the lock screen; a persisted disable made it run on every start. This closes that class everywhere it appeared. - The touchpad/touchscreen disable is now the device name in a plain-text sidecar file, read back by a packaged Lua module on reload, never a generated Lua file. hyprctl eval Lua-quotes the name and control characters are rejected outright. - Dropped the shipped *-disabled.lua templates so nothing seeds a disabled state to /etc/skel, making the name file the single source of truth read from a hardcoded ~/.local/state to match the sibling tools. - The reload loader excludes those two legacy filenames, so a leftover generated *-disabled.lua on a not-yet-migrated install can never be sourced as code again; a migration then recovers the device name from it and deletes it, sanitizing installs that ran the vulnerable version. - All four monitor scripts (internal, mirror, clamshell, scaling) now validate an output name against a plain-connector-name pattern before writing it as Lua, closing the same latent pattern in the siblings. - paths.lua treats a set-but-empty XDG_STATE_HOME as unset, matching the bash side so state is never read from the filesystem root. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0144ZDt44vtxjyF8j9Y88NrM * Let a failing Lua assertion fail the test lua discards the status of a chunk read from stdin, so a blown assert printed its traceback and still exited 0: the surrounding `set -euo pipefail` never fired and the following `pass` printed `ok`. Every Lua block in these two files was unenforced, including the assertion that a quoted `hyprctl eval` cannot reach `os.execute` and the negative control that proves the test can detect the injection at all. Passing the chunk as a script argument makes lua report the failure. Co-Authored-By: Claude Opus 5 (1M context) * Re-apply a recovered input-device disable to the running session The package hook reloads Hyprland during `omarchy-update-system-pkgs`, before `omarchy-migrate` runs, and at that reload the generated Lua is already excluded while the name file does not exist yet — so a touchpad or touchscreen the user had switched off comes back on, and stays on until their next login. Reload once more once the name has been recovered, which is the same path a login already takes to read it. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh --------- Co-authored-by: Claude Fable 5 Co-authored-by: Omarchybot Co-authored-by: Codex XHigh --- bin/omarchy-hyprland-monitor-clamshell | 8 + bin/omarchy-hyprland-monitor-internal | 7 + bin/omarchy-hyprland-monitor-internal-mirror | 9 + bin/omarchy-hyprland-monitor-scaling | 8 + bin/omarchy-toggle-input-device | 54 +++- default/hypr/disabled-input-device.lua | 21 ++ default/hypr/paths.lua | 16 +- default/hypr/require_all.lua | 22 +- default/hypr/toggles.lua | 16 +- manual/13-toggles-idle-screensaver.md | 2 +- migrations/1787618700.sh | 39 +++ test/shell.d/hyprland-paths-test.sh | 28 ++ test/shell.d/monitor-output-name-test.sh | 123 ++++++++ test/shell.d/toggle-input-device-test.sh | 286 +++++++++++++++++++ 14 files changed, 612 insertions(+), 27 deletions(-) create mode 100644 default/hypr/disabled-input-device.lua create mode 100644 migrations/1787618700.sh create mode 100644 test/shell.d/hyprland-paths-test.sh create mode 100644 test/shell.d/monitor-output-name-test.sh create mode 100755 test/shell.d/toggle-input-device-test.sh diff --git a/bin/omarchy-hyprland-monitor-clamshell b/bin/omarchy-hyprland-monitor-clamshell index cbd1b33f..f33f23af 100755 --- a/bin/omarchy-hyprland-monitor-clamshell +++ b/bin/omarchy-hyprland-monitor-clamshell @@ -11,6 +11,14 @@ MONITOR_LUA="$HOME/.config/hypr/monitors.lua" INTERNAL=$(omarchy-hyprland-monitor-laptop) +# INTERNAL is written into generated Lua and hyprctl eval/dispatch below, so a +# name that is not a plain connector string could execute on the next reload. +# Names come from hyprctl; a user-created headless output can carry anything. +if [[ -n $INTERNAL && ! $INTERNAL =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "Refusing unsafe internal monitor name" >&2 + exit 1 +fi + valid_scale() { [[ $1 =~ ^[0-9]+([.][0-9]+)?$ ]] } diff --git a/bin/omarchy-hyprland-monitor-internal b/bin/omarchy-hyprland-monitor-internal index 836073ee..c0be60fa 100755 --- a/bin/omarchy-hyprland-monitor-internal +++ b/bin/omarchy-hyprland-monitor-internal @@ -28,6 +28,13 @@ off() { exit 1 fi + # The name is written into generated Lua below, so only a plain connector + # name may pass; anything else could execute on the next reload. + if [[ ! $INTERNAL =~ ^[A-Za-z0-9._-]+$ ]]; then + omarchy-notification-send -g 󰍹 "Refusing unsafe monitor name" + exit 1 + fi + if ! omarchy-hyprland-monitor-external-active; then omarchy-notification-send -g 󰍹 "Can't disable the only active display" exit 1 diff --git a/bin/omarchy-hyprland-monitor-internal-mirror b/bin/omarchy-hyprland-monitor-internal-mirror index d5213d4c..ec48c740 100755 --- a/bin/omarchy-hyprland-monitor-internal-mirror +++ b/bin/omarchy-hyprland-monitor-internal-mirror @@ -22,6 +22,15 @@ on() { exit 1 fi + # Both names are written into generated Lua below, so only plain connector + # names may pass; a user-created headless output can carry any name. + for output in "$INTERNAL" "$EXTERNAL"; do + if [[ ! $output =~ ^[A-Za-z0-9._-]+$ ]]; then + omarchy-notification-send -g 󰍹 "Refusing unsafe monitor name" + exit 1 + fi + done + omarchy-hyprland-toggle $DISABLE_TOGGLE off if omarchy-hyprland-toggle-disabled $TOGGLE; then diff --git a/bin/omarchy-hyprland-monitor-scaling b/bin/omarchy-hyprland-monitor-scaling index 240a1ea5..8de107f4 100755 --- a/bin/omarchy-hyprland-monitor-scaling +++ b/bin/omarchy-hyprland-monitor-scaling @@ -80,6 +80,14 @@ set_scale() { local width="$(echo "$monitor_info" | jq -r '.width')" local height="$(echo "$monitor_info" | jq -r '.height')" local refresh_rate="$(echo "$monitor_info" | jq -r '.refreshRate')" + + # active_monitor is written into the Lua string eval'd below, so only a plain + # connector name may pass; a hostile output name could execute otherwise. + if [[ ! $active_monitor =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "Refusing unsafe monitor name" >&2 + exit 1 + fi + local new_scale="$(clean_scale "$requested_scale" "$width" "$height")" # GTK only honors integer GDK_SCALE values, so persist the nearest whole # factor even when the monitor scale itself is fractional. diff --git a/bin/omarchy-toggle-input-device b/bin/omarchy-toggle-input-device index aea0ca11..77ff084a 100755 --- a/bin/omarchy-toggle-input-device +++ b/bin/omarchy-toggle-input-device @@ -7,44 +7,70 @@ KIND="${1:-}" ACTION="${2:-toggle}" +usage() { + echo "Usage: omarchy-toggle-input-device [on|off|toggle]" >&2 +} + case "$KIND" in touchpad) LABEL="Touchpad" ICON="touchpad" ;; touchscreen) LABEL="Touchscreen" ICON="touch" ;; *) - echo "Usage: omarchy-toggle-input-device [on|off|toggle]" >&2 + usage exit 1 ;; esac -# Hyprland sources this directory on reload, so the disabled state survives restarts -STATE_FILE="$HOME/.local/state/omarchy/toggles/hypr/$KIND-disabled.lua" +# The persisted disable is the device name stored as plain data; on every +# reload default/hypr/disabled-input-device.lua reads it back and disables the +# device. Names come from USB descriptors and must not be interpolated into +# shell or Lua. The path is hardcoded to ~/.local/state like the sibling +# toggle tools, so it keeps working when XDG_STATE_HOME diverges. +NAME_FILE="$HOME/.local/state/omarchy/toggles/hypr/$KIND-disabled-name" device="$("omarchy-hw-$KIND")" -if [[ -z $device ]]; then - echo "No $KIND device found" >&2 - exit 1 -fi +require_device() { + if [[ -z $device ]]; then + echo "No $KIND device found" >&2 + exit 1 + fi + + if [[ $device == *[[:cntrl:]]* ]]; then + echo "Invalid $KIND device name" >&2 + exit 1 + fi +} + +apply_device() { + local enabled=$1 + local quoted=${device//\\/\\\\} + quoted=${quoted//\"/\\\"} + hyprctl eval "hl.device({ name = \"$quoted\", enabled = $enabled })" >/dev/null +} enable() { - hyprctl eval "hl.device({ name = \"$device\", enabled = true })" >/dev/null - rm -f "$STATE_FILE" + # Clear the persisted state before requiring a usable device, so a device + # that stops reporting a valid name can never wedge the disable in place. + rm -f "$NAME_FILE" + require_device + apply_device true omarchy-osd -i "$ICON" -m "$LABEL enabled" } disable() { - hyprctl eval "hl.device({ name = \"$device\", enabled = false })" >/dev/null - mkdir -p "$(dirname "$STATE_FILE")" - printf 'hl.device({ name = "%s", enabled = false })\n' "$device" >"$STATE_FILE" + require_device + apply_device false + mkdir -p "$(dirname "$NAME_FILE")" + printf '%s\n' "$device" >"$NAME_FILE" omarchy-osd -i "$ICON" -m "$LABEL disabled" } case "$ACTION" in on) enable ;; off) disable ;; - toggle) if [[ -f $STATE_FILE ]]; then enable; else disable; fi ;; + toggle) if [[ -f $NAME_FILE ]]; then enable; else disable; fi ;; *) - echo "Usage: omarchy-toggle-input-device [on|off|toggle]" >&2 + usage exit 1 ;; esac diff --git a/default/hypr/disabled-input-device.lua b/default/hypr/disabled-input-device.lua new file mode 100644 index 00000000..c9c20943 --- /dev/null +++ b/default/hypr/disabled-input-device.lua @@ -0,0 +1,21 @@ +-- Disable a Hyprland input device whose name was stored as data, not Lua. +-- Device names come from USB descriptors and must never be loaded as code. + +local paths = require("default.hypr.paths") + +return function(kind) + -- Hardcoded to ~/.local/state to match omarchy-toggle-input-device and the + -- sibling bash toggle tools, which all write there regardless of + -- XDG_STATE_HOME. + local file = io.open(paths.home .. "/.local/state/omarchy/toggles/hypr/" .. kind .. "-disabled-name", "r") + if not file then + return + end + + local name = file:read("*l") + file:close() + + if name and name ~= "" then + hl.device({ name = name, enabled = false }) + end +end diff --git a/default/hypr/paths.lua b/default/hypr/paths.lua index dd3fc348..c20489f6 100644 --- a/default/hypr/paths.lua +++ b/default/hypr/paths.lua @@ -4,9 +4,19 @@ local home = os.getenv("HOME") +-- A variable that is set but empty means "unset" (XDG Base Directory spec); +-- bash's ${VAR:-fallback} in the sibling tools treats it the same way. +local function env_or(name, fallback) + local value = os.getenv(name) + if value == nil or value == "" then + return fallback + end + return value +end + return { home = home, - config_home = os.getenv("XDG_CONFIG_HOME") or (home .. "/.config"), - state_home = os.getenv("XDG_STATE_HOME") or (home .. "/.local/state"), - omarchy_path = os.getenv("OMARCHY_PATH") or "/usr/share/omarchy", + config_home = env_or("XDG_CONFIG_HOME", home .. "/.config"), + state_home = env_or("XDG_STATE_HOME", home .. "/.local/state"), + omarchy_path = env_or("OMARCHY_PATH", "/usr/share/omarchy"), } diff --git a/default/hypr/require_all.lua b/default/hypr/require_all.lua index 56153a00..b77a55cd 100644 --- a/default/hypr/require_all.lua +++ b/default/hypr/require_all.lua @@ -4,6 +4,8 @@ -- Pass a module prefix for normal package.path modules, e.g. -- require_all.files(paths.omarchy_path .. "/default/hypr/apps", "default.hypr.apps") -- Pass nil as the prefix when the directory itself has been added to package.path. +-- Pass options.exclude as a set of base names (without ".lua") to skip; a legacy +-- file that must never be loaded as code stays on disk for a migration to remove. local M = {} @@ -12,19 +14,23 @@ local function shell_quote(path) end function M.files(dir, module_prefix, options) + local exclude = options and options.exclude or {} local handle = io.popen("find " .. shell_quote(dir) .. " -maxdepth 1 -type f -name '*.lua' -printf '%f\\n' 2>/dev/null | sort") if handle then for filename in handle:lines() do - local module = filename:gsub("%.lua$", "") - if module_prefix then - module = module_prefix .. "." .. module - end + local name = filename:gsub("%.lua$", "") + if not exclude[name] then + local module = name + if module_prefix then + module = module_prefix .. "." .. module + end - if options and options.reload then - package.loaded[module] = nil - end + if options and options.reload then + package.loaded[module] = nil + end - require(module) + require(module) + end end handle:close() end diff --git a/default/hypr/toggles.lua b/default/hypr/toggles.lua index 1ca62205..bb9f28d4 100644 --- a/default/hypr/toggles.lua +++ b/default/hypr/toggles.lua @@ -4,6 +4,20 @@ local require_all = require("default.hypr.require_all") local toggles_dir = paths.state_home .. "/omarchy/toggles/hypr" package.path = toggles_dir .. "/?.lua;" .. package.path -require_all.files(toggles_dir, nil, { reload = true }) +-- touchpad-disabled.lua / touchscreen-disabled.lua were generated Lua in older +-- versions and could carry an injected USB device name. They must never be loaded +-- as code again: exclude them so a not-yet-migrated install cannot execute a +-- leftover payload on reload. The migration recovers the name and deletes them. +require_all.files(toggles_dir, nil, { + reload = true, + exclude = { + ["touchpad-disabled"] = true, + ["touchscreen-disabled"] = true, + }, +}) + +local disabled_input_device = require("default.hypr.disabled-input-device") +disabled_input_device("touchpad") +disabled_input_device("touchscreen") require("default.hypr.workspace-layouts") diff --git a/manual/13-toggles-idle-screensaver.md b/manual/13-toggles-idle-screensaver.md index 6c01398c..2effdac1 100644 --- a/manual/13-toggles-idle-screensaver.md +++ b/manual/13-toggles-idle-screensaver.md @@ -21,7 +21,7 @@ From the terminal, the same switches are `omarchy toggle `. Run `omarchy | Suspend | — | `omarchy toggle suspend` | | Hybrid GPU | — | `omarchy toggle hybrid gpu` | -The touchpad, touchscreen, and hybrid GPU switches live under _Trigger > Hardware_ (`Super + Ctrl + H`) rather than under Toggle, since they only show up when you actually have that hardware. The touchpad and touchscreen ones survive a Hyprland reload — the disabled state is written back out as a small Lua file that Hyprland sources on startup. +The touchpad, touchscreen, and hybrid GPU switches live under _Trigger > Hardware_ (`Super + Ctrl + H`) rather than under Toggle, since they only show up when you actually have that hardware. The touchpad and touchscreen ones survive a Hyprland reload — the disabled device's name is saved to a small state file that Hyprland reads on startup to disable it again. The Toggle menu also carries a few things that aren't `omarchy toggle` commands but behave the same: battery percentage in the bar, workspace layout (`Super + L`), window gaps (`Super + Shift + Backspace`), and the 1-window square aspect (`Super + Ctrl + Backspace`). diff --git a/migrations/1787618700.sh b/migrations/1787618700.sh new file mode 100644 index 00000000..a5e8e6a0 --- /dev/null +++ b/migrations/1787618700.sh @@ -0,0 +1,39 @@ +echo "Store Hyprland input-device names as data instead of generated Lua" + +# omarchy-toggle-input-device used to interpolate hyprctl device names into +# hyprctl eval and a generated Lua file. Those names come from USB descriptors, +# so recover the plain device name as data and delete the generated Lua. A name +# that could have broken out of the old Lua string literal is discarded, not +# trusted. The old script wrote to ~/.local/state regardless of XDG_STATE_HOME. +toggles_dir="$HOME/.local/state/omarchy/toggles/hypr" + +reapply=0 + +for kind in touchpad touchscreen; do + state_file="$toggles_dir/$kind-disabled.lua" + name_file="$toggles_dir/$kind-disabled-name" + + [[ -f $state_file ]] || continue + + if [[ ! -f $name_file && -r $state_file ]]; then + old=$(<"$state_file") + pattern='^hl\.device\(\{ name = "([^"\\[:cntrl:]]+)", enabled = false \}\)$' + if [[ $old =~ $pattern ]]; then + printf '%s\n' "${BASH_REMATCH[1]}" >"$name_file" + fi + fi + + rm -f "$state_file" + + if [[ -f $name_file ]]; then + reapply=1 + fi +done + +# The package hook reloads Hyprland before migrations run, so this session has +# already dropped the disable: the generated Lua is no longer loaded and the +# name file did not exist yet to replace it. Reload once more now that it does, +# or the device the user switched off stays on until their next login. +if (( reapply )); then + hyprctl reload >/dev/null 2>&1 || true +fi diff --git a/test/shell.d/hyprland-paths-test.sh b/test/shell.d/hyprland-paths-test.sh new file mode 100644 index 00000000..4d880754 --- /dev/null +++ b/test/shell.d/hyprland-paths-test.sh @@ -0,0 +1,28 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +require_command lua + +run_paths() { + lua - <<'LUA' +package.path = os.getenv("OMARCHY_PATH") .. "/?.lua;" .. package.path +local paths = require("default.hypr.paths") +assert(paths.config_home == os.getenv("EXPECTED_CONFIG"), "config_home: " .. paths.config_home) +assert(paths.state_home == os.getenv("EXPECTED_STATE"), "state_home: " .. paths.state_home) +LUA +} + +HOME="/home/test-user" OMARCHY_PATH="$ROOT" \ + XDG_CONFIG_HOME= XDG_STATE_HOME= \ + EXPECTED_CONFIG="/home/test-user/.config" EXPECTED_STATE="/home/test-user/.local/state" \ + run_paths +pass "empty XDG path variables fall back to their defaults" + +HOME="/home/test-user" OMARCHY_PATH="$ROOT" \ + XDG_CONFIG_HOME="/custom/config" XDG_STATE_HOME="/custom/state" \ + EXPECTED_CONFIG="/custom/config" EXPECTED_STATE="/custom/state" \ + run_paths +pass "set XDG path variables are honored" diff --git a/test/shell.d/monitor-output-name-test.sh b/test/shell.d/monitor-output-name-test.sh new file mode 100644 index 00000000..14cd8852 --- /dev/null +++ b/test/shell.d/monitor-output-name-test.sh @@ -0,0 +1,123 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +require_command jq + +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +stub_dir="$tmpdir/bin" +home_dir="$tmpdir/home" +monitors_json="$tmpdir/monitors.json" +flag_dir="$home_dir/.local/state/omarchy/toggles/hypr" +mkdir -p "$stub_dir" "$flag_dir" + +make_stub() { + local name=$1 + local body=$2 + printf '#!/bin/bash\n%s\n' "$body" >"$stub_dir/$name" + chmod +x "$stub_dir/$name" +} + +make_stub omarchy-notification-send ':' +make_stub omarchy-hyprland-monitor-external-active 'exit 0' +make_stub omarchy-hyprland-toggle-disabled 'exit 0' +make_stub omarchy-hyprland-toggle ':' +make_stub omarchy-hyprland-monitor-internal ':' +make_stub omarchy-hyprland-monitor-internal-mirror ':' +make_stub omarchy-hw-clamshell 'exit 0' +make_stub omarchy-hyprland-monitor-laptop 'printf "%s\n" "$LAPTOP_NAME"' +make_stub hyprctl 'case "$1" in + monitors) cat "$MONITORS_JSON" ;; + eval) printf "%s\n" "$2" >>"$EVAL_LOG" ;; +esac' + +eval_log="$tmpdir/eval.log" + +run_monitor() { + local command=$1 + shift + : >"$eval_log" + HOME="$home_dir" \ + XDG_STATE_HOME="$home_dir/.local/state" \ + LAPTOP_NAME="${LAPTOP_NAME:-eDP-1}" \ + MONITORS_JSON="$monitors_json" \ + EVAL_LOG="$eval_log" \ + PATH="$stub_dir:$ROOT/bin:$PATH" \ + "$ROOT/bin/$command" "$@" +} + +printf '[{"name":"eDP-1"},{"name":"DP-3"}]\n' >"$monitors_json" + +disable_flag="$flag_dir/internal-monitor-disable.lua" +run_monitor omarchy-hyprland-monitor-internal off +grep -Fx 'hl.monitor({ output = "eDP-1", disabled = true })' "$disable_flag" >/dev/null || + fail "internal off writes the connector name into the toggle flag" +pass "internal off accepts a plain connector name" + +rm -f "$disable_flag" +set +e +LAPTOP_NAME='eDP-1", disabled = false })os.execute("calc")--' \ + run_monitor omarchy-hyprland-monitor-internal off >/dev/null 2>&1 +status=$? +set -e +(( status != 0 )) || fail "internal off rejects a monitor name with Lua metacharacters" +[[ ! -e $disable_flag ]] || fail "an unsafe monitor name is not written as Lua" +pass "internal off refuses an unsafe monitor name" + +mirror_flag="$flag_dir/internal-monitor-mirror.lua" +run_monitor omarchy-hyprland-monitor-internal-mirror on +grep -Fx 'hl.monitor({ output = "DP-3", mode = "preferred", position = "auto", scale = 1, mirror = "eDP-1" })' \ + "$mirror_flag" >/dev/null || + fail "mirror on writes the connector names into the toggle flag" +pass "mirror on accepts plain connector names" + +rm -f "$mirror_flag" +printf '[{"name":"eDP-1"},{"name":"HEAD\\" })os.execute(\\"calc\\")--"}]\n' >"$monitors_json" +set +e +run_monitor omarchy-hyprland-monitor-internal-mirror on >/dev/null 2>&1 +status=$? +set -e +(( status != 0 )) || fail "mirror on rejects an external name with Lua metacharacters" +[[ ! -e $mirror_flag ]] || fail "an unsafe external monitor name is not written as Lua" +pass "mirror on refuses an unsafe headless output name" + +# The clamshell sync writes the internal-monitor name into generated Lua too. +clamshell_flag="$flag_dir/internal-monitor-clamshell.lua" +printf '[{"name":"eDP-1"}]\n' >"$monitors_json" +rm -f "$clamshell_flag" +run_monitor omarchy-hyprland-monitor-clamshell +grep -Fx 'hl.monitor({ output = "eDP-1", disabled = true })' "$clamshell_flag" >/dev/null || + fail "clamshell disable writes the connector name into the toggle flag" +pass "clamshell disable accepts a plain connector name" + +rm -f "$clamshell_flag" +set +e +LAPTOP_NAME='eDP-1", disabled = true })os.execute("calc")--' \ + run_monitor omarchy-hyprland-monitor-clamshell >/dev/null 2>&1 +status=$? +set -e +(( status != 0 )) || fail "clamshell rejects a monitor name with Lua metacharacters" +[[ ! -e $clamshell_flag ]] || fail "an unsafe internal monitor name is not written as clamshell Lua" +pass "clamshell refuses an unsafe internal monitor name" + +# The scaling command eval's the focused-monitor name into a Lua string. +printf '[{"name":"eDP-1","focused":true,"scale":1.0,"width":1920,"height":1080,"refreshRate":60.0}]\n' \ + >"$monitors_json" +run_monitor omarchy-hyprland-monitor-scaling 1.6 +grep -F 'hl.monitor({ output = "eDP-1"' "$eval_log" >/dev/null || + fail "scaling eval's the focused connector name" +pass "scaling accepts a plain connector name" + +printf '[{"name":"eDP-1\\" })os.execute(\\"calc\\")--","focused":true,"scale":1.0,"width":1920,"height":1080,"refreshRate":60.0}]\n' \ + >"$monitors_json" +set +e +run_monitor omarchy-hyprland-monitor-scaling 1.6 >/dev/null 2>&1 +status=$? +set -e +(( status != 0 )) || fail "scaling rejects a focused monitor name with Lua metacharacters" +[[ ! -s $eval_log ]] || fail "an unsafe focused monitor name is not eval'd as Lua" +pass "scaling refuses an unsafe focused monitor name" diff --git a/test/shell.d/toggle-input-device-test.sh b/test/shell.d/toggle-input-device-test.sh new file mode 100755 index 00000000..2b0c10d8 --- /dev/null +++ b/test/shell.d/toggle-input-device-test.sh @@ -0,0 +1,286 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +require_command lua + +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +stub_dir="$tmpdir/bin" +home_dir="$tmpdir/home" +xdg_decoy="$tmpdir/xdg-decoy" +log_file="$tmpdir/hyprctl.log" +marker="$tmpdir/marker" +mkdir -p "$stub_dir" "$home_dir" "$xdg_decoy" + +state_dir="$home_dir/.local/state/omarchy/toggles/hypr" +name_file="$state_dir/touchpad-disabled-name" +state_lua="$state_dir/touchpad-disabled.lua" + +cat >"$stub_dir/hyprctl" <<'EOF' +#!/bin/bash +case $1 in + eval) printf '%s\n' "$2" >>"$HYPRCTL_LOG" ;; + reload) printf 'reload\n' >>"$HYPRCTL_LOG" ;; +esac +EOF +chmod +x "$stub_dir/hyprctl" + +cat >"$stub_dir/omarchy-osd" <<'EOF' +#!/bin/bash +: +EOF +chmod +x "$stub_dir/omarchy-osd" + +stub_device() { + local kind=$1 + local name=$2 + cat >"$stub_dir/omarchy-hw-$kind" </dev/null) ]] || + fail "input-device state must ignore XDG_STATE_HOME" +} + +: >"$log_file" +stub_device touchpad 'elan-touchpad' + +run_toggle touchpad off +[[ $(<"$name_file") == "elan-touchpad" ]] || fail "touchpad disable stores the device name as data" +[[ ! -e $state_lua ]] || fail "touchpad disable writes no generated Lua" +grep -Fx 'hl.device({ name = "elan-touchpad", enabled = false })' "$log_file" >/dev/null || + fail "touchpad disable applies a quoted Lua device name" +assert_decoy_untouched +pass "touchpad disable persists the device name as data" + +: >"$log_file" +run_toggle touchpad on +[[ ! -e $name_file ]] || fail "touchpad enable clears the persisted device name" +grep -Fx 'hl.device({ name = "elan-touchpad", enabled = true })' "$log_file" >/dev/null || + fail "touchpad enable applies a quoted Lua device name" +pass "touchpad enable clears persisted disable state" + +run_toggle touchpad +[[ -f $name_file ]] || fail "default toggle action disables an enabled touchpad" +run_toggle touchpad +[[ ! -e $name_file ]] || fail "default toggle action enables a disabled touchpad" +pass "default toggle action flips the persisted state" + +: >"$log_file" +stub_device touchscreen 'wacom-hid-52eb-finger' +ts_name_file="$state_dir/touchscreen-disabled-name" + +run_toggle touchscreen off +[[ $(<"$ts_name_file") == "wacom-hid-52eb-finger" ]] || + fail "touchscreen disable stores the device name as data" +grep -Fx 'hl.device({ name = "wacom-hid-52eb-finger", enabled = false })' "$log_file" >/dev/null || + fail "touchscreen disable applies a quoted Lua device name" +run_toggle touchscreen on +[[ ! -e $ts_name_file ]] || fail "touchscreen enable clears the persisted device name" +pass "touchscreen routes through the same persisted-name state" + +: >"$log_file" +rm -f "$marker" +stub_device touchpad 'touchpad"; touch '"$marker"'; echo "' + +run_toggle touchpad off +[[ ! -e $marker ]] || fail "touchpad disable does not execute metacharacters in the device name" +[[ $(<"$name_file") == 'touchpad"; touch '"$marker"'; echo "' ]] || + fail "a hostile device name is stored only as data" +[[ ! -e $state_lua ]] || fail "a hostile device name is not written as Lua" +grep -F 'hl.device({ name = "touchpad\"' "$log_file" >/dev/null || + fail "hyprctl eval Lua-quotes quotes in the device name" "$(<"$log_file")" +pass "touchpad disable treats USB device names as data" + +HOME="$home_dir" XDG_STATE_HOME="$xdg_decoy" OMARCHY_PATH="$ROOT" MARKER="$marker" lua - <<'LUA' +local seen = {} +hl = { + device = function(opts) + table.insert(seen, opts) + end, +} + +dofile(os.getenv("OMARCHY_PATH") .. "/default/hypr/bootstrap.lua") +require("default.hypr.toggles") +assert(#seen == 1, "reload disables one device") +assert(seen[1].enabled == false) +assert(seen[1].name == 'touchpad"; touch ' .. os.getenv("MARKER") .. '; echo "', "device name is passed as a string") +LUA +pass "Hyprland reload loads the device name as a string" + +# Public PoC device name: USB iProduct is interpolated into hl.device({ name = "..." }). +# os.execute is stubbed so the string is only checked as data. +poc_name='trackpad"})os.execute("~/calc&")--' +stub_device touchpad "$poc_name" + +run_toggle touchpad on +: >"$log_file" +run_toggle touchpad off +[[ $(<"$name_file") == "$poc_name" ]] || fail "PoC device name is stored only as data" +[[ ! -e $state_lua ]] || fail "PoC device name is not written as Lua" + +HOME="$home_dir" XDG_STATE_HOME="$xdg_decoy" OMARCHY_PATH="$ROOT" \ + POC_NAME="$poc_name" EVAL_SNIPPET="$(<"$log_file")" lua - <<'LUA' +local poc = os.getenv("POC_NAME") +local snippet = os.getenv("EVAL_SNIPPET") +local seen, executed = {}, false + +hl = { + device = function(opts) + table.insert(seen, opts) + end, +} +os.execute = function() + executed = true +end + +assert(load(snippet, "eval", "t"))() +assert(executed == false, "quoted hyprctl eval must not run os.execute") +assert(#seen == 1) +assert(seen[1].name == poc) +assert(seen[1].enabled == false) + +seen, executed = {}, false +assert(load('hl.device({ name = "' .. poc .. '", enabled = false })', "unquoted", "t"))() +assert(executed == true, "unquoted interpolation is the Lua injection") + +seen, executed = {}, false +dofile(os.getenv("OMARCHY_PATH") .. "/default/hypr/bootstrap.lua") +require("default.hypr.toggles") +assert(executed == false, "reload must not run os.execute") +assert(#seen == 1) +assert(seen[1].name == poc) +LUA +pass "PoC device name cannot execute via eval or reload" + +cat >"$stub_dir/omarchy-hw-touchpad" <<'EOF' +#!/bin/bash +printf 'evil\nname\n' +EOF +chmod +x "$stub_dir/omarchy-hw-touchpad" + +rm -f "$name_file" +set +e +run_toggle touchpad off >/dev/null 2>&1 +status=$? +set -e +(( status != 0 )) || fail "disable rejects a device name with a newline" +[[ ! -e $name_file ]] || fail "a rejected device name is not persisted" +pass "disable rejects control characters in a device name" + +printf 'elan-touchpad\n' >"$name_file" +set +e +run_toggle touchpad on >/dev/null 2>&1 +status=$? +set -e +(( status != 0 )) || fail "enable still reports an invalid device name" +[[ ! -e $name_file ]] || fail "enable clears persisted state even with an invalid device name" +pass "a bad device name cannot wedge the persisted disable" + +cat >"$stub_dir/omarchy-hw-touchpad" <<'EOF' +#!/bin/bash +: +EOF +chmod +x "$stub_dir/omarchy-hw-touchpad" + +set +e +run_toggle touchpad off >/dev/null 2>&1 +status=$? +set -e +(( status != 0 )) || fail "disable errors when no device is found" +[[ ! -e $name_file ]] || fail "no state is written when no device is found" +pass "disable errors when no device is found" + +# The migration runs with the same XDG decoy: legacy files were written to +# ~/.local/state, so that is where it must look no matter what XDG says. +run_migration() { + HOME="$home_dir" XDG_STATE_HOME="$xdg_decoy" HYPRCTL_LOG="$log_file" \ + PATH="$stub_dir:$ROOT/bin:$PATH" \ + bash -euo pipefail "$ROOT/migrations/1787618700.sh" >/dev/null +} + +mkdir -p "$state_dir" +rm -f "$state_dir"/*-disabled-name +printf 'hl.device({ name = "synps/2-synaptics-touchpad", enabled = false })\n' >"$state_lua" +printf 'hl.device({ name = "hostile\\"")", enabled = false })\n' >"$state_dir/touchscreen-disabled.lua" + +: >"$log_file" +run_migration +[[ $(<"$name_file") == "synps/2-synaptics-touchpad" ]] || + fail "migration recovers a device name containing a slash" +[[ ! -e $state_lua ]] || fail "migration deletes the generated touchpad Lua" +[[ ! -e $state_dir/touchscreen-disabled-name ]] || + fail "migration does not copy a hostile name out of generated Lua" +[[ ! -e $state_dir/touchscreen-disabled.lua ]] || + fail "migration deletes hostile generated Lua even when no name is recovered" +assert_decoy_untouched +# The package hook reloads Hyprland before migrations run, so the disable was +# already dropped for this session; the migration has to put it back. +grep -Fx 'reload' "$log_file" >/dev/null || + fail "migration reloads so the recovered disable applies to this session" +pass "migration recovers plain names and discards hostile generated Lua" + +printf 'kept-name\n' >"$name_file" +printf 'hl.device({ name = "other-touchpad", enabled = false })\n' >"$state_lua" +run_migration +[[ $(<"$name_file") == "kept-name" ]] || fail "migration keeps an existing device-name file" +[[ ! -e $state_lua ]] || fail "migration still deletes the generated Lua" +pass "migration is idempotent over an existing device-name file" + +rm -f "$name_file" +printf 'garbage\n' >"$state_lua" +chmod 000 "$state_lua" +run_migration +[[ ! -e $state_lua ]] || fail "migration removes an unreadable generated Lua" +[[ ! -e $name_file ]] || fail "no name is recovered from an unreadable file" +pass "an unreadable state file does not wedge the migration" + +: >"$log_file" +run_migration +[[ ! -s $log_file ]] || fail "migration with nothing to migrate does not reload" +pass "migration no-ops with nothing left to migrate" + +# A compromised install carries a leftover generated touchpad-disabled.lua whose +# device name broke out into os.execute. Until the migration deletes it, a reload +# must not source it. toggles.lua excludes those two names from require_all, so the +# payload never runs, while a current name-file disable still applies. +reload_home="$tmpdir/reload-home" +reload_state="$reload_home/.local/state/omarchy/toggles/hypr" +mkdir -p "$reload_state" +reload_marker="$tmpdir/reload-executed" +rm -f "$reload_marker" +printf 'hl.device({ name = "trackpad"})os.execute("touch %s")--", enabled = false })\n' "$reload_marker" \ + >"$reload_state/touchpad-disabled.lua" +printf 'elan-touchpad\n' >"$reload_state/touchpad-disabled-name" + +HOME="$reload_home" XDG_STATE_HOME="$reload_home/.local/state" OMARCHY_PATH="$ROOT" lua - <<'LUA' +local disabled = {} +hl = { device = function(opts) table.insert(disabled, opts) end } +dofile(os.getenv("OMARCHY_PATH") .. "/default/hypr/bootstrap.lua") +require("default.hypr.toggles") +assert(#disabled == 1, "only the current name-file disable is applied") +assert(disabled[1].name == "elan-touchpad", "disable uses the stored device name") +assert(disabled[1].enabled == false) +LUA +[[ ! -e $reload_marker ]] || fail "a leftover legacy generated toggle Lua must not execute on reload" +pass "reload excludes leftover legacy toggle Lua while applying the data disable" From 23dab9ec4d7179bb1e03a70ae942653f8daa8003 Mon Sep 17 00:00:00 2001 From: Mehmet INCE <4004716+mdisec@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:09:17 +0100 Subject: [PATCH 11/41] [Security] Stop the FIDO2 setup staging its authfile at a predictable /tmp path (#7904) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Security] Stop the FIDO2 setup staging its authfile at a predictable /tmp path pamu2fcfg wrote to /tmp/fido2 and the registration was then moved into place with `sudo mv`. Any other local user can pre-create /tmp/fido2, and rename(2) does not dereference the final component, so the privileged move installed the attacker's symlink itself as pam_u2f's global authfile -- a file consulted by `sufficient` lines in /etc/pam.d/sudo and /etc/pam.d/polkit-1. The same move also carried the staged file's ownership into /etc, so on every install to date /etc/fido2/fido2 is owned by the invoking user at mode 0644. That needs no attacker: anything running as that uid can add its own credential and satisfy the machine's sudo prompt without root. Stage under mktemp and hand the bytes to `install` instead, so the authfile is always a fresh root-owned regular file rather than an inode a non-root user still controls. Guard the already-registered check with -L, which -f would otherwise follow, and reject a symlinked /etc/fido2 in the remove path for the same reason. A migration takes ownership of authfiles left behind by the old code; it reports a symlink rather than repairing one, since chown would follow it and removing it would strip sudo from anyone whose only credential is the token. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012e6WagC5iUigCGoK1tQeFz * Install the FIDO2 authfile with -T and refuse a non-regular path `install SRC DEST` without -T treats an existing directory at DEST as a destination directory: it drops the credential inside as /etc/fido2/fido2/tmp.XXXX, exits 0, and setup reports a successful registration while PAM goes on reading a path that is not a file. -T makes that an error. The already-registered check has the same blind spot from the other side. -f follows symlinks, so it reads a symlinked authfile as a registration and leaves it in place, and is false for a directory, so it tries to register over one. Only a regular file is a pam_u2f authfile; anything else is now refused with the same advice to remove it and set FIDO2 up again. The test deleted every staged path that fell outside its scratch directory, taking the path from the script under test and already resolved through any symlink -- so a script staging through one would have named a file of the user's and had it unlinked. It now unlinks only a file its own stub wrote into. On a machine that already has /etc/fido2/fido2 the staging assertions cannot run at all, and the file used to pass without exercising one of them. That branch now asserts what the host state promises instead: a regular authfile still has to be recognised as a registration and left alone, and anything else has to be refused. Co-Authored-By: Claude Opus 5 Co-Authored-By: Codex XHigh * Replace the FIDO2 authfile inode rather than chowning it in place Permission is checked at open(2), not at write(2), so a descriptor the registering user opened on the authfile while it was still theirs stays writable through chown and chmod alike. pam_u2f resolves /etc/fido2/fido2 to that same inode, so the repair left the account it authenticates able to append a credential it controls -- the exact state the migration exists to end, now recorded as migrated and never revisited. Installing a fresh root-owned copy and renaming it over the path leaves any such descriptor writing to a file nothing reads. Credit to #7703, which reached the same conclusion independently. An interrupted run heals: the staged copy is root-owned 600 and inert, no marker is written, and the next run replaces it. A directory or device at the authfile path is no more ours to rewrite than a symlink is, and chmod 600 on a directory would only make it untraversable, so both are now reported rather than repaired. The repair had no test, because it names an absolute path no unprivileged suite can write. It is exercised through a scratch copy with that one literal retargeted, rather than by reading the path from the environment: the migration hands `install` and `mv` root, and an operand the caller can choose is a privileged write to anywhere. The copy is only as honest as the substitution, so the test fails if the migration stops naming the path exactly once. Covered: the no-op on a machine that never registered a key, which must not cost a password prompt; the repair itself; the new inode; the absence of a staged copy afterwards; a second account finding it done; and the symlink and non-regular cases. Each assertion was checked against a mutation that defeats it -- notably a repair with the right install call, mode, content and cleanup that writes through the old inode, which only the inode assertion catches. Co-Authored-By: Claude Opus 5 Co-Authored-By: Codex XHigh * Finish hardening FIDO2 authfile installation * Guard the FIDO2 directory and the stage path the setup writes through install -d follows a symlink at /etc/fido2 and applies the mode and ownership to whatever it points at, so the credential would be staged and published inside the link target and that directory silently reopened to root:root 755. The leaf guard above it only covered fido2 itself, and this is the same threat omarchy-remove-security-fido2 already names on its side. mktemp's output is an operand for a privileged tee, chmod, mv and rm. The migration validates it before any of them run; the setup did not, so take only the name it asked for there too. The suite was guarded on the host's own /etc/fido2/fido2 and exited early when one existed, which meant the staging assertions asserted nothing on exactly the machines that use FIDO2. Drive a retargeted copy the way the migration suite already does, so every branch is a fixture and all of them run everywhere. Co-Authored-By: Claude Opus 5 (1M context) * Stop the FIDO2 migration recording a repair it never made omarchy-migrate writes the per-user completion marker on any zero exit, so the two states this migration cannot repair got one line in the update terminal and were then silenced for good: no login notice, no re-run, the migration recorded as done having repaired nothing. Those are precisely the machines where the authfile may already be under someone else's control, so raise them through omarchy-notification-send as well, where they outlive the scrollback. Delivery is best-effort: a machine with no user bus or no notification server must not abort the migration and take every later one with it. The early exit had the same shape of problem. It read the authfile unprivileged, and the old setup created /etc/fido2 with `sudo mkdir -p`, which took the union of the caller's umask and sudoers' 0022 — so registering under `umask 077` left the directory mode 0700 with the user-owned authfile still inside. Absence and "cannot look" are the same answer to those tests, and the migration exited 0 and marked itself complete. Ask root whether a registration is actually behind an untraversable directory before reopening it, so an aborted setup that left an empty directory, or one an administrator keeps private, does not have its mode widened and its group and special bits discarded for a repair it does not need. A machine that never set FIDO2 up has no directory here and still reaches exit 0 without a password prompt. The notification assertion checks argument shape rather than a substring of the command line. The glyph is a private-use codepoint, and losing it shifts every argument left: -g swallows the headline, the body becomes the title, and the message goes out with no description — which a substring match reads as a pass. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh * Cover the FIDO2 removal's symlink guard The -d to -e || -L change is load-bearing for the threat its own comment names — a dangling link at /etc/fido2 that -d reads as absent, left for the next setup to install an authfile through — and it was the one part of this work with no test behind it. Name the directory once so the suite can retarget a copy, the same seam the setup and migration suites use, and assert both halves: the link goes, and the directory it pointed at does not. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: David Heinemeier Hansson Co-authored-by: Codex XHigh --- bin/omarchy-remove-security-fido2 | 9 +- bin/omarchy-setup-security-fido2 | 75 ++- migrations/1787494718.sh | 116 ++++ test/shell.d/security-fido2-migration-test.sh | 557 ++++++++++++++++++ test/shell.d/security-fido2-remove-test.sh | 126 ++++ test/shell.d/security-fido2-test.sh | 480 +++++++++++++++ 6 files changed, 1357 insertions(+), 6 deletions(-) create mode 100644 migrations/1787494718.sh create mode 100755 test/shell.d/security-fido2-migration-test.sh create mode 100755 test/shell.d/security-fido2-remove-test.sh create mode 100755 test/shell.d/security-fido2-test.sh diff --git a/bin/omarchy-remove-security-fido2 b/bin/omarchy-remove-security-fido2 index c93b490f..6df388c0 100755 --- a/bin/omarchy-remove-security-fido2 +++ b/bin/omarchy-remove-security-fido2 @@ -24,9 +24,14 @@ echo -e "\e[32mRemoving FIDO2 device from authentication.\n\e[0m" remove_pam_config -if [[ -d /etc/fido2 ]]; then +authdir=/etc/fido2 + +# -d follows symlinks, so a dangling link at /etc/fido2 would survive this and +# a later setup would install the authfile through it. rm -rf on a symlink +# removes the link itself, never the directory it points at. +if [[ -e $authdir || -L $authdir ]]; then echo "Removing FIDO2 configuration..." - sudo rm -rf /etc/fido2 + sudo rm -rf "$authdir" fi echo "Removing FIDO2 packages..." diff --git a/bin/omarchy-setup-security-fido2 b/bin/omarchy-setup-security-fido2 index 8513968e..85fe2039 100755 --- a/bin/omarchy-setup-security-fido2 +++ b/bin/omarchy-setup-security-fido2 @@ -4,6 +4,7 @@ # omarchy:requires-sudo=true set -e +set -o pipefail check_fido2_hardware() { @@ -50,13 +51,79 @@ if ! check_fido2_hardware; then fi # Create the pamu2fcfg file -if [[ ! -f /etc/fido2/fido2 ]]; then - sudo mkdir -p /etc/fido2 +authdir=/etc/fido2 +authfile=/etc/fido2/fido2 + +# install -d follows a symlink here and applies the mode and ownership to +# whatever it points at, so the credential would be staged and published inside +# the link target and that directory reopened to root:root 755. This is the +# threat omarchy-remove-security-fido2 already names on its side. +if [[ -L $authdir || ( -e $authdir && ! -d $authdir ) ]]; then + echo -e "\e[31m\n$authdir is not a FIDO2 configuration directory.\e[0m" + echo "Run omarchy-remove-security-fido2 first, then set FIDO2 up again." + exit 1 +fi + +# -f follows symlinks, so the already-registered check below reads a symlinked +# authfile as a registration and leaves it in place, and is false for a +# directory, so it tries to register over one. Only a regular file is a valid +# pam_u2f authfile. +if [[ -L $authfile || ( -e $authfile && ! -f $authfile ) ]]; then + echo -e "\e[31m\n$authfile is not a FIDO2 registration file.\e[0m" + echo "Run omarchy-remove-security-fido2 first, then set FIDO2 up again." + exit 1 +fi + +if [[ ! -f $authfile ]]; then + sudo install -d -m 755 -o root -g root "$authdir" echo -e "\e[32m\nLet's setup your device by confirming on the device now.\e[0m" echo -e "Touch your FIDO2 key when it lights up...\n" - if pamu2fcfg >/tmp/fido2; then - sudo mv /tmp/fido2 /etc/fido2/fido2 + # A unique sibling created by root cannot be replaced by another process + # running as this user. Stream pamu2fcfg into it instead of asking root to + # reopen a caller-owned path: an observed temporary name could otherwise be + # replaced with a symlink before the privileged copy. The final rename is + # atomic, and -T refuses a directory at the destination. Mode 644 keeps the + # root-owned global authfile readable when pam_u2f uses openasuser; only root + # can still rewrite it. + stage="" + + # mktemp's output is an operand for four privileged commands below, one of + # them an rm. Take only the name this script asked for rather than whatever + # came back on stdout. + safe_stage_path() { + local candidate=$1 + local prefix="$authfile.new." + local suffix + + [[ $candidate == "$prefix"* ]] || return 1 + suffix=${candidate#"$prefix"} + [[ $suffix =~ ^[[:alnum:]]{6}$ ]] + } + + cleanup_stage() { + local status=$? + + if safe_stage_path "$stage"; then + sudo rm -f -- "$stage" || true + fi + + return "$status" + } + + trap cleanup_stage EXIT + stage=$(sudo mktemp "$authfile.new.XXXXXX") + + if ! safe_stage_path "$stage" || [[ ! -f $stage || -L $stage ]]; then + echo -e "\e[31m\nCould not create a safe staging file beside $authfile.\e[0m" + exit 1 + fi + + if pamu2fcfg | sudo tee "$stage" >/dev/null && [[ -s $stage ]]; then + sudo chmod 644 "$stage" + sudo mv -Tf "$stage" "$authfile" + stage="" + trap - EXIT echo -e "\e[32mFIDO2 device registered successfully!\e[0m" else echo -e "\e[31m\nFIDO2 registration failed. Please try again.\e[0m" diff --git a/migrations/1787494718.sh b/migrations/1787494718.sh new file mode 100644 index 00000000..0f11ada6 --- /dev/null +++ b/migrations/1787494718.sh @@ -0,0 +1,116 @@ +echo "Take ownership of the FIDO2 authfile so it cannot be rewritten without root" + +authfile="/etc/fido2/fido2" + +# omarchy-migrate records this migration as complete whenever it exits zero, so +# a line printed here scrolls past once in the update terminal and is never +# shown again. The states below cannot be repaired without deciding what to do +# with a file we do not own, and they are exactly the ones where the authfile +# may already be under someone else's control, so say so where it outlives the +# scrollback as well. +report_unrepairable() { + echo " $1" + echo " $2" + omarchy-notification-send -u critical -g  "FIDO2 authfile needs attention" "$1 $2" || true +} + +# Nothing to repair on any machine that never set FIDO2 up, which is almost all +# of them. Checked before any sudo so those machines never see a password +# prompt. -L as well as -e: a dangling symlink is invisible to -e. +if [[ ! -L $authfile && ! -e $authfile ]]; then + # Absence and "cannot look" are the same answer to the tests above. The old + # setup created /etc/fido2 with `sudo mkdir -p`, which took the union of the + # caller's umask and sudoers' 0022, so anyone registering under `umask 077` + # left it mode 0700 with the user-owned authfile still inside. Escalate for + # that case alone -- a machine that never set FIDO2 up has no directory here + # and still reaches exit 0 without a password prompt. Not through a symlink: + # chmod would act on whatever it points at. + authdir=${authfile%/*} + + if [[ -L $authdir || ! -d $authdir || -x $authdir ]]; then + exit 0 + fi + + # Ask root whether a registration is behind it before touching the directory + # itself. An aborted setup that left an empty 0700 directory, or one an + # administrator deliberately keeps private, must not have its mode widened + # and its group and special bits discarded for a repair it does not need. + if ! sudo test -e "$authfile" && ! sudo test -L "$authfile"; then + exit 0 + fi + + sudo chmod 755 "$authdir" +fi + +# The old privileged move could install a symlink here if its fixed staging path +# was redirected. Reported, not repaired: chown follows symlinks and would take +# ownership of the target instead, and removing it would strip sudo and polkit +# from anyone whose only credential is the token. +if [[ -L $authfile ]]; then + report_unrepairable "$authfile is a symlink, not a regular file." \ + "Leaving it alone. If you did not create it, remove it and re-run Setup > Security > Fido2." + exit 0 +fi + +# A directory or a device here is no more ours to rewrite than a symlink is, +# and changing a directory's mode would alter an object we do not own. +if [[ ! -f $authfile ]]; then + report_unrepairable "$authfile is not a regular file." \ + "Leaving it alone. Remove it and re-run Setup > Security > Fido2." + exit 0 +fi + +# Migration state is per-user, so every account re-runs this. The file's own +# ownership is the state check: the second account finds the repair already +# done and exits without escalating. +owner=$(stat -c %U "$authfile" 2>/dev/null) || owner="" +group=$(stat -c %G "$authfile" 2>/dev/null) || group="" +mode=$(stat -c %a "$authfile" 2>/dev/null) || mode="" +if [[ $owner == "root" && $group == "root" && $mode == "644" ]]; then + exit 0 +fi + +# Setup used to `mv` this in from /tmp, which carried the invoking user's +# ownership into /etc. Root ownership stops that user from rewriting their own +# PAM credential without root. Mode 644 keeps the public credential mapping +# readable when pam_u2f opens an absolute authfile as the authenticating user. +# +# Rename a fresh copy over the path rather than chowning in place. A descriptor +# opened while the file was still the user's own stays writable on that inode +# through any later chmod or chown, since permission is checked at open(2), and +# pam_u2f resolving the path would keep landing on it. Replacing the inode +# leaves that descriptor writing to a file nothing reads. +stage="" + +safe_stage_path() { + local candidate=$1 + local prefix="$authfile.new." + local suffix + + [[ $candidate == "$prefix"* ]] || return 1 + suffix=${candidate#"$prefix"} + [[ $suffix =~ ^[[:alnum:]]{6}$ ]] +} + +cleanup_stage() { + local status=$? + + if safe_stage_path "$stage"; then + sudo rm -f -- "$stage" || true + fi + + return "$status" +} + +trap cleanup_stage EXIT +stage=$(sudo mktemp "$authfile.new.XXXXXX") + +if ! safe_stage_path "$stage" || [[ ! -f $stage || -L $stage ]]; then + echo " Could not create a safe staging file beside $authfile." + exit 1 +fi + +sudo install -T -m 644 -o root -g root "$authfile" "$stage" +sudo mv -Tf "$stage" "$authfile" +stage="" +trap - EXIT diff --git a/test/shell.d/security-fido2-migration-test.sh b/test/shell.d/security-fido2-migration-test.sh new file mode 100755 index 00000000..01e5b698 --- /dev/null +++ b/test/shell.d/security-fido2-migration-test.sh @@ -0,0 +1,557 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +migration="$ROOT/migrations/1787494718.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +stub_bin="$test_tmp/bin" +calls="$test_tmp/calls.log" +stages="$test_tmp/stages.log" +notifications="$test_tmp/notifications.log" +# A directory of its own, not $test_tmp: the migration derives the FIDO2 +# directory from the authfile, and the case below where that directory is +# untraversable has to be able to take the permissions off it. +authdir="$test_tmp/etc-fido2" +authfile="$authdir/fido2" +migration_copy="$test_tmp/migration.sh" +mkdir -p "$stub_bin" "$authdir" +: >"$stages" +: >"$notifications" + +# The migration repairs an absolute path no unprivileged suite can write, and an +# environment override in the shipped file would hand a root install and mv an +# operand the caller chooses. Retarget a scratch copy instead, and fail if the +# path is not named exactly once, so this seam cannot quietly stop standing for +# the file it copies. +occurrences=$(grep -Fo /etc/fido2/fido2 "$migration" | wc -l) || occurrences=0 +(( occurrences == 1 )) || + fail "the migration names its authfile exactly once, so the test can retarget a copy" \ + "found $occurrences occurrences" +grep -Fxq 'authfile="/etc/fido2/fido2"' "$migration" || + fail "the production authfile path is a fixed literal, not caller-controlled" +pass "migration names its authfile once, and the test drives a retargeted copy" + +# Log every escalation, then execute only the expected bare sudo forms. Each +# operand is matched against the scratch authfile or a stage this stub created. +# This contains malformed calls made through that interface; arbitrary direct +# privileged commands in the migration are outside this harness. +cat >"$stub_bin/sudo" <<'SH' +#!/bin/bash + +set -euo pipefail + +reject() { + printf 'refusing unexpected sudo invocation:' >&2 + printf ' %q' "$@" >&2 + printf '\n' >&2 + exit 97 +} + +if [[ ${TEST_TMP:-} != /* || ${TEST_AUTHDIR:-} != "$TEST_TMP/etc-fido2" || ${TEST_AUTHFILE:-} != "$TEST_AUTHDIR/fido2" || ${TEST_LOG:-} != "$TEST_TMP/calls.log" || ${TEST_STAGES:-} != "$TEST_TMP/stages.log" ]]; then + reject "$@" +fi + +printf 'sudo' >>"$TEST_LOG" +printf '\t%s' "$@" >>"$TEST_LOG" +printf '\n' >>"$TEST_LOG" + +safe_stage_path() { + local candidate=$1 + local prefix="$TEST_AUTHFILE.new." + local suffix + + [[ $candidate == "$prefix"* ]] || return 1 + suffix=${candidate#"$prefix"} + [[ $suffix =~ ^[[:alnum:]]{6}$ ]] +} + +recorded_stage() { + local candidate=$1 + + safe_stage_path "$candidate" || return 1 + [[ -f $candidate && ! -L $candidate ]] || return 1 + /usr/bin/grep -Fxq -- "$candidate" "$TEST_STAGES" +} + +case "$1" in + mktemp) + if (( $# != 2 )) || [[ $2 != "$TEST_AUTHFILE.new.XXXXXX" ]]; then + reject "$@" + fi + + case ${TEST_MKTEMP_MODE:-normal} in + normal) + stage=$(/usr/bin/mktemp -- "$2") + if ! safe_stage_path "$stage" || [[ ! -f $stage || -L $stage ]]; then + reject "$@" + fi + + printf '%s\n' "$stage" >>"$TEST_STAGES" + printf '%s\n' "$stage" + ;; + malformed) + stage="$TEST_AUTHFILE.new.A/BCDE" + /usr/bin/mkdir -- "${stage%/*}" + : >"$stage" + printf '%s\n' "$stage" + ;; + nonregular) + stage="$TEST_AUTHFILE.new.BAD123" + /usr/bin/mkdir -- "$stage" + printf '%s\n' "$stage" + ;; + *) + reject "$@" + ;; + esac + ;; + install) + if (( $# != 10 )) || [[ $2 != "-T" || $3 != "-m" || $4 != "644" || $5 != "-o" || $6 != "root" || $7 != "-g" || $8 != "root" || $9 != "$TEST_AUTHFILE" ]] || ! recorded_stage "${10}"; then + reject "$@" + fi + + if [[ ${TEST_FAIL_INSTALL:-0} == "1" ]]; then + exit 71 + fi + + if (( EUID == 0 )); then + exec /usr/bin/install -T -m 644 -o root -g root "$9" "${10}" + else + exec /usr/bin/install -T -m 644 "$9" "${10}" + fi + ;; + mv) + if (( $# != 4 )) || [[ $2 != "-Tf" || $4 != "$TEST_AUTHFILE" ]] || ! recorded_stage "$3"; then + reject "$@" + fi + + if [[ ${TEST_FAIL_MV:-0} == "1" ]]; then + exit 72 + fi + + exec /usr/bin/mv -Tf -- "$3" "$4" + ;; + chmod) + # Only ever the FIDO2 directory, and only back to the mode the setup + # installs. Nothing here may reopen the authfile itself. + if (( $# != 3 )) || [[ $2 != "755" || $3 != "$TEST_AUTHDIR" ]]; then + reject "$@" + fi + + exec /usr/bin/chmod 755 "$TEST_AUTHDIR" + ;; + test) + # Looking behind an untraversable directory, never a write. This stub is not + # really root, so open the directory just long enough to answer the way root + # would and put its mode straight back -- the suite then still sees whether + # production left the mode alone. + if (( $# != 3 )) || [[ $2 != "-e" && $2 != "-L" ]] || [[ $3 != "$TEST_AUTHFILE" ]]; then + reject "$@" + fi + + saved_mode=$(/usr/bin/stat -c %a "$TEST_AUTHDIR") + /usr/bin/chmod 755 "$TEST_AUTHDIR" + probe_status=0 + /usr/bin/test "$2" "$3" || probe_status=$? + /usr/bin/chmod "$saved_mode" "$TEST_AUTHDIR" + exit "$probe_status" + ;; + rm) + if (( $# != 4 )) || [[ $2 != "-f" || $3 != "--" ]]; then + reject "$@" + fi + + if [[ ${TEST_MKTEMP_MODE:-normal} == "nonregular" && $4 == "$TEST_AUTHFILE.new.BAD123" && -d $4 && ! -L $4 ]]; then + exit 73 + fi + + recorded_stage "$4" || reject "$@" + exec /usr/bin/rm -f -- "$4" + ;; + *) + reject "$@" + ;; +esac +SH + +chmod +x "$stub_bin/sudo" + +cat >"$stub_bin/stat" <<'SH' +#!/bin/bash + +set -euo pipefail + +if [[ ${TEST_FAKE_STAT:-0} == "1" && ${TEST_AUTHFILE:-} == "${TEST_AUTHDIR:-}/fido2" ]] && + (( $# == 3 )) && [[ $1 == "-c" && $3 == "$TEST_AUTHFILE" ]]; then + case "$2" in + %U) printf '%s\n' "$TEST_STAT_OWNER" ;; + %G) printf '%s\n' "$TEST_STAT_GROUP" ;; + %a) printf '%s\n' "$TEST_STAT_MODE" ;; + *) exec /usr/bin/stat "$@" ;; + esac +else + exec /usr/bin/stat "$@" +fi +SH + +chmod +x "$stub_bin/stat" + +# omarchy-migrate records this migration complete on any zero exit, so the +# states it cannot repair have to reach the user somewhere that outlives the +# update terminal's scrollback. +cat >"$stub_bin/omarchy-notification-send" <<'SH' +#!/bin/bash + +printf 'notify' >>"$TEST_NOTIFICATIONS" +printf '\t%s' "$@" >>"$TEST_NOTIFICATIONS" +printf '\n' >>"$TEST_NOTIFICATIONS" +exit "${TEST_NOTIFY_STATUS:-0}" +SH + +chmod +x "$stub_bin/omarchy-notification-send" + +run_migration() { + local fail_install="${1:-0}" + local fail_mv="${2:-0}" + local stat_owner="${3:-}" + local stat_group="${4:-}" + local stat_mode="${5:-}" + local mktemp_mode="${6:-normal}" + local notify_status="${7:-0}" + local fake_stat=0 + + if [[ -n $stat_owner || -n $stat_group || -n $stat_mode ]]; then + [[ -n $stat_owner && -n $stat_group && -n $stat_mode ]] || + fail "a fake stat fixture supplies owner, group and mode together" + fake_stat=1 + fi + + : >"$calls" + : >"$notifications" + sed "s|/etc/fido2/fido2|$authfile|" "$migration" >"$migration_copy" + + PATH="$stub_bin:$PATH" TEST_AUTHDIR="$authdir" TEST_AUTHFILE="$authfile" \ + TEST_FAIL_INSTALL="$fail_install" TEST_FAIL_MV="$fail_mv" TEST_FAKE_STAT="$fake_stat" \ + TEST_LOG="$calls" TEST_MKTEMP_MODE="$mktemp_mode" TEST_NOTIFICATIONS="$notifications" \ + TEST_NOTIFY_STATUS="$notify_status" TEST_STAGES="$stages" TEST_STAT_GROUP="$stat_group" \ + TEST_STAT_MODE="$stat_mode" TEST_STAT_OWNER="$stat_owner" TEST_TMP="$test_tmp" \ + bash -euo pipefail "$migration_copy" >/dev/null +} + +safe_fixture_stage_path() { + local candidate=$1 + local prefix="$authfile.new." + local suffix + + [[ $candidate == "$prefix"* ]] || return 1 + suffix=${candidate#"$prefix"} + [[ $suffix =~ ^[[:alnum:]]{6}$ ]] +} + +# Every repair case is about an authfile its own user can still rewrite. The +# calls below give stat an explicit caller-owned state, so the same assertions +# work as an ordinary user, as real root, and in a namespace mapping only UID 0. +write_authfile() { + printf 'tester:credential-handle,public-key,es256,+presence\n' >"$authfile" + chmod "$1" "$authfile" +} + +# Almost every machine has never registered a key, and establishing that must +# not cost those users a password prompt. +rm -f "$authfile" +run_migration +[[ ! -s $calls ]] || fail "a machine with no authfile escalates nothing" "$(cat "$calls")" +pass "migration skips a machine that never set FIDO2 up" + +# What the old `sudo mv` left behind on every machine that did: the authfile PAM +# consults for sudo, owned by the account it authenticates, at the caller's umask. +write_authfile 644 || fail "the test can stage a non-root-owned authfile" +before_inode=$(stat -c %i "$authfile") +run_migration 0 0 caller caller 644 + +grep -Fq $'sudo\tmktemp\t'"$authfile.new.XXXXXX" "$calls" || + fail "the repair asks root for a unique sibling stage" "$(cat "$calls")" +grep -Fq $'sudo\tinstall\t-T\t-m\t644\t-o\troot\t-g\troot\t'"$authfile"$'\t' "$calls" || + fail "a user-owned authfile is reinstalled root:root and mode 644" "$(cat "$calls")" +grep -Fq $'sudo\tmv\t-Tf\t' "$calls" || + fail "the staged authfile is atomically renamed over the live path" "$(cat "$calls")" +if grep -Fq $'sudo\tchown\t' "$calls"; then + fail "the repair replaces the authfile rather than chowning it" "$(cat "$calls")" +fi +if grep -Fq $'sudo\trm\t' "$calls"; then + fail "a successful repair disarms its EXIT cleanup" "$(cat "$calls")" +fi +pass "migration stages and atomically installs a root-owned authfile" + +[[ $(stat -c %a "$authfile") == "644" ]] || + fail "the repaired authfile is mode 644" "got: $(stat -c %a "$authfile")" +[[ $(cat "$authfile") == "tester:credential-handle,public-key,es256,+presence" ]] || + fail "the repaired authfile keeps its credential" "got: $(cat "$authfile")" +if (( EUID == 0 )) && [[ $(stat -c %U:%G "$authfile") != "root:root" ]]; then + fail "the repaired authfile is root:root" "got: $(stat -c %U:%G "$authfile")" +fi +pass "migration preserves the credential with its PAM-readable mode" + +# The whole point of replacing rather than chowning. Permission is checked at +# open(2), so a descriptor the registering user opened before the update stays +# writable on the old inode through any chmod or chown -- and pam_u2f resolving +# the authfile path would keep reading exactly that inode. +[[ $(stat -c %i "$authfile") != "$before_inode" ]] || + fail "the repair lands on a new inode, orphaning any descriptor already open on the old one" +pass "migration replaces the inode a pre-existing writer would still hold" + +mapfile -t staged_paths <"$stages" +(( ${#staged_paths[@]} == 1 )) || + fail "the first repair creates exactly one stage" "got: ${staged_paths[*]}" +first_stage=${staged_paths[0]} +safe_fixture_stage_path "$first_stage" || + fail "the stage is a unique sibling of the authfile" "got: $first_stage" +[[ ! -e $first_stage && ! -L $first_stage ]] || + fail "the staged copy does not outlive the repair" "left behind: $first_stage" +pass "migration uses a unique sibling and leaves no staged copy behind" + +# Treat mktemp's output as untrusted even though sudo normally resolves the +# system binary. This existing regular path has a six-character suffix only if +# `/` is accepted as one of the characters, as the old ?????? glob did. The +# strict shape check must reject it before any privileged write or cleanup. +write_authfile 644 || fail "the test can stage the malformed-output fixture" +before_inode=$(stat -c %i "$authfile") +malformed_parent="$authfile.new.A" +malformed_stage="$malformed_parent/BCDE" +if run_migration 0 0 caller caller 644 malformed; then + fail "malformed mktemp output fails the migration" +fi + +grep -Fq $'sudo\tmktemp\t' "$calls" || + fail "the malformed-output fixture reaches mktemp" "$(cat "$calls")" +if grep -Fq $'sudo\tinstall\t' "$calls" || grep -Fq $'sudo\tmv\t' "$calls" || grep -Fq $'sudo\trm\t' "$calls"; then + fail "malformed mktemp output reaches no install, rename or cleanup" "$(cat "$calls")" +fi +[[ $(stat -c %i "$authfile") == "$before_inode" ]] || + fail "malformed mktemp output leaves the live authfile inode alone" +[[ -f $malformed_stage && ! -L $malformed_stage ]] || + fail "the malformed-output fixture remains a regular scratch file" "got: $malformed_stage" +/usr/bin/rm -- "$malformed_stage" +/usr/bin/rmdir -- "$malformed_parent" +pass "migration rejects malformed mktemp output before any privileged write" + +# A name can have the right prefix and six-character suffix but still name an +# object mktemp would never return. Production must reject that object before +# install/mv; its cleanup may address only that validated scratch sibling and +# must not recursively remove the unexpected directory. +write_authfile 644 || fail "the test can stage the nonregular-output fixture" +before_inode=$(stat -c %i "$authfile") +nonregular_stage="$authfile.new.BAD123" +if run_migration 0 0 caller caller 644 nonregular; then + fail "nonregular mktemp output fails the migration" +fi + +safe_fixture_stage_path "$nonregular_stage" || + fail "the nonregular fixture uses a syntactically valid stage name" "got: $nonregular_stage" +if grep -Fq $'sudo\tinstall\t' "$calls" || grep -Fq $'sudo\tmv\t' "$calls"; then + fail "nonregular mktemp output is rejected before install or rename" "$(cat "$calls")" +fi +grep -Fq $'sudo\trm\t-f\t--\t'"$nonregular_stage" "$calls" || + fail "cleanup addresses only the validated nonregular sibling" "$(cat "$calls")" +[[ -d $nonregular_stage && ! -L $nonregular_stage ]] || + fail "cleanup does not recursively remove a nonregular stage" "got: $nonregular_stage" +[[ $(stat -c %i "$authfile") == "$before_inode" ]] || + fail "nonregular mktemp output leaves the live authfile inode alone" +/usr/bin/rmdir -- "$nonregular_stage" +pass "migration rejects and safely handles nonregular mktemp output" + +# A caller-owned file still needs a fresh inode and root ownership whatever its +# current mode. +write_authfile 600 || fail "the test can restage a non-root-owned authfile" +run_migration 0 0 caller caller 600 +grep -Fq $'sudo\tinstall\t-T\t' "$calls" || + fail "a mode-600 authfile the user still owns is repaired" "$(cat "$calls")" + +mapfile -t staged_paths <"$stages" +(( ${#staged_paths[@]} == 2 )) || + fail "two repairs create two stages" "got: ${staged_paths[*]}" +second_stage=${staged_paths[1]} +[[ ! -e $second_stage && ! -L $second_stage ]] || + fail "the second staged copy does not outlive the repair" "left behind: $second_stage" +pass "migration repairs a user-owned authfile whatever its mode and cleans its stage" + +# A failure after mktemp must remove only the exact stage the stub created. The +# live authfile stays on its original inode because mv was never reached. +write_authfile 644 || fail "the test can stage the cleanup fixture" +before_inode=$(stat -c %i "$authfile") +if run_migration 1 0 caller caller 644; then + fail "an install failure propagates out of the migration" +fi + +mapfile -t staged_paths <"$stages" +(( ${#staged_paths[@]} == 3 )) || + fail "the failed repair creates one stage" "got: ${staged_paths[*]}" +failed_stage=${staged_paths[2]} +grep -Fq $'sudo\trm\t-f\t--\t'"$failed_stage" "$calls" || + fail "the EXIT trap removes the failed repair's exact stage" "$(cat "$calls")" +[[ ! -e $failed_stage && ! -L $failed_stage ]] || + fail "the failed stage is cleaned up" "left behind: $failed_stage" +[[ $(stat -c %i "$authfile") == "$before_inode" ]] || + fail "a failed repair leaves the live authfile inode alone" +pass "migration cleans its unique stage after a failed repair" + +# A failure after install has the same cleanup obligation. In particular, the +# EXIT trap must still be armed when mv fails. +write_authfile 644 || fail "the test can stage the mv-failure fixture" +before_inode=$(stat -c %i "$authfile") +if run_migration 0 1 caller caller 644; then + fail "an mv failure propagates out of the migration" +fi + +mapfile -t staged_paths <"$stages" +(( ${#staged_paths[@]} == 4 )) || + fail "the mv-failed repair creates one stage" "got: ${staged_paths[*]}" +failed_mv_stage=${staged_paths[3]} +grep -Fq $'sudo\tmv\t-Tf\t'"$failed_mv_stage"$'\t'"$authfile" "$calls" || + fail "the injected mv failure occurs after install" "$(cat "$calls")" +grep -Fq $'sudo\trm\t-f\t--\t'"$failed_mv_stage" "$calls" || + fail "the EXIT trap removes the mv-failed repair's exact stage" "$(cat "$calls")" +[[ ! -e $failed_mv_stage && ! -L $failed_mv_stage ]] || + fail "the mv-failed stage is cleaned up" "left behind: $failed_mv_stage" +[[ $(stat -c %i "$authfile") == "$before_inode" ]] || + fail "an mv failure leaves the live authfile inode alone" +pass "migration cleans its unique stage after a failed rename" + +# The state a completed repair leaves, which is also where every machine that +# registers after this fix starts. A second account, and a second run for the +# same account, must find it done and escalate nothing. Fake only stat's view of +# the scratch authfile so this stays deterministic without borrowing a host +# file or requiring the suite itself to run as root. +write_authfile 644 || fail "the test can stage the settled-state fixture" +run_migration 0 0 root root 644 +[[ ! -s $calls ]] || + fail "an already root:root mode-644 authfile escalates nothing" "$(cat "$calls")" +pass "migration deterministically no-ops on its settled state" + +# Owner, group and mode are independent parts of that state check. Hold two at +# their settled values while making each third value wrong, and require repair. +write_authfile 644 || fail "the test can stage the wrong-owner fixture" +run_migration 0 0 nobody root 644 +grep -Fq $'sudo\tinstall\t-T\t' "$calls" || + fail "a non-root-owned authfile is repaired even when group and mode are settled" "$(cat "$calls")" +pass "migration repairs an authfile with the wrong owner" + +write_authfile 644 || fail "the test can stage the wrong-group fixture" +run_migration 0 0 root nobody 644 +grep -Fq $'sudo\tinstall\t-T\t' "$calls" || + fail "a non-root-group authfile is repaired even when owner and mode are settled" "$(cat "$calls")" +pass "migration repairs an authfile with the wrong group" + +write_authfile 644 || fail "the test can stage the wrong-mode fixture" +run_migration 0 0 root root 600 +grep -Fq $'sudo\tinstall\t-T\t' "$calls" || + fail "a mode-600 authfile is repaired even when owner and group are settled" "$(cat "$calls")" +pass "migration repairs an authfile with the wrong mode" + +# Neither of these is ours to rewrite, and both must say so without escalating: +# chown follows a symlink and would take the target instead, while changing a +# directory's mode would alter an object the migration does not own. +rm -rf "$authfile" +ln -s "$test_tmp/elsewhere" "$authfile" +: >"$test_tmp/elsewhere" +run_migration +[[ ! -s $calls ]] || fail "a symlinked authfile escalates nothing" "$(cat "$calls")" +[[ -s $notifications ]] || + fail "a symlinked authfile is raised where the update terminal cannot swallow it" + +rm -f "$authfile" +ln -s "$test_tmp/missing" "$authfile" +run_migration +[[ ! -s $calls ]] || fail "a dangling symlink escalates nothing" "$(cat "$calls")" +[[ -s $notifications ]] || fail "a dangling symlink is raised the same way" +pass "migration reports a symlinked authfile and repairs nothing" + +rm -f "$authfile" +mkdir -p "$authfile" +run_migration +[[ ! -s $calls ]] || fail "a directory at the authfile path escalates nothing" "$(cat "$calls")" +[[ -s $notifications ]] || fail "a non-regular authfile is raised the same way" +pass "migration reports a non-regular authfile and repairs nothing" + +# omarchy-migrate writes this migration's completion marker on any zero exit, so +# a machine it cannot repair gets one shot at telling the user. The states above +# are exactly the ones where the authfile may already be under someone else's +# control, and a line in the update terminal scrolls past. +# Assert the argument shape rather than a substring. The glyph is a private-use +# codepoint that an edit can silently drop, and losing it shifts every argument +# left: -g swallows the headline, the body becomes the title, and the message +# goes out with no description. A substring match sees all of that as fine. +awk -F'\t' ' + $1 == "notify" && NF == 7 && $2 == "-u" && $3 == "critical" && $4 == "-g" && + $5 != "" && $6 == "FIDO2 authfile needs attention" && $7 != "" { found = 1 } + END { exit !found } +' "$notifications" || + fail "the notification passes a glyph, headline and body as separate arguments" \ + "$(cat -A "$notifications")" +pass "migration raises its unrepairable states as a desktop notification" + +# The old setup created the FIDO2 directory with `sudo mkdir -p`, which took the +# caller's umask: registering under `umask 077` left it mode 0700 with the +# user-owned authfile still inside. Absence and "cannot look" are the same +# answer to an unprivileged test, so keying the early exit on the authfile +# recorded a repair on exactly the machines that still needed one. +rm -rf "$authfile" +write_authfile 644 || fail "the test can stage the untraversable-directory fixture" +before_inode=$(stat -c %i "$authfile") +chmod 000 "$authdir" +run_migration 0 0 caller caller 644 +[[ $(stat -c %a "$authdir") == "755" ]] || + fail "the migration reopens the directory the old umask closed" "got: $(stat -c %a "$authdir")" +grep -Fxq $'sudo\tchmod\t755\t'"$authdir" "$calls" || + fail "the migration asks root to reopen the FIDO2 directory" "$(cat "$calls")" +grep -Fq $'sudo\tinstall\t-T\t' "$calls" || + fail "an authfile hidden behind an untraversable directory is still repaired" "$(cat "$calls")" +[[ $(stat -c %i "$authfile") != "$before_inode" ]] || + fail "the repair behind an untraversable directory still replaces the inode" +pass "migration repairs an authfile an unreadable directory hid from it" + +# The narrow escalation above must not reach a machine that never registered a +# key, which is almost all of them. +rm -f "$authfile" +rm -rf "$authdir" +run_migration +[[ ! -s $calls ]] || + fail "a machine with no FIDO2 directory still escalates nothing" "$(cat "$calls")" +mkdir -p "$authdir" +run_migration +[[ ! -s $calls ]] || + fail "an empty readable FIDO2 directory escalates nothing" "$(cat "$calls")" +pass "migration still costs no password prompt on a machine that never set FIDO2 up" + +# An aborted setup can leave the directory behind with nothing in it, and an +# administrator may keep one deliberately private. Looking costs a probe, but +# neither may have its mode widened, or its group and special bits discarded, +# for a repair that is not needed. +rm -f "$authfile" +chmod 000 "$authdir" +run_migration +[[ $(stat -c %a "$authdir") == "0" ]] || + fail "an empty inaccessible FIDO2 directory keeps its mode" "got: $(stat -c %a "$authdir")" +! grep -Fq $'sudo\tchmod\t' "$calls" || + fail "an empty inaccessible FIDO2 directory is never reopened" "$(cat "$calls")" +if grep -Fq $'sudo\tinstall\t' "$calls" || grep -Fq $'sudo\tmv\t' "$calls"; then + fail "an empty inaccessible FIDO2 directory is never repaired" "$(cat "$calls")" +fi +chmod 755 "$authdir" +pass "migration looks behind an inaccessible FIDO2 directory without widening it" + +# Notification delivery fails on a machine with no user bus or no notification +# server. That must not abort the migration under `bash -euo pipefail` and take +# every later migration with it. +rm -f "$authfile" +ln -s "$test_tmp/missing" "$authfile" +run_migration 0 0 "" "" "" normal 1 +[[ -s $notifications ]] || + fail "the failing notification was still attempted" "$(cat "$notifications")" +pass "migration survives a notification it could not deliver" +rm -f "$authfile" diff --git a/test/shell.d/security-fido2-remove-test.sh b/test/shell.d/security-fido2-remove-test.sh new file mode 100755 index 00000000..6f85090b --- /dev/null +++ b/test/shell.d/security-fido2-remove-test.sh @@ -0,0 +1,126 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +remove="$ROOT/bin/omarchy-remove-security-fido2" + +test_tmp=$(mktemp -d) +stub_bin="$test_tmp/bin" +calls="$test_tmp/calls.log" +authdir="$test_tmp/etc-fido2" +elsewhere="$test_tmp/elsewhere" +remove_copy="$test_tmp/remove.sh" +mkdir -p "$stub_bin" + +cleanup() { + rm -rf "$test_tmp" + return 0 +} +trap cleanup EXIT + +# The same seam the setup and migration suites use: the removal deletes an +# absolute path no unprivileged suite can own, and an environment override in +# the shipped command would hand a privileged rm -rf an operand the caller +# chooses. Retarget a copy instead, and fail if the path is not named exactly +# once so this seam cannot quietly stop standing for the command it copies. +occurrences=$(grep -Fxc 'authdir=/etc/fido2' "$remove") || occurrences=0 +(( occurrences == 1 )) || + fail "the removal names its FIDO2 directory exactly once" "found $occurrences occurrences" +pass "removal names its FIDO2 directory once, and the test drives a retargeted copy" + +sed "s|^authdir=/etc/fido2$|authdir=$authdir|" "$remove" >"$remove_copy" + +cat >"$stub_bin/sudo" <<'SH' +#!/bin/bash + +set -euo pipefail + +reject() { + printf 'refusing unexpected sudo invocation:' >&2 + printf ' %q' "$@" >&2 + printf '\n' >&2 + exit 97 +} + +if [[ ${TEST_AUTHDIR:-} != /* || ${TEST_LOG:-} != /* ]]; then + reject "$@" +fi + +printf 'sudo' >>"$TEST_LOG" +printf '\t%s' "$@" >>"$TEST_LOG" +printf '\n' >>"$TEST_LOG" + +case "${1:-}" in + rm) + if (( $# != 3 )) || [[ $2 != "-rf" || $3 != "$TEST_AUTHDIR" ]]; then + reject "$@" + fi + + exec /usr/bin/rm -rf "$TEST_AUTHDIR" + ;; + sed) + if (( $# != 4 )) || [[ $2 != "-i" ]]; then + reject "$@" + fi + ;; + *) + reject "$@" + ;; +esac +SH + +cat >"$stub_bin/omarchy-pkg-drop" <<'SH' +#!/bin/bash +SH + +chmod +x "$stub_bin/sudo" "$stub_bin/omarchy-pkg-drop" + +invoke_remove() { + : >"$calls" + TEST_AUTHDIR="$authdir" TEST_LOG="$calls" \ + PATH="$stub_bin:$ROOT/bin:$PATH" \ + bash "$remove_copy" /dev/null +} + +# The ordinary case: a real directory holding a registration. +rm -rf "$authdir" +mkdir -p "$authdir" +printf 'tester:credential-handle,public-key,es256,+presence\n' >"$authdir/fido2" +invoke_remove +grep -Fxq $'sudo\trm\t-rf\t'"$authdir" "$calls" || + fail "removal deletes the FIDO2 directory" "$(cat "$calls")" +[[ ! -e $authdir ]] || fail "the FIDO2 directory is gone" +pass "removal deletes a real FIDO2 directory" + +# -d is false for a dangling link, so the guard it replaced left one sitting +# there for the next setup to install an authfile through. +rm -rf "$authdir" +ln -s "$test_tmp/missing" "$authdir" +invoke_remove +grep -Fxq $'sudo\trm\t-rf\t'"$authdir" "$calls" || + fail "removal deletes a dangling symlink at the FIDO2 directory" "$(cat "$calls")" +[[ ! -e $authdir && ! -L $authdir ]] || + fail "the dangling symlink is gone" +pass "removal deletes a dangling symlink where -d would have skipped it" + +# rm -rf on a symlink unlinks the link. Whatever it pointed at is not ours. +rm -rf "$authdir" +rm -rf "$elsewhere" +mkdir -p "$elsewhere" +printf 'keep me\n' >"$elsewhere/canary" +ln -s "$elsewhere" "$authdir" +invoke_remove +[[ ! -e $authdir && ! -L $authdir ]] || + fail "the symlink at the FIDO2 directory is gone" +[[ -d $elsewhere && -f $elsewhere/canary ]] || + fail "removal takes the symlink, never the directory it points at" +pass "removal takes a symlink itself and leaves its target intact" + +# Nothing there at all: no escalation, so removing FIDO2 twice costs no prompt. +rm -rf "$authdir" +invoke_remove +! grep -Fq $'sudo\trm\t' "$calls" || + fail "removal escalates no rm when there is no FIDO2 directory" "$(cat "$calls")" +pass "removal escalates nothing when there is no FIDO2 directory" diff --git a/test/shell.d/security-fido2-test.sh b/test/shell.d/security-fido2-test.sh new file mode 100755 index 00000000..f7a6a25c --- /dev/null +++ b/test/shell.d/security-fido2-test.sh @@ -0,0 +1,480 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +setup="$ROOT/bin/omarchy-setup-security-fido2" + +test_tmp=$(mktemp -d) +stub_bin="$test_tmp/bin" +stages="$test_tmp/stages.log" +calls="$test_tmp/calls.log" +pamu_targets="$test_tmp/pamu-targets.log" +bare_mktemp="$test_tmp/bare-mktemp.log" +credential="tester:credential-handle,public-key,es256,+presence" +authdir="$test_tmp/etc-fido2" +authfile="$authdir/fido2" +setup_copy="$test_tmp/setup.sh" +mkdir -p "$stub_bin" + +cleanup() { + rm -rf "$test_tmp" + return 0 +} +trap cleanup EXIT + +# The setup installs to an absolute path no unprivileged suite can write, and an +# environment override in the shipped command would hand its privileged install +# and mv an operand the caller chooses. Retarget a scratch copy instead, and +# fail if either path is not named exactly once, so this seam cannot quietly +# stop standing for the command it copies. Keying the suite on the host's own +# /etc/fido2 instead is what let the staging checks below pass without asserting +# anything on the machines that actually use FIDO2. +occurrences=$(grep -Fxc 'authdir=/etc/fido2' "$setup") || occurrences=0 +(( occurrences == 1 )) || + fail "the setup names its FIDO2 directory exactly once" "found $occurrences occurrences" +occurrences=$(grep -Fxc 'authfile=/etc/fido2/fido2' "$setup") || occurrences=0 +(( occurrences == 1 )) || + fail "the setup names its authfile exactly once" "found $occurrences occurrences" +pass "setup names its FIDO2 paths once each, and the test drives a retargeted copy" + +sed -e "s|^authdir=/etc/fido2$|authdir=$authdir|" \ + -e "s|^authfile=/etc/fido2/fido2$|authfile=$authfile|" "$setup" >"$setup_copy" + +# The setup must not create a caller-owned named file for pamu2fcfg. A bare +# mktemp is therefore a test failure; only the sudo stub below may invoke the +# real command, and it does so with an absolute scratch template. +cat >"$stub_bin/mktemp" <<'SH' +#!/bin/bash + +printf 'mktemp' >>"$TEST_BARE_MKTEMP" +printf '\t%s' "$@" >>"$TEST_BARE_MKTEMP" +printf '\n' >>"$TEST_BARE_MKTEMP" +exit 98 +SH + +# Execute only the setup's expected bare-sudo protocol. The production mktemp +# template is logged exactly, but its root-created sibling is represented by a +# unique regular file inside the scratch directory. The whitelisted operations +# map every write into that directory; arbitrary direct commands are outside +# this harness. +cat >"$stub_bin/sudo" <<'SH' +#!/bin/bash + +set -euo pipefail + +reject() { + printf 'refusing unexpected sudo invocation:' >&2 + printf ' %q' "$@" >&2 + printf '\n' >&2 + exit 97 +} + +if [[ ${TEST_TMP:-} != /* || ${TEST_AUTHDIR:-} != "$TEST_TMP/etc-fido2" || ${TEST_AUTHFILE:-} != "$TEST_AUTHDIR/fido2" || ${TEST_STAGES:-} != "$TEST_TMP/stages.log" || ${TEST_LOG:-} != "$TEST_TMP/calls.log" || ! ${TEST_FAIL_CHMOD:-} =~ ^[01]$ || ! ${TEST_FAIL_MV:-} =~ ^[01]$ ]]; then + reject "$@" +fi + +printf 'sudo' >>"$TEST_LOG" +printf '\t%s' "$@" >>"$TEST_LOG" +printf '\n' >>"$TEST_LOG" + +safe_stage_path() { + local candidate=$1 + local prefix="$TEST_AUTHFILE.new." + local suffix + + [[ $candidate == "$prefix"* ]] || return 1 + suffix=${candidate#"$prefix"} + [[ $suffix =~ ^[[:alnum:]]{6}$ ]] +} + +recorded_stage() { + local candidate=$1 + + safe_stage_path "$candidate" || return 1 + [[ -f $candidate && ! -L $candidate ]] || return 1 + /usr/bin/grep -Fxq -- "$candidate" "$TEST_STAGES" +} + +case "${1:-}" in + install) + if (( $# != 9 )) || [[ $2 != "-d" || $3 != "-m" || $4 != "755" || $5 != "-o" || $6 != "root" || $7 != "-g" || $8 != "root" || $9 != "$TEST_AUTHDIR" ]]; then + reject "$@" + fi + + if (( EUID == 0 )); then + exec /usr/bin/install -d -m 755 -o root -g root "$TEST_AUTHDIR" + else + exec /usr/bin/install -d -m 755 "$TEST_AUTHDIR" + fi + ;; + mktemp) + if (( $# != 2 )) || [[ $2 != "$TEST_AUTHFILE.new.XXXXXX" ]]; then + reject "$@" + fi + + case ${TEST_MKTEMP_MODE:-normal} in + normal) + stage=$(/usr/bin/mktemp -- "$2") + if ! safe_stage_path "$stage" || [[ ! -f $stage || -L $stage ]]; then + reject "$@" + fi + + printf '%s\n' "$stage" >>"$TEST_STAGES" + printf '%s\n' "$stage" + ;; + malformed) + stage="$TEST_AUTHFILE.new.A/BCDE" + /usr/bin/mkdir -- "${stage%/*}" + : >"$stage" + printf '%s\n' "$stage" + ;; + nonregular) + stage="$TEST_AUTHFILE.new.BAD123" + /usr/bin/mkdir -- "$stage" + printf '%s\n' "$stage" + ;; + *) + reject "$@" + ;; + esac + ;; + tee) + if (( $# == 2 )) && recorded_stage "$2"; then + exec /usr/bin/tee "$2" + elif (( $# == 2 )) && [[ $2 == "/etc/pam.d/polkit-1" ]]; then + /usr/bin/cat >/dev/null + else + reject "$@" + fi + ;; + test) + if (( $# != 3 )) || [[ $2 != "-s" ]] || ! recorded_stage "$3"; then + reject "$@" + fi + /usr/bin/test -s "$3" + ;; + chmod) + if (( $# != 3 )) || [[ $2 != "644" ]] || ! recorded_stage "$3"; then + reject "$@" + fi + if [[ $TEST_FAIL_CHMOD == "1" ]]; then + exit 73 + fi + exec /usr/bin/chmod 644 "$3" + ;; + mv) + if (( $# != 4 )) || [[ $2 != "-Tf" || $4 != "$TEST_AUTHFILE" ]] || ! recorded_stage "$3"; then + reject "$@" + fi + if [[ $TEST_FAIL_MV == "1" ]]; then + exit 74 + fi + exec /usr/bin/mv -Tf -- "$3" "$TEST_AUTHFILE" + ;; + rm) + if (( $# != 4 )) || [[ $2 != "-f" || $3 != "--" ]] || ! recorded_stage "$4"; then + reject "$@" + fi + exec /usr/bin/rm -f -- "$4" + ;; + sed) + if (( $# != 4 )) || [[ $2 != "-i" ]]; then + reject "$@" + fi + + if [[ $3 == "1i auth sufficient pam_u2f.so cue authfile=/etc/fido2/fido2" && $4 == "/etc/pam.d/sudo" ]]; then + exit 0 + elif [[ $3 == "1i auth sufficient pam_u2f.so cue authfile=/etc/fido2/fido2" && $4 == "/etc/pam.d/polkit-1" ]]; then + exit 0 + else + reject "$@" + fi + ;; + echo) + if (( $# != 2 )) || [[ $2 != "FIDO2 authentication test successful" ]]; then + reject "$@" + fi + ;; + *) + reject "$@" + ;; +esac +SH + +cat >"$stub_bin/fido2-token" <<'SH' +#!/bin/bash + +echo '/dev/hidraw0: vendor=0x1050, product=0x0407 (Yubico YubiKey)' +SH + +cat >"$stub_bin/omarchy-pkg-add" <<'SH' +#!/bin/bash +SH + +# Record what pamu2fcfg's stdout actually targets. The fixed implementation +# gives it a pipe to privileged tee; refusing a regular-file descriptor keeps a +# regression from writing credential bytes into a caller-owned named file. +cat >"$stub_bin/pamu2fcfg" <<'SH' +#!/bin/bash + +set -euo pipefail + +target=$(readlink /proc/self/fd/1) +printf '%s\n' "$target" >>"$TEST_PAMU_TARGETS" +[[ $target == pipe:* ]] || exit 96 + +case "$TEST_PAMU_MODE" in + success) + printf '%s\n' "$TEST_CREDENTIAL" + ;; + fail) + printf '%s\n' "$TEST_CREDENTIAL" + exit 23 + ;; + empty) + exit 0 + ;; + *) + exit 95 + ;; +esac +SH + +chmod +x "$stub_bin/mktemp" "$stub_bin/sudo" "$stub_bin/fido2-token" \ + "$stub_bin/omarchy-pkg-add" "$stub_bin/pamu2fcfg" + +reset_run() { + : >"$calls" + : >"$stages" + : >"$pamu_targets" + : >"$bare_mktemp" + rm -rf "$authdir" +} + +invoke_setup() { + local pamu_mode="${1:-success}" + local fail_chmod="${2:-0}" + local fail_mv="${3:-0}" + local mktemp_mode="${4:-normal}" + + TEST_AUTHDIR="$authdir" TEST_AUTHFILE="$authfile" TEST_BARE_MKTEMP="$bare_mktemp" \ + TEST_CREDENTIAL="$credential" TEST_FAIL_CHMOD="$fail_chmod" TEST_FAIL_MV="$fail_mv" \ + TEST_LOG="$calls" TEST_MKTEMP_MODE="$mktemp_mode" TEST_PAMU_MODE="$pamu_mode" \ + TEST_PAMU_TARGETS="$pamu_targets" TEST_STAGES="$stages" TEST_TMP="$test_tmp" \ + PATH="$stub_bin:$ROOT/bin:$PATH" \ + bash "$setup_copy" /dev/null +} + +run_setup() { + invoke_setup "${1:-success}" || + fail "FIDO2 setup registers a device that answers fido2-token" "sudo calls: +$(cat "$calls")" +} + +safe_fixture_stage_path() { + local candidate=$1 + local prefix="$authfile.new." + local suffix + + [[ $candidate == "$prefix"* ]] || return 1 + suffix=${candidate#"$prefix"} + [[ $suffix =~ ^[[:alnum:]]{6}$ ]] +} + +single_stage() { + local count + + count=$(wc -l <"$stages") + (( count == 1 )) || fail "setup creates exactly one privileged stage" "got $count stages" + head -n 1 "$stages" +} + +assert_pipe_target() { + local count target + + count=$(wc -l <"$pamu_targets") + (( count == 1 )) || fail "setup invokes pamu2fcfg exactly once" "got $count invocations" + target=$(head -n 1 "$pamu_targets") + [[ $target == pipe:* ]] || + fail "pamu2fcfg writes only to a pipe, never a caller-owned named file" "got: $target" +} + +assert_failed_stage_cleanup() { + local stage_path + + stage_path=$(single_stage) + safe_fixture_stage_path "$stage_path" || + fail "the failed setup stage is a unique scratch sibling" "got: $stage_path" + grep -Fxq $'sudo\trm\t-f\t--\t'"$stage_path" "$calls" || + fail "failed setup removes its exact privileged stage" "$(cat "$calls")" + [[ ! -e $stage_path && ! -L $stage_path ]] || + fail "the failed setup stage is gone" "left behind: $stage_path" + [[ ! -e $authfile ]] || fail "failed setup never publishes a credential" +} + +# Each branch below is a fixture rather than whatever the host happens to have +# at /etc/fido2, so all of them run on every machine and the staging assertions +# that follow are reached even on one that already uses FIDO2. +reset_run +mkdir -p "$authdir" +printf '%s\n' "$credential" >"$authfile" +run_setup +[[ ! -s $stages && ! -s $pamu_targets ]] || + fail "FIDO2 setup stages nothing when a registration already exists" +! grep -Fq $'sudo\tmktemp\t' "$calls" || + fail "FIDO2 setup creates no stage over an existing registration" "$(cat "$calls")" +pass "FIDO2 setup leaves an existing registration alone" + +reset_run +mkdir -p "$authdir" +ln -s /dev/null "$authfile" +invoke_setup >/dev/null 2>&1 && + fail "FIDO2 setup refuses a symlinked authfile" +[[ ! -s $stages && ! -s $pamu_targets ]] || + fail "FIDO2 setup stages nothing against a symlinked authfile" +[[ -L $authfile ]] || fail "FIDO2 setup leaves the symlinked authfile in place" +pass "FIDO2 setup refuses a symlinked authfile" + +reset_run +mkdir -p "$authfile" +invoke_setup >/dev/null 2>&1 && + fail "FIDO2 setup refuses a directory where the authfile belongs" +[[ ! -s $stages && ! -s $pamu_targets ]] || + fail "FIDO2 setup stages nothing against a directory authfile" +pass "FIDO2 setup refuses a non-regular authfile" + +# install -d follows a symlink at the directory and applies its mode and +# ownership to whatever it points at, so the credential would be staged and +# published inside the target and that directory reopened to root:root 755. +reset_run +mkdir -p "$test_tmp/elsewhere" +chmod 700 "$test_tmp/elsewhere" +ln -s "$test_tmp/elsewhere" "$authdir" +invoke_setup >/dev/null 2>&1 && + fail "FIDO2 setup refuses a symlinked FIDO2 directory" +[[ ! -s $stages && ! -s $pamu_targets ]] || + fail "FIDO2 setup stages nothing through a symlinked FIDO2 directory" +! grep -Fq $'sudo\tinstall\t' "$calls" || + fail "FIDO2 setup never runs install -d through a symlink" "$(cat "$calls")" +[[ $(stat -c %a "$test_tmp/elsewhere") == "700" ]] || + fail "FIDO2 setup leaves the symlink target's mode alone" "got: $(stat -c %a "$test_tmp/elsewhere")" +[[ ! -e $test_tmp/elsewhere/fido2 ]] || + fail "FIDO2 setup publishes nothing inside the symlink target" +pass "FIDO2 setup refuses a symlinked FIDO2 directory and leaves its target alone" + +reset_run +run_setup +stage_path=$(single_stage) +safe_fixture_stage_path "$stage_path" || + fail "FIDO2 setup uses a unique sibling stage" "got: $stage_path" +assert_pipe_target + +[[ ! -s $bare_mktemp ]] || + fail "FIDO2 setup never creates a caller-owned temporary file" "$(cat "$bare_mktemp")" +grep -Fxq $'sudo\tmktemp\t'"$authfile.new.XXXXXX" "$calls" || + fail "FIDO2 setup asks root to create a unique sibling stage" "$(cat "$calls")" +grep -Fxq $'sudo\ttee\t'"$stage_path" "$calls" || + fail "pamu2fcfg is piped into the exact privileged stage" "$(cat "$calls")" +grep -Fxq $'sudo\tchmod\t644\t'"$stage_path" "$calls" || + fail "FIDO2 setup makes the completed authfile PAM-readable" "$(cat "$calls")" +grep -Fxq $'sudo\tmv\t-Tf\t'"$stage_path"$'\t'"$authfile" "$calls" || + fail "FIDO2 setup atomically publishes the exact privileged stage" "$(cat "$calls")" +! grep -Fq $'sudo\trm\t' "$calls" || + fail "successful setup leaves its cleanup trap inert" "$(cat "$calls")" + +[[ ! -e $stage_path && ! -L $stage_path ]] || + fail "the privileged stage path is gone after publication" "left behind: $stage_path" +[[ -f $authfile && $(<"$authfile") == "$credential" ]] || + fail "the published authfile contains the generated credential" +[[ $(stat -c %a "$authfile") == "644" ]] || + fail "the published authfile is mode 644" "got: $(stat -c %a "$authfile")" +pass "FIDO2 setup pipes the credential into a unique root-created stage and publishes it atomically" + +# A chmod failure happens after a complete credential has been written but +# before publication. It must abort the setup and leave the EXIT trap armed. +reset_run +if invoke_setup success 1 >/dev/null 2>&1; then + fail "a failed chmod propagates out of FIDO2 setup" +fi +failed_stage=$(single_stage) +assert_pipe_target +grep -Fxq $'sudo\tchmod\t644\t'"$failed_stage" "$calls" || + fail "the injected chmod failure targets the exact privileged stage" "$(cat "$calls")" +! grep -Fq $'sudo\tmv\t' "$calls" || + fail "a stage whose chmod failed is never published" "$(cat "$calls")" +assert_failed_stage_cleanup +pass "FIDO2 setup propagates chmod failure and cleans its privileged stage" + +# A failed atomic rename has the same cleanup obligation. The completed stage +# must not survive beside the live authfile when publication fails. +reset_run +if invoke_setup success 0 1 >/dev/null 2>&1; then + fail "a failed mv propagates out of FIDO2 setup" +fi +failed_stage=$(single_stage) +assert_pipe_target +grep -Fxq $'sudo\tchmod\t644\t'"$failed_stage" "$calls" || + fail "the mv-failure fixture reaches a completed mode-644 stage" "$(cat "$calls")" +grep -Fxq $'sudo\tmv\t-Tf\t'"$failed_stage"$'\t'"$authfile" "$calls" || + fail "the injected mv failure targets the exact privileged stage" "$(cat "$calls")" +assert_failed_stage_cleanup +pass "FIDO2 setup propagates mv failure and cleans its privileged stage" + +# Emit a valid credential and then fail. Without pipefail, tee's success masks +# pamu2fcfg's status and the nonempty file would be published. +reset_run +if invoke_setup fail >/dev/null 2>&1; then + fail "a failing pamu2fcfg pipeline fails setup" +fi +assert_pipe_target +assert_failed_stage_cleanup +! grep -Fq $'sudo\tchmod\t' "$calls" || + fail "a failed pamu2fcfg result is never prepared for publication" "$(cat "$calls")" +! grep -Fq $'sudo\tmv\t' "$calls" || + fail "a failed pamu2fcfg result is never published" "$(cat "$calls")" +pass "FIDO2 setup propagates pamu2fcfg failure and cleans its privileged stage" + +# A successful pipeline can still produce no credential. Reject that before +# chmod or rename, and clean the exact stage just as on command failure. +reset_run +if invoke_setup empty >/dev/null 2>&1; then + fail "an empty pamu2fcfg result fails setup" +fi +assert_pipe_target +assert_failed_stage_cleanup +! grep -Fq $'sudo\tchmod\t' "$calls" || + fail "an empty pamu2fcfg result is never prepared for publication" "$(cat "$calls")" +! grep -Fq $'sudo\tmv\t' "$calls" || + fail "an empty pamu2fcfg result is never published" "$(cat "$calls")" +pass "FIDO2 setup rejects an empty credential and cleans its privileged stage" + +# mktemp's output is an operand for a privileged tee, chmod, mv and rm. Take +# only the name this script asked for: a stage path outside that shape must stop +# the setup before any of them runs, exactly as the migration does. +reset_run +invoke_setup success 0 0 malformed >/dev/null 2>&1 && + fail "a malformed mktemp result fails setup" +! grep -Fq $'sudo\ttee\t' "$calls" || + fail "no credential is written to a malformed stage path" "$(cat "$calls")" +! grep -Fq $'sudo\tchmod\t' "$calls" || + fail "a malformed stage path never reaches a privileged chmod" "$(cat "$calls")" +! grep -Fq $'sudo\tmv\t' "$calls" || + fail "a malformed stage path is never published" "$(cat "$calls")" +! grep -Fq $'sudo\trm\t' "$calls" || + fail "a malformed stage path never reaches a privileged rm" "$(cat "$calls")" +[[ ! -e $authfile ]] || fail "a malformed stage publishes no authfile" +pass "FIDO2 setup rejects malformed mktemp output before any privileged write" + +reset_run +invoke_setup success 0 0 nonregular >/dev/null 2>&1 && + fail "a nonregular mktemp result fails setup" +! grep -Fq $'sudo\ttee\t' "$calls" || + fail "no credential is written into a nonregular stage" "$(cat "$calls")" +! grep -Fq $'sudo\tchmod\t' "$calls" || + fail "a nonregular stage never reaches a privileged chmod" "$(cat "$calls")" +! grep -Fq $'sudo\tmv\t' "$calls" || + fail "a nonregular stage is never published" "$(cat "$calls")" +[[ ! -e $authfile ]] || fail "a nonregular stage publishes no authfile" +pass "FIDO2 setup rejects nonregular mktemp output before any privileged write" From fe56d68e905c8dc63a4c6a43727fef40833360d7 Mon Sep 17 00:00:00 2001 From: bastidotnet <233381911+bastidotnet@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:24:00 +0200 Subject: [PATCH 12/41] Validate the cached Apple-display device path before use The cached device path was trusted for merely existing, not for being a hiddev node, and fell back to a predictable /tmp path when XDG_RUNTIME_DIR was unset. Validate the cache shape (hiddev char device) and cache only under the user-private runtime dir; asdcontrol already gates non-Apple devices downstream, so this is defense-in-depth in the layer Omarchy owns. --- bin/omarchy-brightness-display-apple | 21 ++- .../brightness-display-apple-cache-test.sh | 120 ++++++++++++++++++ 2 files changed, 137 insertions(+), 4 deletions(-) create mode 100755 test/shell.d/brightness-display-apple-cache-test.sh diff --git a/bin/omarchy-brightness-display-apple b/bin/omarchy-brightness-display-apple index 81202b87..1027686d 100755 --- a/bin/omarchy-brightness-display-apple +++ b/bin/omarchy-brightness-display-apple @@ -4,7 +4,13 @@ # omarchy:args=[--no-osd] [+N%|N%-|N%] # omarchy:examples=omarchy brightness display apple | omarchy brightness display apple +5% | omarchy brightness display apple --no-osd 50% -device_cache="${XDG_RUNTIME_DIR:-/tmp}/omarchy-brightness-display-apple.device" +# Only cache under the user-private runtime dir. With no XDG_RUNTIME_DIR we skip +# caching (detect every run) rather than fall back to a predictable, world-writable +# /tmp path another user could pre-create. +device_cache="" +if [[ -n "${XDG_RUNTIME_DIR:-}" ]]; then + device_cache="$XDG_RUNTIME_DIR/omarchy-brightness-display-apple.device" +fi no_osd=0 if [[ ${1:-} == "--no-osd" ]]; then no_osd=1 @@ -28,9 +34,14 @@ find_apple_display_device() { local cached="" local device="" - if [[ -r $device_cache ]]; then + if [[ -n "$device_cache" && -r $device_cache ]]; then read -r cached <"$device_cache" || true - if [[ -n $cached && -e $cached ]]; then + # Trust a cached value only if it still names a hiddev character device. A + # stale or unexpected cache (a regular file, a non-hiddev node) is ignored and + # we re-detect instead of handing an arbitrary path to asdcontrol. The globs + # are left unquoted on purpose: [[ ]] pattern-matches an unquoted right side, + # and quoting them would turn the match into a literal string comparison. + if [[ ( $cached == /dev/hiddev* || $cached == /dev/usb/hiddev* ) && -c $cached ]]; then printf '%s\n' "$cached" return 0 fi @@ -39,7 +50,9 @@ find_apple_display_device() { device="$(detect_apple_display_device)" || return 1 [[ -n $device ]] || return 1 - printf '%s\n' "$device" >"$device_cache" + if [[ -n "$device_cache" ]]; then + printf '%s\n' "$device" >"$device_cache" + fi printf '%s\n' "$device" } diff --git a/test/shell.d/brightness-display-apple-cache-test.sh b/test/shell.d/brightness-display-apple-cache-test.sh new file mode 100755 index 00000000..2808f93f --- /dev/null +++ b/test/shell.d/brightness-display-apple-cache-test.sh @@ -0,0 +1,120 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +# Stubs on PATH: drop sudo so asdcontrol runs directly, record every asdcontrol +# invocation, make detection deterministic by having --detect report no device, +# and no-op the OSD. On a host without any /dev/*hiddev* node the wrapper's +# detect_apple_display_device returns before it ever runs asdcontrol, so the +# reject cases assert on the negative: a refused cache value is never handed to +# `asdcontrol -- `. Blind-trust validation would hand it over and be +# caught here. +stub_dir="$TMPDIR/stubs" +mkdir -p "$stub_dir" + +asd_log="$TMPDIR/asdcontrol.log" + +cat >"$stub_dir/sudo" <<'STUB' +#!/bin/bash +exec "$@" +STUB +chmod +x "$stub_dir/sudo" + +cat >"$stub_dir/asdcontrol" <>"$asd_log" +# --detect reports nothing, so detection never yields a device. +if [[ \$1 == "--detect" ]]; then + exit 0 +fi +# A brightness read (a lone device arg) returns a plausible value; a set +# ( -- ) just succeeds. +if [[ \$# -eq 1 ]]; then + printf '%s: BRIGHTNESS=30000\n' "\$1" +fi +exit 0 +STUB +chmod +x "$stub_dir/asdcontrol" + +cat >"$stub_dir/omarchy-osd" <<'STUB' +#!/bin/bash +exit 0 +STUB +chmod +x "$stub_dir/omarchy-osd" + +run_wrapper() { + # $1: value for XDG_RUNTIME_DIR ("" means unset); remaining args go to the wrapper. + local xdg="$1" + shift + : >"$asd_log" + if [[ -n $xdg ]]; then + XDG_RUNTIME_DIR="$xdg" PATH="$stub_dir:$ROOT/bin:$PATH" \ + omarchy-brightness-display-apple "$@" 2>&1 || true + else + env -u XDG_RUNTIME_DIR PATH="$stub_dir:$ROOT/bin:$PATH" \ + omarchy-brightness-display-apple "$@" 2>&1 || true + fi +} + +# --- A cache value that is not a hiddev character device is rejected ---------- +xdg_dir="$TMPDIR/xdg" +mkdir -p "$xdg_dir" +cache_file="$xdg_dir/omarchy-brightness-display-apple.device" + +regular_file="$TMPDIR/not-a-device" +: >"$regular_file" + +for poison in "/dev/null" "$regular_file" "/tmp/omarchy-evil"; do + printf '%s\n' "$poison" >"$cache_file" + output=$(run_wrapper "$xdg_dir" "+5%") + if grep -qF -- "$poison -- +5%" "$asd_log"; then + fail "wrapper handed a non-hiddev cache value to asdcontrol: $poison" "$output" + fi +done +pass "wrapper rejects a cached path that is not a hiddev character device" + +# NOTE: the complementary arm (a cache value that DOES match /dev/hiddev* but is +# not a character device) cannot be built without root -- only real device nodes +# live under /dev. It is covered by the -c test and exercised below only when a +# real hiddev node happens to be present. + +# --- A legitimate cached hiddev node is trusted (only where HW is present) ---- +real_hiddev="" +for candidate in /dev/usb/hiddev* /dev/hiddev*; do + if [[ -c $candidate ]]; then + real_hiddev="$candidate" + break + fi +done +if [[ -n $real_hiddev ]]; then + printf '%s\n' "$real_hiddev" >"$cache_file" + run_wrapper "$xdg_dir" "+5%" >/dev/null + grep -qF -- "$real_hiddev -- +5%" "$asd_log" || + fail "wrapper did not trust a valid cached hiddev node: $real_hiddev" + pass "wrapper trusts a cached hiddev character device without re-detecting" +else + pass "no /dev/hiddev* character device present; skipping the valid-cache case" +fi + +# --- With no XDG_RUNTIME_DIR, the predictable /tmp cache is not consulted ------ +# Guard on the real path not pre-existing so we never clobber a live cache, and +# remove what we create. Old code read /tmp and would hand /dev/null to +# asdcontrol; new code has no cache path at all when XDG_RUNTIME_DIR is unset. +tmp_cache="/tmp/omarchy-brightness-display-apple.device" +if [[ -e $tmp_cache ]]; then + pass "$tmp_cache already exists on this host; skipping the /tmp-fallback case" +else + printf '%s\n' "/dev/null" >"$tmp_cache" + output=$(run_wrapper "" "+5%") + used=1 + grep -qF -- "/dev/null -- +5%" "$asd_log" || used=0 + rm -f "$tmp_cache" + (( used == 0 )) || + fail "wrapper consulted the world-writable /tmp cache with no XDG_RUNTIME_DIR" "$output" + pass "wrapper ignores the /tmp cache path when XDG_RUNTIME_DIR is unset" +fi From e53548fae28ecd6b08dc2f7f5facf7f71f05621b Mon Sep 17 00:00:00 2001 From: bastidotnet <233381911+bastidotnet@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:51:39 +0200 Subject: [PATCH 13/41] Harden the test's temp-file handling against a symlink race The /tmp-fallback case did check-then-create on a fixed /tmp name, a TOCTOU/symlink race, and the EXIT trap only cleaned $TMPDIR. Create the decoy atomically with noclobber (O_EXCL) so it refuses to overwrite an existing file or follow a symlink at that path, and remove it on exit only when this test created it. The fixed path is required (it is exactly the path the old code would form), so a random mktemp name cannot replace it. Addresses the Copilot review on #8198; the wrapper fix is unchanged. --- .../brightness-display-apple-cache-test.sh | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/test/shell.d/brightness-display-apple-cache-test.sh b/test/shell.d/brightness-display-apple-cache-test.sh index 2808f93f..c3ac8d53 100755 --- a/test/shell.d/brightness-display-apple-cache-test.sh +++ b/test/shell.d/brightness-display-apple-cache-test.sh @@ -5,7 +5,20 @@ set -euo pipefail source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" TMPDIR=$(mktemp -d) -trap 'rm -rf "$TMPDIR"' EXIT +# The /tmp-fallback case (below) must place its decoy at exactly the fixed path the +# old wrapper would have formed, so it cannot use a random mktemp name. Track whether +# we created it and remove it on exit only then -- never touch a path we did not create. +tmp_cache="/tmp/omarchy-brightness-display-apple.device" +created_tmp_cache=0 + +cleanup() { + rm -rf "$TMPDIR" + # Remove the /tmp decoy only if this test is the one that created it. + if (( created_tmp_cache )); then + rm -f "$tmp_cache" + fi +} +trap cleanup EXIT # Stubs on PATH: drop sudo so asdcontrol runs directly, record every asdcontrol # invocation, make detection deterministic by having --detect report no device, @@ -102,14 +115,14 @@ else fi # --- With no XDG_RUNTIME_DIR, the predictable /tmp cache is not consulted ------ -# Guard on the real path not pre-existing so we never clobber a live cache, and -# remove what we create. Old code read /tmp and would hand /dev/null to -# asdcontrol; new code has no cache path at all when XDG_RUNTIME_DIR is unset. -tmp_cache="/tmp/omarchy-brightness-display-apple.device" -if [[ -e $tmp_cache ]]; then - pass "$tmp_cache already exists on this host; skipping the /tmp-fallback case" -else - printf '%s\n' "/dev/null" >"$tmp_cache" +# Create the decoy atomically with noclobber (O_EXCL) instead of check-then-create: +# this refuses to overwrite an existing file or follow a symlink at the fixed path, +# closing the TOCTOU/symlink race. The fixed path is required -- it is exactly the +# path the old code would have formed, so a decoy anywhere else would prove nothing. +# If the path is already taken, skip rather than touch it; the EXIT trap removes the +# decoy only when this test created it. +if ( set -C; printf '%s\n' "/dev/null" >"$tmp_cache" ) 2>/dev/null; then + created_tmp_cache=1 output=$(run_wrapper "" "+5%") used=1 grep -qF -- "/dev/null -- +5%" "$asd_log" || used=0 @@ -117,4 +130,6 @@ else (( used == 0 )) || fail "wrapper consulted the world-writable /tmp cache with no XDG_RUNTIME_DIR" "$output" pass "wrapper ignores the /tmp cache path when XDG_RUNTIME_DIR is unset" +else + pass "$tmp_cache already present or not safely creatable; skipping the /tmp-fallback case" fi From 4cd8a081cb67af345be7d8677faeee6575d89bef Mon Sep 17 00:00:00 2001 From: orienw <1744079+orienw@users.noreply.github.com> Date: Tue, 25 Aug 2026 07:07:17 -0700 Subject: [PATCH 14/41] Fix Codex usage collection on 0.149 (#7649) * Fix Codex usage collector approval policy * Capture codex argv with boundaries in the scanner test The stub joined its arguments with "$*", so the assertion compared one flattened string and could not tell five arguments from fewer containing spaces. Passing "-s read-only" and "-a on-request" as single arguments -- which codex rejects as an unexpected argument -- passed the test. NUL separation and an array comparison keep the boundaries the assertion is about. Co-Authored-By: Claude Opus 5 (1M context) Co-authored-by: Codex XHigh --------- Co-authored-by: Omabot Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Codex XHigh --- bin/omarchy-agent-usage-codex | 2 +- test/shell.d/agent-usage-codex-scanner-test.sh | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/bin/omarchy-agent-usage-codex b/bin/omarchy-agent-usage-codex index e0200535..972c164d 100755 --- a/bin/omarchy-agent-usage-codex +++ b/bin/omarchy-agent-usage-codex @@ -528,7 +528,7 @@ def fetch_codex_rpc(): try: proc = subprocess.Popen( - [codex, "-s", "read-only", "-a", "untrusted", "app-server"], + [codex, "-s", "read-only", "-a", "on-request", "app-server"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, diff --git a/test/shell.d/agent-usage-codex-scanner-test.sh b/test/shell.d/agent-usage-codex-scanner-test.sh index f3f9aa1e..3ce4883c 100644 --- a/test/shell.d/agent-usage-codex-scanner-test.sh +++ b/test/shell.d/agent-usage-codex-scanner-test.sh @@ -13,6 +13,10 @@ mkdir -p "$TEST_HOME/.codex/sessions/$(date +%Y/%m/%d)" "$TEST_HOME/bin" cat >"$TEST_HOME/bin/codex" <<'EOF' #!/bin/bash +if [[ -n ${CODEX_ARGS_FILE:-} ]]; then + printf '%s\0' "$@" >"$CODEX_ARGS_FILE" +fi + while read -r request; do id=$(jq -r '.id // empty' <<<"$request") method=$(jq -r '.method // empty' <<<"$request") @@ -40,9 +44,18 @@ cat >"$session" < Date: Tue, 25 Aug 2026 15:04:13 +0000 Subject: [PATCH 15/41] Update Omarchy on Mac guide link to omarchy-mac repo (#8192) --- manual/49-omarchy-on.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manual/49-omarchy-on.md b/manual/49-omarchy-on.md index d5c64f4b..c33642fd 100644 --- a/manual/49-omarchy-on.md +++ b/manual/49-omarchy-on.md @@ -2,7 +2,7 @@ ### Apple M1/M2 chips -[Asahi Alarm](https://asahi-alarm.org/) is a version of Arch for Apple M1/M2 computers built on top of [Asahi Linux](https://asahilinux.org/). You can get Omarchy running on top of that with some effort. See [the user-driven guide](https://codeberg.org/malik-na/omarchy-mac). +[Asahi Alarm](https://asahi-alarm.org/) is a version of Arch for Apple M1/M2 computers built on top of [Asahi Linux](https://asahilinux.org/). You can get Omarchy running on top of that with some effort. See [the user-driven guide](https://github.com/omarchy-mac/omarchy-mac). ### Apple Virtual Machine From 2c93e66b0cd0b8138e16dc580fa26878f31f4e50 Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Tue, 25 Aug 2026 16:05:39 +0100 Subject: [PATCH 16/41] Run ONCE with sudo when installing The install user is no longer in the docker group by default, so a bare `once` cannot reach the Docker socket. Run with `sudo` instead. The script already requires sudo to install the command and enable the service, so we can safely use it for the initial command launch as well. --- bin/omarchy-install-service-once | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/omarchy-install-service-once b/bin/omarchy-install-service-once index 9b5cf446..b721a738 100755 --- a/bin/omarchy-install-service-once +++ b/bin/omarchy-install-service-once @@ -10,4 +10,4 @@ echo "Enabling ONCE background service..." sudo systemctl enable --now once-background.service echo -e "\nLaunching ONCE..." -once +sudo once From 95b791af16dc7cdb1a5291c94c7301f3972c0926 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Mon, 24 Aug 2026 01:20:40 -0600 Subject: [PATCH 17/41] Stop world-writable browser policy directories Chromium managed policy is mandatory for every profile. World-writable dirs let any local uid plant policy, including force-installed extensions. Write goes through the omarchy-browser-policy group at 2775 so theme colour still works without other-write. --- bin/omarchy-install-browser | 30 ++- bin/omarchy-provision-owner | 9 + bin/omarchy-theme-set-browser | 18 +- bin/omarchy-upgrade-to-quattro | 22 +- install/config/all.sh | 1 + install/config/browser-policy.sh | 3 + install/config/theme-system.sh | 4 - install/helpers/as-root.sh | 7 + install/helpers/browser-policy.sh | 171 ++++++++++++++++ migrations/1787515927.sh | 24 +++ test/shell.d/browser-policy-dir-test.sh | 244 +++++++++++++++++++++++ test/shell.d/default-apps-test.sh | 47 ++++- test/shell.d/provisioning-groups-test.sh | 37 +++- test/shell.d/upgrade-to-quattro-test.sh | 17 ++ 14 files changed, 596 insertions(+), 38 deletions(-) create mode 100644 install/config/browser-policy.sh create mode 100644 install/helpers/as-root.sh create mode 100644 install/helpers/browser-policy.sh create mode 100644 migrations/1787515927.sh create mode 100755 test/shell.d/browser-policy-dir-test.sh diff --git a/bin/omarchy-install-browser b/bin/omarchy-install-browser index f71c7c98..d808c805 100755 --- a/bin/omarchy-install-browser +++ b/bin/omarchy-install-browser @@ -6,9 +6,12 @@ set -e -setup_policy_directory() { - sudo mkdir -p "$1" - sudo chmod a+rw "$1" +source "$OMARCHY_PATH/install/helpers/browser-policy.sh" + +setup_chromium_policy_directory() { + browser_policy_setup_group + browser_policy_grant_user "${USER:-$(id -un)}" + browser_policy_setup_dir "$1" } announce_browser_installed() { @@ -23,13 +26,6 @@ copy_chromium_flags() { omarchy-install-chromium-ytdlp } -setup_firefox_preferences() { - local distribution_dir="$1" - - setup_policy_directory "$distribution_dir" - sudo cp -f "$OMARCHY_PATH/default/firefox/policies.json" "$distribution_dir/policies.json" -} - setup_firefox_wayland() { mkdir -p ~/.config/environment.d echo "MOZ_ENABLE_WAYLAND=1" > ~/.config/environment.d/omarchy-firefox-wayland.conf @@ -40,7 +36,7 @@ chromium) echo "Installing Chromium..." omarchy-pkg-add chromium - setup_policy_directory /etc/chromium/policies/managed + setup_chromium_policy_directory /etc/chromium/policies/managed copy_chromium_flags ~/.config/chromium-flags.conf omarchy-theme-set-browser announce_browser_installed "Chromium" @@ -49,7 +45,7 @@ chrome) echo "Installing Chrome..." omarchy-pkg-aur-add google-chrome || exit 1 - setup_policy_directory /etc/opt/chrome/policies/managed + setup_chromium_policy_directory /etc/opt/chrome/policies/managed copy_chromium_flags ~/.config/chrome-flags.conf omarchy-theme-set-browser announce_browser_installed "Chrome" @@ -58,7 +54,7 @@ edge) echo "Installing Edge..." omarchy-pkg-aur-add microsoft-edge-stable-bin || exit 1 - setup_policy_directory /etc/opt/edge/policies/managed + setup_chromium_policy_directory /etc/opt/edge/policies/managed copy_chromium_flags ~/.config/microsoft-edge-stable-flags.conf omarchy-theme-set-browser announce_browser_installed "Edge" @@ -67,7 +63,7 @@ brave) echo "Installing Brave..." omarchy-pkg-aur-add brave-bin || exit 1 - setup_policy_directory /etc/brave/policies/managed + setup_chromium_policy_directory /etc/brave/policies/managed copy_chromium_flags ~/.config/brave-flags.conf omarchy-theme-set-browser announce_browser_installed "Brave" @@ -76,7 +72,7 @@ brave-origin) echo "Installing Brave Origin..." omarchy-pkg-aur-add brave-origin-bin || exit 1 - setup_policy_directory /etc/brave/policies/managed + setup_chromium_policy_directory /etc/brave/policies/managed copy_chromium_flags ~/.config/brave-origin-flags.conf omarchy-theme-set-browser announce_browser_installed "Brave Origin" @@ -85,7 +81,7 @@ firefox) echo "Installing Firefox..." omarchy-pkg-add firefox || exit 1 - setup_firefox_preferences /usr/lib/firefox/distribution + browser_policy_setup_firefox_distribution /usr/lib/firefox/distribution setup_firefox_wayland announce_browser_installed "Firefox" ;; @@ -93,7 +89,7 @@ zen) echo "Installing Zen..." omarchy-pkg-aur-add zen-browser-bin || exit 1 - setup_firefox_preferences /opt/zen-browser/distribution + browser_policy_setup_firefox_distribution /opt/zen-browser/distribution setup_firefox_wayland announce_browser_installed "Zen" ;; diff --git a/bin/omarchy-provision-owner b/bin/omarchy-provision-owner index c27de22c..7c208eb9 100755 --- a/bin/omarchy-provision-owner +++ b/bin/omarchy-provision-owner @@ -742,6 +742,15 @@ create_user() { # for specific commands), and a duplicate grant is harmless. echo "%wheel ALL=(ALL:ALL) ALL" >/etc/sudoers.d/00-omarchy-wheel chmod 440 /etc/sudoers.d/00-omarchy-wheel + + source "$OMARCHY_PATH/install/helpers/browser-policy.sh" + OMARCHY_INSTALL_USER=$username + OMARCHY_PROVISIONING_DIR=$PROVISIONING_DIR + browser_policy_setup_group + for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do + [[ -d $dir ]] || continue + browser_policy_setup_dir "$dir" + done } install_authorized_keys() { diff --git a/bin/omarchy-theme-set-browser b/bin/omarchy-theme-set-browser index 4dd7592f..a22cb0d6 100755 --- a/bin/omarchy-theme-set-browser +++ b/bin/omarchy-theme-set-browser @@ -13,11 +13,10 @@ else THEME_HEX_COLOR="#1c2027" fi -set_browser_policy() { - local policy_dir="$1" +source "$OMARCHY_PATH/install/helpers/browser-policy.sh" - [[ -d $policy_dir ]] || return - echo "{\"BrowserThemeColor\": \"$THEME_HEX_COLOR\", \"BrowserColorScheme\": \"device\"}" | tee "$policy_dir/color.json" >/dev/null +set_browser_policy() { + browser_policy_write_color "$1" "$THEME_HEX_COLOR" } refresh_running_browser() { @@ -30,17 +29,20 @@ refresh_running_browser() { fi } -set_browser_policy /etc/chromium/policies/managed +failed=0 +set_browser_policy /etc/chromium/policies/managed || failed=1 refresh_running_browser chromium chromium -set_browser_policy /etc/opt/chrome/policies/managed +set_browser_policy /etc/opt/chrome/policies/managed || failed=1 refresh_running_browser chrome google-chrome-stable || refresh_running_browser chrome google-chrome -set_browser_policy /etc/opt/edge/policies/managed +set_browser_policy /etc/opt/edge/policies/managed || failed=1 refresh_running_browser msedge microsoft-edge-stable -set_browser_policy /etc/brave/policies/managed +set_browser_policy /etc/brave/policies/managed || failed=1 refresh_running_browser brave brave # Match on the binary path: the running process is named plain "brave", and a # bare -f brave-origin pattern would also match the installer's own terminal. refresh_running_browser /opt/brave-origin-bin/ brave-origin -f + +exit "$failed" diff --git a/bin/omarchy-upgrade-to-quattro b/bin/omarchy-upgrade-to-quattro index e28da697..62ef21b9 100755 --- a/bin/omarchy-upgrade-to-quattro +++ b/bin/omarchy-upgrade-to-quattro @@ -1312,7 +1312,22 @@ apply_system_transition() { /usr/share/icons/Yaru/scalable/actions/go-next-symbolic.svg as_root gtk-update-icon-cache /usr/share/icons/Yaru >/dev/null 2>&1 || true - as_root install -d -m 0777 /etc/chromium/policies/managed + local browser_policy_helper=/usr/share/omarchy/install/helpers/browser-policy.sh + if ! as_root test -f "$browser_policy_helper"; then + warn "$browser_policy_helper is unavailable; Chromium policy directories were not hardened." + else + as_root env OMARCHY_PATH=/usr/share/omarchy OMARCHY_INSTALL_USER="$target_user" \ + bash -euo pipefail -c ' + source "$OMARCHY_PATH/install/helpers/browser-policy.sh" + browser_policy_setup_group + browser_policy_setup_dir /etc/chromium/policies/managed + for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do + [[ $dir == "/etc/chromium/policies/managed" ]] && continue + [[ -d $dir ]] || continue + browser_policy_setup_dir "$dir" + done + ' + fi as_root install -d -m 0755 /usr/lib/chromium printf '%s\n' '{"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' | \ as_root tee /usr/lib/chromium/initial_preferences >/dev/null @@ -2306,6 +2321,11 @@ refresh_current_theme_after_upgrade() { # hooks because one of them runs `hyprctl reload`. Still poke terminal # emulators so the active upgrade terminal picks up generated theme files. run_as_user_omarchy omarchy-restart-terminal >/dev/null 2>&1 || true + + # apply_system_transition purged user-owned color.json. Headless theme-set + # skipped omarchy-theme-set-browser, so rewrite the colour here. + run_as_user_omarchy omarchy-theme-set-browser >/dev/null 2>&1 || + warn "Could not apply browser theme colour. Run 'omarchy theme set \"$theme_name\"' after reboot if Chromium's theme looks stale." } # Everything below mutates the system, so a non-zero exit from here on leaves a diff --git a/install/config/all.sh b/install/config/all.sh index 91256dc7..d8c7d9bb 100644 --- a/install/config/all.sh +++ b/install/config/all.sh @@ -1,4 +1,5 @@ run_logged "$OMARCHY_INSTALL/config/theme-system.sh" +run_logged "$OMARCHY_INSTALL/config/browser-policy.sh" run_logged "$OMARCHY_INSTALL/config/increase-lockout-limit.sh" run_logged "$OMARCHY_INSTALL/config/lockscreen-pam.sh" run_logged "$OMARCHY_INSTALL/config/fix-powerprofilesctl-shebang.sh" diff --git a/install/config/browser-policy.sh b/install/config/browser-policy.sh new file mode 100644 index 00000000..a02de2f3 --- /dev/null +++ b/install/config/browser-policy.sh @@ -0,0 +1,3 @@ +source "$OMARCHY_PATH/install/helpers/browser-policy.sh" +browser_policy_setup_group +browser_policy_setup_dir /etc/chromium/policies/managed diff --git a/install/config/theme-system.sh b/install/config/theme-system.sh index 2902e1cd..83db0e15 100644 --- a/install/config/theme-system.sh +++ b/install/config/theme-system.sh @@ -6,10 +6,6 @@ ln -snf /usr/share/icons/Adwaita/symbolic/actions/go-next-symbolic.svg \ /usr/share/icons/Yaru/scalable/actions/go-next-symbolic.svg gtk-update-icon-cache /usr/share/icons/Yaru &>/dev/null || true -# Chromium policy directory for theme -mkdir -p /etc/chromium/policies/managed -chmod a+rw /etc/chromium/policies/managed - # Default Chromium to follow system appearance ("device") instead of dark mkdir -p /usr/lib/chromium echo '{"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' > \ diff --git a/install/helpers/as-root.sh b/install/helpers/as-root.sh new file mode 100644 index 00000000..005ae351 --- /dev/null +++ b/install/helpers/as-root.sh @@ -0,0 +1,7 @@ +as_root() { + if (( EUID == 0 )); then + "$@" + else + sudo "$@" + fi +} diff --git a/install/helpers/browser-policy.sh b/install/helpers/browser-policy.sh new file mode 100644 index 00000000..803df2c6 --- /dev/null +++ b/install/helpers/browser-policy.sh @@ -0,0 +1,171 @@ +# Chromium-family machine policy is mandatory for every profile. A dedicated +# group at 2775 lets every Omarchy user write color.json and every other uid +# read; other-write stays off. Setgid so new files inherit the group. + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/as-root.sh" + +BROWSER_POLICY_GROUP=omarchy-browser-policy + +BROWSER_POLICY_MANAGED_DIRS=( + /etc/chromium/policies/managed + /etc/opt/chrome/policies/managed + /etc/opt/edge/policies/managed + /etc/brave/policies/managed +) + +BROWSER_POLICY_FIREFOX_DIRS=( + /usr/lib/firefox/distribution + /opt/zen-browser/distribution +) + +browser_policy_setup_group() { + local provisioning_dir="${OMARCHY_PROVISIONING_DIR:-/var/lib/omarchy/provisioning}" + + as_root groupadd --system --force "$BROWSER_POLICY_GROUP" + as_root mkdir -p "$provisioning_dir" + if ! grep -qxF "$BROWSER_POLICY_GROUP" "$provisioning_dir/groups" 2>/dev/null; then + printf '%s\n' "$BROWSER_POLICY_GROUP" | as_root tee -a "$provisioning_dir/groups" >/dev/null + fi + + if [[ -n ${OMARCHY_INSTALL_USER:-} ]] && getent passwd "$OMARCHY_INSTALL_USER" >/dev/null; then + as_root usermod -aG "$BROWSER_POLICY_GROUP" "$OMARCHY_INSTALL_USER" + fi +} + +browser_policy_grant_user() { + local user=${1:-} + + if [[ -z $user || $user == "root" ]]; then + user=${SUDO_USER:-} + fi + + [[ -n $user && $user != "root" ]] || return 0 + getent passwd "$user" >/dev/null || return 0 + as_root usermod -aG "$BROWSER_POLICY_GROUP" "$user" +} + +browser_policy_purge_dir() { + local dir=$1 + + as_root find "$dir" -mindepth 1 -maxdepth 1 ! -user root -exec rm -rf -- {} + +} + +browser_policy_dir_hardened() { + local dir=$1 + + [[ -d $dir ]] || return 1 + [[ $(stat -c '%a' "$dir") == "2775" ]] || return 1 + [[ $(stat -c '%U' "$dir") == "root" ]] || return 1 + [[ $(stat -c '%G' "$dir") == $BROWSER_POLICY_GROUP ]] || return 1 +} + +browser_policy_setup_dir() { + local dir=$1 + + as_root install -d -m 2775 -o root -g "$BROWSER_POLICY_GROUP" "$dir" + browser_policy_purge_dir "$dir" +} + +browser_policy_file_owner() { + local user + + if [[ -n ${OMARCHY_INSTALL_USER:-} && $OMARCHY_INSTALL_USER != "root" ]]; then + printf '%s\n' "$OMARCHY_INSTALL_USER" + return + fi + if [[ -n ${SUDO_USER:-} && $SUDO_USER != "root" ]]; then + printf '%s\n' "$SUDO_USER" + return + fi + if [[ -n ${PKEXEC_UID:-} ]]; then + user=$(getent passwd "$PKEXEC_UID" | cut -d: -f1) + if [[ -n $user && $user != "root" ]]; then + printf '%s\n' "$user" + return + fi + fi + user=${USER:-$(id -un)} + if [[ $user != "root" ]]; then + printf '%s\n' "$user" + fi +} + +# sudo when this process has a controlling terminal (fd 0 is /dev/null under +# `bash -lc cmd &`, but /dev/tty still works). pkexec when it does not. +browser_policy_elevate() { + if (( EUID == 0 )); then + "$@" + elif { exec 3/dev/null; then + exec 3<&- + sudo "$@" + else + pkexec "$@" + fi +} + +browser_policy_write_color() { + local policy_dir=$1 + local hex=$2 + local dest=$policy_dir/color.json + local payload + local tmp + local owner + + [[ -d $policy_dir ]] || return 0 + + payload=$(printf '{"BrowserThemeColor": "%s", "BrowserColorScheme": "device"}\n' "$hex") + tmp=$(mktemp) || return 1 + printf '%s' "$payload" >"$tmp" + + # A planted symlink or directory must not be written through or into. + if [[ -L $dest || -d $dest ]]; then + if ! rm -rf -- "$dest" 2>/dev/null; then + if ! browser_policy_elevate rm -rf -- "$dest"; then + rm -f "$tmp" + echo "omarchy-theme-set-browser: cannot replace $dest (need group $BROWSER_POLICY_GROUP)" >&2 + return 1 + fi + fi + fi + + if install -m 664 -T "$tmp" "$dest" 2>/dev/null; then + rm -f "$tmp" + return 0 + fi + + owner=$(browser_policy_file_owner) + [[ -n $owner ]] || owner=root + if browser_policy_elevate install -m 664 -o "$owner" -g "$BROWSER_POLICY_GROUP" -T "$tmp" "$dest"; then + rm -f "$tmp" + return 0 + fi + + rm -f "$tmp" + echo "omarchy-theme-set-browser: cannot write $dest (need group $BROWSER_POLICY_GROUP)" >&2 + return 1 +} + +browser_policy_firefox_hardened() { + local dir=$1 + + [[ -d $dir ]] || return 1 + [[ $(stat -c '%a' "$dir") == "755" ]] || return 1 + [[ $(stat -c '%U' "$dir") == "root" ]] || return 1 + [[ -f $dir/policies.json && ! -L $dir/policies.json ]] || return 1 +} + +browser_policy_install_firefox_policies() { + local distribution_dir=$1 + local policies=${2:-$OMARCHY_PATH/default/firefox/policies.json} + + as_root install -m 644 -o root -g root -T "$policies" "$distribution_dir/policies.json" +} + +browser_policy_setup_firefox_distribution() { + local distribution_dir=$1 + local policies=${2:-$OMARCHY_PATH/default/firefox/policies.json} + + as_root install -d -m 0755 -o root -g root "$distribution_dir" + browser_policy_purge_dir "$distribution_dir" + browser_policy_install_firefox_policies "$distribution_dir" "$policies" +} diff --git a/migrations/1787515927.sh b/migrations/1787515927.sh new file mode 100644 index 00000000..f15298bd --- /dev/null +++ b/migrations/1787515927.sh @@ -0,0 +1,24 @@ +echo "Stop world-writable Chromium and Firefox policy directories" + +source "$OMARCHY_PATH/install/helpers/browser-policy.sh" + +browser_policy_setup_group +browser_policy_grant_user "${USER:-$(id -un)}" + +repaired=0 +for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do + [[ -d $dir ]] || continue + browser_policy_dir_hardened "$dir" && continue + browser_policy_setup_dir "$dir" + repaired=1 +done + +if (( repaired )); then + omarchy-theme-set-browser +fi + +for dir in "${BROWSER_POLICY_FIREFOX_DIRS[@]}"; do + [[ -d $dir ]] || continue + browser_policy_firefox_hardened "$dir" && continue + browser_policy_setup_firefox_distribution "$dir" +done diff --git a/test/shell.d/browser-policy-dir-test.sh b/test/shell.d/browser-policy-dir-test.sh new file mode 100755 index 00000000..34a0c3cd --- /dev/null +++ b/test/shell.d/browser-policy-dir-test.sh @@ -0,0 +1,244 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +mock_bin=$test_tmp/bin +mkdir -p "$mock_bin" +elev_log=$test_tmp/elev.log +cat >"$mock_bin/sudo" <>"$elev_log" +[[ \${OMARCHY_TEST_SUDO_FAIL:-} == 1 ]] && exit 1 +exit 0 +SH +cat >"$mock_bin/pkexec" <>"$elev_log" +[[ \${OMARCHY_TEST_SUDO_FAIL:-} == 1 ]] && exit 1 +exit 0 +SH +chmod +x "$mock_bin/sudo" "$mock_bin/pkexec" +export PATH="$mock_bin:$PATH" +: >"$elev_log" +export OMARCHY_PATH="$ROOT" +export OMARCHY_PROVISIONING_DIR="$test_tmp/provisioning" + +source "$ROOT/install/helpers/browser-policy.sh" + +# Temp dirs are user-owned; drop -o/-g so install(1) can run unprivileged. +unprivileged_as_root() { + if [[ $1 == "install" ]]; then + shift + local args=() + local skip=0 + local arg + for arg in "$@"; do + if (( skip )); then + skip=0 + continue + fi + case $arg in + -o|-g) skip=1 ;; + *) args+=("$arg") ;; + esac + done + command install "${args[@]}" + else + "$@" + fi +} + +write_dir=$test_tmp/writable +mkdir -p "$write_dir" +browser_policy_write_color "$write_dir" "#aabbcc" || + fail "theme colour writes into a writable policy directory" +grep -F '"BrowserThemeColor": "#aabbcc"' "$write_dir/color.json" >/dev/null || + fail "theme colour writes BrowserThemeColor" +mode=$(stat -c '%a' "$write_dir/color.json") +[[ $mode == "664" ]] || fail "theme colour creates a group-writable policy file" "mode=$mode" +pass "theme colour writes a group-writable color.json" + +if (( EUID == 0 )); then + pass "running as root; skipping the mktemp-failure check" +else + chmod u+w "$write_dir" + export TMPDIR=$test_tmp/missing-tmp + if browser_policy_write_color "$write_dir" "#dead00" 2>/dev/null; then + fail "theme colour fails when mktemp cannot create a file" + fi + unset TMPDIR + grep -F '"BrowserThemeColor": "#aabbcc"' "$write_dir/color.json" >/dev/null || + fail "a failed mktemp leaves an existing color.json intact" + pass "a failed mktemp does not truncate color.json" +fi + +printf 'original\n' >"$test_tmp/pwn" +rm -f "$write_dir/color.json" +ln -s "$test_tmp/pwn" "$write_dir/color.json" +browser_policy_write_color "$write_dir" "#aabbcc" || + fail "theme colour replaces a planted color.json symlink" +[[ -f $write_dir/color.json && ! -L $write_dir/color.json ]] || + fail "theme colour unlinks a planted color.json symlink instead of writing through it" +grep -Fxq 'original' "$test_tmp/pwn" || fail "theme colour leaves the symlink target unchanged" +pass "theme colour does not follow a planted color.json symlink" + +plant_write=$test_tmp/plant-dir +mkdir -p "$plant_write/color.json/nested" +printf 'inside\n' >"$plant_write/color.json/nested/x" +browser_policy_write_color "$plant_write" "#aabbcc" || + fail "theme colour replaces a planted color.json directory" +[[ -f $plant_write/color.json && ! -d $plant_write/color.json ]] || + fail "theme colour does not write into a planted color.json directory" +pass "theme colour does not write into a planted color.json directory" + +missing_dir=$test_tmp/missing +browser_policy_write_color "$missing_dir" "#aabbcc" || + fail "theme colour skips a policy directory that does not exist" +[[ ! -e $missing_dir ]] || fail "theme colour does not create a missing policy directory" +pass "theme colour skips a missing policy directory" + +if (( EUID == 0 )); then + pass "running as root; skipping elevation checks" +else + denied_dir=$test_tmp/denied + mkdir -p "$denied_dir" + chmod a-w "$denied_dir" + owner=${USER:-$(id -un)} + : >"$elev_log" + browser_policy_write_color "$denied_dir" "#aabbcc" || + fail "elevated install reports success from pkexec" + grep -E "^PKEXEC install -m 664 -o $owner -g omarchy-browser-policy -T .+ $denied_dir/color.json$" "$elev_log" >/dev/null || + fail "without a controlling tty, colour write elevates through pkexec as the owner" "$(cat "$elev_log")" + if grep -E '^SUDO ' "$elev_log" >/dev/null; then + fail "without a controlling tty, colour write does not call sudo" "$(cat "$elev_log")" + fi + pass "without a controlling tty, colour write elevates through pkexec" + + : >"$elev_log" + export OMARCHY_TEST_SUDO_FAIL=1 + if browser_policy_write_color "$denied_dir" "#aabbcc" 2>"$test_tmp/write.err"; then + fail "theme colour fails when the policy directory is not writable" + fi + unset OMARCHY_TEST_SUDO_FAIL + grep -F 'omarchy-browser-policy' "$test_tmp/write.err" >/dev/null || + fail "theme colour names the group when the write is denied" + pass "theme colour reports a denied policy write" + + if command -v script >/dev/null; then + : >"$elev_log" + cat >"$test_tmp/tty-write.sh" </dev/null + grep -E "^SUDO install -m 664 -o $owner -g omarchy-browser-policy -T .+ $denied_dir/color.json$" "$elev_log" >/dev/null || + fail "with a controlling tty, colour write elevates through sudo" "$(cat "$elev_log")" + if grep -E '^PKEXEC ' "$elev_log" >/dev/null; then + fail "with a controlling tty, colour write does not call pkexec" "$(cat "$elev_log")" + fi + pass "with a controlling tty, colour write elevates through sudo" + else + pass "script(1) unavailable; skipping the controlling-tty elevation check" + fi +fi + +planted_dir=$test_tmp/planted +mkdir -p "$planted_dir/evil" +printf 'evil\n' >"$planted_dir/evil/f" +printf 'old\n' >"$planted_dir/color.json" +as_root() { unprivileged_as_root "$@"; } +browser_policy_setup_dir "$planted_dir" +[[ ! -e $planted_dir/evil ]] || fail "policy setup drops a non-empty non-root subdirectory" +[[ ! -e $planted_dir/color.json ]] || fail "policy setup drops a non-root color.json" +[[ -d $planted_dir ]] || fail "policy setup leaves the managed directory in place" +pass "policy setup drops non-root files and non-empty subdirectories" + +owned=$test_tmp/not-root +mkdir -p "$owned" +chmod 2775 "$owned" +BROWSER_POLICY_GROUP=$(id -gn) +if browser_policy_dir_hardened "$owned"; then + fail "a user-owned 2775 directory is not treated as hardened" +fi +BROWSER_POLICY_GROUP=omarchy-browser-policy +pass "a hardened directory must be root-owned" + +dist=$test_tmp/distribution +mkdir -p "$dist" +printf 'original\n' >"$test_tmp/firefox-pwn" +ln -s "$test_tmp/firefox-pwn" "$dist/policies.json" +as_root() { unprivileged_as_root "$@"; } +browser_policy_install_firefox_policies "$dist" || + fail "Firefox policy install replaces a planted policies.json symlink" +[[ -f $dist/policies.json && ! -L $dist/policies.json ]] || + fail "Firefox policy install unlinks a planted policies.json symlink instead of writing through it" +grep -Fxq 'original' "$test_tmp/firefox-pwn" || fail "Firefox policy install leaves the symlink target unchanged" +grep -q '"policies"' "$dist/policies.json" || fail "Firefox policy install writes the stock policies" +pass "Firefox policy install does not follow a planted policies.json symlink" + +dir_dist=$test_tmp/distribution-dir +mkdir -p "$dir_dist" +mkdir "$dir_dist/policies.json" +as_root() { unprivileged_as_root "$@"; } +if browser_policy_install_firefox_policies "$dir_dist" 2>/dev/null; then + fail "Firefox policy install refuses a planted policies.json directory" +fi +[[ -d $dir_dist/policies.json ]] || fail "Firefox policy install leaves a planted policies.json directory in place" +pass "Firefox policy install does not write into a planted policies.json directory" + +grant_log=$test_tmp/usermod.calls +as_root() { + if [[ $1 == "usermod" ]]; then + printf '%s\n' "$*" >>"$grant_log" + return 0 + fi + unprivileged_as_root "$@" +} +invoker=${USER:-$(id -un)} +: >"$grant_log" +SUDO_USER=$invoker +browser_policy_grant_user root +unset SUDO_USER +grep -qx -- "usermod -aG omarchy-browser-policy $invoker" "$grant_log" || + fail "granting as root uses SUDO_USER" "$(cat "$grant_log")" +: >"$grant_log" +OMARCHY_INSTALL_USER="" +browser_policy_grant_user "" +[[ ! -s $grant_log ]] || fail "an empty grant does not usermod" +pass "sudo install browser grants the invoking user, not root" + +grep -F 'exit "$failed"' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null || + fail "omarchy-theme-set-browser exits non-zero when a policy write fails" +pass "omarchy-theme-set-browser exits non-zero when a policy write fails" + +policy_files=( + "$ROOT/bin/omarchy-install-browser" + "$ROOT/bin/omarchy-provision-owner" + "$ROOT/bin/omarchy-theme-set-browser" + "$ROOT/bin/omarchy-upgrade-to-quattro" + "$ROOT/install/config/theme-system.sh" + "$ROOT/install/config/browser-policy.sh" + "$ROOT/install/helpers/browser-policy.sh" + "$ROOT/migrations/1787515927.sh" +) +if grep -nE 'chmod a\+rwx\b|chmod a\+rw\b|chmod a\+w\b|chmod o\+w|chmod ugo\+w|chmod 2777\b|chmod 0777\b|chmod 777\b|install -d -m 0?[27]?777' "${policy_files[@]}" >/dev/null; then + fail "browser policy setup is not world-writable" +fi +pass "browser policy setup is not world-writable" + +mapfile -t migrations < <(rg -l 'Stop world-writable Chromium and Firefox policy directories' "$ROOT/migrations") +(( ${#migrations[@]} == 1 )) || fail "exactly one migration locks existing policy directories" "${migrations[*]}" +grep -F 'browser_policy_dir_hardened' "${migrations[0]}" >/dev/null || + fail "the policy-directory migration no-ops a machine already repaired" +grep -F 'browser_policy_grant_user' "${migrations[0]}" >/dev/null || + fail "the policy-directory migration still grants the current user the group" +grep -F 'BROWSER_POLICY_FIREFOX_DIRS' "${migrations[0]}" >/dev/null || + fail "the policy-directory migration covers Firefox and Zen" +grep -F '/opt/zen-browser/distribution' "$ROOT/install/helpers/browser-policy.sh" >/dev/null || + fail "the shared helper names the Zen distribution directory" +pass "a migration locks existing policy directories" diff --git a/test/shell.d/default-apps-test.sh b/test/shell.d/default-apps-test.sh index aa07659d..00a9af55 100755 --- a/test/shell.d/default-apps-test.sh +++ b/test/shell.d/default-apps-test.sh @@ -61,11 +61,13 @@ if [[ $installer == "omarchy-install-browser" && ${OMARCHY_TEST_REAL_BROWSER_INS fi case $installer in -omarchy-pkg-add) +omarchy-pkg-add|omarchy-pkg-aur-add) package=$1 printf 'pkg:%s\n' "$package" >>"$OMARCHY_TEST_INSTALL_LOG" case $package in chromium) command=chromium ;; + firefox) command=firefox ;; + zen-browser-bin) command=zen-browser ;; cursor-bin) command=cursor ;; sublime-text-4) command=sublime_text ;; vim) command=vim ;; @@ -107,6 +109,7 @@ SH for installer in \ omarchy-pkg-add \ + omarchy-pkg-aur-add \ omarchy-install-browser \ omarchy-install-terminal \ omarchy-install-editor-vscode \ @@ -205,10 +208,14 @@ OMARCHY_TEST_REAL_BROWSER_INSTALL=true omarchy-default-browser --install chromiu [[ $(omarchy-default-browser) == "chromium" ]] || fail "Chromium becomes the default after its full installer succeeds" cmp -s "$ROOT/config/chromium-flags.conf" "$test_home/.config/chromium-flags.conf" || fail "Chromium browser installer copies the default flags" -grep -Fxq 'sudo:mkdir -p /etc/chromium/policies/managed' "$setup_log" || - fail "Chromium browser installer creates its policy directory" -grep -Fxq 'sudo:chmod a+rw /etc/chromium/policies/managed' "$setup_log" || - fail "Chromium browser installer makes its policy directory writable" +grep -Fxq 'sudo:groupadd --system --force omarchy-browser-policy' "$setup_log" || + fail "Chromium browser installer creates the browser-policy group" +grep -Fxq 'sudo:install -d -m 2775 -o root -g omarchy-browser-policy /etc/chromium/policies/managed' "$setup_log" || + fail "Chromium browser installer creates a group-writable managed policy directory" +grep -Fxq 'sudo:find /etc/chromium/policies/managed -mindepth 1 -maxdepth 1 ! -user root -exec rm -rf -- {} +' "$setup_log" || + fail "Chromium browser installer drops non-root files from its policy directory" +grep -Fxq "sudo:usermod -aG omarchy-browser-policy ${USER:-$(id -un)}" "$setup_log" || + fail "Chromium browser installer grants the installing user the browser-policy group" grep -Fxq 'omarchy-install-chromium-copy-url:' "$setup_log" || fail "Chromium browser installer registers the Copy URL host" grep -Fxq 'omarchy-install-chromium-ytdlp:' "$setup_log" || @@ -217,6 +224,36 @@ grep -Fxq 'omarchy-theme-set-browser:' "$setup_log" || fail "Chromium browser installer applies the current theme" pass "Chromium browser installer restores the complete Omarchy setup" +: >"$install_log" +: >"$setup_log" +rm -f "$installed_dir/firefox" +OMARCHY_TEST_REAL_BROWSER_INSTALL=true omarchy-default-browser --install firefox >/dev/null +[[ $(<"$install_log") == "pkg:firefox" ]] || fail "Firefox browser installer installs the package" +[[ $(omarchy-default-browser) == "firefox" ]] || fail "Firefox becomes the default after its full installer succeeds" +grep -Fxq 'sudo:install -d -m 0755 -o root -g root /usr/lib/firefox/distribution' "$setup_log" || + fail "Firefox browser installer creates its distribution directory" +grep -Fxq 'sudo:find /usr/lib/firefox/distribution -mindepth 1 -maxdepth 1 ! -user root -exec rm -rf -- {} +' "$setup_log" || + fail "Firefox browser installer drops non-root files from its distribution directory" +grep -Fxq "sudo:install -m 644 -o root -g root -T $ROOT/default/firefox/policies.json /usr/lib/firefox/distribution/policies.json" "$setup_log" || + fail "Firefox browser installer copies policies.json without following a destination symlink" +[[ -e $installed_dir/firefox ]] || fail "Firefox browser installer marks firefox installed" +pass "Firefox browser installer restores the complete Omarchy setup" + +: >"$install_log" +: >"$setup_log" +rm -f "$installed_dir/zen-browser" +OMARCHY_TEST_REAL_BROWSER_INSTALL=true omarchy-default-browser --install zen >/dev/null +[[ $(<"$install_log") == "pkg:zen-browser-bin" ]] || fail "Zen browser installer installs the package" +[[ $(omarchy-default-browser) == "zen" ]] || fail "Zen becomes the default after its full installer succeeds" +grep -Fxq 'sudo:install -d -m 0755 -o root -g root /opt/zen-browser/distribution' "$setup_log" || + fail "Zen browser installer creates its distribution directory" +grep -Fxq 'sudo:find /opt/zen-browser/distribution -mindepth 1 -maxdepth 1 ! -user root -exec rm -rf -- {} +' "$setup_log" || + fail "Zen browser installer drops non-root files from its distribution directory" +grep -Fxq "sudo:install -m 644 -o root -g root -T $ROOT/default/firefox/policies.json /opt/zen-browser/distribution/policies.json" "$setup_log" || + fail "Zen browser installer copies policies.json without following a destination symlink" +[[ -e $installed_dir/zen-browser ]] || fail "Zen browser installer marks zen-browser installed" +pass "Zen browser installer restores the complete Omarchy setup" + omarchy-default-browser zen rm -f "$installed_dir/chromium" if OMARCHY_TEST_REAL_BROWSER_INSTALL=true OMARCHY_TEST_INSTALL_FAIL=true \ diff --git a/test/shell.d/provisioning-groups-test.sh b/test/shell.d/provisioning-groups-test.sh index d0eac226..7b5c6964 100644 --- a/test/shell.d/provisioning-groups-test.sh +++ b/test/shell.d/provisioning-groups-test.sh @@ -27,16 +27,40 @@ cat >"$TMPDIR/bin/usermod" <>"$TMPDIR/usermod.calls" STUB -chmod +x "$TMPDIR/bin/getent" "$TMPDIR/bin/usermod" +cat >"$TMPDIR/bin/groupadd" <>"$TMPDIR/groupadd.calls" +STUB +cat >"$TMPDIR/bin/install" <>"$TMPDIR/install.calls" +STUB +cat >"$TMPDIR/bin/find" <>"$TMPDIR/find.calls" +STUB +cat >"$TMPDIR/bin/sudo" <>"$TMPDIR/sudo.calls" +exec "\$@" +STUB +chmod +x "$TMPDIR/bin"/{getent,usermod,groupadd,install,find,sudo} export PATH="$TMPDIR/bin:$PATH" +export OMARCHY_PATH="$ROOT" -# No install user (deferred-provisioning install): input recorded, usermod not called. +# No install user (deferred-provisioning install): groups recorded, usermod not called. OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/config/docker.sh" OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/hardware/input-group.sh" +OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/config/browser-policy.sh" [[ -f $OMARCHY_PROVISIONING_DIR/groups ]] || fail "groups file written without an install user" grep -qxF input "$OMARCHY_PROVISIONING_DIR/groups" || fail "input group recorded" +grep -qxF omarchy-browser-policy "$OMARCHY_PROVISIONING_DIR/groups" || fail "browser-policy group recorded" [[ ! -f $TMPDIR/usermod.calls ]] || fail "usermod not called without an install user" +grep -F -- '--system --force omarchy-browser-policy' "$TMPDIR/groupadd.calls" >/dev/null || + fail "browser-policy group is created as a system group" +grep -F -- '-d -m 2775 -o root -g omarchy-browser-policy /etc/chromium/policies/managed' "$TMPDIR/install.calls" >/dev/null || + fail "browser-policy directory is created group-writable" pass "deferred provisioning records groups without calling usermod" # The docker group is root-equivalent and must never be granted automatically. @@ -45,17 +69,24 @@ pass "docker group is not recorded at install" # Missing user (defensive): no usermod either. OMARCHY_INSTALL_USER=ghost bash -eE "$ROOT/install/hardware/input-group.sh" +OMARCHY_INSTALL_USER=ghost bash -eE "$ROOT/install/config/browser-policy.sh" [[ ! -f $TMPDIR/usermod.calls ]] || fail "usermod not called for a missing user" pass "missing install user defers group grants" # Re-running never duplicates entries. OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/hardware/input-group.sh" [[ $(grep -cxF input "$OMARCHY_PROVISIONING_DIR/groups") == 1 ]] || fail "input group recorded once" +OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/config/browser-policy.sh" +[[ $(grep -cxF omarchy-browser-policy "$OMARCHY_PROVISIONING_DIR/groups") == 1 ]] || + fail "browser-policy group recorded once" pass "group recording is idempotent" # Existing user: usermod applies the recorded groups, and docker is never among them. OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/config/docker.sh" OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/hardware/input-group.sh" +OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/config/browser-policy.sh" grep -qx -- "-aG input existing" "$TMPDIR/usermod.calls" || fail "usermod grants input to the install user" +grep -qx -- "-aG omarchy-browser-policy existing" "$TMPDIR/usermod.calls" || + fail "usermod grants browser-policy to the install user" ! grep -q -- "docker" "$TMPDIR/usermod.calls" || fail "usermod must not grant docker to the install user" -pass "existing install user gets input but never docker" +pass "existing install user gets input and browser-policy but never docker" diff --git a/test/shell.d/upgrade-to-quattro-test.sh b/test/shell.d/upgrade-to-quattro-test.sh index bf94605f..c38c17f7 100644 --- a/test/shell.d/upgrade-to-quattro-test.sh +++ b/test/shell.d/upgrade-to-quattro-test.sh @@ -67,6 +67,23 @@ grep -F 'OMARCHY_INSTALL_USER="$target_user"' "$upgrade_to_quattro" >/dev/null grep -F '"$apply_lock"' "$upgrade_to_quattro" >/dev/null pass "Omarchy 4 upgrade configures lock screen authentication for the target user" +grep -F 'install/helpers/browser-policy.sh' "$upgrade_to_quattro" >/dev/null || + fail "Omarchy 4 upgrade uses the shared browser-policy helper" +grep -F 'as_root test -f "$browser_policy_helper"' "$upgrade_to_quattro" >/dev/null || + fail "Omarchy 4 upgrade survives a packaged tree without the browser-policy helper" +grep -F 'browser_policy_setup_group' "$upgrade_to_quattro" >/dev/null || + fail "Omarchy 4 upgrade creates the browser-policy group" +grep -F 'browser_policy_setup_dir /etc/chromium/policies/managed' "$upgrade_to_quattro" >/dev/null || + fail "Omarchy 4 upgrade creates a group-writable Chromium policy directory" +grep -F 'BROWSER_POLICY_MANAGED_DIRS' "$upgrade_to_quattro" >/dev/null || + fail "Omarchy 4 upgrade hardens every Chromium-family policy directory" +grep -F 'run_as_user_omarchy omarchy-theme-set-browser' "$upgrade_to_quattro" >/dev/null || + fail "Omarchy 4 upgrade rewrites browser theme colour after a headless theme-set" +if grep -E 'install -d -m 0?[27]?777 /etc/.*/policies|chmod a\+rw' "$upgrade_to_quattro" >/dev/null; then + fail "Omarchy 4 upgrade does not create a world-writable Chromium policy directory" +fi +pass "Omarchy 4 upgrade locks the Chromium policy directory to the browser-policy group" + grep -F 'OMARCHY_UPGRADE_TO_QUATTRO_LIVE=1' "$upgrade_to_quattro" >/dev/null grep -F 'systemd-networkd.service' "$upgrade_to_quattro" >/dev/null grep -F 'systemd-networkd.socket' "$upgrade_to_quattro" >/dev/null From b0e6611c704e617a238d250ab62109047a8239ac Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Mon, 24 Aug 2026 02:01:24 -0600 Subject: [PATCH 18/41] Require root-owned Firefox policies before skipping repair The hardened gate only looked at the distribution directory. A regular policies.json planted under the old 777 mode would then be left in place if the directory later looked 755/root. --- install/helpers/browser-policy.sh | 16 +++++++++++++++- test/shell.d/browser-policy-dir-test.sh | 12 ++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/install/helpers/browser-policy.sh b/install/helpers/browser-policy.sh index 803df2c6..b383e325 100644 --- a/install/helpers/browser-policy.sh +++ b/install/helpers/browser-policy.sh @@ -145,13 +145,27 @@ browser_policy_write_color() { return 1 } +browser_policy_firefox_policy_file_ok() { + local file=$1 + local mode + local group_write + local other_write + + [[ -f $file && ! -L $file ]] || return 1 + [[ $(stat -c '%U' "$file") == "root" ]] || return 1 + mode=$(stat -c '%a' "$file") + group_write=$((8#${mode: -2:1})) + other_write=$((8#${mode: -1})) + (( (group_write & 2) == 0 && (other_write & 2) == 0 )) +} + browser_policy_firefox_hardened() { local dir=$1 [[ -d $dir ]] || return 1 [[ $(stat -c '%a' "$dir") == "755" ]] || return 1 [[ $(stat -c '%U' "$dir") == "root" ]] || return 1 - [[ -f $dir/policies.json && ! -L $dir/policies.json ]] || return 1 + browser_policy_firefox_policy_file_ok "$dir/policies.json" } browser_policy_install_firefox_policies() { diff --git a/test/shell.d/browser-policy-dir-test.sh b/test/shell.d/browser-policy-dir-test.sh index 34a0c3cd..c7b795fc 100755 --- a/test/shell.d/browser-policy-dir-test.sh +++ b/test/shell.d/browser-policy-dir-test.sh @@ -168,6 +168,18 @@ fi BROWSER_POLICY_GROUP=omarchy-browser-policy pass "a hardened directory must be root-owned" +fx_policy=$test_tmp/policies.json +printf '%s\n' '{"policies":{}}' >"$fx_policy" +chmod 644 "$fx_policy" +if browser_policy_firefox_policy_file_ok "$fx_policy"; then + fail "a user-owned policies.json is not treated as hardened" +fi +ln -sf "$fx_policy" "$test_tmp/policies-link.json" +if browser_policy_firefox_policy_file_ok "$test_tmp/policies-link.json"; then + fail "a policies.json symlink is not treated as hardened" +fi +pass "Firefox policy files must be root-owned regular files without group or other write" + dist=$test_tmp/distribution mkdir -p "$dist" printf 'original\n' >"$test_tmp/firefox-pwn" From 87dfa14c5645d37bc9df454450f91680ffe587dc Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Mon, 24 Aug 2026 10:58:33 -0600 Subject: [PATCH 19/41] Keep a trusted Firefox policies.json when repairing the directory A world-writable distribution dir failed the hardened check even when policies.json was already root-owned, and setup then overwrote it. --- migrations/1787515927.sh | 6 +++++- test/shell.d/browser-policy-dir-test.sh | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/migrations/1787515927.sh b/migrations/1787515927.sh index f15298bd..e16efd20 100644 --- a/migrations/1787515927.sh +++ b/migrations/1787515927.sh @@ -20,5 +20,9 @@ fi for dir in "${BROWSER_POLICY_FIREFOX_DIRS[@]}"; do [[ -d $dir ]] || continue browser_policy_firefox_hardened "$dir" && continue - browser_policy_setup_firefox_distribution "$dir" + as_root install -d -m 0755 -o root -g root "$dir" + browser_policy_purge_dir "$dir" + if ! browser_policy_firefox_policy_file_ok "$dir/policies.json"; then + browser_policy_install_firefox_policies "$dir" + fi done diff --git a/test/shell.d/browser-policy-dir-test.sh b/test/shell.d/browser-policy-dir-test.sh index c7b795fc..63591de1 100755 --- a/test/shell.d/browser-policy-dir-test.sh +++ b/test/shell.d/browser-policy-dir-test.sh @@ -251,6 +251,8 @@ grep -F 'browser_policy_grant_user' "${migrations[0]}" >/dev/null || fail "the policy-directory migration still grants the current user the group" grep -F 'BROWSER_POLICY_FIREFOX_DIRS' "${migrations[0]}" >/dev/null || fail "the policy-directory migration covers Firefox and Zen" +grep -F 'browser_policy_firefox_policy_file_ok' "${migrations[0]}" >/dev/null || + fail "the policy-directory migration keeps a trusted Firefox policies.json" grep -F '/opt/zen-browser/distribution' "$ROOT/install/helpers/browser-policy.sh" >/dev/null || fail "the shared helper names the Zen distribution directory" pass "a migration locks existing policy directories" From bebe19bc70696c916d4fa1d0fd39d480bfff99a0 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Tue, 25 Aug 2026 12:10:08 -0600 Subject: [PATCH 20/41] Harden browser policy parent directories and validate theme RGB install -d follows a planted ancestor symlink, and a writable parent can rename the managed leaf aside. chromium.theme is user-installed, so only a 0-255 RGB triple becomes a colour. --- bin/omarchy-theme-set-browser | 11 ++-- install/helpers/browser-policy.sh | 67 ++++++++++++++++++++ migrations/1787515927.sh | 4 +- test/shell.d/browser-policy-dir-test.sh | 83 +++++++++++++++++++++++++ test/shell.d/default-apps-test.sh | 4 ++ 5 files changed, 161 insertions(+), 8 deletions(-) diff --git a/bin/omarchy-theme-set-browser b/bin/omarchy-theme-set-browser index a22cb0d6..60fa3211 100755 --- a/bin/omarchy-theme-set-browser +++ b/bin/omarchy-theme-set-browser @@ -3,18 +3,15 @@ # omarchy:summary=Apply the current theme color to Chromium, Chrome, Edge, and Brave # omarchy:hidden=true +source "$OMARCHY_PATH/install/helpers/browser-policy.sh" + CHROMIUM_THEME=$HOME/.local/state/omarchy/current/theme/chromium.theme +THEME_HEX_COLOR=$BROWSER_POLICY_DEFAULT_COLOR if [[ -f $CHROMIUM_THEME ]]; then - THEME_RGB_COLOR=$(<$CHROMIUM_THEME) - THEME_HEX_COLOR=$(printf '#%02x%02x%02x' ${THEME_RGB_COLOR//,/ }) -else - # Use a default, neutral grey if theme doesn't have a color - THEME_HEX_COLOR="#1c2027" + THEME_HEX_COLOR=$(browser_policy_theme_hex "$(<$CHROMIUM_THEME)") fi -source "$OMARCHY_PATH/install/helpers/browser-policy.sh" - set_browser_policy() { browser_policy_write_color "$1" "$THEME_HEX_COLOR" } diff --git a/install/helpers/browser-policy.sh b/install/helpers/browser-policy.sh index b383e325..8826243b 100644 --- a/install/helpers/browser-policy.sh +++ b/install/helpers/browser-policy.sh @@ -13,11 +13,26 @@ BROWSER_POLICY_MANAGED_DIRS=( /etc/brave/policies/managed ) +# Ancestors of the managed dirs, shortest first. A writable or attacker-owned +# parent can rename the leaf aside; install -d follows a planted symlink. +BROWSER_POLICY_PARENT_DIRS=( + /etc/chromium + /etc/chromium/policies + /etc/opt/chrome + /etc/opt/chrome/policies + /etc/opt/edge + /etc/opt/edge/policies + /etc/brave + /etc/brave/policies +) + BROWSER_POLICY_FIREFOX_DIRS=( /usr/lib/firefox/distribution /opt/zen-browser/distribution ) +BROWSER_POLICY_DEFAULT_COLOR="#1c2027" + browser_policy_setup_group() { local provisioning_dir="${OMARCHY_PROVISIONING_DIR:-/var/lib/omarchy/provisioning}" @@ -59,13 +74,65 @@ browser_policy_dir_hardened() { [[ $(stat -c '%G' "$dir") == $BROWSER_POLICY_GROUP ]] || return 1 } +browser_policy_parent_hardened() { + local dir=$1 + + [[ -d $dir && ! -L $dir ]] || return 1 + [[ $(stat -c '%a' "$dir") == "755" ]] || return 1 + [[ $(stat -c '%U' "$dir") == "root" ]] || return 1 +} + +browser_policy_parents_hardened() { + local dir=$1 + local parent + + for parent in "${BROWSER_POLICY_PARENT_DIRS[@]}"; do + [[ $dir == "$parent"/* ]] || continue + [[ -e $parent || -L $parent ]] || continue + browser_policy_parent_hardened "$parent" || return 1 + done +} + +browser_policy_setup_parent() { + local dir=$1 + + if [[ -L $dir || ( -e $dir && ! -d $dir ) ]]; then + as_root rm -rf -- "$dir" + fi + as_root install -d -m 0755 -o root -g root "$dir" +} + +browser_policy_setup_parents_for() { + local dir=$1 + local parent + + for parent in "${BROWSER_POLICY_PARENT_DIRS[@]}"; do + [[ $dir == "$parent"/* ]] || continue + browser_policy_setup_parent "$parent" + done +} + browser_policy_setup_dir() { local dir=$1 + browser_policy_setup_parents_for "$dir" as_root install -d -m 2775 -o root -g "$BROWSER_POLICY_GROUP" "$dir" browser_policy_purge_dir "$dir" } +# Themes are user-installed. Accept only three 0-255 components. +browser_policy_theme_hex() { + local theme_rgb=$1 + + if [[ $theme_rgb =~ ^[[:space:]]*([0-9]{1,3})[[:space:]]*,[[:space:]]*([0-9]{1,3})[[:space:]]*,[[:space:]]*([0-9]{1,3})[[:space:]]*$ ]] && + (( 10#${BASH_REMATCH[1]} < 256 && 10#${BASH_REMATCH[2]} < 256 && 10#${BASH_REMATCH[3]} < 256 )); then + printf '#%02x%02x%02x' "$((10#${BASH_REMATCH[1]}))" "$((10#${BASH_REMATCH[2]}))" "$((10#${BASH_REMATCH[3]}))" + return + fi + + printf '%s' "$BROWSER_POLICY_DEFAULT_COLOR" +} + browser_policy_file_owner() { local user diff --git a/migrations/1787515927.sh b/migrations/1787515927.sh index e16efd20..787418e7 100644 --- a/migrations/1787515927.sh +++ b/migrations/1787515927.sh @@ -8,7 +8,9 @@ browser_policy_grant_user "${USER:-$(id -un)}" repaired=0 for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do [[ -d $dir ]] || continue - browser_policy_dir_hardened "$dir" && continue + if browser_policy_dir_hardened "$dir" && browser_policy_parents_hardened "$dir"; then + continue + fi browser_policy_setup_dir "$dir" repaired=1 done diff --git a/test/shell.d/browser-policy-dir-test.sh b/test/shell.d/browser-policy-dir-test.sh index 63591de1..7092a515 100755 --- a/test/shell.d/browser-policy-dir-test.sh +++ b/test/shell.d/browser-policy-dir-test.sh @@ -168,6 +168,87 @@ fi BROWSER_POLICY_GROUP=omarchy-browser-policy pass "a hardened directory must be root-owned" +saved_parent_dirs=("${BROWSER_POLICY_PARENT_DIRS[@]}") +parent_root=$test_tmp/parents +mkdir -p "$parent_root/etc/chromium/policies/managed/keep" +printf 'keep\n' >"$parent_root/etc/chromium/policies/managed/keep/x" +chmod 0777 "$parent_root/etc/chromium" "$parent_root/etc/chromium/policies" +chmod 2775 "$parent_root/etc/chromium/policies/managed" +BROWSER_POLICY_PARENT_DIRS=( + "$parent_root/etc/chromium" + "$parent_root/etc/chromium/policies" +) +as_root() { unprivileged_as_root "$@"; } +if browser_policy_parents_hardened "$parent_root/etc/chromium/policies/managed"; then + fail "a world-writable policy parent is not treated as hardened" +fi +browser_policy_setup_parents_for "$parent_root/etc/chromium/policies/managed" +mode=$(stat -c '%a' "$parent_root/etc/chromium") +[[ $mode == "755" ]] || fail "setup tightens /etc/chromium" "mode=$mode" +mode=$(stat -c '%a' "$parent_root/etc/chromium/policies") +[[ $mode == "755" ]] || fail "setup tightens /etc/chromium/policies" "mode=$mode" +[[ -d $parent_root/etc/chromium/policies/managed/keep ]] || + fail "parent repair does not purge the managed directory" +pass "policy parent directories are tightened to 0755 without purging the leaf" + +symlink_root=$test_tmp/symlink-parents +mkdir -p "$symlink_root/etc" "$symlink_root/attacker/policies/managed" +printf 'planted\n' >"$symlink_root/attacker/policies/managed/evil.json" +ln -s "$symlink_root/attacker" "$symlink_root/etc/chromium" +BROWSER_POLICY_PARENT_DIRS=( + "$symlink_root/etc/chromium" + "$symlink_root/etc/chromium/policies" +) +as_root() { unprivileged_as_root "$@"; } +browser_policy_setup_dir "$symlink_root/etc/chromium/policies/managed" +[[ ! -L $symlink_root/etc/chromium ]] || fail "setup replaces a planted /etc/chromium symlink" +[[ -d $symlink_root/etc/chromium && ! -L $symlink_root/etc/chromium ]] || + fail "setup recreates /etc/chromium as a real directory" +[[ -d $symlink_root/etc/chromium/policies && ! -L $symlink_root/etc/chromium/policies ]] || + fail "setup recreates /etc/chromium/policies as a real directory" +[[ ! -e $symlink_root/etc/chromium/policies/managed/evil.json ]] || + fail "setup does not keep policy that lived behind a planted parent symlink" +grep -Fxq 'planted' "$symlink_root/attacker/policies/managed/evil.json" || + fail "replacing a parent symlink does not delete the symlink target" +BROWSER_POLICY_PARENT_DIRS=("${saved_parent_dirs[@]}") +pass "policy setup does not follow a planted parent symlink" + +[[ $(browser_policy_theme_hex "242,240,229") == "#f2f0e5" ]] || + fail "theme colour converts an RGB triple to hex" +[[ $(browser_policy_theme_hex $'14,31,41\n') == "#0e1f29" ]] || + fail "theme colour accepts a trailing newline" +[[ $(browser_policy_theme_hex "0,0,0") == "#000000" ]] || + fail "theme colour pads single-digit components" +[[ $(browser_policy_theme_hex " 12 , 11 , 12 ") == "#0c0b0c" ]] || + fail "theme colour tolerates surrounding whitespace" +[[ $(browser_policy_theme_hex "08,09,10") == "#08090a" ]] || + fail "theme colour treats leading zeros as decimal" +for malformed in "" "not,a,color" "1,2" "1,2,3,4" "256,0,0" "999,999,999" "-1,0,0" \ + "1,2,3;id" '1,2,$(id)' "0x10,0,0" "1,2,3 4,5,6"; do + [[ $(browser_policy_theme_hex "$malformed") == "#1c2027" ]] || + fail "theme colour falls back to the neutral grey for '$malformed'" +done +pass "theme colour is six hex digits or the stock grey" + +for theme in "$ROOT"/themes/*/chromium.theme; do + [[ -f $theme ]] || continue + rgb=$(<$theme) + hex=$(browser_policy_theme_hex "$rgb") + [[ $hex =~ ^#[0-9a-f]{6}$ ]] || + fail "shipped $(basename "$(dirname "$theme")") chromium.theme parses as hex" "got: $hex from $(printf %q "$rgb")" + if [[ $hex == "#1c2027" && ! $rgb =~ ^[[:space:]]*28[[:space:]]*,[[:space:]]*32[[:space:]]*,[[:space:]]*39[[:space:]]*$ ]]; then + fail "shipped $(basename "$(dirname "$theme")") chromium.theme is a valid RGB triple" "got: $(printf %q "$rgb")" + fi +done +pass "shipped chromium.theme files parse as RGB triples" + +grep -F 'browser_policy_theme_hex' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null || + fail "omarchy-theme-set-browser parses chromium.theme through browser_policy_theme_hex" +if grep -E 'printf.*THEME_RGB_COLOR' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null; then + fail "omarchy-theme-set-browser does not hand unvetted theme words to printf" +fi +pass "omarchy-theme-set-browser validates the theme colour" + fx_policy=$test_tmp/policies.json printf '%s\n' '{"policies":{}}' >"$fx_policy" chmod 644 "$fx_policy" @@ -247,6 +328,8 @@ mapfile -t migrations < <(rg -l 'Stop world-writable Chromium and Firefox policy (( ${#migrations[@]} == 1 )) || fail "exactly one migration locks existing policy directories" "${migrations[*]}" grep -F 'browser_policy_dir_hardened' "${migrations[0]}" >/dev/null || fail "the policy-directory migration no-ops a machine already repaired" +grep -F 'browser_policy_parents_hardened' "${migrations[0]}" >/dev/null || + fail "the policy-directory migration repairs a world-writable parent of a hardened leaf" grep -F 'browser_policy_grant_user' "${migrations[0]}" >/dev/null || fail "the policy-directory migration still grants the current user the group" grep -F 'BROWSER_POLICY_FIREFOX_DIRS' "${migrations[0]}" >/dev/null || diff --git a/test/shell.d/default-apps-test.sh b/test/shell.d/default-apps-test.sh index 00a9af55..c88c2253 100755 --- a/test/shell.d/default-apps-test.sh +++ b/test/shell.d/default-apps-test.sh @@ -210,6 +210,10 @@ cmp -s "$ROOT/config/chromium-flags.conf" "$test_home/.config/chromium-flags.con fail "Chromium browser installer copies the default flags" grep -Fxq 'sudo:groupadd --system --force omarchy-browser-policy' "$setup_log" || fail "Chromium browser installer creates the browser-policy group" +grep -Fxq 'sudo:install -d -m 0755 -o root -g root /etc/chromium' "$setup_log" || + fail "Chromium browser installer creates a root-owned Chromium policy parent" +grep -Fxq 'sudo:install -d -m 0755 -o root -g root /etc/chromium/policies' "$setup_log" || + fail "Chromium browser installer creates a root-owned Chromium policies parent" grep -Fxq 'sudo:install -d -m 2775 -o root -g omarchy-browser-policy /etc/chromium/policies/managed' "$setup_log" || fail "Chromium browser installer creates a group-writable managed policy directory" grep -Fxq 'sudo:find /etc/chromium/policies/managed -mindepth 1 -maxdepth 1 ! -user root -exec rm -rf -- {} +' "$setup_log" || From 44a186afe48891f5304cd14fafa3462093cfede9 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Tue, 25 Aug 2026 12:14:52 -0600 Subject: [PATCH 21/41] Replace planted policy directory symlinks instead of following them install -d follows a managed or distribution symlink and would chmod the target. Unlink those paths first, and treat a dangling symlink as a directory the migration still has to repair. --- install/helpers/browser-policy.sh | 9 +++-- migrations/1787515927.sh | 6 ++-- test/shell.d/browser-policy-dir-test.sh | 44 +++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/install/helpers/browser-policy.sh b/install/helpers/browser-policy.sh index 8826243b..687edce2 100644 --- a/install/helpers/browser-policy.sh +++ b/install/helpers/browser-policy.sh @@ -68,7 +68,7 @@ browser_policy_purge_dir() { browser_policy_dir_hardened() { local dir=$1 - [[ -d $dir ]] || return 1 + [[ -d $dir && ! -L $dir ]] || return 1 [[ $(stat -c '%a' "$dir") == "2775" ]] || return 1 [[ $(stat -c '%U' "$dir") == "root" ]] || return 1 [[ $(stat -c '%G' "$dir") == $BROWSER_POLICY_GROUP ]] || return 1 @@ -116,6 +116,9 @@ browser_policy_setup_dir() { local dir=$1 browser_policy_setup_parents_for "$dir" + if [[ -L $dir || ( -e $dir && ! -d $dir ) ]]; then + as_root rm -rf -- "$dir" + fi as_root install -d -m 2775 -o root -g "$BROWSER_POLICY_GROUP" "$dir" browser_policy_purge_dir "$dir" } @@ -229,7 +232,7 @@ browser_policy_firefox_policy_file_ok() { browser_policy_firefox_hardened() { local dir=$1 - [[ -d $dir ]] || return 1 + [[ -d $dir && ! -L $dir ]] || return 1 [[ $(stat -c '%a' "$dir") == "755" ]] || return 1 [[ $(stat -c '%U' "$dir") == "root" ]] || return 1 browser_policy_firefox_policy_file_ok "$dir/policies.json" @@ -246,7 +249,7 @@ browser_policy_setup_firefox_distribution() { local distribution_dir=$1 local policies=${2:-$OMARCHY_PATH/default/firefox/policies.json} - as_root install -d -m 0755 -o root -g root "$distribution_dir" + browser_policy_setup_parent "$distribution_dir" browser_policy_purge_dir "$distribution_dir" browser_policy_install_firefox_policies "$distribution_dir" "$policies" } diff --git a/migrations/1787515927.sh b/migrations/1787515927.sh index 787418e7..113daf34 100644 --- a/migrations/1787515927.sh +++ b/migrations/1787515927.sh @@ -7,7 +7,7 @@ browser_policy_grant_user "${USER:-$(id -un)}" repaired=0 for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do - [[ -d $dir ]] || continue + [[ -d $dir || -L $dir ]] || continue if browser_policy_dir_hardened "$dir" && browser_policy_parents_hardened "$dir"; then continue fi @@ -20,9 +20,9 @@ if (( repaired )); then fi for dir in "${BROWSER_POLICY_FIREFOX_DIRS[@]}"; do - [[ -d $dir ]] || continue + [[ -d $dir || -L $dir ]] || continue browser_policy_firefox_hardened "$dir" && continue - as_root install -d -m 0755 -o root -g root "$dir" + browser_policy_setup_parent "$dir" browser_policy_purge_dir "$dir" if ! browser_policy_firefox_policy_file_ok "$dir/policies.json"; then browser_policy_install_firefox_policies "$dir" diff --git a/test/shell.d/browser-policy-dir-test.sh b/test/shell.d/browser-policy-dir-test.sh index 7092a515..b06f174f 100755 --- a/test/shell.d/browser-policy-dir-test.sh +++ b/test/shell.d/browser-policy-dir-test.sh @@ -213,6 +213,50 @@ grep -Fxq 'planted' "$symlink_root/attacker/policies/managed/evil.json" || BROWSER_POLICY_PARENT_DIRS=("${saved_parent_dirs[@]}") pass "policy setup does not follow a planted parent symlink" +leaf_link_root=$test_tmp/leaf-link +mkdir -p "$leaf_link_root/etc/chromium/policies" "$leaf_link_root/attacker" +printf 'planted\n' >"$leaf_link_root/attacker/evil.json" +chmod 755 "$leaf_link_root/etc/chromium" "$leaf_link_root/etc/chromium/policies" +ln -s "$leaf_link_root/attacker" "$leaf_link_root/etc/chromium/policies/managed" +BROWSER_POLICY_PARENT_DIRS=( + "$leaf_link_root/etc/chromium" + "$leaf_link_root/etc/chromium/policies" +) +as_root() { unprivileged_as_root "$@"; } +if browser_policy_dir_hardened "$leaf_link_root/etc/chromium/policies/managed"; then + fail "a planted managed symlink is not treated as hardened" +fi +browser_policy_setup_dir "$leaf_link_root/etc/chromium/policies/managed" +[[ ! -L $leaf_link_root/etc/chromium/policies/managed ]] || + fail "setup replaces a planted managed symlink" +[[ -d $leaf_link_root/etc/chromium/policies/managed && ! -L $leaf_link_root/etc/chromium/policies/managed ]] || + fail "setup recreates managed as a real directory" +[[ ! -e $leaf_link_root/etc/chromium/policies/managed/evil.json ]] || + fail "setup does not keep policy that lived behind a planted managed symlink" +grep -Fxq 'planted' "$leaf_link_root/attacker/evil.json" || + fail "replacing a managed symlink does not delete the symlink target" +BROWSER_POLICY_PARENT_DIRS=("${saved_parent_dirs[@]}") +pass "policy setup does not follow a planted managed symlink" + +fx_link_root=$test_tmp/fx-link +mkdir -p "$fx_link_root/attacker" "$fx_link_root/opt" +printf 'planted\n' >"$fx_link_root/attacker/policies.json" +ln -s "$fx_link_root/attacker" "$fx_link_root/opt/zen" +as_root() { unprivileged_as_root "$@"; } +if browser_policy_firefox_hardened "$fx_link_root/opt/zen"; then + fail "a planted Firefox distribution symlink is not treated as hardened" +fi +browser_policy_setup_firefox_distribution "$fx_link_root/opt/zen" || + fail "Firefox setup replaces a planted distribution symlink" +[[ ! -L $fx_link_root/opt/zen ]] || fail "Firefox setup unlinks a planted distribution symlink" +[[ -d $fx_link_root/opt/zen && ! -L $fx_link_root/opt/zen ]] || + fail "Firefox setup recreates the distribution directory" +[[ -f $fx_link_root/opt/zen/policies.json && ! -L $fx_link_root/opt/zen/policies.json ]] || + fail "Firefox setup writes policies.json into the recreated directory" +grep -Fxq 'planted' "$fx_link_root/attacker/policies.json" || + fail "replacing a Firefox distribution symlink does not delete the symlink target" +pass "Firefox setup does not follow a planted distribution symlink" + [[ $(browser_policy_theme_hex "242,240,229") == "#f2f0e5" ]] || fail "theme colour converts an RGB triple to hex" [[ $(browser_policy_theme_hex $'14,31,41\n') == "#0e1f29" ]] || From bafc9a1000b503856e6fd642b87791cc3ddae5cf Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Tue, 25 Aug 2026 13:01:01 -0600 Subject: [PATCH 22/41] Write browser theme colour through a passwordless helper Managed policy dirs are enterprise trust roots, so they stay 0755 root:root. The menu path takes root for that one write through a sudoers glob of six hex digits, the same shape as omarchy-dns, and falls back to pkexec where the grant is not installed. Drop omarchy-browser-policy; a group member could plant any JSON, not just a colour. --- bin/omarchy-install-browser | 2 - bin/omarchy-provision-owner | 5 +- bin/omarchy-theme-set-browser | 13 +- bin/omarchy-theme-set-browser-policy | 118 +++++++++++++ bin/omarchy-upgrade-to-quattro | 5 +- etc/sudoers.d/omarchy-theme-browser | 8 + install/config/browser-policy.sh | 1 - install/helpers/browser-policy.sh | 117 ++----------- migrations/1787515927.sh | 11 +- test/shell.d/browser-policy-dir-test.sh | 132 +++----------- test/shell.d/browser-policy-sudoers-test.sh | 184 ++++++++++++++++++++ test/shell.d/default-apps-test.sh | 11 +- test/shell.d/provisioning-groups-test.sh | 19 +- test/shell.d/upgrade-to-quattro-test.sh | 11 +- 14 files changed, 383 insertions(+), 254 deletions(-) create mode 100755 bin/omarchy-theme-set-browser-policy create mode 100644 etc/sudoers.d/omarchy-theme-browser create mode 100755 test/shell.d/browser-policy-sudoers-test.sh diff --git a/bin/omarchy-install-browser b/bin/omarchy-install-browser index d808c805..4593bc72 100755 --- a/bin/omarchy-install-browser +++ b/bin/omarchy-install-browser @@ -9,8 +9,6 @@ set -e source "$OMARCHY_PATH/install/helpers/browser-policy.sh" setup_chromium_policy_directory() { - browser_policy_setup_group - browser_policy_grant_user "${USER:-$(id -un)}" browser_policy_setup_dir "$1" } diff --git a/bin/omarchy-provision-owner b/bin/omarchy-provision-owner index 7c208eb9..0db99f2e 100755 --- a/bin/omarchy-provision-owner +++ b/bin/omarchy-provision-owner @@ -744,11 +744,8 @@ create_user() { chmod 440 /etc/sudoers.d/00-omarchy-wheel source "$OMARCHY_PATH/install/helpers/browser-policy.sh" - OMARCHY_INSTALL_USER=$username - OMARCHY_PROVISIONING_DIR=$PROVISIONING_DIR - browser_policy_setup_group for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do - [[ -d $dir ]] || continue + [[ -d $dir || -L $dir ]] || continue browser_policy_setup_dir "$dir" done } diff --git a/bin/omarchy-theme-set-browser b/bin/omarchy-theme-set-browser index 60fa3211..612e586f 100755 --- a/bin/omarchy-theme-set-browser +++ b/bin/omarchy-theme-set-browser @@ -12,10 +12,6 @@ if [[ -f $CHROMIUM_THEME ]]; then THEME_HEX_COLOR=$(browser_policy_theme_hex "$(<$CHROMIUM_THEME)") fi -set_browser_policy() { - browser_policy_write_color "$1" "$THEME_HEX_COLOR" -} - refresh_running_browser() { local process="$1" local command="$2" @@ -27,16 +23,11 @@ refresh_running_browser() { } failed=0 -set_browser_policy /etc/chromium/policies/managed || failed=1 +omarchy-theme-set-browser-policy "${THEME_HEX_COLOR#\#}" || failed=1 + refresh_running_browser chromium chromium - -set_browser_policy /etc/opt/chrome/policies/managed || failed=1 refresh_running_browser chrome google-chrome-stable || refresh_running_browser chrome google-chrome - -set_browser_policy /etc/opt/edge/policies/managed || failed=1 refresh_running_browser msedge microsoft-edge-stable - -set_browser_policy /etc/brave/policies/managed || failed=1 refresh_running_browser brave brave # Match on the binary path: the running process is named plain "brave", and a # bare -f brave-origin pattern would also match the installer's own terminal. diff --git a/bin/omarchy-theme-set-browser-policy b/bin/omarchy-theme-set-browser-policy new file mode 100755 index 00000000..6f628f9b --- /dev/null +++ b/bin/omarchy-theme-set-browser-policy @@ -0,0 +1,118 @@ +#!/bin/bash + +# omarchy:summary=Write the current theme color into the browser policy directories +# omarchy:args= +# omarchy:hidden=true + +set -euo pipefail + +# Whenever this runs as root — invoked directly through the passwordless +# sudoers rule, or re-execed by require_root below — sudo's secure_path decides +# where a bare helper resolves, and a dev link (etc/sudoers.d/omarchy-dev-path) +# prepends a user-writable checkout bin/ to it. Every helper this script calls +# by bare name (printf's builtin aside: install, mktemp, rm) is a system tool, +# never an omarchy-* command, so pin PATH to trusted system directories and keep +# root from resolving one out of that checkout. The unprivileged wrapper phase +# keeps the caller's PATH so it can still find sudo/pkexec. +if (( EUID == 0 )); then + export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/sbin:/bin:/sbin +fi + +# Enterprise policy trust roots. The list is fixed here rather than taken from +# the caller: the caller chooses a color, never a path. +POLICY_DIRS=( + /etc/chromium/policies/managed + /etc/opt/chrome/policies/managed + /etc/opt/edge/policies/managed + /etc/brave/policies/managed +) + +# The path etc/sudoers.d/omarchy-theme-browser names. The privileged half always +# runs from there rather than from whichever copy was invoked, so the rule +# matches even where $OMARCHY_PATH points at a checkout. +PACKAGED_PATH=/usr/bin/omarchy-theme-set-browser-policy + +usage() { + echo "Usage: omarchy-theme-set-browser-policy " >&2 +} + +if (( $# != 1 )); then + usage + exit 1 +fi + +color="$1" + +# Six lowercase hex digits is the whole of what this accepts. The leading "#" +# is added when the JSON is written rather than passed in: "#" opens a comment +# in sudoers, and keeping it out of argv lets the sudoers rule spell the +# argument as a plain six-character glob. +if [[ ! $color =~ ^[0-9a-f]{6}$ ]]; then + echo "omarchy-theme-set-browser-policy: expected six lowercase hex digits, got '$color'" >&2 + exit 1 +fi + +# True when sudo would run this exact command without stopping for a password. +# `sudo -l` on its own reports whether a command is permitted, which the blanket +# %wheel rule answers yes to for everything; the long listing prints the matched +# entry's tags, so !authenticate is the grant in +# etc/sudoers.d/omarchy-theme-browser and nothing else. Listing runs nothing +# and, under -n, prompts for nothing. +sudo_grants_passwordless() { + sudo -n -l -l "$PACKAGED_PATH" "$@" 2>/dev/null | grep -q '!authenticate' +} + +require_root() { + if (( EUID == 0 )); then + return + elif [[ -t 0 ]] || sudo_grants_passwordless "$@"; then + exec sudo "$PACKAGED_PATH" "$@" + else + exec pkexec "$PACKAGED_PATH" "$@" + fi +} + +require_root "$color" + +failed=0 +staged="" +cleanup() { + [[ -n $staged ]] && rm -f "$staged" +} +trap cleanup EXIT + +for policy_dir in "${POLICY_DIRS[@]}"; do + # Only browsers Omarchy has installed have a policy directory. Creating one + # here would hand a browser a managed-policy root it does not otherwise have. + [[ -d $policy_dir && ! -L $policy_dir ]] || continue + + dest=$policy_dir/color.json + staged=$(mktemp) || { + failed=1 + continue + } + printf '{"BrowserThemeColor": "#%s", "BrowserColorScheme": "device"}\n' "$color" >"$staged" + + if [[ -L $dest || -d $dest ]]; then + if ! rm -rf -- "$dest"; then + rm -f "$staged" + staged="" + echo "omarchy-theme-set-browser-policy: cannot replace $dest" >&2 + failed=1 + continue + fi + fi + + if ! install -m 0644 -o root -g root -T "$staged" "$dest"; then + rm -f "$staged" + staged="" + echo "omarchy-theme-set-browser-policy: cannot write $dest" >&2 + failed=1 + continue + fi + + rm -f "$staged" + staged="" +done + +exit "$failed" diff --git a/bin/omarchy-upgrade-to-quattro b/bin/omarchy-upgrade-to-quattro index 62ef21b9..32ea6465 100755 --- a/bin/omarchy-upgrade-to-quattro +++ b/bin/omarchy-upgrade-to-quattro @@ -1316,14 +1316,13 @@ apply_system_transition() { if ! as_root test -f "$browser_policy_helper"; then warn "$browser_policy_helper is unavailable; Chromium policy directories were not hardened." else - as_root env OMARCHY_PATH=/usr/share/omarchy OMARCHY_INSTALL_USER="$target_user" \ + as_root env OMARCHY_PATH=/usr/share/omarchy \ bash -euo pipefail -c ' source "$OMARCHY_PATH/install/helpers/browser-policy.sh" - browser_policy_setup_group browser_policy_setup_dir /etc/chromium/policies/managed for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do [[ $dir == "/etc/chromium/policies/managed" ]] && continue - [[ -d $dir ]] || continue + [[ -d $dir || -L $dir ]] || continue browser_policy_setup_dir "$dir" done ' diff --git a/etc/sudoers.d/omarchy-theme-browser b/etc/sudoers.d/omarchy-theme-browser new file mode 100644 index 00000000..853d2412 --- /dev/null +++ b/etc/sudoers.d/omarchy-theme-browser @@ -0,0 +1,8 @@ +# Theme switching is a menu action with no terminal to carry a password prompt, +# and it repaints the browser accent on every switch, so this one write must not +# stop for a password. The argument is spelled out as six hex digits rather than +# a wildcard: the grant covers a color and nothing else, and sudoers matches a +# command's arguments exactly, so it cannot be stretched into extra ones. The +# helper revalidates the same shape, since the terminal path does not come +# through this rule. +%wheel ALL=(root) NOPASSWD: /usr/bin/omarchy-theme-set-browser-policy [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f] diff --git a/install/config/browser-policy.sh b/install/config/browser-policy.sh index a02de2f3..fd802c66 100644 --- a/install/config/browser-policy.sh +++ b/install/config/browser-policy.sh @@ -1,3 +1,2 @@ source "$OMARCHY_PATH/install/helpers/browser-policy.sh" -browser_policy_setup_group browser_policy_setup_dir /etc/chromium/policies/managed diff --git a/install/helpers/browser-policy.sh b/install/helpers/browser-policy.sh index 687edce2..c2c93d8e 100644 --- a/install/helpers/browser-policy.sh +++ b/install/helpers/browser-policy.sh @@ -1,11 +1,9 @@ -# Chromium-family machine policy is mandatory for every profile. A dedicated -# group at 2775 lets every Omarchy user write color.json and every other uid -# read; other-write stays off. Setgid so new files inherit the group. +# Chromium-family machine policy is mandatory for every profile. Directories +# stay 0755 root:root; omarchy-theme-set-browser-policy is the privileged +# write for color.json. source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/as-root.sh" -BROWSER_POLICY_GROUP=omarchy-browser-policy - BROWSER_POLICY_MANAGED_DIRS=( /etc/chromium/policies/managed /etc/opt/chrome/policies/managed @@ -33,47 +31,12 @@ BROWSER_POLICY_FIREFOX_DIRS=( BROWSER_POLICY_DEFAULT_COLOR="#1c2027" -browser_policy_setup_group() { - local provisioning_dir="${OMARCHY_PROVISIONING_DIR:-/var/lib/omarchy/provisioning}" - - as_root groupadd --system --force "$BROWSER_POLICY_GROUP" - as_root mkdir -p "$provisioning_dir" - if ! grep -qxF "$BROWSER_POLICY_GROUP" "$provisioning_dir/groups" 2>/dev/null; then - printf '%s\n' "$BROWSER_POLICY_GROUP" | as_root tee -a "$provisioning_dir/groups" >/dev/null - fi - - if [[ -n ${OMARCHY_INSTALL_USER:-} ]] && getent passwd "$OMARCHY_INSTALL_USER" >/dev/null; then - as_root usermod -aG "$BROWSER_POLICY_GROUP" "$OMARCHY_INSTALL_USER" - fi -} - -browser_policy_grant_user() { - local user=${1:-} - - if [[ -z $user || $user == "root" ]]; then - user=${SUDO_USER:-} - fi - - [[ -n $user && $user != "root" ]] || return 0 - getent passwd "$user" >/dev/null || return 0 - as_root usermod -aG "$BROWSER_POLICY_GROUP" "$user" -} - browser_policy_purge_dir() { local dir=$1 as_root find "$dir" -mindepth 1 -maxdepth 1 ! -user root -exec rm -rf -- {} + } -browser_policy_dir_hardened() { - local dir=$1 - - [[ -d $dir && ! -L $dir ]] || return 1 - [[ $(stat -c '%a' "$dir") == "2775" ]] || return 1 - [[ $(stat -c '%U' "$dir") == "root" ]] || return 1 - [[ $(stat -c '%G' "$dir") == $BROWSER_POLICY_GROUP ]] || return 1 -} - browser_policy_parent_hardened() { local dir=$1 @@ -82,6 +45,10 @@ browser_policy_parent_hardened() { [[ $(stat -c '%U' "$dir") == "root" ]] || return 1 } +browser_policy_dir_hardened() { + browser_policy_parent_hardened "$1" +} + browser_policy_parents_hardened() { local dir=$1 local parent @@ -116,10 +83,7 @@ browser_policy_setup_dir() { local dir=$1 browser_policy_setup_parents_for "$dir" - if [[ -L $dir || ( -e $dir && ! -d $dir ) ]]; then - as_root rm -rf -- "$dir" - fi - as_root install -d -m 2775 -o root -g "$BROWSER_POLICY_GROUP" "$dir" + browser_policy_setup_parent "$dir" browser_policy_purge_dir "$dir" } @@ -136,82 +100,31 @@ browser_policy_theme_hex() { printf '%s' "$BROWSER_POLICY_DEFAULT_COLOR" } -browser_policy_file_owner() { - local user - - if [[ -n ${OMARCHY_INSTALL_USER:-} && $OMARCHY_INSTALL_USER != "root" ]]; then - printf '%s\n' "$OMARCHY_INSTALL_USER" - return - fi - if [[ -n ${SUDO_USER:-} && $SUDO_USER != "root" ]]; then - printf '%s\n' "$SUDO_USER" - return - fi - if [[ -n ${PKEXEC_UID:-} ]]; then - user=$(getent passwd "$PKEXEC_UID" | cut -d: -f1) - if [[ -n $user && $user != "root" ]]; then - printf '%s\n' "$user" - return - fi - fi - user=${USER:-$(id -un)} - if [[ $user != "root" ]]; then - printf '%s\n' "$user" - fi -} - -# sudo when this process has a controlling terminal (fd 0 is /dev/null under -# `bash -lc cmd &`, but /dev/tty still works). pkexec when it does not. -browser_policy_elevate() { - if (( EUID == 0 )); then - "$@" - elif { exec 3/dev/null; then - exec 3<&- - sudo "$@" - else - pkexec "$@" - fi -} - -browser_policy_write_color() { +browser_policy_install_color() { local policy_dir=$1 local hex=$2 local dest=$policy_dir/color.json - local payload local tmp - local owner - [[ -d $policy_dir ]] || return 0 + [[ -d $policy_dir && ! -L $policy_dir ]] || return 0 + [[ $hex =~ ^#[0-9a-f]{6}$ ]] || return 1 - payload=$(printf '{"BrowserThemeColor": "%s", "BrowserColorScheme": "device"}\n' "$hex") tmp=$(mktemp) || return 1 - printf '%s' "$payload" >"$tmp" + printf '{"BrowserThemeColor": "%s", "BrowserColorScheme": "device"}\n' "$hex" >"$tmp" - # A planted symlink or directory must not be written through or into. if [[ -L $dest || -d $dest ]]; then if ! rm -rf -- "$dest" 2>/dev/null; then - if ! browser_policy_elevate rm -rf -- "$dest"; then - rm -f "$tmp" - echo "omarchy-theme-set-browser: cannot replace $dest (need group $BROWSER_POLICY_GROUP)" >&2 - return 1 - fi + rm -f "$tmp" + return 1 fi fi - if install -m 664 -T "$tmp" "$dest" 2>/dev/null; then - rm -f "$tmp" - return 0 - fi - - owner=$(browser_policy_file_owner) - [[ -n $owner ]] || owner=root - if browser_policy_elevate install -m 664 -o "$owner" -g "$BROWSER_POLICY_GROUP" -T "$tmp" "$dest"; then + if install -m 0644 -T "$tmp" "$dest" 2>/dev/null; then rm -f "$tmp" return 0 fi rm -f "$tmp" - echo "omarchy-theme-set-browser: cannot write $dest (need group $BROWSER_POLICY_GROUP)" >&2 return 1 } diff --git a/migrations/1787515927.sh b/migrations/1787515927.sh index 113daf34..f1f33869 100644 --- a/migrations/1787515927.sh +++ b/migrations/1787515927.sh @@ -2,15 +2,9 @@ echo "Stop world-writable Chromium and Firefox policy directories" source "$OMARCHY_PATH/install/helpers/browser-policy.sh" -browser_policy_setup_group -browser_policy_grant_user "${USER:-$(id -un)}" - repaired=0 for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do [[ -d $dir || -L $dir ]] || continue - if browser_policy_dir_hardened "$dir" && browser_policy_parents_hardened "$dir"; then - continue - fi browser_policy_setup_dir "$dir" repaired=1 done @@ -21,7 +15,10 @@ fi for dir in "${BROWSER_POLICY_FIREFOX_DIRS[@]}"; do [[ -d $dir || -L $dir ]] || continue - browser_policy_firefox_hardened "$dir" && continue + if browser_policy_firefox_hardened "$dir"; then + browser_policy_purge_dir "$dir" + continue + fi browser_policy_setup_parent "$dir" browser_policy_purge_dir "$dir" if ! browser_policy_firefox_policy_file_ok "$dir/policies.json"; then diff --git a/test/shell.d/browser-policy-dir-test.sh b/test/shell.d/browser-policy-dir-test.sh index b06f174f..20b8067c 100755 --- a/test/shell.d/browser-policy-dir-test.sh +++ b/test/shell.d/browser-policy-dir-test.sh @@ -7,24 +7,6 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" test_tmp=$(mktemp -d) trap 'rm -rf "$test_tmp"' EXIT -mock_bin=$test_tmp/bin -mkdir -p "$mock_bin" -elev_log=$test_tmp/elev.log -cat >"$mock_bin/sudo" <>"$elev_log" -[[ \${OMARCHY_TEST_SUDO_FAIL:-} == 1 ]] && exit 1 -exit 0 -SH -cat >"$mock_bin/pkexec" <>"$elev_log" -[[ \${OMARCHY_TEST_SUDO_FAIL:-} == 1 ]] && exit 1 -exit 0 -SH -chmod +x "$mock_bin/sudo" "$mock_bin/pkexec" -export PATH="$mock_bin:$PATH" -: >"$elev_log" export OMARCHY_PATH="$ROOT" export OMARCHY_PROVISIONING_DIR="$test_tmp/provisioning" @@ -55,20 +37,20 @@ unprivileged_as_root() { write_dir=$test_tmp/writable mkdir -p "$write_dir" -browser_policy_write_color "$write_dir" "#aabbcc" || +browser_policy_install_color "$write_dir" "#aabbcc" || fail "theme colour writes into a writable policy directory" grep -F '"BrowserThemeColor": "#aabbcc"' "$write_dir/color.json" >/dev/null || fail "theme colour writes BrowserThemeColor" mode=$(stat -c '%a' "$write_dir/color.json") -[[ $mode == "664" ]] || fail "theme colour creates a group-writable policy file" "mode=$mode" -pass "theme colour writes a group-writable color.json" +[[ $mode == "644" ]] || fail "theme colour creates a root-mode policy file" "mode=$mode" +pass "theme colour writes a 0644 color.json" if (( EUID == 0 )); then pass "running as root; skipping the mktemp-failure check" else chmod u+w "$write_dir" export TMPDIR=$test_tmp/missing-tmp - if browser_policy_write_color "$write_dir" "#dead00" 2>/dev/null; then + if browser_policy_install_color "$write_dir" "#dead00" 2>/dev/null; then fail "theme colour fails when mktemp cannot create a file" fi unset TMPDIR @@ -80,7 +62,7 @@ fi printf 'original\n' >"$test_tmp/pwn" rm -f "$write_dir/color.json" ln -s "$test_tmp/pwn" "$write_dir/color.json" -browser_policy_write_color "$write_dir" "#aabbcc" || +browser_policy_install_color "$write_dir" "#aabbcc" || fail "theme colour replaces a planted color.json symlink" [[ -f $write_dir/color.json && ! -L $write_dir/color.json ]] || fail "theme colour unlinks a planted color.json symlink instead of writing through it" @@ -90,62 +72,25 @@ pass "theme colour does not follow a planted color.json symlink" plant_write=$test_tmp/plant-dir mkdir -p "$plant_write/color.json/nested" printf 'inside\n' >"$plant_write/color.json/nested/x" -browser_policy_write_color "$plant_write" "#aabbcc" || +browser_policy_install_color "$plant_write" "#aabbcc" || fail "theme colour replaces a planted color.json directory" [[ -f $plant_write/color.json && ! -d $plant_write/color.json ]] || fail "theme colour does not write into a planted color.json directory" pass "theme colour does not write into a planted color.json directory" missing_dir=$test_tmp/missing -browser_policy_write_color "$missing_dir" "#aabbcc" || +browser_policy_install_color "$missing_dir" "#aabbcc" || fail "theme colour skips a policy directory that does not exist" [[ ! -e $missing_dir ]] || fail "theme colour does not create a missing policy directory" pass "theme colour skips a missing policy directory" -if (( EUID == 0 )); then - pass "running as root; skipping elevation checks" -else - denied_dir=$test_tmp/denied - mkdir -p "$denied_dir" - chmod a-w "$denied_dir" - owner=${USER:-$(id -un)} - : >"$elev_log" - browser_policy_write_color "$denied_dir" "#aabbcc" || - fail "elevated install reports success from pkexec" - grep -E "^PKEXEC install -m 664 -o $owner -g omarchy-browser-policy -T .+ $denied_dir/color.json$" "$elev_log" >/dev/null || - fail "without a controlling tty, colour write elevates through pkexec as the owner" "$(cat "$elev_log")" - if grep -E '^SUDO ' "$elev_log" >/dev/null; then - fail "without a controlling tty, colour write does not call sudo" "$(cat "$elev_log")" - fi - pass "without a controlling tty, colour write elevates through pkexec" - - : >"$elev_log" - export OMARCHY_TEST_SUDO_FAIL=1 - if browser_policy_write_color "$denied_dir" "#aabbcc" 2>"$test_tmp/write.err"; then - fail "theme colour fails when the policy directory is not writable" - fi - unset OMARCHY_TEST_SUDO_FAIL - grep -F 'omarchy-browser-policy' "$test_tmp/write.err" >/dev/null || - fail "theme colour names the group when the write is denied" - pass "theme colour reports a denied policy write" - - if command -v script >/dev/null; then - : >"$elev_log" - cat >"$test_tmp/tty-write.sh" </dev/null - grep -E "^SUDO install -m 664 -o $owner -g omarchy-browser-policy -T .+ $denied_dir/color.json$" "$elev_log" >/dev/null || - fail "with a controlling tty, colour write elevates through sudo" "$(cat "$elev_log")" - if grep -E '^PKEXEC ' "$elev_log" >/dev/null; then - fail "with a controlling tty, colour write does not call pkexec" "$(cat "$elev_log")" - fi - pass "with a controlling tty, colour write elevates through sudo" - else - pass "script(1) unavailable; skipping the controlling-tty elevation check" - fi +if browser_policy_install_color "$write_dir" "aabbcc" 2>/dev/null; then + fail "theme colour rejects hex without a leading #" fi +if browser_policy_install_color "$write_dir" "#AABBCC" 2>/dev/null; then + fail "theme colour rejects uppercase hex" +fi +pass "theme colour accepts only # plus six lowercase hex digits" planted_dir=$test_tmp/planted mkdir -p "$planted_dir/evil" @@ -156,16 +101,16 @@ browser_policy_setup_dir "$planted_dir" [[ ! -e $planted_dir/evil ]] || fail "policy setup drops a non-empty non-root subdirectory" [[ ! -e $planted_dir/color.json ]] || fail "policy setup drops a non-root color.json" [[ -d $planted_dir ]] || fail "policy setup leaves the managed directory in place" +mode=$(stat -c '%a' "$planted_dir") +[[ $mode == "755" ]] || fail "policy setup leaves the managed directory 0755" "mode=$mode" pass "policy setup drops non-root files and non-empty subdirectories" owned=$test_tmp/not-root mkdir -p "$owned" -chmod 2775 "$owned" -BROWSER_POLICY_GROUP=$(id -gn) +chmod 755 "$owned" if browser_policy_dir_hardened "$owned"; then - fail "a user-owned 2775 directory is not treated as hardened" + fail "a user-owned 0755 directory is not treated as hardened" fi -BROWSER_POLICY_GROUP=omarchy-browser-policy pass "a hardened directory must be root-owned" saved_parent_dirs=("${BROWSER_POLICY_PARENT_DIRS[@]}") @@ -173,7 +118,7 @@ parent_root=$test_tmp/parents mkdir -p "$parent_root/etc/chromium/policies/managed/keep" printf 'keep\n' >"$parent_root/etc/chromium/policies/managed/keep/x" chmod 0777 "$parent_root/etc/chromium" "$parent_root/etc/chromium/policies" -chmod 2775 "$parent_root/etc/chromium/policies/managed" +chmod 755 "$parent_root/etc/chromium/policies/managed" BROWSER_POLICY_PARENT_DIRS=( "$parent_root/etc/chromium" "$parent_root/etc/chromium/policies" @@ -270,7 +215,7 @@ pass "Firefox setup does not follow a planted distribution symlink" for malformed in "" "not,a,color" "1,2" "1,2,3,4" "256,0,0" "999,999,999" "-1,0,0" \ "1,2,3;id" '1,2,$(id)' "0x10,0,0" "1,2,3 4,5,6"; do [[ $(browser_policy_theme_hex "$malformed") == "#1c2027" ]] || - fail "theme colour falls back to the neutral grey for '$malformed'" + fail "theme colour falls back to the stock grey for '$malformed'" done pass "theme colour is six hex digits or the stock grey" @@ -288,6 +233,8 @@ pass "shipped chromium.theme files parse as RGB triples" grep -F 'browser_policy_theme_hex' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null || fail "omarchy-theme-set-browser parses chromium.theme through browser_policy_theme_hex" +grep -F 'omarchy-theme-set-browser-policy' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null || + fail "omarchy-theme-set-browser writes colour through omarchy-theme-set-browser-policy" if grep -E 'printf.*THEME_RGB_COLOR' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null; then fail "omarchy-theme-set-browser does not hand unvetted theme words to printf" fi @@ -328,27 +275,6 @@ fi [[ -d $dir_dist/policies.json ]] || fail "Firefox policy install leaves a planted policies.json directory in place" pass "Firefox policy install does not write into a planted policies.json directory" -grant_log=$test_tmp/usermod.calls -as_root() { - if [[ $1 == "usermod" ]]; then - printf '%s\n' "$*" >>"$grant_log" - return 0 - fi - unprivileged_as_root "$@" -} -invoker=${USER:-$(id -un)} -: >"$grant_log" -SUDO_USER=$invoker -browser_policy_grant_user root -unset SUDO_USER -grep -qx -- "usermod -aG omarchy-browser-policy $invoker" "$grant_log" || - fail "granting as root uses SUDO_USER" "$(cat "$grant_log")" -: >"$grant_log" -OMARCHY_INSTALL_USER="" -browser_policy_grant_user "" -[[ ! -s $grant_log ]] || fail "an empty grant does not usermod" -pass "sudo install browser grants the invoking user, not root" - grep -F 'exit "$failed"' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null || fail "omarchy-theme-set-browser exits non-zero when a policy write fails" pass "omarchy-theme-set-browser exits non-zero when a policy write fails" @@ -357,25 +283,25 @@ policy_files=( "$ROOT/bin/omarchy-install-browser" "$ROOT/bin/omarchy-provision-owner" "$ROOT/bin/omarchy-theme-set-browser" + "$ROOT/bin/omarchy-theme-set-browser-policy" "$ROOT/bin/omarchy-upgrade-to-quattro" "$ROOT/install/config/theme-system.sh" "$ROOT/install/config/browser-policy.sh" "$ROOT/install/helpers/browser-policy.sh" "$ROOT/migrations/1787515927.sh" ) -if grep -nE 'chmod a\+rwx\b|chmod a\+rw\b|chmod a\+w\b|chmod o\+w|chmod ugo\+w|chmod 2777\b|chmod 0777\b|chmod 777\b|install -d -m 0?[27]?777' "${policy_files[@]}" >/dev/null; then - fail "browser policy setup is not world-writable" +if grep -nE 'chmod a\+rwx\b|chmod a\+rw\b|chmod a\+w\b|chmod o\+w|chmod ugo\+w|chmod 2775\b|chmod 2777\b|chmod 0777\b|chmod 777\b|install -d -m 0?[27]?777|omarchy-browser-policy' "${policy_files[@]}" >/dev/null; then + fail "browser policy setup is not world-writable and does not use omarchy-browser-policy" fi pass "browser policy setup is not world-writable" mapfile -t migrations < <(rg -l 'Stop world-writable Chromium and Firefox policy directories' "$ROOT/migrations") (( ${#migrations[@]} == 1 )) || fail "exactly one migration locks existing policy directories" "${migrations[*]}" -grep -F 'browser_policy_dir_hardened' "${migrations[0]}" >/dev/null || - fail "the policy-directory migration no-ops a machine already repaired" -grep -F 'browser_policy_parents_hardened' "${migrations[0]}" >/dev/null || - fail "the policy-directory migration repairs a world-writable parent of a hardened leaf" -grep -F 'browser_policy_grant_user' "${migrations[0]}" >/dev/null || - fail "the policy-directory migration still grants the current user the group" +grep -F 'browser_policy_setup_dir' "${migrations[0]}" >/dev/null || + fail "the policy-directory migration repairs managed directories" +if grep -F 'browser_policy_grant_user' "${migrations[0]}" >/dev/null; then + fail "the policy-directory migration does not grant a browser-policy group" +fi grep -F 'BROWSER_POLICY_FIREFOX_DIRS' "${migrations[0]}" >/dev/null || fail "the policy-directory migration covers Firefox and Zen" grep -F 'browser_policy_firefox_policy_file_ok' "${migrations[0]}" >/dev/null || diff --git a/test/shell.d/browser-policy-sudoers-test.sh b/test/shell.d/browser-policy-sudoers-test.sh new file mode 100755 index 00000000..2af8ea2b --- /dev/null +++ b/test/shell.d/browser-policy-sudoers-test.sh @@ -0,0 +1,184 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +helper="$ROOT/bin/omarchy-theme-set-browser-policy" +setter="$ROOT/bin/omarchy-theme-set-browser" +sudoers_file="$ROOT/etc/sudoers.d/omarchy-theme-browser" +rule='%wheel ALL=(root) NOPASSWD: /usr/bin/omarchy-theme-set-browser-policy [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]' + +# Exactly one rule, matched whole. Dropping the argument -- which sudoers reads +# as "any arguments" -- or widening the glob to `*` would let the grant carry +# something other than a color while leaving this line looking right. +rules=$(grep -vE '^[[:space:]]*(#|$)' "$sudoers_file") +[[ $rules == "$rule" ]] || + fail "browser policy sudoers file carries exactly the six-hex-digit rule and nothing else" "got: $rules" + +if command -v visudo >/dev/null; then + visudo -cf "$sudoers_file" >/dev/null || fail "browser policy sudoers rule parses" +fi + +grep -Fx 'PACKAGED_PATH=/usr/bin/omarchy-theme-set-browser-policy' "$helper" >/dev/null || + fail "omarchy-theme-set-browser-policy elevates the path the sudoers rule names" + +grep -E 'sudo -n -l -l' "$helper" >/dev/null || + fail "omarchy-theme-set-browser-policy reads the grant from the long sudo listing" + +grep -Eq '^\s*export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin' "$helper" || + fail "omarchy-theme-set-browser-policy pins PATH to trusted system directories when it holds root" +gated=$(grep -A1 -E '^if \(\( EUID == 0 \)\); then$' "$helper" || true) +[[ $gated == *"export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin"* ]] || + fail "omarchy-theme-set-browser-policy gates the trusted-PATH pin on holding root" + +pass "browser policy sudoers rule is scoped to a single color argument" + +for dir in /etc/chromium/policies/managed /etc/opt/chrome/policies/managed \ + /etc/opt/edge/policies/managed /etc/brave/policies/managed; do + grep -Fx " $dir" "$helper" >/dev/null || + fail "omarchy-theme-set-browser-policy names $dir in its fixed policy directory list" +done + +policy_dir_count=$(sed -n '/^POLICY_DIRS=(/,/^)/p' "$helper" | grep -c '^ /') +((policy_dir_count == 4)) || + fail "omarchy-theme-set-browser-policy writes only the four known policy directories" \ + "got: $policy_dir_count" + +grep -F 'install -m 0644 -o root -g root -T' "$helper" >/dev/null || + fail "omarchy-theme-set-browser-policy installs color.json with install -T" +if grep -E 'mv -f' "$helper" >/dev/null; then + fail "omarchy-theme-set-browser-policy does not mv into a planted color.json directory" +fi + +pass "browser policy helper writes a fixed set of policy directories" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +stub_bin="$test_tmp/bin" +mkdir -p "$stub_bin" + +cat >"$stub_bin/pkexec" <<'SH' +#!/bin/bash +printf 'pkexec %s\n' "$*" >"$ELEVATION_LOG" +SH +chmod +x "$stub_bin/pkexec" + +# STUB_GRANTED empty stands for an install whose omarchy-settings predates the +# sudoers file. The default is granted, matching a current Omarchy. +cat >"$stub_bin/sudo" <<'SH' +#!/bin/bash +if [[ $1 == -n && $2 == -l ]]; then + if [[ ${STUB_GRANTED-granted} == "granted" ]]; then + echo " Options: !authenticate" + else + echo " Matched: ${!#}" + fi + exit 0 +fi +printf 'sudo %s\n' "$*" >"$ELEVATION_LOG" +SH +chmod +x "$stub_bin/sudo" + +if ((EUID == 0)); then + pass "running as root; skipping the elevation checks, which would rewrite this machine's browser policy" +else + elevation_for() { + : >"$test_tmp/elevation" + ELEVATION_LOG="$test_tmp/elevation" \ + PATH="$stub_bin:$PATH" \ + bash "$helper" "$@" /dev/null 2>&1 || true + cat "$test_tmp/elevation" + } + + elevation=$(elevation_for 1c2027) + [[ $elevation == "sudo /usr/bin/omarchy-theme-set-browser-policy 1c2027" ]] || + fail "omarchy-theme-set-browser-policy takes the passwordless sudo grant without a terminal" \ + "got: $elevation" + + dev_linked=$(OMARCHY_PATH="$test_tmp/checkout" elevation_for 1c2027) + [[ $dev_linked == "sudo /usr/bin/omarchy-theme-set-browser-policy 1c2027" ]] || + fail "omarchy-theme-set-browser-policy elevates the system install wherever OMARCHY_PATH points" \ + "got: $dev_linked" + + pass "browser policy helper elevates a valid color through the sudo grant" + + ungranted=$(STUB_GRANTED="" elevation_for 1c2027) + [[ $ungranted == "pkexec /usr/bin/omarchy-theme-set-browser-policy 1c2027" ]] || + fail "omarchy-theme-set-browser-policy falls back to polkit where the grant does not reach" \ + "got: $ungranted" + + pass "browser policy helper falls back to polkit wherever the grant does not reach" + + for bad in "" "1C2027" "abc12" "abc1234" "1c202g" "../../etc/passwd" "1c2027 1c2027" \ + '$(id)' "1c2027;id" "#1c2027"; do + if PATH="$stub_bin:$PATH" ELEVATION_LOG="$test_tmp/elevation" \ + bash "$helper" "$bad" /dev/null 2>&1; then + fail "omarchy-theme-set-browser-policy rejects '$bad'" + fi + + rejected=$(elevation_for "$bad") + [[ -z $rejected ]] || + fail "omarchy-theme-set-browser-policy rejects '$bad' before elevating" "got: $rejected" + done + + if PATH="$stub_bin:$PATH" bash "$helper" 1c2027 ffffff /dev/null 2>&1; then + fail "omarchy-theme-set-browser-policy rejects more than one argument" + fi + + pass "browser policy helper accepts nothing but six lowercase hex digits" +fi + +setter_bin="$test_tmp/setter-bin" +mkdir -p "$setter_bin" + +cat >"$setter_bin/omarchy-theme-set-browser-policy" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >"$COLOR_LOG" +SH +chmod +x "$setter_bin/omarchy-theme-set-browser-policy" + +cat >"$setter_bin/omarchy-cmd-present" <<'SH' +#!/bin/bash +exit 1 +SH +chmod +x "$setter_bin/omarchy-cmd-present" + +setter_home="$test_tmp/home" +theme_dir="$setter_home/.local/state/omarchy/current/theme" +mkdir -p "$theme_dir" + +color_for_theme() { + : >"$test_tmp/color" + if [[ $# -gt 0 ]]; then + printf '%s' "$1" >"$theme_dir/chromium.theme" + else + rm -f "$theme_dir/chromium.theme" + fi + + HOME="$setter_home" COLOR_LOG="$test_tmp/color" PATH="$setter_bin:$stub_bin:$PATH" \ + OMARCHY_PATH="$ROOT" bash "$setter" /dev/null 2>&1 || true + cat "$test_tmp/color" +} + +[[ $(color_for_theme "242,240,229") == "f2f0e5" ]] || + fail "omarchy-theme-set-browser converts an RGB triple to six hex digits" +[[ $(color_for_theme $'14,31,41\n') == "0e1f29" ]] || + fail "omarchy-theme-set-browser accepts a trailing newline" +[[ $(color_for_theme "0,0,0") == "000000" ]] || + fail "omarchy-theme-set-browser pads single-digit components" +[[ $(color_for_theme " 12 , 11 , 12 ") == "0c0b0c" ]] || + fail "omarchy-theme-set-browser tolerates surrounding whitespace" + +for malformed in "" "not,a,color" "1,2" "1,2,3,4" "256,0,0" "999,999,999" "-1,0,0" \ + "1,2,3;id" '1,2,$(id)' "0x10,0,0" "1,2,3 4,5,6"; do + color=$(color_for_theme "$malformed") + [[ $color == "1c2027" ]] || + fail "omarchy-theme-set-browser falls back to the stock colour for '$malformed'" "got: $color" +done + +[[ $(color_for_theme) == "1c2027" ]] || + fail "omarchy-theme-set-browser falls back to the stock colour with no theme file" + +pass "browser theme color is derived as six hex digits or falls back to the stock grey" diff --git a/test/shell.d/default-apps-test.sh b/test/shell.d/default-apps-test.sh index c88c2253..8151c250 100755 --- a/test/shell.d/default-apps-test.sh +++ b/test/shell.d/default-apps-test.sh @@ -208,18 +208,17 @@ OMARCHY_TEST_REAL_BROWSER_INSTALL=true omarchy-default-browser --install chromiu [[ $(omarchy-default-browser) == "chromium" ]] || fail "Chromium becomes the default after its full installer succeeds" cmp -s "$ROOT/config/chromium-flags.conf" "$test_home/.config/chromium-flags.conf" || fail "Chromium browser installer copies the default flags" -grep -Fxq 'sudo:groupadd --system --force omarchy-browser-policy' "$setup_log" || - fail "Chromium browser installer creates the browser-policy group" grep -Fxq 'sudo:install -d -m 0755 -o root -g root /etc/chromium' "$setup_log" || fail "Chromium browser installer creates a root-owned Chromium policy parent" grep -Fxq 'sudo:install -d -m 0755 -o root -g root /etc/chromium/policies' "$setup_log" || fail "Chromium browser installer creates a root-owned Chromium policies parent" -grep -Fxq 'sudo:install -d -m 2775 -o root -g omarchy-browser-policy /etc/chromium/policies/managed' "$setup_log" || - fail "Chromium browser installer creates a group-writable managed policy directory" +grep -Fxq 'sudo:install -d -m 0755 -o root -g root /etc/chromium/policies/managed' "$setup_log" || + fail "Chromium browser installer creates a root-owned managed policy directory" grep -Fxq 'sudo:find /etc/chromium/policies/managed -mindepth 1 -maxdepth 1 ! -user root -exec rm -rf -- {} +' "$setup_log" || fail "Chromium browser installer drops non-root files from its policy directory" -grep -Fxq "sudo:usermod -aG omarchy-browser-policy ${USER:-$(id -un)}" "$setup_log" || - fail "Chromium browser installer grants the installing user the browser-policy group" +if grep -E 'groupadd|usermod|omarchy-browser-policy' "$setup_log" >/dev/null; then + fail "Chromium browser installer does not create a browser-policy group" "$(cat "$setup_log")" +fi grep -Fxq 'omarchy-install-chromium-copy-url:' "$setup_log" || fail "Chromium browser installer registers the Copy URL host" grep -Fxq 'omarchy-install-chromium-ytdlp:' "$setup_log" || diff --git a/test/shell.d/provisioning-groups-test.sh b/test/shell.d/provisioning-groups-test.sh index 7b5c6964..5a5fc516 100644 --- a/test/shell.d/provisioning-groups-test.sh +++ b/test/shell.d/provisioning-groups-test.sh @@ -55,12 +55,13 @@ OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/config/browser-policy.sh" [[ -f $OMARCHY_PROVISIONING_DIR/groups ]] || fail "groups file written without an install user" grep -qxF input "$OMARCHY_PROVISIONING_DIR/groups" || fail "input group recorded" -grep -qxF omarchy-browser-policy "$OMARCHY_PROVISIONING_DIR/groups" || fail "browser-policy group recorded" +! grep -qxF omarchy-browser-policy "$OMARCHY_PROVISIONING_DIR/groups" || + fail "browser-policy group must not be recorded" [[ ! -f $TMPDIR/usermod.calls ]] || fail "usermod not called without an install user" -grep -F -- '--system --force omarchy-browser-policy' "$TMPDIR/groupadd.calls" >/dev/null || - fail "browser-policy group is created as a system group" -grep -F -- '-d -m 2775 -o root -g omarchy-browser-policy /etc/chromium/policies/managed' "$TMPDIR/install.calls" >/dev/null || - fail "browser-policy directory is created group-writable" +[[ ! -f $TMPDIR/groupadd.calls ]] || ! grep -F omarchy-browser-policy "$TMPDIR/groupadd.calls" >/dev/null || + fail "browser-policy group is not created" +grep -F -- '-d -m 0755 -o root -g root /etc/chromium/policies/managed' "$TMPDIR/install.calls" >/dev/null || + fail "browser-policy directory is created root-owned" pass "deferred provisioning records groups without calling usermod" # The docker group is root-equivalent and must never be granted automatically. @@ -77,8 +78,6 @@ pass "missing install user defers group grants" OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/hardware/input-group.sh" [[ $(grep -cxF input "$OMARCHY_PROVISIONING_DIR/groups") == 1 ]] || fail "input group recorded once" OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/config/browser-policy.sh" -[[ $(grep -cxF omarchy-browser-policy "$OMARCHY_PROVISIONING_DIR/groups") == 1 ]] || - fail "browser-policy group recorded once" pass "group recording is idempotent" # Existing user: usermod applies the recorded groups, and docker is never among them. @@ -86,7 +85,7 @@ OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/config/docker.sh" OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/hardware/input-group.sh" OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/config/browser-policy.sh" grep -qx -- "-aG input existing" "$TMPDIR/usermod.calls" || fail "usermod grants input to the install user" -grep -qx -- "-aG omarchy-browser-policy existing" "$TMPDIR/usermod.calls" || - fail "usermod grants browser-policy to the install user" +! grep -q -- "omarchy-browser-policy" "$TMPDIR/usermod.calls" || + fail "usermod must not grant browser-policy to the install user" ! grep -q -- "docker" "$TMPDIR/usermod.calls" || fail "usermod must not grant docker to the install user" -pass "existing install user gets input and browser-policy but never docker" +pass "existing install user gets input but never docker or browser-policy" diff --git a/test/shell.d/upgrade-to-quattro-test.sh b/test/shell.d/upgrade-to-quattro-test.sh index c38c17f7..60e8abff 100644 --- a/test/shell.d/upgrade-to-quattro-test.sh +++ b/test/shell.d/upgrade-to-quattro-test.sh @@ -71,18 +71,19 @@ grep -F 'install/helpers/browser-policy.sh' "$upgrade_to_quattro" >/dev/null || fail "Omarchy 4 upgrade uses the shared browser-policy helper" grep -F 'as_root test -f "$browser_policy_helper"' "$upgrade_to_quattro" >/dev/null || fail "Omarchy 4 upgrade survives a packaged tree without the browser-policy helper" -grep -F 'browser_policy_setup_group' "$upgrade_to_quattro" >/dev/null || - fail "Omarchy 4 upgrade creates the browser-policy group" +if grep -F 'browser_policy_setup_group' "$upgrade_to_quattro" >/dev/null; then + fail "Omarchy 4 upgrade does not create a browser-policy group" +fi grep -F 'browser_policy_setup_dir /etc/chromium/policies/managed' "$upgrade_to_quattro" >/dev/null || - fail "Omarchy 4 upgrade creates a group-writable Chromium policy directory" + fail "Omarchy 4 upgrade creates a root-owned Chromium policy directory" grep -F 'BROWSER_POLICY_MANAGED_DIRS' "$upgrade_to_quattro" >/dev/null || fail "Omarchy 4 upgrade hardens every Chromium-family policy directory" grep -F 'run_as_user_omarchy omarchy-theme-set-browser' "$upgrade_to_quattro" >/dev/null || fail "Omarchy 4 upgrade rewrites browser theme colour after a headless theme-set" -if grep -E 'install -d -m 0?[27]?777 /etc/.*/policies|chmod a\+rw' "$upgrade_to_quattro" >/dev/null; then +if grep -E 'install -d -m 0?[27]?777 /etc/.*/policies|chmod a\+rw|2775' "$upgrade_to_quattro" >/dev/null; then fail "Omarchy 4 upgrade does not create a world-writable Chromium policy directory" fi -pass "Omarchy 4 upgrade locks the Chromium policy directory to the browser-policy group" +pass "Omarchy 4 upgrade locks the Chromium policy directory to root" grep -F 'OMARCHY_UPGRADE_TO_QUATTRO_LIVE=1' "$upgrade_to_quattro" >/dev/null grep -F 'systemd-networkd.service' "$upgrade_to_quattro" >/dev/null From 45749c5b68cbe3ef41eac75baf5e8afc729896c8 Mon Sep 17 00:00:00 2001 From: David Helmus Date: Tue, 25 Aug 2026 21:09:45 +0200 Subject: [PATCH 23/41] test: cover Python shim bypass Place a synthetic python3 shim first in PATH so CI catches any regression that resolves REAL_PYTHON through user-managed shims. --- test/shell.d/copy-url-shortcut-migration-test.sh | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/test/shell.d/copy-url-shortcut-migration-test.sh b/test/shell.d/copy-url-shortcut-migration-test.sh index dd25420e..934a57ee 100644 --- a/test/shell.d/copy-url-shortcut-migration-test.sh +++ b/test/shell.d/copy-url-shortcut-migration-test.sh @@ -28,8 +28,18 @@ write_stale_preferences() { stub_bin="$test_dir/bin" mkdir -p "$stub_bin" -REAL_PYTHON=$(command -p -v python3) +cat >"$stub_bin/python3" <<'STUB' +#!/bin/bash +exit 127 +STUB +chmod +x "$stub_bin/python3" + +# Test stubs must delegate to the system interpreter, not a user shim that can +# route python3 back through the stubs and recurse. +REAL_PYTHON=$(PATH="$stub_bin:$PATH" command -p -v python3) +[[ $REAL_PYTHON != "$stub_bin/python3" ]] || fail "real Python resolution bypasses user shims" export REAL_PYTHON +rm -f "$stub_bin/python3" run_migration() { HOME="$home" PATH="$stub_bin:$PATH" bash -euo pipefail "$migration" >/dev/null 2>&1 From 77305ed3b9f5e19bf2c85bd02bf8842041fc727d Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Tue, 25 Aug 2026 15:29:27 -0500 Subject: [PATCH 24/41] Enable Dell XPS 13 sidecar speaker amplifiers (#7032) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Dell XPS 13 DX13260 drives its two CS35L56 sidecar speaker amplifiers through a quirk that Linux only gains in 7.2, so until Arch ships that kernel the machine plays through one amplifier with no bass. The dell-xps13-sidecar-amps package selects the same driver path with a module override; this installs it on that exact machine and nowhere else. The detector requires both the DX13260 product name and SKU 0E53, because the override forces a quirk value rather than merging into one, and a machine that gets it wrong loses whatever quirk the kernel would have chosen for itself. Pacman registers a package even when its post_install scriptlet fails, so the leaf calls dell-xps13-sidecar-amps-apply itself instead of trusting the install to have applied: a failed cleanup or boot-image rebuild has to reach the caller rather than hide behind a package pacman considers installed. That is also why the migration marks reboot-required only after the apply succeeds — a migration that exits non-zero keeps no completion marker and retries the apply on the next run, even though pacman already has the package. The leaf runs after intel/ptl-kernel.sh rather than beside the other Dell leaf at the top of install/hardware/all.sh, so its boot-image rebuild sees the Panther Lake kernel that step swaps in rather than the stock one it removes. Co-authored-by: Codex XHigh --- bin/omarchy-hw-dell-xps13-sidecar-amps | 8 ++ install/hardware/all.sh | 4 + install/hardware/dell-xps13-sidecar-amps.sh | 10 ++ install/omarchy-other.packages | 1 + migrations/1787666837.sh | 6 + test/shell.d/xps13-sidecar-amps-test.sh | 147 ++++++++++++++++++++ 6 files changed, 176 insertions(+) create mode 100755 bin/omarchy-hw-dell-xps13-sidecar-amps create mode 100644 install/hardware/dell-xps13-sidecar-amps.sh create mode 100644 migrations/1787666837.sh create mode 100755 test/shell.d/xps13-sidecar-amps-test.sh diff --git a/bin/omarchy-hw-dell-xps13-sidecar-amps b/bin/omarchy-hw-dell-xps13-sidecar-amps new file mode 100755 index 00000000..ecc206fa --- /dev/null +++ b/bin/omarchy-hw-dell-xps13-sidecar-amps @@ -0,0 +1,8 @@ +#!/bin/bash + +# omarchy:summary=Match the Dell XPS 13 DX13260 that requires the sidecar amplifier workaround. + +product_sku="${OMARCHY_DMI_PRODUCT_SKU:-/sys/class/dmi/id/product_sku}" + +omarchy-hw-match "DX13260" && + grep -qix "0E53" "$product_sku" 2>/dev/null diff --git a/install/hardware/all.sh b/install/hardware/all.sh index 6adcff9c..9b54d1c0 100644 --- a/install/hardware/all.sh +++ b/install/hardware/all.sh @@ -25,6 +25,10 @@ run_logged "$OMARCHY_INSTALL/hardware/intel/fred.sh" run_logged "$OMARCHY_INSTALL/hardware/intel/fix-wifi7-eht.sh" run_logged "$OMARCHY_INSTALL/hardware/intel/sof-firmware.sh" +# Rebuilds the boot image, so it has to follow the Panther Lake kernel swap +# above rather than sit with the other Dell leaf at the top of this file. +run_logged "$OMARCHY_INSTALL/hardware/dell-xps13-sidecar-amps.sh" + run_logged "$OMARCHY_INSTALL/hardware/asus/fix-asus-ptl-display-backlight.sh" run_logged "$OMARCHY_INSTALL/hardware/asus/fix-asus-ptl-b9406-display.sh" run_logged "$OMARCHY_INSTALL/hardware/asus/fix-asus-ptl-b9406-touchpad.sh" diff --git a/install/hardware/dell-xps13-sidecar-amps.sh b/install/hardware/dell-xps13-sidecar-amps.sh new file mode 100644 index 00000000..5682b595 --- /dev/null +++ b/install/hardware/dell-xps13-sidecar-amps.sh @@ -0,0 +1,10 @@ +# Enable the temporary sidecar amplifier workaround on the exact Dell XPS 13 model that needs it. +# +# Pacman registers a package even when its post_install scriptlet fails, so the +# apply command runs explicitly here: a failed cleanup or boot-image rebuild has +# to reach the caller rather than hide behind a successfully registered package. + +if omarchy-hw-dell-xps13-sidecar-amps; then + omarchy-pkg-add dell-xps13-sidecar-amps && + sudo dell-xps13-sidecar-amps-apply +fi diff --git a/install/omarchy-other.packages b/install/omarchy-other.packages index 02ac645e..e5d56d56 100644 --- a/install/omarchy-other.packages +++ b/install/omarchy-other.packages @@ -61,6 +61,7 @@ linux-firmware-marvell # Dell laptop support packages dell-xps-touchpad-haptics +dell-xps13-sidecar-amps # Speaker tunings (LV2 limiter every tuning ends in) lsp-plugins-lv2 diff --git a/migrations/1787666837.sh b/migrations/1787666837.sh new file mode 100644 index 00000000..33c5d9dc --- /dev/null +++ b/migrations/1787666837.sh @@ -0,0 +1,6 @@ +echo "Enable Dell XPS 13 sidecar speaker amplifiers" + +if omarchy-hw-dell-xps13-sidecar-amps; then + source "$OMARCHY_PATH/install/hardware/dell-xps13-sidecar-amps.sh" + omarchy-state set reboot-required +fi diff --git a/test/shell.d/xps13-sidecar-amps-test.sh b/test/shell.d/xps13-sidecar-amps-test.sh new file mode 100755 index 00000000..d82498f9 --- /dev/null +++ b/test/shell.d/xps13-sidecar-amps-test.sh @@ -0,0 +1,147 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +detector="$ROOT/bin/omarchy-hw-dell-xps13-sidecar-amps" +leaf="$ROOT/install/hardware/dell-xps13-sidecar-amps.sh" +all="$ROOT/install/hardware/all.sh" +migration=$(grep -l "dell-xps13-sidecar-amps" "$ROOT"/migrations/*.sh | head -1) + +grep -q 'run_logged .*hardware/dell-xps13-sidecar-amps.sh' "$all" || + fail "the sidecar amplifier workaround runs during hardware setup" +pass "the sidecar amplifier workaround runs during hardware setup" + +# The apply step rebuilds the boot image, so it has to see the Panther Lake +# kernel that ptl-kernel.sh swaps in rather than the stock one it replaces. +ptl_line=$(grep -n 'hardware/intel/ptl-kernel.sh' "$all" | cut -d: -f1) +amps_line=$(grep -n 'hardware/dell-xps13-sidecar-amps.sh' "$all" | cut -d: -f1) +((ptl_line < amps_line)) || + fail "the sidecar amplifier workaround runs after the Panther Lake kernel swap" +pass "the sidecar amplifier workaround runs after the Panther Lake kernel swap" + +[[ -n $migration ]] || fail "a migration enables the workaround on existing installs" +pass "a migration enables the workaround on existing installs" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT +mkdir -p "$test_tmp/bin" + +cat >"$test_tmp/bin/omarchy-hw-match" <<'SH' +#!/bin/bash +[[ ${TEST_PRODUCT_NAME:-} == *"$1"* ]] +SH + +cat >"$test_tmp/bin/omarchy-pkg-add" <<'SH' +#!/bin/bash +printf 'pkg-add %s\n' "$*" >>"$CALL_LOG" +exit "${TEST_PKG_ADD_STATUS:-0}" +SH + +cat >"$test_tmp/bin/sudo" <<'SH' +#!/bin/bash +exec "$@" +SH + +cat >"$test_tmp/bin/dell-xps13-sidecar-amps-apply" <<'SH' +#!/bin/bash +printf 'apply\n' >>"$CALL_LOG" +exit "${TEST_APPLY_STATUS:-0}" +SH + +cat >"$test_tmp/bin/omarchy-state" <<'SH' +#!/bin/bash +printf 'state %s\n' "$*" >>"$CALL_LOG" +SH + +chmod +x "$test_tmp/bin"/* + +sku_file="$test_tmp/product_sku" +call_log="$test_tmp/calls.log" + +run_detector() { + printf '%s\n' "${2-0E53}" >"$sku_file" + PATH="$test_tmp/bin:$PATH" \ + TEST_PRODUCT_NAME="${1-XPS 13 DX13260}" \ + OMARCHY_DMI_PRODUCT_SKU="${3-$sku_file}" \ + bash "$detector" +} + +run_detector || fail "the detector matches the DX13260 with SKU 0E53" +pass "the detector matches the DX13260 with SKU 0E53" + +run_detector "XPS 13 DX13261" && fail "the detector rejects another model" +pass "the detector rejects another model" + +run_detector "XPS 13 DX13260" "0E54" && fail "the detector rejects another SKU" +pass "the detector rejects another SKU" + +# An exact match must not be satisfied by a SKU that merely contains it. +run_detector "XPS 13 DX13260" "0E530" && fail "the detector rejects a longer SKU" +pass "the detector rejects a longer SKU" + +run_detector "XPS 13 DX13260" "0E53" "$test_tmp/absent" && + fail "the detector fails closed when the SKU attribute is missing" +pass "the detector fails closed when the SKU attribute is missing" + +# Sourced the way run_logged runs it. +run_leaf() { + : >"$call_log" + printf '0E53\n' >"$sku_file" + PATH="$test_tmp/bin:$ROOT/bin:$PATH" \ + CALL_LOG="$call_log" \ + TEST_PRODUCT_NAME="${1-XPS 13 DX13260}" \ + TEST_PKG_ADD_STATUS="${2:-0}" \ + TEST_APPLY_STATUS="${3:-0}" \ + OMARCHY_DMI_PRODUCT_SKU="$sku_file" \ + bash -c 'source "$1"' bash "$leaf" +} + +run_leaf || fail "the leaf installs and applies on the target machine" +grep -q 'pkg-add dell-xps13-sidecar-amps' "$call_log" || + fail "the leaf installs the package on the target machine" +grep -q '^apply$' "$call_log" || + fail "the leaf applies the workaround on the target machine" +pass "the leaf installs and applies on the target machine" + +run_leaf "ThinkPad X1" || fail "the leaf no-ops on other hardware" +[[ -s $call_log ]] && fail "the leaf no-ops on other hardware" +pass "the leaf no-ops on other hardware" + +# Pacman registers a package even when its scriptlet fails, so a failing apply +# has to surface rather than be swallowed by a successful install. +run_leaf "XPS 13 DX13260" 0 1 && fail "a failing apply fails the leaf" +pass "a failing apply fails the leaf" + +run_leaf "XPS 13 DX13260" 1 && fail "a failing package install fails the leaf" +grep -q '^apply$' "$call_log" && fail "a failing package install skips the apply" +pass "a failing package install fails the leaf without applying" + +# The migration runner uses bash -euo pipefail and only records the migration +# when it exits clean, so a failed apply has to leave reboot-required unset. +run_migration() { + : >"$call_log" + printf '0E53\n' >"$sku_file" + PATH="$test_tmp/bin:$ROOT/bin:$PATH" \ + CALL_LOG="$call_log" \ + OMARCHY_PATH="$ROOT" \ + TEST_PRODUCT_NAME="${1-XPS 13 DX13260}" \ + TEST_APPLY_STATUS="${2:-0}" \ + OMARCHY_DMI_PRODUCT_SKU="$sku_file" \ + bash -euo pipefail "$migration" >/dev/null +} + +run_migration || fail "the migration applies the workaround and asks for a reboot" +grep -q 'state set reboot-required' "$call_log" || + fail "the migration applies the workaround and asks for a reboot" +pass "the migration applies the workaround and asks for a reboot" + +run_migration "XPS 13 DX13260" 1 && fail "a failing apply leaves the migration pending" +grep -q 'state set reboot-required' "$call_log" && + fail "a failing apply does not mark reboot-required" +pass "a failing apply leaves the migration pending without marking reboot-required" + +run_migration "ThinkPad X1" || fail "the migration no-ops on other hardware" +[[ -s $call_log ]] && fail "the migration no-ops on other hardware" +pass "the migration no-ops on other hardware" From 0ae1694830b6bd9511042fe1b89a0062d8c083cb Mon Sep 17 00:00:00 2001 From: Omarchybot Date: Tue, 25 Aug 2026 22:30:09 +0200 Subject: [PATCH 25/41] Constrain the tzupdate sudoers rule to a single timezone argument (#8194) The wildcard granted passwordless root for timedatectl set-timezone plus any trailing arguments, so -H/--host and -M/--machine reached the SSH and machine transports as root. Systemd 261 guards argv injection into ssh, but -H still drives root's SSH client at an attacker-chosen host, and the transport resolves its helper through PATH; only Defaults secure_path stands between that and a planted ssh running as root. Match the argument with an anchored POSIX ERE that admits exactly one timezone token (no whitespace, no leading-dash segment, no traversal component), so no second argument and no option can ever match. The sole caller, omarchy-menu-timezone, passes one list-timezones value and is unaffected. Co-authored-by: Claude Opus 4.8 Co-authored-by: Codex XHigh --- etc/sudoers.d/omarchy-tzupdate | 2 +- test/shell.d/timezone-test.sh | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/etc/sudoers.d/omarchy-tzupdate b/etc/sudoers.d/omarchy-tzupdate index d35adb82..98b30771 100644 --- a/etc/sudoers.d/omarchy-tzupdate +++ b/etc/sudoers.d/omarchy-tzupdate @@ -1 +1 @@ -%wheel ALL=(root) NOPASSWD: /usr/bin/timedatectl set-timezone * +%wheel ALL=(root) NOPASSWD: /usr/bin/timedatectl ^set-timezone [A-Za-z0-9_+][A-Za-z0-9_+.-]*(/[A-Za-z0-9_+][A-Za-z0-9_+.-]*)*$ diff --git a/test/shell.d/timezone-test.sh b/test/shell.d/timezone-test.sh index c1880a14..dc4a0263 100644 --- a/test/shell.d/timezone-test.sh +++ b/test/shell.d/timezone-test.sh @@ -7,9 +7,12 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" timezone_menu="$ROOT/bin/omarchy-menu-timezone" sudoers_file="$ROOT/etc/sudoers.d/omarchy-tzupdate" -grep -F '%wheel ALL=(root) NOPASSWD: /usr/bin/timedatectl set-timezone *' "$sudoers_file" >/dev/null || +grep -F '%wheel ALL=(root) NOPASSWD: /usr/bin/timedatectl ^set-timezone [A-Za-z0-9_+][A-Za-z0-9_+.-]*(/[A-Za-z0-9_+][A-Za-z0-9_+.-]*)*$' "$sudoers_file" >/dev/null || fail "timezone sudoers rule allows passwordless timedatectl timezone changes" +! grep -F 'set-timezone *' "$sudoers_file" >/dev/null || + fail "timezone sudoers rule uses a bare wildcard that admits extra arguments like -H and -M" + ! grep -F 'tzupdate' "$sudoers_file" >/dev/null || fail "timezone sudoers rule does not grant passwordless tzupdate" From 3af7675a10fdfc5a49789ea4723e454aac17704b Mon Sep 17 00:00:00 2001 From: Erik Melton Date: Wed, 26 Aug 2026 16:14:51 +0200 Subject: [PATCH 26/41] Require `textFormat` declaration for all `Text` elements. --- shell/Ui/Button.qml | 3 + shell/Ui/ConfirmDialog.qml | 2 + shell/Ui/Dropdown.qml | 3 + shell/Ui/MultiSelect.qml | 6 + shell/Ui/NumberField.qml | 1 + shell/Ui/OpticalGlyph.qml | 1 + shell/Ui/PanelActionButton.qml | 1 + shell/Ui/PanelHero.qml | 3 + shell/Ui/PanelSectionHeader.qml | 4 + shell/Ui/PanelToolTip.qml | 1 + shell/Ui/SearchableDropdown.qml | 5 + shell/Ui/SpeedTestOverlay.qml | 5 + shell/Ui/Toggle.qml | 2 + shell/Ui/WidgetButton.qml | 1 + shell/plugins/agents/Panel.qml | 12 ++ shell/plugins/bar/Bar.qml | 1 + shell/plugins/bar/widgets/ActiveWindow.qml | 1 + shell/plugins/bar/widgets/Tray.qml | 4 + shell/plugins/clipboard/Clipboard.qml | 4 + shell/plugins/dev-gallery/GalleryPanel.qml | 12 ++ shell/plugins/emojis/Emojis.qml | 3 + shell/plugins/image-picker/ImagePicker.qml | 2 + shell/plugins/lock/LockView.qml | 1 + shell/plugins/menu/Menu.qml | 7 + .../notifications/NotificationLogic.js | 28 +++- .../components/NotificationCard.qml | 7 + shell/plugins/osd/Osd.qml | 2 + shell/plugins/panels/audio/Panel.qml | 11 ++ shell/plugins/panels/bluetooth/Panel.qml | 6 + shell/plugins/panels/clock/Panel.qml | 8 + shell/plugins/panels/dropbox/Panel.qml | 6 + shell/plugins/panels/monitor/Panel.qml | 7 + shell/plugins/panels/network/Panel.qml | 8 + shell/plugins/panels/power/Panel.qml | 3 + shell/plugins/panels/tailscale/Panel.qml | 10 ++ shell/plugins/panels/weather/Panel.qml | 14 ++ shell/plugins/panels/wifiqr/Panel.qml | 3 + shell/plugins/polkit/PolkitAgent.qml | 2 + shell/plugins/reminders/ReminderFlow.qml | 1 + shell/plugins/services/media/BarWidget.qml | 8 + test/shell.d/notifications-test.sh | 34 +++++ test/shell.d/qml-text-format-test.sh | 144 ++++++++++++++++++ 42 files changed, 386 insertions(+), 1 deletion(-) create mode 100755 test/shell.d/qml-text-format-test.sh diff --git a/shell/Ui/Button.qml b/shell/Ui/Button.qml index 2c093b4b..2b84577a 100644 --- a/shell/Ui/Button.qml +++ b/shell/Ui/Button.qml @@ -138,6 +138,7 @@ BorderSurface { radius: 0 } contentItem: Text { + textFormat: Text.PlainText text: root.tooltipText color: root.tooltipForeground font.family: root.fontFamily @@ -158,6 +159,7 @@ BorderSurface { spacing: Style.spacing.controlGap Text { + textFormat: Text.PlainText visible: root.iconText !== "" text: root.iconText color: root.selected ? root._selectedColor : root.foreground @@ -177,6 +179,7 @@ BorderSurface { } Text { + textFormat: Text.PlainText visible: root.text !== "" text: root.text color: root.selected ? root._selectedColor : root.foreground diff --git a/shell/Ui/ConfirmDialog.qml b/shell/Ui/ConfirmDialog.qml index ed4f8c98..bc108d97 100644 --- a/shell/Ui/ConfirmDialog.qml +++ b/shell/Ui/ConfirmDialog.qml @@ -69,6 +69,7 @@ Item { Text { id: messageText + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top @@ -105,6 +106,7 @@ Item { radius: 0 Text { + textFormat: Text.PlainText anchors.centerIn: parent text: modelData color: destructive ? (selected ? Color.urgent : root.foreground) : (selected ? root.selectedText : root.foreground) diff --git a/shell/Ui/Dropdown.qml b/shell/Ui/Dropdown.qml index 214a7fe4..58386c9b 100644 --- a/shell/Ui/Dropdown.qml +++ b/shell/Ui/Dropdown.qml @@ -71,6 +71,7 @@ Item { spacing: Style.spacing.labelGap Text { + textFormat: Text.PlainText visible: root.showLabel && root.label !== "" text: root.label color: Qt.darker(root.foreground, 1.4) @@ -110,6 +111,7 @@ Item { } Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: chevron.left anchors.verticalCenter: parent.verticalCenter @@ -214,6 +216,7 @@ Item { : "transparent" Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter diff --git a/shell/Ui/MultiSelect.qml b/shell/Ui/MultiSelect.qml index f759b66d..85705779 100644 --- a/shell/Ui/MultiSelect.qml +++ b/shell/Ui/MultiSelect.qml @@ -259,6 +259,7 @@ Item { spacing: Style.spacing.labelGap Text { + textFormat: Text.PlainText visible: root.showLabel && root.label !== "" text: root.label color: Qt.darker(root.foreground, 1.4) @@ -298,6 +299,7 @@ Item { } Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: chevron.left anchors.verticalCenter: parent.verticalCenter @@ -451,6 +453,7 @@ Item { : Border.controlSpec("normal", root.foreground, root.accent) Text { + textFormat: Text.PlainText anchors.centerIn: parent text: root.loadingOptions ? "󰦖" : "󰑐" color: root.foreground @@ -486,6 +489,7 @@ Item { height: popup.height - searchHeader.height - Style.spacing.xxs - 1 Text { + textFormat: Text.PlainText anchors.centerIn: parent visible: resultList.count === 0 text: root.loadingOptions ? "Loading…" : (root.optionsError !== "" ? root.optionsError : root.emptyText) @@ -581,6 +585,7 @@ Item { spacing: Style.spacing.xxs Text { + textFormat: Text.PlainText text: modelData.label color: index === resultList.currentIndex ? Style.hoverStateColor(root.foreground, root.accent) : root.foreground font.family: root.fontFamily @@ -589,6 +594,7 @@ Item { width: parent.width } Text { + textFormat: Text.PlainText visible: text !== "" text: modelData.description color: Qt.darker(root.foreground, 1.5) diff --git a/shell/Ui/NumberField.qml b/shell/Ui/NumberField.qml index 24b70f5b..985c9f7e 100644 --- a/shell/Ui/NumberField.qml +++ b/shell/Ui/NumberField.qml @@ -25,6 +25,7 @@ Column { spacing: Style.spacing.md Text { + textFormat: Text.PlainText visible: root.label !== "" text: root.label color: Qt.darker(root.foreground, 1.4) diff --git a/shell/Ui/OpticalGlyph.qml b/shell/Ui/OpticalGlyph.qml index d446a52c..a8881d49 100644 --- a/shell/Ui/OpticalGlyph.qml +++ b/shell/Ui/OpticalGlyph.qml @@ -25,6 +25,7 @@ Item { Text { id: glyph + textFormat: Text.PlainText // Keep the shared line box and baseline intact. Correcting only the // horizontal painted bounds avoids per-glyph vertical drift. anchors.centerIn: parent diff --git a/shell/Ui/PanelActionButton.qml b/shell/Ui/PanelActionButton.qml index 8a1b10bc..05f7d6be 100644 --- a/shell/Ui/PanelActionButton.qml +++ b/shell/Ui/PanelActionButton.qml @@ -69,6 +69,7 @@ BorderSurface { Behavior on color { ColorAnimation { duration: 60 } } Text { + textFormat: Text.PlainText anchors.centerIn: parent text: root.iconText color: root.enabled diff --git a/shell/Ui/PanelHero.qml b/shell/Ui/PanelHero.qml index 7d663f37..4d13cf14 100644 --- a/shell/Ui/PanelHero.qml +++ b/shell/Ui/PanelHero.qml @@ -48,6 +48,7 @@ Item { width: parent.width Text { + textFormat: Text.PlainText visible: root.title !== "" text: root.title width: Math.min(implicitWidth, Math.max(0, parent.width - (detailPill.visible ? detailPill.implicitWidth + Style.space(8) : 0))) @@ -75,6 +76,7 @@ Item { Text { id: detailText + textFormat: Text.PlainText anchors.centerIn: parent text: root.detail color: root.dim @@ -87,6 +89,7 @@ Item { Text { id: metaText + textFormat: Text.PlainText width: parent.width text: root.meta.toUpperCase() visible: text !== "" diff --git a/shell/Ui/PanelSectionHeader.qml b/shell/Ui/PanelSectionHeader.qml index 5559248e..f0d54fb9 100644 --- a/shell/Ui/PanelSectionHeader.qml +++ b/shell/Ui/PanelSectionHeader.qml @@ -11,6 +11,10 @@ Text { property string fontFamily: Style.font.family property real fontSize: Style.font.caption + // Callers bind `text` from outside this file, so the default has to be set + // here. AutoText would let a section title that happens to carry a device or + // network name promote itself to rich text. + textFormat: Text.PlainText color: Qt.darker(foreground, 1.4) font.family: fontFamily font.pixelSize: fontSize diff --git a/shell/Ui/PanelToolTip.qml b/shell/Ui/PanelToolTip.qml index 139b6cf0..90d3133a 100644 --- a/shell/Ui/PanelToolTip.qml +++ b/shell/Ui/PanelToolTip.qml @@ -36,6 +36,7 @@ ToolTip { } contentItem: Text { + textFormat: Text.PlainText text: root.text color: root.panelForeground font.family: root.fontFamily diff --git a/shell/Ui/SearchableDropdown.qml b/shell/Ui/SearchableDropdown.qml index 9cf0aa49..7728d86b 100644 --- a/shell/Ui/SearchableDropdown.qml +++ b/shell/Ui/SearchableDropdown.qml @@ -93,6 +93,7 @@ Item { spacing: Style.spacing.labelGap Text { + textFormat: Text.PlainText visible: root.showLabel && root.label !== "" text: root.label color: Qt.darker(root.foreground, 1.4) @@ -132,6 +133,7 @@ Item { } Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: chevron.left anchors.verticalCenter: parent.verticalCenter @@ -246,6 +248,7 @@ Item { height: popup.height - searchHeader.height - Style.spacing.xxs - 1 Text { + textFormat: Text.PlainText anchors.centerIn: parent visible: resultList.count === 0 text: root.emptyText @@ -313,6 +316,7 @@ Item { spacing: Style.spacing.xxs Text { + textFormat: Text.PlainText text: root.optionLabel(modelData) color: index === resultList.currentIndex ? Style.hoverStateColor(root.foreground, root.accent) : root.foreground font.family: root.fontFamily @@ -321,6 +325,7 @@ Item { width: parent.width } Text { + textFormat: Text.PlainText visible: text !== "" text: root.optionDescription(modelData) color: Qt.darker(root.foreground, 1.5) diff --git a/shell/Ui/SpeedTestOverlay.qml b/shell/Ui/SpeedTestOverlay.qml index 1216f348..a8f84c7b 100644 --- a/shell/Ui/SpeedTestOverlay.qml +++ b/shell/Ui/SpeedTestOverlay.qml @@ -130,6 +130,7 @@ PanelWindow { spacing: Style.space(16) Text { + textFormat: Text.PlainText visible: root.title !== "" text: root.title.toUpperCase() color: root.onScrimDim @@ -182,6 +183,7 @@ PanelWindow { } Text { + textFormat: Text.PlainText visible: root.failed text: root.error color: root.onScrimUrgent @@ -368,6 +370,7 @@ PanelWindow { spacing: 0 Text { + textFormat: Text.PlainText anchors.horizontalCenter: parent.horizontalCenter // Both branches go through the locale: a reading is a measurement, so // its separators follow the system's number conventions rather than the @@ -383,6 +386,7 @@ PanelWindow { } Text { + textFormat: Text.PlainText anchors.horizontalCenter: parent.horizontalCenter text: root.unit color: root.onScrimDim @@ -394,6 +398,7 @@ PanelWindow { // The 90° gap at the bottom of the scale is where a cluster prints its // unit; here it names the direction. Text { + textFormat: Text.PlainText anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom text: dial.label diff --git a/shell/Ui/Toggle.qml b/shell/Ui/Toggle.qml index 26a5cf96..b861fec7 100644 --- a/shell/Ui/Toggle.qml +++ b/shell/Ui/Toggle.qml @@ -69,6 +69,7 @@ BorderSurface { anchors.verticalCenter: parent.verticalCenter Text { + textFormat: Text.PlainText text: root.label color: root.foreground font.family: root.fontFamily @@ -79,6 +80,7 @@ BorderSurface { } Text { + textFormat: Text.PlainText visible: root.description !== "" text: root.description color: Qt.darker(root.foreground, 1.5) diff --git a/shell/Ui/WidgetButton.qml b/shell/Ui/WidgetButton.qml index 02d843ab..87d18050 100644 --- a/shell/Ui/WidgetButton.qml +++ b/shell/Ui/WidgetButton.qml @@ -74,6 +74,7 @@ Item { Text { id: label + textFormat: Text.PlainText visible: root.labelVisible anchors.centerIn: parent text: root.text diff --git a/shell/plugins/agents/Panel.qml b/shell/plugins/agents/Panel.qml index 6637531a..f4ecdd9a 100644 --- a/shell/plugins/agents/Panel.qml +++ b/shell/plugins/agents/Panel.qml @@ -434,6 +434,7 @@ Panel { } Text { + textFormat: Text.PlainText anchors.centerIn: parent visible: heroMarkImage.status !== Image.Ready text: button.text @@ -504,6 +505,7 @@ Panel { Text { id: statusText + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -558,6 +560,7 @@ Panel { Text { id: balanceValue + textFormat: Text.PlainText text: root.balance ? root.formatMoney(root.balance.remaining, root.balance.currency) : "" color: root.balanceAlarming ? root.urgent : root.foreground font.family: root.fontFamily @@ -575,6 +578,7 @@ Panel { } Text { + textFormat: Text.PlainText visible: text !== "" width: parent.width text: root.balanceDetailText(root.balance) @@ -680,6 +684,7 @@ Panel { } Text { + textFormat: Text.PlainText visible: text !== "" width: parent.width topPadding: Style.space(2) @@ -710,6 +715,7 @@ Panel { Text { id: limitLabel + textFormat: Text.PlainText // A model-scoped window is titled after its model, and those names run // long enough to reach the percentage, so the title gives way first. text: limitRow.window ? limitRow.window.title : "" @@ -725,6 +731,7 @@ Panel { Text { id: limitValue + textFormat: Text.PlainText text: limitRow.window && limitRow.window.percent >= 0 ? Math.round(limitRow.window.percent * 100) + "%" : "—" @@ -744,6 +751,7 @@ Panel { Text { id: resetText + textFormat: Text.PlainText width: parent.width text: { var remainingMs = root.resetMsFor(limitRow.window) @@ -798,6 +806,7 @@ Panel { Text { id: dayLabel + textFormat: Text.PlainText text: root.dayLabel(dayRow.day ? dayRow.day.date : "", dayRow.today) color: dayRow.today ? root.foreground : root.dim font.family: root.fontFamily @@ -835,6 +844,7 @@ Panel { Text { id: dayValue + textFormat: Text.PlainText text: usage.formatTokenCount(dayRow.day ? Number(dayRow.day.messageCount || 0) : 0) color: dayRow.today ? root.foreground : root.dim font.family: root.fontFamily @@ -890,6 +900,7 @@ Panel { Text { id: modelName + textFormat: Text.PlainText text: modelRow.row ? modelRow.row.name : "" color: root.foreground font.family: root.fontFamily @@ -904,6 +915,7 @@ Panel { Text { id: modelTokens + textFormat: Text.PlainText text: modelRow.row ? usage.formatTokenCount(modelRow.row.total) : "" color: root.dim font.family: root.fontFamily diff --git a/shell/plugins/bar/Bar.qml b/shell/plugins/bar/Bar.qml index 5dcd205f..9e736b3f 100644 --- a/shell/plugins/bar/Bar.qml +++ b/shell/plugins/bar/Bar.qml @@ -1090,6 +1090,7 @@ Item { Text { id: tooltipLabel + textFormat: Text.PlainText anchors.centerIn: parent text: root.tooltipText color: Color.tooltip.text diff --git a/shell/plugins/bar/widgets/ActiveWindow.qml b/shell/plugins/bar/widgets/ActiveWindow.qml index 97ccce8d..ff7e83d8 100644 --- a/shell/plugins/bar/widgets/ActiveWindow.qml +++ b/shell/plugins/bar/widgets/ActiveWindow.qml @@ -29,6 +29,7 @@ BarWidget { Text { id: labelText + textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left width: parent.width diff --git a/shell/plugins/bar/widgets/Tray.qml b/shell/plugins/bar/widgets/Tray.qml index d0d07f57..650358c1 100644 --- a/shell/plugins/bar/widgets/Tray.qml +++ b/shell/plugins/bar/widgets/Tray.qml @@ -467,6 +467,7 @@ BarWidget { } Text { + textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter anchors.left: rowIcon.right anchors.leftMargin: Style.space(10) @@ -577,6 +578,7 @@ BarWidget { } Text { + textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left anchors.leftMargin: Style.space(28) @@ -681,6 +683,7 @@ BarWidget { } Text { + textFormat: Text.PlainText visible: !menuRow.modelData.isSeparator && menuRow.modelData.buttonType !== QsMenuButtonType.None anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left @@ -709,6 +712,7 @@ BarWidget { } Text { + textFormat: Text.PlainText visible: !menuRow.modelData.isSeparator anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left diff --git a/shell/plugins/clipboard/Clipboard.qml b/shell/plugins/clipboard/Clipboard.qml index d819f949..da969e65 100644 --- a/shell/plugins/clipboard/Clipboard.qml +++ b/shell/plugins/clipboard/Clipboard.qml @@ -432,6 +432,7 @@ Item { color: "transparent" Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -500,6 +501,7 @@ Item { } Text { + textFormat: Text.PlainText width: parent.width - (parent.parent.previewImage.length > 0 ? parent.height + parent.spacing : 0) height: parent.height text: parent.parent.previewText @@ -546,6 +548,7 @@ Item { } Text { + textFormat: Text.PlainText visible: parent.activeRow && !parent.activeRow.previewImage anchors.fill: parent anchors.leftMargin: root.contentMargin @@ -593,6 +596,7 @@ Item { } Text { + textFormat: Text.PlainText text: root.history.length === 0 ? "Clipboard is empty" : "No matches for “" + root.filterText + "”" color: root.foreground opacity: 0.7 diff --git a/shell/plugins/dev-gallery/GalleryPanel.qml b/shell/plugins/dev-gallery/GalleryPanel.qml index 945b6252..680bdbef 100644 --- a/shell/plugins/dev-gallery/GalleryPanel.qml +++ b/shell/plugins/dev-gallery/GalleryPanel.qml @@ -519,12 +519,14 @@ Item { width: Style.space(140) spacing: Style.space(1) Text { + textFormat: Text.PlainText text: "Style.font." + modelData.key color: root.foreground font.family: root.fontFamily font.pixelSize: Style.font.bodySmall } Text { + textFormat: Text.PlainText text: modelData.size + " px" color: Qt.darker(root.foreground, 1.5) font.family: root.fontFamily @@ -534,6 +536,7 @@ Item { Text { id: sampleText + textFormat: Text.PlainText anchors.left: metaCol.right anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -574,6 +577,7 @@ Item { font.pixelSize: Style.font.bodySmall } Text { + textFormat: Text.PlainText text: Style.font.family color: root.foreground font.family: root.fontFamily @@ -587,6 +591,7 @@ Item { font.pixelSize: Style.font.bodySmall } Text { + textFormat: Text.PlainText text: Style.font.resolvedFamily color: root.foreground font.family: root.fontFamily @@ -600,6 +605,7 @@ Item { font.pixelSize: Style.font.bodySmall } Text { + textFormat: Text.PlainText text: Style.font.baseSize + " px" color: root.foreground font.family: root.fontFamily @@ -613,6 +619,7 @@ Item { font.pixelSize: Style.font.bodySmall } Text { + textFormat: Text.PlainText text: Style.bar.sizeHorizontal + " px" color: root.foreground font.family: root.fontFamily @@ -626,6 +633,7 @@ Item { font.pixelSize: Style.font.bodySmall } Text { + textFormat: Text.PlainText text: Style.bar.sizeVertical + " px" color: root.foreground font.family: root.fontFamily @@ -639,6 +647,7 @@ Item { font.pixelSize: Style.font.bodySmall } Text { + textFormat: Text.PlainText text: Style.spacing.scale.toFixed(2) color: root.foreground font.family: root.fontFamily @@ -652,6 +661,7 @@ Item { font.pixelSize: Style.font.bodySmall } Text { + textFormat: Text.PlainText text: Style.spacing.panelPadding + " px" color: root.foreground font.family: root.fontFamily @@ -818,6 +828,7 @@ Item { Text { id: csLabel + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -1273,6 +1284,7 @@ Item { } Text { + textFormat: Text.PlainText text: Math.round((demoSlider.dragging ? demoSlider.liveValue : sliderRow.demoVolume) * 100) + "%" color: root.foreground font.family: root.fontFamily diff --git a/shell/plugins/emojis/Emojis.qml b/shell/plugins/emojis/Emojis.qml index cbdf541d..376c382e 100644 --- a/shell/plugins/emojis/Emojis.qml +++ b/shell/plugins/emojis/Emojis.qml @@ -247,6 +247,7 @@ Item { color: "transparent" Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -284,6 +285,7 @@ Item { color: hasCursor ? root.selectedBackground : "transparent" Text { + textFormat: Text.PlainText text: parent.emoji font.family: root.fontFamily font.pixelSize: Style.font.display @@ -326,6 +328,7 @@ Item { } Text { + textFormat: Text.PlainText text: "No matches for “" + root.filterText + "”" color: root.foreground opacity: 0.7 diff --git a/shell/plugins/image-picker/ImagePicker.qml b/shell/plugins/image-picker/ImagePicker.qml index 672a5d16..5c002402 100644 --- a/shell/plugins/image-picker/ImagePicker.qml +++ b/shell/plugins/image-picker/ImagePicker.qml @@ -545,6 +545,7 @@ Item { Text { id: selectedLabel + textFormat: Text.PlainText visible: root.showLabels anchors.top: carousel.bottom anchors.topMargin: Style.space(16) @@ -561,6 +562,7 @@ Item { } Text { + textFormat: Text.PlainText visible: root.filterable && root.filterText anchors.top: selectedLabel.bottom anchors.topMargin: Style.space(8) diff --git a/shell/plugins/lock/LockView.qml b/shell/plugins/lock/LockView.qml index 7b0b0ae0..c2deae0f 100644 --- a/shell/plugins/lock/LockView.qml +++ b/shell/plugins/lock/LockView.qml @@ -184,6 +184,7 @@ Item { } Text { + textFormat: Text.PlainText anchors.fill: passwordInput text: root.authenticatingPassword ? "Checking…" : (root.failureMessage.length > 0 ? root.failureMessage : root.placeholderText) visible: passwordInput.text.length === 0 diff --git a/shell/plugins/menu/Menu.qml b/shell/plugins/menu/Menu.qml index eeaf2e25..aa879c18 100644 --- a/shell/plugins/menu/Menu.qml +++ b/shell/plugins/menu/Menu.qml @@ -1199,6 +1199,7 @@ Item { color: "transparent" Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -1287,6 +1288,7 @@ Item { Text { id: iconText + textFormat: Text.PlainText visible: row.hasIcon && !row.isApp text: row.icon color: row.hasCursor ? root.selectedText : root.foreground @@ -1328,6 +1330,7 @@ Item { Text { id: labelText + textFormat: Text.PlainText width: parent.width text: row.label color: row.hasCursor ? root.selectedText : root.foreground @@ -1338,6 +1341,7 @@ Item { } Text { + textFormat: Text.PlainText width: parent.width text: row.detail visible: (root.filterText || row.kind === "dmenu") && row.detail.length > 0 @@ -1358,6 +1362,7 @@ Item { spacing: 0 Text { + textFormat: Text.PlainText visible: false text: row.childCount color: root.foreground @@ -1368,6 +1373,7 @@ Item { } Text { + textFormat: Text.PlainText text: row.kind === "menu" || row.kind === "link" ? "›" : "" color: row.hasCursor ? root.selectedText : root.foreground opacity: row.kind === "menu" || row.kind === "link" ? 0.36 : 0 @@ -1452,6 +1458,7 @@ Item { } Text { + textFormat: Text.PlainText text: root.filterText ? "No matches for “" + root.filterText + "”" : "Nothing here yet" color: root.foreground opacity: 0.7 diff --git a/shell/plugins/notifications/NotificationLogic.js b/shell/plugins/notifications/NotificationLogic.js index 9bad602d..fc71a824 100644 --- a/shell/plugins/notifications/NotificationLogic.js +++ b/shell/plugins/notifications/NotificationLogic.js @@ -5,8 +5,34 @@ function isChromiumDerived(app, appIcon) { source.indexOf("opera") >= 0 } +// The body renders as StyledText so notifications can use the markup the +// body-markup capability advertises (see Service.qml). StyledText honours +// , and a remote src makes the shell issue an unauthenticated GET +// with no user action, so image tags go before the renderer sees them. +// +// One replace() pass is not enough. String.replace scans left to right once, +// so a payload spliced inside the literal "g src="http://a/beacon.png"> +// -> +// +// Repeat to a fixed point. Each pass can only shorten the string, so this +// terminates. +function stripImageTags(text) { + var current = text + var previous + do { + previous = current + // The `$` alternative catches a tag left unterminated at the end of the + // string, which the renderer closes for itself. + current = current.replace(/]*(?:>|$)/gi, "") + } while (current !== previous) + return current +} + function sanitizeBody(body, app, appIcon) { - var text = String(body || "").replace(/]*>/gi, "") + var text = stripImageTags(String(body || "")) if (!isChromiumDerived(app, appIcon)) return text return text diff --git a/shell/plugins/notifications/components/NotificationCard.qml b/shell/plugins/notifications/components/NotificationCard.qml index cf88f23e..1171ddc4 100644 --- a/shell/plugins/notifications/components/NotificationCard.qml +++ b/shell/plugins/notifications/components/NotificationCard.qml @@ -133,6 +133,7 @@ BorderSurface { // Glyph fallback (Nerd Font character) when no image icon is // available. Used by omarchy-notification-send's `-g` flag. Text { + textFormat: Text.PlainText anchors.centerIn: parent visible: root.hasGlyph && smallIconImage.status !== Image.Ready text: root.glyph @@ -143,6 +144,7 @@ BorderSurface { } Text { + textFormat: Text.PlainText Layout.alignment: Qt.AlignVCenter visible: root.compactGlyph text: root.glyph @@ -159,6 +161,11 @@ BorderSurface { spacing: Style.space(2) Text { + // The spec defines the summary as a single line of plain text, so + // AutoText could only ever promote a hostile string to rich text. + // The body below is StyledText on purpose — see Service.qml's + // bodyMarkupSupported — and is stripped in NotificationLogic. + textFormat: Text.PlainText Layout.fillWidth: true visible: root.summary.length > 0 text: root.summary diff --git a/shell/plugins/osd/Osd.qml b/shell/plugins/osd/Osd.qml index abf53e22..581bfa3c 100644 --- a/shell/plugins/osd/Osd.qml +++ b/shell/plugins/osd/Osd.qml @@ -159,6 +159,7 @@ Item { width: root.iconWidth height: parent.height Text { + textFormat: Text.PlainText // Sit the glyph's ink flush in the column, centered when the // column is wider than this particular glyph. x: Math.round((root.iconWidth - root.iconInkWidth) / 2 - iconMetrics.tightBoundingRect.x) @@ -186,6 +187,7 @@ Item { } } Text { + textFormat: Text.PlainText visible: root.message !== "" width: root.hasProgress ? root.valueWidth : root.messageWidth // The readout hugs the card edge so a short percentage doesn't leave diff --git a/shell/plugins/panels/audio/Panel.qml b/shell/plugins/panels/audio/Panel.qml index f8a86c6f..26d0c58d 100644 --- a/shell/plugins/panels/audio/Panel.qml +++ b/shell/plugins/panels/audio/Panel.qml @@ -711,6 +711,7 @@ Panel { // Status only — the switch owns muting, mouse and keyboard alike. Text { id: heroIcon + textFormat: Text.PlainText text: root.outputIcon() color: root.bar.foreground font.family: root.bar.fontFamily @@ -761,6 +762,7 @@ Panel { Text { id: heroLabel + textFormat: Text.PlainText text: root.outputVolumeName( outputSlider.dragging ? outputSlider.liveValue : root.outputVolume, root.outputMuted @@ -800,6 +802,7 @@ Panel { Text { id: outputPercent + textFormat: Text.PlainText text: Math.round((outputSlider.dragging ? outputSlider.liveValue : root.outputVolume) * 100) + "%" color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily @@ -886,6 +889,7 @@ Panel { Text { id: microphonePercent + textFormat: Text.PlainText text: Math.round((inputSlider.dragging ? inputSlider.liveValue : root.inputVolume) * 100) + "%" color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily @@ -1030,6 +1034,7 @@ Panel { spacing: Style.space(8) Text { + textFormat: Text.PlainText text: root.sinkGlyph(sinkRow.node) color: root.bar.foreground font.family: root.bar.fontFamily @@ -1040,6 +1045,7 @@ Panel { } Text { + textFormat: Text.PlainText text: root.nodeLabel(sinkRow.node) color: root.bar.foreground font.family: root.bar.fontFamily @@ -1089,6 +1095,7 @@ Panel { spacing: Style.space(8) Text { + textFormat: Text.PlainText text: root.sourceGlyph(sourceRow.node) color: root.bar.foreground font.family: root.bar.fontFamily @@ -1099,6 +1106,7 @@ Panel { } Text { + textFormat: Text.PlainText text: root.nodeLabel(sourceRow.node) color: root.bar.foreground font.family: root.bar.fontFamily @@ -1159,6 +1167,7 @@ Panel { Text { id: streamMuteIcon + textFormat: Text.PlainText text: streamRow.streamMuted ? "󰝟" : "󰕾" color: root.bar.foreground font.family: root.bar.fontFamily @@ -1179,6 +1188,7 @@ Panel { } Text { + textFormat: Text.PlainText text: root.streamLabel(streamRow.node) color: root.bar.foreground font.family: root.bar.fontFamily @@ -1191,6 +1201,7 @@ Panel { Text { id: streamPct + textFormat: Text.PlainText text: Math.round(streamRow.streamVolume * 100) + "%" color: Qt.darker(root.bar.foreground, 1.5) font.family: root.bar.fontFamily diff --git a/shell/plugins/panels/bluetooth/Panel.qml b/shell/plugins/panels/bluetooth/Panel.qml index 343357b4..b0078f84 100644 --- a/shell/plugins/panels/bluetooth/Panel.qml +++ b/shell/plugins/panels/bluetooth/Panel.qml @@ -698,6 +698,7 @@ Panel { // Status only — the switch owns toggling, mouse and keyboard alike. Text { id: heroIcon + textFormat: Text.PlainText anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter text: root.icon @@ -748,6 +749,7 @@ Panel { Text { id: heroStatus + textFormat: Text.PlainText text: root.heroStatusText.toUpperCase() color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily @@ -863,6 +865,7 @@ Panel { } Text { + textFormat: Text.PlainText visible: root.connectedDevices.length === 0 && root.scrollRows.length === 0 text: !root.adapter ? "No Bluetooth adapter" : !root.adapter.enabled ? "Turn Bluetooth on to scan" @@ -971,6 +974,7 @@ Panel { Text { id: deviceIcon + textFormat: Text.PlainText text: row.isConnected ? "󰂱" : "󰂯" color: row.statusColor font.family: root.bar.fontFamily @@ -989,6 +993,7 @@ Panel { anchors.verticalCenter: parent.verticalCenter Text { + textFormat: Text.PlainText text: root.deviceLabel(row.dev) || "Device" color: root.bar.foreground font.family: root.bar.fontFamily @@ -997,6 +1002,7 @@ Panel { width: parent.width } Text { + textFormat: Text.PlainText visible: row.statusText !== "" text: row.statusText color: row.statusColor diff --git a/shell/plugins/panels/clock/Panel.qml b/shell/plugins/panels/clock/Panel.qml index f0dff3ae..be5d08a0 100644 --- a/shell/plugins/panels/clock/Panel.qml +++ b/shell/plugins/panels/clock/Panel.qml @@ -311,6 +311,7 @@ Panel { Text { id: heroDate + textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter text: Qt.formatDate(root.today, "MMMM d") color: heroMouse.containsMouse @@ -413,6 +414,7 @@ Panel { Text { id: yearLabel + textFormat: Text.PlainText visible: !root.editingLife anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter @@ -425,6 +427,7 @@ Panel { Text { id: yearPercent + textFormat: Text.PlainText visible: !root.editingLife anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -485,6 +488,7 @@ Panel { Text { id: lifePercent + textFormat: Text.PlainText anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter text: root.lifeDonePercent + "%" @@ -608,6 +612,7 @@ Panel { model: root.weekdays Text { + textFormat: Text.PlainText required property var modelData width: root.cellWidth height: Style.space(16) @@ -631,6 +636,7 @@ Panel { spacing: root.cellSpacing Text { + textFormat: Text.PlainText width: root.weekColumnWidth height: root.cellHeight horizontalAlignment: Text.AlignHCenter @@ -662,6 +668,7 @@ Panel { border.color: Style.normalBorderFor(root.contentForeground, Color.accent) Text { + textFormat: Text.PlainText anchors.centerIn: parent text: modelData.day color: modelData.inMonth @@ -707,6 +714,7 @@ Panel { Text { id: monthLabel + textFormat: Text.PlainText anchors.horizontalCenter: parent.horizontalCenter anchors.verticalCenter: parent.verticalCenter // Fixed width so the chevrons hold still between a diff --git a/shell/plugins/panels/dropbox/Panel.qml b/shell/plugins/panels/dropbox/Panel.qml index f1dc2301..b470cee2 100644 --- a/shell/plugins/panels/dropbox/Panel.qml +++ b/shell/plugins/panels/dropbox/Panel.qml @@ -281,6 +281,7 @@ Panel { } Text { + textFormat: Text.PlainText visible: dropbox.actionStatus !== "" || dropbox.lastError !== "" width: parent.width text: dropbox.actionStatus !== "" ? dropbox.actionStatus : dropbox.lastError @@ -421,6 +422,7 @@ Panel { spacing: Style.space(1) Text { + textFormat: Text.PlainText Layout.fillWidth: true text: dropbox.installed ? "Login to Dropbox" : "Dropbox CLI is not installed" color: root.foreground @@ -430,6 +432,7 @@ Panel { } Text { + textFormat: Text.PlainText Layout.fillWidth: true text: dropbox.installed ? "Start the authentication flow" : "Install Dropbox from the service menu" color: root.dim @@ -478,6 +481,7 @@ Panel { spacing: Style.space(8) Text { + textFormat: Text.PlainText text: Model.fileGlyph(fileRow.fileName) color: root.foreground font.family: root.fontFamily @@ -491,6 +495,7 @@ Panel { spacing: Style.space(1) Text { + textFormat: Text.PlainText Layout.fillWidth: true text: fileRow.fileName color: root.foreground @@ -500,6 +505,7 @@ Panel { } Text { + textFormat: Text.PlainText Layout.fillWidth: true text: Model.fileMeta(fileRow.file) color: root.dim diff --git a/shell/plugins/panels/monitor/Panel.qml b/shell/plugins/panels/monitor/Panel.qml index 1753906e..bec38820 100644 --- a/shell/plugins/panels/monitor/Panel.qml +++ b/shell/plugins/panels/monitor/Panel.qml @@ -531,6 +531,7 @@ Panel { Text { id: heroIcon + textFormat: Text.PlainText text: root.displays.length > 1 ? "󰍺" : "󰍹" color: root.bar.foreground font.family: root.bar.fontFamily @@ -559,6 +560,7 @@ Panel { Text { id: heroLabel + textFormat: Text.PlainText text: { if (root.brightnessAvailable) { return root.brightnessName(brightnessSlider.dragging ? brightnessSlider.liveValue : root.brightnessPercent).toUpperCase() @@ -602,6 +604,7 @@ Panel { Text { id: brightnessPercent + textFormat: Text.PlainText text: Math.round(brightnessSlider.dragging ? brightnessSlider.liveValue : root.brightnessPercent) + "%" color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily @@ -674,6 +677,7 @@ Panel { Text { id: textSizePx + textFormat: Text.PlainText text: (textSizeSlider.dragging ? root.textSizeStops[Math.round(textSizeSlider.liveValue)] : root.displayedTextPx()) + "px" @@ -747,6 +751,7 @@ Panel { // focused one. Text { id: scaleMonitor + textFormat: Text.PlainText text: root.focusedMonitor // Only worth naming when more than one display is in play. visible: root.focusedMonitor !== "" && root.enabledDisplayCount > 1 @@ -887,6 +892,7 @@ Panel { } Text { + textFormat: Text.PlainText text: monitorRow.display.name + (monitorRow.display.focused ? " · focused" : "") color: root.bar.foreground font.family: root.bar.fontFamily @@ -897,6 +903,7 @@ Panel { } Text { + textFormat: Text.PlainText text: monitorRow.display.enabled ? "󰄬" : "" color: root.bar.foreground font.family: root.bar.fontFamily diff --git a/shell/plugins/panels/network/Panel.qml b/shell/plugins/panels/network/Panel.qml index dea1d280..c4da0afe 100644 --- a/shell/plugins/panels/network/Panel.qml +++ b/shell/plugins/panels/network/Panel.qml @@ -1090,6 +1090,7 @@ Panel { // Status only — the switch owns toggling, mouse and keyboard alike. Text { id: heroIcon + textFormat: Text.PlainText text: root.icon color: root.bar.foreground font.family: root.bar.fontFamily @@ -1170,6 +1171,7 @@ Panel { // rather than in a pill, which crowded the on/off switch. Text { id: heroSsid + textFormat: Text.PlainText width: parent.width readonly property string title: { @@ -1189,6 +1191,7 @@ Panel { Text { id: heroMeta + textFormat: Text.PlainText width: parent.width text: { if (root.info.type === "wifi") { @@ -1711,6 +1714,7 @@ Panel { Text { id: networkIcon + textFormat: Text.PlainText text: row.net ? root.wifiIconFor(row.net.signal) : "" color: row.statusColor font.family: root.bar.fontFamily @@ -1732,6 +1736,7 @@ Panel { Text { id: lockIndicator + textFormat: Text.PlainText visible: row.requiresCredentials || row.forgetVisible width: parent.width anchors.verticalCenter: parent.verticalCenter @@ -1779,6 +1784,7 @@ Panel { anchors.verticalCenter: parent.verticalCenter Text { + textFormat: Text.PlainText text: row.net ? (row.net.ssid || "Hidden") : "" color: root.bar.foreground font.family: root.bar.fontFamily @@ -1787,6 +1793,7 @@ Panel { width: parent.width } Text { + textFormat: Text.PlainText // Signal strength is conveyed by the wifi-bars icon and the // right-edge glyph/buttons carry protection or forget affordances, // so the second line only carries action status (Connecting…, @@ -1893,6 +1900,7 @@ Panel { radius: Style.cornerRadius Text { + textFormat: Text.PlainText anchors.fill: parent horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter diff --git a/shell/plugins/panels/power/Panel.qml b/shell/plugins/panels/power/Panel.qml index 871ee2f6..7733bb37 100644 --- a/shell/plugins/panels/power/Panel.qml +++ b/shell/plugins/panels/power/Panel.qml @@ -325,6 +325,7 @@ Panel { Text { id: heroIcon + textFormat: Text.PlainText text: root.batteryIcon() color: root.bar.foreground font.family: root.bar.fontFamily @@ -356,6 +357,7 @@ Panel { Text { id: heroStatus + textFormat: Text.PlainText text: root.heroStatusText.toUpperCase() color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily @@ -369,6 +371,7 @@ Panel { Text { id: heroPercent + textFormat: Text.PlainText text: root.batteryInfo.percentage || "—" color: root.bar.foreground font.family: root.bar.fontFamily diff --git a/shell/plugins/panels/tailscale/Panel.qml b/shell/plugins/panels/tailscale/Panel.qml index 34278eda..6a976307 100644 --- a/shell/plugins/panels/tailscale/Panel.qml +++ b/shell/plugins/panels/tailscale/Panel.qml @@ -498,6 +498,7 @@ Panel { } Text { + textFormat: Text.PlainText visible: tailscale.actionStatus !== "" || tailscale.lastError !== "" width: parent.width text: tailscale.actionStatus !== "" ? tailscale.actionStatus : tailscale.lastError @@ -841,6 +842,7 @@ Panel { } Text { + textFormat: Text.PlainText text: accountRow.accountText color: root.foreground font.family: root.fontFamily @@ -933,6 +935,7 @@ Panel { spacing: Style.space(8) Text { + textFormat: Text.PlainText text: tailscale.osIcon(peer ? peer.OS : "") color: root.foreground font.family: root.fontFamily @@ -946,6 +949,7 @@ Panel { spacing: Style.space(1) Text { + textFormat: Text.PlainText Layout.fillWidth: true text: peerRow.peerName color: root.foreground @@ -955,6 +959,7 @@ Panel { } Text { + textFormat: Text.PlainText Layout.fillWidth: true text: { var parts = [] @@ -1087,6 +1092,7 @@ Panel { spacing: Style.space(10) Text { + textFormat: Text.PlainText Layout.fillWidth: true text: copyChoice.label color: root.foreground @@ -1134,6 +1140,7 @@ Panel { Text { id: exitNodeGlyph + textFormat: Text.PlainText text: exitNodeRow.addMullvad ? "+" : (peer && peer.Mullvad === true ? "󰖂" : "󱇢") color: exitNodeRow.activeExitNode || exitNodeRow.settingExitNode || exitNodeRow.addMullvad ? root.foreground : root.dim font.family: root.fontFamily @@ -1154,6 +1161,7 @@ Panel { } Text { + textFormat: Text.PlainText text: exitNodeRow.peerName color: root.foreground font.family: root.fontFamily @@ -1224,6 +1232,7 @@ Panel { spacing: Style.space(1) Text { + textFormat: Text.PlainText width: parent.width text: regionRow.regionName color: root.foreground @@ -1234,6 +1243,7 @@ Panel { } Text { + textFormat: Text.PlainText width: parent.width text: regionRow.regionDetail visible: text !== "" diff --git a/shell/plugins/panels/weather/Panel.qml b/shell/plugins/panels/weather/Panel.qml index dacb4ac9..edb12777 100644 --- a/shell/plugins/panels/weather/Panel.qml +++ b/shell/plugins/panels/weather/Panel.qml @@ -531,6 +531,7 @@ Panel { Text { id: heroIcon + textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter anchors.verticalCenterOffset: 5 text: root.label || "—" @@ -547,6 +548,7 @@ Panel { Text { id: tempBig + textFormat: Text.PlainText text: root.reportTempNum || "—" color: root.bar.foreground font.family: root.bar.fontFamily @@ -556,6 +558,7 @@ Panel { font.bold: true } Text { + textFormat: Text.PlainText text: root.current ? root.tempUnit : "" color: root.bar.foreground font.family: root.bar.fontFamily @@ -593,6 +596,7 @@ Panel { anchors.verticalCenter: parent.verticalCenter } Text { + textFormat: Text.PlainText text: (root.reportLocation || "").toUpperCase() color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily @@ -643,6 +647,7 @@ Panel { color: !root.savingLocation && clearLocationArea.containsMouse ? Style.hoverFillFor(root.bar.foreground, Color.accent) : "transparent" Text { + textFormat: Text.PlainText anchors.centerIn: parent text: root.savingLocation ? "󰦖" : "✕" font.family: root.bar.fontFamily @@ -683,6 +688,7 @@ Panel { font.letterSpacing: 1 } Text { + textFormat: Text.PlainText text: root.reportFeels color: root.bar.foreground font.family: root.bar.fontFamily @@ -700,6 +706,7 @@ Panel { font.letterSpacing: 1 } Text { + textFormat: Text.PlainText text: root.reportWind color: root.bar.foreground font.family: root.bar.fontFamily @@ -717,6 +724,7 @@ Panel { font.letterSpacing: 1 } Text { + textFormat: Text.PlainText text: root.reportHumidity color: root.bar.foreground font.family: root.bar.fontFamily @@ -752,12 +760,14 @@ Panel { spacing: Style.space(8) Text { + textFormat: Text.PlainText text: modelData.name color: index === root.suggestionIndex ? Style.hoverStateColor(root.bar.foreground, Color.accent) : root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.body } Text { + textFormat: Text.PlainText visible: text !== "" text: modelData.description color: Qt.darker(root.bar.foreground, 1.5) @@ -817,6 +827,7 @@ Panel { spacing: Style.space(10) Text { + textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter text: root.dayIcon(modelData) color: root.bar.foreground @@ -829,6 +840,7 @@ Panel { spacing: Style.space(2) Text { + textFormat: Text.PlainText text: root.dayName(modelData.date).toUpperCase() color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily @@ -840,12 +852,14 @@ Panel { spacing: Style.space(6) Text { + textFormat: Text.PlainText text: root.bareTempForDay(modelData, "max") color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.body } Text { + textFormat: Text.PlainText text: root.bareTempForDay(modelData, "min") color: Qt.darker(root.bar.foreground, 1.5) font.family: root.bar.fontFamily diff --git a/shell/plugins/panels/wifiqr/Panel.qml b/shell/plugins/panels/wifiqr/Panel.qml index 276434a8..1426b5fd 100644 --- a/shell/plugins/panels/wifiqr/Panel.qml +++ b/shell/plugins/panels/wifiqr/Panel.qml @@ -257,6 +257,7 @@ Item { spacing: Style.space(16) Text { + textFormat: Text.PlainText text: (root.ssid || "Wi-Fi").toUpperCase() color: root.onScrimDim font.family: root.fontFamily @@ -318,6 +319,7 @@ Item { } Text { + textFormat: Text.PlainText visible: root.error !== "" text: root.error color: root.onScrimUrgent @@ -340,6 +342,7 @@ Item { } Text { + textFormat: Text.PlainText visible: root.showingQr && root.secured text: root.passwordError !== "" ? root.passwordError : root.passwordVisible ? root.password diff --git a/shell/plugins/polkit/PolkitAgent.qml b/shell/plugins/polkit/PolkitAgent.qml index 8ce95973..8786eeeb 100644 --- a/shell/plugins/polkit/PolkitAgent.qml +++ b/shell/plugins/polkit/PolkitAgent.qml @@ -332,6 +332,7 @@ Item { } Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -374,6 +375,7 @@ Item { Text { id: justificationText + textFormat: Text.PlainText anchors.fill: parent anchors.leftMargin: Style.space(12) anchors.rightMargin: Style.space(12) diff --git a/shell/plugins/reminders/ReminderFlow.qml b/shell/plugins/reminders/ReminderFlow.qml index bc6616db..fef95cf0 100644 --- a/shell/plugins/reminders/ReminderFlow.qml +++ b/shell/plugins/reminders/ReminderFlow.qml @@ -156,6 +156,7 @@ Item { anchors.leftMargin: card.contentLeftInset Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter diff --git a/shell/plugins/services/media/BarWidget.qml b/shell/plugins/services/media/BarWidget.qml index 52793c16..02650efc 100644 --- a/shell/plugins/services/media/BarWidget.qml +++ b/shell/plugins/services/media/BarWidget.qml @@ -32,6 +32,7 @@ BarWidget { Text { id: glyph + textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter text: root.playIcon color: activePlayer && activePlayer.isPlaying ? root.bar.barForeground : Qt.darker(root.bar.barForeground, 1.5) @@ -53,6 +54,7 @@ BarWidget { Text { id: labelText + textFormat: Text.PlainText text: root.title + (root.artist ? " · " + root.artist : "") color: root.bar.barForeground font.family: root.bar.fontFamily @@ -148,6 +150,7 @@ BarWidget { width: parent.width - Style.space(74) Text { + textFormat: Text.PlainText text: root.title || "Nothing playing" color: root.bar.foreground font.family: root.bar.fontFamily @@ -158,6 +161,7 @@ BarWidget { } Text { + textFormat: Text.PlainText text: root.artist color: Qt.darker(root.bar.foreground, 1.3) font.family: root.bar.fontFamily @@ -168,6 +172,7 @@ BarWidget { } Text { + textFormat: Text.PlainText text: root.activePlayer && root.activePlayer.trackAlbum ? root.activePlayer.trackAlbum : "" color: Qt.darker(root.bar.foreground, 1.6) font.family: root.bar.fontFamily @@ -255,6 +260,7 @@ BarWidget { spacing: Style.space(8) Text { + textFormat: Text.PlainText text: sourceRow.player && sourceRow.player.isPlaying ? "󰏤" : "󰐊" color: root.bar.foreground font.family: root.bar.fontFamily @@ -270,6 +276,7 @@ BarWidget { anchors.verticalCenter: parent.verticalCenter Text { + textFormat: Text.PlainText text: sourceRow.sourceTitle color: root.bar.foreground font.family: root.bar.fontFamily @@ -280,6 +287,7 @@ BarWidget { } Text { + textFormat: Text.PlainText text: sourceRow.sourceDetail color: Qt.darker(root.bar.foreground, 1.5) font.family: root.bar.fontFamily diff --git a/test/shell.d/notifications-test.sh b/test/shell.d/notifications-test.sh index 58a4e32a..754dd29e 100644 --- a/test/shell.d/notifications-test.sh +++ b/test/shell.d/notifications-test.sh @@ -18,6 +18,40 @@ assertEqual( 'notifications strip inline image tags' ) +// The body renders as StyledText, which fetches over the network, so +// the strip has to survive a payload built to outlive one replace() pass. A +// single left-to-right pass consumes the inner tag and lets the outer halves +// close up into a live tag: . +assertEqual( + notifications.sanitizeBody('g src="http://host/beacon.png">', 'Slack', ''), + '', + 'notifications strip image tags that reassemble after one substitution' +) + +assertEqual( + notifications.sanitizeBody('g src=b>g src="http://host/deep.png">', 'Slack', ''), + '', + 'notifications strip nested image tags to a fixed point' +) + +assertEqual( + notifications.sanitizeBody('trailing shout', 'Slack', ''), + 'shout', + 'notifications strip image tags regardless of case' +) + +assertEqual( + notifications.sanitizeBody('bold and link', 'Slack', ''), + 'bold and link', + 'notifications keep the body markup the body-markup capability advertises' +) + assertEqual( notifications.sanitizeBody('example.com Message body', 'Chromium', ''), 'Message body', diff --git a/test/shell.d/qml-text-format-test.sh b/test/shell.d/qml-text-format-test.sh new file mode 100755 index 00000000..ff088969 --- /dev/null +++ b/test/shell.d/qml-text-format-test.sh @@ -0,0 +1,144 @@ +#!/bin/bash + +# A QML Text element with no textFormat uses Text.AutoText. Qt then runs +# mightBeRichText() over the string and promotes it to Text.RichText when it +# looks like markup, and RichText fetches through +# QQuickPixmap. Any string that reaches such an element from outside the shell +# — a notification summary, an MPRIS track title, a window title, an SSID, a +# Bluetooth device name, clipboard content, a weather API response — can +# therefore make the shell issue an unauthenticated outbound GET with no user +# interaction. +# +# The promotion needs only that the attacker contribute the first `<` in the +# string, on the first line. A fixed label in front of the value does not +# protect it, and neither does .toUpperCase(), because the parser lowercases +# the tag before looking it up. +# +# So require an explicit textFormat on every Text whose text: binding is not a +# bare string literal. A literal carries no external data, so AutoText has +# nothing to promote; this test is what catches the edit that later turns such +# a literal into an expression. + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +require_command python3 + +violations=$(ROOT="$ROOT" python3 <<'PY' +import os +import re +from pathlib import Path + +OPEN_ELEMENT = re.compile(r'(?:^|[:\s])([A-Z][A-Za-z0-9_.]*)\s*\{\s*$') +PROP = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_.]*)\s*:') +STRING_LITERAL = re.compile(r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'') +PROPERTY_DECL = re.compile(r'^\s*(?:readonly\s+)?property\b') +ROOT_TEXT = re.compile(r'^Text\s*\{\s*$') + + +def strip_noise(line, keep_strings=False): + out = [] + i = 0 + quote = None + while i < len(line): + c = line[i] + if quote: + if keep_strings: + out.append(c) + if c == '\\': + if keep_strings and i + 1 < len(line): + out.append(line[i + 1]) + i += 2 + continue + if c == quote: + quote = None + if not keep_strings: + out.append('S') + i += 1 + continue + if c in '"\'': + quote = c + if keep_strings: + out.append(c) + i += 1 + continue + if c == '/' and i + 1 < len(line) and line[i + 1] == '/': + break + out.append(c) + i += 1 + return ''.join(out) + + +def is_pure_literal(expr): + residue = STRING_LITERAL.sub('', expr) + residue = re.sub(r'[\s+]', '', residue) + return residue == '' and STRING_LITERAL.search(expr) is not None + + +def blocks(lines): + stack = [] + done = [] + depth = 0 + for idx, raw in enumerate(lines): + code = strip_noise(raw) + opened = OPEN_ELEMENT.search(code) + prop = PROP.match(code) + if (prop and stack and stack[-1]['depth'] == depth + and not opened and not PROPERTY_DECL.match(code)): + stack[-1]['props'].setdefault(prop.group(1), idx) + n_open = code.count('{') + n_close = code.count('}') + if opened and n_open > 0: + depth += 1 + stack.append({'name': opened.group(1), 'depth': depth, + 'props': {}, 'start': idx}) + depth += n_open - 1 - n_close + else: + depth += n_open - n_close + while stack and depth < stack[-1]['depth']: + done.append(stack.pop()) + done.extend(stack) + return done + + +root = Path(os.environ['ROOT']) +found = [] +for path in sorted((root / 'shell').rglob('*.qml')): + lines = path.read_text().splitlines() + rel = path.relative_to(root) + + # A component whose root element is a Text takes its binding from callers, + # so the default has to be declared in the component itself. + if lines and any(ROOT_TEXT.match(l) for l in lines[:40]): + if not any(re.match(r'\s*textFormat\s*:', l) for l in lines): + found.append(f'{rel}: root Text element declares no textFormat') + + for b in blocks(lines): + if b['name'] != 'Text' or 'textFormat' in b['props']: + continue + if 'text' not in b['props']: + continue + tline = b['props']['text'] + expr = strip_noise(lines[tline], keep_strings=True).split(':', 1)[1] + if is_pure_literal(expr): + continue + found.append(f'{rel}:{tline + 1}: text binding without textFormat') + +for line in found: + print(line) +PY +) + +if [[ -n $violations ]]; then + count=$(printf '%s\n' "$violations" | wc -l) + fail "every Text with a dynamic text binding declares textFormat" \ + "$violations + +$count Text element(s) rely on Text.AutoText for a non-literal binding. +Add an explicit textFormat. Text.PlainText is right for anything that renders +data from outside the shell; use Text.StyledText only where markup is a +deliberate, documented feature, and strip before it reaches the renderer." +fi + +pass "every Text with a dynamic text binding declares textFormat" From 6e962b4466d245bc468352c83bafc9f60986653f Mon Sep 17 00:00:00 2001 From: Erik Melton Date: Wed, 26 Aug 2026 16:36:12 +0200 Subject: [PATCH 27/41] Address review comments: Enforce stricter tag handling and image sanitation in notifications. --- .../notifications/NotificationLogic.js | 58 +++++++++--- test/shell.d/notifications-test.sh | 65 ++++++++++--- test/shell.d/qml-text-format-test.sh | 94 ++++++++++++++++--- 3 files changed, 177 insertions(+), 40 deletions(-) diff --git a/shell/plugins/notifications/NotificationLogic.js b/shell/plugins/notifications/NotificationLogic.js index fc71a824..67b7bcca 100644 --- a/shell/plugins/notifications/NotificationLogic.js +++ b/shell/plugins/notifications/NotificationLogic.js @@ -5,30 +5,58 @@ function isChromiumDerived(app, appIcon) { source.indexOf("opera") >= 0 } +// True when a `<...>` run is an image tag, so the name is read the way Qt's +// parser reads it: after the `<` and an optional `/`, the leading run of +// letters and digits. +function isImageTag(tag) { + var name = /^<\/?\s*([A-Za-z0-9]+)/.exec(tag) + return !!name && name[1].toLowerCase() === "img" +} + // The body renders as StyledText so notifications can use the markup the // body-markup capability advertises (see Service.qml). StyledText honours // , and a remote src makes the shell issue an unauthenticated GET // with no user action, so image tags go before the renderer sees them. // -// One replace() pass is not enough. String.replace scans left to right once, -// so a payload spliced inside the literal "`, nested `<` and all — that is how Qt's parser bounds it — +// and only a tag whose own name is `img` is dropped. +// +// Deleting a substring is what makes a naive `/]*>/g` unsafe. Given // // g src="http://a/beacon.png"> -// -> // -// Repeat to a fixed point. Each pass can only shorten the string, so this -// terminates. +// Qt reads ONE malformed tag named `im` and renders nothing, but removing the +// inner match closes the surviving halves up into `` +// — a live tag the input never contained. The stripper would be manufacturing +// the very thing it exists to remove. +// +// Because every `<` opens a tag, the text between tags never contains one, so +// dropping a tag cannot splice its neighbours into a new one. That makes a +// single pass sufficient, with no re-scanning and no input bound to police. function stripImageTags(text) { - var current = text - var previous - do { - previous = current - // The `$` alternative catches a tag left unterminated at the end of the - // string, which the renderer closes for itself. - current = current.replace(/]*(?:>|$)/gi, "") - } while (current !== previous) - return current + var out = "" + var i = 0 + + while (i < text.length) { + var open = text.indexOf("<", i) + if (open === -1) { + out += text.slice(i) + break + } + + out += text.slice(i, open) + + // An unterminated tag at the end of the string still reaches the renderer, + // which closes it itself, so treat the remainder as one tag. + var close = text.indexOf(">", open) + var tag = close === -1 ? text.slice(open) : text.slice(open, close + 1) + + if (!isImageTag(tag)) out += tag + i = close === -1 ? text.length : close + 1 + } + + return out } function sanitizeBody(body, app, appIcon) { diff --git a/test/shell.d/notifications-test.sh b/test/shell.d/notifications-test.sh index 754dd29e..370598f6 100644 --- a/test/shell.d/notifications-test.sh +++ b/test/shell.d/notifications-test.sh @@ -18,20 +18,61 @@ assertEqual( 'notifications strip inline image tags' ) -// The body renders as StyledText, which fetches over the network, so -// the strip has to survive a payload built to outlive one replace() pass. A -// single left-to-right pass consumes the inner tag and lets the outer halves -// close up into a live tag: . -assertEqual( - notifications.sanitizeBody('g src="http://host/beacon.png">', 'Slack', ''), - '', - 'notifications strip image tags that reassemble after one substitution' +// The body renders as StyledText, which fetches over the network. The +// invariant that matters is not a particular output string but that no tag Qt +// would honour as an image survives, so assert that directly. Tags are bounded +// the way Qt bounds them: a `<` opens a tag that runs to the next `>`. +function survivingTagNames(text) { + const names = [] + let i = 0 + while (i < text.length) { + const open = text.indexOf('<', i) + if (open === -1) break + const close = text.indexOf('>', open) + const tag = close === -1 ? text.slice(open) : text.slice(open, close + 1) + const name = /^<\/?\s*([A-Za-z0-9]+)/.exec(tag) + if (name) names.push(name[1].toLowerCase()) + i = close === -1 ? text.length : close + 1 + } + return names +} + +function assertNoImageSurvives(body, description) { + const out = notifications.sanitizeBody(body, 'Slack', '') + const names = survivingTagNames(out) + assert( + !names.includes('img'), + description, + `input: ${body}\noutput: ${out}\ntags: ${JSON.stringify(names)}` + ) +} + +assertNoImageSurvives( + '', + 'notifications leave no image tag for a plain payload' ) -assertEqual( - notifications.sanitizeBody('g src=b>g src="http://host/deep.png">', 'Slack', ''), - '', - 'notifications strip nested image tags to a fixed point' +// A payload spliced inside the literal " the input never had. +assertNoImageSurvives( + 'g src="http://host/beacon.png">', + 'notifications leave no image tag when a payload is spliced inside g src=b>g src="http://host/deep.png">', + 'notifications leave no image tag for a doubly nested payload' +) + +assertNoImageSurvives( + '', + 'notifications leave no image tag when the outer tag is itself named img' +) + +assertNoImageSurvives( + '< img src="http://host/spaced.png">', + 'notifications leave no image tag when whitespace follows the angle bracket' ) assertEqual( diff --git a/test/shell.d/qml-text-format-test.sh b/test/shell.d/qml-text-format-test.sh index ff088969..2d7774f0 100755 --- a/test/shell.d/qml-text-format-test.sh +++ b/test/shell.d/qml-text-format-test.sh @@ -34,7 +34,10 @@ OPEN_ELEMENT = re.compile(r'(?:^|[:\s])([A-Z][A-Za-z0-9_.]*)\s*\{\s*$') PROP = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_.]*)\s*:') STRING_LITERAL = re.compile(r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'') PROPERTY_DECL = re.compile(r'^\s*(?:readonly\s+)?property\b') -ROOT_TEXT = re.compile(r'^Text\s*\{\s*$') +# A binding that runs onto the next line: this line ends on an operator, or the +# next line opens with one. +TRAILING_OPERATOR = re.compile(r'(?:&&|\|\||[?:+\-*/,(\[=&|])$') +LEADING_OPERATOR = re.compile(r'^\s*(?:&&|\|\||[?:+\-*/,)\]&|.])') def strip_noise(line, keep_strings=False): @@ -76,6 +79,40 @@ def is_pure_literal(expr): return residue == '' and STRING_LITERAL.search(expr) is not None +def binding_expression(lines, start): + """The whole right-hand side of the binding beginning on line `start`. + + The literal exemption has to be judged on the complete expression. Reading + only the physical `text:` line would exempt `text: "prefix"` while + `+ externalValue` sits underneath, letting a dynamic AutoText binding + through. Reading a wrapped concatenation of literals as dynamic would be + the opposite error, so follow the expression to its end either way. + """ + parts = [] + parens = brackets = 0 + i = start + while i < len(lines): + parts.append(strip_noise(lines[i], keep_strings=True)) + counted = strip_noise(lines[i]) + parens += counted.count('(') - counted.count(')') + brackets += counted.count('[') - counted.count(']') + following = strip_noise(lines[i + 1]) if i + 1 < len(lines) else '' + continues = (parens > 0 or brackets > 0 + or TRAILING_OPERATOR.search(counted.rstrip()) + or LEADING_OPERATOR.match(following)) + if not continues: + break + i += 1 + + chunk = ' '.join(parts) + return chunk.split(':', 1)[1] if ':' in chunk else chunk + + +def exempt_as_literal(lines, tline): + """True when the binding is only string literals, however many lines.""" + return is_pure_literal(binding_expression(lines, tline)) + + def blocks(lines): stack = [] done = [] @@ -89,39 +126,70 @@ def blocks(lines): stack[-1]['props'].setdefault(prop.group(1), idx) n_open = code.count('{') n_close = code.count('}') + depth += n_open - n_close if opened and n_open > 0: - depth += 1 + # OPEN_ELEMENT anchors at the end of the line, so the element it + # matched is the innermost one opened here and its depth is the + # depth after every brace on the line. stack.append({'name': opened.group(1), 'depth': depth, 'props': {}, 'start': idx}) - depth += n_open - 1 - n_close - else: - depth += n_open - n_close while stack and depth < stack[-1]['depth']: done.append(stack.pop()) done.extend(stack) return done +INLINE_TEXT = re.compile(r'(?:^|[:\s])Text\s*\{([^{}]*)\}') +INLINE_BINDING = re.compile(r'\btext\s*:\s*(.*?)\s*(?:;|$)') + + +def inline_violations(lines, rel): + """Whole Text blocks written on one line. + + OPEN_ELEMENT anchors at the end of the line, so the brace scanner never + sees these. A Repeater delegate is a plausible place for one. + """ + out = [] + for idx, raw in enumerate(lines): + code = strip_noise(raw, keep_strings=True) + for match in INLINE_TEXT.finditer(code): + body = match.group(1) + if 'textFormat' in body: + continue + binding = INLINE_BINDING.search(body) + if not binding or is_pure_literal(binding.group(1)): + continue + out.append(f'{rel}:{idx + 1}: inline Text block without textFormat') + return out + + root = Path(os.environ['ROOT']) found = [] for path in sorted((root / 'shell').rglob('*.qml')): lines = path.read_text().splitlines() rel = path.relative_to(root) - - # A component whose root element is a Text takes its binding from callers, - # so the default has to be declared in the component itself. - if lines and any(ROOT_TEXT.match(l) for l in lines[:40]): - if not any(re.match(r'\s*textFormat\s*:', l) for l in lines): - found.append(f'{rel}: root Text element declares no textFormat') + found.extend(inline_violations(lines, rel)) for b in blocks(lines): if b['name'] != 'Text' or 'textFormat' in b['props']: continue + + # Read the block's own properties. A nested child declaring textFormat + # says nothing about its parent, so `Text { Text { textFormat: ... } }` + # must still report the outer element. + # The root element of a component takes its binding from callers, so it + # needs the default whether or not this file binds `text`. Require both + # depth 1 and column 0: the scanner attributes one element per line, so + # a `Row { Text {` line would report depth 1 for a nested block, and + # falling through to the binding check below is the safe reading. + if b['depth'] == 1 and lines[b['start']].startswith('Text'): + found.append(f'{rel}:{b["start"] + 1}: root Text element declares no textFormat') + continue + if 'text' not in b['props']: continue tline = b['props']['text'] - expr = strip_noise(lines[tline], keep_strings=True).split(':', 1)[1] - if is_pure_literal(expr): + if exempt_as_literal(lines, tline): continue found.append(f'{rel}:{tline + 1}: text binding without textFormat') From e428dc26278d529f7754cda57918227d655027fa Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 26 Aug 2026 17:10:17 +0200 Subject: [PATCH 28/41] Strip image tags whose separator Qt skips but `\s` does not QQuickStyledText skips the characters between `<` and the tag name with QChar::isSpace(), which counts U+0085 NEL. JavaScript's `\s` does not, so isImageTag() read no name at all from a tag written as `<`, U+0085, `img`, kept it, and Qt then read `img` and issued the GET the stripper exists to prevent. Measured against Qt 6.11.2 with an offscreen StyledText and a local HTTP server. Read the name by skipping everything that is not part of it rather than by matching the separator, so the two definitions cannot drift apart again. Over-skipping is the safe direction: it can only classify more runs as images, and dropping a run never manufactures a tag. Co-Authored-By: Claude Opus 5 (1M context) --- .../notifications/NotificationLogic.js | 28 +++++++++++++++---- test/shell.d/notifications-test.sh | 28 +++++++++++++++++-- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/shell/plugins/notifications/NotificationLogic.js b/shell/plugins/notifications/NotificationLogic.js index 67b7bcca..b3f7be09 100644 --- a/shell/plugins/notifications/NotificationLogic.js +++ b/shell/plugins/notifications/NotificationLogic.js @@ -6,10 +6,21 @@ function isChromiumDerived(app, appIcon) { } // True when a `<...>` run is an image tag, so the name is read the way Qt's -// parser reads it: after the `<` and an optional `/`, the leading run of -// letters and digits. +// parser reads it: after the `<`, the leading run of letters and digits. +// +// Skip everything up to that run rather than matching the separator, because +// there is no JavaScript expression for what Qt skips. QQuickStyledText calls +// skipSpace(), which is QChar::isSpace(), and that set is not `\s`: Qt counts +// U+0085 NEL and `\s` does not, while `\s` counts U+FEFF and Qt does not. A +// name read with `\s` therefore misses a tag written as `<`, U+0085, `img`: +// Qt skips the NEL, reads `img` and issues the GET, while the regex finds no +// name at all and the tag is kept. Measured against Qt 6.11.2. +// +// Over-skipping is the safe direction. It can only classify more runs as +// images, and dropping a run never manufactures a tag: a dropped run joins two +// stretches of text that each contain no `<`. function isImageTag(tag) { - var name = /^<\/?\s*([A-Za-z0-9]+)/.exec(tag) + var name = /^<[^A-Za-z0-9]*([A-Za-z0-9]+)/.exec(tag) return !!name && name[1].toLowerCase() === "img" } @@ -19,8 +30,15 @@ function isImageTag(tag) { // with no user action, so image tags go before the renderer sees them. // // Work in whole tags, never in substrings of one. A `<` opens a tag that runs -// to the next `>`, nested `<` and all — that is how Qt's parser bounds it — -// and only a tag whose own name is `img` is dropped. +// to the next `>`, nested `<` and all, and only a tag whose own name is `img` +// is dropped. +// +// That is the conservative bound, not Qt's exact one: Qt lets a `>` inside a +// quoted attribute value pass without closing the tag, so a Qt tag can be +// longer than the run taken here. Do not "correct" this to match Qt. Taking +// the shorter run only ever splits one Qt tag into several, and a split can +// only expose an `` through. // // Deleting a substring is what makes a naive `/]*>/g` unsafe. Given // diff --git a/test/shell.d/notifications-test.sh b/test/shell.d/notifications-test.sh index 370598f6..6dfc54a8 100644 --- a/test/shell.d/notifications-test.sh +++ b/test/shell.d/notifications-test.sh @@ -21,7 +21,10 @@ assertEqual( // The body renders as StyledText, which fetches over the network. The // invariant that matters is not a particular output string but that no tag Qt // would honour as an image survives, so assert that directly. Tags are bounded -// the way Qt bounds them: a `<` opens a tag that runs to the next `>`. +// the conservative way the stripper bounds them: a `<` opens a tag that runs to +// the next `>`. Qt's own bound can be longer, since a `>` inside a quoted +// attribute value does not close a tag there — which only ever splits one Qt +// tag into several here, so a name this helper reads is a name Qt reads too. function survivingTagNames(text) { const names = [] let i = 0 @@ -30,7 +33,11 @@ function survivingTagNames(text) { if (open === -1) break const close = text.indexOf('>', open) const tag = close === -1 ? text.slice(open) : text.slice(open, close + 1) - const name = /^<\/?\s*([A-Za-z0-9]+)/.exec(tag) + // Read the name the way Qt does, skipping anything that is not part of it. + // Matching the separator with \s instead would give this helper the same + // blind spot as the code it is checking — Qt skips U+0085 and \s does not — + // and an assertion that shares the implementation's bug proves nothing. + const name = /^<[^A-Za-z0-9]*([A-Za-z0-9]+)/.exec(tag) if (name) names.push(name[1].toLowerCase()) i = close === -1 ? text.length : close + 1 } @@ -75,6 +82,23 @@ assertNoImageSurvives( 'notifications leave no image tag when whitespace follows the angle bracket' ) +// Qt skips the separator between `<` and the tag name with QChar::isSpace(), +// which counts U+0085 NEL. JavaScript's \s does not. Reading the name with \s +// finds none here, keeps the tag, and Qt then reads `img` and fetches it — +// measured against Qt 6.11.2, where this exact body makes a StyledText Text +// issue an outbound GET. Asserted on the whole output rather than through +// assertNoImageSurvives so it holds even if that helper is ever loosened. +assertEqual( + notifications.sanitizeBody('<\u0085img src="http://host/nel.png">after', 'Slack', ''), + 'after', + 'notifications strip an image tag whose separator is U+0085, which Qt skips but \\s does not' +) + +assertNoImageSurvives( + '<\u0085img src="http://host/nel2.png">', + 'notifications leave no image tag when U+0085 follows the angle bracket' +) + assertEqual( notifications.sanitizeBody('trailing Date: Wed, 26 Aug 2026 17:10:30 +0200 Subject: [PATCH 29/41] Stop the textFormat test from passing when it has not checked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root rule matched only a file-level root Text, of which this tree has exactly one. QML inline components are roots for the same reason — the `text` of `component InfoValue: Text {` comes from every caller, so the file it lives in never binds it — but they sit inside another element, so the depth-1 test never saw them. Six went uncovered while the test reported green, among them the network panel's InfoValue, which callers bind to the IP address and gateway. Six more ways to write a Text were read as clean rather than as unreadable: an opening brace that is not last on its line, a brace on the line after `Text`, a one-line block containing nested braces, a wrapped binding split by a comment or a blank line before its `+` (which exempted a dynamic binding as a literal), and a root Text indented from column zero. Require the forms a line scanner can read instead of parsing QML; the tree already writes every Text that way. Last, a run that read no files reported success. A checkout with no shell/ QML now fails instead, since an all-clear from a scan that opened nothing is the one answer this test must never give. Each case is covered by a fixture that fails without its fix. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: OpenAI Codex (gpt-5, xhigh) --- shell/plugins/panels/dropbox/Panel.qml | 2 + shell/plugins/panels/network/Panel.qml | 2 + shell/plugins/panels/power/Panel.qml | 2 + test/shell.d/qml-text-format-test.sh | 105 ++++++++++++++++++++++++- 4 files changed, 109 insertions(+), 2 deletions(-) diff --git a/shell/plugins/panels/dropbox/Panel.qml b/shell/plugins/panels/dropbox/Panel.qml index b470cee2..515b43ce 100644 --- a/shell/plugins/panels/dropbox/Panel.qml +++ b/shell/plugins/panels/dropbox/Panel.qml @@ -530,6 +530,7 @@ Panel { } component InfoLabel: Text { + textFormat: Text.PlainText color: root.foreground opacity: 0.6 font.family: root.fontFamily @@ -537,6 +538,7 @@ Panel { } component InfoValue: Text { + textFormat: Text.PlainText color: root.foreground font.family: root.fontFamily font.pixelSize: Style.font.bodySmall diff --git a/shell/plugins/panels/network/Panel.qml b/shell/plugins/panels/network/Panel.qml index c4da0afe..d1e41149 100644 --- a/shell/plugins/panels/network/Panel.qml +++ b/shell/plugins/panels/network/Panel.qml @@ -1954,6 +1954,7 @@ Panel { } component InfoLabel: Text { + textFormat: Text.PlainText color: root.bar.foreground opacity: 0.6 font.family: root.bar.fontFamily @@ -1961,6 +1962,7 @@ Panel { } component InfoValue: Text { + textFormat: Text.PlainText color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.bodySmall diff --git a/shell/plugins/panels/power/Panel.qml b/shell/plugins/panels/power/Panel.qml index 7733bb37..b1c34da2 100644 --- a/shell/plugins/panels/power/Panel.qml +++ b/shell/plugins/panels/power/Panel.qml @@ -520,6 +520,7 @@ Panel { } component InfoLabel: Text { + textFormat: Text.PlainText color: root.bar.foreground opacity: 0.6 font.family: root.bar.fontFamily @@ -527,6 +528,7 @@ Panel { } component InfoValue: Text { + textFormat: Text.PlainText color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.bodySmall diff --git a/test/shell.d/qml-text-format-test.sh b/test/shell.d/qml-text-format-test.sh index 2d7774f0..0b286a76 100755 --- a/test/shell.d/qml-text-format-test.sh +++ b/test/shell.d/qml-text-format-test.sh @@ -31,6 +31,8 @@ import re from pathlib import Path OPEN_ELEMENT = re.compile(r'(?:^|[:\s])([A-Z][A-Za-z0-9_.]*)\s*\{\s*$') +INLINE_COMPONENT = re.compile(r'^\s*component\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*Text\s*\{\s*$') +INLINE_COMPONENT_ONELINE = re.compile(r'^\s*component\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*Text\s*\{') PROP = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_.]*)\s*:') STRING_LITERAL = re.compile(r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'') PROPERTY_DECL = re.compile(r'^\s*(?:readonly\s+)?property\b') @@ -96,7 +98,17 @@ def binding_expression(lines, start): counted = strip_noise(lines[i]) parens += counted.count('(') - counted.count(')') brackets += counted.count('[') - counted.count(']') - following = strip_noise(lines[i + 1]) if i + 1 < len(lines) else '' + # Look past blank and comment-only lines for the continuation. A + # comment or a blank line dropped into a wrapped expression does not + # end it, and stopping there would read `text: "prefix"` as the whole + # binding and exempt it as a literal while `+ externalValue` waits + # below — the exact misreading this function exists to prevent. + following = '' + for ahead in range(i + 1, len(lines)): + candidate = strip_noise(lines[ahead]) + if candidate.strip(): + following = candidate + break continues = (parens > 0 or brackets > 0 or TRAILING_OPERATOR.search(counted.rstrip()) or LEADING_OPERATOR.match(following)) @@ -156,6 +168,12 @@ def inline_violations(lines, rel): body = match.group(1) if 'textFormat' in body: continue + # A component root written on one line needs the default whether or + # not this line binds `text`, for the same reason the block form + # does: every caller supplies the binding. + if INLINE_COMPONENT_ONELINE.match(code): + out.append(f'{rel}:{idx + 1}: inline component root Text declares no textFormat') + continue binding = INLINE_BINDING.search(body) if not binding or is_pure_literal(binding.group(1)): continue @@ -163,12 +181,75 @@ def inline_violations(lines, rel): return out +# `Text { text: someValue` with the block carrying on below is valid QML and is +# invisible to both scanners: OPEN_ELEMENT anchors its `{` at the end of the +# line so the brace tracker never opens the block, and INLINE_TEXT needs the +# closing brace on the same line. A dynamic AutoText binding written that way +# passes this file in silence, which is the one failure a test like this must +# not have. +# +# Rather than teach a line scanner to parse QML, require the two forms it can +# read: the whole block on one line, or nothing after the opening brace. Every +# Text in this tree is already written that way, so keeping to it costs nothing. +UNSCANNABLE_TEXT = re.compile(r'(?:^|[:\s])Text\s*\{\s*\S') +BARE_TEXT_OPENER = re.compile(r'(?:^|[:\s])Text\s*$') + +UNSCANNABLE = ('Text block written in a form this scanner cannot read; put the ' + 'opening brace last on the line, or write the whole block on ' + 'one line with no nested braces') + + +def unscannable_violations(lines, rel): + out = [] + for idx, raw in enumerate(lines): + code = strip_noise(raw) + + # `Text` with its brace on the next line. OPEN_ELEMENT needs both on + # one line, so the block is never opened and everything in it is + # attributed to the enclosing element instead. + if BARE_TEXT_OPENER.search(code): + following = '' + for ahead in range(idx + 1, len(lines)): + candidate = strip_noise(lines[ahead]).strip() + if candidate: + following = candidate + break + if following.startswith('{'): + out.append(f'{rel}:{idx + 1}: {UNSCANNABLE}') + continue + + for match in UNSCANNABLE_TEXT.finditer(code): + # A complete one-line block with no nested braces is fine — + # inline_violations reads those. Count rather than looking for a + # `}`, because `Text { text: ({ a: external }).a }` closes on this + # line yet INLINE_TEXT's brace-free body pattern cannot match it, + # so treating any `}` as "handled elsewhere" would drop it. + rest = code[match.end() - 1:] + depth = 1 + closed = False + for char in rest: + if char == '{': + depth += 1 + elif char == '}': + depth -= 1 + if depth == 0: + closed = True + break + if closed and '{' not in rest: + continue + out.append(f'{rel}:{idx + 1}: {UNSCANNABLE}') + return out + + root = Path(os.environ['ROOT']) found = [] +scanned = 0 for path in sorted((root / 'shell').rglob('*.qml')): + scanned += 1 lines = path.read_text().splitlines() rel = path.relative_to(root) found.extend(inline_violations(lines, rel)) + found.extend(unscannable_violations(lines, rel)) for b in blocks(lines): if b['name'] != 'Text' or 'textFormat' in b['props']: @@ -182,10 +263,24 @@ for path in sorted((root / 'shell').rglob('*.qml')): # depth 1 and column 0: the scanner attributes one element per line, so # a `Row { Text {` line would report depth 1 for a nested block, and # falling through to the binding check below is the safe reading. - if b['depth'] == 1 and lines[b['start']].startswith('Text'): + # Indentation is not what makes it a root; depth 1 is. A `Row { Text {` + # line still reads as `Row` here, so leading whitespace can be ignored + # without letting a nested block be mistaken for the file's root. + if b['depth'] == 1 and lines[b['start']].lstrip().startswith('Text'): found.append(f'{rel}:{b["start"] + 1}: root Text element declares no textFormat') continue + # A QML inline component is a root for the same reason, and the rule + # above cannot see one: `component InfoValue: Text {` sits inside + # another element, so its depth is not 1 and its line does not start + # with `Text`. Its `text` comes from every caller, so the file it lives + # in never binds it and the binding check below lets it through in + # silence. Only one file-level root Text exists in this tree, so + # without this the root rule is very nearly dead code. + if INLINE_COMPONENT.match(lines[b['start']]): + found.append(f'{rel}:{b["start"] + 1}: inline component root Text declares no textFormat') + continue + if 'text' not in b['props']: continue tline = b['props']['text'] @@ -193,6 +288,12 @@ for path in sorted((root / 'shell').rglob('*.qml')): continue found.append(f'{rel}:{tline + 1}: text binding without textFormat') +# A scan that read nothing reports nothing, and an all-clear from a run that +# never opened a file is the one result this test must never give. Only a +# checkout with no shell/ QML at all reaches this. +if scanned == 0: + raise SystemExit('no .qml files found under shell/; the scan read nothing') + for line in found: print(line) PY From eb7ecd13f30a0455373725f45042f20c142a0c73 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:21:07 -0500 Subject: [PATCH 30/41] Keep Ori interactive when launched with a prompt (#8455) --- bin/omarchy-agent | 4 +++- test/shell.d/default-agent-test.sh | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/bin/omarchy-agent b/bin/omarchy-agent index 3c009460..3e3b7a5d 100755 --- a/bin/omarchy-agent +++ b/bin/omarchy-agent @@ -92,8 +92,10 @@ omp) ;; ori) # Ori is a harness launcher, and `ori code` is the agent it runs itself. + # A prompt alone means one headless turn there, printed after the turn ends, + # so --interactive is what seeds the session with it and keeps the window. command=(ori code) - [[ -n ${prompt:-} ]] && command+=(--prompt "$prompt") + [[ -n ${prompt:-} ]] && command+=(--interactive --prompt "$prompt") ;; pi) command=(pi) diff --git a/test/shell.d/default-agent-test.sh b/test/shell.d/default-agent-test.sh index 5b4512f1..db964d90 100644 --- a/test/shell.d/default-agent-test.sh +++ b/test/shell.d/default-agent-test.sh @@ -457,7 +457,7 @@ assert_bypass() { assert_launch pi pi "Review this project" assert_launch omp omp --auto-approve -- "Review this project" assert_launch opencode opencode --auto --prompt "Review this project" -assert_launch ori ori code --prompt "Review this project" +assert_launch ori ori code --interactive --prompt "Review this project" assert_launch claude claude --permission-mode auto -- "Review this project" assert_launch codex codex --approve-for-me -- "Review this project" assert_launch crush crush run "Review this project" From 8d14869689ae4772b1181d1012a37c7b0712cfc9 Mon Sep 17 00:00:00 2001 From: Omarchybot Date: Thu, 27 Aug 2026 10:28:11 +0200 Subject: [PATCH 31/41] Let a crash diagnosis mute that program's notifications A crash that is understood is not a crash that stops: an upstream bug waiting on a release, a program that dumps core every time it exits. The diagnosis explains it once and the toast keeps arriving, and the only answer Omarchy had was Crash Capture, which turns off every program's notifications in order to silence one. The watcher already resolves a name to dedupe on and announces that same name in the toast, so the mute is keyed on it: a flag file under toggles/crash-ignore/, written by the existing omarchy-toggle and read by the existing omarchy-toggle-enabled. One flag per name rather than one list, so `on` mutes, `off` un-mutes, and `ls -A` shows what is muted, with no new file format and nothing to parse. It is the executable's basename wherever one was recorded, falling back to the process name, which the kernel truncates to fifteen characters -- muting the truncated form would match nothing, forever, while looking like it worked. The name is not always a name, though, and the mute turns it into a path. A program picks its own comm and prctl takes anything, including slashes, and the watcher falls back to comm whenever a crash carries no absolute executable. So it is stripped to its last component first: without that, `a/../bar-off` is a legal comm aimed at an unrelated Omarchy flag, letting a crashing program suppress its own notification and letting a user who accepted the offered mute hide their bar instead. Stripping does not always leave a component either -- `/` leaves an empty string, which is no kind of array subscript and no kind of toast, and `.` or `..` names a directory that omarchy-toggle would touch and report success on, leaving a mute that never matches. Both fall back to `unknown`, the word omarchy-agent-crash already uses for a name it does not have, and which mutes like any other. The skill offers this at the end of a diagnosis and never runs it unprompted, which makes it the single change a diagnosis may make to a system it otherwise only reads. It tells the agent to use the name it was handed rather than re-derive one, since the watcher resolved that name already and the two agree for ordinary names and not for strange ones; a diagnosis started by hand from `omarchy agent crash ` is given no name and gets the derivation instead. It also says to treat the name as hostile text rather than as a word -- it is whatever the crashed program's author called a file, so a single quote inside one closes the quotes around it and the rest runs as the shell -- and to check the flag arrived rather than assume it. Co-Authored-By: Codex XHigh --- bin/omarchy-crash-watch | 17 ++ default/agents/skills/diagnose-crash/SKILL.md | 55 ++++- manual/17-ai.md | 2 + test/shell.d/crash-capture-test.sh | 188 ++++++++++++++++++ 4 files changed, 261 insertions(+), 1 deletion(-) diff --git a/bin/omarchy-crash-watch b/bin/omarchy-crash-watch index ee1ed82d..09dd32d3 100755 --- a/bin/omarchy-crash-watch +++ b/bin/omarchy-crash-watch @@ -71,11 +71,28 @@ journalctl -f -n 0 -o json "MESSAGE_ID=$COREDUMP_MESSAGE_ID" 2>/dev/null | name=$comm [[ $exe == /* ]] && name=${exe##*/} + # A process can set its own comm to anything prctl takes, slashes included, + # and a crash with no recorded executable falls back to it. The mute below + # turns this name into a path, so keep it one component: a crash must not + # reach a flag outside crash-ignore/, nor have a diagnosis write one there. + name=${name##*/} + + # What that leaves is not always a name. "/" leaves nothing, which is no + # kind of array subscript and no kind of toast, and a dot component names a + # directory rather than a flag, so a mute on it would touch that directory + # and then never match. + [[ -n $name && $name != "." && $name != ".." ]] || name=unknown + [[ -n $ignore_pattern && $name =~ $ignore_pattern ]] && continue # Never announce our own machinery, or it notifies about itself. [[ $name == omarchy-crash-* || $name == omarchy-agent-* ]] && continue + # Muted at the end of a diagnosis, when the user was offered it and said + # yes. A flag per program rather than one list, so it un-mutes with + # `omarchy-toggle crash-ignore/ off` and reads with `ls -A`. + omarchy-toggle-enabled "crash-ignore/$name" && continue + now=$EPOCHSECONDS (((now - ${last_notified[$name]:-0}) < dedupe_seconds)) && continue diff --git a/default/agents/skills/diagnose-crash/SKILL.md b/default/agents/skills/diagnose-crash/SKILL.md index 7859c6ec..7faadb1f 100644 --- a/default/agents/skills/diagnose-crash/SKILL.md +++ b/default/agents/skills/diagnose-crash/SKILL.md @@ -87,7 +87,60 @@ ambiguous, say so rather than assembling confidence out of guesswork. **Leave the system as you found it.** Diagnosis reads; it does not fix, tidy, or reconfigure. The one thing to clean up is your own: delete the core you extracted -above, which is a copy of the crashed process's memory. +above, which is a copy of the crashed process's memory. The single change a +diagnosis may make is the mute below, and only when the user asks for it. + +## Offer to stop the notifications for this program + +A crash that is now understood keeps announcing itself, and understanding it +rarely stops it happening: an upstream bug waiting on a release, a program that +dumps core every time it exits, a driver that misbehaves on this hardware. Finish +by offering to silence crash notifications for **that one program**: + +```bash +omarchy-toggle 'crash-ignore/' on +``` + +`` is the `process:` name in the crash facts, verbatim. The watcher works +that name out and then announces it, so what you were handed is already the exact +string the mute is keyed on — do not re-derive it from `coredumpctl` when you were +given it, because the two agree for ordinary names and not for strange ones. + +A diagnosis started by hand from `omarchy agent crash ` is given no name, so +there you do have to work it out the way the watcher does: the executable's +basename when an absolute `Executable:` was recorded, otherwise the process name +with everything up to the last `/` dropped, and `unknown` when that leaves +nothing, `.` or `..`. Prefer the executable — the kernel truncates the process +name to 15 characters and does not truncate the basename, so a mute on the +truncated one matches nothing, forever, while looking like it worked. + +The name is whatever the crashed program's author chose to call a file, so handle +it as hostile text rather than as a word. Single quotes hold a space or a `$(...)`, +but a name containing a single quote closes them and the rest of it runs as your +shell — escape it, or the program that just crashed chooses the command. Then +check the flag actually arrived, which is also how you learn a name was too long +for the filesystem to keep: + +```bash +omarchy-toggle-enabled 'crash-ignore/' && echo muted +``` + +Offer it; never run it unprompted. The user may well want to keep being told. + +Say how to undo it in the same breath, so it is not a one-way door: the same +command with `off` un-mutes, and each mute is one file in +`~/.local/state/omarchy/toggles/crash-ignore/`, which `ls -A` lists — the +directory appears with the first mute, so before that there is nothing to list. + +The key is a bare name, so programs sharing one share a mute, and anything run +through an interpreter is keyed as the interpreter. Muting `python3.13` or `node` +silences every other Python or Node program on the machine, which is rarely what +the user means: say so rather than quietly doing it. + +This silences one program. Every other crash still notifies, and the muted +program still crashes — nothing here fixes anything, and a mute offered instead +of a fix that was within reach is the wrong answer. If the user wants crash +notifications off altogether, that is _Trigger > Toggle > Crash Capture_ instead. ## If it is an Omarchy bug diff --git a/manual/17-ai.md b/manual/17-ai.md index f5516b88..5588ef9b 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -39,6 +39,8 @@ Omarchy watches systemd-coredump for process crashes. When something segfaults, The watching is on by default. Turn it off under _Trigger > Toggle > Crash Capture_ (or with `omarchy toggle crash-capture`) and the notifications stop; `omarchy agent crash ` still works by hand. +Crashes can also be silenced one program at a time, which is what the diagnosis offers you at the end: `omarchy toggle 'crash-ignore/' on` stops the notifications for that program only, and the same command with `off` brings them back. Use `` exactly as the notification named it, in quotes, since a program name can carry spaces and punctuation your shell would otherwise read as its own. Each mute is a file in `~/.local/state/omarchy/toggles/crash-ignore/`, so `ls -A` there shows what you've muted once you've muted something. Everything else still notifies, and the muted program still crashes — this hides the reminder, it doesn't fix anything. + ### Desktop apps The _Install > AI_ menu also carries a couple of graphical AI apps: the ChatGPT desktop app, and Grok Bot for chatting with xAI's models. diff --git a/test/shell.d/crash-capture-test.sh b/test/shell.d/crash-capture-test.sh index d4119cd8..2f705310 100755 --- a/test/shell.d/crash-capture-test.sh +++ b/test/shell.d/crash-capture-test.sh @@ -54,6 +54,194 @@ grep -F 'omarchy-crash-watch.service' "$ROOT/install/user/first-run/enable-user- fail "crash capture is no longer on by default for new installs" pass "crash capture is on by default" +require_command jq + +# The per-program mute, driven through the real watcher with a stubbed journal: +# these prove what a person sees -- a toast arriving or not -- where asserting +# that a flag file was read would prove only that a flag file was read. +watch_bin="$TMPDIR/watch-bin" +watch_home="$TMPDIR/watch-home" +NOTIFY_LOG="$TMPDIR/notify-log" +JOURNAL_ENTRIES="$TMPDIR/journal-entries" + +mkdir -p "$watch_bin" "$watch_home" + +cat >"$watch_bin/journalctl" <<'SH' +#!/bin/bash +cat "$JOURNAL_ENTRIES" +SH + +cat >"$watch_bin/omarchy-default-agent" <<'SH' +#!/bin/bash +echo claude +SH + +cat >"$watch_bin/omarchy-notification-wait" <<'SH' +#!/bin/bash +exit 0 +SH + +cat >"$watch_bin/omarchy-notification-send" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >>"$NOTIFY_LOG" +SH + +chmod +x "$watch_bin/journalctl" "$watch_bin/omarchy-default-agent" \ + "$watch_bin/omarchy-notification-wait" "$watch_bin/omarchy-notification-send" + +reset_entries() { + : >"$JOURNAL_ENTRIES" +} + +# One core dump as systemd-coredump journals it. The UID must be this user's, or +# the watcher discards it as somebody else's crash before anything under test. +crash_entry() { + local comm="$1" exe="$2" + + jq -cn --arg uid "$UID" --arg comm "$comm" --arg exe "$exe" \ + '{_UID: $uid, COREDUMP_COMM: $comm, COREDUMP_PID: "4242", + COREDUMP_EXE: $exe, COREDUMP_SIGNAL_NAME: "SIGSEGV"}' >>"$JOURNAL_ENTRIES" +} + +# The stubbed journalctl ends after the entries, so the watcher's loop ends too. +# Its exit status is asserted rather than discarded: a watcher that dies on a +# muted crash notifies about nothing afterwards, which every assertion below +# that expects silence would otherwise read as success. +run_watch() { + local status=0 + + : >"$NOTIFY_LOG" + + PATH="$watch_bin:$ROOT/bin:$PATH" \ + JOURNAL_ENTRIES="$JOURNAL_ENTRIES" \ + NOTIFY_LOG="$NOTIFY_LOG" \ + HOME="$watch_home" \ + "$ROOT/bin/omarchy-crash-watch" || status=$? + + (( status == 0 )) || + fail "the watcher exited $status rather than carrying on, so a mute takes the service down with it" +} + +mute() { + HOME="$watch_home" "$ROOT/bin/omarchy-toggle" "crash-ignore/$1" "$2" +} + +announced() { + grep -Fq "Process crashed: $1" "$NOTIFY_LOG" +} + +reset_entries +crash_entry hyprland /usr/bin/hyprland +run_watch +announced hyprland || + fail "a crash nobody muted still announces itself" +pass "a crash nobody muted still announces itself" + +mute hyprland on +run_watch +! announced hyprland || + fail "muting a program stops the crash notifications the diagnosis offered to stop" +pass "muting a program stops its crash notifications" + +reset_entries +crash_entry nautilus /usr/bin/nautilus +run_watch +announced nautilus || + fail "muting one program silences every other program, which is the global toggle's job and not this one's" +pass "muting one program leaves every other program announcing" + +mute hyprland off +reset_entries +crash_entry hyprland /usr/bin/hyprland +run_watch +announced hyprland || + fail "un-muting a program brings its crash notifications back" +pass "un-muting a program brings its crash notifications back" + +# The diagnosis tells the user to mute the name the toast showed them, so the +# toast has to show the name the watcher checks. COMM is truncated to 15 +# characters and the executable's basename is not, and announcing the truncated +# one would leave a dutifully-followed mute matching nothing forever. +reset_entries +crash_entry chromium-browse /usr/lib/chromium/chromium-browser +run_watch +announced chromium-browser || + fail "the toast announces a name the mute cannot be keyed on, so following the diagnosis mutes nothing" +pass "the toast announces the name the mute is keyed on" + +mute chromium-browser on +run_watch +! announced chromium-browser || + fail "the mute is keyed on the name the notification announced, not on the truncated COMM" +pass "muting the announced name silences a program whose COMM was truncated" + +# A muted crash must not end the watcher. Restart=always would paper over it +# with a five-second gap, and the watcher restarts on `journalctl -n 0`, which +# never replays the crashes it missed while it was away. +reset_entries +crash_entry chromium-browse /usr/lib/chromium/chromium-browser +crash_entry nautilus /usr/bin/nautilus +run_watch +announced nautilus || + fail "a muted crash stops the watcher reading the journal, losing every crash after it" +pass "a muted crash does not stop the watcher reading the next one" + +# A process can set its own comm to anything prctl takes, slashes included, and +# a crash with no recorded executable falls back to it. A name that climbed out +# of crash-ignore/ would let a crashing program silence itself against an +# unrelated flag -- and have the diagnosis write one there on the user's behalf. +# The fixture carries two slashes so that dropping only the first is not mistaken +# for dropping all of them. +reset_entries +crash_entry a/../bar-off - +sibling_flag="$watch_home/.local/state/omarchy/toggles/bar-off" +touch "$sibling_flag" +run_watch +announced bar-off || + fail "a comm that climbs out of crash-ignore/ reads an unrelated toggle, letting a crash suppress its own notification" +pass "a comm that climbs out of crash-ignore/ cannot reach an unrelated toggle" +rm -f "$sibling_flag" + +# Stripping to the last component does not always leave a component. An empty +# name is no kind of array subscript and no kind of toast, and a dot component +# names a directory the mute would touch and then never match. +for empty_comm in / a/ . ..; do + reset_entries + crash_entry "$empty_comm" - + run_watch + announced unknown || + fail "a comm of '$empty_comm' leaves no usable name, so the toast cannot say what crashed and the mute has nothing to key on" +done +pass "a comm that strips down to nothing or a dot still announces under a name a mute can use" + +# Only "." and ".." are special. A leading dot is an ordinary filename, and +# folding those into the fallback would have one program's mute silence another. +for dotted_comm in .hidden ...; do + reset_entries + crash_entry "$dotted_comm" - + run_watch + announced "$dotted_comm" || + fail "'$dotted_comm' is an ordinary name, but it lands in the fallback, so muting it would silence unrelated crashes" +done +pass "a leading dot is an ordinary name rather than a special component" + +# And the name it settles on is mutable like any other. +mute unknown on +reset_entries +crash_entry / - +run_watch +! announced unknown || + fail "the fallback name cannot be muted, so the one crash most likely to repeat is the one that cannot be silenced" +pass "the fallback name can be muted like any other" +mute unknown off + +skill="$ROOT/default/agents/skills/diagnose-crash/SKILL.md" +grep -Fq 'crash-ignore/' "$skill" || + fail "the diagnosis no longer offers the mute under the name the watcher reads, so the two have drifted apart" +grep -Fq 'crash-ignore/$name' "$ROOT/bin/omarchy-crash-watch" || + fail "the watcher no longer reads the flag the diagnosis offers to write" +pass "the diagnosis and the watcher name the same flag" + run_node_test <<'JS' const fs = require('fs') const menu = requireFromRoot('shell/plugins/menu/MenuModel.js') From eeb4206c7b4a665e714fe8f5060c1647b63dad43 Mon Sep 17 00:00:00 2001 From: Omarchybot Date: Thu, 27 Aug 2026 10:38:18 +0200 Subject: [PATCH 32/41] Say in the manual what the skill already says about quoting The manual had single quotes covering "punctuation your shell would otherwise read as its own", which is more than they do: a name containing a single quote closes them, and the rest of it is read as shell. The skill states that correctly and the manual did not, so the one document a person reads before typing the command was the one making the claim that does not hold. --- manual/17-ai.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manual/17-ai.md b/manual/17-ai.md index 5588ef9b..86c99354 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -39,7 +39,7 @@ Omarchy watches systemd-coredump for process crashes. When something segfaults, The watching is on by default. Turn it off under _Trigger > Toggle > Crash Capture_ (or with `omarchy toggle crash-capture`) and the notifications stop; `omarchy agent crash ` still works by hand. -Crashes can also be silenced one program at a time, which is what the diagnosis offers you at the end: `omarchy toggle 'crash-ignore/' on` stops the notifications for that program only, and the same command with `off` brings them back. Use `` exactly as the notification named it, in quotes, since a program name can carry spaces and punctuation your shell would otherwise read as its own. Each mute is a file in `~/.local/state/omarchy/toggles/crash-ignore/`, so `ls -A` there shows what you've muted once you've muted something. Everything else still notifies, and the muted program still crashes — this hides the reminder, it doesn't fix anything. +Crashes can also be silenced one program at a time, which is what the diagnosis offers you at the end: `omarchy toggle 'crash-ignore/' on` stops the notifications for that program only, and the same command with `off` brings them back. Use `` exactly as the notification named it, in single quotes, which is what carries a name with a space in it; a name containing a quote character closes those quotes, so that one has to be escaped as well. Each mute is a file in `~/.local/state/omarchy/toggles/crash-ignore/`, so `ls -A` there shows what you've muted once you've muted something. Everything else still notifies, and the muted program still crashes — this hides the reminder, it doesn't fix anything. ### Desktop apps From ea6ee9440ab4be75b2c3b05527d61ea206a01ce1 Mon Sep 17 00:00:00 2001 From: Omarchybot Date: Thu, 27 Aug 2026 11:26:28 +0200 Subject: [PATCH 33/41] Add omarchy-crash-mute to mute and unmute one program The mute was reachable only as `omarchy-toggle crash-ignore/`, which asks whoever runs it to know the flag layout, to reduce a binary's path to the name the watcher keys on, and to have read the rule that a name climbing out of that directory writes an unrelated toggle. All of that was carried in the skill's prose, which is the wrong place for a rule that has to hold: prose is advice, and the thing being advised about is a name the crashed program chose. So it is a command now. `omarchy crash mute hyprland` silences that program, `off` lifts it, `toggle` flips it, and no argument lists what is muted. It takes the binary's path as readily as the name and reduces it the way the watcher does, so the `Executable:` line from `coredumpctl` can be handed straight to it; it refuses what is not one component of a name, so it cannot be talked into writing outside its own directory whatever it is given; and it re-reads the flag afterwards and reports what is now true rather than what was asked for. The listing counts only regular files, because that is all the watcher honours -- anything else in there would read as muted while the crashes kept arriving. A leading `--` is consumed so a program named `-h`, which the router would otherwise answer with its own help, can still be muted. The watcher gained an unrelated fix that this uncovered. Its fields are read with `IFS=$'\t'`, and tab is IFS whitespace, so an empty field collapsed into the next delimiter and shifted every field after it along one: a crash whose comm was empty had a path read as its pid and was discarded as somebody else's. A process can set its comm to nothing, so that was reachable. Empty fields now arrive as a dash like missing ones, and a dash joins the empty and dot cases that fall back to `unknown`. Co-Authored-By: Codex XHigh --- bin/omarchy | 1 + bin/omarchy-crash-mute | 73 +++++++++ bin/omarchy-crash-watch | 26 +-- default/agents/skills/diagnose-crash/SKILL.md | 53 +++--- manual/17-ai.md | 2 +- test/shell.d/crash-capture-test.sh | 152 +++++++++++++++++- 6 files changed, 264 insertions(+), 43 deletions(-) create mode 100755 bin/omarchy-crash-mute diff --git a/bin/omarchy b/bin/omarchy index 111d214a..4219109b 100755 --- a/bin/omarchy +++ b/bin/omarchy @@ -40,6 +40,7 @@ GROUP_DESCRIPTIONS[channel]="Omarchy release channel management" GROUP_DESCRIPTIONS[clipboard]="Clipboard helpers" GROUP_DESCRIPTIONS[cmd]="Command and shortcut helpers" GROUP_DESCRIPTIONS[config]="System configuration helpers" +GROUP_DESCRIPTIONS[crash]="Crash notification controls" GROUP_DESCRIPTIONS[debug]="Diagnostics and support logs" GROUP_DESCRIPTIONS[finalize]="Finalize user setup" GROUP_DESCRIPTIONS[default]="Default application selection" diff --git a/bin/omarchy-crash-mute b/bin/omarchy-crash-mute new file mode 100755 index 00000000..88229fea --- /dev/null +++ b/bin/omarchy-crash-mute @@ -0,0 +1,73 @@ +#!/bin/bash + +# omarchy:summary=Silence crash notifications for one program, or list what is silenced +# omarchy:args=[--] [] [on|off|toggle] +# omarchy:examples=omarchy crash mute | omarchy crash mute hyprland | omarchy crash mute /usr/bin/hyprland | omarchy crash mute hyprland off + +# The flag omarchy-crash-watch reads before announcing a crash. Muting is per +# program; Trigger > Toggle > Crash Capture is the switch for all of them. + +set -uo pipefail + +readonly MUTES="$HOME/.local/state/omarchy/toggles/crash-ignore" + +usage() { + echo "Usage: omarchy crash mute [--] [] [on|off|toggle]" >&2 +} + +# Only regular files, because that is all the watcher honours: anything else in +# there would be reported as muted while the crashes kept arriving. The dotted +# glob is for a program legitimately called .hidden, and `.` and `..` fail the +# same -f test that keeps them out. +list() { + local entry found=0 + + for entry in "$MUTES"/* "$MUTES"/.*; do + [[ -f $entry ]] || continue + printf '%s\n' "${entry##*/}" + found=1 + done + + ((found)) || echo "No programs muted. Crashes all notify." +} + +# A program may be named -h, and the router answers that with its own help +# before this ever runs. `omarchy crash mute -- -h` is the way through. +[[ ${1:-} == "--" ]] && shift + +if (($# == 0)); then + list + exit 0 +fi + +program=$1 +action=${2:-on} + +# The watcher keys the mute on the executable's basename, so accept the path it +# reports as readily as the name, and reduce either the same way it does. +program=${program##*/} + +if [[ -z $program || $program == "." || $program == ".." ]]; then + echo "Not a program name: $1" >&2 + usage + exit 1 +fi + +case "$action" in + on|off|toggle) ;; + *) + echo "Not an action: $action" >&2 + usage + exit 1 + ;; +esac + +omarchy-toggle "crash-ignore/$program" "$action" || exit 1 + +# Report what is now true rather than what was asked for: the flag is what the +# watcher reads, and a toggle does not say which way it went. +if omarchy-toggle-enabled "crash-ignore/$program"; then + echo "Muted crash notifications for $program." +else + echo "Crash notifications for $program are back on." +fi diff --git a/bin/omarchy-crash-watch b/bin/omarchy-crash-watch index 09dd32d3..d3e78d16 100755 --- a/bin/omarchy-crash-watch +++ b/bin/omarchy-crash-watch @@ -48,12 +48,17 @@ announce() { # -n 0 so a restart does not re-announce crashes already dealt with. journalctl -f -n 0 -o json "MESSAGE_ID=$COREDUMP_MESSAGE_ID" 2>/dev/null | while IFS= read -r entry; do + # A dash for a field that is empty as well as one that is missing: tab is + # IFS whitespace, so an empty field collapses into the next delimiter and + # every field after it shifts along one. A process can set its own comm to + # nothing, and that crash used to be read as somebody else's and dropped. IFS=$'\t' read -r uid comm pid exe signal < <( - jq -r '[(._UID // "-"), - (.COREDUMP_COMM // "-"), - (.COREDUMP_PID // "-"), - (.COREDUMP_EXE // "-"), - (.COREDUMP_SIGNAL_NAME // "-")] | @tsv' <<<"$entry" 2>/dev/null + jq -r 'def field: if . == null or . == "" then "-" else . end; + [(._UID | field), + (.COREDUMP_COMM | field), + (.COREDUMP_PID | field), + (.COREDUMP_EXE | field), + (.COREDUMP_SIGNAL_NAME | field)] | @tsv' <<<"$entry" 2>/dev/null ) [[ $pid =~ ^[0-9]+$ ]] || continue @@ -78,10 +83,11 @@ journalctl -f -n 0 -o json "MESSAGE_ID=$COREDUMP_MESSAGE_ID" 2>/dev/null | name=${name##*/} # What that leaves is not always a name. "/" leaves nothing, which is no - # kind of array subscript and no kind of toast, and a dot component names a + # kind of array subscript and no kind of toast; a dot component names a # directory rather than a flag, so a mute on it would touch that directory - # and then never match. - [[ -n $name && $name != "." && $name != ".." ]] || name=unknown + # and then never match; and a dash is what the read above puts there when + # the crash recorded no name at all. + [[ -n $name && $name != "-" && $name != "." && $name != ".." ]] || name=unknown [[ -n $ignore_pattern && $name =~ $ignore_pattern ]] && continue @@ -89,8 +95,8 @@ journalctl -f -n 0 -o json "MESSAGE_ID=$COREDUMP_MESSAGE_ID" 2>/dev/null | [[ $name == omarchy-crash-* || $name == omarchy-agent-* ]] && continue # Muted at the end of a diagnosis, when the user was offered it and said - # yes. A flag per program rather than one list, so it un-mutes with - # `omarchy-toggle crash-ignore/ off` and reads with `ls -A`. + # yes. A flag per program rather than one list, so omarchy-crash-mute can + # lift one without reading, rewriting and re-parsing the rest. omarchy-toggle-enabled "crash-ignore/$name" && continue now=$EPOCHSECONDS diff --git a/default/agents/skills/diagnose-crash/SKILL.md b/default/agents/skills/diagnose-crash/SKILL.md index 7faadb1f..3ae58493 100644 --- a/default/agents/skills/diagnose-crash/SKILL.md +++ b/default/agents/skills/diagnose-crash/SKILL.md @@ -98,39 +98,40 @@ dumps core every time it exits, a driver that misbehaves on this hardware. Finis by offering to silence crash notifications for **that one program**: ```bash -omarchy-toggle 'crash-ignore/' on +omarchy-crash-mute '' ``` -`` is the `process:` name in the crash facts, verbatim. The watcher works -that name out and then announces it, so what you were handed is already the exact -string the mute is keyed on — do not re-derive it from `coredumpctl` when you were -given it, because the two agree for ordinary names and not for strange ones. +`` is the `process:` name in the crash facts, or the `binary:` path — +the command reduces a path to the same name the watcher keys on, so passing +`/usr/lib/chromium/chromium-browser` and passing `chromium-browser` land on the +same flag. Prefer the binary's path wherever the crash recorded one: the kernel +truncates the process name to 15 characters and does not truncate a basename, and +a mute on the truncated form matches nothing, forever, while looking like it +worked. -A diagnosis started by hand from `omarchy agent crash ` is given no name, so -there you do have to work it out the way the watcher does: the executable's -basename when an absolute `Executable:` was recorded, otherwise the process name -with everything up to the last `/` dropped, and `unknown` when that leaves -nothing, `.` or `..`. Prefer the executable — the kernel truncates the process -name to 15 characters and does not truncate the basename, so a mute on the -truncated one matches nothing, forever, while looking like it worked. +That is also the answer for a diagnosis started by hand from `omarchy agent crash +`, which is handed no name at all: give the command the `Executable:` line +from `coredumpctl info` and let it do the reducing. Some crashes record no +executable — pass the process name then, and `unknown` where the crash has +neither, which is the name such a crash is announced under. -The name is whatever the crashed program's author chose to call a file, so handle -it as hostile text rather than as a word. Single quotes hold a space or a `$(...)`, -but a name containing a single quote closes them and the rest of it runs as your -shell — escape it, or the program that just crashed chooses the command. Then -check the flag actually arrived, which is also how you learn a name was too long -for the filesystem to keep: - -```bash -omarchy-toggle-enabled 'crash-ignore/' && echo muted -``` +The name is still whatever the crashed program's author chose to call a file, so +handle it as hostile text rather than as a word. The command refuses a name that +is not one — it cannot be talked into writing a flag outside its own directory — +but that is no help if the name reaches a shell unescaped first: single quotes +hold a space or a `$(...)`, and a name containing a single quote closes them and +runs the rest as your shell. Escape it, or the program that just crashed picks +the command. Offer it; never run it unprompted. The user may well want to keep being told. -Say how to undo it in the same breath, so it is not a one-way door: the same -command with `off` un-mutes, and each mute is one file in -`~/.local/state/omarchy/toggles/crash-ignore/`, which `ls -A` lists — the -directory appears with the first mute, so before that there is nothing to list. +Say how to undo it in the same breath, so it is not a one-way door — and the +command answers both halves itself: + +```bash +omarchy-crash-mute '' off # un-mute this one +omarchy-crash-mute # list what is muted +``` The key is a bare name, so programs sharing one share a mute, and anything run through an interpreter is keyed as the interpreter. Muting `python3.13` or `node` diff --git a/manual/17-ai.md b/manual/17-ai.md index 86c99354..57698f42 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -39,7 +39,7 @@ Omarchy watches systemd-coredump for process crashes. When something segfaults, The watching is on by default. Turn it off under _Trigger > Toggle > Crash Capture_ (or with `omarchy toggle crash-capture`) and the notifications stop; `omarchy agent crash ` still works by hand. -Crashes can also be silenced one program at a time, which is what the diagnosis offers you at the end: `omarchy toggle 'crash-ignore/' on` stops the notifications for that program only, and the same command with `off` brings them back. Use `` exactly as the notification named it, in single quotes, which is what carries a name with a space in it; a name containing a quote character closes those quotes, so that one has to be escaped as well. Each mute is a file in `~/.local/state/omarchy/toggles/crash-ignore/`, so `ls -A` there shows what you've muted once you've muted something. Everything else still notifies, and the muted program still crashes — this hides the reminder, it doesn't fix anything. +Crashes can also be silenced one program at a time, which is what the diagnosis offers you at the end. `omarchy crash mute hyprland` stops the notifications for that program only, `omarchy crash mute hyprland off` brings them back, and `omarchy crash mute` on its own lists what you've muted. It takes the binary's path as happily as its name, so `omarchy crash mute /usr/bin/hyprland` does the same thing. Quote a name with a space in it, as in `omarchy crash mute 'Some App'`. Everything else still notifies, and the muted program still crashes — this hides the reminder, it doesn't fix anything. ### Desktop apps diff --git a/test/shell.d/crash-capture-test.sh b/test/shell.d/crash-capture-test.sh index 2f705310..1b7e93d0 100755 --- a/test/shell.d/crash-capture-test.sh +++ b/test/shell.d/crash-capture-test.sh @@ -122,8 +122,12 @@ run_watch() { fail "the watcher exited $status rather than carrying on, so a mute takes the service down with it" } +# Through the real command rather than writing the flag by hand: these assertions +# are then the guard that the thing the diagnosis runs and the thing the watcher +# reads have not drifted apart. mute() { - HOME="$watch_home" "$ROOT/bin/omarchy-toggle" "crash-ignore/$1" "$2" + HOME="$watch_home" PATH="$ROOT/bin:$PATH" \ + "$ROOT/bin/omarchy-crash-mute" "$1" "$2" >/dev/null } announced() { @@ -214,6 +218,19 @@ for empty_comm in / a/ . ..; do done pass "a comm that strips down to nothing or a dot still announces under a name a mute can use" +# An empty comm is not a missing entry. Tab is IFS whitespace, so an empty field +# collapses and every field after it shifts along one -- the pid becomes a path, +# the crash reads as somebody else's, and it is dropped without a word. +reset_entries +crash_entry "" - +crash_entry nautilus /usr/bin/nautilus +run_watch +announced unknown || + fail "a crash whose comm is empty is dropped instead of announced, because the empty field shifted every field after it" +announced nautilus || + fail "an empty comm derails the rest of the journal entry" +pass "an empty comm is announced rather than parsed into the next field" + # Only "." and ".." are special. A leading dot is an ordinary filename, and # folding those into the fallback would have one program's mute silence another. for dotted_comm in .hidden ...; do @@ -235,12 +252,135 @@ run_watch pass "the fallback name can be muted like any other" mute unknown off +# What omarchy-crash-mute does on its own. That it agrees with the watcher is +# already covered above, which drives it for every mute it makes. +mute_home="$TMPDIR/mute-home" +mkdir -p "$mute_home" + +crash_mute() { + HOME="$mute_home" PATH="$ROOT/bin:$PATH" "$ROOT/bin/omarchy-crash-mute" "$@" +} + +mute_flag() { + [[ $1 == "--" ]] && shift + printf '%s' "$mute_home/.local/state/omarchy/toggles/crash-ignore/$1" +} + +crash_mute | grep -Fq "No programs muted" || + fail "an empty mute list prints nothing, so a user cannot tell it from a broken command" +pass "the command says so when nothing is muted" + +crash_mute hyprland >/dev/null +crash_mute | grep -Fqx hyprland || + fail "a muted program is missing from the list, so a mute cannot be found again to lift it" +pass "the command lists what it muted" + +# The watcher keys on the basename, so the command has to take the path a crash +# recorded and land on the same flag the watcher will look for. +crash_mute /usr/lib/chromium/chromium-browser >/dev/null +[[ -f $(mute_flag chromium-browser) ]] || + fail "a binary's path is muted verbatim rather than by name, so the watcher never sees that flag" +pass "the command reduces a path to the name the watcher checks" + +crash_mute hyprland off >/dev/null +[[ ! -f $(mute_flag hyprland) ]] || + fail "off leaves the program muted, making the mute a one-way door" +pass "the command un-mutes" + +# Muting is not flipping. The diagnosis offers this on a program the user may +# already have muted, and asking for a mute twice has to leave it muted. +crash_mute hyprland >/dev/null +crash_mute hyprland >/dev/null +[[ -f $(mute_flag hyprland) ]] || + fail "muting an already-muted program un-mutes it, so offering the mute a second time turns it back on" +pass "asking to mute twice leaves it muted" + +# A program may legitimately be called .hidden, and a mute nobody can see is a +# mute nobody can lift. +crash_mute .hidden >/dev/null +crash_mute | grep -Fqx .hidden || + fail "a mute on a dotted name is missing from the list, so it can never be found and lifted" +pass "the list shows a name that begins with a dot" + +# It turns what it is given into a path, so it has to refuse whatever is not one +# component of one. +for bad_name in . .. /; do + ! crash_mute "$bad_name" >/dev/null 2>&1 || + fail "'$bad_name' is taken as a program name, and the flag that writes is not one the watcher will ever read" +done +pass "the command refuses a name that is not a name" + +! crash_mute hyprland sideways >/dev/null 2>&1 || + fail "an action it does not know is treated as a mute, so a typo silences a program" +pass "the command refuses an action it does not know" + +# And says what it refused, or the user retypes the same thing. Captured rather +# than piped: the command exits non-zero here, which pipefail would surface as +# the pipeline's status and read as a failed assertion. +refusal=$(crash_mute hyprland sideways 2>&1) || true +grep -Fq "Not an action" <<<"$refusal" || + fail "an unknown action is refused without naming it, leaving the user nothing to correct" +pass "the command names the action it refused" + +crash_mute ../bar-off >/dev/null +[[ ! -e "$mute_home/.local/state/omarchy/toggles/bar-off" ]] || + fail "a name that climbs out writes a sibling toggle, so muting a crash could turn off the bar instead" +pass "the command cannot be talked into writing outside crash-ignore/" + +# A program may be called -h, and the router answers that with its own help +# before the command runs. A leading -- is the way through, so it has to be +# consumed rather than taken for the program name. +crash_mute -- -h >/dev/null 2>&1 || + fail "a leading -- is refused rather than consumed, so a program named like a flag cannot be muted at all" +[[ -f $(mute_flag -- -h) ]] || + fail "a leading -- is taken for the program name, so muting -h mutes something else" +pass "a leading -- lets a program named like a flag be muted" + +# toggle is advertised, so it has to flip both ways rather than quietly mute. +crash_mute toggler off >/dev/null +crash_mute toggler toggle >/dev/null +[[ -f $(mute_flag toggler) ]] || + fail "toggle does not mute an un-muted program" +crash_mute toggler toggle >/dev/null +[[ ! -f $(mute_flag toggler) ]] || + fail "toggle mutes but never un-mutes, so the advertised action only goes one way" +pass "toggle flips a mute both ways" + +# The listing means what the watcher means, and the watcher honours a regular +# file. Anything else in there is not a mute, however much it looks like one. +mkdir -p "$(mute_flag notactuallymuted)" +! crash_mute | grep -Fqx notactuallymuted || + fail "a directory is reported as muted while that program's crashes keep arriving" +pass "the listing counts only the flags the watcher honours" +rmdir "$(mute_flag notactuallymuted)" + +# A mute that could not be written must not be reported as one. Without this the +# command can print success for a flag that was never created. +failing_bin="$TMPDIR/failing-bin" +mkdir -p "$failing_bin" +cat >"$failing_bin/omarchy-toggle" <<'SH' +#!/bin/bash +exit 1 +SH +chmod +x "$failing_bin/omarchy-toggle" + +status=0 +refusal=$(HOME="$mute_home" PATH="$failing_bin:$ROOT/bin:$PATH" \ + "$ROOT/bin/omarchy-crash-mute" hyprland 2>&1) || status=$? +(( status != 0 )) || + fail "a mute that could not be written exits zero, so nothing downstream learns it failed" +! grep -Fq "Muted crash notifications" <<<"$refusal" || + fail "a mute that could not be written still reports success, so the user believes a program is silenced when it is not" +pass "a mute that could not be written is not reported as one" + skill="$ROOT/default/agents/skills/diagnose-crash/SKILL.md" -grep -Fq 'crash-ignore/' "$skill" || - fail "the diagnosis no longer offers the mute under the name the watcher reads, so the two have drifted apart" -grep -Fq 'crash-ignore/$name' "$ROOT/bin/omarchy-crash-watch" || - fail "the watcher no longer reads the flag the diagnosis offers to write" -pass "the diagnosis and the watcher name the same flag" +grep -Fq 'omarchy-crash-mute' "$skill" || + fail "the diagnosis no longer names the command that mutes, so the offer it makes cannot be carried out" +pass "the diagnosis names the command that mutes" + +grep -Fq 'GROUP_DESCRIPTIONS[crash]' "$ROOT/bin/omarchy" || + fail "the crash group has no description, so the router lists a group it cannot describe" +pass "the crash group is described in the router" run_node_test <<'JS' const fs = require('fs') From b68d4142d77c0ebedbadd7ed8b05ca1cf3e319b3 Mon Sep 17 00:00:00 2001 From: Omarchybot Date: Thu, 27 Aug 2026 12:22:54 +0200 Subject: [PATCH 34/41] Cut the crash-mute section of the skill to what it instructs The section had grown a paragraph per review round, each one explaining why the last was right, until one offer took a third of the file. Most of it was reassurance about what the command refuses rather than anything an agent has to do, and the command enforces that itself whatever the prose says. What is left is the instruction: offer it and never run it unprompted, say how to lift it, which of the two names to pass and why the binary is the better one, quote it because the name is the crashed program's to choose, and name the interpreter collision before muting python or node on someone's behalf. Fifty-four lines to thirty-two, with nothing dropped that changes what the agent does. --- default/agents/skills/diagnose-crash/SKILL.md | 65 ++++++------------- 1 file changed, 21 insertions(+), 44 deletions(-) diff --git a/default/agents/skills/diagnose-crash/SKILL.md b/default/agents/skills/diagnose-crash/SKILL.md index 3ae58493..ea773007 100644 --- a/default/agents/skills/diagnose-crash/SKILL.md +++ b/default/agents/skills/diagnose-crash/SKILL.md @@ -92,56 +92,33 @@ diagnosis may make is the mute below, and only when the user asks for it. ## Offer to stop the notifications for this program -A crash that is now understood keeps announcing itself, and understanding it -rarely stops it happening: an upstream bug waiting on a release, a program that -dumps core every time it exits, a driver that misbehaves on this hardware. Finish -by offering to silence crash notifications for **that one program**: +A crash you have explained often keeps happening anyway. Finish by offering to +silence notifications for **that one program**, and never run it unprompted. Say +how to lift it in the same breath, so it is not a one-way door. ```bash -omarchy-crash-mute '' -``` - -`` is the `process:` name in the crash facts, or the `binary:` path — -the command reduces a path to the same name the watcher keys on, so passing -`/usr/lib/chromium/chromium-browser` and passing `chromium-browser` land on the -same flag. Prefer the binary's path wherever the crash recorded one: the kernel -truncates the process name to 15 characters and does not truncate a basename, and -a mute on the truncated form matches nothing, forever, while looking like it -worked. - -That is also the answer for a diagnosis started by hand from `omarchy agent crash -`, which is handed no name at all: give the command the `Executable:` line -from `coredumpctl info` and let it do the reducing. Some crashes record no -executable — pass the process name then, and `unknown` where the crash has -neither, which is the name such a crash is announced under. - -The name is still whatever the crashed program's author chose to call a file, so -handle it as hostile text rather than as a word. The command refuses a name that -is not one — it cannot be talked into writing a flag outside its own directory — -but that is no help if the name reaches a shell unescaped first: single quotes -hold a space or a `$(...)`, and a name containing a single quote closes them and -runs the rest as your shell. Escape it, or the program that just crashed picks -the command. - -Offer it; never run it unprompted. The user may well want to keep being told. - -Say how to undo it in the same breath, so it is not a one-way door — and the -command answers both halves itself: - -```bash -omarchy-crash-mute '' off # un-mute this one +omarchy-crash-mute '' # silence it +omarchy-crash-mute '' off # let it speak again omarchy-crash-mute # list what is muted ``` -The key is a bare name, so programs sharing one share a mute, and anything run -through an interpreter is keyed as the interpreter. Muting `python3.13` or `node` -silences every other Python or Node program on the machine, which is rarely what -the user means: say so rather than quietly doing it. +Pass the `binary:` path from the crash facts, or the `process:` name where no +binary was recorded; the command reduces either to the name the watcher keys on. +A diagnosis run by hand from `omarchy agent crash ` has neither, so take +them from `coredumpctl info`. Prefer the binary: a process name is truncated to +15 characters and a basename is not, so muting the truncated form matches +nothing, forever, while looking like it worked. -This silences one program. Every other crash still notifies, and the muted -program still crashes — nothing here fixes anything, and a mute offered instead -of a fix that was within reach is the wrong answer. If the user wants crash -notifications off altogether, that is _Trigger > Toggle > Crash Capture_ instead. +Quote it. The name is whatever the crashed program's author called a file, and a +single quote inside one closes yours and runs the rest as your shell. + +The key is a bare name, so anything run through an interpreter is keyed as the +interpreter: muting `python3.13` silences every Python program on the machine. +Say so rather than quietly doing it. + +None of this fixes anything, and a mute offered in place of a fix that was within +reach is the wrong answer. For every program rather than one, the switch is +_Trigger > Toggle > Crash Capture_. ## If it is an Omarchy bug From 7026ede90b4c78dd3398f30b9611f72a63566121 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 27 Aug 2026 16:53:55 +0200 Subject: [PATCH 35/41] Strip image tags after the newline rewrite, not before it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card binds the body Text to styledBody, which rewrites newlines to
*after* sanitizeBody has run. That rewrite inserts tag syntax into text the stripper deliberately kept: a kept tag may hold a `<` of its own, and `` is one tag named `x` to both the stripper and Qt, so it survives whole — until the rewrite splits it into `` and a live image tag the input never contained. Measured against Qt 6.11.2 with an offscreen StyledText and a local HTTP server: that body issues the GET after this branch's sanitizer and issues nothing before it, because the one-pass /]*>/gi it replaces deleted the inner substring outright. The whole-tag bound is still the right trade — it is what stops the stripper manufacturing tags — but it only holds if nothing edits the string afterwards. So move the rewrite into NotificationLogic, next to the reasoning it depends on, and strip again after it. What Qt parses is then what was checked last. The tests assert on styledBody for the same reason, since sanitizeBody's output is no longer the string that reaches the renderer, and a regex assertion pins the card's binding because no JavaScript assertion can see a QML property. --- .../notifications/NotificationLogic.js | 13 +++++ .../components/NotificationCard.qml | 2 +- test/shell.d/notifications-test.sh | 48 ++++++++++++++++++- 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/shell/plugins/notifications/NotificationLogic.js b/shell/plugins/notifications/NotificationLogic.js index b3f7be09..b5a6f3ea 100644 --- a/shell/plugins/notifications/NotificationLogic.js +++ b/shell/plugins/notifications/NotificationLogic.js @@ -77,6 +77,18 @@ function stripImageTags(text) { return out } +// What the card renders, and the last thing to touch the string before Qt parses +// it. The newline rewrite belongs here rather than in the card because it inserts +// `
` into text stripImageTags chose to KEEP, and a kept tag may hold a `<` of +// its own: `` is one tag named `x` to both the +// stripper and Qt, until the rewrite splits it into `` and a live image tag +// the input never contained. Measured against Qt 6.11.2 — the rewritten form +// fetches, the original does not. So strip again after, and what Qt parses is what +// was checked last. +function styledBody(body, app, appIcon) { + return stripImageTags(sanitizeBody(body, app, appIcon).replace(/\r\n|\r|\n/g, "
")) +} + function sanitizeBody(body, app, appIcon) { var text = stripImageTags(String(body || "")) if (!isChromiumDerived(app, appIcon)) return text @@ -438,6 +450,7 @@ if (typeof module !== "undefined") { module.exports = { isChromiumDerived: isChromiumDerived, sanitizeBody: sanitizeBody, + styledBody: styledBody, summaryStartsWithGlyph: summaryStartsWithGlyph, shouldBypassDnd: shouldBypassDnd, isEphemeralApp: isEphemeralApp, diff --git a/shell/plugins/notifications/components/NotificationCard.qml b/shell/plugins/notifications/components/NotificationCard.qml index 1171ddc4..64e3870b 100644 --- a/shell/plugins/notifications/components/NotificationCard.qml +++ b/shell/plugins/notifications/components/NotificationCard.qml @@ -44,7 +44,7 @@ BorderSurface { readonly property bool singleLineToast: sanitizedBody.length === 0 readonly property bool collapseRedundantIcon: singleLineToast && !hasGlyph && summaryStartsWithGlyph readonly property string sanitizedBody: sanitizeBody(body) - readonly property string styledBody: sanitizedBody.replace(/\r\n|\r|\n/g, "
") + readonly property string styledBody: NotificationLogic.styledBody(body, app, appIcon) readonly property color dimColor: Qt.darker(Color.notifications.text, 1.4) readonly property color bodyColor: Qt.darker(Color.notifications.text, 1.15) diff --git a/test/shell.d/notifications-test.sh b/test/shell.d/notifications-test.sh index 6dfc54a8..f06e0481 100644 --- a/test/shell.d/notifications-test.sh +++ b/test/shell.d/notifications-test.sh @@ -44,8 +44,12 @@ function survivingTagNames(text) { return names } +// Assert on styledBody, not sanitizeBody: styledBody is the string the card +// binds to the StyledText, so it is the only one Qt ever parses. Checking the +// sanitizer's output instead would pass a body whose surviving tag the newline +// rewrite later splits open. function assertNoImageSurvives(body, description) { - const out = notifications.sanitizeBody(body, 'Slack', '') + const out = notifications.styledBody(body, 'Slack', '') const names = survivingTagNames(out) assert( !names.includes('img'), @@ -99,6 +103,48 @@ assertNoImageSurvives( 'notifications leave no image tag when U+0085 follows the angle bracket' ) +// The card rewrites newlines to
for the StyledText, which puts tag syntax +// inside a tag the stripper kept: `` is one tag named `x` +// to both the stripper and Qt, and the rewrite splits it into `` and a +// live image tag. Measured against Qt 6.11.2 — the rewritten form issues the GET +// and the original does not — so the strip has to run after the rewrite, which +// is what styledBody() does. +assertNoImageSurvives( + '', + 'notifications leave no image tag when a newline rewrite splits a kept tag' +) + +assertNoImageSurvives( + '', + 'notifications leave no image tag when a CRLF rewrite splits a kept tag' +) + +assertEqual( + notifications.styledBody('', 'Slack', ''), + '', + 'notifications drop the image half of a tag the newline rewrite splits' +) + +// The rewrite itself still happens, and body markup other than images survives it. +assertEqual( + notifications.styledBody('bold\nsecond line', 'Slack', ''), + 'bold
second line', + 'notifications keep body markup and the line break the card renders' +) + +// The order above is only worth anything if the card actually renders it, and no +// JavaScript assertion can see a QML binding. Pin the binding itself: the rewrite +// belongs in the logic module, where the strip runs after it. +const cardQml = fs.readFileSync(path.join(root, 'shell/plugins/notifications/components/NotificationCard.qml'), 'utf8') +assert( + /readonly property string styledBody: NotificationLogic\.styledBody\(body, app, appIcon\)/.test(cardQml), + 'the notification card renders the body that was stripped after the newline rewrite' +) +assert( + !//.test(cardQml), + 'the notification card does not rewrite newlines itself, which would leave tag syntax unchecked' +) + assertEqual( notifications.sanitizeBody('trailing Date: Thu, 27 Aug 2026 17:28:13 +0200 Subject: [PATCH 36/41] Close six ways the textFormat scan reported success without checking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each of these is a Text rendering external data with no textFormat, written in a form that passed silently. None exists in this tree, so they were holes in the guard rather than live exposures — but a guard is only worth what it catches, and every one of them is a single line someone could plausibly write. Text /* why */ { strip_noise knew // and not /* */, so a block comment between the type name and its brace hid the element from every rule at once QQ.Text { ... } a namespaced import made the name compare unequal to `Text`, and the element was skipped outright visible: textFormatEnabled textFormat was matched as a substring, so a lookalike property exempted the whole block component Info: a component root with its Text on the next line; Text { the one-line form was covered and this was not an unreadable subdirectory rglob() swallows a directory it cannot enter, so a locked subtree scanned as though it were empty The scan moves out of the heredoc into qml-text-format-scan.py, taking its root as an argument, because nothing could run it over anything but the real tree — and a scanner whose only input always passes cannot be shown to fail. The test now runs it over nineteen fixtures, one per form above and one per form the scan already handled, so a later edit that loosens it fails here instead of going unnoticed until something renders a remote image. Two limits stay open and are written down in the module docstring rather than papered over: text assigned from elsewhere (a Binding element, PropertyChanges, an onCompleted assignment, a property alias onto a child) is invisible to a scanner that reads each element's own declaration, and a regex literal holding a brace throws off the brace depth. Neither shape exists in this tree and both need a QML parser, not another regex. Co-Authored-By: Codex XHigh Co-Authored-By: Claude Opus 5 (1M context) --- test/shell.d/qml-text-format-scan.py | 373 ++++++++++++++++++++ test/shell.d/qml-text-format-test.sh | 507 +++++++++++++-------------- 2 files changed, 608 insertions(+), 272 deletions(-) create mode 100644 test/shell.d/qml-text-format-scan.py diff --git a/test/shell.d/qml-text-format-scan.py b/test/shell.d/qml-text-format-scan.py new file mode 100644 index 00000000..d405e06c --- /dev/null +++ b/test/shell.d/qml-text-format-scan.py @@ -0,0 +1,373 @@ +"""Report every QML Text that renders a non-literal value without a textFormat. + +Usage: qml-text-format-scan.py ROOT (scans ROOT/shell, prints one line per +violation, exits 1 on an unreadable tree). Lives in its own file rather than a +heredoc so the test can run it over fixtures and prove it still fails when it +should — a guard nothing can fail is a guard nobody should trust. + +Two limits are deliberate, because a line scanner cannot close them. It reads +each Text element's own declaration, so text assigned from somewhere else — +`Binding { target: label; property: "text" }`, `PropertyChanges`, a +`Component.onCompleted` assignment, a `property alias` onto a child's text — +is invisible to it. And a regex literal containing a brace throws off the brace +depth. Neither shape exists in this tree; both would need a QML parser. +""" + +import os +import re +import sys +from pathlib import Path + +BLOCK_COMMENT = re.compile(r'/\*.*?\*/|/\*.*\Z', re.S) + + +def strip_block_comments(text): + """Blank out /* */ comments, keeping every newline so line numbers hold. + + strip_noise() only knows `//`, so before this a block comment between a + type name and its brace — `Text /* why */ {` — hid the element from + OPEN_ELEMENT and from the unscannable-form check alike, and the block + passed with no textFormat at all. + """ + out = [] + i = 0 + quote = None + while i < len(text): + c = text[i] + if quote: + if c == '\\': + out.append(text[i:i + 2]) + i += 2 + continue + if c == quote: + quote = None + out.append(c) + i += 1 + continue + if c in '"\'': + quote = c + out.append(c) + i += 1 + continue + if c == '/' and text.startswith('//', i): + end = text.find('\n', i) + if end == -1: + break + out.append(text[i:end]) + i = end + continue + if c == '/' and text.startswith('/*', i): + end = text.find('*/', i + 2) + end = len(text) if end == -1 else end + 2 + out.append(''.join(ch if ch == '\n' else ' ' for ch in text[i:end])) + i = end + continue + out.append(c) + i += 1 + return ''.join(out) + + +# A Text under a namespaced import — `import QtQuick as QQ` then `QQ.Text` — is +# the same element and was skipped, because the name compared unequal to `Text`. +TEXT_NAME = r'(?:[A-Za-z_][A-Za-z0-9_]*\.)?Text' + +OPEN_ELEMENT = re.compile(r'(?:^|[:\s])([A-Z][A-Za-z0-9_.]*)\s*\{\s*$') +INLINE_COMPONENT = re.compile(r'^\s*component\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*' + TEXT_NAME + r'\s*\{\s*$') +INLINE_COMPONENT_ONELINE = re.compile(r'^\s*component\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*' + TEXT_NAME + r'\s*\{') +PROP = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_.]*)\s*:') +STRING_LITERAL = re.compile(r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'') +PROPERTY_DECL = re.compile(r'^\s*(?:readonly\s+)?property\b') +# A binding that runs onto the next line: this line ends on an operator, or the +# next line opens with one. +TRAILING_OPERATOR = re.compile(r'(?:&&|\|\||[?:+\-*/,(\[=&|])$') +LEADING_OPERATOR = re.compile(r'^\s*(?:&&|\|\||[?:+\-*/,)\]&|.])') + + +def strip_noise(line, keep_strings=False): + out = [] + i = 0 + quote = None + while i < len(line): + c = line[i] + if quote: + if keep_strings: + out.append(c) + if c == '\\': + if keep_strings and i + 1 < len(line): + out.append(line[i + 1]) + i += 2 + continue + if c == quote: + quote = None + if not keep_strings: + out.append('S') + i += 1 + continue + if c in '"\'': + quote = c + if keep_strings: + out.append(c) + i += 1 + continue + if c == '/' and i + 1 < len(line) and line[i + 1] == '/': + break + out.append(c) + i += 1 + return ''.join(out) + + +def is_pure_literal(expr): + residue = STRING_LITERAL.sub('', expr) + residue = re.sub(r'[\s+]', '', residue) + return residue == '' and STRING_LITERAL.search(expr) is not None + + +def binding_expression(lines, start): + """The whole right-hand side of the binding beginning on line `start`. + + The literal exemption has to be judged on the complete expression. Reading + only the physical `text:` line would exempt `text: "prefix"` while + `+ externalValue` sits underneath, letting a dynamic AutoText binding + through. Reading a wrapped concatenation of literals as dynamic would be + the opposite error, so follow the expression to its end either way. + """ + parts = [] + parens = brackets = 0 + i = start + while i < len(lines): + parts.append(strip_noise(lines[i], keep_strings=True)) + counted = strip_noise(lines[i]) + parens += counted.count('(') - counted.count(')') + brackets += counted.count('[') - counted.count(']') + # Look past blank and comment-only lines for the continuation. A + # comment or a blank line dropped into a wrapped expression does not + # end it, and stopping there would read `text: "prefix"` as the whole + # binding and exempt it as a literal while `+ externalValue` waits + # below — the exact misreading this function exists to prevent. + following = '' + for ahead in range(i + 1, len(lines)): + candidate = strip_noise(lines[ahead]) + if candidate.strip(): + following = candidate + break + continues = (parens > 0 or brackets > 0 + or TRAILING_OPERATOR.search(counted.rstrip()) + or LEADING_OPERATOR.match(following)) + if not continues: + break + i += 1 + + chunk = ' '.join(parts) + return chunk.split(':', 1)[1] if ':' in chunk else chunk + + +def exempt_as_literal(lines, tline): + """True when the binding is only string literals, however many lines.""" + return is_pure_literal(binding_expression(lines, tline)) + + +def blocks(lines): + stack = [] + done = [] + depth = 0 + for idx, raw in enumerate(lines): + code = strip_noise(raw) + opened = OPEN_ELEMENT.search(code) + prop = PROP.match(code) + if (prop and stack and stack[-1]['depth'] == depth + and not opened and not PROPERTY_DECL.match(code)): + stack[-1]['props'].setdefault(prop.group(1), idx) + n_open = code.count('{') + n_close = code.count('}') + depth += n_open - n_close + if opened and n_open > 0: + # OPEN_ELEMENT anchors at the end of the line, so the element it + # matched is the innermost one opened here and its depth is the + # depth after every brace on the line. + stack.append({'name': opened.group(1), 'depth': depth, + 'props': {}, 'start': idx}) + while stack and depth < stack[-1]['depth']: + done.append(stack.pop()) + done.extend(stack) + return done + + +INLINE_TEXT = re.compile(r'(?:^|[:\s])' + TEXT_NAME + r'\s*\{([^{}]*)\}') +INLINE_BINDING = re.compile(r'\btext\s*:\s*(.*?)\s*(?:;|$)') +# As a property of this block, not as a substring: `visible: root.textFormatEnabled` +# used to read as a declaration and exempt the element. +INLINE_TEXT_FORMAT = re.compile(r'(?:^|[;{\s])textFormat\s*:') + + +def inline_violations(lines, rel): + """Whole Text blocks written on one line. + + OPEN_ELEMENT anchors at the end of the line, so the brace scanner never + sees these. A Repeater delegate is a plausible place for one. + """ + out = [] + for idx, raw in enumerate(lines): + code = strip_noise(raw, keep_strings=True) + for match in INLINE_TEXT.finditer(code): + body = match.group(1) + if INLINE_TEXT_FORMAT.search(body): + continue + # A component root written on one line needs the default whether or + # not this line binds `text`, for the same reason the block form + # does: every caller supplies the binding. + if INLINE_COMPONENT_ONELINE.match(code): + out.append(f'{rel}:{idx + 1}: inline component root Text declares no textFormat') + continue + binding = INLINE_BINDING.search(body) + if not binding or is_pure_literal(binding.group(1)): + continue + out.append(f'{rel}:{idx + 1}: inline Text block without textFormat') + return out + + +# `Text { text: someValue` with the block carrying on below is valid QML and is +# invisible to both scanners: OPEN_ELEMENT anchors its `{` at the end of the +# line so the brace tracker never opens the block, and INLINE_TEXT needs the +# closing brace on the same line. A dynamic AutoText binding written that way +# passes this file in silence, which is the one failure a test like this must +# not have. +# +# Rather than teach a line scanner to parse QML, require the two forms it can +# read: the whole block on one line, or nothing after the opening brace. Every +# Text in this tree is already written that way, so keeping to it costs nothing. +UNSCANNABLE_TEXT = re.compile(r'(?:^|[:\s])' + TEXT_NAME + r'\s*\{\s*\S') +BARE_TEXT_OPENER = re.compile(r'(?:^|[:\s])' + TEXT_NAME + r'\s*$') + +UNSCANNABLE = ('Text block written in a form this scanner cannot read; put the ' + 'opening brace last on the line, or write the whole block on ' + 'one line with no nested braces') + + +COMPONENT_OPENER = re.compile(r'^\s*component\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*$') + + +def opens_component(lines, start): + """True when the Text block at `start` is a component root declared above it.""" + for back in range(start - 1, -1, -1): + code = strip_noise(lines[back]).strip() + if not code: + continue + return bool(COMPONENT_OPENER.match(lines[back])) + return False + + +def unscannable_violations(lines, rel): + out = [] + for idx, raw in enumerate(lines): + code = strip_noise(raw) + + # `Text` with its brace on the next line. OPEN_ELEMENT needs both on + # one line, so the block is never opened and everything in it is + # attributed to the enclosing element instead. + if BARE_TEXT_OPENER.search(code): + following = '' + for ahead in range(idx + 1, len(lines)): + candidate = strip_noise(lines[ahead]).strip() + if candidate: + following = candidate + break + if following.startswith('{'): + out.append(f'{rel}:{idx + 1}: {UNSCANNABLE}') + continue + + for match in UNSCANNABLE_TEXT.finditer(code): + # A complete one-line block with no nested braces is fine — + # inline_violations reads those. Count rather than looking for a + # `}`, because `Text { text: ({ a: external }).a }` closes on this + # line yet INLINE_TEXT's brace-free body pattern cannot match it, + # so treating any `}` as "handled elsewhere" would drop it. + rest = code[match.end() - 1:] + depth = 1 + closed = False + for char in rest: + if char == '{': + depth += 1 + elif char == '}': + depth -= 1 + if depth == 0: + closed = True + break + if closed and '{' not in rest: + continue + out.append(f'{rel}:{idx + 1}: {UNSCANNABLE}') + return out + + +root = Path(sys.argv[1]) +found = [] +scanned = 0 + + +def unreadable(error): + # rglob() swallows a directory it cannot enter, so a shell/ subtree with no + # read permission scanned as though it were empty and the run reported + # success. Same failure as an empty tree, and it fails the same way. + raise SystemExit(f'cannot read {error.filename}: {error.strerror}') + + +qml = [] +for dirpath, dirnames, filenames in os.walk(root / 'shell', onerror=unreadable): + dirnames.sort() + qml.extend(Path(dirpath) / name for name in filenames if name.endswith('.qml')) + +for path in sorted(qml): + scanned += 1 + lines = strip_block_comments(path.read_text()).splitlines() + rel = path.relative_to(root) + found.extend(inline_violations(lines, rel)) + found.extend(unscannable_violations(lines, rel)) + + for b in blocks(lines): + if b['name'].split('.')[-1] != 'Text' or 'textFormat' in b['props']: + continue + + # Read the block's own properties. A nested child declaring textFormat + # says nothing about its parent, so `Text { Text { textFormat: ... } }` + # must still report the outer element. + # The root element of a component takes its binding from callers, so it + # needs the default whether or not this file binds `text`. Require both + # depth 1 and column 0: the scanner attributes one element per line, so + # a `Row { Text {` line would report depth 1 for a nested block, and + # falling through to the binding check below is the safe reading. + # Indentation is not what makes it a root; depth 1 is. A `Row { Text {` + # line still reads as `Row` here, so leading whitespace can be ignored + # without letting a nested block be mistaken for the file's root. + if b['depth'] == 1 and lines[b['start']].lstrip().startswith('Text'): + found.append(f'{rel}:{b["start"] + 1}: root Text element declares no textFormat') + continue + + # A QML inline component is a root for the same reason, and the rule + # above cannot see one: `component InfoValue: Text {` sits inside + # another element, so its depth is not 1 and its line does not start + # with `Text`. Its `text` comes from every caller, so the file it lives + # in never binds it and the binding check below lets it through in + # silence. Only one file-level root Text exists in this tree, so + # without this the root rule is very nearly dead code. + # `component Info:` may also put its `Text {` on the following line, + # which INLINE_COMPONENT cannot match and which then reads as an + # ordinary nested block with no binding of its own — a caller's dynamic + # text passing in silence. + if INLINE_COMPONENT.match(lines[b['start']]) or opens_component(lines, b['start']): + found.append(f'{rel}:{b["start"] + 1}: inline component root Text declares no textFormat') + continue + + if 'text' not in b['props']: + continue + tline = b['props']['text'] + if exempt_as_literal(lines, tline): + continue + found.append(f'{rel}:{tline + 1}: text binding without textFormat') + +# A scan that read nothing reports nothing, and an all-clear from a run that +# never opened a file is the one result this test must never give. Only a +# checkout with no shell/ QML at all reaches this. +if scanned == 0: + raise SystemExit('no .qml files found under shell/; the scan read nothing') + +for line in found: + print(line) diff --git a/test/shell.d/qml-text-format-test.sh b/test/shell.d/qml-text-format-test.sh index 0b286a76..4d987ce4 100755 --- a/test/shell.d/qml-text-format-test.sh +++ b/test/shell.d/qml-text-format-test.sh @@ -18,6 +18,11 @@ # bare string literal. A literal carries no external data, so AutoText has # nothing to promote; this test is what catches the edit that later turns such # a literal into an expression. +# +# The scan itself lives in qml-text-format-scan.py. It is run twice: over the +# real tree, and over the fixtures below, which are the forms that have already +# slipped past it once. A guard nothing can fail is a guard nobody should trust, +# and every one of those fixtures passed silently before it was written down. set -euo pipefail @@ -25,279 +30,9 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" require_command python3 -violations=$(ROOT="$ROOT" python3 <<'PY' -import os -import re -from pathlib import Path +SCAN="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/qml-text-format-scan.py" -OPEN_ELEMENT = re.compile(r'(?:^|[:\s])([A-Z][A-Za-z0-9_.]*)\s*\{\s*$') -INLINE_COMPONENT = re.compile(r'^\s*component\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*Text\s*\{\s*$') -INLINE_COMPONENT_ONELINE = re.compile(r'^\s*component\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*Text\s*\{') -PROP = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_.]*)\s*:') -STRING_LITERAL = re.compile(r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'') -PROPERTY_DECL = re.compile(r'^\s*(?:readonly\s+)?property\b') -# A binding that runs onto the next line: this line ends on an operator, or the -# next line opens with one. -TRAILING_OPERATOR = re.compile(r'(?:&&|\|\||[?:+\-*/,(\[=&|])$') -LEADING_OPERATOR = re.compile(r'^\s*(?:&&|\|\||[?:+\-*/,)\]&|.])') - - -def strip_noise(line, keep_strings=False): - out = [] - i = 0 - quote = None - while i < len(line): - c = line[i] - if quote: - if keep_strings: - out.append(c) - if c == '\\': - if keep_strings and i + 1 < len(line): - out.append(line[i + 1]) - i += 2 - continue - if c == quote: - quote = None - if not keep_strings: - out.append('S') - i += 1 - continue - if c in '"\'': - quote = c - if keep_strings: - out.append(c) - i += 1 - continue - if c == '/' and i + 1 < len(line) and line[i + 1] == '/': - break - out.append(c) - i += 1 - return ''.join(out) - - -def is_pure_literal(expr): - residue = STRING_LITERAL.sub('', expr) - residue = re.sub(r'[\s+]', '', residue) - return residue == '' and STRING_LITERAL.search(expr) is not None - - -def binding_expression(lines, start): - """The whole right-hand side of the binding beginning on line `start`. - - The literal exemption has to be judged on the complete expression. Reading - only the physical `text:` line would exempt `text: "prefix"` while - `+ externalValue` sits underneath, letting a dynamic AutoText binding - through. Reading a wrapped concatenation of literals as dynamic would be - the opposite error, so follow the expression to its end either way. - """ - parts = [] - parens = brackets = 0 - i = start - while i < len(lines): - parts.append(strip_noise(lines[i], keep_strings=True)) - counted = strip_noise(lines[i]) - parens += counted.count('(') - counted.count(')') - brackets += counted.count('[') - counted.count(']') - # Look past blank and comment-only lines for the continuation. A - # comment or a blank line dropped into a wrapped expression does not - # end it, and stopping there would read `text: "prefix"` as the whole - # binding and exempt it as a literal while `+ externalValue` waits - # below — the exact misreading this function exists to prevent. - following = '' - for ahead in range(i + 1, len(lines)): - candidate = strip_noise(lines[ahead]) - if candidate.strip(): - following = candidate - break - continues = (parens > 0 or brackets > 0 - or TRAILING_OPERATOR.search(counted.rstrip()) - or LEADING_OPERATOR.match(following)) - if not continues: - break - i += 1 - - chunk = ' '.join(parts) - return chunk.split(':', 1)[1] if ':' in chunk else chunk - - -def exempt_as_literal(lines, tline): - """True when the binding is only string literals, however many lines.""" - return is_pure_literal(binding_expression(lines, tline)) - - -def blocks(lines): - stack = [] - done = [] - depth = 0 - for idx, raw in enumerate(lines): - code = strip_noise(raw) - opened = OPEN_ELEMENT.search(code) - prop = PROP.match(code) - if (prop and stack and stack[-1]['depth'] == depth - and not opened and not PROPERTY_DECL.match(code)): - stack[-1]['props'].setdefault(prop.group(1), idx) - n_open = code.count('{') - n_close = code.count('}') - depth += n_open - n_close - if opened and n_open > 0: - # OPEN_ELEMENT anchors at the end of the line, so the element it - # matched is the innermost one opened here and its depth is the - # depth after every brace on the line. - stack.append({'name': opened.group(1), 'depth': depth, - 'props': {}, 'start': idx}) - while stack and depth < stack[-1]['depth']: - done.append(stack.pop()) - done.extend(stack) - return done - - -INLINE_TEXT = re.compile(r'(?:^|[:\s])Text\s*\{([^{}]*)\}') -INLINE_BINDING = re.compile(r'\btext\s*:\s*(.*?)\s*(?:;|$)') - - -def inline_violations(lines, rel): - """Whole Text blocks written on one line. - - OPEN_ELEMENT anchors at the end of the line, so the brace scanner never - sees these. A Repeater delegate is a plausible place for one. - """ - out = [] - for idx, raw in enumerate(lines): - code = strip_noise(raw, keep_strings=True) - for match in INLINE_TEXT.finditer(code): - body = match.group(1) - if 'textFormat' in body: - continue - # A component root written on one line needs the default whether or - # not this line binds `text`, for the same reason the block form - # does: every caller supplies the binding. - if INLINE_COMPONENT_ONELINE.match(code): - out.append(f'{rel}:{idx + 1}: inline component root Text declares no textFormat') - continue - binding = INLINE_BINDING.search(body) - if not binding or is_pure_literal(binding.group(1)): - continue - out.append(f'{rel}:{idx + 1}: inline Text block without textFormat') - return out - - -# `Text { text: someValue` with the block carrying on below is valid QML and is -# invisible to both scanners: OPEN_ELEMENT anchors its `{` at the end of the -# line so the brace tracker never opens the block, and INLINE_TEXT needs the -# closing brace on the same line. A dynamic AutoText binding written that way -# passes this file in silence, which is the one failure a test like this must -# not have. -# -# Rather than teach a line scanner to parse QML, require the two forms it can -# read: the whole block on one line, or nothing after the opening brace. Every -# Text in this tree is already written that way, so keeping to it costs nothing. -UNSCANNABLE_TEXT = re.compile(r'(?:^|[:\s])Text\s*\{\s*\S') -BARE_TEXT_OPENER = re.compile(r'(?:^|[:\s])Text\s*$') - -UNSCANNABLE = ('Text block written in a form this scanner cannot read; put the ' - 'opening brace last on the line, or write the whole block on ' - 'one line with no nested braces') - - -def unscannable_violations(lines, rel): - out = [] - for idx, raw in enumerate(lines): - code = strip_noise(raw) - - # `Text` with its brace on the next line. OPEN_ELEMENT needs both on - # one line, so the block is never opened and everything in it is - # attributed to the enclosing element instead. - if BARE_TEXT_OPENER.search(code): - following = '' - for ahead in range(idx + 1, len(lines)): - candidate = strip_noise(lines[ahead]).strip() - if candidate: - following = candidate - break - if following.startswith('{'): - out.append(f'{rel}:{idx + 1}: {UNSCANNABLE}') - continue - - for match in UNSCANNABLE_TEXT.finditer(code): - # A complete one-line block with no nested braces is fine — - # inline_violations reads those. Count rather than looking for a - # `}`, because `Text { text: ({ a: external }).a }` closes on this - # line yet INLINE_TEXT's brace-free body pattern cannot match it, - # so treating any `}` as "handled elsewhere" would drop it. - rest = code[match.end() - 1:] - depth = 1 - closed = False - for char in rest: - if char == '{': - depth += 1 - elif char == '}': - depth -= 1 - if depth == 0: - closed = True - break - if closed and '{' not in rest: - continue - out.append(f'{rel}:{idx + 1}: {UNSCANNABLE}') - return out - - -root = Path(os.environ['ROOT']) -found = [] -scanned = 0 -for path in sorted((root / 'shell').rglob('*.qml')): - scanned += 1 - lines = path.read_text().splitlines() - rel = path.relative_to(root) - found.extend(inline_violations(lines, rel)) - found.extend(unscannable_violations(lines, rel)) - - for b in blocks(lines): - if b['name'] != 'Text' or 'textFormat' in b['props']: - continue - - # Read the block's own properties. A nested child declaring textFormat - # says nothing about its parent, so `Text { Text { textFormat: ... } }` - # must still report the outer element. - # The root element of a component takes its binding from callers, so it - # needs the default whether or not this file binds `text`. Require both - # depth 1 and column 0: the scanner attributes one element per line, so - # a `Row { Text {` line would report depth 1 for a nested block, and - # falling through to the binding check below is the safe reading. - # Indentation is not what makes it a root; depth 1 is. A `Row { Text {` - # line still reads as `Row` here, so leading whitespace can be ignored - # without letting a nested block be mistaken for the file's root. - if b['depth'] == 1 and lines[b['start']].lstrip().startswith('Text'): - found.append(f'{rel}:{b["start"] + 1}: root Text element declares no textFormat') - continue - - # A QML inline component is a root for the same reason, and the rule - # above cannot see one: `component InfoValue: Text {` sits inside - # another element, so its depth is not 1 and its line does not start - # with `Text`. Its `text` comes from every caller, so the file it lives - # in never binds it and the binding check below lets it through in - # silence. Only one file-level root Text exists in this tree, so - # without this the root rule is very nearly dead code. - if INLINE_COMPONENT.match(lines[b['start']]): - found.append(f'{rel}:{b["start"] + 1}: inline component root Text declares no textFormat') - continue - - if 'text' not in b['props']: - continue - tline = b['props']['text'] - if exempt_as_literal(lines, tline): - continue - found.append(f'{rel}:{tline + 1}: text binding without textFormat') - -# A scan that read nothing reports nothing, and an all-clear from a run that -# never opened a file is the one result this test must never give. Only a -# checkout with no shell/ QML at all reaches this. -if scanned == 0: - raise SystemExit('no .qml files found under shell/; the scan read nothing') - -for line in found: - print(line) -PY -) +violations=$(python3 "$SCAN" "$ROOT") if [[ -n $violations ]]; then count=$(printf '%s\n' "$violations" | wc -l) @@ -311,3 +46,231 @@ deliberate, documented feature, and strip before it reaches the renderer." fi pass "every Text with a dynamic text binding declares textFormat" + +# The scanner's own tests. Each fixture is a Text that renders external data +# with no textFormat, written in a form that once passed. `caught` asserts the +# scan reports something; `clean` asserts it does not, so the fixtures prove the +# scanner can fail rather than that it fails at everything. +fixture_root=$(mktemp -d) +trap 'chmod -R u+rwX "$fixture_root" 2>/dev/null; rm -rf "$fixture_root"' EXIT + +function scan_fixture { + local name=$1 + local dir="$fixture_root/$name" + mkdir -p "$dir/shell/Ui" + cat > "$dir/shell/Ui/Fixture.qml" + python3 "$SCAN" "$dir" 2>&1 +} + +function caught { + local name=$1 description=$2 output + output=$(scan_fixture "$name" || true) + if [[ -z $output ]]; then + fail "$description" "the scan reported nothing for fixture $name" + fi + pass "$description" +} + +function clean { + local name=$1 description=$2 output + output=$(scan_fixture "$name" || true) + if [[ -n $output ]]; then + fail "$description" "the scan reported: $output" + fi + pass "$description" +} + +caught plain "the scan reports a plain dynamic binding with no textFormat" <<'QML' +import QtQuick +Item { + property string external: "x" + Text { + text: external + } +} +QML + +clean literal "the scan leaves a string literal alone" <<'QML' +import QtQuick +Item { + Text { + text: "a literal" + } +} +QML + +clean declared "the scan leaves a declared textFormat alone" <<'QML' +import QtQuick +Item { + property string external: "x" + Text { + textFormat: Text.PlainText + text: external + } +} +QML + +# strip_noise() knew `//` and not `/* */`, so a block comment between the type +# name and its brace hid the whole element from every rule. +caught block-comment "the scan reads a Text whose brace a block comment hides" <<'QML' +import QtQuick +Item { + property string external: "x" + Text /* explanation */ { + text: external + } +} +QML + +caught block-comment-multiline "the scan reads past a block comment spanning lines" <<'QML' +import QtQuick +Item { + property string external: "x" + /* + * Text { text: "not this one" } + */ + Text { + text: external + } +} +QML + +# `import QtQuick as QQ` makes the element `QQ.Text`, which compared unequal to +# `Text` and was skipped outright. +caught namespaced "the scan reads a Text reached through a namespaced import" <<'QML' +import QtQuick as QQ +QQ.Item { + property string external: "x" + QQ.Text { + text: external + } +} +QML + +# textFormat was matched as a substring, so any property whose name merely +# started that way exempted the element. +caught namespaced-inline "the scan reads a one-line namespaced Text block" <<'QML' +import QtQuick as QQ +QQ.Item { + property string external: "x" + QQ.Text { text: external } +} +QML + +caught namespaced-unscannable "the scan rejects an unreadable namespaced Text block" <<'QML' +import QtQuick as QQ +QQ.Item { + property string external: "x" + QQ.Text { text: external + color: "red" + } +} +QML + +caught textformat-substring "the scan does not accept a lookalike property as textFormat" <<'QML' +import QtQuick +Item { + property string external: "x" + property bool textFormatEnabled: true + Text { text: external; visible: textFormatEnabled } +} +QML + +# A component root takes its text from every caller, so the file it lives in +# never binds it. The one-line form was covered; this one was not. +caught component-next-line "the scan reads a component root whose Text sits on the next line" <<'QML' +import QtQuick +Item { + component Info: + Text { + } +} +QML + +caught component-one-line "the scan reads a component root written on one line" <<'QML' +import QtQuick +Item { + component Info: Text { color: "red" } +} +QML + +# Forms the scanner cannot read are reported rather than passed, which is the +# whole reason it can be a line scanner at all. +caught brace-next-line "the scan rejects a Text whose opening brace is on the next line" <<'QML' +import QtQuick +Item { + property string external: "x" + Text + { + text: external + } +} +QML + +caught trailing-binding "the scan rejects a Text with a binding after the opening brace" <<'QML' +import QtQuick +Item { + property string external: "x" + Text { text: external + color: "red" + } +} +QML + +# A wrapped binding is judged whole: a literal first line says nothing about +# what is concatenated onto it below. +caught wrapped-binding "the scan follows a wrapped binding past its literal first line" <<'QML' +import QtQuick +Item { + property string external: "x" + Text { + text: "prefix" + + external + } +} +QML + +clean wrapped-literals "the scan leaves a wrapped concatenation of literals alone" <<'QML' +import QtQuick +Item { + Text { + text: "one" + + "two" + } +} +QML + +# A nested child's textFormat says nothing about its parent. +caught nested-child "the scan does not let a nested child's textFormat cover its parent" <<'QML' +import QtQuick +Text { + text: external.value + Text { + textFormat: Text.PlainText + text: "literal" + } +} +QML + +# A scan that reads less than the tree holds must not report success. Both of +# these once did. +empty_root=$(mktemp -d) +mkdir -p "$empty_root/shell" +if python3 "$SCAN" "$empty_root" > /dev/null 2>&1; then + rm -rf "$empty_root" + fail "the scan fails when it reads no files" "an empty shell/ tree exited 0" +fi +rm -rf "$empty_root" +pass "the scan fails when it reads no files" + +blind_root="$fixture_root/blind" +mkdir -p "$blind_root/shell/Ui/locked" +printf 'import QtQuick\nItem {\n Text {\n textFormat: Text.PlainText\n text: "ok"\n }\n}\n' > "$blind_root/shell/Ui/Good.qml" +printf 'import QtQuick\nItem {\n property string external: "x"\n Text {\n text: external\n }\n}\n' > "$blind_root/shell/Ui/locked/Bad.qml" +chmod 000 "$blind_root/shell/Ui/locked" +if python3 "$SCAN" "$blind_root" > /dev/null 2>&1; then + chmod 755 "$blind_root/shell/Ui/locked" + fail "the scan fails when a directory hides files from it" "an unreadable subdirectory exited 0" +fi +chmod 755 "$blind_root/shell/Ui/locked" +pass "the scan fails when a directory hides files from it" From 9ece53cede223add78664959a7d807d94f247df2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 17:04:25 +0200 Subject: [PATCH 37/41] Prove the web app name guard, and reject before the icon is fetched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slash guard was the only thing keeping a name out of the directory structure, and nothing tested it: deleting it left the suite green, because creating the launcher directly in the applications directory already makes the redirect fail on its own, with a raw bash error instead of the message. The assertion is on the message now, alongside the traversal case the guard actually closes -- on quattro a name of `../../../../escaped` writes its launcher clean outside the applications directory. The interactive prompt read the name, fetched the favicon, wrote it and updated the icon cache before the name was ever checked, so a URL typed into the Name field left an icon behind on every attempt. Validating as soon as the name is read covers both paths from one place. Removing by name also scanned unconditionally, so a machine with no applications directory printed a find error where omarchy-remove-gaming-xbox-cloud does not hide stderr. 🤖 Generated by Opus 5 in Claude Code. Co-Authored-By: Claude Opus 5 (1M context) --- bin/omarchy-webapp-install | 22 ++++++---- bin/omarchy-webapp-remove | 2 +- test/shell.d/webapp-name-test.sh | 71 ++++++++++++++++++++++++++++++-- 3 files changed, 83 insertions(+), 12 deletions(-) diff --git a/bin/omarchy-webapp-install b/bin/omarchy-webapp-install index b7985968..d976622d 100755 --- a/bin/omarchy-webapp-install +++ b/bin/omarchy-webapp-install @@ -13,6 +13,18 @@ safe_icon_name() { | sed 's/[^[:alnum:]]\+/-/g; s/^-//; s/-$//' } +require_plain_name() { + # The name becomes a filename. A slash would turn it into directory levels, so + # the launcher lands somewhere omarchy-webapp-remove cannot address and the app + # is stuck in the launcher; a leading ../ leaves the applications directory + # altogether. Refuse rather than silently renaming what the user typed -- most + # often it is a URL entered in the name field. + if [[ $1 == */* ]]; then + echo "App name cannot contain '/': $1" + exit 1 + fi +} + icon_name_from_ref() { local ref="$1" local name @@ -68,6 +80,7 @@ fetch_site_icon() { if (( $# < 3 )); then echo -e "\e[32mLet's create a new web app you can start with the app launcher.\n\e[0m" APP_NAME=$(gum input --prompt "Name> " --placeholder "My favorite web app") + require_plain_name "$APP_NAME" APP_URL=$(gum input --prompt "URL> " --placeholder "https://example.com") if [[ ! $APP_URL =~ ^[a-zA-Z][a-zA-Z0-9+.-]*: ]]; then APP_URL="https://$APP_URL" @@ -104,14 +117,7 @@ if [[ -z $APP_NAME || -z $APP_URL ]]; then exit 1 fi -# The name becomes a filename. A slash would turn it into directory levels, so -# the launcher lands somewhere omarchy-webapp-remove cannot address and the app -# is stuck in the launcher. Refuse rather than silently renaming what the user -# typed -- most often it is a URL entered in the name field. -if [[ $APP_NAME == */* ]]; then - echo "App name cannot contain '/': $APP_NAME" - exit 1 -fi +require_plain_name "$APP_NAME" if [[ -z $ICON_REF ]]; then ICON_VALUE=$(safe_icon_name "$APP_NAME") diff --git a/bin/omarchy-webapp-remove b/bin/omarchy-webapp-remove index 303b3a5d..b3244637 100755 --- a/bin/omarchy-webapp-remove +++ b/bin/omarchy-webapp-remove @@ -19,7 +19,7 @@ while IFS= read -r -d '' file; do WEB_APPS+=("$(basename "${file%.desktop}")") WEB_APP_PATHS+=("$file") fi -done < <(find "$DESKTOP_DIR" -name '*.desktop' -print0) +done < <(find "$DESKTOP_DIR" -name '*.desktop' -print0 2>/dev/null) # The launcher matching a chosen name, or empty when nothing was indexed under # it (an app removed between the scan and the pick, say). diff --git a/test/shell.d/webapp-name-test.sh b/test/shell.d/webapp-name-test.sh index cd66d445..903f851a 100644 --- a/test/shell.d/webapp-name-test.sh +++ b/test/shell.d/webapp-name-test.sh @@ -24,16 +24,73 @@ run_remove() { } apps_dir="$tmp_dir/home/.local/share/applications" +icons_dir="$tmp_dir/home/.local/share/icons/hicolor/256x256/apps" # A URL typed into the name field is the reported way in. Every slash used to -# become a directory level, leaving a launcher nothing could address. -if run_install "http://example.test/oops" "https://example.com" hey >/dev/null 2>&1; then +# become a directory level, leaving a launcher nothing could address. Assert on +# the message: creating the launcher directly in the applications directory +# already makes the redirect fail on its own, so a bare non-zero exit would pass +# just as well with no validation at all. +output=$(run_install "http://example.test/oops" "https://example.com" hey 2>&1) && fail "webapp install rejects a name containing a slash" -fi +[[ $output == *"App name cannot contain '/'"* ]] || + fail "webapp install says why it refused a slashed name" "$output" [[ -e "$apps_dir/http:" ]] && fail "webapp install does not create a directory from a slashed name" pass "webapp install rejects a name that would nest the launcher" +# The name was a path fragment until something said otherwise, so ../ climbed +# out of the applications directory entirely and wrote wherever it landed. +if run_install "../../../../escaped" "https://example.com" hey >/dev/null 2>&1; then + fail "webapp install rejects a name that climbs out of the applications directory" +fi +[[ -e "$tmp_dir/escaped.desktop" ]] && + fail "webapp install writes no launcher outside the applications directory" +pass "webapp install refuses a name that would escape the applications directory" + +# The interactive prompt reads the name long before it is used as a path, and +# fetches the site icon in between. Rejecting only at the write leaves that icon +# behind in the user's icon theme, once per attempt. +mkdir -p "$tmp_dir/ibin" +cp "$tmp_dir/bin"/* "$tmp_dir/ibin/" +cat >"$tmp_dir/ibin/gum" <<'STUB' +#!/bin/bash +count_file="${GUM_STUB_COUNT:?}" +count=$(cat "$count_file" 2>/dev/null || echo 0) +count=$((count + 1)) +echo "$count" >"$count_file" +if (( count == 1 )); then + echo "http://example.test/oops" +else + echo "https://example.com" +fi +STUB +cat >"$tmp_dir/ibin/curl" <<'STUB' +#!/bin/bash +# Answer any download with a real PNG so the icon fetch reports success. +out="" +prev="" +for arg in "$@"; do + [[ $prev == "-o" ]] && out="$arg" + prev="$arg" +done +if [[ -n $out ]]; then + printf '%s' 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==' | base64 -d >"$out" +fi +STUB +chmod +x "$tmp_dir/ibin/gum" "$tmp_dir/ibin/curl" + +if HOME="$tmp_dir/home" PATH="$tmp_dir/ibin:$PATH" \ + GUM_STUB_COUNT="$tmp_dir/gum-count" \ + "$ROOT/bin/omarchy-webapp-install" >/dev/null 2>&1; then + fail "interactive webapp install rejects a name containing a slash" +fi +if compgen -G "$icons_dir/*.png" >/dev/null; then + fail "interactive webapp install downloads no icon for a name it refuses" \ + "$(ls "$icons_dir")" +fi +pass "webapp install refuses a slashed name before fetching its icon" + # A normal name still installs and removes. run_install "Example App" "https://example.com" hey >/dev/null [[ -f "$apps_dir/Example App.desktop" ]] || @@ -59,3 +116,11 @@ run_remove "127.0.0.1:4000" >/dev/null [[ -f "$apps_dir/http:/127.0.0.1:4000/.desktop" ]] && fail "webapp remove deletes a launcher left nested by an older install" pass "webapp remove reaches a nested legacy launcher" + +# Removing by name on a machine with no applications directory yet must stay +# quiet: omarchy-remove-gaming-xbox-cloud calls it without hiding stderr. +noise=$(HOME="$tmp_dir/empty" PATH="$tmp_dir/bin:$PATH" OMARCHY_REMOVE_NOTIFY=false \ + "$ROOT/bin/omarchy-webapp-remove" "Xbox Cloud Gaming" 2>&1 >/dev/null) +[[ -n $noise ]] && + fail "webapp remove stays quiet with no applications directory" "$noise" +pass "webapp remove stays quiet when there is no applications directory" From 9d8c0176d172a4397b560aae7b8fa751aac586dd Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 27 Aug 2026 21:40:31 +0200 Subject: [PATCH 38/41] Make the cache tests fail when either check is removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neither half of the validation was covered. Dropping `&& -c $cached` from the wrapper left the whole file green: all three poison values fail on the pathname prefix, so none of them ever reached the character-device test. A path that matches the hiddev glob but is not a device now covers it, and it is the real case rather than a synthetic one -- the display replugs, the interface renumbers, and the cached node is gone. It is added only when the host has no such node, so a machine with the display attached cannot fail there spuriously. The no-XDG_RUNTIME_DIR assertion had the same problem for the opposite reason: its decoy held a path the validation rejects on its own, so restoring the `${XDG_RUNTIME_DIR:-/tmp}` fallback left it passing. It asserts on the open now instead of on the contents -- a FIFO with no writer blocks whoever opens it, so a wrapper that consults the path hangs and one that ignores it exits. mkfifo is atomic and fails outright if the path is taken, so it still neither overwrites a file nor follows a symlink at the fixed path. Clearing created_tmp_cache as soon as the decoy is removed keeps this run's EXIT trap from deleting a concurrent run's decoy at the same fixed path, which would have let that run pass against the old code. 🤖 Generated by Opus 5 in Claude Code. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh --- .../brightness-display-apple-cache-test.sh | 51 ++++++++++++------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/test/shell.d/brightness-display-apple-cache-test.sh b/test/shell.d/brightness-display-apple-cache-test.sh index c3ac8d53..18b65cd0 100755 --- a/test/shell.d/brightness-display-apple-cache-test.sh +++ b/test/shell.d/brightness-display-apple-cache-test.sh @@ -82,7 +82,19 @@ cache_file="$xdg_dir/omarchy-brightness-display-apple.device" regular_file="$TMPDIR/not-a-device" : >"$regular_file" -for poison in "/dev/null" "$regular_file" "/tmp/omarchy-evil"; do +poisons=("/dev/null" "$regular_file" "/tmp/omarchy-evil") + +# The cases above all fail on the pathname prefix, so none of them reaches the -c +# test -- drop `&& -c $cached` from the wrapper and they all still pass. A path +# that matches the hiddev glob but is not a character device is what -c is for, +# and it is the realistic stale cache: the display replugs, the interface +# renumbers, and the cached node is simply gone. Add it only when the host really +# has no such node, so a machine with the display attached cannot fail here. +if [[ ! -e /dev/hiddev999 ]]; then + poisons+=("/dev/hiddev999") +fi + +for poison in "${poisons[@]}"; do printf '%s\n' "$poison" >"$cache_file" output=$(run_wrapper "$xdg_dir" "+5%") if grep -qF -- "$poison -- +5%" "$asd_log"; then @@ -91,10 +103,10 @@ for poison in "/dev/null" "$regular_file" "/tmp/omarchy-evil"; do done pass "wrapper rejects a cached path that is not a hiddev character device" -# NOTE: the complementary arm (a cache value that DOES match /dev/hiddev* but is -# not a character device) cannot be built without root -- only real device nodes -# live under /dev. It is covered by the -c test and exercised below only when a -# real hiddev node happens to be present. +# NOTE: the /dev/hiddev999 case above covers the -c test for a glob-matching path +# that does not exist. The remaining arm -- a path under /dev that exists, matches +# the glob, and is not a character device -- cannot be built without root, since +# only real device nodes live there. # --- A legitimate cached hiddev node is trusted (only where HW is present) ---- real_hiddev="" @@ -115,20 +127,25 @@ else fi # --- With no XDG_RUNTIME_DIR, the predictable /tmp cache is not consulted ------ -# Create the decoy atomically with noclobber (O_EXCL) instead of check-then-create: -# this refuses to overwrite an existing file or follow a symlink at the fixed path, -# closing the TOCTOU/symlink race. The fixed path is required -- it is exactly the -# path the old code would have formed, so a decoy anywhere else would prove nothing. -# If the path is already taken, skip rather than touch it; the EXIT trap removes the -# decoy only when this test created it. -if ( set -C; printf '%s\n' "/dev/null" >"$tmp_cache" ) 2>/dev/null; then +# Assert on the open, not on the contents. A decoy holding a rejectable path proves +# nothing: the validation above refuses it whether or not the /tmp fallback is still +# there, so that assertion passes against both wrappers. A FIFO with no writer blocks +# whoever opens it, so a wrapper that consults the path hangs and one that ignores it +# exits -- which separates the two. mkfifo is atomic and fails outright if the path is +# taken, so it neither overwrites a file nor follows a symlink; the fixed path is +# required, being exactly the path the old code would have formed. Clear the flag as +# soon as the decoy is gone, so a concurrent run's decoy cannot be removed by this +# run's EXIT trap. +if mkfifo "$tmp_cache" 2>/dev/null; then created_tmp_cache=1 - output=$(run_wrapper "" "+5%") - used=1 - grep -qF -- "/dev/null -- +5%" "$asd_log" || used=0 + status=0 + env -u XDG_RUNTIME_DIR PATH="$stub_dir:$ROOT/bin:$PATH" \ + timeout 5 omarchy-brightness-display-apple "+5%" >/dev/null 2>&1 || status=$? rm -f "$tmp_cache" - (( used == 0 )) || - fail "wrapper consulted the world-writable /tmp cache with no XDG_RUNTIME_DIR" "$output" + created_tmp_cache=0 + (( status != 124 )) || + fail "wrapper consulted the world-writable /tmp cache with no XDG_RUNTIME_DIR" \ + "it blocked reading the FIFO decoy at $tmp_cache" pass "wrapper ignores the /tmp cache path when XDG_RUNTIME_DIR is unset" else pass "$tmp_cache already present or not safely creatable; skipping the /tmp-fallback case" From d1845245d3c7441270f4da91e0032281486c2fd9 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 27 Aug 2026 21:40:31 +0200 Subject: [PATCH 39/41] Unquote the new variables inside [[ ]] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md asks for unquoted variables inside `[[ ]]`, with quotes reserved for string literals being compared. The three conditions added here quoted them. 🤖 Generated by Opus 5 in Claude Code. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh --- bin/omarchy-brightness-display-apple | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bin/omarchy-brightness-display-apple b/bin/omarchy-brightness-display-apple index 1027686d..c9cce2ff 100755 --- a/bin/omarchy-brightness-display-apple +++ b/bin/omarchy-brightness-display-apple @@ -8,7 +8,7 @@ # caching (detect every run) rather than fall back to a predictable, world-writable # /tmp path another user could pre-create. device_cache="" -if [[ -n "${XDG_RUNTIME_DIR:-}" ]]; then +if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then device_cache="$XDG_RUNTIME_DIR/omarchy-brightness-display-apple.device" fi no_osd=0 @@ -34,7 +34,7 @@ find_apple_display_device() { local cached="" local device="" - if [[ -n "$device_cache" && -r $device_cache ]]; then + if [[ -n $device_cache && -r $device_cache ]]; then read -r cached <"$device_cache" || true # Trust a cached value only if it still names a hiddev character device. A # stale or unexpected cache (a regular file, a non-hiddev node) is ignored and @@ -50,7 +50,7 @@ find_apple_display_device() { device="$(detect_apple_display_device)" || return 1 [[ -n $device ]] || return 1 - if [[ -n "$device_cache" ]]; then + if [[ -n $device_cache ]]; then printf '%s\n' "$device" >"$device_cache" fi printf '%s\n' "$device" From 5925929cb6c72a8fe4de3ac297179a0020cdbcfa Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Fri, 28 Aug 2026 19:00:03 -0400 Subject: [PATCH 40/41] Stop the browser policy EXIT trap from reporting a clean run as failed --- bin/omarchy-theme-set-browser-policy | 7 ++++++- test/shell.d/browser-policy-dir-test.sh | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/bin/omarchy-theme-set-browser-policy b/bin/omarchy-theme-set-browser-policy index 6f628f9b..ccd07722 100755 --- a/bin/omarchy-theme-set-browser-policy +++ b/bin/omarchy-theme-set-browser-policy @@ -76,8 +76,13 @@ require_root "$color" failed=0 staged="" +# Bash 5.3 makes the EXIT trap's last command decide the script's exit status, +# so this handler must not end on a false test. Every successful run clears +# staged, and a trailing `[[ -n $staged ]] && ...` would report that as failure. cleanup() { - [[ -n $staged ]] && rm -f "$staged" + if [[ -n $staged ]]; then + rm -f "$staged" + fi } trap cleanup EXIT diff --git a/test/shell.d/browser-policy-dir-test.sh b/test/shell.d/browser-policy-dir-test.sh index 20b8067c..39a87c2b 100755 --- a/test/shell.d/browser-policy-dir-test.sh +++ b/test/shell.d/browser-policy-dir-test.sh @@ -279,6 +279,21 @@ grep -F 'exit "$failed"' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null || fail "omarchy-theme-set-browser exits non-zero when a policy write fails" pass "omarchy-theme-set-browser exits non-zero when a policy write fails" +# Bash 5.3 adopts the EXIT trap's last status as the script's exit status, so a +# handler ending on a false test turns a clean run into a failure and aborts the +# migration that calls this through omarchy-theme-set-browser. +policy_cleanup=$(sed -n '/^cleanup() {/,/^}/p' "$ROOT/bin/omarchy-theme-set-browser-policy") +[[ -n $policy_cleanup ]] || fail "omarchy-theme-set-browser-policy defines an EXIT cleanup handler" +eval "$policy_cleanup" +staged="" +cleanup || fail "omarchy-theme-set-browser-policy's EXIT trap succeeds with nothing staged" +staged=$test_tmp/staged-policy +: >"$staged" +cleanup || fail "omarchy-theme-set-browser-policy's EXIT trap succeeds with a staged file" +[[ ! -e $staged ]] || fail "omarchy-theme-set-browser-policy's EXIT trap removes the staged file" +unset -f cleanup +pass "omarchy-theme-set-browser-policy's EXIT trap never leaks a failure status" + policy_files=( "$ROOT/bin/omarchy-install-browser" "$ROOT/bin/omarchy-provision-owner" From 6b10dbf1910f77deb3ac1082adc6e55101efe1cc Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Fri, 28 Aug 2026 19:00:03 -0400 Subject: [PATCH 41/41] Harden Firefox policy dirs even when the theme refresh fails --- migrations/1787515927.sh | 5 ++++- test/shell.d/browser-policy-dir-test.sh | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/migrations/1787515927.sh b/migrations/1787515927.sh index f1f33869..9adae003 100644 --- a/migrations/1787515927.sh +++ b/migrations/1787515927.sh @@ -9,8 +9,11 @@ for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do repaired=1 done +# Repainting the policy color is cosmetic and the next theme change redoes it. +# Under bash -euo pipefail a failure here would abort the migration before the +# Firefox directories below are hardened, and the marker would never be written. if (( repaired )); then - omarchy-theme-set-browser + omarchy-theme-set-browser || true fi for dir in "${BROWSER_POLICY_FIREFOX_DIRS[@]}"; do diff --git a/test/shell.d/browser-policy-dir-test.sh b/test/shell.d/browser-policy-dir-test.sh index 39a87c2b..0d66d216 100755 --- a/test/shell.d/browser-policy-dir-test.sh +++ b/test/shell.d/browser-policy-dir-test.sh @@ -294,6 +294,10 @@ cleanup || fail "omarchy-theme-set-browser-policy's EXIT trap succeeds with a st unset -f cleanup pass "omarchy-theme-set-browser-policy's EXIT trap never leaks a failure status" +grep -F 'omarchy-theme-set-browser || true' "$ROOT/migrations/1787515927.sh" >/dev/null || + fail "the policy-directory migration hardens Firefox even when the theme refresh fails" +pass "the policy-directory migration does not abort on a failed theme refresh" + policy_files=( "$ROOT/bin/omarchy-install-browser" "$ROOT/bin/omarchy-provision-owner"