Allow launches only under configured roots #3

Merged
benvin merged 8 commits from benvin/launch-dir-allowlist into main 2026-10-24 20:40:16 +11:00
Member

Why: a sandbox could reach the host through any writable dir it is launched from: $HOME, config, the API socket, a system dir, or a $PATH dir where a planted binary runs on the host.

  • Launch only from a dir strictly under an allowed root (allowed-paths, default $HOME minus dot-dirs).
  • Refuse dirs that are or contain $HOME, the allowlist or any $PATH entry, or that overlap /run, /tmp, /var/tmp, /proc, /sys, /dev, config home, runtime dir, ~/.ssh, ~/.gnupg or container storage; listed roots that hit these abort.
  • Mount the resolved, checked path as source; the launch path stays the in-container path and workdir.
  • Tests use fixture dirs only; README notes launch-dir files can still run on the host.

Stacked on #2.

Why: a sandbox could reach the host through any writable dir it is launched from: $HOME, config, the API socket, a system dir, or a $PATH dir where a planted binary runs on the host. - Launch only from a dir strictly under an allowed root (allowed-paths, default $HOME minus dot-dirs). - Refuse dirs that are or contain $HOME, the allowlist or any $PATH entry, or that overlap /run, /tmp, /var/tmp, /proc, /sys, /dev, config home, runtime dir, ~/.ssh, ~/.gnupg or container storage; listed roots that hit these abort. - Mount the resolved, checked path as source; the launch path stays the in-container path and workdir. - Tests use fixture dirs only; README notes launch-dir files can still run on the host. Stacked on #2.
Author
Member
  • main.go:361 — default root $HOME minus dot-dirs still admits non-dot dirs on the host PATH (~/bin, ~/go → ~/go/bin); cd ~/bin && agent-sandbox passes and lets the agent drop host executables, same class as the /run/user escape → refuse launch dirs that are or contain a $PATH entry (or drop the implicit $HOME default and require allowed-paths)
  • main.go:404 — check runs on realpath(pwd) but main.go:276 bind-mounts the unresolved pwd (os.Getwd returns a symlinked $PWD); podman resolves the symlink at mount time, so a symlink swapped after the check (e.g. by an agent in another session with the parent mounted) mounts an unchecked target → mount and WorkDir the resolved path that was checked
  • nit: main_test.go:330 — t.Skip on missing dir silently skips run-user/symlink-to-run-user where /run/user/$UID is absent (CI pods) → create the target under the test tree or fail instead of skip
- main.go:361 — default root `$HOME` minus dot-dirs still admits non-dot dirs on the host PATH (`~/bin`, `~/go` → `~/go/bin`); `cd ~/bin && agent-sandbox` passes and lets the agent drop host executables, same class as the /run/user escape → refuse launch dirs that are or contain a `$PATH` entry (or drop the implicit `$HOME` default and require allowed-paths) - main.go:404 — check runs on `realpath(pwd)` but main.go:276 bind-mounts the unresolved `pwd` (`os.Getwd` returns a symlinked `$PWD`); podman resolves the symlink at mount time, so a symlink swapped after the check (e.g. by an agent in another session with the parent mounted) mounts an unchecked target → mount and `WorkDir` the resolved path that was checked - nit: main_test.go:330 — `t.Skip` on missing dir silently skips `run-user`/`symlink-to-run-user` where `/run/user/$UID` is absent (CI pods) → create the target under the test tree or fail instead of skip
Author
Member
  • main.go:372 — inside(r, bad) only rejects roots under a forbidden dir, not roots containing one: allowed-paths = /var is accepted and checkLaunchDir("/var/tmp") returns ok, so /var/tmp gets mounted rw → also reject inside(bad, r) (or check pwd against forbiddenRoots in checkLaunchDir), and add /var to the TestCheckLaunchDirRoots bad list.
  • nit: main.go:419 — filepath.Dir(paths) is configDir(), already first in the list → drop it.
- main.go:372 — `inside(r, bad)` only rejects roots under a forbidden dir, not roots containing one: `allowed-paths` = `/var` is accepted and `checkLaunchDir("/var/tmp")` returns ok, so /var/tmp gets mounted rw → also reject `inside(bad, r)` (or check pwd against forbiddenRoots in checkLaunchDir), and add `/var` to the TestCheckLaunchDirRoots bad list. - nit: main.go:419 — `filepath.Dir(paths)` is `configDir()`, already first in the list → drop it.
Author
Member
  • main.go:414 — under the default $HOME root, launch dirs are never checked against sensitiveDirs, so a non-dot XDG_CONFIG_HOME/XDG_DATA_HOME (e.g. ~/cfg, ~/data) lets you launch from ~/cfg/systemd or ~/data/containers (podman storage), which listed roots reject → in checkLaunchDir, refuse any pwd that overlaps sensitiveDirs(home) for every root, default included, and add a refusal test.
  • nit: main_test.go:599 — cfg and cfg + "/agent-sandbox/x" sit under /tmp, so they fail on the /tmp rule. Dropping filepath.Dir(configDir()) or the XDG_DATA_HOME containers entry from sensitiveDirs still passes every test → use a non-/tmp XDG_CONFIG_HOME/XDG_DATA_HOME fixture and check for the "overlaps" reason, not just "allowed-paths".
- main.go:414 — under the default $HOME root, launch dirs are never checked against `sensitiveDirs`, so a non-dot `XDG_CONFIG_HOME`/`XDG_DATA_HOME` (e.g. `~/cfg`, `~/data`) lets you launch from `~/cfg/systemd` or `~/data/containers` (podman storage), which listed roots reject → in checkLaunchDir, refuse any pwd that overlaps `sensitiveDirs(home)` for every root, default included, and add a refusal test. - nit: main_test.go:599 — `cfg` and `cfg + "/agent-sandbox/x"` sit under /tmp, so they fail on the /tmp rule. Dropping `filepath.Dir(configDir())` or the `XDG_DATA_HOME` containers entry from `sensitiveDirs` still passes every test → use a non-/tmp `XDG_CONFIG_HOME`/`XDG_DATA_HOME` fixture and check for the "overlaps" reason, not just "allowed-paths".
Author
Member
  • main.go:432 — launch-dir forbiddenRoots loop is untested: deleting it passes every test; main_test.go:375 ("forbidden default root") is also satisfied by the $XDG_RUNTIME_DIR→/tmp fallback in sensitiveDirs → set XDG_RUNTIME_DIR elsewhere in that case so only the forbidden-root rule can refuse HOME=/tmp/home.
  • main.go:427 — filepath.Dir(realpath(paths)) is untested: deleting it passes every test (the allowed-paths-dir case is caught by configDir()/the sensitive check first); without it, a launch dir holding the target of a symlinked allowed-paths (e.g. a dotfiles repo) is mounted RW, and the sandbox can add any root → add a refusal case with allowed-paths symlinked into a dir under a root.
  • main.go:379 — the top-level dot-dir root rejection is untested: deleting it passes every test (~/.local, ~/.local/share are rejected by the podman-storage sensitive check instead) → add a root like ~/.cache and assert the "dot-directory" error.
  • main.go:376 — the "is or contains $HOME" root rejection is untested: deleting it passes every test (/, /home, ~ hit the forbidden/sensitive checks first, and the test only matches the "allowed-paths" substring) → assert the "$HOME" message for those roots.
  • nit: main.go:93 — the : check on src is untested → add a case with a symlink whose target has a : in it.
- main.go:432 — launch-dir `forbiddenRoots` loop is untested: deleting it passes every test; `main_test.go:375` ("forbidden default root") is also satisfied by the `$XDG_RUNTIME_DIR`→`/tmp` fallback in `sensitiveDirs` → set `XDG_RUNTIME_DIR` elsewhere in that case so only the forbidden-root rule can refuse `HOME=/tmp/home`. - main.go:427 — `filepath.Dir(realpath(paths))` is untested: deleting it passes every test (the `allowed-paths-dir` case is caught by `configDir()`/the sensitive check first); without it, a launch dir holding the target of a symlinked `allowed-paths` (e.g. a dotfiles repo) is mounted RW, and the sandbox can add any root → add a refusal case with `allowed-paths` symlinked into a dir under a root. - main.go:379 — the top-level dot-dir root rejection is untested: deleting it passes every test (`~/.local`, `~/.local/share` are rejected by the podman-storage sensitive check instead) → add a root like `~/.cache` and assert the "dot-directory" error. - main.go:376 — the "is or contains $HOME" root rejection is untested: deleting it passes every test (`/`, `/home`, `~` hit the forbidden/sensitive checks first, and the test only matches the "allowed-paths" substring) → assert the "$HOME" message for those roots. - nit: main.go:93 — the `:` check on `src` is untested → add a case with a symlink whose target has a `:` in it.
benvin changed target branch from benvin/go-launcher to main 2026-10-24 18:43:26 +11:00
benvin added 8 commits 2026-10-24 18:43:26 +11:00
Allow launches only under configured roots
ci/woodpecker/pr/lint Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
3ec134147a
Replace the launch-dir denylist with an allowlist of roots from
allowed-paths (default $HOME), always refusing $HOME dot-dirs and
invalid roots under /run, /tmp, /var/tmp, /proc, /sys or /dev.
Trust listed roots under dot-dirs, reject sensitive ones
ci/woodpecker/pr/lint Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
90c7412194
Merge benvin/go-launcher
ci/woodpecker/pr/lint Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
3f1cc9644d
# Conflicts:
#	main_test.go
Refuse launch dirs holding $PATH entries and mount the checked path
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/lint Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
f60caa7e98
Refuse roots and launch dirs overlapping forbidden system dirs
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/lint Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
80c90769fd
Refuse launch dirs overlapping sensitive dirs under every root
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/lint Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
9958b5557e
benvin merged commit e866d5d1b4 into main 2026-10-24 20:40:16 +11:00
benvin deleted branch benvin/launch-dir-allowlist 2026-10-24 20:40:16 +11:00
Sign in to join this conversation.