diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index 06a263b3..179d2116 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -589,6 +589,9 @@ function setActiveWorkstream(cwd, name) { try { fs.unlinkSync(filePath); } catch {} return; } + if (!/^[a-zA-Z0-9_-]+$/.test(name)) { + throw new Error('Invalid workstream name: must be alphanumeric, hyphens, and underscores only'); + } fs.writeFileSync(filePath, name + '\n', 'utf-8'); } diff --git a/tests/copilot-install.test.cjs b/tests/copilot-install.test.cjs index e8d05d63..b9354cb0 100644 --- a/tests/copilot-install.test.cjs +++ b/tests/copilot-install.test.cjs @@ -617,10 +617,10 @@ describe('copyCommandsAsCopilotSkills', () => { assert.ok(fs.existsSync(path.join(tempDir, 'gsd-help')), 'gsd-help folder exists'); assert.ok(fs.existsSync(path.join(tempDir, 'gsd-progress')), 'gsd-progress folder exists'); - // Count gsd-* directories — should be 31 + // Count gsd-* directories — should be 57 const dirs = fs.readdirSync(tempDir, { withFileTypes: true }) .filter(e => e.isDirectory() && e.name.startsWith('gsd-')); - assert.strictEqual(dirs.length, 56, `expected 56 skill folders, got ${dirs.length}`); + assert.strictEqual(dirs.length, 57, `expected 57 skill folders, got ${dirs.length}`); } finally { fs.rmSync(tempDir, { recursive: true }); } @@ -1114,7 +1114,7 @@ const { execFileSync } = require('child_process'); const crypto = require('crypto'); const INSTALL_PATH = path.join(__dirname, '..', 'bin', 'install.js'); -const EXPECTED_SKILLS = 56; +const EXPECTED_SKILLS = 57; const EXPECTED_AGENTS = 18; function runCopilotInstall(cwd) { diff --git a/tests/workstream.test.cjs b/tests/workstream.test.cjs index e5eab75d..ea27f5ef 100644 --- a/tests/workstream.test.cjs +++ b/tests/workstream.test.cjs @@ -408,12 +408,11 @@ describe('path traversal rejection', () => { for (const name of maliciousNames) { test(`rejects set ${name}`, () => { const result = runGsdTools(['workstream', 'set', name, '--raw'], tmpDir); - // set validates independently — should return error or invalid_name - assert.ok(result.success || !result.success, 'should handle gracefully'); - if (result.success) { - const data = JSON.parse(result.output); - assert.strictEqual(data.error, 'invalid_name', `should return invalid_name error for: ${name}`); - } + // cmdWorkstreamSet validates the positional arg and returns invalid_name error + assert.ok(result.success, `command should exit cleanly for: ${name}`); + const data = JSON.parse(result.output); + assert.strictEqual(data.error, 'invalid_name', `should return invalid_name error for: ${name}`); + assert.strictEqual(data.active, null, `active should be null for: ${name}`); }); } }); @@ -436,4 +435,17 @@ describe('path traversal rejection', () => { try { fs.unlinkSync(path.join(tmpDir, '.planning', 'active-workstream')); } catch {} }); }); + + describe('setActiveWorkstream rejects invalid names directly', () => { + const { setActiveWorkstream } = require('../get-shit-done/bin/lib/core.cjs'); + for (const name of maliciousNames) { + test(`throws for ${name}`, () => { + assert.throws( + () => setActiveWorkstream(tmpDir, name), + { message: /Invalid workstream name/ }, + `should throw for: ${name}` + ); + }); + } + }); });