diff --git a/bin/install.js b/bin/install.js index 92acdb147..a214124cf 100755 --- a/bin/install.js +++ b/bin/install.js @@ -315,16 +315,46 @@ try { _capabilityRegistry = undefined; } +// Fail-safe floor for the reference host's #338-privacy-critical behaviors, used +// ONLY when the first-party capability registry cannot be loaded (a broken bundle). +// Without it, a registry-load failure would make `_hostBehaviors('claude')` return +// {} and silently route a claude LOCAL install to the repo-shared, committed +// `settings.json` instead of the gitignored `settings.local.json` (#338) — leaking +// engineer-specific absolute paths. Keyed by runtime id (a DATA lookup, not a +// `runtime === 'claude'` branch) so behavior degrades CLOSED (safe), never open. +// The live descriptor (capabilities/claude/capability.json) remains the source of +// truth; this mirrors only the privacy-load-bearing subset. (ADR-1239 / #2086) +const FALLBACK_HOST_BEHAVIORS = Object.freeze({ + claude: Object.freeze({ + settingsFileByScope: Object.freeze({ local: 'settings.local.json', global: 'settings.json' }), + permissionsSchema: 'claude', + sourceMarkerFile: '.gsd-source', + }), +}); + +/** + * Resolve a runtime's host behaviors from a capability registry, with the + * #338-privacy fail-safe floor when the registry (or the runtime's descriptor) + * is unavailable. Registry is passed in so this is unit-testable under a + * simulated registry-load failure. (ADR-1239 / #2086) + */ +function _resolveHostBehaviors(runtime, registry) { + const cap = registry && registry.runtimes && registry.runtimes[runtime]; + const declared = cap && cap.runtime && cap.runtime.hostBehaviors; + if (declared) return declared; + return FALLBACK_HOST_BEHAVIORS[runtime] || {}; +} + /** * Host-specific install behaviors, declared on the runtime descriptor * (capabilities//capability.json -> runtime.hostBehaviors) instead of * scattered `runtime === ''` string checks (ADR-1239 / #2086). Returns {} * for runtimes that declare none, so every behavior branch degrades to the - * generic path by default. + * generic path by default — EXCEPT the reference host's #338-critical keys, which + * fall back to FALLBACK_HOST_BEHAVIORS if the registry failed to load. */ function _hostBehaviors(runtime) { - const cap = _capabilityRegistry && _capabilityRegistry.runtimes && _capabilityRegistry.runtimes[runtime]; - return (cap && cap.runtime && cap.runtime.hostBehaviors) || {}; + return _resolveHostBehaviors(runtime, _capabilityRegistry); } /** @@ -11099,6 +11129,9 @@ module.exports = { install, installAllRuntimes, uninstall, + // #2086 — host-behavior resolution + the #338 privacy fail-safe floor (exported for tests) + _resolveHostBehaviors, + FALLBACK_HOST_BEHAVIORS, convertSlashCommandsToCodexSkillMentions, convertClaudeCommandToCodexSkill, convertClaudeCommandToKimiSkill, diff --git a/tests/claude-imperative-reference.test.cjs b/tests/claude-imperative-reference.test.cjs index 94ef54df7..8b67c3dfd 100644 --- a/tests/claude-imperative-reference.test.cjs +++ b/tests/claude-imperative-reference.test.cjs @@ -36,6 +36,11 @@ const CLAUDE_CAP = JSON.parse( ); const CLAUDE_AXES = CLAUDE_CAP.runtime.hostIntegration; +// Requiring the installer (not as main) never runs the CLI; GSD_TEST_MODE is set +// defensively to match the install-test convention. +process.env.GSD_TEST_MODE = process.env.GSD_TEST_MODE || '1'; +const installMod = require('../bin/install.js'); + // -- AC2: driven through the public interface (imperative adapter) ----------- test('createImperativeAdapter classifies claude as imperative + composes the registry', () => { @@ -136,3 +141,36 @@ test('bin/install.js contains no `runtime === "claude"` / `runtime !== "claude"` `AC2: every hardcoded runtime==='claude'/!=='claude' branch must be descriptor-driven; found: ${offenders.join(', ')}`, ); }); + +// -- Reviewer #2106 (elevated): #338 privacy fail-safe on registry-load failure -- +// If the first-party capability registry fails to load, `_hostBehaviors('claude')` +// would return {} and route a claude LOCAL install to the repo-shared settings.json +// instead of the gitignored settings.local.json — silently reintroducing #338. The +// reference host must degrade CLOSED (safe) for its privacy-critical keys. + +test('claude #338-critical host behaviors degrade CLOSED when the capability registry cannot load', () => { + // Simulate a broken bundle: registry is undefined. + const degraded = installMod._resolveHostBehaviors('claude', undefined); + assert.equal(degraded.settingsFileByScope.local, 'settings.local.json', + '#338: a claude LOCAL install must still route to the gitignored settings.local.json'); + assert.equal(degraded.settingsFileByScope.global, 'settings.json'); + assert.equal(degraded.permissionsSchema, 'claude', 'permission cleanup/merge must still apply'); + assert.equal(degraded.sourceMarkerFile, '.gsd-source'); +}); + +test('with the registry present, claude host behaviors come from the live descriptor (superset of the fail-safe floor)', () => { + const reg = require('../gsd-core/bin/lib/capability-registry.cjs'); + const declared = installMod._resolveHostBehaviors('claude', reg); + assert.equal(declared.settingsFileByScope.local, 'settings.local.json'); + assert.equal(declared.localInstallStyle, 'legacy-flat'); + assert.equal(declared.authorsCanonicalWorkflow, true); + // The fail-safe floor is a strict subset of what the descriptor declares. + for (const k of Object.keys(installMod.FALLBACK_HOST_BEHAVIORS.claude)) { + assert.ok(k in declared, `descriptor must still declare the #338-critical key '${k}'`); + } +}); + +test('a non-reference runtime has no fail-safe fallback (degrades to the generic path)', () => { + assert.deepEqual(installMod._resolveHostBehaviors('opencode', undefined), {}); + assert.deepEqual(installMod._resolveHostBehaviors('codex', undefined), {}); +});