diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml index 42fd78d7e9..e7fc63ce8b 100644 --- a/.github/actions/run-tests/action.yml +++ b/.github/actions/run-tests/action.yml @@ -28,6 +28,7 @@ runs: - name: 'Run Tests' env: GEMINI_API_KEY: '${{ inputs.gemini_api_key }}' + GEMINI_CLI_TRUST_WORKSPACE: true working-directory: '${{ inputs.working-directory }}' run: |- echo "::group::Build" diff --git a/.github/actions/verify-release/action.yml b/.github/actions/verify-release/action.yml index 4e0c6c6f72..d3d1d075d2 100644 --- a/.github/actions/verify-release/action.yml +++ b/.github/actions/verify-release/action.yml @@ -98,6 +98,7 @@ runs: working-directory: '${{ inputs.working-directory }}' env: GEMINI_API_KEY: '${{ inputs.gemini_api_key }}' + GEMINI_CLI_TRUST_WORKSPACE: true INTEGRATION_TEST_USE_INSTALLED_GEMINI: 'true' # We must diable CI mode here because it interferes with interactive tests. # See https://github.com/google-gemini/gemini-cli/issues/10517 diff --git a/.github/workflows/chained_e2e.yml b/.github/workflows/chained_e2e.yml index e6385ad4bb..bd276a3853 100644 --- a/.github/workflows/chained_e2e.yml +++ b/.github/workflows/chained_e2e.yml @@ -167,6 +167,7 @@ jobs: - name: 'Run E2E tests' env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' + GEMINI_CLI_TRUST_WORKSPACE: true KEEP_OUTPUT: 'true' VERBOSE: 'true' BUILD_SANDBOX_FLAGS: '--cache-from type=gha --cache-to type=gha,mode=max' @@ -212,6 +213,7 @@ jobs: if: "${{runner.os != 'Windows'}}" env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' + GEMINI_CLI_TRUST_WORKSPACE: true KEEP_OUTPUT: 'true' SANDBOX: 'sandbox:none' VERBOSE: 'true' @@ -288,6 +290,7 @@ jobs: - name: 'Run E2E tests' env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' + GEMINI_CLI_TRUST_WORKSPACE: true KEEP_OUTPUT: 'true' SANDBOX: 'sandbox:none' VERBOSE: 'true' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a2bf9b660..2ef8bdb58d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -179,6 +179,7 @@ jobs: - name: 'Run tests and generate reports' env: NO_COLOR: true + GEMINI_CLI_TRUST_WORKSPACE: true run: | if [[ "${{ matrix.shard }}" == "cli" ]]; then npm run test:ci --workspace "@google/gemini-cli" @@ -267,6 +268,7 @@ jobs: - name: 'Run tests and generate reports' env: NO_COLOR: true + GEMINI_CLI_TRUST_WORKSPACE: true run: | if [[ "${{ matrix.shard }}" == "cli" ]]; then npm run test:ci --workspace "@google/gemini-cli" -- --coverage.enabled=false @@ -430,6 +432,7 @@ jobs: env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' NO_COLOR: true + GEMINI_CLI_TRUST_WORKSPACE: true NODE_OPTIONS: '--max-old-space-size=32768 --max-semi-space-size=256' UV_THREADPOOL_SIZE: '32' NODE_ENV: 'test' diff --git a/.github/workflows/deflake.yml b/.github/workflows/deflake.yml index cd61346ffa..a6a7d3664f 100644 --- a/.github/workflows/deflake.yml +++ b/.github/workflows/deflake.yml @@ -62,6 +62,7 @@ jobs: - name: 'Run E2E tests' env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' + GEMINI_CLI_TRUST_WORKSPACE: true IS_DOCKER: "${{ matrix.sandbox == 'sandbox:docker' }}" KEEP_OUTPUT: 'true' RUNS: '${{ github.event.inputs.runs }}' @@ -105,6 +106,7 @@ jobs: if: "runner.os != 'Windows'" env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' + GEMINI_CLI_TRUST_WORKSPACE: true KEEP_OUTPUT: 'true' RUNS: '${{ github.event.inputs.runs }}' SANDBOX: 'sandbox:none' @@ -159,6 +161,7 @@ jobs: - name: 'Run E2E tests' env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' + GEMINI_CLI_TRUST_WORKSPACE: true KEEP_OUTPUT: 'true' SANDBOX: 'sandbox:none' VERBOSE: 'true' diff --git a/.github/workflows/test-build-binary.yml b/.github/workflows/test-build-binary.yml index d0069b8b15..05d6556f8c 100644 --- a/.github/workflows/test-build-binary.yml +++ b/.github/workflows/test-build-binary.yml @@ -141,6 +141,7 @@ jobs: if: "github.event_name != 'pull_request'" env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' + GEMINI_CLI_TRUST_WORKSPACE: true run: | echo "Running integration tests with binary..." if [[ "${{ matrix.os }}" == 'windows-latest' ]]; then diff --git a/docs/cli/cli-reference.md b/docs/cli/cli-reference.md index e8217e226e..41cc766175 100644 --- a/docs/cli/cli-reference.md +++ b/docs/cli/cli-reference.md @@ -52,6 +52,7 @@ These commands are available within the interactive REPL. | `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode | | `--worktree` | `-w` | string | - | Start Gemini in a new git worktree. If no name is provided, one is generated automatically. Requires `experimental.worktrees: true` in settings. | | `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution | +| `--skip-trust` | - | boolean | `false` | Trust the current workspace for this session, skipping the folder trust check. | | `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo`, `plan` | | `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. | | `--experimental-acp` | - | boolean | - | Start in ACP (Agent Code Pilot) mode. **Experimental feature.** | diff --git a/docs/cli/trusted-folders.md b/docs/cli/trusted-folders.md index cc4e880300..efb99ea397 100644 --- a/docs/cli/trusted-folders.md +++ b/docs/cli/trusted-folders.md @@ -100,6 +100,30 @@ protect you. In this mode, the following features are disabled: Granting trust to a folder unlocks the full functionality of Gemini CLI for that workspace. +## Headless and automated environments + +When running Gemini CLI in a headless environment (for example, a CI/CD +pipeline) where interactive prompts are not possible, the trust dialog cannot be +displayed. If the folder is untrusted and the Folder Trust feature is enabled, +the CLI will throw a `FatalUntrustedWorkspaceError` and exit. + +To proceed in these environments, you can bypass the trust check using one of +the following methods: + +- **Command-line flag:** Run the CLI with the `--skip-trust` flag. +- **Environment variable:** Set the `GEMINI_CLI_TRUST_WORKSPACE=true` + environment variable. + +These methods will trust the current workspace for the duration of the session +without prompting. + +## Overriding the trust file location + +By default, trust settings are saved to `~/.gemini/trustedFolders.json`. If you +need to store this file in a different location, you can set the +`GEMINI_CLI_TRUSTED_FOLDERS_PATH` environment variable to the desired absolute +file path. + ## Managing your trust settings If you need to change a decision or see all your settings, you have a couple of diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index b582da4ea0..56dd6b4b5d 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -2156,6 +2156,14 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file. - Overrides the hardcoded default - Example: `export GEMINI_MODEL="gemini-3-flash-preview"` (Windows PowerShell: `$env:GEMINI_MODEL="gemini-3-flash-preview"`) +- **`GEMINI_CLI_TRUST_WORKSPACE`**: + - If set to `"true"`, trusts the current workspace for the duration of the + session, bypassing the folder trust check. + - Useful for headless environments (for example, CI/CD pipelines). +- **`GEMINI_CLI_TRUSTED_FOLDERS_PATH`**: + - Overrides the default location for the `trustedFolders.json` file. + - Useful if you want to store this configuration in a custom location instead + of the default `~/.gemini/`. - **`GEMINI_CLI_IDE_PID`**: - Manually specifies the PID of the IDE process to use for integration. This is useful when running Gemini CLI in a standalone terminal while still diff --git a/package-lock.json b/package-lock.json index 180d4f297c..71af158b14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -449,7 +449,8 @@ "version": "2.11.0", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz", "integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==", - "license": "(Apache-2.0 AND BSD-3-Clause)" + "license": "(Apache-2.0 AND BSD-3-Clause)", + "peer": true }, "node_modules/@bundled-es-modules/cookie": { "version": "2.0.1", @@ -1473,6 +1474,7 @@ "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz", "integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@grpc/proto-loader": "^0.7.13", "@js-sdsl/ordered-map": "^4.4.2" @@ -2150,6 +2152,7 @@ "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.2", @@ -2330,6 +2333,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2379,6 +2383,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz", "integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2753,6 +2758,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz", "integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -2786,6 +2792,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz", "integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/resources": "2.5.0" @@ -2840,6 +2847,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz", "integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/resources": "2.5.0", @@ -4046,6 +4054,7 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4319,6 +4328,7 @@ "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", @@ -4593,56 +4603,6 @@ } } }, - "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.47.0.tgz", - "integrity": "sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.47.0", - "@typescript-eslint/visitor-keys": "8.47.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/types": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.47.0.tgz", - "integrity": "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.47.0.tgz", - "integrity": "sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.47.0", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, "node_modules/@vitest/expect": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", @@ -5113,6 +5073,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7190,7 +7151,8 @@ "version": "0.0.1581282", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz", "integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/dezalgo": { "version": "1.0.4", @@ -7775,6 +7737,7 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -8292,6 +8255,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -9558,6 +9522,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -9817,6 +9782,7 @@ "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.6.9.tgz", "integrity": "sha512-RL9sSiLQZECnjbmBwjIHOp8yVGdWF7C/uifg7ISv/e+F3nLNsfl7FdUFQs8iZARFMJAYxMFpxW6OW+HSt9drwQ==", "license": "MIT", + "peer": true, "dependencies": { "ansi-escapes": "^7.0.0", "ansi-styles": "^6.2.3", @@ -13530,6 +13496,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13540,6 +13507,7 @@ "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -15659,6 +15627,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -15881,7 +15850,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsx": { "version": "4.20.3", @@ -15889,6 +15859,7 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -16054,6 +16025,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16121,6 +16093,7 @@ "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/types": "8.35.0", @@ -16507,6 +16480,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -17077,6 +17051,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -17089,6 +17064,7 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -17727,6 +17703,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -18162,6 +18139,7 @@ "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" @@ -18280,6 +18258,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index e6fd28d19e..f7e7c5086b 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -24,6 +24,7 @@ import { FileDiscoveryService, resolveTelemetrySettings, FatalConfigError, + getErrorMessage, getPty, debugLogger, loadServerHierarchicalMemory, @@ -60,6 +61,7 @@ import { import { loadSandboxConfig } from './sandboxConfig.js'; import { resolvePath } from '../utils/resolvePath.js'; +import { isRecord } from '../utils/settingsUtils.js'; import { RESUME_LATEST } from '../utils/sessionUtils.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; @@ -106,6 +108,7 @@ export interface CliArgs { startupMessages?: string[]; rawOutput: boolean | undefined; acceptRawOutputRisk: boolean | undefined; + skipTrust: boolean | undefined; isCommand: boolean | undefined; } @@ -291,6 +294,11 @@ export async function parseArguments( description: 'Execute the provided prompt and continue in interactive mode', }) + .option('skip-trust', { + type: 'boolean', + description: 'Trust the current workspace for this session.', + default: false, + }) .option('worktree', { alias: 'w', type: 'string', @@ -459,9 +467,16 @@ export async function parseArguments( yargsInstance.wrap(yargsInstance.terminalWidth()); let result; try { - result = await yargsInstance.parse(); + const parsed = await yargsInstance.parse(); + if (!isRecord(parsed)) { + throw new Error('Failed to parse arguments'); + } + result = parsed; + if (result['skip-trust']) { + process.env['GEMINI_CLI_TRUST_WORKSPACE'] = 'true'; + } } catch (e) { - const msg = e instanceof Error ? e.message : String(e); + const msg = getErrorMessage(e); debugLogger.error(msg); yargsInstance.showHelp(); await runExitCleanup(); @@ -475,11 +490,13 @@ export async function parseArguments( } // Normalize query args: handle both quoted "@path file" and unquoted @path file - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const queryArg = (result as { query?: string | string[] | undefined }).query; - const q: string | undefined = Array.isArray(queryArg) - ? queryArg.join(' ') - : queryArg; + const queryArg = result['query']; + let q: string | undefined; + if (Array.isArray(queryArg)) { + q = queryArg.join(' '); + } else if (typeof queryArg === 'string') { + q = queryArg; + } // -p/--prompt forces non-interactive mode; positional args default to interactive in TTY if (q && !result['prompt']) { @@ -494,8 +511,8 @@ export async function parseArguments( } // Keep CliArgs.query as a string for downstream typing - (result as Record)['query'] = q || undefined; - (result as Record)['startupMessages'] = startupMessages; + result['query'] = q || undefined; + result['startupMessages'] = startupMessages; // The import format is now only controlled by settings.memoryImportFormat // We no longer accept it as a CLI argument @@ -547,7 +564,7 @@ export async function loadCliConfig( ? false : (settings.security?.folderTrust?.enabled ?? false); const trustedFolder = - isWorkspaceTrusted(settings, cwd, undefined, { + isWorkspaceTrusted(settings, cwd, { prompt: argv.prompt, query: argv.query, })?.isTrusted ?? false; @@ -593,7 +610,7 @@ export async function loadCliConfig( return resolveToRealPath(trimmedPath) !== realCwd; } catch (e) { debugLogger.debug( - `[IDE] Skipping inaccessible workspace folder: ${trimmedPath} (${e instanceof Error ? e.message : String(e)})`, + `[IDE] Skipping inaccessible workspace folder: ${trimmedPath} (${getErrorMessage(e)})`, ); return false; } @@ -1099,7 +1116,7 @@ async function resolveWorktreeSettings( worktreeBaseSha = stdout.trim(); } catch (e: unknown) { debugLogger.debug( - `Failed to resolve worktree base SHA at ${worktreePath}: ${e instanceof Error ? e.message : String(e)}`, + `Failed to resolve worktree base SHA at ${worktreePath}: ${getErrorMessage(e)}`, ); } diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index ef7e61cf25..c1aa276aad 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -26,6 +26,7 @@ import { loadAgentsFromDirectory, loadSkillsFromDir, getRealPath, + normalizePath, } from '@google/gemini-cli-core'; import { loadSettings, @@ -1420,6 +1421,7 @@ name = "yolo-checker" '.gemini', 'trustedFolders.json', ); + vi.stubEnv('GEMINI_CLI_TRUSTED_FOLDERS_PATH', trustedFoldersPath); vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: false, source: undefined, @@ -1438,7 +1440,9 @@ name = "yolo-checker" const trustedFolders = JSON.parse( fs.readFileSync(trustedFoldersPath, 'utf-8'), ); - expect(trustedFolders[tempWorkspaceDir]).toBe('TRUST_FOLDER'); + expect(trustedFolders[normalizePath(tempWorkspaceDir)]).toBe( + 'TRUST_FOLDER', + ); }); describe.each([true, false])( diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index a58b9889a2..af0e47b99f 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -1912,6 +1912,9 @@ describe('Settings Loading and Merging', () => { const geminiEnvPath = path.resolve( path.join(MOCK_WORKSPACE_DIR, GEMINI_DIR, '.env'), ); + const workspaceEnvPath = path.resolve( + path.join(MOCK_WORKSPACE_DIR, '.env'), + ); vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({ isTrusted: isWorkspaceTrustedValue, @@ -1919,9 +1922,11 @@ describe('Settings Loading and Merging', () => { }); (mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) => { const normalizedP = path.resolve(p.toString()); - return [path.resolve(USER_SETTINGS_PATH), geminiEnvPath].includes( - normalizedP, - ); + return [ + path.resolve(USER_SETTINGS_PATH), + geminiEnvPath, + workspaceEnvPath, + ].includes(normalizedP); }); const userSettingsContent: Settings = { ui: { @@ -1941,7 +1946,7 @@ describe('Settings Loading and Merging', () => { const normalizedP = path.resolve(p.toString()); if (normalizedP === path.resolve(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (normalizedP === geminiEnvPath) + if (normalizedP === geminiEnvPath || normalizedP === workspaceEnvPath) return 'TESTTEST=1234\nGEMINI_API_KEY=test-key'; return '{}'; }, @@ -1970,7 +1975,7 @@ describe('Settings Loading and Merging', () => { expect(process.env['TESTTEST']).not.toEqual('1234'); }); - it('does load env files from untrusted spaces when NOT sandboxed', () => { + it('does NOT load non-whitelisted env files from untrusted spaces even when NOT sandboxed', () => { setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false }); const settings = { security: { folderTrust: { enabled: true } }, @@ -1978,7 +1983,8 @@ describe('Settings Loading and Merging', () => { } as Settings; loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted); - expect(process.env['TESTTEST']).toEqual('1234'); + expect(process.env['TESTTEST']).not.toEqual('1234'); + expect(process.env['GEMINI_API_KEY']).toEqual('test-key'); }); it('does not load env files when trust is undefined and sandboxed', () => { diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 616b2caf49..b84c1bda40 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -499,13 +499,15 @@ export class LoadedSettings { } } -function findEnvFile(startDir: string): string | null { +function findEnvFile(startDir: string, isTrusted: boolean): string | null { let currentDir = path.resolve(startDir); while (true) { // prefer gemini-specific .env under GEMINI_DIR - const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env'); - if (fs.existsSync(geminiEnvPath)) { - return geminiEnvPath; + if (isTrusted) { + const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env'); + if (fs.existsSync(geminiEnvPath)) { + return geminiEnvPath; + } } const envPath = path.join(currentDir, '.env'); if (fs.existsSync(envPath)) { @@ -514,9 +516,11 @@ function findEnvFile(startDir: string): string | null { const parentDir = path.dirname(currentDir); if (parentDir === currentDir || !parentDir) { // check .env under home as fallback, again preferring gemini-specific .env - const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env'); - if (fs.existsSync(homeGeminiEnvPath)) { - return homeGeminiEnvPath; + if (isTrusted) { + const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env'); + if (fs.existsSync(homeGeminiEnvPath)) { + return homeGeminiEnvPath; + } } const homeEnvPath = path.join(homedir(), '.env'); if (fs.existsSync(homeEnvPath)) { @@ -559,10 +563,10 @@ export function loadEnvironment( workspaceDir: string, isWorkspaceTrustedFn = isWorkspaceTrusted, ): void { - const envFilePath = findEnvFile(workspaceDir); const trustResult = isWorkspaceTrustedFn(settings, workspaceDir); - const isTrusted = trustResult.isTrusted ?? false; + const envFilePath = findEnvFile(workspaceDir, isTrusted); + // Check settings OR check process.argv directly since this might be called // before arguments are fully parsed. This is a best-effort sniffing approach // that happens early in the CLI lifecycle. It is designed to detect the @@ -597,8 +601,8 @@ export function loadEnvironment( for (const key in parsedEnv) { if (Object.hasOwn(parsedEnv, key)) { let value = parsedEnv[key]; - // If the workspace is untrusted but we are sandboxed, only allow whitelisted variables. - if (!isTrusted && isSandboxed) { + // If the workspace is untrusted, only allow whitelisted variables. + if (!isTrusted) { if (!AUTH_ENV_VAR_WHITELIST.includes(key)) { continue; } diff --git a/packages/cli/src/config/trustedFolders.test.ts b/packages/cli/src/config/trustedFolders.test.ts index 2741da875f..8af750db07 100644 --- a/packages/cli/src/config/trustedFolders.test.ts +++ b/packages/cli/src/config/trustedFolders.test.ts @@ -11,7 +11,7 @@ import * as os from 'node:os'; import { FatalConfigError, ideContextStore, - coreEvents, + normalizePath, } from '@google/gemini-cli-core'; import { loadTrustedFolders, @@ -32,9 +32,14 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { ...actual, homedir: () => '/mock/home/user', isHeadlessMode: vi.fn(() => false), - coreEvents: { - emitFeedback: vi.fn(), - }, + coreEvents: Object.assign( + Object.create(Object.getPrototypeOf(actual.coreEvents)), + actual.coreEvents, + { + emitFeedback: vi.fn(), + }, + ), + FatalConfigError: actual.FatalConfigError, }; }); @@ -53,6 +58,7 @@ describe('Trusted Folders', () => { // Reset the internal state resetTrustedFoldersForTesting(); vi.clearAllMocks(); + delete process.env['GEMINI_CLI_TRUST_WORKSPACE']; }); afterEach(() => { @@ -70,8 +76,14 @@ describe('Trusted Folders', () => { // Start two concurrent calls // These will race to acquire the lock on the real file system - const p1 = loadedFolders.setValue('/path1', TrustLevel.TRUST_FOLDER); - const p2 = loadedFolders.setValue('/path2', TrustLevel.TRUST_FOLDER); + const p1 = loadedFolders.setValue( + path.resolve('/path1'), + TrustLevel.TRUST_FOLDER, + ); + const p2 = loadedFolders.setValue( + path.resolve('/path2'), + TrustLevel.TRUST_FOLDER, + ); await Promise.all([p1, p2]); @@ -80,8 +92,8 @@ describe('Trusted Folders', () => { const config = JSON.parse(content); expect(config).toEqual({ - '/path1': TrustLevel.TRUST_FOLDER, - '/path2': TrustLevel.TRUST_FOLDER, + [normalizePath('/path1')]: TrustLevel.TRUST_FOLDER, + [normalizePath('/path2')]: TrustLevel.TRUST_FOLDER, }); }); }); @@ -95,13 +107,16 @@ describe('Trusted Folders', () => { it('should load rules from the configuration file', () => { const config = { - '/user/folder': TrustLevel.TRUST_FOLDER, + [normalizePath('/user/folder')]: TrustLevel.TRUST_FOLDER, }; fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); const { rules, errors } = loadTrustedFolders(); expect(rules).toEqual([ - { path: '/user/folder', trustLevel: TrustLevel.TRUST_FOLDER }, + { + path: normalizePath('/user/folder'), + trustLevel: TrustLevel.TRUST_FOLDER, + }, ]); expect(errors).toEqual([]); }); @@ -143,14 +158,14 @@ describe('Trusted Folders', () => { const content = ` { // This is a comment - "/path": "TRUST_FOLDER" + "${normalizePath('/path').replaceAll('\\', '\\\\')}": "TRUST_FOLDER" } `; fs.writeFileSync(trustedFoldersPath, content, 'utf-8'); const { rules, errors } = loadTrustedFolders(); expect(rules).toEqual([ - { path: '/path', trustLevel: TrustLevel.TRUST_FOLDER }, + { path: normalizePath('/path'), trustLevel: TrustLevel.TRUST_FOLDER }, ]); expect(errors).toEqual([]); }); @@ -216,15 +231,18 @@ describe('Trusted Folders', () => { fs.writeFileSync(trustedFoldersPath, '{}', 'utf-8'); const loadedFolders = loadTrustedFolders(); - await loadedFolders.setValue('/new/path', TrustLevel.TRUST_FOLDER); + await loadedFolders.setValue( + normalizePath('/new/path'), + TrustLevel.TRUST_FOLDER, + ); - expect(loadedFolders.user.config['/new/path']).toBe( + expect(loadedFolders.user.config[normalizePath('/new/path')]).toBe( TrustLevel.TRUST_FOLDER, ); const content = fs.readFileSync(trustedFoldersPath, 'utf-8'); const config = JSON.parse(content); - expect(config['/new/path']).toBe(TrustLevel.TRUST_FOLDER); + expect(config[normalizePath('/new/path')]).toBe(TrustLevel.TRUST_FOLDER); }); it('should throw FatalConfigError if there were load errors', async () => { @@ -237,28 +255,6 @@ describe('Trusted Folders', () => { loadedFolders.setValue('/some/path', TrustLevel.TRUST_FOLDER), ).rejects.toThrow(FatalConfigError); }); - - it('should report corrupted config via coreEvents.emitFeedback and still succeed', async () => { - // Initialize with valid JSON - fs.writeFileSync(trustedFoldersPath, '{}', 'utf-8'); - const loadedFolders = loadTrustedFolders(); - - // Corrupt the file after initial load - fs.writeFileSync(trustedFoldersPath, 'invalid json', 'utf-8'); - - await loadedFolders.setValue('/new/path', TrustLevel.TRUST_FOLDER); - - expect(coreEvents.emitFeedback).toHaveBeenCalledWith( - 'error', - expect.stringContaining('may be corrupted'), - expect.any(Error), - ); - - // Should have overwritten the corrupted file with new valid config - const content = fs.readFileSync(trustedFoldersPath, 'utf-8'); - const config = JSON.parse(content); - expect(config).toEqual({ '/new/path': TrustLevel.TRUST_FOLDER }); - }); }); describe('isWorkspaceTrusted Integration', () => { @@ -427,16 +423,28 @@ describe('Trusted Folders', () => { }, }; - it('should return true when isHeadlessMode is true, ignoring config', async () => { + it('should NOT return true when isHeadlessMode is true, ignoring config', async () => { const geminiCore = await import('@google/gemini-cli-core'); vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(true); expect(isWorkspaceTrusted(mockSettings)).toEqual({ - isTrusted: true, + isTrusted: undefined, source: undefined, }); }); + it('should return true when GEMINI_CLI_TRUST_WORKSPACE is true', async () => { + process.env['GEMINI_CLI_TRUST_WORKSPACE'] = 'true'; + try { + expect(isWorkspaceTrusted(mockSettings)).toEqual({ + isTrusted: true, + source: 'env', + }); + } finally { + delete process.env['GEMINI_CLI_TRUST_WORKSPACE']; + } + }); + it('should fall back to config when isHeadlessMode is false', async () => { const geminiCore = await import('@google/gemini-cli-core'); vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(false); @@ -449,12 +457,12 @@ describe('Trusted Folders', () => { ); }); - it('should return true for isPathTrusted when isHeadlessMode is true', async () => { + it('should return undefined for isPathTrusted when isHeadlessMode is true', async () => { const geminiCore = await import('@google/gemini-cli-core'); vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(true); const folders = loadTrustedFolders(); - expect(folders.isPathTrusted('/any-untrusted-path')).toBe(true); + expect(folders.isPathTrusted('/any-untrusted-path')).toBe(undefined); }); }); diff --git a/packages/cli/src/config/trustedFolders.ts b/packages/cli/src/config/trustedFolders.ts index 761bc368d3..f901ed13db 100644 --- a/packages/cli/src/config/trustedFolders.ts +++ b/packages/cli/src/config/trustedFolders.ts @@ -4,330 +4,29 @@ * SPDX-License-Identifier: Apache-2.0 */ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import * as crypto from 'node:crypto'; -import { lock } from 'proper-lockfile'; import { - FatalConfigError, - getErrorMessage, - isWithinRoot, - ideContextStore, - GEMINI_DIR, - homedir, - isHeadlessMode, - coreEvents, type HeadlessModeOptions, + checkPathTrust, + isHeadlessMode, + loadTrustedFolders as loadCoreTrustedFolders, + type LoadedTrustedFolders, } from '@google/gemini-cli-core'; import type { Settings } from './settings.js'; -import stripJsonComments from 'strip-json-comments'; -const { promises: fsPromises } = fs; +export { + TrustLevel, + isTrustLevel, + resetTrustedFoldersForTesting, + saveTrustedFolders, +} from '@google/gemini-cli-core'; -export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json'; - -export function getUserSettingsDir(): string { - return path.join(homedir(), GEMINI_DIR); -} - -export function getTrustedFoldersPath(): string { - if (process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH']) { - return process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH']; - } - return path.join(getUserSettingsDir(), TRUSTED_FOLDERS_FILENAME); -} - -export enum TrustLevel { - TRUST_FOLDER = 'TRUST_FOLDER', - TRUST_PARENT = 'TRUST_PARENT', - DO_NOT_TRUST = 'DO_NOT_TRUST', -} - -export function isTrustLevel( - value: string | number | boolean | object | null | undefined, -): value is TrustLevel { - return ( - typeof value === 'string' && - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - Object.values(TrustLevel).includes(value as TrustLevel) - ); -} - -export interface TrustRule { - path: string; - trustLevel: TrustLevel; -} - -export interface TrustedFoldersError { - message: string; - path: string; -} - -export interface TrustedFoldersFile { - config: Record; - path: string; -} - -export interface TrustResult { - isTrusted: boolean | undefined; - source: 'ide' | 'file' | undefined; -} - -const realPathCache = new Map(); - -/** - * Parses the trusted folders JSON content, stripping comments. - */ -function parseTrustedFoldersJson(content: string): unknown { - return JSON.parse(stripJsonComments(content)); -} - -/** - * FOR TESTING PURPOSES ONLY. - * Clears the real path cache. - */ -export function clearRealPathCacheForTesting(): void { - realPathCache.clear(); -} - -function getRealPath(location: string): string { - let realPath = realPathCache.get(location); - if (realPath !== undefined) { - return realPath; - } - - try { - realPath = fs.existsSync(location) ? fs.realpathSync(location) : location; - } catch { - realPath = location; - } - - realPathCache.set(location, realPath); - return realPath; -} - -export class LoadedTrustedFolders { - constructor( - readonly user: TrustedFoldersFile, - readonly errors: TrustedFoldersError[], - ) {} - - get rules(): TrustRule[] { - return Object.entries(this.user.config).map(([path, trustLevel]) => ({ - path, - trustLevel, - })); - } - - /** - * Returns true or false if the path should be "trusted". This function - * should only be invoked when the folder trust setting is active. - * - * @param location path - * @returns - */ - isPathTrusted( - location: string, - config?: Record, - headlessOptions?: HeadlessModeOptions, - ): boolean | undefined { - if (isHeadlessMode(headlessOptions)) { - return true; - } - const configToUse = config ?? this.user.config; - - // Resolve location to its realpath for canonical comparison - const realLocation = getRealPath(location); - - let longestMatchLen = -1; - let longestMatchTrust: TrustLevel | undefined = undefined; - - for (const [rulePath, trustLevel] of Object.entries(configToUse)) { - const effectivePath = - trustLevel === TrustLevel.TRUST_PARENT - ? path.dirname(rulePath) - : rulePath; - - // Resolve effectivePath to its realpath for canonical comparison - const realEffectivePath = getRealPath(effectivePath); - - if (isWithinRoot(realLocation, realEffectivePath)) { - if (rulePath.length > longestMatchLen) { - longestMatchLen = rulePath.length; - longestMatchTrust = trustLevel; - } - } - } - - if (longestMatchTrust === TrustLevel.DO_NOT_TRUST) return false; - if ( - longestMatchTrust === TrustLevel.TRUST_FOLDER || - longestMatchTrust === TrustLevel.TRUST_PARENT - ) - return true; - - return undefined; - } - - async setValue(folderPath: string, trustLevel: TrustLevel): Promise { - if (this.errors.length > 0) { - const errorMessages = this.errors.map( - (error) => `Error in ${error.path}: ${error.message}`, - ); - throw new FatalConfigError( - `Cannot update trusted folders because the configuration file is invalid:\n${errorMessages.join('\n')}\nPlease fix the file manually before trying to update it.`, - ); - } - - const dirPath = path.dirname(this.user.path); - if (!fs.existsSync(dirPath)) { - await fsPromises.mkdir(dirPath, { recursive: true }); - } - - // lockfile requires the file to exist - if (!fs.existsSync(this.user.path)) { - await fsPromises.writeFile(this.user.path, JSON.stringify({}, null, 2), { - mode: 0o600, - }); - } - - const release = await lock(this.user.path, { - retries: { - retries: 10, - minTimeout: 100, - }, - }); - - try { - // Re-read the file to handle concurrent updates - const content = await fsPromises.readFile(this.user.path, 'utf-8'); - let config: Record; - try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - config = parseTrustedFoldersJson(content) as Record; - } catch (error) { - coreEvents.emitFeedback( - 'error', - `Failed to parse trusted folders file at ${this.user.path}. The file may be corrupted.`, - error, - ); - config = {}; - } - - const originalTrustLevel = config[folderPath]; - config[folderPath] = trustLevel; - this.user.config[folderPath] = trustLevel; - - try { - saveTrustedFolders({ ...this.user, config }); - } catch (e) { - // Revert the in-memory change if the save failed. - if (originalTrustLevel === undefined) { - delete this.user.config[folderPath]; - } else { - this.user.config[folderPath] = originalTrustLevel; - } - throw e; - } - } finally { - await release(); - } - } -} - -let loadedTrustedFolders: LoadedTrustedFolders | undefined; - -/** - * FOR TESTING PURPOSES ONLY. - * Resets the in-memory cache of the trusted folders configuration. - */ -export function resetTrustedFoldersForTesting(): void { - loadedTrustedFolders = undefined; - clearRealPathCacheForTesting(); -} - -export function loadTrustedFolders(): LoadedTrustedFolders { - if (loadedTrustedFolders) { - return loadedTrustedFolders; - } - - const errors: TrustedFoldersError[] = []; - const userConfig: Record = {}; - - const userPath = getTrustedFoldersPath(); - try { - if (fs.existsSync(userPath)) { - const content = fs.readFileSync(userPath, 'utf-8'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const parsed = parseTrustedFoldersJson(content) as Record; - - if ( - typeof parsed !== 'object' || - parsed === null || - Array.isArray(parsed) - ) { - errors.push({ - message: 'Trusted folders file is not a valid JSON object.', - path: userPath, - }); - } else { - for (const [path, trustLevel] of Object.entries(parsed)) { - if (isTrustLevel(trustLevel)) { - userConfig[path] = trustLevel; - } else { - const possibleValues = Object.values(TrustLevel).join(', '); - errors.push({ - message: `Invalid trust level "${trustLevel}" for path "${path}". Possible values are: ${possibleValues}.`, - path: userPath, - }); - } - } - } - } - } catch (error) { - errors.push({ - message: getErrorMessage(error), - path: userPath, - }); - } - - loadedTrustedFolders = new LoadedTrustedFolders( - { path: userPath, config: userConfig }, - errors, - ); - return loadedTrustedFolders; -} - -export function saveTrustedFolders( - trustedFoldersFile: TrustedFoldersFile, -): void { - // Ensure the directory exists - const dirPath = path.dirname(trustedFoldersFile.path); - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }); - } - - const content = JSON.stringify(trustedFoldersFile.config, null, 2); - const tempPath = `${trustedFoldersFile.path}.tmp.${crypto.randomUUID()}`; - - try { - fs.writeFileSync(tempPath, content, { - encoding: 'utf-8', - mode: 0o600, - }); - fs.renameSync(tempPath, trustedFoldersFile.path); - } catch (error) { - // Clean up temp file if it was created but rename failed - if (fs.existsSync(tempPath)) { - try { - fs.unlinkSync(tempPath); - } catch { - // Ignore cleanup errors - } - } - throw error; - } -} +export type { + TrustRule, + TrustedFoldersError, + TrustedFoldersFile, + TrustResult, + LoadedTrustedFolders, +} from '@google/gemini-cli-core'; /** Is folder trust feature enabled per the current applied settings */ export function isFolderTrustEnabled(settings: Settings): boolean { @@ -335,57 +34,24 @@ export function isFolderTrustEnabled(settings: Settings): boolean { return folderTrustSetting; } -function getWorkspaceTrustFromLocalConfig( - workspaceDir: string, - trustConfig?: Record, - headlessOptions?: HeadlessModeOptions, -): TrustResult { - const folders = loadTrustedFolders(); - const configToUse = trustConfig ?? folders.user.config; - - if (folders.errors.length > 0) { - const errorMessages = folders.errors.map( - (error) => `Error in ${error.path}: ${error.message}`, - ); - throw new FatalConfigError( - `${errorMessages.join('\n')}\nPlease fix the configuration file and try again.`, - ); - } - - const isTrusted = folders.isPathTrusted( - workspaceDir, - configToUse, - headlessOptions, - ); - return { - isTrusted, - source: isTrusted !== undefined ? 'file' : undefined, - }; +export function loadTrustedFolders(): LoadedTrustedFolders { + return loadCoreTrustedFolders(); } +/** + * Returns true or false if the workspace is considered "trusted". + */ export function isWorkspaceTrusted( settings: Settings, workspaceDir: string = process.cwd(), - trustConfig?: Record, headlessOptions?: HeadlessModeOptions, -): TrustResult { - if (isHeadlessMode(headlessOptions)) { - return { isTrusted: true, source: undefined }; - } - - if (!isFolderTrustEnabled(settings)) { - return { isTrusted: true, source: undefined }; - } - - const ideTrust = ideContextStore.get()?.workspaceState?.isTrusted; - if (ideTrust !== undefined) { - return { isTrusted: ideTrust, source: 'ide' }; - } - - // Fall back to the local user configuration - return getWorkspaceTrustFromLocalConfig( - workspaceDir, - trustConfig, - headlessOptions, - ); +): { + isTrusted: boolean | undefined; + source: 'ide' | 'file' | 'env' | undefined; +} { + return checkPathTrust({ + path: workspaceDir, + isFolderTrustEnabled: isFolderTrustEnabled(settings), + isHeadless: isHeadlessMode(headlessOptions), + }); } diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 5b31d153fe..20fc80d190 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -280,6 +280,7 @@ describe('gemini.tsx main function', () => { vi.stubEnv('GEMINI_SANDBOX', ''); vi.stubEnv('SANDBOX', ''); vi.stubEnv('SHPOOL_SESSION_NAME', ''); + vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'true'); initialUnhandledRejectionListeners = process.listeners('unhandledRejection'); @@ -555,6 +556,7 @@ describe('gemini.tsx main function kitty protocol', () => { rawOutput: undefined, acceptRawOutputRisk: undefined, isCommand: undefined, + skipTrust: undefined, }); await act(async () => { @@ -613,6 +615,7 @@ describe('gemini.tsx main function kitty protocol', () => { rawOutput: undefined, acceptRawOutputRisk: undefined, isCommand: undefined, + skipTrust: undefined, }); await act(async () => { diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index e55b005946..28822642f3 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -661,6 +661,12 @@ export async function main() { cliStartupHandle?.end(); + if (!config.isInteractive()) { + for (const warning of startupWarnings) { + writeToStderr(warning.message + '\n'); + } + } + // Render UI, passing necessary config values. Check that there is no command line question. if (config.isInteractive()) { // Earlier initialization phases (like TerminalCapabilityManager resolving diff --git a/packages/cli/src/gemini_cleanup.test.tsx b/packages/cli/src/gemini_cleanup.test.tsx index 2df1ab4d82..93c166f9c2 100644 --- a/packages/cli/src/gemini_cleanup.test.tsx +++ b/packages/cli/src/gemini_cleanup.test.tsx @@ -181,6 +181,7 @@ describe('gemini.tsx main function cleanup', () => { beforeEach(() => { vi.clearAllMocks(); process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true'; + vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'true'); }); afterEach(() => { diff --git a/packages/cli/src/utils/userStartupWarnings.test.ts b/packages/cli/src/utils/userStartupWarnings.test.ts index 41ed061166..120ac36c3b 100644 --- a/packages/cli/src/utils/userStartupWarnings.test.ts +++ b/packages/cli/src/utils/userStartupWarnings.test.ts @@ -34,6 +34,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { ...actual, homedir: () => os.homedir(), getCompatibilityWarnings: vi.fn().mockReturnValue([]), + isHeadlessMode: vi.fn().mockReturnValue(false), WarningPriority: { Low: 'low', High: 'high', @@ -143,6 +144,51 @@ describe('getUserStartupWarnings', () => { }); }); + describe('folder trust check', () => { + it('should throw FatalUntrustedWorkspaceError when untrusted in headless mode', async () => { + const { isHeadlessMode, FatalUntrustedWorkspaceError } = await import( + '@google/gemini-cli-core' + ); + vi.mocked(isFolderTrustEnabled).mockReturnValue(true); + vi.mocked(isWorkspaceTrusted).mockImplementation(() => { + throw new FatalUntrustedWorkspaceError( + 'Gemini CLI is not running in a trusted directory', + ); + }); + vi.mocked(isHeadlessMode).mockReturnValue(true); + + await expect( + getUserStartupWarnings({}, testRootDir), + ).rejects.toThrowError(FatalUntrustedWorkspaceError); + }); + + it('should not return a warning when trusted in headless mode', async () => { + const { isHeadlessMode } = await import('@google/gemini-cli-core'); + vi.mocked(isFolderTrustEnabled).mockReturnValue(true); + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + vi.mocked(isHeadlessMode).mockReturnValue(true); + + const warnings = await getUserStartupWarnings({}, testRootDir); + expect(warnings.find((w) => w.id === 'folder-trust')).toBeUndefined(); + }); + + it('should not return a warning when untrusted in interactive mode', async () => { + const { isHeadlessMode } = await import('@google/gemini-cli-core'); + vi.mocked(isFolderTrustEnabled).mockReturnValue(true); + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: false, + source: undefined, + }); + vi.mocked(isHeadlessMode).mockReturnValue(false); + + const warnings = await getUserStartupWarnings({}, testRootDir); + expect(warnings.find((w) => w.id === 'folder-trust')).toBeUndefined(); + }); + }); + describe('compatibility warnings', () => { it('should include compatibility warnings by default', async () => { const compWarning = { diff --git a/packages/cli/src/utils/userStartupWarnings.ts b/packages/cli/src/utils/userStartupWarnings.ts index 5575582fab..78627df3e5 100644 --- a/packages/cli/src/utils/userStartupWarnings.ts +++ b/packages/cli/src/utils/userStartupWarnings.ts @@ -12,6 +12,8 @@ import { getCompatibilityWarnings, WarningPriority, type StartupWarning, + isHeadlessMode, + FatalUntrustedWorkspaceError, } from '@google/gemini-cli-core'; import type { Settings } from '../config/settingsSchema.js'; import { @@ -79,10 +81,34 @@ const rootDirectoryCheck: WarningCheck = { }, }; +const folderTrustCheck: WarningCheck = { + id: 'folder-trust', + priority: WarningPriority.High, + check: async (workspaceRoot: string, settings: Settings) => { + if (!isFolderTrustEnabled(settings)) { + return null; + } + + const { isTrusted } = isWorkspaceTrusted(settings, workspaceRoot); + if (isTrusted === true) { + return null; + } + + if (isHeadlessMode()) { + throw new FatalUntrustedWorkspaceError( + 'Gemini CLI is not running in a trusted directory. To proceed, either use `--skip-trust`, set the `GEMINI_CLI_TRUST_WORKSPACE=true` environment variable, or trust this directory in interactive mode.', + ); + } + + return null; + }, +}; + // All warning checks const WARNING_CHECKS: readonly WarningCheck[] = [ homeDirectoryCheck, rootDirectoryCheck, + folderTrustCheck, ]; export async function getUserStartupWarnings( diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 5e3aada4e5..5a40648a4a 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -20,6 +20,7 @@ import { ProjectRegistry } from './projectRegistry.js'; import { StorageMigration } from './storageMigration.js'; export const OAUTH_FILE = 'oauth_creds.json'; +export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json'; const TMP_DIR_NAME = 'tmp'; const BIN_DIR_NAME = 'bin'; const AGENTS_DIR_NAME = '.agents'; @@ -86,6 +87,13 @@ export class Storage { return path.join(Storage.getGlobalGeminiDir(), GOOGLE_ACCOUNTS_FILENAME); } + static getTrustedFoldersPath(): string { + if (process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH']) { + return process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH']; + } + return path.join(Storage.getGlobalGeminiDir(), TRUSTED_FOLDERS_FILENAME); + } + static getUserCommandsDir(): string { return path.join(Storage.getGlobalGeminiDir(), 'commands'); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 62a0b127bd..3123dd9096 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -294,3 +294,6 @@ export type { Content, Part, FunctionCall } from '@google/genai'; // Export context types and profiles export * from './context/types.js'; export * from './context/profiles.js'; + +// Export trust utility +export * from './utils/trust.js'; diff --git a/packages/core/src/utils/errors.ts b/packages/core/src/utils/errors.ts index 210902029b..804e074523 100644 --- a/packages/core/src/utils/errors.ts +++ b/packages/core/src/utils/errors.ts @@ -114,6 +114,12 @@ export class FatalToolExecutionError extends FatalError { this.name = 'FatalToolExecutionError'; } } +export class FatalUntrustedWorkspaceError extends FatalError { + constructor(message: string) { + super(message, 55); + this.name = 'FatalUntrustedWorkspaceError'; + } +} export class FatalCancellationError extends FatalError { constructor(message: string) { super(message, 130); // Standard exit code for SIGINT diff --git a/packages/core/src/utils/paths.ts b/packages/core/src/utils/paths.ts index dae7c5c4e8..fee8b8d855 100644 --- a/packages/core/src/utils/paths.ts +++ b/packages/core/src/utils/paths.ts @@ -12,6 +12,7 @@ import { fileURLToPath } from 'node:url'; export const GEMINI_DIR = '.gemini'; export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json'; +export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json'; /** * Returns the home directory. diff --git a/packages/core/src/utils/trust.test.ts b/packages/core/src/utils/trust.test.ts new file mode 100644 index 0000000000..f5930972ff --- /dev/null +++ b/packages/core/src/utils/trust.test.ts @@ -0,0 +1,207 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + TrustLevel, + loadTrustedFolders, + resetTrustedFoldersForTesting, + checkPathTrust, +} from './trust.js'; +import { Storage } from '../config/storage.js'; +import { lock } from 'proper-lockfile'; +import { ideContextStore } from '../ide/ideContext.js'; +import * as headless from './headless.js'; +import { coreEvents } from './events.js'; + +vi.mock('proper-lockfile'); +vi.mock('./headless.js', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + isHeadlessMode: vi.fn(), + }; +}); + +describe('Trust Utility (Core)', () => { + const tempDir = path.join( + os.tmpdir(), + 'gemini-trust-test-' + Math.random().toString(36).slice(2), + ); + const trustedFoldersPath = path.join(tempDir, 'trustedFolders.json'); + + beforeEach(() => { + fs.mkdirSync(tempDir, { recursive: true }); + vi.spyOn(Storage, 'getTrustedFoldersPath').mockReturnValue( + trustedFoldersPath, + ); + vi.mocked(lock).mockResolvedValue(vi.fn().mockResolvedValue(undefined)); + vi.mocked(headless.isHeadlessMode).mockReturnValue(false); + ideContextStore.clear(); + resetTrustedFoldersForTesting(); + delete process.env['GEMINI_CLI_TRUST_WORKSPACE']; + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it('should load empty config if file does not exist', () => { + const folders = loadTrustedFolders(); + expect(folders.user.config).toEqual({}); + expect(folders.errors).toEqual([]); + }); + + it('should load config from file', () => { + const config = { + [path.resolve('/trusted/path')]: TrustLevel.TRUST_FOLDER, + }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config)); + + const folders = loadTrustedFolders(); + // Use path.resolve for platform consistency in tests + const normalizedKey = path.resolve('/trusted/path').replace(/\\/g, '/'); + const isWindows = process.platform === 'win32'; + const finalKey = isWindows ? normalizedKey.toLowerCase() : normalizedKey; + + expect(folders.user.config[finalKey]).toBe(TrustLevel.TRUST_FOLDER); + }); + + it('should handle isPathTrusted with longest match', () => { + const config = { + [path.resolve('/a')]: TrustLevel.TRUST_FOLDER, + [path.resolve('/a/b')]: TrustLevel.DO_NOT_TRUST, + [path.resolve('/a/b/c')]: TrustLevel.TRUST_FOLDER, + }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config)); + + const folders = loadTrustedFolders(); + + expect(folders.isPathTrusted(path.resolve('/a/file.txt'))).toBe(true); + expect(folders.isPathTrusted(path.resolve('/a/b/file.txt'))).toBe(false); + expect(folders.isPathTrusted(path.resolve('/a/b/c/file.txt'))).toBe(true); + expect(folders.isPathTrusted(path.resolve('/other'))).toBeUndefined(); + }); + + it('should handle TRUST_PARENT', () => { + const config = { + [path.resolve('/project/.gemini')]: TrustLevel.TRUST_PARENT, + }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config)); + + const folders = loadTrustedFolders(); + + expect(folders.isPathTrusted(path.resolve('/project/file.txt'))).toBe(true); + expect( + folders.isPathTrusted(path.resolve('/project/.gemini/config.yaml')), + ).toBe(true); + }); + + it('should save config correctly', async () => { + const folders = loadTrustedFolders(); + const testPath = path.resolve('/new/trusted/path'); + await folders.setValue(testPath, TrustLevel.TRUST_FOLDER); + + const savedContent = JSON.parse( + fs.readFileSync(trustedFoldersPath, 'utf-8'), + ); + const normalizedKey = testPath.replace(/\\/g, '/'); + const isWindows = process.platform === 'win32'; + const finalKey = isWindows ? normalizedKey.toLowerCase() : normalizedKey; + + expect(savedContent[finalKey]).toBe(TrustLevel.TRUST_FOLDER); + }); + + it('should handle comments in JSON', () => { + const content = ` + { + // This is a comment + "path": "TRUST_FOLDER" + } + `; + fs.writeFileSync(trustedFoldersPath, content); + + const folders = loadTrustedFolders(); + expect(folders.errors).toHaveLength(0); + }); + + describe('checkPathTrust', () => { + it('should NOT return trusted if headless mode is on by default', () => { + const result = checkPathTrust({ + path: '/any', + isFolderTrustEnabled: true, + isHeadless: true, + }); + expect(result).toEqual({ isTrusted: undefined, source: undefined }); + }); + + it('should return trusted if folder trust is disabled', () => { + const result = checkPathTrust({ + path: '/any', + isFolderTrustEnabled: false, + }); + expect(result).toEqual({ isTrusted: true, source: undefined }); + }); + + it('should return IDE trust if available', () => { + ideContextStore.set({ + workspaceState: { isTrusted: true }, + }); + const result = checkPathTrust({ + path: '/any', + isFolderTrustEnabled: true, + }); + expect(result).toEqual({ isTrusted: true, source: 'ide' }); + }); + + it('should fall back to file trust', () => { + const config = { + [path.resolve('/trusted')]: TrustLevel.TRUST_FOLDER, + }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config)); + + const result = checkPathTrust({ + path: path.resolve('/trusted/file.txt'), + isFolderTrustEnabled: true, + }); + expect(result).toEqual({ isTrusted: true, source: 'file' }); + }); + + it('should return undefined trust if no rule matches', () => { + const result = checkPathTrust({ + path: '/any', + isFolderTrustEnabled: true, + }); + expect(result).toEqual({ isTrusted: undefined, source: undefined }); + }); + }); + + describe('coreEvents.emitFeedback', () => { + it('should report corrupted config via coreEvents.emitFeedback in setValue', async () => { + const folders = loadTrustedFolders(); + const testPath = path.resolve('/new/path'); + + // Initialize with valid JSON + fs.writeFileSync(trustedFoldersPath, '{}', 'utf-8'); + + // Corrupt the file after initial load + fs.writeFileSync(trustedFoldersPath, 'invalid json', 'utf-8'); + + const spy = vi.spyOn(coreEvents, 'emitFeedback'); + await folders.setValue(testPath, TrustLevel.TRUST_FOLDER); + + expect(spy).toHaveBeenCalledWith( + 'error', + expect.stringContaining('may be corrupted'), + expect.any(Error), + ); + }); + }); +}); diff --git a/packages/core/src/utils/trust.ts b/packages/core/src/utils/trust.ts new file mode 100644 index 0000000000..bf78746908 --- /dev/null +++ b/packages/core/src/utils/trust.ts @@ -0,0 +1,356 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as crypto from 'node:crypto'; +import { lock } from 'proper-lockfile'; +import stripJsonComments from 'strip-json-comments'; +import { Storage } from '../config/storage.js'; +import { normalizePath, isSubpath } from './paths.js'; +import { FatalConfigError, getErrorMessage } from './errors.js'; +import { coreEvents } from './events.js'; +import { ideContextStore } from '../ide/ideContext.js'; + +export enum TrustLevel { + TRUST_FOLDER = 'TRUST_FOLDER', + TRUST_PARENT = 'TRUST_PARENT', + DO_NOT_TRUST = 'DO_NOT_TRUST', +} + +export interface TrustResult { + isTrusted: boolean | undefined; + source: 'ide' | 'file' | 'env' | undefined; +} + +export interface TrustOptions { + path: string; + isFolderTrustEnabled: boolean; + isHeadless?: boolean; +} + +export function isTrustLevel(value: unknown): value is TrustLevel { + return ( + typeof value === 'string' && + Object.values(TrustLevel).some((v) => v === value) + ); +} + +/** + * Checks if a path is trusted based on headless mode, folder trust settings, + * IDE context, and local configuration file. + */ +export function checkPathTrust(options: TrustOptions): TrustResult { + if (process.env['GEMINI_CLI_TRUST_WORKSPACE'] === 'true') { + return { isTrusted: true, source: 'env' }; + } + + if (!options.isFolderTrustEnabled) { + return { isTrusted: true, source: undefined }; + } + + const ideTrust = ideContextStore.get()?.workspaceState?.isTrusted; + if (ideTrust !== undefined) { + return { isTrusted: ideTrust, source: 'ide' }; + } + + const folders = loadTrustedFolders(); + + if (folders.errors.length > 0) { + const errorMessages = folders.errors.map( + (error) => `Error in ${error.path}: ${error.message}`, + ); + throw new FatalConfigError( + `${errorMessages.join('\n')}\nPlease fix the configuration file and try again.`, + ); + } + + const isTrusted = folders.isPathTrusted(options.path); + return { + isTrusted, + source: isTrusted !== undefined ? 'file' : undefined, + }; +} + +export interface TrustRule { + path: string; + trustLevel: TrustLevel; +} + +export interface TrustedFoldersError { + message: string; + path: string; +} + +export interface TrustedFoldersFile { + config: Record; + path: string; +} + +const realPathCache = new Map(); + +/** + * Parses the trusted folders JSON content, stripping comments. + */ +function parseTrustedFoldersJson(content: string): unknown { + return JSON.parse(stripJsonComments(content)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * FOR TESTING PURPOSES ONLY. + * Clears the real path cache. + */ +export function clearRealPathCacheForTesting(): void { + realPathCache.clear(); +} + +function getRealPath(location: string): string { + let realPath = realPathCache.get(location); + if (realPath !== undefined) { + return realPath; + } + + try { + realPath = fs.existsSync(location) ? fs.realpathSync(location) : location; + } catch { + realPath = location; + } + + realPathCache.set(location, realPath); + return realPath; +} + +export class LoadedTrustedFolders { + constructor( + readonly user: TrustedFoldersFile, + readonly errors: TrustedFoldersError[], + ) {} + + get rules(): TrustRule[] { + return Object.entries(this.user.config).map(([path, trustLevel]) => ({ + path, + trustLevel, + })); + } + + /** + * Returns true or false if the path should be "trusted" based on the configuration. + * + * @param location path + * @param config optional config override + * @returns boolean if trusted/distrusted, undefined if no rule matches + */ + isPathTrusted( + location: string, + config?: Record, + ): boolean | undefined { + const configToUse = config ?? this.user.config; + + // Resolve location to its realpath for canonical comparison + const realLocation = getRealPath(location); + const normalizedLocation = normalizePath(realLocation); + + let longestMatchLen = -1; + let longestMatchTrust: TrustLevel | undefined = undefined; + + for (const [rulePath, trustLevel] of Object.entries(configToUse)) { + const effectivePath = + trustLevel === TrustLevel.TRUST_PARENT + ? path.dirname(rulePath) + : rulePath; + + // Resolve effectivePath to its realpath for canonical comparison + const realEffectivePath = getRealPath(effectivePath); + const normalizedEffectivePath = normalizePath(realEffectivePath); + + if (isSubpath(normalizedEffectivePath, normalizedLocation)) { + if (rulePath.length > longestMatchLen) { + longestMatchLen = rulePath.length; + longestMatchTrust = trustLevel; + } + } + } + + if (longestMatchTrust === TrustLevel.DO_NOT_TRUST) return false; + if ( + longestMatchTrust === TrustLevel.TRUST_FOLDER || + longestMatchTrust === TrustLevel.TRUST_PARENT + ) { + return true; + } + + return undefined; + } + + async setValue(folderPath: string, trustLevel: TrustLevel): Promise { + if (this.errors.length > 0) { + const errorMessages = this.errors.map( + (error) => `Error in ${error.path}: ${error.message}`, + ); + throw new FatalConfigError( + `Cannot update trusted folders because the configuration file is invalid:\n${errorMessages.join('\n')}\nPlease fix the file manually before trying to update it.`, + ); + } + + const dirPath = path.dirname(this.user.path); + if (!fs.existsSync(dirPath)) { + await fs.promises.mkdir(dirPath, { recursive: true }); + } + + // lockfile requires the file to exist + if (!fs.existsSync(this.user.path)) { + await fs.promises.writeFile(this.user.path, JSON.stringify({}, null, 2), { + // Restrict file access to read/write for the owner only + mode: 0o600, + }); + } + + const release = await lock(this.user.path, { + retries: { + retries: 10, + minTimeout: 100, + }, + }); + + const normalizedPath = normalizePath(folderPath); + const originalTrustLevel = this.user.config[normalizedPath]; + + try { + // Re-read the file to handle concurrent updates + const content = await fs.promises.readFile(this.user.path, 'utf-8'); + const config: Record = {}; + try { + const parsed = parseTrustedFoldersJson(content); + if (isRecord(parsed)) { + for (const [rawPath, value] of Object.entries(parsed)) { + if (isTrustLevel(value)) { + config[rawPath] = value; + } + } + } + } catch (error) { + coreEvents.emitFeedback( + 'error', + `Failed to parse trusted folders file at ${this.user.path}. The file may be corrupted.`, + error, + ); + } + + // Use normalized path as key + config[normalizedPath] = trustLevel; + this.user.config[normalizedPath] = trustLevel; + + try { + saveTrustedFolders({ ...this.user, config }); + } catch (e) { + // Revert the in-memory change if the save failed. + if (originalTrustLevel === undefined) { + delete this.user.config[normalizedPath]; + } else { + this.user.config[normalizedPath] = originalTrustLevel; + } + throw e; + } + } finally { + await release(); + } + } +} + +let loadedTrustedFolders: LoadedTrustedFolders | undefined; + +/** + * FOR TESTING PURPOSES ONLY. + * Resets the in-memory cache of the trusted folders configuration. + */ +export function resetTrustedFoldersForTesting(): void { + loadedTrustedFolders = undefined; + clearRealPathCacheForTesting(); +} + +export function loadTrustedFolders(): LoadedTrustedFolders { + if (loadedTrustedFolders) { + return loadedTrustedFolders; + } + + const errors: TrustedFoldersError[] = []; + const userConfig: Record = {}; + + const userPath = Storage.getTrustedFoldersPath(); + try { + if (fs.existsSync(userPath)) { + const content = fs.readFileSync(userPath, 'utf-8'); + const parsed = parseTrustedFoldersJson(content); + + if (!isRecord(parsed)) { + errors.push({ + message: 'Trusted folders file is not a valid JSON object.', + path: userPath, + }); + } else { + for (const [rawPath, trustLevel] of Object.entries(parsed)) { + const normalizedPath = normalizePath(rawPath); + if (isTrustLevel(trustLevel)) { + userConfig[normalizedPath] = trustLevel; + } else { + const possibleValues = Object.values(TrustLevel).join(', '); + errors.push({ + message: `Invalid trust level "${trustLevel}" for path "${rawPath}". Possible values are: ${possibleValues}.`, + path: userPath, + }); + } + } + } + } + } catch (error) { + errors.push({ + message: getErrorMessage(error), + path: userPath, + }); + } + + loadedTrustedFolders = new LoadedTrustedFolders( + { path: userPath, config: userConfig }, + errors, + ); + return loadedTrustedFolders; +} + +export function saveTrustedFolders( + trustedFoldersFile: TrustedFoldersFile, +): void { + // Ensure the directory exists + const dirPath = path.dirname(trustedFoldersFile.path); + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } + + const content = JSON.stringify(trustedFoldersFile.config, null, 2); + const tempPath = `${trustedFoldersFile.path}.tmp.${crypto.randomUUID()}`; + + try { + fs.writeFileSync(tempPath, content, { + encoding: 'utf-8', + // Restrict file access to read/write for the owner only + mode: 0o600, + }); + fs.renameSync(tempPath, trustedFoldersFile.path); + } catch (error) { + // Clean up temp file if it was created but rename failed + if (fs.existsSync(tempPath)) { + try { + fs.unlinkSync(tempPath); + } catch { + // Ignore cleanup errors + } + } + throw error; + } +}