-
Notifications
You must be signed in to change notification settings - Fork 2.8k
feat: sandboxing for macos #7197
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0b2963e
cba6c4f
d5e4bf4
2919c28
8535687
1f95f55
7c13d38
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,182 @@ | ||
| # macOS Sandbox for goosed | ||
|
|
||
| goose includes an optional macOS sandbox that restricts the goosed process using Apple's seatbelt (`sandbox-exec`) and routes all network traffic through a local egress proxy. This limits what the agent can do on your system — blocking sensitive file writes, raw sockets, tunneling tools, and unapproved network destinations. | ||
|
|
||
| > **Requirements:** macOS only. The sandbox relies on `/usr/bin/sandbox-exec` which is only available on macOS. | ||
|
|
||
| ## Quick Start | ||
|
|
||
| Set the environment variable before launching the goose desktop app: | ||
|
|
||
| ```bash | ||
| GOOSE_SANDBOX=true | ||
| ``` | ||
|
|
||
| Then start the desktop app as normal. goose will: | ||
|
|
||
| 1. Generate a seatbelt sandbox profile | ||
| 2. Start a local HTTP CONNECT proxy on localhost | ||
| 3. Launch goosed inside `sandbox-exec`, forcing all traffic through the proxy | ||
|
|
||
| If `sandbox-exec` is not available (e.g. you're on Linux), goose will fail fast with a clear error rather than running unsandboxed. | ||
|
|
||
| ## What Gets Restricted | ||
|
|
||
| ### File System (seatbelt) | ||
|
|
||
| By default, the sandbox blocks writes to: | ||
|
|
||
| | Path | Purpose | | ||
| |------|---------| | ||
| | `~/.ssh/` | Prevent SSH key tampering | | ||
| | `~/.bashrc`, `~/.zshrc`, `~/.bash_profile`, `~/.zprofile` | Prevent shell config injection | | ||
| | `~/.config/goose/sandbox/` | Protect sandbox config from the sandboxed process | | ||
| | `~/.config/goose/config.yaml` | Protect goose config | | ||
|
|
||
| ### Network (seatbelt) | ||
|
|
||
| All direct network access is denied. The only allowed paths are: | ||
|
|
||
| - **Localhost** — so the process can reach the egress proxy and its own server port | ||
| - **Unix sockets** — for local IPC | ||
| - **mDNSResponder** — for DNS resolution | ||
|
|
||
| Everything else must go through the proxy. | ||
|
|
||
| ### Process Restrictions (seatbelt) | ||
|
|
||
| - **Tunneling tools blocked:** `nc`, `ncat`, `netcat`, `socat`, `telnet` — prevents the agent from bypassing the proxy | ||
| - **Raw sockets blocked:** `SOCK_RAW` on `AF_INET`/`AF_INET6` — prevents raw packet crafting | ||
| - **Kernel extensions blocked:** `system-kext-load` denied | ||
|
|
||
| ### Network (proxy) | ||
|
|
||
| The egress proxy checks connections in this order: | ||
|
|
||
| 1. **Loopback detection** — prevents using the proxy as a relay back to localhost | ||
| 2. **Raw IP blocking** — connections to bare IP addresses (no domain) are blocked | ||
| 3. **Domain blocklist** — domains listed in `blocked.txt` are denied (including all subdomains) | ||
| 4. **SSH/Git host restrictions** — SSH ports (22, 2222, 7999) are restricted to known git hosts | ||
| 5. **LaunchDarkly allowlist** (optional) — dynamic egress control via feature flag | ||
|
|
||
| ## Configuration | ||
|
|
||
| All configuration is via environment variables. Defaults are designed to be secure out of the box. | ||
|
|
||
| ### Core | ||
|
|
||
| | Variable | Default | Description | | ||
| |----------|---------|-------------| | ||
| | `GOOSE_SANDBOX` | `false` | Set to `true` or `1` to enable the sandbox | | ||
|
|
||
| ### Seatbelt Profile | ||
|
|
||
| | Variable | Default | Description | | ||
| |----------|---------|-------------| | ||
| | `GOOSE_SANDBOX_PROTECT_FILES` | `true` | Write-protect `~/.ssh` and shell configs. Set to `false` to disable | | ||
| | `GOOSE_SANDBOX_BLOCK_RAW_SOCKETS` | `true` | Block `SOCK_RAW`. Set to `false` to disable | | ||
| | `GOOSE_SANDBOX_BLOCK_TUNNELING` | `true` | Block `nc`/`netcat`/`socat`/`telnet`. Set to `false` to disable | | ||
|
|
||
| ### Proxy | ||
|
|
||
| | Variable | Default | Description | | ||
| |----------|---------|-------------| | ||
| | `GOOSE_SANDBOX_ALLOW_IP` | `false` | Set to `true` to allow connections to raw IP addresses | | ||
| | `GOOSE_SANDBOX_BLOCK_LOOPBACK` | `false` | Set to `true` to block loopback relay through the proxy | | ||
| | `GOOSE_SANDBOX_ALLOW_SSH` | `true` | Set to `false` to block all SSH traffic | | ||
| | `GOOSE_SANDBOX_GIT_HOSTS` | built-in list | Comma-separated list of allowed SSH git hosts (e.g. `github.com,gitlab.com`) | | ||
| | `GOOSE_SANDBOX_SSH_ALL_HOSTS` | `false` | Set to `true` to allow SSH to any host (not just git hosts) | | ||
|
|
||
| ### LaunchDarkly (optional — not required) | ||
|
|
||
| LaunchDarkly is **not required**. The sandbox works fully without it using the local `blocked.txt` blocklist. These settings only apply if your organization uses LaunchDarkly for dynamic egress control. | ||
|
|
||
| | Variable | Default | Description | | ||
| |----------|---------|-------------| | ||
| | `LAUNCHDARKLY_CLIENT_ID` | — | LD client SDK key to enable dynamic egress control | | ||
| | `GOOSE_SANDBOX_LD_FAILOVER` | — | Failover mode if LD is unreachable: `allow`, `deny`, or `blocklist` | | ||
|
|
||
| ## Domain Blocklist | ||
|
|
||
| The file `~/.config/goose/sandbox/blocked.txt` controls which domains are blocked by the proxy. It's created automatically on first run from a bundled template. | ||
|
|
||
| ``` | ||
| # One domain per line. Subdomains are blocked automatically. | ||
| # Lines starting with # are comments. | ||
| evil.com # blocks evil.com and *.evil.com | ||
| pastebin.com | ||
| transfer.sh | ||
| webhook.site | ||
| ``` | ||
|
|
||
| **Live reload:** Changes to `blocked.txt` take effect immediately — the proxy watches the file with `fs.watch` and reloads it automatically. No restart needed. | ||
|
|
||
| ## SSH and Git | ||
|
|
||
| SSH git operations (`git clone git@github.com:...`) work through the sandbox via a bundled `connect-proxy.pl` script that acts as an SSH `ProxyCommand`. This routes SSH connections through the egress proxy, which then applies the same allowlist rules. | ||
|
|
||
| By default, SSH is only allowed to well-known git hosting domains (GitHub, GitLab, Bitbucket, etc.). To customise: | ||
|
|
||
| ```bash | ||
| # Add custom git hosts | ||
| export GOOSE_SANDBOX_GIT_HOSTS="github.com,gitlab.com,your-gitea.internal.com" | ||
|
|
||
| # Or allow SSH to all hosts | ||
| export GOOSE_SANDBOX_SSH_ALL_HOSTS=true | ||
| ``` | ||
|
|
||
| ## Example Configurations | ||
|
|
||
| ### Maximum security | ||
|
|
||
| ```bash | ||
| export GOOSE_SANDBOX=true | ||
| # All protections enabled (defaults) | ||
| ``` | ||
|
|
||
| ### Allow raw IP connections (e.g. for internal APIs) | ||
|
|
||
| ```bash | ||
| export GOOSE_SANDBOX=true | ||
| export GOOSE_SANDBOX_ALLOW_IP=true | ||
| ``` | ||
|
|
||
| ### Disable SSH entirely | ||
|
|
||
| ```bash | ||
| export GOOSE_SANDBOX=true | ||
| export GOOSE_SANDBOX_ALLOW_SSH=false | ||
| ``` | ||
|
|
||
| ### Relaxed mode (sandbox on, fewer restrictions) | ||
|
|
||
| ```bash | ||
| export GOOSE_SANDBOX=true | ||
| export GOOSE_SANDBOX_PROTECT_FILES=false | ||
| export GOOSE_SANDBOX_BLOCK_RAW_SOCKETS=false | ||
| export GOOSE_SANDBOX_BLOCK_TUNNELING=false | ||
| export GOOSE_SANDBOX_ALLOW_IP=true | ||
| export GOOSE_SANDBOX_SSH_ALL_HOSTS=true | ||
| ``` | ||
|
|
||
| ### With LaunchDarkly egress control | ||
|
|
||
| ```bash | ||
| export GOOSE_SANDBOX=true | ||
| export LAUNCHDARKLY_CLIENT_ID=sdk-your-key-here | ||
| export GOOSE_SANDBOX_LD_FAILOVER=blocklist # fall back to local blocklist if LD is down | ||
| ``` | ||
|
|
||
| ## Troubleshooting | ||
|
|
||
| **"GOOSE_SANDBOX=true but sandbox-exec is not available (macOS only)"** | ||
| You're not on macOS, or `/usr/bin/sandbox-exec` is missing. The sandbox only works on macOS. | ||
|
|
||
| **Extensions or tools can't reach the network** | ||
| Check if the destination domain is in `~/.config/goose/sandbox/blocked.txt`, or if you need to enable `GOOSE_SANDBOX_ALLOW_IP=true` for IP-based endpoints. | ||
|
|
||
| **Git clone over SSH fails** | ||
| The target host may not be in the default git hosts allowlist. Add it with `GOOSE_SANDBOX_GIT_HOSTS=your-host.com` or set `GOOSE_SANDBOX_SSH_ALL_HOSTS=true`. | ||
|
|
||
| **Want to inspect what the proxy is blocking?** | ||
| Check the Electron/goosed logs — blocked connections are logged with the reason. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| # Blocked domains — edit this file while goosed is running. | ||
| # Changes take effect immediately (re-read on every connection). | ||
michaelneale marked this conversation as resolved.
Show resolved
Hide resolved
michaelneale marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| # One domain per line. Subdomains are blocked automatically. | ||
| # Lines starting with # are comments. | ||
| # | ||
| # Examples: | ||
| # evil.com — blocks evil.com and *.evil.com | ||
| # pastebin.com — blocks pastebin.com and *.pastebin.com | ||
| # transfer.sh | ||
| # webhook.site | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| #!/usr/bin/perl | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. eh, perl?
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correct reaction. Mic and I were talking a bit about this, to proxy git you need to give it a "proxy command". I couldn't get it working in Bash and we know Perl is available on MacOS machines by default (system internal usage). The alternatives we spoke about (and also happy to use) were:
I'd be super open to other/better ideas. It's worth noting this was actually blocked by Mic's initial commit not my additional self-ssh mitigations so for any for of network sandboxing (without just general bypass over port 22) we would need something shaped like this to allow Git over SSH (or just enforce HTTP only but that has CorpEnv implications for Block and likely others who may want to try this out)
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yeah - no other obvious solution yet... this was my reaction and still is!
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @DOsinga @shellz-n-stuff weirdly it does seem the idiomatic solution for this.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. and this is only for ssh, https will work the usual way. I think I have made peace with this approach. |
||
| use strict; | ||
| use warnings; | ||
| use IO::Socket::INET; | ||
| use IO::Select; | ||
|
|
||
| my ($host, $port) = @ARGV; | ||
| die "Usage: connect-proxy.pl <host> <port>\n" unless $host && $port; | ||
|
|
||
| my $proxy_port = $ENV{SANDBOX_PROXY_PORT} || die "SANDBOX_PROXY_PORT not set\n"; | ||
|
|
||
| my $sock = IO::Socket::INET->new( | ||
| PeerAddr => '127.0.0.1', | ||
| PeerPort => $proxy_port, | ||
| Proto => 'tcp', | ||
| ) or die "Cannot connect to proxy: $!\n"; | ||
|
|
||
| print $sock "CONNECT $host:$port HTTP/1.1\r\nHost: $host:$port\r\n\r\n"; | ||
|
|
||
| my $status = <$sock>; | ||
| die "Proxy error: $status" unless $status && $status =~ /\b200\b/; | ||
michaelneale marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| while (my $hdr = <$sock>) { | ||
| last if $hdr =~ /^\r?\n$/; | ||
| } | ||
|
|
||
| $| = 1; | ||
| binmode STDIN; | ||
| binmode STDOUT; | ||
| binmode $sock; | ||
|
|
||
| my $sel = IO::Select->new($sock, \*STDIN); | ||
| while (my @ready = $sel->can_read()) { | ||
| for my $fh (@ready) { | ||
| my $buf; | ||
| my $n = sysread($fh, $buf, 8192); | ||
| exit 0 unless $n; | ||
| if ($fh == $sock) { | ||
| syswrite(STDOUT, $buf) or exit 0; | ||
| } else { | ||
| syswrite($sock, $buf) or exit 0; | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The proxy cleanup is not awaited before resolving the cleanup promise. If stopProxy() takes longer than 5 seconds or encounters an error, the cleanup function could complete before the proxy is stopped, potentially leaving resources open. Consider awaiting stopProxy() before the setTimeout or ensuring it completes within the 5-second window.