feat(cli): secure .env loading and enforce workspace trust in headless mode (#25814)

Co-authored-by: galz10 <galzahavi@google.com>
Co-authored-by: davidapierce <davidapierce@google.com>
This commit is contained in:
Emily Hedlund
2026-04-23 09:09:14 -07:00
committed by GitHub
parent a007f64d20
commit dba9b9a0ff
27 changed files with 881 additions and 489 deletions
+1
View File
@@ -28,6 +28,7 @@ runs:
- name: 'Run Tests' - name: 'Run Tests'
env: env:
GEMINI_API_KEY: '${{ inputs.gemini_api_key }}' GEMINI_API_KEY: '${{ inputs.gemini_api_key }}'
GEMINI_CLI_TRUST_WORKSPACE: true
working-directory: '${{ inputs.working-directory }}' working-directory: '${{ inputs.working-directory }}'
run: |- run: |-
echo "::group::Build" echo "::group::Build"
@@ -98,6 +98,7 @@ runs:
working-directory: '${{ inputs.working-directory }}' working-directory: '${{ inputs.working-directory }}'
env: env:
GEMINI_API_KEY: '${{ inputs.gemini_api_key }}' GEMINI_API_KEY: '${{ inputs.gemini_api_key }}'
GEMINI_CLI_TRUST_WORKSPACE: true
INTEGRATION_TEST_USE_INSTALLED_GEMINI: 'true' INTEGRATION_TEST_USE_INSTALLED_GEMINI: 'true'
# We must diable CI mode here because it interferes with interactive tests. # We must diable CI mode here because it interferes with interactive tests.
# See https://github.com/google-gemini/gemini-cli/issues/10517 # See https://github.com/google-gemini/gemini-cli/issues/10517
+3
View File
@@ -167,6 +167,7 @@ jobs:
- name: 'Run E2E tests' - name: 'Run E2E tests'
env: env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GEMINI_CLI_TRUST_WORKSPACE: true
KEEP_OUTPUT: 'true' KEEP_OUTPUT: 'true'
VERBOSE: 'true' VERBOSE: 'true'
BUILD_SANDBOX_FLAGS: '--cache-from type=gha --cache-to type=gha,mode=max' BUILD_SANDBOX_FLAGS: '--cache-from type=gha --cache-to type=gha,mode=max'
@@ -212,6 +213,7 @@ jobs:
if: "${{runner.os != 'Windows'}}" if: "${{runner.os != 'Windows'}}"
env: env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GEMINI_CLI_TRUST_WORKSPACE: true
KEEP_OUTPUT: 'true' KEEP_OUTPUT: 'true'
SANDBOX: 'sandbox:none' SANDBOX: 'sandbox:none'
VERBOSE: 'true' VERBOSE: 'true'
@@ -288,6 +290,7 @@ jobs:
- name: 'Run E2E tests' - name: 'Run E2E tests'
env: env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GEMINI_CLI_TRUST_WORKSPACE: true
KEEP_OUTPUT: 'true' KEEP_OUTPUT: 'true'
SANDBOX: 'sandbox:none' SANDBOX: 'sandbox:none'
VERBOSE: 'true' VERBOSE: 'true'
+3
View File
@@ -179,6 +179,7 @@ jobs:
- name: 'Run tests and generate reports' - name: 'Run tests and generate reports'
env: env:
NO_COLOR: true NO_COLOR: true
GEMINI_CLI_TRUST_WORKSPACE: true
run: | run: |
if [[ "${{ matrix.shard }}" == "cli" ]]; then if [[ "${{ matrix.shard }}" == "cli" ]]; then
npm run test:ci --workspace "@google/gemini-cli" npm run test:ci --workspace "@google/gemini-cli"
@@ -267,6 +268,7 @@ jobs:
- name: 'Run tests and generate reports' - name: 'Run tests and generate reports'
env: env:
NO_COLOR: true NO_COLOR: true
GEMINI_CLI_TRUST_WORKSPACE: true
run: | run: |
if [[ "${{ matrix.shard }}" == "cli" ]]; then if [[ "${{ matrix.shard }}" == "cli" ]]; then
npm run test:ci --workspace "@google/gemini-cli" -- --coverage.enabled=false npm run test:ci --workspace "@google/gemini-cli" -- --coverage.enabled=false
@@ -430,6 +432,7 @@ jobs:
env: env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
NO_COLOR: true NO_COLOR: true
GEMINI_CLI_TRUST_WORKSPACE: true
NODE_OPTIONS: '--max-old-space-size=32768 --max-semi-space-size=256' NODE_OPTIONS: '--max-old-space-size=32768 --max-semi-space-size=256'
UV_THREADPOOL_SIZE: '32' UV_THREADPOOL_SIZE: '32'
NODE_ENV: 'test' NODE_ENV: 'test'
+3
View File
@@ -62,6 +62,7 @@ jobs:
- name: 'Run E2E tests' - name: 'Run E2E tests'
env: env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GEMINI_CLI_TRUST_WORKSPACE: true
IS_DOCKER: "${{ matrix.sandbox == 'sandbox:docker' }}" IS_DOCKER: "${{ matrix.sandbox == 'sandbox:docker' }}"
KEEP_OUTPUT: 'true' KEEP_OUTPUT: 'true'
RUNS: '${{ github.event.inputs.runs }}' RUNS: '${{ github.event.inputs.runs }}'
@@ -105,6 +106,7 @@ jobs:
if: "runner.os != 'Windows'" if: "runner.os != 'Windows'"
env: env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GEMINI_CLI_TRUST_WORKSPACE: true
KEEP_OUTPUT: 'true' KEEP_OUTPUT: 'true'
RUNS: '${{ github.event.inputs.runs }}' RUNS: '${{ github.event.inputs.runs }}'
SANDBOX: 'sandbox:none' SANDBOX: 'sandbox:none'
@@ -159,6 +161,7 @@ jobs:
- name: 'Run E2E tests' - name: 'Run E2E tests'
env: env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GEMINI_CLI_TRUST_WORKSPACE: true
KEEP_OUTPUT: 'true' KEEP_OUTPUT: 'true'
SANDBOX: 'sandbox:none' SANDBOX: 'sandbox:none'
VERBOSE: 'true' VERBOSE: 'true'
+1
View File
@@ -141,6 +141,7 @@ jobs:
if: "github.event_name != 'pull_request'" if: "github.event_name != 'pull_request'"
env: env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GEMINI_CLI_TRUST_WORKSPACE: true
run: | run: |
echo "Running integration tests with binary..." echo "Running integration tests with binary..."
if [[ "${{ matrix.os }}" == 'windows-latest' ]]; then if [[ "${{ matrix.os }}" == 'windows-latest' ]]; then
+1
View File
@@ -52,6 +52,7 @@ These commands are available within the interactive REPL.
| `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode | | `--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. | | `--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 | | `--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` | | `--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. | | `--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.** | | `--experimental-acp` | - | boolean | - | Start in ACP (Agent Code Pilot) mode. **Experimental feature.** |
+24
View File
@@ -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 Granting trust to a folder unlocks the full functionality of Gemini CLI for that
workspace. 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 ## Managing your trust settings
If you need to change a decision or see all your settings, you have a couple of If you need to change a decision or see all your settings, you have a couple of
+8
View File
@@ -2156,6 +2156,14 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
- Overrides the hardcoded default - Overrides the hardcoded default
- Example: `export GEMINI_MODEL="gemini-3-flash-preview"` (Windows PowerShell: - Example: `export GEMINI_MODEL="gemini-3-flash-preview"` (Windows PowerShell:
`$env:GEMINI_MODEL="gemini-3-flash-preview"`) `$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`**: - **`GEMINI_CLI_IDE_PID`**:
- Manually specifies the PID of the IDE process to use for integration. This - 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 is useful when running Gemini CLI in a standalone terminal while still
+32 -53
View File
@@ -449,7 +449,8 @@
"version": "2.11.0", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz",
"integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==", "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": { "node_modules/@bundled-es-modules/cookie": {
"version": "2.0.1", "version": "2.0.1",
@@ -1473,6 +1474,7 @@
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz",
"integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==", "integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@grpc/proto-loader": "^0.7.13", "@grpc/proto-loader": "^0.7.13",
"@js-sdsl/ordered-map": "^4.4.2" "@js-sdsl/ordered-map": "^4.4.2"
@@ -2150,6 +2152,7 @@
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@octokit/auth-token": "^6.0.0", "@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.2", "@octokit/graphql": "^9.0.2",
@@ -2330,6 +2333,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"engines": { "engines": {
"node": ">=8.0.0" "node": ">=8.0.0"
} }
@@ -2379,6 +2383,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz",
"integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==", "integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0" "@opentelemetry/semantic-conventions": "^1.29.0"
}, },
@@ -2753,6 +2758,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz",
"integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==", "integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@opentelemetry/core": "2.5.0", "@opentelemetry/core": "2.5.0",
"@opentelemetry/semantic-conventions": "^1.29.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", "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz",
"integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==", "integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@opentelemetry/core": "2.5.0", "@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "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", "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz",
"integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==", "integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@opentelemetry/core": "2.5.0", "@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "2.5.0", "@opentelemetry/resources": "2.5.0",
@@ -4046,6 +4054,7 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
@@ -4319,6 +4328,7 @@
"integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/scope-manager": "8.58.2",
"@typescript-eslint/types": "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": { "node_modules/@vitest/expect": {
"version": "3.2.4", "version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", "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", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -7190,7 +7151,8 @@
"version": "0.0.1581282", "version": "0.0.1581282",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz",
"integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==", "integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause",
"peer": true
}, },
"node_modules/dezalgo": { "node_modules/dezalgo": {
"version": "1.0.4", "version": "1.0.4",
@@ -7775,6 +7737,7 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -8292,6 +8255,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"accepts": "^2.0.0", "accepts": "^2.0.0",
"body-parser": "^2.2.1", "body-parser": "^2.2.1",
@@ -9558,6 +9522,7 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz",
"integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=16.9.0" "node": ">=16.9.0"
} }
@@ -9817,6 +9782,7 @@
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.6.9.tgz", "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.6.9.tgz",
"integrity": "sha512-RL9sSiLQZECnjbmBwjIHOp8yVGdWF7C/uifg7ISv/e+F3nLNsfl7FdUFQs8iZARFMJAYxMFpxW6OW+HSt9drwQ==", "integrity": "sha512-RL9sSiLQZECnjbmBwjIHOp8yVGdWF7C/uifg7ISv/e+F3nLNsfl7FdUFQs8iZARFMJAYxMFpxW6OW+HSt9drwQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"ansi-escapes": "^7.0.0", "ansi-escapes": "^7.0.0",
"ansi-styles": "^6.2.3", "ansi-styles": "^6.2.3",
@@ -13530,6 +13496,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -13540,6 +13507,7 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"shell-quote": "^1.6.1", "shell-quote": "^1.6.1",
"ws": "^7" "ws": "^7"
@@ -15659,6 +15627,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -15881,7 +15850,8 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "license": "0BSD",
"peer": true
}, },
"node_modules/tsx": { "node_modules/tsx": {
"version": "4.20.3", "version": "4.20.3",
@@ -15889,6 +15859,7 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "~0.25.0", "esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5" "get-tsconfig": "^4.7.5"
@@ -16054,6 +16025,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -16121,6 +16093,7 @@
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0", "@typescript-eslint/types": "8.35.0",
@@ -16507,6 +16480,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -17077,6 +17051,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -17089,6 +17064,7 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/chai": "^5.2.2", "@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4", "@vitest/expect": "3.2.4",
@@ -17727,6 +17703,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
@@ -18162,6 +18139,7 @@
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
"integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@grpc/proto-loader": "^0.8.0", "@grpc/proto-loader": "^0.8.0",
"@js-sdsl/ordered-map": "^4.4.2" "@js-sdsl/ordered-map": "^4.4.2"
@@ -18280,6 +18258,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
+29 -12
View File
@@ -24,6 +24,7 @@ import {
FileDiscoveryService, FileDiscoveryService,
resolveTelemetrySettings, resolveTelemetrySettings,
FatalConfigError, FatalConfigError,
getErrorMessage,
getPty, getPty,
debugLogger, debugLogger,
loadServerHierarchicalMemory, loadServerHierarchicalMemory,
@@ -60,6 +61,7 @@ import {
import { loadSandboxConfig } from './sandboxConfig.js'; import { loadSandboxConfig } from './sandboxConfig.js';
import { resolvePath } from '../utils/resolvePath.js'; import { resolvePath } from '../utils/resolvePath.js';
import { isRecord } from '../utils/settingsUtils.js';
import { RESUME_LATEST } from '../utils/sessionUtils.js'; import { RESUME_LATEST } from '../utils/sessionUtils.js';
import { isWorkspaceTrusted } from './trustedFolders.js'; import { isWorkspaceTrusted } from './trustedFolders.js';
@@ -106,6 +108,7 @@ export interface CliArgs {
startupMessages?: string[]; startupMessages?: string[];
rawOutput: boolean | undefined; rawOutput: boolean | undefined;
acceptRawOutputRisk: boolean | undefined; acceptRawOutputRisk: boolean | undefined;
skipTrust: boolean | undefined;
isCommand: boolean | undefined; isCommand: boolean | undefined;
} }
@@ -291,6 +294,11 @@ export async function parseArguments(
description: description:
'Execute the provided prompt and continue in interactive mode', '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', { .option('worktree', {
alias: 'w', alias: 'w',
type: 'string', type: 'string',
@@ -459,9 +467,16 @@ export async function parseArguments(
yargsInstance.wrap(yargsInstance.terminalWidth()); yargsInstance.wrap(yargsInstance.terminalWidth());
let result; let result;
try { 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) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e); const msg = getErrorMessage(e);
debugLogger.error(msg); debugLogger.error(msg);
yargsInstance.showHelp(); yargsInstance.showHelp();
await runExitCleanup(); await runExitCleanup();
@@ -475,11 +490,13 @@ export async function parseArguments(
} }
// Normalize query args: handle both quoted "@path file" and unquoted @path file // 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['query'];
const queryArg = (result as { query?: string | string[] | undefined }).query; let q: string | undefined;
const q: string | undefined = Array.isArray(queryArg) if (Array.isArray(queryArg)) {
? queryArg.join(' ') q = queryArg.join(' ');
: queryArg; } else if (typeof queryArg === 'string') {
q = queryArg;
}
// -p/--prompt forces non-interactive mode; positional args default to interactive in TTY // -p/--prompt forces non-interactive mode; positional args default to interactive in TTY
if (q && !result['prompt']) { if (q && !result['prompt']) {
@@ -494,8 +511,8 @@ export async function parseArguments(
} }
// Keep CliArgs.query as a string for downstream typing // Keep CliArgs.query as a string for downstream typing
(result as Record<string, unknown>)['query'] = q || undefined; result['query'] = q || undefined;
(result as Record<string, unknown>)['startupMessages'] = startupMessages; result['startupMessages'] = startupMessages;
// The import format is now only controlled by settings.memoryImportFormat // The import format is now only controlled by settings.memoryImportFormat
// We no longer accept it as a CLI argument // We no longer accept it as a CLI argument
@@ -547,7 +564,7 @@ export async function loadCliConfig(
? false ? false
: (settings.security?.folderTrust?.enabled ?? false); : (settings.security?.folderTrust?.enabled ?? false);
const trustedFolder = const trustedFolder =
isWorkspaceTrusted(settings, cwd, undefined, { isWorkspaceTrusted(settings, cwd, {
prompt: argv.prompt, prompt: argv.prompt,
query: argv.query, query: argv.query,
})?.isTrusted ?? false; })?.isTrusted ?? false;
@@ -593,7 +610,7 @@ export async function loadCliConfig(
return resolveToRealPath(trimmedPath) !== realCwd; return resolveToRealPath(trimmedPath) !== realCwd;
} catch (e) { } catch (e) {
debugLogger.debug( 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; return false;
} }
@@ -1099,7 +1116,7 @@ async function resolveWorktreeSettings(
worktreeBaseSha = stdout.trim(); worktreeBaseSha = stdout.trim();
} catch (e: unknown) { } catch (e: unknown) {
debugLogger.debug( 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)}`,
); );
} }
+5 -1
View File
@@ -26,6 +26,7 @@ import {
loadAgentsFromDirectory, loadAgentsFromDirectory,
loadSkillsFromDir, loadSkillsFromDir,
getRealPath, getRealPath,
normalizePath,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { import {
loadSettings, loadSettings,
@@ -1420,6 +1421,7 @@ name = "yolo-checker"
'.gemini', '.gemini',
'trustedFolders.json', 'trustedFolders.json',
); );
vi.stubEnv('GEMINI_CLI_TRUSTED_FOLDERS_PATH', trustedFoldersPath);
vi.mocked(isWorkspaceTrusted).mockReturnValue({ vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false, isTrusted: false,
source: undefined, source: undefined,
@@ -1438,7 +1440,9 @@ name = "yolo-checker"
const trustedFolders = JSON.parse( const trustedFolders = JSON.parse(
fs.readFileSync(trustedFoldersPath, 'utf-8'), fs.readFileSync(trustedFoldersPath, 'utf-8'),
); );
expect(trustedFolders[tempWorkspaceDir]).toBe('TRUST_FOLDER'); expect(trustedFolders[normalizePath(tempWorkspaceDir)]).toBe(
'TRUST_FOLDER',
);
}); });
describe.each([true, false])( describe.each([true, false])(
+12 -6
View File
@@ -1912,6 +1912,9 @@ describe('Settings Loading and Merging', () => {
const geminiEnvPath = path.resolve( const geminiEnvPath = path.resolve(
path.join(MOCK_WORKSPACE_DIR, GEMINI_DIR, '.env'), path.join(MOCK_WORKSPACE_DIR, GEMINI_DIR, '.env'),
); );
const workspaceEnvPath = path.resolve(
path.join(MOCK_WORKSPACE_DIR, '.env'),
);
vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({ vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({
isTrusted: isWorkspaceTrustedValue, isTrusted: isWorkspaceTrustedValue,
@@ -1919,9 +1922,11 @@ describe('Settings Loading and Merging', () => {
}); });
(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) => { (mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) => {
const normalizedP = path.resolve(p.toString()); const normalizedP = path.resolve(p.toString());
return [path.resolve(USER_SETTINGS_PATH), geminiEnvPath].includes( return [
normalizedP, path.resolve(USER_SETTINGS_PATH),
); geminiEnvPath,
workspaceEnvPath,
].includes(normalizedP);
}); });
const userSettingsContent: Settings = { const userSettingsContent: Settings = {
ui: { ui: {
@@ -1941,7 +1946,7 @@ describe('Settings Loading and Merging', () => {
const normalizedP = path.resolve(p.toString()); const normalizedP = path.resolve(p.toString());
if (normalizedP === path.resolve(USER_SETTINGS_PATH)) if (normalizedP === path.resolve(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent); return JSON.stringify(userSettingsContent);
if (normalizedP === geminiEnvPath) if (normalizedP === geminiEnvPath || normalizedP === workspaceEnvPath)
return 'TESTTEST=1234\nGEMINI_API_KEY=test-key'; return 'TESTTEST=1234\nGEMINI_API_KEY=test-key';
return '{}'; return '{}';
}, },
@@ -1970,7 +1975,7 @@ describe('Settings Loading and Merging', () => {
expect(process.env['TESTTEST']).not.toEqual('1234'); 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 }); setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
const settings = { const settings = {
security: { folderTrust: { enabled: true } }, security: { folderTrust: { enabled: true } },
@@ -1978,7 +1983,8 @@ describe('Settings Loading and Merging', () => {
} as Settings; } as Settings;
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted); 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', () => { it('does not load env files when trust is undefined and sandboxed', () => {
+15 -11
View File
@@ -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); let currentDir = path.resolve(startDir);
while (true) { while (true) {
// prefer gemini-specific .env under GEMINI_DIR // prefer gemini-specific .env under GEMINI_DIR
const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env'); if (isTrusted) {
if (fs.existsSync(geminiEnvPath)) { const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env');
return geminiEnvPath; if (fs.existsSync(geminiEnvPath)) {
return geminiEnvPath;
}
} }
const envPath = path.join(currentDir, '.env'); const envPath = path.join(currentDir, '.env');
if (fs.existsSync(envPath)) { if (fs.existsSync(envPath)) {
@@ -514,9 +516,11 @@ function findEnvFile(startDir: string): string | null {
const parentDir = path.dirname(currentDir); const parentDir = path.dirname(currentDir);
if (parentDir === currentDir || !parentDir) { if (parentDir === currentDir || !parentDir) {
// check .env under home as fallback, again preferring gemini-specific .env // check .env under home as fallback, again preferring gemini-specific .env
const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env'); if (isTrusted) {
if (fs.existsSync(homeGeminiEnvPath)) { const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env');
return homeGeminiEnvPath; if (fs.existsSync(homeGeminiEnvPath)) {
return homeGeminiEnvPath;
}
} }
const homeEnvPath = path.join(homedir(), '.env'); const homeEnvPath = path.join(homedir(), '.env');
if (fs.existsSync(homeEnvPath)) { if (fs.existsSync(homeEnvPath)) {
@@ -559,10 +563,10 @@ export function loadEnvironment(
workspaceDir: string, workspaceDir: string,
isWorkspaceTrustedFn = isWorkspaceTrusted, isWorkspaceTrustedFn = isWorkspaceTrusted,
): void { ): void {
const envFilePath = findEnvFile(workspaceDir);
const trustResult = isWorkspaceTrustedFn(settings, workspaceDir); const trustResult = isWorkspaceTrustedFn(settings, workspaceDir);
const isTrusted = trustResult.isTrusted ?? false; const isTrusted = trustResult.isTrusted ?? false;
const envFilePath = findEnvFile(workspaceDir, isTrusted);
// Check settings OR check process.argv directly since this might be called // Check settings OR check process.argv directly since this might be called
// before arguments are fully parsed. This is a best-effort sniffing approach // 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 // 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) { for (const key in parsedEnv) {
if (Object.hasOwn(parsedEnv, key)) { if (Object.hasOwn(parsedEnv, key)) {
let value = parsedEnv[key]; let value = parsedEnv[key];
// If the workspace is untrusted but we are sandboxed, only allow whitelisted variables. // If the workspace is untrusted, only allow whitelisted variables.
if (!isTrusted && isSandboxed) { if (!isTrusted) {
if (!AUTH_ENV_VAR_WHITELIST.includes(key)) { if (!AUTH_ENV_VAR_WHITELIST.includes(key)) {
continue; continue;
} }
+49 -41
View File
@@ -11,7 +11,7 @@ import * as os from 'node:os';
import { import {
FatalConfigError, FatalConfigError,
ideContextStore, ideContextStore,
coreEvents, normalizePath,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { import {
loadTrustedFolders, loadTrustedFolders,
@@ -32,9 +32,14 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
...actual, ...actual,
homedir: () => '/mock/home/user', homedir: () => '/mock/home/user',
isHeadlessMode: vi.fn(() => false), isHeadlessMode: vi.fn(() => false),
coreEvents: { coreEvents: Object.assign(
emitFeedback: vi.fn(), 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 // Reset the internal state
resetTrustedFoldersForTesting(); resetTrustedFoldersForTesting();
vi.clearAllMocks(); vi.clearAllMocks();
delete process.env['GEMINI_CLI_TRUST_WORKSPACE'];
}); });
afterEach(() => { afterEach(() => {
@@ -70,8 +76,14 @@ describe('Trusted Folders', () => {
// Start two concurrent calls // Start two concurrent calls
// These will race to acquire the lock on the real file system // These will race to acquire the lock on the real file system
const p1 = loadedFolders.setValue('/path1', TrustLevel.TRUST_FOLDER); const p1 = loadedFolders.setValue(
const p2 = loadedFolders.setValue('/path2', TrustLevel.TRUST_FOLDER); path.resolve('/path1'),
TrustLevel.TRUST_FOLDER,
);
const p2 = loadedFolders.setValue(
path.resolve('/path2'),
TrustLevel.TRUST_FOLDER,
);
await Promise.all([p1, p2]); await Promise.all([p1, p2]);
@@ -80,8 +92,8 @@ describe('Trusted Folders', () => {
const config = JSON.parse(content); const config = JSON.parse(content);
expect(config).toEqual({ expect(config).toEqual({
'/path1': TrustLevel.TRUST_FOLDER, [normalizePath('/path1')]: TrustLevel.TRUST_FOLDER,
'/path2': TrustLevel.TRUST_FOLDER, [normalizePath('/path2')]: TrustLevel.TRUST_FOLDER,
}); });
}); });
}); });
@@ -95,13 +107,16 @@ describe('Trusted Folders', () => {
it('should load rules from the configuration file', () => { it('should load rules from the configuration file', () => {
const config = { const config = {
'/user/folder': TrustLevel.TRUST_FOLDER, [normalizePath('/user/folder')]: TrustLevel.TRUST_FOLDER,
}; };
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8');
const { rules, errors } = loadTrustedFolders(); const { rules, errors } = loadTrustedFolders();
expect(rules).toEqual([ expect(rules).toEqual([
{ path: '/user/folder', trustLevel: TrustLevel.TRUST_FOLDER }, {
path: normalizePath('/user/folder'),
trustLevel: TrustLevel.TRUST_FOLDER,
},
]); ]);
expect(errors).toEqual([]); expect(errors).toEqual([]);
}); });
@@ -143,14 +158,14 @@ describe('Trusted Folders', () => {
const content = ` const content = `
{ {
// This is a comment // This is a comment
"/path": "TRUST_FOLDER" "${normalizePath('/path').replaceAll('\\', '\\\\')}": "TRUST_FOLDER"
} }
`; `;
fs.writeFileSync(trustedFoldersPath, content, 'utf-8'); fs.writeFileSync(trustedFoldersPath, content, 'utf-8');
const { rules, errors } = loadTrustedFolders(); const { rules, errors } = loadTrustedFolders();
expect(rules).toEqual([ expect(rules).toEqual([
{ path: '/path', trustLevel: TrustLevel.TRUST_FOLDER }, { path: normalizePath('/path'), trustLevel: TrustLevel.TRUST_FOLDER },
]); ]);
expect(errors).toEqual([]); expect(errors).toEqual([]);
}); });
@@ -216,15 +231,18 @@ describe('Trusted Folders', () => {
fs.writeFileSync(trustedFoldersPath, '{}', 'utf-8'); fs.writeFileSync(trustedFoldersPath, '{}', 'utf-8');
const loadedFolders = loadTrustedFolders(); 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, TrustLevel.TRUST_FOLDER,
); );
const content = fs.readFileSync(trustedFoldersPath, 'utf-8'); const content = fs.readFileSync(trustedFoldersPath, 'utf-8');
const config = JSON.parse(content); 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 () => { it('should throw FatalConfigError if there were load errors', async () => {
@@ -237,28 +255,6 @@ describe('Trusted Folders', () => {
loadedFolders.setValue('/some/path', TrustLevel.TRUST_FOLDER), loadedFolders.setValue('/some/path', TrustLevel.TRUST_FOLDER),
).rejects.toThrow(FatalConfigError); ).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', () => { 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'); const geminiCore = await import('@google/gemini-cli-core');
vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(true); vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(true);
expect(isWorkspaceTrusted(mockSettings)).toEqual({ expect(isWorkspaceTrusted(mockSettings)).toEqual({
isTrusted: true, isTrusted: undefined,
source: 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 () => { it('should fall back to config when isHeadlessMode is false', async () => {
const geminiCore = await import('@google/gemini-cli-core'); const geminiCore = await import('@google/gemini-cli-core');
vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(false); 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'); const geminiCore = await import('@google/gemini-cli-core');
vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(true); vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(true);
const folders = loadTrustedFolders(); const folders = loadTrustedFolders();
expect(folders.isPathTrusted('/any-untrusted-path')).toBe(true); expect(folders.isPathTrusted('/any-untrusted-path')).toBe(undefined);
}); });
}); });
+31 -365
View File
@@ -4,330 +4,29 @@
* SPDX-License-Identifier: Apache-2.0 * 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 { import {
FatalConfigError,
getErrorMessage,
isWithinRoot,
ideContextStore,
GEMINI_DIR,
homedir,
isHeadlessMode,
coreEvents,
type HeadlessModeOptions, type HeadlessModeOptions,
checkPathTrust,
isHeadlessMode,
loadTrustedFolders as loadCoreTrustedFolders,
type LoadedTrustedFolders,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import type { Settings } from './settings.js'; 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 type {
TrustRule,
export function getUserSettingsDir(): string { TrustedFoldersError,
return path.join(homedir(), GEMINI_DIR); TrustedFoldersFile,
} TrustResult,
LoadedTrustedFolders,
export function getTrustedFoldersPath(): string { } from '@google/gemini-cli-core';
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<string, TrustLevel>;
path: string;
}
export interface TrustResult {
isTrusted: boolean | undefined;
source: 'ide' | 'file' | undefined;
}
const realPathCache = new Map<string, string>();
/**
* 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<string, TrustLevel>,
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<void> {
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<string, TrustLevel>;
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
config = parseTrustedFoldersJson(content) as Record<string, TrustLevel>;
} 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<string, TrustLevel> = {};
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<string, string>;
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;
}
}
/** Is folder trust feature enabled per the current applied settings */ /** Is folder trust feature enabled per the current applied settings */
export function isFolderTrustEnabled(settings: Settings): boolean { export function isFolderTrustEnabled(settings: Settings): boolean {
@@ -335,57 +34,24 @@ export function isFolderTrustEnabled(settings: Settings): boolean {
return folderTrustSetting; return folderTrustSetting;
} }
function getWorkspaceTrustFromLocalConfig( export function loadTrustedFolders(): LoadedTrustedFolders {
workspaceDir: string, return loadCoreTrustedFolders();
trustConfig?: Record<string, TrustLevel>,
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,
};
} }
/**
* Returns true or false if the workspace is considered "trusted".
*/
export function isWorkspaceTrusted( export function isWorkspaceTrusted(
settings: Settings, settings: Settings,
workspaceDir: string = process.cwd(), workspaceDir: string = process.cwd(),
trustConfig?: Record<string, TrustLevel>,
headlessOptions?: HeadlessModeOptions, headlessOptions?: HeadlessModeOptions,
): TrustResult { ): {
if (isHeadlessMode(headlessOptions)) { isTrusted: boolean | undefined;
return { isTrusted: true, source: undefined }; source: 'ide' | 'file' | 'env' | undefined;
} } {
return checkPathTrust({
if (!isFolderTrustEnabled(settings)) { path: workspaceDir,
return { isTrusted: true, source: undefined }; isFolderTrustEnabled: isFolderTrustEnabled(settings),
} isHeadless: isHeadlessMode(headlessOptions),
});
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,
);
} }
+3
View File
@@ -280,6 +280,7 @@ describe('gemini.tsx main function', () => {
vi.stubEnv('GEMINI_SANDBOX', ''); vi.stubEnv('GEMINI_SANDBOX', '');
vi.stubEnv('SANDBOX', ''); vi.stubEnv('SANDBOX', '');
vi.stubEnv('SHPOOL_SESSION_NAME', ''); vi.stubEnv('SHPOOL_SESSION_NAME', '');
vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'true');
initialUnhandledRejectionListeners = initialUnhandledRejectionListeners =
process.listeners('unhandledRejection'); process.listeners('unhandledRejection');
@@ -555,6 +556,7 @@ describe('gemini.tsx main function kitty protocol', () => {
rawOutput: undefined, rawOutput: undefined,
acceptRawOutputRisk: undefined, acceptRawOutputRisk: undefined,
isCommand: undefined, isCommand: undefined,
skipTrust: undefined,
}); });
await act(async () => { await act(async () => {
@@ -613,6 +615,7 @@ describe('gemini.tsx main function kitty protocol', () => {
rawOutput: undefined, rawOutput: undefined,
acceptRawOutputRisk: undefined, acceptRawOutputRisk: undefined,
isCommand: undefined, isCommand: undefined,
skipTrust: undefined,
}); });
await act(async () => { await act(async () => {
+6
View File
@@ -661,6 +661,12 @@ export async function main() {
cliStartupHandle?.end(); 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. // Render UI, passing necessary config values. Check that there is no command line question.
if (config.isInteractive()) { if (config.isInteractive()) {
// Earlier initialization phases (like TerminalCapabilityManager resolving // Earlier initialization phases (like TerminalCapabilityManager resolving
+1
View File
@@ -181,6 +181,7 @@ describe('gemini.tsx main function cleanup', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true'; process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true';
vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'true');
}); });
afterEach(() => { afterEach(() => {
@@ -34,6 +34,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
...actual, ...actual,
homedir: () => os.homedir(), homedir: () => os.homedir(),
getCompatibilityWarnings: vi.fn().mockReturnValue([]), getCompatibilityWarnings: vi.fn().mockReturnValue([]),
isHeadlessMode: vi.fn().mockReturnValue(false),
WarningPriority: { WarningPriority: {
Low: 'low', Low: 'low',
High: 'high', 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', () => { describe('compatibility warnings', () => {
it('should include compatibility warnings by default', async () => { it('should include compatibility warnings by default', async () => {
const compWarning = { const compWarning = {
@@ -12,6 +12,8 @@ import {
getCompatibilityWarnings, getCompatibilityWarnings,
WarningPriority, WarningPriority,
type StartupWarning, type StartupWarning,
isHeadlessMode,
FatalUntrustedWorkspaceError,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import type { Settings } from '../config/settingsSchema.js'; import type { Settings } from '../config/settingsSchema.js';
import { 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 // All warning checks
const WARNING_CHECKS: readonly WarningCheck[] = [ const WARNING_CHECKS: readonly WarningCheck[] = [
homeDirectoryCheck, homeDirectoryCheck,
rootDirectoryCheck, rootDirectoryCheck,
folderTrustCheck,
]; ];
export async function getUserStartupWarnings( export async function getUserStartupWarnings(
+8
View File
@@ -20,6 +20,7 @@ import { ProjectRegistry } from './projectRegistry.js';
import { StorageMigration } from './storageMigration.js'; import { StorageMigration } from './storageMigration.js';
export const OAUTH_FILE = 'oauth_creds.json'; export const OAUTH_FILE = 'oauth_creds.json';
export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json';
const TMP_DIR_NAME = 'tmp'; const TMP_DIR_NAME = 'tmp';
const BIN_DIR_NAME = 'bin'; const BIN_DIR_NAME = 'bin';
const AGENTS_DIR_NAME = '.agents'; const AGENTS_DIR_NAME = '.agents';
@@ -86,6 +87,13 @@ export class Storage {
return path.join(Storage.getGlobalGeminiDir(), GOOGLE_ACCOUNTS_FILENAME); 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 { static getUserCommandsDir(): string {
return path.join(Storage.getGlobalGeminiDir(), 'commands'); return path.join(Storage.getGlobalGeminiDir(), 'commands');
} }
+3
View File
@@ -294,3 +294,6 @@ export type { Content, Part, FunctionCall } from '@google/genai';
// Export context types and profiles // Export context types and profiles
export * from './context/types.js'; export * from './context/types.js';
export * from './context/profiles.js'; export * from './context/profiles.js';
// Export trust utility
export * from './utils/trust.js';
+6
View File
@@ -114,6 +114,12 @@ export class FatalToolExecutionError extends FatalError {
this.name = 'FatalToolExecutionError'; this.name = 'FatalToolExecutionError';
} }
} }
export class FatalUntrustedWorkspaceError extends FatalError {
constructor(message: string) {
super(message, 55);
this.name = 'FatalUntrustedWorkspaceError';
}
}
export class FatalCancellationError extends FatalError { export class FatalCancellationError extends FatalError {
constructor(message: string) { constructor(message: string) {
super(message, 130); // Standard exit code for SIGINT super(message, 130); // Standard exit code for SIGINT
+1
View File
@@ -12,6 +12,7 @@ import { fileURLToPath } from 'node:url';
export const GEMINI_DIR = '.gemini'; export const GEMINI_DIR = '.gemini';
export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json'; export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json';
export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json';
/** /**
* Returns the home directory. * Returns the home directory.
+207
View File
@@ -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<typeof import('./headless.js')>();
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),
);
});
});
});
+356
View File
@@ -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<string, TrustLevel>;
path: string;
}
const realPathCache = new Map<string, string>();
/**
* 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<string, unknown> {
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<string, TrustLevel>,
): 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<void> {
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<string, TrustLevel> = {};
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<string, TrustLevel> = {};
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;
}
}