AI Agent 老是“多改文件”?写一份任务范围白名单检查脚本
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 格式。团队项目里如果规则更复杂,可以换成 PyYAML 或 ruamel.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 改完之后,自动检查它有没有改出任务范围。
更多推荐

所有评论(0)