Skip to content

fix: gate untrusted project-local mcpls.toml and scope document_tracker lock per path#245

Merged
bug-ops merged 2 commits into
mainfrom
fix/229-mcpls-toml-untrusted-exec
Jul 25, 2026
Merged

fix: gate untrusted project-local mcpls.toml and scope document_tracker lock per path#245
bug-ops merged 2 commits into
mainfrom
fix/229-mcpls-toml-untrusted-exec

Conversation

@bug-ops

@bug-ops bug-ops commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Summary

Two independent fixes, developed and reviewed together since they landed in the same worktree/branch.

#229 (P1, security) — untrusted project-local config could redirect the spawned LSP command

ServerConfig::load() discovered and loaded ./mcpls.toml relative to the process's current working directory unconditionally, and that file's command/args/[workspace] fed directly into the LSP server mcpls spawns. Running mcpls against an untrusted checkout (git clone && mcpls) could execute an attacker-chosen command with no confirmation.

Interactive confirmation isn't viable since stdio is the MCP transport itself, so the fix is a load-time opt-in gate: a CWD-discovered ./mcpls.toml is now ignored by default (with a warning naming the ignored path), falling through to global config and the existing built-in project-marker heuristics. Pass --trust-project-config or set MCPLS_TRUST_PROJECT_CONFIG=true to opt in. An explicit --config/MCPLS_CONFIG path is unaffected — naming a path is itself consent.

Note: the original report's claim that env is attacker-controlled at spawn time does not hold today — env is parsed but never passed to the spawned process. This fix is scoped to command/args/[workspace].

Two informational findings surfaced during audit are explicitly out of scope for this PR and worth their own issues:

  • env field is parsed but never applied to the spawned process (dead field today, so not currently exploitable).
  • No env_clear() before spawn — a trusted server's child process inherits mcpls's full environment.
  • Also worth a fast-follow: the ignore-warning is stderr-only and MCP clients typically swallow it, so an agent gets silent degradation. Recommend surfacing via ServerInfo.instructions in a follow-up.

Breaking change (acceptable pre-1.0): existing users relying on bare ./mcpls.toml auto-load must now pass --trust-project-config / set the env var.

#227 (P3) — document_tracker lock spanned disk I/O and didOpen notify in ensure_open

The document_tracker lock was a single lock shared across every language and path, held across ensure_open's own awaits (a disk stat, an optional full file re-read, and the textDocument/didOpen notify — a bounded channel send with no timeout). A wedged language server that stopped draining its stdin could stall ensure_open for unrelated files and languages.

DocumentTracker now guards its map only for short synchronous sections and serializes ensure_open per path via a keyed async lock: concurrent opens on different paths no longer wait on each other, while concurrent opens on the same path still collapse into exactly one didOpen.

Breaking change (acceptable pre-1.0): DocumentTracker methods now take &self instead of &mut self; get returns an owned DocumentState; open_paths returns Vec<PathBuf>; Translator::open_document_paths/is_document_open are no longer async.

Test plan

  • cargo +nightly fmt --all -- --check
  • cargo clippy --all-targets --all-features --workspace -- -D warnings
  • cargo nextest run --workspace --all-features --lib --bins (455 passed)
  • RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features
  • New regression tests for both fixes, including concurrency tests exercising the per-path lock invariants (different paths don't serialize; same path still produces exactly one didOpen) and CLI subprocess tests for the trust gate (flag grant, env true grant, env false/malformed no-grant)
  • auto-loaded ./mcpls.toml executes arbitrary command/args/env with no confirmation #229 fix independently verified twice against the built binary with a planted hostile mcpls.toml (untrusted run does not spawn the hostile command; --trust-project-config does)

Closes #229, closes #227

bug-ops added 2 commits July 25, 2026 04:24
The document_tracker lock spanned every ensure_open await -- a disk
stat, an optional full re-read, and the didOpen/didChange notify (a
bounded channel send with no timeout) -- across every language and
path. A wedged language server that stopped draining its stdin could
stall ensure_open for unrelated files and languages, not just the one
talking to that server.

DocumentTracker now guards its map only for short synchronous
sections and serializes ensure_open per path via a keyed async lock,
so concurrent opens on different paths no longer wait on each other
while concurrent opens on the same path still collapse into exactly
one didOpen.

Fixes #227
mcpls auto-loaded ./mcpls.toml relative to the process working
directory and fed its command/args/[workspace] straight into the
spawned LSP server with no confirmation. Since mcpls is normally
started against an arbitrary checkout by an AI agent, a hostile
repository could ship an mcpls.toml that spawns an attacker-chosen
process the moment mcpls starts -- effectively code execution via
git clone && start mcpls in that directory.

Interactive confirmation isn't viable since stdio is the MCP
transport itself, so the gate is opt-in and load-time: a
CWD-discovered ./mcpls.toml is now ignored by default (logging a
warning that names the ignored path), falling through to global
config and the existing built-in project-marker heuristics. Passing
--trust-project-config or setting MCPLS_TRUST_PROJECT_CONFIG=true
opts back in. An explicit --config/MCPLS_CONFIG path is unaffected,
since naming a path is itself consent.

Note: the original report's claim that env is attacker-controlled
at spawn time doesn't hold today -- env is parsed but never passed
to the spawned process. This fix is scoped to command/args/
[workspace].

Fixes #229
@github-actions github-actions Bot added documentation Improvements or additions to documentation rust Rust code changes testing Test-related changes mcpls-core mcpls-core crate changes mcpls-cli mcpls-cli crate changes labels Jul 25, 2026
@bug-ops
bug-ops requested a review from Copilot July 25, 2026 02:25

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR hardens mcpls startup against untrusted workspace configuration by gating CWD-discovered ./mcpls.toml, and improves concurrency in the MCP↔LSP bridge by scoping document tracking synchronization to a per-path async lock instead of a single global lock.

Changes:

  • Add an explicit trust gate (--trust-project-config / MCPLS_TRUST_PROJECT_CONFIG) so ./mcpls.toml in the current directory is ignored by default unless opted in.
  • Refactor DocumentTracker to avoid holding a global lock across disk I/O / LSP notifications by using per-path async locking and short-lived std mutex critical sections.
  • Update docs, changelog, and tests (including CLI integration and concurrency regressions) to cover the new behavior and API changes.

Reviewed changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
README.md Documents MCPLS_TRUST_PROJECT_CONFIG and warns that ./mcpls.toml is ignored by default.
examples/mcpls.toml Notes the new trust gate behavior for project-local configs.
docs/user-guide/troubleshooting.md Adds guidance for the “CWD config ignored” warning and how to opt in.
docs/user-guide/installation.md Updates config discovery order to reflect the trust requirement.
docs/user-guide/getting-started.md Updates config discovery order to reflect the trust requirement.
docs/user-guide/configuration.md Adds a dedicated “Trusting a Project-Local Config” section and clarifies trust semantics.
crates/mcpls-core/tests/integration/basic_tests.rs Adjusts tests to account for Translator APIs no longer being async.
crates/mcpls-core/src/mcp/server.rs Updates resource listing to use non-async Translator::open_document_paths.
crates/mcpls-core/src/lib.rs Re-exports ProjectConfigTrust as part of the public API surface.
crates/mcpls-core/src/config/mod.rs Introduces ProjectConfigTrust and ServerConfig::load_with_trust to gate CWD config loading; adds tests.
crates/mcpls-core/src/bridge/translator.rs Removes outer async lock around DocumentTracker; relies on internal per-path locking.
crates/mcpls-core/src/bridge/state.rs Implements per-path async locking + short std-mutex map locks; adds concurrency regression tests.
crates/mcpls-core/src/bridge/mod.rs Centralizes lock_std helper for consistent poisoned-mutex recovery.
crates/mcpls-cli/tests/cli_integration.rs Adds CLI regression tests for trust gating; ensures ambient env doesn’t leak into tests.
crates/mcpls-cli/src/main.rs Wires CLI flag/env into ServerConfig::load_with_trust.
crates/mcpls-cli/src/args.rs Adds --trust-project-config / MCPLS_TRUST_PROJECT_CONFIG argument and parsing tests.
crates/mcpls-cli/README.md Documents the new trust gate and CLI flag/env var.
CHANGELOG.md Adds entries for the new trust gate and document tracker changes (needs minor structure adjustment per guideline).

Comment thread CHANGELOG.md
@bug-ops
bug-ops enabled auto-merge (squash) July 25, 2026 02:29
@bug-ops
bug-ops merged commit f57d85c into main Jul 25, 2026
28 checks passed
@bug-ops
bug-ops deleted the fix/229-mcpls-toml-untrusted-exec branch July 25, 2026 02:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation mcpls-cli mcpls-cli crate changes mcpls-core mcpls-core crate changes rust Rust code changes testing Test-related changes

Projects

None yet

2 participants