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)" \ omarchy-notification-send "Screen recording saved" "Open with Super + Alt + , (or click this)" \
-t 10000 --image "${preview:-$filename}" \ -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 # 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 # 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. # 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)" \ omarchy-notification-send "Screenshot saved to clipboard and file" "Edit with Super + Alt + , (or click this)" \
--image "$FILEPATH" \ --image "$FILEPATH" \
--exec "$(printf '%q %q' "$SCREENSHOT_EDITOR" "$FILEPATH")" || true --exec-arg "$SCREENSHOT_EDITOR" --exec-arg "$FILEPATH" || true
;; ;;
copy) copy)
grim -g "$SELECTION" - | wl-copy --type image/png grim -g "$SELECTION" - | wl-copy --type image/png
+5 -5
View File
@@ -70,10 +70,10 @@ title_from_file() {
fi fi
} }
# `--` keeps a path that starts with `-` from being parsed as an mpv option. # The click action is passed to the shell as an argv vector (--exec-arg), so the
playback_command() { # path is one literal argument and never reaches a shell. `--` still guards mpv
printf 'mpv -- %q' "$1" # 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 # Drive the Quickshell OSD — a single overlay that updates in place (like the
# volume/brightness bar), so download progress never stacks like notifications. # 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. # toast would exit before the thumbnail cleanup below is ever scheduled.
omarchy-notification-send -g 󰄬 "Download complete" "$title" \ omarchy-notification-send -g 󰄬 "Download complete" "$title" \
-t 10000 --image "${preview:-$filepath}" \ -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 # 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 # 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 declare -A last_notified
announce() { announce() {
local comm=$1 pid=$2 exe=$3 signal=$4 exec_command local comm=$1 pid=$2 exe=$3 signal=$4
exec_command=$(printf 'omarchy-agent-crash %q %q %q %q' "$pid" "$comm" "$exe" "$signal")
# The shell owns org.freedesktop.Notifications, so a shell crash takes the # 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. # 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. # likely to be delivered is the one most worth reporting.
omarchy-notification-wait || return 1 omarchy-notification-wait || return 1
# --exec rather than a libnotify action: the shell runs clicks from its own # --exec-arg rather than a libnotify action: the shell runs clicks from its own
# omarchy-exec hint and never emits ActionInvoked. Keeps the default # omarchy-exec-argv hint and never emits ActionInvoked. Keeps the default
# "omarchy-action" app name too, the only one shouldBypassDnd() lets through. # "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 \ omarchy-notification-send \
--urgency critical \ --urgency critical \
--glyph "$CRASH_GLYPH" \ --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" \ "Process crashed: $comm" \
"Click to diagnose with AI" "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 # 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. # invitation over and exit instead of staying activated until it is answered.
omarchy-notification-send -u critical -g  "Pending Omarchy Migrations" "$message" \ 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 # Reached when the notification could not be handed off, so fall back to telling
# the user in the terminal. # the user in the terminal.
+28 -3
View File
@@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
# omarchy:summary=Send an Omarchy desktop notification # 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 󰢌 # omarchy:examples=omarchy notification send "Reminder" "5 minutes are up" -g 󰢌
set -euo pipefail set -euo pipefail
@@ -13,6 +13,7 @@ urgency="low"
app_name="omarchy-action" app_name="omarchy-action"
image= image=
exec_command= exec_command=
exec_args=()
args=() args=()
parsed_option_args=0 parsed_option_args=0
@@ -63,6 +64,19 @@ parse_omarchy_option() {
parsed_option_args=2 parsed_option_args=2
return 0 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 esac
return 1 return 1
@@ -77,7 +91,7 @@ while (($# > 0)); do
done done
if (($# < 1)); then 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 exit 1
fi fi
@@ -112,7 +126,18 @@ fi
# The shell runs the click command itself, from a copy it keeps alongside the # 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 # on-screen popup. A libnotify action would instead keep this process blocked
# until the click, and die unanswered whenever the shell restarts underneath it. # 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") args+=("--hint=string:omarchy-exec:$exec_command")
fi fi
+1 -1
View File
@@ -72,7 +72,7 @@ announce() {
# Announcing is best-effort: the file is already delivered, and under `set -e` # Announcing is best-effort: the file is already delivered, and under `set -e`
# a notification outage would otherwise kill the long-running receiver. # 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() { deliver() {
+26 -5
View File
@@ -69,7 +69,8 @@ notify-send arguments and passes any unrecognized options through:
| Flag | Becomes | Meaning | | Flag | Becomes | Meaning |
|---|---|---| |---|---|---|
| `-g` / `--glyph` | `--hint=string:omarchy-glyph:` | Nerd Font glyph for the icon slot when no image icon resolves | | `-g` / `--glyph` | `--hint=string:omarchy-glyph:` | Nerd Font glyph for the icon slot when no image icon resolves |
| `--exec` | `--hint=string:omarchy-exec:` | shell command the card runs when clicked | | `--exec-arg` (repeatable) | `--hint=string:omarchy-exec-argv:` | one literal argument of the click command; the collected args become a JSON argv the shell runs without a shell |
| `--exec` | `--hint=string:omarchy-exec:` | legacy free-form shell command the card runs when clicked (deprecated — see below) |
| `--image` | `--hint=string:image-path:` | the standard freedesktop image hint | | `--image` | `--hint=string:image-path:` | the standard freedesktop image hint |
| `--app-name` | `-a` | defaults to `omarchy-action` | | `--app-name` | `-a` | defaults to `omarchy-action` |
| `-u` / `--urgency` | `-u` | defaults to `low` | | `-u` / `--urgency` | `-u` | defaults to `low` |
@@ -78,9 +79,9 @@ The defaults are the point: an unadorned `omarchy-notification-send "Done"`
is a low-urgency user-action toast that pops through DND and is treated as is a low-urgency user-action toast that pops through DND and is treated as
ephemeral noise when silenced. ephemeral noise when silenced.
`--exec` is deliberately not a libnotify action. An action keeps the sender The click command is deliberately not a libnotify action. An action keeps the
blocked waiting for `ActionInvoked`, and dies unanswered whenever the shell sender blocked waiting for `ActionInvoked`, and dies unanswered whenever the
restarts underneath it — the installer toasts restart the shell as their shell restarts underneath it — the installer toasts restart the shell as their
first act. Carrying the command as a hint means the shell executes the click first act. Carrying the command as a hint means the shell executes the click
itself (detached, so the command outlives the shell process) from the copy it itself (detached, so the command outlives the shell process) from the copy it
keeps with the popup, which the persistence files preserve: a restored toast keeps with the popup, which the persistence files preserve: a restored toast
@@ -90,6 +91,25 @@ immediately. For third-party clients the click falls back to the libnotify
window by class via `omarchy-hyprland-focus-app` — chat apps rarely register window by class via `omarchy-hyprland-focus-app` — chat apps rarely register
an action and just expect click-to-jump. an action and just expect click-to-jump.
### Click commands must be argv, not shell strings
Prefer `--exec-arg` for every click command. Each `--exec-arg` contributes one
literal argument; the shell runs the resulting vector with
`Quickshell.execDetached(argv)` and **no shell**, so a value carrying data an
attacker controls — a downloaded video's title, a received filename, a crashed
process's name — is only ever a single argument and can never be reparsed as a
command. This is the parameterized form: pass untrusted data as its own
`--exec-arg` rather than quoting it into a string.
`--exec` is the legacy free-form variant, run through `bash -lc`. It is safe
only when the caller shell-quoted every interpolated value perfectly — the same
trap as string-concatenated SQL, and the exact shape of the yt-dlp title RCE
that motivated the argv form. It is retained for compatibility and honored only
from toasts whose `app_name` is Omarchy's own `omarchy-action`; new senders must
use `--exec-arg`. The shell fails closed on a malformed argv hint (it must be a
JSON array of strings whose program is present and not a leading-dash option)
rather than letting it fall through to a shell.
## Helper commands ## Helper commands
- `omarchy-notification-wait [timeout]` — polls until the shell answers IPC - `omarchy-notification-wait [timeout]` — polls until the shell answers IPC
@@ -114,7 +134,8 @@ Everything goes through the same sender contract, so the pieces are small:
`battery-low` hook. `battery-low` hook.
- **Crash capture** — `omarchy-crash-watch` follows the systemd-coredump - **Crash capture** — `omarchy-crash-watch` follows the systemd-coredump
journal stream and announces each crashed program (deduped per minute) as a journal stream and announces each crashed program (deduped per minute) as a
critical toast whose `--exec` runs `omarchy-agent-crash`. It waits for the critical toast whose click runs `omarchy-agent-crash` (via `--exec-arg`, so a
hostile process name stays a literal argument). It waits for the
server first: a shell crash takes the notification server down with it, and server first: a shell crash takes the notification server down with it, and
that crash is the one most worth reporting. that crash is the one most worth reporting.
- **Pending migrations** — `omarchy-migrate-notify` (from its user service - **Pending migrations** — `omarchy-migrate-notify` (from its user service
+8
View File
@@ -54,6 +54,14 @@ QtObject {
Quickshell.execDetached(["bash", "-lc", command]) Quickshell.execDetached(["bash", "-lc", command])
} }
// Run an argv vector directly, without a shell. Nothing in the array is
// reparsed, so an argument carrying attacker-controlled data (a filename, a
// title) can never turn into a command. Prefer this over execDetached for any
// command assembled from untrusted input.
function execArgv(argv) {
Quickshell.execDetached(argv)
}
function isPlainObject(value) { function isPlainObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value) return value !== null && typeof value === "object" && !Array.isArray(value)
} }
@@ -63,10 +63,51 @@ function glyphFromHints(hints) {
// after a shell restart clicks through exactly like a live one. A libnotify // after a shell restart clicks through exactly like a live one. A libnotify
// action can't: its sender is still waiting on an id from a server generation // action can't: its sender is still waiting on an id from a server generation
// that no longer exists. // that no longer exists.
//
// This is a free-form shell string run through `bash -lc`, so it is safe only
// when every value interpolated into it was shell-quoted perfectly. It is kept
// for compatibility and honored only from Omarchy's own trusted toasts (see
// Service.invokePopupDefault); new senders use --exec-arg / the argv form
// below, which never reaches a shell.
function execFromHints(hints) { function execFromHints(hints) {
return stringHint(hints, "omarchy-exec") return stringHint(hints, "omarchy-exec")
} }
// The click action as an argv vector, sent by omarchy-notification-send
// --exec-arg and carried as a JSON array string in the omarchy-exec-argv hint.
// The shell runs it with Quickshell.execDetached (no shell), so a value that an
// attacker controls — a video title, a filename, a URL — is only ever one
// argument and can never be reparsed as a command. This is the parameterized
// form: the "prepared statement" to execFromHints's string concatenation.
function execArgvFromHints(hints) {
return stringHint(hints, "omarchy-exec-argv")
}
// Validate a persisted omarchy-exec-argv value into an argv the shell may run,
// or null for anything that is not one. A malformed or hostile hint must fail
// closed here rather than fall through to a shell: we require a JSON array of
// strings, non-empty, whose first element (the program) is present and does not
// start with "-" (which would let a forged record smuggle in a leading-dash
// option in the program slot).
function parseExecArgv(value) {
var text = String(value || "")
if (!text) return null
var parsed
try {
parsed = JSON.parse(text)
} catch (e) {
return null
}
if (!Array.isArray(parsed) || parsed.length === 0) return null
for (var i = 0; i < parsed.length; i++) {
if (typeof parsed[i] !== "string") return null
}
if (!parsed[0] || parsed[0].charAt(0) === "-") return null
return parsed
}
function shouldRenderCompactGlyph(glyph, iconSource, singleLineToast) { function shouldRenderCompactGlyph(glyph, iconSource, singleLineToast) {
return String(glyph || "").length > 0 && String(iconSource || "").length === 0 && !!singleLineToast return String(glyph || "").length > 0 && String(iconSource || "").length === 0 && !!singleLineToast
} }
@@ -86,6 +127,7 @@ function snapshotOf(notification, timestamp) {
image: n.image || "", image: n.image || "",
glyph: glyphFromHints(n.hints), glyph: glyphFromHints(n.hints),
exec: execFromHints(n.hints), exec: execFromHints(n.hints),
execArgv: execArgvFromHints(n.hints),
urgency: n.urgency, urgency: n.urgency,
expireTimeout: expireTimeout, expireTimeout: expireTimeout,
timestamp: timestamp === undefined ? Date.now() : timestamp timestamp: timestamp === undefined ? Date.now() : timestamp
@@ -94,7 +136,7 @@ function snapshotOf(notification, timestamp) {
// Everything the popup card draws, and therefore everything an in-place // Everything the popup card draws, and therefore everything an in-place
// update has to write through to the row and its file. // update has to write through to the row and its file.
var POPUP_ROLES = ["app", "appIcon", "summary", "body", "image", "glyph", "exec", "urgency", "expireTimeout"] var POPUP_ROLES = ["app", "appIcon", "summary", "body", "image", "glyph", "exec", "execArgv", "urgency", "expireTimeout"]
function popupRoles() { function popupRoles() {
return POPUP_ROLES return POPUP_ROLES
@@ -137,6 +179,7 @@ function historyEntry(value, normalUrgency) {
image: e.image || "", image: e.image || "",
glyph: e.glyph || "", glyph: e.glyph || "",
exec: e.exec || "", exec: e.exec || "",
execArgv: e.execArgv || "",
urgency: typeof e.urgency === "number" ? e.urgency : normalUrgency, urgency: typeof e.urgency === "number" ? e.urgency : normalUrgency,
expireTimeout: 0, expireTimeout: 0,
timestamp: e.timestamp || 0 timestamp: e.timestamp || 0
@@ -347,6 +390,8 @@ if (typeof module !== "undefined") {
stringHint: stringHint, stringHint: stringHint,
glyphFromHints: glyphFromHints, glyphFromHints: glyphFromHints,
execFromHints: execFromHints, execFromHints: execFromHints,
execArgvFromHints: execArgvFromHints,
parseExecArgv: parseExecArgv,
shouldRenderCompactGlyph: shouldRenderCompactGlyph, shouldRenderCompactGlyph: shouldRenderCompactGlyph,
snapshotOf: snapshotOf, snapshotOf: snapshotOf,
popupRoles: popupRoles, popupRoles: popupRoles,
+18 -3
View File
@@ -360,10 +360,25 @@ Item {
function invokePopupDefault(index) { function invokePopupDefault(index) {
if (index < 0 || index >= popupModel.count) return if (index < 0 || index >= popupModel.count) return
var entry = popupModel.get(index) var entry = popupModel.get(index)
// Preferred path: an argv vector run without a shell, so data an attacker
// controls (a video title, a filename) is only ever an argument and can
// never be reparsed as a command. Detached so it outlives the shell, which
// the installer toasts depend on: they restart the shell as their first act.
var argv = NotificationLogic.parseExecArgv(entry ? entry.execArgv : "")
if (argv) {
Util.execArgv(argv)
dismissPopup(index)
return
}
// Legacy free-form shell exec (deprecated). It runs through `bash -lc`, so
// it is only as safe as the sender's quoting — honored solely from
// Omarchy's own trusted toasts. app_name is spoofable, so this is a
// compatibility courtesy, not a security boundary; new callers use the argv
// form above.
var command = entry ? String(entry.exec || "") : "" var command = entry ? String(entry.exec || "") : ""
if (command) { if (command && String(entry.app || "") === "omarchy-action") {
// Detached so the launched command outlives the shell process, which the
// installer toasts depend on: they restart the shell as their first act.
Util.execDetached(command) Util.execDetached(command)
dismissPopup(index) dismissPopup(index)
return return
+10 -12
View File
@@ -131,15 +131,13 @@ dash_title=$(host_fn title_from_file "$download_dir/--include.mp4")
[[ $dash_title == "Video" ]] || fail "yt-dlp native host does not pass a leading-dash title to notify-send" "$dash_title" [[ $dash_title == "Video" ]] || fail "yt-dlp native host does not pass a leading-dash title to notify-send" "$dash_title"
pass "yt-dlp native host does not pass a leading-dash title to notify-send" pass "yt-dlp native host does not pass a leading-dash title to notify-send"
cmd=$(host_fn playback_command --include=not-a-file) # The click action is an argv vector the shell runs without a shell, so the path
[[ $cmd == "mpv -- --include=not-a-file" ]] || # is a literal --exec-arg rather than a value quoted into a command string. The
fail "yt-dlp native host runs mpv with -- before the path" "$cmd" # prefix is static; `--` keeps mpv from parsing a leading-dash filename.
pass "yt-dlp native host runs mpv with -- before the path" playback_argv=$(host_fn eval 'printf "%s\n" "${playback_exec_args[@]}"')
[[ $playback_argv == $'--exec-arg\nmpv\n--exec-arg\n--\n--exec-arg' ]] ||
spaced_cmd=$(host_fn playback_command "$download_dir/a b.mp4") fail "yt-dlp native host runs mpv with -- before the path as argv" "$playback_argv"
[[ $spaced_cmd == "mpv -- $download_dir/a\\ b.mp4" ]] || pass "yt-dlp native host runs mpv with -- before the path as argv"
fail "yt-dlp native host shell-quotes the mpv path" "$spaced_cmd"
pass "yt-dlp native host shell-quotes the mpv path"
parse_script="$TMPDIR/parse-ytdlp-lines.sh" parse_script="$TMPDIR/parse-ytdlp-lines.sh"
cat >"$parse_script" <<'EOF' cat >"$parse_script" <<'EOF'
@@ -234,9 +232,9 @@ grep -qF -- $'OMARCHY_FILE\t%(title)s' "$ytdlp_argv" &&
fail "yt-dlp native host never prints the title into the file record" "$(cat "$ytdlp_argv")" fail "yt-dlp native host never prints the title into the file record" "$(cat "$ytdlp_argv")"
pass "yt-dlp native host never prints the title into the file record" pass "yt-dlp native host never prints the title into the file record"
grep -q -- "--exec mpv -- " "$notify_argv" || grep -q -- "--exec-arg mpv --exec-arg -- --exec-arg " "$notify_argv" ||
fail "yt-dlp native host builds the click command as mpv -- <path>" "$(cat "$notify_argv")" fail "yt-dlp native host builds the click command as an mpv -- <path> argv" "$(cat "$notify_argv")"
pass "yt-dlp native host builds the click command as mpv -- <path>" pass "yt-dlp native host builds the click command as an mpv -- <path> argv"
grep -qF -- "Download complete My Great Clip" "$notify_argv" || grep -qF -- "Download complete My Great Clip" "$notify_argv" ||
fail "yt-dlp native host toasts the page title, not the sanitised filename" "$(cat "$notify_argv")" fail "yt-dlp native host toasts the page title, not the sanitised filename" "$(cat "$notify_argv")"
+3 -2
View File
@@ -158,8 +158,9 @@ exec {foreign_lock_fd}>&-
rm -f "$test_tmp/notify-args" rm -f "$test_tmp/notify-args"
run_notify 1 >/dev/null 2>&1 run_notify 1 >/dev/null 2>&1
notify_args_written || fail "migration notifier sends the notification before exiting" notify_args_written || fail "migration notifier sends the notification before exiting"
grep -Fx -- '--exec' "$test_tmp/notify-args" >/dev/null || grep -Fx -- '--exec-arg' "$test_tmp/notify-args" >/dev/null ||
fail "migration notifier attaches the click command to the toast" fail "migration notifier attaches the click command to the toast"
grep -Fx 'omarchy-launch-floating-terminal-with-presentation omarchy-migrate' "$test_tmp/notify-args" >/dev/null || grep -Fx 'omarchy-launch-floating-terminal-with-presentation' "$test_tmp/notify-args" >/dev/null &&
grep -Fx 'omarchy-migrate' "$test_tmp/notify-args" >/dev/null ||
fail "migration notifier points the click command at omarchy-migrate" fail "migration notifier points the click command at omarchy-migrate"
pass "migration notifier lets the shell own the click instead of waiting for it" pass "migration notifier lets the shell own the click instead of waiting for it"
+28
View File
@@ -49,3 +49,31 @@ if OMARCHY_TEST_NOTIFY_ARGS="$args_file" PATH="$tmpdir:$ROOT/bin:$PATH" \
fail "notification wrapper rejects --exec without a command" fail "notification wrapper rejects --exec without a command"
fi fi
pass "notification wrapper rejects --exec without a command" pass "notification wrapper rejects --exec without a command"
# --exec-arg builds an argv vector encoded as a JSON array, so shell
# metacharacters in a value are carried as data, never as a command. The shell
# runs this argv directly (no shell), which is what keeps a hostile title or
# filename from becoming code when the toast is clicked.
: >"$args_file"
OMARCHY_TEST_NOTIFY_ARGS="$args_file" PATH="$tmpdir:$ROOT/bin:$PATH" \
omarchy-notification-send --exec-arg mpv --exec-arg -- --exec-arg '$(rm -rf ~); echo pwned' \
"Download complete" >/dev/null
argv_hint=$(grep -- "--hint=string:omarchy-exec-argv:" "$args_file")
argv_json=${argv_hint#--hint=string:omarchy-exec-argv:}
[[ $(jq -r '.[0]' <<<"$argv_json") == "mpv" ]] || fail "notification wrapper puts the program first in the exec argv"
[[ $(jq -r '.[1]' <<<"$argv_json") == "--" ]] || fail "notification wrapper preserves a -- separator in the exec argv"
[[ $(jq -r '.[2]' <<<"$argv_json") == '$(rm -rf ~); echo pwned' ]] ||
fail "notification wrapper carries shell metacharacters as literal argv data" "$argv_json"
grep -q "omarchy-exec:" "$args_file" && fail "notification wrapper emits no legacy exec string when --exec-arg is used"
pass "notification wrapper encodes --exec-arg as a literal JSON argv vector"
# The argv form is the safe one, so it wins when a caller supplies both.
: >"$args_file"
OMARCHY_TEST_NOTIFY_ARGS="$args_file" PATH="$tmpdir:$ROOT/bin:$PATH" \
omarchy-notification-send --exec 'legacy string' --exec-arg xdg-open --exec-arg /tmp/file \
"Headline" >/dev/null
grep -q -- "--hint=string:omarchy-exec-argv:" "$args_file" || fail "notification wrapper emits the argv hint when both exec forms are given"
grep -q -- "--hint=string:omarchy-exec:" "$args_file" && fail "notification wrapper drops the legacy exec string in favor of argv"
pass "notification wrapper prefers the argv exec form over the legacy string"
+32
View File
@@ -49,6 +49,38 @@ assert(!notifications.shouldBypassDnd({ appName: 'Slack', urgency: 2 }, 2), 'cri
assert(!notifications.shouldBypassDnd({ appName: 'omarchy-menu-keybindings', urgency: 1 }, 2), 'omarchy command app names do not bypass DND') assert(!notifications.shouldBypassDnd({ appName: 'omarchy-menu-keybindings', urgency: 1 }, 2), 'omarchy command app names do not bypass DND')
assert(!notifications.isEphemeralApp('omarchy-menu-keybindings'), 'notifications treat omarchy command app names as normal apps') assert(!notifications.isEphemeralApp('omarchy-menu-keybindings'), 'notifications treat omarchy command app names as normal apps')
// The click action's argv form: parsed from the persisted omarchy-exec-argv
// JSON only when it is a non-empty array of strings whose program is present
// and not a leading-dash option. Everything else fails closed so a malformed or
// hostile hint can never fall through to a shell.
assertDeepEqual(
notifications.parseExecArgv('["mpv","--","/home/me/a b.mp4"]'),
['mpv', '--', '/home/me/a b.mp4'],
'notifications parse a valid exec argv vector'
)
assertEqual(notifications.parseExecArgv(''), null, 'notifications reject an empty exec argv hint')
assertEqual(notifications.parseExecArgv('not json'), null, 'notifications reject a non-JSON exec argv hint')
assertEqual(notifications.parseExecArgv('"mpv"'), null, 'notifications reject an exec argv hint that is not an array')
assertEqual(notifications.parseExecArgv('[]'), null, 'notifications reject an empty exec argv array')
assertEqual(notifications.parseExecArgv('["mpv",5]'), null, 'notifications reject a non-string element in the exec argv')
assertEqual(notifications.parseExecArgv('["--include=x","y"]'), null, 'notifications reject a leading-dash program in the exec argv')
assertEqual(notifications.parseExecArgv('["",""]'), null, 'notifications reject an empty program in the exec argv')
// The argv vector rides on the snapshot as the raw JSON string, so the model's
// value comparison stays a plain string compare and the file round-trip is
// lossless.
const execSnapshot = notifications.snapshotOf({
id: 3,
appName: 'omarchy-action',
summary: 'Download complete',
hints: { 'omarchy-exec-argv': '["mpv","--","/tmp/clip.mp4"]' }
}, 1)
assertEqual(
execSnapshot.execArgv,
'["mpv","--","/tmp/clip.mp4"]',
'notifications carry the exec argv hint onto the snapshot'
)
assertDeepEqual( assertDeepEqual(
notifications.popupPlacement('top', 32, 6), notifications.popupPlacement('top', 32, 6),
{ {
+5 -4
View File
@@ -62,11 +62,12 @@ pass "taildrop receive announces other files with a glyph"
# The shell keeps the click command with the toast, so receiving does not have # The shell keeps the click command with the toast, so receiving does not have
# to sit blocked on an answer -- and the toast still opens the file after a shell # to sit blocked on an answer -- and the toast still opens the file after a shell
# restart. Names with spaces have to arrive quoted for the shell to run them. # restart. The path rides as its own --exec-arg, so the shell runs it as literal
grep -qF -- "--exec xdg-open $downloads/photo.png" <<<"$notifications" || # data with no quoting for a name with spaces to get wrong.
grep -qF -- "--exec-arg xdg-open --exec-arg $downloads/photo.png" <<<"$notifications" ||
fail "taildrop receive attaches the open command to the notification" "$notifications" fail "taildrop receive attaches the open command to the notification" "$notifications"
grep -qF -- "--exec xdg-open $(printf %q "$downloads/notes with space.pdf")" <<<"$notifications" || grep -qF -- "--exec-arg xdg-open --exec-arg $downloads/notes with space.pdf" <<<"$notifications" ||
fail "taildrop receive quotes spaced names in the open command" "$notifications" fail "taildrop receive carries spaced names as a literal open argument" "$notifications"
pass "taildrop receive lets a click open the received file" pass "taildrop receive lets a click open the received file"
grep -q "unrelated.txt" <<<"$notifications" && grep -q "unrelated.txt" <<<"$notifications" &&