fix(#2997): mask SECRET_CONFIG_KEYS in SDK config-set/get and init responses (#2999)

* fix(#2997): mask SECRET_CONFIG_KEYS in SDK config-set/get and init responses

The CJS→TS port at sdk/src/query/config-mutation.ts:240,243 and
config-query.ts:122,128,132 dropped the masking layer that secrets.cjs
spec defines for brave_search/firecrawl/exa_search. Result: the SDK
echoed plaintext API keys into machine-readable JSON output (stdout,
transcripts, CI logs).

Adjacent leak in init.ts:673-675 / init.cjs:728-730: the init bundle
passed config.brave_search through raw, leaking the API key whenever
the user had stored one.

Fix:
- New sdk/src/query/secrets.ts ports SECRET_CONFIG_KEYS, isSecretKey,
  maskSecret, maskIfSecret. Exact CJS parity (verified by 17 tests
  in secrets.test.ts that import secrets.cjs and compare).
- config-set masks value + previousValue in response; on-disk plaintext
  intact (key stays usable).
- config-get masks read response. --default flows through unmasked
  (user's own input, not stored secret).
- init.ts/init.cjs mask string values only; booleans (availability
  flags) pass through unchanged so the typed contract is preserved.

Tests: 17 in secrets.test.ts (including CJS parity), 5 in
config-mutation.test.ts (#2997 block — covers on-disk-preserved,
previousValue masking, short-value, unset, non-secret pass-through),
4 in config-query.test.ts.

Closes #2997

* chore(#2997): add changeset fragment for PR #2999

* chore(#2997): add changeset fragment for PR #2999

* chore(#2999): drop direct CHANGELOG.md edit; release entry now lives in .changeset/

The changeset-fragment workflow (#2975) renders fragments into
CHANGELOG.md at release time. Direct edits to [Unreleased] on
each PR caused merge conflicts on every concurrent PR. This commit
restores CHANGELOG.md to match origin/main; the release entry for
this fix is preserved in the .changeset/*.md fragment(s) on this
branch, which the release workflow consolidates.
This commit is contained in:
Tom Boucher
2026-05-02 00:17:45 -04:00
committed by GitHub
parent 4e378d37d8
commit ffeeb92c14
10 changed files with 246 additions and 10 deletions

View File

@@ -219,3 +219,50 @@ describe('VALID_PROFILES', () => {
expect(VALID_PROFILES).toEqual(['quality', 'balanced', 'budget', 'adaptive']);
});
});
// ─── #2997: Secret masking in configGet response ────────────────────────────
describe('configGet secret masking (#2997)', () => {
it('masks the response data for SECRET_CONFIG_KEYS', async () => {
const { configGet } = await import('./config-query.js');
const apiKey = 'BSA-1234567890abcdef';
await writeFile(
join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ brave_search: apiKey }),
);
const result = await configGet(['brave_search'], tmpDir);
expect(result.data).toBe('****cdef');
expect(result.data).not.toBe(apiKey);
});
it('does NOT mask non-secret keys', async () => {
const { configGet } = await import('./config-query.js');
await writeFile(
join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ model_profile: 'quality' }),
);
const result = await configGet(['model_profile'], tmpDir);
expect(result.data).toBe('quality');
});
it('renders short secret values as **** (no tail leak)', async () => {
const { configGet } = await import('./config-query.js');
await writeFile(
join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ firecrawl: 'abc' }),
);
const result = await configGet(['firecrawl'], tmpDir);
expect(result.data).toBe('****');
});
it('does not mask the user-supplied --default value (it is the user\'s own input, not a stored secret)', async () => {
const { configGet } = await import('./config-query.js');
await writeFile(
join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ model_profile: 'balanced' }),
);
const result = await configGet(['brave_search', '--default', 'placeholder'], tmpDir);
// Default flows through unchanged: the user typed it, the SDK echoed it.
expect(result.data).toBe('placeholder');
});
});