Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af1436cea1 | |||
| 1ef412fe34 | |||
| e6c4238594 | |||
| 913092aa8a |
@@ -1,89 +0,0 @@
|
||||
# --- STAGE 1: Base Runtime ---
|
||||
FROM docker.io/library/node:20-slim AS base
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
curl \
|
||||
dnsutils \
|
||||
less \
|
||||
jq \
|
||||
ca-certificates \
|
||||
git \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# --- STAGE 2: Builder (Compile Main) ---
|
||||
FROM base AS builder
|
||||
WORKDIR /build
|
||||
COPY . .
|
||||
RUN npm ci --ignore-scripts
|
||||
RUN npm run bundle
|
||||
# Run the official release preparation script to move the bundle and assets into packages/cli
|
||||
RUN node scripts/prepare-npm-release.js
|
||||
|
||||
# --- STAGE 3: Development Environment ---
|
||||
FROM base AS development
|
||||
|
||||
WORKDIR /home/node/dev/main
|
||||
|
||||
# Set up npm global package folder
|
||||
RUN mkdir -p /usr/local/share/npm-global \
|
||||
&& chown -R node:node /usr/local/share/npm-global
|
||||
ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global
|
||||
ENV PATH=$PATH:/usr/local/share/npm-global/bin
|
||||
|
||||
# Copy package.json to extract versions for global tools
|
||||
COPY package.json /tmp/package.json
|
||||
|
||||
# Install Build Tools, Global Dev Tools (pinned), and Linters
|
||||
ARG ACTIONLINT_VER=1.7.7
|
||||
ARG SHELLCHECK_VER=0.11.0
|
||||
ARG YAMLLINT_VER=1.35.1
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
make \
|
||||
g++ \
|
||||
gh \
|
||||
git \
|
||||
unzip \
|
||||
rsync \
|
||||
ripgrep \
|
||||
procps \
|
||||
psmisc \
|
||||
lsof \
|
||||
socat \
|
||||
tmux \
|
||||
docker.io \
|
||||
build-essential \
|
||||
libsecret-1-dev \
|
||||
libkrb5-dev \
|
||||
file \
|
||||
&& curl -sSLo /tmp/actionlint.tar.gz https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VER}/actionlint_${ACTIONLINT_VER}_linux_amd64.tar.gz \
|
||||
&& tar -xzf /tmp/actionlint.tar.gz -C /usr/local/bin actionlint \
|
||||
&& curl -sSLo /tmp/shellcheck.tar.xz https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VER}/shellcheck-v${SHELLCHECK_VER}.linux.x86_64.tar.xz \
|
||||
&& tar -xf /tmp/shellcheck.tar.xz -C /usr/local/bin --strip-components=1 shellcheck-v${SHELLCHECK_VER}/shellcheck \
|
||||
&& pip3 install --break-system-packages yamllint==${YAMLLINT_VER} \
|
||||
&& export TSX_VER=$(node -p "require('/tmp/package.json').devDependencies.tsx") \
|
||||
&& export VITEST_VER=$(node -p "require('/tmp/package.json').devDependencies.vitest") \
|
||||
&& export PRETTIER_VER=$(node -p "require('/tmp/package.json').devDependencies.prettier") \
|
||||
&& export ESLINT_VER=$(node -p "require('/tmp/package.json').devDependencies.eslint") \
|
||||
&& export CROSS_ENV_VER=$(node -p "require('/tmp/package.json').devDependencies['cross-env']") \
|
||||
&& npm install -g tsx@$TSX_VER vitest@$VITEST_VER prettier@$PRETTIER_VER eslint@$ESLINT_VER cross-env@$CROSS_ENV_VER typescript@5.3.3 \
|
||||
&& npm install -g @google/gemini-cli@nightly && mv /usr/local/share/npm-global/bin/gemini /usr/local/share/npm-global/bin/g-nightly \
|
||||
&& npm install -g @google/gemini-cli@preview && mv /usr/local/share/npm-global/bin/gemini /usr/local/share/npm-global/bin/g-preview \
|
||||
&& npm install -g @google/gemini-cli@latest && mv /usr/local/share/npm-global/bin/gemini /usr/local/share/npm-global/bin/g-stable \
|
||||
&& apt-get purge -y build-essential libsecret-1-dev libkrb5-dev \
|
||||
&& apt-get autoremove -y \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /root/.npm
|
||||
|
||||
# Copy the bundled CLI package to a permanent location and install it
|
||||
# We MUST not delete this source folder as 'npm install -g <folder>'
|
||||
# often symlinks to it for local folder installs.
|
||||
COPY --from=builder /build/packages/cli /usr/local/lib/gemini-cli
|
||||
RUN npm install -g /usr/local/lib/gemini-cli
|
||||
|
||||
USER node
|
||||
CMD ["/bin/bash"]
|
||||
@@ -1,10 +0,0 @@
|
||||
node_modules
|
||||
.git
|
||||
.gemini/workspaces
|
||||
dist
|
||||
!packages/*/dist/*.tgz
|
||||
bundle
|
||||
out
|
||||
*.log
|
||||
.env
|
||||
.DS_Store
|
||||
@@ -1,58 +0,0 @@
|
||||
substitutions:
|
||||
_IMAGE_NAME: 'development'
|
||||
_ARTIFACT_REGISTRY_REPO: 'us-docker.pkg.dev/gemini-code-dev/gemini-cli'
|
||||
|
||||
steps:
|
||||
# Step 1: Install root dependencies
|
||||
- name: 'us-west1-docker.pkg.dev/gemini-code-dev/gemini-code-containers/gemini-code-builder'
|
||||
id: 'Install Dependencies'
|
||||
entrypoint: 'npm'
|
||||
args: ['install']
|
||||
|
||||
# Step 2: Authenticate for Docker
|
||||
- name: 'us-west1-docker.pkg.dev/gemini-code-dev/gemini-code-containers/gemini-code-builder'
|
||||
id: 'Authenticate docker'
|
||||
entrypoint: 'npm'
|
||||
args: ['run', 'auth']
|
||||
|
||||
# Step 3: Build workspace packages
|
||||
- name: 'us-west1-docker.pkg.dev/gemini-code-dev/gemini-code-containers/gemini-code-builder'
|
||||
id: 'Build packages'
|
||||
entrypoint: 'npm'
|
||||
args: ['run', 'build:packages']
|
||||
|
||||
# Step 4: Build Development Image
|
||||
- name: 'us-west1-docker.pkg.dev/gemini-code-dev/gemini-code-containers/gemini-code-builder'
|
||||
id: 'Build Development Image'
|
||||
entrypoint: 'bash'
|
||||
env:
|
||||
- 'RAW_BRANCH_VALUE=${BRANCH_NAME}'
|
||||
args:
|
||||
- '-c'
|
||||
- |-
|
||||
IMAGE_BASE="${_ARTIFACT_REGISTRY_REPO}/${_IMAGE_NAME}"
|
||||
|
||||
# Determine the primary tag (branch name or 'latest' for main)
|
||||
# Use $$ for shell variables to avoid Cloud Build attempting premature substitution
|
||||
RAW_BRANCH="$$RAW_BRANCH_VALUE"
|
||||
if [ "$${RAW_BRANCH}" == "main" ]; then
|
||||
TAG_PRIMARY="latest"
|
||||
else
|
||||
TAG_PRIMARY=$$(echo "$${RAW_BRANCH}" | sed 's/[^a-zA-Z0-9]/-/g' | tr '[:upper:]' '[:lower:]')
|
||||
fi
|
||||
|
||||
# Use SHORT_SHA if available (Cloud Build) or fallback to latest-dev
|
||||
TAG_SHA="$${SHORT_SHA:-latest-dev}"
|
||||
|
||||
echo "📦 Building Development Image for: $${RAW_BRANCH} -> $${TAG_PRIMARY} ($${TAG_SHA})"
|
||||
|
||||
docker build -f .gcp/Dockerfile.development \
|
||||
-t "$${IMAGE_BASE}:$${TAG_SHA}" \
|
||||
-t "$${IMAGE_BASE}:$${TAG_PRIMARY}" .
|
||||
|
||||
docker push "$${IMAGE_BASE}:$${TAG_SHA}"
|
||||
docker push "$${IMAGE_BASE}:$${TAG_PRIMARY}"
|
||||
|
||||
options:
|
||||
defaultLogsBucketBehavior: 'REGIONAL_USER_OWNED_BUCKET'
|
||||
dynamicSubstitutions: true
|
||||
@@ -1,69 +0,0 @@
|
||||
---
|
||||
name: review-duplication
|
||||
description: Use this skill during code reviews to proactively investigate the codebase for duplicated functionality, reinvented wheels, or failure to reuse existing project best practices and shared utilities.
|
||||
---
|
||||
|
||||
# Review Duplication
|
||||
|
||||
## Overview
|
||||
|
||||
This skill provides a structured workflow for investigating a codebase during a code review to identify duplicated logic, reinvented utilities, and missed opportunities to reuse established patterns. By executing this workflow, you ensure that new code integrates seamlessly with the existing project architecture.
|
||||
|
||||
## Workflow: Investigating for Duplication
|
||||
|
||||
When reviewing code, perform the following steps before finalizing your review:
|
||||
|
||||
### 1. Extract Core Logic
|
||||
Analyze the new code to identify the core algorithms, utility functions, generic data structures, or UI components being introduced. Look beyond the specific business logic to see the underlying mechanics.
|
||||
|
||||
### 2. Hypothesize Existing Locations & Trace Dependencies
|
||||
Think about where this type of code *would* live if it already existed in the project. Provide absolute paths from the repo root to disambiguate.
|
||||
- **Utilities:** `packages/core/src/utils/`, `packages/cli/src/utils/`
|
||||
- **UI Components:** `packages/cli/src/ui/components/`, `packages/cli/src/ui/`
|
||||
- **Services:** `packages/core/src/services/`, `packages/cli/src/services/`
|
||||
- **Configuration:** `packages/core/src/config/`, `packages/cli/src/config/`
|
||||
- **Core Logic:** Call out `packages/core/` if functionality does not appear React UI specific.
|
||||
|
||||
**Trace Third-Party Dependencies:** If the PR introduces a new import for a utility library (e.g., `lodash.merge`, `date-fns`), trace how and where the project currently uses that library. There is likely an existing wrapper or shared utility.
|
||||
|
||||
**Check Package Files:** Before flagging a custom implementation of a complex algorithm, check `package.json` to see if a standard library (like `lodash` or `uuid`) is already installed that provides this functionality.
|
||||
|
||||
### 3. Investigate the Codebase (Sub-Agent Delegation)
|
||||
Delegate the heavy lifting of codebase investigation to specialized sub-agents. They are optimized to perform deep searches and semantic mapping without bloating your session history.
|
||||
|
||||
To ensure a comprehensive review, you MUST formulate highly specific objectives for the sub-agents, providing them with the "scents" you discovered in Step 1.
|
||||
|
||||
- **Codebase Investigator:** Use the `codebase_investigator` as your primary researcher. When delegating, formulate an objective that asks specific, investigative questions about the codebase, explicitly including these search vectors:
|
||||
- **Structural Similarity:** Ask if existing code uses the same underlying APIs (e.g., "Does any existing code use `Intl.DateTimeFormat` or `setTimeout` for similar purposes?").
|
||||
- **Naming Conventions:** Ask if there are existing symbols with similar naming patterns (e.g., "Are there existing symbols with naming patterns like `*Format*` or `*Debounce*`?").
|
||||
- **Comments & Documentation:** Ask if keywords from the PR's comments or JSDoc exist in describing similar behavior elsewhere.
|
||||
- **Architectural Fit:** Ask where this type of logic is currently centralized (e.g., "Where is centralized date formatting logic located?").
|
||||
- **Refactoring Guidance:** Crucially, ask the sub-agent to explain *how* the new code could be refactored to use any existing logic it finds.
|
||||
- **Generalist Agent:** Use the `generalist` for detailed, turn-intensive comparisons. For example: "Review the implementation of `MyNewComponent` in the PR and compare it semantically against all components in `packages/ui/src`. Are there any existing components that could be extended or used instead?"
|
||||
- **Retain Fast Path for Simple Searches:** For extremely simple, unambiguous checks (e.g., "Does `package.json` include `lodash`?"), perform a direct search to save time. Default to delegation for any open-ended "investigations."
|
||||
|
||||
### 4. Evaluate Best Practices
|
||||
Check if the new code aligns with the project's established conventions.
|
||||
- **Error Handling:** Does it use the project's standard error classes or logging mechanisms?
|
||||
- **State Management:** Does it bypass established stores or contexts?
|
||||
- **Styling:** Does it hardcode colors or spacing instead of using theme variables?
|
||||
If the PR introduces a new pattern, compare it against the documented standards and explicitly confirm if an existing project pattern should have been used instead.
|
||||
|
||||
### 5. Formulate Constructive Feedback
|
||||
If you discover that the PR duplicates existing functionality or ignores a best practice:
|
||||
- Provide a clear review comment.
|
||||
- **Identify the Source:** Explicitly mention the absolute or project-relative file path and the specific symbol (function, component, class) that should be reused.
|
||||
- **Implementation Guidance:** Provide a brief code snippet or a clear explanation showing **how** to integrate the existing code to fulfill the task's requirements.
|
||||
- **Explain the Value:** Briefly explain why reusing the existing code is beneficial (e.g., maintainability, consistency, built-in edge case handling).
|
||||
|
||||
Example comment:
|
||||
> "It looks like this PR introduces a new `formatDate` utility. We already have a robust, tested `formatDate` function in `src/utils/dateHelpers.ts`.
|
||||
>
|
||||
> You can replace your implementation by importing it like this:
|
||||
> ```typescript
|
||||
> import { formatDate } from '../utils/dateHelpers';
|
||||
>
|
||||
> // Then use it here:
|
||||
> const displayDate = formatDate(userDate, 'MMM Do, YYYY');
|
||||
> ```
|
||||
> Reusing this ensures that the date formatting remains consistent with the rest of the application and handles timezone conversions correctly."
|
||||
@@ -22,3 +22,6 @@ Makefile eol=lf
|
||||
*.eot binary
|
||||
*.ttf binary
|
||||
*.otf binary
|
||||
|
||||
# Configure GitHub to treat Markdoc files as Markdown
|
||||
*.mdoc linguist-language=Markdown
|
||||
|
||||
@@ -334,20 +334,8 @@ jobs:
|
||||
if: "${{ steps.check_evals.outputs.should_run == 'true' }}"
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GEMINI_MODEL: 'gemini-3-pro-preview'
|
||||
# Disable Vitest internal retries to avoid double-retrying;
|
||||
# custom retry logic is handled in evals/test-helper.ts
|
||||
VITEST_RETRY: 0
|
||||
run: 'npm run test:always_passing_evals'
|
||||
|
||||
- name: 'Upload Reliability Logs'
|
||||
if: "always() && steps.check_evals.outputs.should_run == 'true'"
|
||||
uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4
|
||||
with:
|
||||
name: 'eval-logs-${{ github.run_id }}-${{ github.run_attempt }}'
|
||||
path: 'evals/logs/api-reliability.jsonl'
|
||||
retention-days: 7
|
||||
|
||||
e2e:
|
||||
name: 'E2E'
|
||||
if: |
|
||||
|
||||
@@ -61,8 +61,6 @@ jobs:
|
||||
GEMINI_MODEL: '${{ matrix.model }}'
|
||||
RUN_EVALS: "${{ github.event.inputs.run_all != 'false' }}"
|
||||
TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}'
|
||||
# Disable Vitest internal retries to avoid double-retrying;
|
||||
# custom retry logic is handled in evals/test-helper.ts
|
||||
VITEST_RETRY: 0
|
||||
run: |
|
||||
CMD="npm run test:all_evals"
|
||||
|
||||
@@ -30,7 +30,7 @@ Learn all about Gemini CLI in our [documentation](https://geminicli.com/docs/).
|
||||
## 📦 Installation
|
||||
|
||||
See
|
||||
[Gemini CLI installation, execution, and releases](./docs/get-started/installation.md)
|
||||
[Gemini CLI installation, execution, and releases](https://geminicli.com/docs/get-started/installation/)
|
||||
for recommended system specifications and a detailed installation guide.
|
||||
|
||||
### Quick Install
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Latest stable release: v0.35.1
|
||||
# Latest stable release: v0.35.0
|
||||
|
||||
Released: March 26, 2026
|
||||
Released: March 24, 2026
|
||||
|
||||
For most users, our latest stable release is the recommended release. Install
|
||||
the latest stable version with:
|
||||
@@ -380,4 +380,4 @@ npm install -g @google/gemini-cli
|
||||
[#23585](https://github.com/google-gemini/gemini-cli/pull/23585)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.34.0...v0.35.1
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.34.0...v0.35.0
|
||||
|
||||
@@ -15,14 +15,14 @@ CLI works in the background.
|
||||
|
||||
## Requirements
|
||||
|
||||
Currently, system notifications are only supported on macOS.
|
||||
|
||||
### Terminal support
|
||||
|
||||
The CLI uses the OSC 9 terminal escape sequence to trigger system notifications.
|
||||
This is supported by several modern terminal emulators including iTerm2,
|
||||
WezTerm, Ghostty, and Kitty. If your terminal does not support OSC 9
|
||||
notifications, Gemini CLI falls back to a terminal bell (BEL) to get your
|
||||
attention. Most terminals respond to BEL with a taskbar flash or system alert
|
||||
sound.
|
||||
This is supported by several modern terminal emulators. If your terminal does
|
||||
not support OSC 9 notifications, Gemini CLI falls back to a system alert sound
|
||||
to get your attention.
|
||||
|
||||
## Enable notifications
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ they appear in the UI.
|
||||
| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` |
|
||||
| Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. YOLO mode (auto-approve all actions) can only be enabled via command line (--yolo or --approval-mode=yolo). | `"default"` |
|
||||
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
|
||||
| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. | `false` |
|
||||
| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` |
|
||||
| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. A custom directory requires a policy to allow write access in Plan Mode. | `undefined` |
|
||||
| Plan Model Routing | `general.plan.modelRouting` | Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase. | `true` |
|
||||
| Retry Fetch Errors | `general.retryFetchErrors` | Retry on "exception TypeError: fetch failed sending request" errors. | `true` |
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# Gemini CLI examples
|
||||
|
||||
Gemini CLI helps you automate common engineering tasks by combining AI reasoning
|
||||
with local system tools. This document provides examples of how to use the CLI
|
||||
for file management, code analysis, and data transformation.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!NOTE]
|
||||
> These examples demonstrate potential capabilities. Your actual
|
||||
> results can vary based on the model used and your project environment.
|
||||
|
||||
## Rename your photographs based on content
|
||||
|
||||
You can use Gemini CLI to automate file management tasks that require visual
|
||||
analysis. In this example, Gemini CLI renames images based on their actual
|
||||
subject matter.
|
||||
|
||||
Scenario: You have a folder containing the following files:
|
||||
|
||||
```bash
|
||||
photos/photo1.png
|
||||
photos/photo2.png
|
||||
photos/photo3.png
|
||||
```
|
||||
|
||||
Give Gemini the following prompt:
|
||||
|
||||
```cli
|
||||
Rename the photos in my "photos" directory based on their contents.
|
||||
```
|
||||
|
||||
Result: Gemini asks for permission to rename your files.
|
||||
|
||||
Select **Allow once** and your files are renamed:
|
||||
|
||||
```bash
|
||||
photos/yellow_flowers.png
|
||||
photos/antique_dresser.png
|
||||
photos/green_android_robot.png
|
||||
```
|
||||
|
||||
## Explain a repository by reading its code
|
||||
|
||||
Gemini CLI is effective for rapid codebase exploration. The following example
|
||||
shows how to ask Gemini CLI to fetch, analyze, and summarize a remote project.
|
||||
|
||||
Scenario: You want to understand how a popular open-source utility works by
|
||||
inspecting its code, not just its README.
|
||||
|
||||
Give Gemini CLI the following prompt:
|
||||
|
||||
```cli
|
||||
Clone the 'chalk' repository from https://github.com/chalk/chalk, read its key source files, and explain how it works.
|
||||
```
|
||||
|
||||
Result: Gemini performs a sequence of actions to answer your request.
|
||||
|
||||
1. First, it asks for permission to run `git clone` to download the repository.
|
||||
2. Next, it finds the important source files and asks for permission to read
|
||||
them.
|
||||
3. Finally, after analyzing the code, it provides a summary.
|
||||
|
||||
Gemini CLI returns an explanation based on the actual source code:
|
||||
|
||||
```markdown
|
||||
The `chalk` library is a popular npm package for styling terminal output with
|
||||
colors. After analyzing the source code, here's how it works:
|
||||
|
||||
- **Core functionality:** The main file sets up a chainable API. Each color or
|
||||
modifier (like `bold` or `italic`) is a getter that appends the corresponding
|
||||
ANSI escape code to an internal stack.
|
||||
|
||||
- **Chainable API:** When you call `chalk.red.bold('Hello')`, you are accessing
|
||||
getters. The `red` getter adds the red color code, and the `bold` getter adds
|
||||
the bold code.
|
||||
|
||||
- **Output generation:** When the chain is treated as a string (e.g., in
|
||||
`console.log`), a final `toString()` method is called. This method joins all
|
||||
the stored ANSI codes, wraps them around the input string ('Hello'), and adds
|
||||
a reset code at the end. This produces the final, styled string that the
|
||||
terminal can render.
|
||||
```
|
||||
|
||||
## Combine two spreadsheets into one spreadsheet
|
||||
|
||||
Gemini CLI can process and transform data across multiple files. Use this
|
||||
capability to merge reports or reformat data sets without manual copying.
|
||||
|
||||
Scenario: You have two .csv files: `Revenue - 2023.csv` and
|
||||
`Revenue - 2024.csv`. Each file contains monthly revenue figures.
|
||||
|
||||
Give Gemini CLI the following prompt:
|
||||
|
||||
```cli
|
||||
Combine the two .csv files into a single .csv file, with each year a different column.
|
||||
```
|
||||
|
||||
Result: Gemini CLI reads each file and then asks for permission to write a new
|
||||
file. Provide your permission and Gemini CLI provides the combined data:
|
||||
|
||||
```csv
|
||||
Month,2023,2024
|
||||
January,0,1000
|
||||
February,0,1200
|
||||
March,0,2400
|
||||
April,900,500
|
||||
May,1000,800
|
||||
June,1000,900
|
||||
July,1200,1000
|
||||
August,1800,400
|
||||
September,2000,2000
|
||||
October,2400,3400
|
||||
November,3400,1800
|
||||
December,2100,9000
|
||||
```
|
||||
|
||||
## Run unit tests
|
||||
|
||||
Gemini CLI can generate boilerplate code and tests based on your existing
|
||||
implementation. This example demonstrates how to request code coverage for a
|
||||
JavaScript component.
|
||||
|
||||
Scenario: You've written a simple login page. You wish to write unit tests to
|
||||
ensure that your login page has code coverage.
|
||||
|
||||
Give Gemini CLI the following prompt:
|
||||
|
||||
```cli
|
||||
Write unit tests for Login.js.
|
||||
```
|
||||
|
||||
Result: Gemini CLI asks for permission to write a new file and creates a test
|
||||
for your login page.
|
||||
|
||||
## Next steps
|
||||
|
||||
- Follow the [File management](../cli/tutorials/file-management.md) guide to
|
||||
start working with your codebase.
|
||||
- Follow the [Quickstart](./index.md) to start your first session.
|
||||
- See the [Cheatsheet](../cli/cli-reference.md) for a quick reference of
|
||||
available commands.
|
||||
@@ -24,7 +24,7 @@ Once Gemini CLI is installed, run Gemini CLI from your command line:
|
||||
gemini
|
||||
```
|
||||
|
||||
For more installation options, see [Gemini CLI Installation](./installation.md).
|
||||
For more installation options, see [Gemini CLI Installation](./installation/).
|
||||
|
||||
## Authenticate
|
||||
|
||||
@@ -62,133 +62,7 @@ Once installed and authenticated, you can start using Gemini CLI by issuing
|
||||
commands and prompts in your terminal. Ask it to generate code, explain files,
|
||||
and more.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!NOTE]
|
||||
> These examples demonstrate potential capabilities. Your actual
|
||||
> results can vary based on the model used and your project environment.
|
||||
|
||||
### Rename your photographs based on content
|
||||
|
||||
You can use Gemini CLI to automate file management tasks that require visual
|
||||
analysis. In this example, Gemini CLI renames images based on their actual
|
||||
subject matter.
|
||||
|
||||
Scenario: You have a folder containing the following files:
|
||||
|
||||
```bash
|
||||
photos/photo1.png
|
||||
photos/photo2.png
|
||||
photos/photo3.png
|
||||
```
|
||||
|
||||
Give Gemini the following prompt:
|
||||
|
||||
```cli
|
||||
Rename the photos in my "photos" directory based on their contents.
|
||||
```
|
||||
|
||||
Result: Gemini asks for permission to rename your files.
|
||||
|
||||
Select **Allow once** and your files are renamed:
|
||||
|
||||
```bash
|
||||
photos/yellow_flowers.png
|
||||
photos/antique_dresser.png
|
||||
photos/green_android_robot.png
|
||||
```
|
||||
|
||||
### Explain a repository by reading its code
|
||||
|
||||
Gemini CLI is effective for rapid codebase exploration. The following example
|
||||
shows how to ask Gemini CLI to fetch, analyze, and summarize a remote project.
|
||||
|
||||
Scenario: You want to understand how a popular open-source utility works by
|
||||
inspecting its code, not just its README.
|
||||
|
||||
Give Gemini CLI the following prompt:
|
||||
|
||||
```cli
|
||||
Clone the 'chalk' repository from https://github.com/chalk/chalk, read its key source files, and explain how it works.
|
||||
```
|
||||
|
||||
Result: Gemini performs a sequence of actions to answer your request.
|
||||
|
||||
1. First, it asks for permission to run `git clone` to download the repository.
|
||||
2. Next, it finds the important source files and asks for permission to read
|
||||
them.
|
||||
3. Finally, after analyzing the code, it provides a summary.
|
||||
|
||||
Gemini CLI returns an explanation based on the actual source code:
|
||||
|
||||
```markdown
|
||||
The `chalk` library is a popular npm package for styling terminal output with
|
||||
colors. After analyzing the source code, here's how it works:
|
||||
|
||||
- **Core functionality:** The main file sets up a chainable API. Each color or
|
||||
modifier (like `bold` or `italic`) is a getter that appends the corresponding
|
||||
ANSI escape code to an internal stack.
|
||||
|
||||
- **Chainable API:** When you call `chalk.red.bold('Hello')`, you are accessing
|
||||
getters. The `red` getter adds the red color code, and the `bold` getter adds
|
||||
the bold code.
|
||||
|
||||
- **Output generation:** When the chain is treated as a string (e.g., in
|
||||
`console.log`), a final `toString()` method is called. This method joins all
|
||||
the stored ANSI codes, wraps them around the input string ('Hello'), and adds
|
||||
a reset code at the end. This produces the final, styled string that the
|
||||
terminal can render.
|
||||
```
|
||||
|
||||
### Combine two spreadsheets into one spreadsheet
|
||||
|
||||
Gemini CLI can process and transform data across multiple files. Use this
|
||||
capability to merge reports or reformat data sets without manual copying.
|
||||
|
||||
Scenario: You have two .csv files: `Revenue - 2023.csv` and
|
||||
`Revenue - 2024.csv`. Each file contains monthly revenue figures.
|
||||
|
||||
Give Gemini CLI the following prompt:
|
||||
|
||||
```cli
|
||||
Combine the two .csv files into a single .csv file, with each year a different column.
|
||||
```
|
||||
|
||||
Result: Gemini CLI reads each file and then asks for permission to write a new
|
||||
file. Provide your permission and Gemini CLI provides the combined data:
|
||||
|
||||
```csv
|
||||
Month,2023,2024
|
||||
January,0,1000
|
||||
February,0,1200
|
||||
March,0,2400
|
||||
April,900,500
|
||||
May,1000,800
|
||||
June,1000,900
|
||||
July,1200,1000
|
||||
August,1800,400
|
||||
September,2000,2000
|
||||
October,2400,3400
|
||||
November,3400,1800
|
||||
December,2100,9000
|
||||
```
|
||||
|
||||
### Run unit tests
|
||||
|
||||
Gemini CLI can generate boilerplate code and tests based on your existing
|
||||
implementation. This example demonstrates how to request code coverage for a
|
||||
JavaScript component.
|
||||
|
||||
Scenario: You've written a simple login page. You wish to write unit tests to
|
||||
ensure that your login page has code coverage.
|
||||
|
||||
Give Gemini CLI the following prompt:
|
||||
|
||||
```cli
|
||||
Write unit tests for Login.js.
|
||||
```
|
||||
|
||||
Result: Gemini CLI asks for permission to write a new file and creates a test
|
||||
for your login page.
|
||||
To explore the power of Gemini CLI, see [Gemini CLI examples](./examples.md).
|
||||
|
||||
## Check usage and quota
|
||||
|
||||
|
||||
@@ -23,34 +23,33 @@ installation methods, and release types.
|
||||
We recommend most users install Gemini CLI using one of the following
|
||||
installation methods:
|
||||
|
||||
- npm
|
||||
- Homebrew
|
||||
- MacPorts
|
||||
- Anaconda
|
||||
|
||||
Note that Gemini CLI comes pre-installed on
|
||||
[**Cloud Shell**](https://docs.cloud.google.com/shell/docs) and
|
||||
[**Cloud Workstations**](https://cloud.google.com/workstations).
|
||||
|
||||
### Install globally with npm
|
||||
{% tabs %}
|
||||
{% tabitem label="npm" %}
|
||||
Install globally with npm:
|
||||
|
||||
```bash
|
||||
npm install -g @google/gemini-cli
|
||||
```
|
||||
{% /tabitem %}
|
||||
|
||||
### Install globally with Homebrew (macOS/Linux)
|
||||
{% tabitem label="Homebrew" %}
|
||||
Install globally with Homebrew (macOS/Linux):
|
||||
|
||||
```bash
|
||||
brew install gemini-cli
|
||||
```
|
||||
{% /tabitem %}
|
||||
|
||||
### Install globally with MacPorts (macOS)
|
||||
{% tabitem label="MacPorts" %}
|
||||
Install globally with MacPorts (macOS):
|
||||
|
||||
```bash
|
||||
sudo port install gemini-cli
|
||||
```
|
||||
{% /tabitem %}
|
||||
|
||||
### Install with Anaconda (for restricted environments)
|
||||
{% tabitem label="Anaconda" %}
|
||||
Install with Anaconda (for restricted environments):
|
||||
|
||||
```bash
|
||||
# Create and activate a new environment
|
||||
@@ -60,6 +59,12 @@ conda activate gemini_env
|
||||
# Install Gemini CLI globally via npm (inside the environment)
|
||||
npm install -g @google/gemini-cli
|
||||
```
|
||||
{% /tabitem %}
|
||||
{% /tabs %}
|
||||
|
||||
Note that Gemini CLI comes pre-installed on
|
||||
[**Cloud Shell**](https://docs.cloud.google.com/shell/docs) and
|
||||
[**Cloud Workstations**](https://cloud.google.com/workstations).
|
||||
|
||||
## Run Gemini CLI
|
||||
|
||||
@@ -102,7 +107,7 @@ the default way that the CLI executes tools that might have side effects.
|
||||
to run the CLI.
|
||||
```bash
|
||||
# Run the published sandbox image
|
||||
docker run --rm -it us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.1.1
|
||||
docker run --rm -it us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:latest
|
||||
```
|
||||
- **Using the `--sandbox` flag:** If you have Gemini CLI installed locally
|
||||
(using the standard installation described above), you can instruct it to run
|
||||
@@ -0,0 +1,157 @@
|
||||
# Gemini CLI installation, execution, and releases
|
||||
|
||||
This document provides an overview of Gemini CLI's system requirements,
|
||||
installation methods, and release types.
|
||||
|
||||
## Recommended system specifications
|
||||
|
||||
- **Operating System:**
|
||||
- macOS 15+
|
||||
- Windows 11 24H2+
|
||||
- Ubuntu 20.04+
|
||||
- **Hardware:**
|
||||
- "Casual" usage: 4GB+ RAM (short sessions, common tasks and edits)
|
||||
- "Power" usage: 16GB+ RAM (long sessions, large codebases, deep context)
|
||||
- **Runtime:** Node.js 20.0.0+
|
||||
- **Shell:** Bash, Zsh, or PowerShell
|
||||
- **Location:**
|
||||
[Gemini Code Assist supported locations](https://developers.google.com/gemini-code-assist/resources/available-locations#americas)
|
||||
- **Internet connection required**
|
||||
|
||||
## Install Gemini CLI
|
||||
|
||||
We recommend most users install Gemini CLI using one of the following
|
||||
installation methods:
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="npm">
|
||||
Install globally with npm:
|
||||
|
||||
```bash
|
||||
npm install -g @google/gemini-cli
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="Homebrew">
|
||||
Install globally with Homebrew (macOS/Linux):
|
||||
|
||||
```bash
|
||||
brew install gemini-cli
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
Note that Gemini CLI comes pre-installed on
|
||||
[**Cloud Shell**](https://docs.cloud.google.com/shell/docs) and
|
||||
[**Cloud Workstations**](https://cloud.google.com/workstations).
|
||||
|
||||
## Run Gemini CLI
|
||||
|
||||
For most users, we recommend running Gemini CLI with the `gemini` command:
|
||||
|
||||
```bash
|
||||
gemini
|
||||
```
|
||||
|
||||
For a list of options and additional commands, see the
|
||||
[CLI cheatsheet](../cli/cli-reference.md).
|
||||
|
||||
You can also run Gemini CLI using one of the following advanced methods:
|
||||
|
||||
- Run instantly with npx. You can run Gemini CLI without permanent installation.
|
||||
- In a sandbox. This method offers increased security and isolation.
|
||||
- From the source. This is recommended for contributors to the project.
|
||||
|
||||
### Run instantly with npx
|
||||
|
||||
```bash
|
||||
# Using npx (no installation required)
|
||||
npx @google/gemini-cli
|
||||
```
|
||||
|
||||
You can also execute the CLI directly from the main branch on GitHub, which is
|
||||
helpful for testing features still in development:
|
||||
|
||||
```bash
|
||||
npx https://github.com/google-gemini/gemini-cli
|
||||
```
|
||||
|
||||
### Run in a sandbox (Docker/Podman)
|
||||
|
||||
For security and isolation, Gemini CLI can be run inside a container. This is
|
||||
the default way that the CLI executes tools that might have side effects.
|
||||
|
||||
- **Directly from the registry:** You can run the published sandbox image
|
||||
directly. This is useful for environments where you only have Docker and want
|
||||
to run the CLI.
|
||||
```bash
|
||||
# Run the published sandbox image
|
||||
docker run --rm -it us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:latest
|
||||
```
|
||||
- **Using the `--sandbox` flag:** If you have Gemini CLI installed locally
|
||||
(using the standard installation described above), you can instruct it to run
|
||||
inside the sandbox container.
|
||||
```bash
|
||||
gemini --sandbox -y -p "your prompt here"
|
||||
```
|
||||
|
||||
### Run from source (recommended for Gemini CLI contributors)
|
||||
|
||||
Contributors to the project will want to run the CLI directly from the source
|
||||
code.
|
||||
|
||||
- **Development mode:** This method provides hot-reloading and is useful for
|
||||
active development.
|
||||
```bash
|
||||
# From the root of the repository
|
||||
npm run start
|
||||
```
|
||||
- **Production-like mode (linked package):** This method simulates a global
|
||||
installation by linking your local package. It's useful for testing a local
|
||||
build in a production workflow.
|
||||
|
||||
```bash
|
||||
# Link the local cli package to your global node_modules
|
||||
npm link packages/cli
|
||||
|
||||
# Now you can run your local version using the `gemini` command
|
||||
gemini
|
||||
```
|
||||
|
||||
## Releases
|
||||
|
||||
Gemini CLI has three release channels: nightly, preview, and stable. For most
|
||||
users, we recommend the stable release, which is the default installation.
|
||||
|
||||
### Stable
|
||||
|
||||
New stable releases are published each week. The stable release is the promotion
|
||||
of last week's `preview` release along with any bug fixes. The stable release
|
||||
uses `latest` tag, but omitting the tag also installs the latest stable release
|
||||
by default:
|
||||
|
||||
```bash
|
||||
# Both commands install the latest stable release.
|
||||
npm install -g @google/gemini-cli
|
||||
npm install -g @google/gemini-cli@latest
|
||||
```
|
||||
|
||||
### Preview
|
||||
|
||||
New preview releases will be published each week. These releases are not fully
|
||||
vetted and may contain regressions or other outstanding issues. Try out the
|
||||
preview release by using the `preview` tag:
|
||||
|
||||
```bash
|
||||
npm install -g @google/gemini-cli@preview
|
||||
```
|
||||
|
||||
### Nightly
|
||||
|
||||
Nightly releases are published every day. The nightly release includes all
|
||||
changes from the main branch at time of release. It should be assumed there are
|
||||
pending validations and issues. You can help test the latest changes by
|
||||
installing with the `nightly` tag:
|
||||
|
||||
```bash
|
||||
npm install -g @google/gemini-cli@nightly
|
||||
```
|
||||
@@ -15,10 +15,12 @@ npm install -g @google/gemini-cli
|
||||
Jump in to Gemini CLI.
|
||||
|
||||
- **[Quickstart](./get-started/index.md):** Your first session with Gemini CLI.
|
||||
- **[Installation](./get-started/installation.md):** How to install Gemini CLI
|
||||
on your system.
|
||||
- **[Installation](./get-started/installation/):** How to install Gemini CLI on
|
||||
your system.
|
||||
- **[Authentication](./get-started/authentication.md):** Setup instructions for
|
||||
personal and enterprise accounts.
|
||||
- **[Examples](./get-started/examples.md):** Practical examples of Gemini CLI in
|
||||
action.
|
||||
- **[CLI cheatsheet](./cli/cli-reference.md):** A quick reference for common
|
||||
commands and options.
|
||||
- **[Gemini 3 on Gemini CLI](./get-started/gemini-3.md):** Learn about Gemini 3
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
"/docs/faq": "/docs/resources/faq",
|
||||
"/docs/get-started/configuration": "/docs/reference/configuration",
|
||||
"/docs/get-started/configuration-v1": "/docs/reference/configuration",
|
||||
"/docs/get-started/examples": "/docs/get-started/index",
|
||||
"/docs/index": "/docs",
|
||||
"/docs/quota-and-pricing": "/docs/resources/quota-and-pricing",
|
||||
"/docs/tos-privacy": "/docs/resources/tos-privacy",
|
||||
|
||||
@@ -133,7 +133,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
- **`general.enableNotifications`** (boolean):
|
||||
- **Description:** Enable run-event notifications for action-required prompts
|
||||
and session completion.
|
||||
and session completion. Currently macOS only.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`general.checkpointing.enabled`** (boolean):
|
||||
|
||||
@@ -12,21 +12,6 @@ quota for your needs, see the [Plans page](https://geminicli.com/plans/).
|
||||
This article outlines the specific quotas and pricing applicable to Gemini CLI
|
||||
when using different authentication methods.
|
||||
|
||||
The following table summarizes the available quotas and their respective limits:
|
||||
|
||||
| Authentication method | Tier / Subscription | Maximum requests per user per day |
|
||||
| :-------------------- | :------------------------------ | :-------------------------------- |
|
||||
| **Google account** | Gemini Code Assist (Individual) | 1,000 requests |
|
||||
| | Google AI Pro | 1,500 requests |
|
||||
| | Google AI Ultra | 2,000 requests |
|
||||
| **Gemini API key** | Free tier (Unpaid) | 250 requests |
|
||||
| | Pay-as-you-go (Paid) | Varies |
|
||||
| **Vertex AI** | Express mode (Free) | Varies |
|
||||
| | Pay-as-you-go (Paid) | Varies |
|
||||
| **Google Workspace** | Code Assist Standard | 1,500 requests |
|
||||
| | Code Assist Enterprise | 2,000 requests |
|
||||
| | Workspace AI Ultra | 2,000 requests |
|
||||
|
||||
Generally, there are three categories to choose from:
|
||||
|
||||
- Free Usage: Ideal for experimentation and light use.
|
||||
@@ -35,9 +20,6 @@ Generally, there are three categories to choose from:
|
||||
- Pay-As-You-Go: The most flexible option for professional use, long-running
|
||||
tasks, or when you need full control over your usage.
|
||||
|
||||
Requests are limited per user per minute and are subject to the availability of
|
||||
the service in times of high demand.
|
||||
|
||||
## Free usage
|
||||
|
||||
Access to Gemini CLI begins with a generous free tier, perfect for
|
||||
@@ -51,7 +33,8 @@ authorization type.
|
||||
For users who authenticate by using their Google account to access Gemini Code
|
||||
Assist for individuals. This includes:
|
||||
|
||||
- 1000 maximum model requests / user / day
|
||||
- 1000 model requests / user / day
|
||||
- 60 model requests / user / minute
|
||||
- Model requests will be made across the Gemini model family as determined by
|
||||
Gemini CLI.
|
||||
|
||||
@@ -63,7 +46,8 @@ Learn more at
|
||||
If you are using a Gemini API key, you can also benefit from a free tier. This
|
||||
includes:
|
||||
|
||||
- 250 maximum model requests / user / day
|
||||
- 250 model requests / user / day
|
||||
- 10 model requests / user / minute
|
||||
- Model requests to Flash model only.
|
||||
|
||||
Learn more at
|
||||
@@ -75,7 +59,7 @@ Vertex AI offers an Express Mode without the need to enable billing. This
|
||||
includes:
|
||||
|
||||
- 90 days before you need to enable billing.
|
||||
- Quotas and models are specific to your account and their limits vary.
|
||||
- Quotas and models are variable and specific to your account.
|
||||
|
||||
Learn more at
|
||||
[Vertex AI Express Mode Limits](https://cloud.google.com/vertex-ai/generative-ai/docs/start/express-mode/overview#quotas).
|
||||
@@ -128,9 +112,11 @@ Standard/Plus and AI Expanded, are not supported._
|
||||
|
||||
This includes the following request limits:
|
||||
- Gemini Code Assist Standard edition:
|
||||
- 1500 maximum model requests / user / day
|
||||
- 1500 model requests / user / day
|
||||
- 120 model requests / user / minute
|
||||
- Gemini Code Assist Enterprise edition:
|
||||
- 2000 maximum model requests / user / day
|
||||
- 2000 model requests / user / day
|
||||
- 120 model requests / user / minute
|
||||
- Model requests will be made across the Gemini model family as determined by
|
||||
Gemini CLI.
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"label": "Authentication",
|
||||
"slug": "docs/get-started/authentication"
|
||||
},
|
||||
{ "label": "Examples", "slug": "docs/get-started/examples" },
|
||||
{ "label": "CLI cheatsheet", "slug": "docs/cli/cli-reference" },
|
||||
{
|
||||
"label": "Gemini 3 on Gemini CLI",
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { internalEvalTest } from './test-helper.js';
|
||||
import { TestRig } from '@google/gemini-cli-test-utils';
|
||||
|
||||
// Mock TestRig to control API success/failure
|
||||
vi.mock('@google/gemini-cli-test-utils', () => {
|
||||
return {
|
||||
TestRig: vi.fn().mockImplementation(() => ({
|
||||
setup: vi.fn(),
|
||||
run: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
readToolLogs: vi.fn().mockReturnValue([]),
|
||||
_lastRunStderr: '',
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
describe('evalTest reliability logic', () => {
|
||||
const LOG_DIR = path.resolve(process.cwd(), 'evals/logs');
|
||||
const RELIABILITY_LOG = path.join(LOG_DIR, 'api-reliability.jsonl');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
if (fs.existsSync(RELIABILITY_LOG)) {
|
||||
fs.unlinkSync(RELIABILITY_LOG);
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(RELIABILITY_LOG)) {
|
||||
fs.unlinkSync(RELIABILITY_LOG);
|
||||
}
|
||||
});
|
||||
|
||||
it('should retry 3 times on 500 INTERNAL error and then SKIP', async () => {
|
||||
const mockRig = new TestRig() as any;
|
||||
(TestRig as any).mockReturnValue(mockRig);
|
||||
|
||||
// Simulate permanent 500 error
|
||||
mockRig.run.mockRejectedValue(new Error('status: INTERNAL - API Down'));
|
||||
|
||||
// Execute the test function directly
|
||||
await internalEvalTest({
|
||||
name: 'test-api-failure',
|
||||
prompt: 'do something',
|
||||
assert: async () => {},
|
||||
});
|
||||
|
||||
// Verify retries: 1 initial + 3 retries = 4 setups/runs
|
||||
expect(mockRig.run).toHaveBeenCalledTimes(4);
|
||||
|
||||
// Verify log content
|
||||
const logContent = fs
|
||||
.readFileSync(RELIABILITY_LOG, 'utf-8')
|
||||
.trim()
|
||||
.split('\n');
|
||||
expect(logContent.length).toBe(4);
|
||||
|
||||
const entries = logContent.map((line) => JSON.parse(line));
|
||||
expect(entries[0].status).toBe('RETRY');
|
||||
expect(entries[0].attempt).toBe(0);
|
||||
expect(entries[3].status).toBe('SKIP');
|
||||
expect(entries[3].attempt).toBe(3);
|
||||
expect(entries[3].testName).toBe('test-api-failure');
|
||||
});
|
||||
|
||||
it('should fail immediately on non-500 errors (like assertion failures)', async () => {
|
||||
const mockRig = new TestRig() as any;
|
||||
(TestRig as any).mockReturnValue(mockRig);
|
||||
|
||||
// Simulate a real logic error/bug
|
||||
mockRig.run.mockResolvedValue('Success');
|
||||
const assertError = new Error('Assertion failed: expected foo to be bar');
|
||||
|
||||
// Expect the test function to throw immediately
|
||||
await expect(
|
||||
internalEvalTest({
|
||||
name: 'test-logic-failure',
|
||||
prompt: 'do something',
|
||||
assert: async () => {
|
||||
throw assertError;
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('Assertion failed');
|
||||
|
||||
// Verify NO retries: only 1 attempt
|
||||
expect(mockRig.run).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Verify NO reliability log was created (it's not an API error)
|
||||
expect(fs.existsSync(RELIABILITY_LOG)).toBe(false);
|
||||
});
|
||||
|
||||
it('should recover if a retry succeeds', async () => {
|
||||
const mockRig = new TestRig() as any;
|
||||
(TestRig as any).mockReturnValue(mockRig);
|
||||
|
||||
// Fail once, then succeed
|
||||
mockRig.run
|
||||
.mockRejectedValueOnce(new Error('status: INTERNAL'))
|
||||
.mockResolvedValueOnce('Success');
|
||||
|
||||
await internalEvalTest({
|
||||
name: 'test-recovery',
|
||||
prompt: 'do something',
|
||||
assert: async () => {},
|
||||
});
|
||||
|
||||
// Ran twice: initial (fail) + retry 1 (success)
|
||||
expect(mockRig.run).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Log should only have the one RETRY entry
|
||||
const logContent = fs
|
||||
.readFileSync(RELIABILITY_LOG, 'utf-8')
|
||||
.trim()
|
||||
.split('\n');
|
||||
expect(logContent.length).toBe(1);
|
||||
expect(JSON.parse(logContent[0]).status).toBe('RETRY');
|
||||
});
|
||||
|
||||
it('should retry 3 times on 503 UNAVAILABLE error and then SKIP', async () => {
|
||||
const mockRig = new TestRig() as any;
|
||||
(TestRig as any).mockReturnValue(mockRig);
|
||||
|
||||
// Simulate permanent 503 error
|
||||
mockRig.run.mockRejectedValue(
|
||||
new Error('status: UNAVAILABLE - Service Busy'),
|
||||
);
|
||||
|
||||
await internalEvalTest({
|
||||
name: 'test-api-503',
|
||||
prompt: 'do something',
|
||||
assert: async () => {},
|
||||
});
|
||||
|
||||
expect(mockRig.run).toHaveBeenCalledTimes(4);
|
||||
|
||||
const logContent = fs
|
||||
.readFileSync(RELIABILITY_LOG, 'utf-8')
|
||||
.trim()
|
||||
.split('\n');
|
||||
const entries = logContent.map((line) => JSON.parse(line));
|
||||
expect(entries[0].errorCode).toBe('503');
|
||||
expect(entries[3].status).toBe('SKIP');
|
||||
});
|
||||
|
||||
it('should throw if an absolute path is used in files', async () => {
|
||||
const mockRig = new TestRig() as any;
|
||||
(TestRig as any).mockReturnValue(mockRig);
|
||||
mockRig.testDir = path.resolve(process.cwd(), 'test-dir-tmp');
|
||||
if (!fs.existsSync(mockRig.testDir)) {
|
||||
fs.mkdirSync(mockRig.testDir, { recursive: true });
|
||||
}
|
||||
|
||||
try {
|
||||
await expect(
|
||||
internalEvalTest({
|
||||
name: 'test-absolute-path',
|
||||
prompt: 'do something',
|
||||
files: {
|
||||
'/etc/passwd': 'hacked',
|
||||
},
|
||||
assert: async () => {},
|
||||
}),
|
||||
).rejects.toThrow('Invalid file path in test case: /etc/passwd');
|
||||
} finally {
|
||||
if (fs.existsSync(mockRig.testDir)) {
|
||||
fs.rmSync(mockRig.testDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw if directory traversal is detected in files', async () => {
|
||||
const mockRig = new TestRig() as any;
|
||||
(TestRig as any).mockReturnValue(mockRig);
|
||||
mockRig.testDir = path.resolve(process.cwd(), 'test-dir-tmp');
|
||||
|
||||
// Create a mock test-dir
|
||||
if (!fs.existsSync(mockRig.testDir)) {
|
||||
fs.mkdirSync(mockRig.testDir, { recursive: true });
|
||||
}
|
||||
|
||||
try {
|
||||
await expect(
|
||||
internalEvalTest({
|
||||
name: 'test-traversal',
|
||||
prompt: 'do something',
|
||||
files: {
|
||||
'../sensitive.txt': 'hacked',
|
||||
},
|
||||
assert: async () => {},
|
||||
}),
|
||||
).rejects.toThrow('Invalid file path in test case: ../sensitive.txt');
|
||||
} finally {
|
||||
if (fs.existsSync(mockRig.testDir)) {
|
||||
fs.rmSync(mockRig.testDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -39,34 +39,87 @@ export * from '@google/gemini-cli-test-utils';
|
||||
export type EvalPolicy = 'ALWAYS_PASSES' | 'USUALLY_PASSES';
|
||||
|
||||
export function evalTest(policy: EvalPolicy, evalCase: EvalCase) {
|
||||
runEval(
|
||||
policy,
|
||||
evalCase.name,
|
||||
() => internalEvalTest(evalCase),
|
||||
evalCase.timeout,
|
||||
);
|
||||
}
|
||||
|
||||
export async function internalEvalTest(evalCase: EvalCase) {
|
||||
const maxRetries = 3;
|
||||
let attempt = 0;
|
||||
|
||||
while (attempt <= maxRetries) {
|
||||
const fn = async () => {
|
||||
const rig = new TestRig();
|
||||
const { logDir, sanitizedName } = await prepareLogDir(evalCase.name);
|
||||
const activityLogFile = path.join(logDir, `${sanitizedName}.jsonl`);
|
||||
const logFile = path.join(logDir, `${sanitizedName}.log`);
|
||||
let isSuccess = false;
|
||||
|
||||
try {
|
||||
rig.setup(evalCase.name, evalCase.params);
|
||||
|
||||
if (evalCase.files) {
|
||||
await setupTestFiles(rig, evalCase.files);
|
||||
}
|
||||
|
||||
// Symlink node modules to reduce the amount of time needed to
|
||||
// bootstrap test projects.
|
||||
symlinkNodeModules(rig.testDir || '');
|
||||
|
||||
if (evalCase.files) {
|
||||
const acknowledgedAgents: Record<string, Record<string, string>> = {};
|
||||
const projectRoot = fs.realpathSync(rig.testDir!);
|
||||
|
||||
for (const [filePath, content] of Object.entries(evalCase.files)) {
|
||||
const fullPath = path.join(rig.testDir!, filePath);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, content);
|
||||
|
||||
// If it's an agent file, calculate hash for acknowledgement
|
||||
if (
|
||||
filePath.startsWith('.gemini/agents/') &&
|
||||
filePath.endsWith('.md')
|
||||
) {
|
||||
const hash = crypto
|
||||
.createHash('sha256')
|
||||
.update(content)
|
||||
.digest('hex');
|
||||
|
||||
try {
|
||||
const agentDefs = await parseAgentMarkdown(fullPath, content);
|
||||
if (agentDefs.length > 0) {
|
||||
const agentName = agentDefs[0].name;
|
||||
if (!acknowledgedAgents[projectRoot]) {
|
||||
acknowledgedAgents[projectRoot] = {};
|
||||
}
|
||||
acknowledgedAgents[projectRoot][agentName] = hash;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to parse agent for test acknowledgement: ${filePath}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write acknowledged_agents.json to the home directory
|
||||
if (Object.keys(acknowledgedAgents).length > 0) {
|
||||
const ackPath = path.join(
|
||||
rig.homeDir!,
|
||||
'.gemini',
|
||||
'acknowledgments',
|
||||
'agents.json',
|
||||
);
|
||||
fs.mkdirSync(path.dirname(ackPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
ackPath,
|
||||
JSON.stringify(acknowledgedAgents, null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
const execOptions = { cwd: rig.testDir!, stdio: 'inherit' as const };
|
||||
execSync('git init', execOptions);
|
||||
execSync('git config user.email "test@example.com"', execOptions);
|
||||
execSync('git config user.name "Test User"', execOptions);
|
||||
|
||||
// Temporarily disable the interactive editor and git pager
|
||||
// to avoid hanging the tests. It seems the the agent isn't
|
||||
// consistently honoring the instructions to avoid interactive
|
||||
// commands.
|
||||
execSync('git config core.editor "true"', execOptions);
|
||||
execSync('git config core.pager "cat"', execOptions);
|
||||
execSync('git config commit.gpgsign false', execOptions);
|
||||
execSync('git add .', execOptions);
|
||||
execSync('git commit --allow-empty -m "Initial commit"', execOptions);
|
||||
}
|
||||
|
||||
// If messages are provided, write a session file so --resume can load it.
|
||||
let sessionId: string | undefined;
|
||||
if (evalCase.messages) {
|
||||
@@ -135,37 +188,6 @@ export async function internalEvalTest(evalCase: EvalCase) {
|
||||
|
||||
await evalCase.assert(rig, result);
|
||||
isSuccess = true;
|
||||
return; // Success! Exit the retry loop.
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
const errorCode = getApiErrorCode(errorMessage);
|
||||
|
||||
if (errorCode) {
|
||||
const status = attempt < maxRetries ? 'RETRY' : 'SKIP';
|
||||
logReliabilityEvent(
|
||||
evalCase.name,
|
||||
attempt,
|
||||
status,
|
||||
errorCode,
|
||||
errorMessage,
|
||||
);
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
attempt++;
|
||||
console.warn(
|
||||
`[Eval] Attempt ${attempt} failed with ${errorCode} Error. Retrying...`,
|
||||
);
|
||||
continue; // Retry
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`[Eval] '${evalCase.name}' failed after ${maxRetries} retries due to persistent API errors. Skipping failure to avoid blocking PR.`,
|
||||
);
|
||||
return; // Gracefully exit without failing the test
|
||||
}
|
||||
|
||||
throw error; // Real failure
|
||||
} finally {
|
||||
if (isSuccess) {
|
||||
await fs.promises.unlink(activityLogFile).catch((err) => {
|
||||
@@ -184,131 +206,9 @@ export async function internalEvalTest(evalCase: EvalCase) {
|
||||
);
|
||||
await rig.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getApiErrorCode(message: string): '500' | '503' | undefined {
|
||||
if (
|
||||
message.includes('status: UNAVAILABLE') ||
|
||||
message.includes('code: 503') ||
|
||||
message.includes('Service Unavailable')
|
||||
) {
|
||||
return '503';
|
||||
}
|
||||
if (
|
||||
message.includes('status: INTERNAL') ||
|
||||
message.includes('code: 500') ||
|
||||
message.includes('Internal error encountered')
|
||||
) {
|
||||
return '500';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log reliability event for later harvesting.
|
||||
*
|
||||
* Note: Uses synchronous file I/O to ensure the log is persisted even if the
|
||||
* test process is abruptly terminated by a timeout or CI crash. Performance
|
||||
* impact is negligible compared to long-running evaluation tests.
|
||||
*/
|
||||
function logReliabilityEvent(
|
||||
testName: string,
|
||||
attempt: number,
|
||||
status: 'RETRY' | 'SKIP',
|
||||
errorCode: '500' | '503',
|
||||
errorMessage: string,
|
||||
) {
|
||||
const reliabilityLog = {
|
||||
timestamp: new Date().toISOString(),
|
||||
testName,
|
||||
model: process.env.GEMINI_MODEL || 'unknown',
|
||||
attempt,
|
||||
status,
|
||||
errorCode,
|
||||
error: errorMessage,
|
||||
};
|
||||
|
||||
try {
|
||||
const relDir = path.resolve(process.cwd(), 'evals/logs');
|
||||
fs.mkdirSync(relDir, { recursive: true });
|
||||
fs.appendFileSync(
|
||||
path.join(relDir, 'api-reliability.jsonl'),
|
||||
JSON.stringify(reliabilityLog) + '\n',
|
||||
);
|
||||
} catch (logError) {
|
||||
console.error('Failed to write reliability log:', logError);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to setup test files and git repository.
|
||||
*
|
||||
* Note: While this is an async function (due to parseAgentMarkdown), it
|
||||
* intentionally uses synchronous filesystem and child_process operations
|
||||
* for simplicity and to ensure sequential environment preparation.
|
||||
*/
|
||||
async function setupTestFiles(rig: TestRig, files: Record<string, string>) {
|
||||
const acknowledgedAgents: Record<string, Record<string, string>> = {};
|
||||
const projectRoot = fs.realpathSync(rig.testDir!);
|
||||
|
||||
for (const [filePath, content] of Object.entries(files)) {
|
||||
if (filePath.includes('..') || path.isAbsolute(filePath)) {
|
||||
throw new Error(`Invalid file path in test case: ${filePath}`);
|
||||
}
|
||||
const fullPath = path.join(projectRoot, filePath);
|
||||
if (!fullPath.startsWith(projectRoot)) {
|
||||
throw new Error(`Path traversal detected: ${filePath}`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, content);
|
||||
|
||||
if (filePath.startsWith('.gemini/agents/') && filePath.endsWith('.md')) {
|
||||
const hash = crypto.createHash('sha256').update(content).digest('hex');
|
||||
try {
|
||||
const agentDefs = await parseAgentMarkdown(fullPath, content);
|
||||
if (agentDefs.length > 0) {
|
||||
const agentName = agentDefs[0].name;
|
||||
if (!acknowledgedAgents[projectRoot]) {
|
||||
acknowledgedAgents[projectRoot] = {};
|
||||
}
|
||||
acknowledgedAgents[projectRoot][agentName] = hash;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to parse agent for test acknowledgement: ${filePath}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(acknowledgedAgents).length > 0) {
|
||||
const ackPath = path.join(
|
||||
rig.homeDir!,
|
||||
'.gemini',
|
||||
'acknowledgments',
|
||||
'agents.json',
|
||||
);
|
||||
fs.mkdirSync(path.dirname(ackPath), { recursive: true });
|
||||
fs.writeFileSync(ackPath, JSON.stringify(acknowledgedAgents, null, 2));
|
||||
}
|
||||
|
||||
const execOptions = { cwd: rig.testDir!, stdio: 'inherit' as const };
|
||||
execSync('git init --initial-branch=main', execOptions);
|
||||
execSync('git config user.email "test@example.com"', execOptions);
|
||||
execSync('git config user.name "Test User"', execOptions);
|
||||
|
||||
// Temporarily disable the interactive editor and git pager
|
||||
// to avoid hanging the tests. It seems the the agent isn't
|
||||
// consistently honoring the instructions to avoid interactive
|
||||
// commands.
|
||||
execSync('git config core.editor "true"', execOptions);
|
||||
execSync('git config core.pager "cat"', execOptions);
|
||||
execSync('git config commit.gpgsign false', execOptions);
|
||||
execSync('git add .', execOptions);
|
||||
execSync('git commit --allow-empty -m "Initial commit"', execOptions);
|
||||
runEval(policy, evalCase.name, fn, evalCase.timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,6 +16,10 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
testTimeout: 300000, // 5 minutes
|
||||
// Retry in CI but not nightly to avoid blocking on API error.
|
||||
retry: process.env['VITEST_RETRY']
|
||||
? parseInt(process.env['VITEST_RETRY'], 10)
|
||||
: 3,
|
||||
reporters: ['default', 'json'],
|
||||
outputFile: {
|
||||
json: 'evals/logs/report.json',
|
||||
|
||||
@@ -4,10 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { GEMINI_DIR, TestRig, checkModelOutputContent } from './test-helper.js';
|
||||
import { TestRig, checkModelOutputContent } from './test-helper.js';
|
||||
|
||||
describe('Plan Mode', () => {
|
||||
let rig: TestRig;
|
||||
@@ -229,68 +227,4 @@ describe('Plan Mode', () => {
|
||||
`Expected write_file to succeed, but it failed with error: ${planWrite?.toolRequest.error}`,
|
||||
).toBe(true);
|
||||
});
|
||||
it('should switch from a pro model to a flash model after exiting plan mode', async () => {
|
||||
const plansDir = 'plans-folder';
|
||||
const planFilename = 'my-plan.md';
|
||||
|
||||
await rig.setup('should-switch-to-flash', {
|
||||
settings: {
|
||||
model: {
|
||||
name: 'auto-gemini-2.5',
|
||||
},
|
||||
experimental: { plan: true },
|
||||
tools: {
|
||||
core: ['exit_plan_mode', 'run_shell_command'],
|
||||
allowed: ['exit_plan_mode', 'run_shell_command'],
|
||||
},
|
||||
general: {
|
||||
defaultApprovalMode: 'plan',
|
||||
plan: {
|
||||
directory: plansDir,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
writeFileSync(
|
||||
join(rig.homeDir!, GEMINI_DIR, 'state.json'),
|
||||
JSON.stringify({ terminalSetupPromptShown: true }, null, 2),
|
||||
);
|
||||
|
||||
const fullPlansDir = join(rig.testDir!, plansDir);
|
||||
mkdirSync(fullPlansDir, { recursive: true });
|
||||
writeFileSync(join(fullPlansDir, planFilename), 'Execute echo hello');
|
||||
|
||||
await rig.run({
|
||||
approvalMode: 'plan',
|
||||
stdin: `Exit plan mode using ${planFilename} and then run a shell command \`echo hello\`.`,
|
||||
});
|
||||
|
||||
const exitCallFound = await rig.waitForToolCall('exit_plan_mode');
|
||||
expect(exitCallFound, 'Expected exit_plan_mode to be called').toBe(true);
|
||||
|
||||
const shellCallFound = await rig.waitForToolCall('run_shell_command');
|
||||
expect(shellCallFound, 'Expected run_shell_command to be called').toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const apiRequests = rig.readAllApiRequest();
|
||||
const modelNames = apiRequests.map((r) => r.attributes?.model || 'unknown');
|
||||
|
||||
const proRequests = apiRequests.filter((r) =>
|
||||
r.attributes?.model?.includes('pro'),
|
||||
);
|
||||
const flashRequests = apiRequests.filter((r) =>
|
||||
r.attributes?.model?.includes('flash'),
|
||||
);
|
||||
|
||||
expect(
|
||||
proRequests.length,
|
||||
`Expected at least one Pro request. Models used: ${modelNames.join(', ')}`,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
expect(
|
||||
flashRequests.length,
|
||||
`Expected at least one Flash request after mode switch. Models used: ${modelNames.join(', ')}`,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -352,37 +352,23 @@ describe('loadConfig', () => {
|
||||
});
|
||||
|
||||
describe('interactivity', () => {
|
||||
it('should always set interactive true', async () => {
|
||||
vi.mocked(isHeadlessMode).mockReturnValue(true);
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
expect(Config).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
interactive: true,
|
||||
}),
|
||||
);
|
||||
|
||||
it('should set interactive true when not headless', async () => {
|
||||
vi.mocked(isHeadlessMode).mockReturnValue(false);
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
expect(Config).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
interactive: true,
|
||||
enableInteractiveShell: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should set enableInteractiveShell based on headless mode', async () => {
|
||||
vi.mocked(isHeadlessMode).mockReturnValue(false);
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
expect(Config).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enableInteractiveShell: true,
|
||||
}),
|
||||
);
|
||||
|
||||
it('should set interactive false when headless', async () => {
|
||||
vi.mocked(isHeadlessMode).mockReturnValue(true);
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
expect(Config).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
interactive: false,
|
||||
enableInteractiveShell: false,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -125,7 +125,7 @@ export async function loadConfig(
|
||||
trustedFolder: true,
|
||||
extensionLoader,
|
||||
checkpointing,
|
||||
interactive: true,
|
||||
interactive: !isHeadlessMode(),
|
||||
enableInteractiveShell: !isHeadlessMode(),
|
||||
ptyInfo: 'auto',
|
||||
enableAgents: settings.experimental?.enableAgents ?? true,
|
||||
|
||||
@@ -143,17 +143,12 @@ vi.mock('@google/gemini-cli-core', async () => {
|
||||
respectGeminiIgnore: true,
|
||||
customIgnoreFilePaths: [],
|
||||
},
|
||||
createPolicyEngineConfig: vi.fn(
|
||||
async (_settings, approvalMode, _workspacePoliciesDir, interactive) => ({
|
||||
rules: [],
|
||||
checkers: [],
|
||||
defaultDecision: interactive
|
||||
? ServerConfig.PolicyDecision.ASK_USER
|
||||
: ServerConfig.PolicyDecision.DENY,
|
||||
approvalMode: approvalMode ?? ServerConfig.ApprovalMode.DEFAULT,
|
||||
nonInteractive: !interactive,
|
||||
}),
|
||||
),
|
||||
createPolicyEngineConfig: vi.fn(async () => ({
|
||||
rules: [],
|
||||
checkers: [],
|
||||
defaultDecision: ServerConfig.PolicyDecision.ASK_USER,
|
||||
approvalMode: ServerConfig.ApprovalMode.DEFAULT,
|
||||
})),
|
||||
getAdminErrorMessage: vi.fn(
|
||||
(_feature) =>
|
||||
`YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli`,
|
||||
@@ -3465,8 +3460,6 @@ describe('Policy Engine Integration in loadCliConfig', () => {
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3488,8 +3481,6 @@ describe('Policy Engine Integration in loadCliConfig', () => {
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3513,8 +3504,6 @@ describe('Policy Engine Integration in loadCliConfig', () => {
|
||||
],
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -792,8 +792,8 @@ export async function loadCliConfig(
|
||||
effectiveSettings,
|
||||
approvalMode,
|
||||
workspacePoliciesDir,
|
||||
interactive,
|
||||
);
|
||||
policyEngineConfig.nonInteractive = !interactive;
|
||||
|
||||
const defaultModel = PREVIEW_GEMINI_MODEL_AUTO;
|
||||
const specifiedModel =
|
||||
|
||||
@@ -605,12 +605,12 @@ describe('Policy Engine Integration Tests', () => {
|
||||
it('should verify non-interactive mode transformation', async () => {
|
||||
const settings: Settings = {};
|
||||
|
||||
const engineConfig = await createPolicyEngineConfig(
|
||||
const config = await createPolicyEngineConfig(
|
||||
settings,
|
||||
ApprovalMode.DEFAULT,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
// Enable non-interactive mode
|
||||
const engineConfig = { ...config, nonInteractive: true };
|
||||
const engine = new PolicyEngine(engineConfig);
|
||||
|
||||
// ASK_USER should become DENY in non-interactive mode
|
||||
|
||||
@@ -53,7 +53,6 @@ export async function createPolicyEngineConfig(
|
||||
settings: Settings,
|
||||
approvalMode: ApprovalMode,
|
||||
workspacePoliciesDir?: string,
|
||||
interactive: boolean = true,
|
||||
): Promise<PolicyEngineConfig> {
|
||||
// Explicitly construct PolicySettings from Settings to ensure type safety
|
||||
// and avoid accidental leakage of other settings properties.
|
||||
@@ -69,12 +68,7 @@ export async function createPolicyEngineConfig(
|
||||
settings.admin?.secureModeEnabled,
|
||||
};
|
||||
|
||||
return createCorePolicyEngineConfig(
|
||||
policySettings,
|
||||
approvalMode,
|
||||
undefined,
|
||||
interactive,
|
||||
);
|
||||
return createCorePolicyEngineConfig(policySettings, approvalMode);
|
||||
}
|
||||
|
||||
export function createPolicyUpdater(
|
||||
|
||||
@@ -261,7 +261,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Enable run-event notifications for action-required prompts and session completion.',
|
||||
'Enable run-event notifications for action-required prompts and session completion. Currently macOS only.',
|
||||
showInDialog: true,
|
||||
},
|
||||
checkpointing: {
|
||||
|
||||
@@ -88,8 +88,6 @@ describe('Workspace-Level Policy CLI Integration', () => {
|
||||
),
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -109,8 +107,6 @@ describe('Workspace-Level Policy CLI Integration', () => {
|
||||
workspacePoliciesDir: undefined,
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -135,8 +131,6 @@ describe('Workspace-Level Policy CLI Integration', () => {
|
||||
workspacePoliciesDir: undefined,
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -169,8 +163,6 @@ describe('Workspace-Level Policy CLI Integration', () => {
|
||||
),
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -209,8 +201,6 @@ describe('Workspace-Level Policy CLI Integration', () => {
|
||||
),
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -247,8 +237,6 @@ describe('Workspace-Level Policy CLI Integration', () => {
|
||||
),
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -290,8 +278,6 @@ describe('Workspace-Level Policy CLI Integration', () => {
|
||||
workspacePoliciesDir: undefined,
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
} finally {
|
||||
// Restore for other tests
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
|
||||
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion. …</text>
|
||||
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -71,7 +71,7 @@
|
||||
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
|
||||
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion. …</text>
|
||||
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -71,7 +71,7 @@
|
||||
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
|
||||
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion. …</text>
|
||||
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -71,7 +71,7 @@
|
||||
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
|
||||
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion. …</text>
|
||||
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -71,7 +71,7 @@
|
||||
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
|
||||
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion. …</text>
|
||||
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -60,7 +60,7 @@
|
||||
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
|
||||
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion. …</text>
|
||||
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -71,7 +71,7 @@
|
||||
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
|
||||
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion. …</text>
|
||||
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -71,7 +71,7 @@
|
||||
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
|
||||
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion. …</text>
|
||||
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -71,7 +71,7 @@
|
||||
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
|
||||
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion. …</text>
|
||||
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -20,7 +20,7 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ │
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
@@ -66,7 +66,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ │
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
@@ -112,7 +112,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ │
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
@@ -158,7 +158,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ │
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
@@ -204,7 +204,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ │
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
@@ -250,7 +250,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ │
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
@@ -296,7 +296,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ │
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
@@ -342,7 +342,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ │
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
@@ -388,7 +388,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ │
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
|
||||
@@ -45,18 +45,20 @@ function addShellCommandToGeminiHistory(
|
||||
? resultText.substring(0, MAX_OUTPUT_LENGTH) + '\n... (truncated)'
|
||||
: resultText;
|
||||
|
||||
// Escape backticks to prevent prompt injection breakouts
|
||||
const safeQuery = rawQuery.replace(/\\/g, '\\\\').replace(/\x60/g, '\\\x60');
|
||||
const safeModelContent = modelContent
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\x60/g, '\\\x60');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
geminiClient.addHistory({
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
text: `I ran the following shell command:\n\`\`\`sh\n${safeQuery}\n\`\`\`\n\nThis produced the following result:\n\`\`\`\n${safeModelContent}\n\`\`\``,
|
||||
text: `I ran the following shell command:
|
||||
\`\`\`sh
|
||||
${rawQuery}
|
||||
\`\`\`
|
||||
|
||||
This produced the following result:
|
||||
\`\`\`
|
||||
${modelContent}
|
||||
\`\`\``,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -442,7 +444,7 @@ export const useShellCommandProcessor = (
|
||||
}
|
||||
|
||||
let mainContent: string;
|
||||
if (isBinaryStream || isBinary(result.rawOutput)) {
|
||||
if (isBinary(result.rawOutput)) {
|
||||
mainContent =
|
||||
'[Command produced binary output, which is not shown.]';
|
||||
} else {
|
||||
|
||||
@@ -106,16 +106,14 @@ describe('useFlickerDetector', () => {
|
||||
it('should re-evaluate on re-render', async () => {
|
||||
// Start with a valid height
|
||||
mockMeasureElement.mockReturnValue({ width: 80, height: 20 });
|
||||
const { rerender } = await renderHook(
|
||||
({ height }) => useFlickerDetector(mockRef, height),
|
||||
{ initialProps: { height: 25 } },
|
||||
const { rerender } = await renderHook(() =>
|
||||
useFlickerDetector(mockRef, 25),
|
||||
);
|
||||
expect(mockRecordFlickerFrame).not.toHaveBeenCalled();
|
||||
|
||||
// Now, simulate a re-render where the height is too great
|
||||
mockMeasureElement.mockReturnValue({ width: 80, height: 30 });
|
||||
// Trigger a change in terminalHeight dependency to force effect to run
|
||||
rerender({ height: 24 });
|
||||
rerender();
|
||||
|
||||
expect(mockRecordFlickerFrame).toHaveBeenCalledTimes(1);
|
||||
expect(mockAppEventsEmit).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -39,5 +39,5 @@ export function useFlickerDetector(
|
||||
appEvents.emit(AppEvent.Flicker);
|
||||
}
|
||||
}
|
||||
}, [rootUiRef, terminalHeight, config, constrainHeight]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export function useSessionResume({
|
||||
useEffect(() => {
|
||||
historyManagerRef.current = historyManager;
|
||||
refreshStaticRef.current = refreshStatic;
|
||||
}, [historyManager, refreshStatic]);
|
||||
});
|
||||
|
||||
const loadHistoryForResume = useCallback(
|
||||
async (
|
||||
|
||||
@@ -691,40 +691,6 @@ describe('useSlashCompletion', () => {
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should rank primary name prefix matches higher than alias prefix matches', async () => {
|
||||
const slashCommands = [
|
||||
createTestCommand({
|
||||
name: 'footer',
|
||||
altNames: ['statusline'],
|
||||
description: 'Configure footer',
|
||||
}),
|
||||
createTestCommand({
|
||||
name: 'stats',
|
||||
altNames: ['usage'],
|
||||
description: 'Check stats',
|
||||
}),
|
||||
];
|
||||
|
||||
const { result, unmount } = await renderHook(() =>
|
||||
useTestHarnessForSlashCompletion(
|
||||
true,
|
||||
'/stat',
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
await resolveMatch();
|
||||
|
||||
await waitFor(() => {
|
||||
// 'stats' should be first because 'stat' is a prefix match on its name
|
||||
// while 'footer' only matches 'stat' via its alias 'statusline'
|
||||
expect(result.current.suggestions[0].label).toBe('stats');
|
||||
expect(result.current.suggestions[1].label).toBe('footer');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sub-Commands', () => {
|
||||
|
||||
@@ -272,45 +272,13 @@ function useCommandSuggestions(
|
||||
}
|
||||
|
||||
if (!signal.aborted) {
|
||||
// Sort potentialSuggestions so that exact name/prefix match comes first,
|
||||
// prioritizing primary name over altNames.
|
||||
const lowerPartial = partial.toLowerCase();
|
||||
// Sort potentialSuggestions so that exact match (by name or altName) comes first
|
||||
const sortedSuggestions = [...potentialSuggestions].sort((a, b) => {
|
||||
// 1. Exact name match
|
||||
const aNameExact = a.name.toLowerCase() === lowerPartial;
|
||||
const bNameExact = b.name.toLowerCase() === lowerPartial;
|
||||
if (aNameExact && !bNameExact) return -1;
|
||||
if (!aNameExact && bNameExact) return 1;
|
||||
|
||||
// 2. Exact altName match
|
||||
const aAltExact =
|
||||
a.altNames?.some((alt) => alt.toLowerCase() === lowerPartial) ||
|
||||
false;
|
||||
const bAltExact =
|
||||
b.altNames?.some((alt) => alt.toLowerCase() === lowerPartial) ||
|
||||
false;
|
||||
if (aAltExact && !bAltExact) return -1;
|
||||
if (!aAltExact && bAltExact) return 1;
|
||||
|
||||
// 3. Prefix name match
|
||||
const aNamePrefix = a.name.toLowerCase().startsWith(lowerPartial);
|
||||
const bNamePrefix = b.name.toLowerCase().startsWith(lowerPartial);
|
||||
if (aNamePrefix && !bNamePrefix) return -1;
|
||||
if (!aNamePrefix && bNamePrefix) return 1;
|
||||
|
||||
// 4. Prefix altName match
|
||||
const aAltPrefix =
|
||||
a.altNames?.some((alt) =>
|
||||
alt.toLowerCase().startsWith(lowerPartial),
|
||||
) || false;
|
||||
const bAltPrefix =
|
||||
b.altNames?.some((alt) =>
|
||||
alt.toLowerCase().startsWith(lowerPartial),
|
||||
) || false;
|
||||
if (aAltPrefix && !bAltPrefix) return -1;
|
||||
if (!aAltPrefix && bAltPrefix) return 1;
|
||||
|
||||
return 0; // Maintain FZF score order for other matches
|
||||
const aIsExact = matchesCommand(a, partial);
|
||||
const bIsExact = matchesCommand(b, partial);
|
||||
if (aIsExact && !bIsExact) return -1;
|
||||
if (!aIsExact && bIsExact) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const finalSuggestions = sortedSuggestions.map((cmd) => {
|
||||
|
||||
@@ -43,7 +43,7 @@ describe('terminal notifications', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('emits notification on non-macOS platforms', async () => {
|
||||
it('returns false without writing on non-macOS platforms', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'linux',
|
||||
configurable: true,
|
||||
@@ -54,8 +54,8 @@ describe('terminal notifications', () => {
|
||||
body: 'b',
|
||||
});
|
||||
|
||||
expect(shown).toBe(true);
|
||||
expect(writeToStdout).toHaveBeenCalled();
|
||||
expect(shown).toBe(false);
|
||||
expect(writeToStdout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns false without writing when disabled', async () => {
|
||||
@@ -69,7 +69,6 @@ describe('terminal notifications', () => {
|
||||
});
|
||||
|
||||
it('emits OSC 9 notification when supported terminal is detected', async () => {
|
||||
vi.stubEnv('WT_SESSION', '');
|
||||
vi.stubEnv('TERM_PROGRAM', 'iTerm.app');
|
||||
|
||||
const shown = await notifyViaTerminal(true, {
|
||||
@@ -127,7 +126,6 @@ describe('terminal notifications', () => {
|
||||
});
|
||||
|
||||
it('strips terminal control sequences and newlines from payload text', async () => {
|
||||
vi.stubEnv('WT_SESSION', '');
|
||||
vi.stubEnv('TERM_PROGRAM', 'iTerm.app');
|
||||
|
||||
const shown = await notifyViaTerminal(true, {
|
||||
|
||||
@@ -75,10 +75,17 @@ export function buildRunEventNotificationContent(
|
||||
|
||||
export function isNotificationsEnabled(settings: LoadedSettings): boolean {
|
||||
const general = settings.merged.general as
|
||||
| { enableNotifications?: boolean }
|
||||
| {
|
||||
enableNotifications?: boolean;
|
||||
enableMacOsNotifications?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
return general?.enableNotifications === true;
|
||||
return (
|
||||
process.platform === 'darwin' &&
|
||||
(general?.enableNotifications === true ||
|
||||
general?.enableMacOsNotifications === true)
|
||||
);
|
||||
}
|
||||
|
||||
function buildTerminalNotificationMessage(
|
||||
@@ -105,7 +112,7 @@ export async function notifyViaTerminal(
|
||||
notificationsEnabled: boolean,
|
||||
content: RunEventNotificationContent,
|
||||
): Promise<boolean> {
|
||||
if (!notificationsEnabled) {
|
||||
if (!notificationsEnabled || process.platform !== 'darwin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import { BrowserManager } from './browserManager.js';
|
||||
import { makeFakeConfig } from '../../test-utils/config.js';
|
||||
import type { Config } from '../../config/config.js';
|
||||
import { injectAutomationOverlay } from './automationOverlay.js';
|
||||
import { injectInputBlocker } from './inputBlocker.js';
|
||||
import { coreEvents } from '../../utils/events.js';
|
||||
|
||||
// Mock the MCP SDK
|
||||
@@ -55,13 +54,6 @@ vi.mock('./automationOverlay.js', () => ({
|
||||
injectAutomationOverlay: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('./inputBlocker.js', () => ({
|
||||
injectInputBlocker: vi.fn().mockResolvedValue(undefined),
|
||||
removeInputBlocker: vi.fn().mockResolvedValue(undefined),
|
||||
suspendInputBlocker: vi.fn().mockResolvedValue(undefined),
|
||||
resumeInputBlocker: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return {
|
||||
@@ -86,7 +78,6 @@ describe('BrowserManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(injectAutomationOverlay).mockClear();
|
||||
vi.mocked(injectInputBlocker).mockClear();
|
||||
vi.spyOn(coreEvents, 'emitFeedback').mockImplementation(() => {});
|
||||
|
||||
// Re-establish consent mock after resetAllMocks
|
||||
@@ -701,66 +692,21 @@ describe('BrowserManager', () => {
|
||||
});
|
||||
|
||||
describe('overlay re-injection in callTool', () => {
|
||||
it('should re-inject overlay and input blocker after click in non-headless mode when input disabling is enabled', async () => {
|
||||
// Enable input disabling in config
|
||||
mockConfig = makeFakeConfig({
|
||||
agents: {
|
||||
overrides: {
|
||||
browser_agent: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
browser: {
|
||||
headless: false,
|
||||
disableUserInput: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
it('should re-inject overlay after click in non-headless mode', async () => {
|
||||
const manager = new BrowserManager(mockConfig);
|
||||
await manager.callTool('click', { uid: '1_2' });
|
||||
|
||||
expect(injectAutomationOverlay).toHaveBeenCalledWith(manager, undefined);
|
||||
expect(injectInputBlocker).toHaveBeenCalledWith(manager, undefined);
|
||||
});
|
||||
|
||||
it('should re-inject overlay and input blocker after navigate_page in non-headless mode when input disabling is enabled', async () => {
|
||||
mockConfig = makeFakeConfig({
|
||||
agents: {
|
||||
overrides: {
|
||||
browser_agent: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
browser: {
|
||||
headless: false,
|
||||
disableUserInput: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
it('should re-inject overlay after navigate_page in non-headless mode', async () => {
|
||||
const manager = new BrowserManager(mockConfig);
|
||||
await manager.callTool('navigate_page', { url: 'https://example.com' });
|
||||
|
||||
expect(injectAutomationOverlay).toHaveBeenCalledWith(manager, undefined);
|
||||
expect(injectInputBlocker).toHaveBeenCalledWith(manager, undefined);
|
||||
});
|
||||
|
||||
it('should re-inject overlay and input blocker after click_at, new_page, press_key, handle_dialog when input disabling is enabled', async () => {
|
||||
mockConfig = makeFakeConfig({
|
||||
agents: {
|
||||
overrides: {
|
||||
browser_agent: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
browser: {
|
||||
headless: false,
|
||||
disableUserInput: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
it('should re-inject overlay after click_at, new_page, press_key, handle_dialog', async () => {
|
||||
const manager = new BrowserManager(mockConfig);
|
||||
for (const tool of [
|
||||
'click_at',
|
||||
@@ -769,15 +715,12 @@ describe('BrowserManager', () => {
|
||||
'handle_dialog',
|
||||
]) {
|
||||
vi.mocked(injectAutomationOverlay).mockClear();
|
||||
vi.mocked(injectInputBlocker).mockClear();
|
||||
await manager.callTool(tool, {});
|
||||
expect(injectAutomationOverlay).toHaveBeenCalledTimes(1);
|
||||
expect(injectInputBlocker).toHaveBeenCalledTimes(1);
|
||||
expect(injectInputBlocker).toHaveBeenCalledWith(manager, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it('should NOT re-inject overlay or input blocker after read-only tools', async () => {
|
||||
it('should NOT re-inject overlay after read-only tools', async () => {
|
||||
const manager = new BrowserManager(mockConfig);
|
||||
for (const tool of [
|
||||
'take_snapshot',
|
||||
@@ -786,10 +729,8 @@ describe('BrowserManager', () => {
|
||||
'fill',
|
||||
]) {
|
||||
vi.mocked(injectAutomationOverlay).mockClear();
|
||||
vi.mocked(injectInputBlocker).mockClear();
|
||||
await manager.callTool(tool, {});
|
||||
expect(injectAutomationOverlay).not.toHaveBeenCalled();
|
||||
expect(injectInputBlocker).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -215,10 +215,6 @@ export class BrowserManager {
|
||||
// Re-inject the automation overlay and input blocker after tools that
|
||||
// can cause a full-page navigation. chrome-devtools-mcp emits no MCP
|
||||
// notifications, so callTool() is the only interception point.
|
||||
//
|
||||
// The input blocker injection is idempotent: the injected function
|
||||
// reuses the existing DOM element when present and only recreates
|
||||
// it when navigation has actually replaced the page DOM.
|
||||
if (
|
||||
!result.isError &&
|
||||
POTENTIALLY_NAVIGATING_TOOLS.has(toolName) &&
|
||||
@@ -228,8 +224,17 @@ export class BrowserManager {
|
||||
if (this.shouldInjectOverlay) {
|
||||
await injectAutomationOverlay(this, signal);
|
||||
}
|
||||
if (this.shouldDisableInput) {
|
||||
await injectInputBlocker(this, signal);
|
||||
// Only re-inject the input blocker for tools that *reliably*
|
||||
// replace the page DOM (navigate_page, new_page, select_page).
|
||||
// click/click_at are handled by pointer-events suspend/resume
|
||||
// in mcpToolWrapper — no full re-inject roundtrip needed.
|
||||
// press_key/handle_dialog only sometimes navigate.
|
||||
const reliableNavigation =
|
||||
toolName === 'navigate_page' ||
|
||||
toolName === 'new_page' ||
|
||||
toolName === 'select_page';
|
||||
if (this.shouldDisableInput && reliableNavigation) {
|
||||
await injectInputBlocker(this);
|
||||
}
|
||||
} catch {
|
||||
// Never let overlay/blocker failures interrupt the tool result
|
||||
|
||||
@@ -5,12 +5,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
injectInputBlocker,
|
||||
removeInputBlocker,
|
||||
suspendInputBlocker,
|
||||
resumeInputBlocker,
|
||||
} from './inputBlocker.js';
|
||||
import { injectInputBlocker, removeInputBlocker } from './inputBlocker.js';
|
||||
import type { BrowserManager } from './browserManager.js';
|
||||
|
||||
describe('inputBlocker', () => {
|
||||
@@ -33,7 +28,6 @@ describe('inputBlocker', () => {
|
||||
{
|
||||
function: expect.stringContaining('__gemini_input_blocker'),
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -83,29 +77,6 @@ describe('inputBlocker', () => {
|
||||
injectInputBlocker(mockBrowserManager),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should be safe to call multiple times (idempotent injection)', async () => {
|
||||
await injectInputBlocker(mockBrowserManager);
|
||||
await injectInputBlocker(mockBrowserManager);
|
||||
|
||||
expect(mockBrowserManager.callTool).toHaveBeenCalledTimes(2);
|
||||
expect(mockBrowserManager.callTool).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'evaluate_script',
|
||||
expect.objectContaining({
|
||||
function: expect.stringContaining('__gemini_input_blocker'),
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(mockBrowserManager.callTool).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'evaluate_script',
|
||||
expect.objectContaining({
|
||||
function: expect.stringContaining('__gemini_input_blocker'),
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeInputBlocker', () => {
|
||||
@@ -117,7 +88,6 @@ describe('inputBlocker', () => {
|
||||
{
|
||||
function: expect.stringContaining('__gemini_input_blocker'),
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -140,38 +110,4 @@ describe('inputBlocker', () => {
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('suspendInputBlocker and resumeInputBlocker', () => {
|
||||
it('should not throw when blocker element is missing', async () => {
|
||||
// Simulate evaluate_script resolving successfully even if the DOM element is absent.
|
||||
mockBrowserManager.callTool = vi.fn().mockResolvedValue({
|
||||
content: [{ type: 'text', text: 'Script ran on page and returned:' }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
suspendInputBlocker(mockBrowserManager),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
resumeInputBlocker(mockBrowserManager),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(mockBrowserManager.callTool).toHaveBeenCalledTimes(2);
|
||||
expect(mockBrowserManager.callTool).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'evaluate_script',
|
||||
expect.objectContaining({
|
||||
function: expect.stringContaining('__gemini_input_blocker'),
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(mockBrowserManager.callTool).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'evaluate_script',
|
||||
expect.objectContaining({
|
||||
function: expect.stringContaining('__gemini_input_blocker'),
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -198,14 +198,11 @@ const RESUME_BLOCKER_FUNCTION = `() => {
|
||||
*/
|
||||
export async function injectInputBlocker(
|
||||
browserManager: BrowserManager,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await browserManager.callTool(
|
||||
'evaluate_script',
|
||||
{ function: INPUT_BLOCKER_FUNCTION },
|
||||
signal,
|
||||
);
|
||||
await browserManager.callTool('evaluate_script', {
|
||||
function: INPUT_BLOCKER_FUNCTION,
|
||||
});
|
||||
debugLogger.log('Input blocker injected successfully');
|
||||
} catch (error) {
|
||||
// Log but don't throw - input blocker is a UX enhancement, not critical functionality
|
||||
@@ -225,14 +222,11 @@ export async function injectInputBlocker(
|
||||
*/
|
||||
export async function removeInputBlocker(
|
||||
browserManager: BrowserManager,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await browserManager.callTool(
|
||||
'evaluate_script',
|
||||
{ function: REMOVE_BLOCKER_FUNCTION },
|
||||
signal,
|
||||
);
|
||||
await browserManager.callTool('evaluate_script', {
|
||||
function: REMOVE_BLOCKER_FUNCTION,
|
||||
});
|
||||
debugLogger.log('Input blocker removed successfully');
|
||||
} catch (error) {
|
||||
// Log but don't throw - removal failure is not critical
|
||||
@@ -250,14 +244,11 @@ export async function removeInputBlocker(
|
||||
*/
|
||||
export async function suspendInputBlocker(
|
||||
browserManager: BrowserManager,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await browserManager.callTool(
|
||||
'evaluate_script',
|
||||
{ function: SUSPEND_BLOCKER_FUNCTION },
|
||||
signal,
|
||||
);
|
||||
await browserManager.callTool('evaluate_script', {
|
||||
function: SUSPEND_BLOCKER_FUNCTION,
|
||||
});
|
||||
} catch {
|
||||
// Non-critical — tool call will still attempt to proceed
|
||||
}
|
||||
@@ -269,14 +260,11 @@ export async function suspendInputBlocker(
|
||||
*/
|
||||
export async function resumeInputBlocker(
|
||||
browserManager: BrowserManager,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await browserManager.callTool(
|
||||
'evaluate_script',
|
||||
{ function: RESUME_BLOCKER_FUNCTION },
|
||||
signal,
|
||||
);
|
||||
await browserManager.callTool('evaluate_script', {
|
||||
function: RESUME_BLOCKER_FUNCTION,
|
||||
});
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
|
||||
@@ -224,7 +224,6 @@ describe('mcpToolWrapper', () => {
|
||||
expect.objectContaining({
|
||||
function: expect.stringContaining('__gemini_input_blocker'),
|
||||
}),
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
|
||||
// Second call: click
|
||||
@@ -242,7 +241,6 @@ describe('mcpToolWrapper', () => {
|
||||
expect.objectContaining({
|
||||
function: expect.stringContaining('__gemini_input_blocker'),
|
||||
}),
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ class McpToolInvocation extends BaseToolInvocation<
|
||||
// chrome-devtools-mcp's interactability checks pass.
|
||||
// Only toggles pointer-events CSS — no DOM change, no flicker.
|
||||
if (this.needsBlockerSuspend) {
|
||||
await suspendInputBlocker(this.browserManager, signal);
|
||||
await suspendInputBlocker(this.browserManager);
|
||||
}
|
||||
|
||||
const result: McpToolCallResult = await this.browserManager.callTool(
|
||||
@@ -155,7 +155,7 @@ class McpToolInvocation extends BaseToolInvocation<
|
||||
|
||||
// Resume input blocker after interactive tool completes.
|
||||
if (this.needsBlockerSuspend) {
|
||||
await resumeInputBlocker(this.browserManager, signal);
|
||||
await resumeInputBlocker(this.browserManager);
|
||||
}
|
||||
|
||||
if (result.isError) {
|
||||
@@ -181,7 +181,7 @@ class McpToolInvocation extends BaseToolInvocation<
|
||||
|
||||
// Resume on error path too so the blocker is always restored
|
||||
if (this.needsBlockerSuspend) {
|
||||
await resumeInputBlocker(this.browserManager, signal).catch(() => {});
|
||||
await resumeInputBlocker(this.browserManager).catch(() => {});
|
||||
}
|
||||
|
||||
debugLogger.error(`MCP tool ${this.toolName} failed: ${errorMsg}`);
|
||||
|
||||
@@ -10,8 +10,6 @@ import { SandboxPolicyManager } from '../policy/sandboxPolicyManager.js';
|
||||
import { inspect } from 'node:util';
|
||||
import process from 'node:process';
|
||||
import { z } from 'zod';
|
||||
import type { ConversationRecord } from '../services/chatRecordingService.js';
|
||||
export type { ConversationRecord };
|
||||
import {
|
||||
AuthType,
|
||||
createContentGenerator,
|
||||
@@ -233,25 +231,6 @@ export interface ResolvedExtensionSetting {
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface TrajectoryProvider {
|
||||
/** Prefix used to identify sessions from this provider (e.g., 'ext:') */
|
||||
prefix: string;
|
||||
/** Optional display name for UI Tabs */
|
||||
displayName?: string;
|
||||
/** Return an array of conversational tags/ids */
|
||||
listSessions(workspaceUri?: string): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
mtime: string;
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
messageCount?: number;
|
||||
}>
|
||||
>;
|
||||
/** Load a single conversation payload */
|
||||
loadSession(id: string): Promise<ConversationRecord | null>;
|
||||
}
|
||||
|
||||
export interface AgentRunConfig {
|
||||
maxTimeMinutes?: number;
|
||||
maxTurns?: number;
|
||||
@@ -407,8 +386,6 @@ export interface GeminiCLIExtension {
|
||||
* Used to migrate an extension to a new repository source.
|
||||
*/
|
||||
migratedTo?: string;
|
||||
/** Loaded JS module for trajectory decoding */
|
||||
trajectoryProviderModule?: TrajectoryProvider;
|
||||
}
|
||||
|
||||
export interface ExtensionInstallMetadata {
|
||||
@@ -2436,7 +2413,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
|
||||
if (isPlanModeTransition || isYoloModeTransition) {
|
||||
if (this._geminiClient?.isInitialized()) {
|
||||
this._geminiClient.clearCurrentSequenceModel();
|
||||
this._geminiClient.setTools().catch((err) => {
|
||||
debugLogger.error('Failed to update tools', err);
|
||||
});
|
||||
|
||||
@@ -132,10 +132,6 @@ export class GeminiClient {
|
||||
this.updateSystemInstruction();
|
||||
};
|
||||
|
||||
clearCurrentSequenceModel(): void {
|
||||
this.currentSequenceModel = null;
|
||||
}
|
||||
|
||||
// Hook state to deduplicate BeforeAgent calls and track response for
|
||||
// AfterAgent
|
||||
private hookStateMap = new Map<
|
||||
|
||||
@@ -285,7 +285,6 @@ export async function createPolicyEngineConfig(
|
||||
settings: PolicySettings,
|
||||
approvalMode: ApprovalMode,
|
||||
defaultPoliciesDir?: string,
|
||||
interactive: boolean = true,
|
||||
): Promise<PolicyEngineConfig> {
|
||||
const systemPoliciesDir = path.resolve(Storage.getSystemPoliciesDir());
|
||||
const userPoliciesDir = path.resolve(Storage.getUserPoliciesDir());
|
||||
@@ -525,10 +524,7 @@ export async function createPolicyEngineConfig(
|
||||
return {
|
||||
rules,
|
||||
checkers,
|
||||
defaultDecision: interactive
|
||||
? PolicyDecision.ASK_USER
|
||||
: PolicyDecision.DENY,
|
||||
nonInteractive: !interactive,
|
||||
defaultDecision: PolicyDecision.ASK_USER,
|
||||
approvalMode,
|
||||
disableAlwaysAllow: settings.disableAlwaysAllow,
|
||||
};
|
||||
|
||||
@@ -6,10 +6,3 @@
|
||||
toolName = "discovered_tool_*"
|
||||
decision = "ask_user"
|
||||
priority = 10
|
||||
interactive = true
|
||||
|
||||
[[rule]]
|
||||
toolName = "discovered_tool_*"
|
||||
decision = "deny"
|
||||
priority = 10
|
||||
interactive = false
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
# Policy for non-interactive mode.
|
||||
# ASK_USER is strictly forbidden here.
|
||||
[[rule]]
|
||||
toolName = "ask_user"
|
||||
decision = "deny"
|
||||
priority = 999
|
||||
interactive = false
|
||||
@@ -86,16 +86,6 @@ toolAnnotations = { readOnlyHint = true }
|
||||
decision = "ask_user"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
interactive = true
|
||||
|
||||
[[rule]]
|
||||
toolName = "*"
|
||||
mcpName = "*"
|
||||
toolAnnotations = { readOnlyHint = true }
|
||||
decision = "deny"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
interactive = false
|
||||
|
||||
[[rule]]
|
||||
toolName = [
|
||||
@@ -118,14 +108,6 @@ toolName = ["ask_user", "save_memory"]
|
||||
decision = "ask_user"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
interactive = true
|
||||
|
||||
[[rule]]
|
||||
toolName = ["ask_user", "save_memory"]
|
||||
decision = "deny"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
interactive = false
|
||||
|
||||
# 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.
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
toolName = "replace"
|
||||
decision = "ask_user"
|
||||
priority = 10
|
||||
interactive = true
|
||||
|
||||
[[rule]]
|
||||
toolName = "replace"
|
||||
@@ -48,25 +47,21 @@ required_context = ["environment"]
|
||||
toolName = "save_memory"
|
||||
decision = "ask_user"
|
||||
priority = 10
|
||||
interactive = true
|
||||
|
||||
[[rule]]
|
||||
toolName = "run_shell_command"
|
||||
decision = "ask_user"
|
||||
priority = 10
|
||||
interactive = true
|
||||
|
||||
[[rule]]
|
||||
toolName = "write_file"
|
||||
decision = "ask_user"
|
||||
priority = 10
|
||||
interactive = true
|
||||
|
||||
[[rule]]
|
||||
toolName = "activate_skill"
|
||||
decision = "ask_user"
|
||||
priority = 10
|
||||
interactive = true
|
||||
|
||||
[[rule]]
|
||||
toolName = "write_file"
|
||||
@@ -89,19 +84,3 @@ modes = ["autoEdit"]
|
||||
toolName = "web_fetch"
|
||||
decision = "ask_user"
|
||||
priority = 10
|
||||
interactive = true
|
||||
|
||||
# Headless Denial Rule (Priority 10)
|
||||
# Ensures that tools that normally default to ASK_USER are denied in non-interactive mode.
|
||||
[[rule]]
|
||||
toolName = [
|
||||
"replace",
|
||||
"save_memory",
|
||||
"run_shell_command",
|
||||
"write_file",
|
||||
"activate_skill",
|
||||
"web_fetch"
|
||||
]
|
||||
decision = "deny"
|
||||
priority = 10
|
||||
interactive = false
|
||||
|
||||
@@ -30,12 +30,12 @@
|
||||
|
||||
# Ask-user tool always requires user interaction, even in YOLO mode.
|
||||
# This ensures the model can gather user preferences/decisions when needed.
|
||||
# Note: In non-interactive mode, this decision is converted to DENY by the policy engine.
|
||||
[[rule]]
|
||||
toolName = "ask_user"
|
||||
decision = "ask_user"
|
||||
priority = 999
|
||||
modes = ["yolo"]
|
||||
interactive = true
|
||||
|
||||
# Plan mode transitions are blocked in YOLO mode to maintain state consistency
|
||||
# and because planning currently requires human interaction (plan approval),
|
||||
|
||||
@@ -293,22 +293,8 @@ describe('PolicyEngine', () => {
|
||||
const config: PolicyEngineConfig = {
|
||||
nonInteractive: true,
|
||||
rules: [
|
||||
{
|
||||
toolName: 'interactive-tool',
|
||||
decision: PolicyDecision.ASK_USER,
|
||||
interactive: true,
|
||||
},
|
||||
{
|
||||
toolName: 'interactive-tool',
|
||||
decision: PolicyDecision.DENY,
|
||||
interactive: false,
|
||||
},
|
||||
{ toolName: 'interactive-tool', decision: PolicyDecision.ASK_USER },
|
||||
{ toolName: 'allowed-tool', decision: PolicyDecision.ALLOW },
|
||||
{
|
||||
toolName: 'ask_user',
|
||||
decision: PolicyDecision.DENY,
|
||||
interactive: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1272,51 +1258,6 @@ describe('PolicyEngine', () => {
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
|
||||
it('should NOT automatically DENY redirected shell commands in non-interactive mode if rules permit it', async () => {
|
||||
const toolName = 'run_shell_command';
|
||||
const command = 'ls > out.txt';
|
||||
|
||||
const rules: PolicyRule[] = [
|
||||
{
|
||||
toolName,
|
||||
decision: PolicyDecision.ALLOW,
|
||||
allowRedirection: true,
|
||||
},
|
||||
];
|
||||
|
||||
engine = new PolicyEngine({ rules, nonInteractive: true });
|
||||
|
||||
expect(
|
||||
(await engine.check({ name: toolName, args: { command } }, undefined))
|
||||
.decision,
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
|
||||
it('should respect DENY rules for redirected shell commands in non-interactive mode', async () => {
|
||||
const toolName = 'run_shell_command';
|
||||
const command = 'ls > out.txt';
|
||||
|
||||
const rules: PolicyRule[] = [
|
||||
{
|
||||
toolName,
|
||||
decision: PolicyDecision.ASK_USER,
|
||||
interactive: true,
|
||||
},
|
||||
{
|
||||
toolName,
|
||||
decision: PolicyDecision.DENY,
|
||||
interactive: false,
|
||||
},
|
||||
];
|
||||
|
||||
engine = new PolicyEngine({ rules, nonInteractive: true });
|
||||
|
||||
expect(
|
||||
(await engine.check({ name: toolName, args: { command } }, undefined))
|
||||
.decision,
|
||||
).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
|
||||
it('should NOT downgrade ALLOW to ASK_USER for quoted redirection chars', async () => {
|
||||
const rules: PolicyRule[] = [
|
||||
{
|
||||
@@ -1482,25 +1423,21 @@ describe('PolicyEngine', () => {
|
||||
expect(result.decision).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
|
||||
it('should respect explicit DENY rules for redirected shell commands in non-interactive mode', async () => {
|
||||
it('should DENY redirected shell commands in non-interactive mode', async () => {
|
||||
const config: PolicyEngineConfig = {
|
||||
nonInteractive: true,
|
||||
rules: [
|
||||
{
|
||||
toolName: 'run_shell_command',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
interactive: true,
|
||||
},
|
||||
{
|
||||
toolName: 'run_shell_command',
|
||||
decision: PolicyDecision.DENY,
|
||||
interactive: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
engine = new PolicyEngine(config);
|
||||
|
||||
// Redirected command should be DENIED in non-interactive mode
|
||||
// (Normally ASK_USER, but ASK_USER -> DENY in non-interactive)
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
@@ -2278,6 +2215,34 @@ describe('PolicyEngine', () => {
|
||||
const result = await engine.check({ name: 'tool' }, undefined);
|
||||
expect(result.decision).toBe(PolicyDecision.ASK_USER);
|
||||
});
|
||||
|
||||
it('should DENY if checker returns ASK_USER in non-interactive mode', async () => {
|
||||
const rules: PolicyRule[] = [
|
||||
{ toolName: 'tool', decision: PolicyDecision.ALLOW },
|
||||
];
|
||||
const checkers: SafetyCheckerRule[] = [
|
||||
{
|
||||
toolName: '*',
|
||||
checker: {
|
||||
type: 'in-process',
|
||||
name: InProcessCheckerType.ALLOWED_PATH,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
engine = new PolicyEngine(
|
||||
{ rules, checkers, nonInteractive: true },
|
||||
mockCheckerRunner,
|
||||
);
|
||||
|
||||
vi.mocked(mockCheckerRunner.runChecker).mockResolvedValue({
|
||||
decision: SafetyCheckDecision.ASK_USER,
|
||||
reason: 'Suspicious path',
|
||||
});
|
||||
|
||||
const result = await engine.check({ name: 'tool' }, undefined);
|
||||
expect(result.decision).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExcludedTools', () => {
|
||||
@@ -2380,42 +2345,18 @@ describe('PolicyEngine', () => {
|
||||
expected: [],
|
||||
},
|
||||
{
|
||||
name: 'should include tools in exclusion list only if explicitly denied in non-interactive mode',
|
||||
name: 'should NOT include ASK_USER tools even in non-interactive mode',
|
||||
rules: [
|
||||
{
|
||||
toolName: 'tool1',
|
||||
decision: PolicyDecision.ASK_USER,
|
||||
modes: [ApprovalMode.DEFAULT],
|
||||
interactive: true,
|
||||
},
|
||||
{
|
||||
toolName: 'tool1',
|
||||
decision: PolicyDecision.DENY,
|
||||
modes: [ApprovalMode.DEFAULT],
|
||||
interactive: false,
|
||||
},
|
||||
],
|
||||
nonInteractive: true,
|
||||
allToolNames: ['tool1'],
|
||||
expected: ['tool1'],
|
||||
},
|
||||
{
|
||||
name: 'should specifically exclude ask_user tool in non-interactive mode',
|
||||
rules: [
|
||||
{
|
||||
toolName: 'ask_user',
|
||||
decision: PolicyDecision.DENY,
|
||||
interactive: false,
|
||||
},
|
||||
{
|
||||
toolName: 'read_file',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
},
|
||||
],
|
||||
nonInteractive: true,
|
||||
allToolNames: ['ask_user', 'read_file'],
|
||||
expected: ['ask_user'],
|
||||
},
|
||||
{
|
||||
name: 'should ignore rules with argsPattern',
|
||||
rules: [
|
||||
|
||||
@@ -244,10 +244,8 @@ export class PolicyEngine {
|
||||
}
|
||||
}
|
||||
|
||||
this.defaultDecision = config.defaultDecision ?? PolicyDecision.ASK_USER;
|
||||
this.nonInteractive = config.nonInteractive ?? false;
|
||||
this.defaultDecision =
|
||||
config.defaultDecision ??
|
||||
(this.nonInteractive ? PolicyDecision.DENY : PolicyDecision.ASK_USER);
|
||||
this.disableAlwaysAllow = config.disableAlwaysAllow ?? false;
|
||||
this.checkerRunner = checkerRunner;
|
||||
this.approvalMode = config.approvalMode ?? ApprovalMode.DEFAULT;
|
||||
@@ -342,7 +340,7 @@ export class PolicyEngine {
|
||||
): Promise<CheckResult> {
|
||||
if (!command) {
|
||||
return {
|
||||
decision: ruleDecision,
|
||||
decision: this.applyNonInteractiveMode(ruleDecision),
|
||||
rule,
|
||||
};
|
||||
}
|
||||
@@ -365,13 +363,13 @@ export class PolicyEngine {
|
||||
}
|
||||
|
||||
debugLogger.debug(
|
||||
`[PolicyEngine.check] Command parsing failed for: ${command}. Falling back to ${this.defaultDecision}.`,
|
||||
`[PolicyEngine.check] Command parsing failed for: ${command}. Falling back to ASK_USER.`,
|
||||
);
|
||||
|
||||
// Parsing logic failed, we can't trust it. Use default decision ASK_USER (or DENY in non-interactive).
|
||||
// Parsing logic failed, we can't trust it. Force ASK_USER (or DENY).
|
||||
// We return the rule that matched so the evaluation loop terminates.
|
||||
return {
|
||||
decision: this.defaultDecision,
|
||||
decision: this.applyNonInteractiveMode(PolicyDecision.ASK_USER),
|
||||
rule,
|
||||
};
|
||||
}
|
||||
@@ -468,7 +466,7 @@ export class PolicyEngine {
|
||||
}
|
||||
|
||||
return {
|
||||
decision: aggregateDecision,
|
||||
decision: this.applyNonInteractiveMode(aggregateDecision),
|
||||
// If we stayed at ALLOW, we return the original rule (if any).
|
||||
// If we downgraded, we return the responsible rule (or undefined if implicit).
|
||||
rule: aggregateDecision === ruleDecision ? rule : responsibleRule,
|
||||
@@ -476,7 +474,7 @@ export class PolicyEngine {
|
||||
}
|
||||
|
||||
return {
|
||||
decision: ruleDecision,
|
||||
decision: this.applyNonInteractiveMode(ruleDecision),
|
||||
rule,
|
||||
};
|
||||
}
|
||||
@@ -599,7 +597,7 @@ export class PolicyEngine {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
decision = rule.decision;
|
||||
decision = this.applyNonInteractiveMode(rule.decision);
|
||||
matchedRule = rule;
|
||||
break;
|
||||
}
|
||||
@@ -643,7 +641,7 @@ export class PolicyEngine {
|
||||
decision = shellResult.decision;
|
||||
matchedRule = shellResult.rule;
|
||||
} else {
|
||||
decision = this.defaultDecision;
|
||||
decision = this.applyNonInteractiveMode(this.defaultDecision);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -699,7 +697,7 @@ export class PolicyEngine {
|
||||
}
|
||||
|
||||
return {
|
||||
decision,
|
||||
decision: this.applyNonInteractiveMode(decision),
|
||||
rule: matchedRule,
|
||||
};
|
||||
}
|
||||
@@ -868,7 +866,7 @@ export class PolicyEngine {
|
||||
continue;
|
||||
} else {
|
||||
// Unconditional rule for this tool
|
||||
const decision = rule.decision;
|
||||
const decision = this.applyNonInteractiveMode(rule.decision);
|
||||
staticallyExcluded = decision === PolicyDecision.DENY;
|
||||
matchFound = true;
|
||||
break;
|
||||
@@ -878,7 +876,7 @@ export class PolicyEngine {
|
||||
|
||||
if (!matchFound) {
|
||||
// Fallback to default decision if no rule matches
|
||||
const defaultDec = this.defaultDecision;
|
||||
const defaultDec = this.applyNonInteractiveMode(this.defaultDecision);
|
||||
if (defaultDec === PolicyDecision.DENY) {
|
||||
staticallyExcluded = true;
|
||||
}
|
||||
@@ -891,4 +889,12 @@ export class PolicyEngine {
|
||||
|
||||
return excludedTools;
|
||||
}
|
||||
|
||||
private applyNonInteractiveMode(decision: PolicyDecision): PolicyDecision {
|
||||
// In non-interactive mode, ASK_USER becomes DENY
|
||||
if (this.nonInteractive && decision === PolicyDecision.ASK_USER) {
|
||||
return PolicyDecision.DENY;
|
||||
}
|
||||
return decision;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { LinuxSandboxManager } from './LinuxSandboxManager.js';
|
||||
import * as sandboxManager from '../../services/sandboxManager.js';
|
||||
import type { SandboxRequest } from '../../services/sandboxManager.js';
|
||||
import fs from 'node:fs';
|
||||
import * as shellUtils from '../../utils/shell-utils.js';
|
||||
|
||||
vi.mock('node:fs', async () => {
|
||||
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
|
||||
@@ -18,43 +18,18 @@ vi.mock('node:fs', async () => {
|
||||
// @ts-expect-error - Property 'default' does not exist on type 'typeof import("node:fs")'
|
||||
...actual.default,
|
||||
existsSync: vi.fn(() => true),
|
||||
realpathSync: vi.fn((p) => p.toString()),
|
||||
statSync: vi.fn(() => ({ isDirectory: () => true }) as fs.Stats),
|
||||
realpathSync: vi.fn((p: string | Buffer) => p.toString()),
|
||||
mkdirSync: vi.fn(),
|
||||
mkdtempSync: vi.fn((prefix: string) => prefix + 'mocked'),
|
||||
openSync: vi.fn(),
|
||||
closeSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
readdirSync: vi.fn(() => []),
|
||||
chmodSync: vi.fn(),
|
||||
unlinkSync: vi.fn(),
|
||||
rmSync: vi.fn(),
|
||||
},
|
||||
existsSync: vi.fn(() => true),
|
||||
realpathSync: vi.fn((p) => p.toString()),
|
||||
statSync: vi.fn(() => ({ isDirectory: () => true }) as fs.Stats),
|
||||
realpathSync: vi.fn((p: string | Buffer) => p.toString()),
|
||||
mkdirSync: vi.fn(),
|
||||
mkdtempSync: vi.fn((prefix: string) => prefix + 'mocked'),
|
||||
openSync: vi.fn(),
|
||||
closeSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
readdirSync: vi.fn(() => []),
|
||||
chmodSync: vi.fn(),
|
||||
unlinkSync: vi.fn(),
|
||||
rmSync: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../utils/shell-utils.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../utils/shell-utils.js')>();
|
||||
return {
|
||||
...actual,
|
||||
spawnAsync: vi.fn(() =>
|
||||
Promise.resolve({ status: 0, stdout: Buffer.from('') }),
|
||||
),
|
||||
initializeShellParsers: vi.fn(),
|
||||
isStrictlyApproved: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -73,12 +48,8 @@ describe('LinuxSandboxManager', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const getBwrapArgs = async (
|
||||
req: SandboxRequest,
|
||||
customManager?: LinuxSandboxManager,
|
||||
) => {
|
||||
const mgr = customManager || manager;
|
||||
const result = await mgr.prepareCommand(req);
|
||||
const getBwrapArgs = async (req: SandboxRequest) => {
|
||||
const result = await manager.prepareCommand(req);
|
||||
expect(result.program).toBe('sh');
|
||||
expect(result.args[0]).toBe('-c');
|
||||
expect(result.args[1]).toBe(
|
||||
@@ -89,6 +60,41 @@ describe('LinuxSandboxManager', () => {
|
||||
return result.args.slice(4);
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to verify only the dynamic, policy-based binds (e.g. allowedPaths, forbiddenPaths).
|
||||
* It asserts that the base workspace and governance files are present exactly once,
|
||||
* then strips them away, leaving only the dynamic binds for a focused, non-brittle assertion.
|
||||
*/
|
||||
const expectDynamicBinds = (
|
||||
bwrapArgs: string[],
|
||||
expectedDynamicBinds: string[],
|
||||
) => {
|
||||
const bindsIndex = bwrapArgs.indexOf('--seccomp');
|
||||
const allBinds = bwrapArgs.slice(bwrapArgs.indexOf('--bind'), bindsIndex);
|
||||
|
||||
const baseBinds = [
|
||||
'--bind',
|
||||
workspace,
|
||||
workspace,
|
||||
'--ro-bind',
|
||||
`${workspace}/.gitignore`,
|
||||
`${workspace}/.gitignore`,
|
||||
'--ro-bind',
|
||||
`${workspace}/.geminiignore`,
|
||||
`${workspace}/.geminiignore`,
|
||||
'--ro-bind',
|
||||
`${workspace}/.git`,
|
||||
`${workspace}/.git`,
|
||||
];
|
||||
|
||||
// Verify the base binds are present exactly at the beginning
|
||||
expect(allBinds.slice(0, baseBinds.length)).toEqual(baseBinds);
|
||||
|
||||
// Extract the remaining dynamic binds
|
||||
const dynamicBinds = allBinds.slice(baseBinds.length);
|
||||
expect(dynamicBinds).toEqual(expectedDynamicBinds);
|
||||
};
|
||||
|
||||
describe('prepareCommand', () => {
|
||||
it('should correctly format the base command and args', async () => {
|
||||
const bwrapArgs = await getBwrapArgs({
|
||||
@@ -111,7 +117,7 @@ describe('LinuxSandboxManager', () => {
|
||||
'/proc',
|
||||
'--tmpfs',
|
||||
'/tmp',
|
||||
'--ro-bind-try',
|
||||
'--bind',
|
||||
workspace,
|
||||
workspace,
|
||||
'--ro-bind',
|
||||
@@ -131,73 +137,6 @@ describe('LinuxSandboxManager', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('binds workspace read-write when readonly is false', async () => {
|
||||
const customManager = new LinuxSandboxManager({
|
||||
workspace,
|
||||
modeConfig: { readonly: false },
|
||||
});
|
||||
const bwrapArgs = await getBwrapArgs(
|
||||
{
|
||||
command: 'ls',
|
||||
args: [],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
},
|
||||
customManager,
|
||||
);
|
||||
|
||||
expect(bwrapArgs).toContain('--bind-try');
|
||||
expect(bwrapArgs).toContain(workspace);
|
||||
});
|
||||
|
||||
it('maps network permissions to --share-net', async () => {
|
||||
const bwrapArgs = await getBwrapArgs({
|
||||
command: 'curl',
|
||||
args: [],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
policy: { additionalPermissions: { network: true } },
|
||||
});
|
||||
|
||||
expect(bwrapArgs).toContain('--share-net');
|
||||
});
|
||||
|
||||
it('maps explicit write permissions to --bind-try', async () => {
|
||||
const bwrapArgs = await getBwrapArgs({
|
||||
command: 'touch',
|
||||
args: [],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
policy: {
|
||||
additionalPermissions: {
|
||||
fileSystem: { write: ['/home/user/workspace/out/dir'] },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const index = bwrapArgs.indexOf('--bind-try');
|
||||
expect(index).not.toBe(-1);
|
||||
expect(bwrapArgs[index + 1]).toBe('/home/user/workspace/out/dir');
|
||||
});
|
||||
|
||||
it('rejects overrides in plan mode', async () => {
|
||||
const customManager = new LinuxSandboxManager({
|
||||
workspace,
|
||||
modeConfig: { allowOverrides: false },
|
||||
});
|
||||
await expect(
|
||||
customManager.prepareCommand({
|
||||
command: 'ls',
|
||||
args: [],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
policy: { additionalPermissions: { network: true } },
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
/Cannot override readonly\/network\/filesystem restrictions in Plan mode/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should correctly pass through the cwd to the resulting command', async () => {
|
||||
const req: SandboxRequest = {
|
||||
command: 'ls',
|
||||
@@ -245,7 +184,12 @@ describe('LinuxSandboxManager', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(bwrapArgs).toContain('--share-net');
|
||||
expect(bwrapArgs).toContain('--unshare-user');
|
||||
expect(bwrapArgs).toContain('--unshare-ipc');
|
||||
expect(bwrapArgs).toContain('--unshare-pid');
|
||||
expect(bwrapArgs).toContain('--unshare-uts');
|
||||
expect(bwrapArgs).toContain('--unshare-cgroup');
|
||||
expect(bwrapArgs).not.toContain('--unshare-all');
|
||||
});
|
||||
|
||||
describe('governance files', () => {
|
||||
@@ -308,32 +252,15 @@ describe('LinuxSandboxManager', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(bwrapArgs).toContain('--bind-try');
|
||||
expect(bwrapArgs[bwrapArgs.indexOf('/tmp/cache') - 1]).toBe(
|
||||
// Verify the specific bindings were added correctly
|
||||
expectDynamicBinds(bwrapArgs, [
|
||||
'--bind-try',
|
||||
);
|
||||
expect(bwrapArgs[bwrapArgs.indexOf('/opt/tools') - 1]).toBe(
|
||||
'/tmp/cache',
|
||||
'/tmp/cache',
|
||||
'--bind-try',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not grant read-write access to allowedPaths inside the workspace when readonly mode is active', async () => {
|
||||
const manager = new LinuxSandboxManager({
|
||||
workspace,
|
||||
modeConfig: { readonly: true },
|
||||
});
|
||||
const result = await manager.prepareCommand({
|
||||
command: 'ls',
|
||||
args: [],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
policy: {
|
||||
allowedPaths: [workspace + '/subdirectory'],
|
||||
},
|
||||
});
|
||||
const bwrapArgs = result.args;
|
||||
const bindIndex = bwrapArgs.indexOf(workspace + '/subdirectory');
|
||||
expect(bwrapArgs[bindIndex - 1]).toBe('--ro-bind-try');
|
||||
'/opt/tools',
|
||||
'/opt/tools',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not bind the workspace twice even if it has a trailing slash in allowedPaths', async () => {
|
||||
@@ -347,20 +274,23 @@ describe('LinuxSandboxManager', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const binds = bwrapArgs.filter((a) => a === workspace);
|
||||
expect(binds.length).toBe(2);
|
||||
// Should only contain the primary workspace bind and governance files, not the second workspace bind with a trailing slash
|
||||
expectDynamicBinds(bwrapArgs, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('forbiddenPaths', () => {
|
||||
it('should parameterize forbidden paths and explicitly deny them', async () => {
|
||||
vi.mocked(fs.statSync).mockImplementation((p) => {
|
||||
vi.spyOn(fs.promises, 'stat').mockImplementation(async (p) => {
|
||||
// Mock /tmp/cache as a directory, and /opt/secret.txt as a file
|
||||
if (p.toString().includes('cache')) {
|
||||
return { isDirectory: () => true } as fs.Stats;
|
||||
}
|
||||
return { isDirectory: () => false } as fs.Stats;
|
||||
});
|
||||
vi.mocked(fs.realpathSync).mockImplementation((p) => p.toString());
|
||||
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) =>
|
||||
p.toString(),
|
||||
);
|
||||
|
||||
const bwrapArgs = await getBwrapArgs({
|
||||
command: 'ls',
|
||||
@@ -372,22 +302,27 @@ describe('LinuxSandboxManager', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const cacheIndex = bwrapArgs.indexOf('/tmp/cache');
|
||||
expect(bwrapArgs[cacheIndex - 1]).toBe('--tmpfs');
|
||||
|
||||
const secretIndex = bwrapArgs.indexOf('/opt/secret.txt');
|
||||
expect(bwrapArgs[secretIndex - 2]).toBe('--ro-bind');
|
||||
expect(bwrapArgs[secretIndex - 1]).toBe('/dev/null');
|
||||
expectDynamicBinds(bwrapArgs, [
|
||||
'--tmpfs',
|
||||
'/tmp/cache',
|
||||
'--remount-ro',
|
||||
'/tmp/cache',
|
||||
'--ro-bind-try',
|
||||
'/dev/null',
|
||||
'/opt/secret.txt',
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves forbidden symlink paths to their real paths', async () => {
|
||||
vi.mocked(fs.statSync).mockImplementation(
|
||||
() => ({ isDirectory: () => false }) as fs.Stats,
|
||||
vi.spyOn(fs.promises, 'stat').mockImplementation(
|
||||
async () => ({ isDirectory: () => false }) as fs.Stats,
|
||||
);
|
||||
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
|
||||
async (p) => {
|
||||
if (p === '/tmp/forbidden-symlink') return '/opt/real-target.txt';
|
||||
return p.toString();
|
||||
},
|
||||
);
|
||||
vi.mocked(fs.realpathSync).mockImplementation((p) => {
|
||||
if (p === '/tmp/forbidden-symlink') return '/opt/real-target.txt';
|
||||
return p.toString();
|
||||
});
|
||||
|
||||
const bwrapArgs = await getBwrapArgs({
|
||||
command: 'ls',
|
||||
@@ -399,18 +334,24 @@ describe('LinuxSandboxManager', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const secretIndex = bwrapArgs.indexOf('/opt/real-target.txt');
|
||||
expect(bwrapArgs[secretIndex - 2]).toBe('--ro-bind');
|
||||
expect(bwrapArgs[secretIndex - 1]).toBe('/dev/null');
|
||||
// Should explicitly mask both the resolved path and the original symlink path
|
||||
expectDynamicBinds(bwrapArgs, [
|
||||
'--ro-bind-try',
|
||||
'/dev/null',
|
||||
'/opt/real-target.txt',
|
||||
'--ro-bind-try',
|
||||
'/dev/null',
|
||||
'/tmp/forbidden-symlink',
|
||||
]);
|
||||
});
|
||||
|
||||
it('explicitly denies non-existent forbidden paths to prevent creation', async () => {
|
||||
const error = new Error('File not found') as NodeJS.ErrnoException;
|
||||
error.code = 'ENOENT';
|
||||
vi.mocked(fs.statSync).mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
vi.mocked(fs.realpathSync).mockImplementation((p) => p.toString());
|
||||
vi.spyOn(fs.promises, 'stat').mockRejectedValue(error);
|
||||
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) =>
|
||||
p.toString(),
|
||||
);
|
||||
|
||||
const bwrapArgs = await getBwrapArgs({
|
||||
command: 'ls',
|
||||
@@ -422,19 +363,23 @@ describe('LinuxSandboxManager', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const idx = bwrapArgs.indexOf('/tmp/not-here.txt');
|
||||
expect(bwrapArgs[idx - 2]).toBe('--symlink');
|
||||
expect(bwrapArgs[idx - 1]).toBe('/dev/null');
|
||||
expectDynamicBinds(bwrapArgs, [
|
||||
'--symlink',
|
||||
'/.forbidden',
|
||||
'/tmp/not-here.txt',
|
||||
]);
|
||||
});
|
||||
|
||||
it('masks directory symlinks with tmpfs for both paths', async () => {
|
||||
vi.mocked(fs.statSync).mockImplementation(
|
||||
() => ({ isDirectory: () => true }) as fs.Stats,
|
||||
vi.spyOn(fs.promises, 'stat').mockImplementation(
|
||||
async () => ({ isDirectory: () => true }) as fs.Stats,
|
||||
);
|
||||
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
|
||||
async (p) => {
|
||||
if (p === '/tmp/dir-link') return '/opt/real-dir';
|
||||
return p.toString();
|
||||
},
|
||||
);
|
||||
vi.mocked(fs.realpathSync).mockImplementation((p) => {
|
||||
if (p === '/tmp/dir-link') return '/opt/real-dir';
|
||||
return p.toString();
|
||||
});
|
||||
|
||||
const bwrapArgs = await getBwrapArgs({
|
||||
command: 'ls',
|
||||
@@ -446,15 +391,25 @@ describe('LinuxSandboxManager', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const idx = bwrapArgs.indexOf('/opt/real-dir');
|
||||
expect(bwrapArgs[idx - 1]).toBe('--tmpfs');
|
||||
expectDynamicBinds(bwrapArgs, [
|
||||
'--tmpfs',
|
||||
'/opt/real-dir',
|
||||
'--remount-ro',
|
||||
'/opt/real-dir',
|
||||
'--tmpfs',
|
||||
'/tmp/dir-link',
|
||||
'--remount-ro',
|
||||
'/tmp/dir-link',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should override allowed paths if a path is also in forbidden paths', async () => {
|
||||
vi.mocked(fs.statSync).mockImplementation(
|
||||
() => ({ isDirectory: () => true }) as fs.Stats,
|
||||
vi.spyOn(fs.promises, 'stat').mockImplementation(
|
||||
async () => ({ isDirectory: () => true }) as fs.Stats,
|
||||
);
|
||||
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) =>
|
||||
p.toString(),
|
||||
);
|
||||
vi.mocked(fs.realpathSync).mockImplementation((p) => p.toString());
|
||||
|
||||
const bwrapArgs = await getBwrapArgs({
|
||||
command: 'ls',
|
||||
@@ -467,53 +422,16 @@ describe('LinuxSandboxManager', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const bindTryIdx = bwrapArgs.indexOf('--bind-try');
|
||||
const tmpfsIdx = bwrapArgs.lastIndexOf('--tmpfs');
|
||||
|
||||
expect(bwrapArgs[bindTryIdx + 1]).toBe('/tmp/conflict');
|
||||
expect(bwrapArgs[tmpfsIdx + 1]).toBe('/tmp/conflict');
|
||||
expect(tmpfsIdx).toBeGreaterThan(bindTryIdx);
|
||||
expectDynamicBinds(bwrapArgs, [
|
||||
'--bind-try',
|
||||
'/tmp/conflict',
|
||||
'/tmp/conflict',
|
||||
'--tmpfs',
|
||||
'/tmp/conflict',
|
||||
'--remount-ro',
|
||||
'/tmp/conflict',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks .env and .env.* files in the workspace root', async () => {
|
||||
vi.mocked(shellUtils.spawnAsync).mockImplementation((cmd, args) => {
|
||||
if (cmd === 'find' && args?.[0] === workspace) {
|
||||
// Assert that find is NOT excluding dotfiles
|
||||
expect(args).not.toContain('-not');
|
||||
expect(args).toContain('-prune');
|
||||
|
||||
return Promise.resolve({
|
||||
status: 0,
|
||||
stdout: Buffer.from(
|
||||
`${workspace}/.env\0${workspace}/.env.local\0${workspace}/.env.test\0`,
|
||||
),
|
||||
} as unknown as ReturnType<typeof shellUtils.spawnAsync>);
|
||||
}
|
||||
return Promise.resolve({
|
||||
status: 0,
|
||||
stdout: Buffer.from(''),
|
||||
} as unknown as ReturnType<typeof shellUtils.spawnAsync>);
|
||||
});
|
||||
|
||||
const bwrapArgs = await getBwrapArgs({
|
||||
command: 'ls',
|
||||
args: [],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
});
|
||||
|
||||
const bindsIndex = bwrapArgs.indexOf('--seccomp');
|
||||
const binds = bwrapArgs.slice(0, bindsIndex);
|
||||
|
||||
expect(binds).toContain(`${workspace}/.env`);
|
||||
expect(binds).toContain(`${workspace}/.env.local`);
|
||||
expect(binds).toContain(`${workspace}/.env.test`);
|
||||
|
||||
// Verify they are bound to a mask file
|
||||
const envIndex = binds.indexOf(`${workspace}/.env`);
|
||||
expect(binds[envIndex - 2]).toBe('--bind');
|
||||
expect(binds[envIndex - 1]).toMatch(/gemini-cli-mask-file-.*mocked\/mask/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,32 +12,15 @@ import {
|
||||
type GlobalSandboxOptions,
|
||||
type SandboxRequest,
|
||||
type SandboxedCommand,
|
||||
type SandboxPermissions,
|
||||
GOVERNANCE_FILES,
|
||||
getSecretFileFindArgs,
|
||||
sanitizePaths,
|
||||
tryRealpath,
|
||||
} from '../../services/sandboxManager.js';
|
||||
import {
|
||||
sanitizeEnvironment,
|
||||
getSecureSanitizationConfig,
|
||||
} from '../../services/environmentSanitization.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
import { spawnAsync } from '../../utils/shell-utils.js';
|
||||
import { type SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js';
|
||||
import {
|
||||
isStrictlyApproved,
|
||||
verifySandboxOverrides,
|
||||
getCommandName,
|
||||
} from '../utils/commandUtils.js';
|
||||
import {
|
||||
tryRealpath,
|
||||
resolveGitWorktreePaths,
|
||||
isErrnoException,
|
||||
} from '../utils/fsUtils.js';
|
||||
import {
|
||||
isKnownSafeCommand,
|
||||
isDangerousCommand,
|
||||
} from '../utils/commandSafety.js';
|
||||
import { isNodeError } from '../../utils/errors.js';
|
||||
|
||||
let cachedBpfPath: string | undefined;
|
||||
|
||||
@@ -91,20 +74,9 @@ function getSeccompBpfPath(): string {
|
||||
buf.writeUInt32LE(inst.k, offset + 4);
|
||||
}
|
||||
|
||||
const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'gemini-cli-seccomp-'));
|
||||
const bpfPath = join(tempDir, 'seccomp.bpf');
|
||||
const bpfPath = join(os.tmpdir(), `gemini-cli-seccomp-${process.pid}.bpf`);
|
||||
fs.writeFileSync(bpfPath, buf);
|
||||
cachedBpfPath = bpfPath;
|
||||
|
||||
// Cleanup on exit
|
||||
process.on('exit', () => {
|
||||
try {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
});
|
||||
|
||||
return bpfPath;
|
||||
}
|
||||
|
||||
@@ -127,24 +99,16 @@ function touch(filePath: string, isDirectory: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
import {
|
||||
isKnownSafeCommand,
|
||||
isDangerousCommand,
|
||||
} from '../macos/commandSafety.js';
|
||||
|
||||
/**
|
||||
* A SandboxManager implementation for Linux that uses Bubblewrap (bwrap).
|
||||
*/
|
||||
|
||||
export interface LinuxSandboxOptions extends GlobalSandboxOptions {
|
||||
modeConfig?: {
|
||||
readonly?: boolean;
|
||||
network?: boolean;
|
||||
approvedTools?: string[];
|
||||
allowOverrides?: boolean;
|
||||
};
|
||||
policyManager?: SandboxPolicyManager;
|
||||
}
|
||||
|
||||
export class LinuxSandboxManager implements SandboxManager {
|
||||
private static maskFilePath: string | undefined;
|
||||
|
||||
constructor(private readonly options: LinuxSandboxOptions) {}
|
||||
constructor(private readonly options: GlobalSandboxOptions) {}
|
||||
|
||||
isKnownSafeCommand(args: string[]): boolean {
|
||||
return isKnownSafeCommand(args);
|
||||
@@ -154,67 +118,7 @@ export class LinuxSandboxManager implements SandboxManager {
|
||||
return isDangerousCommand(args);
|
||||
}
|
||||
|
||||
private getMaskFilePath(): string {
|
||||
if (
|
||||
LinuxSandboxManager.maskFilePath &&
|
||||
fs.existsSync(LinuxSandboxManager.maskFilePath)
|
||||
) {
|
||||
return LinuxSandboxManager.maskFilePath;
|
||||
}
|
||||
const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'gemini-cli-mask-file-'));
|
||||
const maskPath = join(tempDir, 'mask');
|
||||
fs.writeFileSync(maskPath, '');
|
||||
fs.chmodSync(maskPath, 0);
|
||||
LinuxSandboxManager.maskFilePath = maskPath;
|
||||
|
||||
// Cleanup on exit
|
||||
process.on('exit', () => {
|
||||
try {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
});
|
||||
|
||||
return maskPath;
|
||||
}
|
||||
|
||||
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
|
||||
const isReadonlyMode = this.options.modeConfig?.readonly ?? true;
|
||||
const allowOverrides = this.options.modeConfig?.allowOverrides ?? true;
|
||||
|
||||
verifySandboxOverrides(allowOverrides, req.policy);
|
||||
|
||||
const commandName = await getCommandName(req);
|
||||
const isApproved = allowOverrides
|
||||
? await isStrictlyApproved(req, this.options.modeConfig?.approvedTools)
|
||||
: false;
|
||||
const workspaceWrite = !isReadonlyMode || isApproved;
|
||||
const networkAccess =
|
||||
this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false;
|
||||
|
||||
const persistentPermissions = allowOverrides
|
||||
? this.options.policyManager?.getCommandPermissions(commandName)
|
||||
: undefined;
|
||||
|
||||
const mergedAdditional: SandboxPermissions = {
|
||||
fileSystem: {
|
||||
read: [
|
||||
...(persistentPermissions?.fileSystem?.read ?? []),
|
||||
...(req.policy?.additionalPermissions?.fileSystem?.read ?? []),
|
||||
],
|
||||
write: [
|
||||
...(persistentPermissions?.fileSystem?.write ?? []),
|
||||
...(req.policy?.additionalPermissions?.fileSystem?.write ?? []),
|
||||
],
|
||||
},
|
||||
network:
|
||||
networkAccess ||
|
||||
persistentPermissions?.network ||
|
||||
req.policy?.additionalPermissions?.network ||
|
||||
false,
|
||||
};
|
||||
|
||||
const sanitizationConfig = getSecureSanitizationConfig(
|
||||
req.policy?.sanitizationConfig,
|
||||
);
|
||||
@@ -222,147 +126,13 @@ export class LinuxSandboxManager implements SandboxManager {
|
||||
const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig);
|
||||
|
||||
const bwrapArgs: string[] = [
|
||||
'--unshare-all',
|
||||
'--new-session', // Isolate session
|
||||
'--die-with-parent', // Prevent orphaned runaway processes
|
||||
...this.getNetworkArgs(req),
|
||||
...this.getBaseArgs(),
|
||||
...this.getGovernanceArgs(),
|
||||
...this.getAllowedPathsArgs(req.policy?.allowedPaths),
|
||||
...(await this.getForbiddenPathsArgs(req.policy?.forbiddenPaths)),
|
||||
];
|
||||
|
||||
if (mergedAdditional.network) {
|
||||
bwrapArgs.push('--share-net');
|
||||
}
|
||||
|
||||
bwrapArgs.push(
|
||||
'--ro-bind',
|
||||
'/',
|
||||
'/',
|
||||
'--dev', // Creates a safe, minimal /dev (replaces --dev-bind)
|
||||
'/dev',
|
||||
'--proc', // Creates a fresh procfs for the unshared PID namespace
|
||||
'/proc',
|
||||
'--tmpfs', // Provides an isolated, writable /tmp directory
|
||||
'/tmp',
|
||||
);
|
||||
|
||||
const workspacePath = tryRealpath(this.options.workspace);
|
||||
|
||||
const bindFlag = workspaceWrite ? '--bind-try' : '--ro-bind-try';
|
||||
|
||||
if (workspaceWrite) {
|
||||
bwrapArgs.push(
|
||||
'--bind-try',
|
||||
this.options.workspace,
|
||||
this.options.workspace,
|
||||
);
|
||||
if (workspacePath !== this.options.workspace) {
|
||||
bwrapArgs.push('--bind-try', workspacePath, workspacePath);
|
||||
}
|
||||
} else {
|
||||
bwrapArgs.push(
|
||||
'--ro-bind-try',
|
||||
this.options.workspace,
|
||||
this.options.workspace,
|
||||
);
|
||||
if (workspacePath !== this.options.workspace) {
|
||||
bwrapArgs.push('--ro-bind-try', workspacePath, workspacePath);
|
||||
}
|
||||
}
|
||||
|
||||
const { worktreeGitDir, mainGitDir } =
|
||||
resolveGitWorktreePaths(workspacePath);
|
||||
if (worktreeGitDir) {
|
||||
bwrapArgs.push(bindFlag, worktreeGitDir, worktreeGitDir);
|
||||
}
|
||||
if (mainGitDir) {
|
||||
bwrapArgs.push(bindFlag, mainGitDir, mainGitDir);
|
||||
}
|
||||
|
||||
const allowedPaths = sanitizePaths(req.policy?.allowedPaths) || [];
|
||||
const normalizedWorkspace = normalize(workspacePath).replace(/\/$/, '');
|
||||
for (const allowedPath of allowedPaths) {
|
||||
const resolved = tryRealpath(allowedPath);
|
||||
if (!fs.existsSync(resolved)) continue;
|
||||
const normalizedAllowedPath = normalize(resolved).replace(/\/$/, '');
|
||||
if (normalizedAllowedPath !== normalizedWorkspace) {
|
||||
if (
|
||||
!workspaceWrite &&
|
||||
normalizedAllowedPath.startsWith(normalizedWorkspace + '/')
|
||||
) {
|
||||
bwrapArgs.push('--ro-bind-try', resolved, resolved);
|
||||
} else {
|
||||
bwrapArgs.push('--bind-try', resolved, resolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const additionalReads =
|
||||
sanitizePaths(mergedAdditional.fileSystem?.read) || [];
|
||||
for (const p of additionalReads) {
|
||||
try {
|
||||
const safeResolvedPath = tryRealpath(p);
|
||||
bwrapArgs.push('--ro-bind-try', safeResolvedPath, safeResolvedPath);
|
||||
} catch (e: unknown) {
|
||||
debugLogger.warn(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
const additionalWrites =
|
||||
sanitizePaths(mergedAdditional.fileSystem?.write) || [];
|
||||
for (const p of additionalWrites) {
|
||||
try {
|
||||
const safeResolvedPath = tryRealpath(p);
|
||||
bwrapArgs.push('--bind-try', safeResolvedPath, safeResolvedPath);
|
||||
} catch (e: unknown) {
|
||||
debugLogger.warn(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of GOVERNANCE_FILES) {
|
||||
const filePath = join(this.options.workspace, file.path);
|
||||
touch(filePath, file.isDirectory);
|
||||
const realPath = tryRealpath(filePath);
|
||||
bwrapArgs.push('--ro-bind', filePath, filePath);
|
||||
if (realPath !== filePath) {
|
||||
bwrapArgs.push('--ro-bind', realPath, realPath);
|
||||
}
|
||||
}
|
||||
|
||||
const forbiddenPaths = sanitizePaths(req.policy?.forbiddenPaths) || [];
|
||||
for (const p of forbiddenPaths) {
|
||||
let resolved: string;
|
||||
try {
|
||||
resolved = tryRealpath(p); // Forbidden paths should still resolve to block the real path
|
||||
if (!fs.existsSync(resolved)) continue;
|
||||
} catch (e: unknown) {
|
||||
debugLogger.warn(
|
||||
`Failed to resolve forbidden path ${p}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
bwrapArgs.push('--ro-bind', '/dev/null', p);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const stat = fs.statSync(resolved);
|
||||
if (stat.isDirectory()) {
|
||||
bwrapArgs.push('--tmpfs', resolved, '--remount-ro', resolved);
|
||||
} else {
|
||||
bwrapArgs.push('--ro-bind', '/dev/null', resolved);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (isErrnoException(e) && e.code === 'ENOENT') {
|
||||
bwrapArgs.push('--symlink', '/dev/null', resolved);
|
||||
} else {
|
||||
debugLogger.warn(
|
||||
`Failed to stat forbidden path ${resolved}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
bwrapArgs.push('--ro-bind', '/dev/null', resolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mask secret files (.env, .env.*)
|
||||
bwrapArgs.push(
|
||||
...(await this.getSecretFilesArgs(req.policy?.allowedPaths)),
|
||||
);
|
||||
|
||||
const bpfPath = getSeccompBpfPath();
|
||||
|
||||
bwrapArgs.push('--seccomp', '9');
|
||||
@@ -385,66 +155,140 @@ export class LinuxSandboxManager implements SandboxManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates bubblewrap arguments to mask secret files.
|
||||
* Generates arguments for network isolation.
|
||||
*/
|
||||
private async getSecretFilesArgs(allowedPaths?: string[]): Promise<string[]> {
|
||||
private getNetworkArgs(req: SandboxRequest): string[] {
|
||||
return req.policy?.networkAccess
|
||||
? [
|
||||
'--unshare-user',
|
||||
'--unshare-ipc',
|
||||
'--unshare-pid',
|
||||
'--unshare-uts',
|
||||
'--unshare-cgroup',
|
||||
]
|
||||
: ['--unshare-all'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the base bubblewrap arguments for isolation.
|
||||
*/
|
||||
private getBaseArgs(): string[] {
|
||||
return [
|
||||
'--new-session', // Isolate session
|
||||
'--die-with-parent', // Prevent orphaned runaway processes
|
||||
'--ro-bind',
|
||||
'/',
|
||||
'/',
|
||||
'--dev', // Creates a safe, minimal /dev (replaces --dev-bind)
|
||||
'/dev',
|
||||
'--proc', // Creates a fresh procfs for the unshared PID namespace
|
||||
'/proc',
|
||||
'--tmpfs', // Provides an isolated, writable /tmp directory
|
||||
'/tmp',
|
||||
// Note: --dev /dev sets up /dev/pts automatically
|
||||
'--bind',
|
||||
this.options.workspace,
|
||||
this.options.workspace,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates arguments for protected governance files.
|
||||
*/
|
||||
private getGovernanceArgs(): string[] {
|
||||
const args: string[] = [];
|
||||
// Protected governance files are bind-mounted as read-only, even if the workspace is RW.
|
||||
// We ensure they exist on the host and resolve real paths to prevent symlink bypasses.
|
||||
// In bwrap, later binds override earlier ones for the same path.
|
||||
for (const file of GOVERNANCE_FILES) {
|
||||
const filePath = join(this.options.workspace, file.path);
|
||||
touch(filePath, file.isDirectory);
|
||||
|
||||
const realPath = fs.realpathSync(filePath);
|
||||
|
||||
args.push('--ro-bind', filePath, filePath);
|
||||
if (realPath !== filePath) {
|
||||
args.push('--ro-bind', realPath, realPath);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates arguments for allowed paths.
|
||||
*/
|
||||
private getAllowedPathsArgs(allowedPaths?: string[]): string[] {
|
||||
const args: string[] = [];
|
||||
const maskPath = this.getMaskFilePath();
|
||||
const paths = sanitizePaths(allowedPaths) || [];
|
||||
const searchDirs = new Set([this.options.workspace, ...paths]);
|
||||
const findPatterns = getSecretFileFindArgs();
|
||||
const normalizedWorkspace = this.normalizePath(this.options.workspace);
|
||||
|
||||
for (const dir of searchDirs) {
|
||||
for (const p of paths) {
|
||||
if (this.normalizePath(p) !== normalizedWorkspace) {
|
||||
args.push('--bind-try', p, p);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates arguments for forbidden paths.
|
||||
*/
|
||||
private async getForbiddenPathsArgs(
|
||||
forbiddenPaths?: string[],
|
||||
): Promise<string[]> {
|
||||
const args: string[] = [];
|
||||
const paths = sanitizePaths(forbiddenPaths) || [];
|
||||
|
||||
for (const p of paths) {
|
||||
try {
|
||||
// Use the native 'find' command for performance and to catch nested secrets.
|
||||
// We limit depth to 3 to keep it fast while covering common nested structures.
|
||||
// We use -prune to skip heavy directories efficiently while matching dotfiles.
|
||||
const findResult = await spawnAsync('find', [
|
||||
dir,
|
||||
'-maxdepth',
|
||||
'3',
|
||||
'-type',
|
||||
'd',
|
||||
'(',
|
||||
'-name',
|
||||
'.git',
|
||||
'-o',
|
||||
'-name',
|
||||
'node_modules',
|
||||
'-o',
|
||||
'-name',
|
||||
'.venv',
|
||||
'-o',
|
||||
'-name',
|
||||
'__pycache__',
|
||||
'-o',
|
||||
'-name',
|
||||
'dist',
|
||||
'-o',
|
||||
'-name',
|
||||
'build',
|
||||
')',
|
||||
'-prune',
|
||||
'-o',
|
||||
'-type',
|
||||
'f',
|
||||
...findPatterns,
|
||||
'-print0',
|
||||
]);
|
||||
const originalPath = this.normalizePath(p);
|
||||
const resolvedPath = await tryRealpath(originalPath);
|
||||
|
||||
const files = findResult.stdout.toString().split('\0');
|
||||
for (const file of files) {
|
||||
if (file.trim()) {
|
||||
args.push('--bind', maskPath, file.trim());
|
||||
}
|
||||
// Mask the resolved path to prevent access to the underlying file.
|
||||
const resolvedMask = await this.getMaskArgs(resolvedPath);
|
||||
args.push(...resolvedMask);
|
||||
|
||||
// If the original path was a symlink, mask it as well to prevent access
|
||||
// through the link itself.
|
||||
if (resolvedPath !== originalPath) {
|
||||
const originalMask = await this.getMaskArgs(originalPath);
|
||||
args.push(...originalMask);
|
||||
}
|
||||
} catch (e) {
|
||||
debugLogger.log(
|
||||
`LinuxSandboxManager: Failed to find or mask secret files in ${dir}`,
|
||||
e,
|
||||
throw new Error(
|
||||
`Failed to deny access to forbidden path: ${p}. ${
|
||||
e instanceof Error ? e.message : String(e)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates bubblewrap arguments to mask a forbidden path.
|
||||
*/
|
||||
private async getMaskArgs(path: string): Promise<string[]> {
|
||||
try {
|
||||
const stats = await fs.promises.stat(path);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
// Directories are masked by mounting an empty, read-only tmpfs.
|
||||
return ['--tmpfs', path, '--remount-ro', path];
|
||||
}
|
||||
// Existing files are masked by binding them to /dev/null.
|
||||
return ['--ro-bind-try', '/dev/null', path];
|
||||
} catch (e) {
|
||||
if (isNodeError(e) && e.code === 'ENOENT') {
|
||||
// Non-existent paths are masked by a broken symlink. This prevents
|
||||
// creation within the sandbox while avoiding host remnants.
|
||||
return ['--symlink', '/.forbidden', path];
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private normalizePath(p: string): string {
|
||||
return normalize(p).replace(/\/$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('MacOsSandboxManager', () => {
|
||||
manager = new MacOsSandboxManager({ workspace: mockWorkspace });
|
||||
|
||||
// Mock the seatbelt args builder to isolate manager tests
|
||||
vi.spyOn(seatbeltArgsBuilder, 'buildSeatbeltArgs').mockReturnValue([
|
||||
vi.spyOn(seatbeltArgsBuilder, 'buildSeatbeltArgs').mockResolvedValue([
|
||||
'-p',
|
||||
'(mock profile)',
|
||||
'-D',
|
||||
@@ -112,10 +112,7 @@ describe('MacOsSandboxManager', () => {
|
||||
SAFE_VAR: '1',
|
||||
GITHUB_TOKEN: 'sensitive',
|
||||
},
|
||||
policy: {
|
||||
...mockPolicy,
|
||||
sanitizationConfig: { enableEnvironmentVariableRedaction: true },
|
||||
},
|
||||
policy: mockPolicy,
|
||||
});
|
||||
|
||||
expect(result.env['SAFE_VAR']).toBe('1');
|
||||
|
||||
@@ -24,9 +24,8 @@ import {
|
||||
isKnownSafeCommand,
|
||||
isDangerousCommand,
|
||||
isStrictlyApproved,
|
||||
} from '../utils/commandSafety.js';
|
||||
} from './commandSafety.js';
|
||||
import { type SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js';
|
||||
import { verifySandboxOverrides } from '../utils/commandUtils.js';
|
||||
|
||||
export interface MacOsSandboxOptions extends GlobalSandboxOptions {
|
||||
/** The current sandbox mode behavior from config. */
|
||||
@@ -71,7 +70,17 @@ export class MacOsSandboxManager implements SandboxManager {
|
||||
const allowOverrides = this.options.modeConfig?.allowOverrides ?? true;
|
||||
|
||||
// Reject override attempts in plan mode
|
||||
verifySandboxOverrides(allowOverrides, req.policy);
|
||||
if (!allowOverrides && req.policy?.additionalPermissions) {
|
||||
const perms = req.policy.additionalPermissions;
|
||||
if (
|
||||
perms.network ||
|
||||
(perms.fileSystem?.write && perms.fileSystem.write.length > 0)
|
||||
) {
|
||||
throw new Error(
|
||||
'Sandbox request rejected: Cannot override readonly/network restrictions in Plan mode.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If not in readonly mode OR it's a strictly approved pipeline, allow workspace writes
|
||||
const isApproved = allowOverrides
|
||||
@@ -111,7 +120,7 @@ export class MacOsSandboxManager implements SandboxManager {
|
||||
false,
|
||||
};
|
||||
|
||||
const sandboxArgs = buildSeatbeltArgs({
|
||||
const sandboxArgs = await buildSeatbeltArgs({
|
||||
workspace: this.options.workspace,
|
||||
allowedPaths: [...(req.policy?.allowedPaths || [])],
|
||||
forbiddenPaths: req.policy?.forbiddenPaths,
|
||||
|
||||
@@ -3,31 +3,25 @@
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { buildSeatbeltArgs } from './seatbeltArgsBuilder.js';
|
||||
import * as fsUtils from '../utils/fsUtils.js';
|
||||
import * as sandboxManager from '../../services/sandboxManager.js';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
|
||||
vi.mock('../utils/fsUtils.js', async () => {
|
||||
const actual = await vi.importActual('../utils/fsUtils.js');
|
||||
return {
|
||||
...actual,
|
||||
tryRealpath: vi.fn((p) => p),
|
||||
resolveGitWorktreePaths: vi.fn(() => ({})),
|
||||
};
|
||||
});
|
||||
|
||||
describe('seatbeltArgsBuilder', () => {
|
||||
afterEach(() => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('buildSeatbeltArgs', () => {
|
||||
it('should build a strict allowlist profile allowing the workspace via param', () => {
|
||||
vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => p);
|
||||
it('should build a strict allowlist profile allowing the workspace via param', async () => {
|
||||
// Mock tryRealpath to just return the path for testing
|
||||
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
|
||||
async (p) => p,
|
||||
);
|
||||
|
||||
const args = buildSeatbeltArgs({
|
||||
const args = await buildSeatbeltArgs({
|
||||
workspace: '/Users/test/workspace',
|
||||
});
|
||||
|
||||
@@ -44,9 +38,11 @@ describe('seatbeltArgsBuilder', () => {
|
||||
expect(args).toContain(`TMPDIR=${os.tmpdir()}`);
|
||||
});
|
||||
|
||||
it('should allow network when networkAccess is true', () => {
|
||||
vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => p);
|
||||
const args = buildSeatbeltArgs({
|
||||
it('should allow network when networkAccess is true', async () => {
|
||||
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
|
||||
async (p) => p,
|
||||
);
|
||||
const args = await buildSeatbeltArgs({
|
||||
workspace: '/test',
|
||||
networkAccess: true,
|
||||
});
|
||||
@@ -55,8 +51,10 @@ describe('seatbeltArgsBuilder', () => {
|
||||
});
|
||||
|
||||
describe('governance files', () => {
|
||||
it('should inject explicit deny rules for governance files', () => {
|
||||
vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => p.toString());
|
||||
it('should inject explicit deny rules for governance files', async () => {
|
||||
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) =>
|
||||
p.toString(),
|
||||
);
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
vi.spyOn(fs, 'lstatSync').mockImplementation(
|
||||
(p) =>
|
||||
@@ -66,29 +64,35 @@ describe('seatbeltArgsBuilder', () => {
|
||||
}) as unknown as fs.Stats,
|
||||
);
|
||||
|
||||
const args = buildSeatbeltArgs({
|
||||
workspace: '/test/workspace',
|
||||
const args = await buildSeatbeltArgs({
|
||||
workspace: '/Users/test/workspace',
|
||||
});
|
||||
const profile = args[1];
|
||||
|
||||
// .gitignore should be a literal deny
|
||||
expect(args).toContain('-D');
|
||||
expect(args).toContain('GOVERNANCE_FILE_0=/test/workspace/.gitignore');
|
||||
expect(args).toContain(
|
||||
'GOVERNANCE_FILE_0=/Users/test/workspace/.gitignore',
|
||||
);
|
||||
expect(profile).toContain(
|
||||
'(deny file-write* (literal (param "GOVERNANCE_FILE_0")))',
|
||||
);
|
||||
|
||||
expect(args).toContain('GOVERNANCE_FILE_2=/test/workspace/.git');
|
||||
// .git should be a subpath deny
|
||||
expect(args).toContain('GOVERNANCE_FILE_2=/Users/test/workspace/.git');
|
||||
expect(profile).toContain(
|
||||
'(deny file-write* (subpath (param "GOVERNANCE_FILE_2")))',
|
||||
);
|
||||
});
|
||||
|
||||
it('should protect both the symlink and the real path if they differ', () => {
|
||||
vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => {
|
||||
if (p === '/test/workspace/.gitignore')
|
||||
return '/test/real/.gitignore';
|
||||
return p.toString();
|
||||
});
|
||||
it('should protect both the symlink and the real path if they differ', async () => {
|
||||
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
|
||||
async (p) => {
|
||||
if (p === '/test/workspace/.gitignore')
|
||||
return '/test/real/.gitignore';
|
||||
return p.toString();
|
||||
},
|
||||
);
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
vi.spyOn(fs, 'lstatSync').mockImplementation(
|
||||
() =>
|
||||
@@ -98,7 +102,7 @@ describe('seatbeltArgsBuilder', () => {
|
||||
}) as unknown as fs.Stats,
|
||||
);
|
||||
|
||||
const args = buildSeatbeltArgs({ workspace: '/test/workspace' });
|
||||
const args = await buildSeatbeltArgs({ workspace: '/test/workspace' });
|
||||
const profile = args[1];
|
||||
|
||||
expect(args).toContain('GOVERNANCE_FILE_0=/test/workspace/.gitignore');
|
||||
@@ -113,13 +117,15 @@ describe('seatbeltArgsBuilder', () => {
|
||||
});
|
||||
|
||||
describe('allowedPaths', () => {
|
||||
it('should parameterize allowed paths and normalize them', () => {
|
||||
vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => {
|
||||
if (p === '/test/symlink') return '/test/real_path';
|
||||
return p;
|
||||
});
|
||||
it('should parameterize allowed paths and normalize them', async () => {
|
||||
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
|
||||
async (p) => {
|
||||
if (p === '/test/symlink') return '/test/real_path';
|
||||
return p;
|
||||
},
|
||||
);
|
||||
|
||||
const args = buildSeatbeltArgs({
|
||||
const args = await buildSeatbeltArgs({
|
||||
workspace: '/test',
|
||||
allowedPaths: ['/custom/path1', '/test/symlink'],
|
||||
});
|
||||
@@ -135,10 +141,12 @@ describe('seatbeltArgsBuilder', () => {
|
||||
});
|
||||
|
||||
describe('forbiddenPaths', () => {
|
||||
it('should parameterize forbidden paths and explicitly deny them', () => {
|
||||
vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => p);
|
||||
it('should parameterize forbidden paths and explicitly deny them', async () => {
|
||||
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
|
||||
async (p) => p,
|
||||
);
|
||||
|
||||
const args = buildSeatbeltArgs({
|
||||
const args = await buildSeatbeltArgs({
|
||||
workspace: '/test',
|
||||
forbiddenPaths: ['/secret/path'],
|
||||
});
|
||||
@@ -153,21 +161,22 @@ describe('seatbeltArgsBuilder', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves forbidden symlink paths to their real paths', () => {
|
||||
vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => {
|
||||
if (p === '/test/symlink' || p === '/test/missing-dir') {
|
||||
return '/test/real_path';
|
||||
}
|
||||
return p;
|
||||
});
|
||||
it('resolves forbidden symlink paths to their real paths', async () => {
|
||||
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
|
||||
async (p) => {
|
||||
if (p === '/test/symlink') return '/test/real_path';
|
||||
return p;
|
||||
},
|
||||
);
|
||||
|
||||
const args = buildSeatbeltArgs({
|
||||
const args = await buildSeatbeltArgs({
|
||||
workspace: '/test',
|
||||
forbiddenPaths: ['/test/symlink'],
|
||||
});
|
||||
|
||||
const profile = args[1];
|
||||
|
||||
// The builder should resolve the symlink and explicitly deny the real target path
|
||||
expect(args).toContain('-D');
|
||||
expect(args).toContain('FORBIDDEN_PATH_0=/test/real_path');
|
||||
expect(profile).toContain(
|
||||
@@ -175,10 +184,12 @@ describe('seatbeltArgsBuilder', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('explicitly denies non-existent forbidden paths to prevent creation', () => {
|
||||
vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => p);
|
||||
it('explicitly denies non-existent forbidden paths to prevent creation', async () => {
|
||||
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
|
||||
async (p) => p,
|
||||
);
|
||||
|
||||
const args = buildSeatbeltArgs({
|
||||
const args = await buildSeatbeltArgs({
|
||||
workspace: '/test',
|
||||
forbiddenPaths: ['/test/missing-dir/missing-file.txt'],
|
||||
});
|
||||
@@ -194,10 +205,12 @@ describe('seatbeltArgsBuilder', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should override allowed paths if a path is also in forbidden paths', () => {
|
||||
vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => p);
|
||||
it('should override allowed paths if a path is also in forbidden paths', async () => {
|
||||
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
|
||||
async (p) => p,
|
||||
);
|
||||
|
||||
const args = buildSeatbeltArgs({
|
||||
const args = await buildSeatbeltArgs({
|
||||
workspace: '/test',
|
||||
allowedPaths: ['/custom/path1'],
|
||||
forbiddenPaths: ['/custom/path1'],
|
||||
@@ -213,6 +226,8 @@ describe('seatbeltArgsBuilder', () => {
|
||||
expect(profile).toContain(allowString);
|
||||
expect(profile).toContain(denyString);
|
||||
|
||||
// Verify ordering: The explicit deny must appear AFTER the explicit allow in the profile string
|
||||
// Seatbelt rules are evaluated in order where the latest rule matching a path wins
|
||||
const allowIndex = profile.indexOf(allowString);
|
||||
const denyIndex = profile.indexOf(denyString);
|
||||
expect(denyIndex).toBeGreaterThan(allowIndex);
|
||||
|
||||
@@ -15,9 +15,8 @@ import {
|
||||
type SandboxPermissions,
|
||||
sanitizePaths,
|
||||
GOVERNANCE_FILES,
|
||||
SECRET_FILES,
|
||||
tryRealpath,
|
||||
} from '../../services/sandboxManager.js';
|
||||
import { tryRealpath, resolveGitWorktreePaths } from '../utils/fsUtils.js';
|
||||
|
||||
/**
|
||||
* Options for building macOS Seatbelt arguments.
|
||||
@@ -45,11 +44,13 @@ export interface SeatbeltArgsOptions {
|
||||
* Returns arguments up to the end of sandbox-exec configuration (e.g. ['-p', '<profile>', '-D', ...])
|
||||
* Does not include the final '--' separator or the command to run.
|
||||
*/
|
||||
export function buildSeatbeltArgs(options: SeatbeltArgsOptions): string[] {
|
||||
export async function buildSeatbeltArgs(
|
||||
options: SeatbeltArgsOptions,
|
||||
): Promise<string[]> {
|
||||
let profile = BASE_SEATBELT_PROFILE + '\n';
|
||||
const args: string[] = [];
|
||||
|
||||
const workspacePath = tryRealpath(options.workspace);
|
||||
const workspacePath = await tryRealpath(options.workspace);
|
||||
args.push('-D', `WORKSPACE=${workspacePath}`);
|
||||
args.push('-D', `WORKSPACE_RAW=${options.workspace}`);
|
||||
profile += `(allow file-read* (subpath (param "WORKSPACE_RAW")))\n`;
|
||||
@@ -66,7 +67,7 @@ export function buildSeatbeltArgs(options: SeatbeltArgsOptions): string[] {
|
||||
// (Seatbelt evaluates rules in order, later rules win for same path).
|
||||
for (let i = 0; i < GOVERNANCE_FILES.length; i++) {
|
||||
const governanceFile = path.join(workspacePath, GOVERNANCE_FILES[i].path);
|
||||
const realGovernanceFile = tryRealpath(governanceFile);
|
||||
const realGovernanceFile = await tryRealpath(governanceFile);
|
||||
|
||||
// Determine if it should be treated as a directory (subpath) or a file (literal).
|
||||
// .git is generally a directory, while ignore files are literals.
|
||||
@@ -90,49 +91,43 @@ export function buildSeatbeltArgs(options: SeatbeltArgsOptions): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
// Add explicit deny rules for secret files (.env, .env.*) in the workspace and allowed paths.
|
||||
// We use regex rules to avoid expensive file discovery scans.
|
||||
// Anchoring to workspace/allowed paths to avoid over-blocking.
|
||||
const searchPaths = sanitizePaths([
|
||||
options.workspace,
|
||||
...(options.allowedPaths || []),
|
||||
]) || [options.workspace];
|
||||
|
||||
for (const basePath of searchPaths) {
|
||||
const resolvedBase = tryRealpath(basePath);
|
||||
for (const secret of SECRET_FILES) {
|
||||
// Map pattern to Seatbelt regex
|
||||
let regexPattern: string;
|
||||
const escapedBase = escapeRegex(resolvedBase);
|
||||
if (secret.pattern.endsWith('*')) {
|
||||
// .env.* -> .env\..+ (match .env followed by dot and something)
|
||||
// We anchor the secret file name to either a directory separator or the start of the relative path.
|
||||
const basePattern = secret.pattern.slice(0, -1).replace(/\./g, '\\\\.');
|
||||
regexPattern = `^${escapedBase}/(.*/)?${basePattern}[^/]+$`;
|
||||
} else {
|
||||
// .env -> \.env$
|
||||
const basePattern = secret.pattern.replace(/\./g, '\\\\.');
|
||||
regexPattern = `^${escapedBase}/(.*/)?${basePattern}$`;
|
||||
}
|
||||
profile += `(deny file-read* file-write* (regex #"${regexPattern}"))\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-detect and support git worktrees by granting read and write access to the underlying git directory
|
||||
const { worktreeGitDir, mainGitDir } = resolveGitWorktreePaths(workspacePath);
|
||||
if (worktreeGitDir) {
|
||||
args.push('-D', `WORKTREE_GIT_DIR=${worktreeGitDir}`);
|
||||
profile += `(allow file-read* file-write* (subpath (param "WORKTREE_GIT_DIR")))\n`;
|
||||
}
|
||||
if (mainGitDir) {
|
||||
args.push('-D', `MAIN_GIT_DIR=${mainGitDir}`);
|
||||
profile += `(allow file-read* file-write* (subpath (param "MAIN_GIT_DIR")))\n`;
|
||||
try {
|
||||
const gitPath = path.join(workspacePath, '.git');
|
||||
const gitStat = fs.lstatSync(gitPath);
|
||||
if (gitStat.isFile()) {
|
||||
const gitContent = fs.readFileSync(gitPath, 'utf8');
|
||||
const match = gitContent.match(/^gitdir:\s*(.+)$/m);
|
||||
if (match && match[1]) {
|
||||
let worktreeGitDir = match[1].trim();
|
||||
if (!path.isAbsolute(worktreeGitDir)) {
|
||||
worktreeGitDir = path.resolve(workspacePath, worktreeGitDir);
|
||||
}
|
||||
const resolvedWorktreeGitDir = await tryRealpath(worktreeGitDir);
|
||||
|
||||
// Grant write access to the worktree's specific .git directory
|
||||
args.push('-D', `WORKTREE_GIT_DIR=${resolvedWorktreeGitDir}`);
|
||||
profile += `(allow file-read* file-write* (subpath (param "WORKTREE_GIT_DIR")))\n`;
|
||||
|
||||
// Grant write access to the main repository's .git directory (objects, refs, etc. are shared)
|
||||
// resolvedWorktreeGitDir is usually like: /path/to/main-repo/.git/worktrees/worktree-name
|
||||
const mainGitDir = await tryRealpath(
|
||||
path.dirname(path.dirname(resolvedWorktreeGitDir)),
|
||||
);
|
||||
if (mainGitDir && mainGitDir.endsWith('.git')) {
|
||||
args.push('-D', `MAIN_GIT_DIR=${mainGitDir}`);
|
||||
profile += `(allow file-read* file-write* (subpath (param "MAIN_GIT_DIR")))\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignore if .git doesn't exist, isn't readable, etc.
|
||||
}
|
||||
|
||||
const tmpPath = tryRealpath(os.tmpdir());
|
||||
const tmpPath = await tryRealpath(os.tmpdir());
|
||||
args.push('-D', `TMPDIR=${tmpPath}`);
|
||||
|
||||
const nodeRootPath = tryRealpath(
|
||||
const nodeRootPath = await tryRealpath(
|
||||
path.dirname(path.dirname(process.execPath)),
|
||||
);
|
||||
args.push('-D', `NODE_ROOT=${nodeRootPath}`);
|
||||
@@ -147,7 +142,7 @@ export function buildSeatbeltArgs(options: SeatbeltArgsOptions): string[] {
|
||||
for (const p of paths) {
|
||||
if (!p.trim()) continue;
|
||||
try {
|
||||
let resolved = tryRealpath(p);
|
||||
let resolved = await tryRealpath(p);
|
||||
|
||||
// If this is a 'bin' directory (like /usr/local/bin or homebrew/bin),
|
||||
// also grant read access to its parent directory so that symlinked
|
||||
@@ -170,10 +165,8 @@ export function buildSeatbeltArgs(options: SeatbeltArgsOptions): string[] {
|
||||
|
||||
// Handle allowedPaths
|
||||
const allowedPaths = sanitizePaths(options.allowedPaths) || [];
|
||||
const resolvedAllowedPaths: string[] = [];
|
||||
for (let i = 0; i < allowedPaths.length; i++) {
|
||||
const allowedPath = tryRealpath(allowedPaths[i]);
|
||||
resolvedAllowedPaths.push(allowedPath);
|
||||
const allowedPath = await tryRealpath(allowedPaths[i]);
|
||||
args.push('-D', `ALLOWED_PATH_${i}=${allowedPath}`);
|
||||
profile += `(allow file-read* file-write* (subpath (param "ALLOWED_PATH_${i}")))\n`;
|
||||
}
|
||||
@@ -183,7 +176,7 @@ export function buildSeatbeltArgs(options: SeatbeltArgsOptions): string[] {
|
||||
const { read, write } = options.additionalPermissions.fileSystem;
|
||||
if (read) {
|
||||
for (let i = 0; i < read.length; i++) {
|
||||
const resolved = tryRealpath(read[i]);
|
||||
const resolved = await tryRealpath(read[i]);
|
||||
const paramName = `ADDITIONAL_READ_${i}`;
|
||||
args.push('-D', `${paramName}=${resolved}`);
|
||||
let isFile = false;
|
||||
@@ -201,7 +194,7 @@ export function buildSeatbeltArgs(options: SeatbeltArgsOptions): string[] {
|
||||
}
|
||||
if (write) {
|
||||
for (let i = 0; i < write.length; i++) {
|
||||
const resolved = tryRealpath(write[i]);
|
||||
const resolved = await tryRealpath(write[i]);
|
||||
const paramName = `ADDITIONAL_WRITE_${i}`;
|
||||
args.push('-D', `${paramName}=${resolved}`);
|
||||
let isFile = false;
|
||||
@@ -222,7 +215,7 @@ export function buildSeatbeltArgs(options: SeatbeltArgsOptions): string[] {
|
||||
// Handle forbiddenPaths
|
||||
const forbiddenPaths = sanitizePaths(options.forbiddenPaths) || [];
|
||||
for (let i = 0; i < forbiddenPaths.length; i++) {
|
||||
const forbiddenPath = tryRealpath(forbiddenPaths[i]);
|
||||
const forbiddenPath = await tryRealpath(forbiddenPaths[i]);
|
||||
args.push('-D', `FORBIDDEN_PATH_${i}=${forbiddenPath}`);
|
||||
profile += `(deny file-read* file-write* (subpath (param "FORBIDDEN_PATH_${i}")))\n`;
|
||||
}
|
||||
@@ -235,23 +228,3 @@ export function buildSeatbeltArgs(options: SeatbeltArgsOptions): string[] {
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes a string for use within a Seatbelt regex literal #"..."
|
||||
*/
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\"]/g, (c) => {
|
||||
if (c === '"') {
|
||||
// Escape double quotes for the Scheme string literal
|
||||
return '\\"';
|
||||
}
|
||||
if (c === '\\') {
|
||||
// A literal backslash needs to be \\ in the regex.
|
||||
// To get \\ in the regex engine, we need \\\\ in the Scheme string literal.
|
||||
return '\\\\\\\\';
|
||||
}
|
||||
// For other regex special characters (like .), we need \c in the regex.
|
||||
// To get \c in the regex engine, we need \\c in the Scheme string literal.
|
||||
return '\\\\' + c;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type SandboxRequest } from '../../services/sandboxManager.js';
|
||||
import {
|
||||
getCommandRoots,
|
||||
initializeShellParsers,
|
||||
splitCommands,
|
||||
stripShellWrapper,
|
||||
} from '../../utils/shell-utils.js';
|
||||
import { isKnownSafeCommand } from './commandSafety.js';
|
||||
import { parse as shellParse } from 'shell-quote';
|
||||
import path from 'node:path';
|
||||
|
||||
export async function isStrictlyApproved(
|
||||
req: SandboxRequest,
|
||||
approvedTools?: string[],
|
||||
): Promise<boolean> {
|
||||
if (!approvedTools || approvedTools.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await initializeShellParsers();
|
||||
|
||||
const fullCmd = [req.command, ...req.args].join(' ');
|
||||
const stripped = stripShellWrapper(fullCmd);
|
||||
|
||||
const roots = getCommandRoots(stripped);
|
||||
if (roots.length === 0) return false;
|
||||
|
||||
const allRootsApproved = roots.every((root) => approvedTools.includes(root));
|
||||
if (allRootsApproved) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const pipelineCommands = splitCommands(stripped);
|
||||
if (pipelineCommands.length === 0) return false;
|
||||
|
||||
for (const cmdString of pipelineCommands) {
|
||||
const parsedArgs = shellParse(cmdString).map(String);
|
||||
if (!isKnownSafeCommand(parsedArgs)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function getCommandName(req: SandboxRequest): Promise<string> {
|
||||
await initializeShellParsers();
|
||||
const fullCmd = [req.command, ...req.args].join(' ');
|
||||
const stripped = stripShellWrapper(fullCmd);
|
||||
const roots = getCommandRoots(stripped).filter(
|
||||
(r) => r !== 'shopt' && r !== 'set',
|
||||
);
|
||||
if (roots.length > 0) {
|
||||
return roots[0];
|
||||
}
|
||||
return path.basename(req.command);
|
||||
}
|
||||
|
||||
export function verifySandboxOverrides(
|
||||
allowOverrides: boolean,
|
||||
policy: SandboxRequest['policy'],
|
||||
) {
|
||||
if (!allowOverrides) {
|
||||
if (
|
||||
policy?.networkAccess ||
|
||||
policy?.allowedPaths?.length ||
|
||||
policy?.additionalPermissions?.network ||
|
||||
policy?.additionalPermissions?.fileSystem?.read?.length ||
|
||||
policy?.additionalPermissions?.fileSystem?.write?.length
|
||||
) {
|
||||
throw new Error(
|
||||
'Sandbox request rejected: Cannot override readonly/network/filesystem restrictions in Plan mode.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export function isErrnoException(e: unknown): e is NodeJS.ErrnoException {
|
||||
return e instanceof Error && 'code' in e;
|
||||
}
|
||||
|
||||
export function tryRealpath(p: string): string {
|
||||
try {
|
||||
return fs.realpathSync(p);
|
||||
} catch (_e) {
|
||||
if (isErrnoException(_e) && _e.code === 'ENOENT') {
|
||||
const parentDir = path.dirname(p);
|
||||
if (parentDir === p) {
|
||||
return p;
|
||||
}
|
||||
return path.join(tryRealpath(parentDir), path.basename(p));
|
||||
}
|
||||
throw _e;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveGitWorktreePaths(workspacePath: string): {
|
||||
worktreeGitDir?: string;
|
||||
mainGitDir?: string;
|
||||
} {
|
||||
try {
|
||||
const gitPath = path.join(workspacePath, '.git');
|
||||
const gitStat = fs.lstatSync(gitPath);
|
||||
if (gitStat.isFile()) {
|
||||
const gitContent = fs.readFileSync(gitPath, 'utf8');
|
||||
const match = gitContent.match(/^gitdir:\s+(.+)$/m);
|
||||
if (match && match[1]) {
|
||||
let worktreeGitDir = match[1].trim();
|
||||
if (!path.isAbsolute(worktreeGitDir)) {
|
||||
worktreeGitDir = path.resolve(workspacePath, worktreeGitDir);
|
||||
}
|
||||
const resolvedWorktreeGitDir = tryRealpath(worktreeGitDir);
|
||||
|
||||
// Security check: Verify the bidirectional link to prevent sandbox escape
|
||||
let isValid = false;
|
||||
try {
|
||||
const backlinkPath = path.join(resolvedWorktreeGitDir, 'gitdir');
|
||||
const backlink = fs.readFileSync(backlinkPath, 'utf8').trim();
|
||||
// The backlink must resolve to the workspace's .git file
|
||||
if (tryRealpath(backlink) === tryRealpath(gitPath)) {
|
||||
isValid = true;
|
||||
}
|
||||
} catch (_e) {
|
||||
// Fallback for submodules: check core.worktree in config
|
||||
try {
|
||||
const configPath = path.join(resolvedWorktreeGitDir, 'config');
|
||||
const config = fs.readFileSync(configPath, 'utf8');
|
||||
const match = config.match(/^\s*worktree\s*=\s*(.+)$/m);
|
||||
if (match && match[1]) {
|
||||
const worktreePath = path.resolve(
|
||||
resolvedWorktreeGitDir,
|
||||
match[1].trim(),
|
||||
);
|
||||
if (tryRealpath(worktreePath) === tryRealpath(workspacePath)) {
|
||||
isValid = true;
|
||||
}
|
||||
}
|
||||
} catch (_e2) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
return {}; // Reject: valid worktrees/submodules must have a readable backlink
|
||||
}
|
||||
|
||||
const mainGitDir = tryRealpath(
|
||||
path.dirname(path.dirname(resolvedWorktreeGitDir)),
|
||||
);
|
||||
return {
|
||||
worktreeGitDir: resolvedWorktreeGitDir,
|
||||
mainGitDir: mainGitDir.endsWith('.git') ? mainGitDir : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignore if .git doesn't exist, isn't readable, etc.
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -5,88 +5,15 @@
|
||||
*/
|
||||
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Principal;
|
||||
using System.Text;
|
||||
using System.IO;
|
||||
|
||||
/**
|
||||
* A native C# helper for the Gemini CLI sandbox on Windows.
|
||||
* This helper uses Restricted Tokens and Job Objects to isolate processes.
|
||||
* It also supports internal commands for safe file I/O within the sandbox.
|
||||
*/
|
||||
public class GeminiSandbox {
|
||||
// P/Invoke constants and structures
|
||||
private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000;
|
||||
private const uint JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTION = 0x00000400;
|
||||
private const uint JOB_OBJECT_LIMIT_ACTIVE_PROCESS = 0x00000008;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
struct JOBOBJECT_BASIC_LIMIT_INFORMATION {
|
||||
public Int64 PerProcessUserTimeLimit;
|
||||
public Int64 PerJobUserTimeLimit;
|
||||
public uint LimitFlags;
|
||||
public UIntPtr MinimumWorkingSetSize;
|
||||
public UIntPtr MaximumWorkingSetSize;
|
||||
public uint ActiveProcessLimit;
|
||||
public UIntPtr Affinity;
|
||||
public uint PriorityClass;
|
||||
public uint SchedulingClass;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION {
|
||||
public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
|
||||
public IO_COUNTERS IoInfo;
|
||||
public UIntPtr ProcessMemoryLimit;
|
||||
public UIntPtr JobMemoryLimit;
|
||||
public UIntPtr PeakProcessMemoryUsed;
|
||||
public UIntPtr PeakJobMemoryUsed;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
struct IO_COUNTERS {
|
||||
public ulong ReadOperationCount;
|
||||
public ulong WriteOperationCount;
|
||||
public ulong OtherOperationCount;
|
||||
public ulong ReadTransferCount;
|
||||
public ulong WriteTransferCount;
|
||||
public ulong OtherTransferCount;
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string lpName);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
static extern bool SetInformationJobObject(IntPtr hJob, int JobObjectInfoClass, IntPtr lpJobObjectInfo, uint cbJobObjectInfoLength);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
static extern bool OpenProcessToken(IntPtr ProcessHandle, uint DesiredAccess, out IntPtr TokenHandle);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
static extern bool CreateRestrictedToken(IntPtr ExistingTokenHandle, uint Flags, uint DisableSidCount, IntPtr SidsToDisable, uint DeletePrivilegeCount, IntPtr PrivilegesToDelete, uint RestrictedSidCount, IntPtr SidsToRestrict, out IntPtr NewTokenHandle);
|
||||
|
||||
[DllImport("advapi32.dll", CharSet = CharSet.Auto, SetLastError = true)]
|
||||
static extern bool CreateProcessAsUser(IntPtr hToken, string lpApplicationName, string lpCommandLine, IntPtr lpProcessAttributes, IntPtr lpThreadAttributes, bool bInheritHandles, uint dwCreationFlags, IntPtr lpEnvironment, string lpCurrentDirectory, ref STARTUPINFO lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
static extern IntPtr GetCurrentProcess();
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
static extern bool CloseHandle(IntPtr hObject);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
static extern IntPtr GetStdHandle(int nStdHandle);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
struct STARTUPINFO {
|
||||
public struct STARTUPINFO {
|
||||
public uint cb;
|
||||
public string lpReserved;
|
||||
public string lpDesktop;
|
||||
@@ -99,8 +26,8 @@ public class GeminiSandbox {
|
||||
public uint dwYCountChars;
|
||||
public uint dwFillAttribute;
|
||||
public uint dwFlags;
|
||||
public short wShowWindow;
|
||||
public short cbReserved2;
|
||||
public ushort wShowWindow;
|
||||
public ushort cbReserved2;
|
||||
public IntPtr lpReserved2;
|
||||
public IntPtr hStdInput;
|
||||
public IntPtr hStdOutput;
|
||||
@@ -108,94 +35,180 @@ public class GeminiSandbox {
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
struct PROCESS_INFORMATION {
|
||||
public struct PROCESS_INFORMATION {
|
||||
public IntPtr hProcess;
|
||||
public IntPtr hThread;
|
||||
public uint dwProcessId;
|
||||
public uint dwThreadId;
|
||||
}
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
static extern bool ImpersonateLoggedOnUser(IntPtr hToken);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
static extern bool RevertToSelf();
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
|
||||
static extern uint GetLongPathName(string lpszShortPath, [Out] StringBuilder lpszLongPath, uint cchBuffer);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Auto)]
|
||||
static extern bool ConvertStringSidToSid(string StringSid, out IntPtr ptrSid);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
static extern bool SetTokenInformation(IntPtr TokenHandle, int TokenInformationClass, IntPtr TokenInformation, uint TokenInformationLength);
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct JOBOBJECT_BASIC_LIMIT_INFORMATION {
|
||||
public Int64 PerProcessUserTimeLimit;
|
||||
public Int64 PerJobUserTimeLimit;
|
||||
public uint LimitFlags;
|
||||
public UIntPtr MinimumWorkingSetSize;
|
||||
public UIntPtr MaximumWorkingSetSize;
|
||||
public uint ActiveProcessLimit;
|
||||
public UIntPtr Affinity;
|
||||
public uint PriorityClass;
|
||||
public uint SchedulingClass;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
struct SID_AND_ATTRIBUTES {
|
||||
public struct IO_COUNTERS {
|
||||
public ulong ReadOperationCount;
|
||||
public ulong WriteOperationCount;
|
||||
public ulong OtherOperationCount;
|
||||
public ulong ReadTransferCount;
|
||||
public ulong WriteTransferCount;
|
||||
public ulong OtherTransferCount;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION {
|
||||
public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
|
||||
public IO_COUNTERS IoInfo;
|
||||
public UIntPtr ProcessMemoryLimit;
|
||||
public UIntPtr JobMemoryLimit;
|
||||
public UIntPtr PeakProcessMemoryUsed;
|
||||
public UIntPtr PeakJobMemoryUsed;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct SID_AND_ATTRIBUTES {
|
||||
public IntPtr Sid;
|
||||
public uint Attributes;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
struct TOKEN_MANDATORY_LABEL {
|
||||
public struct TOKEN_MANDATORY_LABEL {
|
||||
public SID_AND_ATTRIBUTES Label;
|
||||
}
|
||||
|
||||
private const int TokenIntegrityLevel = 25;
|
||||
private const uint SE_GROUP_INTEGRITY = 0x00000020;
|
||||
public enum JobObjectInfoClass {
|
||||
ExtendedLimitInformation = 9
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern IntPtr GetCurrentProcess();
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
public static extern bool OpenProcessToken(IntPtr ProcessHandle, uint DesiredAccess, out IntPtr TokenHandle);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
public static extern bool CreateRestrictedToken(IntPtr ExistingTokenHandle, uint Flags, uint DisableSidCount, IntPtr SidsToDisable, uint DeletePrivilegeCount, IntPtr PrivilegesToDelete, uint RestrictedSidCount, IntPtr SidsToRestrict, out IntPtr NewTokenHandle);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
public static extern bool CreateProcessAsUser(IntPtr hToken, string lpApplicationName, string lpCommandLine, IntPtr lpProcessAttributes, IntPtr lpThreadAttributes, bool bInheritHandles, uint dwCreationFlags, IntPtr lpEnvironment, string lpCurrentDirectory, ref STARTUPINFO lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
public static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string lpName);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern bool SetInformationJobObject(IntPtr hJob, JobObjectInfoClass JobObjectInfoClass, IntPtr lpJobObjectInfo, uint cbJobObjectInfoLength);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern uint ResumeThread(IntPtr hThread);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern bool CloseHandle(IntPtr hObject);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern IntPtr GetStdHandle(int nStdHandle);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
public static extern bool ConvertStringSidToSid(string StringSid, out IntPtr Sid);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
public static extern bool SetTokenInformation(IntPtr TokenHandle, int TokenInformationClass, IntPtr TokenInformation, uint TokenInformationLength);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern IntPtr LocalFree(IntPtr hMem);
|
||||
|
||||
public const uint TOKEN_DUPLICATE = 0x0002;
|
||||
public const uint TOKEN_QUERY = 0x0008;
|
||||
public const uint TOKEN_ASSIGN_PRIMARY = 0x0001;
|
||||
public const uint TOKEN_ADJUST_DEFAULT = 0x0080;
|
||||
public const uint DISABLE_MAX_PRIVILEGE = 0x1;
|
||||
public const uint CREATE_SUSPENDED = 0x00000004;
|
||||
public const uint CREATE_UNICODE_ENVIRONMENT = 0x00000400;
|
||||
public const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000;
|
||||
public const uint STARTF_USESTDHANDLES = 0x00000100;
|
||||
public const int TokenIntegrityLevel = 25;
|
||||
public const uint SE_GROUP_INTEGRITY = 0x00000020;
|
||||
public const uint INFINITE = 0xFFFFFFFF;
|
||||
|
||||
static int Main(string[] args) {
|
||||
if (args.Length < 3) {
|
||||
Console.WriteLine("Usage: GeminiSandbox.exe <network:0|1> <cwd> [--forbidden-manifest <path>] <command> [args...]");
|
||||
Console.WriteLine("Usage: GeminiSandbox.exe <network:0|1> <cwd> <command> [args...]");
|
||||
Console.WriteLine("Internal commands: __read <path>, __write <path>");
|
||||
return 1;
|
||||
}
|
||||
|
||||
bool networkAccess = args[0] == "1";
|
||||
string cwd = args[1];
|
||||
HashSet<string> forbiddenPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
int argIndex = 2;
|
||||
|
||||
if (argIndex < args.Length && args[argIndex] == "--forbidden-manifest") {
|
||||
if (argIndex + 1 < args.Length) {
|
||||
string manifestPath = args[argIndex + 1];
|
||||
if (File.Exists(manifestPath)) {
|
||||
foreach (string line in File.ReadAllLines(manifestPath)) {
|
||||
if (!string.IsNullOrWhiteSpace(line)) {
|
||||
forbiddenPaths.Add(GetNormalizedPath(line.Trim()));
|
||||
}
|
||||
}
|
||||
}
|
||||
argIndex += 2;
|
||||
}
|
||||
}
|
||||
|
||||
if (argIndex >= args.Length) {
|
||||
Console.WriteLine("Error: Missing command");
|
||||
return 1;
|
||||
}
|
||||
|
||||
string command = args[argIndex];
|
||||
string command = args[2];
|
||||
|
||||
IntPtr hToken = IntPtr.Zero;
|
||||
IntPtr hRestrictedToken = IntPtr.Zero;
|
||||
IntPtr hJob = IntPtr.Zero;
|
||||
IntPtr pSidsToDisable = IntPtr.Zero;
|
||||
IntPtr pSidsToRestrict = IntPtr.Zero;
|
||||
IntPtr networkSid = IntPtr.Zero;
|
||||
IntPtr restrictedSid = IntPtr.Zero;
|
||||
IntPtr lowIntegritySid = IntPtr.Zero;
|
||||
|
||||
try {
|
||||
// 1. Create Restricted Token
|
||||
if (!OpenProcessToken(GetCurrentProcess(), 0x0002 /* TOKEN_DUPLICATE */ | 0x0008 /* TOKEN_QUERY */ | 0x0080 /* TOKEN_ADJUST_DEFAULT */, out hToken)) {
|
||||
Console.WriteLine("Error: OpenProcessToken failed (" + Marshal.GetLastWin32Error() + ")");
|
||||
// 1. Setup Token
|
||||
IntPtr hCurrentProcess = GetCurrentProcess();
|
||||
if (!OpenProcessToken(hCurrentProcess, TOKEN_DUPLICATE | TOKEN_QUERY | TOKEN_ASSIGN_PRIMARY | TOKEN_ADJUST_DEFAULT, out hToken)) {
|
||||
Console.Error.WriteLine("Failed to open process token");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Flags: 0x1 (DISABLE_MAX_PRIVILEGE)
|
||||
if (!CreateRestrictedToken(hToken, 1, 0, IntPtr.Zero, 0, IntPtr.Zero, 0, IntPtr.Zero, out hRestrictedToken)) {
|
||||
Console.WriteLine("Error: CreateRestrictedToken failed (" + Marshal.GetLastWin32Error() + ")");
|
||||
uint sidCount = 0;
|
||||
uint restrictCount = 0;
|
||||
|
||||
// "networkAccess == false" implies Strict Sandbox Level 1.
|
||||
if (!networkAccess) {
|
||||
if (ConvertStringSidToSid("S-1-5-2", out networkSid)) {
|
||||
sidCount = 1;
|
||||
int saaSize = Marshal.SizeOf(typeof(SID_AND_ATTRIBUTES));
|
||||
pSidsToDisable = Marshal.AllocHGlobal(saaSize);
|
||||
SID_AND_ATTRIBUTES saa = new SID_AND_ATTRIBUTES();
|
||||
saa.Sid = networkSid;
|
||||
saa.Attributes = 0;
|
||||
Marshal.StructureToPtr(saa, pSidsToDisable, false);
|
||||
}
|
||||
|
||||
// S-1-5-12 is Restricted Code SID
|
||||
if (ConvertStringSidToSid("S-1-5-12", out restrictedSid)) {
|
||||
restrictCount = 1;
|
||||
int saaSize = Marshal.SizeOf(typeof(SID_AND_ATTRIBUTES));
|
||||
pSidsToRestrict = Marshal.AllocHGlobal(saaSize);
|
||||
SID_AND_ATTRIBUTES saa = new SID_AND_ATTRIBUTES();
|
||||
saa.Sid = restrictedSid;
|
||||
saa.Attributes = 0;
|
||||
Marshal.StructureToPtr(saa, pSidsToRestrict, false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!CreateRestrictedToken(hToken, DISABLE_MAX_PRIVILEGE, sidCount, pSidsToDisable, 0, IntPtr.Zero, restrictCount, pSidsToRestrict, out hRestrictedToken)) {
|
||||
Console.Error.WriteLine("Failed to create restricted token");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 2. Lower Integrity Level to Low
|
||||
// S-1-16-4096 is the SID for "Low Mandatory Level"
|
||||
// 2. Set Integrity Level to Low
|
||||
if (ConvertStringSidToSid("S-1-16-4096", out lowIntegritySid)) {
|
||||
TOKEN_MANDATORY_LABEL tml = new TOKEN_MANDATORY_LABEL();
|
||||
tml.Label.Sid = lowIntegritySid;
|
||||
@@ -204,184 +217,154 @@ public class GeminiSandbox {
|
||||
IntPtr pTml = Marshal.AllocHGlobal(tmlSize);
|
||||
try {
|
||||
Marshal.StructureToPtr(tml, pTml, false);
|
||||
if (!SetTokenInformation(hRestrictedToken, TokenIntegrityLevel, pTml, (uint)tmlSize)) {
|
||||
Console.WriteLine("Error: SetTokenInformation failed (" + Marshal.GetLastWin32Error() + ")");
|
||||
return 1;
|
||||
}
|
||||
SetTokenInformation(hRestrictedToken, TokenIntegrityLevel, pTml, (uint)tmlSize);
|
||||
} finally {
|
||||
Marshal.FreeHGlobal(pTml);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Setup Job Object for cleanup
|
||||
IntPtr hJob = CreateJobObject(IntPtr.Zero, null);
|
||||
JOBOBJECT_EXTENDED_LIMIT_INFORMATION jobLimits = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION();
|
||||
jobLimits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTION;
|
||||
|
||||
IntPtr lpJobLimits = Marshal.AllocHGlobal(Marshal.SizeOf(jobLimits));
|
||||
Marshal.StructureToPtr(jobLimits, lpJobLimits, false);
|
||||
SetInformationJobObject(hJob, 9 /* JobObjectExtendedLimitInformation */, lpJobLimits, (uint)Marshal.SizeOf(jobLimits));
|
||||
Marshal.FreeHGlobal(lpJobLimits);
|
||||
|
||||
// 4. Handle Internal Commands or External Process
|
||||
// 3. Handle Internal Commands or External Process
|
||||
if (command == "__read") {
|
||||
if (argIndex + 1 >= args.Length) {
|
||||
Console.WriteLine("Error: Missing path for __read");
|
||||
return 1;
|
||||
}
|
||||
string path = args[argIndex + 1];
|
||||
CheckForbidden(path, forbiddenPaths);
|
||||
string path = args[3];
|
||||
return RunInImpersonation(hRestrictedToken, () => {
|
||||
try {
|
||||
using (FileStream fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read))
|
||||
using (Stream stdout = Console.OpenStandardOutput()) {
|
||||
fs.CopyTo(stdout);
|
||||
using (StreamReader sr = new StreamReader(fs, System.Text.Encoding.UTF8)) {
|
||||
char[] buffer = new char[4096];
|
||||
int bytesRead;
|
||||
while ((bytesRead = sr.Read(buffer, 0, buffer.Length)) > 0) {
|
||||
Console.Write(buffer, 0, bytesRead);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
} catch (Exception e) {
|
||||
Console.Error.WriteLine("Error reading file: " + e.Message);
|
||||
Console.Error.WriteLine(e.Message);
|
||||
return 1;
|
||||
}
|
||||
});
|
||||
} else if (command == "__write") {
|
||||
if (argIndex + 1 >= args.Length) {
|
||||
Console.WriteLine("Error: Missing path for __write");
|
||||
return 1;
|
||||
}
|
||||
string path = args[argIndex + 1];
|
||||
CheckForbidden(path, forbiddenPaths);
|
||||
string path = args[3];
|
||||
return RunInImpersonation(hRestrictedToken, () => {
|
||||
try {
|
||||
using (StreamReader reader = new StreamReader(Console.OpenStandardInput(), System.Text.Encoding.UTF8))
|
||||
using (FileStream fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None))
|
||||
using (StreamWriter writer = new StreamWriter(fs, System.Text.Encoding.UTF8)) {
|
||||
writer.Write(reader.ReadToEnd());
|
||||
char[] buffer = new char[4096];
|
||||
int bytesRead;
|
||||
while ((bytesRead = reader.Read(buffer, 0, buffer.Length)) > 0) {
|
||||
writer.Write(buffer, 0, bytesRead);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
} catch (Exception e) {
|
||||
Console.Error.WriteLine("Error writing file: " + e.Message);
|
||||
Console.Error.WriteLine(e.Message);
|
||||
return 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// External Process
|
||||
// 4. Setup Job Object for external process
|
||||
hJob = CreateJobObject(IntPtr.Zero, null);
|
||||
if (hJob != IntPtr.Zero) {
|
||||
JOBOBJECT_EXTENDED_LIMIT_INFORMATION limitInfo = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION();
|
||||
limitInfo.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||
int limitSize = Marshal.SizeOf(limitInfo);
|
||||
IntPtr pLimit = Marshal.AllocHGlobal(limitSize);
|
||||
try {
|
||||
Marshal.StructureToPtr(limitInfo, pLimit, false);
|
||||
SetInformationJobObject(hJob, JobObjectInfoClass.ExtendedLimitInformation, pLimit, (uint)limitSize);
|
||||
} finally {
|
||||
Marshal.FreeHGlobal(pLimit);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Launch Process
|
||||
STARTUPINFO si = new STARTUPINFO();
|
||||
si.cb = (uint)Marshal.SizeOf(si);
|
||||
si.dwFlags = 0x00000100; // STARTF_USESTDHANDLES
|
||||
si.dwFlags = STARTF_USESTDHANDLES;
|
||||
si.hStdInput = GetStdHandle(-10);
|
||||
si.hStdOutput = GetStdHandle(-11);
|
||||
si.hStdError = GetStdHandle(-12);
|
||||
|
||||
string commandLine = "";
|
||||
for (int i = argIndex; i < args.Length; i++) {
|
||||
if (i > argIndex) commandLine += " ";
|
||||
for (int i = 2; i < args.Length; i++) {
|
||||
if (i > 2) commandLine += " ";
|
||||
commandLine += QuoteArgument(args[i]);
|
||||
}
|
||||
|
||||
PROCESS_INFORMATION pi = new PROCESS_INFORMATION();
|
||||
// Creation Flags: 0x04000000 (CREATE_BREAKAWAY_FROM_JOB) to allow job assignment if parent is in job
|
||||
uint creationFlags = 0;
|
||||
if (!CreateProcessAsUser(hRestrictedToken, null, commandLine, IntPtr.Zero, IntPtr.Zero, true, creationFlags, IntPtr.Zero, cwd, ref si, out pi)) {
|
||||
Console.WriteLine("Error: CreateProcessAsUser failed (" + Marshal.GetLastWin32Error() + ") Command: " + commandLine);
|
||||
PROCESS_INFORMATION pi;
|
||||
if (!CreateProcessAsUser(hRestrictedToken, null, commandLine, IntPtr.Zero, IntPtr.Zero, true, CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT, IntPtr.Zero, cwd, ref si, out pi)) {
|
||||
Console.Error.WriteLine("Failed to create process. Error: " + Marshal.GetLastWin32Error());
|
||||
return 1;
|
||||
}
|
||||
|
||||
AssignProcessToJobObject(hJob, pi.hProcess);
|
||||
|
||||
// Wait for exit
|
||||
uint waitResult = WaitForSingleObject(pi.hProcess, 0xFFFFFFFF);
|
||||
uint exitCode = 0;
|
||||
GetExitCodeProcess(pi.hProcess, out exitCode);
|
||||
try {
|
||||
if (hJob != IntPtr.Zero) {
|
||||
AssignProcessToJobObject(hJob, pi.hProcess);
|
||||
}
|
||||
|
||||
CloseHandle(pi.hProcess);
|
||||
CloseHandle(pi.hThread);
|
||||
CloseHandle(hJob);
|
||||
ResumeThread(pi.hThread);
|
||||
WaitForSingleObject(pi.hProcess, INFINITE);
|
||||
|
||||
return (int)exitCode;
|
||||
} finally {
|
||||
if (hToken != IntPtr.Zero) CloseHandle(hToken);
|
||||
if (hRestrictedToken != IntPtr.Zero) CloseHandle(hRestrictedToken);
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode);
|
||||
|
||||
private static int RunInImpersonation(IntPtr hToken, Func<int> action) {
|
||||
if (!ImpersonateLoggedOnUser(hToken)) {
|
||||
Console.WriteLine("Error: ImpersonateLoggedOnUser failed (" + Marshal.GetLastWin32Error() + ")");
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
return action();
|
||||
} finally {
|
||||
RevertToSelf();
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetNormalizedPath(string path) {
|
||||
string fullPath = Path.GetFullPath(path);
|
||||
StringBuilder longPath = new StringBuilder(1024);
|
||||
uint result = GetLongPathName(fullPath, longPath, (uint)longPath.Capacity);
|
||||
if (result > 0 && result < longPath.Capacity) {
|
||||
return longPath.ToString();
|
||||
}
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
private static void CheckForbidden(string path, HashSet<string> forbiddenPaths) {
|
||||
string fullPath = GetNormalizedPath(path);
|
||||
foreach (string forbidden in forbiddenPaths) {
|
||||
if (fullPath.Equals(forbidden, StringComparison.OrdinalIgnoreCase) || fullPath.StartsWith(forbidden + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) {
|
||||
throw new UnauthorizedAccessException("Access to forbidden path is denied: " + path);
|
||||
uint exitCode = 0;
|
||||
GetExitCodeProcess(pi.hProcess, out exitCode);
|
||||
return (int)exitCode;
|
||||
} finally {
|
||||
CloseHandle(pi.hProcess);
|
||||
CloseHandle(pi.hThread);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Console.Error.WriteLine("Unexpected error: " + e.Message);
|
||||
return 1;
|
||||
} finally {
|
||||
if (hRestrictedToken != IntPtr.Zero) CloseHandle(hRestrictedToken);
|
||||
if (hToken != IntPtr.Zero) CloseHandle(hToken);
|
||||
if (hJob != IntPtr.Zero) CloseHandle(hJob);
|
||||
if (pSidsToDisable != IntPtr.Zero) Marshal.FreeHGlobal(pSidsToDisable);
|
||||
if (pSidsToRestrict != IntPtr.Zero) Marshal.FreeHGlobal(pSidsToRestrict);
|
||||
if (networkSid != IntPtr.Zero) LocalFree(networkSid);
|
||||
if (restrictedSid != IntPtr.Zero) LocalFree(restrictedSid);
|
||||
if (lowIntegritySid != IntPtr.Zero) LocalFree(lowIntegritySid);
|
||||
}
|
||||
}
|
||||
|
||||
private static string QuoteArgument(string arg) {
|
||||
if (string.IsNullOrEmpty(arg)) return "\"\"";
|
||||
|
||||
bool needsQuotes = false;
|
||||
foreach (char c in arg) {
|
||||
if (char.IsWhiteSpace(c) || c == '\"') {
|
||||
needsQuotes = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
bool hasSpace = arg.IndexOfAny(new char[] { ' ', '\t' }) != -1;
|
||||
if (!hasSpace && arg.IndexOf('\"') == -1) return arg;
|
||||
|
||||
if (!needsQuotes) return arg;
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
// Windows command line escaping for arguments is complex.
|
||||
// Rule: Backslashes only need escaping if they precede a double quote or the end of the string.
|
||||
System.Text.StringBuilder sb = new System.Text.StringBuilder();
|
||||
sb.Append('\"');
|
||||
for (int i = 0; i < arg.Length; i++) {
|
||||
char c = arg[i];
|
||||
if (c == '\"') {
|
||||
sb.Append("\\\"");
|
||||
} else if (c == '\\') {
|
||||
int backslashCount = 0;
|
||||
while (i < arg.Length && arg[i] == '\\') {
|
||||
backslashCount++;
|
||||
i++;
|
||||
}
|
||||
int backslashCount = 0;
|
||||
while (i < arg.Length && arg[i] == '\\') {
|
||||
backslashCount++;
|
||||
i++;
|
||||
}
|
||||
|
||||
if (i == arg.Length) {
|
||||
sb.Append('\\', backslashCount * 2);
|
||||
} else if (arg[i] == '\"') {
|
||||
sb.Append('\\', backslashCount * 2 + 1);
|
||||
sb.Append('\"');
|
||||
} else {
|
||||
sb.Append('\\', backslashCount);
|
||||
sb.Append(arg[i]);
|
||||
}
|
||||
if (i == arg.Length) {
|
||||
// Escape backslashes before the closing double quote
|
||||
sb.Append('\\', backslashCount * 2);
|
||||
} else if (arg[i] == '\"') {
|
||||
// Escape backslashes before a literal double quote
|
||||
sb.Append('\\', backslashCount * 2 + 1);
|
||||
sb.Append('\"');
|
||||
} else {
|
||||
sb.Append(c);
|
||||
// Backslashes don't need escaping here
|
||||
sb.Append('\\', backslashCount);
|
||||
sb.Append(arg[i]);
|
||||
}
|
||||
}
|
||||
sb.Append('\"');
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static int RunInImpersonation(IntPtr hToken, Func<int> action) {
|
||||
using (WindowsIdentity.Impersonate(hToken)) {
|
||||
return action();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,14 +60,7 @@ describe('WindowsSandboxManager', () => {
|
||||
const result = await manager.prepareCommand(req);
|
||||
|
||||
expect(result.program).toContain('GeminiSandbox.exe');
|
||||
expect(result.args).toEqual([
|
||||
'0',
|
||||
testCwd,
|
||||
'--forbidden-manifest',
|
||||
expect.stringMatching(/manifest\.txt$/),
|
||||
'whoami',
|
||||
'/groups',
|
||||
]);
|
||||
expect(result.args).toEqual(['0', testCwd, 'whoami', '/groups']);
|
||||
});
|
||||
|
||||
it('should handle networkAccess from config', async () => {
|
||||
@@ -118,7 +111,7 @@ describe('WindowsSandboxManager', () => {
|
||||
};
|
||||
|
||||
await expect(planManager.prepareCommand(req)).rejects.toThrow(
|
||||
'Sandbox request rejected: Cannot override readonly/network/filesystem restrictions in Plan mode.',
|
||||
'Sandbox request rejected: Cannot override readonly/network restrictions in Plan mode.',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
type SandboxRequest,
|
||||
type SandboxedCommand,
|
||||
GOVERNANCE_FILES,
|
||||
findSecretFiles,
|
||||
type GlobalSandboxOptions,
|
||||
sanitizePaths,
|
||||
tryRealpath,
|
||||
@@ -32,7 +31,6 @@ import {
|
||||
isStrictlyApproved,
|
||||
} from './commandSafety.js';
|
||||
import { type SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js';
|
||||
import { verifySandboxOverrides } from '../utils/commandUtils.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -216,7 +214,17 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
const allowOverrides = this.options.modeConfig?.allowOverrides ?? true;
|
||||
|
||||
// Reject override attempts in plan mode
|
||||
verifySandboxOverrides(allowOverrides, req.policy);
|
||||
if (!allowOverrides && req.policy?.additionalPermissions) {
|
||||
const perms = req.policy.additionalPermissions;
|
||||
if (
|
||||
perms.network ||
|
||||
(perms.fileSystem?.write && perms.fileSystem.write.length > 0)
|
||||
) {
|
||||
throw new Error(
|
||||
'Sandbox request rejected: Cannot override readonly/network restrictions in Plan mode.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch persistent approvals for this command
|
||||
const commandName = await getCommandName(req.command, req.args);
|
||||
@@ -270,96 +278,43 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
await this.grantLowIntegrityAccess(writePath);
|
||||
}
|
||||
|
||||
// 2. Collect secret files and apply protective ACLs
|
||||
// On Windows, we explicitly deny access to secret files for Low Integrity
|
||||
// processes to ensure they cannot be read or written.
|
||||
const secretsToBlock: string[] = [];
|
||||
const searchDirs = new Set([this.options.workspace, ...allowedPaths]);
|
||||
for (const dir of searchDirs) {
|
||||
try {
|
||||
// We use maxDepth 3 to catch common nested secrets while keeping performance high.
|
||||
const secretFiles = await findSecretFiles(dir, 3);
|
||||
for (const secretFile of secretFiles) {
|
||||
try {
|
||||
secretsToBlock.push(secretFile);
|
||||
await this.denyLowIntegrityAccess(secretFile);
|
||||
} catch (e) {
|
||||
debugLogger.log(
|
||||
`WindowsSandboxManager: Failed to secure secret file ${secretFile}`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugLogger.log(
|
||||
`WindowsSandboxManager: Failed to find secret files in ${dir}`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Denies access to forbiddenPaths for Low Integrity processes.
|
||||
// Note: Denying access to arbitrary paths (like system files) via icacls
|
||||
// is restricted to avoid host corruption. External commands rely on
|
||||
// Low Integrity read/write restrictions, while internal commands
|
||||
// use the manifest for enforcement.
|
||||
const forbiddenPaths = sanitizePaths(req.policy?.forbiddenPaths) || [];
|
||||
for (const forbiddenPath of forbiddenPaths) {
|
||||
try {
|
||||
await this.denyLowIntegrityAccess(forbiddenPath);
|
||||
} catch (e) {
|
||||
debugLogger.log(
|
||||
`WindowsSandboxManager: Failed to secure forbidden path ${forbiddenPath}`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
await this.denyLowIntegrityAccess(forbiddenPath);
|
||||
}
|
||||
|
||||
// 3. Protected governance files
|
||||
// 2. Protected governance files
|
||||
// These must exist on the host before running the sandbox to prevent
|
||||
// the sandboxed process from creating them with Low integrity.
|
||||
// By being created as Medium integrity, they are write-protected from Low processes.
|
||||
for (const file of GOVERNANCE_FILES) {
|
||||
const filePath = path.join(this.options.workspace, file.path);
|
||||
this.touch(filePath, file.isDirectory);
|
||||
|
||||
// We resolve real paths to ensure protection for both the symlink and its target.
|
||||
try {
|
||||
const realPath = fs.realpathSync(filePath);
|
||||
if (realPath !== filePath) {
|
||||
// If it's a symlink, the target is already implicitly protected
|
||||
// if it's outside the Low integrity workspace (likely Medium).
|
||||
// If it's inside, we ensure it's not accidentally Low.
|
||||
}
|
||||
} catch {
|
||||
// Ignore realpath errors
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Forbidden paths manifest
|
||||
// We use a manifest file to avoid command-line length limits.
|
||||
const allForbidden = Array.from(
|
||||
new Set([...secretsToBlock, ...forbiddenPaths]),
|
||||
);
|
||||
const tempDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-cli-forbidden-'),
|
||||
);
|
||||
const manifestPath = path.join(tempDir, 'manifest.txt');
|
||||
fs.writeFileSync(manifestPath, allForbidden.join('\n'));
|
||||
|
||||
// Cleanup on exit
|
||||
process.on('exit', () => {
|
||||
try {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
});
|
||||
|
||||
// 5. Construct the helper command
|
||||
// GeminiSandbox.exe <network:0|1> <cwd> --forbidden-manifest <path> <command> [args...]
|
||||
// 3. Construct the helper command
|
||||
// GeminiSandbox.exe <network:0|1> <cwd> <command> [args...]
|
||||
const program = this.helperPath;
|
||||
|
||||
const defaultNetwork =
|
||||
this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false;
|
||||
const networkAccess = defaultNetwork || mergedAdditional.network;
|
||||
|
||||
const args = [
|
||||
networkAccess ? '1' : '0',
|
||||
req.cwd,
|
||||
'--forbidden-manifest',
|
||||
manifestPath,
|
||||
req.command,
|
||||
...req.args,
|
||||
];
|
||||
// If the command starts with __, it's an internal command for the sandbox helper itself.
|
||||
const args = [networkAccess ? '1' : '0', req.cwd, req.command, ...req.args];
|
||||
|
||||
return {
|
||||
program,
|
||||
@@ -396,7 +351,17 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isSystemDirectory(resolvedPath)) {
|
||||
// Never modify integrity levels for system directories
|
||||
const systemRoot = process.env['SystemRoot'] || 'C:\\Windows';
|
||||
const programFiles = process.env['ProgramFiles'] || 'C:\\Program Files';
|
||||
const programFilesX86 =
|
||||
process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
|
||||
|
||||
if (
|
||||
resolvedPath.toLowerCase().startsWith(systemRoot.toLowerCase()) ||
|
||||
resolvedPath.toLowerCase().startsWith(programFiles.toLowerCase()) ||
|
||||
resolvedPath.toLowerCase().startsWith(programFilesX86.toLowerCase())
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -425,11 +390,6 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// Never modify ACEs for system directories
|
||||
if (this.isSystemDirectory(resolvedPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// S-1-16-4096 is the SID for "Low Mandatory Level" (Low Integrity)
|
||||
const LOW_INTEGRITY_SID = '*S-1-16-4096';
|
||||
|
||||
@@ -466,17 +426,4 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private isSystemDirectory(resolvedPath: string): boolean {
|
||||
const systemRoot = process.env['SystemRoot'] || 'C:\\Windows';
|
||||
const programFiles = process.env['ProgramFiles'] || 'C:\\Program Files';
|
||||
const programFilesX86 =
|
||||
process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
|
||||
|
||||
return (
|
||||
resolvedPath.toLowerCase().startsWith(systemRoot.toLowerCase()) ||
|
||||
resolvedPath.toLowerCase().startsWith(programFiles.toLowerCase()) ||
|
||||
resolvedPath.toLowerCase().startsWith(programFilesX86.toLowerCase())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,9 +375,9 @@ describe('sanitizeEnvironment', () => {
|
||||
});
|
||||
|
||||
describe('getSecureSanitizationConfig', () => {
|
||||
it('should default enableEnvironmentVariableRedaction to false', () => {
|
||||
it('should enable environment variable redaction by default', () => {
|
||||
const config = getSecureSanitizationConfig();
|
||||
expect(config.enableEnvironmentVariableRedaction).toBe(false);
|
||||
expect(config.enableEnvironmentVariableRedaction).toBe(true);
|
||||
});
|
||||
|
||||
it('should merge allowed and blocked variables from base and requested configs', () => {
|
||||
@@ -440,13 +440,13 @@ describe('getSecureSanitizationConfig', () => {
|
||||
expect(config.blockedEnvironmentVariables).toEqual(['BLOCKED_VAR']);
|
||||
});
|
||||
|
||||
it('should respect requested enableEnvironmentVariableRedaction value', () => {
|
||||
it('should force enableEnvironmentVariableRedaction to true even if requested false', () => {
|
||||
const requestedConfig = {
|
||||
enableEnvironmentVariableRedaction: false,
|
||||
};
|
||||
|
||||
const config = getSecureSanitizationConfig(requestedConfig);
|
||||
|
||||
expect(config.enableEnvironmentVariableRedaction).toBe(false);
|
||||
expect(config.enableEnvironmentVariableRedaction).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -230,9 +230,6 @@ export function getSecureSanitizationConfig(
|
||||
allowedEnvironmentVariables: [...new Set(allowed)],
|
||||
blockedEnvironmentVariables: [...new Set(blocked)],
|
||||
// Redaction must be enabled for secure configurations
|
||||
enableEnvironmentVariableRedaction:
|
||||
requestedConfig.enableEnvironmentVariableRedaction ??
|
||||
baseConfig?.enableEnvironmentVariableRedaction ??
|
||||
false,
|
||||
enableEnvironmentVariableRedaction: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export type ExecutionMethod =
|
||||
| 'none';
|
||||
|
||||
export interface ExecutionResult {
|
||||
rawOutput?: Buffer;
|
||||
rawOutput: Buffer;
|
||||
output: string;
|
||||
exitCode: number | null;
|
||||
signal: number | null;
|
||||
|
||||
@@ -108,18 +108,7 @@ function ensureSandboxAvailable(): boolean {
|
||||
|
||||
if (platform === 'darwin') {
|
||||
if (fs.existsSync('/usr/bin/sandbox-exec')) {
|
||||
try {
|
||||
execSync('sandbox-exec -p "(version 1)(allow default)" echo test', {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
'sandbox-exec is present but cannot be used (likely running inside a sandbox already). Skipping sandbox tests.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
throw new Error(
|
||||
'Sandboxing tests on macOS require /usr/bin/sandbox-exec to be present.',
|
||||
|
||||
@@ -3,120 +3,20 @@
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import fs from 'node:fs/promises';
|
||||
import { afterEach, describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
NoopSandboxManager,
|
||||
LocalSandboxManager,
|
||||
sanitizePaths,
|
||||
findSecretFiles,
|
||||
isSecretFile,
|
||||
tryRealpath,
|
||||
} from './sandboxManager.js';
|
||||
import { createSandboxManager } from './sandboxManagerFactory.js';
|
||||
import { LinuxSandboxManager } from '../sandbox/linux/LinuxSandboxManager.js';
|
||||
import { MacOsSandboxManager } from '../sandbox/macos/MacOsSandboxManager.js';
|
||||
import { WindowsSandboxManager } from '../sandbox/windows/WindowsSandboxManager.js';
|
||||
import type fs from 'node:fs';
|
||||
|
||||
vi.mock('node:fs/promises', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('node:fs/promises')>(
|
||||
'node:fs/promises',
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
...actual,
|
||||
readdir: vi.fn(),
|
||||
realpath: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
},
|
||||
readdir: vi.fn(),
|
||||
realpath: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('isSecretFile', () => {
|
||||
it('should return true for .env', () => {
|
||||
expect(isSecretFile('.env')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for .env.local', () => {
|
||||
expect(isSecretFile('.env.local')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for .env.production', () => {
|
||||
expect(isSecretFile('.env.production')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for regular files', () => {
|
||||
expect(isSecretFile('package.json')).toBe(false);
|
||||
expect(isSecretFile('index.ts')).toBe(false);
|
||||
expect(isSecretFile('.gitignore')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for files starting with .env but not matching pattern', () => {
|
||||
// This depends on the pattern ".env.*". ".env-backup" would match ".env*" but not ".env.*"
|
||||
expect(isSecretFile('.env-backup')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSecretFiles', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should find secret files in the root directory', async () => {
|
||||
vi.mocked(fsPromises.readdir).mockImplementation(((dir: string) => {
|
||||
if (dir === '/workspace') {
|
||||
return Promise.resolve([
|
||||
{ name: '.env', isDirectory: () => false, isFile: () => true },
|
||||
{
|
||||
name: 'package.json',
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
},
|
||||
{ name: 'src', isDirectory: () => true, isFile: () => false },
|
||||
] as unknown as fs.Dirent[]);
|
||||
}
|
||||
return Promise.resolve([] as unknown as fs.Dirent[]);
|
||||
}) as unknown as typeof fsPromises.readdir);
|
||||
|
||||
const secrets = await findSecretFiles('/workspace');
|
||||
expect(secrets).toEqual([path.join('/workspace', '.env')]);
|
||||
});
|
||||
|
||||
it('should NOT find secret files recursively (shallow scan only)', async () => {
|
||||
vi.mocked(fsPromises.readdir).mockImplementation(((dir: string) => {
|
||||
if (dir === '/workspace') {
|
||||
return Promise.resolve([
|
||||
{ name: '.env', isDirectory: () => false, isFile: () => true },
|
||||
{ name: 'packages', isDirectory: () => true, isFile: () => false },
|
||||
] as unknown as fs.Dirent[]);
|
||||
}
|
||||
if (dir === path.join('/workspace', 'packages')) {
|
||||
return Promise.resolve([
|
||||
{ name: '.env.local', isDirectory: () => false, isFile: () => true },
|
||||
] as unknown as fs.Dirent[]);
|
||||
}
|
||||
return Promise.resolve([] as unknown as fs.Dirent[]);
|
||||
}) as unknown as typeof fsPromises.readdir);
|
||||
|
||||
const secrets = await findSecretFiles('/workspace');
|
||||
expect(secrets).toEqual([path.join('/workspace', '.env')]);
|
||||
// Should NOT have called readdir for subdirectories
|
||||
expect(fsPromises.readdir).toHaveBeenCalledTimes(1);
|
||||
expect(fsPromises.readdir).not.toHaveBeenCalledWith(
|
||||
path.join('/workspace', 'packages'),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SandboxManager', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
@@ -148,30 +48,24 @@ describe('SandboxManager', () => {
|
||||
});
|
||||
|
||||
it('should return the realpath if the file exists', async () => {
|
||||
vi.mocked(fsPromises.realpath).mockResolvedValue(
|
||||
'/real/path/to/file.txt' as never,
|
||||
);
|
||||
vi.spyOn(fs, 'realpath').mockResolvedValue('/real/path/to/file.txt');
|
||||
const result = await tryRealpath('/some/symlink/to/file.txt');
|
||||
expect(result).toBe('/real/path/to/file.txt');
|
||||
expect(fsPromises.realpath).toHaveBeenCalledWith(
|
||||
'/some/symlink/to/file.txt',
|
||||
);
|
||||
expect(fs.realpath).toHaveBeenCalledWith('/some/symlink/to/file.txt');
|
||||
});
|
||||
|
||||
it('should fallback to parent directory if file does not exist (ENOENT)', async () => {
|
||||
vi.mocked(fsPromises.realpath).mockImplementation(((p: string) => {
|
||||
vi.spyOn(fs, 'realpath').mockImplementation(async (p) => {
|
||||
if (p === '/workspace/nonexistent.txt') {
|
||||
return Promise.reject(
|
||||
Object.assign(new Error('ENOENT: no such file or directory'), {
|
||||
code: 'ENOENT',
|
||||
}),
|
||||
);
|
||||
throw Object.assign(new Error('ENOENT: no such file or directory'), {
|
||||
code: 'ENOENT',
|
||||
});
|
||||
}
|
||||
if (p === '/workspace') {
|
||||
return Promise.resolve('/real/workspace');
|
||||
return '/real/workspace';
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected path: ${p}`));
|
||||
}) as never);
|
||||
throw new Error(`Unexpected path: ${p}`);
|
||||
});
|
||||
|
||||
const result = await tryRealpath('/workspace/nonexistent.txt');
|
||||
|
||||
@@ -180,22 +74,18 @@ describe('SandboxManager', () => {
|
||||
});
|
||||
|
||||
it('should recursively fallback up the directory tree on multiple ENOENT errors', async () => {
|
||||
vi.mocked(fsPromises.realpath).mockImplementation(((p: string) => {
|
||||
vi.spyOn(fs, 'realpath').mockImplementation(async (p) => {
|
||||
if (p === '/workspace/missing_dir/missing_file.txt') {
|
||||
return Promise.reject(
|
||||
Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
|
||||
);
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
}
|
||||
if (p === '/workspace/missing_dir') {
|
||||
return Promise.reject(
|
||||
Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
|
||||
);
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
}
|
||||
if (p === '/workspace') {
|
||||
return Promise.resolve('/real/workspace');
|
||||
return '/real/workspace';
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected path: ${p}`));
|
||||
}) as never);
|
||||
throw new Error(`Unexpected path: ${p}`);
|
||||
});
|
||||
|
||||
const result = await tryRealpath(
|
||||
'/workspace/missing_dir/missing_file.txt',
|
||||
@@ -209,22 +99,20 @@ describe('SandboxManager', () => {
|
||||
|
||||
it('should return the path unchanged if it reaches the root directory and it still does not exist', async () => {
|
||||
const rootPath = path.resolve('/');
|
||||
vi.mocked(fsPromises.realpath).mockImplementation(() =>
|
||||
Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })),
|
||||
);
|
||||
vi.spyOn(fs, 'realpath').mockImplementation(async () => {
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
const result = await tryRealpath(rootPath);
|
||||
expect(result).toBe(rootPath);
|
||||
});
|
||||
|
||||
it('should throw an error if realpath fails with a non-ENOENT error (e.g. EACCES)', async () => {
|
||||
vi.mocked(fsPromises.realpath).mockImplementation(() =>
|
||||
Promise.reject(
|
||||
Object.assign(new Error('EACCES: permission denied'), {
|
||||
code: 'EACCES',
|
||||
}),
|
||||
),
|
||||
);
|
||||
vi.spyOn(fs, 'realpath').mockImplementation(async () => {
|
||||
throw Object.assign(new Error('EACCES: permission denied'), {
|
||||
code: 'EACCES',
|
||||
});
|
||||
});
|
||||
|
||||
await expect(tryRealpath('/secret/file.txt')).rejects.toThrow(
|
||||
'EACCES: permission denied',
|
||||
@@ -260,11 +148,6 @@ describe('SandboxManager', () => {
|
||||
MY_SECRET: 'super-secret',
|
||||
SAFE_VAR: 'is-safe',
|
||||
},
|
||||
policy: {
|
||||
sanitizationConfig: {
|
||||
enableEnvironmentVariableRedaction: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await sandboxManager.prepareCommand(req);
|
||||
@@ -275,7 +158,7 @@ describe('SandboxManager', () => {
|
||||
expect(result.env['MY_SECRET']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow disabling environment variable redaction if requested in config', async () => {
|
||||
it('should NOT allow disabling environment variable redaction if requested in config (vulnerability fix)', async () => {
|
||||
const req = {
|
||||
command: 'echo',
|
||||
args: ['hello'],
|
||||
@@ -292,8 +175,8 @@ describe('SandboxManager', () => {
|
||||
|
||||
const result = await sandboxManager.prepareCommand(req);
|
||||
|
||||
// API_KEY should be preserved because redaction was explicitly disabled
|
||||
expect(result.env['API_KEY']).toBe('sensitive-key');
|
||||
// API_KEY should be redacted because SandboxManager forces redaction and API_KEY matches NEVER_ALLOWED_NAME_PATTERNS
|
||||
expect(result.env['API_KEY']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should respect allowedEnvironmentVariables in config but filter sensitive ones', async () => {
|
||||
@@ -308,7 +191,6 @@ describe('SandboxManager', () => {
|
||||
policy: {
|
||||
sanitizationConfig: {
|
||||
allowedEnvironmentVariables: ['MY_SAFE_VAR', 'MY_TOKEN'],
|
||||
enableEnvironmentVariableRedaction: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -332,7 +214,6 @@ describe('SandboxManager', () => {
|
||||
policy: {
|
||||
sanitizationConfig: {
|
||||
blockedEnvironmentVariables: ['BLOCKED_VAR'],
|
||||
enableEnvironmentVariableRedaction: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ import path from 'node:path';
|
||||
import {
|
||||
isKnownSafeCommand as isMacSafeCommand,
|
||||
isDangerousCommand as isMacDangerousCommand,
|
||||
} from '../sandbox/utils/commandSafety.js';
|
||||
} from '../sandbox/macos/commandSafety.js';
|
||||
import {
|
||||
isKnownSafeCommand as isWindowsSafeCommand,
|
||||
isDangerousCommand as isWindowsDangerousCommand,
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
getSecureSanitizationConfig,
|
||||
type EnvironmentSanitizationConfig,
|
||||
} from './environmentSanitization.js';
|
||||
|
||||
export interface SandboxPermissions {
|
||||
/** Filesystem permissions. */
|
||||
fileSystem?: {
|
||||
@@ -121,87 +120,6 @@ export const GOVERNANCE_FILES = [
|
||||
{ path: '.git', isDirectory: true },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Files that contain sensitive secrets or credentials and should be
|
||||
* completely hidden (deny read/write) in any sandbox.
|
||||
*/
|
||||
export const SECRET_FILES = [
|
||||
{ pattern: '.env' },
|
||||
{ pattern: '.env.*' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Checks if a given file name matches any of the secret file patterns.
|
||||
*/
|
||||
export function isSecretFile(fileName: string): boolean {
|
||||
return SECRET_FILES.some((s) => {
|
||||
if (s.pattern.endsWith('*')) {
|
||||
const prefix = s.pattern.slice(0, -1);
|
||||
return fileName.startsWith(prefix);
|
||||
}
|
||||
return fileName === s.pattern;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns arguments for the Linux 'find' command to locate secret files.
|
||||
*/
|
||||
export function getSecretFileFindArgs(): string[] {
|
||||
const args: string[] = ['('];
|
||||
SECRET_FILES.forEach((s, i) => {
|
||||
if (i > 0) args.push('-o');
|
||||
args.push('-name', s.pattern);
|
||||
});
|
||||
args.push(')');
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all secret files in a directory up to a certain depth.
|
||||
* Default is shallow scan (depth 1) for performance.
|
||||
*/
|
||||
export async function findSecretFiles(
|
||||
baseDir: string,
|
||||
maxDepth = 1,
|
||||
): Promise<string[]> {
|
||||
const secrets: string[] = [];
|
||||
const skipDirs = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.venv',
|
||||
'__pycache__',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'.idea',
|
||||
'.vscode',
|
||||
]);
|
||||
|
||||
async function walk(dir: string, depth: number) {
|
||||
if (depth > maxDepth) return;
|
||||
try {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (!skipDirs.has(entry.name)) {
|
||||
await walk(fullPath, depth + 1);
|
||||
}
|
||||
} else if (entry.isFile()) {
|
||||
if (isSecretFile(entry.name)) {
|
||||
secrets.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore read errors
|
||||
}
|
||||
}
|
||||
|
||||
await walk(baseDir, 1);
|
||||
return secrets;
|
||||
}
|
||||
|
||||
/**
|
||||
* A no-op implementation of SandboxManager that silently passes commands
|
||||
* through while applying environment sanitization.
|
||||
|
||||
@@ -42,11 +42,7 @@ export function createSandboxManager(
|
||||
policyManager,
|
||||
});
|
||||
} else if (os.platform() === 'linux') {
|
||||
return new LinuxSandboxManager({
|
||||
workspace,
|
||||
modeConfig,
|
||||
policyManager,
|
||||
});
|
||||
return new LinuxSandboxManager({ workspace });
|
||||
} else if (os.platform() === 'darwin') {
|
||||
return new MacOsSandboxManager({
|
||||
workspace,
|
||||
|
||||
@@ -880,12 +880,15 @@ describe('ShellExecutionService', () => {
|
||||
const binaryChunk1 = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
||||
const binaryChunk2 = Buffer.from([0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
await simulateExecution('cat image.png', (pty) => {
|
||||
const { result } = await simulateExecution('cat image.png', (pty) => {
|
||||
pty.onData.mock.calls[0][0](binaryChunk1);
|
||||
pty.onData.mock.calls[0][0](binaryChunk2);
|
||||
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
|
||||
});
|
||||
|
||||
expect(result.rawOutput).toEqual(
|
||||
Buffer.concat([binaryChunk1, binaryChunk2]),
|
||||
);
|
||||
expect(onOutputEventMock).toHaveBeenCalledTimes(4);
|
||||
expect(onOutputEventMock.mock.calls[0][0]).toEqual({
|
||||
type: 'binary_detected',
|
||||
@@ -1461,12 +1464,15 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
const binaryChunk1 = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
||||
const binaryChunk2 = Buffer.from([0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
await simulateExecution('cat image.png', (cp) => {
|
||||
const { result } = await simulateExecution('cat image.png', (cp) => {
|
||||
cp.stdout?.emit('data', binaryChunk1);
|
||||
cp.stdout?.emit('data', binaryChunk2);
|
||||
cp.emit('exit', 0, null);
|
||||
});
|
||||
|
||||
expect(result.rawOutput).toEqual(
|
||||
Buffer.concat([binaryChunk1, binaryChunk2]),
|
||||
);
|
||||
expect(onOutputEventMock).toHaveBeenCalledTimes(4);
|
||||
expect(onOutputEventMock.mock.calls[0][0]).toEqual({
|
||||
type: 'binary_detected',
|
||||
|
||||
@@ -120,8 +120,7 @@ interface ActiveChildProcess {
|
||||
state: {
|
||||
output: string;
|
||||
truncated: boolean;
|
||||
sniffChunks: Buffer[];
|
||||
binaryBytesReceived: number;
|
||||
outputChunks: Buffer[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -494,8 +493,7 @@ export class ShellExecutionService {
|
||||
const state = {
|
||||
output: '',
|
||||
truncated: false,
|
||||
sniffChunks: [] as Buffer[],
|
||||
binaryBytesReceived: 0,
|
||||
outputChunks: [] as Buffer[],
|
||||
};
|
||||
|
||||
if (child.pid) {
|
||||
@@ -565,19 +563,14 @@ export class ShellExecutionService {
|
||||
}
|
||||
}
|
||||
|
||||
if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) {
|
||||
state.sniffChunks.push(data);
|
||||
} else if (!isStreamingRawContent) {
|
||||
state.binaryBytesReceived += data.length;
|
||||
}
|
||||
state.outputChunks.push(data);
|
||||
|
||||
if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) {
|
||||
const sniffBuffer = Buffer.concat(state.sniffChunks.slice(0, 20));
|
||||
const sniffBuffer = Buffer.concat(state.outputChunks.slice(0, 20));
|
||||
sniffedBytes = sniffBuffer.length;
|
||||
|
||||
if (isBinary(sniffBuffer)) {
|
||||
isStreamingRawContent = false;
|
||||
state.binaryBytesReceived = sniffBuffer.length;
|
||||
const event: ShellOutputEvent = { type: 'binary_detected' };
|
||||
onOutputEvent(event);
|
||||
if (child.pid) {
|
||||
@@ -617,7 +610,10 @@ export class ShellExecutionService {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const totalBytes = state.binaryBytesReceived;
|
||||
const totalBytes = state.outputChunks.reduce(
|
||||
(sum, chunk) => sum + chunk.length,
|
||||
0,
|
||||
);
|
||||
const event: ShellOutputEvent = {
|
||||
type: 'binary_progress',
|
||||
bytesReceived: totalBytes,
|
||||
@@ -633,7 +629,7 @@ export class ShellExecutionService {
|
||||
code: number | null,
|
||||
signal: NodeJS.Signals | null,
|
||||
) => {
|
||||
cleanup();
|
||||
const { finalBuffer } = cleanup();
|
||||
|
||||
let combinedOutput = state.output;
|
||||
if (state.truncated) {
|
||||
@@ -648,7 +644,7 @@ export class ShellExecutionService {
|
||||
const exitSignal = signal ? os.constants.signals[signal] : null;
|
||||
|
||||
const resultPayload: ShellExecutionResult = {
|
||||
rawOutput: Buffer.from(''),
|
||||
rawOutput: finalBuffer,
|
||||
output: finalStrippedOutput,
|
||||
exitCode,
|
||||
signal: exitSignal,
|
||||
@@ -737,7 +733,8 @@ export class ShellExecutionService {
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
const finalBuffer = Buffer.concat(state.outputChunks);
|
||||
return { finalBuffer };
|
||||
}
|
||||
|
||||
return { pid: child.pid, result };
|
||||
@@ -867,8 +864,7 @@ export class ShellExecutionService {
|
||||
let processingChain = Promise.resolve();
|
||||
let decoder: TextDecoder | null = null;
|
||||
let output: string | AnsiOutput | null = null;
|
||||
const sniffChunks: Buffer[] = [];
|
||||
let binaryBytesReceived = 0;
|
||||
const outputChunks: Buffer[] = [];
|
||||
const error: Error | null = null;
|
||||
let exited = false;
|
||||
|
||||
@@ -999,19 +995,14 @@ export class ShellExecutionService {
|
||||
}
|
||||
}
|
||||
|
||||
if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) {
|
||||
sniffChunks.push(data);
|
||||
} else if (!isStreamingRawContent) {
|
||||
binaryBytesReceived += data.length;
|
||||
}
|
||||
outputChunks.push(data);
|
||||
|
||||
if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) {
|
||||
const sniffBuffer = Buffer.concat(sniffChunks.slice(0, 20));
|
||||
const sniffBuffer = Buffer.concat(outputChunks.slice(0, 20));
|
||||
sniffedBytes = sniffBuffer.length;
|
||||
|
||||
if (isBinary(sniffBuffer)) {
|
||||
isStreamingRawContent = false;
|
||||
binaryBytesReceived = sniffBuffer.length;
|
||||
const event: ShellOutputEvent = { type: 'binary_detected' };
|
||||
onOutputEvent(event);
|
||||
ExecutionLifecycleService.emitEvent(ptyPid, event);
|
||||
@@ -1036,7 +1027,10 @@ export class ShellExecutionService {
|
||||
resolveChunk();
|
||||
});
|
||||
} else {
|
||||
const totalBytes = binaryBytesReceived;
|
||||
const totalBytes = outputChunks.reduce(
|
||||
(sum, chunk) => sum + chunk.length,
|
||||
0,
|
||||
);
|
||||
const event: ShellOutputEvent = {
|
||||
type: 'binary_progress',
|
||||
bytesReceived: totalBytes,
|
||||
@@ -1082,7 +1076,7 @@ export class ShellExecutionService {
|
||||
});
|
||||
|
||||
ExecutionLifecycleService.completeWithResult(ptyPid, {
|
||||
rawOutput: Buffer.from(''),
|
||||
rawOutput: Buffer.concat(outputChunks),
|
||||
output: getFullBufferText(headlessTerminal),
|
||||
exitCode,
|
||||
signal: signal ?? null,
|
||||
|
||||
@@ -93,8 +93,8 @@
|
||||
},
|
||||
"enableNotifications": {
|
||||
"title": "Enable Notifications",
|
||||
"description": "Enable run-event notifications for action-required prompts and session completion.",
|
||||
"markdownDescription": "Enable run-event notifications for action-required prompts and session completion.\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `false`",
|
||||
"description": "Enable run-event notifications for action-required prompts and session completion. Currently macOS only.",
|
||||
"markdownDescription": "Enable run-event notifications for action-required prompts and session completion. Currently macOS only.\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `false`",
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Gemini API Reliability Harvester
|
||||
# -------------------------------
|
||||
# This script gathers data about 500 API errors encountered during evaluation runs
|
||||
# (eval.yml) from GitHub Actions. It is used to analyze developer friction caused
|
||||
# by transient API failures.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/harvest_api_reliability.sh [SINCE] [LIMIT] [BRANCH]
|
||||
#
|
||||
# Examples:
|
||||
# ./scripts/harvest_api_reliability.sh # Last 7 days, all branches
|
||||
# ./scripts/harvest_api_reliability.sh 14d 500 # Last 14 days, limit 500
|
||||
# ./scripts/harvest_api_reliability.sh 2026-03-01 100 my-branch # Specific date and branch
|
||||
#
|
||||
# Prerequisites:
|
||||
# - GitHub CLI (gh) installed and authenticated (`gh auth login`)
|
||||
# - jq installed
|
||||
|
||||
# Arguments & Defaults
|
||||
if [[ -n "$1" && $1 =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
|
||||
SINCE="$1"
|
||||
elif [[ -n "$1" && $1 =~ ^([0-9]+)d$ ]]; then
|
||||
DAYS="${BASH_REMATCH[1]}"
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
SINCE=$(date -u -v-"${DAYS}"d +%Y-%m-%d)
|
||||
else
|
||||
SINCE=$(date -u -d "${DAYS} days ago" +%Y-%m-%d)
|
||||
fi
|
||||
else
|
||||
# Default to 7 days ago in YYYY-MM-DD format (UTC)
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
SINCE=$(date -u -v-7d +%Y-%m-%d)
|
||||
else
|
||||
SINCE=$(date -u -d "7 days ago" +%Y-%m-%d)
|
||||
fi
|
||||
fi
|
||||
|
||||
LIMIT=${2:-300}
|
||||
BRANCH=${3:-""}
|
||||
WORKFLOWS=("Testing: E2E (Chained)" "Evals: Nightly")
|
||||
DEST_DIR=$(mktemp -d -t gemini-reliability-XXXXXX)
|
||||
MERGED_FILE="api-reliability-summary.jsonl"
|
||||
|
||||
# Ensure cleanup on exit
|
||||
trap 'rm -rf "$DEST_DIR"' EXIT
|
||||
|
||||
if ! command -v gh &> /dev/null; then
|
||||
echo "❌ Error: GitHub CLI (gh) is not installed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v jq &> /dev/null; then
|
||||
echo "❌ Error: jq is not installed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Clean start
|
||||
rm -f "$MERGED_FILE"
|
||||
|
||||
# gh run list --created expects a date (YYYY-MM-DD) or a range
|
||||
CREATED_QUERY=">=$SINCE"
|
||||
|
||||
for WORKFLOW in "${WORKFLOWS[@]}"; do
|
||||
echo "🔍 Fetching runs for '$WORKFLOW' created since $SINCE (max $LIMIT runs, branch: ${BRANCH:-all})..."
|
||||
|
||||
# Construct arguments for gh run list
|
||||
GH_ARGS=("--workflow" "$WORKFLOW" "--created" "$CREATED_QUERY" "--limit" "$LIMIT" "--json" "databaseId" "--jq" ".[].databaseId")
|
||||
if [ -n "$BRANCH" ]; then
|
||||
GH_ARGS+=("--branch" "$BRANCH")
|
||||
fi
|
||||
|
||||
RUN_IDS=$(gh run list "${GH_ARGS[@]}")
|
||||
exit_code=$?
|
||||
|
||||
if [ $exit_code -ne 0 ]; then
|
||||
echo "❌ Failed to fetch runs for '$WORKFLOW' (exit code: $exit_code). Please check 'gh auth status' and permissions." >&2
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ -z "$RUN_IDS" ]; then
|
||||
echo "📭 No runs found for workflow '$WORKFLOW' since $SINCE."
|
||||
continue
|
||||
fi
|
||||
|
||||
for ID in $RUN_IDS; do
|
||||
# Download artifacts named 'eval-logs-*'
|
||||
# Silencing output because many older runs won't have artifacts
|
||||
gh run download "$ID" -p "eval-logs-*" -D "$DEST_DIR/$ID" &>/dev/null || continue
|
||||
|
||||
# Append to master log
|
||||
# Use find to locate api-reliability.jsonl in any subdirectory of $DEST_DIR/$ID
|
||||
find "$DEST_DIR/$ID" -type f -name "api-reliability.jsonl" -exec cat {} + >> "$MERGED_FILE" 2>/dev/null
|
||||
done
|
||||
done
|
||||
|
||||
if [ ! -f "$MERGED_FILE" ]; then
|
||||
echo "📭 No reliability data found in the retrieved logs."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo -e "\n✅ Harvest Complete! Data merged into: $MERGED_FILE"
|
||||
echo "------------------------------------------------"
|
||||
echo "📊 Gemini API Reliability Summary (Since $SINCE)"
|
||||
echo "------------------------------------------------"
|
||||
|
||||
cat "$MERGED_FILE" | jq -s '
|
||||
group_by(.model) | map({
|
||||
model: .[0].model,
|
||||
"500s": (map(select(.errorCode == "500")) | length),
|
||||
"503s": (map(select(.errorCode == "503")) | length),
|
||||
retries: (map(select(.status == "RETRY")) | length),
|
||||
skips: (map(select(.status == "SKIP")) | length)
|
||||
})'
|
||||
|
||||
echo -e "\n💡 Total events captured: $(wc -l < "$MERGED_FILE")"
|
||||