diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index d8e378fffe..8ef3f76856 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 3e9754b60e..be6fb6c161 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -2844,6 +2844,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 822e1c70be..5f886cb08a 100644 --- a/packages/core/src/config/storage.test.ts +++ b/packages/core/src/config/storage.test.ts @@ -291,6 +291,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; @@ -310,7 +344,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', @@ -336,7 +370,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 ..', @@ -356,7 +390,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 d49e027369..efdb7ce912 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -320,22 +320,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(); }