From e8d292cdb9744290ecad0d0960d7834178f872f4 Mon Sep 17 00:00:00 2001 From: galz10 Date: Tue, 17 Mar 2026 18:23:00 -0700 Subject: [PATCH] wip: dynamic expansion proto --- .gemini/settings.json | 6 +- docs/sandboxing-implementation-plan.md | 257 ------------------ packages/cli/src/ui/AppContainer.tsx | 92 ++++++- .../messages/ToolConfirmationMessage.tsx | 31 +++ .../src/ui/contexts/ToolActionsContext.tsx | 2 +- packages/core/src/confirmation-bus/types.ts | 22 ++ .../core/src/services/execPolicyEngine.ts | 131 +++++++++ packages/core/src/services/sandboxManager.ts | 11 +- .../src/services/shellExecutionService.ts | 169 +++++++----- .../dynamic-declaration-helpers.ts | 4 +- packages/core/src/tools/mcp-client.ts | 2 +- packages/core/src/tools/shell.test.ts | 8 +- packages/core/src/tools/shell.ts | 186 +++++++++++-- packages/core/src/utils/sandbox-prompt.ts | 92 ++++--- packages/core/src/utils/shell-utils.ts | 6 +- 15 files changed, 609 insertions(+), 410 deletions(-) delete mode 100644 docs/sandboxing-implementation-plan.md create mode 100644 packages/core/src/services/execPolicyEngine.ts diff --git a/.gemini/settings.json b/.gemini/settings.json index cc91f9fabd..14116580d1 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -11,6 +11,10 @@ "enabled": true }, "tools": { - "coreTools": ["ShellTool(echo)"] + "coreTools": ["ShellTool(echo)"], + "exclude": [ + "replace", + "write_file" + ] } } diff --git a/docs/sandboxing-implementation-plan.md b/docs/sandboxing-implementation-plan.md deleted file mode 100644 index 1427da8d73..0000000000 --- a/docs/sandboxing-implementation-plan.md +++ /dev/null @@ -1,257 +0,0 @@ -# Gemini CLI: Tool Sandboxing Implementation Tasks - -This document outlines the engineering plan to integrate Codex's OS-level sandboxing (macOS Seatbelt, Linux Bubblewrap/Seccomp, Windows Restricted Tokens/ACLs) into the Gemini CLI execution flow. - -These tasks are structured as Epics (Parent Tasks) and Issues (Sub-tasks) suitable for importing into GitHub or Linear. - ---- - -## Epic 1: Foundation - Sandbox Manager & Configuration - -**Description:** Establish the core interfaces, configuration models, and service injection points necessary to support OS-specific sandboxing without breaking the existing execution flow. - -### Task 1.1: Update Configuration Schema for Sandboxing -**Assignee:** TBD -**Description:** -We need to extend the Gemini CLI configuration to support sandbox settings. -* **Action:** Update `Config` and `ConfigSchema` in `packages/core/src/config/config.ts`. -* **Details:** - * Add a `sandbox` block. - * Fields: `enabled` (boolean, default false for now), `allowedPaths` (array of strings, e.g., workspace roots, `/tmp`), `networkAccess` (boolean or string enum). -* **Acceptance Criteria:** `gemini-cli` can parse and validate a configuration file containing the new `sandbox` block. - -### Task 1.2: Implement `SandboxManager` Base Service -**Assignee:** TBD -**Description:** -Create the abstract service responsible for preparing a command to run inside a sandbox. -* **Action:** Create `packages/core/src/services/sandboxManager.ts`. -* **Details:** - * Define the `SandboxManager` interface with a method like `prepareCommand(req: SandboxRequest): Promise`. - * The `SandboxRequest` should include the original command, arguments, `cwd`, environment variables, and the `sandbox` config block. - * The `SandboxedCommand` should return a possibly mutated `program` and `args` array (e.g., returning `sandbox-exec` as the program and the original command as args). - * Implement a `StandardSandboxManager` that handles platform-specific logic. -* **Acceptance Criteria:** The interface is defined and available via the dependency injection container. - -### Task 1.3: Intercept Execution in `ShellExecutionService` -**Assignee:** TBD -**Description:** -Modify the core execution engine to route commands through the `SandboxManager` before spawning. -* **Action:** Update `packages/core/src/services/shellExecutionService.ts`. -* **Details:** - * Inject the `SandboxManager` into `ShellExecutionService`. - * Before calling `node-pty.spawn` or `child_process.spawn`, pass the command payload to `sandboxManager.prepareCommand()`. - * Use the returned `program` and `args` to perform the actual spawn. -* **Acceptance Criteria:** When sandboxing is disabled, tool execution behaves exactly as it did before. - ---- - -## Epic 2: macOS Seatbelt Integration - -**Description:** Implement the Tier 1 Tool Sandboxing for macOS using `/usr/bin/sandbox-exec` and dynamically generated `.sb` profiles. - -### Task 2.1: Seatbelt Profile Generation -**Assignee:** TBD -**Description:** -* **Action:** Implement `generateSeatbeltProfile` in `packages/core/src/services/sandboxManager.ts`. -* **Details:** - * Take `allowedPaths` and dynamically generate a Scheme/Lisp formatted Seatbelt profile. - * Include essential "Life Support" rules: `mach-lookup` for `logd`, `sysmond`, and `trustd`. - * Broaden `file-map-executable` to ensure system libraries can load. - * Explicitly handle Git Worktree detection to allow access to external `.git` metadata directories. -* **Acceptance Criteria:** Standard binaries like `ls`, `cat`, and `git` run successfully without triggering `Signal 6` (SIGABRT). - -### Task 2.2: Implement `MacOsSandboxManager` logic -**Assignee:** TBD -**Description:** -Connect the profile generator to the execution pipeline. -* **Details:** - * In `prepareCommand`, generate the `.sb` string. - * Write this string to a secure, temporary file (`/tmp/gemini-sandbox-/sandbox.sb`). - * Return `program: '/usr/bin/sandbox-exec'` and `args: ['-f', '', ...originalCmd]`. -* **Acceptance Criteria:** Commands executed on macOS with sandboxing enabled correctly invoke `sandbox-exec`. - ---- - -## Epic 3: Linux Bubblewrap & Seccomp Integration - -**Description:** Implement Tool Sandboxing for Linux using `bwrap` for namespaces and a Seccomp BPF filter for syscall restriction. - -### Task 3.1: Implement Bubblewrap Argument Generation -**Assignee:** TBD -**Description:** -Generate the `bwrap` CLI arguments to isolate the filesystem. -* **Action:** Update `StandardSandboxManager` to handle Linux. -* **Details:** - * Map `allowedPaths` to `bwrap` binds. E.g., `--ro-bind / / --bind --dev-bind /dev /dev --unshare-all`. - * Ensure `/dev/pts` is correctly mounted to allow `node-pty` to function. -* **Acceptance Criteria:** `prepareCommand` correctly outputs `bwrap` as the program with the appropriate isolation flags. - ---- - -## Epic 4: Windows Restricted Tokens & ACLs - -**Description:** Implement Tool Sandboxing for Windows using Win32 API security primitives. - -### Task 4.1: Develop Windows Sandbox N-API Addon -**Assignee:** TBD -**Description:** -Node.js lacks native APIs for token manipulation. We must build an addon. -* **Action:** Create a Rust `napi-rs` module or C++ `node-addon-api` module. -* **Details:** - * Implement logic to call `CreateRestrictedToken`. - * Expose functions to call `SetEntriesInAclW` to dynamically grant "Allow" ACEs for the workspace directory. -* **Acceptance Criteria:** The Node.js application can successfully invoke the addon methods to retrieve a restricted token handle. - ---- - -## Epic 5: Network Proxies & Egress Control - -**Description:** Restrict network egress across all platforms, ensuring the agent cannot exfiltrate data. - -### Task 5.1: Implement Loopback Proxy & Rules -**Assignee:** TBD -**Description:** -Route allowed network traffic through a managed proxy. -* **Action:** Update all platform sandbox managers. -* **Details:** - * **macOS:** Add Seatbelt rules explicitly denying network except to specific ports or using `(allow network-outbound)`. - * **Linux:** Ensure `bwrap --unshare-net` is active if network is disabled. -* **Acceptance Criteria:** Network access is strictly controlled by the sandbox manager based on policy. - ---- - -## Epic 6: Dynamic Sandbox Expansion & `sandboxing.toml` - -**Description:** Implement a user-guided workflow that allows the sandbox to evolve based on real-world tool usage. Instead of a static profile, the agent will detect failures and propose atomic permission updates stored in a dedicated configuration file. - -### Task 6.1: Implement `sandboxing.toml` Schema & Parser -**Assignee:** TBD -**Description:** -Create a dedicated, human-readable TOML file for local sandbox overrides. -* **Action:** Define the schema and implement a parser. -* **Details:** - * Support fields like `extraAllowedPaths` (array of strings), `allowNetwork` (boolean or domain list), and `binaryWhitelists`. - * The loader should look for this file in the project root or `.gemini/sandboxing.toml`. - * The `SandboxManager` must be updated to merge these rules into the OS-specific profile generation logic. -* **Acceptance Criteria:** Changing a path in `sandboxing.toml` immediately reflects in the next generated sandbox execution. - -### Task 6.2: Sandbox Violation Heuristics (Failure Detection) -**Assignee:** TBD -**Description:** -Enable the agent to distinguish between a "tool error" and a "sandbox block." -* **Action:** Update `ShellExecutionService` to analyze exit signals and error codes. -* **Details:** - * Detect specific signatures: `Signal 6` (SIGABRT on macOS), `Exit 128` (Git repository/worktree errors), and `EPERM` (Permission Denied). - * When a block is suspected, extract the paths or resources the command was attempting to access. -* **Acceptance Criteria:** The execution engine provides a "Security Hint" in the internal error object when a sandbox violation is detected. - -### Task 6.3: Interactive Permission Expansion Workflow -**Assignee:** TBD -**Description:** -Implement the user-facing loop for approving new permissions. -* **Action:** Create a handler in the agent logic to process sandbox failures. -* **Details:** - * If a command fails due to a sandbox violation, the agent uses `ask_user` to propose an update. - * *Example Prompt:* "It looks like `nvim` was blocked. Would you like to permanently allow read/write access to `~/.config/nvim` in your `sandboxing.toml`?" - * On approval, the agent automatically updates the TOML file. -* **Acceptance Criteria:** A user can "fix" a sandbox failure for a new tool in 1-2 interactive turns. - -### Task 6.4: OS-Specific Rule Translators for Expansion -**Assignee:** TBD -**Description:** -Ensure that generic paths in `sandboxing.toml` are correctly translated across OS boundaries. -* **Action:** Update the `generateSeatbeltProfile` (macOS) and `bwrap` argument generator (Linux). -* **Details:** - * Handle tilde expansion (`~/`) and environment variables in the TOML paths. -* **Acceptance Criteria:** A single `sandboxing.toml` entry works correctly across all supported operating systems. - ---- - -## Epic 7: Governance & Secret Protection - -**Description:** Prevent the AI from tampering with its own security boundaries or accessing sensitive environment secrets. - -### Task 7.1: Implementation of "Write-Protected" Governance Files -**Assignee:** TBD -**Description:** -Ensure that the "Constitution" of the repository cannot be modified by the AI. -* **Details:** Update the `SandboxManager` to explicitly add `deny file-write` rules for `.gitignore` and `.geminiignore`. -* **Acceptance Criteria:** A shell command like `rm .gitignore` or `sed -i ... .gitignore` fails with "Permission Denied" even when the tool has workspace-wide write access. - -### Task 7.2: Secret Visibility Lockdown (`.env`) -**Assignee:** TBD -**Description:** -Protect API keys and credentials stored in environment files. -* **Details:** Add strict "Deny Read/Write" rules for any file named `.env` or matching `.env.*`. -* **Acceptance Criteria:** `cat .env` returns "Permission Denied." The file is effectively invisible to the sandboxed tool. - ---- - -## Epic 8: Cross-Platform Ignore Enforcement - -**Description:** Implement a generic, OS-agnostic system to ensure that any file pattern listed in `.gitignore` or `.geminiignore` is strictly inaccessible at the kernel level. - -### Task 8.1: Platform-Agnostic Ignore Resolver -**Assignee:** TBD -**Description:** -Create a core service to resolve ignore patterns into absolute paths. -* **Action:** Implement `packages/core/src/services/ignoreResolver.ts`. -* **Details:** - * Uses standard glob-matching logic. - * Consolidates rules from `.gitignore` and `.geminiignore`. - * Outputs a standardized `ForbiddenResource` list. -* **Acceptance Criteria:** The service correctly identifies `node_modules/` or `.env` as forbidden regardless of the OS it's running on. - -### Task 8.2: Standardized "Deny" Interface in `SandboxManager` -**Assignee:** TBD -**Description:** -Update the base sandbox interface to handle forbidden resources. -* **Action:** Update the `prepareCommand` signature to accept `forbiddenPaths`. -* **Details:** Pass the list of standardized forbidden resources to the platform-specific implementation. -* **Acceptance Criteria:** Every OS-specific driver receives the same list of paths to block. - -### Task 8.3: OS-Specific "Deny" Implementations -**Assignee:** TBD -**Description:** -Implement the actual blocking mechanism for each OS. -* **Details:** - * **macOS (Seatbelt)**: Generate `(deny file-read* file-write* (subpath "/path"))`. - * **Linux (Bubblewrap)**: Ensure the forbidden path is **not** mounted into the namespace. - * **Windows (Restricted Tokens)**: Apply a "Deny" Access Control Entry (ACE) to the specific file/folder for the restricted SID used by the tool. -* **Acceptance Criteria:** A shell command `cat secret.txt` returns a "Permission Denied" error on all platforms if `secret.txt` is ignored. - ---- - -## Team Parallelization Strategy (3 Engineers) - -To maximize velocity, implementation is divided by Platform Ownership. Each engineer is responsible for the full security stack on their respective OS, while coordinating on shared core logic. - -### Engineer A: macOS Platform Lead -* **Primary Focus**: Epic 2 (macOS Integration) & Epic 1 (Foundation). -* **Tasks**: - * Implement the macOS `sandbox-exec` driver. - * **Epic 5 Lead**: Design and implement the shared loopback proxy logic and the macOS network enforcement rules. - * Implement macOS "translators" for ignore rules and governance files. -* **Shared Responsibility**: Leads the design of the generic `SandboxManager` interface. - -### Engineer B: Linux Platform Lead -* **Primary Focus**: Epic 3 (Linux Integration) & Epic 8 (Ignore Enforcement). -* **Tasks**: - * Implement `bwrap` integration and the native Seccomp helper. - * **Epic 8 Lead**: Build the generic "Ignore Resolver" that parses glob patterns for all platforms. - * **Epic 7 Lead**: Build the shared "Governance" logic to protect `.gitignore` and `.env` files. - * Implement Linux "translators" for governance and network rules. - -### Engineer C: Windows Platform Lead -* **Primary Focus**: Epic 4 (Windows Integration) & Epic 6 (Dynamic Expansion). -* **Tasks**: - * Develop the Rust/N-API addon and native process spawning for Windows. - * **Epic 6 Lead**: Build the shared interactive "Permission Expansion" loop and `sandboxing.toml` orchestration. - * Implement Windows "translators" for ignore rules, network, and governance. - -### Milestone Map -1. **Week 1 (Interface Alignment)**: Engineer A/B/C agree on the `prepareCommand` signature and the standardized `ForbiddenResource` list. -2. **Week 2-3 (Parallel Implementation)**: Each engineer implements the drivers for their specific OS. Engineer B and C also build the "Shared Core" modules (Ignore Resolver and Expansion Loop) in parallel. -3. **Week 4 (Cross-Platform Validation)**: Unified testing to ensure a single `sandboxing.toml` file works correctly on all three systems. - -hello world diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 4611b3b2bc..d91b664fd2 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -19,6 +19,8 @@ import { useStdout, useStdin, type AppProps, + Box, + Text, } from 'ink'; import { App } from './App.js'; import { AppContext } from './contexts/AppContext.js'; @@ -39,7 +41,7 @@ import { } from './types.js'; import { checkPermissions } from './hooks/atCommandProcessor.js'; import { MessageType, StreamingState } from './types.js'; -import { ToolActionsProvider } from './contexts/ToolActionsContext.js'; +import { ToolActionsProvider, ToolActionsContext } from './contexts/ToolActionsContext.js'; import { type StartupWarning, type EditorType, @@ -86,6 +88,7 @@ import { logBillingEvent, ApiKeyUpdatedEvent, MessageBusType, + ToolConfirmationOutcome, } from '@google/gemini-cli-core'; import { validateAuthMethod } from '../config/auth.js'; import process from 'node:process'; @@ -150,6 +153,9 @@ import { useIncludeDirsTrust } from './hooks/useIncludeDirsTrust.js'; import { isWorkspaceTrusted } from '../config/trustedFolders.js'; import { useSettings } from './contexts/SettingsContext.js'; import { AskUserDialog } from './components/AskUserDialog.js'; +import { ToolConfirmationMessage } from './components/messages/ToolConfirmationMessage.js'; +import { StickyHeader } from './components/StickyHeader.js'; +import { theme } from './semantic-colors.js'; import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js'; import { useInputHistoryStore } from './hooks/useInputHistoryStore.js'; import { useBanner } from './hooks/useBanner.js'; @@ -1583,11 +1589,95 @@ Logging in with Google... Restarting Gemini CLI to continue. }} /> ); + } else if (msg.type === MessageBusType.SANDBOX_EXPANSION_REQUEST) { + const customToolActions = { + confirm: async (callId: string, outcome: ToolConfirmationOutcome) => { + messageBus.publish({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: msg.correlationId, + confirmed: outcome !== ToolConfirmationOutcome.Cancel, + outcome, + }); + setCustomDialog(null); + }, + cancel: async (callId: string) => { + messageBus.publish({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: msg.correlationId, + confirmed: false, + outcome: ToolConfirmationOutcome.Cancel, + }); + setCustomDialog(null); + }, + isDiffingEnabled: false, + }; + + setCustomDialog( + + + + + + Action Required + + + + + + + + undefined} + terminalWidth={terminalWidth - 4} + availableTerminalHeight={terminalHeight} + isFocused={true} + /> + + + + + ); } }; messageBus.subscribe(MessageBusType.ASK_USER_REQUEST, handler); + messageBus.subscribe(MessageBusType.SANDBOX_EXPANSION_REQUEST, handler); return () => { messageBus.unsubscribe(MessageBusType.ASK_USER_REQUEST, handler); + messageBus.unsubscribe(MessageBusType.SANDBOX_EXPANSION_REQUEST, handler); }; }, [config, setCustomDialog, terminalWidth, terminalHeight]); diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index d01416171f..5b8d4742c0 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -363,6 +363,24 @@ export const ToolConfirmationMessage: React.FC< value: ToolConfirmationOutcome.Cancel, key: 'No, suggest changes (esc)', }); + } else if (confirmationDetails.type === 'sandbox_expansion') { + options.push({ + label: 'Allow once', + value: ToolConfirmationOutcome.ProceedOnce, + key: 'Allow once', + }); + if (allowPermanentApproval) { + options.push({ + label: 'Allow for all future sessions', + value: ToolConfirmationOutcome.ProceedAlwaysAndSave, + key: 'Allow for all future sessions', + }); + } + options.push({ + label: 'No, suggest changes (esc)', + value: ToolConfirmationOutcome.Cancel, + key: 'No, suggest changes (esc)', + }); } return options; }, [ @@ -484,6 +502,8 @@ export const ToolConfirmationMessage: React.FC< // mcp tool confirmation const mcpProps = confirmationDetails; question = `Allow execution of MCP tool "${sanitizeForDisplay(mcpProps.toolName)}" from server "${sanitizeForDisplay(mcpProps.serverName)}"?`; + } else if (confirmationDetails.type === 'sandbox_expansion') { + question = `Sandbox blocked access to ${sanitizeForDisplay(confirmationDetails.blockedPath)}. Allow access?`; } if (confirmationDetails.type === 'edit') { @@ -642,6 +662,17 @@ export const ToolConfirmationMessage: React.FC< )} ); + } else if (confirmationDetails.type === 'sandbox_expansion') { + bodyContent = ( + + + The sandbox prevented a command from accessing a file outside the workspace. + + + {sanitizeForDisplay(confirmationDetails.blockedPath)} + + + ); } return { question, bodyContent, options, securityWarnings }; diff --git a/packages/cli/src/ui/contexts/ToolActionsContext.tsx b/packages/cli/src/ui/contexts/ToolActionsContext.tsx index 10e063e098..6a3474d140 100644 --- a/packages/cli/src/ui/contexts/ToolActionsContext.tsx +++ b/packages/cli/src/ui/contexts/ToolActionsContext.tsx @@ -50,7 +50,7 @@ interface ToolActionsContextValue { isDiffingEnabled: boolean; } -const ToolActionsContext = createContext(null); +export const ToolActionsContext = createContext(null); export const useToolActions = () => { const context = useContext(ToolActionsContext); diff --git a/packages/core/src/confirmation-bus/types.ts b/packages/core/src/confirmation-bus/types.ts index 598d7372b7..28d4bebf2c 100644 --- a/packages/core/src/confirmation-bus/types.ts +++ b/packages/core/src/confirmation-bus/types.ts @@ -21,6 +21,8 @@ export enum MessageBusType { TOOL_CALLS_UPDATE = 'tool-calls-update', ASK_USER_REQUEST = 'ask-user-request', ASK_USER_RESPONSE = 'ask-user-response', + SANDBOX_EXPANSION_REQUEST = 'sandbox-expansion-request', + SANDBOX_EXPANSION_RESPONSE = 'sandbox-expansion-response', } export interface ToolCallsUpdateMessage { @@ -113,6 +115,11 @@ export type SerializableConfirmationDetails = type: 'exit_plan_mode'; title: string; planPath: string; + } + | { + type: 'sandbox_expansion'; + title: string; + blockedPath: string; }; export interface UpdatePolicy { @@ -179,6 +186,19 @@ export interface AskUserResponse { cancelled?: boolean; } +export interface SandboxExpansionRequest { + type: MessageBusType.SANDBOX_EXPANSION_REQUEST; + correlationId: string; + blockedPath: string; + requiresUnsandboxed?: boolean; +} + +export interface SandboxExpansionResponse { + type: MessageBusType.SANDBOX_EXPANSION_RESPONSE; + correlationId: string; + decision: 'Allow Once' | 'Always Allow' | 'Deny'; +} + export type Message = | ToolConfirmationRequest | ToolConfirmationResponse @@ -188,4 +208,6 @@ export type Message = | UpdatePolicy | AskUserRequest | AskUserResponse + | SandboxExpansionRequest + | SandboxExpansionResponse | ToolCallsUpdateMessage; diff --git a/packages/core/src/services/execPolicyEngine.ts b/packages/core/src/services/execPolicyEngine.ts new file mode 100644 index 0000000000..1fcad650b7 --- /dev/null +++ b/packages/core/src/services/execPolicyEngine.ts @@ -0,0 +1,131 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ + +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import toml from '@iarna/toml'; +import { SandboxProfile } from './sandboxManager.js'; +import { debugLogger } from '../utils/debugLogger.js'; + +export interface ExecPolicyRule { + prefix: string[]; + profile: SandboxProfile; +} + +export class ExecPolicyEngine { + private rules: ExecPolicyRule[] = []; + + constructor(private readonly configDir: string) { + this.loadPolicy(); + } + + loadPolicy() { + this.rules = []; + const policyPath = path.join(this.configDir, 'execpolicy.toml'); + if (fs.existsSync(policyPath)) { + try { + const content = fs.readFileSync(policyPath, 'utf8'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const parsed = toml.parse(content) as any; + if (parsed && Array.isArray(parsed.rules)) { + for (const rule of parsed.rules) { + if (Array.isArray(rule.prefix) && typeof rule.profile === 'string') { + const profile = + rule.profile === 'WorkspaceWrite' + ? SandboxProfile.WORKSPACE_WRITE + : SandboxProfile.READ_ONLY; + this.rules.push({ + prefix: rule.prefix, + profile, + }); + } + } + } + } catch (_error) { + debugLogger.error('Failed to parse execpolicy.toml:', _error); + } + } + } + + getProfileForCommand(commandStr: string): SandboxProfile { + // Basic tokenization by space. + // In a real shell parser, this would handle quotes, but this is a prototype. + const tokens = commandStr.trim().split(/\s+/); + + let bestMatchLength = -1; + let bestMatchProfile = SandboxProfile.READ_ONLY; // Default + + for (const rule of this.rules) { + if (rule.prefix.length > tokens.length) { + continue; + } + let match = true; + for (let i = 0; i < rule.prefix.length; i++) { + if (rule.prefix[i] !== tokens[i]) { + match = false; + break; + } + } + if (match && rule.prefix.length > bestMatchLength) { + bestMatchLength = rule.prefix.length; + bestMatchProfile = rule.profile; + } + } + + return bestMatchProfile; + } + + public async addRule(prefix: string[], profile: SandboxProfile) { + this.rules.push({ prefix, profile }); + const policyPath = path.join(this.configDir, 'execpolicy.toml'); + + let parsed: { rules: any[] } = { rules: [] }; + if (fs.existsSync(policyPath)) { + try { + const content = fs.readFileSync(policyPath, 'utf8'); + const tomlParsed = toml.parse(content); + parsed = tomlParsed as unknown as { rules: any[] }; + if (!Array.isArray(parsed.rules)) { + parsed.rules = []; + } + } catch (_error) { + // Ignore parse errors, just overwrite + } + } + + const profileStr = + profile === SandboxProfile.WORKSPACE_WRITE + ? 'WorkspaceWrite' + : 'ReadOnly'; + + // Check if the rule already exists to avoid duplicates + let exists = false; + for (const r of parsed.rules) { + if (Array.isArray(r.prefix) && r.prefix.length === prefix.length && r.prefix.every((val: string, index: number) => val === prefix[index])) { + r.profile = profileStr; + exists = true; + break; + } + } + + if (!exists) { + parsed.rules.push({ + prefix, + profile: profileStr, + }); + } + + if (!fs.existsSync(this.configDir)) { + fs.mkdirSync(this.configDir, { recursive: true }); + } + fs.writeFileSync(policyPath, toml.stringify(parsed as any)); + } +} diff --git a/packages/core/src/services/sandboxManager.ts b/packages/core/src/services/sandboxManager.ts index cbfecff3d2..4a650e44a0 100644 --- a/packages/core/src/services/sandboxManager.ts +++ b/packages/core/src/services/sandboxManager.ts @@ -23,6 +23,10 @@ export enum SandboxProfile { * Allows both read and write access to the workspace and specified paths. */ WORKSPACE_WRITE = 'WORKSPACE_WRITE', + /** + * Completely bypasses the sandbox. + */ + UNSANDBOXED = 'UNSANDBOXED', } /** @@ -93,8 +97,8 @@ export class StandardSandboxManager implements SandboxManager { prepareCommandSync(options: SandboxOptions): SandboxedCommand { const sandboxConfig = this.config.getSandbox(); - // If sandbox is not enabled or not configured, return the original command. - if (!sandboxConfig?.enabled) { + // If sandbox is not enabled or not configured, or if profile is UNSANDBOXED, return the original command. + if (!sandboxConfig?.enabled || options.profile === SandboxProfile.UNSANDBOXED) { return { program: options.command, args: options.args, @@ -213,7 +217,9 @@ export class StandardSandboxManager implements SandboxManager { // Project Workspace and Temp `(allow ${workspacePermission} (subpath "${path.resolve(options.cwd)}"))`, + `(allow file-map-executable (subpath "${path.resolve(options.cwd)}"))`, ...allowedPaths.map(p => `(allow ${workspacePermission} (subpath "${path.resolve(p)}"))`), + ...allowedPaths.map(p => `(allow file-map-executable (subpath "${path.resolve(p)}"))`), '(allow file-read* file-write* (subpath "/private/tmp"))', '(allow file-read* file-write* (subpath "/tmp"))', '(allow file-read* file-write* (subpath "/var/tmp"))', @@ -240,3 +246,4 @@ export class StandardSandboxManager implements SandboxManager { return rules.join('\n'); } } + diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index 4888d7ba26..42c0abec33 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -5,8 +5,9 @@ */ import stripAnsi from 'strip-ansi'; +import path from 'node:path'; import { getPty, type PtyImplementation } from '../utils/getPty.js'; -import { spawn as cpSpawn, exec, type ChildProcess } from 'node:child_process'; +import { spawn as cpSpawn, type ChildProcess } from 'node:child_process'; import { TextDecoder } from 'node:util'; import os from 'node:os'; import type { IPty } from '@lydell/node-pty'; @@ -25,7 +26,7 @@ import { type AnsiOutput, } from '../utils/terminalSerializer.js'; import { sanitizeEnvironment, type EnvironmentSanitizationConfig } from './environmentSanitization.js'; -import { type SandboxManager, SandboxProfile } from './sandboxManager.js'; +import type { SandboxProfile, SandboxManager } from './sandboxManager.js'; import { killProcessGroup } from '../utils/process-utils.js'; const { Terminal } = pkg; @@ -466,23 +467,9 @@ export class ShellExecutionService { const exitCode = code; const exitSignal = signal ? os.constants.signals[signal] : null; - let epermFallback = false; - if (exitSignal === 6 && child.pid) { - const violation = await ShellExecutionService.extractSandboxViolation(child.pid); - if (violation) { - finalStrippedOutput += `\n[Sandbox Violation Detected]: ${violation}`; - } else { - epermFallback = true; - } - } else if (exitCode !== 0 && exitCode !== null) { - epermFallback = true; - } - - if (epermFallback) { - const epermViolation = ShellExecutionService.extractEpermViolation(finalStrippedOutput); - if (epermViolation) { - finalStrippedOutput += `\n[Sandbox Violation Detected]: EPERM ${epermViolation}`; - } + const violation = ShellExecutionService.detectSandboxViolation(exitCode, exitSignal, finalStrippedOutput, cwd); + if (violation) { + finalStrippedOutput += `\n[Sandbox Violation Detected]${typeof violation === 'string' ? `: ${violation}` : ''}`; } if (child.pid) { @@ -881,23 +868,10 @@ export class ShellExecutionService { } let finalOutput = getFullBufferText(headlessTerminal); - let epermFallback = false; - if (signal === 6) { - const violation = await ShellExecutionService.extractSandboxViolation(ptyProcess.pid); - if (violation) { - finalOutput += `\n[Sandbox Violation Detected]: ${violation}`; - } else { - epermFallback = true; - } - } else if (exitCode !== 0 && exitCode !== null) { - epermFallback = true; - } - - if (epermFallback) { - const epermViolation = ShellExecutionService.extractEpermViolation(stripAnsi(finalOutput).trim()); - if (epermViolation) { - finalOutput += `\n[Sandbox Violation Detected]: EPERM ${epermViolation}`; - } + + const violation = ShellExecutionService.detectSandboxViolation(exitCode, signal ?? null, stripAnsi(finalOutput).trim(), cwd); + if (violation) { + finalOutput += `\n[Sandbox Violation Detected]${typeof violation === 'string' ? `: ${violation}` : ''}`; } resolve({ @@ -1268,45 +1242,100 @@ export class ShellExecutionService { } } - private static extractEpermViolation(output: string): string | undefined { - // Matches patterns like: /bin/bash: /path/to/file: Operation not permitted - // or: curl: (23) Failed writing body ... Permission denied - // or: fatal: unable to access '/Users/galzahavi/.gitconfig': Operation not permitted - const match = output.match(/(.*?):\s+(?:Operation not permitted|Permission denied)/i); - if (match) { - const text = match[1]; - const pathMatch = text.match(/['"]([^<>'"]+)['"]/) || text.match(/(?:^|\s)(\/[^\s:]+)/); - if (pathMatch) { - return pathMatch[1]; - } - // The first capture group usually contains the tool and the path, e.g., "/bin/bash: /path" - const parts = text.split(':').map((p) => p.trim()); - // The last part before the error message is usually the path - const path = parts[parts.length - 1]; - if (path && path.startsWith('/')) { - return path; + public static detectSandboxViolation(exitCode: number | null, signal: string | number | null, output: string, cwd: string): string | boolean { + if (exitCode === 0 && !signal) { + return false; + } + + let extractedBlockedPath: string | null = null; + + // Attempt to extract the exact path first + const gitMatch = output.match(/fatal: not a git repository:\s*([^\s]+)/i); + if (gitMatch && gitMatch[1]) { + extractedBlockedPath = path.resolve(cwd, gitMatch[1]); + } + + if (!extractedBlockedPath) { + const dyldMatch = output.match(/'([^']+)'\s+\(file system sandbox blocked/i); + if (dyldMatch && dyldMatch[1]) { + extractedBlockedPath = path.resolve(cwd, dyldMatch[1]); } } - return undefined; - } - private static async extractSandboxViolation(pid: number): Promise { - if (process.platform !== 'darwin') return undefined; - return new Promise((resolve) => { - // Allow max 2000ms for log extraction to avoid hanging - const child = exec(`log show --predicate 'process == "sandboxd" and eventMessage contains "${pid}"' --last 1m --style compact`, { timeout: 2000 }, (error, stdout) => { - if (!error && stdout) { - const lines = stdout.split('\n'); - for (const line of lines) { - if (line.includes('System Policy:') && line.includes('deny')) { - resolve(line.trim()); - return; + if (!extractedBlockedPath) { + const nodeMatch = output.match(/EPERM:\s+(?:operation not permitted|permission denied),\s*(?:[a-z]+)\s+['"]?([^<>'"]+)['"]?/i); + if (nodeMatch && nodeMatch[1]) { + extractedBlockedPath = path.resolve(cwd, nodeMatch[1]); + } + } + + if (!extractedBlockedPath) { + const match = output.match(/(.*?):\s+(?:Operation not permitted|Permission denied)/i); + if (match) { + const text = match[1]; + if (text && !text.includes('Error: EPERM')) { + const quotedMatch = text.match(/['"]([^<>'"]+)['"]/); + if (quotedMatch && quotedMatch[1]) { + extractedBlockedPath = path.resolve(cwd, quotedMatch[1]); + } else { + const parts = text.split(':').map((p) => p.trim()); + const extractedPath = parts[parts.length - 1]; + if (extractedPath) { + extractedBlockedPath = path.resolve(cwd, extractedPath); } } } - resolve(undefined); - }); - child.on('error', () => resolve(undefined)); - }); + } + } + + if (extractedBlockedPath) { + // Auto-expand .git paths to the root of the .git directory to prevent + // the user from having to approve config, packed-refs, HEAD, etc., one by one. + const gitIndex = extractedBlockedPath.indexOf('/.git/'); + if (gitIndex !== -1) { + extractedBlockedPath = extractedBlockedPath.substring(0, gitIndex + 5); + } + + // Auto-expand node_modules to the root of the node_modules directory + // to prevent individual file approvals for npm and node executions. + const nodeModulesIndex = extractedBlockedPath.indexOf('/node_modules/'); + if (nodeModulesIndex !== -1) { + extractedBlockedPath = extractedBlockedPath.substring(0, nodeModulesIndex + 14); + } + + return extractedBlockedPath; + } + + const QUICK_REJECT_EXIT_CODES = [2, 126, 127]; + if (exitCode !== null && QUICK_REJECT_EXIT_CODES.includes(exitCode)) { + return false; + } + + const SANDBOX_DENIED_KEYWORDS = [ + 'operation not permitted', + 'permission denied', + 'read-only file system', + 'seccomp', + 'sandbox', + 'landlock', + 'failed to write file', + ]; + + const lowerOutput = output.toLowerCase(); + const hasSandboxKeyword = SANDBOX_DENIED_KEYWORDS.some((keyword) => + lowerOutput.includes(keyword), + ); + + if (hasSandboxKeyword) { + return true; + } + + // Signal 6 (SIGABRT) is common for macOS sandbox terminations + // Signal 31 (SIGSYS) is common for Linux seccomp terminations + if (signal === 6 || signal === 'SIGABRT' || signal === 31 || signal === 'SIGSYS') { + return true; + } + + return false; } } diff --git a/packages/core/src/tools/definitions/dynamic-declaration-helpers.ts b/packages/core/src/tools/definitions/dynamic-declaration-helpers.ts index 48a88dfb3d..65cf0b997f 100644 --- a/packages/core/src/tools/definitions/dynamic-declaration-helpers.ts +++ b/packages/core/src/tools/definitions/dynamic-declaration-helpers.ts @@ -109,13 +109,13 @@ export function getShellDeclaration( description: 'Set to true if this command should be run in the background (e.g. for long-running servers or watchers). The command will be started, allowed to run for a brief moment to check for immediate errors, and then moved to the background.', }, - sandbox_ephemeral_rules: { + required_sandbox_paths: { type: 'array', items: { type: 'string', }, description: - 'Optional list of paths to allow via ephemeral sandbox rules on a retry. Only provide this if the command previously failed with a Sandbox Violation.', + '(OPTIONAL) If you know this command will need to access specific files or directories outside the workspace (e.g. global binaries, homebrew paths, global config files), list their absolute paths here. If the sandbox blocks the command, the user will be asked to approve these broader paths instead of individual files, preventing multiple interruption prompts.', }, }, required: [SHELL_PARAM_COMMAND], diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index bc8944c01e..12f92a399c 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -2263,7 +2263,7 @@ export async function createTransport( underlyingTransport instanceof XcodeMcpBridgeFixTransport || underlyingTransport instanceof SandboxedTransport ) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-type-assertion + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-type-assertion underlyingTransport = (underlyingTransport as any).transport; } diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index b6df06443b..6634ba8bde 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -275,7 +275,7 @@ describe('ShellTool', () => { expect.any(AbortSignal), false, { pager: 'cat', sanitizationConfig: {} }, - SandboxProfile.WORKSPACE_WRITE, + SandboxProfile.READ_ONLY, [], ); expect(result.llmContent).toContain('Background PIDs: 54322'); @@ -302,7 +302,7 @@ describe('ShellTool', () => { expect.any(AbortSignal), false, { pager: 'cat', sanitizationConfig: {} }, - SandboxProfile.WORKSPACE_WRITE, + SandboxProfile.READ_ONLY, [], ); }); @@ -325,7 +325,7 @@ describe('ShellTool', () => { expect.any(AbortSignal), false, { pager: 'cat', sanitizationConfig: {} }, - SandboxProfile.WORKSPACE_WRITE, + SandboxProfile.READ_ONLY, [], ); }); @@ -373,7 +373,7 @@ describe('ShellTool', () => { expect.any(AbortSignal), false, { pager: 'cat', sanitizationConfig: {} }, - SandboxProfile.WORKSPACE_WRITE, + SandboxProfile.READ_ONLY, ); }, 20000, diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 7e55e0fd3c..f6180539ca 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -11,6 +11,7 @@ import crypto from 'node:crypto'; import type { Config } from '../config/config.js'; import { debugLogger } from '../index.js'; import { ToolErrorType } from './tool-error.js'; +import { ApprovalMode } from '../policy/types.js'; import { BaseDeclarativeTool, BaseToolInvocation, @@ -32,6 +33,7 @@ import { type ShellOutputEvent, } from '../services/shellExecutionService.js'; import { SandboxProfile } from '../services/sandboxManager.js'; +import { ExecPolicyEngine } from '../services/execPolicyEngine.js'; import { formatBytes } from '../utils/formatters.js'; import type { AnsiOutput } from '../utils/terminalSerializer.js'; import { @@ -45,19 +47,18 @@ import { SHELL_TOOL_NAME } from './tool-names.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; import { getShellDefinition } from './definitions/coreTools.js'; import { resolveToolDeclaration } from './definitions/resolver.js'; -import { promptSandboxExpansion } from '../utils/sandbox-prompt.js'; +import { promptSandboxExpansion, addToSandboxingToml } from '../utils/sandbox-prompt.js'; export const OUTPUT_UPDATE_INTERVAL_MS = 1000; // Delay so user does not see the output of the process before the process is moved to the background. const BACKGROUND_DELAY_MS = 200; - export interface ShellToolParams { command: string; description?: string; dir_path?: string; is_background?: boolean; - sandbox_ephemeral_rules?: string[]; + required_sandbox_paths?: string[]; } export class ShellToolInvocation extends BaseToolInvocation< @@ -122,14 +123,14 @@ export class ShellToolInvocation extends BaseToolInvocation< ): Promise { const command = stripShellWrapper(this.params.command); - const parsed = await parseCommandDetails(command); + const parsed = parseCommandDetails(command); let rootCommandDisplay = ''; if (!parsed || parsed.hasError || parsed.details.length === 0) { // Fallback if parser fails const fallback = command.trim().split(/\s+/)[0]; rootCommandDisplay = fallback || 'shell command'; - if (await hasRedirection(command)) { + if (hasRedirection(command)) { rootCommandDisplay += ', redirection'; } } else { @@ -138,9 +139,9 @@ export class ShellToolInvocation extends BaseToolInvocation< .join(', '); } - const rootCommands = await getCommandRoots(command); + const rootCommands = getCommandRoots(command); const uniqueRootCommands = [...new Set(rootCommands)]; - const redirection = await hasRedirection(command); + const redirection = hasRedirection(command); // Rely entirely on PolicyEngine for interactive confirmation. // If we are here, it means PolicyEngine returned ASK_USER (or no message bus), @@ -234,9 +235,21 @@ export class ShellToolInvocation extends BaseToolInvocation< once: true, }); - let ephemeralRules: string[] = this.params.sandbox_ephemeral_rules ? this.params.sandbox_ephemeral_rules.map(r => r.startsWith('(') ? r : `(allow file-read* file-write* (subpath "${r}"))`) : []; + const ephemeralRules: string[] = []; + const previouslyPromptedTargets = new Set(); + const approvedPaths: string[] = []; + let isAlwaysAllow = false; let result; + const configDir = path.join(cwd, '.gemini'); + const policyEngine = new ExecPolicyEngine(configDir); + let evaluatedProfile = policyEngine.getProfileForCommand(commandToExecute); + + const mode = this.config.getApprovalMode(); + if (mode === ApprovalMode.AUTO_EDIT || mode === ApprovalMode.YOLO) { + evaluatedProfile = SandboxProfile.WORKSPACE_WRITE; + } + while (true) { // Start timeout resetTimeout(); @@ -295,7 +308,7 @@ export class ShellToolInvocation extends BaseToolInvocation< shellExecutionConfig?.sanitizationConfig ?? this.config.sanitizationConfig, }, - SandboxProfile.WORKSPACE_WRITE, + evaluatedProfile, ephemeralRules, ); @@ -314,30 +327,153 @@ export class ShellToolInvocation extends BaseToolInvocation< result = await resultPromise; + if (result.aborted) { + break; + } + if (result.output.includes('[Sandbox Violation Detected]')) { let blockedPath = null; - let match = result.output.match(/\[Sandbox Violation Detected\]:.*?deny\(\d+\) [^\s]+ (.*)/); - if (match) { + const match = result.output.match(/\[Sandbox Violation Detected\]:?\s*(.*)?/); + if (match && match[1]) { blockedPath = match[1].trim(); - } else { - match = result.output.match(/\[Sandbox Violation Detected\]: EPERM (.*)/); - if (match) { - blockedPath = match[1].trim(); + } + + if (blockedPath && this.params.required_sandbox_paths) { + for (const requiredPath of this.params.required_sandbox_paths) { + if (blockedPath.startsWith(requiredPath)) { + blockedPath = requiredPath; + break; + } } } - if (blockedPath) { - const decision = await promptSandboxExpansion(this.messageBus, blockedPath, cwd); - if (decision === 'Allow Once' || decision === 'Always Allow') { - ephemeralRules.push(`(allow file-read* file-write* (subpath "${blockedPath}"))`); - continue; - } else { - return { - llmContent: `Sandbox Violation: The command was blocked from accessing ${blockedPath}. The user denied the sandbox expansion request.\n\nOriginal output:\n${result.output}`, - returnDisplay: `Sandbox Violation: Blocked access to ${blockedPath}. User denied request.` - }; + const fallbackPrefix = commandToExecute.trim().split(/\s+/)[0] || 'command'; + let promptTarget = blockedPath || fallbackPrefix; + + // Loop protection MUST run before auto-approval. + let requiresUnsandboxed = false; + if (previouslyPromptedTargets.has(promptTarget)) { + requiresUnsandboxed = true; + } else { + previouslyPromptedTargets.add(promptTarget); + } + + // Check for auto-approval based on similarity with previously approved paths in this run + let autoApproved = false; + if (!requiresUnsandboxed && blockedPath) { + for (const approved of approvedPaths) { + if (blockedPath === approved) { + continue; // Prevent infinite loop on the exact same path + } + const p1 = approved.split('/').filter(Boolean); + const p2 = blockedPath.split('/').filter(Boolean); + let i = 0; + while (i < p1.length && i < p2.length && p1[i] === p2[i]) { + i++; + } + // If they share at least 3 directories (e.g., /Users/name/folder) + if (i >= 3) { + const commonAncestor = '/' + p1.slice(0, i).join('/'); + ephemeralRules.push(`(allow file-read* file-write* (subpath "${commonAncestor}"))`); + ephemeralRules.push(`(allow file-map-executable (subpath "${commonAncestor}"))`); + approvedPaths.push(commonAncestor); + + try { + const realPath = await fsPromises.realpath(commonAncestor); + if (realPath !== commonAncestor) { + ephemeralRules.push(`(allow file-read* file-write* (subpath "${realPath}"))`); + ephemeralRules.push(`(allow file-map-executable (subpath "${realPath}"))`); + approvedPaths.push(realPath); + } + } catch (err) { + // Ignore if path doesn't exist + } + + if (isAlwaysAllow) { + await addToSandboxingToml(cwd, commonAncestor); + } + autoApproved = true; + break; + } } } + + if (autoApproved) { + evaluatedProfile = SandboxProfile.WORKSPACE_WRITE; + continue; + } + + // If we've already prompted for this target, the sandbox is fundamentally incompatible. We must ask for completely unsandboxed execution. + if (requiresUnsandboxed && evaluatedProfile === SandboxProfile.UNSANDBOXED) { + return { + llmContent: `Sandbox Violation: The command '${promptTarget}' was executed entirely without the sandbox but still failed. The failure is not sandbox-related. Original output:\n${result.output}`, + returnDisplay: `Sandbox Violation: ${promptTarget} failed even when completely un-sandboxed.` + }; + } + + const decision = await promptSandboxExpansion(this.messageBus, promptTarget, cwd, !!blockedPath, requiresUnsandboxed); + + if (decision === 'Allow Once' || decision === 'Always Allow') { + if (decision === 'Always Allow') { + isAlwaysAllow = true; + } + + if (requiresUnsandboxed) { + evaluatedProfile = SandboxProfile.UNSANDBOXED; + if (isAlwaysAllow) { + const tokens = commandToExecute.trim().split(/\s+/); + let prefix: string[] = []; + if (tokens.length >= 2 && !tokens[0].includes('=')) { + prefix = tokens.slice(0, 2); + } else if (tokens.length > 0) { + prefix = [tokens[0]]; + } + if (prefix.length > 0) { + await policyEngine.addRule(prefix, SandboxProfile.UNSANDBOXED); + } + } + continue; + } + + if (blockedPath) { + ephemeralRules.push(`(allow file-read* file-write* (subpath "${blockedPath}"))`); + ephemeralRules.push(`(allow file-map-executable (subpath "${blockedPath}"))`); + approvedPaths.push(blockedPath); + + try { + const realPath = await fsPromises.realpath(blockedPath); + if (realPath !== blockedPath) { + ephemeralRules.push(`(allow file-read* file-write* (subpath "${realPath}"))`); + ephemeralRules.push(`(allow file-map-executable (subpath "${realPath}"))`); + approvedPaths.push(realPath); + } + } catch (err) { + // Ignore if path doesn't exist or can't be resolved + } + } + + if (isAlwaysAllow) { + // Try to add rule to execpolicy to authorize the command itself for the future + const tokens = commandToExecute.trim().split(/\s+/); + let prefix: string[] = []; + if (tokens.length >= 2 && !tokens[0].includes('=')) { + prefix = tokens.slice(0, 2); + } else if (tokens.length > 0) { + prefix = [tokens[0]]; + } + if (prefix.length > 0) { + await policyEngine.addRule(prefix, SandboxProfile.WORKSPACE_WRITE); + } + } + + evaluatedProfile = SandboxProfile.WORKSPACE_WRITE; // Promote to workspace write for retry + continue; + } else { + return { + llmContent: `Sandbox Violation: The sandbox prevented '${promptTarget}' from accessing files outside the workspace. The user denied the sandbox expansion request.\n\nOriginal output:\n${result.output}`, + returnDisplay: `Sandbox Violation: User denied sandbox expansion for ${promptTarget}.` + }; + } } break; } diff --git a/packages/core/src/utils/sandbox-prompt.ts b/packages/core/src/utils/sandbox-prompt.ts index 13853d53c4..f1dafb7763 100644 --- a/packages/core/src/utils/sandbox-prompt.ts +++ b/packages/core/src/utils/sandbox-prompt.ts @@ -1,67 +1,73 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ import crypto from 'node:crypto'; import path from 'node:path'; import fsPromises from 'node:fs/promises'; import toml from '@iarna/toml'; -import { MessageBusType, QuestionType } from '../confirmation-bus/types.js'; +import { MessageBusType } from '../confirmation-bus/types.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; import { debugLogger } from '../index.js'; +import { ToolConfirmationOutcome } from '../tools/tools.js'; + +export async function addToSandboxingToml(cwd: string, allowedPath: string) { + try { + const configDir = path.join(path.resolve(cwd), '.gemini'); + const tomlPath = path.join(configDir, 'sandboxing.toml'); + let parsed: Record = {}; + try { + const content = await fsPromises.readFile(tomlPath, 'utf8'); + parsed = toml.parse(content) as Record; + } catch { + await fsPromises.mkdir(configDir, { recursive: true }); + } + const sandboxSection = (parsed['sandbox'] as Record) || {}; + const allowedPathsList = (sandboxSection['allowedPaths'] as string[]) || []; + if (!allowedPathsList.includes(allowedPath)) { + allowedPathsList.push(allowedPath); + sandboxSection['allowedPaths'] = allowedPathsList; + parsed['sandbox'] = sandboxSection; + await fsPromises.writeFile(tomlPath, toml.stringify(parsed as any)); + } + } catch (e) { + debugLogger.error('Failed to update sandboxing.toml:', e); + } +} export async function promptSandboxExpansion( messageBus: MessageBus, blockedPath: string, - cwd: string + cwd: string, + saveToSandboxingToml: boolean = true, + requiresUnsandboxed: boolean = false ): Promise<'Allow Once' | 'Always Allow' | 'Deny'> { const decision = await new Promise<'Allow Once' | 'Always Allow' | 'Deny'>((resolve) => { const correlationId = crypto.randomUUID(); - const handler = (msg: any) => { - if (msg.type === MessageBusType.ASK_USER_RESPONSE && msg.correlationId === correlationId) { - messageBus.unsubscribe(MessageBusType.ASK_USER_RESPONSE, handler); - if (msg.cancelled || !msg.answers['0']) { - resolve('Deny'); + const handler = (msg: unknown) => { + if (msg && typeof msg === 'object' && 'type' in msg && msg.type === MessageBusType.TOOL_CONFIRMATION_RESPONSE && 'correlationId' in msg && msg.correlationId === correlationId) { + messageBus.unsubscribe(MessageBusType.TOOL_CONFIRMATION_RESPONSE, handler); + const m = msg as any; + if (m.outcome === ToolConfirmationOutcome.ProceedOnce) { + resolve('Allow Once'); + } else if (m.outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave) { + resolve('Always Allow'); } else { - resolve(msg.answers['0'] as 'Allow Once' | 'Always Allow' | 'Deny'); + resolve('Deny'); } } }; - messageBus.subscribe(MessageBusType.ASK_USER_RESPONSE, handler); - messageBus.publish({ - type: MessageBusType.ASK_USER_REQUEST, + messageBus.subscribe(MessageBusType.TOOL_CONFIRMATION_RESPONSE, handler); + void messageBus.publish({ + type: MessageBusType.SANDBOX_EXPANSION_REQUEST, correlationId, - questions: [{ - type: QuestionType.CHOICE, - header: `Sandbox blocked access to ${blockedPath}.`, - question: `The sandbox prevented this command from accessing a file outside the workspace.`, - options: [ - { label: 'Allow Once', description: 'Temporarily allow for this execution.' }, - { label: 'Always Allow', description: 'Permanently allow for future executions.' }, - { label: 'Deny', description: 'Do not allow access.' }, - ] - }] + blockedPath, }); }); - if (decision === 'Always Allow') { - try { - const configDir = path.join(path.resolve(cwd), '.gemini'); - const tomlPath = path.join(configDir, 'sandboxing.toml'); - let parsed: Record = {}; - try { - const content = await fsPromises.readFile(tomlPath, 'utf8'); - parsed = toml.parse(content) as Record; - } catch { - await fsPromises.mkdir(configDir, { recursive: true }); - } - const sandboxSection = (parsed['sandbox'] as Record) || {}; - const allowedPathsList = (sandboxSection['allowedPaths'] as string[]) || []; - if (!allowedPathsList.includes(blockedPath)) { - allowedPathsList.push(blockedPath); - sandboxSection['allowedPaths'] = allowedPathsList; - parsed['sandbox'] = sandboxSection; - await fsPromises.writeFile(tomlPath, toml.stringify(parsed as any)); - } - } catch (e) { - debugLogger.error('Failed to update sandboxing.toml:', e); - } + if (decision === 'Always Allow' && saveToSandboxingToml) { + await addToSandboxingToml(cwd, blockedPath); } return decision; diff --git a/packages/core/src/utils/shell-utils.ts b/packages/core/src/utils/shell-utils.ts index df0fe27027..a8acfdd7bd 100644 --- a/packages/core/src/utils/shell-utils.ts +++ b/packages/core/src/utils/shell-utils.ts @@ -19,10 +19,10 @@ import * as readline from 'node:readline'; import { Language, Parser, Query, type Node, type Tree } from 'web-tree-sitter'; import { loadWasmBinary } from './fileUtils.js'; import { debugLogger } from './debugLogger.js'; -import { - type SandboxManager, - type SandboxedCommand, +import type { SandboxProfile, + SandboxManager, + SandboxedCommand } from '../services/sandboxManager.js'; export const SHELL_TOOL_NAMES = ['run_shell_command', 'ShellTool'];