Cameron Mcloughlinagent: Re-enable sandbox (#61711)12a19dc3 months agoHistory
3642 lines · 141.8 KB · Rust
RawDownload
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.