From 8234dbbca872f388faf6b2cb2285966c72129a9c Mon Sep 17 00:00:00 2001 From: mkultraWasHere Date: Tue, 29 Sep 2026 15:14:20 -0400 Subject: [PATCH 1/3] fix(console): isolate all launcher LLM credentials Prevent ambient OpenRouter and selected provider credentials from reaching build, dependency, deployment, or other non-LLM subprocesses. Co-Authored-By: Codex --- console/README.md | 3 +- console/SECURITY.md | 6 +- console/backend/subprocess_env.py | 28 +++- console/backend/tests/test_subprocess_env.py | 168 +++++++++++++++++-- dreadgoad-console | 100 ++++++----- 5 files changed, 241 insertions(+), 64 deletions(-) diff --git a/console/README.md b/console/README.md index 8db0c0b0..8cc01d40 100644 --- a/console/README.md +++ b/console/README.md @@ -69,7 +69,8 @@ For another model provider, export its native credential variable and name it with `--api-key-env`; for example, use `--model anthropic/... --api-key-env ANTHROPIC_API_KEY`. The launcher does not rename one provider's credential into another provider's variable. The selected -name is shown in Settings and its value is removed from CLI, cloud CLI, +name is shown in Settings. The default OpenRouter credential and selected +provider credential are removed from dependency, build, CLI, cloud CLI, Terraform, Ansible, and helper subprocesses. The state root and session/config directories are restricted to the console user diff --git a/console/SECURITY.md b/console/SECURITY.md index 6b448db2..c6a4d431 100644 --- a/console/SECURITY.md +++ b/console/SECURITY.md @@ -82,8 +82,10 @@ The provider-native LLM API key selected by the launcher or entered in Settings is kept in the backend process environment and is not returned by the API or written to the console database. `OPENROUTER_API_KEY` remains the default for the default OpenRouter model; other providers retain their own variable names. -Every registered LLM credential is removed from the environment passed to CLI, -cloud CLI, Terraform, Ansible, and helper subprocesses. +The launcher registers both that default and the selected provider variable, +then removes their values before dependency, build, and frontend helpers run. +Every registered LLM credential is also removed from the environment passed to +CLI, cloud CLI, Terraform, Ansible, and other backend subprocesses. Cloud credentials are not collected or managed by the console. Provider tooling uses the ambient credentials available to the launcher through environment diff --git a/console/backend/subprocess_env.py b/console/backend/subprocess_env.py index fdb7b3dd..3f9df31d 100644 --- a/console/backend/subprocess_env.py +++ b/console/backend/subprocess_env.py @@ -13,6 +13,7 @@ DEFAULT_LLM_SECRET_ENV = "OPENROUTER_API_KEY" LLM_SECRET_ENV_SETTING = "DREADGOAD_CONSOLE_LLM_SECRET_ENV" +LLM_SECRET_ENVS_SETTING = "DREADGOAD_CONSOLE_LLM_SECRET_ENVS" # These names pass the Settings route's credential-shaped-name validation, but # they belong to infrastructure commands rather than the LLM. Treating one as @@ -29,16 +30,23 @@ _configured_secret_env = ( os.environ.get(LLM_SECRET_ENV_SETTING, "").strip() or DEFAULT_LLM_SECRET_ENV ) -if _configured_secret_env in _INFRASTRUCTURE_CREDENTIAL_ENV_NAMES: - raise RuntimeError( - f"{LLM_SECRET_ENV_SETTING} cannot name infrastructure credential " - f"{_configured_secret_env}" - ) +_configured_secret_env_names = {DEFAULT_LLM_SECRET_ENV, _configured_secret_env} +_configured_secret_env_names.update( + name.strip() + for name in os.environ.get(LLM_SECRET_ENVS_SETTING, "").split(",") + if name.strip() +) +for _secret_env_name in _configured_secret_env_names: + if _secret_env_name in _INFRASTRUCTURE_CREDENTIAL_ENV_NAMES: + raise RuntimeError( + f"LLM secret metadata cannot name infrastructure credential " + f"{_secret_env_name}" + ) -# Start with only the configured provider credential. Rebinding an immutable -# snapshot lets child_env() iterate safely if a Settings request registers -# another key name at the same time as a command is starting. -_llm_secret_env_names = frozenset({_configured_secret_env}) +# Start with every credential name registered by the launcher. Rebinding an +# immutable snapshot lets child_env() iterate safely if a Settings request +# registers another key name at the same time as a command is starting. +_llm_secret_env_names = frozenset(_configured_secret_env_names) _active_llm_secret_env = _configured_secret_env @@ -52,6 +60,7 @@ def register_llm_secret_env(name: str) -> None: _llm_secret_env_names = _llm_secret_env_names | {name} _active_llm_secret_env = name os.environ[LLM_SECRET_ENV_SETTING] = name + os.environ[LLM_SECRET_ENVS_SETTING] = ",".join(sorted(_llm_secret_env_names)) def active_llm_secret_env() -> str: @@ -70,4 +79,5 @@ def child_env() -> dict[str, str]: for name in _llm_secret_env_names: env.pop(name, None) env.pop(LLM_SECRET_ENV_SETTING, None) + env.pop(LLM_SECRET_ENVS_SETTING, None) return env diff --git a/console/backend/tests/test_subprocess_env.py b/console/backend/tests/test_subprocess_env.py index e752f2de..68189d3b 100644 --- a/console/backend/tests/test_subprocess_env.py +++ b/console/backend/tests/test_subprocess_env.py @@ -9,14 +9,17 @@ import json import os import pathlib +import shlex import subprocess import sys +import tempfile sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[3])) from console.backend.cli import capture, run_command # noqa: E402 from console.backend.subprocess_env import ( # noqa: E402 LLM_SECRET_ENV_SETTING, + LLM_SECRET_ENVS_SETTING, child_env, register_llm_secret_env, ) @@ -65,11 +68,12 @@ def test_child_env_scrubs_only_registered_llm_credentials() -> None: _restore_environment(previous) -def test_launcher_scrubs_only_the_selected_provider_key() -> None: - """Startup metadata scrubs the selected provider's native variable only.""" +def test_backend_scrubs_all_launcher_registered_provider_keys() -> None: + """Startup metadata registers both the default and selected provider keys.""" provider_key = "DREADGOAD_TEST_PROVIDER_API_KEY" env = os.environ.copy() env[LLM_SECRET_ENV_SETTING] = provider_key + env[LLM_SECRET_ENVS_SETTING] = f"OPENROUTER_API_KEY,{provider_key}" env[provider_key] = "provider-secret" env["OPENROUTER_API_KEY"] = "openrouter-secret" code = ( @@ -79,9 +83,11 @@ def test_launcher_scrubs_only_the_selected_provider_key() -> None: "child = child_env(); " f"print(json.dumps({{'active': active_llm_secret_env(), " f"'provider_value': os.environ.get({provider_key!r}), " + "'openrouter_value': os.environ.get('OPENROUTER_API_KEY'), " f"'provider_in_child': {provider_key!r} in child, " "'openrouter_in_child': 'OPENROUTER_API_KEY' in child, " - f"'metadata_in_child': {LLM_SECRET_ENV_SETTING!r} in child}}))" + f"'metadata_in_child': {LLM_SECRET_ENV_SETTING!r} in child, " + f"'metadata_list_in_child': {LLM_SECRET_ENVS_SETTING!r} in child}}))" ) result = subprocess.run( # noqa: S603 [sys.executable, "-c", code], @@ -95,9 +101,11 @@ def test_launcher_scrubs_only_the_selected_provider_key() -> None: assert values == { "active": provider_key, "provider_value": "provider-secret", + "openrouter_value": "openrouter-secret", "provider_in_child": False, - "openrouter_in_child": True, + "openrouter_in_child": False, "metadata_in_child": False, + "metadata_list_in_child": False, } @@ -106,13 +114,120 @@ def test_launcher_preserves_provider_native_key_name() -> None: launcher = pathlib.Path(__file__).resolve().parents[3] / "dreadgoad-console" source = launcher.read_text(encoding="utf-8") assert 'export DREADGOAD_CONSOLE_LLM_SECRET_ENV="$API_KEY_ENV"' in source + assert 'export DREADGOAD_CONSOLE_LLM_SECRET_ENVS="$LLM_SECRET_ENV_LIST"' in source assert 'export OPENROUTER_API_KEY="$API_KEY_VALUE"' not in source - assert source.count('export "$API_KEY_ENV=$LLM_API_KEY_VALUE"') == 1 - assert source.index('unset "$API_KEY_ENV"') < source.index( + assert 'LLM_SECRET_ENV_NAMES=("OPENROUTER_API_KEY")' in source + assert 'export "$secret_name=$secret_value"' in source + assert source.index('unset "$secret_name"') < source.index( "# --- Python venv + backend deps" ) +def test_launcher_help_runs_without_external_tools() -> None: + """Even early help rendering must not expose credentials to a child tool.""" + repo_root = pathlib.Path(__file__).resolve().parents[3] + launcher = repo_root / "dreadgoad-console" + env = os.environ.copy() + env.update( + { + "PATH": "", + "OPENROUTER_API_KEY": "openrouter-secret", + "ANTHROPIC_API_KEY": "anthropic-secret", + } + ) + result = subprocess.run( # noqa: S603 + ["/bin/bash", str(launcher), "--api-key-env", "ANTHROPIC_API_KEY", "--help"], + cwd=repo_root, + env=env, + check=True, + capture_output=True, + text=True, + ) + assert "USAGE" in result.stdout + + +def test_launcher_restores_registered_keys_only_for_backend() -> None: + """The real start_backend function handles every launch credential matrix.""" + repo_root = pathlib.Path(__file__).resolve().parents[3] + source = (repo_root / "dreadgoad-console").read_text(encoding="utf-8") + start = source.index("start_backend() {") + end = source.index('\n}\n\nif [ "$DEV"', start) + 2 + function_source = source[start:end] + + with tempfile.TemporaryDirectory() as temp_dir: + venv_bin = pathlib.Path(temp_dir) / "bin" + venv_bin.mkdir() + fake_uvicorn = venv_bin / "uvicorn" + fake_uvicorn.write_text( + "#!/usr/bin/env python3\n" + "import json, os\n" + 'names = ["OPENROUTER_API_KEY", ' + '"DREADGOAD_TEST_PROVIDER_API_KEY", ' + '"DREADGOAD_CONSOLE_AUTH_TOKEN", ' + '"LLM_SECRET_ENV_VALUES", "secret_name", "secret_value"]\n' + "print(json.dumps({name: os.environ.get(name) for name in names}))\n", + encoding="utf-8", + ) + fake_uvicorn.chmod(0o755) + cases = ( + ( + "default only", + '("OPENROUTER_API_KEY")', + '("openrouter-secret")', + "openrouter-secret", + None, + ), + ( + "selected only", + '("OPENROUTER_API_KEY" "DREADGOAD_TEST_PROVIDER_API_KEY")', + '("" "provider-secret")', + None, + "provider-secret", + ), + ( + "default and selected", + '("OPENROUTER_API_KEY" "DREADGOAD_TEST_PROVIDER_API_KEY")', + '("openrouter-secret" "provider-secret")', + "openrouter-secret", + "provider-secret", + ), + ) + for label, names, secrets, openrouter_value, provider_value in cases: + script = ( + f"{function_source}\n" + f"LLM_SECRET_ENV_NAMES={names}\n" + f"LLM_SECRET_ENV_VALUES={secrets}\n" + f"VENV={shlex.quote(temp_dir)}\n" + 'AUTH_TOKEN="browser-token"\n' + "start_backend console.backend.server:app\n" + ) + env = os.environ.copy() + env.update( + { + "LLM_SECRET_ENV_VALUES": "attacker-array-seed", + "secret_name": "attacker-name-seed", + "secret_value": "attacker-value-seed", + } + ) + result = subprocess.run( # noqa: S603 + ["bash", "-c", script], + cwd=repo_root, + env=env, + check=True, + capture_output=True, + text=True, + ) + values = json.loads(result.stdout) + assert values == { + "OPENROUTER_API_KEY": openrouter_value, + "DREADGOAD_TEST_PROVIDER_API_KEY": provider_value, + "DREADGOAD_CONSOLE_AUTH_TOKEN": "browser-token", + "LLM_SECRET_ENV_VALUES": None, + "secret_name": None, + "secret_value": None, + }, label + + def test_launcher_rejects_infrastructure_credential_names() -> None: """AWS session-token aliases cannot be selected as LLM credentials.""" repo_root = pathlib.Path(__file__).resolve().parents[3] @@ -129,6 +244,23 @@ def test_launcher_rejects_infrastructure_credential_names() -> None: assert f"cannot use infrastructure credential {name}" in result.stderr +def test_backend_rejects_infrastructure_credential_metadata() -> None: + """Launcher metadata cannot classify cloud credentials as LLM secrets.""" + repo_root = pathlib.Path(__file__).resolve().parents[3] + env = os.environ.copy() + env[LLM_SECRET_ENVS_SETTING] = "OPENROUTER_API_KEY,AWS_SESSION_TOKEN" + result = subprocess.run( # noqa: S603 + [sys.executable, "-c", "import console.backend.subprocess_env"], + cwd=repo_root, + env=env, + check=False, + capture_output=True, + text=True, + ) + assert result.returncode != 0 + assert "cannot name infrastructure credential AWS_SESSION_TOKEN" in result.stderr + + def test_launcher_scrubs_provider_key_before_build_helpers() -> None: """The real launcher prefix removes the key before dependency setup.""" repo_root = pathlib.Path(__file__).resolve().parents[3] @@ -136,16 +268,20 @@ def test_launcher_scrubs_provider_key_before_build_helpers() -> None: source = launcher.read_text(encoding="utf-8") prefix = source.split("# --- Python venv + backend deps", maxsplit=1)[0] probe = ( - "\nprintf 'VERIFY:%s:%s:%s\\n' " - '"${ANTHROPIC_API_KEY-unset}" "$LLM_API_KEY_VALUE" ' - '"$DREADGOAD_CONSOLE_LLM_SECRET_ENV"\n' - 'python3 -c \'import os; print("CHILD:" + ' - 'os.environ.get("LLM_API_KEY_VALUE", "unset"))\'\n' + "\nprintf 'VERIFY:%s:%s:%s:%s:%s\\n' " + '"${ANTHROPIC_API_KEY-unset}" "${OPENROUTER_API_KEY-unset}" ' + '"$LLM_API_KEY_VALUE" "$DREADGOAD_CONSOLE_LLM_SECRET_ENV" ' + '"$DREADGOAD_CONSOLE_LLM_SECRET_ENVS"\n' + 'python3 -c \'import os; print("CHILD:%s:%s:%s" % ' + '(os.environ.get("ANTHROPIC_API_KEY", "unset"), ' + 'os.environ.get("OPENROUTER_API_KEY", "unset"), ' + 'os.environ.get("LLM_API_KEY_VALUE", "unset")))\'\n' ) env = os.environ.copy() env.update( { "ANTHROPIC_API_KEY": "anthropic-secret", + "OPENROUTER_API_KEY": "openrouter-secret", "DREADGOAD_CONSOLE_PORT": "0", "LLM_API_KEY_VALUE": "attacker-seed", "NO_COLOR": "1", @@ -167,8 +303,9 @@ def test_launcher_scrubs_provider_key_before_build_helpers() -> None: text=True, ) assert result.stdout.splitlines()[-2:] == [ - "VERIFY:unset:anthropic-secret:ANTHROPIC_API_KEY", - "CHILD:unset", + "VERIFY:unset:unset:anthropic-secret:ANTHROPIC_API_KEY:" + "OPENROUTER_API_KEY,ANTHROPIC_API_KEY", + "CHILD:unset:unset:unset", ] @@ -200,9 +337,12 @@ async def test_captured_command_uses_scrubbed_environment() -> None: def main() -> None: test_child_env_scrubs_only_registered_llm_credentials() - test_launcher_scrubs_only_the_selected_provider_key() + test_backend_scrubs_all_launcher_registered_provider_keys() test_launcher_preserves_provider_native_key_name() + test_launcher_help_runs_without_external_tools() + test_launcher_restores_registered_keys_only_for_backend() test_launcher_rejects_infrastructure_credential_names() + test_backend_rejects_infrastructure_credential_metadata() test_launcher_scrubs_provider_key_before_build_helpers() asyncio.run(test_streaming_command_uses_scrubbed_environment()) asyncio.run(test_captured_command_uses_scrubbed_environment()) diff --git a/dreadgoad-console b/dreadgoad-console index be557669..baf2c9bb 100755 --- a/dreadgoad-console +++ b/dreadgoad-console @@ -9,7 +9,12 @@ # (or at cli/dreadgoad) — the backend shells out to it. See design §10. set -euo pipefail -ROOT="$(cd "$(dirname "$0")" && pwd)" +# Resolve the repository root with Bash built-ins only. LLM credentials are +# still ambient at this point, so no external executable may run yet. +LAUNCHER_DIR="${0%/*}" +if [ "$LAUNCHER_DIR" = "$0" ]; then LAUNCHER_DIR="."; fi +ROOT="$(cd "$LAUNCHER_DIR" && pwd)" +unset LAUNCHER_DIR BACKEND="$ROOT/console/backend" FRONTEND="$ROOT/console/frontend" VENV="$ROOT/.venv" @@ -54,8 +59,6 @@ else RESET='' fi -COLS="$(tput cols 2>/dev/null || echo 80)" - # resolved_model — what a new session will actually run on. # # Asks the backend rather than repeating its fallback: paths.py is stdlib-only @@ -86,7 +89,7 @@ help_cont() { printf ' %-18s %s%s%s\n' "" "$FAINT" "$1" "$RESET"; } usage() { local self - self="$(basename "$0")" + self="${0##*/}" # The compact wordmark, matching banner()'s narrow-terminal form — help is # reference material, not a launch, so it does not get the full six-line # ASCII lockup. @@ -103,7 +106,11 @@ usage() { help_opt "(none)" "build the frontend and serve it on :$PORT" help_opt "--dev" "vite dev server + backend, with hot reload" help_opt "--model NAME" "agent model for new sessions" - help_cont "default: $(resolved_model)" + if [ -n "$MODEL" ]; then + help_cont "default: $MODEL" + else + help_cont "default: backend default" + fi help_opt "--api-key-env VAR" "use the provider-native LLM key in VAR" help_cont "default: $API_KEY_ENV" help_opt "-h, --help" "show this message and exit" @@ -218,6 +225,37 @@ step() { printf '\n %s▸%s %s%s%s\n' "$BRAND" "$RESET" "$BOLD" "$1" "$RESET"; warn() { printf ' %s!%s %s%s%s\n' "$AMBER" "$RESET" "$AMBER" "$1" "$RESET"; } fail() { printf ' %s✕%s %s%s%s\n' "$RED" "$RESET" "$RED" "$1" "$RESET"; } +# Capture every credential name known to this launch before dependency, build, +# or frontend helpers run. The documented OpenRouter default remains sensitive +# even when another provider is selected explicitly. +if [ "$API_KEY_ENV" != "OPENROUTER_API_KEY" ] && [ -z "${!API_KEY_ENV:-}" ]; then + printf '\n' + fail "--api-key-env named $API_KEY_ENV, but that variable is empty or unset." + fail "export it first, or drop the flag to use OPENROUTER_API_KEY." + exit 1 +fi +LLM_API_KEY_VALUE="${!API_KEY_ENV:-}" +# Assignment preserves an inherited export attribute in Bash. Force this +# holding variable to remain shell-private before any build helper starts. +export -n LLM_API_KEY_VALUE +LLM_SECRET_ENV_NAMES=("OPENROUTER_API_KEY") +LLM_SECRET_ENV_VALUES=("${OPENROUTER_API_KEY:-}") +if [ "$API_KEY_ENV" != "OPENROUTER_API_KEY" ]; then + LLM_SECRET_ENV_NAMES+=("$API_KEY_ENV") + LLM_SECRET_ENV_VALUES+=("$LLM_API_KEY_VALUE") +fi +# Arrays are not placed in a child environment by Bash, but clear any inherited +# export attributes as defense in depth before they hold credential values. +export -n LLM_SECRET_ENV_NAMES LLM_SECRET_ENV_VALUES +for secret_name in "${LLM_SECRET_ENV_NAMES[@]}"; do + unset "$secret_name" +done +unset secret_name +LLM_SECRET_ENV_LIST="$(IFS=,; printf '%s' "${LLM_SECRET_ENV_NAMES[*]}")" +export DREADGOAD_CONSOLE_LLM_SECRET_ENV="$API_KEY_ENV" +export DREADGOAD_CONSOLE_LLM_SECRET_ENVS="$LLM_SECRET_ENV_LIST" + +COLS="$(tput cols 2>/dev/null || echo 80)" banner printf ' %sActive Directory range control plane%s %s·%s %s%s%s\n\n' \ "$DIM" "$RESET" "$FAINT" "$RESET" "$CORAL" \ @@ -269,36 +307,15 @@ else CLI_PATH="" fi -# --- credentials and model reach the backend through the environment -------- +# --- model reaches the backend through the environment ---------------------- # Uvicorn is exec'd (or backgrounded) from this shell. The model can remain -# exported, while the credential is held privately until start_backend restores -# it. Previously MODEL was computed for the banner and never exported, so the -# summary reported a model the backend had no idea about unless the caller had -# already exported it themselves. +# exported. Previously MODEL was computed for the banner and never exported, +# so the summary reported a model the backend had no idea about unless the +# caller had already exported it themselves. if [ -n "$MODEL" ]; then export DREADGOAD_CONSOLE_MODEL="$MODEL" fi -# The selected name is provider-native: ANTHROPIC_API_KEY stays Anthropic, -# OPENAI_API_KEY stays OpenAI, and the default remains OpenRouter. Keep its -# value in an unexported shell variable while dependencies and frontend assets -# are built, then restore it only in the Uvicorn process. The backend receives -# the name as scrub-policy metadata; the secret value is never renamed. -if [ "$API_KEY_ENV" != "OPENROUTER_API_KEY" ]; then - if [ -z "${!API_KEY_ENV:-}" ]; then - printf '\n' - fail "--api-key-env named $API_KEY_ENV, but that variable is empty or unset." - fail "export it first, or drop the flag to use OPENROUTER_API_KEY." - exit 1 - fi -fi -LLM_API_KEY_VALUE="${!API_KEY_ENV:-}" -# Assignment preserves an inherited export attribute in Bash. Force this -# holding variable to remain shell-private before any build helper starts. -export -n LLM_API_KEY_VALUE -unset "$API_KEY_ENV" -export DREADGOAD_CONSOLE_LLM_SECRET_ENV="$API_KEY_ENV" - # One credential per launch protects the loopback API from other local users # and unrelated processes. It is carried to the browser in a URL fragment (not # sent in HTTP access logs); the frontend removes it from the address bar after @@ -397,15 +414,22 @@ ready() { printf ' %s╰%s╯%s\n\n' "$BRAND" "$bar" "$RESET" } -# Give the selected provider credential only to the backend. This function runs -# in a background subshell in development and replaces the launcher in normal -# mode, so the export cannot leak back into npm, Vite, Go, or package installers. +# Give launcher-known provider credentials only to the backend. This function +# runs in a background subshell in development and replaces the launcher in +# normal mode, so the export cannot leak back into npm, Vite, Go, or package +# installers. start_backend() { - if [ -n "$LLM_API_KEY_VALUE" ]; then - export "$API_KEY_ENV=$LLM_API_KEY_VALUE" - else - unset "$API_KEY_ENV" - fi + export -n secret_name secret_value + local index secret_name secret_value + for ((index = 0; index < ${#LLM_SECRET_ENV_NAMES[@]}; index++)); do + secret_name="${LLM_SECRET_ENV_NAMES[$index]}" + secret_value="${LLM_SECRET_ENV_VALUES[$index]}" + if [ -n "$secret_value" ]; then + export "$secret_name=$secret_value" + else + unset "$secret_name" + fi + done export DREADGOAD_CONSOLE_AUTH_TOKEN="$AUTH_TOKEN" exec "$VENV/bin/uvicorn" "$@" } From 92e57e6e4f9ebefd5b20dba62c0eaa797f818f06 Mon Sep 17 00:00:00 2001 From: mkultraWasHere Date: Tue, 29 Sep 2026 15:56:36 -0400 Subject: [PATCH 2/3] style(console): format credential list assignment --- dreadgoad-console | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dreadgoad-console b/dreadgoad-console index baf2c9bb..3309164a 100755 --- a/dreadgoad-console +++ b/dreadgoad-console @@ -251,7 +251,10 @@ for secret_name in "${LLM_SECRET_ENV_NAMES[@]}"; do unset "$secret_name" done unset secret_name -LLM_SECRET_ENV_LIST="$(IFS=,; printf '%s' "${LLM_SECRET_ENV_NAMES[*]}")" +LLM_SECRET_ENV_LIST="$( + IFS=, + printf '%s' "${LLM_SECRET_ENV_NAMES[*]}" +)" export DREADGOAD_CONSOLE_LLM_SECRET_ENV="$API_KEY_ENV" export DREADGOAD_CONSOLE_LLM_SECRET_ENVS="$LLM_SECRET_ENV_LIST" From 92ec12c60c5be89abc5af4ecb3f90ce41f88a2da Mon Sep 17 00:00:00 2001 From: mkultraWasHere Date: Tue, 29 Sep 2026 17:31:59 -0400 Subject: [PATCH 3/3] fix(console): register supported LLM credentials Scrub known ambient provider keys before external tools run while restoring only the selected provider credential to the LLM backend. --- console/README.md | 7 +- console/SECURITY.md | 10 +- console/backend/subprocess_env.py | 3 + console/backend/tests/test_subprocess_env.py | 172 +++++++++++-------- dreadgoad-console | 55 +++--- 5 files changed, 139 insertions(+), 108 deletions(-) diff --git a/console/README.md b/console/README.md index 8cc01d40..b1273560 100644 --- a/console/README.md +++ b/console/README.md @@ -69,9 +69,10 @@ For another model provider, export its native credential variable and name it with `--api-key-env`; for example, use `--model anthropic/... --api-key-env ANTHROPIC_API_KEY`. The launcher does not rename one provider's credential into another provider's variable. The selected -name is shown in Settings. The default OpenRouter credential and selected -provider credential are removed from dependency, build, CLI, cloud CLI, -Terraform, Ansible, and helper subprocesses. +name is shown in Settings. Registered OpenRouter, Anthropic, and OpenAI +credentials, plus any custom selected provider credential, are removed from +dependency, build, CLI, cloud CLI, Terraform, Ansible, and helper subprocesses. +Only the selected provider credential is restored to the LLM backend. The state root and session/config directories are restricted to the console user (`0700`), and the SQLite database and sidecars use `0600`. Startup repairs modes diff --git a/console/SECURITY.md b/console/SECURITY.md index c6a4d431..8f74f3cc 100644 --- a/console/SECURITY.md +++ b/console/SECURITY.md @@ -82,10 +82,12 @@ The provider-native LLM API key selected by the launcher or entered in Settings is kept in the backend process environment and is not returned by the API or written to the console database. `OPENROUTER_API_KEY` remains the default for the default OpenRouter model; other providers retain their own variable names. -The launcher registers both that default and the selected provider variable, -then removes their values before dependency, build, and frontend helpers run. -Every registered LLM credential is also removed from the environment passed to -CLI, cloud CLI, Terraform, Ansible, and other backend subprocesses. +The launcher explicitly registers the supported OpenRouter, Anthropic, and +OpenAI credential variables plus any custom selected provider variable, then +removes their values before dependency, build, and frontend helpers run. Only +the selected credential is restored to the LLM backend. Every registered LLM +credential is also removed from the environment passed to CLI, cloud CLI, +Terraform, Ansible, and other backend subprocesses. Cloud credentials are not collected or managed by the console. Provider tooling uses the ambient credentials available to the launcher through environment diff --git a/console/backend/subprocess_env.py b/console/backend/subprocess_env.py index 3f9df31d..21a68691 100644 --- a/console/backend/subprocess_env.py +++ b/console/backend/subprocess_env.py @@ -21,9 +21,12 @@ _INFRASTRUCTURE_CREDENTIAL_ENV_NAMES = frozenset( { "AWS_ACCESS_KEY_ID", + "AWS_CONTAINER_AUTHORIZATION_TOKEN", "AWS_SECRET_ACCESS_KEY", "AWS_SECURITY_TOKEN", "AWS_SESSION_TOKEN", + "ARM_OIDC_TOKEN", + "LUDUS_API_KEY", } ) diff --git a/console/backend/tests/test_subprocess_env.py b/console/backend/tests/test_subprocess_env.py index 68189d3b..b44f084f 100644 --- a/console/backend/tests/test_subprocess_env.py +++ b/console/backend/tests/test_subprocess_env.py @@ -26,6 +26,7 @@ _CUSTOM_LLM_KEY = "DREADGOAD_TEST_LLM_API_KEY" _CLOUD_KEY = "AWS_SESSION_TOKEN" +_DEPLOYMENT_API_KEY = "LUDUS_API_KEY" def _set_test_environment() -> dict[str, str | None]: @@ -33,6 +34,7 @@ def _set_test_environment() -> dict[str, str | None]: "OPENROUTER_API_KEY": "openrouter-secret", _CUSTOM_LLM_KEY: "custom-secret", _CLOUD_KEY: "cloud-session-token", + _DEPLOYMENT_API_KEY: "deployment-api-key", } previous = {name: os.environ.get(name) for name in values} os.environ.update(values) @@ -49,7 +51,12 @@ def _restore_environment(previous: dict[str, str | None]) -> None: def _probe_argv() -> list[str]: - names = ["OPENROUTER_API_KEY", _CUSTOM_LLM_KEY, _CLOUD_KEY] + names = [ + "OPENROUTER_API_KEY", + _CUSTOM_LLM_KEY, + _CLOUD_KEY, + _DEPLOYMENT_API_KEY, + ] code = ( "import json, os; " f"print(json.dumps({{name: os.environ.get(name) for name in {names!r}}}))" @@ -64,28 +71,33 @@ def test_child_env_scrubs_only_registered_llm_credentials() -> None: assert "OPENROUTER_API_KEY" not in env assert _CUSTOM_LLM_KEY not in env assert env[_CLOUD_KEY] == "cloud-session-token" + assert env[_DEPLOYMENT_API_KEY] == "deployment-api-key" finally: _restore_environment(previous) def test_backend_scrubs_all_launcher_registered_provider_keys() -> None: - """Startup metadata registers both the default and selected provider keys.""" + """Startup metadata registers known and custom selected provider keys.""" provider_key = "DREADGOAD_TEST_PROVIDER_API_KEY" + registered_keys = ( + "OPENROUTER_API_KEY", + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + provider_key, + ) env = os.environ.copy() env[LLM_SECRET_ENV_SETTING] = provider_key - env[LLM_SECRET_ENVS_SETTING] = f"OPENROUTER_API_KEY,{provider_key}" - env[provider_key] = "provider-secret" - env["OPENROUTER_API_KEY"] = "openrouter-secret" + env[LLM_SECRET_ENVS_SETTING] = ",".join(registered_keys) + env.update({name: f"{name.lower()}-secret" for name in registered_keys}) code = ( "import json, os; " "from console.backend.subprocess_env import " "active_llm_secret_env, child_env; " "child = child_env(); " - f"print(json.dumps({{'active': active_llm_secret_env(), " - f"'provider_value': os.environ.get({provider_key!r}), " - "'openrouter_value': os.environ.get('OPENROUTER_API_KEY'), " - f"'provider_in_child': {provider_key!r} in child, " - "'openrouter_in_child': 'OPENROUTER_API_KEY' in child, " + f"names = {registered_keys!r}; " + "print(json.dumps({'active': active_llm_secret_env(), " + "'backend_values': {name: os.environ.get(name) for name in names}, " + "'child_values': {name: child.get(name) for name in names}, " f"'metadata_in_child': {LLM_SECRET_ENV_SETTING!r} in child, " f"'metadata_list_in_child': {LLM_SECRET_ENVS_SETTING!r} in child}}))" ) @@ -98,15 +110,11 @@ def test_backend_scrubs_all_launcher_registered_provider_keys() -> None: text=True, ) values = json.loads(result.stdout) - assert values == { - "active": provider_key, - "provider_value": "provider-secret", - "openrouter_value": "openrouter-secret", - "provider_in_child": False, - "openrouter_in_child": False, - "metadata_in_child": False, - "metadata_list_in_child": False, - } + assert values["active"] == provider_key + assert all(values["backend_values"].values()) + assert values["child_values"] == dict.fromkeys(registered_keys) + assert values["metadata_in_child"] is False + assert values["metadata_list_in_child"] is False def test_launcher_preserves_provider_native_key_name() -> None: @@ -116,8 +124,10 @@ def test_launcher_preserves_provider_native_key_name() -> None: assert 'export DREADGOAD_CONSOLE_LLM_SECRET_ENV="$API_KEY_ENV"' in source assert 'export DREADGOAD_CONSOLE_LLM_SECRET_ENVS="$LLM_SECRET_ENV_LIST"' in source assert 'export OPENROUTER_API_KEY="$API_KEY_VALUE"' not in source - assert 'LLM_SECRET_ENV_NAMES=("OPENROUTER_API_KEY")' in source - assert 'export "$secret_name=$secret_value"' in source + assert '"OPENROUTER_API_KEY"' in source + assert '"ANTHROPIC_API_KEY"' in source + assert '"OPENAI_API_KEY"' in source + assert 'export "$API_KEY_ENV=$LLM_API_KEY_VALUE"' in source assert source.index('unset "$secret_name"') < source.index( "# --- Python venv + backend deps" ) @@ -146,8 +156,8 @@ def test_launcher_help_runs_without_external_tools() -> None: assert "USAGE" in result.stdout -def test_launcher_restores_registered_keys_only_for_backend() -> None: - """The real start_backend function handles every launch credential matrix.""" +def test_launcher_restores_only_selected_key_for_backend() -> None: + """The real start_backend function restores only the active provider key.""" repo_root = pathlib.Path(__file__).resolve().parents[3] source = (repo_root / "dreadgoad-console").read_text(encoding="utf-8") start = source.index("start_backend() {") @@ -161,52 +171,47 @@ def test_launcher_restores_registered_keys_only_for_backend() -> None: fake_uvicorn.write_text( "#!/usr/bin/env python3\n" "import json, os\n" - 'names = ["OPENROUTER_API_KEY", ' + 'names = ["OPENROUTER_API_KEY", "ANTHROPIC_API_KEY", ' + '"OPENAI_API_KEY", ' '"DREADGOAD_TEST_PROVIDER_API_KEY", ' '"DREADGOAD_CONSOLE_AUTH_TOKEN", ' - '"LLM_SECRET_ENV_VALUES", "secret_name", "secret_value"]\n' + '"LLM_API_KEY_VALUE"]\n' "print(json.dumps({name: os.environ.get(name) for name in names}))\n", encoding="utf-8", ) fake_uvicorn.chmod(0o755) cases = ( ( - "default only", - '("OPENROUTER_API_KEY")', - '("openrouter-secret")', + "default selected", + "OPENROUTER_API_KEY", "openrouter-secret", - None, - ), - ( - "selected only", - '("OPENROUTER_API_KEY" "DREADGOAD_TEST_PROVIDER_API_KEY")', - '("" "provider-secret")', - None, - "provider-secret", ), ( - "default and selected", - '("OPENROUTER_API_KEY" "DREADGOAD_TEST_PROVIDER_API_KEY")', - '("openrouter-secret" "provider-secret")', - "openrouter-secret", + "custom selected", + "DREADGOAD_TEST_PROVIDER_API_KEY", "provider-secret", ), ) - for label, names, secrets, openrouter_value, provider_value in cases: + for label, selected_name, selected_value in cases: script = ( f"{function_source}\n" - f"LLM_SECRET_ENV_NAMES={names}\n" - f"LLM_SECRET_ENV_VALUES={secrets}\n" + f"API_KEY_ENV={shlex.quote(selected_name)}\n" + f"LLM_API_KEY_VALUE={shlex.quote(selected_value)}\n" f"VENV={shlex.quote(temp_dir)}\n" 'AUTH_TOKEN="browser-token"\n' "start_backend console.backend.server:app\n" ) env = os.environ.copy() + for name in ( + "OPENROUTER_API_KEY", + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "DREADGOAD_TEST_PROVIDER_API_KEY", + ): + env.pop(name, None) env.update( { - "LLM_SECRET_ENV_VALUES": "attacker-array-seed", - "secret_name": "attacker-name-seed", - "secret_value": "attacker-value-seed", + "LLM_API_KEY_VALUE": "attacker-seed", } ) result = subprocess.run( # noqa: S603 @@ -218,21 +223,29 @@ def test_launcher_restores_registered_keys_only_for_backend() -> None: text=True, ) values = json.loads(result.stdout) - assert values == { - "OPENROUTER_API_KEY": openrouter_value, - "DREADGOAD_TEST_PROVIDER_API_KEY": provider_value, + expected = { + "OPENROUTER_API_KEY": None, + "ANTHROPIC_API_KEY": None, + "OPENAI_API_KEY": None, + "DREADGOAD_TEST_PROVIDER_API_KEY": None, "DREADGOAD_CONSOLE_AUTH_TOKEN": "browser-token", - "LLM_SECRET_ENV_VALUES": None, - "secret_name": None, - "secret_value": None, - }, label + "LLM_API_KEY_VALUE": None, + } + expected[selected_name] = selected_value + assert values == expected, label def test_launcher_rejects_infrastructure_credential_names() -> None: - """AWS session-token aliases cannot be selected as LLM credentials.""" + """Deployment credentials cannot be selected as LLM credentials.""" repo_root = pathlib.Path(__file__).resolve().parents[3] launcher = repo_root / "dreadgoad-console" - for name in ("AWS_SECURITY_TOKEN", "AWS_SESSION_TOKEN"): + for name in ( + "ARM_OIDC_TOKEN", + "AWS_CONTAINER_AUTHORIZATION_TOKEN", + "AWS_SECURITY_TOKEN", + "AWS_SESSION_TOKEN", + "LUDUS_API_KEY", + ): result = subprocess.run( # noqa: S603 ["bash", str(launcher), "--api-key-env", name], cwd=repo_root, @@ -247,18 +260,19 @@ def test_launcher_rejects_infrastructure_credential_names() -> None: def test_backend_rejects_infrastructure_credential_metadata() -> None: """Launcher metadata cannot classify cloud credentials as LLM secrets.""" repo_root = pathlib.Path(__file__).resolve().parents[3] - env = os.environ.copy() - env[LLM_SECRET_ENVS_SETTING] = "OPENROUTER_API_KEY,AWS_SESSION_TOKEN" - result = subprocess.run( # noqa: S603 - [sys.executable, "-c", "import console.backend.subprocess_env"], - cwd=repo_root, - env=env, - check=False, - capture_output=True, - text=True, - ) - assert result.returncode != 0 - assert "cannot name infrastructure credential AWS_SESSION_TOKEN" in result.stderr + for name in ("ARM_OIDC_TOKEN", "AWS_SESSION_TOKEN", "LUDUS_API_KEY"): + env = os.environ.copy() + env[LLM_SECRET_ENVS_SETTING] = f"OPENROUTER_API_KEY,{name}" + result = subprocess.run( # noqa: S603 + [sys.executable, "-c", "import console.backend.subprocess_env"], + cwd=repo_root, + env=env, + check=False, + capture_output=True, + text=True, + ) + assert result.returncode != 0 + assert f"cannot name infrastructure credential {name}" in result.stderr def test_launcher_scrubs_provider_key_before_build_helpers() -> None: @@ -268,19 +282,25 @@ def test_launcher_scrubs_provider_key_before_build_helpers() -> None: source = launcher.read_text(encoding="utf-8") prefix = source.split("# --- Python venv + backend deps", maxsplit=1)[0] probe = ( - "\nprintf 'VERIFY:%s:%s:%s:%s:%s\\n' " + "\nprintf 'VERIFY:%s:%s:%s:%s:%s:%s:%s\\n' " + '"${DREADGOAD_TEST_PROVIDER_API_KEY-unset}" ' '"${ANTHROPIC_API_KEY-unset}" "${OPENROUTER_API_KEY-unset}" ' + '"${OPENAI_API_KEY-unset}" ' '"$LLM_API_KEY_VALUE" "$DREADGOAD_CONSOLE_LLM_SECRET_ENV" ' '"$DREADGOAD_CONSOLE_LLM_SECRET_ENVS"\n' - 'python3 -c \'import os; print("CHILD:%s:%s:%s" % ' - '(os.environ.get("ANTHROPIC_API_KEY", "unset"), ' + 'python3 -c \'import os; print("CHILD:%s:%s:%s:%s:%s" % ' + '(os.environ.get("DREADGOAD_TEST_PROVIDER_API_KEY", "unset"), ' + 'os.environ.get("ANTHROPIC_API_KEY", "unset"), ' 'os.environ.get("OPENROUTER_API_KEY", "unset"), ' + 'os.environ.get("OPENAI_API_KEY", "unset"), ' 'os.environ.get("LLM_API_KEY_VALUE", "unset")))\'\n' ) env = os.environ.copy() env.update( { "ANTHROPIC_API_KEY": "anthropic-secret", + "DREADGOAD_TEST_PROVIDER_API_KEY": "provider-secret", + "OPENAI_API_KEY": "openai-secret", "OPENROUTER_API_KEY": "openrouter-secret", "DREADGOAD_CONSOLE_PORT": "0", "LLM_API_KEY_VALUE": "attacker-seed", @@ -294,7 +314,7 @@ def test_launcher_scrubs_provider_key_before_build_helpers() -> None: prefix + probe, "./dreadgoad-console", "--api-key-env", - "ANTHROPIC_API_KEY", + "DREADGOAD_TEST_PROVIDER_API_KEY", ], cwd=repo_root, env=env, @@ -303,9 +323,11 @@ def test_launcher_scrubs_provider_key_before_build_helpers() -> None: text=True, ) assert result.stdout.splitlines()[-2:] == [ - "VERIFY:unset:unset:anthropic-secret:ANTHROPIC_API_KEY:" - "OPENROUTER_API_KEY,ANTHROPIC_API_KEY", - "CHILD:unset:unset:unset", + "VERIFY:unset:unset:unset:unset:provider-secret:" + "DREADGOAD_TEST_PROVIDER_API_KEY:" + "OPENROUTER_API_KEY,ANTHROPIC_API_KEY,OPENAI_API_KEY," + "DREADGOAD_TEST_PROVIDER_API_KEY", + "CHILD:unset:unset:unset:unset:unset", ] @@ -340,7 +362,7 @@ def main() -> None: test_backend_scrubs_all_launcher_registered_provider_keys() test_launcher_preserves_provider_native_key_name() test_launcher_help_runs_without_external_tools() - test_launcher_restores_registered_keys_only_for_backend() + test_launcher_restores_only_selected_key_for_backend() test_launcher_rejects_infrastructure_credential_names() test_backend_rejects_infrastructure_credential_metadata() test_launcher_scrubs_provider_key_before_build_helpers() diff --git a/dreadgoad-console b/dreadgoad-console index 3309164a..b1f474a9 100755 --- a/dreadgoad-console +++ b/dreadgoad-console @@ -196,7 +196,7 @@ if ! [[ "$API_KEY_ENV" =~ ^[A-Z][A-Z0-9_]*_(API_KEY|KEY|TOKEN)$ ]]; then exit 2 fi case "$API_KEY_ENV" in -AWS_ACCESS_KEY_ID | AWS_SECRET_ACCESS_KEY | AWS_SESSION_TOKEN | AWS_SECURITY_TOKEN) +ARM_OIDC_TOKEN | AWS_ACCESS_KEY_ID | AWS_CONTAINER_AUTHORIZATION_TOKEN | AWS_SECRET_ACCESS_KEY | AWS_SESSION_TOKEN | AWS_SECURITY_TOKEN | LUDUS_API_KEY) printf 'dreadgoad-console: --api-key-env cannot use infrastructure credential %s\n' \ "$API_KEY_ENV" >&2 exit 2 @@ -225,9 +225,14 @@ step() { printf '\n %s▸%s %s%s%s\n' "$BRAND" "$RESET" "$BOLD" "$1" "$RESET"; warn() { printf ' %s!%s %s%s%s\n' "$AMBER" "$RESET" "$AMBER" "$1" "$RESET"; } fail() { printf ' %s✕%s %s%s%s\n' "$RED" "$RESET" "$RED" "$1" "$RESET"; } -# Capture every credential name known to this launch before dependency, build, -# or frontend helpers run. The documented OpenRouter default remains sensitive -# even when another provider is selected explicitly. +# Register supported LLM provider credentials explicitly. Any custom selected +# provider is appended below; infrastructure credentials are never inferred +# from broad *_KEY or *_TOKEN patterns. +LLM_SECRET_ENV_NAMES=( + "OPENROUTER_API_KEY" + "ANTHROPIC_API_KEY" + "OPENAI_API_KEY" +) if [ "$API_KEY_ENV" != "OPENROUTER_API_KEY" ] && [ -z "${!API_KEY_ENV:-}" ]; then printf '\n' fail "--api-key-env named $API_KEY_ENV, but that variable is empty or unset." @@ -238,19 +243,23 @@ LLM_API_KEY_VALUE="${!API_KEY_ENV:-}" # Assignment preserves an inherited export attribute in Bash. Force this # holding variable to remain shell-private before any build helper starts. export -n LLM_API_KEY_VALUE -LLM_SECRET_ENV_NAMES=("OPENROUTER_API_KEY") -LLM_SECRET_ENV_VALUES=("${OPENROUTER_API_KEY:-}") -if [ "$API_KEY_ENV" != "OPENROUTER_API_KEY" ]; then +LLM_SECRET_ENV_SELECTED=0 +for secret_name in "${LLM_SECRET_ENV_NAMES[@]}"; do + if [ "$secret_name" = "$API_KEY_ENV" ]; then + LLM_SECRET_ENV_SELECTED=1 + break + fi +done +if [ "$LLM_SECRET_ENV_SELECTED" = 0 ]; then LLM_SECRET_ENV_NAMES+=("$API_KEY_ENV") - LLM_SECRET_ENV_VALUES+=("$LLM_API_KEY_VALUE") fi # Arrays are not placed in a child environment by Bash, but clear any inherited -# export attributes as defense in depth before they hold credential values. -export -n LLM_SECRET_ENV_NAMES LLM_SECRET_ENV_VALUES +# export attribute before the registry is used by helper commands. +export -n LLM_SECRET_ENV_NAMES for secret_name in "${LLM_SECRET_ENV_NAMES[@]}"; do unset "$secret_name" done -unset secret_name +unset LLM_SECRET_ENV_SELECTED secret_name LLM_SECRET_ENV_LIST="$( IFS=, printf '%s' "${LLM_SECRET_ENV_NAMES[*]}" @@ -417,22 +426,16 @@ ready() { printf ' %s╰%s╯%s\n\n' "$BRAND" "$bar" "$RESET" } -# Give launcher-known provider credentials only to the backend. This function -# runs in a background subshell in development and replaces the launcher in -# normal mode, so the export cannot leak back into npm, Vite, Go, or package -# installers. +# Give only the selected provider credential to the backend. This function runs +# in a background subshell in development and replaces the launcher in normal +# mode, so the export cannot leak back into npm, Vite, Go, or package installers. start_backend() { - export -n secret_name secret_value - local index secret_name secret_value - for ((index = 0; index < ${#LLM_SECRET_ENV_NAMES[@]}; index++)); do - secret_name="${LLM_SECRET_ENV_NAMES[$index]}" - secret_value="${LLM_SECRET_ENV_VALUES[$index]}" - if [ -n "$secret_value" ]; then - export "$secret_name=$secret_value" - else - unset "$secret_name" - fi - done + export -n LLM_API_KEY_VALUE + if [ -n "$LLM_API_KEY_VALUE" ]; then + export "$API_KEY_ENV=$LLM_API_KEY_VALUE" + else + unset "$API_KEY_ENV" + fi export DREADGOAD_CONSOLE_AUTH_TOKEN="$AUTH_TOKEN" exec "$VENV/bin/uvicorn" "$@" }