diff --git a/skills/brainstorming/scripts/start-server.sh b/skills/brainstorming/scripts/start-server.sh index 016a8e4870..a1619f521f 100755 --- a/skills/brainstorming/scripts/start-server.sh +++ b/skills/brainstorming/scripts/start-server.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Start the brainstorm server and output connection info -# Usage: start-server.sh [--project-dir ] [--host ] [--url-host ] [--foreground] [--background] +# Usage: start-server.sh [--project-dir ] [--host ] [--url-host ] [--insecure-remote] [--foreground] [--background] # # Starts server on a random high port, outputs JSON with URL. # Each session gets its own directory to avoid conflicts. @@ -9,8 +9,10 @@ # --project-dir Store session files under /.superpowers/brainstorm/ # instead of /tmp. Files persist after server stops. # --host Host/interface to bind (default: 127.0.0.1). -# Use 0.0.0.0 in remote/containerized environments. +# Non-loopback binds require --insecure-remote. # --url-host Hostname shown in returned URL JSON. +# --insecure-remote Acknowledge that a non-loopback bind uses plaintext HTTP. +# Prefer an SSH tunnel or a TLS-terminating reverse proxy. # --idle-timeout-minutes Shut down after n minutes idle (default 240 = 4h). # --open Auto-open the browser on the first screen (use only # after the user approves the visual companion). @@ -23,6 +25,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="" FOREGROUND="false" FORCE_BACKGROUND="false" +INSECURE_REMOTE="false" BIND_HOST="127.0.0.1" URL_HOST="" IDLE_TIMEOUT_MINUTES="" @@ -40,6 +43,10 @@ while [[ $# -gt 0 ]]; do URL_HOST="$2" shift 2 ;; + --insecure-remote) + INSECURE_REMOTE="true" + shift + ;; --idle-timeout-minutes) IDLE_TIMEOUT_MINUTES="$2" shift 2 @@ -63,6 +70,22 @@ while [[ $# -gt 0 ]]; do esac done +is_loopback_bind_host() { + case "$1" in + localhost|127.*|::1|'[::1]') return 0 ;; + *) return 1 ;; + esac +} + +# The session key is a bearer credential carried in the companion URL and +# WebSocket connection. This server intentionally speaks HTTP only, so exposing +# it beyond loopback is unsafe unless the operator has arranged a secure +# transport (for example SSH port forwarding or TLS termination). +if ! is_loopback_bind_host "$BIND_HOST" && [[ "$INSECURE_REMOTE" != "true" ]]; then + echo '{"error": "Refusing non-loopback HTTP bind without --insecure-remote. Use SSH port forwarding or a TLS-terminating reverse proxy."}' + exit 2 +fi + if [[ -z "$URL_HOST" ]]; then if [[ "$BIND_HOST" == "127.0.0.1" || "$BIND_HOST" == "localhost" ]]; then URL_HOST="localhost" diff --git a/skills/brainstorming/visual-companion.md b/skills/brainstorming/visual-companion.md index 906c9ac87b..4447ce503f 100644 --- a/skills/brainstorming/visual-companion.md +++ b/skills/brainstorming/visual-companion.md @@ -91,16 +91,24 @@ scripts/start-server.sh --project-dir /path/to/project --open --foreground **Other environments:** The server must keep running in the background across conversation turns. If your environment reaps detached processes, use `--foreground` and launch the command with your platform's background execution mechanism. -If the URL is unreachable from your browser (common in remote/containerized setups), bind a non-loopback host: +If the URL is unreachable from your browser because the agent runs remotely or +inside a container, keep the companion bound to loopback and forward its port +through SSH. The session URL contains a bearer key, so the server must not be +exposed through plaintext HTTP on a LAN or the Internet. ```bash +# On the remote machine: start normally and note the returned port and URL. scripts/start-server.sh \ - --project-dir /path/to/project \ - --host 0.0.0.0 \ - --url-host localhost + --project-dir /path/to/project + +# On your local machine: replace both ports with the returned remote port. +ssh -L :127.0.0.1: user@remote-host ``` -Use `--url-host` to control what hostname is printed in the returned URL JSON. +Open the returned URL after replacing its host and port with +`localhost:`. If direct non-loopback exposure is unavoidable, pass +`--insecure-remote` explicitly and place the server behind TLS; this mode is +not safe over plaintext HTTP. ## The Loop diff --git a/tests/brainstorm-server/start-server.test.sh b/tests/brainstorm-server/start-server.test.sh index a4802dab69..eb2fb7fcb5 100644 --- a/tests/brainstorm-server/start-server.test.sh +++ b/tests/brainstorm-server/start-server.test.sh @@ -108,6 +108,26 @@ else "expected foreground node path, got: $captured" fi +echo "" +echo "--- start-server.sh remote bind safety ---" + +captured=$(bash "$START_SCRIPT" --project-dir "$TEST_DIR/project" --host 0.0.0.0 2>&1 || true) +if echo "$captured" | grep -q 'Refusing non-loopback HTTP bind without --insecure-remote'; then + pass "rejects non-loopback HTTP binds without explicit acknowledgement" +else + fail "rejects non-loopback HTTP binds without explicit acknowledgement" \ + "expected remote bind safety error, got: $captured" +fi + +captured=$(PATH="$TEST_DIR/fake-bin:$PATH" \ + bash "$START_SCRIPT" --project-dir "$TEST_DIR/project" --host 0.0.0.0 --insecure-remote --foreground 2>&1 || true) +if echo "$captured" | grep -q 'FOREGROUND_MODE=true'; then + pass "allows explicitly acknowledged non-loopback binds" +else + fail "allows explicitly acknowledged non-loopback binds" \ + "expected fake node to run after acknowledgement, got: $captured" +fi + echo "" echo "--- Results: $passed passed, $failed failed ---" if [[ $failed -gt 0 ]]; then