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
12 changes: 12 additions & 0 deletions docs/operator-guide.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 都计入,
Expand Down
47 changes: 47 additions & 0 deletions src/reposteward/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -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, ...]], ...]:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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")
),
Expand Down
11 changes: 11 additions & 0 deletions src/reposteward/verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -156,6 +161,7 @@ def verify(
environment_dir=environment_dir,
git_dir=git_dir,
**cancellation,
**host_options,
)
results.append(result)
if result.exit_code:
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions tests/test_mcp_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
146 changes: 146 additions & 0 deletions tests/test_verification_hosts.py
Original file line number Diff line number Diff line change
@@ -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)