Make --exec take the command as rest-of-line words

Replace --exec-arg with an ergonomic --exec that consumes the rest of the line
as the click command. The caller's shell tokenizes the words into discrete
arguments before the tool sees them, and the shell runs them as positional
parameters (never a re-parsed string), so safety is identical to the argv form
while the call sites read naturally: `--exec omarchy toggle something`.

Crucially the tool never splits a string itself — a single quoted whole-command
argument is rejected and points at the unquoted form, because whitespace-
splitting a string hands argument boundaries to whoever controls its content
(the injection we are avoiding). --exec must come last; migrate every caller.
This commit is contained in:
Ryan Hughes
2026-08-23 14:26:25 -04:00
parent eb988b42e6
commit bf2013e6f3
21 changed files with 148 additions and 113 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ cat >"$test_bin/omarchy-notification-send" <<'EOF'
echo notification >>"$TEST_LOG"
exec_args=()
while (($# > 0)); do
[[ $1 == "--exec-arg" ]] && exec_args+=("$2")
if [[ $1 == "--exec" ]]; then shift; exec_args=("$@"); break; fi
shift
done
((${#exec_args[@]})) && echo "exec:${exec_args[*]}" >>"$TEST_LOG"
+6 -10
View File
@@ -131,13 +131,9 @@ 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"
pass "yt-dlp native host does not pass a leading-dash title to notify-send"
# The click action is an argv vector the shell runs without a shell, so the path
# is a literal --exec-arg rather than a value quoted into a command string. The
# prefix is static; `--` keeps mpv from parsing a leading-dash filename.
playback_argv=$(host_fn eval 'printf "%s\n" "${playback_exec_args[@]}"')
[[ $playback_argv == $'--exec-arg\nmpv\n--exec-arg\n--\n--exec-arg' ]] ||
fail "yt-dlp native host runs mpv with -- before the path as argv" "$playback_argv"
pass "yt-dlp native host runs mpv with -- before the path as argv"
# 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
# leading-dash filename as an option.
parse_script="$TMPDIR/parse-ytdlp-lines.sh"
cat >"$parse_script" <<'EOF'
@@ -232,9 +228,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")"
pass "yt-dlp native host never prints the title into the file record"
grep -q -- "--exec-arg mpv --exec-arg -- --exec-arg " "$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 an mpv -- <path> argv"
grep -q -- "--exec mpv -- " "$notify_argv" ||
fail "yt-dlp native host builds the click command as mpv -- <path>" "$(cat "$notify_argv")"
pass "yt-dlp native host builds the click command as mpv -- <path>"
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")"
+1 -1
View File
@@ -33,7 +33,7 @@ cat >"$test_bin/omarchy-notification-send" <<'EOF'
echo notification >>"$TEST_LOG"
exec_args=()
while (($# > 0)); do
[[ $1 == "--exec-arg" ]] && exec_args+=("$2")
if [[ $1 == "--exec" ]]; then shift; exec_args=("$@"); break; fi
shift
done
((${#exec_args[@]})) && echo "exec:${exec_args[*]}" >>"$TEST_LOG"
+1 -1
View File
@@ -158,7 +158,7 @@ exec {foreign_lock_fd}>&-
rm -f "$test_tmp/notify-args"
run_notify 1 >/dev/null 2>&1
notify_args_written || fail "migration notifier sends the notification before exiting"
grep -Fx -- '--exec-arg' "$test_tmp/notify-args" >/dev/null ||
grep -Fx -- '--exec' "$test_tmp/notify-args" >/dev/null ||
fail "migration notifier attaches the click command to the toast"
grep -Fx 'omarchy-launch-floating-terminal-with-presentation' "$test_tmp/notify-args" >/dev/null &&
grep -Fx 'omarchy-migrate' "$test_tmp/notify-args" >/dev/null ||
+42 -29
View File
@@ -16,9 +16,14 @@ printf '%s\n' \
>"$stub"
chmod +x "$stub"
OMARCHY_TEST_NOTIFY_ARGS="$args_file" PATH="$tmpdir:$ROOT/bin:$PATH" \
omarchy-notification-send --app-name custom-app -g K -u critical --image /tmp/image.png \
--exec-arg omarchy-menu-keybindings --exec-arg 'a b' "Learn Keybindings" "Body"
send() {
OMARCHY_TEST_NOTIFY_ARGS="$args_file" PATH="$tmpdir:$ROOT/bin:$PATH" \
omarchy-notification-send "$@"
}
# --exec consumes the rest of the line, so it comes after the headline/description.
send --app-name custom-app -g K -u critical --image /tmp/image.png \
"Learn Keybindings" "Body" --exec omarchy-menu-keybindings 'a b'
mapfile -t args <"$args_file"
@@ -28,47 +33,55 @@ mapfile -t args <"$args_file"
[[ ${args[3]} == "critical" ]] || fail "notification wrapper uses custom urgency"
[[ ${args[4]} == "--hint=string:omarchy-glyph:K" ]] || fail "notification wrapper converts glyph to hint"
[[ ${args[5]} == "--hint=string:image-path:/tmp/image.png" ]] || fail "notification wrapper converts image to hint"
[[ ${args[6]} == '--hint=string:omarchy-exec-argv:["omarchy-menu-keybindings","a b"]' ]] || fail "notification wrapper converts exec args to an argv hint" "${args[6]}"
[[ ${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[7]} == "Learn Keybindings" ]] || fail "notification wrapper preserves headline"
[[ ${args[8]} == "Body" ]] || fail "notification wrapper preserves description"
pass "notification wrapper supports app, glyph, urgency, image, and exec-arg options"
pass "notification wrapper supports app, glyph, urgency, image, and exec options"
# The shell runs the click command itself, so nothing may block the sender on a
# libnotify action round-trip.
grep -q -- "-A" "$args_file" && fail "notification wrapper must not register a libnotify action"
: >"$args_file"
OMARCHY_TEST_NOTIFY_ARGS="$args_file" PATH="$tmpdir:$ROOT/bin:$PATH" \
omarchy-notification-send "Plain" >/dev/null
send "Plain" >/dev/null
grep -q "omarchy-exec" "$args_file" && fail "notification wrapper adds no exec hint without --exec"
pass "notification wrapper omits the exec hint when no command is given"
# The free-form shell-string --exec is gone: its existence let a caller skip
# quoting and reintroduce the RCE, so it is rejected outright in favor of the
# argv-only --exec-arg.
# Rest-of-line --exec: the caller's shell has already split the words into
# 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"
if OMARCHY_TEST_NOTIFY_ARGS="$args_file" PATH="$tmpdir:$ROOT/bin:$PATH" \
omarchy-notification-send --exec 'anything' "Headline" 2>/dev/null; then
fail "notification wrapper rejects the removed --exec flag"
fi
grep -q "omarchy-exec" "$args_file" && fail "notification wrapper emits no exec hint for a rejected --exec"
pass "notification wrapper rejects the removed --exec flag"
# --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
send "Download complete" --exec mpv -- '$(rm -rf ~); echo pwned' >/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"
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
# of a single string could never do.
: >"$args_file"
send "Head" --exec mpv -- "/tmp/a b.mp4" >/dev/null
argv_hint=$(grep -- "--hint=string:omarchy-exec-argv:" "$args_file")
argv_json=${argv_hint#--hint=string:omarchy-exec-argv:}
[[ $(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"
# The muscle-memory trap: a single quoted whole command would run a program named
# with spaces. Reject it and point at the unquoted form rather than splitting it
# ourselves (which is the injection we avoid).
: >"$args_file"
if send "Head" --exec "omarchy toggle something" 2>/dev/null; then
fail "notification wrapper rejects a quoted whole command"
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"
# --exec with nothing after it is a usage error, not a silent no-op.
if send "Head" --exec 2>/dev/null; then
fail "notification wrapper rejects --exec with no command"
fi
pass "notification wrapper rejects --exec with no command"
+4 -4
View File
@@ -62,11 +62,11 @@ pass "taildrop receive announces other files with a glyph"
# 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
# restart. The path rides as its own --exec-arg, so the shell runs it as literal
# data with no quoting for a name with spaces to get wrong.
grep -qF -- "--exec-arg xdg-open --exec-arg $downloads/photo.png" <<<"$notifications" ||
# restart. The path rides as its own discrete --exec argument, so the shell runs
# it as literal data with no quoting for a name with spaces to get wrong.
grep -qF -- "--exec xdg-open $downloads/photo.png" <<<"$notifications" ||
fail "taildrop receive attaches the open command to the notification" "$notifications"
grep -qF -- "--exec-arg xdg-open --exec-arg $downloads/notes with space.pdf" <<<"$notifications" ||
grep -qF -- "--exec xdg-open $downloads/notes with space.pdf" <<<"$notifications" ||
fail "taildrop receive carries spaced names as a literal open argument" "$notifications"
pass "taildrop receive lets a click open the received file"
+1 -1
View File
@@ -20,7 +20,7 @@ cat >"$test_bin/omarchy-notification-send" <<'EOF'
echo notification >>"$TEST_LOG"
exec_args=()
while (($# > 0)); do
[[ $1 == "--exec-arg" ]] && exec_args+=("$2")
if [[ $1 == "--exec" ]]; then shift; exec_args=("$@"); break; fi
shift
done
((${#exec_args[@]})) && echo "exec:${exec_args[*]}" >>"$TEST_LOG"