From 2cfd2656794b9fc6d0f41ba800dd56c5cb4a1ae7 Mon Sep 17 00:00:00 2001 From: echobt <154886644+echobt@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:19:07 +0000 Subject: [PATCH] fix(master): load per-challenge embed.env into isolated child env Embedded challenges start under `env -i` so Prism never inherits CHALLENGE_* and agent-challenge never inherits PRISM_*. That isolation is deliberate, but the passed-through list was hardcoded with no extension point, so every operator setting was silently dropped -- including CHALLENGE_PHALA_ATTESTATION_ENABLED and CHALLENGE_ATTESTED_REVIEW_ENABLED. Agent Challenge therefore booted with attestation off even though the operator env file enabled it, producing unattested zero scores, no raw weight snapshot, and a withheld epoch that fell back to burn. Add the supported extension point: a per-challenge env file (default /embed.env, overridable via BASE_MASTER_AC_ENV_FILE and BASE_MASTER_PRISM_ENV_FILE) whose allowlisted keys are appended after the built-in defaults, so operator values win. Prefixes stay disjoint per challenge, preserving cross-challenge isolation. Values are never logged. --- docker/master-entrypoint.sh | 86 ++++++++++++ tests/unit/test_master_embed_env_file.py | 172 +++++++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 tests/unit/test_master_embed_env_file.py diff --git a/docker/master-entrypoint.sh b/docker/master-entrypoint.sh index 8160498c..b1589976 100755 --- a/docker/master-entrypoint.sh +++ b/docker/master-entrypoint.sh @@ -32,6 +32,11 @@ AC_PORT="${BASE_MASTER_AC_PORT:-18081}" PRISM_DATA_DIR="${BASE_MASTER_PRISM_DATA_DIR:-/var/lib/base/challenges/prism}" AC_DATA_DIR="${BASE_MASTER_AC_DATA_DIR:-/var/lib/base/challenges/agent-challenge}" +# Operator-supplied overrides merged into the isolated child environments. +# Defaults live beside each challenge's data so they survive image rebuilds. +PRISM_ENV_FILE="${BASE_MASTER_PRISM_ENV_FILE:-${PRISM_DATA_DIR}/embed.env}" +AC_ENV_FILE="${BASE_MASTER_AC_ENV_FILE:-${AC_DATA_DIR}/embed.env}" + # Child PIDs for cleanup (proxy is usually the last foreground wait target). CHILD_PIDS=() @@ -56,6 +61,80 @@ embed_truthy() { esac } +# Merge operator-supplied KEY=VALUE lines into an isolated child environment. +# +# Embedded challenges start under `env -i` so Prism never sees CHALLENGE_* and +# agent-challenge never sees PRISM_*. That isolation is deliberate, but it also +# dropped every operator setting -- including the Phala attestation switches +# (CHALLENGE_PHALA_ATTESTATION_ENABLED / CHALLENGE_ATTESTED_REVIEW_ENABLED) and +# the eval/review app identities -- because the built-in list was hardcoded with +# no extension point. This is that extension point: allowlisted keys from the +# file are appended AFTER the defaults, so the file wins. +# +# Values are never logged (secrets hygiene); only key names are. +load_challenge_env_file() { + local -n _target_env="$1" + local env_file="$2" + shift 2 + local -a allowed_prefixes=("$@") + + if [[ -z "${env_file}" ]]; then + return 0 + fi + if [[ ! -e "${env_file}" ]]; then + log "no env file at ${env_file}; using built-in defaults only" + return 0 + fi + if [[ ! -r "${env_file}" ]]; then + log "ERROR: env file ${env_file} exists but is not readable" + return 1 + fi + + local line key value prefix allowed + local -a accepted=() + while IFS= read -r line || [[ -n "${line}" ]]; do + # Trim surrounding whitespace. + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + if [[ -z "${line}" || "${line}" == '#'* ]]; then + continue + fi + line="${line#export }" + if [[ "${line}" != *=* ]]; then + continue + fi + key="${line%%=*}" + value="${line#*=}" + if [[ ! "${key}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then + continue + fi + # Strip one layer of matching quotes around the value. + if [[ "${value}" == \"*\" && "${#value}" -ge 2 ]]; then + value="${value:1:${#value}-2}" + elif [[ "${value}" == \'*\' && "${#value}" -ge 2 ]]; then + value="${value:1:${#value}-2}" + fi + allowed=0 + for prefix in "${allowed_prefixes[@]}"; do + if [[ "${key}" == "${prefix}"* ]]; then + allowed=1 + break + fi + done + if (( ! allowed )); then + continue + fi + _target_env+=("${key}=${value}") + accepted+=("${key}") + done < "${env_file}" + + if (( ${#accepted[@]} )); then + log "loaded ${#accepted[@]} override(s) from ${env_file}: ${accepted[*]}" + else + log "no applicable overrides in ${env_file}" + fi +} + prepare_challenge_dirs() { mkdir -p \ "${PRISM_DATA_DIR}/tmp" \ @@ -155,6 +234,13 @@ start_embedded_challenges() { ac_env+=("PYTHONPATH=${py_path}") fi + # Operator overrides win over the defaults above. Prefixes stay disjoint per + # challenge so the env -i isolation is preserved. + load_challenge_env_file prism_env "${PRISM_ENV_FILE}" \ + PRISM_ PHALA_ DSTACK_ OPENROUTER_API_KEY + load_challenge_env_file ac_env "${AC_ENV_FILE}" \ + CHALLENGE_ BASE_CHALLENGE_ PHALA_ DSTACK_ OPENROUTER_API_KEY + log "starting embedded prism on ${PRISM_HOST}:${PRISM_PORT}" # env -i: isolate prefixes so Prism never sees CHALLENGE_* and AC never sees # unrelated PRISM_* secrets as accidental Settings keys. diff --git a/tests/unit/test_master_embed_env_file.py b/tests/unit/test_master_embed_env_file.py new file mode 100644 index 00000000..6028e4c3 --- /dev/null +++ b/tests/unit/test_master_embed_env_file.py @@ -0,0 +1,172 @@ +"""Embedded challenge env-file contract for the master supervisor. + +``docker/master-entrypoint.sh`` launches each embedded challenge under +``env -i`` so Prism never inherits ``CHALLENGE_*`` and agent-challenge never +inherits ``PRISM_*``. That isolation is deliberate, but it also dropped every +operator-supplied setting -- notably the Phala attestation switches +(``CHALLENGE_PHALA_ATTESTATION_ENABLED``, ``CHALLENGE_ATTESTED_REVIEW_ENABLED``) +and ``PHALA_CLOUD_API_KEY`` -- because the allowlist was hardcoded with no +extension point. + +These tests lock the supported extension point: a per-challenge env file whose +keys are merged into the isolated child environment, without breaking +cross-challenge isolation. +""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +ENTRYPOINT = REPO_ROOT / "docker/master-entrypoint.sh" + +FAKE_UVICORN = """#!/usr/bin/env bash +# Dump the isolated child environment so the test can assert on it. +# The dump path is baked in: `env -i` strips DUMP_DIR from the child env. +target="unknown" +for arg in "$@"; do + case "${arg}" in + prism_challenge.app:app) target="prism" ;; + agent_challenge.app:app) target="ac" ;; + esac +done +env > "__DUMP_DIR__/${target}.env" +""" + +FAKE_PYTHON = """#!/usr/bin/env bash +exit 0 +""" + + +def _write_exec(path: Path, body: str) -> None: + path.write_text(body, encoding="utf-8") + path.chmod(0o755) + + +def _run_entrypoint(tmp_path: Path, ac_env_file_body: str | None) -> dict[str, str]: + """Run the entrypoint with stubbed uvicorn/python; return child env dumps.""" + + bin_dir = tmp_path / "bin" + dump_dir = tmp_path / "dump" + ac_dir = tmp_path / "ac" + prism_dir = tmp_path / "prism" + for directory in (bin_dir, dump_dir, ac_dir, prism_dir): + directory.mkdir(parents=True, exist_ok=True) + + _write_exec( + bin_dir / "uvicorn", FAKE_UVICORN.replace("__DUMP_DIR__", str(dump_dir)) + ) + _write_exec(bin_dir / "python", FAKE_PYTHON) + + token_file = tmp_path / "shared_token" + token_file.write_text("test-token", encoding="utf-8") + + if ac_env_file_body is not None: + (ac_dir / "embed.env").write_text(ac_env_file_body, encoding="utf-8") + + env = { + "PATH": f"{bin_dir}:{os.environ.get('PATH', '/usr/bin:/bin')}", + "HOME": str(tmp_path), + "DUMP_DIR": str(dump_dir), + "BASE_MASTER_AC_DATA_DIR": str(ac_dir), + "BASE_MASTER_PRISM_DATA_DIR": str(prism_dir), + "PRISM_SHARED_TOKEN_FILE": str(token_file), + "CHALLENGE_SHARED_TOKEN_FILE": str(token_file), + } + + subprocess.run( + ["bash", str(ENTRYPOINT), "/bin/true"], + env=env, + check=True, + capture_output=True, + timeout=60, + ) + + dumps: dict[str, str] = {} + for name in ("ac", "prism"): + dump = dump_dir / f"{name}.env" + dumps[name] = dump.read_text(encoding="utf-8") if dump.is_file() else "" + return dumps + + +def test_ac_env_file_supplies_phala_settings_to_isolated_child(tmp_path: Path) -> None: + """Operator embed.env must reach the agent-challenge child under env -i.""" + + dumps = _run_entrypoint( + tmp_path, + "\n".join( + [ + "# durable AC embed overrides", + "CHALLENGE_PHALA_ATTESTATION_ENABLED=true", + "CHALLENGE_ATTESTED_REVIEW_ENABLED=true", + "PHALA_CLOUD_API_KEY=phala-secret", + "BASE_CHALLENGE_SLUG=agent-challenge", + "", + ] + ), + ) + + ac_env = dumps["ac"] + assert "CHALLENGE_PHALA_ATTESTATION_ENABLED=true" in ac_env + assert "CHALLENGE_ATTESTED_REVIEW_ENABLED=true" in ac_env + assert "PHALA_CLOUD_API_KEY=phala-secret" in ac_env + assert "BASE_CHALLENGE_SLUG=agent-challenge" in ac_env + + +def test_ac_env_file_does_not_leak_into_prism_child(tmp_path: Path) -> None: + """Cross-challenge isolation must survive the env-file merge.""" + + dumps = _run_entrypoint( + tmp_path, + "CHALLENGE_PHALA_ATTESTATION_ENABLED=true\nPHALA_CLOUD_API_KEY=phala-secret\n", + ) + + prism_env = dumps["prism"] + assert "CHALLENGE_PHALA_ATTESTATION_ENABLED" not in prism_env + assert "PHALA_CLOUD_API_KEY" not in prism_env + + +def test_ac_env_file_overrides_builtin_default(tmp_path: Path) -> None: + """File-provided values win over the hardcoded defaults.""" + + dumps = _run_entrypoint(tmp_path, "CHALLENGE_DOCKER_ENABLED=true\n") + + assert "CHALLENGE_DOCKER_ENABLED=true" in dumps["ac"] + assert "CHALLENGE_DOCKER_ENABLED=false" not in dumps["ac"] + + +def test_missing_env_file_is_not_fatal(tmp_path: Path) -> None: + """Absent embed.env keeps the previous behaviour (defaults only).""" + + dumps = _run_entrypoint(tmp_path, None) + + assert "CHALLENGE_DOCKER_ENABLED=false" in dumps["ac"] + assert "PHALA_CLOUD_API_KEY" not in dumps["ac"] + + +def test_env_file_ignores_comments_blanks_and_malformed_keys(tmp_path: Path) -> None: + """Only well-formed KEY=VALUE lines with allowed prefixes are exported.""" + + dumps = _run_entrypoint( + tmp_path, + "\n".join( + [ + "# comment", + "", + " ", + "export CHALLENGE_EVAL_MAX_ATTEMPTS=3", + "not-a-valid-key=nope", + "RANDOM_UNRELATED=leak", + "CHALLENGE_EVAL_APP_IDENTITY=app-id", + "", + ] + ), + ) + + ac_env = dumps["ac"] + assert "CHALLENGE_EVAL_MAX_ATTEMPTS=3" in ac_env + assert "CHALLENGE_EVAL_APP_IDENTITY=app-id" in ac_env + assert "RANDOM_UNRELATED" not in ac_env + assert "not-a-valid-key" not in ac_env