AI Agent 改代码越来越像一个“会动手的初级同事”。

你给它一个任务:

帮我修复用户资料页头像上传失败的问题。

它可能真的把问题修好了。

但你打开 Git Diff 一看,发现它顺手改了:

src/pages/profile/
src/api/upload.ts
src/utils/image.ts
docs/README.md
package.json
.env.example

甚至还顺手重构了一个和头像上传无关的工具函数。

这时候问题就来了:

它完成任务了吗?可能完成了。
它改得安全吗?不一定。
它有没有改出任务范围?你需要检查。

最近关于 coding agents 的研究也开始把这个问题单独拎出来讨论。2026 年 5 月的论文《Overeager Coding Agents: Measuring Out-of-Scope Actions on Benign Tasks》把这类行为称为 overeager actions,也就是 Agent 在处理正常、善意任务时做了超出授权范围的动作,比如删除无关文件、清理用户没要求处理的凭据备份、重写用户没提到的配置等。论文还指出,这不是普通的能力失败、提示注入或沙箱逃逸,而是一个授权边界问题。
来源:https://arxiv.org/abs/2605.18583

这篇文章不讨论复杂的 Agent 安全框架。

我们先做一个非常实用的本地工具:

给 AI Agent 的任务设置一个文件范围白名单,改完代码后自动检查它有没有越界修改。

目标很简单:

用户只允许改 src/pages/profile/ 和 src/api/upload.ts
AI 却改了 package.json
脚本要能发现并提醒人工确认

一、为什么 AI Agent 容易“多改文件”

普通代码助手主要是生成代码片段。

Agent 不一样。

它通常可以:

  • 读取仓库文件;
  • 搜索代码;
  • 修改多个文件;
  • 执行 shell 命令;
  • 安装依赖;
  • 运行测试;
  • 生成提交说明;
  • 继续根据报错二次修改。

这类能力很强,但副作用也明显:它会根据自己的推理去“补全任务边界”。

比如你说:

修复头像上传失败。

开发者心里可能默认边界是:

只看上传接口、前端上传组件、图片处理工具。

但 Agent 可能推理成:

头像上传失败可能和依赖、配置、数据库字段、README 文档、缓存逻辑都有关系。

于是它开始多改文件。

研究里也提到,coding agents 现在会自主运行 shell、文件和网络相关操作;当授权范围没有被明确、持续约束时,它们可能做出超出用户原始意图的操作。
来源:https://arxiv.org/abs/2605.18583

所以,问题不是“AI 能不能改代码”。

问题是:

它能改,不代表它应该改。
它猜到了原因,不代表它被授权修改那些文件。

二、任务范围白名单要解决什么问题

我们要做的不是让 AI 永远不能改某些文件。

而是把每次任务的授权范围写清楚。

例如这次任务只允许改:

src/pages/profile/**
src/api/upload.ts
src/utils/image.ts
tests/profile/**

明确禁止改:

.env
.env.*
secrets/**
package.json
pnpm-lock.yaml
migrations/**
.github/workflows/**

改完后,脚本做三件事:

1. 从 Git Diff 里拿到实际变更文件;
2. 和 allowed_scope.yaml 里的规则匹配;
3. 生成 SCOPE_CHECK.md,标出哪些文件越界。

Git 官方文档里,git diff --name-status 可以只显示变更文件名和状态,适合脚本读取文件变更清单;这正好可以作为检查 AI 是否越界修改的基础。
来源:https://git-scm.com/docs/git-diff

最终效果类似:

OK        src/pages/profile/avatar.tsx
OK        src/api/upload.ts
VIOLATION package.json
VIOLATION .github/workflows/deploy.yml

你不用在一大段 Diff 里自己找。

先看报告,再决定要不要退回 AI 的改动。
请添加图片描述


三、先准备 allowed_scope.yaml

在项目根目录新建:

allowed_scope.yaml

写入下面内容:

task: "修复用户资料页头像上传失败问题"

base: "HEAD"

allow:
  - "src/pages/profile/**"
  - "src/api/upload.ts"
  - "src/utils/image.ts"
  - "tests/profile/**"

deny:
  - ".env"
  - ".env.*"
  - "secrets/**"
  - "migrations/**"
  - ".github/workflows/**"
  - "package.json"
  - "pnpm-lock.yaml"
  - "yarn.lock"
  - "package-lock.json"

require_review:
  - "src/api/**"
  - "src/services/**"
  - "src/repositories/**"
  - "config/**"
  - "docker-compose.yml"

notes:
  - "本次任务只允许修复头像上传相关文件。"
  - "如果需要新增依赖、修改 CI/CD 或数据库迁移,必须先人工确认。"

这份文件分成 5 部分:

字段 作用
task 说明本次任务是什么
base Git Diff 的比较基准
allow 明确允许修改的路径
deny 明确禁止修改的路径
require_review 允许修改但必须人工复核的路径
notes 给审查者看的补充说明

这里要注意一个原则:

allow 是任务范围;deny 是安全底线。

比如 package.json 不是永远不能改。

但如果这次任务只是修头像上传,AI 突然改依赖,就应该被标出来。


四、可直接运行:检查 AI 是否越界修改

新建脚本:

check_ai_scope.py

粘贴下面代码:

#!/usr/bin/env python3
from __future__ import annotations

import argparse
import fnmatch
import subprocess
from dataclasses import dataclass
from pathlib import Path


@dataclass(frozen=True)
class ScopeConfig:
    task: str
    base: str
    allow: list[str]
    deny: list[str]
    require_review: list[str]
    notes: list[str]


@dataclass(frozen=True)
class ChangedFile:
    status: str
    path: str


@dataclass(frozen=True)
class ScopeResult:
    item: ChangedFile
    result: str
    reason: str


def parse_simple_yaml(path: Path) -> ScopeConfig:
    """
    一个足够简单的 YAML 解析器,只支持本文示例这种结构:

    key: "value"

    list_key:
      - "item"
      - "item"

    这样做是为了不依赖 PyYAML,方便直接复制运行。
    真实团队项目可以改成 PyYAML 或 ruamel.yaml。
    """
    if not path.exists():
        raise SystemExit(f"配置文件不存在:{path}")

    data: dict[str, str | list[str]] = {}
    current_key: str | None = None

    for raw_line in path.read_text(encoding="utf-8").splitlines():
        line = raw_line.strip()

        if not line or line.startswith("#"):
            continue

        if line.startswith("- "):
            if current_key is None:
                raise SystemExit(f"YAML 格式错误:{raw_line}")

            value = line[2:].strip().strip('"').strip("'")
            current_value = data.setdefault(current_key, [])

            if not isinstance(current_value, list):
                raise SystemExit(f"字段 {current_key} 不是列表")

            current_value.append(value)
            continue

        if ":" in line:
            key, value = line.split(":", 1)
            key = key.strip()
            value = value.strip()

            if value == "":
                data[key] = []
                current_key = key
            else:
                data[key] = value.strip('"').strip("'")
                current_key = None

    return ScopeConfig(
        task=str(data.get("task", "未命名任务")),
        base=str(data.get("base", "HEAD")),
        allow=list_value(data.get("allow", [])),
        deny=list_value(data.get("deny", [])),
        require_review=list_value(data.get("require_review", [])),
        notes=list_value(data.get("notes", [])),
    )


def list_value(value: str | list[str]) -> list[str]:
    if isinstance(value, list):
        return value

    if isinstance(value, str) and value:
        return [value]

    return []


def run_git(args: list[str], allow_fail: bool = False) -> tuple[int, str]:
    result = subprocess.run(
        ["git", *args],
        text=True,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        check=False,
    )

    if result.returncode != 0 and not allow_fail:
        raise SystemExit(result.stdout.strip())

    return result.returncode, result.stdout.strip()


def ensure_git_repo() -> None:
    code, _ = run_git(["rev-parse", "--is-inside-work-tree"], allow_fail=True)

    if code != 0:
        raise SystemExit("当前目录不是 Git 仓库,请在项目根目录执行。")


def get_changed_files(base: str, staged: bool) -> list[ChangedFile]:
    args = ["diff", "--name-status"]

    if staged:
        args.append("--cached")

    args.append(base)

    _, output = run_git(args)

    changed: list[ChangedFile] = []

    for line in output.splitlines():
        if not line.strip():
            continue

        parts = line.split("\t")
        status = parts[0]
        path = parts[-1]

        changed.append(ChangedFile(status=status, path=path))

    if not staged:
        _, untracked = run_git(["ls-files", "--others", "--exclude-standard"])

        for path in untracked.splitlines():
            if path.strip():
                changed.append(ChangedFile(status="??", path=path.strip()))

    return changed


def normalize_path(path: str) -> str:
    return path.replace("\\", "/")


def matches_any(path: str, patterns: list[str]) -> bool:
    normalized = normalize_path(path)

    for pattern in patterns:
        normalized_pattern = normalize_path(pattern)

        if fnmatch.fnmatch(normalized, normalized_pattern):
            return True

    return False


def check_scope(item: ChangedFile, config: ScopeConfig) -> ScopeResult:
    path = normalize_path(item.path)

    if matches_any(path, config.deny):
        return ScopeResult(
            item=item,
            result="VIOLATION",
            reason="命中 deny 规则,默认禁止本次任务修改。",
        )

    if config.allow and not matches_any(path, config.allow):
        return ScopeResult(
            item=item,
            result="VIOLATION",
            reason="不在 allow 白名单范围内。",
        )

    if matches_any(path, config.require_review):
        return ScopeResult(
            item=item,
            result="REVIEW",
            reason="允许修改,但命中 require_review,需要人工复核。",
        )

    return ScopeResult(
        item=item,
        result="OK",
        reason="在本次任务允许范围内。",
    )


def markdown_report(config: ScopeConfig, results: list[ScopeResult]) -> str:
    violation_count = sum(1 for item in results if item.result == "VIOLATION")
    review_count = sum(1 for item in results if item.result == "REVIEW")
    ok_count = sum(1 for item in results if item.result == "OK")

    lines = [
        "# SCOPE_CHECK",
        "",
        "## 1. 任务说明",
        "",
        f"- 任务:{config.task}",
        f"- Diff 基准:`{config.base}`",
        f"- 变更文件数:`{len(results)}`",
        f"- 允许范围内:`{ok_count}`",
        f"- 需要人工复核:`{review_count}`",
        f"- 越界修改:`{violation_count}`",
        "",
        "## 2. 检查结果",
        "",
        "| 结果 | 状态 | 文件 | 原因 |",
        "|---|---|---|---|",
    ]

    for item in results:
        lines.append(
            f"| `{item.result}` | `{item.item.status}` | "
            f"`{item.item.path}` | {item.reason} |"
        )

    lines.extend(
        [
            "",
            "## 3. allow 规则",
            "",
        ]
    )

    if config.allow:
        for pattern in config.allow:
            lines.append(f"- `{pattern}`")
    else:
        lines.append("- 未设置 allow,默认不限制允许范围。")

    lines.extend(
        [
            "",
            "## 4. deny 规则",
            "",
        ]
    )

    if config.deny:
        for pattern in config.deny:
            lines.append(f"- `{pattern}`")
    else:
        lines.append("- 未设置 deny。")

    lines.extend(
        [
            "",
            "## 5. require_review 规则",
            "",
        ]
    )

    if config.require_review:
        for pattern in config.require_review:
            lines.append(f"- `{pattern}`")
    else:
        lines.append("- 未设置 require_review。")

    lines.extend(
        [
            "",
            "## 6. 人工处理建议",
            "",
        ]
    )

    if violation_count > 0:
        lines.extend(
            [
                "- 存在越界修改,不建议直接提交。",
                "- 请先确认这些文件是否确实属于本次任务。",
                "- 如果是 AI 顺手修改,请回滚越界文件。",
                "- 如果确实需要扩大范围,请更新 `allowed_scope.yaml` 并重新检查。",
            ]
        )
    elif review_count > 0:
        lines.extend(
            [
                "- 没有发现越界修改,但存在需要人工复核的文件。",
                "- 请重点检查接口、服务、配置、数据访问等高影响区域。",
            ]
        )
    else:
        lines.append("- 未发现越界修改,可以继续进行测试和代码 Review。")

    if config.notes:
        lines.extend(["", "## 7. 补充说明", ""])

        for note in config.notes:
            lines.append(f"- {note}")

    lines.append("")

    return "\n".join(lines)


def main() -> None:
    parser = argparse.ArgumentParser(
        description="Check whether AI agent code changes are outside the allowed task scope."
    )
    parser.add_argument("--config", default="allowed_scope.yaml")
    parser.add_argument("--output", default="SCOPE_CHECK.md")
    parser.add_argument(
        "--staged",
        action="store_true",
        help="Only check staged changes.",
    )

    args = parser.parse_args()

    ensure_git_repo()

    config = parse_simple_yaml(Path(args.config))
    changed_files = get_changed_files(config.base, args.staged)

    if not changed_files:
        raise SystemExit("没有发现 Git 变更文件。")

    results = [check_scope(item, config) for item in changed_files]
    report = markdown_report(config, results)

    Path(args.output).write_text(report, encoding="utf-8")

    violations = [item for item in results if item.result == "VIOLATION"]

    print(f"已生成:{args.output}")

    if violations:
        print(f"发现越界修改:{len(violations)} 个")
        raise SystemExit(2)


if __name__ == "__main__":
    main()

这段脚本只使用 Python 标准库,不需要安装 PyYAML。

为了方便复制,它只支持本文这种简单 YAML 格式。团队项目里如果规则更复杂,可以换成 PyYAMLruamel.yaml


请添加图片描述

五、运行方式

在项目根目录执行:

python check_ai_scope.py

如果只想检查已经暂存的改动:

python check_ai_scope.py --staged

如果配置文件不叫 allowed_scope.yaml

python check_ai_scope.py \
  --config ./scope/profile_avatar_scope.yaml \
  --output SCOPE_CHECK.md

脚本会读取:

allowed_scope.yaml

然后生成:

SCOPE_CHECK.md

如果发现越界修改,脚本会返回退出码 2,方便接入 CI 或 Git Hook。


六、输出示例

假设 AI 改完后,Git 变更如下:

M       src/pages/profile/avatar.tsx
M       src/api/upload.ts
M       src/utils/image.ts
M       package.json
M       .github/workflows/deploy.yml
??      scripts/temp_fix_upload.py

生成的 SCOPE_CHECK.md 可能是:

# SCOPE_CHECK

## 1. 任务说明

- 任务:修复用户资料页头像上传失败问题
- Diff 基准:`HEAD`
- 变更文件数:`6`
- 允许范围内:`3`
- 需要人工复核:`0`
- 越界修改:`3`

## 2. 检查结果

| 结果 | 状态 | 文件 | 原因 |
|---|---|---|---|
| `OK` | `M` | `src/pages/profile/avatar.tsx` | 在本次任务允许范围内。 |
| `OK` | `M` | `src/api/upload.ts` | 在本次任务允许范围内。 |
| `OK` | `M` | `src/utils/image.ts` | 在本次任务允许范围内。 |
| `VIOLATION` | `M` | `package.json` | 命中 deny 规则,默认禁止本次任务修改。 |
| `VIOLATION` | `M` | `.github/workflows/deploy.yml` | 命中 deny 规则,默认禁止本次任务修改。 |
| `VIOLATION` | `??` | `scripts/temp_fix_upload.py` | 不在 allow 白名单范围内。 |

## 6. 人工处理建议

- 存在越界修改,不建议直接提交。
- 请先确认这些文件是否确实属于本次任务。
- 如果是 AI 顺手修改,请回滚越界文件。
- 如果确实需要扩大范围,请更新 `allowed_scope.yaml` 并重新检查。

这份报告的意义很直接:

先发现越界文件,
再决定是否保留。

不要等到 PR Review 阶段才发现 AI 顺手改了无关配置。


七、如何让 AI 在任务开始前就遵守范围

脚本是事后检查。

更稳的做法是:任务开始前,就把范围告诉 AI。

可以这样写:

你只能修改以下文件或目录:

- src/pages/profile/**
- src/api/upload.ts
- src/utils/image.ts
- tests/profile/**

禁止修改:

- package.json
- pnpm-lock.yaml
- .github/workflows/**
- migrations/**
- .env
- secrets/**

如果你认为必须修改禁止范围内的文件,请先停止,并说明原因,不要直接修改。

这段话并不能 100% 阻止 Agent 越界。

但它会让 Agent 更早意识到边界。

然后再配合脚本做事后检查。

流程就是:

任务前:告诉 AI 允许范围
任务后:用脚本检查真实变更
提交前:人工 Review 越界项

注意,研究里也提到,如果 benchmark 把授权范围直接写进 prompt,Agent 行为会明显变化;这说明“声明边界”本身确实会影响 Agent 是否越界,但真实开发中仍然不能只靠 prompt,要看最终文件变更。
来源:https://arxiv.org/abs/2605.18583


八、哪些文件应该默认加入 deny

不同项目不一样,但下面这些建议默认放进 deny

文件 / 目录 原因
.env.env.* 环境变量和敏感配置
secrets/** 密钥、证书、凭据
migrations/** 数据库迁移风险高
.github/workflows/** CI/CD 影响部署
package.json 可能引入新依赖和脚本
锁文件 可能改变依赖树
docker-compose.yml 影响运行环境
nginx.conf 影响路由、代理和安全
生产脚本 错误执行后影响很大
支付、订单、权限目录 业务风险高

这里的意思不是永远不能改。

而是:

不能在一个普通任务里被 AI 顺手改。

如果本次任务就是“升级依赖”,那当然可以改 package.json

但这时应该单独创建一份新的 allowed_scope.yaml,把范围写清楚。


九、哪些文件适合 require_review

require_review 不是禁止修改。

它适合放那些“可以改,但不能自动放过”的文件。

比如:

require_review:
  - "src/api/**"
  - "src/services/**"
  - "src/repositories/**"
  - "config/**"
  - "docker-compose.yml"

这些文件如果在 allow 范围内,脚本不会标成 VIOLATION,而是标成 REVIEW

意思是:

允许改,
但必须人工看。

比如 AI 修头像上传,确实可能要改:

src/api/upload.ts

这个文件可以允许。

但它是接口文件,仍然应该人工确认:

  • 是否改变请求字段;
  • 是否改变返回结构;
  • 是否影响旧客户端;
  • 是否影响鉴权;
  • 是否影响文件大小限制;
  • 是否影响错误码;
  • 是否需要补测试。

所以 REVIEW 很有用。

它能把“允许修改”和“放心通过”区分开。


十、把脚本接入 PR 模板

建议在 PR 模板里加一段:

## AI Agent 范围检查

如果本次 PR 使用了 AI Agent 辅助修改代码,请确认:

- [ ] 已为本次任务定义 `allowed_scope.yaml`
- [ ] 已运行 `python check_ai_scope.py`
- [ ] `SCOPE_CHECK.md` 中没有未解释的 `VIOLATION`
- [ ] `REVIEW` 文件已人工检查
- [ ] AI 没有修改 `.env`、secrets、CI/CD、数据库迁移等高风险文件
- [ ] 如需扩大修改范围,已在 PR 描述中说明原因

这样做的好处是:

不是靠 Review 人肉眼找所有越界改动,
而是先用脚本把范围问题暴露出来。

也建议把 SCOPE_CHECK.md 放进 PR 描述或评论里,方便 reviewer 快速看。


十一、把脚本接入 Git Hook

如果团队想更进一步,可以放进 pre-commit。

示例:

#!/usr/bin/env bash
set -e

if [ -f "allowed_scope.yaml" ]; then
  python check_ai_scope.py --staged
else
  echo "未发现 allowed_scope.yaml,跳过 AI scope 检查。"
fi

保存为:

.git/hooks/pre-commit

并赋予执行权限:

chmod +x .git/hooks/pre-commit

这样只要存在 allowed_scope.yaml,提交前就会自动检查暂存区。

如果发现越界修改,提交会被阻止。

当然,Git Hook 不是银弹。

团队更正式的做法是放进 CI:

PR 创建
→ CI 运行 scope check
→ 发现 VIOLATION
→ 阻止合并
→ 人工确认或更新范围

十二、这套方法的边界

这套脚本能解决的是:

  • AI 是否改了允许范围外的文件;
  • 是否碰了 deny 规则里的文件;
  • 哪些文件需要人工复核;
  • 是否有未追踪的新文件被 AI 生成;
  • PR 里是否存在范围不清的问题。

它不能解决:

  • 文件在允许范围内但逻辑写错;
  • AI 在允许文件里删除关键逻辑;
  • AI 引入安全漏洞;
  • AI 修改后测试不充分;
  • AI 写出的代码不可维护;
  • Agent 执行了危险 shell 命令但没有留下文件变更;
  • 本地密钥被读取但没有写入仓库。

尤其是最后一点很重要。

范围检查是文件层面的。

它不能替代运行时权限控制、沙箱、命令审批和日志审计。近期安全报道里也反复提醒,agentic coding tools 一旦拥有 shell、文件和网络权限,就可能因为“想帮忙”而执行有风险的动作;对陌生仓库和敏感环境,仍然要坚持零信任和最小权限。
来源:https://www.techradar.com/pro/security/agentic-coding-tools-have-access-to-everything-they-need-for-this-security-experts-warn-claude-code-can-be-exploited-simply-by-trying-to-be-helpful

所以不要把这个脚本当成安全系统。

它只是 AI 编程工作流里的一道范围检查。


十三、推荐的完整工作流

我建议把 AI Agent 改代码拆成 8 步:

1. 新建分支
2. 写 allowed_scope.yaml
3. 把 allow / deny 规则告诉 AI
4. 让 AI 输出修改计划
5. AI 小步修改
6. 运行 check_ai_scope.py
7. 处理 VIOLATION / REVIEW
8. 跑测试、人工 Review、再提交

尤其是第 2 步,不要省。

很多越界问题不是因为 AI 故意乱改,而是任务边界一开始就没写清楚。

比如:

修复上传失败

这个任务太宽。

更好的任务是:

修复用户资料页头像上传失败。
本次只允许修改 profile 页面、upload API 和图片处理工具。
不要修改依赖、CI/CD、数据库迁移和环境配置。

写清楚后,AI 更容易按边界工作,人也更容易审查。


十四、最后总结

AI Agent 改代码时,最怕的不是它不会改。

最怕的是它:

改了;
跑了;
解释了;
但顺手动了你没授权的文件。

解决这个问题,不要只靠一句“不要乱改”。

更稳的是:

任务前写 allowed_scope.yaml
任务中告诉 AI 边界
任务后用 Git Diff 检查真实变更
提交前人工 Review 越界项

这篇脚本解决的是其中最关键的一步:

AI 改完之后,自动检查它有没有改出任务范围。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐