Skip to content

Stop embedding .env files; stop returning document text three times - #6

Open
seiz888 wants to merge 3 commits into
enowdev:mainfrom
seiz888:hardening/secret-skip-and-payload-dedup
Open

Stop embedding .env files; stop returning document text three times#6
seiz888 wants to merge 3 commits into
enowdev:mainfrom
seiz888:hardening/secret-skip-and-payload-dedup

Conversation

@seiz888

@seiz888 seiz888 commented Aug 4, 2026

Copy link
Copy Markdown

1. .env files are read and sent to the embedding provider

.env is present in the isIndexable() extension allowlist, so rag_index_project
reads any .env (and any *.env) in a scanned project, sends its contents to the
configured embedding provider — Voyage AI by default — and persists it in the vector
store, where it stays semantically retrievable by any agent with access to the
collection.

This is the documented happy path, not an edge case: the README tells agents to call
rag_index_project to sync file changes, and a typical Node/Next/Django project keeps
a .env at its root.

It looks like an oversight rather than a decision, because the handling is inconsistent:

file filepath.Ext() indexed before this PR
.env .env yes
prod.env .env yes
.env.local .local no
.env.production .production no
.env.example .example no

Nobody deliberately designs "index .env but skip .env.production". The walker also
indexes .gitignore as a document but never consults it, so a .env that is excluded
from version control precisely because it holds credentials is still read.

Fix: drop .env from the allowlist and add an isSensitive() gate after
isIndexable().

Scope, stated honestly

This is not remotely exploitable — it needs an operator to index a directory containing
a .env. The impact is that the operator's own credentials leave their machine to a
third-party API and persist in their own vector store. Moderate severity, high
likelihood, two-line root cause.

Key material was never affected. id_rsa, *.pem, *.key, .netrc and .pgpass
have extensions absent from codeExts, so isIndexable() already rejected them; I
checked rather than assumed. isSensitive() now covers them anyway so the guarantee no
longer depends on codeExts never growing.

What the second gate does newly protect are names that are otherwise perfectly
indexable through allowlisted extensions: secrets.json, db_password.yml,
credentials.yaml. The name-substring match is deliberately broad — skipping a document
that merely mentions a password is a cheaper mistake than embedding a live credential.

2. Retrieval returns the same text three times

The Qdrant read path copies every string payload field into Result.Meta, and content
is a payload field. So each result carried the document text in both .Content and
.Meta["content"]. rag_retrieve_context then returned the concatenated context
string alongside those chunks — putting the same text into one tool response three
times. Agents pay for all three in tokens; two are unreadable noise.

  • exclude the content key from the metadata map on read (search and export)
  • return chunks from rag_retrieve_context as references (id, score, meta)

Warning

Breaking: rag_retrieve_context no longer returns chunks[].Content. The
context string already carries every chunk's text with its score, so callers should
read it from there. Flagged in the changelog. The repo is pre-1.0, so this seemed
preferable to shipping an opt-in flag that would leave the default path paying triple.

3. Metadata filtering for memory-shaped collections

While in the read path: PointInfo now carries the remaining payload tags in meta,
and rag_list_points accepts an exact-match filter on any metadata key rather than
only source_file. Both are needed when a collection holds agent memory tagged with
bucket/kind/ts instead of a code index — previously those tags could neither be
filtered on nor read back.

Tests

Five new tests. Each was mutation-checked: reverted the fix, confirmed the test fails,
restored it, confirmed it passes.

  • TestIsSensitive — the predicate, including the deliberate over-inclusion
  • TestIndexerSkipsSensitiveFiles — writes secrets to disk, asserts they never reach the provider
  • TestQdrantSearchMetaExcludesContent — text returned once, useful tags survive
  • TestQdrantListPointsSurfacesMetabucket/kind/ts exposed, promoted fields not echoed

go build ./... and go vet ./... are clean. pkg/rag and pkg/indexer pass.

pkg/httpapi has 14 failures on Windows — verified pre-existing by running the same
suite on a pristine clone of main: identical 14, so none are introduced here. They are
POSIX-permission and HOME-path assumptions (expected 0600 ... got 666).

🤖 Generated with Claude Code

seiz888 and others added 3 commits August 4, 2026 14:18
`.env` sits in the isIndexable() extension allowlist, so rag_index_project
reads any `.env` (and any `*.env`) in a scanned project and ships its
contents to the configured embedding provider — Voyage AI by default —
where it then persists in the vector store, semantically retrievable by
any agent with access to the collection.

This reads as an oversight rather than a decision: `.env.local`,
`.env.production` and `.env.example` are all skipped already, because
filepath.Ext() returns `.local`/`.production`/`.example` for them. Only
the bare `.env` and `*.env` forms match the allowlist. The walker also
indexes `.gitignore` as a document but never consults it, so a `.env`
excluded from version control precisely because it holds credentials is
still read.

Drop `.env` from the allowlist, and add isSensitive() as a second gate
for names that are otherwise indexable: `secrets.json`, `db_password.yml`
and `credentials.yaml` all reach the embedder today through the
allowlisted `.json`/`.yaml`/`.yml` extensions. The name-substring match
is deliberately broad — skipping a document that merely mentions a
password is a cheaper mistake than embedding a live credential.

For the record, key material (`id_rsa`, `*.pem`, `*.key`, `.netrc`,
`.pgpass`) was NOT affected: those extensions are absent from codeExts,
so they were already skipped. isSensitive() now covers them too, so the
guarantee no longer depends on codeExts never growing.

Co-Authored-By: Claude Mythos <noreply@anthropic.com>
The Qdrant read path copies every string payload field into Result.Meta,
and "content" is a payload field — so each search result carried the full
document text in both .Content and .Meta["content"]. rag_retrieve_context
then returned the concatenated "context" string alongside those chunks,
putting the same text into a single tool response three times. Agents pay
for all three in tokens; two of them are unreadable noise.

- exclude the content key from the metadata map on read (search + export)
- return chunks from rag_retrieve_context as references (id, score, meta);
  the context string already carries the text with its scores

While in the read path, surface the remaining payload tags through a new
PointInfo.Meta and let rag_list_points take an exact-match metadata
filter. Both are needed when the store holds agent memory tagged with
bucket/kind/ts rather than a code index — previously only source_file was
filterable, and those tags were not returned at all.

Co-Authored-By: Claude Mythos <noreply@anthropic.com>
Flags the rag_retrieve_context response-shape change as breaking under
[Unreleased] > Changed, and files the .env leak under Security.

Co-Authored-By: Claude Mythos <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant