use agent_client_protocol::schema::v1 as acp;use anyhow::Result;use futures::FutureExt as _;use gpui::{App, AsyncApp, Entity, SharedString, Task};use project::Project;use schemars::JsonSchema;use serde::{Deserialize, Serialize};use settings::Settings;use std::{ path::{Path, PathBuf}, rc::Rc, sync::Arc, time::Duration,}; #[cfg(any(target_os = "linux", target_os = "windows"))]use crate::SandboxFallbackDecision;use crate::sandboxing::{ NetworkRequest, sandbox_git_dirs, sandbox_worktree_writable_paths, sandboxing_enabled_for_project,};use crate::{AgentTool, ThreadEnvironment, ToolCallEventStream, ToolInput}; const COMMAND_OUTPUT_LIMIT: u64 = 16 * 1024; /// Executes a shell one-liner and returns the combined output.////// This tool spawns a process using the user's shell, reads from stdout and stderr (preserving the order of writes), and returns a string with the combined output result.////// The output results will be shown to the user already, only list it again if necessary, avoid being redundant.////// Make sure you use the `cd` parameter to navigate to one of the root directories of the project. NEVER do it as part of the `command` itself, otherwise it will error.////// Do not generate terminal commands that use shell substitutions or interpolations such as `$VAR`, `${VAR}`, `$(...)`, backticks, `$((...))`, `<(...)`, or `>(...)`. Resolve those values yourself before calling this tool, or ask the user for the literal value to use.////// Do not pipe output to `head`, `tail`, or similar output-filtering commands just to reduce what you receive. Instead, use `head_lines` and/or `tail_lines`; this keeps the terminal output visible to the user in real time while limiting only the final output sent back to you. When both are specified, the first `head_lines` lines are returned, then a blank line, then the last `tail_lines` lines. Avoid requesting too many lines, or the response may waste tokens or exceed the context window.////// Do not use this tool for commands that run indefinitely, such as servers (like `npm run start`, `npm run dev`, `python -m http.server`, etc) or file watchers that don't terminate on their own.////// For potentially long-running commands, prefer specifying `timeout_ms` to bound runtime and prevent indefinite hangs.////// Remember that each invocation of this tool will spawn a new shell process, so you can't rely on any state from previous invocations.////// The terminal is an interactive pty, so any command that blocks waiting for input will hang the tool until it times out. To avoid this:////// - Always insert `--no-pager` immediately after `git` for any read-only git command, including `git log`, `git diff`, `git show`, `git blame`, and `git stash show`. Example: `git --no-pager log -n 5` (NOT `git log -n 5`)./// - Prefer Git flags that avoid optional metadata writes when possible, such as `git --no-optional-locks status` instead of `git status`./// - Always prepend `GIT_EDITOR=true ` to any git command that may invoke an editor, including `git rebase`, `git commit`, `git merge`, and `git tag`. Example: `GIT_EDITOR=true git rebase origin/main` (NOT `git rebase origin/main`)./// - For other commands that may open a pager or editor, set `PAGER=cat` and/or `EDITOR=true` similarly.#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]pub struct TerminalToolInput { /// The one-liner command to execute. Do not include shell substitutions or interpolations such as `$VAR`, `${VAR}`, `$(...)`, backticks, `$((...))`, `<(...)`, or `>(...)`; resolve those values first or ask the user for the literal value to use. /// /// REMINDER: read-only git commands (`git log`, `git diff`, `git show`, `git blame`) MUST include `--no-pager` (e.g. `git --no-pager log`). Prefer `git --no-optional-locks status` over `git status` to avoid optional metadata writes. Git commands that may open an editor (`git rebase`, `git commit`, `git merge`, `git tag`) MUST be prefixed with `GIT_EDITOR=true ` (e.g. `GIT_EDITOR=true git rebase origin/main`). Otherwise the terminal will hang. pub command: String, /// Working directory for the command. This must be one of the root directories of the project. pub cd: String, /// Optional maximum runtime (in milliseconds). If exceeded, the running terminal task is killed. pub timeout_ms: Option<u64>, /// Return only the first N lines of terminal output to the model after the command finishes. Do not pipe output to `head`; use this parameter instead so the user can still see live output. Avoid requesting too many lines, or the response may waste tokens or exceed the context window. #[serde(default)] pub head_lines: Option<usize>, /// Return only the last N lines of terminal output to the model after the command finishes. Do not pipe output to `tail`; use this parameter instead so the user can still see live output. Avoid requesting too many lines, or the response may waste tokens or exceed the context window. #[serde(default)] pub tail_lines: Option<usize>,} /// Executes a shell one-liner and returns the combined output.////// This tool spawns a process using the user's shell, reads from stdout and stderr (preserving the order of writes), and returns a string with the combined output result.////// The output results will be shown to the user already, only list it again if necessary, avoid being redundant.////// Make sure you use the `cd` parameter to navigate to one of the root directories of the project. NEVER do it as part of the `command` itself, otherwise it will error.////// Do not generate terminal commands that use shell substitutions or interpolations such as `$VAR`, `${VAR}`, `$(...)`, backticks, `$((...))`, `<(...)`, or `>(...)`. Resolve those values first or ask the user for the literal value to use.////// Do not pipe output to `head`, `tail`, or similar output-filtering commands just to reduce what you receive. Instead, use `head_lines` and/or `tail_lines`; this keeps the terminal output visible to the user in real time while limiting only the final output sent back to you. When both are specified, the first `head_lines` lines are returned, then a blank line, then the last `tail_lines` lines. Avoid requesting too many lines, or the response may waste tokens or exceed the context window.////// Do not use this tool for commands that run indefinitely, such as servers (like `npm run start`, `npm run dev`, `python -m http.server`, etc) or file watchers that don't terminate on their own.////// For potentially long-running commands, prefer specifying `timeout_ms` to bound runtime and prevent indefinite hangs.////// Remember that each invocation of this tool will spawn a new shell process, so you can't rely on any state from previous invocations.////// The terminal is an interactive pty, so any command that blocks waiting for input will hang the tool until it times out. To avoid this:////// - Always insert `--no-pager` immediately after `git` for any read-only git command, including `git log`, `git diff`, `git show`, `git blame`, and `git stash show`. Example: `git --no-pager log -n 5` (NOT `git log -n 5`)./// - Prefer Git flags that avoid optional metadata writes when possible, such as `git --no-optional-locks status` instead of `git status`./// - Always prepend `GIT_EDITOR=true ` to any git command that may invoke an editor, including `git rebase`, `git commit`, `git merge`, and `git tag`. Example: `GIT_EDITOR=true git rebase origin/main` (NOT `git rebase origin/main`)./// - For other commands that may open a pager or editor, set `PAGER=cat` and/or `EDITOR=true` similarly.#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]pub struct SandboxedTerminalToolInput { /// The one-liner command to execute. Do not include shell substitutions or interpolations such as `$VAR`, `${VAR}`, `$(...)`, backticks, `$((...))`, `<(...)`, or `>(...)`; resolve those values first or ask the user for the literal value to use. /// /// REMINDER: read-only git commands (`git log`, `git diff`, `git show`, `git blame`) MUST include `--no-pager` (e.g. `git --no-pager log`). Prefer `git --no-optional-locks status` over `git status` to avoid optional metadata writes. Git commands that may open an editor (`git rebase`, `git commit`, `git merge`, `git tag`) MUST be prefixed with `GIT_EDITOR=true ` (e.g. `GIT_EDITOR=true git rebase origin/main`). Otherwise the terminal will hang. pub command: String, /// Working directory for the command. This must be one of the root directories of the project. pub cd: String, /// Optional maximum runtime (in milliseconds). If exceeded, the running terminal task is killed. pub timeout_ms: Option<u64>, /// Return only the first N lines of terminal output to the model after the command finishes. Do not pipe output to `head`; use this parameter instead so the user can still see live output. Avoid requesting too many lines, or the response may waste tokens or exceed the context window. #[serde(default)] pub head_lines: Option<usize>, /// Return only the last N lines of terminal output to the model after the command finishes. Do not pipe output to `tail`; use this parameter instead so the user can still see live output. Avoid requesting too many lines, or the response may waste tokens or exceed the context window. #[serde(default)] pub tail_lines: Option<usize>, /// Hosts the command needs outbound network access to. /// /// Sandboxed commands cannot reach the network by default. List the hosts /// the command needs (e.g. `["github.com", "*.npmjs.org"]`) when running /// commands that fetch or upload (installing dependencies, cloning, /// pushing, downloading, etc.). Each entry must be a hostname or a /// leading-`*.` subdomain wildcard; IP literals and other wildcards are /// rejected. Requesting network access triggers a user approval prompt, so /// only list hosts you expect the command to need. #[cfg_attr( any(target_os = "macos", target_os = "linux"), doc = "\nHost-specific access is enforced by an HTTP/HTTPS proxy, so use \ `https://` URLs rather than `git@`/`ssh://`." )] #[cfg_attr( target_os = "windows", doc = "\nNOTE: on Windows the sandbox cannot currently restrict network \ access to specific hosts. Do not set `allow_hosts` on Windows; request \ `allow_all_hosts: true` if the command needs network access, or omit \ network permissions entirely." )] #[serde(default)] pub allow_hosts: Vec<String>, /// Set to `true` only if the command needs outbound network access to /// hosts you can't enumerate up front. /// /// This grants unrestricted outbound network access. On platforms that /// support host-specific grants, prefer `allow_hosts` with specific /// hostnames whenever possible, so the user knows what's being approved. /// Requesting it triggers a user approval prompt. #[serde(default)] pub allow_all_hosts: Option<bool>, /// Paths the command needs to write to outside the default-writable /// locations. /// #[cfg_attr( target_os = "macos", doc = "Sandboxed commands can already write to the project worktree \ directories and a per-thread temporary directory (exposed via \ `$TMPDIR`), so only list paths outside those." )] /// Provide absolute or worktree-relative paths; each /// directory grants write access to its whole subtree. Prefer this over /// `allow_fs_write_all` whenever you can enumerate the paths. Requesting /// paths triggers a user approval prompt. Git metadata paths cannot be /// requested and will never be made writable while sandboxed. #[cfg_attr( target_os = "linux", doc = "\nOn Linux, every path here must be a directory that already exists. \ Requesting a file, or a path that does not exist yet, is an error. To create new \ files, request write access to the existing directory that will contain them." )] #[cfg_attr( target_os = "windows", doc = "\nEvery path here must be an existing directory, given as a Windows drive \ path (`C:\\...`) or a WSL absolute path (`/...`); a path that does not exist \ cannot be granted. To write somewhere new, request write access to the nearest \ existing parent directory." )] #[serde(default)] pub fs_write_paths: Vec<String>, /// Set to `true` only when the command needs to write outside the /// default-writable locations but the specific paths cannot be /// enumerated up front. /// /// This is a broad escape hatch — prefer `fs_write_paths` whenever the /// set of paths is known. Protected Git metadata remains read-only. /// Requesting it triggers a user approval prompt. #[serde(default, alias = "allow_fs_write")] pub allow_fs_write_all: Option<bool>, /// Set to `true` only as a last resort, to run the command fully outside /// the sandbox. /// /// First try the narrower options (`allow_hosts`, `fs_write_paths`, or /// `allow_fs_write_all`); use this only when the command /// needs behavior the sandbox can't grant on a per-permission basis, /// including commands that must write Git metadata. /// Requesting it triggers a user approval prompt. #[cfg_attr( target_os = "windows", doc = "\nOn Windows, running unsandboxed also switches the shell. Sandboxed \ commands run under WSL's Linux bash; an unsandboxed command instead runs in the \ host's default shell — Git Bash (or scoop's bash) when one is installed, otherwise \ PowerShell/cmd. Path conventions change accordingly (e.g. `C:\\...` or `/c/...` \ rather than WSL's `/mnt/c/...`), so a command written for the sandboxed shell may \ behave differently here." )] #[serde(default)] pub unsandboxed: Option<bool>, /// A short justification for why this command needs the sandbox /// permission(s) it requests (`allow_hosts`, `allow_all_hosts`, /// `fs_write_paths`, `allow_fs_write_all`, or `unsandboxed`). /// /// Required whenever you request any of those permissions; omit it for /// ordinary commands that request none. Write it in your own voice — it /// is shown to the user, attributed to you, when they're asked to approve /// the request. #[serde(default)] pub reason: Option<String>,} #[derive(Clone, Debug, Default)]struct TerminalSandboxInput { allow_hosts: Vec<String>, allow_all_hosts: Option<bool>, fs_write_paths: Vec<String>, allow_fs_write_all: Option<bool>, unsandboxed: Option<bool>, reason: Option<String>,} struct TerminalToolRequest { command: String, cd: String, timeout_ms: Option<u64>, selection: TerminalOutputSelection, sandbox: Option<TerminalSandboxInput>,} impl From<TerminalToolInput> for TerminalToolRequest { fn from(input: TerminalToolInput) -> Self { Self { command: input.command, cd: input.cd, timeout_ms: input.timeout_ms, selection: TerminalOutputSelection { head_lines: input.head_lines, tail_lines: input.tail_lines, }, sandbox: None, } }} impl From<SandboxedTerminalToolInput> for TerminalToolRequest { fn from(input: SandboxedTerminalToolInput) -> Self { Self { command: input.command, cd: input.cd, timeout_ms: input.timeout_ms, selection: TerminalOutputSelection { head_lines: input.head_lines, tail_lines: input.tail_lines, }, sandbox: Some(TerminalSandboxInput { allow_hosts: input.allow_hosts, allow_all_hosts: input.allow_all_hosts, fs_write_paths: input.fs_write_paths, allow_fs_write_all: input.allow_fs_write_all, unsandboxed: input.unsandboxed, reason: input.reason, }), } }} pub struct TerminalTool { project: Entity<Project>, environment: Rc<dyn ThreadEnvironment>,} impl TerminalTool { pub fn new(project: Entity<Project>, environment: Rc<dyn ThreadEnvironment>) -> Self { Self { project, environment, } }} pub struct SandboxedTerminalTool { project: Entity<Project>, environment: Rc<dyn ThreadEnvironment>,} impl SandboxedTerminalTool { pub fn new(project: Entity<Project>, environment: Rc<dyn ThreadEnvironment>) -> Self { Self { project, environment, } }} impl AgentTool for TerminalTool { type Input = TerminalToolInput; type Output = String; const NAME: &'static str = "terminal"; fn kind() -> acp::ToolKind { acp::ToolKind::Execute } fn allow_in_restricted_mode() -> bool { false } fn initial_title( &self, input: Result<Self::Input, serde_json::Value>, _cx: &mut App, ) -> SharedString { terminal_initial_title(input.map(|input| input.command)) } fn run( self: Arc<Self>, input: ToolInput<Self::Input>, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task<Result<Self::Output, Self::Output>> { cx.spawn(async move |cx| { let input = input.recv().await.map_err(|e| e.to_string())?; run_terminal_tool( self.project.clone(), self.environment.clone(), input.into(), event_stream, cx, ) .await }) }} impl AgentTool for SandboxedTerminalTool { type Input = SandboxedTerminalToolInput; type Output = String; const NAME: &'static str = "sandboxed_terminal"; fn kind() -> acp::ToolKind { acp::ToolKind::Execute } fn allow_in_restricted_mode() -> bool { false } fn initial_title( &self, input: Result<Self::Input, serde_json::Value>, _cx: &mut App, ) -> SharedString { terminal_initial_title(input.map(|input| input.command)) } fn run( self: Arc<Self>, input: ToolInput<Self::Input>, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task<Result<Self::Output, Self::Output>> { cx.spawn(async move |cx| { let input = input.recv().await.map_err(|e| e.to_string())?; run_terminal_tool( self.project.clone(), self.environment.clone(), input.into(), event_stream, cx, ) .await }) }} fn terminal_initial_title(input: Result<String, serde_json::Value>) -> SharedString { if let Ok(command) = input { command.into() } else { "".into() }} /// Windows only: resolve the `(release channel, version)` of the Linux `zed` to/// provision inside WSL as the sandbox helper. Dev (source) builds have no/// matching release, so they pull the latest nightly. Nightly builds also track/// `latest`: nightly assets are keyed by their full build metadata/// (`X.Y.Z+nightly.<n>.<sha>`), which `AppVersion` strips, so a bare `X.Y.Z`/// never resolves on the nightly host. Preview and stable pin their exact/// running version (stripped of pre-release/build metadata, which the release/// API doesn't key on).#[cfg(target_os = "windows")]fn wsl_zed_release(cx: &App) -> Option<(String, String)> { use release_channel::{AppVersion, ReleaseChannel}; match *release_channel::RELEASE_CHANNEL { ReleaseChannel::Dev | ReleaseChannel::Nightly => { Some(("nightly".to_string(), "latest".to_string())) } channel => { let version = AppVersion::global(cx); Some(( channel.dev_name().to_string(), format!("{}.{}.{}", version.major, version.minor, version.patch), )) } }} /// Non-Windows platforms don't route through WSL, so there's no helper to fetch.#[cfg(not(target_os = "windows"))]fn wsl_zed_release(_cx: &App) -> Option<(String, String)> { None} async fn run_terminal_tool( project: Entity<Project>, environment: Rc<dyn ThreadEnvironment>, input: TerminalToolRequest, event_stream: ToolCallEventStream, cx: &mut AsyncApp,) -> Result<String, String> { let selection = input.selection; let sandbox_input = input.sandbox.clone().unwrap_or_default(); let (working_dir, authorize, sandboxing, is_local_project, wsl_zed_release) = cx.update(|cx| { let working_dir = working_dir(&input.cd, &project, cx).map_err(|err| err.to_string())?; let context = crate::ToolPermissionContext::new(TerminalTool::NAME, vec![input.command.clone()]); let authorize = event_stream.authorize(SharedString::new(input.command.clone()), context, cx); let sandboxing = input.sandbox.is_some() && sandboxing_enabled_for_project(project.read(cx), cx); let is_local_project = project.read(cx).is_local(); let wsl_zed_release = wsl_zed_release(cx); Result::<_, String>::Ok(( working_dir, authorize, sandboxing, is_local_project, wsl_zed_release, )) })?; authorize.await.map_err(|e| e.to_string())?; let want_fs_write_all = sandboxing && sandbox_input.allow_fs_write_all == Some(true); let want_unsandboxed = sandboxing && sandbox_input.unsandboxed == Some(true); let want_all_hosts = sandboxing && sandbox_input.allow_all_hosts == Some(true); let persistent = cx.update(|cx| { agent_settings::AgentSettings::get_global(cx) .sandbox_permissions .clone() }); // Standing permissions the user already approved — in settings or "for this // thread" — that every command in the thread inherits and that the model // cannot narrow. The actually-enforced policy is always at least this // permissive, so a request asking for something *more* restrictive would be // silently widened to the floor and mislead the model about its real access. // Reject such requests with an explanation instead of running them. let floor = event_stream .effective_sandbox_request(&crate::sandboxing::SandboxRequest::default(), &persistent); let unsandboxed_floor = sandboxing && (event_stream.unsandboxed_granted_for_thread() || event_stream.sandbox_fallback_granted_for_thread()); let fs_unrestricted_floor = sandboxing && floor.allow_fs_write_all; let net_unrestricted_floor = sandboxing && matches!(floor.network, NetworkRequest::AnyHost); if sandboxing && !want_unsandboxed { if unsandboxed_floor { // The user turned the sandbox off for this thread, so every command // runs without one and no sandbox-scoping field can take effect. // Name exactly which ones the model set so it can drop them. let mut ineffective = Vec::new(); if !sandbox_input.allow_hosts.is_empty() { ineffective.push("`allow_hosts`"); } if sandbox_input.allow_all_hosts == Some(true) { ineffective.push("`allow_all_hosts`"); } if !sandbox_input.fs_write_paths.is_empty() { ineffective.push("`fs_write_paths`"); } if sandbox_input.allow_fs_write_all == Some(true) { ineffective.push("`allow_fs_write_all`"); } if !ineffective.is_empty() { return Err(format!( "Sandboxing is disabled for this thread, so every command runs without an OS \ sandbox and these fields have no effect: {}. Remove them and rerun the \ command (it will run unsandboxed), or pass `unsandboxed: true` to acknowledge \ it runs without a sandbox.", ineffective.join(", "), )); } } else {Lines 1–500 of 3642. Load more
use agent_client_protocol::schema::v1 as acp; use anyhow::Result; use futures::FutureExt as _; use gpui::{App, AsyncApp, Entity, SharedString, Task}; use project::Project; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::Settings; use std::{ path::{Path, PathBuf}, rc::Rc, sync::Arc, time::Duration, }; #[cfg(any(target_os = "linux", target_os = "windows"))] use crate::SandboxFallbackDecision; use crate::sandboxing::{ NetworkRequest, sandbox_git_dirs, sandbox_worktree_writable_paths, sandboxing_enabled_for_project, }; use crate::{AgentTool, ThreadEnvironment, ToolCallEventStream, ToolInput}; const COMMAND_OUTPUT_LIMIT: u64 = 16 * 1024; /// Executes a shell one-liner and returns the combined output. /// /// This tool spawns a process using the user's shell, reads from stdout and stderr (preserving the order of writes), and returns a string with the combined output result. /// /// The output results will be shown to the user already, only list it again if necessary, avoid being redundant. /// /// Make sure you use the `cd` parameter to navigate to one of the root directories of the project. NEVER do it as part of the `command` itself, otherwise it will error. /// /// Do not generate terminal commands that use shell substitutions or interpolations such as `$VAR`, `${VAR}`, `$(...)`, backticks, `$((...))`, `<(...)`, or `>(...)`. Resolve those values yourself before calling this tool, or ask the user for the literal value to use. /// /// Do not pipe output to `head`, `tail`, or similar output-filtering commands just to reduce what you receive. Instead, use `head_lines` and/or `tail_lines`; this keeps the terminal output visible to the user in real time while limiting only the final output sent back to you. When both are specified, the first `head_lines` lines are returned, then a blank line, then the last `tail_lines` lines. Avoid requesting too many lines, or the response may waste tokens or exceed the context window. /// /// Do not use this tool for commands that run indefinitely, such as servers (like `npm run start`, `npm run dev`, `python -m http.server`, etc) or file watchers that don't terminate on their own. /// /// For potentially long-running commands, prefer specifying `timeout_ms` to bound runtime and prevent indefinite hangs. /// /// Remember that each invocation of this tool will spawn a new shell process, so you can't rely on any state from previous invocations. /// /// The terminal is an interactive pty, so any command that blocks waiting for input will hang the tool until it times out. To avoid this: /// /// - Always insert `--no-pager` immediately after `git` for any read-only git command, including `git log`, `git diff`, `git show`, `git blame`, and `git stash show`. Example: `git --no-pager log -n 5` (NOT `git log -n 5`). /// - Prefer Git flags that avoid optional metadata writes when possible, such as `git --no-optional-locks status` instead of `git status`. /// - Always prepend `GIT_EDITOR=true ` to any git command that may invoke an editor, including `git rebase`, `git commit`, `git merge`, and `git tag`. Example: `GIT_EDITOR=true git rebase origin/main` (NOT `git rebase origin/main`). /// - For other commands that may open a pager or editor, set `PAGER=cat` and/or `EDITOR=true` similarly. #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] pub struct TerminalToolInput { /// The one-liner command to execute. Do not include shell substitutions or interpolations such as `$VAR`, `${VAR}`, `$(...)`, backticks, `$((...))`, `<(...)`, or `>(...)`; resolve those values first or ask the user for the literal value to use. /// /// REMINDER: read-only git commands (`git log`, `git diff`, `git show`, `git blame`) MUST include `--no-pager` (e.g. `git --no-pager log`). Prefer `git --no-optional-locks status` over `git status` to avoid optional metadata writes. Git commands that may open an editor (`git rebase`, `git commit`, `git merge`, `git tag`) MUST be prefixed with `GIT_EDITOR=true ` (e.g. `GIT_EDITOR=true git rebase origin/main`). Otherwise the terminal will hang. pub command: String, /// Working directory for the command. This must be one of the root directories of the project. pub cd: String, /// Optional maximum runtime (in milliseconds). If exceeded, the running terminal task is killed. pub timeout_ms: Option<u64>, /// Return only the first N lines of terminal output to the model after the command finishes. Do not pipe output to `head`; use this parameter instead so the user can still see live output. Avoid requesting too many lines, or the response may waste tokens or exceed the context window. #[serde(default)] pub head_lines: Option<usize>, /// Return only the last N lines of terminal output to the model after the command finishes. Do not pipe output to `tail`; use this parameter instead so the user can still see live output. Avoid requesting too many lines, or the response may waste tokens or exceed the context window. #[serde(default)] pub tail_lines: Option<usize>, } /// Executes a shell one-liner and returns the combined output. /// /// This tool spawns a process using the user's shell, reads from stdout and stderr (preserving the order of writes), and returns a string with the combined output result. /// /// The output results will be shown to the user already, only list it again if necessary, avoid being redundant. /// /// Make sure you use the `cd` parameter to navigate to one of the root directories of the project. NEVER do it as part of the `command` itself, otherwise it will error. /// /// Do not generate terminal commands that use shell substitutions or interpolations such as `$VAR`, `${VAR}`, `$(...)`, backticks, `$((...))`, `<(...)`, or `>(...)`. Resolve those values first or ask the user for the literal value to use. /// /// Do not pipe output to `head`, `tail`, or similar output-filtering commands just to reduce what you receive. Instead, use `head_lines` and/or `tail_lines`; this keeps the terminal output visible to the user in real time while limiting only the final output sent back to you. When both are specified, the first `head_lines` lines are returned, then a blank line, then the last `tail_lines` lines. Avoid requesting too many lines, or the response may waste tokens or exceed the context window. /// /// Do not use this tool for commands that run indefinitely, such as servers (like `npm run start`, `npm run dev`, `python -m http.server`, etc) or file watchers that don't terminate on their own. /// /// For potentially long-running commands, prefer specifying `timeout_ms` to bound runtime and prevent indefinite hangs. /// /// Remember that each invocation of this tool will spawn a new shell process, so you can't rely on any state from previous invocations. /// /// The terminal is an interactive pty, so any command that blocks waiting for input will hang the tool until it times out. To avoid this: /// /// - Always insert `--no-pager` immediately after `git` for any read-only git command, including `git log`, `git diff`, `git show`, `git blame`, and `git stash show`. Example: `git --no-pager log -n 5` (NOT `git log -n 5`). /// - Prefer Git flags that avoid optional metadata writes when possible, such as `git --no-optional-locks status` instead of `git status`. /// - Always prepend `GIT_EDITOR=true ` to any git command that may invoke an editor, including `git rebase`, `git commit`, `git merge`, and `git tag`. Example: `GIT_EDITOR=true git rebase origin/main` (NOT `git rebase origin/main`). /// - For other commands that may open a pager or editor, set `PAGER=cat` and/or `EDITOR=true` similarly. #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] pub struct SandboxedTerminalToolInput { /// The one-liner command to execute. Do not include shell substitutions or interpolations such as `$VAR`, `${VAR}`, `$(...)`, backticks, `$((...))`, `<(...)`, or `>(...)`; resolve those values first or ask the user for the literal value to use. /// /// REMINDER: read-only git commands (`git log`, `git diff`, `git show`, `git blame`) MUST include `--no-pager` (e.g. `git --no-pager log`). Prefer `git --no-optional-locks status` over `git status` to avoid optional metadata writes. Git commands that may open an editor (`git rebase`, `git commit`, `git merge`, `git tag`) MUST be prefixed with `GIT_EDITOR=true ` (e.g. `GIT_EDITOR=true git rebase origin/main`). Otherwise the terminal will hang. pub command: String, /// Working directory for the command. This must be one of the root directories of the project. pub cd: String, /// Optional maximum runtime (in milliseconds). If exceeded, the running terminal task is killed. pub timeout_ms: Option<u64>, /// Return only the first N lines of terminal output to the model after the command finishes. Do not pipe output to `head`; use this parameter instead so the user can still see live output. Avoid requesting too many lines, or the response may waste tokens or exceed the context window. #[serde(default)] pub head_lines: Option<usize>, /// Return only the last N lines of terminal output to the model after the command finishes. Do not pipe output to `tail`; use this parameter instead so the user can still see live output. Avoid requesting too many lines, or the response may waste tokens or exceed the context window. #[serde(default)] pub tail_lines: Option<usize>, /// Hosts the command needs outbound network access to. /// /// Sandboxed commands cannot reach the network by default. List the hosts /// the command needs (e.g. `["github.com", "*.npmjs.org"]`) when running /// commands that fetch or upload (installing dependencies, cloning, /// pushing, downloading, etc.). Each entry must be a hostname or a /// leading-`*.` subdomain wildcard; IP literals and other wildcards are /// rejected. Requesting network access triggers a user approval prompt, so /// only list hosts you expect the command to need. #[cfg_attr( any(target_os = "macos", target_os = "linux"), doc = "\nHost-specific access is enforced by an HTTP/HTTPS proxy, so use \ `https://` URLs rather than `git@`/`ssh://`." )] #[cfg_attr( target_os = "windows", doc = "\nNOTE: on Windows the sandbox cannot currently restrict network \ access to specific hosts. Do not set `allow_hosts` on Windows; request \ `allow_all_hosts: true` if the command needs network access, or omit \ network permissions entirely." )] #[serde(default)] pub allow_hosts: Vec<String>, /// Set to `true` only if the command needs outbound network access to /// hosts you can't enumerate up front. /// /// This grants unrestricted outbound network access. On platforms that /// support host-specific grants, prefer `allow_hosts` with specific /// hostnames whenever possible, so the user knows what's being approved. /// Requesting it triggers a user approval prompt. #[serde(default)] pub allow_all_hosts: Option<bool>, /// Paths the command needs to write to outside the default-writable /// locations. /// #[cfg_attr( target_os = "macos", doc = "Sandboxed commands can already write to the project worktree \ directories and a per-thread temporary directory (exposed via \ `$TMPDIR`), so only list paths outside those." )] /// Provide absolute or worktree-relative paths; each /// directory grants write access to its whole subtree. Prefer this over /// `allow_fs_write_all` whenever you can enumerate the paths. Requesting /// paths triggers a user approval prompt. Git metadata paths cannot be /// requested and will never be made writable while sandboxed. #[cfg_attr( target_os = "linux", doc = "\nOn Linux, every path here must be a directory that already exists. \ Requesting a file, or a path that does not exist yet, is an error. To create new \ files, request write access to the existing directory that will contain them." )] #[cfg_attr( target_os = "windows", doc = "\nEvery path here must be an existing directory, given as a Windows drive \ path (`C:\\...`) or a WSL absolute path (`/...`); a path that does not exist \ cannot be granted. To write somewhere new, request write access to the nearest \ existing parent directory." )] #[serde(default)] pub fs_write_paths: Vec<String>, /// Set to `true` only when the command needs to write outside the /// default-writable locations but the specific paths cannot be /// enumerated up front. /// /// This is a broad escape hatch — prefer `fs_write_paths` whenever the /// set of paths is known. Protected Git metadata remains read-only. /// Requesting it triggers a user approval prompt. #[serde(default, alias = "allow_fs_write")] pub allow_fs_write_all: Option<bool>, /// Set to `true` only as a last resort, to run the command fully outside /// the sandbox. /// /// First try the narrower options (`allow_hosts`, `fs_write_paths`, or /// `allow_fs_write_all`); use this only when the command /// needs behavior the sandbox can't grant on a per-permission basis, /// including commands that must write Git metadata. /// Requesting it triggers a user approval prompt. #[cfg_attr( target_os = "windows", doc = "\nOn Windows, running unsandboxed also switches the shell. Sandboxed \ commands run under WSL's Linux bash; an unsandboxed command instead runs in the \ host's default shell — Git Bash (or scoop's bash) when one is installed, otherwise \ PowerShell/cmd. Path conventions change accordingly (e.g. `C:\\...` or `/c/...` \ rather than WSL's `/mnt/c/...`), so a command written for the sandboxed shell may \ behave differently here." )] #[serde(default)] pub unsandboxed: Option<bool>, /// A short justification for why this command needs the sandbox /// permission(s) it requests (`allow_hosts`, `allow_all_hosts`, /// `fs_write_paths`, `allow_fs_write_all`, or `unsandboxed`). /// /// Required whenever you request any of those permissions; omit it for /// ordinary commands that request none. Write it in your own voice — it /// is shown to the user, attributed to you, when they're asked to approve /// the request. #[serde(default)] pub reason: Option<String>, } #[derive(Clone, Debug, Default)] struct TerminalSandboxInput { allow_hosts: Vec<String>, allow_all_hosts: Option<bool>, fs_write_paths: Vec<String>, allow_fs_write_all: Option<bool>, unsandboxed: Option<bool>, reason: Option<String>, } struct TerminalToolRequest { command: String, cd: String, timeout_ms: Option<u64>, selection: TerminalOutputSelection, sandbox: Option<TerminalSandboxInput>, } impl From<TerminalToolInput> for TerminalToolRequest { fn from(input: TerminalToolInput) -> Self { Self { command: input.command, cd: input.cd, timeout_ms: input.timeout_ms, selection: TerminalOutputSelection { head_lines: input.head_lines, tail_lines: input.tail_lines, }, sandbox: None, } } } impl From<SandboxedTerminalToolInput> for TerminalToolRequest { fn from(input: SandboxedTerminalToolInput) -> Self { Self { command: input.command, cd: input.cd, timeout_ms: input.timeout_ms, selection: TerminalOutputSelection { head_lines: input.head_lines, tail_lines: input.tail_lines, }, sandbox: Some(TerminalSandboxInput { allow_hosts: input.allow_hosts, allow_all_hosts: input.allow_all_hosts, fs_write_paths: input.fs_write_paths, allow_fs_write_all: input.allow_fs_write_all, unsandboxed: input.unsandboxed, reason: input.reason, }), } } } pub struct TerminalTool { project: Entity<Project>, environment: Rc<dyn ThreadEnvironment>, } impl TerminalTool { pub fn new(project: Entity<Project>, environment: Rc<dyn ThreadEnvironment>) -> Self { Self { project, environment, } } } pub struct SandboxedTerminalTool { project: Entity<Project>, environment: Rc<dyn ThreadEnvironment>, } impl SandboxedTerminalTool { pub fn new(project: Entity<Project>, environment: Rc<dyn ThreadEnvironment>) -> Self { Self { project, environment, } } } impl AgentTool for TerminalTool { type Input = TerminalToolInput; type Output = String; const NAME: &'static str = "terminal"; fn kind() -> acp::ToolKind { acp::ToolKind::Execute } fn allow_in_restricted_mode() -> bool { false } fn initial_title( &self, input: Result<Self::Input, serde_json::Value>, _cx: &mut App, ) -> SharedString { terminal_initial_title(input.map(|input| input.command)) } fn run( self: Arc<Self>, input: ToolInput<Self::Input>, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task<Result<Self::Output, Self::Output>> { cx.spawn(async move |cx| { let input = input.recv().await.map_err(|e| e.to_string())?; run_terminal_tool( self.project.clone(), self.environment.clone(), input.into(), event_stream, cx, ) .await }) } } impl AgentTool for SandboxedTerminalTool { type Input = SandboxedTerminalToolInput; type Output = String; const NAME: &'static str = "sandboxed_terminal"; fn kind() -> acp::ToolKind { acp::ToolKind::Execute } fn allow_in_restricted_mode() -> bool { false } fn initial_title( &self, input: Result<Self::Input, serde_json::Value>, _cx: &mut App, ) -> SharedString { terminal_initial_title(input.map(|input| input.command)) } fn run( self: Arc<Self>, input: ToolInput<Self::Input>, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task<Result<Self::Output, Self::Output>> { cx.spawn(async move |cx| { let input = input.recv().await.map_err(|e| e.to_string())?; run_terminal_tool( self.project.clone(), self.environment.clone(), input.into(), event_stream, cx, ) .await }) } } fn terminal_initial_title(input: Result<String, serde_json::Value>) -> SharedString { if let Ok(command) = input { command.into() } else { "".into() } } /// Windows only: resolve the `(release channel, version)` of the Linux `zed` to /// provision inside WSL as the sandbox helper. Dev (source) builds have no /// matching release, so they pull the latest nightly. Nightly builds also track /// `latest`: nightly assets are keyed by their full build metadata /// (`X.Y.Z+nightly.<n>.<sha>`), which `AppVersion` strips, so a bare `X.Y.Z` /// never resolves on the nightly host. Preview and stable pin their exact /// running version (stripped of pre-release/build metadata, which the release /// API doesn't key on). #[cfg(target_os = "windows")] fn wsl_zed_release(cx: &App) -> Option<(String, String)> { use release_channel::{AppVersion, ReleaseChannel}; match *release_channel::RELEASE_CHANNEL { ReleaseChannel::Dev | ReleaseChannel::Nightly => { Some(("nightly".to_string(), "latest".to_string())) } channel => { let version = AppVersion::global(cx); Some(( channel.dev_name().to_string(), format!("{}.{}.{}", version.major, version.minor, version.patch), )) } } } /// Non-Windows platforms don't route through WSL, so there's no helper to fetch. #[cfg(not(target_os = "windows"))] fn wsl_zed_release(_cx: &App) -> Option<(String, String)> { None } async fn run_terminal_tool( project: Entity<Project>, environment: Rc<dyn ThreadEnvironment>, input: TerminalToolRequest, event_stream: ToolCallEventStream, cx: &mut AsyncApp, ) -> Result<String, String> { let selection = input.selection; let sandbox_input = input.sandbox.clone().unwrap_or_default(); let (working_dir, authorize, sandboxing, is_local_project, wsl_zed_release) = cx.update(|cx| { let working_dir = working_dir(&input.cd, &project, cx).map_err(|err| err.to_string())?; let context = crate::ToolPermissionContext::new(TerminalTool::NAME, vec![input.command.clone()]); let authorize = event_stream.authorize(SharedString::new(input.command.clone()), context, cx); let sandboxing = input.sandbox.is_some() && sandboxing_enabled_for_project(project.read(cx), cx); let is_local_project = project.read(cx).is_local(); let wsl_zed_release = wsl_zed_release(cx); Result::<_, String>::Ok(( working_dir, authorize, sandboxing, is_local_project, wsl_zed_release, )) })?; authorize.await.map_err(|e| e.to_string())?; let want_fs_write_all = sandboxing && sandbox_input.allow_fs_write_all == Some(true); let want_unsandboxed = sandboxing && sandbox_input.unsandboxed == Some(true); let want_all_hosts = sandboxing && sandbox_input.allow_all_hosts == Some(true); let persistent = cx.update(|cx| { agent_settings::AgentSettings::get_global(cx) .sandbox_permissions .clone() }); // Standing permissions the user already approved — in settings or "for this // thread" — that every command in the thread inherits and that the model // cannot narrow. The actually-enforced policy is always at least this // permissive, so a request asking for something *more* restrictive would be // silently widened to the floor and mislead the model about its real access. // Reject such requests with an explanation instead of running them. let floor = event_stream .effective_sandbox_request(&crate::sandboxing::SandboxRequest::default(), &persistent); let unsandboxed_floor = sandboxing && (event_stream.unsandboxed_granted_for_thread() || event_stream.sandbox_fallback_granted_for_thread()); let fs_unrestricted_floor = sandboxing && floor.allow_fs_write_all; let net_unrestricted_floor = sandboxing && matches!(floor.network, NetworkRequest::AnyHost); if sandboxing && !want_unsandboxed { if unsandboxed_floor { // The user turned the sandbox off for this thread, so every command // runs without one and no sandbox-scoping field can take effect. // Name exactly which ones the model set so it can drop them. let mut ineffective = Vec::new(); if !sandbox_input.allow_hosts.is_empty() { ineffective.push("`allow_hosts`"); } if sandbox_input.allow_all_hosts == Some(true) { ineffective.push("`allow_all_hosts`"); } if !sandbox_input.fs_write_paths.is_empty() { ineffective.push("`fs_write_paths`"); } if sandbox_input.allow_fs_write_all == Some(true) { ineffective.push("`allow_fs_write_all`"); } if !ineffective.is_empty() { return Err(format!( "Sandboxing is disabled for this thread, so every command runs without an OS \ sandbox and these fields have no effect: {}. Remove them and rerun the \ command (it will run unsandboxed), or pass `unsandboxed: true` to acknowledge \ it runs without a sandbox.", ineffective.join(", "), )); } } else {