From dbe3f920a34f1011b08ef58fb272b406560e1cf4 Mon Sep 17 00:00:00 2001 From: Mahima Shanware Date: Tue, 14 Apr 2026 05:40:24 +0000 Subject: [PATCH 1/3] feat(core): add resolveWorkspaceRelativePath and getExtensionSetting utilities --- packages/core/src/config/config.test.ts | 50 ++++++++++++++++++++++ packages/core/src/config/config.ts | 21 ++++++++++ packages/core/src/config/storage.test.ts | 40 ++++++++++++++++-- packages/core/src/config/storage.ts | 53 +++++++++++++++++------- 4 files changed, 147 insertions(+), 17 deletions(-) diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 895d9ca963..4292fa3471 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -1511,6 +1511,56 @@ describe('Server Config (config.ts)', () => { }); }); + describe('getExtensionSetting', () => { + it('returns undefined if the extension does not exist', () => { + const config = new Config(baseParams); + vi.spyOn(config, 'getExtensions').mockReturnValue([]); + expect(config.getExtensionSetting('foo', 'bar')).toBeUndefined(); + }); + + it('returns undefined if the extension has no resolvedSettings', () => { + const config = new Config(baseParams); + vi.spyOn(config, 'getExtensions').mockReturnValue([ + { + name: 'my-ext', + version: '1.0', + isActive: true, + path: '/ext', + contextFiles: [], + id: 'my-ext', + }, + ]); + expect( + config.getExtensionSetting('my-ext', 'some.setting'), + ).toBeUndefined(); + }); + + it('returns the setting value if it exists', () => { + const config = new Config(baseParams); + vi.spyOn(config, 'getExtensions').mockReturnValue([ + { + name: 'my-ext', + version: '1.0', + isActive: true, + path: '/ext', + contextFiles: [], + id: 'my-ext', + resolvedSettings: [ + { + name: 'some.setting', + value: 'custom-val', + envVar: 'MY_EXT_SOME_SETTING', + sensitive: false, + }, + ], + }, + ]); + expect(config.getExtensionSetting('my-ext', 'some.setting')).toBe( + 'custom-val', + ); + }); + }); + describe('getTruncateToolOutputThreshold', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 918b114129..14bc3722b3 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -2900,6 +2900,27 @@ export class Config implements McpContext, AgentLoopContext { return this._extensionLoader.getExtensions(); } + /** + * Retrieves a setting value for a specific extension. + * + * @param extensionName - The name of the extension. + * @param settingName - The name of the setting to retrieve. + */ + getExtensionSetting( + extensionName: string, + settingName: string, + ): string | undefined { + const ext = this.getExtensions().find( + (e) => e.name === extensionName && e.isActive, + ); + if (!ext || !ext.resolvedSettings) { + return undefined; + } + + const setting = ext.resolvedSettings.find((s) => s.name === settingName); + return setting?.value; + } + getExtensionLoader(): ExtensionLoader { return this._extensionLoader; } diff --git a/packages/core/src/config/storage.test.ts b/packages/core/src/config/storage.test.ts index 6b73e0105e..92af723113 100644 --- a/packages/core/src/config/storage.test.ts +++ b/packages/core/src/config/storage.test.ts @@ -312,6 +312,40 @@ describe('Storage – additional helpers', () => { }); }); + describe('resolveWorkspaceRelativePath', () => { + it('resolves a relative path correctly', () => { + expect(storage.resolveWorkspaceRelativePath('foo/bar')).toBe( + path.join(projectRoot, 'foo/bar'), + ); + }); + + it('throws if homedir path escapes workspace', () => { + // In this test, projectRoot is /tmp/project, and homedir is likely outside. + // We expect this to throw an error about escaping the project root. + expect(() => storage.resolveWorkspaceRelativePath('~/foo')).toThrow( + /outside the project root/, + ); + }); + + it('throws if path escapes workspace', () => { + expect(() => storage.resolveWorkspaceRelativePath('../outside')).toThrow( + /outside the project root/, + ); + }); + + it('resolves an absolute path within workspace', () => { + expect( + storage.resolveWorkspaceRelativePath(path.join(projectRoot, 'inner')), + ).toBe(path.join(projectRoot, 'inner')); + }); + + it('throws for an absolute path outside workspace', () => { + expect(() => storage.resolveWorkspaceRelativePath('/tmp/foo')).toThrow( + /outside the project root/, + ); + }); + }); + describe('getPlansDir', () => { interface TestCase { name: string; @@ -331,7 +365,7 @@ describe('Storage – additional helpers', () => { name: 'custom absolute path outside throws', customDir: path.resolve('/absolute/path/to/plans'), expected: '', - expectedError: `Custom plans directory '${path.resolve('/absolute/path/to/plans')}' resolves to '${path.resolve('/absolute/path/to/plans')}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`, + expectedError: `Path '${path.resolve('/absolute/path/to/plans')}' resolves to '${path.resolve('/absolute/path/to/plans')}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`, }, { name: 'absolute path that happens to be inside project root', @@ -357,7 +391,7 @@ describe('Storage – additional helpers', () => { name: 'escaping relative path throws', customDir: '../escaped-plans', expected: '', - expectedError: `Custom plans directory '../escaped-plans' resolves to '${resolveToRealPath(path.resolve(projectRoot, '../escaped-plans'))}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`, + expectedError: `Path '../escaped-plans' resolves to '${resolveToRealPath(path.resolve(projectRoot, '../escaped-plans'))}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`, }, { name: 'hidden directory starting with ..', @@ -377,7 +411,7 @@ describe('Storage – additional helpers', () => { return () => vi.mocked(fs.realpathSync).mockRestore(); }, expected: '', - expectedError: `Custom plans directory 'symlink-to-outside' resolves to '${path.resolve('/outside/project/root')}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`, + expectedError: `Path 'symlink-to-outside' resolves to '${path.resolve('/outside/project/root')}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`, }, ]; diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 5e3aada4e5..f82e1654a5 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -328,22 +328,47 @@ export class Storage { return path.join(this.getProjectTempDir(), 'tracker'); } - getPlansDir(): string { - if (this.customPlansDir) { - const resolvedPath = path.resolve( - this.getProjectRoot(), - this.customPlansDir, - ); - const realProjectRoot = resolveToRealPath(this.getProjectRoot()); - const realResolvedPath = resolveToRealPath(resolvedPath); - - if (!isSubpath(realProjectRoot, realResolvedPath)) { - throw new Error( - `Custom plans directory '${this.customPlansDir}' resolves to '${realResolvedPath}', which is outside the project root '${realProjectRoot}'.`, - ); + /** + * Resolves a path securely relative to the project root. + * Throws if the path attempts to escape the workspace (e.g. via ../). + */ + resolveWorkspaceRelativePath(customPath: string): string { + const isWindows = os.platform() === 'win32'; + // Normalize tilde to homedir + let expandedPath = customPath; + if ( + expandedPath.startsWith('~/') || + (isWindows && expandedPath.startsWith('~\\')) + ) { + const home = homedir(); + if (home) { + expandedPath = path.join(home, expandedPath.slice(2)); } + } else if (expandedPath === '~') { + expandedPath = homedir() || expandedPath; + } - return resolvedPath; + const resolvedPath = path.resolve(this.getProjectRoot(), expandedPath); + const realProjectRoot = resolveToRealPath(this.getProjectRoot()); + + // By enforcing resolveToRealPath, we guarantee symlinks are evaluated. + // If the path doesn't exist, this will throw an error, strictly preventing + // traversal vulnerabilities via missing symlinks or permission gaps. + const realResolvedPath = resolveToRealPath(resolvedPath); + + if (!isSubpath(realProjectRoot, realResolvedPath)) { + throw new Error( + `Path '${customPath}' resolves to '${realResolvedPath}', which is outside the project root '${realProjectRoot}'.`, + ); + } + + return resolvedPath; + } + + getPlansDir(customDir?: string): string { + const dirToResolve = customDir ?? this.customPlansDir; + if (dirToResolve) { + return this.resolveWorkspaceRelativePath(dirToResolve); } return this.getProjectTempPlansDir(); } From eee646603d7b879abdfc1d24b7978943dd39f4cc Mon Sep 17 00:00:00 2001 From: Mahima Shanware Date: Wed, 15 Apr 2026 19:19:02 +0000 Subject: [PATCH 2/3] refactor(core): use extensionId for getExtensionSetting lookup --- packages/core/src/config/config.test.ts | 12 ++++++------ packages/core/src/config/config.ts | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 4292fa3471..a87de43234 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -1522,16 +1522,16 @@ describe('Server Config (config.ts)', () => { const config = new Config(baseParams); vi.spyOn(config, 'getExtensions').mockReturnValue([ { - name: 'my-ext', + name: 'my-ext-name', version: '1.0', isActive: true, path: '/ext', contextFiles: [], - id: 'my-ext', + id: 'my-ext-id', }, ]); expect( - config.getExtensionSetting('my-ext', 'some.setting'), + config.getExtensionSetting('my-ext-id', 'some.setting'), ).toBeUndefined(); }); @@ -1539,12 +1539,12 @@ describe('Server Config (config.ts)', () => { const config = new Config(baseParams); vi.spyOn(config, 'getExtensions').mockReturnValue([ { - name: 'my-ext', + name: 'my-ext-name', version: '1.0', isActive: true, path: '/ext', contextFiles: [], - id: 'my-ext', + id: 'my-ext-id', resolvedSettings: [ { name: 'some.setting', @@ -1555,7 +1555,7 @@ describe('Server Config (config.ts)', () => { ], }, ]); - expect(config.getExtensionSetting('my-ext', 'some.setting')).toBe( + expect(config.getExtensionSetting('my-ext-id', 'some.setting')).toBe( 'custom-val', ); }); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 14bc3722b3..70af6526c9 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -2903,15 +2903,15 @@ export class Config implements McpContext, AgentLoopContext { /** * Retrieves a setting value for a specific extension. * - * @param extensionName - The name of the extension. + * @param extensionId - The ID of the extension. * @param settingName - The name of the setting to retrieve. */ getExtensionSetting( - extensionName: string, + extensionId: string, settingName: string, ): string | undefined { const ext = this.getExtensions().find( - (e) => e.name === extensionName && e.isActive, + (e) => e.id === extensionId && e.isActive, ); if (!ext || !ext.resolvedSettings) { return undefined; From 8da37013aecb918a27a12aae91c25e4384d77cb2 Mon Sep 17 00:00:00 2001 From: Mahima Shanware Date: Fri, 17 Apr 2026 18:22:16 +0000 Subject: [PATCH 3/3] fix(core): add ENOENT fallback to resolveWorkspaceRelativePath for JIT provisioning --- packages/core/src/config/storage.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index f82e1654a5..5e8cfda26b 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -352,9 +352,22 @@ export class Storage { const realProjectRoot = resolveToRealPath(this.getProjectRoot()); // By enforcing resolveToRealPath, we guarantee symlinks are evaluated. - // If the path doesn't exist, this will throw an error, strictly preventing - // traversal vulnerabilities via missing symlinks or permission gaps. - const realResolvedPath = resolveToRealPath(resolvedPath); + // If the path doesn't exist, we fallback to string normalization (JIT provisioning). + let realResolvedPath: string; + try { + realResolvedPath = resolveToRealPath(resolvedPath); + } catch (error: unknown) { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ENOENT' + ) { + realResolvedPath = path.normalize(resolvedPath); + } else { + throw error; + } + } if (!isSubpath(realProjectRoot, realResolvedPath)) { throw new Error(