Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions bin/fm-x-dismiss.sh
Original file line number Diff line number Diff line change
Expand Up @@ -89,18 +89,17 @@ if [ -z "$FMX_TOKEN" ]; then
exit 1
fi
command -v curl >/dev/null 2>&1 || { echo "fm-x-dismiss: curl not found" >&2; exit 1; }
AUTH_HEADER_FILE=$(fmx_auth_header_file) || {
CURL_CONFIG=$(fmx_curl_config "$FMX_RELAY/connector/dismiss") || {
echo "fm-x-dismiss: invalid FMX_PAIRING_TOKEN" >&2
exit 1
}
trap 'rm -f "$AUTH_HEADER_FILE"' EXIT

code=$(curl -m 10 -s -o /dev/null -w '%{http_code}' \
# URL and bearer header go in on stdin (see fmx_curl_config) so the token is
# never visible in curl's argv.
code=$(printf '%s\n' "$CURL_CONFIG" | curl -K - -m 10 -s -o /dev/null -w '%{http_code}' \
-X POST \
-H "@$AUTH_HEADER_FILE" \
-H 'Content-Type: application/json' \
--data "$PAYLOAD" \
"$FMX_RELAY/connector/dismiss" 2>/dev/null) || {
--data "$PAYLOAD" 2>/dev/null) || {
echo "fm-x-dismiss: request to relay failed" >&2
exit 1
}
Expand Down
53 changes: 35 additions & 18 deletions bin/fm-x-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
# fmx_env_get <key> <file> - read one KEY=VALUE from a .env-style file
# fmx_load_config - resolve FMX_TOKEN, FMX_RELAY, FMX_DRY, FMX_MAX,
# and FMX_THREAD_MAX (env wins over .env)
# fmx_auth_header_file - write the bearer header to a 0600 temp file
# fmx_curl_config <url> - emit a `curl -K -` config carrying the request
# URL and the bearer header, for feeding on stdin
# fmx_extract_reply_context <json-file> - the single owner of reply-context
# extraction: infer {platform, reply_max_chars}
# from any mention/relay payload file
Expand Down Expand Up @@ -730,15 +731,34 @@ fmx_split_thread() {
'
}

fmx_auth_header_file() {
local file
case "$FMX_TOKEN" in
# Emit a curl config carrying the request URL and the bearer header, to be fed
# to `curl -K -` on stdin. This is the single owner of how the token reaches
# curl, and every fm-x-* request must go through it.
#
# The token must never appear in curl's argv: process arguments are world
# readable (ps) for the life of the call, so `-H "Authorization: Bearer $tok"`
# would leak the credential to any local process. stdin is private to the pipe,
# and unlike the 0600 temp file this replaces it leaves nothing on disk that can
# outlive the call - a SIGKILL cannot skip a cleanup trap that no longer exists.
#
# Callers must build the config with a shell BUILTIN printf (as below) and pipe
# it in; passing it as an argument to any external command would reintroduce the
# exact argv exposure this exists to prevent.
#
# Values are emitted in curl's quoted-value syntax, so a literal backslash or
# double quote is escaped. A token containing a newline is rejected outright:
# the config is line oriented, so no escaping could keep it one directive.
fmx_curl_config() {
local url=$1 token=${FMX_TOKEN-}
case "$token" in
*$'\n'*|*$'\r'*) return 1 ;;
esac
file=$(umask 077; mktemp "${TMPDIR:-/tmp}/fm-x-auth.XXXXXX") || return 1
chmod 600 "$file" 2>/dev/null || { rm -f "$file"; return 1; }
printf 'Authorization: Bearer %s\n' "$FMX_TOKEN" > "$file" || { rm -f "$file"; return 1; }
printf '%s\n' "$file"
token=${token//\\/\\\\}
token=${token//\"/\\\"}
url=${url//\\/\\\\}
url=${url//\"/\\\"}
printf 'url = "%s"\n' "$url"
printf 'header = "Authorization: Bearer %s"\n' "$token"
}

fmx_image_media_type_from_path() {
Expand Down Expand Up @@ -875,21 +895,18 @@ fmx_reply_outbox_json() {
}

fmx_post_json() (
local endpoint=$1 payload_file=$2 body_file=${3:-/dev/null} auth_header_file code rc
local endpoint=$1 payload_file=$2 body_file=${3:-/dev/null} config code rc
command -v curl >/dev/null 2>&1 || return 127
[ -r "$payload_file" ] || return 2
auth_header_file=$(fmx_auth_header_file) || return 3
trap 'rm -f "$auth_header_file"' EXIT
trap 'rm -f "$auth_header_file"; exit 143' HUP INT TERM
code=$(curl -m 10 -s -o "$body_file" -w '%{http_code}' \
config=$(fmx_curl_config "$FMX_RELAY/connector/$endpoint") || return 3
# The URL and bearer header arrive on stdin; the payload is read from its own
# file, so nothing here contends for stdin. printf is a builtin, so the token
# never becomes another process's argv.
code=$(printf '%s\n' "$config" | curl -K - -m 10 -s -o "$body_file" -w '%{http_code}' \
-X POST \
-H "@$auth_header_file" \
-H 'Content-Type: application/json' \
--data-binary "@$payload_file" \
"$FMX_RELAY/connector/$endpoint" 2>/dev/null)
--data-binary "@$payload_file" 2>/dev/null)
rc=$?
rm -f "$auth_header_file"
trap - EXIT HUP INT TERM
[ "$rc" = 0 ] || return 4
printf '%s\n' "$code"
)
Expand Down
14 changes: 7 additions & 7 deletions bin/fm-x-poll.sh
Original file line number Diff line number Diff line change
Expand Up @@ -105,17 +105,17 @@ command -v jq >/dev/null 2>&1 || { emit_error_once "missing jq"; exit 0; }
fmx_context_registry_prune "$STATE"

BODY_FILE=$(mktemp "${TMPDIR:-/tmp}/fm-x-poll.XXXXXX") || exit 0
AUTH_HEADER_FILE=
trap 'rm -f "$BODY_FILE" "$AUTH_HEADER_FILE"' EXIT
AUTH_HEADER_FILE=$(fmx_auth_header_file) || { emit_error_once "invalid token"; exit 0; }
trap 'rm -f "$BODY_FILE"' EXIT
CURL_CONFIG=$(fmx_curl_config "$FMX_RELAY/connector/poll") || { emit_error_once "invalid token"; exit 0; }

# Short, bounded poll: a failure or timeout simply means "no wake this cycle";
# the next check cycle retries. -m 5 keeps this well inside the watcher's
# per-check timeout so the supervision loop is never starved.
code=$(curl -m 5 -s -o "$BODY_FILE" -w '%{http_code}' \
-H "@$AUTH_HEADER_FILE" \
-H 'Accept: application/json' \
"$FMX_RELAY/connector/poll" 2>/dev/null) || exit 0
#
# URL and bearer header go in on stdin (see fmx_curl_config) so the token is
# never visible in curl's argv.
code=$(printf '%s\n' "$CURL_CONFIG" | curl -K - -m 5 -s -o "$BODY_FILE" -w '%{http_code}' \
-H 'Accept: application/json' 2>/dev/null) || exit 0

# 204 (nothing pending) is the common path; only 200 can carry a mention.
case "$code" in
Expand Down
1 change: 1 addition & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ A user enables it by putting `FMX_PAIRING_TOKEN` in the firstmate home's gitigno
That token is standing authorization for firstmate to answer public mentions and act autonomously on normal reversible mention requests.
Destructive, irreversible, or security-sensitive asks are escalated for trusted-channel confirmation instead of being executed from a public mention.
The relay uses owner-only routing: a mention delivered to a home is from that home's owner, while its surrounding conversation context may still include other public accounts.
`bin/fm-x-lib.sh:fmx_curl_config` is the only thing that hands that token to `curl`: every Relay request feeds its URL and bearer header in on stdin (`curl -K -`), so the credential never enters world-readable process arguments and never lands on disk, and a new request site goes through that helper instead of adding an `-H` argument or a header file.
On the locked session-start bootstrap step, that token creates the local polling and watcher-cadence artifacts described in the [Relay configuration reference](configuration.md#relay-env).
Without the token, the locked session-start bootstrap step removes those artifacts on opt-out and otherwise stays silent, so non-Relay users see no behavior change.
Newly offered mentions are stored as `state/x-inbox/<request_id>.json` and wake firstmate once per retained request ID; the [Relay configuration reference](configuration.md#relay-env) owns the durable offer-marker and re-offer contract.
Expand Down
157 changes: 134 additions & 23 deletions tests/fm-x-mode.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,36 @@ make_fake_curl() {
#!/usr/bin/env bash
ofile="" method=GET data="" url="" auth=""
argv=$*

# Undo curl's quoted-value escaping (\\ and \") from a -K config directive.
unesc() {
local s=$1 out= c
while [ -n "$s" ]; do
c=${s:0:1}
if [ "$c" = '\' ]; then out=$out${s:1:1}; s=${s:2}; else out=$out$c; s=${s:1}; fi
done
printf '%s' "$out"
}

# Parse the url/header directives the client feeds to `curl -K -` on stdin.
read_config() {
local line v
while IFS= read -r line; do
case "$line" in
'url = "'*)
v=${line#'url = "'}; v=${v%'"'}; url=$(unesc "$v") ;;
'header = "'*)
v=${line#'header = "'}; v=${v%'"'}; v=$(unesc "$v")
case "$v" in Authorization:*) auth=$v ;; esac ;;
esac
done
}

while [ $# -gt 0 ]; do
case "$1" in
-K)
if [ "$2" = "-" ]; then read_config; else read_config < "$2"; fi
shift 2 ;;
-o) ofile=$2; shift 2 ;;
-X) method=$2; shift 2 ;;
--data) data=$2; shift 2 ;;
Expand All @@ -60,7 +88,11 @@ while [ $# -gt 0 ]; do
esac
done
if [ -n "${FAKE_CURL_LOG:-}" ]; then
{ echo "argv=$argv"; echo "method=$method"; echo "url=$url"; echo "auth=$auth"; echo "data=$data"; } >> "$FAKE_CURL_LOG"
# psargv is what ANY other local user's process would see via ps for this
# call - the actual exposure the bearer-in-argv defect is about. It is read
# from the OS, not reconstructed from "$@", so it cannot be faked by the stub.
psargv=$(ps -o args= -p $$ 2>/dev/null | tr '\n' ' ')
{ echo "argv=$argv"; echo "psargv=$psargv"; echo "method=$method"; echo "url=$url"; echo "auth=$auth"; echo "data=$data"; } >> "$FAKE_CURL_LOG"
fi
case "$url" in
*/connector/poll)
Expand Down Expand Up @@ -602,40 +634,31 @@ test_reply_non_2xx_fails() {
pass "fm-x-reply exits non-zero on a non-2xx relay response"
}

test_reply_auth_header_tempfile_cleans_up_on_interrupted_post() {
local home fakebin log out rc auth_file
# A post killed mid-flight is the case a cleanup trap can lose: the credential
# now rides stdin, so there is no auth file to leak in the first place. Assert
# the absence directly against a private TMPDIR rather than trusting a trap.
test_reply_interrupted_post_writes_no_credential_file() {
local home fakebin tmpdir out rc leaked
home="$TMP_ROOT/reply-auth-interrupt"; mkdir -p "$home"
fakebin=$(fm_fakebin "$home")
log="$home/auth-file.txt"
tmpdir="$home/tmp"; mkdir -p "$tmpdir"
cat > "$fakebin/curl" <<'SH'
#!/usr/bin/env bash
auth_file=
while [ $# -gt 0 ]; do
case "$1" in
-H)
case "$2" in @*) auth_file=${2#@} ;; esac
shift 2
;;
-o|-w|-X|-m|--data|--data-binary) shift 2 ;;
-s) shift ;;
*) shift ;;
esac
done
printf '%s\n' "$auth_file" > "$FAKE_AUTH_FILE_LOG"
# Die mid-post, exactly where a trap-based cleanup would have to run.
kill -TERM "$PPID"
exit 143
SH
chmod +x "$fakebin/curl"
printf 'FMX_PAIRING_TOKEN=tok-clean\n' > "$home/.env"
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
FAKE_AUTH_FILE_LOG="$log" \
TMPDIR="$tmpdir" \
"$ROOT/bin/fm-x-reply.sh" "req-clean" "Hello." 2>"$home/err"); rc=$?
[ "$rc" -ne 0 ] || fail "interrupted relay post must fail"
[ -z "$out" ] || fail "interrupted relay post must not echo the request_id (got: $out)"
auth_file=$(cat "$log")
[ -n "$auth_file" ] || fail "fake curl must record the auth header temp file"
[ ! -e "$auth_file" ] || fail "auth header temp file must be removed after an interrupted post"
pass "fm-x-reply cleans up auth header temp files on interrupted posts"
leaked=$(grep -rl "tok-clean" "$tmpdir" 2>/dev/null || true)
[ -z "$leaked" ] \
|| fail "interrupted post left the token in a temp file: $leaked"
pass "fm-x-reply writes no credential temp file, even on an interrupted post"
}

test_reply_usage_error() {
Expand Down Expand Up @@ -2863,6 +2886,94 @@ test_followup_usage_errors() {
pass "fm-x-followup rejects malformed invocations"
}

# --- the bearer must never reach curl's argv --------------------------------
#
# Process arguments are world readable: any local process can read them from ps
# for the life of the call, so a bearer passed as `-H "Authorization: Bearer
# $tok"` leaks the credential. Every fm-x-* request must hand curl its URL and
# auth header on stdin (`curl -K -`) instead.
#
# This asserts against psargv - the command line read back from the OS by the
# fake curl itself - so it measures the real exposure rather than restating what
# the source says. Each case also asserts the token DID arrive in the auth
# header, so a client that simply stopped authenticating could not pass.
assert_token_off_argv() {
local log=$1 token=$2 label=$3 psargv
# Exact string compare, not a pattern: a token may contain regex metacharacters.
[ "$(grep '^auth=' "$log" | tail -1)" = "auth=Authorization: Bearer $token" ] \
|| fail "$label must still send the bearer token (else this check is vacuous)"
psargv=$(grep '^psargv=' "$log" | tail -1)
[ -n "$psargv" ] || fail "$label: fake curl recorded no OS-visible command line"
if printf '%s' "$psargv" | grep -Fq "$token"; then
fail "$label leaked the bearer token into curl argv (visible via ps): $psargv"
fi
if grep '^argv=' "$log" | grep -Fq "$token"; then
fail "$label leaked the bearer token into curl argv"
fi
}

test_poll_keeps_bearer_off_curl_argv() {
local home fakebin log
home="$TMP_ROOT/argv-poll"; mkdir -p "$home"
fakebin=$(make_fake_curl "$home")
log="$home/curl.log"
printf 'FMX_PAIRING_TOKEN=tok-argv-poll\n' > "$home/.env"
PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
FAKE_CURL_LOG="$log" FAKE_POLL_CODE=204 \
"$ROOT/bin/fm-x-poll.sh" >/dev/null 2>&1
assert_grep "url=https://relay.test/connector/poll" "$log" "poll must still reach the relay"
assert_token_off_argv "$log" "tok-argv-poll" "poll"
pass "fm-x-poll keeps the bearer token out of curl argv"
}

test_reply_keeps_bearer_off_curl_argv() {
local home fakebin log
home="$TMP_ROOT/argv-reply"; mkdir -p "$home"
fakebin=$(make_fake_curl "$home")
log="$home/curl.log"
printf 'FMX_PAIRING_TOKEN=tok-argv-reply\n' > "$home/.env"
PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
FAKE_CURL_LOG="$log" FAKE_ANSWER_CODE=200 \
"$ROOT/bin/fm-x-reply.sh" "req-argv" "Aye." >/dev/null 2>&1
assert_grep "url=https://relay.test/connector/answer" "$log" "reply must still reach the relay"
assert_token_off_argv "$log" "tok-argv-reply" "reply"
pass "fm-x-reply keeps the bearer token out of curl argv"
}

test_dismiss_keeps_bearer_off_curl_argv() {
local home fakebin log
home="$TMP_ROOT/argv-dismiss"; mkdir -p "$home"
fakebin=$(make_fake_curl "$home")
log="$home/curl.log"
printf 'FMX_PAIRING_TOKEN=tok-argv-dismiss\n' > "$home/.env"
PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
FAKE_CURL_LOG="$log" FAKE_DISMISS_CODE=200 \
"$ROOT/bin/fm-x-dismiss.sh" "req-argv" >/dev/null 2>&1
assert_grep "url=https://relay.test/connector/dismiss" "$log" "dismiss must still reach the relay"
assert_token_off_argv "$log" "tok-argv-dismiss" "dismiss"
pass "fm-x-dismiss keeps the bearer token out of curl argv"
}

# A token carrying curl config metacharacters must survive the stdin config
# round trip intact, or the escaping could silently corrupt or truncate auth.
test_curl_config_escapes_awkward_token() {
local home fakebin log token
home="$TMP_ROOT/argv-escape"; mkdir -p "$home"
fakebin=$(make_fake_curl "$home")
log="$home/curl.log"
token='tok "quoted" \and\ slashed'
printf 'FMX_PAIRING_TOKEN=%s\n' "$token" > "$home/.env"
PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
FAKE_CURL_LOG="$log" FAKE_POLL_CODE=204 \
"$ROOT/bin/fm-x-poll.sh" >/dev/null 2>&1
assert_token_off_argv "$log" "$token" "poll (awkward token)"
pass "the curl stdin config escapes quotes and backslashes in a token"
}

test_poll_keeps_bearer_off_curl_argv
test_reply_keeps_bearer_off_curl_argv
test_dismiss_keeps_bearer_off_curl_argv
test_curl_config_escapes_awkward_token
test_poll_no_token_is_hard_noop
test_poll_empty_env_token_overrides_env_file
test_poll_204_is_silent
Expand All @@ -2880,7 +2991,7 @@ test_poll_rejects_unsafe_request_id
test_reply_success_posts_request_bound_only
test_reply_text_file_and_stdin
test_reply_non_2xx_fails
test_reply_auth_header_tempfile_cleans_up_on_interrupted_post
test_reply_interrupted_post_writes_no_credential_file
test_reply_usage_error
test_reply_help_mentions_image
test_reply_whitespace_text_rejected
Expand Down