Roots 与文件系统边界控制:工作区声明、路径收敛与最小授权

系统讲解 MCP Roots 机制:客户端如何用 file:// URI 声明工作区边界、roots/list 请求与 list_changed 通知的时序、服务器侧的路径规范化与符号链接收敛算法、多根与动态切换工作区的处理、Roots 作为「建议边界」而非安全边界的本质,以及它与操作系统级沙箱的分工与常见越界陷阱。

一个文件系统工具最容易被问倒的问题不是「怎么读文件」,而是「你怎么知道该读哪个目录」。MCP 服务器是独立进程,它没有「当前项目」的概念;如果它默认以进程工作目录为根,在 IDE 里可能指向编辑器安装目录,在容器里可能是 /。让 LLM 传绝对路径更糟——模型会自信地拼出一个不存在的路径,或者更坏,拼出一个存在的敏感路径。

MCP Roots 就是回答这个问题的机制:由客户端(编辑器、IDE、Agent 宿主)声明一组「工作区根」,服务器据此理解自己在哪个范围内工作。本文要回答的是:Roots 的请求/通知时序怎么走、服务器如何把任意路径收敛到根内、多根与工作区切换怎么处理、以及为什么 Roots 是「协作约定」而不是「安全边界」。

1. Roots 是什么

1.1 定义

Roots 是客户端向服务器暴露的一组工作区根目录,用 file:// URI 表示:

{
  "roots": [
    { "uri": "file:///Users/dev/project-api", "name": "project-api" },
    { "uri": "file:///Users/dev/shared-schemas", "name": "shared-schemas" }
  ]
}

注意方向:Roots 由客户端提供,服务器消费。这与直觉相反——不是服务器告诉客户端「我能访问哪些目录」,而是客户端告诉服务器「你只该在这些目录里活动」。

1.2 为什么需要它

没有 Roots有 Roots
服务器以进程 cwd 为根,行为随启动方式漂移根由客户端显式声明,行为可预期
LLM 需自己猜绝对路径服务器可把相对路径解析到根,减少猜测
多项目同时打开时无法区分一项目一根,天然隔离
无法向用户展示「这个服务器在看哪些目录」客户端 UI 可直接展示授权范围

1.3 与相关机制的分工

机制作用是否安全边界
Roots声明工作区范围,供服务器解析相对路径否,协作约定
工具入参校验校验单次调用的路径合法性是(第一道)
OS 权限 / 沙箱文件系统 ACL、容器挂载、WASI preopen是(最终防线)
用户审批高危操作人在回路是(策略层)

一句话:Roots 决定「服务器认为自己在哪儿工作」,不决定「进程能碰什么」。混淆这两者是 Roots 最常见的误用。

2. 协议时序

2.1 服务器拉取根列表

Roots 由服务器主动查询,属于「服务器 → 客户端」方向的请求:

// 服务器 → 客户端
{ "jsonrpc": "2.0", "id": 7, "method": "roots/list" }

// 客户端 → 服务器
{
  "jsonrpc": "2.0",
  "id": 7,
  "result": {
    "roots": [{ "uri": "file:///Users/dev/project-api", "name": "project-api" }]
  }
}

2.2 能力协商

客户端必须在 initialize 时声明支持:

{
  "capabilities": {
    "roots": { "listChanged": true }
  }
}

listChanged: true 表示客户端会在根集合变化时推送通知。服务器如果收到 roots/list 的 MethodNotFound,说明客户端不支持 Roots,此时必须退回显式配置(如服务器启动参数 --allowed-dirs),而不是默默使用 cwd。

2.3 变化通知

用户切换工作区、打开新文件夹时,客户端推送:

{ "jsonrpc": "2.0", "method": "notifications/roots/list_changed" }

服务器收到后应重新拉取 roots/list(通知不带载荷,这是刻意的:根列表可能很大,且客户端不需要在每个通知里复制一遍)。

2.4 服务器端缓存与刷新

class RootRegistry {
  private roots: string[] = [];
  private version = 0;

  async refresh(client: Client): Promise<void> {
    const res = await client.listRoots();
    // 规范化:去掉末尾斜杠、解析为绝对路径、过滤非 file:// 协议
    this.roots = res.roots
      .map((r) => fileURLToPath(r.uri))
      .map((p) => path.resolve(p))
      .filter((p) => path.isAbsolute(p));
    this.version++;
  }

  snapshot(): { roots: string[]; version: number } {
    return { roots: [...this.roots], version: this.version };
  }
}

关键点:缓存快照并带版本号。工具执行期间根可能变化,长任务应绑定执行开始时的快照,避免「读到一半根没了」导致路径解析结果前后不一致。

3. 路径收敛算法

拿到根列表后,服务器要做的是把任意输入路径收敛到根内,越界就拒绝。这一步不能用字符串前缀比较。

3.1 为什么字符串比较会出错

根:/Users/dev/project-api

输入:/Users/dev/project-api-evil/secret.txt
字符串前缀匹配 → 通过(错!这是另一个目录)

输入:/Users/dev/project-api/../../etc/passwd
包含 ".." → 规范化后越界(必须在规范化后再比较)

3.2 正确的收敛流程

import fs from "node:fs/promises";
import path from "node:path";

async function resolveWithinRoots(input: string, roots: string[]): Promise<string> {
  // 1. 相对路径先拼到第一个根(或要求调用方显式给出根)
  const abs = path.isAbsolute(input) ? input : path.join(roots[0], input);

  // 2. 词法规范化:消掉 . 与 ..
  const lexical = path.resolve(abs);

  // 3. 真实路径解析:解开符号链接(这一步才是关键)
  const real = await fs.realpath(lexical).catch(() => lexical);

  // 4. 用分隔符边界比较,而不是 startsWith
  for (const root of roots) {
    const realRoot = await fs.realpath(root).catch(() => path.resolve(root));
    if (real === realRoot || real.startsWith(realRoot + path.sep)) {
      return real;
    }
  }
  throw new Error(`路径越界:${input}`);
}

四步缺一不可:

步骤拦截的攻击
相对路径拼接空路径 / 纯文件名
path.resolve../ 穿越、重复分隔符
fs.realpath符号链接指向根外
分隔符边界比较/root-evil 冒充 /root

3.3 符号链接的两个时机

符号链接的危险在于它是可变的:

t0: /root/link -> /root/safe.txt      (检查时安全)
t1: 攻击者把 link 改成 -> /etc/shadow (使用时不安全)

这就是 TOCTOU(Time-of-Check to Time-of-Use)竞态。两种缓解方式:

方式一:解析后只用真实路径(realpath 结果),不用原路径再 open
方式二:打开时带 O_NOFOLLOW 语义(Node 可用 fs.open 后 fstat 校验),
        或在容器/WASI 层禁止符号链接逃逸

仅做「检查」而不做「使用同一路径」是无效的——检查完再 fs.readFile(原路径),等于把竞态窗口重新打开。

3.4 归一化后的比较表

输入词法规范化真实路径判定
src/index.ts/root/src/index.ts同左允许
../etc/passwd/etc/passwd同左拒绝
link(指向 /root/a.txt)/root/link/root/a.txt允许
link(指向 /etc/shadow)/root/link/etc/shadow拒绝
/root-evil/x/root-evil/x同左拒绝(边界比较)

4. 多根与工作区切换

4.1 多根下的相对路径歧义

{ "roots": ["file:///work/api", "file:///work/web"] }

输入 package.json 该解析到哪个根?三种策略:

策略行为适用
显式根参数工具入参加 root: "api"明确、可审计,推荐
首根优先总是拼到第一个根单根场景的兼容做法
全根搜索逐个根探测存在性只读搜索类工具

写操作必须用显式根参数。让模型自己决定往哪个根写,等价于把「写错项目」变成常态。

4.2 工作区切换的处理

用户在 IDE 里关闭 project-api、打开 project-b
  → 客户端推送 notifications/roots/list_changed
  → 服务器重新 roots/list,得到新的根集合
  → 旧根下的资源订阅全部失效
  → 已缓存的资源内容必须作废(否则会读到上一个项目的文件)

最后一条最容易漏。资源缓存、目录索引、文件监听器都绑定了旧根,必须在版本号变化时统一清理:

onRootsChanged(async () => {
  await registry.refresh(client);
  resourceCache.clear();
  watchers.closeAll();
  server.sendNotification(ResourceListChangedNotificationSchema, {});
});

4.3 与资源订阅的配合

根变化会让资源 URI 集合整体变化,因此除了清缓存,还应主动推送 notifications/resources/list_changed,让客户端重新拉取资源列表。URI 模板的设计要让根可辨识(如 file://{root}/{path}),否则客户端无法判断某个资源是否已失效。模板与订阅的完整机制见 https://plumephp.com/mcp-resources-templates-subscriptions/。

5. Roots 不是安全边界

这是本文最重要的一节。

5.1 为什么它不构成安全边界

1. Roots 由客户端提供,服务器实现可能有 bug,也可能故意不遵守
2. 恶意服务器可以完全忽略 Roots,直接读 /etc/passwd
3. 服务器进程的文件系统权限由操作系统决定,与协议声明无关
4. 提示注入可以诱导 Agent 调用绕过校验的工具路径

结论:Roots 是给「守规矩的服务器」的协作约定。它降低误操作概率(模型不会读到无关项目),但拦不住恶意实现。

5.2 真正的防线在哪

层次手段拦截对象
协议层Roots + 路径收敛误操作、模型猜错路径
进程层独立用户、目录 ACL、只读挂载越权访问
沙箱层容器挂载白名单、WASI preopen、chroot逃逸尝试
审计层记录每次文件访问的根与真实路径事后追溯

容器与 WASI 的目录预开放(preopen)机制值得单独一提:它把「允许访问的目录」下沉到运行时,服务器代码即使被注入也无法访问未挂载的路径,可参考 WASI 文件系统沙箱 ;需要更强的进程隔离时,可结合 cgroups 与 namespaces 做挂载命名空间收敛。

5.3 用户可见性

客户端应当把根列表展示给用户,并提供「本次会话不再允许该根」的开关。用户看不到授权范围,就无法做风险判断——这与权限提示必须标注发起方是同一个道理,参见 https://plumephp.com/mcp-security-practices/ 中的用户可见性原则。

6. 落地实践

6.1 服务器启动时的三步

1. initialize 后检查客户端能力:支持 roots → 调 roots/list 缓存
2. 不支持 → 读启动参数 --allowed-dirs,或读环境变量 MCP_ALLOWED_ROOTS
3. 两者都为空 → 拒绝启动(而不是退化到 cwd)

第 3 条是硬性建议:退化到 cwd 会让同一个服务器在不同启动方式下暴露不同目录,这是最难排查的一类安全问题。

6.2 工具描述里写明根语义

{
  "name": "read_file",
  "description": "读取工作区内的文件。path 可为相对路径(相对第一个工作区根)或绝对路径,绝对路径必须位于已授权工作区根内。",
  "inputSchema": {
    "type": "object",
    "properties": {
      "path": { "type": "string", "description": "相对于工作区根的路径,如 src/index.ts" },
      "root": { "type": "string", "description": "工作区根名称,多根场景下必填" }
    },
    "required": ["path"]
  }
}

把「相对路径优先」写进描述能显著降低模型拼绝对路径的倾向。工具描述的整体设计原则见 https://plumephp.com/mcp-tools-design-patterns/。

6.3 越界时的错误信息

越界不要静默截断或改写路径,也不要把真实根路径暴露给模型:

差:返回 /etc/passwd 的内容
差:返回空文件(模型会以为是空文件,继续尝试)
好:isError=true, "路径不在已授权工作区范围内:../etc/passwd"

错误信息要能指导模型修正(提示它用相对路径),但不能泄露服务器侧的真实目录结构。

6.4 与只读/读写分级结合

只读根(shared-schemas):允许 list/read/search
读写根(project-api):允许上述 + write/edit/move

同一服务器可以服务多个根,但每个根的能力等级应独立配置。若某些操作需要更强隔离(如执行模型生成的代码),应转到独立沙箱进程处理。

7. 常见陷阱

陷阱症状解决
用 startsWith 判边界/root-evil 被误放行比较 root + path.sep
只做词法规范化符号链接逃逸加 fs.realpath
检查与使用路径不一致TOCTOU 竞态使用检查后的真实路径
根变化不清缓存读到上一个项目的文件版本号驱动统一失效
无 Roots 就退到 cwd行为随启动方式漂移显式配置或拒绝启动
把 Roots 当安全边界恶意服务器畅通无阻叠加 OS/容器/WASI 层
多根下默认首根写写错项目写操作强制显式根参数
错误信息泄露真实路径信息泄露回显用户输入而非内部路径

8. 小结

Roots 的定位可以概括为「声明范围、收敛路径、承认边界」:

层面要点
协议客户端提供 roots/list,变化时 list_changed 通知
解析相对路径优先,词法规范化 + realpath + 分隔符边界比较
多根写操作强制显式根参数,避免歧义
切换根变化 → 清缓存、关监听、推送资源列表变更
边界Roots 是协作约定,安全靠 OS 权限与沙箱
可见性客户端展示授权范围,用户可逐根关闭

把 Roots 用对的关键,是既不要高估它(它不是沙箱),也不要低估它(它决定了模型眼里的「世界」有多大)。前者防的是安全事故,后者防的是模型在错误目录里瞎转——两件事都值得认真做。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「AI工程」更多文章

  1. IDE 与编辑器集成:stdio 生命周期、工作区上下文与诊断回写
  2. 工具版本与兼容性治理:能力协商、Schema 演进与灰度下线
  3. 流式响应与进度通知:progress token、日志通知与背压处理