feat: add selective Workdir symlink policies

This commit is contained in:
2026-09-14 19:09:12 +09:00
parent d2cb50d081
commit 8a3e06bc81
34 changed files with 1017 additions and 155 deletions
+16 -1
View File
@@ -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),
+21 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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