feat: add interactive read-write external workdir sharing
This commit is contained in:
@@ -25,6 +25,7 @@ const MAX_EXTERNAL_PATTERN_BYTES: usize = 4 * 1024;
|
||||
const MAX_EXTERNAL_RESULT_ITEMS: usize = 10_000;
|
||||
const MAX_EXTERNAL_GREP_CONTEXT: usize = 100;
|
||||
const MAX_EXTERNAL_SCOPE_RULES: usize = 64;
|
||||
pub const MAX_EXTERNAL_WRITE_BYTES: usize = 512 * 1024;
|
||||
|
||||
/// Fail-closed wire version for the External Workdir provider protocol.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
@@ -122,7 +123,10 @@ fn is_root_relative(path: &WorkdirPath) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_read_only_operation(operation: &WorkdirSessionOperation) -> Result<(), String> {
|
||||
/// Validate the filesystem-only subset shared by read-only and read-write
|
||||
/// External Workdirs. Capability enforcement remains a separate grant/session
|
||||
/// boundary; command operations never enter the provider protocol.
|
||||
fn validate_filesystem_operation(operation: &WorkdirSessionOperation) -> Result<(), String> {
|
||||
let path_is_valid = match operation {
|
||||
WorkdirSessionOperation::AuthorizeScope(request) => {
|
||||
is_root_relative(&request.path)
|
||||
@@ -165,13 +169,24 @@ fn validate_read_only_operation(operation: &WorkdirSessionOperation) -> Result<(
|
||||
&& request.limit <= MAX_EXTERNAL_RESULT_ITEMS
|
||||
&& request.offset <= 1_000_000
|
||||
}
|
||||
WorkdirSessionOperation::Write(_)
|
||||
| WorkdirSessionOperation::Edit(_)
|
||||
| WorkdirSessionOperation::CommandStart(_)
|
||||
WorkdirSessionOperation::Write(request) => {
|
||||
is_root_relative(&request.path) && request.content.len() <= MAX_EXTERNAL_WRITE_BYTES
|
||||
}
|
||||
WorkdirSessionOperation::Edit(request) => {
|
||||
is_root_relative(&request.path)
|
||||
&& request.old_string.len() <= MAX_EXTERNAL_WRITE_BYTES
|
||||
&& request.new_string.len() <= MAX_EXTERNAL_WRITE_BYTES
|
||||
&& request
|
||||
.old_string
|
||||
.len()
|
||||
.checked_add(request.new_string.len())
|
||||
.is_some_and(|size| size <= MAX_EXTERNAL_WRITE_BYTES)
|
||||
}
|
||||
WorkdirSessionOperation::CommandStart(_)
|
||||
| WorkdirSessionOperation::CommandStatus(_)
|
||||
| WorkdirSessionOperation::CommandOutput(_)
|
||||
| WorkdirSessionOperation::CommandCancel(_) => {
|
||||
return Err("operation is not available to a read-only External Workdir".to_string());
|
||||
return Err("command operations are not available to an External Workdir".to_string());
|
||||
}
|
||||
};
|
||||
if path_is_valid {
|
||||
@@ -181,7 +196,7 @@ fn validate_read_only_operation(operation: &WorkdirSessionOperation) -> Result<(
|
||||
}
|
||||
}
|
||||
|
||||
/// Validated read-only subset of the shared Workdir operation contract.
|
||||
/// Validated filesystem-only subset of the shared Workdir operation contract.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ExternalWorkdirOperation(WorkdirSessionOperation);
|
||||
@@ -200,7 +215,7 @@ impl TryFrom<WorkdirSessionOperation> for ExternalWorkdirOperation {
|
||||
type Error = String;
|
||||
|
||||
fn try_from(operation: WorkdirSessionOperation) -> Result<Self, Self::Error> {
|
||||
validate_read_only_operation(&operation)?;
|
||||
validate_filesystem_operation(&operation)?;
|
||||
Ok(Self(operation))
|
||||
}
|
||||
}
|
||||
@@ -224,7 +239,7 @@ fn result_paths_fit<'a>(paths: impl IntoIterator<Item = &'a WorkdirPath>) -> boo
|
||||
.is_some_and(|retained| retained <= fs_operation::MAX_RESULT_PATH_BYTES)
|
||||
}
|
||||
|
||||
fn validate_read_only_result(result: &WorkdirSessionOperationResult) -> Result<(), String> {
|
||||
fn validate_filesystem_result(result: &WorkdirSessionOperationResult) -> Result<(), String> {
|
||||
let result_is_valid = match result {
|
||||
WorkdirSessionOperationResult::AuthorizeScope
|
||||
| WorkdirSessionOperationResult::ScopeRulesOverlap { .. } => true,
|
||||
@@ -251,14 +266,19 @@ fn validate_read_only_result(result: &WorkdirSessionOperationResult) -> Result<(
|
||||
&& result.match_count <= MAX_EXTERNAL_RESULT_ITEMS
|
||||
&& result.matched_files <= MAX_EXTERNAL_RESULT_ITEMS
|
||||
}
|
||||
WorkdirSessionOperationResult::Write(_)
|
||||
| WorkdirSessionOperationResult::Edit(_)
|
||||
| WorkdirSessionOperationResult::CommandStart(_)
|
||||
WorkdirSessionOperationResult::Write(result) => {
|
||||
result.bytes_written <= MAX_EXTERNAL_WRITE_BYTES
|
||||
}
|
||||
WorkdirSessionOperationResult::Edit(result) => {
|
||||
result.bytes_written <= MAX_EXTERNAL_WRITE_BYTES
|
||||
&& result.replacements <= MAX_EXTERNAL_RESULT_ITEMS
|
||||
}
|
||||
WorkdirSessionOperationResult::CommandStart(_)
|
||||
| WorkdirSessionOperationResult::CommandStatus(_)
|
||||
| WorkdirSessionOperationResult::CommandOutput(_)
|
||||
| WorkdirSessionOperationResult::CommandCancel => {
|
||||
return Err(
|
||||
"operation result is not available to a read-only External Workdir".to_string(),
|
||||
"command operation results are not available to an External Workdir".to_string(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -269,7 +289,7 @@ fn validate_read_only_result(result: &WorkdirSessionOperationResult) -> Result<(
|
||||
}
|
||||
}
|
||||
|
||||
/// Validated read-only subset of the shared Workdir operation result contract.
|
||||
/// Validated filesystem-only subset of the shared Workdir operation result contract.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ExternalWorkdirOperationResult(WorkdirSessionOperationResult);
|
||||
@@ -288,7 +308,7 @@ impl TryFrom<WorkdirSessionOperationResult> for ExternalWorkdirOperationResult {
|
||||
type Error = String;
|
||||
|
||||
fn try_from(result: WorkdirSessionOperationResult) -> Result<Self, Self::Error> {
|
||||
validate_read_only_result(&result)?;
|
||||
validate_filesystem_result(&result)?;
|
||||
Ok(Self(result))
|
||||
}
|
||||
}
|
||||
@@ -411,7 +431,10 @@ mod tests {
|
||||
use serde_json::Value;
|
||||
|
||||
use super::*;
|
||||
use crate::{CommandRequest, GrepResult, ListRequest, ReadRequest, Workdir, WorkdirPath};
|
||||
use crate::{
|
||||
CommandRequest, EditRequest, GrepResult, ListRequest, ReadRequest, Workdir, WorkdirPath,
|
||||
WriteRequest,
|
||||
};
|
||||
|
||||
fn assert_no_provider_authority(value: &Value) {
|
||||
match value {
|
||||
@@ -521,7 +544,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_protocol_rejects_host_paths_and_command_payloads() {
|
||||
fn filesystem_protocol_rejects_host_paths_and_commands_but_allows_bounded_mutations() {
|
||||
let absolute = WorkdirSessionOperation::Read(ReadRequest {
|
||||
path: WorkdirPath::new_scoped("/home/operator/private/session.log").unwrap(),
|
||||
offset: 0,
|
||||
@@ -539,6 +562,33 @@ mod tests {
|
||||
tool_call_id: None,
|
||||
});
|
||||
assert!(ExternalWorkdirOperation::try_from(command).is_err());
|
||||
|
||||
assert!(
|
||||
ExternalWorkdirOperation::try_from(WorkdirSessionOperation::Write(WriteRequest {
|
||||
path: WorkdirPath::new("created.txt").unwrap(),
|
||||
content: b"bounded".to_vec(),
|
||||
expected_hash: None,
|
||||
}))
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
ExternalWorkdirOperation::try_from(WorkdirSessionOperation::Edit(EditRequest {
|
||||
path: WorkdirPath::new("item.txt").unwrap(),
|
||||
old_string: "old".to_string(),
|
||||
new_string: "new".to_string(),
|
||||
replace_all: false,
|
||||
expected_hash: [0; 32],
|
||||
}))
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
ExternalWorkdirOperation::try_from(WorkdirSessionOperation::Write(WriteRequest {
|
||||
path: WorkdirPath::new("large.txt").unwrap(),
|
||||
content: vec![0; MAX_EXTERNAL_WRITE_BYTES + 1],
|
||||
expected_hash: None,
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -79,6 +79,82 @@ async fn external_local_provider_is_strictly_read_only() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_local_provider_read_write_is_filesystem_only() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::fs::write(root.path().join("item.txt"), "original").unwrap();
|
||||
let session = LocalWorkdirSession::external_read_write(
|
||||
Workdir::new("external-workdir-rw"),
|
||||
root.path(),
|
||||
BoundedReadLimits::new(4096, 1024).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(session.capabilities(), WorkdirSessionCapabilities::READ_WRITE);
|
||||
assert!(!session.capabilities().supports(WorkdirSessionCapability::Command));
|
||||
|
||||
let observed = WorkdirSession::read(
|
||||
&session,
|
||||
ReadRequest {
|
||||
path: WorkdirPath::new("item.txt").unwrap(),
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
max_bytes: 1024,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
WorkdirSession::edit(
|
||||
&session,
|
||||
EditRequest {
|
||||
path: WorkdirPath::new("item.txt").unwrap(),
|
||||
old_string: "original".to_string(),
|
||||
new_string: "changed".to_string(),
|
||||
replace_all: false,
|
||||
expected_hash: observed.content_hash,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
WorkdirSession::write(
|
||||
&session,
|
||||
WriteRequest {
|
||||
path: WorkdirPath::new("created.txt").unwrap(),
|
||||
content: b"created".to_vec(),
|
||||
expected_hash: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.path().join("item.txt")).unwrap(),
|
||||
"changed"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.path().join("created.txt")).unwrap(),
|
||||
"created"
|
||||
);
|
||||
let command = WorkdirSession::start_command(
|
||||
&session,
|
||||
CommandRequest {
|
||||
command: "touch command-ran".to_string(),
|
||||
timeout_secs: 1,
|
||||
output_limit: 1024,
|
||||
cwd: WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
tool_call_id: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
command,
|
||||
WorkdirError::Unsupported(WorkdirSessionCapability::Command)
|
||||
));
|
||||
assert!(!root.path().join("command-ran").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn common_dispatcher_preserves_operation_result_pairing_and_capabilities() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
@@ -228,6 +304,51 @@ async fn external_local_provider_rejects_symlink_roots_and_traversal() {
|
||||
assert!(matches!(error, WorkdirError::Denied(_)));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn external_read_write_provider_rejects_symlink_mutation_escape() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
|
||||
symlink(
|
||||
outside.path().join("secret.txt"),
|
||||
root.path().join("outside.txt"),
|
||||
)
|
||||
.unwrap();
|
||||
let session = LocalWorkdirSession::external_read_write(
|
||||
Workdir::new("external-workdir-rw-symlink"),
|
||||
root.path(),
|
||||
BoundedReadLimits::new(4096, 1024).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let error = WorkdirSession::write(
|
||||
&session,
|
||||
WriteRequest {
|
||||
path: WorkdirPath::new("outside.txt").unwrap(),
|
||||
content: b"escaped".to_vec(),
|
||||
expected_hash: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
error,
|
||||
WorkdirError::SymlinkOutOfScope { .. }
|
||||
| WorkdirError::OutOfScope(_)
|
||||
| WorkdirError::Denied(_)
|
||||
),
|
||||
"unexpected error: {error:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(outside.path().join("secret.txt")).unwrap(),
|
||||
"secret"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::test]
|
||||
async fn external_grep_honors_gitignore_above_a_nested_search_root() {
|
||||
|
||||
@@ -134,6 +134,14 @@ impl WorkdirSessionCapabilities {
|
||||
bits: Self::READ | Self::GLOB | Self::GREP,
|
||||
};
|
||||
|
||||
/// Filesystem read/write authority without process execution.
|
||||
///
|
||||
/// External Workdirs use this capability set so operator-approved file
|
||||
/// mutation never implicitly grants command or shell authority.
|
||||
pub const READ_WRITE: Self = Self {
|
||||
bits: Self::READ | Self::WRITE | Self::EDIT | Self::GLOB | Self::GREP,
|
||||
};
|
||||
|
||||
pub const fn union(self, other: Self) -> Self {
|
||||
Self {
|
||||
bits: self.bits | other.bits,
|
||||
|
||||
+90
-11
@@ -347,6 +347,44 @@ impl fs_operation::FsAccessPolicy for ScopeAccess {
|
||||
}
|
||||
}
|
||||
|
||||
fn atomic_write_file(
|
||||
&self,
|
||||
_logical: &Path,
|
||||
resolved: &Path,
|
||||
content: &[u8],
|
||||
) -> std::io::Result<()> {
|
||||
if !self.reject_symlinks {
|
||||
use std::io::Write;
|
||||
|
||||
let parent = resolved.parent().ok_or_else(|| {
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidInput, "file path has no parent")
|
||||
})?;
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
|
||||
temporary.write_all(content)?;
|
||||
temporary.flush()?;
|
||||
temporary.as_file().sync_all()?;
|
||||
self.check_cancelled()?;
|
||||
temporary.persist(resolved).map_err(|error| error.error)?;
|
||||
return Ok(());
|
||||
}
|
||||
let root = self.pinned_root.as_ref().ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::Unsupported,
|
||||
"External Workdir root handle is unavailable",
|
||||
)
|
||||
})?;
|
||||
let relative = resolved.strip_prefix(&self.root).map_err(|_| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
"path is outside provider root",
|
||||
)
|
||||
})?;
|
||||
fs_operation::atomic_write_beneath_no_symlinks_at(root, relative, content, || {
|
||||
self.check_cancelled()
|
||||
})
|
||||
}
|
||||
|
||||
fn open_read_dir(&self, _logical: &Path, resolved: &Path) -> std::io::Result<std::fs::ReadDir> {
|
||||
if !self.reject_symlinks {
|
||||
return std::fs::read_dir(resolved);
|
||||
@@ -686,11 +724,6 @@ impl LocalWorkdirSession {
|
||||
|
||||
/// Construct a strictly read-only provider session for an
|
||||
/// authority-assigned Workdir and an operator-selected local directory.
|
||||
///
|
||||
/// The selected directory is atomically validated and pinned for the
|
||||
/// session lifetime. A symlink selected as the root is rejected, and all
|
||||
/// operation paths are confined below the pinned root without following
|
||||
/// symbolic links. Read source and response sizes are provider-bounded.
|
||||
pub fn external_read_only(
|
||||
workdir: Workdir,
|
||||
directory: impl AsRef<Path>,
|
||||
@@ -700,20 +733,66 @@ impl LocalWorkdirSession {
|
||||
Self::external_read_only_pinned(workdir, root, read_limits)
|
||||
}
|
||||
|
||||
/// Construct a provider session from a root pinned before remote grant
|
||||
/// creation, preventing any later pathname replacement from changing the
|
||||
/// approved filesystem authority.
|
||||
/// Construct a filesystem read/write provider session without command
|
||||
/// authority for an operator-selected local directory.
|
||||
pub fn external_read_write(
|
||||
workdir: Workdir,
|
||||
directory: impl AsRef<Path>,
|
||||
read_limits: BoundedReadLimits,
|
||||
) -> Result<Self, WorkdirError> {
|
||||
let root = ExternalWorkdirRoot::pin(directory)?;
|
||||
Self::external_read_write_pinned(workdir, root, read_limits)
|
||||
}
|
||||
|
||||
/// Construct a read-only provider session from a root pinned before remote
|
||||
/// grant creation.
|
||||
pub fn external_read_only_pinned(
|
||||
workdir: Workdir,
|
||||
pinned: ExternalWorkdirRoot,
|
||||
read_limits: BoundedReadLimits,
|
||||
) -> Result<Self, WorkdirError> {
|
||||
Self::external_pinned(
|
||||
workdir,
|
||||
pinned,
|
||||
read_limits,
|
||||
Permission::Read,
|
||||
WorkdirSessionCapabilities::READ_ONLY,
|
||||
)
|
||||
}
|
||||
|
||||
/// Construct a filesystem read/write provider session from a root pinned
|
||||
/// before remote grant creation. Command execution remains unavailable.
|
||||
pub fn external_read_write_pinned(
|
||||
workdir: Workdir,
|
||||
pinned: ExternalWorkdirRoot,
|
||||
read_limits: BoundedReadLimits,
|
||||
) -> Result<Self, WorkdirError> {
|
||||
Self::external_pinned(
|
||||
workdir,
|
||||
pinned,
|
||||
read_limits,
|
||||
Permission::Write,
|
||||
WorkdirSessionCapabilities::READ_WRITE,
|
||||
)
|
||||
}
|
||||
|
||||
/// The selected directory is atomically validated and pinned for the
|
||||
/// session lifetime. A symlink selected as the root is rejected, and all
|
||||
/// operation paths are confined below the pinned root without following
|
||||
/// symbolic links. Read source and response sizes are provider-bounded.
|
||||
fn external_pinned(
|
||||
workdir: Workdir,
|
||||
pinned: ExternalWorkdirRoot,
|
||||
read_limits: BoundedReadLimits,
|
||||
permission: Permission,
|
||||
capabilities: WorkdirSessionCapabilities,
|
||||
) -> Result<Self, WorkdirError> {
|
||||
read_limits.validate().map_err(WorkdirError::from)?;
|
||||
let root = pinned.canonical_path;
|
||||
let scope = Scope::from_config(&ScopeConfig {
|
||||
allow: vec![ScopeRule {
|
||||
target: root.clone(),
|
||||
permission: Permission::Read,
|
||||
permission,
|
||||
recursive: true,
|
||||
symlink_policy: SymlinkPolicy::Resolved,
|
||||
}],
|
||||
@@ -721,7 +800,7 @@ impl LocalWorkdirSession {
|
||||
})
|
||||
.map_err(|_| {
|
||||
WorkdirError::InvalidArgument(
|
||||
"External Workdir read scope could not be established".to_string(),
|
||||
"External Workdir filesystem scope could not be established".to_string(),
|
||||
)
|
||||
})?;
|
||||
let scope = SharedScope::new(scope);
|
||||
@@ -731,7 +810,7 @@ impl LocalWorkdirSession {
|
||||
root,
|
||||
scope.clone(),
|
||||
scope,
|
||||
WorkdirSessionCapabilities::READ_ONLY,
|
||||
capabilities,
|
||||
BTreeMap::new(),
|
||||
Vec::new(),
|
||||
Some(pinned.handle),
|
||||
|
||||
Reference in New Issue
Block a user