From 0b2963ee1987914e1399285d3ac47c290114d61c Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Fri, 13 Feb 2026 12:28:09 +1100 Subject: [PATCH 1/6] first pass at real sandboxing for macos --- ui/desktop/forge.config.ts | 2 +- ui/desktop/src/goosed.ts | 35 ++- ui/desktop/src/sandbox/blocked.txt | 10 + ui/desktop/src/sandbox/index.ts | 103 ++++++++ ui/desktop/src/sandbox/proxy.ts | 394 +++++++++++++++++++++++++++++ ui/desktop/src/sandbox/sandbox.sb | 29 +++ 6 files changed, 568 insertions(+), 5 deletions(-) create mode 100644 ui/desktop/src/sandbox/blocked.txt create mode 100644 ui/desktop/src/sandbox/index.ts create mode 100644 ui/desktop/src/sandbox/proxy.ts create mode 100644 ui/desktop/src/sandbox/sandbox.sb 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 2528a816e833..4155b6bf7f03 100644 --- a/ui/desktop/src/goosed.ts +++ b/ui/desktop/src/goosed.ts @@ -11,6 +11,13 @@ import { Buffer } from 'node:buffer'; import { status } from './api'; import { Client } from './api/client'; import { ExternalGoosedConfig } from './utils/settings'; +import { + buildSandboxSpawn, + ensureProxy, + stopProxy, + isSandboxEnabled, + isSandboxAvailable, +} from './sandbox'; export const findAvailablePort = (): Promise => { return new Promise((resolve, _reject) => { @@ -109,6 +116,11 @@ export const startGoosed = async (options: StartGoosedOptions): Promise { log.info('App quitting, terminating goosed server'); try_kill_goose(); + if (useSandbox) { + stopProxy().catch((err) => log.error('Error stopping sandbox proxy:', err)); + } }); log.info(`Goosed server successfully started on port ${port}`); 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/index.ts b/ui/desktop/src/sandbox/index.ts new file mode 100644 index 000000000000..804fd3658fa6 --- /dev/null +++ b/ui/desktop/src/sandbox/index.ts @@ -0,0 +1,103 @@ +/** + * macOS Seatbelt sandbox for goosed. + * + * GOOSE_SANDBOX=true — enable sandbox + * LAUNCHDARKLY_CLIENT_ID=sdk-xxx — optional LD egress control + */ + +import path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { app } from 'electron'; +import log from '../utils/logger'; +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'); + +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 { + if (app.isPackaged) { + return path.join(process.resourcesPath, 'sandbox', filename); + } + return path.join(process.cwd(), 'src', 'sandbox', filename); +} + +/** + * Copy a bundled file to the runtime sandbox dir on first use. + * The .sb profile has __HOMEDIR__ replaced with the actual home directory + * since seatbelt can't expand ~ or use env vars. + */ +function materialise(filename: string): string { + const runtimePath = path.join(sandboxDir, filename); + if (!fs.existsSync(runtimePath)) { + fs.mkdirSync(sandboxDir, { recursive: true }); + let content = fs.readFileSync(bundledPath(filename), 'utf-8'); + content = content.split('__HOMEDIR__').join(homeDir); + fs.writeFileSync(runtimePath, content); + log.info(`[sandbox] Materialised ${filename}`); + } + return runtimePath; +} + +export function buildSandboxSpawn( + goosedPath: string, + goosedArgs: string[], + proxyPort: number +): { command: string; args: string[]; env: Record } { + const sandboxProfile = materialise('sandbox.sb'); + const proxyUrl = `http://127.0.0.1:${proxyPort}`; + + log.info(`[sandbox] Profile: ${sandboxProfile}`); + log.info(`[sandbox] Proxy port: ${proxyPort}`); + + 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', + }, + }; +} + +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 } + : undefined, + }); + + log.info(`[sandbox] Proxy started on port ${activeProxy.port}`); + return activeProxy; +} + +export async function stopProxy(): Promise { + if (activeProxy) { + await activeProxy.close(); + log.info('[sandbox] Proxy stopped'); + activeProxy = null; + } +} diff --git a/ui/desktop/src/sandbox/proxy.ts b/ui/desktop/src/sandbox/proxy.ts new file mode 100644 index 000000000000..96bc7e4a721c --- /dev/null +++ b/ui/desktop/src/sandbox/proxy.ts @@ -0,0 +1,394 @@ +/** + * 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). + * + * Blocking layers (checked in order): + * 1. Local blocklist (blocked.txt) — fast, no network, live-reloaded + * 2. LaunchDarkly flag ("egress-allowlist") — if configured, evaluates + * per-domain with a TTL cache. Unreachable LD → default allow. + */ + +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 log from '../utils/logger'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface LaunchDarklyConfig { + clientId: string; + username?: string; + cacheTtlSeconds?: number; +} + +export interface ProxyOptions { + port?: number; + blockedPath?: string; + launchDarkly?: LaunchDarklyConfig; +} + +export interface ProxyInstance { + port: number; + server: http.Server; + close: () => Promise; +} + +// --------------------------------------------------------------------------- +// Local blocklist +// --------------------------------------------------------------------------- + +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(); + } +} + +function matchesBlocked(host: string, blocked: Set): boolean { + const h = host.toLowerCase(); + 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 +// --------------------------------------------------------------------------- + +async function checkBlocked( + host: string, + blockedPath: string | undefined, + ldConfig: LaunchDarklyConfig | undefined, + ldCache: TTLCache | undefined +): Promise<{ blocked: boolean; reason: string }> { + // LaunchDarkly replaces blocked.txt when configured + if (ldConfig && ldCache) { + const domain = host.toLowerCase(); + const cached = ldCache.get(domain); + 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, + domain + ); + if (flag !== null) { + ldCache.put(domain, flag.value); + const action = flag.value ? 'LD:OK' : 'LD:BLK'; + log.info(`[sandbox-proxy] ${action} ${host}`); + sendLDEvent(ldConfig.clientId, ldConfig.username || os.userInfo().username, domain, flag); + return { blocked: !flag.value, reason: flag.value ? '' : 'launchdarkly' }; + } + + // LD unreachable — default allow + log.info(`[sandbox-proxy] LD:ERR ${host} (defaulting to allow)`); + return { blocked: false, reason: '' }; + } + + // No LD — use local blocklist + const blocked = loadBlocked(blockedPath); + if (matchesBlocked(host, blocked)) { + return { blocked: true, reason: 'blocklist' }; + } + + 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; + + const server = http.createServer((req, res) => { + const url = req.url || ''; + let host = ''; + try { + const parsed = new URL(url); + host = parsed.hostname || ''; + } catch { + host = ''; + } + + // Use void to handle the async check without making the callback async + void (async () => { + if (host) { + const result = await checkBlocked(host, blockedPath, launchDarkly, ldCache); + 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, portStr] = target.split(':'); + const port = parseInt(portStr || '443', 10); + + void (async () => { + const result = await checkBlocked(host, blockedPath, launchDarkly, ldCache); + 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; + 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}`); + 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) => { + server.close(() => res()); + }), + }); + }); + + server.on('error', reject); + }); +} diff --git a/ui/desktop/src/sandbox/sandbox.sb b/ui/desktop/src/sandbox/sandbox.sb new file mode 100644 index 000000000000..dba7cbb29ec2 --- /dev/null +++ b/ui/desktop/src/sandbox/sandbox.sb @@ -0,0 +1,29 @@ +;; Sandbox profile: allow everything EXCEPT direct outbound network. +;; The process can only reach the network via localhost (where our proxy lives). +;; All child processes inherit this restriction. +;; +;; __HOMEDIR__ is replaced with the actual home directory at materialisation time. + +(version 1) +(allow default) + +;; Protect sandbox config (sandbox.sb, blocked.txt) from the sandboxed process +(deny file-write* (subpath "__HOMEDIR__/.config/goose/sandbox")) + +;; Protect goose config from the sandboxed process +(deny file-write* (literal "__HOMEDIR__/.config/goose/config.yaml")) + +;; Block all network, then poke holes for localhost + DNS +(deny network*) + +;; DNS resolution via macOS system resolver (mDNSResponder) +(allow network-outbound (literal "/private/var/run/mDNSResponder")) + +;; Unix domain sockets (used by various local services) +(allow network-outbound (remote unix-socket)) + +;; Localhost only — this is the only way out, through our proxy +(allow network-outbound (remote ip "localhost:*")) + +;; Allow network-inbound on localhost (for local dev servers, LSPs, etc.) +(allow network-inbound (local ip "localhost:*")) From cba6c4fbf8ecdd33c5e0b01c57d4b7cd3e288256 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Fri, 13 Feb 2026 13:05:45 +1100 Subject: [PATCH 2/6] lint --- ui/desktop/src/sandbox/proxy.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/desktop/src/sandbox/proxy.ts b/ui/desktop/src/sandbox/proxy.ts index 96bc7e4a721c..715223af64f4 100644 --- a/ui/desktop/src/sandbox/proxy.ts +++ b/ui/desktop/src/sandbox/proxy.ts @@ -19,6 +19,7 @@ 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'; import log from '../utils/logger'; // --------------------------------------------------------------------------- From d5e4bf4693b6738054375e19db656c37af403550 Mon Sep 17 00:00:00 2001 From: Alex Rosenzweig Date: Fri, 13 Feb 2026 15:51:54 +1100 Subject: [PATCH 3/6] feat(sandbox): configurable macOS seatbelt sandbox with egress proxy Adds a macOS sandbox for the goosed process using seatbelt (sandbox-exec) and an HTTP CONNECT proxy for egress filtering. All outbound network traffic from goosed is forced through a local proxy that enforces domain blocking, IP restrictions, and SSH allowlisting. ## Sandbox profile (seatbelt) The sandbox profile is built programmatically via buildSandboxProfile() rather than a static template, ensuring it is always regenerated fresh on each spawn and never becomes stale across app updates. Configurable protections (all default to enabled): - GOOSE_SANDBOX_PROTECT_FILES: write-protect ~/.ssh, shell configs - GOOSE_SANDBOX_BLOCK_RAW_SOCKETS: deny SOCK_RAW on AF_INET/AF_INET6 - GOOSE_SANDBOX_BLOCK_TUNNELING: block nc, ncat, netcat, socat, telnet Always-on protections: - All network denied except localhost (forces proxy usage) - Sandbox config and goose config are write-protected - Kernel extension loading denied ## Egress proxy An HTTP/CONNECT proxy runs in the Electron main process on 127.0.0.1. The sandboxed process uses it via HTTP_PROXY/HTTPS_PROXY env vars. Blocking layers checked in order: 1. Loopback detection (prevents proxy-as-relay bypass) 2. Raw IP address blocking (no domain = blocked) 3. Local blocklist (blocked.txt, live-reloaded via fs.watch) 4. SSH/Git host restriction (port 22/2222/7999) 5. LaunchDarkly egress-allowlist flag (optional, with configurable failover) Configurable proxy options: - GOOSE_SANDBOX_ALLOW_IP: allow raw IP connections (default: blocked) - GOOSE_SANDBOX_BLOCK_LOOPBACK: block loopback relay via proxy (default: off) - GOOSE_SANDBOX_ALLOW_SSH: enable/disable SSH (default: on) - GOOSE_SANDBOX_GIT_HOSTS: custom git host allowlist for SSH - GOOSE_SANDBOX_SSH_ALL_HOSTS: allow SSH to any host (default: off) - GOOSE_SANDBOX_LD_FAILOVER: LaunchDarkly failover mode (allow|deny|blocklist) ## Other changes - Startup now throws an error if GOOSE_SANDBOX=true but sandbox-exec is unavailable, instead of silently proceeding unsandboxed - Domain normalization handles trailing dots, punycode, and IPv6 brackets - Blocklist loaded once at startup and cached in memory with fs.watch for live reload (no per-request disk I/O) - Proxy resources cleaned up on app quit - Removed unused dns import and resolveIPToHostnames function - Formatted with prettier Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019c551f-ba60-709e-b293-5dedad8d243f --- ui/desktop/src/goosed.ts | 8 +- ui/desktop/src/sandbox/connect-proxy.pl | 43 +++++++ ui/desktop/src/sandbox/index.ts | 151 ++++++++++++++++++++++-- ui/desktop/src/sandbox/proxy.ts | 151 ++++++++++++++++++++---- ui/desktop/src/sandbox/sandbox.sb | 28 ++++- 5 files changed, 345 insertions(+), 36 deletions(-) create mode 100755 ui/desktop/src/sandbox/connect-proxy.pl diff --git a/ui/desktop/src/goosed.ts b/ui/desktop/src/goosed.ts index 4155b6bf7f03..b78af95b8038 100644 --- a/ui/desktop/src/goosed.ts +++ b/ui/desktop/src/goosed.ts @@ -116,10 +116,10 @@ export const startGoosed = async (options: StartGoosedOptions): Promise \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 index 804fd3658fa6..def2428bd73c 100644 --- a/ui/desktop/src/sandbox/index.ts +++ b/ui/desktop/src/sandbox/index.ts @@ -3,6 +3,23 @@ * * 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'; @@ -18,6 +35,78 @@ 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'; } @@ -33,33 +122,58 @@ function bundledPath(filename: string): string { return path.join(process.cwd(), 'src', 'sandbox', filename); } -/** - * Copy a bundled file to the runtime sandbox dir on first use. - * The .sb profile has __HOMEDIR__ replaced with the actual home directory - * since seatbelt can't expand ~ or use env vars. - */ function materialise(filename: string): string { const runtimePath = path.join(sandboxDir, filename); if (!fs.existsSync(runtimePath)) { fs.mkdirSync(sandboxDir, { recursive: true }); - let content = fs.readFileSync(bundledPath(filename), 'utf-8'); - content = content.split('__HOMEDIR__').join(homeDir); + const content = fs.readFileSync(bundledPath(filename), 'utf-8'); fs.writeFileSync(runtimePath, content); log.info(`[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 sandboxProfile = materialise('sandbox.sb'); + 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(); log.info(`[sandbox] Profile: ${sandboxProfile}`); log.info(`[sandbox] Proxy port: ${proxyPort}`); + log.info( + `[sandbox] Config: protectSensitiveFiles=${profileOptions.protectSensitiveFiles}, blockRawSockets=${profileOptions.blockRawSockets}, blockTunnelingTools=${profileOptions.blockTunnelingTools}` + ); return { command: '/usr/bin/sandbox-exec', @@ -71,10 +185,16 @@ export function buildSandboxSpawn( 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 { @@ -86,8 +206,21 @@ export async function ensureProxy(): Promise { activeProxy = await startProxy({ blockedPath, launchDarkly: ldClientId - ? { clientId: ldClientId, username: os.userInfo().username } + ? { + 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', }); log.info(`[sandbox] Proxy started on port ${activeProxy.port}`); diff --git a/ui/desktop/src/sandbox/proxy.ts b/ui/desktop/src/sandbox/proxy.ts index 715223af64f4..935e697e1bd1 100644 --- a/ui/desktop/src/sandbox/proxy.ts +++ b/ui/desktop/src/sandbox/proxy.ts @@ -6,10 +6,15 @@ * 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. Local blocklist (blocked.txt) — fast, no network, live-reloaded - * 2. LaunchDarkly flag ("egress-allowlist") — if configured, evaluates - * per-domain with a TTL cache. Unreachable LD → default allow. + * 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'; @@ -30,12 +35,18 @@ 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 { @@ -65,8 +76,40 @@ function loadBlocked(blockedPath: string | undefined): Set { } } +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; +} + +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; +} + +const LOOPBACK_RE = /^(localhost|127\.\d+\.\d+\.\d+|::1|\[::1\])$/i; + +function isLoopback(host: string): boolean { + return LOOPBACK_RE.test(host); +} + +const DEFAULT_GIT_HOSTS = ['github.com', 'gitlab.com', 'bitbucket.org', 'ssh.dev.azure.com']; + function matchesBlocked(host: string, blocked: Set): boolean { - const h = host.toLowerCase(); + const h = normalizeDomain(host); if (blocked.has(h)) return true; const parts = h.split('.'); for (let i = 1; i < parts.length; i++) { @@ -156,7 +199,12 @@ async function evaluateLDFlag( 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 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; @@ -216,14 +264,44 @@ function sendLDEvent(clientId: string, username: string, domain: string, flag: L async function checkBlocked( host: string, - blockedPath: string | undefined, + port: number, + blocked: Set, ldConfig: LaunchDarklyConfig | undefined, - ldCache: TTLCache | undefined + ldCache: TTLCache | undefined, + options: ProxyOptions ): Promise<{ blocked: boolean; reason: string }> { - // LaunchDarkly replaces blocked.txt when configured + 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 domain = host.toLowerCase(); - const cached = ldCache.get(domain); + 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)' }; @@ -232,27 +310,31 @@ async function checkBlocked( const flag = await evaluateLDFlag( ldConfig.clientId, ldConfig.username || os.userInfo().username, - domain + normalized ); if (flag !== null) { - ldCache.put(domain, flag.value); + 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, domain, flag); + sendLDEvent(ldConfig.clientId, ldConfig.username || os.userInfo().username, normalized, flag); return { blocked: !flag.value, reason: flag.value ? '' : 'launchdarkly' }; } - // LD unreachable — default allow + 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: '' }; } - // No LD — use local blocklist - const blocked = loadBlocked(blockedPath); - if (matchesBlocked(host, blocked)) { - return { blocked: true, reason: 'blocklist' }; - } - return { blocked: false, reason: '' }; } @@ -263,13 +345,26 @@ async function checkBlocked( 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 = ''; } @@ -277,7 +372,14 @@ export async function startProxy(options: ProxyOptions = {}): Promise { if (host) { - const result = await checkBlocked(host, blockedPath, launchDarkly, ldCache); + 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' }); @@ -330,7 +432,7 @@ export async function startProxy(options: ProxyOptions = {}): Promise { - const result = await checkBlocked(host, blockedPath, launchDarkly, ldCache); + 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'); @@ -363,6 +465,8 @@ export async function startProxy(options: ProxyOptions = {}): Promise { 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') { @@ -370,7 +474,7 @@ export async function startProxy(options: ProxyOptions = {}): Promise new Promise((res) => { + watcher?.close(); server.close(() => res()); }), }); diff --git a/ui/desktop/src/sandbox/sandbox.sb b/ui/desktop/src/sandbox/sandbox.sb index dba7cbb29ec2..94b00cacb31e 100644 --- a/ui/desktop/src/sandbox/sandbox.sb +++ b/ui/desktop/src/sandbox/sandbox.sb @@ -2,17 +2,32 @@ ;; The process can only reach the network via localhost (where our proxy lives). ;; All child processes inherit this restriction. ;; -;; __HOMEDIR__ is replaced with the actual home directory at materialisation time. +;; Placeholders replaced at materialisation time: +;; __HOMEDIR__ → actual home directory +;; __PROXY_PORT__ → actual proxy port +;; __SENSITIVE_FILES__ → file protection rules (or empty) +;; __RAW_SOCKETS__ → raw socket blocking rules (or empty) +;; __TUNNELING_TOOLS__ → tunneling tool blocking rules (or empty) (version 1) (allow default) +;; ============================================================================ +;; FILE SYSTEM PROTECTIONS +;; ============================================================================ + ;; Protect sandbox config (sandbox.sb, blocked.txt) from the sandboxed process (deny file-write* (subpath "__HOMEDIR__/.config/goose/sandbox")) ;; Protect goose config from the sandboxed process (deny file-write* (literal "__HOMEDIR__/.config/goose/config.yaml")) +__SENSITIVE_FILES__ + +;; ============================================================================ +;; NETWORK RESTRICTIONS +;; ============================================================================ + ;; Block all network, then poke holes for localhost + DNS (deny network*) @@ -27,3 +42,14 @@ ;; Allow network-inbound on localhost (for local dev servers, LSPs, etc.) (allow network-inbound (local ip "localhost:*")) + +__RAW_SOCKETS__ + +;; ============================================================================ +;; PROCESS RESTRICTIONS +;; ============================================================================ + +__TUNNELING_TOOLS__ + +;; Prevent loading of network-related kernel extensions +(deny system-kext-load) From 853568776f1f747564c5185ded039ccff874a1c3 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Mon, 16 Feb 2026 11:57:55 +1100 Subject: [PATCH 4/6] zip version was yanked --- Cargo.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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", From 1f95f55e2c72b0267e030d0dd16ecddda53886fc Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Mon, 16 Feb 2026 12:17:26 +1100 Subject: [PATCH 5/6] new testing needs this --- ui/desktop/src/sandbox/index.ts | 23 +++++++++++++---------- ui/desktop/src/sandbox/proxy.ts | 6 +++++- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/ui/desktop/src/sandbox/index.ts b/ui/desktop/src/sandbox/index.ts index def2428bd73c..46ff8ebfd8fd 100644 --- a/ui/desktop/src/sandbox/index.ts +++ b/ui/desktop/src/sandbox/index.ts @@ -25,8 +25,6 @@ import path from 'node:path'; import fs from 'node:fs'; import os from 'node:os'; -import { app } from 'electron'; -import log from '../utils/logger'; import { startProxy, ProxyInstance } from './proxy'; export { startProxy } from './proxy'; @@ -116,8 +114,13 @@ export function isSandboxAvailable(): boolean { } function bundledPath(filename: string): string { - if (app.isPackaged) { - return path.join(process.resourcesPath, 'sandbox', filename); + // 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); } @@ -128,7 +131,7 @@ function materialise(filename: string): string { fs.mkdirSync(sandboxDir, { recursive: true }); const content = fs.readFileSync(bundledPath(filename), 'utf-8'); fs.writeFileSync(runtimePath, content); - log.info(`[sandbox] Materialised ${filename}`); + console.log(`[sandbox] Materialised ${filename}`); } return runtimePath; } @@ -169,9 +172,9 @@ export function buildSandboxSpawn( const proxyUrl = `http://127.0.0.1:${proxyPort}`; const connectProxy = writeConnectProxy(); - log.info(`[sandbox] Profile: ${sandboxProfile}`); - log.info(`[sandbox] Proxy port: ${proxyPort}`); - log.info( + console.log(`[sandbox] Profile: ${sandboxProfile}`); + console.log(`[sandbox] Proxy port: ${proxyPort}`); + console.log( `[sandbox] Config: protectSensitiveFiles=${profileOptions.protectSensitiveFiles}, blockRawSockets=${profileOptions.blockRawSockets}, blockTunnelingTools=${profileOptions.blockTunnelingTools}` ); @@ -223,14 +226,14 @@ export async function ensureProxy(): Promise { allowSSHToAllHosts: process.env.GOOSE_SANDBOX_SSH_ALL_HOSTS === 'true', }); - log.info(`[sandbox] Proxy started on port ${activeProxy.port}`); + console.log(`[sandbox] Proxy started on port ${activeProxy.port}`); return activeProxy; } export async function stopProxy(): Promise { if (activeProxy) { await activeProxy.close(); - log.info('[sandbox] Proxy stopped'); + console.log('[sandbox] Proxy stopped'); activeProxy = null; } } diff --git a/ui/desktop/src/sandbox/proxy.ts b/ui/desktop/src/sandbox/proxy.ts index 935e697e1bd1..1594a4ba40a3 100644 --- a/ui/desktop/src/sandbox/proxy.ts +++ b/ui/desktop/src/sandbox/proxy.ts @@ -25,7 +25,11 @@ import os from 'node:os'; import crypto from 'node:crypto'; import { URL } from 'node:url'; import { Buffer } from 'node:buffer'; -import log from '../utils/logger'; +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 From 7c13d38c83ecf1ef4c0d5ed5772df79fc7253dae Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Mon, 16 Feb 2026 12:45:46 +1100 Subject: [PATCH 6/6] cleanup unneeded .sb, add tests, and tidy --- documentation/docs/guides/sandbox.md | 10 +- ui/desktop/src/sandbox/proxy.test.ts | 282 +++++++++++++++++++++++++++ ui/desktop/src/sandbox/proxy.ts | 44 ++++- ui/desktop/src/sandbox/sandbox.sb | 55 ------ 4 files changed, 323 insertions(+), 68 deletions(-) create mode 100644 ui/desktop/src/sandbox/proxy.test.ts delete mode 100644 ui/desktop/src/sandbox/sandbox.sb diff --git a/documentation/docs/guides/sandbox.md b/documentation/docs/guides/sandbox.md index 6bd3463acb3d..71b1d77fb887 100644 --- a/documentation/docs/guides/sandbox.md +++ b/documentation/docs/guides/sandbox.md @@ -1,24 +1,24 @@ -# macOS Sandbox for Goosed +# 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. +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: +Set the environment variable before launching the goose desktop app: ```bash GOOSE_SANDBOX=true ``` -Then start the desktop app as normal. Goose will: +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. +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 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 index 1594a4ba40a3..bd6149766c29 100644 --- a/ui/desktop/src/sandbox/proxy.ts +++ b/ui/desktop/src/sandbox/proxy.ts @@ -63,7 +63,7 @@ export interface ProxyInstance { // Local blocklist // --------------------------------------------------------------------------- -function loadBlocked(blockedPath: string | undefined): Set { +export function loadBlocked(blockedPath: string | undefined): Set { if (!blockedPath) return new Set(); try { if (!fs.existsSync(blockedPath)) return new Set(); @@ -80,7 +80,7 @@ function loadBlocked(blockedPath: string | undefined): Set { } } -function normalizeDomain(host: string): string { +export function normalizeDomain(host: string): string { let normalized = host.toLowerCase().trim(); if (normalized.endsWith('.')) { normalized = normalized.slice(0, -1); @@ -97,22 +97,44 @@ function normalizeDomain(host: string): string { return normalized; } -function isIPAddress(host: string): boolean { +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; -function isLoopback(host: string): boolean { +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']; -function matchesBlocked(host: string, blocked: Set): boolean { +export function matchesBlocked(host: string, blocked: Set): boolean { const h = normalizeDomain(host); if (blocked.has(h)) return true; const parts = h.split('.'); @@ -266,7 +288,7 @@ function sendLDEvent(clientId: string, username: string, domain: string, flag: L // Combined blocking check // --------------------------------------------------------------------------- -async function checkBlocked( +export async function checkBlocked( host: string, port: number, blocked: Set, @@ -432,8 +454,14 @@ export async function startProxy(options: ProxyOptions = {}): Promise { const target = req.url || ''; - const [host, portStr] = target.split(':'); - const port = parseInt(portStr || '443', 10); + 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); diff --git a/ui/desktop/src/sandbox/sandbox.sb b/ui/desktop/src/sandbox/sandbox.sb deleted file mode 100644 index 94b00cacb31e..000000000000 --- a/ui/desktop/src/sandbox/sandbox.sb +++ /dev/null @@ -1,55 +0,0 @@ -;; Sandbox profile: allow everything EXCEPT direct outbound network. -;; The process can only reach the network via localhost (where our proxy lives). -;; All child processes inherit this restriction. -;; -;; Placeholders replaced at materialisation time: -;; __HOMEDIR__ → actual home directory -;; __PROXY_PORT__ → actual proxy port -;; __SENSITIVE_FILES__ → file protection rules (or empty) -;; __RAW_SOCKETS__ → raw socket blocking rules (or empty) -;; __TUNNELING_TOOLS__ → tunneling tool blocking rules (or empty) - -(version 1) -(allow default) - -;; ============================================================================ -;; FILE SYSTEM PROTECTIONS -;; ============================================================================ - -;; Protect sandbox config (sandbox.sb, blocked.txt) from the sandboxed process -(deny file-write* (subpath "__HOMEDIR__/.config/goose/sandbox")) - -;; Protect goose config from the sandboxed process -(deny file-write* (literal "__HOMEDIR__/.config/goose/config.yaml")) - -__SENSITIVE_FILES__ - -;; ============================================================================ -;; NETWORK RESTRICTIONS -;; ============================================================================ - -;; Block all network, then poke holes for localhost + DNS -(deny network*) - -;; DNS resolution via macOS system resolver (mDNSResponder) -(allow network-outbound (literal "/private/var/run/mDNSResponder")) - -;; Unix domain sockets (used by various local services) -(allow network-outbound (remote unix-socket)) - -;; Localhost only — this is the only way out, through our proxy -(allow network-outbound (remote ip "localhost:*")) - -;; Allow network-inbound on localhost (for local dev servers, LSPs, etc.) -(allow network-inbound (local ip "localhost:*")) - -__RAW_SOCKETS__ - -;; ============================================================================ -;; PROCESS RESTRICTIONS -;; ============================================================================ - -__TUNNELING_TOOLS__ - -;; Prevent loading of network-related kernel extensions -(deny system-kext-load)