diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..91e6c621 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,94 @@ +# Security Policy + +Thank you for your interest in the security of Maestro. We welcome contributions from security researchers and the broader community to help keep this project safe. + +## Reporting a Vulnerability + +### For Most Issues +Please open a [GitHub issue](https://github.com/pedramamini/Maestro/issues) with the `security` label. Include: +- A clear description of the vulnerability +- Steps to reproduce +- Potential impact +- Suggested fix (if you have one) + +### For Serious/Critical Issues +If you discover a vulnerability that could cause significant harm if disclosed publicly before a fix is available, please contact us directly: + +**Email:** pedram@runmaestro.ai + +This allows us to develop and release a patch before public disclosure. + +## Scope + +### In Scope +- Maestro application code (Electron main process, renderer, preload scripts) +- IPC handler security +- Process spawning and command execution +- Local file system access +- Web server and tunnel functionality +- Authentication and session management + +### Out of Scope +The following are **not** in scope for Maestro security reports: +- **AI agent vulnerabilities** - Security issues within Claude Code, OpenAI Codex, Gemini CLI, Qwen3 Coder, or other integrated agents are the responsibility of their respective maintainers +- **Upstream dependencies** - Vulnerabilities in Electron, Node.js, or npm packages should be reported to those projects (though please let us know if Maestro is using a vulnerable version) +- **Social engineering attacks** +- **Denial of service on local application** +- **Issues requiring physical access to the user's machine** + +## Response Timeline + +We aim to respond to security reports as soon as possible. However, please understand that Maestro is an open source side project maintained by volunteers. Until there is a larger developer community behind it, we cannot commit to specific response timelines. + +What you can expect: +- Acknowledgment of your report +- Assessment of severity and impact +- A fix prioritized based on severity +- Credit in our release notes (unless you prefer to remain anonymous) + +## Recognition + +We appreciate security researchers who help improve Maestro. Contributors who report valid security issues will be: +- Credited in release notes and this document (with permission) +- Thanked publicly (unless anonymity is preferred) + +**Security Contributors:** +- *Your name could be here!* + +## Bug Bounty + +There is no bug bounty program at this time. Maestro is an open source project without funding for monetary rewards. We hope the satisfaction of contributing to open source security and public recognition is sufficient motivation. + +We also welcome pull requests! If you find a vulnerability and know how to fix it, PRs are greatly appreciated. + +## Known Security Considerations + +The following are known aspects of Maestro's design that users should be aware of: + +### Process Execution +Maestro spawns AI agents and terminal processes with the same privileges as the user running the application. This is by design—the agents need filesystem and command access to function. Users should: +- Only run Maestro on projects they trust +- Be aware that AI agents can execute commands on your system +- Review agent actions, especially on sensitive repositories + +### Local Web Server +When the web/mobile interface is enabled, Maestro runs a local web server. The Cloudflare tunnel feature can expose this externally. Users should: +- Only enable tunnels when needed +- Be aware of who has access to tunnel URLs + +### IPC Security +Maestro uses Electron's IPC for communication between the main process and renderer. We follow Electron security best practices including context isolation and a minimal preload API surface. + +### Sentry DSN +The Sentry DSN in the codebase is a **public secret by design**. This is standard practice for client-side error reporting—the DSN is intentionally exposed to allow error telemetry. Reporting this as a vulnerability is not necessary. We monitor for abuse and will rotate keys if needed. + +## Security Best Practices in Codebase + +For contributors, Maestro enforces these security patterns: + +- **Always use `execFileNoThrow`** for external commands—never shell-based execution +- **Validate all IPC inputs** in main process handlers +- **Minimize preload API surface**—only expose what's necessary +- **Sanitize file paths** before filesystem operations + +See [CLAUDE.md](CLAUDE.md) and [CONTRIBUTING.md](CONTRIBUTING.md) for more details. diff --git a/src/__tests__/renderer/components/SettingsModal.test.tsx b/src/__tests__/renderer/components/SettingsModal.test.tsx index 015a3407..c38fc5bb 100644 --- a/src/__tests__/renderer/components/SettingsModal.test.tsx +++ b/src/__tests__/renderer/components/SettingsModal.test.tsx @@ -253,8 +253,8 @@ describe('SettingsModal', () => { await vi.advanceTimersByTimeAsync(50); }); - // General tab content should show the Default AI Agent label - expect(screen.getByText('Default AI Agent')).toBeInTheDocument(); + // General tab content should show the Font Size label + expect(screen.getByText('Font Size')).toBeInTheDocument(); }); it('should respect initialTab prop', async () => { @@ -326,7 +326,7 @@ describe('SettingsModal', () => { }); // Start on general tab - expect(screen.getByText('Default AI Agent')).toBeInTheDocument(); + expect(screen.getByText('Font Size')).toBeInTheDocument(); // Press Cmd+Shift+] to go to shortcuts fireEvent.keyDown(window, { key: ']', metaKey: true, shiftKey: true }); @@ -355,7 +355,7 @@ describe('SettingsModal', () => { await vi.advanceTimersByTimeAsync(100); }); - expect(screen.getByText('Default AI Agent')).toBeInTheDocument(); + expect(screen.getByText('Font Size')).toBeInTheDocument(); }); it('should wrap around when navigating past last tab', async () => { @@ -375,7 +375,7 @@ describe('SettingsModal', () => { await vi.advanceTimersByTimeAsync(100); }); - expect(screen.getByText('Default AI Agent')).toBeInTheDocument(); + expect(screen.getByText('Font Size')).toBeInTheDocument(); }); it('should wrap around when navigating before first tab', async () => { @@ -386,7 +386,7 @@ describe('SettingsModal', () => { }); // Start on general tab (first tab) - expect(screen.getByText('Default AI Agent')).toBeInTheDocument(); + expect(screen.getByText('Font Size')).toBeInTheDocument(); // Press Cmd+Shift+[ to wrap to AI Commands fireEvent.keyDown(window, { key: '[', metaKey: true, shiftKey: true }); @@ -418,64 +418,6 @@ describe('SettingsModal', () => { }); }); - describe('General tab - Agent settings', () => { - it('should load agents on modal open', async () => { - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - expect(window.maestro.agents.detect).toHaveBeenCalled(); - }); - - it('should display available agents', async () => { - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - expect(screen.getByText('Claude Code')).toBeInTheDocument(); - expect(screen.getByText('OpenAI Codex')).toBeInTheDocument(); - }); - - it('should show Available badge for available agents', async () => { - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - expect(screen.getByText('Available')).toBeInTheDocument(); - }); - - it('should call setDefaultAgent when agent is selected', async () => { - const setDefaultAgent = vi.fn(); - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - const agentButton = screen.getByText('Claude Code').closest('button'); - fireEvent.click(agentButton!); - - expect(setDefaultAgent).toHaveBeenCalledWith('claude-code'); - }); - - it('should disable agents that are not claude-code or not available', async () => { - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - const codexButton = screen.getByText('OpenAI Codex').closest('button'); - expect(codexButton).toBeDisabled(); - }); - }); - describe('General tab - Font settings', () => { it('should show font loading message initially', async () => { render(); @@ -1265,52 +1207,7 @@ describe('SettingsModal', () => { }); }); - describe('agent custom paths', () => { - it('should display custom path input for claude-code', async () => { - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - expect(screen.getByPlaceholderText('/path/to/claude')).toBeInTheDocument(); - }); - - it('should save custom path on blur', async () => { - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - const customPathInput = screen.getByPlaceholderText('/path/to/claude'); - fireEvent.change(customPathInput, { target: { value: '/custom/path/claude' } }); - fireEvent.blur(customPathInput); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - expect((window.maestro as any).agents.setCustomPath).toHaveBeenCalledWith('claude-code', '/custom/path/claude'); - }); - }); - describe('edge cases', () => { - it('should handle agent detection failure gracefully', async () => { - vi.mocked(window.maestro.agents.detect).mockRejectedValue(new Error('Detection failed')); - - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - expect(consoleSpy).toHaveBeenCalled(); - consoleSpy.mockRestore(); - }); - it('should handle font detection failure gracefully', async () => { (window.maestro as any).fonts.detect.mockRejectedValue(new Error('Font detection failed')); @@ -1844,28 +1741,4 @@ describe('SettingsModal', () => { expect(window.maestro.shells.detect).toHaveBeenCalledTimes(1); }); }); - - describe('Custom agent path clear button', () => { - it('should clear custom agent path when clear button is clicked', async () => { - (window.maestro as any).agents.getAllCustomPaths.mockResolvedValue({ 'claude-code': '/custom/path/to/claude' }); - - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - // Find the Clear button in the agent path section - const clearButtons = screen.getAllByText('Clear'); - const agentPathClearButton = clearButtons[0]; // First Clear button is for agent path - - fireEvent.click(agentPathClearButton); - - await act(async () => { - await vi.advanceTimersByTimeAsync(100); - }); - - expect((window.maestro as any).agents.setCustomPath).toHaveBeenCalledWith('claude-code', null); - }); - }); }); diff --git a/src/__tests__/renderer/components/TabSwitcherModal.test.tsx b/src/__tests__/renderer/components/TabSwitcherModal.test.tsx index f633efc7..c0adf8fc 100644 --- a/src/__tests__/renderer/components/TabSwitcherModal.test.tsx +++ b/src/__tests__/renderer/components/TabSwitcherModal.test.tsx @@ -85,7 +85,7 @@ describe('TabSwitcherModal', () => { Element.prototype.scrollIntoView = vi.fn(); // Reset the mocks for each test - vi.mocked(window.maestro.claude.getAllNamedSessions).mockResolvedValue([]); + vi.mocked(window.maestro.agentSessions.getAllNamedSessions).mockResolvedValue([]); vi.mocked(window.maestro.agentSessions.updateSessionName).mockResolvedValue(undefined); }); @@ -868,7 +868,7 @@ describe('TabSwitcherModal', () => { it('switches to All Named mode on pill click', async () => { const tabs = [createTestTab({ name: 'Open Tab' })]; - vi.mocked(window.maestro.claude.getAllNamedSessions).mockResolvedValue([ + vi.mocked(window.maestro.agentSessions.getAllNamedSessions).mockResolvedValue([ { agentSessionId: 'closed-123-abc-def-789', projectPath: '/test', @@ -891,7 +891,7 @@ describe('TabSwitcherModal', () => { // Wait for named sessions to load await waitFor(() => { - expect(window.maestro.claude.getAllNamedSessions).toHaveBeenCalled(); + expect(window.maestro.agentSessions.getAllNamedSessions).toHaveBeenCalled(); }); // Click All Named pill @@ -927,7 +927,7 @@ describe('TabSwitcherModal', () => { }); it('shows "Closed" badge for closed named sessions', async () => { - vi.mocked(window.maestro.claude.getAllNamedSessions).mockResolvedValue([ + vi.mocked(window.maestro.agentSessions.getAllNamedSessions).mockResolvedValue([ { agentSessionId: 'closed-session-id', projectPath: '/test', @@ -949,7 +949,7 @@ describe('TabSwitcherModal', () => { // Wait for load await waitFor(() => { - expect(window.maestro.claude.getAllNamedSessions).toHaveBeenCalled(); + expect(window.maestro.agentSessions.getAllNamedSessions).toHaveBeenCalled(); }); fireEvent.click(screen.getByText(/All Named/)); @@ -960,7 +960,7 @@ describe('TabSwitcherModal', () => { }); it('filters named sessions by current project', async () => { - vi.mocked(window.maestro.claude.getAllNamedSessions).mockResolvedValue([ + vi.mocked(window.maestro.agentSessions.getAllNamedSessions).mockResolvedValue([ { agentSessionId: 'same-project-id', projectPath: '/test', @@ -986,7 +986,7 @@ describe('TabSwitcherModal', () => { ); await waitFor(() => { - expect(window.maestro.claude.getAllNamedSessions).toHaveBeenCalled(); + expect(window.maestro.agentSessions.getAllNamedSessions).toHaveBeenCalled(); }); fireEvent.click(screen.getByText(/All Named/)); @@ -1001,7 +1001,7 @@ describe('TabSwitcherModal', () => { const starredTab = createTestTab({ name: 'Starred Tab', starred: true }); const unstarredTab = createTestTab({ name: 'Unstarred Tab', starred: false }); - vi.mocked(window.maestro.claude.getAllNamedSessions).mockResolvedValue([ + vi.mocked(window.maestro.agentSessions.getAllNamedSessions).mockResolvedValue([ { agentSessionId: 'starred-closed-123', projectPath: '/test', @@ -1029,7 +1029,7 @@ describe('TabSwitcherModal', () => { ); await waitFor(() => { - expect(window.maestro.claude.getAllNamedSessions).toHaveBeenCalled(); + expect(window.maestro.agentSessions.getAllNamedSessions).toHaveBeenCalled(); }); // Click Starred pill (use exact pattern to avoid matching list items) @@ -1049,7 +1049,7 @@ describe('TabSwitcherModal', () => { }); it('shows "No starred sessions" when there are no starred items', async () => { - vi.mocked(window.maestro.claude.getAllNamedSessions).mockResolvedValue([]); + vi.mocked(window.maestro.agentSessions.getAllNamedSessions).mockResolvedValue([]); renderWithLayerStack( { ); await waitFor(() => { - expect(window.maestro.claude.getAllNamedSessions).toHaveBeenCalled(); + expect(window.maestro.agentSessions.getAllNamedSessions).toHaveBeenCalled(); }); fireEvent.click(screen.getByRole('button', { name: /Starred \(\d+\)/ })); @@ -1079,7 +1079,7 @@ describe('TabSwitcherModal', () => { const starredTab2 = createTestTab({ name: 'Starred 2', starred: true }); const unstarredTab = createTestTab({ name: 'Unstarred', starred: false }); - vi.mocked(window.maestro.claude.getAllNamedSessions).mockResolvedValue([ + vi.mocked(window.maestro.agentSessions.getAllNamedSessions).mockResolvedValue([ { agentSessionId: 'starred-closed-abc', projectPath: '/test', @@ -1101,7 +1101,7 @@ describe('TabSwitcherModal', () => { ); await waitFor(() => { - expect(window.maestro.claude.getAllNamedSessions).toHaveBeenCalled(); + expect(window.maestro.agentSessions.getAllNamedSessions).toHaveBeenCalled(); }); // Should show count of 3: 2 open starred + 1 closed starred @@ -1555,7 +1555,7 @@ describe('TabSwitcherModal', () => { }); it('calls onNamedSessionSelect when clicking a closed named session', async () => { - vi.mocked(window.maestro.claude.getAllNamedSessions).mockResolvedValue([ + vi.mocked(window.maestro.agentSessions.getAllNamedSessions).mockResolvedValue([ { agentSessionId: 'closed-abc-123', projectPath: '/test', @@ -1580,7 +1580,7 @@ describe('TabSwitcherModal', () => { ); await waitFor(() => { - expect(window.maestro.claude.getAllNamedSessions).toHaveBeenCalled(); + expect(window.maestro.agentSessions.getAllNamedSessions).toHaveBeenCalled(); }); fireEvent.click(screen.getByText(/All Named/));