Skip to content

sandbox: chdir into tmpdir before exec to avoid getcwd EPERM#22438

Closed
jamessawle wants to merge 1 commit into
Homebrew:masterfrom
jamessawle:sandbox-chdir-before-exec
Closed

sandbox: chdir into tmpdir before exec to avoid getcwd EPERM#22438
jamessawle wants to merge 1 commit into
Homebrew:masterfrom
jamessawle:sandbox-chdir-before-exec

Conversation

@jamessawle
Copy link
Copy Markdown
Contributor

@jamessawle jamessawle commented May 28, 2026


  • Have you followed the guidelines in our Contributing document?
  • Have you checked to ensure there aren't other open Pull Requests for the same change?
  • Have you added an explanation of what your changes do and why you'd like us to include them? Performance claims (e.g. "this is faster") must include Hyperfine benchmarks.
  • Have you written new tests (excluding integration tests) for your changes? Here's an example.
  • Have you successfully run brew lgtm (style, typechecking and tests) with your changes locally?

  • AI was used to generate or assist with generating this PR.

Diagnosis and patch were produced with Claude Code. The author read the upstream source (sandbox.rb, utils/fork.rb, mktemp.rb, resource.rb), the macOS kernel log (log show … Sandbox), and the failing HOMEBREW_DEBUG=1 stack traces directly before, during, and after the change — I'm vouching for the fix on the merits, not because an LLM produced it. Earlier sessions (including one in jamessawle/osch#67) chased two wrong hypotheses (eager Pathname.pwd in Resource#unpack, and macOS TCC tightening) before kernel-log + source reading landed on this one; that history is in the issue for anyone curious about what was not the bug.


Summary

Since commit dd8119e ("Harden sandboxed install phases", 2026-05-27), Sandbox#deny_read_home deny-reads sensitive home subpaths (~/Documents, ~/.ssh, ~/.aws, ~/.gnupg, ~/.config/gcloud, ~/Library/Keychains, ~/Dropbox, etc.) inside the build and postinstall sandboxes.

The sandboxed child inherits the user's CWD from the parent brew invocation. When the user runs brew install <source-style-formula> from a CWD under one of those denied subpaths, the first getcwd(3) in the child — getcwd walks every parent inode calling file-read-metadata — hits the deny rule, returns EPERM, and the install crashes with:

Error: An exception occurred within a child process:
  Errno::EPERM: Operation not permitted - getcwd

The first getcwd happens to live in Resource#unpack (eager current_working_directory = Pathname.pwd at the top of the method), but removing that just defers the EPERM by one frame to Mktemp#run's block-form Dir.chdir (Ruby's block-form Dir.chdir calls getcwd internally to save the previous directory). There are likely more such call sites downstream. So this PR fixes the underlying cause rather than the first symptom: it moves the sandboxed child into a non-denied directory before exec, so every subsequent getcwd walks only /opt/homebrew/... parents.

The patch

In Sandbox#run's child branch, just before exec-ing into the sandbox profile, Dir.chdir(tmpdir) (where tmpdir is the per-invocation Dir.mktmpdir("homebrew-sandbox", HOMEBREW_TEMP) already allocated a few lines up). The non-block form of Dir.chdir does not itself call getcwd, so this works even when starting from a denied CWD — only chdir(2) runs.

What this does NOT change

  • The deny rules added in dd8119e remain intact; this PR does not weaken sandbox hardening in any way.
  • Bottles were never affected (they cd into HOMEBREW_CELLAR/<name>/<version> before any getcwd runs); behaviour is unchanged there.
  • Resource#unpack's eager Pathname.pwd is left alone — with the CWD now under HOMEBREW_TEMP it walks only safe parents, and removing it would be a real (if usually-dead) semantic change for relative-target callers per a01715f.

Repro

Before this patch, on macOS with Homebrew including dd8119e:

cd ~/Documents/anywhere   # or ~/.ssh, ~/.aws, etc.
brew install --build-from-source <any-source-style-formula>
# Error: An exception occurred within a child process:
#   Errno::EPERM: Operation not permitted - getcwd

Kernel log during the failure:

kernel: (Sandbox) Sandbox: ruby(PID) deny(1) file-read-metadata /Users/<u>/Documents

Stack trace (with HOMEBREW_DEBUG=1), showing both candidate first-getcwd sites depending on which one you fix:

Errno::EPERM: Operation not permitted - getcwd
  Library/Homebrew/resource.rb:142:in 'Pathname.getwd'        # original
  Library/Homebrew/resource.rb:142:in 'Resource#unpack'
  …
Errno::EPERM: Operation not permitted - getcwd
  Library/Homebrew/mktemp.rb:97:in 'Dir.chdir'                # after fixing resource.rb only
  Library/Homebrew/mktemp.rb:97:in 'Mktemp#run'
  Library/Homebrew/resource.rb:286:in 'Resource#stage_resource'
  …

Verification

Tested locally on macOS 26.5 arm64, Homebrew 5.1.14-41-g0cba9a2:

Scenario Before patch After patch
cd ~/Documents/foo && brew reinstall jamessawle/tap/osch (source-style) EPERM crash succeeds
cd ~ && brew reinstall jamessawle/tap/osch (source-style) succeeds succeeds
cd ~/Documents/foo && brew reinstall jq (bottle) succeeds succeeds
brew tests --only=sandbox passes passes
brew tests --only=resource passes passes
brew lgtm (typecheck + style + changed tests) n/a passes

Tests

Added sandbox_spec.rb regression: stand up a fresh Sandbox, deny_read_path a tmpdir, Dir.chdir into it, and run /bin/pwd through the sandbox. Without the patch this raises ErrorDuringExecution (sandbox-exec exits 1 from getcwd EPERM); with the patch it succeeds. Verified both directions locally by stashing/unstashing the sandbox.rb change.

Links

`Sandbox#deny_read_home` (dd8119e) deny-reads home subpaths such as
`~/Documents`, `~/.ssh`, `~/.aws` inside the build/postinstall sandbox.
The sandboxed child inherits the user's CWD from the parent `brew`
invocation, so when the user runs `brew install` from a CWD under one
of those subpaths, the first `getcwd(3)` in the child (e.g. in
`Resource#unpack` or `Mktemp#run`'s block-form `Dir.chdir`) walks across
a denied parent and the install crashes with:

    Error: An exception occurred within a child process:
      Errno::EPERM: Operation not permitted - getcwd

Move the sandbox child into its own tmpdir (under `HOMEBREW_TEMP`) before
`exec` so every subsequent `getcwd` walks only `/opt/homebrew` parents.
The non-block `Dir.chdir` does not itself call `getcwd` and so works
even from a denied CWD. The deny rules added in dd8119e are unchanged.

Only source-style installs were affected; bottles take a different path
that already `cd`s into the Cellar before `getcwd` runs.
@github-actions
Copy link
Copy Markdown

Thanks for your pull request. This has been closed because it appears to use an incomplete or outdated pull request template.

Please edit the pull request body to fill in the current pull request template. This workflow will reopen the pull request automatically once the template is complete.

@github-actions github-actions Bot closed this May 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant