Send notifications via the Notify D-Bus method, never notify-send

omarchy-notification-send now calls org.freedesktop.Notifications.Notify
directly with `busctl --user`, instead of shelling out to notify-send. Each
value is one typed D-Bus parameter, so there is no argv/option layer that could
reinterpret a relayed headline like `--hint=…` or `-rf` as an option or a hint:
the summary and body are strings, and omarchy-exec-argv is built only from
--exec. A leading `--` keeps busctl's own getopt from reading a dash-leading
value as a busctl option.

Map -i to app_icon, -t to expire_timeout, and urgency to the byte hint; unknown
options are now a hard error rather than a silent pass-through. Route the unused
hypr o.notify helper and the sample hooks through the wrapper too, and tighten
the bin-style test so nothing under bin/ may call notify-send. The test stubs
busctl and trips if notify-send is invoked.
This commit is contained in:
Ryan Hughes
2026-08-23 17:02:29 -04:00
parent be63983d16
commit e3729a385b
10 changed files with 206 additions and 147 deletions
+74 -44
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=[--app-name <app-name>] [-g <glyph>] [-u <low|normal|critical>] [--image <path-or-uri>] <headline> [description] [notify-send options] [--exec <program> [args...]] # omarchy:args=[--app-name <app-name>] [-g <glyph>] [-u <low|normal|critical>] [-i <icon>] [-t <ms>] [--image <path-or-uri>] <headline> [description] [--exec <program> [args...]]
# 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
@@ -11,46 +11,63 @@ description=""
glyph= glyph=
urgency="low" urgency="low"
app_name="omarchy-action" app_name="omarchy-action"
app_icon=""
image= image=
expire_timeout=-1
exec_args=() exec_args=()
exec_present=0 exec_present=0
args=()
parsed_option_args=0 parsed_option_args=0
usage() {
echo "Usage: omarchy-notification-send [--app-name <app-name>] [-g <glyph>] [-u <low|normal|critical>] [-i <icon>] [-t <ms>] [--image <path-or-uri>] <headline> [description] [--exec <program> [args...]]" >&2
}
need_value() {
if (($1 < 2)); then
echo "Missing value for $2" >&2
exit 1
fi
}
parse_omarchy_option() { parse_omarchy_option() {
case $1 in case $1 in
-g | --glyph) -g | --glyph)
if (($# < 2)); then need_value $# "$1"
echo "Missing value for $1" >&2
exit 1
fi
glyph=$2 glyph=$2
parsed_option_args=2 parsed_option_args=2
return 0 return 0
;; ;;
-u | --urgency) -u | --urgency)
if (($# < 2)); then need_value $# "$1"
echo "Missing value for $1" >&2
exit 1
fi
urgency="$2" urgency="$2"
parsed_option_args=2 parsed_option_args=2
return 0 return 0
;; ;;
--app-name) --app-name)
if (($# < 2)); then need_value $# "$1"
echo "Missing value for $1" >&2
exit 1
fi
app_name=$2 app_name=$2
parsed_option_args=2 parsed_option_args=2
return 0 return 0
;; ;;
--image) -i | --icon)
if (($# < 2)); then need_value $# "$1"
echo "Missing value for $1" >&2 app_icon=$2
parsed_option_args=2
return 0
;;
-t | --expire-time)
need_value $# "$1"
if [[ $2 != *[!0-9-]* && $2 =~ ^-?[0-9]+$ ]]; then
expire_timeout=$2
else
echo "Invalid $1 value (milliseconds expected): $2" >&2
exit 1 exit 1
fi fi
parsed_option_args=2
return 0
;;
--image)
need_value $# "$1"
image=$2 image=$2
parsed_option_args=2 parsed_option_args=2
return 0 return 0
@@ -69,7 +86,7 @@ while (($# > 0)); do
done done
if (($# < 1)); then if (($# < 1)); then
echo "Usage: omarchy-notification-send [--app-name <app-name>] [-g <glyph>] [-u <low|normal|critical>] [--image <path-or-uri>] <headline> [description] [notify-send options] [--exec <program> [args...]]" usage
exit 1 exit 1
fi fi
@@ -97,33 +114,35 @@ while (($# > 0)); do
elif parse_omarchy_option "$@"; then elif parse_omarchy_option "$@"; then
shift "$parsed_option_args" shift "$parsed_option_args"
else else
# --exec is the only door to a click command. A relayed title or filename echo "Unknown option: $1" >&2
# that lands here -- passthrough is the one position an untrusted value can usage
# still reach notify-send as an option -- must not be able to set the hint exit 1
# itself, which is the injection this transport exists to close.
if [[ $1 == *omarchy-exec-argv* ]]; then
echo "The click command hint can only be set with --exec, not passed through." >&2
exit 1
fi
args+=("$1")
shift
fi fi
done done
# Tag as a user-action toast so it pops through DND. case $urgency in
args+=("-a" "$app_name" "-u" "$urgency") low) urgency_byte=0 ;;
normal) urgency_byte=1 ;;
critical) urgency_byte=2 ;;
*)
echo "Unknown urgency: $urgency (use low, normal, or critical)" >&2
exit 1
;;
esac
# a{sv} hints, as busctl triples (key, variant type, value). urgency is a byte;
# the rest are strings. The click command rides here as omarchy-exec-argv, built
# only from --exec below.
hints=(urgency y "$urgency_byte")
if [[ -n $glyph ]]; then if [[ -n $glyph ]]; then
args+=("--hint=string:omarchy-glyph:$glyph") hints+=(omarchy-glyph s "$glyph")
fi fi
if [[ -n $image ]]; then if [[ -n $image ]]; then
args+=("--hint=string:image-path:$image") hints+=(image-path s "$image")
fi fi
# The click command travels with the popup as an argv hint the shell runs
# itself, so restored toasts stay clickable and senders don't block on a
# libnotify action (which dies when the shell restarts).
if ((exec_present)); then if ((exec_present)); then
if ((${#exec_args[@]} == 0)); then if ((${#exec_args[@]} == 0)); then
echo "--exec needs a command: --exec <program> [args...]" >&2 echo "--exec needs a command: --exec <program> [args...]" >&2
@@ -141,14 +160,25 @@ if ((exec_present)); then
# NUL-delimit into jq so every byte survives as data: jq's own --args would eat # NUL-delimit into jq so every byte survives as data: jq's own --args would eat
# a bare "--", and a newline in an arg must not split the vector. # a bare "--", and a newline in an arg must not split the vector.
exec_argv_json=$(printf '%s\0' "${exec_args[@]}" | jq -Rsc 'split("\u0000")[:-1]') exec_argv_json=$(printf '%s\0' "${exec_args[@]}" | jq -Rsc 'split("\u0000")[:-1]')
args+=("--hint=string:omarchy-exec-argv:$exec_argv_json") hints+=(omarchy-exec-argv s "$exec_argv_json")
fi fi
# `--` so notify-send reads the headline and description as text. Without it a hint_count=$((${#hints[@]} / 3))
# headline beginning with a dash is parsed as options ("-rf x" becomes -r), and
# one shaped like `--hint=string:...` sets a hint of its own. # Call org.freedesktop.Notifications.Notify directly — never notify-send. Its
if [[ -n $description ]]; then # argv parsing is the surface that reinterprets a relayed headline like
notify-send "${args[@]}" -- "$headline" "$description" # `--hint=…` or `-rf` as options or hints; busctl takes each value as one typed
else # D-Bus parameter instead, and the leading `--` keeps a dash-leading value
notify-send "${args[@]}" -- "$headline" # (headline, description, a negative timeout) positional rather than a busctl
fi # option. So the summary and body are strings that can never become a hint, and
# omarchy-exec-argv is set only from --exec.
#
# Signature susssasa{sv}i: app_name, replaces_id, app_icon, summary, body,
# actions (empty), hints, expire_timeout.
busctl --user -- call \
org.freedesktop.Notifications /org/freedesktop/Notifications \
org.freedesktop.Notifications Notify susssasa{sv}i \
"$app_name" 0 "$app_icon" "$headline" "$description" \
0 \
"$hint_count" "${hints[@]}" \
"$expire_timeout" >/dev/null
@@ -4,4 +4,4 @@
# To put it into use, remove .sample from this file name. # To put it into use, remove .sample from this file name.
# Example: Show the name of the font that was just set. # Example: Show the name of the font that was just set.
# notify-send -u low "New font" "Your new font is $1" # omarchy-notification-send -u low "New font" "Your new font is $1"
@@ -6,5 +6,5 @@
weather=$(omarchy-weather-status 2>/dev/null) || true weather=$(omarchy-weather-status 2>/dev/null) || true
if [[ -n $weather && $weather != "Weather unavailable" ]]; then if [[ -n $weather && $weather != "Weather unavailable" ]]; then
notify-send -u low "$weather" omarchy-notification-send -u low "$weather"
fi fi
@@ -4,4 +4,4 @@
# To put it into use, remove .sample from this file name. # To put it into use, remove .sample from this file name.
# Example: Show notification after the system has been updated. # Example: Show notification after the system has been updated.
# notify-send -u low "Update Performed" "Your system is now up to date" # omarchy-notification-send -u low "Update Performed" "Your system is now up to date"
@@ -4,4 +4,4 @@
# To put it into use, remove .sample from this file name. # To put it into use, remove .sample from this file name.
# Example: Show the name of the theme that was just set. # Example: Show the name of the theme that was just set.
# notify-send -u low "New theme" "Your new theme is $1" # omarchy-notification-send -u low "New theme" "Your new theme is $1"
+1 -1
View File
@@ -136,7 +136,7 @@ function o.bind_toggle(keys, description, toggle, options)
end end
function o.notify(message) function o.notify(message)
return "notify-send -u low " .. shell_quote(message) return "omarchy-notification-send -u low " .. shell_quote(message)
end end
function o.window(match, rules) function o.window(match, rules)
+24 -12
View File
@@ -63,16 +63,25 @@ Ephemeral ones (the freedesktop `transient` hint, or an `app_name` of
## The sender contract ## The sender contract
`bin/omarchy-notification-send` is the one way Omarchy code sends `bin/omarchy-notification-send` is the one way Omarchy code sends
notifications — never raw `notify-send`. It translates its flags into notifications — never raw `notify-send`. It calls
notify-send arguments and passes any unrecognized options through: `org.freedesktop.Notifications.Notify` directly over the session bus (via
`busctl --user`), so each value is one typed D-Bus parameter and there is no
argv layer that could reinterpret a relayed headline as an option or a hint. Its
flags map onto that call:
| 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 `omarchy-glyph` | Nerd Font glyph for the icon slot when no image icon resolves |
| `--exec <program> [args…]` | `--hint=string:omarchy-exec-argv:` | the click command; consumes the rest of the line, so it comes last. Each word is a discrete argument the shell runs without re-parsing (see below) | | `--exec <program> [args…]` | hint `omarchy-exec-argv` | the click command; consumes the rest of the line, so it comes last. Each word is a discrete argument the shell runs without re-parsing (see below) |
| `--image` | `--hint=string:image-path:` | the standard freedesktop image hint | | `--image` | hint `image-path` | the standard freedesktop image hint |
| `--app-name` | `-a` | defaults to `omarchy-action` | | `-i` / `--icon` | `app_icon` | themed icon name for the toast |
| `-u` / `--urgency` | `-u` | defaults to `low` | | `--app-name` | `app_name` | defaults to `omarchy-action` |
| `-u` / `--urgency` | hint `urgency` (byte) | `low`/`normal`/`critical`; defaults to `low` |
| `-t` / `--expire-time` | `expire_timeout` | milliseconds on screen; server default otherwise |
Unknown flags are a hard error, not a silent pass-through: `--exec` is the only
door to a click command, and there is no generic option pass-through to smuggle
one through.
The defaults are the point: an unadorned `omarchy-notification-send "Done"` 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
@@ -127,11 +136,14 @@ and needs no notification to execute code. What is fully closed is untrusted
*content* — web notifications can't set the exec hint at all, and any relayed *content* — web notifications can't set the exec hint at all, and any relayed
title/filename is confined to inert argument data. title/filename is confined to inert argument data.
The sender keeps that last part true rather than leaving it to each caller. The The sender keeps that last part true structurally rather than leaving it to each
headline and description go to `notify-send` behind a `--`, so a relayed value caller. Because it calls `Notify` directly, the headline and description are
beginning with a dash is text and not flags, and a word that reaches the typed string parameters — a relayed value like `--hint=…` or `-rf` is the
pass-through option position carrying `omarchy-exec-argv` is refused outright: summary or body, never an option or a hint, and there is no argv/option layer
`--exec` is the only thing that may build a click command. (no `notify-send`) left to reinterpret it. `--exec` is the only thing that can
build the `omarchy-exec-argv` hint. (The leading `--` on the `busctl` call is a
belt for `busctl`'s own getopt, which would otherwise read a dash-leading value
as a `busctl` option; the summary/body themselves are never parsed as options.)
## Helper commands ## Helper commands
+2 -3
View File
@@ -9,7 +9,6 @@ raw_command_checks=$(rg -l 'command -v' "$ROOT/bin" \
[[ -z $raw_command_checks ]] || fail "bin commands use command helpers" "$raw_command_checks" [[ -z $raw_command_checks ]] || fail "bin commands use command helpers" "$raw_command_checks"
pass "bin commands use command helpers" pass "bin commands use command helpers"
raw_notifications=$(rg -l -P '^[[:space:]]*[^#[:space:]].*\bnotify-send\b' "$ROOT/bin" \ raw_notifications=$(rg -l -P '^[[:space:]]*[^#[:space:]].*\bnotify-send\b' "$ROOT/bin" || true)
| rg -v '/omarchy-notification-send$' || true) [[ -z $raw_notifications ]] || fail "bin commands use the notification helper, never notify-send" "$raw_notifications"
[[ -z $raw_notifications ]] || fail "bin commands use the notification helper" "$raw_notifications"
pass "bin commands use the notification helper" pass "bin commands use the notification helper"
+4 -4
View File
@@ -120,16 +120,16 @@ forged_title=$(host_fn decode_title '"Clip\nOMARCHY_FILE\tPlay me\t--include=not
pass "yt-dlp native host keeps only the readable part of a forged title" pass "yt-dlp native host keeps only the readable part of a forged title"
host_fn decode_title '"--include=not-a-file"' && host_fn decode_title '"--include=not-a-file"' &&
fail "yt-dlp native host refuses a title notify-send would read as an option" fail "yt-dlp native host refuses a leading-dash title the notifier would reject as an option"
pass "yt-dlp native host refuses a title notify-send would read as an option" pass "yt-dlp native host refuses a leading-dash title the notifier would reject as an option"
host_fn decode_title 'null' && host_fn decode_title 'null' &&
fail "yt-dlp native host refuses a title that is not a JSON string" fail "yt-dlp native host refuses a title that is not a JSON string"
pass "yt-dlp native host refuses a title that is not a JSON string" pass "yt-dlp native host refuses a title that is not a JSON string"
dash_title=$(host_fn title_from_file "$download_dir/--include.mp4") 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 the notifier" "$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 the notifier"
# The click action passes the path as a discrete --exec argument (asserted # The click action passes the path as a discrete --exec argument (asserted
# end-to-end below against the real download); `--` keeps mpv from parsing a # end-to-end below against the real download); `--` keeps mpv from parsing a
+97 -79
View File
@@ -7,114 +7,132 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
tmpdir=$(mktemp -d) tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT trap 'rm -rf "$tmpdir"' EXIT
stub="$tmpdir/notify-send"
args_file="$tmpdir/args" args_file="$tmpdir/args"
# Stub the D-Bus transport and record the Notify call verbatim.
printf '%s\n' \ printf '%s\n' \
'#!/bin/bash' \ '#!/bin/bash' \
'printf "%s\n" "$@" >"$OMARCHY_TEST_NOTIFY_ARGS"' \ 'printf "%s\n" "$@" >"$OMARCHY_TEST_BUSCTL_ARGS"' \
>"$stub" >"$tmpdir/busctl"
chmod +x "$stub" chmod +x "$tmpdir/busctl"
# notify-send must never be used. If anything reaches for it, fail loudly.
printf '%s\n' '#!/bin/bash' 'echo "notify-send was invoked" >"$OMARCHY_TEST_NOTIFY_TRIPWIRE"; exit 3' \
>"$tmpdir/notify-send"
chmod +x "$tmpdir/notify-send"
tripwire="$tmpdir/notify-send-was-used"
send() { send() {
OMARCHY_TEST_NOTIFY_ARGS="$args_file" PATH="$tmpdir:$ROOT/bin:$PATH" \ OMARCHY_TEST_BUSCTL_ARGS="$args_file" OMARCHY_TEST_NOTIFY_TRIPWIRE="$tripwire" \
omarchy-notification-send "$@" PATH="$tmpdir:$ROOT/bin:$PATH" omarchy-notification-send "$@"
} }
# --exec consumes the rest of the line, so it comes after the headline/description. # Notify(susssasa{sv}i) args, by position in the recorded busctl argv:
send --app-name custom-app -g K -u critical --image /tmp/image.png \ # 0 --user 1 -- 2 call 3 dest 4 path 5 iface 6 Notify 7 signature
"Learn Keybindings" "Body" --exec omarchy-menu-keybindings 'a b' # 8 app_name 9 replaces_id 10 app_icon 11 summary 12 body
# 13 actions-count 14 hint-count 15.. hint triples last expire_timeout
declare -a args
load() { mapfile -t args <"$args_file"; }
hint_count() { echo "${args[14]}"; }
has_hint() { # key
local i end=$((15 + 3 * ${args[14]}))
for ((i = 15; i < end; i += 3)); do [[ ${args[i]} == "$1" ]] && return 0; done
return 1
}
hint_value() { # key -> variant value
local i end=$((15 + 3 * ${args[14]}))
for ((i = 15; i < end; i += 3)); do [[ ${args[i]} == "$1" ]] && { echo "${args[i + 2]}"; return 0; }; done
return 1
}
mapfile -t args <"$args_file" # ---------------------------------------------------------------- happy path
send --app-name custom-app -g K -u critical -i battery-caution -t 5000 \
"Download complete" "A body" --exec mpv -- "/tmp/a b.mp4"
load
[[ ${args[0]} == "-a" ]] || fail "notification wrapper passes app flag" [[ ${args[2]} == "call" ]] || fail "notification wrapper calls a bus method"
[[ ${args[1]} == "custom-app" ]] || fail "notification wrapper uses custom app name" [[ ${args[3]} == "org.freedesktop.Notifications" ]] || fail "notification wrapper targets the notifications service"
[[ ${args[2]} == "-u" ]] || fail "notification wrapper passes urgency flag" [[ ${args[6]} == "Notify" ]] || fail "notification wrapper invokes Notify"
[[ ${args[3]} == "critical" ]] || fail "notification wrapper uses custom urgency" [[ ${args[8]} == "custom-app" ]] || fail "notification wrapper sets the app name" "${args[8]}"
[[ ${args[4]} == "--hint=string:omarchy-glyph:K" ]] || fail "notification wrapper converts glyph to hint" [[ ${args[10]} == "battery-caution" ]] || fail "notification wrapper sets the app icon from -i" "${args[10]}"
[[ ${args[5]} == "--hint=string:image-path:/tmp/image.png" ]] || fail "notification wrapper converts image to hint" [[ ${args[11]} == "Download complete" ]] || fail "notification wrapper sets the summary" "${args[11]}"
[[ ${args[6]} == '--hint=string:omarchy-exec-argv:["omarchy-menu-keybindings","a b"]' ]] || fail "notification wrapper converts the click command to an argv hint" "${args[6]}" [[ ${args[12]} == "A body" ]] || fail "notification wrapper sets the body" "${args[12]}"
[[ ${args[7]} == "--" ]] || fail "notification wrapper ends the options before the text" "${args[7]}" [[ ${args[-1]} == "5000" ]] || fail "notification wrapper sets the expire timeout from -t" "${args[-1]}"
[[ ${args[8]} == "Learn Keybindings" ]] || fail "notification wrapper preserves headline" [[ $(hint_value urgency) == "2" ]] || fail "notification wrapper maps critical urgency to 2"
[[ ${args[9]} == "Body" ]] || fail "notification wrapper preserves description" [[ $(hint_value omarchy-glyph) == "K" ]] || fail "notification wrapper sets the glyph hint"
pass "notification wrapper supports app, glyph, urgency, image, and exec options" [[ $(hint_value omarchy-exec-argv) == '["mpv","--","/tmp/a b.mp4"]' ]] || fail "notification wrapper builds the click argv hint" "$(hint_value omarchy-exec-argv)"
pass "notification wrapper issues a Notify call with app, icon, urgency, glyph, and click argv"
# The shell runs the click command itself, so nothing may block the sender on a [[ -f $tripwire ]] && fail "notification wrapper must never invoke notify-send"
# libnotify action round-trip. pass "notification wrapper never invokes notify-send"
grep -q -- "-A" "$args_file" && fail "notification wrapper must not register a libnotify action"
# ---------------------------------------------------------------- no click cmd
: >"$args_file" : >"$args_file"
send "Plain" >/dev/null send "Plain" >/dev/null
grep -q "omarchy-exec" "$args_file" && fail "notification wrapper adds no exec hint without --exec" load
pass "notification wrapper omits the exec hint when no command is given" has_hint omarchy-exec-argv && fail "notification wrapper adds no click hint without --exec"
[[ ${args[11]} == "Plain" ]] || fail "notification wrapper still sends a plain toast"
pass "notification wrapper omits the click hint when no command is given"
# Rest-of-line --exec: the caller's shell has already split the words into # ------------------------------------------------ rest-of-line --exec is literal
# discrete arguments, and the shell runs them without re-parsing, so shell
# metacharacters in a value are carried as data, never as a command.
: >"$args_file" : >"$args_file"
send "Download complete" --exec mpv -- '$(rm -rf ~); echo pwned' >/dev/null send "Download complete" --exec mpv -- '$(rm -rf ~); echo pwned' >/dev/null
argv_hint=$(grep -- "--hint=string:omarchy-exec-argv:" "$args_file") load
argv_json=${argv_hint#--hint=string:omarchy-exec-argv:} json=$(hint_value omarchy-exec-argv)
[[ $(jq -r '.[0]' <<<"$argv_json") == "mpv" ]] || fail "notification wrapper puts the program first in the exec argv" [[ $(jq -r '.[0]' <<<"$json") == "mpv" ]] || fail "click argv program is first"
[[ $(jq -r '.[1]' <<<"$argv_json") == "--" ]] || fail "notification wrapper preserves a -- separator in the exec argv" [[ $(jq -r '.[2]' <<<"$json") == '$(rm -rf ~); echo pwned' ]] || fail "click argv carries metacharacters as literal data" "$json"
[[ $(jq -r '.[2]' <<<"$argv_json") == '$(rm -rf ~); echo pwned' ]] || pass "rest-of-line --exec is a literal argv vector"
fail "notification wrapper carries shell metacharacters as literal argv data" "$argv_json"
pass "notification wrapper encodes rest-of-line --exec as a literal JSON argv vector"
# A quoted argument with spaces stays ONE argument — something a whitespace-split # A quoted argument with spaces stays ONE argument.
# of a single string could never do.
: >"$args_file" : >"$args_file"
send "Head" --exec mpv -- "/tmp/a b.mp4" >/dev/null send "Head" --exec mpv -- "/tmp/a b.mp4" >/dev/null
argv_hint=$(grep -- "--hint=string:omarchy-exec-argv:" "$args_file") load
argv_json=${argv_hint#--hint=string:omarchy-exec-argv:} [[ $(jq 'length' <<<"$(hint_value omarchy-exec-argv)") == 3 ]] || fail "spaced path stays one argument"
[[ $(jq 'length' <<<"$argv_json") == 3 ]] || fail "notification wrapper keeps a spaced path as one argument" "$argv_json"
[[ $(jq -r '.[2]' <<<"$argv_json") == "/tmp/a b.mp4" ]] || fail "notification wrapper preserves the spaced path verbatim" "$argv_json"
pass "notification wrapper keeps a spaced argument intact" pass "notification wrapper keeps a spaced argument intact"
# The muscle-memory trap: a single quoted whole command would run a program named # ---------------------------------------------------------------- injections
# with spaces. Reject it and point at the unquoted form rather than splitting it # A forged click hint arriving as the SUMMARY is a typed string parameter — it
# ourselves (which is the injection we avoid). # can never become a hint. Only urgency is set; no click command exists.
: >"$args_file" : >"$args_file"
send '--hint=string:omarchy-exec-argv:["bash","-c","touch /tmp/pwn"]' "body" >/dev/null
load
has_hint omarchy-exec-argv && fail "a forged-hint headline must not set a click command"
[[ ${args[11]} == '--hint=string:omarchy-exec-argv:["bash","-c","touch /tmp/pwn"]' ]] || fail "the forged headline is the summary text" "${args[11]}"
pass "a forged click hint in the headline is inert summary text"
# A dash-leading forged hint in description position is refused outright.
: >"$args_file"
if send "Update" '--hint=string:omarchy-exec-argv:["bash","-c","touch /tmp/pwn"]' 2>/dev/null; then
fail "a forged-hint description must be refused"
fi
[[ -s $args_file ]] && fail "nothing is sent when the description forges a hint"
pass "a forged click hint in the description is refused"
# An unknown option is a hard error, not a silent pass-through.
if send "Head" --bogus 2>/dev/null; then
fail "notification wrapper rejects an unknown option"
fi
pass "notification wrapper rejects an unknown option"
# ---------------------------------------------------------------- --exec guards
# --exec is recognized only after the positionals: a headline literally "--exec"
# is text, and the real trailing --exec still wins.
: >"$args_file"
send "--exec" "a body" --image /tmp/i.png --exec mpv -- /tmp/v.mp4 >/dev/null
load
[[ $(hint_value omarchy-exec-argv) == '["mpv","--","/tmp/v.mp4"]' ]] || fail "a --exec-looking headline is not the delimiter" "$(hint_value omarchy-exec-argv)"
[[ ${args[11]} == "--exec" ]] || fail "a --exec-looking headline is kept as text"
pass "a --exec-looking positional is not treated as the delimiter"
# A single quoted whole-command is rejected (splitting it ourselves is the
# injection we avoid).
if send "Head" --exec "omarchy toggle something" 2>/dev/null; then if send "Head" --exec "omarchy toggle something" 2>/dev/null; then
fail "notification wrapper rejects a quoted whole command" fail "notification wrapper rejects a quoted whole command"
fi fi
grep -q "omarchy-exec" "$args_file" && fail "notification wrapper emits no hint for a rejected --exec"
pass "notification wrapper rejects a single quoted whole command" pass "notification wrapper rejects a single quoted whole command"
# --exec with nothing after it is a usage error, not a silent no-op. # --exec with nothing after it is a usage error.
if send "Head" --exec 2>/dev/null; then if send "Head" --exec 2>/dev/null; then
fail "notification wrapper rejects --exec with no command" fail "notification wrapper rejects --exec with no command"
fi fi
pass "notification wrapper rejects --exec with no command" pass "notification wrapper rejects --exec with no command"
# --exec is recognized only after the positionals, so an untrusted headline or
# description that is literally "--exec" is taken as text and cannot be mistaken
# for the delimiter (the real --exec later still wins).
: >"$args_file"
send "--exec" "a body" --image /tmp/i.png --exec mpv -- /tmp/v.mp4 >/dev/null
argv_hint=$(grep -- "--hint=string:omarchy-exec-argv:" "$args_file")
argv_json=${argv_hint#--hint=string:omarchy-exec-argv:}
[[ $(jq -c '.' <<<"$argv_json") == '["mpv","--","/tmp/v.mp4"]' ]] || fail "notification wrapper ignores a --exec-looking headline as the delimiter" "$argv_json"
grep -qx -- "--exec" "$args_file" || fail "notification wrapper keeps a --exec-looking headline as text"
grep -q 'image-path:/tmp/i.png' "$args_file" || fail "notification wrapper still parses options after a --exec-looking headline"
pass "notification wrapper does not treat a --exec-looking positional as the delimiter"
# The headline and description are text, never options. notify-send parses a
# leading-dash summary as flags ("-rf x" is -r with the value x) and reads a
# `--hint=string:...` word as a hint, so they go behind a `--` separator.
: >"$args_file"
send "-rf oops" "a body" >/dev/null
mapfile -t args <"$args_file"
[[ ${args[-3]} == "--" ]] || fail "notification wrapper separates a dash headline from the options" "${args[*]}"
[[ ${args[-2]} == "-rf oops" ]] || fail "notification wrapper keeps a dash headline as text" "${args[*]}"
[[ ${args[-1]} == "a body" ]] || fail "notification wrapper keeps the description after a dash headline" "${args[*]}"
pass "notification wrapper hands the headline to notify-send as text, not options"
# The click command has exactly one door. A relayed title or filename that
# reaches option position must not be able to forge the hint --exec produces.
: >"$args_file"
if send "Download complete" '--hint=string:omarchy-exec-argv:["sh","-c","touch /tmp/pwned"]' 2>/dev/null; then
fail "notification wrapper rejects a forged click-command hint"
fi
grep -q "omarchy-exec-argv" "$args_file" && fail "notification wrapper sends nothing when a click hint is forged"
pass "notification wrapper refuses a click-command hint it did not build from --exec"