MCP stdio 与 Unix 套接字取决于信任边界
比较桌面密钥代理中的 MCP stdio 与 Unix 套接字,涵盖生命周期、权限、调用方身份、清理和部署取舍。

如果授权属于单个代理进程,桌面密钥代理应使用 stdio;如果授权属于共享的本地服务,则应使用 Unix 域套接字。这听起来像传输方式的选择,但字节传递反而是简单的部分。难点是决定谁能触发操作、权限持续多久,以及任一方崩溃后留下什么证据。
我见过团队因为套接字看起来更像基础设施而从它开始,随后花数周重建进程树本来已经提供的调用方归属和生命周期控制。我也见过 stdio 被启动器、多路复用器和隐藏状态一层层拉长,硬做成常驻服务。两种传输都能安全。只要生命周期和身份假设与所授予的权限不匹配,任何一种都可能悄悄破坏威胁模型。
本文假设有一个 macOS 桌面代理,它保存 API 或 SSH 凭据,并代表本地 AI 代理执行操作。代理绝不能拿到密钥。因此,代理保护的不只是本地缓存,而是决定哪个进程可以把保存的权限转化为外部副作用。
先选信任边界,再选传输
正确的传输方式取决于授权单位。如果用户批准的是一次代理运行,作为子进程启动的 stdio 服务器就提供了自然边界:管道只为这段进程关系存在,一端退出时就会关闭。如果多个工具应该共享一个已解锁的代理,Unix 套接字能提供稳定的会合点,但代理必须在套接字之上建立自己的会话边界。
把威胁模型写成参与者和操作,不要只写一个笼统的「安全 IPC」愿望。列出哪些本地进程可以连接、代理保存哪些凭据、这些凭据允许什么操作,以及哪些事件必须终止访问。把以同一登录用户身份运行的恶意软件也算进去。文件权限常能挡住其他用户,却很难阻止同一账户下的另一个进程。还要考虑复制出来的代理二进制文件、被修改的插件、代理启动的 shell,以及可见任务结束后仍存活的旧进程。
接着定义批准究竟意味着什么。它可能授权一个已签名可执行文件、一个操作系统进程、一棵进程树、一个终端任务,或者登录用户拥有的所有客户端。这些承诺并不相同。传输方式无法替你选择,只能让某些承诺更容易执行。
撤销是很实用的设计测试。问清楚代理可以立即撤销什么,而不必锁住整个保险库。使用 stdio 时,关闭管道或终止子进程会结束该通道,不过如果启动器没有妥善处理,后代进程仍可能继承文件描述符。使用套接字时,关闭一个已接受连接会终止该客户端,而监听套接字仍可用。如果授权记录比连接活得更久,两种关闭操作都不够。
除非代理还暴露网络监听器,否则不要把网络攻击者放在这个本地决策的中心。更直接的风险是身份混淆、登录会话带来的环境权限、描述符继承、文件系统套接字被替换,以及授权持续时间超过用户批准的工作。
Stdio 把通道绑定到进程生命周期
当代理垫片应当随代理进程一起启动和终止时,基于 stdio 的 MCP 最合适。客户端启动服务器命令,把 JSON-RPC 消息写入标准输入,再从标准输出读取响应。EOF 是操作系统提供的生命周期信号,不是藏在应用心跳中的约定。
Model Context Protocol 传输规范还提出一条很实用的运行要求:stdio 服务器不得向 stdout 写入协议外数据。日志应写到 stderr。人们很容易把这当成单纯的帧格式卫生,但这条规则能防止诊断文本在安全边界内变成无效消息。把 stdout 当作协议内存。首个响应发出前就配置好库和崩溃报告器,避免它们污染输出。
Stdio 不需要文件系统名称、套接字目录、权限模式、发现文件或常驻监听器。代理配置只需指向一个可执行文件及其参数。对桌面产品来说,这确实降低了部署成本,因为无需寻找端点,也没有陈旧路径要修复。更新也有明确生效点:下一次启动垫片时使用新可执行文件。正在运行的会话可能继续使用旧版本,所以应在会话中记录可执行文件身份和版本,而不是假定安装后所有活跃客户端都已改变。
进程关系是有用的证据,但不是完整身份。代理可以检查连接到私有后端的垫片,垫片也能检查父进程。父进程退出后,其进程 ID 可能被重用;可见代理与垫片之间也可能隔着启动器。进程仍存活时就捕获审计令牌或代码签名信息。不要只保存 PID,留到以后解析。
管道也有继承陷阱。如果启动器把描述符标为可继承,孙进程就能在代理退出后继续持有写入端。代理会一直等待永远不会到来的 EOF。在平台没有自动处理的地方设置 close on exec,派生进程后立刻关闭未用端,并让会话同时监视管道和预期的客户端进程。EOF 应撤销访问,进程退出也应独立撤销访问。
并发能力有意保持狭窄。一个 stdio 服务器实例通常只服务一个客户端进程。隔离因此很清楚,代价是每个代理多一个进程。如果真正的保险库位于桌面应用内,stdio 可执行文件通常只是一个小垫片,把类型明确的请求转发给应用。内部这一跳仍需认证和会话绑定。MCP 边缘使用 stdio,并不能让未认证的共享后端变得安全。
Unix 套接字建立的是服务生命周期
如果代理本来就是常驻服务,并且会不断接收独立客户端,Unix 域套接字很合适。监听端点在客户端退出后仍存在,因此菜单栏应用可以接受调用,而不必让每个代理拥有代理进程。多客户端、背压和集中升级都容易处理。代价是,服务必须明确定义原本由单客户端进程隐式提供的每一个边界。
套接字路径名用于发现,不用于认证。能找到它的客户端仍需连接权限,能连接的客户端仍需经过授权决定。把套接字放在用户或应用控制的目录中,以 0700 模式创建目录,并把套接字设为 0600。不要在共享可写目录中使用可预测路径。临时目录的 sticky bit 能阻止一部分删除攻击,却不会把该目录变成可信命名空间。
权限不只取决于最终模式。进程 umask 会影响创建。现有父目录决定其他账户能否遍历路径或替换名称。符号链接或陈旧节点可能已经占用目标路径。服务应打开可信父目录,在不跟随链接的情况下检查现有条目,并且只有证明它是已终止实例的套接字或属于当前安装的路径后才能删除。盲目 unlink 一个已知名称会制造替换竞态。
在 macOS 上,服务可以通过 getpeereid 等系统调用获取已接受本地套接字的对端凭据,更底层的 API 还能提供审计令牌。用户和组 ID 只能说明哪个账户拥有对端进程,不能说明用户打算授权哪个应用。要回答后一个问题,应把审计令牌解析到仍存活的进程,再根据产品声明的策略评估代码签名身份、可执行文件路径和启动环境。
套接字让多路复用变得简单,但多路复用也会模糊会话。绝不能因为一个连接成功,就批准其中所有携带任意会话标识符的请求。服务器应分配连接身份,把授权绑定到该身份,并拒绝客户端自行切换身份的尝试。如果辅助程序在服务重启后重新连接,应要求新的授权决定,除非威胁模型明确允许批准跨越该事件继续有效。
性能很少决定这个选择。本地管道和 Unix 套接字处理小型 JSON-RPC 请求的速度,都远快于一次 HTTP API 或 SSH 操作。若代理传输大型响应正文,可以实测;但不要为了猜测中的本地 IPC 降耗,放弃清晰可读的权限模型。
套接字权限无法证明用户意图
模式 0600 表示其他用户 ID 运行的进程无法通过普通自主访问检查打开套接字。它不表示同一用户 ID 下的每个进程都配得上存储的凭据。桌面恶意软件、不受信任的软件包脚本、编辑器扩展和已批准代理往往共用一个用户 ID。
这个区别经常被混淆,因为 Unix 权限具体且容易检查。团队看到私有套接字就把端点称为已认证。它只在账户边界上完成了认证。如果威胁模型只防其他登录用户,这可能足够。面向自主工具的密钥代理通常还声称能在单个登录会话内实施控制,因此需要更窄的身份。
在 macOS 上,代码签名信息可以缩小身份范围。验证活的对端,而不是请求中提供的路径字符串。路径可能指向已替换文件,进程也可能在更新后继续执行旧的映射映像。记录操作系统为该进程报告的签名机构和 designated requirement。明确规定临时签名的开发构建如何处理;静默地把它们当成生产应用,会让开发便利变成绕过路径。
调用方身份和调用方权限也不同。身份回答哪个进程打开了连接。权限回答该进程此刻是否可以用某项凭据执行某个操作。一个签名有效的代理可能正在处理未经审查的代码库,其提示也可能受到恶意内容影响。仅因可执行文件熟悉就放行所有操作,是把来源误当成同意。
对于 stdio,启动器可以在启动垫片时提供直接父进程的身份,代理再把这份证据绑定到新的通道随机值。对于套接字,代理可以在 accept 时推导对端身份,并绑定到已接受的文件描述符。两种设计都应让代理成为身份来源。client_name 之类的字段只能用于显示,不能作为证据。
诚实的批准卡应展示操作系统能够确定的事实,以及用户正在批准的内容。如果包装脚本让顶层代理无法可靠归属,就明确说明或拒绝调用。沿进程树向上猜测,直到发现熟悉名称,会产生由攻击者控制的搜索路径。
清理也是安全模型的一部分
Stdio 清理围绕描述符和子进程。正常情况很简单:客户端关闭 stdin,服务器看到 EOF,完成或取消活跃工作,然后退出。异常情况更值得注意。客户端可能崩溃,而后代仍持有管道;服务器可能挂起,而客户端以为它已经终止;如果撤销没有传到真正执行操作的工作线程,特权操作仍可能在撤销后完成。
给每个请求分配由代理生成的操作 ID,并设置 queued、executing、completed、denied 或 indeterminate 等状态。通道关闭时立刻撤销后续请求。对于已经发往远程 API 的操作,除非远端支持取消,否则不要声称它已取消。如果本地进程在得知结果前退出,就记录 indeterminate。这样可以防止重试循环悄悄把操作执行两次。
套接字清理涉及两个独立对象:已接受连接和监听路径名。发生协议错误、授权失败、空闲过期或服务关闭时,关闭相应客户端连接。只有当服务仍拥有同一个文件系统对象时才删除路径名。重启时出现 EADDRINUSE 是调查原因的信号,不是随意 unlink 现有对象的许可。
下面的 shell 检查适合在开发中使用。它会显示路径类型、所有者、模式和监听进程,但不会修改任何内容:
sock="$TMPDIR/com.example.broker.sock"
stat -f 'type=%HT owner=%Su mode=%Sp inode=%i' "$sock"
lsof -n -U "$sock"
正常的 macOS 结果具有下面的形状:
type=Socket owner=alice mode=srw------- inode=123456
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
Broker 48102 alice 9u unix 0x0123456789abcdef 0t0 /.../com.example.broker.sock
不要解析示例值。应断言类型是套接字、所有者是预期用户、模式拒绝组和其他用户访问,并且进程是已安装的代理实例。在强制崩溃、应用更新、注销和第二次启动后重复同样的检查。只在正常退出后有效的清理设计还没有经过充分测试。
如果由 launchd 启动服务,就始终让 launchd 负责相应生命周期。把应用自行管理的启动方式与 launch agent 混在一起,可能产生两个实例争抢同一路径。只能有一个组件负责创建、就绪和移除,客户端还需要明确的 unavailable 状态,而不是反复派生更多代理。
部署工作只是移到了别处
Stdio 在安装时成本较低,但多个客户端需要共享常驻状态时成本更高。每个 MCP 客户端都需要命令配置。垫片必须找到已签名应用或其私有端点,协商兼容的协议版本,并在桌面应用不存在或锁定时报告清楚的错误。打包过程必须保留可执行权限和代码签名。不要依赖 shell 启动文件,因为 GUI 应用启动时常常没有终端用户预期的环境。
Unix 套接字需要服务安装、启动归属、端点发现、目录权限、崩溃恢复,以及独立客户端和服务器升级时的兼容性。作为交换,每个客户端可以使用一个稳定端点,服务也能在单个进程中维护保险库状态和审计顺序。桌面应用本来就持续运行时,这一点很有吸引力。
端点发现需要明确契约。在 macOS 上,$TMPDIR 按用户区分,但终端、编辑器和 GUI 启动器继承的环境可能不同。受保护的应用支持目录中的固定路径更容易推理,不过沙箱和安装布局可能限制它。如果客户端通过引导命令取得路径,应认证引导结果,不能接受项目配置给出的任意路径。
两种模型都会发生版本错配。使用 stdio 时,客户端选择启动哪个垫片可执行文件。使用常驻套接字时,在分阶段更新期间,旧客户端可能连到新服务,反之亦然。第一次交换就进行简短的版本协商。在请求批准前拒绝不支持的组合,因为如果代理与客户端对请求的解析不同,用户就无法有意义地批准它。
运维团队有时偏爱套接字,因为熟悉的工具可以列出它们。开发者有时偏爱 stdio,因为命令可以直接在终端运行。两种便利都不应变成调试后门。能够提交特权请求的诊断客户端,必须走和代理相同的归属及批准流程。跳过授权的调试标志迟早会离开开发机器。
如果代理不是常驻服务,部署比较会改变。每次 stdio 连接都启动完整 UI 应用,会导致缓慢启动和突兀提示。仅仅为了避开一个小垫片而保持套接字服务常驻,又会增加更新和清理工作。先决定应用生命周期,再让外部传输方式适配它。
让设计对应明确的威胁
平台团队应记录每项设计属性解决哪个威胁。下表是决策记录,不是计分卡。只有周边实现提供了所述控制,传输方式才能在该行胜出。
| 威胁或要求 | Stdio 设计 | Unix 套接字设计 |
|---|---|---|
| 其他登录用户尝试访问 | 私有进程描述符和正确的继承设置 | 受保护父目录加模式 0600 |
| 同一用户下的另一个进程连接 | 如果只有启动器持有管道会更困难,但继承描述符仍有风险 | 默认就可能发生,因此必须检查对端凭据和应用身份 |
| 批准应随一次代理运行结束 | EOF 和被监视的进程退出提供自然撤销信号 | 服务器必须创建会话,并绑定到连接和进程生命周期 |
| 多个独立代理共享一个保险库 | 每个垫片都需要通往保险库进程的私有认证通道 | 常驻服务接受各自认证的连接 |
| 代理崩溃并重启 | 客户端看到 EOF,启动新实例或报告失败 | 服务必须处理陈旧路径,并强制客户端重新连接和授权 |
| 客户端可执行文件在运行中变化 | 捕获的身份继续附着于该会话 | 捕获的对端身份继续附着于该连接;重连触发重新评估 |
| 简单的首次安装 | 命令加已签名可执行文件,无需设置端点 | 需要启动注册、受保护路径、发现和清理 |
| 集中审计排序 | 需要共享保险库核心对各垫片事件排序 | 单个常驻服务自然做到,不过持久日志仍需设计 |
这项分析通常会留下两个结论。第一,同一用户攻击者会抹去文件模式位带来的大部分安全感。第二,批准时长和端点访问同样重要。一个完全私有的套接字,如果批准记录持续一整天,可能比谨慎限定的 stdio 通道授予更多权限。
除非团队能解释数字的依据,否则不要分配数字权重。一项高风险凭据可能让一次遗漏的身份检查压过所有部署优势。应为每一行编写验收测试。例如,以同一用户启动未批准进程并证明代理拒绝它;批准一个代理后终止它,让后代进程继续存活,再证明旧授权无法复用。
两种方式也可以分层。MCP 在代理和小垫片之间使用 stdio,垫片再通过私有 Unix 套接字或另一种操作系统 IPC 与常驻桌面代理通信。这通常是合适的桌面形态,但它产生了两个边界。两边都要认证。垫片必须证明自己代表哪次代理运行,应用也必须证明垫片到达的是预期代理,而不是被项目文件替换的端点。
把身份放进已验证的会话信封
一个小型协议信封能让安全决策便于审查。它不能替代操作系统身份,而是把代理已经验证的事实绑定到即将执行的请求。
代理检查活的调用方后,应由自己创建会话标识符和随机值,再通过已认证通道返回它们。后续每个请求都携带该会话标识符、单调递增的序列号、请求能力和操作参数。遇到未知会话、重复序列、严格排序时跳号、超出批准范围的能力,或从另一通道收到的请求,代理都应拒绝。
{"session_id":"s_7M4K","sequence":12,"capability":"http:billing.read","action":{"method":"GET","path":"/v1/invoices"}}
响应应重复操作身份和状态,不应返回密钥或注入后的授权头:
{"operation_id":"op_01J8","sequence":12,"state":"completed","result":{"status":200,"body_ref":"activity:8841"}}
把这些看作协议形状,而不是通用字段名。有用的属性是:服务器分配身份、执行顺序,并返回已记录操作的持久引用。不要用交给代理的密钥给信封签名;那会让代理成为凭据持有者。把它绑定到已认证本地通道,并把秘密材料留在代理内部。
对于 stdio,通道绑定可以包含只通过新管道传递的随机值和捕获的启动器身份。对于 Unix 套接字,它可以包含已接受连接和对端审计令牌。如果请求可以在连接之间迁移,就定义一个有意设计的恢复协议,使用短生命周期的一次性令牌。从日志复制的环境会话标识符绝不能恢复权限。
把批准数据和显示数据分开。代码库路径、代理提供的标签、工具名称和申请理由能帮助人做决定,但攻击者可以选择这些内容。代理记录应区分经过验证的进程事实、用户声明、已批准能力和请求内容。在事件审查时,这个区别很有用,否则精心编写的标签看起来会像证据。
桌面代理可以同时使用两者而不混淆
对于一个已签名并持续运行的 macOS 应用,最清楚的设计往往是在 MCP 边界使用 stdio,再通过单独认证的本地通道进入应用。代理获得 MCP 预期的启动模型和进程范围生命周期。应用保留一个保险库、一个批准界面和一份有序审计历史。只有内部通道保留外部调用方身份,而不是把所有垫片压成一个可信客户端,这个设计才成立。
Sallyport 就采用这种形态:支持 MCP 的代理启动捆绑的 sp mcp stdio 垫片,应用执行 HTTP 和 SSH 操作,因此密钥不会到达代理。它的会话批准首先展示进程的代码签名机构,正面处理了套接字权限无法回答的弱点。
这项选择并不表示 Unix 套接字普遍错误,也不表示 stdio 本身就足够。构建多种原生客户端代理的平台团队,完全可以把受保护套接字作为主 API。团队随后要负责对端检查、会话构建、陈旧端点恢复和升级兼容。如果不能说明每项如何运作,该套接字就还不适合承载凭据。
选择失败时最容易拒绝操作的设计。无法归属调用方时,拒绝操作。代理无法证明端点属于当前实例时,不要连接,也不要 unlink。任一进程重启后客户端重新连接时,创建新会话。这些规则牺牲一点便利,却消除了让本地代理变危险的无声连续性。
最终架构审查应能放进一页:授权单位、已验证的调用方证据、会话开始、会话结束、端点所有权、崩溃行为、更新行为和审计记录。如果任何答案依赖「同一用户」这个词,就应明确同一用户恶意软件是否在威胁模型之外。这一句会暴露团队究竟在把 stdio 和 Unix 套接字当作传输方式比较,还是把它们当作安全决策的替代品。
常见问题
MCP stdio 比 Unix 域套接字更安全吗?
不能脱离实现这样判断。Stdio 会自然地把通道限定到已启动进程,Unix 套接字则自然支持常驻服务;安全性取决于这种生命周期是否符合授权单位。
模式 0600 的套接字权限能认证调用应用吗?
不能。它通常把访问限制到所有者用户,但该用户下的每个进程仍可尝试连接。应检查活的对端,并另做一次授权决定。
桌面密钥代理能通过 stdio 支持多个代理吗?
可以。每个代理运行一个垫片,再让各垫片通过经过认证的私有通道访问共享保险库核心。会话身份要彼此分开,防止一个垫片冒用另一个代理的批准。
stdio 客户端崩溃时应如何处理?
代理看到 EOF 或进程退出时,应撤销后续调用并记录已经运行工作的结果。如果远程操作可能已经完成,就标为 indeterminate,不要盲目重试。
代理应如何删除陈旧的 Unix 套接字?
在不跟随链接的情况下检查现有文件系统对象,验证类型、所有者及其与已终止代理实例的关系。绝不能只因为 bind 返回 EADDRINUSE 就 unlink 一个可预测路径。
Unix 域套接字对 MCP 来说够快吗?
普通 MCP 请求完全够用。与 HTTP API 或 SSH 操作相比,本地传输耗时通常很小,所以应先让身份和生命周期决定架构。
授权应该在代理重启后保留吗?
通常不应保留。重启会打断已验证通道和进程证据,因此应要求重连和新会话,除非产品明确承诺持久授权并提供相应保护。
客户端能发送自己的进程名作为归属依据吗?
它可以发送用于显示的标签,但代理不能信任它。应从活进程、审计令牌、代码签名或操作系统提供的启动关系推导身份。
macOS 应用应把 Unix 套接字放在哪里?
使用由用户或应用控制的父目录,并拒绝组和其他用户访问。还要规定终端、编辑器和 GUI 客户端如何发现路径,而无需信任项目提供的配置。
什么时候适合混合使用 stdio 和套接字?
常驻桌面保险库服务于分别启动的 MCP 代理时很合适。stdio 边缘提供进程范围会话,内部套接字仍需独立认证,并且必须传递经过验证的调用方信息。