* feat(#1213): Capability State Writer — write-side inverse of the resolver Adds src/capability-writer.cts (setCapabilityState + cmdCapabilitySet) and the `gsd-tools capability set` subcommand: the write-side inverse of the capability resolver (ADR-1213). One desired capability state projects onto the substrates — `enabled` drives the runtime surface (canonical on/off), `gates` drive federated config keys (hook granularity), install profile is a read-only floor — then re-resolves and reports divergence (assert-and-report), so "off means off" holds as a write-time invariant. Adds batched setConfigValues; routes gsd:settings capability hook-gates through the writer. Docs: CLI-TOOLS reference, how-to, ADR-1213, CONTEXT.md term. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#1213): add changeset for Capability State Writer (#1225) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
466
src/capability-writer.cts
Normal file
466
src/capability-writer.cts
Normal file
@@ -0,0 +1,466 @@
|
||||
/**
|
||||
* Capability Writer — ADR-1213 write-side inverse of capability-state resolver.
|
||||
*
|
||||
* Exports:
|
||||
* setCapabilityState(cwd, runtimeConfigDir, desired, opts?)
|
||||
* → { capabilities: CapabilityStateEntry[], warnings: string[] }
|
||||
* cmdCapabilitySet(cwd, runtimeConfigDir, capId, options, raw)
|
||||
*
|
||||
* Projection rules (three axes: install, surface, config):
|
||||
* - enabled axis: mutates .gsd-surface.json via readSurface/writeSurface
|
||||
* - gates axis: mutates .planning/config.json via setConfigValues (batched)
|
||||
* - materialize: optionally calls applySurface to write skill files
|
||||
* - re-resolve: always calls resolveCapabilityRuntimeState for the return value
|
||||
*
|
||||
* Dependencies (leaf modules only — no circular risk):
|
||||
* - ./core.cjs (output, error)
|
||||
* - ./capability-state.cjs (resolveCapabilityRuntimeState, _resolveManifest, _resolveCommandsGsdDir)
|
||||
* - ./surface.cjs (readSurface, writeSurface, applySurface)
|
||||
* - ./install-profiles.cjs (readActiveProfile)
|
||||
* - ./config.cjs (setConfigValues)
|
||||
* - ./runtime-artifact-layout.cjs (resolveRuntimeArtifactLayout)
|
||||
* - capability-registry.cjs (loaded at call time)
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import core = require('./core.cjs');
|
||||
const { output: coreOutput, error: coreError } = core;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import capabilityStateMod = require('./capability-state.cjs');
|
||||
const { resolveCapabilityRuntimeState, _resolveManifest, _resolveCommandsGsdDir } = capabilityStateMod;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import surfaceMod = require('./surface.cjs');
|
||||
const { readSurface, writeSurface, applySurface } = surfaceMod;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import installProfilesMod = require('./install-profiles.cjs');
|
||||
const { readActiveProfile } = installProfilesMod;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import configMod = require('./config.cjs');
|
||||
const { setConfigValues } = configMod;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningWorkspaceMod = require('./planning-workspace.cjs');
|
||||
const { planningDir } = planningWorkspaceMod;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import nodefs = require('fs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import nodepath = require('path');
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface HookEntry {
|
||||
point: string;
|
||||
kind: 'step' | 'gate' | 'contribution';
|
||||
when: unknown;
|
||||
configured: boolean;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface CapabilityStateEntry {
|
||||
id: string;
|
||||
tier: string;
|
||||
skills: string[];
|
||||
installed: boolean;
|
||||
surfaced: boolean;
|
||||
enabled: boolean;
|
||||
hooks: HookEntry[];
|
||||
}
|
||||
|
||||
interface SurfaceState {
|
||||
baseProfile: string;
|
||||
disabledClusters: string[];
|
||||
explicitAdds: string[];
|
||||
explicitRemoves: string[];
|
||||
}
|
||||
|
||||
interface DesiredCapability {
|
||||
id: string;
|
||||
enabled?: boolean;
|
||||
gates?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
interface SetCapabilityStateOptions {
|
||||
materialize?: { runtime: string; scope: string };
|
||||
}
|
||||
|
||||
interface SetCapabilityStateResult {
|
||||
capabilities: CapabilityStateEntry[];
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
// ─── Implementation ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Write-side capability state mutator.
|
||||
*
|
||||
* Applies desired capability state changes (enabled axis via surface, gates
|
||||
* axis via config) then re-resolves and returns the full capability state.
|
||||
*
|
||||
* Control flow:
|
||||
* 1. RESOLVE BEFORE STATE: call resolveCapabilityRuntimeState once to get the
|
||||
* canonical runtimeConfigDir and current capability state.
|
||||
* 2. VALIDATION PASS (no writes): validate each desired entry against the
|
||||
* registry and `before` state; collect errors and warnings.
|
||||
* 3. If errors → return early with before.capabilities (no writes performed).
|
||||
* 4. APPLY PASS: compute new surface state, writeSurface once if changed,
|
||||
* setConfigValues once for gate writes; materialize if opts provided.
|
||||
* 5. RE-RESOLVE: call resolveCapabilityRuntimeState again to get final state.
|
||||
* 6. POST CHECKS: enabled=true but not-surfaced (not-in-profile) error;
|
||||
* present-but-dead warning; append resolver warnings.
|
||||
* 7. Return { capabilities: after.capabilities, warnings, errors }.
|
||||
*/
|
||||
function setCapabilityState(
|
||||
cwd: string,
|
||||
runtimeConfigDir: string | undefined | null,
|
||||
desired: DesiredCapability[],
|
||||
opts?: SetCapabilityStateOptions,
|
||||
): SetCapabilityStateResult {
|
||||
const warnings: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
// ── Step 1: Resolve BEFORE state once ────────────────────────────────────
|
||||
const before = resolveCapabilityRuntimeState(cwd, runtimeConfigDir);
|
||||
const resolvedConfigDir = before.runtimeConfigDir;
|
||||
|
||||
// ── Load registry ─────────────────────────────────────────────────────────
|
||||
const registry = before.registry;
|
||||
const capabilitiesMap = (
|
||||
registry['capabilities'] && typeof registry['capabilities'] === 'object' && !Array.isArray(registry['capabilities'])
|
||||
? registry['capabilities']
|
||||
: {}
|
||||
) as Record<string, unknown>;
|
||||
|
||||
// ── Step 2: VALIDATION PASS (no writes) ──────────────────────────────────
|
||||
// Accumulate all valid gate writes and surface deltas.
|
||||
// If ANY error is found, we will return early without writing anything.
|
||||
|
||||
const pendingGateWrites: Array<{ keyPath: string; value: unknown }> = [];
|
||||
// surface-delta accumulators: ids to add to / remove from disabledClusters
|
||||
const idsToDisable: string[] = [];
|
||||
const idsToEnable: string[] = [];
|
||||
// Track which ids need surface loading (have skills + explicit enabled flag)
|
||||
let needsSurface = false;
|
||||
|
||||
for (const entry of desired) {
|
||||
const { id, enabled, gates } = entry;
|
||||
|
||||
// Validate capability id
|
||||
if (!Object.prototype.hasOwnProperty.call(capabilitiesMap, id)) {
|
||||
errors.push(`unknown capability: "${id}"`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const capObj = capabilitiesMap[id] as Record<string, unknown>;
|
||||
const skillsRaw = capObj['skills'];
|
||||
const skills: string[] = Array.isArray(skillsRaw)
|
||||
? skillsRaw.filter((s): s is string => typeof s === 'string')
|
||||
: [];
|
||||
const configDef = (
|
||||
capObj['config'] && typeof capObj['config'] === 'object' && !Array.isArray(capObj['config'])
|
||||
? capObj['config']
|
||||
: {}
|
||||
) as Record<string, unknown>;
|
||||
|
||||
// ── Validate gate keys / values ──────────────────────────────────────────
|
||||
if (gates !== undefined) {
|
||||
for (const [key, val] of Object.entries(gates)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(configDef, key)) {
|
||||
errors.push(`unknown gate key "${key}" for capability "${id}"`);
|
||||
continue;
|
||||
}
|
||||
if (typeof val !== 'boolean') {
|
||||
errors.push(`gate value for "${key}" must be boolean, got ${typeof val}`);
|
||||
continue;
|
||||
}
|
||||
pendingGateWrites.push({ keyPath: key, value: val });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validate enabled axis ────────────────────────────────────────────────
|
||||
if (enabled !== undefined) {
|
||||
if (skills.length === 0) {
|
||||
// Advisory only — no surface effect possible
|
||||
warnings.push(
|
||||
`capability "${id}" owns no skills; 'enabled' has no surface effect — use gates to toggle its hooks`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Install-floor check: cannot enable a capability whose skills are not installed
|
||||
if (enabled === true) {
|
||||
const beforeEntry = before.capabilities.find((c: CapabilityStateEntry) => c.id === id);
|
||||
if (beforeEntry && beforeEntry.installed === false) {
|
||||
errors.push(`cannot enable "${id}": its skills are not in the install profile`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
needsSurface = true;
|
||||
if (enabled === false) {
|
||||
idsToDisable.push(id);
|
||||
} else {
|
||||
idsToEnable.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fix D: Pre-validate config.json parseability before any write ────────
|
||||
// If there are pending gate writes, attempt to read and parse the target
|
||||
// config.json BEFORE writing anything. A malformed file would cause
|
||||
// setConfigValues to error() mid-operation leaving a partial write.
|
||||
if (pendingGateWrites.length > 0) {
|
||||
try {
|
||||
const configJsonPath = nodepath.join(planningDir(cwd), 'config.json');
|
||||
if (nodefs.existsSync(configJsonPath)) {
|
||||
const raw = nodefs.readFileSync(configJsonPath, 'utf-8');
|
||||
try {
|
||||
JSON.parse(raw);
|
||||
} catch (parseErr: unknown) {
|
||||
const msg = parseErr instanceof Error ? parseErr.message : String(parseErr);
|
||||
errors.push(`config.json is malformed: ${msg}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Cannot read the file path — not an error (e.g. planningDir env-var issue); let setConfigValues handle it
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 3: Early return on validation errors ────────────────────────────
|
||||
if (errors.length > 0) {
|
||||
return {
|
||||
capabilities: before.capabilities,
|
||||
warnings,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Step 4: APPLY PASS ────────────────────────────────────────────────────
|
||||
|
||||
// ── Surface writes ────────────────────────────────────────────────────────
|
||||
if (needsSurface && (idsToDisable.length > 0 || idsToEnable.length > 0)) {
|
||||
const existing = readSurface(resolvedConfigDir);
|
||||
let pendingSurface: SurfaceState = existing ?? {
|
||||
baseProfile: readActiveProfile(resolvedConfigDir) ?? 'full',
|
||||
disabledClusters: [],
|
||||
explicitAdds: [],
|
||||
explicitRemoves: [],
|
||||
};
|
||||
let surfaceChanged = false;
|
||||
|
||||
for (const id of idsToDisable) {
|
||||
// Add id to disabledClusters (dedupe)
|
||||
if (!pendingSurface.disabledClusters.includes(id)) {
|
||||
pendingSurface = {
|
||||
...pendingSurface,
|
||||
disabledClusters: [...pendingSurface.disabledClusters, id],
|
||||
};
|
||||
surfaceChanged = true;
|
||||
}
|
||||
// Fix A: explicitAdds contains SKILL STEMS, not capability ids.
|
||||
// Remove the capability's skill stems from explicitAdds so that
|
||||
// resolveSurface does not re-add those skills after the cluster disable.
|
||||
const capObjForDisable = capabilitiesMap[id] as Record<string, unknown>;
|
||||
const skillsRawForDisable = capObjForDisable?.['skills'];
|
||||
const skillStemsForDisable: string[] = Array.isArray(skillsRawForDisable)
|
||||
? skillsRawForDisable.filter((s): s is string => typeof s === 'string')
|
||||
: [];
|
||||
const newExplicitAdds = pendingSurface.explicitAdds.filter(
|
||||
(x) => !skillStemsForDisable.includes(x),
|
||||
);
|
||||
if (newExplicitAdds.length !== pendingSurface.explicitAdds.length) {
|
||||
pendingSurface = { ...pendingSurface, explicitAdds: newExplicitAdds };
|
||||
surfaceChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of idsToEnable) {
|
||||
// Remove id from disabledClusters
|
||||
if (pendingSurface.disabledClusters.includes(id)) {
|
||||
pendingSurface = {
|
||||
...pendingSurface,
|
||||
disabledClusters: pendingSurface.disabledClusters.filter((x) => x !== id),
|
||||
};
|
||||
surfaceChanged = true;
|
||||
}
|
||||
// Fix A (enable branch): also remove the capability's skill stems from
|
||||
// explicitRemoves so that resolveSurface does not subtract those skills.
|
||||
// Do NOT add anything to explicitAdds — a cap that was only in explicitAdds
|
||||
// and was disabled is caught by the post-check below.
|
||||
const capObjForEnable = capabilitiesMap[id] as Record<string, unknown>;
|
||||
const skillsRawForEnable = capObjForEnable?.['skills'];
|
||||
const skillStemsForEnable: string[] = Array.isArray(skillsRawForEnable)
|
||||
? skillsRawForEnable.filter((s): s is string => typeof s === 'string')
|
||||
: [];
|
||||
const newExplicitRemoves = pendingSurface.explicitRemoves.filter(
|
||||
(x) => !skillStemsForEnable.includes(x),
|
||||
);
|
||||
if (newExplicitRemoves.length !== pendingSurface.explicitRemoves.length) {
|
||||
pendingSurface = { ...pendingSurface, explicitRemoves: newExplicitRemoves };
|
||||
surfaceChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (surfaceChanged) {
|
||||
writeSurface(resolvedConfigDir, pendingSurface);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Config writes (once, batched) ─────────────────────────────────────────
|
||||
if (pendingGateWrites.length > 0) {
|
||||
setConfigValues(cwd, pendingGateWrites);
|
||||
}
|
||||
|
||||
// ── Materialize (optional) ────────────────────────────────────────────────
|
||||
if (opts?.materialize) {
|
||||
const { runtime, scope } = opts.materialize;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const runtimeArtifactLayout = require('./runtime-artifact-layout.cjs') as {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
resolveRuntimeArtifactLayout: (runtime: string, configDir: string, scope: string) => any;
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, resolvedConfigDir, scope);
|
||||
const commandsGsdDir = _resolveCommandsGsdDir();
|
||||
const manifest = _resolveManifest(commandsGsdDir, resolvedConfigDir);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
applySurface(resolvedConfigDir, layout, manifest, undefined, registry);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
// Fix C: materialise was explicitly requested — a failure is an error (non-zero exit),
|
||||
// not merely advisory.
|
||||
errors.push(`materialize failed: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 5: RE-RESOLVE ────────────────────────────────────────────────────
|
||||
const after = resolveCapabilityRuntimeState(cwd, resolvedConfigDir);
|
||||
|
||||
// ── Step 6: POST CHECKS ───────────────────────────────────────────────────
|
||||
|
||||
// Check: desired enabled=true but not actually enabled after write
|
||||
// (catches the not-in-profile / not-surfaced silent no-op case).
|
||||
// The install-floor case (installed===false) was already caught in validation.
|
||||
for (const entry of desired) {
|
||||
if (entry.enabled === true) {
|
||||
const afterCap = after.capabilities.find((c: CapabilityStateEntry) => c.id === entry.id);
|
||||
if (afterCap && afterCap.enabled !== true) {
|
||||
errors.push(
|
||||
`cannot enable "${entry.id}": not in the active surface/profile (widen the profile or use /gsd:surface enable)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Fix B: desired enabled=false — assert it is actually disabled after write.
|
||||
// Prevents "off means off" silent failures (e.g. explicitAdds containing the
|
||||
// cap's skill stems re-adds them after the cluster disable).
|
||||
if (entry.enabled === false) {
|
||||
const afterCap = after.capabilities.find((c: CapabilityStateEntry) => c.id === entry.id);
|
||||
if (afterCap && afterCap.enabled !== false) {
|
||||
errors.push(`failed to disable "${entry.id}": still surfaced after write`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check: present-but-dead — SCOPED to touched (desired) capabilities only.
|
||||
const desiredIds = new Set(desired.map((d) => d.id));
|
||||
for (const cap of after.capabilities as CapabilityStateEntry[]) {
|
||||
if (!desiredIds.has(cap.id)) continue;
|
||||
if (
|
||||
cap.enabled === true &&
|
||||
cap.hooks.length > 0 &&
|
||||
cap.hooks.every((h: HookEntry) => !h.configured)
|
||||
) {
|
||||
warnings.push(
|
||||
`capability "${cap.id}" is surfaced but every hook is gated off — did you mean enabled:false?`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Append resolver's own warnings
|
||||
for (const w of after.warnings) {
|
||||
warnings.push(w);
|
||||
}
|
||||
|
||||
return {
|
||||
capabilities: after.capabilities,
|
||||
warnings,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI command entry point for `gsd-tools capability set`.
|
||||
*
|
||||
* Builds one DesiredCapability from the provided options, calls setCapabilityState,
|
||||
* then prints the result. When raw=true emits JSON; else emits a human summary.
|
||||
* Warnings are always printed to stderr.
|
||||
*/
|
||||
function cmdCapabilitySet(
|
||||
cwd: string,
|
||||
runtimeConfigDir: string | undefined | null,
|
||||
capId: string,
|
||||
options: { enabled?: boolean; gates?: Record<string, boolean>; runtime?: string; scope?: string },
|
||||
raw: boolean,
|
||||
): void {
|
||||
const desired: DesiredCapability[] = [
|
||||
{
|
||||
id: capId,
|
||||
...(options.enabled !== undefined ? { enabled: options.enabled } : {}),
|
||||
...(options.gates ? { gates: options.gates } : {}),
|
||||
},
|
||||
];
|
||||
|
||||
const opts: SetCapabilityStateOptions | undefined =
|
||||
options.runtime
|
||||
? { materialize: { runtime: options.runtime, scope: options.scope ?? 'global' } }
|
||||
: undefined;
|
||||
|
||||
const result = setCapabilityState(cwd, runtimeConfigDir, desired, opts);
|
||||
|
||||
if (raw) {
|
||||
// Raw mode: emit JSON to stdout including errors; exit non-zero if errors present.
|
||||
// Do NOT print human stderr lines — raw consumers parse the JSON.
|
||||
coreOutput({ capabilities: result.capabilities, warnings: result.warnings, errors: result.errors }, true);
|
||||
if (result.errors.length > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Human mode: print warnings and errors to stderr (non-fatally for warnings).
|
||||
for (const w of result.warnings) {
|
||||
process.stderr.write(`capability set: warning: ${w}\n`);
|
||||
}
|
||||
for (const e of result.errors) {
|
||||
process.stderr.write(`capability set: error: ${e}\n`);
|
||||
}
|
||||
|
||||
// Exit non-zero if any errors (hard failures — requested action was not realized).
|
||||
if (result.errors.length > 0) {
|
||||
coreError(`capability set: ${String(result.errors.length)} error(s) — see above`);
|
||||
return; // unreachable — coreError calls process.exit(1)
|
||||
}
|
||||
|
||||
// Human-readable summary: focus on the target capability
|
||||
const cap = result.capabilities.find((c: CapabilityStateEntry) => c.id === capId);
|
||||
if (!cap) {
|
||||
const msg = `capability "${capId}" not found in registry`;
|
||||
coreOutput(msg, false, msg);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeHooks = cap.hooks.filter((h: HookEntry) => h.active).length;
|
||||
const summary = `capability ${capId}: enabled=${String(cap.enabled)}, surfaced=${String(cap.surfaced)}, installed=${String(cap.installed)}, activeHooks=${String(activeHooks)}/${String(cap.hooks.length)}`;
|
||||
coreOutput({ id: cap.id, enabled: cap.enabled, surfaced: cap.surfaced, installed: cap.installed, warnings: result.warnings.length > 0 ? result.warnings : undefined }, false, summary);
|
||||
}
|
||||
|
||||
export = {
|
||||
setCapabilityState,
|
||||
cmdCapabilitySet,
|
||||
};
|
||||
117
src/config.cts
117
src/config.cts
@@ -407,6 +407,47 @@ function cmdConfigEnsureSection(cwd: string, raw: boolean): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared helper: write a single key-path into an in-memory config object.
|
||||
*
|
||||
* Prototype-pollution guard: reject dangerous segments via inline literal
|
||||
* comparisons on the exact key used to index `current`, immediately before
|
||||
* each write. The inline comparison is the barrier CodeQL's
|
||||
* js/prototype-pollution-utility query recognises — the previous Set-based
|
||||
* pre-loop check was functionally correct but not traced through, so
|
||||
* code-scanning alert #26 kept firing. Behaviour is unchanged from #663.
|
||||
*
|
||||
* Returns the previous value at the leaf key (undefined if absent).
|
||||
* Never writes to disk — callers handle persistence.
|
||||
* Calls error() (process.exit(1)) on prototype-pollution attempts.
|
||||
*/
|
||||
function _setNestedValue(
|
||||
config: Record<string, unknown>,
|
||||
keyPath: string,
|
||||
parsedValue: unknown,
|
||||
): unknown {
|
||||
const keys = keyPath.split('.');
|
||||
let current: Record<string, unknown> = config;
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const key = keys[i];
|
||||
if (key === '__proto__' || key === 'prototype' || key === 'constructor') {
|
||||
error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED);
|
||||
}
|
||||
const existingChild = current[key];
|
||||
if (existingChild === undefined || existingChild === null || typeof existingChild !== 'object' || Array.isArray(existingChild)) {
|
||||
current[key] = {};
|
||||
}
|
||||
current = current[key] as Record<string, unknown>;
|
||||
}
|
||||
const lastKey = keys[keys.length - 1];
|
||||
if (lastKey === '__proto__' || lastKey === 'prototype' || lastKey === 'constructor') {
|
||||
error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED);
|
||||
}
|
||||
const previousValue = current[lastKey];
|
||||
current[lastKey] = parsedValue;
|
||||
return previousValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a value in the config file, allowing nested values via dot notation (e.g.,
|
||||
* "workflow.research").
|
||||
@@ -428,31 +469,7 @@ function setConfigValue(cwd: string, keyPath: string, parsedValue: unknown): Set
|
||||
error('Failed to read config.json: ' + (err as Error).message, ERROR_REASON.CONFIG_PARSE_FAILED);
|
||||
}
|
||||
|
||||
// Set nested value using dot notation (e.g., "workflow.research").
|
||||
// Prototype-pollution guard: reject dangerous segments via inline literal
|
||||
// comparisons on the exact key used to index `current`, immediately before
|
||||
// each write. The inline comparison is the barrier CodeQL's
|
||||
// js/prototype-pollution-utility query recognises — the previous Set-based
|
||||
// pre-loop check was functionally correct but not traced through, so
|
||||
// code-scanning alert #26 kept firing. Behaviour is unchanged from #663.
|
||||
const keys = keyPath.split('.');
|
||||
let current: Record<string, unknown> = config;
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const key = keys[i];
|
||||
if (key === '__proto__' || key === 'prototype' || key === 'constructor') {
|
||||
error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED);
|
||||
}
|
||||
if (current[key] === undefined || typeof current[key] !== 'object') {
|
||||
current[key] = {};
|
||||
}
|
||||
current = current[key] as Record<string, unknown>;
|
||||
}
|
||||
const lastKey = keys[keys.length - 1];
|
||||
if (lastKey === '__proto__' || lastKey === 'prototype' || lastKey === 'constructor') {
|
||||
error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED);
|
||||
}
|
||||
const previousValue = current[lastKey]; // Capture previous value before overwriting
|
||||
current[lastKey] = parsedValue;
|
||||
const previousValue = _setNestedValue(config, keyPath, parsedValue);
|
||||
|
||||
// Write back
|
||||
try {
|
||||
@@ -464,6 +481,53 @@ function setConfigValue(cwd: string, keyPath: string, parsedValue: unknown): Set
|
||||
}) as SetConfigValueResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batched sibling of setConfigValue: apply multiple key-path writes in a
|
||||
* single load → set-all → write cycle inside ONE withPlanningLock call.
|
||||
*
|
||||
* Returns { updated: true, results: SetConfigValueResult[] } on success.
|
||||
* An empty entries array is a no-op and returns { updated: false, results: [] }.
|
||||
*
|
||||
* Prototype-pollution guards are enforced per entry (identical inline-literal
|
||||
* guards as setConfigValue — CodeQL barrier requirement).
|
||||
*/
|
||||
function setConfigValues(
|
||||
cwd: string,
|
||||
entries: Array<{ keyPath: string; value: unknown }>,
|
||||
): { updated: boolean; results: SetConfigValueResult[] } {
|
||||
if (entries.length === 0) {
|
||||
return { updated: false, results: [] };
|
||||
}
|
||||
|
||||
const configPath = path.join(planningDir(cwd), 'config.json');
|
||||
|
||||
return withPlanningLock(cwd, () => {
|
||||
// Load existing config or start with empty object
|
||||
let config: Record<string, unknown> = {};
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
|
||||
}
|
||||
} catch (err) {
|
||||
error('Failed to read config.json: ' + (err as Error).message, ERROR_REASON.CONFIG_PARSE_FAILED);
|
||||
}
|
||||
|
||||
const results: SetConfigValueResult[] = [];
|
||||
for (const entry of entries) {
|
||||
const previousValue = _setNestedValue(config, entry.keyPath, entry.value);
|
||||
results.push({ updated: true, key: entry.keyPath, value: entry.value, previousValue });
|
||||
}
|
||||
|
||||
// Write back once for all entries
|
||||
try {
|
||||
platformWriteSync(configPath, JSON.stringify(config, null, 2));
|
||||
return { updated: true, results };
|
||||
} catch (err) {
|
||||
error('Failed to write config.json: ' + (err as Error).message);
|
||||
}
|
||||
}) as { updated: boolean; results: SetConfigValueResult[] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Command to set a value in the config file, allowing nested values via dot notation (e.g.,
|
||||
* "workflow.research").
|
||||
@@ -800,4 +864,7 @@ export = {
|
||||
cmdConfigNewProject,
|
||||
cmdConfigPath,
|
||||
cmdMigrateConfig,
|
||||
// Exported for programmatic use by capability-writer and tests
|
||||
setConfigValue,
|
||||
setConfigValues,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user