Skip to content

Sandbox mode never mounts the live virtio-fs share (--mount) inside the workload container #216

Closed

Description

@lpcox

Summary

In sandbox mode (nvx_sandbox=1, --microvm-sandbox-block), the guest never mounts the live virtio-fs share, even though OpenVMM attaches it and passes its parameters on the kernel command line. A workload running in the sandbox container can't see a host directory exported with --mount.

This blocks github/gh-aw-firewall (AWF) from giving NVX the same workspace-sharing model as its Cloud Hypervisor backend. Cloud Hypervisor live-shares the job workspace read-write, and optionally the tool cache read-only, over virtio-fs. With NVX, AWF currently has to stage the workspace into an EROFS layer and copy results back after the VM exits.

Current behavior

References are to dev @ 20ce852. The same behavior is in the currently pinned v0.1.0-dev.d561c4300ebe, under alpine/.

Host side (works). OpenVMM accepts --mount GUEST_TARGET,HOST_PATH,ro|rw and --mount-deny HOST_PATH together with --microvm-sandbox-block, and validation doesn't reject the combination. openvmm_defs/src/config.rs appends virtfs_dir=<target> virtfs_tag=microvm virtfs_mode=<ro|rw> to the kernel command line, and the guest logs virtiofs virtio1: discovered new tag: microvm.

Guest side (missing).

  • guest/common/init (~L111-116) calls mount_virtfs (/sbin/nvx-hostmount) only when sandbox_mode=false. In sandbox mode it goes straight to exec /sbin/nvx-init-agent (~L183-187).
  • guest/common/nvx-init-agent assembles the container overlay at $rootfs (~L208-210) and starts the workload with nvx-container-launch → nvx-container-enter (~L277). It never reads virtfs_* or mounts the microvm tag.
  • The workload can't mount the share itself. nvx-container-enter runs it through setpriv with --bounding-set=-all --inh-caps=-all --ambient-caps=-all inside a private mount namespace, which is correct and should stay that way.

As a result, the share is attached but unreachable from the sandboxed workload.

Requested change

When virtfs_tag is present in sandbox mode, nvx-init-agent (or nvx-container-enter) should mount the share inside the container rootfs at virtfs_dir before dropping privileges. For example:

  1. Parse virtfs_tag, virtfs_dir and virtfs_mode the same way nvx-hostmount does.
  2. Validate virtfs_dir: it must be absolute, must not contain .., and must not be /, /proc, /sys, /dev, or /.nvx-agent.
  3. After the overlay is built, run mkdir -p "$rootfs$virtfs_dir" and mount -t virtiofs -o "$virtfs_mode,nosuid,nodev" microvm "$rootfs$virtfs_dir", so the mount survives the --make-rprivate / unshare --mount / chroot.
  4. Unmount it during teardown, before the overlay unmount (~L295). If the mount fails, report it as a sandbox error (fail closed) rather than silently running without the share.
  5. Keep the existing sandbox behavior unchanged when no --mount is given.

Guest-side requirements:

  • UID mapping: the workload identity (--microvm-workload-identity, for example uid/gid 1001 runner) must be able to read and write files in an rw share, and files it creates should be owned by the host user that owns the export. The host-side uid/gid behavior of the built-in virtio-fs server should be documented.
  • --mount-deny paths must remain hidden inside the container.
  • An ro share must reject writes from the workload (EROFS/EACCES).
  • A second read-only share (for example a tool cache at /opt/hostedtoolcache) would be ideal for full parity with AWF's workspace-and-tool-cache policy. The microVM currently has a single fixed microvm slot, so please say whether multiple shares are feasible or whether a single share is the intended contract.

Acceptance criteria

  • A microVM test such as scripts/nvx_tools/microvm_test_scripts/filesystem-live.sh, run in sandbox mode with --mount /workspace,<dir>,rw, shows that:
    • a host-created file is visible in the workload;
    • a file written by the workload appears on the host immediately, with no copy-back;
    • a host edit made while the workload runs is visible to the workload;
    • --mount-deny paths are absent;
    • a ro mount rejects writes.
  • A new v0.1.0-dev.* release (package plus SOURCE-MANIFEST.json) that includes the change, so AWF can bump its pinned nvxCommit/openvmmCommit.
  • doc/run.md (virtio-fs host mapping section) and doc/design/current-limits.md document sandbox-mode support.

AWF follow-up (for context)

Once a release is available, AWF will:

  • pass --mount /workspace,$GITHUB_WORKSPACE,rw, plus --mount-deny for credential paths, in src/nvx/runtime-lifecycle.ts;
  • drop the workspace-staging and copy-back path;
  • add a smoke-nvx-virtiofs hardware smoke test covering the criteria above.

Activity

  1. lpcox commented on Sep 25, 2026

    @lpcox
    Author

    Prototype: a guest-only fix works (tested on KVM)

    I patched the pinned v0.1.0-dev.d561c4300ebe initramfs and booted it on a GitHub-hosted ubuntu-latest KVM runner. OpenVMM ran with AWF's sandbox arguments (--microvm-sandbox-block, --microvm-workload-identity 1001:1001, --microvm-lifecycle one-shot, --single-process) plus --mount /workspace,<dir>,rw|ro --mount-deny <dir>/secrets.

    The patch doesn't unpack or modify the base image: it appends a second root-owned cpio member that overrides /sbin/nvx-init-agent. No OpenVMM change was needed.

    Run: https://github.com/github/gh-aw-firewall/actions/runs/36199851844

    Case Result
    Stock initramfs, rw ❌ No mount at /workspace, the host file isn't visible, and guest writes fail. The guest still logs virtiofs virtio0: discovered new tag: microvm.
    Patched, rw ✅ The workload (uid 1001) sees a virtiofs mount and the host file, and --mount-deny hides secrets/. The host saw guest.txt while the VM was running, then wrote host-edit.txt, and the guest saw that edit live.
    Patched, ro ✅ Mounted ro,nosuid,nodev, the host file is visible, the denied path is hidden, and writes are rejected.

    Every patched run reported outcome.category: success and teardown.virtiofs_released: true.

    Patch (nvx-init-agent, right after the container overlay is assembled)

    virtfs_target=
    virtfs_tag=$(cmdline_value virtfs_tag || true)
    if [ -n "$virtfs_tag" ]; then
        [ "$virtfs_tag" = microvm ] || fatal "unexpected virtio-fs tag: $virtfs_tag"
        virtfs_dir=$(cmdline_value virtfs_dir || true)
        virtfs_mode=$(cmdline_value virtfs_mode || true)
        case "$virtfs_mode" in ro | rw) ;; *) fatal "invalid virtio-fs mode" ;; esac
        case "$virtfs_dir" in
            /*) ;;
            *) fatal "virtio-fs target must be absolute" ;;
        esac
        case "$virtfs_dir" in
            / | /proc | /proc/* | /sys | /sys/* | /dev | /dev/* | /.nvx-agent | /.nvx-agent/* | *//* | */./* | */../* | */. | */..)
                fatal "virtio-fs target is not permitted: $virtfs_dir" ;;
        esac
        virtfs_target=$rootfs$virtfs_dir
        [ ! -L "$virtfs_target" ] || fatal "virtio-fs target is a symlink"
        mkdir -p "$virtfs_target" || fatal "failed to create virtio-fs target"
        mount -t virtiofs -o "$virtfs_mode,nosuid,nodev" "$virtfs_tag" "$virtfs_target" ||
            fatal "failed to mount virtio-fs share at $virtfs_dir"
        echo "NVX-SANDBOX-VIRTFS: mounted $virtfs_tag at $virtfs_dir ($virtfs_mode)"
    fi
    
    # ... and in teardown, before `umount "$rootfs"`:
    if [ -n "$virtfs_target" ] && ! umount "$virtfs_target"; then
        echo "NVX-SANDBOX-ERROR: failed to unmount the virtio-fs share" >&2
        status=1
    fi

    This needs no changes to nvx-container-enter. The mount lives under $rootfs, so it survives --make-rprivate, unshare --mount and chroot. The workload still runs with every capability dropped.

    Open issue found: host-side ownership of guest-created files

    A file created by the uid 1001 workload appeared on the host as 0:0 (OpenVMM runs as root). For AWF's use case, the workspace belongs to the runner user, so files created in the guest should be owned by the export owner, or by a configurable host uid/gid, rather than root. Could the built-in virtio-fs server map creates to the export root's owner (or accept an option for this), like virtiofsd's --translate-uid/--translate-gid?

  2. lpcox commented on Sep 25, 2026

    @lpcox
    Author

    Follow-up: guest-created files are owned by the VMM identity on the host — prototype fix (OpenVMM)

    With the sandbox mount from the previous comment in place, files the workload creates on the share appear on the host as the OpenVMM process identity (0:0 when run as root, or the dedicated VMM account in AWF), not the guest caller. Cause: OpenVMM's lxutil Unix backend ignores LxCreateOptions uid/gid ("uid/gid are never used"), and every other operation also runs as the VMM identity. Side effects beyond ownership: a nested write into a guest-created directory fails (dir is root 0755 and the guest kernel enforces it), and chmod on own files fails.

    Proposed design (prototyped against nanvix/openvmm 0bc357bb)

    • New CLI flag --mount-owner process|caller (default process = current behavior; requires --mount). caller is carried as HostFs mount_options = "owner=caller" (the microVM resolver previously rejected any mount options).
    • In caller mode, process_virtiofs_request wraps the entire synchronous Session::dispatch in a per-thread setfsuid/setfsgid switch to the FUSE request's uid/gid — the same model as virtiofsd. So create, append, truncate, rename, unlink, chmod, … are all permission-checked and owned as the guest caller. FORGET/BATCH_FORGET/DESTROY skip the switch.
    • Root squash: guest uid 0 / gid 0 map to the export root's owner, so the guest can never create host root-owned (or setuid-root) objects.
    • Fail closed: the switch is verified by querying with -1; if it didn't take effect (no CAP_SETUID/CAP_SETGID) the request gets EPERM and nothing runs as the VMM identity.
    • New public lxutil::FsCredentials (Unix-only scoped guard, restores on drop). Unit tests: owner option parsing, root-squash mapping, switch/restore, fail-closed without capability.

    Results on a KVM runner (patched initramfs + patched OpenVMM, workload uid 1001)

    Probe caller, VMM root default, VMM root caller, VMM non-root + only CAP_SETUID/CAP_SETGID caller, VMM non-root no caps
    create file / mkdir+nested ✅ 1001:1001 ✅ 0:0 / ❌ nested ✅ 1001:1001 ❌ EPERM (fail closed)
    append / truncate / rename / unlink host-owned file ✅ ✅ ✅ ❌ EPERM
    write into host-owned subdir ✅ 1001 ✅ 0:0 ✅ 1001 ❌
    other user's 0644 file stays read-only ✅ ✅ ✅ ✅
    chmod own file ✅ ❌ ✅ ❌
    live host↔guest visibility, --mount-deny ✅ ✅ ✅ ❌ / ✅
    symlink ❌ ENOTSUP ❌ ❌ ❌

    cargo test -p lxutil -p virtiofs: all pass. The third column is the configuration AWF would ship: VMM as a dedicated non-root account with only CAP_SETUID+CAP_SETGID — everything except symlinks works, and ownership matches the guest user.

    Asks / notes for the NVX release

    1. Please upstream (or equivalent) --mount-owner caller to the OpenVMM NVX builds, and have NVX pass it for sandbox --mount.
    2. Security: CAP_SETUID/CAP_SETGID would also permit setuid(0) from a compromised VMM. Recommend OpenVMM's seccomp filter (or the embedder's) allow setfsuid/setfsgid but deny setuid/setgid/setreuid/setregid/setresuid/setresgid/setgroups in caller mode. Only setfsuid/setfsgid are used by the patch.
    3. Supplementary groups are not propagated (VMM runs with cleared groups); group-writable access via secondary groups won't work. Probably acceptable for AWF (single workspace user).
    4. chmod u+s on the caller's own file succeeds (host sees -rwS owned by 1001, never root thanks to squash). virtiofsd clears suid/sgid on write with killpriv_v2; consider similar, or document.
    5. Symlink creation returns ENOTSUP by design on the microVM profile (lib.rs symlink() when is_microvm()). This isn't an ownership issue, but it is a parity gap vs. Cloud Hypervisor/virtiofsd: npm ci (node_modules/.bin), many build tools, and git checkouts with symlinks will fail on a live-shared workspace. Could symlinks be allowed with targets stored verbatim (resolution already happens guest-side, and the host side uses strict/O_NOFOLLOW path walking)?
    openvmm-mount-owner.patch (against nanvix/openvmm 0bc357bb)
    diff --git a/openvmm/openvmm_entry/src/cli_args.rs b/openvmm/openvmm_entry/src/cli_args.rs
    index 4b763c1a..b015d9d9 100644
    --- a/openvmm/openvmm_entry/src/cli_args.rs
    +++ b/openvmm/openvmm_entry/src/cli_args.rs
    @@ -1133,6 +1133,21 @@ pub struct Options {
         )]
         pub microvm_mount_deny: Vec<PathBuf>,
     
    +    /// Host identity for guest requests on `--mount`.
    +    ///
    +    /// `process` (default) uses the OpenVMM process identity. `caller` runs
    +    /// each request as the guest caller's uid/gid, like virtiofsd, so files the
    +    /// guest creates are owned by the guest user; guest uid/gid 0 is squashed to
    +    /// the export root's owner. `caller` requires CAP_SETUID and CAP_SETGID
    +    /// (only setfsuid/setfsgid are used); requests fail with EPERM otherwise.
    +    #[clap(
    +        long = "mount-owner",
    +        value_name = "process|caller",
    +        default_value = "process",
    +        requires = "microvm_mount"
    +    )]
    +    pub microvm_mount_owner: MicrovmMountOwnerCli,
    +
         /// virtio PMEM device
         ///
         /// Prefix with `pcie_port=<port_name>:` to expose the device over
    @@ -2416,6 +2431,13 @@ pub struct MicrovmMountCli {
         pub access: openvmm_defs::config::MicrovmFilesystemAccess,
     }
     
    +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
    +pub enum MicrovmMountOwnerCli {
    +    #[default]
    +    Process,
    +    Caller,
    +}
    +
     impl FromStr for MicrovmMountCli {
         type Err = anyhow::Error;
     
    diff --git a/openvmm/openvmm_entry/src/lib.rs b/openvmm/openvmm_entry/src/lib.rs
    index 667a39a0..40657aff 100644
    --- a/openvmm/openvmm_entry/src/lib.rs
    +++ b/openvmm/openvmm_entry/src/lib.rs
    @@ -4359,7 +4359,11 @@ async fn vm_config_from_command_line(
                 (
                     virtio_resources::fs::VirtioFsBackend::HostFs {
                         root_path: filesystem.root_path.clone(),
    -                    mount_options: String::new(),
    +                    mount_options: match opt.microvm_mount_owner {
    +                        cli_args::MicrovmMountOwnerCli::Process => String::new(),
    +                        // Matches `virtiofs::MicroVmCreateOwner::CALLER_MOUNT_OPTION`.
    +                        cli_args::MicrovmMountOwnerCli::Caller => "owner=caller".to_owned(),
    +                    },
                     },
                     virtio_resources::fs::VirtioFsProfile::Microvm {
                         stable_id: MICROVM_FILESYSTEM_STABLE_ID.to_owned(),
    diff --git a/vm/devices/support/fs/lxutil/src/lib.rs b/vm/devices/support/fs/lxutil/src/lib.rs
    index 0244a9f3..e9d3a285 100644
    --- a/vm/devices/support/fs/lxutil/src/lib.rs
    +++ b/vm/devices/support/fs/lxutil/src/lib.rs
    @@ -25,6 +25,8 @@
     
     pub use path::PathBufExt;
     pub use path::PathExt;
    +#[cfg(unix)]
    +pub use unix::FsCredentials;
     
     /// A platform-independent abstraction that allows you to treat an area of the file system as if
     /// it has Unix semantics.
    diff --git a/vm/devices/support/fs/lxutil/src/unix/mod.rs b/vm/devices/support/fs/lxutil/src/unix/mod.rs
    index d94f66a7..3125bb5f 100644
    --- a/vm/devices/support/fs/lxutil/src/unix/mod.rs
    +++ b/vm/devices/support/fs/lxutil/src/unix/mod.rs
    @@ -478,3 +478,83 @@ pub fn fsync(&self, data_only: bool) -> lx::Result<()> {
             Ok(())
         }
     }
    +
    +/// Scoped switch of the calling thread's filesystem uid and gid.
    +///
    +/// Linux keeps fsuid/fsgid per thread, so while the guard is alive every
    +/// filesystem operation on this thread is permission-checked, and every new
    +/// object is owned, as the requested identity. Other threads are unaffected,
    +/// and the previous identity is restored on drop. Switching to an identity
    +/// other than the current one requires `CAP_SETUID`/`CAP_SETGID`; without them
    +/// `switch` fails with `EPERM` instead of silently keeping the old identity.
    +pub struct FsCredentials {
    +    previous_uid: libc::uid_t,
    +    previous_gid: libc::gid_t,
    +}
    +
    +impl FsCredentials {
    +    /// Switches the calling thread to `uid`/`gid` until the guard is dropped.
    +    pub fn switch(uid: lx::uid_t, gid: lx::gid_t) -> lx::Result<Self> {
    +        // SAFETY: setfsuid/setfsgid only change the calling thread's
    +        // filesystem credentials; `u32::MAX` (-1) queries without changing.
    +        unsafe {
    +            let previous_gid = libc::setfsgid(gid) as libc::gid_t;
    +            if libc::setfsgid(libc::gid_t::MAX) as libc::gid_t != gid {
    +                libc::setfsgid(previous_gid);
    +                return Err(lx::Error::EPERM);
    +            }
    +            let previous_uid = libc::setfsuid(uid) as libc::uid_t;
    +            if libc::setfsuid(libc::uid_t::MAX) as libc::uid_t != uid {
    +                libc::setfsuid(previous_uid);
    +                libc::setfsgid(previous_gid);
    +                return Err(lx::Error::EPERM);
    +            }
    +            Ok(Self {
    +                previous_uid,
    +                previous_gid,
    +            })
    +        }
    +    }
    +}
    +
    +impl Drop for FsCredentials {
    +    fn drop(&mut self) {
    +        // SAFETY: Restores this thread's filesystem credentials saved by
    +        // `switch`.
    +        unsafe {
    +            libc::setfsuid(self.previous_uid);
    +            libc::setfsgid(self.previous_gid);
    +        }
    +    }
    +}
    +
    +#[cfg(test)]
    +mod fs_credentials_tests {
    +    use super::FsCredentials;
    +
    +    #[test]
    +    fn switch_to_current_identity_and_restore() {
    +        // SAFETY: -1 queries the current values without changing them.
    +        let (uid, gid) = unsafe {
    +            (
    +                libc::setfsuid(libc::uid_t::MAX) as libc::uid_t,
    +                libc::setfsgid(libc::gid_t::MAX) as libc::gid_t,
    +            )
    +        };
    +        drop(FsCredentials::switch(uid, gid).unwrap());
    +        // SAFETY: as above.
    +        unsafe {
    +            assert_eq!(libc::setfsuid(libc::uid_t::MAX) as libc::uid_t, uid);
    +            assert_eq!(libc::setfsgid(libc::gid_t::MAX) as libc::gid_t, gid);
    +        }
    +    }
    +
    +    #[test]
    +    fn switch_fails_closed_without_capability() {
    +        // SAFETY: capget-free probe; geteuid has no side effects.
    +        if unsafe { libc::geteuid() } == 0 {
    +            return;
    +        }
    +        assert!(FsCredentials::switch(u32::MAX - 7, u32::MAX - 7).is_err());
    +    }
    +}
    diff --git a/vm/devices/virtio/virtiofs/src/lib.rs b/vm/devices/virtio/virtiofs/src/lib.rs
    index dce54cca..28051fab 100644
    --- a/vm/devices/virtio/virtiofs/src/lib.rs
    +++ b/vm/devices/virtio/virtiofs/src/lib.rs
    @@ -31,6 +31,52 @@
     use inode::VirtioFsInode;
     use inode::VirtioFsVolume;
     pub use lxutil::LxVolumeOptions;
    +
    +/// Host identity used for guest requests on the microVM virtio-fs attachment.
    +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
    +pub enum MicroVmCreateOwner {
    +    /// Every request runs as the OpenVMM process identity (historical behavior).
    +    #[default]
    +    Process,
    +    /// Every request runs as the guest caller's uid/gid, like virtiofsd, so
    +    /// guest-created objects are owned by the guest user on the host. Guest
    +    /// uid 0 and gid 0 are squashed to the export root's owner.
    +    Caller,
    +}
    +
    +impl MicroVmCreateOwner {
    +    /// The HostFs `mount_options` value that selects [`Self::Caller`].
    +    pub const CALLER_MOUNT_OPTION: &'static str = "owner=caller";
    +
    +    /// Parses the microVM HostFs mount options; only the owner option is accepted.
    +    pub fn from_mount_options(options: &str) -> anyhow::Result<Self> {
    +        match options {
    +            "" => Ok(Self::Process),
    +            Self::CALLER_MOUNT_OPTION if cfg!(unix) => Ok(Self::Caller),
    +            _ => anyhow::bail!(
    +                "microVM virtio-fs accepts only the '{}' HostFs mount option on Unix",
    +                Self::CALLER_MOUNT_OPTION
    +            ),
    +        }
    +    }
    +}
    +
    +/// Host identity mapping for [`MicroVmCreateOwner::Caller`].
    +#[derive(Clone, Copy, Debug, Eq, PartialEq)]
    +pub struct CallerIdentity {
    +    squash_uid: lx::uid_t,
    +    squash_gid: lx::gid_t,
    +}
    +
    +impl CallerIdentity {
    +    /// Returns the host uid/gid for a guest request.
    +    pub fn map(&self, uid: lx::uid_t, gid: lx::gid_t) -> (lx::uid_t, lx::gid_t) {
    +        (
    +            if uid == 0 { self.squash_uid } else { uid },
    +            if gid == 0 { self.squash_gid } else { gid },
    +        )
    +    }
    +}
     use parking_lot::RwLock;
     use profile::MICROVM_FUSE_MAJOR;
     use profile::MICROVM_FUSE_MAX_WRITE;
    @@ -136,6 +182,7 @@ struct VirtioFsInner {
         policy: CacheAndIoPolicy,
         microvm_profile: Option<MicroVmVirtioFsProfile>,
         negotiation: RwLock<FuseNegotiation>,
    +    caller_identity: Option<CallerIdentity>,
     }
     
     /// Distinguishes a single-share device from a multi-share aggregate.
    @@ -717,6 +764,7 @@ pub fn new(
                     policy: CacheAndIoPolicy::normal(),
                     microvm_profile: None,
                     negotiation: RwLock::new(FuseNegotiation::default()),
    +                caller_identity: None,
                 }),
             })
         }
    @@ -728,6 +776,15 @@ pub fn new(
         pub fn new_microvm(
             root_path: impl AsRef<Path>,
             profile: MicroVmVirtioFsProfile,
    +    ) -> anyhow::Result<Self> {
    +        Self::new_microvm_with_owner(root_path, profile, MicroVmCreateOwner::Process)
    +    }
    +
    +    /// Like [`Self::new_microvm`], selecting the host identity for guest requests.
    +    pub fn new_microvm_with_owner(
    +        root_path: impl AsRef<Path>,
    +        profile: MicroVmVirtioFsProfile,
    +        owner: MicroVmCreateOwner,
         ) -> anyhow::Result<Self> {
             let root_path = root_path.as_ref();
             profile.validate_root_path(root_path)?;
    @@ -765,6 +822,10 @@ pub fn new_microvm(
                     policy: CacheAndIoPolicy::microvm(&profile),
                     microvm_profile: Some(profile),
                     negotiation: RwLock::new(FuseNegotiation::default()),
    +                caller_identity: (owner == MicroVmCreateOwner::Caller).then_some(CallerIdentity {
    +                    squash_uid: root_stat.uid,
    +                    squash_gid: root_stat.gid,
    +                }),
                 }),
             })
         }
    @@ -785,6 +846,7 @@ pub fn new_aggregate() -> Self {
                     policy: CacheAndIoPolicy::normal(),
                     microvm_profile: None,
                     negotiation: RwLock::new(FuseNegotiation::default()),
    +                caller_identity: None,
                 }),
             }
         }
    @@ -805,6 +867,10 @@ pub(crate) fn microvm_profile(&self) -> Option<&MicroVmVirtioFsProfile> {
             self.inner.microvm_profile.as_ref()
         }
     
    +    pub(crate) fn caller_identity(&self) -> Option<CallerIdentity> {
    +        self.inner.caller_identity
    +    }
    +
         fn is_microvm(&self) -> bool {
             self.inner.microvm_profile.is_some()
         }
    @@ -2498,3 +2564,36 @@ fn relative_path_validation_rejects_platform_escapes() {
             assert!(validate_relative_path(Path::new("alternate:name"), true).is_err());
         }
     }
    +
    +#[cfg(test)]
    +mod owner_tests {
    +    use super::CallerIdentity;
    +    use super::MicroVmCreateOwner;
    +
    +    #[test]
    +    fn parses_owner_mount_options() {
    +        assert_eq!(
    +            MicroVmCreateOwner::from_mount_options("").unwrap(),
    +            MicroVmCreateOwner::Process
    +        );
    +        #[cfg(unix)]
    +        assert_eq!(
    +            MicroVmCreateOwner::from_mount_options("owner=caller").unwrap(),
    +            MicroVmCreateOwner::Caller
    +        );
    +        assert!(MicroVmCreateOwner::from_mount_options("owner=root").is_err());
    +        assert!(MicroVmCreateOwner::from_mount_options("ro").is_err());
    +    }
    +
    +    #[test]
    +    fn squashes_guest_root_to_export_owner() {
    +        let identity = CallerIdentity {
    +            squash_uid: 1001,
    +            squash_gid: 1002,
    +        };
    +        assert_eq!(identity.map(0, 0), (1001, 1002));
    +        assert_eq!(identity.map(1234, 0), (1234, 1002));
    +        assert_eq!(identity.map(0, 5678), (1001, 5678));
    +        assert_eq!(identity.map(1234, 5678), (1234, 5678));
    +    }
    +}
    diff --git a/vm/devices/virtio/virtiofs/src/resolver.rs b/vm/devices/virtio/virtiofs/src/resolver.rs
    index 7fe0afc6..e199d379 100644
    --- a/vm/devices/virtio/virtiofs/src/resolver.rs
    +++ b/vm/devices/virtio/virtiofs/src/resolver.rs
    @@ -111,17 +111,15 @@ fn resolve(
                     else {
                         anyhow::bail!("microVM virtio-fs requires a HostFs backend");
                     };
    -                anyhow::ensure!(
    -                    mount_options.is_empty(),
    -                    "microVM virtio-fs does not accept HostFs mount options"
    -                );
    -                VirtioFsDevice::new_microvm_hostfs(
    +                let owner = crate::MicroVmCreateOwner::from_mount_options(&mount_options)?;
    +                VirtioFsDevice::new_microvm_hostfs_with_owner(
                         input.driver_source,
                         stable_id,
                         root_identity,
                         read_only,
                         denied_paths,
                         root_path,
    +                    owner,
                         None,
                     )?
                 }
    diff --git a/vm/devices/virtio/virtiofs/src/virtio.rs b/vm/devices/virtio/virtiofs/src/virtio.rs
    index c690563b..2eda1159 100644
    --- a/vm/devices/virtio/virtiofs/src/virtio.rs
    +++ b/vm/devices/virtio/virtiofs/src/virtio.rs
    @@ -98,6 +98,8 @@ pub struct VirtioFsDevice {
         #[inspect(skip)]
         microvm_profile: Option<MicroVmVirtioFsProfile>,
         #[inspect(skip)]
    +    caller_identity: Option<crate::CallerIdentity>,
    +    #[inspect(skip)]
         stateful_fs: Option<VirtioFs>,
         #[inspect(skip)]
         admission: Arc<RequestAdmission>,
    @@ -288,6 +290,30 @@ pub fn new_microvm_hostfs(
             denied_paths: Vec<String>,
             root_path: impl AsRef<Path>,
             notify_corruption: Option<Arc<dyn Fn() + Sync + Send>>,
    +    ) -> anyhow::Result<Self> {
    +        Self::new_microvm_hostfs_with_owner(
    +            driver_source,
    +            stable_id,
    +            root_identity,
    +            read_only,
    +            denied_paths,
    +            root_path,
    +            crate::MicroVmCreateOwner::Process,
    +            notify_corruption,
    +        )
    +    }
    +
    +    /// Like [`Self::new_microvm_hostfs`], selecting the host identity for guest requests.
    +    #[expect(clippy::too_many_arguments)]
    +    pub fn new_microvm_hostfs_with_owner(
    +        driver_source: &VmTaskDriverSource,
    +        stable_id: String,
    +        root_identity: Vec<u8>,
    +        read_only: bool,
    +        denied_paths: Vec<String>,
    +        root_path: impl AsRef<Path>,
    +        owner: crate::MicroVmCreateOwner,
    +        notify_corruption: Option<Arc<dyn Fn() + Sync + Send>>,
         ) -> anyhow::Result<Self> {
             let profile = MicroVmVirtioFsProfile::from_attachment(
                 stable_id,
    @@ -295,7 +321,7 @@ pub fn new_microvm_hostfs(
                 read_only,
                 denied_paths,
             )?;
    -        let fs = VirtioFs::new_microvm(root_path, profile.clone())?;
    +        let fs = VirtioFs::new_microvm_with_owner(root_path, profile.clone(), owner)?;
             Self::new_microvm(driver_source, profile, fs, notify_corruption)
         }
     
    @@ -338,6 +364,7 @@ fn from_parts<Fs>(
                 shared_memory_region: None,
                 notify_corruption,
                 num_request_queues,
    +            caller_identity: stateful_fs.as_ref().and_then(VirtioFs::caller_identity),
                 microvm_attachment_id,
                 microvm_profile,
                 stateful_fs,
    @@ -414,6 +441,7 @@ async fn start_queue(
                 shared_memory_size: self.shmem_size,
                 notify_corruption: self.notify_corruption.clone(),
                 admission: Arc::clone(&self.admission),
    +            caller_identity: self.caller_identity,
             });
     
             let queue_event = PolledWait::new(&self.driver, resources.event)
    @@ -447,6 +475,7 @@ async fn start_queue(
                         shared_memory_size: 0,
                         notify_corruption: self.notify_corruption.clone(),
                         admission: Arc::clone(&self.admission),
    +                    caller_identity: self.caller_identity,
                     })
                 });
             }
    @@ -608,6 +637,7 @@ struct VirtioFsWorker {
         shared_memory_size: u64,
         notify_corruption: Arc<dyn Fn() + Sync + Send>,
         admission: Arc<RequestAdmission>,
    +    caller_identity: Option<crate::CallerIdentity>,
     }
     
     struct VirtioFsQueue {
    @@ -692,6 +722,42 @@ fn process_virtiofs_request(
             mem,
             bytes_written: 0,
         };
    +    // Dispatch is synchronous on this thread, so a per-thread fsuid/fsgid
    +    // switch covers exactly this request. Fail closed if it is not permitted.
    +    #[cfg(unix)]
    +    let _credentials = match worker.caller_identity {
    +        // Inode bookkeeping and session teardown touch no host files.
    +        Some(_)
    +            if matches!(
    +                request.opcode(),
    +                fuse::protocol::FUSE_FORGET
    +                    | fuse::protocol::FUSE_BATCH_FORGET
    +                    | fuse::protocol::FUSE_DESTROY
    +            ) =>
    +        {
    +            None
    +        }
    +        Some(identity) => {
    +            let (uid, gid) = identity.map(request.uid(), request.gid());
    +            match lxutil::FsCredentials::switch(uid, gid) {
    +                Ok(guard) => Some(guard),
    +                Err(error) => {
    +                    tracelimit::error_ratelimited!(
    +                        uid,
    +                        gid,
    +                        "[virtiofs] cannot run request as the guest caller"
    +                    );
    +                    let _ = fuse::ReplySender::send_error(
    +                        &mut sender,
    +                        request.unique(),
    +                        error.value(),
    +                    );
    +                    return sender.bytes_written;
    +                }
    +            }
    +        }
    +        None => None,
    +    };
         let mapper = worker
             .shared_memory_region
             .as_ref()
  3. lpcox commented on Sep 26, 2026

    @lpcox
    Author

    Baseline from the AWF NVX build smoke (release v0.1.0-dev.d561c4300ebe, no virtio-fs)

    github/gh-aw-firewall#9027, run 36203009609: all checks pass (npm ci 863 packages + npm run build + Jest subset, Go build/test, network isolation). This is today's staging + copy-back path. Details that affect the sandbox --mount implementation:

    1. Workload identity is nvx_workload_uid=1001 nvx_workload_gid=1001, which is the runner uid that owns the workspace on the host. With --mount-owner caller, FUSE requests arrive as 1001, and root-squash maps to the export owner, which is also 1001. So AWF needs no uid translation. Please keep nvx_workload_uid/gid as the identity that FUSE requests carry, i.e. no user namespace remap inside the guest.
    2. The workspace is currently copied to scratch before building (tar /workspace -> /tmp/nvx-build/repo, excluding node_modules/.git/dist). This was done to keep node_modules out of the layer/copy-back. With a live share, the natural thing is to build in place, and npm ci then creates node_modules/.bin/* symlinks. The microVM profile's symlink() → ENOTSUP would make that fail. This is the most important parity item after ownership; Cloud Hypervisor + virtiofsd builds in place fine.
    3. Credential-named files are stripped from the workspace. NVX strips .npmrc (and similar) from every layer, including the workspace, so the smoke has to restate legacy-peer-deps via env. For --mount, please state the policy explicitly. Options: (a) a live share shows host files as-is and the embedder uses --mount-deny for secrets (virtiofsd parity; AWF would prefer this), or (b) a built-in deny list. If (b), make it overridable, and make it exact paths rather than a name pattern.
    4. Single vCPU guest. Jest has to run --runInBand. Metadata-heavy workloads (npm ci = tens of thousands of files) will be sensitive to the virtio-fs cache mode. For reference, AWF runs virtiofsd with --cache=auto --inode-file-handles=never. Please document which cache/attr timeouts sandbox --mount uses.
    5. Device/cmdline layout today: virtio_mmio devices at 0xd0000000..0xd0006000 for console, net, the distro layer (vda), the custom layer (vdb) and scratch ext4 (vdc). The patched init from the earlier comment reads virtfs_tag/virtfs_dir/virtfs_mode from the cmdline and mounts after the overlay is built. It must also be unmounted before the scratch EXT4-fs (vdc): unmounting step at teardown, as the prototype does.
    6. What AWF drops once this ships: the workspace EROFS layer, /run tmpfs sizing for it (the smoke has to grow /run), and copy-back (applied 8 path(s) here). A copy-back step that silently dropped files was a debugging pain point; a live share removes that class of bug.
    7. Egress is unchanged. Guest traffic still flows through the host api-proxy/Squid (registry.npmjs.org, github.com allowed; example.com denied). A virtio-fs share adds no network path. Please confirm the --mount device has no effect on the netns / egress policy.
  4. huzaifa-d commented on Sep 30, 2026

    @huzaifa-d

    Rechecked this on Windows/WHP on 30 September with NVX e0a3ddaf0b3a83f43aed765147bb329b57217862 and OpenVMM 885fde30f334dc18c1042a0acfe6a1b43c9a7f2b, using the stock v0.1.0-dev.84b8d9d3e3fa runtime. The release and selected source have matching runtime/guest inputs; their differences are documentation and benchmark data.

    The mount-integration gap is still reproducible:

    Path tested Result
    Native initramfs HostFs controls The owned host share was readable/writable; RO and denied-subpath controls also worked.
    Public nvx.py sandbox CLI --mount is rejected as an unrecognized argument.
    Direct OpenVMM sandbox mode with HostFs attached A real workload running as UID/GID 65534:65534 could not see /workspace or its known host marker. This occurred with RW, RO and denied-subpath configurations.

    The sandbox workload reported:

    NVX-SANDBOX-PROBE-START mode=rw uid=65534 gid=65534
    NVX-SANDBOX-WORKSPACE-DIRECTORY-ABSENT
    NVX-SANDBOX-SHARE-ABSENT
    

    No runtime or guest-initialization code was patched. The test entrypoint was supplied in an owned scratch image. Host share contents remained unchanged and the VMs were cleaned up.

    This confirms missing workload mount visibility; it does not count failed sandbox reads/writes as successful RO or denied-path enforcement. These checks were Windows-only and do not assess the Linux ownership or symlink-creation concerns raised above.

    Related requests from the MXC filesystem compatibility checks:

    Keeping this issue as the existing tracker for exposing HostFs inside the sandbox workload rather than creating a duplicate.

  5. lpcox commented on Sep 30, 2026

    @lpcox
    Author

    Superseded by updated requirements for live-sharing host directories with NVX via virtio-fs in sandbox mode to match Cloud Hypervisor parity. A new issue will track the updated requirements.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions