Run notification click actions as argv, not shell strings

The click action of a notification was a free-form shell string run through
`bash -lc`, safe only when every sender shell-quoted every interpolated value
perfectly. One slip is RCE: a hostile yt-dlp video title forged an output
record and injected an mpv option into the click command (mehmetince.net RCE,
partially addressed by #7847).

Add a parameterized transport: omarchy-notification-send gains --exec-arg
(repeatable), encoding a JSON argv into the omarchy-exec-argv hint. The shell
runs it with Quickshell.execDetached(argv) and no shell, so data an attacker
controls is only ever one argument and can never be reparsed as a command. The
shell fails closed on a malformed argv hint.

The legacy free-form --exec string is retained but honored only from Omarchy's
own omarchy-action toasts, and deprecated. Migrate all in-repo callers
(screenshot, screen recording, taildrop receive, migrate-notify, crash-watch,
yt-dlp host) to --exec-arg. Update docs and tests.
This commit is contained in:
Ryan Hughes
2026-08-23 12:00:03 -04:00
parent ef6d9e6605
commit 07443f3970
16 changed files with 219 additions and 45 deletions
+1 -1
View File
@@ -228,7 +228,7 @@ stop_screenrecording() {
omarchy-notification-send "Screen recording saved" "Open with Super + Alt + , (or click this)" \
-t 10000 --image "${preview:-$filename}" \
--exec "$(printf 'mpv %q' "$filename")"
--exec-arg mpv --exec-arg -- --exec-arg "$filename"
# The shell loads the thumbnail into memory when the toast appears and never
# re-reads the file, so the preview only has to outlive that load -- not the
+1 -1
View File
@@ -70,7 +70,7 @@ case "$PROCESSING" in
# notification outage must not report the capture itself as failed.
omarchy-notification-send "Screenshot saved to clipboard and file" "Edit with Super + Alt + , (or click this)" \
--image "$FILEPATH" \
--exec "$(printf '%q %q' "$SCREENSHOT_EDITOR" "$FILEPATH")" || true
--exec-arg "$SCREENSHOT_EDITOR" --exec-arg "$FILEPATH" || true
;;
copy)
grim -g "$SELECTION" - | wl-copy --type image/png
+5 -5
View File
@@ -70,10 +70,10 @@ title_from_file() {
fi
}
# `--` keeps a path that starts with `-` from being parsed as an mpv option.
playback_command() {
printf 'mpv -- %q' "$1"
}
# The click action is passed to the shell as an argv vector (--exec-arg), so the
# path is one literal argument and never reaches a shell. `--` still guards mpv
# itself against parsing a leading-dash filename as an option.
playback_exec_args=(--exec-arg mpv --exec-arg -- --exec-arg)
# Drive the Quickshell OSD — a single overlay that updates in place (like the
# volume/brightness bar), so download progress never stacks like notifications.
@@ -153,7 +153,7 @@ download_url() {
# toast would exit before the thumbnail cleanup below is ever scheduled.
omarchy-notification-send -g 󰄬 "Download complete" "$title" \
-t 10000 --image "${preview:-$filepath}" \
--exec "$(playback_command "$filepath")" || true
"${playback_exec_args[@]}" "$filepath" || true
# The shell loads the thumbnail into memory when the toast appears and never
# re-reads the file, so the preview only has to outlive that load, not the
+6 -6
View File
@@ -24,9 +24,7 @@ readonly ignore_pattern=${OMARCHY_CRASH_IGNORE:-}
declare -A last_notified
announce() {
local comm=$1 pid=$2 exe=$3 signal=$4 exec_command
exec_command=$(printf 'omarchy-agent-crash %q %q %q %q' "$pid" "$comm" "$exe" "$signal")
local comm=$1 pid=$2 exe=$3 signal=$4
# The shell owns org.freedesktop.Notifications, so a shell crash takes the
# notification server down with it and a toast sent into that gap is lost.
@@ -34,13 +32,15 @@ announce() {
# likely to be delivered is the one most worth reporting.
omarchy-notification-wait || return 1
# --exec rather than a libnotify action: the shell runs clicks from its own
# omarchy-exec hint and never emits ActionInvoked. Keeps the default
# --exec-arg rather than a libnotify action: the shell runs clicks from its own
# omarchy-exec-argv hint and never emits ActionInvoked. Keeps the default
# "omarchy-action" app name too, the only one shouldBypassDnd() lets through.
# The argv form carries the crash details as literal arguments, so a hostile
# process name can't be reparsed as a command when the toast is clicked.
omarchy-notification-send \
--urgency critical \
--glyph "$CRASH_GLYPH" \
--exec "$exec_command" \
--exec-arg omarchy-agent-crash --exec-arg "$pid" --exec-arg "$comm" --exec-arg "$exe" --exec-arg "$signal" \
"Process crashed: $comm" \
"Click to diagnose with AI"
}
+1 -1
View File
@@ -41,7 +41,7 @@ fi
# The shell keeps the click command with the toast, so this oneshot can hand the
# invitation over and exit instead of staying activated until it is answered.
omarchy-notification-send -u critical -g  "Pending Omarchy Migrations" "$message" \
--exec "omarchy-launch-floating-terminal-with-presentation omarchy-migrate" && exit 0
--exec-arg omarchy-launch-floating-terminal-with-presentation --exec-arg omarchy-migrate && exit 0
# Reached when the notification could not be handed off, so fall back to telling
# the user in the terminal.
+28 -3
View File
@@ -1,7 +1,7 @@
#!/bin/bash
# omarchy:summary=Send an Omarchy desktop notification
# omarchy:args=[--exec <command>] [--app-name <app-name>] [-g <glyph>] [-u <low|normal|critical>] [--image <path-or-uri>] <headline> [description] [notify-send options]
# omarchy:args=[--exec-arg <arg>]... [--exec <command>] [--app-name <app-name>] [-g <glyph>] [-u <low|normal|critical>] [--image <path-or-uri>] <headline> [description] [notify-send options]
# omarchy:examples=omarchy notification send "Reminder" "5 minutes are up" -g 󰢌
set -euo pipefail
@@ -13,6 +13,7 @@ urgency="low"
app_name="omarchy-action"
image=
exec_command=
exec_args=()
args=()
parsed_option_args=0
@@ -63,6 +64,19 @@ parse_omarchy_option() {
parsed_option_args=2
return 0
;;
--exec-arg)
if (($# < 2)); then
echo "Missing value for $1" >&2
exit 1
fi
# Each --exec-arg contributes one literal argument to the click command.
# The shell runs the resulting argv vector directly (no shell), so callers
# pass untrusted data as its own --exec-arg rather than quoting it into a
# command string. Value is taken verbatim, even when it starts with "-".
exec_args+=("$2")
parsed_option_args=2
return 0
;;
esac
return 1
@@ -77,7 +91,7 @@ while (($# > 0)); do
done
if (($# < 1)); then
echo "Usage: omarchy-notification-send [--exec <command>] [--app-name <app-name>] [-g <glyph>] [-u <low|normal|critical>] [--image <path-or-uri>] <headline> [description] [notify-send options]"
echo "Usage: omarchy-notification-send [--exec-arg <arg>]... [--exec <command>] [--app-name <app-name>] [-g <glyph>] [-u <low|normal|critical>] [--image <path-or-uri>] <headline> [description] [notify-send options]"
exit 1
fi
@@ -112,7 +126,18 @@ fi
# The shell runs the click command itself, from a copy it keeps alongside the
# on-screen popup. A libnotify action would instead keep this process blocked
# until the click, and die unanswered whenever the shell restarts underneath it.
if [[ -n $exec_command ]]; then
#
# --exec-arg builds an argv vector the shell runs directly, so a value carrying
# untrusted data is only ever one argument and never reaches a shell. It wins
# over the legacy free-form --exec string, which is run through `bash -lc` and
# is only safe when the caller quoted every interpolated value itself.
if ((${#exec_args[@]} > 0)); then
# NUL-delimit the args into jq so every byte survives as data — jq's own
# --args would eat a bare "--", and a title with a newline must stay one
# element, not split the vector.
exec_argv_json=$(printf '%s\0' "${exec_args[@]}" | jq -Rsc 'split("\u0000")[:-1]')
args+=("--hint=string:omarchy-exec-argv:$exec_argv_json")
elif [[ -n $exec_command ]]; then
args+=("--hint=string:omarchy-exec:$exec_command")
fi
+1 -1
View File
@@ -72,7 +72,7 @@ announce() {
# Announcing is best-effort: the file is already delivered, and under `set -e`
# a notification outage would otherwise kill the long-running receiver.
omarchy-notification-send "${args[@]}" --exec "$(printf 'xdg-open %q' "$path")" || true
omarchy-notification-send "${args[@]}" --exec-arg xdg-open --exec-arg "$path" || true
}
deliver() {