1400 lines
46 KiB
Rust
1400 lines
46 KiB
Rust
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<BlocklistAIPermissions>,
|
|
history: ModelHandle<BlocklistAIHistoryModel>,
|
|
terminal_view_id: EntityId,
|
|
user_workspaces: ModelHandle<UserWorkspaces>,
|
|
profile_model: ModelHandle<AIExecutionProfilesModel>,
|
|
}
|
|
|
|
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
|
|
)
|
|
));
|
|
});
|
|
})
|
|
}
|