文件读写与代码修改
a3647eb025c7 · 官方 Release
Agent 生成档案
Section titled “Agent 生成档案”- 章节 ID:
06-file-editing - 章节摘要:沿一次局部 edit,理解路径边界、进程内文件锁、容错匹配、diff 审批、落盘、格式化与 LSP 反馈。
- 教程版本:
v1.18.16 - 源码基线:
a3647eb025c7615159d417dcc49fc39fdaeba65b - 章节元数据:/versions/v1-18-16/data/chapters.json
- 源码映射:/versions/v1-18-16/data/source-map.json
主要源码路径
Section titled “主要源码路径”packages/opencode/src/tool/read.tspackages/opencode/src/tool/edit.tspackages/opencode/src/tool/write.tspackages/core/src/filesystem/watcher.tspackages/opencode/src/format/packages/opencode/src/lsp/lsp.ts
源码基线:
v1.18.16(提交a3647eb025c7)。本章中的参数和文件名是教学用的典型调用,用于串起已核对的源码分支,不是一次真实运行录屏。
0. 本章学习目标
Section titled “0. 本章学习目标”学完本章,你应该能:
- 画出
read / edit / write与权限、文件系统、格式化、事件、LSP 的关系图。 - 解释为什么“模型生成了正确文本”仍不等于“修改可靠”。
- 从一次
edit典型调用追到替换、diff、审批、写入、格式化和诊断。 - 区分
edit与write的使用边界,并说出二者共享的安全闸门。 - 识别文件不存在、匹配不唯一、越过工作区、审批拒绝和诊断失败等分支。
- 把“先计算、再授权、后反馈”的做法迁移到自己的 coding agent。
1. 一句话讲明白
Section titled “1. 一句话讲明白”文件工具不是三个 fs 包装器,而是一条受控变更管线:先确认目标和预期改动,再经过权限闸门落盘,最后把格式化与诊断结果反馈给下一轮 Agent。
本章只追一个问题:
模型说“把一个旧代码片段换成新片段”之后,OpenCode 怎样避免改错文件、改错位置,并让模型知道改完是否仍有错误?
v1.18.16 相比旧基线改变了什么
Section titled “v1.18.16 相比旧基线改变了什么”- 文件基础设施已下沉到
@opencode-ai/core:工具依赖FSUtil.Service、FileSystem.Event与共享Watcher.Event,旧版的本地src/file/目录不再是证据入口。 edit增加按绝对路径归一化的 semaphore,同一文件的并发修改被串行化;同时保留 BOM 与原始换行风格。- edit/write 都先计算 diff 并审批,落盘后再格式化、发布文件事件、触发 LSP;这是“先计算、再授权、后反馈”在最新版中的真实顺序。
文件锁、新文件审批与事件
packages/opencode/src/tool/edit.ts:35-120
锁和审批发生在写入之前,Watcher 事件发生在写入之后。
35const locks = new Map<string, Semaphore.Semaphore>()3637function lock(filePath: string) {定义一段可复用逻辑。38 const resolvedFilePath = FSUtil.resolve(filePath)39 const hit = locks.get(resolvedFilePath)40 if (hit) return hit按条件进入分支。4142 const next = Semaphore.makeUnsafe(1)43 locks.set(resolvedFilePath, next)44 return next返回给上一层。45}4647export const Parameters = Schema.Struct({定义并校验数据形状。48 filePath: Schema.String.annotate({ description: "The absolute path to the file to modify" }),定义并校验数据形状。49 oldString: Schema.String.annotate({ description: "The text to replace" }),定义并校验数据形状。50 newString: Schema.String.annotate({定义并校验数据形状。51 description: "The text to replace it with (must be different from oldString)",52 }),53 replaceAll: Schema.optional(Schema.Boolean).annotate({定义并校验数据形状。54 description: "Replace all occurrences of oldString (default false)",55 }),56})5758export const EditTool = Tool.define(声明可调用工具。59 "edit",60 Effect.gen(function* () {Effect 异步工作流。61 const lsp = yield* LSP.Service处理语言服务诊断。62 const afs = yield* FSUtil.Service等待 Effect 结果。63 const format = yield* Format.Service等待 Effect 结果。64 const events = yield* EventV2Bridge.Service等待 Effect 结果。6566 return {返回给上一层。67 description: DESCRIPTION,68 parameters: Parameters,69 execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>定义并校验数据形状。70 Effect.gen(function* () {Effect 异步工作流。71 if (!params.filePath) {按条件进入分支。72 throw new Error("filePath is required")失败时抛出错误。73 }7475 if (params.oldString === params.newString) {按条件进入分支。76 throw new Error("No changes to apply: oldString and newString are identical.")准备修改文件内容。77 }7879 const instance = yield* InstanceState.context等待 Effect 结果。80 const filePath = path.isAbsolute(params.filePath)81 ? params.filePath82 : path.join(instance.directory, params.filePath)83 yield* assertExternalDirectoryEffect(ctx, filePath)等待 Effect 结果。8485 let diff = ""86 let contentOld = ""87 let contentNew = ""88 yield* lock(filePath).withPermits(1)(等待 Effect 结果。89 Effect.gen(function* () {Effect 异步工作流。90 if (params.oldString === "") {按条件进入分支。91 const existed = yield* afs.existsSafe(filePath)读写本地文件。92 if (existed) {按条件进入分支。93 throw new Error(失败时抛出错误。94 "oldString cannot be empty when editing an existing file. Provide the exact text to replace, or use write for an intentional full-file replacement.",准备修改文件内容。95 )96 }97 const next = Bom.split(params.newString)98 const desiredBom = next.bom99 contentOld = ""100 contentNew = next.text101 diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))准备修改文件内容。102 yield* ctx.ask({进入权限审批。103 permission: "edit",104 patterns: [path.relative(instance.worktree, filePath)],105 always: ["*"],106 metadata: {107 filepath: filePath,108 diff,109 },110 })111 yield* afs.writeWithDirs(filePath, Bom.join(contentNew, desiredBom))准备修改文件内容。112 if (yield* format.file(filePath)) {等待 Effect 结果。113 contentNew = yield* Bom.syncFile(afs, filePath, desiredBom)等待 Effect 结果。114 }115 yield* events.publish(FileSystem.Event.Edited, { file: filePath })广播状态变化。116 yield* events.publish(Watcher.Event.Updated, {广播状态变化。117 file: filePath,118 event: "add",119 })120 return返回给上一层。
现有文件的 diff、写入与诊断
packages/opencode/src/tool/edit.ts:137-201
最终 diff 会按格式化后的内容重新计算。
137 diff = trimDiff(138 createTwoFilesPatch(准备修改文件内容。139 filePath,140 filePath,141 normalizeLineEndings(contentOld),142 normalizeLineEndings(contentNew),143 ),144 )145 yield* ctx.ask({进入权限审批。146 permission: "edit",147 patterns: [path.relative(instance.worktree, filePath)],148 always: ["*"],149 metadata: {150 filepath: filePath,151 diff,152 },153 })154155 yield* afs.writeWithDirs(filePath, Bom.join(contentNew, desiredBom))准备修改文件内容。156 if (yield* format.file(filePath)) {等待 Effect 结果。157 contentNew = yield* Bom.syncFile(afs, filePath, desiredBom)等待 Effect 结果。158 }159 yield* events.publish(FileSystem.Event.Edited, { file: filePath })广播状态变化。160 yield* events.publish(Watcher.Event.Updated, {广播状态变化。161 file: filePath,162 event: "change",163 })164 diff = trimDiff(165 createTwoFilesPatch(准备修改文件内容。166 filePath,167 filePath,168 normalizeLineEndings(contentOld),169 normalizeLineEndings(contentNew),170 ),171 )172 }).pipe(Effect.orDie),Effect 异步工作流。173 )174175 let additions = 0176 let deletions = 0177 for (const change of diffLines(contentOld, contentNew)) {遍历集合。178 if (change.added) additions += change.count || 0按条件进入分支。179 if (change.removed) deletions += change.count || 0按条件进入分支。180 }181 const filediff: Snapshot.FileDiff = {182 file: filePath,183 patch: diff,准备修改文件内容。184 additions,185 deletions,186 }187188 yield* ctx.metadata({等待 Effect 结果。189 metadata: {190 diff,191 filediff,192 diagnostics: {},处理语言服务诊断。193 },194 })195196 let output = "Edit applied successfully."197 yield* lsp.touchFile(filePath, "document")等待 Effect 结果。198 const diagnostics = yield* lsp.diagnostics()处理语言服务诊断。199 const normalizedFilePath = FSUtil.normalizePath(filePath)200 const block = LSP.Diagnostic.report(filePath, diagnostics[normalizedFilePath] ?? [])处理语言服务诊断。201 if (block) output += `\n\nLSP errors detected in this file, please fix:\n${block}`处理语言服务诊断。
2. 先看全图:文件工具在 Agent 中的位置
Section titled “2. 先看全图:文件工具在 Agent 中的位置”模型产生 tool call | vSessionTools 提供 Tool.Context | ask / metadata / abort / session 信息 v+---------------- 文件工具 ----------------+| read edit write || 读或列目录 定位旧片段并替换 全量写入 |+------------------+------------------------+ | +----------+-----------+ | | | v v v Permission FSUtil/FileSystem Format 路径/操作审批 真正读写 可选格式化 | v 文件事件 + LSP | v tool result 回到 Agent loop边界要先分清:
- 通用内核:解析目标、校验前置条件、授权、执行、报告结果。
- OpenCode 产品层:
Tool.Context、Effect service、FileSystem.Event.Edited、Watcher.Event.Updated、formatter registry、LSP diagnostics。 - 不在本章解决:模型为什么选择
edit,以及 tool result 如何驱动下一轮模型;它们分别属于 Tool 系统和 Agent loop。
SessionTools.resolve 负责把工具包装给模型,并在 Tool.Context 中接好 ask 与 metadata。文件工具只消费这个上下文,不直接关心 Provider。见 packages/opencode/src/session/tools.ts。
3. 最小机制:一次可靠修改至少有六步
Section titled “3. 最小机制:一次可靠修改至少有六步”先忽略 Effect、BOM、事件等细节,最小骨架只有这些:
1async function safeEdit(input, ctx) {定义一段可复用逻辑。2 const file = resolvePath(input.filePath)3 await checkExternalBoundary(file, ctx)45 const before = await readExistingFile(file)6 const after = replaceUniquely(before, input.oldString, input.newString)7 const diff = makeDiff(before, after)89 await ctx.ask({ permission: "edit", pattern: relative(file), diff })进入权限审批。10 await write(file, after)11 await formatIfAvailable(file)12 return await reportDiagnostics(file)处理语言服务诊断。13}
顺序很重要。若先写后审批,审批就失去意义;若不先计算 after 和 diff,用户也不知道自己批准了什么;若写完不反馈,Agent 只能把“系统调用成功”误当成“代码正确”。
4. 三种工具不是重复接口
Section titled “4. 三种工具不是重复接口”| 工具 | 输入意图 | 主要前置条件 | 输出重点 | 典型用途 |
|---|---|---|---|---|
read | 看文件或目录 | 路径、外部目录、read 权限 | 带行号内容、分页提示或附件 | 建立修改前事实 |
edit | 用 oldString 替换为 newString | 文件存在、目标可定位、edit 权限 | diff、增删行统计、当前文件诊断 | 小而明确的局部修改 |
write | 用完整内容创建或覆盖文件 | edit 权限 | diff、当前及有限数量的项目诊断 | 新文件或整体重写 |
write 的参数描述要求绝对路径,但实现仍兼容相对路径并相对 instance directory 解析。这里应以执行代码为准:packages/opencode/src/tool/write.ts、packages/opencode/src/tool/write.ts。
Java 类比可以帮助定位职责:FSUtil.Service 像基础设施 adapter,ctx.ask 像可异步等待的授权拦截器,Format.Service 像保存后 hook,LSP.Service 像 IDE 诊断服务。类比到此为止:OpenCode 用 Effect 管理依赖与失败,不是 Spring AOP 自动包住所有文件调用。
5. 一条具体源码旅程:局部替换一个配置值
Section titled “5. 一条具体源码旅程:局部替换一个配置值”假设模型发出下面的典型调用:
1{2 "filePath": "src/config.ts",3 "oldString": "const timeout = 30",4 "newString": "const timeout = 60"5}
这不是本章实际执行过的命令。我们只用它回答:源码会按什么顺序处理?
5.1 参数先挡住无意义调用
Section titled “5.1 参数先挡住无意义调用”EditTool 暴露 filePath / oldString / newString / replaceAll。执行入口会拒绝空路径,也会拒绝新旧文本完全相同。见 packages/opencode/src/tool/edit.ts、packages/opencode/src/tool/edit.ts。
随后,相对路径基于 instance.directory 解析,并先检查是否越过 instance/worktree 边界:
1const filePath = path.isAbsolute(params.filePath)2 ? params.filePath3 : path.join(instance.directory, params.filePath)4yield* assertExternalDirectoryEffect(ctx, filePath)等待 Effect 结果。
路径:packages/opencode/src/tool/edit.ts
外部目录检查并不直接“禁止一切外部路径”。它把目标父目录转成 glob,再申请 external_directory 权限。工作区内路径直接通过。见 packages/opencode/src/tool/external-directory.ts。
5.2 同一文件的 edit 被串行化
Section titled “5.2 同一文件的 edit 被串行化”EditTool 用规范化后的文件路径作为 key,为每个文件维护一个单许可 Semaphore:
1const locks = new Map<string, Semaphore.Semaphore>()23function lock(filePath: string) {定义一段可复用逻辑。4 const resolvedFilePath = FSUtil.resolve(filePath)5 // 同一路径复用一个 semaphore6}
路径:packages/opencode/src/tool/edit.ts
真正的读取、替换、审批和写入都包在 withPermits(1) 内,见 packages/opencode/src/tool/edit.ts。这减少同一进程内两个 edit 对同一文件交错执行的风险。
证据边界:源码证明了 EditTool 内部的进程内串行化;它不等于跨进程文件锁,也不证明 WriteTool 与 EditTool 之间互斥。
5.3 替换前保留原文件的文本约定
Section titled “5.3 替换前保留原文件的文本约定”文件不存在或目标是目录时会立即失败。读取时保留 BOM,并检测原文件使用 LF 还是 CRLF,再把工具输入转换成相同换行风格:
1const ending = detectLineEnding(contentOld)2const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)3const replacement = convertToLineEnding(normalizeLineEndings(params.newString), ending)
路径:packages/opencode/src/tool/edit.ts
这不是装饰性细节。若忽略换行和 BOM,模型看似只改一行,落盘后却可能让整个文件产生无意义 diff。
5.4 “找到旧文本”不是简单 String.replace
Section titled “5.4 “找到旧文本”不是简单 String.replace”replace 依次尝试精确、行裁剪、块锚点、空白归一化、缩进弹性等 replacer。只改一次时,它最终要求候选唯一;找不到会报错,多处匹配又没有 replaceAll 也会报错。见 packages/opencode/src/tool/edit.ts。
这里体现一个重要取舍:
- 只做精确匹配最可预测,但模型容易因空白差异失败。
- 允许模糊匹配更有容错性,但必须保留“唯一候选”闸门,否则可能静默改错位置。
因此,OpenCode 选择“多级容错定位 + 最终歧义失败”,而不是“永远精确”或“随便找一个最像的”。具体相似度算法在 packages/opencode/src/tool/edit.ts,第一次阅读不必逐行展开。
5.5 diff 是审批对象,不只是结果展示
Section titled “5.5 diff 是审批对象,不只是结果展示”新内容算出后,工具先生成 diff,再把它放进权限请求的 metadata:
1diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))2yield* ctx.ask({进入权限审批。3 permission: "edit",4 patterns: [path.relative(instance.worktree, filePath)],5 always: ["*"],6 metadata: { filepath: filePath, diff },7})
路径:packages/opencode/src/tool/edit.ts
ctx.ask 返回,才会 writeWithDirs。如果规则 deny 或用户 reject,控制流在这里失败,后面的落盘、格式化和 LSP 都不会执行。
5.6 落盘后,格式化可能改变最终 diff
Section titled “5.6 落盘后,格式化可能改变最终 diff”写入后调用 format.file(filePath);如果有匹配 formatter,工具会重新同步文件内容,再次计算最终 diff。见 packages/opencode/src/tool/edit.ts。
formatter 根据扩展名选择,进程执行失败会记日志而不是直接让整次编辑失败。见 packages/opencode/src/format/index.ts。因此审批时看到的是格式化前的计划 diff,tool metadata 中返回的 diff 可能包含 formatter 造成的最终变化。
这是一个真实取舍:保存后自动格式化降低风格噪声,但也意味着最终落盘内容不一定逐字等于模型提交的 newString。
5.7 事件告诉系统“文件真的变了”
Section titled “5.7 事件告诉系统“文件真的变了””修改完成后发布两个事件:
FileSystem.Event.Edited:表达 OpenCode 已编辑文件。Watcher.Event.Updated:把 add/change 变化交给共享 watcher 消费者。FileWatcher.Event.Updated:表达文件是change,创建分支则可能是add。
路径:packages/opencode/src/tool/edit.ts、packages/opencode/src/tool/edit.ts。
这比让所有消费者轮询文件更清晰。事件是 OpenCode 的产品层,不是可靠文件编辑的通用必需条件。
5.8 LSP 把“写成功”与“代码正确”分开
Section titled “5.8 LSP 把“写成功”与“代码正确”分开”最后,EditTool 通知 LSP 并读取诊断:
1yield* lsp.touchFile(filePath, "document")等待 Effect 结果。2const diagnostics = yield* lsp.diagnostics()处理语言服务诊断。3const block = LSP.Diagnostic.report(filePath, diagnostics[normalizedFilePath] ?? [])处理语言服务诊断。
路径:packages/opencode/src/tool/edit.ts
有 error 时,诊断被追加到 tool output,下一轮模型能继续修。没有 error 只能说明“当前已连接的语言服务没有报告这里的 error”,不能推导为测试通过或业务正确。LSP 的完整机制留到第 08 章。
6. 回头看 ReadTool:可靠编辑从可靠读取开始
Section titled “6. 回头看 ReadTool:可靠编辑从可靠读取开始”ReadTool 的主路径是:解析路径 → 确认 reference → stat → 外部目录审批 → read 权限 → 按类型读取。见 packages/opencode/src/tool/read.ts。
它不只处理普通文本:
- 目录会排序、分页并返回 entry 列表:
packages/opencode/src/tool/read.ts。 - 图片和 PDF 会作为附件返回:
packages/opencode/src/tool/read.ts。 - 已识别的二进制文件会失败:
packages/opencode/src/tool/read.ts、packages/opencode/src/tool/read.ts。 - 文本默认最多 2000 行,同时受 50 KB 和单行 2000 字符限制:
packages/opencode/src/tool/read.ts、packages/opencode/src/tool/read.ts。 - 输出被截断时明确给出下一次
offset:packages/opencode/src/tool/read.ts。
所以 read 的截断是上下文保护,不是“已经读完整个文件”。模型若忽略 truncated 或 continuation 提示,后续 edit 可能建立在不完整事实之上。
另一个失败分支很值得学:文件不存在时,miss 会在父目录里找最多三个相近名称作为提示;父目录本身读取失败则退化为普通 not found。见 packages/opencode/src/tool/read.ts。
7. WriteTool:同一安全骨架,不同变更语义
Section titled “7. WriteTool:同一安全骨架,不同变更语义”WriteTool 不定位旧片段,而是把完整 content 与现有内容做 diff。它仍然遵循:
解析路径 -> 外部目录检查 -> 读取旧内容并保留 BOM -> 生成 diff -> ctx.ask(permission = edit) -> writeWithDirs -> formatter -> 文件事件 -> LSP diagnostics源码主干:packages/opencode/src/tool/write.ts。
为什么 write 申请的是 edit 权限?因为权限描述的是副作用类别,不必与工具名一一对应。创建和覆盖文件都会改变工作区,所以统一归入 edit。
WriteTool 与 EditTool 的诊断范围略有不同:前者除了当前文件,还最多报告五个有 error 的其他文件;后者只把当前文件 error 拼进文本输出。见 packages/opencode/src/tool/write.ts、packages/opencode/src/tool/write.ts、packages/opencode/src/tool/edit.ts。
8. 失败路径:在哪一步停,留下什么
Section titled “8. 失败路径:在哪一步停,留下什么”| 失败 | 发生位置 | 是否已写文件 | 给 Agent 的信号 |
|---|---|---|---|
filePath 缺失或新旧文本相同 | edit 参数校验 | 否 | 明确 error |
| 工作区外路径未获授权 | external-directory ask | 否 | permission error |
| 文件不存在或路径是目录 | edit stat | 否 | 明确 error |
oldString 找不到 | replacer | 否 | 要求匹配真实文本 |
oldString 多处匹配 | replacer | 否 | 要求提供更多上下文或显式 replaceAll |
edit 权限 deny/reject | ctx.ask | 否 | permission error |
| formatter 启动/执行失败 | Format.file | 文件已写 | 主要记录日志,编辑继续 |
| LSP 无 client、启动失败或等待异常 | touchFile | 文件已写 | best-effort;可能没有诊断文本 |
最后两行尤其重要:后置检查失败不能自动回滚已经完成的文件写入。源码也没有在这里展示事务式 rollback,因此不要把整条管线称为数据库意义上的事务。
9. OpenCode 的选择:可靠性与可用性的取舍
Section titled “9. OpenCode 的选择:可靠性与可用性的取舍”选择一:文本替换,而不是让模型直接提交任意 patch
Section titled “选择一:文本替换,而不是让模型直接提交任意 patch”oldString/newString 让“预期旧状态”成为前置条件,天然带一点乐观并发控制的味道;代价是复杂替换需要更长上下文,容错匹配本身也更复杂。
选择二:审批在落盘前,格式化在落盘后
Section titled “选择二:审批在落盘前,格式化在落盘后”这样用户能看到模型计划的 diff,同时 formatter 又能统一最终风格;代价是审批 diff 与最终 diff 可能不同。工具通过重新计算 final diff 缩小这个信息差,但并没有第二次审批。
选择三:LSP 是 best-effort 后置反馈
Section titled “选择三:LSP 是 best-effort 后置反馈”语言服务坏掉时仍保留已完成编辑,避免“没有 IDE 能力就完全不能改文件”;代价是成功结果不能保证包含最新诊断。
10. 可以带走的方法
Section titled “10. 可以带走的方法”方法一:把副作用拆成“计划—授权—提交—验证”
Section titled “方法一:把副作用拆成“计划—授权—提交—验证””适用于文件修改、数据库迁移、部署和外部 API 写入。验证问题:用户批准时,是否能看到足够具体的将要发生的变化?
方法二:把前置失败和后置失败分开报告
Section titled “方法二:把前置失败和后置失败分开报告”前置失败意味着副作用未发生;后置检查失败意味着副作用已经发生,只是验证不完整。验证问题:错误信息能否让调用者判断是否需要回滚?
方法三:容错定位必须配歧义闸门
Section titled “方法三:容错定位必须配歧义闸门”可以允许空白、缩进差异,但多候选时宁可失败也不静默猜测。验证问题:模糊匹配会不会把“不确定”伪装成“成功”?
11. 费曼复述与练习
Section titled “11. 费曼复述与练习”先合上源码,用 90 秒复述:
edit为什么在写文件前同时需要旧内容、目标新内容和 diff?external_directory与edit是哪两个不同维度的权限?- formatter 或 LSP 失败时,文件可能处于什么状态?
- 为什么“LSP 没报错”不等于“修改正确”?
如果答不清,按下面的梯子重走一遍:
- 定位题:在
edit.ts找到ctx.ask,确认它位于writeWithDirs之前。 - 解释题:说明
replaceAll=false时多候选为什么必须失败。 - 对比题:列出
edit与write的一个共同点和两个差异。 - 实现题:为 mini agent 写一个
planEdit(),只返回{ before, after, diff },暂不落盘。 - 故障题:让 formatter 抛错,设计一个结果结构,明确区分
writeSucceeded与verificationSucceeded。
12. 最后复盘:文件能改了,命令能直接跑吗?
Section titled “12. 最后复盘:文件能改了,命令能直接跑吗?”本章的最小闭环是:
读取事实 -> 计算唯一修改 -> 展示 diff 并授权 -> 落盘 -> 格式化 -> 诊断反馈你现在知道,coding agent 的文件能力不是“会调 fs.writeFile”,而是能让副作用保持可见、可拒绝、可追踪,并把后果重新放回推理上下文。
但文件工具的输入结构很受控;Shell 允许模型提交一整段命令,里面可能有管道、变量、重定向、外部路径和长时间进程。下一章的问题因此更难:在命令真正启动前,runtime 能从一段 shell 文本中判断出哪些风险?