diff --git a/apps/ui/src/components/dialogs/index.ts b/apps/ui/src/components/dialogs/index.ts index 4cadb26d..dd2597f5 100644 --- a/apps/ui/src/components/dialogs/index.ts +++ b/apps/ui/src/components/dialogs/index.ts @@ -3,4 +3,6 @@ export { DeleteAllArchivedSessionsDialog } from './delete-all-archived-sessions- export { DeleteSessionDialog } from './delete-session-dialog'; export { FileBrowserDialog } from './file-browser-dialog'; export { NewProjectModal } from './new-project-modal'; +export { SandboxRejectionScreen } from './sandbox-rejection-screen'; +export { SandboxRiskDialog } from './sandbox-risk-dialog'; export { WorkspacePickerModal } from './workspace-picker-modal'; diff --git a/apps/ui/src/components/dialogs/sandbox-rejection-screen.tsx b/apps/ui/src/components/dialogs/sandbox-rejection-screen.tsx new file mode 100644 index 00000000..1e057836 --- /dev/null +++ b/apps/ui/src/components/dialogs/sandbox-rejection-screen.tsx @@ -0,0 +1,53 @@ +/** + * Sandbox Rejection Screen + * + * Shown in web mode when user denies the sandbox risk confirmation. + * Prompts them to either restart the app in a container or reload to try again. + */ + +import { ShieldX, RefreshCw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +export function SandboxRejectionScreen() { + const handleReload = () => { + // Clear the rejection state and reload + sessionStorage.removeItem('automaker-sandbox-denied'); + window.location.reload(); + }; + + return ( +
+
+
+
+ +
+
+ +
+

Access Denied

+

+ You declined to accept the risks of running Automaker outside a sandbox environment. +

+
+ +

+ For safer operation, consider running Automaker in Docker. See the README for + instructions. +

+ +
+ +
+
+
+ ); +} diff --git a/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx b/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx new file mode 100644 index 00000000..3a5f6d35 --- /dev/null +++ b/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx @@ -0,0 +1,108 @@ +/** + * Sandbox Risk Confirmation Dialog + * + * Shows when the app is running outside a containerized environment. + * Users must acknowledge the risks before proceeding. + */ + +import { useState } from 'react'; +import { ShieldAlert } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Label } from '@/components/ui/label'; + +interface SandboxRiskDialogProps { + open: boolean; + onConfirm: (skipInFuture: boolean) => void; + onDeny: () => void; +} + +export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialogProps) { + const [skipInFuture, setSkipInFuture] = useState(false); + + const handleConfirm = () => { + onConfirm(skipInFuture); + // Reset checkbox state after confirmation + setSkipInFuture(false); + }; + + return ( + {}}> + e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + showCloseButton={false} + > + + + + Sandbox Environment Not Detected + + +
+

+ Warning: This application is running outside of a containerized + sandbox environment. AI agents will have direct access to your filesystem and can + execute commands on your system. +

+ +
+

Potential Risks:

+
    +
  • Agents can read, modify, or delete files on your system
  • +
  • Agents can execute arbitrary commands and install software
  • +
  • Agents can access environment variables and credentials
  • +
  • Unintended side effects from agent actions may affect your system
  • +
+
+ +

+ For safer operation, consider running Automaker in Docker. See the README for + instructions. +

+
+
+
+ + +
+ setSkipInFuture(checked === true)} + data-testid="sandbox-skip-checkbox" + /> + +
+
+ + +
+
+
+
+ ); +} diff --git a/apps/ui/src/components/views/settings-view.tsx b/apps/ui/src/components/views/settings-view.tsx index 8f016a4d..64e71134 100644 --- a/apps/ui/src/components/views/settings-view.tsx +++ b/apps/ui/src/components/views/settings-view.tsx @@ -52,6 +52,8 @@ export function SettingsView() { setAutoLoadClaudeMd, promptCustomization, setPromptCustomization, + skipSandboxWarning, + setSkipSandboxWarning, } = useAppStore(); // Convert electron Project to settings-view Project type @@ -149,6 +151,8 @@ export function SettingsView() { setShowDeleteDialog(true)} + skipSandboxWarning={skipSandboxWarning} + onResetSandboxWarning={() => setSkipSandboxWarning(false)} /> ); default: diff --git a/apps/ui/src/components/views/settings-view/danger-zone/danger-zone-section.tsx b/apps/ui/src/components/views/settings-view/danger-zone/danger-zone-section.tsx index 08d3ea6f..0a1d6ed9 100644 --- a/apps/ui/src/components/views/settings-view/danger-zone/danger-zone-section.tsx +++ b/apps/ui/src/components/views/settings-view/danger-zone/danger-zone-section.tsx @@ -1,14 +1,21 @@ import { Button } from '@/components/ui/button'; -import { Trash2, Folder, AlertTriangle } from 'lucide-react'; +import { Trash2, Folder, AlertTriangle, Shield, RotateCcw } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { Project } from '../shared/types'; interface DangerZoneSectionProps { project: Project | null; onDeleteClick: () => void; + skipSandboxWarning: boolean; + onResetSandboxWarning: () => void; } -export function DangerZoneSection({ project, onDeleteClick }: DangerZoneSectionProps) { +export function DangerZoneSection({ + project, + onDeleteClick, + skipSandboxWarning, + onResetSandboxWarning, +}: DangerZoneSectionProps) { return (
+ {/* Sandbox Warning Reset */} + {skipSandboxWarning && ( +
+
+
+ +
+
+

Sandbox Warning Disabled

+

+ The sandbox environment warning is hidden on startup +

+
+
+ +
+ )} + {/* Project Delete */} {project && (
@@ -60,7 +97,7 @@ export function DangerZoneSection({ project, onDeleteClick }: DangerZoneSectionP )} {/* Empty state when nothing to show */} - {!project && ( + {!skipSandboxWarning && !project && (

No danger zone actions available.

diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index 6c0d096d..728293d3 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -479,6 +479,7 @@ function hydrateStoreFromSettings(settings: GlobalSettings): void { enabledCursorModels: settings.enabledCursorModels ?? current.enabledCursorModels, cursorDefaultModel: settings.cursorDefaultModel ?? 'auto', autoLoadClaudeMd: settings.autoLoadClaudeMd ?? false, + skipSandboxWarning: settings.skipSandboxWarning ?? false, keyboardShortcuts: { ...current.keyboardShortcuts, ...(settings.keyboardShortcuts as unknown as Partial), @@ -535,6 +536,7 @@ function buildSettingsUpdateFromStore(): Record { validationModel: state.validationModel, phaseModels: state.phaseModels, autoLoadClaudeMd: state.autoLoadClaudeMd, + skipSandboxWarning: state.skipSandboxWarning, keyboardShortcuts: state.keyboardShortcuts, aiProfiles: state.aiProfiles, mcpServers: state.mcpServers, diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 8d4188ff..f01d67cf 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -379,6 +379,32 @@ export const verifySession = async (): Promise => { } }; +/** + * Check if the server is running in a containerized (sandbox) environment. + * This endpoint is unauthenticated so it can be checked before login. + */ +export const checkSandboxEnvironment = async (): Promise<{ + isContainerized: boolean; + error?: string; +}> => { + try { + const response = await fetch(`${getServerUrl()}/api/health/environment`, { + method: 'GET', + }); + + if (!response.ok) { + logger.warn('Failed to check sandbox environment'); + return { isContainerized: false, error: 'Failed to check environment' }; + } + + const data = await response.json(); + return { isContainerized: data.isContainerized ?? false }; + } catch (error) { + logger.error('Sandbox environment check failed:', error); + return { isContainerized: false, error: 'Network error' }; + } +}; + type EventType = | 'agent:stream' | 'auto-mode:event' diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index c253ffa2..502aba11 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -16,19 +16,28 @@ import { initApiKey, isElectronMode, verifySession, + checkSandboxEnvironment, getServerUrlSync, checkExternalServerMode, isExternalServerMode, } from '@/lib/http-api-client'; import { Toaster } from 'sonner'; import { ThemeOption, themeOptions } from '@/config/theme-options'; +import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog'; +import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen'; import { LoadingState } from '@/components/ui/loading-state'; const logger = createLogger('RootLayout'); function RootLayoutContent() { const location = useLocation(); - const { setIpcConnected, currentProject, getEffectiveTheme } = useAppStore(); + const { + setIpcConnected, + currentProject, + getEffectiveTheme, + skipSandboxWarning, + setSkipSandboxWarning, + } = useAppStore(); const { setupComplete } = useSetupStore(); const navigate = useNavigate(); const [isMounted, setIsMounted] = useState(false); @@ -44,6 +53,12 @@ function RootLayoutContent() { const isSetupRoute = location.pathname === '/setup'; const isLoginRoute = location.pathname === '/login'; + // Sandbox environment check state + type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed'; + // Always start from pending on a fresh page load so the user sees the prompt + // each time the app is launched/refreshed (unless running in a container). + const [sandboxStatus, setSandboxStatus] = useState('pending'); + // Hidden streamer panel - opens with "\" key const handleStreamerPanelShortcut = useCallback((event: KeyboardEvent) => { const activeElement = document.activeElement; @@ -90,6 +105,73 @@ function RootLayoutContent() { setIsMounted(true); }, []); + // Check sandbox environment on mount + useEffect(() => { + // Skip if already decided + if (sandboxStatus !== 'pending') { + return; + } + + const checkSandbox = async () => { + try { + const result = await checkSandboxEnvironment(); + + if (result.isContainerized) { + // Running in a container, no warning needed + setSandboxStatus('containerized'); + } else if (skipSandboxWarning) { + // User opted to skip the warning, auto-confirm + setSandboxStatus('confirmed'); + } else { + // Not containerized, show warning dialog + setSandboxStatus('needs-confirmation'); + } + } catch (error) { + logger.error('Failed to check environment:', error); + // On error, assume not containerized and show warning + if (skipSandboxWarning) { + setSandboxStatus('confirmed'); + } else { + setSandboxStatus('needs-confirmation'); + } + } + }; + + checkSandbox(); + }, [sandboxStatus, skipSandboxWarning]); + + // Handle sandbox risk confirmation + const handleSandboxConfirm = useCallback( + (skipInFuture: boolean) => { + if (skipInFuture) { + setSkipSandboxWarning(true); + } + setSandboxStatus('confirmed'); + }, + [setSkipSandboxWarning] + ); + + // Handle sandbox risk denial + const handleSandboxDeny = useCallback(async () => { + if (isElectron()) { + // In Electron mode, quit the application + // Use window.electronAPI directly since getElectronAPI() returns the HTTP client + try { + const electronAPI = window.electronAPI; + if (electronAPI?.quit) { + await electronAPI.quit(); + } else { + logger.error('quit() not available on electronAPI'); + } + } catch (error) { + logger.error('Failed to quit app:', error); + } + } else { + // In web mode, show rejection screen + setSandboxStatus('denied'); + } + }, []); + // Ref to prevent concurrent auth checks from running const authCheckRunning = useRef(false); @@ -234,12 +316,28 @@ function RootLayoutContent() { } }, [deferredTheme]); + // Show sandbox rejection screen if user denied the risk warning + if (sandboxStatus === 'denied') { + return ; + } + + // Show sandbox risk dialog if not containerized and user hasn't confirmed + // The dialog is rendered as an overlay while the main content is blocked + const showSandboxDialog = sandboxStatus === 'needs-confirmation'; + // Show login page (full screen, no sidebar) if (isLoginRoute) { return ( -
- -
+ <> +
+ +
+ + ); } @@ -275,30 +373,37 @@ function RootLayoutContent() { } return ( -
- {/* Full-width titlebar drag region for Electron window dragging */} - {isElectron() && ( + <> +
+ {/* Full-width titlebar drag region for Electron window dragging */} + {isElectron() && ( +
+ - -
+ ); } diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 03cee293..a3915fd1 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -511,6 +511,7 @@ export interface AppState { // Claude Agent SDK Settings autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option + skipSandboxWarning: boolean; // Skip the sandbox environment warning dialog on startup // MCP Servers mcpServers: MCPServerConfig[]; // List of configured MCP servers for agent use @@ -816,6 +817,7 @@ export interface AppActions { // Claude Agent SDK Settings actions setAutoLoadClaudeMd: (enabled: boolean) => Promise; + setSkipSandboxWarning: (skip: boolean) => Promise; // Prompt Customization actions setPromptCustomization: (customization: PromptCustomization) => Promise; @@ -1036,6 +1038,7 @@ const initialState: AppState = { enabledCursorModels: getAllCursorModelIds(), // All Cursor models enabled by default cursorDefaultModel: 'auto', // Default to auto selection autoLoadClaudeMd: false, // Default to disabled (user must opt-in) + skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog) mcpServers: [], // No MCP servers configured by default promptCustomization: {}, // Empty by default - all prompts use built-in defaults aiProfiles: DEFAULT_AI_PROFILES, @@ -1734,6 +1737,17 @@ export const useAppStore = create()((set, get) => ({ set({ autoLoadClaudeMd: previous }); } }, + setSkipSandboxWarning: async (skip) => { + const previous = get().skipSandboxWarning; + set({ skipSandboxWarning: skip }); + // Sync to server settings file + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + const ok = await syncSettingsToServer(); + if (!ok) { + logger.error('Failed to sync skipSandboxWarning setting to server - reverting'); + set({ skipSandboxWarning: previous }); + } + }, // Prompt Customization actions setPromptCustomization: async (customization) => { set({ promptCustomization: customization }); diff --git a/apps/ui/src/styles/global.css b/apps/ui/src/styles/global.css index a335ebd0..70d6a0f6 100644 --- a/apps/ui/src/styles/global.css +++ b/apps/ui/src/styles/global.css @@ -367,6 +367,17 @@ background-color: var(--background); } + /* Text selection styling for readability */ + ::selection { + background-color: var(--primary); + color: var(--primary-foreground); + } + + ::-moz-selection { + background-color: var(--primary); + color: var(--primary-foreground); + } + /* Ensure all clickable elements show pointer cursor */ button:not(:disabled), [role='button']:not([aria-disabled='true']), diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 6cce2b9b..d8b0dab2 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -486,6 +486,8 @@ export interface GlobalSettings { // Claude Agent SDK Settings /** Auto-load CLAUDE.md files using SDK's settingSources option */ autoLoadClaudeMd?: boolean; + /** Skip the sandbox environment warning dialog on startup */ + skipSandboxWarning?: boolean; // MCP Server Configuration /** List of configured MCP servers for agent use */