Scanner sandboxing¶
ASH runs third-party scanners against code you may not trust. In local mode those
scanners run as ordinary child processes with your user's full access: they can read
~/.ssh, write anywhere you can, and open network connections. --sandbox runs every
scanner subprocess inside an OS-level sandbox instead. It is off by default.
ash scan --sandbox auto # best sandbox this machine has
ash scan --sandbox bwrap --offline # bubblewrap, no scanner gets a network
or in your ASH config file (.ashrc.yaml in a new project):
If you ask for a sandbox and ASH cannot provide one, the affected scanners are recorded
MISSING with the reason, and the scan exits 1 like any other incomplete scan. ASH
never falls back to running a scanner unsandboxed after you asked for a sandbox. That
includes asking in a config file that cannot be loaded: a scan whose config file is
missing or does not parse stops with exit code 3, rather than running with the default
configuration, whose mode is off.
Modes¶
| Mode | Platform | Needs | Notes |
|---|---|---|---|
off |
any | nothing | Default. Scanners run as plain subprocesses. |
auto |
any | one of the below | Linux: bwrap, then firejail, then landlock. macOS: sandbox-exec. Windows: none. |
bwrap |
Linux | bubblewrap, unprivileged user namespaces |
Recommended. |
firejail |
Linux | firejail |
Fallback. Weaker filesystem hiding, see below. |
landlock |
Linux 6.2+ (Landlock ABI 3) | nothing (kernel LSM) | No package to install. Needs Landlock enabled in the kernel's LSM list. Below ABI 3 it counts as unavailable. |
sandbox-exec |
macOS | ships with macOS | Apple marks sandbox-exec deprecated. See risks. |
Container mode (--mode container) does not apply the setting: it never sandboxes the
scanners inside the image, and the container is the boundary. So --sandbox with any
mode but off is refused there, with exit 5 (a usage error), before the image is built
or run. When sandbox.mode in a config file or a --config-overrides value asks for a
sandbox, container mode prints a warning on stderr that it is not applied (whatever
--quiet or --log-level say), gives the scan inside
sandbox.mode=off as its last override, and runs the scan, so one config file can serve
local and container runs.
Running ash scan --sandbox <mode> yourself inside the image, as in
docker run <image> ash scan --sandbox landlock, is your own opt-in: the scan there is
a local one, and it applies the mode. The wrappers below never pass a mode other than
off. When the
backend cannot run in the image, each scanner is recorded MISSING with the reason, and
none runs unsandboxed.
./ash, invoke-ash and Invoke-ASH, which always run the container, refuse
--sandbox the same way (Invoke-ASH by throwing with $LASTEXITCODE set to 5). It is
the one refusal of theirs that exits 5, a usage error, as ash itself gives for it; the
others refuse a value the wrapper cannot use and exit 3. They also
give the scan inside --sandbox off, which it applies after every --config-overrides
value and above a --cli-json-input document's sandbox. Only scan, which also runs
when no command is named, takes --sandbox; another command is given nothing. They do
not read the config
file, so they print no warning about a mode set there. Nor do they pass ASH_CONFIG to
the scan in the container; when it is set they warn that it is not applied. Pass a config
with --config (-Config to Invoke-ASH) instead, as a path relative to the source
directory, which is all the scan in the container sees.
Use --mode local to sandbox each scanner. Nix mode composes with it: the inner scan
that runs inside nix develop applies the sandbox to each scanner.
ASH finds bwrap and firejail, and the readlink and true its probes run, the way
it finds scanners: on absolute PATH entries and in ASH's own bin directory, never
through the working directory, and not inside the scanned directory unless you pass
--allow-in-tree-tools or set ASH_ALLOW_IN_TREE_TOOLS=1. One found only inside the
scanned directory counts as not installed: auto moves on to the next backend, and a
mode that names that backend reports its scanners MISSING. See
Tools inside the scanned directory.
What a sandboxed scanner can do¶
Every scanner subprocess gets the same baseline policy, plus what that scanner declares it needs.
- The source directory, and the converted-files directory, are read-only.
- The scanner's own results directory (
<output>/scanners/<name>/) is the only persistent location it can write. The rest of the output directory is read-only. /tmpand$HOMEare private and empty. Your real home directory is not visible. The tool locations a scanner needs (ASH's bin directory, uv's tool and Python directories, the scanner's declared caches) are mounted at their usual paths. A uv directory thatUV_TOOL_DIR,UV_PYTHON_INSTALL_DIRorUV_CACHE_DIRnames is not mounted, with a warning, when it is/, a home directory, a directory holding one, or a directory directly in one such as~/.local.- Tool caches (uv's cache, grype's and trivy's databases, semgrep's settings) are writable only where the writes are thrown away: under bwrap (bubblewrap 0.8+ and Linux 5.11+) through an overlay, so writes succeed but are discarded when the scanner exits. firejail, Landlock, sandbox-exec, and bwrap without overlay support mount them read-only, because a write in place would change what later runs, sandboxed or not, read; uv hard-links its cache into the tool environments it builds. There, uv and the scanners that have to write a cache (semgrep, opengrep, npm-audit) get a private, empty one under the results directory, removed after the spawn, so uv starts from an empty cache: online it downloads what it runs, offline it has nothing cached.
- grype and trivy cannot update their databases from inside a sandbox, so before a
sandboxed online scan ASH updates them itself, outside the sandbox:
grype db update, andtrivy image --download-db-onlyplus, for a misconfiguration scan, trivy's checks bundle. trivy and trivy-repo do this on every online scan, sandboxed or not, since they share the cache. Nothing from the scanned repository reaches these, or ASH's check of the database's age after the scan: they run from a directory outside the scanned tree or any other checkout, with an explicit config file (an empty one ASH ships, or for trivy the config the scan passes: the operator'sconfig_fileonce it passes the rule for that option, elseTRIVY_CONFIGfor a file outside the scanned tree), against the cache directory the scan reads. A lock in that cache directory makes concurrent scans take turns, and each tool is updated once per scan. The scanners then run with their own update turned off (trivy --skip-db-update,GRYPE_DB_AUTO_UPDATE=false). Offline nothing is updated, and in both cases ASH's staleness check still holds the database to its bound. trivy's Java database (about 935 MiB) is not updated and trivy and trivy-repo run withtrivy --skip-java-db-update:trivy fsandtrivy repositorydo not analyze JAR, WAR or EAR files and never read it. In every run, sandboxed or not, online or offline, trivy and trivy-repo keep their scan cache in memory (trivy --cache-backend=memory), because trivy otherwise opensfanal/fanal.dbin a cache directory it may only read, and exits 1. A cache can be read-only outside a sandbox too, when it is shared or baked into an image. - The cache directory is the one grype or trivy itself uses on the host:
GRYPE_DB_CACHE_DIRorTRIVY_CACHE_DIRwhen set, else$XDG_CACHE_HOMEwhen it is an absolute path (trivy reads it only on Linux, and a relative one makes it use$TMPDIR/trivy), else~/.cacheon Linux,~/Library/Cacheson macOS, and%LOCALAPPDATA%\cache(grype) or%LocalAppData%(trivy) on Windows. grype keeps its database ingrype/dbunder it and trivy intrivy. The sandbox does not passXDG_CACHE_HOME, so in every run, sandboxed or not, ASH names the directory to the scan (trivy --cache-dir,GRYPE_DB_CACHE_DIR), to the update and to the staleness check, and all three read the same database. Acache.dirin atrivy.yamlor adb.cache-dirin a grype config does not move it; set the variable instead. ASH logs a warning naming the grype config when it setsdb.cache-dir; a trivycache.diris overridden without one. - The config files ASH writes for a scanner's tool (the empty config bandit, grype,
syft, ferret-scan, trivy, trivy-repo, actionlint and cfn-lint get when you name
none, gitleaks' default-rules config, trivy's and trivy-repo's empty ignore file
and secret config, the empty directories ASH passes trivy and trivy-repo as their
modules directory and gitleaks as its ignore path, the copies of your
rules_pathsfiles ASH gives cfn-guard, and the copies of the files a.gitleaksignorenames that gitleaks re-scans) are in a directory of that scanner run's own, outside the scanned tree (normally in the system temporary directory, never under one another account can change), made with mode 0700 and removed when the run ends. The scanner can read it and cannot write to it. Under sandbox-exec, the one backend that limits where programs run from, it cannot run programs from it either. - System directories (
/usr,/etc,/opt,/nix) and the directories onPATHare read-only. APATHentry inside the scanned tree or the output directory is not a tool directory; the tree is mounted only as the scan's data. The same goes for an entry of ASH's ownsys.paththere (PYTHONPATH=src): it stays readable for the imports, and nothing is run from it. Inside$HOMEonlyPATHentries that arebin,sbinorScriptsdirectories are mounted, and a tool's install prefix only when it is deeper than a directory directly under$HOME(~/.nvm/versions/node/v22yes,~/.cargono). A directory counts as abindirectory only by the directory it resolves to: that directory must be namedbin,sbinorScripts, must not be a home directory, and must not contain one. So a real~/binis mounted, while a link spelledbinthat resolves somewhere else (~/binlinked to~/.aws, or$WORK/binlinked to$WORK) is not abindirectory. An install prefix that is/, a home directory or a directory holding one is never mounted: a tool in$WORK/binwithHOME=$WORK/homegets$WORK/bin, and nothing of$WORKwhen$WORK/binis a link to$WORKitself. WhenHOMEnames a directory other than your account's passwd entry's home, both directories are screened this way. A link at the top of a home that names a directory elsewhere, such as~/.localkept on another disk, counts as part of that home: the directory it names is screened as if it were directly in the home, so itsshareand keyrings are not mounted. A link to a system directory (~/ulinked to/usr) changes nothing. - A program that one of ASH's own checks has a spawn's tool start gets the grants
that tool gets, through the same rules: its directory and its install prefix. The
check of a pre-installed scanner runs
uv, which starts the scanner's Python to inspect it, so that Python's environment is mounted for the check. Only ASH's own code names such a program, never a scanner's options or the scanned repository. - ASH's own Python is mounted where it is installed, and
ASH_BIN_PATHwith it. A Python whose binary sits directly in a top-level system directory (/opt/python3, or/bin/python3where/binis not merged into/usr) gets that directory, which is already mounted, and never/. One directly in any other top-level directory (/mnt/python3,/srv/python3) is refused, since it would mount that whole directory, and so is a Python whosesys.prefixis/. The scanner is refused rather than given the home when the Python's prefix is a home directory or a directory directly in one that is not a virtual environment or conda installation, when the Python is reached through a link kept in a home outside abin,sbinorScriptsdirectory and outside the Python's own installation (a virtual environment made with~/python3 -m venv, or a link in~/.config), or whenASH_BIN_PATHnames a home or a directory directly in one that is not abindirectory. Use an interpreter or virtual environment outside the home directory, or one created from the interpreter's real path. A virtual environment made from a Python whose prefix is refused keeps that prefix as its base, so it is refused too. - A virtual environment or conda installation directly in a home is told apart from
any other directory there by its marker (
pyvenv.cfg,conda-meta) and by a short list of directories that never count as one, whatever marker they hold:~/.local, and any directory holdingshare/keyrings. That list is a heuristic, not a proof. Another directory with a marker, such as a venv made at~/work, is mounted whole. It is acceptable becausesys.prefixis the Python the operator chose to run ASH with, so keep that environment free of files a scanner must not read. - The offline rule cache of semgrep and opengrep, the directory
SEMGREP_RULES_CACHE_DIRorOPENGREP_RULES_CACHE_DIRnames, is mounted read-only for that scanner wherever it is,$HOMEincluded. Under sandbox-exec, the backend that restricts which files may be executed, no program runs from it; bwrap, firejail and Landlock restrict writes and reads, not exec, there. Only the variable in ASH's own environment grants it, never a path in the scanner's options; an empty or relative value grants nothing, and one naming a file,/,$HOMEor a directory above$HOME(or the passwd entry's home, or a directory above it, whenHOMEnames another directory) is not mounted, with a warning. A directory directly under$HOME, such as~/rules, is mounted as named, so point the variable at the rules directory itself. - The environment is filtered to an allowlist: locale,
PATH, TLS and proxy settings, ASH's and uv's own variables, and the prefixes the scanner declares (GRYPE_,SEMGREP_, ...). Within those, a name containingTOKEN,PASSWORD,SECRET,AUTH,CREDENTIAL,API_KEYorSESSION, and any value carryinguser:password@in a URL, is dropped unless the scanner names that exact variable (snyk-code namesSNYK_TOKEN). Cloud credentials and tokens are not passed in. - No local IPC endpoint is reachable, with or without a network: not the Docker or
Podman socket, the session bus,
$SSH_AUTH_SOCK, the journal, nor a socket in a directory the sandbox mounts, such as the Nix daemon's under/nix. On Linux every backend refusessocket(AF_UNIX)and datagramsocketpair()with a seccomp filter (see Landlock below), so neither a socket path nor an abstract socket can be reached. The variables that name these endpoints (SSH_AUTH_SOCK,DBUS_SESSION_BUS_ADDRESS,DOCKER_HOST,CONTAINER_HOST,XDG_RUNTIME_DIR) are not passed in. firejail setsDBUS_SESSION_BUS_ADDRESSitself, to a path of its own thatfirejail --dbus-user=noneleaves unserved. - A results directory that is, or is reached through, a symlink is refused (the
scanner is recorded
MISSING). The default output directory is inside the source tree, so the scanned repository could otherwise plant one pointing anywhere. - So is an output directory that is a symlink, or that is reached through one below
the source directory (a committed
build -> /some/host/dirscanned with--output-dir build/ash), however either path is spelled, or that is inside the source directory by its path but outside it by its real path, or whoseanalysis,reports,scannersorconverteddirectory, or a file ASH writes there by a fixed name such asash.log, is one. The scan stops with an error that names the link before ASH writes anything there, whatever the sandbox mode, since ASH clears and writes those itself; pass--output-dirwith a directory that is not a link. See Links are refused for the full list. Links above an output directory outside the source tree (/tmpon macOS) are your own and are not examined. - ASH writes into the results directory after the scanner exits, and ASH is not
sandboxed. So after every sandboxed spawn, and again after the scan, ASH removes
every symlink and special file the scanner left there. A symlink already below the
results directory when the scanner starts (one committed with the scanned tree) is
refused instead, and the scanner is
ERROR. - ASH's own writes below the output directory never follow a symlink, with the
sandbox on or off:
ash.logandash.log.jsonl, worker request files, stream logs,ASH.ScanResults.json,ash_aggregated_results.json, every report, the files a converter extracts and the files a scanner override writes. A link atash.logstops the scan with exit 3 before it starts, as a linked output directory does. A symlink at any component of the path, the file name included, stops the write with "<path>was not written:<link>is a symlink, and ASH does not write through a symlink below its output directory" (OutputLinkRefused, anOSError). Nothing is written where the link points, the link is left in place, and the scanner whose write was refused isERROR. A file there that another name also refers to (a hard link, which an extracted archive can hold) is refused the same way, before anything in it is changed, with "<path>was not written:<n>names refer to it" (OutputHardLinkRefused); so is one already below a sandboxed scanner's results directory when the scanner starts. Links at or above the output directory itself are not examined, as above. On Windows, which has no way to open a path relative to a directory without following a link, ASH checks each component by name before it opens the file, symlinks and reparse points such as junctions included: a link already there is refused, but one created between that check and the open is not. A process the scanner leaves running cannot keep planting links afterwards: bwrap and firejail end the whole process tree with the scanner, and the Landlock wrapper is a child subreaper that kills any process its scanner left behind before it exits. On a timeout ASH sends a sandboxed process SIGTERM first (SIGKILL after 10 seconds), so the Landlock wrapper gets to end its whole tree rather than being killed outright. - Network: under
--offlineno scanner gets a network. Online, only scanners that declare a network need get one (to fetch a content database, a rule pack, or audit data from a package registry); everything else runs with no network.
Per-scanner needs¶
| Scanner | Network when online | Caches | Runtime |
|---|---|---|---|
| bandit | no | uv cache | uv-managed Python |
| checkov | no | uv cache | uv-managed Python |
| semgrep | yes (registry rules, p/ci) |
uv cache, ~/.semgrep; $SEMGREP_RULES_CACHE_DIR read-only |
uv-managed Python |
| opengrep | yes (registry rules) | ~/.opengrep; $OPENGREP_RULES_CACHE_DIR read-only |
single binary; on macOS it unpacks itself into a private directory per spawn |
| grype | yes (database update) | grype database cache (read-only, or a throwaway overlay under bwrap) | single binary |
| syft | no | syft cache | single binary |
| trivy-repo | yes (database update) | trivy cache (read-only, or a throwaway overlay under bwrap) | single binary |
| npm-audit | yes (registry audit API) | ~/.npm |
Node.js |
| cfn-nag | no | none | Ruby and its gem paths |
| detect-secrets | only when sandbox.network_scanners names it |
none | ASH's Python, in a worker subprocess |
| cdk-nag | no | jsii's runtime cache, not used on macOS | ASH's Python with the cdk extra, and Node.js for jsii, in a worker subprocess |
| actionlint | no | none | single binary |
| cfn-lint | no | uv cache | uv-managed Python |
| cfn-guard | no; reads its rules bundle ($ASH_CFN_GUARD_RULES_DIR, or share/cfn-guard-rules beside ASH's bin directory) |
none | single binary |
| gitleaks | no | none | single binary |
| zizmor | no; online_audits gets neither a network nor a GitHub token yet |
uv cache | uv-managed binary |
Community and third-party plugin scanners¶
The sandbox applies to every scanner, whatever module it comes from. A scanner from a
community module (snyk-code, ferret-scan, trivy-repo) or your own plugin package gets
its policy from its sandbox_requirements class attribute. A scanner that declares
none gets the strictest default: no network, the source tree read-only, its own
results directory as the only writable place, a private empty $HOME and /tmp, and
the baseline environment allowlist with nothing added. It still runs; it is never
skipped for lacking a declaration, and never run unsandboxed.
A plugin that needs more declares it:
from typing import ClassVar
from automated_security_helper.base.scanner_plugin import ScannerPluginBase
from automated_security_helper.utils.sandbox.policy import SandboxRequirements
class MyScanner(ScannerPluginBase[MyScannerConfig]):
sandbox_requirements: ClassVar[SandboxRequirements] = SandboxRequirements(
# A network when the scan is online; never under --offline.
network=True,
# Extra read-only paths.
read_paths=("~/.my-tool/rules",),
# Read-only, or writable through bwrap's throwaway overlay.
cache_paths=("~/.cache/my-tool",),
# Where the cache is read-only, these point at a private, empty
# directory, for a tool that has to write its cache.
cache_env=("MYTOOL_CACHE_DIR",),
# Variables passed through.
env_prefixes=("MYTOOL_",),
# Credential-shaped names it needs.
env_names=("MYTOOL_TOKEN",),
# Set under sandbox-exec only, after the allowlist.
sandbox_exec_env=(("MYTOOL_PACKAGE_CACHE", "disabled"),),
# The tool unpacks itself where this variable says and runs what it
# unpacked; sandbox-exec points it at a private directory per spawn.
unpack_dir_env="XDG_CACHE_HOME",
# True when read_paths come from the scanner's options, which the
# scanned repository can write: they are then mounted only for scanners
# named in sandbox.read_path_scanners, and cache_paths are not mounted.
read_paths_require_grant=False,
# The same for env_prefixes and env_names, with sandbox.env_scanners.
env_requires_grant=False,
# Variables in ASH's own environment naming a directory the tool reads
# (semgrep's SEMGREP_RULES_CACHE_DIR): mounted read-only, and not
# executable under sandbox-exec.
# An unset or empty variable, or one naming /, $HOME or a directory above
# it, grants nothing.
read_path_env=("MYTOOL_RULES_DIR",),
# True for a tool handed a staged copy of its input outside the tree
# (the jupyter converter's nbconvert): it may not read the source or
# output directory. See "Converters" below.
staged_input_only=False,
)
The community scanners declare theirs:
- snyk-code asks for a network, its
SNYK_variables andSNYK_TOKEN, and read access to its token file. - trivy-repo shares the builtin trivy's declaration: a network, the database cache and
the
TRIVY_variables. - ferret-scan asks only for its
FERRET_variables.
A file outside the source tree that a scanner option names is not mounted (cfn-guard's
rules_paths needs no mount: ASH copies those rules into the scanner run's own
directory above): a
config_file of actionlint, gitleaks, cfn-lint,
zizmor, trivy or trivy-repo, gitleaks' baseline_path, trivy's ignore_file, a
secret_config_file of trivy or trivy-repo, trivy-repo's module_dir, or a
path-valued actionlint shellcheck or pyflakes. Nor do gitleaks' GITLEAKS_* variables or
zizmor's GitHub token reach a sandbox. Grants derived from options or the environment
wait for the sandbox's grant gates; until then list such a path in
sandbox.extra_read_paths, from a config outside the tree.
detect-secrets needs a network only to verify candidate secrets with their issuers,
which it does when its settings list the verification filter. Only the operator's own
configuration can list it: ASH removes it from a .secrets.baseline inside the
scanned tree and from a config file the scanned repository or an MCP client wrote,
and offline from every source (see
What each tier may set). A baseline the
operator names can still load a detect-secrets plugin from a file outside the tree, so
the sandbox doesn't take detect-secrets' own word for it either: detect-secrets gets a
network only when sandbox.network_scanners names it, and a repository can't set that
(see below). Without a network every candidate counts as unverified, so a sandboxed
scan whose settings verify reports the candidates that verification would have
dropped, and ASH logs a warning naming the setting.
detect-secrets and cdk-nag are Python libraries. They run in worker subprocesses,
not in the ASH process, where no OS sandbox could reach them. The workers use the
same interpreter and the same library calls, so their findings are unchanged
and the sandbox wraps them like any other scanner. To let detect-secrets verify
secrets under the sandbox, pass --config-overrides 'sandbox.network_scanners=[detect-secrets]'
(plus any other scanners that need a network). Under --offline it gets none.
Give a scanner more access¶
To give an extra scanner network access, or take it away:
sandbox:
mode: bwrap
network_scanners: [grype, trivy-repo] # replaces the per-scanner defaults
extra_read_paths: [/opt/company-ca]
Who may grant¶
Four settings grant access: network_scanners, extra_read_paths, and the two
below that let a scanner have what its own options ask for. A config file the
scanned repository could have written can't grant any of them. That means a config
file inside any git checkout, where a directory at or above the file, by its own path
or its resolved path, holds a .git entry (a directory, or the file a submodule or
linked worktree has). Only --config-overrides and config files outside every git
checkout can grant; the --sandbox flag sets only the mode. For a scanned directory
outside any checkout, such as an extracted archive, a config file inside that
directory can't grant either, whether it is reached by the directory's own name, its
resolved path, or the shell's working directory ($PWD).
An in-tree network_scanners list still takes network away: a scanner it doesn't
name gets none, so a repository can keep its own scan offline with
network_scanners: [].
When any file the config was built from can't grant (the discovered .ash/.ash.yaml,
a --config path, an extends base, or the file ASH_CONFIG names), the four
settings are taken from the files that can: in workspace mode the operator's
default config (under the MCP server, the session's bound profile), else
ASH_CONFIG's file, else the defaults, plus --config-overrides. A file that can
grant itself but extends a base that can't keeps the settings its own sandbox
section writes, mode included. For example, a --config outside every checkout
that extends the scanned repository's config keeps the grants it sets, and loses
what it inherits from any base, even one outside every checkout, and the grants it
adds with patch; a mode its own patch sets counts as written. ASH logs a
warning that names the files that can't grant and the settings it dropped. A network_scanners list in such a file still takes network away: a scanner
it doesn't name gets none, so a repository can keep its own scan offline with
network_scanners: [].
For a config in /home/me/src/app/.ash/.ash.yaml that grants grype a network and a
read path, the warning reads:
WARNING Ignoring sandbox.network_scanners, sandbox.extra_read_paths from /home/me/src/app/.ash/.ash.yaml: that config is inside a git checkout or the scanned tree, or was delivered by an MCP client. A network_scanners list there still removes network from scanners it does not name, and a sandbox mode there still applies when nothing else turns the sandbox on. To keep a setting, move the file outside every git checkout, or pass the setting with --config-overrides. A file inside the scanned directory has to move out of it as well.
This applies to a trusted config kept in a checkout of its own as well, such as an
ops or dotfiles repository: its grants are dropped with the same warning. Pass them
with --config-overrides instead, for example
--config-overrides 'sandbox.network_scanners=[grype,trivy-repo]'.
A plugin scanner can take its read paths or environment variables from its options,
such as a rules file outside the source tree, or a token for an online check. The
repository can write those options, so such a scanner declares it with
read_paths_require_grant or env_requires_grant, and the sandbox grants those
needs only to the scanners named in sandbox.read_path_scanners or
sandbox.env_scanners. None of the scanners ASH ships declares either;
detect-secrets' baseline, below, is gated by read_path_scanners directly. The two
settings follow the same rule as network_scanners, and ASH logs a warning when it
withholds such a need. Even when granted, a path a scanner's options name is
mounted only if it resolves inside the source tree or one of the operator's
extra_read_paths entries, and only if it is a regular file or directory, never a
socket or device. To let a scanner read a
rules file elsewhere on the host, put its directory in sandbox.extra_read_paths. A
cache path a scanner's options name is never mounted.
ASH itself reads some files that scanner options name, outside the sandbox, and
passes their contents on. detect-secrets' baseline_file is one. Under a sandbox,
ASH reads such a file only when it is inside the source tree, or when
sandbox.read_path_scanners names the scanner and the file is inside one of the
operator's sandbox.extra_read_paths entries, the same containment a path the
scanner reads itself gets. Otherwise it skips the file with a warning, and the
candidates the baseline would have marked are reported. A baseline an MCP client
delivered is not read at all.
A config file is judged by the name it was found under as well as by the file it
resolves to, so a .ash/.ash.yaml that is a symlink out of the scanned directory
still counts as inside it. An extends base is judged the same way, by the path its
extends entry names as well as by the file it resolves to, so a base the repository
turns into a symlink to some other file still counts as inside the checkout. Every
symlink met on the way counts too: a path that leads through a link inside a
checkout, even from a link of your own, counts as inside that checkout, and the
reason names the link.
Plugin modules are judged by the same rule. A tool config file a scanner option
names is judged by it too when a config inside the scanned tree names it; one the
operator names may sit in a checkout of the operator's own. See
How a file's tier is decided.
Sandbox grants follow a scanner's own name, the one its code gives it. If a config
file renames a scanner's section, for example by setting name: grype on a plugin's
section, that scanner is not run sandboxed: it is recorded MISSING with the reason.
mode is a restriction, so it is honored from any source, as a floor. When
--sandbox, ASH_CONFIG, or the operator's config file turns the sandbox on, a
config file the repository could have written can't turn it off or switch it to
another backend. The operator's mode holds even if the operator's file is itself in
a checkout, because a mode other than off grants nothing. Only --sandbox off or a
sandbox.mode override turns it off. When nothing the operator set turns the
sandbox on, the repository's mode applies, because a sandbox the repository asks
for only takes access away. In workspace mode, the operator's --config decides for
a project that has its own config file, the same as for one that doesn't.
Install tools before a sandboxed scan¶
A sandboxed scan does not install tools, for scanners and converters alike:
installing runs a package's build code and writes uv's tool directory, which a
sandboxed scanner may only read. Run ash dependencies install first; a scanner
whose tool is missing is recorded MISSING, and so is the jupyter converter when
nbconvert is missing, which fails the scan if the tree has notebooks.
Converters¶
The jupyter converter runs nbconvert, a third-party tool, over the repository's
notebooks, so under a sandbox nbconvert runs with the confinement a scanner gets: no
network unless sandbox.network_scanners names jupyter, the environment allowlist,
and the converter's own <output>/converted/jupyter/ directory as the only writable
place. Unlike a scanner, it may not read the source tree or the output directory:
nbconvert is handed a copy of one notebook in a private directory, and reads that
copy and its tools (SandboxRequirements.staged_input_only). bwrap mounts an empty
directory over the tree where a wider mount would show it (a checkout under /opt),
and sandbox-exec denies reading the tree and the output directory after the reads it
allows; both give back the results directory and any tool path inside them. Landlock
can only grant access and firejail shows everything outside $HOME and /tmp, so
under those two a tree inside a directory they show (/opt, /usr/local, or for
firejail any directory outside $HOME and /tmp) stays readable to nbconvert. Its
version probe and dependency check run sandboxed as well, with the same read scope. Under a sandbox the converter runs
nbconvert's own jupyter-nbconvert executable rather than uv tool run, which writes
uv's tools directory and so cannot run there. The convert phase applies the same scope
to every converter, so a third-party converter's tool is confined the same way.
If no sandbox backend can be used, or the backend cannot plan one notebook's run,
nbconvert is not run on that notebook, and it is listed in the converter's
refused_inputs with the reason. A notebook nbconvert fails on (a non-zero exit,
as for a notebook that is not JSON, a timeout, or a run that writes no output) is
not converted either, with or without a sandbox. Each such notebook is listed in
the converter row's failed_inputs, which the completeness gate reads, so with
fail_on_incomplete_scanners on (the default) the scan exits 1; exclude a notebook
that is malformed on purpose, such as a test fixture, with ignore_paths. The same
holds for
any converter: a process its convert() started that the sandbox refused is named
in its failure. Nothing a converter starts runs unsandboxed under a sandbox.
Threat model¶
The sandbox is for three cases.
- A scanner, plugin, or rule pack that does more than it says. A typosquatted or hijacked package on PyPI or npm, a scanner binary replaced on disk, a community rule pack that runs code.
- Scanned content that takes control of a scanner. Scanners parse untrusted input and some
execute it on purpose: tool configuration files committed to the repository
(
trivy.yaml,.semgrep.yml,.checkov.yaml,.npmrc,.bandit) change what a scanner does, plugin systemsevalcode found in the tree, and parser bugs give code execution or path traversal on write. - Data sent off the machine. Code running in any of the above reading credentials
(
~/.ssh,~/.aws, tokens in the environment) and sending them over the network.
Inside the sandbox such code can read the source tree (it is scanning it), write its own results, and, if the scanner is allowed a network, talk to the network. It cannot read the rest of your home directory, write outside its results directory, modify the source tree, or see environment variables outside the allowlist.
A symlink in the tree is resolved inside the sandbox, so it reads only what the
sandbox shows. Under bwrap and Landlock a link to a file outside the read list, in
$HOME or anywhere else, cannot be read. firejail shows everything outside $HOME
and /tmp, and sandbox-exec reads everywhere except $HOME and the shared temporary
directories, so under those two a link can be read unless its target is in what they
hide.
A hard link is not a link to anything: it is the file, under another name, and nothing tells it apart from an ordinary file. A file in the tree that is a hard link to a file outside it is read as part of the tree, by ASH and by every scanner, sandbox or not. ASH does not try to detect one; a git checkout never makes one, but a tree copied or unpacked by another tool can. What contains a scanner that reads one is the sandbox: with no network unless it is granted one, and writes only to its results directory, what it read reaches only its results. ASH's own writes refuse a file another name refers to (see What a sandboxed scanner can do).
Out of scope:
- ASH itself and
git, which run unsandboxed. ASH is the trusted base, and that includes the converters' own code: the archive converter unpacks archives into the work directory in ASH's process. The tools a converter starts are not ASH code and are sandboxed; see Converters.gitruns only for--changed-files-onlyand workspace planning, never inside a scanner. - Links in the scanned tree. ASH's scan set leaves out a link to a file whose
target, with every link on the way resolved, is outside the source directory, and
warns about each one, as
Not scanning 'vendor/config.py': link leaves the scanned tree, naming only the link (see Ignore files and the scan set). So the scanners and converters that read the scan set are not given it, and npm-audit does not audit a package whose files link out of the tree. The scanners that walk the directory they scan themselves are told to skip each such link in their own exclusion option:bandit --exclude, onecheckov --skip-patheach,ferret-scan --exclude,grype --excludeandsyft --exclude, andtrivy --skip-filesfor trivy-repo. Where a link's path cannot be put in that option alone (a comma, a glob character or a line break, or a path another file's path holds, for bandit; values checkov's terraform module finder cannot compile into its one regex for checkov; a comma, or a path another file's path holds, for ferret-scan), that scanner is not run on the directory, and the scan fails naming the link. snyk-code tests a copy of the scan set. - Plugin modules. A config file can list
ash_plugin_modules, which ASH imports into its own process, so they run unsandboxed. From a config file inside the scanned tree, only installed modules outside the tree are imported (see What each tier may set), but an installed package still runs with ASH's access. The sandbox doesn't change this; review the plugin modules a repository's config names before you scan it. ASH's ownash_aws_pluginsandash_snyk_pluginspackages are not imported from such a file at all, because they send findings or source off the host with the operator's credentials. A virtualenv kept inside any git checkout counts as inside it, so a repository's config can't import plugins installed there; the warning names the checkout. Name such plugins with--ash-plugin-modulesinstead. - Resource exhaustion. A scanner can still use all the CPU and memory it can get, or
fork until a limit stops it; the existing per-scanner
scan_timeoutbounds how long. - A scanner allowed a network shares the host's network, so TCP and UDP services listening on the host, loopback included, are reachable from it under every backend. Abstract Unix sockets bound by host processes are not: the socket filter refuses Unix sockets.
- A scanner allowed a network can still send what it can read (the source tree) wherever it likes. The online allowlist is per scanner, not per host. Host-level filtering would need a proxy inside the sandbox, which tools can go around unless the network namespace forces all traffic through it; that is future work.
- Results integrity. A scanner can still write false results to its own results file.
- Kernel defects. All backends share the host kernel.
When a scanner cannot be sandboxed¶
ASH records a scanner it cannot sandbox as MISSING, and under the default
fail_on_incomplete_scanners the scan exits 1. The reason is in the scan output, in
ash.log, and in the scanner's missing_reason in ash_aggregated_results.json:
| Message | Fix |
|---|---|
bwrap is not installed (install the 'bubblewrap' package) |
Install bubblewrap. |
bwrap cannot create a sandbox here (...) |
Allow unprivileged user namespaces, or use landlock. |
bwrap cannot apply the Unix-socket filter in its sandbox (...) |
Use the bwrap from your distribution, on x86_64 or aarch64, on a kernel that allows seccomp filters. |
Landlock is not available (...) |
Add landlock to the lsm= kernel parameter. |
Landlock ABI <n> is below 3 (Linux 6.2), ... |
Use bwrap, a kernel with Landlock ABI 3 or later, or container mode. |
firejail is not installed |
Install firejail, or use another backend. |
firejail ran a test command without a sandbox (...) |
Use bwrap or landlock; firejail does not sandbox inside this container. |
sandbox-exec is not present (it has been removed from this macOS) |
No macOS backend remains; scan in a container. |
--sandbox auto found no working sandbox (...) |
Lists why each backend failed; fix one of them. |
no scanner sandbox is available on Windows; ... |
Use WSL2 or container mode. |
<name>: the config names this scanner '<name>', but its own name is '<other>' |
Configure the scanner under its own name; grants follow a scanner's own name. |
<scanner> cannot be sandboxed as requested: ... |
Logged while ASH checks the scanner's tool; fix the reason that follows. |
the results directory path <path> is a symlink, ... |
Remove the link under the output directory, or pass another --output-dir. |
a scanner tried to start a shell command, which the sandbox does not wrap |
The scanner's plugin starts its tool through a shell. Leave that scanner out of sandboxed scans, or ask the plugin's author to start the tool directly. |
Scan a repository you do not trust¶
For a repository you do not trust, on Linux or macOS:
- Install the scanners first, from a trusted environment, with
ash dependencies install. Run it from a directory outside the repository, because it reads the ASH config of the directory it runs in. A sandboxed scan does not install tools (see Install tools before a sandboxed scan), and a scanner whose tool is missing is recordedMISSING. - Keep your own config file outside every git checkout and outside the scanned
directory, and pass it with
--config. Only such a file and--config-overridescan grant sandbox access (see Who may grant). - Scan in local mode with the sandbox on. On Linux, name the backend with
--sandbox bwrap, or setsandbox.modetobwrapin that file:--sandbox autofalls back to firejail when bwrap is not usable, and firejail leaves readable every path outside$HOMEthat your user can read. On macOS the backend is sandbox-exec, which reads everywhere except the home directory and the shared temporary directories. - Scan with
--offlineif you can, so that no scanner gets a network. Otherwise setsandbox.network_scannersto the shortest list that works. Offline, npm-audit isMISSINGfor a project that has a lockfile, because npm, pnpm and yarn audit only against the package registry; leave it out with--exclude-scanners npm-audit. - Put the output directory outside the repository with
--output-dir. The default output directory,.ash/ash_output, is inside the scanned directory. - Do not pass
--allow-in-tree-toolsor setASH_ALLOW_IN_TREE_TOOLS, so that ASH starts no tool it finds only inside the repository (see Tools inside the scanned directory). - Name only plugin modules you trust. A plugin module runs in ASH's own process, outside the sandbox.
With the default fail_on_incomplete_scanners, a scanner whose tool is found only
inside the repository, or that cannot be sandboxed under --sandbox auto because no
backend works, is MISSING. A scanner whose sandbox backend you named and that is
unavailable, or whose sandbox is refused for another reason, is ERROR. Either way the
scan exits 1, rather than passing on fewer scanners than you asked for.
On Windows there is no sandbox backend. Run ASH under WSL2 with --sandbox bwrap, or
use container mode, where the container is the boundary.
Design¶
One choke point¶
Every scanner subprocess is started through one function,
utils/subprocess_utils.py:_prepare_spawn, whether the scanner is run directly, through
uv tool run, or as a version or availability probe. A sandbox scope (a context
variable holding the policy for that scanner) is active around each scanner's
construction and dependency check, which is when scanners probe their tools, around
its scan, and around the content-database check that runs grype db status.
_prepare_spawn rewrites the command line for the active backend when a scope is
active. Probes get a throwaway results directory. A scanner that cannot be sandboxed
as requested gets a refusing scope, so its probes fail instead of running unsandboxed.
The convert phase enters the same kind of scope around each converter's construction,
dependency check and convert(), and the jupyter converter enters one around each
nbconvert run, with the staged notebook as its scan target. Spawns made outside any
scope (ASH's own git calls, the container runtime) are unaffected. A sandboxed scan
never installs a tool.
tests/unit/utils/test_sandbox_choke_point.py fails the build if any module in the
package outside a short, reasoned exemption list calls subprocess.run,
subprocess.Popen, os.system, os.exec* or the like directly, so a new spawn site
cannot go around the choke point. The scope is per thread: a scanner that started a
process from a thread it created itself would not inherit it. No builtin scanner does.
Linux: bubblewrap (preferred)¶
bubblewrap builds an empty root from a tmpfs and mounts into it only what the policy
lists, in new user, mount, PID, IPC, UTS, cgroup and (when the scanner gets no network)
network namespaces. It needs no setuid binary on distributions that allow unprivileged
user namespaces. ASH passes bwrap --die-with-parent and bwrap --new-session, so a scanner
cannot outlive ASH or inject keystrokes into the terminal through TIOCSTI.
bwrap's mounts hide /run and $HOME, but not a Unix socket inside a directory it
does mount (the Nix daemon's socket is under /nix), and a scanner with a network
shares the host's network namespace and every abstract Unix socket bound there. So
the scanner is started through the Landlock wrapper in its socket-filter mode, which
installs only its seccomp socket filter (described under Landlock) and then execs the
scanner. IP sockets are left to the network namespace, so a tool can still use its own
loopback when it has no network. ASH's probe runs the filter inside bwrap once, so an
architecture the filter does not cover makes bwrap unavailable rather than unfiltered.
Ubuntu 23.10 and later restrict unprivileged user namespaces through AppArmor
(kernel.apparmor_restrict_unprivileged_userns=1). Install bubblewrap from the
distribution (apt install bubblewrap); if bwrap --unshare-all --ro-bind / / true
still fails with "setting up uid map: Permission denied", the restriction applies to
it, and either an AppArmor profile that grants userns to /usr/bin/bwrap or
sysctl kernel.apparmor_restrict_unprivileged_userns=0 lifts it. ASH probes by
running bwrap ... true once per process, so a bwrap that is installed but cannot
start is reported as unavailable with the error it printed, not used and found broken
mid-scan.
The throwaway cache overlay uses bwrap --overlay-src and bwrap --tmp-overlay, which need
bubblewrap 0.8 and Linux 5.11. On older systems those caches are mounted read-only
instead, as under the other backends, and the log says so.
Linux: firejail (fallback)¶
firejail is a setuid-root binary, a privileged program of its own (it has had
privilege-escalation advisories), and its filesystem model is "everything visible, then
restrict" rather than "nothing visible, then allow". ASH uses
--noprofile --private-dev --nonewprivs --caps.drop=all --seccomp --nogroups,
firejail --dbus-user=none --dbus-system=none, firejail --net=none when the scanner gets no network,
makes / read-only, blacklists the container runtime sockets, /run/user/<uid> and
$SSH_AUTH_SOCK, whitelists inside $HOME (and /tmp) only the paths the policy
lists, uses firejail --private-tmp when the policy lists nothing under /tmp, and makes the
results directory read-write. Paths outside $HOME that your user can read remain readable, and scanner
caches are read-only because firejail has no throwaway overlay. Because
everything outside $HOME stays visible, /run and its sockets included, the
scanner is started through the same seccomp socket filter as under bwrap. Use bwrap
when you can.
firejail takes the home directory it hides from your account's passwd entry, not
from HOME. When HOME names another directory, ASH decides the whitelists inside
the home against the passwd entry's home, as firejail applies them, and also
blacklists the directory HOME names, unless it is under /tmp, which is already
private. A tool installed inside that directory then fails to start rather than the
directory being shown. sandbox-exec likewise denies reading both directories.
A directory that a link at the top of either home names outside the homes, such as
~/.local kept on another disk, is part of that home but lies outside it, where
both backends would otherwise show it. sandbox-exec denies reading it along with the
homes, before the policy's own paths are allowed again, so a tool's bin directory
inside it still runs while its share and keyrings stay unreadable. firejail
blacklists it, or, where the policy lists paths inside it (that bin directory, or a
scanned repository kept in a linked ~/code), every entry beside the way to those
paths. A link to a system directory, such as ~/u linked to /usr, is not hidden.
firejail also decides for itself whether to build a sandbox at all. When it finds no
kernel threads among the first ten PIDs, as inside a container that has its own PID
namespace, it concludes it is already sandboxed and runs the command with none of the
options above. The command still exits 0, and the only sign is a warning that
--quiet hides. So ASH's probe runs readlink /proc/self/ns/mnt under the same
options, without --quiet, and requires a mount namespace other than ASH's own, since
every sandbox firejail builds has one. When the command reports ASH's namespace, or
firejail prints that warning, firejail is unavailable with that reason:
--sandbox firejail records each scanner MISSING, and --sandbox auto moves on to
Landlock.
Linux: Landlock¶
Landlock is an unprivileged kernel LSM, so this mode needs nothing installed. It
needs Landlock ABI 3 (Linux 6.2) or later, the first that controls truncation: on an
older kernel a scanner could truncate files it may only read, so ASH reports
Landlock unavailable there. --sandbox landlock then records each scanner MISSING
with the ABI in the reason, and auto does not use it. ASH starts a small wrapper
(utils/sandbox/landlock_exec.py, standard library only) that restricts itself and
then execs the scanner:
- Landlock filesystem rules: read and execute beneath the read-only paths, full access
beneath the results directory and a fresh private temporary directory (
TMPDIR). Nothing else, including$HOMEand/tmp, is reachable. - Sockets: Landlock does not mediate
connect()on a Unix socket path, so a scanner that could create a Unix socket could talk to the Docker socket or the session bus whatever the filesystem rules say. A seccomp filter therefore refusessocket(AF_UNIX)always.socketpair()of the stream and seqpacket kinds, which tools use for pipes, still works; a datagram pair is refused, because a datagram socket cansendto()orconnect()any datagram socket by path, such as the journal's/dev/log, however it was created. The filter also refusessocket()for every family when the scanner has no network, which blocks UDP and DNS too, and refusesio_uring_setup, because io_uring can create sockets without thesocketsyscall. Landlock's own network rules (ABI 4, Linux 6.7) cover only TCP and are added as a second layer. - A tool that creates a Unix socket of its own fails too, under this backend and, since
bwrap and firejail run the same filter, under every Linux backend. Python 3.14's
default multiprocessing start method,
forkserver, is one: it listens on a Unix socket, so a Python tool on 3.14 that relies on the default fails withPermissionError, while theforkandspawnmethods work. ASH's own workers selectfork, and the builtin scanners do not rely on the default. - The wrapper starts a new session before it execs the scanner, so the scanner has no controlling terminal to inject keystrokes into.
/dev/shmis the host's and is writable, because POSIX semaphores live there and multiprocessing needs them (detect-secrets scans in a process pool). Landlock cannot make it private. A scanner can therefore leave files in, or read other processes' shared memory objects of your user from,/dev/shm. bwrap gives each scanner its own.io_uring_setupfails with ENOSYS rather than EACCES, so runtimes that use io_uring when it exists (semgrep-core) fall back to ordinary syscalls instead of aborting.- ABI 6 (Linux 6.12) scoping blocks abstract Unix sockets and signals to processes outside the sandbox. Below ABI 6 those are not restricted, and the log says so.
/procis readable, but Landlock deniesptrace-mode access from a sandboxed process to processes outside its domain, so/proc/<ASH's pid>/environcannot be read.
Landlock cannot mount an empty /tmp or $HOME over the real ones; it denies them
instead. TMPDIR and HOME point at a fresh private directory, and each path the
policy grants under the real home appears in the private one as a symlink to the real
path, so tools that write settings under ~ keep working. Granted caches are read-only,
as Landlock has no overlay. A tool that hard-codes /tmp fails rather
than escaping.
macOS: sandbox-exec¶
sandbox-exec ships with macOS at /usr/bin/sandbox-exec; there is nothing to install.
--sandbox auto uses it.
sandbox-exec applies a Seatbelt (SBPL) profile. ASH generates one per scanner:
deny by default, read everywhere except the home directory and the shared temporary
directories, read the policy's tool paths inside home, write only to the results
directory and a private TMPDIR. With no network, no socket of any kind is allowed.
With a network, IP sockets are allowed and Unix sockets are not, except the
resolver's (mDNSResponder), so Docker Desktop's socket and the launchd SSH agent
stay out of reach. The scanner starts in a new session, with no controlling terminal.
The home directory is the one HOME names. When that is not the home your account's
passwd entry names, as in a CI job or HOME=/tmp/x ash scan, the profile denies
reading both, and the keychain rule covers Library/Keychains under both. The paths
the policy lists are allowed again after those denies, wherever they are, so a tool
path inside either home stays readable. With HOME set to the passwd entry's home,
the profile is the same as before.
Programs run only from the system and tool paths the policy makes readable, and from
two directories made for the spawn alone: its private uv cache, where uv tool run
keeps the environments of tools it was not asked to install, and a self-unpacking
tool's unpack directory (below). Nothing runs from the source tree, the output and
results directories, the host caches, the other private caches or the private
TMPDIR, even where a tool path contains one of them (a repository checked out under
/opt), so a program the scanner writes, or one the scanned repository ships, cannot
be started. A tool path inside the source tree, such as a virtualenv ASH itself runs
from, stays executable.
Mach service lookups are limited to a list measured on the macOS 14, 15 and 26 CI
runners, where the ten builtin scanners looked up the same thirteen services on all
three. Nine of them are allowed. Every scanner may reach preferences (cfprefsd),
logging (logd), notifications (notifyd) and user and group lookups
(opendirectoryd). A scanner with a network may also reach the DNS and network
configuration (configd) and certificate trust (trustd). The other four are not
allowed: LaunchServices, which node looks up on start and does without, and the
keychain daemon (SecurityServer) and a telemetry service, both looked up by
/usr/bin/security. LaunchServices (launchservicesd, coreservicesd,
com.apple.lsd.*), the pasteboard (com.apple.pasteboard.*) and the keychain daemon
are denied after every allow, so no allow can reach them: LaunchServices asks launchd
to start an app, and launchd starts it outside the sandbox. The keychain files
themselves (/Library/Keychains and ~/Library/Keychains) are denied after every
file rule.
semgrep's core runs /usr/bin/security only to read the system root certificates,
through OCaml's ca-certs. For a scanner that declares this need
(SandboxRequirements.system_trust_roots), ASH exports the same certificates before
the spawn, outside the sandbox, with the same command (security find-certificate -a
-p on the system root and system keychains), writes them to a file the scanner can
read and not write, and sets SSL_CERT_FILE to it, which ca-certs reads instead. An
SSL_CERT_FILE you set yourself is passed through and wins.
sandbox-exec has no throwaway overlay, so caches are read-only there, as above. The macOS-specific locations are declared per scanner and granted as narrowly as the tool allows:
- grype's database at
~/Library/Caches/grype, its default location on macOS, is a read-only cache, from which nothing may run. Before an online scan ASH updates it there, outside the sandbox, as it does~/.cache/grypeon Linux. The database trivy and trivy-repo share at~/Library/Caches/trivyis handled the same way. - cdk-nag runs with jsii's package cache disabled, so jsii unpacks into its own
temporary directory instead of the shared
~/Library/Caches/com.amazonaws.jsii, whose JavaScript every CDK process on the machine runs. - opengrep's macOS binary unpacks itself to
$XDG_CACHE_HOME/opengrep/<version>and runsopengrep.binfrom there.XDG_CACHE_HOMEpoints at a directory made for the spawn, writable and executable for it alone, and removed when it exits; it is the only writable directory a program may run from. uv tool runopens uv's tools-directory lock read-write before it looks for an installed tool. That one file is writable; the tools directory is not.- A script's interpreter, from its
#!line, has its install prefix readable, so a RubyGems wrapper finds its Ruby's library when Ruby is installed under$HOME.
sandbox-exec does not end processes the scanner leaves running. ASH's removal of symlinks after each spawn and its non-following writes still apply, but a process that outlives the scanner could replace a subdirectory of the results directory with a link between the sweep and ASH's next write there. That is a known gap of this backend.
A macOS release or a scanner update can make a tool look up a service that is not on the list. The profile denies it, the tool usually fails or reports less, and the unified log says which service it was:
log show --last 10m --predicate 'sender == "Sandbox"' | grep -E 'deny\(1\) (mach-lookup|process-exec)'
The list is MACH_SERVICES and MACH_SERVICES_WITH_NETWORK in
automated_security_helper/utils/sandbox/backends.py.
Risk to record: Apple has marked sandbox-exec deprecated since macOS 10.13 and
documents SBPL as private. It still works on current macOS and is what Apple's own
tools and several other developer tools use, but a future macOS could remove or change
it without notice. ASH probes it at startup like every other backend, so removal would
show up as "sandbox-exec unavailable" and MISSING scanners, not as an unsandboxed
scan.
Windows¶
There is no sandbox backend on Windows. With --sandbox auto, every scanner is
reported MISSING with:
no scanner sandbox is available on Windows; run ASH under WSL2 and use --sandbox bwrap there, or use --mode container
Run ASH under WSL2 and use bwrap there, or use container mode. A native backend would use an AppContainer or a restricted token plus
a job object; that is a larger piece of work and is not in this release.
Why not gVisor or nsjail by default¶
gVisor (runsc) gives the strongest isolation here because scanner syscalls go to a
user-space kernel, but it is a container runtime: it wants an OCI bundle and root or a
rootless setup, which is what container mode already provides. nsjail is close to
bubblewrap in capability but is not packaged on most distributions and is configured
through protobuf files; bubblewrap is packaged everywhere (Flatpak depends on it) and
its command line maps one to one onto the policy above.
Verifying it¶
tests/integration/sandbox/ holds a fixture scanner that tries to read a
file outside the source tree (a planted ~/.ssh/id_rsa), write outside its results
directory, open a network socket under --offline, and modify the source tree. CI runs
it under each backend available on the runner. Each attempt must fail with the sandbox
on, and succeed with --sandbox off, which is the negative control that proves the
attempts are real. The one exception is the links the fixture plants for ASH to write
through: ASH refuses those with the sandbox off too, and the control checks that it
does. CI also runs every builtin scanner under bubblewrap on Linux and
under sandbox-exec on macOS against the snapshot fixture, offline and online, and
asserts the findings match the unsandboxed run.
On macOS, tests/integration/sandbox/test_sandbox_exec_services.py has the fixture
scanner open TextEdit through LaunchServices, read a canary back from the pasteboard
and from the keychain, read the keychain files, and look up the LaunchServices,
pasteboard and keychain Mach services directly. Each must succeed unsandboxed and fail
under sandbox-exec.