Three related defects in cmdConfigSet, all 'config-set stores invalid values silently': 1. Missing guards: workflow.security_block_on (enum) and workflow.security_asvs_level (integer 1-3) had no store-time validation. 2. Systemic JSON-coercion bypass: every string-enum guard used VALID_X.includes(String(parsedValue)). Because the value is JSON-parsed before validation, String(["member"]) === "member" let a JSON array slip through and an array was stored in a scalar key. Reproduced on human_verify_mode, statusline.context_position, context_guard_mode, fallow.scope/profile, source_grounding_authority, drift_action, context. 3. Unvalidated capability keys: 32 capability-registry-owned keys (4 enum, 25 boolean, 2 number, 1 string) had no hardcoded guard, so any value — including coerced arrays/objects and out-of-enum strings like code_review_depth=garbage — was stored silently. Fix: a type-safe assertEnumValue() helper (requires typeof === 'string' before membership), routed through all nine central string-enum guards (messages preserved byte-for-byte); plus a generic capability-registry validation block that validates every capability key against its declared type/values (enum via the registry's values — single source of truth — boolean, number, string). Behavioral regression tests cover every central enum key and representative capability keys (array + object coercion rejected, out-of-enum rejected, valid accepted) with boundary coverage for the security keys. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
84 lines
3.4 KiB
TypeScript
84 lines
3.4 KiB
TypeScript
/**
|
|
* Thin adapter — sources schema data from the manifest via the generated
|
|
* Configuration Module. All inline literals have been removed; the manifest
|
|
* at gsd-core/bin/shared/config-schema.manifest.json is the single source of truth.
|
|
*
|
|
* Imported by:
|
|
* - config.cjs (isValidConfigKey validator)
|
|
* - many tests (config-schema.property.test.cjs, bug-*, feat-*, etc.)
|
|
* (core.cjs re-export spine retired in epic #1267)
|
|
*
|
|
* See Phase 2 Cycle 5 (#3536) — schema manifest migration.
|
|
*
|
|
* ADR-457 build-at-publish: the hand-written bin/lib/config-schema.cjs collapsed
|
|
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour from
|
|
* the prior hand-written .cjs; only types are added.
|
|
*/
|
|
|
|
import {
|
|
VALID_CONFIG_KEYS,
|
|
RUNTIME_STATE_KEYS,
|
|
DYNAMIC_KEY_PATTERNS,
|
|
} from './configuration.cjs';
|
|
|
|
// Frozen first-party capability config-schema — the fallback when no project cwd
|
|
// is available (cwd-agnostic call sites).
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const capabilityRegistry = require('./capability-registry.cjs') as {
|
|
configSchema?: Record<string, unknown>;
|
|
};
|
|
|
|
// Resolve the capability config-schema for a project (ADR-1244 D2). When a cwd is
|
|
// supplied, compose installed overlay capabilities for THAT project — LAZILY (never
|
|
// at module load: a bare require of this module never scans the filesystem) —
|
|
// falling back to the frozen first-party schema. Without a cwd, first-party only.
|
|
function _capabilityConfigSchema(cwd?: string): Record<string, unknown> {
|
|
if (typeof cwd === 'string' && cwd) {
|
|
try {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
const loaderMod: { loadRegistry: (o?: Record<string, unknown>) => { configSchema?: Record<string, unknown> } } = require('./capability-loader.cjs');
|
|
// #1459 IC-04: thread the consent home explicitly so a consented project cap's config key
|
|
// federates at the SAME user-owned home that gated its activation.
|
|
const schema = loaderMod.loadRegistry({ includeInstalled: true, cwd, gsdHome: process.env['GSD_HOME'] }).configSchema;
|
|
if (schema && typeof schema === 'object') return schema;
|
|
} catch { /* fall back to first-party */ }
|
|
}
|
|
const fp = capabilityRegistry.configSchema;
|
|
return fp && typeof fp === 'object' ? fp : {};
|
|
}
|
|
|
|
function isCapabilityConfigKey(keyPath: string, cwd?: string): boolean {
|
|
if (typeof keyPath !== 'string') return false;
|
|
return Object.prototype.hasOwnProperty.call(_capabilityConfigSchema(cwd), keyPath);
|
|
}
|
|
|
|
/**
|
|
* Returns true for keys owned by the central schema adapter rather than a
|
|
* federated Capability config slice.
|
|
*/
|
|
function isCentralConfigKey(keyPath: string): boolean {
|
|
if (typeof keyPath !== 'string') return false;
|
|
if (VALID_CONFIG_KEYS.has(keyPath)) return true;
|
|
if (RUNTIME_STATE_KEYS.has(keyPath)) return true;
|
|
return DYNAMIC_KEY_PATTERNS.some((p) => p.test(keyPath));
|
|
}
|
|
|
|
/**
|
|
* Returns true if keyPath is a valid central, runtime-state, dynamic, or
|
|
* federated Capability config key.
|
|
*/
|
|
function isValidConfigKey(keyPath: string, cwd?: string): boolean {
|
|
if (isCentralConfigKey(keyPath)) return true;
|
|
return isCapabilityConfigKey(keyPath, cwd);
|
|
}
|
|
|
|
export = {
|
|
VALID_CONFIG_KEYS,
|
|
RUNTIME_STATE_KEYS,
|
|
DYNAMIC_KEY_PATTERNS,
|
|
isCapabilityConfigKey,
|
|
isCentralConfigKey,
|
|
isValidConfigKey,
|
|
getCapabilityConfigSchema: _capabilityConfigSchema,
|
|
};
|