Skip to main content

capsule_runtime/
host_paths.rs

1//! Host folders the operator lends every contained capsule on this host: the `host_paths:` list in
2//! `~/.murmur/config.yaml`.
3//!
4//! Each entry names one host directory and an access, `read` or `read-write`. On `landlock+seccomp`
5//! it becomes one Landlock rule for every command, plan shell step and `murmur:process` program; on
6//! `mountns+pivot_root+landlock+seccomp` it is also bound into the composed root at the directory's
7//! own absolute path. No rule ever carries `Execute`, so `capabilities.shell.allow` stays the
8//! complete list of what a contained capsule can run. Below `landlock+seccomp` nothing is scoped,
9//! and an entry is checked and reported but adds nothing.
10//!
11//! Checking happens in three steps, each refusing with the entry's index and its path as written:
12//!
13//! 1. [`declare_host_paths`]: the shape of what the operator wrote (`path`, `access`, `~`
14//!    expansion, `/`, the same path twice). No filesystem access.
15//! 2. [`resolve_host_paths`]: the entry on this host, at launch (exists, is a directory, not a
16//!    system path, not read-write over murmur's own home, not inside the work folder, not the
17//!    same directory twice), and the credential locations it reaches.
18//! 3. [`check_execute_roots`]: against what the session may run, on the applied tier, at
19//!    staging and by `mur run --explain-scope`.
20//!
21//! Platform-neutral: the Landlock rules and binds live in `sandbox` and `sealed`, behind the same
22//! `target_os = "linux"` gates as every other rule.
23
24use std::fmt;
25use std::path::{Path, PathBuf};
26
27use serde::{Deserialize, Serialize};
28
29/// What a host path lets a capsule do there. Required in every entry: there is no default.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
31#[serde(rename_all = "kebab-case")]
32pub enum HostPathAccess {
33    /// Read files and list directories: Landlock `ReadFile | ReadDir`, and a read-only bind.
34    Read,
35    /// The workdir's rights without `Execute`, and a read-write bind.
36    ReadWrite,
37}
38
39impl HostPathAccess {
40    /// The spelling in `config.yaml` and in every report.
41    #[must_use]
42    pub fn wire_name(self) -> &'static str {
43        match self {
44            Self::Read => "read",
45            Self::ReadWrite => "read-write",
46        }
47    }
48
49    /// The access `value` spells, or `None` for anything but `read` and `read-write`.
50    #[must_use]
51    pub fn parse(value: &str) -> Option<Self> {
52        match value {
53            "read" => Some(Self::Read),
54            "read-write" => Some(Self::ReadWrite),
55            _ => None,
56        }
57    }
58}
59
60impl fmt::Display for HostPathAccess {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        f.write_str(self.wire_name())
63    }
64}
65
66/// One `host_paths` entry exactly as the config file holds it. Both fields are optional here so
67/// a missing one is refused by [`declare_host_paths`] naming the entry, rather than failing the
68/// whole file's parse.
69#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
70pub struct HostPathEntry {
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub path: Option<String>,
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub access: Option<String>,
75}
76
77/// An entry whose shape is valid: `~` expanded, `.` and `..` folded, not yet looked up on the
78/// host.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct DeclaredHostPath {
81    /// Position in the config file's list.
82    pub index: usize,
83    /// `path` as written.
84    pub declared: String,
85    /// `path` made absolute against `HOME` and folded lexically.
86    pub expanded: PathBuf,
87    pub access: HostPathAccess,
88}
89
90/// A host path a session grants: checked on this host, in config order.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct HostPath {
93    /// `path` as written in the config file.
94    pub declared: String,
95    /// The directory it resolves to, symlinks followed. The rule and the bind are taken here.
96    pub path: PathBuf,
97    pub access: HostPathAccess,
98    /// Every [`CREDENTIAL_LOCATIONS`] entry present on this host that this directory is, lies
99    /// under, or contains, spelled `~/<location>`. Empty for an entry reaching none.
100    pub credential_locations: Vec<String>,
101}
102
103impl HostPath {
104    /// The locations joined for a report, or `None` when there are none.
105    #[must_use]
106    pub fn credential_location(&self) -> Option<String> {
107        (!self.credential_locations.is_empty()).then(|| self.credential_locations.join(", "))
108    }
109}
110
111/// Why an entry cannot be honoured. Every refusal names the entry's index, its path as written,
112/// the directory it resolves to when it has one, and the reason.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct HostPathRefusal {
115    pub index: usize,
116    /// `path` as written, or `<none>` for an entry with no `path`.
117    pub declared: String,
118    pub resolved: Option<PathBuf>,
119    pub reason: String,
120    /// The entry's directory does not exist on this host. The one refusal a harness need reports
121    /// as `missing` rather than refusing the launch.
122    pub missing: bool,
123}
124
125impl fmt::Display for HostPathRefusal {
126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127        write!(f, "host_paths[{}] ({}", self.index, self.declared)?;
128        if let Some(resolved) = &self.resolved {
129            if Path::new(&self.declared) != resolved.as_path() {
130                write!(f, ", resolved to {}", resolved.display())?;
131            }
132        }
133        write!(f, "): {}", self.reason)
134    }
135}
136
137impl std::error::Error for HostPathRefusal {}
138
139/// What the applied tier does with a host path, as a stable wire name.
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
141#[serde(rename_all = "lowercase")]
142pub enum HostPathMechanism {
143    /// `landlock+seccomp`: one Landlock rule over the host's own filesystem.
144    Landlock,
145    /// `mountns+pivot_root+landlock+seccomp`: a bind into the composed root, plus the same rule.
146    Bind,
147    /// Any weaker tier: nothing is scoped, so the entry adds nothing.
148    None,
149}
150
151impl HostPathMechanism {
152    /// The mechanism of the tier whose wire name is `enforcement_tier`
153    /// ([`crate::ScopeReport::enforcement_tier`]).
154    #[must_use]
155    pub fn for_enforcement_tier(enforcement_tier: &str) -> Self {
156        match enforcement_tier {
157            "mountns+pivot_root+landlock+seccomp" => Self::Bind,
158            "landlock+seccomp" => Self::Landlock,
159            _ => Self::None,
160        }
161    }
162
163    #[must_use]
164    pub fn wire_name(self) -> &'static str {
165        match self {
166            Self::Landlock => "landlock",
167            Self::Bind => "bind",
168            Self::None => "none",
169        }
170    }
171
172    /// What this mechanism does with one entry, in words, for `mur doctor`.
173    #[must_use]
174    pub fn describe(self, access: HostPathAccess) -> &'static str {
175        match (self, access) {
176            (Self::Landlock, HostPathAccess::Read) => {
177                "a read-only Landlock rule over the host's own folder"
178            }
179            (Self::Landlock, HostPathAccess::ReadWrite) => {
180                "a read-write Landlock rule over the host's own folder, never executable"
181            }
182            (Self::Bind, HostPathAccess::Read) => {
183                "a read-only bind at its own path in the composed root, plus a read-only Landlock rule"
184            }
185            (Self::Bind, HostPathAccess::ReadWrite) => {
186                "a read-write bind at its own path in the composed root, plus a read-write \
187                 Landlock rule, never executable"
188            }
189            (Self::None, _) => "nothing: this tier scopes no filesystem, so every host folder is already reachable",
190        }
191    }
192}
193
194/// One element of `ScopeReport::host_paths`: `--explain-scope --json` and
195/// `session_start.effective_grants.host_paths`. Every key is always serialized.
196#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
197pub struct HostPathReport {
198    /// The directory the entry resolves to.
199    pub path: String,
200    /// The entry's `path` as written.
201    pub declared: String,
202    pub access: HostPathAccess,
203    pub mechanism: HostPathMechanism,
204    /// The credential locations the entry reaches, joined with `, `, or `null`.
205    pub credential_location: Option<String>,
206}
207
208impl HostPathReport {
209    /// `declared → path (access)`, plus the credential note.
210    #[must_use]
211    pub fn render(&self) -> String {
212        let mut line = format!("{} → {} ({})", self.declared, self.path, self.access);
213        if let Some(location) = &self.credential_location {
214            line.push_str(&format!(", reaches credential location {location}"));
215        }
216        line
217    }
218}
219
220/// The reports for `host_paths` under `mechanism`, in config order.
221#[must_use]
222pub fn host_path_reports(
223    host_paths: &[HostPath],
224    mechanism: HostPathMechanism,
225) -> Vec<HostPathReport> {
226    host_paths
227        .iter()
228        .map(|host_path| HostPathReport {
229            path: host_path.path.display().to_string(),
230            declared: host_path.declared.clone(),
231            access: host_path.access,
232            mechanism,
233            credential_location: host_path.credential_location(),
234        })
235        .collect()
236}
237
238/// A home-relative place that holds a credential, and the credential backstop patterns
239/// (`shell::CREDENTIAL_ENV_PATTERNS`) for the same credential, where there is one.
240#[derive(Debug, Clone, Copy, PartialEq, Eq)]
241pub struct CredentialLocation {
242    /// Relative to `HOME`.
243    pub path: &'static str,
244    pub backstop: &'static [&'static str],
245}
246
247/// Every credential location a host path is warned about (`W-SEC-035`). The one list.
248pub const CREDENTIAL_LOCATIONS: &[CredentialLocation] = &[
249    CredentialLocation {
250        path: ".ssh",
251        backstop: &[],
252    },
253    CredentialLocation {
254        path: ".gnupg",
255        backstop: &[],
256    },
257    CredentialLocation {
258        path: ".aws",
259        backstop: &["AWS_*"],
260    },
261    CredentialLocation {
262        path: ".config/gh",
263        backstop: &["GITHUB_TOKEN", "GH_TOKEN"],
264    },
265    CredentialLocation {
266        path: ".docker",
267        backstop: &["DOCKER_*"],
268    },
269    CredentialLocation {
270        path: ".kube",
271        backstop: &["KUBECONFIG"],
272    },
273    CredentialLocation {
274        path: ".npmrc",
275        backstop: &["NPM_TOKEN"],
276    },
277    CredentialLocation {
278        path: ".pypirc",
279        backstop: &["PYPI_TOKEN"],
280    },
281    CredentialLocation {
282        path: ".cargo/credentials.toml",
283        backstop: &["CARGO_REGISTRY_TOKEN"],
284    },
285    CredentialLocation {
286        path: ".cargo/credentials",
287        backstop: &["CARGO_REGISTRY_TOKEN"],
288    },
289    CredentialLocation {
290        path: ".config/gcloud",
291        backstop: &["GOOGLE_API_KEY"],
292    },
293    CredentialLocation {
294        path: ".cache/huggingface",
295        backstop: &["HUGGING_FACE_HUB_TOKEN"],
296    },
297    CredentialLocation {
298        path: ".netrc",
299        backstop: &[],
300    },
301    CredentialLocation {
302        path: ".git-credentials",
303        backstop: &[],
304    },
305    // Provider keys in `config.yaml`'s `credentials:` and `inference.api_key`.
306    CredentialLocation {
307        path: ".murmur",
308        backstop: &[],
309    },
310];
311
312/// Host directories a host path may not be, or lie under: a composed root carries its own copy of
313/// each or replaces it, and the private `/tmp` replaces `/tmp`, so an entry there would not reach
314/// the host's folder on every tier. The canonical `/tmp` is added by [`system_paths`].
315const SYSTEM_PATHS: &[&str] = &["/etc", "/proc", "/sys", "/dev"];
316
317/// Every system path a host path is refused under, literal and canonical.
318fn system_paths() -> Vec<PathBuf> {
319    let mut paths: Vec<PathBuf> = crate::sealed::SEALED_RUNTIME_PATHS
320        .iter()
321        .chain(SYSTEM_PATHS)
322        .map(PathBuf::from)
323        .collect();
324    paths.push(PathBuf::from("/tmp"));
325    paths.push(crate::temp_dir::canonical_tmp());
326    let canonical: Vec<PathBuf> = paths
327        .iter()
328        .filter_map(|path| std::fs::canonicalize(path).ok())
329        .collect();
330    for path in canonical {
331        if !paths.contains(&path) {
332            paths.push(path);
333        }
334    }
335    paths
336}
337
338/// Checks the shape of every entry and expands `~` against `home`, the `HOME` `mur` runs with.
339///
340/// Refuses an entry with no `path` or no `access`, an `access` other than `read` or
341/// `read-write`, a relative path (`~user/…` and `$VAR/…` are not expanded, so they are relative),
342/// a path that is or folds to `/`, and a path an earlier entry already names.
343pub fn declare_host_paths(
344    entries: &[HostPathEntry],
345    home: Option<&Path>,
346) -> Result<Vec<DeclaredHostPath>, HostPathRefusal> {
347    let mut declared: Vec<DeclaredHostPath> = Vec::with_capacity(entries.len());
348    for (index, entry) in entries.iter().enumerate() {
349        let written = entry.path.clone().unwrap_or_else(|| "<none>".to_string());
350        let refuse = |reason: String| HostPathRefusal {
351            index,
352            declared: written.clone(),
353            resolved: None,
354            reason,
355            missing: false,
356        };
357        let Some(path) = entry.path.as_deref().filter(|path| !path.trim().is_empty()) else {
358            return Err(refuse(
359                "the entry has no path (an unquoted `~` is YAML null: write `path: \"~\"`)"
360                    .to_string(),
361            ));
362        };
363        let access = match entry.access.as_deref() {
364            None => {
365                return Err(refuse(
366                    "access is required: write `access: read` or `access: read-write`".to_string(),
367                ))
368            }
369            Some(value) => HostPathAccess::parse(value).ok_or_else(|| {
370                refuse(format!(
371                    "access '{value}' is neither `read` nor `read-write`"
372                ))
373            })?,
374        };
375        let expanded = expand_home(path, home).map_err(refuse)?;
376        let expanded = crate::protected_paths::normalize_absolute(&expanded);
377        if expanded == Path::new("/") {
378            return Err(refuse(
379                "names /, the whole host filesystem; name the folder the capsule needs".to_string(),
380            ));
381        }
382        if let Some(earlier) = declared.iter().find(|other| other.expanded == expanded) {
383            return Err(refuse(format!(
384                "names the same directory as host_paths[{}]",
385                earlier.index
386            )));
387        }
388        declared.push(DeclaredHostPath {
389            index,
390            declared: path.to_string(),
391            expanded,
392            access,
393        });
394    }
395    Ok(declared)
396}
397
398/// `path` made absolute against `home` (`~` and `~/…`) and folded lexically, without touching the
399/// filesystem, so an ancestor comparison over the result is purely lexical. The shape rules of
400/// [`declare_host_paths`] are not re-checked here: the harness-need path is validated at driver
401/// load. Shared by [`crate::harness_needs`], which compares driver needs and manifest grants.
402pub(crate) fn expand_and_fold(path: &str, home: Option<&Path>) -> Result<PathBuf, String> {
403    Ok(crate::protected_paths::normalize_absolute(&expand_home(
404        path, home,
405    )?))
406}
407
408/// `path` made absolute: `~` and `~/…` against `home`; anything else must already be absolute.
409fn expand_home(path: &str, home: Option<&Path>) -> Result<PathBuf, String> {
410    let rest = if path == "~" {
411        Some("")
412    } else {
413        path.strip_prefix("~/")
414    };
415    if let Some(rest) = rest {
416        let home = home
417            .filter(|home| home.is_absolute())
418            .ok_or_else(|| "starts with ~, and HOME is not set to an absolute path".to_string())?;
419        return Ok(home.join(rest));
420    }
421    if path.starts_with('~') {
422        return Err(
423            "is relative: ~user is not expanded; write an absolute path or ~/…".to_string(),
424        );
425    }
426    let path = PathBuf::from(path);
427    if !path.is_absolute() {
428        return Err(
429            "is relative; write an absolute path or ~/… (environment variables are not expanded)"
430                .to_string(),
431        );
432    }
433    Ok(path)
434}
435
436/// `path` folded lexically, then canonicalized through its nearest existing ancestor: for a
437/// directory that may not exist yet, such as a workdir root before its first run. Folded first so
438/// a `..` beneath the missing part cannot defeat a `starts_with` comparison.
439fn resolve_through_existing_ancestor(path: &Path) -> PathBuf {
440    crate::protected_paths::canonicalize_existing_prefix(
441        &crate::protected_paths::normalize_absolute(path),
442    )
443}
444
445/// What [`resolve_host_paths`] checks each entry against.
446#[derive(Debug, Clone, Copy)]
447pub struct HostPathContext<'a> {
448    /// The `HOME` `mur` runs with: where the credential locations are looked for.
449    pub home: Option<&'a Path>,
450    /// Murmur's own home, `$HOME/.murmur` (not `murmur_home::home_path()`, which is `HOME`
451    /// itself): a `read-write` entry over it is refused.
452    pub murmur_home: Option<&'a Path>,
453    /// The session's work folder root: an entry inside it is refused.
454    pub workdir: &'a Path,
455}
456
457/// Resolves every declared entry on this host and checks it, in config order.
458///
459/// Refuses an entry that does not exist or is not a directory, that resolves to `/`, that is or
460/// lies under a system path the runtime replaces, a `read-write` entry that is, lies under or
461/// contains murmur's own home, an entry inside the work folder, and an entry resolving to a
462/// directory an earlier one already names. Credential locations are found but never refuse.
463pub fn resolve_host_paths(
464    declared: &[DeclaredHostPath],
465    context: &HostPathContext<'_>,
466) -> Result<Vec<HostPath>, HostPathRefusal> {
467    let system = system_paths();
468    let murmur_home = context.murmur_home.map(resolve_through_existing_ancestor);
469    let workdir = resolve_through_existing_ancestor(context.workdir);
470    let mut resolved: Vec<HostPath> = Vec::with_capacity(declared.len());
471    let mut indices: Vec<usize> = Vec::with_capacity(declared.len());
472    for entry in declared {
473        let refuse = |resolved: Option<PathBuf>, reason: String| HostPathRefusal {
474            index: entry.index,
475            declared: entry.declared.clone(),
476            resolved,
477            reason,
478            missing: false,
479        };
480        let path = match std::fs::canonicalize(&entry.expanded) {
481            Ok(path) => path,
482            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
483                return Err(HostPathRefusal {
484                    missing: true,
485                    ..refuse(None, format!("{} does not exist", entry.expanded.display()))
486                })
487            }
488            Err(error) => {
489                return Err(refuse(
490                    None,
491                    format!("{} cannot be resolved: {error}", entry.expanded.display()),
492                ))
493            }
494        };
495        let is_dir = std::fs::metadata(&path).map(|meta| meta.is_dir());
496        if !matches!(is_dir, Ok(true)) {
497            return Err(refuse(
498                Some(path),
499                "is not a directory; host_paths names folders only".to_string(),
500            ));
501        }
502        if path == Path::new("/") {
503            return Err(refuse(
504                Some(path),
505                "resolves to /, the whole host filesystem".to_string(),
506            ));
507        }
508        if let Some(system_path) = system.iter().find(|system| path.starts_with(system)) {
509            return Err(refuse(
510                Some(path.clone()),
511                format!(
512                    "lies under {}, which the runtime replaces with its own copy in a composed \
513                     root (and a private /tmp replaces /tmp), so the entry would not reach the \
514                     host's folder on every tier",
515                    system_path.display()
516                ),
517            ));
518        }
519        if entry.access == HostPathAccess::ReadWrite {
520            if let Some(murmur_home) = murmur_home.as_deref() {
521                if path.starts_with(murmur_home) || murmur_home.starts_with(&path) {
522                    return Err(refuse(
523                        Some(path.clone()),
524                        format!(
525                            "is read-write over murmur's own home {}, which holds this config \
526                             file and every session's records; a capsule that could write it \
527                             would widen what the next capsule gets. Use `access: read`, or \
528                             name a folder that does not contain it",
529                            murmur_home.display()
530                        ),
531                    ));
532                }
533            }
534        }
535        if let Some(murmur_home) = murmur_home.as_deref() {
536            let keys = crate::record_seal::keys_dir(murmur_home);
537            if path.starts_with(&keys) || keys.starts_with(&path) {
538                return Err(refuse(
539                    Some(path.clone()),
540                    format!(
541                        "is or contains {}, which holds the record-seal key that signs every \
542                         session's records; no capsule may reach it, at any access. Name a \
543                         folder that does not contain it, such as {}",
544                        keys.display(),
545                        murmur_home
546                            .join(crate::records_dir::RECORDS_DIR_NAME)
547                            .display()
548                    ),
549                ));
550            }
551        }
552        if path.starts_with(&workdir) {
553            return Err(refuse(
554                Some(path.clone()),
555                format!(
556                    "lies inside the work folder {}, which the capsule already reaches read-write",
557                    workdir.display()
558                ),
559            ));
560        }
561        if let Some(position) = resolved.iter().position(|other| other.path == path) {
562            return Err(refuse(
563                Some(path),
564                format!(
565                    "resolves to the same directory as host_paths[{}]",
566                    indices[position]
567                ),
568            ));
569        }
570        let credential_locations = context
571            .home
572            .map(|home| credential_locations(&path, home))
573            .unwrap_or_default();
574        indices.push(entry.index);
575        resolved.push(HostPath {
576            declared: entry.declared.clone(),
577            path,
578            access: entry.access,
579            credential_locations,
580        });
581    }
582    Ok(resolved)
583}
584
585/// Every [`CREDENTIAL_LOCATIONS`] entry that exists under `home` and that `path` is, lies under,
586/// or contains, spelled `~/<location>`.
587#[must_use]
588pub fn credential_locations(path: &Path, home: &Path) -> Vec<String> {
589    let home = std::fs::canonicalize(home).unwrap_or_else(|_| home.to_path_buf());
590    CREDENTIAL_LOCATIONS
591        .iter()
592        .filter(|location| {
593            let lexical = home.join(location.path);
594            if std::fs::symlink_metadata(&lexical).is_err() {
595                return false;
596            }
597            let canonical = std::fs::canonicalize(&lexical).unwrap_or_else(|_| lexical.clone());
598            [lexical, canonical]
599                .iter()
600                .any(|location| path.starts_with(location) || location.starts_with(path))
601        })
602        .map(|location| format!("~/{}", location.path))
603        .collect()
604}
605
606/// The `W-SEC-035` line for an entry reaching a credential location, or `None` when it reaches
607/// none. Shared by `mur run` and `mur doctor` so the two cannot word it differently.
608#[must_use]
609pub fn credential_warning(host_path: &HostPath) -> Option<String> {
610    use murmur_artifact::{security_warning_link, W_SEC_035};
611    let locations = host_path.credential_location()?;
612    let verb = match host_path.access {
613        HostPathAccess::Read => "read",
614        HostPathAccess::ReadWrite => "read and change",
615    };
616    Some(format!(
617        "warning[{W_SEC_035}]: host_paths entry {} ({}) reaches the credential location {}, so \
618         every scoped or sealed capsule on this host can {verb} it ({})",
619        host_path.declared,
620        host_path.access,
621        locations,
622        security_warning_link(W_SEC_035)
623    ))
624}
625
626/// A directory the session may run something out of: the directory of an executable file its
627/// Landlock grants name, or an executable directory grant.
628#[derive(Debug, Clone, PartialEq, Eq)]
629pub struct ExecuteRoot {
630    pub dir: PathBuf,
631    /// The executable file or directory grant that makes `dir` an execute root.
632    pub because: PathBuf,
633}
634
635/// Checks the `read-write` entries against what the session may run, under `mechanism`.
636///
637/// On both Landlock mechanisms a `read-write` entry that is or lies under an execute root is
638/// refused. On `landlock` a `read-write` entry that contains one is refused too: nothing keeps
639/// the capsule from rewriting a binary it may run in place. On `bind` such an entry is allowed,
640/// and the execute roots inside it are returned, each to be bound read-only on top of it (a
641/// required bind). Below both, nothing is scoped and nothing is checked.
642pub fn check_execute_roots(
643    host_paths: &[HostPath],
644    roots: &[ExecuteRoot],
645    mechanism: HostPathMechanism,
646) -> Result<Vec<PathBuf>, HostPathRefusal> {
647    if mechanism == HostPathMechanism::None {
648        return Ok(Vec::new());
649    }
650    let mut protected: Vec<PathBuf> = Vec::new();
651    for (index, host_path) in host_paths.iter().enumerate() {
652        if host_path.access != HostPathAccess::ReadWrite {
653            continue;
654        }
655        for root in roots {
656            let refuse = |reason: String| HostPathRefusal {
657                index,
658                declared: host_path.declared.clone(),
659                resolved: Some(host_path.path.clone()),
660                reason,
661                missing: false,
662            };
663            if host_path.path.starts_with(&root.dir) {
664                return Err(refuse(format!(
665                    "is read-write inside {}, a folder this session may run {} from; name a \
666                     folder outside it, or make the entry `access: read`",
667                    root.dir.display(),
668                    root.because.display()
669                )));
670            }
671            if root.dir.starts_with(&host_path.path) {
672                if mechanism == HostPathMechanism::Landlock {
673                    return Err(refuse(format!(
674                        "is read-write over {}, a folder this session may run {} from, so the \
675                         capsule could rewrite what it runs; at scoped nothing prevents that. \
676                         Name a folder that does not contain it, make the entry `access: read`, \
677                         or declare containment: sealed, which binds it read-only",
678                        root.dir.display(),
679                        root.because.display()
680                    )));
681                }
682                if !protected.contains(&root.dir) {
683                    protected.push(root.dir.clone());
684                }
685            }
686        }
687    }
688    Ok(protected)
689}
690
691/// [`check_execute_roots`] for `policy`'s own host paths against the execute roots its grants
692/// resolve to on this host. `stage_session` calls it on the applied tier, and
693/// `mur run --explain-scope` on the tier its report names, so both refuse the same entry.
694pub fn check_policy_execute_roots(
695    policy: &crate::types::CapabilityPolicy,
696    mechanism: HostPathMechanism,
697) -> Result<(), HostPathRefusal> {
698    if mechanism == HostPathMechanism::None
699        || !policy
700            .host_paths
701            .iter()
702            .any(|host_path| host_path.access == HostPathAccess::ReadWrite)
703    {
704        return Ok(());
705    }
706    check_execute_roots(
707        &policy.host_paths,
708        &crate::sandbox::host_path_execute_roots(policy),
709        mechanism,
710    )
711    .map(|_| ())
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717
718    fn entry(path: &str, access: &str) -> HostPathEntry {
719        HostPathEntry {
720            path: Some(path.to_string()),
721            access: Some(access.to_string()),
722        }
723    }
724
725    fn refusal(entries: &[HostPathEntry]) -> HostPathRefusal {
726        declare_host_paths(entries, Some(Path::new("/home/op"))).unwrap_err()
727    }
728
729    /// A scratch directory outside `/tmp`, which a host path may not be under: the workspace's
730    /// `target/` when there is one, else the crate directory.
731    fn scratch() -> tempfile::TempDir {
732        let target = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target");
733        let base = if target.is_dir() {
734            target
735        } else {
736            PathBuf::from(env!("CARGO_MANIFEST_DIR"))
737        };
738        tempfile::tempdir_in(base).unwrap()
739    }
740
741    #[test]
742    fn host_paths_two_entries_parse_and_tilde_expands_against_home() {
743        let declared = declare_host_paths(
744            &[entry("~", "read"), entry("~/x/./y/../z", "read-write")],
745            Some(Path::new("/home/op")),
746        )
747        .unwrap();
748        assert_eq!(declared[0].expanded, PathBuf::from("/home/op"));
749        assert_eq!(declared[0].access, HostPathAccess::Read);
750        assert_eq!(declared[1].expanded, PathBuf::from("/home/op/x/z"));
751        assert_eq!(declared[1].access, HostPathAccess::ReadWrite);
752        assert_eq!(declared[1].declared, "~/x/./y/../z");
753        assert_eq!(declared[1].index, 1);
754    }
755
756    #[test]
757    fn host_paths_shape_refusals_name_the_index_and_the_path_as_written() {
758        let cases: Vec<(Vec<HostPathEntry>, &str, &str)> = vec![
759            (
760                vec![entry("relative/dir", "read")],
761                "host_paths[0] (relative/dir)",
762                "is relative",
763            ),
764            (
765                vec![entry("~user/x", "read")],
766                "host_paths[0] (~user/x)",
767                "~user is not expanded",
768            ),
769            (
770                vec![entry("$HOME/x", "read")],
771                "host_paths[0] ($HOME/x)",
772                "is relative",
773            ),
774            (vec![entry("/", "read")], "host_paths[0] (/)", "names /"),
775            (
776                vec![entry("/home/..", "read")],
777                "host_paths[0] (/home/..)",
778                "names /",
779            ),
780            (
781                vec![HostPathEntry {
782                    path: Some("/srv".to_string()),
783                    access: None,
784                }],
785                "host_paths[0] (/srv)",
786                "access is required",
787            ),
788            (
789                vec![entry("/srv", "write")],
790                "host_paths[0] (/srv)",
791                "access 'write'",
792            ),
793            (
794                vec![entry("/srv", "read"), entry("/srv/", "read-write")],
795                "host_paths[1] (/srv/)",
796                "same directory as host_paths[0]",
797            ),
798            (
799                vec![HostPathEntry {
800                    path: None,
801                    access: Some("read".to_string()),
802                }],
803                "host_paths[0] (<none>)",
804                "no path",
805            ),
806        ];
807        for (entries, head, reason) in cases {
808            let text = refusal(&entries).to_string();
809            assert!(text.starts_with(head), "{text}");
810            assert!(text.contains(reason), "{text}");
811        }
812    }
813
814    #[test]
815    fn host_paths_tilde_needs_an_absolute_home() {
816        let error = declare_host_paths(&[entry("~/x", "read")], None).unwrap_err();
817        assert!(error.reason.contains("HOME"), "{error}");
818    }
819
820    fn resolve_one(
821        path: &Path,
822        access: HostPathAccess,
823        murmur_home: Option<&Path>,
824        workdir: &Path,
825    ) -> Result<Vec<HostPath>, HostPathRefusal> {
826        let declared = vec![DeclaredHostPath {
827            index: 0,
828            declared: path.display().to_string(),
829            expanded: path.to_path_buf(),
830            access,
831        }];
832        resolve_host_paths(
833            &declared,
834            &HostPathContext {
835                home: None,
836                murmur_home,
837                workdir,
838            },
839        )
840    }
841
842    #[test]
843    fn host_paths_launch_refusals_name_the_entry_its_resolution_and_the_reason() {
844        let dir = scratch();
845        let root = dir.path().canonicalize().unwrap();
846        let workdir = root.join("project/workdir");
847        std::fs::create_dir_all(workdir.join("inner")).unwrap();
848        let home = root.join("home");
849        let murmur_home = home.join(".murmur");
850        std::fs::create_dir_all(murmur_home.join("records")).unwrap();
851        let file = root.join("file.txt");
852        std::fs::write(&file, "x").unwrap();
853        let to_root = root.join("to-root");
854        std::os::unix::fs::symlink("/", &to_root).unwrap();
855        let tmp_dir = tempfile::tempdir_in("/tmp").unwrap();
856
857        let read = HostPathAccess::Read;
858        let read_write = HostPathAccess::ReadWrite;
859        let cases: Vec<(PathBuf, HostPathAccess, &str)> = vec![
860            (root.join("missing"), read, "does not exist"),
861            (to_root.clone(), read, "resolves to /"),
862            (file.clone(), read, "is not a directory"),
863            (PathBuf::from("/usr/share"), read, "lies under /usr"),
864            (PathBuf::from("/etc"), read, "lies under /etc"),
865            (PathBuf::from("/proc"), read, "lies under /proc"),
866            (tmp_dir.path().to_path_buf(), read, "/tmp"),
867            (murmur_home.clone(), read_write, "murmur's own home"),
868            (murmur_home.join("records"), read_write, "murmur's own home"),
869            (home.clone(), read_write, "murmur's own home"),
870            (workdir.join("inner"), read, "inside the work folder"),
871            (workdir.clone(), read, "inside the work folder"),
872        ];
873        for (path, access, reason) in cases {
874            let error = resolve_one(&path, access, Some(&murmur_home), &workdir)
875                .expect_err(&format!("{} should be refused", path.display()));
876            let text = error.to_string();
877            assert!(
878                text.starts_with(&format!("host_paths[0] ({}", path.display())),
879                "{text}"
880            );
881            assert!(text.contains(reason), "{reason}: {text}");
882        }
883        // A symlink names its resolution as well as what was written.
884        let error = resolve_one(&to_root, read, None, &workdir).unwrap_err();
885        assert_eq!(error.resolved.as_deref(), Some(Path::new("/")));
886        assert!(error.to_string().contains("resolved to /"), "{error}");
887    }
888
889    #[test]
890    fn host_paths_murmur_home_read_and_a_symlinked_entry_resolve() {
891        let dir = scratch();
892        let root = dir.path().canonicalize().unwrap();
893        let murmur_home = root.join("home/.murmur");
894        std::fs::create_dir_all(&murmur_home).unwrap();
895        let target = root.join("real");
896        std::fs::create_dir(&target).unwrap();
897        let link = root.join("link");
898        std::os::unix::fs::symlink(&target, &link).unwrap();
899        let workdir = root.join("project/workdir");
900
901        std::fs::create_dir_all(murmur_home.join("records")).unwrap();
902        let resolved = resolve_one(
903            &murmur_home.join("records"),
904            HostPathAccess::Read,
905            Some(&murmur_home),
906            &workdir,
907        )
908        .unwrap();
909        assert_eq!(resolved[0].path, murmur_home.join("records"));
910
911        let resolved = resolve_one(&link, HostPathAccess::ReadWrite, None, &workdir).unwrap();
912        assert_eq!(resolved[0].path, target);
913        assert_eq!(resolved[0].declared, link.display().to_string());
914    }
915
916    /// The record-seal key's folder, and every folder containing it, is refused at any access;
917    /// a sibling under murmur's home at `read` is not.
918    #[test]
919    fn host_paths_an_entry_reaching_the_record_seal_key_is_refused_at_any_access() {
920        let dir = scratch();
921        let root = dir.path().canonicalize().unwrap();
922        let home = root.join("home");
923        let murmur_home = home.join(".murmur");
924        std::fs::create_dir_all(murmur_home.join("keys")).unwrap();
925        std::fs::create_dir_all(murmur_home.join("records")).unwrap();
926        let workdir = root.join("project/workdir");
927        for (path, access) in [
928            (murmur_home.clone(), HostPathAccess::Read),
929            (murmur_home.join("keys"), HostPathAccess::Read),
930            (home.clone(), HostPathAccess::Read),
931        ] {
932            let error = resolve_one(&path, access, Some(&murmur_home), &workdir)
933                .expect_err(&format!("{} should be refused", path.display()));
934            let text = error.to_string();
935            assert!(text.contains("record-seal key"), "{text}");
936            assert!(
937                text.contains(&murmur_home.join("keys").display().to_string()),
938                "{text}"
939            );
940        }
941        // `read-write` beneath murmur's home is refused by the rule that came first.
942        assert!(resolve_one(
943            &murmur_home.join("keys"),
944            HostPathAccess::ReadWrite,
945            Some(&murmur_home),
946            &workdir
947        )
948        .is_err());
949        let resolved = resolve_one(
950            &murmur_home.join("records"),
951            HostPathAccess::Read,
952            Some(&murmur_home),
953            &workdir,
954        )
955        .unwrap();
956        assert_eq!(resolved[0].path, murmur_home.join("records"));
957    }
958
959    #[test]
960    fn host_paths_two_entries_resolving_to_one_directory_are_refused() {
961        let dir = scratch();
962        let root = dir.path().canonicalize().unwrap();
963        let target = root.join("real");
964        std::fs::create_dir(&target).unwrap();
965        let link = root.join("link");
966        std::os::unix::fs::symlink(&target, &link).unwrap();
967        let declared = declare_host_paths(
968            &[
969                entry(&target.display().to_string(), "read"),
970                entry(&link.display().to_string(), "read"),
971            ],
972            None,
973        )
974        .unwrap();
975        let error = resolve_host_paths(
976            &declared,
977            &HostPathContext {
978                home: None,
979                murmur_home: None,
980                workdir: &root.join("workdir"),
981            },
982        )
983        .unwrap_err();
984        assert_eq!(error.index, 1);
985        assert!(error.reason.contains("host_paths[0]"), "{error}");
986    }
987
988    #[test]
989    fn host_paths_credential_locations_warn_when_is_under_or_contains() {
990        let dir = scratch();
991        let home = dir.path().canonicalize().unwrap();
992        std::fs::create_dir_all(home.join(".ssh/keys")).unwrap();
993        std::fs::create_dir_all(home.join(".cargo")).unwrap();
994        std::fs::write(home.join(".cargo/credentials.toml"), "").unwrap();
995        std::fs::create_dir_all(home.join("plain")).unwrap();
996
997        // is
998        assert_eq!(credential_locations(&home.join(".ssh"), &home), ["~/.ssh"]);
999        // lies under
1000        assert_eq!(
1001            credential_locations(&home.join(".ssh/keys"), &home),
1002            ["~/.ssh"]
1003        );
1004        // contains
1005        assert_eq!(
1006            credential_locations(&home.join(".cargo"), &home),
1007            ["~/.cargo/credentials.toml"]
1008        );
1009        assert_eq!(
1010            credential_locations(&home, &home),
1011            ["~/.ssh", "~/.cargo/credentials.toml"]
1012        );
1013        // none
1014        assert!(credential_locations(&home.join("plain"), &home).is_empty());
1015        // A location absent from this host is never named: no `.aws` here.
1016        assert!(!credential_locations(&home, &home).contains(&"~/.aws".to_string()));
1017
1018        let host_path = HostPath {
1019            declared: "~/.ssh".to_string(),
1020            path: home.join(".ssh"),
1021            access: HostPathAccess::Read,
1022            credential_locations: vec!["~/.ssh".to_string()],
1023        };
1024        let warning = credential_warning(&host_path).unwrap();
1025        assert!(warning.starts_with("warning[W-SEC-035]: host_paths entry ~/.ssh (read)"));
1026        assert!(warning.contains("diagnostics/#w-sec-035"), "{warning}");
1027        let silent = HostPath {
1028            credential_locations: Vec::new(),
1029            ..host_path
1030        };
1031        assert!(credential_warning(&silent).is_none());
1032    }
1033
1034    #[test]
1035    fn host_paths_credential_backstops_are_credential_patterns() {
1036        for location in CREDENTIAL_LOCATIONS {
1037            for pattern in location.backstop {
1038                assert!(
1039                    crate::shell::CREDENTIAL_ENV_PATTERNS.contains(pattern),
1040                    "{pattern} for ~/{} is not a credential backstop pattern",
1041                    location.path
1042                );
1043            }
1044        }
1045    }
1046
1047    fn host_path(path: &str, access: HostPathAccess) -> HostPath {
1048        HostPath {
1049            declared: path.to_string(),
1050            path: PathBuf::from(path),
1051            access,
1052            credential_locations: Vec::new(),
1053        }
1054    }
1055
1056    fn root(dir: &str, because: &str) -> ExecuteRoot {
1057        ExecuteRoot {
1058            dir: PathBuf::from(dir),
1059            because: PathBuf::from(because),
1060        }
1061    }
1062
1063    #[test]
1064    fn host_paths_execute_root_rules_per_mechanism() {
1065        let roots = [root("/opt/tools/bin", "/opt/tools/bin/tool")];
1066        let rw = HostPathAccess::ReadWrite;
1067        // Containing an execute root: refused on landlock, protected on bind.
1068        let tools = [host_path("/opt/tools", rw)];
1069        let error = check_execute_roots(&tools, &roots, HostPathMechanism::Landlock).unwrap_err();
1070        assert!(error.to_string().contains("/opt/tools/bin/tool"), "{error}");
1071        assert_eq!(
1072            check_execute_roots(&tools, &roots, HostPathMechanism::Bind).unwrap(),
1073            [PathBuf::from("/opt/tools/bin")]
1074        );
1075        // Is or lies under an execute root: refused on both.
1076        for path in ["/opt/tools/bin", "/opt/tools/bin/sub"] {
1077            let entry = [host_path(path, rw)];
1078            for mechanism in [HostPathMechanism::Landlock, HostPathMechanism::Bind] {
1079                let error = check_execute_roots(&entry, &roots, mechanism).unwrap_err();
1080                assert!(error.reason.contains("/opt/tools/bin"), "{error}");
1081            }
1082        }
1083        // Read entries and weak tiers are never checked.
1084        let read = [host_path("/opt/tools/bin", HostPathAccess::Read)];
1085        assert!(
1086            check_execute_roots(&read, &roots, HostPathMechanism::Landlock)
1087                .unwrap()
1088                .is_empty()
1089        );
1090        assert!(check_execute_roots(&tools, &roots, HostPathMechanism::None)
1091            .unwrap()
1092            .is_empty());
1093        // A disjoint entry is untouched.
1094        let other = [host_path("/srv/data", rw)];
1095        assert!(
1096            check_execute_roots(&other, &roots, HostPathMechanism::Landlock)
1097                .unwrap()
1098                .is_empty()
1099        );
1100    }
1101
1102    #[test]
1103    fn host_paths_reports_carry_every_key_in_config_order() {
1104        let paths = [
1105            HostPath {
1106                declared: "~/.cargo".to_string(),
1107                path: PathBuf::from("/home/op/.cargo"),
1108                access: HostPathAccess::ReadWrite,
1109                credential_locations: vec!["~/.cargo/credentials.toml".to_string()],
1110            },
1111            host_path("/srv/data", HostPathAccess::Read),
1112        ];
1113        let reports = host_path_reports(&paths, HostPathMechanism::Landlock);
1114        let json = serde_json::to_value(&reports).unwrap();
1115        assert_eq!(
1116            json,
1117            serde_json::json!([
1118                {
1119                    "path": "/home/op/.cargo",
1120                    "declared": "~/.cargo",
1121                    "access": "read-write",
1122                    "mechanism": "landlock",
1123                    "credential_location": "~/.cargo/credentials.toml"
1124                },
1125                {
1126                    "path": "/srv/data",
1127                    "declared": "/srv/data",
1128                    "access": "read",
1129                    "mechanism": "landlock",
1130                    "credential_location": null
1131                }
1132            ])
1133        );
1134        assert_eq!(
1135            reports[0].render(),
1136            "~/.cargo → /home/op/.cargo (read-write), reaches credential location \
1137             ~/.cargo/credentials.toml"
1138        );
1139        for (tier, mechanism) in [
1140            ("mountns+pivot_root+landlock+seccomp", "bind"),
1141            ("landlock+seccomp", "landlock"),
1142            ("seccomp-only", "none"),
1143            ("none", "none"),
1144        ] {
1145            assert_eq!(
1146                HostPathMechanism::for_enforcement_tier(tier).wire_name(),
1147                mechanism
1148            );
1149        }
1150    }
1151}