diff --git a/packages/cli/src/__tests__/cmd-link-cov.test.ts b/packages/cli/src/__tests__/cmd-link-cov.test.ts index 8f940195..5e6d16b4 100644 --- a/packages/cli/src/__tests__/cmd-link-cov.test.ts +++ b/packages/cli/src/__tests__/cmd-link-cov.test.ts @@ -42,12 +42,12 @@ const SSH_DETECT_CLOUD_HETZNER = (_host: string, _user: string, _keys: string[], }; const SSH_DETECT_AGENT_VIA_WHICH = (_host: string, _user: string, _keys: string[], cmd: string) => { - // ps aux returns nothing, but which finds the binary + // ps aux returns nothing, but command -v finds the binary if (cmd.includes("ps aux")) { return null; } - if (cmd.includes("which")) { - return "/usr/local/bin/claude\nclaude"; + if (cmd === "command -v claude") { + return "/usr/local/bin/claude"; } return null; }; diff --git a/packages/cli/src/commands/link.ts b/packages/cli/src/commands/link.ts index d1c6e006..bafa0227 100644 --- a/packages/cli/src/commands/link.ts +++ b/packages/cli/src/commands/link.ts @@ -87,13 +87,11 @@ function detectAgent(host: string, user: string, keyOpts: string[], runCmd: SshC } } - // Second: check installed binaries - const whichCmd = KNOWN_AGENTS.map((b) => `(which ${b} 2>/dev/null && echo ${b})`).join(" || "); - const whichOut = runCmd(host, user, keyOpts, whichCmd); - if (whichOut) { - const match = KNOWN_AGENTS.find((b: KnownAgent) => whichOut.includes(b)); - if (match) { - return match; + // Second: check installed binaries — one SSH call per agent to avoid shell injection + for (const agent of KNOWN_AGENTS) { + const whichOut = runCmd(host, user, keyOpts, `command -v ${agent}`); + if (whichOut) { + return agent; } } diff --git a/sh/e2e/lib/clouds/gcp.sh b/sh/e2e/lib/clouds/gcp.sh index 10f87b48..91687e97 100644 --- a/sh/e2e/lib/clouds/gcp.sh +++ b/sh/e2e/lib/clouds/gcp.sh @@ -14,6 +14,27 @@ set -eo pipefail _GCP_INSTANCE_IP="" _GCP_INSTANCE_APP="" +# --------------------------------------------------------------------------- +# _gcp_validate_instance_name NAME +# +# Validate that a GCP instance name contains only safe characters. +# GCP requires: lowercase letters, digits, and hyphens; must start with a +# letter and not end with a hyphen; max 63 chars. +# Returns 0 on valid, 1 on invalid. +# --------------------------------------------------------------------------- +_gcp_validate_instance_name() { + local name="$1" + if [ -z "${name}" ]; then + log_err "Instance name is empty" + return 1 + fi + if ! printf '%s' "${name}" | grep -qE '^[a-z][a-z0-9-]{0,61}[a-z0-9]$'; then + log_err "Invalid GCP instance name: ${name} (must match [a-z][a-z0-9-]*[a-z0-9], max 63 chars)" + return 1 + fi + return 0 +} + # --------------------------------------------------------------------------- # _gcp_validate_env # @@ -105,6 +126,7 @@ process.stdout.write(d.GCP_ZONE || ''); _gcp_headless_env() { local app="$1" # $2 = agent (unused but part of the interface) + _gcp_validate_instance_name "${app}" || return 1 printf 'export GCP_INSTANCE_NAME="%s"\n' "${app}" printf 'export GCP_PROJECT="%s"\n' "${GCP_PROJECT:-}" @@ -127,6 +149,7 @@ _gcp_provision_verify() { local log_dir="$2" local zone="${GCP_ZONE:-us-central1-a}" local project="${GCP_PROJECT:-}" + _gcp_validate_instance_name "${app}" || return 1 # Check instance exists if ! gcloud compute instances describe "${app}" \ @@ -174,6 +197,7 @@ _gcp_exec() { local app="$1" local cmd="$2" local ssh_user="${GCP_SSH_USER:-$(whoami)}" + _gcp_validate_instance_name "${app}" || return 1 # Validate SSH user contains only safe characters (defense-in-depth) if ! printf '%s' "${ssh_user}" | grep -qE '^[a-zA-Z0-9._-]+$'; then @@ -238,6 +262,7 @@ _gcp_teardown() { local app="$1" local zone="${GCP_ZONE:-us-central1-a}" local project="${GCP_PROJECT:-}" + _gcp_validate_instance_name "${app}" || return 1 # Try reading zone/project from metadata file if [ -n "${LOG_DIR:-}" ] && [ -f "${LOG_DIR}/${app}.meta" ]; then @@ -330,6 +355,12 @@ _gcp_cleanup_stale() { instance_name=$(printf '%s' "${entry}" | awk '{print $1}') instance_zone_url=$(printf '%s' "${entry}" | awk '{print $2}') + if ! _gcp_validate_instance_name "${instance_name}"; then + log_warn "Skipping ${instance_name} — invalid name format" + skipped=$((skipped + 1)) + continue + fi + # Extract zone name from full URL (zones/us-central1-a -> us-central1-a) local instance_zone instance_zone=$(printf '%s' "${instance_zone_url}" | sed 's|.*/||')