harn-vm 0.10.153

Async bytecode virtual machine for the Harn programming language
Documentation
//! The per-user root sets the sandbox grants beyond the workspace.
//!
//! These are data, not policy: each function answers "which directories does
//! this class of tooling keep under a user's home", and the preset-gated
//! wrappers in the parent module decide whether a given policy gets them. Both
//! the OS backends and the pure path-scope checks read the same answers from
//! here, which is what keeps a backend's rendered grant and the parent's view
//! of the jail from drifting apart.
//!
//! [`normalized_read_only_roots`] is the one policy reading here: the
//! read-only scope both consumers resolve. Host-granted external roots arrive
//! through `CapabilityPolicy::read_only_roots`; approval-policy metadata does
//! not independently grant filesystem access.

use std::path::{Path, PathBuf};

use super::paths::normalize_for_policy;
use crate::orchestration::CapabilityPolicy;

/// Normalize the policy's read-only roots. Unlike
/// [`super::normalized_workspace_roots`], an empty list stays empty — read-only
/// scope is purely additive, so there is no execution-root fallback to
/// synthesize.
///
/// Host-granted external roots are already projected into the capability
/// policy's `read_only_roots`. Keeping this scope owned by the capability
/// policy means a caller-authored approval policy cannot widen filesystem
/// access. Both the in-process filesystem builtins
/// ([`super::check_fs_path_scope`]) and the OS sandbox profile for a confined
/// child resolve the same roots here.
pub(super) fn normalized_read_only_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
    let mut roots: Vec<PathBuf> = Vec::new();
    for root in &policy.read_only_roots {
        let root = normalize_for_policy(&super::resolve_policy_path(root));
        if !roots.contains(&root) {
            roots.push(root);
        }
    }
    // Object stores borrowed through `objects/info/alternates` (e.g. a
    // `git clone --shared`) live outside the workspace and are only ever read
    // by git; grant them read-only scope. See [`crate::stdlib::git_topology`].
    for dir in super::git_scope_extension_for_roots(&super::base_workspace_roots(policy)).read_only
    {
        if !roots.iter().any(|existing| existing == &dir) {
            roots.push(dir);
        }
    }
    roots
}

/// Per-user toolchain *cache* roots that JVM/iOS build tools read **and write**
/// while a sandboxed build runs (Gradle, Maven, CocoaPods, Xcode, Kotlin
/// Native). Unlike [`developer_toolchain_read_roots_for_home`] these are not
/// read-only: a build legitimately populates `~/.gradle/caches`,
/// `~/.m2/repository`, `~/Library/Developer/Xcode/DerivedData`, etc. They are
/// gated on the `DeveloperToolchains` preset and granted *write* only when the
/// active policy lets its children write (`CapabilityPolicy::children_may_write`,
/// mirroring `UserTemp`); otherwise they fall back to read access so dependency resolution
/// still works.
// Cache *write* roots are only consumed by the macOS (seatbelt) and Linux
// (Landlock) sandbox backends; the Windows backend deliberately does not grant
// recursive home-scoped cache roots (see `windows.rs`). Gating to those two
// targets keeps `-D warnings` happy on Windows, where this would otherwise be
// dead code.
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
pub(crate) fn sandbox_user_home_dir() -> Option<PathBuf> {
    // Only an absolute home grounds the user-scope read-roots below; a
    // relative or unset home yields no extra roots (the safe direction).
    crate::user_dirs::home_dir().filter(|path| path.is_absolute())
}

#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
pub(crate) fn developer_toolchain_read_roots_for_home(home: &Path) -> Vec<PathBuf> {
    let mut roots: Vec<_> = [
        ".asdf",
        ".bun",
        ".cargo",
        ".fnm",
        ".juliaup",
        ".local/bin",
        ".local/share/mise",
        ".local/share/pnpm",
        ".local/share/uv",
        ".nvm",
        ".pyenv",
        ".rbenv",
        ".rustup",
        ".sdkman",
        ".swiftly",
        ".volta",
        "Library/pnpm",
        "go",
    ]
    .into_iter()
    .map(|entry| normalize_for_policy(&home.join(entry)))
    .collect();
    #[cfg(target_os = "windows")]
    roots.extend(
        [
            "AppData/Local/Programs/Python",
            "AppData/Local/uv",
            "AppData/Roaming/uv",
            "scoop",
        ]
        .into_iter()
        .map(|entry| normalize_for_policy(&home.join(entry))),
    );
    roots.sort_unstable();
    roots.dedup();
    roots
}

/// Per-user JVM/iOS toolchain cache roots (read+write). Kept platform-shared so
/// the macOS seatbelt and Linux Landlock backends render the same set; the
/// macOS-only `~/Library/...` entries are simply absent on Linux disk and the
/// `optional`/NotFound handling in each backend skips roots that do not exist.
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub(crate) fn developer_toolchain_cache_write_roots_for_home(home: &Path) -> Vec<PathBuf> {
    let mut roots: Vec<_> = [
        ".gradle",                             // Gradle (JVM/Android/Kotlin)
        ".m2",                                 // Maven (JVM)
        ".konan",                              // Kotlin/Native
        "Library/Caches/CocoaPods",            // CocoaPods (iOS/macOS)
        "Library/Developer/Xcode/DerivedData", // Xcode build products
        // Go build + module caches. `go build`/`go test` write compiled
        // package objects to GOCACHE and downloaded modules to GOMODCACHE;
        // when neither is granted, the toolchain fails — and go reports the
        // write miss as the misleading "package X is not in std (GOROOT/...)"
        // rather than a permissions error, so it reads as a code defect. The
        // default GOCACHE differs by OS (macOS `~/Library/Caches/go-build`,
        // Linux `~/.cache/go-build`); listing both is safe because the
        // OS-foreign entry is simply absent on disk and skipped. GOMODCACHE
        // defaults to `$GOPATH/pkg/mod` (`~/go/pkg/mod`); `~/go` itself stays
        // read-only via `developer_toolchain_read_roots_for_home`.
        "Library/Caches/go-build", // Go build cache (GOCACHE, macOS default)
        ".cache/go-build",         // Go build cache (GOCACHE, Linux default)
        "go/pkg/mod",              // Go module cache (GOMODCACHE default)
        // Harn's own package cache, which needs write and not merely read: an
        // entry is claimed through a lock file under `locks/` before it is
        // read, so a read-only grant denies the claim and the cached entry
        // reads as unusable. Both OS defaults are listed for the same reason
        // the Go caches above are; the OS-foreign one is simply absent.
        // `HARN_CACHE_DIR` overrides the location, and the workspace env hands
        // the child the resolved root so the two always agree.
        "Library/Caches/harn", // Harn package cache (macOS default)
        ".cache/harn",         // Harn package cache (Linux default)
        // Go env config (GOENV). `go` rewrites `go/env` on first use (e.g. to
        // record GOTOOLCHAIN); when its parent is not writable the toolchain
        // fails with `writing go env config: ... operation not permitted`. The
        // macOS default is `~/Library/Application Support/go/env`
        // (`os.UserConfigDir()/go`). The Linux default `~/.config/go/env` sits
        // under the read-only `.config` package-manager root, so granting it
        // needs a nested carve-out and is tracked separately.
        "Library/Application Support/go", // Go env config dir (GOENV, macOS default)
        // Cargo registry + git caches. `cargo fetch`/`cargo build` unpack crate
        // sources into `registry/src`, download tarballs into `registry/cache`,
        // refresh the index under `registry/index`, and check out git deps under
        // `git/db` + `git/checkouts`; a build fails to unpack ("failed to create
        // directory .../registry/src/...: Operation not permitted") when these
        // are read-only. These hold build artifacts only — Cargo credentials and
        // config live at the CARGO_HOME root (`.cargo/credentials.toml`,
        // `.cargo/config.toml`), OUTSIDE `registry`/`git`, and stay read-only
        // (granted read via `.cargo` in `developer_toolchain_read_roots_for_home`
        // and re-denied write by the package-manager preset). `.package-cache` is
        // Cargo's advisory build lock at the CARGO_HOME root.
        ".cargo/registry",       // crate cache/index/src (CARGO_HOME default)
        ".cargo/git",            // git dependency db + checkouts
        ".cargo/.package-cache", // Cargo's advisory build lock file
    ]
    .into_iter()
    .map(|entry| normalize_for_policy(&home.join(entry)))
    .collect();
    roots.sort_unstable();
    roots.dedup();
    roots
}

#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
pub(crate) fn package_manager_config_read_roots_for_home(home: &Path) -> Vec<PathBuf> {
    let mut roots: Vec<_> = [
        ".npmrc",
        ".gitconfig",
        ".netrc",
        ".yarnrc.yml",
        // Measured by scripts/sandbox_config_census.harn. Keep unknown XDG
        // siblings closed; the credential denylist still wins inside these.
        ".config/git",
        ".config/pip",
        ".config/go",
        ".config/uv",
        ".config/pnpm",
        ".config/yarn",
        ".config/composer",
        ".config/rustfmt",
        ".config/cmake",
        ".config/gem",
        ".config/mise",
        ".config/ruff",
        ".config/black",
        ".config/sbt",
        ".config/coursier/mirror.properties",
        ".config/swiftpm/configuration",
        ".npm",
        ".cache/pip",
        ".cache/uv",
        ".cache/node/corepack",
        ".cache/yarn",
        ".cache/composer",
        ".cache/mise",
        ".cache/black",
        ".cache/sbt",
        ".cache/coursier",
        ".cache/JNA",
        ".cache/clang",
        ".cache/org.swift.swiftpm",
        ".cache/org.swift.foundation.URLCache",
        // .cache/go-build and .cache/harn are owned by the writable
        // DeveloperToolchains roots. Repeating them here cancels that grant
        // on macOS, where the read-only rules follow the write allows.
        ".pip",
        ".pypirc",
        // Composer's macOS home (`COMPOSER_HOME` default). `config.json` and
        // the global `composer.json` are configuration; `auth.json` inside it
        // is on the credential denylist, which beats this grant.
        ".composer",
        // pnpm's global rc on macOS (`env-paths` config dir). A registry or
        // store-dir setting, never a token: those live in `.npmrc`.
        "Library/Preferences/pnpm",
        ".cargo/config",
        ".cargo/config.toml",
        ".cargo/credentials",
        ".cargo/credentials.toml",
        // NOTE: `.cargo/registry` and `.cargo/git` are deliberately NOT here.
        // They are build caches Cargo must WRITE, so they moved to
        // `developer_toolchain_cache_write_roots_for_home`. Listing them here
        // too would re-deny their writes: the macOS backend emits a
        // `(deny file-write*)` for every package-manager read root AFTER the
        // write-allow block, and last-match-wins would cancel the cache grant.
        // `.cargo` itself stays readable via `developer_toolchain_read_roots`.
    ]
    .into_iter()
    .map(|entry| normalize_for_policy(&home.join(entry)))
    .collect();
    roots.sort_unstable();
    roots.dedup();
    roots
}

#[cfg(any(target_os = "linux", target_os = "macos"))]
pub(super) mod path_grants;

#[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
mod tests {
    use super::*;

    #[test]
    fn package_manager_roots_do_not_admit_unknown_xdg_siblings() {
        let home = tempfile::tempdir().unwrap();
        let roots = package_manager_config_read_roots_for_home(home.path());
        for relative in [".config/unlisted/token", ".cache/unlisted/token"] {
            let target = normalize_for_policy(&home.path().join(relative));
            assert!(
                !roots.iter().any(|root| target.starts_with(root)),
                "unknown XDG sibling is covered by a preset: {roots:?}"
            );
        }
        for relative in [".config/git", ".config/rustfmt", ".cache/pip"] {
            assert!(roots.contains(&normalize_for_policy(&home.path().join(relative))));
        }
    }

    #[test]
    fn read_only_xdg_roots_do_not_cancel_writable_toolchain_caches() {
        let home = tempfile::tempdir().unwrap();
        let reads = package_manager_config_read_roots_for_home(home.path());
        let cache = normalize_for_policy(&home.path().join(".cache"));
        let mut checked = 0;
        for writable in developer_toolchain_cache_write_roots_for_home(home.path()) {
            if writable.starts_with(&cache) {
                checked += 1;
                assert!(
                    !reads.iter().any(|read| writable.starts_with(read)),
                    "read-only {reads:?} would cancel writes to {writable:?} on macOS"
                );
            }
        }
        assert!(checked >= 2, "must check the Go and Harn writable caches");
    }
}