Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

182 changes: 182 additions & 0 deletions documentation/docs/guides/sandbox.md
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.
2 changes: 1 addition & 1 deletion ui/desktop/forge.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const { resolve } = require('path');

let cfg = {
asar: true,
extraResource: ['src/bin', 'src/images'],
extraResource: ['src/bin', 'src/images', 'src/sandbox'],
icon: 'src/images/icon',
// Windows specific configuration
win32: {
Expand Down
38 changes: 35 additions & 3 deletions ui/desktop/src/goosed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ import { createServer } from 'net';
import { Buffer } from 'node:buffer';
import { status } from './api';
import { Client, createClient, createConfig } from './api/client';
import {
buildSandboxSpawn,
ensureProxy,
stopProxy,
isSandboxEnabled,
isSandboxAvailable,
} from './sandbox';

export interface Logger {
info: (...args: unknown[]) => void;
Expand Down Expand Up @@ -222,14 +229,21 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
};
}

if (isSandboxEnabled() && !isSandboxAvailable()) {
throw new Error('GOOSE_SANDBOX=true but sandbox-exec is not available (macOS only)');
}
const useSandbox = isSandboxEnabled();

const goosedPath = findGoosedBinaryPath({ isPackaged, resourcesPath });

const port = await findAvailablePort();
logger.info(`Starting goosed from: ${goosedPath} on port ${port} in dir ${workingDir}`);
logger.info(
`Starting goosed from: ${goosedPath} on port ${port} in dir ${workingDir}${useSandbox ? ' [SANDBOXED]' : ''}`
);

const baseUrl = `http://127.0.0.1:${port}`;

const spawnEnv = {
const spawnEnv: Record<string, string | undefined> = {
...process.env,
...buildGoosedEnv(port, serverSecret, goosedPath),
};
Expand All @@ -240,6 +254,20 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
}
}

// If sandbox mode, start proxy and wrap with sandbox-exec
let spawnCommand = goosedPath;
let spawnArgs = ['agent'];

if (useSandbox) {
const proxy = await ensureProxy();
const sandboxSpawn = buildSandboxSpawn(goosedPath, ['agent'], proxy.port);
spawnCommand = sandboxSpawn.command;
spawnArgs = sandboxSpawn.args;
// Merge proxy env vars into the process env
Object.assign(spawnEnv, sandboxSpawn.env);
logger.info(`[sandbox] Spawning via: ${spawnCommand} ${spawnArgs.join(' ')}`);
}

const isWindows = process.platform === 'win32';
const spawnOptions = {
env: spawnEnv,
Expand All @@ -262,7 +290,7 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
};
logger.info('Spawn options:', JSON.stringify(safeSpawnOptions, null, 2));

const goosedProcess = spawn(goosedPath, ['agent'], spawnOptions);
const goosedProcess = spawn(spawnCommand, spawnArgs, spawnOptions);

goosedProcess.stdout?.on('data', (data: Buffer) => {
logger.info(`goosed stdout for port ${port} and dir ${workingDir}: ${data.toString()}`);
Expand Down Expand Up @@ -311,6 +339,10 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
logger.error('Error while terminating goosed process:', error);
}

if (useSandbox) {
stopProxy().catch((err) => logger.error('Error stopping sandbox proxy:', err));
}
Comment on lines +342 to +344
Copy link

Copilot AI Feb 16, 2026

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.

Copilot uses AI. Check for mistakes.

setTimeout(() => {
if (goosedProcess && !goosedProcess.killed && process.platform !== 'win32') {
goosedProcess.kill('SIGKILL');
Expand Down
10 changes: 10 additions & 0 deletions ui/desktop/src/sandbox/blocked.txt
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).
# 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
43 changes: 43 additions & 0 deletions ui/desktop/src/sandbox/connect-proxy.pl
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#!/usr/bin/perl
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

eh, perl?

Copy link
Collaborator

Choose a reason for hiding this comment

The 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:

  1. Spawn a Node server JIT (I opted against this as it seemed non-performant).
  2. Have another rust binary we produce and then bundle as part of this.

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)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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/;
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;
}
}
}
Loading