From 7752234e7518ffc6613ffbcc639b4f6448ca480d Mon Sep 17 00:00:00 2001 From: Tibsfox Date: Fri, 10 Apr 2026 11:57:26 -0700 Subject: [PATCH 1/2] feat(config): support global skills from ~/.claude/skills/ in agent_skills (#1992) Add global: prefix for agent_skills config entries that resolve to ~/.claude/skills//SKILL.md instead of the project root. This allows injecting globally-installed skills (e.g., shadcn, supabase) into GSD sub-agents without duplicating them into every project. Example config: "agent_skills": { "gsd-executor": ["global:shadcn", "global:supabase-postgres"] } Security: skill names are validated against /^[a-zA-Z0-9_-]+$/ to prevent path traversal. The ~/.claude/skills/ directory is a trusted runtime-controlled location. Project-relative paths continue to use validatePath() containment checks as before. Closes #1992 Co-Authored-By: Claude Opus 4.6 --- get-shit-done/bin/lib/init.cjs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/get-shit-done/bin/lib/init.cjs b/get-shit-done/bin/lib/init.cjs index 0f50fe57c..20a58dfa5 100644 --- a/get-shit-done/bin/lib/init.cjs +++ b/get-shit-done/bin/lib/init.cjs @@ -1470,6 +1470,24 @@ function buildAgentSkillsBlock(config, agentType, projectRoot) { for (const skillPath of skillPaths) { if (typeof skillPath !== 'string') continue; + // Support global: prefix for skills installed at ~/.claude/skills/ (#1992) + if (skillPath.startsWith('global:')) { + const skillName = skillPath.slice(7); + // Sanitize: skill name must be alphanumeric, hyphens, or underscores only + if (!/^[a-zA-Z0-9_-]+$/.test(skillName)) { + process.stderr.write(`[agent-skills] WARNING: Invalid global skill name "${skillName}" — skipping\n`); + continue; + } + const globalSkillDir = path.join(require('os').homedir(), '.claude', 'skills', skillName); + const globalSkillMd = path.join(globalSkillDir, 'SKILL.md'); + if (!fs.existsSync(globalSkillMd)) { + process.stderr.write(`[agent-skills] WARNING: Global skill not found at "~/.claude/skills/${skillName}/SKILL.md" — skipping\n`); + continue; + } + validPaths.push({ ref: `${globalSkillDir}/SKILL.md`, display: `~/.claude/skills/${skillName}` }); + continue; + } + // Validate path safety — must resolve within project root const pathCheck = validatePath(skillPath, projectRoot); if (!pathCheck.safe) { @@ -1484,12 +1502,12 @@ function buildAgentSkillsBlock(config, agentType, projectRoot) { continue; } - validPaths.push(skillPath); + validPaths.push({ ref: `${skillPath}/SKILL.md`, display: skillPath }); } if (validPaths.length === 0) return ''; - const lines = validPaths.map(p => `- @${p}/SKILL.md`).join('\n'); + const lines = validPaths.map(p => `- @${p.ref}`).join('\n'); return `\nRead these user-configured skills:\n${lines}\n`; } From 0a18fc346444553c977e6c89798bbf80446892db Mon Sep 17 00:00:00 2001 From: Tibsfox Date: Sat, 11 Apr 2026 03:39:29 -0700 Subject: [PATCH 2/2] fix(config): global skill symlink guard, tests, and empty-name handling (#1992) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback from @trek-e — three blocking issues and one style fix: 1. **Symlink escape guard** — Added validatePath() call on the resolved global skill path with allowAbsolute: true. This routes the path through the existing symlink-resolution and containment logic in security.cjs, preventing a skill directory symlinked to an arbitrary location from being injected. The name regex alone prevented traversal in the literal name but not in the underlying directory. 2. **5 new tests** covering the global: code path: - global:valid-skill resolves and appears in output - global:invalid!name rejected by regex, skipped without crash - global:missing-skill (directory absent) skipped gracefully - Mix of global: and project-relative paths both resolve - global: with empty name produces clear warning and skips 3. **Explicit empty-name guard** — Added before the regex check so "global:" produces "empty skill name" instead of the confusing 'Invalid global skill name ""'. 4. **Style fix** — Hoisted require('os') and globalSkillsBase calculation out of the loop, alongside the existing validatePath import at the top of buildAgentSkillsBlock. All 16 agent-skills tests pass. --- get-shit-done/bin/lib/init.cjs | 17 ++++++- tests/agent-skills.test.cjs | 87 ++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/get-shit-done/bin/lib/init.cjs b/get-shit-done/bin/lib/init.cjs index 20a58dfa5..68615c288 100644 --- a/get-shit-done/bin/lib/init.cjs +++ b/get-shit-done/bin/lib/init.cjs @@ -1456,6 +1456,8 @@ function cmdInitRemoveWorkspace(cwd, name, raw) { */ function buildAgentSkillsBlock(config, agentType, projectRoot) { const { validatePath } = require('./security.cjs'); + const os = require('os'); + const globalSkillsBase = path.join(os.homedir(), '.claude', 'skills'); if (!config || !config.agent_skills || !agentType) return ''; @@ -1473,17 +1475,30 @@ function buildAgentSkillsBlock(config, agentType, projectRoot) { // Support global: prefix for skills installed at ~/.claude/skills/ (#1992) if (skillPath.startsWith('global:')) { const skillName = skillPath.slice(7); + // Explicit empty-name guard before regex for clearer error message + if (!skillName) { + process.stderr.write(`[agent-skills] WARNING: "global:" prefix with empty skill name — skipping\n`); + continue; + } // Sanitize: skill name must be alphanumeric, hyphens, or underscores only if (!/^[a-zA-Z0-9_-]+$/.test(skillName)) { process.stderr.write(`[agent-skills] WARNING: Invalid global skill name "${skillName}" — skipping\n`); continue; } - const globalSkillDir = path.join(require('os').homedir(), '.claude', 'skills', skillName); + const globalSkillDir = path.join(globalSkillsBase, skillName); const globalSkillMd = path.join(globalSkillDir, 'SKILL.md'); if (!fs.existsSync(globalSkillMd)) { process.stderr.write(`[agent-skills] WARNING: Global skill not found at "~/.claude/skills/${skillName}/SKILL.md" — skipping\n`); continue; } + // Symlink escape guard: validatePath resolves symlinks and enforces + // containment within globalSkillsBase. Prevents a skill directory + // symlinked to an arbitrary location from being injected (#1992). + const pathCheck = validatePath(globalSkillMd, globalSkillsBase, { allowAbsolute: true }); + if (!pathCheck.safe) { + process.stderr.write(`[agent-skills] WARNING: Global skill "${skillName}" failed path check (symlink escape?) — skipping\n`); + continue; + } validPaths.push({ ref: `${globalSkillDir}/SKILL.md`, display: `~/.claude/skills/${skillName}` }); continue; } diff --git a/tests/agent-skills.test.cjs b/tests/agent-skills.test.cjs index 4976a0400..4d2ad5fd1 100644 --- a/tests/agent-skills.test.cjs +++ b/tests/agent-skills.test.cjs @@ -205,3 +205,90 @@ describe('config-set agent_skills', () => { ); }); }); + +// ─── global: prefix support (#1992) ────────────────────────────────────────── + +describe('agent-skills global: prefix', () => { + let tmpDir; + let fakeHome; + let globalSkillsDir; + + beforeEach(() => { + tmpDir = createTempProject(); + // Create a fake HOME with ~/.claude/skills/ structure + fakeHome = fs.mkdtempSync(path.join(require('os').tmpdir(), 'gsd-1992-home-')); + globalSkillsDir = path.join(fakeHome, '.claude', 'skills'); + fs.mkdirSync(globalSkillsDir, { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpDir); + fs.rmSync(fakeHome, { recursive: true, force: true }); + }); + + function createGlobalSkill(name) { + const skillDir = path.join(globalSkillsDir, name); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), `# ${name}\nGlobal skill content.\n`); + return skillDir; + } + + test('global:valid-skill resolves to $HOME/.claude/skills/valid-skill/SKILL.md', () => { + createGlobalSkill('valid-skill'); + writeConfig(tmpDir, { + agent_skills: { 'gsd-executor': ['global:valid-skill'] }, + }); + + const result = runGsdTools(['agent-skills', 'gsd-executor'], tmpDir, { HOME: fakeHome, USERPROFILE: fakeHome }); + assert.ok(result.output.includes('valid-skill/SKILL.md'), `should reference the global skill: ${result.output}`); + assert.ok(result.output.includes(''), 'should emit agent_skills block'); + }); + + test('global:invalid!name is rejected by regex and skipped', () => { + writeConfig(tmpDir, { + agent_skills: { 'gsd-executor': ['global:invalid!name'] }, + }); + + const result = runGsdTools(['agent-skills', 'gsd-executor'], tmpDir, { HOME: fakeHome, USERPROFILE: fakeHome }); + // No valid skills → empty output, command succeeds + assert.strictEqual(result.output, '', 'should skip invalid name without crashing'); + }); + + test('global:missing-skill is skipped when directory is absent', () => { + // Do NOT create the skill directory + writeConfig(tmpDir, { + agent_skills: { 'gsd-executor': ['global:missing-skill'] }, + }); + + const result = runGsdTools(['agent-skills', 'gsd-executor'], tmpDir, { HOME: fakeHome, USERPROFILE: fakeHome }); + assert.strictEqual(result.output, '', 'should skip missing skill gracefully'); + }); + + test('mix of global: and project-relative paths both resolve correctly', () => { + createGlobalSkill('shadcn'); + + // Create a project-relative skill + const projectSkillDir = path.join(tmpDir, 'skills', 'local-skill'); + fs.mkdirSync(projectSkillDir, { recursive: true }); + fs.writeFileSync(path.join(projectSkillDir, 'SKILL.md'), '# local\n'); + + writeConfig(tmpDir, { + agent_skills: { 'gsd-executor': ['global:shadcn', 'skills/local-skill'] }, + }); + + const result = runGsdTools(['agent-skills', 'gsd-executor'], tmpDir, { HOME: fakeHome, USERPROFILE: fakeHome }); + assert.ok(result.output.includes('shadcn/SKILL.md'), 'should include global shadcn'); + assert.ok(result.output.includes('skills/local-skill/SKILL.md'), 'should include project-relative skill'); + }); + + test('global: with empty name produces clear warning and skips', () => { + writeConfig(tmpDir, { + agent_skills: { 'gsd-executor': ['global:'] }, + }); + + const result = runGsdTools(['agent-skills', 'gsd-executor'], tmpDir, { HOME: fakeHome, USERPROFILE: fakeHome }); + assert.strictEqual(result.output, '', 'should skip empty global: prefix'); + // The warning goes to stderr — cannot assert on it through runGsdTools's output field, + // but the command must not crash and must return empty. + }); +});