Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 28 additions & 7 deletions src/hyrule_engineering_loop/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
}
Comment on lines +64 to +69

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep Pi env in sync with accepted provider keys

For live Pi runs where operators set only HYRULE_LLM_API_KEY (the generic key name accepted by provider_env_names()/provider_env() for OpenRouter, OpenAI, and Anthropic), this allowlist drops the sole configured provider credential. The live preflight can still mark provider_key as configured, but SubprocessBackend invokes pi with an env built from this set, so the backend still fails with no API key; either pass/translate the generic key for Pi or make preflight reject that configuration.

Useful? React with 👍 / 👎.

)


class BackendExecutionError(RuntimeError):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)}")

Expand Down Expand Up @@ -700,6 +720,7 @@ class PiBackend(SubprocessBackend):

name = "pi"
default_command = ("pi", "--print", "{prompt}")
extra_env_names = PI_PROVIDER_ENV_NAMES


class ClaudeCodeBackend(SubprocessBackend):
Expand Down
13 changes: 10 additions & 3 deletions src/hyrule_engineering_loop/preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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(
Expand Down
22 changes: 22 additions & 0 deletions tests/test_phase20_agent_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from hyrule_engineering_loop.backend import (
BackendConstraints,
ClaudeCodeBackend,
PI_PROVIDER_ENV_NAMES,
PiBackend,
TaskSpec,
assemble_backend_prompt,
Expand Down Expand Up @@ -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",
Expand Down