Skip to main content

agentos_kernel/
permissions.rs

1use crate::vfs::{
2    validate_path, VfsError, VfsResult, VirtualDirEntry, VirtualFileSystem, VirtualStat,
3    VirtualUtimeSpec,
4};
5use std::collections::{BTreeMap, HashMap};
6use std::error::Error;
7use std::fmt;
8use std::path::Path;
9use std::sync::Arc;
10
11pub type FsPermissionCheck = Arc<dyn Fn(&FsAccessRequest) -> PermissionDecision + Send + Sync>;
12pub type NetworkPermissionCheck =
13    Arc<dyn Fn(&NetworkAccessRequest) -> PermissionDecision + Send + Sync>;
14pub type CommandPermissionCheck =
15    Arc<dyn Fn(&CommandAccessRequest) -> PermissionDecision + Send + Sync>;
16pub type EnvironmentPermissionCheck =
17    Arc<dyn Fn(&EnvAccessRequest) -> PermissionDecision + Send + Sync>;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct PermissionDecision {
21    pub allow: bool,
22    pub reason: Option<String>,
23}
24
25impl PermissionDecision {
26    pub fn allow() -> Self {
27        Self {
28            allow: true,
29            reason: None,
30        }
31    }
32
33    pub fn deny(reason: impl Into<String>) -> Self {
34        Self {
35            allow: false,
36            reason: Some(reason.into()),
37        }
38    }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct PermissionError {
43    code: &'static str,
44    message: String,
45}
46
47impl PermissionError {
48    pub fn code(&self) -> &'static str {
49        self.code
50    }
51
52    fn access_denied(subject: impl Into<String>, reason: Option<&str>) -> Self {
53        let subject = subject.into();
54        let message = match reason {
55            Some(reason) => format!("permission denied, {subject}: {reason}"),
56            None => format!("permission denied, {subject}"),
57        };
58
59        Self {
60            code: "EACCES",
61            message,
62        }
63    }
64}
65
66impl fmt::Display for PermissionError {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        write!(f, "{}: {}", self.code, self.message)
69    }
70}
71
72impl Error for PermissionError {}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum FsOperation {
76    Read,
77    Write,
78    Mkdir,
79    CreateDir,
80    ReadDir,
81    Stat,
82    Remove,
83    Rename,
84    Exists,
85    Symlink,
86    ReadLink,
87    Link,
88    Chmod,
89    Chown,
90    Utimes,
91    Truncate,
92    MountSensitive,
93}
94
95impl FsOperation {
96    fn as_str(self) -> &'static str {
97        match self {
98            Self::Read => "read",
99            Self::Write => "write",
100            Self::Mkdir => "mkdir",
101            Self::CreateDir => "createDir",
102            Self::ReadDir => "readdir",
103            Self::Stat => "stat",
104            Self::Remove => "rm",
105            Self::Rename => "rename",
106            Self::Exists => "exists",
107            Self::Symlink => "symlink",
108            Self::ReadLink => "readlink",
109            Self::Link => "link",
110            Self::Chmod => "chmod",
111            Self::Chown => "chown",
112            Self::Utimes => "utimes",
113            Self::Truncate => "truncate",
114            Self::MountSensitive => "mount",
115        }
116    }
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct FsAccessRequest {
121    pub vm_id: String,
122    pub op: FsOperation,
123    pub path: String,
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum NetworkOperation {
128    Fetch,
129    Http,
130    Dns,
131    Listen,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct NetworkAccessRequest {
136    pub vm_id: String,
137    pub op: NetworkOperation,
138    pub resource: String,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct CommandAccessRequest {
143    pub vm_id: String,
144    pub command: String,
145    pub args: Vec<String>,
146    pub cwd: Option<String>,
147    pub env: BTreeMap<String, String>,
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub enum EnvironmentOperation {
152    Read,
153    Write,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct EnvAccessRequest {
158    pub vm_id: String,
159    pub op: EnvironmentOperation,
160    pub key: String,
161    pub value: Option<String>,
162}
163
164#[derive(Clone, Default)]
165pub struct Permissions {
166    pub filesystem: Option<FsPermissionCheck>,
167    pub network: Option<NetworkPermissionCheck>,
168    pub child_process: Option<CommandPermissionCheck>,
169    pub environment: Option<EnvironmentPermissionCheck>,
170}
171
172impl Permissions {
173    pub fn allow_all() -> Self {
174        Self {
175            filesystem: Some(Arc::new(|_: &FsAccessRequest| PermissionDecision::allow())),
176            network: Some(Arc::new(|_: &NetworkAccessRequest| {
177                PermissionDecision::allow()
178            })),
179            child_process: Some(Arc::new(|_: &CommandAccessRequest| {
180                PermissionDecision::allow()
181            })),
182            environment: Some(Arc::new(|_: &EnvAccessRequest| PermissionDecision::allow())),
183        }
184    }
185}
186
187pub fn permission_glob_matches(pattern: &str, value: &str) -> bool {
188    fn matches(
189        pattern: &[u8],
190        value: &[u8],
191        pattern_index: usize,
192        value_index: usize,
193        memo: &mut HashMap<(usize, usize), bool>,
194    ) -> bool {
195        if let Some(result) = memo.get(&(pattern_index, value_index)) {
196            return *result;
197        }
198
199        let result = if pattern_index == pattern.len() {
200            value_index == value.len()
201        } else {
202            match pattern[pattern_index] {
203                b'?' => {
204                    value_index < value.len()
205                        && value[value_index] != b'/'
206                        && matches(pattern, value, pattern_index + 1, value_index + 1, memo)
207                }
208                b'*' => {
209                    let mut next_pattern_index = pattern_index;
210                    while next_pattern_index < pattern.len() && pattern[next_pattern_index] == b'*'
211                    {
212                        next_pattern_index += 1;
213                    }
214
215                    if matches(pattern, value, next_pattern_index, value_index, memo) {
216                        true
217                    } else {
218                        let crosses_separators = next_pattern_index - pattern_index > 1;
219                        let mut next_value_index = value_index;
220                        while next_value_index < value.len()
221                            && (crosses_separators || value[next_value_index] != b'/')
222                        {
223                            next_value_index += 1;
224                            if matches(pattern, value, next_pattern_index, next_value_index, memo) {
225                                return true;
226                            }
227                        }
228                        false
229                    }
230                }
231                expected => {
232                    value_index < value.len()
233                        && expected == value[value_index]
234                        && matches(pattern, value, pattern_index + 1, value_index + 1, memo)
235                }
236            }
237        };
238
239        memo.insert((pattern_index, value_index), result);
240        result
241    }
242
243    matches(
244        pattern.as_bytes(),
245        value.as_bytes(),
246        0,
247        0,
248        &mut HashMap::new(),
249    )
250}
251
252pub fn filter_env(
253    vm_id: &str,
254    env: &BTreeMap<String, String>,
255    permissions: &Permissions,
256) -> BTreeMap<String, String> {
257    let Some(check) = permissions.environment.as_ref() else {
258        return BTreeMap::new();
259    };
260
261    env.iter()
262        .filter_map(|(key, value)| {
263            let request = EnvAccessRequest {
264                vm_id: vm_id.to_owned(),
265                op: EnvironmentOperation::Read,
266                key: key.clone(),
267                value: Some(value.clone()),
268            };
269            let decision = check(&request);
270            decision.allow.then(|| (key.clone(), value.clone()))
271        })
272        .collect()
273}
274
275pub fn check_command_execution(
276    vm_id: &str,
277    permissions: &Permissions,
278    command: &str,
279    args: &[String],
280    cwd: Option<&str>,
281    env: &BTreeMap<String, String>,
282) -> Result<(), PermissionError> {
283    let Some(check) = permissions.child_process.as_ref() else {
284        return Err(PermissionError::access_denied(
285            format!("spawn '{command}'"),
286            None,
287        ));
288    };
289
290    let request = CommandAccessRequest {
291        vm_id: vm_id.to_owned(),
292        command: command.to_owned(),
293        args: args.to_vec(),
294        cwd: cwd.map(ToOwned::to_owned),
295        env: env.clone(),
296    };
297    let decision = check(&request);
298    if decision.allow {
299        Ok(())
300    } else {
301        Err(PermissionError::access_denied(
302            format!("spawn '{command}'"),
303            decision.reason.as_deref(),
304        ))
305    }
306}
307
308pub fn check_network_access(
309    vm_id: &str,
310    permissions: &Permissions,
311    op: NetworkOperation,
312    resource: &str,
313) -> Result<(), PermissionError> {
314    let Some(check) = permissions.network.as_ref() else {
315        return Err(PermissionError::access_denied(resource, None));
316    };
317
318    let request = NetworkAccessRequest {
319        vm_id: vm_id.to_owned(),
320        op,
321        resource: resource.to_owned(),
322    };
323    let decision = check(&request);
324    if decision.allow {
325        Ok(())
326    } else {
327        Err(PermissionError::access_denied(
328            resource,
329            decision.reason.as_deref(),
330        ))
331    }
332}
333
334#[derive(Clone)]
335pub struct PermissionedFileSystem<F> {
336    inner: F,
337    vm_id: String,
338    permissions: Permissions,
339}
340
341impl<F> PermissionedFileSystem<F> {
342    pub fn new(inner: F, vm_id: impl Into<String>, permissions: Permissions) -> Self {
343        Self {
344            inner,
345            vm_id: vm_id.into(),
346            permissions,
347        }
348    }
349
350    pub fn into_inner(self) -> F {
351        self.inner
352    }
353
354    pub fn inner(&self) -> &F {
355        &self.inner
356    }
357
358    pub fn inner_mut(&mut self) -> &mut F {
359        &mut self.inner
360    }
361
362    pub fn set_permissions(&mut self, permissions: Permissions) {
363        self.permissions = permissions;
364    }
365
366    fn check(&self, op: FsOperation, path: &str) -> VfsResult<()> {
367        validate_path(path)?;
368        // Standard emulated character devices (/dev/null, /dev/zero, /dev/urandom,
369        // /dev/std{in,out,err}) are world-accessible on Linux and have no host
370        // backing; the device layer enforces their fixed semantics. Exempt them from
371        // the VM file-permission policy so guest fs ops on them (readFileSync /
372        // existsSync / redirects) behave like native Linux regardless of policy.
373        if crate::device_layer::is_standard_device_path(path) {
374            return Ok(());
375        }
376        let Some(check) = self.permissions.filesystem.as_ref() else {
377            return Err(VfsError::access_denied(op.as_str(), path, None));
378        };
379
380        let request = FsAccessRequest {
381            vm_id: self.vm_id.clone(),
382            op,
383            path: path.to_owned(),
384        };
385        let decision = check(&request);
386        if decision.allow {
387            Ok(())
388        } else {
389            Err(VfsError::access_denied(
390                op.as_str(),
391                path,
392                decision.reason.as_deref(),
393            ))
394        }
395    }
396}
397
398impl<F: VirtualFileSystem> PermissionedFileSystem<F> {
399    fn resolved_existing_path(&self, path: &str) -> VfsResult<String> {
400        self.inner.realpath(path)
401    }
402
403    fn resolved_destination_path(&self, path: &str) -> VfsResult<String> {
404        let normalized = crate::vfs::normalize_path(path);
405        if normalized == "/" {
406            return Ok(normalized);
407        }
408
409        let parent = Path::new(&normalized)
410            .parent()
411            .unwrap_or_else(|| Path::new("/"))
412            .to_string_lossy()
413            .into_owned();
414        let basename = Path::new(&normalized)
415            .file_name()
416            .map(|value| value.to_string_lossy().into_owned())
417            .unwrap_or_default();
418
419        let mut candidate = parent;
420        let mut unresolved_segments = Vec::new();
421
422        let resolved_parent = loop {
423            match self.inner.realpath(&candidate) {
424                Ok(resolved) => break resolved,
425                Err(error) if matches!(error.code(), "ENOENT" | "ENOTDIR") => {
426                    if candidate == "/" {
427                        break String::from("/");
428                    }
429                    let candidate_path = Path::new(&candidate);
430                    if let Some(segment) = candidate_path.file_name() {
431                        unresolved_segments.push(segment.to_string_lossy().into_owned());
432                    }
433                    candidate = candidate_path
434                        .parent()
435                        .unwrap_or_else(|| Path::new("/"))
436                        .to_string_lossy()
437                        .into_owned();
438                }
439                Err(error) => return Err(error),
440            }
441        };
442
443        let mut resolved = resolved_parent;
444        for segment in unresolved_segments.iter().rev() {
445            if resolved == "/" {
446                resolved = format!("/{segment}");
447            } else {
448                resolved = format!("{resolved}/{segment}");
449            }
450        }
451
452        if resolved == "/" {
453            Ok(format!("/{basename}"))
454        } else {
455            Ok(format!("{resolved}/{basename}"))
456        }
457    }
458
459    fn permission_subject(&self, op: FsOperation, path: &str) -> VfsResult<String> {
460        validate_path(path)?;
461        match op {
462            FsOperation::Read
463            | FsOperation::ReadDir
464            | FsOperation::Stat
465            | FsOperation::ReadLink
466            | FsOperation::Chmod
467            | FsOperation::Chown
468            | FsOperation::Utimes
469            | FsOperation::Truncate => self.resolved_existing_path(path),
470            FsOperation::Exists | FsOperation::Write => self
471                .resolved_existing_path(path)
472                .or_else(|_| self.resolved_destination_path(path)),
473            FsOperation::Mkdir
474            | FsOperation::CreateDir
475            | FsOperation::Rename
476            | FsOperation::Symlink
477            | FsOperation::Link
478            | FsOperation::MountSensitive
479            | FsOperation::Remove => self.resolved_destination_path(path),
480        }
481    }
482
483    fn check_subject(&self, op: FsOperation, path: &str) -> VfsResult<()> {
484        let subject = self.permission_subject(op, path)?;
485        self.check(op, &subject)
486    }
487
488    fn check_existing_subject(&self, op: FsOperation, path: &str) -> VfsResult<()> {
489        validate_path(path)?;
490        let subject = self.resolved_existing_path(path)?;
491        self.check(op, &subject)
492    }
493
494    fn check_destination_subject(&self, op: FsOperation, path: &str) -> VfsResult<()> {
495        validate_path(path)?;
496        let subject = self.resolved_destination_path(path)?;
497        self.check(op, &subject)
498    }
499
500    pub fn check_path(&self, op: FsOperation, path: &str) -> VfsResult<()> {
501        self.check_subject(op, path)
502    }
503
504    pub fn check_virtual_path(&self, op: FsOperation, path: &str) -> VfsResult<()> {
505        self.check(op, path)
506    }
507
508    pub fn exists(&self, path: &str) -> VfsResult<bool> {
509        if let Err(error) = self.check_subject(FsOperation::Exists, path) {
510            if matches!(error.code(), "EACCES" | "ENOENT" | "ENOTDIR" | "ELOOP") {
511                return Ok(false);
512            }
513            return Err(error);
514        }
515        Ok(self.inner.exists(path))
516    }
517}
518
519impl<F: VirtualFileSystem> VirtualFileSystem for PermissionedFileSystem<F> {
520    fn read_file(&mut self, path: &str) -> VfsResult<Vec<u8>> {
521        self.check_subject(FsOperation::Read, path)?;
522        self.inner.read_file(path)
523    }
524
525    fn read_dir(&mut self, path: &str) -> VfsResult<Vec<String>> {
526        self.check_subject(FsOperation::ReadDir, path)?;
527        self.inner.read_dir(path)
528    }
529
530    fn read_dir_limited(&mut self, path: &str, max_entries: usize) -> VfsResult<Vec<String>> {
531        self.check_subject(FsOperation::ReadDir, path)?;
532        self.inner.read_dir_limited(path, max_entries)
533    }
534
535    fn read_dir_with_types(&mut self, path: &str) -> VfsResult<Vec<VirtualDirEntry>> {
536        self.check_subject(FsOperation::ReadDir, path)?;
537        self.inner.read_dir_with_types(path)
538    }
539
540    fn write_file(&mut self, path: &str, content: impl Into<Vec<u8>>) -> VfsResult<()> {
541        self.check_subject(FsOperation::Write, path)?;
542        self.inner.write_file(path, content)
543    }
544
545    fn create_file_exclusive(&mut self, path: &str, content: impl Into<Vec<u8>>) -> VfsResult<()> {
546        self.check_subject(FsOperation::Write, path)?;
547        self.inner.create_file_exclusive(path, content)
548    }
549
550    fn append_file(&mut self, path: &str, content: impl Into<Vec<u8>>) -> VfsResult<u64> {
551        self.check_subject(FsOperation::Write, path)?;
552        self.inner.append_file(path, content)
553    }
554
555    fn create_dir(&mut self, path: &str) -> VfsResult<()> {
556        self.check_subject(FsOperation::CreateDir, path)?;
557        self.inner.create_dir(path)
558    }
559
560    fn mkdir(&mut self, path: &str, recursive: bool) -> VfsResult<()> {
561        self.check_subject(FsOperation::Mkdir, path)?;
562        self.inner.mkdir(path, recursive)
563    }
564
565    fn exists(&self, path: &str) -> bool {
566        PermissionedFileSystem::exists(self, path).unwrap_or(false)
567    }
568
569    fn stat(&mut self, path: &str) -> VfsResult<VirtualStat> {
570        self.check_subject(FsOperation::Stat, path)?;
571        self.inner.stat(path)
572    }
573
574    fn remove_file(&mut self, path: &str) -> VfsResult<()> {
575        self.check_subject(FsOperation::Remove, path)?;
576        self.inner.remove_file(path)
577    }
578
579    fn remove_dir(&mut self, path: &str) -> VfsResult<()> {
580        self.check_subject(FsOperation::Remove, path)?;
581        self.inner.remove_dir(path)
582    }
583
584    fn rename(&mut self, old_path: &str, new_path: &str) -> VfsResult<()> {
585        self.check_subject(FsOperation::Rename, old_path)?;
586        self.check_subject(FsOperation::Rename, new_path)?;
587        self.inner.rename(old_path, new_path)
588    }
589
590    fn realpath(&self, path: &str) -> VfsResult<String> {
591        self.check_subject(FsOperation::Read, path)?;
592        self.inner.realpath(path)
593    }
594
595    fn symlink(&mut self, target: &str, link_path: &str) -> VfsResult<()> {
596        self.check_subject(FsOperation::Symlink, link_path)?;
597        self.inner.symlink(target, link_path)
598    }
599
600    fn read_link(&self, path: &str) -> VfsResult<String> {
601        // Authorize the parent-symlink-resolved path (without following the
602        // final component, matching `lstat`/`readlink` semantics). A lexical
603        // check would let a symlink whose parent resolves into a denied prefix
604        // disclose link targets of permission-denied paths.
605        validate_path(path)?;
606        let subject = self.resolved_destination_path(path)?;
607        self.check(FsOperation::ReadLink, &subject)?;
608        self.inner.read_link(path)
609    }
610
611    fn lstat(&self, path: &str) -> VfsResult<VirtualStat> {
612        // Authorize the parent-symlink-resolved path (see `read_link`); a
613        // lexical check would leak metadata (size/mode/mtime/inode) of files
614        // under a permission-denied prefix reached via a symlinked parent.
615        validate_path(path)?;
616        let subject = self.resolved_destination_path(path)?;
617        self.check(FsOperation::Stat, &subject)?;
618        self.inner.lstat(path)
619    }
620
621    fn link(&mut self, old_path: &str, new_path: &str) -> VfsResult<()> {
622        self.check_existing_subject(FsOperation::Link, old_path)?;
623        self.check_destination_subject(FsOperation::Link, new_path)?;
624        self.inner.link(old_path, new_path)
625    }
626
627    fn chmod(&mut self, path: &str, mode: u32) -> VfsResult<()> {
628        self.check_subject(FsOperation::Chmod, path)?;
629        self.inner.chmod(path, mode)
630    }
631
632    fn chown(&mut self, path: &str, uid: u32, gid: u32) -> VfsResult<()> {
633        self.check_subject(FsOperation::Chown, path)?;
634        self.inner.chown(path, uid, gid)
635    }
636
637    fn utimes(&mut self, path: &str, atime_ms: u64, mtime_ms: u64) -> VfsResult<()> {
638        self.check_subject(FsOperation::Utimes, path)?;
639        self.inner.utimes(path, atime_ms, mtime_ms)
640    }
641
642    fn utimes_spec(
643        &mut self,
644        path: &str,
645        atime: VirtualUtimeSpec,
646        mtime: VirtualUtimeSpec,
647        follow_symlinks: bool,
648    ) -> VfsResult<()> {
649        self.check_subject(FsOperation::Utimes, path)?;
650        self.inner.utimes_spec(path, atime, mtime, follow_symlinks)
651    }
652
653    fn truncate(&mut self, path: &str, length: u64) -> VfsResult<()> {
654        self.check_subject(FsOperation::Truncate, path)?;
655        self.inner.truncate(path, length)
656    }
657
658    fn pread(&mut self, path: &str, offset: u64, length: usize) -> VfsResult<Vec<u8>> {
659        self.check_subject(FsOperation::Read, path)?;
660        self.inner.pread(path, offset, length)
661    }
662}