我平时不太关注前端构建链。直到看见那个 57MB 的 cli.js.map,才意识到一个配置文件能让整个工程的源码原样躺在公共注册表里,而且不需要任何攻击手段。
事情本身很简单:某个命令行工具发版的时候,构建产物里的映射文件没有被排除,跟着 npm 包一起公开了。任何人下载下来,解开 JSON,就能拿到一千九百多个源文件、五十多万行 TypeScript。
映射文件为什么会装着源码
现代前端和 Node 项目的代码要经过打包、压缩、混淆,最后输出的 js 里变量名是 a、b、c,所有逻辑挤在一行。这样跑得快,但线上报错时堆栈只指向“第一行第几万列”,没法查。
source map 就是为解决这个而生的:它是一个 JSON,记录压缩后位置和原始位置的对应关系,浏览器开发者工具读到它,就能把压缩代码还原成可读的目录结构。
标准里最小的一套只需要 mappings 字段。但大多数构建工具额外提供了一个选项,叫内联源码。开启之后,工具会把每个文件的原始内容整段塞进 sourcesContent 数组,和 sources 里的路径一一对应。这个设计有它的道理:调试时不必再去请求原始文件,拿到 map 就能看代码。
问题在于,这个选项和压缩配置放在一起,很容易被当成“调试相关”而顺手打开,然后在生产构建里忘了关。
还有一层更朴素的原因:npm 包默认是公开的。推上去的 tarball 里有什么,全世界就能下到什么,没有鉴权,也没有“仅内部可见”这个选项。所以发版前的文件过滤不是包体积优化,是安全边界。这条线一旦被当成“打包配置”,就会有人为了省事把整个产物目录塞进去。
还原的过程没有任何技术含量:
1 | const map = JSON.parse(fs.readFileSync('cli.js.map', 'utf8')); |
真正需要处理的是路径:构建工具会在 sources 里写 webpack:// 前缀、node: 协议,还有以 ../../ 开头的相对路径。不清洗就可能写到项目目录之外。这属于脚本里的工程细节,和“破解”没有关系。所谓源码泄露,很多时候只是别人把一份本来就不打算公开的文本,打包进了公开的压缩包。
写这类脚本唯一需要小心的是最后一道校验:resolve 之后确认目标路径仍在输出目录内,否则一条 ../../ 开头的记录就能把文件写到系统目录去。这段逻辑很短,但没有它,一个解压工具就成了任意写入工具。
顺带一个容易看错的点:文件总数和“自己的代码”不是一回事。sources 里有几千项,包含依赖、运行时垫片、包装层的类型声明,真正属于这个工具自身的源文件只有一千九百多个。数字上的差异不影响结论,但能看出这类项目对第三方代码的依赖有多重。
体积本身也是一个信号。一个压缩过的命令行工具,正常应该在几百 KB 到几 MB 之间;如果发布出来的包大得离谱,多半是有东西没被排除。我现在的习惯是发版前看一眼包大小,和上一个版本比,涨得没道理就打开文件列表查。
这类文件进仓库的路径通常很普通:本地构建完顺手 git add .,或者 CI 把 dist 缓存下来传给下游任务,或者一个“备份产物”的目录没人清理。它们不会报错,也不会被测试发现,只是安静地躺在那里,直到某次发布把它们带上。
泄露出来的东西,重心不在模型
我原本以为这类 AI 工具的源码主体是提示词和 API 调用。看完结构之后判断变了:调用模型那部分占比很小,代码量集中在模型之外的地方。
会话状态单独有一个引擎持有:消息历史、中断控制器、权限拒绝记录、token 用量、文件状态缓存、动态发现的技能名。这些字段说明它面对的不是“一问一答”,而是长时间运行、随时可能被掐断、需要记账的进程。
我看到这几个字段时的第一反应是:这不是聊天框该有的东西。中断控制器意味着任何时候都可能有人按停;权限拒绝记录意味着它经常被拦,而拦下之后要记住,不能反复去撞同一堵墙;token 用量单独记账,说明成本是被当成一等指标在管。把这些拼起来,才明白“Agent”这个词在工程上意味着什么。
工具执行是流式的。传统做法是等模型把完整的工具调用 JSON 吐完,解析,再执行。它选择在流式响应里一旦凑齐完整参数就先把工具跑起来,让本地 I/O 和模型剩余输出重叠。四十多个工具,各有输入 schema 和分级权限,互相不依赖的调用会被拆成并行批次。
权限不是布尔值。同一套工具在默认、自动、跳过、完全放行几种模式下行为不同,这个设计说明它面对的使用场景差异很大:有人在终端里随手问问题,有人把它挂进流水线批量改仓库。用一套权限去覆盖两种场景,要么太吵,要么太危险。
上下文接近阈值时会触发压缩,把早期的长输出折叠成摘要。这一条在长任务里是必需品,否则窗口一定会爆。压缩本身也会丢信息,所以触发阈值和保留策略比压缩算法更值得调。
提示词也不是一整块静态文本。通用指令跨会话缓存,工作目录、Git 状态这类每次都要重新拼,源码里甚至有个名字很直白的函数专门标记“这一段不要进缓存”,防止动态内容污染缓存命中率。这个细节很小,但它解释了为什么这类工具的首字延迟能压住。
终端界面用了 React 和一套类 Zustand 的状态管理。刚看到时觉得重,想了下能理解:一边流式打印回复,一边显示多个工具的进度,一边准备 diff 预览,用命令式的光标控制去维护这些局部刷新,代码会很快烂掉。
界面层还有一点值得记:它没有用全局状态驱动整棵树,而是让高频的那一小块单独更新,其余部分只在会话状态变化时才重绘。这个划分方式在任何实时界面上都成立,和是不是终端无关。我自己写过一个带进度条的批量任务面板,第一版就是全量刷新,风扇转得比任务本身还响。
把这些拼在一起,我得到的判断是:这类产品的差异越来越多地落在 harness 上,而不是提示词上。提示词决定风格,harness 决定它能不能在一个几万行的仓库里跑一小时不出事。前者可以被抄,后者要一个个 bug 喂出来。
一个 Agent 的难度分布,通常八成在模型之外。
这不是替谁辩护。它只是说明,把这东西理解成“套壳”会误判它的工程量,也会误判自己复刻它的成本。
我自己写过的那个小 Agent,恰好缺的都是这些“模型之外”的东西。没有中断控制,用户按 Ctrl-C 之后工具还在后台跑;没有权限拒绝记录,被拦下的调用下次还会再试;没有文件状态缓存,同一个文件在十分钟里被读了五遍。这些缺陷在演示时看不出来,连续用两小时就会全部暴露。
所以我把这份源码当成一张清单,不是当成范本。能抄的部分是那几个字段和几条状态转移,抄不了的是它们背后的取舍密度。取舍密度这个东西,只能靠自己在真实项目里撞几次才长出来。
一个默认值的代价
回到发布环节。这类事故的共同点是:默认值站在“全部打包”那一边。
这套默认值不是某一个人的疏忽,而是工具链长期演化的结果。TypeScript 里 inlineSources 默认关闭,但只要有人为了调试打开过,配置就会留在仓库里;打包器在生产模式下默认生成映射,构建脚本为了排查问题又往往是照抄开发配置。每一处都合理,叠起来就是“生产包里带着完整源码”。
还有一件事值得记住:npm 发出去的版本收不回来。删包、改版本号只能让后来的人看不到,已经下载过的、同步到镜像里的、躺在别人缓存里的,都在。所以这类问题没有“事后补救”,只有“发之前查”。
黑名单机制(.npmignore)的问题不是不好用,是会漏。项目迭代时新增的目录、临时产物、测试脚本,只要没被显式加进去,就会被推上公共注册表。而白名单(package.json 的 files 字段)是反过来的:没写进去的东西一律不发。新增文件会踩空,需要维护,但它是唯一能被机械验证的边界。
再往前一步是发布前的检查。npm pack --dry-run 会列出即将进入压缩包的所有文件,一行 grep 就能在 CI 里卡住 .map。接手老项目时,我习惯先把当前的文件清单存成基线,之后每次发版对比——配置会过时,实际打出来的东西不会。再稳一点是让脚本直接判定:出现 .map、.ts、.env 或体积异常就失败。这件事单次成本十分钟,写脚本再加半小时,和一次事故的代价比不需要犹豫。
监控那一侧也有更彻底的做法:构建时生成隐藏的映射文件(不在产物末尾写 sourceMappingURL 注释),CI 里上传到错误监控平台,上传成功之后立刻删掉本地的 map,再把干净的产物发出去。线上报错仍然能定位到原始行列,外部分发的东西里一个字都没有。这套流程要多写几行流水线脚本,我认为值得。
还有一条容易被忽略的出口:依赖。自己不发 map,不代表 node_modules 里那些包不发;它们内部如果带着内联源码,照样会进产物。我在一个项目里就见过第三方组件库的 map 被一起打进了 dist,原因是构建配置对 node_modules 一视同仁。检查发布内容的时候,最好连依赖一起看一遍。
我自己的项目规模小得多,但也改了两件事:把 files 白名单写全,发布脚本里加一步打包预览。改的时候有点烦,因为每加一个目录都要回去补一行。不过想清楚这是“默认拒绝”还是“默认放行”的区别之后,就不觉得烦了。
如果只能做一件事,我会选在 CI 里加那条 grep。它不依赖任何人记得,也不依赖交接时的口头叮嘱。
这件事最荒诞的地方在于,没有攻防,没有零日漏洞,只有一个默认值没改。我把它当成对自己流程的一次提醒:凡是默认包含的东西,都值得重新看一遍。
评论