构建 LangGraph Code Review Agent(二):建立仓库路径安全边界

一、前言

上一篇完成了 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 本篇目标

本篇需要实现:

  1. 校验允许根目录必须存在且为绝对目录;
  2. 校验 API 传入的仓库路径必须是绝对路径;
  3. 将仓库路径解析为真实规范路径;
  4. 限制仓库必须位于 ALLOWED_REPO_ROOTS 下;
  5. 阻止相似目录名前缀绕过;
  6. 阻止仓库符号链接逃逸;
  7. 校验仓库内相对文件路径;
  8. 阻止 ..、盘符、绝对文件路径和控制字符;
  9. 阻止文件符号链接逃逸;
  10. 将路径策略接入 FastAPI Review 接口;
  11. 为无效路径和越界路径返回不同错误码;
  12. 使用自动化测试覆盖安全边界。

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))

初始化过程完成三件事:

  1. 逐个规范化允许根目录;
  2. 禁止空白名单;
  3. 按第一次出现顺序去重。

__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

除了文件不存在,权限错误、符号链接循环等情况也可能导致解析失败,因此同时捕获 OSErrorRuntimeError,再转换成项目自己的异常类型。

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_filesearch_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_filesearch_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_refhead_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

主要内容:

  1. 使用 subprocess.run() 参数数组执行 Git;
  2. 禁止 shell=True
  3. 设置 Git 命令超时;
  4. 确认目标目录是 Git 工作树;
  5. 获取工作区已跟踪与未跟踪变更;
  6. 获取 base_ref...head_ref 范围 Diff;
  7. 使用 --end-of-options 防止 Ref 被解释为选项;
  8. 使用 -- 分隔路径参数;
  9. 限制 Diff 最大字节数;
  10. 解析文件状态、Hunk 和新版本变更行号;
  11. 处理新增、删除、重命名和二进制文件;
  12. 让 Review API 返回真实 changed_files

完成第三阶段后,项目将第一次真正读取 Git 变更,为后续 AnalysisPackage 和 LangGraph 工作流提供确定性输入。


二十六、总结

本篇没有调用 LLM,而是完成了 Agent 工具系统最基础的安全边界。

路径安全不能只依赖字符串前缀,也不能只校验 ..。一个可靠实现至少需要同时处理:

  • 绝对路径;
  • 路径存在性;
  • 文件与目录类型;
  • 多个允许根目录;
  • POSIX 与 Windows 路径语义;
  • 相似目录名前缀;
  • 仓库符号链接;
  • 文件符号链接;
  • 结构化错误映射;
  • 自动化回归测试。

完成这些工作后,后续 Git 客户端和上下文工具不需要各自重新发明一套路径判断逻辑,只需要在执行文件操作前统一调用 PathPolicy。

下一篇将进入确定性输入处理的第二部分:安全执行 Git,并把真实代码变化转换成结构化 Diff。

相关推荐
雨师@1 小时前
python通过rust编写组件扩展自己的能力
python·rust
时光不染。回忆不淡1 小时前
Python基础语法教程
开发语言·python
带刺的坐椅2 小时前
Solon AI:Tool 与 Talent 怎么选?从函数到领域专家
java·ai·llm·agent·solon
白执落2 小时前
使用 Python + LangChain + Vue3 构建 LLM 聊天应用
人工智能·python·langchain
蓝创工坊Blue Foundry2 小时前
本地 PDF、图片字段提取到 Excel:用文档工作台的完整流程
python·ocr·vim·paddlepaddle
小白说大模型2 小时前
AI Agent 调试实战:链路追踪、Prompt 可视化与异常定位的系统方法
java·人工智能·python·算法·prompt
MicrosoftReactor2 小时前
技术速递|智能体构建智能体:基于 Microsoft Agent Framework 与 Foundry 的 Skill 优先架构蓝图
ai·架构·agent·智能体·mcp
浊酒南街2 小时前
subprocess.run函数介绍
python