security: isolate release subprocess credentials (#333)
This commit is contained in:
1 parent
671c88d287
commit
7342b2e6de
4 files changed
+156
-17
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
@@ -44,6 +45,29 @@ def test_shell_runner_treats_metacharacters_as_literal_arguments(tmp_path):
|
||||
assert not injected.exists()
|
||||
|
||||
|
||||
def test_shell_runner_uses_only_the_explicit_environment(tmp_path, monkeypatch):
|
||||
output = tmp_path / "environment.json"
|
||||
monkeypatch.setenv("GITEA_TOKEN", "write-capable-secret")
|
||||
monkeypatch.setenv("UNRELATED_SECRET", "also-secret")
|
||||
|
||||
result = ShellRunner().run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
"import json, os, sys; json.dump(dict(os.environ), open(sys.argv[1], 'w'))",
|
||||
str(output),
|
||||
],
|
||||
tmp_path,
|
||||
{"PATH": os.environ["PATH"], "EXPECTED_VALUE": "available"},
|
||||
)
|
||||
|
||||
environment = json.loads(output.read_text())
|
||||
assert result.returncode == 0
|
||||
assert environment["EXPECTED_VALUE"] == "available"
|
||||
assert "GITEA_TOKEN" not in environment
|
||||
assert "UNRELATED_SECRET" not in environment
|
||||
|
||||
|
||||
def test_shell_runner_times_out_and_terminates_spawned_children(tmp_path):
|
||||
marker = tmp_path / "child-survived.txt"
|
||||
child = (
|
||||
@@ -215,6 +239,18 @@ def test_non_positive_command_deadlines_fail_before_claim(tmp_path, field, value
|
||||
assert client.claimed == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["GITEA_TOKEN", "RELEASE_REPO", "bad-name", "1START"])
|
||||
def test_invalid_agent_environment_allowlist_fails_before_claim(tmp_path, name):
|
||||
issue = Issue(14, "Engine", "body", "open", [], None)
|
||||
client = FakeClient([issue])
|
||||
cfg = replace(config(tmp_path), agent_env_vars=(name,))
|
||||
|
||||
with pytest.raises(ValueError, match="invalid agent environment variable"):
|
||||
ReleaseEngine(cfg, client, FakeRunner([])).execute(issue_number=14)
|
||||
|
||||
assert client.claimed == []
|
||||
|
||||
|
||||
def test_command_deadlines_are_configurable_from_cli(monkeypatch):
|
||||
monkeypatch.setattr(sys, "argv", [
|
||||
"release-engine",
|
||||
@@ -222,6 +258,8 @@ def test_command_deadlines_are_configurable_from_cli(monkeypatch):
|
||||
"--agent-timeout", "120",
|
||||
"--test-timeout", "45.5",
|
||||
"--termination-grace", "2",
|
||||
"--agent-env", "CODEX_HOME",
|
||||
"--agent-env", "HTTPS_PROXY",
|
||||
])
|
||||
|
||||
args = _parse_args()
|
||||
@@ -229,6 +267,7 @@ def test_command_deadlines_are_configurable_from_cli(monkeypatch):
|
||||
assert args.agent_timeout == 120
|
||||
assert args.test_timeout == 45.5
|
||||
assert args.termination_grace == 2
|
||||
assert args.agent_env == ["CODEX_HOME", "HTTPS_PROXY"]
|
||||
|
||||
|
||||
def test_agent_failure_blocks_tests_and_pr(tmp_path):
|
||||
@@ -306,6 +345,41 @@ def test_issue_content_reaches_agent_only_through_environment(tmp_path):
|
||||
assert runner.environments[1]["RELEASE_ISSUE_BODY"] == body
|
||||
|
||||
|
||||
def test_release_stages_receive_least_privilege_environments(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("GITEA_TOKEN", "write-capable-secret")
|
||||
monkeypatch.setenv("UNRELATED_SECRET", "also-secret")
|
||||
monkeypatch.setenv("CODEX_HOME", "/approved/codex")
|
||||
issue = Issue(14, "Engine MVP", "sensitive body", "open", [], None)
|
||||
client = FakeClient([issue])
|
||||
runner = FakeRunner([
|
||||
CommandResult(0, "", ""),
|
||||
CommandResult(0, "implemented", ""),
|
||||
CommandResult(0, "abc123\n", ""),
|
||||
CommandResult(0, "8 passed", ""),
|
||||
CommandResult(0, "", ""),
|
||||
])
|
||||
cfg = replace(config(tmp_path), agent_env_vars=("CODEX_HOME",))
|
||||
|
||||
ReleaseEngine(cfg, client, runner).execute(issue_number=14)
|
||||
|
||||
for environment in runner.environments:
|
||||
assert environment is not None
|
||||
assert "PATH" in environment
|
||||
assert "GITEA_TOKEN" not in environment
|
||||
assert "UNRELATED_SECRET" not in environment
|
||||
agent_environment = runner.environments[1]
|
||||
assert agent_environment["CODEX_HOME"] == "/approved/codex"
|
||||
assert agent_environment["RELEASE_ISSUE_NUMBER"] == "14"
|
||||
assert agent_environment["RELEASE_ISSUE_TITLE"] == issue.title
|
||||
assert agent_environment["RELEASE_ISSUE_BODY"] == issue.body
|
||||
assert agent_environment["RELEASE_REPO"] == cfg.repo
|
||||
assert agent_environment["RELEASE_BRANCH"] == "timmy/14-engine-mvp"
|
||||
assert not any(
|
||||
name.startswith("RELEASE_")
|
||||
for name in runner.environments[3]
|
||||
)
|
||||
|
||||
|
||||
def test_failed_tests_block_push_and_pr(tmp_path):
|
||||
issue = Issue(14, "Engine", "body", "open", [], None)
|
||||
client = FakeClient([issue])
|
||||
|
||||
Reference in new issue
Block a user