October 31, 2026
CVE Decoded #7: CVE-2026–77304 (llm-council-core)
This is my attempt to dumbdown CVE and explain them the way I understand them.
By Saijalpreet Kaur
8 min read
Basic Details
- CVE ID: CVE-2026–77304
- Published: July 10, 2026
- CVE ID assigned: August 20, 2026
- Severity: Medium, CVSS Score: 5.3
- Affected Software: llm-council-core (before 0.22.0, 0.39.0)
- Platform: Python, PyPI, Open Source
- Vulnerability Type: Exposure of Sensitive Information to an Unauthorized Actor (CWE-200)
Issue in one line
An AI code-review tool accidentally scooped up your .env file..passwords, API keys, and all and mailed it straight to a third-party AI provider as part of its homework.
What Even Is llm-council?
Imagine you're a teacher grading essays. You've got a stack of papers, and you want a second opinion, so you photocopy each essay and mail a copy to three different tutors for feedback. Each tutor reads the essay independently, grades it, and then they also review each other's feedback, rank the responses, and a head tutor synthesizes everything into one final verdict.
That's essentially what llm-council (COUNCIL- group of people/agents) does, but for code. It's an open-source Python project (originally inspired by Andrej Karpathy) that implements a multi-LLM deliberation system a "council" of AI models that collaborate in three stages:
-
Fan-Out: Your code is sent simultaneously to multiple LLM providers (OpenAI, Anthropic, Google, etc.) for independent analysis.
-
Peer Review: Each model reviews and ranks the other models' responses anonymously.
-
Chairman Synthesis: A designated "chairman" model compiles everything into a final, consensus-driven verdict.
The part of the library we care about is the verify() function (often used via the council-verify or llm-council gate CLI commands). It's a CI/CD quality gate: it takes a snapshot of your code (pinned to a specific git commit), bundles the files into prompts, sends them to the council of AI models, and returns a hard pass/fail verdict with a confidence score. Developers wire it into GitHub Actions, pre-commit hooks, and deployment pipelines to catch bugs, security flaws, and code quality issues before merging.
Sounds useful, right? It was …until someone realized the librarian was photocopying a lot more than just the essays.
So What Went Wrong?
The File Selector: What Gets Sent to the AI?
Before verify() can ask an AI model to review your code, it needs to decide which files to read. Not every file in a project is source code there are images, compiled binaries, font files. You don't want to send a JPEG to GPT-4 and ask it for a code review.
So llm-council maintained a collection of "text-like" file extensions and filenames in a constants module, essentially a whitelist of file types it considered safe and useful to read:
python
# src/llm_council/verification/constants.py (Vulnerable - before v0.39.0)
TEXT_EXTENSIONS = {
".py",
".js",
".ts",
".md",
".txt",
".json",
".yaml",
".yml",
".toml",
".cfg",
".ini",
".env", ##
".npmrc", ##
".yarnrc", ##
# … ~80–140 entries total, including full filenames like ".gitignore"
}python
# src/llm_council/verification/constants.py (Vulnerable - before v0.39.0)
TEXT_EXTENSIONS = {
".py",
".js",
".ts",
".md",
".txt",
".json",
".yaml",
".yml",
".toml",
".cfg",
".ini",
".env", ##
".npmrc", ##
".yarnrc", ##
# … ~80–140 entries total, including full filenames like ".gitignore"
}Did you see the problem?
.env files are text files, technically. But they're not source code. They're secret vaults.
What Lives Inside a .env File?
If you've never worked with one, a .env file is the place developers store the passwords and API keys their application needs to run. It looks something like this:
bash
# .env - THIS FILE SHOULD NEVER LEAVE YOUR MACHINE
DATABASE_URL=postgres://admin:s3cretP@ssw0rd@db.example.com:5432/production
OPENAI_API_KEY=sk-proj-8a7f3b…
STRIPE_SECRET_KEY=sk_live_4eC39H…
AWS_SECRET_ACCESS_KEY=AKIAIOSFODNN7EXAMPLE
JWT_SIGNING_SECRET=super-secret-do-not-share
bash
# .env - THIS FILE SHOULD NEVER LEAVE YOUR MACHINE
DATABASE_URL=postgres://admin:s3cretP@ssw0rd@db.example.com:5432/production
OPENAI_API_KEY=sk-proj-8a7f3b…
STRIPE_SECRET_KEY=sk_live_4eC39H…
AWS_SECRET_ACCESS_KEY=AKIAIOSFODNN7EXAMPLE
JWT_SIGNING_SECRET=super-secret-do-not-share
This file is the crown jewels. It's the first thing listed in every .gitignore template. Security training says: never commit your .env to version control. Never share it. Never send it anywhere.
But llm-council's file selector saw .env and thought: That's a text file! The AI reviewer will want to see that!
The Photocopier Problem
Here's what happened, step by step, every time a developer ran verify() on a project that had a .env file committed to git:
-
verify()takes a snapshot of the project- pinned to a specific git commit SHA. -
The file selector queries the git snapshot and checks if each file's name or extension matches TEXT_EXTENSIONS.
-
.envmatches. So does.npmrc(which stores private npm registry tokens) and.yarnrc(which can contain authentication tokens for Yarn package registries). -
The file contents are read (via
git show <sha>:<path>) and assembled into a prompt string. -
The prompt now containing your database passwords, API keys, and registry tokens is sent via HTTPS to one or more third-party LLM providers.
IMPORTANT
Because verify() reads from git snapshots, only files that were actually committed to the repository were at risk. If your .env was properly .gitignore and never committed, this CVE didn't affect you. But many teams especially those with private repositories commit ``.env.example, .npmrc, or .yarnrc` files with real tokens, assuming "it's a private repo, nobody will see it." Those teams got burned.
In plain English: the tool was designed to photocopy your code and mail it to AI tutors for review. But because nobody told it which drawers were private, it also photocopied the contents of the safe and mailed those too.
Why .env.example and anything likewise is an issue
You might think: Okay, .env is bad. But .env.example is just a template with fake placeholder values, right?
Sometimes. But in practice, lazy developers (all of us, at some point) copy .env.example to .env, fill in real values, and then forget to scrub the example file. Or they leave real default values in the example "for convenience." The file selector's broad matching logic caught these variants too any file whose name started with .envmatched the text-file check meaning even semi-sensitive template files were shipped off.
The Chatty Librarian Analogy
Think of it like this:
You hire a librarian to organize your office bookshelves. You tell them: Anything that's a document, scan it and send it to our consultants for review.
The librarian diligently scans every piece of paper on every shelf- your reports, your memos, your meeting notes. Great!
But taped to the underside of your keyboard is a sticky note that says:
Bank password: hunter2
Server root: abcdjfhnciudcn
The librarian sees it, thinks That's text on paper!, scans it, and emails it to three external consulting firms.
That's CVE-2026–77304. The librarian wasn't malicious it just didn't know which documents were sensitive. And nobody told it.
The Code
The Vulnerable File Selector
The core issue was the absence of any secret-path filtering. The file selector in the verify() pipeline looked roughly like this:\
python
# Simplified from src/llm_council/verification/file_selector.py (Vulnerable)
# (The real code uses git snapshot queries, not os.walk - simplified for clarity)
import os
from llm_council.verification.constants import TEXT_EXTENSIONS
def collect_files(snapshot_dir: str) -> list[str]:
"""Walk the snapshot and collect all text-like files for LLM review."""
collected = []
for root, dirs, files in os.walk(snapshot_dir):
for filename in files:
filepath = os.path.join(root, filename)
# FLAW: Only checks against TEXT_EXTENSIONS. No sensitive-path filtering!
if filepath.endswith(tuple(TEXT_EXTENSIONS)):
collected.append(filepath)
return collectedpython
# Simplified from src/llm_council/verification/file_selector.py (Vulnerable)
# (The real code uses git snapshot queries, not os.walk - simplified for clarity)
import os
from llm_council.verification.constants import TEXT_EXTENSIONS
def collect_files(snapshot_dir: str) -> list[str]:
"""Walk the snapshot and collect all text-like files for LLM review."""
collected = []
for root, dirs, files in os.walk(snapshot_dir):
for filename in files:
filepath = os.path.join(root, filename)
# FLAW: Only checks against TEXT_EXTENSIONS. No sensitive-path filtering!
if filepath.endswith(tuple(TEXT_EXTENSIONS)):
collected.append(filepath)
return collectedThere was exactly one gate: does the file match TEXT_EXTENSIONS? If yes, it goes into the prompt. No denylist. No sensitive-path check. No content inspection.
The Fixed Code (v0.39.0)
The patch introduced three changes:
- A compiled-in, non-overridable secret-path denylist:
python
# src/llm_council/verification/constants.py (Patched - v0.39.0)
TEXT_EXTENSIONS = {
".py",
".js",
".ts",
".md",
".txt",
".json",
".yaml",
".yml",
".toml",
".cfg",
".ini",
# .env, .npmrc, .yarnrc REMOVED from this set
}
# NEW: Hard-coded denylist - cannot be overridden by user config
SENSITIVE_FILE_NAMES = frozenset({
".env",
".env.local",
".env.development",
".env.production",
".env.staging",
".env.test",
".env.example",
".env.sample",
".npmrc",
".yarnrc",
".yarnrc.yml",
"id_rsa",
"id_ed25519",
"id_ecdsa",
})
SENSITIVE_EXTENSIONS = (
".pem",
".key",
".cert",
".pfx",
".p12",
)python
# src/llm_council/verification/constants.py (Patched - v0.39.0)
TEXT_EXTENSIONS = {
".py",
".js",
".ts",
".md",
".txt",
".json",
".yaml",
".yml",
".toml",
".cfg",
".ini",
# .env, .npmrc, .yarnrc REMOVED from this set
}
# NEW: Hard-coded denylist - cannot be overridden by user config
SENSITIVE_FILE_NAMES = frozenset({
".env",
".env.local",
".env.development",
".env.production",
".env.staging",
".env.test",
".env.example",
".env.sample",
".npmrc",
".yarnrc",
".yarnrc.yml",
"id_rsa",
"id_ed25519",
"id_ecdsa",
})
SENSITIVE_EXTENSIONS = (
".pem",
".key",
".cert",
".pfx",
".p12",
)2. A unified file selector with denylist enforcement:
python
# Simplified from src/llm_council/verification/file_selector.py (Patched)
import os
from llm_council.verification.constants import (
TEXT_EXTENSIONS,
SENSITIVE_FILE_NAMES,
SENSITIVE_EXTENSIONS,
)
def should_include_file(filepath: str) -> bool:
"""Single gate that every candidate file must pass through."""
filename = os.path.basename(filepath).lower()
# Gate 1: Block known sensitive filenames
if filename in SENSITIVE_FILE_NAMES:
return False
# Gate 2: Block known sensitive extensions (private keys, certs)
if filepath.endswith(tuple(SENSITIVE_EXTENSIONS)):
return False
# Gate 3: Only include known text extensions
if not filepath.endswith(tuple(TEXT_EXTENSIONS)):
return False
return True
def collect_files(snapshot_dir: str) -> list[str]:
"""Walk the snapshot and collect files, routing every path through the unified selector."""
collected = []
for root, dirs, files in os.walk(snapshot_dir):
for filename in files:
filepath = os.path.join(root, filename)
# Every file now passes through a single, strict selector
if should_include_file(filepath):
collected.append(filepath)
return collectedpython
# Simplified from src/llm_council/verification/file_selector.py (Patched)
import os
from llm_council.verification.constants import (
TEXT_EXTENSIONS,
SENSITIVE_FILE_NAMES,
SENSITIVE_EXTENSIONS,
)
def should_include_file(filepath: str) -> bool:
"""Single gate that every candidate file must pass through."""
filename = os.path.basename(filepath).lower()
# Gate 1: Block known sensitive filenames
if filename in SENSITIVE_FILE_NAMES:
return False
# Gate 2: Block known sensitive extensions (private keys, certs)
if filepath.endswith(tuple(SENSITIVE_EXTENSIONS)):
return False
# Gate 3: Only include known text extensions
if not filepath.endswith(tuple(TEXT_EXTENSIONS)):
return False
return True
def collect_files(snapshot_dir: str) -> list[str]:
"""Walk the snapshot and collect files, routing every path through the unified selector."""
collected = []
for root, dirs, files in os.walk(snapshot_dir):
for filename in files:
filepath = os.path.join(root, filename)
# Every file now passes through a single, strict selector
if should_include_file(filepath):
collected.append(filepath)
return collected- Boundary validation on
snapshot_id:
The patch also added validation at the run_verification entry point to ensure the snapshot_id parameter can't be manipulated to point outside the expected snapshot directory closing a secondary path-traversal vector.
python
# Simplified from src/llm_council/verification/verify.py (Patched)
def run_verification(snapshot_id: str, **kwargs):
# Validate snapshot_id to prevent directory traversal
if not snapshot_id.isalnum() or len(snapshot_id) > 64:
raise ValueError(f"Invalid snapshot_id: {snapshot_id!r}")
snapshot_dir = os.path.join(SNAPSHOTS_ROOT, snapshot_id)
if not os.path.realpath(snapshot_dir).startswith(os.path.realpath(SNAPSHOTS_ROOT)):
raise ValueError("snapshot_id escapes snapshot root")
files = collect_files(snapshot_dir)
# … build prompts and send to LLM providerspython
# Simplified from src/llm_council/verification/verify.py (Patched)
def run_verification(snapshot_id: str, **kwargs):
# Validate snapshot_id to prevent directory traversal
if not snapshot_id.isalnum() or len(snapshot_id) > 64:
raise ValueError(f"Invalid snapshot_id: {snapshot_id!r}")
snapshot_dir = os.path.join(SNAPSHOTS_ROOT, snapshot_id)
if not os.path.realpath(snapshot_dir).startswith(os.path.realpath(SNAPSHOTS_ROOT)):
raise ValueError("snapshot_id escapes snapshot root")
files = collect_files(snapshot_dir)
# … build prompts and send to LLM providersThe Diff That Matters
The heart of the fix, stripped to its essence:
diff
- - a/src/llm_council/verification/constants.py
+++ b/src/llm_council/verification/constants.py
@@ -15,9 +15,6 @@
".toml",
".cfg",
".ini",
- ".env",
- ".npmrc",
- ".yarnrc",
}
+
+SENSITIVE_FILE_NAMES = frozenset({
+ ".env", ".env.local", ".env.development", ".env.production",
+ ".env.staging", ".env.test", ".env.example", ".env.sample",
+ ".npmrc", ".yarnrc", ".yarnrc.yml",
+ "id_rsa", "id_ed25519", "id_ecdsa",
+})
+
+SENSITIVE_EXTENSIONS = (".pem", ".key", ".cert", ".pfx", ".p12")diff
- - a/src/llm_council/verification/constants.py
+++ b/src/llm_council/verification/constants.py
@@ -15,9 +15,6 @@
".toml",
".cfg",
".ini",
- ".env",
- ".npmrc",
- ".yarnrc",
}
+
+SENSITIVE_FILE_NAMES = frozenset({
+ ".env", ".env.local", ".env.development", ".env.production",
+ ".env.staging", ".env.test", ".env.example", ".env.sample",
+ ".npmrc", ".yarnrc", ".yarnrc.yml",
+ "id_rsa", "id_ed25519", "id_ecdsa",
+})
+
+SENSITIVE_EXTENSIONS = (".pem", ".key", ".cert", ".pfx", ".p12")The Attack, Step by Step
Let's be clear: this isn't a "hack" in the traditional sense. There's no attacker sending a malicious payload. The damage happens through normal, intended use of the tool.
-
Developer installs
llm-council-core(any version from 0.22.0 to 0.38.x) and configures it with their preferred LLM providers. -
Developer runs
verify()on their project- maybe through a CI pipeline, maybe locally targeting a git commit. -
The file selector queries every file in the git snapshot and checks against TEXT_EXTENSIONS. Committed .env, .npmrc, and .yarnrc files match.
-
The tool reads the contents of those files from the git snapshot and assembles them into a prompt string alongside the actual source code.
-
The prompt is transmitted over HTTPS to one or more external LLM API endpoints (OpenAI, Anthropic, Google, etc.).
-
The LLM provider now has your secrets. Depending on their data retention policies, your database passwords, API keys, and registry tokens may be logged, cached, or stored in training pipelines.
The scary part? The developer never sees a warning. The tool works exactly as designed. The output looks normal. There's no error, no red flag. Your secrets are just quietly riding along in the prompt payload.
How Bad Is It, Really?
This CVE scored a 5.3 Medium (CVSS v3.1), and that score feels about right but the consequences can be much worse than "medium" suggests.
The CVSS score reflects that exploitation requires the developer to actively run the tool (it's not remotely triggerable). But the downstream damage from leaked credentials unauthorized database access, drained cloud billing accounts, hijacked package registries can be catastrophic.
The Fix
If you've ever used llm-council-core on a repo with committed .env files, act now.
Action Checklist:
- Update
llm-council-coreimmediately: pip install upgrade llm-council-core>=0.39.0 - Rotate every credential that was stored in
.env,.npmrc, or.yarnrcin any project where you ranverify().This means database passwords, API keys, cloud access keys, JWT secrets everything. Assume they've been read by a third party. - Audit your CI/CD logs to see how many times
verify()was invoked and against which repositories. Each invocation against a repo with a.envfile was a potential leak. - Check your LLM provider's data policies. Some providers log prompts for debugging or training. Contact them if you need confirmation that leaked data has been purged.
- Add
.envto your project's.gitignoreif it isn't already. Sinceverify()works on git snapshots, it can only read files that have been committed to git. If your.envwas never committed, it was never at risk from this specific CVE but keeping it out of version control is a critical safety net regardless.
TAKEAWAY
-
'Text file' is not a security classification. The file selector treated all text files equally source code, documentation, configuration, secrets. But sensitivity isn't a property of the file format; it's a property of the file's content and purpose. A
.pyfile and a.envfile are both plaintext, but one belongs in a code review and the other absolutely does not. Any tool that collects files needs both an allowlist and a denylist. -
The most dangerous bugs don't look like bugs. There was no crash, no error message, no exploit payload. The tool worked exactly as designed. It just designed itself into leaking your secrets. The developer experience was seamless and that seamlessness is what made it so dangerous. When the happy path is the attack path, nobody goes looking for a fix.
-
LLM tooling is the new attack surface. We've spent decades learning to protect secrets from git commits, log files, and error messages. Now we need to add a new item to that list: AI prompts. Any tool that reads your codebase and sends it to an external API is a potential data exfiltration vector even if it's "just" asking for a code review. The
verify()function was never compromised or exploited by an attacker. It was doing its job. The problem was that its job description was too broad.
Resources
- https://github.com/amiable-dev/llm-council/issues/551
- https://cwe.mitre.org/data/definitions/200.html
- https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/07-Session_Management_Testing/
- https://github.com/motdotla/dotenv#should-i-commit-my-env-file
This is part of my CVE Decoded series, where I pick a CVE, learn it from scratch, and explain it as simply as I can. I'm not an expert.