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, or a runnable proof of concept.

Executive summary

CVE-2026-86542 is a path-traversal vulnerability in the import routes of knowns before 0.30.0. The import feature clones or registers an external source and stores its .knowns/docs and .knowns/templates content under .knowns/imports/{name}/. That {name} came straight from the request body — or was derived from the source URL — and was never validated as a single path segment before being joined onto the imports directory.

The result is an arbitrary file write: the destination escapes the imports directory, and for a git source the content is whatever the attacker put in the repository they asked knowns to clone. Attacker-chosen path, attacker-chosen content.

The weakness is CWE-22: Improper Limitation of a Pathname to a Restricted Directory. The vendor scores it CVSS 4.0 8.8 High (CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:H/SC:N/SI:N/SA:N) and CVSS 3.1 9.1 Critical (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:H) — the highest CVSS 3.1 score in this advisory set apart from the exposure issue itself.

PR:N again reflects CVE-2026-86543: the management API was unauthenticated on all interfaces by default.

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

Key facts

Item Finding Confidence
Identifier CVE-2026-86542 Verified
Advisory GHSA-wh3c-v55g-qfg8 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 name parameter on the import routes Verified
Dangerous sink os.MkdirAll and copyFile under an unvalidated destination Verified
Authentication required None, by default Verified
Impact Overwrite of arbitrary files writable by the server process Verified
CVSS 4.0 8.8 High Verified
CVSS 3.1 9.1 Critical Verified
Reporters Tong Hoang Gia, Nguyen Huy Hoang Verified
Fix commit d3989829fb5095666d23d005b2f78a082832a396 Verified

Why the write side is categorically worse

An arbitrary read is a confidentiality problem with a bounded, if severe, outcome: the attacker learns things. An arbitrary write on a developer workstation is an execution problem, because a developer’s home directory is full of files that something will later read and act on.

Inferred: the general shape of the escalation is that a write primitive plus any auto-executed configuration file equals code execution at the next trigger. Shell startup files, editor and tool configuration, git hooks, and per-project settings all get read by something that will do what they say. The attacker does not need memory corruption or a shell — they need patience and one file.

That is why this CVE matters more than its CVSS 4.0 score suggests. It is the pivot in the chain:

1
2
3
4
5
6
7
CVE-2026-86543           CVE-2026-86542            CVE-2026-86540
no auth, all interfaces arbitrary file write config-driven binary execution
| | |
+------------------------+--------------------------+
v
unauthenticated network caller -> persistent code execution
as the developer's user

Writing a crafted .knowns/config.json into a project directory is enough to arm CVE-2026-86540, which executes an unvalidated binary name from that file when the project is opened.

Root-cause analysis

The name is taken and used, never checked

From internal/server/routes/imports.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
// add registers a new import source.
//
// POST /api/imports
func (ir *ImportRoutes) add(w http.ResponseWriter, r *http.Request) {
var req addImportRequest
if err := decodeJSON(r, &req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
if req.Source == "" {
respondError(w, http.StatusBadRequest, "source is required")
return
}

name := req.Name
if name == "" {
name = nameFromSource(req.Source)
}

// Git URL: clone and copy .knowns/docs + templates.
if isGitURL(req.Source) {
changes, warnings, _, _, err := ir.gitCloneImport(req.Source, name, req.Ref, "", req.DryRun)
...
}

// Non-git source: create empty import directory.
destDir := filepath.Join(ir.importsDir(), name)
if err := os.MkdirAll(destDir, 0755); err != nil {
respondError(w, http.StatusInternalServerError, "create import dir: "+err.Error())
return
}
...
}

req.Source is checked for emptiness. req.Name is checked for emptiness only as a trigger for the fallback. Neither branch establishes that name is a single path segment before it reaches filepath.Join.

The non-git branch already gives directory creation at an arbitrary location. The git branch gives the full primitive.

The clone path multiplies the problem

From the same file, in gitCloneImport:

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
// Collect files from docs/ and templates/ subdirectories.
var changes []importChange
var warnings []string
destBase := filepath.Join(ir.importsDir(), name)

for _, sub := range []string{"docs", "templates"} {
srcDir := filepath.Join(knownsDir, sub)
if _, err := os.Stat(srcDir); os.IsNotExist(err) {
continue
}

err := filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
relPath, _ := filepath.Rel(knownsDir, path)
relPath = filepath.ToSlash(relPath)
if sub == "docs" {
subRel := strings.TrimPrefix(relPath, "docs/")
relPath = "docs/" + name + "/" + subRel
}
destPath := filepath.Join(destBase, relPath)
...
if !dryRun && action != "skip" {
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
return err
}
if err := copyFile(path, destPath); err != nil {
return err
}
}
...
})
}

Three properties of this loop are worth drawing out:

  1. destBase inherits the unvalidated name. Every destination in the walk is relative to a base that may already sit outside the imports directory.
  2. The name is interpolated a second time in relPath = "docs/" + name + "/" + subRel, so it influences the path twice — once through the base and once through the relative portion.
  3. os.MkdirAll creates whatever directories the destination needs. The write is not limited to overwriting files that already exist; it can construct the target directory tree first.

Combined with a source repository the attacker controls, the loop copies attacker-authored file content to attacker-chosen destinations. The only structural constraints are that the content lives under .knowns/docs or .knowns/templates in the source repo, and that the target must be writable by the knowns process.

The dryRun flag is not a security control

dryRun suppresses the copy, and is presumably why the feature felt safe to expose — a caller can preview changes. But it is a caller-supplied boolean. It reduces the impact of an accidental import; it does nothing about a deliberate one.

The other handlers shared the flaw

The fix commit adds validation to get, remove, and gitCloneImport as well as add. Inferred: the DELETE /api/imports/{name} route joining an unvalidated URL parameter onto the imports directory before calling os.RemoveAll is the availability half of the VA:H rating — a traversal there removes a directory tree outside the import root.

Impact

Verified impact

  • Unauthenticated attackers could write files outside the imports directory.
  • Arbitrary files writable by the server process could be overwritten.
  • Integrity and availability impacts are rated High; confidentiality is not directly affected (VC:N).

Deployment-dependent consequences

The following are inferred:

  • overwriting shell startup files, tool configuration, or git hooks to obtain code execution the next time the developer opens a terminal or runs a repository operation;
  • planting a malicious .knowns/config.json to chain into CVE-2026-86540;
  • corrupting or replacing source files, build scripts, and CI configuration in any repository the process can write to — a local supply-chain attack that reaches production through the developer’s own commits;
  • poisoning the docs, specs, and memory entries that an AI assistant treats as authoritative, which then influences code the developer reviews and accepts;
  • destructive impact through the delete path, including loss of project data;
  • persistence that survives restarting knowns, because the payload lives in files outside the tool.

Inferred: the AI-specific consequence deserves separate weight. A poisoned architectural decision record is not a defaced file; it is an instruction that a coding assistant will follow, in a document a human is unlikely to re-read. That is a quiet, durable influence channel over future code.

Safe validation and detection

Inventory and configuration review

  1. Identify knowns installations and versions; anything before 0.30.0 is affected.
  2. Establish whether the API was reachable beyond loopback and for how long — see CVE-2026-86543.
  3. List the configured imports and compare them to what the team actually intended to import. An import whose name is not a plain identifier is a finding.
  4. Review .knowns/imports/ for unexpected entries, and the directory above it for unexpected siblings.

Telemetry and integrity review

  • POST /api/imports and DELETE /api/imports/{name} requests where the name is not a simple single segment;
  • imports whose source is a repository nobody on the team recognises;
  • file modification times outside the project that cluster around an import request;
  • unexpected changes to shell startup files, git hooks, editor configuration, and any .knowns/config.json;
  • for repositories under version control, git status and git fsck will surface unexpected working-tree changes, though files outside a repository will not be covered;
  • host filesystem audit events showing the knowns process writing outside its data directory.

Inferred: because the payload is a file rather than a process, detection should focus on integrity monitoring rather than on runtime behaviour. The malicious write may execute days later, or never — but it is still there.

Remediation and hardening

Required remediation

Upgrade to knowns 0.30.0 or later. The fix adds an explicit, reusable validator and applies it at every entry point:

1
2
3
4
5
6
7
8
9
func validateImportName(name string) error {
if name == "" || name == "." || name == ".." ||
filepath.IsAbs(name) || filepath.VolumeName(name) != "" ||
strings.ContainsAny(name, `/\`) || strings.ContainsRune(name, '\x00') ||
filepath.Base(name) != name {
return fmt.Errorf("invalid import name %q: must be a single path segment", name)
}
return nil
}

This is a good validator, and it is worth reading closely because each clause closes a distinct bypass:

  • name == "." || name == ".." — the degenerate relative names;
  • filepath.IsAbs(name) — absolute paths that would ignore the base entirely;
  • filepath.VolumeName(name) != "" — Windows drive and UNC prefixes;
  • strings.ContainsAny(name, "/\\") — both separators, so a Windows-style separator cannot slip past a Unix-oriented check;
  • strings.ContainsRune(name, '\x00') — NUL truncation against the syscall layer;
  • filepath.Base(name) != name — the catch-all invariant: if the name is not identical to its own basename, it is not a single segment.

That final clause is the one to steal. It expresses the actual requirement — “this is a name, not a path” — rather than enumerating forbidden characters, so it stays correct as platform quirks change.

The validator is called in add, get, remove, and at the top of gitCloneImport, so the check cannot be bypassed by reaching the clone through a different route:

1
2
3
4
5
6
func (ir *ImportRoutes) gitCloneImport(source, name, ref, cachedHash string, dryRun bool) ([]importChange, []string, string, bool, error) {
if err := validateImportName(name); err != nil {
return nil, nil, "", false, err
}
...
}

Note that the fallback nameFromSource(req.Source) result is validated too, because the check sits after the fallback assignment. Derived values are untrusted values.

Defense in depth

  • Set a password and keep the listener on loopback (both are default in 0.30.0).
  • Treat “import from a URL” as a privileged operation and require authentication and explicit user confirmation for it.
  • Run developer tooling with a filesystem view that does not include the home directory’s dotfiles where practical.
  • Enable file integrity monitoring for shell startup files, git hooks, and tool configuration on developer machines.
  • If an affected version was exposed, audit for unexpected writes rather than assuming absence of an alert means absence of a write.

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-86542 reserved and published CVE record metadata
2026-09-08 This analysis published This site

Lessons for builders

A name is not a path

The single most useful invariant in this patch is filepath.Base(name) != name. If a value is documented as an identifier, enforce that it is one at the boundary. Everything downstream then gets to assume it.

Validate derived values, not just supplied ones

name could come from the request or from nameFromSource(req.Source). Only validating the request field would have left the derived path open. Put the check after the value is finalised, immediately before it is used.

Validate at every entry to the dangerous function

gitCloneImport is reachable from more than one route. The fix validates inside it as well as in the handlers. Guarding call sites is a maintenance burden that eventually fails; guarding the function is durable.

Write primitives are execution primitives on developer machines

Threat-model a file write as “the attacker will pick a file that something else executes”. On a workstation, that set is large and mostly undocumented. Score accordingly, and prioritise write traversals above read traversals when triaging.

dryRun is a UX feature

Any flag the caller controls is part of the attack surface, not part of the defence. If a preview mode exists, ensure the security properties hold identically in both modes.

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.