From aa29db8ae5a771e4f9d1ce0bc9e5cb6d04a2843f Mon Sep 17 00:00:00 2001 From: David Hyrule Date: Mon, 15 Jun 2026 07:14:52 +0200 Subject: [PATCH] fix: pass provider keys to pi backend only --- src/hyrule_engineering_loop/backend.py | 35 +++++++++++++++++++----- src/hyrule_engineering_loop/preflight.py | 13 +++++++-- tests/test_phase20_agent_backend.py | 22 +++++++++++++++ 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/src/hyrule_engineering_loop/backend.py b/src/hyrule_engineering_loop/backend.py index 0742b8e..e3fe7cd 100644 --- a/src/hyrule_engineering_loop/backend.py +++ b/src/hyrule_engineering_loop/backend.py @@ -37,9 +37,10 @@ KNOWN_BACKENDS = ("mock", "pi", "claude-code") -# Environment hygiene: the backend gets the repo and its toolchain, nothing -# else. Allowlist, not denylist — anything not named here never reaches the -# backend process (no Vault, no fleet SSH agent, no provider API keys). +# Environment hygiene: the backend gets the repo, its toolchain, and only the +# model-provider credentials the selected harness needs. Allowlist, not denylist +# — anything not named here never reaches the backend process (no Vault, GitHub, +# fleet SSH agent, cloud, or app runtime credentials). ENV_ALLOWED_NAMES = frozenset( { "PATH", @@ -60,6 +61,13 @@ ENV_DENIED_PATTERN = re.compile( r"(?i)(token|secret|passwd|password|api[_-]?key|vault|ssh|aws_|credential)" ) +PI_PROVIDER_ENV_NAMES = frozenset( + { + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "OPENROUTER_API_KEY", + } +) class BackendExecutionError(RuntimeError): @@ -181,9 +189,21 @@ def scrubbed_backend_env(*, allow_names: frozenset[str] | set[str] | None = None return env -def env_hygiene_violations(env: Mapping[str, str]) -> list[str]: - """Return env var names that look credential-bearing (defense in depth).""" - return sorted(key for key in env if ENV_DENIED_PATTERN.search(key)) +def env_hygiene_violations( + env: Mapping[str, str], + *, + allowed_secret_names: frozenset[str] | set[str] | None = None, +) -> list[str]: + """Return credential-looking env var names not explicitly allowed. + + Real harnesses need model-provider API keys to call their selected model. + Those keys are allowed only when the backend class opts in by exact name; + Vault, GitHub, SSH, cloud, and application credentials remain blocked. + """ + allowed = frozenset(allowed_secret_names or ()) + return sorted( + key for key in env if key not in allowed and ENV_DENIED_PATTERN.search(key) + ) def loop_repo_root() -> Path: @@ -601,7 +621,7 @@ def _result( prompt = assemble_backend_prompt(task_spec, constraints) command = self.build_command(prompt=prompt, constraints=constraints) env = scrubbed_backend_env(allow_names=self.extra_env_names) - leaked = env_hygiene_violations(env) + leaked = env_hygiene_violations(env, allowed_secret_names=self.extra_env_names) if leaked: return _result("failed", error=f"backend env hygiene violation: {', '.join(leaked)}") @@ -700,6 +720,7 @@ class PiBackend(SubprocessBackend): name = "pi" default_command = ("pi", "--print", "{prompt}") + extra_env_names = PI_PROVIDER_ENV_NAMES class ClaudeCodeBackend(SubprocessBackend): diff --git a/src/hyrule_engineering_loop/preflight.py b/src/hyrule_engineering_loop/preflight.py index 8c632b4..eaaeb76 100644 --- a/src/hyrule_engineering_loop/preflight.py +++ b/src/hyrule_engineering_loop/preflight.py @@ -113,8 +113,16 @@ def preflight_feature_state( ) backend_selection = select_backend_for_state(state) - backend_env = scrubbed_backend_env() - leaked = env_hygiene_violations(backend_env) + backend_instance = create_backend(backend_selection.name, command=backend_selection.command) + backend_extra_env = ( + backend_instance.extra_env_names + if isinstance(backend_instance, SubprocessBackend) + else frozenset() + ) + backend_env = scrubbed_backend_env(allow_names=backend_extra_env) + leaked = env_hygiene_violations( + backend_env, allowed_secret_names=backend_extra_env + ) checks.append( _check( "backend_env_hygiene", @@ -125,7 +133,6 @@ def preflight_feature_state( backend_spec = task_spec_from_state(state) backend_constraints = constraints_from_state(state) backend_prompt = assemble_backend_prompt(backend_spec, backend_constraints) - backend_instance = create_backend(backend_selection.name, command=backend_selection.command) command_preview: list[str] | None = None if isinstance(backend_instance, SubprocessBackend): command_preview = backend_instance.build_command( diff --git a/tests/test_phase20_agent_backend.py b/tests/test_phase20_agent_backend.py index 609c33d..b024335 100644 --- a/tests/test_phase20_agent_backend.py +++ b/tests/test_phase20_agent_backend.py @@ -12,6 +12,7 @@ from hyrule_engineering_loop.backend import ( BackendConstraints, ClaudeCodeBackend, + PI_PROVIDER_ENV_NAMES, PiBackend, TaskSpec, assemble_backend_prompt, @@ -80,6 +81,27 @@ def test_env_hygiene_scrubs_credentials(monkeypatch: pytest.MonkeyPatch) -> None assert env_hygiene_violations(env) == [] +def test_pi_backend_allows_only_model_provider_keys(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-openrouter") + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-anthropic") + monkeypatch.setenv("OPENAI_API_KEY", "sk-openai") + monkeypatch.setenv("GH_TOKEN", "github-token") + monkeypatch.setenv("VAULT_TOKEN", "vault-token") + monkeypatch.setenv("SSH_AUTH_SOCK", "/run/agent.sock") + + env = scrubbed_backend_env(allow_names=PiBackend.extra_env_names) + + for key in PI_PROVIDER_ENV_NAMES: + assert env[key] + assert "GH_TOKEN" not in env + assert "VAULT_TOKEN" not in env + assert "SSH_AUTH_SOCK" not in env + assert env_hygiene_violations( + env, allowed_secret_names=PiBackend.extra_env_names + ) == [] + assert set(env_hygiene_violations(env)) == PI_PROVIDER_ENV_NAMES + + def test_subprocess_backend_command_assembly_and_refusals(tmp_path: Path) -> None: spec = TaskSpec( change_id="CMD_ASSEMBLY",