fix(relink): never delete or link over a skill gstack does not own (#2119)

gstack-relink runs on every ./setup. Its cleanup did `rm -rf` on any same-name
entry whose SKILL.md was a symlink, with no readlink check, and its link step
did `mkdir -p` then `ln -snf` onto any existing SKILL.md — on Linux that
replaces a user's real file with a symlink into gstack (macOS refused by
accident). setup's Windows mode-flip cleanup deleted any real dir whose name
matched a gstack skill. A personal `qa` skill, or a fork installed under
another path, was destroyed by the installer of a tool it never asked for.

Ownership is now proven, never assumed. An entry is ours when it is a symlink
resolving into INSTALL_DIR or RENDER_DIR, a real dir whose SKILL.md is such a
symlink, or a real dir carrying the .gstack-owned marker setup now writes for
Windows copy installs (legacy copies count when byte-identical to the source
or carrying gen-skill-docs' AUTO-GENERATED header). Anything else — including
an entry whose readlink fails — is foreign: left untouched, reported on
stderr, and listed in relink's summary line. The same rule replaces setup's
Windows name-match deletion; setup:1040 and gstack-uninstall:204 already
gated on readlink, so this closes the last unguarded deleter of the class.

Tests: foreign real dir in flat mode, foreign flat entry on a prefix flip,
foreign directory symlink, RENDER_DIR-targeted entry (ours), marker-carrying
copy (ours), marker-less copy (foreign); the Windows cleanup test now proves
provenance three ways and keeps the user's own same-name skill.

Idea and two regression cases from PR #2119 (@smblight); implemented on the
destination entry, not only the symlink target.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-09-03 01:18:18 +00:00
co-authored by Claude Fable 5.1
parent 17733302cc
commit 584c2a44fb
4 changed files with 212 additions and 13 deletions
+102
View File
@@ -672,3 +672,105 @@ describe('gstack-patch-names (#620/#578)', () => {
expect(content).toBe('# qa\nSome content.');
});
});
// ============================================================
// Ownership gate (#2119): relink runs on every ./setup and must never delete
// or link over a skill it does not own. Ownership = symlink into INSTALL_DIR
// or RENDER_DIR, a real dir whose SKILL.md is such a symlink, or the
// .gstack-owned marker setup writes for Windows copy installs.
// ============================================================
describe('gstack-relink ownership gate (#2119)', () => {
const FOREIGN = '---\nname: qa\ndescription: my own qa skill\n---\n# not gstack';
function relink(env: Record<string, string> = {}): string {
return run(`${path.join(installDir, 'bin', 'gstack-relink')} 2>&1`, {
GSTACK_INSTALL_DIR: installDir,
GSTACK_SKILLS_DIR: skillsDir,
...env,
});
}
function setPrefix(v: 'true' | 'false') {
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix ${v}`, {
GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir,
});
}
test('flat mode: a foreign real dir with a real SKILL.md is never linked over (Linux ln -snf would replace the file)', () => {
setupMockInstall(['qa', 'ship']);
// The foreign skill exists before gstack ever runs (gstack-config `set`
// auto-relinks, so fixtures go in first).
fs.mkdirSync(path.join(skillsDir, 'qa'));
fs.writeFileSync(path.join(skillsDir, 'qa', 'SKILL.md'), FOREIGN);
setPrefix('false');
const out = relink();
const md = path.join(skillsDir, 'qa', 'SKILL.md');
expect(fs.lstatSync(md).isSymbolicLink()).toBe(false);
expect(fs.readFileSync(md, 'utf-8')).toBe(FOREIGN);
expect(out).toContain('skipped');
expect(out).toContain('Skipped 1 foreign entry');
// The other skill still links normally.
expect(fs.lstatSync(path.join(skillsDir, 'ship', 'SKILL.md')).isSymbolicLink()).toBe(true);
});
test('prefix flip: a foreign flat entry sharing a skill name survives the cleanup pass', () => {
setupMockInstall(['qa']);
fs.mkdirSync(path.join(skillsDir, 'qa'));
fs.writeFileSync(path.join(skillsDir, 'qa', 'SKILL.md'), FOREIGN);
setPrefix('true');
const out = relink();
expect(fs.existsSync(path.join(skillsDir, 'qa', 'SKILL.md'))).toBe(true);
expect(fs.readFileSync(path.join(skillsDir, 'qa', 'SKILL.md'), 'utf-8')).toBe(FOREIGN);
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'))).toBe(true);
expect(out).toContain('skipped');
});
test('a foreign directory symlink sharing a skill name is left in place', () => {
setupMockInstall(['qa']);
const elsewhere = path.join(tmpDir, 'elsewhere', 'qa');
fs.mkdirSync(elsewhere, { recursive: true });
fs.writeFileSync(path.join(elsewhere, 'SKILL.md'), FOREIGN);
fs.symlinkSync(elsewhere, path.join(skillsDir, 'qa'));
setPrefix('false');
const out = relink();
expect(fs.lstatSync(path.join(skillsDir, 'qa')).isSymbolicLink()).toBe(true);
expect(fs.readlinkSync(path.join(skillsDir, 'qa'))).toBe(elsewhere);
expect(fs.readFileSync(path.join(elsewhere, 'SKILL.md'), 'utf-8')).toBe(FOREIGN);
expect(out).toContain('skipped');
});
test('an entry whose SKILL.md links into RENDER_DIR is ours and is cleaned on a mode flip', () => {
setupMockInstall(['qa']);
setPrefix('false');
const renderDir = path.join(tmpDir, 'render', 'claude');
fs.mkdirSync(path.join(renderDir, 'qa'), { recursive: true });
fs.writeFileSync(path.join(renderDir, 'qa', 'SKILL.md'), '---\nname: gstack-qa\ndescription: rendered\n---\n');
// Stale prefixed entry from a prior prefix-mode run, pointing at the render.
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
fs.symlinkSync(path.join(renderDir, 'qa', 'SKILL.md'), path.join(skillsDir, 'gstack-qa', 'SKILL.md'));
const out = relink({ GSTACK_USER_RENDER_DIR: renderDir });
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
expect(fs.readlinkSync(path.join(skillsDir, 'qa', 'SKILL.md'))).toBe(path.join(renderDir, 'qa', 'SKILL.md'));
expect(out).not.toContain('skipped');
});
test('a real-file copy carrying the .gstack-owned marker (Windows install shape) is ours', () => {
setupMockInstall(['qa']);
setPrefix('false');
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'), '---\nname: gstack-qa\n---\n');
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', '.gstack-owned'), '');
const out = relink();
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
expect(out).not.toContain('skipped');
});
test('a real-file copy WITHOUT the marker is foreign and survives', () => {
setupMockInstall(['qa']);
setPrefix('false');
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'), FOREIGN);
const out = relink();
expect(fs.readFileSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'), 'utf-8')).toBe(FOREIGN);
expect(out).toContain('skipped');
});
});
+24 -6
View File
@@ -259,21 +259,39 @@ describe.skipIf(process.platform === 'win32')('setup: cleanup_old_claude_symlink
}
});
test('Windows real-file leftover is removed when the payload still names it', () => {
// #2119: a bare name match used to delete a USER's own skill that happened to
// share a gstack skill name. Provenance must be proven: the .gstack-owned
// marker, a byte-identical copy of the payload source, or gen-skill-docs'
// AUTO-GENERATED header (legacy copies made before the marker existed).
test('Windows real-file leftover is removed only when provably gstack-owned', () => {
const generated = '---\nname: ship\n---\n<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n# ship\n';
const r = runCleanup({
isWindows: '1',
payload: true,
plant(skills, payload) {
const src = path.join(payload, 'qa');
fs.mkdirSync(src);
fs.writeFileSync(path.join(src, 'SKILL.md'), '---\nname: qa\n---\n');
plantUserSkill(skills, 'qa');
for (const name of ['qa', 'ship', 'review', 'browse']) {
fs.mkdirSync(path.join(payload, name));
fs.writeFileSync(path.join(payload, name, 'SKILL.md'), name === 'ship' ? generated : `---\nname: ${name}\n---\n`);
}
// Byte-identical copy of the payload source → ours.
fs.mkdirSync(path.join(skills, 'qa'));
fs.copyFileSync(path.join(payload, 'qa', 'SKILL.md'), path.join(skills, 'qa', 'SKILL.md'));
// Legacy copy carrying the generated header but drifted from source → ours.
fs.mkdirSync(path.join(skills, 'ship'));
fs.writeFileSync(path.join(skills, 'ship', 'SKILL.md'), generated.replace('# ship', '# ship (older render)'));
// Marker-carrying copy with arbitrary content → ours.
fs.mkdirSync(path.join(skills, 'browse'));
fs.writeFileSync(path.join(skills, 'browse', 'SKILL.md'), '---\nname: browse\n---\n# stale copy\n');
fs.writeFileSync(path.join(skills, 'browse', '.gstack-owned'), '');
// The user's OWN skill that shares a gstack name → foreign, must survive.
plantUserSkill(skills, 'review');
plantUserSkill(skills, 'my-own');
},
});
try {
expect(r.status).toBe(0);
expect(r.names).toEqual(['gstack', 'my-own']);
expect(r.names).toEqual(['gstack', 'my-own', 'review']);
expect(fs.readFileSync(path.join(r.tmp, 'skills', 'review', 'SKILL.md'), 'utf-8')).toContain('user-owned');
} finally {
fs.rmSync(r.tmp, { recursive: true, force: true });
}