首页
/ get-shit-done 安全修复解析:config-set/config-get 与 init 响应不再回显明文 API Key

get-shit-done 安全修复解析:config-set/config-get 与 init 响应不再回显明文 API Key

2026-09-07 13:59:08作者:宣利权Counsellor

导读

这是一篇针对 get-shit-done 仓库 PR #2997 安全修复(changelog 条目 .changeset/merry-foxes-climb.md,类型 Fixed)的技术解读。该修复解决了一个真实泄漏隐患:配置命令与初始化响应会把 brave_searchfirecrawlexa_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.tssdk/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 引入的防呆),以及针对 contextworkflow.drift_actionship.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 在写入响应中同时处理 valuepreviousValue,与 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 这类键在配置里存在两种合法形态——

  1. 布尔可用性标记:init 阶段会探测各搜索/抓取集成是否已配置可用 Key(init.cjs 附近有 "Detect Brave Search API key availability" 等探测逻辑),探测结果以布尔值写盘/上报,供上层判断"该集成能否使用";
  2. 字符串 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.test.ts 里的一组嵌套测试直接加载 CJS 工件做逐样本奇偶校验:断言两边 SECRET_CONFIG_KEYS 集合完全一致,且对代表性输入 maskSecret 输出逐字节相同。仓库根下另有 tests/secrets-generator.test.cjs 守护生成器的正确性,保证"生成—手写—测试"闭环不被破坏。

八、修复验证与迁移价值

把本变更放到整个修复闭环中看,它回答了三个可验证的问题:

  1. 敏感键集合是否完整SECRET_CONFIG_KEYS 当前锁定为 brave_searchfirecrawlexa_search 三个键,由 secrets.test.ts 断言锁定;
  2. 两条调用链是否都脱敏:CJS 侧由 config.cjsconfig-set/config-get 覆盖,SDK 侧由 config-query.tsconfig-mutation.ts 及对应测试(如 config-query.test.ts 中 "masks the response data for SECRET_CONFIG_KEYS" 用例)覆盖;
  3. 布尔可用性契约是否保留:init 响应按"值类型分流",布尔透传、字符串掩码,CJS 与 init.ts 行为镜像,杜绝因脱敏误伤功能开关。

这套机制的核心思想值得在同类工具中复制:密钥明文只存放在磁盘配置文件这一处,任何对外输出(CLI、SDK 响应、init 上报)在构造边界统一脱敏;脱敏逻辑做成单源生成的可共享模块,并用奇偶测试保证多运行时行为一致。它同时提醒迁移者:把老代码逻辑迁到新语言/新模块时,安全语义(如脱敏)是最容易被"看起来无关紧要"而丢掉的隐性契约——而 PR #2997 正是这样一个被追回来的契约。

登录后查看全文
热门项目推荐
相关项目推荐