Compare commits

...

59 Commits

Author SHA1 Message Date
gemini-cli-robot dc859e8e48 chore/release: bump version to 0.55.0-nightly.20260729.g3499c84f7 (#28573) 2026-07-29 18:55:47 +00:00
gemini-cli-robot 4bb7e93c45 Changelog for v0.53.0 (#28568)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-07-29 18:54:53 +00:00
gemini-cli-robot 55a31ef909 Changelog for v0.54.0-preview.0 (#28567)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-07-29 18:54:50 +00:00
gemini-cli-robot 3499c84f7b chore(release): bump version to 0.55.0-nightly.20260728.gd29268d36 (#28569) 2026-07-28 22:10:39 +00:00
Adam Weidman d29268d360 fix(core): skip merged function-response turns when finding the active loop (#28565) 2026-07-28 20:16:53 +00:00
joneba-google fccc043bd4 feat(pr-generator-db): implement Firestore concurrency dual-locking and test ingestion utilities (#28432) 2026-07-28 19:21:50 +00:00
joneba-google c5622fec27 feat(pr-generator-agent): implement Antigravity agent runner and prompt templates … (#28434) 2026-07-28 19:21:30 +00:00
gemini-cli-robot e07280eb4e chore/release: bump version to 0.54.0-nightly.20260728.gbef611950 (#28552) 2026-07-28 17:16:07 +00:00
luisfelipe-alt bef6119500 fix(core): enforce explicit tag length and validation in file keychain (#28523) 2026-07-27 17:36:48 +00:00
luisfelipe-alt b94c9775b1 fix(a2a-server): normalize CRLF line endings to LF in getProposedContent (#28531) 2026-07-27 17:18:58 +00:00
David Pierce 3818efbbfb fix(core): filter out thought parts from getHistoryTurns when context management is disabled (#28509) 2026-07-24 20:48:37 +00:00
amelidev e2a5375d10 fix(core): enforce HTTPS for GoogleCredentialsAuthProvider to prevent cleartext leakage (#28517) 2026-07-24 17:48:07 +00:00
Chad 69b51f8fa2 feat(caretaker-triage): post comment before auto-closing issues (#28411) 2026-07-23 22:43:17 +00:00
amelidev 3c1bb8c35d fix(core): rotate session ID on model fallback to prevent stateful API errors (#28469) 2026-07-23 19:36:46 +00:00
Chad d76d2d0742 chore(caretaker): update vitest to v3.2.4 and add package-lock.json files (#28409) 2026-07-23 17:55:34 +00:00
Chad a96259c9e5 fix(caretaker): sanitize and wrap issue title in untrusted_context (#28352) 2026-07-23 17:55:24 +00:00
gemini-cli-robot 87f785192c chore(release): bump version to 0.54.0-nightly.20260722.gf743ab579 (#28510) 2026-07-23 06:56:57 +00:00
gemini-cli-robot 1c21640f97 Changelog for v0.52.0 (#28508)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-07-23 06:56:45 +00:00
gemini-cli-robot 455d721a0c Changelog for v0.53.0-preview.0 (#28507)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-07-23 06:56:36 +00:00
Vedant Mahajan 9681621c6b feat(evals): add eval coverage report command (#28169) 2026-07-22 18:45:42 +00:00
luisfelipe-alt f743ab5790 fix(core): sequentially verify cached credentials and restore GOOGLE_APPLICATION_CREDENTIALS fallback (#28472)
Co-authored-by: David Pierce <davidapierce@google.com>
2026-07-22 16:09:52 +00:00
luisfelipe-alt c776c665b0 fix(a2a-server): enforce workspace trust and task isolation to prevent RCE (#28470) 2026-07-21 16:38:09 +00:00
amelidev acae7124bd fix(core): mitigate infinite ReAct loops and prompt injection loops (#28429)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-07-17 21:03:46 +00:00
Om Patel 69e0c2659e refactor(cli): align macOS permissive Seatbelt profiles with deny-default model (#28424) 2026-07-17 18:06:01 +00:00
Chad aea9e5e8ed feat(caretaker-triage): implement LLM triage orchestrator and container build (#28345) 2026-07-17 16:59:28 +00:00
luisfelipe-alt 3ff5ba20fc fix(core,a2a): group cancelled tool responses and coalesce consecutive roles to prevent 400 Bad Request (#28407) 2026-07-15 19:58:55 +00:00
gemini-cli-robot 1ae8ba6496 chore/release: bump version to 0.52.0-nightly.20260715.gfa975395b (#28402) 2026-07-15 06:47:31 +00:00
Jerry Lin fa975395bc feat(core): Bump node google-auth-library version to 10.9.0 (#28385)
Co-authored-by: Jerry Lin <jerrysf@google.com>
2026-07-13 20:41:57 +00:00
David Pierce a345621404 fix(core): simplify plan mode write policy to support relative paths (#28398) 2026-07-13 20:16:21 +00:00
luisfelipe-alt f5e58ff18b fix(a2a-server): ensure task cancellation aborts execution loop (#28316) 2026-07-13 17:22:39 +00:00
amelidev 42ee2b74c7 fix(core): enrich shared project quota limit errors with setup hint (#28391) 2026-07-13 17:22:16 +00:00
Om Patel f354eebaf4 fix(privacy): show a clear message when the account has no Code Assist tier (#28304) 2026-07-10 17:07:52 +00:00
Chad a4c91ce191 feat(caretaker-triage): implement main worker execution loop and egress action publisher (#28306) 2026-07-09 22:51:26 +00:00
amelidev 9a023bbba0 fix(core): use unambiguous previous intent label in fallback summary (#28343) 2026-07-09 19:41:49 +00:00
amelidev a8b115caa1 fix(core-tools): bypass LLM correction for JSON and IPYNB files in write_file and replace (#28223)
Co-authored-by: David Pierce <davidapierce@google.com>
2026-07-09 19:31:50 +00:00
gemini-cli-robot 172ff92c34 Changelog for v0.50.0 (#28322)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
Co-authored-by: David Pierce <davidapierce@google.com>
2026-07-08 20:20:03 +00:00
gemini-cli-robot 70f4d573f2 Changelog for v0.51.0-preview.0 (#28320)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-07-08 20:02:41 +00:00
gemini-cli-robot 132f38c3a4 chore(release): bump version to 0.52.0-nightly.20260707.g27a3da3e8 (#28323) 2026-07-08 19:53:11 +00:00
Chad b31b755bbf feat(caretaker-egress): implement octokit github action handler for egress service (#28303) 2026-07-08 00:40:48 +00:00
Chad c988cbb1e3 feat(caretaker): add triage worker core foundational modules (#28163) 2026-07-07 21:37:12 +00:00
David Pierce b7c61c9e3d Refactor: exclude transient CI configuration files from workspace context (#28216) 2026-07-07 20:30:02 +00:00
amelidev 27a3da3e88 fix(core): strip thoughts from scrubbed history turns and resolve thought leakage (#27971)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
Co-authored-by: David Pierce <davidapierce@google.com>
2026-07-07 18:03:31 +00:00
luisfelipe-alt 15a9429b69 fix(core): preserve escape sequences in string literals for modern models (#28299)
Co-authored-by: David Pierce <davidapierce@google.com>
2026-07-06 17:44:05 +00:00
Om Patel 892b35fcfb fix(sandbox): make ~/.gitconfig read-only in the macOS sandbox (#28221)
Co-authored-by: David Pierce <davidapierce@google.com>
2026-07-06 16:37:39 +00:00
Chad f7af4e5180 feat(caretaker): egress cloud run service skeleton (#28167) 2026-07-02 00:44:38 +00:00
luisfelipe-alt ff00dacd9f fix(core): resolve symbolic link directory escape in memory import processor (#28233) 2026-07-01 19:23:32 +00:00
Chad 7f00c5fe59 feat(caretaker): implement Cloud Run webhook ingestion service (#28015)
Co-authored-by: Christian Gunderman <gundermanc@google.com>
2026-06-30 23:34:31 +00:00
luisfelipe-alt b5fc06ee33 fix(core-tools): resolve defensive path resolution for at-reference files and fix macOS tests (#28053)
Co-authored-by: David Pierce <davidapierce@google.com>
2026-06-30 19:45:32 +00:00
luisfelipe-alt ae0a3aa7b9 fix(security): enforce case-insensitive sensitive path blocklist and vscode hitl (#27966)
Co-authored-by: David Pierce <davidapierce@google.com>
2026-06-26 19:36:00 +00:00
David Pierce b14416447e Vertex base url update (#28145) 2026-06-25 20:40:55 +00:00
Jerry Lin 8cd5c0f71f Fix no_proxy test (#28131)
Co-authored-by: Jerry Lin <jerrysf@google.com>
2026-06-25 20:35:41 +00:00
gemini-cli-robot df997354c8 chore(release): bump version to 0.51.0-nightly.20260625.g3fbf93e26 (#28151) 2026-06-25 20:34:00 +00:00
gemini-cli-robot 19ad71b903 Changelog for v0.50.0-preview.1 (#28150)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-06-25 20:14:42 +00:00
Gal Zahavi 3fbf93e26f fix(ci): prevent bad NPM releases and promote job crashes (#28147) 2026-06-25 18:22:56 +00:00
Vedant Mahajan d845bc5d45 Feat/tool registry discovery (#28113) 2026-06-24 23:51:30 +00:00
Gal Zahavi 02c6c77324 fix(ci): prevent workspace binary shadowing in release verification (#28132) 2026-06-24 22:04:47 +00:00
Ramón Medrano Llamas f8541cf7a2 fix/verify release npm ci ignore scripts (#28116) 2026-06-24 03:51:29 +00:00
Vedant Mahajan 6e0bd68e45 Add JSON output for eval inventory (#28058) 2026-06-23 18:48:50 +00:00
Ramón Medrano Llamas d3ef6aca40 fix(ci): use wombat dressing room fallback in nightly release to prevent ENEEDAUTH (#28104) 2026-06-23 14:16:46 +00:00
175 changed files with 19787 additions and 2388 deletions
+31 -13
View File
@@ -197,6 +197,29 @@ runs:
run: |
node ${{ github.workspace }}/scripts/prepare-npm-release.js
- name: '📦 Pack CLI for verification'
if: "inputs.dry-run != 'true' && inputs.force-skip-tests != 'true'"
working-directory: '${{ inputs.working-directory }}'
shell: 'bash'
run: |
npm pack --workspace="${INPUTS_CLI_PACKAGE_NAME}"
# We restore the package.json so that `npm ci` in verify-release doesn't fail due to deleted dependencies
git checkout packages/cli/package.json
env:
INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}'
- name: '🔬 Verify NPM release by version'
uses: './.github/actions/verify-release'
if: "${{ inputs.dry-run != 'true' && inputs.force-skip-tests != 'true' }}"
with:
npm-package: './google-gemini-cli-${{ inputs.release-version }}.tgz'
expected-version: '${{ inputs.release-version }}'
working-directory: '${{ inputs.working-directory }}'
gemini_api_key: '${{ inputs.gemini_api_key }}'
github-token: '${{ inputs.github-token }}'
npm-registry-url: '${{ inputs.npm-registry-url }}'
npm-registry-scope: '${{ inputs.npm-registry-scope }}'
- name: 'Get CLI Token'
uses: './.github/actions/npm-auth-token'
id: 'cli-token'
@@ -213,12 +236,19 @@ runs:
NODE_AUTH_TOKEN: '${{ steps.cli-token.outputs.auth-token }}'
INPUTS_DRY_RUN: '${{ inputs.dry-run }}'
INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}'
INPUTS_RELEASE_VERSION: '${{ inputs.release-version }}'
shell: 'bash'
run: |
if [ -f "google-gemini-cli-${INPUTS_RELEASE_VERSION}.tgz" ]; then
PUBLISH_TARGET="google-gemini-cli-${INPUTS_RELEASE_VERSION}.tgz"
else
PUBLISH_TARGET="--workspace=${INPUTS_CLI_PACKAGE_NAME}"
fi
npm publish \
--ignore-scripts \
--dry-run="${INPUTS_DRY_RUN}" \
--workspace="${INPUTS_CLI_PACKAGE_NAME}" \
${PUBLISH_TARGET} \
--tag staging-tmp
if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then
npm dist-tag rm ${INPUTS_CLI_PACKAGE_NAME} staging-tmp
@@ -252,18 +282,6 @@ runs:
npm dist-tag rm ${INPUTS_A2A_PACKAGE_NAME} staging-tmp
fi
- name: '🔬 Verify NPM release by version'
uses: './.github/actions/verify-release'
if: "${{ inputs.dry-run != 'true' && inputs.force-skip-tests != 'true' }}"
with:
npm-package: '${{ inputs.cli-package-name }}@${{ inputs.release-version }}'
expected-version: '${{ inputs.release-version }}'
working-directory: '${{ inputs.working-directory }}'
gemini_api_key: '${{ inputs.gemini_api_key }}'
github-token: '${{ inputs.github-token }}'
npm-registry-url: '${{ inputs.npm-registry-url }}'
npm-registry-scope: '${{ inputs.npm-registry-scope }}'
- name: '🏷️ Tag release'
uses: './.github/actions/tag-npm-release'
with:
+1 -1
View File
@@ -19,6 +19,6 @@ runs:
run: |-
echo ""@google-gemini:registry=https://npm.pkg.github.com"" > ~/.npmrc
echo ""//npm.pkg.github.com/:_authToken=${INPUTS_GITHUB_TOKEN}"" >> ~/.npmrc
echo ""@google:registry=https://wombat-dressing-room.appspot.com/"" >> ~/.npmrc
echo ""@google:registry=https://wombat-dressing-room.appspot.com"" >> ~/.npmrc
env:
INPUTS_GITHUB_TOKEN: '${{ inputs.github-token }}'
+5 -3
View File
@@ -74,7 +74,7 @@ runs:
shell: 'bash'
working-directory: '${{ inputs.working-directory }}'
run: |-
gemini_version=$(npx --prefer-online "${INPUTS_NPM_PACKAGE}" --version)
gemini_version=$(npx --yes --prefer-online "${INPUTS_NPM_PACKAGE}" --version)
if [ "$gemini_version" != "${INPUTS_EXPECTED_VERSION}" ]; then
echo "❌ NPX Run Version mismatch: Got $gemini_version from ${INPUTS_NPM_PACKAGE}, expected ${INPUTS_EXPECTED_VERSION}"
exit 1
@@ -86,7 +86,7 @@ runs:
- name: 'Install dependencies for integration tests'
shell: 'bash'
working-directory: '${{ inputs.working-directory }}'
run: 'npm ci'
run: 'npm ci --ignore-scripts'
- name: '🔬 Run integration tests against NPM release'
working-directory: '${{ inputs.working-directory }}'
@@ -98,4 +98,6 @@ runs:
# See https://github.com/google-gemini/gemini-cli/issues/10517
CI: 'false'
shell: 'bash'
run: 'npm run test:integration:sandbox:none'
run: |
export INTEGRATION_TEST_GEMINI_BINARY_PATH=$(which gemini)
npm run test:integration:sandbox:none
+2 -2
View File
@@ -145,8 +145,8 @@ jobs:
skip-branch-cleanup: true
force-skip-tests: "${{ github.event_name != 'schedule' && github.event.inputs.force_skip_tests == 'true' }}"
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
npm-registry-publish-url: "${{ vars.NPM_REGISTRY_PUBLISH_URL || 'https://registry.npmjs.org/' }}"
npm-registry-url: "${{ vars.NPM_REGISTRY_URL || 'https://registry.npmjs.org/' }}"
npm-registry-publish-url: "${{ vars.NPM_REGISTRY_PUBLISH_URL || 'https://wombat-dressing-room.appspot.com' }}"
npm-registry-url: "${{ vars.NPM_REGISTRY_URL || 'https://wombat-dressing-room.appspot.com' }}"
npm-registry-scope: "${{ vars.NPM_REGISTRY_SCOPE || '@google' }}"
cli-package-name: "${{ vars.CLI_PACKAGE_NAME || '@google/gemini-cli' }}"
core-package-name: "${{ vars.CORE_PACKAGE_NAME || '@google/gemini-cli-core' }}"
+3 -1
View File
@@ -106,7 +106,9 @@ jobs:
echo "NIGHTLY_JSON: ${NIGHTLY_JSON}"
echo "STABLE_VERSION=${STABLE_VERSION}" >> "${GITHUB_OUTPUT}"
# shellcheck disable=SC1083
echo "STABLE_SHA=$(git rev-parse "$(echo "${PREVIEW_JSON}" | jq -r .previousReleaseTag)"^{commit})" >> "${GITHUB_OUTPUT}"
PREVIOUS_PREVIEW_TAG=$(echo "${PREVIEW_JSON}" | jq -r .previousReleaseTag)
STABLE_SHA=$(git rev-parse "${PREVIOUS_PREVIEW_TAG}^{commit}")
echo "STABLE_SHA=${STABLE_SHA}" >> "${GITHUB_OUTPUT}"
echo "PREVIOUS_STABLE_TAG=$(echo "${STABLE_JSON}" | jq -r .previousReleaseTag)" >> "${GITHUB_OUTPUT}"
echo "PREVIEW_VERSION=$(echo "${PREVIEW_JSON}" | jq -r .releaseVersion)" >> "${GITHUB_OUTPUT}"
# shellcheck disable=SC1083
+2 -1
View File
@@ -82,7 +82,8 @@ jobs:
ORIGIN_TAG: '${{ steps.origin_tag.outputs.ORIGIN_TAG }}'
shell: 'bash'
run: |
echo "ORIGIN_HASH=$(git rev-parse "${ORIGIN_TAG}")" >> "$GITHUB_OUTPUT"
ORIGIN_HASH=$(git rev-parse "${ORIGIN_TAG}")
echo "ORIGIN_HASH=${ORIGIN_HASH}" >> "$GITHUB_OUTPUT"
- name: 'Change tag'
if: "${{ github.event.inputs.rollback_destination != '' }}"
+45
View File
@@ -0,0 +1,45 @@
name: 'Testing: Tools (Python)'
on:
push:
branches:
- 'main'
- 'release/**'
paths:
- 'tools/**'
pull_request:
branches:
- 'main'
- 'release/**'
paths:
- 'tools/**'
defaults:
run:
shell: 'bash'
jobs:
python-tests:
name: 'Python Tests'
runs-on: 'ubuntu-latest'
steps:
- name: 'Checkout'
uses: 'actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683' # ratchet:actions/checkout@v4
- name: 'Set up Python'
uses: 'actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55' # ratchet:actions/setup-python@v5
with:
python-version: '3.13'
cache: 'pip'
cache-dependency-path: 'tools/caretaker-agent/cloudrun/triage-worker/requirements.txt'
- name: 'Install dependencies'
run: |
python -m pip install --upgrade pip
if [ -f tools/caretaker-agent/cloudrun/triage-worker/requirements.txt ]; then
python -m pip install -r tools/caretaker-agent/cloudrun/triage-worker/requirements.txt
fi
- name: 'Run unittest suite'
run: |
PYTHONPATH=tools/caretaker-agent/cloudrun/triage-worker python -m unittest discover -s tools/caretaker-agent/cloudrun/triage-worker/tests -t tools/caretaker-agent/cloudrun/triage-worker
+1 -1
View File
@@ -1 +1 @@
@google:registry=https://wombat-dressing-room.appspot.com/
@google:registry=https://wombat-dressing-room.appspot.com
+3 -3
View File
@@ -421,9 +421,9 @@ To debug the CLI's React-based UI, you can use React DevTools.
On macOS, `gemini` uses Seatbelt (`sandbox-exec`) under a `permissive-open`
profile (see `packages/cli/src/utils/sandbox-macos-permissive-open.sb`) that
restricts writes to the project folder but otherwise allows all other operations
and outbound network traffic ("open") by default. You can switch to a
`strict-open` profile (see
denies operations by default, confining writes to the project folder while
allowing broad file reads and outbound network traffic ("open") by default. You
can switch to a `strict-open` profile (see
`packages/cli/src/utils/sandbox-macos-strict-open.sb`) that restricts both reads
and writes to the working directory while allowing outbound network traffic by
setting `SEATBELT_PROFILE=strict-open` in your environment or `.env` file.
+50
View File
@@ -18,6 +18,56 @@ on GitHub.
| [Preview](preview.md) | Experimental features ready for early feedback. |
| [Stable](latest.md) | Stable, recommended for general use. |
## Announcements: v0.53.0 - 2026-07-28
- **Caretaker Triage Orchestration:** Implemented an LLM triage orchestrator and
container build setup
([#28345](https://github.com/google-gemini/gemini-cli/pull/28345) by
@chadd28).
- **Eval Coverage Reporting:** Introduced a new command for generating
evaluation coverage reports
([#28169](https://github.com/google-gemini/gemini-cli/pull/28169) by @ved015).
- **Security & Loop Mitigations:** Enforced workspace trust and task isolation
in the A2A server, aligned macOS Seatbelt profiles with the deny-default
model, and mitigated infinite ReAct/prompt injection loops
([#28470](https://github.com/google-gemini/gemini-cli/pull/28470) by
@luisfelipe-alt,
[#28424](https://github.com/google-gemini/gemini-cli/pull/28424) by
@ompatel-aiml).
## Announcements: v0.52.0 - 2026-07-22
- **Caretaker Triage & Egress Services:** Implemented the core triage worker
foundational modules, main worker execution loops, and egress action
publishers alongside the octokit GitHub Action handler for egress services
([#28163](https://github.com/google-gemini/gemini-cli/pull/28163),
[#28306](https://github.com/google-gemini/gemini-cli/pull/28306) by @chadd28).
- **Core Tool Enhancements:** Bypassed LLM correction for JSON and IPYNB files
in `write_file` and `replace` tools, and simplified plan mode write policy to
support relative paths
([#28223](https://github.com/google-gemini/gemini-cli/pull/28223) by
@amelidev, [#28398](https://github.com/google-gemini/gemini-cli/pull/28398) by
@DavidAPierce).
- **Auth & Privacy Improvements:** Displayed clear error messages when user
account has no Code Assist tier, and bumped `google-auth-library` to version
10.9.0 ([#28304](https://github.com/google-gemini/gemini-cli/pull/28304) by
@ompatel-aiml,
[#28385](https://github.com/google-gemini/gemini-cli/pull/28385) by
@jerrylin3321).
## Announcements: v0.50.0 - 2026-07-08
- **Tool Registry Discovery:** Introduced tool registry discovery capabilities
to automatically detect and register available tools
([#28113](https://github.com/google-gemini/gemini-cli/pull/28113) by @ved015).
- **Release Verification & CI Stability:** Enhanced release verification by
ignoring scripts during verification, preventing workspace binary shadowing,
and safeguarding against bad NPM releases
([#28116](https://github.com/google-gemini/gemini-cli/pull/28116) by
@rmedranollamas,
[#28132](https://github.com/google-gemini/gemini-cli/pull/28132) by
@galdawave).
## Announcements: v0.45.0 - 2026-06-03
- **Context Simplification:** Completed major architectural work to simplify the
+32 -49
View File
@@ -1,6 +1,6 @@
# Latest stable release: v0.45.0
# Latest stable release: v0.53.0
Released: June 03, 2026
Released: July 28, 2026
For most users, our latest stable release is the recommended release. Install
the latest stable version with:
@@ -11,55 +11,38 @@ npm install -g @google/gemini-cli
## Highlights
- **Context Manager Simplification:** Completed a significant refactoring of the
context management system to improve reliability and architectural clarity.
- **A2A Usage Metadata:** Enhanced the Agent-to-Agent protocol to expose usage
metadata, enabling more transparent resource monitoring.
- **Terminal & PTY Robustness:** Resolved several critical issues related to
terminal interactions, including Termux relaunch loops and PTY resize errors.
- **Routing Optimizations:** Updated default auto-routing and bypassed
classifiers for specific tool responses to prevent orphaned function errors.
- **Tool Execution Control:** Forced the `update_topic` tool to execute
sequentially, ensuring consistent narrative flow in agent interactions.
- **Caretaker Triage Orchestrator:** Implemented an LLM triage orchestrator and
container build setup to manage automated caretakers.
- **Eval Coverage Reporting:** Introduced a new command for generating
evaluation coverage reports to track agent decision logic and testing.
- **Security and Sandboxing:** Aligned macOS permissive Seatbelt profiles with
the deny-default model, and enforced workspace trust with task isolation in
the A2A server.
- **Robust Conversation Loops:** Coalesced consecutive message roles and grouped
cancelled tool responses to avoid Bad Request errors, and mitigated infinite
ReAct loops and prompt injection vulnerabilities.
## What's Changed
- chore(release): bump version to 0.45.0-nightly.20260521.g854f811be by
@gemini-cli-robot in
[#27362](https://github.com/google-gemini/gemini-cli/pull/27362)
- fix(cli): prevent Termux relaunch and resize remount loops by @saymanq in
[#27110](https://github.com/google-gemini/gemini-cli/pull/27110)
- Feat/a2a expose usage metadata by @jvargassanchez-dot in
[#27288](https://github.com/google-gemini/gemini-cli/pull/27288)
- feat(context): Complete simplification work. by @joshualitt in
[#27345](https://github.com/google-gemini/gemini-cli/pull/27345)
- fix(core): force update_topic tool to execute sequentially by
@jvargassanchez-dot in
[#27357](https://github.com/google-gemini/gemini-cli/pull/27357)
- Changelog for v0.44.0-preview.0 by @gemini-cli-robot in
[#27360](https://github.com/google-gemini/gemini-cli/pull/27360)
- Changelog for v0.43.0 by @gemini-cli-robot in
[#27361](https://github.com/google-gemini/gemini-cli/pull/27361)
- Revert "fix(core): prevent SIGHUP kills in PTY environments" by @bbiggs in
[#27401](https://github.com/google-gemini/gemini-cli/pull/27401)
- fix(cli): filter internal session context from history during resumption by
@rmedranollamas in
[#27391](https://github.com/google-gemini/gemini-cli/pull/27391)
- Update default auto routing by @DavidAPierce in
[#27071](https://github.com/google-gemini/gemini-cli/pull/27071)
- fix(core): bypass routing classifiers to prevent orphaned function response
errors by @danielweis in
[#27389](https://github.com/google-gemini/gemini-cli/pull/27389)
- fix(core): suppress PTY resize EBADF errors by @scidomino in
[#27461](https://github.com/google-gemini/gemini-cli/pull/27461)
- fix(core): prevent blacklist bypass in mcp list by @ompatel-aiml in
[#27377](https://github.com/google-gemini/gemini-cli/pull/27377)
- fix(cli): ignore unmapped vim normal keys by @MukundaKatta in
[#27102](https://github.com/google-gemini/gemini-cli/pull/27102)
- fix(patch): cherry-pick bd53951 to release/v0.45.0-preview.0-pr-27496 to patch
version v0.45.0-preview.0 and create version 0.45.0-preview.1 by
@gemini-cli-robot in
[#27535](https://github.com/google-gemini/gemini-cli/pull/27535)
- fix(core,a2a): group cancelled tool responses and coalesce consecutive roles
to prevent 400 Bad Request by @luisfelipe-alt in
[#28407](https://github.com/google-gemini/gemini-cli/pull/28407)
- feat(caretaker-triage): implement LLM triage orchestrator and container build
by @chadd28 in
[#28345](https://github.com/google-gemini/gemini-cli/pull/28345)
- refactor(cli): align macOS permissive Seatbelt profiles with deny-default
model by @ompatel-aiml in
[#28424](https://github.com/google-gemini/gemini-cli/pull/28424)
- fix(core): mitigate infinite ReAct loops and prompt injection loops by
@amelidev in [#28429](https://github.com/google-gemini/gemini-cli/pull/28429)
- fix(a2a-server): enforce workspace trust and task isolation to prevent RCE by
@luisfelipe-alt in
[#28470](https://github.com/google-gemini/gemini-cli/pull/28470)
- fix(core): sequentially verify cached credentials and restore
GOOGLE_APPLICATION_CREDENTIALS fallback by @luisfelipe-alt in
[#28472](https://github.com/google-gemini/gemini-cli/pull/28472)
- feat(evals): add eval coverage report command by @ved015 in
[#28169](https://github.com/google-gemini/gemini-cli/pull/28169)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.44.1...v0.45.0
https://github.com/google-gemini/gemini-cli/compare/v0.52.0...v0.53.0
+50 -49
View File
@@ -1,6 +1,6 @@
# Preview release: v0.48.0-preview.0
# Preview release: v0.54.0-preview.0
Released: June 17, 2026
Released: July 28, 2026
Our preview release includes the latest, new, and experimental features. This
release may not be as stable as our [latest weekly release](latest.md).
@@ -13,58 +13,59 @@ npm install -g @google/gemini-cli@preview
## Highlights
- **GDC Service Identity Support**: Added support for GDC air-gapped Service
Identity after a major auth library update.
- **Standardised Tool Outputs**: Standardised tool output formatting to ensure
consistency and readability across different CLI commands.
- **Static Evaluation Analyzer**: Introduced a new static evaluation source
analyzer to improve development and testing.
- **Vulnerability Prevention**: Hardened CLI security by preventing path
traversal vulnerabilities during the installation of Skills.
- **Configuration & Error Hardening**: Migrated the `coreTools` configuration
setting to `tools.core` and ensured zero-quota limits fail fast to prevent
infinite retry loops.
- **Antigravity Agent & PR Generator:** Integrated the Antigravity agent runner,
Firestore dual-locking for concurrency, prompt templates, and ingestion
testing utilities.
- **Caretaker Triage & Issue Management:** Enhanced the issue triage workflow by
automatically posting a comment before closing issues, and sanitizing and
wrapping issue titles in `untrusted_context`.
- **Core API & Session Stability:** Enforced HTTPS for
GoogleCredentialsAuthProvider to prevent cleartext leakage, rotated session
IDs on model fallback to prevent stateful API errors, and refined chat history
by filtering out thought parts when context management is disabled.
## What's Changed
- chore(release): bump version to 0.48.0-nightly.20260609.g3a13b8eeb by
- Changelog for v0.53.0-preview.0 by @gemini-cli-robot in
[#28507](https://github.com/google-gemini/gemini-cli/pull/28507)
- Changelog for v0.52.0 by @gemini-cli-robot in
[#28508](https://github.com/google-gemini/gemini-cli/pull/28508)
- chore(release): bump version to 0.54.0-nightly.20260722.gf743ab579 by
@gemini-cli-robot in
[#27779](https://github.com/google-gemini/gemini-cli/pull/27779)
- ci(dependabot): enable cooldown period for npm packages by @ruomengz in
[#27743](https://github.com/google-gemini/gemini-cli/pull/27743)
- refactor(core): standardize tool output formatting by @galz10 in
[#27772](https://github.com/google-gemini/gemini-cli/pull/27772)
- ci: update workflow logging and policy configurations by @galz10 in
[#27853](https://github.com/google-gemini/gemini-cli/pull/27853)
- fix(core): Ensure zero-quota limits fail fast to prevent retry loop hang by
[#28510](https://github.com/google-gemini/gemini-cli/pull/28510)
- fix(caretaker): sanitize and wrap issue title in untrusted_context by @chadd28
in [#28352](https://github.com/google-gemini/gemini-cli/pull/28352)
- chore(caretaker): update vitest to v3.2.4 and add package-lock.json files by
@chadd28 in [#28409](https://github.com/google-gemini/gemini-cli/pull/28409)
- fix(core): rotate session ID on model fallback to prevent stateful API errors
by @amelidev in
[#28469](https://github.com/google-gemini/gemini-cli/pull/28469)
- feat(caretaker-triage): post comment before auto-closing issues by @chadd28 in
[#28411](https://github.com/google-gemini/gemini-cli/pull/28411)
- fix(core): enforce HTTPS for GoogleCredentialsAuthProvider to prevent
cleartext leakage by @amelidev in
[#28517](https://github.com/google-gemini/gemini-cli/pull/28517)
- fix(core): filter out thought parts from getHistoryTurns when context
management is disabled by @DavidAPierce in
[#28509](https://github.com/google-gemini/gemini-cli/pull/28509)
- fix(a2a-server): normalize CRLF line endings to LF in getProposedContent by
@luisfelipe-alt in
[#27698](https://github.com/google-gemini/gemini-cli/pull/27698)
- fix(core): handle multi-line escaped quotes in stripShellWrapper by
@sanchezcoraspe in
[#27467](https://github.com/google-gemini/gemini-cli/pull/27467)
- fix(cli): prevent path traversal vulnerabilities during skill install… by
@ompatel-aiml in
[#27767](https://github.com/google-gemini/gemini-cli/pull/27767)
- Fix/pending tools and trust overrides by @jvargassanchez-dot in
[#27854](https://github.com/google-gemini/gemini-cli/pull/27854)
- ci: use internal environment for scheduled nightly releases (#27865) by
@rmedranollamas in
[#27939](https://github.com/google-gemini/gemini-cli/pull/27939)
- feat(core): Support GDC air-gapped Service Identity after auth library update
by @sidhantgoyal-droid in
[#27956](https://github.com/google-gemini/gemini-cli/pull/27956)
- fix(cli): handle tmux false positive background detection by @amelidev in
[#27572](https://github.com/google-gemini/gemini-cli/pull/27572)
- Add static eval source analyzer by @ved015 in
[#27631](https://github.com/google-gemini/gemini-cli/pull/27631)
- fix(config): migrate coreTools setting to tools.core by @galz10 in
[#27947](https://github.com/google-gemini/gemini-cli/pull/27947)
- fix(core-tools): resolve defensive path resolution for at-reference files by
[#28531](https://github.com/google-gemini/gemini-cli/pull/28531)
- fix(core): enforce explicit tag length and validation in file keychain by
@luisfelipe-alt in
[#27943](https://github.com/google-gemini/gemini-cli/pull/27943)
- Revert "fix(core-tools): resolve defensive path resolution for at-reference
files" by @galz10 in
[#27992](https://github.com/google-gemini/gemini-cli/pull/27992)
[#28523](https://github.com/google-gemini/gemini-cli/pull/28523)
- chore/release: bump version to 0.54.0-nightly.20260728.gbef611950 by
@gemini-cli-robot in
[#28552](https://github.com/google-gemini/gemini-cli/pull/28552)
- feat(pr-generator-db): implement Firestore concurrency dual-locking and test
ingestion utilities by @joneba-google in
[#28432](https://github.com/google-gemini/gemini-cli/pull/28432)
- feat(pr-generator-agent): implement Antigravity agent runner and prompt
templates … by @joneba-google in
[#28434](https://github.com/google-gemini/gemini-cli/pull/28434)
- fix(core): skip merged function-response turns when finding the active loop by
@adamfweidman in
[#28565](https://github.com/google-gemini/gemini-cli/pull/28565)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.47.0-preview.0...v0.48.0-preview.0
https://github.com/google-gemini/gemini-cli/compare/v0.53.0-preview.0...v0.54.0-preview.0
+3 -2
View File
@@ -87,8 +87,9 @@ preferred container solution.
Lightweight, built-in sandboxing using `sandbox-exec`.
**Default profile**: `permissive-open` - restricts writes outside project
directory but allows most other operations.
**Default profile**: `permissive-open` - denies operations by default; confines
writes to the project directory while allowing broad file reads and network
access.
Built-in profiles (set via `SEATBELT_PROFILE` env var):
+4 -4
View File
@@ -2736,10 +2736,10 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
- Run the CLI once with this set to generate the file.
- **`SEATBELT_PROFILE`** (macOS specific):
- Switches the Seatbelt (`sandbox-exec`) profile on macOS.
- `permissive-open`: (Default) Restricts writes to the project folder (and a
few other folders, see
`packages/cli/src/utils/sandbox-macos-permissive-open.sb`) but allows other
operations.
- `permissive-open`: (Default) Denies operations by default, confining writes
to the project folder (and a few other folders, see
`packages/cli/src/utils/sandbox-macos-permissive-open.sb`) while allowing
broad file reads and network access.
- `restrictive-open`: Declines operations by default, allows network.
- `strict-open`: Restricts both reads and writes to the working directory,
allows network.
+11 -3
View File
@@ -56,6 +56,7 @@ export default tseslint.config(
'eslint.config.js',
'**/coverage/**',
'packages/**/dist/**',
'tools/**/dist/**',
'bundle/**',
'package/bundle/**',
'.integration-tests/**',
@@ -80,8 +81,8 @@ export default tseslint.config(
},
},
{
// Rules for packages/*/src (TS/TSX)
files: ['packages/*/src/**/*.{ts,tsx}'],
// Rules for packages/*/src and tools/caretaker-agent (TS/TSX)
files: ['packages/*/src/**/*.{ts,tsx}', 'tools/caretaker-agent/**/*.{ts,tsx}'],
plugins: {
import: importPlugin,
},
@@ -284,7 +285,7 @@ export default tseslint.config(
},
},
{
files: ['packages/*/src/**/*.test.{ts,tsx}'],
files: ['packages/*/src/**/*.test.{ts,tsx}', 'tools/**/*.test.ts'],
plugins: {
vitest,
},
@@ -410,6 +411,13 @@ export default tseslint.config(
'@typescript-eslint/no-require-imports': 'off',
},
},
// Allow console logging for backend services (Cloud Logging)
{
files: ['tools/**/*.ts', 'tools/**/*.test.ts'],
rules: {
'no-console': 'off',
},
},
// Prettier config must be last
prettierConfig,
// extra settings for scripts that we run directly with node
@@ -35,7 +35,7 @@ describe('Interactive file system', () => {
const run = await rig.runInteractive();
// Step 1: Read the file
const readPrompt = `Read the version from ${fileName}`;
const readPrompt = `Read the version from ${fileName} using the read_file tool`;
await run.type(readPrompt);
await run.type('\r');
+94 -1455
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
"version": "0.55.0-nightly.20260729.g3499c84f7",
"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.49.0-nightly.20260617.g4d3dcdce1"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.55.0-nightly.20260729.g3499c84f7"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
@@ -33,6 +33,8 @@
"docs:settings": "tsx ./scripts/generate-settings-doc.ts",
"docs:keybindings": "tsx ./scripts/generate-keybindings-doc.ts",
"eval:inventory": "tsx ./scripts/eval-inventory-cli.ts",
"eval:inventory:json": "tsx ./scripts/eval-inventory-cli.ts --json",
"eval:coverage": "tsx ./scripts/eval-coverage-cli.ts",
"build": "node scripts/build.js",
"build-and-start": "npm run build && npm run start --",
"build:vscode": "node scripts/build_vscode_companion.js",
@@ -94,8 +96,10 @@
],
"devDependencies": {
"@agentclientprotocol/sdk": "0.16.1",
"@modelcontextprotocol/sdk": "1.23.0",
"read-package-up": "11.0.0",
"@octokit/rest": "22.0.0",
"@types/express": "5.0.3",
"@types/marked": "5.0.2",
"@types/mime-types": "3.0.1",
"@types/minimatch": "5.1.2",
@@ -120,6 +124,7 @@
"eslint-plugin-import": "2.32.0",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "5.2.0",
"express": "5.1.0",
"glob": "12.0.0",
"globals": "16.0.0",
"google-artifactregistry-auth": "3.4.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
"version": "0.55.0-nightly.20260729.g3499c84f7",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
@@ -14,7 +14,24 @@ import type {
import { EventEmitter } from 'node:events';
import { requestStorage } from '../http/requestStorage.js';
vi.mock('../utils/path_utils.js', () => ({
validateWorkspacePath: vi
.fn()
.mockImplementation(async (path?: string) => path || process.cwd()),
}));
// Mocks for constructor dependencies
vi.mock('@google/gemini-cli-core', () => ({
GeminiEventType: {
PRIMARY_TURN_STARTED: 'PRIMARY_TURN_STARTED',
SECONDARY_TURN_STARTED: 'SECONDARY_TURN_STARTED',
},
SimpleExtensionLoader: vi.fn(),
checkPathTrust: vi.fn().mockReturnValue({ isTrusted: false }),
isHeadlessMode: vi.fn().mockReturnValue(true),
resolveToRealPath: vi.fn().mockImplementation((p) => p),
}));
vi.mock('../config/config.js', () => ({
loadConfig: vi.fn().mockReturnValue({
getSessionId: () => 'test-session',
@@ -24,6 +41,10 @@ vi.mock('../config/config.js', () => ({
loadEnvironment: vi.fn(),
setIsTrusted: vi.fn().mockReturnValue(false),
setTargetDir: vi.fn().mockReturnValue('/tmp'),
envStorage: {
run: (env: Record<string, string>, cb: () => unknown) => cb(),
},
cwdSymbol: Symbol('cwd'),
}));
vi.mock('../config/settings.js', () => ({
@@ -300,4 +321,125 @@ describe('CoderAgentExecutor', () => {
true,
);
});
it('cancelTask should abort the active execution loop', async () => {
const abortSpy = vi.spyOn(AbortController.prototype, 'abort');
const taskId = 'test-task-to-cancel';
const contextId = 'test-context';
const mockSocket = new EventEmitter();
(requestStorage.getStore as Mock).mockReturnValue({
req: { socket: mockSocket },
});
const requestContext = {
userMessage: {
messageId: 'msg-1',
taskId,
contextId,
parts: [{ kind: 'text', text: 'a long running prompt' }],
metadata: {
coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
},
},
} as unknown as RequestContext;
// Don't await this, let it run in the background.
let primaryError: Error | null = null;
const primaryPromise = executor.execute(requestContext, mockEventBus);
primaryPromise.catch((err) => {
primaryError = err as Error;
});
// Poll until the task is registered in the executor to avoid flaky timeouts in slow CI environments.
let attempts = 0;
while (!executor.getTask(taskId)) {
if (primaryError) {
throw new Error(`Primary execution failed early: ${primaryError}`);
}
if (attempts++ > 100) {
// 100 * 5ms = 500ms timeout
throw new Error('Timed out waiting for task to be registered');
}
await new Promise((resolve) => setTimeout(resolve, 5));
}
const wrapper = executor.getTask(taskId);
expect(wrapper).toBeDefined();
const setTaskStateSpy = vi
.spyOn(wrapper!.task, 'setTaskStateAndPublishUpdate')
.mockImplementation((newState) => {
// Make the mock realistic: actually update the state when called.
wrapper!.task.taskState = newState;
});
// Now, cancel the task.
await executor.cancelTask(taskId, mockEventBus);
// Verify that the abort method on the controller was called and state was updated.
expect(abortSpy).toHaveBeenCalledOnce();
expect(setTaskStateSpy).toHaveBeenCalledWith(
'canceled',
expect.any(Object),
'Task canceled by user request.',
undefined,
true,
);
// Clean up the test by allowing the promise to resolve.
// The abort call should have unblocked the acceptUserMessage generator.
await primaryPromise;
// Verify task is evicted from cache
expect(executor.getTask(taskId)).toBeUndefined();
abortSpy.mockRestore();
});
it('cancelTask should explicitly save task state to TaskStore and evict task during active aborts', async () => {
const taskId = 'test-task-active-abort-save';
const contextId = 'test-context';
const mockSocket = new EventEmitter();
(requestStorage.getStore as Mock).mockReturnValue({
req: { socket: mockSocket },
});
const requestContext = {
userMessage: {
messageId: 'msg-1',
taskId,
contextId,
parts: [{ kind: 'text', text: 'a long running prompt' }],
metadata: {
coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
},
},
} as unknown as RequestContext;
const primaryPromise = executor.execute(requestContext, mockEventBus);
// Wait for task to be registered
let attempts = 0;
while (!executor.getTask(taskId)) {
if (attempts++ > 100) {
throw new Error('Timed out waiting for task to be registered');
}
await new Promise((resolve) => setTimeout(resolve, 5));
}
const wrapper = executor.getTask(taskId)!;
const saveSpy = vi.spyOn(mockTaskStore, 'save');
// Now, cancel the task.
await executor.cancelTask(taskId, mockEventBus);
// Verify that the task state was saved to TaskStore during cancelTask
expect(saveSpy).toHaveBeenCalled();
expect(wrapper.task.dispose).toHaveBeenCalled();
expect(executor.getTask(taskId)).toBeUndefined();
// Clean up the test by allowing the promise to resolve.
await primaryPromise;
});
});
File diff suppressed because it is too large Load Diff
+103
View File
@@ -752,4 +752,107 @@ describe('Task', () => {
expect(changed3).toBe(true);
});
});
describe('getProposedContent (CRLF Line Ending Normalization)', () => {
it('should successfully replace LF-based strings in CRLF-based files', async () => {
const fs = await import('node:fs');
const path = await import('node:path');
const os = await import('node:os');
const mockConfig = createMockConfig({
getTargetDir: () => os.tmpdir(),
validatePathAccess: () => null,
});
const mockEventBus: ExecutionEventBus = {
publish: vi.fn(),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
removeAllListeners: vi.fn(),
finished: vi.fn(),
};
// @ts-expect-error - Calling private constructor
const task = new Task(
'task-id',
'context-id',
mockConfig as Config,
mockEventBus,
);
const tempFile = path.resolve(os.tmpdir(), 'crlf_test_file.txt');
const crlfContent = 'line1\r\nline2\r\nline3\r\n';
fs.writeFileSync(tempFile, crlfContent, 'utf8');
try {
const oldString = 'line2\n';
const newString = 'line2-optimized\n';
const result = await task['getProposedContent'](
tempFile,
oldString,
newString,
);
expect(result).toContain('line2-optimized');
expect(result).toContain('\r\n'); // It should preserve the original CRLF line endings
} finally {
if (fs.existsSync(tempFile)) {
fs.unlinkSync(tempFile);
}
}
});
it('should successfully replace CRLF-based strings in CRLF-based files by normalizing all to LF', async () => {
const fs = await import('node:fs');
const path = await import('node:path');
const os = await import('node:os');
const mockConfig = createMockConfig({
getTargetDir: () => os.tmpdir(),
validatePathAccess: () => null,
});
const mockEventBus: ExecutionEventBus = {
publish: vi.fn(),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
removeAllListeners: vi.fn(),
finished: vi.fn(),
};
// @ts-expect-error - Calling private constructor
const task = new Task(
'task-id',
'context-id',
mockConfig as Config,
mockEventBus,
);
const tempFile = path.resolve(
os.tmpdir(),
'crlf_test_file_crlf_inputs.txt',
);
const crlfContent = 'line1\r\nline2\r\nline3\r\n';
fs.writeFileSync(tempFile, crlfContent, 'utf8');
try {
const oldString = 'line2\r\n';
const newString = 'line2-optimized\r\n';
const result = await task['getProposedContent'](
tempFile,
oldString,
newString,
);
expect(result).toContain('line2-optimized');
expect(result).toContain('\r\n'); // It should preserve the original CRLF line endings
} finally {
if (fs.existsSync(tempFile)) {
fs.unlinkSync(tempFile);
}
}
});
});
});
+24 -17
View File
@@ -666,13 +666,18 @@ export class Task {
}
try {
const currentContent = await fs.readFile(resolvedPath, 'utf8');
return this._applyReplacement(
const rawContent = await fs.readFile(resolvedPath, 'utf8');
const hasCrlf = rawContent.includes('\r\n');
const currentContent = rawContent.replace(/\r\n/g, '\n');
const normalizedOldString = old_string.replace(/\r\n/g, '\n');
const normalizedNewString = new_string.replace(/\r\n/g, '\n');
const proposedContent = this._applyReplacement(
currentContent,
old_string,
new_string,
old_string === '' && currentContent === '',
normalizedOldString,
normalizedNewString,
normalizedOldString === '' && currentContent === '',
);
return hasCrlf ? proposedContent.replace(/\n/g, '\r\n') : proposedContent;
} catch (err) {
if (!isNodeError(err) || err.code !== 'ENOENT') throw err;
return '';
@@ -1092,19 +1097,21 @@ export class Task {
logger.info(
`[Task] Adding ${completedTools.length} tool responses to history without generating a new response.`,
);
const responsesToAdd = completedTools.flatMap(
(toolCall) => toolCall.response.responseParts,
);
for (const response of responsesToAdd) {
let parts: genAiPart[];
if (Array.isArray(response)) {
parts = response;
} else if (typeof response === 'string') {
parts = [{ text: response }];
} else {
parts = [response];
const parts: genAiPart[] = [];
for (const toolCall of completedTools) {
const response = toolCall.response?.responseParts;
if (!response) {
continue;
}
if (Array.isArray(response)) {
parts.push(...response);
} else if (typeof response === 'string') {
parts.push({ text: response });
} else {
parts.push(response);
}
}
if (parts.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.geminiClient.addHistory({
role: 'user',
+381 -42
View File
@@ -7,6 +7,7 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as dotenv from 'dotenv';
import { AsyncLocalStorage } from 'node:async_hooks';
import {
AuthType,
@@ -17,6 +18,7 @@ import {
startupProfiler,
PREVIEW_GEMINI_MODEL,
homedir,
tmpdir,
GitService,
fetchAdminControlsOnce,
getCodeAssistServer,
@@ -28,28 +30,246 @@ import {
type TelemetryTarget,
type ConfigParameters,
type ExtensionLoader,
resolveToRealPath,
} from '@google/gemini-cli-core';
import { logger } from '../utils/logger.js';
import type { Settings } from './settings.js';
import { type AgentSettings, CoderAgentEvent } from '../types.js';
const INITIAL_FOLDER_TRUST = process.env['GEMINI_FOLDER_TRUST'];
export const envStorage = new AsyncLocalStorage<TaskEnv>();
const deletedKeysSymbol = Symbol('deletedKeys');
export const cwdSymbol = Symbol('cwd');
export interface TaskEnv extends Record<string, string | undefined> {
[deletedKeysSymbol]?: Set<string>;
[cwdSymbol]?: string;
}
// Set up a Proxy on process.env to intercept reads and writes, isolating environment variables per task
const originalEnv = process.env;
const envProxy = new Proxy(originalEnv, {
get(target, prop) {
if (typeof prop === 'string') {
const taskEnv = envStorage.getStore();
if (taskEnv) {
const deleted = taskEnv[deletedKeysSymbol];
if (deleted?.has(prop)) {
return undefined;
}
if (Object.prototype.hasOwnProperty.call(taskEnv, prop)) {
return taskEnv[prop];
}
}
return target[prop];
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
return target[prop as any];
},
has(target, prop) {
if (typeof prop === 'string') {
const taskEnv = envStorage.getStore();
if (taskEnv) {
const deleted = taskEnv[deletedKeysSymbol];
if (deleted?.has(prop)) {
return false;
}
if (Object.prototype.hasOwnProperty.call(taskEnv, prop)) {
return true;
}
}
return prop in target;
}
return prop in target;
},
set(target, prop, value) {
if (typeof prop === 'string') {
if (
prop === '__proto__' ||
prop === 'constructor' ||
prop === 'prototype'
) {
return false;
}
const taskEnv = envStorage.getStore();
if (taskEnv) {
taskEnv[deletedKeysSymbol]?.delete(prop);
taskEnv[prop] = String(value);
return true;
}
target[prop] = String(value);
return true;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
target[prop as any] = value;
return true;
},
deleteProperty(target, prop) {
if (typeof prop === 'string') {
if (
prop === '__proto__' ||
prop === 'constructor' ||
prop === 'prototype'
) {
return false;
}
const taskEnv = envStorage.getStore();
if (taskEnv) {
delete taskEnv[prop];
(taskEnv[deletedKeysSymbol] ??= new Set()).add(prop);
return true;
}
delete target[prop];
return true;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
return delete target[prop as any];
},
ownKeys(target) {
const taskEnv = envStorage.getStore();
if (taskEnv) {
const keys = new Set<string | symbol>([
...Object.getOwnPropertyNames(target),
...Object.getOwnPropertySymbols(target),
...Object.keys(taskEnv),
]);
taskEnv[deletedKeysSymbol]?.forEach((key) => {
keys.delete(key);
});
return Array.from(keys);
}
return [
...Object.getOwnPropertyNames(target),
...Object.getOwnPropertySymbols(target),
];
},
getOwnPropertyDescriptor(target, prop) {
const taskEnv = envStorage.getStore();
if (taskEnv && typeof prop === 'string') {
const deleted = taskEnv[deletedKeysSymbol];
if (deleted?.has(prop)) {
return undefined;
}
if (Object.prototype.hasOwnProperty.call(taskEnv, prop)) {
return {
value: taskEnv[prop],
writable: true,
enumerable: true,
configurable: true,
};
}
}
return Object.getOwnPropertyDescriptor(target, prop);
},
defineProperty(target, prop, descriptor) {
if (typeof prop === 'string') {
if (
prop === '__proto__' ||
prop === 'constructor' ||
prop === 'prototype'
) {
return false;
}
const taskEnv = envStorage.getStore();
if (taskEnv) {
taskEnv[deletedKeysSymbol]?.delete(prop);
taskEnv[prop] =
descriptor.value !== undefined ? String(descriptor.value) : undefined;
return true;
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
Object.defineProperty(target, prop as any, descriptor);
return true;
},
});
Object.defineProperty(process, 'env', {
value: envProxy,
writable: false,
configurable: true,
});
// NOTE: Monkey-patching process.cwd and process.chdir via AsyncLocalStorage is a robust way
// to simulate workspace isolation in a concurrent server. However, please be aware of a critical
// limitation: Node.js native C++ APIs (such as fs.readFileSync, fs.writeFile, etc.) and child
// process spawning APIs (like child_process.spawn) resolve relative paths using the OS-level
// working directory of the process, NOT the JS-level process.cwd() function.
// To prevent cross-task interference, all file paths in the core package must be resolved to
// absolute paths using path.resolve/path.join relative to config.getTargetDir() or config.getCwd()
// before being passed to native APIs.
const originalCwd = process.cwd;
process.cwd = function () {
const taskEnv = envStorage.getStore();
if (taskEnv && taskEnv[cwdSymbol]) {
return taskEnv[cwdSymbol];
}
return originalCwd.call(process);
};
const originalChdir = process.chdir;
process.chdir = function (directory: string) {
const taskEnv = envStorage.getStore();
if (taskEnv) {
const resolved = path.resolve(process.cwd(), directory);
try {
const stats = fs.statSync(resolved);
if (!stats.isDirectory()) {
const err = new Error(
"ENOTDIR: not a directory, chdir '" + resolved + "'",
);
(err as NodeJS.ErrnoException).code = 'ENOTDIR';
throw err;
}
} catch (err: unknown) {
if (
err &&
typeof err === 'object' &&
'code' in err &&
err.code === 'ENOENT'
) {
const chdirErr = new Error(
"ENOENT: no such file or directory, chdir '" + resolved + "'",
);
(chdirErr as NodeJS.ErrnoException).code = 'ENOENT';
throw chdirErr;
}
throw err;
}
taskEnv[cwdSymbol] = resolved;
return;
}
return originalChdir.call(process, directory);
};
export function getEnv(key: string): string | undefined {
return process.env[key];
}
export async function loadConfig(
settings: Settings,
extensionLoader: ExtensionLoader,
taskId: string,
trusted: boolean = false,
workspaceDir: string = process.cwd(),
): Promise<Config> {
const workspaceDir = process.cwd();
const workspaceEnv = await loadEnvironment(trusted, workspaceDir);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const envVars: Record<string, string> = { ...process.env } as Record<
string,
string
>;
Object.assign(envVars, workspaceEnv);
const getEnvLocal = (key: string) => envVars[key];
const folderTrust =
settings.folderTrust === true ||
process.env['GEMINI_FOLDER_TRUST'] === 'true';
getEnvLocal('GEMINI_FOLDER_TRUST') === 'true';
let checkpointing = process.env['CHECKPOINTING']
? process.env['CHECKPOINTING'] === 'true'
let checkpointing = getEnvLocal('CHECKPOINTING')
? getEnvLocal('CHECKPOINTING') === 'true'
: settings.checkpointing?.enabled;
if (checkpointing) {
@@ -62,7 +282,7 @@ export async function loadConfig(
}
const approvalMode =
process.env['GEMINI_YOLO_MODE'] === 'true'
getEnvLocal('GEMINI_YOLO_MODE') === 'true'
? ApprovalMode.YOLO
: ApprovalMode.DEFAULT;
@@ -91,8 +311,9 @@ export async function loadConfig(
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
sandbox: undefined, // Sandbox might not be relevant for a server-side agent
targetDir: workspaceDir, // Or a specific directory the agent operates on
debugMode: process.env['DEBUG'] === 'true' || false,
debugMode: getEnvLocal('DEBUG') === 'true' || false,
question: '', // Not used in server mode directly like CLI
env: envVars,
coreTools: settings.tools?.core || undefined,
excludeTools: settings.tools?.exclude || undefined,
@@ -107,7 +328,7 @@ export async function loadConfig(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
target: settings.telemetry?.target as TelemetryTarget,
otlpEndpoint:
process.env['OTEL_EXPORTER_OTLP_ENDPOINT'] ??
getEnvLocal('OTEL_EXPORTER_OTLP_ENDPOINT') ??
settings.telemetry?.otlpEndpoint,
logPrompts: settings.telemetry?.logPrompts,
},
@@ -119,8 +340,8 @@ export async function loadConfig(
settings.fileFiltering?.enableRecursiveFileSearch,
customIgnoreFilePaths: [
...(settings.fileFiltering?.customIgnoreFilePaths || []),
...(process.env['CUSTOM_IGNORE_FILE_PATHS']
? process.env['CUSTOM_IGNORE_FILE_PATHS'].split(path.delimiter)
...(getEnvLocal('CUSTOM_IGNORE_FILE_PATHS')
? getEnvLocal('CUSTOM_IGNORE_FILE_PATHS').split(path.delimiter)
: []),
],
},
@@ -179,7 +400,7 @@ export async function loadConfig(
await config.waitForMcpInit();
startupProfiler.flush(config);
await refreshAuthentication(config, 'Config');
await refreshAuthentication(config, 'Config', envVars);
return config;
}
@@ -187,16 +408,19 @@ export async function loadConfig(
export function setIsTrusted(
agentSettings: AgentSettings | undefined,
): boolean {
if (INITIAL_FOLDER_TRUST !== undefined) {
return INITIAL_FOLDER_TRUST === 'true';
const folderTrustEnv = getEnv('GEMINI_FOLDER_TRUST');
if (folderTrustEnv !== undefined) {
return folderTrustEnv === 'true';
}
return !!agentSettings?.isTrusted;
}
export function setTargetDir(agentSettings: AgentSettings | undefined): string {
export async function setTargetDir(
agentSettings: AgentSettings | undefined,
): Promise<string> {
const originalCWD = process.cwd();
const targetDir =
process.env['CODER_AGENT_WORKSPACE_PATH'] ??
getEnv('CODER_AGENT_WORKSPACE_PATH') ??
(agentSettings?.kind === CoderAgentEvent.StateAgentSettingsEvent
? agentSettings.workspacePath
: undefined);
@@ -210,58 +434,170 @@ export function setTargetDir(agentSettings: AgentSettings | undefined): string {
);
try {
const resolvedPath = path.resolve(targetDir);
process.chdir(resolvedPath);
let resolvedPath: string;
try {
resolvedPath = resolveToRealPath(targetDir);
} catch (err: unknown) {
if (
err &&
typeof err === 'object' &&
'code' in err &&
err.code === 'ENOENT'
) {
const parentDir = path.dirname(path.resolve(targetDir));
resolvedPath = path.join(
resolveToRealPath(parentDir),
path.basename(targetDir),
);
} else {
throw err;
}
}
const isTestEnv =
process.env['VITEST'] === 'true' ||
process.env['NODE_ENV'] === 'test' ||
process.argv.some((arg) => arg.includes('vitest')) ||
resolvedPath.startsWith(resolveToRealPath(tmpdir()));
const allowedRoot = resolveToRealPath(
getEnv('CODER_AGENT_ALLOWED_ROOT') ||
(isTestEnv ? path.parse(resolvedPath).root : homedir()),
);
const relative = path.relative(allowedRoot, resolvedPath);
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(
`Workspace path ${resolvedPath} is outside the allowed root directory`,
);
}
let stats: fs.Stats;
try {
stats = await fs.promises.stat(resolvedPath);
} catch (err: unknown) {
if (
err &&
typeof err === 'object' &&
'code' in err &&
err.code === 'ENOENT'
) {
if (isTestEnv) {
await fs.promises.mkdir(resolvedPath, { recursive: true });
stats = await fs.promises.stat(resolvedPath);
} else {
throw new Error(`Workspace path ${resolvedPath} does not exist`);
}
} else {
throw err;
}
}
if (!stats.isDirectory()) {
throw new Error(`Workspace path ${resolvedPath} is not a directory`);
}
return resolvedPath;
} catch (e) {
logger.error(
`[CoderAgentExecutor] Error resolving workspace path: ${e}, returning original os.cwd()`,
);
return originalCWD;
logger.error(`[CoderAgentExecutor] Error resolving workspace path: ${e}`);
throw e;
}
}
export function loadEnvironment(): void {
const envFilePath = findEnvFile(process.cwd());
export async function loadEnvironment(
isTrusted: boolean = false,
workspacePath: string = process.cwd(),
): Promise<Record<string, string>> {
// For untrusted workspaces, we completely bypass workspace-level .env loading
// and only load environment variables from the user's trusted home directory.
let envFilePath: string | null = null;
if (isTrusted) {
envFilePath = await findEnvFile(workspacePath);
} else {
const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env');
try {
await fs.promises.access(homeGeminiEnvPath);
envFilePath = homeGeminiEnvPath;
} catch {
const homeEnvPath = path.join(homedir(), '.env');
try {
await fs.promises.access(homeEnvPath);
envFilePath = homeEnvPath;
} catch {
// Ignore
}
}
}
const envVars: Record<string, string> = {};
if (envFilePath) {
dotenv.config({ path: envFilePath, override: true });
try {
const content = await fs.promises.readFile(envFilePath, 'utf-8');
const parsed = dotenv.parse(content);
for (const key in parsed) {
if (
Object.prototype.hasOwnProperty.call(parsed, key) &&
key !== '__proto__' &&
key !== 'constructor' &&
key !== 'prototype'
) {
envVars[key] = parsed[key];
}
}
} catch {
// Ignore errors
}
}
return envVars;
}
function findEnvFile(startDir: string): string | null {
async function findEnvFile(startDir: string): Promise<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)) {
try {
await fs.promises.access(geminiEnvPath);
return geminiEnvPath;
} catch {
// Ignore
}
const envPath = path.join(currentDir, '.env');
if (fs.existsSync(envPath)) {
try {
await fs.promises.access(envPath);
return envPath;
} catch {
// Ignore
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir || !parentDir) {
// check .env under home as fallback, again preferring gemini-specific .env
const homeGeminiEnvPath = path.join(process.cwd(), GEMINI_DIR, '.env');
if (fs.existsSync(homeGeminiEnvPath)) {
return homeGeminiEnvPath;
}
const homeEnvPath = path.join(homedir(), '.env');
if (fs.existsSync(homeEnvPath)) {
return homeEnvPath;
}
return null;
break;
}
currentDir = parentDir;
}
// check .env under home as fallback, again preferring gemini-specific .env
const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env');
try {
await fs.promises.access(homeGeminiEnvPath);
return homeGeminiEnvPath;
} catch {
// Ignore
}
const homeEnvPath = path.join(homedir(), '.env');
try {
await fs.promises.access(homeEnvPath);
return homeEnvPath;
} catch {
return null;
}
}
async function refreshAuthentication(
config: Config,
logPrefix: string,
envVars: Record<string, string>,
): Promise<void> {
if (process.env['USE_CCPA']) {
const getEnvLocal = (key: string) => envVars[key];
if (getEnvLocal('USE_CCPA')) {
logger.info(`[${logPrefix}] Using CCPA Auth:`);
logger.info(`[${logPrefix}] Attempting COMPUTE_ADC first.`);
@@ -276,7 +612,7 @@ async function refreshAuthentication(
);
const useComputeAdc =
process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true';
getEnvLocal('GEMINI_CLI_USE_COMPUTE_ADC') === 'true';
const isHeadless = isHeadlessMode();
if (isHeadless || useComputeAdc) {
@@ -305,11 +641,14 @@ async function refreshAuthentication(
}
logger.info(
`[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${process.env['GOOGLE_CLOUD_PROJECT']}`,
`[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${getEnvLocal('GOOGLE_CLOUD_PROJECT')}`,
);
} else if (process.env['GEMINI_API_KEY']) {
} else if (getEnvLocal('GEMINI_API_KEY')) {
logger.info(`[${logPrefix}] Using Gemini API Key`);
await config.refreshAuth(AuthType.USE_GEMINI);
await config.refreshAuth(
AuthType.USE_GEMINI,
getEnvLocal('GEMINI_API_KEY'),
);
} else {
const errorMessage = `[${logPrefix}] Unable to set GeneratorConfig. Please provide a GEMINI_API_KEY or set USE_CCPA.`;
logger.error(errorMessage);
+5 -2
View File
@@ -36,9 +36,12 @@ interface ExtensionConfig {
excludeTools?: string[];
}
export function loadExtensions(workspaceDir: string): GeminiCLIExtension[] {
export function loadExtensions(
workspaceDir: string,
isTrusted: boolean = false,
): GeminiCLIExtension[] {
const allExtensions = [
...loadExtensionsFromDir(workspaceDir),
...(isTrusted ? loadExtensionsFromDir(workspaceDir) : []),
...loadExtensionsFromDir(homedir()),
];
@@ -0,0 +1,113 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
let mockHomeDir = '';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...original,
homedir: () => mockHomeDir,
};
});
import { loadEnvironment } from './config.js';
describe('Vulnerability Mitigation: b-519269096', () => {
let tempWorkspaceDir: string;
beforeEach(() => {
// Create a temporary home directory securely using mkdtempSync to ensure hermeticity
mockHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-mock-home-'));
// Create a temporary workspace directory representing an untrusted repo
tempWorkspaceDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-exploit-workspace-'),
);
const geminiDir = path.join(tempWorkspaceDir, '.gemini');
fs.mkdirSync(geminiDir, { recursive: true });
// Mock process.cwd to return the untrusted workspace
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
});
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
fs.rmSync(mockHomeDir, { recursive: true, force: true });
});
it('should ignore GEMINI_CLI_TRUST_WORKSPACE and GEMINI_YOLO_MODE in untrusted workspaces', async () => {
const geminiDir = path.join(tempWorkspaceDir, '.gemini');
fs.writeFileSync(
path.join(geminiDir, '.env'),
'GEMINI_CLI_TRUST_WORKSPACE=true\nGEMINI_YOLO_MODE=true\n',
);
// Ensure initially not set
vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', '');
vi.stubEnv('GEMINI_YOLO_MODE', '');
// Act: load environment with isTrusted = false
const envVars = await loadEnvironment(false);
// Assert: In a SECURE system, these variables should NOT be loaded
expect(envVars['GEMINI_CLI_TRUST_WORKSPACE']).toBeUndefined();
expect(envVars['GEMINI_YOLO_MODE']).toBeUndefined();
expect(process.env['GEMINI_CLI_TRUST_WORKSPACE']).toBeFalsy();
expect(process.env['GEMINI_YOLO_MODE']).toBeFalsy();
});
it('should not load any variables from untrusted workspaces', async () => {
fs.writeFileSync(
path.join(tempWorkspaceDir, '.env'),
'GEMINI_API_KEY=safe-key-123;rm -rf /\nGOOGLE_CLOUD_PROJECT=my-project\n',
);
// Ensure initially not set
vi.stubEnv('GEMINI_API_KEY', '');
vi.stubEnv('GOOGLE_CLOUD_PROJECT', '');
// Act: load environment with isTrusted = false
const envVars = await loadEnvironment(false);
// Assert: No variables should be loaded from the untrusted workspace
expect(envVars['GEMINI_API_KEY']).toBeUndefined();
expect(envVars['GOOGLE_CLOUD_PROJECT']).toBeUndefined();
expect(process.env['GEMINI_API_KEY']).toBeFalsy();
expect(process.env['GOOGLE_CLOUD_PROJECT']).toBeFalsy();
});
it('should load all variables in trusted workspaces with isolation', async () => {
const geminiDir = path.join(tempWorkspaceDir, '.gemini');
fs.writeFileSync(
path.join(geminiDir, '.env'),
'GEMINI_CLI_TRUST_WORKSPACE=true\nGEMINI_YOLO_MODE=true\n',
);
// Ensure initially not set
vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', '');
vi.stubEnv('GEMINI_YOLO_MODE', '');
// Load environment variables
const envVars = await loadEnvironment(true);
// Assert: In a trusted workspace, variables should be loaded
expect(envVars['GEMINI_CLI_TRUST_WORKSPACE']).toBe('true');
expect(envVars['GEMINI_YOLO_MODE']).toBe('true');
// Assert: Global process.env should NOT be polluted
expect(process.env['GEMINI_CLI_TRUST_WORKSPACE']).toBeFalsy();
expect(process.env['GEMINI_YOLO_MODE']).toBeFalsy();
});
});
+24 -3
View File
@@ -197,8 +197,7 @@ async function handleExecuteCommand(
export async function createApp() {
try {
// Load the server configuration once on startup.
const workspaceRoot = setTargetDir(undefined);
loadEnvironment();
const workspaceRoot = await setTargetDir(undefined);
// Use a temporary settings load to check if folder trust is enabled.
// This is similar to how the CLI handles the initial trust check.
@@ -209,13 +208,35 @@ export async function createApp() {
isHeadless: isHeadlessMode(),
});
// Change the global working directory to the workspace root during startup
process.chdir(workspaceRoot);
// Load environment globally for the server startup
const globalEnv = await loadEnvironment(isTrusted ?? false, workspaceRoot);
// Only assign safe server-config variables to process.env to prevent credential leakage
const allowedServerKeys = [
'CODER_AGENT_PORT',
'CODER_AGENT_WORKSPACE_PATH',
'GCS_BUCKET_NAME',
'LOG_LEVEL',
'GOOGLE_APPLICATION_CREDENTIALS',
'GOOGLE_CLOUD_PROJECT',
'GEMINI_CLI_USE_COMPUTE_ADC',
];
for (const key of allowedServerKeys) {
if (globalEnv[key] !== undefined) {
process.env[key] = globalEnv[key];
}
}
const settings = loadSettings(workspaceRoot, isTrusted ?? false);
const extensions = loadExtensions(workspaceRoot);
const extensions = loadExtensions(workspaceRoot, isTrusted ?? false);
const config = await loadConfig(
settings,
new SimpleExtensionLoader(extensions),
'a2a-server',
isTrusted ?? false,
workspaceRoot,
);
let git: GitService | undefined;
+1 -1
View File
@@ -258,7 +258,7 @@ export class GCSTaskStore implements TaskStore {
}
const agentSettings = persistedState._agentSettings;
const workDir = setTargetDir(agentSettings);
const workDir = await setTargetDir(agentSettings);
await fse.ensureDir(workDir);
const workspaceFile = this.storage
.bucket(this.bucketName)
@@ -0,0 +1,62 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { resolveToRealPath, isSubpath } from '@google/gemini-cli-core';
/**
* Validates a workspace path to prevent path traversal attacks.
*
* @param workspacePath The path to validate.
* @param allowedRoot The root directory the path must be within. Defaults to CWD.
* @returns The resolved, safe path.
* @throws An error if the path is invalid or outside the allowed root.
*/
export async function validateWorkspacePath(
workspacePath?: string,
allowedRoot: string = process.cwd(),
): Promise<string> {
const trimmedPath = workspacePath?.trim();
if (!trimmedPath) {
return resolveToRealPath(allowedRoot);
}
if (trimmedPath.includes('\0')) {
throw new Error('Security violation: Null byte detected in path.');
}
try {
const canonicalAllowedRoot = resolveToRealPath(allowedRoot);
const resolvedWorkspacePath = path.resolve(
canonicalAllowedRoot,
trimmedPath,
);
const canonicalWorkspacePath = resolveToRealPath(resolvedWorkspacePath);
// Check if the resolved path is within the allowed root directory
if (
canonicalWorkspacePath !== canonicalAllowedRoot &&
!isSubpath(canonicalAllowedRoot, canonicalWorkspacePath)
) {
throw new Error(
`Security violation: The path "${trimmedPath}" is outside the allowed root directory.`,
);
}
const stats = await fs.promises.stat(canonicalWorkspacePath);
if (!stats.isDirectory()) {
throw new Error(`The path "${trimmedPath}" is not a directory.`);
}
return canonicalWorkspacePath;
} catch (e) {
if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
throw new Error(`The path "${trimmedPath}" does not exist.`);
}
throw e; // Re-throw other errors
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
"version": "0.55.0-nightly.20260729.g3499c84f7",
"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.49.0-nightly.20260617.g4d3dcdce1"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.55.0-nightly.20260729.g3499c84f7"
},
"dependencies": {
"@agentclientprotocol/sdk": "0.16.1",
@@ -63,8 +63,10 @@ describe('handleAtCommand', () => {
vi.restoreAllMocks();
vi.resetAllMocks();
testRootDir = await fsPromises.mkdtemp(
path.join(os.tmpdir(), 'folder-structure-test-'),
testRootDir = await fsPromises.realpath(
await fsPromises.mkdtemp(
path.join(os.tmpdir(), 'folder-structure-test-'),
),
);
abortController = new AbortController();
@@ -1467,8 +1469,8 @@ describe('handleAtCommand', () => {
});
it('should resolve files in multiple workspace directories', async () => {
const secondRootDir = await fsPromises.mkdtemp(
path.join(os.tmpdir(), 'second-root-'),
const secondRootDir = await fsPromises.realpath(
await fsPromises.mkdtemp(path.join(os.tmpdir(), 'second-root-')),
);
try {
const fileContent = 'Second root content';
@@ -1649,8 +1651,10 @@ describe('checkPermissions', () => {
beforeEach(async () => {
vi.restoreAllMocks();
testRootDir = await fsPromises.mkdtemp(
path.join(os.tmpdir(), 'check-permissions-test-'),
testRootDir = await fsPromises.realpath(
await fsPromises.mkdtemp(
path.join(os.tmpdir(), 'check-permissions-test-'),
),
);
mockConfig = {
@@ -37,8 +37,8 @@ describe('handleAtCommand with Agents', () => {
beforeEach(async () => {
vi.resetAllMocks();
testRootDir = await fsPromises.mkdtemp(
path.join(os.tmpdir(), 'agent-test-'),
testRootDir = await fsPromises.realpath(
await fsPromises.mkdtemp(path.join(os.tmpdir(), 'agent-test-')),
);
abortController = new AbortController();
@@ -49,7 +49,7 @@ describe('usePrivacySettings', () => {
};
};
it('should throw error when content generator is not a CodeAssistServer', async () => {
it('should report tier unavailable when OAuth is not being used', async () => {
vi.mocked(getCodeAssistServer).mockReturnValue(undefined);
const { result } = await act(async () => renderPrivacySettingsHook());
@@ -58,7 +58,8 @@ describe('usePrivacySettings', () => {
expect(result.current.privacyState.isLoading).toBe(false);
});
expect(result.current.privacyState.error).toBe('Oauth not being used');
expect(result.current.privacyState.isTierUnavailable).toBe(true);
expect(result.current.privacyState.error).toBeUndefined();
});
it('should handle paid tier users correctly', async () => {
@@ -79,7 +80,7 @@ describe('usePrivacySettings', () => {
expect(result.current.privacyState.dataCollectionOptIn).toBeUndefined();
});
it('should throw error when CodeAssistServer has no projectId', async () => {
it('should report tier unavailable when CodeAssistServer has no projectId', async () => {
vi.mocked(getCodeAssistServer).mockReturnValue({
userTier: UserTierId.FREE,
} as unknown as CodeAssistServer);
@@ -90,9 +91,63 @@ describe('usePrivacySettings', () => {
expect(result.current.privacyState.isLoading).toBe(false);
});
expect(result.current.privacyState.error).toBe(
'CodeAssist server is missing a project ID',
);
expect(result.current.privacyState.isTierUnavailable).toBe(true);
expect(result.current.privacyState.error).toBeUndefined();
});
it('should report tier unavailable when the user has no tier', async () => {
vi.mocked(getCodeAssistServer).mockReturnValue({
projectId: 'test-project-id',
userTier: undefined,
} as unknown as CodeAssistServer);
const { result } = await act(async () => renderPrivacySettingsHook());
await waitFor(() => {
expect(result.current.privacyState.isLoading).toBe(false);
});
expect(result.current.privacyState.isTierUnavailable).toBe(true);
expect(result.current.privacyState.isFreeTier).toBeUndefined();
expect(result.current.privacyState.error).toBeUndefined();
});
it('should report tier unavailable when the backend reports no current tier', async () => {
vi.mocked(getCodeAssistServer).mockReturnValue({
projectId: 'test-project-id',
userTier: UserTierId.FREE,
getCodeAssistGlobalUserSetting: vi
.fn()
.mockRejectedValue(new Error('User does not have a current tier')),
} as unknown as CodeAssistServer);
const { result } = await act(async () => renderPrivacySettingsHook());
await waitFor(() => {
expect(result.current.privacyState.isLoading).toBe(false);
});
expect(result.current.privacyState.isTierUnavailable).toBe(true);
expect(result.current.privacyState.error).toBeUndefined();
});
it('should surface unexpected errors while loading opt-in settings', async () => {
vi.mocked(getCodeAssistServer).mockReturnValue({
projectId: 'test-project-id',
userTier: UserTierId.FREE,
getCodeAssistGlobalUserSetting: vi
.fn()
.mockRejectedValue(new Error('network unavailable')),
} as unknown as CodeAssistServer);
const { result } = await act(async () => renderPrivacySettingsHook());
await waitFor(() => {
expect(result.current.privacyState.isLoading).toBe(false);
});
expect(result.current.privacyState.error).toBe('network unavailable');
expect(result.current.privacyState.isTierUnavailable).toBeUndefined();
});
it('should update data collection opt-in setting', async () => {
@@ -18,8 +18,22 @@ export interface PrivacyState {
error?: string;
isFreeTier?: boolean;
dataCollectionOptIn?: boolean;
/**
* True when the signed-in account has no consumer Code Assist tier, so the
* data-collection opt-in isn't applicable (e.g. Workspace/enterprise accounts,
* or an OAuth login without a Google Cloud project). This is an expected state
* rendered as a friendly, actionable notice rather than a raw backend `error`.
*/
isTierUnavailable?: boolean;
}
/**
* Signals that the current account can't be mapped to a consumer Code Assist
* tier, so the privacy opt-in can't be shown. Handled by rendering a friendly
* notice instead of surfacing a raw backend error.
*/
class TierUnavailableError extends Error {}
export const usePrivacySettings = (config: Config) => {
const [privacyState, setPrivacyState] = useState<PrivacyState>({
isLoading: true,
@@ -34,7 +48,13 @@ export const usePrivacySettings = (config: Config) => {
const server = getCodeAssistServerOrFail(config);
const tier = server.userTier;
if (tier === undefined) {
throw new Error('Could not determine user tier.');
// The account has no resolved Code Assist tier (e.g. Workspace or an
// incomplete OAuth). Show a friendly notice instead of a raw error.
setPrivacyState({
isLoading: false,
isTierUnavailable: true,
});
return;
}
if (tier !== UserTierId.FREE) {
// We don't need to fetch opt-out info since non-free tier
@@ -53,6 +73,13 @@ export const usePrivacySettings = (config: Config) => {
dataCollectionOptIn: optIn,
});
} catch (e) {
if (isTierUnavailableError(e)) {
setPrivacyState({
isLoading: false,
isTierUnavailable: true,
});
return;
}
setPrivacyState({
isLoading: false,
error: e instanceof Error ? e.message : String(e),
@@ -74,6 +101,13 @@ export const usePrivacySettings = (config: Config) => {
dataCollectionOptIn: updatedOptIn,
});
} catch (e) {
if (isTierUnavailableError(e)) {
setPrivacyState({
isLoading: false,
isTierUnavailable: true,
});
return;
}
setPrivacyState({
isLoading: false,
error: e instanceof Error ? e.message : String(e),
@@ -92,13 +126,30 @@ export const usePrivacySettings = (config: Config) => {
function getCodeAssistServerOrFail(config: Config): CodeAssistServer {
const server = getCodeAssistServer(config);
if (server === undefined) {
throw new Error('Oauth not being used');
throw new TierUnavailableError('Oauth not being used');
} else if (server.projectId === undefined) {
throw new Error('CodeAssist server is missing a project ID');
throw new TierUnavailableError('CodeAssist server is missing a project ID');
}
return server;
}
/**
* Determines whether an error means the account simply has no consumer Code
* Assist tier, as opposed to an unexpected failure. Covers the local
* {@link TierUnavailableError} as well as the Code Assist backend error (e.g.
* "User does not have a current tier") returned for Workspace/enterprise
* accounts.
*/
function isTierUnavailableError(error: unknown): boolean {
if (error instanceof TierUnavailableError) {
return true;
}
const message = error instanceof Error ? error.message : String(error);
// Match the specific Code Assist backend message rather than a broad substring
// so an unrelated error that merely mentions "tier" isn't masked as a benign notice.
return /does not have a current tier/i.test(message);
}
async function getRemoteDataCollectionOptIn(
server: CodeAssistServer,
): Promise<boolean> {
@@ -525,6 +525,102 @@ Your admin might have disabled the access. Contact them to enable the Preview Re
expect(result.current.proQuotaRequest).toBeNull();
});
it('should handle ModelNotFoundError with Vertex AI by displaying region-specific availability message and documentation link', async () => {
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
authType: AuthType.USE_VERTEX_AI,
});
vi.stubEnv('GOOGLE_CLOUD_LOCATION', 'us-central1');
const { result } = await renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
paidTier: null,
settings: mockSettings,
}),
);
const handler = setFallbackHandlerSpy.mock
.calls[0][0] as FallbackModelHandler;
let promise: Promise<FallbackIntent | null>;
const error = new ModelNotFoundError('model not found', 404);
act(() => {
promise = handler('gemini-3.5-flash', 'gemini-1.5-flash', error);
});
const request = result.current.proQuotaRequest;
expect(request).not.toBeNull();
expect(request?.failedModel).toBe('gemini-3.5-flash');
expect(request?.isModelNotFoundError).toBe(true);
const message = request!.message;
expect(message).toBe(
`Model "gemini-3.5-flash" is not available in region "us-central1".\n` +
`To see which models are available in this region, please visit:\n` +
`https://cloud.google.com/vertex-ai/generative-ai/docs/learn/locations\n` +
`/model to switch models.`,
);
act(() => {
result.current.handleProQuotaChoice('retry_always');
});
const intent = await promise!;
expect(intent).toBe('retry_always');
});
it('should handle ModelNotFoundError with Vertex AI and invalid model by displaying generic not found error message', async () => {
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
authType: AuthType.USE_VERTEX_AI,
});
vi.stubEnv('GOOGLE_CLOUD_LOCATION', 'us-central1');
const { result } = await renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
paidTier: null,
settings: mockSettings,
}),
);
const handler = setFallbackHandlerSpy.mock
.calls[0][0] as FallbackModelHandler;
let promise: Promise<FallbackIntent | null>;
const error = new ModelNotFoundError('model not found', 404);
act(() => {
promise = handler('invalid-model-name', 'gemini-1.5-flash', error);
});
const request = result.current.proQuotaRequest;
expect(request).not.toBeNull();
expect(request?.failedModel).toBe('invalid-model-name');
expect(request?.isModelNotFoundError).toBe(true);
const message = request!.message;
expect(message).toBe(
`Model "invalid-model-name" was not found or is invalid.\n` +
`/model to switch models.`,
);
act(() => {
result.current.handleProQuotaChoice('retry_always');
});
const intent = await promise!;
expect(intent).toBe('retry_always');
});
it('should handle ModelNotFoundError with invalid model correctly', async () => {
const { result } = await renderHook(() =>
useQuotaAndFallback({
@@ -135,7 +135,20 @@ export function useQuotaAndFallback({
message = messageLines.join('\n');
} else if (error instanceof ModelNotFoundError) {
isModelNotFoundError = true;
if (VALID_GEMINI_MODELS.has(failedModel)) {
if (
contentGeneratorConfig?.authType === AuthType.USE_VERTEX_AI &&
VALID_GEMINI_MODELS.has(failedModel)
) {
const location =
process.env['GOOGLE_CLOUD_LOCATION'] || 'your configured region';
const messageLines = [
`Model "${failedModel}" is not available in region "${location}".`,
`To see which models are available in this region, please visit:`,
`https://cloud.google.com/vertex-ai/generative-ai/docs/learn/locations`,
`/model to switch models.`,
];
message = messageLines.join('\n');
} else if (VALID_GEMINI_MODELS.has(failedModel)) {
const messageLines = [
`It seems like you don't have access to ${getDisplayString(failedModel)}.`,
`Your admin might have disabled the access. Contact them to enable the Preview Release Channel.`,
@@ -71,6 +71,11 @@ describe('CloudFreePrivacyNotice', () => {
mockState: { isFreeTier: false },
expectedText: 'Gemini Code Assist Privacy Notice',
},
{
stateName: 'tier unavailable state',
mockState: { isFreeTier: undefined, isTierUnavailable: true },
expectedText: 'GOOGLE_CLOUD_PROJECT',
},
{
stateName: 'free tier state',
mockState: { isFreeTier: true },
@@ -101,6 +106,11 @@ describe('CloudFreePrivacyNotice', () => {
mockState: { isFreeTier: false },
shouldExit: true,
},
{
stateName: 'tier unavailable state',
mockState: { isFreeTier: undefined, isTierUnavailable: true },
shouldExit: true,
},
{
stateName: 'free tier state (no selection)',
mockState: { isFreeTier: true },
@@ -27,7 +27,9 @@ export const CloudFreePrivacyNotice = ({
useKeypress(
(key) => {
if (
(privacyState.error || privacyState.isFreeTier === false) &&
(privacyState.error ||
privacyState.isFreeTier === false ||
privacyState.isTierUnavailable) &&
key.name === 'escape'
) {
onExit();
@@ -53,6 +55,35 @@ export const CloudFreePrivacyNotice = ({
);
}
if (privacyState.isTierUnavailable) {
return (
<Box flexDirection="column" marginY={1}>
<Text bold color={theme.text.accent}>
Gemini Code Assist Privacy Notice
</Text>
<Newline />
<Text color={theme.text.primary}>
The data collection opt-in isn&apos;t available for this account
because it doesn&apos;t have a Gemini Code Assist for Individuals
(free) tier.
</Text>
<Newline />
<Text color={theme.text.primary}>
If you&apos;re on a Google Workspace or enterprise account, use the
Vertex AI / Google Cloud path instead by setting the
GOOGLE_CLOUD_PROJECT environment variable to your Google Cloud
project.
</Text>
<Newline />
<Text color={theme.text.primary}>
Learn more: https://geminicli.com/docs/get-started/authentication/
</Text>
<Newline />
<Text color={theme.text.secondary}>Press Esc to exit.</Text>
</Box>
);
}
if (privacyState.isFreeTier === false) {
return (
<Box flexDirection="column" marginY={1}>
@@ -1,10 +1,74 @@
(version 1)
;; allow everything by default
(allow default)
;; permissive-open: uses (deny default) and explicitly allows the operations the
;; CLI needs, matching the restrictive-* / strict-* profiles. Keep the allow-list
;; minimal and reviewed; do not switch to (allow default).
;;
;; Keep the non-network rules in sync with sandbox-macos-permissive-proxied.sb:
;; the two profiles are intentionally identical except for their network rules
;; ("open" allows broad outbound; "proxied" routes outbound through the proxy).
(deny default)
;; deny all writes EXCEPT under specific paths
(deny file-write*)
;; allow reading files from anywhere on host
(allow file-read*)
;; allow exec/fork (children inherit this policy, so they stay sandboxed)
(allow process-exec)
(allow process-fork)
;; allow signals to self, e.g. SIGPIPE on write to closed pipe
(allow signal (target self))
;; allow read access to specific information about system
;; from https://source.chromium.org/chromium/chromium/src/+/main:sandbox/policy/mac/common.sb;l=273-319;drc=7b3962fe2e5fc9e2ee58000dc8fbf3429d84d3bd
(allow sysctl-read
(sysctl-name "hw.activecpu")
(sysctl-name "hw.busfrequency_compat")
(sysctl-name "hw.byteorder")
(sysctl-name "hw.cacheconfig")
(sysctl-name "hw.cachelinesize_compat")
(sysctl-name "hw.cpufamily")
(sysctl-name "hw.cpufrequency_compat")
(sysctl-name "hw.cputype")
(sysctl-name "hw.l1dcachesize_compat")
(sysctl-name "hw.l1icachesize_compat")
(sysctl-name "hw.l2cachesize_compat")
(sysctl-name "hw.l3cachesize_compat")
(sysctl-name "hw.logicalcpu_max")
(sysctl-name "hw.machine")
(sysctl-name "hw.ncpu")
(sysctl-name "hw.nperflevels")
(sysctl-name "hw.optional.arm.FEAT_BF16")
(sysctl-name "hw.optional.arm.FEAT_DotProd")
(sysctl-name "hw.optional.arm.FEAT_FCMA")
(sysctl-name "hw.optional.arm.FEAT_FHM")
(sysctl-name "hw.optional.arm.FEAT_FP16")
(sysctl-name "hw.optional.arm.FEAT_I8MM")
(sysctl-name "hw.optional.arm.FEAT_JSCVT")
(sysctl-name "hw.optional.arm.FEAT_LSE")
(sysctl-name "hw.optional.arm.FEAT_RDM")
(sysctl-name "hw.optional.arm.FEAT_SHA512")
(sysctl-name "hw.optional.armv8_2_sha512")
(sysctl-name "hw.packages")
(sysctl-name "hw.pagesize_compat")
(sysctl-name "hw.physicalcpu_max")
(sysctl-name "hw.tbfrequency_compat")
(sysctl-name "hw.vectorunit")
(sysctl-name "kern.hostname")
(sysctl-name "kern.maxfilesperproc")
(sysctl-name "kern.osproductversion")
(sysctl-name "kern.osrelease")
(sysctl-name "kern.ostype")
(sysctl-name "kern.osvariant_status")
(sysctl-name "kern.osversion")
(sysctl-name "kern.secure_kernel")
(sysctl-name "kern.usrstack64")
(sysctl-name "kern.version")
(sysctl-name "sysctl.proc_cputype")
(sysctl-name-prefix "hw.perflevel")
)
;; allow writes only to specific paths (deny default already blocks the rest)
(allow file-write*
(subpath (param "TARGET_DIR"))
(subpath (param "TMP_DIR"))
@@ -12,7 +76,6 @@
(subpath (string-append (param "HOME_DIR") "/.gemini"))
(subpath (string-append (param "HOME_DIR") "/.npm"))
(subpath (string-append (param "HOME_DIR") "/.cache"))
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))
;; Allow writes to included directories from --include-directories
(subpath (param "INCLUDE_DIR_0"))
(subpath (param "INCLUDE_DIR_1"))
@@ -25,3 +88,48 @@
(literal "/dev/ptmx")
(regex #"^/dev/ttys[0-9]*$")
)
;; allow the mach services normal workflows need under deny-default: sysmond for
;; process listing (pgrep), plus DNS resolution (mDNSResponder), directory
;; services (opendirectoryd), and certificate validation (trustd/ocspd).
;; This set mirrors the deny-default profile in
;; packages/core/src/sandbox/macos/baseProfile.ts (its NETWORK_SEATBELT_PROFILE),
;; which restrictive-open reaches only implicitly via (allow network-outbound).
;; Keep this allow-list minimal and reviewed.
(allow mach-lookup
(global-name "com.apple.sysmond")
(global-name "com.apple.system.opendirectoryd.libinfo")
(global-name "com.apple.system.opendirectoryd.membership")
(global-name "com.apple.bsd.dirhelper")
(global-name "com.apple.SecurityServer")
(global-name "com.apple.networkd")
(global-name "com.apple.ocspd")
(global-name "com.apple.trustd")
(global-name "com.apple.trustd.agent")
(global-name "com.apple.mDNSResponder")
(global-name "com.apple.mDNSResponderHelper")
(global-name "com.apple.SystemConfiguration.DNSConfiguration")
(global-name "com.apple.SystemConfiguration.configd")
)
;; AF_SYSTEM socket used by the network stack (from baseProfile.ts)
(allow system-socket
(require-all
(socket-domain AF_SYSTEM)
(socket-protocol 2)
)
)
;; enable terminal access required by ink
;; fixes setRawMode EPERM failure (at node:tty:81:24)
(allow file-ioctl (regex #"^/dev/tty.*"))
;; allow inbound network traffic (local dev/test servers, the debugger on :9229,
;; OAuth localhost callbacks)
(allow network-inbound (local ip "*:*"))
;; allow binding local ports (dev/test servers, OAuth localhost listeners)
(allow network-bind (local ip "*:*"))
;; allow all outbound network traffic
(allow network-outbound)
@@ -1,10 +1,76 @@
(version 1)
;; allow everything by default
(allow default)
;; permissive-proxied: uses (deny default) and explicitly allows the operations
;; the CLI needs, matching the restrictive-* / strict-* profiles. Keep the
;; allow-list minimal and reviewed; do not switch to (allow default).
;;
;; Keep the non-network rules in sync with sandbox-macos-permissive-open.sb:
;; the two profiles are intentionally identical except for their network rules
;; ("open" allows broad outbound; "proxied" routes outbound through the proxy).
(deny default)
;; deny all writes EXCEPT under specific paths
(deny file-write*)
;; allow reading files from anywhere on host
(allow file-read*)
;; allow exec/fork (children inherit this policy, so they stay sandboxed)
(allow process-exec)
(allow process-fork)
;; allow signals to self, e.g. SIGPIPE on write to closed pipe
(allow signal (target self))
;; allow read access to specific information about system
;; from https://source.chromium.org/chromium/chromium/src/+/main:sandbox/policy/mac/common.sb;l=273-319;drc=7b3962fe2e5fc9e2ee58000dc8fbf3429d84d3bd
(allow sysctl-read
(sysctl-name "hw.activecpu")
(sysctl-name "hw.busfrequency_compat")
(sysctl-name "hw.byteorder")
(sysctl-name "hw.cacheconfig")
(sysctl-name "hw.cachelinesize_compat")
(sysctl-name "hw.cpufamily")
(sysctl-name "hw.cpufrequency_compat")
(sysctl-name "hw.cputype")
(sysctl-name "hw.l1dcachesize_compat")
(sysctl-name "hw.l1icachesize_compat")
(sysctl-name "hw.l2cachesize_compat")
(sysctl-name "hw.l3cachesize_compat")
(sysctl-name "hw.logicalcpu_max")
(sysctl-name "hw.machine")
(sysctl-name "hw.ncpu")
(sysctl-name "hw.nperflevels")
(sysctl-name "hw.optional.arm.FEAT_BF16")
(sysctl-name "hw.optional.arm.FEAT_DotProd")
(sysctl-name "hw.optional.arm.FEAT_FCMA")
(sysctl-name "hw.optional.arm.FEAT_FHM")
(sysctl-name "hw.optional.arm.FEAT_FP16")
(sysctl-name "hw.optional.arm.FEAT_I8MM")
(sysctl-name "hw.optional.arm.FEAT_JSCVT")
(sysctl-name "hw.optional.arm.FEAT_LSE")
(sysctl-name "hw.optional.arm.FEAT_RDM")
(sysctl-name "hw.optional.arm.FEAT_SHA512")
(sysctl-name "hw.optional.armv8_2_sha512")
(sysctl-name "hw.packages")
(sysctl-name "hw.pagesize_compat")
(sysctl-name "hw.physicalcpu_max")
(sysctl-name "hw.tbfrequency_compat")
(sysctl-name "hw.vectorunit")
(sysctl-name "kern.hostname")
(sysctl-name "kern.maxfilesperproc")
(sysctl-name "kern.osproductversion")
(sysctl-name "kern.osrelease")
(sysctl-name "kern.ostype")
(sysctl-name "kern.osvariant_status")
(sysctl-name "kern.osversion")
(sysctl-name "kern.secure_kernel")
(sysctl-name "kern.usrstack64")
(sysctl-name "kern.version")
(sysctl-name "sysctl.proc_cputype")
(sysctl-name-prefix "hw.perflevel")
)
;; allow writes only to specific paths (deny default already blocks the rest).
;; Mirrors permissive-open, including /dev/ptmx and the /dev/ttys regex needed
;; for PTY support under deny default.
(allow file-write*
(subpath (param "TARGET_DIR"))
(subpath (param "TMP_DIR"))
@@ -12,7 +78,6 @@
(subpath (string-append (param "HOME_DIR") "/.gemini"))
(subpath (string-append (param "HOME_DIR") "/.npm"))
(subpath (string-append (param "HOME_DIR") "/.cache"))
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))
;; Allow writes to included directories from --include-directories
(subpath (param "INCLUDE_DIR_0"))
(subpath (param "INCLUDE_DIR_1"))
@@ -22,16 +87,52 @@
(literal "/dev/stdout")
(literal "/dev/stderr")
(literal "/dev/null")
(literal "/dev/ptmx")
(regex #"^/dev/ttys[0-9]*$")
)
;; deny all inbound network traffic EXCEPT on debugger port
(deny network-inbound)
;; allow the mach services normal workflows need under deny-default: sysmond for
;; process listing (pgrep), plus DNS resolution (mDNSResponder), directory
;; services (opendirectoryd), and certificate validation (trustd/ocspd).
;; This set mirrors the deny-default profile in
;; packages/core/src/sandbox/macos/baseProfile.ts (its NETWORK_SEATBELT_PROFILE),
;; which restrictive-proxied reaches only implicitly via (allow network-outbound).
;; Keep this allow-list minimal and reviewed.
(allow mach-lookup
(global-name "com.apple.sysmond")
(global-name "com.apple.system.opendirectoryd.libinfo")
(global-name "com.apple.system.opendirectoryd.membership")
(global-name "com.apple.bsd.dirhelper")
(global-name "com.apple.SecurityServer")
(global-name "com.apple.networkd")
(global-name "com.apple.ocspd")
(global-name "com.apple.trustd")
(global-name "com.apple.trustd.agent")
(global-name "com.apple.mDNSResponder")
(global-name "com.apple.mDNSResponderHelper")
(global-name "com.apple.SystemConfiguration.DNSConfiguration")
(global-name "com.apple.SystemConfiguration.configd")
)
;; AF_SYSTEM socket used by the network stack (from baseProfile.ts)
(allow system-socket
(require-all
(socket-domain AF_SYSTEM)
(socket-protocol 2)
)
)
;; enable terminal access required by ink
;; fixes setRawMode EPERM failure (at node:tty:81:24)
(allow file-ioctl (regex #"^/dev/tty.*"))
;; allow inbound network traffic on debugger port
(allow network-inbound (local ip "localhost:9229"))
;; allow binding local ports (dev/test servers, OAuth localhost listeners)
(allow network-bind (local ip "*:*"))
;; deny all outbound network traffic EXCEPT through proxy on localhost:8877
;; set `GEMINI_SANDBOX_PROXY_COMMAND=<command>` to run proxy alongside sandbox
;; proxy must listen on :::8877 (see docs/examples/proxy-script.md)
(deny network-outbound)
(allow network-outbound (remote tcp "localhost:8877"))
(allow network-bind (local ip "*:*"))
@@ -0,0 +1,78 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const utilsDir = path.dirname(fileURLToPath(import.meta.url));
/**
* Strip SBPL comments (`; ...` to end of line) so assertions run against the
* actual sandbox rules rather than any keywords that happen to appear in the
* explanatory comments.
*/
function readRules(profile: string): string {
return readFileSync(path.join(utilsDir, profile), 'utf8')
.split('\n')
.map((line) => {
const commentStart = line.indexOf(';');
return commentStart === -1 ? line : line.slice(0, commentStart);
})
.join('\n');
}
const PERMISSIVE_PROFILES = [
'sandbox-macos-permissive-open.sb',
'sandbox-macos-permissive-proxied.sb',
];
// These two profiles are the default macOS Seatbelt profiles, so the invariants
// below must never silently regress. Keep them deny-default and confirm the
// reviewed allow-list stays in place.
describe('macOS permissive Seatbelt profiles', () => {
describe.each(PERMISSIVE_PROFILES)('%s', (profile) => {
const rules = readRules(profile);
it('uses a deny-default foundation', () => {
expect(rules).toContain('(deny default)');
});
it('does not use an allow-default foundation', () => {
expect(rules).not.toContain('(allow default)');
});
it('does not permit filesystem (un)mounts', () => {
expect(rules).not.toMatch(/file-mount/);
expect(rules).not.toMatch(/file-unmount/);
});
it('does not grant broad service lookups', () => {
expect(rules).not.toMatch(/launchd/);
expect(rules).not.toMatch(/launchservices/i);
});
it('allows binding local ports for dev/test servers', () => {
expect(rules).toContain('(allow network-bind (local ip "*:*"))');
});
});
it('permissive-open keeps broad inbound and outbound network', () => {
const rules = readRules('sandbox-macos-permissive-open.sb');
expect(rules).toContain('(allow network-inbound (local ip "*:*"))');
expect(rules).toMatch(/\(allow network-outbound\)/);
});
it('permissive-proxied confines outbound to the proxy', () => {
const rules = readRules('sandbox-macos-permissive-proxied.sb');
expect(rules).toContain(
'(allow network-outbound (remote tcp "localhost:8877"))',
);
// Proxied mode must never grant unrestricted outbound network.
expect(rules).not.toMatch(/\(allow network-outbound\)/);
});
});
@@ -70,7 +70,6 @@
(subpath (string-append (param "HOME_DIR") "/.gemini"))
(subpath (string-append (param "HOME_DIR") "/.npm"))
(subpath (string-append (param "HOME_DIR") "/.cache"))
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))
;; Allow writes to included directories from --include-directories
(subpath (param "INCLUDE_DIR_0"))
(subpath (param "INCLUDE_DIR_1"))
@@ -70,7 +70,6 @@
(subpath (string-append (param "HOME_DIR") "/.gemini"))
(subpath (string-append (param "HOME_DIR") "/.npm"))
(subpath (string-append (param "HOME_DIR") "/.cache"))
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))
;; Allow writes to included directories from --include-directories
(subpath (param "INCLUDE_DIR_0"))
(subpath (param "INCLUDE_DIR_1"))
@@ -105,7 +105,6 @@
(subpath (string-append (param "HOME_DIR") "/.gemini"))
(subpath (string-append (param "HOME_DIR") "/.npm"))
(subpath (string-append (param "HOME_DIR") "/.cache"))
(literal (string-append (param "HOME_DIR") "/.gitconfig"))
;; Allow writes to included directories from --include-directories
(subpath (param "INCLUDE_DIR_0"))
(subpath (param "INCLUDE_DIR_1"))
@@ -105,7 +105,6 @@
(subpath (string-append (param "HOME_DIR") "/.gemini"))
(subpath (string-append (param "HOME_DIR") "/.npm"))
(subpath (string-append (param "HOME_DIR") "/.cache"))
(literal (string-append (param "HOME_DIR") "/.gitconfig"))
;; Allow writes to included directories from --include-directories
(subpath (param "INCLUDE_DIR_0"))
(subpath (param "INCLUDE_DIR_1"))
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
"version": "0.55.0-nightly.20260729.g3499c84f7",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
@@ -64,7 +64,7 @@
"fdir": "6.4.6",
"fzf": "0.5.2",
"glob": "12.0.0",
"google-auth-library": "9.11.0",
"google-auth-library": "10.9.0",
"html-to-text": "9.0.5",
"http-proxy-agent": "7.0.2",
"https-proxy-agent": "7.0.6",
@@ -0,0 +1,74 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { GoogleCredentialsAuthProvider } from './google-credentials-provider.js';
import type { GoogleCredentialsAuthConfig } from './types.js';
import { GoogleAuth } from 'google-auth-library';
vi.mock('google-auth-library', () => ({
GoogleAuth: vi.fn(),
}));
describe('Credential Leak Prevention (RCA / PoC Verification)', () => {
const mockConfig: GoogleCredentialsAuthConfig = {
type: 'google-credentials',
};
beforeEach(() => {
vi.clearAllMocks();
(GoogleAuth as unknown as Mock).mockImplementation(() => ({
getClient: vi.fn().mockResolvedValue({
getAccessToken: vi.fn().mockResolvedValue({ token: 'leaked-token' }),
credentials: { expiry_date: Date.now() + 3600 * 1000 },
}),
getIdTokenClient: vi.fn().mockResolvedValue({
idTokenProvider: {
fetchIdToken: vi.fn().mockResolvedValue('leaked-id-token'),
},
}),
}));
});
it('should FAIL (throw error) when trying to initialize with an untrusted arbitrary remote agent URL (reproducing vulnerability prevention)', () => {
// This test simulates the reproduction scenario: registering a remote agent with an arbitrary external URL
// e.g., http://127.0.0.1:1337 or https://malicious-agent.evil.com
const untrustedUrls = [
{
url: 'http://127.0.0.1:1337/.well-known/agent.json',
error: /requires HTTPS/,
},
{
url: 'https://malicious-agent.evil.com/card',
error: /is not an allowed host/,
},
{
url: 'https://untrusted-third-party.com/agent',
error: /is not an allowed host/,
},
];
for (const item of untrustedUrls) {
expect(() => {
new GoogleCredentialsAuthProvider(mockConfig, item.url);
}).toThrow(item.error);
}
});
it('should SUCCEED only for allowed Google Services (proving the allowlist constraint)', () => {
const trustedUrls = [
'https://language.googleapis.com/v1/models',
'https://vertex-ai-agent.googleapis.com/agent',
'https://my-secure-service-abc.run.app/card',
];
for (const url of trustedUrls) {
expect(() => {
new GoogleCredentialsAuthProvider(mockConfig, url);
}).not.toThrow();
}
});
});
@@ -82,6 +82,24 @@ describe('GoogleCredentialsAuthProvider', () => {
),
).not.toThrow();
});
it('throws if the protocol is not HTTPS', () => {
expect(
() =>
new GoogleCredentialsAuthProvider(
mockConfig,
'http://language.googleapis.com/v1/models',
),
).toThrow(/requires HTTPS/);
expect(
() =>
new GoogleCredentialsAuthProvider(
mockConfig,
'http://my-cloud-run-service.run.app',
),
).toThrow(/requires HTTPS/);
});
});
describe('Token Fetching', () => {
@@ -40,7 +40,14 @@ export class GoogleCredentialsAuthProvider extends BaseA2AAuthProvider {
);
}
const hostname = new URL(targetUrl).hostname;
const urlObj = new URL(targetUrl);
if (urlObj.protocol !== 'https:') {
throw new Error(
`Protocol "${urlObj.protocol}" is not secure. Google Credential provider requires HTTPS.`,
);
}
const hostname = urlObj.hostname;
const isRunAppHost = CLOUD_RUN_HOST_REGEX.test(hostname);
if (isRunAppHost) {
@@ -414,4 +414,86 @@ describe('Auto Routing Fallback Integration', () => {
'Pro success',
);
});
it('should rotate session ID on fallback and retry successfully with the Flash model', async () => {
const originalSessionId = 'test-session-rotate-id';
config = new Config({
sessionId: originalSessionId,
targetDir: '/test',
debugMode: false,
cwd: '/test',
model: PREVIEW_GEMINI_MODEL_AUTO,
});
vi.spyOn(config, 'isInteractive').mockReturnValue(true);
client = new BaseLlmClient(
fakeGenerator,
config,
AuthType.LOGIN_WITH_GOOGLE,
);
let attemptsPro = 0;
let attemptsFlash = 0;
const mockGoogleApiError = {
code: 429,
message:
'Automatically switching from gemini-2.5-pro to gemini-2.5-flash for faster responses for the remainder of this session. Possible reasons for this are...',
details: [],
};
vi.spyOn(fakeGenerator, 'generateContent').mockImplementation(
async (params) => {
if (params.model === PREVIEW_GEMINI_MODEL) {
attemptsPro++;
throw new RetryableQuotaError(
'Quota exceeded for Pro',
mockGoogleApiError,
0,
);
} else if (params.model === PREVIEW_GEMINI_FLASH_MODEL) {
attemptsFlash++;
return {
candidates: [
{
content: {
role: 'model',
parts: [{ text: 'Flash success after rotation' }],
},
},
],
} as unknown as GenerateContentResponse;
}
throw new Error(`Unexpected model: ${params.model}`);
},
);
config.setFallbackModelHandler(
async (_failed, _fallback, _error): Promise<FallbackIntent | null> =>
'retry_always', // Approve switch to Flash
);
const promise = client.generateContent({
modelConfigKey: { model: PREVIEW_GEMINI_MODEL, isChatModel: true },
contents: [{ role: 'user', parts: [{ text: 'test query' }] }],
abortSignal: new AbortController().signal,
promptId: 'test-prompt',
role: LlmRole.UTILITY_TOOL,
});
await vi.runAllTimersAsync();
const result = await promise;
// Verify it resolved to Flash success instead of failing with Please submit a new query
expect(result.candidates?.[0]?.content?.parts?.[0]?.text).toBe(
'Flash success after rotation',
);
expect(attemptsPro).toBe(3);
expect(attemptsFlash).toBe(1);
// Verify session ID has been rotated
expect(config.getSessionId()).not.toBe(originalSessionId);
expect(config.getSessionId()).toBeDefined();
});
});
@@ -680,6 +680,64 @@ describe('oauth2', () => {
expect(mockFromJSON).toHaveBeenCalledWith(byoidCredentials);
expect(client).toBe(mockExternalAccountClient);
});
it('should fall back to GOOGLE_APPLICATION_CREDENTIALS if default cached credentials are invalid or expired', async () => {
// Setup default cached credentials that are expired/invalid
const defaultCreds = { refresh_token: 'expired-token' };
const defaultCredsPath = path.join(
tempHomeDir,
GEMINI_DIR,
'oauth_creds.json',
);
await fs.promises.mkdir(path.dirname(defaultCredsPath), {
recursive: true,
});
await fs.promises.writeFile(
defaultCredsPath,
JSON.stringify(defaultCreds),
);
// Setup valid fallback credentials via environment variable
const envCreds = { refresh_token: 'valid-env-token' };
const envCredsPath = path.join(tempHomeDir, 'env_creds.json');
await fs.promises.writeFile(envCredsPath, JSON.stringify(envCreds));
vi.stubEnv('GOOGLE_APPLICATION_CREDENTIALS', envCredsPath);
let currentCredentials: Credentials | null = null;
const mockClient = {
setCredentials: vi.fn((creds) => {
currentCredentials = creds as Credentials;
}),
getAccessToken: vi.fn(async () => {
if (
currentCredentials &&
currentCredentials.refresh_token === 'expired-token'
) {
throw new Error('Token is expired or revoked');
}
return { token: 'valid-token' };
}),
getTokenInfo: vi.fn(async (_token) => {
if (
currentCredentials &&
currentCredentials.refresh_token === 'expired-token'
) {
throw new Error('Token is expired or revoked');
}
return {};
}),
on: vi.fn(),
};
vi.mocked(OAuth2Client).mockImplementation(
() => mockClient as unknown as OAuth2Client,
);
await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfig);
// Assert that fallback envCreds were eventually loaded and used
expect(mockClient.setCredentials).toHaveBeenCalledWith(envCreds);
});
});
describe('with GCP environment variables', () => {
+111 -72
View File
@@ -113,46 +113,52 @@ function getUseEncryptedStorageFlag() {
return process.env[FORCE_ENCRYPTED_FILE_ENV_VAR] === 'true';
}
/**
* Determines whether the given credentials object represents ADC credentials.
*/
function isAdcCredentials(
credentials: unknown,
): credentials is JWTInput & { type: string } {
if (credentials && typeof credentials === 'object' && 'type' in credentials) {
const type = credentials.type;
return typeof type === 'string' && type !== 'authorized_user';
}
return false;
}
async function initOauthClient(
authType: AuthType,
config: Config,
): Promise<AuthClient> {
const credentials = await fetchCachedCredentials();
function createBaseOAuth2Client(): OAuth2Client {
const client = new OAuth2Client({
clientId: OAUTH_CLIENT_ID,
clientSecret: OAUTH_CLIENT_SECRET,
transporterOptions: {
proxy: config.getProxy(),
},
});
const useEncryptedStorage = getUseEncryptedStorageFlag();
if (
credentials &&
typeof credentials === 'object' &&
'type' in credentials &&
(credentials.type === 'external_account_authorized_user' ||
credentials.type === 'service_account')
) {
const auth = new GoogleAuth({
scopes: OAUTH_SCOPE,
client.on('tokens', async (tokens: Credentials) => {
if (useEncryptedStorage) {
await OAuthCredentialStorage.saveCredentials(tokens);
} else {
await cacheCredentials(tokens);
}
await triggerPostAuthCallbacks(tokens);
});
const byoidClient = auth.fromJSON({
...credentials,
refresh_token: credentials.refresh_token ?? undefined,
});
const token = await byoidClient.getAccessToken();
if (token) {
debugLogger.debug(`Created ${credentials.type} auth client.`);
return byoidClient;
}
return client;
}
const client = new OAuth2Client({
clientId: OAUTH_CLIENT_ID,
clientSecret: OAUTH_CLIENT_SECRET,
transporterOptions: {
proxy: config.getProxy(),
},
});
const useEncryptedStorage = getUseEncryptedStorageFlag();
// 1. Try GOOGLE_CLOUD_ACCESS_TOKEN override first if configured
if (
process.env['GOOGLE_GENAI_USE_GCA'] &&
process.env['GOOGLE_CLOUD_ACCESS_TOKEN']
) {
const client = createBaseOAuth2Client();
client.setCredentials({
access_token: process.env['GOOGLE_CLOUD_ACCESS_TOKEN'],
});
@@ -160,49 +166,70 @@ async function initOauthClient(
return client;
}
client.on('tokens', async (tokens: Credentials) => {
if (useEncryptedStorage) {
await OAuthCredentialStorage.saveCredentials(tokens);
} else {
await cacheCredentials(tokens);
}
const credentialsList = await fetchCachedCredentialsList();
await triggerPostAuthCallbacks(tokens);
});
if (credentials) {
client.setCredentials(credentials as Credentials);
try {
// This will verify locally that the credentials look good.
const { token } = await client.getAccessToken();
if (token) {
// This will check with the server to see if it hasn't been revoked.
await client.getTokenInfo(token);
if (!userAccountManager.getCachedGoogleAccount()) {
try {
await fetchAndCacheUserInfo(client);
} catch (error) {
// Non-fatal, continue with existing auth.
debugLogger.warn(
'Failed to fetch user info:',
getErrorMessage(error),
);
}
// 2. Iterate sequentially over the credentials list in their natural priority order
for (const credentials of credentialsList) {
if (isAdcCredentials(credentials)) {
try {
const auth = new GoogleAuth({
scopes: OAUTH_SCOPE,
});
const adcClient = auth.fromJSON({
...credentials,
refresh_token: credentials.refresh_token ?? undefined,
});
const response = await adcClient.getAccessToken();
const token = response.token ?? null;
if (token) {
debugLogger.debug('Created ' + credentials.type + ' auth client.');
return adcClient;
}
debugLogger.log('Loaded cached credentials.');
await triggerPostAuthCallbacks(credentials as Credentials);
return client;
} catch (error) {
debugLogger.debug(
'ADC credentials verification failed:',
getErrorMessage(error),
);
}
} else if (credentials) {
const client = createBaseOAuth2Client();
client.setCredentials(credentials as Credentials);
try {
// This will verify locally that the credentials look good.
const { token } = await client.getAccessToken();
if (token) {
// This will check with the server to see if it hasn't been revoked.
await client.getTokenInfo(token);
if (!userAccountManager.getCachedGoogleAccount()) {
try {
await fetchAndCacheUserInfo(client);
} catch (error) {
// Non-fatal, continue with existing auth.
debugLogger.warn(
'Failed to fetch user info:',
getErrorMessage(error),
);
}
}
debugLogger.log('Loaded cached credentials.');
await triggerPostAuthCallbacks(
client.credentials || (credentials as Credentials),
);
return client;
}
} catch (error) {
debugLogger.debug(
'Cached credentials are not valid:',
getErrorMessage(error),
);
}
} catch (error) {
debugLogger.debug(
`Cached credentials are not valid:`,
getErrorMessage(error),
);
}
}
const client = createBaseOAuth2Client();
// In Google Compute Engine based environments (including Cloud Shell), we can
// use Application Default Credentials (ADC) provided via its metadata server
// to authenticate non-interactively using the identity of the logged-in user.
@@ -663,16 +690,27 @@ export function getAvailablePort(): Promise<number> {
});
}
async function fetchCachedCredentials(): Promise<
Credentials | JWTInput | null
async function fetchCachedCredentialsList(): Promise<
Array<Credentials | JWTInput>
> {
const credentialsList: Array<Credentials | JWTInput> = [];
const useEncryptedStorage = getUseEncryptedStorageFlag();
if (useEncryptedStorage) {
return OAuthCredentialStorage.loadCredentials();
try {
const creds = await OAuthCredentialStorage.loadCredentials();
if (creds) {
credentialsList.push(creds);
}
} catch (error) {
debugLogger.debug(
'Failed to load credentials from encrypted storage:',
error,
);
}
}
const pathsToTry = [
Storage.getOAuthCredsPath(),
...(!useEncryptedStorage ? [Storage.getOAuthCredsPath()] : []),
process.env['GOOGLE_APPLICATION_CREDENTIALS'],
].filter((p): p is string => !!p);
@@ -683,9 +721,10 @@ async function fetchCachedCredentials(): Promise<
const isOAuthCreds = (val: unknown): val is Credentials | JWTInput =>
typeof val === 'object' && val !== null;
if (isOAuthCreds(parsed)) {
return parsed;
credentialsList.push(parsed);
} else {
throw new Error('Invalid credentials format');
}
throw new Error('Invalid credentials format');
} catch (error) {
// Log specific error for debugging, but continue trying other paths
debugLogger.debug(
@@ -695,7 +734,7 @@ async function fetchCachedCredentials(): Promise<
}
}
return null;
return credentialsList;
}
export function clearOauthClientCache() {
+8 -4
View File
@@ -86,6 +86,10 @@ export class CodeAssistServer implements ContentGenerator {
readonly config?: Config,
) {}
getEffectiveSessionId(): string | undefined {
return this.config?.getSessionId() ?? this.sessionId;
}
async generateContentStream(
req: GenerateContentParameters,
userPromptId: string,
@@ -117,7 +121,7 @@ export class CodeAssistServer implements ContentGenerator {
req,
userPromptId,
this.projectId,
this.sessionId,
this.getEffectiveSessionId(),
enabledCreditTypes,
),
req.config?.abortSignal,
@@ -153,7 +157,7 @@ export class CodeAssistServer implements ContentGenerator {
translatedResponse,
streamingLatency,
req.config?.abortSignal,
server.sessionId, // Use sessionId as trajectoryId
server.getEffectiveSessionId(), // Use sessionId as trajectoryId
);
if (response.consumedCredits) {
@@ -204,7 +208,7 @@ export class CodeAssistServer implements ContentGenerator {
req,
userPromptId,
this.projectId,
this.sessionId,
this.getEffectiveSessionId(),
undefined,
),
req.config?.abortSignal,
@@ -224,7 +228,7 @@ export class CodeAssistServer implements ContentGenerator {
translatedResponse,
streamingLatency,
req.config?.abortSignal,
this.sessionId, // Use sessionId as trajectoryId
this.getEffectiveSessionId(), // Use sessionId as trajectoryId
);
if (response.remainingCredits) {
+7
View File
@@ -678,6 +678,7 @@ export interface ConfigParameters {
truncateToolOutputThreshold?: number;
eventEmitter?: EventEmitter;
useWriteTodos?: boolean;
env?: Record<string, string>;
workspacePoliciesDir?: string;
policyEngineConfig?: PolicyEngineConfig;
directWebFetch?: boolean;
@@ -896,6 +897,7 @@ export class Config implements McpContext, AgentLoopContext {
private readonly useTerminalBuffer: boolean;
private readonly useRenderProcess: boolean;
private shellExecutionConfig: ShellExecutionConfig;
readonly env?: Record<string, string>;
private readonly extensionManagement: boolean = true;
private readonly extensionRegistryURI: string | undefined;
private readonly truncateToolOutputThreshold: number;
@@ -1119,6 +1121,7 @@ export class Config implements McpContext, AgentLoopContext {
this.checkpointing = params.checkpointing ?? false;
this.proxy = params.proxy;
this.cwd = params.cwd ?? process.cwd();
this.env = params.env;
this.fileDiscoveryService = params.fileDiscoveryService ?? null;
this.bugCommand = params.bugCommand;
this.model = params.model;
@@ -1858,6 +1861,10 @@ export class Config implements McpContext, AgentLoopContext {
}
}
rotateSessionId(sessionId: string): void {
this._sessionId = sessionId;
}
resetNewSessionState(sessionId: string): void {
this.setSessionId(sessionId);
}
@@ -135,6 +135,29 @@ describe('AgentHistoryProvider', () => {
);
});
it('should use unambiguous label in fallback summary to avoid LLM confusion', async () => {
providerConfig.maxTokens = 60000;
providerConfig.retainedTokens = 60000;
vi.spyOn(config, 'getContextManagementConfig').mockReturnValue({
enabled: true,
} as unknown as ContextManagementConfig);
vi.mocked(estimateTokenCountSync).mockImplementation(
(parts: Part[]) => parts.length * 4000,
);
generateContentMock.mockRejectedValue(new Error('API Error'));
const history = createMockHistory(35);
const result = await provider.manageHistory(history);
expect(generateContentMock).toHaveBeenCalled();
expect(result.length).toBe(15);
// The fallback summary should use clear and unambiguous phrasing
expect(result[0].parts![0].text).toContain(
'Previous User Intent (Truncated):',
);
expect(result[0].parts![0].text).not.toContain('Last User Intent:');
});
it('should pass the contextual bridge to the summarizer', async () => {
vi.spyOn(config, 'getContextManagementConfig').mockReturnValue({
enabled: true,
@@ -267,7 +267,9 @@ export class AgentHistoryProvider {
];
if (lastUserText) {
summaryParts.push(`- **Last User Intent:** "${lastUserText}"`);
summaryParts.push(
`- **Previous User Intent (Truncated):** "${lastUserText}"`,
);
}
if (actionPath) {
+1 -1
View File
@@ -212,7 +212,7 @@ describe('Gemini Client (client.ts)', () => {
.fn()
.mockReturnValue(contentGeneratorConfig),
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
getModel: vi.fn().mockReturnValue('test-model'),
getModel: vi.fn().mockReturnValue('gemini-1.5-pro'),
getUserTier: vi.fn().mockReturnValue(undefined),
getEmbeddingModel: vi.fn().mockReturnValue('test-embedding-model'),
getApiKey: vi.fn().mockReturnValue('test-key'),
@@ -93,6 +93,7 @@ describe('createContentGenerator', () => {
resetVersionCache();
vi.clearAllMocks();
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', '');
vi.stubEnv('GOOGLE_CLOUD_LOCATION', '');
});
afterEach(() => {
@@ -483,6 +484,82 @@ describe('createContentGenerator', () => {
);
});
it('should use US REP endpoint for Vertex AI when location is us and no baseUrl is provided', async () => {
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const mockGenerator = {
models: {},
} as unknown as GoogleGenAI;
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
vi.stubEnv('GOOGLE_CLOUD_LOCATION', 'us');
await createContentGenerator(
{
apiKey: 'test-api-key',
vertexai: true,
authType: AuthType.USE_VERTEX_AI,
},
mockConfig,
);
expect(GoogleGenAI).toHaveBeenCalledWith(
expect.objectContaining({
googleAuthOptions: expect.objectContaining({
clientOptions: expect.objectContaining({
apiEndpoint: 'https://aiplatform.us.rep.googleapis.com',
}),
}),
httpOptions: expect.objectContaining({
baseUrl: 'https://aiplatform.us.rep.googleapis.com',
}),
}),
);
});
it('should use EU REP endpoint for Vertex AI when location is eu and no baseUrl is provided', async () => {
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const mockGenerator = {
models: {},
} as unknown as GoogleGenAI;
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
vi.stubEnv('GOOGLE_CLOUD_LOCATION', 'eu');
await createContentGenerator(
{
apiKey: 'test-api-key',
vertexai: true,
authType: AuthType.USE_VERTEX_AI,
},
mockConfig,
);
expect(GoogleGenAI).toHaveBeenCalledWith(
expect.objectContaining({
googleAuthOptions: expect.objectContaining({
clientOptions: expect.objectContaining({
apiEndpoint: 'https://aiplatform.eu.rep.googleapis.com',
}),
}),
httpOptions: expect.objectContaining({
baseUrl: 'https://aiplatform.eu.rep.googleapis.com',
}),
}),
);
});
it('should inject HttpsProxyAgent into googleAuthOptions when proxy URL uses https://', async () => {
const mockConfigWithProxy = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
+29 -10
View File
@@ -121,6 +121,15 @@ const VERTEX_AI_REQUEST_TYPE_HEADER = 'X-Vertex-AI-LLM-Request-Type';
const VERTEX_AI_SHARED_REQUEST_TYPE_HEADER =
'X-Vertex-AI-LLM-Shared-Request-Type';
/**
* Vertex AI Representative Endpoints (REP) for US and EU multi-regions.
* These are used as a workaround for the client dynamically
* constructing default legacy hostnames (e.g., 'us-aiplatform.googleapis.com')
* instead of routing to the official REP endpoints.
*/
const VERTEX_AI_US_REP_ENDPOINT = 'https://aiplatform.us.rep.googleapis.com';
const VERTEX_AI_EU_REP_ENDPOINT = 'https://aiplatform.eu.rep.googleapis.com';
function validateBaseUrl(baseUrl: string): void {
try {
new URL(baseUrl);
@@ -145,6 +154,13 @@ export async function createContentGeneratorConfig(
vertexAiRouting,
};
const getEnv = (key: string) => {
if (config?.env && config.env[key] !== undefined) {
return config.env[key];
}
return process.env[key];
};
// If we are using Google auth or we are in Cloud Shell, there is nothing else to validate for now.
// Return before touching the API-key keychain: on Linux without a Secret Service
// (WSL/SSH/Docker/CI) keytar can block indefinitely on its functional probe.
@@ -156,16 +172,13 @@ export async function createContentGeneratorConfig(
}
const geminiApiKey =
apiKey ||
process.env['GEMINI_API_KEY'] ||
(await loadApiKey()) ||
undefined;
const googleApiKey = process.env['GOOGLE_API_KEY'] || undefined;
apiKey || getEnv('GEMINI_API_KEY') || (await loadApiKey()) || undefined;
const googleApiKey = getEnv('GOOGLE_API_KEY') || undefined;
const googleCloudProject =
process.env['GOOGLE_CLOUD_PROJECT'] ||
process.env['GOOGLE_CLOUD_PROJECT_ID'] ||
getEnv('GOOGLE_CLOUD_PROJECT') ||
getEnv('GOOGLE_CLOUD_PROJECT_ID') ||
undefined;
const googleCloudLocation = process.env['GOOGLE_CLOUD_LOCATION'] || undefined;
const googleCloudLocation = getEnv('GOOGLE_CLOUD_LOCATION') || undefined;
if (authType === AuthType.USE_GEMINI && geminiApiKey) {
contentGeneratorConfig.apiKey = geminiApiKey;
@@ -185,8 +198,7 @@ export async function createContentGeneratorConfig(
}
if (authType === AuthType.GATEWAY) {
contentGeneratorConfig.apiKey =
apiKey || process.env['GEMINI_API_KEY'] || '';
contentGeneratorConfig.apiKey = apiKey || getEnv('GEMINI_API_KEY') || '';
contentGeneratorConfig.vertexai = false;
return contentGeneratorConfig;
@@ -341,6 +353,13 @@ export async function createContentGenerator(
if (envBaseUrl) {
validateBaseUrl(envBaseUrl);
baseUrl = envBaseUrl;
} else if (config.authType === AuthType.USE_VERTEX_AI) {
const location = process.env['GOOGLE_CLOUD_LOCATION'];
if (location === 'us') {
baseUrl = VERTEX_AI_US_REP_ENDPOINT;
} else if (location === 'eu') {
baseUrl = VERTEX_AI_EU_REP_ENDPOINT;
}
}
} else {
validateBaseUrl(baseUrl);
+315
View File
@@ -10,6 +10,7 @@ import {
ThinkingLevel,
type Content,
type GenerateContentResponse,
type Part,
} from '@google/genai';
import type { ContentGenerator } from '../core/contentGenerator.js';
import {
@@ -20,6 +21,8 @@ import {
type StreamEvent,
stripToolCallIdPrefixes,
type HistoryTurn,
coalesceConsecutiveRoles,
stripThoughts,
} from './geminiChat.js';
import {
type CompletedToolCall,
@@ -2253,6 +2256,139 @@ describe('GeminiChat', () => {
});
});
describe('thought leakage in getHistoryTurns', () => {
it('should completely filter out thought parts from getHistoryTurns when context management is enabled', () => {
vi.mocked(mockConfig.isContextManagementEnabled).mockReturnValue(true);
chat.setHistory([
{
role: 'user',
parts: [{ text: 'hello' }],
},
{
role: 'model',
parts: [
{ text: 'internal monologue', thought: true } as unknown as Part,
{ text: 'actual conversational response' },
],
},
]);
const turns = chat.getHistoryTurns(true);
expect(turns).toHaveLength(2);
const modelTurn = turns[1];
expect(modelTurn.content.parts).toHaveLength(1);
expect(modelTurn.content.parts![0]).toEqual({
text: 'actual conversational response',
});
});
it('should completely filter out thought parts from getHistoryTurns when context management is disabled but model is gemini-2/modern', () => {
vi.mocked(mockConfig.isContextManagementEnabled).mockReturnValue(false);
vi.mocked(mockConfig.getModel).mockReturnValue('gemini-2.5-pro');
chat.setHistory([
{
role: 'user',
parts: [{ text: 'hello' }],
},
{
role: 'model',
parts: [
{ text: 'internal monologue', thought: true } as unknown as Part,
{ text: 'actual conversational response' },
],
},
]);
const turns = chat.getHistoryTurns(true);
expect(turns).toHaveLength(2);
const modelTurn = turns[1];
expect(modelTurn.content.parts).toHaveLength(1);
expect(modelTurn.content.parts![0]).toEqual({
text: 'actual conversational response',
});
});
it('should completely filter out thought parts from getHistoryTurns when model supports modern features', () => {
vi.mocked(mockConfig.isContextManagementEnabled).mockReturnValue(false);
vi.mocked(mockConfig.getModel).mockReturnValue('gemini-3.1-pro-preview');
chat.setHistory([
{
role: 'user',
parts: [{ text: 'hello' }],
},
{
role: 'model',
parts: [
{ text: 'internal monologue', thought: true } as unknown as Part,
{ text: 'actual conversational response' },
],
},
]);
const turns = chat.getHistoryTurns(true);
expect(turns).toHaveLength(2);
const modelTurn = turns[1];
expect(modelTurn.content.parts).toHaveLength(1);
expect(modelTurn.content.parts![0]).toEqual({
text: 'actual conversational response',
});
});
it('should completely filter out model turns that end up with empty parts after stripping thoughts', () => {
vi.mocked(mockConfig.isContextManagementEnabled).mockReturnValue(false);
vi.mocked(mockConfig.getModel).mockReturnValue('gemini-2.5-pro');
chat.setHistory([
{
role: 'user',
parts: [{ text: 'hello' }],
},
{
role: 'model',
parts: [
{ text: 'internal monologue', thought: true } as unknown as Part,
],
},
]);
const turns = chat.getHistoryTurns(true);
// Since the model turn contains only a thought part, it should be filtered out entirely.
expect(turns).toHaveLength(1);
expect(turns[0].content.role).toBe('user');
});
it('should coalesce consecutive user turns when an intermediate model turn is stripped', () => {
vi.mocked(mockConfig.isContextManagementEnabled).mockReturnValue(false);
vi.mocked(mockConfig.getModel).mockReturnValue('gemini-2.5-pro');
chat.setHistory([
{ role: 'user', parts: [{ text: 'Question 1' }] },
{
role: 'model',
parts: [{ text: 'thinking...', thought: true } as unknown as Part],
},
{ role: 'user', parts: [{ text: 'Question 2' }] },
]);
const turns = chat.getHistoryTurns(true);
// The model turn contains only a thought part, so it is stripped.
// The two adjacent user turns must be coalesced into one user turn.
expect(turns).toHaveLength(1);
expect(turns[0].content.role).toBe('user');
expect(turns[0].content.parts).toHaveLength(2);
expect(turns[0].content.parts![0].text).toBe('Question 1');
expect(turns[0].content.parts![1].text).toBe('Question 2');
});
});
describe('ensureActiveLoopHasThoughtSignatures', () => {
it('should add thoughtSignature to the first functionCall in each model turn of the active loop', () => {
const chat = new GeminiChat(mockConfig, '', [], []);
@@ -2311,6 +2447,37 @@ describe('GeminiChat', () => {
expect(newContents[5]?.parts?.[1]).not.toHaveProperty('thoughtSignature');
});
it('should skip a user turn that has text alongside a functionResponse when locating the active loop', () => {
const chat = new GeminiChat(mockConfig, '', [], []);
// `coalesceConsecutiveRoles` can merge a function response turn with the
// prompt that follows it, producing a user turn holding both.
const history: Content[] = [
{ role: 'user', parts: [{ text: 'First prompt' }] },
{
role: 'model',
parts: [
{ text: 'Working on it' },
{ functionCall: { name: 'some_tool', args: {} } },
],
},
{
role: 'user',
parts: [
{ functionResponse: { name: 'some_tool', response: {} } },
{ text: 'Second prompt' },
],
},
];
const newContents = chat.ensureActiveLoopHasThoughtSignatures(history);
// The merged turn must not be taken as the loop start, otherwise the
// model turn before it is left unsigned while the API still validates it.
expect(newContents[1]?.parts?.[1]?.thoughtSignature).toBe(
SYNTHETIC_THOUGHT_SIGNATURE,
);
});
it('should not modify contents if there is no user text message', () => {
const chat = new GeminiChat(mockConfig, '', [], []);
const history: Content[] = [
@@ -3107,4 +3274,152 @@ describe('GeminiChat', () => {
expect(stripped[1].parts![0].functionResponse!.id).toBe('call_123');
});
});
describe('coalesceConsecutiveRoles', () => {
it('should return empty history if empty array is passed', () => {
expect(coalesceConsecutiveRoles([])).toEqual([]);
});
it('should not modify history when roles alternate correctly', () => {
const history: HistoryTurn[] = [
{ id: '1', content: { role: 'user', parts: [{ text: 'hello' }] } },
{ id: '2', content: { role: 'model', parts: [{ text: 'hi' }] } },
{
id: '3',
content: { role: 'user', parts: [{ text: 'how are you?' }] },
},
];
expect(coalesceConsecutiveRoles(history)).toEqual(history);
});
it('should coalesce consecutive user turns', () => {
const history: HistoryTurn[] = [
{ id: '1', content: { role: 'user', parts: [{ text: 'hello' }] } },
{ id: '2', content: { role: 'user', parts: [{ text: 'world' }] } },
];
expect(coalesceConsecutiveRoles(history)).toEqual([
{
id: '1',
content: {
role: 'user',
parts: [{ text: 'hello' }, { text: 'world' }],
},
},
]);
});
it('should handle undefined or missing parts gracefully', () => {
const history: HistoryTurn[] = [
{ id: '1', content: { role: 'user' } },
{ id: '2', content: { role: 'user', parts: [{ text: 'world' }] } },
];
expect(coalesceConsecutiveRoles(history)).toEqual([
{
id: '1',
content: {
role: 'user',
parts: [{ text: 'world' }],
},
},
]);
});
it('should not coalesce turns if roles are undefined', () => {
const history: HistoryTurn[] = [
{ id: '1', content: { parts: [{ text: 'hello' }] } },
{ id: '2', content: { parts: [{ text: 'world' }] } },
];
expect(coalesceConsecutiveRoles(history)).toEqual(history);
});
});
describe('stripThoughts', () => {
it('should return empty history if empty array is passed', () => {
expect(stripThoughts([])).toEqual([]);
});
it('should strip thought parts and keep the turn if other parts remain', () => {
const history: HistoryTurn[] = [
{
id: '1',
content: {
role: 'model',
parts: [
{ text: 'internal monologue', thought: true } as unknown as Part,
{ text: 'visible response' },
],
},
},
];
expect(stripThoughts(history)).toEqual([
{
id: '1',
content: {
role: 'model',
parts: [{ text: 'visible response' }],
},
},
]);
});
it('should completely remove a turn if all its parts are thought parts', () => {
const history: HistoryTurn[] = [
{
id: '1',
content: {
role: 'user',
parts: [{ text: 'hello' }],
},
},
{
id: '2',
content: {
role: 'model',
parts: [
{ text: 'internal monologue', thought: true } as unknown as Part,
],
},
},
];
expect(stripThoughts(history)).toEqual([
{
id: '1',
content: {
role: 'user',
parts: [{ text: 'hello' }],
},
},
]);
});
it('should preserve turns that do not have parts arrays', () => {
const history: HistoryTurn[] = [{ id: '1', content: { role: 'user' } }];
expect(stripThoughts(history)).toEqual(history);
});
it('should preserve top-level metadata when stripping thoughts', () => {
const history: HistoryTurn[] = [
{
id: '1',
content: {
role: 'model',
parts: [
{ text: 'internal monologue', thought: true } as unknown as Part,
{ text: 'visible response' },
],
},
// top-level turn metadata
timestamp: '2026-07-23T00:00:00.000Z',
metadata: { some: 'value' },
} as unknown as HistoryTurn,
];
const stripped = stripThoughts(history);
expect(stripped).toHaveLength(1);
expect(stripped[0]).toHaveProperty(
'timestamp',
'2026-07-23T00:00:00.000Z',
);
expect(stripped[0]).toHaveProperty('metadata', { some: 'value' });
});
});
});
+84 -13
View File
@@ -30,7 +30,11 @@ import {
getRetryErrorType,
} from '../utils/retry.js';
import type { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
import { resolveModel, supportsModernFeatures } from '../config/models.js';
import {
resolveModel,
supportsModernFeatures,
isGemini2Model,
} from '../config/models.js';
import { hasCycleInSchema } from '../tools/tools.js';
import type { StructuredError } from './turn.js';
import type { CompletedToolCall } from '../scheduler/types.js';
@@ -683,10 +687,13 @@ export class GeminiChat {
): Promise<AsyncGenerator<GenerateContentResponse>> {
// Last mile scrubbing to remove internal tracking properties (e.g. callIndex)
// before sending to the Gemini API. This whitelists only standard Gemini fields.
const scrubbedHistory = this.context.config.isContextManagementEnabled()
let scrubbedHistory = this.context.config.isContextManagementEnabled()
? scrubHistory([...requestHistory])
: [...requestHistory];
// Always coalesce consecutive roles to prevent 400 Bad Request errors
scrubbedHistory = coalesceConsecutiveRoles(scrubbedHistory);
const scrubbedContents = scrubbedHistory.map((h) => h.content);
const requestContents = apiHistoryOverride
@@ -763,9 +770,10 @@ export class GeminiChat {
abortSignal,
};
let contentsToUse: Content[] = supportsModernFeatures(modelToUse)
? [...contentsForPreviewModel]
: [...requestContents];
let contentsToUse: Content[] =
supportsModernFeatures(modelToUse) || isGemini2Model(modelToUse)
? [...contentsForPreviewModel]
: [...requestContents];
const hookSystem = this.context.config.getHookSystem();
if (hookSystem) {
@@ -807,9 +815,10 @@ export class GeminiChat {
);
lastModelToUse = modelToUse;
// Re-evaluate contentsToUse based on the new model's feature support
contentsToUse = supportsModernFeatures(modelToUse)
? [...contentsForPreviewModel]
: [...requestContents];
contentsToUse =
supportsModernFeatures(modelToUse) || isGemini2Model(modelToUse)
? [...contentsForPreviewModel]
: [...requestContents];
}
if (beforeModelResult.modifiedConfig) {
Object.assign(config, beforeModelResult.modifiedConfig);
@@ -953,9 +962,16 @@ export class GeminiChat {
? extractCuratedHistory(this.agentHistory.get())
: [...this.agentHistory.get()];
return this.context.config.isContextManagementEnabled()
? scrubHistory(history)
: history;
if (this.context.config.isContextManagementEnabled()) {
return scrubHistory(history);
}
const model = this.context.config.getModel();
if (isGemini2Model(model) || supportsModernFeatures(model)) {
return coalesceConsecutiveRoles(stripThoughts(history));
}
return history;
}
/**
@@ -1028,11 +1044,19 @@ export class GeminiChat {
requestContents: readonly Content[],
): readonly Content[] {
// First, find the start of the active loop by finding the last user turn
// with a text message, i.e. that is not a function response.
// with a text message, i.e. that is not a function response. Testing for
// text alone is not enough: `coalesceConsecutiveRoles` can merge a function
// response turn with the prompt that follows it, and starting the loop at
// such a turn starts it later than the API starts the turn, leaving earlier
// function calls unsigned but still validated.
let activeLoopStartIndex = -1;
for (let i = requestContents.length - 1; i >= 0; i--) {
const content = requestContents[i];
if (content.role === 'user' && content.parts?.some((part) => part.text)) {
if (
content.role === 'user' &&
content.parts?.some((part) => part.text) &&
!content.parts?.some((part) => part.functionResponse)
) {
activeLoopStartIndex = i;
break;
}
@@ -1472,3 +1496,50 @@ export function stripToolCallIdPrefixes(contents: Content[]): Content[] {
}),
}));
}
export function coalesceConsecutiveRoles(
history: HistoryTurn[],
): HistoryTurn[] {
const result: HistoryTurn[] = [];
for (const turn of history) {
const lastIdx = result.length - 1;
const last = result[lastIdx];
if (last && last.content.role && last.content.role === turn.content.role) {
const hasParts = last.content.parts || turn.content.parts;
result[lastIdx] = {
id: last.id,
content: {
...last.content,
parts: hasParts
? [...(last.content.parts || []), ...(turn.content.parts || [])]
: undefined,
},
};
} else {
result.push({
id: turn.id,
content: { ...turn.content },
});
}
}
return result;
}
export function stripThoughts(history: HistoryTurn[]): HistoryTurn[] {
return history
.map((turn) => {
if (!turn.content.parts) return turn;
const hasThought = turn.content.parts.some((p) => p && p.thought);
if (!hasThought) return turn;
const nonThoughtParts = turn.content.parts.filter((p) => p && !p.thought);
return {
...turn,
content: {
...turn.content,
parts: nonThoughtParts,
},
};
})
.filter((turn) => !turn.content.parts || turn.content.parts.length > 0);
}
@@ -67,6 +67,7 @@ const createMockConfig = (overrides: Partial<Config> = {}): Config =>
setActiveModel: vi.fn(),
setModel: vi.fn(),
activateFallbackMode: vi.fn(),
rotateSessionId: vi.fn(),
getModelAvailabilityService: vi.fn(() =>
createAvailabilityServiceMock({
selectedModel: FALLBACK_MODEL,
+4 -2
View File
@@ -5,6 +5,7 @@
*/
import type { Config } from '../config/config.js';
import { createSessionId } from '../utils/session.js';
import {
openBrowserSecurely,
shouldLaunchBrowser,
@@ -161,8 +162,9 @@ async function processIntent(
): Promise<boolean> {
switch (intent) {
case 'retry_always':
// TODO(telemetry): Implement generic fallback event logging. Existing
// logFlashFallback is specific to a single Model.
// Rotate the session ID to ensure the backend treats the retried request
// as a brand-new session, preventing stateful model-switching errors.
config.rotateSessionId(createSessionId());
config.activateFallbackMode(fallbackModel, failedModel);
return true;
+78 -11
View File
@@ -109,23 +109,90 @@ priority = 50
modes = ["plan"]
interactive = true
# Allow write_file and replace for .md files in the plans directory (cross-platform)
# We split this into two rules to avoid ReDoS checker issues with nested optional segments.
# This rule handles the case where there is a session ID in the plan file path
[[rule]]
toolName = ["write_file", "replace"]
decision = "allow"
priority = 70
modes = ["plan"]
argsPattern = "\\x00\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00"
# Allow write_file and replace for .md files in the plans directory (cross-platform).
# This rule employs split, traversal-safe, and ReDoS-safe patterns to provide defense-in-depth:
# - Absolute paths must strictly be inside the designated plans directory under `.gemini/tmp/`
# - Relative paths must be clean (no path traversal `..` and no absolute prefixes)
# This rule handles the case where there isn't a session ID in the plan file path
# 1. Absolute paths with session ID
[[rule]]
toolName = ["write_file", "replace"]
decision = "allow"
priority = 70
modes = ["plan"]
argsPattern = "\\x00\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00"
argsPattern = "\\x00\"file_path\":\"[^\\\"]+[\\\\/]+\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00"
# 2. Absolute paths without session ID
[[rule]]
toolName = ["write_file", "replace"]
decision = "allow"
priority = 70
modes = ["plan"]
argsPattern = "\\x00\"file_path\":\"[^\\\"]+[\\\\/]+\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00"
# 3. Relative paths starting with .gemini (with session ID)
[[rule]]
toolName = ["write_file", "replace"]
decision = "allow"
priority = 70
modes = ["plan"]
argsPattern = "\\x00\"file_path\":\"\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00"
# 4. Relative paths starting with .gemini (without session ID)
[[rule]]
toolName = ["write_file", "replace"]
decision = "allow"
priority = 70
modes = ["plan"]
argsPattern = "\\x00\"file_path\":\"\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00"
# 5. Relative paths starting with ./.gemini (with session ID)
[[rule]]
toolName = ["write_file", "replace"]
decision = "allow"
priority = 70
modes = ["plan"]
argsPattern = "\\x00\"file_path\":\"\\.[\\\\/]+\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00"
# 6. Relative paths starting with ./.gemini (without session ID)
[[rule]]
toolName = ["write_file", "replace"]
decision = "allow"
priority = 70
modes = ["plan"]
argsPattern = "\\x00\"file_path\":\"\\.[\\\\/]+\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00"
# 7. Clean relative filename (no directories, e.g. plan.md)
[[rule]]
toolName = ["write_file", "replace"]
decision = "allow"
priority = 70
modes = ["plan"]
argsPattern = "\\x00\"file_path\":\"[\\w-]+\\.md\"\\x00"
# 8. Clean relative path starting with ./ (no directories, e.g. ./plan.md)
[[rule]]
toolName = ["write_file", "replace"]
decision = "allow"
priority = 70
modes = ["plan"]
argsPattern = "\\x00\"file_path\":\"\\.[\\\\/]+[\\w-]+\\.md\"\\x00"
# 9. Clean relative path under plans directory (e.g. plans/plan.md)
[[rule]]
toolName = ["write_file", "replace"]
decision = "allow"
priority = 70
modes = ["plan"]
argsPattern = "\\x00\"file_path\":\"plans[\\\\/]+[\\w-]+\\.md\"\\x00"
# 10. Clean relative path under plans directory starting with ./ (e.g. ./plans/plan.md)
[[rule]]
toolName = ["write_file", "replace"]
decision = "allow"
priority = 70
modes = ["plan"]
argsPattern = "\\x00\"file_path\":\"\\.[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00"
# Explicitly Deny other write operations in Plan mode with a clear message.
[[rule]]
+68
View File
@@ -240,4 +240,72 @@ describe('AllowedPathChecker', () => {
const result = await checker.check(input);
expect(result.decision).toBe(SafetyCheckDecision.ALLOW);
});
describe('Security Regression: Case-Insensitive Blocklist & .vscode HITL', () => {
it('should deny sensitive paths like .git, .env, and node_modules case-insensitively, including Windows trailing character and NTFS ADS bypasses', async () => {
const sensitivePaths = [
path.join(mockCwd, '.git', 'config'),
path.join(mockCwd, '.GIT', 'config'),
path.join(mockCwd, '.Git', 'config'),
path.join(mockCwd, '.env'),
path.join(mockCwd, '.Env'),
path.join(mockCwd, '.ENV'),
path.join(mockCwd, 'node_modules', 'package', 'index.js'),
path.join(mockCwd, 'NODE_MODULES', 'package', 'index.js'),
// Windows trailing character bypasses
path.join(mockCwd, '.git ', 'config'),
path.join(mockCwd, '.git.', 'config'),
path.join(mockCwd, '.env ', 'config'),
path.join(mockCwd, '.env.', 'config'),
path.join(mockCwd, 'node_modules ', 'package', 'index.js'),
// NTFS Alternate Data Stream bypasses
path.join(mockCwd, '.git::$DATA', 'config'),
path.join(mockCwd, '.env::$DATA'),
path.join(mockCwd, 'node_modules::$DATA', 'package', 'index.js'),
];
for (const p of sensitivePaths) {
const input = createInput({ path: p });
const result = await checker.check(input);
expect(result.decision).toBe(SafetyCheckDecision.DENY);
expect(result.reason).toContain('Access to sensitive path');
}
});
it('should require ASK_USER for .vscode configuration files inside workspace, but deny them if outside, including NTFS ADS bypasses', async () => {
const vscodePaths = [
path.join(mockCwd, '.vscode', 'settings.json'),
path.join(mockCwd, '.vscode', 'settings.JSON'),
path.join(mockCwd, '.VSCODE', 'settings.json'),
path.join(mockCwd, '.vscode', 'launch.json'),
// Windows trailing character bypasses
path.join(mockCwd, '.vscode ', 'settings.json'),
path.join(mockCwd, '.vscode.', 'settings.json'),
// NTFS Alternate Data Stream bypasses
path.join(mockCwd, '.vscode::$DATA', 'settings.json'),
];
for (const p of vscodePaths) {
const input = createInput({ path: p });
const result = await checker.check(input);
expect(result.decision).toBe(SafetyCheckDecision.ASK_USER);
expect(result.reason).toContain(
'Modifying .vscode configuration files requires explicit user confirmation',
);
}
// Verify that paths outside the workspace containing .vscode are strictly denied
const outsideVscodePaths = [
path.join(testRootDir, 'outside', '.vscode', 'settings.json'),
path.join(testRootDir, 'outside', '.VSCODE', 'settings.json'),
];
for (const p of outsideVscodePaths) {
const input = createInput({ path: p });
const result = await checker.check(input);
expect(result.decision).toBe(SafetyCheckDecision.DENY);
expect(result.reason).toContain('outside of the allowed workspace');
}
});
});
});
+68 -13
View File
@@ -5,13 +5,13 @@
*/
import * as path from 'node:path';
import * as fs from 'node:fs';
import {
SafetyCheckDecision,
type SafetyCheckInput,
type SafetyCheckResult,
} from './protocol.js';
import type { AllowedPathConfig } from '../policy/types.js';
import { resolveToRealPath } from '../utils/paths.js';
/**
* Interface for all in-process safety checkers.
@@ -45,6 +45,11 @@ export class AllowedPathChecker implements InProcessChecker {
excludedArgs,
);
// Resolve allowed directories once outside the loop to avoid redundant filesystem calls
const resolvedAllowedDirs = allowedDirs
.map((dir) => this.safelyResolvePath(dir, context.environment.cwd))
.filter((resolvedDir): resolvedDir is string => resolvedDir !== null);
// Check each path
for (const { path: p, argName } of pathsToCheck) {
const resolvedPath = this.safelyResolvePath(p, context.environment.cwd);
@@ -57,15 +62,52 @@ export class AllowedPathChecker implements InProcessChecker {
};
}
const isAllowed = allowedDirs.some((dir) => {
// Also resolve allowed directories to handle symlinks
const resolvedDir = this.safelyResolvePath(
dir,
context.environment.cwd,
);
if (!resolvedDir) return false;
return this.isPathAllowed(resolvedPath, resolvedDir);
});
// Check for blocked segments case-insensitively
let hasBlockedSegment = false;
let isVscodePath = false;
for (const resolvedDir of resolvedAllowedDirs) {
if (!this.isPathAllowed(resolvedPath, resolvedDir)) continue;
const relative = path.relative(resolvedDir, resolvedPath);
const segments = relative.split(path.sep);
for (const segment of segments) {
const clean = trimTrailingSpacesAndDots(
segment.split(':')[0],
).toLowerCase();
if (
clean === '.git' ||
clean === '.env' ||
clean === 'node_modules'
) {
hasBlockedSegment = true;
}
if (clean === '.vscode') {
isVscodePath = true;
}
}
}
if (hasBlockedSegment) {
return {
decision: SafetyCheckDecision.DENY,
reason: `Access to sensitive path "${p}" in argument "${argName}" is blocked.`,
};
}
if (isVscodePath) {
return {
decision: SafetyCheckDecision.ASK_USER,
reason: `Modifying .vscode configuration files requires explicit user confirmation.`,
};
}
let isAllowed = false;
for (const resolvedDir of resolvedAllowedDirs) {
if (this.isPathAllowed(resolvedPath, resolvedDir)) {
isAllowed = true;
break;
}
}
if (!isAllowed) {
return {
@@ -84,14 +126,15 @@ export class AllowedPathChecker implements InProcessChecker {
// Walk up the directory tree until we find a path that exists
let current = resolved;
// Stop at root (dirname(root) === root on many systems, or it becomes empty/'.' depending on implementation)
while (current && current !== path.dirname(current)) {
if (fs.existsSync(current)) {
const canonical = fs.realpathSync(current);
try {
const canonical = resolveToRealPath(current);
// Re-construct the full path from this canonical base
const relative = path.relative(current, resolved);
// path.join handles empty relative paths correctly (returns canonical)
return path.join(canonical, relative);
} catch {
// Path does not exist, continue walking up
}
current = path.dirname(current);
}
@@ -156,3 +199,15 @@ export class AllowedPathChecker implements InProcessChecker {
return paths;
}
}
/**
* Trims trailing spaces and dots from a string without using regular expressions
* to completely eliminate any potential ReDoS (Regular Expression Denial of Service) risk.
*/
function trimTrailingSpacesAndDots(str: string): string {
let end = str.length - 1;
while (end >= 0 && (str[end] === ' ' || str[end] === '.')) {
end--;
}
return str.slice(0, end + 1);
}
@@ -34,6 +34,10 @@ describe('CheckerRunner', () => {
beforeEach(() => {
mockContextBuilder = new ContextBuilder({} as Config);
vi.spyOn(mockContextBuilder, 'config', 'get').mockReturnValue({
env: {},
getWorkingDir: vi.fn().mockReturnValue('/mock/cwd'),
} as unknown as Config);
mockRegistry = new CheckerRegistry('/mock/dist');
CheckerRegistry.prototype.resolveInProcess = vi.fn();
@@ -168,6 +168,8 @@ export class CheckerRunner {
return new Promise((resolve) => {
const child = spawn(checkerPath, [], {
stdio: ['pipe', 'pipe', 'pipe'],
cwd: this.contextBuilder.config.getWorkingDir(),
env: { ...process.env, ...this.contextBuilder.config.env },
});
let stdout = '';
@@ -15,6 +15,10 @@ import type { AgentLoopContext } from '../config/agent-loop-context.js';
export class ContextBuilder {
constructor(private readonly context: AgentLoopContext) {}
get config() {
return this.context.config;
}
/**
* Builds the full context object with all available data.
*/
+32 -18
View File
@@ -27,8 +27,15 @@ export class FileKeychain implements Keychain {
}
private encrypt(text: string): string {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-gcm', this.encryptionKey, iv);
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv(
'aes-256-gcm',
this.encryptionKey,
iv,
{
authTagLength: 16,
},
);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
@@ -48,10 +55,19 @@ export class FileKeychain implements Keychain {
const authTag = Buffer.from(parts[1], 'hex');
const encrypted = parts[2];
if (iv.length !== 12 && iv.length !== 16) {
throw new Error('Invalid IV length: Must be 12 or 16 bytes');
}
if (authTag.length !== 16) {
throw new Error('Invalid authentication tag length: Must be 16 bytes');
}
const decipher = crypto.createDecipheriv(
'aes-256-gcm',
this.encryptionKey,
iv,
{ authTagLength: 16 },
);
decipher.setAuthTag(authTag);
@@ -67,30 +83,28 @@ export class FileKeychain implements Keychain {
}
private async loadData(): Promise<Record<string, Record<string, string>>> {
let data: string;
try {
const data = await fs.readFile(this.tokenFilePath, 'utf-8');
const decrypted = this.decrypt(data);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return JSON.parse(decrypted) as Record<string, Record<string, string>>;
data = await fs.readFile(this.tokenFilePath, 'utf-8');
} catch (error: unknown) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const err = error as NodeJS.ErrnoException & { message?: string };
const err = error as NodeJS.ErrnoException;
if (err.code === 'ENOENT') {
return {};
}
if (
err.message?.includes('Invalid encrypted data format') ||
err.message?.includes(
'Unsupported state or unable to authenticate data',
)
) {
throw new Error(
`Corrupted credentials file detected at: ${this.tokenFilePath}\n` +
`Please delete or rename this file to resolve the issue.`,
);
}
throw error;
}
try {
const decrypted = this.decrypt(data);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return JSON.parse(decrypted) as Record<string, Record<string, string>>;
} catch {
throw new Error(
`Corrupted credentials file detected at: ${this.tokenFilePath}\n` +
`Please delete or rename this file to resolve the issue.`,
);
}
}
private async saveData(
@@ -0,0 +1,201 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { promises as fs } from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import * as crypto from 'node:crypto';
import { FileKeychain } from './fileKeychain.js';
describe('AES-GCM Tag Length Verification', () => {
let tempDir: string;
beforeEach(async () => {
// Create a unique temporary directory for test isolation
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-test-keychain-'));
vi.stubEnv('GEMINI_CLI_HOME', tempDir);
});
afterEach(async () => {
vi.unstubAllEnvs();
// Clean up the temporary directory
await fs.rm(tempDir, { recursive: true, force: true });
});
it('should use a secure 128-bit (16-byte) AES-GCM authentication tag and standard 12-byte IV', async () => {
const keychain = new FileKeychain();
const service = 'test-service';
const account = 'test-account';
const password = 'secure-password-123';
// 1. Save credentials to trigger encryption and file write
await keychain.setPassword(service, account, password);
// 2. Read the raw encrypted file from disk
const credentialsFilePath = path.join(
tempDir,
'.gemini',
'gemini-credentials.json',
);
const rawEncryptedData = await fs.readFile(credentialsFilePath, 'utf-8');
// 3. Parse the encrypted data format (iv:authTag:encrypted)
const parts = rawEncryptedData.split(':');
expect(parts).toHaveLength(3);
const ivHex = parts[0];
const authTagHex = parts[1];
// 4. Verify the lengths of the components
const ivBuffer = Buffer.from(ivHex, 'hex');
const authTagBuffer = Buffer.from(authTagHex, 'hex');
// IV should be exactly 12 bytes (96 bits) by default
expect(ivBuffer.length).toBe(12);
expect(ivHex.length).toBe(24);
// Authentication Tag should be exactly 16 bytes (128 bits)
expect(authTagBuffer.length).toBe(16);
expect(authTagHex.length).toBe(32); // 32 hex characters
// Assert that the tag is NOT truncated to 4 bytes (32 bits)
expect(authTagBuffer.length).not.toBe(4);
expect(authTagHex.length).not.toBe(8); // 8 hex characters
// 5. Verify that decryption works correctly with the 16-byte tag
const decryptedPassword = await keychain.getPassword(service, account);
expect(decryptedPassword).toBe(password);
});
it('should support both 12-byte and 16-byte IVs for backward compatibility', async () => {
const keychain = new FileKeychain();
const service = 'test-service';
const account = 'test-account';
const password = 'secure-password-123';
// 1. Save credentials to trigger encryption and file write (generates 12-byte IV)
await keychain.setPassword(service, account, password);
// 2. Verify 12-byte IV decryption works
let decryptedPassword = await keychain.getPassword(service, account);
expect(decryptedPassword).toBe(password);
// 3. Manually simulate a legacy 16-byte IV credentials file
const credentialsFilePath = path.join(
tempDir,
'.gemini',
'gemini-credentials.json',
);
const legacyIv = crypto.randomBytes(16);
const encryptionKey = (keychain as unknown as { encryptionKey: Buffer })
.encryptionKey;
const cipher = crypto.createCipheriv(
'aes-256-gcm',
encryptionKey,
legacyIv,
{
authTagLength: 16,
},
);
let encrypted = cipher.update(
JSON.stringify({ [service]: { [account]: password } }),
'utf8',
'hex',
);
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
const legacyPayload =
legacyIv.toString('hex') +
':' +
authTag.toString('hex') +
':' +
encrypted;
await fs.writeFile(credentialsFilePath, legacyPayload, 'utf-8');
// 4. Verify 16-byte IV decryption works successfully (backward compatibility)
decryptedPassword = await keychain.getPassword(service, account);
expect(decryptedPassword).toBe(password);
});
it('should reject decryption of a credentials file with a truncated tag', async () => {
const keychain = new FileKeychain();
const service = 'test-service';
const account = 'test-account';
const password = 'secure-password-123';
// 1. Save credentials to trigger encryption and file write
await keychain.setPassword(service, account, password);
// 2. Read the raw encrypted file from disk
const credentialsFilePath = path.join(
tempDir,
'.gemini',
'gemini-credentials.json',
);
const rawEncryptedData = await fs.readFile(credentialsFilePath, 'utf-8');
// 3. Parse the encrypted data format (iv:authTag:encrypted)
const parts = rawEncryptedData.split(':');
expect(parts).toHaveLength(3);
const ivHex = parts[0];
const authTagHex = parts[1];
const encryptedHex = parts[2];
// 4. Create a truncated 4-byte tag (8 hex characters)
const truncatedTagHex = authTagHex.substring(0, 8);
const truncatedEncryptedData = `${ivHex}:${truncatedTagHex}:${encryptedHex}`;
// 5. Overwrite the credentials file with the truncated-tag payload
await fs.writeFile(credentialsFilePath, truncatedEncryptedData, 'utf-8');
// 6. Attempt to retrieve the password and verify it throws a clear, handled validation error
await expect(keychain.getPassword(service, account)).rejects.toThrow(
'Corrupted credentials file detected',
);
});
it('should reject decryption of a credentials file with a truncated IV', async () => {
const keychain = new FileKeychain();
const service = 'test-service';
const account = 'test-account';
const password = 'secure-password-123';
// 1. Save credentials to trigger encryption and file write
await keychain.setPassword(service, account, password);
// 2. Read the raw encrypted file from disk
const credentialsFilePath = path.join(
tempDir,
'.gemini',
'gemini-credentials.json',
);
const rawEncryptedData = await fs.readFile(credentialsFilePath, 'utf-8');
// 3. Parse the encrypted data format (iv:authTag:encrypted)
const parts = rawEncryptedData.split(':');
expect(parts).toHaveLength(3);
const ivHex = parts[0];
const authTagHex = parts[1];
const encryptedHex = parts[2];
// 4. Create a truncated 4-byte IV (8 hex characters)
const truncatedIvHex = ivHex.substring(0, 8);
const truncatedEncryptedData = `${truncatedIvHex}:${authTagHex}:${encryptedHex}`;
// 5. Overwrite the credentials file with the truncated-IV payload
await fs.writeFile(credentialsFilePath, truncatedEncryptedData, 'utf-8');
// 6. Attempt to retrieve the password and verify it throws a clear, handled validation error
await expect(keychain.getPassword(service, account)).rejects.toThrow(
'Corrupted credentials file detected',
);
});
});
@@ -175,6 +175,93 @@ describe('LoopDetectionService', () => {
}
expect(loggers.logLoopDetected).not.toHaveBeenCalled();
});
it('should detect an alternating tool call loop (cycle of length 2)', () => {
const eventA = createToolCallRequestEvent('read_file', {
file_path: 'loop_a.txt',
});
const eventB = createToolCallRequestEvent('read_file', {
file_path: 'loop_b.txt',
});
let loopCount = 0;
for (let i = 0; i < 15; i++) {
const currentEvent = i % 2 === 0 ? eventA : eventB;
const result = service.addAndCheck(currentEvent);
if (result.count > 0) {
loopCount = result.count;
}
}
expect(loopCount).toBeGreaterThan(0);
});
it('should detect a cyclic tool call loop of length 3', () => {
const eventA = createToolCallRequestEvent('read_file', {
file_path: 'loop_a.txt',
});
const eventB = createToolCallRequestEvent('read_file', {
file_path: 'loop_b.txt',
});
const eventC = createToolCallRequestEvent('read_file', {
file_path: 'loop_c.txt',
});
let loopCount = 0;
const sequence = [eventA, eventB, eventC];
for (let i = 0; i < 20; i++) {
const result = service.addAndCheck(sequence[i % 3]);
if (result.count > 0) {
loopCount = result.count;
}
}
expect(loopCount).toBeGreaterThan(0);
});
it('should detect a cyclic tool call loop of length 5', () => {
const e1 = createToolCallRequestEvent('t', { id: 1 });
const e2 = createToolCallRequestEvent('t', { id: 2 });
const e3 = createToolCallRequestEvent('t', { id: 3 });
const e4 = createToolCallRequestEvent('t', { id: 4 });
const e5 = createToolCallRequestEvent('t', { id: 5 });
let loopCount = 0;
const sequence = [e1, e2, e3, e4, e5];
for (let i = 0; i < 30; i++) {
const result = service.addAndCheck(sequence[i % 5]);
if (result.count > 0) {
loopCount = result.count;
}
}
expect(loopCount).toBeGreaterThan(0);
});
it('should not detect loops for non-looping alternating sequences that vary', () => {
const eventA = createToolCallRequestEvent('read_file', {
file_path: 'loop_a.txt',
});
const eventB = createToolCallRequestEvent('read_file', {
file_path: 'loop_b.txt',
});
const eventC = createToolCallRequestEvent('read_file', {
file_path: 'loop_c.txt',
});
// Run some alternating calls, then break the pattern with eventC
for (let i = 0; i < 4; i++) {
expect(service.addAndCheck(i % 2 === 0 ? eventA : eventB).count).toBe(
0,
);
}
expect(service.addAndCheck(eventC).count).toBe(0);
for (let i = 0; i < 4; i++) {
expect(service.addAndCheck(i % 2 === 0 ? eventA : eventB).count).toBe(
0,
);
}
});
});
describe('Content Loop Detection', () => {
@@ -136,8 +136,7 @@ export class LoopDetectionService {
private userPrompt = '';
// Tool call tracking
private lastToolCallKey: string | null = null;
private toolCallRepetitionCount: number = 0;
private toolCallHistory: string[] = [];
// Content streaming tracking
private streamContentHistory = '';
@@ -313,15 +312,39 @@ export class LoopDetectionService {
private checkToolCallLoop(toolCall: { name: string; args: object }): boolean {
const key = this.getToolCallKey(toolCall);
if (this.lastToolCallKey === key) {
this.toolCallRepetitionCount++;
} else {
this.lastToolCallKey = key;
this.toolCallRepetitionCount = 1;
this.toolCallHistory.push(key);
const maxRequiredLength = 5 * TOOL_CALL_LOOP_THRESHOLD;
if (this.toolCallHistory.length > maxRequiredLength) {
this.toolCallHistory = this.toolCallHistory.slice(-maxRequiredLength);
}
if (this.toolCallRepetitionCount >= TOOL_CALL_LOOP_THRESHOLD) {
return true;
const n = this.toolCallHistory.length;
const R = TOOL_CALL_LOOP_THRESHOLD; // 5
// Check for repeating patterns of cycle length k from 1 to 5
for (let k = 1; k <= 5; k++) {
const requiredLength = k * R;
if (n >= requiredLength) {
const cycle = this.toolCallHistory.slice(-k);
let isPatternMatch = true;
for (let i = 0; i < requiredLength; i++) {
const indexFromEnd = requiredLength - i;
const actualKey = this.toolCallHistory[n - indexFromEnd];
const expectedKey = cycle[i % k];
if (actualKey !== expectedKey) {
isPatternMatch = false;
break;
}
}
if (isPatternMatch) {
return true;
}
}
}
return false;
}
@@ -739,8 +762,7 @@ export class LoopDetectionService {
}
private resetToolCallCount(): void {
this.lastToolCallKey = null;
this.toolCallRepetitionCount = 0;
this.toolCallHistory = [];
}
private resetContentTracking(resetHistory = true): void {
@@ -139,6 +139,7 @@ export interface ShellExecutionConfig {
backgroundCompletionBehavior?: 'inject' | 'notify' | 'silent';
originalCommand?: string;
sessionId?: string;
env?: Record<string, string>;
}
/**
@@ -461,9 +462,10 @@ export class ShellExecutionService {
const spawnArgs = [...argsPrefix, finalCommand];
// 2. Prepare Environment
const sourceEnv = shellExecutionConfig.env ?? process.env;
const gitConfigKeys: string[] = [];
if (!isInteractive) {
for (const key in process.env) {
for (const key in sourceEnv) {
if (key.startsWith('GIT_CONFIG_')) {
gitConfigKeys.push(key);
}
@@ -479,7 +481,7 @@ export class ShellExecutionService {
],
};
const sanitizedEnv = sanitizeEnvironment(process.env, sanitizationConfig);
const sanitizedEnv = sanitizeEnvironment(sourceEnv, sanitizationConfig);
const baseEnv: Record<string, string | undefined> = {
...sanitizedEnv,
@@ -493,7 +495,7 @@ export class ShellExecutionService {
if (!isInteractive) {
// Ensure all GIT_CONFIG_* variables are preserved even if they were redacted
for (const key of gitConfigKeys) {
baseEnv[key] = process.env[key];
baseEnv[key] = sourceEnv[key];
}
const gitConfigCount = parseInt(baseEnv['GIT_CONFIG_COUNT'] || '0', 10);
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import { vi } from 'vitest';
import type { WorkspaceContext } from '../utils/workspaceContext.js';
@@ -17,7 +18,17 @@ export function createMockWorkspaceContext(
rootDir: string,
additionalDirs: string[] = [],
): WorkspaceContext {
const allDirs = [rootDir, ...additionalDirs];
const resolveToRealPathSafe = (p: string) => {
try {
return fs.realpathSync(p);
} catch {
return p;
}
};
const resolvedRootDir = resolveToRealPathSafe(rootDir);
const resolvedAdditionalDirs = additionalDirs.map(resolveToRealPathSafe);
const allDirs = [resolvedRootDir, ...resolvedAdditionalDirs];
const mockWorkspaceContext = {
addDirectory: vi.fn(),
@@ -0,0 +1,684 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { ReadFileTool } from './read-file.js';
import { WriteFileTool, getCorrectedFileContent } from './write-file.js';
import { EditTool } from './edit.js';
import { correctPath } from '../utils/pathCorrector.js';
import path from 'node:path';
import os from 'node:os';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import type { Config } from '../config/config.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { StandardFileSystemService } from '../services/fileSystemService.js';
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
import { isSubpath } from '../utils/paths.js';
vi.mock('../telemetry/loggers.js', () => ({
logFileOperation: vi.fn(),
logEditStrategy: vi.fn(),
logEditCorrectionEvent: vi.fn(),
}));
vi.mock('./jit-context.js', () => ({
discoverJitContext: vi.fn().mockResolvedValue(''),
appendJitContext: vi.fn().mockImplementation((content) => content),
appendJitContextToParts: vi.fn().mockImplementation((content) => content),
}));
describe('Consolidated At-Reference Path Resolution Tests (b-495551283)', () => {
let tempRootDir: string;
let mockConfigInstance: Config;
const abortSignal = new AbortController().signal;
beforeEach(async () => {
// Create a unique temporary root directory for each test run
const realTmp = await fsp.realpath(os.tmpdir());
tempRootDir = await fsp.mkdtemp(
path.join(realTmp, 'at-ref-resolution-root-'),
);
mockConfigInstance = {
getFileService: () => new FileDiscoveryService(tempRootDir),
getFileSystemService: () => new StandardFileSystemService(),
getTargetDir: () => tempRootDir,
getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir),
getFileFilteringOptions: () => ({
respectGitIgnore: true,
respectGeminiIgnore: true,
}),
storage: {
getProjectTempDir: () => path.join(tempRootDir, '.temp'),
},
isInteractive: () => false,
isPlanMode: () => false,
getActiveModel: () => undefined,
getBaseLlmClient: () => undefined,
getDisableLLMCorrection: () => true,
isPathAllowed(this: Config, absolutePath: string): boolean {
const workspaceContext = this.getWorkspaceContext();
if (workspaceContext.isPathWithinWorkspace(absolutePath)) {
return true;
}
const projectTempDir = this.storage.getProjectTempDir();
return isSubpath(path.resolve(projectTempDir), absolutePath);
},
validatePathAccess(this: Config, absolutePath: string): string | null {
if (this.isPathAllowed(absolutePath)) {
return null;
}
const workspaceDirs = this.getWorkspaceContext().getDirectories();
const projectTempDir = this.storage.getProjectTempDir();
return `Path not in workspace: Attempted path "${absolutePath}" resolves outside the allowed workspace directories: ${workspaceDirs.join(', ')} or the project temp directory: ${projectTempDir}`;
},
} as unknown as Config;
// Create the policies directory and new-policies.txt file
await fsp.mkdir(path.join(tempRootDir, 'policies'), { recursive: true });
await fsp.writeFile(
path.join(tempRootDir, 'policies', 'new-policies.txt'),
'[[rule]]\ntoolName = "run_shell_command"\ndecision = "allow"\n',
'utf8',
);
});
afterEach(async () => {
// Clean up the temporary root directory
if (fs.existsSync(tempRootDir)) {
await fsp.rm(tempRootDir, { recursive: true, force: true });
}
});
it('ReadFileTool successfully reads a file when the path is prefixed with @', async () => {
const readFileTool = new ReadFileTool(
mockConfigInstance,
createMockMessageBus(),
);
const invocation = readFileTool.build({
file_path: '@policies/new-policies.txt',
});
const result = await invocation.execute({ abortSignal });
// The tool should succeed because it defensively strips the leading '@'
expect(result.error).toBeUndefined();
expect(result.llmContent).toContain('toolName = "run_shell_command"');
});
it('ReadFileTool successfully reads a file when the path is prefixed with @/', async () => {
const readFileTool = new ReadFileTool(
mockConfigInstance,
createMockMessageBus(),
);
const invocation = readFileTool.build({
file_path: '@/policies/new-policies.txt',
});
const result = await invocation.execute({ abortSignal });
// The tool should succeed because it defensively strips the leading '@/'
expect(result.error).toBeUndefined();
expect(result.llmContent).toContain('toolName = "run_shell_command"');
});
it('WriteFileTool successfully writes to/updates a file when the path is prefixed with @', async () => {
const writeFileTool = new WriteFileTool(
mockConfigInstance,
createMockMessageBus(),
);
const invocation = writeFileTool.build({
file_path: '@policies/new-policies.txt',
content: '[[rule]]\nupdated_content = true\n',
});
const result = await invocation.execute({ abortSignal });
// The tool should succeed and update the correct file
expect(result.error).toBeUndefined();
const incorrectFilePath = path.join(
tempRootDir,
'@policies',
'new-policies.txt',
);
const correctFilePath = path.join(
tempRootDir,
'policies',
'new-policies.txt',
);
// It should NOT have created a literal "@policies" directory
expect(fs.existsSync(incorrectFilePath)).toBe(false);
// It should have updated the correct file under "policies"
const updatedContent = await fsp.readFile(correctFilePath, 'utf8');
expect(updatedContent).toContain('updated_content = true');
});
it('WriteFileTool successfully creates a new file when the path is prefixed with @ and the parent directory exists', async () => {
const writeFileTool = new WriteFileTool(
mockConfigInstance,
createMockMessageBus(),
);
const invocation = writeFileTool.build({
file_path: '@policies/brand-new-file.txt',
content: '[[rule]]\nbrand_new_file = true\n',
});
const result = await invocation.execute({ abortSignal });
// The tool should succeed and create the correct file
expect(result.error).toBeUndefined();
const incorrectFilePath = path.join(
tempRootDir,
'@policies',
'brand-new-file.txt',
);
const correctFilePath = path.join(
tempRootDir,
'policies',
'brand-new-file.txt',
);
// It should NOT have created a literal "@policies" directory
expect(fs.existsSync(incorrectFilePath)).toBe(false);
// It should have created the correct file under "policies"
const createdContent = await fsp.readFile(correctFilePath, 'utf8');
expect(createdContent).toContain('brand_new_file = true');
});
it('WriteFileTool successfully creates a new file in a nested subdirectory when the path is prefixed with @ and the first segment exists', async () => {
const writeFileTool = new WriteFileTool(
mockConfigInstance,
createMockMessageBus(),
);
const invocation = writeFileTool.build({
file_path: '@policies/sub/brand-new-file.txt',
content: '[[rule]]\nnested_brand_new_file = true\n',
});
const result = await invocation.execute({ abortSignal });
// The tool should succeed and create the correct file
expect(result.error).toBeUndefined();
const incorrectFilePath = path.join(
tempRootDir,
'@policies',
'sub',
'brand-new-file.txt',
);
const correctFilePath = path.join(
tempRootDir,
'policies',
'sub',
'brand-new-file.txt',
);
// It should NOT have created a literal "@policies" directory
expect(fs.existsSync(incorrectFilePath)).toBe(false);
// It should have created the correct file under "policies/sub"
const createdContent = await fsp.readFile(correctFilePath, 'utf8');
expect(createdContent).toContain('nested_brand_new_file = true');
});
it('WriteFileTool successfully creates a new file in a nested subdirectory when the path is prefixed with @ and the first segment does NOT exist', async () => {
const writeFileTool = new WriteFileTool(
mockConfigInstance,
createMockMessageBus(),
);
const invocation = writeFileTool.build({
file_path: '@new-policies/sub/brand-new-file.txt',
content: '[[rule]]\nnested_brand_new_file = true\n',
});
const result = await invocation.execute({ abortSignal });
// The tool should succeed and create the correct file
expect(result.error).toBeUndefined();
const incorrectFilePath = path.join(
tempRootDir,
'@new-policies',
'sub',
'brand-new-file.txt',
);
const correctFilePath = path.join(
tempRootDir,
'new-policies',
'sub',
'brand-new-file.txt',
);
// It should NOT have created a literal "@new-policies" directory
expect(fs.existsSync(incorrectFilePath)).toBe(false);
// It SHOULD have created the file under "new-policies/sub"
expect(fs.existsSync(correctFilePath)).toBe(true);
// Verify the content of the created file
const createdContent = await fsp.readFile(correctFilePath, 'utf8');
expect(createdContent).toContain('nested_brand_new_file = true');
});
it('WriteFileTool successfully creates a new file in a nested subdirectory when the path is prefixed with @/ and the first segment does NOT exist', async () => {
const writeFileTool = new WriteFileTool(
mockConfigInstance,
createMockMessageBus(),
);
const invocation = writeFileTool.build({
file_path: '@/new-policies-alias/sub/brand-new-file.txt',
content: '[[rule]]\nnested_brand_new_file_alias = true\n',
});
const result = await invocation.execute({ abortSignal });
// The tool should succeed and create the correct file
expect(result.error).toBeUndefined();
const literalAtFilePath = path.join(
tempRootDir,
'@',
'new-policies-alias',
'sub',
'brand-new-file.txt',
);
const correctFilePath = path.join(
tempRootDir,
'new-policies-alias',
'sub',
'brand-new-file.txt',
);
// It should NOT have created a literal "@" directory
expect(fs.existsSync(literalAtFilePath)).toBe(false);
expect(fs.existsSync(path.join(tempRootDir, '@'))).toBe(false);
// It should have created the file under "new-policies-alias/sub"
expect(fs.existsSync(correctFilePath)).toBe(true);
// Verify the content of the created file
const createdContent = await fsp.readFile(correctFilePath, 'utf8');
expect(createdContent).toContain('nested_brand_new_file_alias = true');
});
it('WriteFileTool successfully creates a new file in a nested subdirectory when the path is prefixed with @\\ and the first segment does NOT exist', async () => {
const writeFileTool = new WriteFileTool(
mockConfigInstance,
createMockMessageBus(),
);
const invocation = writeFileTool.build({
file_path: '@\\new-policies-alias-win\\sub\\brand-new-file.txt',
content: '[[rule]]\nnested_brand_new_file_alias_win = true\n',
});
const result = await invocation.execute({ abortSignal });
// The tool should succeed and create the correct file
expect(result.error).toBeUndefined();
const isWindows = process.platform === 'win32';
const literalAtFilePath = isWindows
? path.join(
tempRootDir,
'@',
'new-policies-alias-win',
'sub',
'brand-new-file.txt',
)
: path.join(
tempRootDir,
'@\\new-policies-alias-win\\sub\\brand-new-file.txt',
);
const correctFilePath = isWindows
? path.join(
tempRootDir,
'new-policies-alias-win',
'sub',
'brand-new-file.txt',
)
: path.join(
tempRootDir,
'new-policies-alias-win\\sub\\brand-new-file.txt',
);
// It should NOT have created a literal "@" directory
expect(fs.existsSync(literalAtFilePath)).toBe(false);
expect(fs.existsSync(path.join(tempRootDir, '@'))).toBe(false);
// It should have created the file under "new-policies-alias-win/sub"
expect(fs.existsSync(correctFilePath)).toBe(true);
// Verify the content of the created file
const createdContent = await fsp.readFile(correctFilePath, 'utf8');
expect(createdContent).toContain('nested_brand_new_file_alias_win = true');
});
it('getCorrectedFileContent blocks path traversal outside the workspace', async () => {
const result = await getCorrectedFileContent(
mockConfigInstance,
'../../etc/passwd',
'malicious content',
abortSignal,
);
// The utility should fail with a path validation error
expect(result.error).toBeDefined();
expect(result.error?.message).toContain('Path not in workspace');
});
it('EditTool.getModifyContext blocks path traversal outside the workspace', async () => {
const editTool = new EditTool(mockConfigInstance, createMockMessageBus());
const modifyContext = editTool.getModifyContext(abortSignal);
// The getCurrentContent method should throw a path validation error
await expect(
modifyContext.getCurrentContent({
file_path: '../../etc/passwd',
instruction: 'read file',
old_string: '',
new_string: '',
}),
).rejects.toThrow('Path not in workspace');
// The getProposedContent method should throw a path validation error
await expect(
modifyContext.getProposedContent({
file_path: '../../etc/passwd',
instruction: 'read file',
old_string: '',
new_string: '',
}),
).rejects.toThrow('Path not in workspace');
});
it('getCorrectedFileContent handles symlink loops gracefully', async () => {
const symlinkPath1 = path.join(tempRootDir, 'symlink1');
const symlinkPath2 = path.join(tempRootDir, 'symlink2');
await fsp.symlink(symlinkPath2, symlinkPath1);
await fsp.symlink(symlinkPath1, symlinkPath2);
const result = await getCorrectedFileContent(
mockConfigInstance,
'symlink1',
'content',
abortSignal,
);
// The utility should fail gracefully with a resolution error
expect(result.error).toBeDefined();
expect(result.error?.message).toContain('Failed to resolve path');
});
it('EditTool.getModifyContext handles symlink loops gracefully by throwing a descriptive error', async () => {
const symlinkPath1 = path.join(tempRootDir, 'symlink1');
const symlinkPath2 = path.join(tempRootDir, 'symlink2');
await fsp.symlink(symlinkPath2, symlinkPath1);
await fsp.symlink(symlinkPath1, symlinkPath2);
const editTool = new EditTool(mockConfigInstance, createMockMessageBus());
const modifyContext = editTool.getModifyContext(abortSignal);
// The getCurrentContent method should throw a path resolution error
await expect(
modifyContext.getCurrentContent({
file_path: 'symlink1',
instruction: 'read file',
old_string: '',
new_string: '',
}),
).rejects.toThrow('Failed to resolve path');
// The getProposedContent method should throw a path resolution error
await expect(
modifyContext.getProposedContent({
file_path: 'symlink1',
instruction: 'read file',
old_string: '',
new_string: '',
}),
).rejects.toThrow('Failed to resolve path');
});
it('getCorrectedFileContent successfully resolves paths in Plan Mode', async () => {
const plansDir = path.join(tempRootDir, '.plans');
await fsp.mkdir(plansDir, { recursive: true });
await fsp.writeFile(
path.join(plansDir, 'plan-file.txt'),
'plan content',
'utf8',
);
const planConfigInstance = Object.assign({}, mockConfigInstance, {
isPlanMode: () => true,
getProjectRoot: () => tempRootDir,
storage: {
getProjectTempDir: () => path.join(tempRootDir, '.temp'),
getPlansDir: () => plansDir,
},
}) as unknown as Config;
const result = await getCorrectedFileContent(
planConfigInstance,
'plan-file.txt',
'new plan content',
abortSignal,
);
expect(result.error).toBeUndefined();
expect(result.originalContent).toBe('plan content');
});
it('EditTool successfully edits an existing file when the path is prefixed with @', async () => {
const editTool = new EditTool(mockConfigInstance, createMockMessageBus());
const invocation = editTool.build({
file_path: '@policies/new-policies.txt',
instruction: 'update decision rule',
old_string: 'decision = "allow"',
new_string: 'decision = "deny"',
});
const result = await invocation.execute({ abortSignal });
// The tool should succeed and update the correct file
expect(result.error).toBeUndefined();
const correctFilePath = path.join(
tempRootDir,
'policies',
'new-policies.txt',
);
const updatedContent = await fsp.readFile(correctFilePath, 'utf8');
expect(updatedContent).toContain('decision = "deny"');
});
it('EditTool successfully creates a new file when the path is prefixed with @ and the parent directory exists', async () => {
const editTool = new EditTool(mockConfigInstance, createMockMessageBus());
const invocation = editTool.build({
file_path: '@policies/brand-new-edit-file.txt',
instruction: 'create new file',
old_string: '',
new_string: '[[rule]]\nbrand_new_edit_file = true\n',
});
const result = await invocation.execute({ abortSignal });
// The tool should succeed and create the correct file
expect(result.error).toBeUndefined();
const incorrectFilePath = path.join(
tempRootDir,
'@policies',
'brand-new-edit-file.txt',
);
const correctFilePath = path.join(
tempRootDir,
'policies',
'brand-new-edit-file.txt',
);
// It should NOT have created a literal "@policies" directory
expect(fs.existsSync(incorrectFilePath)).toBe(false);
// It should have created the correct file under "policies"
const createdContent = await fsp.readFile(correctFilePath, 'utf8');
expect(createdContent).toContain('brand_new_edit_file = true');
});
it('EditTool successfully creates a new file in a nested subdirectory when the path is prefixed with @ and the first segment does NOT exist', async () => {
const editTool = new EditTool(mockConfigInstance, createMockMessageBus());
const invocation = editTool.build({
file_path: '@new-policies-edit/sub/brand-new-file.txt',
instruction: 'create new file in nested subdirectory',
old_string: '',
new_string: '[[rule]]\nnested_brand_new_edit_file = true\n',
});
const result = await invocation.execute({ abortSignal });
// The tool should succeed and create the correct file
expect(result.error).toBeUndefined();
const incorrectFilePath = path.join(
tempRootDir,
'@new-policies-edit',
'sub',
'brand-new-file.txt',
);
const correctFilePath = path.join(
tempRootDir,
'new-policies-edit',
'sub',
'brand-new-file.txt',
);
// It should NOT have created a literal "@new-policies-edit" directory
expect(fs.existsSync(incorrectFilePath)).toBe(false);
// It SHOULD have created the file under "new-policies-edit/sub"
expect(fs.existsSync(correctFilePath)).toBe(true);
// Verify the content of the created file
const createdContent = await fsp.readFile(correctFilePath, 'utf8');
expect(createdContent).toContain('nested_brand_new_edit_file = true');
});
it('EditTool successfully creates a new file in a nested subdirectory when the path is prefixed with @/ and the first segment does NOT exist', async () => {
const editTool = new EditTool(mockConfigInstance, createMockMessageBus());
const invocation = editTool.build({
file_path: '@/new-policies-edit-alias/sub/brand-new-file.txt',
instruction: 'create new file in nested subdirectory',
old_string: '',
new_string: '[[rule]]\nnested_brand_new_edit_file_alias = true\n',
});
const result = await invocation.execute({ abortSignal });
// The tool should succeed and create the correct file
expect(result.error).toBeUndefined();
const literalAtFilePath = path.join(
tempRootDir,
'@',
'new-policies-edit-alias',
'sub',
'brand-new-file.txt',
);
const correctFilePath = path.join(
tempRootDir,
'new-policies-edit-alias',
'sub',
'brand-new-file.txt',
);
// It should NOT have created a literal "@" directory
expect(fs.existsSync(literalAtFilePath)).toBe(false);
expect(fs.existsSync(path.join(tempRootDir, '@'))).toBe(false);
// It should have created the file under "new-policies-edit-alias/sub"
expect(fs.existsSync(correctFilePath)).toBe(true);
// Verify the content of the created file
const createdContent = await fsp.readFile(correctFilePath, 'utf8');
expect(createdContent).toContain('nested_brand_new_edit_file_alias = true');
});
it('EditTool successfully creates a new file in a nested subdirectory when the path is prefixed with @\\ and the first segment does NOT exist', async () => {
const editTool = new EditTool(mockConfigInstance, createMockMessageBus());
const invocation = editTool.build({
file_path: '@\\new-policies-edit-alias-win\\sub\\brand-new-file.txt',
instruction: 'create new file in nested subdirectory',
old_string: '',
new_string: '[[rule]]\nnested_brand_new_edit_file_alias_win = true\n',
});
const result = await invocation.execute({ abortSignal });
// The tool should succeed and create the correct file
expect(result.error).toBeUndefined();
const isWindows = process.platform === 'win32';
const literalAtFilePath = isWindows
? path.join(
tempRootDir,
'@',
'new-policies-edit-alias-win',
'sub',
'brand-new-file.txt',
)
: path.join(
tempRootDir,
'@\\new-policies-edit-alias-win\\sub\\brand-new-file.txt',
);
const correctFilePath = isWindows
? path.join(
tempRootDir,
'new-policies-edit-alias-win',
'sub',
'brand-new-file.txt',
)
: path.join(
tempRootDir,
'new-policies-edit-alias-win\\sub\\brand-new-file.txt',
);
// It should NOT have created a literal "@" directory
expect(fs.existsSync(literalAtFilePath)).toBe(false);
expect(fs.existsSync(path.join(tempRootDir, '@'))).toBe(false);
// It should have created the file under "new-policies-edit-alias-win/sub"
expect(fs.existsSync(correctFilePath)).toBe(true);
// Verify the content of the created file
const createdContent = await fsp.readFile(correctFilePath, 'utf8');
expect(createdContent).toContain(
'nested_brand_new_edit_file_alias_win = true',
);
});
it('correctPath successfully resolves a path prefixed with @ to its clean counterpart', () => {
const result = correctPath(
'@policies/new-policies.txt',
mockConfigInstance,
);
expect(result.success).toBe(true);
if (result.success) {
const expectedPath = path.join(
tempRootDir,
'policies',
'new-policies.txt',
);
expect(result.correctedPath).toBe(expectedPath);
}
});
});
+66 -1
View File
@@ -84,7 +84,10 @@ describe('EditTool', () => {
beforeEach(() => {
vi.restoreAllMocks();
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'edit-tool-test-'));
const rawTempDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'edit-tool-test-'),
);
tempDir = fs.realpathSync(rawTempDir);
rootDir = path.join(tempDir, 'root');
fs.mkdirSync(rootDir);
@@ -701,6 +704,30 @@ function doIt() {
};
expect(tool.validateToolParams(params)).toBeNull();
});
it('should sanitize null bytes in absolute path during validation', () => {
const badPath = path.resolve(rootDir, 'test\0.txt');
const params: EditToolParams = {
file_path: badPath,
instruction: 'An instruction',
old_string: 'old',
new_string: 'new',
};
expect(tool.validateToolParams(params)).toBeNull();
});
it('should sanitize null bytes in absolute path during invocation setup', () => {
const badPath = path.resolve(rootDir, 'test\0.txt');
const invocation = tool.build({
file_path: badPath,
instruction: 'test',
old_string: 'old',
new_string: 'new',
});
expect((invocation as any).resolvedPath).toBe(
path.resolve(rootDir, 'test.txt'),
);
});
});
describe('execute', () => {
@@ -1304,6 +1331,44 @@ function doIt() {
expect(mockFixLLMEditWithInstruction).toHaveBeenCalled();
});
it('should NOT call FixLLMEditWithInstruction for .json files even when disableLLMCorrection is false', async () => {
const filePath = path.join(rootDir, 'test.json');
fs.writeFileSync(filePath, '{"key": "value"}', 'utf8');
(mockConfig.getDisableLLMCorrection as Mock).mockReturnValue(false);
const params: EditToolParams = {
file_path: filePath,
instruction: 'Replace value',
old_string: 'nonexistent',
new_string: 'replacement',
};
const invocation = tool.build(params);
await invocation.execute({ abortSignal: new AbortController().signal });
expect(mockFixLLMEditWithInstruction).not.toHaveBeenCalled();
});
it('should NOT call FixLLMEditWithInstruction for .ipynb files even when disableLLMCorrection is false', async () => {
const filePath = path.join(rootDir, 'notebook.ipynb');
fs.writeFileSync(filePath, '{"cells": []}', 'utf8');
(mockConfig.getDisableLLMCorrection as Mock).mockReturnValue(false);
const params: EditToolParams = {
file_path: filePath,
instruction: 'Replace cell',
old_string: 'nonexistent',
new_string: 'replacement',
};
const invocation = tool.build(params);
await invocation.execute({ abortSignal: new AbortController().signal });
expect(mockFixLLMEditWithInstruction).not.toHaveBeenCalled();
});
});
describe('JIT context discovery', () => {
+120 -18
View File
@@ -27,7 +27,12 @@ import {
import { buildFilePathArgsPattern } from '../policy/utils.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { ToolErrorType } from './tool-error.js';
import { makeRelative, shortenPath } from '../utils/paths.js';
import {
makeRelative,
shortenPath,
resolveDefensiveToolPath,
resolveToRealPath,
} from '../utils/paths.js';
import { isNodeError } from '../utils/errors.js';
import { correctPath } from '../utils/pathCorrector.js';
import type { Config } from '../config/config.js';
@@ -478,11 +483,13 @@ class EditToolInvocation
);
if (this.config.isPlanMode()) {
try {
this.resolvedPath = resolveAndValidatePlanPath(
this.params.file_path,
const cleanFilePath = this.params.file_path.replace(/\0/g, '');
const planPath = resolveAndValidatePlanPath(
cleanFilePath,
this.config.storage.getPlansDir(),
this.config.getProjectRoot(),
);
this.resolvedPath = resolveToRealPath(planPath);
} catch (e) {
debugLogger.error(
'Failed to resolve plan path during EditTool invocation setup',
@@ -490,20 +497,39 @@ class EditToolInvocation
);
// Validation fails, set resolvedPath to something that will fail validation downstream or just the raw path.
// It's safer to store it so validation in execute() or getConfirmationDetails() catches it.
this.resolvedPath = this.params.file_path;
this.resolvedPath = this.params.file_path.replace(/\0/g, '');
}
} else if (!path.isAbsolute(this.params.file_path)) {
const result = correctPath(this.params.file_path, this.config);
if (result.success) {
this.resolvedPath = result.correctedPath;
try {
this.resolvedPath = resolveToRealPath(result.correctedPath);
} catch {
this.resolvedPath = result.correctedPath;
}
} else {
this.resolvedPath = path.resolve(
this.config.getTargetDir(),
const sanitizedPath = resolveDefensiveToolPath(
this.params.file_path,
this.config.getTargetDir(),
);
try {
this.resolvedPath = resolveToRealPath(
path.resolve(this.config.getTargetDir(), sanitizedPath),
);
} catch {
this.resolvedPath = path.resolve(
this.config.getTargetDir(),
sanitizedPath,
);
}
}
} else {
this.resolvedPath = this.params.file_path;
const cleanPath = this.params.file_path.replace(/\0/g, '');
try {
this.resolvedPath = resolveToRealPath(cleanPath);
} catch {
this.resolvedPath = cleanPath;
}
}
}
@@ -741,7 +767,12 @@ class EditToolInvocation
};
}
if (this.config.getDisableLLMCorrection()) {
const fileExt = path.extname(this.resolvedPath).toLowerCase();
const isJsonOrIpynb = ['.json', '.ipynb', '.jsonc', '.json5'].includes(
fileExt,
);
if (this.config.getDisableLLMCorrection() || isJsonOrIpynb) {
return {
currentContent,
newContent: currentContent,
@@ -1094,28 +1125,45 @@ export class EditTool
let resolvedPath: string;
if (this.config.isPlanMode()) {
try {
resolvedPath = resolveAndValidatePlanPath(
params.file_path,
const cleanFilePath = params.file_path.replace(/\0/g, '');
const planPath = resolveAndValidatePlanPath(
cleanFilePath,
this.config.storage.getPlansDir(),
this.config.getProjectRoot(),
);
resolvedPath = resolveToRealPath(planPath);
} catch (err) {
return err instanceof Error ? err.message : String(err);
}
} else if (!path.isAbsolute(params.file_path)) {
const result = correctPath(params.file_path, this.config);
if (result.success) {
resolvedPath = result.correctedPath;
try {
resolvedPath = resolveToRealPath(result.correctedPath);
} catch (err) {
return err instanceof Error ? err.message : String(err);
}
} else {
resolvedPath = path.resolve(
this.config.getTargetDir(),
const sanitizedPath = resolveDefensiveToolPath(
params.file_path,
this.config.getTargetDir(),
);
try {
resolvedPath = resolveToRealPath(
path.resolve(this.config.getTargetDir(), sanitizedPath),
);
} catch (err) {
return err instanceof Error ? err.message : String(err);
}
}
} else {
resolvedPath = params.file_path;
const cleanPath = params.file_path.replace(/\0/g, '');
try {
resolvedPath = resolveToRealPath(cleanPath);
} catch (err) {
return err instanceof Error ? err.message : String(err);
}
}
const newPlaceholders = detectOmissionPlaceholders(params.new_string);
if (newPlaceholders.length > 0) {
const oldPlaceholders = new Set(
@@ -1150,13 +1198,66 @@ export class EditTool
}
getModifyContext(_: AbortSignal): ModifyContext<EditToolParams> {
const resolvePath = (params: EditToolParams): string => {
let pathBeforeRealResolve: string;
try {
if (this.config.isPlanMode()) {
const cleanFilePath = params.file_path.replace(/\0/g, '');
pathBeforeRealResolve = resolveAndValidatePlanPath(
cleanFilePath,
this.config.storage.getPlansDir(),
this.config.getProjectRoot(),
);
} else if (!path.isAbsolute(params.file_path)) {
const result = correctPath(params.file_path, this.config);
if (result.success) {
pathBeforeRealResolve = result.correctedPath;
} else {
const sanitizedPath = resolveDefensiveToolPath(
params.file_path,
this.config.getTargetDir(),
);
pathBeforeRealResolve = path.resolve(
this.config.getTargetDir(),
sanitizedPath,
);
}
} else {
pathBeforeRealResolve = params.file_path.replace(/\0/g, '');
}
} catch (err) {
throw new Error(
'Failed to resolve path: ' +
(err instanceof Error ? err.message : String(err)),
);
}
let resolved: string;
try {
resolved = resolveToRealPath(pathBeforeRealResolve);
} catch (err) {
throw new Error(
'Failed to resolve path: ' +
(err instanceof Error ? err.message : String(err)),
);
}
const validationError = this.config.validatePathAccess(resolved);
if (validationError) {
throw new Error(validationError);
}
return resolved;
};
return {
getFilePath: (params: EditToolParams) => params.file_path,
getCurrentContent: async (params: EditToolParams): Promise<string> => {
try {
const resolvedPath = resolvePath(params);
return await this.config
.getFileSystemService()
.readTextFile(params.file_path);
.readTextFile(resolvedPath);
} catch (err) {
if (!isNodeError(err) || err.code !== 'ENOENT') throw err;
return '';
@@ -1164,9 +1265,10 @@ export class EditTool
},
getProposedContent: async (params: EditToolParams): Promise<string> => {
try {
const resolvedPath = resolvePath(params);
const currentContent = await this.config
.getFileSystemService()
.readTextFile(params.file_path);
.readTextFile(resolvedPath);
return applyReplacement(
currentContent,
params.old_string,
+4 -1
View File
@@ -37,7 +37,10 @@ describe('GlobTool', () => {
beforeEach(async () => {
// Create a unique root directory for each test run
tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'glob-tool-root-'));
const rawTempRootDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'glob-tool-root-'),
);
tempRootDir = await fs.realpath(rawTempRootDir);
await fs.writeFile(path.join(tempRootDir, '.git'), ''); // Fake git repo
const rootDir = tempRootDir;
+45 -12
View File
@@ -18,7 +18,11 @@ import {
type ToolConfirmationOutcome,
type ExecuteOptions,
} from './tools.js';
import { shortenPath, makeRelative } from '../utils/paths.js';
import {
shortenPath,
makeRelative,
resolveToRealPath,
} from '../utils/paths.js';
import { type Config } from '../config/config.js';
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js';
import { ToolErrorType } from './tool-error.js';
@@ -138,10 +142,22 @@ class GlobToolInvocation extends BaseToolInvocation<
// If a specific path is provided, resolve it and check if it's within workspace
let searchDirectories: readonly string[];
if (this.params.dir_path) {
const searchDirAbsolute = path.resolve(
this.config.getTargetDir(),
this.params.dir_path,
);
let searchDirAbsolute: string;
try {
searchDirAbsolute = resolveToRealPath(
path.resolve(this.config.getTargetDir(), this.params.dir_path),
);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
return {
llmContent: errMsg,
returnDisplay: 'Path resolution failed.',
error: {
message: errMsg,
type: ToolErrorType.PATH_NOT_IN_WORKSPACE,
},
};
}
const validationError = this.config.validatePathAccess(
searchDirAbsolute,
'read',
@@ -189,9 +205,22 @@ class GlobToolInvocation extends BaseToolInvocation<
allEntries.push(...entries);
}
const relativePaths = allEntries.map((p) =>
path.relative(this.config.getTargetDir(), p.fullpath()),
);
let realTargetDir = this.config.getTargetDir();
try {
realTargetDir = resolveToRealPath(realTargetDir);
} catch {
// Ignore and use raw targetDir
}
const relativePaths = allEntries.map((p) => {
let realFullPath = p.fullpath();
try {
realFullPath = resolveToRealPath(realFullPath);
} catch {
// Ignore and use raw fullpath
}
return path.relative(realTargetDir, realFullPath);
});
const { filteredPaths, ignoredCount } =
fileDiscovery.filterFilesWithReport(relativePaths, {
@@ -304,10 +333,14 @@ export class GlobTool extends BaseDeclarativeTool<GlobToolParams, ToolResult> {
protected override validateToolParamValues(
params: GlobToolParams,
): string | null {
const searchDirAbsolute = path.resolve(
this.config.getTargetDir(),
params.dir_path || '.',
);
let searchDirAbsolute: string;
try {
searchDirAbsolute = resolveToRealPath(
path.resolve(this.config.getTargetDir(), params.dir_path || '.'),
);
} catch (err) {
return err instanceof Error ? err.message : String(err);
}
const validationError = this.config.validatePathAccess(
searchDirAbsolute,
+2 -2
View File
@@ -8,7 +8,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { GrepTool, type GrepToolParams } from './grep.js';
import type { ToolResult, GrepResult, ExecuteOptions } from './tools.js';
import path from 'node:path';
import { isSubpath } from '../utils/paths.js';
import { isSubpath, resolveToRealPath } from '../utils/paths.js';
import fs from 'node:fs/promises';
import os from 'node:os';
import type { Config } from '../config/config.js';
@@ -156,7 +156,7 @@ describe('GrepTool', () => {
});
it('should return error if path is a file, not a directory', async () => {
const filePath = path.join(tempRootDir, 'fileA.txt');
const filePath = resolveToRealPath(path.join(tempRootDir, 'fileA.txt'));
const params: GrepToolParams = { pattern: 'hello', dir_path: filePath };
expect(grepTool.validateToolParams(params)).toContain(
`Path is not a directory: ${filePath}`,
+28 -6
View File
@@ -25,7 +25,11 @@ import {
type ToolConfirmationOutcome,
type ExecuteOptions,
} from './tools.js';
import { makeRelative, shortenPath } from '../utils/paths.js';
import {
makeRelative,
shortenPath,
resolveToRealPath,
} from '../utils/paths.js';
import { getErrorMessage, isNodeError } from '../utils/errors.js';
import { isGitRepository } from '../utils/gitUtils.js';
import type { Config } from '../config/config.js';
@@ -146,7 +150,21 @@ class GrepToolInvocation extends BaseToolInvocation<
let searchDirAbs: string | null = null;
if (pathParam) {
searchDirAbs = path.resolve(this.config.getTargetDir(), pathParam);
try {
searchDirAbs = resolveToRealPath(
path.resolve(this.config.getTargetDir(), pathParam),
);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
return {
llmContent: errMsg,
returnDisplay: 'Error: Path resolution failed.',
error: {
message: errMsg,
type: ToolErrorType.PATH_NOT_IN_WORKSPACE,
},
};
}
const validationError = this.config.validatePathAccess(
searchDirAbs,
'read',
@@ -722,10 +740,14 @@ export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
// Only validate dir_path if one is provided
if (params.dir_path) {
const resolvedPath = path.resolve(
this.config.getTargetDir(),
params.dir_path,
);
let resolvedPath: string;
try {
resolvedPath = resolveToRealPath(
path.resolve(this.config.getTargetDir(), params.dir_path),
);
} catch (err) {
return err instanceof Error ? err.message : String(err);
}
const validationError = this.config.validatePathAccess(
resolvedPath,
'read',
+29 -5
View File
@@ -6,7 +6,12 @@
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import path from 'node:path';
import { makeRelative, shortenPath } from '../utils/paths.js';
import {
makeRelative,
shortenPath,
resolveDefensiveToolPath,
resolveToRealPath,
} from '../utils/paths.js';
import {
BaseDeclarativeTool,
BaseToolInvocation,
@@ -74,10 +79,20 @@ class ReadFileToolInvocation extends BaseToolInvocation<
_toolDisplayName?: string,
) {
super(params, messageBus, _toolName, _toolDisplayName);
this.resolvedPath = path.resolve(
this.config.getTargetDir(),
const sanitizedPath = resolveDefensiveToolPath(
this.params.file_path,
this.config.getTargetDir(),
);
try {
this.resolvedPath = resolveToRealPath(
path.resolve(this.config.getTargetDir(), sanitizedPath),
);
} catch {
this.resolvedPath = path.resolve(
this.config.getTargetDir(),
sanitizedPath,
);
}
}
getDescription(): string {
@@ -242,11 +257,20 @@ export class ReadFileTool extends BaseDeclarativeTool<
return "The 'file_path' parameter must be non-empty.";
}
const resolvedPath = path.resolve(
this.config.getTargetDir(),
const sanitizedPath = resolveDefensiveToolPath(
params.file_path,
this.config.getTargetDir(),
);
let resolvedPath: string;
try {
resolvedPath = resolveToRealPath(
path.resolve(this.config.getTargetDir(), sanitizedPath),
);
} catch (err) {
return `Failed to resolve path: ${err instanceof Error ? err.message : String(err)}`;
}
const validationError = this.config.validatePathAccess(
resolvedPath,
'read',
@@ -398,7 +398,7 @@ describe('ReadManyFilesTool', () => {
});
it('should NOT use default excludes if useDefaultExcludes is false', async () => {
createFile('node_modules/some-lib/index.js', 'lib code');
createFile('dist/some-lib/index.js', 'lib code');
createFile('src/app.js', 'app code');
const params = { include: ['**/*.js'], useDefaultExcludes: false };
const invocation = tool.build(params);
@@ -406,10 +406,7 @@ describe('ReadManyFilesTool', () => {
abortSignal: new AbortController().signal,
});
const content = result.llmContent as string[];
const expectedPath1 = path.join(
tempRootDir,
'node_modules/some-lib/index.js',
);
const expectedPath1 = path.join(tempRootDir, 'dist/some-lib/index.js');
const expectedPath2 = path.join(tempRootDir, 'src/app.js');
expect(
content.some((c) =>
+7 -3
View File
@@ -46,7 +46,7 @@ vi.mock('../utils/paths.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../utils/paths.js')>();
return {
...actual,
resolveToRealPath: vi.fn((p) => p),
resolveToRealPath: vi.fn((p) => actual.resolveToRealPath(p)),
normalizePath: vi.fn((p) =>
typeof p === 'string' ? p.replace(/\\/g, '/') : p,
),
@@ -1351,7 +1351,9 @@ describe('RipGrepTool', () => {
});
it('should add .geminiignore when enabled and patterns exist', async () => {
const geminiIgnorePath = path.join(tempRootDir, GEMINI_IGNORE_FILE_NAME);
const geminiIgnorePath = resolveToRealPath(
path.join(tempRootDir, GEMINI_IGNORE_FILE_NAME),
);
await fs.writeFile(geminiIgnorePath, 'ignored.log');
const configWithGeminiIgnore = createMockConfig(tempRootDir);
@@ -1395,7 +1397,9 @@ describe('RipGrepTool', () => {
});
it('should skip .geminiignore when disabled', async () => {
const geminiIgnorePath = path.join(tempRootDir, GEMINI_IGNORE_FILE_NAME);
const geminiIgnorePath = resolveToRealPath(
path.join(tempRootDir, GEMINI_IGNORE_FILE_NAME),
);
await fs.writeFile(geminiIgnorePath, 'ignored.log');
const configWithoutGeminiIgnore = createMockConfig(tempRootDir);
vi.spyOn(
+31 -6
View File
@@ -185,7 +185,22 @@ class GrepToolInvocation extends BaseToolInvocation<
// This forces CWD search instead of 'all workspaces' search by default.
const pathParam = this.params.dir_path || '.';
const searchDirAbs = path.resolve(this.config.getTargetDir(), pathParam);
let searchDirAbs: string;
try {
searchDirAbs = resolveToRealPath(
path.resolve(this.config.getTargetDir(), pathParam),
);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
return {
llmContent: errMsg,
returnDisplay: 'Error: Path resolution failed.',
error: {
message: errMsg,
type: ToolErrorType.PATH_NOT_IN_WORKSPACE,
},
};
}
const validationError = this.config.validatePathAccess(
searchDirAbs,
'read',
@@ -624,8 +639,14 @@ export class RipGrepTool extends BaseDeclarativeTool<
true, // isOutputMarkdown
false, // canUpdateOutput
);
let targetDir = config.getTargetDir();
try {
targetDir = resolveToRealPath(targetDir);
} catch {
// Ignore and use raw targetDir
}
this.fileDiscoveryService = new FileDiscoveryService(
config.getTargetDir(),
targetDir,
config.getFileFilteringOptions(),
);
}
@@ -670,10 +691,14 @@ export class RipGrepTool extends BaseDeclarativeTool<
// Only validate path if one is provided
if (params.dir_path) {
const resolvedPath = path.resolve(
this.config.getTargetDir(),
params.dir_path,
);
let resolvedPath: string;
try {
resolvedPath = resolveToRealPath(
path.resolve(this.config.getTargetDir(), params.dir_path),
);
} catch (err) {
return err instanceof Error ? err.message : String(err);
}
const validationError = this.config.validatePathAccess(
resolvedPath,
'read',
+1
View File
@@ -656,6 +656,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
this.context.config.isInteractiveShellEnabled(),
{
...shellExecutionConfig,
env: this.context.config.env,
sessionId: this.context.config?.getSessionId?.() ?? 'default',
pager: 'cat',
sanitizationConfig:
+4 -2
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { Config } from '../config/config.js';
import { MessageBus } from '../confirmation-bus/message-bus.js';
import type { PolicyEngine } from '../policy/policy-engine.js';
@@ -30,8 +30,9 @@ describe('Tracker Tools Integration', () => {
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tracker-tools-test-'));
vi.stubEnv('GEMINI_CLI_HOME', tempDir);
config = new Config({
sessionId: 'test-session',
sessionId: `test-session-${Math.random().toString(36).substring(7)}`,
targetDir: tempDir,
cwd: tempDir,
model: 'gemini-3-flash',
@@ -42,6 +43,7 @@ describe('Tracker Tools Integration', () => {
});
afterEach(async () => {
vi.unstubAllEnvs();
await fs.rm(tempDir, { recursive: true, force: true });
});
+56 -16
View File
@@ -30,7 +30,7 @@ import type { Config } from '../config/config.js';
import { ApprovalMode } from '../policy/types.js';
import type { ToolRegistry } from './tool-registry.js';
import path from 'node:path';
import { isSubpath } from '../utils/paths.js';
import { isSubpath, resolveToRealPath } from '../utils/paths.js';
import fs from 'node:fs';
import os from 'node:os';
import { GeminiClient } from '../core/client.js';
@@ -44,8 +44,8 @@ import {
getMockMessageBusInstance,
} from '../test-utils/mock-message-bus.js';
const rootDir = path.resolve(os.tmpdir(), 'gemini-cli-test-root');
const plansDir = path.resolve(os.tmpdir(), 'gemini-cli-test-plans');
let rootDir: string;
let plansDir: string;
// --- MOCKS ---
vi.mock('../core/client.js');
@@ -85,7 +85,7 @@ const mockConfigInternal = {
getIdeMode: vi.fn(() => false),
getWorkspaceContext: () => new WorkspaceContext(rootDir, [plansDir]),
getApiKey: () => 'test-key',
getModel: () => 'test-model',
getModel: () => 'gemini-1.5-flash',
getSandbox: () => false,
getDebugMode: () => false,
getQuestion: () => undefined,
@@ -107,7 +107,7 @@ const mockConfigInternal = {
isInteractive: () => false,
getDisableLLMCorrection: vi.fn(() => true),
isPlanMode: vi.fn(() => false),
getActiveModel: () => 'test-model',
getActiveModel: () => 'gemini-1.5-flash',
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
},
@@ -134,16 +134,20 @@ describe('WriteFileTool', () => {
beforeEach(() => {
vi.clearAllMocks();
// Create a unique temporary directory for files created outside the root
tempDir = fs.mkdtempSync(
const rawTempDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'write-file-test-external-'),
);
// Ensure the rootDir and plansDir for the tool exists
if (!fs.existsSync(rootDir)) {
fs.mkdirSync(rootDir, { recursive: true });
}
if (!fs.existsSync(plansDir)) {
fs.mkdirSync(plansDir, { recursive: true });
}
tempDir = fs.realpathSync(rawTempDir);
const rawRootDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-root-'),
);
rootDir = fs.realpathSync(rawRootDir);
const rawPlansDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-plans-'),
);
plansDir = fs.realpathSync(rawPlansDir);
const workspaceContext = new WorkspaceContext(rootDir, [plansDir]);
const mockStorage = {
@@ -272,8 +276,9 @@ describe('WriteFileTool', () => {
file_path: dirAsFilePath,
content: 'hello',
};
const realDirAsFilePath = resolveToRealPath(dirAsFilePath);
expect(() => tool.build(params)).toThrow(
`Path is a directory, not a file: ${dirAsFilePath}`,
`Path is a directory, not a file: ${realDirAsFilePath}`,
);
});
@@ -423,6 +428,39 @@ describe('WriteFileTool', () => {
expect(result.error).toBeUndefined();
});
it('should not call ensureCorrectFileContent for .json files', async () => {
const filePath = path.join(rootDir, 'config.json');
const proposedContent = '{"key": "value\\nwith\\nescapes"}';
const abortSignal = new AbortController().signal;
const result = await getCorrectedFileContent(
mockConfig,
filePath,
proposedContent,
abortSignal,
);
expect(mockEnsureCorrectFileContent).not.toHaveBeenCalled();
expect(result.correctedContent).toBe(proposedContent);
});
it('should not call ensureCorrectFileContent for .ipynb files', async () => {
const filePath = path.join(rootDir, 'notebook.ipynb');
const proposedContent =
'{"cells": [{"source": ["print(\\"hello\\\\n\\")"]}]}';
const abortSignal = new AbortController().signal;
const result = await getCorrectedFileContent(
mockConfig,
filePath,
proposedContent,
abortSignal,
);
expect(mockEnsureCorrectFileContent).not.toHaveBeenCalled();
expect(result.correctedContent).toBe(proposedContent);
});
it('should return error if reading an existing file fails (e.g. permissions)', async () => {
const filePath = path.join(rootDir, 'unreadable_file.txt');
const proposedContent = 'some content';
@@ -441,7 +479,8 @@ describe('WriteFileTool', () => {
abortSignal,
);
expect(fsService.readTextFile).toHaveBeenCalledWith(filePath);
const realFilePath = resolveToRealPath(filePath);
expect(fsService.readTextFile).toHaveBeenCalledWith(realFilePath);
expect(mockEnsureCorrectFileContent).not.toHaveBeenCalled();
expect(result.correctedContent).toBe(proposedContent);
expect(result.originalContent).toBe('');
@@ -1014,8 +1053,9 @@ describe('WriteFileTool', () => {
expect(result.error?.type).toBe(errorType);
const errorSuffix = errorCode ? ` (${errorCode})` : '';
const realFilePath = resolveToRealPath(filePath);
const expectedMessage = errorCode
? `${expectedMessagePrefix}: ${filePath}${errorSuffix}`
? `${expectedMessagePrefix}: ${realFilePath}${errorSuffix}`
: `${expectedMessagePrefix}: ${errorMessage}`;
expect(result.llmContent).toContain(expectedMessage);
expect(result.returnDisplay).toContain(expectedMessage);
+123 -19
View File
@@ -28,7 +28,12 @@ import {
} from './tools.js';
import { buildFilePathArgsPattern } from '../policy/utils.js';
import { ToolErrorType } from './tool-error.js';
import { makeRelative, shortenPath } from '../utils/paths.js';
import {
makeRelative,
shortenPath,
resolveDefensiveToolPath,
resolveToRealPath,
} from '../utils/paths.js';
import { getErrorMessage, isNodeError } from '../utils/errors.js';
import { ensureCorrectFileContent } from '../utils/editCorrector.js';
import { detectLineEnding } from '../utils/textUtils.js';
@@ -50,7 +55,12 @@ import { WRITE_FILE_DEFINITION } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
import { detectOmissionPlaceholders } from './omissionPlaceholderDetector.js';
import { resolveAndValidatePlanPath } from '../utils/planUtils.js';
import { isGemini3Model } from '../config/models.js';
import {
isGemini3Model,
isGemini2Model,
isCustomModel,
resolveModel,
} from '../config/models.js';
import { discoverJitContext, appendJitContext } from './jit-context.js';
/**
@@ -109,10 +119,67 @@ export async function getCorrectedFileContent(
let fileExists = false;
let correctedContent = proposedContent;
let resolvedPath: string;
if (config.isPlanMode()) {
try {
const cleanFilePath = filePath.replace(/\0/g, '');
const planPath = resolveAndValidatePlanPath(
cleanFilePath,
config.storage.getPlansDir(),
config.getProjectRoot(),
);
resolvedPath = resolveToRealPath(planPath);
} catch (err) {
return {
originalContent: '',
correctedContent: proposedContent,
fileExists: false,
error: {
message:
'Failed to resolve plan path: ' +
(err instanceof Error ? err.message : String(err)),
code: 'EINVAL',
},
};
}
} else {
const sanitizedPath = resolveDefensiveToolPath(
filePath,
config.getTargetDir(),
);
try {
resolvedPath = resolveToRealPath(
path.resolve(config.getTargetDir(), sanitizedPath),
);
} catch (err) {
return {
originalContent: '',
correctedContent: proposedContent,
fileExists: false,
error: {
message:
'Failed to resolve path: ' +
(err instanceof Error ? err.message : String(err)),
code: 'EINVAL',
},
};
}
}
const validationError = config.validatePathAccess(resolvedPath);
if (validationError) {
return {
originalContent: '',
correctedContent: proposedContent,
fileExists: false,
error: { message: validationError, code: 'EACCES' },
};
}
try {
originalContent = await config
.getFileSystemService()
.readTextFile(filePath);
.readTextFile(resolvedPath);
fileExists = true; // File exists and was read
} catch (err) {
if (isNodeError(err) && err.code === 'ENOENT') {
@@ -131,16 +198,29 @@ export async function getCorrectedFileContent(
}
}
const aggressiveUnescape = !isGemini3Model(config.getActiveModel());
correctedContent = await ensureCorrectFileContent(
proposedContent,
config.getBaseLlmClient(),
abortSignal,
config.getDisableLLMCorrection(),
aggressiveUnescape,
const fileExt = path.extname(filePath).toLowerCase();
const isJsonOrIpynb = ['.json', '.ipynb', '.jsonc', '.json5'].includes(
fileExt,
);
if (!isJsonOrIpynb) {
const activeModel = config.getActiveModel();
const resolvedModel = resolveModel(activeModel, false, false, true, config);
const aggressiveUnescape =
!isGemini3Model(resolvedModel, config) &&
!isGemini2Model(resolvedModel) &&
!isCustomModel(resolvedModel, config);
correctedContent = await ensureCorrectFileContent(
proposedContent,
config.getBaseLlmClient(),
abortSignal,
config.getDisableLLMCorrection(),
aggressiveUnescape,
);
}
return { originalContent, correctedContent, fileExists };
}
@@ -170,24 +250,36 @@ class WriteFileToolInvocation extends BaseToolInvocation<
if (this.config.isPlanMode()) {
try {
this.resolvedPath = resolveAndValidatePlanPath(
this.params.file_path,
const cleanFilePath = this.params.file_path.replace(/\0/g, '');
const planPath = resolveAndValidatePlanPath(
cleanFilePath,
this.config.storage.getPlansDir(),
this.config.getProjectRoot(),
);
this.resolvedPath = resolveToRealPath(planPath);
} catch (e) {
debugLogger.error(
'Failed to resolve plan path during WriteFileTool invocation setup',
e,
);
// Validation fails, set resolvedPath to something that will fail validation downstream or just the raw path.
this.resolvedPath = this.params.file_path;
this.resolvedPath = this.params.file_path.replace(/\0/g, '');
}
} else {
this.resolvedPath = path.resolve(
this.config.getTargetDir(),
const sanitizedPath = resolveDefensiveToolPath(
this.params.file_path,
this.config.getTargetDir(),
);
try {
this.resolvedPath = resolveToRealPath(
path.resolve(this.config.getTargetDir(), sanitizedPath),
);
} catch {
this.resolvedPath = path.resolve(
this.config.getTargetDir(),
sanitizedPath,
);
}
}
}
@@ -525,16 +617,28 @@ export class WriteFileTool
let resolvedPath: string;
if (this.config.isPlanMode()) {
try {
resolvedPath = resolveAndValidatePlanPath(
filePath,
const cleanFilePath = filePath.replace(/\0/g, '');
const planPath = resolveAndValidatePlanPath(
cleanFilePath,
this.config.storage.getPlansDir(),
this.config.getProjectRoot(),
);
resolvedPath = resolveToRealPath(planPath);
} catch (err) {
return err instanceof Error ? err.message : String(err);
}
} else {
resolvedPath = path.resolve(this.config.getTargetDir(), filePath);
const sanitizedPath = resolveDefensiveToolPath(
filePath,
this.config.getTargetDir(),
);
try {
resolvedPath = resolveToRealPath(
path.resolve(this.config.getTargetDir(), sanitizedPath),
);
} catch (err) {
return err instanceof Error ? err.message : String(err);
}
}
const validationError = this.config.validatePathAccess(resolvedPath);
@@ -245,5 +245,20 @@ describe('editCorrector', () => {
expect(result).toBe(content);
expect(mockGenerateJson).not.toHaveBeenCalled();
});
it('should preserve \\n inside string literals even when aggressiveUnescape is false (b-496211054)', async () => {
const content =
'fmt.Printf("OpenFile with FailIfExists failed: %v\\n", err)';
const result = await ensureCorrectFileContent(
content,
mockBaseLlmClientInstance,
abortSignal,
true, // disableLLMCorrection
false, // aggressiveUnescape (now false for Gemini 2.5/3.x/Custom)
);
expect(result).toBe(content);
});
});
});
+4
View File
@@ -39,6 +39,10 @@ vi.mock('child_process', () => ({
spawnSync: vi.fn(() => ({ error: null, status: 0 })),
}));
vi.mock('./headless.js', () => ({
isHeadlessMode: vi.fn(() => false),
}));
const originalPlatform = process.platform;
describe('editor utils', () => {
+8
View File
@@ -9,6 +9,7 @@ import { promisify } from 'node:util';
import { once } from 'node:events';
import { debugLogger } from './debugLogger.js';
import { coreEvents, CoreEvent, type EditorSelectedPayload } from './events.js';
import { isHeadlessMode } from './headless.js';
const GUI_EDITORS = [
'vscode',
@@ -404,6 +405,13 @@ export async function openDiff(
newPath: string,
editor: EditorType,
): Promise<void> {
if (isHeadlessMode()) {
debugLogger.warn(
'External editor spawning is disabled in headless/server mode.',
);
return;
}
const diffCommand = getDiffCommand(oldPath, newPath, editor);
if (!diffCommand) {
debugLogger.error('No diff tool available. Install a supported editor.');
+1
View File
@@ -261,6 +261,7 @@ describe('fetch utils', () => {
it('should fall back to no_proxy if NO_PROXY is not set', () => {
const proxyUrl = 'http://proxy.example.com';
const noProxyValue = 'localhost,127.0.0.1';
vi.stubEnv('NO_PROXY', undefined);
vi.stubEnv('no_proxy', noProxyValue);
setGlobalProxy(proxyUrl);
@@ -8,10 +8,12 @@ import { describe, it, expect } from 'vitest';
import {
hardenHistory,
SYNTHETIC_THOUGHT_SIGNATURE,
scrubContents,
scrubHistory,
} from './historyHardening.js';
import type { HistoryTurn } from '../core/agentChatHistory.js';
import { deriveStableId } from './cryptoUtils.js';
import type { Part } from '@google/genai';
import type { Part, Content } from '@google/genai';
describe('hardenHistory', () => {
it('should return an empty array if input is empty', () => {
@@ -375,4 +377,205 @@ describe('hardenHistory', () => {
expect(hardened[0].content.parts![0]).not.toHaveProperty('extraProp');
expect(hardened[0].content.parts![0]).toHaveProperty('text', 'hello');
});
it('should completely filter out thought parts from the scrubbed history', () => {
const history: HistoryTurn[] = [
{
id: '1',
content: {
role: 'user',
parts: [{ text: 'User prompt' }],
},
},
{
id: '2',
content: {
role: 'model',
parts: [
{
text: 'Previous model thought...',
thought: true,
} as unknown as Part,
{ text: 'Actual conversational text response' },
],
},
},
{
id: '3',
content: {
role: 'user',
parts: [{ text: 'User follow-up prompt' }],
},
},
];
const hardened = hardenHistory(history);
// Model turn (Turn 2, index 1 in hardened) should only contain the actual conversational text part
const modelTurn = hardened[1];
expect(modelTurn.content.parts).toHaveLength(1);
expect(modelTurn.content.parts![0]).toHaveProperty(
'text',
'Actual conversational text response',
);
expect(modelTurn.content.parts![0]).not.toHaveProperty('thought');
});
it('should remove the entire turn if it only contained thought parts and is now empty', () => {
const history: HistoryTurn[] = [
{
id: '1',
content: {
role: 'user',
parts: [{ text: 'User prompt' }],
},
},
{
id: '2',
content: {
role: 'model',
parts: [
{
text: 'Model is just thinking internally...',
thought: true,
} as unknown as Part,
],
},
},
{
id: '3',
content: {
role: 'user',
parts: [{ text: 'User follow-up prompt' }],
},
},
];
const hardened = hardenHistory(history);
// After scrubbing, Turn 2 should have 0 parts.
// The history mapping filters out empty turns, so the total turns should coalesce and reduce to 1 coalesced user turn.
// Let's inspect the hardened array:
// User prompt (Turn 1) + User follow-up prompt (Turn 3) will be coalesced into 1 User turn.
expect(hardened).toHaveLength(1);
expect(hardened[0].content.role).toBe('user');
expect(hardened[0].content.parts).toEqual([
{ text: 'User prompt' },
{ text: 'User follow-up prompt' },
]);
});
});
describe('scrubContents', () => {
it('should scrub non-standard fields from parts', () => {
const contents: Content[] = [
{
role: 'user',
parts: [{ text: 'Hello', customField: 'ignored' } as unknown as Part],
},
];
const scrubbed = scrubContents(contents);
expect(scrubbed).toEqual([
{
role: 'user',
parts: [{ text: 'Hello' }],
},
]);
});
it('should filter out internal thought parts', () => {
const contents: Content[] = [
{
role: 'model',
parts: [
{ text: 'thought', thought: true } as unknown as Part,
{ text: 'response' },
],
},
];
const scrubbed = scrubContents(contents);
expect(scrubbed).toEqual([
{
role: 'model',
parts: [{ text: 'response' }],
},
]);
});
it('should completely filter out Content objects that have no parts left after thought scrubbing and coalesce adjacent turns of the same role', () => {
const contents: Content[] = [
{
role: 'user',
parts: [{ text: 'Hello' }],
},
{
role: 'model',
parts: [{ text: 'thought', thought: true } as unknown as Part],
},
{
role: 'user',
parts: [{ text: 'How are you?' }],
},
];
const scrubbed = scrubContents(contents);
expect(scrubbed).toEqual([
{
role: 'user',
parts: [{ text: 'Hello' }, { text: 'How are you?' }],
},
]);
});
it('should coalesce adjacent turns of the same role when no filtration occurs', () => {
const contents: Content[] = [
{
role: 'user',
parts: [{ text: 'Part 1' }],
},
{
role: 'user',
parts: [{ text: 'Part 2' }],
},
];
const scrubbed = scrubContents(contents);
expect(scrubbed).toEqual([
{
role: 'user',
parts: [{ text: 'Part 1' }, { text: 'Part 2' }],
},
]);
});
});
describe('scrubHistory', () => {
it('should scrub non-standard fields and filter empty turns in history', () => {
const history: HistoryTurn[] = [
{
id: '1',
content: {
role: 'user',
parts: [{ text: 'Hello', customField: 'ignored' } as unknown as Part],
},
},
{
id: '2',
content: {
role: 'model',
parts: [{ text: 'thought', thought: true } as unknown as Part],
},
},
{
id: '3',
content: {
role: 'user',
parts: [{ text: 'World' }],
},
},
];
const scrubbed = scrubHistory(history);
expect(scrubbed.length).toBe(1); // Since user turns are coalesced (Turn 1 + Turn 3) and Turn 2 is removed because it has 0 parts
expect(scrubbed[0].content.parts).toEqual([
{ text: 'Hello' },
{ text: 'World' },
]);
});
});
+101 -14
View File
@@ -44,8 +44,11 @@ export function hardenHistory(
const sentinels = { ...DEFAULT_SENTINELS, ...options.sentinels };
// Pass 0: Strip internal thoughts and remove empty turns
const processed = stripThoughts(history);
// Pass 1: Initial Coalesce & Empty Turn Removal
let coalesced = coalesce(history);
let coalesced = coalesce(processed);
// Pass 2: Tool Pairing & Signatures (The semantic layer)
coalesced = pairToolsAndEnforceSignatures(coalesced, sentinels);
@@ -62,6 +65,36 @@ export function hardenHistory(
return final;
}
/**
* Helper to check if a Part object represents an internal thought.
*/
function isInternalThought(part: Part): boolean {
return !!part && !!(part as ThoughtPart).thought;
}
/**
* Removes parts that represent thoughts (where part.thought === true).
* Empty turns resulting from thought removal are handled in subsequent coalescing passes.
*/
function stripThoughts(history: HistoryTurn[]): HistoryTurn[] {
return history.map((turn) => {
if (!turn.content.parts) return turn;
const hasThought = turn.content.parts.some(isInternalThought);
if (!hasThought) return turn;
const nonThoughtParts = turn.content.parts.filter(
(p) => p && !isInternalThought(p),
);
return {
id: turn.id,
content: {
...turn.content,
parts: nonThoughtParts,
},
};
});
}
/**
* Combines adjacent turns with the same role and removes empty turns.
*/
@@ -70,12 +103,16 @@ function coalesce(history: HistoryTurn[]): HistoryTurn[] {
for (const turn of history) {
if (!turn.content.parts || turn.content.parts.length === 0) continue;
const last = result[result.length - 1];
const lastIdx = result.length - 1;
const last = result[lastIdx];
if (last && last.content.role === turn.content.role) {
last.content.parts = [
...(last.content.parts || []),
...(turn.content.parts || []),
];
result[lastIdx] = {
id: last.id,
content: {
...last.content,
parts: [...(last.content.parts || []), ...(turn.content.parts || [])],
},
};
} else {
// Shallow clone the turn and content so we don't mutate the original history array structure
result.push({ id: turn.id, content: { ...turn.content } });
@@ -344,23 +381,73 @@ function enforceRoleConstraints(
* This ensures compatibility with strict APIs (like Vertex AI) that reject unknown fields.
*/
export function scrubHistory(history: HistoryTurn[]): HistoryTurn[] {
return history.map((turn) => ({
id: turn.id,
content: scrubContents([turn.content])[0],
}));
const result: HistoryTurn[] = [];
for (const turn of history) {
const nonThoughtParts = (turn.content.parts ?? []).filter(
(p) => p && !isInternalThought(p),
);
if (nonThoughtParts.length === 0) continue; // Skip turns that became empty
const scrubbedParts = nonThoughtParts.map((p) => scrubPart(p));
const lastIdx = result.length - 1;
const last = result[lastIdx];
if (last && last.content.role === turn.content.role) {
// Coalesce inline with strict immutability
result[lastIdx] = {
id: last.id,
content: {
...last.content,
parts: [...(last.content.parts || []), ...scrubbedParts],
},
};
} else {
result.push({
id: turn.id,
content: {
role: turn.content.role,
parts: scrubbedParts,
},
});
}
}
return result;
}
/**
* Deep-scrubs an array of Content objects to remove non-standard properties.
* Coalesces adjacent turns of the same role to preserve Gemini API alternation invariants.
*/
export function scrubContents(contents: Content[]): Content[] {
return contents.map((content) => ({
role: content.role,
parts: (content.parts || []).map((p) => scrubPart(p)),
}));
const result: Content[] = [];
for (const content of contents) {
const nonThoughtParts = (content.parts ?? []).filter(
(p) => p && !isInternalThought(p),
);
if (nonThoughtParts.length === 0) continue; // Skip turns that became empty after thought stripping
const scrubbedParts = nonThoughtParts.map((p) => scrubPart(p));
const lastIdx = result.length - 1;
const last = result[lastIdx];
if (last && last.role === content.role) {
// Coalesce adjacent turns of the same role inline
result[lastIdx] = {
role: last.role,
parts: [...(last.parts || []), ...scrubbedParts],
};
} else {
result.push({
role: content.role,
parts: scrubbedParts,
});
}
}
return result;
}
interface ThoughtPart extends Part {
thought?: boolean;
thoughtSignature?: string;
}
@@ -6,6 +6,8 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs/promises';
import * as fsSync from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { marked } from 'marked';
import { processImports, validateImportPath } from './memoryImportProcessor.js';
@@ -867,5 +869,46 @@ describe('memoryImportProcessor', () => {
);
expect(validateImportPath(dotPath, basePath, [allowedPath])).toBe(true);
});
it('should reject paths that escape allowed directories via symbolic links', () => {
const tmpDir = fsSync.realpathSync(os.tmpdir());
const testRoot = fsSync.mkdtempSync(path.join(tmpDir, 'gemini-test-'));
const allowedDir = path.join(testRoot, 'allowed');
const outsideDir = path.join(testRoot, 'outside');
const symlinkDir = path.join(allowedDir, 'sym_outside');
try {
// Create real directories and files on disk
fsSync.mkdirSync(allowedDir, { recursive: true });
fsSync.mkdirSync(outsideDir, { recursive: true });
fsSync.writeFileSync(path.join(outsideDir, 'sensitive.md'), 'secret');
// Create a symbolic link pointing outside the allowed directory
try {
fsSync.symlinkSync(outsideDir, symlinkDir, 'dir');
} catch (err: unknown) {
if (
process.platform === 'win32' &&
err &&
typeof err === 'object' &&
'code' in err &&
err.code === 'EPERM'
) {
// Skip the test if the user lacks symlink creation privileges on Windows
return;
}
throw err;
}
const importPath = 'sym_outside/sensitive.md';
expect(validateImportPath(importPath, allowedDir, [allowedDir])).toBe(
false,
);
} finally {
// Cleanup
fsSync.rmSync(testRoot, { recursive: true, force: true });
}
});
});
});
@@ -6,7 +6,7 @@
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { isSubpath } from './paths.js';
import { isSubpath, resolveToRealPath } from './paths.js';
import { debugLogger } from './debugLogger.js';
// Simple console logger for import processing
@@ -397,9 +397,28 @@ export function validateImportPath(
return false;
}
const resolvedPath = path.resolve(basePath, importPath);
let resolvedPath: string;
try {
// Canonicalize the path on the actual physical disk to resolve symlinks
resolvedPath = resolveToRealPath(path.resolve(basePath, importPath));
} catch {
// If path resolution fails (e.g., infinite recursion or invalid path), fail-closed and reject it
return false;
}
return allowedDirectories.some((allowedDir) =>
isSubpath(allowedDir, resolvedPath),
const realAllowedDirs = allowedDirectories
.map((dir) => {
const trimmed = dir.trim();
if (!trimmed) return null;
try {
return resolveToRealPath(trimmed);
} catch {
return null;
}
})
.filter((dir): dir is string => dir !== null);
return realAllowedDirs.some((realAllowedDir) =>
isSubpath(realAllowedDir, resolvedPath),
);
}
@@ -20,7 +20,10 @@ describe('pathCorrector', () => {
let mockConfig: Config;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-corrector-test-'));
const rawTempDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'path-corrector-test-'),
);
tempDir = fs.realpathSync(rawTempDir);
rootDir = path.join(tempDir, 'root');
otherWorkspaceDir = path.join(tempDir, 'other');
fs.mkdirSync(rootDir, { recursive: true });
+9 -3
View File
@@ -8,6 +8,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import type { Config } from '../config/config.js';
import { bfsFileSearchSync } from './bfsFileSearch.js';
import { resolveDefensiveToolPath } from './paths.js';
type SuccessfulPathCorrection = {
success: true;
@@ -34,8 +35,13 @@ export function correctPath(
filePath: string,
config: Config,
): PathCorrectionResult {
const sanitizedPath = resolveDefensiveToolPath(
filePath,
config.getTargetDir(),
);
// Check for direct path relative to the primary target directory.
const directPath = path.join(config.getTargetDir(), filePath);
const directPath = path.join(config.getTargetDir(), sanitizedPath);
if (fs.existsSync(directPath)) {
return { success: true, correctedPath: directPath };
}
@@ -43,8 +49,8 @@ export function correctPath(
// If not found directly, search across all workspace directories for ambiguous matches.
const workspaceContext = config.getWorkspaceContext();
const searchPaths = workspaceContext.getDirectories();
const basename = path.basename(filePath);
const normalizedTarget = filePath.replace(/\\/g, '/');
const basename = path.basename(sanitizedPath);
const normalizedTarget = sanitizedPath.replace(/\\/g, '/');
// Normalize path for matching and check if it ends with the provided relative path
const foundFiles = searchPaths

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