mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-28 02:31:05 -07:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d5bdc0525 | |||
| cefbd08f7a | |||
| 7fd336f5fc | |||
| 5116b5ac05 | |||
| 31bdf112d2 | |||
| 8482eccf5d | |||
| 61c6a2adf8 | |||
| c48c419f3b | |||
| a9b630f808 | |||
| ee7e037c93 |
@@ -28,6 +28,7 @@ runs:
|
||||
- name: 'Run Tests'
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ inputs.gemini_api_key }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
run: |-
|
||||
echo "::group::Build"
|
||||
|
||||
@@ -98,6 +98,7 @@ runs:
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ inputs.gemini_api_key }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
INTEGRATION_TEST_USE_INSTALLED_GEMINI: 'true'
|
||||
# We must diable CI mode here because it interferes with interactive tests.
|
||||
# See https://github.com/google-gemini/gemini-cli/issues/10517
|
||||
|
||||
@@ -167,6 +167,7 @@ jobs:
|
||||
- name: 'Run E2E tests'
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
KEEP_OUTPUT: 'true'
|
||||
VERBOSE: 'true'
|
||||
BUILD_SANDBOX_FLAGS: '--cache-from type=gha --cache-to type=gha,mode=max'
|
||||
@@ -212,6 +213,7 @@ jobs:
|
||||
if: "${{runner.os != 'Windows'}}"
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
KEEP_OUTPUT: 'true'
|
||||
SANDBOX: 'sandbox:none'
|
||||
VERBOSE: 'true'
|
||||
@@ -288,6 +290,7 @@ jobs:
|
||||
- name: 'Run E2E tests'
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
KEEP_OUTPUT: 'true'
|
||||
SANDBOX: 'sandbox:none'
|
||||
VERBOSE: 'true'
|
||||
|
||||
@@ -179,6 +179,7 @@ jobs:
|
||||
- name: 'Run tests and generate reports'
|
||||
env:
|
||||
NO_COLOR: true
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
run: |
|
||||
if [[ "${{ matrix.shard }}" == "cli" ]]; then
|
||||
npm run test:ci --workspace "@google/gemini-cli"
|
||||
@@ -267,6 +268,7 @@ jobs:
|
||||
- name: 'Run tests and generate reports'
|
||||
env:
|
||||
NO_COLOR: true
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
run: |
|
||||
if [[ "${{ matrix.shard }}" == "cli" ]]; then
|
||||
npm run test:ci --workspace "@google/gemini-cli" -- --coverage.enabled=false
|
||||
@@ -430,6 +432,7 @@ jobs:
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
NO_COLOR: true
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
NODE_OPTIONS: '--max-old-space-size=32768 --max-semi-space-size=256'
|
||||
UV_THREADPOOL_SIZE: '32'
|
||||
NODE_ENV: 'test'
|
||||
|
||||
@@ -62,6 +62,7 @@ jobs:
|
||||
- name: 'Run E2E tests'
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
IS_DOCKER: "${{ matrix.sandbox == 'sandbox:docker' }}"
|
||||
KEEP_OUTPUT: 'true'
|
||||
RUNS: '${{ github.event.inputs.runs }}'
|
||||
@@ -105,6 +106,7 @@ jobs:
|
||||
if: "runner.os != 'Windows'"
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
KEEP_OUTPUT: 'true'
|
||||
RUNS: '${{ github.event.inputs.runs }}'
|
||||
SANDBOX: 'sandbox:none'
|
||||
@@ -159,6 +161,7 @@ jobs:
|
||||
- name: 'Run E2E tests'
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
KEEP_OUTPUT: 'true'
|
||||
SANDBOX: 'sandbox:none'
|
||||
VERBOSE: 'true'
|
||||
|
||||
@@ -141,6 +141,7 @@ jobs:
|
||||
if: "github.event_name != 'pull_request'"
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
run: |
|
||||
echo "Running integration tests with binary..."
|
||||
if [[ "${{ matrix.os }}" == 'windows-latest' ]]; then
|
||||
|
||||
@@ -52,6 +52,7 @@ These commands are available within the interactive REPL.
|
||||
| `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode |
|
||||
| `--worktree` | `-w` | string | - | Start Gemini in a new git worktree. If no name is provided, one is generated automatically. Requires `experimental.worktrees: true` in settings. |
|
||||
| `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution |
|
||||
| `--skip-trust` | - | boolean | `false` | Trust the current workspace for this session, skipping the folder trust check. |
|
||||
| `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo`, `plan` |
|
||||
| `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. |
|
||||
| `--experimental-acp` | - | boolean | - | Start in ACP (Agent Code Pilot) mode. **Experimental feature.** |
|
||||
|
||||
@@ -100,6 +100,34 @@ protect you. In this mode, the following features are disabled:
|
||||
Granting trust to a folder unlocks the full functionality of Gemini CLI for that
|
||||
workspace.
|
||||
|
||||
## Headless and automated environments
|
||||
|
||||
When running Gemini CLI in a headless environment (for example, a CI/CD
|
||||
pipeline) where interactive prompts are not possible, the trust dialog cannot be
|
||||
displayed. If the folder is untrusted and the Folder Trust feature is enabled,
|
||||
the CLI will throw a `FatalUntrustedWorkspaceError` and exit.
|
||||
|
||||
To proceed in these environments, you can bypass the trust check using one of
|
||||
the following methods:
|
||||
|
||||
- **Command-line flag:** Run the CLI with the `--skip-trust` flag.
|
||||
- **Environment variable:** Set the `GEMINI_CLI_TRUST_WORKSPACE=true`
|
||||
environment variable.
|
||||
|
||||
These methods will trust the current workspace for the duration of the session
|
||||
without prompting.
|
||||
|
||||
For detailed instructions on managing folder trust within CI/CD workflows,
|
||||
review the
|
||||
[Gemini CLI trust guidance for GitHub Actions](https://github.com/google-github-actions/run-gemini-cli/blob/main/docs/trust-guidance.md).
|
||||
|
||||
## Overriding the trust file location
|
||||
|
||||
By default, trust settings are saved to `~/.gemini/trustedFolders.json`. If you
|
||||
need to store this file in a different location, you can set the
|
||||
`GEMINI_CLI_TRUSTED_FOLDERS_PATH` environment variable to the desired absolute
|
||||
file path.
|
||||
|
||||
## Managing your trust settings
|
||||
|
||||
If you need to change a decision or see all your settings, you have a couple of
|
||||
|
||||
@@ -1151,7 +1151,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
},
|
||||
"stateTransitions": {
|
||||
"terminal": "terminal",
|
||||
"transient": "terminal",
|
||||
"transient": "sticky_retry",
|
||||
"not_found": "terminal",
|
||||
"unknown": "terminal"
|
||||
}
|
||||
@@ -1167,7 +1167,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
},
|
||||
"stateTransitions": {
|
||||
"terminal": "terminal",
|
||||
"transient": "terminal",
|
||||
"transient": "sticky_retry",
|
||||
"not_found": "terminal",
|
||||
"unknown": "terminal"
|
||||
}
|
||||
@@ -1184,7 +1184,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
},
|
||||
"stateTransitions": {
|
||||
"terminal": "terminal",
|
||||
"transient": "terminal",
|
||||
"transient": "sticky_retry",
|
||||
"not_found": "terminal",
|
||||
"unknown": "terminal"
|
||||
}
|
||||
@@ -1200,7 +1200,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
},
|
||||
"stateTransitions": {
|
||||
"terminal": "terminal",
|
||||
"transient": "terminal",
|
||||
"transient": "sticky_retry",
|
||||
"not_found": "terminal",
|
||||
"unknown": "terminal"
|
||||
}
|
||||
@@ -1217,7 +1217,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
},
|
||||
"stateTransitions": {
|
||||
"terminal": "terminal",
|
||||
"transient": "terminal",
|
||||
"transient": "sticky_retry",
|
||||
"not_found": "terminal",
|
||||
"unknown": "terminal"
|
||||
}
|
||||
@@ -1232,7 +1232,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
},
|
||||
"stateTransitions": {
|
||||
"terminal": "terminal",
|
||||
"transient": "terminal",
|
||||
"transient": "sticky_retry",
|
||||
"not_found": "terminal",
|
||||
"unknown": "terminal"
|
||||
}
|
||||
@@ -1248,7 +1248,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
},
|
||||
"stateTransitions": {
|
||||
"terminal": "terminal",
|
||||
"transient": "terminal",
|
||||
"transient": "sticky_retry",
|
||||
"not_found": "terminal",
|
||||
"unknown": "terminal"
|
||||
}
|
||||
@@ -1467,6 +1467,12 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `undefined`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`tools.confirmationRequired`** (array):
|
||||
- **Description:** Tool names that always require user confirmation. Takes
|
||||
precedence over allowed tools and core tool allowlists.
|
||||
- **Default:** `undefined`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`tools.exclude`** (array):
|
||||
- **Description:** Tool names to exclude from discovery.
|
||||
- **Default:** `undefined`
|
||||
@@ -2156,6 +2162,14 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
|
||||
- Overrides the hardcoded default
|
||||
- Example: `export GEMINI_MODEL="gemini-3-flash-preview"` (Windows PowerShell:
|
||||
`$env:GEMINI_MODEL="gemini-3-flash-preview"`)
|
||||
- **`GEMINI_CLI_TRUST_WORKSPACE`**:
|
||||
- If set to `"true"`, trusts the current workspace for the duration of the
|
||||
session, bypassing the folder trust check.
|
||||
- Useful for headless environments (for example, CI/CD pipelines).
|
||||
- **`GEMINI_CLI_TRUSTED_FOLDERS_PATH`**:
|
||||
- Overrides the default location for the `trustedFolders.json` file.
|
||||
- Useful if you want to store this configuration in a custom location instead
|
||||
of the default `~/.gemini/`.
|
||||
- **`GEMINI_CLI_IDE_PID`**:
|
||||
- Manually specifies the PID of the IDE process to use for integration. This
|
||||
is useful when running Gemini CLI in a standalone terminal while still
|
||||
|
||||
@@ -18,6 +18,11 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingFavoriteColor,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
|
||||
prompt: `remember that my favorite color is blue.
|
||||
|
||||
@@ -40,6 +45,11 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingCommandRestrictions,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
|
||||
prompt: `I don't want you to ever run npm commands.`,
|
||||
assert: async (rig, result) => {
|
||||
@@ -61,6 +71,11 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingWorkflow,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
|
||||
prompt: `I want you to always lint after building.`,
|
||||
assert: async (rig, result) => {
|
||||
@@ -83,6 +98,11 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: ignoringTemporaryInformation,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
|
||||
prompt: `I'm going to get a coffee.`,
|
||||
assert: async (rig, result) => {
|
||||
@@ -108,6 +128,11 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingPetName,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
|
||||
prompt: `Please remember that my dog's name is Buddy.`,
|
||||
assert: async (rig, result) => {
|
||||
@@ -129,6 +154,11 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingCommandAlias,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
|
||||
prompt: `When I say 'start server', you should run 'npm run dev'.`,
|
||||
assert: async (rig, result) => {
|
||||
@@ -151,6 +181,11 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: savingDbSchemaLocationAsProjectMemory,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
prompt: `The database schema for this workspace is located in \`db/schema.sql\`.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall(
|
||||
@@ -180,6 +215,11 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingCodingStyle,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
|
||||
prompt: `I prefer to use tabs instead of spaces for indentation.`,
|
||||
assert: async (rig, result) => {
|
||||
@@ -202,6 +242,11 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: savingBuildArtifactLocationAsProjectMemory,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
prompt: `In this workspace, build artifacts are stored in the \`dist/artifacts\` directory.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall(
|
||||
@@ -231,6 +276,11 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: savingMainEntryPointAsProjectMemory,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
prompt: `The main entry point for this workspace is \`src/index.js\`.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall(
|
||||
@@ -259,6 +309,11 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingBirthday,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
|
||||
prompt: `My birthday is on June 15th.`,
|
||||
assert: async (rig, result) => {
|
||||
|
||||
@@ -172,6 +172,7 @@ export async function internalEvalTest(evalCase: EvalCase) {
|
||||
timeout: evalCase.timeout,
|
||||
env: {
|
||||
GEMINI_CLI_ACTIVITY_LOG_TARGET: activityLogFile,
|
||||
GEMINI_CLI_TRUST_WORKSPACE: 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Generated
+9
-59
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.40.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.40.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -4593,56 +4593,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.47.0.tgz",
|
||||
"integrity": "sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.47.0",
|
||||
"@typescript-eslint/visitor-keys": "8.47.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/types": {
|
||||
"version": "8.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.47.0.tgz",
|
||||
"integrity": "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.47.0.tgz",
|
||||
"integrity": "sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.47.0",
|
||||
"eslint-visitor-keys": "^4.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
||||
@@ -17742,7 +17692,7 @@
|
||||
},
|
||||
"packages/a2a-server": {
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.40.0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
"@google-cloud/storage": "^7.16.0",
|
||||
@@ -17871,7 +17821,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.40.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.16.1",
|
||||
@@ -18019,7 +17969,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.40.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
@@ -18329,7 +18279,7 @@
|
||||
},
|
||||
"packages/devtools": {
|
||||
"name": "@google/gemini-cli-devtools",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.40.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"ws": "^8.16.0"
|
||||
@@ -18344,7 +18294,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@google/gemini-cli-sdk",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.40.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -18375,7 +18325,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.40.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -18407,7 +18357,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.40.0",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.40.0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -14,7 +14,7 @@
|
||||
"url": "git+https://github.com/google-gemini/gemini-cli.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.40.0-nightly.20260414.g5b1f7375a"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.40.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.40.0",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.40.0",
|
||||
"description": "Gemini CLI",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -27,7 +27,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.40.0-nightly.20260414.g5b1f7375a"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.40.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.16.1",
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
FileDiscoveryService,
|
||||
resolveTelemetrySettings,
|
||||
FatalConfigError,
|
||||
getErrorMessage,
|
||||
getPty,
|
||||
debugLogger,
|
||||
loadServerHierarchicalMemory,
|
||||
@@ -60,6 +61,7 @@ import {
|
||||
|
||||
import { loadSandboxConfig } from './sandboxConfig.js';
|
||||
import { resolvePath } from '../utils/resolvePath.js';
|
||||
import { isRecord } from '../utils/settingsUtils.js';
|
||||
import { RESUME_LATEST } from '../utils/sessionUtils.js';
|
||||
|
||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
@@ -106,6 +108,7 @@ export interface CliArgs {
|
||||
startupMessages?: string[];
|
||||
rawOutput: boolean | undefined;
|
||||
acceptRawOutputRisk: boolean | undefined;
|
||||
skipTrust: boolean | undefined;
|
||||
isCommand: boolean | undefined;
|
||||
}
|
||||
|
||||
@@ -291,6 +294,11 @@ export async function parseArguments(
|
||||
description:
|
||||
'Execute the provided prompt and continue in interactive mode',
|
||||
})
|
||||
.option('skip-trust', {
|
||||
type: 'boolean',
|
||||
description: 'Trust the current workspace for this session.',
|
||||
default: false,
|
||||
})
|
||||
.option('worktree', {
|
||||
alias: 'w',
|
||||
type: 'string',
|
||||
@@ -459,9 +467,16 @@ export async function parseArguments(
|
||||
yargsInstance.wrap(yargsInstance.terminalWidth());
|
||||
let result;
|
||||
try {
|
||||
result = await yargsInstance.parse();
|
||||
const parsed = await yargsInstance.parse();
|
||||
if (!isRecord(parsed)) {
|
||||
throw new Error('Failed to parse arguments');
|
||||
}
|
||||
result = parsed;
|
||||
if (result['skip-trust']) {
|
||||
process.env['GEMINI_CLI_TRUST_WORKSPACE'] = 'true';
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
const msg = getErrorMessage(e);
|
||||
debugLogger.error(msg);
|
||||
yargsInstance.showHelp();
|
||||
await runExitCleanup();
|
||||
@@ -475,11 +490,13 @@ export async function parseArguments(
|
||||
}
|
||||
|
||||
// Normalize query args: handle both quoted "@path file" and unquoted @path file
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const queryArg = (result as { query?: string | string[] | undefined }).query;
|
||||
const q: string | undefined = Array.isArray(queryArg)
|
||||
? queryArg.join(' ')
|
||||
: queryArg;
|
||||
const queryArg = result['query'];
|
||||
let q: string | undefined;
|
||||
if (Array.isArray(queryArg)) {
|
||||
q = queryArg.join(' ');
|
||||
} else if (typeof queryArg === 'string') {
|
||||
q = queryArg;
|
||||
}
|
||||
|
||||
// -p/--prompt forces non-interactive mode; positional args default to interactive in TTY
|
||||
if (q && !result['prompt']) {
|
||||
@@ -494,8 +511,8 @@ export async function parseArguments(
|
||||
}
|
||||
|
||||
// Keep CliArgs.query as a string for downstream typing
|
||||
(result as Record<string, unknown>)['query'] = q || undefined;
|
||||
(result as Record<string, unknown>)['startupMessages'] = startupMessages;
|
||||
result['query'] = q || undefined;
|
||||
result['startupMessages'] = startupMessages;
|
||||
|
||||
// The import format is now only controlled by settings.memoryImportFormat
|
||||
// We no longer accept it as a CLI argument
|
||||
@@ -547,7 +564,7 @@ export async function loadCliConfig(
|
||||
? false
|
||||
: (settings.security?.folderTrust?.enabled ?? false);
|
||||
const trustedFolder =
|
||||
isWorkspaceTrusted(settings, cwd, undefined, {
|
||||
isWorkspaceTrusted(settings, cwd, {
|
||||
prompt: argv.prompt,
|
||||
query: argv.query,
|
||||
})?.isTrusted ?? false;
|
||||
@@ -593,7 +610,7 @@ export async function loadCliConfig(
|
||||
return resolveToRealPath(trimmedPath) !== realCwd;
|
||||
} catch (e) {
|
||||
debugLogger.debug(
|
||||
`[IDE] Skipping inaccessible workspace folder: ${trimmedPath} (${e instanceof Error ? e.message : String(e)})`,
|
||||
`[IDE] Skipping inaccessible workspace folder: ${trimmedPath} (${getErrorMessage(e)})`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -1099,7 +1116,7 @@ async function resolveWorktreeSettings(
|
||||
worktreeBaseSha = stdout.trim();
|
||||
} catch (e: unknown) {
|
||||
debugLogger.debug(
|
||||
`Failed to resolve worktree base SHA at ${worktreePath}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
`Failed to resolve worktree base SHA at ${worktreePath}: ${getErrorMessage(e)}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
loadAgentsFromDirectory,
|
||||
loadSkillsFromDir,
|
||||
getRealPath,
|
||||
normalizePath,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
loadSettings,
|
||||
@@ -1420,6 +1421,7 @@ name = "yolo-checker"
|
||||
'.gemini',
|
||||
'trustedFolders.json',
|
||||
);
|
||||
vi.stubEnv('GEMINI_CLI_TRUSTED_FOLDERS_PATH', trustedFoldersPath);
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: undefined,
|
||||
@@ -1438,7 +1440,9 @@ name = "yolo-checker"
|
||||
const trustedFolders = JSON.parse(
|
||||
fs.readFileSync(trustedFoldersPath, 'utf-8'),
|
||||
);
|
||||
expect(trustedFolders[tempWorkspaceDir]).toBe('TRUST_FOLDER');
|
||||
expect(trustedFolders[normalizePath(tempWorkspaceDir)]).toBe(
|
||||
'TRUST_FOLDER',
|
||||
);
|
||||
});
|
||||
|
||||
describe.each([true, false])(
|
||||
|
||||
@@ -1912,6 +1912,9 @@ describe('Settings Loading and Merging', () => {
|
||||
const geminiEnvPath = path.resolve(
|
||||
path.join(MOCK_WORKSPACE_DIR, GEMINI_DIR, '.env'),
|
||||
);
|
||||
const workspaceEnvPath = path.resolve(
|
||||
path.join(MOCK_WORKSPACE_DIR, '.env'),
|
||||
);
|
||||
|
||||
vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({
|
||||
isTrusted: isWorkspaceTrustedValue,
|
||||
@@ -1919,9 +1922,11 @@ describe('Settings Loading and Merging', () => {
|
||||
});
|
||||
(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) => {
|
||||
const normalizedP = path.resolve(p.toString());
|
||||
return [path.resolve(USER_SETTINGS_PATH), geminiEnvPath].includes(
|
||||
normalizedP,
|
||||
);
|
||||
return [
|
||||
path.resolve(USER_SETTINGS_PATH),
|
||||
geminiEnvPath,
|
||||
workspaceEnvPath,
|
||||
].includes(normalizedP);
|
||||
});
|
||||
const userSettingsContent: Settings = {
|
||||
ui: {
|
||||
@@ -1941,7 +1946,7 @@ describe('Settings Loading and Merging', () => {
|
||||
const normalizedP = path.resolve(p.toString());
|
||||
if (normalizedP === path.resolve(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (normalizedP === geminiEnvPath)
|
||||
if (normalizedP === geminiEnvPath || normalizedP === workspaceEnvPath)
|
||||
return 'TESTTEST=1234\nGEMINI_API_KEY=test-key';
|
||||
return '{}';
|
||||
},
|
||||
@@ -1970,7 +1975,7 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(process.env['TESTTEST']).not.toEqual('1234');
|
||||
});
|
||||
|
||||
it('does load env files from untrusted spaces when NOT sandboxed', () => {
|
||||
it('does NOT load non-whitelisted env files from untrusted spaces even when NOT sandboxed', () => {
|
||||
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
|
||||
const settings = {
|
||||
security: { folderTrust: { enabled: true } },
|
||||
@@ -1978,7 +1983,8 @@ describe('Settings Loading and Merging', () => {
|
||||
} as Settings;
|
||||
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
|
||||
|
||||
expect(process.env['TESTTEST']).toEqual('1234');
|
||||
expect(process.env['TESTTEST']).not.toEqual('1234');
|
||||
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
|
||||
});
|
||||
|
||||
it('does not load env files when trust is undefined and sandboxed', () => {
|
||||
|
||||
@@ -499,13 +499,15 @@ export class LoadedSettings {
|
||||
}
|
||||
}
|
||||
|
||||
function findEnvFile(startDir: string): string | null {
|
||||
function findEnvFile(startDir: string, isTrusted: boolean): string | null {
|
||||
let currentDir = path.resolve(startDir);
|
||||
while (true) {
|
||||
// prefer gemini-specific .env under GEMINI_DIR
|
||||
const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env');
|
||||
if (fs.existsSync(geminiEnvPath)) {
|
||||
return geminiEnvPath;
|
||||
if (isTrusted) {
|
||||
const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env');
|
||||
if (fs.existsSync(geminiEnvPath)) {
|
||||
return geminiEnvPath;
|
||||
}
|
||||
}
|
||||
const envPath = path.join(currentDir, '.env');
|
||||
if (fs.existsSync(envPath)) {
|
||||
@@ -514,9 +516,11 @@ function findEnvFile(startDir: string): string | null {
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir || !parentDir) {
|
||||
// check .env under home as fallback, again preferring gemini-specific .env
|
||||
const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env');
|
||||
if (fs.existsSync(homeGeminiEnvPath)) {
|
||||
return homeGeminiEnvPath;
|
||||
if (isTrusted) {
|
||||
const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env');
|
||||
if (fs.existsSync(homeGeminiEnvPath)) {
|
||||
return homeGeminiEnvPath;
|
||||
}
|
||||
}
|
||||
const homeEnvPath = path.join(homedir(), '.env');
|
||||
if (fs.existsSync(homeEnvPath)) {
|
||||
@@ -559,10 +563,10 @@ export function loadEnvironment(
|
||||
workspaceDir: string,
|
||||
isWorkspaceTrustedFn = isWorkspaceTrusted,
|
||||
): void {
|
||||
const envFilePath = findEnvFile(workspaceDir);
|
||||
const trustResult = isWorkspaceTrustedFn(settings, workspaceDir);
|
||||
|
||||
const isTrusted = trustResult.isTrusted ?? false;
|
||||
const envFilePath = findEnvFile(workspaceDir, isTrusted);
|
||||
|
||||
// Check settings OR check process.argv directly since this might be called
|
||||
// before arguments are fully parsed. This is a best-effort sniffing approach
|
||||
// that happens early in the CLI lifecycle. It is designed to detect the
|
||||
@@ -597,8 +601,8 @@ export function loadEnvironment(
|
||||
for (const key in parsedEnv) {
|
||||
if (Object.hasOwn(parsedEnv, key)) {
|
||||
let value = parsedEnv[key];
|
||||
// If the workspace is untrusted but we are sandboxed, only allow whitelisted variables.
|
||||
if (!isTrusted && isSandboxed) {
|
||||
// If the workspace is untrusted, only allow whitelisted variables.
|
||||
if (!isTrusted) {
|
||||
if (!AUTH_ENV_VAR_WHITELIST.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1667,6 +1667,19 @@ const SETTINGS_SCHEMA = {
|
||||
showInDialog: false,
|
||||
items: { type: 'string' },
|
||||
},
|
||||
confirmationRequired: {
|
||||
type: 'array',
|
||||
label: 'Confirmation Required',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: undefined as string[] | undefined,
|
||||
description: oneLine`
|
||||
Tool names that always require user confirmation.
|
||||
Takes precedence over allowed tools and core tool allowlists.
|
||||
`,
|
||||
showInDialog: false,
|
||||
items: { type: 'string' },
|
||||
},
|
||||
exclude: {
|
||||
type: 'array',
|
||||
label: 'Exclude Tools',
|
||||
|
||||
@@ -11,7 +11,7 @@ import * as os from 'node:os';
|
||||
import {
|
||||
FatalConfigError,
|
||||
ideContextStore,
|
||||
coreEvents,
|
||||
normalizePath,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
loadTrustedFolders,
|
||||
@@ -32,9 +32,14 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
...actual,
|
||||
homedir: () => '/mock/home/user',
|
||||
isHeadlessMode: vi.fn(() => false),
|
||||
coreEvents: {
|
||||
emitFeedback: vi.fn(),
|
||||
},
|
||||
coreEvents: Object.assign(
|
||||
Object.create(Object.getPrototypeOf(actual.coreEvents)),
|
||||
actual.coreEvents,
|
||||
{
|
||||
emitFeedback: vi.fn(),
|
||||
},
|
||||
),
|
||||
FatalConfigError: actual.FatalConfigError,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -53,6 +58,7 @@ describe('Trusted Folders', () => {
|
||||
// Reset the internal state
|
||||
resetTrustedFoldersForTesting();
|
||||
vi.clearAllMocks();
|
||||
delete process.env['GEMINI_CLI_TRUST_WORKSPACE'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -70,8 +76,14 @@ describe('Trusted Folders', () => {
|
||||
|
||||
// Start two concurrent calls
|
||||
// These will race to acquire the lock on the real file system
|
||||
const p1 = loadedFolders.setValue('/path1', TrustLevel.TRUST_FOLDER);
|
||||
const p2 = loadedFolders.setValue('/path2', TrustLevel.TRUST_FOLDER);
|
||||
const p1 = loadedFolders.setValue(
|
||||
path.resolve('/path1'),
|
||||
TrustLevel.TRUST_FOLDER,
|
||||
);
|
||||
const p2 = loadedFolders.setValue(
|
||||
path.resolve('/path2'),
|
||||
TrustLevel.TRUST_FOLDER,
|
||||
);
|
||||
|
||||
await Promise.all([p1, p2]);
|
||||
|
||||
@@ -80,8 +92,8 @@ describe('Trusted Folders', () => {
|
||||
const config = JSON.parse(content);
|
||||
|
||||
expect(config).toEqual({
|
||||
'/path1': TrustLevel.TRUST_FOLDER,
|
||||
'/path2': TrustLevel.TRUST_FOLDER,
|
||||
[normalizePath('/path1')]: TrustLevel.TRUST_FOLDER,
|
||||
[normalizePath('/path2')]: TrustLevel.TRUST_FOLDER,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -95,13 +107,16 @@ describe('Trusted Folders', () => {
|
||||
|
||||
it('should load rules from the configuration file', () => {
|
||||
const config = {
|
||||
'/user/folder': TrustLevel.TRUST_FOLDER,
|
||||
[normalizePath('/user/folder')]: TrustLevel.TRUST_FOLDER,
|
||||
};
|
||||
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8');
|
||||
|
||||
const { rules, errors } = loadTrustedFolders();
|
||||
expect(rules).toEqual([
|
||||
{ path: '/user/folder', trustLevel: TrustLevel.TRUST_FOLDER },
|
||||
{
|
||||
path: normalizePath('/user/folder'),
|
||||
trustLevel: TrustLevel.TRUST_FOLDER,
|
||||
},
|
||||
]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
@@ -143,14 +158,14 @@ describe('Trusted Folders', () => {
|
||||
const content = `
|
||||
{
|
||||
// This is a comment
|
||||
"/path": "TRUST_FOLDER"
|
||||
"${normalizePath('/path').replaceAll('\\', '\\\\')}": "TRUST_FOLDER"
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(trustedFoldersPath, content, 'utf-8');
|
||||
|
||||
const { rules, errors } = loadTrustedFolders();
|
||||
expect(rules).toEqual([
|
||||
{ path: '/path', trustLevel: TrustLevel.TRUST_FOLDER },
|
||||
{ path: normalizePath('/path'), trustLevel: TrustLevel.TRUST_FOLDER },
|
||||
]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
@@ -216,15 +231,18 @@ describe('Trusted Folders', () => {
|
||||
fs.writeFileSync(trustedFoldersPath, '{}', 'utf-8');
|
||||
const loadedFolders = loadTrustedFolders();
|
||||
|
||||
await loadedFolders.setValue('/new/path', TrustLevel.TRUST_FOLDER);
|
||||
await loadedFolders.setValue(
|
||||
normalizePath('/new/path'),
|
||||
TrustLevel.TRUST_FOLDER,
|
||||
);
|
||||
|
||||
expect(loadedFolders.user.config['/new/path']).toBe(
|
||||
expect(loadedFolders.user.config[normalizePath('/new/path')]).toBe(
|
||||
TrustLevel.TRUST_FOLDER,
|
||||
);
|
||||
|
||||
const content = fs.readFileSync(trustedFoldersPath, 'utf-8');
|
||||
const config = JSON.parse(content);
|
||||
expect(config['/new/path']).toBe(TrustLevel.TRUST_FOLDER);
|
||||
expect(config[normalizePath('/new/path')]).toBe(TrustLevel.TRUST_FOLDER);
|
||||
});
|
||||
|
||||
it('should throw FatalConfigError if there were load errors', async () => {
|
||||
@@ -237,28 +255,6 @@ describe('Trusted Folders', () => {
|
||||
loadedFolders.setValue('/some/path', TrustLevel.TRUST_FOLDER),
|
||||
).rejects.toThrow(FatalConfigError);
|
||||
});
|
||||
|
||||
it('should report corrupted config via coreEvents.emitFeedback and still succeed', async () => {
|
||||
// Initialize with valid JSON
|
||||
fs.writeFileSync(trustedFoldersPath, '{}', 'utf-8');
|
||||
const loadedFolders = loadTrustedFolders();
|
||||
|
||||
// Corrupt the file after initial load
|
||||
fs.writeFileSync(trustedFoldersPath, 'invalid json', 'utf-8');
|
||||
|
||||
await loadedFolders.setValue('/new/path', TrustLevel.TRUST_FOLDER);
|
||||
|
||||
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.stringContaining('may be corrupted'),
|
||||
expect.any(Error),
|
||||
);
|
||||
|
||||
// Should have overwritten the corrupted file with new valid config
|
||||
const content = fs.readFileSync(trustedFoldersPath, 'utf-8');
|
||||
const config = JSON.parse(content);
|
||||
expect(config).toEqual({ '/new/path': TrustLevel.TRUST_FOLDER });
|
||||
});
|
||||
});
|
||||
|
||||
describe('isWorkspaceTrusted Integration', () => {
|
||||
@@ -427,16 +423,28 @@ describe('Trusted Folders', () => {
|
||||
},
|
||||
};
|
||||
|
||||
it('should return true when isHeadlessMode is true, ignoring config', async () => {
|
||||
it('should NOT return true when isHeadlessMode is true, ignoring config', async () => {
|
||||
const geminiCore = await import('@google/gemini-cli-core');
|
||||
vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(true);
|
||||
|
||||
expect(isWorkspaceTrusted(mockSettings)).toEqual({
|
||||
isTrusted: true,
|
||||
isTrusted: undefined,
|
||||
source: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return true when GEMINI_CLI_TRUST_WORKSPACE is true', async () => {
|
||||
process.env['GEMINI_CLI_TRUST_WORKSPACE'] = 'true';
|
||||
try {
|
||||
expect(isWorkspaceTrusted(mockSettings)).toEqual({
|
||||
isTrusted: true,
|
||||
source: 'env',
|
||||
});
|
||||
} finally {
|
||||
delete process.env['GEMINI_CLI_TRUST_WORKSPACE'];
|
||||
}
|
||||
});
|
||||
|
||||
it('should fall back to config when isHeadlessMode is false', async () => {
|
||||
const geminiCore = await import('@google/gemini-cli-core');
|
||||
vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(false);
|
||||
@@ -449,12 +457,12 @@ describe('Trusted Folders', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should return true for isPathTrusted when isHeadlessMode is true', async () => {
|
||||
it('should return undefined for isPathTrusted when isHeadlessMode is true', async () => {
|
||||
const geminiCore = await import('@google/gemini-cli-core');
|
||||
vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(true);
|
||||
|
||||
const folders = loadTrustedFolders();
|
||||
expect(folders.isPathTrusted('/any-untrusted-path')).toBe(true);
|
||||
expect(folders.isPathTrusted('/any-untrusted-path')).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,330 +4,29 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as crypto from 'node:crypto';
|
||||
import { lock } from 'proper-lockfile';
|
||||
import {
|
||||
FatalConfigError,
|
||||
getErrorMessage,
|
||||
isWithinRoot,
|
||||
ideContextStore,
|
||||
GEMINI_DIR,
|
||||
homedir,
|
||||
isHeadlessMode,
|
||||
coreEvents,
|
||||
type HeadlessModeOptions,
|
||||
checkPathTrust,
|
||||
isHeadlessMode,
|
||||
loadTrustedFolders as loadCoreTrustedFolders,
|
||||
type LoadedTrustedFolders,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Settings } from './settings.js';
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
|
||||
const { promises: fsPromises } = fs;
|
||||
export {
|
||||
TrustLevel,
|
||||
isTrustLevel,
|
||||
resetTrustedFoldersForTesting,
|
||||
saveTrustedFolders,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json';
|
||||
|
||||
export function getUserSettingsDir(): string {
|
||||
return path.join(homedir(), GEMINI_DIR);
|
||||
}
|
||||
|
||||
export function getTrustedFoldersPath(): string {
|
||||
if (process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH']) {
|
||||
return process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH'];
|
||||
}
|
||||
return path.join(getUserSettingsDir(), TRUSTED_FOLDERS_FILENAME);
|
||||
}
|
||||
|
||||
export enum TrustLevel {
|
||||
TRUST_FOLDER = 'TRUST_FOLDER',
|
||||
TRUST_PARENT = 'TRUST_PARENT',
|
||||
DO_NOT_TRUST = 'DO_NOT_TRUST',
|
||||
}
|
||||
|
||||
export function isTrustLevel(
|
||||
value: string | number | boolean | object | null | undefined,
|
||||
): value is TrustLevel {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
Object.values(TrustLevel).includes(value as TrustLevel)
|
||||
);
|
||||
}
|
||||
|
||||
export interface TrustRule {
|
||||
path: string;
|
||||
trustLevel: TrustLevel;
|
||||
}
|
||||
|
||||
export interface TrustedFoldersError {
|
||||
message: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface TrustedFoldersFile {
|
||||
config: Record<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;
|
||||
}
|
||||
}
|
||||
export type {
|
||||
TrustRule,
|
||||
TrustedFoldersError,
|
||||
TrustedFoldersFile,
|
||||
TrustResult,
|
||||
LoadedTrustedFolders,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
/** Is folder trust feature enabled per the current applied settings */
|
||||
export function isFolderTrustEnabled(settings: Settings): boolean {
|
||||
@@ -335,57 +34,24 @@ export function isFolderTrustEnabled(settings: Settings): boolean {
|
||||
return folderTrustSetting;
|
||||
}
|
||||
|
||||
function getWorkspaceTrustFromLocalConfig(
|
||||
workspaceDir: string,
|
||||
trustConfig?: Record<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,
|
||||
};
|
||||
export function loadTrustedFolders(): LoadedTrustedFolders {
|
||||
return loadCoreTrustedFolders();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true or false if the workspace is considered "trusted".
|
||||
*/
|
||||
export function isWorkspaceTrusted(
|
||||
settings: Settings,
|
||||
workspaceDir: string = process.cwd(),
|
||||
trustConfig?: Record<string, TrustLevel>,
|
||||
headlessOptions?: HeadlessModeOptions,
|
||||
): TrustResult {
|
||||
if (isHeadlessMode(headlessOptions)) {
|
||||
return { isTrusted: true, source: undefined };
|
||||
}
|
||||
|
||||
if (!isFolderTrustEnabled(settings)) {
|
||||
return { isTrusted: true, source: undefined };
|
||||
}
|
||||
|
||||
const ideTrust = ideContextStore.get()?.workspaceState?.isTrusted;
|
||||
if (ideTrust !== undefined) {
|
||||
return { isTrusted: ideTrust, source: 'ide' };
|
||||
}
|
||||
|
||||
// Fall back to the local user configuration
|
||||
return getWorkspaceTrustFromLocalConfig(
|
||||
workspaceDir,
|
||||
trustConfig,
|
||||
headlessOptions,
|
||||
);
|
||||
): {
|
||||
isTrusted: boolean | undefined;
|
||||
source: 'ide' | 'file' | 'env' | undefined;
|
||||
} {
|
||||
return checkPathTrust({
|
||||
path: workspaceDir,
|
||||
isFolderTrustEnabled: isFolderTrustEnabled(settings),
|
||||
isHeadless: isHeadlessMode(headlessOptions),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -280,6 +280,7 @@ describe('gemini.tsx main function', () => {
|
||||
vi.stubEnv('GEMINI_SANDBOX', '');
|
||||
vi.stubEnv('SANDBOX', '');
|
||||
vi.stubEnv('SHPOOL_SESSION_NAME', '');
|
||||
vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'true');
|
||||
|
||||
initialUnhandledRejectionListeners =
|
||||
process.listeners('unhandledRejection');
|
||||
@@ -555,6 +556,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
rawOutput: undefined,
|
||||
acceptRawOutputRisk: undefined,
|
||||
isCommand: undefined,
|
||||
skipTrust: undefined,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
@@ -613,6 +615,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
rawOutput: undefined,
|
||||
acceptRawOutputRisk: undefined,
|
||||
isCommand: undefined,
|
||||
skipTrust: undefined,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -661,6 +661,12 @@ export async function main() {
|
||||
|
||||
cliStartupHandle?.end();
|
||||
|
||||
if (!config.isInteractive()) {
|
||||
for (const warning of startupWarnings) {
|
||||
writeToStderr(warning.message + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Render UI, passing necessary config values. Check that there is no command line question.
|
||||
if (config.isInteractive()) {
|
||||
// Earlier initialization phases (like TerminalCapabilityManager resolving
|
||||
|
||||
@@ -181,6 +181,7 @@ describe('gemini.tsx main function cleanup', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true';
|
||||
vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'true');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -44,7 +44,7 @@ enum TerminalKeys {
|
||||
LEFT_ARROW = '\u001B[D',
|
||||
RIGHT_ARROW = '\u001B[C',
|
||||
ESCAPE = '\u001B',
|
||||
BACKSPACE = '\x7f',
|
||||
BACKSPACE = '\u0008',
|
||||
CTRL_P = '\u0010',
|
||||
CTRL_N = '\u000E',
|
||||
}
|
||||
|
||||
@@ -168,6 +168,13 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 4`] = `
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> [Pasted Text: 10 lines]
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > multiline rendering > should correctly render multiline input including blank lines 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
> hello
|
||||
|
||||
@@ -24,7 +24,7 @@ enum TerminalKeys {
|
||||
LEFT_ARROW = '\u001B[D',
|
||||
RIGHT_ARROW = '\u001B[C',
|
||||
ESCAPE = '\u001B',
|
||||
BACKSPACE = '\x7f',
|
||||
BACKSPACE = '\u0008',
|
||||
CTRL_L = '\u000C',
|
||||
}
|
||||
|
||||
|
||||
@@ -9,17 +9,7 @@ import { act } from 'react';
|
||||
import { renderHookWithProviders } from '../../test-utils/render.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import type { Mock } from 'vitest';
|
||||
import {
|
||||
vi,
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
} from 'vitest';
|
||||
import { vi, afterAll, beforeAll, type Mock } from 'vitest';
|
||||
import {
|
||||
useKeypressContext,
|
||||
ESC_TIMEOUT,
|
||||
@@ -441,80 +431,6 @@ describe('KeypressContext', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('Windows Terminal Backspace handling', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should NOT treat \\b as ctrl when WT_SESSION is NOT present and OS is not Windows_NT', async () => {
|
||||
vi.stubEnv('WT_SESSION', '');
|
||||
vi.stubEnv('OS', 'Linux');
|
||||
const { keyHandler } = await setupKeypressTest();
|
||||
|
||||
act(() => {
|
||||
stdin.write('\b');
|
||||
});
|
||||
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'backspace',
|
||||
ctrl: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should treat \\b as ctrl when WT_SESSION IS present (even if not Windows_NT)', async () => {
|
||||
vi.stubEnv('WT_SESSION', 'some-id');
|
||||
vi.stubEnv('OS', 'Linux');
|
||||
const { keyHandler } = await setupKeypressTest();
|
||||
|
||||
act(() => {
|
||||
stdin.write('\b');
|
||||
});
|
||||
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'backspace',
|
||||
ctrl: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should treat \\b as ctrl when OS is Windows_NT', async () => {
|
||||
vi.stubEnv('WT_SESSION', '');
|
||||
vi.stubEnv('OS', 'Windows_NT');
|
||||
const { keyHandler } = await setupKeypressTest();
|
||||
|
||||
act(() => {
|
||||
stdin.write('\b');
|
||||
});
|
||||
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'backspace',
|
||||
ctrl: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should treat \\x7f as regular backspace regardless of WT_SESSION or OS', async () => {
|
||||
vi.stubEnv('WT_SESSION', 'some-id');
|
||||
vi.stubEnv('OS', 'Windows_NT');
|
||||
const { keyHandler } = await setupKeypressTest();
|
||||
|
||||
act(() => {
|
||||
stdin.write('\x7f');
|
||||
});
|
||||
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'backspace',
|
||||
ctrl: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('paste mode', () => {
|
||||
it.each([
|
||||
{
|
||||
|
||||
@@ -651,20 +651,8 @@ function* emitKeys(
|
||||
// tab
|
||||
name = 'tab';
|
||||
alt = escaped;
|
||||
} else if (ch === '\b') {
|
||||
// ctrl+h / ctrl+backspace (windows terminals send \x08 for ctrl+backspace)
|
||||
name = 'backspace';
|
||||
// In Windows environments, \b is sent for Ctrl+Backspace (standard backspace is translated to \x7f).
|
||||
// We scope this to Windows/WT_SESSION to avoid breaking other unixes where \b is a plain backspace.
|
||||
if (
|
||||
typeof process !== 'undefined' &&
|
||||
(process.env?.['OS'] === 'Windows_NT' || !!process.env?.['WT_SESSION'])
|
||||
) {
|
||||
ctrl = true;
|
||||
}
|
||||
alt = escaped;
|
||||
} else if (ch === '\x7f') {
|
||||
// backspace
|
||||
} else if (ch === '\b' || ch === '\x7f') {
|
||||
// backspace or ctrl+h
|
||||
name = 'backspace';
|
||||
alt = escaped;
|
||||
} else if (ch === ESC) {
|
||||
|
||||
@@ -34,6 +34,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
...actual,
|
||||
homedir: () => os.homedir(),
|
||||
getCompatibilityWarnings: vi.fn().mockReturnValue([]),
|
||||
isHeadlessMode: vi.fn().mockReturnValue(false),
|
||||
WarningPriority: {
|
||||
Low: 'low',
|
||||
High: 'high',
|
||||
@@ -143,6 +144,51 @@ describe('getUserStartupWarnings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('folder trust check', () => {
|
||||
it('should throw FatalUntrustedWorkspaceError when untrusted in headless mode', async () => {
|
||||
const { isHeadlessMode, FatalUntrustedWorkspaceError } = await import(
|
||||
'@google/gemini-cli-core'
|
||||
);
|
||||
vi.mocked(isFolderTrustEnabled).mockReturnValue(true);
|
||||
vi.mocked(isWorkspaceTrusted).mockImplementation(() => {
|
||||
throw new FatalUntrustedWorkspaceError(
|
||||
'Gemini CLI is not running in a trusted directory',
|
||||
);
|
||||
});
|
||||
vi.mocked(isHeadlessMode).mockReturnValue(true);
|
||||
|
||||
await expect(
|
||||
getUserStartupWarnings({}, testRootDir),
|
||||
).rejects.toThrowError(FatalUntrustedWorkspaceError);
|
||||
});
|
||||
|
||||
it('should not return a warning when trusted in headless mode', async () => {
|
||||
const { isHeadlessMode } = await import('@google/gemini-cli-core');
|
||||
vi.mocked(isFolderTrustEnabled).mockReturnValue(true);
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
vi.mocked(isHeadlessMode).mockReturnValue(true);
|
||||
|
||||
const warnings = await getUserStartupWarnings({}, testRootDir);
|
||||
expect(warnings.find((w) => w.id === 'folder-trust')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not return a warning when untrusted in interactive mode', async () => {
|
||||
const { isHeadlessMode } = await import('@google/gemini-cli-core');
|
||||
vi.mocked(isFolderTrustEnabled).mockReturnValue(true);
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: undefined,
|
||||
});
|
||||
vi.mocked(isHeadlessMode).mockReturnValue(false);
|
||||
|
||||
const warnings = await getUserStartupWarnings({}, testRootDir);
|
||||
expect(warnings.find((w) => w.id === 'folder-trust')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('compatibility warnings', () => {
|
||||
it('should include compatibility warnings by default', async () => {
|
||||
const compWarning = {
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
getCompatibilityWarnings,
|
||||
WarningPriority,
|
||||
type StartupWarning,
|
||||
isHeadlessMode,
|
||||
FatalUntrustedWorkspaceError,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Settings } from '../config/settingsSchema.js';
|
||||
import {
|
||||
@@ -79,10 +81,36 @@ 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. ' +
|
||||
'For more details, see https://geminicli.com/docs/cli/trusted-folders/#headless-and-automated-environments',
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
// All warning checks
|
||||
const WARNING_CHECKS: readonly WarningCheck[] = [
|
||||
homeDirectoryCheck,
|
||||
rootDirectoryCheck,
|
||||
folderTrustCheck,
|
||||
];
|
||||
|
||||
export async function getUserStartupWarnings(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.40.0",
|
||||
"description": "Gemini CLI Core",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('policyCatalog', () => {
|
||||
it('marks preview transients as sticky retries', () => {
|
||||
const [previewPolicy] = getModelPolicyChain({ previewEnabled: true });
|
||||
expect(previewPolicy.model).toBe(PREVIEW_GEMINI_MODEL);
|
||||
expect(previewPolicy.stateTransitions.transient).toBe('terminal');
|
||||
expect(previewPolicy.stateTransitions.transient).toBe('sticky_retry');
|
||||
});
|
||||
|
||||
it('applies default actions and state transitions for unspecified kinds', () => {
|
||||
|
||||
@@ -50,7 +50,7 @@ export const SILENT_ACTIONS: ModelPolicyActionMap = {
|
||||
|
||||
const DEFAULT_STATE: ModelPolicyStateMap = {
|
||||
terminal: 'terminal',
|
||||
transient: 'terminal',
|
||||
transient: 'sticky_retry',
|
||||
not_found: 'terminal',
|
||||
unknown: 'terminal',
|
||||
};
|
||||
|
||||
@@ -520,7 +520,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
},
|
||||
stateTransitions: {
|
||||
terminal: 'terminal',
|
||||
transient: 'terminal',
|
||||
transient: 'sticky_retry',
|
||||
not_found: 'terminal',
|
||||
unknown: 'terminal',
|
||||
},
|
||||
@@ -536,7 +536,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
},
|
||||
stateTransitions: {
|
||||
terminal: 'terminal',
|
||||
transient: 'terminal',
|
||||
transient: 'sticky_retry',
|
||||
not_found: 'terminal',
|
||||
unknown: 'terminal',
|
||||
},
|
||||
@@ -553,7 +553,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
},
|
||||
stateTransitions: {
|
||||
terminal: 'terminal',
|
||||
transient: 'terminal',
|
||||
transient: 'sticky_retry',
|
||||
not_found: 'terminal',
|
||||
unknown: 'terminal',
|
||||
},
|
||||
@@ -569,7 +569,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
},
|
||||
stateTransitions: {
|
||||
terminal: 'terminal',
|
||||
transient: 'terminal',
|
||||
transient: 'sticky_retry',
|
||||
not_found: 'terminal',
|
||||
unknown: 'terminal',
|
||||
},
|
||||
@@ -586,7 +586,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
},
|
||||
stateTransitions: {
|
||||
terminal: 'terminal',
|
||||
transient: 'terminal',
|
||||
transient: 'sticky_retry',
|
||||
not_found: 'terminal',
|
||||
unknown: 'terminal',
|
||||
},
|
||||
@@ -601,7 +601,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
},
|
||||
stateTransitions: {
|
||||
terminal: 'terminal',
|
||||
transient: 'terminal',
|
||||
transient: 'sticky_retry',
|
||||
not_found: 'terminal',
|
||||
unknown: 'terminal',
|
||||
},
|
||||
@@ -617,7 +617,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
},
|
||||
stateTransitions: {
|
||||
terminal: 'terminal',
|
||||
transient: 'terminal',
|
||||
transient: 'sticky_retry',
|
||||
not_found: 'terminal',
|
||||
unknown: 'terminal',
|
||||
},
|
||||
|
||||
@@ -20,6 +20,7 @@ import { ProjectRegistry } from './projectRegistry.js';
|
||||
import { StorageMigration } from './storageMigration.js';
|
||||
|
||||
export const OAUTH_FILE = 'oauth_creds.json';
|
||||
export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json';
|
||||
const TMP_DIR_NAME = 'tmp';
|
||||
const BIN_DIR_NAME = 'bin';
|
||||
const AGENTS_DIR_NAME = '.agents';
|
||||
@@ -86,6 +87,13 @@ export class Storage {
|
||||
return path.join(Storage.getGlobalGeminiDir(), GOOGLE_ACCOUNTS_FILENAME);
|
||||
}
|
||||
|
||||
static getTrustedFoldersPath(): string {
|
||||
if (process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH']) {
|
||||
return process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH'];
|
||||
}
|
||||
return path.join(Storage.getGlobalGeminiDir(), TRUSTED_FOLDERS_FILENAME);
|
||||
}
|
||||
|
||||
static getUserCommandsDir(): string {
|
||||
return path.join(Storage.getGlobalGeminiDir(), 'commands');
|
||||
}
|
||||
|
||||
@@ -294,3 +294,6 @@ export type { Content, Part, FunctionCall } from '@google/genai';
|
||||
// Export context types and profiles
|
||||
export * from './context/types.js';
|
||||
export * from './context/profiles.js';
|
||||
|
||||
// Export trust utility
|
||||
export * from './utils/trust.js';
|
||||
|
||||
@@ -74,7 +74,9 @@ export const ADMIN_POLICY_TIER = 5;
|
||||
|
||||
export const MCP_EXCLUDED_PRIORITY = USER_POLICY_TIER + 0.9;
|
||||
export const EXCLUDE_TOOLS_FLAG_PRIORITY = USER_POLICY_TIER + 0.4;
|
||||
export const CONFIRMATION_REQUIRED_PRIORITY = USER_POLICY_TIER + 0.35;
|
||||
export const ALLOWED_TOOLS_FLAG_PRIORITY = USER_POLICY_TIER + 0.3;
|
||||
export const CORE_TOOLS_FLAG_PRIORITY = USER_POLICY_TIER + 0.25;
|
||||
export const TRUSTED_MCP_SERVER_PRIORITY = USER_POLICY_TIER + 0.2;
|
||||
export const ALLOWED_MCP_SERVER_PRIORITY = USER_POLICY_TIER + 0.1;
|
||||
|
||||
@@ -434,10 +436,21 @@ export async function createPolicyEngineConfig(
|
||||
}
|
||||
}
|
||||
|
||||
// Tools that are explicitly allowed in the settings.
|
||||
// Priority: ALLOWED_TOOLS_FLAG_PRIORITY (user tier - explicit temporary allows)
|
||||
if (settings.tools?.allowed) {
|
||||
for (const tool of settings.tools.allowed) {
|
||||
const nonPlanModes = [
|
||||
ApprovalMode.DEFAULT,
|
||||
ApprovalMode.AUTO_EDIT,
|
||||
ApprovalMode.YOLO,
|
||||
];
|
||||
|
||||
const mapToolsToRules = (
|
||||
tools: string[],
|
||||
priority: number,
|
||||
source: string,
|
||||
modes?: ApprovalMode[],
|
||||
addDefaultDenyForTools = false,
|
||||
) => {
|
||||
const toolsWithNarrowing = new Set<string>();
|
||||
for (const tool of tools) {
|
||||
// Check for legacy format: toolName(args)
|
||||
const match = tool.match(/^([a-zA-Z0-9_-]+)\((.*)\)$/);
|
||||
if (match) {
|
||||
@@ -449,15 +462,17 @@ export async function createPolicyEngineConfig(
|
||||
|
||||
// Treat args as a command prefix for shell tool
|
||||
if (toolName === SHELL_TOOL_NAME) {
|
||||
toolsWithNarrowing.add(toolName);
|
||||
const patterns = buildArgsPatterns(undefined, args);
|
||||
for (const pattern of patterns) {
|
||||
if (pattern) {
|
||||
rules.push({
|
||||
toolName,
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: ALLOWED_TOOLS_FLAG_PRIORITY,
|
||||
priority,
|
||||
argsPattern: new RegExp(pattern),
|
||||
source: 'Settings (Tools Allowed)',
|
||||
source,
|
||||
modes,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -467,8 +482,9 @@ export async function createPolicyEngineConfig(
|
||||
rules.push({
|
||||
toolName,
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: ALLOWED_TOOLS_FLAG_PRIORITY,
|
||||
source: 'Settings (Tools Allowed)',
|
||||
priority,
|
||||
source,
|
||||
modes,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -479,11 +495,70 @@ export async function createPolicyEngineConfig(
|
||||
rules.push({
|
||||
toolName,
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: ALLOWED_TOOLS_FLAG_PRIORITY,
|
||||
source: 'Settings (Tools Allowed)',
|
||||
priority,
|
||||
source,
|
||||
modes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (addDefaultDenyForTools) {
|
||||
for (const toolName of toolsWithNarrowing) {
|
||||
rules.push({
|
||||
toolName,
|
||||
decision: PolicyDecision.DENY,
|
||||
priority: priority - 0.01,
|
||||
source: `${source} (Narrowing Enforcement)`,
|
||||
modes,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Tools that are explicitly allowed in the settings.
|
||||
// Priority: ALLOWED_TOOLS_FLAG_PRIORITY (user tier - explicit temporary allows)
|
||||
if (settings.tools?.allowed) {
|
||||
mapToolsToRules(
|
||||
settings.tools.allowed,
|
||||
ALLOWED_TOOLS_FLAG_PRIORITY,
|
||||
'Settings (Tools Allowed)',
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
// Tools that explicitly require confirmation in the settings.
|
||||
// Priority: CONFIRMATION_REQUIRED_PRIORITY (overrides allowed and core)
|
||||
if (settings.tools?.confirmationRequired) {
|
||||
for (const tool of settings.tools.confirmationRequired) {
|
||||
rules.push({
|
||||
toolName: SHELL_TOOL_NAMES.includes(tool) ? SHELL_TOOL_NAME : tool,
|
||||
decision: PolicyDecision.ASK_USER,
|
||||
priority: CONFIRMATION_REQUIRED_PRIORITY,
|
||||
source: 'Settings (Confirmation Required)',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Core tools that are restricted in the settings.
|
||||
// Priority: CORE_TOOLS_FLAG_PRIORITY (user tier - core tool allowlist)
|
||||
if (settings.tools?.core) {
|
||||
mapToolsToRules(
|
||||
settings.tools.core,
|
||||
CORE_TOOLS_FLAG_PRIORITY,
|
||||
'Settings (Core Tools)',
|
||||
nonPlanModes,
|
||||
);
|
||||
|
||||
// If core tools are restricted, we should add a default DENY rule for everything else
|
||||
// at a slightly lower priority than the explicit allows.
|
||||
rules.push({
|
||||
toolName: '*',
|
||||
decision: PolicyDecision.DENY,
|
||||
priority: CORE_TOOLS_FLAG_PRIORITY - 0.01,
|
||||
source: 'Settings (Core Tools Allowlist Enforcement)',
|
||||
modes: nonPlanModes,
|
||||
});
|
||||
}
|
||||
|
||||
// MCP servers that are trusted in the settings.
|
||||
@@ -501,6 +576,7 @@ export async function createPolicyEngineConfig(
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: TRUSTED_MCP_SERVER_PRIORITY,
|
||||
source: 'Settings (MCP Trusted)',
|
||||
modes: nonPlanModes,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -519,6 +595,7 @@ export async function createPolicyEngineConfig(
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: ALLOWED_MCP_SERVER_PRIORITY,
|
||||
source: 'Settings (MCP Allowed)',
|
||||
modes: nonPlanModes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createPolicyEngineConfig } from './config.js';
|
||||
import { PolicyEngine } from './policy-engine.js';
|
||||
import { PolicyDecision, ApprovalMode } from './types.js';
|
||||
|
||||
describe('PolicyEngine - Core Tools Mapping', () => {
|
||||
it('should allow tools explicitly listed in settings.tools.core', async () => {
|
||||
const settings = {
|
||||
tools: {
|
||||
core: ['run_shell_command(ls)', 'run_shell_command(git status)'],
|
||||
},
|
||||
};
|
||||
|
||||
const config = await createPolicyEngineConfig(
|
||||
settings,
|
||||
ApprovalMode.DEFAULT,
|
||||
undefined,
|
||||
true, // interactive
|
||||
);
|
||||
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
// Test simple tool name
|
||||
const result1 = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'ls' } },
|
||||
undefined,
|
||||
);
|
||||
expect(result1.decision).toBe(PolicyDecision.ALLOW);
|
||||
|
||||
// Test tool name with args
|
||||
const result2 = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'git status' } },
|
||||
undefined,
|
||||
);
|
||||
expect(result2.decision).toBe(PolicyDecision.ALLOW);
|
||||
|
||||
// Test tool not in core list
|
||||
const result3 = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'npm test' } },
|
||||
undefined,
|
||||
);
|
||||
// Should be DENIED because of strict allowlist
|
||||
expect(result3.decision).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
|
||||
it('should allow tools in tools.core even if they are restricted by default policies', async () => {
|
||||
// By default run_shell_command is ASK_USER.
|
||||
// Putting it in tools.core should make it ALLOW.
|
||||
const settings = {
|
||||
tools: {
|
||||
core: ['run_shell_command'],
|
||||
},
|
||||
};
|
||||
|
||||
const config = await createPolicyEngineConfig(
|
||||
settings,
|
||||
ApprovalMode.DEFAULT,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
const result = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'any command' } },
|
||||
undefined,
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,35 @@ vi.mock('../utils/shell-utils.js', async (importOriginal) => {
|
||||
}
|
||||
return [command];
|
||||
}),
|
||||
parseCommandDetails: vi.fn().mockImplementation((command: string) => {
|
||||
// Basic mock implementation for PolicyEngine test needs
|
||||
const commands = command.includes('&&')
|
||||
? command.split('&&').map((c) => c.trim())
|
||||
: [command.trim()];
|
||||
|
||||
// Detect $(...) or `...` and add as sub-commands for recursion tests
|
||||
const subCommands = [...commands];
|
||||
for (const cmd of commands) {
|
||||
const subMatch = cmd.match(/\$\((.*)\)/) || cmd.match(/`(.*)`/);
|
||||
if (subMatch?.[1]) {
|
||||
subCommands.push(subMatch[1].trim());
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
details: subCommands.map((c, i) => ({
|
||||
name: c.split(' ')[0],
|
||||
text: c,
|
||||
startIndex: i === 0 ? 0 : -1, // Simple root indication
|
||||
})),
|
||||
hasError: false,
|
||||
};
|
||||
}),
|
||||
stripShellWrapper: vi.fn().mockImplementation((command: string) => {
|
||||
// Simple mock for stripping wrappers
|
||||
const match = command.match(/^(?:bash|sh|zsh)\s+-c\s+["'](.*)["']$/i);
|
||||
return match ? match[1] : command;
|
||||
}),
|
||||
hasRedirection: vi.fn().mockImplementation(
|
||||
(command: string) =>
|
||||
// Simple mock: true if '>' is present, unless it looks like "-> arrow"
|
||||
@@ -1862,7 +1891,6 @@ describe('PolicyEngine', () => {
|
||||
});
|
||||
|
||||
it('should return ASK_USER in non-YOLO mode if shell command parsing fails', async () => {
|
||||
const { splitCommands } = await import('../utils/shell-utils.js');
|
||||
const rules: PolicyRule[] = [
|
||||
{
|
||||
toolName: 'run_shell_command',
|
||||
@@ -1877,7 +1905,11 @@ describe('PolicyEngine', () => {
|
||||
});
|
||||
|
||||
// Simulate parsing failure
|
||||
vi.mocked(splitCommands).mockReturnValueOnce([]);
|
||||
const { parseCommandDetails } = await import('../utils/shell-utils.js');
|
||||
vi.mocked(parseCommandDetails).mockReturnValueOnce({
|
||||
details: [],
|
||||
hasError: true,
|
||||
});
|
||||
|
||||
const result = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'complex command' } },
|
||||
|
||||
@@ -7,8 +7,10 @@
|
||||
import { type FunctionCall } from '@google/genai';
|
||||
import {
|
||||
SHELL_TOOL_NAMES,
|
||||
REDIRECTION_NAMES,
|
||||
initializeShellParsers,
|
||||
splitCommands,
|
||||
parseCommandDetails,
|
||||
stripShellWrapper,
|
||||
hasRedirection,
|
||||
extractStringFromParseEntry,
|
||||
} from '../utils/shell-utils.js';
|
||||
@@ -359,7 +361,8 @@ export class PolicyEngine {
|
||||
}
|
||||
|
||||
await initializeShellParsers();
|
||||
const subCommands = splitCommands(command);
|
||||
const parsed = parseCommandDetails(command);
|
||||
const subCommands = parsed?.details ?? [];
|
||||
|
||||
if (subCommands.length === 0) {
|
||||
// If the matched rule says DENY, we should respect it immediately even if parsing fails.
|
||||
@@ -380,115 +383,109 @@ export class PolicyEngine {
|
||||
);
|
||||
|
||||
// Parsing logic failed, we can't trust it. Use default decision ASK_USER (or DENY in non-interactive).
|
||||
// We return the rule that matched so the evaluation loop terminates.
|
||||
return {
|
||||
decision: this.defaultDecision,
|
||||
rule,
|
||||
};
|
||||
}
|
||||
|
||||
// If there are multiple parts, or if we just want to validate the single part against DENY rules
|
||||
if (subCommands.length > 0) {
|
||||
debugLogger.debug(
|
||||
`[PolicyEngine.check] Validating shell command: ${subCommands.length} parts`,
|
||||
);
|
||||
debugLogger.debug(
|
||||
`[PolicyEngine.check] Validating shell command: ${subCommands.length} parts`,
|
||||
);
|
||||
|
||||
if (ruleDecision === PolicyDecision.DENY) {
|
||||
return { decision: PolicyDecision.DENY, rule };
|
||||
}
|
||||
if (ruleDecision === PolicyDecision.DENY) {
|
||||
return { decision: PolicyDecision.DENY, rule };
|
||||
}
|
||||
|
||||
// Start optimistically. If all parts are ALLOW, the whole is ALLOW.
|
||||
// We will downgrade if any part is ASK_USER or DENY.
|
||||
let aggregateDecision = PolicyDecision.ALLOW;
|
||||
let responsibleRule: PolicyRule | undefined;
|
||||
// Start with the decision from the rule or heuristics.
|
||||
// If the tool call was already downgraded (e.g. by heuristics), we start there.
|
||||
let aggregateDecision = ruleDecision;
|
||||
|
||||
// Check for redirection on the full command string
|
||||
if (this.shouldDowngradeForRedirection(command, allowRedirection)) {
|
||||
// If heuristics downgraded the decision, we don't blame the rule.
|
||||
let responsibleRule: PolicyRule | undefined =
|
||||
rule && ruleDecision === rule.decision ? rule : undefined;
|
||||
|
||||
// Check for redirection on the full command string.
|
||||
// Redirection always downgrades ALLOW to ASK_USER (it never upgrades).
|
||||
if (this.shouldDowngradeForRedirection(command, allowRedirection)) {
|
||||
if (aggregateDecision === PolicyDecision.ALLOW) {
|
||||
debugLogger.debug(
|
||||
`[PolicyEngine.check] Downgrading ALLOW to ASK_USER for redirected command: ${command}`,
|
||||
);
|
||||
aggregateDecision = PolicyDecision.ASK_USER;
|
||||
responsibleRule = undefined; // Inherent policy
|
||||
}
|
||||
}
|
||||
|
||||
for (const rawSubCmd of subCommands) {
|
||||
const subCmd = rawSubCmd.trim();
|
||||
// Prevent infinite recursion for the root command
|
||||
if (subCmd === command) {
|
||||
if (this.shouldDowngradeForRedirection(subCmd, allowRedirection)) {
|
||||
debugLogger.debug(
|
||||
`[PolicyEngine.check] Downgrading ALLOW to ASK_USER for redirected command: ${subCmd}`,
|
||||
);
|
||||
// Redirection always downgrades ALLOW to ASK_USER
|
||||
if (aggregateDecision === PolicyDecision.ALLOW) {
|
||||
aggregateDecision = PolicyDecision.ASK_USER;
|
||||
responsibleRule = undefined; // Inherent policy
|
||||
}
|
||||
for (const detail of subCommands) {
|
||||
if (REDIRECTION_NAMES.has(detail.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const subCmd = detail.text.trim();
|
||||
const isAtomic =
|
||||
subCmd === command ||
|
||||
(detail.startIndex === 0 && detail.text.length === command.length);
|
||||
|
||||
// Recursive check for shell wrappers (bash -c, etc.)
|
||||
const stripped = stripShellWrapper(subCmd);
|
||||
if (stripped !== subCmd) {
|
||||
const wrapperResult = await this.check(
|
||||
{ name: toolName, args: { command: stripped, dir_path } },
|
||||
serverName,
|
||||
toolAnnotations,
|
||||
subagent,
|
||||
true,
|
||||
);
|
||||
|
||||
if (wrapperResult.decision === PolicyDecision.DENY)
|
||||
return wrapperResult;
|
||||
if (wrapperResult.decision === PolicyDecision.ASK_USER) {
|
||||
if (aggregateDecision === PolicyDecision.ALLOW) {
|
||||
responsibleRule = wrapperResult.rule;
|
||||
} else {
|
||||
// Atomic command matching the rule.
|
||||
if (
|
||||
ruleDecision === PolicyDecision.ASK_USER &&
|
||||
aggregateDecision === PolicyDecision.ALLOW
|
||||
) {
|
||||
aggregateDecision = PolicyDecision.ASK_USER;
|
||||
responsibleRule = rule;
|
||||
}
|
||||
responsibleRule ??= wrapperResult.rule;
|
||||
}
|
||||
continue;
|
||||
aggregateDecision = PolicyDecision.ASK_USER;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAtomic) {
|
||||
const subResult = await this.check(
|
||||
{ name: toolName, args: { command: subCmd, dir_path } },
|
||||
serverName,
|
||||
toolAnnotations,
|
||||
subagent,
|
||||
true,
|
||||
);
|
||||
|
||||
// subResult.decision is already filtered through applyNonInteractiveMode by this.check()
|
||||
const subDecision = subResult.decision;
|
||||
if (subResult.decision === PolicyDecision.DENY) return subResult;
|
||||
|
||||
// If any part is DENIED, the whole command is DENY
|
||||
if (subDecision === PolicyDecision.DENY) {
|
||||
return {
|
||||
decision: PolicyDecision.DENY,
|
||||
rule: subResult.rule,
|
||||
};
|
||||
}
|
||||
|
||||
// If any part requires ASK_USER, the whole command requires ASK_USER
|
||||
if (subDecision === PolicyDecision.ASK_USER) {
|
||||
aggregateDecision = PolicyDecision.ASK_USER;
|
||||
if (!responsibleRule) {
|
||||
if (subResult.decision === PolicyDecision.ASK_USER) {
|
||||
if (aggregateDecision === PolicyDecision.ALLOW) {
|
||||
responsibleRule = subResult.rule;
|
||||
} else {
|
||||
responsibleRule ??= subResult.rule;
|
||||
}
|
||||
aggregateDecision = PolicyDecision.ASK_USER;
|
||||
}
|
||||
|
||||
// Check for redirection in allowed sub-commands
|
||||
// Downgrade if sub-command has redirection
|
||||
if (
|
||||
subDecision === PolicyDecision.ALLOW &&
|
||||
subResult.decision === PolicyDecision.ALLOW &&
|
||||
this.shouldDowngradeForRedirection(subCmd, allowRedirection)
|
||||
) {
|
||||
debugLogger.debug(
|
||||
`[PolicyEngine.check] Downgrading ALLOW to ASK_USER for redirected command: ${subCmd}`,
|
||||
);
|
||||
if (aggregateDecision === PolicyDecision.ALLOW) {
|
||||
aggregateDecision = PolicyDecision.ASK_USER;
|
||||
responsibleRule = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
decision: aggregateDecision,
|
||||
// If we stayed at ALLOW, we return the original rule (if any).
|
||||
// If we downgraded, we return the responsible rule (or undefined if implicit).
|
||||
rule: aggregateDecision === ruleDecision ? rule : responsibleRule,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
decision: ruleDecision,
|
||||
rule,
|
||||
decision: aggregateDecision,
|
||||
rule: aggregateDecision === ruleDecision ? rule : responsibleRule,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -501,6 +498,7 @@ export class PolicyEngine {
|
||||
serverName: string | undefined,
|
||||
toolAnnotations?: Record<string, unknown>,
|
||||
subagent?: string,
|
||||
skipHeuristics = false,
|
||||
): Promise<CheckResult> {
|
||||
// Case 1: Metadata injection is the primary and safest way to identify an MCP server.
|
||||
// If we have explicit `_serverName` metadata (usually injected by tool-registry for active tools), use it.
|
||||
@@ -594,6 +592,7 @@ export class PolicyEngine {
|
||||
|
||||
let ruleDecision = rule.decision;
|
||||
if (
|
||||
!skipHeuristics &&
|
||||
isShellCommand &&
|
||||
command &&
|
||||
!('commandPrefix' in rule) &&
|
||||
@@ -615,12 +614,10 @@ export class PolicyEngine {
|
||||
subagent,
|
||||
);
|
||||
decision = shellResult.decision;
|
||||
if (shellResult.rule) {
|
||||
matchedRule = shellResult.rule;
|
||||
break;
|
||||
}
|
||||
matchedRule = shellResult.rule;
|
||||
break;
|
||||
} else {
|
||||
decision = rule.decision;
|
||||
decision = ruleDecision;
|
||||
matchedRule = rule;
|
||||
break;
|
||||
}
|
||||
@@ -643,7 +640,7 @@ export class PolicyEngine {
|
||||
);
|
||||
if (toolName && SHELL_TOOL_NAMES.includes(toolName)) {
|
||||
let heuristicDecision = this.defaultDecision;
|
||||
if (command) {
|
||||
if (!skipHeuristics && command) {
|
||||
heuristicDecision = await this.applyShellHeuristics(
|
||||
command,
|
||||
heuristicDecision,
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { PolicyEngine } from './policy-engine.js';
|
||||
import { PolicyDecision, ApprovalMode } from './types.js';
|
||||
import { initializeShellParsers } from '../utils/shell-utils.js';
|
||||
import { buildArgsPatterns } from './utils.js';
|
||||
|
||||
describe('PolicyEngine - Shell Safety Regression Suite', () => {
|
||||
let engine: PolicyEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
await initializeShellParsers();
|
||||
});
|
||||
|
||||
const setupEngine = (allowedCommands: string[]) => {
|
||||
const rules = allowedCommands.map((cmd) => ({
|
||||
toolName: 'run_shell_command',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
argsPattern: new RegExp(buildArgsPatterns(undefined, cmd)[0]!),
|
||||
priority: 10,
|
||||
}));
|
||||
|
||||
return new PolicyEngine({
|
||||
rules,
|
||||
approvalMode: ApprovalMode.DEFAULT,
|
||||
defaultDecision: PolicyDecision.ASK_USER,
|
||||
});
|
||||
};
|
||||
|
||||
it('should block unauthorized chained command with &&', async () => {
|
||||
engine = setupEngine(['echo']);
|
||||
const result = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'echo hi && ls' } },
|
||||
undefined,
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.ASK_USER);
|
||||
});
|
||||
|
||||
it('should allow authorized chained command with &&', async () => {
|
||||
engine = setupEngine(['echo', 'ls']);
|
||||
const result = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'echo hi && ls' } },
|
||||
undefined,
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
|
||||
it('should block unauthorized chained command with ||', async () => {
|
||||
engine = setupEngine(['false']);
|
||||
const result = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'false || ls' } },
|
||||
undefined,
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.ASK_USER);
|
||||
});
|
||||
|
||||
it('should block unauthorized chained command with ;', async () => {
|
||||
engine = setupEngine(['echo']);
|
||||
const result = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'echo hi; ls' } },
|
||||
undefined,
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.ASK_USER);
|
||||
});
|
||||
|
||||
it('should block unauthorized command in pipe |', async () => {
|
||||
engine = setupEngine(['echo']);
|
||||
const result = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'echo hi | grep "hi"' } },
|
||||
undefined,
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.ASK_USER);
|
||||
});
|
||||
|
||||
it('should allow authorized command in pipe |', async () => {
|
||||
engine = setupEngine(['echo', 'grep']);
|
||||
const result = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'echo hi | grep "hi"' } },
|
||||
undefined,
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
|
||||
it('should block unauthorized chained command with &', async () => {
|
||||
engine = setupEngine(['echo']);
|
||||
const result = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'echo hi & ls' } },
|
||||
undefined,
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.ASK_USER);
|
||||
});
|
||||
|
||||
it('should allow authorized chained command with &', async () => {
|
||||
engine = setupEngine(['echo', 'ls']);
|
||||
const result = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'echo hi & ls' } },
|
||||
undefined,
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
|
||||
it('should block unauthorized command in nested substitution', async () => {
|
||||
engine = setupEngine(['echo', 'cat']);
|
||||
const result = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'echo $(cat $(ls))' } },
|
||||
undefined,
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.ASK_USER);
|
||||
});
|
||||
|
||||
it('should allow authorized command in nested substitution', async () => {
|
||||
engine = setupEngine(['echo', 'cat', 'ls']);
|
||||
const result = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'echo $(cat $(ls))' } },
|
||||
undefined,
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
|
||||
it('should block command redirection if not explicitly allowed', async () => {
|
||||
engine = setupEngine(['echo']);
|
||||
const result = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'echo hi > /tmp/test' } },
|
||||
undefined,
|
||||
);
|
||||
// Inherent policy: redirection downgrades to ASK_USER
|
||||
expect(result.decision).toBe(PolicyDecision.ASK_USER);
|
||||
});
|
||||
});
|
||||
@@ -59,6 +59,30 @@ vi.mock('../utils/shell-utils.js', async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
initializeShellParsers: vi.fn(),
|
||||
parseCommandDetails: (command: string) => {
|
||||
if (Object.prototype.hasOwnProperty.call(commandMap, command)) {
|
||||
const subcommands = commandMap[command];
|
||||
return {
|
||||
details: subcommands.map((text) => ({
|
||||
name: text.split(' ')[0],
|
||||
text,
|
||||
startIndex: command.indexOf(text),
|
||||
})),
|
||||
hasError: subcommands.length === 0 && command.includes('&&&'),
|
||||
};
|
||||
}
|
||||
return {
|
||||
details: [
|
||||
{
|
||||
name: command.split(' ')[0],
|
||||
text: command,
|
||||
startIndex: 0,
|
||||
},
|
||||
],
|
||||
hasError: false,
|
||||
};
|
||||
},
|
||||
stripShellWrapper: (command: string) => command,
|
||||
splitCommands: (command: string) => {
|
||||
if (Object.prototype.hasOwnProperty.call(commandMap, command)) {
|
||||
return commandMap[command];
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { expect, describe, it, beforeAll, vi } from 'vitest';
|
||||
import { PolicyEngine } from './policy-engine.js';
|
||||
import { PolicyDecision } from './types.js';
|
||||
import { initializeShellParsers } from '../utils/shell-utils.js';
|
||||
|
||||
// Mock node:os to ensure shell-utils logic always thinks it's on a POSIX-like system.
|
||||
// This ensures that internal calls to getShellConfiguration() and isWindows()
|
||||
// within the shell-utils module return 'bash' configuration, even on Windows CI.
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:os')>();
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
...actual,
|
||||
platform: () => 'linux',
|
||||
},
|
||||
platform: () => 'linux',
|
||||
};
|
||||
});
|
||||
|
||||
// Mock shell-utils to ensure consistent behavior across platforms (especially Windows CI)
|
||||
// We want to test PolicyEngine logic with Bash syntax rules.
|
||||
vi.mock('../utils/shell-utils.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../utils/shell-utils.js')>();
|
||||
return {
|
||||
...actual,
|
||||
getShellConfiguration: () => ({
|
||||
executable: 'bash',
|
||||
argsPrefix: ['-c'],
|
||||
shell: 'bash',
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('PolicyEngine Command Substitution Validation', () => {
|
||||
beforeAll(async () => {
|
||||
await initializeShellParsers();
|
||||
});
|
||||
|
||||
const setupEngine = (blockedCmd: string) =>
|
||||
new PolicyEngine({
|
||||
defaultDecision: PolicyDecision.ALLOW,
|
||||
rules: [
|
||||
{
|
||||
toolName: 'run_shell_command',
|
||||
argsPattern: new RegExp(`"command":"${blockedCmd}"`),
|
||||
decision: PolicyDecision.DENY,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
it('should block echo $(dangerous_cmd) when dangerous_cmd is explicitly blocked', async () => {
|
||||
const engine = setupEngine('dangerous_cmd');
|
||||
const result = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'echo $(dangerous_cmd)' } },
|
||||
'test-server',
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
|
||||
it('should block backtick substitution `dangerous_cmd`', async () => {
|
||||
const engine = setupEngine('dangerous_cmd');
|
||||
const result = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'echo `dangerous_cmd`' } },
|
||||
'test-server',
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
|
||||
it('should block commands inside subshells (dangerous_cmd)', async () => {
|
||||
const engine = setupEngine('dangerous_cmd');
|
||||
const result = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: '(dangerous_cmd)' } },
|
||||
'test-server',
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
|
||||
it('should handle nested substitutions deeply', async () => {
|
||||
const engine = setupEngine('deep_danger');
|
||||
const result = await engine.check(
|
||||
{
|
||||
name: 'run_shell_command',
|
||||
args: { command: 'echo $(ls $(deep_danger))' },
|
||||
},
|
||||
'test-server',
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
});
|
||||
@@ -335,8 +335,10 @@ export interface PolicySettings {
|
||||
allowed?: string[];
|
||||
};
|
||||
tools?: {
|
||||
core?: string[];
|
||||
exclude?: string[];
|
||||
allowed?: string[];
|
||||
confirmationRequired?: string[];
|
||||
};
|
||||
mcpServers?: Record<string, { trust?: boolean }>;
|
||||
// User provided policies that will replace the USER level policies in ~/.gemini/policies
|
||||
|
||||
@@ -1993,13 +1993,15 @@ describe('getRipgrepPath', () => {
|
||||
vi.mocked(fileExists).mockImplementation(
|
||||
async (checkPath) =>
|
||||
checkPath.includes(path.normalize('core/vendor/ripgrep')) &&
|
||||
!checkPath.includes('tools'),
|
||||
!checkPath.includes(path.join(path.sep, 'tools', path.sep)),
|
||||
);
|
||||
|
||||
const resolvedPath = await getRipgrepPath();
|
||||
expect(resolvedPath).not.toBeNull();
|
||||
expect(resolvedPath).toContain(path.normalize('core/vendor/ripgrep'));
|
||||
expect(resolvedPath).not.toContain('tools');
|
||||
expect(resolvedPath).not.toContain(
|
||||
path.join(path.sep, 'tools', path.sep),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null if binary is missing from both paths', async () => {
|
||||
|
||||
@@ -114,6 +114,12 @@ export class FatalToolExecutionError extends FatalError {
|
||||
this.name = 'FatalToolExecutionError';
|
||||
}
|
||||
}
|
||||
export class FatalUntrustedWorkspaceError extends FatalError {
|
||||
constructor(message: string) {
|
||||
super(message, 55);
|
||||
this.name = 'FatalUntrustedWorkspaceError';
|
||||
}
|
||||
}
|
||||
export class FatalCancellationError extends FatalError {
|
||||
constructor(message: string) {
|
||||
super(message, 130); // Standard exit code for SIGINT
|
||||
|
||||
@@ -12,6 +12,7 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const GEMINI_DIR = '.gemini';
|
||||
export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json';
|
||||
export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json';
|
||||
|
||||
/**
|
||||
* Returns the home directory.
|
||||
|
||||
@@ -240,11 +240,15 @@ foreach ($commandAst in $commandAsts) {
|
||||
'utf16le',
|
||||
).toString('base64');
|
||||
|
||||
const REDIRECTION_NAMES = new Set([
|
||||
export const REDIRECTION_NAMES = new Set([
|
||||
'redirection (<)',
|
||||
'redirection (>)',
|
||||
'heredoc (<<)',
|
||||
'herestring (<<<)',
|
||||
'command substitution',
|
||||
'backtick substitution',
|
||||
'process substitution',
|
||||
'subshell',
|
||||
]);
|
||||
|
||||
function createParser(): Parser | null {
|
||||
@@ -360,6 +364,14 @@ function extractNameFromNode(node: Node): string | null {
|
||||
return 'heredoc (<<)';
|
||||
case 'herestring_redirect':
|
||||
return 'herestring (<<<)';
|
||||
case 'command_substitution':
|
||||
return 'command substitution';
|
||||
case 'backtick_substitution':
|
||||
return 'backtick substitution';
|
||||
case 'process_substitution':
|
||||
return 'process substitution';
|
||||
case 'subshell':
|
||||
return 'subshell';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-devtools",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.40.0",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"main": "dist/src/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-sdk",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.40.0",
|
||||
"description": "Gemini CLI SDK",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.40.0",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"displayName": "Gemini CLI Companion",
|
||||
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.40.0",
|
||||
"publisher": "google",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user