feat(#1213): Capability State Writer — write-side inverse of the resolver (#1225)

* 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:
Tom Boucher
2026-06-14 12:51:17 -04:00
committed by GitHub
parent 22f56f4431
commit bf634b95c3
16 changed files with 1427 additions and 28 deletions

View File

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