feat: add external workdir command permissions

This commit is contained in:
2026-09-29 11:26:25 +09:00
parent d1303ab638
commit 8b951d53e6
20 changed files with 1445 additions and 238 deletions
+1
View File
@@ -11,6 +11,7 @@ http-client = ["dep:reqwest"]
[dependencies]
async-trait.workspace = true
fs-operation.workspace = true
libc.workspace = true
manifest.workspace = true
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"], optional = true }
schemars.workspace = true
+102 -21
View File
@@ -26,6 +26,17 @@ pub 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;
pub const MAX_EXTERNAL_COMMAND_BYTES: usize = 64 * 1024;
pub const MAX_EXTERNAL_COMMAND_OUTPUT_BYTES: usize = 512 * 1024;
pub const MAX_EXTERNAL_COMMAND_TIMEOUT_SECS: u64 = 600;
const MAX_EXTERNAL_COMMAND_CURSOR: usize = 1_000_000;
fn valid_command_handle(handle: &crate::CommandHandle) -> bool {
!handle.0.is_empty()
&& handle.0.len() <= MAX_PROTOCOL_ID_BYTES
&& handle.0.trim() == handle.0
&& !handle.0.chars().any(char::is_control)
}
/// Fail-closed wire version for the External Workdir provider protocol.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
@@ -123,10 +134,9 @@ fn is_root_relative(path: &WorkdirPath) -> bool {
})
}
/// 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> {
/// Validate the bounded operation subset available to External Workdirs.
/// Capability enforcement remains a separate grant/session boundary.
fn validate_external_operation(operation: &WorkdirSessionOperation) -> Result<(), String> {
let path_is_valid = match operation {
WorkdirSessionOperation::AuthorizeScope(request) => {
is_root_relative(&request.path)
@@ -182,17 +192,34 @@ fn validate_filesystem_operation(operation: &WorkdirSessionOperation) -> Result<
.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("command operations are not available to an External Workdir".to_string());
WorkdirSessionOperation::CommandStart(request) => {
!request.command.is_empty()
&& request.command.len() <= MAX_EXTERNAL_COMMAND_BYTES
&& (1..=MAX_EXTERNAL_COMMAND_TIMEOUT_SECS).contains(&request.timeout_secs)
&& (1..=MAX_EXTERNAL_COMMAND_OUTPUT_BYTES).contains(&request.output_limit)
&& is_root_relative(&request.cwd)
&& request.spill_dir.is_none()
&& request.tool_call_id.as_ref().is_none_or(|tool_call_id| {
!tool_call_id.is_empty()
&& tool_call_id.len() <= MAX_PROTOCOL_ID_BYTES
&& !tool_call_id.chars().any(char::is_control)
})
}
WorkdirSessionOperation::CommandStatus(handle)
| WorkdirSessionOperation::CommandCancel(handle) => valid_command_handle(handle),
WorkdirSessionOperation::CommandOutput(request) => {
valid_command_handle(&request.handle)
&& request.cursor <= MAX_EXTERNAL_COMMAND_CURSOR
&& (1..=MAX_EXTERNAL_COMMAND_OUTPUT_BYTES).contains(&request.limit)
}
};
if path_is_valid {
Ok(())
} else {
Err("External Workdir operation paths must be root-relative".to_string())
Err(
"External Workdir operation exceeds protocol bounds or uses an invalid path"
.to_string(),
)
}
}
@@ -215,7 +242,7 @@ impl TryFrom<WorkdirSessionOperation> for ExternalWorkdirOperation {
type Error = String;
fn try_from(operation: WorkdirSessionOperation) -> Result<Self, Self::Error> {
validate_filesystem_operation(&operation)?;
validate_external_operation(&operation)?;
Ok(Self(operation))
}
}
@@ -239,7 +266,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_filesystem_result(result: &WorkdirSessionOperationResult) -> Result<(), String> {
fn validate_external_result(result: &WorkdirSessionOperationResult) -> Result<(), String> {
let result_is_valid = match result {
WorkdirSessionOperationResult::AuthorizeScope
| WorkdirSessionOperationResult::ScopeRulesOverlap { .. } => true,
@@ -273,14 +300,16 @@ fn validate_filesystem_result(result: &WorkdirSessionOperationResult) -> Result<
result.bytes_written <= MAX_EXTERNAL_WRITE_BYTES
&& result.replacements <= MAX_EXTERNAL_RESULT_ITEMS
}
WorkdirSessionOperationResult::CommandStart(_)
| WorkdirSessionOperationResult::CommandStatus(_)
| WorkdirSessionOperationResult::CommandOutput(_)
| WorkdirSessionOperationResult::CommandCancel => {
return Err(
"command operation results are not available to an External Workdir".to_string(),
);
WorkdirSessionOperationResult::CommandStart(handle) => valid_command_handle(handle),
WorkdirSessionOperationResult::CommandStatus(_) => true,
WorkdirSessionOperationResult::CommandOutput(output) => {
output.content.len() <= MAX_EXTERNAL_COMMAND_OUTPUT_BYTES
&& output
.next_cursor
.is_none_or(|cursor| cursor <= MAX_EXTERNAL_COMMAND_CURSOR)
&& output.output_path.is_none()
}
WorkdirSessionOperationResult::CommandCancel => true,
};
if result_is_valid {
Ok(())
@@ -308,7 +337,7 @@ impl TryFrom<WorkdirSessionOperationResult> for ExternalWorkdirOperationResult {
type Error = String;
fn try_from(result: WorkdirSessionOperationResult) -> Result<Self, Self::Error> {
validate_filesystem_result(&result)?;
validate_external_result(&result)?;
Ok(Self(result))
}
}
@@ -544,7 +573,7 @@ mod tests {
}
#[test]
fn filesystem_protocol_rejects_host_paths_and_commands_but_allows_bounded_mutations() {
fn protocol_rejects_host_paths_and_unbounded_commands_but_allows_bounded_operations() {
let absolute = WorkdirSessionOperation::Read(ReadRequest {
path: WorkdirPath::new_scoped("/home/operator/private/session.log").unwrap(),
offset: 0,
@@ -563,6 +592,29 @@ mod tests {
});
assert!(ExternalWorkdirOperation::try_from(command).is_err());
let command = WorkdirSessionOperation::CommandStart(CommandRequest {
command: "printf bounded".to_string(),
timeout_secs: 30,
output_limit: 12 * 1024,
cwd: WorkdirPath::root(),
spill_dir: None,
tool_call_id: Some("tool-call-1".to_string()),
});
assert!(ExternalWorkdirOperation::try_from(command).is_ok());
assert!(
ExternalWorkdirOperation::try_from(WorkdirSessionOperation::CommandStart(
CommandRequest {
command: "x".repeat(MAX_EXTERNAL_COMMAND_BYTES + 1),
timeout_secs: 30,
output_limit: 12 * 1024,
cwd: WorkdirPath::root(),
spill_dir: None,
tool_call_id: None,
}
))
.is_err()
);
assert!(
ExternalWorkdirOperation::try_from(WorkdirSessionOperation::Write(WriteRequest {
path: WorkdirPath::new("created.txt").unwrap(),
@@ -589,6 +641,35 @@ mod tests {
}))
.is_err()
);
assert!(
ExternalWorkdirOperationResult::try_from(WorkdirSessionOperationResult::CommandOutput(
crate::CommandOutput {
status: crate::CommandStatus::Completed,
exit_code: Some(0),
timed_out: false,
content: "bounded".to_string(),
next_cursor: None,
truncated: false,
output_path: None,
}
))
.is_ok()
);
assert!(
ExternalWorkdirOperationResult::try_from(WorkdirSessionOperationResult::CommandOutput(
crate::CommandOutput {
status: crate::CommandStatus::Completed,
exit_code: Some(0),
timed_out: false,
content: "bounded".to_string(),
next_cursor: None,
truncated: true,
output_path: Some("/provider/private/output".into()),
}
))
.is_err()
);
}
#[test]
+71 -4
View File
@@ -2,10 +2,10 @@ use crate::http::{
WorkdirSessionOperation, WorkdirSessionOperationResult, dispatch_workdir_session_operation,
};
use crate::{
BoundedReadLimits, CommandRequest, EditRequest, ExternalWorkdirRoot, GlobRequest,
GrepOutputMode, GrepRequest, LocalWorkdirSession, ReadRequest, Workdir, WorkdirError,
WorkdirPath, WorkdirSession, WorkdirSessionCapabilities, WorkdirSessionCapability,
WriteRequest,
BoundedReadLimits, CommandOutputRequest, CommandRequest, EditRequest, ExternalWorkdirRoot,
GlobRequest, GrepOutputMode, GrepRequest, LocalWorkdirSession, ReadRequest, Workdir,
WorkdirError, WorkdirPath, WorkdirSession, WorkdirSessionCapabilities,
WorkdirSessionCapability, WriteRequest,
};
fn external_session(root: &tempfile::TempDir, limits: BoundedReadLimits) -> LocalWorkdirSession {
@@ -162,6 +162,73 @@ async fn external_local_provider_read_write_is_filesystem_only() {
assert!(!root.path().join("command-ran").exists());
}
#[tokio::test]
async fn external_command_permission_is_explicit_and_independent_from_file_access() {
let root = tempfile::tempdir().unwrap();
std::fs::write(root.path().join("item.txt"), "private").unwrap();
let pinned = ExternalWorkdirRoot::pin(root.path()).unwrap();
let session = LocalWorkdirSession::external_with_capabilities_pinned(
Workdir::new("external-workdir-command"),
pinned,
BoundedReadLimits::new(4096, 1024).unwrap(),
WorkdirSessionCapabilities::COMMAND_ONLY,
)
.unwrap();
assert_eq!(
session.capabilities(),
WorkdirSessionCapabilities::COMMAND_ONLY
);
assert!(matches!(
session
.read(ReadRequest {
path: WorkdirPath::new("item.txt").unwrap(),
offset: 0,
limit: 10,
max_bytes: 1024,
})
.await,
Err(WorkdirError::Unsupported(WorkdirSessionCapability::Read))
));
let handle = session
.start_command(CommandRequest {
command: "printf command-ok".to_string(),
timeout_secs: 5,
output_limit: 1024,
cwd: WorkdirPath::root(),
spill_dir: None,
tool_call_id: None,
})
.await
.unwrap();
let output = session
.command_output(CommandOutputRequest {
handle,
cursor: 0,
limit: 1024,
wait: true,
})
.await
.unwrap();
assert_eq!(output.content, "command-ok");
assert_eq!(output.exit_code, Some(0));
}
#[test]
fn external_write_category_never_implicitly_enables_read() {
let root = tempfile::tempdir().unwrap();
let pinned = ExternalWorkdirRoot::pin(root.path()).unwrap();
let error = LocalWorkdirSession::external_with_capabilities_pinned(
Workdir::new("external-workdir-invalid-write"),
pinned,
BoundedReadLimits::new(4096, 1024).unwrap(),
WorkdirSessionCapabilities::WRITE_ONLY,
)
.unwrap_err();
assert!(error.to_string().contains("WRITE requires READ"));
}
#[tokio::test]
async fn common_dispatcher_preserves_operation_result_pairing_and_capabilities() {
let root = tempfile::tempdir().unwrap();
+30
View File
@@ -134,6 +134,16 @@ impl WorkdirSessionCapabilities {
bits: Self::READ | Self::GLOB | Self::GREP,
};
/// Operator-facing WRITE category without implicitly granting READ or COMMAND.
pub const WRITE_ONLY: Self = Self {
bits: Self::WRITE | Self::EDIT,
};
/// Operator-facing COMMAND category without implicitly granting file access.
pub const COMMAND_ONLY: Self = Self {
bits: Self::COMMAND,
};
/// Filesystem read/write authority without process execution.
///
/// External Workdirs use this capability set so operator-approved file
@@ -391,3 +401,23 @@ impl From<fs_operation::FsError> for WorkdirError {
}
}
}
#[cfg(test)]
mod capability_tests {
use super::*;
fn bits(capabilities: WorkdirSessionCapabilities) -> u8 {
serde_json::to_value(capabilities).unwrap()["bits"]
.as_u64()
.unwrap() as u8
}
#[test]
fn external_categories_preserve_operation_bit_values() {
assert_eq!(bits(WorkdirSessionCapabilities::READ_ONLY), 25);
assert_eq!(bits(WorkdirSessionCapabilities::WRITE_ONLY), 6);
assert_eq!(bits(WorkdirSessionCapabilities::READ_WRITE), 31);
assert_eq!(bits(WorkdirSessionCapabilities::COMMAND_ONLY), 32);
assert_eq!(bits(WorkdirSessionCapabilities::ALL), 63);
}
}
+138 -8
View File
@@ -11,6 +11,8 @@
use std::collections::{BTreeMap, HashMap};
use std::fmt::Debug;
use std::io::{Read as _, Seek as _, SeekFrom, Write as _};
#[cfg(unix)]
use std::os::unix::process::CommandExt as _;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
@@ -762,6 +764,42 @@ impl LocalWorkdirSession {
Self::external_read_write_pinned(workdir, root, read_limits)
}
/// Construct an External provider session with an exact operator-approved
/// READ/WRITE/COMMAND category set. WRITE requires READ so callers can
/// satisfy the read-before-write contract; COMMAND remains independent.
pub fn external_with_capabilities_pinned(
workdir: Workdir,
pinned: ExternalWorkdirRoot,
read_limits: BoundedReadLimits,
capabilities: WorkdirSessionCapabilities,
) -> Result<Self, WorkdirError> {
let read = WorkdirSessionCapabilities::READ_ONLY;
let read_write = WorkdirSessionCapabilities::READ_WRITE;
let command = WorkdirSessionCapabilities::COMMAND_ONLY;
if ![
read,
read_write,
command,
read.union(command),
read_write.union(command),
]
.contains(&capabilities)
{
return Err(WorkdirError::InvalidArgument(
"External Workdir capabilities must contain at least one of READ or COMMAND, and WRITE requires READ"
.to_string(),
));
}
let permission = if capabilities.supports(WorkdirSessionCapability::Write) {
Permission::Write
} else {
// Command execution still needs provider-internal read access to
// validate and enter its cwd. This scope is not an exposed READ grant.
Permission::Read
};
Self::external_pinned(workdir, pinned, read_limits, permission, capabilities)
}
/// Construct a read-only provider session from a root pinned before remote
/// grant creation.
pub fn external_read_only_pinned(
@@ -769,11 +807,10 @@ impl LocalWorkdirSession {
pinned: ExternalWorkdirRoot,
read_limits: BoundedReadLimits,
) -> Result<Self, WorkdirError> {
Self::external_pinned(
Self::external_with_capabilities_pinned(
workdir,
pinned,
read_limits,
Permission::Read,
WorkdirSessionCapabilities::READ_ONLY,
)
}
@@ -785,11 +822,10 @@ impl LocalWorkdirSession {
pinned: ExternalWorkdirRoot,
read_limits: BoundedReadLimits,
) -> Result<Self, WorkdirError> {
Self::external_pinned(
Self::external_with_capabilities_pinned(
workdir,
pinned,
read_limits,
Permission::Write,
WorkdirSessionCapabilities::READ_WRITE,
)
}
@@ -1556,6 +1592,45 @@ fn sanitize_error(error: WorkdirError, logical: &WorkdirPath) -> WorkdirError {
}
}
#[derive(Debug)]
struct CommandProcessGroupGuard {
process_group_id: Option<u32>,
}
impl CommandProcessGroupGuard {
fn new(process_group_id: Option<u32>) -> Self {
Self { process_group_id }
}
fn terminate(&mut self) {
let Some(process_group_id) = self.process_group_id.take() else {
return;
};
terminate_process_group(process_group_id);
}
}
impl Drop for CommandProcessGroupGuard {
fn drop(&mut self) {
self.terminate();
}
}
#[cfg(unix)]
fn terminate_process_group(process_group_id: u32) {
let Ok(process_group_id) = i32::try_from(process_group_id) else {
return;
};
// The command is spawned as its own process-group leader below. A negative
// pid addresses that exact group, including descendants that outlive bash.
unsafe {
libc::kill(-process_group_id, libc::SIGKILL);
}
}
#[cfg(not(unix))]
fn terminate_process_group(_process_group_id: u32) {}
async fn run_command(
cwd: PathBuf,
request: CommandRequest,
@@ -1574,7 +1649,8 @@ async fn run_command(
.map_err(|error| WorkdirError::io(&stderr_path, error))?;
telemetry.started(&command_id, request.tool_call_id.clone());
let mut child = match Command::new("bash")
let mut command = Command::new("bash");
command
.arg("-c")
.arg(&request.command)
.current_dir(&cwd)
@@ -1582,15 +1658,17 @@ async fn run_command(
.stdin(Stdio::null())
.stdout(Stdio::from(stdout_file))
.stderr(Stdio::from(stderr_file))
.kill_on_drop(true)
.spawn()
{
.kill_on_drop(true);
#[cfg(unix)]
command.as_std_mut().process_group(0);
let mut child = match command.spawn() {
Ok(child) => child,
Err(error) => {
telemetry.terminal(&command_id, CommandStatus::Failed, None);
return Err(WorkdirError::io(&cwd, error));
}
};
let mut process_group = CommandProcessGroupGuard::new(child.id());
let mut stdout_reader =
std::fs::File::open(&stdout_path).map_err(|error| WorkdirError::io(&stdout_path, error))?;
@@ -1609,18 +1687,21 @@ async fn run_command(
tokio::select! {
exit = child.wait() => {
let exit = exit.map_err(|error| WorkdirError::io(&cwd, error))?;
process_group.terminate();
break (
if exit.success() { CommandStatus::Completed } else { CommandStatus::Failed },
exit.code(),
);
}
_ = &mut timeout => {
process_group.terminate();
let _ = child.start_kill();
let exit_code = child.wait().await.ok().and_then(|status| status.code());
break (CommandStatus::TimedOut, exit_code);
}
changed = cancel.changed() => {
if changed.is_err() || *cancel.borrow() {
process_group.terminate();
let _ = child.start_kill();
let exit_code = child.wait().await.ok().and_then(|status| status.code());
break (CommandStatus::Cancelled, exit_code);
@@ -3473,6 +3554,55 @@ mod tests {
assert!(released.load(Ordering::Acquire));
}
#[cfg(unix)]
#[tokio::test]
async fn cancellation_kills_command_process_group_descendants() {
let dir = TempDir::new().unwrap();
let workdir = make_fs(&dir);
let handle = WorkdirSession::start_command(
&workdir,
CommandRequest {
command: "(sleep 0.4; echo leaked > child-marker) & echo ready > child-ready; wait"
.into(),
timeout_secs: 60,
output_limit: 1024,
cwd: WorkdirPath::root(),
spill_dir: None,
tool_call_id: None,
},
)
.await
.unwrap();
for _ in 0..100 {
if dir.path().join("child-ready").exists() {
break;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
assert!(dir.path().join("child-ready").exists());
WorkdirSession::cancel_command(&workdir, handle.clone())
.await
.unwrap();
let output = WorkdirSession::command_output(
&workdir,
CommandOutputRequest {
handle,
cursor: 0,
limit: 1024,
wait: true,
},
)
.await
.unwrap();
assert_eq!(output.status, CommandStatus::Cancelled);
tokio::time::sleep(Duration::from_millis(500)).await;
assert!(
!dir.path().join("child-marker").exists(),
"cancelled command descendant survived its process group"
);
}
#[tokio::test]
async fn provider_cancels_active_command() {
let dir = TempDir::new().unwrap();