feat: add interactive read-write external workdir sharing

This commit is contained in:
2026-09-29 05:53:32 +09:00
parent 54c1174698
commit 24be69918d
12 changed files with 1510 additions and 242 deletions
+66 -16
View File
@@ -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]
+121
View File
@@ -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() {
+8
View File
@@ -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
View File
@@ -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),