use std::path::PathBuf; use uuid::Uuid; use warp_util::path::EscapeChar; use warpui::{App, EntityId, ModelHandle}; use warp_core::execution_mode::ExecutionMode; use crate::ai::active_agent_views_model::ActiveAgentViewsModel; use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel; use crate::{ ai::{ agent::conversation::AIConversationId, blocklist::{ permissions::{ CommandExecutionPermission, CommandExecutionPermissionDeniedReason, FileReadPermission, FileReadPermissionAllowedReason, FileReadPermissionDeniedReason, FileWritePermission, FileWritePermissionAllowedReason, FileWritePermissionDeniedReason, }, CommandExecutionPermissionAllowedReason, }, execution_profiles::{ profiles::AIExecutionProfilesModel, ActionPermission, WriteToPtyPermission, }, mcp::templatable_manager::TemplatableMCPServerManager, }, auth::AuthStateProvider, cloud_object::model::persistence::CloudModel, network::NetworkStatus, server::{cloud_objects::update_manager::UpdateManager, sync_queue::SyncQueue}, settings::{AgentModeCommandExecutionPredicate, PrivacySettings}, test_util::settings::initialize_settings_for_tests_with_mode, workspaces::{ team_tester::TeamTesterStatus, user_workspaces::UserWorkspaces, workspace::SandboxedAgentSettings, }, AgentNotificationsModel, GlobalResourceHandles, GlobalResourceHandlesProvider, LaunchMode, }; use super::{BlocklistAIHistoryModel, BlocklistAIPermissions}; struct PermissionsTestState { convo_id: AIConversationId, permissions: ModelHandle, history: ModelHandle, terminal_view_id: EntityId, user_workspaces: ModelHandle, profile_model: ModelHandle, } fn initialize_permissions_test(app: &mut App) -> PermissionsTestState { initialize_permissions_test_with_mode(app, ExecutionMode::App, false) } fn initialize_permissions_test_sandboxed(app: &mut App) -> PermissionsTestState { let state = initialize_permissions_test_with_mode(app, ExecutionMode::Sdk, true); state.profile_model.update(app, |model, ctx| { let profile_id = *model.default_profile(ctx).id(); model.apply_cli_profile_defaults_for_test(profile_id, true, ctx); }); state } fn initialize_permissions_test_with_mode( app: &mut App, mode: ExecutionMode, is_sandboxed: bool, ) -> PermissionsTestState { initialize_settings_for_tests_with_mode(app, mode, is_sandboxed); let global_resource_handles = GlobalResourceHandles::mock(app); app.add_singleton_model(|_| GlobalResourceHandlesProvider::new(global_resource_handles)); let history = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[])); app.add_singleton_model(|_| CLIAgentSessionsModel::new()); app.add_singleton_model(|_| ActiveAgentViewsModel::new()); app.add_singleton_model(AgentNotificationsModel::new); let permissions = app.add_singleton_model(BlocklistAIPermissions::new); let terminal_view_id = EntityId::new(); app.add_singleton_model(|_| AuthStateProvider::new_for_test()); app.add_singleton_model(SyncQueue::mock); app.add_singleton_model(|_| NetworkStatus::new()); app.add_singleton_model(TeamTesterStatus::mock); app.add_singleton_model(UpdateManager::mock); app.add_singleton_model(CloudModel::mock); app.add_singleton_model(|_| TemplatableMCPServerManager::default()); let profile_model = app.add_singleton_model(|ctx| { AIExecutionProfilesModel::new(&LaunchMode::new_for_unit_test(), ctx) }); app.add_singleton_model(PrivacySettings::mock); let user_workspaces = app.add_singleton_model(UserWorkspaces::default_mock); let conversation_id = history.update(app, |history_model, ctx| { history_model.start_new_conversation(terminal_view_id, false, false, ctx) }); PermissionsTestState { convo_id: conversation_id, permissions, history, terminal_view_id, user_workspaces, profile_model, } } #[test] fn test_can_read_files_empty_paths() { App::test((), |mut app| async move { let PermissionsTestState { convo_id, permissions, terminal_view_id, .. } = initialize_permissions_test(&mut app); permissions.read(&app, |model, ctx| { let result = model.can_read_files_with_conversation( &convo_id, vec![], Some(terminal_view_id), ctx, ); assert!(result.is_allowed()); assert!(matches!( result, FileReadPermission::Allowed(FileReadPermissionAllowedReason::ExplicitlyAllowlisted) )); }); }) } #[test] fn test_can_read_files_workspace_settings_override_profile() { App::test((), |mut app| async move { let PermissionsTestState { convo_id, permissions, user_workspaces, profile_model, terminal_view_id, .. } = initialize_permissions_test(&mut app); profile_model.update(&mut app, |model, ctx| { model.set_read_files( *model.active_profile(Some(terminal_view_id), ctx).id(), &ActionPermission::AlwaysAllow, ctx, ); }); permissions.read(&app, |model, ctx| { let result = model.can_read_files_with_conversation( &convo_id, vec![PathBuf::from("/test/file.txt")], Some(terminal_view_id), ctx, ); assert!(result.is_allowed()); assert!(matches!( result, FileReadPermission::Allowed( FileReadPermissionAllowedReason::AutoreadSettingEnabled ) )); }); // Now set the workspace to AlwaysAsk user_workspaces.update(&mut app, |model, ctx| { model.setup_test_workspace(ctx); model.update_ai_autonomy_settings( |settings| { settings.read_files_setting = Some(ActionPermission::AlwaysAsk); }, ctx, ); }); permissions.read(&app, |model, ctx| { let result = model.can_read_files_with_conversation( &convo_id, vec![PathBuf::from("/test/file.txt")], Some(terminal_view_id), ctx, ); assert!(!result.is_allowed()); assert!( matches!( result, FileReadPermission::Denied(FileReadPermissionDeniedReason::AlwaysAskEnabled) ), "the workspace setting should override the profile setting" ); }); }) } #[test] fn test_can_read_files_profile_workspace_allowlist_interaction() { App::test((), |mut app| async move { let PermissionsTestState { convo_id, permissions, user_workspaces, profile_model, terminal_view_id, .. } = initialize_permissions_test(&mut app); // Set up profile with allowlist and AlwaysAsk profile_model.update(&mut app, |model, ctx| { model.set_read_files( *model.active_profile(Some(terminal_view_id), ctx).id(), &ActionPermission::AlwaysAsk, ctx, ); model.add_to_directory_allowlist( *model.active_profile(Some(terminal_view_id), ctx).id(), &PathBuf::from("/profile/allowed"), ctx, ); }); // Test that files in profile's allowlist are allowed permissions.read(&app, |model, ctx| { let result = model.can_read_files_with_conversation( &convo_id, vec![PathBuf::from("/profile/allowed/file.txt")], Some(terminal_view_id), ctx, ); assert!(result.is_allowed()); assert!(matches!( result, FileReadPermission::Allowed(FileReadPermissionAllowedReason::ExplicitlyAllowlisted) )); // Test that files not in profile's allowlist are denied let result = model.can_read_files_with_conversation( &convo_id, vec![PathBuf::from("/not/allowed/file.txt")], Some(terminal_view_id), ctx, ); assert!(!result.is_allowed()); assert!(matches!( result, FileReadPermission::Denied(FileReadPermissionDeniedReason::AlwaysAskEnabled) )); }); // Set up workspace with AlwaysAsk but no allowlist user_workspaces.update(&mut app, |model, ctx| { model.setup_test_workspace(ctx); model.update_ai_autonomy_settings( |settings| { settings.read_files_setting = Some(ActionPermission::AlwaysAsk); settings.read_files_allowlist = None; }, ctx, ); }); // Test that the user's profile is respected when there's no workspace allowlist permissions.read(&app, |model, ctx| { let result = model.can_read_files_with_conversation( &convo_id, vec![PathBuf::from("/profile/allowed/file.txt")], Some(terminal_view_id), ctx, ); assert!(result.is_allowed()); assert!(matches!( result, FileReadPermission::Allowed(FileReadPermissionAllowedReason::ExplicitlyAllowlisted) )); }); // Set up workspace with AlwaysAsk and a different allowlist user_workspaces.update(&mut app, |model, ctx| { model.update_ai_autonomy_settings( |settings| { settings.read_files_setting = Some(ActionPermission::AlwaysAsk); settings.read_files_allowlist = Some(vec![PathBuf::from("/workspace/allowed")]); }, ctx, ); }); // Test that workspace allowlist takes precedence permissions.read(&app, |model, ctx| { // Files in workspace allowlist should be allowed let result = model.can_read_files_with_conversation( &convo_id, vec![PathBuf::from("/workspace/allowed/file.txt")], Some(terminal_view_id), ctx, ); assert!(result.is_allowed()); assert!(matches!( result, FileReadPermission::Allowed(FileReadPermissionAllowedReason::ExplicitlyAllowlisted) )); // Files in profile allowlist but not workspace allowlist should be denied let result = model.can_read_files_with_conversation( &convo_id, vec![PathBuf::from("/profile/allowed/file.txt")], Some(terminal_view_id), ctx, ); assert!(!result.is_allowed()); assert!(matches!( result, FileReadPermission::Denied(FileReadPermissionDeniedReason::AlwaysAskEnabled) )); // Files in neither allowlist should be denied let result = model.can_read_files_with_conversation( &convo_id, vec![PathBuf::from("/not/allowed/file.txt")], Some(terminal_view_id), ctx, ); assert!(!result.is_allowed()); assert!(matches!( result, FileReadPermission::Denied(FileReadPermissionDeniedReason::AlwaysAskEnabled) )); }); }) } #[test] fn test_can_write_files() { App::test((), |mut app| async move { let PermissionsTestState { terminal_view_id, convo_id, permissions, profile_model, .. } = initialize_permissions_test(&mut app); // Test AgentDecides setting profile_model.update(&mut app, |model, ctx| { model.set_apply_code_diffs( *model.active_profile(Some(terminal_view_id), ctx).id(), &ActionPermission::AgentDecides, ctx, ); }); permissions.read(&app, |model, ctx| { let result = model.can_write_files(&convo_id, &[], Some(terminal_view_id), ctx); assert!(!result.is_allowed()); assert!( matches!( result, FileWritePermission::Denied(FileWritePermissionDeniedReason::AgentDecided) ), "not allowed because AgentDecides right now just means ask" ); }); // Test AlwaysAllow setting profile_model.update(&mut app, |model, ctx| { model.set_apply_code_diffs( *model.active_profile(Some(terminal_view_id), ctx).id(), &ActionPermission::AlwaysAllow, ctx, ); }); permissions.read(&app, |model, ctx| { let result = model.can_write_files(&convo_id, &[], Some(terminal_view_id), ctx); assert!(result.is_allowed()); assert!(matches!( result, FileWritePermission::Allowed( FileWritePermissionAllowedReason::AutowriteSettingEnabled ) )); }); // Test AlwaysAsk setting profile_model.update(&mut app, |model, ctx| { model.set_apply_code_diffs( *model.active_profile(Some(terminal_view_id), ctx).id(), &ActionPermission::AlwaysAsk, ctx, ); }); permissions.read(&app, |model, ctx| { let result = model.can_write_files(&convo_id, &[], Some(terminal_view_id), ctx); assert!(!result.is_allowed()); assert!(matches!( result, FileWritePermission::Denied(FileWritePermissionDeniedReason::AlwaysAskEnabled) )); }); }) } #[test] fn test_can_write_files_workspace_settings_override_profile() { App::test((), |mut app| async move { let PermissionsTestState { convo_id, permissions, user_workspaces, profile_model, terminal_view_id, .. } = initialize_permissions_test(&mut app); // Set profile to AlwaysAllow profile_model.update(&mut app, |model, ctx| { model.set_apply_code_diffs( *model.active_profile(Some(terminal_view_id), ctx).id(), &ActionPermission::AlwaysAllow, ctx, ); }); // Test that profile setting is respected when no workspace setting permissions.read(&app, |model, ctx| { let result = model.can_write_files(&convo_id, &[], Some(terminal_view_id), ctx); assert!(result.is_allowed()); assert!(matches!( result, FileWritePermission::Allowed( FileWritePermissionAllowedReason::AutowriteSettingEnabled ) )); }); // Set workspace to AlwaysAsk user_workspaces.update(&mut app, |model, ctx| { model.setup_test_workspace(ctx); model.update_ai_autonomy_settings( |settings| { settings.apply_code_diffs_setting = Some(ActionPermission::AlwaysAsk); }, ctx, ); }); // Test that workspace setting overrides profile permissions.read(&app, |model, ctx| { let result = model.can_write_files(&convo_id, &[], Some(terminal_view_id), ctx); assert!(!result.is_allowed()); assert!(matches!( result, FileWritePermission::Denied(FileWritePermissionDeniedReason::AlwaysAskEnabled) )); }); }) } #[test] fn test_can_write_files_mcp_config_always_denied() { App::test((), |mut app| async move { let PermissionsTestState { terminal_view_id, convo_id, permissions, profile_model, .. } = initialize_permissions_test(&mut app); // Even with AlwaysAllow, writing to an MCP config must be denied. profile_model.update(&mut app, |model, ctx| { model.set_apply_code_diffs( *model.active_profile(Some(terminal_view_id), ctx).id(), &ActionPermission::AlwaysAllow, ctx, ); }); let mcp_config_paths = vec![ PathBuf::from("/project/.mcp.json"), PathBuf::from("/project/.warp/.mcp.json"), PathBuf::from("/project/.codex/config.toml"), ]; for path in mcp_config_paths { permissions.read(&app, |model, ctx| { let result = model.can_write_files( &convo_id, std::slice::from_ref(&path), Some(terminal_view_id), ctx, ); assert!( !result.is_allowed(), "expected MCP config path {path:?} to be denied" ); assert!( matches!( result, FileWritePermission::Denied(FileWritePermissionDeniedReason::ProtectedPath) ), "expected ProtectedPath denial for {path:?}, got {result:?}" ); }); } }) } #[test] fn test_can_autoexecute_command_workspace_settings_override_profile() { App::test((), |mut app| async move { let PermissionsTestState { convo_id, permissions, user_workspaces, profile_model, terminal_view_id, .. } = initialize_permissions_test(&mut app); // Set profile to AlwaysAllow profile_model.update(&mut app, |model, ctx| { model.set_execute_commands( *model.active_profile(Some(terminal_view_id), ctx).id(), &ActionPermission::AlwaysAllow, ctx, ); }); // Test that profile setting is respected when no workspace setting permissions.read(&app, |model, ctx| { let result = model.can_autoexecute_command( &convo_id, "git status", EscapeChar::Backslash, false, None, Some(terminal_view_id), ctx, ); assert!(result.is_allowed()); assert!(matches!( result, CommandExecutionPermission::Allowed( CommandExecutionPermissionAllowedReason::AlwaysAllowed ) )); }); // Set workspace to AlwaysAsk user_workspaces.update(&mut app, |model, ctx| { model.setup_test_workspace(ctx); model.update_ai_autonomy_settings( |settings| { settings.execute_commands_setting = Some(ActionPermission::AlwaysAsk); }, ctx, ); }); // Test that workspace setting overrides profile permissions.read(&app, |model, ctx| { let result = model.can_autoexecute_command( &convo_id, "git status", EscapeChar::Backslash, false, None, Some(terminal_view_id), ctx, ); assert!(!result.is_allowed()); assert!(matches!( result, CommandExecutionPermission::Denied( CommandExecutionPermissionDeniedReason::AlwaysAskEnabled ) )); }); }) } #[test] fn test_can_autoexecute_command_denylist_precedence() { App::test((), |mut app| async move { let PermissionsTestState { convo_id, permissions, user_workspaces, profile_model, terminal_view_id, .. } = initialize_permissions_test(&mut app); // Set up profile with denylist profile_model.update(&mut app, |model, ctx| { model.add_to_command_denylist( *model.active_profile(Some(terminal_view_id), ctx).id(), &AgentModeCommandExecutionPredicate::new_regex("rm .*").unwrap(), ctx, ); }); // Test that profile denylist is respected when no workspace denylist permissions.read(&app, |model, ctx| { let result = model.can_autoexecute_command( &convo_id, "rm file.txt", EscapeChar::Backslash, false, None, Some(terminal_view_id), ctx, ); assert!(!result.is_allowed()); assert!(matches!( result, CommandExecutionPermission::Denied( CommandExecutionPermissionDeniedReason::ExplicitlyDenylisted ) )); }); // Set workspace denylist user_workspaces.update(&mut app, |model, ctx| { model.setup_test_workspace(ctx); model.update_ai_autonomy_settings( |settings| { settings.execute_commands_denylist = Some(vec![AgentModeCommandExecutionPredicate::new_regex( "git .*", ) .unwrap()]); }, ctx, ); }); // Test that workspace denylist overrides profile denylist permissions.read(&app, |model, ctx| { // git commands should now be denied let result = model.can_autoexecute_command( &convo_id, "git status", EscapeChar::Backslash, false, None, Some(terminal_view_id), ctx, ); assert!(!result.is_allowed()); assert!(matches!( result, CommandExecutionPermission::Denied( CommandExecutionPermissionDeniedReason::ExplicitlyDenylisted ) )); // rm commands should now not be explicitly denylisted let result = model.can_autoexecute_command( &convo_id, "rm file.txt", EscapeChar::Backslash, false, None, Some(terminal_view_id), ctx, ); assert!(!matches!( result, CommandExecutionPermission::Denied( CommandExecutionPermissionDeniedReason::ExplicitlyDenylisted ) )); }); }) } #[test] fn test_can_autoexecute_command_allowlist_precedence() { App::test((), |mut app| async move { let PermissionsTestState { convo_id, permissions, user_workspaces, profile_model, terminal_view_id, .. } = initialize_permissions_test(&mut app); // Set up profile with AlwaysAsk and allowlist profile_model.update(&mut app, |model, ctx| { model.set_execute_commands( *model.active_profile(Some(terminal_view_id), ctx).id(), &ActionPermission::AlwaysAsk, ctx, ); model.add_to_command_allowlist( *model.active_profile(Some(terminal_view_id), ctx).id(), &AgentModeCommandExecutionPredicate::new_regex("git .*").unwrap(), ctx, ); }); // Test that profile allowlist is respected when no workspace allowlist permissions.read(&app, |model, ctx| { let result = model.can_autoexecute_command( &convo_id, "git status", EscapeChar::Backslash, false, None, Some(terminal_view_id), ctx, ); assert!(result.is_allowed()); assert!(matches!( result, CommandExecutionPermission::Allowed( CommandExecutionPermissionAllowedReason::ExplicitlyAllowlisted ) )); }); // Set workspace with AlwaysAsk and different allowlist user_workspaces.update(&mut app, |model, ctx| { model.setup_test_workspace(ctx); model.update_ai_autonomy_settings( |settings| { settings.execute_commands_setting = Some(ActionPermission::AlwaysAsk); settings.execute_commands_allowlist = Some(vec![ AgentModeCommandExecutionPredicate::new_regex("ls .*").unwrap(), ]); }, ctx, ); }); // Test that workspace allowlist overrides profile allowlist permissions.read(&app, |model, ctx| { // git commands should now be denied (not in workspace allowlist) let result = model.can_autoexecute_command( &convo_id, "git status", EscapeChar::Backslash, false, None, Some(terminal_view_id), ctx, ); assert!(!result.is_allowed()); assert!(matches!( result, CommandExecutionPermission::Denied( CommandExecutionPermissionDeniedReason::AlwaysAskEnabled ) )); // ls commands should now be allowed let result = model.can_autoexecute_command( &convo_id, "ls -l", EscapeChar::Backslash, false, None, Some(terminal_view_id), ctx, ); assert!(result.is_allowed()); assert!(matches!( result, CommandExecutionPermission::Allowed( CommandExecutionPermissionAllowedReason::ExplicitlyAllowlisted ) )); }); }) } #[test] fn test_can_autoexecute_command_denylist_beats_run_to_completion() { App::test((), |mut app| async move { let PermissionsTestState { convo_id, permissions, history, profile_model, terminal_view_id, .. } = initialize_permissions_test(&mut app); // Add a denylist rule that matches the test command. profile_model.update(&mut app, |model, ctx| { model.add_to_command_denylist( *model.active_profile(Some(terminal_view_id), ctx).id(), &AgentModeCommandExecutionPredicate::new_regex("rm .*").unwrap(), ctx, ); }); // Toggle run-to-completion override for this conversation. history.update(&mut app, |history, ctx| { history.toggle_autoexecute_override(&convo_id, terminal_view_id, ctx); }); // Despite run-to-completion, denylist must take precedence and deny execution. permissions.read(&app, |model, ctx| { let result = model.can_autoexecute_command( &convo_id, "rm important.txt", EscapeChar::Backslash, false, None, Some(terminal_view_id), ctx, ); assert!(!result.is_allowed()); assert!(matches!( result, CommandExecutionPermission::Denied( CommandExecutionPermissionDeniedReason::ExplicitlyDenylisted ) )); }); }) } #[test] fn test_can_autoexecute_command_run_to_completion_allows_non_denylisted() { App::test((), |mut app| async move { let PermissionsTestState { convo_id, permissions, history, terminal_view_id, .. } = initialize_permissions_test(&mut app); // Enable run-to-completion override for the conversation. history.update(&mut app, |history, ctx| { history.toggle_autoexecute_override(&convo_id, terminal_view_id, ctx); }); // Since the command is not denylisted, the override should allow execution with RunToCompletion. permissions.read(&app, |model, ctx| { let result = model.can_autoexecute_command( &convo_id, "echo hello", EscapeChar::Backslash, true, // read-only command Some(false), // not risky Some(terminal_view_id), ctx, ); assert!(result.is_allowed()); assert!(matches!( result, CommandExecutionPermission::Allowed( CommandExecutionPermissionAllowedReason::RunToCompletion ) )); }); }) } #[test] fn test_can_write_to_pty() { App::test((), |mut app| async move { let PermissionsTestState { convo_id, permissions, user_workspaces, profile_model, terminal_view_id, .. } = initialize_permissions_test(&mut app); // Set profile to AlwaysAllow profile_model.update(&mut app, |model, ctx| { model.set_write_to_pty( *model.active_profile(Some(terminal_view_id), ctx).id(), &WriteToPtyPermission::AlwaysAllow, ctx, ); }); // Test that profile setting is respected when no workspace setting permissions.read(&app, |model, ctx| { let result = model.can_write_to_pty(&convo_id, Some(terminal_view_id), ctx); assert_eq!(result, WriteToPtyPermission::AlwaysAllow); }); // Set workspace to AlwaysAsk user_workspaces.update(&mut app, |model, ctx| { model.setup_test_workspace(ctx); model.update_ai_autonomy_settings( |settings| { settings.write_to_pty_setting = Some(WriteToPtyPermission::AlwaysAsk); }, ctx, ); }); // Test that workspace setting overrides profile permissions.read(&app, |model, ctx| { let result = model.can_write_to_pty(&convo_id, Some(terminal_view_id), ctx); assert_eq!(result, WriteToPtyPermission::AlwaysAsk); }); }) } #[test] fn test_can_use_mcp_server_always_allow_no_denylist() { App::test((), |mut app| async move { let PermissionsTestState { convo_id, permissions, profile_model, terminal_view_id, .. } = initialize_permissions_test(&mut app); let server_uuid = Uuid::new_v4(); profile_model.update(&mut app, |model, ctx| { model.set_mcp_permissions( *model.active_profile(Some(terminal_view_id), ctx).id(), &ActionPermission::AlwaysAllow, ctx, ); }); permissions.read(&app, |model, ctx| { // Any server should be allowed when AlwaysAllow and not denylisted. assert!(model.can_use_mcp_server( &convo_id, Some(server_uuid), Some(terminal_view_id), ctx )); // None UUID should also be allowed (no denylist match possible). assert!(model.can_use_mcp_server(&convo_id, None, Some(terminal_view_id), ctx)); }); }) } #[test] fn test_can_use_mcp_server_always_allow_with_denylist() { App::test((), |mut app| async move { let PermissionsTestState { convo_id, permissions, profile_model, terminal_view_id, .. } = initialize_permissions_test(&mut app); let server_uuid = Uuid::new_v4(); let other_uuid = Uuid::new_v4(); profile_model.update(&mut app, |model, ctx| { model.set_mcp_permissions( *model.active_profile(Some(terminal_view_id), ctx).id(), &ActionPermission::AlwaysAllow, ctx, ); model.add_to_mcp_denylist( *model.active_profile(Some(terminal_view_id), ctx).id(), &server_uuid, ctx, ); }); permissions.read(&app, |model, ctx| { // Denylisted server should be denied. assert!(!model.can_use_mcp_server( &convo_id, Some(server_uuid), Some(terminal_view_id), ctx )); // Non-denylisted server should be allowed. assert!(model.can_use_mcp_server( &convo_id, Some(other_uuid), Some(terminal_view_id), ctx )); }); }) } #[test] fn test_can_use_mcp_server_always_ask_with_allowlist() { App::test((), |mut app| async move { let PermissionsTestState { convo_id, permissions, profile_model, terminal_view_id, .. } = initialize_permissions_test(&mut app); let server_uuid = Uuid::new_v4(); let other_uuid = Uuid::new_v4(); profile_model.update(&mut app, |model, ctx| { model.set_mcp_permissions( *model.active_profile(Some(terminal_view_id), ctx).id(), &ActionPermission::AlwaysAsk, ctx, ); model.add_to_mcp_allowlist( *model.active_profile(Some(terminal_view_id), ctx).id(), &server_uuid, ctx, ); }); permissions.read(&app, |model, ctx| { // Allowlisted server should be allowed. assert!(model.can_use_mcp_server( &convo_id, Some(server_uuid), Some(terminal_view_id), ctx )); // Non-allowlisted server should be denied. assert!(!model.can_use_mcp_server( &convo_id, Some(other_uuid), Some(terminal_view_id), ctx )); // None UUID should be denied. assert!(!model.can_use_mcp_server(&convo_id, None, Some(terminal_view_id), ctx)); }); }) } #[test] fn test_can_use_mcp_server_always_ask_denylist_overrides_allowlist() { App::test((), |mut app| async move { let PermissionsTestState { convo_id, permissions, profile_model, terminal_view_id, .. } = initialize_permissions_test(&mut app); let server_uuid = Uuid::new_v4(); profile_model.update(&mut app, |model, ctx| { model.set_mcp_permissions( *model.active_profile(Some(terminal_view_id), ctx).id(), &ActionPermission::AlwaysAsk, ctx, ); model.add_to_mcp_allowlist( *model.active_profile(Some(terminal_view_id), ctx).id(), &server_uuid, ctx, ); model.add_to_mcp_denylist( *model.active_profile(Some(terminal_view_id), ctx).id(), &server_uuid, ctx, ); }); permissions.read(&app, |model, ctx| { // Both allowlisted and denylisted: denylist wins. assert!(!model.can_use_mcp_server( &convo_id, Some(server_uuid), Some(terminal_view_id), ctx )); }); }) } #[test] fn test_can_use_mcp_server_agent_decides() { App::test((), |mut app| async move { let PermissionsTestState { convo_id, permissions, profile_model, terminal_view_id, .. } = initialize_permissions_test(&mut app); let server_uuid = Uuid::new_v4(); let other_uuid = Uuid::new_v4(); profile_model.update(&mut app, |model, ctx| { model.set_mcp_permissions( *model.active_profile(Some(terminal_view_id), ctx).id(), &ActionPermission::AgentDecides, ctx, ); model.add_to_mcp_allowlist( *model.active_profile(Some(terminal_view_id), ctx).id(), &server_uuid, ctx, ); }); permissions.read(&app, |model, ctx| { // Allowlisted and not denylisted should be allowed. assert!(model.can_use_mcp_server( &convo_id, Some(server_uuid), Some(terminal_view_id), ctx )); // Not allowlisted should be denied. assert!(!model.can_use_mcp_server( &convo_id, Some(other_uuid), Some(terminal_view_id), ctx )); }); }) } #[test] fn test_can_use_mcp_server_agent_decides_denylist_overrides_allowlist() { App::test((), |mut app| async move { let PermissionsTestState { convo_id, permissions, profile_model, terminal_view_id, .. } = initialize_permissions_test(&mut app); let server_uuid = Uuid::new_v4(); profile_model.update(&mut app, |model, ctx| { model.set_mcp_permissions( *model.active_profile(Some(terminal_view_id), ctx).id(), &ActionPermission::AgentDecides, ctx, ); model.add_to_mcp_allowlist( *model.active_profile(Some(terminal_view_id), ctx).id(), &server_uuid, ctx, ); model.add_to_mcp_denylist( *model.active_profile(Some(terminal_view_id), ctx).id(), &server_uuid, ctx, ); }); permissions.read(&app, |model, ctx| { // Both allowlisted and denylisted: denylist wins. assert!(!model.can_use_mcp_server( &convo_id, Some(server_uuid), Some(terminal_view_id), ctx )); }); }) } #[test] fn test_sandboxed_mode_allows_read_write_files() { App::test((), |mut app| async move { let PermissionsTestState { convo_id, permissions, user_workspaces, terminal_view_id, .. } = initialize_permissions_test_sandboxed(&mut app); // Set workspace to AlwaysAsk user_workspaces.update(&mut app, |model, ctx| { model.setup_test_workspace(ctx); model.update_ai_autonomy_settings( |settings| { settings.apply_code_diffs_setting = Some(ActionPermission::AlwaysAsk); settings.read_files_setting = Some(ActionPermission::AlwaysAsk); }, ctx, ); }); // In sandboxed mode the workspace read/write restrictions are bypassed, // so the profile's AlwaysAllow setting takes effect. permissions.read(&app, |model, ctx| { let result = model.can_write_files(&convo_id, &[], Some(terminal_view_id), ctx); assert!( result.is_allowed(), "write files should be allowed in sandboxed mode (workspace restriction bypassed)" ); assert!(matches!( result, FileWritePermission::Allowed( FileWritePermissionAllowedReason::AutowriteSettingEnabled ) )); let result = model.can_read_files_with_conversation( &convo_id, vec![PathBuf::from("/test/file.txt")], Some(terminal_view_id), ctx, ); assert!( result.is_allowed(), "read files should be allowed in sandboxed mode (workspace restriction bypassed)" ); assert!(matches!( result, FileReadPermission::Allowed( FileReadPermissionAllowedReason::AutoreadSettingEnabled ) )); }); }) } #[test] fn test_sandboxed_denylist_used_in_sandboxed_mode() { App::test((), |mut app| async move { let PermissionsTestState { convo_id, permissions, user_workspaces, terminal_view_id, .. } = initialize_permissions_test_sandboxed(&mut app); user_workspaces.update(&mut app, |model, ctx| { model.setup_test_workspace(ctx); // Regular workspace denylist blocks "git .*". model.update_ai_autonomy_settings( |settings| { settings.execute_commands_denylist = Some(vec![AgentModeCommandExecutionPredicate::new_regex( "git .*", ) .unwrap()]); }, ctx, ); // Sandboxed denylist blocks "rm .*" instead. model.update_sandboxed_agent_settings( |settings| { *settings = Some(SandboxedAgentSettings { execute_commands_denylist: Some(vec![ AgentModeCommandExecutionPredicate::new_regex("rm .*").unwrap(), ]), }); }, ctx, ); }); permissions.read(&app, |model, ctx| { // "git status" should be allowed: the regular denylist is not consulted in // sandboxed mode, so only the sandboxed denylist ("rm .*") applies. let result = model.can_autoexecute_command( &convo_id, "git status", EscapeChar::Backslash, false, None, Some(terminal_view_id), ctx, ); assert!( result.is_allowed(), "git status should be allowed in sandboxed mode (regular denylist bypassed)" ); // "rm file.txt" should be denied by the sandboxed denylist. let result = model.can_autoexecute_command( &convo_id, "rm file.txt", EscapeChar::Backslash, false, None, Some(terminal_view_id), ctx, ); assert!(!result.is_allowed()); assert!( matches!( result, CommandExecutionPermission::Denied( CommandExecutionPermissionDeniedReason::ExplicitlyDenylisted ) ), "rm file.txt should be denied by the sandboxed denylist" ); }); }) } #[test] fn test_denylist_matches_multiline_commands() { App::test((), |mut app| async move { let PermissionsTestState { convo_id, permissions, profile_model, terminal_view_id, .. } = initialize_permissions_test(&mut app); // Add denylist rule for rm profile_model.update(&mut app, |model, ctx| { model.add_to_command_denylist( *model.active_profile(Some(terminal_view_id), ctx).id(), &AgentModeCommandExecutionPredicate::new_regex("rm .*").unwrap(), ctx, ); }); // Single-line rm command should be denied permissions.read(&app, |model, ctx| { let result = model.can_autoexecute_command( &convo_id, "rm file.txt", EscapeChar::Backslash, false, None, Some(terminal_view_id), ctx, ); assert!(!result.is_allowed()); assert!(matches!( result, CommandExecutionPermission::Denied( CommandExecutionPermissionDeniedReason::ExplicitlyDenylisted ) )); }); // Multiline rm command with backslash continuations should also be denied (POSIX) permissions.read(&app, |model, ctx| { let result = model.can_autoexecute_command( &convo_id, "rm file1.txt \\\nfile2.txt \\\nfile3.txt", EscapeChar::Backslash, false, None, Some(terminal_view_id), ctx, ); assert!( !result.is_allowed(), "multiline rm command should be denied by denylist" ); assert!(matches!( result, CommandExecutionPermission::Denied( CommandExecutionPermissionDeniedReason::ExplicitlyDenylisted ) )); }); // Multiline rm command with backtick continuations should also be denied (PowerShell) permissions.read(&app, |model, ctx| { let result = model.can_autoexecute_command( &convo_id, "rm file1.txt `\nfile2.txt `\nfile3.txt", EscapeChar::Backtick, false, None, Some(terminal_view_id), ctx, ); assert!( !result.is_allowed(), "multiline rm command with backtick continuations should be denied by denylist" ); assert!(matches!( result, CommandExecutionPermission::Denied( CommandExecutionPermissionDeniedReason::ExplicitlyDenylisted ) )); }); }) }