diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e8aef8aa..5c67a0ea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -215,7 +215,7 @@ To debug the CLI's React-based UI, you can use React DevTools. Ink, the library ### MacOS Seatbelt -On MacOS, `gemini` uses Seatbelt (`sandbox-exec`) under a `minimal` profile (see `packages/cli/src/utils/sandbox-macos-minimal.sb`) that restricts writes to the project folder but otherwise allows all other operations by default. You can switch to a `strict` profile (see `.../sandbox-macos-strict.sb`) that declines operations by default by setting `SEATBELT_PROFILE=strict` in your environment or `.env` file. You can also switch to a custom profile `SEATBELT_PROFILE=` if you also create a file `.gemini/sandbox-macos-.sb` under your project settings directory `.gemini`. +On MacOS, `gemini` uses Seatbelt (`sandbox-exec`) under a `permissive-open` profile (see `packages/cli/src/utils/sandbox-macos-permissive-open.sb`) that restricts writes to the project folder but otherwise allows all other operations and outbound network traffic ("open") by default. You can switch to a `restrictive-closed` profile (see `.../sandbox-macos-strict.sb`) that declines all operations and outbound network traffic ("closed") by default by setting `SEATBELT_PROFILE=restrictive-closed` in your environment or `.env` file. Available built-in profiles are `{permissive,restrictive}-{open,closed,proxied}` (see below for proxied networking). You can also switch to a custom profile `SEATBELT_PROFILE=` if you also create a file `.gemini/sandbox-macos-.sb` under your project settings directory `.gemini`. ### Container-based Sandboxing (All Platforms) @@ -223,6 +223,10 @@ For stronger container-based sandboxing on MacOS or other platforms, you can set Container-based sandboxing mounts the project directory (and system temp directory) with read-write access and is started/stopped/removed automatically as you start/stop Gemini CLI. Files created within the sandbox should be automatically mapped to your user/group on host machine. You can easily specify additional mounts, ports, or environment variables by setting `SANDBOX_{MOUNTS,PORTS,ENV}` as needed. You can also fully customize the sandbox for your projects by creating the files `.gemini/sandbox.Dockerfile` and/or `.gemini/sandbox.bashrc` under your project settings directory (`.gemini`) and running `gemini` with `BUILD_SANDBOX=1` to trigger building of your custom sandbox. +#### Proxied Networking + +All sandboxing methods, including MacOS Seatbelt using `*-proxied` profiles, support restricting outbound network traffic through a custom proxy server that can be specified as `GEMINI_SANDBOX_PROXY_COMMAND=`, where `` must start a proxy server that listens on `0.0.0.0:8877` for relevant requests. See `scripts/example-proxy.js` for a minimal proxy that only allows `HTTPS` connections to `example.com:443` (e.g. `curl https://example.com`) and declines all other requests. The proxy is started and stopped automatically alongside the sandbox. + ## Manual Publish We publish an artifact for each commit to our internal registry. But if you need to manually cut a local build, then run the following commands: diff --git a/Dockerfile b/Dockerfile index d6e8c2ae..092e1ed5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,6 +23,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ psmisc \ lsof \ socat \ + ca-certificates \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index 7c8ff81e..05dfc65b 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -203,7 +203,7 @@ The CLI automatically loads environment variables from an `.env` file. The loadi - Accepts `true`, `false`, `docker`, `podman`, or a custom command string. - **`SEATBELT_PROFILE`** (macOS specific): - Switches the Seatbelt (`sandbox-exec`) profile on macOS. - - `minimal`: (Default) Restricts writes to the project folder (and a few other folders, see `packages/cli/src/utils/sandbox-macos-minimal.sb`) but allows other operations. + - `permissive-open`: (Default) Restricts writes to the project folder (and a few other folders, see `packages/cli/src/utils/sandbox-macos-permissive-open.sb`) but allows other operations. - `strict`: Uses a strict profile that declines operations by default. - ``: Uses a custom profile. To define a custom profile, create a file named `sandbox-macos-.sb` in your project's `.gemini/` directory (e.g., `my-project/.gemini/sandbox-macos-custom.sb`). - **`DEBUG` or `DEBUG_MODE`** (often used by underlying libraries or the CLI itself): diff --git a/package-lock.json b/package-lock.json index becc665a..5c502b4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9403,6 +9403,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.10.0.tgz", + "integrity": "sha512-u5otvFBOBZvmdjWLVW+5DAc9Nkq8f24g0O9oY7qw2JVIF1VocIFoyz9JFkuVOS2j41AufeO0xnlweJ2RLT8nGw==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", @@ -10754,7 +10763,8 @@ "fast-glob": "^3.3.3", "ignore": "^7.0.0", "shell-quote": "^1.8.2", - "strip-ansi": "^7.1.0" + "strip-ansi": "^7.1.0", + "undici": "^7.10.0" }, "devDependencies": { "@types/diff": "^7.0.2", diff --git a/packages/cli/src/utils/sandbox-macos-permissive-closed.sb b/packages/cli/src/utils/sandbox-macos-permissive-closed.sb new file mode 100644 index 00000000..36d88995 --- /dev/null +++ b/packages/cli/src/utils/sandbox-macos-permissive-closed.sb @@ -0,0 +1,26 @@ +(version 1) + +;; allow everything by default +(allow default) + +;; deny all writes EXCEPT under specific paths +(deny file-write*) +(allow file-write* + (subpath (param "TARGET_DIR")) + (subpath (param "TMP_DIR")) + (subpath (param "CACHE_DIR")) + (subpath (string-append (param "HOME_DIR") "/.gemini")) + (subpath (string-append (param "HOME_DIR") "/.npm")) + (subpath (string-append (param "HOME_DIR") "/.cache")) + (subpath (string-append (param "HOME_DIR") "/.gitconfig")) + (literal "/dev/stdout") + (literal "/dev/stderr") + (literal "/dev/null") +) + +;; deny all inbound network traffic EXCEPT on debugger port +(deny network-inbound) +(allow network-inbound (local ip "localhost:9229")) + +;; deny all outbound network traffic +(deny network-outbound) diff --git a/packages/cli/src/utils/sandbox-macos-minimal.sb b/packages/cli/src/utils/sandbox-macos-permissive-open.sb similarity index 100% rename from packages/cli/src/utils/sandbox-macos-minimal.sb rename to packages/cli/src/utils/sandbox-macos-permissive-open.sb diff --git a/packages/cli/src/utils/sandbox-macos-permissive-proxied.sb b/packages/cli/src/utils/sandbox-macos-permissive-proxied.sb new file mode 100644 index 00000000..861e503d --- /dev/null +++ b/packages/cli/src/utils/sandbox-macos-permissive-proxied.sb @@ -0,0 +1,31 @@ +(version 1) + +;; allow everything by default +(allow default) + +;; deny all writes EXCEPT under specific paths +(deny file-write*) +(allow file-write* + (subpath (param "TARGET_DIR")) + (subpath (param "TMP_DIR")) + (subpath (param "CACHE_DIR")) + (subpath (string-append (param "HOME_DIR") "/.gemini")) + (subpath (string-append (param "HOME_DIR") "/.npm")) + (subpath (string-append (param "HOME_DIR") "/.cache")) + (subpath (string-append (param "HOME_DIR") "/.gitconfig")) + (literal "/dev/stdout") + (literal "/dev/stderr") + (literal "/dev/null") +) + +;; deny all inbound network traffic EXCEPT on debugger port +(deny network-inbound) +(allow network-inbound (local ip "localhost:9229")) + +;; deny all outbound network traffic EXCEPT through proxy on localhost:8877 +;; set `GEMINI_SANDBOX_PROXY_COMMAND=` to run proxy alongside sandbox +;; proxy must listen on 0.0.0.0:8877 (see scripts/example-proxy.js) +(deny network-outbound) +(allow network-outbound (remote tcp "localhost:8877")) + +(allow network-bind (local ip "*:*")) diff --git a/packages/cli/src/utils/sandbox-macos-restrictive-closed.sb b/packages/cli/src/utils/sandbox-macos-restrictive-closed.sb new file mode 100644 index 00000000..9ce68e9d --- /dev/null +++ b/packages/cli/src/utils/sandbox-macos-restrictive-closed.sb @@ -0,0 +1,87 @@ +(version 1) + +;; deny everything by default +(deny default) + +;; allow reading files from anywhere on host +(allow file-read*) + +;; allow exec/fork (children inherit policy) +(allow process-exec) +(allow process-fork) + +;; allow signals to self, e.g. SIGPIPE on write to closed pipe +(allow signal (target self)) + +;; allow read access to specific information about system +;; from https://source.chromium.org/chromium/chromium/src/+/main:sandbox/policy/mac/common.sb;l=273-319;drc=7b3962fe2e5fc9e2ee58000dc8fbf3429d84d3bd +(allow sysctl-read + (sysctl-name "hw.activecpu") + (sysctl-name "hw.busfrequency_compat") + (sysctl-name "hw.byteorder") + (sysctl-name "hw.cacheconfig") + (sysctl-name "hw.cachelinesize_compat") + (sysctl-name "hw.cpufamily") + (sysctl-name "hw.cpufrequency_compat") + (sysctl-name "hw.cputype") + (sysctl-name "hw.l1dcachesize_compat") + (sysctl-name "hw.l1icachesize_compat") + (sysctl-name "hw.l2cachesize_compat") + (sysctl-name "hw.l3cachesize_compat") + (sysctl-name "hw.logicalcpu_max") + (sysctl-name "hw.machine") + (sysctl-name "hw.ncpu") + (sysctl-name "hw.nperflevels") + (sysctl-name "hw.optional.arm.FEAT_BF16") + (sysctl-name "hw.optional.arm.FEAT_DotProd") + (sysctl-name "hw.optional.arm.FEAT_FCMA") + (sysctl-name "hw.optional.arm.FEAT_FHM") + (sysctl-name "hw.optional.arm.FEAT_FP16") + (sysctl-name "hw.optional.arm.FEAT_I8MM") + (sysctl-name "hw.optional.arm.FEAT_JSCVT") + (sysctl-name "hw.optional.arm.FEAT_LSE") + (sysctl-name "hw.optional.arm.FEAT_RDM") + (sysctl-name "hw.optional.arm.FEAT_SHA512") + (sysctl-name "hw.optional.armv8_2_sha512") + (sysctl-name "hw.packages") + (sysctl-name "hw.pagesize_compat") + (sysctl-name "hw.physicalcpu_max") + (sysctl-name "hw.tbfrequency_compat") + (sysctl-name "hw.vectorunit") + (sysctl-name "kern.hostname") + (sysctl-name "kern.maxfilesperproc") + (sysctl-name "kern.osproductversion") + (sysctl-name "kern.osrelease") + (sysctl-name "kern.ostype") + (sysctl-name "kern.osvariant_status") + (sysctl-name "kern.osversion") + (sysctl-name "kern.secure_kernel") + (sysctl-name "kern.usrstack64") + (sysctl-name "kern.version") + (sysctl-name "sysctl.proc_cputype") + (sysctl-name-prefix "hw.perflevel") +) + +;; allow writes to specific paths +(allow file-write* + (subpath (param "TARGET_DIR")) + (subpath (param "TMP_DIR")) + (subpath (param "CACHE_DIR")) + (subpath (string-append (param "HOME_DIR") "/.gemini")) + (subpath (string-append (param "HOME_DIR") "/.npm")) + (subpath (string-append (param "HOME_DIR") "/.cache")) + (subpath (string-append (param "HOME_DIR") "/.gitconfig")) + (literal "/dev/stdout") + (literal "/dev/stderr") + (literal "/dev/null") +) + +;; allow communication with sysmond for process listing (e.g. for pgrep) +(allow mach-lookup (global-name "com.apple.sysmond")) + +;; enable terminal access required by ink +;; fixes setRawMode EPERM failure (at node:tty:81:24) +(allow file-ioctl (regex #"^/dev/tty.*")) + +;; allow inbound network traffic on debugger port +(allow network-inbound (local ip "localhost:9229")) \ No newline at end of file diff --git a/packages/cli/src/utils/sandbox-macos-strict.sb b/packages/cli/src/utils/sandbox-macos-restrictive-open.sb similarity index 94% rename from packages/cli/src/utils/sandbox-macos-strict.sb rename to packages/cli/src/utils/sandbox-macos-restrictive-open.sb index 010fee00..e89b8090 100644 --- a/packages/cli/src/utils/sandbox-macos-strict.sb +++ b/packages/cli/src/utils/sandbox-macos-restrictive-open.sb @@ -76,15 +76,15 @@ (literal "/dev/null") ) -;; allow outbound network connections -(allow network-outbound) - -;; allow inbound network connections to debugging port -(allow network-inbound (local ip (string-append "*:" "9229"))) - ;; allow communication with sysmond for process listing (e.g. for pgrep) (allow mach-lookup (global-name "com.apple.sysmond")) ;; enable terminal access required by ink ;; fixes setRawMode EPERM failure (at node:tty:81:24) (allow file-ioctl (regex #"^/dev/tty.*")) + +;; allow inbound network traffic on debugger port +(allow network-inbound (local ip "localhost:9229")) + +;; allow all outbound network traffic +(allow network-outbound) \ No newline at end of file diff --git a/packages/cli/src/utils/sandbox-macos-restrictive-proxied.sb b/packages/cli/src/utils/sandbox-macos-restrictive-proxied.sb new file mode 100644 index 00000000..cc4c1e5e --- /dev/null +++ b/packages/cli/src/utils/sandbox-macos-restrictive-proxied.sb @@ -0,0 +1,92 @@ +(version 1) + +;; deny everything by default +(deny default) + +;; allow reading files from anywhere on host +(allow file-read*) + +;; allow exec/fork (children inherit policy) +(allow process-exec) +(allow process-fork) + +;; allow signals to self, e.g. SIGPIPE on write to closed pipe +(allow signal (target self)) + +;; allow read access to specific information about system +;; from https://source.chromium.org/chromium/chromium/src/+/main:sandbox/policy/mac/common.sb;l=273-319;drc=7b3962fe2e5fc9e2ee58000dc8fbf3429d84d3bd +(allow sysctl-read + (sysctl-name "hw.activecpu") + (sysctl-name "hw.busfrequency_compat") + (sysctl-name "hw.byteorder") + (sysctl-name "hw.cacheconfig") + (sysctl-name "hw.cachelinesize_compat") + (sysctl-name "hw.cpufamily") + (sysctl-name "hw.cpufrequency_compat") + (sysctl-name "hw.cputype") + (sysctl-name "hw.l1dcachesize_compat") + (sysctl-name "hw.l1icachesize_compat") + (sysctl-name "hw.l2cachesize_compat") + (sysctl-name "hw.l3cachesize_compat") + (sysctl-name "hw.logicalcpu_max") + (sysctl-name "hw.machine") + (sysctl-name "hw.ncpu") + (sysctl-name "hw.nperflevels") + (sysctl-name "hw.optional.arm.FEAT_BF16") + (sysctl-name "hw.optional.arm.FEAT_DotProd") + (sysctl-name "hw.optional.arm.FEAT_FCMA") + (sysctl-name "hw.optional.arm.FEAT_FHM") + (sysctl-name "hw.optional.arm.FEAT_FP16") + (sysctl-name "hw.optional.arm.FEAT_I8MM") + (sysctl-name "hw.optional.arm.FEAT_JSCVT") + (sysctl-name "hw.optional.arm.FEAT_LSE") + (sysctl-name "hw.optional.arm.FEAT_RDM") + (sysctl-name "hw.optional.arm.FEAT_SHA512") + (sysctl-name "hw.optional.armv8_2_sha512") + (sysctl-name "hw.packages") + (sysctl-name "hw.pagesize_compat") + (sysctl-name "hw.physicalcpu_max") + (sysctl-name "hw.tbfrequency_compat") + (sysctl-name "hw.vectorunit") + (sysctl-name "kern.hostname") + (sysctl-name "kern.maxfilesperproc") + (sysctl-name "kern.osproductversion") + (sysctl-name "kern.osrelease") + (sysctl-name "kern.ostype") + (sysctl-name "kern.osvariant_status") + (sysctl-name "kern.osversion") + (sysctl-name "kern.secure_kernel") + (sysctl-name "kern.usrstack64") + (sysctl-name "kern.version") + (sysctl-name "sysctl.proc_cputype") + (sysctl-name-prefix "hw.perflevel") +) + +;; allow writes to specific paths +(allow file-write* + (subpath (param "TARGET_DIR")) + (subpath (param "TMP_DIR")) + (subpath (param "CACHE_DIR")) + (subpath (string-append (param "HOME_DIR") "/.gemini")) + (subpath (string-append (param "HOME_DIR") "/.npm")) + (subpath (string-append (param "HOME_DIR") "/.cache")) + (subpath (string-append (param "HOME_DIR") "/.gitconfig")) + (literal "/dev/stdout") + (literal "/dev/stderr") + (literal "/dev/null") +) + +;; allow communication with sysmond for process listing (e.g. for pgrep) +(allow mach-lookup (global-name "com.apple.sysmond")) + +;; enable terminal access required by ink +;; fixes setRawMode EPERM failure (at node:tty:81:24) +(allow file-ioctl (regex #"^/dev/tty.*")) + +;; allow inbound network traffic on debugger port +(allow network-inbound (local ip "localhost:9229")) + +;; allow outbound network traffic through proxy on localhost:8877 +;; set `GEMINI_SANDBOX_PROXY_COMMAND=` to run proxy alongside sandbox +;; proxy must listen on 0.0.0.0:8877 (see scripts/example-proxy.js) +(allow network-outbound (remote tcp "localhost:8877")) diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index c75bd544..b91fd5bf 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { execSync, spawnSync, spawn } from 'node:child_process'; +import { execSync, spawn, type ChildProcess } from 'node:child_process'; import os from 'node:os'; import path from 'node:path'; import fs from 'node:fs'; @@ -30,6 +30,16 @@ function getContainerPath(hostPath: string): string { } const LOCAL_DEV_SANDBOX_IMAGE_NAME = 'gemini-cli-sandbox'; +const SANDBOX_NETWORK_NAME = 'gemini-cli-sandbox'; +const SANDBOX_PROXY_NAME = 'gemini-cli-sandbox-proxy'; +const BUILTIN_SEATBELT_PROFILES = [ + 'permissive-open', + 'permissive-closed', + 'permissive-proxied', + 'restrictive-open', + 'restrictive-closed', + 'restrictive-proxied', +]; /** * Determines whether the sandbox container should be run with the current user's UID and GID. @@ -230,14 +240,14 @@ export async function start_sandbox(sandbox: string) { if (sandbox === 'sandbox-exec') { // disallow BUILD_SANDBOX if (process.env.BUILD_SANDBOX) { - console.error('ERROR: cannot BUILD_SANDBOX when using MacOC Seatbelt'); + console.error('ERROR: cannot BUILD_SANDBOX when using MacOS Seatbelt'); process.exit(1); } - const profile = (process.env.SEATBELT_PROFILE ??= 'minimal'); + const profile = (process.env.SEATBELT_PROFILE ??= 'permissive-open'); let profileFile = new URL(`sandbox-macos-${profile}.sb`, import.meta.url) .pathname; - // if profile is anything other than 'minimal' or 'strict', then look for the profile file under the project settings directory - if (profile !== 'minimal' && profile !== 'strict') { + // if profile name is not recognized, then look for file under project settings directory + if (!BUILTIN_SEATBELT_PROFILES.includes(profile)) { profileFile = path.join( SETTINGS_DIRECTORY_NAME, `sandbox-macos-${profile}.sb`, @@ -251,10 +261,6 @@ export async function start_sandbox(sandbox: string) { } console.error(`using macos seatbelt (profile: ${profile}) ...`); // if DEBUG is set, convert to --inspect-brk in NODE_OPTIONS - if (process.env.DEBUG) { - process.env.NODE_OPTIONS ??= ''; - process.env.NODE_OPTIONS += ` --inspect-brk`; - } const args = [ '-D', `TARGET_DIR=${fs.realpathSync(process.cwd())}`, @@ -270,11 +276,67 @@ export async function start_sandbox(sandbox: string) { '-c', [ `SANDBOX=sandbox-exec`, - `NODE_OPTIONS="${process.env.NODE_OPTIONS}"`, + `NODE_OPTIONS="${process.env.DEBUG ? `--inspect-brk` : ''}"`, ...process.argv.map((arg) => quote([arg])), ].join(' '), ]; - spawnSync(sandbox, args, { stdio: 'inherit' }); + // start and set up proxy if GEMINI_SANDBOX_PROXY_COMMAND is set + const proxyCommand = process.env.GEMINI_SANDBOX_PROXY_COMMAND; + let proxyProcess: ChildProcess | undefined; + const sandboxEnv = { ...process.env }; + if (proxyCommand) { + const proxy = + process.env.HTTPS_PROXY || + process.env.https_proxy || + process.env.HTTP_PROXY || + process.env.http_proxy || + 'http://localhost:8877'; + sandboxEnv['HTTPS_PROXY'] = proxy; + sandboxEnv['https_proxy'] = proxy; // lower-case can be required, e.g. for curl + sandboxEnv['HTTP_PROXY'] = proxy; + sandboxEnv['http_proxy'] = proxy; + const noProxy = process.env.NO_PROXY || process.env.no_proxy; + if (noProxy) { + sandboxEnv['NO_PROXY'] = noProxy; + sandboxEnv['no_proxy'] = noProxy; + } + proxyProcess = spawn(proxyCommand, { + stdio: ['ignore', 'pipe', 'pipe'], + shell: true, + detached: true, + }); + // commented out as it disrupts ink rendering + // proxyProcess.stdout?.on('data', (data) => { + // console.info(data.toString()); + // }); + proxyProcess.stderr?.on('data', (data) => { + console.error(data.toString()); + }); + console.log('waiting for proxy to start ...'); + execSync(`until lsof -i :8877 | grep -q "LISTEN"; do sleep 0.1; done`); + } + try { + // spawn child and let it inherit stdio + const child = spawn(sandbox, args, { + stdio: 'inherit', + env: sandboxEnv, + }); + if (proxyProcess) { + proxyProcess.on('close', (code, signal) => { + console.error( + `ERROR: proxy command '${proxyCommand}' exited with code ${code}, signal ${signal}`, + ); + if (child.pid) { + process.kill(-child.pid, 'SIGTERM'); + } + }); + } + await new Promise((resolve) => child.on('close', resolve)); + } finally { + if (proxyProcess?.pid) { + process.kill(-proxyProcess.pid, 'SIGTERM'); + } + } return; } @@ -408,6 +470,45 @@ export async function start_sandbox(sandbox: string) { args.push(`--publish`, `${debugPort}:${debugPort}`); } + // copy proxy environment variables, replacing localhost with SANDBOX_PROXY_NAME + // copy as both upper-case and lower-case as is required by some utilities + // GEMINI_SANDBOX_PROXY_COMMAND implies HTTPS_PROXY unless HTTP_PROXY is set + const proxyCommand = process.env.GEMINI_SANDBOX_PROXY_COMMAND; + let proxy = + process.env.HTTPS_PROXY || + process.env.https_proxy || + process.env.HTTP_PROXY || + process.env.http_proxy || + 'http://localhost:8877'; + proxy = proxy.replace('localhost', SANDBOX_PROXY_NAME); + if (proxy) { + args.push('--env', `HTTPS_PROXY=${proxy}`); + args.push('--env', `https_proxy=${proxy}`); // lower-case can be required, e.g. for curl + args.push('--env', `HTTP_PROXY=${proxy}`); + args.push('--env', `http_proxy=${proxy}`); + } + const noProxy = process.env.NO_PROXY || process.env.no_proxy; + if (noProxy) { + args.push('--env', `NO_PROXY=${noProxy}`); + args.push('--env', `no_proxy=${noProxy}`); + } + + // if using proxy, switch to internal networking through proxy + if (proxy) { + execSync( + `${sandbox} network exists ${SANDBOX_NETWORK_NAME} || ${sandbox} network create --internal ${SANDBOX_NETWORK_NAME}`, + ); + args.push('--network', SANDBOX_NETWORK_NAME); + // if proxy command is set, create a separate network w/ host access (i.e. non-internal) + // we will run proxy in its own container connected to both host network and internal network + // this allows proxy to work even on rootless podman on macos with host<->vm<->container isolation + if (proxyCommand) { + execSync( + `${sandbox} network exists ${SANDBOX_PROXY_NAME} || ${sandbox} network create ${SANDBOX_PROXY_NAME}`, + ); + } + } + // name container after image, plus numeric suffix to avoid conflicts const imageName = parseImageName(image); let index = 0; @@ -510,28 +611,52 @@ export async function start_sandbox(sandbox: string) { // push container entrypoint (including args) args.push(...entrypoint(workdir)); - // spawn child and let it inherit stdio - const child = spawn(sandbox, args, { - stdio: 'inherit', - detached: os.platform() !== 'win32', - }); - - child.on('error', (err) => { - console.error('Sandbox process error:', err); - }); - - // uncomment this line (and comment the await on following line) to let parent exit - // child.unref(); - await new Promise((resolve) => { - child.on('close', (code, signal) => { - if (code !== 0) { - console.log( - `Sandbox process exited with code: ${code}, signal: ${signal}`, - ); - } - resolve(); + // start and set up proxy if GEMINI_SANDBOX_PROXY_COMMAND is set + let proxyProcess: ChildProcess | undefined; + if (proxyCommand) { + // run proxyCommand in its own container + const proxyContainerCommand = `${sandbox} run --rm --init --name ${SANDBOX_PROXY_NAME} --network ${SANDBOX_PROXY_NAME} --network ${SANDBOX_NETWORK_NAME} -p 8877:8877 -v ${process.cwd()}:${workdir} --workdir ${workdir} ${image} ${proxyCommand}`; + proxyProcess = spawn(proxyContainerCommand, { + stdio: ['ignore', 'pipe', 'pipe'], + shell: true, + detached: true, }); - }); + // commented out as it disrupts ink rendering + // proxyProcess.stdout?.on('data', (data) => { + // console.info(data.toString()); + // }); + proxyProcess.stderr?.on('data', (data) => { + console.error(data.toString().trim()); + }); + console.log('waiting for proxy to start ...'); + execSync(`until lsof -i :8877 | grep -q "LISTEN"; do sleep 0.1; done`); + } + + try { + // spawn child and let it inherit stdio + const child = spawn(sandbox, args, { + stdio: 'inherit', + }); + + child.on('error', (err) => { + console.error('Sandbox process error:', err); + }); + + await new Promise((resolve) => { + child.on('close', (code, signal) => { + if (code !== 0) { + console.log( + `Sandbox process exited with code: ${code}, signal: ${signal}`, + ); + } + resolve(); + }); + }); + } finally { + if (proxyProcess?.pid) { + process.kill(-proxyProcess.pid, 'SIGTERM'); + } + } } // Helper functions to ensure sandbox image is present diff --git a/packages/core/package.json b/packages/core/package.json index 344f3a5a..033fbddc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -34,7 +34,8 @@ "fast-glob": "^3.3.3", "ignore": "^7.0.0", "shell-quote": "^1.8.2", - "strip-ansi": "^7.1.0" + "strip-ansi": "^7.1.0", + "undici": "^7.10.0" }, "devDependencies": { "@types/diff": "^7.0.2", diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 1b953d30..3046116e 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -38,6 +38,18 @@ import { createContentGenerator, } from './contentGenerator.js'; +import { ProxyAgent, setGlobalDispatcher } from 'undici'; + +const proxy = + process.env.HTTPS_PROXY || + process.env.https_proxy || + process.env.HTTP_PROXY || + process.env.http_proxy; + +if (proxy) { + setGlobalDispatcher(new ProxyAgent(proxy)); +} + export class GeminiClient { private chat: Promise; private contentGenerator: ContentGenerator; diff --git a/scripts/build_sandbox.js b/scripts/build_sandbox.js index bfcf1bf9..89e186fa 100644 --- a/scripts/build_sandbox.js +++ b/scripts/build_sandbox.js @@ -111,7 +111,7 @@ function buildImage(imageName, dockerfile) { execSync( `${buildCommand} ${process.env.BUILD_SANDBOX_FLAGS || ''} -f "${dockerfile}" -t "${imageName}" .`, - { stdio: buildStdout }, + { stdio: buildStdout, shell: '/bin/bash' }, ); console.log(`built ${imageName}`); } diff --git a/scripts/example-proxy.js b/scripts/example-proxy.js new file mode 100755 index 00000000..284a2eed --- /dev/null +++ b/scripts/example-proxy.js @@ -0,0 +1,74 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Example proxy server that listens on 0.0.0.0:8877 and only allows HTTPS connections to example.com. +// Set `GEMINI_SANDBOX_PROXY_COMMAND=scripts/example-proxy.js` to run proxy alongside sandbox +// Test via `curl https://example.com` inside sandbox (in shell mode or via shell tool) + +import http from 'http'; +import net from 'net'; +import { URL } from 'url'; +import console from 'console'; + +const PROXY_PORT = 8877; +const ALLOWED_DOMAINS = ['example.com', 'googleapis.com']; +const ALLOWED_PORT = '443'; + +const server = http.createServer((req, res) => { + // Deny all requests other than CONNECT for HTTPS + console.log( + `[PROXY] Denying non-CONNECT request for: ${req.method} ${req.url}`, + ); + res.writeHead(405, { 'Content-Type': 'text/plain' }); + res.end('Method Not Allowed'); +}); + +server.on('connect', (req, clientSocket, head) => { + // req.url will be in the format "hostname:port" for a CONNECT request. + const { port, hostname } = new URL(`http://${req.url}`); + + console.log(`[PROXY] Intercepted CONNECT request for: ${hostname}:${port}`); + + if ( + ALLOWED_DOMAINS.some( + (domain) => hostname == domain || hostname.endsWith(`.${domain}`), + ) && + port === ALLOWED_PORT + ) { + console.log(`[PROXY] Allowing connection to ${hostname}:${port}`); + + // Establish a TCP connection to the original destination. + const serverSocket = net.connect(port, hostname, () => { + clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + // Create a tunnel by piping data between the client and the destination server. + serverSocket.write(head); + serverSocket.pipe(clientSocket); + clientSocket.pipe(serverSocket); + }); + + serverSocket.on('error', (err) => { + console.error(`[PROXY] Error connecting to destination: ${err.message}`); + clientSocket.end(`HTTP/1.1 502 Bad Gateway\r\n\r\n`); + }); + } else { + console.log(`[PROXY] Denying connection to ${hostname}:${port}`); + clientSocket.end('HTTP/1.1 403 Forbidden\r\n\r\n'); + } + + clientSocket.on('error', (err) => { + // This can happen if the client hangs up. + console.error(`[PROXY] Client socket error: ${err.message}`); + }); +}); + +server.listen(PROXY_PORT, '0.0.0.0', () => { + console.log(`[PROXY] Proxy listening on 0.0.0.0:${PROXY_PORT}`); + console.log( + `[PROXY] Allowing HTTPS connections to domains: ${ALLOWED_DOMAINS.join(', ')}`, + ); +}); diff --git a/scripts/start.js b/scripts/start.js index f9f85c8e..d38e00f6 100644 --- a/scripts/start.js +++ b/scripts/start.js @@ -33,11 +33,12 @@ execSync('node ./scripts/check-build-status.js', { // inside sandbox SANDBOX should be set and sandbox_command.js should fail const nodeArgs = []; try { - execSync('node scripts/sandbox_command.js -q', { - stdio: 'inherit', + const sandboxCommand = execSync('node scripts/sandbox_command.js', { cwd: root, - }); - if (process.env.DEBUG) { + }) + .toString() + .trim(); + if (process.env.DEBUG && !sandboxCommand) { if (process.env.SANDBOX) { const port = process.env.DEBUG_PORT || '9229'; nodeArgs.push(`--inspect-brk=0.0.0.0:${port}`);