mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-25 04:24:51 -07:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.** |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Generated
+32
-53
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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])(
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user