fix: harden external workdir lifecycle

This commit is contained in:
2026-09-22 07:56:20 +09:00
parent ce9534f3a2
commit b5ee6b5804
12 changed files with 1113 additions and 174 deletions
+37 -2
View File
@@ -2,8 +2,8 @@ use crate::http::{
WorkdirSessionOperation, WorkdirSessionOperationResult, dispatch_workdir_session_operation,
};
use crate::{
BoundedReadLimits, CommandRequest, EditRequest, LocalWorkdirSession, ReadRequest, Workdir,
WorkdirError, WorkdirPath, WorkdirSession, WorkdirSessionCapabilities,
BoundedReadLimits, CommandRequest, EditRequest, ExternalWorkdirRoot, LocalWorkdirSession,
ReadRequest, Workdir, WorkdirError, WorkdirPath, WorkdirSession, WorkdirSessionCapabilities,
WorkdirSessionCapability, WriteRequest,
};
@@ -224,6 +224,41 @@ async fn external_local_provider_rejects_symlink_roots_and_traversal() {
assert!(matches!(error, WorkdirError::Denied(_)));
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn external_local_provider_keeps_pre_grant_root_when_path_changes_before_session_creation() {
let parent = tempfile::tempdir().unwrap();
let approved = parent.path().join("shared");
std::fs::create_dir(&approved).unwrap();
std::fs::write(approved.join("item.txt"), "approved").unwrap();
// This is the CLI's approval/open boundary. Remote grant creation happens
// only after this descriptor has been pinned.
let pinned = ExternalWorkdirRoot::pin(&approved).unwrap();
std::fs::rename(&approved, parent.path().join("approved-original")).unwrap();
std::fs::create_dir(&approved).unwrap();
std::fs::write(approved.join("item.txt"), "replacement").unwrap();
let session = LocalWorkdirSession::external_read_only_pinned(
Workdir::new("external-workdir-pre-grant-pin"),
pinned,
BoundedReadLimits::new(4096, 1024).unwrap(),
)
.unwrap();
let read = WorkdirSession::read(
&session,
ReadRequest {
path: WorkdirPath::new("item.txt").unwrap(),
offset: 0,
limit: 10,
max_bytes: 1024,
},
)
.await
.unwrap();
assert_eq!(read.bytes, b"approved");
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn external_local_provider_pins_the_approved_root_across_directory_replacement() {
+2 -1
View File
@@ -30,7 +30,8 @@ pub use fs_operation::{
};
pub use http::dispatch_workdir_session_operation;
pub use local::{
LocalWorkdirSession, SymlinkInfo, WorkdirSessionResource, direct_symlink, first_symlink,
ExternalWorkdirRoot, LocalWorkdirSession, SymlinkInfo, WorkdirSessionResource, direct_symlink,
first_symlink,
};
pub use operation::*;
pub use router::{
+148 -34
View File
@@ -15,7 +15,7 @@ use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex as StdMutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use manifest::{Permission, Scope, ScopeConfig, ScopeRule, SharedScope, SymlinkPolicy};
@@ -208,6 +208,7 @@ struct ScopeAccess {
root: PathBuf,
pinned_root: Option<Arc<std::fs::File>>,
reject_symlinks: bool,
operation_guard: Option<OperationGuard>,
}
impl ScopeAccess {
@@ -216,12 +217,14 @@ impl ScopeAccess {
root: &Path,
pinned_root: Option<Arc<std::fs::File>>,
reject_symlinks: bool,
operation_guard: Option<OperationGuard>,
) -> Self {
Self {
scope,
root: root.to_path_buf(),
pinned_root,
reject_symlinks,
operation_guard,
}
}
@@ -258,6 +261,26 @@ impl fs_operation::FsAccessPolicy for ScopeAccess {
self.scope.is_writable(path)
}
fn check_cancelled(&self) -> std::io::Result<()> {
let Some(guard) = &self.operation_guard else {
return Ok(());
};
if guard.cancelled.load(Ordering::Acquire) {
return Err(std::io::Error::new(
std::io::ErrorKind::Interrupted,
"External Workdir operation was cancelled",
));
}
if Instant::now() >= guard.deadline {
guard.cancelled.store(true, Ordering::Release);
return Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"External Workdir operation exceeded its provider deadline",
));
}
Ok(())
}
fn open_read_file(&self, _logical: &Path, resolved: &Path) -> std::io::Result<std::fs::File> {
if self.reject_symlinks {
self.open_confined(resolved)
@@ -381,6 +404,98 @@ impl Drop for LocalWorkdirSessionInner {
pub trait WorkdirSessionResource: Debug + Send + Sync {}
impl<T> WorkdirSessionResource for T where T: Debug + Send + Sync {}
/// An atomically validated, descriptor-pinned External Workdir root.
///
/// Pinning compares the selected path identity with the opened descriptor, so
/// a rename or replacement between approval and open fails closed. The
/// descriptor, rather than the pathname, remains the filesystem authority for
/// the lifetime of every session created from this root.
#[derive(Debug, Clone)]
pub struct ExternalWorkdirRoot {
canonical_path: PathBuf,
handle: Arc<std::fs::File>,
}
impl ExternalWorkdirRoot {
pub fn pin(directory: impl AsRef<Path>) -> Result<Self, WorkdirError> {
let directory = directory.as_ref();
if !directory.is_absolute() {
return Err(WorkdirError::InvalidArgument(
"External Workdir directory must be absolute".to_string(),
));
}
let selected_metadata = std::fs::symlink_metadata(directory)
.map_err(|error| WorkdirError::io(Path::new("<external-root>"), error))?;
if selected_metadata.file_type().is_symlink() {
return Err(WorkdirError::Denied(
"External Workdir root must not be a symbolic link".to_string(),
));
}
if !selected_metadata.is_dir() {
return Err(WorkdirError::InvalidArgument(
"External Workdir root must be a directory".to_string(),
));
}
let handle = Arc::new(
fs_operation::open_root_no_symlinks(directory)
.map_err(|error| WorkdirError::io(Path::new("<external-root>"), error))?,
);
let opened_metadata = handle
.metadata()
.map_err(|error| WorkdirError::io(Path::new("<external-root>"), error))?;
if !opened_metadata.is_dir() {
return Err(WorkdirError::InvalidArgument(
"External Workdir root must be a directory".to_string(),
));
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if (selected_metadata.dev(), selected_metadata.ino())
!= (opened_metadata.dev(), opened_metadata.ino())
{
return Err(WorkdirError::Denied(
"External Workdir root changed while it was being pinned".to_string(),
));
}
}
#[cfg(target_os = "linux")]
let canonical_path = {
use std::os::fd::AsRawFd;
let path = std::fs::read_link(format!("/proc/self/fd/{}", handle.as_raw_fd()))
.map_err(|error| WorkdirError::io(Path::new("<external-root>"), error))?;
if !path.is_absolute() || path.to_string_lossy().ends_with(" (deleted)") {
return Err(WorkdirError::Denied(
"External Workdir root moved while it was being pinned".to_string(),
));
}
path
};
#[cfg(not(target_os = "linux"))]
let canonical_path = directory.to_path_buf();
Ok(Self {
canonical_path,
handle,
})
}
pub fn canonical_path(&self) -> &Path {
&self.canonical_path
}
}
#[derive(Debug, Clone)]
struct OperationGuard {
cancelled: Arc<AtomicBool>,
deadline: Instant,
}
/// Scope-aware filesystem handle. Clone-cheap (`Arc` inside).
///
/// The wrapped [`SharedScope`] is shared with every clone of this
@@ -391,6 +506,7 @@ impl<T> WorkdirSessionResource for T where T: Debug + Send + Sync {}
#[derive(Debug, Clone)]
pub struct LocalWorkdirSession {
inner: Arc<LocalWorkdirSessionInner>,
operation_guard: Option<OperationGuard>,
}
/// First symlink encountered while resolving a path.
@@ -495,45 +611,29 @@ impl LocalWorkdirSession {
/// Construct a strictly read-only provider session for an
/// authority-assigned Workdir and an operator-selected local directory.
///
/// The selected directory is canonicalized and fixed for the lifetime of
/// the session. A symlink selected as the root is rejected, and all
/// operation paths are confined below the canonical root without following
/// The selected directory is atomically validated and pinned for the
/// session lifetime. A symlink selected as the root is rejected, and all
/// operation paths are confined below the pinned root without following
/// symbolic links. Read source and response sizes are provider-bounded.
pub fn external_read_only(
workdir: Workdir,
directory: impl AsRef<Path>,
read_limits: BoundedReadLimits,
) -> Result<Self, WorkdirError> {
let root = ExternalWorkdirRoot::pin(directory)?;
Self::external_read_only_pinned(workdir, root, read_limits)
}
/// Construct a provider session from a root pinned before remote grant
/// creation, preventing any later pathname replacement from changing the
/// approved filesystem authority.
pub fn external_read_only_pinned(
workdir: Workdir,
pinned: ExternalWorkdirRoot,
read_limits: BoundedReadLimits,
) -> Result<Self, WorkdirError> {
read_limits.validate().map_err(WorkdirError::from)?;
let directory = directory.as_ref();
if !directory.is_absolute() {
return Err(WorkdirError::InvalidArgument(
"External Workdir directory must be absolute".to_string(),
));
}
let selected_metadata = std::fs::symlink_metadata(directory)
.map_err(|error| WorkdirError::io(Path::new("<external-root>"), error))?;
if selected_metadata.file_type().is_symlink() {
return Err(WorkdirError::Denied(
"External Workdir root must not be a symbolic link".to_string(),
));
}
let root = directory
.canonicalize()
.map_err(|error| WorkdirError::io(Path::new("<external-root>"), error))?;
if !root
.metadata()
.map_err(|error| WorkdirError::io(Path::new("<external-root>"), error))?
.is_dir()
{
return Err(WorkdirError::InvalidArgument(
"External Workdir root must be a directory".to_string(),
));
}
let pinned_root = Arc::new(
fs_operation::open_root_no_symlinks(&root)
.map_err(|error| WorkdirError::io(Path::new("<external-root>"), error))?,
);
let root = pinned.canonical_path;
let scope = Scope::from_config(&ScopeConfig {
allow: vec![ScopeRule {
target: root.clone(),
@@ -556,12 +656,24 @@ impl LocalWorkdirSession {
WorkdirSessionCapabilities::READ_ONLY,
BTreeMap::new(),
Vec::new(),
Some(pinned_root),
Some(pinned.handle),
Some(read_limits),
true,
))
}
/// Return an operation-local clone that cooperatively stops filesystem
/// work when cancelled or when its provider-side deadline expires.
pub fn with_operation_guard(&self, cancelled: Arc<AtomicBool>, deadline: Instant) -> Self {
Self {
inner: self.inner.clone(),
operation_guard: Some(OperationGuard {
cancelled,
deadline,
}),
}
}
fn materialized_bound_with_policy(
workdir: Workdir,
root: PathBuf,
@@ -592,6 +704,7 @@ impl LocalWorkdirSession {
command_environment,
resources: StdMutex::new(resources),
}),
operation_guard: None,
}
}
@@ -809,6 +922,7 @@ impl LocalWorkdirSession {
&self.inner.root,
self.inner.pinned_root.clone(),
self.inner.reject_symlinks,
self.operation_guard.clone(),
)
}
}