feat(policy,sandbox): yabai + skhd bundles + unix_socket_connects primitive (SEA-636)#349
feat(policy,sandbox): yabai + skhd bundles + unix_socket_connects primitive (SEA-636)#349mattwilkinsonn wants to merge 1 commit into
Conversation
…mitive (SEA-636)
Adds two new curated bundles for macOS window-management CLIs:
- yabai — read bind for ~/.config/yabai plus AF_UNIX socket-connect
grants for /tmp/yabai_$USER.socket and /tmp/yabai-sa_$USER.socket
so yabai -m query/window/space/display/... reaches its daemon
from inside the sandbox-exec envelope. macOS-only in effect; the
bundle parses cross-platform for manifest stability.
- skhd — read bind for ~/.config/skhd. skhd auto-reloads on
skhdrc modtime change, so most agent workflows never need to
invoke the CLI; bundle ships for parity.
Introduces a new BundleSpec field, unix_socket_connects, with
matching primitives on both platforms:
- macOS: emits `(allow network-outbound (literal "<path>"))` SBPL
rules in compile_profile, with literal+canonical dual-emission
through host_fs.canonicalize so /tmp <-> /private/tmp firmlink
pathways both land on a matching rule. macOS's `(deny default)`
gates AF_UNIX connect even when the inode is fs-readable, so
the rule is load-bearing.
- Linux: emits `--bind-try <path> <path>` in compile_into so the
host-side socket inode is bind-mounted into the bwrap mount
namespace. `--unshare-net` doesn't gate AF_UNIX (those connects
are filesystem-routed), so reaching the inode through the
bind is what authorizes the connect.
`<USER>` placeholder in bundle-table paths expands against the
daemon's $USER at compile() time so the static table stays
user-agnostic. Entries fail-closed (dropped) when $USER is unset.
The field is reusable for future bundles wrapping local-daemon
CLIs (tmux, sketchybar, borders, socket_vmnet, i3-msg on Linux,
etc.) without each one needing bespoke SBPL/bind plumbing.
## Sandbox-in-sandbox fixes (out of scope for SEA-636 proper but landing here)
Two macOS-side issues surfaced while implementing the bundle:
1. `(allow file-read* (literal "/private"))` added to the SBPL
baseline. Symptom: any `realpath(3)` / `std::fs::canonicalize`
walk through `/tmp` (a firmlink to `/private/tmp`) would
return EPERM on the `lstat("/private")` step because the
baseline's subpath rules covered `/private/tmp` but not
`/private` itself. Same shape as the existing `(allow
file-read* (literal "/"))` workaround at the root vnode. Five
insta snapshots regenerated.
2. `sandbox_exec_can_nest()` probe added to
`sandbox_exec_integration.rs`. macOS `sandbox_apply()` returns
EPERM when called from inside an existing sandbox-exec'd
process — there's no Apple-blessed nesting mechanism (the
command is officially deprecated per `man 1 sandbox-exec`).
The 7 integration tests now skip cleanly when run from inside
a seal session instead of hard-failing with
`"sandbox_apply: Operation not permitted"`. Mirrors the Linux
side's `find_bwrap()` skip pattern.
3. Pre-existing `-D warnings` blockers on macOS cleaned up so
`cargo clippy --workspace --all-targets -- -D warnings` passes:
`#[cfg_attr(not(target_os = "linux"), allow(dead_code))]` on
`SpawnRuntime`'s Linux-only proxy fields (`extra_ro_binds`,
`proxy_port`, `socks_uds`, `socks_port`, `loopback_dialer_uds`),
on `ProxyHandle::new`, on `ToolScope.mitm_ca`/`permissions`/
`cancel_tx`, and on `test_outbound_roots_override`. The
`NETBRIDGE_IN_SANDBOX_PATH` const moved behind
`#[cfg(target_os = "linux")]` since it's strictly Linux-only.
The `mut` on `ca_env`/`ca_binds` in `tool_scope.rs` gets
`#[allow(unused_mut)]` because the mutation is itself
`#[cfg(target_os = "linux")]`. The two
`std::fs::read_to_string(profile_path)` calls in
`sandbox_spawn/macos.rs` tests swap to
`seal_utils::io::read` + `String::from_utf8` per the
`disallowed_methods` lint. Each annotation references SEA-634
so once the macOS proxy bridge lands these revert to plain
reads.
## Out of scope
`yabai --restart-service` / `skhd --restart-service`. Both shell
out to `launchctl bootout/bootstrap/kickstart` against the
`gui/<uid>` launchd domain. SBPL can't authorize those without
granting `(signal others)` + `(mach-priv-task-port others)`
system-wide — too coarse for a default-on bundle. Restart from a
non-sandboxed shell; revisit if usage demands an escape hatch
(e.g. a per-bundle `host_exec` field).
## Tests
- seal-policy: yabai/skhd shorthand + expanded parsing; the
yabai bundle's curated socket-connect list is pinned; every
non-yabai bundle has an empty unix_socket_connects list
(catches accidental over-application).
- seal-sandbox compile: `<USER>` template substitution unit tests
(passthrough, single, multi-occurrence, drop-when-unset);
yabai bundle populates KernelParams.unix_socket_connects on
yabai:* spawns, drops when $USER unset, doesn't leak to
unrelated patterns, dedupes across compile() calls, dual-emits
firmlink-canonical form.
- seal-sandbox macOS: SBPL emission (single literal, multi-
literal order preservation, no-section-when-empty, escape
handling for quotes + backslashes).
- seal-sandbox Linux: --bind-try emission, one bind per entry,
empty list emits nothing extra, socket bind precedes tmpdir
bind in argv ordering.
- seal-sandbox Linux integration (bwrap_integration.rs): two
end-to-end tests — positive (UnixListener on host + bwrap'd
client connects + marker round-trips through the bind),
negative (without unix_socket_connects, in-namespace connect
fails with ENOENT, proving the bind is load-bearing).
- 5 macOS insta snapshots regenerated for the `/private`
literal allow.
## Docs
- docs/site/scripts/gen-schema-reference.ts list updated.
- Reference page regenerated; yabai + skhd appear under
[sandbox.os.command_tools].
- Schema regen: schemas/seal.toml.json updated by
`cargo run -p seal-policy --bin generate-schema`.
Linear: SEA-636.
SEA-636 sandbox(bundles): yabai + skhd bundles + unix_socket_connects primitive on BundleSpec
yabai + skhd bundles +
|
How to use the Graphite Merge QueueAdd either label to this PR to merge it via the merge queue:
You must have a Graphite account in order to use the merge queue. Sign up using this link. An organization admin has required the Graphite Merge Queue in this repository. Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue. This stack of pull requests is managed by Graphite. Learn more about stacking. |
|
Docs preview: https://27a35f74.seal-docs.pages.dev |

Pull request
Summary
Adds
yabaiandskhdtool bundles to the sandbox catalog, and introduces theunix_socket_connectsprimitive onBundleSpecso curated bundles can declare AF_UNIX socket paths a tool needs toconnect(2)to. Theyabaibundle uses this to authorize CLI access to the yabai window manager daemon socket from inside the macOS sandbox.Related issues
Refs SEA-636
Changes
unix_socket_connectsfield onBundleSpecandGrantToolEntry: carries verbatim socket path templates (e.g./tmp/yabai_<USER>.socket) with<USER>substitution deferred to compile time so the grant hash stays user-agnostic.compile_profileemits(allow network-outbound (literal "..."))rules per resolved entry, with literal + canonical dual-emission to cover the/tmp↔/private/tmpfirmlink on macOS.compile_intoemits--bind-try <socket> <socket>per entry so the host-side socket inode is reachable inside the mount namespace (--unshare-netdoesn't gate AF_UNIX, but the inode must be present).yabaibundle: read bind for~/.config/yabai;unix_socket_connectsentries for/tmp/yabai_<USER>.socketand/tmp/yabai-sa_<USER>.socket. No curated network domains (yabai is local-only). Does not cover--restart-service(requireslaunchctlsystem-wide privileges).skhdbundle: read bind for~/.config/skhd; no socket connects needed (skhd auto-reloads on config file modtime change and its CLI verbs don't require a daemon socket).(allow file-read* (literal "/private"))to unblockrealpath/canonicalizetraversal through macOS firmlinks — without it,canonicalize("/tmp/foo")returns EPERM even when/private/tmpsubpath rules are present.sandbox_exec_can_nest()probe: integration tests now skip gracefully when running inside an outersandbox-execwrapper (e.g.cargo nextestinvoked from a seal session) rather than failing with EPERM.Test plan
seal-policypin theyabaibundle'sunix_socket_connectslist and verify every other bundle has an empty list.seal-sandbox::compilecover<USER>substitution, drop-on-missing-$USER, no-leak-to-unrelated-pattern, deduplication, and firmlink dual-emit via a stubHostFs.seal-sandbox::macosverify SBPLnetwork-outboundliteral emission, ordering, empty-list no-op, and quote/backslash escaping.seal-sandbox::linuxverify--bind-tryemission, one-bind-per-entry, empty-list no-op, and ordering relative to tmpdir.unix_socket_connect_succeeds_inside_namespace/unix_socket_connect_fails_inside_namespace_without_bind) provide end-to-end positive and negative verification that the--bind-tryis the load-bearing step.Notes for reviewers
yabai --restart-serviceis explicitly out of scope — it requireslaunchctl bootout/bootstrap/kickstartagainst thegui/<uid>launchd domain, which would need(signal others)+(mach-priv-task-port others)SBPL grants system-wide. Users should restart yabai from a non-sandboxed shell. The macOS proxy bridge (SEA-634) is still pending; several#[cfg_attr(not(target_os = "linux"), allow(dead_code))]annotations are added to suppress warnings on macOS until that work lands.Need help on this PR? Tag
@codesmithwith what you need. Autofix is disabled.