阅读需 8 分钟

MCP 进程树:发现意外子进程

了解如何在 macOS 上检查 MCP 进程树、发现意外子进程、追踪套接字,并限制高风险的本地代理执行。

MCP 进程树:发现意外子进程

本地 MCP 服务器很容易让人把干净的协议边界误认为干净的执行边界。代理发送工具调用,服务器返回结果,整个记录看起来受到控制。与此同时,服务器可能启动 shell、包运行器、语言运行时、编译器、SSH 客户端,或下载到项目目录中的辅助程序。进程树能告诉你实际工作到底去了哪里。

在审查 MCP 服务器时,我不会把它当成一个进程。我会把服务器和它启动的每个后代进程视为同一个执行家族,直到这些后代退出。这个规则能发现工具记录经常遗漏的问题:某个参数让 shell 调用了别的程序,通过 PATH 选中了辅助工具,某个子进程保留了 API 令牌,或者某个长时间运行的程序在原始请求看似完成后打开了套接字。

一次工具调用可能变成多个本地程序

MCP 服务器启动子进程,通常都有正常理由。仓库工具可能调用 git,代码工具可能运行格式化程序,基础设施工具可能调用 ssh,支持包管理的服务器可能启动运行时,然后再启动一个包中的可执行文件。出现子进程并不等于发生了入侵。

真正有用的区分是预期的辅助工具未登记的执行路径。预期的辅助工具有明确记录的用途、已知的可执行文件位置、与请求相符的参数,以及符合任务需要的生命周期。未登记的路径会破坏其中一项假设。它可能仍然无害,但在给它凭据、文件系统访问权限或网络可达性之前,必须先解释清楚。

只有父 PID 并不够。Unix 创建进程会形成血缘关系,却不会自动形成安全契约。后代进程通常会继承父进程的用户 ID、当前工作目录、环境变量、资源限制,有时还会继承打开的文件描述符。具体继承内容取决于父进程如何启动它,但子进程通常已经获得了足够的上下文,可以在关键位置以父进程的身份行动。

因此,「MCP 服务器只调用一个命令」并不是有用的安全保证。tool-server 可能启动 /bin/sh -c,后者启动 node,再启动包脚本,最后启动 curl。只记录 tool-server 的审查人员,记录的恰恰是整条链中最不值得关注的部分。

POSIX 的 exec 模型解释了其中的风险。进程可以用另一个程序替换自己的映像,同时保持 PID 不变。因此,在请求前后各做一次简单的进程列表检查,可能会漏掉短暂存在的中间进程。当任务足够敏感时,需要同时使用树状视图和事件证据。

让代理工作前先建立基线

基线是服务器在执行一次普通且获准的请求期间启动的子进程记录。它为之后的观察提供比较依据。没有基线,每个解释器看起来都可疑,每个警告都会变成一场关于「什么才算正常」的争论。

在专用的 Terminal 会话中手动启动服务器。立即记录它的 PID,然后用完整命令行捕获进程表:

ps -axo pid,ppid,user,etime,stat,command > mcp-process-baseline.txt

这些列都有意义。PID 标识进程,PPID 标识直接父进程,USER 显示进程属于哪个账户,ETIME 显示已运行时间,STAT 可以显示进程是否已停止或成为僵尸进程。COMMAND 的完整程度取决于操作系统能够报告什么,但它仍然是查找 shell 包装器、临时路径或意外参数的第一处位置。

在 macOS 上,ps 没有一种普遍可用的树状显示方式。你可以用 pgrep 枚举直接子进程:

pgrep -P 48192 -alf

48192 替换为服务器 PID。典型输出如下:

48207 /usr/bin/python3 /Users/me/tools/format_request.py
48211 /usr/bin/ssh -o BatchMode=yes build.example

然后对每个子进程 PID 重复执行该命令,直到不再出现新的后代进程。这听起来很麻烦,对一次性审查来说确实如此。但它会迫使你看到实际链条,而不是相信文档中的示意图。如果通过正常的包管理流程安装了 pstree,可以用 pstree -p 48192 获得更易读的快照。不要把显示工具本身当成安全依据,它只是帮你少输入一些内容。

运行一小组预期请求:只读仓库查询、格式化操作,以及服务器支持的话再运行一次 SSH 操作。分别保存快照。记录可执行文件路径、正常参数、正常工作目录,以及辅助进程是否会退出。构建请求中出现编译器可能是正常的,但在总结文本文件的请求中出现同一个编译器,就属于偏差。

不要以充满可变包装器和包脚本的开发检出目录建立基线,然后把结果称为可信。那种设置只能说明当前机器碰巧运行了什么,不能说明服务器应该被允许运行什么。请单独写下预期的可执行文件路径。

shell 包装器会隐藏你真正想检查的进程

调用 shell 很受欢迎,因为它让动态拼接命令变得容易。但它也会把参数边界变成文本,而文本可能通过引号、展开、通配、重定向、命令替换和 shell 函数获得新的含义。服务器作者可能本想运行格式化程序,实际启动的却是一个 shell,由它决定格式化命令究竟意味着什么。

这是审查中常见的误导来源。有人看到源代码里有一个列入允许清单的二进制名称,就假设运行的一定是它。运行时证据却显示 /bin/sh -c ...,剩余内容由 shell 稍后解析。这是两种不同的说法。

比较服务器实现中的以下两种模式:

spawn("/usr/bin/git", ["status", "--short"], {
  cwd: repositoryPath,
  shell: false
});
exec(`git -C ${repositoryPath} status --short`);

第一种模式将可执行文件和参数分开。它仍然需要验证 repositoryPath,但避免了 shell 解析。第二种模式则让 repositoryPath 有机会在引号处理不正确时改变命令语法。它还会向进程树中加入一个 shell,并可能在最终命令启动前再创建更多进程。

不要把「我们会清理输入」当成参数数组的替代方案。随着选项增加,清理规则会逐渐失效。开发人员增加功能、允许空格、添加条件参数后,旧过滤器就不再准确描述命令语法。明确的可执行文件加参数数组,能让边界在代码和审计输出中都清晰可见。

如果服务器确实需要 shell 语法,就缩小范围。使用存放在代理可写工作区之外的固定脚本,通过位置参数传递数据,并在部署记录中写下脚本路径和摘要。不要因为某条 shell 命令来自自己的源代码树,就把由工具输入拼接出的命令当成预期的子进程。

包工具是一个比较棘手的情况。包运行器之类的命令经常会执行项目提供的生命周期脚本。因此,在代理编辑过的检出目录中运行包命令,可能会启动代理写入项目配置的命令。这不是包管理器的缺陷,而是你把可变仓库当成可信指令来源后做出的执行决定。

PATH 和工作目录会改变命令的含义

git 不是一个确定的可执行文件身份,而是一个查找请求。进程会通过搜索 PATH 来解析它。如果服务器包含项目本地目录或继承 shell 配置,代理控制的仓库就可能影响这次搜索。这样的目录中如果有一个名为 git 的文件,它可能会在 /usr/bin/git 之前运行。

检查进程收到的环境,而不只是工具记录中显示的命令。在许多 macOS 版本中,ps 可以查询进程环境信息,但具体是否可用会有所不同。一种实用做法是让服务器在启动时记录一份刻意缩短且经过删减的环境信息:PATHHOMETMPDIR、当前目录,以及固定可执行文件的路径。不要把访问令牌、会话 Cookie 或完整环境转储写入共享项目日志。

将敏感命令解析为绝对路径。当代理可以修改工作树时,这不是形式主义。例如:

/usr/bin/git -C /Users/me/work/repo status --short
/usr/bin/ssh -o BatchMode=yes -o IdentitiesOnly=yes host.example

绝对路径能消除一个查找问题,但不能让目标自动变得安全。在某些配置下,git 仍然可以调用钩子或外部差异程序。SSH 也可以加载配置并调用辅助工具。工作目录还会影响相对文件读取、项目配置和临时输出。检查子进程时,要记录它的工作目录。

使用 lsof 检查进程的当前目录和打开的文件:

lsof -nP -p 48207 | sed -n '1,35p'

查看文件描述符列中的 cwdtxt 下的可执行文本,以及项目、临时目录或凭据目录中的文件。lsof 只是快照。快速启动又退出的子进程可能在你运行命令前就消失了,但它仍然很适合发现某个服务器是否悄悄让辅助工具保持运行。

/private/var/folders/... 启动的子进程,比从受管理的应用目录启动的子进程更需要审查,尤其是在它的名称看起来像普通工具时。临时位置确实是存放构建产物的合理地点,也方便进程隐藏在一次性文件中。要问清楚哪个组件创建了它,以及为什么需要在那里放置可执行文件。

网络活动必须符合请求的操作

在保险库处停止操作
锁定保险库后,所有操作都会被拒绝,包括新生成的代理进程发出的请求。

本地子进程即使没有写入可疑文件,也可能越过 MCP 请求的边界。格式化程序通常不应该打开出站连接。SSH 辅助工具应该连接到请求的主机,然后退出。安装包可能需要联系注册表,但必须明确审查这一操作,因为它可能下载并执行新代码。

检查服务器和每个存活时间足够长的子进程所使用的套接字:

lsof -nP -i -p 48211

输出通常包括协议、本地地址、远程地址和连接状态。-nP 参数会阻止名称和服务查找,让输出保持字面形式,也避免调查期间产生额外的解析流量。LISTEN 表示进程接受本地或网络连接,ESTABLISHED 表示它有一个活动对端。将每条连接与触发它的操作进行匹配。

不要对每个网络库进程都过度反应。有些开发工具会检查更新服务、证书状态或依赖元数据。但我仍然把这类行为视为收紧服务器范围的理由。一个声称只读取本地代码的工具,不应仅因为某个辅助程序觉得方便,就获得未声明的出站通道。

将凭据注入与进程观察分开。进程检查可以告诉你 curl 运行过,但不能保证放入环境变量的令牌没有被复制、记录,或被后代进程继承。常见建议是「只为这一次命令把 API 密钥放进子进程环境」。它很受欢迎,因为简单,而且演示时有效。但对于代理运行的操作,这是错误做法,因为子进程可以打印环境、继续传递令牌,或者在可见命令返回后继续存活。

Sallyport 为 HTTP 和 SSH 通道采用了不同的边界:代理不会获得存储的 API 或 SSH 凭据,而是由应用执行操作。这并不会消除检查本地 MCP 子进程的必要性,但能避免让每个辅助进程都可能持有秘密。

失败往往始于一个看似合理的便利包装器

设想一个提供 run_test 工具的本地仓库 MCP 服务器。作者希望命令足够灵活,于是让处理程序切换到仓库目录,通过包命令运行项目定义的测试脚本。代理拥有编辑检出目录的权限,因为编辑代码正是它的工作。

代理在提出修复时修改了项目脚本。服务器运行测试工具。包命令启动 shell,shell 启动运行时,运行时执行修改后的脚本。脚本启动了一个后台辅助程序,并将输出重定向到临时文件。由于前台命令成功退出,工具返回「测试通过」。

这个过程不需要什么复杂的漏洞利用。问题在于,服务器把代理可写的项目元数据当成了获准执行的配置。审查人员可能只能看到最初的 MCP 请求和成功结果,而进程树展示了真正重要的故事:

mcp-repo-server(48192)
  package-runner(48230)
    sh(48233)
      runtime(48234)
        test-script(48240)
          helper(48247)

后台辅助程序是调查重点。检查它的完整命令行、可执行文件路径、父进程链、工作目录、打开的文件、套接字和启动时间。检查服务器会话退出后它是否仍然存活。然后检查提供该脚本的项目变更。不要把这单纯归咎于代理失败。服务器允许对可变指令执行操作,并把结果称为测试。

修复方式取决于产品原本想实现的行为。谨慎的服务器可以用固定参数运行固定的测试可执行文件。如果确实需要项目定义的脚本,就应把脚本文件和包元数据当成可执行输入:展示给人批准,在受限环境中运行,并尽可能禁止后台运行。至少,服务器必须报告它启动的每个后代进程,包括在工具调用结束后仍然存活的进程。

关键区别在于:代理请求服务器执行一个已知测试命令,和代理先改变测试命令的含义再让服务器执行它,是两件不同的事。在 MCP 记录中,它们都可能显示为 run_test,但风险完全不同。

观察进程创建,而不只是快照

让 API 密钥留在 Sallyport 中
Sallyport 自己注入 HTTP 凭据,因此代理永远不会收到明文或占位符密钥。

快照能回答「现在有哪些进程还活着」,却回答不了「哪个进程运行了 200 毫秒后退出」。对于可疑或敏感的工具调用,应在请求运行期间观察进程创建事件。

macOS 为具备所需授权的安全产品提供 Endpoint Security,但普通本地工具不能默认拥有这种能力。除非你确实提供并运行了特权事件收集能力,否则不要设计一个依赖它的方案。开发调查可以使用受控的可执行文件包装器,收集启动和退出详情,或者让服务器在环境中可用的进程监视器下运行。

当你能控制服务器的命令路径时,一个简单的包装器就能让执行过程可见:

#!/bin/sh
printf '%s pid=%s ppid=%s cwd=%s argv=%s\n' \\
  "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$$" "$PPID" "$(pwd)" "$*" \\
  >> "$HOME/.local/state/mcp-exec.log"
exec /usr/bin/git "$@"

这个产物会记录包装器的身份,然后使用 exec 将包装器替换为 git,不会让包装器作为无关的父进程继续存在。它无法捕获 git 可能创建的所有后代进程,而且不应让它在参数中接收秘密。把它用于验证受控假设,不要把它当成完整审计系统。

对于已有进程,macOS 的 dtruss 可以显示系统调用,但通常需要提升权限,而且会产生大量输出。它适合调查,不适合日常监控。先使用进程树、lsof 和应用日志。只有在需要回答具体问题时再使用系统调用跟踪,例如确认某个子进程是否执行了另一条路径,或是否连接了某个套接字。

使用 UTC 时间戳,并为每个事件记录父 PID。没有父进程和时间的命令行,证据力度很弱。进程退出后 PID 可能被重新使用,因此较晚的快照可能会错误地把一个新的无关进程与旧事件关联起来。ps 提供的进程启动时间,或你自己的事件日志,都能帮助避免这个错误。

授权必须明确可执行文件边界

「允许运行 run_test 吗?」这样的确认给人的信息太少。批准者需要知道哪个已签名进程发起了操作、哪个可执行文件将运行、工作目录在哪里、操作是否能访问网络,以及它是否能继续启动其他程序。含糊的授权卡片会训练人们批准含糊的操作。

不要试图为每次 fork 调用都弹窗。这会造成授权疲劳,用户随后会机械地点击批准,或者直接关闭提示。应该审查有意义的转换:出现新的可执行文件路径、从本地工作转向网络访问、命令来自可变项目文件,或子进程在请求结束后仍然存活。

代码签名信息可以在 macOS 上提供有用的身份信号,但不要夸大它的作用。使用以下命令检查可执行文件:

codesign -dv --verbose=4 /path/to/executable 2>&1 | sed -n '1,20p'

如果存在签名,该命令会报告签名机构;如果代码未签名,则会报告错误。它只能告诉你 macOS 检查的文件由谁签名,不能告诉你命令参数是否安全、配置是否可信,或它之后是否会执行未签名脚本。子进程路径及其执行上下文需要单独检查。

Sallyport 的会话授权会首先显示连接进程的代码签名机构,这有助于决定是否允许新的代理进程开始操作。按调用密钥适用于需要每次都由人决定的特定凭据使用场景。但这两种控制都不能用来假装批准了父进程,就自动解释了它可能创建的每个后代进程。

授权语言应围绕后果来设计。「此请求将以你的账户运行 /usr/bin/ssh,并连接到 host.example」是可以审查的。「此工具需要访问权限」则不是。如果工具可以调用项目脚本,就直接说明这一点。请求具有具体形状时,人才能做出有依据的决定。

调查子进程前先限制服务器

批准实际运行的代理进程
会话授权会先根据代码签名机构识别发起连接的进程,然后才允许该进程运行。

发现意外子进程时,先保存足以解释它的证据,然后停止整个执行家族。不要一开始就删除临时文件或重启。这些操作可能会清除你唯一拥有的路径、命令行和时间证据。

如果子进程仍在运行,可以按以下顺序处理:

  1. 捕获服务器、服务器父进程和已知后代进程的 ps 输出。记录 PID、PPID、运行时间和完整命令。
  2. 对可疑进程运行 lsof -nP -p <pid>lsof -nP -i -p <pid>。将输出保存到代理工作区之外。
  3. 如果正常终止是安全的,使用 kill <pid> 停止可疑后代。只有在它无法退出且继续运行会造成不可接受的风险时,才升级使用 kill -KILL <pid>
  4. 停止 MCP 服务器,并撤销或结束发起该操作的代理会话。检查服务器退出后被重新收养的子进程。
  5. 检查可执行文件、启动来源,以及产生该命令的仓库或配置变更。

kill 不会自动终止进程组或所有子进程。后台运行的后代进程可能在直接父进程退出后继续运行。因此清理前必须先保存进程树。在受控服务器中,应将辅助工具放入专用进程组或监督范围,以便服务器在会话结束时终止整个组。在依赖该机制之前,先用一个刻意后台运行的辅助程序测试它。

还要检查持久化。在 macOS 上,如果有明确的标签或域需要检查,可以使用 launchctl print 查看已加载的启动服务。不要因为某些任务名称陌生,就盲目卸载无关任务。先将可疑子进程与其可执行文件路径、启动配置和时间戳对应起来。一个在干净重启或服务器重新启动后再次出现的进程,与只在测试会话中存在过的进程,需要采用不同的调查方式。

让每次操作事后都能解释

有用的审计记录应把经过用户批准的代理进程、请求、执行的操作和观察到的结果关联起来。进程遥测还应记录可执行文件路径、父进程链、工作目录、启动和退出时间,以及网络活动的目标。如果记录没有进程树,就无法回答辅助工具究竟是请求的一部分,还是机器上的无关进程。

不要为了让审计记录完整而把秘密写进去。保存凭据名称或不透明标识符、审查人员需要的请求元数据,以及经过删减的操作结果。通过复制承载令牌来解决来源追踪问题,只会创建第二个控制更弱的凭据存储。

事件发生后,防篡改证据很重要,因为本地日志可能被执行命令的同一账户修改。Sallyport 会把 Sessions 和 Activity 日志都写入加密、哈希链式审计日志,sp audit verify 可以在没有保险库密钥的情况下离线检查这条链。这能为代理操作提供有用证据,但进程记录仍然需要足够的上下文,解释本地服务器启动了什么。

在服务器的操作流程中加入一个审查问题:「这个请求导致哪个可执行文件运行?为什么允许该可执行文件存在于这个工作目录中?」如果无法根据请求记录和进程捕获回答,就缩小工具的权限范围。无法说明其后代进程的本地 MCP 服务器,拥有的权限已经超过操作人员能够安全审查的范围。

常见问题

如何在 macOS 上查看 MCP 服务器启动的所有子进程?

先运行 ps -axo pid,ppid,user,etime,command,找到服务器的 PID。然后运行 pgrep -P <pid> 查看直接子进程。如果安装了 pstree,也可以使用 pstree -p <pid>。要检查完整的后代进程树,因为危险进程往往是由 shell 启动的孙进程。

MCP 子进程会继承 MCP 服务器的权限吗?

子进程不会以一种简单、普遍适用的方式继承父进程的全部权限,但它通常会继承相同的用户身份、当前目录、环境变量、打开的文件描述符,以及访问本地服务的能力。当父进程拥有广泛访问权限时,这些继承内容已经足以造成损害。应把每个后代进程都视为运行在代理操作边界内的代码。

已签名的 MCP 服务器可以启动未签名的子进程吗?

会。已签名的父进程可以启动未签名的脚本、解释器,或来自可写目录的二进制文件。不要只记录顶层服务器的签名机构,也要记录每个重要可执行文件的签名信息。

如何检查 MCP 子进程是否打开了网络连接?

对某个进程运行 lsof -nP -i -p <pid>,并对可疑的后代进程重复运行。输出会显示进程是否有监听套接字或出站连接,同时避免 DNS 查询造成的时间混淆。网络连接本身不一定恶意,但必须符合任务和预期目标。

MCP 服务器使用 shell 运行命令安全吗?

shell 本身并不必然不安全,但它会隐藏许多原本便于审查的结构。shell 可以展开变量、执行命令替换、切换目录,并在真正的程序启动前调用另一个 shell。能使用明确的可执行文件和参数列表时,就不要使用 shell。无法避免时,应检查由 shell 启动的后代进程。

为什么 PATH 对本地 MCP 服务器有风险?

不安全。PATH 可能选择与作者预期不同的可执行文件,尤其是在代理控制的目录排在前面时。记录解析后的路径,为敏感命令使用绝对路径,并将可写的项目目录排除在服务的 PATH 之外。

如果 MCP 服务器启动了意外进程,我该怎么办?

如果能识别出该进程,先终止子进程,然后停止 MCP 服务器,并撤销启动它的代理会话。在清理前保存命令行、父 PID、打开的文件、网络套接字和相关日志。先重启往往会破坏解释事件所需的证据。

进程树监控能防止凭据被窃取吗?

进程检查可以显示运行了什么,以及哪个父进程启动了它。但它无法证明程序行为正确,也无法保护服务器已经放入子进程环境的凭据。应同时采用范围狭窄的凭据和清晰的操作边界,让秘密远离代理控制的进程。

应该多久审计一次 MCP 进程树?

全天候观察每个进程会带来大量噪声,不能增加多少保障。服务器启动时建立基线,检查每个新出现的可执行文件或面向网络的子进程,并在敏感操作期间调查偏差。这样审查人员得到的是一份真正读得完的短清单。

在 MCP 设置中,什么算是意外子进程?

当服务器记录的工作确实需要语言运行时、编译器、格式化程序或 SSH 客户端等辅助工具时,子进程就是预期行为。如果它的可执行文件、参数、工作目录、运行时长或网络行为与任务不符,就值得怀疑。是否陌生不如具体上下文重要。

Sallyport

Sallyport 替你的 AI 智能体执行 API 调用和 SSH 命令。密钥留在你 Mac 上的本地密钥库里;每次运行由你批准,每个操作都落入一份密封的审计日志。

© 2026 Sallyport · 依据 Apache-2.0 开源 · Oleg Sotnikov