Skip to content

macOS: harness reached through a multi-hop symlink chain fails with a misleading ENOENT #229

Description

@NoRiceToday

Context / Summary

Reported on omac 0.7.1 (macOS 26.6.1, arm64). omac start opencode -- --version
exits 71 with

sandbox-exec: execvp() of 'opencode' failed: No such file or directory

on a machine where which opencode resolves fine. The same binary launched by
its fully resolved path (omac start opencode -inner <realpath> -- --version)
runs and prints its version. So the harness is present and executable — what
fails is reaching it from inside the sandbox.

Problem / What

The reporter's distinguishing property is Homebrew in a non-default prefix
(~/homebrew), which no baseline grant covers —
internal/sandboxprofile/baseline.go:106 grants /opt/homebrew,
/usr/local/Cellar, /usr/local/opt only. The harness is then reached through
a three-hop symlink chain:

~/homebrew/bin/opencode
  -> ~/homebrew/Cellar/opencode/1.18.15/bin/opencode                          <- never granted
  -> ~/homebrew/Cellar/opencode/1.18.15/libexec/.../opencode-ai/bin/opencode.exe

resolveCommandBinaryDirs (internal/sandboxrun/resolve_binary.go:47) grants
only the two ends of that chain — Dir(PATH entry) and
Dir(EvalSymlinks(...)). The kernel resolves a chain one hop at a time and
needs read access to each link it reads on the way, so resolution dies on the
middle directory. Seatbelt reports the denied in-sandbox PATH lookup of a bare
argv[0] as ENOENT, which names neither the deny nor the path.

Isolated from omac with a hand-written SBPL granting exactly the two ends —
three spellings of the same file behave differently, which is what pins the
cause to path resolution rather than to exec:

argv[0] result
opencode (bare — what omac passes) execvp() ... No such file or directory, rc 71
/…/homebrew/bin/opencode (absolute, still the link) Operation not permitted
/…/libexec/…/opencode.exe (resolved) runs, rc 0

Adding a read grant on the middle directory makes all three rc 0.

Two secondary gaps make the failure less readable than it needs to be:

  • BuildChildArgv hands sandbox-exec a bare argv[0]
    (internal/sandboxrun/backend_darwin.go:39), so the PATH lookup happens a
    second time inside the sandbox, where a failure surfaces as ENOENT.
  • The pre-flight checks harness.InnerCmd rather than the resolved argv
    (internal/cli/start.go:250), so a profile-pinned inner_cmd validates the
    wrong binary and --inner skips the check entirely.

Suggested fix / Ask

  • Grant the directory of every hop of the symlink chain, not just the two ends
  • Same for interpreter resolution — nvm/mise/asdf shims are multi-hop too
  • Pass argv[0] to sandbox-exec as an absolute path so the second, in-sandbox lookup disappears
  • Pre-flight the resolved argv, and warn up front when the inner command is unresolvable or sits inside a protected-path deny

Acceptance criteria

  • omac start opencode -- --version succeeds against a $HOME-rooted Homebrew prefix with a three-hop chain and no profile grant for that prefix
  • A profile that denies the tree holding the harness still denies it, and says so before the launch fails
  • Unit coverage for the multi-hop chain, for cycle termination, and for the unchanged single-hop/plain-binary cases

Non-goals

  • Resolving the harness out of a denied tree. Exec of a main image consults
    (allow process-exec*) and never the file-read rules, so passing the
    EvalSymlinks form would launch a binary the user's profile explicitly
    denies. omac must report that conflict, not route around it.
  • Widening the baseline to cover $HOME-rooted install prefixes wholesale.

Evidence / Environment

  • omac 0.7.1, macOS 26.6.1 arm64, Homebrew prefix ~/homebrew, opencode 1.18.15
  • Repro: fixture $HOME with the three-hop chain, profile with neither a
    filesystem.deny nor a grant for the prefix. The fixture must live outside
    /tmp
    — the baseline grants /tmp and /private/tmp, which masks the bug
    entirely.
  • 0.7.1 → exit 71 with the ENOENT above; a build carrying the chain grant →
    prints the version, exit 0.
  • Unrelated to this issue: the reporter's log also shows
    sandbox profile "builtin" could not be resolved ... GET /sandbox/denied disabled.
    That is already fixed on main by fix(cli,sandbox): resolve the sandbox plan once so /sandbox/denied answers #219 and is absent from current builds.

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions