Repository: apache/buildstream
Issue: SandboxFlags API allows too much control, remove some

[See original issue on GitLab](https://gitlab.com/BuildStream/buildstream/-/issues/265)
In GitLab by [[Gitlab user @tristanvb]](https://gitlab.com/tristanvb) on Feb 22, 2018, 12:24

Currently the `SandboxFlags` argument for sandboxes allows plugins too much control on how to use a sandbox.

With the exception of `SandboxFlags.ROOT_READ_ONLY`, the remaining flags are only for internal use, so that we can control the behavior of a sandbox in a `bst shell` environment.

As a result, we should remove the remaining options from here while retaining `ROOT_READ_ONLY`, technically this is an API break; but it can only break for plugins who are doing something illegal in the first place.

Changed files:
src/buildstream/sandbox/__init__.py, src/buildstream/sandbox/_sandboxdummy.py, src/buildstream/sandbox/_sandboxreapi.py, src/buildstream/plugins/elements/manual.py, src/buildstream/scriptelement.py, src/buildstream/plugins/elements/compose.py, src/buildstream/plugins/elements/autotools.py, src/buildstream/element.py, src/buildstream/sandbox/sandbox.py, src/buildstream/sandbox/_sandboxbuildboxrun.py, src/buildstream/buildelement.py, src/buildstream/__init__.py

diff --git a/src/buildstream/__init__.py b/src/buildstream/__init__.py
index aad334477..b710431fc 100644
--- a/src/buildstream/__init__.py
+++ b/src/buildstream/__init__.py
@@ -28,7 +28,7 @@ if "_BST_COMPLETION" not in os.environ:
     del get_versions
 
     from .utils import UtilError, ProgramNotFoundError
-    from .sandbox import Sandbox, SandboxFlags, SandboxCommandError
+    from .sandbox import Sandbox, SandboxCommandError
     from .storage import Directory
     from .types import CoreWarnings, OverlapAction
     from .node import MappingNode, Node, ProvenanceInformation, ScalarNode, SequenceNode
diff --git a/src/buildstream/buildelement.py b/src/buildstream/buildelement.py
index 7f672f968..5bdd86180 100644
--- a/src/buildstream/buildelement.py
+++ b/src/buildstream/buildelement.py
@@ -1,5 +1,5 @@
 #
-#  Copyright (C) 2016 Codethink Limited
+#  Copyright (C) 2022 Codethink Limited
 #  Copyright (C) 2018 Bloomberg Finance LP
 #
 #  Licensed under the Apache License, Version 2.0 (the "License");
@@ -171,7 +171,6 @@ artifact collection purposes.
 import os
 
 from .element import Element
-from .sandbox import SandboxFlags
 
 
 _command_steps = ["configure-commands", "build-commands", "install-commands", "strip-commands"]
@@ -287,7 +286,7 @@ class BuildElement(Element):
         root_list = self.__layout.get("/", None)
         if root_list:
             element_list = [element for element, _ in root_list]
-            with self.timed_activity("Integrating sandbox", silent_nested=True), sandbox.batch(SandboxFlags.NONE):
+            with self.timed_activity("Integrating sandbox", silent_nested=True), sandbox.batch():
                 for dep in self.dependencies(element_list):
                     dep.integrate(sandbox)
 
@@ -301,7 +300,7 @@ class BuildElement(Element):
             if not commands or command_name == "configure-commands":
                 continue
 
-            with sandbox.batch(SandboxFlags.ROOT_READ_ONLY, label="Running {}".format(command_name)):
+            with sandbox.batch(root_read_only=True, label="Running {}".format(command_name)):
                 for cmd in commands:
                     self.__run_command(sandbox, cmd)
 
@@ -347,7 +346,7 @@ class BuildElement(Element):
                 # Already prepared
                 return
 
-        with sandbox.batch(SandboxFlags.ROOT_READ_ONLY, label="Running configure-commands"):
+        with sandbox.batch(root_read_only=True, label="Running configure-commands"):
             for cmd in commands:
                 self.__run_command(sandbox, cmd)
 
@@ -371,4 +370,4 @@ class BuildElement(Element):
         # Note the -e switch to 'sh' means to exit with an error
         # if any untested command fails.
         #
-        sandbox.run(["sh", "-c", "-e", cmd + "\\n"], SandboxFlags.ROOT_READ_ONLY, label=cmd)
+        sandbox.run(["sh", "-c", "-e", cmd + "\\n"], root_read_only=True, label=cmd)
diff --git a/src/buildstream/element.py b/src/buildstream/element.py
index 92a4757bf..ee7e76c06 100644
--- a/src/buildstream/element.py
+++ b/src/buildstream/element.py
@@ -1,5 +1,5 @@
 #
-#  Copyright (C) 2016-2020 Codethink Limited
+#  Copyright (C) 2016-2022 Codethink Limited
 #  Copyright (C) 2017-2020 Bloomberg Finance LP
 #
 #  Licensed under the Apache License, Version 2.0 (the "License");
@@ -98,7 +98,7 @@ from . import _cachekey
 from . import _site
 from .node import Node
 from .plugin import Plugin
-from .sandbox import SandboxFlags, SandboxCommandError
+from .sandbox import _SandboxFlags, SandboxCommandError
 from .sandbox._config import SandboxConfig
 from .sandbox._sandboxremote import SandboxRemote
 from .types import _Scope, _CacheBuildTrees, _KeyStrength, OverlapAction, _DisplayKey
@@ -302,7 +302,9 @@ class Element(Plugin):
         self.__sandbox_config = None  # type: Optional[SandboxConfig]
 
         self.__batch_prepare_assemble = False  # Whether batching across prepare()/assemble() is configured
-        self.__batch_prepare_assemble_flags = 0  # Sandbox flags for batching across prepare()/assemble()
+        self.__batch_prepare_assemble_rro = (
+            False  # Whether the root is readonly when batching across prepare()/assemble()
+        )
         # Collect dir for batching across prepare()/assemble()
         self.__batch_prepare_assemble_collect = None  # type: Optional[str]
 
@@ -739,9 +741,9 @@ class Element(Plugin):
         environment = self.get_environment()
 
         if bstdata is not None:
-            with sandbox.batch(SandboxFlags.NONE):
+            with sandbox.batch():
                 for command in bstdata.get_str_list("integration-commands", []):
-                    sandbox.run(["sh", "-e", "-c", command], 0, env=environment, cwd="/", label=command)
+                    sandbox.run(["sh", "-e", "-c", command], env=environment, cwd="/", label=command)
 
     def stage_sources(self, sandbox: "Sandbox", directory: str) -> None:
         """Stage this element's sources to a directory in the sandbox
@@ -819,11 +821,11 @@ class Element(Plugin):
         assert self.__variables
         return self.__variables.get(varname)
 
-    def batch_prepare_assemble(self, flags: int, *, collect: Optional[str] = None) -> None:
+    def batch_prepare_assemble(self, *, root_read_only: bool = False, collect: Optional[str] = None) -> None:
         """ Configure command batching across prepare() and assemble()
 
         Args:
-           flags: The :class:`.SandboxFlags` for the command batch
+           root_read_only: Whether the filesystem root should be read only
            collect: An optional directory containing partial install contents
                     on command failure.
 
@@ -834,7 +836,7 @@ class Element(Plugin):
             raise ElementError("{}: Command batching for prepare/assemble is already configured".format(self))
 
         self.__batch_prepare_assemble = True
-        self.__batch_prepare_assemble_flags = flags
+        self.__batch_prepare_assemble_rro = root_read_only
         self.__batch_prepare_assemble_collect = collect
 
     #############################################################
@@ -1446,7 +1448,7 @@ class Element(Plugin):
                 # Run any integration commands provided by the dependencies
                 # once they are all staged and ready
                 if integrate:
-                    with self.timed_activity("Integrating sandbox"), sandbox.batch(SandboxFlags.NONE):
+                    with self.timed_activity("Integrating sandbox"), sandbox.batch():
                         for dep in self._dependencies(scope):
                             dep.integrate(sandbox)
 
@@ -1725,7 +1727,8 @@ class Element(Plugin):
                 try:
                     if self.__batch_prepare_assemble:
                         cm = sandbox.batch(
-                            self.__batch_prepare_assemble_flags, collect=self.__batch_prepare_assemble_collect
+                            root_read_only=self.__batch_prepare_assemble_rro,
+                            collect=self.__batch_prepare_assemble_collect,
                         )
                     else:
                         cm = contextlib.suppress()
@@ -2002,7 +2005,7 @@ class Element(Plugin):
         with self._prepare_sandbox(scope, shell=True, usebuildtree=usebuildtree) as sandbox:
             environment = self.get_environment()
             environment = copy.copy(environment)
-            flags = SandboxFlags.INTERACTIVE | SandboxFlags.ROOT_READ_ONLY
+            flags = _SandboxFlags.INTERACTIVE | _SandboxFlags.ROOT_READ_ONLY
 
             # Fetch the main toplevel project, in case this is a junctioned
             # subproject, we want to use the rules defined by the main one.
@@ -2018,7 +2021,7 @@ class Element(Plugin):
 
                 # Open the network, and reuse calling uid/gid
                 #
-                flags |= SandboxFlags.NETWORK_ENABLED | SandboxFlags.INHERIT_UID
+                flags |= _SandboxFlags.NETWORK_ENABLED | _SandboxFlags.INHERIT_UID
 
                 # Apply project defined environment vars to set for a shell
                 for key, value in shell_environment.items():
@@ -2044,7 +2047,7 @@ class Element(Plugin):
             self.status("Running command", detail=" ".join(argv))
 
             # Run shells with network enabled and readonly root.
-            return sandbox.run(argv, flags, env=environment)
+            return sandbox._run_with_flags(argv, flags=flags, env=environment)
 
     # _open_workspace():
     #
diff --git a/src/buildstream/plugins/elements/autotools.py b/src/buildstream/plugins/elements/autotools.py
index cc7a17771..b5b8d432d 100644
--- a/src/buildstream/plugins/elements/autotools.py
+++ b/src/buildstream/plugins/elements/autotools.py
@@ -54,7 +54,7 @@ See :ref:`built-in functionality documentation <core_buildelement_builtins>` for
 details on common configuration options for build elements.
 """
 
-from buildstream import BuildElement, SandboxFlags
+from buildstream import BuildElement
 
 
 # Element implementation for the 'autotools' kind.
@@ -66,7 +66,7 @@ class AutotoolsElement(BuildElement):
     # Enable command batching across prepare() and assemble()
     def configure_sandbox(self, sandbox):
         super().configure_sandbox(sandbox)
-        self.batch_prepare_assemble(SandboxFlags.ROOT_READ_ONLY, collect=self.get_variable("install-root"))
+        self.batch_prepare_assemble(root_read_only=True, collect=self.get_variable("install-root"))
 
 
 # Plugin entry point
diff --git a/src/buildstream/plugins/elements/compose.py b/src/buildstream/plugins/elements/compose.py
index ebf7d1c98..36cf96cd6 100644
--- a/src/buildstream/plugins/elements/compose.py
+++ b/src/buildstream/plugins/elements/compose.py
@@ -114,7 +114,7 @@ class ComposeElement(Element):
                     snapshot = set(vbasedir.list_relative_paths())
                     vbasedir.mark_unmodified()
 
-                with sandbox.batch(0):
+                with sandbox.batch():
                     for dep in self.dependencies():
                         dep.integrate(sandbox)
 
diff --git a/src/buildstream/plugins/elements/manual.py b/src/buildstream/plugins/elements/manual.py
index e1984b28c..405e39ea6 100644
--- a/src/buildstream/plugins/elements/manual.py
+++ b/src/buildstream/plugins/elements/manual.py
@@ -30,7 +30,7 @@ See :ref:`built-in functionality documentation <core_buildelement_builtins>` for
 details on common configuration options for build elements.
 """
 
-from buildstream import BuildElement, SandboxFlags
+from buildstream import BuildElement
 
 
 # Element implementation for the 'manual' kind.
@@ -42,7 +42,7 @@ class ManualElement(BuildElement):
     # Enable command batching across prepare() and assemble()
     def configure_sandbox(self, sandbox):
         super().configure_sandbox(sandbox)
-        self.batch_prepare_assemble(SandboxFlags.ROOT_READ_ONLY, collect=self.get_variable("install-root"))
+        self.batch_prepare_assemble(root_read_only=True, collect=self.get_variable("install-root"))
 
 
 # Plugin entry point
diff --git a/src/buildstream/sandbox/__init__.py b/src/buildstream/sandbox/__init__.py
index 8b1ca1980..7564995e5 100644
--- a/src/buildstream/sandbox/__init__.py
+++ b/src/buildstream/sandbox/__init__.py
@@ -1,5 +1,5 @@
 #
-#  Copyright (C) 2017 Codethink Limited
+#  Copyright (C) 2022 Codethink Limited
 #
 #  Licensed under the Apache License, Version 2.0 (the "License");
 #  you may not use this file except in compliance with the License.
@@ -16,6 +16,7 @@
 #  Authors:
 #        Tristan Maat <tristan.maat@codethink.co.uk>
 
-from .sandbox import Sandbox, SandboxFlags, SandboxCommandError
+from .sandbox import Sandbox, SandboxCommandError
+from .sandbox import _SandboxFlags
 from ._sandboxremote import SandboxRemote
 from ._sandboxdummy import SandboxDummy
diff --git a/src/buildstream/sandbox/_sandboxbuildboxrun.py b/src/buildstream/sandbox/_sandboxbuildboxrun.py
index 5aec9fbec..b3504b223 100644
--- a/src/buildstream/sandbox/_sandboxbuildboxrun.py
+++ b/src/buildstream/sandbox/_sandboxbuildboxrun.py
@@ -22,7 +22,7 @@ from contextlib import ExitStack
 import psutil
 
 from .. import utils, _signals
-from . import SandboxFlags
+from . import _SandboxFlags
 from .._exceptions import SandboxError
 from .._platform import Platform
 from .._protos.build.bazel.remote.execution.v2 import remote_execution_pb2
@@ -121,7 +121,7 @@ class SandboxBuildBoxRun(SandboxREAPI):
             # If we're interactive, we want to inherit our stdin,
             # otherwise redirect to /dev/null, ensuring process
             # disconnected from terminal.
-            if flags & SandboxFlags.INTERACTIVE:
+            if flags & _SandboxFlags.INTERACTIVE:
                 stdin = sys.stdin
 
                 if "bind-mount" in self._capabilities:
@@ -132,7 +132,7 @@ class SandboxBuildBoxRun(SandboxREAPI):
                 stdin = subprocess.DEVNULL
 
             self._run_buildbox(
-                buildbox_command, stdin, stdout, stderr, interactive=(flags & SandboxFlags.INTERACTIVE),
+                buildbox_command, stdin, stdout, stderr, interactive=(flags & _SandboxFlags.INTERACTIVE),
             )
 
             return remote_execution_pb2.ActionResult().FromString(result_file.read())
diff --git a/src/buildstream/sandbox/_sandboxdummy.py b/src/buildstream/sandbox/_sandboxdummy.py
index 588cc2864..9db651843 100644
--- a/src/buildstream/sandbox/_sandboxdummy.py
+++ b/src/buildstream/sandbox/_sandboxdummy.py
@@ -24,7 +24,7 @@ class SandboxDummy(Sandbox):
         super().__init__(*args, **kwargs)
         self._reason = kwargs.get("dummy_reason", "no reason given")
 
-    def _run(self, command, flags, *, cwd, env):
+    def _run(self, command, *, flags, cwd, env):
 
         if not self._has_command(command[0], env):
             raise SandboxCommandError(
diff --git a/src/buildstream/sandbox/_sandboxreapi.py b/src/buildstream/sandbox/_sandboxreapi.py
index a3658a978..d118bb7f1 100644
--- a/src/buildstream/sandbox/_sandboxreapi.py
+++ b/src/buildstream/sandbox/_sandboxreapi.py
@@ -16,7 +16,7 @@
 import os
 import shlex
 
-from .sandbox import Sandbox, SandboxFlags, SandboxCommandError, _SandboxBatch
+from .sandbox import Sandbox, _SandboxFlags, SandboxCommandError, _SandboxBatch
 from .. import utils
 from .._exceptions import ImplError, SandboxError
 from .._protos.build.bazel.remote.execution.v2 import remote_execution_pb2
@@ -33,7 +33,7 @@ class SandboxREAPI(Sandbox):
 
         self._output_node_properties = kwargs.get("output_node_properties")
 
-    def _run(self, command, flags, *, cwd, env):
+    def _run(self, command, *, flags, cwd, env):
         context = self._get_context()
         cascache = context.get_cascache()
 
@@ -53,7 +53,7 @@ class SandboxREAPI(Sandbox):
         # Ensure directories required for sandboxed execution exist
         for directory in ["dev", "proc", "tmp"]:
             vsubdir = vdir.descend(directory, create=True)
-            if flags & SandboxFlags.ROOT_READ_ONLY:
+            if flags & _SandboxFlags.ROOT_READ_ONLY:
                 vsubdir._set_subtree_read_only(False)
 
         # Create directories for all marked directories. This emulates
@@ -83,10 +83,10 @@ class SandboxREAPI(Sandbox):
                 # Read-write directory
                 marked_vdir = vdir.descend(*directory.split(os.path.sep), create=True)
                 read_write_directories.append(directory)
-                if flags & SandboxFlags.ROOT_READ_ONLY:
+                if flags & _SandboxFlags.ROOT_READ_ONLY:
                     marked_vdir._set_subtree_read_only(False)
 
-        if flags & SandboxFlags.ROOT_READ_ONLY:
+        if flags & _SandboxFlags.ROOT_READ_ONLY:
             vdir._set_subtree_read_only(True)
         else:
             # The whole sandbox is writable
@@ -125,7 +125,7 @@ class SandboxREAPI(Sandbox):
         platform_dict["OSFamily"] = config.build_os
         platform_dict["ISA"] = config.build_arch
 
-        if flags & SandboxFlags.INHERIT_UID:
+        if flags & _SandboxFlags.INHERIT_UID:
             uid = os.geteuid()
             gid = os.getegid()
         else:
@@ -136,7 +136,7 @@ class SandboxREAPI(Sandbox):
         if gid is not None:
             platform_dict["unixGID"] = str(gid)
 
-        if flags & SandboxFlags.NETWORK_ENABLED:
+        if flags & _SandboxFlags.NETWORK_ENABLED:
             platform_dict["network"] = "on"
 
         # Remove unsupported platform properties from the dict
@@ -228,7 +228,12 @@ class _SandboxREAPIBatch(_SandboxBatch):
                 detail=self.main_group.combined_label(),
                 element_name=self.sandbox._get_element_name(),
             ):
-                if self.sandbox.run(["sh", "-c", "-e", self.script], self.flags, cwd=first.cwd, env=first.env) != 0:
+                if (
+                    self.sandbox._run_with_flags(
+                        ["sh", "-c", "-e", self.script], flags=self.flags, cwd=first.cwd, env=first.env
+                    )
+                    != 0
+                ):
                     raise SandboxCommandError("Command failed", collect=self.collect)
 
     def execute_group(self, group):
diff --git a/src/buildstream/sandbox/sandbox.py b/src/buildstream/sandbox/sandbox.py
index 7e081b2fc..16d871d8a 100644
--- a/src/buildstream/sandbox/sandbox.py
+++ b/src/buildstream/sandbox/sandbox.py
@@ -1,5 +1,5 @@
 #
-#  Copyright (C) 2017 Codethink Limited
+#  Copyright (C) 2022 Codethink Limited
 #  Copyright (C) 2018 Bloomberg Finance LP
 #
 #  Licensed under the Apache License, Version 2.0 (the "License");
@@ -48,47 +48,6 @@ if TYPE_CHECKING:
     # pylint: enable=cyclic-import
 
 
-class SandboxFlags:
-    """Flags indicating how the sandbox should be run.
-    """
-
-    NONE = 0
-    """Use default sandbox configuration.
-    """
-
-    ROOT_READ_ONLY = 0x01
-    """The root filesystem is read only.
-
-    This is normally true except when running integration commands
-    on staged dependencies, where we have to update caches and run
-    things such as ldconfig.
-    """
-
-    NETWORK_ENABLED = 0x02
-    """Whether to expose host network.
-
-    This should not be set when running builds, but can
-    be allowed for running a shell in a sandbox.
-    """
-
-    INTERACTIVE = 0x04
-    """Whether to run the sandbox interactively
-
-    This determines if the sandbox should attempt to connect
-    the terminal through to the calling process, or detach
-    the terminal entirely.
-    """
-
-    INHERIT_UID = 0x08
-    """Whether to use the user id and group id from the host environment
-
-    This determines if processes in the sandbox should run with the
-    same user id and group id as BuildStream itself. By default,
-    processes run with user id and group id 0, protected by a user
-    namespace where available.
-    """
-
-
 class SandboxCommandError(SandboxError):
     """Raised by :class:`.Sandbox` implementations when a command fails.
 
@@ -196,8 +155,8 @@ class Sandbox:
     def run(
         self,
         command: List[str],
-        flags: int,
         *,
+        root_read_only: bool = False,
         cwd: Optional[str] = None,
         env: Optional[Dict[str, str]] = None,
         label: str = None
@@ -214,7 +173,7 @@ class Sandbox:
         Args:
             command: The command to run in the sandboxed environment, as a list
                      of strings starting with the binary to run.
-            flags (:class:`.SandboxFlags`): The flags for running this command.
+            root_read_only: Whether the sandbox root should be readonly.
             cwd: The sandbox relative working directory in which to run the command.
             env: A dictionary of string key, value pairs to set as environment
                  variables inside the sandbox environment.
@@ -234,35 +193,17 @@ class Sandbox:
            function must make sure the directory will be created if it does
            not exist yet, even if a workspace is being used.
         """
-
-        if not self.__allow_run:
-            raise SandboxError("Sandbox.run() has been disabled")
-
-        # Fallback to the sandbox default settings for
-        # the cwd and env.
-        #
-        cwd = self._get_work_directory(cwd=cwd)
-        env = self._get_environment(cwd=cwd, env=env)
-
-        assert cwd.startswith("/"), "The working directory must be an absolute path"
-
-        # Convert single-string argument to a list
-        if isinstance(command, str):
-            command = [command]
-
-        if self.__batch:
-            assert flags == self.__batch.flags, "Inconsistent sandbox flags in single command batch"
-
-            batch_command = _SandboxBatchCommand(command, cwd=cwd, env=env, label=label)
-
-            current_group = self.__batch.current_group
-            current_group.append(batch_command)
-            return None
+        if root_read_only:
+            flags = _SandboxFlags.ROOT_READ_ONLY
         else:
-            return self._run(command, flags, cwd=cwd, env=env)
+            flags = _SandboxFlags.NONE
+
+        self._run_with_flags(command, flags=flags, cwd=cwd, env=env, label=label)
 
     @contextmanager
-    def batch(self, flags: int, *, label: str = None, collect: str = None) -> Generator[None, None, None]:
+    def batch(
+        self, *, root_read_only: bool = False, label: str = None, collect: str = None
+    ) -> Generator[None, None, None]:
         """Context manager for command batching
 
         This provides a batch context that defers execution of commands until
@@ -273,7 +214,7 @@ class Sandbox:
         level batch context ends.
 
         Args:
-            flags (:class:`.SandboxFlags`): The flags for this command batch.
+            root_read_only: Whether the sandbox root should be readonly.
             label: An optional label for the batch group, used for logging.
             collect: An optional directory containing partial install contents
                            on command failure.
@@ -281,6 +222,10 @@ class Sandbox:
         Raises:
             (:class:`.SandboxCommandError`): If a command fails.
         """
+        if root_read_only:
+            flags = _SandboxFlags.ROOT_READ_ONLY
+        else:
+            flags = _SandboxFlags.NONE
 
         group = _SandboxBatchGroup(label=label)
 
@@ -326,7 +271,7 @@ class Sandbox:
     # Returns:
     #    (int): The program exit code.
     #
-    def _run(self, command, flags, *, cwd, env):
+    def _run(self, command, *, flags, cwd, env):
         raise ImplError("Sandbox of type '{}' does not implement _run()".format(type(self).__name__))
 
     # _create_batch()
@@ -357,6 +302,58 @@ class Sandbox:
     ################################################
     #               Private methods                #
     ################################################
+
+    # _run_with_flags()
+    #
+    # An internal method for running commands, which exposes the private _SandboxFlags.
+    #
+    # Args:
+    #    command: The command to run in the sandboxed environment, as a list
+    #             of strings starting with the binary to run.
+    #    flags: The SandboxFlags for running this command.
+    #    cwd: The sandbox relative working directory in which to run the command.
+    #    env: A dictionary of string key, value pairs to set as environment
+    #         variables inside the sandbox environment.
+    #    label: An optional label for the command, used for logging.
+    #
+    # Returns:
+    #    (int): The program exit code, or None if running in batch context.
+    #
+    def _run_with_flags(
+        self,
+        command: List[str],
+        *,
+        flags: int,
+        cwd: Optional[str] = None,
+        env: Optional[Dict[str, str]] = None,
+        label: str = None
+    ) -> Optional[int]:
+        if not self.__allow_run:
+            raise SandboxError("Sandbox.run() has been disabled")
+
+        # Fallback to the sandbox default settings for
+        # the cwd and env.
+        #
+        cwd = self._get_work_directory(cwd=cwd)
+        env = self._get_environment(cwd=cwd, env=env)
+
+        assert cwd.startswith("/"), "The working directory must be an absolute path"
+
+        # Convert single-string argument to a list
+        if isinstance(command, str):
+            command = [command]
+
+        if self.__batch:
+            assert flags == self.__batch.flags, "Inconsistent sandbox flags in single command batch"
+
+            batch_command = _SandboxBatchCommand(command, cwd=cwd, env=env, label=label)
+
+            current_group = self.__batch.current_group
+            current_group.append(batch_command)
+            return None
+        else:
+            return self._run(command, flags=flags, cwd=cwd, env=env)
+
     # _get_context()
     #
     # Fetches the context BuildStream was launched with.
@@ -547,6 +544,43 @@ class Sandbox:
         self._build_directory_always = always
 
 
+# SandboxFlags()
+#
+# Flags indicating how the sandbox should be run.
+#
+class _SandboxFlags:
+
+    # Use default sandbox configuration.
+    #
+    NONE = 0
+
+    # Whether the root filesystem should be readonly.
+    #
+    # Usually this is true except for when running integration commands
+    ROOT_READ_ONLY = 0x01
+
+    # Whether to expose host network.
+    #
+    # This should not be set when running builds, but can
+    # be allowed for running a shell in a sandbox.
+    NETWORK_ENABLED = 0x02
+
+    # Whether to run the sandbox interactively.
+    #
+    # This determines if the sandbox should attempt to connect
+    # the terminal through to the calling process, or detach
+    # the terminal entirely.
+    INTERACTIVE = 0x04
+
+    # Whether to use the user id and group id from the host environment.
+    #
+    # This determines if processes in the sandbox should run with the
+    # same user id and group id as BuildStream itself. By default,
+    # processes run with user id and group id 0, protected by a user
+    # namespace where available.
+    INHERIT_UID = 0x08
+
+
 # _SandboxBatch()
 #
 # A batch of sandbox commands.
@@ -579,7 +613,7 @@ class _SandboxBatch:
                 "Running command", detail=command.label, element_name=self.sandbox._get_element_name(),
             )
 
-        exitcode = self.sandbox._run(command.command, self.flags, cwd=command.cwd, env=command.env)
+        exitcode = self.sandbox._run(command.command, flags=self.flags, cwd=command.cwd, env=command.env)
         if exitcode != 0:
             cmdline = " ".join(shlex.quote(cmd) for cmd in command.command)
             label = command.label or cmdline
diff --git a/src/buildstream/scriptelement.py b/src/buildstream/scriptelement.py
index 7b505b33f..7101bfae1 100644
--- a/src/buildstream/scriptelement.py
+++ b/src/buildstream/scriptelement.py
@@ -1,5 +1,5 @@
 #
-#  Copyright (C) 2017 Codethink Limited
+#  Copyright (C) 2022 Codethink Limited
 #
 #  Licensed under the Apache License, Version 2.0 (the "License");
 #  you may not use this file except in compliance with the License.
@@ -36,7 +36,6 @@ from collections import OrderedDict
 from typing import List, Optional, TYPE_CHECKING
 
 from .element import Element
-from .sandbox import SandboxFlags
 
 if TYPE_CHECKING:
     from typing import Dict, Tuple
@@ -221,7 +220,7 @@ class ScriptElement(Element):
             with self.timed_activity("Staging dependencies", silent_nested=True):
                 self.stage_dependency_artifacts(sandbox)
 
-            with sandbox.batch(SandboxFlags.NONE, label="Integrating sandbox"):
+            with sandbox.batch(label="Integrating sandbox"):
                 for dep in self.dependencies():
                     dep.integrate(sandbox)
 
@@ -240,7 +239,7 @@ class ScriptElement(Element):
             root_list = self.__layout.get("/", None)
             if root_list:
                 element_list = [element for element, _ in root_list]
-                with sandbox.batch(SandboxFlags.NONE), self.timed_activity("Integrating sandbox", silent_nested=True):
+                with sandbox.batch(), self.timed_activity("Integrating sandbox", silent_nested=True):
                     for dep in self.dependencies(element_list):
                         dep.integrate(sandbox)
 
@@ -250,18 +249,13 @@ class ScriptElement(Element):
         sandbox.get_virtual_directory().descend(*install_root_path_components, create=True)
 
     def assemble(self, sandbox):
-
-        flags = SandboxFlags.NONE
-        if self.__root_read_only:
-            flags |= SandboxFlags.ROOT_READ_ONLY
-
-        with sandbox.batch(flags, collect=self.__install_root):
+        with sandbox.batch(root_read_only=self.__root_read_only, collect=self.__install_root):
             for groupname, commands in self.__commands.items():
-                with sandbox.batch(flags, label="Running '{}'".format(groupname)):
+                with sandbox.batch(root_read_only=self.__root_read_only, label="Running '{}'".format(groupname)):
                     for cmd in commands:
                         # Note the -e switch to 'sh' means to exit with an error
                         # if any untested command fails.
-                        sandbox.run(["sh", "-c", "-e", cmd + "\\n"], flags, label=cmd)
+                        sandbox.run(["sh", "-c", "-e", cmd + "\\n"], root_read_only=self.__root_read_only, label=cmd)
 
         # Return where the result can be collected from
         return self.__install_root