diff --git a/uefi-test-runner/src/bin/shell_launcher.rs b/uefi-test-runner/src/bin/shell_launcher.rs
index 123307bd6..5146bac46 100644
--- a/uefi-test-runner/src/bin/shell_launcher.rs
+++ b/uefi-test-runner/src/bin/shell_launcher.rs
@@ -13,6 +13,7 @@ extern crate alloc;
 
 use alloc::vec::Vec;
 use log::info;
+use uefi::boot;
 use uefi::prelude::*;
 use uefi::proto::device_path::build::{self, DevicePathBuilder};
 use uefi::proto::device_path::{DevicePath, DeviceSubType, DeviceType, LoadedImageDevicePath};
@@ -21,13 +22,10 @@ use uefi::table::boot::LoadImageSource;
 
 /// Get the device path of the shell app. This is the same as the
 /// currently-loaded image's device path, but with the file path part changed.
-fn get_shell_app_device_path<'a>(
-    boot_services: &BootServices,
-    storage: &'a mut Vec<u8>,
-) -> &'a DevicePath {
-    let loaded_image_device_path = boot_services
-        .open_protocol_exclusive::<LoadedImageDevicePath>(boot_services.image_handle())
-        .expect("failed to open LoadedImageDevicePath protocol");
+fn get_shell_app_device_path(storage: &mut Vec<u8>) -> &DevicePath {
+    let loaded_image_device_path =
+        boot::open_protocol_exclusive::<LoadedImageDevicePath>(boot::image_handle())
+            .expect("failed to open LoadedImageDevicePath protocol");
 
     let mut builder = DevicePathBuilder::with_vec(storage);
     for node in loaded_image_device_path.node_iter() {
@@ -50,7 +48,7 @@ fn efi_main(image: Handle, st: SystemTable<Boot>) -> Status {
     let boot_services = st.boot_services();
 
     let mut storage = Vec::new();
-    let shell_image_path = get_shell_app_device_path(boot_services, &mut storage);
+    let shell_image_path = get_shell_app_device_path(&mut storage);
 
     // Load the shell app.
     let shell_image_handle = boot_services
@@ -65,8 +63,7 @@ fn efi_main(image: Handle, st: SystemTable<Boot>) -> Status {
 
     // Set the command line passed to the shell app so that it will run the
     // test-runner app. This automatically turns off the five-second delay.
-    let mut shell_loaded_image = boot_services
-        .open_protocol_exclusive::<LoadedImage>(shell_image_handle)
+    let mut shell_loaded_image = boot::open_protocol_exclusive::<LoadedImage>(shell_image_handle)
         .expect("failed to open LoadedImage protocol");
     let load_options = cstr16!(r"shell.efi test_runner.efi arg1 arg2");
     unsafe {
diff --git a/uefi/src/boot.rs b/uefi/src/boot.rs
index 5f05c7e46..d8a3b4917 100644
--- a/uefi/src/boot.rs
+++ b/uefi/src/boot.rs
@@ -38,7 +38,7 @@ pub fn image_handle() -> Handle {
 ///
 /// This function should only be called as described above, and the
 /// `image_handle` must be a valid image [`Handle`]. The safety guarantees of
-/// `open_protocol_exclusive` rely on the global image handle being correct.
+/// [`open_protocol_exclusive`] rely on the global image handle being correct.
 pub unsafe fn set_image_handle(image_handle: Handle) {
     IMAGE_HANDLE.store(image_handle.as_ptr(), Ordering::Release);
 }
@@ -162,7 +162,7 @@ pub fn locate_handle_buffer(search_ty: SearchType) -> Result<HandleBuffer> {
 
 /// Opens a protocol interface for a handle.
 ///
-/// See also `open_protocol_exclusive`, which provides a safe subset of this
+/// See also [`open_protocol_exclusive`], which provides a safe subset of this
 /// functionality.
 ///
 /// This function attempts to get the protocol implementation of a handle, based
@@ -214,6 +214,34 @@ pub unsafe fn open_protocol<P: ProtocolPointer + ?Sized>(
     })
 }
 
+/// Opens a protocol interface for a handle in exclusive mode.
+///
+/// If successful, a [`ScopedProtocol`] is returned that will automatically
+/// close the protocol interface when dropped.
+///
+/// # Errors
+///
+/// * [`Status::UNSUPPORTED`]: the handle does not support the protocol.
+/// * [`Status::ACCESS_DENIED`]: the protocol is already open in a way that is
+///   incompatible with the new request.
+pub fn open_protocol_exclusive<P: ProtocolPointer + ?Sized>(
+    handle: Handle,
+) -> Result<ScopedProtocol<P>> {
+    // Safety: opening in exclusive mode with the correct agent
+    // handle set ensures that the protocol cannot be modified or
+    // removed while it is open, so this usage is safe.
+    unsafe {
+        open_protocol::<P>(
+            OpenProtocolParams {
+                handle,
+                agent: image_handle(),
+                controller: None,
+            },
+            OpenProtocolAttributes::Exclusive,
+        )
+    }
+}
+
 /// A buffer returned by [`locate_handle_buffer`] that contains an array of
 /// [`Handle`]s that support the requested protocol.
 #[derive(Debug, Eq, PartialEq)]