security: isolate release issue content from shell execution (#307)
This commit is contained in:
1 parent
bd5e66b4e5
commit
7d633e67e8
4 files changed
+111
-23
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
@@ -12,12 +13,34 @@ from src.release_engine import (
|
||||
Issue,
|
||||
ReleaseEngine,
|
||||
RunState,
|
||||
ShellRunner,
|
||||
StateStore,
|
||||
branch_name,
|
||||
select_issue,
|
||||
)
|
||||
|
||||
|
||||
def test_shell_runner_treats_metacharacters_as_literal_arguments(tmp_path):
|
||||
output = tmp_path / "argument.txt"
|
||||
injected = tmp_path / "injected.txt"
|
||||
payload = f"release title; touch {injected}"
|
||||
|
||||
result = ShellRunner().run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
"from pathlib import Path; import sys; Path(sys.argv[1]).write_text(sys.argv[2])",
|
||||
str(output),
|
||||
payload,
|
||||
],
|
||||
tmp_path,
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert output.read_text() == payload
|
||||
assert not injected.exists()
|
||||
|
||||
|
||||
def test_stale_token_error_explains_how_to_restore_cron_authentication():
|
||||
class UnauthorizedHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
@@ -70,9 +93,11 @@ class FakeRunner:
|
||||
def __init__(self, results):
|
||||
self.results = list(results)
|
||||
self.commands = []
|
||||
self.environments = []
|
||||
|
||||
def run(self, command, cwd, env=None):
|
||||
self.commands.append(command)
|
||||
self.environments.append(env)
|
||||
return self.results.pop(0)
|
||||
|
||||
|
||||
@@ -91,7 +116,7 @@ def config(tmp_path):
|
||||
agent="timmy",
|
||||
repo_path=tmp_path,
|
||||
state_path=tmp_path / "state.json",
|
||||
agent_command="agent --issue {issue_number}",
|
||||
agent_command="agent --issue-from-env",
|
||||
test_command="pytest -q",
|
||||
)
|
||||
|
||||
@@ -121,6 +146,24 @@ def test_claim_verification_blocks_execution(tmp_path):
|
||||
assert runner.commands == []
|
||||
|
||||
|
||||
def test_empty_agent_command_fails_before_claim(tmp_path):
|
||||
issue = Issue(14, "Engine", "body", "open", [], None)
|
||||
client = FakeClient([issue])
|
||||
cfg = EngineConfig(
|
||||
repo="stackchain/stackchain-dashboard",
|
||||
agent="timmy",
|
||||
repo_path=tmp_path,
|
||||
state_path=tmp_path / "state.json",
|
||||
agent_command=" ",
|
||||
test_command="pytest -q",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="invalid agent command: command is empty"):
|
||||
ReleaseEngine(cfg, client, FakeRunner([])).execute(issue_number=14)
|
||||
|
||||
assert client.claimed == []
|
||||
|
||||
|
||||
def test_agent_failure_blocks_tests_and_pr(tmp_path):
|
||||
issue = Issue(14, "Engine", "body", "open", [], None)
|
||||
client = FakeClient([issue])
|
||||
@@ -134,12 +177,41 @@ def test_agent_failure_blocks_tests_and_pr(tmp_path):
|
||||
engine.execute(issue_number=14)
|
||||
|
||||
assert runner.commands == [
|
||||
"git checkout -B timmy/14-engine",
|
||||
"agent --issue 14",
|
||||
["git", "checkout", "-B", "timmy/14-engine"],
|
||||
["agent", "--issue-from-env"],
|
||||
]
|
||||
assert client.created_prs == []
|
||||
|
||||
|
||||
def test_issue_content_reaches_agent_only_through_environment(tmp_path):
|
||||
title = "$(touch /tmp/title-injected); `id`"
|
||||
body = "line one\n&& touch /tmp/body-injected > stolen"
|
||||
issue = Issue(14, title, body, "open", [], None)
|
||||
client = FakeClient([issue])
|
||||
runner = FakeRunner([
|
||||
CommandResult(0, "", ""),
|
||||
CommandResult(1, "", "agent stopped"),
|
||||
])
|
||||
cfg = EngineConfig(
|
||||
repo="stackchain/stackchain-dashboard",
|
||||
agent="timmy",
|
||||
repo_path=tmp_path,
|
||||
state_path=tmp_path / "state.json",
|
||||
agent_command="agent --work",
|
||||
test_command="pytest -q",
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="coding agent failed"):
|
||||
ReleaseEngine(cfg, client, runner).execute(issue_number=14)
|
||||
|
||||
assert runner.commands == [
|
||||
["git", "checkout", "-B", "timmy/14-touch-tmp-title-injected-id"],
|
||||
["agent", "--work"],
|
||||
]
|
||||
assert runner.environments[1]["RELEASE_ISSUE_TITLE"] == title
|
||||
assert runner.environments[1]["RELEASE_ISSUE_BODY"] == body
|
||||
|
||||
|
||||
def test_failed_tests_block_push_and_pr(tmp_path):
|
||||
issue = Issue(14, "Engine", "body", "open", [], None)
|
||||
client = FakeClient([issue])
|
||||
@@ -153,7 +225,7 @@ def test_failed_tests_block_push_and_pr(tmp_path):
|
||||
with pytest.raises(RuntimeError, match="tests failed"):
|
||||
engine.execute(issue_number=14)
|
||||
|
||||
assert "git push" not in " ".join(runner.commands)
|
||||
assert not any(command[:2] == ["git", "push"] for command in runner.commands)
|
||||
assert client.created_prs == []
|
||||
|
||||
|
||||
@@ -171,7 +243,7 @@ def test_passing_run_pushes_and_opens_linked_pr(tmp_path):
|
||||
result = engine.execute(issue_number=14)
|
||||
|
||||
assert result["pr_number"] == 44
|
||||
assert runner.commands[-1].startswith("git push -u origin")
|
||||
assert runner.commands[-1] == ["git", "push", "-u", "origin", "timmy/14-engine-mvp"]
|
||||
assert len(client.created_prs) == 1
|
||||
body = client.created_prs[0][4]
|
||||
assert "Closes #14" in body
|
||||
|
||||
Reference in new issue
Block a user