1mod find_up;
2
3use bytes::Bytes;
4use codex_protocol::config_types::WindowsSandboxLevel;
5use codex_protocol::models::ManagedFileSystemPermissions;
6use codex_protocol::models::PermissionProfile;
7use codex_protocol::models::SandboxEnforcement;
8use codex_protocol::permissions::FileSystemAccessMode;
9use codex_protocol::permissions::FileSystemPath;
10use codex_protocol::permissions::FileSystemSandboxEntry;
11use codex_protocol::permissions::FileSystemSandboxEntryMissingPathBehavior;
12use codex_protocol::permissions::FileSystemSandboxKind;
13use codex_protocol::permissions::FileSystemSandboxPolicy;
14use codex_protocol::permissions::FileSystemSpecialPath;
15use codex_protocol::permissions::NetworkSandboxPolicy;
16use codex_protocol::protocol::SandboxPolicy;
17use codex_utils_path_uri::PathUri;
18pub use find_up::FindUpErrorPolicy;
19pub use find_up::find_nearest_ancestor_with_markers;
20pub use find_up::find_nearest_native_ancestor_with_markers;
21use futures::Stream;
22use serde::Deserialize;
23use serde::Serialize;
24use std::collections::HashSet;
25use std::collections::VecDeque;
26use std::future::Future;
27use std::io;
28use std::num::NonZeroUsize;
29use std::path::Path;
30use std::pin::Pin;
31use std::task::Context;
32use std::task::Poll;
33
34pub const FILE_READ_CHUNK_SIZE: usize = 1024 * 1024;
36const MAX_WALK_DEPTH: usize = 64;
37const MAX_WALK_DIRECTORIES: usize = 10_000;
38const MAX_WALK_ENTRIES: usize = 50_000;
39const MAX_WALK_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
40const WALK_RESPONSE_ITEM_OVERHEAD_BYTES: usize = 64;
41
42#[derive(Clone, Copy, Debug, Eq, PartialEq)]
43pub struct CreateDirectoryOptions {
44 pub recursive: bool,
45}
46
47#[derive(Clone, Copy, Debug, Eq, PartialEq)]
48pub struct RemoveOptions {
49 pub recursive: bool,
50 pub force: bool,
51}
52
53#[derive(Clone, Copy, Debug, Eq, PartialEq)]
54pub struct CopyOptions {
55 pub recursive: bool,
56}
57
58#[derive(Clone, Debug, Eq, PartialEq)]
59pub struct FileMetadata {
60 pub is_directory: bool,
61 pub is_file: bool,
62 pub is_symlink: bool,
63 pub size: u64,
65 pub created_at_ms: i64,
66 pub modified_at_ms: i64,
67}
68
69#[derive(Clone, Debug, Eq, PartialEq)]
70pub struct ReadDirectoryEntry {
71 pub file_name: String,
72 pub is_directory: bool,
73 pub is_file: bool,
74}
75
76#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
78#[serde(rename_all = "camelCase")]
79pub struct WalkOptions {
80 pub max_depth: usize,
82 pub max_directories: usize,
84 pub max_entries: usize,
86 pub follow_directory_symlinks: bool,
88 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
90 pub prune_hidden_directories: bool,
91}
92
93#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
95#[serde(rename_all = "camelCase")]
96pub enum WalkEntryKind {
97 Directory,
98 File,
99}
100
101#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
103#[serde(rename_all = "camelCase")]
104pub struct WalkEntry {
105 pub path: PathUri,
106 pub kind: WalkEntryKind,
107}
108
109#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
111#[serde(rename_all = "camelCase")]
112pub struct WalkError {
113 pub path: PathUri,
114 pub message: String,
115}
116
117#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
119#[serde(rename_all = "camelCase")]
120pub struct WalkOutcome {
121 pub entries: Vec<WalkEntry>,
122 pub errors: Vec<WalkError>,
123 pub truncated: bool,
124}
125
126#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
127#[serde(tag = "type", rename_all = "snake_case")]
128pub enum ExecFileSystemPath {
129 Path { path: PathUri },
130 GlobPattern { pattern: String },
131 Special { value: FileSystemSpecialPath },
132}
133
134impl From<FileSystemPath> for ExecFileSystemPath {
135 fn from(value: FileSystemPath) -> Self {
136 match value {
137 FileSystemPath::Path { path } => Self::Path {
138 path: PathUri::from_abs_path(&path),
139 },
140 FileSystemPath::GlobPattern { pattern } => Self::GlobPattern { pattern },
141 FileSystemPath::Special { value } => Self::Special { value },
142 }
143 }
144}
145
146impl TryFrom<ExecFileSystemPath> for FileSystemPath {
147 type Error = io::Error;
148
149 fn try_from(value: ExecFileSystemPath) -> Result<Self, Self::Error> {
150 Ok(match value {
151 ExecFileSystemPath::Path { path } => Self::Path {
152 path: path.to_abs_path()?,
153 },
154 ExecFileSystemPath::GlobPattern { pattern } => Self::GlobPattern { pattern },
155 ExecFileSystemPath::Special { value } => Self::Special { value },
156 })
157 }
158}
159
160#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
161pub struct ExecFileSystemSandboxEntry {
162 pub path: ExecFileSystemPath,
163 pub access: FileSystemAccessMode,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub missing_path_behavior: Option<FileSystemSandboxEntryMissingPathBehavior>,
166}
167
168impl From<FileSystemSandboxEntry> for ExecFileSystemSandboxEntry {
169 fn from(value: FileSystemSandboxEntry) -> Self {
170 Self {
171 path: value.path.into(),
172 access: value.access,
173 missing_path_behavior: value.missing_path_behavior,
174 }
175 }
176}
177
178impl TryFrom<ExecFileSystemSandboxEntry> for FileSystemSandboxEntry {
179 type Error = io::Error;
180
181 fn try_from(value: ExecFileSystemSandboxEntry) -> Result<Self, Self::Error> {
182 Ok(Self {
183 path: value.path.try_into()?,
184 access: value.access,
185 missing_path_behavior: value.missing_path_behavior,
186 })
187 }
188}
189
190#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
191#[serde(tag = "type", rename_all = "snake_case")]
192pub enum ExecManagedFileSystemPermissions {
193 Restricted {
194 entries: Vec<ExecFileSystemSandboxEntry>,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
196 glob_scan_max_depth: Option<NonZeroUsize>,
197 },
198 Unrestricted,
199}
200
201impl From<ManagedFileSystemPermissions> for ExecManagedFileSystemPermissions {
202 fn from(value: ManagedFileSystemPermissions) -> Self {
203 match value {
204 ManagedFileSystemPermissions::Restricted {
205 entries,
206 glob_scan_max_depth,
207 } => Self::Restricted {
208 entries: entries.into_iter().map(Into::into).collect(),
209 glob_scan_max_depth,
210 },
211 ManagedFileSystemPermissions::Unrestricted => Self::Unrestricted,
212 }
213 }
214}
215
216impl TryFrom<ExecManagedFileSystemPermissions> for ManagedFileSystemPermissions {
217 type Error = io::Error;
218
219 fn try_from(value: ExecManagedFileSystemPermissions) -> Result<Self, Self::Error> {
220 Ok(match value {
221 ExecManagedFileSystemPermissions::Restricted {
222 entries,
223 glob_scan_max_depth,
224 } => Self::Restricted {
225 entries: entries
226 .into_iter()
227 .map(TryInto::try_into)
228 .collect::<io::Result<_>>()?,
229 glob_scan_max_depth,
230 },
231 ExecManagedFileSystemPermissions::Unrestricted => Self::Unrestricted,
232 })
233 }
234}
235
236#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
237#[serde(tag = "type", rename_all = "snake_case")]
238pub enum ExecPermissionProfile {
239 Managed {
240 file_system: ExecManagedFileSystemPermissions,
241 network: NetworkSandboxPolicy,
242 },
243 Disabled,
244 External {
245 network: NetworkSandboxPolicy,
246 },
247}
248
249impl From<PermissionProfile> for ExecPermissionProfile {
250 fn from(value: PermissionProfile) -> Self {
251 match value {
252 PermissionProfile::Managed {
253 file_system,
254 network,
255 } => Self::Managed {
256 file_system: file_system.into(),
257 network,
258 },
259 PermissionProfile::Disabled => Self::Disabled,
260 PermissionProfile::External { network } => Self::External { network },
261 }
262 }
263}
264
265impl TryFrom<ExecPermissionProfile> for PermissionProfile {
266 type Error = io::Error;
267
268 fn try_from(value: ExecPermissionProfile) -> Result<Self, Self::Error> {
269 Ok(match value {
270 ExecPermissionProfile::Managed {
271 file_system,
272 network,
273 } => Self::Managed {
274 file_system: file_system.try_into()?,
275 network,
276 },
277 ExecPermissionProfile::Disabled => Self::Disabled,
278 ExecPermissionProfile::External { network } => Self::External { network },
279 })
280 }
281}
282
283#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
284#[serde(rename_all = "camelCase")]
285pub struct FileSystemSandboxContext {
286 pub permissions: ExecPermissionProfile,
287 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub cwd: Option<PathUri>,
289 #[serde(default, skip_serializing_if = "Vec::is_empty")]
290 pub workspace_roots: Vec<PathUri>,
291 pub windows_sandbox_level: WindowsSandboxLevel,
292 #[serde(default)]
293 pub windows_sandbox_private_desktop: bool,
294 #[serde(default)]
295 pub use_legacy_landlock: bool,
296}
297
298impl FileSystemSandboxContext {
299 pub fn from_legacy_sandbox_policy(
300 sandbox_policy: SandboxPolicy,
301 cwd: PathUri,
302 ) -> io::Result<Self> {
303 let native_cwd = cwd.to_abs_path()?;
306 let file_system_sandbox_policy =
307 FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(
308 &sandbox_policy,
309 &native_cwd,
310 );
311 let permissions = PermissionProfile::from_runtime_permissions_with_enforcement(
312 SandboxEnforcement::from_legacy_sandbox_policy(&sandbox_policy),
313 &file_system_sandbox_policy,
314 NetworkSandboxPolicy::from(&sandbox_policy),
315 );
316 Ok(Self::from_permission_profile_with_cwd(permissions, cwd))
317 }
318
319 pub fn from_permission_profile(permissions: PermissionProfile) -> Self {
320 Self::from_permissions_and_cwd(permissions, None)
321 }
322
323 pub fn from_permission_profile_with_cwd(permissions: PermissionProfile, cwd: PathUri) -> Self {
324 Self::from_permissions_and_cwd(permissions, Some(cwd))
325 }
326
327 fn from_permissions_and_cwd(permissions: PermissionProfile, cwd: Option<PathUri>) -> Self {
328 let workspace_roots = cwd.iter().cloned().collect();
329 Self {
330 permissions: permissions.into(),
331 cwd,
332 workspace_roots,
333 windows_sandbox_level: WindowsSandboxLevel::Disabled,
334 windows_sandbox_private_desktop: false,
335 use_legacy_landlock: false,
336 }
337 }
338
339 pub fn should_run_in_sandbox(&self) -> bool {
340 let Ok(permissions) = PermissionProfile::try_from(self.permissions.clone()) else {
341 return true;
343 };
344 let file_system_policy = permissions.file_system_sandbox_policy();
345 matches!(file_system_policy.kind, FileSystemSandboxKind::Restricted)
346 && !file_system_policy.has_full_disk_write_access()
347 }
348
349 pub fn has_cwd_dependent_permissions(&self) -> bool {
350 match &self.permissions {
351 ExecPermissionProfile::Managed {
352 file_system: ExecManagedFileSystemPermissions::Restricted { entries, .. },
353 ..
354 } => entries.iter().any(|entry| match &entry.path {
355 ExecFileSystemPath::GlobPattern { pattern } => !Path::new(pattern).is_absolute(),
356 ExecFileSystemPath::Special {
357 value: FileSystemSpecialPath::ProjectRoots { .. },
358 } => true,
359 ExecFileSystemPath::Path { .. } | ExecFileSystemPath::Special { .. } => false,
360 }),
361 ExecPermissionProfile::Managed {
362 file_system: ExecManagedFileSystemPermissions::Unrestricted,
363 ..
364 }
365 | ExecPermissionProfile::Disabled
366 | ExecPermissionProfile::External { .. } => false,
367 }
368 }
369
370 pub fn drop_cwd_if_unused(mut self) -> Self {
371 if !self.has_cwd_dependent_permissions() {
372 self.cwd = None;
373 self.workspace_roots.clear();
374 }
375 self
376 }
377}
378
379pub type FileSystemResult<T> = io::Result<T>;
380
381pub type ExecutorFileSystemFuture<'a, T> =
383 Pin<Box<dyn Future<Output = FileSystemResult<T>> + Send + 'a>>;
384
385pub struct FileSystemReadStream {
387 inner: Pin<Box<dyn Stream<Item = FileSystemResult<Bytes>> + Send + 'static>>,
388}
389
390impl FileSystemReadStream {
391 pub fn new(stream: impl Stream<Item = FileSystemResult<Bytes>> + Send + 'static) -> Self {
393 Self {
394 inner: Box::pin(stream),
395 }
396 }
397}
398
399impl Stream for FileSystemReadStream {
400 type Item = FileSystemResult<Bytes>;
401
402 fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
403 self.inner.as_mut().poll_next(cx)
404 }
405}
406
407pub trait ExecutorFileSystem: Send + Sync {
410 fn canonicalize<'a>(
412 &'a self,
413 path: &'a PathUri,
414 sandbox: Option<&'a FileSystemSandboxContext>,
415 ) -> ExecutorFileSystemFuture<'a, PathUri>;
416
417 fn read_file<'a>(
418 &'a self,
419 path: &'a PathUri,
420 sandbox: Option<&'a FileSystemSandboxContext>,
421 ) -> ExecutorFileSystemFuture<'a, Vec<u8>>;
422
423 fn read_file_stream<'a>(
425 &'a self,
426 path: &'a PathUri,
427 sandbox: Option<&'a FileSystemSandboxContext>,
428 ) -> ExecutorFileSystemFuture<'a, FileSystemReadStream>;
429
430 fn read_file_text<'a>(
432 &'a self,
433 path: &'a PathUri,
434 sandbox: Option<&'a FileSystemSandboxContext>,
435 ) -> ExecutorFileSystemFuture<'a, String> {
436 Box::pin(async move {
437 let bytes = self.read_file(path, sandbox).await?;
438 String::from_utf8(bytes).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))
439 })
440 }
441
442 fn write_file<'a>(
443 &'a self,
444 path: &'a PathUri,
445 contents: Vec<u8>,
446 sandbox: Option<&'a FileSystemSandboxContext>,
447 ) -> ExecutorFileSystemFuture<'a, ()>;
448
449 fn create_directory<'a>(
450 &'a self,
451 path: &'a PathUri,
452 create_directory_options: CreateDirectoryOptions,
453 sandbox: Option<&'a FileSystemSandboxContext>,
454 ) -> ExecutorFileSystemFuture<'a, ()>;
455
456 fn get_metadata<'a>(
457 &'a self,
458 path: &'a PathUri,
459 sandbox: Option<&'a FileSystemSandboxContext>,
460 ) -> ExecutorFileSystemFuture<'a, FileMetadata>;
461
462 fn read_directory<'a>(
463 &'a self,
464 path: &'a PathUri,
465 sandbox: Option<&'a FileSystemSandboxContext>,
466 ) -> ExecutorFileSystemFuture<'a, Vec<ReadDirectoryEntry>>;
467
468 fn walk<'a>(
470 &'a self,
471 path: &'a PathUri,
472 options: WalkOptions,
473 sandbox: Option<&'a FileSystemSandboxContext>,
474 ) -> ExecutorFileSystemFuture<'a, WalkOutcome> {
475 self.walk_via_directory_reads(path, options, sandbox)
476 }
477
478 fn walk_via_directory_reads<'a>(
482 &'a self,
483 path: &'a PathUri,
484 options: WalkOptions,
485 sandbox: Option<&'a FileSystemSandboxContext>,
486 ) -> ExecutorFileSystemFuture<'a, WalkOutcome> {
487 Box::pin(walk_via_directory_reads(self, path, options, sandbox))
488 }
489
490 fn remove<'a>(
491 &'a self,
492 path: &'a PathUri,
493 remove_options: RemoveOptions,
494 sandbox: Option<&'a FileSystemSandboxContext>,
495 ) -> ExecutorFileSystemFuture<'a, ()>;
496
497 fn copy<'a>(
498 &'a self,
499 source_path: &'a PathUri,
500 destination_path: &'a PathUri,
501 copy_options: CopyOptions,
502 sandbox: Option<&'a FileSystemSandboxContext>,
503 ) -> ExecutorFileSystemFuture<'a, ()>;
504}
505
506async fn walk_via_directory_reads<F: ExecutorFileSystem + ?Sized>(
507 file_system: &F,
508 root: &PathUri,
509 options: WalkOptions,
510 sandbox: Option<&FileSystemSandboxContext>,
511) -> FileSystemResult<WalkOutcome> {
512 if options.max_directories == 0 || options.max_entries == 0 {
513 return Err(io::Error::new(
514 io::ErrorKind::InvalidInput,
515 "filesystem walk limits must be greater than zero",
516 ));
517 }
518 if options.max_depth > MAX_WALK_DEPTH
519 || options.max_directories > MAX_WALK_DIRECTORIES
520 || options.max_entries > MAX_WALK_ENTRIES
521 {
522 return Err(io::Error::new(
523 io::ErrorKind::InvalidInput,
524 format!(
525 "filesystem walk limits exceed maximums: depth={MAX_WALK_DEPTH}, directories={MAX_WALK_DIRECTORIES}, entries={MAX_WALK_ENTRIES}"
526 ),
527 ));
528 }
529
530 let root_metadata = file_system.get_metadata(root, sandbox).await?;
531 if !root_metadata.is_directory
532 || (root_metadata.is_symlink && !options.follow_directory_symlinks)
533 {
534 return Ok(WalkOutcome::default());
535 }
536
537 let root_identity = if options.follow_directory_symlinks {
538 file_system.canonicalize(root, sandbox).await?
539 } else {
540 root.clone()
541 };
542 let mut outcome = WalkOutcome::default();
543 let mut queue = VecDeque::from([(root.clone(), 0usize)]);
544 let mut visited_directories = HashSet::from([root_identity]);
545 let mut directory_count = 1usize;
546 let mut entry_count = 0usize;
547 let mut response_bytes = 0usize;
548
549 while let Some((directory, depth)) = queue.pop_front() {
550 let mut entries = match file_system.read_directory(&directory, sandbox).await {
551 Ok(entries) => entries,
552 Err(error) => {
553 if !push_walk_error(
554 &mut outcome,
555 &mut response_bytes,
556 directory,
557 error.to_string(),
558 ) {
559 return Ok(outcome);
560 }
561 continue;
562 }
563 };
564 entries.sort_by(|left, right| left.file_name.cmp(&right.file_name));
565
566 for entry in entries {
567 if entry_count == options.max_entries {
568 outcome.truncated = true;
569 return Ok(outcome);
570 }
571 entry_count += 1;
572
573 let path = match directory.join(&entry.file_name) {
574 Ok(path) => path,
575 Err(error) => {
576 if !push_walk_error(
577 &mut outcome,
578 &mut response_bytes,
579 directory.clone(),
580 error.to_string(),
581 ) {
582 return Ok(outcome);
583 }
584 continue;
585 }
586 };
587 let metadata = match file_system.get_metadata(&path, sandbox).await {
588 Ok(metadata) => metadata,
589 Err(error) => {
590 if !push_walk_error(&mut outcome, &mut response_bytes, path, error.to_string())
591 {
592 return Ok(outcome);
593 }
594 continue;
595 }
596 };
597 if metadata.is_symlink && (!options.follow_directory_symlinks || !metadata.is_directory)
598 {
599 continue;
600 }
601
602 let kind = if metadata.is_directory {
603 WalkEntryKind::Directory
604 } else if metadata.is_file {
605 WalkEntryKind::File
606 } else {
607 continue;
608 };
609 if !reserve_walk_response_bytes(
610 &mut outcome,
611 &mut response_bytes,
612 path.to_string().len(),
613 ) {
614 return Ok(outcome);
615 }
616 outcome.entries.push(WalkEntry {
617 path: path.clone(),
618 kind,
619 });
620
621 if kind == WalkEntryKind::Directory && depth < options.max_depth {
622 if options.prune_hidden_directories && entry.file_name.starts_with('.') {
623 continue;
624 }
625 let directory_identity = if options.follow_directory_symlinks {
626 match file_system.canonicalize(&path, sandbox).await {
627 Ok(path) => path,
628 Err(error) => {
629 if !push_walk_error(
630 &mut outcome,
631 &mut response_bytes,
632 path,
633 error.to_string(),
634 ) {
635 return Ok(outcome);
636 }
637 continue;
638 }
639 }
640 } else {
641 path.clone()
642 };
643 if !visited_directories.insert(directory_identity) {
644 continue;
645 }
646 if directory_count == options.max_directories {
647 outcome.truncated = true;
648 } else {
649 directory_count += 1;
650 queue.push_back((path, depth + 1));
651 }
652 }
653 }
654 }
655
656 Ok(outcome)
657}
658
659fn push_walk_error(
660 outcome: &mut WalkOutcome,
661 response_bytes: &mut usize,
662 path: PathUri,
663 message: String,
664) -> bool {
665 let item_bytes = path.to_string().len().saturating_add(message.len());
666 if !reserve_walk_response_bytes(outcome, response_bytes, item_bytes) {
667 return false;
668 }
669 outcome.errors.push(WalkError { path, message });
670 true
671}
672
673fn reserve_walk_response_bytes(
674 outcome: &mut WalkOutcome,
675 response_bytes: &mut usize,
676 content_bytes: usize,
677) -> bool {
678 let item_bytes = content_bytes.saturating_add(WALK_RESPONSE_ITEM_OVERHEAD_BYTES);
679 let Some(total_bytes) = response_bytes.checked_add(item_bytes) else {
680 outcome.truncated = true;
681 return false;
682 };
683 if total_bytes > MAX_WALK_RESPONSE_BYTES {
684 outcome.truncated = true;
685 return false;
686 }
687 *response_bytes = total_bytes;
688 true
689}