feat: add selective Workdir symlink policies
This commit is contained in:
@@ -11,7 +11,7 @@ use crate::{
|
||||
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
|
||||
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
|
||||
ReadRequest, ReadResult, StatRequest, StatResult, WorkdirError, WorkdirId,
|
||||
WorkdirSessionCapabilities, WriteRequest, WriteResult,
|
||||
WorkdirScopeAuthorizationRequest, WorkdirSessionCapabilities, WriteRequest, WriteResult,
|
||||
};
|
||||
|
||||
/// Opaque Runtime-owned identifier for one ephemeral Workdir session.
|
||||
@@ -55,6 +55,7 @@ pub struct OpenWorkdirSessionResponse {
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "operation", content = "request", rename_all = "snake_case")]
|
||||
pub enum WorkdirSessionOperation {
|
||||
AuthorizeScope(WorkdirScopeAuthorizationRequest),
|
||||
Stat(StatRequest),
|
||||
Read(ReadRequest),
|
||||
Write(WriteRequest),
|
||||
@@ -79,6 +80,7 @@ pub struct WorkdirSessionOperationRequest {
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "operation", content = "result", rename_all = "snake_case")]
|
||||
pub enum WorkdirSessionOperationResult {
|
||||
AuthorizeScope,
|
||||
Stat(StatResult),
|
||||
Read(ReadResult),
|
||||
Write(WriteResult),
|
||||
@@ -447,6 +449,19 @@ mod client {
|
||||
self.capabilities
|
||||
}
|
||||
|
||||
async fn authorize_scope_path(
|
||||
&self,
|
||||
request: WorkdirScopeAuthorizationRequest,
|
||||
) -> Result<(), WorkdirError> {
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::AuthorizeScope(request))
|
||||
.await?
|
||||
{
|
||||
WorkdirSessionOperationResult::AuthorizeScope => Ok(()),
|
||||
_ => Err(Self::mismatch("authorize_scope")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||
match self.operate(WorkdirSessionOperation::Stat(request)).await? {
|
||||
WorkdirSessionOperationResult::Stat(result) => Ok(result),
|
||||
|
||||
@@ -28,8 +28,8 @@ pub use local::{
|
||||
};
|
||||
pub use operation::*;
|
||||
pub use scope::{
|
||||
ReadOnlyWorkdirSession, WorkdirScopeLease, WorkdirToolBroker, WorkdirToolScope,
|
||||
WorkdirToolScopePermission, WorkdirToolScopeRule,
|
||||
ReadOnlyWorkdirSession, WorkdirScopeAuthorizationRequest, WorkdirScopeLease, WorkdirToolBroker,
|
||||
WorkdirToolScope, WorkdirToolScopePermission, WorkdirToolScopeRule,
|
||||
};
|
||||
|
||||
/// Persistent, opaque identity of one materialized Workdir.
|
||||
@@ -147,6 +147,25 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
|
||||
fn workdir(&self) -> &Workdir;
|
||||
fn capabilities(&self) -> WorkdirSessionCapabilities;
|
||||
|
||||
/// Validate an attenuated filesystem rule at the provider boundary without
|
||||
/// exposing the resolved host path. Providers that cannot resolve symbolic
|
||||
/// links must reject resolved-policy checks rather than downgrade them.
|
||||
async fn authorize_scope_path(
|
||||
&self,
|
||||
request: WorkdirScopeAuthorizationRequest,
|
||||
) -> Result<(), WorkdirError> {
|
||||
if request.rules.iter().any(|rule| {
|
||||
rule.symlink_policy == manifest::SymlinkPolicy::Logical
|
||||
&& scope::rule_allows_path(rule, &request.path, request.permission)
|
||||
}) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(WorkdirError::Denied(
|
||||
"Workdir provider cannot establish resolved scope authority".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError>;
|
||||
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError>;
|
||||
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError>;
|
||||
|
||||
+216
-12
@@ -18,7 +18,7 @@ use std::sync::{Arc, Mutex as StdMutex};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use manifest::{Scope, SharedScope};
|
||||
use manifest::{Permission, Scope, SharedScope, SymlinkPolicy};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::{Mutex, broadcast, watch};
|
||||
@@ -28,8 +28,9 @@ use crate::{
|
||||
CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest,
|
||||
CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice, EditRequest, EditResult,
|
||||
GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, ReadRequest,
|
||||
ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirPath, WorkdirSession,
|
||||
WorkdirSessionCapabilities, WorkdirSessionCapability, WriteRequest, WriteResult,
|
||||
ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirPath,
|
||||
WorkdirScopeAuthorizationRequest, WorkdirSession, WorkdirSessionCapabilities,
|
||||
WorkdirSessionCapability, WorkdirToolScopePermission, WriteRequest, WriteResult,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::{EntryKind, WriteOutcome};
|
||||
@@ -211,6 +212,17 @@ impl fs_operation::FsAccessPolicy for ScopeAccess {
|
||||
fn is_writable(&self, path: &Path) -> bool {
|
||||
self.0.is_writable(path)
|
||||
}
|
||||
|
||||
fn is_readable_paths(&self, logical: &Path, resolved: &Path) -> bool {
|
||||
matches!(
|
||||
self.0.permission_at_paths(logical, resolved),
|
||||
Some(Permission::Read | Permission::Write)
|
||||
)
|
||||
}
|
||||
|
||||
fn is_writable_paths(&self, logical: &Path, resolved: &Path) -> bool {
|
||||
self.0.permission_at_paths(logical, resolved) == Some(Permission::Write)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -397,6 +409,11 @@ impl LocalWorkdirSession {
|
||||
return Err(WorkdirError::RelativePath(path.to_path_buf()));
|
||||
}
|
||||
let symlink = first_symlink(path);
|
||||
if let Some(info) = symlink.as_ref()
|
||||
&& !info.target_exists
|
||||
{
|
||||
return Err(broken_symlink_error(path, info));
|
||||
}
|
||||
let scope = self.inner.scope.load();
|
||||
if !scope.is_readable(path) {
|
||||
return Err(symlink_out_of_scope_or_plain(
|
||||
@@ -406,11 +423,6 @@ impl LocalWorkdirSession {
|
||||
&scope,
|
||||
));
|
||||
}
|
||||
if let Some(info) = symlink.as_ref() {
|
||||
if !info.target_exists {
|
||||
return Err(broken_symlink_error(path, info));
|
||||
}
|
||||
}
|
||||
let meta = std::fs::metadata(path).map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => WorkdirError::NotFound(path.to_path_buf()),
|
||||
_ => WorkdirError::io(path, e),
|
||||
@@ -556,6 +568,64 @@ impl WorkdirSession for LocalWorkdirSession {
|
||||
self.inner.capabilities
|
||||
}
|
||||
|
||||
async fn authorize_scope_path(
|
||||
&self,
|
||||
request: WorkdirScopeAuthorizationRequest,
|
||||
) -> Result<(), WorkdirError> {
|
||||
self.ensure_open()?;
|
||||
let logical = self.inner.root.join(request.path.as_str());
|
||||
let resolved = fs_operation::resolve_access_path(&logical)
|
||||
.map_err(|error| WorkdirError::io(&logical, error))?;
|
||||
let parent_permission = self
|
||||
.inner
|
||||
.scope
|
||||
.load()
|
||||
.permission_at_paths(&logical, &resolved);
|
||||
let parent_allows = match request.permission {
|
||||
WorkdirToolScopePermission::Read => matches!(
|
||||
parent_permission,
|
||||
Some(Permission::Read | Permission::Write)
|
||||
),
|
||||
WorkdirToolScopePermission::Write => parent_permission == Some(Permission::Write),
|
||||
};
|
||||
if !parent_allows {
|
||||
return Err(WorkdirError::Denied(format!(
|
||||
"Workdir path `{}` exceeds the provider attachment scope",
|
||||
request.path
|
||||
)));
|
||||
}
|
||||
let allowed = request.rules.iter().any(|rule| {
|
||||
if request.permission == WorkdirToolScopePermission::Write
|
||||
&& rule.permission != WorkdirToolScopePermission::Write
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let logical_target = self.inner.root.join(rule.target.as_str());
|
||||
let (candidate, target) = match rule.symlink_policy {
|
||||
SymlinkPolicy::Logical => (logical.as_path(), logical_target),
|
||||
SymlinkPolicy::Resolved => {
|
||||
let Ok(target) = fs_operation::resolve_access_path(&logical_target) else {
|
||||
return false;
|
||||
};
|
||||
(resolved.as_path(), target)
|
||||
}
|
||||
};
|
||||
if rule.recursive {
|
||||
candidate.starts_with(target)
|
||||
} else {
|
||||
candidate == target || candidate.parent() == Some(target.as_path())
|
||||
}
|
||||
});
|
||||
if allowed {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(WorkdirError::Denied(format!(
|
||||
"Workdir path `{}` is outside the provider-resolved delegated scope",
|
||||
request.path
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||
self.ensure_capability(WorkdirSessionCapability::Read)?;
|
||||
let logical = request.path.clone();
|
||||
@@ -1334,6 +1404,22 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
fn make_logical_fs(dir: &TempDir) -> LocalWorkdirSession {
|
||||
LocalWorkdirSession::new(
|
||||
Scope::from_config(&ScopeConfig {
|
||||
allow: vec![ScopeRule {
|
||||
target: dir.path().to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: SymlinkPolicy::Logical,
|
||||
}],
|
||||
deny: Vec::new(),
|
||||
})
|
||||
.unwrap(),
|
||||
dir.path().to_path_buf(),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logical_provider_operations_cover_read_write_edit_stat_and_list() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -1533,6 +1619,102 @@ mod tests {
|
||||
assert_eq!(read.bytes, b"persisted");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn resolved_provider_scope_rejects_read_and_write_through_outside_alias() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = TempDir::new().unwrap();
|
||||
let outside = TempDir::new().unwrap();
|
||||
let target = outside.path().join("target.txt");
|
||||
fs::write(&target, "secret").unwrap();
|
||||
symlink(&target, root.path().join("alias.txt")).unwrap();
|
||||
symlink(outside.path(), root.path().join("alias-dir")).unwrap();
|
||||
let workdir = make_fs(&root);
|
||||
|
||||
assert!(matches!(
|
||||
WorkdirSession::read(
|
||||
&workdir,
|
||||
ReadRequest {
|
||||
path: WorkdirPath::new("alias.txt").unwrap(),
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
max_bytes: 1024,
|
||||
}
|
||||
)
|
||||
.await,
|
||||
Err(WorkdirError::SymlinkOutOfScope { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
WorkdirSession::write(
|
||||
&workdir,
|
||||
WriteRequest {
|
||||
path: WorkdirPath::new("alias.txt").unwrap(),
|
||||
content: b"changed".to_vec(),
|
||||
expected_hash: None,
|
||||
}
|
||||
)
|
||||
.await,
|
||||
Err(WorkdirError::SymlinkOutOfScope { .. })
|
||||
));
|
||||
assert_eq!(fs::read_to_string(target).unwrap(), "secret");
|
||||
assert!(matches!(
|
||||
WorkdirSession::write(
|
||||
&workdir,
|
||||
WriteRequest {
|
||||
path: WorkdirPath::new("alias-dir/new.txt").unwrap(),
|
||||
content: b"new".to_vec(),
|
||||
expected_hash: None,
|
||||
}
|
||||
)
|
||||
.await,
|
||||
Err(WorkdirError::ReadOnly(_))
|
||||
));
|
||||
assert!(!outside.path().join("new.txt").exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn resolved_deny_blocks_missing_write_through_logical_alias() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = TempDir::new().unwrap();
|
||||
let outside = TempDir::new().unwrap();
|
||||
symlink(outside.path(), root.path().join("alias")).unwrap();
|
||||
let workdir = LocalWorkdirSession::new(
|
||||
Scope::from_config(&ScopeConfig {
|
||||
allow: vec![ScopeRule {
|
||||
target: root.path().to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: SymlinkPolicy::Logical,
|
||||
}],
|
||||
deny: vec![ScopeRule {
|
||||
target: outside.path().join("blocked.txt"),
|
||||
permission: Permission::Read,
|
||||
recursive: false,
|
||||
symlink_policy: SymlinkPolicy::Logical,
|
||||
}],
|
||||
})
|
||||
.unwrap(),
|
||||
root.path().to_path_buf(),
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
WorkdirSession::write(
|
||||
&workdir,
|
||||
WriteRequest {
|
||||
path: WorkdirPath::new("alias/blocked.txt").unwrap(),
|
||||
content: b"blocked".to_vec(),
|
||||
expected_hash: None,
|
||||
}
|
||||
)
|
||||
.await,
|
||||
Err(WorkdirError::ReadOnly(_))
|
||||
));
|
||||
assert!(!outside.path().join("blocked.txt").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capability_boundary_rejects_direct_unsupported_operation() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -1645,7 +1827,7 @@ mod tests {
|
||||
let link = dir.path().join("outside-repo.txt");
|
||||
symlink(&target, &link).unwrap();
|
||||
|
||||
let fs = make_fs(&dir);
|
||||
let fs = make_logical_fs(&dir);
|
||||
assert_eq!(fs.read_bytes(&link).unwrap(), b"secret");
|
||||
}
|
||||
|
||||
@@ -1748,7 +1930,7 @@ mod tests {
|
||||
let link = dir.path().join("outside-repo.txt");
|
||||
symlink(&target, &link).unwrap();
|
||||
|
||||
let fs = make_fs(&dir);
|
||||
let fs = make_logical_fs(&dir);
|
||||
fs.write(&link, b"new").unwrap();
|
||||
assert_eq!(fs::read(&target).unwrap(), b"new");
|
||||
assert!(
|
||||
@@ -1778,11 +1960,13 @@ mod tests {
|
||||
target: dir.path().to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
deny: vec![ScopeRule {
|
||||
target: sub.clone(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
};
|
||||
let scope = Scope::from_config(&cfg).unwrap();
|
||||
@@ -1846,6 +2030,7 @@ mod tests {
|
||||
target: extra.path().to_path_buf(),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}])
|
||||
})
|
||||
.unwrap();
|
||||
@@ -1882,6 +2067,7 @@ mod tests {
|
||||
target: sub.clone(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}])
|
||||
})
|
||||
.unwrap();
|
||||
@@ -1918,6 +2104,7 @@ mod tests {
|
||||
target: dir.path().to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}])
|
||||
})
|
||||
.unwrap();
|
||||
@@ -1935,14 +2122,14 @@ mod tests {
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn provider_uses_logical_paths_through_symlinked_directories() {
|
||||
async fn provider_uses_explicit_logical_policy_through_symlinked_directories() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let outside = TempDir::new().unwrap();
|
||||
std::fs::write(outside.path().join("worker.json"), "scope-needle\n").unwrap();
|
||||
symlink(outside.path(), dir.path().join("yoi.local")).unwrap();
|
||||
let workdir = make_fs(&dir);
|
||||
let workdir = make_logical_fs(&dir);
|
||||
|
||||
let read = WorkdirSession::read(
|
||||
&workdir,
|
||||
@@ -1956,6 +2143,19 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(read.bytes, b"scope-needle\n");
|
||||
let list = WorkdirSession::list(
|
||||
&workdir,
|
||||
ListRequest {
|
||||
path: WorkdirPath::new("yoi.local").unwrap(),
|
||||
limit: 10,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
list.entries[0].path,
|
||||
WorkdirPath::new("yoi.local/worker.json").unwrap()
|
||||
);
|
||||
let glob = WorkdirSession::glob(
|
||||
&workdir,
|
||||
GlobRequest {
|
||||
@@ -2084,11 +2284,13 @@ mod tests {
|
||||
target: dir.path().to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
},
|
||||
ScopeRule {
|
||||
target: spill.path().to_path_buf(),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
},
|
||||
],
|
||||
deny: Vec::new(),
|
||||
@@ -2165,11 +2367,13 @@ mod tests {
|
||||
target: dir.path().to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
},
|
||||
ScopeRule {
|
||||
target: spill.path().to_path_buf(),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
},
|
||||
],
|
||||
deny: Vec::new(),
|
||||
|
||||
+172
-22
@@ -8,6 +8,7 @@ use fs_operation::{
|
||||
EditRequest, EditResult, FsPath, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest,
|
||||
ListResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult,
|
||||
};
|
||||
use manifest::SymlinkPolicy;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
const MAX_SCOPED_COMMANDS: usize = 16;
|
||||
@@ -31,6 +32,17 @@ pub struct WorkdirToolScopeRule {
|
||||
pub target: FsPath,
|
||||
pub permission: WorkdirToolScopePermission,
|
||||
pub recursive: bool,
|
||||
#[serde(default)]
|
||||
pub symlink_policy: SymlinkPolicy,
|
||||
}
|
||||
|
||||
/// Provider-side check for one operation under an attenuated tool scope.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkdirScopeAuthorizationRequest {
|
||||
pub rules: Vec<WorkdirToolScopeRule>,
|
||||
pub path: FsPath,
|
||||
pub permission: WorkdirToolScopePermission,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
@@ -492,9 +504,39 @@ impl ScopedWorkdirSession {
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_operation_path(&self, path: &FsPath) -> Result<FsPath, WorkdirError> {
|
||||
async fn ensure_scope_targets_are_authorized(
|
||||
&self,
|
||||
rules: &[WorkdirToolScopeRule],
|
||||
) -> Result<(), WorkdirError> {
|
||||
for rule in rules {
|
||||
self.source
|
||||
.authorize_scope_path(WorkdirScopeAuthorizationRequest {
|
||||
rules: rules.to_vec(),
|
||||
path: rule.target.clone(),
|
||||
permission: rule.permission,
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn resolve_operation_path(
|
||||
&self,
|
||||
path: &FsPath,
|
||||
permission: WorkdirToolScopePermission,
|
||||
) -> Result<FsPath, WorkdirError> {
|
||||
self.ensure_active()?;
|
||||
self.resolve_path(path)
|
||||
let resolved = self.resolve_path(path)?;
|
||||
if let Some(rules) = self.scope.as_ref() {
|
||||
self.source
|
||||
.authorize_scope_path(WorkdirScopeAuthorizationRequest {
|
||||
rules: rules.clone(),
|
||||
path: resolved.clone(),
|
||||
permission,
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
fn validate_scope(
|
||||
@@ -582,6 +624,8 @@ impl ScopedWorkdirSession {
|
||||
request.cwd
|
||||
)));
|
||||
}
|
||||
self.ensure_scope_targets_are_authorized(&request.rules)
|
||||
.await?;
|
||||
let validity = SessionValidity::child(self.validity.clone());
|
||||
let cleanup_pending = Arc::new(AtomicBool::new(true));
|
||||
let id = self.next_lease_id.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -692,49 +736,63 @@ impl WorkdirSession for ScopedWorkdirSession {
|
||||
}
|
||||
|
||||
async fn stat(&self, mut request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||
let path = self.resolve_operation_path(&request.path)?;
|
||||
let path = self
|
||||
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
|
||||
.await?;
|
||||
self.ensure_read(&path, WorkdirSessionCapability::Read)?;
|
||||
request.path = path;
|
||||
self.source.stat(request).await
|
||||
}
|
||||
|
||||
async fn read(&self, mut request: ReadRequest) -> Result<ReadResult, WorkdirError> {
|
||||
let path = self.resolve_operation_path(&request.path)?;
|
||||
let path = self
|
||||
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
|
||||
.await?;
|
||||
self.ensure_read(&path, WorkdirSessionCapability::Read)?;
|
||||
request.path = path;
|
||||
self.source.read(request).await
|
||||
}
|
||||
|
||||
async fn write(&self, mut request: WriteRequest) -> Result<WriteResult, WorkdirError> {
|
||||
let path = self.resolve_operation_path(&request.path)?;
|
||||
let path = self
|
||||
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Write)
|
||||
.await?;
|
||||
self.ensure_write(&path, WorkdirSessionCapability::Write)?;
|
||||
request.path = path;
|
||||
self.source.write(request).await
|
||||
}
|
||||
|
||||
async fn edit(&self, mut request: EditRequest) -> Result<EditResult, WorkdirError> {
|
||||
let path = self.resolve_operation_path(&request.path)?;
|
||||
let path = self
|
||||
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Write)
|
||||
.await?;
|
||||
self.ensure_write(&path, WorkdirSessionCapability::Edit)?;
|
||||
request.path = path;
|
||||
self.source.edit(request).await
|
||||
}
|
||||
|
||||
async fn list(&self, mut request: ListRequest) -> Result<ListResult, WorkdirError> {
|
||||
let path = self.resolve_operation_path(&request.path)?;
|
||||
let path = self
|
||||
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
|
||||
.await?;
|
||||
self.ensure_read(&path, WorkdirSessionCapability::Read)?;
|
||||
request.path = path;
|
||||
self.source.list(request).await
|
||||
}
|
||||
|
||||
async fn glob(&self, mut request: GlobRequest) -> Result<GlobResult, WorkdirError> {
|
||||
let path = self.resolve_operation_path(&request.path)?;
|
||||
let path = self
|
||||
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
|
||||
.await?;
|
||||
self.ensure_read(&path, WorkdirSessionCapability::Glob)?;
|
||||
request.path = path;
|
||||
self.source.glob(request).await
|
||||
}
|
||||
|
||||
async fn grep(&self, mut request: GrepRequest) -> Result<GrepResult, WorkdirError> {
|
||||
let path = self.resolve_operation_path(&request.path)?;
|
||||
let path = self
|
||||
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
|
||||
.await?;
|
||||
self.ensure_read(&path, WorkdirSessionCapability::Grep)?;
|
||||
request.path = path;
|
||||
self.source.grep(request).await
|
||||
@@ -943,6 +1001,16 @@ impl WorkdirSession for ReadOnlyWorkdirSession {
|
||||
WorkdirSessionCapabilities::READ_ONLY
|
||||
}
|
||||
|
||||
async fn authorize_scope_path(
|
||||
&self,
|
||||
request: WorkdirScopeAuthorizationRequest,
|
||||
) -> Result<(), WorkdirError> {
|
||||
if request.permission == WorkdirToolScopePermission::Write {
|
||||
return Err(WorkdirError::Denied("read-only workdir session".into()));
|
||||
}
|
||||
self.inner.authorize_scope_path(request).await
|
||||
}
|
||||
|
||||
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||
self.inner.stat(request).await
|
||||
}
|
||||
@@ -1106,7 +1174,7 @@ fn rules_overlap(left: &WorkdirToolScopeRule, right: &WorkdirToolScopeRule) -> b
|
||||
|| rule_allows_path(right, &left.target, WorkdirToolScopePermission::Write))
|
||||
}
|
||||
|
||||
fn rule_allows_path(
|
||||
pub(crate) fn rule_allows_path(
|
||||
rule: &WorkdirToolScopeRule,
|
||||
path: &FsPath,
|
||||
required: WorkdirToolScopePermission,
|
||||
@@ -1138,6 +1206,11 @@ fn rule_contains_rule(parent: &WorkdirToolScopeRule, child: &WorkdirToolScopeRul
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Resolved < Logical: a child may narrow a Logical grant to Resolved,
|
||||
// but cannot turn a Resolved parent grant into logical-alias authority.
|
||||
if parent.symlink_policy < child.symlink_policy {
|
||||
return false;
|
||||
}
|
||||
if !path_in_rule(parent, &child.target) {
|
||||
return false;
|
||||
}
|
||||
@@ -1168,6 +1241,7 @@ mod tests {
|
||||
target: root.to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
deny: Vec::new(),
|
||||
})
|
||||
@@ -1188,6 +1262,7 @@ mod tests {
|
||||
target: fs_path(path),
|
||||
permission,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
cwd: fs_path(path),
|
||||
command: permission == WorkdirToolScopePermission::Write,
|
||||
@@ -1298,6 +1373,7 @@ mod tests {
|
||||
target: fs_path("work"),
|
||||
permission: WorkdirToolScopePermission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
cwd: fs_path("work"),
|
||||
command: false,
|
||||
@@ -1385,12 +1461,24 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workdir_rule_defaults_to_resolved_symlink_policy_on_restore() {
|
||||
let rule: WorkdirToolScopeRule = serde_json::from_value(serde_json::json!({
|
||||
"target": "src",
|
||||
"permission": "read",
|
||||
"recursive": true
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(rule.symlink_policy, SymlinkPolicy::Resolved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_recursive_rule_covers_target_and_direct_children_only() {
|
||||
let rule = WorkdirToolScopeRule {
|
||||
target: fs_path("docs"),
|
||||
permission: WorkdirToolScopePermission::Read,
|
||||
recursive: false,
|
||||
symlink_policy: Default::default(),
|
||||
};
|
||||
assert!(path_in_rule(&rule, &fs_path("docs")));
|
||||
assert!(path_in_rule(&rule, &fs_path("docs/readme.md")));
|
||||
@@ -1443,7 +1531,7 @@ mod tests {
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn provider_scope_allows_read_through_its_logical_symlink_path() {
|
||||
async fn provider_scope_rejects_symlink_aliases_by_default() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = TempDir::new().unwrap();
|
||||
@@ -1457,6 +1545,73 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
child.read(read("link")).await,
|
||||
Err(WorkdirError::Denied(message))
|
||||
if message.contains("provider-resolved delegated scope")
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn resolved_scope_follows_its_target_but_rejects_nested_escape() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = TempDir::new().unwrap();
|
||||
fs::create_dir_all(root.path().join("target")).unwrap();
|
||||
fs::create_dir_all(root.path().join("secret")).unwrap();
|
||||
fs::write(root.path().join("target/visible"), "visible").unwrap();
|
||||
fs::write(root.path().join("secret/key"), "hidden").unwrap();
|
||||
symlink("target", root.path().join("granted")).unwrap();
|
||||
symlink("../secret/key", root.path().join("target/escape")).unwrap();
|
||||
let parent = session(root.path());
|
||||
let child = parent
|
||||
.scope(request("granted", WorkdirToolScopePermission::Read))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(child.read(read("visible")).await.unwrap().bytes, b"visible");
|
||||
assert!(matches!(
|
||||
child.read(read("escape")).await,
|
||||
Err(WorkdirError::Denied(message))
|
||||
if message.contains("provider-resolved delegated scope")
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nested_scope_cannot_expand_resolved_policy_to_logical() {
|
||||
let root = TempDir::new().unwrap();
|
||||
fs::create_dir_all(root.path().join("granted")).unwrap();
|
||||
let parent = session(root.path());
|
||||
let child = parent
|
||||
.scope(request("granted", WorkdirToolScopePermission::Read))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut expanded = request(".", WorkdirToolScopePermission::Read);
|
||||
expanded.rules[0].symlink_policy = SymlinkPolicy::Logical;
|
||||
|
||||
assert!(matches!(
|
||||
child.scope(expanded).await,
|
||||
Err(WorkdirError::Denied(message))
|
||||
if message.contains("exceeds the parent tool scope")
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn provider_scope_allows_read_through_its_logical_symlink_path() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = TempDir::new().unwrap();
|
||||
fs::create_dir_all(root.path().join("granted")).unwrap();
|
||||
fs::create_dir_all(root.path().join("secret")).unwrap();
|
||||
fs::write(root.path().join("secret/key"), "hidden").unwrap();
|
||||
symlink("../secret/key", root.path().join("granted/link")).unwrap();
|
||||
let parent = session(root.path());
|
||||
let mut scope = request("granted", WorkdirToolScopePermission::Read);
|
||||
scope.rules[0].symlink_policy = SymlinkPolicy::Logical;
|
||||
let child = parent.scope(scope).await.unwrap();
|
||||
|
||||
assert_eq!(child.read(read("link")).await.unwrap().bytes, b"hidden");
|
||||
}
|
||||
|
||||
@@ -1470,10 +1625,9 @@ mod tests {
|
||||
fs::create_dir_all(root.path().join("secret")).unwrap();
|
||||
symlink("../secret", root.path().join("granted/outside")).unwrap();
|
||||
let parent = session(root.path());
|
||||
let child = parent
|
||||
.scope(request("granted", WorkdirToolScopePermission::Write))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut scope = request("granted", WorkdirToolScopePermission::Write);
|
||||
scope.rules[0].symlink_policy = SymlinkPolicy::Logical;
|
||||
let child = parent.scope(scope).await.unwrap();
|
||||
|
||||
child
|
||||
.write(write("outside/new", "through-logical-path"))
|
||||
@@ -1496,13 +1650,9 @@ mod tests {
|
||||
symlink("../secret", root.path().join("granted/outside")).unwrap();
|
||||
let parent = session(root.path());
|
||||
|
||||
let child = parent
|
||||
.scope(request(
|
||||
"granted/outside",
|
||||
WorkdirToolScopePermission::Write,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut scope = request("granted/outside", WorkdirToolScopePermission::Write);
|
||||
scope.rules[0].symlink_policy = SymlinkPolicy::Logical;
|
||||
let child = parent.scope(scope).await.unwrap();
|
||||
child
|
||||
.write(write("from-child", "child-authoritative"))
|
||||
.await
|
||||
|
||||
Reference in New Issue
Block a user