Scope and safety. This article analyses a fixed, publicly disclosed vulnerability using the vendor’s own published source references. It describes the defective data flow and the remediation. It does not include working traversal strings, target path lists, injection prompts, or a runnable proof of concept.

Executive summary

CVE-2026-86541 is a path-traversal vulnerability in the code.replace MCP action of knowns before 0.30.0. The handler took a caller-supplied path, used it verbatim if it was absolute and joined it to the project root otherwise, read the file, performed a literal or regular-expression substitution, and wrote the result back with os.WriteFile.

No step established that the resolved path was inside the project. An absolute path skipped the project root entirely; a relative path with parent-directory segments walked out of it.

The weakness is CWE-22: Improper Limitation of a Pathname to a Restricted Directory. The vendor scores it CVSS 4.0 7.2 High (CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:L/VI:H/VA:H/SC:N/SI:N/SA:N) and CVSS 3.1 8.3 High (CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:H/A:H). The CVE record specifically names shell startup scripts and SSH configuration files as targets.

Like CVE-2026-86439, the caller here is an AI assistant, not a browser. And like that issue, it pairs with CVE-2026-86544: code.replace was also misclassified as a read operation, so a session restricted to reading could invoke it in the first place. Read-only session, arbitrary host file write.

Fixed in knowns 0.30.0 by commit a2c98fc5c313.

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 deployment or attack-path consequence derived from those facts and clearly marked as such.

Key facts

Item Finding Confidence
Identifier CVE-2026-86541 Verified
Advisory GHSA-f539-xgc6-xw7q 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 Path traversal, CWE-22 Verified
Entry point path argument of the code.replace MCP action Verified
Dangerous sink os.WriteFile on an unconstrained path Verified
Accepts absolute paths Yes, used verbatim Verified
Named targets Shell startup scripts, SSH configuration Verified (CVE description)
CVSS 4.0 7.2 High Verified
CVSS 3.1 8.3 High Verified
Reporters Tong Hoang Gia, Nguyen Huy Hoang Verified
Fix commit a2c98fc5c313463576c9348beeec6a74ddd7333b Verified

Root-cause analysis

From internal/mcp/handlers/code.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
27
28
29
30
31
32
33
34
35
36
37
func handleCodeReplace(ctx context.Context, getStore func() *storage.Store, getCodeRuntime func() CodeRuntime, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
store := getStore()
if store == nil {
return errResult("no project loaded")
}
args := req.GetArguments()
path, ok := stringArg(args, "path")
if !ok || strings.TrimSpace(path) == "" {
return errResult("path is required")
}
needle, ok := textArg(args, "needle")
if !ok || needle == "" {
return errResult("needle is required")
}
repl, ok := textArg(args, "repl")
if !ok {
return errResult("repl is required")
}
mode, _ := stringArg(args, "mode")
if mode == "" {
mode = "literal"
}
allowMultiple := boolArg(args, "allow_multiple_occurrences")
absPath := path
if !filepath.IsAbs(absPath) {
absPath = filepath.Join(projectRoot(store), absPath)
}
data, err := os.ReadFile(absPath)
if err != nil {
return errResult(err.Error())
}
...
if err := os.WriteFile(absPath, []byte(next), 0644); err != nil {
return errResult(err.Error())
}
...
}

Two independent escapes in four lines

1
2
3
4
absPath := path
if !filepath.IsAbs(absPath) {
absPath = filepath.Join(projectRoot(store), absPath)
}

This is a resolution helper, not a containment check, and it fails in both branches:

  • Absolute path: the if is skipped and path is used exactly as supplied. The project root is never consulted. This is not really traversal at all — it is an unrestricted write target handed over directly.
  • Relative path: filepath.Join cleans the result lexically and resolves parent-directory segments outward, past the project root.

The pattern is deceptively reasonable. “If it is already absolute, we are done; otherwise make it absolute relative to the project” is correct resolution logic. It is only wrong because nothing afterwards asks whether the resolved path is somewhere the caller is allowed to write. Resolution and authorisation got conflated, and only resolution was implemented.

There is also no symlink handling, so even a correctly contained lexical path could point outside the root through a link inside it.

The substitution constraints are weaker than they look

At first glance the write seems bounded: the file must already exist (os.ReadFile must succeed), needle must be present, and by default more than one occurrence is rejected. That is not much of a constraint:

  • allow_multiple_occurrences is caller-supplied, so the multiplicity guard is off whenever the attacker wants it off;
  • mode: "regex" compiles needle as a Go regular expression, and a pattern that matches broadly turns a targeted substitution into a wholesale rewrite of the file’s contents;
  • the file only has to exist, and the most valuable targets on a developer machine — shell startup files, SSH configuration, git configuration — generally do.

Inferred: in practice this is a full-content write against any existing file the process can write, not a surgical patch. The VI:H/VA:H rating reflects that.

The permissions layer did not compensate

In the same release, code.replace had no entry in the action registry and inherited CapRead from the tool-level fallback. That is CVE-2026-86544. The practical consequence for this issue is that the capability gate, which existed and worked, was gating the wrong verb — so a session deliberately restricted to reading could still reach os.WriteFile.

Inferred: this is the more general lesson of the pair. A path-containment bug and a classification bug are each individually recoverable; together they remove both the outer and inner control on the same call.

The neighbouring handlers shared it

The fix also rewrites lspPathRequest, which fed handleCodeRename, handleCodeReplaceBody, handleCodeInsert, and handleCodeDelete with the identical IsAbs-or-Join idiom, and changes applyWorkspaceEdit to take the project root so that LSP-driven multi-file edits are contained too. Inferred: the pattern had been copied across the file, which is the usual reason a single-line bug appears in five handlers at once.

Why user interaction is in the vector, and why it is cheap

The vector carries UI:P (CVSS 4.0 passive) / UI:R (CVSS 3.1 required). That is accurate — somebody must be running an assistant session against the knowns MCP server for the call to happen.

Inferred: in an agentic workflow, this requirement is nearly free for an attacker. The “interaction” is a developer doing their normal job: opening a repository, asking an assistant to make a change, letting it read a README or an issue thread or a dependency’s documentation. If any of that content is attacker-influenced, the tool call can be too. The user does not approve a write to ~/.ssh/config; they approve “help me refactor this module”, and the tool argument is chosen downstream by the model.

The attack path:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Untrusted content the assistant reads
|
v
Model emits code.replace with an attacker-influenced `path`
|
| classified CapRead (CVE-2026-86544) -> capability gate passes
v
handleCodeReplace: absPath := path (absolute, used verbatim)
|
v
os.WriteFile(absPath, ...) as the developer's user
|
v
Shell startup file / SSH config / git hook -> execution at next trigger

Impact

Verified impact

  • Files outside the project root could be overwritten.
  • Both absolute paths and relative paths with traversal sequences were accepted.
  • Integrity and availability impacts are High; confidentiality is Low (the read that precedes the write returns nothing to the caller directly, but the substitution result is observable).
  • The CVE record names shell startup scripts and SSH configuration files as realistic targets.

Deployment-dependent consequences

The following are inferred:

  • code execution at the developer’s next shell start, git operation, or tool invocation, depending on which configuration file was modified;
  • redirection of SSH connections or acceptance of attacker key material, converting a local write into remote access;
  • modification of source, build scripts, or CI configuration in repositories outside the current project — a supply-chain path that reaches production via the developer’s legitimate commits;
  • corruption or truncation of arbitrary files through a broadly matching regular expression, which is the availability impact;
  • silent, targeted edits to code the developer is about to review, where a small change in a large assistant-generated diff is easy to miss.

Inferred: that final point is specific to agentic tooling and under-appreciated. Developers reviewing AI-generated changes are already scanning a larger diff than they wrote. A one-line modification introduced by a compromised tool call is competing for attention with dozens of legitimate ones.

Safe validation and detection

Inventory and configuration review

  1. Identify knowns installations and versions; anything before 0.30.0 is affected.
  2. Identify which assistants had the knowns MCP server configured, and whether code tools were auto-approved.
  3. Establish the window during which an affected version was in use with an assistant attached.
  4. Review the integrity of files outside the project that the account could write — this is the exposure scope, not the project directory.

Telemetry and integrity review

  • MCP tool invocations of code.replace, code.rename, code.insert, code.replace_body, and code.delete where path is absolute or contains parent-directory segments;
  • any code.replace call in regex mode with a broadly matching needle, or with allow_multiple_occurrences set;
  • modification times on shell startup files, SSH configuration, git hooks, and editor configuration that cluster around assistant activity;
  • for repositories under version control, unexplained working-tree changes; note that files outside a repository will not be covered by this check;
  • host filesystem audit events showing the knowns process writing outside the project root.

Inferred: assistant session transcripts are again the decisive artefact, because they contain the exact path argument. Retain them.

Remediation and hardening

Required remediation

Upgrade to knowns 0.30.0 or later. The fix replaces the resolution idiom with a containment-enforcing resolver and adds a second, independent check before any mutation:

1
2
3
4
5
6
7
8
absPath, err := safepath.ResolveProject(projectRoot(store), path)
if err != nil {
return errResult(err.Error())
}
if err := ensureCodeMutationPath(projectRoot(store), absPath); err != nil {
return errResult(err.Error())
}
data, err := os.ReadFile(absPath)

safepath.ResolveProject still accepts native absolute paths — the tool legitimately needs to work with them — but only when they resolve inside the root:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ResolveProject resolves a project path inside root. Native absolute paths
// are accepted only when they still resolve inside root.
func ResolveProject(root, untrusted string) (string, error) {
return resolve(root, untrusted, true)
}

// ResolveProjectReal is ResolveProject plus canonical symlink resolution. It
// is useful when callers must enforce policy against the final target path.
func ResolveProjectReal(root, untrusted string) (string, error) {
path, err := resolve(root, untrusted, true)
if err != nil {
return "", err
}
return resolveProspectivePath(path)
}

That distinction is the well-designed part of the patch. The requirement was never “reject absolute paths”; it was “the final target must be inside the root”. Expressing it that way keeps the feature working while making the unsafe case impossible.

ensureCodeMutationPath then resolves symlinks with filepath.EvalSymlinks and re-checks containment against the real root, so a link planted inside the project cannot redirect a write outside it. The same commit applies both checks to lspPathRequest and handleCodeRename, and threads the project root into applyWorkspaceEdit(projectRoot(store), changes) so LSP-driven multi-file edits are contained as well.

Separately, the commit adds confirmed and reason arguments to the code tool for action=delete, so destructive operations require explicit acknowledgement rather than inheriting the same path as an edit.

Defense in depth

  • Do not auto-approve MCP code-mutation tools; require confirmation that shows the resolved absolute path, not the argument as supplied.
  • Run assistants against a filesystem boundary enforced by the OS — container, dedicated user, or sandbox — so an application-level containment bug is not the only control.
  • Keep SSH keys, cloud credentials, and shell startup files outside the reach of tooling that edits code, where your workflow allows it.
  • Enable file integrity monitoring on the small set of files that grant execution or access: shell rc files, SSH config, git hooks, editor startup configuration.
  • Review AI-generated diffs against the set of files touched before reviewing their contents; an unexpected path is faster to spot than an unexpected line.

Timeline

Date Event Source certainty
2026-08-15 Fix commit a2c98fc5c313 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-86541 reserved and published CVE record metadata
2026-09-08 This analysis published This site

Lessons for builders

Resolving a path is not authorising it

if !filepath.IsAbs(p) { p = filepath.Join(root, p) } answers “where does this point?” and never asks “is the caller allowed to write there?”. Those are two operations. Write them as two, or use one function whose contract covers both.

Accepting absolute paths is fine; trusting them is not

The fix did not remove the capability. ResolveProject accepts absolute input and verifies containment. Prefer that shape over a blanket rejection — it keeps legitimate workflows working and removes the temptation to add an unchecked bypass later.

Lexical containment is necessary and insufficient. If the write matters, canonicalise with EvalSymlinks and check the real path, as ensureCodeMutationPath does.

Copied idioms copy their bugs

The same four lines appeared across several handlers, so one review miss became five vulnerable entry points. When you find a path bug, grep for the idiom rather than fixing the reported call site.

Two weak layers can fail as one

code.replace was both misclassified and uncontained. Neither the permission gate nor the path check stopped the write. When reviewing a mutating operation, verify each layer independently — a control you assume is upstream may be gating the wrong verb.

  • CVE-2026-86544 — the classification half of this pair, fixed in the same commit
  • CVE-2026-86439 — path traversal through MCP doc and memory tools
  • CVE-2026-86543 — unauthenticated management API exposure
  • CVE-2026-86538 — unauthenticated arbitrary file read via templateFile
  • CVE-2026-86542 — unauthenticated arbitrary file write via import name
  • CVE-2026-86540 — arbitrary code execution via the LSP binary override
  • 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.