get-shit-done SDK 安全加固剖析:relPlanningPath 对工作流名的路径穿越校验(Issue 3589)
本文基于仓库中的变更集文档 .changeset/3589-planning-paths-workstream-validation.md,深入讲解 get-shit-done(GSD)SDK 中一次针对 planning 路径拼接的路径穿越(path traversal)安全修复。读完本文,你将理解 GSD 多工作流(workstream)机制下 .planning/ 路径是如何按工作流路由的、relPlanningPath 为何成为所有消费方共享的"失败关闭(fail closed)"校验缝合点,以及显式参数与环境变量来源的工作流名在安全语义上为何必须采用不同的处理策略。
1. 问题背景:多工作流机制与 planning 路径路由
GSD 支持在同一项目中按工作流(workstream)隔离规划产物。根据 workstream-utils.ts 头部注释,当提供 --ws <name> 参数时,所有 .planning/ 路径都会被路由到 .planning/workstreams/<name>/ 子树。这条路由逻辑的核心就是 relPlanningPath():
export function relPlanningPath(workstream?: string): string {
if (!workstream) return '.planning';
if (!validateWorkstreamName(workstream)) {
throw new Error(
`Invalid workstream name: ${JSON.stringify(workstream)}. ` +
`Workstream names must match /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/ and may not contain '..'.`,
);
}
// Use POSIX segments so the same logical path string is used on all platforms (Windows included).
return posix.join('.planning', 'workstreams', workstream);
}
(源码见 workstream-utils.ts)
围绕它有两类主要消费方:
planningPaths(projectDir, workstream?):返回一套完整的规划文件路径对象(planning、state、roadmap、project、config、phases、requirements),内部通过join(projectDir, relPlanningPath(...))构造基路径(见 query/helpers.ts)。ContextEngine:SDK 的上下文引擎在构造器中直接执行this.planningDir = join(projectDir, relPlanningPath(workstream))(见 context-engine.ts),后续所有上下文文件的解析都基于这个planningDir。
此外,SDK 入口 index.ts 对外同时导出了 validateWorkstreamName 与 relPlanningPath,CLI 侧 cli.ts 在 --ws 缺省时也会回退读取 GSD_WORKSTREAM 环境变量并用同一策略做校验。也就是说,工作流名到文件路径的映射散落在多个入口,而这些入口最终都汇聚到 relPlanningPath 这一条路径构造缝上。
2. 漏洞本质:显式参数缺失路径穿越门禁
变更前,relPlanningPath 对显式传入的 workstream 参数没有任何校验。这意味着一个直接调用 SDK 的一方可以传入诸如 '../../../outside' 这样的值,它会原样流经 posix.join('.planning', 'workstreams', name),把本应落在 .planning/workstreams/<name> 子树内的所有规划操作——读取 STATE.md、ROADMAP.md 等——悄悄路由到项目根之外的任意目录。
需要注意漏洞面与正常调用路径之间的差异:
- CLI 与 gsd-tools 路径:
--ws参数或GSD_WORKSTREAM环境变量在 cli.ts 中已经过validateWorkstreamName过滤,非法值被丢弃而非直接用于拼路径。 planningPaths内部的环境变量来源:resolveWorkspaceContext()(见 query/workspace.ts)读到的GSD_WORKSTREAM会在planningPaths中被validateWorkstreamName预校验,非法值被置为null并静默回退到根.planning/——这是 issue #2791 确立的契约。- 直接 SDK 调用方:
relPlanningPath、planningPaths(projectDir, workstream)的第二个参数、以及ContextEngine的workstream构造参数,在修复前都没有任何门禁。这正是 #3589 关闭的缺口。
3. 修复方案:在共享缝合点统一执行校验策略
修复思路不是在每个调用点各加一遍校验,而是把共享的 validateWorkstreamName 策略直接下沉到 relPlanningPath 内部,使所有消费方在同一缝合点失败关闭(changeset 原文表述为 "every consumer fails closed at the same seam")。校验时机刻意放在路径构造之前,非法名字会同步抛出异常,而不是构造出一个半成品的越界路径。
校验策略本体位于 workstream-name-policy.ts,这是 CJS 运行时(active-workstream-store.cjs、planning-workspace.cjs、workstream.cjs)与 SDK 层共享的规范策略模块,其中:
ACTIVE_WORKSTREAM_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/(第 12 行):名字必须以字母或数字开头,仅允许字母、数字、点、下划线、连字符;isValidActiveWorkstreamName(第 52-56 行)额外显式拒绝任何包含..的序列,堵住路径穿越;hasInvalidPathSegment(第 41-44 行)提供另一维度的判定:含路径分隔符、裸./..、或出现..子序列的名字均不安全;validateWorkstreamName是isValidActiveWorkstreamName的 SDK 层别名,toWorkstreamSlug则负责把展示名规范化为文件系统安全的 slug。
由此可以枚举修复前后的行为矩阵:
| 输入 | 修复前 | 修复后 |
|---|---|---|
undefined / '' |
返回 .planning |
返回 .planning(空字符串按"未提供"处理,保持向后兼容) |
合法名(如 frontend、api_v2、alpha.beta-1) |
返回 .planning/workstreams/<name> |
不变 |
'../../../outside'、'../escape'、'.. '、'foo/bar'、'foo\\bar'、'foo bar'、'.hidden'、'/abs'、'-leading-hyphen' |
静默构造越界路径 | 同步抛出 Invalid workstream name: ... |
4. 双轨语义:显式参数 fail-closed,环境变量静默回退
修复中最值得推敲的设计是:同一条代码路径上,对"显式参数"和"环境变量来源"采用了截然相反的失败语义。
在 planningPaths 中:
const envCtx = resolveWorkspaceContext();
// Validate env workstream before use: invalid GSD_WORKSTREAM falls back to
// root .planning/ (bug-2791 contract — invalid env must not crash or route
// to a bad path; silent fallback to root preserves pre-#3269 behaviour).
const validEnvWorkstream =
envCtx.workstream && validateWorkstreamName(envCtx.workstream) ? envCtx.workstream : null;
const effectiveWorkstream = workstream ?? validEnvWorkstream;
const base = join(projectDir, relPlanningPath(effectiveWorkstream ?? undefined));
两条语义可以这样理解:
- 显式参数(fail-closed):调用方在代码里主动写死了工作流名,这个名字错了更可能是调用方 bug 或恶意输入,静默回退会让问题被掩盖,因此必须在
relPlanningPath内抛错让调用方立刻感知。 - 环境变量(silent fallback,#2791 契约):
GSD_WORKSTREAM是外部注入的环境状态,一个陈旧或非法的 env 值不应该让工作流"崩溃或路由到坏路径";planningPaths在进入relPlanningPath之前就把非法 env 值过滤为null,从而静默回退到根.planning/,保持了 #3269 之前的行为。也就是说,env 值根本不会到达relPlanningPath的抛错分支,新的守卫不会改变 env 来源的任何既有行为——这正是 changeset 文档强调的最后一段内容。
这种"校验下沉 + 前置过滤"的组合,让单一入口同时满足两种失败语义,且两者的优先级清晰:显式参数优先于 env(workstream ?? validEnvWorkstream)。
5. 测试证据:穿越用例与"先抛错再构造"保证
回归测试 bug-3589-planning-paths-validation.test.ts 完整固化了上述契约:
TRAVERSAL_CASES列出 9 个非法名字('../../../outside'、'../escape'、'..'、'foo/bar'、'foo\\bar'、'foo bar'、'.hidden'、'/abs'、'-leading-hyphen'),逐个断言relPlanningPath抛出匹配/workstream/i的异常(第 26-55 行);- 显式验证了"在构造路径之前就抛出":用 try/catch 包裹
relPlanningPath('../../../outside'),断言不存在任何部分路径产出(第 57-65 行); planningPaths层面的对称测试:显式非法名抛错,合法名构造出…/.planning/workstreams/frontend/{STATE.md,ROADMAP.md}等预期子树,省略参数时回根.planning(第 68-88 行);- 兼容性测试确认
relPlanningPath()、relPlanningPath(undefined)、relPlanningPath('')三者都返回.planning,保证修复对既有调用方零破坏。
6. 对 SDK 使用者的实践启示
从源码结构看,这条修复确立了 GSD SDK 中工作流名处理的三个可复用原则:
- 路径构造与校验必须同缝:任何把外部标识符拼进文件路径的函数,都应在拼路径前执行策略校验并同步抛错,而不是指望上游各调用方自觉。
relPlanningPath现在是唯一的路径构造缝,planningPaths与ContextEngine都自动受益。 - 策略集中、多处共享:命名策略收敛在 workstream-name-policy.ts 一个模块,CJS 运行时与 SDK 层共同消费,避免校验规则漂移;仓库内 shared-module-handsync-allowlist.json 等脚本也在守护这类共享模块的一致性。
- 区分输入来源的失败语义:代码内显式参数应 fail-closed,环境注入值可按契约静默回退——但前提是回退分支必须在到达共享校验缝之前完成过滤,两者不可混用。
如果你在自己的代码中集成 GSD SDK 并需要自定义工作流路由,正确的做法是先调用公开导出的 validateWorkstreamName 预判合法性,或直接信任 relPlanningPath / planningPaths 的同步抛错来捕获非法名,而不是自行用 path.join 拼接 .planning/workstreams/<name>。
小结
#3589 是一个典型"共享工具函数缺少输入门禁"的安全修复:它把校验从"分散在各入口"收敛到 relPlanningPath 这唯一的路径构造缝合点,使直接 SDK 调用、planningPaths 与 ContextEngine 对 '../../../outside' 这类穿越输入一律失败关闭;同时通过 planningPaths 中对 GSD_WORKSTREAM 的前置过滤,完整保留了 #2791 确立的"非法环境变量静默回退根 .planning/"契约。变更集文档 .changeset/3589-planning-paths-workstream-validation.md 将其分类为 Security 类型,配套回归测试位于 sdk/src/bug-3589-planning-paths-validation.test.ts。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00