Compare commits

...

17 Commits

Author SHA1 Message Date
Spencer e9ba38ed5c Merge branch 'main' into debug-fix 2026-04-24 14:18:12 -04:00
Christian Gunderman c4b38a5aef feat(repo): add gemini-cli-bot metrics and workflows (#25888) 2026-04-24 17:16:20 +00:00
Spencer 3f82b001d2 Merge branch 'main' into debug-fix 2026-04-24 13:10:03 -04:00
JAYADITYA 3dc8e7e13c docs: add Gemini CLI course link to README (#25925) 2026-04-24 17:01:16 +00:00
Emily Hedlund 571ca5a555 update FatalUntrustedWorkspaceError message to include doc link (#25874) 2026-04-23 23:26:29 +00:00
Spencer 2eebc2980b Merge branch 'main' into debug-fix 2026-04-23 19:24:09 -04:00
Spencer e96eb82a13 Merge branch 'main' into debug-fix 2026-04-23 19:03:32 -04:00
Abhijit Balaji d4c5333dcf feat(core,cli): add support for Gemma 4 models (experimental) (#25604) 2026-04-23 23:02:17 +00:00
Spencer 85f99d174f fix(core): drain stderr stream unconditionally for StdioClientTransport 2026-04-23 22:51:17 +00:00
Spencer 1f73ec70c5 perf(core): fix slow boot by fetching experiments and quota asynchronously (#25758)
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
Co-authored-by: David Pierce <davidapierce@google.com>
Co-authored-by: Keith Schaab <keithsc@google.com>
Co-authored-by: Keith Schaab <keith.schaab@gmail.com>
Co-authored-by: Emily Hedlund <ehedlund@google.com>
2026-04-23 21:52:58 +00:00
Dev Randalpura 69150e87b2 fix(ui): corrected background color check in user message components (#25880) 2026-04-23 21:29:11 +00:00
Gal Zahavi 27927c55e5 feat(core): enhance shell command validation and add core tools allowlist (#25720)
Co-authored-by: David Pierce <davidapierce@google.com>
Co-authored-by: Keith Schaab <keithsc@google.com>
Co-authored-by: Keith Schaab <keith.schaab@gmail.com>
Co-authored-by: Emily Hedlund <ehedlund@google.com>
2026-04-23 20:26:01 +00:00
Emily Hedlund c024064f47 update package-lock.json (#25876) 2026-04-23 19:05:12 +00:00
hsm207 ff28d55100 fix: fatal hard-crash on loop detection via unhandled AbortError (#20108)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-04-23 16:51:21 +00:00
Emily Hedlund dba9b9a0ff feat(cli): secure .env loading and enforce workspace trust in headless mode (#25814)
Co-authored-by: galz10 <galzahavi@google.com>
Co-authored-by: davidapierce <davidapierce@google.com>
2026-04-23 16:09:14 +00:00
cynthialong0-0 a007f64d20 fix(core): only show list suggestion if the partial input is empty (#25821) 2026-04-23 14:07:06 +00:00
gemini-cli-robot d1c91f5267 chore(release): bump version to 0.41.0-nightly.20260423.gaa05b4583 (#25847) 2026-04-23 04:28:26 +00:00
101 changed files with 3664 additions and 674 deletions
+2 -1
View File
@@ -2,7 +2,8 @@
"experimental": {
"extensionReloading": true,
"modelSteering": true,
"autoMemory": true
"autoMemory": true,
"gemma": true
},
"general": {
"devtools": true
+1
View File
@@ -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
+3
View File
@@ -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'
+3
View File
@@ -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'
+3
View File
@@ -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'
@@ -0,0 +1,45 @@
name: '🧠 Gemini CLI Bot: Brain'
on:
schedule:
- cron: '0 0 * * *' # Every 24 hours
workflow_dispatch:
concurrency:
group: '${{ github.workflow }}-${{ github.ref }}'
cancel-in-progress: true
permissions:
contents: 'write'
issues: 'write'
pull-requests: 'write'
jobs:
brain:
name: 'Brain (Reasoning Layer)'
runs-on: 'ubuntu-latest'
if: "github.repository == 'google-gemini/gemini-cli'"
steps:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
with:
fetch-depth: 0
- name: 'Setup Node.js'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: 'Install dependencies'
run: 'npm ci'
- name: 'Build Gemini CLI'
run: 'npm run bundle'
- name: 'Download Previous Metrics'
uses: 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093' # ratchet:actions/download-artifact@v4
with:
name: 'metrics-before'
path: 'tools/gemini-cli-bot/history/'
continue-on-error: true
@@ -0,0 +1,59 @@
name: '🔄 Gemini CLI Bot: Pulse'
on:
schedule:
- cron: '*/30 * * * *' # Every 30 minutes
workflow_dispatch:
concurrency:
group: '${{ github.workflow }}-${{ github.ref }}'
cancel-in-progress: true
permissions:
contents: 'write'
issues: 'write'
pull-requests: 'write'
jobs:
pulse:
name: 'Pulse (Reflex Layer)'
runs-on: 'ubuntu-latest'
if: "github.repository == 'google-gemini/gemini-cli'"
steps:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
with:
fetch-depth: 0
- name: 'Setup Node.js'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: 'Install dependencies'
run: 'npm ci'
- name: 'Collect Metrics'
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
run: 'npm run metrics'
- name: 'Archive Metrics'
uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4
with:
name: 'metrics-before'
path: 'metrics-before.csv'
- name: 'Run Reflex Processes'
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
run: |
if [ -d "tools/gemini-cli-bot/processes/scripts" ] && [ "$(ls -A tools/gemini-cli-bot/processes/scripts)" ]; then
for script in tools/gemini-cli-bot/processes/scripts/*.ts; do
echo "Running reflex script: $script"
npx tsx "$script"
done
else
echo "No reflex scripts found."
fi
+1
View File
@@ -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
+2
View File
@@ -371,6 +371,8 @@ for planned features and priorities.
## 📖 Resources
- **[Free Course](https://learn.deeplearning.ai/courses/gemini-cli-code-and-create-with-an-open-source-agent/information)** -
Learn the basics.
- **[Official Roadmap](./ROADMAP.md)** - See what's coming next.
- **[Changelog](https://www.geminicli.com/docs/changelogs)** - See recent
notable updates.
+1
View File
@@ -52,6 +52,7 @@ These commands are available within the interactive REPL.
| `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode |
| `--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.** |
+1
View File
@@ -163,6 +163,7 @@ they appear in the UI.
| UI Label | Setting | Description | Default |
| ---------------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Gemma Models | `experimental.gemma` | Enable access to Gemma 4 models (experimental). | `false` |
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
+28
View File
@@ -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
+59
View File
@@ -563,6 +563,18 @@ their corresponding top-level category object in your `settings.json` file.
"model": "gemini-2.5-flash-lite"
}
},
"gemma-4-31b-it": {
"extends": "chat-base-3",
"modelConfig": {
"model": "gemma-4-31b-it"
}
},
"gemma-4-26b-a4b-it": {
"extends": "chat-base-3",
"modelConfig": {
"model": "gemma-4-26b-a4b-it"
}
},
"gemini-2.5-flash-base": {
"extends": "base",
"modelConfig": {
@@ -834,6 +846,28 @@ their corresponding top-level category object in your `settings.json` file.
"multimodalToolUse": false
}
},
"gemma-4-31b-it": {
"displayName": "gemma-4-31b-it",
"tier": "custom",
"family": "gemma-4",
"isPreview": false,
"isVisible": true,
"features": {
"thinking": true,
"multimodalToolUse": false
}
},
"gemma-4-26b-a4b-it": {
"displayName": "gemma-4-26b-a4b-it",
"tier": "custom",
"family": "gemma-4",
"isPreview": false,
"isVisible": true,
"features": {
"thinking": true,
"multimodalToolUse": false
}
},
"auto": {
"tier": "auto",
"isPreview": true,
@@ -904,6 +938,12 @@ their corresponding top-level category object in your `settings.json` file.
```json
{
"gemma-4-31b-it": {
"default": "gemma-4-31b-it"
},
"gemma-4-26b-a4b-it": {
"default": "gemma-4-26b-a4b-it"
},
"gemini-3.1-pro-preview": {
"default": "gemini-3.1-pro-preview",
"contexts": [
@@ -1467,6 +1507,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`
@@ -1640,6 +1686,11 @@ their corresponding top-level category object in your `settings.json` file.
#### `experimental`
- **`experimental.gemma`** (boolean):
- **Description:** Enable access to Gemma 4 models (experimental).
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.adk.agentSessionNoninteractiveEnabled`** (boolean):
- **Description:** Enable non-interactive agent sessions.
- **Default:** `false`
@@ -2156,6 +2207,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
+3 -3
View File
@@ -17,7 +17,7 @@ describe('Hierarchical Memory', () => {
params: {
settings: {
security: {
folderTrust: { enabled: true },
folderTrust: { enabled: false },
},
},
},
@@ -55,7 +55,7 @@ What is my favorite fruit? Tell me just the name of the fruit.`,
params: {
settings: {
security: {
folderTrust: { enabled: true },
folderTrust: { enabled: false },
},
},
},
@@ -96,7 +96,7 @@ Provide the answer as an XML block like this:
params: {
settings: {
security: {
folderTrust: { enabled: true },
folderTrust: { enabled: false },
},
},
},
+55
View File
@@ -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) => {
+1
View File
@@ -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',
},
});
+398 -63
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"workspaces": [
"packages/*"
],
@@ -991,6 +991,37 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/config-array/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/@eslint/config-array/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/@eslint/config-helpers": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.3.tgz",
@@ -1038,6 +1069,24 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@eslint/eslintrc/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/@eslint/eslintrc/node_modules/globals": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
@@ -1051,6 +1100,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@eslint/eslintrc/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/@eslint/js": {
"version": "9.29.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.29.0.tgz",
@@ -3733,6 +3795,37 @@
"path-browserify": "^1.0.1"
}
},
"node_modules/@ts-morph/common/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/@ts-morph/common/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/@ts-morph/common/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -4593,56 +4686,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",
@@ -4943,6 +4986,13 @@
"win32"
]
},
"node_modules/@vscode/vsce/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/@vscode/vsce/node_modules/hosted-git-info": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
@@ -4982,6 +5032,30 @@
"node": ">=4"
}
},
"node_modules/@vscode/vsce/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/@vscode/vsce/node_modules/minimatch/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/@vscode/vsce/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
@@ -6479,6 +6553,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true,
"license": "MIT"
},
"node_modules/config-chain": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
@@ -6994,6 +7075,23 @@
"sprintf-js": "~1.0.2"
}
},
"node_modules/depcheck/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/depcheck/node_modules/brace-expansion": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/depcheck/node_modules/camelcase": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
@@ -7053,6 +7151,22 @@
"node": ">=6"
}
},
"node_modules/depcheck/node_modules/minimatch": {
"version": "7.4.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-7.4.9.tgz",
"integrity": "sha512-Brg/fp/iAVDOQoHxkuN5bEYhyQlZhxddI78yWsCbeEwTHXQjlNLtiJDUsp1GIptVqMI7/gkJMz4vVAc01mpoBw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/depcheck/node_modules/resolve-from": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
@@ -7943,6 +8057,24 @@
"eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9"
}
},
"node_modules/eslint-plugin-import/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/eslint-plugin-import/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/eslint-plugin-import/node_modules/debug": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
@@ -7953,6 +8085,19 @@
"ms": "^2.1.1"
}
},
"node_modules/eslint-plugin-import/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/eslint-plugin-import/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -8009,6 +8154,37 @@
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
}
},
"node_modules/eslint-plugin-react/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/eslint-plugin-react/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/eslint-plugin-react/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/eslint-plugin-react/node_modules/resolve": {
"version": "2.0.0-next.5",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
@@ -8067,6 +8243,37 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/eslint/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/eslint/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/espree": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
@@ -11707,12 +11914,12 @@
}
},
"node_modules/minimatch": {
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.2"
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
@@ -11893,6 +12100,37 @@
"dev": true,
"license": "MIT"
},
"node_modules/multimatch/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/multimatch/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/multimatch/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/mute-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz",
@@ -12133,6 +12371,24 @@
"node": ">=4"
}
},
"node_modules/npm-run-all/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/npm-run-all/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/npm-run-all/node_modules/chalk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
@@ -12182,6 +12438,19 @@
"dev": true,
"license": "ISC"
},
"node_modules/npm-run-all/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/npm-run-all/node_modules/normalize-package-data": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
@@ -15548,6 +15817,39 @@
"node": ">=18"
}
},
"node_modules/test-exclude/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/test-exclude/node_modules/brace-expansion": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/test-exclude/node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/text-decoder": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
@@ -16306,6 +16608,23 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/typescript-eslint/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/typescript-eslint/node_modules/brace-expansion": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/typescript-eslint/node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
@@ -16316,6 +16635,22 @@
"node": ">= 4"
}
},
"node_modules/typescript-eslint/node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
@@ -17742,7 +18077,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
"@google-cloud/storage": "^7.16.0",
@@ -17871,7 +18206,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
@@ -18019,7 +18354,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
@@ -18329,7 +18664,7 @@
},
"packages/devtools": {
"name": "@google/gemini-cli-devtools",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"license": "Apache-2.0",
"dependencies": {
"ws": "^8.16.0"
@@ -18344,7 +18679,7 @@
},
"packages/sdk": {
"name": "@google/gemini-cli-sdk",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18375,7 +18710,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18407,7 +18742,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"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.41.0-nightly.20260423.gaa05b4583"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
@@ -63,6 +63,7 @@
"lint:all": "node scripts/lint.js",
"format": "prettier --experimental-cli --write .",
"typecheck": "npm run typecheck --workspaces --if-present && tsc -b evals/tsconfig.json integration-tests/tsconfig.json memory-tests/tsconfig.json",
"metrics": "tsx tools/gemini-cli-bot/metrics/index.ts",
"preflight": "npm run clean && npm ci && npm run format && npm run build && npm run lint:ci && npm run typecheck && npm run test:ci",
"prepare": "husky && npm run bundle",
"prepare:package": "node scripts/prepare-package.js",
@@ -81,8 +82,7 @@
"glob": "^12.0.0",
"node-domexception": "npm:empty@^0.10.1",
"prebuild-install": "npm:nop@1.0.0",
"cross-spawn": "^7.0.6",
"minimatch": "^10.2.2"
"cross-spawn": "^7.0.6"
},
"bin": {
"gemini": "bundle/gemini.js"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
@@ -112,6 +112,7 @@ export function createMockConfig(
}),
isContextManagementEnabled: vi.fn().mockReturnValue(false),
getContextManagementConfig: vi.fn().mockReturnValue({ enabled: false }),
getExperimentalGemma: vi.fn().mockReturnValue(false),
...overrides,
} as unknown as Config;
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"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.41.0-nightly.20260423.gaa05b4583"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
+12
View File
@@ -3055,6 +3055,18 @@ describe('loadCliConfig gemmaModelRouter', () => {
expect(gemmaSettings.classifier?.model).toBe('custom-gemma');
});
it('should load experimental.gemma setting from merged settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings({
experimental: {
gemma: true,
},
});
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getExperimentalGemma()).toBe(true);
});
it('should handle partial gemmaModelRouter settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
+30 -12
View File
@@ -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;
}
@@ -994,6 +1011,7 @@ export async function loadCliConfig(
experimentalJitContext,
experimentalMemoryV2: settings.experimental?.memoryV2,
experimentalAutoMemory: settings.experimental?.autoMemory,
experimentalGemma: settings.experimental?.gemma,
contextManagement,
modelSteering: settings.experimental?.modelSteering,
topicUpdateNarration:
@@ -1099,7 +1117,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)}`,
);
}
+5 -1
View File
@@ -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])(
+12 -6
View File
@@ -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', () => {
+15 -11
View File
@@ -499,13 +499,15 @@ export class LoadedSettings {
}
}
function findEnvFile(startDir: string): string | null {
function findEnvFile(startDir: string, isTrusted: boolean): string | null {
let currentDir = path.resolve(startDir);
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;
}
+22
View File
@@ -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',
@@ -2039,6 +2052,15 @@ const SETTINGS_SCHEMA = {
description: 'Setting to enable experimental features',
showInDialog: false,
properties: {
gemma: {
type: 'boolean',
label: 'Gemma Models',
category: 'Experimental',
requiresRestart: true,
default: false,
description: 'Enable access to Gemma 4 models (experimental).',
showInDialog: true,
},
adk: {
type: 'object',
label: 'ADK',
+49 -41
View File
@@ -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);
});
});
+31 -365
View File
@@ -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),
});
}
+3
View File
@@ -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 () => {
+6
View File
@@ -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
+2
View File
@@ -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(() => {
@@ -305,6 +306,7 @@ describe('gemini.tsx main function cleanup', () => {
getMessageBus: () => ({ subscribe: vi.fn() }),
getEnableHooks: vi.fn(() => true),
getHookSystem: vi.fn(() => undefined),
getExperimentalGemma: vi.fn(() => false),
initialize: vi.fn(),
storage: { initialize: vi.fn().mockResolvedValue(undefined) },
getContentGeneratorConfig: vi.fn(),
@@ -89,6 +89,7 @@ describe('ShellProcessor', () => {
getPolicyEngine: vi.fn().mockReturnValue({
check: mockPolicyEngineCheck,
}),
getExperimentalGemma: vi.fn().mockReturnValue(false),
get config() {
return this as unknown as Config;
},
@@ -168,6 +168,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getAdminSkillsEnabled: vi.fn().mockReturnValue(false),
getDisabledSkills: vi.fn().mockReturnValue([]),
getExperimentalJitContext: vi.fn().mockReturnValue(false),
getExperimentalGemma: vi.fn().mockReturnValue(false),
getMemoryBoundaryMarkers: vi.fn().mockReturnValue(['.git']),
getTerminalBackground: vi.fn().mockReturnValue(undefined),
getEmbeddingModel: vi.fn().mockReturnValue('embedding-model'),
@@ -65,6 +65,7 @@ describe('<ModelDialog />', () => {
getGemini31FlashLiteLaunchedSync: () => boolean;
getProModelNoAccess: () => Promise<boolean>;
getProModelNoAccessSync: () => boolean;
getExperimentalGemma: () => boolean;
getLastRetrievedQuota: () =>
| {
buckets: Array<{
@@ -85,6 +86,7 @@ describe('<ModelDialog />', () => {
getGemini31FlashLiteLaunchedSync: mockGetGemini31FlashLiteLaunchedSync,
getProModelNoAccess: mockGetProModelNoAccess,
getProModelNoAccessSync: mockGetProModelNoAccessSync,
getExperimentalGemma: () => false,
getLastRetrievedQuota: () => ({ buckets: [] }),
getSessionId: () => 'test-session-id',
};
+23 -4
View File
@@ -19,6 +19,8 @@ import {
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
DEFAULT_GEMINI_MODEL_AUTO,
GEMMA_4_31B_IT_MODEL,
GEMMA_4_26B_A4B_IT_MODEL,
ModelSlashCommandEvent,
logModelSlashCommand,
getDisplayString,
@@ -222,7 +224,9 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
}
// --- LEGACY PATH ---
const list = [
const showGemmaModels = config?.getExperimentalGemma() ?? false;
const options = [
{
value: DEFAULT_GEMINI_MODEL,
title: getDisplayString(DEFAULT_GEMINI_MODEL),
@@ -240,6 +244,21 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
},
];
if (showGemmaModels) {
options.push(
{
value: GEMMA_4_31B_IT_MODEL,
title: getDisplayString(GEMMA_4_31B_IT_MODEL),
key: GEMMA_4_31B_IT_MODEL,
},
{
value: GEMMA_4_26B_A4B_IT_MODEL,
title: getDisplayString(GEMMA_4_26B_A4B_IT_MODEL),
key: GEMMA_4_26B_A4B_IT_MODEL,
},
);
}
if (shouldShowPreviewModels) {
const previewProModel = useGemini31
? PREVIEW_GEMINI_3_1_MODEL
@@ -270,15 +289,15 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
});
}
list.unshift(...previewOptions);
options.unshift(...previewOptions);
}
if (!hasAccessToProModel) {
// Filter out all Pro models for free tier
return list.filter((option) => !isProModel(option.value));
return options.filter((option) => !isProModel(option.value));
}
return list;
return options;
}, [
shouldShowPreviewModels,
useGemini31,
@@ -86,6 +86,7 @@ const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getProjectTempDir: () => '/tmp/test',
},
getSessionId: () => 'default-session-id',
getExperimentalGemma: () => false,
...overrides,
}) as Config;
@@ -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
@@ -0,0 +1,56 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../../test-utils/render.js';
import { HintMessage } from './HintMessage.js';
import { describe, it, expect, vi } from 'vitest';
import { makeFakeConfig } from '@google/gemini-cli-core';
describe('HintMessage', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it('renders normal hint message with correct prefix', async () => {
const { lastFrame, unmount } = await renderWithProviders(
<HintMessage text="Try this instead" />,
{ width: 80 },
);
const output = lastFrame();
expect(output).toContain('💡');
expect(output).toContain('Steering Hint: Try this instead');
unmount();
});
describe('with NO_COLOR set', () => {
beforeEach(() => {
vi.stubEnv('NO_COLOR', '1');
});
it('uses margins instead of background blocks when NO_COLOR is set', async () => {
const { lastFrame, unmount } = await renderWithProviders(
<HintMessage text="Try this instead" />,
{ width: 80, config: makeFakeConfig({ useBackgroundColor: true }) },
);
const output = lastFrame();
// In NO_COLOR mode, the block characters (▄/▀) should NOT be present.
expect(output).not.toContain('▄');
expect(output).not.toContain('▀');
const lines = output.split('\n').filter((l) => l.trim() !== '');
expect(lines).toHaveLength(1);
expect(lines[0]).toContain('💡');
expect(lines[0]).toContain('Steering Hint: Try this instead');
expect(output).toMatchSnapshot();
unmount();
});
});
});
@@ -19,7 +19,9 @@ export const HintMessage: React.FC<HintMessageProps> = ({ text }) => {
const prefix = '💡 ';
const prefixWidth = prefix.length;
const config = useConfig();
const useBackgroundColor = config.getUseBackgroundColor();
const useBackgroundColorSetting = config.getUseBackgroundColor();
const useBackgroundColor =
useBackgroundColorSetting && !!theme.background.message;
return (
<HalfLinePaddedBox
@@ -7,6 +7,7 @@
import { renderWithProviders } from '../../../test-utils/render.js';
import { UserMessage } from './UserMessage.js';
import { describe, it, expect, vi } from 'vitest';
import { makeFakeConfig } from '@google/gemini-cli-core';
// Mock the commandUtils to control isSlashCommand behavior
vi.mock('../../utils/commandUtils.js', () => ({
@@ -14,6 +15,11 @@ vi.mock('../../utils/commandUtils.js', () => ({
}));
describe('UserMessage', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it('renders normal user message with correct prefix', async () => {
const { lastFrame, unmount } = await renderWithProviders(
<UserMessage text="Hello Gemini" width={80} />,
@@ -60,4 +66,32 @@ describe('UserMessage', () => {
expect(output).toMatchSnapshot();
unmount();
});
describe('with NO_COLOR set', () => {
beforeEach(() => {
vi.stubEnv('NO_COLOR', '1');
});
it('uses margins instead of background blocks when NO_COLOR is set', async () => {
const { lastFrame, unmount } = await renderWithProviders(
<UserMessage text="Hello Gemini" width={80} />,
{ width: 80, config: makeFakeConfig({ useBackgroundColor: true }) },
);
const output = lastFrame();
// In NO_COLOR mode, the block characters (▄/▀) should NOT be present.
expect(output).not.toContain('▄');
expect(output).not.toContain('▀');
// There should be empty lines above and below the message due to marginY={1}.
// lastFrame() returns the full buffer, so we can check for leading/trailing newlines or empty lines.
const lines = output.split('\n').filter((l) => l.trim() !== '');
expect(lines).toHaveLength(1);
expect(lines[0]).toContain('> Hello Gemini');
expect(output).toMatchSnapshot();
unmount();
});
});
});
@@ -27,7 +27,9 @@ export const UserMessage: React.FC<UserMessageProps> = ({ text, width }) => {
const prefixWidth = prefix.length;
const isSlashCommand = checkIsSlashCommand(text);
const config = useConfig();
const useBackgroundColor = config.getUseBackgroundColor();
const useBackgroundColorSetting = config.getUseBackgroundColor();
const useBackgroundColor =
useBackgroundColorSetting && !!theme.background.message;
const textColor = isSlashCommand ? theme.text.accent : theme.text.primary;
@@ -0,0 +1,54 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../../test-utils/render.js';
import { UserShellMessage } from './UserShellMessage.js';
import { describe, it, expect, vi } from 'vitest';
import { makeFakeConfig } from '@google/gemini-cli-core';
describe('UserShellMessage', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it('renders normal shell message with correct prefix', async () => {
const { lastFrame, unmount } = await renderWithProviders(
<UserShellMessage text="ls -la" width={80} />,
{ width: 80 },
);
const output = lastFrame();
expect(output).toContain('$ ls -la');
unmount();
});
describe('with NO_COLOR set', () => {
beforeEach(() => {
vi.stubEnv('NO_COLOR', '1');
});
it('uses margins instead of background blocks when NO_COLOR is set', async () => {
const { lastFrame, unmount } = await renderWithProviders(
<UserShellMessage text="ls -la" width={80} />,
{ width: 80, config: makeFakeConfig({ useBackgroundColor: true }) },
);
const output = lastFrame();
// In NO_COLOR mode, the block characters (▄/▀) should NOT be present.
expect(output).not.toContain('▄');
expect(output).not.toContain('▀');
const lines = output.split('\n').filter((l) => l.trim() !== '');
expect(lines).toHaveLength(1);
expect(lines[0]).toContain('$ ls -la');
expect(output).toMatchSnapshot();
unmount();
});
});
});
@@ -20,7 +20,9 @@ export const UserShellMessage: React.FC<UserShellMessageProps> = ({
width,
}) => {
const config = useConfig();
const useBackgroundColor = config.getUseBackgroundColor();
const useBackgroundColorSetting = config.getUseBackgroundColor();
const useBackgroundColor =
useBackgroundColorSetting && !!theme.background.message;
// Remove leading '!' if present, as App.tsx adds it for the processor.
const commandToDisplay = text.startsWith('!') ? text.substring(1) : text;
@@ -0,0 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`HintMessage > with NO_COLOR set > uses margins instead of background blocks when NO_COLOR is set 1`] = `
"
💡 Steering Hint: Try this instead
"
`;
@@ -28,3 +28,9 @@ exports[`UserMessage > transforms image paths in user message 1`] = `
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"
`;
exports[`UserMessage > with NO_COLOR set > uses margins instead of background blocks when NO_COLOR is set 1`] = `
"
> Hello Gemini
"
`;
@@ -0,0 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`UserShellMessage > with NO_COLOR set > uses margins instead of background blocks when NO_COLOR is set 1`] = `
"
$ ls -la
"
`;
@@ -513,6 +513,70 @@ describe('useSlashCompletion', () => {
unmountResume();
});
it('should NOT suggest the auto-list command when typing a non-matching partial after /chat', async () => {
const slashCommands = [
createTestCommand({
name: 'chat',
description: 'Manage chat history',
subCommands: [
createTestCommand({ name: 'list', description: 'List chats' }),
],
}),
];
const { result, unmount } = await renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/chat x', // 'x' does not match 'list'
slashCommands,
mockCommandContext,
),
);
await resolveMatch();
await waitFor(() => {
// It should NOT have the 'auto' section 'list' suggestion
const autoSuggestion = result.current.suggestions.find(
(s) => s.sectionTitle === 'auto',
);
expect(autoSuggestion).toBeUndefined();
});
unmount();
});
it('should STILL suggest the auto-list command when typing a matching partial after /chat', async () => {
const slashCommands = [
createTestCommand({
name: 'chat',
description: 'Manage chat history',
subCommands: [
createTestCommand({ name: 'list', description: 'List chats' }),
],
}),
];
const { result, unmount } = await renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/chat l', // 'l' matches 'list'
slashCommands,
mockCommandContext,
),
);
await resolveMatch();
await waitFor(() => {
const autoSuggestion = result.current.suggestions.find(
(s) => s.sectionTitle === 'auto',
);
expect(autoSuggestion).toBeDefined();
expect(autoSuggestion?.label).toBe('list');
});
unmount();
});
it('should sort exact altName matches to the top', async () => {
const slashCommands = [
createTestCommand({
+17 -11
View File
@@ -338,17 +338,23 @@ function useCommandSuggestions(
if (isTopLevelChatOrResumeContext) {
const canonicalParentName = leafCommand.name;
const autoSectionSuggestion: Suggestion = {
label: 'list',
value: 'list',
insertValue: canonicalParentName,
description: 'Browse auto-saved chats',
commandKind: CommandKind.BUILT_IN,
sectionTitle: 'auto',
submitValue: `/${canonicalParentName}`,
};
setSuggestions([autoSectionSuggestion, ...finalSuggestions]);
return;
const autoLabel = 'list';
if (
partial === '' ||
autoLabel.toLowerCase().startsWith(partial.toLowerCase())
) {
const autoSectionSuggestion: Suggestion = {
label: autoLabel,
value: autoLabel,
insertValue: canonicalParentName,
description: 'Browse auto-saved chats',
commandKind: CommandKind.BUILT_IN,
sectionTitle: 'auto',
submitValue: `/${canonicalParentName}`,
};
setSuggestions([autoSectionSuggestion, ...finalSuggestions]);
return;
}
}
setSuggestions(finalSuggestions);
@@ -69,6 +69,7 @@ describe('Session Cleanup (Refactored)', () => {
},
getSessionId: () => 'current123',
getDebugMode: () => false,
getExperimentalGemma: () => false,
initialize: async () => {},
...overrides,
} as unknown as Config;
@@ -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(
+2
View File
@@ -12,6 +12,8 @@ export {
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
DEFAULT_GEMINI_EMBEDDING_MODEL,
GEMMA_4_31B_IT_MODEL,
GEMMA_4_26B_A4B_IT_MODEL,
} from './src/config/models.js';
export {
serializeTerminalToObject,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
+45 -1
View File
@@ -960,8 +960,11 @@ describe('Server Config (config.ts)', () => {
});
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
await config.getExperimentsAsync();
expect(config.getModel()).toBe(PREVIEW_GEMINI_FLASH_MODEL);
await vi.waitFor(() => {
expect(config.getModel()).toBe(PREVIEW_GEMINI_FLASH_MODEL);
});
});
it('should NOT switch to flash model if user has Pro access and model is auto', async () => {
@@ -3642,6 +3645,47 @@ describe('Config JIT Initialization', () => {
expect(config.isAutoMemoryEnabled()).toBe(true);
});
it('should return true when experimentalGemma is true', () => {
const params: ConfigParameters = {
sessionId: 'test-session',
targetDir: '/tmp/test',
debugMode: false,
model: 'test-model',
cwd: '/tmp/test',
experimentalGemma: true,
};
config = new Config(params);
expect(config.getExperimentalGemma()).toBe(true);
});
it('should return false when experimentalGemma is false', () => {
const params: ConfigParameters = {
sessionId: 'test-session',
targetDir: '/tmp/test',
debugMode: false,
model: 'test-model',
cwd: '/tmp/test',
experimentalGemma: false,
};
config = new Config(params);
expect(config.getExperimentalGemma()).toBe(false);
});
it('should return false when experimentalGemma is not provided', () => {
const params: ConfigParameters = {
sessionId: 'test-session',
targetDir: '/tmp/test',
debugMode: false,
model: 'test-model',
cwd: '/tmp/test',
};
config = new Config(params);
expect(config.getExperimentalGemma()).toBe(false);
});
it('should be independent of experimentalMemoryV2', () => {
const params: ConfigParameters = {
sessionId: 'test-session',
+28 -14
View File
@@ -711,6 +711,7 @@ export interface ConfigParameters {
autoDistillation?: boolean;
experimentalMemoryV2?: boolean;
experimentalAutoMemory?: boolean;
experimentalGemma?: boolean;
experimentalContextManagementConfig?: string;
experimentalAgentHistoryTruncation?: boolean;
experimentalAgentHistoryTruncationThreshold?: number;
@@ -956,6 +957,7 @@ export class Config implements McpContext, AgentLoopContext {
private readonly experimentalJitContext: boolean;
private readonly experimentalMemoryV2: boolean;
private readonly experimentalAutoMemory: boolean;
private readonly experimentalGemma: boolean;
private readonly experimentalContextManagementConfig?: string;
private readonly memoryBoundaryMarkers: readonly string[];
private readonly topicUpdateNarration: boolean;
@@ -1174,6 +1176,7 @@ export class Config implements McpContext, AgentLoopContext {
this.experimentalJitContext = params.experimentalJitContext ?? true;
this.experimentalMemoryV2 = params.experimentalMemoryV2 ?? true;
this.experimentalAutoMemory = params.experimentalAutoMemory ?? false;
this.experimentalGemma = params.experimentalGemma ?? false;
this.experimentalContextManagementConfig =
params.experimentalContextManagementConfig;
this.memoryBoundaryMarkers = params.memoryBoundaryMarkers ?? ['.git'];
@@ -1590,8 +1593,12 @@ export class Config implements McpContext, AgentLoopContext {
return undefined;
});
// Fetch experiments and update timeouts before continuing initialization
const experiments = await this.experimentsPromise;
const [experiments] = await Promise.all([
this.experimentsPromise,
quotaPromise.catch((e) => {
debugLogger.error('Failed to fetch user quota', e);
}),
]);
const requestTimeoutMs = this.getRequestTimeoutMs();
if (requestTimeoutMs !== undefined) {
@@ -1601,8 +1608,6 @@ export class Config implements McpContext, AgentLoopContext {
// Initialize BaseLlmClient now that the ContentGenerator and experiments are available
this.baseLlmClient = new BaseLlmClient(this.contentGenerator, this);
await quotaPromise;
const authType = this.contentGeneratorConfig.authType;
if (
authType === AuthType.USE_GEMINI ||
@@ -1623,16 +1628,21 @@ export class Config implements McpContext, AgentLoopContext {
const adminControlsEnabled =
experiments?.flags[ExperimentFlags.ENABLE_ADMIN_CONTROLS]?.boolValue ??
false;
const adminControls = await fetchAdminControls(
codeAssistServer,
this.getRemoteAdminSettings(),
adminControlsEnabled,
(newSettings: AdminControlsSettings) => {
this.setRemoteAdminSettings(newSettings);
coreEvents.emitAdminSettingsChanged();
},
);
this.setRemoteAdminSettings(adminControls);
try {
const adminControls = await fetchAdminControls(
codeAssistServer,
this.getRemoteAdminSettings(),
adminControlsEnabled,
(newSettings: AdminControlsSettings) => {
this.setRemoteAdminSettings(newSettings);
coreEvents.emitAdminSettingsChanged();
},
);
this.setRemoteAdminSettings(adminControls);
} catch (e) {
debugLogger.error('Failed to fetch admin controls', e);
}
if ((await this.getProModelNoAccess()) && isAutoModel(this.model)) {
this.setModel(PREVIEW_GEMINI_FLASH_MODEL);
@@ -2514,6 +2524,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.experimentalAutoMemory;
}
getExperimentalGemma(): boolean {
return this.experimentalGemma;
}
getExperimentalContextManagementConfig(): string | undefined {
return this.experimentalContextManagementConfig;
}
@@ -89,6 +89,19 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
model: 'gemini-2.5-flash-lite',
},
},
'gemma-4-31b-it': {
extends: 'chat-base-3',
modelConfig: {
model: 'gemma-4-31b-it',
},
},
'gemma-4-26b-a4b-it': {
extends: 'chat-base-3',
modelConfig: {
model: 'gemma-4-26b-a4b-it',
},
},
// Bases for the internal model configs.
'gemini-2.5-flash-base': {
extends: 'base',
@@ -317,6 +330,23 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
isVisible: true,
features: { thinking: false, multimodalToolUse: false },
},
'gemma-4-31b-it': {
displayName: 'gemma-4-31b-it',
tier: 'custom',
family: 'gemma-4',
isPreview: false,
isVisible: true,
features: { thinking: true, multimodalToolUse: false },
},
'gemma-4-26b-a4b-it': {
displayName: 'gemma-4-26b-a4b-it',
tier: 'custom',
family: 'gemma-4',
isPreview: false,
isVisible: true,
features: { thinking: true, multimodalToolUse: false },
},
// Aliases
auto: {
tier: 'auto',
@@ -362,6 +392,13 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
},
},
modelIdResolutions: {
'gemma-4-31b-it': {
default: 'gemma-4-31b-it',
},
'gemma-4-26b-a4b-it': {
default: 'gemma-4-26b-a4b-it',
},
'gemini-3.1-pro-preview': {
default: 'gemini-3.1-pro-preview',
contexts: [
+17
View File
@@ -32,6 +32,8 @@ import {
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
isPreviewModel,
isProModel,
GEMMA_4_31B_IT_MODEL,
GEMMA_4_26B_A4B_IT_MODEL,
} from './models.js';
import type { Config } from './config.js';
import { ModelConfigService } from '../services/modelConfigService.js';
@@ -356,6 +358,10 @@ describe('getDisplayString', () => {
it('should return the model name as is for other models', () => {
expect(getDisplayString('custom-model')).toBe('custom-model');
expect(getDisplayString(GEMMA_4_31B_IT_MODEL)).toBe(GEMMA_4_31B_IT_MODEL);
expect(getDisplayString(GEMMA_4_26B_A4B_IT_MODEL)).toBe(
GEMMA_4_26B_A4B_IT_MODEL,
);
expect(getDisplayString(DEFAULT_GEMINI_FLASH_LITE_MODEL)).toBe(
DEFAULT_GEMINI_FLASH_LITE_MODEL,
);
@@ -573,6 +579,17 @@ describe('isActiveModel', () => {
expect(isActiveModel(DEFAULT_GEMINI_FLASH_MODEL)).toBe(true);
});
it('should return true for Gemma 4 models only when experimentalGemma is true', () => {
expect(isActiveModel(GEMMA_4_31B_IT_MODEL)).toBe(false);
expect(isActiveModel(GEMMA_4_26B_A4B_IT_MODEL)).toBe(false);
expect(isActiveModel(GEMMA_4_31B_IT_MODEL, false, false, false, true)).toBe(
true,
);
expect(
isActiveModel(GEMMA_4_26B_A4B_IT_MODEL, false, false, false, true),
).toBe(true);
});
it('should return false for Gemini 3.1 models when Gemini 3.1 is not launched', () => {
expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL)).toBe(false);
expect(isActiveModel(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL)).toBe(false);
+14
View File
@@ -61,6 +61,9 @@ export const DEFAULT_GEMINI_MODEL = 'gemini-2.5-pro';
export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-2.5-flash';
export const DEFAULT_GEMINI_FLASH_LITE_MODEL = 'gemini-2.5-flash-lite';
export const GEMMA_4_31B_IT_MODEL = 'gemma-4-31b-it';
export const GEMMA_4_26B_A4B_IT_MODEL = 'gemma-4-26b-a4b-it';
export const VALID_GEMINI_MODELS = new Set([
PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_3_1_MODEL,
@@ -70,6 +73,9 @@ export const VALID_GEMINI_MODELS = new Set([
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
GEMMA_4_31B_IT_MODEL,
GEMMA_4_26B_A4B_IT_MODEL,
]);
export const PREVIEW_GEMINI_MODEL_AUTO = 'auto-gemini-3';
@@ -257,6 +263,10 @@ export function getDisplayString(
return 'Auto (Gemini 3)';
case DEFAULT_GEMINI_MODEL_AUTO:
return 'Auto (Gemini 2.5)';
case GEMMA_4_31B_IT_MODEL:
return GEMMA_4_31B_IT_MODEL;
case GEMMA_4_26B_A4B_IT_MODEL:
return GEMMA_4_26B_A4B_IT_MODEL;
case GEMINI_MODEL_ALIAS_PRO:
return PREVIEW_GEMINI_MODEL;
case GEMINI_MODEL_ALIAS_FLASH:
@@ -438,10 +448,14 @@ export function isActiveModel(
useGemini3_1: boolean = false,
useGemini3_1FlashLite: boolean = false,
useCustomToolModel: boolean = false,
experimentalGemma: boolean = false,
): boolean {
if (!VALID_GEMINI_MODELS.has(model)) {
return false;
}
if (model === GEMMA_4_31B_IT_MODEL || model === GEMMA_4_26B_A4B_IT_MODEL) {
return experimentalGemma;
}
if (model === PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL) {
return useGemini3_1FlashLite;
}
+8
View File
@@ -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');
}
-4
View File
@@ -1244,9 +1244,6 @@ ${JSON.stringify(
count: 2,
});
const abortSpy = vi.spyOn(AbortController.prototype, 'abort');
// Act
const stream = client.sendMessageStream(
[{ text: 'Hi' }],
new AbortController().signal,
@@ -1267,7 +1264,6 @@ ${JSON.stringify(
// Assert
expect(events).toContainEqual({ type: GeminiEventType.LoopDetected });
expect(abortSpy).toHaveBeenCalled();
expect(finalResult).toBeInstanceOf(Turn);
});
+1 -10
View File
@@ -684,9 +684,6 @@ export class GeminiClient {
// Re-initialize turn with fresh history
turn = new Turn(this.getChat(), prompt_id);
const controller = new AbortController();
const linkedSignal = AbortSignal.any([signal, controller.signal]);
const loopResult = await this.loopDetector.turnStarted(signal);
if (loopResult.count > 1) {
yield { type: GeminiEventType.LoopDetected };
@@ -747,7 +744,7 @@ export class GeminiClient {
const resultStream = turn.run(
modelConfigKey,
request,
linkedSignal,
signal,
displayContent,
);
let isError = false;
@@ -783,7 +780,6 @@ export class GeminiClient {
}
if (loopDetectedAbort) {
controller.abort();
return turn;
}
@@ -795,10 +791,8 @@ export class GeminiClient {
boundedTurns,
isInvalidStreamRetry,
displayContent,
controller,
);
}
if (isError) {
return turn;
}
@@ -1252,10 +1246,7 @@ export class GeminiClient {
boundedTurns: number,
isInvalidStreamRetry: boolean,
displayContent?: PartListUnion,
controllerToAbort?: AbortController,
): AsyncGenerator<ServerGeminiStreamEvent, Turn> {
controllerToAbort?.abort();
// Clear the detection flag so the recursive turn can proceed, but the count remains 1.
this.loopDetector.clearDetection();
+6
View File
@@ -10,17 +10,23 @@ import {
DEFAULT_GEMINI_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_MODEL,
GEMMA_4_31B_IT_MODEL,
GEMMA_4_26B_A4B_IT_MODEL,
} from '../config/models.js';
type Model = string;
type TokenCount = number;
export const DEFAULT_TOKEN_LIMIT = 1_048_576;
export const GEMMA_4_TOKEN_LIMIT = 256_000;
export function tokenLimit(model: Model): TokenCount {
// Add other models as they become relevant or if specified by config
// Pulled from https://ai.google.dev/gemini-api/docs/models
switch (model) {
case GEMMA_4_31B_IT_MODEL:
case GEMMA_4_26B_A4B_IT_MODEL:
return GEMMA_4_TOKEN_LIMIT;
case PREVIEW_GEMINI_MODEL:
case PREVIEW_GEMINI_FLASH_MODEL:
case DEFAULT_GEMINI_MODEL:
+3
View File
@@ -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';
+87 -10
View File
@@ -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);
});
});
+34 -2
View File
@@ -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' } },
+70 -73
View File
@@ -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);
});
});
+2
View File
@@ -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
@@ -97,6 +97,30 @@
"topK": 64
}
},
"gemma-4-31b-it": {
"model": "gemma-4-31b-it",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true,
"thinkingLevel": "HIGH"
},
"topK": 64
}
},
"gemma-4-26b-a4b-it": {
"model": "gemma-4-26b-a4b-it",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true,
"thinkingLevel": "HIGH"
},
"topK": 64
}
},
"gemini-2.5-flash-base": {
"model": "gemini-2.5-flash",
"generateContentConfig": {
@@ -97,6 +97,30 @@
"topK": 64
}
},
"gemma-4-31b-it": {
"model": "gemma-4-31b-it",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true,
"thinkingLevel": "HIGH"
},
"topK": 64
}
},
"gemma-4-26b-a4b-it": {
"model": "gemma-4-26b-a4b-it",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true,
"thinkingLevel": "HIGH"
},
"topK": 64
}
},
"gemini-2.5-flash-base": {
"model": "gemini-2.5-flash",
"generateContentConfig": {
@@ -1993,6 +1993,27 @@ describe('mcp-client', () => {
});
});
it('should unconditionally attach a data listener to stderr to prevent process hang', async () => {
const mockStderr = {
on: vi.fn(),
};
vi.spyOn(SdkClientStdioLib, 'StdioClientTransport').mockReturnValue({
stderr: mockStderr,
} as unknown as SdkClientStdioLib.StdioClientTransport);
await createTransport(
'test-server',
{
command: 'test-command',
},
false, // debugMode = false
MOCK_CONTEXT,
);
expect(mockStderr.on).toHaveBeenCalledWith('data', expect.any(Function));
});
it('sets an env variable GEMINI_CLI=1 for stdio MCP servers', async () => {
const mockedTransport = vi
.spyOn(SdkClientStdioLib, 'StdioClientTransport')
+27 -18
View File
@@ -2291,31 +2291,40 @@ export async function createTransport(
transport = new XcodeMcpBridgeFixTransport(transport);
}
if (debugMode) {
// The `XcodeMcpBridgeFixTransport` wrapper hides the underlying `StdioClientTransport`,
// which exposes `stderr` for debug logging. We need to unwrap it to attach the listener.
// The `XcodeMcpBridgeFixTransport` wrapper hides the underlying `StdioClientTransport`,
// which exposes `stderr` for debug logging. We need to unwrap it to attach the listener.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const underlyingTransport =
transport instanceof XcodeMcpBridgeFixTransport
? // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
(transport as any).transport
: transport;
const underlyingTransport =
transport instanceof XcodeMcpBridgeFixTransport
? // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(transport as unknown as { transport: unknown }).transport
: transport;
if (
underlyingTransport instanceof StdioClientTransport &&
underlyingTransport.stderr
) {
underlyingTransport.stderr.on('data', (data) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const stderrStr = data.toString().trim();
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const streamTransport = underlyingTransport as {
stderr?: {
on: (event: string, listener: (data: unknown) => void) => void;
};
};
if (
streamTransport &&
typeof streamTransport === 'object' &&
'stderr' in streamTransport &&
streamTransport.stderr &&
typeof streamTransport.stderr.on === 'function'
) {
streamTransport.stderr.on('data', (data: unknown) => {
if (debugMode) {
const stderrStr = String(data).trim();
debugLogger.debug(
`[DEBUG] [MCP STDERR (${mcpServerName})]: `,
stderrStr,
);
});
}
}
});
}
return transport;
}
+4 -2
View File
@@ -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 () => {
+9 -2
View File
@@ -37,6 +37,7 @@ describe('Tracker Tools Integration', () => {
model: 'gemini-3-flash',
debugMode: false,
});
await config.initialize();
messageBus = new MessageBus(null as unknown as PolicyEngine, false);
});
@@ -120,8 +121,14 @@ describe('Tracker Tools Integration', () => {
);
const tasks = await config.getTrackerService().listTasks();
const parentId = tasks.find((t) => t.title === 'Parent Task')!.id;
const childId = tasks.find((t) => t.title === 'Child Task')!.id;
const parentTask = tasks.find((t) => t.title === 'Parent Task');
const childTask = tasks.find((t) => t.title === 'Child Task');
expect(parentTask).toBeDefined();
expect(childTask).toBeDefined();
const parentId = parentTask!.id;
const childId = childTask!.id;
// Add Dependency
const addDepTool = new TrackerAddDependencyTool(config, messageBus);
+6
View File
@@ -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
+1
View File
@@ -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.
+13 -1
View File
@@ -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;
}
+207
View File
@@ -0,0 +1,207 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import {
TrustLevel,
loadTrustedFolders,
resetTrustedFoldersForTesting,
checkPathTrust,
} from './trust.js';
import { Storage } from '../config/storage.js';
import { lock } from 'proper-lockfile';
import { ideContextStore } from '../ide/ideContext.js';
import * as headless from './headless.js';
import { coreEvents } from './events.js';
vi.mock('proper-lockfile');
vi.mock('./headless.js', async (importOriginal) => {
const original = await importOriginal<typeof import('./headless.js')>();
return {
...original,
isHeadlessMode: vi.fn(),
};
});
describe('Trust Utility (Core)', () => {
const tempDir = path.join(
os.tmpdir(),
'gemini-trust-test-' + Math.random().toString(36).slice(2),
);
const trustedFoldersPath = path.join(tempDir, 'trustedFolders.json');
beforeEach(() => {
fs.mkdirSync(tempDir, { recursive: true });
vi.spyOn(Storage, 'getTrustedFoldersPath').mockReturnValue(
trustedFoldersPath,
);
vi.mocked(lock).mockResolvedValue(vi.fn().mockResolvedValue(undefined));
vi.mocked(headless.isHeadlessMode).mockReturnValue(false);
ideContextStore.clear();
resetTrustedFoldersForTesting();
delete process.env['GEMINI_CLI_TRUST_WORKSPACE'];
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
it('should load empty config if file does not exist', () => {
const folders = loadTrustedFolders();
expect(folders.user.config).toEqual({});
expect(folders.errors).toEqual([]);
});
it('should load config from file', () => {
const config = {
[path.resolve('/trusted/path')]: TrustLevel.TRUST_FOLDER,
};
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config));
const folders = loadTrustedFolders();
// Use path.resolve for platform consistency in tests
const normalizedKey = path.resolve('/trusted/path').replace(/\\/g, '/');
const isWindows = process.platform === 'win32';
const finalKey = isWindows ? normalizedKey.toLowerCase() : normalizedKey;
expect(folders.user.config[finalKey]).toBe(TrustLevel.TRUST_FOLDER);
});
it('should handle isPathTrusted with longest match', () => {
const config = {
[path.resolve('/a')]: TrustLevel.TRUST_FOLDER,
[path.resolve('/a/b')]: TrustLevel.DO_NOT_TRUST,
[path.resolve('/a/b/c')]: TrustLevel.TRUST_FOLDER,
};
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config));
const folders = loadTrustedFolders();
expect(folders.isPathTrusted(path.resolve('/a/file.txt'))).toBe(true);
expect(folders.isPathTrusted(path.resolve('/a/b/file.txt'))).toBe(false);
expect(folders.isPathTrusted(path.resolve('/a/b/c/file.txt'))).toBe(true);
expect(folders.isPathTrusted(path.resolve('/other'))).toBeUndefined();
});
it('should handle TRUST_PARENT', () => {
const config = {
[path.resolve('/project/.gemini')]: TrustLevel.TRUST_PARENT,
};
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config));
const folders = loadTrustedFolders();
expect(folders.isPathTrusted(path.resolve('/project/file.txt'))).toBe(true);
expect(
folders.isPathTrusted(path.resolve('/project/.gemini/config.yaml')),
).toBe(true);
});
it('should save config correctly', async () => {
const folders = loadTrustedFolders();
const testPath = path.resolve('/new/trusted/path');
await folders.setValue(testPath, TrustLevel.TRUST_FOLDER);
const savedContent = JSON.parse(
fs.readFileSync(trustedFoldersPath, 'utf-8'),
);
const normalizedKey = testPath.replace(/\\/g, '/');
const isWindows = process.platform === 'win32';
const finalKey = isWindows ? normalizedKey.toLowerCase() : normalizedKey;
expect(savedContent[finalKey]).toBe(TrustLevel.TRUST_FOLDER);
});
it('should handle comments in JSON', () => {
const content = `
{
// This is a comment
"path": "TRUST_FOLDER"
}
`;
fs.writeFileSync(trustedFoldersPath, content);
const folders = loadTrustedFolders();
expect(folders.errors).toHaveLength(0);
});
describe('checkPathTrust', () => {
it('should NOT return trusted if headless mode is on by default', () => {
const result = checkPathTrust({
path: '/any',
isFolderTrustEnabled: true,
isHeadless: true,
});
expect(result).toEqual({ isTrusted: undefined, source: undefined });
});
it('should return trusted if folder trust is disabled', () => {
const result = checkPathTrust({
path: '/any',
isFolderTrustEnabled: false,
});
expect(result).toEqual({ isTrusted: true, source: undefined });
});
it('should return IDE trust if available', () => {
ideContextStore.set({
workspaceState: { isTrusted: true },
});
const result = checkPathTrust({
path: '/any',
isFolderTrustEnabled: true,
});
expect(result).toEqual({ isTrusted: true, source: 'ide' });
});
it('should fall back to file trust', () => {
const config = {
[path.resolve('/trusted')]: TrustLevel.TRUST_FOLDER,
};
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config));
const result = checkPathTrust({
path: path.resolve('/trusted/file.txt'),
isFolderTrustEnabled: true,
});
expect(result).toEqual({ isTrusted: true, source: 'file' });
});
it('should return undefined trust if no rule matches', () => {
const result = checkPathTrust({
path: '/any',
isFolderTrustEnabled: true,
});
expect(result).toEqual({ isTrusted: undefined, source: undefined });
});
});
describe('coreEvents.emitFeedback', () => {
it('should report corrupted config via coreEvents.emitFeedback in setValue', async () => {
const folders = loadTrustedFolders();
const testPath = path.resolve('/new/path');
// Initialize with valid JSON
fs.writeFileSync(trustedFoldersPath, '{}', 'utf-8');
// Corrupt the file after initial load
fs.writeFileSync(trustedFoldersPath, 'invalid json', 'utf-8');
const spy = vi.spyOn(coreEvents, 'emitFeedback');
await folders.setValue(testPath, TrustLevel.TRUST_FOLDER);
expect(spy).toHaveBeenCalledWith(
'error',
expect.stringContaining('may be corrupted'),
expect.any(Error),
);
});
});
});
+356
View File
@@ -0,0 +1,356 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as crypto from 'node:crypto';
import { lock } from 'proper-lockfile';
import stripJsonComments from 'strip-json-comments';
import { Storage } from '../config/storage.js';
import { normalizePath, isSubpath } from './paths.js';
import { FatalConfigError, getErrorMessage } from './errors.js';
import { coreEvents } from './events.js';
import { ideContextStore } from '../ide/ideContext.js';
export enum TrustLevel {
TRUST_FOLDER = 'TRUST_FOLDER',
TRUST_PARENT = 'TRUST_PARENT',
DO_NOT_TRUST = 'DO_NOT_TRUST',
}
export interface TrustResult {
isTrusted: boolean | undefined;
source: 'ide' | 'file' | 'env' | undefined;
}
export interface TrustOptions {
path: string;
isFolderTrustEnabled: boolean;
isHeadless?: boolean;
}
export function isTrustLevel(value: unknown): value is TrustLevel {
return (
typeof value === 'string' &&
Object.values(TrustLevel).some((v) => v === value)
);
}
/**
* Checks if a path is trusted based on headless mode, folder trust settings,
* IDE context, and local configuration file.
*/
export function checkPathTrust(options: TrustOptions): TrustResult {
if (process.env['GEMINI_CLI_TRUST_WORKSPACE'] === 'true') {
return { isTrusted: true, source: 'env' };
}
if (!options.isFolderTrustEnabled) {
return { isTrusted: true, source: undefined };
}
const ideTrust = ideContextStore.get()?.workspaceState?.isTrusted;
if (ideTrust !== undefined) {
return { isTrusted: ideTrust, source: 'ide' };
}
const folders = loadTrustedFolders();
if (folders.errors.length > 0) {
const errorMessages = folders.errors.map(
(error) => `Error in ${error.path}: ${error.message}`,
);
throw new FatalConfigError(
`${errorMessages.join('\n')}\nPlease fix the configuration file and try again.`,
);
}
const isTrusted = folders.isPathTrusted(options.path);
return {
isTrusted,
source: isTrusted !== undefined ? 'file' : undefined,
};
}
export interface TrustRule {
path: string;
trustLevel: TrustLevel;
}
export interface TrustedFoldersError {
message: string;
path: string;
}
export interface TrustedFoldersFile {
config: Record<string, TrustLevel>;
path: string;
}
const realPathCache = new Map<string, string>();
/**
* Parses the trusted folders JSON content, stripping comments.
*/
function parseTrustedFoldersJson(content: string): unknown {
return JSON.parse(stripJsonComments(content));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* FOR TESTING PURPOSES ONLY.
* Clears the real path cache.
*/
export function clearRealPathCacheForTesting(): void {
realPathCache.clear();
}
function getRealPath(location: string): string {
let realPath = realPathCache.get(location);
if (realPath !== undefined) {
return realPath;
}
try {
realPath = fs.existsSync(location) ? fs.realpathSync(location) : location;
} catch {
realPath = location;
}
realPathCache.set(location, realPath);
return realPath;
}
export class LoadedTrustedFolders {
constructor(
readonly user: TrustedFoldersFile,
readonly errors: TrustedFoldersError[],
) {}
get rules(): TrustRule[] {
return Object.entries(this.user.config).map(([path, trustLevel]) => ({
path,
trustLevel,
}));
}
/**
* Returns true or false if the path should be "trusted" based on the configuration.
*
* @param location path
* @param config optional config override
* @returns boolean if trusted/distrusted, undefined if no rule matches
*/
isPathTrusted(
location: string,
config?: Record<string, TrustLevel>,
): boolean | undefined {
const configToUse = config ?? this.user.config;
// Resolve location to its realpath for canonical comparison
const realLocation = getRealPath(location);
const normalizedLocation = normalizePath(realLocation);
let longestMatchLen = -1;
let longestMatchTrust: TrustLevel | undefined = undefined;
for (const [rulePath, trustLevel] of Object.entries(configToUse)) {
const effectivePath =
trustLevel === TrustLevel.TRUST_PARENT
? path.dirname(rulePath)
: rulePath;
// Resolve effectivePath to its realpath for canonical comparison
const realEffectivePath = getRealPath(effectivePath);
const normalizedEffectivePath = normalizePath(realEffectivePath);
if (isSubpath(normalizedEffectivePath, normalizedLocation)) {
if (rulePath.length > longestMatchLen) {
longestMatchLen = rulePath.length;
longestMatchTrust = trustLevel;
}
}
}
if (longestMatchTrust === TrustLevel.DO_NOT_TRUST) return false;
if (
longestMatchTrust === TrustLevel.TRUST_FOLDER ||
longestMatchTrust === TrustLevel.TRUST_PARENT
) {
return true;
}
return undefined;
}
async setValue(folderPath: string, trustLevel: TrustLevel): Promise<void> {
if (this.errors.length > 0) {
const errorMessages = this.errors.map(
(error) => `Error in ${error.path}: ${error.message}`,
);
throw new FatalConfigError(
`Cannot update trusted folders because the configuration file is invalid:\n${errorMessages.join('\n')}\nPlease fix the file manually before trying to update it.`,
);
}
const dirPath = path.dirname(this.user.path);
if (!fs.existsSync(dirPath)) {
await fs.promises.mkdir(dirPath, { recursive: true });
}
// lockfile requires the file to exist
if (!fs.existsSync(this.user.path)) {
await fs.promises.writeFile(this.user.path, JSON.stringify({}, null, 2), {
// Restrict file access to read/write for the owner only
mode: 0o600,
});
}
const release = await lock(this.user.path, {
retries: {
retries: 10,
minTimeout: 100,
},
});
const normalizedPath = normalizePath(folderPath);
const originalTrustLevel = this.user.config[normalizedPath];
try {
// Re-read the file to handle concurrent updates
const content = await fs.promises.readFile(this.user.path, 'utf-8');
const config: Record<string, TrustLevel> = {};
try {
const parsed = parseTrustedFoldersJson(content);
if (isRecord(parsed)) {
for (const [rawPath, value] of Object.entries(parsed)) {
if (isTrustLevel(value)) {
config[rawPath] = value;
}
}
}
} catch (error) {
coreEvents.emitFeedback(
'error',
`Failed to parse trusted folders file at ${this.user.path}. The file may be corrupted.`,
error,
);
}
// Use normalized path as key
config[normalizedPath] = trustLevel;
this.user.config[normalizedPath] = trustLevel;
try {
saveTrustedFolders({ ...this.user, config });
} catch (e) {
// Revert the in-memory change if the save failed.
if (originalTrustLevel === undefined) {
delete this.user.config[normalizedPath];
} else {
this.user.config[normalizedPath] = originalTrustLevel;
}
throw e;
}
} finally {
await release();
}
}
}
let loadedTrustedFolders: LoadedTrustedFolders | undefined;
/**
* FOR TESTING PURPOSES ONLY.
* Resets the in-memory cache of the trusted folders configuration.
*/
export function resetTrustedFoldersForTesting(): void {
loadedTrustedFolders = undefined;
clearRealPathCacheForTesting();
}
export function loadTrustedFolders(): LoadedTrustedFolders {
if (loadedTrustedFolders) {
return loadedTrustedFolders;
}
const errors: TrustedFoldersError[] = [];
const userConfig: Record<string, TrustLevel> = {};
const userPath = Storage.getTrustedFoldersPath();
try {
if (fs.existsSync(userPath)) {
const content = fs.readFileSync(userPath, 'utf-8');
const parsed = parseTrustedFoldersJson(content);
if (!isRecord(parsed)) {
errors.push({
message: 'Trusted folders file is not a valid JSON object.',
path: userPath,
});
} else {
for (const [rawPath, trustLevel] of Object.entries(parsed)) {
const normalizedPath = normalizePath(rawPath);
if (isTrustLevel(trustLevel)) {
userConfig[normalizedPath] = trustLevel;
} else {
const possibleValues = Object.values(TrustLevel).join(', ');
errors.push({
message: `Invalid trust level "${trustLevel}" for path "${rawPath}". Possible values are: ${possibleValues}.`,
path: userPath,
});
}
}
}
}
} catch (error) {
errors.push({
message: getErrorMessage(error),
path: userPath,
});
}
loadedTrustedFolders = new LoadedTrustedFolders(
{ path: userPath, config: userConfig },
errors,
);
return loadedTrustedFolders;
}
export function saveTrustedFolders(
trustedFoldersFile: TrustedFoldersFile,
): void {
// Ensure the directory exists
const dirPath = path.dirname(trustedFoldersFile.path);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
const content = JSON.stringify(trustedFoldersFile.config, null, 2);
const tempPath = `${trustedFoldersFile.path}.tmp.${crypto.randomUUID()}`;
try {
fs.writeFileSync(tempPath, content, {
encoding: 'utf-8',
// Restrict file access to read/write for the owner only
mode: 0o600,
});
fs.renameSync(tempPath, trustedFoldersFile.path);
} catch (error) {
// Clean up temp file if it was created but rename failed
if (fs.existsSync(tempPath)) {
try {
fs.unlinkSync(tempPath);
} catch {
// Ignore cleanup errors
}
}
throw error;
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-devtools",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"license": "Apache-2.0",
"type": "module",
"main": "dist/src/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-sdk",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"description": "Gemini CLI SDK",
"license": "Apache-2.0",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-test-utils",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",
+1
View File
@@ -680,6 +680,7 @@ export class TestRig {
key !== 'GEMINI_DEBUG' &&
key !== 'GEMINI_CLI_TEST_VAR' &&
key !== 'GEMINI_CLI_INTEGRATION_TEST' &&
key !== 'GOOGLE_GEMINI_BASE_URL' &&
!key.startsWith('GEMINI_CLI_ACTIVITY_LOG')
) {
delete cleanEnv[key];
+1 -1
View File
@@ -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.41.0-nightly.20260423.gaa05b4583",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {
File diff suppressed because one or more lines are too long
+51
View File
@@ -0,0 +1,51 @@
# Gemini CLI Bot (Cognitive Repository)
This directory contains the foundational architecture for the `gemini-cli-bot`,
transforming the repository into a proactive, evolutionary system.
It implements a dual-layer approach to balance immediate responsiveness with
long-term strategic optimization.
## Layered Execution Model
### 1. System 1: The Pulse (Reflex Layer)
- **Purpose**: High-frequency, deterministic maintenance and data collection.
- **Frequency**: 30-minute cron (`.github/workflows/gemini-cli-bot-pulse.yml`).
- **Implementation**: Pure TypeScript/JavaScript scripts.
- **Role**: Currently focuses on gathering repository metrics
(`tools/gemini-cli-bot/metrics/scripts`).
- **Output**: Action execution and `metrics-before.csv` artifact generation.
### 2. System 2: The Brain (Reasoning Layer)
- **Purpose**: Strategic investigation, policy refinement, and
self-optimization.
- **Frequency**: 24-hour cron (`.github/workflows/gemini-cli-bot-brain.yml`).
- **Implementation**: Agentic Gemini CLI phases.
- **Role**: Analyzing metric trends and running deeper repository health
investigations.
## Directory Structure
- `metrics/`: Contains the deterministic runner (`index.ts`) and individual
TypeScript scripts (`scripts/`) that use the GitHub CLI to track metrics like
open issues, PR latency, throughput, and reviewer domain expertise.
- `processes/scripts/`: Placeholder directory for future deterministic triage
and routing scripts.
- `investigations/`: Placeholder directory for agentic root-cause analysis
phases.
- `critique/`: Placeholder directory for policy evaluation.
- `history/`: Storage for downloaded metrics artifacts from previous runs.
## Usage
To manually collect repository metrics locally, run the following command from
the workspace root:
```bash
npm run metrics
```
This will execute all scripts within `metrics/scripts/` and output the results
to a `metrics-before.csv` file in the root directory.
+69
View File
@@ -0,0 +1,69 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { readdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { execSync } from 'node:child_process';
const SCRIPTS_DIR = join(
process.cwd(),
'tools',
'gemini-cli-bot',
'metrics',
'scripts',
);
const OUTPUT_FILE = join(process.cwd(), 'metrics-before.csv');
function processOutputLine(line: string, results: string[]) {
const trimmedLine = line.trim();
if (!trimmedLine) return;
try {
const parsed = JSON.parse(trimmedLine);
if (
parsed &&
typeof parsed === 'object' &&
'metric' in parsed &&
'value' in parsed
) {
results.push(`${parsed.metric},${parsed.value}`);
} else {
results.push(trimmedLine);
}
} catch {
results.push(trimmedLine);
}
}
async function run() {
const scripts = readdirSync(SCRIPTS_DIR).filter(
(file) => file.endsWith('.ts') || file.endsWith('.js'),
);
const results: string[] = ['metric,value'];
for (const script of scripts) {
console.log(`Running metric script: ${script}`);
try {
const scriptPath = join(SCRIPTS_DIR, script);
const output = execSync(`npx tsx ${JSON.stringify(scriptPath)}`, {
encoding: 'utf-8',
});
const lines = output.trim().split('\n');
for (const line of lines) {
processOutputLine(line, results);
}
} catch (error) {
console.error(`Error running ${script}:`, error);
}
}
writeFileSync(OUTPUT_FILE, results.join('\n'));
console.log(`Saved metrics to ${OUTPUT_FILE}`);
}
run().catch(console.error);
@@ -0,0 +1,157 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*
* @license
*/
import { GITHUB_OWNER, GITHUB_REPO, MetricOutput } from '../types.js';
import { execSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '../../../../');
try {
// 1. Fetch recent PR numbers and reviews from GitHub (so we have reviewer names/logins)
const query = `
query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
pullRequests(last: 100, states: MERGED) {
nodes {
number
reviews(first: 20) {
nodes {
authorAssociation
author { login, ... on User { name } }
}
}
}
}
}
}
`;
const output = execSync(
`gh api graphql -F owner=${GITHUB_OWNER} -F repo=${GITHUB_REPO} -f query='${query}'`,
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] },
);
const data = JSON.parse(output).data.repository;
// 2. Map PR numbers to local commits using git log
const logOutput = execSync('git log -n 5000 --format="%H|%s"', {
cwd: repoRoot,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
});
const prCommits = new Map<number, string>();
for (const line of logOutput.split('\n')) {
if (!line) continue;
const [hash, subject] = line.split('|');
const match = subject.match(/\(#(\d+)\)$/);
if (match) {
prCommits.set(parseInt(match[1], 10), hash);
}
}
let totalMaintainerReviews = 0;
let maintainerReviewsWithExpertise = 0;
for (const pr of data.pullRequests.nodes) {
if (!pr.reviews?.nodes || pr.reviews.nodes.length === 0) continue;
const commitHash = prCommits.get(pr.number);
if (!commitHash) continue; // Skip if we don't have the commit locally
// 3. Get exact files changed using local git diff-tree, bypassing GraphQL limits
const diffTreeOutput = execSync(
`git diff-tree --no-commit-id --name-only -r ${commitHash}`,
{ cwd: repoRoot, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] },
);
const files = diffTreeOutput.split('\n').filter(Boolean);
if (files.length === 0) continue;
// Cache git log authors per path to avoid redundant child_process calls
const authorCache = new Map<string, string>();
const getAuthors = (targetPath: string) => {
if (authorCache.has(targetPath)) return authorCache.get(targetPath)!;
try {
const authors = execSync(
`git log --format="%an|%ae" -- ${JSON.stringify(targetPath)}`,
{
cwd: repoRoot,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
},
).toLowerCase();
authorCache.set(targetPath, authors);
return authors;
} catch {
authorCache.set(targetPath, '');
return '';
}
};
const reviewersOnPR = new Map<string, { name?: string }>();
for (const review of pr.reviews.nodes) {
if (
['MEMBER', 'OWNER'].includes(review.authorAssociation) &&
review.author?.login
) {
const login = review.author.login.toLowerCase();
if (login.endsWith('[bot]') || login.includes('bot')) continue;
reviewersOnPR.set(login, review.author);
}
}
for (const [login, authorInfo] of reviewersOnPR.entries()) {
totalMaintainerReviews++;
let hasExpertise = false;
const name = authorInfo.name ? authorInfo.name.toLowerCase() : '';
for (const file of files) {
// Precise check: immediate file
let authorsStr = getAuthors(file);
if (authorsStr.includes(login) || (name && authorsStr.includes(name))) {
hasExpertise = true;
break;
}
// Fallback: file's directory
const dir = path.dirname(file);
authorsStr = getAuthors(dir);
if (authorsStr.includes(login) || (name && authorsStr.includes(name))) {
hasExpertise = true;
break;
}
}
if (hasExpertise) {
maintainerReviewsWithExpertise++;
}
}
}
const ratio =
totalMaintainerReviews > 0
? maintainerReviewsWithExpertise / totalMaintainerReviews
: 0;
const timestamp = new Date().toISOString();
process.stdout.write(
JSON.stringify(<MetricOutput>{
metric: 'domain_expertise',
value: Math.round(ratio * 100) / 100,
timestamp,
details: {
totalMaintainerReviews,
maintainerReviewsWithExpertise,
},
}) + '\n',
);
} catch (err) {
process.stderr.write(err instanceof Error ? err.message : String(err));
process.exit(1);
}
@@ -0,0 +1,138 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*
* @license
*/
import { GITHUB_OWNER, GITHUB_REPO, MetricOutput } from '../types.js';
import { execSync } from 'node:child_process';
try {
const query = `
query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
pullRequests(last: 100, states: MERGED) {
nodes {
authorAssociation
createdAt
mergedAt
}
}
issues(last: 100, states: CLOSED) {
nodes {
authorAssociation
createdAt
closedAt
}
}
}
}
`;
const output = execSync(
`gh api graphql -F owner=${GITHUB_OWNER} -F repo=${GITHUB_REPO} -f query='${query}'`,
{ encoding: 'utf-8' },
);
const data = JSON.parse(output).data.repository;
const prs = data.pullRequests.nodes.map(
(p: {
authorAssociation: string;
mergedAt: string;
createdAt: string;
}) => ({
association: p.authorAssociation,
latencyHours:
(new Date(p.mergedAt).getTime() - new Date(p.createdAt).getTime()) /
(1000 * 60 * 60),
}),
);
const issues = data.issues.nodes.map(
(i: {
authorAssociation: string;
closedAt: string;
createdAt: string;
}) => ({
association: i.authorAssociation,
latencyHours:
(new Date(i.closedAt).getTime() - new Date(i.createdAt).getTime()) /
(1000 * 60 * 60),
}),
);
const isMaintainer = (assoc: string) =>
['MEMBER', 'OWNER', 'COLLABORATOR'].includes(assoc);
const calculateAvg = (
items: { association: string; latencyHours: number }[],
) =>
items.length
? items.reduce((a, b) => a + b.latencyHours, 0) / items.length
: 0;
const prMaintainers = calculateAvg(
prs.filter((i: { association: string; latencyHours: number }) =>
isMaintainer(i.association),
),
);
const prCommunity = calculateAvg(
prs.filter(
(i: { association: string; latencyHours: number }) =>
!isMaintainer(i.association),
),
);
const prOverall = calculateAvg(prs);
const issueMaintainers = calculateAvg(
issues.filter((i: { association: string; latencyHours: number }) =>
isMaintainer(i.association),
),
);
const issueCommunity = calculateAvg(
issues.filter(
(i: { association: string; latencyHours: number }) =>
!isMaintainer(i.association),
),
);
const issueOverall = calculateAvg(issues);
const timestamp = new Date().toISOString();
const metrics: MetricOutput[] = [
{
metric: 'latency_pr_overall_hours',
value: Math.round(prOverall * 100) / 100,
timestamp,
},
{
metric: 'latency_pr_maintainers_hours',
value: Math.round(prMaintainers * 100) / 100,
timestamp,
},
{
metric: 'latency_pr_community_hours',
value: Math.round(prCommunity * 100) / 100,
timestamp,
},
{
metric: 'latency_issue_overall_hours',
value: Math.round(issueOverall * 100) / 100,
timestamp,
},
{
metric: 'latency_issue_maintainers_hours',
value: Math.round(issueMaintainers * 100) / 100,
timestamp,
},
{
metric: 'latency_issue_community_hours',
value: Math.round(issueCommunity * 100) / 100,
timestamp,
},
];
metrics.forEach((m) => process.stdout.write(JSON.stringify(m) + '\n'));
} catch (err) {
process.stderr.write(err instanceof Error ? err.message : String(err));
process.exit(1);
}
@@ -0,0 +1,20 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { execSync } from 'node:child_process';
try {
const count = execSync(
'gh issue list --state open --limit 1000 --json number --jq length',
{
encoding: 'utf-8',
},
).trim();
console.log(`open_issues,${count}`);
} catch {
// Fallback if gh fails or no issues found
console.log('open_issues,0');
}
@@ -0,0 +1,20 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { execSync } from 'node:child_process';
try {
const count = execSync(
'gh pr list --state open --limit 1000 --json number --jq length',
{
encoding: 'utf-8',
},
).trim();
console.log(`open_prs,${count}`);
} catch {
// Fallback if gh fails or no PRs found
console.log('open_prs,0');
}
@@ -0,0 +1,82 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*
* @license
*/
import { GITHUB_OWNER, GITHUB_REPO, MetricOutput } from '../types.js';
import { execSync } from 'node:child_process';
try {
const query = `
query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
pullRequests(last: 100) {
nodes {
reviews(first: 50) {
nodes {
author { login }
authorAssociation
}
}
}
}
}
}
`;
const output = execSync(
`gh api graphql -F owner=${GITHUB_OWNER} -F repo=${GITHUB_REPO} -f query='${query}'`,
{ encoding: 'utf-8' },
);
const data = JSON.parse(output).data.repository;
const reviewCounts: Record<string, number> = {};
for (const pr of data.pullRequests.nodes) {
if (!pr.reviews?.nodes) continue;
// We only count one review per author per PR to avoid counting multiple review comments as multiple reviews
const reviewersOnPR = new Set<string>();
for (const review of pr.reviews.nodes) {
if (
['MEMBER', 'OWNER'].includes(review.authorAssociation) &&
review.author?.login
) {
const login = review.author.login.toLowerCase();
if (login.endsWith('[bot]') || login.includes('bot')) {
continue; // Ignore bots
}
reviewersOnPR.add(review.author.login);
}
}
for (const reviewer of reviewersOnPR) {
reviewCounts[reviewer] = (reviewCounts[reviewer] || 0) + 1;
}
}
const counts = Object.values(reviewCounts);
let variance = 0;
if (counts.length > 0) {
const mean = counts.reduce((a, b) => a + b, 0) / counts.length;
variance =
counts.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / counts.length;
}
const timestamp = new Date().toISOString();
process.stdout.write(
JSON.stringify(<MetricOutput>{
metric: 'review_distribution_variance',
value: Math.round(variance * 100) / 100,
timestamp,
details: reviewCounts,
}) + '\n',
);
} catch (err) {
process.stderr.write(err instanceof Error ? err.message : String(err));
process.exit(1);
}
@@ -0,0 +1,148 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*
* @license
*/
import { GITHUB_OWNER, GITHUB_REPO, MetricOutput } from '../types.js';
import { execSync } from 'node:child_process';
try {
const query = `
query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
pullRequests(last: 100, states: MERGED) {
nodes {
authorAssociation
mergedAt
}
}
issues(last: 100, states: CLOSED) {
nodes {
authorAssociation
closedAt
}
}
}
}
`;
const output = execSync(
`gh api graphql -F owner=${GITHUB_OWNER} -F repo=${GITHUB_REPO} -f query='${query}'`,
{ encoding: 'utf-8' },
);
const data = JSON.parse(output).data.repository;
const prs = data.pullRequests.nodes
.map((p: { authorAssociation: string; mergedAt: string }) => ({
association: p.authorAssociation,
date: new Date(p.mergedAt).getTime(),
}))
.sort((a: { date: number }, b: { date: number }) => a.date - b.date);
const issues = data.issues.nodes
.map((i: { authorAssociation: string; closedAt: string }) => ({
association: i.authorAssociation,
date: new Date(i.closedAt).getTime(),
}))
.sort((a: { date: number }, b: { date: number }) => a.date - b.date);
const isMaintainer = (assoc: string) =>
['MEMBER', 'OWNER', 'COLLABORATOR'].includes(assoc);
const calculateThroughput = (
items: { association: string; date: number }[],
) => {
if (items.length < 2) return 0;
const first = items[0].date;
const last = items[items.length - 1].date;
const days = (last - first) / (1000 * 60 * 60 * 24);
return days > 0 ? items.length / days : items.length; // items per day
};
const prOverall = calculateThroughput(prs);
const prMaintainers = calculateThroughput(
prs.filter((i: { association: string; date: number }) =>
isMaintainer(i.association),
),
);
const prCommunity = calculateThroughput(
prs.filter(
(i: { association: string; date: number }) =>
!isMaintainer(i.association),
),
);
const issueOverall = calculateThroughput(issues);
const issueMaintainers = calculateThroughput(
issues.filter((i: { association: string; date: number }) =>
isMaintainer(i.association),
),
);
const issueCommunity = calculateThroughput(
issues.filter(
(i: { association: string; date: number }) =>
!isMaintainer(i.association),
),
);
const timestamp = new Date().toISOString();
const metrics: MetricOutput[] = [
{
metric: 'throughput_pr_overall_per_day',
value: Math.round(prOverall * 100) / 100,
timestamp,
},
{
metric: 'throughput_pr_maintainers_per_day',
value: Math.round(prMaintainers * 100) / 100,
timestamp,
},
{
metric: 'throughput_pr_community_per_day',
value: Math.round(prCommunity * 100) / 100,
timestamp,
},
{
metric: 'throughput_issue_overall_per_day',
value: Math.round(issueOverall * 100) / 100,
timestamp,
},
{
metric: 'throughput_issue_maintainers_per_day',
value: Math.round(issueMaintainers * 100) / 100,
timestamp,
},
{
metric: 'throughput_issue_community_per_day',
value: Math.round(issueCommunity * 100) / 100,
timestamp,
},
{
metric: 'throughput_issue_overall_days_per_issue',
value: issueOverall > 0 ? Math.round((1 / issueOverall) * 100) / 100 : 0,
timestamp,
},
{
metric: 'throughput_issue_maintainers_days_per_issue',
value:
issueMaintainers > 0
? Math.round((1 / issueMaintainers) * 100) / 100
: 0,
timestamp,
},
{
metric: 'throughput_issue_community_days_per_issue',
value:
issueCommunity > 0 ? Math.round((1 / issueCommunity) * 100) / 100 : 0,
timestamp,
},
];
metrics.forEach((m) => process.stdout.write(JSON.stringify(m) + '\n'));
} catch (err) {
process.stderr.write(err instanceof Error ? err.message : String(err));
process.exit(1);
}
@@ -0,0 +1,157 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*
* @license
*/
import { GITHUB_OWNER, GITHUB_REPO, MetricOutput } from '../types.js';
import { execSync } from 'node:child_process';
try {
const query = `
query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
pullRequests(last: 100) {
nodes {
authorAssociation
author { login }
createdAt
comments(first: 20) {
nodes {
author { login }
createdAt
}
}
reviews(first: 20) {
nodes {
author { login }
createdAt
}
}
}
}
issues(last: 100) {
nodes {
authorAssociation
author { login }
createdAt
comments(first: 20) {
nodes {
author { login }
createdAt
}
}
}
}
}
}
`;
const output = execSync(
`gh api graphql -F owner=${GITHUB_OWNER} -F repo=${GITHUB_REPO} -f query='${query}'`,
{ encoding: 'utf-8' },
);
const data = JSON.parse(output).data.repository;
const getFirstResponseTime = (item: {
createdAt: string;
author: { login: string };
comments: { nodes: { createdAt: string; author?: { login: string } }[] };
reviews?: { nodes: { createdAt: string; author?: { login: string } }[] };
}) => {
const authorLogin = item.author?.login;
let earliestResponse: number | null = null;
const checkNodes = (
nodes: { createdAt: string; author?: { login: string } }[],
) => {
for (const node of nodes) {
if (node.author?.login && node.author.login !== authorLogin) {
const login = node.author.login.toLowerCase();
if (login.endsWith('[bot]') || login.includes('bot')) {
continue; // Ignore bots
}
const time = new Date(node.createdAt).getTime();
if (!earliestResponse || time < earliestResponse) {
earliestResponse = time;
}
}
}
};
if (item.comments?.nodes) checkNodes(item.comments.nodes);
if (item.reviews?.nodes) checkNodes(item.reviews.nodes);
if (earliestResponse) {
return (
(earliestResponse - new Date(item.createdAt).getTime()) /
(1000 * 60 * 60)
);
}
return null; // No response yet
};
const processItems = (
items: {
authorAssociation: string;
createdAt: string;
author: { login: string };
comments: {
nodes: { createdAt: string; author?: { login: string } }[];
};
reviews?: {
nodes: { createdAt: string; author?: { login: string } }[];
};
}[],
) => {
return items
.map((item) => ({
association: item.authorAssociation,
ttfr: getFirstResponseTime(item),
}))
.filter((i) => i.ttfr !== null) as {
association: string;
ttfr: number;
}[];
};
const prs = processItems(data.pullRequests.nodes);
const issues = processItems(data.issues.nodes);
const allItems = [...prs, ...issues];
const isMaintainer = (assoc: string) => ['MEMBER', 'OWNER'].includes(assoc);
const is1P = (assoc: string) => ['COLLABORATOR'].includes(assoc);
const calculateAvg = (items: { ttfr: number; association: string }[]) =>
items.length ? items.reduce((a, b) => a + b.ttfr, 0) / items.length : 0;
const maintainers = calculateAvg(
allItems.filter((i) => isMaintainer(i.association)),
);
const firstParty = calculateAvg(allItems.filter((i) => is1P(i.association)));
const overall = calculateAvg(allItems);
const timestamp = new Date().toISOString();
const metrics: MetricOutput[] = [
{
metric: 'time_to_first_response_overall_hours',
value: Math.round(overall * 100) / 100,
timestamp,
},
{
metric: 'time_to_first_response_maintainers_hours',
value: Math.round(maintainers * 100) / 100,
timestamp,
},
{
metric: 'time_to_first_response_1p_hours',
value: Math.round(firstParty * 100) / 100,
timestamp,
},
];
metrics.forEach((m) => process.stdout.write(JSON.stringify(m) + '\n'));
} catch (err) {
process.stderr.write(err instanceof Error ? err.message : String(err));
process.exit(1);
}
@@ -0,0 +1,100 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*
* @license
*/
import { GITHUB_OWNER, GITHUB_REPO, type MetricOutput } from '../types.js';
import { execSync } from 'node:child_process';
try {
const query = `
query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
pullRequests(last: 100, states: MERGED) {
nodes {
authorAssociation
comments { totalCount }
reviews { totalCount }
}
}
issues(last: 100, states: CLOSED) {
nodes {
authorAssociation
comments { totalCount }
}
}
}
}
`;
const output = execSync(
`gh api graphql -F owner=${GITHUB_OWNER} -F repo=${GITHUB_REPO} -f query='${query}'`,
{ encoding: 'utf-8' },
);
const data = JSON.parse(output).data.repository;
const prs = data.pullRequests.nodes;
const issues = data.issues.nodes;
const allItems = [
...prs.map(
(p: {
authorAssociation: string;
comments: { totalCount: number };
reviews?: { totalCount: number };
}) => ({
association: p.authorAssociation,
touches: p.comments.totalCount + (p.reviews ? p.reviews.totalCount : 0),
}),
),
...issues.map(
(i: { authorAssociation: string; comments: { totalCount: number } }) => ({
association: i.authorAssociation,
touches: i.comments.totalCount,
}),
),
];
const isMaintainer = (assoc: string) =>
['MEMBER', 'OWNER', 'COLLABORATOR'].includes(assoc);
const calculateAvg = (items: { touches: number; association: string }[]) =>
items.length ? items.reduce((a, b) => a + b.touches, 0) / items.length : 0;
const overall = calculateAvg(allItems);
const maintainers = calculateAvg(
allItems.filter((i) => isMaintainer(i.association)),
);
const community = calculateAvg(
allItems.filter((i) => !isMaintainer(i.association)),
);
const timestamp = new Date().toISOString();
process.stdout.write(
JSON.stringify(<MetricOutput>{
metric: 'user_touches_overall',
value: Math.round(overall * 100) / 100,
timestamp,
}) + '\n',
);
process.stdout.write(
JSON.stringify(<MetricOutput>{
metric: 'user_touches_maintainers',
value: Math.round(maintainers * 100) / 100,
timestamp,
}) + '\n',
);
process.stdout.write(
JSON.stringify(<MetricOutput>{
metric: 'user_touches_community',
value: Math.round(community * 100) / 100,
timestamp,
}) + '\n',
);
} catch (err) {
process.stderr.write(err instanceof Error ? err.message : String(err));
process.exit(1);
}

Some files were not shown because too many files have changed in this diff Show More