一、前言
上一篇完成了 LangGraph Code Review Agent 的第一阶段:
- 创建 FastAPI 项目骨架;
- 定义配置系统;
- 定义 Git Diff、AnalysisPackage 和 Finding 等 Pydantic 模型;
- 预留本地代码审查接口;
- 建立 pytest 和 Ruff 检查体系。
但是,上一篇的 POST /api/v1/reviews/local 只完成了请求模型校验。只要请求 JSON 格式正确,接口就会直接返回 501 Not Implemented,并没有判断:
text
repo_path 是否存在?
repo_path 是否为目录?
repo_path 是否位于允许访问的目录下?
repo_path 是否通过符号链接跳到了其他位置?
后续工具读取的文件是否仍然位于当前仓库?
这些问题必须在执行 Git 命令和调用 LLM 之前解决。
本篇完成第二阶段:建立确定性的仓库与文件路径安全边界。
完成后的请求流程变成:
text
ReviewRequest
↓
读取 ALLOWED_REPO_ROOTS
↓
PathPolicy 解析仓库真实路径
↓
检查存在性、目录类型和允许根目录
↓
路径无效:400 invalid_repository
路径越界:403 repository_not_allowed
路径合法:进入下一阶段
↓
当前 Git Diff 尚未实现,因此返回 501
二、本篇目标与非目标
2.1 本篇目标
本篇需要实现:
- 校验允许根目录必须存在且为绝对目录;
- 校验 API 传入的仓库路径必须是绝对路径;
- 将仓库路径解析为真实规范路径;
- 限制仓库必须位于
ALLOWED_REPO_ROOTS下; - 阻止相似目录名前缀绕过;
- 阻止仓库符号链接逃逸;
- 校验仓库内相对文件路径;
- 阻止
..、盘符、绝对文件路径和控制字符; - 阻止文件符号链接逃逸;
- 将路径策略接入 FastAPI Review 接口;
- 为无效路径和越界路径返回不同错误码;
- 使用自动化测试覆盖安全边界。
2.2 本篇暂时不做什么
本篇不会实现:
- 判断目录是不是 Git 仓库;
- 执行任何 Git 命令;
- 获取或解析 Git Diff;
- 创建 LangGraph StateGraph;
- 调用 LLM;
- 读取真实代码内容;
- 发布 GitHub PR 评论。
因此,本篇解决的是"程序允许访问哪里",而下一篇才解决"在允许的仓库中如何安全执行 Git"。
三、为什么路径安全是 Agent 的基础能力
未来 Code Review Agent 会暴露三个上下文工具:
text
read_file
read_diff
search_code
这些工具都可能访问本地文件系统。如果工具直接执行:
python
content = Path(user_input).read_text()
那么调用方或模型只需要构造特殊路径,就可能访问仓库外文件。
例如:
text
../secrets.txt
C:\Users\ad\.ssh\id_rsa
/etc/passwd
即使工具只接收相对路径,也不能简单认为安全。下面的仓库内符号链接:
text
repository/linked-secret.txt
可能实际指向:
text
C:\Users\ad\secret.txt
因此,路径校验不能只检查输入字符串,还必须检查文件系统解析后的真实目标。
本项目采用两层边界:
text
第一层:仓库边界
repo_path 必须位于 ALLOWED_REPO_ROOTS
第二层:文件边界
工具读取的文件必须位于当前 repo_path
并且每次文件工具调用都要重新经过第二层校验,不能因为仓库路径曾经验证过就直接拼接文件路径。
四、本篇代码变化总览
4.1 新增文件
text
src/code_review_agent/guardrails
├── __init__.py
└── path_policy.py
tests
└── test_path_policy.py
4.2 修改文件
text
src/code_review_agent/api/routes.py
tests/test_review_route.py
pyproject.toml
README.md
文件职责如下:
| 文件 | 本节变化 | 作用 |
|---|---|---|
guardrails/path_policy.py |
新增 | 实现仓库与仓库文件路径安全策略 |
guardrails/__init__.py |
新增 | 对外统一导出 PathPolicy 和异常类型 |
api/routes.py |
修改 | Review API 在进入后续流程前执行路径校验 |
test_path_policy.py |
新增 | 覆盖目录、文件、穿越和符号链接场景 |
test_review_route.py |
修改 | 验证 API 的 400、403 和 501 响应 |
pyproject.toml |
修改 | 将 LangGraph 锁定到 1.2.9 |
README.md |
修改 | 更新当前里程碑和下一阶段说明 |
本篇没有修改 schemas.py 的业务结构,而是复用已有的:
text
FailureCode.invalid_repository
FailureCode.repository_not_allowed
五、升级并锁定 LangGraph 版本
本节把依赖精确锁定为当前使用的稳定版本:
toml
"langgraph==1.2.9"
安装命令:
powershell
python -m pip install --upgrade langgraph==1.2.9
python -m pip install -e ".[dev]"
验证版本:
powershell
python -c "from importlib.metadata import version; print(version('langgraph'))"
实际输出:
text
1.2.9
再检查依赖完整性:
powershell
python -m pip check
输出:
text
No broken requirements found.
需要说明:本篇仍然没有创建 LangGraph 图。现在锁定版本,是为了后续实现工作流时,本地环境、CI 和 Docker 使用相同 API,而不是为了给路径校验增加不必要的 LangGraph 依赖。
LangGraph 当前版本可以在以下官方页面核对:
六、创建 guardrails 包
安全相关代码放在:
text
src/code_review_agent/guardrails
guardrails/__init__.py 统一导出公共类型:
python
from code_review_agent.guardrails.path_policy import (
InvalidPathError,
PathAccessDeniedError,
PathPolicy,
PathPolicyError,
)
__all__ = [
"InvalidPathError",
"PathAccessDeniedError",
"PathPolicy",
"PathPolicyError",
]
其他模块可以使用:
python
from code_review_agent.guardrails import PathPolicy
而不需要依赖 path_policy.py 的内部文件位置。
这种组织方式为后续扩展预留了清晰位置:
text
guardrails
├── path_policy.py
├── budget.py
└── finding_validator.py
其中:
path_policy.py负责文件系统访问边界;budget.py将负责 Agent 轮数、工具次数和超时;finding_validator.py将负责评论文件、行号和重复问题校验。
七、设计路径异常类型
path_policy.py 先定义三种异常:
python
class PathPolicyError(ValueError):
"""Base class for rejected repository path operations."""
class InvalidPathError(PathPolicyError):
"""The supplied path is missing, malformed, or has the wrong type."""
class PathAccessDeniedError(PathPolicyError):
"""The supplied path resolves outside its configured security boundary."""
继承关系为:
text
ValueError
└── PathPolicyError
├── InvalidPathError
└── PathAccessDeniedError
两类错误需要分开,因为它们的语义不同。
7.1 InvalidPathError
表示路径本身无效,例如:
- 使用相对仓库路径;
- 路径不存在;
- 仓库路径指向普通文件;
- 文件路径为空;
- 文件路径包含
..; - 目标不是普通文件。
API 将它转换为:
text
400 Bad Request
invalid_repository
7.2 PathAccessDeniedError
表示路径存在,但访问范围不允许,例如:
- 仓库位于
ALLOWED_REPO_ROOTS外; - 仓库符号链接指向白名单外;
- 仓库内文件符号链接指向仓库外。
API 将它转换为:
text
403 Forbidden
repository_not_allowed
如果只使用一个普通 ValueError,API 就无法稳定区分"输入错误"和"权限边界拒绝"。
八、初始化 PathPolicy
核心类定义如下:
python
class PathPolicy:
"""Resolve paths canonically and keep them inside trusted roots."""
__slots__ = ("_allowed_roots",)
def __init__(self, allowed_roots: Iterable[Path]) -> None:
roots = tuple(self._canonicalize_allowed_root(root) for root in allowed_roots)
if not roots:
raise ValueError("At least one allowed repository root must be configured.")
self._allowed_roots = tuple(dict.fromkeys(roots))
初始化过程完成三件事:
- 逐个规范化允许根目录;
- 禁止空白名单;
- 按第一次出现顺序去重。
__slots__ 表示实例只保存 _allowed_roots,避免运行时意外添加其他属性。它不是安全机制,但可以让这个小型策略对象的结构更明确。
对外只暴露只读属性:
python
@property
def allowed_roots(self) -> tuple[Path, ...]:
return self._allowed_roots
使用不可变 tuple,避免调用方拿到列表后修改安全边界。
九、规范化允许根目录
允许根目录不是普通业务输入,而是服务端安全配置,因此配置错误应该尽早失败。
核心代码:
python
@staticmethod
def _canonicalize_allowed_root(root: Path) -> Path:
candidate = Path(root).expanduser()
if not candidate.is_absolute():
raise ValueError("Allowed repository roots must be absolute paths.")
try:
resolved = candidate.resolve(strict=True)
except (OSError, RuntimeError) as exc:
raise ValueError(
f"Allowed repository root cannot be resolved: {candidate}"
) from exc
if not resolved.is_dir():
raise ValueError(
f"Allowed repository root is not a directory: {candidate}"
)
return resolved
9.1 为什么要求绝对路径
如果允许配置:
env
ALLOWED_REPO_ROOTS=projects
它的含义会随着进程工作目录变化。使用绝对路径后,服务启动位置不会改变安全范围。
9.2 expanduser 的作用
expanduser() 会展开用户目录表达,例如:
text
~/projects
不过展开后仍然必须满足绝对路径、存在性和目录类型要求。
9.3 resolve(strict=True) 的作用
resolve(strict=True) 会:
- 转换为绝对规范路径;
- 解析
.和..; - 跟随已存在的符号链接;
- 在路径不存在时抛出异常。
因此 PathPolicy 保存的是文件系统中的真实允许目录,而不是未经验证的配置字符串。
十、校验仓库根目录
仓库入口由 resolve_repository() 负责:
python
def resolve_repository(self, repo_path: str | Path) -> Path:
candidate = Path(repo_path).expanduser()
if not candidate.is_absolute():
raise InvalidPathError("Repository path must be absolute.")
resolved = self._resolve_existing(candidate, "Repository path")
if not resolved.is_dir():
raise InvalidPathError("Repository path must point to a directory.")
if not self._is_within_any(resolved, self._allowed_roots):
raise PathAccessDeniedError(
"Repository path resolves outside ALLOWED_REPO_ROOTS."
)
return resolved
执行顺序不能随意打乱:
text
转换 Path
↓
检查绝对路径
↓
解析真实存在路径
↓
检查必须为目录
↓
检查是否位于任一允许根目录
↓
返回规范路径
10.1 统一处理不存在和不可解析
辅助方法:
python
@staticmethod
def _resolve_existing(path: Path, label: str) -> Path:
try:
return path.resolve(strict=True)
except (OSError, RuntimeError) as exc:
raise InvalidPathError(
f"{label} does not exist or cannot be resolved."
) from exc
除了文件不存在,权限错误、符号链接循环等情况也可能导致解析失败,因此同时捕获 OSError 和 RuntimeError,再转换成项目自己的异常类型。
10.2 为什么不使用字符串 startswith
下面的写法不安全:
python
if str(repo_path).startswith(str(allowed_root)):
return repo_path
假设允许目录是:
text
D:\agent\allowed
攻击路径是:
text
D:\agent\allowed-lookalike\repository
字符串前缀仍然可能匹配,但两个目录没有父子关系。
本项目使用:
python
@staticmethod
def _is_within_any(path: Path, roots: tuple[Path, ...]) -> bool:
return any(path.is_relative_to(root) for root in roots)
Path.is_relative_to() 按路径层级判断,而不是比较字符串前缀。
十一、仓库符号链接逃逸
假设允许目录为:
text
D:\agent\allowed
其中存在:
text
D:\agent\allowed\linked-repository
但这个符号链接实际指向:
text
C:\private-repository
如果只判断输入字符串,路径看起来仍然位于允许目录中。
本项目先执行:
python
resolved = candidate.resolve(strict=True)
得到真实目标后再执行:
python
resolved.is_relative_to(allowed_root)
因此最终判断的是:
text
C:\private-repository 是否位于 D:\agent\allowed
结果为 False,PathPolicy 抛出 PathAccessDeniedError。
这说明路径安全判断必须采用:
text
解析后的真实路径 + 路径层级判断
而不是:
text
原始输入字符串 + 前缀比较
十二、校验仓库内文件路径
仓库路径安全不代表文件工具自动安全。未来模型会向 read_file 传入文件路径,因此新增:
python
def resolve_repo_file(
self,
repo_path: str | Path,
relative_path: str | Path,
*,
must_exist: bool = True,
) -> Path:
repository = self.resolve_repository(repo_path)
relative = self._normalize_relative_file_path(relative_path)
candidate = repository.joinpath(*relative.parts)
try:
resolved = candidate.resolve(strict=must_exist)
except (OSError, RuntimeError) as exc:
raise InvalidPathError(
"Repository file does not exist or cannot be resolved."
) from exc
if not resolved.is_relative_to(repository):
raise PathAccessDeniedError(
"Repository file resolves outside the repository root."
)
if resolved.exists() and not resolved.is_file():
raise InvalidPathError(
"Repository file path must point to a file."
)
return resolved
这个方法没有直接相信已经传入的 repo_path,而是再次调用:
python
repository = self.resolve_repository(repo_path)
这样每次工具调用都会重新确认仓库仍然位于允许范围内。
12.1 must_exist 参数
默认:
python
must_exist=True
用于 read_file 等读取工具,目标文件必须已经存在。
设置为:
python
must_exist=False
时,可以安全解析将来要创建的仓库内目标路径,例如生成报告文件。即使文件尚未存在,解析后的目标仍然必须位于仓库根目录内。
12.2 为什么还要检查 is_file
一个路径可能存在但指向目录:
text
repository/src
read_file 不应该把目录当成普通文件读取,因此存在目标还必须满足:
python
resolved.is_file()
十三、规范化仓库相对路径
_normalize_relative_file_path() 负责字符串层面的第一轮拒绝:
python
@staticmethod
def _normalize_relative_file_path(value: str | Path) -> PurePosixPath:
raw = str(value)
if not raw.strip() or any(
char in raw for char in ("\x00", "\r", "\n")
):
raise InvalidPathError(
"Repository file path is empty or malformed."
)
normalized = raw.replace("\\", "/")
posix_path = PurePosixPath(normalized)
windows_path = PureWindowsPath(raw)
if (
posix_path.is_absolute()
or windows_path.is_absolute()
or windows_path.drive
or ".." in posix_path.parts
or normalized == "."
):
raise InvalidPathError(
"Repository file path must be a safe relative path."
)
return posix_path
13.1 同时使用 PurePosixPath 和 PureWindowsPath
服务可能在 Windows、本地容器或 Linux CI 中运行,而模型也可能生成不同风格路径。
因此同时检查:
python
PurePosixPath(normalized)
PureWindowsPath(raw)
需要拒绝的路径包括:
text
../secret.txt
C:\secret.txt
C:relative.txt
/etc/passwd
\\server\share\secret.txt
13.2 为什么统一成正斜杠
Git Diff 和 GitHub API 通常使用:
text
src/service.py
Windows 输入可能是:
text
src\service.py
先执行:
python
normalized = raw.replace("\\", "/")
可以让后续路径模型、Diff 路径和 Finding 路径使用同一种表示。
13.3 为什么显式拒绝控制字符
空字符、回车和换行不应该出现在工具文件路径中:
text
\x00
\r
\n
提前拒绝可以避免异常日志分行、底层文件 API 报错以及路径语义混乱。
十四、文件符号链接逃逸
即使文件名只是:
text
linked-secret.txt
仓库中的该文件也可能是指向仓库外部的符号链接。
resolve_repo_file() 先拼接:
python
candidate = repository.joinpath(*relative.parts)
然后解析真实目标:
python
resolved = candidate.resolve(strict=True)
最后重新判断:
python
if not resolved.is_relative_to(repository):
raise PathAccessDeniedError(...)
因此安全检查流程为:
text
模型输入 linked-secret.txt
↓
拼接 repository/linked-secret.txt
↓
解析符号链接真实目标
↓
发现真实目标位于 repository 外
↓
拒绝访问
这段逻辑将直接被后续的 read_file 和 search_code 工具复用。
十五、将 PathPolicy 接入 Review API
上一阶段的 Review 路由只接收请求后返回 501。本篇修改为:
python
def review_local(
request: ReviewRequest,
settings: Annotated[Settings, Depends(get_settings)],
) -> ReviewResponse:
policy = PathPolicy(settings.allowed_repo_root_paths)
try:
repository = policy.resolve_repository(request.repo_path)
except PathAccessDeniedError as exc:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"code": FailureCode.repository_not_allowed.value,
"message": str(exc),
},
) from exc
except InvalidPathError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": FailureCode.invalid_repository.value,
"message": str(exc),
},
) from exc
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail=(
f"Repository path passed security validation: {repository}. "
"Git diff collection will be implemented in the next stage."
),
)
调用链变成:
text
FastAPI 解析 ReviewRequest
↓
Depends(get_settings) 注入 Settings
↓
Settings.allowed_repo_root_paths
↓
构造 PathPolicy
↓
resolve_repository(request.repo_path)
↓
异常映射成 HTTP 状态码
15.1 为什么合法路径仍然返回 501
本篇只证明仓库目录可以安全访问,还没有证明它是 Git 仓库,更没有生成 Diff。
因此不能返回:
json
{
"status": "completed",
"findings": []
}
合法路径通过后仍返回 501,但响应内容已经从:
text
request contract is ready
变化为:
text
Repository path passed security validation
它表示第一道业务安全边界已经真正执行。
十六、结构化错误响应
16.1 不存在的仓库路径
请求:
json
{
"repo_path": "D:\\agent\\missing-repository"
}
响应状态码:
text
400 Bad Request
响应正文:
json
{
"detail": {
"code": "invalid_repository",
"message": "Repository path does not exist or cannot be resolved."
}
}
16.2 白名单外仓库路径
请求:
json
{
"repo_path": "C:\\Users\\ad\\Documents\\New project"
}
当配置为:
env
ALLOWED_REPO_ROOTS=D:\agent
响应状态码:
text
403 Forbidden
响应正文:
json
{
"detail": {
"code": "repository_not_allowed",
"message": "Repository path resolves outside ALLOWED_REPO_ROOTS."
}
}
16.3 合法仓库目录
请求:
json
{
"repo_path": "D:\\agent\\langgraph-code-review-agent"
}
响应状态码:
text
501 Not Implemented
响应正文:
json
{
"detail": "Repository path passed security validation: D:\\agent\\langgraph-code-review-agent. Git diff collection will be implemented in the next stage."
}
501 在这里不是路径校验失败,而是说明请求已经通过第二阶段,停在尚未实现的第三阶段。
十七、测试 PathPolicy
本篇新增:
text
tests/test_path_policy.py
17.1 使用临时允许目录
测试通过 pytest 的 tmp_path 创建彼此隔离的目录:
python
@pytest.fixture
def allowed_root(tmp_path: Path) -> Path:
root = tmp_path / "allowed"
root.mkdir()
return root
每个测试都有独立文件系统,不会读取真实项目文件,也不会依赖开发机器上的固定目录。
17.2 配置边界测试
测试覆盖:
- 不允许空白名单;
- 不允许相对的允许根目录;
- 不允许不存在的允许根目录;
- 支持多个允许根目录。
多个根目录测试:
python
def test_resolve_repository_accepts_each_configured_root(tmp_path: Path) -> None:
first_root = tmp_path / "first"
second_root = tmp_path / "second"
first_repository = first_root / "repository"
second_repository = second_root / "repository"
first_repository.mkdir(parents=True)
second_repository.mkdir(parents=True)
policy = PathPolicy([first_root, second_root])
assert policy.resolve_repository(first_repository) == first_repository.resolve(
strict=True
)
assert policy.resolve_repository(second_repository) == second_repository.resolve(
strict=True
)
17.3 仓库路径测试
测试覆盖:
- 允许根目录下的真实目录;
- 相对仓库路径;
- 不存在路径;
- 普通文件伪装成仓库目录;
- 白名单外目录;
- 相似名称前缀目录;
- 符号链接逃逸。
相似前缀测试专门防止字符串 startswith 写法回归:
python
def test_resolve_repository_rejects_lookalike_root_prefix(
allowed_root: Path,
) -> None:
lookalike_root = allowed_root.parent / f"{allowed_root.name}-lookalike"
repository = lookalike_root / "repository"
repository.mkdir(parents=True)
with pytest.raises(PathAccessDeniedError):
PathPolicy([allowed_root]).resolve_repository(repository)
17.4 仓库文件测试
测试覆盖:
- 正常读取仓库内文件;
- 拒绝
../secret.txt; - 拒绝
C:\secret.txt; - 拒绝
/etc/passwd; - 拒绝空路径和
.; - 拒绝不存在文件;
- 在
must_exist=False时解析未来文件; - 拒绝把目录当文件;
- 拒绝文件符号链接逃逸。
不安全路径使用参数化测试:
python
@pytest.mark.parametrize(
"unsafe_path",
["../secret.txt", r"C:\secret.txt", "/etc/passwd", "", "."],
)
def test_resolve_repo_file_rejects_unsafe_relative_path(
allowed_root: Path,
unsafe_path: str,
) -> None:
repository = allowed_root / "repository"
repository.mkdir()
with pytest.raises(InvalidPathError):
PathPolicy([allowed_root]).resolve_repo_file(repository, unsafe_path)
17.5 符号链接测试
Windows 是否允许普通用户创建符号链接与系统配置有关,因此测试使用辅助函数:
python
def _create_symlink_or_skip(
link: Path,
target: Path,
*,
target_is_directory: bool,
) -> None:
try:
link.symlink_to(target, target_is_directory=target_is_directory)
except (NotImplementedError, OSError) as exc:
pytest.skip(
f"Symbolic links are unavailable in this environment: {exc}"
)
在本次真实运行中,两个符号链接测试都实际执行并通过,没有被跳过。
十八、测试 Review API
API 测试需要临时修改 ALLOWED_REPO_ROOTS,不能依赖机器上的 D:\agent。
通过 FastAPI 依赖覆盖实现:
python
@pytest.fixture
def review_client(
tmp_path: Path,
) -> Iterator[tuple[TestClient, Path, Path]]:
allowed_root = tmp_path / "allowed"
repository = allowed_root / "repository"
repository.mkdir(parents=True)
settings = Settings(allowed_repo_roots=str(allowed_root))
app.dependency_overrides[get_settings] = lambda: settings
try:
with TestClient(app) as client:
yield client, repository, allowed_root
finally:
app.dependency_overrides.pop(get_settings, None)
这里有两个关键点。
第一,测试为每次运行创建自己的允许目录。
第二,finally 中移除依赖覆盖,避免某个测试的配置污染其他测试。
API 测试最终覆盖:
text
合法仓库目录 -> 501
未知请求字段 -> 422
不存在目录 -> 400 invalid_repository
白名单外目录 -> 403 repository_not_allowed
十九、运行测试与静态检查
执行:
powershell
cd D:\agent\langgraph-code-review-agent
conda activate agent
python -m pytest
实际结果:
text
...................................
35 passed, 1 warning in 0.28s
这 35 个测试包括第一阶段已有测试和第二阶段新增测试。
当前的一条 warning 来自 FastAPI/Starlette 测试客户端依赖的弃用提示,不是 PathPolicy 断言失败,也不影响测试通过。
执行 Ruff:
powershell
python -m ruff check src tests
实际结果:
text
All checks passed!
执行依赖检查:
powershell
python -m pip check
实际结果:
text
No broken requirements found.
二十、运行真实 HTTP 验证
启动服务:
powershell
python -m uvicorn code_review_agent.main:app --reload
本次实际验证结果:
text
GET /api/v1/health -> 200
POST 合法仓库目录 -> 501
POST 白名单外目录 -> 403
POST 不存在目录 -> 400
健康检查响应:
json
{
"status": "ok",
"service": "LangGraph Code Review Agent API",
"version": "0.1.0",
"environment": "development"
}
如果本机设置了全局 HTTP 代理,使用 HTTPX 调试 localhost 时可能被代理接管。可以显式关闭环境代理:
python
import httpx
client = httpx.Client(trust_env=False)
response = client.get("http://127.0.0.1:8000/api/v1/health")
print(response.status_code, response.text)
这属于本地调试环境问题,不应该通过修改 FastAPI 路由规避。
二十一、为什么 PathPolicy 不直接读取文件
PathPolicy 只负责:
text
输入路径
↓
规范化
↓
安全边界判断
↓
返回可信的 Path
它不负责:
- 读取文本;
- 检测编码;
- 限制文件字节数;
- 截断输出行数;
- 统计工具预算。
未来 read_file 工具会执行:
text
PathPolicy.resolve_repo_file
↓
检查 MAX_FILE_BYTES
↓
检测文本编码
↓
限制返回行数
↓
记录一次工具调用
把路径策略与文件读取分开,可以让 read_file、search_code 和报告写入逻辑复用同一个安全边界,同时避免 PathPolicy 承担无关职责。
二十二、本阶段仍然存在的边界
22.1 目录合法不代表 Git 仓库合法
当前只检查目录和路径范围。
下面的普通目录也会通过 PathPolicy:
text
D:\agent\empty-directory
下一阶段需要执行安全 Git 命令确认:
text
git rev-parse --is-inside-work-tree
22.2 尚未处理 Git Ref
base_ref 和 head_ref 已经拒绝控制字符,但还没有交给 Git 验证。
下一阶段必须:
- 使用参数数组调用 subprocess;
- 禁止
shell=True; - 使用
--end-of-options分隔 Git Ref; - 设置命令超时;
- 限制 Diff 最大字节数。
22.3 不能完全消除文件系统竞态
PathPolicy 在校验完成后,文件仍可能被其他进程替换。这属于"检查后、使用前"的竞态问题。
当前项目采用的策略是:
- 每次工具调用重新校验;
- 校验规范路径和符号链接;
- 缩短校验与读取之间的时间;
- 不执行用户仓库中的脚本。
更高安全等级需要基于操作系统文件句柄实现打开后验证,不属于当前本地开发工具的第一版范围。
22.4 尚未加入 API 身份认证
当前服务默认绑定:
text
127.0.0.1
适用于本地开发。如果未来部署为多人可访问服务,还需要身份认证、请求级仓库授权和审计日志,不能只依赖全局允许目录。
二十三、本篇关键设计决策
23.1 路径策略早于 Git 和 LLM
先限制程序能访问什么,再实现读取和分析能力。否则工具能力越强,安全风险越大。
23.2 判断真实路径而不是原始字符串
先执行 resolve() 跟随符号链接,再用 is_relative_to() 判断父子关系。
23.3 仓库和文件采用两层边界
仓库必须位于允许根目录,文件必须位于当前仓库。两层规则不能互相替代。
23.4 输入错误和权限拒绝分开
无效路径返回 400,越界路径返回 403,调用方可以根据稳定错误码采取不同处理方式。
23.5 合法路径不伪装成审查完成
路径通过后仍然返回 501,因为 Git Diff、LangGraph 和 LLM 尚未执行。
23.6 对应用依赖精确锁定版本
项目固定使用 langgraph==1.2.9,保证本地、CI 和后续容器使用一致版本。
二十四、第二阶段完成了什么
第二阶段完成后,项目新增了以下能力:
text
ALLOWED_REPO_ROOTS 配置
↓
规范化允许目录
↓
校验仓库绝对路径
↓
解析仓库真实路径
↓
阻止仓库越界和符号链接逃逸
↓
校验仓库内文件相对路径
↓
阻止文件穿越和符号链接逃逸
↓
FastAPI 返回结构化 400 / 403
项目现在已经能够回答:
这次审查请求是否有权访问指定目录,以及未来工具要读取的文件是否仍然位于当前仓库。
但还不能回答:
这个目录是不是 Git 仓库,本次具体修改了哪些文件和代码行。
二十五、下一篇计划
下一篇将实现安全 Git 客户端和 Git Diff 获取,计划新增:
text
src/code_review_agent/git
├── __init__.py
├── client.py
└── diff_parser.py
主要内容:
- 使用
subprocess.run()参数数组执行 Git; - 禁止
shell=True; - 设置 Git 命令超时;
- 确认目标目录是 Git 工作树;
- 获取工作区已跟踪与未跟踪变更;
- 获取
base_ref...head_ref范围 Diff; - 使用
--end-of-options防止 Ref 被解释为选项; - 使用
--分隔路径参数; - 限制 Diff 最大字节数;
- 解析文件状态、Hunk 和新版本变更行号;
- 处理新增、删除、重命名和二进制文件;
- 让 Review API 返回真实
changed_files。
完成第三阶段后,项目将第一次真正读取 Git 变更,为后续 AnalysisPackage 和 LangGraph 工作流提供确定性输入。
二十六、总结
本篇没有调用 LLM,而是完成了 Agent 工具系统最基础的安全边界。
路径安全不能只依赖字符串前缀,也不能只校验 ..。一个可靠实现至少需要同时处理:
- 绝对路径;
- 路径存在性;
- 文件与目录类型;
- 多个允许根目录;
- POSIX 与 Windows 路径语义;
- 相似目录名前缀;
- 仓库符号链接;
- 文件符号链接;
- 结构化错误映射;
- 自动化回归测试。
完成这些工作后,后续 Git 客户端和上下文工具不需要各自重新发明一套路径判断逻辑,只需要在执行文件操作前统一调用 PathPolicy。
下一篇将进入确定性输入处理的第二部分:安全执行 Git,并把真实代码变化转换成结构化 Diff。