通过JSON分类账记录Agent允许操作的路径,用checker脚本在diff阶段就拒绝触碰未声明路径/环境变量的补丁,防止Agent自行发明依赖。
背景:廉价的补丁与沉默的假设
本周关于 Agent 写稿的文章不断在围绕一个实际失败打转,而非术语问题。模型假设某个辅助模块存在,发明出一个环境变量,然后生成一个看起来本地连贯的统一 diff。你在 commit message 中审阅文案时错过了未声明的路径,因为补丁很小,叙述却很自信。廉价的生成让这个失误代价高昂——因为下一个 session 会把这个虚构的文件当作承重架构。
你不需要一个新的 Agent 框架来打断这个循环。你需要一个冻结点,一个脚本可以在不解释意图的情况下强制执行。这个项目中的冻结点是一份你先写入的 JSON 账本(ledger),然后是一个只读取 diff 和账本的检查器。如果 diff 中出现了账本中不存在的内容,这个补丁永远不会进入你的工作树。
你将构建一个微小的假设账本工具箱,在一小时内复制到任何仓库中。目标不是让模型更聪明。目标是让未声明的编辑无法被合并——即使生成的推理听起来很谨慎。
成功的样子是四个你可以测试的具体行为:
只编辑已声明路径的补丁应退出码为 0。
添加 src/utils/helpers.py 而账本中无记录的补丁应退出码非零。
当账本只列了 PORT 时导出 DATABASE_URL 的补丁应退出码非零。
当账本只允许 pytest 时执行 curl 的补丁应退出码非零。
你应该把下面的每个数字都当作这个示例项目的 fixture 结果,而不是生产基准。检查器是一道门,不是一个质量分数。
创建一个目录来存放契约、检查器和测试。保持表面小,这样你可以在信任它之前读懂每一行。
assumption-ledger/
ledger.schema.json
ledger.json
check_patch.py
test_check_patch.py
fixtures/
ok.patch
undeclared_path.patch
undeclared_env.patch
undeclared_cmd.patch
账本在任何模型运行之前回答三个问题。哪些路径可能出现在 diff 中?哪些环境变量名可能出现在新增行中?哪些可执行令牌可能出现在 shell 提示符后或 subprocess 调用中?
{
"version": 1,
"allowed_paths": [
"src/app.py",
"src/config.py",
"tests/test_app.py"
],
"allowed_env": ["PORT", "LOG_LEVEL"],
"allowed_commands": ["pytest", "python"]
}
你在一次人工 commit 中更新这个文件,而不是在你即将检查的同一个生成的补丁中。如果 Agent 需要一个新路径,你先扩展账本,然后再重新生成。这个顺序就是本案例研究的全部产品。
检查器不应该导入你的应用,因为应用导入会隐藏你试图捕捉的假设。解析一个统一 diff,收集文件头,然后扫描新增行中的类环境变量令牌和类命令令牌。将这些模式标记为启发式规则。它们对于周末服务足够严格,但对于内核树来说太粗糙了。
#!/usr/bin/env python3
"""Fail closed when a unified diff violates assumption-ledger.json."""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
ENV_RE = re.compile(r"\b([A-Z][A-Z0-9_]{2,})\b")
CMD_RE = re.compile(r"\b(?:subprocess\.run\(|os\.system\(|popen\(|`)([a-zA-Z0-9._-]+)")
DIFF_FILE_RE = re.compile(r"^\+\+\+ b/(.+)$")
def load_ledger(path: Path) -> dict:
data = json.loads(path.read_text(encoding="utf-8"))
for key in ("allowed_paths", "allowed_env", "allowed_commands"):
if key not in data or not isinstance(data[key], list):
raise ValueError(f"ledger missing list field: {key}")
return data
def collect_claims(diff_text: str) -> tuple[set[str], set[str], set[str]]:
paths, envs, cmds = set(), set(), set()
for line in diff_text.splitlines():
header = DIFF_FILE_RE.match(line)
if header:
paths.add(header.group(1).strip())
continue
if not line.startswith("+") or line.startswith("+++"):
continue
added = line[1:]
envs.update(ENV_RE.findall(added))
cmds.update(m.group(1) for m in CMD_RE.finditer(added))
return paths, envs, cmds
def violations(ledger: dict, diff_text: str) -> list[str]:
paths, envs, cmds = collect_claims(diff_text)
problems: list[str] = []
allowed_paths = set(ledger["allowed_paths"])
allowed_env = set(ledger["allowed_env"])
allowed_cmds = set(ledger["allowed_commands"])
for path in sorted(paths):
if path != "/dev/null" and path not in allowed_paths:
problems.append(f"undeclared path: {path}")
for env in sorted(envs):
if env not in allowed_env and env not in {"True", "False", "None"}:
problems.append(f"undeclared env token: {env}")
for cmd in sorted(cmds):
if cmd not in allowed_cmds:
problems.append(f"undeclared command: {cmd}")
return problems
def main(argv: list[str]) -> int:
if len(argv) != 3:
print("usage: check_patch.py LEDGER.json PATCH.diff", file=sys.stderr)
return 2
ledger = load_ledger(Path(argv[1]))
diff_text = Path(argv[2]).read_text(encoding="utf-8")
problems = violations(ledger, diff_text)
if problems:
print("assumption ledger rejected this patch:")
for item in problems:
print(f"- {item}")
return 1
print("assumption ledger accepted this patch")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
你应该注意到这个脚本拒绝做什么。它不调用模型。它不美化架构建议。它打印一份契约破裂清单,以及你流水线其余部分可以分支的进程状态。
保持测试枯燥且基于文件,这样未来的 Agent 无法通过 stub 网络调用来"修复"它们。以下是一个提议的测试套件。在你把输出当作证据之前,自己跑一遍。
from pathlib import Path
from check_patch import load_ledger, violations
ROOT = Path(__file__).parent
LEDGER = load_ledger(ROOT / "ledger.json")
def _diff(name: str) -> str:
return (ROOT / "fixtures" / name).read_text(encoding="utf-8")
def test_declared_edit_is_clean():
assert violations(LEDGER, _diff("ok.patch")) == []
def test_new_helper_module_is_rejected():
problems = violations(LEDGER, _diff("undeclared_path.patch"))
assert "undeclared path: src/utils/helpers.py" in problems
def test_new_env_token_is_rejected():
problems = violations(LEDGER, _diff("undeclared_env.patch"))
assert "undeclared env token: DATABASE_URL" in problems
def test_curl_is_rejected():
problems = violations(LEDGER, _diff("undeclared_cmd.patch"))
assert "undeclared command: curl" in problems
一个最小化的失败 fixture 看起来像这样。把它保存为 fixtures/undeclared_path.patch,把文案留在检查器外面。
diff --git a/src/utils/helpers.py b/src/utils/helpers.py
new file mode 100644
index 0000000..1111111
--- /dev/null
+++ b/src/utils/helpers.py
@@ -0,0 +1,3 @@
+def load_db():
+ import os
+ return os.environ["DATABASE_URL"]
把这个 session 标记为演练,不是实时流量报告。你从一个空的 feature 分支开始,写账本,然后向任何编码 Agent 请求一个"添加健康端点并带重试"的补丁。有用的部分是你对 diff 做的事,而不是哪个供应商生成的。
python3 -m venv .venv
. .venv/bin/activate
pip install pytest
python check_patch.py ledger.json fixtures/ok.patch
# expected sample: assumption ledger accepted this patch
python check_patch.py ledger.json fixtures/undeclared_path.patch
# expected sample: non-zero exit, undeclared path listed
pytest -q
如果 Agent 返回了一个创建 src/utils/helpers.py 的补丁,你不在聊天记录中争论命名。你把 diff 粘贴到检查器,把违规列表粘贴回去,并在第二次生成之前要求账本修订。这种反馈比在文件已经存在之后写的 review 评论便宜得多。
模型在检查器失败之后有用,且仅限于解释原因。你可以把违规列表粘贴过去,要求一个三点的解释:哪个假设泄露了,哪个账本字段会让它合法化,你应该添加哪个测试。你不应该让模型去发明下一个路径集,因为这会在契约文件中重现原始失败。
披露:本文是 MonkeyCode 产品推广的一部分。如果你想要这个检查器脱离你的笔记本,MonkeyCode 的免费模型访问和免费服务器选项可以托管同一个脚本,并在流程已经非零退出之后起草那些失败笔记。去掉那个托管选择,账本仍然有效——这就是把门保持为纯 Python 的意义。
你应该期待每个补丁的二元结果,不是质量仪表盘。在这个示例项目中,已声明的编辑通过,未声明的辅助模块失败,未声明的环境令牌失败,未声明的 shell 命令失败。这就是整个结果表。
你不会得到一个衡量已接受补丁是否优雅的指标。你会得到一个衡量补丁是否停留在你故意冻结的表面内的指标。如果你需要设计质量,你仍然要读 diff。
这个检查器读取统一 diff 和正则表达式,所以它会错过从未作为新增文本出现的假设。运行时导入、生成的文件和字符串构建的命令可以溜过去。环境检测也会对看起来像 MAX_RETRIES 的常量产生误报,这就是为什么 allowlist 必须保持显式且简短。
如果你在同一 commit 中更新账本和一次庞大的生成变更,账本会腐烂。你应该要求 ledger 编辑有独立的人工 commit,否则契约就变成了模型已经做过的事情的日记。这个工具也不谈许可证、安全性或产品正确性。它只回答补丁是否停留在一个声明的 envelope 内。
你不应该把这个作为支付、身份或安全关键路径代码审查的替代品。如果你的团队甚至无法为一个服务冻结路径列表,你不应该使用这个。如果你想让 Agent 通过创建文件直到测试通过来发现架构,你不应该使用这个。这个工作流假设你已经知道在一次严格限定的任务中哪些文件可能会改变。
在一次性 spike 中跳过它,那里未声明的文件就是实验本身。当一个廉价的 Agent 即将触碰一个你下个季度仍需解释的棕地树时,跑它。
你学到的是,Agent 输出的昂贵部分不是 token 量。它是把虚构的文件沉默地晋升为下一个 session 的上下文。一份 JSON allowlist 和一个三十行的检查器在审阅疲劳出现之前打断那个晋升。
当你复用这个模式时保持顺序稳定。冻结账本,生成补丁,运行检查器,然后可选地让模型解释一个失败。如果你颠倒那个顺序,你就回到了希望文案是诚实的状态。希望不是一道合并门,本案例研究的存在就是为了让你不必假装它是。