Avoid running uv format in untrusted workspaces (#28873)

Summary
--

This PR adds the `--untrusted-workspace` flag to the server CLI, which
informs the server that it's running in an untrusted workspace. When
this is the case, we fall back on the internal formatting backend
instead of using `uv format` to avoid reading uv configuration and
potentially installing packages based on it.

Test Plan
--

New server tests
This commit is contained in:
Brent Westbrook authored and GitHub committed 2026-09-28 16:47:06 -04:00
1 parent 33e920cd16
commit 6cc0678f70
13 files changed
+119 -21

No files matched your search

Generated
+1
View File
@@ -3763,6 +3763,7 @@ dependencies = [
"shellexpand",
"smallvec",
"tempfile",
"test-case",
"thiserror 2.0.20",
"toml 1.1.4+spec-1.1.0",
"tracing",
@@ -14,6 +14,7 @@ when deciding which inputs are trusted. Browser integrations have a separate
- **Trusted local input:** the editor, its extensions and their bundled files, the local machine and
its file system, and the user's configuration and trust decisions.
Ruff treats workspaces as trusted unless the server starts with `--untrusted-workspace`.
ty treats workspaces as trusted unless `untrustedWorkspace` is true at initialization. Trust extends
to everything in the workspace, including source code, configuration, notebooks, and the targets of
symlinks that point outside it.
+12
View File
@@ -627,6 +627,10 @@ pub struct FormatCommand {
#[derive(Copy, Clone, Debug, clap::Parser)]
pub struct ServerCommand {
/// Treat all workspaces as untrusted, disabling the uv formatter backend.
#[arg(long)]
untrusted_workspace: bool,
/// Enable preview mode. Use `--no-preview` to disable.
///
/// This enables unstable server features and turns on the preview mode for the linter
@@ -638,6 +642,14 @@ pub struct ServerCommand {
}
impl ServerCommand {
pub(crate) fn resolve_workspace_trust(self) -> ruff_server::WorkspaceTrust {
if self.untrusted_workspace {
ruff_server::WorkspaceTrust::Untrusted
} else {
ruff_server::WorkspaceTrust::Trusted
}
}
pub(crate) fn resolve_preview(self) -> Option<bool> {
resolve_bool_arg(self.preview, self.no_preview)
}
+6 -2
View File
@@ -1,7 +1,11 @@
use crate::ExitStatus;
use anyhow::Result;
use ruff_server::WorkspaceTrust;
pub(crate) fn run_server(preview: Option<bool>) -> Result<ExitStatus> {
ruff_server::run(preview)?;
pub(crate) fn run_server(
preview: Option<bool>,
workspace_trust: WorkspaceTrust,
) -> Result<ExitStatus> {
ruff_server::run(preview, workspace_trust)?;
Ok(ExitStatus::Success)
}
+1 -1
View File
@@ -231,7 +231,7 @@ fn analyze_graph(
}
fn server(args: ServerCommand) -> Result<ExitStatus> {
commands::server::run_server(args.resolve_preview())
commands::server::run_server(args.resolve_preview(), args.resolve_workspace_trust())
}
pub fn check(args: CheckCommand, global_options: GlobalConfigArgs) -> Result<ExitStatus> {
+1
View File
@@ -54,6 +54,7 @@ dunce = { workspace = true }
regex = { workspace = true }
smallvec = { workspace = true }
tempfile = { workspace = true }
test-case = { workspace = true }
[features]
test-uv = []
+9 -2
View File
@@ -36,11 +36,18 @@ const NOTEBOOK_SOURCE_ORGANIZE_IMPORTS_RUFF: CodeActionKind =
/// result type is needed.
pub(crate) type Result<T> = anyhow::Result<T>;
/// Whether the server may execute code from the workspace.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum WorkspaceTrust {
Trusted,
Untrusted,
}
fn version() -> &'static str {
ruff_linter::VERSION
}
pub fn run(preview: Option<bool>) -> Result<()> {
pub fn run(preview: Option<bool>, workspace_trust: WorkspaceTrust) -> Result<()> {
let four = NonZeroUsize::new(4).unwrap();
// by default, we set the number of worker threads to `num_cpus`, with a maximum of 4.
@@ -50,7 +57,7 @@ pub fn run(preview: Option<bool>) -> Result<()> {
let (connection, io_threads) = ConnectionInitializer::stdio();
let server_result = Server::new(worker_threads, connection, preview, false)
let server_result = Server::new(worker_threads, connection, preview, workspace_trust, false)
.context("Failed to start server")?
.run();
+3 -1
View File
@@ -24,11 +24,11 @@ use types::WorkspaceFoldersServerCapabilities;
pub use self::connection::ConnectionInitializer;
pub(crate) use self::connection::ConnectionSender;
use self::schedule::spawn_main_loop;
use crate::PositionEncoding;
pub(crate) use crate::server::main_loop::MainLoopSender;
pub(crate) use crate::server::main_loop::{Event, MainLoopReceiver};
use crate::session::{AllOptions, Client, Session};
use crate::workspace::Workspaces;
use crate::{PositionEncoding, WorkspaceTrust};
pub(crate) use api::Error;
mod api;
@@ -52,6 +52,7 @@ impl Server {
worker_threads: NonZeroUsize,
connection: ConnectionInitializer,
preview: Option<bool>,
workspace_trust: WorkspaceTrust,
is_test: bool,
) -> crate::Result<Self> {
let (id, init_params) = connection.initialize_start()?;
@@ -121,6 +122,7 @@ impl Server {
global,
&workspaces,
&client,
workspace_trust,
)?,
client_capabilities,
})
@@ -45,10 +45,7 @@ impl super::BackgroundDocumentRequestHandler for Format {
pub(super) fn format_full_document(snapshot: &DocumentSnapshot) -> Result<Fixes> {
let mut fixes = Fixes::default();
let query = snapshot.query();
let backend = snapshot
.client_settings()
.editor_settings()
.format_backend();
let backend = snapshot.format_backend();
match snapshot.query() {
DocumentQuery::Notebook { notebook, .. } => {
@@ -84,10 +81,7 @@ fn format_document(snapshot: &DocumentSnapshot) -> Result<super::FormatResponse>
.context("Failed to get text document for the format request")
.unwrap();
let query = snapshot.query();
let backend = snapshot
.client_settings()
.editor_settings()
.format_backend();
let backend = snapshot.format_backend();
format_text_document(
text_document,
query,
@@ -47,10 +47,7 @@ fn format_document_range(
.context("Failed to get text document for the format range request")
.unwrap();
let query = snapshot.query();
let backend = snapshot
.client_settings()
.editor_settings()
.format_backend();
let backend = snapshot.format_backend();
format_text_document_range(text_document, range, query, snapshot.encoding(), backend)
}
+70 -1
View File
@@ -7,10 +7,11 @@ use lsp_types::{ClientCapabilities, FileEvent, NotebookDocumentCellChanges, Uri}
use settings::ClientSettings;
use crate::edit::{DocumentKey, DocumentVersion, NotebookDocument};
use crate::format::FormatBackend;
use crate::session::request_queue::RequestQueue;
use crate::session::settings::GlobalClientSettings;
use crate::workspace::Workspaces;
use crate::{PositionEncoding, TextDocument};
use crate::{PositionEncoding, TextDocument, WorkspaceTrust};
pub(crate) use self::capabilities::ResolvedClientCapabilities;
pub(crate) use self::index::DocumentQuery;
@@ -34,6 +35,9 @@ pub(crate) struct Session {
/// Global settings provided by the client.
global_settings: GlobalClientSettings,
/// Set at startup so client settings cannot enable external formatting in an untrusted workspace.
workspace_trust: WorkspaceTrust,
/// Tracks what LSP features the client supports and doesn't support.
resolved_client_capabilities: Arc<ResolvedClientCapabilities>,
@@ -51,6 +55,7 @@ pub(crate) struct DocumentSnapshot {
client_settings: Arc<settings::ClientSettings>,
document_ref: index::DocumentQuery,
position_encoding: PositionEncoding,
workspace_trust: WorkspaceTrust,
}
impl Session {
@@ -60,11 +65,13 @@ impl Session {
global: GlobalClientSettings,
workspaces: &Workspaces,
client: &Client,
workspace_trust: WorkspaceTrust,
) -> crate::Result<Self> {
Ok(Self {
position_encoding,
index: index::Index::new(workspaces, &global, client)?,
global_settings: global,
workspace_trust,
resolved_client_capabilities: Arc::new(ResolvedClientCapabilities::new(
client_capabilities,
)),
@@ -104,6 +111,7 @@ impl Session {
.unwrap_or_else(|| self.global_settings.to_settings_arc()),
document_ref: self.index.make_document_ref(key, &self.global_settings)?,
position_encoding: self.position_encoding,
workspace_trust: self.workspace_trust,
})
}
@@ -215,6 +223,21 @@ impl Session {
}
impl DocumentSnapshot {
pub(crate) fn format_backend(&self) -> FormatBackend {
let backend = self.client_settings.editor_settings().format_backend();
match self.workspace_trust {
WorkspaceTrust::Trusted => backend,
WorkspaceTrust::Untrusted => {
if backend == FormatBackend::Uv {
tracing::info!(
"Using the internal formatter because the workspace is untrusted; the uv backend is disabled"
);
}
FormatBackend::Internal
}
}
}
pub(crate) fn resolved_client_capabilities(&self) -> &ResolvedClientCapabilities {
&self.resolved_client_capabilities
}
@@ -242,3 +265,49 @@ impl DocumentSnapshot {
)
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use anyhow::{Context, Result};
use lsp_types::Uri;
use serde_json::json;
use test_case::test_case;
use super::index::Index;
use super::options::GlobalOptions;
use super::{Client, Session};
use crate::format::FormatBackend;
use crate::session::request_queue::RequestQueue;
use crate::{PositionEncoding, TextDocument, WorkspaceTrust};
#[test_case(WorkspaceTrust::Untrusted, FormatBackend::Internal)]
#[test_case(WorkspaceTrust::Trusted, FormatBackend::Uv)]
fn format_backend(workspace_trust: WorkspaceTrust, expected: FormatBackend) -> Result<()> {
let (main_loop_sender, _) = crossbeam::channel::unbounded();
let (client_sender, _) = crossbeam::channel::unbounded();
let client = Client::new(main_loop_sender, client_sender);
let options: GlobalOptions = serde_json::from_value(json!({
"format": {"backend": "uv"},
}))?;
let mut session = Session {
index: Index::default(),
position_encoding: PositionEncoding::default(),
global_settings: options.into_settings(client),
workspace_trust,
resolved_client_capabilities: Arc::default(),
request_queue: RequestQueue::new(),
shutdown_requested: false,
};
let uri: Uri = "untitled:test.py".parse()?;
session.open_text_document(uri.clone(), TextDocument::new(String::new(), 1));
let snapshot = session
.take_snapshot(uri)
.context("missing document snapshot")?;
assert_eq!(snapshot.format_backend(), expected);
Ok(())
}
}
+8 -2
View File
@@ -66,7 +66,7 @@ use lsp_types::{
DidChangeWorkspaceFoldersNotification, DidCloseTextDocumentNotification,
DidOpenTextDocumentNotification, ExitNotification, InitializedNotification, Notification,
};
use ruff_server::{ConnectionInitializer, LogLevel, Server, init_logging};
use ruff_server::{ConnectionInitializer, LogLevel, Server, WorkspaceTrust, init_logging};
use rustc_hash::FxHashMap;
use tempfile::TempDir;
@@ -207,7 +207,13 @@ impl TestServer {
// TODO: This should probably be configurable to test concurrency issues
let worker_threads = NonZeroUsize::new(1).unwrap();
match Server::new(worker_threads, server_connection, None, true) {
match Server::new(
worker_threads,
server_connection,
None,
WorkspaceTrust::Trusted,
true,
) {
Ok(server) => {
if let Err(err) = server.run() {
panic!("Server stopped with error: {err:?}");
+4
View File
@@ -981,6 +981,10 @@ The backend to use for formatting files. Following options are available:
For `internal`, the formatter version will match the selected Ruff version while for `uv`, the
formatter version may differ.
Starting the server with `ruff server --untrusted-workspace` disables the uv backend. With this flag,
the server always uses the internal formatter, regardless of this setting. This prevents workspace
configuration from causing uv to execute untrusted code.
**Default value**: `"internal"`
**Type**: `"internal" | "uv"`