fix: enforce delegated workdir scope after resolution

This commit is contained in:
2026-08-19 10:43:37 +09:00
parent 21bd089a23
commit 1cb6cd4e98
11 changed files with 386 additions and 169 deletions
+19
View File
@@ -214,6 +214,25 @@ impl Scope {
}) })
} }
/// Return whether this effective scope fully contains a requested rule.
/// This is used when attenuating provider authority without mutating the
/// parent scope.
pub fn allows_rule(&self, requested: &ScopeRule) -> Result<bool, ScopeError> {
let requested = resolve_rule(requested)?;
let covered = self
.allow
.iter()
.any(|candidate| rule_covers(candidate, &requested));
if !covered {
return Ok(false);
}
let denied = self
.deny
.iter()
.any(|deny| denial_overlaps_requested(deny, &requested));
Ok(!denied)
}
/// Effective permission for `path`. /// Effective permission for `path`.
/// ///
/// Returns `None` when `path` is outside every allow rule, or when /// Returns `None` when `path` is outside every allow rule, or when
+106 -32
View File
@@ -15,20 +15,23 @@ use crate::{
WorkdirSessionHandle, WorkdirSessionHandle,
}; };
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkdirDelegationPermission { pub enum WorkdirDelegationPermission {
Read, Read,
Write, Write,
} }
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkdirDelegationRule { pub struct WorkdirDelegationRule {
pub target: FsPath, pub target: FsPath,
pub permission: WorkdirDelegationPermission, pub permission: WorkdirDelegationPermission,
pub recursive: bool, pub recursive: bool,
} }
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkdirDelegationRequest { pub struct WorkdirDelegationRequest {
pub rules: Vec<WorkdirDelegationRule>, pub rules: Vec<WorkdirDelegationRule>,
pub cwd: FsPath, pub cwd: FsPath,
@@ -101,6 +104,7 @@ struct ActiveWriteLease {
struct DelegatingWorkdirSession { struct DelegatingWorkdirSession {
source: WorkdirSessionHandle, source: WorkdirSessionHandle,
cwd: FsPath,
scope: Option<Vec<WorkdirDelegationRule>>, scope: Option<Vec<WorkdirDelegationRule>>,
capabilities: WorkdirSessionCapabilities, capabilities: WorkdirSessionCapabilities,
validity: Arc<SessionValidity>, validity: Arc<SessionValidity>,
@@ -125,6 +129,7 @@ pub fn delegation_capable_session(source: WorkdirSessionHandle) -> WorkdirSessio
let capabilities = source.capabilities(); let capabilities = source.capabilities();
Arc::new(DelegatingWorkdirSession { Arc::new(DelegatingWorkdirSession {
source, source,
cwd: FsPath::new("").expect("empty Workdir path is valid"),
scope: None, scope: None,
capabilities, capabilities,
validity: SessionValidity::root(), validity: SessionValidity::root(),
@@ -180,6 +185,17 @@ impl DelegatingWorkdirSession {
Ok(()) Ok(())
} }
fn resolve_path(&self, path: &FsPath) -> Result<FsPath, WorkdirError> {
if self.cwd.as_str().is_empty() {
return Ok(path.clone());
}
let joined = Path::new(self.cwd.as_str()).join(path.as_str());
let joined = joined.to_str().ok_or_else(|| {
WorkdirError::Denied("logical Workdir path is not valid UTF-8".into())
})?;
FsPath::new(joined).map_err(|error| WorkdirError::Denied(error.to_string()))
}
fn ensure_read( fn ensure_read(
&self, &self,
path: &FsPath, path: &FsPath,
@@ -308,14 +324,17 @@ impl WorkdirSession for DelegatingWorkdirSession {
true true
} }
async fn capture_delegation_source(&self) -> Result<WorkdirSessionHandle, WorkdirError> { async fn capture_delegation_source(
&self,
request: &WorkdirDelegationRequest,
) -> Result<WorkdirSessionHandle, WorkdirError> {
self.ensure_active()?; self.ensure_active()?;
if self.scope.is_some() { if self.scope.is_some() {
return Err(WorkdirError::Denied( return Err(WorkdirError::Denied(
"scoped Workdir sessions cannot expose their provider source".into(), "scoped Workdir sessions cannot expose their provider source".into(),
)); ));
} }
self.source.capture_delegation_source().await self.source.capture_delegation_source(request).await
} }
async fn delegate( async fn delegate(
@@ -333,7 +352,7 @@ impl WorkdirSession for DelegatingWorkdirSession {
request.cwd request.cwd
))); )));
} }
let source = self.source.capture_delegation_source().await?; let source = self.source.capture_delegation_source(&request).await?;
let validity = SessionValidity::child(self.validity.clone()); let validity = SessionValidity::child(self.validity.clone());
let id = self.next_lease_id.fetch_add(1, Ordering::Relaxed); let id = self.next_lease_id.fetch_add(1, Ordering::Relaxed);
if request if request
@@ -354,6 +373,7 @@ impl WorkdirSession for DelegatingWorkdirSession {
} }
let child: WorkdirSessionHandle = Arc::new(DelegatingWorkdirSession { let child: WorkdirSessionHandle = Arc::new(DelegatingWorkdirSession {
source, source,
cwd: request.cwd,
scope: Some(request.rules), scope: Some(request.rules),
capabilities, capabilities,
validity: validity.clone(), validity: validity.clone(),
@@ -374,37 +394,44 @@ impl WorkdirSession for DelegatingWorkdirSession {
}) })
} }
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> { async fn stat(&self, mut request: StatRequest) -> Result<StatResult, WorkdirError> {
request.path = self.resolve_path(&request.path)?;
self.ensure_read(&request.path, WorkdirSessionCapability::Read)?; self.ensure_read(&request.path, WorkdirSessionCapability::Read)?;
self.source.stat(request).await self.source.stat(request).await
} }
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError> { async fn read(&self, mut request: ReadRequest) -> Result<ReadResult, WorkdirError> {
request.path = self.resolve_path(&request.path)?;
self.ensure_read(&request.path, WorkdirSessionCapability::Read)?; self.ensure_read(&request.path, WorkdirSessionCapability::Read)?;
self.source.read(request).await self.source.read(request).await
} }
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> { async fn write(&self, mut request: WriteRequest) -> Result<WriteResult, WorkdirError> {
request.path = self.resolve_path(&request.path)?;
self.ensure_write(&request.path, WorkdirSessionCapability::Write)?; self.ensure_write(&request.path, WorkdirSessionCapability::Write)?;
self.source.write(request).await self.source.write(request).await
} }
async fn edit(&self, request: EditRequest) -> Result<EditResult, WorkdirError> { async fn edit(&self, mut request: EditRequest) -> Result<EditResult, WorkdirError> {
request.path = self.resolve_path(&request.path)?;
self.ensure_write(&request.path, WorkdirSessionCapability::Edit)?; self.ensure_write(&request.path, WorkdirSessionCapability::Edit)?;
self.source.edit(request).await self.source.edit(request).await
} }
async fn list(&self, request: ListRequest) -> Result<ListResult, WorkdirError> { async fn list(&self, mut request: ListRequest) -> Result<ListResult, WorkdirError> {
request.path = self.resolve_path(&request.path)?;
self.ensure_read(&request.path, WorkdirSessionCapability::Read)?; self.ensure_read(&request.path, WorkdirSessionCapability::Read)?;
self.source.list(request).await self.source.list(request).await
} }
async fn glob(&self, request: GlobRequest) -> Result<GlobResult, WorkdirError> { async fn glob(&self, mut request: GlobRequest) -> Result<GlobResult, WorkdirError> {
request.path = self.resolve_path(&request.path)?;
self.ensure_read(&request.path, WorkdirSessionCapability::Glob)?; self.ensure_read(&request.path, WorkdirSessionCapability::Glob)?;
self.source.glob(request).await self.source.glob(request).await
} }
async fn grep(&self, request: GrepRequest) -> Result<GrepResult, WorkdirError> { async fn grep(&self, mut request: GrepRequest) -> Result<GrepResult, WorkdirError> {
request.path = self.resolve_path(&request.path)?;
self.ensure_read(&request.path, WorkdirSessionCapability::Grep)?; self.ensure_read(&request.path, WorkdirSessionCapability::Grep)?;
self.source.grep(request).await self.source.grep(request).await
} }
@@ -571,7 +598,10 @@ fn rule_contains_rule(parent: &WorkdirDelegationRule, child: &WorkdirDelegationR
if !path_in_rule(parent, &child.target) { if !path_in_rule(parent, &child.target) {
return false; return false;
} }
parent.recursive || !child.recursive if parent.recursive {
return true;
}
!child.recursive && parent.target == child.target
} }
#[cfg(test)] #[cfg(test)]
@@ -650,7 +680,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn read_only_delegation_allows_prefix_and_denies_siblings_and_mutation() { async fn read_only_delegation_allows_prefix_and_denies_mutation() {
let root = TempDir::new().unwrap(); let root = TempDir::new().unwrap();
fs::create_dir_all(root.path().join("docs")).unwrap(); fs::create_dir_all(root.path().join("docs")).unwrap();
fs::create_dir_all(root.path().join("secret")).unwrap(); fs::create_dir_all(root.path().join("secret")).unwrap();
@@ -666,18 +696,14 @@ mod tests {
assert_eq!( assert_eq!(
child child
.scoped_session .scoped_session
.read(read("docs/readme.md")) .read(read("readme.md"))
.await .await
.unwrap() .unwrap()
.bytes, .bytes,
b"visible" b"visible"
); );
assert!(matches!( assert!(matches!(
child.scoped_session.read(read("secret/key")).await, child.scoped_session.write(write("new.md", "no")).await,
Err(WorkdirError::Denied(_))
));
assert!(matches!(
child.scoped_session.write(write("docs/new.md", "no")).await,
Err(WorkdirError::Denied(_)) Err(WorkdirError::Denied(_))
)); ));
assert!( assert!(
@@ -687,6 +713,55 @@ mod tests {
); );
} }
#[cfg(unix)]
#[tokio::test]
async fn provider_scope_denies_read_through_symlink_outside_grant() {
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 child = parent
.delegate(request("granted", WorkdirDelegationPermission::Read))
.await
.unwrap();
let result = child.scoped_session.read(read("link")).await;
assert!(
result.is_err(),
"symlink read escaped provider scope: {result:?}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn provider_scope_denies_write_through_symlink_outside_grant() {
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();
symlink("../secret", root.path().join("granted/outside")).unwrap();
let parent = session(root.path());
let child = parent
.delegate(request("granted", WorkdirDelegationPermission::Write))
.await
.unwrap();
let result = child
.scoped_session
.write(write("outside/new", "forbidden"))
.await;
assert!(
result.is_err(),
"symlink write escaped provider scope: {result:?}"
);
assert!(!root.path().join("secret/new").exists());
}
#[tokio::test] #[tokio::test]
async fn write_lease_blocks_parent_region_until_release() { async fn write_lease_blocks_parent_region_until_release() {
let root = TempDir::new().unwrap(); let root = TempDir::new().unwrap();
@@ -705,7 +780,7 @@ mod tests {
parent.write(write("other/file", "parent")).await.unwrap(); parent.write(write("other/file", "parent")).await.unwrap();
child child
.scoped_session .scoped_session
.write(write("leased/file", "child")) .write(write("file", "child"))
.await .await
.unwrap(); .unwrap();
child.release(); child.release();
@@ -714,7 +789,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
assert!(matches!( assert!(matches!(
child.scoped_session.read(read("leased/file")).await, child.scoped_session.read(read("file")).await,
Err(WorkdirError::SessionClosed) Err(WorkdirError::SessionClosed)
)); ));
} }
@@ -737,15 +812,14 @@ mod tests {
.await .await
.unwrap(); .unwrap();
nested nested.scoped_session.read(read("a")).await.unwrap();
assert!(
child
.scoped_session .scoped_session
.read(read("docs/sub/a")) .delegate(request("other", WorkdirDelegationPermission::Read))
.await .await
.unwrap(); .is_err()
assert!(matches!( );
nested.scoped_session.read(read("docs/peer/b")).await,
Err(WorkdirError::Denied(_))
));
assert!( assert!(
child child
.scoped_session .scoped_session
@@ -756,7 +830,7 @@ mod tests {
child.release(); child.release();
assert!(matches!( assert!(matches!(
nested.scoped_session.read(read("docs/sub/a")).await, nested.scoped_session.read(read("a")).await,
Err(WorkdirError::SessionClosed) Err(WorkdirError::SessionClosed)
)); ));
} }
@@ -774,7 +848,7 @@ mod tests {
parent.close().await.unwrap(); parent.close().await.unwrap();
assert!(matches!( assert!(matches!(
child.scoped_session.read(read("docs/a")).await, child.scoped_session.read(read("a")).await,
Err(WorkdirError::SessionClosed) Err(WorkdirError::SessionClosed)
)); ));
} }
+20 -1
View File
@@ -68,6 +68,15 @@ pub enum WorkdirSessionOperation {
CommandCancel(CommandHandle), CommandCancel(CommandHandle),
} }
/// Wire envelope for an operation and its optional provider-enforced child scope.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkdirSessionOperationRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delegation: Option<crate::WorkdirDelegationRequest>,
pub operation: WorkdirSessionOperation,
}
/// Typed result paired with [`WorkdirSessionOperation`]. /// Typed result paired with [`WorkdirSessionOperation`].
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "operation", content = "result", rename_all = "snake_case")] #[serde(tag = "operation", content = "result", rename_all = "snake_case")]
@@ -207,6 +216,7 @@ mod client {
workdir: Workdir, workdir: Workdir,
session_id: WorkdirSessionId, session_id: WorkdirSessionId,
capabilities: WorkdirSessionCapabilities, capabilities: WorkdirSessionCapabilities,
delegation: Option<crate::WorkdirDelegationRequest>,
closed: AtomicBool, closed: AtomicBool,
} }
@@ -259,6 +269,7 @@ mod client {
workdir: Workdir::new(opened.workdir_id.as_str()), workdir: Workdir::new(opened.workdir_id.as_str()),
session_id: opened.session_id, session_id: opened.session_id,
capabilities: opened.capabilities, capabilities: opened.capabilities,
delegation: None,
closed: AtomicBool::new(false), closed: AtomicBool::new(false),
}) })
} }
@@ -285,6 +296,10 @@ mod client {
"operations", "operations",
], ],
)?; )?;
let operation = WorkdirSessionOperationRequest {
delegation: self.delegation.clone(),
operation,
};
let response = self let response = self
.client .client
.post(url) .post(url)
@@ -313,7 +328,10 @@ mod client {
self.capabilities self.capabilities
} }
async fn capture_delegation_source(&self) -> Result<WorkdirSessionHandle, WorkdirError> { async fn capture_delegation_source(
&self,
request: &crate::WorkdirDelegationRequest,
) -> Result<WorkdirSessionHandle, WorkdirError> {
if self.closed.load(Ordering::Acquire) { if self.closed.load(Ordering::Acquire) {
return Err(WorkdirError::SessionClosed); return Err(WorkdirError::SessionClosed);
} }
@@ -324,6 +342,7 @@ mod client {
workdir: self.workdir.clone(), workdir: self.workdir.clone(),
session_id: self.session_id.clone(), session_id: self.session_id.clone(),
capabilities: self.capabilities, capabilities: self.capabilities,
delegation: Some(request.clone()),
closed: AtomicBool::new(false), closed: AtomicBool::new(false),
})) }))
} }
+4 -1
View File
@@ -151,7 +151,10 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
/// Capture a provider-specific source for a delegated child session. /// Capture a provider-specific source for a delegated child session.
/// Remote providers use this boundary to pin attachment identity without /// Remote providers use this boundary to pin attachment identity without
/// exposing transport handles or host paths. /// exposing transport handles or host paths.
async fn capture_delegation_source(&self) -> Result<WorkdirSessionHandle, WorkdirError> { async fn capture_delegation_source(
&self,
_request: &WorkdirDelegationRequest,
) -> Result<WorkdirSessionHandle, WorkdirError> {
Err(WorkdirError::Denied( Err(WorkdirError::Denied(
"workdir provider does not support delegated sessions".into(), "workdir provider does not support delegated sessions".into(),
)) ))
+45 -6
View File
@@ -19,7 +19,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::Duration; use std::time::Duration;
use async_trait::async_trait; use async_trait::async_trait;
use manifest::{Scope, SharedScope}; use manifest::{Permission, Scope, ScopeConfig, ScopeRule, SharedScope};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use tokio::process::Command; use tokio::process::Command;
use tokio::sync::{Mutex, Notify}; use tokio::sync::{Mutex, Notify};
@@ -28,9 +28,10 @@ use tokio::task::JoinHandle;
use crate::{ use crate::{
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
ReadRequest, ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirPath, ReadRequest, ReadResult, StatRequest, StatResult, Workdir, WorkdirDelegationPermission,
WorkdirSession, WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirSessionHandle, WorkdirDelegationRequest, WorkdirError, WorkdirPath, WorkdirSession,
WriteRequest, WriteResult, WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirSessionHandle, WriteRequest,
WriteResult,
}; };
#[cfg(test)] #[cfg(test)]
use crate::{EntryKind, WriteOutcome}; use crate::{EntryKind, WriteOutcome};
@@ -371,8 +372,46 @@ impl WorkdirSession for LocalWorkdirSession {
self.inner.capabilities self.inner.capabilities
} }
async fn capture_delegation_source(&self) -> Result<WorkdirSessionHandle, WorkdirError> { async fn capture_delegation_source(
Ok(Arc::new(self.clone())) &self,
request: &WorkdirDelegationRequest,
) -> Result<WorkdirSessionHandle, WorkdirError> {
let host_rules = request
.rules
.iter()
.map(|rule| ScopeRule {
target: self.inner.root.join(rule.target.as_str()),
permission: match rule.permission {
WorkdirDelegationPermission::Read => Permission::Read,
WorkdirDelegationPermission::Write => Permission::Write,
},
recursive: rule.recursive,
})
.collect::<Vec<_>>();
let parent_scope = self.inner.scope.snapshot();
for rule in &host_rules {
if !parent_scope
.allows_rule(rule)
.map_err(|error| WorkdirError::Denied(error.to_string()))?
{
return Err(WorkdirError::Denied(format!(
"delegated provider scope `{}` exceeds the parent session",
rule.target.display()
)));
}
}
let child_scope = Scope::from_config(&ScopeConfig {
allow: host_rules,
deny: Vec::new(),
})
.map_err(|error| WorkdirError::Denied(error.to_string()))?;
Ok(Arc::new(LocalWorkdirSession::materialized_bound(
self.inner.workdir.clone(),
self.inner.root.clone(),
self.inner.cwd.clone(),
SharedScope::new(child_scope),
self.inner.capabilities,
)))
} }
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> { async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
+2
View File
@@ -320,6 +320,8 @@ mod tests {
pub struct WorkspaceWorkdirSessionOperationRequest { pub struct WorkspaceWorkdirSessionOperationRequest {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_session_fence: Option<String>, pub expected_session_fence: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delegation: Option<crate::WorkdirDelegationRequest>,
pub operation: crate::http::WorkdirSessionOperation, pub operation: crate::http::WorkdirSessionOperation,
} }
+66 -8
View File
@@ -59,8 +59,8 @@ use workdir::{
CommandOutput, CommandStatus, WorkdirSessionHandle, CommandOutput, CommandStatus, WorkdirSessionHandle,
http::{ http::{
OpenWorkdirSessionRequest, OpenWorkdirSessionResponse, WorkdirSessionId, OpenWorkdirSessionRequest, OpenWorkdirSessionResponse, WorkdirSessionId,
WorkdirSessionOperation, WorkdirSessionOperationResult, WorkdirTransportError, WorkdirSessionOperation, WorkdirSessionOperationRequest, WorkdirSessionOperationResult,
WorkdirTransportErrorCode, WorkdirTransportError, WorkdirTransportErrorCode,
}, },
}; };
@@ -596,11 +596,11 @@ async fn run_workdir_session_operation(
State(state): State<RuntimeHttpState>, State(state): State<RuntimeHttpState>,
Path(session_id): Path<String>, Path(session_id): Path<String>,
auth: Option<Extension<RuntimeAuthContext>>, auth: Option<Extension<RuntimeAuthContext>>,
body: Result<Json<WorkdirSessionOperation>, JsonRejection>, body: Result<Json<WorkdirSessionOperationRequest>, JsonRejection>,
) -> Result<Json<WorkdirSessionOperationResult>, RuntimeHttpWorkdirError> { ) -> Result<Json<WorkdirSessionOperationResult>, RuntimeHttpWorkdirError> {
let Json(operation) = body.map_err(|_| RuntimeHttpWorkdirError::invalid_request())?; let Json(request) = body.map_err(|_| RuntimeHttpWorkdirError::invalid_request())?;
let owner = required_workdir_owner(auth)?; let owner = required_workdir_owner(auth)?;
let session = { let source = {
let sessions = state let sessions = state
.workdir_sessions .workdir_sessions
.lock() .lock()
@@ -611,6 +611,19 @@ async fn run_workdir_session_operation(
.ok_or_else(RuntimeHttpWorkdirError::not_found)?; .ok_or_else(RuntimeHttpWorkdirError::not_found)?;
record.session.clone() record.session.clone()
}; };
let delegation = if let Some(delegation) = request.delegation {
Some(
workdir::delegation_capable_session(source.clone())
.delegate(delegation)
.await?,
)
} else {
None
};
let session = delegation.as_ref().map_or(source.as_ref(), |delegation| {
delegation.scoped_session.as_ref()
});
let operation = request.operation;
let result = match operation { let result = match operation {
WorkdirSessionOperation::Stat(request) => { WorkdirSessionOperation::Stat(request) => {
@@ -1836,7 +1849,8 @@ mod tests {
use manifest::{Scope, SharedScope}; use manifest::{Scope, SharedScope};
use tower::ServiceExt; use tower::ServiceExt;
use workdir::{ use workdir::{
LocalWorkdirSession, StatRequest, Workdir, WorkdirPath, WorkdirSessionCapabilities, LocalWorkdirSession, ReadRequest, StatRequest, Workdir, WorkdirPath,
WorkdirSessionCapabilities,
}; };
fn test_bundle(profile: ProfileSelector) -> ConfigBundle { fn test_bundle(profile: ProfileSelector) -> ConfigBundle {
@@ -2224,6 +2238,14 @@ mod tests {
async fn workdir_session_operations_enforce_owner_and_close_terminally() { async fn workdir_session_operations_enforce_owner_and_close_terminally() {
let temp = tempfile::tempdir().expect("tempdir"); let temp = tempfile::tempdir().expect("tempdir");
std::fs::write(temp.path().join("hello.txt"), "hello").expect("write fixture"); std::fs::write(temp.path().join("hello.txt"), "hello").expect("write fixture");
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
std::fs::create_dir(temp.path().join("granted")).expect("granted directory");
std::fs::create_dir(temp.path().join("secret")).expect("secret directory");
std::fs::write(temp.path().join("secret/key"), "hidden").expect("secret fixture");
symlink("../secret/key", temp.path().join("granted/link")).expect("symlink fixture");
}
let scope = SharedScope::new(Scope::writable(temp.path()).expect("scope")); let scope = SharedScope::new(Scope::writable(temp.path()).expect("scope"));
let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::materialized_bound( let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::materialized_bound(
Workdir::new("wd-1"), Workdir::new("wd-1"),
@@ -2256,9 +2278,12 @@ mod tests {
token_id: "token-a".to_string(), token_id: "token-a".to_string(),
expires_at: u64::MAX, expires_at: u64::MAX,
}; };
let operation = WorkdirSessionOperation::Stat(StatRequest { let operation = WorkdirSessionOperationRequest {
delegation: None,
operation: WorkdirSessionOperation::Stat(StatRequest {
path: WorkdirPath::new("hello.txt").expect("logical path"), path: WorkdirPath::new("hello.txt").expect("logical path"),
}); }),
};
let Json(result) = run_workdir_session_operation( let Json(result) = run_workdir_session_operation(
State(state.clone()), State(state.clone()),
@@ -2270,6 +2295,39 @@ mod tests {
.expect("owned operation"); .expect("owned operation");
assert!(matches!(result, WorkdirSessionOperationResult::Stat(_))); assert!(matches!(result, WorkdirSessionOperationResult::Stat(_)));
#[cfg(unix)]
{
let delegated_read = WorkdirSessionOperationRequest {
delegation: Some(workdir::WorkdirDelegationRequest {
rules: vec![workdir::WorkdirDelegationRule {
target: WorkdirPath::new("granted").unwrap(),
permission: workdir::WorkdirDelegationPermission::Read,
recursive: true,
}],
cwd: WorkdirPath::new("granted").unwrap(),
}),
operation: WorkdirSessionOperation::Read(ReadRequest {
path: WorkdirPath::new("link").unwrap(),
offset: 0,
limit: 20,
max_bytes: 1024,
}),
};
let error = run_workdir_session_operation(
State(state.clone()),
Path("session-1".to_string()),
Some(Extension(auth.clone())),
Ok(Json(delegated_read)),
)
.await
.expect_err("provider must reject delegated symlink escape");
assert_ne!(error.status, StatusCode::OK);
assert_eq!(
std::fs::read_to_string(temp.path().join("secret/key")).unwrap(),
"hidden"
);
}
let wrong_owner = RuntimeAuthContext { let wrong_owner = RuntimeAuthContext {
workspace_id: "workspace-b".to_string(), workspace_id: "workspace-b".to_string(),
..auth.clone() ..auth.clone()
-5
View File
@@ -910,10 +910,6 @@ where
// without one so invocation fails deterministically until the parent // without one so invocation fails deterministically until the parent
// attaches a Workdir. // attaches a Workdir.
if feature_config.sub_worker.enabled { if feature_config.sub_worker.enabled {
let spawner_cwd = local_filesystem
.as_ref()
.map(|local| local.cwd.clone())
.unwrap_or_else(|| PathBuf::from("/"));
let spawner_workspace_root = local_workspace_root let spawner_workspace_root = local_workspace_root
.clone() .clone()
.unwrap_or_else(|| PathBuf::from("/")); .unwrap_or_else(|| PathBuf::from("/"));
@@ -923,7 +919,6 @@ where
parent_notifications, parent_notifications,
runtime_base.clone(), runtime_base.clone(),
spawner_workspace_root, spawner_workspace_root,
spawner_cwd.clone(),
source_workdir_session, source_workdir_session,
spawned_registry.clone(), spawned_registry.clone(),
spawner_manifest, spawner_manifest,
@@ -155,6 +155,7 @@ pub struct WorkspaceAttachedWorkdirSession {
client: Arc<dyn WorkspaceClient>, client: Arc<dyn WorkspaceClient>,
workdir: Workdir, workdir: Workdir,
expected_session_fence: Option<String>, expected_session_fence: Option<String>,
delegation: Option<workdir::WorkdirDelegationRequest>,
} }
impl WorkspaceAttachedWorkdirSession { impl WorkspaceAttachedWorkdirSession {
@@ -163,6 +164,7 @@ impl WorkspaceAttachedWorkdirSession {
client, client,
workdir: Workdir::new("workspace-attachment"), workdir: Workdir::new("workspace-attachment"),
expected_session_fence: None, expected_session_fence: None,
delegation: None,
}) })
} }
@@ -181,6 +183,7 @@ impl WorkspaceAttachedWorkdirSession {
), ),
serde_json::to_string(&WorkspaceWorkdirSessionOperationRequest { serde_json::to_string(&WorkspaceWorkdirSessionOperationRequest {
expected_session_fence: self.expected_session_fence.clone(), expected_session_fence: self.expected_session_fence.clone(),
delegation: self.delegation.clone(),
operation, operation,
}) })
.map_err(|error| { .map_err(|error| {
@@ -231,7 +234,10 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession {
WorkdirSessionCapabilities::ALL WorkdirSessionCapabilities::ALL
} }
async fn capture_delegation_source(&self) -> Result<WorkdirSessionHandle, WorkdirError> { async fn capture_delegation_source(
&self,
request: &workdir::WorkdirDelegationRequest,
) -> Result<WorkdirSessionHandle, WorkdirError> {
let expected_session_fence = if let Some(fence) = &self.expected_session_fence { let expected_session_fence = if let Some(fence) = &self.expected_session_fence {
fence.clone() fence.clone()
} else { } else {
@@ -265,6 +271,7 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession {
client: self.client.clone(), client: self.client.clone(),
workdir: self.workdir.clone(), workdir: self.workdir.clone(),
expected_session_fence: Some(expected_session_fence), expected_session_fence: Some(expected_session_fence),
delegation: Some(request.clone()),
})) }))
} }
@@ -1067,11 +1074,11 @@ mod tests {
let delegation = parent let delegation = parent
.delegate(workdir::WorkdirDelegationRequest { .delegate(workdir::WorkdirDelegationRequest {
rules: vec![workdir::WorkdirDelegationRule { rules: vec![workdir::WorkdirDelegationRule {
target: workdir::WorkdirPath::new("visible.txt").unwrap(), target: workdir::WorkdirPath::new("").unwrap(),
permission: workdir::WorkdirDelegationPermission::Read, permission: workdir::WorkdirDelegationPermission::Read,
recursive: false, recursive: false,
}], }],
cwd: workdir::WorkdirPath::new("visible.txt").unwrap(), cwd: workdir::WorkdirPath::new("").unwrap(),
}) })
.await .await
.unwrap(); .unwrap();
@@ -1093,6 +1100,7 @@ mod tests {
serde_json::from_str(requests[1].body.as_deref().unwrap()).unwrap(); serde_json::from_str(requests[1].body.as_deref().unwrap()).unwrap();
assert_eq!(body["expected_session_fence"], "attachment-fence"); assert_eq!(body["expected_session_fence"], "attachment-fence");
assert_eq!(body["operation"]["operation"], "stat"); assert_eq!(body["operation"]["operation"], "stat");
assert_eq!(body["delegation"]["rules"][0]["target"], "");
} }
#[test] #[test]
+83 -100
View File
@@ -13,7 +13,7 @@ use async_trait::async_trait;
use fs_operation::FsPath; use fs_operation::FsPath;
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use manifest::{ use manifest::{
CompactionConfigPartial, EngineManifestConfig, FileUploadLimitsPartial, Permission, CompactionConfigPartial, EngineManifestConfig, FileUploadLimitsPartial,
PermissionConfigPartial, ProfileDiscovery, ProfileError, ProfileRegistry, PermissionConfigPartial, ProfileDiscovery, ProfileError, ProfileRegistry,
ProfileRegistrySource, ProfileResolveOptions, ProfileResolver, ProfileSelector, ScopeConfig, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver, ProfileSelector, ScopeConfig,
ScopeRule, SessionConfigPartial, ToolOutputLimitsPartial, WorkerManifest, WorkerManifestConfig, ScopeRule, SessionConfigPartial, ToolOutputLimitsPartial, WorkerManifest, WorkerManifestConfig,
@@ -53,11 +53,10 @@ struct SubWorkerSpawnInput {
/// Exact catalog-root dotted Prompt name (for example `default` or `role.coder`). /// Exact catalog-root dotted Prompt name (for example `default` or `role.coder`).
#[serde(default)] #[serde(default)]
instruction: Option<String>, instruction: Option<String>,
/// Child process/tool working directory. This is not the runtime workspace /// Logical Workdir-relative child tool working directory. This path is not
/// root and grants no filesystem authority. When omitted, the spawned SubWorker /// a host path and grants no authority. When omitted, the Workdir root is used.
/// starts in the spawner's current working directory.
#[serde(default)] #[serde(default)]
cwd: Option<PathBuf>, cwd: Option<String>,
/// First message sent to the spawned SubWorker via `Method::Run`. /// First message sent to the spawned SubWorker via `Method::Run`.
task: String, task: String,
/// Allow rules delegated to the spawned SubWorker. Must be a subset of the /// Allow rules delegated to the spawned SubWorker. Must be a subset of the
@@ -77,8 +76,9 @@ struct ReviewerHandoffInput {
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct ScopeRuleInput { struct ScopeRuleInput {
/// Absolute target path. Relative paths are rejected. /// Logical Workdir-relative target such as `.` or `src`. Absolute host
target: PathBuf, /// paths and parent traversal are rejected.
target: String,
/// `"read"` or `"write"`. /// `"read"` or `"write"`.
permission: PermissionInput, permission: PermissionInput,
/// When `false`, the rule matches the target itself and its direct /// When `false`, the rule matches the target itself and its direct
@@ -98,15 +98,6 @@ fn default_true() -> bool {
true true
} }
impl From<PermissionInput> for Permission {
fn from(p: PermissionInput) -> Self {
match p {
PermissionInput::Read => Permission::Read,
PermissionInput::Write => Permission::Write,
}
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct AvailableProfiles { struct AvailableProfiles {
registry: Option<ProfileRegistry>, registry: Option<ProfileRegistry>,
@@ -274,7 +265,6 @@ pub struct SubWorkerSpawnTool {
workspace_root: PathBuf, workspace_root: PathBuf,
/// Directory the spawned SubWorker's tools should use when the LLM did not /// Directory the spawned SubWorker's tools should use when the LLM did not
/// override it. Defaults to the spawner's cwd. /// override it. Defaults to the spawner's cwd.
spawner_cwd: PathBuf,
/// Active provider-backed Workdir session from which child leases are captured. /// Active provider-backed Workdir session from which child leases are captured.
source_workdir_session: Option<WorkdirSessionHandle>, source_workdir_session: Option<WorkdirSessionHandle>,
/// Parent-owned in-memory registry shared by the five SubWorker tools. /// Parent-owned in-memory registry shared by the five SubWorker tools.
@@ -302,7 +292,6 @@ impl SubWorkerSpawnTool {
parent_notifications: ParentNotificationTarget, parent_notifications: ParentNotificationTarget,
runtime_base: PathBuf, runtime_base: PathBuf,
workspace_root: PathBuf, workspace_root: PathBuf,
spawner_cwd: PathBuf,
source_workdir_session: Option<WorkdirSessionHandle>, source_workdir_session: Option<WorkdirSessionHandle>,
registry: Arc<SpawnedWorkerRegistry>, registry: Arc<SpawnedWorkerRegistry>,
spawner_manifest: WorkerManifest, spawner_manifest: WorkerManifest,
@@ -315,7 +304,6 @@ impl SubWorkerSpawnTool {
parent_notifications, parent_notifications,
runtime_base, runtime_base,
workspace_root, workspace_root,
spawner_cwd,
source_workdir_session, source_workdir_session,
registry, registry,
spawner_manifest, spawner_manifest,
@@ -378,11 +366,10 @@ impl Tool for SubWorkerSpawnTool {
.reserve_internal_name(input.name.clone()) .reserve_internal_name(input.name.clone())
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?; .map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
let scope_allow = parse_scope(&input.scope)?; let workdir_rules = parse_workdir_scope(&input.scope)?;
let source_workdir_session = let source_workdir_session =
require_active_workdir_session(self.source_workdir_session.as_ref())?; require_active_workdir_session(self.source_workdir_session.as_ref())?;
let delegation_request = let delegation_request = workdir_delegation_request(input.cwd.as_deref(), workdir_rules)?;
self.workdir_delegation_request(input.cwd.as_deref(), &scope_allow)?;
let workdir_delegation = source_workdir_session let workdir_delegation = source_workdir_session
.delegate(delegation_request) .delegate(delegation_request)
.await .await
@@ -397,6 +384,7 @@ impl Tool for SubWorkerSpawnTool {
self.available_profiles.error_suffix() self.available_profiles.error_suffix()
)) ))
})?; })?;
let scope_allow = Vec::new();
let spawn_config_json = self let spawn_config_json = self
.build_spawn_config_json( .build_spawn_config_json(
&input.name, &input.name,
@@ -618,64 +606,58 @@ impl Tool for SubWorkerSpawnTool {
} }
} }
impl SubWorkerSpawnTool { fn logical_workdir_path(value: &str, field: &str) -> Result<FsPath, ToolError> {
fn workdir_delegation_request( let path = Path::new(value);
&self, if path.is_absolute() {
cwd: Option<&Path>, return Err(ToolError::InvalidArgument(format!(
scope_allow: &[ScopeRule], "{field} must be Workdir-relative, got `{value}`"
) -> Result<WorkdirDelegationRequest, ToolError> { )));
let rules = scope_allow }
let normalized = path
.components()
.filter_map(|component| match component {
std::path::Component::CurDir => None,
other => Some(other.as_os_str()),
})
.collect::<PathBuf>();
let normalized = normalized.to_str().ok_or_else(|| {
ToolError::InvalidArgument(format!("{field} `{value}` is not valid UTF-8"))
})?;
FsPath::new(normalized).map_err(|error| {
ToolError::InvalidArgument(format!(
"{field} `{value}` is not a valid logical Workdir path: {error}"
))
})
}
fn parse_workdir_scope(rules: &[ScopeRuleInput]) -> Result<Vec<WorkdirDelegationRule>, ToolError> {
if rules.is_empty() {
return Err(ToolError::InvalidArgument("scope must not be empty".into()));
}
rules
.iter() .iter()
.map(|rule| { .map(|rule| {
Ok(WorkdirDelegationRule { Ok(WorkdirDelegationRule {
target: self.logical_workdir_path(&rule.target)?, target: logical_workdir_path(&rule.target, "scope.target")?,
permission: match rule.permission { permission: match rule.permission {
Permission::Read => WorkdirDelegationPermission::Read, PermissionInput::Read => WorkdirDelegationPermission::Read,
Permission::Write => WorkdirDelegationPermission::Write, PermissionInput::Write => WorkdirDelegationPermission::Write,
}, },
recursive: rule.recursive, recursive: rule.recursive,
}) })
}) })
.collect::<Result<Vec<_>, ToolError>>()?; .collect()
let cwd = cwd.unwrap_or(&self.spawner_cwd);
if !cwd.is_absolute() {
return Err(ToolError::InvalidArgument(format!(
"cwd must be absolute, got `{}`",
cwd.display()
)));
}
Ok(WorkdirDelegationRequest {
rules,
cwd: self.logical_workdir_path(cwd)?,
})
} }
fn logical_workdir_path(&self, path: &Path) -> Result<FsPath, ToolError> { fn workdir_delegation_request(
let logical = if self.workspace_root == Path::new("/") { cwd: Option<&str>,
path.strip_prefix(Path::new("/")) rules: Vec<WorkdirDelegationRule>,
} else { ) -> Result<WorkdirDelegationRequest, ToolError> {
path.strip_prefix(&self.workspace_root) Ok(WorkdirDelegationRequest {
} rules,
.map_err(|_| { cwd: logical_workdir_path(cwd.unwrap_or("."), "cwd")?,
ToolError::InvalidArgument(format!(
"scope target `{}` is not a Workdir-owned logical path",
path.display()
))
})?;
let logical = logical.to_str().ok_or_else(|| {
ToolError::InvalidArgument(format!(
"scope target `{}` is not valid UTF-8",
path.display()
))
})?;
FsPath::new(logical).map_err(|error| {
ToolError::InvalidArgument(format!(
"scope target `{}` is not a valid logical Workdir path: {error}",
path.display()
))
}) })
} }
}
fn require_active_workdir_session( fn require_active_workdir_session(
session: Option<&WorkdirSessionHandle>, session: Option<&WorkdirSessionHandle>,
@@ -688,28 +670,6 @@ fn require_active_workdir_session(
}) })
} }
fn parse_scope(rules: &[ScopeRuleInput]) -> Result<Vec<ScopeRule>, ToolError> {
if rules.is_empty() {
return Err(ToolError::InvalidArgument("scope must not be empty".into()));
}
rules
.iter()
.map(|r| {
if !r.target.is_absolute() {
return Err(ToolError::InvalidArgument(format!(
"scope.target must be absolute: {}",
r.target.display()
)));
}
Ok(ScopeRule {
target: r.target.clone(),
permission: r.permission.into(),
recursive: r.recursive,
})
})
.collect()
}
/// Serialise the internal manifest config that gets handed to the child /// Serialise the internal manifest config that gets handed to the child
/// Worker runtime process via the hidden `--spawn-config-json` flag. /// Worker runtime process via the hidden `--spawn-config-json` flag.
/// `WorkerManifestConfig`'s `Serialize` impl is the single source of truth for the /// `WorkerManifestConfig`'s `Serialize` impl is the single source of truth for the
@@ -915,7 +875,6 @@ pub(crate) fn sub_worker_spawn_tool(
parent_notifications: ParentNotificationTarget, parent_notifications: ParentNotificationTarget,
runtime_base: PathBuf, runtime_base: PathBuf,
workspace_root: PathBuf, workspace_root: PathBuf,
spawner_cwd: PathBuf,
source_workdir_session: Option<WorkdirSessionHandle>, source_workdir_session: Option<WorkdirSessionHandle>,
registry: Arc<SpawnedWorkerRegistry>, registry: Arc<SpawnedWorkerRegistry>,
spawner_manifest: WorkerManifest, spawner_manifest: WorkerManifest,
@@ -927,7 +886,6 @@ pub(crate) fn sub_worker_spawn_tool(
parent_notifications, parent_notifications,
runtime_base, runtime_base,
workspace_root, workspace_root,
spawner_cwd,
source_workdir_session, source_workdir_session,
registry, registry,
spawner_manifest, spawner_manifest,
@@ -941,7 +899,6 @@ fn sub_worker_spawn_tool_impl(
parent_notifications: ParentNotificationTarget, parent_notifications: ParentNotificationTarget,
runtime_base: PathBuf, runtime_base: PathBuf,
workspace_root: PathBuf, workspace_root: PathBuf,
spawner_cwd: PathBuf,
source_workdir_session: Option<WorkdirSessionHandle>, source_workdir_session: Option<WorkdirSessionHandle>,
registry: Arc<SpawnedWorkerRegistry>, registry: Arc<SpawnedWorkerRegistry>,
spawner_manifest: WorkerManifest, spawner_manifest: WorkerManifest,
@@ -973,7 +930,6 @@ fn sub_worker_spawn_tool_impl(
parent_notifications.clone(), parent_notifications.clone(),
runtime_base.clone(), runtime_base.clone(),
workspace_root.clone(), workspace_root.clone(),
spawner_cwd.clone(),
source_workdir_session.clone(), source_workdir_session.clone(),
registry.clone(), registry.clone(),
spawner_manifest.clone(), spawner_manifest.clone(),
@@ -987,7 +943,7 @@ fn sub_worker_spawn_tool_impl(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use manifest::{DelegationScope, Scope, SharedScope}; use manifest::{DelegationScope, Permission, Scope, SharedScope};
use std::pin::Pin; use std::pin::Pin;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::time::Duration; use std::time::Duration;
@@ -1016,25 +972,53 @@ mod tests {
)); ));
} }
#[test]
fn workdir_scope_uses_logical_relative_paths() {
let rules = parse_workdir_scope(&[
ScopeRuleInput {
target: ".".to_string(),
permission: PermissionInput::Read,
recursive: true,
},
ScopeRuleInput {
target: "src".to_string(),
permission: PermissionInput::Write,
recursive: false,
},
])
.unwrap();
assert_eq!(rules[0].target.as_str(), "");
assert_eq!(rules[1].target.as_str(), "src");
for target in ["/host/path", "../escape"] {
let error = parse_workdir_scope(&[ScopeRuleInput {
target: target.to_string(),
permission: PermissionInput::Read,
recursive: true,
}])
.unwrap_err();
assert!(matches!(error, ToolError::InvalidArgument(_)));
}
}
#[test] #[test]
fn reviewer_handoff_requires_explicit_builtin_profile_and_read_only_scope() { fn reviewer_handoff_requires_explicit_builtin_profile_and_read_only_scope() {
let valid: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({ let valid: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
"name":"reviewer","task":"review","profile":"builtin:reviewer", "name":"reviewer","task":"review","profile":"builtin:reviewer",
"scope":[{"target":"/tmp/work","permission":"read"}], "scope":[{"target":"work","permission":"read"}],
"review":{"ticket_id":"T1"} "review":{"ticket_id":"T1"}
})) }))
.unwrap(); .unwrap();
assert!(validate_reviewer_handoff(&valid).is_ok()); assert!(validate_reviewer_handoff(&valid).is_ok());
let wrong_profile: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({ let wrong_profile: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
"name":"reviewer","task":"review","profile":"builtin:coder", "name":"reviewer","task":"review","profile":"builtin:coder",
"scope":[{"target":"/tmp/work","permission":"read"}], "scope":[{"target":"work","permission":"read"}],
"review":{"ticket_id":"T1"} "review":{"ticket_id":"T1"}
})) }))
.unwrap(); .unwrap();
assert!(validate_reviewer_handoff(&wrong_profile).is_err()); assert!(validate_reviewer_handoff(&wrong_profile).is_err());
let writable: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({ let writable: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
"name":"reviewer","task":"review","profile":"builtin:reviewer", "name":"reviewer","task":"review","profile":"builtin:reviewer",
"scope":[{"target":"/tmp/work","permission":"write"}], "scope":[{"target":"work","permission":"write"}],
"review":{"ticket_id":"T1"} "review":{"ticket_id":"T1"}
})) }))
.unwrap(); .unwrap();
@@ -1128,7 +1112,6 @@ extract_threshold = 4000
ParentNotificationTarget::Controller(parent_method_tx.downgrade()), ParentNotificationTarget::Controller(parent_method_tx.downgrade()),
runtime.path().to_path_buf(), runtime.path().to_path_buf(),
workspace_root.clone(), workspace_root.clone(),
workspace_root.clone(),
Some(source_workdir_session), Some(source_workdir_session),
registry.clone(), registry.clone(),
manifest.clone(), manifest.clone(),
@@ -1149,7 +1132,7 @@ extract_threshold = 4000
"instruction": "role.reviewer", "instruction": "role.reviewer",
"task": "review immutable commit", "task": "review immutable commit",
"scope": [{ "scope": [{
"target": workspace_root.clone(), "target": ".",
"permission": "read", "permission": "read",
"recursive": true "recursive": true
}] }]
+19 -2
View File
@@ -5138,8 +5138,25 @@ async fn scoped_execute_current_worker_workdir_operation(
&link, &link,
request.expected_session_fence.as_deref(), request.expected_session_fence.as_deref(),
)?; )?;
let session = open_current_worker_workdir_session_locked(&api, &worker, &link).await?; let source = open_current_worker_workdir_session_locked(&api, &worker, &link).await?;
let result = execute_workdir_session_operation(&session, request.operation) let delegation = if let Some(delegation) = request.delegation {
Some(
workdir::delegation_capable_session(source.clone())
.delegate(delegation)
.await
.map_err(|error| Error::RuntimeOperationFailed {
runtime_id: worker.runtime_id.clone(),
code: "workdir_session_delegation_failed".to_string(),
message: error.to_string(),
})?,
)
} else {
None
};
let session = delegation
.as_ref()
.map_or(&source, |delegation| &delegation.scoped_session);
let result = execute_workdir_session_operation(session, request.operation)
.await .await
.map_err(|error| Error::RuntimeOperationFailed { .map_err(|error| Error::RuntimeOperationFailed {
runtime_id: worker.runtime_id.clone(), runtime_id: worker.runtime_id.clone(),