diff --git a/.changeset/proud-birds-howl.md b/.changeset/proud-birds-howl.md new file mode 100644 index 000000000..30c7759e8 --- /dev/null +++ b/.changeset/proud-birds-howl.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 1632 +--- +**`config-set` now rejects invalid config values instead of storing them silently** — out-of-enum strings, JSON array/object coercion (e.g. `["high"]` stored as an array in a scalar key), and wrong-typed values for capability-registry-owned keys are validated against each key's declared schema at set time. Previously these were accepted and persisted, mis-configuring GSD. (#1628) diff --git a/scripts/lint-test-file-count.allowlist.json b/scripts/lint-test-file-count.allowlist.json index a24597826..6c83710ad 100644 --- a/scripts/lint-test-file-count.allowlist.json +++ b/scripts/lint-test-file-count.allowlist.json @@ -21,7 +21,8 @@ "config-get-default.test.cjs", "config-schema.property.test.cjs", "config.test.cjs", - "enh-1055-config-intent-descriptor-drive.test.cjs" + "enh-1055-config-intent-descriptor-drive.test.cjs", + "fix-1628-config-set-validation.test.cjs" ], "issue": "TBD" }, diff --git a/src/config-schema.cts b/src/config-schema.cts index 7778b38b0..41ec28abb 100644 --- a/src/config-schema.cts +++ b/src/config-schema.cts @@ -79,4 +79,5 @@ export = { isCapabilityConfigKey, isCentralConfigKey, isValidConfigKey, + getCapabilityConfigSchema: _capabilityConfigSchema, }; diff --git a/src/config.cts b/src/config.cts index 493aecc88..0a411140e 100644 --- a/src/config.cts +++ b/src/config.cts @@ -24,7 +24,7 @@ import modelProfiles = require('./model-profiles.cjs'); const { VALID_PROFILES, getAgentToModelMapForProfile, formatAgentToModelMapAsTable } = modelProfiles; // eslint-disable-next-line @typescript-eslint/no-require-imports import configSchema = require('./config-schema.cjs'); -const { VALID_CONFIG_KEYS, isValidConfigKey } = configSchema; +const { VALID_CONFIG_KEYS, isValidConfigKey, getCapabilityConfigSchema } = configSchema; import { isSecretKey, maskSecret } from './secrets.cjs'; import { normalizeConfiguredDefaultReviewers } from './review-reviewer-selection.cjs'; import { migrateOnDisk } from './configuration.cjs'; @@ -532,6 +532,23 @@ function setConfigValues( }) as { updated: boolean; results: SetConfigValueResult[] }; } +/** + * Type-safe enum guard for config-set string-enum keys. + * + * Rejects any parsedValue that is not a plain string AND a member of `allowed`. + * This closes the JSON-array coercion bypass: String(["val"]) === "val" satisfies + * a bare .includes(String(parsedValue)) check, but typeof parsedValue !== 'string' + * catches the array before the includes test. + * + * The `label` parameter is used verbatim in the error message so callers can + * preserve existing message text byte-for-byte. + */ +function assertEnumValue(parsedValue: unknown, rawVal: string, allowed: readonly string[], label: string): void { + if (typeof parsedValue !== 'string' || !allowed.includes(parsedValue)) { + error(`Invalid ${label} '${rawVal}'. Valid values: ${allowed.join(', ')}`); + } +} + /** * Command to set a value in the config file, allowing nested values via dot notation (e.g., * "workflow.research"). @@ -575,15 +592,11 @@ function cmdConfigSet(cwd: string, keyPath: string | undefined, value: string | } const VALID_CONTEXT_VALUES = ['dev', 'research', 'review']; - if (kp === 'context' && !VALID_CONTEXT_VALUES.includes(String(parsedValue))) { - error(`Invalid context value '${val}'. Valid values: ${VALID_CONTEXT_VALUES.join(', ')}`); - } + if (kp === 'context') assertEnumValue(parsedValue, val, VALID_CONTEXT_VALUES, 'context value'); // Codebase drift detector (#2003) const VALID_DRIFT_ACTIONS = ['warn', 'auto-remap']; - if (kp === 'workflow.drift_action' && !VALID_DRIFT_ACTIONS.includes(String(parsedValue))) { - error(`Invalid workflow.drift_action '${val}'. Valid values: ${VALID_DRIFT_ACTIONS.join(', ')}`); - } + if (kp === 'workflow.drift_action') assertEnumValue(parsedValue, val, VALID_DRIFT_ACTIONS, 'workflow.drift_action'); if (kp === 'workflow.drift_threshold') { if (typeof parsedValue !== 'number' || !Number.isInteger(parsedValue) || parsedValue < 1) { error(`Invalid workflow.drift_threshold '${val}'. Must be a positive integer.`); @@ -610,31 +623,21 @@ function cmdConfigSet(cwd: string, keyPath: string | undefined, value: string | // Human verification checkpoint mode (#3309) const VALID_HUMAN_VERIFY_MODES = ['mid-flight', 'end-of-phase']; - if (kp === 'workflow.human_verify_mode' && !VALID_HUMAN_VERIFY_MODES.includes(String(parsedValue))) { - error(`Invalid workflow.human_verify_mode '${val}'. Valid values: ${VALID_HUMAN_VERIFY_MODES.join(', ')}`); - } + if (kp === 'workflow.human_verify_mode') assertEnumValue(parsedValue, val, VALID_HUMAN_VERIFY_MODES, 'workflow.human_verify_mode'); // Context exhaustion guard mode (#1452) const VALID_CONTEXT_GUARD_MODES = ['auto', 'warn', 'off']; - if (kp === 'workflow.context_guard_mode' && !VALID_CONTEXT_GUARD_MODES.includes(String(parsedValue))) { - error(`Invalid workflow.context_guard_mode '${val}'. Valid values: ${VALID_CONTEXT_GUARD_MODES.join(', ')}`); - } + if (kp === 'workflow.context_guard_mode') assertEnumValue(parsedValue, val, VALID_CONTEXT_GUARD_MODES, 'workflow.context_guard_mode'); // Context position enum validation (#2937) const VALID_CONTEXT_POSITIONS = ['front', 'end']; - if (kp === 'statusline.context_position' && !VALID_CONTEXT_POSITIONS.includes(String(parsedValue))) { - error(`Invalid statusline.context_position '${val}'. Valid values: ${VALID_CONTEXT_POSITIONS.join(', ')}`); - } + if (kp === 'statusline.context_position') assertEnumValue(parsedValue, val, VALID_CONTEXT_POSITIONS, 'statusline.context_position'); // Fallow scope + profile enum validation (#3424) const VALID_FALLOW_SCOPES = ['phase', 'repo']; - if (kp === 'code_quality.fallow.scope' && !VALID_FALLOW_SCOPES.includes(String(parsedValue))) { - error(`Invalid code_quality.fallow.scope '${val}'. Valid values: ${VALID_FALLOW_SCOPES.join(', ')}`); - } + if (kp === 'code_quality.fallow.scope') assertEnumValue(parsedValue, val, VALID_FALLOW_SCOPES, 'code_quality.fallow.scope'); const VALID_FALLOW_PROFILES = ['minimal', 'standard', 'strict']; - if (kp === 'code_quality.fallow.profile' && !VALID_FALLOW_PROFILES.includes(String(parsedValue))) { - error(`Invalid code_quality.fallow.profile '${val}'. Valid values: ${VALID_FALLOW_PROFILES.join(', ')}`); - } + if (kp === 'code_quality.fallow.profile') assertEnumValue(parsedValue, val, VALID_FALLOW_PROFILES, 'code_quality.fallow.profile'); // plan_review.source_grounding (#22) — boolean only if (kp === 'plan_review.source_grounding') { @@ -645,8 +648,43 @@ function cmdConfigSet(cwd: string, keyPath: string | undefined, value: string | // plan_review.source_grounding_authority (#22) — enum const VALID_SOURCE_GROUNDING_AUTHORITIES = ['grep', 'intel', 'treesitter', 'lsp', 'scip']; - if (kp === 'plan_review.source_grounding_authority' && !VALID_SOURCE_GROUNDING_AUTHORITIES.includes(String(parsedValue))) { - error(`Invalid plan_review.source_grounding_authority '${val}'. Valid values: ${VALID_SOURCE_GROUNDING_AUTHORITIES.join(', ')}`); + if (kp === 'plan_review.source_grounding_authority') assertEnumValue(parsedValue, val, VALID_SOURCE_GROUNDING_AUTHORITIES, 'plan_review.source_grounding_authority'); + + // Generic capability-registry validation (#1628). Capability-owned keys declare + // their type/values in the registry but most lack a hardcoded guard, so out-of- + // domain values (including JSON array/object coercion) were stored silently. + const capDef = getCapabilityConfigSchema(cwd)[kp] as { type?: string; values?: unknown[] } | undefined; + if (capDef && typeof capDef.type === 'string') { + switch (capDef.type) { + case 'enum': + if (Array.isArray(capDef.values)) { + assertEnumValue(parsedValue, val, capDef.values.map((v) => String(v)), kp); + } + break; + case 'boolean': + if (typeof parsedValue !== 'boolean') { + error(`Invalid ${kp} '${val}'. Must be a boolean (true or false).`); + } + break; + case 'number': + if (typeof parsedValue !== 'number' || !Number.isFinite(parsedValue)) { + error(`Invalid ${kp} '${val}'. Must be a number.`); + } + break; + case 'string': + if (typeof parsedValue !== 'string') { + error(`Invalid ${kp} '${val}'. Must be a string.`); + } + break; + } + } + + // Security — ASVS level range (#1628) + // Must be an integer in {1, 2, 3} (OWASP ASVS levels). + if (kp === 'workflow.security_asvs_level') { + if (typeof parsedValue !== 'number' || !Number.isInteger(parsedValue) || parsedValue < 1 || parsedValue > 3) { + error(`Invalid workflow.security_asvs_level '${val}'. Must be an integer 1, 2, or 3.`); + } } if (kp === 'review.default_reviewers') { diff --git a/tests/fix-1628-config-set-validation.test.cjs b/tests/fix-1628-config-set-validation.test.cjs new file mode 100644 index 000000000..0493bc567 --- /dev/null +++ b/tests/fix-1628-config-set-validation.test.cjs @@ -0,0 +1,614 @@ +'use strict'; + +/** + * Regression test suite for bug #1628: config-set validation gaps. + * + * This file consolidates all #1628 config-set validation regression tests: + * 1. Security-key enum guards (workflow.security_block_on, workflow.security_asvs_level) + * 2. JSON-array coercion bypass: every affected string-enum key + * 3. Generic capability-registry validation (enum/boolean/number/string keys) + * + * Covers: + * - workflow.security_block_on must be one of: critical | high | medium | low | none + * - workflow.security_asvs_level must be an integer in {1, 2, 3} + * - JSON-array ([""]) and JSON-object ({"x":1}) values must be REJECTED for + * all string-enum keys (typeof check before enum guard) + * - capability-registry-owned keys: ENUM, BOOLEAN, NUMBER, STRING + * + * Boundary coverage per RULESET.TESTS.boundary-coverage: + * security_asvs_level: 0 (limit-1), 1 (limit), 2, 3 (limit), 4 (limit+1) + * security_block_on: each valid enum member + bogus values + * + * Registry canary: verifies capability registry's .values for workflow.security_block_on + * matches the canonical enum (guards against silent gutting per DEFECT.GENERATIVE-FIX). + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); + +// ─── Registry canary ────────────────────────────────────────────────────────── +// Verify the capability registry's declared .values for workflow.security_block_on +// matches the canonical enum. config.cts sources its allowed set DIRECTLY from the +// registry, so this canary guards against the registry being silently gutted — which +// would cause every config-set call to fail (per DEFECT.GENERATIVE-FIX). +describe('fix-1628: registry canary — capability registry declares the canonical security_block_on enum', () => { + test('registry workflow.security_block_on.values declares the expected canonical enum', () => { + // Load the capability registry as a module (behavioral call, not source grep). + // The registry IS the source of truth: config.cts reads from it at runtime. + // This assertion guards against the registry entry being gutted or values removed. + const { configSchema } = require('../gsd-core/bin/lib/capability-registry.cjs'); + const entry = configSchema['workflow.security_block_on']; + assert.ok(entry, 'capability registry must have an entry for workflow.security_block_on'); + assert.ok(Array.isArray(entry.values), 'registry entry must have a .values array'); + + const EXPECTED = ['critical', 'high', 'medium', 'low', 'none']; + assert.deepEqual( + [...entry.values].sort(), + [...EXPECTED].sort(), + `Registry workflow.security_block_on.values must be ${JSON.stringify(EXPECTED)} — update ` + + `the capability registry if the canonical enum changes` + ); + }); +}); + +// ─── workflow.security_block_on ─────────────────────────────────────────────── + +describe('fix-1628: workflow.security_block_on enum validation', () => { + const VALID_VALUES = ['critical', 'high', 'medium', 'low', 'none']; + const INVALID_VALUES = ['bogus', 'High', 'CRITICAL', '', 'all', 'urgent']; + + for (const v of VALID_VALUES) { + test(`config-set workflow.security_block_on=${v} is ACCEPTED`, (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools( + ['config-set', 'workflow.security_block_on', v], + tmpDir + ); + assert.ok( + result.success, + [ + `config-set workflow.security_block_on=${v} must succeed,`, + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + } + + for (const v of INVALID_VALUES) { + test(`config-set workflow.security_block_on=${JSON.stringify(v)} is REJECTED`, (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools( + ['config-set', 'workflow.security_block_on', v], + tmpDir + ); + assert.ok( + !result.success, + `config-set workflow.security_block_on=${JSON.stringify(v)} must fail, but it succeeded` + ); + const combined = (result.output || '') + (result.error || ''); + // Error message must mention the valid values + assert.ok( + combined.includes('critical') && combined.includes('none'), + `Error message must mention valid values (got: ${combined})` + ); + }); + } +}); + +// ─── workflow.security_block_on — JSON-parse coercion bypass ───────────────── +// Regression for the String(parsedValue) coercion bug: an array like ["high"] +// coerces to "high" via String(), bypassing the enum check and writing an array +// to a string-enum key. The fix requires typeof parsedValue === 'string'. + +describe('fix-1628: workflow.security_block_on rejects JSON-parsed non-string inputs', () => { + const JSON_BYPASS_CASES = [ + { val: '["high"]', label: 'JSON array with valid member' }, + { val: '["bogus"]', label: 'JSON array with invalid member' }, + { val: '{"high":1}', label: 'JSON object' }, + ]; + + for (const { val, label } of JSON_BYPASS_CASES) { + test(`config-set workflow.security_block_on=${label} is REJECTED`, (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools( + ['config-set', 'workflow.security_block_on', val], + tmpDir + ); + assert.ok( + !result.success, + `config-set workflow.security_block_on=${label} must fail, but it succeeded` + ); + }); + } +}); + +// ─── workflow.security_asvs_level ───────────────────────────────────────────── + +describe('fix-1628: workflow.security_asvs_level range validation', () => { + // Boundary: 0 (below limit), 1 (min valid), 2 (mid), 3 (max valid), 4 (above limit) + const ACCEPTED_INTEGERS = [1, 2, 3]; + const REJECTED_VALUES = [ + { val: '0', label: '0 (below lower bound)' }, + { val: '4', label: '4 (above upper bound)' }, + { val: '2.5', label: '2.5 (non-integer float)' }, + { val: 'abc', label: '"abc" (non-numeric string)' }, + { val: '-1', label: '-1 (negative)' }, + ]; + + for (const n of ACCEPTED_INTEGERS) { + test(`config-set workflow.security_asvs_level=${n} is ACCEPTED`, (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools( + ['config-set', 'workflow.security_asvs_level', String(n)], + tmpDir + ); + assert.ok( + result.success, + [ + `config-set workflow.security_asvs_level=${n} must succeed,`, + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + } + + for (const { val, label } of REJECTED_VALUES) { + test(`config-set workflow.security_asvs_level=${label} is REJECTED`, (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools( + ['config-set', 'workflow.security_asvs_level', val], + tmpDir + ); + assert.ok( + !result.success, + `config-set workflow.security_asvs_level=${label} must fail, but it succeeded` + ); + const combined = (result.output || '') + (result.error || ''); + assert.ok( + combined.includes('security_asvs_level'), + `Error message must reference the key name (got: ${combined})` + ); + }); + } +}); + +// ─── JSON-array coercion bypass — parameterised matrix ─────────────────────── +// The root cause: cmdConfigSet JSON-parses any value starting with '[' or '{' +// BEFORE per-key enum guards run. Guards using `.includes(String(parsedValue))` +// are then fooled because `String(["mid-flight"]) === "mid-flight"`, so the +// array bypasses the guard and gets stored in a scalar key. +// +// The fix: `assertEnumValue()` checks `typeof parsedValue === 'string'` FIRST, +// so a parsed array is rejected regardless of its string coercion. +// +// Coverage: every affected string-enum key. +// - `[""]` (JSON array with valid member) → REJECTED +// - `{"x":1}` (JSON object) → REJECTED +// - `` (plain string, valid) → ACCEPTED + +// Each row: { key, member } where `member` is a valid enum value for `key`. +// Verified against VALID_* arrays in src/config.cts. +const ENUM_KEYS = [ + { key: 'context', member: 'research' }, + { key: 'workflow.drift_action', member: 'warn' }, + { key: 'workflow.human_verify_mode', member: 'mid-flight' }, + { key: 'workflow.context_guard_mode', member: 'off' }, + { key: 'statusline.context_position', member: 'front' }, + { key: 'code_quality.fallow.scope', member: 'phase' }, + { key: 'code_quality.fallow.profile', member: 'standard' }, + { key: 'plan_review.source_grounding_authority', member: 'grep' }, + { key: 'workflow.security_block_on', member: 'high' }, +]; + +for (const { key, member } of ENUM_KEYS) { + describe(`fix-1628 coercion bypass: ${key}`, () => { + // ── JSON array with valid member must be REJECTED ──────────────────────── + test(`["${member}"] (JSON array with valid member) is REJECTED`, (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const val = `["${member}"]`; + const result = runGsdTools(['config-set', key, val], tmpDir); + assert.ok( + !result.success, + [ + `config-set ${key}=${val} must be REJECTED (JSON-array coercion bypass)`, + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + // ── JSON object must be REJECTED ───────────────────────────────────────── + test(`{"x":1} (JSON object) is REJECTED`, (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const val = '{"x":1}'; + const result = runGsdTools(['config-set', key, val], tmpDir); + assert.ok( + !result.success, + [ + `config-set ${key}=${val} must be REJECTED (JSON-object bypass)`, + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + // ── Plain valid string must be ACCEPTED ────────────────────────────────── + test(`"${member}" (plain valid string) is ACCEPTED`, (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', key, member], tmpDir); + assert.ok( + result.success, + [ + `config-set ${key}=${member} must be ACCEPTED (plain string, valid enum member)`, + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + }); +} + +// ─── ENUM: workflow.code_review_depth ──────────────────────────────────────── + +describe('fix-1628 capability validation: workflow.code_review_depth (enum)', () => { + const VALID_VALUES = ['quick', 'standard', 'deep']; + + for (const v of VALID_VALUES) { + test(`config-set workflow.code_review_depth=${v} is ACCEPTED`, (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'workflow.code_review_depth', v], tmpDir); + assert.ok( + result.success, + [ + `config-set workflow.code_review_depth=${v} must succeed`, + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + } + + test('config-set workflow.code_review_depth=["standard"] (JSON array bypass) is REJECTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'workflow.code_review_depth', '["standard"]'], tmpDir); + assert.ok( + !result.success, + [ + 'config-set workflow.code_review_depth=["standard"] must be REJECTED (JSON-array coercion bypass)', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + test('config-set workflow.code_review_depth=garbage is REJECTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'workflow.code_review_depth', 'garbage'], tmpDir); + assert.ok( + !result.success, + [ + 'config-set workflow.code_review_depth=garbage must be REJECTED (out-of-enum)', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); +}); + +// ─── ENUM: mempalace.memory_mode ───────────────────────────────────────────── + +describe('fix-1628 capability validation: mempalace.memory_mode (enum)', () => { + const VALID_VALUES = ['augment', 'kg_backend', 'replace']; + + for (const v of VALID_VALUES) { + test(`config-set mempalace.memory_mode=${v} is ACCEPTED`, (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'mempalace.memory_mode', v], tmpDir); + assert.ok( + result.success, + [ + `config-set mempalace.memory_mode=${v} must succeed`, + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + } + + test('config-set mempalace.memory_mode=["augment"] (JSON array bypass) is REJECTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'mempalace.memory_mode', '["augment"]'], tmpDir); + assert.ok( + !result.success, + [ + 'config-set mempalace.memory_mode=["augment"] must be REJECTED (JSON-array coercion bypass)', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + test('config-set mempalace.memory_mode=garbage is REJECTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'mempalace.memory_mode', 'garbage'], tmpDir); + assert.ok( + !result.success, + [ + 'config-set mempalace.memory_mode=garbage must be REJECTED (out-of-enum)', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); +}); + +// ─── BOOLEAN: workflow.tdd_mode ────────────────────────────────────────────── + +describe('fix-1628 capability validation: workflow.tdd_mode (boolean)', () => { + test('config-set workflow.tdd_mode=true is ACCEPTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'workflow.tdd_mode', 'true'], tmpDir); + assert.ok( + result.success, + [ + 'config-set workflow.tdd_mode=true must succeed', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + test('config-set workflow.tdd_mode=false is ACCEPTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'workflow.tdd_mode', 'false'], tmpDir); + assert.ok( + result.success, + [ + 'config-set workflow.tdd_mode=false must succeed', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + test('config-set workflow.tdd_mode=["true"] (JSON array bypass) is REJECTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'workflow.tdd_mode', '["true"]'], tmpDir); + assert.ok( + !result.success, + [ + 'config-set workflow.tdd_mode=["true"] must be REJECTED (JSON-array coercion bypass)', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + test('config-set workflow.tdd_mode={"x":1} (JSON object) is REJECTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'workflow.tdd_mode', '{"x":1}'], tmpDir); + assert.ok( + !result.success, + [ + 'config-set workflow.tdd_mode={"x":1} must be REJECTED (JSON-object bypass)', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + test('config-set workflow.tdd_mode=maybe (non-boolean string) is REJECTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'workflow.tdd_mode', 'maybe'], tmpDir); + assert.ok( + !result.success, + [ + 'config-set workflow.tdd_mode=maybe must be REJECTED (non-boolean string)', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + test('config-set workflow.tdd_mode=1 (numeric 1 coerces to number, not boolean) is REJECTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'workflow.tdd_mode', '1'], tmpDir); + assert.ok( + !result.success, + [ + 'config-set workflow.tdd_mode=1 must be REJECTED (number, not boolean)', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); +}); + +// ─── BOOLEAN: graphify.enabled ──────────────────────────────────────────────── + +describe('fix-1628 capability validation: graphify.enabled (boolean)', () => { + test('config-set graphify.enabled=true is ACCEPTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'graphify.enabled', 'true'], tmpDir); + assert.ok( + result.success, + [ + 'config-set graphify.enabled=true must succeed', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + test('config-set graphify.enabled=false is ACCEPTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'graphify.enabled', 'false'], tmpDir); + assert.ok( + result.success, + [ + 'config-set graphify.enabled=false must succeed', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + test('config-set graphify.enabled=["true"] (JSON array bypass) is REJECTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'graphify.enabled', '["true"]'], tmpDir); + assert.ok( + !result.success, + [ + 'config-set graphify.enabled=["true"] must be REJECTED (JSON-array coercion bypass)', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + test('config-set graphify.enabled={"x":1} (JSON object) is REJECTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'graphify.enabled', '{"x":1}'], tmpDir); + assert.ok( + !result.success, + [ + 'config-set graphify.enabled={"x":1} must be REJECTED (JSON-object bypass)', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + test('config-set graphify.enabled=maybe (non-boolean string) is REJECTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'graphify.enabled', 'maybe'], tmpDir); + assert.ok( + !result.success, + [ + 'config-set graphify.enabled=maybe must be REJECTED (non-boolean string)', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + test('config-set graphify.enabled=1 (numeric 1, not boolean) is REJECTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'graphify.enabled', '1'], tmpDir); + assert.ok( + !result.success, + [ + 'config-set graphify.enabled=1 must be REJECTED (number, not boolean)', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); +}); + +// ─── NUMBER: workflow.drift_threshold ──────────────────────────────────────── + +describe('fix-1628 capability validation: workflow.drift_threshold (number)', () => { + test('config-set workflow.drift_threshold=5 is ACCEPTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'workflow.drift_threshold', '5'], tmpDir); + assert.ok( + result.success, + [ + 'config-set workflow.drift_threshold=5 must succeed', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + test('config-set workflow.drift_threshold=["3"] (JSON array bypass) is REJECTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'workflow.drift_threshold', '["3"]'], tmpDir); + assert.ok( + !result.success, + [ + 'config-set workflow.drift_threshold=["3"] must be REJECTED (JSON-array coercion bypass)', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); +}); + +// ─── STRING: mempalace.wing ─────────────────────────────────────────────────── + +describe('fix-1628 capability validation: mempalace.wing (string)', () => { + test('config-set mempalace.wing=myWing is ACCEPTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'mempalace.wing', 'myWing'], tmpDir); + assert.ok( + result.success, + [ + 'config-set mempalace.wing=myWing must succeed', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + test('config-set mempalace.wing=["x"] (JSON array bypass) is REJECTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'mempalace.wing', '["x"]'], tmpDir); + assert.ok( + !result.success, + [ + 'config-set mempalace.wing=["x"] must be REJECTED (JSON-array coercion bypass)', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + test('config-set mempalace.wing={"a":1} (JSON object) is REJECTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'mempalace.wing', '{"a":1}'], tmpDir); + assert.ok( + !result.success, + [ + 'config-set mempalace.wing={"a":1} must be REJECTED (JSON-object bypass)', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); +});