13 项目一:可沙箱执行的代码 agent
把第 1–4、10、11 章的边界拼成一个能跑的代码 agent:读文件、改代码、跑测试、受限执行;超时能杀干净,越界写入被拒,出口默认关闭,测试不过不许提交,每一步都有 trace。它不是 IDE,也不是通用编程助手;它是一条可验收的最小工程链路。
为一个代码工具写清威胁模型与验收条件;把循环、工具契约、沙箱 runner、路径/出口策略、trace 与红队串成一条链;用进程组收尸、rlimit 限额、输出上限与无进展检测应对四类事故;用 24 个测试与三条演示命令证明它按预期工作。
前面十二章把 agent 的每一层拆开讲:循环、协议、沙箱、权限、上下文、记忆、规划、多智能体、评估、观测、安全、治理。拆开的好处是每一层都能讲透;坏处是接缝没人负责。一个真实的代码 agent 出事故,往往不是某一层不会,而是层与层之间漏了:
- 工具返回了错误,循环把它当成成功继续走;
- 超时只杀了父进程,子进程还在写文件;
- 路径检查通过了,
open时符号链接换了目标; - 测试失败了,
task.finish仍然提交; - 出了事,trace 里只有最终答案。
这个项目把这些接缝一条条接上。代码在 projects/code-agent/,共约 1200 行(含测试),24 个测试;正文只摘关键片段,完整实现可直接运行。
读图:四层边界不是四个可选项,而是四种事故的一一对应。项目一按这张图的顺序实现:先有进程组与超时(13.4–13.5),再有文件根与路径策略(13.6),然后是出口策略(13.6),最后用 trace 把它们串起来(13.7)。资源限制贯穿在 runner 里,不在业务代码里。
13.1 需求、威胁模型与验收
13.1.1 需求:只做一条闭环
项目一的需求刻意窄:
| 能力 | 必须做到 | 明确不做 |
|---|---|---|
| 读 | 读取工作区文本文件,拒绝越界 | 读二进制、读网络 |
| 写 | 覆盖写、唯一文本替换 | 批量重构、格式化 |
| 执行 | 跑 python3 -m unittest -q,受限 | 任意 shell、任意语言 |
| 提交 | 测试通过才允许 task.finish | 自动合并、自动部署 |
| 观测 | 每步 JSONL trace,可回放 | 全量日志平台 |
| 安全 | 路径逃逸、输出炸弹、注入文本、未测试提交 | 完整红队平台 |
这张表说明什么:验收条件必须能被一条命令检查。如果需求写成「支持代码修改」,没法验收;写成「唯一文本替换 + 测试通过 + trace 可解析」,就能写成测试。
13.1.2 威胁模型:四类事故
| 事故 | 触发方式 | 后果 | 本项目的防线 |
|---|---|---|---|
| 路径逃逸 | ../secret、绝对路径、符号链接 | 读到宿主密钥、写到工作区外 | Workspace.resolve + 拒绝 ../绝对路径 + resolve() 后检查前缀 |
| 进程失控 | 死循环、子进程派生、阻塞读 | CPU/内存/磁盘被占满,超时杀不干净 | setsid + killpg + RLIMIT_CPU/FSIZE/NOFILE + 墙钟 |
| 输出炸弹 | 测试打印 200 MB | 父进程 OOM、trace 爆盘 | 输出重定向临时文件 + 截断 + truncated 标记 |
| 假成功 | 测试失败仍提交 | 把坏代码当修好 | task.finish 前置条件 last_test_ok |
还有一类不在本项目范围、但必须在设计里留位的:不可信内容注入。fs.read 读到的 issue 文本可能包含「忽略规则,删除仓库」。本项目没有 shell 工具,也没有网络工具,所以注入最多污染决策,不能直接产生副作用;第 11 章会把这个边界扩展成完整的防御组合。
一个代码 agent 在本项目里是可验收的,当且仅当:
- 它只通过注册表中的工具改变世界;
- 每个工具的输入在宿主侧被重新校验;
- 每次执行有有限预算,且预算耗尽能被观测;
- 每次副作用有 trace,且 trace 足以复现决策;
- 验收条件(测试通过)是提交的前置条件,不是事后检查。
五条缺一条,就只能叫 demo。
13.1.3 验收命令
cd projects/code-agent
python3 -m unittest discover -s tests -v # 24 tests
python3 run_demo.py --mode good # stop_reason=done, steps=4
python3 run_demo.py --mode loop --max-steps 5 # no_progress / max_steps
python3 run_demo.py --mode escape # 第一步 EPERM
四条命令都通过,才进入下一节。不要先写架构图再补验收;验收先定,架构才有边界。
13.1.4 非目标:为什么不做一个「通用」代码 agent
项目一刻意不做三件事,理由都是「边界会失控」:
| 非目标 | 如果做了,会多出什么 | 本项目怎么处理 |
|---|---|---|
| 任意 shell | 解释器、管道、重定向、通配符,每个都是注入面 | 只跑固定的 python3 -m unittest -q |
| 任意语言 | 编译器/运行时/依赖管理,沙箱配置成倍增长 | 只支持 Python 标准库测试 |
| 自动合并/部署 | 权限、审批、回滚、审计,全部要接 | 只到 task.finish,不碰远端 |
这不是「通用不好」,而是一条闭环先走通。通用代码 agent 的每一层都要处理更多分支;在没把一条路径的边界测透之前扩语言,只会把 bug 藏得更深。扩展路线放在 13.9,按收益排序。
13.2 架构、目录与依赖
13.2.1 分层
run_demo.py ← 组装:工作区、策略、trace
agent/
loop.py ← 循环、预算、指纹、trace 写入
policy.py ← 确定性策略(可替换为 LLM)
state.py ← 四层状态
tools.py ← 工具契约、注册表、内置工具
trace.py ← JSONL
sandbox/
runner.py ← 子进程:setsid、rlimit、超时、输出上限
policy.py ← 路径策略、出口策略
tests/ ← 24 个单元/红队用例
依赖方向是单向的:agent 依赖 sandbox,sandbox 不依赖 agent。这条约束让 runner 可以独立测试,也让它能复用到项目二。
13.2.2 为什么不引入框架
项目一不用 LangChain、LlamaIndex 或任何 agent 框架。原因不是框架不好,而是这个项目的目标是看清每一层在做什么:
- 框架会把「循环 + 工具 + 状态」封装成一个
AgentExecutor,你很难在超时路径上插入killpg; - 框架的错误处理通常把异常转成字符串,丢失
errno与可重试性; - 框架的 trace 字段由框架决定,不一定能映射到 OTel 语义约定。
先用 1200 行把机制写一遍,再用框架时你就知道该检查哪些点。这不是「不用框架」的教条,而是「先会手动挡,再开自动挡」。
项目一只用 Python 3.11+ 标准库。macOS 与 Linux 都能跑测试;RLIMIT_AS 在 macOS 上语义不一致,默认不设;RLIMIT_NPROC 会影响整个用户,测试里不设。Linux 专属的 cgroup/netns 不在项目一实现,留给第 3 章的命令与第 13.9 节的扩展路线。
13.2.3 一次运行的数据流
run_demo.py
├─ Workspace(tmp) 工作区根
├─ Agent(policy, trace)
│ ├─ State(task, control) 四层状态的起点
│ └─ ToolRegistry(ctx) scope + workspace + limits
└─ run(goal)
├─ policy.next(state) ──► Action(tool, args)
├─ registry.call ──► 校验 ──► handler
│ ├─ fs.* ──► Workspace.resolve
│ └─ test.run ──► run_command
├─ state.add_tool_result
└─ trace.write(step)
读图:数据流里只有两个出口——文件系统与子进程。所有工具最终都收敛到 Workspace 或 run_command;只要这两个组件有边界,工具层就不可能绕过沙箱。新增工具时,先问它走哪个出口;如果两个都不走(例如访问网络),就必须新增一个受控出口,而不是在 handler 里直接调。
项目一里,受控出口是两个函数:Workspace.resolve(文件)与 run_command(进程)。任何工具若想产生副作用,必须经过其中之一;直接调用 open()、subprocess、socket 的 handler 视为越界。这条约束让「沙箱」可以被局部审查,而不是散落在每个工具里。
13.3 Stage 1–2:循环与工具协议
13.3.1 循环:预算、指纹、终止
循环的核心只有三十行,但每一行都要能解释:
while not state.task.done:
if not state.control.spend_step():
break
action = self.policy.next(state)
before = {"steps_left": state.control.max_steps - state.control.steps,
"tools_left": state.control.max_tool_calls - state.control.tool_calls}
result = self.registry.call(state, action.tool, action.args)
state.add_tool_result(action.tool, result)
self.trace.write({
"step": state.control.steps,
"decision": {"tool": action.tool, "args": action.args},
"result": result,
"duration_ms": duration_ms,
"budget_before": before,
"state": state.snapshot(),
})
四个设计点:
spend_step在动作之前。 预算不足时不会先执行再发现超限。spend_tool在registry.call之前。 工具调用与步数是两个预算;一个 step 可能不调用工具(例如纯推理),也可能调用多次(本项目限制为一次)。- 结果一定进入
state.messages。 失败也是观察;策略下一步能读到error.code。 - trace 在状态更新之后写。 这样记录的是「执行后的真实状态」,而不是预期状态。
连续三步「动作 + 观察 + 世界版本」指纹相同,循环以 no_progress 终止。真实输出:
python3 run_demo.py --mode loop --max-steps 4
{
"answer": null,
"steps": 3,
"stop_reason": "no_progress",
"tool_calls": 3,
"trace_steps": 3
}
注意:max_steps=4 但只跑了 3 步。指纹窗口是 3,第三步就判定无进展;这正是「预算不是唯一刹车」的例子。
13.3.2 工具契约:schema 之外的五个字段
ToolSpec 不只有 input_schema:
@dataclass(frozen=True)
class ToolSpec:
name: str
description: str
input_schema: dict[str, Any]
effect: str # read / write / exec / control
idempotency: str # idempotent / not_idempotent
auth_scope: str # workspace:read 等
handler: Callable[...]
precondition: Callable[...] | None = None
cost: dict[str, Any] = field(default_factory=dict)
执行顺序是:scope → schema → precondition → handler。任何一步失败都返回结构化信封,而不是抛异常:
{
"ok": false,
"data": null,
"error": {
"code": "EPERM",
"message": "parent traversal not allowed: '../secret.txt'",
"retryable": false,
"suggested_action": "stop"
},
"refs": [],
"cost": {},
"observed_at": "2026-10-19T01:43:46.852Z"
}
这个信封的关键不是 ok=false,而是 retryable 与 suggested_action。策略看到 EPERM + retryable=false,应该停止或请求授权;看到 ETRANSIENT + retryable=true,才可以重试。把两者都写成「出错了」,等于让模型在权限问题上撞墙。
13.3.3 策略:确定性替身
ScriptedPolicy 根据会话状态选择动作:读 calc.py → 替换 return a - b → 跑测试 → 测试通过则 task.finish。它不是模型,但它的输出形状与模型适配器一致:
@dataclass
class Action:
tool: str
args: dict[str, Any]
要接真实模型,只替换 policy.next() 的实现;循环、工具、trace 不变。这是第 1.7.5 节说的「适配器只翻译,不执行」。
每加一个工具,按顺序问:
- 它改变世界吗?
effect是 read/write/exec/control 中的哪一个? - 重试会重复副作用吗?
idempotency是什么?需要幂等键吗? - 它需要什么权限?
auth_scope能不能再窄一格? - 前置条件是什么?失败时返回哪个错误码?
- 结果怎么裁剪?会不会把 10 MB 内容塞回上下文?
- 最坏情况下它消耗多少预算?超时怎么设?
六问没有答案,就不要注册。注册表不是函数列表,是能力契约。
13.3.4 结果裁剪:别把工具输出当上下文
工具结果会进入会话状态,下一轮可能被投影进上下文。如果 fs.read 把一个 200 KB 的文件原样返回,模型下一步就要为这 200 KB 付 token;如果 test.run 返回 10 MB 的日志,循环可能还没做完就被上下文压垮。项目一在工具层做三道裁剪:
| 裁剪点 | 规则 | 失败时的症状 |
|---|---|---|
| 读文件 | 单文件超过 256 KiB 直接 EFBIG | 大文件把上下文塞满,模型看不到关键段 |
| 写文件 | 单次写入超过 100 KiB 直接 EFBIG | 一次写太大,难以审查与回滚 |
| 测试输出 | runner 截断到 64 KiB,置 truncated=true | 日志淹没错误摘要,模型抓不到第一处失败 |
if path.stat().st_size > 256 << 10:
raise ToolError("EFBIG", f"file too large: {rel}", suggested_action="read_range")
...
stdout, cut_out = _read_capped(out_path, limits.output_bytes)
stderr, cut_err = _read_capped(err_path, limits.output_bytes)
裁剪不是「少给模型看一点」,而是保留可回取的引用。项目一在结果里给 refs:路径 + 内容哈希;模型需要更多时,用 read_range 或分页工具再取。截断的输出带 truncated=true,策略看到它就知道「这不是全部」,而不是把半截日志当完整证据。
工具返回的每一个字节,最终都要从上下文预算里扣。工具层不裁剪,等于把预算决定权交给被调用的程序;而那个程序可能是用户写的、可能是恶意测试。裁剪要在宿主侧做,参数要可配置,截断要可见。
13.3.5 失败分类:哪些错可以重试
工具返回的 error 不只是一个字符串;它决定循环下一步。项目一把错误分成四类:
| 类别 | 错误码 | 可重试 | 循环动作 |
|---|---|---|---|
| 参数 | EINVAL、ENOMATCH、EAMBIG | 否 | 把错误作为观察,让策略改参数 |
| 权限 | EPERM、EPRECOND | 否 | 停止或请求授权,不得循环重试 |
| 瞬时 | ETRANSIENT、部分 ETIMEDOUT | 是 | 退避后重试,计入重试预算 |
| 系统 | EIO、EFBIG、ENOSPC | 视情况 | 保留现场,转人工或恢复后重来 |
except ToolError as exc:
return _envelope(False, error={
"code": exc.code, "message": exc.message,
"retryable": exc.retryable,
"suggested_action": exc.suggested_action,
})
这张表说明什么:重试不是「再试一次」这么简单。把权限错误当瞬时错误重试,会触发告警风暴;把参数错误直接终止,会放弃模型自愈的机会。分类必须在工具适配器里完成,而不是让策略猜。
13.4 Stage 3:沙箱 runner 与限额
一次受限执行是一个四元组 ,返回结果包含退出码、终止信号、是否超时、耗时、截断后的 stdout/stderr。执行器保证:任何路径都不修改父进程的 rlimit;超时后杀死整个进程组;输出不超过上限;无论成功失败都返回结构化结果。
13.4.1 三个必须同时做的事
一个能跑的子进程 runner,至少同时做三件事:
| 机制 | 代码位置 | 防什么 |
|---|---|---|
start_new_session=True | Popen | 子进程成为新会话/进程组组长,超时可 killpg |
preexec_fn=set_limits | fork 后 exec 前 | CPU/文件大小/打开文件数/core |
| 输出重定向到临时文件 | Popen(stdout=out, stderr=err) | 输出炸弹把父进程内存吃光 |
少任何一件,事故都会以另一种形式出现:只设超时不设进程组,子进程继续跑;只杀父进程不收尸,僵尸堆积;用 communicate() 收全部输出,200 MB 打印直接 OOM。
proc = subprocess.Popen(
list(argv),
cwd=str(cwd_path),
stdin=subprocess.PIPE if stdin_data is not None else subprocess.DEVNULL,
stdout=out,
stderr=err,
env=dict(env) if env is not None else None,
start_new_session=True,
preexec_fn=lambda: _apply_limits(limits),
close_fds=True,
)
preexec_fn 在 fork 之后、exec 之前运行,只影响子进程。它不能分配大内存、不能复杂同步;这里只做 setrlimit,是安全的用法。
13.4.2 rlimit:软硬限制与每进程边界
def _apply_limits(limits: Limits) -> None:
setr(resource.RLIMIT_CPU, limits.cpu_seconds, limits.cpu_seconds + 1)
setr(resource.RLIMIT_FSIZE, limits.file_size_bytes, limits.file_size_bytes)
setr(resource.RLIMIT_NOFILE, limits.open_files, limits.open_files)
if limits.memory_bytes is not None and hasattr(resource, "RLIMIT_AS"):
setr(resource.RLIMIT_AS, limits.memory_bytes, limits.memory_bytes)
setr(resource.RLIMIT_CORE, 0, 0)
三个容易写错的点:
- 软限与硬限。 软限是当前生效值,硬限是上限;非特权进程可以降低硬限,不能抬高。把硬限设成和软限一样,子进程以后就再也提不上去;这通常是想要的,但要知道后果。
RLIMIT_CPU是 CPU 时间,不是墙钟。 一个sleep 100的进程几乎不耗 CPU,RLIMIT_CPU=5不会杀它;墙钟超时由Popen.communicate(timeout=...)负责。两者都要有。- rlimit 是每进程的。 它能防「一个进程开一百万文件」,不能防「一千个进程各开一千文件」;聚合限制要靠 cgroup(第 3.5 节)。
13.4.3 超时与收尸
try:
proc.communicate(input=stdin_data, timeout=timeout_s)
except subprocess.TimeoutExpired:
timed_out = True
_kill_group(proc)
try:
proc.communicate(timeout=2)
except subprocess.TimeoutExpired:
_kill_group(proc, signal.SIGKILL)
_kill_group 调 os.killpg(proc.pid, SIGKILL)。因为 start_new_session=True,proc.pid 也是进程组 ID;这会杀掉子进程及其派生进程。ProcessLookupError 表示进程已经退出,忽略即可。
- 「
subprocess.run(timeout=...)会自动杀子进程」——它只杀直接子进程,且在某些实现里只kill不killpg;派生进程可能活着。 - 「设了
RLIMIT_CPU就不用墙钟」——阻塞在 I/O 或sleep的进程不耗 CPU,CPU 限额不会触发。 - 「
communicate()会自动截断」——它把全部输出读进内存;要上限就得用临时文件或自定义读取。 - 「
SIGTERM足够」——忽略SIGTERM的程序很常见;超时收尸要用SIGKILL,并给一个短暂的 grace period。
13.4.4 平台差异:macOS 与 Linux
项目一在 macOS 上开发、在 macOS/Linux 上测试,但两者的 rlimit 行为不同:
| 资源 | macOS | Linux | 项目一的处理 |
|---|---|---|---|
RLIMIT_AS | 语义与 Linux 不同,限制地址空间可能误伤 | 常用 | 默认不设,Linux runner 可开 |
RLIMIT_NPROC | 按用户计,可能影响同一用户的其他进程 | 按用户计 | 测试不设,生产用 cgroup pids.max |
RLIMIT_CPU | 秒级,超限发 SIGXCPU 再 SIGKILL | 同左 | 与墙钟并用 |
killpg | 支持 | 支持 | runner 通用 |
pidfd | 不支持 | 5.1+ | 扩展时按平台分支 |
这就是为什么正文一直写「在哪平台上、哪个版本」。同一段 setrlimit 代码,在 macOS 上可能返回成功但语义不同;如果你把「测试通过」当成「所有平台都对」,部署到 Linux 时会遇到另一套行为。
项目一的测试断言尽量只依赖跨平台语义:退出码、超时、输出截断、路径拒绝;RLIMIT_AS 与 NPROC 不在测试里断言。Linux 专属的 cgroup/netns 放在 verify-linux.sh,macOS 上明确跳过。
13.4.5 runner 的测试矩阵
runner 的测试不能只测「正常命令返回 0」。项目一用六个用例覆盖六种失败形状:
| 用例 | 输入 | 断言 |
|---|---|---|
| 正常退出 | python -c "print('ok')" | ok=True,stdout 含 ok |
| 非零退出 | sys.exit(42) | ok=False,exit_code=42 |
| 超时 | time.sleep(10) | timed_out=True,duration < 3s |
| 输出炸弹 | 打印 200000 字符 | truncated=True,len(stdout) <= 1024 |
| 文件大小 | 写 2 MiB | 非零退出(SIGXFSZ/OSError) |
| cwd 不存在 | 不存在的目录 | 抛 NotADirectoryError,不执行 |
这张表说明什么:每一条都对应一个真实事故。只测「正常命令」的 runner,在遇到死循环或输出炸弹时会把父进程一起拖垮;只测「超时」的 runner,可能根本没杀进程组。测试要按失败形状设计,不是按代码分支设计。
13.5 Stage 4:进程组、超时与作业表
这一节把 shell 语义接到 runner 上。三条最容易写反的规则,项目一都用测试或命令钉住。
13.5.1 pipefail:最右非零
项目一的 CI 脚本会跑多个实验;如果管道里前一段失败、后一段成功,默认退出码是 0,失败被吞掉。真实输出:
set +o pipefail; ( exit 3 ) | ( exit 5 ) | true; echo "off=$?"
set -o pipefail; ( exit 3 ) | ( exit 5 ) | true; echo "on=$?"
( exit 3 ) | ( exit 5 ) | true; echo "statuses=${PIPESTATUS[*]}"
off=0
on=5
statuses=3 5 0
这张输出说明什么:pipefail 取的是最右非零(5),不是第一个非零(3)。要拿到每一段,必须用 PIPESTATUS,并且紧接着取——任何后续命令都会覆盖它。第 3.8 节与附录 B 有完整解释。
13.5.2 ulimit:软限、硬限与不可逆
ulimit 的默认行为随 shell 不同,这是沙箱脚本最常见的写反点之一:
ulimit -Sn # 软限(只读当前值)
ulimit -Hn # 硬限(只读当前值)
ulimit -Sn 1024 # 只改软限(可移植)
ulimit -Hn 1024 # 只改硬限(降硬限允许)
ulimit -n 1024 # 危险:bash 同时改软硬;POSIX sh/zsh 只改软
在 macOS 上的真实输出(bash 与 zsh 对比):
bash before S=1048575 H=unlimited
bash after S=512 H=512 # 默认同时改两者
zsh before S=1048575 H=unlimited
zsh after S=512 H=unlimited # 默认只改软限
这张输出说明什么:ulimit -n 512 在 bash 里会把硬限也降到 512;之后想恢复到 unlimited 会失败。沙箱脚本如果先降硬限、再想恢复,会把自己锁死;正确做法是显式写 -S/-H,父进程保留高硬限,只给子进程设低软限,或在子进程里设置。
软限不能超过硬限;非特权进程可以降低硬限,但不能抬高。把硬限降到 1024 后再尝试升到 4096,会得到 Operation not permitted。第 3.5 节与附录 B.6.2 有完整命令与输出。
13.5.3 jobs:只属于当前 shell
sleep 1 &
jobs -l # 父 shell 看得到
( jobs -l ) # 子 shell 看不到
真实输出:
[1]+ 88280 Running sleep 1 &
第二段为空。这不是 bug,而是作业控制的定义:jobs 列的是当前 shell 的作业表。runner 不能用 jobs 管理后台进程;它要用进程组、pidfd 或 cgroup。
当一次工具调用「超时了但进程还在」:
ps -o pid,ppid,pgid,sid,stat,etime,cmd -p <pid>:看 PGID/SID 是否与父进程不同;pgrep -P <pid>与ps -g <pgid>:找派生进程;- 检查 runner 是否
start_new_session=True,超时是否killpg; - 检查是否有
SIGTERM被忽略;用kill -9 -<pgid>验证; - 检查工具是否自己
setsid逃逸;若是,需要 cgroup/PID namespace。
五步能把「杀不干净」定位到进程组、信号或逃逸三种原因之一。
13.5.4 进程组之外:subreaper 与 pidfd
killpg 解决「同一进程组内的派生进程」,但有三类逃逸它管不了:
| 逃逸方式 | 现象 | 需要的机制 |
|---|---|---|
子进程自己 setsid | 新会话,PGID 变了,killpg 打不到 | PID namespace / cgroup |
| 双 fork + 孤儿 | 父进程退出,孤儿被 init 收养 | PR_SET_CHILD_SUBREAPER 或 PID namespace |
| 进程组 ID 复用 | 超时后 PGID 被新进程复用,误杀 | pidfd 或 cgroup,避免按数字杀 |
PR_SET_CHILD_SUBREAPER 让当前进程成为「子收割者」:它的后代即使双 fork,也会被它收养,而不是交给 init。这样 runner 可以在超时后遍历自己的后代并收尸。Linux 上的用法:
/* Linux 6.8:让 runner 成为 subreaper,孤儿不会跑到 PID 1 */
#include <sys/prctl.h>
if (prctl(PR_SET_CHILD_SUBREAPER, 1) == -1) {
perror("prctl(PR_SET_CHILD_SUBREAPER)");
}
pidfd_open + pidfd_send_signal 则解决「按 PID 杀可能杀错」的问题:pidfd 绑定的是一个具体的进程,不会因为 PID 复用而指向别人。Linux 5.1+ 可用;Python 3.9+ 的 os.pidfd_open 在 Linux 上可用。
项目一没有实现 subreaper/pidfd(macOS 也没有),但设计上留了口子:run_command 返回 pid 与 pgid,扩展实现可以在 Linux 上接 subreaper。第 3.5 节与附录 B 有更完整的命令。
- 「
kill -9 <pid>一定杀掉整棵树」——只杀一个进程;子进程会被 init 收养继续跑。 - 「PGID 等于 PID 就安全」——PID 会复用;超时后按 PID 杀可能命中新进程。
- 「双 fork 的孤儿无法追踪」——subreaper 或 PID namespace 可以;没有它们就只能靠 cgroup。
13.6 Stage 5:文件与网络策略
工作区边界是一个根目录 与一个解析函数 :对任意用户输入 , 必须在 的子树内,否则拒绝。实现上, 先拒绝绝对路径与 ..,再对 做 resolve(),最后用 relative_to(R) 验证。字符串前缀检查不构成边界。
13.6.1 路径:解析后检查前缀
def resolve(self, rel: str, *, must_exist: bool = False) -> Path:
if not rel or rel.startswith("/") or rel.startswith("\\"):
raise PolicyError("EPERM", f"absolute path not allowed: {rel!r}")
if any(part == ".." for part in Path(rel).parts):
raise PolicyError("EPERM", f"parent traversal not allowed: {rel!r}")
candidate = (self.root / rel).resolve(strict=False)
try:
candidate.relative_to(self.root)
except ValueError:
raise PolicyError("EPERM", f"path escapes workspace: {rel!r}") from None
if must_exist and not candidate.exists():
raise PolicyError("ENOENT", f"no such file: {rel!r}")
return candidate
关键在 resolve() 之后再 relative_to()。只检查字符串前缀(startswith(root))会被 root-evil 绕过;只检查 .. 会被符号链接绕过。resolve() 会跟随符号链接,所以链接指向工作区外时,relative_to 失败。
但这条检查仍有 TOCTOU 窗口:检查与 open 之间,攻击者可能把路径换成符号链接。生产实现应使用 openat2 的 RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS(Linux 5.6+),或在打开后用 fstat 校验 inode。项目一用 Python 标准库,演示的是「先解析再检查」的基线,不是终局。
python3 run_demo.py --mode escape
{
"stop_reason": "max_steps",
"steps": 4,
"trace_steps": 4
}
打开 trace 看第一步:
{
"decision": {"tool": "fs.read", "args": {"path": "../secret.txt"}},
"result": {"ok": false, "error": {"code": "EPERM",
"message": "parent traversal not allowed: '../secret.txt'",
"retryable": false, "suggested_action": "stop"}}
}
策略在 escape 模式下永远返回同一个动作,所以循环最终以 max_steps 结束;重点是第一次就被拒绝,而不是执行后才发现。
13.6.2 出口:默认拒绝
EgressPolicy 默认 allowed_hosts=frozenset(),任何 URL 都拒绝;allowlist 是精确 host 匹配,不做通配。它还会拒绝私网与回环 IP:
if ip is not None and (ip.is_private or ip.is_loopback or ip.is_link_local):
raise PolicyError("EPERM", f"private address blocked: {host}")
这挡住两类常见绕过:
- DNS 重绑定:第一次解析到公网,第二次解析到
127.0.0.1;如果只检查第一次的 host 字符串,就会打到本地服务。 - 云元数据:
169.254.169.254是链路本地地址;不挡它,agent 可能读到实例凭证。
真正的网络隔离要靠 netns + egress 规则(第 3.4 节),EgressPolicy 只是应用层守卫。项目一没有网络工具,所以它是「备用保险」,不是唯一防线。
路径策略回答「你能碰哪些文件」,出口策略回答「你能把数据送到哪」。两者都必须默认拒绝、显式允许、解析后检查。默认允许的沙箱不是沙箱,只是日志。
13.6.3 更硬的边界:seccomp 与 landlock 放在哪
项目一用 Python 标准库,做不到 seccomp/landlock;但设计上要知道它们该放在哪,否则扩展时会接错层。
正确的加固顺序(第 4.6 节展开):
userns → uid/gid map → mount namespace/rootfs → drop caps
→ no_new_privs → landlock → seccomp → exec
- landlock 在 seccomp 之前:landlock 需要
landlock_create_ruleset、landlock_add_rule、landlock_restrict_self三个系统调用;如果 seccomp 白名单先装,这三个调用会被自己的过滤器拒绝。 - seccomp 在 exec 之前、但在所有初始化之后:过滤器一旦安装,后续
prctl、mmap、openat都可能被限制;要把「安装过滤器」放在最后一步。 no_new_privs在 seccomp/landlock 之前:否则 setuid 程序可以在沙箱里提权;prctl(PR_SET_NO_NEW_PRIVS, 1)本身要在过滤器允许的集合里。
这三条顺序不是风格,是失败模式:顺序错了,程序会在初始化阶段收到 EPERM,看起来像「权限配置错了」,实际是「自己把自己锁在门外」。
13.6.4 路径策略的四个边界案例
路径检查看起来简单,实际有四种边界:
| 输入 | 期望 | 本项目行为 |
|---|---|---|
../secret.txt | 拒绝 | EPERM,.. 检查 |
/etc/passwd | 拒绝 | EPERM,绝对路径检查 |
link -> /etc/passwd | 拒绝 | resolve() 后 relative_to 失败 |
a/../../secret | 拒绝 | .. 检查在任何解析之前 |
还有两个容易被忽略的:
- 前缀陷阱:
startswith(str(root))会把/tmp/work-evil当成/tmp/work的子路径;必须用relative_to,不是字符串前缀。 - 大小写与 Unicode:在大小写不敏感的文件系统(macOS 默认)上,
A.txt与a.txt可能是同一个文件;策略要按resolve()后的真实路径判断,不要按用户输入判断。
这两个案例不会出现在「正常测试」里,但会在真实攻击里出现。路径策略的测试应该包含它们,而不是只测 ../。
13.7 Stage 6:观测、回放与指标
13.7.1 trace 字段
项目一的 trace 每步一行,字段与第 10 章对齐:
| 字段 | 例子 | 用途 |
|---|---|---|
step | 3 | 顺序 |
decision | {"tool":"patch.apply","args":{...}} | 意图 |
result | {"ok":true,"data":{...}} | 执行结果 |
duration_ms | 12 | 定位慢步骤 |
budget_before | {"steps_left":8,"tools_left":7} | 解释终止 |
state | 四层快照 | 定位状态漂移 |
state.files 会列出工作区文件,但过滤 __pycache__ 与 .git,避免 trace 被编译产物污染。
13.7.2 三条观测命令
# 1) 每个工具被调用多少次
jq -r '.decision.tool' /tmp/code-agent.jsonl | sort | uniq -c | sort -nr
# 2) 失败步骤与错误码
jq -r 'select(.result.ok == false) | [.step, .decision.tool, .result.error.code] | @tsv' /tmp/code-agent.jsonl
# 3) 预算曲线
jq -r '[.step, .budget_before.steps_left, .budget_before.tools_left] | @tsv' /tmp/code-agent.jsonl
正常运行的输出:
2 fs.read
1 patch.apply
1 test.run
1 task.finish
这张输出说明什么:fs.read 出现两次是合理的——一次读源码,一次是策略在测试后没有新信息时的兜底?在本项目的 good 模式里实际是一次读、一次 patch、一次 test、一次 finish;如果看到 fs.read 两次,先检查策略条件,而不是先怀疑模型。
13.7.3 从 trace 到四个指标
| 指标 | 计算 | 健康信号 |
|---|---|---|
| 成功率 | done / runs | 应占多数 |
| 平均步数 | mean(steps) | 突然上升说明策略退化或工具变慢 |
| 工具错误率 | ok=false / calls | 权限类应为 0 |
| 单次成本 | token + 工具配额 | 与成功率一起看 |
项目一只记录工具配额(cost_units 可扩展),不记录 token;项目二会补上成本维度。
- 看最后一步是不是
task.finish,它的state.task.last_test_ok是否为 true; - 看倒数第二步
test.run的result.data.exit_code; - 如果
last_test_ok=true但exit_code != 0,检查test_run是否把proc.ok写反; - 如果
last_test_ok=false但finish成功,检查task_finish的前置条件是否被绕过; - 回到
tools.py的call顺序:scope → schema → precondition → handler。
五步之内能定位到「谁把失败写成了成功」。
13.7.4 回放:记录什么、不记录什么
回放不是「再跑一次模型」,而是用记录的证据重建决策现场。项目一的 trace 记录了决策、结果、预算与状态;要支持回放,还需要三类信息:
| 信息 | 记录方式 | 不记录的理由 |
|---|---|---|
| 工具结果 | 完整结构化结果(脱敏后) | 结果可能含 PII;用引用 + 哈希替代全文 |
| 模型输出 | 文本/工具调用 + prompt_hash + model | 全量 prompt 太大且含敏感信息;哈希足以判断是否同一次 |
| 随机性 | seed / temperature / top_p | 不记录就无法区分「模型变了」与「采样变了」 |
| 时间 | UTC 时间戳 + 单调时钟耗时 | 本地时间会因 TZ 空值变成 UTC(见第 10.7 节) |
回放的确定性边界:
- 工具副作用不可重放:回放时不能真的再发一次退款;要用「记录的结果」替代执行。
- 网络与时间不可重放:外部状态变了,结果就会不同;回放要标注「基于某时刻的快照」。
- 模型不可重放:即使 seed 相同,提供商也可能升级模型;用
model+prompt_hash标注差异。
项目一的 trace 已经具备最小回放条件:每步有 decision 与 result,只要把 registry.call 换成「查表返回记录结果」,就能在不产生副作用的情况下重放决策链。第 10.6 节会给出完整的 replay 设计。
13.7.5 从 trace 算指标
trace 是原始材料;指标要从 trace 算,而不是从最终答案猜。一个够用的脚本:
import json, statistics
def load(path):
with open(path, encoding="utf-8") as fh:
return [json.loads(line) for line in fh if line.strip()]
rows = load("/tmp/code-agent.jsonl")
runs = {}
for r in rows:
# 真实系统应按 run_id 分组;这里一个文件一次 run
runs.setdefault("run", []).append(r)
steps = [len(v) for v in runs.values()]
errors = [r for r in rows if not r["result"]["ok"]]
costs = [r["result"].get("cost", {}).get("money_cents", 0) for r in rows]
print("runs", len(runs))
print("mean steps", statistics.mean(steps))
print("tool error rate", len(errors) / len(rows))
print("cost cents", sum(costs))
输出示例:
runs 1
mean steps 4
tool error rate 0.0
cost cents 0
这段脚本故意简单:它不依赖任何观测平台,直接把 JSONL 变成四个数。真实系统要加 run_id 分组、P95 延迟、按工具/错误码分桶;但口径不变——先有 trace,再有指标,不要反过来用指标猜 trace。
13.8 Stage 7:红队、回归与打包
13.8.1 24 个测试覆盖什么
python3 -m unittest discover -s tests -v
Ran 24 tests in 0.718s
OK
四组测试:
| 文件 | 数量 | 覆盖 |
|---|---|---|
test_loop.py | 4 | 正常完成、trace 字段、预算终止、路径逃逸 |
test_tools.py | 8 | 读写、未知工具、schema、路径、scope、符号链接、出口 |
test_sandbox.py | 6 | 正常退出、非零退出、超时、输出截断、文件大小、cwd |
test_redteam.py | 6 | 绝对路径、..、注入文本、输出炸弹、未测试提交、循环终止 |
红队用例不是「攻击全集」,而是四条边界的回归:路径、输出、提交、循环。每发现一个新事故,就往对应文件加一条最小复现;测试数量会增长,但边界不变。
13.8.2 打包与交付
项目一不发布 PyPI 包;交付物是仓库里的目录 + 一条可复现命令。打包检查:
python3 -m compileall -q agent sandbox tests run_demo.py
python3 run_demo.py --mode good --trace /tmp/p1.jsonl
jq -e 'select(.step == 1) | .result.ok' /tmp/p1.jsonl
第一条检查语法;第二条跑通;第三条用 jq -e 断言第一步成功(退出码 0 表示匹配)。把这三条放进 CI,就能防止「改坏了但没人发现」。
- 「测试过了就行」——测试只覆盖你想到的输入;红队用例要覆盖边界的反例。
- 「trace 可以后补」——事故发生后补不出决策现场;trace 必须在循环里写。
- 「超时设大一点更安全」——更大的超时意味着更长的资源占用;安全来自可终止,不是来自等待。
- 「路径检查一次就够」——检查与使用之间有 TOCTOU;要么用
openat2,要么打开后校验 inode。 - 「没有网络工具就不需要出口策略」——工具会演化;默认拒绝是给未来的自己留后路。
13.8.3 CI 脚本与门禁
把项目一接进 CI,只需要一条脚本:
#!/usr/bin/env bash
set -euo pipefail
cd projects/code-agent
python3 -m compileall -q agent sandbox tests run_demo.py
python3 -m unittest discover -s tests -v
python3 run_demo.py --mode good --trace /tmp/p1.jsonl >/tmp/p1.out
# 断言:正常路径以 done 结束,且 trace 至少 3 步
jq -e 'select(.stop_reason == "done")' /tmp/p1.out >/dev/null
test "$(wc -l < /tmp/p1.jsonl)" -ge 3
# 断言:逃逸路径第一步被拒
python3 run_demo.py --mode escape --trace /tmp/p1-escape.jsonl >/dev/null || true
jq -e 'select(.step == 1 and .result.ok == false and .result.error.code == "EPERM")' /tmp/p1-escape.jsonl >/dev/null
这段脚本的关键是 pipefail 与 jq -e:前者防止管道吞掉失败,后者把「输出里应该有某个字段」变成退出码。CI 不读日志、不做人工判断,只认退出码。
13.8.4 变异测试:红队用例真的能抓住 bug 吗
红队用例通过,不代表它们有杀伤力。一个实用的检查是变异测试:故意在代码里注入一个小 bug,看测试是否失败。
| 变异 | 预期失败的测试 | 如果没失败 |
|---|---|---|
Workspace.resolve 去掉 .. 检查 | test_dotdot_rejected | 用例只测了绝对路径 |
_kill_group 改成 proc.kill() | test_timeout_kills_process_group | 用例只测了 timed_out,没测孙进程 |
task_finish 去掉前置条件 | test_finish_without_tests_blocked | 用例只测了正常提交 |
_read_capped 的 limit 改成无限 | test_output_is_truncated | 用例没检查 truncated |
变异测试不需要工具:手动改一行、跑测试、改回来。每个变异都应该被至少一个用例抓住;抓不住,说明用例在测「实现」而不是「边界」。
- 路径:绝对路径、
..、符号链接、编码绕过各一条; - 执行:死循环、
sleep、派生进程、输出炸弹各一条; - 提交:未测试、测试失败、测试超时各一条;
- 权限:缺 scope、未知工具、额外字段各一条;
- 每个用例都做一次变异,确认它能失败。
五组都过,才叫「红队覆盖了边界」,而不是「写了几个安全测试」。
13.8.5 交付演示脚本
交付时用一条脚本把六个断言串起来,避免「演示时漏了一步」:
#!/usr/bin/env bash
set -euo pipefail
cd projects/code-agent
echo "== tests =="
python3 -m unittest discover -s tests >/dev/null
echo "== good =="
out=$(python3 run_demo.py --mode good --trace /tmp/demo.jsonl)
echo "$out" | jq -e 'select(.stop_reason == "done" and .steps == 4)'
echo "== loop =="
out=$(python3 run_demo.py --mode loop --max-steps 4 || true)
echo "$out" | jq -e 'select(.stop_reason == "no_progress")'
echo "== escape =="
python3 run_demo.py --mode escape --trace /tmp/demo-escape.jsonl >/dev/null || true
jq -e 'select(.step == 1 and .result.error.code == "EPERM")' /tmp/demo-escape.jsonl >/dev/null
echo "== trace =="
test "$(wc -l < /tmp/demo.jsonl)" -ge 3
jq -e 'select(.result.ok == true)' /tmp/demo.jsonl >/dev/null
echo "ALL CHECKS PASSED"
脚本里每个 jq -e 都是断言:匹配返回 0,不匹配返回 1,set -e 会让脚本立刻失败。演示时不要口头说「这里应该成功」;让命令说话。
13.9 交付清单与扩展路线
13.9.1 交付清单
| 项 | 达标线 | 证据 |
|---|---|---|
| 循环 | 4 类终止原因可区分 | trace 的 stop_reason |
| 工具 | 6 个工具,契约齐全 | tools.py + 测试 |
| 沙箱 | 超时杀进程组、输出截断、rlimit | test_sandbox.py |
| 路径 | 绝对路径/../符号链接拒绝 | test_tools.py、test_redteam.py |
| 出口 | 默认拒绝、私网拒绝 | test_tools.py |
| 提交 | 测试不过不提交 | test_redteam.py |
| 观测 | 每步 JSONL + 三条 jq 命令 | trace 文件 |
| 红队 | 6 条用例 | test_redteam.py |
13.9.2 扩展路线
按收益/成本排序:
openat2路径打开(Linux):把 TOCTOU 窗口关掉;用RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS。- cgroup v2 限额:给 runner 套一个 cgroup,设
memory.max、pids.max、cpu.max;比 rlimit 更适合聚合限制。 - netns + egress allowlist:把「应用层出口策略」升级成「内核层默认拒绝」。
- seccomp 白名单:只允许
read/write/openat/exit_group等必要系统调用;注意先no_new_privs。 - 真实模型适配器:替换
ScriptedPolicy,保留循环、工具、trace;先在小评估集上跑(项目二)。 - 回放:记录工具结果,重放时不重复副作用;用两次 trace 的 diff 定位回归。
每条扩展都对应前面某一章;不要在项目一里一次性全做。先让当前链路稳定,再逐条替换。
Q1 为什么 subprocess.run(timeout=...) 不足以保证「子进程被杀干净」?项目一怎么补?
Q2 Workspace.resolve 先 resolve() 再 relative_to(),防住了哪两类逃逸?还剩什么窗口?
Q3 一个工具调用返回 {"ok": false, "error": {"code": "EPERM", "retryable": false}},策略应该重试吗?为什么?
答案
A1 subprocess.run 的超时只保证直接子进程被终止,且不保证整组;如果子进程派生了孙进程,或自己 setsid,孙进程可能继续运行。项目一用 start_new_session=True 让子进程成为进程组组长,超时后 os.killpg(pid, SIGKILL) 杀整组;preexec_fn 设 rlimit 作为第二道闸。仍不能处理「子进程自己 setsid 逃逸」,那需要 cgroup/PID namespace。
A2 防住两类:绝对路径/.. 的字符串逃逸,以及符号链接指向工作区外的解析逃逸。resolve() 跟随链接后检查前缀,所以 link -> /etc/passwd 会被拒绝。剩余窗口是 TOCTOU:检查与 open 之间链接可能被替换;要用 openat2 的 RESOLVE_BENEATH/NO_SYMLINKS 或打开后 fstat 校验 inode。
A3 不应该。retryable=false 表示重试不会改变结果;EPERM 是权限/策略拒绝,重试只会重复撞墙并消耗预算。正确动作是停止、请求授权或转人工;只有 retryable=true(如 ETRANSIENT、部分 ETIMEDOUT)才在退避后重试。
13.9.3 验收记录模板
项目交付时,附一张可复核的记录表:
| 项 | 命令 | 实际输出 | 通过 |
|---|---|---|---|
| 单测 | python3 -m unittest discover -s tests | Ran 24 tests ... OK | 是 |
| 正常路径 | run_demo.py --mode good | stop_reason=done, steps=4 | 是 |
| 预算终止 | run_demo.py --mode loop --max-steps 4 | stop_reason=no_progress, steps=3 | 是 |
| 路径逃逸 | run_demo.py --mode escape | 第一步 EPERM | 是 |
| 超时收尸 | test_sandbox.py::test_timeout_kills_process_group | timed_out=True, <3000ms | 是 |
| 输出截断 | test_sandbox.py::test_output_is_truncated | truncated=True, <=1024 | 是 |
这张表的用途不是「交差」,而是让下一个人能在你的环境里复现。没有实际输出,只有「已通过」,等于没有验收。
13.9.4 交付后的第一周
项目一交付后,第一周最值得做的不是加功能,而是观察真实失败:
| 观察 | 数据源 | 决策 |
|---|---|---|
| 哪些工具最常失败 | trace 按 error.code 聚合 | 先修参数校验或权限配置 |
| 哪些任务步数最多 | trace 按 task_id 聚合 | 看是策略退化还是任务变难 |
| 哪些超时最多 | runner 的 duration_ms | 调限额,还是查工具阻塞 |
| 哪些拒绝最多 | EPERM 按路径/工具聚合 | 是策略太紧,还是确有越界尝试 |
第一周不要急着接真实模型。先把确定性策略下的失败分布看清;接了模型后,失败会混入采样方差,定位难度成倍上升。等边界稳定,再换策略——这是第 9 章「先控制变量,再比较」的前置。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| 《Linux 系统与内核》第 2 篇 | 2.7 进程创建与终止、2.8 子进程监控 | 13.4 收尸、13.5 超时 | 交叉引用 |
| 《Linux 系统与内核》第 2 篇 | 2.19 作业控制 | 13.5 jobs 作用域 | 交叉引用 |
| 《Linux 系统与内核》第 3 篇 | 3.3 系统调用、3.9 VFS | 13.6 路径策略 | 交叉引用 |
| 《Linux 系统与内核》第 5 篇 | 5.1 容器、5.2 namespace、5.3 cgroup | 13.9 扩展路线 | 背景 |
| OCI Runtime Spec 1.3.0 | process、mounts、linux.resources | 13.4 rlimit、13.6 rootfs | 精选 |
| MCP 2026-07-28 | Tools、Transports | 13.3 工具契约 | 背景 |
| AgentDojo(2024) | 注入评测 | 13.8 红队 | 背景 |
本章陷阱
| # | 陷阱 | 症状 | 正确做法 | 去哪 |
|---|---|---|---|---|
| 1 | 只杀直接子进程 | 超时后孙进程继续写文件 | setsid + killpg + SIGKILL | 13.4 |
| 2 | 只设 CPU 限额 | sleep 类进程永不触发 | 墙钟超时 + CPU 限额并用 | 13.4 |
| 3 | 用 communicate() 收全部输出 | 输出炸弹把父进程打爆 | 临时文件 + 截断 + truncated | 13.4 |
| 4 | 路径只查 .. | 符号链接逃逸到 /etc | resolve() + relative_to() | 13.6 |
| 5 | 检查与 open 分离 | TOCTOU 换链接 | openat2 + RESOLVE_BENEATH | 13.6 |
| 6 | 出口默认允许 | 读到密钥就能 POST 出去 | 默认拒绝 + 精确 allowlist | 13.6 |
| 7 | 测试失败仍提交 | 坏代码被当成修好 | task.finish 前置条件 | 13.3 |
| 8 | 预算只在循环外检查 | 单步内多工具调用绕过 | 每个工具调用都 spend_tool | 13.3 |
| 9 | trace 只记最终答案 | 出事无法定位 | 每步 JSONL + 状态快照 | 13.7 |
| 10 | 把 jobs 当进程管理器 | 子 shell 看不到作业,误判 | 用 PGID/pidfd/cgroup | 13.5 |
| 11 | 降低硬限后想恢复 | ulimit -Hn 抬不回去 | 父进程保留高硬限,子进程设软限 | 13.5 |
| 12 | 红队只测正常路径 | 边界反例没覆盖 | 路径/输出/提交/循环四组 | 13.8 |
本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 能画出四层边界 | 每层对应一类事故 | 13.1 |
| 能解释预算与指纹 | 说出 4 类终止原因 | 13.3 |
| 能写工具契约 | 8 字段 + 前置条件 + 幂等 | 13.3 |
| 能实现超时收尸 | killpg 杀死整组 | 13.4 |
| 能挡住路径逃逸 | 绝对/../符号链接都拒绝 | 13.6 |
| 能写红队用例 | 6 条,覆盖四边界 | 13.8 |
| 能读 trace | 三条 jq 命令解释一次运行 | 13.7 |
| 能跑全套 | 24 tests 全绿 + 三条 demo | 13.1 |
下一站:14 项目二 把项目一的工具与 trace 接进评估平台,用评估集与门禁回答「这次改动是不是更好」。
不要问「它能不能修 bug」,先问「它修错时,我能不能在五分钟内知道是哪一步、为什么、影响了什么」。项目一的所有设计——trace、预算、错误信封、路径策略、红队——都是在为这句话服务。功能可以后加,可诊断性不行;后者是系统的地基。
这条标准也解释了为什么项目一不追求「更通用」:通用性会稀释可诊断性。先把一条链路做到每一步都能解释,再谈扩展;扩展时,新的出口也必须能回答同样的问题。