
1. 从 Matt Pocock 的新玩具说起Sandcastle 到底想解决什么问题第一次看到 Sandcastle 这个名字是在 Matt Pocock 的一条动态里。如果你长期混 TypeScript 圈子对这个名字应该不陌生——Total TypeScript 的作者TypeScript 类型体操领域最会讲课的人之一社区里管他叫“类型魔法师”也不为过。他做的很多工具都带着强烈的个人风格类型安全做到极致、API 设计追求“用起来像在写声明式代码”、文档和示例永远比源码还长。Sandcastle 这个项目延续了这套审美但它的野心比一个类型工具大得多——它想解决的是AI 编码代理AI Coding Agent在真实项目里“乱跑”的问题。先说清楚 Sandcastle 是什么。用一句话概括它是一个用 TypeScript 编写的沙箱编排框架专门用来给 AI 编码代理提供一个受控、可复现、可观测的执行环境。你可以把它理解成“给 AI 代理准备的虚拟机管理器”但比传统虚拟机轻得多也比单纯的容器封装更贴近开发者的日常心智模型。它不负责训练模型也不负责生成代码它负责的是当 AI 代理要执行一段代码、跑一次测试、改一个文件、装一个依赖时这些动作应该发生在哪里、以什么权限发生、发生之后怎么回滚、整个过程怎么记录下来。为什么这件事值得单独做一个框架因为过去一年里几乎所有认真用 AI 代理写代码的人都踩过同一类坑。你让代理帮你重构一个模块它顺手把package.json里的依赖版本全升了一遍你让它跑个测试它在你的主分支上直接git commit你让它修一个 bug它把.env文件读出来打印到日志里。这些不是模型“笨”而是代理的执行边界没有被工程化地约束住。Sandcastle 的核心主张就是代理的智能归模型代理的边界归框架。模型可以天马行空但落地执行必须经过沙箱这一层“海关”。这个定位决定了它的目标用户不是“随便玩玩 AI 写代码”的人而是那些已经把 AI 代理纳入日常开发流程、并且被它的不可控性折磨过的人。比如你在做一个基于 Vue3 Three.js TypeScript 的机房可视化项目代理需要频繁改 shader、调场景参数、跑构建验证一旦它把vite.config.ts改坏整个项目就起不来。Sandcastle 要做的就是让这类操作被隔离、被记录、被限制。它适合的读者是有 TypeScript 基础、用过至少一种 AI 编码工具、并且对“代理执行安全”这件事有真实痛感的开发者。如果你只是偶尔让 AI 补全几行代码这个框架对你来说可能偏重但如果你已经在搭代理工作流它值得你花一个周末认真读一遍。2. 沙箱编排框架的核心设计思路拆解2.1 为什么是“编排”而不是“隔离”很多人第一反应会问沙箱不就是隔离吗Docker 不就干这个的这个理解只对了一半。传统沙箱解决的是“不让坏代码影响宿主”而 Sandcastle 要解决的是“让代理的每一步操作都可被编排”。这两个目标的差别很大。隔离是被动的、静态的你起一个容器把代码丢进去跑完销毁完事。编排是主动的、动态的它关心的是这一步该不该执行、执行前要不要快照、执行后要不要验证、失败了怎么回退、成功了怎么合并。我举个具体场景你就明白了。假设代理要执行一个“升级依赖并跑测试”的任务。纯隔离方案是起容器、跑命令、看结果。而 Sandcastle 的编排方案是先对package.json和 lock 文件做快照然后在沙箱内执行升级接着跑测试如果测试通过则把变更“提升”回主工作区如果失败则丢弃沙箱并恢复快照整个过程生成一份结构化报告。你看这里的关键不是“隔离得好不好”而是“每一步都有明确的准入、验证、回滚策略”。这就是编排的含义——它把代理的一次任务拆解成一系列受控的阶段每个阶段都有明确的边界和契约。Matt Pocock 在类型工具上的经验在这里体现得很明显Sandcastle 的编排策略是用 TypeScript 的类型系统来描述的。你可以定义一个任务流水线每个阶段的输入输出类型都被约束住阶段之间的数据传递如果类型不匹配在编译期就会报错而不是等到运行时才发现代理传了个乱七八糟的东西过来。这个设计思路很“Matt”——把运行时的不确定性尽量前移到编译期。2.2 类型安全如何贯穿整个执行链路这是 Sandcastle 最值得细看的部分。大多数沙箱工具的配置是 YAML 或者 JSON写错了要到运行时才炸。Sandcastle 把整个配置和执行链路都做成了类型驱动的。具体来说它有几个关键的类型抽象TaskDefinition描述一个代理任务包含任务名、输入 schema、允许的操作集合、超时策略。SandboxPolicy描述沙箱的权限边界比如允许写哪些目录、允许执行哪些命令、网络访问白名单。StageContract描述流水线中每个阶段的输入输出类型阶段之间靠这个契约衔接。Artifact描述阶段产出的可验证物比如构建产物、测试报告、diff 补丁。这些类型不是摆设。当你定义一个任务时如果你在SandboxPolicy里声明只允许写src/目录但某个阶段的StageContract输出类型里包含了要写config/的意图类型检查会直接告诉你冲突。这种“策略即类型”的做法好处是把安全约束从“运行时检查”变成了“编译期约束”。代理再聪明也没法绕过编译器的检查。我实测下来这套类型系统对新手有一点门槛但一旦你理解了它的心智模型写配置的速度反而比 YAML 快因为编辑器会给你完整的补全和错误提示。这跟 Matt 一贯的教学理念一致类型不是为了炫技是为了让错误在最早的地方暴露。2.3 与常见方案的取舍对比为了说清楚 Sandcastle 的定位我把它和几种常见方案做了个对比。需要说明的是这里的对比是基于我自己的使用经验和对框架设计的理解不是官方结论。方案隔离粒度编排能力类型安全回滚机制适用场景裸跑代理无无无手动玩具项目Docker 容器进程级弱无镜像重建一次性任务Git worktree文件级中无git 操作分支隔离Sandcastle操作级强强快照契约代理工作流从表里能看出来Sandcastle 的差异化在于“操作级隔离”和“强编排”。Git worktree 能隔离文件但隔离不了命令执行和网络访问Docker 能隔离进程但编排能力弱回滚成本高。Sandcastle 走的是中间路线它不追求最强的隔离那会牺牲速度而是追求“隔离 编排 类型安全”三者的平衡。这个取舍很务实因为代理工作流的特点是高频、短任务、需要快速反馈如果每次任务都起一个完整容器开发体验会很差。3. 核心细节解析与实操要点3.1 环境准备与依赖安装Sandcastle 是纯 TypeScript 项目运行在 Node 环境。我建议用 Node 20 LTS 以上因为框架内部用到了较新的node:fs和node:child_processAPI。安装本身很简单npm install -D sandcastle # 或者 pnpm add -D sandcastle但这里有个坑我要提前说Sandcastle 依赖一个“执行后端”来真正跑沙箱。它默认支持两种后端一种是基于本地进程的轻量后端适合开发阶段一种是基于容器运行时的强隔离后端适合 CI 和不可信任务。开发阶段用轻量后端就够了启动快、调试方便。但如果你要让代理执行来自外部的、不完全可信的代码一定要切到强隔离后端。这个切换在配置里是一个字段的事但很多人会忽略直接用默认配置跑所有任务这是有风险的。提示轻量后端和强隔离后端的 API 是一致的所以你可以开发时用轻量、上线时切强隔离代码不用改。但两者的行为有细微差别比如文件系统快照的实现方式不同建议在切换后重新跑一遍测试用例。3.2 定义第一个受控任务我拿一个真实场景来演示让代理帮我升级一个依赖并验证。这个任务看起来简单但涉及文件修改、命令执行、结果验证三个环节正好能体现 Sandcastle 的编排能力。第一步是定义任务。你需要声明任务的输入、允许的操作、以及流水线阶段。下面是一个简化后的示例结构import { defineTask, definePolicy, defineStage } from sandcastle; const policy definePolicy({ writablePaths: [package.json, pnpm-lock.yaml], allowedCommands: [pnpm install, pnpm test], network: { allow: [registry.npmjs.org] }, timeoutMs: 120_000, }); const task defineTask({ name: upgrade-dependency, policy, stages: [ defineStage(snapshot, async (ctx) { return ctx.snapshot([package.json, pnpm-lock.yaml]); }), defineStage(upgrade, async (ctx) { await ctx.exec(pnpm install some-packagelatest); }), defineStage(verify, async (ctx) { const result await ctx.exec(pnpm test); return ctx.artifact(test-report, result); }), ], });这段代码里policy是关键。它明确告诉框架这个任务只能写两个文件、只能跑两条命令、只能访问 npm registry、最多跑两分钟。代理在这个任务里无论怎么“发挥”都突破不了这个边界。这就是“操作级隔离”的实际含义——不是把整个环境封死而是精确地划定代理能碰什么。3.3 快照与回滚的实现细节快照机制是 Sandcastle 编排能力的核心。它的实现思路不是对整个文件系统做镜像那太慢而是对“策略声明的可写路径”做内容快照。也就是说只有writablePaths里列出的文件才会被快照其他文件即使被代理意外修改也不会进入回滚范围——但反过来说这也意味着你的策略声明必须准确漏掉一个可写路径回滚就不完整。快照的存储用的是内容寻址的方式相同内容只存一份所以即使你跑很多次任务磁盘占用也不会爆炸。回滚的时候框架会把快照内容写回原路径并清理掉任务期间新增的文件。这里有个细节值得注意回滚是“恢复到快照时刻”不是“撤销变更”。如果你在任务开始前手动改了文件但没提交快照会包含你的改动回滚后你的改动还在。这个行为是合理的但新手容易误解以为回滚会回到 git 的某个状态。注意快照不包含 git 历史。如果你的任务涉及 git 操作比如代理自己 commit回滚文件内容不会撤销 commit。所以策略里最好禁止代理执行 git 写操作或者把 git 操作也纳入编排阶段单独处理。3.4 阶段契约与数据传递阶段之间的数据传递是很多人写流水线时容易乱的地方。Sandcastle 用StageContract来约束这个传递。每个阶段声明自己的输入类型和输出类型框架在运行时校验实际数据是否符合契约。如果不符合任务会在该阶段失败而不是把脏数据传给下一个阶段。这个设计的好处是“失败点前移”。假设你的“升级”阶段输出了一个非预期的结构而“验证”阶段期望的是标准结构契约校验会在阶段切换时立刻报错你能马上定位到是升级阶段的问题而不是等到验证阶段跑出一堆莫名其妙的错误再去回溯。我在实际项目里把每个阶段的输出都定义得比较严格虽然写的时候多花几分钟但排查问题时省的时间远不止这几分钟。4. 实操过程与核心环节实现4.1 完整流程从任务定义到结果合并我把上面那个依赖升级任务完整跑一遍把每个环节的实际操作和观察记录下来。整个流程分六步任务注册、策略校验、沙箱启动、阶段执行、结果验证、变更合并。任务注册阶段框架会先做一次静态检查确认任务定义里的策略和阶段契约没有类型冲突。这一步是编译期行为如果你用tsc或者编辑器的类型检查在写代码的时候就能看到错误。我建议把任务定义放在独立的.ts文件里让类型检查覆盖到。策略校验阶段框架会检查策略本身是否自洽。比如你声明了allowedCommands里有pnpm test但writablePaths里没有测试可能产生的快照目录框架会给出警告。这个校验不是强制的但强烈建议开启它能帮你发现策略里的逻辑漏洞。沙箱启动阶段轻量后端会创建一个隔离的工作目录把策略声明的可写路径复制进去并设置好命令执行的环境变量。这个过程通常在几百毫秒内完成比起容器快得多。启动后框架会记录一个沙箱 ID后续所有操作都关联到这个 ID。阶段执行阶段每个阶段按顺序跑。我实测下来snapshot阶段几乎瞬间完成upgrade阶段取决于网络和包管理器verify阶段取决于测试套件。整个任务在我的机器上跑了大约 40 秒其中大部分时间花在pnpm install上。框架会在每个阶段结束时输出一份阶段报告包含耗时、退出码、标准输出摘要。结果验证阶段框架会检查所有声明的 artifact 是否产出。如果某个阶段声明了要产出test-report但实际没产出任务会被标记为“未完成”。这个机制能防止代理“假装成功”——它必须真的产出可验证的东西。变更合并阶段如果所有阶段都成功框架会把沙箱内的变更“提升”回主工作区。提升的方式是逐文件对比只写回真正变化的文件。如果任何阶段失败框架会触发回滚把主工作区恢复到任务开始前的状态。这个“全成功才合并任一失败就回滚”的策略是默认行为你也可以配置成“部分成功也合并”但我不建议因为那会让状态变得难以预测。4.2 参数选择与超时策略的计算超时策略看起来是个小配置但设不好会直接影响体验。设太短正常任务被误杀设太长卡住的任务浪费资源。我的经验是按阶段分别设置超时而不是给整个任务一个总超时。因为不同阶段的耗时特征差别很大快照是毫秒级安装是十秒到分钟级测试是秒到分钟级。具体怎么算我的做法是先跑几次任务记录每个阶段的 P95 耗时然后在这个基础上乘以 2 作为超时值。比如upgrade阶段 P95 是 30 秒那超时设 60 秒。这样既能容忍网络波动又不会让卡死的任务拖太久。Sandcastle 支持在阶段级别覆盖策略级别的超时这个粒度很实用。网络访问的白名单也要仔细配。默认情况下我建议只允许包管理器的 registry 域名其他一律禁止。代理如果尝试访问其他域名会被框架拦截并记录。这个记录很有价值它能告诉你代理在“想”做什么——有时候代理会尝试访问一些你根本没预期的服务这些尝试本身就是信号。4.3 与 AI 编码代理的对接方式Sandcastle 本身不绑定任何特定的 AI 代理它提供的是一个执行层。对接方式通常有两种一种是把 Sandcastle 作为代理的“工具执行器”代理生成的操作请求先经过 Sandcastle 的策略校验再执行另一种是把 Sandcastle 作为“任务运行器”你手动或通过脚本触发任务代理只在任务内部生成代码。我两种都试过。第一种更自动化但对代理的集成度要求高需要代理支持自定义工具执行器。第二种更可控适合把代理纳入现有 CI 流程。如果你用的是支持工具调用的代理框架第一种体验更好因为代理能实时得到沙箱的反馈并调整行为。如果你只是想给代理加一层保护第二种改动最小。提示无论哪种对接方式都建议把 Sandcastle 的任务报告接入你的日志系统。报告里包含每个阶段的详细记录出问题时这是第一手排查材料。5. 常见问题与排查技巧实录5.1 任务失败但看不出原因怎么办这是最常见的问题。代理任务失败报告里只有一句“stage verify failed”没有更多信息。我的排查顺序是这样的先看阶段报告里的退出码和标准错误大部分问题在这里就能定位如果标准错误是空的检查 artifact 是否产出有时候是产出路径写错了如果还找不到把策略里的allowedCommands临时放宽手动在沙箱目录里跑一遍命令看真实报错。这里有个技巧Sandcastle 支持“保留失败沙箱”选项。开启后任务失败时沙箱不会被销毁你可以进去手动检查。这个选项在开发阶段非常有用但记得在 CI 里关掉否则失败任务会堆积沙箱占磁盘。5.2 回滚不完整或回滚后状态异常回滚出问题九成是策略声明不完整。代理修改了某个文件但那个文件不在writablePaths里快照就没覆盖它回滚自然恢复不了。排查方法是对比任务前后的文件系统差异找出所有被修改但不在策略里的路径把它们补进策略。如果某个路径你不想让代理写那就应该在策略里明确禁止而不是靠“没声明”来隐式禁止——因为隐式禁止不会阻止写入只会让回滚失效。另一个可能是快照时机不对。如果你的任务在快照之前就有其他进程在改文件快照会包含那些改动回滚后它们还在。这种情况要确保任务执行期间没有其他写操作。5.3 类型报错但运行时正常这是 TypeScript 项目常见的困惑。Sandcastle 的类型系统比较严格有时候你的代码逻辑上没问题但类型不匹配。遇到这种情况先别急着用as any绕过仔细看类型报错的位置。大多数时候是阶段契约的输入输出类型定义得太窄或者策略类型和实际使用有偏差。修正类型定义往往能发现潜在的逻辑问题。我踩过的一个坑是阶段输出用了unknown类型下游阶段期望具体类型类型检查报错。我一开始想直接断言后来发现是上游阶段确实可能输出非预期结构修正上游的产出逻辑后类型自然就匹配了。这个经历让我更信任这套类型系统——它报错的地方往往真的有隐患。5.4 常见问题速查表现象可能原因排查方向解决方式任务超时超时设置过短查看阶段耗时报告按 P95 的 2 倍调整回滚不完整策略遗漏可写路径对比任务前后文件差异补全 writablePaths网络被拦截白名单未包含目标域名查看拦截日志按需添加域名类型报错契约类型不匹配检查阶段输入输出定义修正类型而非断言沙箱启动慢用了强隔离后端查看后端配置开发阶段切轻量后端artifact 缺失产出路径错误检查阶段产出逻辑修正路径或产出方式5.5 几个我踩过的坑和独家心得第一个坑是策略写得太宽。我一开始图省事把writablePaths设成整个src/结果代理在里面乱改回滚虽然能恢复但每次任务都产生大量 diff审查成本很高。后来我把策略收窄到具体文件代理的行为反而更聚焦了。这让我意识到策略不只是安全边界也是给代理的“行为提示”——你限制得越精确代理越不容易跑偏。第二个坑是忽略阶段报告的细节。阶段报告里有个字段叫“命令执行轨迹”记录了每条命令的完整参数和环境变量。我一开始不看这个后来发现代理有时候会传一些奇怪的参数比如给pnpm install加--force这个参数不在白名单里但被 shell 解析成了别的东西。看轨迹才发现问题。现在我养成了定期审查轨迹的习惯。第三个心得是把 Sandcastle 的任务定义当成文档来写。因为它是类型化的、结构清晰的一个任务定义读起来就像一份操作说明书。我把项目里所有代理任务都定义在tasks/目录下新人接手时读这些文件就能理解代理能做什么、不能做什么。这比写一堆 Markdown 文档管用得多因为文档会过时类型定义不会——它过不了编译。6. 这套框架适合什么样的项目以及后续可以怎么扩展聊到这里我想说说适用边界。Sandcastle 不是银弹它解决的是“代理执行可控性”问题不解决“代理生成质量”问题。如果你的代理本身生成质量就不行加再多沙箱也白搭。它最适合的场景是代理已经能完成大部分任务但偶尔会“手滑”造成破坏你需要一层工程化的保险。这种场景下它的价值立竿见影。对于小型项目或者个人玩具项目它的配置成本可能偏高。但如果你在做团队协作的项目尤其是那种多人共用一套代理工作流的场景它的价值会被放大——因为策略定义是共享的所有人都用同一套边界行为一致性有保障。我见过一些团队用口头约定来约束代理行为效果很差因为约定没法强制执行。Sandcastle 把约定变成了代码这是本质区别。后续扩展方面我觉得有几个方向值得关注。一是策略的“继承与组合”让不同任务可以复用基础策略再叠加差异减少重复定义。二是任务报告的“可视化”现在报告是结构化的 JSON如果能生成直观的执行时间线和 diff 视图审查效率会更高。三是和更多代理框架的原生集成降低对接成本。这些方向不一定都由官方做社区完全可以基于现有 API 扩展。我个人在实际操作中的体会是这类框架的价值不在于它帮你省了多少事而在于它帮你避免了多少“事后救火”。代理跑得越快出错的代价就越高边界就越重要。Sandcastle 把边界做成了类型、做成了契约、做成了可回滚的快照这套组合拳打下来代理工作流才算真正能上生产。如果你正在被代理的不可控性困扰花点时间读它的类型定义比读十篇教程都有用。