diff --git a/Cargo.lock b/Cargo.lock index 65edc5f6e535..0be343b92311 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1585,7 +1585,7 @@ dependencies = [ "safetensors 0.7.0", "thiserror 2.0.18", "yoke 0.8.1", - "zip 7.4.0", + "zip 7.2.0", ] [[package]] @@ -12456,9 +12456,9 @@ dependencies = [ [[package]] name = "zip" -version = "7.4.0" +version = "7.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc12baa6db2b15a140161ce53d72209dacea594230798c24774139b54ecaa980" +checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0" dependencies = [ "crc32fast", "indexmap 2.13.0", diff --git a/documentation/docs/guides/sandbox.md b/documentation/docs/guides/sandbox.md new file mode 100644 index 000000000000..71b1d77fb887 --- /dev/null +++ b/documentation/docs/guides/sandbox.md @@ -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. diff --git a/ui/desktop/forge.config.ts b/ui/desktop/forge.config.ts index 879dc0d01f94..a97c286a30ce 100644 --- a/ui/desktop/forge.config.ts +++ b/ui/desktop/forge.config.ts @@ -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: { diff --git a/ui/desktop/src/goosed.ts b/ui/desktop/src/goosed.ts index ea55c7c319f2..85ae04b9c7a9 100644 --- a/ui/desktop/src/goosed.ts +++ b/ui/desktop/src/goosed.ts @@ -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; @@ -222,14 +229,21 @@ export const startGoosed = async (options: StartGoosedOptions): Promise = { ...process.env, ...buildGoosedEnv(port, serverSecret, goosedPath), }; @@ -240,6 +254,20 @@ export const startGoosed = async (options: StartGoosedOptions): Promise { logger.info(`goosed stdout for port ${port} and dir ${workingDir}: ${data.toString()}`); @@ -311,6 +339,10 @@ export const startGoosed = async (options: StartGoosedOptions): Promise logger.error('Error stopping sandbox proxy:', err)); + } + setTimeout(() => { if (goosedProcess && !goosedProcess.killed && process.platform !== 'win32') { goosedProcess.kill('SIGKILL'); diff --git a/ui/desktop/src/sandbox/blocked.txt b/ui/desktop/src/sandbox/blocked.txt new file mode 100644 index 000000000000..a4cdfdba85bd --- /dev/null +++ b/ui/desktop/src/sandbox/blocked.txt @@ -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 diff --git a/ui/desktop/src/sandbox/connect-proxy.pl b/ui/desktop/src/sandbox/connect-proxy.pl new file mode 100755 index 000000000000..86d9c3f1b44c --- /dev/null +++ b/ui/desktop/src/sandbox/connect-proxy.pl @@ -0,0 +1,43 @@ +#!/usr/bin/perl +use strict; +use warnings; +use IO::Socket::INET; +use IO::Select; + +my ($host, $port) = @ARGV; +die "Usage: connect-proxy.pl \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; + } + } +} diff --git a/ui/desktop/src/sandbox/index.ts b/ui/desktop/src/sandbox/index.ts new file mode 100644 index 000000000000..46ff8ebfd8fd --- /dev/null +++ b/ui/desktop/src/sandbox/index.ts @@ -0,0 +1,239 @@ +/** + * macOS Seatbelt sandbox for goosed. + * + * GOOSE_SANDBOX=true — enable sandbox + * LAUNCHDARKLY_CLIENT_ID=sdk-xxx — optional LD egress control + * + * Seatbelt profile options (all default to enabled): + * GOOSE_SANDBOX_PROTECT_FILES=false — disable SSH/shell config protection + * GOOSE_SANDBOX_BLOCK_RAW_SOCKETS=false — disable raw socket blocking + * GOOSE_SANDBOX_BLOCK_TUNNELING=false — disable tunneling tool blocking + * + * Proxy options: + * GOOSE_SANDBOX_ALLOW_IP=true — allow raw IP address connections + * GOOSE_SANDBOX_BLOCK_LOOPBACK=true — block loopback via proxy (default: off) + * GOOSE_SANDBOX_ALLOW_SSH=false — block SSH ports (22/2222/7999) via proxy + * GOOSE_SANDBOX_GIT_HOSTS=host1,host2 — custom git host allowlist for SSH + * GOOSE_SANDBOX_SSH_ALL_HOSTS=true — allow SSH to all hosts (default: git hosts only) + * + * SSH git operations (git clone git@...) are routed through the proxy via + * a bundled connect-proxy.pl script used as SSH ProxyCommand. This avoids + * needing nc (which is blocked by the seatbelt profile). + * GOOSE_SANDBOX_LD_FAILOVER=allow|deny|blocklist — LD failover mode + */ + +import path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { startProxy, ProxyInstance } from './proxy'; + +export { startProxy } from './proxy'; +export type { ProxyInstance } from './proxy'; + +const homeDir = os.homedir(); +const sandboxDir = path.join(homeDir, '.config', 'goose', 'sandbox'); + +// --------------------------------------------------------------------------- +// Sandbox profile builder +// --------------------------------------------------------------------------- + +export interface SandboxProfileOptions { + homeDir: string; + protectSensitiveFiles: boolean; + blockRawSockets: boolean; + blockTunnelingTools: boolean; +} + +export function buildSandboxProfile(opts: SandboxProfileOptions): string { + const h = opts.homeDir; + const lines: string[] = [ + '(version 1)', + '(allow default)', + '', + `;; Protect sandbox config from the sandboxed process`, + `(deny file-write* (subpath "${h}/.config/goose/sandbox"))`, + `(deny file-write* (literal "${h}/.config/goose/config.yaml"))`, + ]; + + if (opts.protectSensitiveFiles) { + lines.push( + '', + `(deny file-write* (subpath "${h}/.ssh"))`, + `(deny file-write* (literal "${h}/.bashrc"))`, + `(deny file-write* (literal "${h}/.zshrc"))`, + `(deny file-write* (literal "${h}/.bash_profile"))`, + `(deny file-write* (literal "${h}/.zprofile"))` + ); + } + + lines.push( + '', + '(deny network*)', + '(allow network-outbound (literal "/private/var/run/mDNSResponder"))', + '(allow network-outbound (remote unix-socket))', + '(allow network-outbound (remote ip "localhost:*"))', + '(allow network-inbound (local ip "localhost:*"))' + ); + + if (opts.blockRawSockets) { + lines.push( + '', + '(deny system-socket (require-all (socket-domain AF_INET) (socket-type SOCK_RAW)))', + '(deny system-socket (require-all (socket-domain AF_INET6) (socket-type SOCK_RAW)))' + ); + } + + if (opts.blockTunnelingTools) { + lines.push( + '', + '(deny process-exec', + ' (literal "/usr/bin/nc")', + ' (literal "/usr/bin/ncat")', + ' (literal "/usr/bin/netcat")', + ' (literal "/usr/bin/socat")', + ' (literal "/usr/bin/telnet")', + ')' + ); + } + + lines.push('', '(deny system-kext-load)', ''); + + return lines.join('\n'); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +export function isSandboxEnabled(): boolean { + return process.env.GOOSE_SANDBOX === 'true' || process.env.GOOSE_SANDBOX === '1'; +} + +export function isSandboxAvailable(): boolean { + return process.platform === 'darwin' && fs.existsSync('/usr/bin/sandbox-exec'); +} + +function bundledPath(filename: string): string { + // In packaged apps, process.resourcesPath points to the app resources directory. + // In development, fall back to the source tree. + const packagedPath = process.resourcesPath + ? path.join(process.resourcesPath, 'sandbox', filename) + : ''; + if (packagedPath && fs.existsSync(packagedPath)) { + return packagedPath; + } + return path.join(process.cwd(), 'src', 'sandbox', filename); +} + +function materialise(filename: string): string { + const runtimePath = path.join(sandboxDir, filename); + if (!fs.existsSync(runtimePath)) { + fs.mkdirSync(sandboxDir, { recursive: true }); + const content = fs.readFileSync(bundledPath(filename), 'utf-8'); + fs.writeFileSync(runtimePath, content); + console.log(`[sandbox] Materialised ${filename}`); + } + return runtimePath; +} + +function writeSandboxProfile(content: string): string { + const runtimePath = path.join(sandboxDir, 'sandbox.sb'); + fs.mkdirSync(sandboxDir, { recursive: true }); + fs.writeFileSync(runtimePath, content); + return runtimePath; +} + +function writeConnectProxy(): string { + const runtimePath = path.join(sandboxDir, 'connect-proxy.pl'); + fs.mkdirSync(sandboxDir, { recursive: true }); + const content = fs.readFileSync(bundledPath('connect-proxy.pl'), 'utf-8'); + fs.writeFileSync(runtimePath, content, { mode: 0o755 }); + return runtimePath; +} + +// --------------------------------------------------------------------------- +// Spawn +// --------------------------------------------------------------------------- + +export function buildSandboxSpawn( + goosedPath: string, + goosedArgs: string[], + proxyPort: number +): { command: string; args: string[]; env: Record } { + const profileOptions: SandboxProfileOptions = { + homeDir, + protectSensitiveFiles: process.env.GOOSE_SANDBOX_PROTECT_FILES !== 'false', + blockRawSockets: process.env.GOOSE_SANDBOX_BLOCK_RAW_SOCKETS !== 'false', + blockTunnelingTools: process.env.GOOSE_SANDBOX_BLOCK_TUNNELING !== 'false', + }; + + const profileContent = buildSandboxProfile(profileOptions); + const sandboxProfile = writeSandboxProfile(profileContent); + const proxyUrl = `http://127.0.0.1:${proxyPort}`; + const connectProxy = writeConnectProxy(); + + console.log(`[sandbox] Profile: ${sandboxProfile}`); + console.log(`[sandbox] Proxy port: ${proxyPort}`); + console.log( + `[sandbox] Config: protectSensitiveFiles=${profileOptions.protectSensitiveFiles}, blockRawSockets=${profileOptions.blockRawSockets}, blockTunnelingTools=${profileOptions.blockTunnelingTools}` + ); + + return { + command: '/usr/bin/sandbox-exec', + args: ['-f', sandboxProfile, goosedPath, ...goosedArgs], + env: { + http_proxy: proxyUrl, + https_proxy: proxyUrl, + HTTP_PROXY: proxyUrl, + HTTPS_PROXY: proxyUrl, + no_proxy: 'localhost,127.0.0.1,::1', + NO_PROXY: 'localhost,127.0.0.1,::1', + GIT_SSH_COMMAND: `ssh -o ProxyCommand='/usr/bin/perl "${connectProxy}" %h %p'`, + SANDBOX_PROXY_PORT: String(proxyPort), + }, + }; +} + +// --------------------------------------------------------------------------- +// Proxy lifecycle +// --------------------------------------------------------------------------- + +let activeProxy: ProxyInstance | null = null; + +export async function ensureProxy(): Promise { + if (activeProxy) return activeProxy; + + const ldClientId = process.env.LAUNCHDARKLY_CLIENT_ID; + const blockedPath = materialise('blocked.txt'); + + activeProxy = await startProxy({ + blockedPath, + launchDarkly: ldClientId + ? { + clientId: ldClientId, + username: os.userInfo().username, + failoverMode: + (process.env.GOOSE_SANDBOX_LD_FAILOVER as 'allow' | 'deny' | 'blocklist') || undefined, + } + : undefined, + allowIPAddresses: process.env.GOOSE_SANDBOX_ALLOW_IP === 'true', + blockLoopback: process.env.GOOSE_SANDBOX_BLOCK_LOOPBACK === 'true', + allowSSH: process.env.GOOSE_SANDBOX_ALLOW_SSH !== 'false', + gitHosts: + process.env.GOOSE_SANDBOX_GIT_HOSTS?.split(',') + .map((h) => h.trim()) + .filter(Boolean) || undefined, + allowSSHToAllHosts: process.env.GOOSE_SANDBOX_SSH_ALL_HOSTS === 'true', + }); + + console.log(`[sandbox] Proxy started on port ${activeProxy.port}`); + return activeProxy; +} + +export async function stopProxy(): Promise { + if (activeProxy) { + await activeProxy.close(); + console.log('[sandbox] Proxy stopped'); + activeProxy = null; + } +} diff --git a/ui/desktop/src/sandbox/proxy.test.ts b/ui/desktop/src/sandbox/proxy.test.ts new file mode 100644 index 000000000000..12879e30c0d5 --- /dev/null +++ b/ui/desktop/src/sandbox/proxy.test.ts @@ -0,0 +1,282 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + normalizeDomain, + isIPAddress, + isLoopback, + matchesBlocked, + checkBlocked, + loadBlocked, + parseConnectTarget, + type ProxyOptions, +} from './proxy'; + +describe('parseConnectTarget', () => { + it('parses host:port', () => { + expect(parseConnectTarget('example.com:443')).toEqual({ host: 'example.com', port: 443 }); + }); + + it('parses host:port with non-standard port', () => { + expect(parseConnectTarget('api.internal:8443')).toEqual({ host: 'api.internal', port: 8443 }); + }); + + it('parses bracketed IPv6 with port', () => { + expect(parseConnectTarget('[2001:db8::1]:443')).toEqual({ host: '2001:db8::1', port: 443 }); + expect(parseConnectTarget('[::1]:8080')).toEqual({ host: '::1', port: 8080 }); + }); + + it('rejects invalid targets', () => { + expect(parseConnectTarget(':443')).toEqual({ host: '', port: 0 }); + expect(parseConnectTarget('')).toEqual({ host: '', port: 0 }); + expect(parseConnectTarget('example.com')).toEqual({ host: '', port: 0 }); + expect(parseConnectTarget('example.com:0')).toEqual({ host: '', port: 0 }); + expect(parseConnectTarget('example.com:99999')).toEqual({ host: '', port: 0 }); + expect(parseConnectTarget('example.com:abc')).toEqual({ host: '', port: 0 }); + }); +}); + +describe('normalizeDomain', () => { + it('lowercases and trims', () => { + expect(normalizeDomain('GitHub.COM')).toBe('github.com'); + expect(normalizeDomain(' example.com ')).toBe('example.com'); + }); + + it('strips trailing dot', () => { + expect(normalizeDomain('example.com.')).toBe('example.com'); + }); + + it('strips IPv6 brackets', () => { + expect(normalizeDomain('[::1]')).toBe('::1'); + }); + + it('handles punycode via URL constructor', () => { + expect(normalizeDomain('MÜNCHEN.de')).toBe(new URL('http://münchen.de').hostname); + }); + + it('handles plain domain', () => { + expect(normalizeDomain('api.example.com')).toBe('api.example.com'); + }); +}); + +describe('isIPAddress', () => { + it('detects IPv4', () => { + expect(isIPAddress('192.168.1.1')).toBe(true); + expect(isIPAddress('10.0.0.1')).toBe(true); + expect(isIPAddress('127.0.0.1')).toBe(true); + }); + + it('detects IPv6', () => { + expect(isIPAddress('::1')).toBe(true); + expect(isIPAddress('2001:db8::1')).toBe(true); + }); + + it('rejects domains', () => { + expect(isIPAddress('example.com')).toBe(false); + expect(isIPAddress('localhost')).toBe(false); + }); +}); + +describe('isLoopback', () => { + it('matches loopback addresses', () => { + expect(isLoopback('localhost')).toBe(true); + expect(isLoopback('LOCALHOST')).toBe(true); + expect(isLoopback('127.0.0.1')).toBe(true); + expect(isLoopback('127.255.255.255')).toBe(true); + expect(isLoopback('::1')).toBe(true); + expect(isLoopback('[::1]')).toBe(true); + }); + + it('rejects non-loopback', () => { + expect(isLoopback('192.168.1.1')).toBe(false); + expect(isLoopback('example.com')).toBe(false); + }); +}); + +describe('matchesBlocked', () => { + const blocked = new Set(['evil.com', 'pastebin.com', 'bad.example.org']); + + it('blocks exact domain and subdomains', () => { + expect(matchesBlocked('evil.com', blocked)).toBe(true); + expect(matchesBlocked('www.evil.com', blocked)).toBe(true); + expect(matchesBlocked('deep.sub.evil.com', blocked)).toBe(true); + }); + + it('allows non-blocked domains', () => { + expect(matchesBlocked('github.com', blocked)).toBe(false); + expect(matchesBlocked('example.com', blocked)).toBe(false); + }); + + it('does not block parent of blocked domain', () => { + expect(matchesBlocked('example.org', blocked)).toBe(false); + expect(matchesBlocked('com', blocked)).toBe(false); + }); + + it('is case-insensitive and handles trailing dot', () => { + expect(matchesBlocked('EVIL.COM', blocked)).toBe(true); + expect(matchesBlocked('evil.com.', blocked)).toBe(true); + }); + + it('handles empty blocklist', () => { + expect(matchesBlocked('anything.com', new Set())).toBe(false); + }); +}); + +describe('loadBlocked', () => { + it('returns empty set for undefined or missing path', () => { + expect(loadBlocked(undefined).size).toBe(0); + expect(loadBlocked('/nonexistent/path/blocked.txt').size).toBe(0); + }); + + it('loads domains from file, skipping comments and blanks', () => { + const tmpFile = path.join(os.tmpdir(), `blocked-test-${Date.now()}.txt`); + fs.writeFileSync( + tmpFile, + `# comment +evil.com + pastebin.com + +# another comment +transfer.sh +` + ); + try { + const result = loadBlocked(tmpFile); + expect(result.size).toBe(3); + expect(result.has('evil.com')).toBe(true); + expect(result.has('pastebin.com')).toBe(true); + expect(result.has('transfer.sh')).toBe(true); + } finally { + fs.unlinkSync(tmpFile); + } + }); +}); + +describe('checkBlocked', () => { + const blocked = new Set(['evil.com', 'pastebin.com']); + const noLD = undefined; + const noLDCache = undefined; + const defaultOptions: ProxyOptions = {}; + + it('allows normal HTTPS traffic', async () => { + const result = await checkBlocked('github.com', 443, blocked, noLD, noLDCache, defaultOptions); + expect(result.blocked).toBe(false); + }); + + it('blocks domains and subdomains on the blocklist', async () => { + const exact = await checkBlocked('evil.com', 443, blocked, noLD, noLDCache, defaultOptions); + expect(exact).toEqual({ blocked: true, reason: 'blocklist' }); + + const sub = await checkBlocked('api.evil.com', 443, blocked, noLD, noLDCache, defaultOptions); + expect(sub).toEqual({ blocked: true, reason: 'blocklist' }); + }); + + it('blocks raw IP addresses by default, allows when opted in', async () => { + const blocked_ = await checkBlocked( + '93.184.216.34', + 443, + blocked, + noLD, + noLDCache, + defaultOptions + ); + expect(blocked_).toEqual({ blocked: true, reason: 'ip-address' }); + + const allowed = await checkBlocked('93.184.216.34', 443, blocked, noLD, noLDCache, { + allowIPAddresses: true, + }); + expect(allowed.blocked).toBe(false); + }); + + it('does not block loopback by default, blocks when opted in', async () => { + const allowed = await checkBlocked( + 'localhost', + 8080, + blocked, + noLD, + noLDCache, + defaultOptions + ); + expect(allowed.blocked).toBe(false); + + const blocked_ = await checkBlocked('localhost', 8080, blocked, noLD, noLDCache, { + blockLoopback: true, + }); + expect(blocked_).toEqual({ blocked: true, reason: 'loopback' }); + + const blocked127 = await checkBlocked('127.0.0.1', 8080, blocked, noLD, noLDCache, { + blockLoopback: true, + }); + expect(blocked127).toEqual({ blocked: true, reason: 'loopback' }); + }); + + it('allows SSH to default git hosts', async () => { + for (const host of ['github.com', 'gitlab.com', 'bitbucket.org', 'ssh.dev.azure.com']) { + const result = await checkBlocked(host, 22, blocked, noLD, noLDCache, defaultOptions); + expect(result.blocked).toBe(false); + } + }); + + it('blocks SSH to non-git hosts on all SSH ports', async () => { + for (const port of [22, 2222, 7999]) { + const result = await checkBlocked( + 'random-server.com', + port, + blocked, + noLD, + noLDCache, + defaultOptions + ); + expect(result).toEqual({ blocked: true, reason: 'ssh-non-git-host' }); + } + }); + + it('blocks all SSH when allowSSH is false', async () => { + const result = await checkBlocked('github.com', 22, blocked, noLD, noLDCache, { + allowSSH: false, + }); + expect(result).toEqual({ blocked: true, reason: 'ssh-disabled' }); + }); + + it('allows SSH to any host when allowSSHToAllHosts is true', async () => { + const result = await checkBlocked('random-server.com', 22, blocked, noLD, noLDCache, { + allowSSHToAllHosts: true, + }); + expect(result.blocked).toBe(false); + }); + + it('respects custom git hosts list', async () => { + const opts = { gitHosts: ['gitea.internal.com'] }; + const allowed = await checkBlocked('gitea.internal.com', 22, blocked, noLD, noLDCache, opts); + expect(allowed.blocked).toBe(false); + + const denied = await checkBlocked('github.com', 22, blocked, noLD, noLDCache, opts); + expect(denied).toEqual({ blocked: true, reason: 'ssh-non-git-host' }); + }); + + it('SSH rules only apply to SSH ports', async () => { + const result = await checkBlocked( + 'random-server.com', + 443, + blocked, + noLD, + noLDCache, + defaultOptions + ); + expect(result.blocked).toBe(false); + }); + + it('checks blocking layers in priority order', async () => { + // loopback before IP + const loopback = await checkBlocked('127.0.0.1', 443, blocked, noLD, noLDCache, { + blockLoopback: true, + allowIPAddresses: false, + }); + expect(loopback.reason).toBe('loopback'); + + // blocklist before SSH + const blocklist = await checkBlocked('evil.com', 22, blocked, noLD, noLDCache, defaultOptions); + expect(blocklist.reason).toBe('blocklist'); + }); +}); diff --git a/ui/desktop/src/sandbox/proxy.ts b/ui/desktop/src/sandbox/proxy.ts new file mode 100644 index 000000000000..bd6149766c29 --- /dev/null +++ b/ui/desktop/src/sandbox/proxy.ts @@ -0,0 +1,532 @@ +/** + * HTTP CONNECT proxy with logging, live domain blocklist, and optional + * LaunchDarkly egress control. + * + * Runs in the Electron main process. All outbound traffic from a sandboxed + * goosed process is funneled through this proxy (the macOS seatbelt profile + * blocks direct outbound network, only allowing localhost). + * + * SSH git operations are routed through this proxy via GIT_SSH_COMMAND + * which uses a bundled connect-proxy script as ProxyCommand. + * + * Blocking layers (checked in order): + * 1. Loopback detection (if blockLoopback enabled) + * 2. IP address blocking (if !allowIPAddresses) + * 3. Local blocklist (blocked.txt) — fast, no network, live-reloaded + * 4. SSH/Git host restriction (port 22/2222/7999) + * 5. LaunchDarkly flag ("egress-allowlist") — if configured + */ + +import http from 'node:http'; +import https from 'node:https'; +import net from 'node:net'; +import fs from 'node:fs'; +import os from 'node:os'; +import crypto from 'node:crypto'; +import { URL } from 'node:url'; +import { Buffer } from 'node:buffer'; +const log = { + info: (...args: unknown[]) => console.log('[sandbox-proxy]', ...args), + warn: (...args: unknown[]) => console.warn('[sandbox-proxy]', ...args), + error: (...args: unknown[]) => console.error('[sandbox-proxy]', ...args), +}; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface LaunchDarklyConfig { + clientId: string; + username?: string; + cacheTtlSeconds?: number; + failoverMode?: 'allow' | 'deny' | 'blocklist'; +} + +export interface ProxyOptions { + port?: number; + blockedPath?: string; + launchDarkly?: LaunchDarklyConfig; + allowIPAddresses?: boolean; + blockLoopback?: boolean; + allowSSH?: boolean; + gitHosts?: string[]; + allowSSHToAllHosts?: boolean; +} + +export interface ProxyInstance { + port: number; + server: http.Server; + close: () => Promise; +} + +// --------------------------------------------------------------------------- +// Local blocklist +// --------------------------------------------------------------------------- + +export function loadBlocked(blockedPath: string | undefined): Set { + if (!blockedPath) return new Set(); + try { + if (!fs.existsSync(blockedPath)) return new Set(); + const domains = new Set(); + for (const line of fs.readFileSync(blockedPath, 'utf-8').split('\n')) { + const trimmed = line.trim().toLowerCase(); + if (trimmed && !trimmed.startsWith('#')) { + domains.add(trimmed); + } + } + return domains; + } catch { + return new Set(); + } +} + +export function normalizeDomain(host: string): string { + let normalized = host.toLowerCase().trim(); + if (normalized.endsWith('.')) { + normalized = normalized.slice(0, -1); + } + if (normalized.startsWith('[') && normalized.endsWith(']')) { + normalized = normalized.slice(1, -1); + } + try { + const url = new URL(`http://${normalized}`); + normalized = url.hostname; + } catch { + // use as-is + } + return normalized; +} + +export function isIPAddress(host: string): boolean { + const ipv4 = /^(\d{1,3}\.){3}\d{1,3}$/; + if (ipv4.test(host)) return true; + if (host.includes(':')) return true; + return false; +} + +export function parseConnectTarget(target: string): { host: string; port: number } { + // Handle [ipv6]:port + const bracketMatch = target.match(/^\[([^\]]+)\]:(\d+)$/); + if (bracketMatch) { + return { host: bracketMatch[1], port: parseInt(bracketMatch[2], 10) }; + } + + // Handle host:port (only split on the last colon to avoid IPv6 issues) + const lastColon = target.lastIndexOf(':'); + if (lastColon <= 0) { + return { host: '', port: 0 }; + } + + const host = target.slice(0, lastColon); + const port = parseInt(target.slice(lastColon + 1), 10); + if (!host || isNaN(port) || port <= 0 || port > 65535) { + return { host: '', port: 0 }; + } + + return { host, port }; +} + +const LOOPBACK_RE = /^(localhost|127\.\d+\.\d+\.\d+|::1|\[::1\])$/i; + +export function isLoopback(host: string): boolean { + return LOOPBACK_RE.test(host); +} + +const DEFAULT_GIT_HOSTS = ['github.com', 'gitlab.com', 'bitbucket.org', 'ssh.dev.azure.com']; + +export function matchesBlocked(host: string, blocked: Set): boolean { + const h = normalizeDomain(host); + if (blocked.has(h)) return true; + const parts = h.split('.'); + for (let i = 1; i < parts.length; i++) { + const parent = parts.slice(i).join('.'); + if (blocked.has(parent)) return true; + } + return false; +} + +// --------------------------------------------------------------------------- +// LaunchDarkly client-side evaluation (no SDK — direct REST calls) +// --------------------------------------------------------------------------- + +interface LDFlagResult { + value: boolean; + variation?: number; + version?: number; + flagVersion?: number; +} + +class TTLCache { + private cache = new Map(); + private ttl: number; + + constructor(ttlSeconds: number) { + this.ttl = ttlSeconds * 1000; + } + + get(key: string): boolean | undefined { + const entry = this.cache.get(key); + if (!entry) return undefined; + if (Date.now() - entry.ts > this.ttl) { + this.cache.delete(key); + return undefined; + } + return entry.value; + } + + put(key: string, value: boolean): void { + this.cache.set(key, { value, ts: Date.now() }); + } +} + +function httpsRequest( + url: string, + method: string, + headers: Record, + body?: string +): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const req = https.request( + { + hostname: parsed.hostname, + port: parsed.port || 443, + path: parsed.pathname + parsed.search, + method, + headers, + timeout: 5000, + }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => { + resolve({ + status: res.statusCode || 0, + body: Buffer.concat(chunks).toString('utf-8'), + }); + }); + } + ); + req.on('error', reject); + req.on('timeout', () => { + req.destroy(); + reject(new Error('Request timed out')); + }); + if (body) req.write(body); + req.end(); + }); +} + +async function evaluateLDFlag( + clientId: string, + username: string, + domain: string +): Promise { + const url = `https://clientsdk.launchdarkly.com/sdk/evalx/${clientId}/context`; + const context = { kind: 'user', key: domain, username }; + try { + const resp = await httpsRequest( + url, + 'REPORT', + { 'Content-Type': 'application/json' }, + JSON.stringify(context) + ); + const flags = JSON.parse(resp.body); + const flag = flags['egress-allowlist']; + if (!flag || !('value' in flag)) return null; + return flag as LDFlagResult; + } catch { + return null; + } +} + +function sendLDEvent(clientId: string, username: string, domain: string, flag: LDFlagResult): void { + // Fire-and-forget — don't await, don't block the proxy + const url = `https://events.launchdarkly.com/events/bulk/${clientId}`; + const ts = Date.now(); + const events = [ + { + kind: 'index', + creationDate: ts, + context: { kind: 'user', key: domain, username }, + }, + { + kind: 'summary', + startDate: ts - 60000, + endDate: ts, + features: { + 'egress-allowlist': { + default: false, + contextKinds: ['user'], + counters: [ + { + variation: flag.variation, + version: flag.version ?? flag.flagVersion, + value: flag.value, + count: 1, + }, + ], + }, + }, + }, + ]; + httpsRequest( + url, + 'POST', + { + 'Content-Type': 'application/json', + 'X-LaunchDarkly-Event-Schema': '4', + 'X-LaunchDarkly-Payload-ID': crypto.randomUUID(), + }, + JSON.stringify(events) + ).catch(() => { + // fire-and-forget + }); +} + +// --------------------------------------------------------------------------- +// Combined blocking check +// --------------------------------------------------------------------------- + +export async function checkBlocked( + host: string, + port: number, + blocked: Set, + ldConfig: LaunchDarklyConfig | undefined, + ldCache: TTLCache | undefined, + options: ProxyOptions +): Promise<{ blocked: boolean; reason: string }> { + const normalized = normalizeDomain(host); + + if (options.blockLoopback && isLoopback(normalized)) { + log.warn( + `[sandbox-proxy] BLOCK loopback ${host}:${port} — if this breaks a local tool, it may not be respecting no_proxy` + ); + return { blocked: true, reason: 'loopback' }; + } + + if (!options.allowIPAddresses && isIPAddress(normalized)) { + return { blocked: true, reason: 'ip-address' }; + } + + if (matchesBlocked(normalized, blocked)) { + return { blocked: true, reason: 'blocklist' }; + } + + if (port === 22 || port === 2222 || port === 7999) { + if (options.allowSSH === false) { + return { blocked: true, reason: 'ssh-disabled' }; + } + if (!options.allowSSHToAllHosts) { + const gitHosts = options.gitHosts || DEFAULT_GIT_HOSTS; + const isGitHost = gitHosts.some((gh) => normalized === gh || normalized.endsWith('.' + gh)); + if (!isGitHost) { + return { blocked: true, reason: 'ssh-non-git-host' }; + } + } + } + + if (ldConfig && ldCache) { + const cached = ldCache.get(normalized); + if (cached !== undefined) { + log.info(`[sandbox-proxy] LD:HIT ${host} ${cached ? 'allow' : 'deny'}`); + return { blocked: !cached, reason: cached ? '' : 'launchdarkly (cached)' }; + } + + const flag = await evaluateLDFlag( + ldConfig.clientId, + ldConfig.username || os.userInfo().username, + normalized + ); + if (flag !== null) { + ldCache.put(normalized, flag.value); + const action = flag.value ? 'LD:OK' : 'LD:BLK'; + log.info(`[sandbox-proxy] ${action} ${host}`); + sendLDEvent(ldConfig.clientId, ldConfig.username || os.userInfo().username, normalized, flag); + return { blocked: !flag.value, reason: flag.value ? '' : 'launchdarkly' }; + } + + const failover = ldConfig.failoverMode || 'allow'; + if (failover === 'deny') { + log.warn(`[sandbox-proxy] LD:FAILOVER-DENY ${host}`); + return { blocked: true, reason: 'launchdarkly-unreachable' }; + } + if (failover === 'blocklist') { + log.warn(`[sandbox-proxy] LD:FAILOVER-BLOCKLIST ${host}`); + if (matchesBlocked(normalized, blocked)) { + return { blocked: true, reason: 'blocklist (LD fallback)' }; + } + } + log.info(`[sandbox-proxy] LD:ERR ${host} (defaulting to allow)`); + return { blocked: false, reason: '' }; + } + + return { blocked: false, reason: '' }; +} + +// --------------------------------------------------------------------------- +// Proxy server +// --------------------------------------------------------------------------- + +export async function startProxy(options: ProxyOptions = {}): Promise { + const { blockedPath, launchDarkly } = options; + const ldCache = launchDarkly ? new TTLCache(launchDarkly.cacheTtlSeconds ?? 3600) : undefined; + let blockedSet = loadBlocked(blockedPath); + let watcher: fs.FSWatcher | undefined; + if (blockedPath) { + try { + watcher = fs.watch(blockedPath, () => { + blockedSet = loadBlocked(blockedPath); + }); + } catch { + // file may not exist yet + } + } + + const server = http.createServer((req, res) => { + const url = req.url || ''; + let host = ''; + let reqPort = 80; + try { + const parsed = new URL(url); + host = parsed.hostname || ''; + reqPort = parseInt(parsed.port, 10) || 80; + } catch { + host = ''; + } + + // Use void to handle the async check without making the callback async + void (async () => { + if (host) { + const result = await checkBlocked( + host, + reqPort, + blockedSet, + launchDarkly, + ldCache, + options + ); + if (result.blocked) { + log.info(`[sandbox-proxy] BLOCK ${req.method} ${url.slice(0, 120)} (${result.reason})`); + res.writeHead(403, { 'Content-Type': 'text/plain' }); + res.end(`Blocked by sandbox proxy: ${host}`); + return; + } + } + + log.info(`[sandbox-proxy] ALLOW ${req.method} ${url.slice(0, 120)}`); + + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch { + res.writeHead(400); + res.end('Bad request URL'); + return; + } + + const proxyReq = http.request( + { + hostname: parsedUrl.hostname, + port: parsedUrl.port || 80, + path: parsedUrl.pathname + parsedUrl.search, + method: req.method, + headers: { ...req.headers, host: parsedUrl.host }, + }, + (proxyRes) => { + res.writeHead(proxyRes.statusCode || 502, proxyRes.headers); + proxyRes.pipe(res); + } + ); + + proxyReq.on('error', (err) => { + log.error(`[sandbox-proxy] ERROR ${req.method} ${url.slice(0, 120)}: ${err.message}`); + if (!res.headersSent) { + res.writeHead(502); + res.end(`Proxy error: ${err.message}`); + } + }); + + req.pipe(proxyReq); + })(); + }); + + // Handle CONNECT for HTTPS tunneling + server.on('connect', (req, clientSocket, head) => { + const target = req.url || ''; + const { host, port } = parseConnectTarget(target); + + if (!host || !port) { + log.error(`[sandbox-proxy] REJECT CONNECT invalid target: ${target}`); + clientSocket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); + clientSocket.destroy(); + return; + } + + void (async () => { + const result = await checkBlocked(host, port, blockedSet, launchDarkly, ldCache, options); + if (result.blocked) { + log.info(`[sandbox-proxy] BLOCK CONNECT ${target} (${result.reason})`); + clientSocket.write('HTTP/1.1 403 Forbidden\r\n\r\n'); + clientSocket.destroy(); + return; + } + + log.info(`[sandbox-proxy] ALLOW CONNECT ${target}`); + + const remoteSocket = net.connect(port, host, () => { + clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + if (head.length > 0) { + remoteSocket.write(head); + } + remoteSocket.pipe(clientSocket); + clientSocket.pipe(remoteSocket); + }); + + remoteSocket.on('error', (err) => { + log.error(`[sandbox-proxy] ERROR CONNECT ${target}: ${err.message}`); + clientSocket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); + clientSocket.destroy(); + }); + + clientSocket.on('error', () => { + remoteSocket.destroy(); + }); + })(); + }); + + return new Promise((resolve, reject) => { + const listenPort = options.port || 0; + // Bind exclusively to IPv4 loopback — the proxy must never be reachable from non-loopback interfaces. + // The sandboxed process connects via HTTP_PROXY=http://127.0.0.1:PORT so IPv6 is not needed. + server.listen(listenPort, '127.0.0.1', () => { + const addr = server.address(); + if (!addr || typeof addr === 'string') { + reject(new Error('Failed to get proxy server address')); + return; + } + const actualPort = addr.port; + log.info(`[sandbox-proxy] Listening on 127.0.0.1:${actualPort} (loopback only)`); + if (blockedPath) { + log.info(`[sandbox-proxy] Blocked domains file: ${blockedPath}`); + } + if (launchDarkly) { + log.info( + `[sandbox-proxy] LaunchDarkly: enabled (user=${launchDarkly.username || os.userInfo().username}, flag=egress-allowlist, cache=${launchDarkly.cacheTtlSeconds ?? 3600}s)` + ); + } + + resolve({ + port: actualPort, + server, + close: () => + new Promise((res) => { + watcher?.close(); + server.close(() => res()); + }), + }); + }); + + server.on('error', reject); + }); +}