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:
5
.changeset/lively-hawks-caper.md
Normal file
5
.changeset/lively-hawks-caper.md
Normal 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)
|
||||
5
.changeset/rapid-elks-rest.md
Normal file
5
.changeset/rapid-elks-rest.md
Normal 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)
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user