Skip to content

feat(policy,sandbox): yabai + skhd bundles + unix_socket_connects primitive (SEA-636)#349

Open
mattwilkinsonn wants to merge 1 commit into
mainfrom
sea-636-yabai-skhd-bundles-unix-sockets
Open

feat(policy,sandbox): yabai + skhd bundles + unix_socket_connects primitive (SEA-636)#349
mattwilkinsonn wants to merge 1 commit into
mainfrom
sea-636-yabai-skhd-bundles-unix-sockets

Conversation

@mattwilkinsonn

@mattwilkinsonn mattwilkinsonn commented May 26, 2026

Copy link
Copy Markdown
Contributor

Pull request

Summary

Adds yabai and skhd tool bundles to the sandbox catalog, and introduces the unix_socket_connects primitive on BundleSpec so curated bundles can declare AF_UNIX socket paths a tool needs to connect(2) to. The yabai bundle 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_connects field on BundleSpec and GrantToolEntry: 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.
  • macOS SBPL emission: compile_profile emits (allow network-outbound (literal "...")) rules per resolved entry, with literal + canonical dual-emission to cover the /tmp/private/tmp firmlink on macOS.
  • Linux bwrap emission: compile_into emits --bind-try <socket> <socket> per entry so the host-side socket inode is reachable inside the mount namespace (--unshare-net doesn't gate AF_UNIX, but the inode must be present).
  • yabai bundle: read bind for ~/.config/yabai; unix_socket_connects entries for /tmp/yabai_<USER>.socket and /tmp/yabai-sa_<USER>.socket. No curated network domains (yabai is local-only). Does not cover --restart-service (requires launchctl system-wide privileges).
  • skhd bundle: 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).
  • macOS SBPL baseline fix: adds (allow file-read* (literal "/private")) to unblock realpath/canonicalize traversal through macOS firmlinks — without it, canonicalize("/tmp/foo") returns EPERM even when /private/tmp subpath rules are present.
  • sandbox_exec_can_nest() probe: integration tests now skip gracefully when running inside an outer sandbox-exec wrapper (e.g. cargo nextest invoked from a seal session) rather than failing with EPERM.
  • Schema, docs, and CHANGELOG updated to reflect the new bundles and primitive.

Test plan

  • New unit tests in seal-policy pin the yabai bundle's unix_socket_connects list and verify every other bundle has an empty list.
  • New unit tests in seal-sandbox::compile cover <USER> substitution, drop-on-missing-$USER, no-leak-to-unrelated-pattern, deduplication, and firmlink dual-emit via a stub HostFs.
  • New unit tests in seal-sandbox::macos verify SBPL network-outbound literal emission, ordering, empty-list no-op, and quote/backslash escaping.
  • New unit tests in seal-sandbox::linux verify --bind-try emission, one-bind-per-entry, empty-list no-op, and ordering relative to tmpdir.
  • New bwrap integration tests (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-try is the load-bearing step.

Notes for reviewers

yabai --restart-service is explicitly out of scope — it requires launchctl bootout/bootstrap/kickstart against the gui/<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.


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag @codesmith with what you need. Autofix is disabled.

…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.
@linear-code

linear-code Bot commented May 26, 2026

Copy link
Copy Markdown
SEA-636 sandbox(bundles): yabai + skhd bundles + unix_socket_connects primitive on BundleSpec

yabai + skhd bundles + unix_socket_connects primitive

Team: SEA
Project: seal 0.1 launch
Labels: sandbox, bundles, macos

Summary

Two new curated bundles (yabai, optionally skhd) plus a new
BundleSpec field — unix_socket_connects — to grant AF_UNIX socket
connect permissions to local-daemon CLIs that talk to their controller
over a host filesystem socket. yabai is the motivating use case; the
primitive is reusable for future bundles (tmux, sketchybar, borders,
socket_vmnet, i3-msg on Linux, etc.).

Motivating problem

yabai -m query --displays is a thin client that connects to
/tmp/yabai_$USER.socket (AF_UNIX, mode 0600). Inside seal's macOS
sandbox-exec envelope, the connect fails:

$ yabai -m query --displays
yabai-msg: failed to connect to socket..

The socket file is visible (system fs baseline + /tmp in
additional_directories) but the (deny default) SBPL baseline blocks
network-outbound against any AF_UNIX literal. There's no manifest
knob to lift it, and no curated bundle currently needs one because every
existing bundle talks TCP through the proxy bridge (when that lands on
macOS — see the separate macOS proxy bridge issue).

Result today: yabai works fine when launched from a normal shell,
silently fails when launched by an agent inside seal's sandbox. Same
shape for skhd reload signalling, and for future bundles wrapping local
daemon CLIs.

Proposed primitive: BundleSpec.unix_socket_connects

New field on BundleSpec (in crates/seal-policy/src/manifest/sandbox.rs):

pub struct BundleSpec {
    // ... existing fields ...

    /// Host AF_UNIX socket paths the CLI needs to `connect(2)` to.
    ///
    /// On macOS: emits `(allow network-outbound (literal "<resolved>"))`
    /// SBPL rules in `compile_profile`. The path is resolved at
    /// compile() time against $USER / $UID templating (see below).
    ///
    /// On Linux: no-op — the existing fs bind on the socket's inode
    /// is sufficient for the connect under bwrap. The field is still
    /// parsed and audited on Linux so the bundle's grant shape stays
    /// stable across platforms.
    ///
    /// Tilde-prefixed entries (`~/...`) expand against `$HOME`.
    /// `<USER>` and `<UID>` substrings get the daemon's resolved
    /// user / uid spliced in at compile time (same template convention
    /// as the existing `<spawn-uuid>` / `<session>` placeholders).
    pub unix_socket_connects: &'static [&'static str],
}

Templating rationale: yabai's socket name is yabai_<USER>.socket. The
bundle table is &'static [&'static str] static data; we either
template at compile() time, or read $USER from the daemon env at
spawn time and splice in the dispatcher. The compile-time template
keeps everything in the audit hash and matches the static-data
convention.

Plumb the field through KernelParams so it's part of the audit-hash
input (same shape as read_binds / write_binds).

SBPL emission (macOS)

In crates/seal-sandbox/src/macos.rs::compile_profile:

// ── Unix-socket connects ────────────────────────────────
if !self.unix_socket_connects.is_empty() {
    out.push_str("\n; AF_UNIX socket connects\n");
    for path in &self.unix_socket_connects {
        out.push_str(&format!(
            "(allow network-outbound (literal {}))\n",
            sbpl_string_literal(&path.to_string_lossy())
        ));
    }
}

Path resolution happens in compile.rs::bundle_path_to_bind (or a new
bundle_socket_path_to_literal helper if the template-substitution
logic gets large enough to warrant its own function). Extend the
existing helper with <USER> / <UID> substitution, fed from the same
home_dir source already plumbed through compile().

macOS uses /tmp as a firmlink to /private/tmp; sandbox-exec's
network-outbound (literal ...) matches against the canonical path.
Use std::fs::canonicalize (already wired via HostFs::canonicalize)
to resolve /tmp/.../private/tmp/... before emitting the rule.
The existing SECRET_MASK_PATHS_MACOS carve-outs handle the same
firmlink case for /etc / /var / /tmp — model the resolution
after that.

yabai bundle

BundleSpec {
    name: "yabai",
    // Config file is read on yabai --restart-service (out of bundle
    // scope, see below) and on yabai -m signal config-related queries.
    read_binds: &["~/.config/yabai"],
    write_binds: &[],
    project_relative_write_binds: &[],
    // yabai reads $USER from the proc table — no env forwarding needed.
    // PATH + HOME are provided by the baseline.
    env_vars: &[],
    command_prefixes: &["yabai"],
    // No network — the CLI is local-only.
    domains: &[],
    credentials: CredentialSource::None,
    psl_exemptions: &[],
    unix_socket_connects: &[
        // Main control socket. Every `yabai -m <verb>` call connects
        // here.
        "/tmp/yabai_<USER>.socket",
        // Scripting-addition socket. Only opened when `--load-sa` is
        // in flight; most invocations don't touch it but we include
        // it so signal-add / sa-load paths work.
        "/tmp/yabai-sa_<USER>.socket",
    ],
}

Covers yabai -m query, yabai -m window, yabai -m space, yabai -m display, yabai -m signal, yabai -m rule, etc.

Does NOT cover yabai --restart-service. See "Out of scope" below.

skhd bundle (optional)

skhd auto-reloads on ~/.config/skhd/skhdrc modtime change — visible
in /tmp/skhd_$USER.err.log as skhd: config-file has been modified.. reloading config. Agents that edit skhdrc don't need to invoke skhd at
all.

If we want a bundle for parity with yabai, the minimal form:

BundleSpec {
    name: "skhd",
    read_binds: &["~/.config/skhd"],
    write_binds: &[],
    project_relative_write_binds: &[],
    env_vars: &[],
    command_prefixes: &["skhd"],
    domains: &[],
    credentials: CredentialSource::None,
    psl_exemptions: &[],
    unix_socket_connects: &[],
}

But this doesn't do anything the existing system fs baseline + a
project-level additional_directories = ["~"] grant wouldn't already
provide. Recommendation: skip the skhd bundle until there's a concrete**
**use case. Note in the bundle docs that skhd's auto-reload is the
canonical path.

Out of scope: --restart-service for both

yabai --restart-service (and skhd --restart-service) under the hood
shell out to /bin/launchctl bootout + bootstrap + kickstart
against gui/<uid>/com.<owner>.<service>. The SBPL primitives needed:

(allow signal (target others))
(allow mach-priv-task-port (target others))
(allow mach-lookup (global-name "com.apple.xpc.launchd.userdomain"))
(allow mach-lookup (global-name "com.apple.system.DirectoryService.libinfo_v1"))

The blocker is that (allow signal (target others)) and
(allow mach-priv-task-port (target others)) are not pid-scoped —
SBPL's target qualifier is same-sandbox / self / others, no
pid-specific form. Granting others on either gives the bundle
permission to SIGKILL / ptrace any user-owned process on the host
(Chrome, ssh-agent, the seal daemon if user-owned, etc.). Too coarse
for a default-on bundle grant.

Track restart-service as a follow-up under one of these shapes:

  1. host_exec field on BundleSpec. Specific command patterns
    that bypass the sandbox-exec envelope entirely while still being
    manifest-gated. The bundle's regular command_prefixes get
    sandboxed; only the explicit host_exec entries run on the bare
    host. Narrow + auditable. Example:

    host_exec: &["yabai --restart-service", "skhd --restart-service"],
  2. Always-prompting verb patterns in the manifest, with the
    dispatcher routing the prompt-accepted invocation through a
    non-sandboxed exec path. Same effect, driven by per-call prompt
    instead of bundle declaration.

For 0.1 launch: ship the bundle without restart-service, document the
limitation, defer the escape hatch.

Tests

  • Unit (seal-policy): parses_yabai_shorthand, parses_yabai_expanded.
  • Unit (seal-policy): parses_skhd_* if we ship the skhd bundle.
  • Unit (seal-sandbox): compile_profile emits the expected
    network-outbound (literal "...") rules on macOS for yabai. Pattern
    after build_dev_server_port_rules_macos tests.
  • Unit (seal-sandbox): Linux compile path no-ops the field — verify
    no mount/bind rule mentions the socket path beyond whatever the
    user explicitly granted.
  • Unit (seal-sandbox): <USER> / <UID> template substitution in
    the helper.
  • Unit (seal-sandbox): firmlink resolution — /tmp/yabai_alice.socket
    resolves to /private/tmp/yabai_alice.socket before emit.
  • Integration (seal-daemon e2e): sandbox_yabai.rs that spawns
    yabai -m --version (no daemon required for --version) — verifies
    the bundle's command_prefixes activate and exit is clean. Also runs
    a query that hits the socket when a yabai daemon is detected on the
    test runner (skip otherwise — CI macOS runners won't have one).
  • Schema-reference regen: confirm the new field shows up in
    docs/site/src/content/docs/reference/manifest/sandbox/command-tools.mdx
    • the yabai entry.

Docs

  • docs/site/.../command-tools.mdx — new yabai entry with the
    template-substitution call-out and the restart-service limitation.
  • The new bundle-authoring guide (separate issue) gets updated with
    unix_socket_connects as a documented field.
  • docs/site/.../sandbox/index.mdx — short mention of AF_UNIX socket
    binds in the macOS-specific section.

Estimated size

~400 LOC + tests:

  • crates/seal-policy/src/manifest/sandbox.rs — ~70 LOC for Yabai
    variant, as_str arm, ExpandedTool::Yabai, name() arm, BUNDLES
    entry, plus the new unix_socket_connects field across BundleSpec
    (every existing bundle gets unix_socket_connects: &[]).
  • crates/seal-sandbox/src/kernel_params.rs — new
    unix_socket_connects: Vec<PathBuf> on KernelParams.
  • crates/seal-sandbox/src/compile.rs — plumb the field through the
    bundle-collection loop; extend the path-to-bind helper with template
    substitution + firmlink resolution.
  • crates/seal-sandbox/src/macos.rs — emit the SBPL rules section
    (~20 LOC + tests).
  • crates/seal-sandbox/src/linux.rs — explicit no-op + a one-line
    doc-comment so future readers know.
  • Tests — ~150 LOC across the three test crates.
  • Docs — ~50 LOC in the reference page + schema regen.

Half of this is reusable unix_socket_connects plumbing that lights up
any future bundle wrapping a local-daemon CLI.

Blocked / depends-on

  • Depends on: bundle authoring guide issue (filed alongside). Land the
    guide first so this PR can reference it for the new field's
    conventions; or land both PRs together with the guide updated to
    include unix_socket_connects.
  • Independent of the macOS proxy bridge issue. yabai is local-only;
    the proxy bridge is for outbound TCP. They share the macOS-sandbox
    surface but not the implementation path.

Open questions

  • <USER> / <UID> template syntax. Picked angle-brackets to match
    existing <spawn-uuid> conventions in
    crates/seal-sandbox/src/macos.rs. If ${USER} shell-style or %USER%
    is preferred, easy to switch.
  • Should unix_socket_connects accept glob patterns
    ("/tmp/tmux-*/default")? Useful for tmux's per-uid socket dir but
    adds matching complexity. Start with literal-only, add globs if a
    concrete bundle needs them.
  • Linux test coverage for the no-op. Probably one assertion that the
    field on KernelParams is non-empty post-compile but the Linux
    dispatcher doesn't emit any extra --binds for it.

Related

  • Personal motivating use case: my nix-config dotfiles repo runs yabai
    • skhd from a Mac, and the agent can't currently sanity-check yabai
      config edits without the bundle.
  • SEA-441 (original curated bundle landing).
  • SEA-557 (gt bundle — recent worked example for the bundle PR shape).

Review in Linear

Copy link
Copy Markdown
Contributor Author

How to use the Graphite Merge Queue

Add either label to this PR to merge it via the merge queue:

  • Merge Queue - adds this PR to the back of the merge queue
  • Merge Queue Fast Track - for urgent changes, fast-track this PR to the front of 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.

@github-actions

Copy link
Copy Markdown

Docs preview: https://27a35f74.seal-docs.pages.dev

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