* 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:
@@ -492,3 +492,54 @@ describe('configEnsureSection', () => {
|
||||
expect(raw.workflow).toEqual({ research: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #2997: Secret masking in configSet response ────────────────────────────
|
||||
|
||||
describe('configSet secret masking (#2997)', () => {
|
||||
it('masks the response value for SECRET_CONFIG_KEYS, leaving on-disk plaintext intact', async () => {
|
||||
const { configSet } = await import('./config-mutation.js');
|
||||
const apiKey = 'BSA-1234567890abcdef';
|
||||
const result = await configSet(['brave_search', apiKey], tmpDir);
|
||||
const data = result.data as { value: string };
|
||||
// Response is masked
|
||||
expect(data.value).toBe('****cdef');
|
||||
expect(data.value).not.toContain(apiKey);
|
||||
// On-disk plaintext is intact (the key is usable)
|
||||
const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8'));
|
||||
expect(raw.brave_search).toBe(apiKey);
|
||||
});
|
||||
|
||||
it('masks previousValue when overwriting an existing secret', async () => {
|
||||
const { configSet } = await import('./config-mutation.js');
|
||||
await writeFile(
|
||||
join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ brave_search: 'OLD-KEY-1234567890' }),
|
||||
);
|
||||
const result = await configSet(['brave_search', 'NEW-KEY-abcdef9999'], tmpDir);
|
||||
const data = result.data as { value: string; previousValue: string };
|
||||
expect(data.value).toBe('****9999');
|
||||
expect(data.previousValue).toBe('****7890');
|
||||
expect(data.previousValue).not.toContain('OLD-KEY');
|
||||
});
|
||||
|
||||
it('does NOT mask non-secret keys', async () => {
|
||||
const { configSet } = await import('./config-mutation.js');
|
||||
const result = await configSet(['model_profile', 'quality'], tmpDir);
|
||||
const data = result.data as { value: string };
|
||||
expect(data.value).toBe('quality');
|
||||
});
|
||||
|
||||
it('renders short secret values as **** (no tail leak)', async () => {
|
||||
const { configSet } = await import('./config-mutation.js');
|
||||
const result = await configSet(['firecrawl', 'short'], tmpDir);
|
||||
const data = result.data as { value: string };
|
||||
expect(data.value).toBe('****');
|
||||
});
|
||||
|
||||
it('renders unset/empty as (unset) in the response', async () => {
|
||||
const { configSet } = await import('./config-mutation.js');
|
||||
const result = await configSet(['exa_search', ''], tmpDir);
|
||||
const data = result.data as { value: string };
|
||||
expect(data.value).toBe('(unset)');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user