From 310e78f28b1d8cace1bb672bead449249bab22e5 Mon Sep 17 00:00:00 2001 From: Gauthier Jolly Date: Wed, 13 May 2026 17:13:47 +0200 Subject: [PATCH] fix(tools): report host view instead of snap mount namespace Strict-confined snaps with base core24 see /etc/os-release and /proc/self/mountinfo from the snap's mount namespace, not the host's, so system_info reported "Ubuntu Core 24", list_mounts was dominated by snap squashfs and bind-mount entries, and list_block_devices reported sda1 mounted at /var/lib/snapd/hostfs with /boot shadowed by the snap base. - system_info: prefer /var/lib/snapd/hostfs/{etc,usr/lib}/os-release when in a snap (granted by the existing system-observe plug). - list_mounts: read /proc/1/mountinfo instead of /proc/self/mountinfo when in a snap; PID 1 lives in the host's mount namespace. - list_block_devices: post-process the lsblk JSON, rewriting each device's mountpoint/mountpoints/fsroots from a major:minor index built from PID 1's mountinfo. - procfs: factor MountsForPID(pid) out of Mounts() to enable the above. Co-Authored-By: Claude Opus 4.7 --- internal/procfs/procfs.go | 9 ++++- internal/tools/block.go | 79 +++++++++++++++++++++++++++++++++++++++ internal/tools/mount.go | 18 ++++++++- internal/tools/system.go | 21 ++++++++++- 4 files changed, 124 insertions(+), 3 deletions(-) diff --git a/internal/procfs/procfs.go b/internal/procfs/procfs.go index 02ed778..fd47351 100644 --- a/internal/procfs/procfs.go +++ b/internal/procfs/procfs.go @@ -214,7 +214,14 @@ type MountEntry struct { // Mounts parses /proc/self/mountinfo following the format described in // Documentation/filesystems/proc.rst. func (r Root) Mounts() ([]MountEntry, error) { - f, err := os.Open(r.file("self", "mountinfo")) + return r.MountsForPID("self") +} + +// MountsForPID parses /proc//mountinfo. Useful for reading the host's +// mount table from inside a strictly-confined snap by passing "1" (host +// systemd lives in the host's mount namespace). +func (r Root) MountsForPID(pid string) ([]MountEntry, error) { + f, err := os.Open(r.file(pid, "mountinfo")) if err != nil { return nil, err } diff --git a/internal/tools/block.go b/internal/tools/block.go index bd9b2c1..cde365e 100644 --- a/internal/tools/block.go +++ b/internal/tools/block.go @@ -5,8 +5,12 @@ import ( "encoding/json" "fmt" "regexp" + "strconv" "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/gjolly/fleetmind/internal/procfs" + "github.com/gjolly/fleetmind/internal/snapconf" ) type listBlockIn struct { @@ -48,6 +52,20 @@ func registerBlock(s *mcp.Server, d Deps) { if err := json.Unmarshal(stdout, &parsed); err != nil { return nil, listBlockOut{}, fmt.Errorf("parse lsblk output: %w", err) } + // Inside a strictly-confined snap, lsblk uses libmount which reads + // /proc/self/mountinfo — the snap's mount namespace. That makes the + // host's rootfs appear mounted at /var/lib/snapd/hostfs, shadows real + // /boot with the snap base squashfs, and adds a long tail of + // /etc/*-bind-mount entries. Rewrite mountpoint/mountpoints/fsroots + // from PID 1's view (the host's mount namespace) so callers see the + // host's actual mount table. + if snapconf.InSnap() { + if idx, err := buildHostMountIndex(d.ProcFS); err == nil { + rewriteHostMounts(parsed, idx) + } + // Soft-fail: keep the raw lsblk output if /proc/1/mountinfo + // is unreadable for any reason. + } if nameRE != nil || len(in.Fields) > 0 { parsed = filterBlockDevices(parsed, nameRE, in.Fields) } @@ -55,6 +73,67 @@ func registerBlock(s *mcp.Server, d Deps) { }) } +// buildHostMountIndex maps "major:minor" → all mount entries for that device +// in PID 1's mount namespace (the host's view). The kernel allocates +// major:minor namespace-independently, so values agree between the snap's +// mount NS and the host's, making this a safe key. +func buildHostMountIndex(pf procfs.Root) (map[string][]procfs.MountEntry, error) { + entries, err := pf.MountsForPID("1") + if err != nil { + return nil, err + } + idx := make(map[string][]procfs.MountEntry, len(entries)) + for _, e := range entries { + key := strconv.Itoa(e.DevMajor) + ":" + strconv.Itoa(e.DevMinor) + idx[key] = append(idx[key], e) + } + return idx, nil +} + +// rewriteHostMounts walks the parsed lsblk JSON and overwrites each device's +// mountpoint/mountpoints/fsroots with the host-namespace view from idx, +// recursing into "children". +func rewriteHostMounts(parsed map[string]any, idx map[string][]procfs.MountEntry) { + devs, ok := parsed["blockdevices"].([]any) + if !ok { + return + } + for _, raw := range devs { + if m, ok := raw.(map[string]any); ok { + rewriteDeviceMounts(m, idx) + } + } +} + +func rewriteDeviceMounts(m map[string]any, idx map[string][]procfs.MountEntry) { + if mm, ok := m["maj:min"].(string); ok { + if entries := idx[mm]; len(entries) > 0 { + mps := make([]any, len(entries)) + roots := make([]any, len(entries)) + for i, e := range entries { + mps[i] = e.MountPoint + roots[i] = e.Root + } + m["mountpoint"] = entries[0].MountPoint + m["mountpoints"] = mps + m["fsroots"] = roots + } else if _, present := m["maj:min"]; present { + // Device exists but isn't mounted in the host NS — report as + // unmounted regardless of what the snap NS shows. + m["mountpoint"] = nil + m["mountpoints"] = []any{nil} + m["fsroots"] = []any{nil} + } + } + if kids, ok := m["children"].([]any); ok { + for _, kid := range kids { + if km, ok := kid.(map[string]any); ok { + rewriteDeviceMounts(km, idx) + } + } + } +} + // filterBlockDevices applies name_regex (top level only) and fields projection // (applied recursively to every device dict) to the parsed lsblk output. func filterBlockDevices(in map[string]any, nameRE *regexp.Regexp, fields []string) map[string]any { diff --git a/internal/tools/mount.go b/internal/tools/mount.go index 6ca0971..4aa3cda 100644 --- a/internal/tools/mount.go +++ b/internal/tools/mount.go @@ -7,6 +7,9 @@ import ( "syscall" "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/gjolly/fleetmind/internal/procfs" + "github.com/gjolly/fleetmind/internal/snapconf" ) type listMountsIn struct { @@ -47,7 +50,20 @@ func registerMount(s *mcp.Server, d Deps) { } mpRE = re } - entries, err := d.ProcFS.Mounts() + // Inside a strictly-confined snap, /proc/self/mountinfo is the snap's + // confined mount namespace (snap base bind-mounts, tmpfs overlays, + // every other snap's squashfs). Read PID 1's mountinfo instead — host + // systemd lives in the host's mount namespace, and the mount-observe + // plug grants read access to /proc/[pid]/mountinfo. + var ( + entries []procfs.MountEntry + err error + ) + if snapconf.InSnap() { + entries, err = d.ProcFS.MountsForPID("1") + } else { + entries, err = d.ProcFS.Mounts() + } if err != nil { return nil, listMountsOut{}, err } diff --git a/internal/tools/system.go b/internal/tools/system.go index 872689a..64fbcf7 100644 --- a/internal/tools/system.go +++ b/internal/tools/system.go @@ -8,6 +8,8 @@ import ( "syscall" "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/gjolly/fleetmind/internal/snapconf" ) type systemInfoIn struct{} @@ -59,8 +61,25 @@ func utsString(b []int8) string { } func readOSRelease() map[string]string { + // Inside a strictly-confined snap, /etc/os-release and /usr/lib/os-release + // are bind-mounted from the snap base (e.g. core24), so reading them + // reports "Ubuntu Core". snapd exposes the host's filesystem under + // /var/lib/snapd/hostfs, and the system-observe interface grants read + // access to os-release there — prefer those paths when in a snap. + var paths []string + if snapconf.InSnap() { + paths = []string{ + "/var/lib/snapd/hostfs/etc/os-release", + "/var/lib/snapd/hostfs/usr/lib/os-release", + "/etc/os-release", + "/usr/lib/os-release", + } + } else { + paths = []string{"/etc/os-release", "/usr/lib/os-release"} + } + out := map[string]string{} - for _, p := range []string{"/etc/os-release", "/usr/lib/os-release"} { + for _, p := range paths { f, err := os.Open(p) //nolint:gosec // fixed allowlist of os-release paths if err != nil { continue