Claude Code 101|08|File Bash Web LSP

Claude Code 的核心工具覆盖文件、Shell、Web 和 LSP,但每类工具都有不同的执行边界、结果形态和安全约束。

Claude Code 101|08|File Bash Web LSP

前两篇分别讨论了 Tool Runtime 和 Permissions。这一篇进入更具体的工程边界:File、Bash、Web、LSP 四类工具如何把 Coding Agent 连接到真实工程环境。

这四类工具很容易被写成能力清单:能读文件、能写文件、能运行命令、能查网页、能用语言服务器。但这种写法会遗漏最重要的东西。对 Agent Runtime 来说,工具的关键不是“支持什么功能”,而是“它连接了哪一种外部边界,以及这个边界应该如何治理”。

File 工具连接文件系统,核心问题是信息暴露、并发修改和覆盖风险。Bash 工具连接 shell,核心问题是任意命令执行、结构解析、沙箱和输出治理。Web 工具连接开放互联网,核心问题是 domain 授权、redirect、server-side search 与数据外泄。LSP 工具连接语言服务器,核心问题是只读代码智能查询如何受文件权限、大小限制和结果过滤约束。

它们共享 src/Tool.ts 定义的 Tool Runtime,也共享权限协议,但风险模型完全不同。这正是成熟工具系统和简单 function calling 的区别:统一抽象,不统一风险。

工程问题:工具不是能力列表,而是环境边界

一个 Coding Agent 如果只能聊天,风险相对有限;一旦它能读写文件、执行命令、联网和调用语言服务器,它就进入了用户真实工作区。这里的设计目标不是“让模型尽可能多做事”,而是“让模型在正确边界内做事”。

File 工具需要回答:读什么、读多少、文件类型是什么、是否是危险设备、写之前是否读过、文件是否被外部修改过、局部替换是否唯一、是否允许写危险路径。

Bash 工具需要回答:命令是否只读、是否可并发、是否需要沙箱、是否有重定向、是否有命令注入、是否会长时间运行、输出是否太大、是否应该后台执行。

Web 工具需要回答:访问哪个 URL、domain 是否允许、redirect 是否改变授权对象、抓到的是 markdown 还是二进制、是否需要用 prompt 提炼内容、WebSearch 的结果来自哪里。

LSP 工具需要回答:查询哪个文件、位置是否合法、文件是否过大、结果是否包含 gitignored 文件、只读查询是否仍需 Read 权限。

这些问题没有一个能靠工具 description 解决。它们必须在工具实现、权限系统和结果序列化中编码。

概念边界:这是 Tool Runtime 的落地层

第六篇讲 Tool Runtime,关注统一执行协议。第七篇讲 Permissions,关注行动风险建模。本篇关注四类基础工具如何把这些抽象落到真实工程边界上。

它与下一篇 Memory 的关系也很自然:File、Bash、Web、LSP 生成了大量观察结果,Memory 会决定哪些长期状态需要外部化、索引、召回和压缩。也就是说,工具层产生事实,记忆层管理事实的长期生命周期。

机制一:File Read 是受限观察,不是无限读取

FileRead 看起来最简单,但它其实有很多边界。src/tools/FileReadTool/FileReadTool.ts 显示它支持文本、图片、notebook、PDF 等不同输出形态,也支持 offset / limit 按行读取。它是 read-only 工具,但仍需要走 Read 权限。它还会处理 file_unchanged 这类结果,避免同一范围重复读取且文件未变时浪费上下文。

为什么读取也要复杂?因为文件系统不是一堆短文本。

第一,文件可能很大。无限读取会吞掉上下文窗口,也会让模型在噪音里丢失任务目标。offset / limit 让模型可以分段观察。

第二,文件可能不是文本。图片、PDF、notebook 需要不同解析和序列化策略。模型可见结果和用户可见 UI 不一定相同。

第三,文件可能是危险设备。读取 /dev/zero/dev/random、stdin、tty 这类设备可能无限输出、阻塞或产生不可控行为。因此 FileRead 必须识别并拒绝特殊设备。

第四,文件可能没变。重复读取同一范围如果内容未变,返回完整内容只是浪费上下文。file_unchanged 是一种上下文预算优化:模型知道“我之前读到的内容仍然有效”,不需要再次消耗完整文本。

可迁移的抽象:

type FileReadRequest = {
  path: string
  offset?: number
  limit?: number
}

type FileObservation =
  | { kind: 'text'; path: string; range: LineRange; content: string }
  | { kind: 'image'; path: string; media: ImageBlock }
  | { kind: 'notebook'; path: string; cells: NotebookCell[] }
  | { kind: 'pdf'; path: string; pages: PagePreview[] }
  | { kind: 'unchanged'; path: string; range: LineRange }

async function readFileForAgent(req: FileReadRequest, ctx: ToolContext): Promise<FileObservation> {
  const path = await authorizeRead(req.path, ctx)
  rejectDeviceOrStreamingPath(path)
  const fingerprint = await fingerprintRange(path, req.offset, req.limit)

  if (ctx.readCache.hasSameFingerprint(path, fingerprint)) {
    return { kind: 'unchanged', path, range: requestedRange(req) }
  }

  return parseByFileType(path, req)
}

这里的重点是 FileObservation。读文件不是返回字符串,而是返回一个带来源、范围、类型和缓存语义的观察。

机制二:File Edit/Write 是乐观并发控制

写文件比读文件更危险。src/tools/FileEditTool/FileEditTool.tssrc/tools/FileWriteTool/FileWriteTool.ts 体现了几个关键约束:写入前通常要求文件已被读取;如果文件自读取以来被外部修改,就不能直接覆盖;Edit 使用 old_string / new_string 做局部替换,并要求 old_string 唯一,除非显式 replace_all;Write 是全量替换,因此风险更高。

这本质上是乐观并发控制。Agent 在某一时刻读到了文件视图,然后基于这个视图生成修改。如果用户、IDE、formatter、git checkout 或另一个工具在此期间改了文件,Agent 的视图就过期了。继续写入会覆盖别人刚做的工作。

所以 FileEdit 的安全性不只来自权限,也来自 staleness check。权限回答“是否允许修改这个路径”,staleness 回答“这次修改是否基于最新事实”。两者缺一不可。

局部替换的唯一性要求也很重要。如果 old_string 出现多次,模型可能以为要改其中一处,但工具无法知道是哪一处。强制唯一可以把歧义转化为可恢复错误:模型需要读取更多上下文或提供更精确的 old_string。

可迁移伪代码:

type EditPlan = {
  path: string
  oldText: string
  newText: string
  replaceAll?: boolean
  basedOnFingerprint: string
}

async function applyEdit(plan: EditPlan, ctx: ToolContext) {
  await authorizeWrite(plan.path, ctx)

  const current = await readText(plan.path)
  const currentFingerprint = fingerprint(current)
  if (currentFingerprint !== plan.basedOnFingerprint) {
    return editError('file changed since last read')
  }

  const matches = countOccurrences(current, plan.oldText)
  if (!plan.replaceAll && matches !== 1) {
    return editError('oldText must match exactly once')
  }

  return writeText(plan.path, replace(current, plan.oldText, plan.newText, plan.replaceAll))
}

这段伪代码表达了一个重要工程原则:Agent 写入文件必须证明自己基于有效观察,而不是凭语言模型记忆覆盖磁盘。

机制三:Bash 是一个 mini runtime

BashTool 是四类工具里最复杂的。src/tools/BashTool/BashTool.tsx 的输入不只有 command,还包括 timeout、description、run_in_background、dangerouslyDisableSandbox 等字段;内部 schema 还包含 _simulatedSedEdit 这类不暴露给模型的字段,避免模型把无害命令和任意文件写入绑定起来绕过权限。

Bash 为什么需要这么多机制?因为 shell 是一个 mini runtime。它有自己的语言、控制流、环境变量、工作目录、重定向、管道、子进程、退出码和后台任务。把 Bash 当成“执行字符串”的工具,会漏掉绝大多数风险。

Claude Code 对 Bash 至少做了几类建模。

第一,只读判断。src/tools/BashTool/BashTool.tsxisConcurrencySafe() 基于 isReadOnly();只读命令可以更安全地并发执行。src/tools/BashTool/readOnlyValidation.ts 和相关 shell validation 负责更细的判断。

第二,权限判断。src/tools/BashTool/bashPermissions.ts 处理 deny / ask / allow、path constraints、sed constraints、permission mode、read-only auto allow、默认 ask、规则建议等。

第三,沙箱。src/tools/BashTool/shouldUseSandbox.ts 和 prompt 相关文件说明命令默认应该在 sandbox 中运行,dangerouslyDisableSandbox 是显式危险选项,并且受策略控制。

第四,长任务和后台任务。run_in_background 允许长命令后台运行,输出再通过后续读取观察。这避免模型被一个长期阻塞命令卡住。

第五,输出治理。Bash 输出可能非常大,因此工具结果需要 preview、持久化和模型可见摘要,而不是把 stdout/stderr 全量塞回上下文。

伪代码:

type BashRequest = {
  command: string
  timeout?: number
  runInBackground?: boolean
  disableSandbox?: boolean
}

async function runBashForAgent(req: BashRequest, ctx: ToolContext) {
  const ast = parseShell(req.command)
  const risk = classifyShellRisk(ast, req)
  const permission = await authorizeBash(req, risk, ctx)
  if (permission.behavior !== 'allow') return permission

  const sandbox = decideSandbox(req, risk, ctx.policy)
  const process = await spawnShell(req.command, { sandbox, timeout: req.timeout })

  if (req.runInBackground) {
    return backgroundHandle(process)
  }

  const output = await collectBoundedOutput(process)
  return serializeBashOutput(output, { persistLargeOutput: true })
}

这里可以看到 BashTool 同时跨越 parser、permission、sandbox、process manager、storage 和 serializer。它不是普通工具,而是工具系统里的 mini runtime。

机制四:WebFetch 与 WebSearch 的边界不同

Web 工具也不能混为一谈。WebFetch 是访问某个 URL,WebSearch 是搜索开放 Web 并返回来源。src/tools/WebFetchTool/WebFetchTool.tssrc/tools/WebSearchTool/WebSearchTool.ts 代表两条不同路径。

WebFetch 的核心边界是 domain。用户批准访问某个域名,不代表允许任意联网;预批准文档站点也只代表某种低风险 GET 访问。跨 host redirect 不能自动继续,因为授权对象已经改变。抓取后,内容可能被转成 markdown,也可能根据 prompt 提炼,二进制内容则需要保存为 artifact。

WebSearch 通常依赖模型 API 的 server-side web search 能力。它不是本地爬虫,而是在模型调用中加入 web_search 工具 schema,然后解析 server_tool_use 和 web_search_tool_result 流式结果。这里的权限与观测边界不同:runtime 需要理解搜索请求、来源、结果如何回填,而不一定直接发起本地 HTTP 请求。

二者都连接开放世界,但治理点不同:

type WebBoundary =
  | { kind: 'fetch'; url: URL; authorizedDomain: string; followRedirects: 'same-host-only' }
  | { kind: 'search'; query: string; provider: 'model-server'; requireCitations: boolean }

async function useWeb(boundary: WebBoundary, ctx: ToolContext) {
  if (boundary.kind === 'fetch') {
    await authorizeDomain(boundary.authorizedDomain, ctx)
    const response = await fetchWithoutCrossHostRedirect(boundary.url)
    return renderFetchedContent(response)
  }

  const result = await callModelSearchTool(boundary.query)
  return normalizeSearchResults(result)
}

Web 工具的工程启发是:开放世界访问必须以授权对象为中心。URL、domain、redirect、search provider、引用来源,都应该成为协议字段,而不是藏在字符串里。

机制五:LSP 是只读代码智能,但仍然需要边界

LSPTool 支持 goToDefinition、findReferences、hover、documentSymbol、workspaceSymbol、goToImplementation、call hierarchy 等操作。src/tools/LSPTool/LSPTool.ts 表明它是代码智能查询层,不是文本搜索工具。

LSP 很容易被误判为“安全只读”。但只读仍然有信息边界。LSP 查询可能让模型看到当前文件之外的大量代码位置、符号、文档注释、类型信息和引用关系;这些结果可能包含 gitignored 文件或超大文件;语言服务器本身也有状态和性能成本。因此 LSPTool 仍然要走 Read 权限,要拒绝过大文件,并对 location 结果做过滤。

LSP 的价值在于它提供语义视图。Grep 能找到字符串,LSP 能找到定义、引用、实现和调用层次。对 Coding Agent 来说,LSP 可以减少盲目搜索,提高修改准确性。但它不能绕过文件权限,也不能把整个 workspace 的语义图无限展开给模型。

伪代码:

type LspQuery =
  | { kind: 'definition'; file: string; position: Position }
  | { kind: 'references'; file: string; position: Position }
  | { kind: 'hover'; file: string; position: Position }
  | { kind: 'symbols'; file?: string; query?: string }

async function runLspQuery(query: LspQuery, ctx: ToolContext) {
  const targetFiles = filesImpliedByQuery(query)
  for (const file of targetFiles) {
    await authorizeRead(file, ctx)
    rejectIfTooLarge(file)
  }

  const locations = await ctx.languageServer.execute(query)
  return filterAndBoundLocations(locations, {
    excludeGitIgnored: true,
    maxResults: ctx.policy.maxLspResults,
  })
}

这段伪代码强调:只读工具也需要授权、大小限制和结果治理。

机制六:四类工具共享流程,但不共享风险模型

把 File、Bash、Web、LSP 放在一起看,可以看到 Tool Runtime 的统一流程:schema validation、permission、execution、UI、model serialization。但每一段的具体语义完全不同。

工具边界 核心风险 关键治理
FileRead 信息暴露、无限读取、设备文件、上下文膨胀 Read 权限、类型识别、offset/limit、设备阻断、file_unchanged
FileEdit/Write 覆盖用户修改、危险路径、歧义替换 Write 权限、先读再写、staleness check、唯一匹配、危险路径保护
Bash 任意命令执行、注入、重定向、环境污染、长任务 AST/结构解析、权限规则、沙箱、只读分类、后台执行、输出持久化
WebFetch/Search 开放世界访问、redirect、数据外泄、来源可信度 domain 权限、same-host redirect、server-side search 结果规范化、引用来源
LSP 代码信息扩散、超大结果、gitignored 文件 Read 权限、文件大小限制、location 过滤、结果上限

这张表的重点不是列功能,而是列风险。工具设计应该从风险面反推协议,而不是从模型希望调用什么函数开始。

统一执行伪代码可以写得很短:

async function executeToolUse(block: ToolUseBlock, ctx: ToolContext) {
  const tool = ctx.registry.get(block.name)
  const input = tool.modelContract.inputSchema.parse(block.input)

  const validation = await tool.governance.validate(input, ctx)
  if (!validation.ok) return toolError(validation.message)

  const permission = await tool.governance.checkPermissions(input, ctx)
  if (permission.behavior !== 'allow') return permissionToResult(permission)

  const output = await tool.execution.call(permission.updatedInput ?? input, ctx)
  return tool.presentation.toModelResult(output, block.id)
}

但千万不要因为流程短,就以为工具简单。复杂性被放进各工具的 validate、permission、call 和 result mapping 中。

机制七:工具结果会反过来塑造上下文与记忆

File、Bash、Web、LSP 的输出都可能进入下一轮上下文,也可能被后续 Memory 系统长期化。这里需要提前看到一个跨篇章的问题:工具不是只“执行动作”,它们还生产事实。

FileRead 生产代码片段和文件 fingerprint;FileEdit 生产 diff 和修改结果;Bash 生产测试结果、构建日志、错误信息;Web 生产外部文档引用;LSP 生产符号关系。这些事实如果全部进入对话历史,会挤爆上下文;如果全部丢弃,Agent 又会失去连续性。

因此工具结果应该带 metadata:来源、时间、范围、是否截断、是否可刷新、是否可持久化、是否适合长期记忆。下一篇 Memory 会继续讨论这些事实如何外部化、索引、召回和压缩。

伪代码:

type ToolFact = {
  sourceTool: string
  createdAt: Date
  subject: string
  content: string
  freshness: 'snapshot' | 'stable' | 'derived'
  visibility: 'model' | 'user' | 'memory-candidate'
  refresh?: ToolCall
}

function factsFromToolOutput(tool: RuntimeTool<any, any>, output: unknown): ToolFact[] {
  return tool.extractFacts?.(output) ?? []
}

这个抽象可以帮助 Agent Harness 避免把工具输出只当成“下一条消息文本”。工具输出是事实流,应该被上下文层和记忆层共同治理。

Claude Code 作为工程边界案例

从 Agent Runtime 的角度看,File、Bash、Web 和 LSP 不是四组功能清单,而是四类完全不同的工程边界。文件工具连接工作区状态,Bash 连接进程和环境,Web 连接开放世界,LSP 连接语言服务器和代码语义。

这也是工具边界设计的关键:统一抽象,不统一风险。工具系统应该从风险面反推协议,而不是从模型想调用什么函数开始。

工程启发

第一,为每类工具定义风险模型。File、Bash、Web、LSP 不应该共享同一个权限判断函数,只应该共享统一执行协议。

第二,读操作也要治理。读取文件、LSP 查询、Web 抓取都会扩大模型可见信息,必须有范围、大小、来源和权限边界。

第三,写操作要做乐观并发控制。先读再写、staleness check、唯一替换,是防止 Agent 覆盖用户修改的基础。

第四,Bash 要按 shell runtime 处理。解析命令结构、处理沙箱、后台任务、输出持久化和权限建议,不要把它当成普通字符串执行。

第五,Web 权限以授权对象为中心。domain、redirect、search provider 和来源引用都应该被协议化。

第六,LSP 虽然只读,但仍应受 Read 权限、大小限制和结果过滤约束。

第七,工具输出是事实流。它会进入上下文,也可能进入记忆系统,因此输出需要 metadata、截断策略和可刷新语义。

小结

File、Bash、Web、LSP 是 Coding Agent 接触真实工程环境的四个基础面。Claude Code 的设计价值不在于“拥有这些工具”,而在于每类工具都被放进 Tool Runtime 和 Permissions 的治理框架中,同时保留各自的风险模型。

到这里,Agent 已经能看到上下文、调用工具、获得结果、受权限约束。下一篇会处理另一个长期问题:这些结果和用户偏好哪些应该留下来,如何外部化、索引、召回和压缩。这就是 Memory。