From 1be6abfee6b01a0a8766d8a13fb0e2b06e0bdc35 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 12 Jun 2026 21:44:11 +0000 Subject: [PATCH 1/6] feat(config): load safe mise.toml files without trust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Config files that only contain min_version and [tools] entries with plain version strings cannot execute code or change mise's behavior, so they no longer require trusting the file before parsing. Anything else — env vars, tasks, hooks, settings, aliases, templates, tool options like postinstall/install_env — still requires trust, as does everything in paranoid mode. Configs the user chose to ignore stay unloaded even when safe. Co-Authored-By: Claude Fable 5 --- docs/cli/trust.md | 5 ++ docs/faq.md | 6 +- e2e/config/test_trust_safe_config | 52 ++++++++++++++ mise.usage.kdl | 5 ++ src/cli/trust.rs | 5 ++ src/config/config_file/mise_toml.rs | 102 ++++++++++++++++++++++++++-- 6 files changed, 170 insertions(+), 5 deletions(-) create mode 100644 e2e/config/test_trust_safe_config diff --git a/docs/cli/trust.md b/docs/cli/trust.md index 0cf6e09207..3fc0d8089a 100644 --- a/docs/cli/trust.md +++ b/docs/cli/trust.md @@ -12,6 +12,11 @@ parsing `mise.toml`. Without trust, mise may prompt, skip the config in some discovery paths, fail with an untrusted-config error when it cannot prompt, or assume trust in detected CI unless paranoid mode is enabled. +Safe config files do not require trust: files that only contain +`min_version` and `[tools]` entries with plain version strings (no +templates and no tool options) are loaded without prompting, since +nothing in them can execute code or change mise's behavior. + ## Arguments ### `[CONFIG_FILE]` diff --git a/docs/faq.md b/docs/faq.md index 4ab9cdd203..53dfbd2c9b 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -241,7 +241,11 @@ mise upgrade --bump node ## My config file is being ignored / `mise trust` issues -mise requires you to trust config files that were not created by you. Common issues: +mise requires you to trust config files that were not created by you. Safe config files — +those that only contain `min_version` and `[tools]` entries with plain version strings (no +templates and no tool options) — are loaded without trust, since nothing in them can execute +code or change mise's behavior. Everything else (env vars, tasks, hooks, settings, aliases, +templates, tool options) requires trust. Common issues: - **Accidentally denied trust**: If mise prompted you to trust a file and you said no, it gets added to the ignore list. Check the `ignored-configs` directory in your diff --git a/e2e/config/test_trust_safe_config b/e2e/config/test_trust_safe_config new file mode 100644 index 0000000000..06bfe2380e --- /dev/null +++ b/e2e/config/test_trust_safe_config @@ -0,0 +1,52 @@ +#!/usr/bin/env bash + +# Safe mise.toml files (only min_version + [tools] with plain version strings) +# can be loaded without trusting them first. Anything that can execute code or +# change mise's behavior still requires trust. + +export MISE_TRUSTED_CONFIG_PATHS="" +unset CI GITHUB_ACTIONS GITHUB_ACTION 2>/dev/null || true + +mkdir -p project +cd project || exit 1 + +cat <<'EOF' >mise.toml +min_version = "2024.1.1" + +[tools] +tiny = "3.1.0" +EOF + +# loads without prompting and without creating a trust marker +MISE_YES=0 mise install tiny +assert_contains "MISE_YES=0 mise ls tiny" "3.1.0" +[[ ! -e $MISE_STATE_DIR/trusted-configs ]] || fail "safe config should not be silently trusted" + +# a template in a version string requires trust and must not execute +cat <<'EOF' >mise.toml +[tools] +tiny = "{{ exec(command='echo PWNED > marker') }}" +EOF +output=$(MISE_YES=0 mise ls 2>&1 || true) +[[ ! -f marker ]] || fail "template executed in untrusted config" +echo "$output" | grep -qi "trust" || fail "expected trust error for template, got: $output" + +# tool options can run code (postinstall) or alter installs (install_env) +cat <<'EOF' >mise.toml +[tools] +tiny = { version = "3.1.0", postinstall = "touch marker" } +EOF +output=$(MISE_YES=0 mise ls 2>&1 || true) +echo "$output" | grep -qi "trust" || fail "expected trust error for tool options, got: $output" + +# env vars require trust +cat <<'EOF' >mise.toml +[env] +FOO = "bar" +EOF +output=$(MISE_YES=0 mise env 2>&1 || true) +echo "$output" | grep -qi "trust" || fail "expected trust error for env, got: $output" + +# unsafe configs still load normally once trusted +mise trust +assert_contains "mise env" "FOO=bar" diff --git a/mise.usage.kdl b/mise.usage.kdl index a35456a67c..0be97b47b8 100644 --- a/mise.usage.kdl +++ b/mise.usage.kdl @@ -3503,6 +3503,11 @@ that may execute code or affect the environment. mise checks trust before parsing `mise.toml`. Without trust, mise may prompt, skip the config in some discovery paths, fail with an untrusted-config error when it cannot prompt, or assume trust in detected CI unless paranoid mode is enabled. + +Safe config files do not require trust: files that only contain +`min_version` and `[tools]` entries with plain version strings (no +templates and no tool options) are loaded without prompting, since +nothing in them can execute code or change mise's behavior. """# after_long_help #""" Examples: diff --git a/src/cli/trust.rs b/src/cli/trust.rs index a18cccf2d2..9347cec131 100644 --- a/src/cli/trust.rs +++ b/src/cli/trust.rs @@ -18,6 +18,11 @@ use itertools::Itertools; /// parsing `mise.toml`. Without trust, mise may prompt, skip the config in some /// discovery paths, fail with an untrusted-config error when it cannot prompt, /// or assume trust in detected CI unless paranoid mode is enabled. +/// +/// Safe config files do not require trust: files that only contain +/// `min_version` and `[tools]` entries with plain version strings (no +/// templates and no tool options) are loaded without prompting, since +/// nothing in them can execute code or change mise's behavior. #[derive(Debug, clap::Args)] #[clap(verbatim_doc_comment, after_long_help = AFTER_LONG_HELP)] pub struct Trust { diff --git a/src/config/config_file/mise_toml.rs b/src/config/config_file/mise_toml.rs index c9dfa7b94f..01f1ab491a 100644 --- a/src/config/config_file/mise_toml.rs +++ b/src/config/config_file/mise_toml.rs @@ -18,11 +18,13 @@ use toml_edit::{Array, DocumentMut, InlineTable, Item, Key, Value, table, value} use versions::Versioning; use crate::cli::args::{BackendArg, ToolVersionType}; -use crate::config::config_file::{ConfigFile, TaskConfig, config_trust_root, trust, trust_check}; +use crate::config::config_file::{ + ConfigFile, TaskConfig, config_trust_root, is_ignored, trust, trust_check, +}; use crate::config::config_file::{config_root, toml::deserialize_arr}; use crate::config::env_directive::{AgeFormat, EnvDirective, EnvDirectiveOptions, RequiredValue}; use crate::config::settings::SettingsPartial; -use crate::config::{Alias, AliasMap, Config}; +use crate::config::{Alias, AliasMap, Config, Settings}; use crate::deps::DepsConfig; use crate::env_diff::EnvMap; use crate::file::{create_dir_all, display_path}; @@ -273,7 +275,9 @@ impl MiseToml { } pub fn from_str(body: &str, path: &Path) -> eyre::Result { - trust_check(path)?; + if !Self::is_trust_exempt(body, path) { + trust_check(path)?; + } trace!("parsing: {}", display_path(path)); let des = toml::Deserializer::parse(body).map_err(|e| toml_parse_error(&e, body, path))?; let de_res = serde_ignored::deserialize(des, |p| { @@ -302,6 +306,25 @@ impl MiseToml { Ok(rf) } + /// Whether this config body can be loaded without trusting the file. + /// + /// Safe configs cannot execute code or change mise's behavior beyond + /// requesting tool versions, so there is nothing to gate behind a trust + /// prompt. Anything else — env vars, tasks, hooks, settings, aliases, + /// templates, tool options like `postinstall`/`install_env` — still + /// requires trust. + fn is_trust_exempt(body: &str, path: &Path) -> bool { + if Settings::try_get().is_ok_and(|settings| settings.paranoid) { + return false; + } + // configs the user chose to ignore should stay unloaded rather than + // becoming loadable because their content happens to be safe + if is_ignored(&config_trust_root(path)) || is_ignored(path) { + return false; + } + is_safe_config_body(body) + } + fn doc(&self) -> eyre::Result { self.doc .lock() @@ -1947,6 +1970,36 @@ impl<'de> de::Deserialize<'de> for Alias { } } +/// A config body is safe to load without trust when it only requests tool +/// versions. `min_version` and `[tools]` entries with plain version strings +/// cannot execute anything by themselves — installation only happens when the +/// user explicitly runs a command like `mise install`. Tool entries with +/// options (tables) are excluded because options like `postinstall` and +/// `install_env` run code or alter the install environment. +fn is_safe_config_body(body: &str) -> bool { + // Tera templates can run arbitrary commands via exec() when rendered + if contains_template_syntax(body) { + return false; + } + let Ok(toml::Value::Table(table)) = toml::from_str::(body) else { + // let the normal trust + parse flow handle invalid TOML + return false; + }; + table.iter().all(|(key, value)| match key.as_str() { + "min_version" => true, + "tools" => value.as_table().is_some_and(|tools| { + tools.values().all(|version| match version { + toml::Value::String(_) => true, + toml::Value::Array(versions) => { + versions.iter().all(|v| matches!(v, toml::Value::String(_))) + } + _ => false, + }) + }), + _ => false, + }) +} + fn is_tools_sorted(tools: &IndexMap) -> bool { let mut last = None; for k in tools.keys() { @@ -1965,7 +2018,7 @@ fn is_tools_sorted(tools: &IndexMap) -> bool { mod tests { use std::sync::Arc; - use indoc::formatdoc; + use indoc::{formatdoc, indoc}; use insta::{assert_debug_snapshot, assert_snapshot}; use test_log::test; @@ -2532,6 +2585,47 @@ run = 'echo "template"' parse(toml).env_entries().unwrap().into_iter().join("\n") } + #[test] + fn test_is_safe_config_body() { + assert!(is_safe_config_body("")); + assert!(is_safe_config_body(indoc! {r#" + min_version = "2024.1.1" + [tools] + node = "20" + python = ["3.11", "3.12"] + "cargo:eza" = "latest" + "#})); + + // templates can execute commands + assert!(!is_safe_config_body(indoc! {r#" + [tools] + node = "{{ exec(command='echo 20') }}" + "#})); + // tool options like postinstall/install_env run code + assert!(!is_safe_config_body(indoc! {r#" + [tools] + node = { version = "20", postinstall = "corepack enable" } + "#})); + assert!(!is_safe_config_body(indoc! {r#" + [tools] + node = [{ version = "20" }] + "#})); + // anything beyond min_version/tools requires trust + for body in [ + "[env]\nFOO = \"bar\"", + "[tasks.build]\nrun = \"echo hi\"", + "[hooks]\nenter = \"echo hi\"", + "[settings]\nparanoid = false", + "[alias]\nnode = \"asdf:foo/bar\"", + "[plugins]\nfoo = \"https://example.com/foo.git\"", + "env_file = \".env\"", + ] { + assert!(!is_safe_config_body(body), "should require trust: {body}"); + } + // invalid toml falls back to the normal trust + parse flow + assert!(!is_safe_config_body("[tools")); + } + #[tokio::test] async fn test_table_syntax_preserves_registry_defaults() { // Test for #8039: table syntax like `ansible = { version = "latest" }` From 46e02fdcc439e5a3e1072d764d63d46e64873f3a Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 12 Jun 2026 22:07:25 +0000 Subject: [PATCH 2/6] feat(config): allow tasks in safe untrusted configs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defining a task is inert — it only executes when the user explicitly runs it — so [tasks] no longer disqualifies a config from loading without trust. Template-free task include files (mise-tasks/ scripts and TOML task files) are likewise loadable untrusted, since templates are the only thing that can execute while tasks load. This also fixes a latent hole: task includes skipped their trust check whenever any config existed at the root, even one that loaded without being trusted (e.g. a plain .tool-versions), letting a templated task file render exec() at load time. Includes now require trust unless a config at the root was actually trusted. Co-Authored-By: Claude Fable 5 --- docs/cli/trust.md | 8 ++-- docs/faq.md | 9 +++-- e2e/config/test_trust_safe_config | 45 +++++++++++++++++++-- e2e/tasks/test_task_monorepo_trust | 8 +++- e2e/tasks/test_task_untrusted_config_error | 19 +++++++-- mise.usage.kdl | 8 ++-- src/cli/trust.rs | 8 ++-- src/config/config_file/mise_toml.rs | 47 ++++++++++++++++------ src/config/config_file/mod.rs | 10 ++++- src/config/mod.rs | 25 ++++++++++-- 10 files changed, 147 insertions(+), 40 deletions(-) diff --git a/docs/cli/trust.md b/docs/cli/trust.md index 3fc0d8089a..89d1b9a094 100644 --- a/docs/cli/trust.md +++ b/docs/cli/trust.md @@ -13,9 +13,11 @@ discovery paths, fail with an untrusted-config error when it cannot prompt, or assume trust in detected CI unless paranoid mode is enabled. Safe config files do not require trust: files that only contain -`min_version` and `[tools]` entries with plain version strings (no -templates and no tool options) are loaded without prompting, since -nothing in them can execute code or change mise's behavior. +`min_version`, `[tools]` entries with plain version strings, and +`[tasks]` (no templates and no tool options) are loaded without +prompting, since nothing in them executes code at load time — tools +install and tasks run only on explicit commands like `mise install` +or `mise run`. ## Arguments diff --git a/docs/faq.md b/docs/faq.md index 53dfbd2c9b..6b3b90f159 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -242,10 +242,11 @@ mise upgrade --bump node ## My config file is being ignored / `mise trust` issues mise requires you to trust config files that were not created by you. Safe config files — -those that only contain `min_version` and `[tools]` entries with plain version strings (no -templates and no tool options) — are loaded without trust, since nothing in them can execute -code or change mise's behavior. Everything else (env vars, tasks, hooks, settings, aliases, -templates, tool options) requires trust. Common issues: +those that only contain `min_version`, `[tools]` entries with plain version strings, and +`[tasks]` (no templates and no tool options) — are loaded without trust, since nothing in +them executes code at load time: tools install and tasks run only on explicit commands like +`mise install` or `mise run`. Everything else (env vars, hooks, settings, aliases, templates, +tool options) requires trust. Common issues: - **Accidentally denied trust**: If mise prompted you to trust a file and you said no, it gets added to the ignore list. Check the `ignored-configs` directory in your diff --git a/e2e/config/test_trust_safe_config b/e2e/config/test_trust_safe_config index 06bfe2380e..a03d5f5e79 100644 --- a/e2e/config/test_trust_safe_config +++ b/e2e/config/test_trust_safe_config @@ -1,8 +1,8 @@ #!/usr/bin/env bash -# Safe mise.toml files (only min_version + [tools] with plain version strings) -# can be loaded without trusting them first. Anything that can execute code or -# change mise's behavior still requires trust. +# Safe mise.toml files (min_version, [tools] with plain version strings, and +# [tasks]) can be loaded without trusting them first. Anything that can execute +# code at load time or change mise's behavior still requires trust. export MISE_TRUSTED_CONFIG_PATHS="" unset CI GITHUB_ACTIONS GITHUB_ACTION 2>/dev/null || true @@ -15,6 +15,9 @@ min_version = "2024.1.1" [tools] tiny = "3.1.0" + +[tasks.hi] +run = "echo task-ran-without-trust" EOF # loads without prompting and without creating a trust marker @@ -22,6 +25,32 @@ MISE_YES=0 mise install tiny assert_contains "MISE_YES=0 mise ls tiny" "3.1.0" [[ ! -e $MISE_STATE_DIR/trusted-configs ]] || fail "safe config should not be silently trusted" +# config tasks only execute on explicit `mise run`, so they don't need trust +assert_contains "MISE_YES=0 mise run hi" "task-ran-without-trust" + +# template-free file tasks are inert at load time and runnable without trust +mkdir -p mise-tasks +cat <<'EOF' >mise-tasks/filetask +#!/usr/bin/env bash +#MISE description="a plain file task" +echo file-task-ran-without-trust +EOF +chmod +x mise-tasks/filetask +assert_contains "MISE_YES=0 mise run filetask" "file-task-ran-without-trust" +[[ ! -e $MISE_STATE_DIR/trusted-configs ]] || fail "file task should not require or create trust" + +# a template in a file task header renders at load time, so it requires trust +cat <<'EOF' >mise-tasks/evil +#!/usr/bin/env bash +#MISE description="{{ exec(command='echo PWNED > marker') }}" +echo evil +EOF +chmod +x mise-tasks/evil +output=$(MISE_YES=0 mise tasks 2>&1 || true) +[[ ! -f marker ]] || fail "file task header template executed without trust" +echo "$output" | grep -qi "trust" || fail "expected trust error for templated file task, got: $output" +rm mise-tasks/evil + # a template in a version string requires trust and must not execute cat <<'EOF' >mise.toml [tools] @@ -31,6 +60,16 @@ output=$(MISE_YES=0 mise ls 2>&1 || true) [[ ! -f marker ]] || fail "template executed in untrusted config" echo "$output" | grep -qi "trust" || fail "expected trust error for template, got: $output" +# a template in a task renders at load time, so it requires trust +cat <<'EOF' >mise.toml +[tasks.hi] +run = "echo hi" +description = "{{ exec(command='echo PWNED > marker') }}" +EOF +output=$(MISE_YES=0 mise tasks 2>&1 || true) +[[ ! -f marker ]] || fail "task template executed in untrusted config" +echo "$output" | grep -qi "trust" || fail "expected trust error for task template, got: $output" + # tool options can run code (postinstall) or alter installs (install_env) cat <<'EOF' >mise.toml [tools] diff --git a/e2e/tasks/test_task_monorepo_trust b/e2e/tasks/test_task_monorepo_trust index f05b8ac8a2..53d84dbd28 100755 --- a/e2e/tasks/test_task_monorepo_trust +++ b/e2e/tasks/test_task_monorepo_trust @@ -43,10 +43,14 @@ assert_not_contains "mise tasks ls --all 2>&1" "Trust them" assert_contains "mise run '//projects/frontend:build'" "frontend build" assert_contains "mise run '//projects/frontend/components:test'" "component test" -# Test that trust warnings DO appear for configs outside monorepo -# Create a parent directory with its own config (not part of monorepo) +# Test that trust warnings DO appear for unsafe configs outside monorepo +# (tasks-only configs are safe and load without trust, so use [env] to make +# this one require trust) mkdir -p ../parent-dir cat <../parent-dir/mise.toml +[env] +FOO = "bar" + [tasks.parent-task] run = 'echo "parent task"' EOF diff --git a/e2e/tasks/test_task_untrusted_config_error b/e2e/tasks/test_task_untrusted_config_error index 2526b875d5..b5a19d43ff 100644 --- a/e2e/tasks/test_task_untrusted_config_error +++ b/e2e/tasks/test_task_untrusted_config_error @@ -5,18 +5,29 @@ # Ensure we start with a clean slate - no trusted configs export MISE_TRUSTED_CONFIG_PATHS="" +unset CI GITHUB_ACTIONS GITHUB_ACTION 2>/dev/null || true -# Create a config file with a task +# A config that only defines tasks is safe: tasks execute nothing until the +# user explicitly runs one, so no trust is required cat <mise.toml [tasks.make] run = "echo 'hello from task'" EOF -# Running a task from an untrusted config should give a helpful error -# It should either: +assert_contains "MISE_YES=0 mise run make" "hello from task" + +# Unsafe configs (here: [env]) still require trust. Running a task from one +# should give a helpful error message: # 1. Prompt the user to trust the config (when interactive), or # 2. Show a clear "config not trusted" error (not "no tasks defined") -# +cat <mise.toml +[env] +FOO = "bar" + +[tasks.make] +run = "echo 'hello from task'" +EOF + # This test verifies we don't get the misleading "no tasks defined" error output=$(MISE_YES=0 mise run make 2>&1 || true) diff --git a/mise.usage.kdl b/mise.usage.kdl index 0be97b47b8..375d8b729b 100644 --- a/mise.usage.kdl +++ b/mise.usage.kdl @@ -3505,9 +3505,11 @@ discovery paths, fail with an untrusted-config error when it cannot prompt, or assume trust in detected CI unless paranoid mode is enabled. Safe config files do not require trust: files that only contain -`min_version` and `[tools]` entries with plain version strings (no -templates and no tool options) are loaded without prompting, since -nothing in them can execute code or change mise's behavior. +`min_version`, `[tools]` entries with plain version strings, and +`[tasks]` (no templates and no tool options) are loaded without +prompting, since nothing in them executes code at load time — tools +install and tasks run only on explicit commands like `mise install` +or `mise run`. """# after_long_help #""" Examples: diff --git a/src/cli/trust.rs b/src/cli/trust.rs index 9347cec131..585db838bc 100644 --- a/src/cli/trust.rs +++ b/src/cli/trust.rs @@ -20,9 +20,11 @@ use itertools::Itertools; /// or assume trust in detected CI unless paranoid mode is enabled. /// /// Safe config files do not require trust: files that only contain -/// `min_version` and `[tools]` entries with plain version strings (no -/// templates and no tool options) are loaded without prompting, since -/// nothing in them can execute code or change mise's behavior. +/// `min_version`, `[tools]` entries with plain version strings, and +/// `[tasks]` (no templates and no tool options) are loaded without +/// prompting, since nothing in them executes code at load time — tools +/// install and tasks run only on explicit commands like `mise install` +/// or `mise run`. #[derive(Debug, clap::Args)] #[clap(verbatim_doc_comment, after_long_help = AFTER_LONG_HELP)] pub struct Trust { diff --git a/src/config/config_file/mise_toml.rs b/src/config/config_file/mise_toml.rs index 01f1ab491a..334a65fea4 100644 --- a/src/config/config_file/mise_toml.rs +++ b/src/config/config_file/mise_toml.rs @@ -309,10 +309,10 @@ impl MiseToml { /// Whether this config body can be loaded without trusting the file. /// /// Safe configs cannot execute code or change mise's behavior beyond - /// requesting tool versions, so there is nothing to gate behind a trust - /// prompt. Anything else — env vars, tasks, hooks, settings, aliases, - /// templates, tool options like `postinstall`/`install_env` — still - /// requires trust. + /// requesting tool versions and defining tasks, so there is nothing to + /// gate behind a trust prompt. Anything else — env vars, hooks, settings, + /// aliases, templates, tool options like `postinstall`/`install_env` — + /// still requires trust. fn is_trust_exempt(body: &str, path: &Path) -> bool { if Settings::try_get().is_ok_and(|settings| settings.paranoid) { return false; @@ -1970,12 +1970,17 @@ impl<'de> de::Deserialize<'de> for Alias { } } -/// A config body is safe to load without trust when it only requests tool -/// versions. `min_version` and `[tools]` entries with plain version strings -/// cannot execute anything by themselves — installation only happens when the -/// user explicitly runs a command like `mise install`. Tool entries with -/// options (tables) are excluded because options like `postinstall` and -/// `install_env` run code or alter the install environment. +/// A config body is safe to load without trust when nothing in it can execute +/// code at load time or change mise's behavior without an explicit user +/// action: +/// - `min_version` is inert +/// - `[tools]` entries with plain version strings only matter when the user +/// runs something like `mise install`. Entries with options (tables) are +/// excluded because options like `postinstall` and `install_env` run code +/// or alter the install environment. +/// - `[tasks]` definitions are inert until the user explicitly runs one +/// - no Tera template syntax anywhere — templates render while config and +/// tasks load and can run arbitrary commands via exec() fn is_safe_config_body(body: &str) -> bool { // Tera templates can run arbitrary commands via exec() when rendered if contains_template_syntax(body) { @@ -1986,7 +1991,7 @@ fn is_safe_config_body(body: &str) -> bool { return false; }; table.iter().all(|(key, value)| match key.as_str() { - "min_version" => true, + "min_version" | "tasks" => true, "tools" => value.as_table().is_some_and(|tools| { tools.values().all(|version| match version { toml::Value::String(_) => true, @@ -2595,6 +2600,16 @@ run = 'echo "template"' python = ["3.11", "3.12"] "cargo:eza" = "latest" "#})); + // tasks are inert until the user explicitly runs one + assert!(is_safe_config_body(indoc! {r#" + [tasks.build] + run = "cargo build" + dir = "src" + env = { FOO = "bar" } + [tasks.test] + depends = ["build"] + run = ["cargo test"] + "#})); // templates can execute commands assert!(!is_safe_config_body(indoc! {r#" @@ -2610,10 +2625,16 @@ run = 'echo "template"' [tools] node = [{ version = "20" }] "#})); - // anything beyond min_version/tools requires trust + // tasks with templates render (and can exec) while loading + assert!(!is_safe_config_body(indoc! {r#" + [tasks.build] + run = "cargo build" + description = "{{ exec(command='echo hi') }}" + "#})); + // anything beyond min_version/tools/tasks requires trust for body in [ "[env]\nFOO = \"bar\"", - "[tasks.build]\nrun = \"echo hi\"", + "[task_config]\nincludes = [\"tasks.toml\"]", "[hooks]\nenter = \"echo hi\"", "[settings]\nparanoid = false", "[alias]\nnode = \"asdf:foo/bar\"", diff --git a/src/config/config_file/mod.rs b/src/config/config_file/mod.rs index e702087bd1..5fc2c228f8 100644 --- a/src/config/config_file/mod.rs +++ b/src/config/config_file/mod.rs @@ -305,6 +305,14 @@ pub fn config_trust_root(path: &Path) -> PathBuf { } } +/// Whether the file or its trust root has been trusted. +/// +/// Unlike a passing [`trust_check`], this is false for files that merely do +/// not *need* trust (e.g. safe configs loaded without it). +pub fn is_path_trusted(path: &Path) -> bool { + is_trusted(&config_trust_root(path)) || is_trusted(path) +} + pub fn trust_check(path: &Path) -> eyre::Result<()> { static MUTEX: Mutex<()> = Mutex::new(()); let _lock = MUTEX.lock().unwrap(); // Prevent multiple checks at once so we don't prompt multiple times for the same path @@ -312,7 +320,7 @@ pub fn trust_check(path: &Path) -> eyre::Result<()> { let default_cmd = String::new(); let args = env::ARGS.read().unwrap(); let cmd = args.get(1).unwrap_or(&default_cmd).as_str(); - if is_trusted(&config_root) || is_trusted(path) || cmd == "trust" || cfg!(test) { + if is_path_trusted(path) || cmd == "trust" || cfg!(test) { return Ok(()); } if cmd != "hook-env" && !is_ignored(&config_root) && !is_ignored(path) { diff --git a/src/config/mod.rs b/src/config/mod.rs index 48a8bd132a..d7c0207970 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -20,7 +20,7 @@ use crate::cli::version; use crate::config::config_file::idiomatic_version::IdiomaticVersionFile; use crate::config::config_file::min_version::MinVersionSpec; use crate::config::config_file::mise_toml::{MiseToml, Tasks}; -use crate::config::config_file::{ConfigFile, config_trust_root, trust_check}; +use crate::config::config_file::{ConfigFile, config_trust_root, is_path_trusted, trust_check}; use crate::config::env_directive::{EnvResolveOptions, EnvResults, ToolsFilter}; use crate::config::tracking::Tracker; use crate::env::{MISE_DEFAULT_CONFIG_FILENAME, MISE_DEFAULT_TOOL_VERSIONS_FILENAME}; @@ -2533,6 +2533,9 @@ async fn load_file_tasks( let config_root = Arc::new(config_root.to_path_buf()); let cf_root = cf.config_root(); let task_config_dir = cf.task_config().dir.clone(); + // a config can only vouch for task include files when it was actually + // trusted — safe configs load without trust and cannot vouch for anything + let require_task_include_trust = !is_path_trusted(cf.get_path()); for include in includes { let paths = if include.starts_with("git::") { @@ -2547,7 +2550,7 @@ async fn load_file_tasks( &config_root, &task_config_dir, templates, - false, + require_task_include_trust, ) .await?; if is_global_task_include_path(&path) { @@ -2600,7 +2603,9 @@ pub async fn load_tasks_in_dir( templates: &IndexMap, ) -> Result> { let configs = configs_at_root(dir, config_files); - let require_task_include_trust = configs.is_empty(); + // a config can only vouch for task include files when it was actually + // trusted — safe configs load without trust and cannot vouch for anything + let require_task_include_trust = !configs.iter().any(|cf| is_path_trusted(cf.get_path())); let (includes, resolve_dir) = configs .iter() @@ -2661,12 +2666,24 @@ pub async fn load_tasks_in_dir( } fn trust_check_task_include(path: &Path, require_trust: bool) -> Result<()> { - if require_trust && !is_global_task_include_path(path) { + if require_trust && !is_global_task_include_path(path) && task_include_requires_trust(path) { trust_check(path)?; } Ok(()) } +/// Template-free task files are inert at load time: TOML and `#MISE` headers +/// are only parsed, and the scripts themselves only run on an explicit +/// `mise run`. Templates are what can execute code while tasks load (via +/// exec() etc. when task fields render), so only files containing template +/// syntax need trust. Paranoid mode keeps requiring trust for everything. +fn task_include_requires_trust(path: &Path) -> bool { + if Settings::try_get().is_ok_and(|settings| settings.paranoid) { + return true; + } + file::read_to_string(path).map_or(true, |body| contains_template_syntax(&body)) +} + async fn load_task_file( config: &Arc, path: &Path, From ad54f73fa28f9591418322a11e8e60ae2c2bd3b8 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 12 Jun 2026 22:27:10 +0000 Subject: [PATCH 3/6] fix(config): close trust bypass via escaped template delimiters is_safe_config_body only checked the raw TOML body for Tera delimiters, so a value like "\u007b\u007b exec(...) \u007d\u007d" (which TOML decodes to a real template) loaded without trust and executed via exec() when the tool version or task field rendered. Re-check every decoded string (keys and values, at any depth) for template syntax so escaped delimiters can't slip through. Also load safe (untrusted) mise.toml files through tracked-config reload paths: get_tracked_config_files skipped any path without a trust marker, which safe configs don't create, so their tool pins went missing from `mise ls --all-sources`, `mise upgrade`, and `mise prune`. Co-Authored-By: Claude Fable 5 --- docs/cli/trust.md | 8 ++-- docs/faq.md | 4 +- e2e/config/test_trust_safe_config | 25 +++++++++++- e2e/config/test_trust_safe_config_tracked | 23 ++++++++++++ mise.usage.kdl | 8 ++-- src/cli/trust.rs | 8 ++-- src/config/config_file/mise_toml.rs | 46 ++++++++++++++++++++++- src/config/mod.rs | 7 +++- 8 files changed, 111 insertions(+), 18 deletions(-) create mode 100644 e2e/config/test_trust_safe_config_tracked diff --git a/docs/cli/trust.md b/docs/cli/trust.md index 89d1b9a094..9b555c7124 100644 --- a/docs/cli/trust.md +++ b/docs/cli/trust.md @@ -13,10 +13,10 @@ discovery paths, fail with an untrusted-config error when it cannot prompt, or assume trust in detected CI unless paranoid mode is enabled. Safe config files do not require trust: files that only contain -`min_version`, `[tools]` entries with plain version strings, and -`[tasks]` (no templates and no tool options) are loaded without -prompting, since nothing in them executes code at load time — tools -install and tasks run only on explicit commands like `mise install` +`min_version`, `[tools]` entries with plain version strings (or arrays +of them), and `[tasks]` (no templates and no tool options) are loaded +without prompting, since nothing in them executes code at load time — +tools install and tasks run only on explicit commands like `mise install` or `mise run`. ## Arguments diff --git a/docs/faq.md b/docs/faq.md index 6b3b90f159..88b3d2bfca 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -242,8 +242,8 @@ mise upgrade --bump node ## My config file is being ignored / `mise trust` issues mise requires you to trust config files that were not created by you. Safe config files — -those that only contain `min_version`, `[tools]` entries with plain version strings, and -`[tasks]` (no templates and no tool options) — are loaded without trust, since nothing in +those that only contain `min_version`, `[tools]` entries with plain version strings (or +arrays of them), and `[tasks]` (no templates and no tool options) — are loaded without trust, since nothing in them executes code at load time: tools install and tasks run only on explicit commands like `mise install` or `mise run`. Everything else (env vars, hooks, settings, aliases, templates, tool options) requires trust. Common issues: diff --git a/e2e/config/test_trust_safe_config b/e2e/config/test_trust_safe_config index a03d5f5e79..d6e85a45b8 100644 --- a/e2e/config/test_trust_safe_config +++ b/e2e/config/test_trust_safe_config @@ -7,6 +7,15 @@ export MISE_TRUSTED_CONFIG_PATHS="" unset CI GITHUB_ACTIONS GITHUB_ACTION 2>/dev/null || true +# Fail if any trust marker file/symlink exists under trusted-configs. This is +# precise (a stale empty dir won't mask a wrongly-written marker) unlike a bare +# directory-existence check. +assert_no_trust_marker() { + local found + found=$(find "$MISE_STATE_DIR/trusted-configs" -mindepth 1 \( -type f -o -type l \) 2>/dev/null || true) + [[ -z $found ]] || fail "$1 (found trust markers: $found)" +} + mkdir -p project cd project || exit 1 @@ -23,7 +32,7 @@ EOF # loads without prompting and without creating a trust marker MISE_YES=0 mise install tiny assert_contains "MISE_YES=0 mise ls tiny" "3.1.0" -[[ ! -e $MISE_STATE_DIR/trusted-configs ]] || fail "safe config should not be silently trusted" +assert_no_trust_marker "safe config should not be silently trusted" # config tasks only execute on explicit `mise run`, so they don't need trust assert_contains "MISE_YES=0 mise run hi" "task-ran-without-trust" @@ -37,7 +46,7 @@ echo file-task-ran-without-trust EOF chmod +x mise-tasks/filetask assert_contains "MISE_YES=0 mise run filetask" "file-task-ran-without-trust" -[[ ! -e $MISE_STATE_DIR/trusted-configs ]] || fail "file task should not require or create trust" +assert_no_trust_marker "file task should not require or create trust" # a template in a file task header renders at load time, so it requires trust cat <<'EOF' >mise-tasks/evil @@ -70,6 +79,18 @@ output=$(MISE_YES=0 mise tasks 2>&1 || true) [[ ! -f marker ]] || fail "task template executed in untrusted config" echo "$output" | grep -qi "trust" || fail "expected trust error for task template, got: $output" +# escaped Tera delimiters ({ == '{', } == '}') decode to a template +# after TOML parsing; this must not bypass the safety check and exec +printf '[tools]\ntiny = "\\u007b\\u007b exec(command=%stouch marker%s) \\u007d\\u007d"\n' "'" "'" >mise.toml +output=$(MISE_YES=0 mise ls 2>&1 || true) +[[ ! -f marker ]] || fail "escaped template executed in untrusted config (tools)" +echo "$output" | grep -qi "trust" || fail "expected trust error for escaped tools template, got: $output" + +printf '[tasks.hi]\nrun = "echo hi"\ndescription = "\\u007b\\u007b exec(command=%stouch marker%s) \\u007d\\u007d"\n' "'" "'" >mise.toml +output=$(MISE_YES=0 mise tasks 2>&1 || true) +[[ ! -f marker ]] || fail "escaped template executed in untrusted config (tasks)" +echo "$output" | grep -qi "trust" || fail "expected trust error for escaped task template, got: $output" + # tool options can run code (postinstall) or alter installs (install_env) cat <<'EOF' >mise.toml [tools] diff --git a/e2e/config/test_trust_safe_config_tracked b/e2e/config/test_trust_safe_config_tracked new file mode 100644 index 0000000000..9800334901 --- /dev/null +++ b/e2e/config/test_trust_safe_config_tracked @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +# A safe (untrusted) mise.toml is tracked and its tool pins remain visible +# through tracked-config reload paths like `mise ls --all-sources`, even when +# invoked from a different directory. Regression: tracked-config loading used +# to skip any config without a trust marker, which safe configs don't create. + +export MISE_TRUSTED_CONFIG_PATHS="" +unset CI GITHUB_ACTIONS GITHUB_ACTION 2>/dev/null || true + +mkdir -p proj other +cat <<'EOF' >proj/mise.toml +[tools] +tiny = "3.1.0" +EOF + +# install from the safe config (loads + tracks it, no trust prompt/marker) +(cd proj && MISE_YES=0 mise install tiny) + +# from an unrelated directory, the tracked safe config's pin is still listed +cd other || exit 1 +assert_contains "MISE_YES=0 mise ls --all-sources 2>&1" "proj/mise.toml" +assert_contains "MISE_YES=0 mise ls --all-sources 2>&1" "tiny" diff --git a/mise.usage.kdl b/mise.usage.kdl index 375d8b729b..9474926a8d 100644 --- a/mise.usage.kdl +++ b/mise.usage.kdl @@ -3505,10 +3505,10 @@ discovery paths, fail with an untrusted-config error when it cannot prompt, or assume trust in detected CI unless paranoid mode is enabled. Safe config files do not require trust: files that only contain -`min_version`, `[tools]` entries with plain version strings, and -`[tasks]` (no templates and no tool options) are loaded without -prompting, since nothing in them executes code at load time — tools -install and tasks run only on explicit commands like `mise install` +`min_version`, `[tools]` entries with plain version strings (or arrays +of them), and `[tasks]` (no templates and no tool options) are loaded +without prompting, since nothing in them executes code at load time — +tools install and tasks run only on explicit commands like `mise install` or `mise run`. """# after_long_help #""" diff --git a/src/cli/trust.rs b/src/cli/trust.rs index 585db838bc..626d860975 100644 --- a/src/cli/trust.rs +++ b/src/cli/trust.rs @@ -20,10 +20,10 @@ use itertools::Itertools; /// or assume trust in detected CI unless paranoid mode is enabled. /// /// Safe config files do not require trust: files that only contain -/// `min_version`, `[tools]` entries with plain version strings, and -/// `[tasks]` (no templates and no tool options) are loaded without -/// prompting, since nothing in them executes code at load time — tools -/// install and tasks run only on explicit commands like `mise install` +/// `min_version`, `[tools]` entries with plain version strings (or arrays +/// of them), and `[tasks]` (no templates and no tool options) are loaded +/// without prompting, since nothing in them executes code at load time — +/// tools install and tasks run only on explicit commands like `mise install` /// or `mise run`. #[derive(Debug, clap::Args)] #[clap(verbatim_doc_comment, after_long_help = AFTER_LONG_HELP)] diff --git a/src/config/config_file/mise_toml.rs b/src/config/config_file/mise_toml.rs index 334a65fea4..5edf32db5f 100644 --- a/src/config/config_file/mise_toml.rs +++ b/src/config/config_file/mise_toml.rs @@ -306,6 +306,13 @@ impl MiseToml { Ok(rf) } + /// Whether the config file at `path` loads without trust (see + /// [`Self::is_trust_exempt`]). Returns false for unreadable files and for + /// non-mise.toml files (e.g. `.tool-versions`), which have their own flow. + pub fn path_is_trust_exempt(path: &Path) -> bool { + file::read_to_string(path).is_ok_and(|body| Self::is_trust_exempt(&body, path)) + } + /// Whether this config body can be loaded without trusting the file. /// /// Safe configs cannot execute code or change mise's behavior beyond @@ -1982,7 +1989,7 @@ impl<'de> de::Deserialize<'de> for Alias { /// - no Tera template syntax anywhere — templates render while config and /// tasks load and can run arbitrary commands via exec() fn is_safe_config_body(body: &str) -> bool { - // Tera templates can run arbitrary commands via exec() when rendered + // Fast reject: literal Tera delimiters in the raw text. if contains_template_syntax(body) { return false; } @@ -1990,6 +1997,14 @@ fn is_safe_config_body(body: &str) -> bool { // let the normal trust + parse flow handle invalid TOML return false; }; + // The raw-body check above misses escaped delimiters that TOML decodes, + // e.g. `"{{ exec(...) }}"` becomes `{{ exec(...) }}` + // after parsing and would still render via Tera. Re-check every decoded + // string (keys and values, at any depth) so no exec()-capable template + // can slip through into tool versions or task fields. + if toml_table_has_template(&table) { + return false; + } table.iter().all(|(key, value)| match key.as_str() { "min_version" | "tasks" => true, "tools" => value.as_table().is_some_and(|tools| { @@ -2005,6 +2020,23 @@ fn is_safe_config_body(body: &str) -> bool { }) } +/// Whether any decoded string (table key or value, at any depth) contains +/// Tera template syntax. +fn toml_value_has_template(value: &toml::Value) -> bool { + match value { + toml::Value::String(s) => contains_template_syntax(s), + toml::Value::Array(arr) => arr.iter().any(toml_value_has_template), + toml::Value::Table(t) => toml_table_has_template(t), + _ => false, + } +} + +fn toml_table_has_template(table: &toml::Table) -> bool { + table + .iter() + .any(|(k, v)| contains_template_syntax(k) || toml_value_has_template(v)) +} + fn is_tools_sorted(tools: &IndexMap) -> bool { let mut last = None; for k in tools.keys() { @@ -2631,6 +2663,18 @@ run = 'echo "template"' run = "cargo build" description = "{{ exec(command='echo hi') }}" "#})); + // escaped Tera delimiters ({ == '{', } == '}') decode to + // `{{ exec(...) }}` after TOML parsing and must not bypass the check + assert!(!is_safe_config_body( + "[tools]\nnode = \"\\u007b\\u007b exec(command='echo 20') \\u007d\\u007d\"\n" + )); + assert!(!is_safe_config_body( + "[tasks.build]\nrun = \"cargo build\"\ndescription = \"\\u007b\\u007b exec(command='echo hi') \\u007d\\u007d\"\n" + )); + // an escaped delimiter in a key must also be caught + assert!(!is_safe_config_body( + "[tasks]\n\"\\u007b\\u007b exec() \\u007d\\u007d\" = { run = \"x\" }\n" + )); // anything beyond min_version/tools/tasks requires trust for body in [ "[env]\nFOO = \"bar\"", diff --git a/src/config/mod.rs b/src/config/mod.rs index d7c0207970..fd31c377f6 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -542,7 +542,12 @@ impl Config { // to parse for trusted files. Untrusted non-MiseToml files (like // .tool-versions) don't need trust and will parse fine regardless. let trust_root = config_file::config_trust_root(&path); - if !config_file::is_trusted(&trust_root) && !config_file::is_trusted(&path) { + if !config_file::is_trusted(&trust_root) + && !config_file::is_trusted(&path) + // safe mise.toml files load without a trust marker, so a missing + // marker doesn't mean they should be skipped here + && !MiseToml::path_is_trust_exempt(&path) + { debug!("skipping untrusted tracked config: {}", display_path(&path)); continue; } From c70a4dc88892a50ea45b4efb2b65498b5e03c087 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 12 Jun 2026 22:28:12 +0000 Subject: [PATCH 4/6] docs: regenerate man page for trust safe-config text Co-Authored-By: Claude Fable 5 --- man/man1/mise.1 | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/man/man1/mise.1 b/man/man1/mise.1 index 752a9c9281..ab9421abc8 100644 --- a/man/man1/mise.1 +++ b/man/man1/mise.1 @@ -3402,6 +3402,13 @@ that may execute code or affect the environment. mise checks trust before parsing `mise.toml`. Without trust, mise may prompt, skip the config in some discovery paths, fail with an untrusted\-config error when it cannot prompt, or assume trust in detected CI unless paranoid mode is enabled. + +Safe config files do not require trust: files that only contain +`min_version`, `[tools]` entries with plain version strings (or arrays +of them), and `[tasks]` (no templates and no tool options) are loaded +without prompting, since nothing in them executes code at load time — +tools install and tasks run only on explicit commands like `mise install` +or `mise run`. .PP \fBUsage:\fR mise trust [OPTIONS] [] .PP From c01306de3a0dfd4d335feb121ab964add237295e Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 12 Jun 2026 22:55:52 +0000 Subject: [PATCH 5/6] fix(task): close trust bypass via escaped templates in task includes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit task_include_requires_trust only scanned the raw file text, so a mise-tasks/*.toml field or a #MISE script header written with escaped delimiters (e.g. "\u007b\u007b exec(...) \u007d\u007d") decoded to a real template after parsing and rendered — running exec() — during `mise tasks` without trust. Now also reject template syntax found in the decoded TOML: whole-file for .toml includes, and #MISE header values for scripts (the only part parsed and rendered at load). Reuses the recursive toml_value_has_template helper added for is_safe_config_body. Co-Authored-By: Claude Fable 5 --- e2e/tasks/test_task_include_trust_escaped | 40 ++++++++++++ src/config/config_file/mise_toml.rs | 5 +- src/config/mod.rs | 8 ++- src/task/mod.rs | 75 +++++++++++++++++++---- 4 files changed, 113 insertions(+), 15 deletions(-) create mode 100644 e2e/tasks/test_task_include_trust_escaped diff --git a/e2e/tasks/test_task_include_trust_escaped b/e2e/tasks/test_task_include_trust_escaped new file mode 100644 index 0000000000..16c5011ea0 --- /dev/null +++ b/e2e/tasks/test_task_include_trust_escaped @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +# Task include files (mise-tasks/*.toml and #MISE script headers) that hide a +# Tera template behind escaped delimiters ({ -> '{', } -> '}') must +# still require trust: the escapes decode to a real template that renders — and +# can exec() — at load time. A raw-text scan alone would miss them. This is the +# non-paranoid case (paranoid mode trivially requires trust for everything). + +export MISE_TRUSTED_CONFIG_PATHS="" +unset CI GITHUB_ACTIONS GITHUB_ACTION 2>/dev/null || true + +mkdir -p mise-tasks + +# 1. escaped template in a .toml task file +printf '[evil]\nrun = "echo hi"\ndescription = "\\u007b\\u007b exec(command=%stouch toml-marker%s) \\u007d\\u007d"\n' "'" "'" >mise-tasks/ci.toml +output=$(MISE_YES=0 mise tasks 2>&1 || true) +[[ ! -f toml-marker ]] || fail "escaped template executed from untrusted .toml task include" +echo "$output" | grep -qi "not trusted" || fail "expected trust error for escaped .toml include, got: $output" +rm -f mise-tasks/ci.toml + +# 2. escaped template in a #MISE script header +cat <<'EOF' >mise-tasks/evil.sh +#!/usr/bin/env bash +#MISE description="{{ exec(command='touch script-marker') }}" +echo hi +EOF +chmod +x mise-tasks/evil.sh +output=$(MISE_YES=0 mise tasks 2>&1 || true) +[[ ! -f script-marker ]] || fail "escaped template executed from untrusted script header" +echo "$output" | grep -qi "not trusted" || fail "expected trust error for escaped script header, got: $output" +rm -f mise-tasks/evil.sh + +# 3. sanity: a plain (template-free) file task still loads without trust +cat <<'EOF' >mise-tasks/hello.sh +#!/usr/bin/env bash +#MISE description="a plain task" +echo plain-task-ran +EOF +chmod +x mise-tasks/hello.sh +assert_contains "MISE_YES=0 mise run hello" "plain-task-ran" diff --git a/src/config/config_file/mise_toml.rs b/src/config/config_file/mise_toml.rs index 5edf32db5f..3e3a1eae39 100644 --- a/src/config/config_file/mise_toml.rs +++ b/src/config/config_file/mise_toml.rs @@ -2021,8 +2021,9 @@ fn is_safe_config_body(body: &str) -> bool { } /// Whether any decoded string (table key or value, at any depth) contains -/// Tera template syntax. -fn toml_value_has_template(value: &toml::Value) -> bool { +/// Tera template syntax. Used to catch escaped delimiters (e.g. `{{`) +/// that a raw-text scan misses but that still render after TOML parsing. +pub(crate) fn toml_value_has_template(value: &toml::Value) -> bool { match value { toml::Value::String(s) => contains_template_syntax(s), toml::Value::Array(arr) => arr.iter().any(toml_value_has_template), diff --git a/src/config/mod.rs b/src/config/mod.rs index fd31c377f6..473a51a6a6 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -2686,7 +2686,13 @@ fn task_include_requires_trust(path: &Path) -> bool { if Settings::try_get().is_ok_and(|settings| settings.paranoid) { return true; } - file::read_to_string(path).map_or(true, |body| contains_template_syntax(&body)) + let Ok(body) = file::read_to_string(path) else { + // can't read it — fall back to requiring trust + return true; + }; + // literal delimiters, plus escaped ones (e.g. `{{`) that decode to + // templates after TOML parsing and would render at load time + contains_template_syntax(&body) || crate::task::file_has_decoded_template(path, &body) } async fn load_task_file( diff --git a/src/task/mod.rs b/src/task/mod.rs index 09ccf2ee7b..a6acf3fd68 100644 --- a/src/task/mod.rs +++ b/src/task/mod.rs @@ -504,6 +504,41 @@ pub struct Task { pub trailing_args: Vec, } +/// Parse the `#MISE key=value` (and `// MISE`, `:: MISE`, `[MISE]`) header +/// lines out of a task script into their decoded TOML values. +fn parse_mise_header_toml(body: &str) -> Result> { + body.lines() + .filter_map(|line| { + regex!(r"^(?:#|//|::)(?:MISE| ?\[MISE\]) ([a-z0-9_.-]+\s*=\s*[^\n]+)$").captures(line) + }) + .map(|captures| captures.extract().1) + .map(|[toml]| { + toml::de::from_str::(toml) + .map_err(|e| eyre::eyre!("failed to parse task header TOML {toml:?}: {e}")) + }) + .collect() +} + +/// Whether a task include file contains Tera template syntax only after TOML +/// decoding (e.g. `{{` written as `{{`). Such escapes pass a +/// raw-text scan but decode to real templates that render — and can `exec()` — +/// at load time, so they must still require trust. `.toml` task files are +/// checked whole; script files are checked through their `#MISE` headers (the +/// only part parsed and rendered at load). +pub(crate) fn file_has_decoded_template(path: &Path, body: &str) -> bool { + use crate::config::config_file::mise_toml::toml_value_has_template; + if path.extension().is_some_and(|e| e == "toml") { + // Unparseable TOML won't load as a task file (it errors before any + // render), so it doesn't need trust on this account. + toml::from_str::(body).is_ok_and(|v| toml_value_has_template(&v)) + } else { + parse_mise_header_toml(body) + .unwrap_or_default() + .iter() + .any(toml_value_has_template) + } +} + impl Task { pub fn new(path: &Path, prefix: &Path, config_root: &Path) -> Result { Ok(Self { @@ -521,18 +556,7 @@ impl Task { config_root: &Path, ) -> Result { let mut task = Task::new(path, prefix, config_root)?; - let info = file::read_to_string(path)? - .lines() - .filter_map(|line| { - regex!(r"^(?:#|//|::)(?:MISE| ?\[MISE\]) ([a-z0-9_.-]+\s*=\s*[^\n]+)$") - .captures(line) - }) - .map(|captures| captures.extract().1) - .map(|[toml]| { - toml::de::from_str::(toml) - .map_err(|e| eyre::eyre!("failed to parse task header TOML {toml:?}: {e}")) - }) - .collect::>>()? + let info = parse_mise_header_toml(&file::read_to_string(path)?)? .into_iter() .filter_map(|toml| toml.as_table().cloned()) .flatten() @@ -2307,6 +2331,33 @@ mod tests { }); } + #[test] + fn test_file_has_decoded_template() { + use super::file_has_decoded_template; + let toml = Path::new("ci.toml"); + let script = Path::new("script.sh"); + + // plain template-free includes do not need trust + assert!(!file_has_decoded_template( + toml, + "[hello]\nrun = \"echo hi\"\ndescription = \"a plain task\"\n" + )); + assert!(!file_has_decoded_template( + script, + "#!/usr/bin/env bash\n#MISE description=\"a plain task\"\necho hi\n" + )); + + // escaped delimiters ({ == '{', } == '}') decode to a template + assert!(file_has_decoded_template( + toml, + "[hello]\nrun = \"echo hi\"\ndescription = \"\\u007b\\u007b exec(command='x') \\u007d\\u007d\"\n" + )); + assert!(file_has_decoded_template( + script, + "#!/usr/bin/env bash\n#MISE description=\"\\u007b\\u007b exec(command='x') \\u007d\\u007d\"\necho hi\n" + )); + } + #[cfg(unix)] fn take_captured_fields() -> Option> { CAPTURED_PARSER_FIELDS.with(|captured| captured.lock().unwrap().take()) From 1dd9a7e26b997603fb3fccb097c60e8cab47f3d1 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 12 Jun 2026 23:08:31 +0000 Subject: [PATCH 6/6] test(task): exercise decoded #MISE header path in escaped-template test The script-header case used a literal {{ template, which the raw-text gate already rejects, so it didn't actually guard the decoded-header check. Use escaped {{ delimiters so the test locks down file_has_decoded_template for the #MISE script path. Co-Authored-By: Claude Fable 5 --- e2e/tasks/test_task_include_trust_escaped | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/e2e/tasks/test_task_include_trust_escaped b/e2e/tasks/test_task_include_trust_escaped index 16c5011ea0..4fd3881811 100644 --- a/e2e/tasks/test_task_include_trust_escaped +++ b/e2e/tasks/test_task_include_trust_escaped @@ -18,12 +18,14 @@ output=$(MISE_YES=0 mise tasks 2>&1 || true) echo "$output" | grep -qi "not trusted" || fail "expected trust error for escaped .toml include, got: $output" rm -f mise-tasks/ci.toml -# 2. escaped template in a #MISE script header -cat <<'EOF' >mise-tasks/evil.sh -#!/usr/bin/env bash -#MISE description="{{ exec(command='touch script-marker') }}" -echo hi -EOF +# 2. escaped template in a #MISE script header (printf so the \u escapes reach +# the file literally, exercising the decoded-header path rather than the +# raw-text gate) +{ + printf '#!/usr/bin/env bash\n' + printf '#MISE description="\\u007b\\u007b exec(command=%stouch script-marker%s) \\u007d\\u007d"\n' "'" "'" + printf 'echo hi\n' +} >mise-tasks/evil.sh chmod +x mise-tasks/evil.sh output=$(MISE_YES=0 mise tasks 2>&1 || true) [[ ! -f script-marker ]] || fail "escaped template executed from untrusted script header"