diff --git a/cmd/Kconfig b/cmd/Kconfig
index 60ebb56de00..58e3f473bf3 100644
--- a/cmd/Kconfig
+++ b/cmd/Kconfig
@@ -2990,7 +2990,7 @@ config CMD_FS_LEGACY
 config CMD_VFS
 	bool "fs - virtual filesystem commands"
 	depends on VFS
-	default y if SANDBOX
+	default VFS
 	help
 	  Provides the 'fs' command with mount, umount and ls subcommands
 	  for the virtual filesystem layer.
diff --git a/cmd/Makefile b/cmd/Makefile
index 412a3096d0e..a4958843b40 100644
--- a/cmd/Makefile
+++ b/cmd/Makefile
@@ -94,7 +94,7 @@ obj-$(CONFIG_CMD_FPGA) += fpga.o
 obj-$(CONFIG_CMD_LUKS) += luks.o
 obj-$(CONFIG_CMD_FPGAD) += fpgad.o
 obj-$(CONFIG_CMD_FS_LEGACY) += fs_legacy.o
-obj-$(CONFIG_CMD_VFS) += vfs.o
+obj-$(CONFIG_CMD_VFS) += vfs.o fs.o
 obj-$(CONFIG_CMD_FUSE) += fuse.o
 obj-$(CONFIG_CMD_FWU_METADATA) += fwu_mdata.o
 obj-$(CONFIG_CMD_GETTIME) += gettime.o
diff --git a/cmd/fs.c b/cmd/fs.c
new file mode 100644
index 00000000000..21fe6ba3655
--- /dev/null
+++ b/cmd/fs.c
@@ -0,0 +1,262 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * VFS-based filesystem commands - mount, umount, ls, load
+ *
+ * These replace the legacy commands in cmd/fs_legacy.c with versions that
+ * use absolute paths through the virtual filesystem layer.
+ *
+ * Copyright 2026 Simon Glass <sjg@chromium.org>
+ */
+
+#include <command.h>
+#include <dm.h>
+#include <env.h>
+#include <file.h>
+#include <fs_legacy.h>
+#include <mapmem.h>
+#include <vfs.h>
+#include <dm/uclass.h>
+
+int do_load(struct cmd_tbl *cmdtp, int flag, int argc, char *const argv[],
+	    int fstype);
+int do_fs_types(struct cmd_tbl *cmdtp, int flag, int argc,
+		char *const argv[]);
+
+static int mount_handler(int argc, char *const argv[])
+{
+	struct udevice *vfs, *fsdev, *dir, *mnt;
+	const char *subpath;
+	int ret;
+
+	vfs = vfs_root();
+	if (!vfs)
+		return -ENXIO;
+
+	if (argc < 2) {
+		vfs_print_mounts();
+		return 0;
+	}
+
+	if (argc < 3)
+		return -EINVAL;
+
+	/* Check if already mounted */
+	ret = vfs_find_mount(vfs, argv[2], &mnt, &subpath);
+	if (!ret && mnt && !*subpath)
+		return -EBUSY;
+
+	ret = uclass_get_device_by_name(UCLASS_FS, argv[1], &fsdev);
+	if (ret)
+		return ret;
+
+	ret = vfs_resolve(vfs, argv[2], &dir);
+	if (ret)
+		return ret;
+
+	return vfs_mount(vfs, dir, fsdev);
+}
+
+static int do_mount(struct cmd_tbl *cmdtp, int flag, int argc,
+		    char *const argv[])
+{
+	int ret;
+
+	ret = mount_handler(argc, argv);
+	if (ret) {
+		printf("mount failed: %dE\n", ret);
+		return CMD_RET_FAILURE;
+	}
+
+	return CMD_RET_SUCCESS;
+}
+
+U_BOOT_CMD(
+	mount,	3,	1,	do_mount,
+	"mount a filesystem",
+	"[<dev> <mountpoint>]\n"
+	"    - With no args, list all mounts\n"
+	"    - Mount device 'dev' at 'mountpoint'"
+);
+
+static int umount_handler(const char *path)
+{
+	struct udevice *vfs;
+
+	vfs = vfs_root();
+	if (!vfs)
+		return -ENXIO;
+
+	return vfs_umount_path(vfs, path);
+}
+
+static int do_umount(struct cmd_tbl *cmdtp, int flag, int argc,
+		     char *const argv[])
+{
+	int ret;
+
+	if (argc < 2)
+		return CMD_RET_USAGE;
+
+	ret = umount_handler(argv[1]);
+	if (ret) {
+		printf("Error: %dE\n", ret);
+		return CMD_RET_FAILURE;
+	}
+
+	return CMD_RET_SUCCESS;
+}
+
+U_BOOT_CMD(
+	umount,	2,	1,	do_umount,
+	"unmount a filesystem",
+	"<mountpoint>\n"
+	"    - Unmount the filesystem at 'mountpoint'"
+);
+
+static int do_cd(struct cmd_tbl *cmdtp, int flag, int argc,
+		 char *const argv[])
+{
+	const char *path = argc >= 2 ? argv[1] : "/";
+	int ret;
+
+	ret = vfs_chdir(path);
+	if (ret) {
+		printf("Error: %dE\n", ret);
+		return CMD_RET_FAILURE;
+	}
+
+	return CMD_RET_SUCCESS;
+}
+
+U_BOOT_CMD(
+	cd,	2,	1,	do_cd,
+	"change working directory",
+	"[<path>]\n"
+	"    - Change to 'path' in the VFS (default /)"
+);
+
+static int do_pwd(struct cmd_tbl *cmdtp, int flag, int argc,
+		  char *const argv[])
+{
+	printf("%s\n", vfs_getcwd());
+
+	return CMD_RET_SUCCESS;
+}
+
+U_BOOT_CMD(
+	pwd,	1,	1,	do_pwd,
+	"print working directory",
+	"\n    - Print the current VFS working directory"
+);
+
+static int do_ls(struct cmd_tbl *cmdtp, int flag, int argc,
+		 char *const argv[])
+{
+	const char *path = argc >= 2 ? argv[1] : NULL;
+	int ret;
+
+	ret = vfs_ls(path);
+	if (ret) {
+		printf("Error: %dE\n", ret);
+		return CMD_RET_FAILURE;
+	}
+
+	return CMD_RET_SUCCESS;
+}
+
+U_BOOT_CMD(
+	ls,	2,	1,	do_ls,
+	"list files in a directory (default cwd)",
+	"[<path>]\n"
+	"    - List files at 'path' in the VFS (default cwd)"
+);
+
+
+static int do_vfs_load(struct cmd_tbl *cmdtp, int flag, int argc,
+		       char *const argv[])
+{
+	struct file_uc_priv *uc_priv;
+	ulong addr, bytes = 0;
+	struct udevice *fil;
+	long len_read;
+	loff_t pos = 0;
+	void *buf;
+	int ret;
+
+	if (argc < 3)
+		return CMD_RET_USAGE;
+
+	addr = hextoul(argv[1], NULL);
+	if (argc >= 4)
+		bytes = hextoul(argv[3], NULL);
+	if (argc >= 5)
+		pos = hextoull(argv[4], NULL);
+
+	ret = vfs_open_file(argv[2], DIR_O_RDONLY, &fil);
+	if (ret) {
+		printf("Error: %dE\n", ret);
+		return CMD_RET_FAILURE;
+	}
+
+	uc_priv = dev_get_uclass_priv(fil);
+	if (!bytes)
+		bytes = uc_priv->size - pos;
+
+	buf = map_sysmem(addr, bytes);
+	len_read = file_read_at(fil, buf, pos, bytes);
+	unmap_sysmem(buf);
+
+	if (len_read < 0) {
+		printf("Read failed: %ldE\n", len_read);
+		return CMD_RET_FAILURE;
+	}
+
+	env_set_hex("fileaddr", addr);
+	env_set_hex("filesize", len_read);
+
+	printf("%ld bytes read\n", len_read);
+
+	return CMD_RET_SUCCESS;
+}
+
+static int do_load_vfs(struct cmd_tbl *cmdtp, int flag, int argc,
+		       char *const argv[])
+{
+	char *endp;
+
+	/*
+	 * Detect legacy syntax: load <interface> [<dev[:part]> ...]
+	 * If argv[1] is not a pure hex number, assume legacy syntax.
+	 */
+	if (argc >= 2) {
+		hextoul(argv[1], &endp);
+		if (*endp)
+			return do_load(cmdtp, flag, argc, argv, FS_TYPE_ANY);
+	}
+
+	return do_vfs_load(cmdtp, flag, argc, argv);
+}
+
+static int do_fstypes(struct cmd_tbl *cmdtp, int flag, int argc,
+		      char *const argv[])
+{
+	return do_fs_types(cmdtp, flag, argc, argv);
+}
+
+U_BOOT_CMD(
+	fstypes, 1, 1, do_fstypes,
+	"List supported filesystem types", ""
+);
+
+U_BOOT_CMD(
+	load,	7,	0,	do_load_vfs,
+	"load binary file from a filesystem",
+	"<addr> <path> [bytes [pos]]\n"
+	"    - Load binary file from 'path' in the VFS to address 'addr'.\n"
+	"      'bytes' gives the size to load in bytes.\n"
+	"      If 'bytes' is 0 or omitted, the file is read until the end.\n"
+	"      'pos' gives the file byte position to start reading from.\n"
+	"      If 'pos' is 0 or omitted, the file is read from the start.\n"
+	"load <interface> [<dev[:part]> [<addr> [<filename> [bytes [pos]]]]]\n"
+	"    - Legacy: load from block device interface"
+);
diff --git a/fs/fs-uclass.c b/fs/fs-uclass.c
index 2525067f166..4492ff60522 100644
--- a/fs/fs-uclass.c
+++ b/fs/fs-uclass.c
@@ -46,6 +46,21 @@ int fs_split_path(const char *fname, char **subdirp, const char **leafp)
 	return 0;
 }
 
+void fs_split_path_inplace(char *fname, const char **dirp, const char **leafp)
+{
+	char *last_slash;
+
+	last_slash = strrchr(fname, '/');
+	if (last_slash) {
+		*leafp = last_slash + 1;
+		*last_slash = '\0';
+		*dirp = fname;
+	} else {
+		*leafp = fname;
+		*dirp = "";
+	}
+}
+
 int fs_lookup_dir(struct udevice *dev, const char *path, struct udevice **dirp)
 {
 	struct fs_ops *ops = fs_get_ops(dev);
diff --git a/fs/vfs.c b/fs/vfs.c
index 79b1ca0ef89..0cce5872735 100644
--- a/fs/vfs.c
+++ b/fs/vfs.c
@@ -408,8 +408,57 @@ static int vfs_resolve_mount(const char *path, char *resolved, int size,
 	return vfs_find_mount(vfs, path, mntp, subpathp);
 }
 
-int vfs_resolve(struct udevice *vfs, const char *path,
-		struct udevice **dirp)
+/**
+ * vfs_resolve_dir() - Resolve a path to its parent directory and leaf name
+ *
+ * Resolves the mount, splits the subpath into directory and leaf, and
+ * looks up the directory device.
+ *
+ * @path: Absolute or relative VFS path
+ * @dirp: Returns the UCLASS_DIR device for the parent directory
+ * @leafp: Returns allocated copy of the leaf filename (caller must free)
+ * Return: 0 if OK, -ve on error
+ */
+static int vfs_resolve_dir(const char *path, struct udevice **dirp,
+			   char **leafp)
+{
+	char resolved[FILE_MAX_PATH_LEN];
+	struct udevice *mnt;
+	struct vfsmount *mnt_priv;
+	const char *subpath, *dirpart, *leaf;
+	char *sub;
+	int ret;
+
+	ret = vfs_resolve_mount(path, resolved, sizeof(resolved),
+				&mnt, &subpath);
+	if (ret)
+		return log_msg_ret("vdm", ret);
+
+	if (!mnt)
+		return log_msg_ret("vdr", -ENOENT);
+
+	mnt_priv = dev_get_uclass_priv(mnt);
+
+	/*
+	 * Split in place - subpath points into resolved[], so we can
+	 * modify it. After the split, dirpart is the directory portion
+	 * and leaf points to the leaf filename.
+	 */
+	sub = (char *)subpath;
+	fs_split_path_inplace(sub, &dirpart, &leaf);
+
+	ret = fs_lookup_dir(mnt_priv->target, dirpart, dirp);
+	if (ret)
+		return log_msg_ret("vdd", ret);
+
+	*leafp = strdup(leaf);
+	if (!*leafp)
+		return log_msg_ret("vdl", -ENOMEM);
+
+	return 0;
+}
+
+int vfs_resolve(struct udevice *vfs, const char *path, struct udevice **dirp)
 {
 	struct udevice *cur_fs, *best;
 	const char *remain;
@@ -501,34 +550,18 @@ void vfs_print_mounts(void)
 int vfs_open_file(const char *path, enum dir_open_flags_t oflags,
 		  struct udevice **filp)
 {
-	struct udevice *vfs, *mnt, *dir;
-	const char *subpath, *leaf;
-	struct vfsmount *mnt_priv;
-	char *dirpath;
+	struct udevice *dir;
+	char *leaf;
 	int ret;
 
-	vfs = vfs_root();
-	if (!vfs)
-		return log_msg_ret("voi", -ENXIO);
-
-	ret = vfs_find_mount(vfs, path, &mnt, &subpath);
-	if (ret)
-		return log_msg_ret("vom", ret);
-
-	mnt_priv = dev_get_uclass_priv(mnt);
-
-	ret = fs_split_path(subpath, &dirpath, &leaf);
-	if (ret)
-		return log_msg_ret("vos", ret);
-
-	ret = fs_lookup_dir(mnt_priv->target, dirpath, &dir);
-	free(dirpath);
+	ret = vfs_resolve_dir(path, &dir, &leaf);
 	if (ret)
-		return log_msg_ret("vod", ret);
+		return log_msg_ret("vof", ret);
 
 	ret = dir_open_file(dir, leaf, oflags, filp);
+	free(leaf);
 	if (ret)
-		return log_msg_ret("vof", ret);
+		return log_msg_ret("voo", ret);
 
 	return 0;
 }
diff --git a/include/fs.h b/include/fs.h
index c6b6323be3e..efca9b80611 100644
--- a/include/fs.h
+++ b/include/fs.h
@@ -122,4 +122,18 @@ int fs_lookup_dir(struct udevice *dev, const char *path, struct udevice **dirp);
  */
 int fs_split_path(const char *fname, char **subdirp, const char **leafp);
 
+/**
+ * fs_split_path_inplace() - Split a path into directory and leaf in place
+ *
+ * Modifies @fname by null-terminating at the last '/'. Sets @dirp to
+ * point to the directory part and @leafp to the leaf. If there is no
+ * '/', @dirp is set to "" and @leafp points to @fname unchanged.
+ *
+ * @fname: Path to split (modified in place when it contains '/')
+ * @dirp: Returns pointer to the directory part
+ * @leafp: Returns pointer to the leaf filename
+ */
+void fs_split_path_inplace(char *fname, const char **dirp,
+			   const char **leafp);
+
 #endif
diff --git a/test/py/tests/test_fs/test_basic.py b/test/py/tests/test_fs/test_basic.py
index 174e2e074f4..17e197df894 100644
--- a/test/py/tests/test_fs/test_basic.py
+++ b/test/py/tests/test_fs/test_basic.py
@@ -16,7 +16,8 @@ from fstest_defs import SMALL_FILE, BIG_FILE
 from fstest_helpers import assert_fs_integrity
 
 
-@pytest.mark.boardspec('sandbox')
+@pytest.mark.buildconfigspec('sandbox')
+@pytest.mark.boardspec('!sandbox')
 @pytest.mark.slow
 class TestFsBasic:
     """Test basic filesystem operations via C unit tests."""
diff --git a/test/py/tests/test_fs/test_ext.py b/test/py/tests/test_fs/test_ext.py
index 41f126e7876..a72e13a87bc 100644
--- a/test/py/tests/test_fs/test_ext.py
+++ b/test/py/tests/test_fs/test_ext.py
@@ -26,7 +26,8 @@ def str2fat(long_filename):
         name = '%s~1' % name[:6]
     return '%-8s %s' % (name, ext)
 
-@pytest.mark.boardspec('sandbox')
+@pytest.mark.buildconfigspec('sandbox')
+@pytest.mark.boardspec('!sandbox')
 @pytest.mark.slow
 class TestFsExt(object):
     def test_fs_ext1(self, ubman, fs_obj_ext):
diff --git a/test/py/tests/test_fs/test_ext4l.py b/test/py/tests/test_fs/test_ext4l.py
index 0930ebd01ea..eb332d1b154 100644
--- a/test/py/tests/test_fs/test_ext4l.py
+++ b/test/py/tests/test_fs/test_ext4l.py
@@ -15,7 +15,8 @@ from tempfile import NamedTemporaryFile
 import pytest
 
 
-@pytest.mark.boardspec('sandbox')
+@pytest.mark.buildconfigspec('sandbox')
+@pytest.mark.buildconfigspec('fs_ext4l')
 class TestExt4l:
     """Test ext4l filesystem operations."""
 
@@ -79,6 +80,7 @@ class TestExt4l:
         with ubman.log.section('Test ext4l msgs'):
             ubman.run_ut('fs', 'fs_test_ext4l_msgs', fs_image=ext4_image)
 
+    @pytest.mark.boardspec('!sandbox')
     def test_ls(self, ubman, ext4_image):
         """Test that ext4l can list directory contents."""
         with ubman.log.section('Test ext4l ls'):
@@ -114,6 +116,7 @@ class TestExt4l:
         with ubman.log.section('Test ext4l statfs'):
             ubman.run_ut('fs', 'fs_test_ext4l_statfs', fs_image=ext4_image)
 
+    @pytest.mark.boardspec('!sandbox')
     def test_fsinfo(self, ubman, ext4_image):
         """Test that fsinfo command displays filesystem statistics."""
         with ubman.log.section('Test ext4l fsinfo'):
diff --git a/test/py/tests/test_fs/test_fs_cmd.py b/test/py/tests/test_fs/test_fs_cmd.py
index c925547c7bc..336300afe3d 100644
--- a/test/py/tests/test_fs/test_fs_cmd.py
+++ b/test/py/tests/test_fs/test_fs_cmd.py
@@ -4,7 +4,8 @@
 
 import pytest
 
-@pytest.mark.boardspec('sandbox')
+@pytest.mark.buildconfigspec('sandbox')
+@pytest.mark.boardspec('!sandbox')
 @pytest.mark.buildconfigspec('cmd_fs_generic')
 def test_fstypes(ubman):
     """Test that `fstypes` prints a result which includes `sandbox`."""
diff --git a/test/py/tests/test_fs/test_fs_fat.py b/test/py/tests/test_fs/test_fs_fat.py
index b61d8ab9eac..8028213dae3 100644
--- a/test/py/tests/test_fs/test_fs_fat.py
+++ b/test/py/tests/test_fs/test_fs_fat.py
@@ -11,7 +11,8 @@ This test verifies fat specific file system behaviour.
 import pytest
 import re
 
-@pytest.mark.boardspec('sandbox')
+@pytest.mark.buildconfigspec('sandbox')
+@pytest.mark.boardspec('!sandbox')
 @pytest.mark.slow
 class TestFsFat(object):
     def test_fs_fat1(self, ubman, fs_obj_fat):
