Scope and safety. This article analyses a fixed, publicly disclosed vulnerability using the vendor’s own published source references. It describes the defective control flow and the remediation. It does not include a malicious configuration file, command payloads, or a runnable proof of concept.

Executive summary

CVE-2026-86540 is an arbitrary code execution vulnerability in knowns before 0.30.0. Project configuration files (.knowns/config.json) may specify a language-server binary override through settings.lsp.languages[].binary. That field was never validated. When a repository containing a crafted configuration was opened, the named binary was resolved and executed under the user’s account — twice, once for the capability check and once when the language server was started.

The weakness is classified as CWE-78: Improper Neutralization of Special Elements used in an OS Command. The vendor scores it CVSS 4.0 8.5 High (CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:P/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N) and CVSS 3.1 7.8 High (CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H).

This is the “malicious repository, hostile on open” class — the same shape as workspace-trust bugs in IDEs and editors, where a checked-in configuration file is honoured before the user has read a single line of the project. It is also the payoff step for CVE-2026-86542: an unauthenticated attacker who can write a file can plant a .knowns/config.json and wait.

Fixed in knowns 0.30.0 by commit d3989829fb50.

Evidence labels used below:

  • Verified means stated by the CVE record, the GitHub Security Advisory, or the project’s published source and release material.
  • Inferred means a consequence derived from those facts and clearly marked as such.

Key facts

Item Finding Confidence
Identifier CVE-2026-86540 Verified
Advisory GHSA-mc52-mwq4-vfx3 Verified
Product knowns (pkg:npm/knowns, Go implementation) Verified
Affected versions All versions before 0.30.0 Verified
Fixed version 0.30.0 Verified
Weakness OS command execution, CWE-78 Verified
Entry point settings.lsp.languages[].binary in .knowns/config.json Verified
Trigger Opening a repository containing a crafted configuration Verified
Executions Twice, under the user’s account, without verification Verified
CVSS 4.0 8.5 High Verified
CVSS 3.1 7.8 High Verified
Reporters Tong Hoang Gia, Nguyen Huy Hoang Verified
Fix commit d3989829fb5095666d23d005b2f78a082832a396 Verified

Why “opening a project” is a security event

Modern developer tooling reads a great deal of configuration out of the repository itself, and that is a genuinely useful design: the project describes how it wants to be built, linted, formatted, and analysed. The trouble is that a repository is untrusted input until somebody has read it, and cloning is not reading.

Developers clone untrusted repositories constantly and for entirely legitimate reasons: reviewing a pull request from an external contributor, reproducing a bug report, evaluating a dependency, triaging a security report, forking an example. In every one of those cases, the ordinary workflow is clone first, read second. Any tool that acts on repository-supplied configuration before that second step has inverted the trust order.

The editor ecosystem learned this the hard way and responded with workspace trust prompts. Inferred: AI-oriented developer tooling is now re-encountering the same class, and often without the prompt, because these tools are designed to reduce the number of moments where a human is asked to confirm something.

The AI angle sharpens it further. A coding assistant’s value proposition is that it opens a project and immediately starts understanding it. That is precisely the behaviour that turns repository-supplied configuration into an execution trigger, and it happens in the window where the developer is least likely to be looking.

Root-cause analysis

The override is trusted verbatim

From internal/lsp/detect.go at v0.29.1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func (d *Detector) resolve(ctx context.Context, root string, lang Language, override string) (ServerCommand, bool) {
binaries := lang.Binaries
if override != "" {
binary := Binary{Name: override}
if len(binaries) > 0 {
binary.CheckArgs = append([]string(nil), binaries[0].CheckArgs...)
}
binaries = []Binary{binary}
}
for _, binary := range binaries {
path, err := d.LookPath(binary.Name)
if err != nil {
continue
}
if d.RunCheck != nil && len(binary.CheckArgs) > 0 {
checkCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
err = d.RunCheck(checkCtx, path, binary.CheckArgs...)
cancel()
if err != nil {
continue
}
}
return ServerCommand{Language: lang.ID, Name: binary.Name, Path: path, Args: append([]string(nil), binary.Args...), LogPath: LanguageLogPath(root, lang.ID)}, true
}
return ServerCommand{}, false
}

The critical line is the first one inside the if:

1
binary := Binary{Name: override}

lang.Binaries is the curated list of language servers the adapter knows about — a natural allowlist that already exists in the code. When an override is present, that list is discarded and replaced with a single entry whose name came from the project’s configuration file. The allowlist is right there and is bypassed rather than consulted.

The override then inherits CheckArgs from the first known binary, which is how the arguments for the check invocation get supplied even though the binary itself is unknown.

Execution happens twice

1
2
3
4
func runVersionCheck(ctx context.Context, path string, args ...string) error {
cmd := knownsprocess.CommandContext(ctx, path, args...)
return cmd.Run()
}

The first execution is the capability check — RunCheck runs the binary with the inherited arguments to confirm it works. The second is the language server start, once resolve returns a ServerCommand and the manager launches it.

Inferred: the check invocation is the more interesting of the two, because it is supposed to be a probe. It carries a two-second timeout, which reads like a safety measure, and against a hostile binary it is not one. Two seconds is ample to spawn a detached child, and a process the caller starts is not bounded by the caller’s context after it detaches. Design intent — “this is only a quick version check” — has no bearing on what the executed program actually does.

Manager.StartLanguage contained the same construction on the runtime path:

1
2
3
if override := m.config.BinaryOverride(langID); override != "" {
binaries = []BinaryCandidate{{Name: override}}
}

There is no shell, and it does not help

knownsprocess.CommandContext executes a program directly; there is no shell interpretation, so classic metacharacter injection does not apply. That is why the interesting field is the program name rather than the arguments.

It is worth being clear that this does not reduce the severity. CWE-78 is assigned because untrusted data determines what program runs. Whether a shell is involved changes the exploitation technique, not the outcome. The attacker does not need to inject a command into a command line when they can simply name the program.

d.LookPath resolves the name. Go’s standard library resolves a bare name against PATH and treats a name containing a path separator as a direct filesystem reference. Inferred: that means the override does not have to name something already installed — a reference to an executable carried inside the repository being opened would resolve, which is what makes the malicious-repository scenario self-contained.

Validation existed, and looked elsewhere

From internal/models/config.go at v0.29.1:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Validate rejects malformed lifecycle durations while permitting zero delay.
func (s ProjectSettings) Validate() error {
settings := s.EffectiveTaskLifecycle()
if _, err := ParseTaskLifecycleDuration(settings.ArchiveAfter); err != nil {
return fmt.Errorf("settings.taskLifecycle.archiveAfter: %w", err)
}
if settings.PurgeAfter != nil {
if _, err := ParseTaskLifecycleDuration(*settings.PurgeAfter); err != nil {
return fmt.Errorf("settings.taskLifecycle.purgeAfter: %w", err)
}
}
return nil
}

ProjectSettings.Validate is the obvious place a reviewer would look to confirm that project settings are checked. It validates duration strings and nothing else. A function named Validate that covers one field creates a false impression of coverage — arguably a worse position than having no validation function at all, because it answers the reviewer’s question with a reassuring “yes”.

The trust boundary

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Repository from an untrusted source
|
| .knowns/config.json
v
Project settings loader
|
| settings.lsp.languages[].binary <-- ProjectSettings.Validate ignores this field
v
Detector.resolve
|
| binaries = []Binary{{Name: override}} <-- known-good list discarded
v
LookPath -> RunCheck (execution #1) -> server start (execution #2)
|
v
Developer's user account, environment, credentials, and network position

Impact

Verified impact

  • Arbitrary binaries execute under the user’s account when a repository with a crafted configuration is opened.
  • No privileges are required beyond the victim opening the project (PR:N, UI:P/UI:R).
  • Confidentiality, integrity, and availability impacts are all rated High.
  • Execution occurs twice and without any verification of the named binary.

Deployment-dependent consequences

The following are inferred:

  • full compromise of the developer’s account, including SSH keys, cloud credentials, tokens, and browser session material;
  • access to whatever the workstation can reach — internal services, VPN-routed networks, artefact registries, production consoles;
  • persistence via shell startup files, scheduled tasks, or tool configuration, established in the seconds after the repository is opened;
  • commit-signing and repository access abuse, turning workstation compromise into a supply-chain event;
  • execution on CI runners and shared development hosts, if automation opens untrusted repositories with knowns attached;
  • completion of a remote chain when combined with CVE-2026-86542 — unauthenticated file write plants the configuration, this issue executes it.

Inferred: the review workflow is the highest-risk path in practice. Reviewing an external contribution means deliberately opening code from someone you do not trust; that is the whole point of the review. A tool that executes configuration on open turns the most security-conscious activity in the workflow into the delivery mechanism.

Safe validation and detection

Inventory and configuration review

  1. Identify knowns installations and versions; anything before 0.30.0 is affected.
  2. Search local repositories for .knowns/config.json files containing an LSP binary override, and compare each against what your team actually configured.
  3. Pay particular attention to repositories cloned from external sources, forks, and pull-request checkouts.
  4. Inspect any override that names a path rather than a familiar language-server binary.

A non-invasive inventory sweep — this only reads and reports, it does not execute anything:

1
2
3
# Locate LSP binary overrides in project configs for manual review.
find . -name config.json -path '*/.knowns/*' -print0 \
| xargs -0 grep -l '"binary"' 2>/dev/null

Review each hit by hand. Do not open a suspicious repository with an affected version in order to test the behaviour.

Telemetry to review

  • process creation events where the knowns process, or a language-server manager, is the parent of an unexpected child;
  • short-lived child processes spawned within seconds of a project being opened;
  • executions of binaries located inside a repository working tree;
  • outbound network connections immediately following a clone or project-open action;
  • new persistence artefacts — shell rc changes, cron or systemd user units, login items — created around the same time;
  • language-server logs referencing a binary name that is not a known server for that language.

Inferred: parent-child process telemetry is the strongest signal, because the anomaly is structural rather than content-based. A documentation and context tool spawning an unrecognised executable is unusual regardless of what that executable does, and it does not depend on having captured the configuration file beforehand.

Remediation and hardening

Required remediation

Upgrade to knowns 0.30.0 or later. The fix converts the override from a free-form name into a selection from the adapter’s registered binaries:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func selectBinaryOverride(override string, candidates []Binary) (Binary, error) {
if strings.TrimSpace(override) != override || override == "" {
return Binary{}, invalidBinaryOverride(override)
}
for _, candidate := range candidates {
if candidate.Name == override {
return candidate, nil
}
}
return Binary{}, invalidBinaryOverride(override)
}

func invalidBinaryOverride(override string) error {
return fmt.Errorf("binary override %q must match a registered language-server binary", override)
}

Detector.resolve now fails closed rather than substituting:

1
2
3
4
5
6
7
if override != "" {
binary, err := selectBinaryOverride(override, binaries)
if err != nil {
return ServerCommand{}, false
}
binaries = []Binary{binary}
}

The manager gained a matching validator with an explicit statement of the rule:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ValidateBinaryOverride permits selecting only binaries declared by the
// registered language adapter. Arbitrary executable names and paths are never
// accepted from runtime configuration.
func (m *Manager) ValidateBinaryOverride(langID, override string) error {
if override == "" {
return nil
}
m.mu.Lock()
adapter := m.adapters[langID]
m.mu.Unlock()
if adapter == nil {
return fmt.Errorf("no adapter registered for language %q", langID)
}
_, err := selectBinaryCandidateOverride(override, adapter.Binaries())
return err
}

and StartLanguage resolves through the same allowlist rather than trusting the configured name. The HTTP LSP route validates the override too, so the rule holds whether the value arrives from a file or an API call.

The design change worth naming: the override went from being a value to being an index into a known set. The whole class of “what if the name is malicious” disappears, because a name that is not in the set is not a name the system will act on. Note also that the returned candidate carries the adapter’s own CheckArgs and Args, so the fix removes the odd inheritance from binaries[0] at the same time.

Defense in depth

  • Treat repository-supplied configuration as untrusted until a human has reviewed it, and do not open unfamiliar repositories with tooling that acts on their configuration.
  • Review external contributions in a disposable environment — a container, a VM, or an ephemeral cloud workspace — rather than on the machine that holds your credentials.
  • Prefer tools that require explicit trust before honouring workspace configuration, and enable that prompt where it exists.
  • Keep long-lived credentials off the machine that opens untrusted code; use short-lived, brokered credentials.
  • Monitor process creation on developer workstations. Parent-child anomalies are the most reliable detection for this entire class.
  • In CI, never let an untrusted repository’s configuration influence what the runner executes.

Timeline

Date Event Source certainty
2026-08-15 Fix commit d3989829fb50 authored in the knowns repository Repository metadata
2026-08-16 Public disclosure date recorded on the CVE record CVE record datePublic
2026-09-07 CVE-2026-86540 reserved and published CVE record metadata
2026-09-08 This analysis published This site

Lessons for builders

Configuration from a repository is attacker input

If a file inside a project can influence what your tool executes, the project’s authors can execute code on your users’ machines. Enumerate every field that reaches a process launch, a plugin load, or a script evaluation, and treat each one as hostile.

Select from a set; do not accept a name

The strongest form of this fix is not a better filter — it is a different data model. When the configuration selects among options the application already knows, malicious values have nowhere to go. Reach for that shape before reaching for validation.

You already had the allowlist

lang.Binaries was the list of acceptable language servers, sitting in the same function. The override discarded it. When adding a customisation point, check whether the code already contains the set of valid answers — overrides that bypass an existing allowlist are a recurring and highly detectable pattern.

CWE-78 without a shell is still CWE-78

Executing a program directly avoids shell metacharacter injection and does not make untrusted program names safe. Focus review on what runs, not only on how arguments are quoted.

A Validate method that validates one field is a trap

ProjectSettings.Validate gave reviewers a reason to stop looking. If a validation function does not cover every security-relevant field in a structure, say so in its name or its comment — or, better, make it cover them.

A probe is a full execution

RunCheck was meant to ask “does this binary work?”. Against a hostile binary, running it is the compromise, and a short timeout does not contain a process that detaches. There is no such thing as a partial execution.

  • CVE-2026-86542 — unauthenticated file write that can plant the configuration this issue executes
  • CVE-2026-86543 — unauthenticated management API exposure
  • CVE-2026-86538 — unauthenticated arbitrary file read via templateFile
  • CVE-2026-86439 — path traversal through MCP doc and memory tools
  • CVE-2026-86541 — path traversal in the code.replace MCP action
  • CVE-2026-86544 — authorisation bypass via misclassified code actions
  • CVE-2026-86775 — unauthenticated path traversal through the HTTP Document API

References

Reported by Tong Hoang Gia and Nguyen Huy Hoang. Recheck the primary sources above during remediation; public vulnerability records are frequently revised after publication.