get-shit-done 安全修复解析:config-set/config-get 与 init 响应不再回显明文 API Key
导读
这是一篇针对 get-shit-done 仓库 PR #2997 安全修复(changelog 条目 .changeset/merry-foxes-climb.md,类型 Fixed)的技术解读。该修复解决了一个真实泄漏隐患:配置命令与初始化响应会把 brave_search、firecrawl、exa_search 等集成的 API Key 明文回显到终端、会话记录(session transcripts)与 Shell 历史中。本文以该变更为主线,结合仓库源码讲解秘密键识别、掩码规则、CJS 与 SDK 双实现的一致性机制,以及 init 场景中"布尔可用性标记"为何必须原样透传。读完后你将理解 get-shit-done 如何在磁盘保留明文、输出层强制脱敏的前提下组织密钥管理,并能复现验证这一修复。
一、变更背景:SDK 移植过程中丢失的脱敏行为
PR #2997 的问题在 sdk/src/query/secrets.ts 的文件头注释中被定性为"security: SDK port lost masking behavior"——即 SDK(TypeScript 查询层)在从 CJS 命令行实现迁移时,丢失了配置面已有的密钥脱敏逻辑。
修复前的泄漏路径很典型:
config-set/config-get这类命令的输出会进入 Agent 会话、workflow 输出与 shell history;- 如果某个键被判定为敏感键且值为 API Key,任何一次查询或写入都会把完整明文打印出来;
- 新迁移出的 SDK(sdk/src/query/config-query.ts、sdk/src/query/config-mutation.ts)在响应构造边界上没有做掩码,导致同样的键在 SDK 调用路径上明文外泄。
本次修复的做法不是"少输一次密钥",而是建立一套统一的脱敏原语,并把 CJS 命令行与 SDK 两条调用链都接到同一套规则上。
二、敏感键集合:SECRET_CONFIG_KEYS 与 maskIfSecret 边界
秘密键集合定义在 sdk/src/query/secrets.ts,目前锁定为三个搜索/抓取类集成:
export const SECRET_CONFIG_KEYS: ReadonlySet<string> = new Set([
'brave_search',
'firecrawl',
'exa_search',
]);
export function isSecretKey(keyPath: string): boolean {
return SECRET_CONFIG_KEYS.has(keyPath);
}
围绕该集合提供两个核心判断工具:
isSecretKey(keyPath):判断某个点号形式的配置键(如brave_search)是否属于敏感键;maskIfSecret<T>(keyPath, value):只在键为敏感键时才掩码,否则原样返回,适合在响应构造边界直接套用:
export function maskIfSecret<T>(keyPath: string, value: T): T | string {
return isSecretKey(keyPath) ? maskSecret(value) : value;
}
需要强调的设计意图(同样写在 secrets.ts 注释中):磁盘上的 config.json 值不变,只有输出响应被脱敏。因为密钥明文本就存放在 config.json 中(那是"密钥的归宿"),CLI/SDK 只是绝不能把明文再送回终端。
三、掩码规则:****<后四位> 的约定与示例
统一的掩码实现 maskSecret 遵循一套可测试、可跨语言复制的约定,定义于 sdk/src/query/secrets.ts:
export function maskSecret(value: unknown): string {
if (value === null || value === undefined || value === '') return '(unset)';
const s = String(value);
if (s.length < 8) return '****';
return '****' + s.slice(-4);
}
规则可以归纳为三条:
| 输入情况 | 掩码输出 | 说明 |
|---|---|---|
null / undefined / 空串 '' |
(unset) |
明确表达"未配置"而非空值 |
| 字符串长度 < 8 | **** |
过短不留尾巴,避免泄露可猜测信息 |
| 字符串长度 ≥ 8 | ****<last-4> |
仅暴露后 4 位,便于人工区分多个 Key |
对应行为由单元测试锁定在 sdk/src/query/secrets.test.ts,例如:
maskSecret('BSA-secret-key-abcd1234')→'****1234'maskSecret('short')→'****'maskSecret(null)→'(unset)'maskIfSecret('model_profile', 'quality')→'quality'(非敏感键原样透传)maskIfSecret('brave_search', 'BSA-1234567890')→'****7890'
同一测试文件还断言了 SECRET_CONFIG_KEYS 集合内容被"锁定"(locked),防止有人误删或误加敏感键导致行为漂移。
四、CJS 侧接入点:config.cjs 如何保证命令行不泄密
命令行的脱敏并非本次新增,而是本次被"找回来"的对齐基准。CLI 侧通过 config.cjs 引入 secrets.cjs(见 get-shit-done/bin/lib/config.cjs)。
4.1 config-set:写入前校验、输出时掩码
cmdConfigSet 在写入成功后立即判断敏感键(config.cjs):
// Mask secrets in both JSON and text output. The plaintext is written
// to config.json (that's where secrets live on disk); the CLI output
// must never echo it.
if (isSecretKey(keyPath)) {
const masked = maskSecret(parsedValue);
const maskedPrev = setConfigValueResult.previousValue === undefined
? undefined
: maskSecret(setConfigValueResult.previousValue);
const maskedResult = {
...setConfigValueResult,
value: masked,
previousValue: maskedPrev,
masked: true,
};
output(maskedResult, raw, `${keyPath}=${masked}`);
return;
}
关键细节是:新值与旧值(previousValue)都被掩码,同时结果对象上追加 masked: true 标记,且 JSON 与文本两种输出形态都走掩码分支,避免 --raw 模式绕过脱敏。
4.2 config-get:读取响应同样永远呈现掩码形态
cmdConfigGet 在遍历点号路径拿到值之后,命中敏感键时直接输出掩码结果(config.cjs):
// Never echo plaintext for sensitive keys via config-get. Plaintext lives
// in config.json on disk; the CLI surface always shows the masked form.
if (isSecretKey(keyPath)) {
const masked = maskSecret(current);
output(masked, raw, masked);
return;
}
也就是说:无论 config.json 里存的是多长的明文 Key,config-get brave_search 反馈给用户的永远只会是 ****后四位 形态。
4.3 config-set 的取值校验前提
另外值得了解的是,config-set 在写值前会做完整参数与枚举校验(config.cjs),包括拒绝"只有键没有值"的形式(issue #3593 引入的防呆),以及针对 context、workflow.drift_action、ship.pr_body_sections 等键的专项合法值检查。这些前置校验保证进入写盘与脱敏逻辑的值是可控的。
五、SDK 侧修复:在响应构造边界统一套用掩码
本次修复的核心是把同一套规则移植进 SDK 查询层。三处调用点与 CJS 严格对齐:
5.1 config-query.ts(读)
sdk/src/query/config-query.ts 引入 maskIfSecret,在读取响应的边界处(L151-L154)掩码:
// Mask plaintext for keys in SECRET_CONFIG_KEYS to match CJS behavior ...
return { data: maskIfSecret(keyPath, current) };
5.2 config-mutation.ts(写)
sdk/src/query/config-mutation.ts 在写入响应中同时处理 value 与 previousValue,与 CJS 的 config-set 语义一致,保证 SDK 调用方拿到的回执不会携带明文。
5.3 init.ts(初始化响应)
sdk/src/query/init.ts 对三个密钥键执行有条件的字符串掩码:
brave_search: typeof config.brave_search === 'string' ? maskIfSecret('brave_search', config.brave_search) : config.brave_search,
firecrawl: typeof config.firecrawl === 'string' ? maskIfSecret('firecrawl', config.firecrawl) : config.firecrawl,
exa_search: typeof config.exa_search === 'string' ? maskIfSecret('exa_search', config.exa_search) : config.exa_search,
这正是 changelog 中"init bundles only mask string values"的实现落点。
六、易踩坑点:布尔可用性标记必须原样透传
这是本变更里最微妙的一处设计,体现在 get-shit-done/bin/lib/init.cjs 的 init 响应构造(L846-L856):
// #2997: secret config keys may be either booleans (availability flags) or
// string API keys (when user did `gsd-tools config-set brave_search XXX`).
// Pass booleans through; mask string values so the init bundle never echoes
// plaintext credentials. SDK init.ts mirrors this masking.
brave_search: typeof config.brave_search === 'string'
? maskIfSecret('brave_search', config.brave_search)
: config.brave_search,
理解这个分支需要知道:brave_search 这类键在配置里存在两种合法形态——
- 布尔可用性标记:init 阶段会探测各搜索/抓取集成是否已配置可用 Key(init.cjs 附近有 "Detect Brave Search API key availability" 等探测逻辑),探测结果以布尔值写盘/上报,供上层判断"该集成能否使用";
- 字符串 API Key:当用户执行
config-set brave_search <key>时,该键的值被替换为真正的密钥字符串。
init 响应同时服务于两种消费方,因此:
- 布尔值不能被塞进
maskSecret(布尔不是字符串,脱敏会破坏true/false语义),必须原样透传以保留可用性契约; - 字符串值必须掩码,否则一次 init 就会把整把 Key 打进会话上下文。
这种"按值类型分流"的写法被 init.cjs 与 SDK 的 init.ts 双份镜像实现,是防止"为了修泄漏反而搞挂功能探测"的关键护栏。
七、双实现一致性:从 TypeScript 单源生成 CJS 工件
仓库里存在 CJS(bin/lib)与 TypeScript SDK(sdk/src)两套运行时,若两份脱敏实现靠手写维护,迟早会漂移。解决方式是单源生成 + 奇偶校验(parity test):
- 逻辑单源为 sdk/src/query/secrets.ts;
- 生成脚本 sdk/scripts/gen-secrets.mjs 读取编译后的 ESM 输出,用
Function.prototype.toString()抽取isSecretKey/maskSecret/maskIfSecret的函数体,并把SECRET_CONFIG_KEYS重建为常量声明,最终写出 get-shit-done/bin/lib/secrets.generated.cjs; - 生成文件头部明示
GENERATED FILE — DO NOT EDIT,并给出再生成命令:cd sdk && npm run gen:secrets; - get-shit-done/bin/lib/secrets.cjs 只是对生成物的一层薄转发(re-export),让历史调用方
require('./secrets')无需改动。
在此基础上,sdk/src/query/secrets.test.ts 里的一组嵌套测试直接加载 CJS 工件做逐样本奇偶校验:断言两边 SECRET_CONFIG_KEYS 集合完全一致,且对代表性输入 maskSecret 输出逐字节相同。仓库根下另有 tests/secrets-generator.test.cjs 守护生成器的正确性,保证"生成—手写—测试"闭环不被破坏。
八、修复验证与迁移价值
把本变更放到整个修复闭环中看,它回答了三个可验证的问题:
- 敏感键集合是否完整:
SECRET_CONFIG_KEYS当前锁定为brave_search、firecrawl、exa_search三个键,由 secrets.test.ts 断言锁定; - 两条调用链是否都脱敏:CJS 侧由 config.cjs 的
config-set/config-get覆盖,SDK 侧由 config-query.ts、config-mutation.ts 及对应测试(如config-query.test.ts中 "masks the response data for SECRET_CONFIG_KEYS" 用例)覆盖; - 布尔可用性契约是否保留:init 响应按"值类型分流",布尔透传、字符串掩码,CJS 与 init.ts 行为镜像,杜绝因脱敏误伤功能开关。
这套机制的核心思想值得在同类工具中复制:密钥明文只存放在磁盘配置文件这一处,任何对外输出(CLI、SDK 响应、init 上报)在构造边界统一脱敏;脱敏逻辑做成单源生成的可共享模块,并用奇偶测试保证多运行时行为一致。它同时提醒迁移者:把老代码逻辑迁到新语言/新模块时,安全语义(如脱敏)是最容易被"看起来无关紧要"而丢掉的隐性契约——而 PR #2997 正是这样一个被追回来的契约。
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 StartedRust0626
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