Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 20 additions & 0 deletions book/src/editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
- [`[editor.smart-tab]` Section](#editorsmart-tab-section)
- [`[editor.inline-diagnostics]` Section](#editorinline-diagnostics-section)
- [`[editor.word-completion]` Section](#editorword-completion-section)
- [`[editor.workspace-trust]` Section](#editorworkspace-trust-section)

### `[editor]` Section

Expand Down Expand Up @@ -528,3 +529,22 @@ enable = true
# Set the trigger length lower so that words are completed more often
trigger-length = 4
```

### `[editor.workspace-trust]` Section

Controls implicit workspace trust. See the [workspace
trust](./workspace-trust.md) chapter for the full feature.

| Key | Description | Default |
| --- | --- | --- |
| `level` | `"none"`: prompt for every workspace. `"servers"`: trust LSP and DAP launches but still gate local config and git. `"all"`: trust everything. | `"servers"` |
| `prompt` | Whether opening a file in an untrusted workspace pops a modal. The statusline `[⚠]` indicator is always shown either way. | `true` |

Example:

```toml
[editor.workspace-trust]
# Start language servers automatically; still require :workspace-trust for
# .helix/config.toml and .helix/languages.toml.
level = "servers"
```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a lot packed into the descriptions, I wonder if moving the details to the example section would be better?

Suggested change
| Key | Description | Default |
| --- | --- | --- |
| `level` | `"none"`: prompt for every workspace. `"servers"`: trust LSP and DAP launches but still gate local config and git. `"all"`: trust everything. | `"servers"` |
| `prompt` | Whether opening a file in an untrusted workspace pops a modal. The statusline `[⚠]` indicator is always shown either way. | `true` |
Example:
```toml
[editor.workspace-trust]
# Start language servers automatically; still require :workspace-trust for
# .helix/config.toml and .helix/languages.toml.
level = "servers"
```
| Key | Description | Default |
| -------- | ---------------------------------------------------------------------- | ----------- |
| `level` | The default level of trust for all workspace's. | `"servers"` |
| `prompt` | Whether to show a modal when opening a file in an untrusted workspace. | `true` |
Example:
```toml
[editor.workspace-trust]
# Even if `false`, the statusline `[⚠]` indicator is always shown.
prompt = false
# `"none"`: prompt for every workspace.
# `"servers"`: trust LSP and DAP launches but still gate local config and git.
# - still requires `:workspace-trust` for .helix/config.toml, .helix/languages.toml, etc.
# `"insecure"`: trust everything.
level = "none|servers|insecure"

5 changes: 3 additions & 2 deletions book/src/generated/typable-cmd.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,5 +97,6 @@
| `:read`, `:r` | Load a file into buffer |
| `:echo` | Prints the given arguments to the statusline. |
| `:noop` | Does nothing. |
| `:workspace-trust` | Add current workspace to the list of trusted workspaces. |
| `:workspace-untrust` | Remove current workspace from the list of trusted workspaces. |
| `:workspace-trust` | Allow language servers and local config for the current workspace. Snapshots the `.helix/` hash so future edits prompt to re-allow. |
| `:workspace-untrust` | Revoke the current workspace's trust grant or exclusion. |
| `:workspace-exclude` | Mark the current workspace as never-prompt. Never prompts for trust again. |
132 changes: 121 additions & 11 deletions book/src/workspace-trust.md

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reads well to me now 👍

Original file line number Diff line number Diff line change
@@ -1,23 +1,133 @@
# Workspace trust

Helix has a number of potentially dangerous features, namely LSP and ability to use local to workspace configurations. Those features can lead to unexpected code execution. To protect against code execution in dangerous contexts, Helix has a workspace trust protection, which will prevent these potentially dangerous features from running automatically.
Helix has two potentially dangerous features, both of which can execute
arbitrary code:

Helix will not trust any workspace by default.
- Language servers (LSP)
- Local workspace configuration (`.helix/config.toml`, `.helix/languages.toml`)

By default, it will prompt about trust when you open new file in a workspace where you didn't make a decision about trust yet.
To protect against malicious projects (a checked-out PR, a freshly cloned
repository, etc.) Helix gates them behind explicit per-workspace trust.
By default language servers and debug adapters still start automatically
(their binaries come from `$PATH`, not from the workspace), but loading
`.helix/config.toml` or `.helix/languages.toml` requires opting in. The
model is intentionally similar to [direnv](https://direnv.net/): you run
`:workspace-trust` once per workspace and Helix remembers across sessions.

If you decide not to trust a workspace and don't want to be prompted about trust every time you start a new session in it, you can exclude the workspace by choosing `Never` option in trust selection window.
## Granting trust

You can always make current workspace trusted by running `:workspace-trust` command, and untrust it with `:workspace-untrust`.
When Helix opens a file inside a workspace it has never seen before, a
modal trust prompt asks:

Lists of trusted and excluded workspaces, delimited by newline characters, are stored in `~/.local/share/helix/trusted_workspaces` and `~/.local/share/helix/excluded_workspaces` correspondingly.
<!-- TODO: Windows paths -->
- **Trust** — allow the workspace permanently.
- **Never** — exclude the workspace; never prompt again.

# Configuration
`<Esc>` (or any other dismissal) caches "untrusted for this session" so
the prompt doesn't re-fire for every file you open in the workspace. The
next time you start Helix in that workspace, it'll prompt again.

You can return to the old behaviour of loading every local `.helix/config.toml` and `.helix/languages.toml` and starting LSP's without an explicit permission by setting following option:
A small `[⚠]` indicator appears in the bottom-right of the editor (next
to the macro-recording `[@]`) whenever the workspace is in restricted mode
*and* running `:workspace-trust` would change observable behavior — i.e.
when there's a local config to load or an LSP that would start.

You can also run `:workspace-trust` / `:workspace-untrust` /
`:workspace-exclude` directly from the typed command prompt.

## Revoking trust

Run `:workspace-untrust` to revoke a workspace's trust grant. The next time
you open a file in that workspace, you're back to the untrusted hint.

## Detecting changes after trust was granted

When you trust a workspace, Helix records a hash of every file under
`.helix/`. If those files change afterwards (a malicious checkout, an
inadvertent rebase, etc.) Helix detects the mismatch on the next open and
reports the workspace as *stale*:

```
Workspace `.helix/` config changed since `:workspace-trust`. Local config
not loaded. Run `:workspace-trust` to re-allow.
```

In the stale state, language servers continue to run (they use the
globally-configured binaries on `$PATH`, which are unchanged), but
`.helix/config.toml` and `.helix/languages.toml` are not loaded. Run
`:workspace-trust` again to re-pin the new hash.

## Storage

Trust grants live in `data_dir()/workspace_trust/`, one small file per
workspace. The filename is the SHA-256 of the workspace's absolute path;
the contents look like:

```
path = /home/user/proj1
hash = sha256:abc123...
excluded = false
```

- Linux, macOS: `~/.local/share/helix/workspace_trust/`
- Windows: `%AppData%\Roaming\helix\workspace_trust\`

The one-file-per-workspace shape is safe under multiple concurrent Helix
instances — different workspaces never write the same file.

## Configuration

Two settings live under `[editor.workspace-trust]`:

| Key | Values | Default | Effect |
| --- | --- | --- | --- |
| `level` | `"none"`, `"servers"`, `"all"` | `"servers"` | What is auto-trusted in every workspace. See below. |
| `prompt` | `true`, `false` | `true` | Whether to surface the modal popup. The `[⚠]` indicator is shown regardless. |

### Recommended setups

**Default: trust servers, prompt before loading workspace config.**

```toml
[editor]
insecure = true
[editor.workspace-trust]
level = "servers"
prompt = true
```

Language servers and debug adapters start automatically in every

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Language servers and debug adapters start automatically in every
Language servers start automatically in every

I think we need to manually kick off debug adapter servers, right? They're never started automatically iirc

workspace — their binaries come from `$PATH` and are not
workspace-controlled. The modal only appears when opening a file in a
workspace whose `.helix/config.toml` or `.helix/languages.toml` would
unlock something. Trust everything else with one keystroke per
workspace, deny with another.

**Maximum security: never prompt, trust each workspace by hand.**

```toml
[editor.workspace-trust]
level = "none"
prompt = false
```

Nothing trusts implicitly: language servers, debug adapters, local
config, and git `Trust::Full` are all off until you run
`:workspace-trust`. The popup never appears; the `[⚠]` indicator in the
bottom-right is your only signal that the current workspace is
restricted. Suited to users who would rather grant trust as a
deliberate action than dismiss a dialog.

> [!WARNING]
> `level = "all"` is highly discouraged. It implicitly trusts every

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still think insecure is better naming so users are less tempted to set it

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think we can just replace all with insecure, that seems clearer to me

> workspace you open, which defeats the protection entirely: a
> checked-out PR with a malicious `.helix/config.toml` would get its
> configuration loaded and any language server it defines launched, with
> no prompt and no indicator. Only set this if you accept full
> responsibility for what's in every project directory you `cd` into.

## Git trust

Workspace trust also gates how Helix opens git repositories. Untrusted
workspaces are opened in [gix](https://github.com/Byron/gitoxide)'s
`Trust::Reduced` mode, which disables risky configuration like
`core.fsmonitor`, `core.sshCommand`, `gpg.openpgp.program`, and similar
options that can execute arbitrary commands from `.git/config`. Trusted
workspaces use `Trust::Full`.
10 changes: 6 additions & 4 deletions helix-core/src/config.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use helix_loader::workspace_trust::WorkspaceTrust;

use crate::syntax::{
config::{Configuration, LanguageConfiguration},
Loader, LoaderError,
Expand Down Expand Up @@ -37,13 +39,13 @@ impl std::fmt::Display for LanguageLoaderError {
impl std::error::Error for LanguageLoaderError {}

/// Language configuration based on user configured languages.toml.
pub fn user_lang_config(insecure: bool) -> Result<Configuration, toml::de::Error> {
helix_loader::config::user_lang_config(insecure)?.try_into()
pub fn user_lang_config(trust: &WorkspaceTrust) -> Result<Configuration, toml::de::Error> {
helix_loader::config::user_lang_config(trust)?.try_into()
}

/// Language configuration loader based on user configured languages.toml.
pub fn user_lang_loader(insecure: bool) -> Result<Loader, LanguageLoaderError> {
let config_val = helix_loader::config::user_lang_config(insecure)
pub fn user_lang_loader(trust: &WorkspaceTrust) -> Result<Loader, LanguageLoaderError> {
let config_val = helix_loader::config::user_lang_config(trust)
.map_err(LanguageLoaderError::DeserializeError)?;
let config = config_val.clone().try_into().map_err(|e| {
if let Some(languages) = config_val.get("language").and_then(|v| v.as_array()) {
Expand Down
2 changes: 2 additions & 0 deletions helix-loader/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ serde = { version = "1.0", features = ["derive"] }
toml.workspace = true
etcetera.workspace = true
once_cell = "1.21"
parking_lot.workspace = true
sha2 = "0.10"
log = "0.4"

# TODO: these two should be on !wasm32 only
Expand Down
9 changes: 6 additions & 3 deletions helix-loader/src/config.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use std::str::from_utf8;

use crate::workspace_trust::{quick_query_workspace, TrustStatus};
use crate::workspace_trust::{TrustQuery, WorkspaceTrust};

/// Default built-in languages.toml.
pub fn default_lang_config() -> toml::Value {
Expand All @@ -10,11 +10,14 @@ pub fn default_lang_config() -> toml::Value {
}

/// User configured languages.toml file, merged with the default config.
pub fn user_lang_config(insecure: bool) -> Result<toml::Value, toml::de::Error> {
///
/// Workspace-local `.helix/languages.toml` is merged in only when the current
/// workspace is trusted for [`TrustQuery::LocalConfig`].
pub fn user_lang_config(trust: &WorkspaceTrust) -> Result<toml::Value, toml::de::Error> {
let global_config = crate::lang_config_file();
let workspace_config = crate::workspace_lang_config_file();

let files = if let TrustStatus::Trusted = quick_query_workspace(insecure) {
let files = if trust.query_current(TrustQuery::LocalConfig).is_trusted() {
vec![global_config, workspace_config]
} else {
vec![global_config]
Expand Down
36 changes: 34 additions & 2 deletions helix-loader/src/grammar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,26 @@ fn ensure_git_is_available() -> Result<()> {
Ok(())
}

/// Print a notice if the current workspace has a `.helix/languages.toml` that we *would* have
/// merged but the workspace-trust gate is keeping us from.
fn warn_if_workspace_languages_skipped(trust: &crate::workspace_trust::WorkspaceTrust) {
let workspace_languages = crate::workspace_lang_config_file();
if !workspace_languages.exists() {
return;
}
if trust
.query_current(crate::workspace_trust::TrustQuery::LocalConfig)
.is_trusted()
{
return;
}
println!(
"Note: workspace `{}` was skipped because the workspace is not trusted. Run \
`:workspace-trust` from an interactive helix session in this workspace to opt in.",
workspace_languages.display(),
);
}

pub fn fetch_grammars(strict: bool) -> Result<()> {
ensure_git_is_available()?;

Expand Down Expand Up @@ -226,7 +246,14 @@ pub fn build_grammars(target: Option<String>, strict: bool) -> Result<()> {
// merged. The `grammar_selection` key of the config is then used to filter
// down all grammars into a subset of the user's choosing.
fn get_grammar_configs() -> Result<Vec<GrammarConfiguration>> {
let config: Configuration = crate::config::user_lang_config(false)
// `--grammar fetch/build` clones grammar sources from URLs in `languages.toml` and compiles
// them into `.so` files helix later loads at runtime. If we let workspace
// `.helix/languages.toml` in through `fully_trusted`, a malicious workspace could inject a
// grammar with an attacker-controlled git source — running grammar build in that
// directory would clone and compile attacker code
let trust = crate::workspace_trust::WorkspaceTrust::new(Default::default());
warn_if_workspace_languages_skipped(&trust);
let config: Configuration = crate::config::user_lang_config(&trust)
.context("Could not parse languages.toml")?
.try_into()?;

Expand All @@ -248,7 +275,12 @@ fn get_grammar_configs() -> Result<Vec<GrammarConfiguration>> {
}

pub fn get_grammar_names() -> Result<Option<HashSet<String>>> {
let config: Configuration = crate::config::user_lang_config(false)
// See `get_grammar_configs`, same threat: workspace-local
// `languages.toml` must not influence the grammar set without
// explicit on-disk trust.
let trust = crate::workspace_trust::WorkspaceTrust::new(Default::default());
warn_if_workspace_languages_skipped(&trust);
let config: Configuration = crate::config::user_lang_config(&trust)
.context("Could not parse languages.toml")?
.try_into()?;

Expand Down
4 changes: 0 additions & 4 deletions helix-loader/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,10 +168,6 @@ pub fn workspace_trust_file() -> PathBuf {
data_dir().join("trusted_workspaces")
Comment thread
archseer marked this conversation as resolved.
Outdated
}

pub fn workspace_exclude_file() -> PathBuf {
data_dir().join("excluded_workspaces")
}

/// Merge two TOML documents, merging values from `right` onto `left`
///
/// `merge_depth` sets the nesting depth up to which values are merged instead
Expand Down
Loading
Loading