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
+66
-17
@@ -15,6 +15,10 @@ from urllib import error, request
|
||||
|
||||
|
||||
PRIORITY = {"P0": 0, "P1": 1, "P2": 2}
|
||||
RUNTIME_ENV_NAMES = (
|
||||
"HOME", "LANG", "LC_ALL", "LC_CTYPE", "LOGNAME", "SHELL",
|
||||
"SSH_AUTH_SOCK", "TMPDIR", "USER",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -47,6 +51,7 @@ class EngineConfig:
|
||||
agent_timeout_seconds: float = 1800.0
|
||||
test_timeout_seconds: float = 900.0
|
||||
termination_grace_seconds: float = 5.0
|
||||
agent_env_vars: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -138,6 +143,30 @@ def parse_command(command: str, name: str) -> list[str]:
|
||||
return arguments
|
||||
|
||||
|
||||
def subprocess_environment(
|
||||
allowed_names: Iterable[str] = (),
|
||||
overrides: dict[str, str] | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Build an explicit child environment without inheriting parent secrets."""
|
||||
environment = {"PATH": os.environ.get("PATH", os.defpath)}
|
||||
for name in (*RUNTIME_ENV_NAMES, *allowed_names):
|
||||
if name in os.environ:
|
||||
environment[name] = os.environ[name]
|
||||
if overrides:
|
||||
environment.update(overrides)
|
||||
return environment
|
||||
|
||||
|
||||
def validate_agent_env_vars(names: Iterable[str]) -> None:
|
||||
for name in names:
|
||||
if (
|
||||
re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name) is None
|
||||
or name == "GITEA_TOKEN"
|
||||
or name.startswith("RELEASE_")
|
||||
):
|
||||
raise ValueError(f"invalid agent environment variable: {name}")
|
||||
|
||||
|
||||
class ShellRunner:
|
||||
def run(
|
||||
self,
|
||||
@@ -147,13 +176,10 @@ class ShellRunner:
|
||||
timeout_seconds: float | None = None,
|
||||
termination_grace_seconds: float = 5.0,
|
||||
) -> CommandResult:
|
||||
merged_env = os.environ.copy()
|
||||
if env:
|
||||
merged_env.update(env)
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
cwd=str(cwd),
|
||||
env=merged_env,
|
||||
env=dict(env or {}),
|
||||
shell=False,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
@@ -249,6 +275,7 @@ class ReleaseEngine:
|
||||
for name, value in deadlines.items():
|
||||
if value <= 0:
|
||||
raise ValueError(f"{name} must be positive")
|
||||
validate_agent_env_vars(self.config.agent_env_vars)
|
||||
|
||||
issue = select_issue(self.client.list_open_issues(self.config.repo), self.config.agent, issue_number)
|
||||
branch = branch_name(self.config.agent, issue)
|
||||
@@ -276,6 +303,17 @@ class ReleaseEngine:
|
||||
|
||||
agent_command = parse_command(self.config.agent_command, "agent command")
|
||||
test_command = parse_command(self.config.test_command, "test command")
|
||||
runtime_env = subprocess_environment()
|
||||
agent_env = subprocess_environment(
|
||||
self.config.agent_env_vars,
|
||||
{
|
||||
"RELEASE_ISSUE_NUMBER": str(issue.number),
|
||||
"RELEASE_ISSUE_TITLE": issue.title,
|
||||
"RELEASE_ISSUE_BODY": issue.body,
|
||||
"RELEASE_REPO": self.config.repo,
|
||||
"RELEASE_BRANCH": branch,
|
||||
},
|
||||
)
|
||||
checkpoint = self.store.load()
|
||||
resumable_statuses = {"agent_complete", "tests_passed", "pushed"}
|
||||
if checkpoint is not None and checkpoint.status in resumable_statuses:
|
||||
@@ -287,14 +325,18 @@ class ReleaseEngine:
|
||||
or not checkpoint.head_sha
|
||||
):
|
||||
raise RuntimeError("release checkpoint does not match this run")
|
||||
branch_result = self.runner.run(["git", "checkout", branch], self.config.repo_path)
|
||||
branch_result = self.runner.run(
|
||||
["git", "checkout", branch], self.config.repo_path, runtime_env
|
||||
)
|
||||
if branch_result.returncode != 0:
|
||||
raise RuntimeError(f"checkpoint branch checkout failed: {branch_result.stderr.strip()}")
|
||||
head_result = self.runner.run(["git", "rev-parse", "HEAD"], self.config.repo_path)
|
||||
head_result = self.runner.run(
|
||||
["git", "rev-parse", "HEAD"], self.config.repo_path, runtime_env
|
||||
)
|
||||
if head_result.returncode != 0 or head_result.stdout.strip() != checkpoint.head_sha:
|
||||
raise RuntimeError("release checkpoint commit does not match the local branch")
|
||||
status_result = self.runner.run(
|
||||
["git", "status", "--porcelain"], self.config.repo_path
|
||||
["git", "status", "--porcelain"], self.config.repo_path, runtime_env
|
||||
)
|
||||
if status_result.returncode != 0 or status_result.stdout.strip():
|
||||
raise RuntimeError("release checkpoint worktree is not clean")
|
||||
@@ -311,20 +353,16 @@ class ReleaseEngine:
|
||||
)
|
||||
self.store.save(RunState(issue.number, branch, "claimed"))
|
||||
|
||||
branch_result = self.runner.run(["git", "checkout", "-B", branch], self.config.repo_path)
|
||||
branch_result = self.runner.run(
|
||||
["git", "checkout", "-B", branch], self.config.repo_path, runtime_env
|
||||
)
|
||||
if branch_result.returncode != 0:
|
||||
raise RuntimeError(f"branch creation failed: {branch_result.stderr.strip()}")
|
||||
|
||||
agent_result = self.runner.run(
|
||||
agent_command,
|
||||
self.config.repo_path,
|
||||
{
|
||||
"RELEASE_ISSUE_NUMBER": str(issue.number),
|
||||
"RELEASE_ISSUE_TITLE": issue.title,
|
||||
"RELEASE_ISSUE_BODY": issue.body,
|
||||
"RELEASE_REPO": self.config.repo,
|
||||
"RELEASE_BRANCH": branch,
|
||||
},
|
||||
agent_env,
|
||||
timeout_seconds=self.config.agent_timeout_seconds,
|
||||
termination_grace_seconds=self.config.termination_grace_seconds,
|
||||
)
|
||||
@@ -335,7 +373,9 @@ class ReleaseEngine:
|
||||
if agent_result.returncode != 0:
|
||||
self.store.save(RunState(issue.number, branch, "agent_failed", agent_result.stderr[-2000:]))
|
||||
raise RuntimeError(f"coding agent failed: {agent_result.stderr.strip()}")
|
||||
head_result = self.runner.run(["git", "rev-parse", "HEAD"], self.config.repo_path)
|
||||
head_result = self.runner.run(
|
||||
["git", "rev-parse", "HEAD"], self.config.repo_path, runtime_env
|
||||
)
|
||||
head_sha = head_result.stdout.strip()
|
||||
if head_result.returncode != 0 or not head_sha:
|
||||
raise RuntimeError("could not identify the release checkpoint commit")
|
||||
@@ -348,6 +388,7 @@ class ReleaseEngine:
|
||||
test_result = self.runner.run(
|
||||
test_command,
|
||||
self.config.repo_path,
|
||||
runtime_env,
|
||||
timeout_seconds=self.config.test_timeout_seconds,
|
||||
termination_grace_seconds=self.config.termination_grace_seconds,
|
||||
)
|
||||
@@ -373,13 +414,14 @@ class ReleaseEngine:
|
||||
remote_result = self.runner.run(
|
||||
["git", "ls-remote", "--heads", "origin", branch],
|
||||
self.config.repo_path,
|
||||
runtime_env,
|
||||
)
|
||||
expected_remote = f"{checkpoint.head_sha}\trefs/heads/{branch}"
|
||||
if remote_result.returncode != 0 or remote_result.stdout.strip() != expected_remote:
|
||||
raise RuntimeError("release checkpoint commit does not match the remote branch")
|
||||
else:
|
||||
push_result = self.runner.run(
|
||||
["git", "push", "-u", "origin", branch], self.config.repo_path
|
||||
["git", "push", "-u", "origin", branch], self.config.repo_path, runtime_env
|
||||
)
|
||||
if push_result.returncode != 0:
|
||||
self.store.save(RunState(
|
||||
@@ -442,6 +484,12 @@ def _parse_args() -> argparse.Namespace:
|
||||
parser.add_argument("--agent-timeout", type=float, default=float(os.getenv("RELEASE_AGENT_TIMEOUT", "1800")))
|
||||
parser.add_argument("--test-timeout", type=float, default=float(os.getenv("RELEASE_TEST_TIMEOUT", "900")))
|
||||
parser.add_argument("--termination-grace", type=float, default=float(os.getenv("RELEASE_TERMINATION_GRACE", "5")))
|
||||
parser.add_argument(
|
||||
"--agent-env",
|
||||
action="append",
|
||||
default=[name for name in os.getenv("RELEASE_AGENT_ENV", "").split(",") if name],
|
||||
help="parent environment variable to pass to the coding agent; repeat as needed",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@@ -462,6 +510,7 @@ def main() -> int:
|
||||
agent_timeout_seconds=args.agent_timeout,
|
||||
test_timeout_seconds=args.test_timeout,
|
||||
termination_grace_seconds=args.termination_grace,
|
||||
agent_env_vars=tuple(args.agent_env),
|
||||
)
|
||||
result = ReleaseEngine(config, GiteaClient(args.api, token), ShellRunner()).execute(
|
||||
issue_number=args.issue,
|
||||
|
||||
Reference in new issue
Block a user