Safe shell-script Action handlers
This page walks through two complete worked examples of bash
handlers — one freeform, one kv — and explains the safety
patterns line by line. Both scripts are also shipped in
examples/actions/posix/.
The general modes-and-contract overview lives at Actions: handler safety; read it first if you haven't.
Worked example 1: SMS as a freeform shell Action
The Action sender writes:
@@482910#sms +15555551212 hello there
graywolf invokes:
/usr/local/bin/sms-freeform.sh sms KE0XYZ true "+15555551212 hello there"
Here is the complete script (also at
examples/actions/posix/sms-freeform.sh):
#!/bin/bash
# sms-freeform.sh — Send an SMS via Twilio. Freeform Action variant.
# Abort on any error, unset variable, or failed pipeline stage.
set -euo pipefail
# Positional args from the runner. Only PAYLOAD is used here.
ACTION="$1"
SENDER="$2"
OTP_VERIFIED="$3"
PAYLOAD="$4"
# Validate format AND split into number + message in one regex match.
# Group 1 = E.164 number, group 2 = message body.
if [[ ! "$PAYLOAD" =~ ^(\+[1-9][0-9]{6,14})[[:space:]]+(.+)$ ]]; then
echo "expected '+<E164> <message>'" >&2
exit 64
fi
NUMBER="${BASH_REMATCH[1]}"
MESSAGE="${BASH_REMATCH[2]}"
# Defense in depth: graywolf already strips control characters, but
# re-check so this script stays safe if the operator widens the regex.
if [[ "$MESSAGE" =~ [[:cntrl:]] ]]; then
echo "message contains control characters" >&2
exit 65
fi
if (( ${#MESSAGE} < 1 || ${#MESSAGE} > 160 )); then
echo "message length out of range (1..160)" >&2
exit 65
fi
# Required Twilio credentials. Fail fast with a clear message if unset.
: "${TWILIO_ACCOUNT_SID:?TWILIO_ACCOUNT_SID not set}"
: "${TWILIO_AUTH_TOKEN:?TWILIO_AUTH_TOKEN not set}"
: "${TWILIO_FROM:?TWILIO_FROM not set}"
# curl invoked argv-style; --data-urlencode lets curl URL-encode each
# value so we never concatenate user data into the request body.
response=$(curl -sS --max-time 8 -X POST \
--data-urlencode "From=${TWILIO_FROM}" \
--data-urlencode "To=${NUMBER}" \
--data-urlencode "Body=${MESSAGE}" \
-u "${TWILIO_ACCOUNT_SID}:${TWILIO_AUTH_TOKEN}" \
-- "https://api.twilio.com/2010-04-01/Accounts/${TWILIO_ACCOUNT_SID}/Messages.json")
# Success heuristic: Twilio returns JSON with a "sid" field on success.
# Echo it so the on-air reply ("ok: SM<sid>...") confirms delivery.
if printf '%s' "$response" | grep -q '"sid"'; then
sid=$(printf '%s' "$response" | grep -o '"sid":"SM[A-Za-z0-9]*"' | head -n1 | sed 's/.*"\(SM[^"]*\)"/\1/')
echo "sent ${sid:-?}"
exit 0
fi
echo "twilio rejected: $(printf '%s' "$response" | head -c 80)" >&2
exit 1
Explanation
set -euo pipefail
Three flags in one line. -e aborts on any non-zero
exit. -u treats unset variables as errors (so a typo in
$GW_ARG_NUMER doesn't silently expand to empty string).
-o pipefail makes a pipeline fail if any stage fails, not
just the last one. Always at the top of every shell handler.
if [[ ! "$PAYLOAD" =~ ^(\+[1-9][0-9]{6,14})[[:space:]]+(.+)$ ]]; then
One POSIX extended regex match validates the format
and splits the payload in a single step. Bash's
[[ =~ ]] operator runs the match and populates the
BASH_REMATCH array with the capture groups on success.
The pattern reads:
^— anchor at start.(\+[1-9][0-9]{6,14})— group 1: an E.164 number. A literal+, then a leading digit 1..9 (E.164 forbids0), then 6 to 14 more digits.[[:space:]]+— one or more whitespace separators between the number and the message.(.+)— group 2: the message body, at least one character.$— anchor at end.
If the match fails, the branch fires and we exit before any value is bound — there is no partial state, no separate "missing separator" branch to forget. We revalidate in the script even though the Action's own arg_schema regex should already have rejected malformed numbers, because this script's safety contract should not depend on the operator setting a strict regex.
The match is a pure in-process string operation: no
eval, no command substitution, no subshell. An attacker
sending ; rm -rf ~ # as the payload cannot trigger any
shell command — those characters are just bytes the regex tries
(and fails) to match.
NUMBER="${BASH_REMATCH[1]}"
MESSAGE="${BASH_REMATCH[2]}"
After the previous =~ match against the
^(\+[1-9][0-9]{6,14})[[:space:]]+(.+)$ pattern,
bash populates the BASH_REMATCH array from that match's
capture groups. BASH_REMATCH[0] holds the whole match;
BASH_REMATCH[1] holds group 1 (the E.164 number) and
BASH_REMATCH[2] holds group 2 (the message body), in
the order the parentheses appear in the regex. Reading them into
named variables happens entirely inside the bash process — no
fork, no exec.
[[ "$MESSAGE" =~ [[:cntrl:]] ]]
[:cntrl:] is the POSIX character class for ASCII
control characters (0x00–0x1F plus 0x7F). graywolf's sanitizer
already strips these unconditionally for freeform Actions, but
checking again here keeps the script self-contained.
: "${VAR:?msg}"
The colon is the no-op builtin. The expansion
${VAR:?msg} exits with msg if
VAR is unset or empty. Combined, this is a one-liner
"abort with a clear error if the operator forgot to set this env
var".
--data-urlencode "From=${TWILIO_FROM}"
curl performs the URL-encoding for us. Critically,
this means we never construct the request body by string-concatenation
— which would risk smuggling &Foo=bar into the
form if the message contained an ampersand.
-- "https://...Messages.json"
The double-dash is a convention curl supports to terminate option
parsing. Habit: always put it before any positional argument that could
conceivably begin with a -.
Anti-patterns to avoid
Each of these would compromise the script's safety:
| Anti-pattern | Attack it enables |
|---|---|
curl ... -d to=$TEXT (unquoted) |
Word-splitting smuggles extra fields or flags. |
eval "twilio send $TEXT" |
Re-parses the payload as shell — full command injection. |
sh -c "twilio send $TEXT" |
Same as eval. Anything in the payload becomes shell. |
String-concat into --data |
& in the payload smuggles extra form fields. |
twilio send-sms "$NUMBER" "$MESSAGE" with no -- |
Payload starting with - becomes a flag. |
Run it through shellcheck
Before deploying any Action handler, run:
shellcheck /path/to/your-handler.sh
shellcheck catches every common quoting and word-splitting mistake.
If your script is failing checks, fix the script — don't add
#shellcheck disable directives.
Worked example 2: SMS as a kv-mode shell Action
Same outcome, different wire format:
@@482910#sms to=+15555551212 msg="hello there"
graywolf invokes:
/usr/local/bin/sms.sh sms KE0XYZ true "to=+15555551212" "msg=hello there"
And the script (also at
examples/actions/posix/sms.sh):
#!/bin/bash
set -euo pipefail
# kv mode: graywolf sets GW_ARG_TO and GW_ARG_MSG.
NUMBER="${GW_ARG_TO:?missing to=}"
MESSAGE="${GW_ARG_MSG:?missing msg=}"
if [[ ! "$NUMBER" =~ ^\+[1-9][0-9]{6,14}$ ]]; then
echo "invalid E.164: $NUMBER" >&2
exit 65
fi
if [[ "$MESSAGE" =~ [[:cntrl:]] ]]; then
echo "message contains control characters" >&2
exit 65
fi
curl -sS --max-time 8 -X POST \
--data-urlencode "From=${TWILIO_FROM}" \
--data-urlencode "To=${NUMBER}" \
--data-urlencode "Body=${MESSAGE}" \
-u "${TWILIO_ACCOUNT_SID}:${TWILIO_AUTH_TOKEN}" \
-- "https://api.twilio.com/2010-04-01/Accounts/${TWILIO_ACCOUNT_SID}/Messages.json"
The script body is shorter because graywolf has already split the
payload for us — GW_ARG_TO and GW_ARG_MSG
arrive as separate variables, each individually validated by the
Action's arg_schema regex. The same revalidation pattern
still applies (defense in depth), but the splitting work moves up to
graywolf's parser.
For when to pick kv vs freeform, see kv vs freeform — when to pick which on the overview page.
Multi-line replies
If the operator sets Max reply lines to a value
greater than 1 in the action's settings, each non-empty stdout line
your script writes becomes a separate outbound APRS message (up to
the configured maximum, ceiling 5).
Example: a three-line weather snapshot.
#!/bin/bash
# Configure Max reply lines = 3 in the UI for this Action.
set -euo pipefail
echo "temp 72F"
echo "wind 5mph N"
echo "baro 30.10"
Each line is capped at 67 characters and stripped of control
characters. Lines beyond Max reply lines are dropped
and the audit row's Truncated flag is set. Default is
1; the ceiling is 5. Each extra line costs one RF frame plus its
own ack and retry budget — keep this at 1 unless multiple
short messages are genuinely needed.