Skip to main content

capsule_runtime/
harness_needs.rs

1//! What a `transport: process` harness needs beyond what the capsule's class gives every command,
2//! and where the capsule manifest allows it.
3//!
4//! A process driver's `describe().harness-needs` names the host directories and hosts the harness
5//! reaches on its own — Claude Code's config folder, its inference host — the environment half
6//! being `required-env`. Each need is granted only where the capsule manifest also allows it:
7//! a path by `capabilities.harness.paths`, a host by `capabilities.network.allow`. A need the
8//! manifest does not allow refuses the launch with [`E_CAP_021`](crate::errors); nothing is
9//! spawned.
10//!
11//! What is granted is the driver's path at the driver's access, never the wider manifest entry,
12//! and a granted path goes through the same host-folder checks as an operator `host_paths` entry
13//! ([`crate::host_paths::resolve_host_paths`]) and never carries `Execute`. The resolution is
14//! computed once at staging, set on the policy's report, and recomputed identically by
15//! `mur run --explain-scope` (read-only, exit 0 even when a need is refused).
16
17use std::path::{Path, PathBuf};
18
19use serde::Serialize;
20
21use crate::host_paths::{self, DeclaredHostPath, HostPath, HostPathAccess, HostPathContext};
22use crate::process_driver::{Description, HarnessNeeds, PathAccess};
23use crate::types::CapabilityPolicy;
24
25/// The resolved harness grant for one session: the report `--explain-scope` and
26/// `session_start.effective_grants` carry, the host paths to add to the harness's enforcement,
27/// and the refusal (`E-CAP-021`) when any need the manifest does not allow, or a granted path
28/// resolving to a refused location, was found.
29///
30/// The report is always built, with every need's status, so `--explain-scope` can print it and
31/// exit 0 even when `refusal` is set. Staging and `mur doctor` propagate `refusal`; explain-scope
32/// ignores it.
33pub struct HarnessGrants {
34    pub report: HarnessReport,
35    /// The host paths to add to the harness's enforcement: one per path need that resolved to a
36    /// real directory on this host (`status: granted`). A missing or refused need contributes
37    /// none.
38    pub granted_paths: Vec<HostPath>,
39    /// `Some` when a path or host need the manifest does not allow, or a granted path resolving to
40    /// a refused location, was found: the `E-CAP-021` refusal naming every one.
41    pub refusal: Option<crate::errors::RuntimeError>,
42}
43
44/// The `harness` key of a [`crate::containment::ScopeReport`]: the driver, the harness binary and
45/// its interpreter, and each path, host and env need with its status. `null` on a capsule whose
46/// transport is not `process`.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
48pub struct HarnessReport {
49    /// The process driver, `name@version`.
50    pub driver: String,
51    /// The resolved harness binary and its `#!` interpreter, or `null` when the binary did not
52    /// resolve (explain-scope on a host missing the harness).
53    pub binary: Option<HarnessBinaryReport>,
54    /// One row per declared path need, in declaration order.
55    pub paths: Vec<HarnessPathReport>,
56    /// One row per declared host need, in declaration order.
57    pub hosts: Vec<HarnessHostReport>,
58    /// One row per `required-env` variable, in declaration order.
59    pub env: Vec<HarnessEnvReport>,
60    /// Why the driver could not be read, for `--explain-scope` on a missing or unloadable driver;
61    /// `null` otherwise, when every list above was resolved from a loaded driver.
62    pub unresolved: Option<String>,
63}
64
65/// The resolved harness binary and, for a `#!` script, the interpreter its first line names.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
67pub struct HarnessBinaryReport {
68    pub path: String,
69    /// The interpreter a `#!` line names, resolved on the harness's `PATH`; `null` for an ELF
70    /// binary or a script whose interpreter did not resolve.
71    pub interpreter: Option<String>,
72}
73
74/// One path need's row: what the driver asked for, where it resolved, and whether it was granted.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
76pub struct HarnessPathReport {
77    /// The need as the driver declared it (`~/…` or absolute).
78    pub need: String,
79    /// The directory it resolved to on this host, or `null` when missing or not allowed.
80    pub path: Option<String>,
81    /// The access the driver asked for.
82    pub access: &'static str,
83    /// The `capabilities.harness.paths` entry that allows it, as written, or `null` when none
84    /// does.
85    pub allowed_by: Option<String>,
86    /// `granted` (allowed and present on this host), `missing` (allowed but absent) or `refused`
87    /// (not allowed, or resolving to a refused location).
88    pub status: &'static str,
89    /// Why, for a `refused` status; `null` otherwise.
90    pub reason: Option<String>,
91}
92
93/// One host need's row.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
95pub struct HarnessHostReport {
96    /// The need as the driver declared it, spelled as a `capabilities.network.allow` entry.
97    pub need: String,
98    /// The `capabilities.network.allow` entry that admits it, or `null` when none does.
99    pub allowed_by: Option<String>,
100    /// `allowed` or `refused`.
101    pub status: &'static str,
102}
103
104/// One `required-env` variable's row.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
106pub struct HarnessEnvReport {
107    pub name: String,
108    /// `allowed` when `capabilities.env.allow` declares it, `refused` otherwise.
109    pub status: &'static str,
110}
111
112const STATUS_GRANTED: &str = "granted";
113const STATUS_MISSING: &str = "missing";
114const STATUS_REFUSED: &str = "refused";
115const STATUS_ALLOWED: &str = "allowed";
116
117/// The resolved harness binary, for the report and the enforcement derivation.
118#[derive(Debug, Clone)]
119pub struct HarnessBinaryInfo {
120    pub path: PathBuf,
121    /// The interpreter a `#!` first line names; `None` for an ELF binary.
122    pub interpreter: Option<PathBuf>,
123    /// For `#!/usr/bin/env <name>`, the `env` the line names (`<name>` being the interpreter);
124    /// `None` otherwise.
125    pub launcher: Option<PathBuf>,
126}
127
128impl HarnessBinaryInfo {
129    /// `binary` and the interpreter its `#!` line names, resolved on the harness's `PATH`.
130    #[must_use]
131    pub fn read(binary: &Path) -> Self {
132        let shebang = read_shebang(binary, &harness_path_env());
133        let (launcher, interpreter) = match shebang {
134            Some(shebang) => (shebang.launcher, Some(shebang.interpreter)),
135            None => (None, None),
136        };
137        Self {
138            path: binary.to_path_buf(),
139            interpreter,
140            launcher,
141        }
142    }
143
144    /// Every file the harness's `exec` runs: the binary, then for a `#!` script the `env` launcher
145    /// the line names, if any, and the interpreter. Each is granted exactly as a
146    /// `capabilities.shell.allow` binary.
147    #[must_use]
148    pub fn exec_files(&self) -> Vec<PathBuf> {
149        std::iter::once(self.path.clone())
150            .chain(self.launcher.clone())
151            .chain(self.interpreter.clone())
152            .collect()
153    }
154}
155
156/// The `PATH` a harness's `#!` interpreter is resolved on: the runtime's own. The harness is
157/// spawned with the operator's environment on an unconfined session and an `env.allow`-derived
158/// one otherwise, both of which carry the host `PATH` when the manifest declares it.
159pub(crate) fn harness_path_env() -> String {
160    std::env::var("PATH").unwrap_or_else(|_| "/usr/bin:/bin".to_string())
161}
162
163/// A capsule's harness grants and the binary they were resolved for: the one call staging,
164/// `mur doctor` and `mur run --explain-scope` make.
165pub(crate) struct ResolvedHarness {
166    pub(crate) grants: HarnessGrants,
167    /// `None` when the harness binary did not resolve on this host.
168    pub(crate) binary: Option<HarnessBinaryInfo>,
169}
170
171/// Resolves a capsule's harness grants from its driver's `description` and its manifest's
172/// `policy`, checking each allowed path on exactly an operator `host_paths` entry's terms: the
173/// `HOME` `mur` runs with, murmur's own home `$HOME/.murmur`, and the session's work folder root
174/// `workdir`. `binary` is the resolved harness binary, or `None` when it did not resolve.
175pub(crate) fn resolve_capsule_harness(
176    driver_ref: &str,
177    description: &Description,
178    binary: Option<&Path>,
179    policy: &CapabilityPolicy,
180    workdir: &Path,
181) -> ResolvedHarness {
182    let binary = binary.map(HarnessBinaryInfo::read);
183    let home = crate::murmur_home::home_path().ok();
184    let murmur_home = home.as_ref().map(|home| home.join(".murmur"));
185    let context = HostPathContext {
186        home: home.as_deref(),
187        murmur_home: murmur_home.as_deref(),
188        workdir,
189    };
190    let grants = resolve_harness_grants(
191        driver_ref,
192        &description.harness_needs,
193        &policy.harness_paths,
194        &policy.network_allow,
195        &description.required_env,
196        &policy.env_allow,
197        binary.as_ref(),
198        &context,
199    );
200    ResolvedHarness { grants, binary }
201}
202
203/// Validates the shape of every declared harness need, as a driver load-time check
204/// ([`crate::process_driver::validate_description`], `E-RUN-032`). Returns the offending need in
205/// the message, or `Ok` for well-formed and empty needs.
206pub(crate) fn validate_harness_needs(needs: &HarnessNeeds) -> Result<(), String> {
207    for need in &needs.paths {
208        validate_need_path(&need.path)?;
209    }
210    for host in &needs.hosts {
211        crate::network_policy::NetworkAllowRule::parse(host).map_err(|error| {
212            format!("names the host need {host:?}, which is not a capabilities.network.allow entry: {error}")
213        })?;
214    }
215    Ok(())
216}
217
218/// A path need is non-empty and has the shape a `capabilities.harness.paths` entry must have
219/// ([`murmur_artifact::harness_path_shape_error`]), so a need and the entry that allows it are
220/// held to one rule.
221fn validate_need_path(path: &str) -> Result<(), String> {
222    if path.is_empty() {
223        return Err(format!("names the path need {path:?}, which is empty"));
224    }
225    match murmur_artifact::harness_path_shape_error(path) {
226        Some(reason) => Err(format!("names the path need {path:?}, which {reason}")),
227        None => Ok(()),
228    }
229}
230
231/// Resolves a session's harness grants: the allow rule against the manifest, then the host-folder
232/// checks for each allowed path, collecting every disallowed or refused need into one
233/// `E-CAP-021` refusal.
234///
235/// `driver_ref` is `name@version`. `manifest_paths` is `capabilities.harness.paths`. `binary` is
236/// the resolved harness binary, or `None` when it did not resolve (explain-scope on a host
237/// missing it). `context` is the same `HostPathContext` an operator `host_paths` entry is checked
238/// against.
239#[allow(clippy::too_many_arguments)]
240pub fn resolve_harness_grants(
241    driver_ref: &str,
242    needs: &HarnessNeeds,
243    manifest_paths: &[murmur_artifact::HarnessPathEntry],
244    network_allow: &[String],
245    required_env: &[String],
246    env_allow: &[String],
247    binary: Option<&HarnessBinaryInfo>,
248    context: &HostPathContext<'_>,
249) -> HarnessGrants {
250    let mut refusals: Vec<String> = Vec::new();
251    let mut granted_paths: Vec<HostPath> = Vec::new();
252
253    // Both the needs and the manifest entries folded lexically against the same home, so the
254    // ancestor comparison is purely lexical and `--explain-scope` answers it without the host.
255    let home = context.home;
256    let manifest = fold_manifest_paths(manifest_paths, home);
257
258    let mut path_reports = Vec::with_capacity(needs.paths.len());
259    for need in &needs.paths {
260        let access = host_path_access(need.access);
261        let folded = match host_paths::expand_and_fold(&need.path, home) {
262            Ok(folded) => folded,
263            // Shape is validated at load, so this is only reachable off a host with no home for a
264            // `~/` need; report it refused rather than panicking.
265            Err(reason) => {
266                refusals.push(format!("{} ({}): {reason}", need.path, access.wire_name()));
267                path_reports.push(HarnessPathReport {
268                    need: need.path.clone(),
269                    path: None,
270                    access: access.wire_name(),
271                    allowed_by: None,
272                    status: STATUS_REFUSED,
273                    reason: Some(reason),
274                });
275                continue;
276            }
277        };
278        match allow_path(&manifest, &folded, access) {
279            PathAllow::Allowed {
280                allowed_by,
281                entry_path,
282            } => {
283                // Granted at the driver's own path and access, never the wider manifest entry.
284                let declared = DeclaredHostPath {
285                    index: 0,
286                    declared: need.path.clone(),
287                    expanded: folded,
288                    access,
289                };
290                let resolved =
291                    host_paths::resolve_host_paths(std::slice::from_ref(&declared), context)
292                        .and_then(|resolved| {
293                            let host_path = resolved.into_iter().next().expect("one in, one out");
294                            escapes_allowing_entry(&host_path, &entry_path, &allowed_by)
295                                .map_or(Ok(host_path), Err)
296                        });
297                match resolved {
298                    Ok(host_path) => {
299                        path_reports.push(HarnessPathReport {
300                            need: need.path.clone(),
301                            path: Some(host_path.path.display().to_string()),
302                            access: access.wire_name(),
303                            allowed_by: Some(allowed_by),
304                            status: STATUS_GRANTED,
305                            reason: None,
306                        });
307                        granted_paths.push(host_path);
308                    }
309                    Err(refusal) if refusal.missing => {
310                        // Allowed but absent on this host: reported, not granted, never refuses.
311                        path_reports.push(HarnessPathReport {
312                            need: need.path.clone(),
313                            path: None,
314                            access: access.wire_name(),
315                            allowed_by: Some(allowed_by),
316                            status: STATUS_MISSING,
317                            reason: None,
318                        });
319                    }
320                    Err(refusal) => {
321                        let reason = refusal.reason.clone();
322                        refusals.push(format!("{} ({}): {reason}", need.path, access.wire_name()));
323                        path_reports.push(HarnessPathReport {
324                            need: need.path.clone(),
325                            path: refusal.resolved.map(|p| p.display().to_string()),
326                            access: access.wire_name(),
327                            allowed_by: Some(allowed_by),
328                            status: STATUS_REFUSED,
329                            reason: Some(reason),
330                        });
331                    }
332                }
333            }
334            PathAllow::NotAllowed { reason } => {
335                refusals.push(format!("{} ({}): {reason}", need.path, access.wire_name()));
336                path_reports.push(HarnessPathReport {
337                    need: need.path.clone(),
338                    path: None,
339                    access: access.wire_name(),
340                    allowed_by: None,
341                    status: STATUS_REFUSED,
342                    reason: Some(reason),
343                });
344            }
345        }
346    }
347
348    let network_rules =
349        crate::network_policy::parse_network_allow_rules(network_allow).unwrap_or_default();
350    let mut host_reports = Vec::with_capacity(needs.hosts.len());
351    for host in &needs.hosts {
352        match allow_host(host, network_allow, &network_rules) {
353            Some(allowed_by) => host_reports.push(HarnessHostReport {
354                need: host.clone(),
355                allowed_by: Some(allowed_by),
356                status: STATUS_ALLOWED,
357            }),
358            None => {
359                refusals.push(format!(
360                    "{host}: no capabilities.network.allow entry admits it"
361                ));
362                host_reports.push(HarnessHostReport {
363                    need: host.clone(),
364                    allowed_by: None,
365                    status: STATUS_REFUSED,
366                });
367            }
368        }
369    }
370
371    let env = required_env
372        .iter()
373        .map(|name| HarnessEnvReport {
374            name: name.clone(),
375            status: if env_allow.iter().any(|allowed| allowed == name) {
376                STATUS_ALLOWED
377            } else {
378                STATUS_REFUSED
379            },
380        })
381        .collect();
382
383    let report = HarnessReport {
384        driver: driver_ref.to_string(),
385        binary: binary.map(|info| HarnessBinaryReport {
386            path: info.path.display().to_string(),
387            interpreter: info.interpreter.as_ref().map(|p| p.display().to_string()),
388        }),
389        paths: path_reports,
390        hosts: host_reports,
391        env,
392        unresolved: None,
393    };
394
395    let refusal =
396        (!refusals.is_empty()).then(|| crate::errors::RuntimeError::HarnessNeedsNotAllowed {
397            driver: driver_ref.to_string(),
398            refusals,
399        });
400
401    HarnessGrants {
402        report,
403        granted_paths,
404        refusal,
405    }
406}
407
408/// The `harness` report for `--explain-scope` when the driver could not be read: every list empty,
409/// `unresolved` naming why.
410#[must_use]
411pub fn unresolved_report(driver_ref: &str, reason: String) -> HarnessReport {
412    HarnessReport {
413        driver: driver_ref.to_string(),
414        binary: None,
415        paths: Vec::new(),
416        hosts: Vec::new(),
417        env: Vec::new(),
418        unresolved: Some(reason),
419    }
420}
421
422/// The `Process harness (<driver>)` block `mur run --explain-scope` prints: the binary and its
423/// interpreter, then each path need (`need → path (access), allowed by <entry>` for a granted one,
424/// or its status and reason), each host need and each required env variable. Starts with a header
425/// and ends with a newline.
426#[must_use]
427pub fn render_harness_block(report: &HarnessReport) -> String {
428    let mut out = format!("Process harness ({})\n", report.driver);
429    if let Some(reason) = &report.unresolved {
430        out.push_str(&format!("  driver not read: {reason}\n"));
431        return out;
432    }
433    match &report.binary {
434        Some(binary) => {
435            out.push_str(&format!("  binary     {}\n", binary.path));
436            if let Some(interpreter) = &binary.interpreter {
437                out.push_str(&format!("  interpreter {interpreter}\n"));
438            }
439        }
440        None => out.push_str("  binary     <unresolved>\n"),
441    }
442    for path in &report.paths {
443        let line = match path.status {
444            STATUS_GRANTED => format!(
445                "  path   {} → {} ({}), allowed by {}",
446                path.need,
447                path.path.as_deref().unwrap_or("?"),
448                path.access,
449                path.allowed_by.as_deref().unwrap_or("?"),
450            ),
451            STATUS_MISSING => format!(
452                "  path   {} ({}): missing on this host, allowed by {}",
453                path.need,
454                path.access,
455                path.allowed_by.as_deref().unwrap_or("?"),
456            ),
457            _ => format!(
458                "  path   {} ({}): refused — {}",
459                path.need,
460                path.access,
461                path.reason.as_deref().unwrap_or(""),
462            ),
463        };
464        out.push_str(&line);
465        out.push('\n');
466    }
467    for host in &report.hosts {
468        let line = match host.status {
469            STATUS_ALLOWED => format!(
470                "  host   {}, allowed by {}",
471                host.need,
472                host.allowed_by.as_deref().unwrap_or("?"),
473            ),
474            _ => format!("  host   {}: refused", host.need),
475        };
476        out.push_str(&line);
477        out.push('\n');
478    }
479    for env in &report.env {
480        out.push_str(&format!("  env    {} ({})\n", env.name, env.status));
481    }
482    out
483}
484
485/// A manifest `capabilities.harness.paths` entry folded lexically against `home`.
486struct FoldedManifestPath {
487    declared: String,
488    expanded: PathBuf,
489    access: HostPathAccess,
490}
491
492fn fold_manifest_paths(
493    entries: &[murmur_artifact::HarnessPathEntry],
494    home: Option<&Path>,
495) -> Vec<FoldedManifestPath> {
496    entries
497        .iter()
498        .filter_map(|entry| {
499            let expanded = host_paths::expand_and_fold(&entry.path, home).ok()?;
500            Some(FoldedManifestPath {
501                declared: entry.path.clone(),
502                expanded,
503                access: manifest_access(entry.access),
504            })
505        })
506        .collect()
507}
508
509enum PathAllow {
510    /// `allowed_by` is the entry as written; `entry_path` is it folded against the home.
511    Allowed {
512        allowed_by: String,
513        entry_path: PathBuf,
514    },
515    NotAllowed {
516        reason: String,
517    },
518}
519
520/// The refusal for a granted path that resolved, through a symlink on its way, outside the entry
521/// that allowed it, or `None` when it lies under that entry as resolved on this host. The allow
522/// rule compares the driver's path lexically; this keeps a symlink below the entry from granting
523/// whatever it points at.
524fn escapes_allowing_entry(
525    host_path: &HostPath,
526    entry_path: &Path,
527    allowed_by: &str,
528) -> Option<host_paths::HostPathRefusal> {
529    let refuse = |reason: String| host_paths::HostPathRefusal {
530        index: 0,
531        declared: host_path.declared.clone(),
532        resolved: Some(host_path.path.clone()),
533        reason,
534        missing: false,
535    };
536    match std::fs::canonicalize(entry_path) {
537        Ok(entry) if host_path.path.starts_with(&entry) => None,
538        Ok(entry) => Some(refuse(format!(
539            "resolves to {}, outside {} where the capabilities.harness.paths entry {allowed_by:?} \
540             that allows it resolves; a symlink on the way leads out of the granted folder",
541            host_path.path.display(),
542            entry.display(),
543        ))),
544        Err(error) => Some(refuse(format!(
545            "the capabilities.harness.paths entry {allowed_by:?} that allows it cannot be \
546             resolved on this host: {error}"
547        ))),
548    }
549}
550
551/// Whether some manifest entry's path equals `folded` or is an ancestor of it, with access at
552/// least the need's. The first match's declared text is `allowed_by`. An access shortfall names
553/// the entry that covered the path at too low an access.
554fn allow_path(
555    manifest: &[FoldedManifestPath],
556    folded: &Path,
557    need_access: HostPathAccess,
558) -> PathAllow {
559    let mut shortfall: Option<&FoldedManifestPath> = None;
560    for entry in manifest {
561        if folded == entry.expanded || folded.starts_with(&entry.expanded) {
562            if access_at_least(entry.access, need_access) {
563                return PathAllow::Allowed {
564                    allowed_by: entry.declared.clone(),
565                    entry_path: entry.expanded.clone(),
566                };
567            }
568            shortfall.get_or_insert(entry);
569        }
570    }
571    let reason = match shortfall {
572        Some(entry) => format!(
573            "capabilities.harness.paths entry {:?} covers it at access {}, lower than the need's \
574             {}",
575            entry.declared,
576            entry.access.wire_name(),
577            need_access.wire_name()
578        ),
579        None => "no capabilities.harness.paths entry names it or an ancestor of it".to_string(),
580    };
581    PathAllow::NotAllowed { reason }
582}
583
584/// Whether `have` is at least `want`: `read-write` ≥ `read`.
585fn access_at_least(have: HostPathAccess, want: HostPathAccess) -> bool {
586    matches!(
587        (have, want),
588        (HostPathAccess::ReadWrite, _) | (HostPathAccess::Read, HostPathAccess::Read)
589    )
590}
591
592/// Whether some `capabilities.network.allow` entry admits every host and port `need` names, under
593/// the matching the egress proxy applies (a bare host spans 80 and 443). Returns the admitting
594/// entry as written.
595fn allow_host(
596    need: &str,
597    network_allow: &[String],
598    rules: &[crate::network_policy::NetworkAllowRule],
599) -> Option<String> {
600    let need_rule = crate::network_policy::NetworkAllowRule::parse(need).ok()?;
601    // The ports the need spans, under the egress proxy's mapping.
602    let need_ports: Vec<u16> = crate::egress_proxy::implied_tcp_ports(&need_rule).collect();
603    for (rule, declared) in rules.iter().zip(network_allow.iter()) {
604        if rule.host != need_rule.host {
605            continue;
606        }
607        let covers_every_port = need_ports.iter().all(|&port| {
608            crate::egress_proxy::implied_tcp_ports(rule).any(|allowed| allowed == port)
609        });
610        if covers_every_port {
611            return Some(declared.clone());
612        }
613    }
614    None
615}
616
617fn host_path_access(access: PathAccess) -> HostPathAccess {
618    match access {
619        PathAccess::Read => HostPathAccess::Read,
620        PathAccess::ReadWrite => HostPathAccess::ReadWrite,
621    }
622}
623
624fn manifest_access(access: murmur_artifact::HarnessPathAccess) -> HostPathAccess {
625    match access {
626        murmur_artifact::HarnessPathAccess::Read => HostPathAccess::Read,
627        murmur_artifact::HarnessPathAccess::ReadWrite => HostPathAccess::ReadWrite,
628    }
629}
630
631/// What a `#!` first line makes `exec` run: `interpreter`, and for `#!/usr/bin/env <name>` the
632/// `env` the line names as `launcher` with `<name>` resolved on the PATH as `interpreter`.
633struct Shebang {
634    launcher: Option<PathBuf>,
635    interpreter: PathBuf,
636}
637
638/// The longest `#!` line the kernel reads (`BINPRM_BUF_SIZE`): the most read of a harness binary.
639const SHEBANG_MAX: u64 = 256;
640
641/// Reads the `#!` first line of `binary`, its interpreter resolved on `path_env`. `None` for an ELF
642/// binary, a script with no readable `#!` line, or an interpreter that does not resolve. Only the
643/// one interpreter the line names is followed; an `env` chain beyond it is not.
644fn read_shebang(binary: &Path, path_env: &str) -> Option<Shebang> {
645    use std::io::Read;
646    let mut head = Vec::with_capacity(SHEBANG_MAX as usize);
647    std::fs::File::open(binary)
648        .ok()?
649        .take(SHEBANG_MAX)
650        .read_to_end(&mut head)
651        .ok()?;
652    let line = head.strip_prefix(b"#!")?.split(|&b| b == b'\n').next()?;
653    let line = std::str::from_utf8(line).ok()?;
654    let mut parts = line.split_whitespace();
655    let first = parts.next()?;
656    let program = std::fs::canonicalize(first).ok()?;
657    if Path::new(first).file_name().and_then(|n| n.to_str()) == Some("env") {
658        let name = parts.next()?;
659        return Some(Shebang {
660            launcher: Some(program),
661            interpreter: resolve_on_path(name, path_env)?,
662        });
663    }
664    Some(Shebang {
665        launcher: None,
666        interpreter: program,
667    })
668}
669
670/// A bare name resolved to an executable on `path_env`, or an absolute path used as given.
671fn resolve_on_path(name: &str, path_env: &str) -> Option<PathBuf> {
672    if name.contains('/') {
673        return std::fs::canonicalize(name).ok();
674    }
675    for dir in path_env.split(':').filter(|d| !d.is_empty()) {
676        let candidate = Path::new(dir).join(name);
677        if candidate.is_file() {
678            return std::fs::canonicalize(&candidate).ok();
679        }
680    }
681    None
682}
683
684#[cfg(test)]
685mod tests {
686    use super::*;
687    use crate::process_driver::PathNeed;
688    use murmur_artifact::{HarnessPathAccess, HarnessPathEntry};
689
690    fn needs(paths: &[(&str, PathAccess)], hosts: &[&str]) -> HarnessNeeds {
691        HarnessNeeds {
692            paths: paths
693                .iter()
694                .map(|(path, access)| PathNeed {
695                    path: (*path).to_string(),
696                    access: *access,
697                })
698                .collect(),
699            hosts: hosts.iter().map(|host| (*host).to_string()).collect(),
700        }
701    }
702
703    fn grant(path: &str, access: HarnessPathAccess) -> HarnessPathEntry {
704        HarnessPathEntry {
705            path: path.to_string(),
706            access,
707        }
708    }
709
710    /// A scratch home outside `/tmp`, which a granted path may not be under: the workspace's
711    /// `target/`, with murmur's own home and a work folder inside it as a session has them.
712    struct Host {
713        _root: tempfile::TempDir,
714        home: PathBuf,
715        murmur_home: PathBuf,
716        workdir: PathBuf,
717    }
718
719    impl Host {
720        fn new() -> Self {
721            let root =
722                tempfile::tempdir_in(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target"))
723                    .unwrap();
724            let home = std::fs::canonicalize(root.path()).unwrap().join("home");
725            let murmur_home = home.join(".murmur");
726            let workdir = home.join("project");
727            std::fs::create_dir_all(&murmur_home).unwrap();
728            std::fs::create_dir_all(&workdir).unwrap();
729            Self {
730                _root: root,
731                home,
732                murmur_home,
733                workdir,
734            }
735        }
736
737        fn context(&self) -> HostPathContext<'_> {
738            HostPathContext {
739                home: Some(&self.home),
740                murmur_home: Some(&self.murmur_home),
741                workdir: &self.workdir,
742            }
743        }
744
745        fn resolve(
746            &self,
747            needs: &HarnessNeeds,
748            manifest: &[HarnessPathEntry],
749            network_allow: &[&str],
750        ) -> HarnessGrants {
751            let network_allow: Vec<String> = network_allow
752                .iter()
753                .map(|rule| (*rule).to_string())
754                .collect();
755            resolve_harness_grants(
756                "local/d@1.0.0",
757                needs,
758                manifest,
759                &network_allow,
760                &[],
761                &[],
762                None,
763                &self.context(),
764            )
765        }
766    }
767
768    fn refusal_text(grants: &HarnessGrants) -> String {
769        grants
770            .refusal
771            .as_ref()
772            .expect("the launch is refused")
773            .to_string()
774    }
775
776    #[test]
777    fn well_formed_and_empty_needs_pass_validation() {
778        assert!(validate_harness_needs(&needs(&[], &[])).is_ok());
779        assert!(validate_harness_needs(&needs(
780            &[
781                ("~/.claude", PathAccess::ReadWrite),
782                ("/opt/harness/", PathAccess::Read),
783            ],
784            &["api.anthropic.com", "example.invalid:443"],
785        ))
786        .is_ok());
787    }
788
789    /// Every malformed need is refused naming it, and the driver load turns the reason
790    /// into `E-RUN-032`.
791    #[test]
792    fn a_malformed_need_is_refused_naming_it() {
793        for (path, expected) in [
794            ("", "is empty"),
795            ("relative/dir", "is relative"),
796            ("~", "names the whole home folder"),
797            ("~/", "names the whole home folder"),
798            ("/", "names /, the whole host filesystem"),
799            ("~/a/../b", "has a . or .. segment"),
800            ("/opt/./x", "has a . or .. segment"),
801            ("/opt//x", "has an empty path segment"),
802        ] {
803            let reason = validate_harness_needs(&needs(&[(path, PathAccess::Read)], &[]))
804                .expect_err("a malformed path need is refused");
805            assert!(reason.contains(&format!("{path:?}")), "{path}: {reason}");
806            assert!(reason.contains(expected), "{path}: {reason}");
807        }
808        let reason = validate_harness_needs(&needs(&[], &["http://example.com/x"]))
809            .expect_err("a host the network.allow parser refuses is refused");
810        assert!(reason.contains("\"http://example.com/x\""), "{reason}");
811        assert!(
812            reason.contains("not a capabilities.network.allow entry"),
813            "{reason}"
814        );
815    }
816
817    #[test]
818    fn an_allowed_need_under_home_is_granted_at_its_own_path_and_access() {
819        let host = Host::new();
820        std::fs::create_dir_all(host.home.join("state/sessions")).unwrap();
821        let grants = host.resolve(
822            &needs(&[("~/state/sessions", PathAccess::ReadWrite)], &[]),
823            &[grant("~/state", HarnessPathAccess::ReadWrite)],
824            &[],
825        );
826        assert!(grants.refusal.is_none(), "{:?}", grants.refusal);
827        assert_eq!(grants.granted_paths.len(), 1);
828        assert_eq!(
829            grants.granted_paths[0].path,
830            host.home.join("state/sessions")
831        );
832        assert_eq!(grants.granted_paths[0].access, HostPathAccess::ReadWrite);
833        let row = &grants.report.paths[0];
834        assert_eq!(row.status, "granted");
835        assert_eq!(row.allowed_by.as_deref(), Some("~/state"));
836        assert_eq!(row.access, "read-write");
837    }
838
839    #[test]
840    fn an_entry_below_the_need_access_names_the_shortfall() {
841        let host = Host::new();
842        std::fs::create_dir_all(host.home.join("state")).unwrap();
843        let grants = host.resolve(
844            &needs(&[("~/state", PathAccess::ReadWrite)], &[]),
845            &[grant("~/state", HarnessPathAccess::Read)],
846            &[],
847        );
848        let message = refusal_text(&grants);
849        assert!(message.contains("'local/d@1.0.0'"), "{message}");
850        assert!(message.contains("~/state (read-write)"), "{message}");
851        assert!(
852            message.contains(
853                "entry \"~/state\" covers it at access read, lower than the need's read-write"
854            ),
855            "{message}"
856        );
857        assert!(grants.granted_paths.is_empty());
858        assert_eq!(grants.report.paths[0].status, "refused");
859    }
860
861    #[test]
862    fn every_disallowed_need_is_named_in_one_refusal() {
863        let host = Host::new();
864        let grants = host.resolve(
865            &needs(
866                &[("~/a", PathAccess::Read), ("/srv/b", PathAccess::ReadWrite)],
867                &["example.invalid:443"],
868            ),
869            &[],
870            &[],
871        );
872        let message = refusal_text(&grants);
873        for need in [
874            "~/a (read)",
875            "/srv/b (read-write)",
876            "example.invalid:443: no capabilities.network.allow",
877        ] {
878            assert!(message.contains(need), "{need}: {message}");
879        }
880        assert!(
881            !message.contains('\n'),
882            "one line, so doctor keeps every need: {message}"
883        );
884    }
885
886    #[test]
887    fn an_allowed_need_absent_on_this_host_is_missing_and_never_refuses() {
888        let host = Host::new();
889        let grants = host.resolve(
890            &needs(&[("~/absent", PathAccess::ReadWrite)], &[]),
891            &[grant("~/absent", HarnessPathAccess::ReadWrite)],
892            &[],
893        );
894        assert!(grants.refusal.is_none(), "{:?}", grants.refusal);
895        assert!(grants.granted_paths.is_empty());
896        let row = &grants.report.paths[0];
897        assert_eq!(row.status, "missing");
898        assert_eq!(row.path, None);
899        assert_eq!(row.allowed_by.as_deref(), Some("~/absent"));
900    }
901
902    /// A read-write need under the home is checked against murmur's own home `~/.murmur`, not
903    /// against the home itself.
904    #[test]
905    fn a_read_write_need_over_murmurs_own_home_is_refused_and_a_sibling_is_not() {
906        let host = Host::new();
907        std::fs::create_dir_all(host.home.join("sibling")).unwrap();
908        let grants = host.resolve(
909            &needs(
910                &[
911                    ("~/.murmur", PathAccess::ReadWrite),
912                    ("~/sibling", PathAccess::ReadWrite),
913                ],
914                &[],
915            ),
916            &[
917                grant("~/.murmur", HarnessPathAccess::ReadWrite),
918                grant("~/sibling", HarnessPathAccess::ReadWrite),
919            ],
920            &[],
921        );
922        let message = refusal_text(&grants);
923        assert!(message.contains("~/.murmur (read-write)"), "{message}");
924        assert!(
925            message.contains("read-write over murmur's own home"),
926            "{message}"
927        );
928        assert!(!message.contains("~/sibling"), "{message}");
929        assert_eq!(grants.report.paths[1].status, "granted");
930    }
931
932    /// A symlink below the allowing entry cannot carry the grant out of it: the need is refused,
933    /// not granted at the link's target.
934    #[test]
935    fn a_need_that_a_symlink_leads_outside_its_entry_is_refused() {
936        let host = Host::new();
937        let parent = host.home.join("parent");
938        let elsewhere = host.home.join("elsewhere");
939        std::fs::create_dir_all(&parent).unwrap();
940        std::fs::create_dir_all(&elsewhere).unwrap();
941        std::os::unix::fs::symlink(&elsewhere, parent.join("sessions")).unwrap();
942        let grants = host.resolve(
943            &needs(&[("~/parent/sessions", PathAccess::ReadWrite)], &[]),
944            &[grant("~/parent", HarnessPathAccess::ReadWrite)],
945            &[],
946        );
947        let message = refusal_text(&grants);
948        assert!(
949            message.contains("~/parent/sessions (read-write)"),
950            "{message}"
951        );
952        assert!(
953            message.contains("a symlink on the way leads out"),
954            "{message}"
955        );
956        assert!(
957            !message.contains("  "),
958            "one space between words: {message}"
959        );
960        assert!(grants.granted_paths.is_empty());
961        let row = &grants.report.paths[0];
962        assert_eq!(row.status, "refused");
963        assert_eq!(row.path.as_deref(), Some(elsewhere.to_str().unwrap()));
964
965        // A link that stays inside the entry is granted at its target.
966        let inside = parent.join("real");
967        std::fs::create_dir_all(&inside).unwrap();
968        std::os::unix::fs::symlink(&inside, parent.join("linked")).unwrap();
969        let grants = host.resolve(
970            &needs(&[("~/parent/linked", PathAccess::ReadWrite)], &[]),
971            &[grant("~/parent", HarnessPathAccess::ReadWrite)],
972            &[],
973        );
974        assert!(grants.refusal.is_none(), "{:?}", grants.refusal);
975        assert_eq!(grants.granted_paths[0].path, inside);
976    }
977
978    /// A host need is allowed only when one rule admits every port it spans, under the egress
979    /// proxy's matching: a bare host spans 80 and 443.
980    #[test]
981    fn a_host_need_is_allowed_only_when_every_port_it_spans_is_admitted() {
982        let host = Host::new();
983        let resolve = |need: &str, allow: &[&str]| {
984            host.resolve(&needs(&[], &[need]), &[], allow).report.hosts[0].clone()
985        };
986        let row = resolve("example.invalid:443", &["example.invalid"]);
987        assert_eq!(
988            (row.status, row.allowed_by.as_deref()),
989            ("allowed", Some("example.invalid"))
990        );
991        let row = resolve("example.invalid:443", &["example.invalid:443"]);
992        assert_eq!(row.status, "allowed");
993        assert_eq!(
994            resolve("example.invalid", &["example.invalid:443"]).status,
995            "refused"
996        );
997        assert_eq!(
998            resolve("example.invalid:8443", &["example.invalid"]).status,
999            "refused"
1000        );
1001        assert_eq!(
1002            resolve("example.invalid:443", &["other.invalid"]).status,
1003            "refused"
1004        );
1005    }
1006
1007    #[test]
1008    fn a_shebang_names_its_interpreter_and_env_both() {
1009        let host = Host::new();
1010        let script = host.home.join("harness");
1011        std::fs::write(&script, "#!/bin/sh\necho hi\n").unwrap();
1012        let info = HarnessBinaryInfo::read(&script);
1013        let sh = std::fs::canonicalize("/bin/sh").unwrap();
1014        assert_eq!(info.interpreter.as_ref(), Some(&sh));
1015        assert_eq!(info.launcher, None);
1016        assert_eq!(info.exec_files(), vec![script.clone(), sh.clone()]);
1017
1018        std::fs::write(&script, "#!/usr/bin/env sh\necho hi\n").unwrap();
1019        let info = HarnessBinaryInfo::read(&script);
1020        let env = std::fs::canonicalize("/usr/bin/env").unwrap();
1021        assert_eq!(info.launcher.as_ref(), Some(&env));
1022        assert_eq!(info.exec_files().len(), 3, "{:?}", info.exec_files());
1023        assert!(info.exec_files().contains(&env));
1024
1025        std::fs::write(&script, b"\x7fELF\x02\x01").unwrap();
1026        let info = HarnessBinaryInfo::read(&script);
1027        assert_eq!((info.interpreter, info.launcher), (None, None));
1028    }
1029}