DEV Community

Riley Lin
Riley Lin

Posted on

Gitignore Is Not a Model Boundary

Gitignore will not stop a coding agent from reading the files you refused to commit. The privacy boundary you inherited from version control ends at the index, not at the model. If an assistant can open a path on disk, that path can leave the machine inside a tool result. You should treat every ignored file as model-visible until a local scan proves otherwise.

This gap widened as coding agents started attaching files, diffs, and command output without waiting for you to paste. You type a short question, and the client quietly packs nearby context for a remote model. Free hosted endpoints make that packing cheap, which also makes over-attachment easy to miss. The leak is rarely the chat box; it is the envelope assembled around the chat box.

Think of gitignore as a shipping label for human collaborators rather than a vault door for secrets. A vault door blocks anyone who lacks the key, including tools that share your username. A shipping label only tells git which boxes stay off the next commit. Your agent does not honor the loading dock. It walks the working tree with a flashlight and reads whichever path a tool policy still allows.

A useful threat model follows the bytes after they leave your editor, not the text you typed into the prompt. Open buffers and recently touched files often include .env, credentials.json, and editor swap files that git already ignores. Tool results then freeze those bytes into the outbound request, including stack traces that reprint connection strings from a failed boot. Command output becomes a second copy when the agent runs env, docker compose config, or cat on a path you would never publish.

Imagine a small Node service that loads DATABASE_URL from .env and a JWT signing key from secrets/dev.pem. Both paths sit in .gitignore, so a pull request looks clean and your review of git history stays quiet. You then ask an agent why login fails on a free remote model. The client reads src/auth.js, follows the dotenv call, and opens .env as a helpful extra. The typed prompt never contained a secret, yet the envelope did.

You can see the first layer of that envelope with git, because git already knows what it refuses to track. Run this from the repository root before you let an agent inspect the tree, and read the names as if they were attachments:

git ls-files -o -i --exclude-standard
Enter fullscreen mode Exit fullscreen mode

The listing is the set of untracked, ignored paths developers treat as private by habit. Pipe those paths through a secret-oriented scan so you notice which ones would be catastrophic inside a read_file tool result. The script below is a local proposal; it does not contact a model and it does not upload a byte.

#!/usr/bin/env python3
"""Local envelope scan: gitignored paths that look unsafe to send to a model."""
from __future__ import annotations

import re
import subprocess
from pathlib import Path

SECRET_PATTERNS = [
    (re.compile(r"(?i)(api[_-]?key|secret|token|password)\s*[:=]\s*\S+"), "credential_assignment"),
    (re.compile(r"AKIA[0-9A-Z]{16}"), "aws_access_key_id"),
    (re.compile(r"-----BEGIN (?:RSA |OPENSSH |EC )?PRIVATE KEY-----"), "private_key_block"),
    (re.compile(r"(?i)postgres(?:ql)?:\/\/[^\s]+"), "db_url"),
    (re.compile(r"(?i)mongodb(?:\+srv)?:\/\/[^\s]+"), "mongo_url"),
]

NAME_HINTS = (".env", "id_rsa", "id_ed25519", "credentials", "service-account", ".pem", ".p12")


def ignored_untracked() -> list[Path]:
    raw = subprocess.check_output(
        ["git", "ls-files", "-o", "-i", "--exclude-standard", "-z"],
        stderr=subprocess.DEVNULL,
    )
    return [Path(p.decode()) for p in raw.split(b"\0") if p]


def classify(path: Path) -> list[str]:
    reasons: list[str] = []
    lowered = path.name.lower()
    if any(hint in lowered for hint in NAME_HINTS):
        reasons.append(f"name_hint:{path.name}")
    try:
        text = path.read_text(encoding="utf-8", errors="ignore")[:200_000]
    except OSError:
        return reasons
    for pattern, label in SECRET_PATTERNS:
        if pattern.search(text):
            reasons.append(label)
    return reasons


def main() -> None:
    hits = []
    for path in ignored_untracked():
        if not path.is_file():
            continue
        reasons = classify(path)
        if reasons:
            hits.append((path, reasons))
    if not hits:
        print("No gitignored files matched local secret heuristics.")
        return
    print("Do not attach these ignored files to a model request:")
    for path, reasons in hits:
        print(f"  {path}  [{', '.join(reasons)}]")


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Save it as envelope_scan.py and keep it outside directories you ask an agent to refactor. Run python3 envelope_scan.py after you add a new secret file, and treat the printed paths as a deny list for the next session. The output is not a certificate of cleanliness. Short tokens, screenshots in tmp, and base64 blobs will sail through these patterns.

You still need a rule for files the scanner does not flag, because many leaks are maps rather than obvious assignments. A docker-compose.yml that references env_file: .env points at the secret even when the YAML looks dull. A failing test that dumps process.env will copy production values into the tool result the moment the agent reruns the suite to be helpful. Customer fixtures and internal URLs travel the same way, riding in files nobody would call a credential.

Make the policy explicit with a tiny allowlist so the remote model sees a named subset of the tree. The file should be boring on purpose and checked into the repo as documentation of what may leave the machine:

# model-allow.txt — paths a remote model may see
README.md
src/
tests/
docs/
Enter fullscreen mode Exit fullscreen mode

Refuse to send anything else until a human confirms the path is dull. The matching helper is only a few lines, and you should run it as preflight rather than as an afterthought once the assistant is already talking:

def allowed(path: Path, rules: list[str]) -> bool:
    posix = path.as_posix()
    for rule in rules:
        rule = rule.strip().rstrip("/")
        if not rule or rule.startswith("#"):
            continue
        if posix == rule or posix.startswith(rule + "/"):
            return True
    return False
Enter fullscreen mode Exit fullscreen mode

When the model runs on your laptop, a mistaken attachment still widens the blast radius inside the process, yet the bytes may never cross the network. When the model runs on a free remote server, that same attachment becomes a copy you cannot retract. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option are handy when you want that remote loop without standing up your own box. They do not shrink the envelope; they only change where the envelope is opened. You still owe the disk scan, because a complimentary endpoint is still an endpoint.

A free remote session is a useful stress test for the habit because it removes the old friction that kept context small. You ask the agent to explain a brownfield service, and it keeps reading config until the answer sounds confident. Confidence is not a privacy control. If the scanner named .env.production and terraform.tfvars, you cancel the tool call, move those files aside, or switch the session onto a redacted clone.

Create the redacted clone when the repository is too messy for an allowlist you trust. Copy only the allowed paths into a temporary directory, scan that copy, and point the agent at the copy rather than at your real working tree:

mkdir -p /tmp/redacted-src
while IFS= read -r rule; do
  case "$rule" in \#*|"") continue ;; esac
  rsync -a --relative "./${rule}" /tmp/redacted-src/
done < model-allow.txt
(cd /tmp/redacted-src && python3 /path/to/envelope_scan.py)
Enter fullscreen mode Exit fullscreen mode

If the scan still prints hits, the allowlist is lying and you should stop before any model call. If it prints nothing, you have a narrower tree to discuss with a remote model. Keep secrets in the original working copy, and do not let the agent change directories back into it mid-session. The clone is a discussion copy, not a second production checkout with different luck.

Test output deserves the same suspicion as source, because agents love to reproduce a failure by rerunning the command that already printed too much. If you must share a stack trace, copy it into a buffer, run the same regexes, and paste the redacted text yourself. Handing over a path under artifacts/ hopes the model will skip connection strings, and hope is not a control. Redact first, then ask why the test failed.

Skip this workflow when a contract already requires managed DLP, tenant isolation, or a private model with no outbound retention path. Heuristic scanners miss encoded secrets, and a hostile README can still instruct an agent to open a denied file. If your threat is a malicious repository rather than an accidental .env, you need a sandbox, blocked egress, and a tool policy that cannot read_file outside the redacted clone. The script will not save you from that adversary.

Avoid the workflow as well if you cannot keep the scanner itself out of the remote context window. Uploading envelope_scan.py with the hits it printed only teaches the model where the secrets live. Run it locally, read the deny list with your own eyes, and only then start the assistant. The order of those steps is the actual control.

Used this way, gitignore stays what it always was: a filter for commits, not a filter for models. The envelope scanner and the redacted clone turn that distinction into a preflight you can repeat, instead of a guilty feeling after the request has already left. Run the scan against the last directory you asked an assistant to inspect, then drop anything it flags before you send the next remote call.

Top comments (0)