metrale_bench/benchmarks/agentic/
agent_path.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: Resolving a file-tool path inside the agent's sandbox.
4//!
5//! Owner: bench, agentic.
6//! Invariants: none beyond the types.
7
8use std::path::{Component, Path, PathBuf};
9
10use anyhow::{Result, bail};
11
12/// 2026-09-26: Resolve `path` inside `sandbox`. The check is lexical, because
13/// the target usually does not exist yet: a relative path, or an absolute one
14/// already under `sandbox`, is accepted unless it has a `..` component or
15/// resolves, through an existing symlink, outside the sandbox.
16///
17/// This is not a privilege boundary: `bash` runs any command, and a symlink
18/// created after this check is not seen.
19pub fn resolve(sandbox: &Path, path: &str) -> Result<PathBuf> {
20    let path = Path::new(path);
21    let path = match path.strip_prefix(sandbox) {
22        Ok(inside) => inside,
23        Err(_) if path.is_absolute() => bail!(
24            "path must be inside the project directory {}: {}",
25            sandbox.display(),
26            path.display()
27        ),
28        Err(_) => path,
29    };
30    let mut out = sandbox.to_path_buf();
31    for component in path.components() {
32        match component {
33            Component::Normal(c) => out.push(c),
34            Component::CurDir => {}
35            Component::ParentDir => bail!("path must not leave the project directory"),
36            Component::RootDir | Component::Prefix(_) => bail!("absolute paths are not allowed"),
37        }
38    }
39    if leaves_via_symlink(sandbox, &out) {
40        bail!("path must not leave the project directory through a symlink");
41    }
42    Ok(out)
43}
44
45/// 2026-09-26: Does `out`, already lexically inside `sandbox`, resolve outside
46/// it? False when `sandbox` itself cannot be canonicalised; true when
47/// `deepest_existing_real` finds nothing within 40 dangling-symlink hops.
48fn leaves_via_symlink(sandbox: &Path, out: &Path) -> bool {
49    let Ok(root) = std::fs::canonicalize(sandbox) else {
50        return false;
51    };
52    deepest_existing_real(out, 40).is_none_or(|real| !real.starts_with(&root))
53}
54
55/// 2026-09-26: Canonicalise `path`, or else the deepest ancestor that
56/// canonicalises, following a dangling symlink's target for up to `hops` links.
57fn deepest_existing_real(path: &Path, hops: usize) -> Option<PathBuf> {
58    let mut probe = path;
59    loop {
60        if let Ok(real) = std::fs::canonicalize(probe) {
61            return Some(real);
62        }
63        if std::fs::symlink_metadata(probe).is_ok_and(|meta| meta.file_type().is_symlink()) {
64            if hops == 0 {
65                return None;
66            }
67            let target = std::fs::read_link(probe).ok()?;
68            let target = match target.is_absolute() {
69                true => target,
70                false => probe.parent()?.join(target),
71            };
72            return deepest_existing_real(&target, hops - 1);
73        }
74        probe = probe.parent()?;
75    }
76}