Skip to main content

capsule_runtime/
session_check.rs

1//! One command run in a session's work folder after the session has ended, under the containment
2//! the session's own commands had: what `mur eval run` judges a case's work by.
3//!
4//! The check is not part of the session. [`StagedSession::check_runner`] captures, before launch,
5//! what the session's [`crate::sandbox::ShellEnforcement`] is built from; [`CheckRunner::run`]
6//! builds a new enforcement from it once `launch_session` has returned and every process the
7//! session started is dead. The command, its files and its output reach no trace, no provider
8//! request and no file the agent could read while it ran.
9//!
10//! The enforcement is the one `runtime::launch_session` installs: the same capability policy,
11//! declared floor, host probe and `/tmp` mapping, so the same shell allowlist, Landlock scope,
12//! seccomp filter, network namespace and resource limits. Only its scope is new: a cgroup scope,
13//! runtime directory and network namespace of its own, each ended or removed when the check
14//! returns. `$HOME` and `$TMPDIR` are the session's `home/` and `tmp/`, as for its commands.
15
16use std::{
17    path::{Path, PathBuf},
18    sync::{
19        atomic::{AtomicI32, Ordering},
20        mpsc, Arc,
21    },
22    time::{Duration, Instant},
23};
24
25use serde::{Deserialize, Serialize};
26
27use crate::{
28    sandbox::{HostProbe, ShellEnforcement},
29    shell::{ShellExecError, ShellResult},
30    types::{CapabilityPolicy, StagedSession},
31    workdir_fs::WorkdirRoot,
32};
33
34/// How much of the check's output [`CheckOutcome::output_tail`] keeps: the last bytes of stdout
35/// followed by stderr.
36pub const CHECK_OUTPUT_TAIL_BYTES: usize = 4096;
37
38/// Where the check's captured stdout then stderr are written, beneath the session's records
39/// directory.
40pub const CHECK_LOG_FILE: &str = "logs/check.log";
41
42/// How long a timed-out check's command is given, once its processes are killed, to hand back
43/// what it wrote before the outcome is reported without it.
44const KILLED_OUTPUT_WAIT: Duration = Duration::from_secs(10);
45
46/// A command to run in a session's work folder once the session has ended.
47#[derive(Debug, Clone)]
48pub struct SessionCheck {
49    command: String,
50    binary: String,
51    args: Vec<String>,
52    files: Option<PathBuf>,
53    timeout: Duration,
54}
55
56impl SessionCheck {
57    /// `command` split into a binary and its arguments the way the shell tool splits a command
58    /// line: whitespace separates words, quotes group them, a backslash escapes one character,
59    /// and nothing is expanded. `files` is a directory copied into the work folder's root before
60    /// the command runs. Refuses a command with no words and a zero `timeout`.
61    pub fn new(command: &str, files: Option<PathBuf>, timeout: Duration) -> Result<Self, String> {
62        let mut words = crate::shell::split_shell_words(command);
63        if words.is_empty() {
64            return Err("the check command is empty".to_string());
65        }
66        if timeout.is_zero() {
67            return Err("the check timeout is zero".to_string());
68        }
69        let binary = words.remove(0);
70        Ok(Self {
71            command: command.to_string(),
72            binary,
73            args: words,
74            files,
75            timeout,
76        })
77    }
78
79    /// The binary the command runs, which must be in `capabilities.shell.allow`.
80    #[must_use]
81    pub fn binary(&self) -> &str {
82        &self.binary
83    }
84
85    /// The arguments after [`Self::binary`].
86    #[must_use]
87    pub fn args(&self) -> &[String] {
88        &self.args
89    }
90
91    /// The command line as given.
92    #[must_use]
93    pub fn command(&self) -> &str {
94        &self.command
95    }
96
97    /// The directory copied into the work folder before the command runs.
98    #[must_use]
99    pub fn files(&self) -> Option<&Path> {
100        self.files.as_deref()
101    }
102}
103
104/// How a check ended.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "snake_case")]
107pub enum CheckResult {
108    /// The command exited 0.
109    Pass,
110    /// The command exited non-zero, or was killed for a resource limit.
111    Fail,
112    /// The command outran the check's timeout and was killed.
113    TimedOut,
114    /// The command never ran to an exit: its files could not be copied, its enforcement could
115    /// not be built, or it could not be started.
116    Error,
117}
118
119/// What one check did.
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct CheckOutcome {
122    pub result: CheckResult,
123    /// The command's exit code. `None` when it timed out or never ran.
124    pub exit_code: Option<i32>,
125    /// From the start of the command to its exit or kill; 0 when it never started.
126    pub duration_ms: u64,
127    /// The host resource limit that killed the command, as the shell tool names it.
128    pub resource_limit: Option<String>,
129    /// The last [`CHECK_OUTPUT_TAIL_BYTES`] bytes of stdout followed by stderr.
130    pub output_tail: String,
131    /// The full captured output, [`CHECK_LOG_FILE`] in the session's records directory. `None`
132    /// when the command produced no result to write.
133    pub output_log: Option<PathBuf>,
134    /// Why the result is [`CheckResult::Error`], or what went wrong around a result.
135    pub error: Option<String>,
136    /// The containment class the session's `session_start` recorded as achieved, which the check
137    /// ran under.
138    pub containment: String,
139}
140
141/// What a session's containment was built from, captured before launch, to run a
142/// [`SessionCheck`] under it after the session ends.
143#[derive(Debug, Clone)]
144pub struct CheckRunner {
145    session_id: String,
146    policy: CapabilityPolicy,
147    /// The session's own decision, so the check installs the tier the session's commands ran on.
148    containment: crate::containment::ContainmentDecision,
149    host_probe: HostProbe,
150    temp_mapping: crate::temp_dir::TempMapping,
151    accessible_root: WorkdirRoot,
152    records_root: WorkdirRoot,
153    records_dir: PathBuf,
154    /// `~/.murmur/sessions`, where the session's runtime directory was and the check's goes.
155    sessions_dir: Option<PathBuf>,
156}
157
158impl StagedSession {
159    /// What [`CheckRunner::run`] needs to run a check under this session's containment once the
160    /// session has ended. Taken before `launch_session`, which consumes the session.
161    #[must_use]
162    pub fn check_runner(&self) -> CheckRunner {
163        CheckRunner {
164            session_id: self.session_id.clone(),
165            policy: self.capability_policy.clone(),
166            containment: self.containment,
167            host_probe: self.host_probe.clone(),
168            temp_mapping: self.temp_mapping,
169            accessible_root: self.accessible_root.clone(),
170            records_root: self.records_root.clone(),
171            records_dir: self.records_dir.clone(),
172            sessions_dir: self
173                .runtime_dir
174                .parent()
175                .and_then(Path::parent)
176                .map(Path::to_path_buf),
177        }
178    }
179}
180
181impl CheckRunner {
182    /// Copies `check`'s files into the work folder and runs its command there, under the
183    /// session's containment, then kills everything it started. Every failure is an outcome.
184    ///
185    /// Call only after `launch_session` has returned: the session's processes are dead then, and
186    /// nothing the session started can see the files or the command.
187    pub fn run(&self, check: &SessionCheck) -> CheckOutcome {
188        let containment = self.containment.achieved().as_str().to_string();
189        let error = |error: String| CheckOutcome {
190            result: CheckResult::Error,
191            exit_code: None,
192            duration_ms: 0,
193            resource_limit: None,
194            output_tail: String::new(),
195            output_log: None,
196            error: Some(error),
197            containment: containment.clone(),
198        };
199
200        if let Some(files) = check.files() {
201            if let Err(refusal) = copy_tree_into(files, &self.accessible_root) {
202                return error(format!("could not copy the check's files: {refusal}"));
203            }
204        }
205
206        let checked = match self.enforcement() {
207            Ok((enforcement, runtime_dir)) => {
208                let checked =
209                    run_with_timeout(check, &self.accessible_root, &self.policy, &enforcement);
210                // Whatever the command left running is killed and its namespace ended before the
211                // runtime directory it was told of is removed.
212                enforcement.end_session();
213                drop(runtime_dir);
214                checked
215            }
216            Err(reason) => return error(reason),
217        };
218
219        let (result, exit_code, duration_ms, shell) = match checked {
220            Checked::Exited(shell) => {
221                let result = if shell.exit_code == 0 {
222                    CheckResult::Pass
223                } else {
224                    CheckResult::Fail
225                };
226                (
227                    result,
228                    Some(shell.exit_code),
229                    shell.duration_ms,
230                    Some(shell),
231                )
232            }
233            Checked::TimedOut { elapsed, shell } => (
234                CheckResult::TimedOut,
235                None,
236                elapsed.as_millis().try_into().unwrap_or(u64::MAX),
237                shell,
238            ),
239            Checked::Failed(reason) => return error(reason),
240        };
241
242        let (stdout, stderr) = shell
243            .as_ref()
244            .map(|shell| (shell.stdout.as_str(), shell.stderr.as_str()))
245            .unwrap_or_default();
246        let mut output = Vec::with_capacity(stdout.len() + stderr.len());
247        output.extend_from_slice(stdout.as_bytes());
248        output.extend_from_slice(stderr.as_bytes());
249        let (output_log, log_error) = match self.records_root.write_file_exact_mode(
250            CHECK_LOG_FILE,
251            &output,
252            crate::murmur_home::PRIVATE_FILE_MODE,
253        ) {
254            Ok(()) => (Some(self.records_dir.join(CHECK_LOG_FILE)), None),
255            Err(error) => (
256                None,
257                Some(format!("could not write {CHECK_LOG_FILE}: {error}")),
258            ),
259        };
260        let tail_start = output.len().saturating_sub(CHECK_OUTPUT_TAIL_BYTES);
261
262        CheckOutcome {
263            result,
264            exit_code,
265            duration_ms,
266            resource_limit: shell
267                .as_ref()
268                .and_then(|shell| shell.resource_limit_hit.clone()),
269            output_tail: String::from_utf8_lossy(&output[tail_start..]).into_owned(),
270            output_log,
271            error: log_error,
272            containment,
273        }
274    }
275
276    /// The enforcement `launch_session` builds for a session's commands, rebuilt from what the
277    /// session's was built from, with a cgroup scope, workdir guard and runtime directory of its
278    /// own. The runtime directory is removed when the returned value drops.
279    fn enforcement(&self) -> Result<(ShellEnforcement, crate::runtime_dir::RuntimeDir), String> {
280        let check_id = format!("{}-check", self.session_id);
281        let prepared = crate::runtime::prepare_launch_scope(
282            &self.policy,
283            false,
284            &check_id,
285            self.accessible_root.path(),
286        )
287        .map_err(|error| error.to_string())?;
288        let temp = self
289            .records_root
290            .open_beneath(crate::temp_dir::SESSION_TEMP_DIR_NAME)
291            .map_err(|error| error.to_string())?;
292        let home = self
293            .records_root
294            .open_beneath(crate::records_dir::HOME_SUBDIR)
295            .map_err(|error| error.to_string())?;
296        let workdir_guard =
297            crate::resources::workdir_guard_max_bytes(&self.policy).map(|max_bytes| {
298                crate::resources::WorkdirGuard::spawn(
299                    &self.accessible_root,
300                    temp.as_ref(),
301                    home.as_ref(),
302                    Some(&self.records_root),
303                    max_bytes,
304                )
305            });
306        let sessions_dir = match &self.sessions_dir {
307            Some(dir) => dir.clone(),
308            None => crate::runtime_dir::sessions_dir().map_err(|error| error.to_string())?,
309        };
310        let runtime_dir =
311            crate::runtime_dir::RuntimeDir::create(&sessions_dir, &check_id, &self.accessible_root)
312                .map_err(|reason| format!("the check's runtime directory: {reason}"))?;
313        let mut roots = runtime_dir
314            .roots()
315            .clone()
316            .with_records(self.records_root.clone());
317        if let Some(temp) = temp {
318            roots = roots.with_temp(temp);
319        }
320        if let Some(home) = home {
321            roots = roots.with_home(home);
322        }
323        let enforcement =
324            ShellEnforcement::resolve(&self.policy, &self.containment, self.host_probe.clone())?
325                .with_host_bounding(prepared.scope, workdir_guard)
326                .with_runtime_dir(roots, None)
327                .with_temp_mapping(self.temp_mapping);
328        Ok((enforcement, runtime_dir))
329    }
330}
331
332/// How the check's command ended, before it is judged.
333enum Checked {
334    Exited(ShellResult),
335    /// Killed at the timeout. `shell` is what it had written, when it handed that back in time.
336    TimedOut {
337        elapsed: Duration,
338        shell: Option<ShellResult>,
339    },
340    Failed(String),
341}
342
343/// Runs the command on a thread of its own and waits at most `check.timeout` for it. At the
344/// timeout every process in the check's cgroup scope is killed, and its process group with them,
345/// which is the only kill where there is no scope.
346fn run_with_timeout(
347    check: &SessionCheck,
348    workdir: &WorkdirRoot,
349    policy: &CapabilityPolicy,
350    enforcement: &ShellEnforcement,
351) -> Checked {
352    let (sender, receiver) = mpsc::channel::<Result<ShellResult, ShellExecError>>();
353    let group = Arc::new(AtomicI32::new(0));
354    let started = Instant::now();
355    {
356        let binary = check.binary.clone();
357        let args = check.args.clone();
358        let workdir = workdir.clone();
359        let policy = policy.clone();
360        let enforcement = enforcement.clone();
361        let group = Arc::clone(&group);
362        let spawned = std::thread::Builder::new()
363            .name("session-check".to_string())
364            .spawn(move || {
365                crate::shell::report_spawned_group_to(group);
366                let args = args.iter().map(String::as_str).collect::<Vec<_>>();
367                let result = crate::shell::execute_shell(
368                    &binary,
369                    &args,
370                    &[],
371                    &workdir,
372                    &policy,
373                    &enforcement,
374                );
375                let _ = sender.send(result);
376            });
377        if let Err(error) = spawned {
378            return Checked::Failed(format!("could not start the check: {error}"));
379        }
380    }
381
382    let settled = |result: Result<ShellResult, ShellExecError>| match result {
383        Ok(shell) => Checked::Exited(shell),
384        Err(error) => Checked::Failed(error.to_string()),
385    };
386    match receiver.recv_timeout(check.timeout) {
387        Ok(result) => settled(result),
388        Err(mpsc::RecvTimeoutError::Disconnected) => {
389            Checked::Failed("the check's thread ended without a result".to_string())
390        }
391        Err(mpsc::RecvTimeoutError::Timeout) => {
392            let elapsed = started.elapsed();
393            enforcement.kill_session_processes();
394            let pgid = group.load(Ordering::SeqCst);
395            if pgid > 0 {
396                crate::formation_launch::signal_group(i64::from(pgid), libc::SIGKILL);
397            }
398            let shell = match receiver.recv_timeout(KILLED_OUTPUT_WAIT) {
399                Ok(Ok(shell)) => Some(shell),
400                _ => None,
401            };
402            Checked::TimedOut { elapsed, shell }
403        }
404    }
405}
406
407/// One entry beneath an operator's directory tree: an eval case's `start` or `check.files`.
408#[derive(Debug, Clone, PartialEq, Eq)]
409pub struct TreeEntry {
410    /// Relative to the tree's root.
411    pub relative: PathBuf,
412    pub is_dir: bool,
413    /// `mode & 0o7777`.
414    pub mode: u32,
415}
416
417/// Every entry beneath `root`, in byte-wise order of the relative path, so a parent comes before
418/// what it holds. Read with no link followed: a symbolic link or a special file anywhere in the
419/// tree is refused, naming it.
420pub fn walk_tree(root: &Path) -> Result<Vec<TreeEntry>, String> {
421    use std::os::unix::{ffi::OsStrExt as _, fs::PermissionsExt as _};
422
423    fn walk(dir: &Path, relative: &Path, out: &mut Vec<TreeEntry>) -> Result<(), String> {
424        let read = |path: &Path, error: std::io::Error| format!("{}: {error}", path.display());
425        for entry in std::fs::read_dir(dir).map_err(|error| read(dir, error))? {
426            let entry = entry.map_err(|error| read(dir, error))?;
427            let path = entry.path();
428            let rel = relative.join(entry.file_name());
429            let meta = std::fs::symlink_metadata(&path).map_err(|error| read(&path, error))?;
430            let mode = meta.permissions().mode() & 0o7777;
431            let file_type = meta.file_type();
432            if file_type.is_symlink() {
433                return Err(format!("{} is a symbolic link", path.display()));
434            }
435            if file_type.is_dir() {
436                out.push(TreeEntry {
437                    relative: rel.clone(),
438                    is_dir: true,
439                    mode,
440                });
441                walk(&path, &rel, out)?;
442            } else if file_type.is_file() {
443                out.push(TreeEntry {
444                    relative: rel,
445                    is_dir: false,
446                    mode,
447                });
448            } else {
449                return Err(format!("{} is a special file", path.display()));
450            }
451        }
452        Ok(())
453    }
454
455    let mut entries = Vec::new();
456    walk(root, Path::new(""), &mut entries)?;
457    entries.sort_by(|a, b| {
458        a.relative
459            .as_os_str()
460            .as_bytes()
461            .cmp(b.relative.as_os_str().as_bytes())
462    });
463    Ok(entries)
464}
465
466/// Copies the tree at `source` into `workdir`'s root: directories and regular files, each
467/// entry's permission bits kept, an existing file overwritten.
468///
469/// The whole of `source` is walked by [`walk_tree`] before anything is written, so a link or
470/// special file in it refuses the copy with the work folder untouched. Every write goes through
471/// `workdir`, so nothing already in the work folder — a link where a directory of the tree goes,
472/// notably — is followed; such a refusal names the path. Each directory is held at owner `rwx`
473/// while it is filled and given its own mode last, deepest first, so a read-only directory is
474/// filled before it is closed.
475pub fn copy_tree_into(source: &Path, workdir: &WorkdirRoot) -> Result<(), String> {
476    const FILLING_DIR_MODE: u32 = 0o700;
477
478    let entries = walk_tree(source)?;
479    for entry in &entries {
480        let rel = entry.relative.to_string_lossy();
481        if entry.is_dir {
482            workdir
483                .create_dir_all(rel.as_ref(), FILLING_DIR_MODE)
484                .and_then(|()| workdir.set_dir_mode(rel.as_ref(), FILLING_DIR_MODE))
485                .map_err(|error| error.to_string())?;
486        } else {
487            let path = source.join(&entry.relative);
488            let bytes =
489                std::fs::read(&path).map_err(|error| format!("{}: {error}", path.display()))?;
490            workdir
491                .write_file_exact_mode(rel.as_ref(), &bytes, entry.mode)
492                .map_err(|error| error.to_string())?;
493        }
494    }
495    for entry in entries.iter().rev().filter(|entry| entry.is_dir) {
496        let rel = entry.relative.to_string_lossy();
497        workdir
498            .set_dir_mode(rel.as_ref(), entry.mode)
499            .map_err(|error| error.to_string())?;
500    }
501    Ok(())
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507
508    #[test]
509    fn a_command_splits_as_the_shell_tool_splits_it() {
510        let command = r#"grep -q "fixed value" 'a b'/c\ d.txt"#;
511        let check = SessionCheck::new(command, None, Duration::from_secs(1)).unwrap();
512        let mut words = crate::shell::split_shell_words(command);
513        assert_eq!(check.binary(), words.remove(0));
514        assert_eq!(check.args(), words.as_slice());
515        assert_eq!(check.binary(), "grep");
516        assert_eq!(check.args(), ["-q", "fixed value", "a b/c d.txt"]);
517        assert_eq!(check.command(), command);
518    }
519
520    #[test]
521    fn an_empty_command_is_refused() {
522        for command in ["", "   ", "\t\n"] {
523            let refused = SessionCheck::new(command, None, Duration::from_secs(1)).unwrap_err();
524            assert!(refused.contains("empty"), "{refused}");
525        }
526    }
527
528    #[test]
529    fn a_zero_timeout_is_refused() {
530        let refused = SessionCheck::new("true", None, Duration::ZERO).unwrap_err();
531        assert!(refused.contains("timeout"), "{refused}");
532    }
533
534    #[test]
535    fn outcomes_serialize_in_snake_case() {
536        let outcome = CheckOutcome {
537            result: CheckResult::TimedOut,
538            exit_code: None,
539            duration_ms: 2000,
540            resource_limit: None,
541            output_tail: String::new(),
542            output_log: None,
543            error: None,
544            containment: "scoped".to_string(),
545        };
546        let json = serde_json::to_value(&outcome).unwrap();
547        assert_eq!(json["result"], "timed_out");
548        assert_eq!(json["containment"], "scoped");
549        let back: CheckOutcome = serde_json::from_value(json).unwrap();
550        assert_eq!(back, outcome);
551    }
552
553    #[test]
554    fn check_files_with_a_link_are_refused_before_anything_is_written() {
555        let source = tempfile::tempdir().unwrap();
556        std::fs::write(source.path().join("a.txt"), "a").unwrap();
557        std::os::unix::fs::symlink("/etc/passwd", source.path().join("z-link")).unwrap();
558        let workdir = tempfile::tempdir().unwrap();
559        let root = WorkdirRoot::open(workdir.path()).unwrap();
560        let refused = copy_tree_into(source.path(), &root).unwrap_err();
561        assert!(refused.contains("z-link"), "{refused}");
562        assert!(!workdir.path().join("a.txt").exists());
563    }
564
565    #[test]
566    fn check_files_keep_their_mode_and_overwrite_what_is_there() {
567        use std::os::unix::fs::PermissionsExt as _;
568        let source = tempfile::tempdir().unwrap();
569        std::fs::create_dir(source.path().join("sub")).unwrap();
570        std::fs::write(source.path().join("sub/run.sh"), "new").unwrap();
571        std::fs::set_permissions(
572            source.path().join("sub/run.sh"),
573            std::fs::Permissions::from_mode(0o751),
574        )
575        .unwrap();
576        let workdir = tempfile::tempdir().unwrap();
577        std::fs::create_dir(workdir.path().join("sub")).unwrap();
578        std::fs::write(workdir.path().join("sub/run.sh"), "old").unwrap();
579        let root = WorkdirRoot::open(workdir.path()).unwrap();
580        copy_tree_into(source.path(), &root).unwrap();
581        let copied = workdir.path().join("sub/run.sh");
582        assert_eq!(std::fs::read_to_string(&copied).unwrap(), "new");
583        assert_eq!(
584            std::fs::metadata(&copied).unwrap().permissions().mode() & 0o7777,
585            0o751
586        );
587    }
588
589    #[test]
590    fn a_read_only_directory_in_check_files_is_filled_then_closed() {
591        use std::os::unix::fs::PermissionsExt as _;
592        let source = tempfile::tempdir().unwrap();
593        std::fs::create_dir(source.path().join("ro")).unwrap();
594        std::fs::write(source.path().join("ro/t.txt"), "t").unwrap();
595        std::fs::set_permissions(
596            source.path().join("ro"),
597            std::fs::Permissions::from_mode(0o555),
598        )
599        .unwrap();
600        let workdir = tempfile::tempdir().unwrap();
601        let root = WorkdirRoot::open(workdir.path()).unwrap();
602        copy_tree_into(source.path(), &root).unwrap();
603        let copied = workdir.path().join("ro");
604        assert_eq!(std::fs::read_to_string(copied.join("t.txt")).unwrap(), "t");
605        assert_eq!(
606            std::fs::metadata(&copied).unwrap().permissions().mode() & 0o7777,
607            0o555
608        );
609        // Copied again over the read-only directory now in the work folder, as a second check
610        // into the same folder would.
611        copy_tree_into(source.path(), &root).unwrap();
612        for dir in [source.path().join("ro"), copied] {
613            std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o755)).unwrap();
614        }
615    }
616
617    #[test]
618    fn a_link_the_agent_left_is_not_followed() {
619        let source = tempfile::tempdir().unwrap();
620        std::fs::create_dir(source.path().join("hidden")).unwrap();
621        std::fs::write(source.path().join("hidden/check.txt"), "x").unwrap();
622        let outside = tempfile::tempdir().unwrap();
623        let workdir = tempfile::tempdir().unwrap();
624        std::os::unix::fs::symlink(outside.path(), workdir.path().join("hidden")).unwrap();
625        let root = WorkdirRoot::open(workdir.path()).unwrap();
626        let refused = copy_tree_into(source.path(), &root).unwrap_err();
627        assert!(refused.contains("hidden"), "{refused}");
628        assert!(std::fs::read_dir(outside.path()).unwrap().next().is_none());
629    }
630}