Sandbox execs a symlinked wrapped tool via its realpath, breaking argv0 self-location (linuxbrew brew prefix) #184

Closed
opened 2026-07-03 07:42:27 +00:00 by coilyco-ops · 1 comment
Member

Summary

When the sandbox jails a wrapped tool that is a symlink, it stashes and execs the tool's fully EvalSymlinks-resolved realpath (cli/sandbox/sandbox_linux.go, installToolShim). A tool that self-locates from argv0 (its own invoked path) then computes the wrong base directory, because the realpath can sit in a structurally different location than the canonical PATH symlink.

Concrete failure

Homebrew on the standard linuxbrew layout. /home/linuxbrew/.linuxbrew/bin/brew is a symlink into /home/linuxbrew/.linuxbrew/Homebrew/bin/brew. bin/brew derives HOMEBREW_PREFIX from argv0's grandparent on every run and never reads it from the environment:

BREW_FILE_DIRECTORY="$(quiet_cd "${0%/*}/" && pwd -P)"
HOMEBREW_BREW_FILE="${BREW_FILE_DIRECTORY%/}/${0##*/}"
HOMEBREW_PREFIX="${HOMEBREW_BREW_FILE%/*/*}"
  • via the canonical symlink .../.linuxbrew/bin/brew -> prefix .../.linuxbrew (correct).
  • via the realpath .../.linuxbrew/Homebrew/bin/brew (what the jail execs) -> prefix .../.linuxbrew/Homebrew, whose Cellar is empty.

So inside the jail every brew operation targets a phantom empty prefix: brew upgrade reports installed kegs "not installed", brew install rebuilds from source into an off-PATH prefix. Full trace and the launcher walkthrough are in ward#546 / ward#543 / ward#537.

Why the current resolution is too aggressive

installToolShim EvalSymlinks the canonical path only to obtain a target distinct from the shim-masked entry (so the exec does not loop back into the shim). But full symlink resolution over-collapses: it discards the canonical directory structure that self-locating tools read from argv0. Resolving just enough to escape the masked inode is not enough here either, since brew's single-level symlink lands exactly on the bad realpath.

Suggested fix

For a wrapped tool whose canonical PATH entry is a symlink, re-invoke it from an unmasked alias in the canonical directory (e.g. bind or link a sibling like <dir>/.<tool>-real alongside the masked <dir>/<tool>), so argv0's parent/grandparent still resolve to the canonical prefix while the exec target still points at the real binary and bypasses the shim. Compiled binaries at their own path (no symlink) keep the current bind-stash behavior. Apple Silicon Homebrew is unaffected either way (prefix == repository, no nesting).

Add a regression test that jails a symlinked shim <root>/bin/tool -> <root>/repo/bin/tool and asserts the exec'd argv0's grandparent is <root>, not <root>/repo.

Interim state

ward has a product-layer workaround landed (ward#543): execBrewRaw drops the jail for the brew exec when LookPath("brew") and its realpath have different grandparents, running brew via the canonical symlink. That keeps brew working on linuxbrew but leaves brew's build subprocesses un-jailed there. This engine fix would let ward restore the jail for brew (drop that special-case).

## Summary When the sandbox jails a wrapped tool that is a **symlink**, it stashes and execs the tool's fully `EvalSymlinks`-resolved realpath (`cli/sandbox/sandbox_linux.go`, `installToolShim`). A tool that self-locates from `argv0` (its own invoked path) then computes the wrong base directory, because the realpath can sit in a structurally different location than the canonical PATH symlink. ## Concrete failure Homebrew on the standard **linuxbrew** layout. `/home/linuxbrew/.linuxbrew/bin/brew` is a symlink into `/home/linuxbrew/.linuxbrew/Homebrew/bin/brew`. `bin/brew` derives `HOMEBREW_PREFIX` from `argv0`'s grandparent on every run and never reads it from the environment: ``` BREW_FILE_DIRECTORY="$(quiet_cd "${0%/*}/" && pwd -P)" HOMEBREW_BREW_FILE="${BREW_FILE_DIRECTORY%/}/${0##*/}" HOMEBREW_PREFIX="${HOMEBREW_BREW_FILE%/*/*}" ``` - via the canonical symlink `.../.linuxbrew/bin/brew` -> prefix `.../.linuxbrew` (correct). - via the realpath `.../.linuxbrew/Homebrew/bin/brew` (what the jail execs) -> prefix `.../.linuxbrew/Homebrew`, whose Cellar is empty. So inside the jail every brew operation targets a phantom empty prefix: `brew upgrade` reports installed kegs "not installed", `brew install` rebuilds from source into an off-PATH prefix. Full trace and the launcher walkthrough are in ward#546 / ward#543 / ward#537. ## Why the current resolution is too aggressive `installToolShim` `EvalSymlinks` the canonical path only to obtain a target distinct from the shim-masked entry (so the exec does not loop back into the shim). But full symlink resolution over-collapses: it discards the canonical directory structure that self-locating tools read from `argv0`. Resolving *just enough to escape the masked inode* is not enough here either, since brew's single-level symlink lands exactly on the bad realpath. ## Suggested fix For a wrapped tool whose canonical PATH entry is a symlink, re-invoke it from an **unmasked alias in the canonical directory** (e.g. bind or link a sibling like `<dir>/.<tool>-real` alongside the masked `<dir>/<tool>`), so `argv0`'s parent/grandparent still resolve to the canonical prefix while the exec target still points at the real binary and bypasses the shim. Compiled binaries at their own path (no symlink) keep the current bind-stash behavior. Apple Silicon Homebrew is unaffected either way (prefix == repository, no nesting). Add a regression test that jails a symlinked shim `<root>/bin/tool -> <root>/repo/bin/tool` and asserts the exec'd `argv0`'s grandparent is `<root>`, not `<root>/repo`. ## Interim state ward has a product-layer workaround landed (ward#543): `execBrewRaw` drops the jail for the brew exec when `LookPath("brew")` and its realpath have different grandparents, running brew via the canonical symlink. That keeps brew working on linuxbrew but leaves brew's build subprocesses un-jailed there. This engine fix would let ward restore the jail for brew (drop that special-case).
Author
Member

Director burn-down cleanup: closing this as fixed by commit 8886aee (fix(sandbox): exec a symlinked tool via a canonical-dir symlink, not its realpath), which is on main and implements the core argv0-preserving symlink behavior requested here. The remaining known drawback from that fix is tracked separately in cli-guard#185: leak-free placement for the .<tool>.cliguard exec symlink.

Director burn-down cleanup: closing this as fixed by commit `8886aee` (`fix(sandbox): exec a symlinked tool via a canonical-dir symlink, not its realpath`), which is on `main` and implements the core argv0-preserving symlink behavior requested here. The remaining known drawback from that fix is tracked separately in cli-guard#185: leak-free placement for the `.<tool>.cliguard` exec symlink.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
coilyco-flight-deck/cli-guard#184
No description provided.