From 9c65a2ea02c6f91bee04dd005acca3af73914f77 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 15 Jul 2026 11:47:15 -0400 Subject: [PATCH] fix(#2256): resolve capability-registry configSchema defaults in config-get (#2299) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ' 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) --- .changeset/lively-hawks-caper.md | 5 + .changeset/rapid-elks-rest.md | 5 + src/config.cts | 82 ++++-- tests/config-get-default.test.cjs | 421 ++++++++++++++++++++++++++++++ 4 files changed, 492 insertions(+), 21 deletions(-) create mode 100644 .changeset/lively-hawks-caper.md create mode 100644 .changeset/rapid-elks-rest.md diff --git a/.changeset/lively-hawks-caper.md b/.changeset/lively-hawks-caper.md new file mode 100644 index 000000000..984e4c34e --- /dev/null +++ b/.changeset/lively-hawks-caper.md @@ -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) diff --git a/.changeset/rapid-elks-rest.md b/.changeset/rapid-elks-rest.md new file mode 100644 index 000000000..98a3a1adb --- /dev/null +++ b/.changeset/rapid-elks-rest.md @@ -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) diff --git a/src/config.cts b/src/config.cts index eced9cf77..359b643ed 100644 --- a/src/config.cts +++ b/src/config.cts @@ -91,6 +91,46 @@ const SCHEMA_DEFAULTS: Record = { '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)['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[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; } 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)[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)[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); } diff --git a/tests/config-get-default.test.cjs b/tests/config-get-default.test.cjs index 119971b43..d89f427fc 100644 --- a/tests/config-get-default.test.cjs +++ b/tests/config-get-default.test.cjs @@ -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)