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

@@ -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);
}