From 0b3f885d06d9fb3b267d64d5bd49792a8f88e38e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:19:08 +0000 Subject: [PATCH 01/17] fix: fetch SSH host key during setup to resolve StrictHostKeyChecking failure Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/4abefe1f-c4e4-4e74-ba3f-608f2861f0e5 Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com> --- api/static/callis.sh | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/api/static/callis.sh b/api/static/callis.sh index bd60dd7..baabcda 100755 --- a/api/static/callis.sh +++ b/api/static/callis.sh @@ -79,6 +79,19 @@ _callis_setup() { chmod 600 "$CALLIS_CONFIG_FILE" echo "Configuration saved to ${CALLIS_CONFIG_FILE}" + + # Fetch the SSH host key (TOFU — trust on first use) and store it in a + # Callis-specific known_hosts file so strict host key checking works. + printf "Fetching SSH host key from %s:%s...\n" "$CALLIS_HOST" "$CALLIS_PORT" + FETCHED=$(ssh-keyscan -p "$CALLIS_PORT" -t ed25519 "$CALLIS_HOST" 2>/dev/null) + if [ -n "$FETCHED" ]; then + printf '%s\n' "$FETCHED" > "${CALLIS_CONFIG_DIR}/known_hosts" + chmod 600 "${CALLIS_CONFIG_DIR}/known_hosts" + echo "Host key saved." + else + echo "Warning: could not fetch SSH host key from ${CALLIS_HOST}:${CALLIS_PORT}." >&2 + echo "Run 'callis setup' again once the server is reachable." >&2 + fi } _callis_load_config() { @@ -114,8 +127,13 @@ _callis_load_config() { _callis_list() { _callis_load_config || return 1 + if [ ! -f "${CALLIS_CONFIG_DIR}/known_hosts" ]; then + echo "Error: SSH host key not found. Run 'callis setup' to fetch it." >&2 + return 1 + fi ssh -i "$CALLIS_KEY" -p "$CALLIS_PORT" \ -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="${CALLIS_CONFIG_DIR}/known_hosts" \ "${CALLIS_USER}@${CALLIS_HOST}" list } @@ -130,6 +148,11 @@ _callis_connect() { return 1 ;; esac + if [ ! -f "${CALLIS_CONFIG_DIR}/known_hosts" ]; then + echo "Error: SSH host key not found. Run 'callis setup' to fetch it." >&2 + return 1 + fi + STDERR_TMP_CREATED=0 if STDERR_TMP=$(mktemp "${TMPDIR:-/tmp}/callis-err.XXXXXX"); then STDERR_TMP_CREATED=1 @@ -139,6 +162,7 @@ _callis_connect() { DEST=$(ssh -i "$CALLIS_KEY" -p "$CALLIS_PORT" \ -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="${CALLIS_CONFIG_DIR}/known_hosts" \ "${CALLIS_USER}@${CALLIS_HOST}" "resolve ${TAG}" 2>"$STDERR_TMP") if [ -z "$DEST" ]; then @@ -161,6 +185,7 @@ _callis_connect() { ssh -i "$CALLIS_KEY" \ -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="${CALLIS_CONFIG_DIR}/known_hosts" \ -J "${CALLIS_USER}@${CALLIS_HOST}:${CALLIS_PORT}" \ -p "$TARGET_PORT" "$@" \ "${CALLIS_USER}@${TARGET_HOST}" From 4a0d3a7a3ca59c49f4f3003c01e2bac6ae5293d1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 18:55:45 +0000 Subject: [PATCH 02/17] fix: include user's known_hosts in final-hop UserKnownHostsFile for callis connect Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/7ae4ae7e-9fcf-4864-83f8-9a0753df3ae9 Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com> --- api/static/callis.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/static/callis.sh b/api/static/callis.sh index baabcda..4ce20c0 100755 --- a/api/static/callis.sh +++ b/api/static/callis.sh @@ -185,7 +185,7 @@ _callis_connect() { ssh -i "$CALLIS_KEY" \ -o BatchMode=yes -o StrictHostKeyChecking=yes \ - -o UserKnownHostsFile="${CALLIS_CONFIG_DIR}/known_hosts" \ + -o "UserKnownHostsFile=${CALLIS_CONFIG_DIR}/known_hosts ${HOME}/.ssh/known_hosts" \ -J "${CALLIS_USER}@${CALLIS_HOST}:${CALLIS_PORT}" \ -p "$TARGET_PORT" "$@" \ "${CALLIS_USER}@${TARGET_HOST}" From d24cdc6112ff17d8e9388305fc914fead24f739e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 19:44:35 +0000 Subject: [PATCH 03/17] fix: setup fails on missing key; robust known_hosts guard; ProxyCommand for isolated bastion verification Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/a8eae23b-7ac0-4f27-8a81-6c7fe0f66db6 Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com> --- api/static/callis.sh | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/api/static/callis.sh b/api/static/callis.sh index 4ce20c0..928469a 100755 --- a/api/static/callis.sh +++ b/api/static/callis.sh @@ -89,8 +89,9 @@ _callis_setup() { chmod 600 "${CALLIS_CONFIG_DIR}/known_hosts" echo "Host key saved." else - echo "Warning: could not fetch SSH host key from ${CALLIS_HOST}:${CALLIS_PORT}." >&2 - echo "Run 'callis setup' again once the server is reachable." >&2 + echo "Error: could not fetch SSH host key from ${CALLIS_HOST}:${CALLIS_PORT}." >&2 + echo "Ensure the server is reachable and run 'callis setup' again." >&2 + return 1 fi } @@ -125,10 +126,16 @@ _callis_load_config() { esac } +_callis_has_known_hosts_entries() { + known_hosts_file="$1" + [ -s "$known_hosts_file" ] || return 1 + grep -Eq '^[[:space:]]*[^#[:space:]]' "$known_hosts_file" +} + _callis_list() { _callis_load_config || return 1 - if [ ! -f "${CALLIS_CONFIG_DIR}/known_hosts" ]; then - echo "Error: SSH host key not found. Run 'callis setup' to fetch it." >&2 + if ! _callis_has_known_hosts_entries "${CALLIS_CONFIG_DIR}/known_hosts"; then + echo "Error: SSH host key file is missing, empty, or invalid. Run 'callis setup' to fetch it again." >&2 return 1 fi ssh -i "$CALLIS_KEY" -p "$CALLIS_PORT" \ @@ -148,8 +155,8 @@ _callis_connect() { return 1 ;; esac - if [ ! -f "${CALLIS_CONFIG_DIR}/known_hosts" ]; then - echo "Error: SSH host key not found. Run 'callis setup' to fetch it." >&2 + if ! _callis_has_known_hosts_entries "${CALLIS_CONFIG_DIR}/known_hosts"; then + echo "Error: SSH host key file is missing, empty, or invalid. Run 'callis setup' to fetch it again." >&2 return 1 fi @@ -183,10 +190,12 @@ _callis_connect() { TARGET_HOST=$(echo "$DEST" | awk '{print $1}') TARGET_PORT=$(echo "$DEST" | awk '{print $2}') + PROXY_COMMAND="ssh -i \"$CALLIS_KEY\" -p \"$CALLIS_PORT\" -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=\"${CALLIS_CONFIG_DIR}/known_hosts\" -W %h:%p \"${CALLIS_USER}@${CALLIS_HOST}\"" + ssh -i "$CALLIS_KEY" \ -o BatchMode=yes -o StrictHostKeyChecking=yes \ - -o "UserKnownHostsFile=${CALLIS_CONFIG_DIR}/known_hosts ${HOME}/.ssh/known_hosts" \ - -J "${CALLIS_USER}@${CALLIS_HOST}:${CALLIS_PORT}" \ + -o "UserKnownHostsFile=${HOME}/.ssh/known_hosts" \ + -o "ProxyCommand=${PROXY_COMMAND}" \ -p "$TARGET_PORT" "$@" \ "${CALLIS_USER}@${TARGET_HOST}" } From f1cdcaa9ade8506341a4654d333af906e2797272 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 19:52:11 +0000 Subject: [PATCH 04/17] fix: rollback config on failure; ssh-keyscan timeout; fingerprint confirmation; GlobalKnownHostsFile isolation Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/0c091bb3-4d36-4819-af22-e5dba5daa332 Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com> --- api/static/callis.sh | 48 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/api/static/callis.sh b/api/static/callis.sh index 928469a..15a4a77 100755 --- a/api/static/callis.sh +++ b/api/static/callis.sh @@ -80,15 +80,48 @@ _callis_setup() { echo "Configuration saved to ${CALLIS_CONFIG_FILE}" - # Fetch the SSH host key (TOFU — trust on first use) and store it in a - # Callis-specific known_hosts file so strict host key checking works. + # Fetch the SSH host key, show its fingerprint, and require explicit user + # confirmation before trusting it for future connections. + KNOWN_HOSTS_FILE="${CALLIS_CONFIG_DIR}/known_hosts" + TMP_KNOWN_HOSTS_FILE="${KNOWN_HOSTS_FILE}.tmp.$$" printf "Fetching SSH host key from %s:%s...\n" "$CALLIS_HOST" "$CALLIS_PORT" - FETCHED=$(ssh-keyscan -p "$CALLIS_PORT" -t ed25519 "$CALLIS_HOST" 2>/dev/null) + FETCHED=$(ssh-keyscan -T 10 -p "$CALLIS_PORT" -t ed25519 "$CALLIS_HOST" 2>/dev/null) if [ -n "$FETCHED" ]; then - printf '%s\n' "$FETCHED" > "${CALLIS_CONFIG_DIR}/known_hosts" - chmod 600 "${CALLIS_CONFIG_DIR}/known_hosts" + printf '%s\n' "$FETCHED" > "$TMP_KNOWN_HOSTS_FILE" + FINGERPRINT=$(ssh-keygen -lf "$TMP_KNOWN_HOSTS_FILE" 2>/dev/null) + if [ -z "$FINGERPRINT" ]; then + rm -f "$TMP_KNOWN_HOSTS_FILE" "$CALLIS_CONFIG_FILE" + echo "Error: could not compute SSH host key fingerprint for ${CALLIS_HOST}:${CALLIS_PORT}." >&2 + return 1 + fi + + echo "Fetched SSH host key fingerprint:" + printf ' %s\n' "$FINGERPRINT" + echo "Verify this fingerprint with your administrator or another trusted out-of-band source before continuing." + printf "Trust and save this host key? Type 'yes' to continue: " + read -r TRUST_HOST_KEY + if [ "$TRUST_HOST_KEY" != "yes" ]; then + rm -f "$TMP_KNOWN_HOSTS_FILE" "$CALLIS_CONFIG_FILE" + echo "Host key was not saved. Setup aborted." + return 1 + fi + + if [ -s "$KNOWN_HOSTS_FILE" ]; then + echo "Warning: ${KNOWN_HOSTS_FILE} already exists and will be replaced." + printf "Type 'yes' to overwrite the existing Callis host key: " + read -r OVERWRITE_KNOWN_HOSTS + if [ "$OVERWRITE_KNOWN_HOSTS" != "yes" ]; then + rm -f "$TMP_KNOWN_HOSTS_FILE" "$CALLIS_CONFIG_FILE" + echo "Existing host key was left unchanged. Setup aborted." + return 1 + fi + fi + + mv "$TMP_KNOWN_HOSTS_FILE" "$KNOWN_HOSTS_FILE" + chmod 600 "$KNOWN_HOSTS_FILE" echo "Host key saved." else + rm -f "$CALLIS_CONFIG_FILE" echo "Error: could not fetch SSH host key from ${CALLIS_HOST}:${CALLIS_PORT}." >&2 echo "Ensure the server is reachable and run 'callis setup' again." >&2 return 1 @@ -140,6 +173,7 @@ _callis_list() { fi ssh -i "$CALLIS_KEY" -p "$CALLIS_PORT" \ -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -o GlobalKnownHostsFile=/dev/null \ -o UserKnownHostsFile="${CALLIS_CONFIG_DIR}/known_hosts" \ "${CALLIS_USER}@${CALLIS_HOST}" list } @@ -169,6 +203,7 @@ _callis_connect() { DEST=$(ssh -i "$CALLIS_KEY" -p "$CALLIS_PORT" \ -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -o GlobalKnownHostsFile=/dev/null \ -o UserKnownHostsFile="${CALLIS_CONFIG_DIR}/known_hosts" \ "${CALLIS_USER}@${CALLIS_HOST}" "resolve ${TAG}" 2>"$STDERR_TMP") @@ -190,10 +225,11 @@ _callis_connect() { TARGET_HOST=$(echo "$DEST" | awk '{print $1}') TARGET_PORT=$(echo "$DEST" | awk '{print $2}') - PROXY_COMMAND="ssh -i \"$CALLIS_KEY\" -p \"$CALLIS_PORT\" -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=\"${CALLIS_CONFIG_DIR}/known_hosts\" -W %h:%p \"${CALLIS_USER}@${CALLIS_HOST}\"" + PROXY_COMMAND="ssh -i \"$CALLIS_KEY\" -p \"$CALLIS_PORT\" -o BatchMode=yes -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=\"${CALLIS_CONFIG_DIR}/known_hosts\" -W %h:%p \"${CALLIS_USER}@${CALLIS_HOST}\"" ssh -i "$CALLIS_KEY" \ -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -o GlobalKnownHostsFile=/dev/null \ -o "UserKnownHostsFile=${HOME}/.ssh/known_hosts" \ -o "ProxyCommand=${PROXY_COMMAND}" \ -p "$TARGET_PORT" "$@" \ From 2d881e0b97c31be9071058a7d154b94680b89dce Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:00:56 +0000 Subject: [PATCH 05/17] fix: mktemp for tmp known_hosts; checked mv/chmod; deferred success msg; POSIX sq escaping for ProxyCommand Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/c9f63a0e-425a-40dc-a640-d3832bb9df40 Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com> --- api/static/callis.sh | 47 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/api/static/callis.sh b/api/static/callis.sh index 15a4a77..9fb8678 100755 --- a/api/static/callis.sh +++ b/api/static/callis.sh @@ -78,16 +78,24 @@ _callis_setup() { } > "$CALLIS_CONFIG_FILE" chmod 600 "$CALLIS_CONFIG_FILE" - echo "Configuration saved to ${CALLIS_CONFIG_FILE}" + echo "Configuration written to ${CALLIS_CONFIG_FILE}; verifying SSH host key before setup is finalized." # Fetch the SSH host key, show its fingerprint, and require explicit user # confirmation before trusting it for future connections. KNOWN_HOSTS_FILE="${CALLIS_CONFIG_DIR}/known_hosts" - TMP_KNOWN_HOSTS_FILE="${KNOWN_HOSTS_FILE}.tmp.$$" + TMP_KNOWN_HOSTS_FILE=$(mktemp "${KNOWN_HOSTS_FILE}.tmp.XXXXXX") || { + rm -f "$CALLIS_CONFIG_FILE" + echo "Error: could not create temporary known_hosts file." >&2 + return 1 + } printf "Fetching SSH host key from %s:%s...\n" "$CALLIS_HOST" "$CALLIS_PORT" FETCHED=$(ssh-keyscan -T 10 -p "$CALLIS_PORT" -t ed25519 "$CALLIS_HOST" 2>/dev/null) if [ -n "$FETCHED" ]; then - printf '%s\n' "$FETCHED" > "$TMP_KNOWN_HOSTS_FILE" + if ! printf '%s\n' "$FETCHED" > "$TMP_KNOWN_HOSTS_FILE"; then + rm -f "$TMP_KNOWN_HOSTS_FILE" "$CALLIS_CONFIG_FILE" + echo "Error: could not write SSH host key to temporary file." >&2 + return 1 + fi FINGERPRINT=$(ssh-keygen -lf "$TMP_KNOWN_HOSTS_FILE" 2>/dev/null) if [ -z "$FINGERPRINT" ]; then rm -f "$TMP_KNOWN_HOSTS_FILE" "$CALLIS_CONFIG_FILE" @@ -117,11 +125,19 @@ _callis_setup() { fi fi - mv "$TMP_KNOWN_HOSTS_FILE" "$KNOWN_HOSTS_FILE" - chmod 600 "$KNOWN_HOSTS_FILE" - echo "Host key saved." + if ! mv "$TMP_KNOWN_HOSTS_FILE" "$KNOWN_HOSTS_FILE"; then + rm -f "$TMP_KNOWN_HOSTS_FILE" "$CALLIS_CONFIG_FILE" + echo "Error: could not save SSH host key to ${KNOWN_HOSTS_FILE}." >&2 + return 1 + fi + if ! chmod 600 "$KNOWN_HOSTS_FILE"; then + rm -f "$KNOWN_HOSTS_FILE" "$CALLIS_CONFIG_FILE" + echo "Error: could not set permissions on ${KNOWN_HOSTS_FILE}." >&2 + return 1 + fi + echo "Setup complete. Configuration and host key saved." else - rm -f "$CALLIS_CONFIG_FILE" + rm -f "$TMP_KNOWN_HOSTS_FILE" "$CALLIS_CONFIG_FILE" echo "Error: could not fetch SSH host key from ${CALLIS_HOST}:${CALLIS_PORT}." >&2 echo "Ensure the server is reachable and run 'callis setup' again." >&2 return 1 @@ -165,6 +181,13 @@ _callis_has_known_hosts_entries() { grep -Eq '^[[:space:]]*[^#[:space:]]' "$known_hosts_file" } +# POSIX single-quote escaping: wraps the argument in single quotes and escapes +# any embedded single quotes so the result is safe for shell evaluation (e.g., +# inside a ProxyCommand string that OpenSSH passes to a shell). +_callis_sq() { + printf '%s' "$1" | sed "s/'/'\\''/g; s/^/'/; s/\$/'/" +} + _callis_list() { _callis_load_config || return 1 if ! _callis_has_known_hosts_entries "${CALLIS_CONFIG_DIR}/known_hosts"; then @@ -225,7 +248,15 @@ _callis_connect() { TARGET_HOST=$(echo "$DEST" | awk '{print $1}') TARGET_PORT=$(echo "$DEST" | awk '{print $2}') - PROXY_COMMAND="ssh -i \"$CALLIS_KEY\" -p \"$CALLIS_PORT\" -o BatchMode=yes -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=\"${CALLIS_CONFIG_DIR}/known_hosts\" -W %h:%p \"${CALLIS_USER}@${CALLIS_HOST}\"" + # Build ProxyCommand using POSIX single-quote escaping so user-controlled + # values (key path, port, username, hostname) cannot inject shell metacharacters + # when OpenSSH evaluates the command string. + _escaped_key=$(_callis_sq "$CALLIS_KEY") + _escaped_port=$(_callis_sq "$CALLIS_PORT") + _escaped_user=$(_callis_sq "$CALLIS_USER") + _escaped_host=$(_callis_sq "$CALLIS_HOST") + _escaped_known=$(_callis_sq "${CALLIS_CONFIG_DIR}/known_hosts") + PROXY_COMMAND="ssh -i ${_escaped_key} -p ${_escaped_port} -o BatchMode=yes -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=${_escaped_known} -W %h:%p ${_escaped_user}@${_escaped_host}" ssh -i "$CALLIS_KEY" \ -o BatchMode=yes -o StrictHostKeyChecking=yes \ From 4e951b6e574095f2c90c1c1ccf64e6c61902a50e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:36:48 +0000 Subject: [PATCH 06/17] fix: stderr for abort messages; validate TARGET_HOST/PORT; quote %h:%p in ProxyCommand Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/1b43c896-e52f-4e14-8d18-076255dc991f Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com> --- api/static/callis.sh | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/api/static/callis.sh b/api/static/callis.sh index 9fb8678..5368e27 100755 --- a/api/static/callis.sh +++ b/api/static/callis.sh @@ -110,7 +110,7 @@ _callis_setup() { read -r TRUST_HOST_KEY if [ "$TRUST_HOST_KEY" != "yes" ]; then rm -f "$TMP_KNOWN_HOSTS_FILE" "$CALLIS_CONFIG_FILE" - echo "Host key was not saved. Setup aborted." + echo "Host key was not saved. Setup aborted." >&2 return 1 fi @@ -120,7 +120,7 @@ _callis_setup() { read -r OVERWRITE_KNOWN_HOSTS if [ "$OVERWRITE_KNOWN_HOSTS" != "yes" ]; then rm -f "$TMP_KNOWN_HOSTS_FILE" "$CALLIS_CONFIG_FILE" - echo "Existing host key was left unchanged. Setup aborted." + echo "Existing host key was left unchanged. Setup aborted." >&2 return 1 fi fi @@ -248,15 +248,33 @@ _callis_connect() { TARGET_HOST=$(echo "$DEST" | awk '{print $1}') TARGET_PORT=$(echo "$DEST" | awk '{print $2}') + # Validate bastion-supplied TARGET_HOST and TARGET_PORT to prevent shell + # injection via OpenSSH's %h/%p substitution in ProxyCommand. + case "$TARGET_HOST" in + ''|*[!A-Za-z0-9._:-]*) + echo "Error: bastion returned an invalid target host" >&2 + return 1 ;; + esac + case "$TARGET_PORT" in + ''|*[!0-9]*) + echo "Error: bastion returned an invalid target port" >&2 + return 1 ;; + esac + if [ "$TARGET_PORT" -lt 1 ] || [ "$TARGET_PORT" -gt 65535 ]; then + echo "Error: bastion returned an invalid target port" >&2 + return 1 + fi + # Build ProxyCommand using POSIX single-quote escaping so user-controlled # values (key path, port, username, hostname) cannot inject shell metacharacters - # when OpenSSH evaluates the command string. + # when OpenSSH evaluates the command string. Quote %h:%p as a single shell + # argument so OpenSSH substitution cannot introduce shell syntax. _escaped_key=$(_callis_sq "$CALLIS_KEY") _escaped_port=$(_callis_sq "$CALLIS_PORT") _escaped_user=$(_callis_sq "$CALLIS_USER") _escaped_host=$(_callis_sq "$CALLIS_HOST") _escaped_known=$(_callis_sq "${CALLIS_CONFIG_DIR}/known_hosts") - PROXY_COMMAND="ssh -i ${_escaped_key} -p ${_escaped_port} -o BatchMode=yes -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=${_escaped_known} -W %h:%p ${_escaped_user}@${_escaped_host}" + PROXY_COMMAND="ssh -i ${_escaped_key} -p ${_escaped_port} -o BatchMode=yes -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=${_escaped_known} -W '%h:%p' ${_escaped_user}@${_escaped_host}" ssh -i "$CALLIS_KEY" \ -o BatchMode=yes -o StrictHostKeyChecking=yes \ From 5113bc9b93e12634dc3ad6a3381e39ffbe52de7b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:37:44 +0000 Subject: [PATCH 07/17] fix: tighten TARGET_HOST charset; reject leading-zero TARGET_PORT before range check Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/1b43c896-e52f-4e14-8d18-076255dc991f Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com> --- api/static/callis.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/static/callis.sh b/api/static/callis.sh index 5368e27..51527e2 100755 --- a/api/static/callis.sh +++ b/api/static/callis.sh @@ -251,12 +251,12 @@ _callis_connect() { # Validate bastion-supplied TARGET_HOST and TARGET_PORT to prevent shell # injection via OpenSSH's %h/%p substitution in ProxyCommand. case "$TARGET_HOST" in - ''|*[!A-Za-z0-9._:-]*) + ''|*[!A-Za-z0-9.-]*) echo "Error: bastion returned an invalid target host" >&2 return 1 ;; esac case "$TARGET_PORT" in - ''|*[!0-9]*) + ''|*[!0-9]*|0[0-9]*) echo "Error: bastion returned an invalid target port" >&2 return 1 ;; esac From b0bba361885694c803c0218e8bf53f1c1d1ef75d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 00:08:14 +0000 Subject: [PATCH 08/17] fix: write config via temp file + atomic mv; check write and chmod return codes Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/9554d4ab-0e49-4a70-b8bf-5d991e04cc12 Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com> --- api/static/callis.sh | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/api/static/callis.sh b/api/static/callis.sh index 51527e2..2d3511c 100755 --- a/api/static/callis.sh +++ b/api/static/callis.sh @@ -69,16 +69,32 @@ _callis_setup() { read -r CALLIS_KEY CALLIS_KEY="${CALLIS_KEY:-$HOME/.ssh/id_ed25519}" - # Write values as plain key=value pairs (not sourced — parsed safely below) - { + # Write values as plain key=value pairs (not sourced — parsed safely below). + # Use a temp file + atomic mv so a failed write never leaves a partial config. + TMP_CONFIG_FILE=$(mktemp "${CALLIS_CONFIG_FILE}.tmp.XXXXXX") || { + echo "Error: could not create temporary config file." >&2 + return 1 + } + if ! { printf 'CALLIS_HOST=%s\n' "$CALLIS_HOST" printf 'CALLIS_PORT=%s\n' "$CALLIS_PORT" printf 'CALLIS_USER=%s\n' "$CALLIS_USER" printf 'CALLIS_KEY=%s\n' "$CALLIS_KEY" - } > "$CALLIS_CONFIG_FILE" - chmod 600 "$CALLIS_CONFIG_FILE" - - echo "Configuration written to ${CALLIS_CONFIG_FILE}; verifying SSH host key before setup is finalized." + } > "$TMP_CONFIG_FILE"; then + rm -f "$TMP_CONFIG_FILE" + echo "Error: could not write configuration to temporary file." >&2 + return 1 + fi + if ! chmod 600 "$TMP_CONFIG_FILE"; then + rm -f "$TMP_CONFIG_FILE" + echo "Error: could not set permissions on temporary config file." >&2 + return 1 + fi + if ! mv "$TMP_CONFIG_FILE" "$CALLIS_CONFIG_FILE"; then + rm -f "$TMP_CONFIG_FILE" + echo "Error: could not save configuration to ${CALLIS_CONFIG_FILE}." >&2 + return 1 + fi # Fetch the SSH host key, show its fingerprint, and require explicit user # confirmation before trusting it for future connections. From 78b5aef20eb545a5ed1399c264e76d75f099f7a7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:12:26 +0000 Subject: [PATCH 09/17] fix: defer config mv until after host key committed; allow underscores in TARGET_HOST Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/e1b2d303-8866-4547-b5bc-c0ba4a2773af Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com> --- api/static/callis.sh | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/api/static/callis.sh b/api/static/callis.sh index 2d3511c..c4e5e04 100755 --- a/api/static/callis.sh +++ b/api/static/callis.sh @@ -90,17 +90,14 @@ _callis_setup() { echo "Error: could not set permissions on temporary config file." >&2 return 1 fi - if ! mv "$TMP_CONFIG_FILE" "$CALLIS_CONFIG_FILE"; then - rm -f "$TMP_CONFIG_FILE" - echo "Error: could not save configuration to ${CALLIS_CONFIG_FILE}." >&2 - return 1 - fi # Fetch the SSH host key, show its fingerprint, and require explicit user # confirmation before trusting it for future connections. + # The config temp file is not committed until both files are ready so that + # aborting or failing here never overwrites a previously working config. KNOWN_HOSTS_FILE="${CALLIS_CONFIG_DIR}/known_hosts" TMP_KNOWN_HOSTS_FILE=$(mktemp "${KNOWN_HOSTS_FILE}.tmp.XXXXXX") || { - rm -f "$CALLIS_CONFIG_FILE" + rm -f "$TMP_CONFIG_FILE" echo "Error: could not create temporary known_hosts file." >&2 return 1 } @@ -108,13 +105,13 @@ _callis_setup() { FETCHED=$(ssh-keyscan -T 10 -p "$CALLIS_PORT" -t ed25519 "$CALLIS_HOST" 2>/dev/null) if [ -n "$FETCHED" ]; then if ! printf '%s\n' "$FETCHED" > "$TMP_KNOWN_HOSTS_FILE"; then - rm -f "$TMP_KNOWN_HOSTS_FILE" "$CALLIS_CONFIG_FILE" + rm -f "$TMP_KNOWN_HOSTS_FILE" "$TMP_CONFIG_FILE" echo "Error: could not write SSH host key to temporary file." >&2 return 1 fi FINGERPRINT=$(ssh-keygen -lf "$TMP_KNOWN_HOSTS_FILE" 2>/dev/null) if [ -z "$FINGERPRINT" ]; then - rm -f "$TMP_KNOWN_HOSTS_FILE" "$CALLIS_CONFIG_FILE" + rm -f "$TMP_KNOWN_HOSTS_FILE" "$TMP_CONFIG_FILE" echo "Error: could not compute SSH host key fingerprint for ${CALLIS_HOST}:${CALLIS_PORT}." >&2 return 1 fi @@ -125,7 +122,7 @@ _callis_setup() { printf "Trust and save this host key? Type 'yes' to continue: " read -r TRUST_HOST_KEY if [ "$TRUST_HOST_KEY" != "yes" ]; then - rm -f "$TMP_KNOWN_HOSTS_FILE" "$CALLIS_CONFIG_FILE" + rm -f "$TMP_KNOWN_HOSTS_FILE" "$TMP_CONFIG_FILE" echo "Host key was not saved. Setup aborted." >&2 return 1 fi @@ -135,25 +132,32 @@ _callis_setup() { printf "Type 'yes' to overwrite the existing Callis host key: " read -r OVERWRITE_KNOWN_HOSTS if [ "$OVERWRITE_KNOWN_HOSTS" != "yes" ]; then - rm -f "$TMP_KNOWN_HOSTS_FILE" "$CALLIS_CONFIG_FILE" + rm -f "$TMP_KNOWN_HOSTS_FILE" "$TMP_CONFIG_FILE" echo "Existing host key was left unchanged. Setup aborted." >&2 return 1 fi fi if ! mv "$TMP_KNOWN_HOSTS_FILE" "$KNOWN_HOSTS_FILE"; then - rm -f "$TMP_KNOWN_HOSTS_FILE" "$CALLIS_CONFIG_FILE" + rm -f "$TMP_KNOWN_HOSTS_FILE" "$TMP_CONFIG_FILE" echo "Error: could not save SSH host key to ${KNOWN_HOSTS_FILE}." >&2 return 1 fi if ! chmod 600 "$KNOWN_HOSTS_FILE"; then - rm -f "$KNOWN_HOSTS_FILE" "$CALLIS_CONFIG_FILE" + rm -f "$KNOWN_HOSTS_FILE" "$TMP_CONFIG_FILE" echo "Error: could not set permissions on ${KNOWN_HOSTS_FILE}." >&2 return 1 fi + # Both files are ready — atomically commit the config last so that + # any earlier abort leaves the previous config intact. + if ! mv "$TMP_CONFIG_FILE" "$CALLIS_CONFIG_FILE"; then + rm -f "$TMP_CONFIG_FILE" "$KNOWN_HOSTS_FILE" + echo "Error: could not save configuration to ${CALLIS_CONFIG_FILE}." >&2 + return 1 + fi echo "Setup complete. Configuration and host key saved." else - rm -f "$TMP_KNOWN_HOSTS_FILE" "$CALLIS_CONFIG_FILE" + rm -f "$TMP_KNOWN_HOSTS_FILE" "$TMP_CONFIG_FILE" echo "Error: could not fetch SSH host key from ${CALLIS_HOST}:${CALLIS_PORT}." >&2 echo "Ensure the server is reachable and run 'callis setup' again." >&2 return 1 @@ -267,7 +271,7 @@ _callis_connect() { # Validate bastion-supplied TARGET_HOST and TARGET_PORT to prevent shell # injection via OpenSSH's %h/%p substitution in ProxyCommand. case "$TARGET_HOST" in - ''|*[!A-Za-z0-9.-]*) + ''|*[!A-Za-z0-9._-]*) echo "Error: bastion returned an invalid target host" >&2 return 1 ;; esac From 446ac01813cfd87cb51c0d48d4c7710b745c5b91 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:26:44 +0000 Subject: [PATCH 10/17] fix: use -f for known_hosts overwrite check; backup/restore known_hosts for atomic setup Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/6592ac9d-1ccd-4ec6-bb48-431d91ea065e Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com> --- api/static/callis.sh | 39 +++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/api/static/callis.sh b/api/static/callis.sh index c4e5e04..a6f5cbd 100755 --- a/api/static/callis.sh +++ b/api/static/callis.sh @@ -127,7 +127,9 @@ _callis_setup() { return 1 fi - if [ -s "$KNOWN_HOSTS_FILE" ]; then + # Use -f (existence) not -s (non-empty) so empty or corrupt files also + # trigger the overwrite confirmation and are not silently replaced. + if [ -f "$KNOWN_HOSTS_FILE" ]; then echo "Warning: ${KNOWN_HOSTS_FILE} already exists and will be replaced." printf "Type 'yes' to overwrite the existing Callis host key: " read -r OVERWRITE_KNOWN_HOSTS @@ -138,23 +140,52 @@ _callis_setup() { fi fi + # Back up the existing known_hosts so it can be restored if the final + # config commit fails, ensuring no partial state is left on disk. + BACKUP_KNOWN_HOSTS="" + if [ -f "$KNOWN_HOSTS_FILE" ]; then + BACKUP_KNOWN_HOSTS=$(mktemp "${KNOWN_HOSTS_FILE}.bak.XXXXXX") || { + rm -f "$TMP_KNOWN_HOSTS_FILE" "$TMP_CONFIG_FILE" + echo "Error: could not create backup of existing known_hosts." >&2 + return 1 + } + if ! cp "$KNOWN_HOSTS_FILE" "$BACKUP_KNOWN_HOSTS"; then + rm -f "$TMP_KNOWN_HOSTS_FILE" "$TMP_CONFIG_FILE" "$BACKUP_KNOWN_HOSTS" + echo "Error: could not back up existing known_hosts." >&2 + return 1 + fi + fi + if ! mv "$TMP_KNOWN_HOSTS_FILE" "$KNOWN_HOSTS_FILE"; then - rm -f "$TMP_KNOWN_HOSTS_FILE" "$TMP_CONFIG_FILE" + rm -f "$TMP_KNOWN_HOSTS_FILE" "$TMP_CONFIG_FILE" "$BACKUP_KNOWN_HOSTS" echo "Error: could not save SSH host key to ${KNOWN_HOSTS_FILE}." >&2 return 1 fi if ! chmod 600 "$KNOWN_HOSTS_FILE"; then - rm -f "$KNOWN_HOSTS_FILE" "$TMP_CONFIG_FILE" + if [ -n "$BACKUP_KNOWN_HOSTS" ]; then + mv "$BACKUP_KNOWN_HOSTS" "$KNOWN_HOSTS_FILE" || \ + echo "Warning: could not restore previous known_hosts from ${BACKUP_KNOWN_HOSTS}." >&2 + else + rm -f "$KNOWN_HOSTS_FILE" + fi + rm -f "$TMP_CONFIG_FILE" echo "Error: could not set permissions on ${KNOWN_HOSTS_FILE}." >&2 return 1 fi # Both files are ready — atomically commit the config last so that # any earlier abort leaves the previous config intact. if ! mv "$TMP_CONFIG_FILE" "$CALLIS_CONFIG_FILE"; then - rm -f "$TMP_CONFIG_FILE" "$KNOWN_HOSTS_FILE" + if [ -n "$BACKUP_KNOWN_HOSTS" ]; then + mv "$BACKUP_KNOWN_HOSTS" "$KNOWN_HOSTS_FILE" || \ + echo "Warning: could not restore previous known_hosts from ${BACKUP_KNOWN_HOSTS}." >&2 + else + rm -f "$KNOWN_HOSTS_FILE" + fi + rm -f "$TMP_CONFIG_FILE" echo "Error: could not save configuration to ${CALLIS_CONFIG_FILE}." >&2 return 1 fi + rm -f "$BACKUP_KNOWN_HOSTS" echo "Setup complete. Configuration and host key saved." else rm -f "$TMP_KNOWN_HOSTS_FILE" "$TMP_CONFIG_FILE" From 24ffa05c2dd84bb94b829c1e954d71df61ea3f79 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:43:56 +0000 Subject: [PATCH 11/17] fix: guard BACKUP_KNOWN_HOSTS rm calls with non-empty check When no prior known_hosts exists, BACKUP_KNOWN_HOSTS is empty string. Calling `rm -f ""` is non-portable and may emit diagnostics or return non-zero on some platforms. Guard both the mv-failure cleanup path and the post-success cleanup with `[ -n "$BACKUP_KNOWN_HOSTS" ]` before invoking rm. Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/09fd8773-d243-4801-b931-b4b54d9da5e2 Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com> --- api/static/callis.sh | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/api/static/callis.sh b/api/static/callis.sh index a6f5cbd..d34018e 100755 --- a/api/static/callis.sh +++ b/api/static/callis.sh @@ -157,7 +157,11 @@ _callis_setup() { fi if ! mv "$TMP_KNOWN_HOSTS_FILE" "$KNOWN_HOSTS_FILE"; then - rm -f "$TMP_KNOWN_HOSTS_FILE" "$TMP_CONFIG_FILE" "$BACKUP_KNOWN_HOSTS" + if [ -n "$BACKUP_KNOWN_HOSTS" ]; then + rm -f "$TMP_KNOWN_HOSTS_FILE" "$TMP_CONFIG_FILE" "$BACKUP_KNOWN_HOSTS" + else + rm -f "$TMP_KNOWN_HOSTS_FILE" "$TMP_CONFIG_FILE" + fi echo "Error: could not save SSH host key to ${KNOWN_HOSTS_FILE}." >&2 return 1 fi @@ -185,7 +189,9 @@ _callis_setup() { echo "Error: could not save configuration to ${CALLIS_CONFIG_FILE}." >&2 return 1 fi - rm -f "$BACKUP_KNOWN_HOSTS" + if [ -n "$BACKUP_KNOWN_HOSTS" ]; then + rm -f "$BACKUP_KNOWN_HOSTS" + fi echo "Setup complete. Configuration and host key saved." else rm -f "$TMP_KNOWN_HOSTS_FILE" "$TMP_CONFIG_FILE" From 41e8f949150a4f71fc1229e4bd63573e6a8e6898 Mon Sep 17 00:00:00 2001 From: pacnpal <183241239+pacnpal@users.noreply.github.com> Date: Wed, 8 Apr 2026 08:49:08 -0400 Subject: [PATCH 12/17] Update api/static/callis.sh Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- api/static/callis.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/static/callis.sh b/api/static/callis.sh index d34018e..cc3a582 100755 --- a/api/static/callis.sh +++ b/api/static/callis.sh @@ -242,7 +242,7 @@ _callis_has_known_hosts_entries() { # any embedded single quotes so the result is safe for shell evaluation (e.g., # inside a ProxyCommand string that OpenSSH passes to a shell). _callis_sq() { - printf '%s' "$1" | sed "s/'/'\\''/g; s/^/'/; s/\$/'/" + printf '%s' "$1" | sed "s/'/'\\''/g; s/^/'/; s/$/'/" } _callis_list() { From 964702198e07cad7268962fea6dcf0dd52f1d62c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:58:23 +0000 Subject: [PATCH 13/17] fix: move $@ before security options in _callis_connect to prevent override Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/a32dff90-e869-490d-8b9c-ac00d22f9883 Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com> --- api/static/callis.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/api/static/callis.sh b/api/static/callis.sh index cc3a582..df6269d 100755 --- a/api/static/callis.sh +++ b/api/static/callis.sh @@ -333,11 +333,14 @@ _callis_connect() { _escaped_known=$(_callis_sq "${CALLIS_CONFIG_DIR}/known_hosts") PROXY_COMMAND="ssh -i ${_escaped_key} -p ${_escaped_port} -o BatchMode=yes -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=${_escaped_known} -W '%h:%p' ${_escaped_user}@${_escaped_host}" - ssh -i "$CALLIS_KEY" \ + # Pass $@ before the enforced options so user-supplied flags (e.g. -v) + # are accepted but cannot override the security settings that follow. + ssh "$@" \ + -i "$CALLIS_KEY" \ + -p "$TARGET_PORT" \ -o BatchMode=yes -o StrictHostKeyChecking=yes \ -o GlobalKnownHostsFile=/dev/null \ -o "UserKnownHostsFile=${HOME}/.ssh/known_hosts" \ -o "ProxyCommand=${PROXY_COMMAND}" \ - -p "$TARGET_PORT" "$@" \ "${CALLIS_USER}@${TARGET_HOST}" } From 5ff08830c78683f5ed0efe90a47558d37564b90e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:52:31 +0000 Subject: [PATCH 14/17] fix: validate CALLIS_HOST/PORT before ssh-keyscan; split $@ into flags+remote-cmd in connect Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/52cd6852-79eb-455c-9e07-189f7063a62b Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com> --- api/static/callis.sh | 87 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 78 insertions(+), 9 deletions(-) diff --git a/api/static/callis.sh b/api/static/callis.sh index df6269d..a4bacac 100755 --- a/api/static/callis.sh +++ b/api/static/callis.sh @@ -56,9 +56,27 @@ _callis_setup() { echo "Error: hostname is required." >&2 return 1 fi + # Reject hostnames that start with '-' (would be parsed as options by + # ssh-keyscan/ssh) or contain whitespace (not valid in a hostname). + case "$CALLIS_HOST" in + -*|*' '*|*' '*) + echo "Error: invalid hostname — must not start with '-' or contain whitespace." >&2 + return 1 ;; + esac printf "Callis SSH port [2222]: " read -r CALLIS_PORT CALLIS_PORT="${CALLIS_PORT:-2222}" + # Validate port: must be a plain positive integer in range 1-65535 with no + # leading zeros (which could be misinterpreted as octal on some systems). + case "$CALLIS_PORT" in + ''|*[!0-9]*|0[0-9]*) + echo "Error: invalid port — must be a positive number with no leading zeros." >&2 + return 1 ;; + esac + if [ "$CALLIS_PORT" -lt 1 ] || [ "$CALLIS_PORT" -gt 65535 ]; then + echo "Error: invalid port — must be between 1 and 65535." >&2 + return 1 + fi printf "Your Callis username: " read -r CALLIS_USER if [ -z "$CALLIS_USER" ]; then @@ -102,7 +120,7 @@ _callis_setup() { return 1 } printf "Fetching SSH host key from %s:%s...\n" "$CALLIS_HOST" "$CALLIS_PORT" - FETCHED=$(ssh-keyscan -T 10 -p "$CALLIS_PORT" -t ed25519 "$CALLIS_HOST" 2>/dev/null) + FETCHED=$(ssh-keyscan -T 10 -p "$CALLIS_PORT" -t ed25519 -- "$CALLIS_HOST" 2>/dev/null) if [ -n "$FETCHED" ]; then if ! printf '%s\n' "$FETCHED" > "$TMP_KNOWN_HOSTS_FILE"; then rm -f "$TMP_KNOWN_HOSTS_FILE" "$TMP_CONFIG_FILE" @@ -333,14 +351,65 @@ _callis_connect() { _escaped_known=$(_callis_sq "${CALLIS_CONFIG_DIR}/known_hosts") PROXY_COMMAND="ssh -i ${_escaped_key} -p ${_escaped_port} -o BatchMode=yes -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=${_escaped_known} -W '%h:%p' ${_escaped_user}@${_escaped_host}" - # Pass $@ before the enforced options so user-supplied flags (e.g. -v) - # are accepted but cannot override the security settings that follow. - ssh "$@" \ - -i "$CALLIS_KEY" \ + # Separate user-supplied SSH option flags from any trailing remote command. + # Leading args that begin with '-' (including their next-arg values for + # options that require them) are treated as SSH flags and placed after the + # enforced security options below. The first non-flag arg (or any args + # after '--') form the remote command and are placed after the destination. + # This means the enforced options always appear first and cannot be + # overridden (OpenSSH uses first-occurrence semantics for -o options). + # + # SSH options that consume the next positional argument as their value: + _nflags=0 + _skip_next=0 + for _ua in "$@"; do + if [ "$_skip_next" -eq 1 ]; then + _nflags=$((_nflags + 1)); _skip_next=0; continue + fi + case "$_ua" in + --) break ;; + -b|-c|-D|-E|-e|-F|-I|-i|-J|-L|-l|-m|-o|-p|-Q|-R|-S|-w|-W) + _nflags=$((_nflags + 1)); _skip_next=1 ;; + -*) _nflags=$((_nflags + 1)) ;; + *) break ;; + esac + done + + # Build single-quoted strings for the flag and command portions so they + # can be safely re-split by eval without shell-injection risk. + _uf='' + _uc='' + _ui=0 + for _ua in "$@"; do + _ui=$((_ui + 1)) + _q=$(_callis_sq "$_ua") + if [ "$_ui" -le "$_nflags" ]; then + _uf="${_uf:+$_uf }$_q" + elif [ "$_ua" = "--" ] && [ "$_ui" -eq "$((_nflags + 1))" ]; then + : # drop the '--' separator — not needed in the final ssh call + else + _uc="${_uc:+$_uc }$_q" + fi + done + + _sq_key=$(_callis_sq "$CALLIS_KEY") + _sq_proxy=$(_callis_sq "$PROXY_COMMAND") + _sq_dest=$(_callis_sq "${CALLIS_USER}@${TARGET_HOST}") + _sq_known=$(_callis_sq "${HOME}/.ssh/known_hosts") + + # Enforced security options come first so they take precedence (first- + # occurrence wins in OpenSSH). User option flags follow, then the fixed + # destination, then any user-supplied remote command arguments. + # shellcheck disable=SC2086 + eval ssh \ + -i "$_sq_key" \ -p "$TARGET_PORT" \ - -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -o BatchMode=yes \ + -o StrictHostKeyChecking=yes \ -o GlobalKnownHostsFile=/dev/null \ - -o "UserKnownHostsFile=${HOME}/.ssh/known_hosts" \ - -o "ProxyCommand=${PROXY_COMMAND}" \ - "${CALLIS_USER}@${TARGET_HOST}" + -o "UserKnownHostsFile=$_sq_known" \ + -o "ProxyCommand=$_sq_proxy" \ + ${_uf} \ + "$_sq_dest" \ + ${_uc} } From 2b038ff2b03f6bb59772c46272a9daa9deee9b5b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:24:14 +0000 Subject: [PATCH 15/17] fix: known_hosts readability check and ssh arg splitter for concatenated options Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/c0b1cb09-5ed0-46a6-9c8e-d12e56a6fc7d Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com> --- api/static/callis.sh | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/api/static/callis.sh b/api/static/callis.sh index a4bacac..344f6a2 100755 --- a/api/static/callis.sh +++ b/api/static/callis.sh @@ -252,8 +252,10 @@ _callis_load_config() { _callis_has_known_hosts_entries() { known_hosts_file="$1" - [ -s "$known_hosts_file" ] || return 1 - grep -Eq '^[[:space:]]*[^#[:space:]]' "$known_hosts_file" + # Use -r (readable) instead of -s (non-empty) so unreadable files are + # rejected cleanly; the grep handles empty files by returning non-zero. + [ -r "$known_hosts_file" ] || return 1 + grep -Eq '^[[:space:]]*[^#[:space:]]' "$known_hosts_file" 2>/dev/null } # POSIX single-quote escaping: wraps the argument in single quotes and escapes @@ -368,8 +370,14 @@ _callis_connect() { fi case "$_ua" in --) break ;; + # Options that take a next argument as a *separate* token. + # When the value is concatenated in the same token (e.g., + # -L8080:host:port or -oStrictHostKeyChecking=yes), the token + # length is > 2 and the value is already present, so do NOT + # set _skip_next in that case. -b|-c|-D|-E|-e|-F|-I|-i|-J|-L|-l|-m|-o|-p|-Q|-R|-S|-w|-W) - _nflags=$((_nflags + 1)); _skip_next=1 ;; + _nflags=$((_nflags + 1)) + if [ "${#_ua}" -eq 2 ]; then _skip_next=1; fi ;; -*) _nflags=$((_nflags + 1)) ;; *) break ;; esac From 9bf2098d96045dbd012cd39bdfad91a993ca116e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:45:22 +0000 Subject: [PATCH 16/17] fix: correct _callis_sq sed escaping and move -p after user flags in _callis_connect Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/489cbf93-0d75-4a6c-9cb0-9a362fd0981d Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com> --- api/static/callis.sh | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/api/static/callis.sh b/api/static/callis.sh index 344f6a2..9c45d50 100755 --- a/api/static/callis.sh +++ b/api/static/callis.sh @@ -262,7 +262,7 @@ _callis_has_known_hosts_entries() { # any embedded single quotes so the result is safe for shell evaluation (e.g., # inside a ProxyCommand string that OpenSSH passes to a shell). _callis_sq() { - printf '%s' "$1" | sed "s/'/'\\''/g; s/^/'/; s/$/'/" + printf '%s' "$1" | sed "s/'/'\\\\''/g; s/^/'/; s/$/'/" } _callis_list() { @@ -405,19 +405,22 @@ _callis_connect() { _sq_dest=$(_callis_sq "${CALLIS_USER}@${TARGET_HOST}") _sq_known=$(_callis_sq "${HOME}/.ssh/known_hosts") - # Enforced security options come first so they take precedence (first- - # occurrence wins in OpenSSH). User option flags follow, then the fixed - # destination, then any user-supplied remote command arguments. + # Enforced -o options come first so they take precedence (first-occurrence + # wins in OpenSSH for -o flags). User option flags follow. -p comes after + # user flags because OpenSSH uses last-occurrence semantics for -p, so + # placing it last ensures the bastion-resolved target port cannot be + # overridden by a user-supplied -p flag. The fixed destination follows, + # then any user-supplied remote command arguments. # shellcheck disable=SC2086 eval ssh \ -i "$_sq_key" \ - -p "$TARGET_PORT" \ -o BatchMode=yes \ -o StrictHostKeyChecking=yes \ -o GlobalKnownHostsFile=/dev/null \ -o "UserKnownHostsFile=$_sq_known" \ -o "ProxyCommand=$_sq_proxy" \ ${_uf} \ + -p "$TARGET_PORT" \ "$_sq_dest" \ ${_uc} } From 851a14c4c20a1bda484db993ebc2552372a768c8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 16:11:11 +0000 Subject: [PATCH 17/17] fix: replace _callis_sq sed implementation with pure POSIX shell Replaces the double-quoted sed program (which used 4 backslashes \\\\ to produce the correct sed replacement) with a pure POSIX shell while-loop that splits on ' characters using %% / # parameter expansion and appends the standard '\'' escape sequence for each embedded quote. Avoids sed and double-quote backslash counting entirely, making the escaping logic visually unambiguous. Adds an inline example in the comment showing that _callis_sq "it's" produces 'it'\''s' which eval reproduces "it's". Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/d8e62c33-f356-4385-b5f6-ec47d72d6ae3 Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com> --- api/static/callis.sh | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/api/static/callis.sh b/api/static/callis.sh index 9c45d50..9181aa0 100755 --- a/api/static/callis.sh +++ b/api/static/callis.sh @@ -258,11 +258,22 @@ _callis_has_known_hosts_entries() { grep -Eq '^[[:space:]]*[^#[:space:]]' "$known_hosts_file" 2>/dev/null } -# POSIX single-quote escaping: wraps the argument in single quotes and escapes -# any embedded single quotes so the result is safe for shell evaluation (e.g., -# inside a ProxyCommand string that OpenSSH passes to a shell). +# POSIX single-quote escaping: wraps the argument in single quotes and replaces +# each embedded ' with '\'' (end-quote, backslash-escaped literal quote, +# reopen-quote) so the result is safe for shell evaluation (e.g., inside a +# ProxyCommand string that OpenSSH passes to a shell). +# Example: _callis_sq "it's" produces 'it'\''s' which eval reproduces "it's". _callis_sq() { - printf '%s' "$1" | sed "s/'/'\\\\''/g; s/^/'/; s/$/'/" + _sq_out='' + _sq_in="$1" + while [ -n "$_sq_in" ]; do + _sq_head="${_sq_in%%\'*}" + _sq_out="${_sq_out}${_sq_head}" + [ "$_sq_in" = "$_sq_head" ] && break + _sq_out="${_sq_out}'\\''" + _sq_in="${_sq_in#*\'}" + done + printf "'%s'" "$_sq_out" } _callis_list() {