Scope and safety. This article analyses a fixed, publicly disclosed vulnerability using the vendor’s own published source references. It describes the defective authorisation logic and the remediation. It does not include a runnable proof of concept or injection prompts.

Executive summary

CVE-2026-86544 is an incorrect-authorisation vulnerability in knowns before 0.30.0. The MCP permission layer classified each tool action as read, write, generate, delete, or admin, and a guard middleware enforced the session’s policy against that classification. The mechanism was sound. The data it consulted was wrong.

The action registry listed only the four read code actions. Every mutating code action — code.replace, code.rename, code.insert, code.replace_body, code.delete — was missing, and the lookup fell back to a tool-level default that classified the entire code tool as CapRead. A session deliberately restricted to reading therefore passed the capability gate for operations that write and delete files.

The weakness is CWE-863: Incorrect Authorization. The vendor scores it CVSS 4.0 7.2 High (CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:H/SC:N/SI:N/SA:N) and CVSS 3.1 8.1 High (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H).

The escalation is what lifts this above a simple gate bypass. Because code.replace can write files, and the permission policy is itself configuration on disk, an attacker in a read-restricted session could rewrite the policy — and then hold genuine elevated permissions on every subsequent call. A bypass becomes a grant.

Fixed in knowns 0.30.0 by commit a2c98fc5c313, the same commit that fixed CVE-2026-86541.

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-86544 Verified
Advisory GHSA-w323-3wpx-f7g5 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 Incorrect authorisation, CWE-863 Verified
Defective component ActionRegistry / ClassifyAction in internal/permissions Verified
Enforcement point Guard middleware capability gate Verified
Escalation Modify permission configuration, then act with elevated rights Verified
CVSS 4.0 7.2 High Verified
CVSS 3.1 8.1 High Verified
Reporters Tong Hoang Gia, Nguyen Huy Hoang Verified
Fix commit a2c98fc5c313463576c9348beeec6a74ddd7333b Verified

Root-cause analysis

The registry knew about reads and not writes

From internal/permissions/registry.go at v0.29.1:

1
2
3
4
5
// code
"code.search": {Capability: CapRead, Target: TargetCode, Risk: RiskLow},
"code.symbols": {Capability: CapRead, Target: TargetCode, Risk: RiskLow},
"code.deps": {Capability: CapRead, Target: TargetCode, Risk: RiskLow},
"code.graph": {Capability: CapRead, Target: TargetCode, Risk: RiskLow},

Four entries. The code tool shipped considerably more actions than four — the handler file implements find, definition, references, implementations, diagnostics, rename, replace, replace_body, insert, and delete. None of the mutating ones appear.

The fallbacks are fail-open, twice

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
// toolFallback provides default classification at the tool level when no
// specific tool.action entry exists.
var toolFallback = map[string]ActionMeta{
"tasks": {Capability: CapRead, Target: TargetTask, Risk: RiskLow},
"docs": {Capability: CapRead, Target: TargetDoc, Risk: RiskLow},
"time": {Capability: CapRead, Target: TargetTime, Risk: RiskLow},
"search": {Capability: CapRead, Target: TargetSearch, Risk: RiskLow},
"code": {Capability: CapRead, Target: TargetCode, Risk: RiskLow},
"templates": {Capability: CapRead, Target: TargetTemplate, Risk: RiskLow},
"validate": {Capability: CapRead, Target: TargetRuntime, Risk: RiskLow},
"memory": {Capability: CapRead, Target: TargetMemory, Risk: RiskLow},
"project": {Capability: CapAdmin, Target: TargetRuntime, Risk: RiskMedium},
}

// defaultMeta is returned when no classification is found.
var defaultMeta = ActionMeta{Capability: CapRead, Target: TargetRuntime, Risk: RiskLow}

// ClassifyAction returns the ActionMeta for a tool+action combination.
// It checks the full "tool.action" key first, then falls back to tool-level
// defaults, and finally returns a safe read default.
func ClassifyAction(tool, action string) ActionMeta {
key := tool + "." + action
if meta, ok := ActionRegistry[key]; ok {
return meta
}
if meta, ok := toolFallback[tool]; ok {
return meta
}
return defaultMeta
}

The doc comment contains the whole bug in three words: “a safe read default”.

CapRead is the safe default for the caller. It is the dangerous default for the system. When an unknown action is classified as read, the effect is not that the action is treated cautiously — it is that the action is permitted under the most permissive common policy, while the guard reports success. The system fails open and says nothing.

Compare the one entry that got it right: "project": {Capability: CapAdmin, ...}. That fallback assumes the most restrictive capability, so an unrecognised project action is denied rather than allowed. The author clearly understood the principle; it simply was not applied to the tool whose action set had grown the most.

The guard was working correctly

From internal/permissions/guard.go at v0.29.1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Classify the action.
meta := classifyToolAction(toolName, action, args)

// Load effective policy.
cfg := configLoader()
policy := EffectivePolicy(cfg)

// Check dryRun from arguments.
dryRun := isDryRun(args) || lifecyclePreview(action, args)

// Pass 1: capability gate (no IO).
confirmedDelete := meta.Capability == CapDelete && explicitDeleteConfirmation(args)
if denial := CheckCapability(policy, meta, dryRun || confirmedDelete); denial != nil {
return denialResult(denial), nil
}

// Allowed — proceed to handler.
return next(ctx, req)

This is well-built enforcement: classify, load policy, evaluate, deny early without IO, and a separate confirmation requirement for deletes. Every line of it did its job. It received CapRead for a call that was about to run os.WriteFile, and correctly allowed a read.

Inferred: this is the characteristic failure mode of policy-driven authorisation, and it is worth naming. The enforcement code is easy to review — it is small, central, and obviously security-relevant. The classification data is a map literal that reads like configuration, gets extended by whoever adds a feature, and attracts no security review at all. The vulnerability lives in the data, and the data is the part nobody looks at.

Registry drift is the mechanism

Nobody wrote "code.replace": {Capability: CapRead}. That would have been caught. The bug arrived by omission: mutating actions were added to the handler and never added to the registry, and the fallback quietly covered the gap. Every new action shipped pre-authorised for read-restricted sessions, and there was no signal — no error, no log line, no test failure — that this had happened.

The escalation

Once a read-restricted session can invoke code.replace, it can write files. The permission policy is loaded from configuration on disk by configLoader(). Rewriting that configuration changes EffectivePolicy(cfg) for subsequent calls.

Inferred: the attacker’s position after that step is qualitatively different. They no longer need the classification bug — they hold a legitimate grant. Restoring the registry alone would not evict them; the modified policy file would have to be found and reverted too. That is why the fix hardens both the classification and the ability to write policy paths.

Combined with CVE-2026-86541, which let code.replace write outside the project root entirely, the pair yields: read-restricted MCP session → arbitrary file write on the host → durable elevated permissions.

Impact

Verified impact

  • Sessions restricted to read-only operations could invoke mutating code actions.
  • Attackers could modify permission configuration and escalate privileges on subsequent calls.
  • Integrity and availability impacts are High; confidentiality is not directly affected (VC:N).
  • Low privileges are required (PR:L); no user interaction is (UI:N).

Deployment-dependent consequences

The following are inferred:

  • any deployment relying on read-only MCP sessions as a safety boundary — sandboxed agents, review-only assistants, shared or multi-tenant setups, CI-attached agents — had no such boundary in practice;
  • deletion of source files through code.delete, which was likewise classified as read;
  • a durable policy grant that survives fixing the classification bug, if the configuration was rewritten;
  • silent failure: because the guard returned success, there is no denial record of the bypass in logs — the calls look like ordinary permitted operations;
  • amplification of prompt injection, since the read-only posture that made auto-approving code tools feel acceptable was not real.

Inferred: the reliance point matters more than the raw impact. A permission system that is trusted is more dangerous when wrong than one that is absent, because teams make architectural decisions on the strength of it — “the agent is read-only, so we can let it run unattended” is a reasonable decision built on a false premise.

Safe validation and detection

Inventory and configuration review

  1. Identify knowns installations and versions; anything before 0.30.0 is affected.
  2. Identify every session or integration configured with a restricted policy, and treat that restriction as not having been enforced for code actions.
  3. Inspect the current permission configuration for entries nobody set, and compare it against version control or a known-good copy.
  4. In your own MCP servers, diff the set of implemented actions against the set of classified actions. Any action present in the first and absent from the second is the same bug.

A useful, non-invasive audit for any registry of this shape:

1
2
# Actions the handlers implement, versus actions the registry classifies.
# Any name in the first list and not the second inherits the fallback.

Inferred: this comparison is worth automating as a test in any project that classifies actions in a lookup table. It is the check that would have caught this at authoring time.

Telemetry to review

  • MCP invocations of code.replace, code.rename, code.insert, code.replace_body, or code.delete from sessions that were supposed to be read-only;
  • file modifications during sessions whose policy should have forbidden them;
  • changes to the knowns permission configuration, at any time;
  • absence of denial events in a session that should have produced them — silence is the signature here, not noise;
  • source files deleted or altered without a corresponding commit or user request.

Remediation and hardening

Required remediation

Upgrade to knowns 0.30.0 or later. The fix does three things, and the third is the durable one.

Every code action is now classified explicitly, with mutations marked CapWrite or CapDelete and RiskHigh:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// code
"code.search": {Capability: CapRead, Target: TargetCode, Risk: RiskLow},
"code.symbols": {Capability: CapRead, Target: TargetCode, Risk: RiskLow},
"code.deps": {Capability: CapRead, Target: TargetCode, Risk: RiskLow},
"code.graph": {Capability: CapRead, Target: TargetCode, Risk: RiskLow},
"code.find": {Capability: CapRead, Target: TargetCode, Risk: RiskLow},
"code.definition": {Capability: CapRead, Target: TargetCode, Risk: RiskLow},
"code.references": {Capability: CapRead, Target: TargetCode, Risk: RiskLow},
"code.implementations": {Capability: CapRead, Target: TargetCode, Risk: RiskLow},
"code.diagnostics": {Capability: CapRead, Target: TargetCode, Risk: RiskLow},
"code.rename": {Capability: CapWrite, Target: TargetCode, Risk: RiskHigh},
"code.replace": {Capability: CapWrite, Target: TargetCode, Risk: RiskHigh},
"code.replace_body": {Capability: CapWrite, Target: TargetCode, Risk: RiskHigh},
"code.insert": {Capability: CapWrite, Target: TargetCode, Risk: RiskHigh},
"code.delete": {Capability: CapDelete, Target: TargetCode, Risk: RiskHigh},

The tool-level fallback is inverted to fail closed, and the maintainers left the reasoning in the code:

1
2
3
// Code has explicit read classifications above. Keep the fallback write
// so a newly added mutation cannot silently inherit read permission.
"code": {Capability: CapWrite, Target: TargetCode, Risk: RiskHigh},

This is the part to take away. The registry will drift again — that is what registries do. The fallback now makes drift fail in the safe direction: a future action added to the handler without a registry entry is over-restricted rather than under-restricted. Someone will file a bug about the restriction, which is a much better outcome than nobody filing anything.

Mutation of managed policy paths is blocked at the handler, via ensureCodeMutationPath, so even a correctly authorised write cannot be aimed at the permission configuration itself. Combined with the path containment from CVE-2026-86541, the escalation route is closed independently of the classification fix — defence in depth applied to the specific chain rather than in the abstract.

The commit also adds confirmed and reason arguments for action=delete, so destructive operations need explicit acknowledgement.

Defense in depth

  • Do not treat an application-level “read-only session” as an isolation boundary. Enforce read-only at the OS level too — a read-only mount, a dedicated user, or a container.
  • Add a test that asserts every registered handler action has an explicit classification. This class of bug is mechanically detectable.
  • Default unknown actions to the most restrictive capability, everywhere, without exception.
  • Make classification part of the definition of an action rather than a parallel table, so the two cannot diverge.
  • Log allow decisions for high-risk capabilities, not only denials — otherwise a fail-open bug produces no evidence at all.
  • Never let a mutating operation write the policy that authorises it.

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

Lessons for builders

The safe default is the restrictive one

“A safe read default” is safe for the request and unsafe for the system. When classification fails, deny. An over-restricted new feature produces a bug report; an under-restricted one produces a CVE.

Authorisation data deserves the same review as authorisation code

The guard middleware was reviewed and correct. The map literal that fed it was not, because it looks like configuration. In any policy-driven system, the lookup table is security code. Review it as such, and put it in the same file or the same review checklist as the enforcement it drives.

Derive classification, do not maintain it in parallel

Two lists that must stay in sync will not. Where the language allows it, attach the capability to the action’s definition — a struct field, a builder argument, an interface method — so adding an action forces a decision about its capability at the point of authoring.

Test the invariant, not the examples

A test asserting code.replace requires write permission is useful. A test asserting every registered action has an explicit classification is the one that catches the next occurrence. Prefer completeness properties over example cases when guarding against omission.

A permission system you trust must be a permission system you verify

Teams build architecture on top of “the agent is read-only”. If that claim rests on a lookup table nobody audits, the architecture rests on it too. Periodically prove the boundary rather than assuming it.

  • CVE-2026-86541 — the containment 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.