'use strict'; /** * Behavioral regression tests for ADR-1239 Phase B write-confinement. * * Tests cover: * - copyWithPathReplacement: happy path, escape rejection, dest===root, * fail-closed (no confinementRoot), symlink escape * - installCodexConfig: happy path, agent name-injection rejection * - _copyStaged: escape rejection, symlink escape (regression preserved) */ const { describe, test, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const { cleanup } = require('./helpers.cjs'); process.env['GSD_TEST_MODE'] = '1'; const { copyWithPathReplacement, installCodexConfig, _resolveUserArtifactStagingRoot: _installJsResolveUserArtifactStagingRoot, _tryResolveUserArtifactStagingRoot: _installJsTryResolveUserArtifactStagingRoot, install, uninstall, } = require('../bin/install.js'); const { _copyStaged, } = require('../gsd-core/bin/lib/install-engine.cjs'); // #2875 (epic #2866 Phase 6): user-artifact-staging confinement rows (E1-E5). // Top-level (not inside any of the folded `__foldDescribe` sections below, // which each scope their own requires to their own closure). const { assertDestWithinConfigHome: _uasAssertDestWithinConfigHome, } = require('../gsd-core/bin/lib/runtime-artifact-install-plan.cjs'); const { _resolveUserArtifactStagingRoot, _tryResolveUserArtifactStagingRoot, } = require('../gsd-core/bin/lib/install-engine.cjs'); const { recoverOrphanedUserArtifacts, stageUserArtifacts, restoreStagedUserArtifacts, } = require('../gsd-core/bin/lib/user-artifact-staging.cjs'); // --------------------------------------------------------------------------- // copyWithPathReplacement // --------------------------------------------------------------------------- describe('copyWithPathReplacement write-confinement', () => { test('1. happy path: file is written under confinementRoot', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cwpr-happy-')); try { const srcDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cwpr-src-')); try { fs.writeFileSync(path.join(srcDir, 'test.md'), '---\nname: test\n---\nbody\n', 'utf8'); const destDir = path.join(root, 'sub', 'dest'); copyWithPathReplacement(srcDir, destDir, '~/.claude/', 'claude', false, false, root); // The dest dir and its content must exist inside root const written = fs.existsSync(path.join(destDir, 'test.md')); assert.ok(written, 'test.md must have been written to destDir under root'); assert.ok( path.resolve(destDir).startsWith(path.resolve(root) + path.sep), 'destDir must be under root', ); } finally { cleanup(srcDir); } } finally { cleanup(root); } }); test('2. escape rejected: destDir outside confinementRoot → throws, nothing written at escape path', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cwpr-root-')); const escapeName = 'gsd-cwpr-escape-' + Date.now(); const escapePath = path.join(os.tmpdir(), escapeName); try { const srcDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cwpr-src2-')); try { fs.writeFileSync(path.join(srcDir, 'evil.md'), '# evil\n', 'utf8'); // destDir resolves outside root via parent traversal const destDir = path.join(root, '..', escapeName); assert.throws( () => copyWithPathReplacement(srcDir, destDir, '~/.claude/', 'claude', false, false, root), /escap|must be a strict subpath|refusing/i, ); // Nothing must have been created at the escape path assert.ok(!fs.existsSync(escapePath), 'must not create anything at the escape path'); } finally { cleanup(srcDir); } } finally { cleanup(root); // also remove escapePath if it was somehow created (defensive) if (fs.existsSync(escapePath)) cleanup(escapePath); } }); test('3. dest === root rejected: throws when destDir equals confinementRoot', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cwpr-eqroot-')); try { const srcDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cwpr-src3-')); try { fs.writeFileSync(path.join(srcDir, 'x.md'), '# x\n', 'utf8'); assert.throws( () => copyWithPathReplacement(srcDir, root, '~/.claude/', 'claude', false, false, root), /escap|must be a strict subpath|refusing|configHome itself/i, ); } finally { cleanup(srcDir); } } finally { cleanup(root); } }); test('4. fail-closed: omitting confinementRoot throws with descriptive message', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cwpr-fc-')); try { const srcDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cwpr-src4-')); try { fs.writeFileSync(path.join(srcDir, 'y.md'), '# y\n', 'utf8'); const destDir = path.join(root, 'sub'); assert.throws( () => copyWithPathReplacement(srcDir, destDir, '~/.claude/', 'claude', false, false, undefined), /confinementRoot is required/, ); } finally { cleanup(srcDir); } } finally { cleanup(root); } }); test('5. symlink escape: destDir via symlink outside root → throws', (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cwpr-syml-')); const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cwpr-out-')); try { const srcDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cwpr-src5-')); try { fs.writeFileSync(path.join(srcDir, 'z.md'), '# z\n', 'utf8'); const linkPath = path.join(root, 'link'); try { fs.symlinkSync(outside, linkPath); } catch (_symlinkErr) { // Symlink creation unsupported on this platform/privilege — skip test body t.skip('symlink creation unsupported on this platform/privilege'); return; } const destDir = path.join(linkPath, 'sub'); assert.throws( () => copyWithPathReplacement(srcDir, destDir, '~/.claude/', 'claude', false, false, root), /symlink|escap|confinement|install root/i, ); // Nothing written to outside assert.strictEqual(fs.readdirSync(outside).length, 0, 'must not write to the outside dir via symlink'); } finally { cleanup(srcDir); } } finally { // unlink the symlink before cleanup to avoid crossing boundaries try { fs.unlinkSync(path.join(root, 'link')); } catch { /* already gone */ } cleanup(root); cleanup(outside); } }); }); // --------------------------------------------------------------------------- // installCodexConfig // --------------------------------------------------------------------------- describe('installCodexConfig write-confinement', () => { test('6. happy path: config.toml and agents/.toml written under targetDir', () => { const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-icc-happy-')); try { const agentsSrc = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-icc-src-')); try { // Minimal valid agent frontmatter fs.writeFileSync( path.join(agentsSrc, 'gsd-foo.md'), '---\nname: gsd-foo\ndescription: x\n---\nbody\n', 'utf8', ); const count = installCodexConfig(targetDir, agentsSrc); assert.strictEqual(count, 1, 'must return count of 1 agent processed'); assert.ok(fs.existsSync(path.join(targetDir, 'config.toml')), 'config.toml must exist under targetDir'); assert.ok(fs.existsSync(path.join(targetDir, 'agents', 'gsd-foo.toml')), 'agents/gsd-foo.toml must exist under targetDir'); } finally { cleanup(agentsSrc); } } finally { cleanup(targetDir); } }); test('7a. name-injection rejected: frontmatter name "../../evil" must throw, nothing written at escape', () => { const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-icc-inj-')); // Use a unique escape name derived from the targetDir basename so the escape // path can never collide with pre-existing files in os.tmpdir(). // agentsTomlDir = resolve(targetDir, 'agents'); ../../.toml from // there = resolve(targetDir, '../.toml') = dirname(targetDir)/.toml const escapeName = path.basename(targetDir) + '-escape'; const escapePath = path.join(path.dirname(targetDir), escapeName + '.toml'); try { const agentsSrc = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-icc-src-inj-')); try { fs.writeFileSync( path.join(agentsSrc, 'gsd-evil.md'), `---\nname: ../../${escapeName}\ndescription: injected\n---\nbody\n`, 'utf8', ); assert.throws( () => installCodexConfig(targetDir, agentsSrc), /escap|strict subpath|refusing|NUL/i, ); // Verify nothing was written at the escape location assert.ok(!fs.existsSync(escapePath), 'no file/dir written at escape location'); } finally { cleanup(agentsSrc); } } finally { cleanup(targetDir); if (fs.existsSync(escapePath)) cleanup(escapePath); } }); test('7b. name-injection: "../config" and "../evil" must both throw (clobber-prevention, tighter agentsTomlDir root)', () => { // With confinement rooted at agentsTomlDir (not targetDir), a name like // "../config" resolves to targetDir/config.toml — still inside the configHome // but OUTSIDE agents/ — so the gate must throw (clobber prevention). // Similarly "../evil" resolves to targetDir/evil.toml, also outside agents/. // Both must throw regardless of whether they escape targetDir. // Case A: "../config" — would clobber config.toml, must throw. const targetDirA = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-icc-inj2a-')); try { const agentsSrcA = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-icc-src-inj2a-')); try { fs.writeFileSync( path.join(agentsSrcA, 'gsd-clobber.md'), '---\nname: ../config\ndescription: clobber attempt\n---\nbody\n', 'utf8', ); assert.throws( () => installCodexConfig(targetDirA, agentsSrcA), /escap|strict subpath|refusing|NUL/i, 'name "../config" must throw — it escapes agents/ even though it stays inside targetDir', ); } finally { cleanup(agentsSrcA); } } finally { cleanup(targetDirA); } // Case B: "../evil" — escapes agents/, must throw (new behavior with agentsTomlDir root). const targetDirB = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-icc-inj2b-')); try { const agentsSrcB = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-icc-src-inj2b-')); try { fs.writeFileSync( path.join(agentsSrcB, 'gsd-up.md'), '---\nname: ../up-escape-attempt\ndescription: boundary test\n---\nbody\n', 'utf8', ); assert.throws( () => installCodexConfig(targetDirB, agentsSrcB), /escap|strict subpath|refusing|NUL/i, 'name "../evil" must throw — it escapes agents/ (resolves to targetDir/evil.toml)', ); } finally { cleanup(agentsSrcB); } } finally { cleanup(targetDirB); } // Case C: "../../evil" still throws (escapes both agents/ and targetDir). const targetDirC = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-icc-inj2c-')); try { const agentsSrcC = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-icc-src-inj2c-')); try { fs.writeFileSync( path.join(agentsSrcC, 'gsd-deep.md'), '---\nname: ../../deep-escape\ndescription: deep escape\n---\nbody\n', 'utf8', ); assert.throws( () => installCodexConfig(targetDirC, agentsSrcC), /escap|strict subpath|refusing|NUL/i, ); } finally { cleanup(agentsSrcC); } } finally { cleanup(targetDirC); } }); test('10. symlink-escape: agents/ is a symlink outside targetDir → throws', (t) => { const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-icc-syml-')); const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-icc-syml-out-')); try { const agentsSrc = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-icc-syml-src-')); try { fs.writeFileSync( path.join(agentsSrc, 'gsd-foo.md'), '---\nname: gsd-foo\ndescription: x\n---\nbody\n', 'utf8', ); const agentsLink = path.join(targetDir, 'agents'); try { fs.symlinkSync(outsideDir, agentsLink); } catch (_symlinkErr) { // Symlink creation unsupported on this platform/privilege — skip t.skip('symlink creation unsupported on this platform/privilege'); return; } assert.throws( () => installCodexConfig(targetDir, agentsSrc), /symlink|escap|refusing/i, ); // Nothing must have been written to the outside dir via the symlink assert.strictEqual(fs.readdirSync(outsideDir).length, 0, 'must not write to the outside dir via symlink'); } finally { cleanup(agentsSrc); } } finally { try { fs.unlinkSync(path.join(targetDir, 'agents')); } catch { /* already gone */ } cleanup(targetDir); cleanup(outsideDir); } }); }); // --------------------------------------------------------------------------- // _copyStaged // --------------------------------------------------------------------------- describe('_copyStaged write-confinement', () => { test('8. escape rejected (regression): destDir escaping configDir → throws', () => { const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cs-cfg-')); const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cs-staged-')); const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cs-outside-')); try { fs.writeFileSync(path.join(stagedDir, 'help.md'), '# help\n', 'utf8'); assert.throws( () => _copyStaged(stagedDir, outsideDir, { kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' }, configDir), /escap|strict subpath|refusing|configHome/i, ); } finally { cleanup(configDir); cleanup(stagedDir); cleanup(outsideDir); } }); test('9. symlink escape rejected: destDir containing symlink to outside → throws', (t) => { const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cs-syml-')); const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cs-syml-out-')); const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cs-staged2-')); try { fs.writeFileSync(path.join(stagedDir, 'help.md'), '# help\n', 'utf8'); const linkPath = path.join(configDir, 'link'); try { fs.symlinkSync(outside, linkPath); } catch (symlinkErr) { // Symlink creation unsupported on this platform/privilege — skip t.skip('symlink creation unsupported on this platform/privilege'); return; } const destDir = path.join(linkPath, 'sub'); assert.throws( () => _copyStaged(stagedDir, destDir, { kind: 'commands', destSubpath: 'commands/link/sub', prefix: 'gsd-' }, configDir), /symlink|escap|confinement|install root/i, ); assert.strictEqual(fs.readdirSync(outside).length, 0, 'must not have written to outside dir'); } finally { try { fs.unlinkSync(path.join(configDir, 'link')); } catch { /* already gone */ } cleanup(configDir); cleanup(outside); cleanup(stagedDir); } }); test('11. fail-closed: omitting configDir throws with descriptive message', () => { const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cs-fc-staged-')); const destDir = path.join(os.tmpdir(), 'gsd-cs-fc-dest-' + Date.now()); try { fs.writeFileSync(path.join(stagedDir, 'help.md'), '# help\n', 'utf8'); assert.throws( () => _copyStaged(stagedDir, destDir, { kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' }, undefined), /configDir.*required|required to confine/i, ); } finally { cleanup(stagedDir); if (fs.existsSync(destDir)) cleanup(destDir); } }); }); // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-2998-pristine-dir-populated.test.cjs — consolidation epic #1969 (B1 #1970) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:bug-2998-pristine-dir-populated (consolidation epic #1969 B1 #1970)", () => { 'use strict'; process.env.GSD_TEST_MODE = '1'; /** * Bug #2998: gsd-pristine/ snapshot is documented but never populated by * the installer. saveLocalPatches declared a pristineDir variable and * promised "saves pristine copies (from manifest) to gsd-pristine/ to * enable three-way merge during reapply-patches" -- but no code ever * wrote to that directory. Effect: the /gsd-reapply-patches Step 5 * verifier (#2972) silently degrades to its over-broad fallback heuristic * ("every significant backup line"), exactly the silent-success-on-lost- * content failure mode #2969 was designed to prevent. * * Fix: new populatePristineDir({...}) helper runs the install transform * pipeline (copyWithPathReplacement) into a tmp staging dir, then copies * out the modified-file paths into gsd-pristine/. saveLocalPatches now * accepts a pristineCtx and calls the helper when local patches are * detected. */ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); const crypto = require('node:crypto'); const ROOT = path.join(__dirname, '..'); const INSTALL = require(path.join(ROOT, 'bin', 'install.js')); const { cleanup } = require('./helpers.cjs'); function sha256(content) { return crypto.createHash('sha256').update(content).digest('hex'); } describe('Bug #2998: populatePristineDir is exported and writes pristine for modified files', () => { test('exported as a function', () => { assert.equal(typeof INSTALL.populatePristineDir, 'function', 'expected populatePristineDir in install.js exports (#2998)'); }); test('returns 0 when no files are modified (no-op)', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-')); try { const written = INSTALL.populatePristineDir({ packageSrc: ROOT, pristineDir: path.join(tmp, 'gsd-pristine'), modified: [], runtime: 'claude', pathPrefix: '$HOME/.claude/', isGlobal: true, }); assert.equal(written, 0); } finally { cleanup(tmp); } }); test('writes one pristine file per modified path that exists in source', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-')); const pristineDir = path.join(tmp, 'gsd-pristine'); try { // Pick a real installed-side relPath from the package source. The // install transforms map source `gsd-core/` to installed // `gsd-core/` for skills-aware runtimes (like claude), // so the relPath is the same on both sides. const candidate = path.join('gsd-core', 'workflows', 'reapply-patches.md'); const sourcePath = path.join(ROOT, candidate); assert.equal(fs.existsSync(sourcePath), true, `precondition: source file exists at ${candidate}`); const written = INSTALL.populatePristineDir({ packageSrc: ROOT, pristineDir, modified: [candidate], runtime: 'claude', pathPrefix: '$HOME/.claude/', isGlobal: true, }); assert.equal(written, 1, 'expected exactly one pristine file written'); const out = path.join(pristineDir, candidate); assert.equal(fs.existsSync(out), true, `expected pristine file at ${out}`); // The pristine content should be the transformed version (not raw source): // copyWithPathReplacement substitutes ~/.claude/ for the runtime path prefix. // For claude+global, the prefix is $HOME/.claude/ which equals the original, // so the transform is effectively identity here. We assert the content is a // non-empty markdown file rather than asserting on transform specifics. const content = fs.readFileSync(out, 'utf-8'); assert.ok(content.length > 0, 'pristine file should be non-empty'); } finally { cleanup(tmp); } }); test('skips paths not present in source (does not corrupt pristine with stale data)', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-')); const pristineDir = path.join(tmp, 'gsd-pristine'); try { const written = INSTALL.populatePristineDir({ packageSrc: ROOT, pristineDir, modified: ['gsd-core/this-path-does-not-exist.md'], runtime: 'claude', pathPrefix: '$HOME/.claude/', isGlobal: true, }); assert.equal(written, 0, 'expected zero pristine files for non-existent source paths'); const out = path.join(pristineDir, 'gsd-core/this-path-does-not-exist.md'); assert.equal(fs.existsSync(out), false, 'pristine should not contain ghost paths'); } finally { cleanup(tmp); } }); test('pristine files have stable content (transformations are deterministic)', () => { // Determinism is what makes the verifier's hash check meaningful: // backup-meta.json records pristine_hashes computed at this same step, // so re-running with the same inputs must yield byte-identical files. const tmp1 = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-d1-')); const tmp2 = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-d2-')); try { const candidate = path.join('gsd-core', 'workflows', 'reapply-patches.md'); const ctx = { packageSrc: ROOT, modified: [candidate], runtime: 'claude', pathPrefix: '$HOME/.claude/', isGlobal: true, }; INSTALL.populatePristineDir(Object.assign({ pristineDir: path.join(tmp1, 'gsd-pristine') }, ctx)); INSTALL.populatePristineDir(Object.assign({ pristineDir: path.join(tmp2, 'gsd-pristine') }, ctx)); const a = fs.readFileSync(path.join(tmp1, 'gsd-pristine', candidate)); const b = fs.readFileSync(path.join(tmp2, 'gsd-pristine', candidate)); assert.equal(sha256(a), sha256(b), 'two runs of the same inputs must yield identical pristine content'); } finally { cleanup(tmp1); cleanup(tmp2); } }); }); // ─── #3004 CR follow-up: multi-root pristine expansion ───────────────────── describe('Bug #2998 (#3004 CR): pristine expansion covers every manifest install root', () => { test('paths under agents/ are staged via copyWithPathReplacement, not silently skipped', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-multi-')); const pristineDir = path.join(tmp, 'gsd-pristine'); try { const candidate = path.join('agents', 'gsd-planner.md'); const sourcePath = path.join(ROOT, candidate); assert.equal(fs.existsSync(sourcePath), true, `precondition: source file exists at ${candidate}`); const written = INSTALL.populatePristineDir({ packageSrc: ROOT, pristineDir, modified: [candidate], runtime: 'claude', pathPrefix: '$HOME/.claude/', isGlobal: true, }); assert.equal(written, 1, 'expected agents/ path to be staged and copied to pristine'); assert.equal(fs.existsSync(path.join(pristineDir, candidate)), true); } finally { cleanup(tmp); } }); test('a mix of gsd-core/ and agents/ paths in modified list are all staged', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-mix-')); const pristineDir = path.join(tmp, 'gsd-pristine'); try { const a = path.join('gsd-core', 'workflows', 'reapply-patches.md'); const b = path.join('agents', 'gsd-planner.md'); assert.equal(fs.existsSync(path.join(ROOT, a)), true); assert.equal(fs.existsSync(path.join(ROOT, b)), true); const written = INSTALL.populatePristineDir({ packageSrc: ROOT, pristineDir, modified: [a, b], runtime: 'claude', pathPrefix: '$HOME/.claude/', isGlobal: true, }); assert.equal(written, 2, 'expected both top-level dirs to be staged'); assert.equal(fs.existsSync(path.join(pristineDir, a)), true); assert.equal(fs.existsSync(path.join(pristineDir, b)), true); } finally { cleanup(tmp); } }); }); describe('Bug #2998: saveLocalPatches no longer leaves the pristineDir variable unused', () => { test('saveLocalPatches accepts a pristineCtx and exposes the helper for direct testing', () => { // Structural assertion: the function exists with the new signature shape. // Behavioral end-to-end is covered by the populatePristineDir tests above // (that helper is what saveLocalPatches calls internally). assert.equal(typeof INSTALL.populatePristineDir, 'function'); // The signature for saveLocalPatches isn't exported, but the helper IS, // and it's the unit of behavior the bug is about. Asserting on the helper // is the structural-IR equivalent of the no-source-grep convention. }); }); }); } // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-3407-pristine-stale-content.test.cjs — consolidation epic #1969 (B1 #1970) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:bug-3407-pristine-stale-content (consolidation epic #1969 B1 #1970)", () => { 'use strict'; process.env.GSD_TEST_MODE = '1'; /** * Bug #3407: Installer leaves stale content in gsd-pristine/ * * Root cause: populatePristineDir() in saveLocalPatches() snapshots from * pristineCtx.packageSrc — the NEWLY-downloaded release tree — and writes * those bytes into gsd-pristine/. For files changed between the old and new * release, this writes the NEW bytes into the pristine baseline instead of * the OLD bytes. The three-way-diff verifier then classifies upstream-changed * lines as user-added → Step 5a gate fails with false FAIL_USER_LINES_MISSING. * * The #3657 fix (OK_PRISTINE_DRIFT_DETECTED) was a symptom workaround: the * verifier detects hash mismatch (backup-meta.json records old-release hash * but gsd-pristine/ has new-release bytes) and skips to over-broad mode * instead of false-failing. The root-cause stale write was never fixed. * * Fix: when a correctly-populated gsd-pristine/ already exists from the * previous install (i.e., the file's sha256 matches the originalHash recorded * in the manifest), preserve it — do NOT wipe and re-populate from the new * release source. This ensures gsd-pristine/ holds old-release bytes even * after an upgrade where the file content changed upstream. * * Regression contract (byte-comparison): * After saveLocalPatches() is called with a user-modified file whose * gsd-pristine/ entry was correctly set by the previous install, the * gsd-pristine/ file MUST still contain the old-release bytes, not the * new-release bytes supplied in pristineCtx.packageSrc. * * Closes: #3407 */ const { test, describe, beforeEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); const crypto = require('node:crypto'); const ROOT = path.join(__dirname, '..'); const INSTALL = require(path.join(ROOT, 'bin', 'install.js')); const { cleanup } = require('./helpers.cjs'); const MANIFEST_NAME = 'gsd-file-manifest.json'; const PATCHES_DIR_NAME = 'gsd-local-patches'; function sha256(content) { return crypto.createHash('sha256').update(content instanceof Buffer ? content : Buffer.from(content)).digest('hex'); } // ─── Bug #3407: gsd-pristine/ must preserve OLD-release bytes across upgrade ── describe('Bug #3407: saveLocalPatches preserves old-release pristine across upgrade', () => { let tmpDir; let configDir; let fakeSrcDir; beforeEach((t) => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3407-')); configDir = path.join(tmpDir, 'config'); fakeSrcDir = path.join(tmpDir, 'new-release-src'); fs.mkdirSync(configDir, { recursive: true }); fs.mkdirSync(fakeSrcDir, { recursive: true }); t.after(() => { cleanup(tmpDir); }); }); /** * Core regression test. * * Timeline: * Install v1: file content = OLD_RELEASE_CONTENT, gsd-pristine/ROOT_FILE * = OLD_RELEASE_CONTENT (correctly set by previous install), * manifest hash = sha256(OLD_RELEASE_CONTENT) * User edits: configDir/ROOT_FILE = USER_MODIFIED_CONTENT * Upgrade v2: pristineCtx.packageSrc has NEW_RELEASE_CONTENT for ROOT_FILE * saveLocalPatches is called before the wipe. * * Expected AFTER fix: gsd-pristine/ROOT_FILE still == OLD_RELEASE_CONTENT * Actual BEFORE fix: gsd-pristine/ROOT_FILE == NEW_RELEASE_CONTENT (stale) */ test('gsd-pristine/ retains old-release bytes when upgrading a user-modified file', () => { const OLD_RELEASE_CONTENT = '# Old Release Content\nThis is v1 pristine.\n'; const NEW_RELEASE_CONTENT = '# New Release Content\nThis is v2 — upstream changed this line.\n'; const USER_MODIFIED_CONTENT = '# Old Release Content\nThis is v1 pristine.\n## User addition\nUser customization here.\n'; const oldHash = sha256(OLD_RELEASE_CONTENT); // Simulate a root-level installed file. Root-level files in the manifest // are denoted without a subdirectory (slash-free relPath). const relPath = 'test-root-file.md'; // Set up configDir: user-modified installed file + manifest recording old hash fs.writeFileSync(path.join(configDir, relPath), USER_MODIFIED_CONTENT); fs.writeFileSync( path.join(configDir, MANIFEST_NAME), JSON.stringify({ version: '1.0.0', files: { [relPath]: oldHash } }, null, 2) ); // Set up fakeSrcDir (new release): the file has NEW content fs.writeFileSync(path.join(fakeSrcDir, relPath), NEW_RELEASE_CONTENT); // Set up gsd-pristine/ with OLD content (as correctly populated by previous install) const pristineDir = path.join(configDir, 'gsd-pristine'); fs.mkdirSync(pristineDir, { recursive: true }); fs.writeFileSync(path.join(pristineDir, relPath), OLD_RELEASE_CONTENT); // Call saveLocalPatches with the new release as packageSrc (the buggy scenario) INSTALL.saveLocalPatches(configDir, { packageSrc: fakeSrcDir, runtime: 'claude', pathPrefix: '$HOME/.claude/', isGlobal: true, }); // Assert: gsd-pristine/ must still contain OLD-release bytes const pristineFile = path.join(pristineDir, relPath); assert.ok( fs.existsSync(pristineFile), `gsd-pristine/${relPath} must exist after saveLocalPatches` ); const actualPristineContent = fs.readFileSync(pristineFile, 'utf8'); assert.equal( sha256(actualPristineContent), oldHash, [ `gsd-pristine/${relPath} must contain OLD-release bytes (sha256=${oldHash.slice(0, 12)}…)`, `but got sha256=${sha256(actualPristineContent).slice(0, 12)}…`, `(If equal to sha256(NEW_RELEASE_CONTENT)=${sha256(NEW_RELEASE_CONTENT).slice(0, 12)}… then #3407 is NOT fixed)`, ].join(' ') ); // Secondary: confirm backup-meta records the old hash (not new) const backupMeta = JSON.parse( fs.readFileSync(path.join(configDir, PATCHES_DIR_NAME, 'backup-meta.json'), 'utf8') ); assert.ok( Object.prototype.hasOwnProperty.call(backupMeta.pristine_hashes, relPath), 'backup-meta.json must record pristine_hash for modified file' ); assert.equal( backupMeta.pristine_hashes[relPath], oldHash, 'backup-meta.json pristine_hash must equal old-release hash (not new-release hash)' ); }); /** * Regression test for Codex finding: when gsd-pristine/ entry is absent * (e.g., post-buggy-run deletion or first upgrade without prior pristine) * but the file is UNCHANGED between old and new release, the hash-validated * regeneration path must restore the pristine entry using new-release source. * * When sha256(newReleaseBytesForFile) === originalHash, the file is identical * between releases — new-release generated bytes ARE the old-release pristine * and may be safely promoted. * * Previously (before the regeneration path was added): missing entries were * left absent unconditionally, causing permanent over-broad fallback even * when the file was unchanged upstream. */ test('gsd-pristine/ is regenerated for missing entries when file is unchanged between releases', () => { const SHARED_RELEASE_CONTENT = '# Shared Content\nThis file is identical in v1 and v2.\n'; const USER_MODIFIED_CONTENT = '# Shared Content\nThis file is identical in v1 and v2.\n## User addition\nCustom.\n'; const oldHash = sha256(SHARED_RELEASE_CONTENT); const relPath = 'test-unchanged-file.md'; // configDir has user-modified file + manifest with old-release hash fs.writeFileSync(path.join(configDir, relPath), USER_MODIFIED_CONTENT); fs.writeFileSync( path.join(configDir, MANIFEST_NAME), JSON.stringify({ version: '1.0.0', files: { [relPath]: oldHash } }, null, 2) ); // fakeSrcDir (new release) has the SAME content — file was not changed upstream fs.writeFileSync(path.join(fakeSrcDir, relPath), SHARED_RELEASE_CONTENT); // NOTE: gsd-pristine/ does NOT exist (simulating post-buggy-run or first-time scenario) INSTALL.saveLocalPatches(configDir, { packageSrc: fakeSrcDir, runtime: 'claude', pathPrefix: '$HOME/.claude/', isGlobal: true, }); // The regeneration path should have detected that sha256(new-release candidate) // === originalHash, and promoted the candidate into gsd-pristine/. const pristineFile = path.join(configDir, 'gsd-pristine', relPath); assert.ok( fs.existsSync(pristineFile), [ `gsd-pristine/${relPath} must exist after hash-validated regeneration.`, `When new-release bytes hash to originalHash, the file was unchanged between`, `releases and the candidate should be promoted to restore the pristine baseline.`, ].join(' ') ); const actualContent = fs.readFileSync(pristineFile, 'utf8'); assert.equal( sha256(actualContent), oldHash, [ `gsd-pristine/${relPath} must contain bytes matching originalHash after regeneration`, `(sha256=${oldHash.slice(0, 12)}…)`, ].join(' ') ); }); /** * Stale-pristine recovery test (pre-fix bug artifact). * * Timeline: * Buggy run: gsd-pristine/ was written with NEW_RELEASE_CONTENT * (the exact #3407 artifact — stale bytes from a buggy populatePristineDir). * Fix run: saveLocalPatches detects the hash mismatch * (sha256(NEW_RELEASE_CONTENT) !== originalHash recorded in manifest), * removes the stale entry, then attempts regeneration. * * When the file CHANGED between releases (NEW !== OLD): * - The stale entry is removed. * - Regeneration discards the new-release candidate (hash mismatch). * - gsd-pristine/ must be ABSENT (over-broad fallback — correct). * * When the file is UNCHANGED between releases (NEW === OLD): * - The stale entry (which happens to have correct bytes despite the bug) is * detected as correct (hash matches originalHash) and PRESERVED. * - gsd-pristine/ must remain present with the correct bytes. * * This test covers the "file changed across release boundary" case. * The "unchanged" case is already covered by the regeneration test above. */ test('stale gsd-pristine/ entry (new-release bytes) is removed when file changed between releases', () => { const OLD_RELEASE_CONTENT = '# Old Release\nv1 content here.\n'; const NEW_RELEASE_CONTENT = '# New Release\nv2 content — upstream changed this.\n'; const USER_MODIFIED_CONTENT = '# Old Release\nv1 content here.\n## User section\nCustom work.\n'; const oldHash = sha256(OLD_RELEASE_CONTENT); const relPath = 'test-stale-recovery.md'; // configDir: user-modified file + manifest recording OLD hash fs.writeFileSync(path.join(configDir, relPath), USER_MODIFIED_CONTENT); fs.writeFileSync( path.join(configDir, MANIFEST_NAME), JSON.stringify({ version: '1.0.0', files: { [relPath]: oldHash } }, null, 2) ); // fakeSrcDir (new release): contains the NEW content fs.writeFileSync(path.join(fakeSrcDir, relPath), NEW_RELEASE_CONTENT); // Pre-populate gsd-pristine/ with NEW_RELEASE_CONTENT — the exact pre-fix bug artifact. // This simulates a prior buggy run that wrote new-release bytes into the pristine baseline. const STALE_BYTES = NEW_RELEASE_CONTENT; // named constant for clarity const pristineDir = path.join(configDir, 'gsd-pristine'); fs.mkdirSync(pristineDir, { recursive: true }); fs.writeFileSync(path.join(pristineDir, relPath), STALE_BYTES); // Verify the pre-condition: stale bytes do NOT match the original hash. // If this assert fails, the test fixture is wrong (not a fix regression). assert.notEqual( sha256(STALE_BYTES), oldHash, 'test fixture check: stale bytes must differ from originalHash' ); INSTALL.saveLocalPatches(configDir, { packageSrc: fakeSrcDir, runtime: 'claude', pathPrefix: '$HOME/.claude/', isGlobal: true, }); // The fix must detect the hash mismatch (stale entry) and remove it. // The regeneration path discards the new-release candidate (its hash !== oldHash). // Result: gsd-pristine/ must be ABSENT — over-broad fallback is the safe outcome. const pristineFile = path.join(pristineDir, relPath); assert.strictEqual( fs.existsSync(pristineFile), false, [ `expected gsd-pristine/${relPath} to be absent after stale-pristine recovery.`, `The stale entry (new-release bytes, sha256=${sha256(STALE_BYTES).slice(0, 12)}…)`, `must be removed; regeneration must discard the candidate because`, `sha256(new-release)=${sha256(NEW_RELEASE_CONTENT).slice(0, 12)}… !== originalHash=${oldHash.slice(0, 12)}….`, `Presence of the file means the stale bytes were NOT cleaned up (pre-fix behavior).`, ].join(' ') ); }); /** * Second scenario: gsd-pristine/ does NOT pre-exist (first upgrade with no * prior pristine population). In this case there is no way to obtain the * old-release pristine bytes — populatePristineDir must NOT write the new- * release bytes either. The correct outcome is: gsd-pristine/ stays empty * for this file, and the verifier falls back to over-broad mode (safe). */ test('gsd-pristine/ stays empty when no prior pristine exists (first upgrade, no stale write)', () => { const OLD_RELEASE_CONTENT = '# Old Release Content\nThis is v1.\n'; const NEW_RELEASE_CONTENT = '# New Release Content\nThis is v2 — changed.\n'; const USER_MODIFIED_CONTENT = '# Old Release Content\nThis is v1.\n## User addition\nCustom.\n'; const oldHash = sha256(OLD_RELEASE_CONTENT); const relPath = 'test-first-upgrade.md'; // configDir has user-modified file + manifest fs.writeFileSync(path.join(configDir, relPath), USER_MODIFIED_CONTENT); fs.writeFileSync( path.join(configDir, MANIFEST_NAME), JSON.stringify({ version: '1.0.0', files: { [relPath]: oldHash } }, null, 2) ); // fakeSrcDir (new release) has new content fs.writeFileSync(path.join(fakeSrcDir, relPath), NEW_RELEASE_CONTENT); // NOTE: gsd-pristine/ does NOT exist yet (first upgrade) INSTALL.saveLocalPatches(configDir, { packageSrc: fakeSrcDir, runtime: 'claude', pathPrefix: '$HOME/.claude/', isGlobal: true, }); const pristineFile = path.join(configDir, 'gsd-pristine', relPath); assert.strictEqual( fs.existsSync(pristineFile), false, [ `expected gsd-pristine/${relPath} to be absent when file changed across release boundary.`, `Writing new-release bytes as pristine for a file whose hash is unknown leads to`, `false FAIL_USER_LINES_MISSING in the reapply-patches verifier (#3407).`, `Over-broad fallback mode is the correct outcome here.`, ].join(' ') ); }); }); // The former "Antipattern hunt" describe block (structural typeof checks only) was // removed — it provided no real behavioral coverage and was a vacuous-truth pattern // per /test-rigor skill. Behavioral tests for populatePristineDir are covered above. }); } // ──────────────────────────────────────────────────────────────────────── // Folded from tests/fix-1679-destsubpath-confinement.test.cjs — consolidation epic #1969 (B5 #1974) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:fix-1679-destsubpath-confinement (consolidation epic #1969 B5 #1974)", () => { 'use strict'; /** * Tests for ADR-1239 Phase B: destSubpath write-confinement security gate. * * Verifies that assertDestWithinConfigHome rejects escaping destSubpath values * and that createRuntimeArtifactInstallPlan and createRuntimeArtifactUninstallPlan * both reject them at plan-build time. * * Also covers: * F3 - assertDestWithinConfigHome rejects destSubpath === configHome itself * F4 - migrateLegacyDevPreferencesToSkill routes through the confinement gate * F2 - write sites (installOpencodeFamilySkills) reject symlink-escaping destDir */ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const { assertDestWithinConfigHome, createRuntimeArtifactInstallPlan, createRuntimeArtifactUninstallPlan, } = require('../gsd-core/bin/lib/runtime-artifact-install-plan.cjs'); const { migrateLegacyDevPreferencesToSkill, installOpencodeFamilySkills, installRuntimeArtifacts, _copyStaged, } = require('../gsd-core/bin/lib/install-engine.cjs'); const { createTempDir, cleanup } = require('./helpers.cjs'); // --------------------------------------------------------------------------- // Unit tests for assertDestWithinConfigHome // --------------------------------------------------------------------------- describe('assertDestWithinConfigHome', () => { let configDir; beforeEach(() => { configDir = createTempDir('gsd-confine-test-'); }); afterEach(() => { cleanup(configDir); }); // --- Rejection cases --- test('rejects destSubpath "../../etc" that escapes configDir', () => { assert.throws( () => assertDestWithinConfigHome(configDir, '../../etc'), (err) => { assert.ok(err instanceof Error, 'must be an Error'); assert.ok( err.message.includes('escapes configHome'), `expected "escapes configHome" in: ${err.message}`, ); return true; }, ); }); test('rejects destSubpath "../foo" that escapes configDir', () => { assert.throws( () => assertDestWithinConfigHome(configDir, '../foo'), /escapes configHome/, ); }); test('rejects destSubpath "a/../../b" that escapes configDir', () => { assert.throws( () => assertDestWithinConfigHome(configDir, 'a/../../b'), /escapes configHome/, ); }); test('rejects destSubpath containing a NUL byte', () => { assert.throws( () => assertDestWithinConfigHome(configDir, 'skills\0evil'), (err) => { assert.ok(err instanceof Error, 'must be an Error'); assert.ok( err.message.includes('NUL'), `expected "NUL" in: ${err.message}`, ); return true; }, ); }); // --- F3: reject destSubpath that resolves to configHome itself --- test('F3: rejects destSubpath "." that resolves to configHome itself', () => { assert.throws( () => assertDestWithinConfigHome(configDir, '.'), (err) => { assert.ok(err instanceof Error, 'must be an Error'); assert.ok( err.message.includes('not configHome itself') || err.message.includes('escapes configHome'), `expected confinement error in: ${err.message}`, ); return true; }, ); }); test('F3: rejects destSubpath "a/.." that resolves to configHome itself', () => { assert.throws( () => assertDestWithinConfigHome(configDir, 'a/..'), (err) => { assert.ok(err instanceof Error, 'must be an Error'); assert.ok( err.message.includes('not configHome itself') || err.message.includes('escapes configHome'), `expected confinement error in: ${err.message}`, ); return true; }, ); }); test('F3: rejects destSubpath "skills/../.." that resolves to configHome parent', () => { assert.throws( () => assertDestWithinConfigHome(configDir, 'skills/../..'), (err) => { assert.ok(err instanceof Error, 'must be an Error'); assert.ok( err.message.includes('not configHome itself') || err.message.includes('escapes configHome'), `expected confinement error in: ${err.message}`, ); return true; }, ); }); // --- Accepted cases --- test('accepts "skills" and returns path under configDir', () => { const result = assertDestWithinConfigHome(configDir, 'skills'); assert.ok( result.startsWith(path.resolve(configDir)), `expected result to start with configDir (${path.resolve(configDir)}), got: ${result}`, ); assert.strictEqual(result, path.join(path.resolve(configDir), 'skills')); }); test('accepts "commands/gsd" and returns path under configDir', () => { const result = assertDestWithinConfigHome(configDir, 'commands/gsd'); assert.ok(result.startsWith(path.resolve(configDir))); assert.strictEqual(result, path.join(path.resolve(configDir), 'commands', 'gsd')); }); test('accepts "./skills" and returns resolved path under configDir', () => { const result = assertDestWithinConfigHome(configDir, './skills'); assert.ok(result.startsWith(path.resolve(configDir))); assert.strictEqual(result, path.join(path.resolve(configDir), 'skills')); }); test('does not match a sibling directory with a shared prefix', () => { // configDir = /tmp/gsd-foo; a sibling like /tmp/gsd-foobar must NOT be accepted. // The path.sep guard in the implementation prevents a startsWith match // from crossing directory boundaries. We verify the happy-path: a valid // nested subpath resolves to a path strictly under configDir (includes sep). const result = assertDestWithinConfigHome(configDir, 'subdir/nested'); assert.ok(result.startsWith(path.resolve(configDir) + path.sep)); }); }); // --------------------------------------------------------------------------- // Integration tests for createRuntimeArtifactInstallPlan // --------------------------------------------------------------------------- describe('createRuntimeArtifactInstallPlan destSubpath confinement', () => { let configDir; beforeEach(() => { configDir = createTempDir('gsd-plan-confine-'); }); afterEach(() => { cleanup(configDir); }); function noopStage() { return '/tmp/staged-noop'; } function makeLayout(destSubpath) { return { runtime: 'claude', configDir, scope: 'global', kinds: [ { kind: 'skills', destSubpath, prefix: 'gsd-', stage: noopStage, }, ], }; } test('rejects an escaping destSubpath ("../../escape") at plan-build time', () => { const layout = makeLayout('../../escape'); assert.throws( () => createRuntimeArtifactInstallPlan({ layout, resolvedProfile: { name: 'core' }, deps: { rewriteStagedSkillBodies: () => undefined, rewriteStagedCommandBodies: () => undefined, }, }), (err) => { assert.ok(err instanceof Error); assert.ok( err.message.includes('escapes'), `expected "escapes" in: ${err.message}`, ); return true; }, ); }); test('normal destSubpath produces plan with destDir under configDir', () => { const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-staged-')); try { const layout = { runtime: 'claude', configDir, scope: 'global', kinds: [ { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-', stage: () => stagedDir, }, ], }; const result = createRuntimeArtifactInstallPlan({ layout, resolvedProfile: { name: 'core' }, deps: { rewriteStagedSkillBodies: () => undefined, rewriteStagedCommandBodies: () => undefined, }, }); assert.strictEqual(result.ok, true, 'plan must succeed for normal destSubpath'); assert.strictEqual(result.plan.items.length, 1); const destDir = result.plan.items[0].destDir; assert.ok( destDir.startsWith(path.resolve(configDir)), `destDir (${destDir}) must be under configDir (${configDir})`, ); } finally { cleanup(stagedDir); } }); }); // --------------------------------------------------------------------------- // Integration tests for createRuntimeArtifactUninstallPlan // --------------------------------------------------------------------------- describe('createRuntimeArtifactUninstallPlan destSubpath confinement', () => { let configDir; beforeEach(() => { configDir = createTempDir('gsd-uninstall-confine-'); }); afterEach(() => { cleanup(configDir); }); function makeUninstallLayout(destSubpath) { return { runtime: 'claude', configDir, kinds: [ { kind: 'skills', destSubpath, prefix: 'gsd-', stage: () => '/tmp/staged-noop', }, ], }; } test('rejects an escaping destSubpath ("../../escape") at uninstall-plan-build time', () => { const layout = makeUninstallLayout('../../escape'); assert.throws( () => createRuntimeArtifactUninstallPlan(layout), (err) => { assert.ok(err instanceof Error); assert.ok( err.message.includes('escapes'), `expected "escapes" in: ${err.message}`, ); return true; }, ); }); test('rejects destSubpath "../outside" at uninstall-plan-build time', () => { const layout = makeUninstallLayout('../outside'); assert.throws( () => createRuntimeArtifactUninstallPlan(layout), /escapes/, ); }); test('normal destSubpath produces uninstall plan with destDir under configDir', () => { const layout = makeUninstallLayout('skills'); const plan = createRuntimeArtifactUninstallPlan(layout); assert.strictEqual(plan.items.length, 1); const destDir = plan.items[0].destDir; assert.ok( destDir.startsWith(path.resolve(configDir)), `destDir (${destDir}) must be under configDir (${configDir})`, ); assert.strictEqual(destDir, path.join(path.resolve(configDir), 'skills')); }); test('normal nested destSubpath ("commands/gsd") produces uninstall plan with destDir under configDir', () => { const layout = makeUninstallLayout('commands/gsd'); const plan = createRuntimeArtifactUninstallPlan(layout); assert.strictEqual(plan.items.length, 1); const destDir = plan.items[0].destDir; assert.ok( destDir.startsWith(path.resolve(configDir)), `destDir (${destDir}) must be under configDir (${configDir})`, ); assert.strictEqual(destDir, path.join(path.resolve(configDir), 'commands', 'gsd')); }); }); // --------------------------------------------------------------------------- // F4: migrateLegacyDevPreferencesToSkill must route through the confinement gate // --------------------------------------------------------------------------- describe('F4: migrateLegacyDevPreferencesToSkill confinement', () => { let configDir; let outsideDir; beforeEach(() => { configDir = createTempDir('gsd-f4-confine-'); outsideDir = createTempDir('gsd-f4-outside-'); }); afterEach(() => { cleanup(configDir); cleanup(outsideDir); }); test('F4: migrateLegacyDevPreferencesToSkill throws when destSubpath resolves to configHome itself (via mocked layout with "." destSubpath)', () => { // We cannot easily inject a bad destSubpath through the real layout resolver // (it resolves to a real valid path). Instead we validate that the function // uses assertDestWithinConfigHome by passing a runtime whose layout's // skillsKindEntry.destSubpath, when joined with configDir, would escape — but // since real layouts are always safe, we test the guard on a deliberately // crafted saved map calling the real function and observing the path written // is always within configDir for a real runtime. // // Real-layout sanity: verify 'opencode' produces a write inside configDir. const savedLegacy = new Map([['dev-preferences.md', '# dev prefs\n']]); // Real opencode layout — should succeed without throwing assert.doesNotThrow(() => { migrateLegacyDevPreferencesToSkill(configDir, savedLegacy, 'opencode', 'global'); }, 'migrateLegacyDevPreferencesToSkill with real opencode layout must not throw'); // Verify the written file is inside configDir const written = []; function findMd(dir) { if (!fs.existsSync(dir)) return; for (const e of fs.readdirSync(dir, { withFileTypes: true })) { if (e.isDirectory()) findMd(path.join(dir, e.name)); else if (e.name.endsWith('.md')) written.push(path.join(dir, e.name)); } } findMd(configDir); assert.ok(written.length > 0, 'at least one .md must have been written'); for (const f of written) { assert.ok( f.startsWith(path.resolve(configDir) + path.sep), `written file ${f} must be inside configDir ${configDir}`, ); } }); test('F4: migrateLegacyDevPreferencesToSkill uses assertDestWithinConfigHome — path.join on configDir+destSubpath cannot escape via symlink in destSubpath string', () => { // Validate that the guard (assertDestWithinConfigHome) would have caught a // manipulated destSubpath value. We simulate by calling assertDestWithinConfigHome // directly with a "."-equivalent subpath (F3 guard) to prove F4 now relies on it. assert.throws( () => assertDestWithinConfigHome(configDir, '.'), (err) => { assert.ok(err instanceof Error); return true; }, 'assertDestWithinConfigHome must reject "." (used by F4 guard)', ); }); }); // --------------------------------------------------------------------------- // F2: write sites reject a symlink-escaping destDir // --------------------------------------------------------------------------- describe('F2: installOpencodeFamilySkills rejects symlink-escaping destDir', () => { let configDir; let outsideDir; let symlinkTarget; beforeEach(() => { configDir = createTempDir('gsd-f2-config-'); outsideDir = createTempDir('gsd-f2-outside-'); // Create a symlink inside configDir pointing outside symlinkTarget = path.join(configDir, 'skills'); fs.symlinkSync(outsideDir, symlinkTarget); }); afterEach(() => { // Remove symlink before cleanup to avoid errors try { fs.unlinkSync(symlinkTarget); } catch { /* already gone */ } cleanup(configDir); cleanup(outsideDir); }); test('F2: installOpencodeFamilySkills throws when skills/ is a symlink pointing outside configDir', () => { // Create a minimal rawCommandsDir with one .md file const rawDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-f2-raw-')); try { fs.writeFileSync(path.join(rawDir, 'help.md'), '# help\n', 'utf8'); assert.throws( () => installOpencodeFamilySkills('opencode', configDir, rawDir, '~/.opencode/'), (err) => { assert.ok(err instanceof Error, 'must be an Error'); assert.ok( err.message.toLowerCase().includes('symlink') || err.message.toLowerCase().includes('escap') || err.message.toLowerCase().includes('outside') || err.message.toLowerCase().includes('confinement'), `expected symlink/escape error in: ${err.message}`, ); return true; }, ); // Verify nothing was written to outsideDir const outsideFiles = fs.readdirSync(outsideDir); assert.strictEqual(outsideFiles.length, 0, 'must not have written anything outside configDir'); } finally { cleanup(rawDir); } }); }); // --------------------------------------------------------------------------- // M1: _copyStaged defense-in-depth must also reject dest === configRoot // --------------------------------------------------------------------------- describe('M1: _copyStaged rejects dest equal to configRoot', () => { let configDir; let stagedDir; beforeEach(() => { configDir = createTempDir('gsd-m1-config-'); stagedDir = createTempDir('gsd-m1-staged-'); // Write a dummy file into stagedDir so _copyStaged has something to copy fs.writeFileSync(path.join(stagedDir, 'help.md'), '# help\n', 'utf8'); }); afterEach(() => { cleanup(configDir); cleanup(stagedDir); }); test('M1: _copyStaged throws when destDir equals configRoot (was silently accepted before fix)', () => { // dest === configRoot: the canonical gate (assertDestWithinConfigHome) rejects // resolved === root with "escapes configHome" / "not configHome itself". assert.throws( () => _copyStaged(stagedDir, configDir, { kind: 'commands', destSubpath: '.', prefix: 'gsd-' }, configDir), (err) => { assert.ok(err instanceof Error, 'must be an Error'); assert.ok( err.message.includes('escapes configHome') || err.message.includes('not configHome itself') || err.message.includes('outside') || err.message.includes('inside'), `expected confinement error in: ${err.message}`, ); return true; }, ); }); test('M1: _copyStaged throws when destDir is outside configRoot', () => { const outsideDir = createTempDir('gsd-m1-outside-'); try { assert.throws( () => _copyStaged(stagedDir, outsideDir, { kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' }, configDir), (err) => { assert.ok(err instanceof Error, 'must be an Error'); assert.ok( // After EDIT 1, _copyStaged delegates to assertDestWithinConfigHome which // emits "escapes configHome"; the old "_copyStaged" prefix is no longer present. err.message.includes('escapes configHome') || err.message.includes('strict subpath') || err.message.includes('refusing'), `expected confinement error in: ${err.message}`, ); return true; }, ); } finally { cleanup(outsideDir); } }); test('M1: _copyStaged accepts destDir strictly under configRoot', () => { const destDir = path.join(configDir, 'commands', 'gsd'); fs.mkdirSync(destDir, { recursive: true }); // Should not throw — just copies (stagedDir has help.md, kind=commands) assert.doesNotThrow( () => _copyStaged(stagedDir, destDir, { kind: 'commands', destSubpath: 'commands/gsd', prefix: 'gsd-' }, configDir), ); }); }); // --------------------------------------------------------------------------- // L2: symlink guard BEFORE mkdirSync in installRuntimeArtifacts // --------------------------------------------------------------------------- describe('L2: installRuntimeArtifacts rejects symlink-escaping dest before mkdirSync', () => { let configDir; let outsideDir; beforeEach(() => { configDir = createTempDir('gsd-l2-config-'); outsideDir = createTempDir('gsd-l2-outside-'); // Create configDir/skills as a symlink pointing outside fs.symlinkSync(outsideDir, path.join(configDir, 'skills')); }); afterEach(() => { // Remove symlink before cleanup to avoid crossing dir boundaries try { fs.unlinkSync(path.join(configDir, 'skills')); } catch { /* already gone */ } cleanup(configDir); cleanup(outsideDir); }); test('L2: installRuntimeArtifacts throws before creating dirs when skills/ is a symlink pointing outside', () => { // Use the full profile shape (skills: '*') so staging short-circuits early // and the symlink guard is the first thing that fires. assert.throws( () => installRuntimeArtifacts('opencode', configDir, 'global', { name: 'full', skills: '*', agents: new Set() }), (err) => { assert.ok(err instanceof Error, 'must be an Error'); assert.ok( err.message.toLowerCase().includes('symlink') || err.message.toLowerCase().includes('escap') || err.message.toLowerCase().includes('outside') || err.message.toLowerCase().includes('confinement') || err.message.toLowerCase().includes('install root'), `expected symlink/escape error in: ${err.message}`, ); return true; }, ); // The symlink itself still exists but no new entries were created in outsideDir const outsideEntries = fs.readdirSync(outsideDir); assert.strictEqual(outsideEntries.length, 0, 'must not have created any dirs/files outside configDir'); }); }); // --------------------------------------------------------------------------- // L1: symlink guard in migrateLegacyDevPreferencesToSkill // --------------------------------------------------------------------------- describe('L1: migrateLegacyDevPreferencesToSkill rejects symlink-escaping skillDir', () => { let configDir; let outsideDir; beforeEach(() => { configDir = createTempDir('gsd-l1-config-'); outsideDir = createTempDir('gsd-l1-outside-'); // Create configDir/skills as a symlink pointing outside fs.symlinkSync(outsideDir, path.join(configDir, 'skills')); }); afterEach(() => { try { fs.unlinkSync(path.join(configDir, 'skills')); } catch { /* already gone */ } cleanup(configDir); cleanup(outsideDir); }); test('L1: migrateLegacyDevPreferencesToSkill throws when skills/ is a symlink pointing outside', () => { const saved = new Map([['dev-preferences.md', '# dev prefs\n']]); assert.throws( () => migrateLegacyDevPreferencesToSkill(configDir, saved, 'opencode', 'global'), (err) => { assert.ok(err instanceof Error, 'must be an Error'); assert.ok( err.message.toLowerCase().includes('symlink') || err.message.toLowerCase().includes('escap') || err.message.toLowerCase().includes('outside') || err.message.toLowerCase().includes('install root'), `expected symlink/escape error in: ${err.message}`, ); return true; }, ); // Nothing must have been written outside const outsideFiles = fs.readdirSync(outsideDir); assert.strictEqual(outsideFiles.length, 0, 'must not have written anything outside configDir'); }); }); // --------------------------------------------------------------------------- // L3: relative configDir support // --------------------------------------------------------------------------- describe('L3: assertDestWithinConfigHome handles relative configDir', () => { test('L3: throws when relative configDir + escaping destSubpath resolves outside', () => { // path.resolve handles relative roots; '../../etc' from '.' would escape assert.throws( () => assertDestWithinConfigHome('.', '../../etc'), (err) => { assert.ok(err instanceof Error, 'must be an Error'); assert.ok( err.message.includes('escapes configHome') || err.message.includes('outside'), `expected escape error in: ${err.message}`, ); return true; }, ); }); test('L3: throws when "." destSubpath resolves to the relative configDir itself', () => { // '.' resolves to the same directory as the configDir — must be rejected (F3) assert.throws( () => assertDestWithinConfigHome('.', '.'), /escapes configHome|not configHome itself/, ); }); test('L3: accepts "skills" under relative "./somedir" and returns absolute path', () => { const tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-l3-')); const relDir = path.relative(process.cwd(), tmpBase); try { const result = assertDestWithinConfigHome(relDir, 'skills'); const expectedBase = path.resolve(relDir); assert.ok( result.startsWith(expectedBase + path.sep), `result (${result}) must be under resolved relDir (${expectedBase})`, ); assert.strictEqual(result, path.join(expectedBase, 'skills')); } finally { fs.rmdirSync(tmpBase); } }); }); // --------------------------------------------------------------------------- // N1: sibling-prefix NEGATIVE assertion // --------------------------------------------------------------------------- describe('N1: sibling directory with shared prefix is rejected', () => { test('N1: rejects sibling path sharing a prefix with configDir', () => { // /tmp/gsd-foobar is NOT inside /tmp/gsd-foo — must throw despite the // startsWith prefix overlap at the string level (the sep-check prevents it). assert.throws( () => assertDestWithinConfigHome('/tmp/gsd-foo', '../gsd-foobar'), (err) => { assert.ok(err instanceof Error, 'must be an Error'); assert.ok( err.message.includes('escapes configHome') || err.message.includes('outside'), `expected confinement error in: ${err.message}`, ); return true; }, ); }); test('N1: accepts a true child subpath inside configDir', () => { // 'bar' appended INSIDE /tmp/gsd-foo => the child path — accepted. // Compute expected via path.resolve (the same primitive the helper uses) so // the assertion is platform-portable: on Windows path.resolve prepends the // cwd drive (C:\...) and uses backslashes, which a hardcoded posix literal / // path.join (no drive) would not match (#1679 Windows-CI portability). const root = path.resolve('/tmp/gsd-foo'); const result = assertDestWithinConfigHome('/tmp/gsd-foo', 'bar'); assert.strictEqual(result, path.resolve('/tmp/gsd-foo', 'bar')); assert.ok(result.startsWith(root + path.sep)); }); test('N1: the accepted child does not imply the sibling is accepted', () => { // Double-check: 'bar' inside is fine, but '../gsd-foobar' (the sibling) is not. // 'bar' resolves to /tmp/gsd-foo/bar ✓ assert.doesNotThrow(() => assertDestWithinConfigHome('/tmp/gsd-foo', 'bar')); // '../gsd-foobar' resolves to /tmp/gsd-foobar — NOT inside /tmp/gsd-foo assert.throws( () => assertDestWithinConfigHome('/tmp/gsd-foo', '../gsd-foobar'), /escapes configHome/, ); }); }); // --------------------------------------------------------------------------- // N3: Windows-separator coverage (structural guard using path.win32) // --------------------------------------------------------------------------- describe('N3: Windows-separator confinement logic (path.win32 semantics)', () => { /** * Replicate the assertDestWithinConfigHome predicate using path.win32 * so we can test the sep-guard logic on any platform. * * This mirrors the implementation in runtime-artifact-install-plan.cjs * but forces win32 path semantics. */ function assertDestWithinConfigHomeWin32(configDir, destSubpath) { if (destSubpath.includes('\0')) { throw new Error(`destSubpath "${destSubpath}" contains a NUL byte and is not valid`); } const root = path.win32.resolve(configDir); const resolved = path.win32.resolve(configDir, destSubpath); if (resolved === root || !resolved.startsWith(root + path.win32.sep)) { throw new Error( `destSubpath "${destSubpath}" must be a strict subpath of configHome "${configDir}" — not configHome itself or outside it (escapes configHome)`, ); } return resolved; } const winRoot = 'C:\\Users\\me\\.claude'; test('N3: rejects ..\\..\\Windows (Windows backslash traversal)', () => { assert.throws( () => assertDestWithinConfigHomeWin32(winRoot, '..\\..\\Windows'), /escapes configHome/, ); }); test('N3: rejects mixed ../..\\x traversal', () => { assert.throws( () => assertDestWithinConfigHomeWin32(winRoot, '../..\\x'), /escapes configHome/, ); }); test('N3: rejects "." that resolves to configHome itself', () => { assert.throws( () => assertDestWithinConfigHomeWin32(winRoot, '.'), /escapes configHome/, ); }); test('N3: accepts "skills" under Windows root', () => { const result = assertDestWithinConfigHomeWin32(winRoot, 'skills'); assert.strictEqual(result, path.win32.join(winRoot, 'skills')); assert.ok(result.startsWith(winRoot + path.win32.sep)); }); test('N3: accepts "commands\\gsd" (Windows nested path) under Windows root', () => { const result = assertDestWithinConfigHomeWin32(winRoot, 'commands\\gsd'); assert.strictEqual(result, path.win32.join(winRoot, 'commands', 'gsd')); assert.ok(result.startsWith(winRoot + path.win32.sep)); }); test('N3: rejects sibling C:\\Users\\me\\.claude-extra under win32 semantics', () => { assert.throws( () => assertDestWithinConfigHomeWin32(winRoot, '..\\.claude-extra'), /escapes configHome/, ); }); }); }); } // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-2995-post-install-script-paths.test.cjs — consolidation epic #1969 (B6 #1975) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:bug-2995-post-install-script-paths (consolidation epic #1969 B6 #1975)", () => { 'use strict'; process.env.GSD_TEST_MODE = '1'; const { test, describe, before, after } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const ROOT = path.join(__dirname, '..'); const { auditWorkflowScriptPaths, AUDIT_FINDING } = require( path.join(ROOT, 'scripts', 'audit-workflow-script-paths.cjs'), ); const { cleanup } = require('./helpers.cjs'); // auditWorkflowScriptPaths is a pure function: it walks workflowsDir, // extracts every ${GSD_HOME}/ script reference, and returns a // structured report. Tests assert on the typed report — no regex on // console output. // #2996 CR: per-fixture repos are rooted under a single tmpRoot so the // after()-hook actually cleans them up. The previous shape created tmpRoot // in before() but never used it, leaking each fixture's mkdtempSync dir. let tmpRoot; function fixtureRepo({ workflows, files }) { // workflows: { 'foo.md': '...content with ${GSD_HOME}/...' } // files: [ 'gsd-core/bin/x.cjs', ... ] — files to create in repo const repoRoot = fs.mkdtempSync(path.join(tmpRoot, 'repo-')); const workflowsDir = path.join(repoRoot, 'gsd-core', 'workflows'); fs.mkdirSync(workflowsDir, { recursive: true }); for (const [name, body] of Object.entries(workflows || {})) { fs.writeFileSync(path.join(workflowsDir, name), body); } for (const rel of files || []) { const full = path.join(repoRoot, rel); fs.mkdirSync(path.dirname(full), { recursive: true }); fs.writeFileSync(full, ''); } return { repoRoot, workflowsDir }; } before(() => { tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2995-')); }); after(() => { cleanup(tmpRoot); }); describe('Bug #2995: post-install script-paths audit (#2995)', () => { test('AUDIT_FINDING enum exposes the documented codes', () => { assert.deepEqual( Object.keys(AUDIT_FINDING).sort(), ['MISSING_FROM_REPO', 'NOT_INSTALLED'].sort(), ); }); test('returns { ok: true, findings: [] } when workflow refs an existing, installed-path script', () => { const { repoRoot, workflowsDir } = fixtureRepo({ workflows: { 'good.md': 'node "${GSD_HOME}/gsd-core/bin/foo.cjs" --json\n', }, files: ['gsd-core/bin/foo.cjs'], }); const r = auditWorkflowScriptPaths({ workflowsDir, repoRoot, installedPrefixes: ['gsd-core', 'commands', 'agents', 'hooks'], }); assert.deepEqual(r, { ok: true, findings: [] }); }); }); describe('Bug #2995: detection paths', () => { const { auditWorkflowScriptPaths, AUDIT_FINDING } = require(require('node:path').join(__dirname, '..', 'scripts', 'audit-workflow-script-paths.cjs')); test('reports MISSING_FROM_REPO when the referenced file does not exist in the repo', () => { const { repoRoot, workflowsDir } = fixtureRepo({ workflows: { 'foo.md': 'node "${GSD_HOME}/gsd-core/bin/typo.cjs" --json\n', }, files: [], }); const r = auditWorkflowScriptPaths({ workflowsDir, repoRoot, installedPrefixes: ['gsd-core'], }); assert.equal(r.ok, false); assert.equal(r.findings.length, 1); assert.deepEqual(r.findings[0], { workflow: 'foo.md', path: 'gsd-core/bin/typo.cjs', kind: AUDIT_FINDING.MISSING_FROM_REPO, }); }); test('reports NOT_INSTALLED when first path segment is outside installedPrefixes (the #2994 case)', () => { const { repoRoot, workflowsDir } = fixtureRepo({ workflows: { 'foo.md': 'node "${GSD_HOME}/scripts/verify-reapply-patches.cjs"\n', }, files: ['scripts/verify-reapply-patches.cjs'], // file exists, but `scripts/` not in installed prefixes }); const r = auditWorkflowScriptPaths({ workflowsDir, repoRoot, installedPrefixes: ['gsd-core', 'commands', 'agents', 'hooks'], }); assert.equal(r.ok, false); assert.equal(r.findings.length, 1); assert.deepEqual(r.findings[0], { workflow: 'foo.md', path: 'scripts/verify-reapply-patches.cjs', kind: AUDIT_FINDING.NOT_INSTALLED, }); }); test('handles ${GSD_HOME:-$HOME/.claude}/... default-fallback syntax', () => { const { repoRoot, workflowsDir } = fixtureRepo({ workflows: { 'a.md': 'node "${GSD_HOME:-$HOME/.claude}/gsd-core/bin/x.cjs"\n', }, files: ['gsd-core/bin/x.cjs'], }); const r = auditWorkflowScriptPaths({ workflowsDir, repoRoot, installedPrefixes: ['gsd-core'], }); assert.deepEqual(r, { ok: true, findings: [] }); }); test('reports both findings when one workflow has multiple problems', () => { const { repoRoot, workflowsDir } = fixtureRepo({ workflows: { 'multi.md': [ 'node "${GSD_HOME}/scripts/a.cjs"', 'node "${GSD_HOME}/gsd-core/bin/b.cjs"', 'node "${GSD_HOME}/gsd-core/bin/missing.cjs"', ].join('\n') + '\n', }, files: ['scripts/a.cjs', 'gsd-core/bin/b.cjs'], }); const r = auditWorkflowScriptPaths({ workflowsDir, repoRoot, installedPrefixes: ['gsd-core'], }); assert.equal(r.ok, false); assert.equal(r.findings.length, 2); const kinds = r.findings.map((f) => f.kind).sort(); assert.deepEqual(kinds, [AUDIT_FINDING.MISSING_FROM_REPO, AUDIT_FINDING.NOT_INSTALLED]); }); test('extracts no findings from a workflow without GSD_HOME script refs', () => { const { repoRoot, workflowsDir } = fixtureRepo({ workflows: { 'plain.md': '# A workflow\n\nSome prose, no script refs.\n', }, }); const r = auditWorkflowScriptPaths({ workflowsDir, repoRoot, installedPrefixes: ['gsd-core'], }); assert.deepEqual(r, { ok: true, findings: [] }); }); }); describe('Bug #2995: real workflow audit', () => { const { auditWorkflowScriptPaths, AUDIT_FINDING } = require(require('node:path').join(__dirname, '..', 'scripts', 'audit-workflow-script-paths.cjs')); // The set of top-level directories the installer (bin/install.js) actually // copies into ${configDir}/. Touching this set requires updating both // bin/install.js AND this constant — the parity is intentional. const INSTALLED_PREFIXES = [ 'gsd-core', // workflows, references, bin/lib, templates 'commands', // commands/gsd/*.md (Claude Code local + Gemini global) 'skills', // skills/gsd-*/SKILL.md (Claude Code 2.1.88+ global, Codex, etc.) 'agents', // agents/gsd-*.md 'hooks', // hooks/gsd-*.{sh,js} ]; // Known existing gaps tracked in their own issues. Removing an entry should // land in the same PR that fixes the underlying issue; CI surfaces any NEW // gap as a hard failure. // (#2994 entry removed: this PR moves verify-reapply-patches.cjs to // gsd-core/bin/ which IS an installed prefix, closing the gap.) const KNOWN_GAPS = new Set(); test('no NEW workflow refs fail to resolve at the deployed path (KNOWN_GAPS allow-listed)', () => { const r = auditWorkflowScriptPaths({ workflowsDir: require('node:path').join(ROOT, 'gsd-core', 'workflows'), repoRoot: ROOT, installedPrefixes: INSTALLED_PREFIXES, }); const newGaps = r.findings.filter( (f) => !KNOWN_GAPS.has(`${f.workflow}|${f.path}|${f.kind}`), ); if (newGaps.length > 0) { const summary = newGaps.map( (f) => ` ${f.workflow}: ${f.path} (${f.kind})`, ).join('\n'); assert.fail( `New workflow ref does not resolve at the deployed path:\n${summary}\n\n` + `Either move the script under one of [${INSTALLED_PREFIXES.join(', ')}], ` + `update bin/install.js to copy the new top-level directory, or ` + `(if intentionally tracked) add an entry to KNOWN_GAPS with the issue reference.`, ); } }); // #2996 CR: a reference that is both outside an installed prefix AND // missing from the repo must emit BOTH findings in one run. Previously // the code short-circuited on NOT_INSTALLED, hiding MISSING_FROM_REPO // until the developer fixed the prefix and re-ran CI. test('a reference that is both not-installed AND missing-from-repo emits both findings (no short-circuit)', () => { const { repoRoot, workflowsDir } = fixtureRepo({ workflows: { 'foo.md': '```bash\nnode "${GSD_HOME}/scripts/missing.cjs"\n```\n', }, // Note: scripts/missing.cjs intentionally NOT created in the repo. }); const r = auditWorkflowScriptPaths({ workflowsDir, repoRoot, installedPrefixes: ['gsd-core', 'agents', 'hooks', 'commands'], }); assert.equal(r.ok, false); const kinds = r.findings.filter((f) => f.path === 'scripts/missing.cjs').map((f) => f.kind).sort(); assert.deepEqual( kinds, [AUDIT_FINDING.MISSING_FROM_REPO, AUDIT_FINDING.NOT_INSTALLED].sort(), 'expected both NOT_INSTALLED and MISSING_FROM_REPO findings for the same ref', ); }); test('KNOWN_GAPS entries still match real findings — fixed gaps must be removed from the allow-list', () => { const r = auditWorkflowScriptPaths({ workflowsDir: require('node:path').join(ROOT, 'gsd-core', 'workflows'), repoRoot: ROOT, installedPrefixes: INSTALLED_PREFIXES, }); const realKeys = new Set(r.findings.map((f) => `${f.workflow}|${f.path}|${f.kind}`)); const stale = [...KNOWN_GAPS].filter((k) => !realKeys.has(k)); assert.deepEqual( stale, [], `KNOWN_GAPS contains entries not present in audit findings — remove these: ${stale.join(', ')}`, ); }); }); }); } // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-3442-shim-projection-drift-guard.test.cjs — consolidation epic #1969 (B6 #1975) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:bug-3442-shim-projection-drift-guard (consolidation epic #1969 B6 #1975)", () => { 'use strict'; process.env.GSD_TEST_MODE = '1'; const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const { runNode } = require('./helpers/process-seam.cjs'); const { cleanup } = require('./helpers.cjs'); const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); const ROOT = path.resolve(__dirname, '..'); const DRIFT_LINT = path.join(ROOT, 'scripts', 'lint-shell-command-projection-drift.cjs'); function runLint(targetFile) { const result = runNode([DRIFT_LINT, targetFile], { cwd: ROOT, timeoutMs: PROBE_TIMEOUT_MS, }); result.status = result.exitCode; return result; } // (The buildWindowsShimTriple parity test was removed with the gsd-sdk shim, // #191. The serialized-command drift guard below is retained and unaffected.) describe('bug #3442: shim/wrapper serialized-command drift guard', () => { test('drift guard passes for current install.js', () => { const result = runLint(path.join(ROOT, 'bin', 'install.js')); assert.equal(result.status, 0, `expected lint pass, got:\n${result.stderr || result.stdout}`); }); test('drift guard fails when install-owned inline shim text builder is present', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3442-')); try { const fixture = path.join(tmp, 'install-inline-builder.js'); fs.writeFileSync( fixture, [ 'function badBuilder() {', " return '@ECHO OFF\\r\\n@SETLOCAL\\r\\n@node \"C:/shim.js\" %*\\r\\n';", '}', '', ].join('\n'), ); const result = runLint(fixture); assert.notEqual(result.status, 0, 'inline shim renderer should be rejected by the drift guard'); } finally { cleanup(tmp); } }); test('drift guard does not block safe subprocess execution patterns', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3442-')); try { const fixture = path.join(tmp, 'install-subprocess-safe.js'); fs.writeFileSync( fixture, [ "const cp = require('node:child_process');", "cp.spawnSync('cmd.exe', ['/c', 'echo ok']);", "cp.execFileSync('bash', ['-lc', 'printf %s \"$PATH\"']);", '', ].join('\n'), ); const result = runLint(fixture); assert.equal(result.status, 0, `spawnSync/execFileSync should remain allowed:\n${result.stderr || result.stdout}`); } finally { cleanup(tmp); } }); }); }); } // ─── #2393: GSD_ALLOW_SYMLINKED_DEST opt-in for intentional symlinked-dest layouts ──── // // Three reporter layouts, all refused by the pre-#2393 guard with no opt-out: // (lars-hh) CLAUDE_CONFIG_DIR=~/.claude-personal with skills/hooks symlinked to // a user-owned external dir // (Mamiki) ~/.claude/skills is a Windows Junction to D:\claude-shared-resources\skills // (Azd325) ~/.claude itself is a symlink to a dotfiles repo (root-is-symlink) // // Fix: GSD_ALLOW_SYMLINKED_DEST=1 follows symlinks instead of refusing them, // while preserving the load-bearing refusals from #1704 / ADR-1239 Phase B: // (a) path-traversal in the destSubpath string itself ('../../etc') // (b) a resolved symlink target equal to the install root (would let _removeGsdEntries // wipe the root — the config-root-wipe threat) describe('#2393: GSD_ALLOW_SYMLINKED_DEST opt-in for intentional symlinked-dest layouts', () => { const { hasExistingSymlinkBetween } = require('../gsd-core/bin/lib/install-engine.cjs'); beforeEach(() => { delete process.env.GSD_ALLOW_SYMLINKED_DEST; }); afterEach(() => { delete process.env.GSD_ALLOW_SYMLINKED_DEST; }); // Reporter case (lars-hh / Mamiki): a child component of configHome is a symlink // to a user-owned dir outside configHome. Default refuses; opt-in follows. test('child-symlink layout: default refuses, GSD_ALLOW_SYMLINKED_DEST=1 allows', (t) => { const configHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2393-cfg-')); const outsideTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2393-out-')); try { const linkPath = path.join(configHome, 'skills'); try { fs.symlinkSync(outsideTarget, linkPath); } catch (_e) { t.skip('symlink creation unsupported on this platform/privilege'); return; } const destDir = path.join(linkPath, 'gsd-foo'); // Default: refuse (existing pre-#2393 behavior unchanged). assert.strictEqual( hasExistingSymlinkBetween(configHome, destDir), true, 'default must refuse symlinked destDir (pre-#2393 behavior)', ); // Opt-in: allow (user asserted they trust the target). assert.strictEqual( hasExistingSymlinkBetween(configHome, destDir, { allowOptInFollow: true }), false, 'GSD_ALLOW_SYMLINKED_DEST=1 must allow intentional user-owned child symlink', ); } finally { try { fs.unlinkSync(path.join(configHome, 'skills')); } catch { /* already gone */ } cleanup(configHome); cleanup(outsideTarget); } }); // Reporter case (Azd325): the install root ITSELF is a symlink. The pre-#2393 // guard had an early-return for this before the component loop even ran. test('root-is-symlink layout (Azd325/nix-darwin): default refuses, opt-in allows', (t) => { const dotfilesTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2393-dot-')); const rootLink = path.join(os.tmpdir(), 'gsd-2393-rootlink-' + Date.now()); try { try { fs.symlinkSync(dotfilesTarget, rootLink); } catch (_e) { t.skip('symlink creation unsupported on this platform/privilege'); return; } // Inside the dotfiles target, skills is a real dir (not a symlink). fs.mkdirSync(path.join(dotfilesTarget, 'skills'), { recursive: true }); const destDir = path.join(rootLink, 'skills', 'gsd-foo'); // Default: refuse (root itself is a symlink → early-return true). assert.strictEqual( hasExistingSymlinkBetween(rootLink, destDir), true, 'default must refuse when install root itself is a symlink', ); // Opt-in: follow the root symlink, walk to the real skills dir — allow. assert.strictEqual( hasExistingSymlinkBetween(rootLink, destDir, { allowOptInFollow: true }), false, 'GSD_ALLOW_SYMLINKED_DEST=1 must follow a root symlink whose target has no further symlinks', ); } finally { try { fs.unlinkSync(rootLink); } catch { /* already gone */ } cleanup(dotfilesTarget); } }); // Load-bearing refusal (a): path-traversal in the destSubpath string itself. // MUST refuse regardless of opt-in — this is the #1704 threat (a). test('path-traversal destSubpath ("../../etc") refused EVEN WITH opt-in', () => { const configHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2393-trav-')); try { const escapePath = path.join(configHome, '..', '..', 'etc-passwd-' + Date.now()); assert.strictEqual( hasExistingSymlinkBetween(configHome, escapePath, { allowOptInFollow: true }), true, 'path-traversal destSubpath must ALWAYS refuse regardless of opt-in (#1704 threat a)', ); } finally { cleanup(configHome); } }); // Load-bearing refusal (b): a symlink whose resolved target equals the install root // itself would let _removeGsdEntries wipe the root. MUST refuse regardless of opt-in. test('resolved-target-equals-install-root refused EVEN WITH opt-in (wipe protection)', (t) => { const configHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2393-wipe-')); try { // Symlink configHome/loop -> configHome (circular). Resolved target == install root. const loopLink = path.join(configHome, 'loop'); try { fs.symlinkSync(configHome, loopLink); } catch (_e) { t.skip('symlink creation unsupported on this platform/privilege'); return; } const destDir = path.join(loopLink, 'gsd-foo'); // Default refuses. assert.strictEqual( hasExistingSymlinkBetween(configHome, destDir), true, 'default must refuse symlink to install root (wipe protection)', ); // Opt-in STILL refuses — this is threat (b), load-bearing even with opt-in. assert.strictEqual( hasExistingSymlinkBetween(configHome, destDir, { allowOptInFollow: true }), true, 'opt-in must NOT allow a symlink resolving to install root itself (#1704 threat b — wipe)', ); } finally { try { fs.unlinkSync(path.join(configHome, 'loop')); } catch { /* already gone */ } cleanup(configHome); } }); // #2393 security-review finding: realpathSync fully resolves symlinks while // path.resolve is lexical. On macOS, /var is a symlink to /private/var, so // `resolvedRoot` carries `/var/...` while the symlink's realtarget carries // `/private/var/...` — a naive `realtarget === resolvedRoot` check would miss // the equality and let threat (b) through. Fix compares against BOTH the // lexical and real forms of root. Test constructs the macOS-style divergence // explicitly: spell configHome one way, point the symlink at its real path. test('resolved-target-equals-install-root via /var ↔ /private/var normalization (macOS-style)', (t) => { if (process.platform !== 'darwin') { t.skip('test exercises the macOS /var → /private/var symlink — darwin only'); return; } const configHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2393-realpath-')); try { // `os.tmpdir()` is spelled with `/var/...` on macOS; realpathSync resolves it // to `/private/var/...`. The lexical resolvedRoot and the real realRoot differ. const realConfigHome = fs.realpathSync(configHome); if (realConfigHome === configHome) { // Defensive — if for some reason there's no /var symlink in the chain, the // test isn't exercising what it claims. Skip rather than pass vacuously. t.skip('os.tmpdir() path contains no symlink component — test does not exercise the /var normalization'); return; } // Symlink spelled via the REAL path — its realtarget will equal realConfigHome, // NOT lexical configHome. The bug shape: realtarget !== resolvedRoot (lexical). const loopLink = path.join(configHome, 'loop'); try { fs.symlinkSync(realConfigHome, loopLink); } catch (_e) { t.skip('symlink creation unsupported on this platform/privilege'); return; } const destDir = path.join(loopLink, 'gsd-foo'); // The fix compares against BOTH lexical and real forms — guard fires. assert.strictEqual( hasExistingSymlinkBetween(configHome, destDir, { allowOptInFollow: true }), true, 'opt-in must refuse a symlink resolving to install root by real path even when ' + 'lexical and real forms differ (macOS /var ↔ /private/var normalization)', ); } finally { try { fs.unlinkSync(path.join(configHome, 'loop')); } catch { /* already gone */ } cleanup(configHome); } }); // #2875 defect fix — REVERSES the previously-pinned "silently passed" contract // this test used to name. The old reasoning ("existsSync(cursor) follows the // link -> false -> loop terminates before the symlink check ever fires; the // caller's subsequent mkdir/write is responsible for whatever happens next") // no longer holds: an adversarial reviewer showed the "caller's responsibility" // it rested on is unenforceable in practice — user-artifact-staging.cts's // recovery path reads a staged file NAME out of an attacker-influenceable // on-disk record (`record.json`) and writes through it without a human in the // loop to notice a bad write; a dangling symlink planted at that destination // (e.g. `/USER-PROFILE.md -> /authorized_keys`) let the // actual copy/write follow it and land attacker-chosen content OUTSIDE the // install root, reproduced end-to-end. hasExistingSymlinkBetween now probes // each path segment with `lstatSync` FIRST (see install-engine.cts's own doc // comment on this change), which — unlike `existsSync` — never follows a // symlink and succeeds for a dangling one, so a dangling symlink is now // correctly seen AS a symlink component instead of "nothing here". The guard's // promise is now: a dangling symlink anywhere on the path between root and // destDir is refused exactly like a live one — default refuses outright, // opt-in attempts to follow it (`realpathSync`) and, finding no target, // refuses too (fail-closed on a broken symlink, same posture used elsewhere in // this function for a `realpathSync` failure). test('broken symlink: now refused by both the default and opt-in paths (#2875 fix)', (t) => { const configHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2393-broken-')); try { const danglingLink = path.join(configHome, 'skills'); const notPresentTarget = path.join(os.tmpdir(), 'gsd-2393-not-present-' + Date.now()); try { fs.symlinkSync(notPresentTarget, danglingLink); } catch (_e) { t.skip('symlink creation unsupported on this platform/privilege'); return; } const destDir = path.join(danglingLink, 'gsd-foo'); // lstatSync(danglingLink) succeeds (it IS a symlink, just a dangling one) — // the segment loop now sees it as a symlink component and refuses. assert.strictEqual( hasExistingSymlinkBetween(configHome, destDir), true, 'broken symlink: default path now refuses — lstatSync sees the dangling symlink as a symlink component', ); // Opt-in attempts to follow it via realpathSync, which throws ENOENT for a // missing target — refused (fail-closed), not silently passed through. assert.strictEqual( hasExistingSymlinkBetween(configHome, destDir, { allowOptInFollow: true }), true, 'broken symlink with opt-in: realpathSync on a dangling target fails, so this refuses too (fail-closed)', ); } finally { try { fs.unlinkSync(path.join(configHome, 'skills')); } catch { /* already gone */ } cleanup(configHome); } }); // Reviewer-driven (Medium): transitive symlink chains. The opt-in is transitive // and unbounded by design — once a symlink is followed, the walk continues from // the resolved real path WITHOUT re-checking that further segments stay inside // a confining boundary. Test pins the documented behavior so a future change is // deliberate. (Default behavior refuses at the first symlink.) test('transitive symlink chain: opt-in follows transitively; default refuses at first hop', (t) => { const configHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2393-trans-')); const outside1 = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2393-t1-')); const outside2 = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2393-t2-')); try { // configHome/outer -> outside1, outside1/inner -> outside2 (two-hop chain). try { fs.symlinkSync(outside1, path.join(configHome, 'outer')); fs.symlinkSync(outside2, path.join(outside1, 'inner')); } catch (_e) { t.skip('symlink creation unsupported on this platform/privilege'); return; } const destDir = path.join(configHome, 'outer', 'inner', 'gsd-foo'); // Default: refuses at the first hop (configHome/outer is a symlink). assert.strictEqual( hasExistingSymlinkBetween(configHome, destDir), true, 'default must refuse at the first symlink (configHome/outer)', ); // Opt-in: follows transitively through both hops to outside2 (no threat-(b) // match — outside2 is neither lexical nor real form of configHome). assert.strictEqual( hasExistingSymlinkBetween(configHome, destDir, { allowOptInFollow: true }), false, 'opt-in must follow transitive chain (outer → outside1 → outside2/inner) — documented transitivity', ); } finally { try { fs.unlinkSync(path.join(configHome, 'outer')); } catch { /* already gone */ } try { fs.unlinkSync(path.join(outside1, 'inner')); } catch { /* already gone */ } cleanup(configHome); cleanup(outside1); cleanup(outside2); } }); // Reviewer-driven (Medium): isSymlinkedDestOptIn env-var parsing is itself // behavioral — a typo in the env-var name or an accepted-values change would // silently disable the opt-in. Pin the contract directly via the exported helper. test('isSymlinkedDestOptIn: accepts only documented values (1, true)', () => { const installEngine = require('../gsd-core/bin/lib/install-engine.cjs'); if (typeof installEngine.isSymlinkedDestOptIn !== 'function') { // Skipping — helper not exported in this build (assertion-only test). return; } const cases = [ { v: '1', expected: true }, { v: 'true', expected: true }, { v: 'TRUE', expected: false }, // only lowercase 'true' documented { v: 'True', expected: false }, { v: 'yes', expected: false }, { v: 'on', expected: false }, { v: '0', expected: false }, { v: 'false', expected: false }, { v: '', expected: false }, { v: undefined, expected: false }, // unset ]; for (const { v, expected } of cases) { if (v === undefined) delete process.env.GSD_ALLOW_SYMLINKED_DEST; else process.env.GSD_ALLOW_SYMLINKED_DEST = v; assert.strictEqual( installEngine.isSymlinkedDestOptIn(), expected, `GSD_ALLOW_SYMLINKED_DEST=${JSON.stringify(v)} should yield isSymlinkedDestOptIn()=${expected}`, ); } }); }); // --------------------------------------------------------------------------- // _installNativePluginIfDeclared write-confinement (#2470) // --------------------------------------------------------------------------- // // The native-plugin copy previously confined only `nativePlugin.dir`, then // joined `nativePlugin.file` onto the validated directory unchecked. #2470 // makes `file` a field we actively change (pi: gsd.cjs -> gsd.js), so the full // dest path is now confined. Descriptors are first-party and compiled into the // capability registry at build time, so this was never reachable in a shipped // build — these tests keep it that way. describe('_installNativePluginIfDeclared write-confinement', () => { const engine = require('../gsd-core/bin/lib/install-engine.cjs'); /** Build a src tree containing the declared plugin source. */ function stageSource() { const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-np-src-')); const full = path.join(src, 'pi', 'gsd.cjs'); fs.mkdirSync(path.dirname(full), { recursive: true }); fs.writeFileSync(full, "'use strict';\n// plugin\n", 'utf8'); return src; } const behaviorsWith = (file) => ({ nativePlugin: { dir: 'extensions', file, source: 'pi/gsd.cjs' }, }); test('happy path: declared dir/file lands inside configDir', () => { const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-np-cfg-')); const src = stageSource(); try { engine._installNativePluginIfDeclared('pi', configDir, behaviorsWith('gsd.js'), src); assert.ok( fs.existsSync(path.join(configDir, 'extensions', 'gsd.js')), 'plugin should be copied to /extensions/gsd.js', ); } finally { cleanup(configDir); cleanup(src); } }); const ESCAPE_CASES = [ ['traversal', '../../evil.js'], ['deep traversal', '../../../../../../tmp/evil.js'], ['NUL byte', 'gsd\u0000.js'], ]; for (const [label, file] of ESCAPE_CASES) { test(`escape rejected: nativePlugin.file with ${label} → throws`, () => { const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-np-esc-')); const src = stageSource(); try { assert.throws( () => engine._installNativePluginIfDeclared('pi', configDir, behaviorsWith(file), src), /escap|strict subpath|configHome|NUL byte/i, `nativePlugin.file=${JSON.stringify(file)} must be rejected, not joined onto the validated dir`, ); } finally { cleanup(configDir); cleanup(src); } }); } test('nothing is written outside configDir when file tries to escape', () => { const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-np-out-')); const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-np-outside-')); const src = stageSource(); try { const escapeFile = path.join('..', '..', path.basename(outside), 'pwned.js'); assert.throws( () => engine._installNativePluginIfDeclared('pi', configDir, behaviorsWith(escapeFile), src), /escap|strict subpath|configHome/i, ); assert.ok( !fs.existsSync(path.join(outside, 'pwned.js')), 'nothing may be written outside configDir', ); } finally { cleanup(configDir); cleanup(outside); cleanup(src); } }); test('missing source is a silent no-op (unchanged behavior)', () => { const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-np-nosrc-')); const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-np-emptysrc-')); try { assert.doesNotThrow(() => engine._installNativePluginIfDeclared('pi', configDir, behaviorsWith('gsd.js'), src), ); assert.ok(!fs.existsSync(path.join(configDir, 'extensions', 'gsd.js'))); } finally { cleanup(configDir); cleanup(src); } }); }); // --------------------------------------------------------------------------- // #2875 (epic #2866 Phase 6): User Artifact Staging confinement — E1-E5 // (.gsd/phase/feat-2875-materialization-primitives/50-test-matrix.md // "E. Confinement"). Every row reuses assertDestWithinConfigHome / // hasExistingSymlinkBetween rather than a bespoke check — see // _resolveUserArtifactStagingRoot's own doc comment (install-engine.cts) and // user-artifact-staging.cts's module doc "Confinement". // --------------------------------------------------------------------------- describe('#2875: user-artifact-staging confinement (E1-E5)', () => { test('E1: the staging root resolves through assertDestWithinConfigHome — an escaping subpath is refused by the SAME guard _resolveUserArtifactStagingRoot uses', () => { const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e1-')); try { // The real staging subpath ('.gsd-staging/user-artifacts') is a fixed // literal that can never escape — so this asserts the PROPERTY the // call site depends on directly against the same primitive, rather // than trying to force an unreachable escape through the real API. assert.throws( () => _uasAssertDestWithinConfigHome(configDir, path.join('..', '..', 'etc', 'staging')), /escap|strict subpath|configHome/i, ); // The real call resolves cleanly and stays confined. const stagingRoot = _resolveUserArtifactStagingRoot(configDir); assert.ok(path.resolve(stagingRoot).startsWith(path.resolve(configDir) + path.sep)); } finally { cleanup(configDir); } }); test('E2: recovery refuses a record naming a destDir outside confinement — never writes outside it', () => { const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e2-')); const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e2-outside-')); try { const stagingRoot = path.join(configDir, '.gsd-staging', 'user-artifacts'); const entryDir = path.join(stagingRoot, 'attackerentry0000'); fs.mkdirSync(path.join(entryDir, 'files'), { recursive: true }); fs.writeFileSync(path.join(entryDir, 'files', 'pwned.md'), 'attacker-controlled content'); // The record is attacker-influenced data — it names a destDir OUTSIDE // configDir entirely. fs.writeFileSync( path.join(entryDir, 'record.json'), JSON.stringify({ destDir: outside, names: ['pwned.md'], timestamp: new Date().toISOString() }), ); const result = recoverOrphanedUserArtifacts(stagingRoot, configDir); assert.equal(result.recovered.length, 0); assert.equal(result.skipped.length, 1); assert.equal(result.skipped[0].reason, 'destDir-outside-confinement'); assert.ok(!fs.existsSync(path.join(outside, 'pwned.md')), 'must never write outside confinement'); } finally { cleanup(configDir); cleanup(outside); } }); test('E3: ..-traversal in a staged file name is rejected', () => { const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e3-')); try { const stagingRoot = path.join(configDir, '.gsd-staging', 'user-artifacts'); const destDir = path.join(configDir, 'gsd-core'); fs.mkdirSync(destDir, { recursive: true }); const entryDir = path.join(stagingRoot, 'traversalentry000'); fs.mkdirSync(path.join(entryDir, 'files'), { recursive: true }); fs.writeFileSync( path.join(entryDir, 'record.json'), JSON.stringify({ destDir: path.resolve(destDir), names: ['../../../etc/passwd'], timestamp: new Date().toISOString() }), ); const result = recoverOrphanedUserArtifacts(stagingRoot, configDir); assert.equal(result.recovered.length, 0, 'traversal name must never be restored'); // Neither the previous assertion (checked a path one level further up // than production ever resolves, so it could never fail) nor a naive // `resolve(destDir, name)` check (which, on a Linux runner, resolves to // the REAL /etc/passwd — a file that pre-exists on disk regardless of // whether this guard works, so `!existsSync(...)` fails unconditionally // and proves nothing) exercises what the guard actually does. Trace the // real call order in recoverOrphanedUserArtifacts: `srcPath = // assertDestWithinConfigHome(filesDir, name)` is computed and throws // FIRST — filesDir (`//files`) is nested several // levels below configDir, and `../../../etc/passwd` resolved against it // still escapes filesDir's own root, so this throw fires before // `destPath` (against confinedDestDir) is ever computed and before any // read/write is attempted. The observable, platform-independent proof // that the traversal was rejected — not merely that some unrelated // system file didn't get overwritten — is that destDir's own directory // listing stays exactly as this test left it: no file materialized // there via the traversal name. assert.deepStrictEqual( fs.readdirSync(destDir), [], 'a rejected traversal name must never result in any file being written into destDir', ); } finally { cleanup(configDir); } }); test('E4: a symlinked staging root is refused — same refusal as the existing dest guard', () => { const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e4-')); const elsewhere = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e4-elsewhere-')); try { // Pre-create .gsd-staging as a symlink pointing outside configDir — // exactly the threat hasExistingSymlinkBetween's root-symlink refusal // (install-engine.cts) already covers for every other write on this // call tree. fs.symlinkSync(elsewhere, path.join(configDir, '.gsd-staging')); assert.throws( () => _resolveUserArtifactStagingRoot(configDir), /symlink/i, ); } finally { cleanup(configDir); cleanup(elsewhere); } }); test('E5: a NUL byte in a staged file name is rejected', () => { const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e5-')); try { const stagingRoot = path.join(configDir, '.gsd-staging', 'user-artifacts'); const destDir = path.join(configDir, 'gsd-core'); fs.mkdirSync(destDir, { recursive: true }); const entryDir = path.join(stagingRoot, 'nulbyteentry00000'); fs.mkdirSync(path.join(entryDir, 'files'), { recursive: true }); fs.writeFileSync( path.join(entryDir, 'record.json'), JSON.stringify({ destDir: path.resolve(destDir), names: ['USER-PROFILE\u0000.md'], timestamp: new Date().toISOString() }), ); let result; assert.doesNotThrow(() => { result = recoverOrphanedUserArtifacts(stagingRoot, configDir); }); assert.equal(result.recovered.length, 0, 'NUL-byte name must never be restored'); } finally { cleanup(configDir); } }); // #2875 defect fix: C2's "never overwrite" guard was `existsSync`-based, // which FOLLOWS symlinks and reports `false` for a DANGLING one — invisible // to the guard, so it never refused. A dangling symlink AT the recovered // destination let `copyFileSync`/`symlinkSync` (which DO follow it) write // outside `configDir`. test('E6: a dangling symlink at the recovered destination is treated as already-present, never written through', () => { const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e6-cfg-')); const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e6-outside-')); try { const destDir = path.join(configDir, 'gsd-core'); fs.mkdirSync(destDir, { recursive: true }); const stagingRoot = path.join(configDir, '.gsd-staging', 'user-artifacts'); fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'orphaned-content'); // user-artifact-staging.cts's owner-liveness guard (recoverOrphanedUserArtifacts) // treats a record whose `runId` belongs to a currently-live process as // "not an orphan yet" and skips the ENTIRE entry with reason // 'owner-still-live' — before ever reaching the per-name // dest-already-present check this test pins. `stageUserArtifacts` // defaults `runId` to `String(process.pid)`, i.e. THIS test process, // which is trivially alive for the whole duration of this in-process // test. A real crashed run has a genuinely DEAD pid, so simulating one // here requires an explicit dead `runId` — same convention // tests/user-artifact-staging.test.cjs's C15 already establishes. const deadPid = '999999'; stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRoot, { runId: deadPid }); cleanup(path.join(destDir, 'USER-PROFILE.md')); const outsideTarget = path.join(outside, 'authorized_keys'); fs.symlinkSync(outsideTarget, path.join(destDir, 'USER-PROFILE.md')); assert.ok(!fs.existsSync(outsideTarget), 'the symlink target must not exist — this is the DANGLING case'); const result = recoverOrphanedUserArtifacts(stagingRoot, configDir); assert.equal(result.recovered.length, 0, 'must refuse — the dangling symlink counts as already-present'); assert.equal(result.skipped.length, 1); assert.equal(result.skipped[0].reason, 'dest-already-present'); assert.ok(!fs.existsSync(outsideTarget), 'must never write through the dangling symlink to outsideTarget'); assert.ok(fs.lstatSync(path.join(destDir, 'USER-PROFILE.md')).isSymbolicLink(), 'the dangling symlink itself is left untouched'); } finally { cleanup(configDir); cleanup(outside); } }); // #2875 defect fix: restoreStagedUserArtifacts had no guard at all against // a dangling symlink at the destination — the identical hole as E6. test('E7: restoreStagedUserArtifacts refuses a dangling symlink at the destination', () => { const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e7-cfg-')); const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e7-outside-')); try { const destDir = path.join(configDir, 'gsd-core'); fs.mkdirSync(destDir, { recursive: true }); const stagingRoot = path.join(configDir, '.gsd-staging', 'user-artifacts'); fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'current-content'); const staged = stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRoot); cleanup(path.join(destDir, 'USER-PROFILE.md')); const outsideTarget = path.join(outside, 'authorized_keys'); fs.symlinkSync(outsideTarget, path.join(destDir, 'USER-PROFILE.md')); restoreStagedUserArtifacts(destDir, staged); assert.ok(!fs.existsSync(outsideTarget), 'must never write through the dangling symlink to outsideTarget'); assert.ok( fs.lstatSync(path.join(destDir, 'USER-PROFILE.md')).isSymbolicLink(), 'the dangling symlink itself is left untouched, not overwritten', ); } finally { cleanup(configDir); cleanup(outside); } }); // #2875 defect fix: assertDestWithinConfigHome is pure lexical path math // and cannot see a symlinked ANCESTOR directory between configDir and a // recorded destDir — only hasExistingSymlinkBetween's component-by- // component walk can. Recovery previously never applied it to the // recorded destDir at all — this is E2 STRENGTHENED: E2 above only covers // a destDir that is lexically outside configDir entirely, which // assertDestWithinConfigHome alone already refused; this row covers the // case that actually failed before this fix. test('E8: recovery refuses a destDir reached only through a symlinked ANCESTOR directory', () => { const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e8-cfg-')); const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e8-outside-')); try { fs.symlinkSync(outside, path.join(configDir, 'linkdir')); const stagingRoot = path.join(configDir, '.gsd-staging', 'user-artifacts'); const entryDir = path.join(stagingRoot, 'e8entry00000000'); fs.mkdirSync(path.join(entryDir, 'files'), { recursive: true }); fs.writeFileSync(path.join(entryDir, 'files', 'USER-PROFILE.md'), 'attacker-controlled content'); // The record names a destDir that is LEXICALLY inside configDir // (assertDestWithinConfigHome alone would accept it) but only // reachable by walking through the `linkdir` symlink to `outside`. fs.writeFileSync( path.join(entryDir, 'record.json'), JSON.stringify({ destDir: path.join(configDir, 'linkdir', 'sub'), names: ['USER-PROFILE.md'], timestamp: new Date().toISOString(), }), ); const result = recoverOrphanedUserArtifacts(stagingRoot, configDir); assert.equal(result.recovered.length, 0, 'must refuse — destDir is reached only through a symlinked ancestor'); assert.equal(result.skipped.length, 1); assert.equal(result.skipped[0].reason, 'destDir-symlink-escape'); assert.ok( !fs.existsSync(path.join(outside, 'sub', 'USER-PROFILE.md')), 'must never write outside configDir via the symlinked ancestor', ); } finally { cleanup(configDir); cleanup(outside); } }); // #2875 defect fix: `names` accepted any non-escaping subpath, including // one containing a path separator, contrary to this module's flat-name // contract (every real caller stages exactly one flat filename) — the // module doc previously claimed separator names were already rejected; // they were not. test('E9: a staged/recorded name containing a path separator is rejected, never nested under destDir', () => { const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e9-cfg-')); try { const destDir = path.join(configDir, 'gsd-core'); fs.mkdirSync(destDir, { recursive: true }); const stagingRoot = path.join(configDir, '.gsd-staging', 'user-artifacts'); const entryDir = path.join(stagingRoot, 'e9entry000000000'); fs.mkdirSync(path.join(entryDir, 'files', 'nested'), { recursive: true }); fs.writeFileSync(path.join(entryDir, 'files', 'nested', 'x.md'), 'should never land'); fs.writeFileSync( path.join(entryDir, 'record.json'), JSON.stringify({ destDir: path.resolve(destDir), names: ['nested/x.md'], timestamp: new Date().toISOString() }), ); const result = recoverOrphanedUserArtifacts(stagingRoot, configDir); assert.equal(result.recovered.length, 0, 'a separator-containing name must never be restored'); assert.ok(!fs.existsSync(path.join(destDir, 'nested', 'x.md')), 'must never create a nested subpath under destDir'); } finally { cleanup(configDir); } }); // #2875 defect fix (security — source-side symlink escape): the ancestor- // symlink guard (E8) covered only `destDir`. A real `entryDir` whose // `files` CHILD is a symlink to an unrelated victim directory (e.g. // `/etc`, `~/.ssh`) was dereferenced by every per-file `existsSync`/ // `stagedCopy` read below `filesDir`, copying victim-readable content into // an attacker-named path inside `configDir`. test('E10: recovery refuses an entryDir whose `files` child is a symlink — never dereferences the source side', () => { const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e10-cfg-')); const victim = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e10-victim-')); try { fs.writeFileSync(path.join(victim, 'attacker-chosen-name.md'), 'VICTIM SECRET CONTENT'); const stagingRoot = path.join(configDir, '.gsd-staging', 'user-artifacts'); const entryDir = path.join(stagingRoot, 'e10entry0000000'); fs.mkdirSync(entryDir, { recursive: true }); fs.symlinkSync(victim, path.join(entryDir, 'files')); const destDir = path.join(configDir, 'gsd-core'); fs.mkdirSync(destDir, { recursive: true }); fs.writeFileSync( path.join(entryDir, 'record.json'), JSON.stringify({ destDir: path.resolve(destDir), names: ['attacker-chosen-name.md'], timestamp: new Date().toISOString() }), ); const result = recoverOrphanedUserArtifacts(stagingRoot, configDir); assert.equal(result.recovered.length, 0, 'must refuse — the files/ child is a symlink to an untrusted directory'); assert.equal(result.skipped.length, 1); assert.equal(result.skipped[0].reason, 'files-symlink-escape'); assert.ok( !fs.existsSync(path.join(destDir, 'attacker-chosen-name.md')), 'the victim directory\'s content must never be copied into destDir', ); } finally { cleanup(configDir); cleanup(victim); } }); // #2875 defect fix (security/correctness — cwd-dependent confinement): a // RELATIVE `record.destDir` made `path.relative(configHome, record.destDir)` // resolve the relative argument against `process.cwd()` internally, not // against `configHome` — so recovery's behavior for a forged or malformed // record depended on whatever directory the CLI happened to be invoked // from, rather than being a deterministic function of `configDir`. // `stageUserArtifacts` (this module's own writer) always records an // ALREADY-resolved absolute `destDir`; a relative one only ever reaches // recovery via a forged/hand-edited record. test('E11: recovery refuses a RELATIVE record.destDir — confinement never depends on process.cwd()', (t) => { const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e11-cfg-')); t.after(() => cleanup(configDir)); const stagingRoot = path.join(configDir, '.gsd-staging', 'user-artifacts'); const entryDir = path.join(stagingRoot, 'e11entry0000000'); fs.mkdirSync(path.join(entryDir, 'files'), { recursive: true }); fs.writeFileSync(path.join(entryDir, 'files', 'x.md'), 'x'); fs.writeFileSync( path.join(entryDir, 'record.json'), JSON.stringify({ destDir: 'gsd-core', names: ['x.md'], timestamp: new Date().toISOString() }), ); // Run from a cwd that resolves the relative destDir straight back to // configDir (mirrors the real cline-local call shape, where targetDir // === process.cwd()) — the exact case that must NOT be treated as // "correctly resolved" just because the coincidence lines up. const previousCwd = process.cwd(); t.after(() => process.chdir(previousCwd)); process.chdir(configDir); const result = recoverOrphanedUserArtifacts(stagingRoot, configDir); assert.equal(result.recovered.length, 0, 'a relative destDir must never be accepted, regardless of cwd'); assert.equal(result.skipped.length, 1); assert.equal(result.skipped[0].reason, 'destDir-outside-confinement'); }); // #2875 security-review finding: E10 covers the default (no opt-in) case. // `GSD_ALLOW_SYMLINKED_DEST` is documented (install-engine.cts // `isSymlinkedDestOptIn`) as relaxing only the DESTINATION-side // pre-existing-symlink refusal — the user asserting they own/trust a // symlinked WRITE destination. `entryDir`/`filesDir` here is the staging // SOURCE, GSD-owned internal state this module creates itself, never a // user-authored layout — the opt-in must NOT relax this read-side check. test('E12: recovery refuses the source-side `files/` symlink even with GSD_ALLOW_SYMLINKED_DEST=1 — the dest opt-in never relaxes the source-side read', (t) => { const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e12-cfg-')); const victim = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e12-victim-')); t.after(() => { cleanup(configDir); cleanup(victim); }); fs.writeFileSync(path.join(victim, 'attacker-chosen-name.md'), 'VICTIM SECRET CONTENT'); const stagingRoot = path.join(configDir, '.gsd-staging', 'user-artifacts'); const entryDir = path.join(stagingRoot, 'e12entry0000000'); fs.mkdirSync(entryDir, { recursive: true }); try { fs.symlinkSync(victim, path.join(entryDir, 'files')); } catch (_e) { t.skip('symlink creation unsupported on this platform/privilege'); return; } const destDir = path.join(configDir, 'gsd-core'); fs.mkdirSync(destDir, { recursive: true }); fs.writeFileSync( path.join(entryDir, 'record.json'), JSON.stringify({ destDir: path.resolve(destDir), names: ['attacker-chosen-name.md'], timestamp: new Date().toISOString() }), ); process.env.GSD_ALLOW_SYMLINKED_DEST = '1'; t.after(() => delete process.env.GSD_ALLOW_SYMLINKED_DEST); const result = recoverOrphanedUserArtifacts(stagingRoot, configDir); assert.equal(result.recovered.length, 0, 'must refuse — the source-side files/ symlink is never relaxed by the dest opt-in'); assert.equal(result.skipped.length, 1); assert.equal(result.skipped[0].reason, 'files-symlink-escape'); assert.ok( !fs.existsSync(path.join(destDir, 'attacker-chosen-name.md')), 'the victim directory\'s content must never be copied into destDir, even with the opt-in set', ); }); // ------------------------------------------------------------------------- // Parity: install-engine.cts's `_resolveUserArtifactStagingRoot` is // deliberately duplicated (not shared) into bin/install.js, to avoid a // circular `require` between the two (see that function's own doc comment // in both files). This codebase names unguarded duplication across // parallel surfaces "Generative Fix Divergence" and requires a parity // assertion that fails the moment the two copies diverge — same inputs, // same resolved root, same refusal behavior. // ------------------------------------------------------------------------- test('parity: install-engine.cts and bin/install.js copies of _resolveUserArtifactStagingRoot resolve the SAME root for the same configDir', () => { const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-parity-happy-')); try { const fromEngine = _resolveUserArtifactStagingRoot(configDir); const fromInstallJs = _installJsResolveUserArtifactStagingRoot(configDir); assert.equal(fromInstallJs, fromEngine, 'both copies must resolve to the identical staging root'); } finally { cleanup(configDir); } }); test('parity: both copies refuse a symlinked staging root identically', () => { const configDirEngine = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-parity-sym-engine-')); const configDirInstallJs = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-parity-sym-installjs-')); const elsewhere = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-parity-sym-elsewhere-')); try { fs.symlinkSync(elsewhere, path.join(configDirEngine, '.gsd-staging')); fs.symlinkSync(elsewhere, path.join(configDirInstallJs, '.gsd-staging')); let engineThrew = false; let engineMessage = ''; try { _resolveUserArtifactStagingRoot(configDirEngine); } catch (err) { engineThrew = true; engineMessage = err.message; } let installJsThrew = false; let installJsMessage = ''; try { _installJsResolveUserArtifactStagingRoot(configDirInstallJs); } catch (err) { installJsThrew = true; installJsMessage = err.message; } assert.ok(engineThrew, 'install-engine.cts copy must refuse a symlinked staging root'); assert.ok(installJsThrew, 'bin/install.js copy must refuse a symlinked staging root'); // Both messages must carry the same refusal shape (symlink refusal), // even though configDir differs between the two (each needs its own // sandbox — the throw itself, not the literal path, is what parity // checks here). assert.match(engineMessage, /symlink/i); assert.match(installJsMessage, /symlink/i); } finally { cleanup(configDirEngine); cleanup(configDirInstallJs); cleanup(elsewhere); } }); // ------------------------------------------------------------------------- // Parity: the DEGRADE-not-abort wrapper (`_tryResolveUserArtifactStagingRoot`) // is ALSO duplicated verbatim into bin/install.js (same doc-comment // rationale as the throwing version above). "Same inputs, same resolved // root, same refusal" above does not cover "same degrade": a caller-facing // regression where one copy started throwing again (bricking the command) // while the other correctly degraded to `null` would slip past the tests // above, since neither calls the wrapper. // ------------------------------------------------------------------------- test('parity: install-engine.cts and bin/install.js copies of _tryResolveUserArtifactStagingRoot resolve the SAME root for the same configDir (happy path)', (t) => { const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-try-parity-happy-')); t.after(() => cleanup(configDir)); const fromEngine = _tryResolveUserArtifactStagingRoot(configDir); const fromInstallJs = _installJsTryResolveUserArtifactStagingRoot(configDir); assert.notEqual(fromEngine, null, 'the engine copy must resolve (not degrade) on a clean configDir'); assert.equal(fromInstallJs, fromEngine, 'both copies must resolve to the identical staging root'); }); test('parity: both copies of _tryResolveUserArtifactStagingRoot degrade to null (never throw) for the SAME symlinked staging root', (t) => { const configDirEngine = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-try-parity-sym-engine-')); const configDirInstallJs = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-try-parity-sym-installjs-')); const elsewhere = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-try-parity-sym-elsewhere-')); t.after(() => { cleanup(configDirEngine); cleanup(configDirInstallJs); cleanup(elsewhere); }); fs.symlinkSync(elsewhere, path.join(configDirEngine, '.gsd-staging')); fs.symlinkSync(elsewhere, path.join(configDirInstallJs, '.gsd-staging')); let engineThrew = false; let engineResult; try { engineResult = _tryResolveUserArtifactStagingRoot(configDirEngine); } catch { engineThrew = true; } let installJsThrew = false; let installJsResult; try { installJsResult = _installJsTryResolveUserArtifactStagingRoot(configDirInstallJs); } catch { installJsThrew = true; } assert.equal(engineThrew, false, 'the engine copy\'s try-wrapper must never throw — this is the whole point of the wrapper'); assert.equal(installJsThrew, false, 'the bin/install.js copy\'s try-wrapper must never throw either'); assert.equal(engineResult, null, 'the engine copy must degrade to null for a refused staging root'); assert.equal(installJsResult, null, 'the bin/install.js copy must degrade to null identically'); }); }); // --------------------------------------------------------------------------- // migrateLegacyDevPreferencesToSkill — dangling-symlink leaf write (security // review finding, found while closing the #2875 agents-descriptor migration // gap): existsSync(skillFile) follows symlinks and reports false for a // dangling one, and the symlink-escape guard only walked to the PARENT // directory, never lstat-checking the leaf file itself — so a dangling // symlink planted exactly at the skill-file destination sailed through both // checks and writeFileSync (which DOES follow symlinks) wrote attacker // content to the symlink's target. // --------------------------------------------------------------------------- describe('migrateLegacyDevPreferencesToSkill — dangling-symlink leaf write', () => { const { migrateLegacyDevPreferencesToSkill } = require('../gsd-core/bin/lib/install-engine.cjs'); test('refuses to write through a dangling symlink planted at the skill-file leaf', () => { const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-migrate-sym-')); const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-migrate-sym-outside-')); const attackerTarget = path.join(outside, 'authorized_keys'); try { const skillDir = path.join(configDir, 'skills', 'gsd-dev-preferences'); fs.mkdirSync(skillDir, { recursive: true }); const skillFile = path.join(skillDir, 'SKILL.md'); fs.symlinkSync(attackerTarget, skillFile); // dangling — target does not exist const saved = new Map([['dev-preferences.md', 'attacker-controlled content\n']]); assert.throws( () => migrateLegacyDevPreferencesToSkill(configDir, saved, 'claude', 'global'), /symlink/i, 'must refuse to write through a symlinked skill-file leaf', ); assert.ok(!fs.existsSync(attackerTarget), 'attacker target must never be created/written'); } finally { cleanup(configDir); cleanup(outside); } }); test('a real, already-migrated skill file (not a symlink) still short-circuits as before', () => { const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-migrate-real-')); try { const skillDir = path.join(configDir, 'skills', 'gsd-dev-preferences'); fs.mkdirSync(skillDir, { recursive: true }); const skillFile = path.join(skillDir, 'SKILL.md'); fs.writeFileSync(skillFile, 'already migrated\n', 'utf8'); const saved = new Map([['dev-preferences.md', 'new content\n']]); const migrated = migrateLegacyDevPreferencesToSkill(configDir, saved, 'claude', 'global'); assert.strictEqual(migrated, false, 'must not clobber an existing real skill file'); assert.strictEqual(fs.readFileSync(skillFile, 'utf8'), 'already migrated\n'); } finally { cleanup(configDir); } }); }); // --------------------------------------------------------------------------- // uninstallRuntimeArtifacts — staged legacy dev-preferences.md symlink // dereference (security review finding). Sibling call sites // (_runLegacyInstallMigrations, bin/install.js's own uninstall()) already // lstat-guard a staged name before readFileSync; this uninstall call site did // not, so a symlinked staged dev-preferences.md had its referent's bytes read // into SKILL.md, and a symlink to a directory threw EISDIR uncaught. // --------------------------------------------------------------------------- describe('uninstallRuntimeArtifacts — staged dev-preferences.md symlink dereference', () => { const { uninstallRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs'); test('a symlinked staged dev-preferences.md is skipped (never dereferenced) and uninstall does not throw', () => { const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uninstall-sym-')); const secretFile = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uninstall-sym-secret-')); const secretPath = path.join(secretFile, 'id_rsa'); fs.writeFileSync(secretPath, 'PRIVATE KEY MATERIAL\n', 'utf8'); try { // Claude global uses the legacy commands/gsd/ location. const legacyDir = path.join(configDir, 'commands', 'gsd'); fs.mkdirSync(legacyDir, { recursive: true }); fs.symlinkSync(secretPath, path.join(legacyDir, 'dev-preferences.md')); // Must not throw (no EISDIR/unhandled error) and must never migrate the // symlink's referent content into any installed skill file. assert.doesNotThrow(() => uninstallRuntimeArtifacts('claude', configDir, 'global')); const skillFile = path.join(configDir, 'skills', 'gsd-dev-preferences', 'SKILL.md'); if (fs.existsSync(skillFile)) { const content = fs.readFileSync(skillFile, 'utf8'); assert.ok(!content.includes('PRIVATE KEY MATERIAL'), 'the symlink referent must never be migrated into SKILL.md'); } } finally { cleanup(configDir); cleanup(secretFile); } }); }); // --------------------------------------------------------------------------- // #2875 defect fix (security/regression): a staging-root resolution failure // (e.g. `.gsd-staging/user-artifacts` is/contains a symlink) must DEGRADE — // skip staging for that step, warn — rather than abort install()/uninstall() // entirely. Before this fix, `_resolveUserArtifactStagingRoot` was called // UNGUARDED as the first statement of both, so a hostile/broken // `.gsd-staging` path bricked BOTH commands, including uninstall — the // remedy for the first problem. // --------------------------------------------------------------------------- describe('install()/uninstall() degrade (never abort) on a staging-root resolution failure', () => { function withHostileStagingSymlink(runFn) { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-staging-degrade-')); const elsewhere = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-staging-degrade-elsewhere-')); const previousCwd = process.cwd(); process.chdir(tmp); try { fs.mkdirSync(path.join(tmp, '.gsd-staging')); fs.symlinkSync(elsewhere, path.join(tmp, '.gsd-staging', 'user-artifacts')); runFn(tmp); } finally { process.chdir(previousCwd); cleanup(tmp); cleanup(elsewhere); } } test('install() proceeds and still writes gsd-core/ when the staging root is a hostile symlink', () => { withHostileStagingSymlink((tmp) => { assert.doesNotThrow(() => install(false, 'cline'), 'install() must degrade, never throw, on a staging-root failure'); assert.ok(fs.existsSync(path.join(tmp, 'gsd-core')), 'gsd-core/ must still be installed despite the staging-root failure'); }); }); test('uninstall() proceeds and still removes gsd-core/ when the staging root is a hostile symlink', () => { withHostileStagingSymlink((tmp) => { // Bypass the (also-degrading) install() path to set up a realistic // pre-existing gsd-core/ without depending on install() itself. fs.mkdirSync(path.join(tmp, 'gsd-core'), { recursive: true }); fs.writeFileSync(path.join(tmp, 'gsd-core', 'USER-PROFILE.md'), 'user content'); assert.doesNotThrow(() => uninstall(false, 'cline'), 'uninstall() must degrade, never throw, on a staging-root failure'); assert.ok(!fs.existsSync(path.join(tmp, 'gsd-core')), 'gsd-core/ must still be removed despite the staging-root failure'); }); }); }); /** * #3712 — an in-process run must never reach the developer's REAL home. * * The confinement rows above bound a write to the root they are handed. A kind * may ALSO declare a global `home` override resolved from `os.homedir()` rather * than from configDir (today: codex's skills kind, `home: ".agents"`, ADR-1239 / * #2088). assertDestWithinConfigHome cannot see that class: it confines a * destSubpath to whatever root it is given, and here the root IS the escaped home. * * The live defect: an in-process caller that forgot to sandbox HOME pruned the * real ~/.agents/skills — 71 gsd-* dirs deleted, a foreign `cloudflare` skill * surviving, suite still exit 0, because the runtime's own config home was * untouched and the manifest kept reporting a healthy install. * * SIX writers resolve a kind `home` and then write or destroy under it. The four * reachable today are covered below by driving the REAL entrypoint — not the * predicate — so that deleting a guard call site turns these rows red. The other * two, `installOpencodeFamilySkills` and `installAgentsKindStandalone`, are * guarded but not wiring-tested: no runtime declares a `home` override on those * kinds, so neither path can be exercised without inventing a descriptor. * * The sixth, `migrateLegacyDevPreferencesToSkill`, CREATES rather than prunes and * runs from `_runLegacyInstallMigrations` — i.e. BEFORE installRuntimeArtifacts' * own assertion — so it carries its own guard call and its own wiring row. The * count read FIVE/three until review of #3725 caught the row missing. */ describe('#3712 in-process home confinement', () => { const { createTempDir, sandboxHome } = require('./helpers.cjs'); const installEngine = require('../gsd-core/bin/lib/install-engine.cjs'); const surface = require('../gsd-core/bin/lib/surface.cjs'); const runtimeArtifactLayout = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); const testHomeGuard = require('../gsd-core/bin/lib/real-home-guard.cjs'); const escapingKinds = (home) => [{ kind: 'skills', home, destSubpath: 'skills' }]; const confinedKinds = [{ kind: 'skills', destSubpath: 'skills' }]; const fakeOs = (homedir, passwdHome) => ({ homedir: () => homedir, userInfo: () => ({ homedir: passwdHome }), }); const underTest = { NODE_TEST_CONTEXT: 'child-v8' }; // ── the predicate ──────────────────────────────────────────────────────── // Driven with REAL directories: the whole question is filesystem identity, and // synthetic paths cannot exercise it. The passwd home is injected via the deps // seam so no row depends on the developer's actual home. describe('predicate', () => { test('refuses a destination that lands inside the real home', (t) => { const realHome = createTempDir('gsd-3712-real-'); t.after(() => cleanup(realHome)); assert.throws( () => testHomeGuard.assertTestHomeSandboxed('installRuntimeArtifacts', 'codex', escapingKinds(path.join(realHome, '.agents')), { os: fakeOs(realHome, realHome), env: underTest }), /destination inside\s+your REAL home/, ); }); test('allows a destination outside the real home — the correct-usage path', (t) => { const realHome = createTempDir('gsd-3712-real2-'); const sandbox = createTempDir('gsd-3712-sandbox-'); t.after(() => { cleanup(realHome); cleanup(sandbox); }); assert.doesNotThrow(() => testHomeGuard.assertTestHomeSandboxed( 'installRuntimeArtifacts', 'codex', escapingKinds(path.join(sandbox, '.agents')), { os: fakeOs(sandbox, realHome), env: underTest }, )); }); // The round-5 defect. A HOME-state check ("is HOME sandboxed right now?") // returns "sandboxed" here and lets the write through, because the layout was // resolved BEFORE the sandbox and its kind.home still names the real home. // applySurface takes an already-resolved layout, so this is reachable, not // theoretical. Asking about the DESTINATION is what closes it. test('refuses a STALE layout resolved before HOME was sandboxed', (t) => { const realHome = createTempDir('gsd-3712-stale-real-'); const sandbox = createTempDir('gsd-3712-stale-sandbox-'); t.after(() => { cleanup(realHome); cleanup(sandbox); }); // kind.home captured the REAL home; HOME is now the sandbox. assert.throws( () => testHomeGuard.assertTestHomeSandboxed('applySurface', 'codex', escapingKinds(path.join(realHome, '.agents')), { os: fakeOs(sandbox, realHome), env: underTest }), /destination inside\s+your REAL home/, 'a sandboxed HOME does not make a destination resolved before the sandbox safe', ); }); // The round-6 defect, found by CI rather than by review: all six Windows // shards of #3725 failed here, every one of them on a legitimately sandboxed // destination. On Windows `os.tmpdir()` is `%LOCALAPPDATA%\Temp` — // `%USERPROFILE%\AppData\Local\Temp` — so every sandbox a test creates is a // DESCENDANT of the real home, and "does this land inside the real home?" // answers yes for the safe case and the dangerous one alike. POSIX hides this: // /tmp and /var/folders both sit outside $HOME, so the containment question // happens to discriminate there and the flaw is invisible. // // What actually separates the two is whether the destination derives from a // HOME that was sandboxed — hence the added conjunct. This must NOT decay into // the "is HOME sandboxed?" check the row above rejects; the row below holds // that line. test('allows a sandbox nested INSIDE the real home — the Windows temp-root shape', (t) => { const realHome = createTempDir('gsd-3712-nested-home-'); t.after(() => cleanup(realHome)); const sandbox = path.join(realHome, 'AppData', 'Local', 'Temp', 'gsd-sandbox-a'); fs.mkdirSync(sandbox, { recursive: true }); assert.doesNotThrow(() => testHomeGuard.assertTestHomeSandboxed( 'installRuntimeArtifacts', 'codex', escapingKinds(path.join(sandbox, '.agents')), { os: fakeOs(sandbox, realHome), env: underTest }, )); }); // The guard against fixing the row above by weakening it to a HOME-state // check. Same nested-sandbox environment, but the layout was resolved before // the sandbox existed, so the destination still names the real home's // `.agents`. HOME is sandboxed and the write is still fatal. test('a nested sandbox does NOT excuse a STALE destination in the real home', (t) => { const realHome = createTempDir('gsd-3712-nested-stale-'); t.after(() => cleanup(realHome)); const sandbox = path.join(realHome, 'AppData', 'Local', 'Temp', 'gsd-sandbox-b'); fs.mkdirSync(sandbox, { recursive: true }); assert.throws( () => testHomeGuard.assertTestHomeSandboxed('applySurface', 'codex', escapingKinds(path.join(realHome, '.agents')), { os: fakeOs(sandbox, realHome), env: underTest }), /destination inside\s+your REAL home/, 'a sandbox beneath the real home vouches only for what is beneath the sandbox', ); }); // Review N2: the leaking cell of the truth table. A destination's ancestor // chain is linear, so "inside the real home AND inside the effective HOME" // admits `realHome ⊂ effectiveHome` as well as the intended // `effectiveHome ⊂ realHome`. HOME at /Users, /home or C:\Users is not a // sandbox — it is the real home spelled more widely — and without the third // conjunct it exempts a stale destination pointing straight at ~/.agents. test('a HOME that is an ANCESTOR of the real home is not a sandbox', (t) => { const container = createTempDir('gsd-3712-ancestor-'); t.after(() => cleanup(container)); const realHome = path.join(container, 'someone'); fs.mkdirSync(path.join(realHome, '.agents'), { recursive: true }); assert.throws( () => testHomeGuard.assertTestHomeSandboxed('applySurface', 'codex', escapingKinds(path.join(realHome, '.agents')), // HOME is the DIRECTORY THAT CONTAINS the real home, so it differs from // the passwd home and still contains the destination. { os: fakeOs(container, realHome), env: underTest }), /destination inside\s+your REAL home/, 'a HOME above the real home spells it more widely; it does not sandbox it', ); }); // The escape the nested-sandbox exemption opens if it trusts the SPELLING of // a path instead of where it resolves. HOME is sandboxed to a directory // inside the real home (legitimate on Windows), but the sandbox's `.agents` // is an alias onto the real one, so a lexical ancestor walk reaches the // sandbox while the write lands in `$REAL/.agents`. On Windows the alias // would be a junction; the mechanism is the same. test('refuses a sandbox whose .agents is an ALIAS onto the real one', (t) => { const realHome = createTempDir('gsd-3712-alias-'); t.after(() => cleanup(realHome)); const realAgents = path.join(realHome, '.agents'); fs.mkdirSync(path.join(realAgents, 'skills'), { recursive: true }); const sandbox = path.join(realHome, 'AppData', 'Local', 'Temp', 'gsd-sandbox-c'); fs.mkdirSync(sandbox, { recursive: true }); fs.symlinkSync(realAgents, path.join(sandbox, '.agents'), 'dir'); assert.throws( () => testHomeGuard.assertTestHomeSandboxed('installRuntimeArtifacts', 'codex', escapingKinds(path.join(sandbox, '.agents')), { os: fakeOs(sandbox, realHome), env: underTest }), /destination inside\s+your REAL home/, 'containment must be decided on where the write LANDS, not how it is spelled', ); }); // The refusal happens before any write, so it is not a partial install. // bin/install.js reads this to decide NOT to run its pre-config rollback, // which would otherwise delete and recreate every snapshotted gsd-* dir in // the real skills root — the exact mutation this guard exists to prevent. test('a refusal is marked so callers can tell it from a partial install', (t) => { const realHome = createTempDir('gsd-3712-flag-'); t.after(() => cleanup(realHome)); let caught; try { testHomeGuard.assertTestHomeSandboxed('installRuntimeArtifacts', 'codex', escapingKinds(path.join(realHome, '.agents')), { os: fakeOs(realHome, realHome), env: underTest }); } catch (err) { caught = err; } assert.ok(caught, 'precondition: the guard must have refused'); assert.equal(testHomeGuard.isTestHomeGuardRefusal(caught), true); assert.equal(testHomeGuard.isTestHomeGuardRefusal(new Error('unrelated')), false, 'an unrelated failure IS a partial install and must still roll back'); assert.equal(testHomeGuard.isTestHomeGuardRefusal(undefined), false); }); // The exemption above is the only path in this guard that can turn a // destination inside the real home into an allowed write, so its // cannot-tell branches have to refuse. Mutating either of them to `true` // left the whole suite green before this row existed. test('refuses when HOME cannot be placed at all, rather than exempting it', (t) => { const realHome = createTempDir('gsd-3712-nohome-place-'); t.after(() => cleanup(realHome)); const unplaceable = { 'an empty homedir': () => '', 'a homedir that throws': () => { throw new Error('no home'); }, 'a homedir that does not exist': () => path.join(realHome, 'absent-sandbox'), }; for (const [label, homedir] of Object.entries(unplaceable)) { assert.throws( () => testHomeGuard.assertTestHomeSandboxed('installRuntimeArtifacts', 'codex', escapingKinds(path.join(realHome, '.agents')), { os: { homedir, userInfo: () => ({ homedir: realHome }) }, env: underTest }), /destination inside\s+your REAL home/, `${label} must refuse — an unplaceable HOME is not evidence of a sandbox`, ); } }); test('the message names the operation and the fix, so it is not silenced blindly', (t) => { const realHome = createTempDir('gsd-3712-msg-'); t.after(() => cleanup(realHome)); let message = ''; try { testHomeGuard.assertTestHomeSandboxed('applySurface', 'codex', escapingKinds(path.join(realHome, '.agents')), { os: fakeOs(realHome, realHome), env: underTest }); } catch (err) { message = err.message; } assert.match(message, /applySurface/, 'must name the writer that was refused'); assert.match(message, /sandboxHome/, 'must point at the helper that fixes it'); assert.match(message, /Fix the TEST, not this guard/); assert.match(message, /BEFORE resolving the layout/, 'must state the ordering that matters'); }); test('is inert outside a test runner — real codex installs still write to $HOME/.agents', (t) => { // The override exists so a REAL install lands in the user's home. Blocking // that would break codex installs outright, so this row is load-bearing. const realHome = createTempDir('gsd-3712-prod-'); t.after(() => cleanup(realHome)); assert.doesNotThrow(() => testHomeGuard.assertTestHomeSandboxed( 'installRuntimeArtifacts', 'codex', escapingKinds(path.join(realHome, '.agents')), { os: fakeOs(realHome, realHome), env: {} }, )); }); test('ignores kinds with no home override — they cannot escape configDir', (t) => { const realHome = createTempDir('gsd-3712-nohome-'); t.after(() => cleanup(realHome)); assert.doesNotThrow(() => testHomeGuard.assertTestHomeSandboxed( 'installRuntimeArtifacts', 'claude', confinedKinds, { os: fakeOs(realHome, realHome), env: underTest }, )); }); // Identity, not pathname: path.resolve() resolves neither symlinks nor case, // and realpath returns a canonical pathname two routes to one directory can // still disagree on. Driven with the REAL fs — an injected os cannot prove // canonicalization, since that is what is under test. test('a destination reached through a SYMLINKED spelling of the real home is refused', (t) => { const realHome = createTempDir('gsd-3712-symreal-'); const linkParent = createTempDir('gsd-3712-symlink-'); const linkHome = path.join(linkParent, 'home-link'); t.after(() => { cleanup(realHome); cleanup(linkParent); }); fs.symlinkSync(realHome, linkHome, 'dir'); assert.throws( () => testHomeGuard.assertTestHomeSandboxed('installRuntimeArtifacts', 'codex', escapingKinds(path.join(linkHome, '.agents')), { os: fakeOs(linkHome, realHome), env: underTest }), /destination inside\s+your REAL home/, 'two spellings of one directory are not two directories', ); }); test('refuses rather than guessing when the passwd home cannot be identified', () => { assert.throws( () => testHomeGuard.assertTestHomeSandboxed('installRuntimeArtifacts', 'codex', escapingKinds('/nonexistent-root-3712/.agents'), { os: { homedir: () => '/nonexistent-root-3712', userInfo: () => { throw new Error('no passwd entry'); } }, env: underTest, }), /no identifiable passwd home/, ); }); test('a caller that recorded THIS home still works with no passwd entry', (t) => { // Keeps fail-closed from breaking passwd-less CI for callers that DID sandbox. const sandbox = createTempDir('gsd-3712-marker-'); t.after(() => cleanup(sandbox)); assert.doesNotThrow(() => testHomeGuard.assertTestHomeSandboxed( 'installRuntimeArtifacts', 'codex', escapingKinds(path.join(sandbox, '.agents')), { os: { homedir: () => sandbox, userInfo: () => { throw new Error('no passwd entry'); } }, env: { ...underTest, [testHomeGuard.SANDBOX_MARKER]: sandbox }, }, )); }); test('a STALE marker naming a different home does not vouch for this call', (t) => { const sandbox = createTempDir('gsd-3712-marker2-'); const other = createTempDir('gsd-3712-other-'); t.after(() => { cleanup(sandbox); cleanup(other); }); assert.throws( () => testHomeGuard.assertTestHomeSandboxed('installRuntimeArtifacts', 'codex', escapingKinds(path.join(sandbox, '.agents')), { os: { homedir: () => sandbox, userInfo: () => { throw new Error('no passwd entry'); } }, env: { ...underTest, [testHomeGuard.SANDBOX_MARKER]: other }, }), /no identifiable passwd home/, 'the marker must name the home actually in effect', ); }); // Codex review of #3725. The marker attests that a caller sandboxed HOME; it // says NOTHING about where an already-resolved destination points. A layout // captured before sandboxHome() still names the real `~/.agents`, and the // marker branch waved it straight through — the exact stale-layout shape the // primary branch refuses by design, reachable on any passwd-less host. Both // halves are now required: the marker names the home in effect AND every // destination derives from it. test('a marker matching HOME does not vouch for a destination that does not derive from it', (t) => { const sandbox = createTempDir('gsd-3712-marker-stale-'); const realHome = createTempDir('gsd-3712-marker-real-'); t.after(() => { cleanup(sandbox); cleanup(realHome); }); assert.throws( () => testHomeGuard.assertTestHomeSandboxed('applySurface', 'codex', escapingKinds(path.join(realHome, '.agents')), { os: { homedir: () => sandbox, userInfo: () => { throw new Error('no passwd entry'); } }, env: { ...underTest, [testHomeGuard.SANDBOX_MARKER]: sandbox }, }), /NOT beneath that sandbox/, 'a recorded sandbox vouches for HOME, never for a destination outside it', ); }); // Codex review of #3725. resolveThroughLinks swallowed EVERY realpathSync // error and fell back to the LEXICAL spelling. That is a fail-open, and it // inverts the function's whole purpose: an aliased `/.agents` that // cannot be canonicalized keeps its sandbox spelling, satisfies the // nested-sandbox exemption, and the write is ALLOWED into the real home. The // module documents ONE named fail-open (the marker branch); this was a second, // unnamed one. ENOENT/ENOTDIR still walk up — that is the ordinary // destination-does-not-exist-yet case, and the split matches identify()'s. test('a destination component that cannot be canonicalized is refused, not assumed safe', (t) => { const sandbox = createTempDir('gsd-3712-uncanon-'); const realHome = createTempDir('gsd-3712-uncanon-real-'); t.after(() => { cleanup(sandbox); cleanup(realHome); }); // A symlink CYCLE is the portable way to make realpathSync fail with // something other than "not there": every lookup through it returns ELOOP // while the parent directory resolves normally. const loopA = path.join(sandbox, 'loop-a'); const loopB = path.join(sandbox, 'loop-b'); try { fs.symlinkSync(loopB, loopA); fs.symlinkSync(loopA, loopB); } catch { t.skip('symlink creation unsupported on this platform/privilege'); return; } assert.throws( () => testHomeGuard.assertTestHomeSandboxed('applySurface', 'codex', escapingKinds(path.join(loopA, '.agents')), { os: fakeOs(sandbox, realHome), env: underTest }), /could not be canonicalized/, 'an unresolvable component must refuse — falling back to the lexical spelling is the ' + 'exact ALLOW an aliased /.agents needs to reach the real home', ); }); // Non-vacuity for the row above, and the reason it cannot simply refuse on // ANY realpath error: the ordinary case is a destination that does not exist // yet, where realpath fails ENOENT on the leaf and on every not-yet-created // ancestor. Refusing there would reject every fresh install. test('… but a destination that merely does not exist yet still resolves and is allowed', (t) => { const sandbox = createTempDir('gsd-3712-uncanon-enoent-'); const realHome = createTempDir('gsd-3712-uncanon-enoent-real-'); t.after(() => { cleanup(sandbox); cleanup(realHome); }); const neverCreated = path.join(sandbox, 'not-created-yet', '.agents'); assert.strictEqual(fs.existsSync(neverCreated), false, 'the row is only meaningful while the destination is absent'); assert.doesNotThrow( () => testHomeGuard.assertTestHomeSandboxed('applySurface', 'codex', escapingKinds(neverCreated), { os: fakeOs(sandbox, realHome), env: underTest }), 'ENOENT is the expected shape of a fresh install, not a canonicalization failure', ); }); // ENOTDIR takes the same walk-up branch as ENOENT, deliberately: both mean // "no such path as named", which is the question identify() answers the same // way. Pinned so the two cannot drift apart. test('… and a component sitting behind a FILE (ENOTDIR) walks up rather than refusing', (t) => { const sandbox = createTempDir('gsd-3712-uncanon-enotdir-'); const realHome = createTempDir('gsd-3712-uncanon-enotdir-real-'); t.after(() => { cleanup(sandbox); cleanup(realHome); }); const asFile = path.join(sandbox, 'a-file'); fs.writeFileSync(asFile, 'not a directory\n'); assert.doesNotThrow( () => testHomeGuard.assertTestHomeSandboxed('applySurface', 'codex', escapingKinds(path.join(asFile, '.agents')), { os: fakeOs(sandbox, realHome), env: underTest }), 'ENOTDIR means the path does not exist as named — identify() calls that absent, and this ' + 'walk must agree with it rather than inventing a second errno policy', ); }); // Review of #3725, Major 1. sameDirectory() is read by the marker branch as // permission to PROCEED, so "cannot tell" has to answer no. It used to answer // yes whenever neither side identified — two absent paths here, or two stats // failing EACCES/EPERM/EIO on a locked-down host — which turned the passwd-less // escape hatch into an unconditional bypass for any marker value at all. // The absent/absent shape is the portable way to pin it; the errno shapes take // the same branch. test('a marker and a home that BOTH fail to identify do not vouch for each other', (t) => { const root = createTempDir('gsd-3712-unident-'); t.after(() => cleanup(root)); // Two DIFFERENT paths, so the same-pathname shortcut cannot fire, and // neither exists, so neither identifies. const missingHome = path.join(root, 'home-never-created'); const missingMarker = path.join(root, 'marker-never-created'); assert.throws( () => testHomeGuard.assertTestHomeSandboxed('installRuntimeArtifacts', 'codex', escapingKinds(path.join(missingHome, '.agents')), { os: { homedir: () => missingHome, userInfo: () => { throw new Error('no passwd entry'); } }, env: { ...underTest, [testHomeGuard.SANDBOX_MARKER]: missingMarker }, }), /no identifiable passwd home/, 'two unidentifiable paths are not evidence that either is a sandbox', ); }); // Review of #3725, Major 2, raised as a question rather than a finding: on // Windows Node derives st_dev/st_ino from BY_HANDLE_FILE_INFORMATION, and the // uniqueness guarantees are weaker than POSIX. If dev collapsed to a per-volume // constant AND ino were 0 for directories, isInside() would match its very // first ancestor and report every path on the drive as inside the real home. // The whole guard rests on this primitive, so assert it directly instead of // reasoning about it — this row runs on every platform in the matrix, so // Windows answers the question itself. test('filesystem identity discriminates directories on this platform', (t) => { const a = createTempDir('gsd-3712-ident-a-'); const b = createTempDir('gsd-3712-ident-b-'); t.after(() => { cleanup(a); cleanup(b); }); const sa = fs.statSync(a); const sb = fs.statSync(b); assert.ok(sa.dev !== sb.dev || sa.ino !== sb.ino, `two distinct directories share an identity (dev=${sa.dev}/${sb.dev}, ino=${sa.ino}/${sb.ino}) — ` + 'isInside() would then match its first ancestor and refuse everything'); const again = fs.statSync(path.join(a, '..', path.basename(a))); assert.equal(again.dev, sa.dev, 'one directory reached by two spellings must keep one dev'); assert.equal(again.ino, sa.ino, 'one directory reached by two spellings must keep one ino'); }); // An ambient marker must not disarm the guard on the PRIMARY path: it is only // consulted when the real home cannot be identified at all. test('an ambient marker does NOT disarm the guard when the real home is identifiable', (t) => { const realHome = createTempDir('gsd-3712-ambient-'); t.after(() => cleanup(realHome)); assert.throws( () => testHomeGuard.assertTestHomeSandboxed('installRuntimeArtifacts', 'codex', escapingKinds(path.join(realHome, '.agents')), { os: fakeOs(realHome, realHome), env: { ...underTest, [testHomeGuard.SANDBOX_MARKER]: realHome }, }), /destination inside\s+your REAL home/, 'destination containment is authoritative; no marker may short-circuit it', ); }); test('the marker name tests/helpers.cjs writes matches the one the guard reads', () => { // helpers.cjs duplicates this string rather than requiring the compiled guard, // to keep its documented no-built-lib-at-import-time contract. Pin the pair. const { TEST_HOME_SANDBOX_MARKER } = require('./helpers.cjs'); assert.strictEqual(TEST_HOME_SANDBOX_MARKER, testHomeGuard.SANDBOX_MARKER, 'the helper and the guard must agree on the marker env var name'); }); }); // ── the wiring ─────────────────────────────────────────────────────────── // These call the REAL entrypoints. Deleting a guard call site makes them fail; // the predicate rows above would stay green, which is why both halves exist. describe('wiring — every writer that resolves kind.home is guarded', () => { /** * Sandbox the real HOME (so nothing real is ever at risk), then tell the guard * — through its deps seam only — that this sandboxed home IS the passwd home. * The layout then resolves its `home` override under that directory, so the * destination lands inside what the guard believes is the real home: exactly * "the caller forgot", reproduced against a throwaway directory. */ function forgottenSandbox(t) { const configDir = createTempDir('gsd-3712-cfg-'); const homeDir = createTempDir('gsd-3712-home-'); t.after(() => { cleanup(configDir); cleanup(homeDir); }); sandboxHome(t, homeDir); return { configDir, homeDir, deps: { os: fakeOs(homeDir, homeDir), env: underTest } }; } test('installRuntimeArtifacts refuses before it writes or prunes anything', (t) => { const { configDir, homeDir, deps } = forgottenSandbox(t); const profile = { name: 'full', skills: '*', agents: new Set() }; assert.throws( () => installEngine.installRuntimeArtifacts( 'codex', configDir, 'global', profile, () => undefined, undefined, deps, ), /destination inside\s+your REAL home/, 'the guard must be wired into installRuntimeArtifacts, not merely exported', ); assert.ok(!fs.existsSync(path.join(homeDir, '.agents', 'skills')), 'it must refuse BEFORE creating the escaped destination'); }); test('uninstallRuntimeArtifacts refuses — it prunes the same escaped home', (t) => { const { configDir, deps } = forgottenSandbox(t); assert.throws( () => installEngine.uninstallRuntimeArtifacts('codex', configDir, 'global', deps), /destination inside\s+your REAL home/, 'uninstall resolves the same kind.home and calls _removeGsdEntries on it', ); }); test('applySurface refuses — its dest selection prefers kind.home, then syncs', (t) => { const { configDir, deps } = forgottenSandbox(t); const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout('codex', configDir, 'global'); assert.throws( () => surface.applySurface(configDir, layout, new Map(), undefined, undefined, undefined, deps), /destination inside\s+your REAL home/, 'surface apply is the third writer that reaches kind.home', ); }); // The fourth reachable writer, and the one the first pass of #3712 missed: it // CREATES SKILL.md under the skills kind's `home` instead of pruning, and it // runs from _runLegacyInstallMigrations — BEFORE installRuntimeArtifacts' // assertion — so the rows above cannot cover it. Driven directly because that // is the seam it has; `saved` must carry dev-preferences.md or the function // returns early before ever resolving a target. test('migrateLegacyDevPreferencesToSkill refuses — it writes SKILL.md under kind.home', (t) => { const { configDir, homeDir, deps } = forgottenSandbox(t); const saved = new Map([['dev-preferences.md', '# saved\n']]); assert.throws( () => installEngine.migrateLegacyDevPreferencesToSkill(configDir, saved, 'codex', 'global', deps), /destination inside\s+your REAL home/, 'the legacy migration is the sixth writer that reaches kind.home, and it runs first', ); assert.ok(!fs.existsSync(path.join(homeDir, '.agents', 'skills', 'gsd-dev-preferences')), 'it must refuse BEFORE creating the escaped skill directory'); }); // The guard call is conditional on `target.hasHomeOverride` — the skills // kind's own answer to "did it declare a home override?", read off the same // layout resolution the write uses. Pin the ALLOW half too, so widening that // condition cannot pass silently. configDir sits INSIDE the (fake) real home // deliberately: that is what gives this row teeth. With the condition as // written the guard is never consulted, because no home override was // declared; widened to run unconditionally it would see a destination inside // the real home with no sandbox to derive from, and refuse an ordinary // confined migration. test('… and still migrates for a runtime whose skills kind declares no home override', (t) => { const { homeDir, deps } = forgottenSandbox(t); const configDir = path.join(homeDir, '.claude'); fs.mkdirSync(configDir, { recursive: true }); const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout('claude', configDir, 'global'); const skills = layout.kinds.find((k) => k.kind === 'skills'); assert.ok(skills && !skills.home, 'claude skills must carry NO home override — if this fails the descriptor drifted ' + 'and this row is no longer testing the ALLOW half'); const saved = new Map([['dev-preferences.md', '# saved\n']]); assert.strictEqual( installEngine.migrateLegacyDevPreferencesToSkill(configDir, saved, 'claude', 'global', deps), true, 'a confined destination must not be refused — the guard is destination-keyed, not runtime-keyed', ); }); // Codex review of #3725. `installRoot !== targetDir` was the stand-in for "the // skills kind declared a home override", and the two are not equivalent: the // inequality is FALSE when the override resolves onto targetDir itself — a // configDir of `$HOME/.agents`, which is exactly where codex's override points. // The guard was skipped and SKILL.md was written into the real home under a // test runner. The condition now reads the declaration off the same layout // resolution instead of comparing two paths. test('… and refuses when the configDir IS the home-override destination', (t) => { const { homeDir, deps } = forgottenSandbox(t); const configDir = path.join(homeDir, '.agents'); fs.mkdirSync(configDir, { recursive: true }); const saved = new Map([['dev-preferences.md', '# saved\n']]); assert.throws( () => installEngine.migrateLegacyDevPreferencesToSkill(configDir, saved, 'codex', 'global', deps), /destination inside\s+your REAL home/, 'an override that resolves onto targetDir is still a declared override', ); assert.ok(!fs.existsSync(path.join(configDir, 'skills', 'gsd-dev-preferences')), 'it must refuse BEFORE creating the skill directory in the real home'); }); }); // ── the property the one real fix depends on ───────────────────────────── test('a sandboxed HOME actually contains codex\'s global skills kind', (t) => { const configDir = createTempDir('gsd-3712-contain-cfg-'); const homeDir = createTempDir('gsd-3712-contain-home-'); t.after(() => { cleanup(configDir); cleanup(homeDir); }); const ambientHome = path.resolve(os.homedir()); sandboxHome(t, homeDir); const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout('codex', configDir, 'global'); const skills = layout.kinds.find((k) => k.kind === 'skills'); assert.ok(skills && skills.home, 'codex skills must still carry the global home override this guard exists for — ' + 'if this fails the descriptor drifted, and the guard is now testing nothing'); const dest = path.resolve(path.join(skills.home, skills.destSubpath)); assert.ok(dest.startsWith(path.resolve(homeDir) + path.sep), `codex skills must resolve inside the sandboxed HOME, got ${dest}`); assert.ok(!dest.startsWith(ambientHome + path.sep), `codex skills must NOT resolve inside the ambient home ${ambientHome}, got ${dest}`); }); }); // ──────────────────────────────────────────────────────────────────────── // Folded regression block — #4086 (Codex skills manifest keys never resolve) // Codex installs skills to the skills-kind `home` override (~/.agents/skills), // outside configDir (~/.codex). writeManifest() hashes skills from the real // location, but saveLocalPatches() resolved every manifest key against // configDir only — every skills/ key missed, so user modifications to Codex // skills were never hash-compared, never backed up, silently overwritten. // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe('folded:bug-4086-codex-skills-manifest-paths', () => { 'use strict'; const { test, beforeEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const crypto = require('node:crypto'); const ROOT = path.join(__dirname, '..'); const INSTALL = require(path.join(ROOT, 'bin', 'install.js')); const { cleanup, sandboxHome, scrubConfigLocationEnv } = require('./helpers.cjs'); const { INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); const MANIFEST_NAME = 'gsd-file-manifest.json'; const PATCHES_DIR_NAME = 'gsd-local-patches'; function sha256(content) { return crypto.createHash('sha256').update(content, 'utf8').digest('hex'); } function writeManifestFile(configDir, files, extra = {}) { fs.mkdirSync(configDir, { recursive: true }); fs.writeFileSync( path.join(configDir, MANIFEST_NAME), JSON.stringify({ version: '1.12.0', timestamp: new Date().toISOString(), files, ...extra }, null, 2), ); } describe('Bug #4086: saveLocalPatches resolves skills/ manifest keys at the runtime skills root', () => { let tmpDir; let homeDir; let configDir; let skillsRoot; let restoreConfigEnv; let fakeSrcDir; beforeEach((t) => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4086-')); homeDir = path.join(tmpDir, 'home'); fs.mkdirSync(homeDir, { recursive: true }); sandboxHome(t, homeDir); restoreConfigEnv = scrubConfigLocationEnv(); configDir = path.join(homeDir, '.codex'); // Same join the layout uses for codex's skills-kind home override // (#3712 block above pins that this is $HOME/.agents/skills). skillsRoot = path.join(homeDir, '.agents', 'skills'); fakeSrcDir = path.join(tmpDir, 'pkg-src'); fs.mkdirSync(fakeSrcDir, { recursive: true }); t.after(() => { restoreConfigEnv(); cleanup(tmpDir); }); }); test('saveLocalPatches backs up a modified Codex skill installed under ~/.agents/skills (#4086)', () => { const pristine = '---\nname: gsd-x\ndescription: stock skill body line for hashing\n---\nstock\n'; const modified = pristine + '\n'; const relKey = 'skills/gsd-x/SKILL.md'; fs.mkdirSync(path.join(skillsRoot, 'gsd-x'), { recursive: true }); fs.writeFileSync(path.join(skillsRoot, 'gsd-x', 'SKILL.md'), modified); writeManifestFile(configDir, { [relKey]: sha256(pristine) }, { runtime: 'codex', scope: 'global' }); const modifiedList = INSTALL.saveLocalPatches(configDir, { packageSrc: fakeSrcDir, runtime: 'codex', pathPrefix: '', isGlobal: true, }); assert.deepEqual(modifiedList, [relKey], 'the modified skill must be detected via the skills root'); const backup = path.join(configDir, PATCHES_DIR_NAME, relKey); assert.ok(fs.existsSync(backup), `backup must exist at ${PATCHES_DIR_NAME}/${relKey}`); assert.equal(fs.readFileSync(backup, 'utf8'), modified, 'backup must hold the user-modified bytes'); const meta = JSON.parse(fs.readFileSync(path.join(configDir, PATCHES_DIR_NAME, 'backup-meta.json'), 'utf8')); assert.deepEqual(meta.files, [relKey]); }); test('unmodified Codex skill under ~/.agents/skills produces no patch (#4086)', () => { const pristine = '---\nname: gsd-x\ndescription: stock skill body line for hashing\n---\nstock\n'; const relKey = 'skills/gsd-x/SKILL.md'; fs.mkdirSync(path.join(skillsRoot, 'gsd-x'), { recursive: true }); fs.writeFileSync(path.join(skillsRoot, 'gsd-x', 'SKILL.md'), pristine); writeManifestFile(configDir, { [relKey]: sha256(pristine) }, { runtime: 'codex', scope: 'global' }); const modifiedList = INSTALL.saveLocalPatches(configDir, { packageSrc: fakeSrcDir, runtime: 'codex', pathPrefix: '', isGlobal: true, }); assert.deepEqual(modifiedList, [], 'no false positive for an unmodified skill at the skills root'); }); test('config-dir-relative skills keep resolving against configDir first (#4086)', () => { const pristine = '---\nname: gsd-x\ndescription: stock claude skill body line\n---\nstock\n'; const modified = pristine + '\n'; const relKey = 'skills/gsd-x/SKILL.md'; // claude has NO skills home override — skills live under configDir itself. const claudeConfig = path.join(homeDir, '.claude'); fs.mkdirSync(path.join(claudeConfig, 'skills', 'gsd-x'), { recursive: true }); fs.writeFileSync(path.join(claudeConfig, 'skills', 'gsd-x', 'SKILL.md'), modified); writeManifestFile(claudeConfig, { [relKey]: sha256(pristine) }, { runtime: 'claude', scope: 'global' }); const modifiedList = INSTALL.saveLocalPatches(claudeConfig, { packageSrc: fakeSrcDir, runtime: 'claude', pathPrefix: '', isGlobal: true, }); assert.deepEqual(modifiedList, [relKey], 'config-dir-relative skills are detected exactly as before'); }); test('configDir copy wins when both locations exist (#4086)', () => { const pristine = '---\nname: gsd-x\ndescription: stock skill body line for hashing\n---\nstock\n'; const configCopy = pristine + '\n'; const rootCopy = pristine + '\n'; const relKey = 'skills/gsd-x/SKILL.md'; fs.mkdirSync(path.join(configDir, 'skills', 'gsd-x'), { recursive: true }); fs.writeFileSync(path.join(configDir, 'skills', 'gsd-x', 'SKILL.md'), configCopy); fs.mkdirSync(path.join(skillsRoot, 'gsd-x'), { recursive: true }); fs.writeFileSync(path.join(skillsRoot, 'gsd-x', 'SKILL.md'), rootCopy); writeManifestFile(configDir, { [relKey]: sha256(pristine) }, { runtime: 'codex', scope: 'global' }); const modifiedList = INSTALL.saveLocalPatches(configDir, { packageSrc: fakeSrcDir, runtime: 'codex', pathPrefix: '', isGlobal: true, }); assert.deepEqual(modifiedList, [relKey]); const backup = path.join(configDir, PATCHES_DIR_NAME, relKey); assert.equal(fs.readFileSync(backup, 'utf8'), configCopy, 'configDir copy must be hashed/backed up, not the skills-root copy'); }); test('legacy runtime-less manifest is tolerated (#4086)', () => { const pristine = '---\nname: gsd-x\ndescription: stock skill body line for hashing\n---\nstock\n'; const relKey = 'skills/gsd-x/SKILL.md'; fs.mkdirSync(path.join(skillsRoot, 'gsd-x'), { recursive: true }); fs.writeFileSync(path.join(skillsRoot, 'gsd-x', 'SKILL.md'), pristine + 'user line\n'); // No runtime field, and the caller passes no pristineCtx.runtime either // (legacy callers) — the redirect is impossible; behave as before (skip). writeManifestFile(configDir, { [relKey]: sha256(pristine) }); let modifiedList; assert.doesNotThrow(() => { modifiedList = INSTALL.saveLocalPatches(configDir, {}); }, 'a runtime-less manifest must not crash saveLocalPatches'); assert.deepEqual(modifiedList, [], 'without a runtime the old skip behavior applies'); }); test('end-to-end codex reinstall backs up the modified skill (#4086)', { timeout: INSTALL_TIMEOUT_MS }, () => { const origLog = console.log; const origWarn = console.warn; console.log = () => {}; console.warn = () => {}; try { INSTALL.install(true, 'codex'); } finally { console.log = origLog; console.warn = origWarn; } const manifest = JSON.parse(fs.readFileSync(path.join(configDir, MANIFEST_NAME), 'utf8')); const skillKeys = Object.keys(manifest.files).filter((k) => k.startsWith('skills/')); assert.ok(skillKeys.length > 0, 'codex global install must record skills/ manifest keys'); const skillAbs = skillKeys.map((k) => path.join(homeDir, '.agents', k)); assert.ok( skillKeys.every((k, i) => !fs.existsSync(path.join(configDir, k)) && fs.existsSync(skillAbs[i])), 'installed skills must live under ~/.agents, not under configDir (the #4086 premise)', ); // User modifies one installed skill, then reinstalls. fs.appendFileSync(skillAbs[0], '\n\n'); try { console.log = () => {}; console.warn = () => {}; INSTALL.install(true, 'codex'); } finally { console.log = origLog; console.warn = origWarn; } const backup = path.join(configDir, PATCHES_DIR_NAME, skillKeys[0]); assert.ok(fs.existsSync(backup), `reinstall must back the modified skill up at ${PATCHES_DIR_NAME}/${skillKeys[0]}`); assert.match(fs.readFileSync(backup, 'utf8'), /user edit 4086 e2e marker line/); }); }); }); } // ──────────────────────────────────────────────────────────────────────── // Folded regression block — #4145 (self-heal side). saveLocalPatches' // preserve-check resolved gsd-pristine/ entries strictly by the manifest-keyed // path, so a hash-matching snapshot stored without the gsd-core/ prefix was // pushed into regeneration from the incoming release; when upstream changed // the file, the candidate hash-mismatched and was discarded. The correct // baseline was never consumed and never pruned — the state repeated on every // future update. The fix rescues exact-recorded-hash orphans by relocating // them to the canonical path. // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe('folded:bug-4145-saveLocalPatches-orphan-rescue', () => { 'use strict'; process.env.GSD_TEST_MODE = '1'; const { test, describe, beforeEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); const crypto = require('node:crypto'); const ROOT = path.join(__dirname, '..'); const INSTALL = require(path.join(ROOT, 'bin', 'install.js')); const { cleanup } = require('./helpers.cjs'); const MANIFEST_NAME = 'gsd-file-manifest.json'; function sha256(content) { return crypto.createHash('sha256').update(content).digest('hex'); } describe('Bug #4145: saveLocalPatches rescues hash-matching orphaned pristine snapshots', () => { let tmpDir; let configDir; let newSrcDir; let pristineDir; const FILE = 'gsd-core/bin/lib/frontmatter.cjs'; const OLD_PRISTINE = '# Old Release Content\nThis is the outgoing pristine.\n'; const NEW_RELEASE = '# New Release Content\nUpstream rewrote this file wholesale in v2.\n'; const USER_MODIFIED = OLD_PRISTINE + '## User addition\nUser customization here.\n'; beforeEach((t) => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4145-slp-')); configDir = path.join(tmpDir, 'config'); newSrcDir = path.join(tmpDir, 'new-release-src'); pristineDir = path.join(configDir, 'gsd-pristine'); fs.mkdirSync(configDir, { recursive: true }); fs.mkdirSync(newSrcDir, { recursive: true }); t.after(() => { cleanup(tmpDir); }); }); function seedFixture({ orphanRel, orphanContent, canonicalContent, newReleaseContent }) { fs.mkdirSync(path.dirname(path.join(configDir, FILE)), { recursive: true }); fs.writeFileSync(path.join(configDir, FILE), USER_MODIFIED); fs.writeFileSync( path.join(configDir, MANIFEST_NAME), JSON.stringify({ version: '1.0.0', files: { [FILE]: sha256(OLD_PRISTINE) } }, null, 2), ); if (canonicalContent !== undefined) { fs.mkdirSync(path.dirname(path.join(pristineDir, FILE)), { recursive: true }); fs.writeFileSync(path.join(pristineDir, FILE), canonicalContent); } if (orphanRel !== undefined) { fs.mkdirSync(path.dirname(path.join(pristineDir, orphanRel)), { recursive: true }); fs.writeFileSync(path.join(pristineDir, orphanRel), orphanContent); } fs.mkdirSync(path.dirname(path.join(newSrcDir, FILE)), { recursive: true }); fs.writeFileSync(path.join(newSrcDir, FILE), newReleaseContent); } /** * Core regression (self-heal): the hash-matching snapshot sits at * bin/lib/frontmatter.cjs — without the gsd-core/ segment. The new release * changed the file upstream, so regeneration candidates are discarded. * After the fix the orphan is relocated to the canonical manifest-keyed * path and the unprefixed copy no longer lingers. */ test('#4145: saveLocalPatches relocates a hash-matching unprefixed orphan to the canonical pristine path', () => { const orphanRel = 'bin/lib/frontmatter.cjs'; seedFixture({ orphanRel, orphanContent: OLD_PRISTINE, newReleaseContent: NEW_RELEASE }); INSTALL.saveLocalPatches(configDir, { packageSrc: newSrcDir, runtime: 'claude', pathPrefix: '$HOME/.claude/', isGlobal: true, }); const canonical = path.join(pristineDir, FILE); assert.ok(fs.existsSync(canonical), 'canonical prefixed pristine must exist after the update'); assert.equal(sha256(fs.readFileSync(canonical, 'utf8')), sha256(OLD_PRISTINE), 'relocated baseline must carry the outgoing (recorded-hash) bytes, not new-release bytes'); assert.equal(fs.existsSync(path.join(pristineDir, orphanRel)), false, 'the unprefixed orphan must not linger once relocated'); }); /** * Stale canonical (new-release bytes) + hash-matching orphan elsewhere: * the stale entry is removed by the #3407 path and then rescued from the * orphan — the file must not end in no-baseline limbo. */ test('#4145: rescues after stale-canonical removal when a hash-matching orphan exists', () => { seedFixture({ orphanRel: 'legacy/frontmatter.cjs', orphanContent: OLD_PRISTINE, canonicalContent: NEW_RELEASE, // stale — hash mismatch newReleaseContent: NEW_RELEASE, }); INSTALL.saveLocalPatches(configDir, { packageSrc: newSrcDir, runtime: 'claude', pathPrefix: '$HOME/.claude/', isGlobal: true, }); const canonical = path.join(pristineDir, FILE); assert.ok(fs.existsSync(canonical), 'canonical pristine must exist after stale removal + rescue'); assert.equal(sha256(fs.readFileSync(canonical, 'utf8')), sha256(OLD_PRISTINE), 'rescued baseline must carry the recorded-hash bytes'); assert.equal(fs.existsSync(path.join(pristineDir, 'legacy', 'frontmatter.cjs')), false, 'the orphan must be consumed by the relocation'); }); /** Negative space: no orphan, upstream changed — regeneration discard (#3407) is unchanged. */ test('#4145: leaves the baseline absent when no orphan exists and upstream changed', () => { seedFixture({ newReleaseContent: NEW_RELEASE }); INSTALL.saveLocalPatches(configDir, { packageSrc: newSrcDir, runtime: 'claude', pathPrefix: '$HOME/.claude/', isGlobal: true, }); assert.equal(fs.existsSync(path.join(pristineDir, FILE)), false, 'no hash-matching source exists — the baseline must stay absent (over-broad/no-baseline fallback)'); }); /** Negative space: a mismatching orphan is neither adopted nor deleted. */ test('#4145: never adopts nor deletes a hash-mismatching orphan', () => { const orphanRel = 'bin/lib/frontmatter.cjs'; seedFixture({ orphanRel, orphanContent: NEW_RELEASE, newReleaseContent: NEW_RELEASE }); INSTALL.saveLocalPatches(configDir, { packageSrc: newSrcDir, runtime: 'claude', pathPrefix: '$HOME/.claude/', isGlobal: true, }); assert.equal(fs.existsSync(path.join(pristineDir, FILE)), false, 'mismatching bytes must not be written to the canonical pristine path'); assert.equal(fs.existsSync(path.join(pristineDir, orphanRel)), true, 'pruning files the recorded hashes do not vouch for is not this fix\'s job'); }); /** * Review finding (fix follow-up): two modified files sharing byte-identical * outgoing content. The orphan scan must never consume a path that is * another manifest file's canonical pristine path — otherwise the rescue * would relocate a correct canonical away from its owner and the two files * would ping-pong it between updates. Only genuine non-canonical orphans * are eligible. */ test('#4145: does not steal a byte-identical canonical belonging to another modified file', () => { const FILE_B = 'gsd-core/bin/lib/other-file.cjs'; const SHARED_OLD = '# Shared Old Content\nByte-identical across two manifest files.\n'; // A and B are both user-modified on top of byte-identical outgoing stock, // so both manifest records carry the SAME pristine hash. fs.mkdirSync(path.dirname(path.join(configDir, FILE_B)), { recursive: true }); fs.writeFileSync(path.join(configDir, FILE), SHARED_OLD + '## User addition A\nCustom A.\n'); fs.writeFileSync(path.join(configDir, FILE_B), SHARED_OLD + '## User addition B\nCustom B.\n'); fs.writeFileSync( path.join(configDir, MANIFEST_NAME), JSON.stringify({ version: '1.0.0', files: { [FILE]: sha256(SHARED_OLD), [FILE_B]: sha256(SHARED_OLD) }, }, null, 2), ); // A (processed first) has the ALREADY-correct canonical holding the shared // old bytes. B has no canonical and no orphan — B's only possible hash // match is A's canonical. Without the canonical skip set, B's rescue would // copy A's canonical to B's path and then DELETE A's canonical. fs.mkdirSync(path.dirname(path.join(pristineDir, FILE)), { recursive: true }); fs.writeFileSync(path.join(pristineDir, FILE), SHARED_OLD); fs.mkdirSync(path.dirname(path.join(newSrcDir, FILE)), { recursive: true }); fs.writeFileSync(path.join(newSrcDir, FILE), NEW_RELEASE); fs.mkdirSync(path.dirname(path.join(newSrcDir, FILE_B)), { recursive: true }); fs.writeFileSync(path.join(newSrcDir, FILE_B), NEW_RELEASE); INSTALL.saveLocalPatches(configDir, { packageSrc: newSrcDir, runtime: 'claude', pathPrefix: '$HOME/.claude/', isGlobal: true, }); // A's canonical must survive untouched — never stolen to become B's. assert.ok(fs.existsSync(path.join(pristineDir, FILE)), 'the byte-identical canonical of the earlier-processed file must survive'); assert.equal(sha256(fs.readFileSync(path.join(pristineDir, FILE), 'utf8')), sha256(SHARED_OLD)); // B gains no baseline from A's canonical (falls to regeneration instead). assert.equal(fs.existsSync(path.join(pristineDir, FILE_B)), false, 'a canonical path of another file must never be relocated as the rescue source'); }); /** Preserve-path lock: an already-correct canonical stays put; the preserve loop ignores the orphan. */ test('#4145: preserves an already-correct canonical and leaves a coexisting identical orphan in place', () => { const orphanRel = 'bin/lib/frontmatter.cjs'; seedFixture({ orphanRel, orphanContent: OLD_PRISTINE, canonicalContent: OLD_PRISTINE, // already correct newReleaseContent: NEW_RELEASE, }); INSTALL.saveLocalPatches(configDir, { packageSrc: newSrcDir, runtime: 'claude', pathPrefix: '$HOME/.claude/', isGlobal: true, }); const canonical = path.join(pristineDir, FILE); assert.ok(fs.existsSync(canonical)); assert.equal(sha256(fs.readFileSync(canonical, 'utf8')), sha256(OLD_PRISTINE), 'preserved canonical must be byte-identical to before the run'); assert.equal(fs.existsSync(path.join(pristineDir, orphanRel)), true, 'the preserve path must not disturb unrelated files'); }); }); }); } // ──────────────────────────────────────────────────────────────────────── // Folded regression block — #4135 (installer side). saveLocalPatches' // hash-validated regeneration (the #3407 promotion rule) keeps only // candidates byte-identical across the whole version span, so a // multi-version update leaves gsd-pristine/ holding near-zero baselines — // and the update output never says so in N-of-M terms. The fix keeps the // hash validation untouched (disk behavior is pinned here) and adds an // honest coverage summary via an exported typed helper. // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe('folded:bug-4135-saveLocalPatches-coverage-line', () => { 'use strict'; process.env.GSD_TEST_MODE = '1'; const { test, describe, beforeEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); const crypto = require('node:crypto'); const ROOT = path.join(__dirname, '..'); const INSTALL = require(path.join(ROOT, 'bin', 'install.js')); const { cleanup } = require('./helpers.cjs'); const MANIFEST_NAME = 'gsd-file-manifest.json'; function sha256(content) { return crypto.createHash('sha256').update(content, 'utf8').digest('hex'); } function countFiles(dir) { let n = 0; if (!fs.existsSync(dir)) return 0; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if (entry.isDirectory()) n += countFiles(path.join(dir, entry.name)); else if (entry.isFile()) n += 1; } return n; } describe('Bug #4135: saveLocalPatches reports honest gsd-pristine coverage on multi-version updates', () => { let tmpDir; let configDir; let newSrcDir; let pristineDir; beforeEach((t) => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4135-cov-')); configDir = path.join(tmpDir, 'config'); newSrcDir = path.join(tmpDir, 'new-release-src'); pristineDir = path.join(configDir, 'gsd-pristine'); fs.mkdirSync(configDir, { recursive: true }); fs.mkdirSync(newSrcDir, { recursive: true }); t.after(() => { cleanup(tmpDir); }); }); /** * Seeds a multi-version-update fixture: `total` modified files, of which * `identical` are byte-identical across the span (the only regenerable * baselines) and the rest changed upstream in the incoming source. */ function seedMultiVersionFixture(total, identical) { const manifestFiles = {}; for (let i = 1; i <= total; i++) { const rel = `gsd-core/workflows/flow-${String(i).padStart(2, '0')}.md`; const pristine = `# Flow ${i}\nStock content of the outgoing release for file ${i}.\n` + `Second outgoing stock line ${i} with plenty of substance.\n`; const user = pristine + `## User customisation ${i}\nCustom section on top of the outgoing release.\n`; const incoming = (i <= identical) ? pristine : `# Flow ${i} (rewritten)\nIncoming release rewrote file ${i} across the span.\n`; manifestFiles[rel] = sha256(pristine); fs.mkdirSync(path.dirname(path.join(configDir, rel)), { recursive: true }); fs.writeFileSync(path.join(configDir, rel), user); fs.mkdirSync(path.dirname(path.join(newSrcDir, rel)), { recursive: true }); fs.writeFileSync(path.join(newSrcDir, rel), incoming); } fs.writeFileSync( path.join(configDir, MANIFEST_NAME), JSON.stringify({ version: '1.10.0', timestamp: '2026-08-01T00:00:00Z', runtime: 'claude', scope: 'global', files: manifestFiles }, null, 2), ); return total; } /** * Core installer regression: the multi-version collapse itself is pinned * (hash validation untouched — only the byte-identical file survives), * and the honest N-of-M summary is available via the typed helper. */ test('#4135: saveLocalPatches multi-version regen keeps hash validation and reports 1-of-13 coverage', () => { const total = seedMultiVersionFixture(13, 1); INSTALL.saveLocalPatches(configDir, { packageSrc: newSrcDir, runtime: 'claude', pathPrefix: '$HOME/.claude/', isGlobal: true, }); const covered = countFiles(pristineDir); assert.equal(covered, 1, 'the collapse is pinned: only the byte-identical file survives hash-validated regeneration'); assert.equal(typeof INSTALL.describeBaselineCoverage, 'function', 'the coverage summary must be rendered by an exported typed helper'); const summary = INSTALL.describeBaselineCoverage(total, covered); assert.equal(summary.complete, false); assert.equal(summary.uncovered, 12); assert.equal( summary.text, 'gsd-pristine/ baselines cover 1 of 13 modified file(s) — 12 will be reported no_baseline by the reapply verifier', 'partial coverage states the N-of-M collapse and its downstream effect', ); }); /** Positive boundary: every modified file covered renders a complete summary. */ test('#4135: describeBaselineCoverage reports complete when every modified file is covered', () => { const total = seedMultiVersionFixture(3, 3); INSTALL.saveLocalPatches(configDir, { packageSrc: newSrcDir, runtime: 'claude', pathPrefix: '$HOME/.claude/', isGlobal: true, }); const covered = countFiles(pristineDir); assert.equal(covered, 3, 'a fully byte-identical span regenerates every baseline'); const summary = INSTALL.describeBaselineCoverage(total, covered); assert.equal(summary.complete, true); assert.equal(summary.uncovered, 0); assert.equal( summary.text, 'gsd-pristine/ baselines cover 3 of 3 modified file(s)', 'complete coverage renders without a collapse tail', ); }); }); }); } // ───────────────────────────────────────────────────────────────────────── // #4636 RED: isPathConfined (external-descriptor-trust.cts) is lexical-only — // it never calls realpath and cannot see a symlink. Three call sites rely on // it with no independent symlink defense of their own. These three tests // pre-plant a REAL symlink at the exact write/read target and assert the // OUTCOME (outside content untouched / not leaked) rather than the mechanism. // Each must FAIL today — that is the deliverable. // ───────────────────────────────────────────────────────────────────────── describe('#4636 RED: capability-skill symlink escape (isPathConfined has no realpath defense)', () => { const { createTempDir } = require('./helpers.cjs'); const runtimeArtifactLayout = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); const runtimeArtifactInstallPlan = require('../gsd-core/bin/lib/runtime-artifact-install-plan.cjs'); const installProfiles = require('../gsd-core/bin/lib/install-profiles.cjs'); const { installOpencodeFamilySkills } = require('../gsd-core/bin/lib/install-engine.cjs'); let savedGsdHome; beforeEach(() => { savedGsdHome = process.env['GSD_HOME']; }); afterEach(() => { if (savedGsdHome === undefined) delete process.env['GSD_HOME']; else process.env['GSD_HOME'] = savedGsdHome; }); test('[RED #4636] a pre-planted symlink at the skill dir must not redirect the SKILL.md write outside dest (install-engine.cts installOpencodeFamilySkills)', (t) => { const targetDir = createTempDir('gsd-4636-oc-target-'); const rawDir = createTempDir('gsd-4636-oc-raw-'); const outsideDir = createTempDir('gsd-4636-oc-outside-'); const gsdHome = createTempDir('gsd-4636-oc-home-'); process.env['GSD_HOME'] = gsdHome; const capId = 'evil-cap'; const stem = 'victim'; const capSkillDir = path.join(gsdHome, '.gsd', 'capabilities', capId, 'skills', stem); fs.mkdirSync(capSkillDir, { recursive: true }); fs.writeFileSync(path.join(capSkillDir, 'SKILL.md'), '# safe capability skill\n', 'utf8'); // A pre-existing, harmless file in rawDir so the writer does not early-return. fs.writeFileSync(path.join(rawDir, 'other.md'), '# other\n', 'utf8'); const registry = { capabilityClusters: { [capId]: [stem] } }; const resolvedProfile = { skills: new Set([stem]) }; // Derive the exact write target the production code will compute, via the // SAME real functions it calls — not a reimplementation. const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout('opencode', targetDir); const skillsKindEntry = layout.kinds.find((k) => k.kind === 'skills'); if (!skillsKindEntry) { t.skip('opencode layout declares no skills kind on this build'); return; } const installRoot = skillsKindEntry.home ?? targetDir; const dest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(installRoot, skillsKindEntry.destSubpath); const skillName = `${skillsKindEntry.prefix}${stem}`; const skillDirPath = path.join(dest, skillName); // installOpencodeFamilySkills creates `dest` and prunes any existing // gsd-*-prefixed entries BEFORE reading rawDir — so a symlink planted // before the call is swept by that prune. The window this defect actually // lives in is AFTER the prune and BEFORE this stem's write: hook the // real fs.readdirSync call that reads rawDir (the first read that happens // once the prune has already run) to plant the symlink deterministically // in that exact window — not a race, a synchronous side effect of a call // the function is already guaranteed to make. const origReaddirSync = fs.readdirSync; let planted = false; fs.readdirSync = (...args) => { const [p] = args; if (!planted && fs.existsSync(dest) && path.resolve(String(p)) === path.resolve(rawDir)) { planted = true; fs.symlinkSync(outsideDir, skillDirPath); } return origReaddirSync.apply(fs, args); }; let installOpencodeFamilySkillsErr = null; try { installOpencodeFamilySkills('opencode', targetDir, rawDir, '~/.opencode/', undefined, resolvedProfile, registry); } catch (err) { installOpencodeFamilySkillsErr = err; } finally { fs.readdirSync = origReaddirSync; } try { assert.strictEqual( installOpencodeFamilySkillsErr, null, `installOpencodeFamilySkills threw unexpectedly: ${installOpencodeFamilySkillsErr && installOpencodeFamilySkillsErr.message}`, ); assert.ok(planted, 'the symlink was never planted — this test would pass vacuously'); assert.strictEqual( fs.existsSync(path.join(outsideDir, 'SKILL.md')), false, 'SKILL.md must not be written outside dest via a pre-planted symlink at the skill dir', ); } finally { try { fs.unlinkSync(skillDirPath); } catch { /* may not exist / already a real dir */ } cleanup(targetDir); cleanup(rawDir); cleanup(outsideDir); cleanup(gsdHome); } }); test('[RED #4636] a pre-planted symlink at the staged skill dir must not redirect the SKILL.md write outside stageDir (install-profiles.cts stageSkillsForRuntimeAsSkills)', () => { const srcCommandsDir = createTempDir('gsd-4636-stage-src-'); const outsideDir = createTempDir('gsd-4636-stage-outside-'); const gsdHome = createTempDir('gsd-4636-stage-home-'); process.env['GSD_HOME'] = gsdHome; const capId = 'evil-cap-2'; const stem = 'victim2'; const capSkillDir = path.join(gsdHome, '.gsd', 'capabilities', capId, 'skills', stem); fs.mkdirSync(capSkillDir, { recursive: true }); fs.writeFileSync(path.join(capSkillDir, 'SKILL.md'), '# safe capability skill\n', 'utf8'); fs.writeFileSync(path.join(srcCommandsDir, 'other.md'), '# other\n', 'utf8'); const registry = { capabilityClusters: { [capId]: [stem] } }; const resolvedProfile = { skills: new Set([stem]) }; const converter = (content, _skillName) => content; const prefix = 'gsd-'; const skillName = `${prefix}${stem}`; // stageDir is a freshly mkdtemp'd, unpredictable-named directory created // INSIDE the function under test, so it cannot be pre-planted from the // outside before the call. Hook the real fs.mkdtempSync call (the same // deterministic-monkeypatch technique already used elsewhere in this repo, // e.g. tests/install-runtime-artifacts.test.cjs's // "rmSync is called on the tempDir when readFileSync throws") to capture // the exact directory this invocation creates and plant the symlink inside // it immediately after creation, before the function's first write. const origMkdtempSync = fs.mkdtempSync; let stageDirSeen = null; fs.mkdtempSync = (...args) => { const dir = origMkdtempSync.apply(fs, args); stageDirSeen = dir; fs.symlinkSync(outsideDir, path.join(dir, skillName)); return dir; }; let stageDir; let stageErr = null; try { stageDir = installProfiles.stageSkillsForRuntimeAsSkills(srcCommandsDir, resolvedProfile, converter, prefix, false, registry); } catch (err) { stageErr = err; } finally { fs.mkdtempSync = origMkdtempSync; } try { assert.strictEqual(stageErr, null, `stageSkillsForRuntimeAsSkills threw unexpectedly: ${stageErr && stageErr.message}`); assert.ok(stageDirSeen, 'the function under test must have created a stageDir via mkdtempSync'); assert.strictEqual( fs.existsSync(path.join(outsideDir, 'SKILL.md')), false, 'SKILL.md must not be written outside stageDir via a pre-planted symlink at the staged skill dir', ); } finally { try { fs.unlinkSync(path.join(stageDirSeen, skillName)); } catch { /* may not exist / already a real dir */ } cleanup(srcCommandsDir); cleanup(outsideDir); cleanup(gsdHome); if (stageDir) cleanup(stageDir); } }); test('[RED #4636] a symlinked SKILL.md inside a capability skill dir must not leak outside file content (install-profiles.cts readInstalledCapabilitySkill)', (t) => { const gsdHome = createTempDir('gsd-4636-read-home-'); process.env['GSD_HOME'] = gsdHome; const outsideDir = createTempDir('gsd-4636-read-outside-'); const capId = 'evil-cap-3'; const stem = 'victim3'; const capSkillDir = path.join(gsdHome, '.gsd', 'capabilities', capId, 'skills', stem); fs.mkdirSync(capSkillDir, { recursive: true }); const CANARY_CONTENT = '# TOP SECRET OUTSIDE FILE — must never be surfaced as a skill\n'; const canaryPath = path.join(outsideDir, 'secret.txt'); fs.writeFileSync(canaryPath, CANARY_CONTENT, 'utf8'); const skillMdPath = path.join(capSkillDir, 'SKILL.md'); try { fs.symlinkSync(canaryPath, skillMdPath, 'file'); } catch (err) { if (err && ['EPERM', 'EACCES', 'ENOTSUP'].includes(err.code)) { t.skip('symlink creation is not available on this platform/privilege'); cleanup(gsdHome); cleanup(outsideDir); return; } throw err; } try { const registry = { capabilityClusters: { [capId]: [stem] } }; const found = installProfiles.readInstalledCapabilitySkill(stem, registry); assert.ok( found === null || !String(found.content).includes(CANARY_CONTENT), 'readInstalledCapabilitySkill must not return content read through a symlinked SKILL.md pointing outside the capability dir', ); } finally { try { fs.unlinkSync(skillMdPath); } catch { /* already gone */ } cleanup(gsdHome); cleanup(outsideDir); } }); });