diff --git a/tests/security.test.cjs b/tests/security.test.cjs index f7e34d523..6433bacae 100644 --- a/tests/security.test.cjs +++ b/tests/security.test.cjs @@ -24,6 +24,8 @@ const { validateFieldName, validateShellArg, validatePromptStructure, + assertWithinRoot, + tryWithinRoot, } = require('../gsd-core/bin/lib/security.cjs'); // ─── Path Traversal Prevention ────────────────────────────────────────────── @@ -1346,3 +1348,148 @@ describe('cross-boundary containment — shared escaping inputs, same rejection }); } }); + +// ─── #4653: assertWithinRoot / tryWithinRoot — narrowed export ────────────── +// +// Phase 3 narrows the public surface: `validatePath` becomes module-internal +// and two new exports appear, both returning a branded ContainedPath. +// `assertWithinRoot` throws on escape (requireSafePath becomes a thin alias +// of it); `tryWithinRoot` returns null on escape. Neither export exists yet +// — this whole block is RED by construction (missing export, not a typo: +// verified against the compiled gsd-core/bin/lib/security.cjs export list, +// which lists only validatePath/loadTrustedGlobalRoots/requireSafePath/ +// scanForInjection/sanitizeForPrompt/sanitizeForDisplay/sanitizeLabel/ +// validateShellArg/safeJsonParse/validatePhaseNumber/validateFieldName/ +// validatePromptStructure). + +describe('assertWithinRoot / tryWithinRoot — narrowed export (#4653)', () => { + const base = '/projects/my-app'; + + describe('assertWithinRoot', () => { + test('returns the resolved path for a contained relative input', () => { + const resolved = assertWithinRoot('src/index.js', base); + assert.equal(resolved, path.resolve(base, 'src/index.js')); + }); + + test('returns the resolved path for an absolute input INSIDE the root when {allowAbsolute:true}', () => { + const resolved = assertWithinRoot(path.join(base, 'src/file.js'), base, null, { allowAbsolute: true }); + assert.equal(resolved, path.resolve(base, 'src/file.js')); + }); + + test('throws on a ../ traversal escaping the root', () => { + assert.throws(() => assertWithinRoot('../../etc/passwd', base)); + }); + + test('throws on an absolute path outside the root even with {allowAbsolute:true}', () => { + assert.throws(() => assertWithinRoot('/etc/passwd', base, null, { allowAbsolute: true })); + }); + + test('throws on a null byte', () => { + assert.throws(() => assertWithinRoot('src/\0evil.js', base)); + }); + + test('throws on empty input', () => { + assert.throws(() => assertWithinRoot('', base)); + }); + + test('throws on non-string input', () => { + assert.throws(() => assertWithinRoot(42, base)); + }); + + test('thrown message uses the label, matching requireSafePath\'s "