fix(#2256): resolve capability-registry configSchema defaults in config-get (#2299)

cmdConfigGet resolved absent keys through only the 4-key SCHEMA_DEFAULTS
map, so the ~42 registry-declared configSchema defaults (including the
workflow.security_enforcement security gate, default true) returned
'Key not found' (rc=1) — diverging from the runtime's own
resolveConfigKey Level-4 resolver and letting '... || echo false'
guards silently read the gate as disabled.

Add a resolveSchemaDefault helper that layers SCHEMA_DEFAULTS over the
already-imported getCapabilityConfigSchema(cwd) accessor, wired into all
three absent-key branches. --default flag precedence, the legacy 4 keys,
and 'Key not found' for genuinely unknown keys are preserved.

Two pre-existing, security-relevant defects in the same surface, found
while writing the regression tests, are fixed inline (no-defer policy):
- The --default fallback path never masked secret-named keys, printing
  e.g. 'config-get brave_search --default <secret>' in plaintext. All
  six default-emission sites now route through emitResolvedDefault,
  which applies the same isSecretKey/maskSecret masking the found-key
  path uses.
- Dotted-key traversal used raw bracket access, so 'config-get __proto__'
  / 'constructor' walked the JS prototype chain and returned internals
  at rc=0 instead of erroring. Each segment is now own-property-gated.

Regression tests folded into tests/config-get-default.test.cjs cover
registry defaults (boolean/enum/number, read live from the registry),
the no-file/mid-traversal/final-undefined branches, --default and legacy
precedence, prototype-pollution keys, secret masking, and the
Key-not-found vs No-config-file negative cases.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-07-15 11:47:15 -04:00
committed by GitHub
parent 315d94f6d4
commit 9c65a2ea02
4 changed files with 492 additions and 21 deletions

View File

@@ -0,0 +1,5 @@
---
type: Security
pr: 2299
---
**`query config-get` no longer leaks secret values or walks the prototype chain** — the `--default` fallback path printed secret-named keys (e.g. `brave_search`) in plaintext instead of masking them, and dotted-key traversal used raw property access so `config-get __proto__`/`constructor` resolved to JavaScript internals at exit 0 instead of erroring. Both absent-key resolution and traversal are now masked and own-property-gated. (#2256)

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 2299
---
**`query config-get` now returns capability-registry defaults for absent keys** — keys declared with a default in the capability registry (e.g. `workflow.security_enforcement`, which defaults to `true`) previously reported "Key not found" (exit 1) when missing from config.json, diverging from the runtime's own resolver and letting `... || echo false` guards silently read the security gate as disabled. config-get now resolves these through the same registry defaults the runtime uses. (#2256)

View File

@@ -91,6 +91,46 @@ const SCHEMA_DEFAULTS: Record<string, unknown> = {
'git.create_tag': true,
};
/**
* Resolve a schema-level default for an absent key (#2256). Checks the legacy
* hardcoded SCHEMA_DEFAULTS first, then the capability-registry configSchema
* default — the same registry default the runtime's capability-activation
* resolver (resolveConfigKey Level 4, capability-activation.cts) already honors,
* so `query config-get` can no longer disagree with the runtime about an absent
* key's effective value.
*/
function resolveSchemaDefault(cwd: string, kp: string): { found: boolean; value: unknown } {
if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, kp)) {
return { found: true, value: SCHEMA_DEFAULTS[kp] };
}
const capSchema = getCapabilityConfigSchema(cwd);
if (capSchema && typeof capSchema === 'object'
&& Object.prototype.hasOwnProperty.call(capSchema, kp)) {
const entry = capSchema[kp];
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
const def = (entry as Record<string, unknown>)['default'];
if (def !== undefined) return { found: true, value: def };
}
}
return { found: false, value: undefined };
}
/**
* Emit a schema-resolved default (#2256), applying the same secret-masking
* invariant the found-key path applies. getCapabilityConfigSchema is a
* federated, third-party-extensible surface (ADR-1244) — a future key-name
* collision with a secret key must not leak a declared default in plaintext.
* Centralizing emission here means masking can't be missed at a call site.
*/
function emitResolvedDefault(kp: string, value: unknown, raw: boolean): void {
if (isSecretKey(kp)) {
const masked = maskSecret(value as Parameters<typeof maskSecret>[0]);
output(masked, raw, masked);
return;
}
output(value, raw, String(value));
}
// ─── Validation helpers ───────────────────────────────────────────────────────
function validateKnownConfigKeyPath(keyPath: string): void {
@@ -910,14 +950,11 @@ function cmdConfigGet(cwd: string, keyPath: string | undefined, raw: boolean, de
if (fs.existsSync(configPath)) {
config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
} else if (hasDefault) {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
output(defaultValue, raw, String(defaultValue));
return;
} else if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, kp)) {
const def = SCHEMA_DEFAULTS[kp];
output(def, raw, String(def));
emitResolvedDefault(kp, defaultValue, raw);
return;
} else {
const sd = resolveSchemaDefault(cwd, kp);
if (sd.found) { emitResolvedDefault(kp, sd.value, raw); return; }
error('No config.json found at ' + configPath, ERROR_REASON.CONFIG_NO_FILE);
}
} catch (err) {
@@ -930,26 +967,29 @@ function cmdConfigGet(cwd: string, keyPath: string | undefined, raw: boolean, de
let current: unknown = config;
for (const key of keys) {
if (current === undefined || current === null || typeof current !== 'object') {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
if (hasDefault) { output(defaultValue, raw, String(defaultValue)); return; }
if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, kp)) {
const def = SCHEMA_DEFAULTS[kp];
output(def, raw, String(def));
return;
}
if (hasDefault) { emitResolvedDefault(kp, defaultValue, raw); return; }
const sd = resolveSchemaDefault(cwd, kp);
if (sd.found) { emitResolvedDefault(kp, sd.value, raw); return; }
error(`Key not found: ${kp}`, ERROR_REASON.CONFIG_KEY_NOT_FOUND);
}
current = (current as Record<string, unknown>)[key];
// Own-property gate: bracket access on a plain object walks the
// prototype chain, so an unqualified `current[key]` would resolve
// '__proto__' / 'constructor' / 'hasOwnProperty' (and other
// Object.prototype members) to their inherited values instead of
// correctly reporting them as absent. hasOwnProperty.call only
// returns true for a key JSON.parse actually assigned as data on
// this object (including a literal "__proto__" JSON key, which
// JSON.parse defines as an own data property, not the accessor) —
// never for something inherited from the prototype chain.
current = Object.prototype.hasOwnProperty.call(current, key)
? (current as Record<string, unknown>)[key]
: undefined;
}
if (current === undefined) {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
if (hasDefault) { output(defaultValue, raw, String(defaultValue)); return; }
if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, kp)) {
const def = SCHEMA_DEFAULTS[kp];
output(def, raw, String(def));
return;
}
if (hasDefault) { emitResolvedDefault(kp, defaultValue, raw); return; }
const sd = resolveSchemaDefault(cwd, kp);
if (sd.found) { emitResolvedDefault(kp, sd.value, raw); return; }
error(`Key not found: ${kp}`, ERROR_REASON.CONFIG_KEY_NOT_FOUND);
}

View File

@@ -229,6 +229,427 @@ describe('config-get --default flag (#1893)', () => {
});
});
// ────────────────────────────────────────────────────────────────────────
// #2256 — config-get was blind to capability-registry configSchema defaults.
//
// cmdConfigGet's three absent-key branches (no-config-file, mid-traversal
// non-object, final-undefined) only consulted the 4-key SCHEMA_DEFAULTS map
// before erroring "Key not found". The capability registry declares ~42
// configSchema defaults (e.g. workflow.security_enforcement -> true) that
// resolveConfigKey's Level 4 (capability-activation.cts) already honors at
// runtime — so `query config-get` could disagree with the runtime about the
// effective value of an absent key. Fix: cmdConfigGet now also consults
// getCapabilityConfigSchema(cwd) via a resolveSchemaDefault() helper before
// erroring.
// ────────────────────────────────────────────────────────────────────────
{
const configSchemaMod = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'config-schema.cjs'));
// Repo idiom for property tests (matches config-schema.property.test.cjs):
// require the shared seeded/bounded wrapper, not bare 'fast-check', so this
// property run is deterministic across CI (seed 42) rather than fuzzing with
// a fresh random seed on every invocation.
const fc = require('./helpers/fast-check-setup.cjs');
describe('config-get registry configSchema defaults (#2256)', () => {
let tmpDir;
let planningDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-config-2256-'));
planningDir = path.join(tmpDir, '.planning');
});
afterEach(() => {
cleanup(tmpDir);
});
function run(...args) {
const { keyPath, raw, defaultValue } = parseConfigGetArgs(args);
const out = captureFdWrite(1, () => {
config.cmdConfigGet(tmpDir, keyPath, raw, defaultValue);
});
return out.trim();
}
function runRaw(...args) {
return run(...args, '--raw');
}
function runExpectError(...args) {
const { keyPath, raw, defaultValue } = parseConfigGetArgs(args);
const origExit = process.exit;
const origWriteSync = fs.writeSync;
io.setJsonErrorMode(true);
let exitCount = 0;
let exitCode;
let stderr = '';
fs.writeSync = (fd, ...rest) => {
if (fd !== 2) return origWriteSync.call(fs, fd, ...rest);
const [data, offset = 0, length] = rest;
const chunk = Buffer.isBuffer(data)
? data.subarray(offset, offset + (length ?? data.length - offset)).toString('utf8')
: String(data);
stderr += chunk;
return Buffer.byteLength(chunk);
};
const lastError = () => {
const parts = stderr.split('\n').filter(Boolean);
try { return JSON.parse(parts[parts.length - 1]); } catch { return {}; }
};
process.exit = (code) => {
exitCount++;
exitCode = code;
throw new _ExitSignal(code, lastError().message);
};
try {
config.cmdConfigGet(tmpDir, keyPath, raw, defaultValue);
} catch (e) {
if (!(e instanceof _ExitSignal)) throw e;
} finally {
process.exit = origExit;
fs.writeSync = origWriteSync;
io.setJsonErrorMode(false);
}
assert.ok(exitCode !== 0 && exitCode !== undefined, 'Expected non-zero exit code');
assert.equal(exitCount, 1, 'error() must fire exactly once (production process.exit terminates)');
const payload = lastError();
return { status: exitCode, reason: payload.reason, message: payload.message, stderr };
}
// Pull the real registry defaults instead of hardcoding a guess, so this
// test tracks the registry rather than pinning a stale snapshot of it.
const capSchema = configSchemaMod.getCapabilityConfigSchema();
const securityEnforcementDefault = capSchema['workflow.security_enforcement']?.default;
const securityBlockOnDefault = capSchema['workflow.security_block_on']?.default;
const securityAsvsLevelDefault = capSchema['workflow.security_asvs_level']?.default;
test('primary: no config.json — registry-defaulted boolean key resolves via --raw (not "Key not found")', () => {
// No .planning dir at all — the no-config-file branch (branch 1).
assert.equal(fs.existsSync(planningDir), false, 'pre-check: no .planning dir');
assert.equal(securityEnforcementDefault, true, 'pre-check: registry default for workflow.security_enforcement is true');
const result = runRaw('config-get', 'workflow.security_enforcement');
assert.equal(result, 'true', 'must return the registry default, not error');
});
test('no config.json — registry-defaulted enum key resolves to its registry default', () => {
assert.equal(fs.existsSync(planningDir), false, 'pre-check: no .planning dir');
assert.equal(typeof securityBlockOnDefault, 'string');
const result = runRaw('config-get', 'workflow.security_block_on');
assert.equal(result, securityBlockOnDefault);
});
test('no config.json — registry-defaulted number key resolves to its registry default', () => {
assert.equal(fs.existsSync(planningDir), false, 'pre-check: no .planning dir');
assert.equal(typeof securityAsvsLevelDefault, 'number');
const result = runRaw('config-get', 'workflow.security_asvs_level');
assert.equal(result, String(securityAsvsLevelDefault));
});
test('config.json exists but key is absent after full traversal — registry default still resolves (final-undefined branch)', () => {
// keys = ['workflow', 'security_enforcement']. First segment traverses
// into a real object ({ auto_advance: false }); the second segment is
// simply absent from it, so the loop completes and `current` comes out
// undefined — this is the FINAL-undefined branch (branch 3), not
// mid-traversal (branch 2 fires only when an INTERMEDIATE segment is a
// non-object scalar; see the dedicated mid-traversal test below).
fs.mkdirSync(planningDir, { recursive: true });
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ workflow: { auto_advance: false } }),
);
const result = runRaw('config-get', 'workflow.security_enforcement');
assert.equal(result, 'true');
});
test('config.json has a non-object intermediate segment — registry default still resolves (true mid-traversal branch)', () => {
// keys = ['workflow', 'security_enforcement']. `workflow` itself is a
// boolean scalar, not an object, so the SECOND loop iteration's guard
// (`typeof current !== 'object'`) fires before any further descent —
// this is the genuine mid-traversal branch (branch 2).
fs.mkdirSync(planningDir, { recursive: true });
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ workflow: true }),
);
const result = runRaw('config-get', 'workflow.security_enforcement');
assert.equal(result, 'true');
});
test('legacy SCHEMA_DEFAULTS key still resolves unchanged (context_window -> 200000)', () => {
fs.mkdirSync(planningDir, { recursive: true });
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ workflow: { auto_advance: false } }),
);
const result = runRaw('config-get', 'context_window');
assert.equal(result, '200000');
});
test('--default flag still wins over the registry default for an absent registry key', () => {
const result = runRaw('config-get', 'workflow.security_enforcement', '--default', 'flag-wins');
assert.equal(result, 'flag-wins');
});
test('a genuinely unknown, non-registry, non-legacy key still errors "Key not found" (rc1) when config.json exists', () => {
// config.json must exist here: with NO config.json, cmdConfigGet's
// no-config-file branch fires first and reports CONFIG_NO_FILE before
// ever reaching the traversal path's CONFIG_KEY_NOT_FOUND check (see
// the dedicated no-config-file test below for that branch). Writing a
// config.json here routes the unknown key through the real traversal
// path so this test actually exercises "unknown key found not
// permissive", not "no config file yet".
fs.mkdirSync(planningDir, { recursive: true });
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ workflow: { auto_advance: true } }),
);
const { status, reason } = runExpectError('config-get', 'nonsense.totally_made_up_key', '--raw');
assert.equal(status, 1);
assert.equal(reason, io.ERROR_REASON.CONFIG_KEY_NOT_FOUND, 'unknown key must not become permissive');
});
test('a genuinely unknown, non-registry, non-legacy key with NO config.json errors "No config.json found" (rc1)', () => {
// Pins the actual (distinct) behavior of the no-config-file branch:
// an unrecognized key with no config file at all legitimately reports
// CONFIG_NO_FILE — it never reaches the CONFIG_KEY_NOT_FOUND check,
// because that check lives in the traversal path which only runs once
// a config object exists (or a default/schema-default short-circuits
// first). A registry-defaulted key in this same no-file scenario
// instead resolves its default (see the "primary" test above) — the
// two behaviors are complementary and both worth locking in.
assert.equal(fs.existsSync(planningDir), false, 'pre-check: no .planning dir');
const { status, reason } = runExpectError('config-get', 'nonsense.totally_made_up_key', '--raw');
assert.equal(status, 1);
assert.equal(reason, io.ERROR_REASON.CONFIG_NO_FILE, 'no config file at all must report CONFIG_NO_FILE');
});
// ── Prototype-pollution guard: bracket-access traversal on a plain
// object walks the JS prototype chain, so an unqualified `current[key]`
// could resolve '__proto__' / 'constructor' / 'hasOwnProperty' to their
// inherited Object.prototype values instead of correctly reporting them
// absent. cmdConfigGet's traversal loop gates each descent on
// Object.prototype.hasOwnProperty.call(current, key) precisely to close
// this off; these tests pin that it stays closed.
for (const protoKey of ['__proto__', 'constructor']) {
test(`config-get ${protoKey} with no config.json errors safely (does not resolve Object.prototype/Function)`, () => {
assert.equal(fs.existsSync(planningDir), false, 'pre-check: no .planning dir');
const { status, reason } = runExpectError('config-get', protoKey, '--raw');
assert.equal(status, 1);
// No config.json at all -> the no-config-file branch fires first
// (same as any other absent, non-registry key); the important
// invariant is that it errors rc1 and never leaks a prototype
// object/function representation at rc0.
assert.equal(reason, io.ERROR_REASON.CONFIG_NO_FILE);
});
test(`config-get ${protoKey} with config.json present errors "Key not found" (does not walk the prototype chain)`, () => {
fs.mkdirSync(planningDir, { recursive: true });
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ workflow: { auto_advance: true } }),
);
const { status, reason } = runExpectError('config-get', protoKey, '--raw');
assert.equal(status, 1);
assert.equal(reason, io.ERROR_REASON.CONFIG_KEY_NOT_FOUND,
`${protoKey} must not resolve via the prototype chain`);
});
}
test('config-get hasOwnProperty (a nested Object.prototype method name) errors "Key not found", not the inherited function', () => {
fs.mkdirSync(planningDir, { recursive: true });
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ workflow: { auto_advance: true } }),
);
const { status, reason } = runExpectError('config-get', 'hasOwnProperty', '--raw');
assert.equal(status, 1);
assert.equal(reason, io.ERROR_REASON.CONFIG_KEY_NOT_FOUND);
});
// ── Secret-masking on the resolved-default path (finding #4). No
// first-party registry key is both secret-named and schema-defaulted,
// and getCapabilityConfigSchema(cwd) is not fixture-injectable from a
// black-box test (it composes from real installed-capability discovery
// under `cwd`, not a seam this test can substitute). The reachable,
// faithful-to-production seam is `--default` on a secret-named key path:
// cmdConfigGet's `hasDefault` branches sit in the exact same absent-key
// position as the resolveSchemaDefault() branches and must apply the
// identical isSecretKey()/maskSecret() masking — this exercises that the
// masking invariant is real and observable at the CLI-args level, not
// merely aspirational in the resolveSchemaDefault plumbing.
test('secret-named key resolved via --default is masked, not echoed in plaintext', () => {
// 'brave_search' is a real entry in SECRET_CONFIG_KEYS (src/secrets.cts) —
// the same isSecretKey() gate emitResolvedDefault() applies.
const result = runRaw('config-get', 'brave_search', '--default', 'sk-plaintext-should-not-leak');
assert.notEqual(result, 'sk-plaintext-should-not-leak', 'a secret-named key must never echo its raw value');
assert.match(result, /\*/, 'masked secret output should contain masking characters');
});
// ── Property test: dotted-key traversal safety contract ────────────────
//
// Runs cmdConfigGet fully in-process against an ISOLATED temp dir created
// fresh for every fc run (unique mkdtemp per run body, cleaned up in a
// finally — no shared/leaked state across runs).
function runInProcessAt(dir, keyPath) {
const origExit = process.exit;
const origWriteSync = fs.writeSync;
io.setJsonErrorMode(true);
let stdout = '';
let stderr = '';
let exitCode = 0;
let exited = false;
fs.writeSync = (fd, ...rest) => {
const [data, offset = 0, length] = rest;
const chunk = Buffer.isBuffer(data)
? data.subarray(offset, offset + (length ?? data.length - offset)).toString('utf8')
: String(data);
if (fd === 1) stdout += chunk;
else if (fd === 2) stderr += chunk;
return Buffer.byteLength(chunk);
};
process.exit = (code) => {
exited = true;
exitCode = code;
throw new _ExitSignal(code, '');
};
try {
config.cmdConfigGet(dir, keyPath, true, undefined);
} catch (e) {
if (!(e instanceof _ExitSignal)) throw e;
} finally {
process.exit = origExit;
fs.writeSync = origWriteSync;
io.setJsonErrorMode(false);
}
let reason = null;
if (exited) {
const parts = stderr.split('\n').filter(Boolean);
try { reason = JSON.parse(parts[parts.length - 1]).reason; } catch { /* no structured payload */ }
}
return { exited, exitCode, stdout: stdout.trim(), reason };
}
test('property: dotted-key traversal never resolves a value sourced from the JS prototype chain', () => {
const PROTO_MEMBER_NAMES = ['__proto__', 'constructor', 'prototype', 'hasOwnProperty', 'toString', 'valueOf', 'isPrototypeOf'];
const randomSegmentArb = fc.stringMatching(/^[a-z][a-z0-9_]{0,8}$/);
const segmentArb = fc.oneof(fc.constantFrom(...PROTO_MEMBER_NAMES), randomSegmentArb);
// Every generated path is FORCED to include at least one prototype-member
// segment (interleaved with 0-4 random segments). That guarantees the
// full dotted path can never equal a real SCHEMA_DEFAULTS or
// capability-registry key: none of those keys have a segment literally
// named '__proto__' / 'constructor' / etc., so equality would require
// every segment to match, which a proto-member segment rules out. That
// means any rc0 resolution below can ONLY be explained by a genuine
// own-property value present in the written config.json — never by the
// legitimate schema-default fallback, and never by the prototype chain.
const keyPathArb = fc.tuple(
fc.array(segmentArb, { maxLength: 2 }),
fc.constantFrom(...PROTO_MEMBER_NAMES),
fc.array(segmentArb, { maxLength: 2 }),
).map(([before, proto, after]) => [...before, proto, ...after].join('.'));
// Own-property-gated reference traversal — mirrors src/config.cts's
// fixed cmdConfigGet traversal loop exactly (Object.prototype.hasOwnProperty.call
// gate at every descent), so "expected" reflects only genuinely-present
// config data, never anything reachable only via the prototype chain.
function safeOwnTraverse(obj, dottedPath) {
let current = obj;
for (const seg of dottedPath.split('.')) {
if (current === undefined || current === null || typeof current !== 'object') return { found: false };
if (!Object.prototype.hasOwnProperty.call(current, seg)) return { found: false };
current = current[seg];
}
if (current === undefined) return { found: false };
return { found: true, value: current };
}
// Leaf value planted at the end of a genuine own-property chain (see
// "plant" below). JSON-safe scalars only — this exercises the "found"
// branch with values of several distinct typeof()s, including the
// `null` edge case (a real, resolvable value, distinct from "absent").
const leafValueArb = fc.oneof(fc.boolean(), fc.integer(), fc.string({ maxLength: 20 }), fc.constant(null));
fc.assert(
fc.property(
keyPathArb,
fc.boolean(),
leafValueArb,
fc.object({ maxDepth: 3 }),
(keyPath, plant, leafValue, backgroundObj) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-proto-prop-'));
try {
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
let configObj;
if (plant) {
// Deliberately construct a config object where `keyPath` IS a
// genuine own-property chain terminating at `leafValue` — via
// COMPUTED property syntax `{ [seg]: nested }`, which (unlike
// `obj.__proto__ = v` / `obj['__proto__'] = v`) is NOT
// Annex-B-special-cased and always defines a real own data
// property, even when `seg === '__proto__'`. This is the
// same mechanism JSON.parse uses for a literal "__proto__"
// key in committed JSON, so it models a real project config.
const segments = keyPath.split('.');
let nested = leafValue;
for (let i = segments.length - 1; i >= 0; i--) {
nested = { [segments[i]]: nested };
}
configObj = nested;
} else {
// Independent random object — keyPath is (overwhelmingly)
// absent from it, exercising the safe-error side.
configObj = backgroundObj;
}
const serialized = JSON.stringify(configObj ?? {});
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), serialized);
// Reference expectation is computed from the SAME round-tripped
// JSON cmdConfigGet itself reads back (JSON.stringify then
// JSON.parse), so it reflects exactly what fs.readFileSync +
// JSON.parse produced.
const roundTripped = JSON.parse(serialized);
const expected = safeOwnTraverse(roundTripped, keyPath);
const result = runInProcessAt(dir, keyPath);
if (result.exited) {
assert.equal(result.exitCode, 1, `keyPath=${JSON.stringify(keyPath)} exited non-1`);
assert.ok(
result.reason === io.ERROR_REASON.CONFIG_KEY_NOT_FOUND
|| result.reason === io.ERROR_REASON.CONFIG_NO_FILE,
`keyPath=${JSON.stringify(keyPath)} errored with unexpected reason=${result.reason}`,
);
// A planted path must NEVER fail to resolve — if it did, that
// would itself be a defect (own data lost/misread), distinct
// from the prototype-leak contract but still worth pinning.
assert.equal(plant, false, `planted own-property path ${JSON.stringify(keyPath)} unexpectedly errored`);
} else {
// rc0 — the guaranteed proto-member segment rules out both the
// SCHEMA_DEFAULTS and capability-registry fallback paths, so
// the ONLY legitimate explanation for a success here is a
// genuine own-property value actually present in config.json.
assert.ok(
expected.found,
`rc0 for keyPath=${JSON.stringify(keyPath)} but no own-reachable value exists in the ` +
`written config — possible prototype-chain leak (stdout=${JSON.stringify(result.stdout)})`,
);
assert.equal(result.stdout, String(expected.value));
}
} finally {
cleanup(dir);
}
},
),
// Bounded below the shared 200-run default (config-schema.property.test.cjs's
// global fc.configureGlobal) because each run does real filesystem I/O
// (mkdtemp + write + rm) rather than pure in-memory computation.
{ numRuns: 60 },
);
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-2798-context-window-config-key.test.cjs — consolidation epic #1969 (B3 #1972)