mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-03 05:31:02 -07:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc06ac3202 | |||
| d6492830b6 | |||
| bcf599b3b2 |
@@ -1,92 +0,0 @@
|
||||
---
|
||||
name: architecture-visualizer
|
||||
description: >
|
||||
Expert architect agent for mapping codebase architecture and generating visual
|
||||
diagrams. MANDATORY HANDOVER PROTOCOL: The calling agent MUST construct a
|
||||
comprehensive prompt using the <known_context> tag. You must pass all relevant
|
||||
architectural details, system assumptions, and specific file paths containing
|
||||
architecture or designs that need to be considered in the <known_context>
|
||||
block. This agent accepts these summaries to accelerate drawing, or explores
|
||||
the codebase autonomously if no context is provided.
|
||||
tools:
|
||||
- run_shell_command
|
||||
- write_file
|
||||
- grep_search
|
||||
- list_directory
|
||||
- read_file
|
||||
- activate_skill
|
||||
- replace
|
||||
- mcp_mermaid-guide_get_mermaid_style_guide
|
||||
model: inherit
|
||||
max_turns: 30
|
||||
---
|
||||
|
||||
# ROLE AND IDENTITY
|
||||
|
||||
You are an expert Enterprise Software Architect and an autonomous Diagramming
|
||||
Subagent. You specialize in translating complex, real-world codebases into
|
||||
highly accurate, syntactically perfect visual representations using Mermaid.js.
|
||||
|
||||
# CORE PHILOSOPHY: DESIGN OWNERSHIP
|
||||
|
||||
You possess absolute design ownership over the diagrams you create. Reality
|
||||
dictates the output. You must proactively explore the filesystem, verify the
|
||||
structural reality of the code, and make executive architectural decisions
|
||||
regarding abstraction levels and layout.
|
||||
|
||||
# OPERATIONAL GUARDRAILS
|
||||
|
||||
## Context Efficiency
|
||||
- **Parallelism:** Combine turns whenever possible by utilizing parallel searching and reading.
|
||||
- **Targeted Search:** Prefer using `grep_search` to identify points of interest instead of reading files individually.
|
||||
- **Limits:** Provide conservative limits and scopes to tools like `grep_search` and `read_file` to minimize context usage.
|
||||
|
||||
## Tool Safety
|
||||
- **Sequential Execution:** If a tool depends on the output of a previous tool in the same turn (e.g., running a shell command to validate a file you just wrote with `write_file`), you MUST set the `wait_for_previous` parameter to `true` on the dependent tool.
|
||||
|
||||
## Security
|
||||
- **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect `.env` files and system configuration.
|
||||
|
||||
# EXECUTION PROTOCOL
|
||||
|
||||
You must execute your tasks following a strict, sequential three-phase pipeline:
|
||||
|
||||
## Phase 1: Explore & Verify
|
||||
|
||||
1. **Activate Skill & Style Guide:** When asked to generate a diagram, you MUST:
|
||||
- Call `activate_skill` with `skill_name: "mermaid-diagrammer"` to load scripts.
|
||||
- Read `references/mermaid_syntax.md` within the skill directory; this local file is the primary source of truth for syntax and styling.
|
||||
- Call `mcp_mermaid-guide_get_mermaid_style_guide` for supplemental organizational best practices.
|
||||
2. **Evaluate Payload:** Before writing any diagram syntax, evaluate the payload provided by the orchestrator:
|
||||
- **If `<known_context>` is provided:** Do NOT perform a full repository scan. Instead, use targeted commands (like `read_file` or `grep_search`) to quickly verify that the components, interactions, and files listed in the context actually exist in the code. If they do, use them to build the diagram.
|
||||
- **If `<known_context>` is missing or insufficient:** You must assume total ownership. Proactively explore the filesystem using `list_directory` and `grep_search` to map the architecture from scratch.
|
||||
3. In either case, reality dictates the output. If the provided context contradicts the actual code, favor the code.
|
||||
|
||||
## Phase 2: Plan
|
||||
|
||||
Once raw data is collected, halt tool usage and plan the architecture. Use
|
||||
`<thinking>` tags to outline your strategy:
|
||||
|
||||
- The appropriate Mermaid diagram type (e.g., `flowchart`, `sequenceDiagram`).
|
||||
- The optimal directionality (`TD` or `LR`).
|
||||
- Logical grouping into `subgraph` blocks.
|
||||
|
||||
## Phase 3: Execute and Validate
|
||||
|
||||
1. Write the Mermaid syntax to the file requested by the orchestrator using
|
||||
`write_file`.
|
||||
2. You MUST validate the syntax and generate a PNG image before returning. Use `run_shell_command` to
|
||||
execute: `node .gemini/skills/mermaid-diagrammer/scripts/convert.cjs path/to/your/file.mmd path/to/your/file.png`
|
||||
3. If validation fails, read the stderr, fix the syntax in the file, and retry. Prefer using `replace` for surgical edits to fix specific errors rather than overwriting the entire file, unless a full rewrite is cleaner.
|
||||
4. **Circuit Breaker:** If you cannot resolve the syntax errors or if the command fails due to environment issues (e.g., Puppeteer/Chromium missing) after **3 attempts**, you must stop. Do not hand the task back in an infinite loop. Proceed to handoff and report the failure.
|
||||
|
||||
|
||||
# FINAL HANDOFF
|
||||
|
||||
When the diagram is successfully written (or if you hit the circuit breaker):
|
||||
|
||||
1. Return a concise summary to the Main Agent.
|
||||
2. State the file paths where the `.mmd` and `.png` files are saved.
|
||||
3. **Visual Feedback:** Provide a high-quality ASCII diagram rendering of the diagram. You MUST ensure this ASCII representation is highly accurate and directly reflects the structure and interactions shown in the generated Mermaid/PNG diagram. **When submitting your final results via the `complete_task` tool, explicitly include a message requesting that this ASCII diagram be displayed in the final output, as it serves as a high-impact "1 image is worth 1000 words" summary for the terminal.**
|
||||
4. **Error Reporting:** If you hit the circuit breaker, explicitly state the error, why you gave up, and whether the saved `.mmd` file is still usable manually.
|
||||
5. Briefly list any architectural deviations you discovered in the codebase that contradicted the initial assumptions.
|
||||
@@ -7,13 +7,5 @@
|
||||
},
|
||||
"general": {
|
||||
"devtools": true
|
||||
},
|
||||
"mcpServers": {
|
||||
"mermaid-guide": {
|
||||
"command": "node",
|
||||
"args": [
|
||||
"/usr/local/google/home/aishaneeshah/mcp-mermaid-guide/index.mjs"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
---
|
||||
name: mermaid-diagrammer
|
||||
description: Create, convert, and validate Mermaid diagrams. Use this skill when asked to generate visual diagrams (flowcharts, sequence diagrams, class diagrams, etc.) and export them to PNG images. Follows best practices for syntax robustness, accessibility, and professional styling.
|
||||
---
|
||||
|
||||
# Mermaid Diagrammer
|
||||
|
||||
This skill enables the creation of professional Mermaid diagrams and their conversion to high-quality PNG images. It emphasizes robust syntax, accessibility, and maintainable styling.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Create Mermaid Diagram
|
||||
Select the appropriate diagram type based on the task.
|
||||
|
||||
**Standard Operating Procedure (Precedence):**
|
||||
1. **Local Standards:** You MUST first read `references/mermaid_syntax.md`. This local file contains project-specific syntax templates and takes absolute precedence.
|
||||
2. **Supplemental Standards:** Call `mcp_mermaid-guide_get_mermaid_style_guide` to retrieve broader organization standards. Use these only if they do not contradict the local file.
|
||||
|
||||
**Handling Existing Diagrams / Inputs:**
|
||||
- **If provided as a `.mmd` file:** Use `read_file` to examine the content and apply edits directly.
|
||||
- **If provided as an image (PNG/JPG):** Use `read_file` to read the image file, then use your vision capabilities to analyze the structure and interactions in the image, and translate it into Mermaid syntax.
|
||||
- **If provided as text:** Parse the description to map the nodes and edges.
|
||||
|
||||
- **Syntax Guardrails:** Always use double quotes for labels containing special characters: `A["Label (with Parens)"]`.
|
||||
- **Accessibility:** Include `accTitle` and `accDescr` for all diagrams.
|
||||
- **Styling:** Use `classDef` and `subgraph` for complex diagrams to ensure readability.
|
||||
- Save to a `.mmd` file.
|
||||
|
||||
### 2. Convert to PNG
|
||||
Use the `scripts/convert.cjs` script.
|
||||
- **Command:** `node <skill_path>/scripts/convert.cjs <input.mmd> <output.png> [mermaid_cli_options...]`
|
||||
- **Defaults:** The script defaults to `--scale 3` (high resolution) and `-b white` (white background).
|
||||
- **Overrides:** You can pass additional flags (e.g., `-t dark` for dark theme, or `--width 800` for custom width) at the end of the command to override defaults.
|
||||
- **Transparency:** Use `-b transparent` if the user explicitly requests transparency (e.g., for dark-mode READMEs or presentation slides), but ensure the colors used are high-contrast and visible on dark backgrounds.
|
||||
- If conversion fails, check the Mermaid CLI output for syntax errors.
|
||||
|
||||
### 3. Validate & Verify
|
||||
Validate the PNG using `scripts/validate_png.cjs`.
|
||||
- **Command:** `node <skill_path>/scripts/validate_png.cjs <output.png>`
|
||||
- **Human Verification:** Inform the user when the PNG is ready and where it is located.
|
||||
|
||||
## Resources
|
||||
|
||||
### scripts/
|
||||
- `convert.cjs`: Converts Mermaid to PNG via `mermaid-cli`.
|
||||
- `validate_png.cjs`: PNG integrity check.
|
||||
|
||||
### references/
|
||||
- `mermaid_syntax.md`: Comprehensive reference with best practices for AI-generated Mermaid code, accessibility, and theming.
|
||||
@@ -1,64 +0,0 @@
|
||||
# Mermaid Syntax & Best Practices
|
||||
|
||||
Use this reference as a template and guide for generating high-quality, robust Mermaid diagrams.
|
||||
|
||||
## Core Rules for AI Generation
|
||||
- **Syntax Robustness:** Always wrap labels in double quotes if they contain special characters (parentheses, brackets, etc.) to prevent renderer crashes.
|
||||
- *Correct:* `A["User (Admin)"]`
|
||||
- *Incorrect:* `A[User (Admin)]`
|
||||
- **Reserved Words:** Avoid using reserved words (e.g., `end`, `graph`, `subgraph`) as node IDs.
|
||||
- **Accessibility:** Always include `accTitle` and `accDescr` for screen readers.
|
||||
|
||||
## Diagram Selection Guide
|
||||
| Use Case | Recommended Type | Layout |
|
||||
| :--- | :--- | :--- |
|
||||
| **Logic/Algorithms** | `flowchart` | `TD` (Top-Down) |
|
||||
| **Data Pipelines/Process** | `flowchart` | `LR` (Left-Right) |
|
||||
| **API/Interactions** | `sequenceDiagram` | N/A |
|
||||
| **Database Schema** | `erDiagram` | N/A |
|
||||
| **State Machines** | `stateDiagram-v2` | N/A |
|
||||
|
||||
## Flowchart (Best Practices)
|
||||
Use `classDef` for consistent styling instead of inline styles.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
accTitle: System Logic
|
||||
accDescr: High-level logic for the authentication service.
|
||||
|
||||
classDef primary fill:#f9f,stroke:#333,stroke-width:2px;
|
||||
classDef database fill:#f96,stroke:#333,stroke-width:2px;
|
||||
|
||||
Start --> Auth{{"Authorized?"}}
|
||||
Auth -- Yes --> Process[Process Request]:::primary
|
||||
Auth -- No --> DB[(Log Error)]:::database
|
||||
```
|
||||
|
||||
## Sequence Diagram
|
||||
Use `participant` aliases for cleaner code.
|
||||
- **Sync/Async:** Use `->>` for synchronous calls and `-->>` for asynchronous returns.
|
||||
- **Lifelines:** Ensure `activate` and `deactivate` are paired correctly.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
accTitle: API Authentication
|
||||
participant U as User
|
||||
participant A as Auth Service
|
||||
U->>A: Request Token
|
||||
A-->>U: JWT Token
|
||||
```
|
||||
|
||||
## Styling and Theming
|
||||
Prefer logical grouping using `subgraph` to manage complexity.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Client
|
||||
A[Mobile]
|
||||
B[Web]
|
||||
end
|
||||
subgraph Server
|
||||
C[API]
|
||||
end
|
||||
Client --> Server
|
||||
```
|
||||
@@ -1,61 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Copyright 2026 Google LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
const { spawnSync } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length < 2) {
|
||||
console.error('Usage: node convert.cjs <input.mmd> <output.png> [mermaid_cli_options...]');
|
||||
console.error('Defaults: --scale 3, -b transparent');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const inputPath = path.resolve(args[0]);
|
||||
const outputPath = path.resolve(args[1]);
|
||||
|
||||
if (!fs.existsSync(inputPath)) {
|
||||
console.error(`Error: Input file not found: ${inputPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const extraArgs = args.slice(2);
|
||||
|
||||
console.log(`Converting ${inputPath} to ${outputPath}...`);
|
||||
if (extraArgs.length > 0) {
|
||||
console.log(`With extra arguments: ${extraArgs.join(' ')}`);
|
||||
}
|
||||
|
||||
const result = spawnSync('npx', [
|
||||
'--yes',
|
||||
'@mermaid-js/mermaid-cli',
|
||||
'-i', inputPath,
|
||||
'-o', outputPath,
|
||||
'-b', 'white',
|
||||
'--scale', '3',
|
||||
...extraArgs
|
||||
], { stdio: 'pipe', encoding: 'utf-8' });
|
||||
|
||||
if (result.status !== 0) {
|
||||
console.error('Failed to convert Mermaid diagram:');
|
||||
console.error(result.stderr);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('Successfully converted Mermaid diagram to PNG.');
|
||||
@@ -1,54 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Copyright 2026 Google LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length < 1) {
|
||||
console.error('Usage: node validate_png.cjs <path_to_png>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const filePath = path.resolve(args[0]);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(`Error: File not found: ${filePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const stats = fs.statSync(filePath);
|
||||
if (stats.size === 0) {
|
||||
console.error(`Error: File is empty: ${filePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const buffer = Buffer.alloc(8);
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
fs.readSync(fd, buffer, 0, 8, 0);
|
||||
fs.closeSync(fd);
|
||||
|
||||
// PNG Magic Numbers: 89 50 4E 47 0D 0A 1A 0A
|
||||
const pngHeader = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
|
||||
|
||||
if (!buffer.equals(pngHeader)) {
|
||||
console.error(`Error: File is not a valid PNG: ${filePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Success: ${filePath} is a valid PNG file (${stats.size} bytes).`);
|
||||
@@ -1,132 +0,0 @@
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
|
||||
|
||||
name: 'Agent Session Drift Check'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'release/**'
|
||||
paths:
|
||||
- 'packages/cli/src/nonInteractiveCli.ts'
|
||||
- 'packages/cli/src/nonInteractiveCliAgentSession.ts'
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}'
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-drift:
|
||||
name: 'Check Agent Session Drift'
|
||||
runs-on: 'ubuntu-latest'
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
permissions:
|
||||
contents: 'read'
|
||||
pull-requests: 'write'
|
||||
steps:
|
||||
- name: 'Detect drift and comment'
|
||||
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' # ratchet:actions/github-script@v8
|
||||
with:
|
||||
script: |-
|
||||
// === Pair configuration — append here to cover more pairs ===
|
||||
const PAIRS = [
|
||||
{
|
||||
legacy: 'packages/cli/src/nonInteractiveCli.ts',
|
||||
session: 'packages/cli/src/nonInteractiveCliAgentSession.ts',
|
||||
label: 'non-interactive CLI',
|
||||
},
|
||||
// Future pairs can be added here. Remember to also add both
|
||||
// paths to the `paths:` filter at the top of this workflow.
|
||||
// Example:
|
||||
// {
|
||||
// legacy: 'packages/core/src/agents/local-invocation.ts',
|
||||
// session: 'packages/core/src/agents/local-session-invocation.ts',
|
||||
// label: 'local subagent invocation',
|
||||
// },
|
||||
];
|
||||
// ============================================================
|
||||
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
// Use the API to list changed files — no checkout/git diff needed.
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
const changed = new Set(files.map((f) => f.filename));
|
||||
|
||||
const warnings = [];
|
||||
for (const { legacy, session, label } of PAIRS) {
|
||||
const legacyChanged = changed.has(legacy);
|
||||
const sessionChanged = changed.has(session);
|
||||
if (legacyChanged && !sessionChanged) {
|
||||
warnings.push(
|
||||
`**${label}**: \`${legacy}\` was modified but \`${session}\` was not.`,
|
||||
);
|
||||
} else if (!legacyChanged && sessionChanged) {
|
||||
warnings.push(
|
||||
`**${label}**: \`${session}\` was modified but \`${legacy}\` was not.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const MARKER = '<!-- agent-session-drift-check -->';
|
||||
|
||||
// Look up our existing drift comment (for upsert/cleanup).
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
const existing = comments.find(
|
||||
(c) => c.user?.type === 'Bot' && c.body?.includes(MARKER),
|
||||
);
|
||||
|
||||
if (warnings.length === 0) {
|
||||
core.info('No drift detected.');
|
||||
// If drift was previously flagged and is now resolved, remove the comment.
|
||||
if (existing) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existing.id,
|
||||
});
|
||||
core.info(`Deleted stale drift comment ${existing.id}.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const body = [
|
||||
MARKER,
|
||||
'### ⚠️ Invocation Drift Warning',
|
||||
'',
|
||||
'The following file pairs should generally be kept in sync during the AgentSession migration:',
|
||||
'',
|
||||
...warnings.map((w) => `- ${w}`),
|
||||
'',
|
||||
'If this is intentional (e.g., a bug fix specific to one implementation), you can ignore this comment.',
|
||||
'',
|
||||
'_This check will be removed once the legacy implementations are deleted._',
|
||||
].join('\n');
|
||||
|
||||
if (existing) {
|
||||
core.info(`Updating existing drift comment ${existing.id}.`);
|
||||
await github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
core.info('Creating new drift comment.');
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body,
|
||||
});
|
||||
}
|
||||
@@ -183,7 +183,7 @@ jobs:
|
||||
needs:
|
||||
- 'merge_queue_skipper'
|
||||
- 'parse_run_context'
|
||||
runs-on: 'macos-latest-large'
|
||||
runs-on: 'macos-latest'
|
||||
if: |
|
||||
github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
|
||||
steps:
|
||||
|
||||
@@ -224,7 +224,7 @@ jobs:
|
||||
|
||||
test_mac:
|
||||
name: 'Test (Mac) - ${{ matrix.node-version }}, ${{ matrix.shard }}'
|
||||
runs-on: 'macos-latest-large'
|
||||
runs-on: 'macos-latest'
|
||||
needs:
|
||||
- 'merge_queue_skipper'
|
||||
if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'"
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
|
||||
deflake_e2e_mac:
|
||||
name: 'E2E Test (macOS)'
|
||||
runs-on: 'macos-latest-large'
|
||||
runs-on: 'macos-latest'
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
!.gemini/config.yaml
|
||||
!.gemini/commands/
|
||||
!.gemini/skills/
|
||||
!.gemini/agents/
|
||||
!.gemini/settings.json
|
||||
|
||||
# Note: .gemini-clipboard/ is NOT in gitignore so Gemini can access pasted images
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Latest stable release: v0.37.2
|
||||
# Latest stable release: v0.37.1
|
||||
|
||||
Released: April 13, 2026
|
||||
Released: April 09, 2026
|
||||
|
||||
For most users, our latest stable release is the recommended release. Install
|
||||
the latest stable version with:
|
||||
@@ -26,9 +26,6 @@ npm install -g @google/gemini-cli
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(patch): cherry-pick 9d741ab to release/v0.37.1-pr-24565 to patch version
|
||||
v0.37.1 and create version 0.37.2 by @gemini-cli-robot in
|
||||
[#25322](https://github.com/google-gemini/gemini-cli/pull/25322)
|
||||
- fix(acp): handle all InvalidStreamError types gracefully in prompt
|
||||
[#24540](https://github.com/google-gemini/gemini-cli/pull/24540)
|
||||
- feat(acp): add support for /about command
|
||||
@@ -425,4 +422,4 @@ npm install -g @google/gemini-cli
|
||||
[#24842](https://github.com/google-gemini/gemini-cli/pull/24842)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.36.0...v0.37.2
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.36.0...v0.37.1
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 60 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 71 KiB |
@@ -1,57 +0,0 @@
|
||||
sequenceDiagram
|
||||
accTitle: Detailed Gemini CLI Request Flow
|
||||
accDescr: Exhaustive sequential flow from user input to Ink rendering, including prompt construction, tool scheduling, and user confirmation loops.
|
||||
|
||||
participant U as User
|
||||
participant Ink as Ink UI (AppContainer)
|
||||
participant AS as AgentSession (Core)
|
||||
participant PP as PromptProvider
|
||||
participant GC as GeminiClient
|
||||
participant Sch as Scheduler
|
||||
participant TR as ToolRegistry
|
||||
participant Bus as MessageBus / Confirmation
|
||||
participant TE as ToolExecutor
|
||||
|
||||
U->>Ink: Type Command / Text
|
||||
Ink->>AS: sendStream(payload)
|
||||
|
||||
rect rgb(240, 240, 255)
|
||||
Note over AS,PP: Turn Loop (_runLoop)
|
||||
AS->>PP: getCoreSystemPrompt()
|
||||
PP->>PP: Resolve Config & Approval Mode
|
||||
PP->>PP: Gather Agent/Skill Metadata
|
||||
PP->>PP: Render Template (snippets.ts)
|
||||
PP-->>AS: Full System Prompt
|
||||
|
||||
AS->>GC: sendMessageStream(Prompt + History)
|
||||
GC-->>AS: Stream [ContentChunk | ToolCallRequest]
|
||||
end
|
||||
|
||||
loop For each ToolCallRequest
|
||||
AS->>Sch: schedule(ToolCallRequests)
|
||||
Sch->>TR: getTool(name)
|
||||
TR-->>Sch: Tool Definition
|
||||
|
||||
Sch->>Sch: checkPolicy() & evaluateHooks()
|
||||
|
||||
alt Policy requires Confirmation
|
||||
Sch->>Bus: publish(TOOL_CONFIRMATION_REQUEST)
|
||||
Bus->>Ink: Event: Confirmation Required
|
||||
Ink-->>U: Render [Approve/Deny] Prompt
|
||||
U->>Ink: Click "Approve"
|
||||
Ink->>Bus: publish(TOOL_CONFIRMATION_RESPONSE)
|
||||
Bus-->>Sch: Confirmed
|
||||
end
|
||||
|
||||
Sch->>TE: execute(invocation)
|
||||
TE->>TE: Run Shell / Read FS / Web Fetch
|
||||
TE-->>Sch: ToolOutput
|
||||
Sch-->>AS: [CompletedToolCall]
|
||||
|
||||
Note over AS: Feedback Turn
|
||||
AS->>GC: send(ToolOutput)
|
||||
GC-->>AS: Stream [Final Response]
|
||||
end
|
||||
|
||||
AS-->>Ink: Stream [AgentEvent]
|
||||
Ink-->>U: Re-render UI (Ink Components)
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 71 KiB |
Generated
+9
-9
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -17864,7 +17864,7 @@
|
||||
},
|
||||
"packages/a2a-server": {
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
"@google-cloud/storage": "^7.16.0",
|
||||
@@ -17979,7 +17979,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.16.1",
|
||||
@@ -18151,7 +18151,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
@@ -18418,7 +18418,7 @@
|
||||
},
|
||||
"packages/devtools": {
|
||||
"name": "@google/gemini-cli-devtools",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"ws": "^8.16.0"
|
||||
@@ -18433,7 +18433,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@google/gemini-cli-sdk",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -18450,7 +18450,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -18468,7 +18468,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -14,7 +14,7 @@
|
||||
"url": "git+https://github.com/google-gemini/gemini-cli.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.40.0-nightly.20260414.g5b1f7375a"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.39.0-nightly.20260408.e77b22e63"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"description": "Gemini CLI",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -27,7 +27,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.40.0-nightly.20260414.g5b1f7375a"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.39.0-nightly.20260408.e77b22e63"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.16.1",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { ApiAuthDialog } from './ApiAuthDialog.js';
|
||||
@@ -40,16 +40,11 @@ vi.mock('../components/shared/text-buffer.js', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../contexts/UIStateContext.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../contexts/UIStateContext.js')>();
|
||||
return {
|
||||
...actual,
|
||||
useUIState: vi.fn(() => ({
|
||||
terminalWidth: 80,
|
||||
})),
|
||||
};
|
||||
});
|
||||
vi.mock('../contexts/UIStateContext.js', () => ({
|
||||
useUIState: vi.fn(() => ({
|
||||
terminalWidth: 80,
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockedUseKeypress = useKeypress as Mock;
|
||||
const mockedUseTextBuffer = useTextBuffer as Mock;
|
||||
@@ -78,7 +73,7 @@ describe('ApiAuthDialog', () => {
|
||||
});
|
||||
|
||||
it('renders correctly', async () => {
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
const { lastFrame, unmount } = await render(
|
||||
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -86,7 +81,7 @@ describe('ApiAuthDialog', () => {
|
||||
});
|
||||
|
||||
it('renders with a defaultValue', async () => {
|
||||
const { unmount } = await renderWithProviders(
|
||||
const { unmount } = await render(
|
||||
<ApiAuthDialog
|
||||
onSubmit={onSubmit}
|
||||
onCancel={onCancel}
|
||||
@@ -116,7 +111,7 @@ describe('ApiAuthDialog', () => {
|
||||
'calls $expectedCall.name when $keyName is pressed',
|
||||
async ({ keyName, sequence, expectedCall, args }) => {
|
||||
mockBuffer.text = 'submitted-key'; // Set for the onSubmit case
|
||||
const { unmount } = await renderWithProviders(
|
||||
const { unmount } = await render(
|
||||
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
|
||||
);
|
||||
// calls[0] is the ApiAuthDialog's useKeypress (Ctrl+C handler)
|
||||
@@ -138,7 +133,7 @@ describe('ApiAuthDialog', () => {
|
||||
);
|
||||
|
||||
it('displays an error message', async () => {
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
const { lastFrame, unmount } = await render(
|
||||
<ApiAuthDialog
|
||||
onSubmit={onSubmit}
|
||||
onCancel={onCancel}
|
||||
@@ -151,7 +146,7 @@ describe('ApiAuthDialog', () => {
|
||||
});
|
||||
|
||||
it('calls clearApiKey and clears buffer when Ctrl+C is pressed', async () => {
|
||||
const { unmount } = await renderWithProviders(
|
||||
const { unmount } = await render(
|
||||
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
|
||||
);
|
||||
// Call 0 is ApiAuthDialog (isActive: true)
|
||||
|
||||
@@ -13,8 +13,7 @@ import {
|
||||
useReducer,
|
||||
useContext,
|
||||
} from 'react';
|
||||
import { Box, Text, type DOMElement } from 'ink';
|
||||
import { useMouseClick } from '../hooks/useMouseClick.js';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { checkExhaustive, type Question } from '@google/gemini-cli-core';
|
||||
import { BaseSelectionList } from './shared/BaseSelectionList.js';
|
||||
@@ -86,24 +85,6 @@ function autoBoldIfPlain(text: string): string {
|
||||
return text;
|
||||
}
|
||||
|
||||
const ClickableCheckbox: React.FC<{
|
||||
isChecked: boolean;
|
||||
onClick: () => void;
|
||||
}> = ({ isChecked, onClick }) => {
|
||||
const ref = useRef<DOMElement>(null);
|
||||
useMouseClick(ref, () => {
|
||||
onClick();
|
||||
});
|
||||
|
||||
return (
|
||||
<Box ref={ref}>
|
||||
<Text color={isChecked ? theme.status.success : theme.text.secondary}>
|
||||
[{isChecked ? 'x' : ' '}]
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
interface AskUserDialogState {
|
||||
answers: { [key: string]: string };
|
||||
isEditingCustomOption: boolean;
|
||||
@@ -938,14 +919,13 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
{showCheck && (
|
||||
<ClickableCheckbox
|
||||
isChecked={isChecked}
|
||||
onClick={() => {
|
||||
if (!context.isSelected) {
|
||||
handleSelect(optionItem);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
color={
|
||||
isChecked ? theme.status.success : theme.text.secondary
|
||||
}
|
||||
>
|
||||
[{isChecked ? 'x' : ' '}]
|
||||
</Text>
|
||||
)}
|
||||
<Text color={theme.text.primary}> </Text>
|
||||
<TextInput
|
||||
@@ -986,14 +966,13 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="row">
|
||||
{showCheck && (
|
||||
<ClickableCheckbox
|
||||
isChecked={isChecked}
|
||||
onClick={() => {
|
||||
if (!context.isSelected) {
|
||||
handleSelect(optionItem);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
color={
|
||||
isChecked ? theme.status.success : theme.text.secondary
|
||||
}
|
||||
>
|
||||
[{isChecked ? 'x' : ' '}]
|
||||
</Text>
|
||||
)}
|
||||
<Text color={labelColor} bold={optionItem.type === 'done'}>
|
||||
{' '}
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import {
|
||||
BaseSelectionList,
|
||||
@@ -15,10 +14,8 @@ import {
|
||||
import { useSelectionList } from '../../hooks/useSelectionList.js';
|
||||
import { Text } from 'ink';
|
||||
import type { theme } from '../../semantic-colors.js';
|
||||
import { useMouseClick } from '../../hooks/useMouseClick.js';
|
||||
|
||||
vi.mock('../../hooks/useSelectionList.js');
|
||||
vi.mock('../../hooks/useMouseClick.js');
|
||||
|
||||
const mockTheme = {
|
||||
text: { primary: 'COLOR_PRIMARY', secondary: 'COLOR_SECONDARY' },
|
||||
@@ -38,7 +35,6 @@ describe('BaseSelectionList', () => {
|
||||
const mockOnSelect = vi.fn();
|
||||
const mockOnHighlight = vi.fn();
|
||||
const mockRenderItem = vi.fn();
|
||||
const mockSetActiveIndex = vi.fn();
|
||||
|
||||
const items = [
|
||||
{ value: 'A', label: 'Item A', key: 'A' },
|
||||
@@ -58,7 +54,7 @@ describe('BaseSelectionList', () => {
|
||||
) => {
|
||||
vi.mocked(useSelectionList).mockReturnValue({
|
||||
activeIndex,
|
||||
setActiveIndex: mockSetActiveIndex,
|
||||
setActiveIndex: vi.fn(),
|
||||
});
|
||||
|
||||
mockRenderItem.mockImplementation(
|
||||
@@ -488,79 +484,6 @@ describe('BaseSelectionList', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mouse Interaction', () => {
|
||||
it('should register mouse click handler for each item', async () => {
|
||||
const { unmount } = await renderComponent();
|
||||
|
||||
// items are A, B (disabled), C
|
||||
expect(useMouseClick).toHaveBeenCalledTimes(3);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should update activeIndex on first click and call onSelect on second click', async () => {
|
||||
const { unmount, waitUntilReady } = await renderComponent();
|
||||
await waitUntilReady();
|
||||
|
||||
// items[0] is 'A' (enabled)
|
||||
// items[1] is 'B' (disabled)
|
||||
// items[2] is 'C' (enabled)
|
||||
|
||||
// Get the mouse click handler for the third item (index 2)
|
||||
const mouseClickHandler = (useMouseClick as Mock).mock.calls[2][1];
|
||||
|
||||
// First click on item C
|
||||
act(() => {
|
||||
mouseClickHandler();
|
||||
});
|
||||
|
||||
expect(mockSetActiveIndex).toHaveBeenCalledWith(2);
|
||||
expect(mockOnSelect).not.toHaveBeenCalled();
|
||||
|
||||
// Now simulate being on item C (isSelected = true)
|
||||
// Rerender or update mocks for the next check
|
||||
await renderComponent({}, 2);
|
||||
|
||||
// Get the updated mouse click handler for item C
|
||||
// useMouseClick is called 3 more times on rerender
|
||||
const updatedMouseClickHandler = (useMouseClick as Mock).mock.calls[5][1];
|
||||
|
||||
// Second click on item C
|
||||
act(() => {
|
||||
updatedMouseClickHandler();
|
||||
});
|
||||
|
||||
expect(mockOnSelect).toHaveBeenCalledWith('C');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not call onSelect when a disabled item is clicked', async () => {
|
||||
const { unmount, waitUntilReady } = await renderComponent();
|
||||
await waitUntilReady();
|
||||
|
||||
// items[1] is 'B' (disabled)
|
||||
const mouseClickHandler = (useMouseClick as Mock).mock.calls[1][1];
|
||||
|
||||
act(() => {
|
||||
mouseClickHandler();
|
||||
});
|
||||
|
||||
expect(mockSetActiveIndex).not.toHaveBeenCalled();
|
||||
expect(mockOnSelect).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should pass isActive: isFocused to useMouseClick', async () => {
|
||||
const { unmount } = await renderComponent({ isFocused: false });
|
||||
|
||||
expect(useMouseClick).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.any(Function),
|
||||
{ isActive: false },
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scroll Arrows (showScrollArrows)', () => {
|
||||
const longList = Array.from({ length: 10 }, (_, i) => ({
|
||||
value: `Item ${i + 1}`,
|
||||
|
||||
@@ -5,14 +5,13 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import { Text, Box, type DOMElement } from 'ink';
|
||||
import { useState } from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import {
|
||||
useSelectionList,
|
||||
type SelectionListItem,
|
||||
} from '../../hooks/useSelectionList.js';
|
||||
import { useMouseClick } from '../../hooks/useMouseClick.js';
|
||||
|
||||
export interface RenderItemContext {
|
||||
isSelected: boolean;
|
||||
@@ -39,119 +38,6 @@ export interface BaseSelectionListProps<
|
||||
renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode;
|
||||
}
|
||||
|
||||
interface SelectionListItemRowProps<
|
||||
T,
|
||||
TItem extends SelectionListItem<T> = SelectionListItem<T>,
|
||||
> {
|
||||
item: TItem;
|
||||
itemIndex: number;
|
||||
isSelected: boolean;
|
||||
isFocused: boolean;
|
||||
showNumbers: boolean;
|
||||
selectedIndicator: string;
|
||||
numberColumnWidth: number;
|
||||
onSelect: (value: T) => void;
|
||||
setActiveIndex: (index: number) => void;
|
||||
renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode;
|
||||
}
|
||||
|
||||
function SelectionListItemRow<
|
||||
T,
|
||||
TItem extends SelectionListItem<T> = SelectionListItem<T>,
|
||||
>({
|
||||
item,
|
||||
itemIndex,
|
||||
isSelected,
|
||||
isFocused,
|
||||
showNumbers,
|
||||
selectedIndicator,
|
||||
numberColumnWidth,
|
||||
onSelect,
|
||||
setActiveIndex,
|
||||
renderItem,
|
||||
}: SelectionListItemRowProps<T, TItem>) {
|
||||
const containerRef = useRef<DOMElement>(null);
|
||||
|
||||
useMouseClick(
|
||||
containerRef,
|
||||
() => {
|
||||
if (!item.disabled) {
|
||||
if (isSelected) {
|
||||
// Second click on the same item triggers submission
|
||||
onSelect(item.value);
|
||||
} else {
|
||||
// First click highlights the item
|
||||
setActiveIndex(itemIndex);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: isFocused },
|
||||
);
|
||||
|
||||
let titleColor = theme.text.primary;
|
||||
let numberColor = theme.text.primary;
|
||||
|
||||
if (isSelected) {
|
||||
titleColor = theme.ui.focus;
|
||||
numberColor = theme.ui.focus;
|
||||
} else if (item.disabled) {
|
||||
titleColor = theme.text.secondary;
|
||||
numberColor = theme.text.secondary;
|
||||
}
|
||||
|
||||
if (!isFocused && !item.disabled) {
|
||||
numberColor = theme.text.secondary;
|
||||
}
|
||||
|
||||
if (!showNumbers) {
|
||||
numberColor = theme.text.secondary;
|
||||
}
|
||||
|
||||
const itemNumberText = `${String(itemIndex + 1).padStart(
|
||||
numberColumnWidth,
|
||||
)}.`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={containerRef}
|
||||
key={item.key}
|
||||
alignItems="flex-start"
|
||||
backgroundColor={isSelected ? theme.background.focus : undefined}
|
||||
>
|
||||
{/* Radio button indicator */}
|
||||
<Box minWidth={2} flexShrink={0}>
|
||||
<Text
|
||||
color={isSelected ? theme.ui.focus : theme.text.primary}
|
||||
aria-hidden
|
||||
>
|
||||
{isSelected ? selectedIndicator : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Item number */}
|
||||
{showNumbers && !item.hideNumber && (
|
||||
<Box
|
||||
marginRight={1}
|
||||
flexShrink={0}
|
||||
minWidth={itemNumberText.length}
|
||||
aria-state={{ checked: isSelected }}
|
||||
>
|
||||
<Text color={numberColor}>{itemNumberText}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Custom content via render prop */}
|
||||
<Box flexGrow={1}>
|
||||
{renderItem(item, {
|
||||
isSelected,
|
||||
titleColor,
|
||||
numberColor,
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base component for selection lists that provides common UI structure
|
||||
* and keyboard navigation logic via the useSelectionList hook.
|
||||
@@ -184,7 +70,7 @@ export function BaseSelectionList<
|
||||
selectedIndicator = '●',
|
||||
renderItem,
|
||||
}: BaseSelectionListProps<T, TItem>): React.JSX.Element {
|
||||
const { activeIndex, setActiveIndex } = useSelectionList({
|
||||
const { activeIndex } = useSelectionList({
|
||||
items,
|
||||
initialIndex,
|
||||
onSelect,
|
||||
@@ -221,12 +107,10 @@ export function BaseSelectionList<
|
||||
);
|
||||
const numberColumnWidth = String(items.length).length;
|
||||
|
||||
const showArrows = showScrollArrows && items.length > maxItemsToShow;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* Use conditional coloring instead of conditional rendering */}
|
||||
{showArrows && (
|
||||
{showScrollArrows && items.length > maxItemsToShow && (
|
||||
<Text
|
||||
color={
|
||||
effectiveScrollOffset > 0
|
||||
@@ -242,24 +126,71 @@ export function BaseSelectionList<
|
||||
const itemIndex = effectiveScrollOffset + index;
|
||||
const isSelected = activeIndex === itemIndex;
|
||||
|
||||
// Determine colors based on selection and disabled state
|
||||
let titleColor = theme.text.primary;
|
||||
let numberColor = theme.text.primary;
|
||||
|
||||
if (isSelected) {
|
||||
titleColor = theme.ui.focus;
|
||||
numberColor = theme.ui.focus;
|
||||
} else if (item.disabled) {
|
||||
titleColor = theme.text.secondary;
|
||||
numberColor = theme.text.secondary;
|
||||
}
|
||||
|
||||
if (!isFocused && !item.disabled) {
|
||||
numberColor = theme.text.secondary;
|
||||
}
|
||||
|
||||
if (!showNumbers) {
|
||||
numberColor = theme.text.secondary;
|
||||
}
|
||||
|
||||
const itemNumberText = `${String(itemIndex + 1).padStart(
|
||||
numberColumnWidth,
|
||||
)}.`;
|
||||
|
||||
return (
|
||||
<SelectionListItemRow
|
||||
<Box
|
||||
key={item.key}
|
||||
item={item}
|
||||
itemIndex={itemIndex}
|
||||
isSelected={isSelected}
|
||||
isFocused={isFocused}
|
||||
showNumbers={showNumbers}
|
||||
selectedIndicator={selectedIndicator}
|
||||
numberColumnWidth={numberColumnWidth}
|
||||
onSelect={onSelect}
|
||||
setActiveIndex={setActiveIndex}
|
||||
renderItem={renderItem}
|
||||
/>
|
||||
alignItems="flex-start"
|
||||
backgroundColor={isSelected ? theme.background.focus : undefined}
|
||||
>
|
||||
{/* Radio button indicator */}
|
||||
<Box minWidth={2} flexShrink={0}>
|
||||
<Text
|
||||
color={isSelected ? theme.ui.focus : theme.text.primary}
|
||||
aria-hidden
|
||||
>
|
||||
{isSelected ? selectedIndicator : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Item number */}
|
||||
{showNumbers && !item.hideNumber && (
|
||||
<Box
|
||||
marginRight={1}
|
||||
flexShrink={0}
|
||||
minWidth={itemNumberText.length}
|
||||
aria-state={{ checked: isSelected }}
|
||||
>
|
||||
<Text color={numberColor}>{itemNumberText}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Custom content via render prop */}
|
||||
<Box flexGrow={1}>
|
||||
{renderItem(item, {
|
||||
isSelected,
|
||||
titleColor,
|
||||
numberColor,
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{showArrows && (
|
||||
{showScrollArrows && items.length > maxItemsToShow && (
|
||||
<Text
|
||||
color={
|
||||
effectiveScrollOffset + maxItemsToShow < items.length
|
||||
|
||||
@@ -11,17 +11,12 @@ import { act } from 'react';
|
||||
import { TextInput } from './TextInput.js';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { useTextBuffer, type TextBuffer } from './text-buffer.js';
|
||||
import { useMouseClick } from '../../hooks/useMouseClick.js';
|
||||
|
||||
// Mocks
|
||||
vi.mock('../../hooks/useKeypress.js', () => ({
|
||||
useKeypress: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useMouseClick.js', () => ({
|
||||
useMouseClick: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./text-buffer.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./text-buffer.js')>();
|
||||
const mockTextBuffer = {
|
||||
@@ -74,7 +69,6 @@ vi.mock('./text-buffer.js', async (importOriginal) => {
|
||||
|
||||
const mockedUseKeypress = useKeypress as Mock;
|
||||
const mockedUseTextBuffer = useTextBuffer as Mock;
|
||||
const mockedUseMouseClick = useMouseClick as Mock;
|
||||
|
||||
describe('TextInput', () => {
|
||||
const onCancel = vi.fn();
|
||||
@@ -90,7 +84,6 @@ describe('TextInput', () => {
|
||||
cursor: [0, 0],
|
||||
visualCursor: [0, 0],
|
||||
viewportVisualLines: [''],
|
||||
visualScrollRow: 0,
|
||||
pastedContent: {} as Record<string, string>,
|
||||
handleInput: vi.fn((key) => {
|
||||
if (key.sequence) {
|
||||
@@ -415,36 +408,4 @@ describe('TextInput', () => {
|
||||
expect(lastFrame()).toContain('line2');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('registers mouse click handler for free-form text input', async () => {
|
||||
const { unmount } = await render(
|
||||
<TextInput buffer={mockBuffer} onSubmit={onSubmit} onCancel={onCancel} />,
|
||||
);
|
||||
|
||||
expect(mockedUseMouseClick).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ isActive: true, name: 'left-press' }),
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('registers mouse click handler for placeholder view', async () => {
|
||||
mockBuffer.text = '';
|
||||
const { unmount } = await render(
|
||||
<TextInput
|
||||
buffer={mockBuffer}
|
||||
placeholder="test"
|
||||
onSubmit={onSubmit}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(mockedUseMouseClick).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ isActive: true, name: 'left-press' }),
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { Text, Box, type DOMElement } from 'ink';
|
||||
import { useCallback } from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||
import chalk from 'chalk';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
@@ -14,7 +14,6 @@ import { expandPastePlaceholders, type TextBuffer } from './text-buffer.js';
|
||||
import { cpSlice, cpIndexToOffset } from '../../utils/textUtils.js';
|
||||
import { Command } from '../../key/keyMatchers.js';
|
||||
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
||||
import { useMouseClick } from '../../hooks/useMouseClick.js';
|
||||
|
||||
export interface TextInputProps {
|
||||
buffer: TextBuffer;
|
||||
@@ -32,8 +31,6 @@ export function TextInput({
|
||||
focus = true,
|
||||
}: TextInputProps): React.JSX.Element {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const containerRef = useRef<DOMElement>(null);
|
||||
|
||||
const {
|
||||
text,
|
||||
handleInput,
|
||||
@@ -43,17 +40,6 @@ export function TextInput({
|
||||
} = buffer;
|
||||
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] = visualCursor;
|
||||
|
||||
useMouseClick(
|
||||
containerRef,
|
||||
(_event, relativeX, relativeY) => {
|
||||
if (focus) {
|
||||
const visRowAbsolute = visualScrollRow + relativeY;
|
||||
buffer.moveToVisualPosition(visRowAbsolute, relativeX);
|
||||
}
|
||||
},
|
||||
{ isActive: focus, name: 'left-press' },
|
||||
);
|
||||
|
||||
const handleKeyPress = useCallback(
|
||||
(key: Key) => {
|
||||
if (key.name === 'escape' && onCancel) {
|
||||
@@ -78,7 +64,7 @@ export function TextInput({
|
||||
|
||||
if (showPlaceholder) {
|
||||
return (
|
||||
<Box ref={containerRef}>
|
||||
<Box>
|
||||
{focus ? (
|
||||
<Text terminalCursorFocus={focus} terminalCursorPosition={0}>
|
||||
{chalk.inverse(placeholder[0] || ' ')}
|
||||
@@ -92,7 +78,7 @@ export function TextInput({
|
||||
}
|
||||
|
||||
return (
|
||||
<Box ref={containerRef} flexDirection="column">
|
||||
<Box flexDirection="column">
|
||||
{viewportVisualLines.map((lineText, idx) => {
|
||||
const currentVisualRow = visualScrollRow + idx;
|
||||
const isCursorLine =
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"description": "Gemini CLI Core",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
AbstractOsSandboxManager,
|
||||
type PreparedExecutionDetails,
|
||||
} from './abstractOsSandboxManager.js';
|
||||
|
||||
import type {
|
||||
SandboxRequest,
|
||||
ParsedSandboxDenial,
|
||||
SandboxedCommand,
|
||||
SandboxPermissions,
|
||||
} from '../services/sandboxManager.js';
|
||||
import type { ShellExecutionResult } from '../services/shellExecutionService.js';
|
||||
import path from 'node:path';
|
||||
import type { SandboxPolicyManager } from '../policy/sandboxPolicyManager.js';
|
||||
|
||||
/**
|
||||
* A minimal concrete subclass to test AbstractOsSandboxManager in isolation.
|
||||
* Overriding abstract methods avoids real OS dependencies and focuses on the
|
||||
* Template Method flow and environment preparation.
|
||||
*/
|
||||
class TestOsSandboxManager extends AbstractOsSandboxManager {
|
||||
isDangerousCommand(_args: string[]): boolean {
|
||||
return false;
|
||||
}
|
||||
parseDenials(_result: ShellExecutionResult): ParsedSandboxDenial | undefined {
|
||||
return undefined;
|
||||
}
|
||||
protected async buildSandboxedCommand(
|
||||
details: PreparedExecutionDetails,
|
||||
): Promise<SandboxedCommand> {
|
||||
return {
|
||||
program: 'test',
|
||||
args: [details.networkAccess ? '1' : '0'],
|
||||
env: details.sanitizedEnv,
|
||||
cwd: details.req.cwd,
|
||||
cleanup: () => {},
|
||||
};
|
||||
}
|
||||
protected override isOsSafeCommand(_args: string[]): boolean {
|
||||
return false;
|
||||
}
|
||||
protected override async isStrictlyApproved(
|
||||
_command: string,
|
||||
_args: string[],
|
||||
_req: SandboxRequest,
|
||||
): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
protected override ensureGovernanceFilesExist(_workspace: string): void {}
|
||||
protected override async initialize(): Promise<void> {}
|
||||
protected override mapVirtualCommandToNative(
|
||||
command: string,
|
||||
args: string[],
|
||||
) {
|
||||
return { command, args };
|
||||
}
|
||||
protected override updateReadWritePermissions() {}
|
||||
protected override rewriteReadWriteCommand(
|
||||
_req: SandboxRequest,
|
||||
command: string,
|
||||
args: string[],
|
||||
_permissions: SandboxPermissions,
|
||||
) {
|
||||
return { command, args };
|
||||
}
|
||||
protected override isCaseInsensitive() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
describe('AbstractOsSandboxManager', () => {
|
||||
const workspace = path.resolve('/workspace');
|
||||
|
||||
it('rejects overrides when allowOverrides is false', async () => {
|
||||
const customManager = new TestOsSandboxManager({
|
||||
workspace,
|
||||
modeConfig: { allowOverrides: false },
|
||||
});
|
||||
await expect(
|
||||
customManager.prepareCommand({
|
||||
command: 'ls',
|
||||
args: [],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
policy: { networkAccess: true },
|
||||
}),
|
||||
).rejects.toThrow(/Cannot override/);
|
||||
});
|
||||
|
||||
it('should correctly pass through the cwd to the resulting command', async () => {
|
||||
const manager = new TestOsSandboxManager({ workspace });
|
||||
const result = await manager.prepareCommand({
|
||||
command: 'echo',
|
||||
args: ['hello'],
|
||||
cwd: '/test/different/cwd',
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.cwd).toBe('/test/different/cwd');
|
||||
});
|
||||
|
||||
it('should apply environment sanitization via the default mechanisms', async () => {
|
||||
const manager = new TestOsSandboxManager({ workspace });
|
||||
const result = await manager.prepareCommand({
|
||||
command: 'echo',
|
||||
args: ['hello'],
|
||||
cwd: workspace,
|
||||
env: {
|
||||
SAFE_VAR: '1',
|
||||
GITHUB_TOKEN: 'sensitive',
|
||||
},
|
||||
policy: {
|
||||
sanitizationConfig: { enableEnvironmentVariableRedaction: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.env['SAFE_VAR']).toBe('1');
|
||||
expect(result.env['GITHUB_TOKEN']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject network access in Plan mode', async () => {
|
||||
const planManager = new TestOsSandboxManager({
|
||||
workspace,
|
||||
modeConfig: { readonly: true, allowOverrides: false },
|
||||
});
|
||||
const req: SandboxRequest = {
|
||||
command: 'curl',
|
||||
args: ['google.com'],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
policy: {
|
||||
additionalPermissions: { network: true },
|
||||
},
|
||||
};
|
||||
|
||||
await expect(planManager.prepareCommand(req)).rejects.toThrow(
|
||||
'Sandbox request rejected: Cannot override readonly/network/filesystem restrictions in Plan mode.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle persistent permissions from policyManager', async () => {
|
||||
const persistentPath = path.join(workspace, 'persistent');
|
||||
const mockPolicyManager = {
|
||||
getCommandPermissions: vi.fn().mockReturnValue({
|
||||
fileSystem: { write: [persistentPath] },
|
||||
network: true,
|
||||
}),
|
||||
};
|
||||
|
||||
const managerWithPolicy = new TestOsSandboxManager({
|
||||
workspace,
|
||||
modeConfig: { allowOverrides: true, network: false },
|
||||
policyManager: mockPolicyManager as unknown as SandboxPolicyManager,
|
||||
});
|
||||
|
||||
const req: SandboxRequest = {
|
||||
command: 'test-cmd',
|
||||
args: [],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
};
|
||||
|
||||
const result = await managerWithPolicy.prepareCommand(req);
|
||||
expect(result.args[0]).toBe('1'); // Network allowed by persistent policy
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
SandboxManager,
|
||||
GlobalSandboxOptions,
|
||||
SandboxRequest,
|
||||
SandboxedCommand,
|
||||
SandboxPermissions,
|
||||
ParsedSandboxDenial,
|
||||
} from '../services/sandboxManager.js';
|
||||
import type { ShellExecutionResult } from '../services/shellExecutionService.js';
|
||||
import {
|
||||
sanitizeEnvironment,
|
||||
getSecureSanitizationConfig,
|
||||
} from '../services/environmentSanitization.js';
|
||||
import {
|
||||
verifySandboxOverrides,
|
||||
getCommandName,
|
||||
} from './utils/commandUtils.js';
|
||||
import {
|
||||
createSandboxDenialCache,
|
||||
type SandboxDenialCache,
|
||||
} from './utils/sandboxDenialUtils.js';
|
||||
import {
|
||||
type ResolvedSandboxPaths,
|
||||
resolveSandboxPaths,
|
||||
} from './utils/sandboxPathUtils.js';
|
||||
|
||||
export interface PreparedExecutionDetails {
|
||||
finalCommand: string;
|
||||
finalArgs: string[];
|
||||
sanitizedEnv: NodeJS.ProcessEnv;
|
||||
resolvedPaths: ResolvedSandboxPaths;
|
||||
workspaceWrite: boolean;
|
||||
networkAccess: boolean;
|
||||
req: SandboxRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for OS-specific sandbox managers.
|
||||
* Enforces the Template Method pattern for command preparation.
|
||||
*/
|
||||
export abstract class AbstractOsSandboxManager implements SandboxManager {
|
||||
protected readonly denialCache: SandboxDenialCache =
|
||||
createSandboxDenialCache();
|
||||
protected governanceFilesInitialized = false;
|
||||
|
||||
constructor(protected readonly options: GlobalSandboxOptions) {}
|
||||
|
||||
getWorkspace(): string {
|
||||
return this.options.workspace;
|
||||
}
|
||||
|
||||
getOptions(): GlobalSandboxOptions {
|
||||
return this.options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares a command for sandboxed execution by resolving permissions and paths.
|
||||
*/
|
||||
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
|
||||
// Initialize OS-specific sandbox mechanisms if needed
|
||||
await this.initialize();
|
||||
|
||||
// Sanitize environment variables based on policy
|
||||
const sanitizationConfig = getSecureSanitizationConfig(
|
||||
req.policy?.sanitizationConfig,
|
||||
);
|
||||
const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig);
|
||||
|
||||
// Verify that request doesn't attempt illegal overrides
|
||||
const isReadonlyMode = this.options.modeConfig?.readonly ?? true;
|
||||
const allowOverrides = this.options.modeConfig?.allowOverrides ?? true;
|
||||
verifySandboxOverrides(allowOverrides, req.policy);
|
||||
|
||||
// Translate virtual commands (like __read) to native commands
|
||||
const { command, args } = this.mapVirtualCommandToNative(
|
||||
req.command,
|
||||
req.args,
|
||||
);
|
||||
|
||||
// Extract a stable command name for policy lookup
|
||||
const commandName = await getCommandName({ ...req, command, args });
|
||||
|
||||
// Determine if this specific execution is strictly approved
|
||||
const isApproved = allowOverrides
|
||||
? await this.isStrictlyApproved(command, args, req)
|
||||
: false;
|
||||
|
||||
// Resolve broad write permissions (workspace, readonly, yolo)
|
||||
const isYolo = this.options.modeConfig?.yolo ?? false;
|
||||
const workspaceWrite = !isReadonlyMode || isApproved || isYolo;
|
||||
|
||||
const defaultNetwork =
|
||||
this.options.modeConfig?.network || req.policy?.networkAccess || isYolo;
|
||||
|
||||
// Load persistent permissions for this command from policy manager
|
||||
const persistentPermissions = allowOverrides
|
||||
? this.options.policyManager?.getCommandPermissions(commandName)
|
||||
: undefined;
|
||||
|
||||
// Merge request-specific and persistent permissions
|
||||
const mergedPermissions: SandboxPermissions = {
|
||||
fileSystem: {
|
||||
read: [
|
||||
...(persistentPermissions?.fileSystem?.read ?? []),
|
||||
...(req.policy?.additionalPermissions?.fileSystem?.read ?? []),
|
||||
],
|
||||
write: [
|
||||
...(persistentPermissions?.fileSystem?.write ?? []),
|
||||
...(req.policy?.additionalPermissions?.fileSystem?.write ?? []),
|
||||
],
|
||||
},
|
||||
network:
|
||||
defaultNetwork ||
|
||||
persistentPermissions?.network ||
|
||||
req.policy?.additionalPermissions?.network ||
|
||||
false,
|
||||
};
|
||||
|
||||
// Allow OS-specific managers to update permissions (rules) based on the request (e.g., Windows tracking file targets for manifests)
|
||||
this.updateReadWritePermissions(mergedPermissions, req);
|
||||
|
||||
// Allow OS-specific managers to rewrite the command (action) based on permissions (e.g., resolving read/write paths for POSIX systems)
|
||||
const { command: finalCommand, args: finalArgs } =
|
||||
this.rewriteReadWriteCommand(req, command, args, mergedPermissions);
|
||||
|
||||
// Resolve all paths to absolute real paths
|
||||
const resolvedPaths = await resolveSandboxPaths(
|
||||
this.options,
|
||||
req,
|
||||
mergedPermissions,
|
||||
);
|
||||
|
||||
// Ensure sandbox policy evidence files exist in workspace
|
||||
this.ensureGovernanceFilesExist(resolvedPaths.workspace.resolved);
|
||||
|
||||
// Delegate the actual construction of the sandboxed invocation
|
||||
return this.buildSandboxedCommand({
|
||||
finalCommand,
|
||||
finalArgs,
|
||||
sanitizedEnv,
|
||||
resolvedPaths,
|
||||
workspaceWrite,
|
||||
networkAccess: mergedPermissions.network ?? false,
|
||||
req,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a command is allowed by policy or considered safe by the OS.
|
||||
*/
|
||||
isKnownSafeCommand(args: string[]): boolean {
|
||||
const toolName = args[0];
|
||||
if (!toolName) return false;
|
||||
|
||||
if (this.isToolApproved(toolName)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.isOsSafeCommand(args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a tool is in the list of approved tools.
|
||||
*/
|
||||
protected isToolApproved(toolName: string): boolean {
|
||||
const tools = this.options.modeConfig?.approvedTools ?? [];
|
||||
if (tools.length === 0) return false;
|
||||
|
||||
if (this.isCaseInsensitive()) {
|
||||
const targetTool = toolName.toLowerCase();
|
||||
return tools.map((t) => t.toLowerCase()).includes(targetTool);
|
||||
}
|
||||
|
||||
return tools.includes(toolName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook called at the very beginning of command preparation.
|
||||
* Used for OS-specific initialization (e.g., compiling helpers).
|
||||
*/
|
||||
protected abstract initialize(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Ensures that governance files exist in the sandbox workspace.
|
||||
*/
|
||||
protected abstract ensureGovernanceFilesExist(workspace: string): void;
|
||||
|
||||
/**
|
||||
* Translates virtual commands (like `__read` or `__write`) into native OS commands.
|
||||
*/
|
||||
protected abstract mapVirtualCommandToNative(
|
||||
command: string,
|
||||
args: string[],
|
||||
): { command: string; args: string[] };
|
||||
|
||||
/**
|
||||
* Allows OS-specific managers to update permissions (rules) for read/write commands.
|
||||
*/
|
||||
protected abstract updateReadWritePermissions(
|
||||
permissions: SandboxPermissions,
|
||||
req: SandboxRequest,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Allows OS-specific managers to rewrite the command (action) for read/write commands
|
||||
* after permissions are resolved.
|
||||
*/
|
||||
protected abstract rewriteReadWriteCommand(
|
||||
req: SandboxRequest,
|
||||
command: string,
|
||||
args: string[],
|
||||
permissions: SandboxPermissions,
|
||||
): { command: string; args: string[] };
|
||||
|
||||
/**
|
||||
* Builds the final sandboxed command execution details.
|
||||
*/
|
||||
protected abstract buildSandboxedCommand(
|
||||
details: PreparedExecutionDetails,
|
||||
): Promise<SandboxedCommand>;
|
||||
|
||||
/**
|
||||
* Returns whether the filesystem is case-insensitive.
|
||||
*/
|
||||
protected abstract isCaseInsensitive(): boolean;
|
||||
|
||||
/**
|
||||
* Returns whether the command or arguments are considered dangerous
|
||||
* regardless of the sandbox configuration.
|
||||
*/
|
||||
abstract isDangerousCommand(args: string[]): boolean;
|
||||
|
||||
/**
|
||||
* OS-specific check for known safe commands.
|
||||
*/
|
||||
protected abstract isOsSafeCommand(args: string[]): boolean;
|
||||
|
||||
/**
|
||||
* Checks if the command is strictly approved for execution,
|
||||
* potentially overriding read-only restrictions.
|
||||
*/
|
||||
protected abstract isStrictlyApproved(
|
||||
command: string,
|
||||
args: string[],
|
||||
req: SandboxRequest,
|
||||
): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Parses denials from execution output to populate the cache.
|
||||
*/
|
||||
abstract parseDenials(
|
||||
result: ShellExecutionResult,
|
||||
): ParsedSandboxDenial | undefined;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Files that represent the governance or "constitution" of the repository
|
||||
* and should be write-protected in any sandbox.
|
||||
*/
|
||||
export const GOVERNANCE_FILES = [
|
||||
{ path: '.gitignore', isDirectory: false },
|
||||
{ path: '.geminiignore', isDirectory: false },
|
||||
{ path: '.git', isDirectory: true },
|
||||
];
|
||||
|
||||
/**
|
||||
* Files that typically contain sensitive secrets or environment variables
|
||||
* and should be protected from unauthorized access or exfiltration.
|
||||
*/
|
||||
export const SECRET_FILES = [
|
||||
{ pattern: '.env' },
|
||||
{ pattern: '.env.*' },
|
||||
] as const;
|
||||
@@ -53,7 +53,6 @@ vi.mock('../../utils/shell-utils.js', async (importOriginal) => {
|
||||
Promise.resolve({ status: 0, stdout: Buffer.from('') }),
|
||||
),
|
||||
initializeShellParsers: vi.fn(),
|
||||
isStrictlyApproved: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -149,21 +148,5 @@ describe('LinuxSandboxManager', () => {
|
||||
expect(result.args[result.args.length - 2]).toBe('/bin/cat');
|
||||
expect(result.args[result.args.length - 1]).toBe(testFile);
|
||||
});
|
||||
|
||||
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: { networkAccess: true },
|
||||
}),
|
||||
).rejects.toThrow(/Cannot override/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,189 +5,95 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { join } from 'node:path';
|
||||
import os from 'node:os';
|
||||
import {
|
||||
type SandboxManager,
|
||||
type GlobalSandboxOptions,
|
||||
type SandboxRequest,
|
||||
type SandboxedCommand,
|
||||
type SandboxPermissions,
|
||||
GOVERNANCE_FILES,
|
||||
type ParsedSandboxDenial,
|
||||
resolveSandboxPaths,
|
||||
} from '../../services/sandboxManager.js';
|
||||
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
|
||||
import {
|
||||
sanitizeEnvironment,
|
||||
getSecureSanitizationConfig,
|
||||
} from '../../services/environmentSanitization.js';
|
||||
import {
|
||||
isStrictlyApproved,
|
||||
verifySandboxOverrides,
|
||||
getCommandName,
|
||||
} from '../utils/commandUtils.js';
|
||||
import { assertValidPathString } from '../../utils/paths.js';
|
||||
import {
|
||||
isKnownSafeCommand,
|
||||
isDangerousCommand,
|
||||
isKnownSafeCommand as isPosixSafeCommand,
|
||||
isDangerousCommand as isPosixDangerousCommand,
|
||||
} from '../utils/commandSafety.js';
|
||||
import {
|
||||
parsePosixSandboxDenials,
|
||||
createSandboxDenialCache,
|
||||
type SandboxDenialCache,
|
||||
} from '../utils/sandboxDenialUtils.js';
|
||||
import { isErrnoException } from '../utils/fsUtils.js';
|
||||
import { ensureGovernanceFilesExist } from '../utils/governanceUtils.js';
|
||||
import { parsePosixSandboxDenials } from '../utils/sandboxDenialUtils.js';
|
||||
import { handleReadWriteCommands } from '../utils/sandboxReadWriteUtils.js';
|
||||
import { buildBwrapArgs } from './bwrapArgsBuilder.js';
|
||||
|
||||
let cachedBpfPath: string | undefined;
|
||||
|
||||
function getSeccompBpfPath(): string {
|
||||
if (cachedBpfPath) return cachedBpfPath;
|
||||
|
||||
const arch = os.arch();
|
||||
let AUDIT_ARCH: number;
|
||||
let SYS_ptrace: number;
|
||||
|
||||
if (arch === 'x64') {
|
||||
AUDIT_ARCH = 0xc000003e; // AUDIT_ARCH_X86_64
|
||||
SYS_ptrace = 101;
|
||||
} else if (arch === 'arm64') {
|
||||
AUDIT_ARCH = 0xc00000b7; // AUDIT_ARCH_AARCH64
|
||||
SYS_ptrace = 117;
|
||||
} else if (arch === 'arm') {
|
||||
AUDIT_ARCH = 0x40000028; // AUDIT_ARCH_ARM
|
||||
SYS_ptrace = 26;
|
||||
} else if (arch === 'ia32') {
|
||||
AUDIT_ARCH = 0x40000003; // AUDIT_ARCH_I386
|
||||
SYS_ptrace = 26;
|
||||
} else {
|
||||
throw new Error(`Unsupported architecture for seccomp filter: ${arch}`);
|
||||
}
|
||||
|
||||
const EPERM = 1;
|
||||
const SECCOMP_RET_KILL_PROCESS = 0x80000000;
|
||||
const SECCOMP_RET_ERRNO = 0x00050000;
|
||||
const SECCOMP_RET_ALLOW = 0x7fff0000;
|
||||
|
||||
const instructions = [
|
||||
{ code: 0x20, jt: 0, jf: 0, k: 4 }, // Load arch
|
||||
{ code: 0x15, jt: 1, jf: 0, k: AUDIT_ARCH }, // Jump to kill if arch != native arch
|
||||
{ code: 0x06, jt: 0, jf: 0, k: SECCOMP_RET_KILL_PROCESS }, // Kill
|
||||
|
||||
{ code: 0x20, jt: 0, jf: 0, k: 0 }, // Load nr
|
||||
{ code: 0x15, jt: 0, jf: 1, k: SYS_ptrace }, // If ptrace, jump to ERRNO
|
||||
{ code: 0x06, jt: 0, jf: 0, k: SECCOMP_RET_ERRNO | EPERM }, // ERRNO
|
||||
|
||||
{ code: 0x06, jt: 0, jf: 0, k: SECCOMP_RET_ALLOW }, // Allow
|
||||
];
|
||||
|
||||
const buf = Buffer.alloc(8 * instructions.length);
|
||||
for (let i = 0; i < instructions.length; i++) {
|
||||
const inst = instructions[i];
|
||||
const offset = i * 8;
|
||||
buf.writeUInt16LE(inst.code, offset);
|
||||
buf.writeUInt8(inst.jt, offset + 2);
|
||||
buf.writeUInt8(inst.jf, offset + 3);
|
||||
buf.writeUInt32LE(inst.k, offset + 4);
|
||||
}
|
||||
|
||||
const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'gemini-cli-seccomp-'));
|
||||
const bpfPath = join(tempDir, 'seccomp.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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a file or directory exists.
|
||||
*/
|
||||
function touch(filePath: string, isDirectory: boolean) {
|
||||
assertValidPathString(filePath);
|
||||
try {
|
||||
// If it exists (even as a broken symlink), do nothing
|
||||
fs.lstatSync(filePath);
|
||||
return;
|
||||
} catch (e: unknown) {
|
||||
if (isErrnoException(e) && e.code !== 'ENOENT') {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
if (isDirectory) {
|
||||
fs.mkdirSync(filePath, { recursive: true });
|
||||
} else {
|
||||
fs.mkdirSync(dirname(filePath), { recursive: true });
|
||||
fs.closeSync(fs.openSync(filePath, 'a'));
|
||||
}
|
||||
}
|
||||
import {
|
||||
AbstractOsSandboxManager,
|
||||
type PreparedExecutionDetails,
|
||||
} from '../abstractOsSandboxManager.js';
|
||||
import { isStrictlyApproved } from '../utils/commandUtils.js';
|
||||
|
||||
/**
|
||||
* A SandboxManager implementation for Linux that uses Bubblewrap (bwrap).
|
||||
*/
|
||||
|
||||
export class LinuxSandboxManager implements SandboxManager {
|
||||
private static maskFilePath: string | undefined;
|
||||
private readonly denialCache: SandboxDenialCache = createSandboxDenialCache();
|
||||
private governanceFilesInitialized = false;
|
||||
export class LinuxSandboxManager extends AbstractOsSandboxManager {
|
||||
private cachedBpfPath: string | undefined;
|
||||
private maskFilePath: string | undefined;
|
||||
|
||||
constructor(private readonly options: GlobalSandboxOptions) {}
|
||||
protected override async initialize(): Promise<void> {
|
||||
// Default no-op
|
||||
}
|
||||
|
||||
private ensureGovernanceFilesExist(workspace: string): void {
|
||||
if (this.governanceFilesInitialized) return;
|
||||
private getSeccompBpfPath(): string {
|
||||
if (this.cachedBpfPath) return this.cachedBpfPath;
|
||||
|
||||
// These must exist on the host before running the sandbox to ensure they are protected.
|
||||
for (const file of GOVERNANCE_FILES) {
|
||||
const filePath = join(workspace, file.path);
|
||||
touch(filePath, file.isDirectory);
|
||||
const arch = os.arch();
|
||||
let AUDIT_ARCH: number;
|
||||
let SYS_ptrace: number;
|
||||
|
||||
if (arch === 'x64') {
|
||||
AUDIT_ARCH = 0xc000003e; // AUDIT_ARCH_X86_64
|
||||
SYS_ptrace = 101;
|
||||
} else if (arch === 'arm64') {
|
||||
AUDIT_ARCH = 0xc00000b7; // AUDIT_ARCH_AARCH64
|
||||
SYS_ptrace = 117;
|
||||
} else if (arch === 'arm') {
|
||||
AUDIT_ARCH = 0x40000028; // AUDIT_ARCH_ARM
|
||||
SYS_ptrace = 26;
|
||||
} else if (arch === 'ia32') {
|
||||
AUDIT_ARCH = 0x40000003; // AUDIT_ARCH_I386
|
||||
SYS_ptrace = 26;
|
||||
} else {
|
||||
throw new Error(`Unsupported architecture for seccomp filter: ${arch}`);
|
||||
}
|
||||
|
||||
this.governanceFilesInitialized = true;
|
||||
}
|
||||
const EPERM = 1;
|
||||
const SECCOMP_RET_KILL_PROCESS = 0x80000000;
|
||||
const SECCOMP_RET_ERRNO = 0x00050000;
|
||||
const SECCOMP_RET_ALLOW = 0x7fff0000;
|
||||
|
||||
isKnownSafeCommand(args: string[]): boolean {
|
||||
return isKnownSafeCommand(args);
|
||||
}
|
||||
const instructions = [
|
||||
{ code: 0x20, jt: 0, jf: 0, k: 4 }, // Load arch
|
||||
{ code: 0x15, jt: 1, jf: 0, k: AUDIT_ARCH }, // Jump to kill if arch != native arch
|
||||
{ code: 0x06, jt: 0, jf: 0, k: SECCOMP_RET_KILL_PROCESS }, // Kill
|
||||
|
||||
isDangerousCommand(args: string[]): boolean {
|
||||
return isDangerousCommand(args);
|
||||
}
|
||||
{ code: 0x20, jt: 0, jf: 0, k: 0 }, // Load nr
|
||||
{ code: 0x15, jt: 0, jf: 1, k: SYS_ptrace }, // If ptrace, jump to ERRNO
|
||||
{ code: 0x06, jt: 0, jf: 0, k: SECCOMP_RET_ERRNO | EPERM }, // ERRNO
|
||||
|
||||
parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined {
|
||||
return parsePosixSandboxDenials(result, this.denialCache);
|
||||
}
|
||||
{ code: 0x06, jt: 0, jf: 0, k: SECCOMP_RET_ALLOW }, // Allow
|
||||
];
|
||||
|
||||
getWorkspace(): string {
|
||||
return this.options.workspace;
|
||||
}
|
||||
|
||||
getOptions(): GlobalSandboxOptions {
|
||||
return this.options;
|
||||
}
|
||||
|
||||
private getMaskFilePath(): string {
|
||||
if (
|
||||
LinuxSandboxManager.maskFilePath &&
|
||||
fs.existsSync(LinuxSandboxManager.maskFilePath)
|
||||
) {
|
||||
return LinuxSandboxManager.maskFilePath;
|
||||
const buf = Buffer.alloc(8 * instructions.length);
|
||||
for (let i = 0; i < instructions.length; i++) {
|
||||
const inst = instructions[i];
|
||||
const offset = i * 8;
|
||||
buf.writeUInt16LE(inst.code, offset);
|
||||
buf.writeUInt8(inst.jt, offset + 2);
|
||||
buf.writeUInt8(inst.jf, offset + 3);
|
||||
buf.writeUInt32LE(inst.k, offset + 4);
|
||||
}
|
||||
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;
|
||||
|
||||
const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'gemini-cli-seccomp-'));
|
||||
const bpfPath = join(tempDir, 'seccomp.bpf');
|
||||
fs.writeFileSync(bpfPath, buf);
|
||||
this.cachedBpfPath = bpfPath;
|
||||
|
||||
// Cleanup on exit
|
||||
process.on('exit', () => {
|
||||
@@ -198,94 +104,81 @@ export class LinuxSandboxManager implements SandboxManager {
|
||||
}
|
||||
});
|
||||
|
||||
return maskPath;
|
||||
return bpfPath;
|
||||
}
|
||||
|
||||
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
|
||||
const isReadonlyMode = this.options.modeConfig?.readonly ?? true;
|
||||
const allowOverrides = this.options.modeConfig?.allowOverrides ?? true;
|
||||
protected override ensureGovernanceFilesExist(workspace: string): void {
|
||||
if (this.governanceFilesInitialized) return;
|
||||
ensureGovernanceFilesExist(workspace);
|
||||
this.governanceFilesInitialized = true;
|
||||
}
|
||||
|
||||
verifySandboxOverrides(allowOverrides, req.policy);
|
||||
|
||||
let command = req.command;
|
||||
let args = req.args;
|
||||
|
||||
// Translate virtual commands for sandboxed file system access
|
||||
/**
|
||||
* Virtual commands like __read and __write must be mapped to native POSIX tools
|
||||
* so that Bubblewrap can enforce file access policies on real executables.
|
||||
*/
|
||||
protected override mapVirtualCommandToNative(
|
||||
command: string,
|
||||
args: string[],
|
||||
): { command: string; args: string[] } {
|
||||
if (command === '__read') {
|
||||
command = 'cat';
|
||||
} else if (command === '__write') {
|
||||
command = 'sh';
|
||||
args = ['-c', 'cat > "$1"', '_', ...args];
|
||||
return { command: 'cat', args };
|
||||
}
|
||||
if (command === '__write') {
|
||||
return { command: 'sh', args: ['-c', 'cat > "$1"', '_', ...args] };
|
||||
}
|
||||
return { command, args };
|
||||
}
|
||||
|
||||
const commandName = await getCommandName({ ...req, command, args });
|
||||
const isApproved = allowOverrides
|
||||
? await isStrictlyApproved(
|
||||
{ ...req, command, args },
|
||||
this.options.modeConfig?.approvedTools,
|
||||
)
|
||||
: false;
|
||||
const isYolo = this.options.modeConfig?.yolo ?? false;
|
||||
const workspaceWrite = !isReadonlyMode || isApproved || isYolo;
|
||||
/**
|
||||
* Linux permissions are entirely driven by Bubblewrap bind mounts and seccomp filters
|
||||
* resolved from paths later in the flow. No intermediate tweaking is needed here.
|
||||
*/
|
||||
protected override updateReadWritePermissions(
|
||||
_permissions: SandboxPermissions,
|
||||
_req: SandboxRequest,
|
||||
): void {
|
||||
// Default no-op
|
||||
}
|
||||
|
||||
const networkAccess =
|
||||
this.options.modeConfig?.network || req.policy?.networkAccess || isYolo;
|
||||
/**
|
||||
* Ensures read/write commands strictly respect allowed paths and workspace boundaries
|
||||
* before the final sandbox invocation is constructed.
|
||||
*/
|
||||
protected override rewriteReadWriteCommand(
|
||||
req: SandboxRequest,
|
||||
command: string,
|
||||
args: string[],
|
||||
permissions: SandboxPermissions,
|
||||
): { command: string; args: string[] } {
|
||||
return handleReadWriteCommands(req, permissions, this.options.workspace, [
|
||||
...(req.policy?.allowedPaths || []),
|
||||
...(this.options.includeDirectories || []),
|
||||
]);
|
||||
}
|
||||
|
||||
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 { command: finalCommand, args: finalArgs } = handleReadWriteCommands(
|
||||
protected async buildSandboxedCommand(
|
||||
details: PreparedExecutionDetails,
|
||||
): Promise<SandboxedCommand> {
|
||||
const {
|
||||
finalCommand,
|
||||
finalArgs,
|
||||
sanitizedEnv,
|
||||
resolvedPaths,
|
||||
workspaceWrite,
|
||||
networkAccess,
|
||||
req,
|
||||
mergedAdditional,
|
||||
this.options.workspace,
|
||||
[
|
||||
...(req.policy?.allowedPaths || []),
|
||||
...(this.options.includeDirectories || []),
|
||||
],
|
||||
);
|
||||
|
||||
const sanitizationConfig = getSecureSanitizationConfig(
|
||||
req.policy?.sanitizationConfig,
|
||||
);
|
||||
|
||||
const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig);
|
||||
|
||||
const resolvedPaths = await resolveSandboxPaths(
|
||||
this.options,
|
||||
req,
|
||||
mergedAdditional,
|
||||
);
|
||||
|
||||
this.ensureGovernanceFilesExist(resolvedPaths.workspace.resolved);
|
||||
} = details;
|
||||
|
||||
const bwrapArgs = await buildBwrapArgs({
|
||||
resolvedPaths,
|
||||
workspaceWrite,
|
||||
networkAccess: mergedAdditional.network ?? false,
|
||||
networkAccess,
|
||||
maskFilePath: this.getMaskFilePath(),
|
||||
isReadOnlyCommand: req.command === '__read',
|
||||
});
|
||||
|
||||
const bpfPath = getSeccompBpfPath();
|
||||
const bpfPath = this.getSeccompBpfPath();
|
||||
bwrapArgs.push('--seccomp', '9');
|
||||
|
||||
const argsPath = this.writeArgsToTempFile(bwrapArgs);
|
||||
@@ -316,6 +209,33 @@ export class LinuxSandboxManager implements SandboxManager {
|
||||
};
|
||||
}
|
||||
|
||||
protected override isCaseInsensitive(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
isDangerousCommand(args: string[]): boolean {
|
||||
return isPosixDangerousCommand(args);
|
||||
}
|
||||
|
||||
protected override isOsSafeCommand(args: string[]): boolean {
|
||||
return isPosixSafeCommand(args);
|
||||
}
|
||||
|
||||
protected override async isStrictlyApproved(
|
||||
command: string,
|
||||
args: string[],
|
||||
req: SandboxRequest,
|
||||
): Promise<boolean> {
|
||||
return isStrictlyApproved(
|
||||
{ ...req, command, args },
|
||||
this.options.modeConfig?.approvedTools,
|
||||
);
|
||||
}
|
||||
|
||||
parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined {
|
||||
return parsePosixSandboxDenials(result, this.denialCache);
|
||||
}
|
||||
|
||||
private writeArgsToTempFile(args: string[]): string {
|
||||
const tempFile = join(
|
||||
os.tmpdir(),
|
||||
@@ -325,4 +245,26 @@ export class LinuxSandboxManager implements SandboxManager {
|
||||
fs.writeFileSync(tempFile, content, { mode: 0o600 });
|
||||
return tempFile;
|
||||
}
|
||||
|
||||
private getMaskFilePath(): string {
|
||||
if (this.maskFilePath && fs.existsSync(this.maskFilePath)) {
|
||||
return this.maskFilePath;
|
||||
}
|
||||
const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'gemini-cli-mask-file-'));
|
||||
const maskPath = join(tempDir, 'mask');
|
||||
fs.writeFileSync(maskPath, '');
|
||||
fs.chmodSync(maskPath, 0);
|
||||
this.maskFilePath = maskPath;
|
||||
|
||||
// Cleanup on exit
|
||||
process.on('exit', () => {
|
||||
try {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
});
|
||||
|
||||
return maskPath;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { buildBwrapArgs, type BwrapArgsOptions } from './bwrapArgsBuilder.js';
|
||||
import fs from 'node:fs';
|
||||
import * as shellUtils from '../../utils/shell-utils.js';
|
||||
import os from 'node:os';
|
||||
import { type ResolvedSandboxPaths } from '../../services/sandboxManager.js';
|
||||
import type { ResolvedSandboxPaths } from '../utils/sandboxPathUtils.js';
|
||||
|
||||
vi.mock('node:fs', async () => {
|
||||
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
|
||||
|
||||
@@ -6,11 +6,8 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import {
|
||||
GOVERNANCE_FILES,
|
||||
getSecretFileFindArgs,
|
||||
type ResolvedSandboxPaths,
|
||||
} from '../../services/sandboxManager.js';
|
||||
import type { ResolvedSandboxPaths } from '../utils/sandboxPathUtils.js';
|
||||
import { GOVERNANCE_FILES, SECRET_FILES } from '../constants.js';
|
||||
import { isErrnoException } from '../utils/fsUtils.js';
|
||||
import { spawnAsync } from '../../utils/shell-utils.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
@@ -215,3 +212,16 @@ async function getSecretFilesArgs(
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns arguments for the Linux 'find' command to locate secret files.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -87,37 +87,6 @@ describe('MacOsSandboxManager', () => {
|
||||
expect(fs.existsSync(tempFile)).toBe(false);
|
||||
});
|
||||
|
||||
it('should correctly pass through the cwd to the resulting command', async () => {
|
||||
const result = await manager.prepareCommand({
|
||||
command: 'echo',
|
||||
args: ['hello'],
|
||||
cwd: '/test/different/cwd',
|
||||
env: {},
|
||||
policy: mockPolicy,
|
||||
});
|
||||
|
||||
expect(result.cwd).toBe('/test/different/cwd');
|
||||
});
|
||||
|
||||
it('should apply environment sanitization via the default mechanisms', async () => {
|
||||
const result = await manager.prepareCommand({
|
||||
command: 'echo',
|
||||
args: ['hello'],
|
||||
cwd: mockWorkspace,
|
||||
env: {
|
||||
SAFE_VAR: '1',
|
||||
GITHUB_TOKEN: 'sensitive',
|
||||
},
|
||||
policy: {
|
||||
...mockPolicy,
|
||||
sanitizationConfig: { enableEnvironmentVariableRedaction: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.env['SAFE_VAR']).toBe('1');
|
||||
expect(result.env['GITHUB_TOKEN']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow network when networkAccess is true', async () => {
|
||||
await manager.prepareCommand({
|
||||
command: 'echo',
|
||||
|
||||
@@ -8,150 +8,95 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
type SandboxManager,
|
||||
type SandboxRequest,
|
||||
type SandboxedCommand,
|
||||
type SandboxPermissions,
|
||||
type GlobalSandboxOptions,
|
||||
type ParsedSandboxDenial,
|
||||
resolveSandboxPaths,
|
||||
} from '../../services/sandboxManager.js';
|
||||
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
|
||||
import {
|
||||
sanitizeEnvironment,
|
||||
getSecureSanitizationConfig,
|
||||
} from '../../services/environmentSanitization.js';
|
||||
import { buildSeatbeltProfile } from './seatbeltArgsBuilder.js';
|
||||
import { isStrictlyApproved } from '../utils/commandUtils.js';
|
||||
import { initializeShellParsers } from '../../utils/shell-utils.js';
|
||||
import {
|
||||
isKnownSafeCommand,
|
||||
isDangerousCommand,
|
||||
isKnownSafeCommand as isPosixSafeCommand,
|
||||
isDangerousCommand as isPosixDangerousCommand,
|
||||
} from '../utils/commandSafety.js';
|
||||
import {
|
||||
verifySandboxOverrides,
|
||||
getCommandName as getFullCommandName,
|
||||
isStrictlyApproved,
|
||||
} from '../utils/commandUtils.js';
|
||||
import {
|
||||
parsePosixSandboxDenials,
|
||||
createSandboxDenialCache,
|
||||
type SandboxDenialCache,
|
||||
} from '../utils/sandboxDenialUtils.js';
|
||||
import { parsePosixSandboxDenials } from '../utils/sandboxDenialUtils.js';
|
||||
import { handleReadWriteCommands } from '../utils/sandboxReadWriteUtils.js';
|
||||
import {
|
||||
AbstractOsSandboxManager,
|
||||
type PreparedExecutionDetails,
|
||||
} from '../abstractOsSandboxManager.js';
|
||||
|
||||
export class MacOsSandboxManager implements SandboxManager {
|
||||
private readonly denialCache: SandboxDenialCache = createSandboxDenialCache();
|
||||
|
||||
constructor(private readonly options: GlobalSandboxOptions) {}
|
||||
|
||||
isKnownSafeCommand(args: string[]): boolean {
|
||||
const toolName = args[0];
|
||||
const approvedTools = this.options.modeConfig?.approvedTools ?? [];
|
||||
if (toolName && approvedTools.includes(toolName)) {
|
||||
return true;
|
||||
}
|
||||
return isKnownSafeCommand(args);
|
||||
}
|
||||
|
||||
isDangerousCommand(args: string[]): boolean {
|
||||
return isDangerousCommand(args);
|
||||
}
|
||||
|
||||
parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined {
|
||||
return parsePosixSandboxDenials(result, this.denialCache);
|
||||
}
|
||||
|
||||
getWorkspace(): string {
|
||||
return this.options.workspace;
|
||||
}
|
||||
|
||||
getOptions(): GlobalSandboxOptions {
|
||||
return this.options;
|
||||
}
|
||||
|
||||
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
|
||||
export class MacOsSandboxManager extends AbstractOsSandboxManager {
|
||||
protected override async initialize(): Promise<void> {
|
||||
await initializeShellParsers();
|
||||
const sanitizationConfig = getSecureSanitizationConfig(
|
||||
req.policy?.sanitizationConfig,
|
||||
);
|
||||
}
|
||||
|
||||
const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig);
|
||||
protected override ensureGovernanceFilesExist(_workspace: string): void {
|
||||
// Default no-op - Seatbelt is able to block non-existent files
|
||||
}
|
||||
|
||||
const isReadonlyMode = this.options.modeConfig?.readonly ?? true;
|
||||
const allowOverrides = this.options.modeConfig?.allowOverrides ?? true;
|
||||
|
||||
// Reject override attempts in plan mode
|
||||
verifySandboxOverrides(allowOverrides, req.policy);
|
||||
|
||||
let command = req.command;
|
||||
let args = req.args;
|
||||
|
||||
// Translate virtual commands for sandboxed file system access
|
||||
/**
|
||||
* Mapping virtual commands to absolute paths of native tools ensures that
|
||||
* Seatbelt profiles can target the exact binaries accurately.
|
||||
*/
|
||||
protected override mapVirtualCommandToNative(
|
||||
command: string,
|
||||
args: string[],
|
||||
): { command: string; args: string[] } {
|
||||
if (command === '__read') {
|
||||
command = '/bin/cat';
|
||||
} else if (command === '__write') {
|
||||
command = '/bin/sh';
|
||||
args = ['-c', 'cat > "$1"', '_', ...args];
|
||||
return { command: '/bin/cat', args };
|
||||
}
|
||||
if (command === '__write') {
|
||||
return { command: '/bin/sh', args: ['-c', 'cat > "$1"', '_', ...args] };
|
||||
}
|
||||
return { command, args };
|
||||
}
|
||||
|
||||
const currentReq = { ...req, command, args };
|
||||
/**
|
||||
* macOS permissions are enforced via dynamically generated Seatbelt profiles
|
||||
* based on file paths. No dynamic tweaking of the request object is needed here.
|
||||
*/
|
||||
protected override updateReadWritePermissions(
|
||||
_permissions: SandboxPermissions,
|
||||
_req: SandboxRequest,
|
||||
): void {
|
||||
// Default no-op
|
||||
}
|
||||
|
||||
// If not in readonly mode OR it's a strictly approved pipeline, allow workspace writes
|
||||
const isApproved = allowOverrides
|
||||
? await isStrictlyApproved(
|
||||
currentReq,
|
||||
this.options.modeConfig?.approvedTools,
|
||||
)
|
||||
: false;
|
||||
/**
|
||||
* Ensures read/write commands strictly respect allowed paths and workspace boundaries
|
||||
* before the Seatbelt profile is generated.
|
||||
*/
|
||||
protected override rewriteReadWriteCommand(
|
||||
req: SandboxRequest,
|
||||
command: string,
|
||||
args: string[],
|
||||
permissions: SandboxPermissions,
|
||||
): { command: string; args: string[] } {
|
||||
return handleReadWriteCommands(req, permissions, this.options.workspace, [
|
||||
...(req.policy?.allowedPaths || []),
|
||||
...(this.options.includeDirectories || []),
|
||||
]);
|
||||
}
|
||||
|
||||
const isYolo = this.options.modeConfig?.yolo ?? false;
|
||||
const workspaceWrite = !isReadonlyMode || isApproved || isYolo;
|
||||
const defaultNetwork =
|
||||
this.options.modeConfig?.network || req.policy?.networkAccess || isYolo;
|
||||
|
||||
// Fetch persistent approvals for this command
|
||||
const commandName = await getFullCommandName(currentReq);
|
||||
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:
|
||||
defaultNetwork ||
|
||||
persistentPermissions?.network ||
|
||||
req.policy?.additionalPermissions?.network ||
|
||||
false,
|
||||
};
|
||||
|
||||
const { command: finalCommand, args: finalArgs } = handleReadWriteCommands(
|
||||
protected async buildSandboxedCommand(
|
||||
details: PreparedExecutionDetails,
|
||||
): Promise<SandboxedCommand> {
|
||||
const {
|
||||
finalCommand,
|
||||
finalArgs,
|
||||
sanitizedEnv,
|
||||
resolvedPaths,
|
||||
workspaceWrite,
|
||||
networkAccess,
|
||||
req,
|
||||
mergedAdditional,
|
||||
this.options.workspace,
|
||||
[
|
||||
...(req.policy?.allowedPaths || []),
|
||||
...(this.options.includeDirectories || []),
|
||||
],
|
||||
);
|
||||
|
||||
const resolvedPaths = await resolveSandboxPaths(
|
||||
this.options,
|
||||
req,
|
||||
mergedAdditional,
|
||||
);
|
||||
} = details;
|
||||
|
||||
const sandboxArgs = buildSeatbeltProfile({
|
||||
resolvedPaths,
|
||||
networkAccess: mergedAdditional.network,
|
||||
networkAccess,
|
||||
workspaceWrite,
|
||||
});
|
||||
|
||||
@@ -172,6 +117,33 @@ export class MacOsSandboxManager implements SandboxManager {
|
||||
};
|
||||
}
|
||||
|
||||
protected override isCaseInsensitive(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
isDangerousCommand(args: string[]): boolean {
|
||||
return isPosixDangerousCommand(args);
|
||||
}
|
||||
|
||||
protected override isOsSafeCommand(args: string[]): boolean {
|
||||
return isPosixSafeCommand(args);
|
||||
}
|
||||
|
||||
protected override async isStrictlyApproved(
|
||||
command: string,
|
||||
args: string[],
|
||||
req: SandboxRequest,
|
||||
): Promise<boolean> {
|
||||
return isStrictlyApproved(
|
||||
{ ...req, command, args },
|
||||
this.options.modeConfig?.approvedTools,
|
||||
);
|
||||
}
|
||||
|
||||
parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined {
|
||||
return parsePosixSandboxDenials(result, this.denialCache);
|
||||
}
|
||||
|
||||
private writeProfileToTempFile(profile: string): string {
|
||||
const tempFile = path.join(
|
||||
os.tmpdir(),
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
buildSeatbeltProfile,
|
||||
escapeSchemeString,
|
||||
} from './seatbeltArgsBuilder.js';
|
||||
import type { ResolvedSandboxPaths } from '../../services/sandboxManager.js';
|
||||
import type { ResolvedSandboxPaths } from '../utils/sandboxPathUtils.js';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
|
||||
|
||||
@@ -11,11 +11,8 @@ import {
|
||||
BASE_SEATBELT_PROFILE,
|
||||
NETWORK_SEATBELT_PROFILE,
|
||||
} from './baseProfile.js';
|
||||
import {
|
||||
GOVERNANCE_FILES,
|
||||
SECRET_FILES,
|
||||
type ResolvedSandboxPaths,
|
||||
} from '../../services/sandboxManager.js';
|
||||
import type { ResolvedSandboxPaths } from '../utils/sandboxPathUtils.js';
|
||||
import { GOVERNANCE_FILES, SECRET_FILES } from '../constants.js';
|
||||
import { resolveToRealPath } from '../../utils/paths.js';
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type SandboxRequest } from '../../services/sandboxManager.js';
|
||||
import type { SandboxRequest } from '../../services/sandboxManager.js';
|
||||
import {
|
||||
getCommandRoots,
|
||||
initializeShellParsers,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { GOVERNANCE_FILES } from '../constants.js';
|
||||
import { assertValidPathString } from '../../utils/paths.js';
|
||||
import { isErrnoException } from './fsUtils.js';
|
||||
|
||||
/**
|
||||
* Ensures that governance files exist in the sandbox workspace.
|
||||
*/
|
||||
export function ensureGovernanceFilesExist(workspace: string): void {
|
||||
for (const file of GOVERNANCE_FILES) {
|
||||
const filePath = join(workspace, file.path);
|
||||
touch(filePath, file.isDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a file or directory if it doesn't exist.
|
||||
*/
|
||||
export function touch(filePath: string, isDirectory: boolean): void {
|
||||
assertValidPathString(filePath);
|
||||
try {
|
||||
fs.lstatSync(filePath);
|
||||
return;
|
||||
} catch (e: unknown) {
|
||||
if (isErrnoException(e) && e.code !== 'ENOENT') {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
if (isDirectory) {
|
||||
fs.mkdirSync(filePath, { recursive: true });
|
||||
} else {
|
||||
fs.mkdirSync(dirname(filePath), { recursive: true });
|
||||
fs.closeSync(fs.openSync(filePath, 'a'));
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { LRUCache } from 'mnemonist';
|
||||
import { type ParsedSandboxDenial } from '../../services/sandboxManager.js';
|
||||
import type { ParsedSandboxDenial } from '../../services/sandboxManager.js';
|
||||
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
|
||||
import { isValidPathString } from '../../utils/paths.js';
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { resolveSandboxPaths } from './sandboxPathUtils.js';
|
||||
import type { SandboxRequest } from '../../services/sandboxManager.js';
|
||||
import path from 'node:path';
|
||||
|
||||
describe('resolveSandboxPaths', () => {
|
||||
it('should resolve allowed and forbidden paths', async () => {
|
||||
const workspace = path.resolve('/workspace');
|
||||
const forbidden = path.join(workspace, 'forbidden');
|
||||
const allowed = path.join(workspace, 'allowed');
|
||||
const options = {
|
||||
workspace,
|
||||
forbiddenPaths: async () => [forbidden],
|
||||
};
|
||||
const req = {
|
||||
command: 'ls',
|
||||
args: [],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
policy: {
|
||||
allowedPaths: [allowed],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await resolveSandboxPaths(options, req as SandboxRequest);
|
||||
|
||||
expect(result.policyAllowed).toEqual([allowed]);
|
||||
expect(result.forbidden).toEqual([forbidden]);
|
||||
});
|
||||
|
||||
it('should filter out workspace from allowed paths', async () => {
|
||||
const workspace = path.resolve('/workspace');
|
||||
const other = path.resolve('/other/path');
|
||||
const options = {
|
||||
workspace,
|
||||
};
|
||||
const req = {
|
||||
command: 'ls',
|
||||
args: [],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
policy: {
|
||||
allowedPaths: [workspace, workspace + path.sep, other],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await resolveSandboxPaths(options, req as SandboxRequest);
|
||||
|
||||
expect(result.policyAllowed).toEqual([other]);
|
||||
});
|
||||
|
||||
it('should prioritize forbidden paths over allowed paths', async () => {
|
||||
const workspace = path.resolve('/workspace');
|
||||
const secret = path.join(workspace, 'secret');
|
||||
const normal = path.join(workspace, 'normal');
|
||||
const options = {
|
||||
workspace,
|
||||
forbiddenPaths: async () => [secret],
|
||||
};
|
||||
const req = {
|
||||
command: 'ls',
|
||||
args: [],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
policy: {
|
||||
allowedPaths: [secret, normal],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await resolveSandboxPaths(options, req as SandboxRequest);
|
||||
|
||||
expect(result.policyAllowed).toEqual([normal]);
|
||||
expect(result.forbidden).toEqual([secret]);
|
||||
});
|
||||
|
||||
it('should handle case-insensitive conflicts on supported platforms', async () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin');
|
||||
const workspace = path.resolve('/workspace');
|
||||
const secretUpper = path.join(workspace, 'SECRET');
|
||||
const secretLower = path.join(workspace, 'secret');
|
||||
const options = {
|
||||
workspace,
|
||||
forbiddenPaths: async () => [secretUpper],
|
||||
};
|
||||
const req = {
|
||||
command: 'ls',
|
||||
args: [],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
policy: {
|
||||
allowedPaths: [secretLower],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await resolveSandboxPaths(options, req as SandboxRequest);
|
||||
|
||||
expect(result.policyAllowed).toEqual([]);
|
||||
expect(result.forbidden).toEqual([secretUpper]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
GlobalSandboxOptions,
|
||||
SandboxRequest,
|
||||
SandboxPermissions,
|
||||
} from '../../services/sandboxManager.js';
|
||||
import { resolveGitWorktreePaths } from './fsUtils.js';
|
||||
import {
|
||||
toPathKey,
|
||||
deduplicateAbsolutePaths,
|
||||
resolveToRealPath,
|
||||
} from '../../utils/paths.js';
|
||||
|
||||
/**
|
||||
* A structured result of fully resolved sandbox paths.
|
||||
* All paths in this object are absolute, deduplicated, and expanded to include
|
||||
* both the original path and its real target (if it is a symlink).
|
||||
*/
|
||||
export interface ResolvedSandboxPaths {
|
||||
/** The primary workspace directory. */
|
||||
workspace: {
|
||||
/** The original path provided in the sandbox options. */
|
||||
original: string;
|
||||
/** The real path. */
|
||||
resolved: string;
|
||||
};
|
||||
/** Explicitly denied paths. */
|
||||
forbidden: string[];
|
||||
/** Directories included globally across all commands in this sandbox session. */
|
||||
globalIncludes: string[];
|
||||
/** Paths explicitly allowed by the policy of the currently executing command. */
|
||||
policyAllowed: string[];
|
||||
/** Paths granted temporary read access by the current command's dynamic permissions. */
|
||||
policyRead: string[];
|
||||
/** Paths granted temporary write access by the current command's dynamic permissions. */
|
||||
policyWrite: string[];
|
||||
/** Auto-detected paths for git worktrees/submodules. */
|
||||
gitWorktree?: {
|
||||
/** The actual .git directory for this worktree. */
|
||||
worktreeGitDir: string;
|
||||
/** The main repository's .git directory (if applicable). */
|
||||
mainGitDir?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves and sanitizes all path categories for a sandbox request.
|
||||
*/
|
||||
export async function resolveSandboxPaths(
|
||||
options: GlobalSandboxOptions,
|
||||
req: SandboxRequest,
|
||||
overridePermissions?: SandboxPermissions,
|
||||
): Promise<ResolvedSandboxPaths> {
|
||||
/**
|
||||
* Helper that expands each path to include its realpath (if it's a symlink)
|
||||
* and pipes the result through deduplicateAbsolutePaths for deduplication and absolute path enforcement.
|
||||
*/
|
||||
const expand = (paths?: string[] | null): string[] => {
|
||||
if (!paths || paths.length === 0) return [];
|
||||
const expanded = paths.flatMap((p) => {
|
||||
try {
|
||||
const resolved = resolveToRealPath(p);
|
||||
return resolved === p ? [p] : [p, resolved];
|
||||
} catch {
|
||||
return [p];
|
||||
}
|
||||
});
|
||||
return deduplicateAbsolutePaths(expanded);
|
||||
};
|
||||
|
||||
const forbidden = expand(await options.forbiddenPaths?.());
|
||||
|
||||
const globalIncludes = expand(options.includeDirectories);
|
||||
const policyAllowed = expand(req.policy?.allowedPaths);
|
||||
|
||||
const policyRead = expand(overridePermissions?.fileSystem?.read);
|
||||
const policyWrite = expand(overridePermissions?.fileSystem?.write);
|
||||
|
||||
const resolvedWorkspace = resolveToRealPath(options.workspace);
|
||||
|
||||
const workspaceIdentities = new Set(
|
||||
[options.workspace, resolvedWorkspace].map(toPathKey),
|
||||
);
|
||||
const forbiddenIdentities = new Set(forbidden.map(toPathKey));
|
||||
|
||||
const { worktreeGitDir, mainGitDir } =
|
||||
await resolveGitWorktreePaths(resolvedWorkspace);
|
||||
const gitWorktree = worktreeGitDir
|
||||
? { gitWorktree: { worktreeGitDir, mainGitDir } }
|
||||
: undefined;
|
||||
|
||||
/**
|
||||
* Filters out any paths that are explicitly forbidden or match the workspace root (original or resolved).
|
||||
*/
|
||||
const filter = (paths: string[]) =>
|
||||
paths.filter((p) => {
|
||||
const identity = toPathKey(p);
|
||||
return (
|
||||
!workspaceIdentities.has(identity) && !forbiddenIdentities.has(identity)
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
workspace: {
|
||||
original: options.workspace,
|
||||
resolved: resolvedWorkspace,
|
||||
},
|
||||
forbidden,
|
||||
globalIncludes: filter(globalIncludes),
|
||||
policyAllowed: filter(policyAllowed),
|
||||
policyRead: filter(policyRead),
|
||||
policyWrite: filter(policyWrite),
|
||||
...gitWorktree,
|
||||
};
|
||||
}
|
||||
@@ -8,11 +8,18 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { WindowsSandboxManager } from './WindowsSandboxManager.js';
|
||||
import * as sandboxManager from '../../services/sandboxManager.js';
|
||||
import * as paths from '../../utils/paths.js';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
|
||||
import * as sandboxPathUtils from '../utils/sandboxPathUtils.js';
|
||||
import {
|
||||
WindowsSandboxManager,
|
||||
findSecretFiles,
|
||||
isSecretFile,
|
||||
} from './WindowsSandboxManager.js';
|
||||
import type { SandboxRequest } from '../../services/sandboxManager.js';
|
||||
import type { SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js';
|
||||
import * as paths from '../../utils/paths.js';
|
||||
|
||||
vi.mock('node:fs/promises');
|
||||
|
||||
vi.mock('../../utils/shell-utils.js', async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -21,7 +28,35 @@ vi.mock('../../utils/shell-utils.js', async (importOriginal) => {
|
||||
...actual,
|
||||
spawnAsync: vi.fn(),
|
||||
initializeShellParsers: vi.fn(),
|
||||
isStrictlyApproved: vi.fn().mockResolvedValue(true),
|
||||
getCommandName: vi
|
||||
.fn()
|
||||
.mockImplementation(async (command: string) => path.basename(command)),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./commandSafety.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./commandSafety.js')>();
|
||||
return {
|
||||
...actual,
|
||||
isStrictlyApproved: vi
|
||||
.fn()
|
||||
.mockImplementation(async (command, _args, approvedTools) => {
|
||||
const tools = approvedTools ?? [];
|
||||
return tools.includes(command);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../utils/commandUtils.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../utils/commandUtils.js')>();
|
||||
return {
|
||||
...actual,
|
||||
getCommandName: vi
|
||||
.fn()
|
||||
.mockImplementation(async (req: { command: string }) =>
|
||||
path.basename(req.command),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -171,81 +206,6 @@ describe('WindowsSandboxManager', () => {
|
||||
expect(result.args[0]).toBe('1');
|
||||
});
|
||||
|
||||
it('should reject network access in Plan mode', async () => {
|
||||
const planManager = new WindowsSandboxManager({
|
||||
workspace: testCwd,
|
||||
modeConfig: { readonly: true, allowOverrides: false },
|
||||
forbiddenPaths: async () => [],
|
||||
});
|
||||
const req: SandboxRequest = {
|
||||
command: 'curl',
|
||||
args: ['google.com'],
|
||||
cwd: testCwd,
|
||||
env: {},
|
||||
policy: {
|
||||
additionalPermissions: { network: true },
|
||||
},
|
||||
};
|
||||
|
||||
await expect(planManager.prepareCommand(req)).rejects.toThrow(
|
||||
'Sandbox request rejected: Cannot override readonly/network/filesystem restrictions in Plan mode.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle persistent permissions from policyManager', async () => {
|
||||
const persistentPath = createTempDir('persistent', testCwd);
|
||||
|
||||
const mockPolicyManager = {
|
||||
getCommandPermissions: vi.fn().mockReturnValue({
|
||||
fileSystem: { write: [persistentPath] },
|
||||
network: true,
|
||||
}),
|
||||
} as unknown as SandboxPolicyManager;
|
||||
|
||||
const managerWithPolicy = new WindowsSandboxManager({
|
||||
workspace: testCwd,
|
||||
modeConfig: { allowOverrides: true, network: false },
|
||||
policyManager: mockPolicyManager,
|
||||
forbiddenPaths: async () => [],
|
||||
});
|
||||
|
||||
const req: SandboxRequest = {
|
||||
command: 'test-cmd',
|
||||
args: [],
|
||||
cwd: testCwd,
|
||||
env: {},
|
||||
};
|
||||
|
||||
const result = await managerWithPolicy.prepareCommand(req);
|
||||
expect(result.args[0]).toBe('1'); // Network allowed by persistent policy
|
||||
|
||||
const { allowed } = getManifestPaths(result.args);
|
||||
expect(allowed).toContain(persistentPath);
|
||||
});
|
||||
|
||||
it('should sanitize environment variables', async () => {
|
||||
const req: SandboxRequest = {
|
||||
command: 'test',
|
||||
args: [],
|
||||
cwd: testCwd,
|
||||
env: {
|
||||
API_KEY: 'secret',
|
||||
PATH: '/usr/bin',
|
||||
},
|
||||
policy: {
|
||||
sanitizationConfig: {
|
||||
allowedEnvironmentVariables: ['PATH'],
|
||||
blockedEnvironmentVariables: ['API_KEY'],
|
||||
enableEnvironmentVariableRedaction: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await manager.prepareCommand(req);
|
||||
expect(result.env['PATH']).toBe('/usr/bin');
|
||||
expect(result.env['API_KEY']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should ensure governance files exist', async () => {
|
||||
const req: SandboxRequest = {
|
||||
command: 'test',
|
||||
@@ -290,7 +250,7 @@ describe('WindowsSandboxManager', () => {
|
||||
const mainGitDir = createTempDir('main-git');
|
||||
|
||||
try {
|
||||
vi.spyOn(sandboxManager, 'resolveSandboxPaths').mockResolvedValue({
|
||||
vi.spyOn(sandboxPathUtils, 'resolveSandboxPaths').mockResolvedValue({
|
||||
workspace: { original: testCwd, resolved: testCwd },
|
||||
forbidden: [],
|
||||
globalIncludes: [],
|
||||
@@ -557,3 +517,81 @@ describe('WindowsSandboxManager', () => {
|
||||
expect(fs.existsSync(path.dirname(forbiddenManifestPath))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSecretFiles', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should find secret files in the root directory', async () => {
|
||||
const workspace = path.resolve('/workspace');
|
||||
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 () => {
|
||||
const workspace = path.resolve('/workspace');
|
||||
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('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', () => {
|
||||
expect(isSecretFile('.env-backup')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,44 +5,33 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path, { join } from 'node:path';
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
type SandboxManager,
|
||||
type SandboxRequest,
|
||||
type SandboxedCommand,
|
||||
GOVERNANCE_FILES,
|
||||
findSecretFiles,
|
||||
type GlobalSandboxOptions,
|
||||
type SandboxPermissions,
|
||||
type ParsedSandboxDenial,
|
||||
resolveSandboxPaths,
|
||||
import type {
|
||||
SandboxRequest,
|
||||
SandboxedCommand,
|
||||
SandboxPermissions,
|
||||
ParsedSandboxDenial,
|
||||
GlobalSandboxOptions,
|
||||
} from '../../services/sandboxManager.js';
|
||||
import { SECRET_FILES } from '../constants.js';
|
||||
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
|
||||
import {
|
||||
sanitizeEnvironment,
|
||||
getSecureSanitizationConfig,
|
||||
} from '../../services/environmentSanitization.js';
|
||||
import { ensureGovernanceFilesExist } from '../utils/governanceUtils.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
import { spawnAsync, getCommandName } from '../../utils/shell-utils.js';
|
||||
import { spawnAsync } from '../../utils/shell-utils.js';
|
||||
import {
|
||||
isKnownSafeCommand,
|
||||
isDangerousCommand,
|
||||
isStrictlyApproved,
|
||||
isKnownSafeCommand as isWindowsSafeCommand,
|
||||
isDangerousCommand as isWindowsDangerousCommand,
|
||||
isStrictlyApproved as isWindowsStrictlyApproved,
|
||||
} from './commandSafety.js';
|
||||
import { verifySandboxOverrides } from '../utils/commandUtils.js';
|
||||
import { parseWindowsSandboxDenials } from './windowsSandboxDenialUtils.js';
|
||||
import { isErrnoException } from '../utils/fsUtils.js';
|
||||
import { isSubpath, resolveToRealPath } from '../../utils/paths.js';
|
||||
import {
|
||||
isSubpath,
|
||||
resolveToRealPath,
|
||||
assertValidPathString,
|
||||
} from '../../utils/paths.js';
|
||||
import {
|
||||
type SandboxDenialCache,
|
||||
createSandboxDenialCache,
|
||||
} from '../utils/sandboxDenialUtils.js';
|
||||
AbstractOsSandboxManager,
|
||||
type PreparedExecutionDetails,
|
||||
} from '../abstractOsSandboxManager.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -52,54 +41,259 @@ const __dirname = path.dirname(__filename);
|
||||
* Job Objects, and Low Integrity levels for process isolation.
|
||||
* Uses a native C# helper to bypass PowerShell restrictions.
|
||||
*/
|
||||
export class WindowsSandboxManager implements SandboxManager {
|
||||
export class WindowsSandboxManager extends AbstractOsSandboxManager {
|
||||
static readonly HELPER_EXE = 'GeminiSandbox.exe';
|
||||
private static helperCompiled = false;
|
||||
|
||||
private readonly helperPath: string;
|
||||
private readonly denialCache: SandboxDenialCache = createSandboxDenialCache();
|
||||
|
||||
private static helperCompiled = false;
|
||||
private governanceFilesInitialized = false;
|
||||
|
||||
constructor(private readonly options: GlobalSandboxOptions) {
|
||||
constructor(options: GlobalSandboxOptions) {
|
||||
super(options);
|
||||
this.helperPath = path.resolve(__dirname, WindowsSandboxManager.HELPER_EXE);
|
||||
}
|
||||
|
||||
isKnownSafeCommand(args: string[]): boolean {
|
||||
const toolName = args[0]?.toLowerCase();
|
||||
const approvedTools = this.options.modeConfig?.approvedTools ?? [];
|
||||
if (toolName && approvedTools.some((t) => t.toLowerCase() === toolName)) {
|
||||
return true;
|
||||
protected override async initialize(): Promise<void> {
|
||||
await this.ensureHelperCompiled();
|
||||
}
|
||||
|
||||
protected override ensureGovernanceFilesExist(workspace: string): void {
|
||||
if (this.governanceFilesInitialized) return;
|
||||
ensureGovernanceFilesExist(workspace);
|
||||
this.governanceFilesInitialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows supports virtual commands directly via the native helper execution.
|
||||
* No translation is required.
|
||||
*/
|
||||
protected override mapVirtualCommandToNative(
|
||||
command: string,
|
||||
args: string[],
|
||||
): { command: string; args: string[] } {
|
||||
return { command, args };
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows requires explicit tracking of requested read/write file targets
|
||||
* to add them dynamically to the allowed and inheritance roots manifest.
|
||||
*/
|
||||
protected override updateReadWritePermissions(
|
||||
permissions: SandboxPermissions,
|
||||
req: SandboxRequest,
|
||||
): void {
|
||||
if (req.command === '__read' && req.args[0]) {
|
||||
permissions.fileSystem?.read?.push(req.args[0]);
|
||||
} else if (req.command === '__write' && req.args[0]) {
|
||||
permissions.fileSystem?.write?.push(req.args[0]);
|
||||
}
|
||||
return isKnownSafeCommand(args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows does not require command string rewriting as strict file system
|
||||
* isolation is enforced independently at the native OS process level.
|
||||
*/
|
||||
protected override rewriteReadWriteCommand(
|
||||
_req: SandboxRequest,
|
||||
command: string,
|
||||
args: string[],
|
||||
_permissions: SandboxPermissions,
|
||||
): { command: string; args: string[] } {
|
||||
return { command, args };
|
||||
}
|
||||
|
||||
protected async buildSandboxedCommand(
|
||||
details: PreparedExecutionDetails,
|
||||
): Promise<SandboxedCommand> {
|
||||
const {
|
||||
finalCommand,
|
||||
finalArgs,
|
||||
sanitizedEnv,
|
||||
resolvedPaths,
|
||||
workspaceWrite,
|
||||
networkAccess,
|
||||
req,
|
||||
} = details;
|
||||
|
||||
// Collect all forbidden paths.
|
||||
const forbiddenManifest = new Set(
|
||||
resolvedPaths.forbidden.map((p) => resolveToRealPath(p)),
|
||||
);
|
||||
|
||||
const searchDirs = new Set([
|
||||
resolvedPaths.workspace.resolved,
|
||||
...resolvedPaths.policyAllowed,
|
||||
...resolvedPaths.globalIncludes,
|
||||
]);
|
||||
|
||||
const secretFilesPromises = Array.from(searchDirs).map(async (dir) => {
|
||||
try {
|
||||
const secretFiles = await findSecretFiles(dir, 3);
|
||||
for (const secretFile of secretFiles) {
|
||||
forbiddenManifest.add(resolveToRealPath(secretFile));
|
||||
}
|
||||
} catch (e) {
|
||||
debugLogger.log(
|
||||
`WindowsSandboxManager: Failed to find secret files in ${dir}`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(secretFilesPromises);
|
||||
|
||||
// Track paths that will be granted write access.
|
||||
const allowedManifest = new Set<string>();
|
||||
const inheritanceRoots = new Set<string>();
|
||||
|
||||
const addWritableRoot = (p: string) => {
|
||||
const resolved = resolveToRealPath(p);
|
||||
inheritanceRoots.add(p);
|
||||
inheritanceRoots.add(resolved);
|
||||
|
||||
if (this.isSystemDirectory(resolved)) return;
|
||||
if (forbiddenManifest.has(resolved)) return;
|
||||
|
||||
if (
|
||||
resolved.startsWith('\\\\') &&
|
||||
!resolved.startsWith('\\\\?\\') &&
|
||||
!resolved.startsWith('\\\\.\\')
|
||||
) {
|
||||
debugLogger.log(
|
||||
'WindowsSandboxManager: Rejecting UNC path for allowed manifest:',
|
||||
resolved,
|
||||
);
|
||||
return;
|
||||
}
|
||||
allowedManifest.add(resolved);
|
||||
};
|
||||
|
||||
// Populate writable roots.
|
||||
if (workspaceWrite) {
|
||||
addWritableRoot(resolvedPaths.workspace.resolved);
|
||||
}
|
||||
|
||||
for (const includeDir of resolvedPaths.globalIncludes) {
|
||||
addWritableRoot(includeDir);
|
||||
}
|
||||
|
||||
for (const allowedPath of resolvedPaths.policyAllowed) {
|
||||
try {
|
||||
await fs.promises.access(allowedPath, fs.constants.F_OK);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Sandbox request rejected: Allowed path does not exist: ${allowedPath}. ` +
|
||||
'On Windows, granular sandbox access can only be granted to existing paths to avoid broad parent directory permissions.',
|
||||
);
|
||||
}
|
||||
addWritableRoot(allowedPath);
|
||||
}
|
||||
|
||||
for (const writePath of resolvedPaths.policyWrite) {
|
||||
try {
|
||||
await fs.promises.access(writePath, fs.constants.F_OK);
|
||||
addWritableRoot(writePath);
|
||||
continue;
|
||||
} catch {
|
||||
const isInherited = Array.from(inheritanceRoots).some((root) =>
|
||||
isSubpath(root, writePath),
|
||||
);
|
||||
|
||||
if (!isInherited) {
|
||||
throw new Error(
|
||||
`Sandbox request rejected: Additional write path does not exist and its parent directory is not allowed: ${writePath}. ` +
|
||||
'On Windows, granular sandbox access can only be granted to existing paths to avoid broad parent directory permissions.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate manifests
|
||||
const tempDir = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'gemini-cli-sandbox-'),
|
||||
);
|
||||
|
||||
const forbiddenManifestPath = path.join(tempDir, 'forbidden.txt');
|
||||
await fs.promises.writeFile(
|
||||
forbiddenManifestPath,
|
||||
Array.from(forbiddenManifest).join('\n'),
|
||||
);
|
||||
|
||||
const allowedManifestPath = path.join(tempDir, 'allowed.txt');
|
||||
await fs.promises.writeFile(
|
||||
allowedManifestPath,
|
||||
Array.from(allowedManifest).join('\n'),
|
||||
);
|
||||
|
||||
// Construct the helper command
|
||||
const program = this.helperPath;
|
||||
|
||||
const finalHelperArgs = [
|
||||
networkAccess ? '1' : '0',
|
||||
req.cwd,
|
||||
'--forbidden-manifest',
|
||||
forbiddenManifestPath,
|
||||
'--allowed-manifest',
|
||||
allowedManifestPath,
|
||||
finalCommand,
|
||||
...finalArgs,
|
||||
];
|
||||
|
||||
const finalEnv = { ...sanitizedEnv };
|
||||
|
||||
return {
|
||||
program,
|
||||
args: finalHelperArgs,
|
||||
env: finalEnv,
|
||||
cwd: req.cwd,
|
||||
cleanup: () => {
|
||||
try {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
protected override isCaseInsensitive(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
isDangerousCommand(args: string[]): boolean {
|
||||
return isDangerousCommand(args);
|
||||
return isWindowsDangerousCommand(args);
|
||||
}
|
||||
|
||||
protected override isOsSafeCommand(args: string[]): boolean {
|
||||
return isWindowsSafeCommand(args);
|
||||
}
|
||||
|
||||
protected override async isStrictlyApproved(
|
||||
command: string,
|
||||
args: string[],
|
||||
_req: SandboxRequest,
|
||||
): Promise<boolean> {
|
||||
return isWindowsStrictlyApproved(
|
||||
command,
|
||||
args,
|
||||
this.options.modeConfig?.approvedTools,
|
||||
);
|
||||
}
|
||||
|
||||
parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined {
|
||||
return parseWindowsSandboxDenials(result, this.denialCache);
|
||||
}
|
||||
|
||||
getWorkspace(): string {
|
||||
return this.options.workspace;
|
||||
}
|
||||
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)';
|
||||
|
||||
getOptions(): GlobalSandboxOptions {
|
||||
return this.options;
|
||||
}
|
||||
|
||||
private ensureGovernanceFilesExist(workspace: string): void {
|
||||
if (this.governanceFilesInitialized) return;
|
||||
|
||||
// These must exist on the host before running the sandbox to ensure they are protected.
|
||||
for (const file of GOVERNANCE_FILES) {
|
||||
const filePath = join(workspace, file.path);
|
||||
touch(filePath, file.isDirectory);
|
||||
}
|
||||
|
||||
this.governanceFilesInitialized = true;
|
||||
return (
|
||||
resolvedPath.toLowerCase().startsWith(systemRoot.toLowerCase()) ||
|
||||
resolvedPath.toLowerCase().startsWith(programFiles.toLowerCase()) ||
|
||||
resolvedPath.toLowerCase().startsWith(programFilesX86.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
private async ensureHelperCompiled(): Promise<void> {
|
||||
@@ -200,285 +394,68 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
|
||||
WindowsSandboxManager.helperCompiled = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares a command for sandboxed execution on Windows.
|
||||
*/
|
||||
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
|
||||
await this.ensureHelperCompiled();
|
||||
|
||||
const sanitizationConfig = getSecureSanitizationConfig(
|
||||
req.policy?.sanitizationConfig,
|
||||
);
|
||||
|
||||
const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig);
|
||||
|
||||
const isReadonlyMode = this.options.modeConfig?.readonly ?? true;
|
||||
const allowOverrides = this.options.modeConfig?.allowOverrides ?? true;
|
||||
|
||||
// Reject override attempts in plan mode
|
||||
verifySandboxOverrides(allowOverrides, req.policy);
|
||||
|
||||
const command = req.command;
|
||||
const args = req.args;
|
||||
|
||||
// Native commands __read and __write are passed directly to GeminiSandbox.exe
|
||||
|
||||
const isYolo = this.options.modeConfig?.yolo ?? false;
|
||||
|
||||
// Fetch persistent approvals for this command
|
||||
const commandName = await getCommandName(command, args);
|
||||
const persistentPermissions = allowOverrides
|
||||
? this.options.policyManager?.getCommandPermissions(commandName)
|
||||
: undefined;
|
||||
|
||||
// Merge all permissions
|
||||
const mergedAdditional: SandboxPermissions = {
|
||||
fileSystem: {
|
||||
read: [
|
||||
...(persistentPermissions?.fileSystem?.read ?? []),
|
||||
...(req.policy?.additionalPermissions?.fileSystem?.read ?? []),
|
||||
],
|
||||
write: [
|
||||
...(persistentPermissions?.fileSystem?.write ?? []),
|
||||
...(req.policy?.additionalPermissions?.fileSystem?.write ?? []),
|
||||
],
|
||||
},
|
||||
network:
|
||||
isYolo ||
|
||||
persistentPermissions?.network ||
|
||||
req.policy?.additionalPermissions?.network ||
|
||||
false,
|
||||
};
|
||||
|
||||
if (req.command === '__read' && req.args[0]) {
|
||||
mergedAdditional.fileSystem!.read!.push(req.args[0]);
|
||||
} else if (req.command === '__write' && req.args[0]) {
|
||||
mergedAdditional.fileSystem!.write!.push(req.args[0]);
|
||||
}
|
||||
|
||||
const defaultNetwork =
|
||||
this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false;
|
||||
const networkAccess = defaultNetwork || mergedAdditional.network;
|
||||
|
||||
const resolvedPaths = await resolveSandboxPaths(
|
||||
this.options,
|
||||
req,
|
||||
mergedAdditional,
|
||||
);
|
||||
|
||||
this.ensureGovernanceFilesExist(resolvedPaths.workspace.resolved);
|
||||
|
||||
// 1. Collect all forbidden paths.
|
||||
// We start with explicitly forbidden paths from the options and request.
|
||||
const forbiddenManifest = new Set(
|
||||
resolvedPaths.forbidden.map((p) => resolveToRealPath(p)),
|
||||
);
|
||||
|
||||
// On Windows, we explicitly deny access to secret files for Low Integrity processes.
|
||||
// We scan common search directories (workspace, allowed paths) for secrets.
|
||||
const searchDirs = new Set([
|
||||
resolvedPaths.workspace.resolved,
|
||||
...resolvedPaths.policyAllowed,
|
||||
...resolvedPaths.globalIncludes,
|
||||
]);
|
||||
|
||||
const secretFilesPromises = Array.from(searchDirs).map(async (dir) => {
|
||||
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) {
|
||||
forbiddenManifest.add(resolveToRealPath(secretFile));
|
||||
}
|
||||
} catch (e) {
|
||||
debugLogger.log(
|
||||
`WindowsSandboxManager: Failed to find secret files in ${dir}`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(secretFilesPromises);
|
||||
|
||||
// 2. Track paths that will be granted write access.
|
||||
// 'allowedManifest' contains resolved paths for the C# helper to apply ACLs.
|
||||
// 'inheritanceRoots' contains both original and resolved paths for Node.js sub-path validation.
|
||||
const allowedManifest = new Set<string>();
|
||||
const inheritanceRoots = new Set<string>();
|
||||
|
||||
const addWritableRoot = (p: string) => {
|
||||
const resolved = resolveToRealPath(p);
|
||||
|
||||
// Track both versions for inheritance checks to be robust against symlinks.
|
||||
inheritanceRoots.add(p);
|
||||
inheritanceRoots.add(resolved);
|
||||
|
||||
// Never grant access to system directories or explicitly forbidden paths.
|
||||
if (this.isSystemDirectory(resolved)) return;
|
||||
if (forbiddenManifest.has(resolved)) return;
|
||||
|
||||
// Explicitly reject UNC paths to prevent credential theft/SSRF,
|
||||
// but allow local extended-length and device paths.
|
||||
if (
|
||||
resolved.startsWith('\\\\') &&
|
||||
!resolved.startsWith('\\\\?\\') &&
|
||||
!resolved.startsWith('\\\\.\\')
|
||||
) {
|
||||
debugLogger.log(
|
||||
'WindowsSandboxManager: Rejecting UNC path for allowed manifest:',
|
||||
resolved,
|
||||
);
|
||||
return;
|
||||
}
|
||||
allowedManifest.add(resolved);
|
||||
};
|
||||
|
||||
// 3. Populate writable roots from various sources.
|
||||
|
||||
// A. Workspace access
|
||||
const isApproved = allowOverrides
|
||||
? await isStrictlyApproved(
|
||||
command,
|
||||
args,
|
||||
this.options.modeConfig?.approvedTools,
|
||||
)
|
||||
: false;
|
||||
|
||||
const workspaceWrite = !isReadonlyMode || isApproved || isYolo;
|
||||
|
||||
if (workspaceWrite) {
|
||||
addWritableRoot(resolvedPaths.workspace.resolved);
|
||||
}
|
||||
|
||||
// B. Globally included directories
|
||||
for (const includeDir of resolvedPaths.globalIncludes) {
|
||||
addWritableRoot(includeDir);
|
||||
}
|
||||
|
||||
// C. Explicitly allowed paths from the request policy
|
||||
for (const allowedPath of resolvedPaths.policyAllowed) {
|
||||
try {
|
||||
await fs.promises.access(allowedPath, fs.constants.F_OK);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Sandbox request rejected: Allowed path does not exist: ${allowedPath}. ` +
|
||||
'On Windows, granular sandbox access can only be granted to existing paths to avoid broad parent directory permissions.',
|
||||
);
|
||||
}
|
||||
addWritableRoot(allowedPath);
|
||||
}
|
||||
|
||||
// D. Additional write paths (e.g. from internal __write command)
|
||||
for (const writePath of resolvedPaths.policyWrite) {
|
||||
try {
|
||||
await fs.promises.access(writePath, fs.constants.F_OK);
|
||||
addWritableRoot(writePath);
|
||||
continue;
|
||||
} catch {
|
||||
// If the file doesn't exist, it's only allowed if it resides within a granted root.
|
||||
const isInherited = Array.from(inheritanceRoots).some((root) =>
|
||||
isSubpath(root, writePath),
|
||||
);
|
||||
|
||||
if (!isInherited) {
|
||||
throw new Error(
|
||||
`Sandbox request rejected: Additional write path does not exist and its parent directory is not allowed: ${writePath}. ` +
|
||||
'On Windows, granular sandbox access can only be granted to existing paths to avoid broad parent directory permissions.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Support git worktrees/submodules; read-only to prevent malicious hook/config modification (RCE).
|
||||
// Read access is inherited; skip addWritableRoot to ensure write protection.
|
||||
if (resolvedPaths.gitWorktree) {
|
||||
// No-op for read access on Windows.
|
||||
}
|
||||
|
||||
// 5. Generate Manifests
|
||||
const tempDir = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'gemini-cli-sandbox-'),
|
||||
);
|
||||
|
||||
const forbiddenManifestPath = path.join(tempDir, 'forbidden.txt');
|
||||
await fs.promises.writeFile(
|
||||
forbiddenManifestPath,
|
||||
Array.from(forbiddenManifest).join('\n'),
|
||||
);
|
||||
|
||||
const allowedManifestPath = path.join(tempDir, 'allowed.txt');
|
||||
await fs.promises.writeFile(
|
||||
allowedManifestPath,
|
||||
Array.from(allowedManifest).join('\n'),
|
||||
);
|
||||
|
||||
// 6. Construct the helper command
|
||||
const program = this.helperPath;
|
||||
|
||||
const finalArgs = [
|
||||
networkAccess ? '1' : '0',
|
||||
req.cwd,
|
||||
'--forbidden-manifest',
|
||||
forbiddenManifestPath,
|
||||
'--allowed-manifest',
|
||||
allowedManifestPath,
|
||||
command,
|
||||
...args,
|
||||
];
|
||||
|
||||
const finalEnv = { ...sanitizedEnv };
|
||||
|
||||
return {
|
||||
program,
|
||||
args: finalArgs,
|
||||
env: finalEnv,
|
||||
cwd: req.cwd,
|
||||
cleanup: () => {
|
||||
try {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a file or directory exists.
|
||||
* Finds all secret files in a directory up to a certain depth.
|
||||
* Default is shallow scan (depth 1) for performance.
|
||||
*/
|
||||
function touch(filePath: string, isDirectory: boolean): void {
|
||||
assertValidPathString(filePath);
|
||||
try {
|
||||
// If it exists (even as a broken symlink), do nothing
|
||||
fs.lstatSync(filePath);
|
||||
return;
|
||||
} catch (e: unknown) {
|
||||
if (isErrnoException(e) && e.code !== 'ENOENT') {
|
||||
throw e;
|
||||
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 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
|
||||
}
|
||||
}
|
||||
|
||||
if (isDirectory) {
|
||||
fs.mkdirSync(filePath, { recursive: true });
|
||||
} else {
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.closeSync(fs.openSync(filePath, 'a'));
|
||||
}
|
||||
await walk(baseDir, 1);
|
||||
return secrets;
|
||||
}
|
||||
|
||||
export function isSecretFile(fileName: string): boolean {
|
||||
return SECRET_FILES.some((s) => {
|
||||
if (s.pattern.includes('*')) {
|
||||
const regex = new RegExp(
|
||||
'^' +
|
||||
s.pattern
|
||||
// Escape all regex special chars
|
||||
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
// Convert the escaped asterisk back to a regex wildcard
|
||||
.replace(/\\\*/g, '.*') +
|
||||
'$',
|
||||
);
|
||||
return regex.test(fileName);
|
||||
}
|
||||
return fileName === s.pattern;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ vi.mock('../utils/events.js', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../utils/debugLogger.js', () => ({
|
||||
debugLogger: { debug: vi.fn() },
|
||||
debugLogger: { log: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
@@ -153,14 +153,14 @@ describe('KeychainService', () => {
|
||||
|
||||
// Because it falls back to FileKeychain, it is always available.
|
||||
expect(available).toBe(true);
|
||||
expect(debugLogger.debug).toHaveBeenCalledWith(
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('encountered an error'),
|
||||
'locked',
|
||||
);
|
||||
expect(coreEvents.emitTelemetryKeychainAvailability).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ available: false }),
|
||||
);
|
||||
expect(debugLogger.debug).toHaveBeenCalledWith(
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Using FileKeychain fallback'),
|
||||
);
|
||||
expect(FileKeychain).toHaveBeenCalled();
|
||||
@@ -173,7 +173,7 @@ describe('KeychainService', () => {
|
||||
const available = await service.isAvailable();
|
||||
|
||||
expect(available).toBe(true);
|
||||
expect(debugLogger.debug).toHaveBeenCalledWith(
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('failed structural validation'),
|
||||
expect.objectContaining({ getPassword: expect.any(Array) }),
|
||||
);
|
||||
@@ -191,7 +191,7 @@ describe('KeychainService', () => {
|
||||
const available = await service.isAvailable();
|
||||
|
||||
expect(available).toBe(true);
|
||||
expect(debugLogger.debug).toHaveBeenCalledWith(
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('functional verification failed'),
|
||||
);
|
||||
expect(FileKeychain).toHaveBeenCalled();
|
||||
@@ -243,7 +243,7 @@ describe('KeychainService', () => {
|
||||
);
|
||||
expect(mockKeytar.setPassword).not.toHaveBeenCalled();
|
||||
expect(FileKeychain).toHaveBeenCalled();
|
||||
expect(debugLogger.debug).toHaveBeenCalledWith(
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('MacOS default keychain not found'),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -114,7 +114,7 @@ export class KeychainService {
|
||||
}
|
||||
|
||||
// If native failed or was skipped, return the secure file fallback.
|
||||
debugLogger.debug('Using FileKeychain fallback for secure storage.');
|
||||
debugLogger.log('Using FileKeychain fallback for secure storage.');
|
||||
return new FileKeychain();
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ export class KeychainService {
|
||||
|
||||
// Probing macOS prevents process-blocking popups when no keychain exists.
|
||||
if (os.platform() === 'darwin' && !this.isMacOSKeychainAvailable()) {
|
||||
debugLogger.debug(
|
||||
debugLogger.log(
|
||||
'MacOS default keychain not found; skipping functional verification.',
|
||||
);
|
||||
return null;
|
||||
@@ -140,15 +140,12 @@ export class KeychainService {
|
||||
return keychainModule;
|
||||
}
|
||||
|
||||
debugLogger.debug('Keychain functional verification failed');
|
||||
debugLogger.log('Keychain functional verification failed');
|
||||
return null;
|
||||
} catch (error) {
|
||||
// Avoid logging full error objects to prevent PII exposure.
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
debugLogger.debug(
|
||||
'Keychain initialization encountered an error:',
|
||||
message,
|
||||
);
|
||||
debugLogger.log('Keychain initialization encountered an error:', message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -165,7 +162,7 @@ export class KeychainService {
|
||||
return potential as Keychain;
|
||||
}
|
||||
|
||||
debugLogger.debug(
|
||||
debugLogger.log(
|
||||
'Keychain module failed structural validation:',
|
||||
result.error.flatten().fieldErrors,
|
||||
);
|
||||
|
||||
@@ -18,8 +18,8 @@ import { getSecureSanitizationConfig } from './environmentSanitization.js';
|
||||
import {
|
||||
type SandboxManager,
|
||||
type SandboxedCommand,
|
||||
GOVERNANCE_FILES,
|
||||
} from './sandboxManager.js';
|
||||
import { GOVERNANCE_FILES } from '../sandbox/constants.js';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import os from 'node:os';
|
||||
|
||||
@@ -6,20 +6,12 @@
|
||||
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import { afterEach, describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
NoopSandboxManager,
|
||||
findSecretFiles,
|
||||
isSecretFile,
|
||||
resolveSandboxPaths,
|
||||
type SandboxRequest,
|
||||
} from './sandboxManager.js';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NoopSandboxManager } 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 =
|
||||
@@ -55,184 +47,9 @@ vi.mock('../utils/paths.js', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const workspace = path.resolve('/workspace');
|
||||
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 () => {
|
||||
const workspace = path.resolve('/workspace');
|
||||
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());
|
||||
|
||||
describe('resolveSandboxPaths', () => {
|
||||
it('should resolve allowed and forbidden paths', async () => {
|
||||
const workspace = path.resolve('/workspace');
|
||||
const forbidden = path.join(workspace, 'forbidden');
|
||||
const allowed = path.join(workspace, 'allowed');
|
||||
const options = {
|
||||
workspace,
|
||||
forbiddenPaths: async () => [forbidden],
|
||||
};
|
||||
const req = {
|
||||
command: 'ls',
|
||||
args: [],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
policy: {
|
||||
allowedPaths: [allowed],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await resolveSandboxPaths(options, req as SandboxRequest);
|
||||
|
||||
expect(result.policyAllowed).toEqual([allowed]);
|
||||
expect(result.forbidden).toEqual([forbidden]);
|
||||
});
|
||||
|
||||
it('should filter out workspace from allowed paths', async () => {
|
||||
const workspace = path.resolve('/workspace');
|
||||
const other = path.resolve('/other/path');
|
||||
const options = {
|
||||
workspace,
|
||||
};
|
||||
const req = {
|
||||
command: 'ls',
|
||||
args: [],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
policy: {
|
||||
allowedPaths: [workspace, workspace + path.sep, other],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await resolveSandboxPaths(options, req as SandboxRequest);
|
||||
|
||||
expect(result.policyAllowed).toEqual([other]);
|
||||
});
|
||||
|
||||
it('should prioritize forbidden paths over allowed paths', async () => {
|
||||
const workspace = path.resolve('/workspace');
|
||||
const secret = path.join(workspace, 'secret');
|
||||
const normal = path.join(workspace, 'normal');
|
||||
const options = {
|
||||
workspace,
|
||||
forbiddenPaths: async () => [secret],
|
||||
};
|
||||
const req = {
|
||||
command: 'ls',
|
||||
args: [],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
policy: {
|
||||
allowedPaths: [secret, normal],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await resolveSandboxPaths(options, req as SandboxRequest);
|
||||
|
||||
expect(result.policyAllowed).toEqual([normal]);
|
||||
expect(result.forbidden).toEqual([secret]);
|
||||
});
|
||||
|
||||
it('should handle case-insensitive conflicts on supported platforms', async () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin');
|
||||
const workspace = path.resolve('/workspace');
|
||||
const secretUpper = path.join(workspace, 'SECRET');
|
||||
const secretLower = path.join(workspace, 'secret');
|
||||
const options = {
|
||||
workspace,
|
||||
forbiddenPaths: async () => [secretUpper],
|
||||
};
|
||||
const req = {
|
||||
command: 'ls',
|
||||
args: [],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
policy: {
|
||||
allowedPaths: [secretLower],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await resolveSandboxPaths(options, req as SandboxRequest);
|
||||
|
||||
expect(result.policyAllowed).toEqual([]);
|
||||
expect(result.forbidden).toEqual([secretUpper]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoopSandboxManager', () => {
|
||||
const sandboxManager = new NoopSandboxManager();
|
||||
|
||||
@@ -392,14 +209,5 @@ describe('SandboxManager', () => {
|
||||
expect(manager).toBeInstanceOf(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it('should return WindowsSandboxManager if sandboxing is enabled on win32', () => {
|
||||
vi.spyOn(os, 'platform').mockReturnValue('win32');
|
||||
const manager = createSandboxManager(
|
||||
{ enabled: true },
|
||||
{ workspace: path.resolve('/workspace') },
|
||||
);
|
||||
expect(manager).toBeInstanceOf(WindowsSandboxManager);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,12 +4,10 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
isKnownSafeCommand as isMacSafeCommand,
|
||||
isDangerousCommand as isMacDangerousCommand,
|
||||
isKnownSafeCommand as isPosixSafeCommand,
|
||||
isDangerousCommand as isPosixDangerousCommand,
|
||||
} from '../sandbox/utils/commandSafety.js';
|
||||
import {
|
||||
isKnownSafeCommand as isWindowsSafeCommand,
|
||||
@@ -22,44 +20,6 @@ import {
|
||||
} from './environmentSanitization.js';
|
||||
import type { ShellExecutionResult } from './shellExecutionService.js';
|
||||
import type { SandboxPolicyManager } from '../policy/sandboxPolicyManager.js';
|
||||
import {
|
||||
toPathKey,
|
||||
deduplicateAbsolutePaths,
|
||||
resolveToRealPath,
|
||||
} from '../utils/paths.js';
|
||||
import { resolveGitWorktreePaths } from '../sandbox/utils/fsUtils.js';
|
||||
|
||||
/**
|
||||
* A structured result of fully resolved sandbox paths.
|
||||
* All paths in this object are absolute, deduplicated, and expanded to include
|
||||
* both the original path and its real target (if it is a symlink).
|
||||
*/
|
||||
export interface ResolvedSandboxPaths {
|
||||
/** The primary workspace directory. */
|
||||
workspace: {
|
||||
/** The original path provided in the sandbox options. */
|
||||
original: string;
|
||||
/** The real path. */
|
||||
resolved: string;
|
||||
};
|
||||
/** Explicitly denied paths. */
|
||||
forbidden: string[];
|
||||
/** Directories included globally across all commands in this sandbox session. */
|
||||
globalIncludes: string[];
|
||||
/** Paths explicitly allowed by the policy of the currently executing command. */
|
||||
policyAllowed: string[];
|
||||
/** Paths granted temporary read access by the current command's dynamic permissions. */
|
||||
policyRead: string[];
|
||||
/** Paths granted temporary write access by the current command's dynamic permissions. */
|
||||
policyWrite: string[];
|
||||
/** Auto-detected paths for git worktrees/submodules. */
|
||||
gitWorktree?: {
|
||||
/** The actual .git directory for this worktree. */
|
||||
worktreeGitDir: string;
|
||||
/** The main repository's .git directory (if applicable). */
|
||||
mainGitDir?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SandboxPermissions {
|
||||
/** Filesystem permissions. */
|
||||
@@ -191,97 +151,6 @@ export interface SandboxManager {
|
||||
getOptions(): GlobalSandboxOptions | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Files that represent the governance or "constitution" of the repository
|
||||
* and should be write-protected in any sandbox.
|
||||
*/
|
||||
export const GOVERNANCE_FILES = [
|
||||
{ path: '.gitignore', isDirectory: false },
|
||||
{ path: '.geminiignore', isDirectory: false },
|
||||
{ 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.
|
||||
@@ -310,13 +179,13 @@ export class NoopSandboxManager implements SandboxManager {
|
||||
isKnownSafeCommand(args: string[]): boolean {
|
||||
return os.platform() === 'win32'
|
||||
? isWindowsSafeCommand(args)
|
||||
: isMacSafeCommand(args);
|
||||
: isPosixSafeCommand(args);
|
||||
}
|
||||
|
||||
isDangerousCommand(args: string[]): boolean {
|
||||
return os.platform() === 'win32'
|
||||
? isWindowsDangerousCommand(args)
|
||||
: isMacDangerousCommand(args);
|
||||
: isPosixDangerousCommand(args);
|
||||
}
|
||||
|
||||
parseDenials(): undefined {
|
||||
@@ -362,76 +231,3 @@ export class LocalSandboxManager implements SandboxManager {
|
||||
return this.options;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves and sanitizes all path categories for a sandbox request.
|
||||
*/
|
||||
export async function resolveSandboxPaths(
|
||||
options: GlobalSandboxOptions,
|
||||
req: SandboxRequest,
|
||||
overridePermissions?: SandboxPermissions,
|
||||
): Promise<ResolvedSandboxPaths> {
|
||||
/**
|
||||
* Helper that expands each path to include its realpath (if it's a symlink)
|
||||
* and pipes the result through deduplicateAbsolutePaths for deduplication and absolute path enforcement.
|
||||
*/
|
||||
const expand = (paths?: string[] | null): string[] => {
|
||||
if (!paths || paths.length === 0) return [];
|
||||
const expanded = paths.flatMap((p) => {
|
||||
try {
|
||||
const resolved = resolveToRealPath(p);
|
||||
return resolved === p ? [p] : [p, resolved];
|
||||
} catch {
|
||||
return [p];
|
||||
}
|
||||
});
|
||||
return deduplicateAbsolutePaths(expanded);
|
||||
};
|
||||
|
||||
const forbidden = expand(await options.forbiddenPaths?.());
|
||||
|
||||
const globalIncludes = expand(options.includeDirectories);
|
||||
const policyAllowed = expand(req.policy?.allowedPaths);
|
||||
|
||||
const policyRead = expand(overridePermissions?.fileSystem?.read);
|
||||
const policyWrite = expand(overridePermissions?.fileSystem?.write);
|
||||
|
||||
const resolvedWorkspace = resolveToRealPath(options.workspace);
|
||||
|
||||
const workspaceIdentities = new Set(
|
||||
[options.workspace, resolvedWorkspace].map(toPathKey),
|
||||
);
|
||||
const forbiddenIdentities = new Set(forbidden.map(toPathKey));
|
||||
|
||||
const { worktreeGitDir, mainGitDir } =
|
||||
await resolveGitWorktreePaths(resolvedWorkspace);
|
||||
const gitWorktree = worktreeGitDir
|
||||
? { gitWorktree: { worktreeGitDir, mainGitDir } }
|
||||
: undefined;
|
||||
|
||||
/**
|
||||
* Filters out any paths that are explicitly forbidden or match the workspace root (original or resolved).
|
||||
*/
|
||||
const filter = (paths: string[]) =>
|
||||
paths.filter((p) => {
|
||||
const identity = toPathKey(p);
|
||||
return (
|
||||
!workspaceIdentities.has(identity) && !forbiddenIdentities.has(identity)
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
workspace: {
|
||||
original: options.workspace,
|
||||
resolved: resolvedWorkspace,
|
||||
},
|
||||
forbidden,
|
||||
globalIncludes: filter(globalIncludes),
|
||||
policyAllowed: filter(policyAllowed),
|
||||
policyRead: filter(policyRead),
|
||||
policyWrite: filter(policyWrite),
|
||||
...gitWorktree,
|
||||
};
|
||||
}
|
||||
|
||||
export { createSandboxManager } from './sandboxManagerFactory.js';
|
||||
|
||||
@@ -195,7 +195,6 @@ describe('compatibility', () => {
|
||||
desc: '256 colors are not supported',
|
||||
},
|
||||
])('should return $expected when $desc', ({ depth, term, expected }) => {
|
||||
vi.stubEnv('COLORTERM', '');
|
||||
process.stdout.getColorDepth = vi.fn().mockReturnValue(depth);
|
||||
if (term !== undefined) {
|
||||
vi.stubEnv('TERM', term);
|
||||
@@ -204,13 +203,6 @@ describe('compatibility', () => {
|
||||
}
|
||||
expect(supports256Colors()).toBe(expected);
|
||||
});
|
||||
|
||||
it('should return true when COLORTERM is kmscon', () => {
|
||||
process.stdout.getColorDepth = vi.fn().mockReturnValue(4);
|
||||
vi.stubEnv('TERM', 'linux');
|
||||
vi.stubEnv('COLORTERM', 'kmscon');
|
||||
expect(supports256Colors()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('supportsTrueColor', () => {
|
||||
@@ -238,12 +230,6 @@ describe('compatibility', () => {
|
||||
expected: true,
|
||||
desc: 'getColorDepth returns >= 24',
|
||||
},
|
||||
{
|
||||
colorterm: 'kmscon',
|
||||
depth: 4,
|
||||
expected: true,
|
||||
desc: 'COLORTERM is kmscon',
|
||||
},
|
||||
{
|
||||
colorterm: '',
|
||||
depth: 8,
|
||||
@@ -423,18 +409,6 @@ describe('compatibility', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should return no color warnings for kmscon terminal', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.stubEnv('TERMINAL_EMULATOR', '');
|
||||
vi.stubEnv('TERM', 'linux');
|
||||
vi.stubEnv('COLORTERM', 'kmscon');
|
||||
process.stdout.getColorDepth = vi.fn().mockReturnValue(4);
|
||||
|
||||
const warnings = getCompatibilityWarnings();
|
||||
expect(warnings.find((w) => w.id === '256-color')).toBeUndefined();
|
||||
expect(warnings.find((w) => w.id === 'true-color')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return no warnings in a standard environment with true color', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
vi.stubEnv('TERMINAL_EMULATOR', '');
|
||||
|
||||
@@ -85,11 +85,6 @@ export function supports256Colors(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Terminals supporting true color (like kmscon) also support 256 colors
|
||||
if (supportsTrueColor()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -100,8 +95,7 @@ export function supportsTrueColor(): boolean {
|
||||
// Check COLORTERM environment variable
|
||||
if (
|
||||
process.env['COLORTERM'] === 'truecolor' ||
|
||||
process.env['COLORTERM'] === '24bit' ||
|
||||
process.env['COLORTERM'] === 'kmscon'
|
||||
process.env['COLORTERM'] === '24bit'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-devtools",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"main": "dist/src/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-sdk",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"description": "Gemini CLI SDK",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"displayName": "Gemini CLI Companion",
|
||||
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"publisher": "google",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
@@ -12,11 +12,6 @@
|
||||
"cpuTotalUs": 12157,
|
||||
"timestamp": "2026-04-08T22:28:19.098Z"
|
||||
},
|
||||
"asian-language-conv": {
|
||||
"wallClockMs": 2315.1,
|
||||
"cpuTotalUs": 6283,
|
||||
"timestamp": "2026-04-14T15:22:56.133Z"
|
||||
},
|
||||
"skill-loading-time": {
|
||||
"wallClockMs": 930.0920409999962,
|
||||
"cpuTotalUs": 1323,
|
||||
|
||||
@@ -98,36 +98,6 @@ describe('CPU Performance Tests', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('asian-language-conv: verify perf is acceptable ', async () => {
|
||||
const result = await harness.runScenario(
|
||||
'asian-language-conv',
|
||||
async () => {
|
||||
const rig = new TestRig();
|
||||
try {
|
||||
rig.setup('perf-asian-language', {
|
||||
fakeResponsesPath: join(__dirname, 'perf.asian-language.responses'),
|
||||
});
|
||||
|
||||
return await harness.measure('asian-language', async () => {
|
||||
await rig.run({
|
||||
args: ['嗨'],
|
||||
timeout: 120000,
|
||||
env: { GEMINI_API_KEY: 'fake-perf-test-key' },
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await rig.cleanup();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (UPDATE_BASELINES) {
|
||||
harness.updateScenarioBaseline(result);
|
||||
} else {
|
||||
harness.assertWithinBaseline(result);
|
||||
}
|
||||
});
|
||||
|
||||
it('skill-loading-time: startup with many skills within baseline', async () => {
|
||||
const SKILL_COUNT = 20;
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"你好!我是 Gemini CLI,你的 AI 编程助手"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":20648,"candidatesTokenCount":12,"totalTokenCount":20769,"promptTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]}
|
||||
Reference in New Issue
Block a user