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:

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-patternAttack 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.