diff --git a/docs/operator-guide.zh-CN.md b/docs/operator-guide.zh-CN.md index 7b9d0b7..6655b6d 100644 --- a/docs/operator-guide.zh-CN.md +++ b/docs/operator-guide.zh-CN.md @@ -171,6 +171,18 @@ image;项目安全设置只能收紧用户限额和默认禁止路径,不能 ASCII 字母、数字及分隔非空片段的连字符;整个占位符不能加引号且最多 128 字符。模板仍须满足 文件大小、UTF-8、普通文件、目录和赋值语法检查;其他非空敏感值继续拒绝,错误只报告位置。 +若测试使用模拟 HTTP 客户端却仍解析测试域名,可以在可信用户配置中为单个仓库设置静态 IPv4 +映射。项目配置不能添加或覆盖这些映射: + +```toml +[repositories."bytedance/deer-flow".verification_hosts] +"example.com" = "93.184.216.34" +``` + +映射以 Docker `--add-host` 参数传给该仓库的安装和验证容器,并记入沙箱清单。其他仓库不受影响; +验证仍使用 `--network none`。地址仅作为测试夹具的解析结果,不会启用网络访问。不支持 IPv6、 +`host-gateway` 或非法主机名。Node 等依赖仍须在联网 bootstrap 阶段预装。 + ### 并发与变更规模 默认情况下,每个仓库最多同时保留 4 个由当前 GitHub 账号创建的 open PR;Draft 和 Ready 都计入, diff --git a/src/reposteward/config.py b/src/reposteward/config.py index 1b9d891..f5cd159 100644 --- a/src/reposteward/config.py +++ b/src/reposteward/config.py @@ -7,6 +7,7 @@ from dataclasses import dataclass, field from datetime import date from decimal import Decimal, InvalidOperation +from ipaddress import AddressValueError, IPv4Address from pathlib import Path from typing import Any from urllib.parse import urlparse @@ -185,6 +186,7 @@ class RepositoryPolicy: ) bootstrap_commands: tuple[str, ...] = () verification_prefixes: tuple[str, ...] = () + verification_hosts: tuple[tuple[str, str], ...] = () required_verification_markers: tuple[str, ...] = () required_contribution_files: tuple[str, ...] = () pull_request_body_style: str = "generic" @@ -362,6 +364,7 @@ def _merge_layers(user: dict[str, Any], project: dict[str, Any]) -> dict[str, An for repository in repositories.values(): if isinstance(repository, dict): repository.pop("unlimited_diff_lines", None) + repository.pop("verification_hosts", None) return result # Runtime state and disposable clones belong to the user/machine trust layer. @@ -474,6 +477,11 @@ def _merge_layers(user: dict[str, Any], project: dict[str, Any]) -> dict[str, An if not isinstance(repository, dict): continue trusted = trusted_by_name.get(str(name).casefold(), {}) + repository["verification_hosts"] = ( + trusted.get("verification_hosts", {}) + if isinstance(trusted, dict) + else {} + ) trusted_unlimited = ( _boolean(trusted.get("unlimited_diff_lines"), False) if isinstance(trusted, dict) @@ -486,6 +494,35 @@ def _merge_layers(user: dict[str, Any], project: dict[str, Any]) -> dict[str, An return result +def _verification_hosts(value: object) -> tuple[tuple[str, str], ...]: + if value is None: + return () + if not isinstance(value, dict): + raise ConfigError("verification_hosts must be a hostname-to-IPv4 table") + hosts: dict[str, str] = {} + label = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?") + for raw_host, raw_address in value.items(): + if not isinstance(raw_host, str) or not isinstance(raw_address, str): + raise ConfigError( + "verification_hosts requires string hostnames and IPv4 values" + ) + hostname = raw_host.casefold() + if len(hostname) > 253 or any( + label.fullmatch(part) is None for part in hostname.split(".") + ): + raise ConfigError("verification_hosts contains an invalid hostname") + if hostname in hosts: + raise ConfigError("verification_hosts contains a duplicate hostname") + try: + address = str(IPv4Address(raw_address)) + except AddressValueError as error: + raise ConfigError( + "verification_hosts requires literal IPv4 addresses" + ) from error + hosts[hostname] = address + return tuple(sorted(hosts.items())) + + def _tracked_sensitive_paths( value: object, ) -> tuple[tuple[str, tuple[str, ...]], ...]: @@ -862,6 +899,13 @@ def load_config( for name, repo_value in repositories_raw.items(): if not isinstance(repo_value, dict): raise ConfigError(f"[repositories.{name!r}] must be a table") + if ( + repo_value.get("verification_hosts") + and REPOSITORY_NAME.fullmatch(name) is None + ): + raise ConfigError( + "verification_hosts requires a valid owner/repository name" + ) if name.count("/") != 1: raise ConfigError(f"repository must use owner/name form: {name!r}") repositories[name.lower()] = RepositoryPolicy( @@ -894,6 +938,9 @@ def load_config( ), bootstrap_commands=_tuple(repo_value.get("bootstrap_commands")), verification_prefixes=_tuple(repo_value.get("verification_prefixes")), + verification_hosts=_verification_hosts( + repo_value.get("verification_hosts") + ), required_verification_markers=_tuple( repo_value.get("required_verification_markers") ), diff --git a/src/reposteward/verifier.py b/src/reposteward/verifier.py index 49ee7d4..2ab5d31 100644 --- a/src/reposteward/verifier.py +++ b/src/reposteward/verifier.py @@ -136,6 +136,11 @@ def verify( cancellation = ( {"cancel_event": cancel_event} if cancel_event is not None else {} ) + host_options = ( + {"host_aliases": policy.verification_hosts} + if policy.verification_hosts + else {} + ) with self._verification_sandbox( worktree, verification_dir, policy=policy ) as sandbox: @@ -156,6 +161,7 @@ def verify( environment_dir=environment_dir, git_dir=git_dir, **cancellation, + **host_options, ) results.append(result) if result.exit_code: @@ -177,6 +183,7 @@ def verify( environment_dir=environment_dir, git_dir=git_dir, **cancellation, + **host_options, ) results.append(result) if snapshot_guard is not None: @@ -443,6 +450,7 @@ def _verification_sandbox( "trusted_tracked_sensitive_paths": list( trusted_sensitive_paths ), + "trusted_host_aliases": list(policy.verification_hosts), "host_workspace_writable": False, "shared_dependency_environment": True, "copied_entries": copied_files, @@ -495,6 +503,7 @@ def _run_container( environment_dir: Path | None = None, git_dir: Path | None = None, cancel_event: Event | None = None, + host_aliases: tuple[tuple[str, str], ...] = (), ) -> CommandResult: runner = self.config.runner environment_dir = environment_dir or worktree @@ -551,6 +560,8 @@ def _run_container( ] if git_dir is not None: docker_command.extend(["-v", f"{git_dir.resolve()}:/reposteward-git:ro"]) + for hostname, address in host_aliases: + docker_command.extend(["--add-host", f"{hostname}:{address}"]) docker_command.extend([runner.image, "bash", "-lc", shell_command]) start = time.monotonic() try: diff --git a/tests/test_mcp_bridge.py b/tests/test_mcp_bridge.py index ccbe72b..258ce06 100644 --- a/tests/test_mcp_bridge.py +++ b/tests/test_mcp_bridge.py @@ -220,6 +220,13 @@ def stdio_environment(self) -> tuple[dict, list[str]]: '[repositories."owner/repo"]', ] for key, value in asdict(self.config.repositories["owner/repo"]).items(): + if key == "verification_hosts": + entries = ", ".join( + f"{json.dumps(host)} = {json.dumps(address)}" + for host, address in value + ) + lines.append(f"{key} = {{ {entries} }}") + continue if key != "name" and value is not None: lines.append(f"{key} = {json.dumps(value)}") user_file.write_text("\n".join(lines) + "\n") diff --git a/tests/test_verification_hosts.py b/tests/test_verification_hosts.py new file mode 100644 index 0000000..66ea1c4 --- /dev/null +++ b/tests/test_verification_hosts.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import json +import subprocess +import tempfile +import unittest +from dataclasses import replace +from pathlib import Path +from unittest.mock import patch + +from reposteward.config import ConfigError, load_config +from reposteward.models import AgentResult, CommandResult +from reposteward.verifier import DockerVerifier + + +class VerificationHostsTests(unittest.TestCase): + def config(self, root: Path, hosts: str, project_hosts: str = ""): + user = root / "user.toml" + project = root / "project.toml" + user.write_text( + 'config_version = 1\n[github]\nlogin = "alice"\n' + '[repositories."owner/repo"]\n' + hosts, + encoding="utf-8", + ) + project.write_text( + 'config_version = 1\n[github]\nlogin = "alice"\n' + '[repositories."owner/repo"]\n' + project_hosts, + encoding="utf-8", + ) + return load_config(project, user_path=user) + + def test_aliases_are_trusted_and_repository_scoped(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + hosts = 'verification_hosts = { "EXAMPLE.com" = "93.184.216.34" }\n' + injected = ( + 'verification_hosts = { "example.com" = "127.0.0.1", ' + '"injected.example" = "10.0.0.1" }\n' + '[repositories."other/repo"]\n' + 'verification_hosts = { "example.com" = "10.0.0.1" }\n' + ) + config = self.config(root, hosts, injected) + self.assertEqual( + config.repositories["owner/repo"].verification_hosts, + (("example.com", "93.184.216.34"),), + ) + self.assertEqual(config.repositories["other/repo"].verification_hosts, ()) + self.assertEqual( + self.config(root, "", injected) + .repositories["owner/repo"] + .verification_hosts, + (), + ) + self.assertEqual( + load_config(root / "project.toml", include_user=False) + .repositories["owner/repo"] + .verification_hosts, + (), + ) + + def test_invalid_host_aliases_are_rejected(self): + invalid = ( + "[]", + '{ "example.com" = "host-gateway" }', + '{ "example.com" = "::1" }', + '{ "example.com" = "999.0.0.1" }', + '{ "example.com" = 123 }', + '{ "bad host" = "1.1.1.1" }', + '{ "-bad.example" = "1.1.1.1" }', + '{ "bad..example" = "1.1.1.1" }', + '{ "bad:80" = "1.1.1.1" }', + '{ "EXAMPLE.com" = "1.1.1.1", "example.com" = "8.8.8.8" }', + '{ "' + "a" * 64 + '.example" = "1.1.1.1" }', + '{ "' + ".".join(["a" * 63] * 4) + '" = "1.1.1.1" }', + ) + with tempfile.TemporaryDirectory() as directory: + for value in invalid: + with self.subTest(value=value), self.assertRaises(ConfigError): + self.config(Path(directory), "verification_hosts = " + value) + + def test_aliases_reach_both_phases_and_the_manifest(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + config = self.config( + root, 'verification_hosts = { "example.com" = "93.184.216.34" }' + ) + worktree = root / "source" + worktree.mkdir() + subprocess.run(["git", "init", "-q"], cwd=worktree, check=True) + (worktree / "source.txt").write_text("unchanged") + subprocess.run(["git", "add", "source.txt"], cwd=worktree, check=True) + verifier = DockerVerifier(config) + policy = config.repositories["owner/repo"] + policy = replace( + policy, bootstrap_commands=("setup",), verification_prefixes=("check",) + ) + result = AgentResult("test", "test", "test", ("check",)) + calls = [] + + def run(_worktree, command, **kwargs): + calls.append(kwargs) + return CommandResult(command, 0, "ok", 0.01) + + with ( + patch.object(verifier, "image_available", return_value=True), + patch.object(verifier, "_run_container", side_effect=run), + ): + observed = verifier.verify( + worktree, policy, result, run_dir=root / "run" + ) + self.assertTrue(observed.passed) + self.assertEqual([value["network"] for value in calls], [True, False]) + self.assertTrue( + all( + value["host_aliases"] == policy.verification_hosts + for value in calls + ) + ) + manifest = json.loads((root / "run/verification/sandbox.json").read_text()) + self.assertEqual( + manifest["trusted_host_aliases"], [["example.com", "93.184.216.34"]] + ) + self.assertTrue(manifest["cleaned"]) + + def test_docker_alias_arguments_preserve_isolation(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + verifier = DockerVerifier(self.config(root, "")) + completed = subprocess.CompletedProcess([], 0, "", "") + with patch( + "reposteward.verifier.subprocess.run", return_value=completed + ) as run: + verifier._run_container( + root, + "check", + network=False, + host_aliases=(("example.com", "93.184.216.34"),), + ) + command = run.call_args.args[0] + self.assertEqual(command[command.index("--network") + 1], "none") + self.assertEqual( + command[command.index("--add-host") + 1], "example.com:93.184.216.34" + ) + self.assertEqual(command[command.index("--cap-drop") + 1], "ALL") + self.assertIn("no-new-privileges", command) + self.assertIn("--user", command)