Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cmd/bbox-init/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ func main() {
return
}

// Make /home/sandbox writable. On certain host kernels (e.g. openSUSE
// Make /home/sandbox writable and reconcile ownership of host-injected files.
// On certain host kernels (e.g. openSUSE
// MicroOS / Tumbleweed), the root virtiofs rejects writes even though
// the mount is nominally rw. A tmpfs on the home directory works
// around this so agents can create config files.
Expand Down
24 changes: 19 additions & 5 deletions internal/guest/homefs/copy.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,27 @@ func copyOwnership(src, dst string) {
_ = os.Lchown(dst, int(stat.Uid), int(stat.Gid))
}

// chownRecursive sets uid:gid on all entries under root. Failures are
// logged rather than silently discarded because a failed chown means
// the sandbox user cannot access its own SSH keys or config files.
// chownRecursive sets uid:gid on all entries under root. Walk errors and
// chown errors are logged and the walk continues; a failed chown means
// the sandbox user cannot access that one path. Entries already owned by
// uid:gid are skipped to avoid triggering overlayfs copy-up on every file
// when the home tree is already correctly owned (the Linux common case).
func chownRecursive(root string, uid, gid int, logger *slog.Logger) {
_ = filepath.WalkDir(root, func(path string, _ fs.DirEntry, err error) error {
_ = filepath.WalkDir(root, func(path string, _ fs.DirEntry, walkErr error) error {
if walkErr != nil {
logger.Warn("walk error during chown, continuing",
"path", path, "error", walkErr)
return nil
}
info, err := os.Lstat(path)
if err != nil {
return err
logger.Warn("lstat failed during chown, continuing",
"path", path, "error", err)
return nil
}
stat, ok := info.Sys().(*syscall.Stat_t)
if ok && int(stat.Uid) == uid && int(stat.Gid) == gid {
return nil
}
if err := os.Lchown(path, uid, gid); err != nil {
logger.Warn("chown failed, sandbox user may lack access",
Expand Down
37 changes: 20 additions & 17 deletions internal/guest/homefs/homefs.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,20 @@ const (
overlayWork = "/tmp/.home-work"
)

// MakeWritable mounts an overlayfs on the sandbox home directory so that
// writes go to a tmpfs-backed upper layer. The original virtiofs contents
// remain visible as the lower layer. If overlayfs is not supported, a
// tmpfs + copy fallback is used. This must be called before seccomp
// blocks mount(2).
//
// If the home directory is already writable, this is a no-op.
// MakeWritable ensures the sandbox user's home directory is both writable
// and owned by the sandbox user. It mounts an overlayfs (with tmpfs upper)
// when the underlying virtiofs rejects writes, falling back to a tmpfs +
// copy approach on kernels without overlayfs. It then recursively chowns
// the tree so host-injected files (settings, credentials, skills) are
// readable by the sandbox user; macOS host-side rootfs hooks cannot chown
// to UID 1000, so reconciliation must happen inside the guest. Must be
// called before seccomp blocks mount(2).
func MakeWritable(logger *slog.Logger, home string, uid, gid int) error {
// Reconcile ownership unconditionally on return so host-injected files
// are readable by the sandbox user even if the mount step fails. On a
// read-only FS, Lchown returns EROFS and is logged as a warning.
defer chownRecursive(home, uid, gid, logger)

// Probe writability as the sandbox user (not root). We run as PID 1
// (root), so os.Create() would always succeed on a normal FS — we
// must test as the actual sandbox user to catch permission issues.
Expand All @@ -51,7 +57,12 @@ func MakeWritable(logger *slog.Logger, home string, uid, gid int) error {

logger.Info("home directory is read-only via virtiofs, mounting overlay",
"home", home)
return mountOverlayOrTmpfs(logger, home)
}

// mountOverlayOrTmpfs mounts an overlayfs with a tmpfs upper layer on home,
// falling back to a plain tmpfs + copy when overlayfs is unavailable.
func mountOverlayOrTmpfs(logger *slog.Logger, home string) error {
// Create upper and work directories on tmpfs.
for _, dir := range []string{overlayUpper, overlayWork} {
if err := os.MkdirAll(dir, 0o700); err != nil {
Expand All @@ -66,12 +77,7 @@ func MakeWritable(logger *slog.Logger, home string, uid, gid int) error {
if err := syscall.Mount("overlay", home, "overlay", 0, opts); err != nil {
logger.Warn("overlayfs mount failed, falling back to tmpfs copy",
"error", err)
return fallbackTmpfs(logger, home, uid, gid)
}

// Chown the overlay mount point so the sandbox user owns it.
if err := os.Chown(home, uid, gid); err != nil {
logger.Warn("chown overlay mount point failed", "error", err)
return fallbackTmpfs(logger, home)
}

logger.Info("overlay mounted on home directory", "home", home)
Expand Down Expand Up @@ -136,7 +142,7 @@ func probeWritableAs(dir string, uid, gid int) bool {
// fallbackTmpfs is used when overlayfs is not available in the guest kernel.
// It mounts a tmpfs directly on the home directory after copying existing
// contents into a staging area.
func fallbackTmpfs(logger *slog.Logger, home string, uid, gid int) error {
func fallbackTmpfs(logger *slog.Logger, home string) error {
logger.Info("falling back to tmpfs + copy for writable home")

staging := "/tmp/.home-staging"
Expand Down Expand Up @@ -164,9 +170,6 @@ func fallbackTmpfs(logger *slog.Logger, home string, uid, gid int) error {
return fmt.Errorf("restoring home from staging: %w", err)
}

// Fix ownership.
chownRecursive(home, uid, gid, logger)

// Clean up staging.
_ = os.RemoveAll(staging)

Expand Down