Compare commits

..

3 Commits

Author SHA1 Message Date
ehedlund cc06ac3202 address Gemini feedback 2026-04-15 17:23:13 -04:00
ehedlund d6492830b6 address GitHub security feedback 2026-04-15 17:18:17 -04:00
ehedlund bcf599b3b2 refactor(core): abstract OsSandboxManager and centralize common logic 2026-04-15 16:01:24 -04:00
59 changed files with 1577 additions and 2177 deletions
-92
View File
@@ -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.
-8
View File
@@ -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,
});
}
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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'"
+1 -1
View File
@@ -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'
-1
View File
@@ -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
+3 -6
View File
@@ -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

+9 -9
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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",
+2 -2
View File
@@ -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",
+11 -16
View File
@@ -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 -1
View File
@@ -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;
}
+24
View File
@@ -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 -208
View File
@@ -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', '');
+1 -7
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "gemini-cli-vscode-ide-companion",
"displayName": "Gemini CLI Companion",
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"version": "0.39.0-nightly.20260408.e77b22e63",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {
-5
View File
@@ -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,
-30
View File
@@ -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;
-2
View File
@@ -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}]}}]}